Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/adityaakashbhu23-svg_cv-toposheet-maps.json
T

1 line
133 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "adityaakashbhu23-svg/cv-toposheet-maps", "files": {"app.py": {"475b54e9f2dbd1f8ba21959ecc488867fefb292e": {"t": 1785674392.2617524, "n": 129487, "h": "5f98c327ddfff7c6", "c": "# app.py – CV-Toposheet Web Interface\n#\n# A simple Flask cover page: upload a map → pipeline runs → view results.\n#\n\nimport sys\nimport time\nimport threading\nimport queue\nimport json\nimport os\nimport re\nimport uuid\nimport shutil\nimport sqlite3\nimport importlib\nimport subprocess\nfrom pathlib import Path\nfrom urllib.parse import quote as url_quote, urlparse\n\nfrom flask import Flask, request, redirect, url_for, send_file, Response, stream_with_context, jsonify\nfrom werkzeug.utils import secure_filename\n\n# In a frozen PyInstaller EXE, __file__ is inside _internal/ (read-only bundle dir).\n# User-writable data (maps, results, .env, JSON credentials) must go next to the EXE.\nif getattr(sys, 'frozen', False):\n BASE_DIR = Path(sys.executable).parent # install dir e.g. C:\\...\\CV-Toposheet\\\nelse:\n BASE_DIR = Path(__file__).parent # project root (dev mode)\n\n# MSIX (WindowsApps) and macOS .app bundles are read-only — use a writable user dir\n# for ALL data that the app writes at runtime (maps, results, logs, flags, .env).\nimport platform as _platform\nif getattr(sys, 'frozen', False):\n if _platform.system() == 'Darwin':\n _USER_DATA_DIR = Path.home() / 'Library' / 'Application Support' / 'CVToposheet'\n else:\n # Windows: %LOCALAPPDATA%\\CVToposheet\\ (writable even from MSIX sandbox)\n _USER_DATA_DIR = Path(os.environ.get('LOCALAPPDATA', Path.home())) / 'CVToposheet'\n _USER_DATA_DIR.mkdir(parents=True, exist_ok=True)\nelse:\n _USER_DATA_DIR = BASE_DIR\n\nRESULTS_DIR = _USER_DATA_DIR / 'results'\nMAPS_DIR = _USER_DATA_DIR / 'maps'\nLOGS_DIR = _USER_DATA_DIR / 'logs'\n\nFIRST_RUN_FLAG = _USER_DATA_DIR / '.welcome_done'\nENV_FILE = _USER_DATA_DIR / '.env'\nAI_REPORTS_FILE = LOGS_DIR / 'ai_content_reports.jsonl'\n\n# Load saved API keys / settings from the writable user data dir on every launch.\nfrom dotenv import load_dotenv as _load_dotenv\n_load_dotenv(ENV_FILE, override=False) # override=False: don't clobber env vars already set\n\n# ── Fresh-reinstall detection (MSIX / Mac .app / EXE) ────────────────────────\n# On every (re)install the app binary is brand-new, so its ctime is later than\n# any flag written during a previous install. If we detect this, delete the\n# flag so the welcome page shows once more for that fresh install.\ndef _reset_flag_if_reinstalled() -> None:\n if not getattr(sys, 'frozen', False):\n return # dev mode — never auto-reset\n if not FIRST_RUN_FLAG.exists():\n return # flag not written yet — nothing to reset\n try:\n exe_ctime = Path(sys.executable).stat().st_ctime\n flag_mtime = FIRST_RUN_FLAG.stat().st_mtime\n if exe_ctime > flag_mtime:\n # Executable is newer than the flag → fresh reinstall\n FIRST_RUN_FLAG.unlink(missing_ok=True)\n except Exception:\n pass # never break startup over this\n\n_reset_flag_if_reinstalled()\n# ─────────────────────────────────────────────────────────────────────────────\n\nSUPPORT_ISSUES_URL = 'https://github.com/adityaakashbhu23-svg/CV-Toposheet-Maps/issues/new'\nSUPPORT_EMAIL = 'cvtoposheet@outlook.com'\n\nALLOWED_EXT = {'.jpg', '.jpeg', '.png', '.tif', '.tiff'}\n\n_process_lock = threading.Lock()\n_current_proc = None # currently running subprocess (for kill support)\n\n_MODEL_ENVS = {\n 'best': {'LLM_PROVIDER': 'vertex', 'OCR_ENGINE': 'gcv'},\n 'fast': {'LLM_PROVIDER': 'groq', 'OCR_ENGINE': 'gcv'},\n 'ensemble': {'LLM_PROVIDER': 'vertex', 'OCR_ENGINE': 'gcv', 'ENSEMBLE_MODE': 'true'},\n 'openai': {'LLM_PROVIDER': 'openai', 'OCR_ENGINE': 'gcv'},\n 'claude': {'LLM_PROVIDER': 'claude', 'OCR_ENGINE': 'gcv'},\n 'grok': {'LLM_PROVIDER': 'grok', 'OCR_ENGINE': 'gcv'},\n 'gemini': {'LLM_PROVIDER': 'gemini', 'OCR_ENGINE': 'gcv'},\n 'openrouter': {'LLM_PROVIDER': 'openrouter', 'OCR_ENGINE': 'gcv'},\n 'offline': {'LLM_PROVIDER': 'groq', 'OCR_ENGINE': 'easyocr', 'OCR_WORKERS': '1'},\n}\n\napp = Flask(__name__)\napp.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500 MB\n\n\n# ── Helpers ───────────────────────────────────────────────────────────────────\ndef _feature_count() -> int:\n db = RESULTS_DIR / 'toposheet.db'\n if not db.exists():\n return 0\n try:\n with sqlite3.connect(str(db)) as con:\n return con.execute('SELECT COUNT(*) FROM features').fetchone()[0]\n except Exception:\n return 0\n\n\ndef _map_count() -> int:\n if not MAPS_DIR.exists():\n return 0\n return sum(1 for _ in MAPS_DIR.glob('*') if _.is_file() and _.suffix.lower() in ALLOWED_EXT)\n\n\ndef _merge_session_to_global(session_id: str) -> None:\n \"\"\"Copy newly processed map(s) from a session DB into the global DB,\n then regenerate the global table_export.html so Map Database stays current.\"\"\"\n session_db = _USER_DATA_DIR / 'results' / session_id / 'toposheet.db'\n global_db = RESULTS_DIR / 'toposheet.db'\n if not session_db.exists():\n return\n try:\n from utils.db_utils import init_db\n if not global_db.exists():\n init_db(str(global_db))\n\n with sqlite3.connect(str(global_db)) as gcon:\n gcon.execute('ATTACH DATABASE ? AS sdb', (str(session_db),))\n maps_in_session = [r[0] for r in gcon.execute(\n 'SELECT DISTINCT map_name FROM sdb.features'\n ).fetchall()]\n inserted = 0\n for map_name in maps_in_session:\n already = gcon.execute(\n 'SELECT 1 FROM features WHERE map_name=? LIMIT 1', (map_name,)\n ).fetchone()\n if not already:\n cur = gcon.execute(\"\"\"\n INSERT INTO features\n (map_name, original_text, feature_name, feature_type,\n grid_reference, confidence, tile_x, tile_y,\n bbox_x1, bbox_y1, bbox_x2, bbox_y2,\n lat_min, lat_max, lon_min, lon_max,\n sheet_ref, district, survey_year, map_scale, verified)\n SELECT map_name, original_text, feature_name, feature_type,\n grid_reference, confidence, tile_x, tile_y,\n bbox_x1, bbox_y1, bbox_x2, bbox_y2,\n lat_min, lat_max, lon_min, lon_max,\n sheet_ref, district, survey_year, map_scale, verified\n FROM sdb.features WHERE map_name=?\n \"\"\", (map_name,))\n inserted += cur.rowcount\n gcon.commit()\n\n if inserted > 0:\n # Regenerate the global HTML so Map Database reflects the new map\n genv = os.environ.copy()\n genv['RESULTS_FOLDER'] = str(RESULTS_DIR)\n genv['PYTHONIOENCODING'] = 'utf-8'\n result = subprocess.run(\n [sys.executable, 'export_table.py'],\n cwd=str(BASE_DIR), env=genv,\n timeout=120, capture_output=True\n )\n if result.returncode != 0:\n print('[merge] export_table.py failed:', result.stderr.decode(errors='replace'))\n print(f'[merge] Added {inserted} features from session {session_id} to global DB.')\n else:\n print(f'[merge] Session {session_id} maps already in global DB — skipped.')\n except Exception as e:\n print(f'[merge] Warning: could not merge session to global DB: {e}')\n\n\ndef _write_env(env: dict):\n lines = []\n if ENV_FILE.exists():\n with open(ENV_FILE, 'r', encoding='utf-8') as f:\n lines = f.readlines()\n existing_keys = {}\n for i, line in enumerate(lines):\n m = re.match(r'^([A-Z_][A-Z0-9_]*)=', line)\n if m:\n existing_keys[m.group(1)] = i\n for k, v in env.items():\n if v:\n entry = f'{k}={v}\\n'\n if k in existing_keys:\n lines[existing_keys[k]] = entry\n else:\n lines.append(entry)\n else:\n # Empty value — remove the key from .env so it doesn't come back on restart\n if k in existing_keys:\n lines[existing_keys[k]] = None # mark for removal\n lines = [l for l in lines if l is not None]\n with open(ENV_FILE, 'w', encoding='utf-8') as f:\n f.writelines(lines)\n\n\n# ── Routes ────────────────────────────────────────────────────────────────────\n@app.route('/')\ndef index():\n if not FIRST_RUN_FLAG.exists():\n return redirect('/welcome')\n try:\n feat_count = _feature_count()\n except Exception:\n feat_count = 0\n try:\n map_count = _map_count()\n except Exception:\n map_count = 0\n return _landing_html(feat_count, map_count)\n\n\n@app.route('/welcome')\ndef welcome():\n return _WELCOME_TEMPLATE\n\n\n@app.route('/dismiss_welcome', methods=['POST'])\ndef dismiss_welcome():\n try:\n FIRST_RUN_FLAG.touch()\n except Exception as e:\n app.logger.error(\"Could not write welcome flag to %s: %s\", FIRST_RUN_FLAG, e)\n return redirect('/')\n\n\n@app.route('/upload_service_account', methods=['POST'])\ndef upload_service_account():\n f = request.files.get('json_file')\n target = request.form.get('target', 'service_account.json')\n # Only allow safe filenames — must end in .json, no path traversal\n if not f or not f.filename.endswith('.json'):\n return json.dumps({'ok': False, 'error': 'Not a JSON file'}), 400\n safe_name = Path(target).name # strip any path component\n if not safe_name.endswith('.json'):\n safe_name = 'service_account.json'\n dest = _USER_DATA_DIR / safe_name\n f.save(str(dest))\n # Update env so it takes effect immediately\n os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = str(dest)\n try:\n _write_env({'GOOGLE_APPLICATION_CREDENTIALS': safe_name})\n import config; importlib.reload(config)\n except Exception:\n pass\n return json.dumps({'ok': True, 'saved': safe_name})\n\n\ndef _session_feature_count(session_id: str) -> int:\n \"\"\"Return feature row count for a session DB (0 if missing/error).\"\"\"\n db = _USER_DATA_DIR / 'results' / session_id / 'toposheet.db'\n if not db.exists():\n return 0\n try:\n with sqlite3.connect(str(db)) as con:\n row = con.execute('SELECT COUNT(*) FROM features').fetchone()\n return int(row[0] or 0)\n except Exception:\n return 0\n\n\n@app.route('/save_env', methods=['POST'])\ndef save_env():\n ref = request.referrer or ''\n if not (ref.startswith('http://127.0.0.1:') or ref.startswith('http://localhost:')):\n return '', 403\n payload = request.get_json(force=True, silent=True) or {}\n # Normalize legacy/blank OpenRouter model to the new default.\n if 'OPENROUTER_MODEL' in payload:\n old_openrouter_default = 'meta-llama/llama-3.3-70b-instruct:free'\n or_model = str(payload.get('OPENROUTER_MODEL') or '').strip()\n if (not or_model) or (or_model.lower() == old_openrouter_default):\n payload['OPENROUTER_MODEL'] = 'openrouter/auto'\n for k, v in payload.items():\n if v:\n os.environ[k] = v\n elif k in os.environ:\n del os.environ[k]\n try:\n _write_env(payload) # pass all keys including empty — _write_env removes blanked keys\n except Exception:\n pass\n try:\n import config\n importlib.reload(config)\n except Exception:\n pass\n return json.dumps({'ok': True})\n\n\n@app.route('/report_ai_content', methods=['POST'])\ndef report_ai_content():\n try:\n ref = request.referrer or ''\n if ref:\n ref_host = (urlparse(ref).hostname or '').lower()\n req_host = (request.host.split(':', 1)[0] if request.host else '').lower()\n allowed_hosts = {'127.0.0.1', 'localhost', '0.0.0.0'}\n if ref_host and ref_host not in allowed_hosts and ref_host != req_host:\n return jsonify({'ok': False, 'error': 'Unauthorized request origin.'}), 403\n\n payload = request.get_json(force=True, silent=True) or {}\n report_id = f\"AIR-{time.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6].upper()}\"\n\n def _clean_str(key: str, limit: int = 4000) -> str:\n return str(payload.get(key, '')).strip()[:limit]\n\n model = _clean_str('model', 80)\n category = _clean_str('category', 120)\n snippet = _clean_str('snippet', 2000)\n details = _clean_str('details', 4000)\n contact = _clean_str('contact', 200)\n files = payload.get('files') or []\n if not isinstance(files, list):\n files = []\n files = [str(item).strip()[:260] for item in files[:20] if str(item).strip()]\n\n if not details:\n return jsonify({'ok': False, 'error': 'Please describe the issue.'}), 400\n\n LOGS_DIR.mkdir(exist_ok=True)\n record = {\n 'report_id': report_id,\n 'created_at': time.strftime('%Y-%m-%d %H:%M:%S'),\n 'category': category or 'inappropriate-output',\n 'model': model,\n 'files': files,\n 'snippet': snippet,\n 'details': details,\n 'contact': contact,\n }\n with open(AI_REPORTS_FILE, 'a', encoding='utf-8') as handle:\n handle.write(json.dumps(record, ensure_ascii=True) + '\\n')\n\n issue_title = url_quote(f'AI content report: {report_id}')\n issue_body = url_quote(\n f\"Report ID: {report_id}\\n\"\n f\"Category: {record['category']}\\n\"\n f\"Model: {model or 'not provided'}\\n\"\n f\"Snippet:\\n{snippet or 'not provided'}\\n\\n\"\n f\"Details:\\n{details}\\n\\n\"\n f\"Contact: {contact or 'not provided'}\\n\"\n )\n issue_url = f'{SUPPORT_ISSUES_URL}?title={issue_title}&body={issue_body}'\n report_text = (\n f\"Report ID: {report_id}\\n\"\n f\"Category: {record['category']}\\n\"\n f\"Model: {model or 'not provided'}\\n\"\n f\"Snippet:\\n{snippet or 'not provided'}\\n\\n\"\n f\"Details:\\n{details}\\n\\n\"\n f\"Contact: {contact or 'not provided'}\"\n )\n email_subject = url_quote(f'CVToposheet AI content report: {report_id}')\n email_body = url_quote(report_text)\n email_url = f'mailto:{SUPPORT_EMAIL}?subject={email_subject}&body={email_body}'\n return jsonify({\n 'ok': True,\n 'report_id': report_id,\n 'issue_url': issue_url,\n 'report_text': report_text,\n 'support_email': SUPPORT_EMAIL,\n 'email_url': email_url,\n })\n except Exception as exc:\n return jsonify({'ok': False, 'error': f'Failed to save report: {exc}'}), 500\n\n\n@app.route('/upload', methods=['POST'])\ndef upload():\n files = request.files.getlist('map_file')\n model = request.form.get('model', 'best')\n\n if not files or not files[0].filename:\n return redirect('/')\n\n session_id = uuid.uuid4().hex[:12]\n session_maps = _USER_DATA_DIR / 'maps' / session_id\n session_maps.mkdir(parents=True, exist_ok=True)\n\n saved_names = []\n for f in files:\n if f.filename and Path(f.filename).suffix.lower() in ALLOWED_EXT:\n safe_name = secure_filename(f.filename)\n dest = session_maps / safe_name\n f.save(str(dest))\n saved_names.append(safe_name)\n\n if not saved_names:\n return redirect('/')\n\n combined = ','.join(saved_names)\n return redirect(f'/process/{url_quote(combined, safe=\"\")}?model={url_quote(model, safe=\"\")}&session={url_quote(session_id, safe=\"\")}')\n\n\n@app.route('/kill', methods=['POST'])\ndef kill_process():\n global _current_proc\n killed = False\n if _current_proc is not None:\n try:\n if os.name == 'nt':\n # Force-kill process AND all its children (OCR workers, LLM calls etc.)\n subprocess.call(['taskkill', '/F', '/T', '/PID', str(_current_proc.pid)],\n stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n else:\n _current_proc.kill()\n killed = True\n except Exception:\n pass\n finally:\n _current_proc = None\n # Release the lock so the next map can be processed immediately\n try:\n _process_lock.release()\n except RuntimeError:\n pass # wasn't held\n return json.dumps({'killed': killed})\n\n\n@app.route('/restart', methods=['POST'])\ndef restart_server():\n \"\"\"Kill any running job and release the lock so the server is ready for a new upload.\"\"\"\n global _current_proc\n # Force-kill running subprocess and any children\n if _current_proc is not None:\n try:\n if os.name == 'nt':\n subprocess.call(['taskkill', '/F', '/T', '/PID', str(_current_proc.pid)],\n stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n else:\n _current_proc.kill()\n except Exception:\n pass\n finally:\n _current_proc = None\n # Release the processing lock so new jobs can run\n try:\n _process_lock.release()\n except RuntimeError:\n pass\n return json.dumps({'ok': True})\n\n\n@app.route('/stream/<path:filename>')\ndef stream(filename):\n model = request.args.get('model', 'best')\n session_id = request.args.get('session', '')\n return Response(\n stream_with_context(_stream_gen(filename, model, session_id)),\n mimetype='text/event-stream',\n headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'},\n )\n\n\ndef _stream_gen(filename, model, session_id):\n global _current_proc\n env_overrides = _MODEL_ENVS.get(model, _MODEL_ENVS['best'])\n\n # ── API key check ────────────────────────────────────────────────────────\n _KEY_NEEDED = {\n 'vertex': None, # uses service-account JSON, no API key\n 'gemini': 'GEMINI_API_KEY',\n 'groq': 'GROQ_API_KEY',\n 'openai': 'OPENAI_API_KEY',\n 'claude': 'CLAUDE_API_KEY',\n 'grok': 'GROK_API_KEY',\n 'openrouter': 'OPENROUTER_API_KEY',\n }\n provider = env_overrides.get('LLM_PROVIDER', 'vertex')\n required_key = _KEY_NEEDED.get(provider)\n if required_key and not os.environ.get(required_key, '').strip():\n friendly = {\n 'GEMINI_API_KEY': 'Gemini API Key',\n 'GROQ_API_KEY': 'Groq API Key',\n 'OPENAI_API_KEY': 'OpenAI API Key',\n 'CLAUDE_API_KEY': 'Claude API Key',\n 'GROK_API_KEY': 'Grok (xAI) API Key',\n 'OPENROUTER_API_KEY': 'OpenRouter API Key',\n }.get(required_key, required_key)\n msg = (f'❌ No {friendly} found. '\n f'Please go to ⚙️ Settings, enter your {friendly}, and save before processing.')\n yield f'data: {json.dumps({\"msg\": msg, \"error\": True})}\\n\\n'\n return\n # Also check service-account JSON for vertex LLM or GCV OCR (both need it)\n ocr_engine = env_overrides.get('OCR_ENGINE', os.environ.get('OCR_ENGINE', 'gcv')).lower()\n needs_gcp = (provider == 'vertex') or (ocr_engine == 'gcv')\n if needs_gcp:\n creds = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS', '').strip()\n _gcp_found = (creds and Path(creds).exists()) or \\\n (_USER_DATA_DIR / 'service_account.json').exists() or \\\n (_USER_DATA_DIR / 'service_account2.json').exists() or \\\n (_USER_DATA_DIR / 'Service_account_Backup.json').exists() or \\\n (BASE_DIR / 'service_account.json').exists() or \\\n (BASE_DIR / 'Service_account_Backup.json').exists()\n if not _gcp_found:\n if provider == 'vertex':\n detail = 'Vertex AI LLM + Google Cloud Vision OCR both require'\n else:\n detail = 'Google Cloud Vision OCR requires'\n msg = (f'❌ No Google service-account JSON found. '\n f'{detail} a GCP service account. '\n f'Please go to ⚙️ Settings, upload your service_account.json, and save before processing.')\n yield f'data: {json.dumps({\"msg\": msg, \"error\": True})}\\n\\n'\n return\n # ── end API key check ────────────────────────────────────────────────────\n\n if not _process_lock.acquire(blocking=False):\n yield f'data: {json.dumps({\"msg\": \"Another map is already being processed. Please wait and try again.\"})}\\n\\n'\n yield f'data: {json.dumps({\"error\": True})}\\n\\n'\n return\n\n try:\n env = os.environ.copy()\n env.update(env_overrides)\n env['PYTHONIOENCODING'] = 'utf-8'\n env['PYTHONUNBUFFERED'] = '1'\n\n # Local OCR (EasyOCR/Tesseract) can trigger native DLL crashes when\n # BLAS/OpenMP thread pools over-subscribe in frozen builds.\n if env_overrides.get('OCR_ENGINE', '').lower() != 'gcv':\n # Force safe single-thread native runtime settings for local OCR.\n # setdefault is not enough because user/system env may already set\n # aggressive OpenMP/BLAS values that crash frozen EasyOCR/Torch.\n env['OMP_NUM_THREADS'] = '1'\n env['OPENBLAS_NUM_THREADS'] = '1'\n env['MKL_NUM_THREADS'] = '1'\n env['NUMEXPR_NUM_THREADS'] = '1'\n env['TORCH_NUM_THREADS'] = '1'\n env['TORCH_NUM_INTEROP_THREADS'] = '1'\n env['OMP_WAIT_POLICY'] = 'PASSIVE'\n env['KMP_AFFINITY'] = 'disabled'\n env['KMP_BLOCKTIME'] = '0'\n env['KMP_DUPLICATE_LIB_OK'] = 'TRUE'\n env['OPENCV_OPENCL_RUNTIME'] = 'disabled'\n env['OPENBLAS_CORETYPE'] = 'GENERIC'\n env['MKL_THREADING_LAYER'] = 'SEQUENTIAL'\n env['MKL_ENABLE_INSTRUCTIONS'] = 'COMPATIBLE'\n env['OMP_DYNAMIC'] = 'FALSE'\n env['PYTORCH_JIT'] = '0'\n env['GOMP_SPINCOUNT'] = '0'\n env['GOMP_STACKSIZE'] = '65536'\n\n session_maps_dir = str(_USER_DATA_DIR / 'maps' / session_id)\n session_results_dir = str(_USER_DATA_DIR / 'results' / session_id)\n session_logs_dir = str(_USER_DATA_DIR / 'logs' / session_id)\n Path(session_maps_dir).mkdir(parents=True, exist_ok=True)\n Path(session_results_dir).mkdir(parents=True, exist_ok=True)\n Path(session_logs_dir).mkdir(parents=True, exist_ok=True)\n env['MAPS_FOLDER'] = session_maps_dir\n env['RESULTS_FOLDER'] = session_results_dir\n env['LOGS_FOLDER'] = session_logs_dir\n results_url = f'/results/{session_id}'\n\n yield f'data: {json.dumps({\"msg\": f\"Starting processing for {filename}...\"})}\\n\\n'\n\n for label, script in [('pipeline', 'process_new_maps.py'),\n ('export', 'export_table.py')]:\n if label == 'export':\n yield f'data: {json.dumps({\"msg\": \"Exporting results table...\"})}\\n\\n'\n\n proc = subprocess.Popen(\n [sys.executable, script],\n cwd=str(BASE_DIR),\n stdout=subprocess.PIPE,\n stderr=subprocess.STDOUT,\n text=True,\n encoding='utf-8',\n errors='replace',\n bufsize=1,\n env=env,\n )\n _current_proc = proc\n\n # Read subprocess output in a background thread so we can send\n # SSE keepalive heartbeats when the process is silent (e.g. GCV\n # API calls), preventing the browser from closing the connection.\n line_q = queue.Queue()\n def _reader(pipe, q):\n for ln in iter(pipe.readline, ''):\n q.put(ln)\n q.put(None) # sentinel\n t = threading.Thread(target=_reader, args=(proc.stdout, line_q), daemon=True)\n t.start()\n\n while True:\n try:\n line = line_q.get(timeout=15)\n except queue.Empty:\n # No output for 15 s — send a keepalive comment so the\n # browser knows the connection is still alive.\n yield ': keepalive\\n\\n'\n continue\n if line is None: # sentinel — subprocess closed stdout\n break\n line = line.rstrip()\n if line:\n yield f'data: {json.dumps({\"msg\": line})}\\n\\n'\n\n proc.wait()\n _current_proc = None\n if label == 'pipeline' and proc.returncode not in (0, None):\n yield f'data: {json.dumps({\"msg\": f\"[ERROR] Pipeline exited with code {proc.returncode} — check logs\", \"error\": True})}\\n\\n'\n return\n\n # Merge this session into the global DB so Map Database stays up to date\n yield 'data: {\"msg\": \"Saving to Map Database...\"}\\n\\n'\n _merge_session_to_global(session_id)\n\n session_rows = _session_feature_count(session_id)\n if session_rows == 0:\n warn_msg = (\n 'Processing completed, but 0 features were extracted. '\n 'View Results may look empty. Try a clearer map, higher contrast scan, '\n 'or run with GCV OCR for stronger detection.'\n )\n yield f'data: {json.dumps({\"done\": True, \"redirect\": results_url, \"empty\": True, \"msg\": warn_msg})}\\n\\n'\n else:\n ok_msg = f'Done! Extracted {session_rows} feature(s). Click View Results.'\n yield f'data: {json.dumps({\"done\": True, \"redirect\": results_url, \"msg\": ok_msg})}\\n\\n'\n\n finally:\n try:\n _process_lock.release()\n except RuntimeError:\n pass\n\n\n@app.route('/process/<path:filename>')\ndef process(filename):\n model = request.args.get('model', 'best')\n session_id = request.args.get('session', '')\n return _process_html(filename, model, session_id)\n\n\n@app.route('/get_env')\ndef get_env():\n # Only allow requests originating from the app's own pages (pywebview or localhost).\n # Direct browser navigation (typing the URL) sends no Referer — block it.\n ref = request.referrer or ''\n if not (ref.startswith('http://127.0.0.1:') or ref.startswith('http://localhost:')):\n return '', 403\n keys = ['GROQ_API_KEY', 'GEMINI_API_KEY', 'GEMINI_API_KEY_2', 'OPENAI_API_KEY',\n 'CLAUDE_API_KEY', 'GROK_API_KEY', 'GOOGLE_API_KEY', 'OPENROUTER_API_KEY',\n 'OPENROUTER_MODEL', 'GOOGLE_APPLICATION_CREDENTIALS',\n 'VERTEX_PROJECT', 'VERTEX_LOCATION', 'VERTEX_MODEL']\n env_data = {k: os.environ.get(k, '') for k in keys}\n\n # Migrate legacy OpenRouter default model to the new recommended auto router.\n old_openrouter_default = 'meta-llama/llama-3.3-70b-instruct:free'\n or_model = env_data.get('OPENROUTER_MODEL', '').strip().lower()\n if (not or_model) or (or_model == old_openrouter_default):\n env_data['OPENROUTER_MODEL'] = 'openrouter/auto'\n os.environ['OPENROUTER_MODEL'] = 'openrouter/auto'\n try:\n _write_env({'OPENROUTER_MODEL': 'openrouter/auto'})\n except Exception:\n pass\n\n return json.dumps(env_data)\n\n\n@app.route('/results', methods=['GET', 'HEAD'])\n@app.route('/results/<session_id>', methods=['GET', 'HEAD'])\ndef results(session_id=None):\n if session_id:\n out = _USER_DATA_DIR / 'results' / session_id / 'table_export.html'\n res_dir = str(_USER_DATA_DIR / 'results' / session_id)\n else:\n out = RESULTS_DIR / 'table_export.html'\n res_dir = str(RESULTS_DIR)\n\n # HEAD request (used by the polling check in the processing page)\n # Only return 200 if the HTML already exists — don't generate on HEAD\n if request.method == 'HEAD':\n if out.exists():\n return '', 200\n return '', 404\n\n # If HTML not yet generated, try to generate it on-demand from the DB\n # (also runs when DB doesn't exist yet — export_table handles empty/missing DB gracefully)\n if not out.exists():\n try:\n Path(res_dir).mkdir(parents=True, exist_ok=True)\n env = os.environ.copy()\n env['RESULTS_FOLDER'] = res_dir\n env['PYTHONIOENCODING'] = 'utf-8'\n env['PYTHONUNBUFFERED'] = '1'\n subprocess.run(\n [sys.executable, 'export_table.py'],\n cwd=str(BASE_DIR), env=env,\n timeout=60, capture_output=True\n )\n except Exception:\n pass\n\n if out.exists():\n return send_file(str(out))\n return ('<h2 style=\"font-family:sans-serif;padding:40px;color:#888\">'\n 'No results yet – please upload and process a map first.'\n ' <a href=\"/\">&#8592; Go back</a></h2>'), 404\n\n\n@app.route('/delete_maps', methods=['POST', 'OPTIONS'])\ndef delete_maps():\n # Allow CORS so the page can call this even when opened as file://\n if request.method == 'OPTIONS':\n resp = app.response_class(status=204)\n resp.headers['Access-Control-Allow-Origin'] = '*'\n resp.headers['Access-Control-Allow-Headers'] = 'Content-Type'\n resp.headers['Access-Control-Allow-Methods'] = 'POST'\n return resp\n\n data = request.get_json(silent=True) or {}\n maps_to_delete = data.get('maps', [])\n if not maps_to_delete:\n resp = app.response_class(json.dumps({'ok': False, 'error': 'No maps specified'}),\n mimetype='application/json')\n resp.headers['Access-Control-Allow-Origin'] = '*'\n return resp, 400\n\n deleted = 0\n db_path = RESULTS_DIR / 'toposheet.db'\n if db_path.exists():\n try:\n with sqlite3.connect(str(db_path)) as con:\n placeholders = ','.join('?' * len(maps_to_delete))\n cur = con.execute(\n f'DELETE FROM features WHERE map_name IN ({placeholders})',\n maps_to_delete\n )\n deleted = cur.rowcount\n con.commit()\n except Exception as e:\n resp = app.response_class(json.dumps({'ok': False, 'error': str(e)}),\n mimetype='application/json')\n resp.headers['Access-Control-Allow-Origin'] = '*'\n return resp, 500\n\n # Regenerate the HTML export so it reflects the deletion\n try:\n env = os.environ.copy()\n env['RESULTS_FOLDER'] = str(RESULTS_DIR)\n env['PYTHONIOENCODING'] = 'utf-8'\n result = subprocess.run([sys.executable, 'export_table.py'],\n cwd=str(BASE_DIR), env=env, timeout=60, capture_output=True)\n if result.returncode != 0:\n print('[export] export_table.py failed:', result.stderr.decode(errors='replace'))\n except Exception as e:\n print('[export] export_table.py exception:', e)\n\n resp = app.response_class(\n json.dumps({'ok': True, 'deleted_rows': deleted, 'maps': len(maps_to_delete)}),\n mimetype='application/json'\n )\n resp.headers['Access-Control-Allow-Origin'] = '*'\n return resp\n\n\n# ── Welcome / first-run onboarding page ──────────────────────────────────────\n_WELCOME_TEMPLATE = \"\"\"<!DOCTYPE html>\n<html lang='en'>\n<head>\n<meta charset='UTF-8'>\n<meta name='viewport' content='width=device-width,initial-scale=1.0'>\n<title>Welcome to CV-Toposheet</title>\n<style>\n* { box-sizing:border-box; margin:0; padding:0; }\nhtml, body { height:100%; overflow:hidden; font-family:'Segoe UI', system-ui, Arial, sans-serif; background:#f0f4f8; }\n.shell { display:flex; flex-direction:column; height:100vh; }\n\n/* Header */\n.hdr { background:#0E7490; padding:12px 28px; flex-shrink:0; display:flex; align-items:center; justify-content:space-between; }\n.hdr-logo { font-size:1.15em; font-weight:700; color:#fff; }\n.hdr-sub { font-size:0.76em; color:rgba(255,255,255,.75); margin-top:1px; }\n.hdr-step { font-size:0.82em; color:rgba(255,255,255,.85); font-weight:600; background:rgba(255,255,255,.15); padding:4px 12px; border-radius:20px; }\n\n/* Progress bar */\n.prog { height:3px; background:rgba(14,116,144,.15); flex-shrink:0; }\n.prog-fill { height:100%; background:#22d3ee; transition:width .35s ease; }\n\n/* Slide container */\n.slides { flex:1; position:relative; overflow:hidden; }\n.slide { position:absolute; inset:0; display:flex; justify-content:center; align-items:flex-start; padding:14px 20px 10px; overflow:hidden; transition:opacity .25s, transform .25s; }\n.slide.hidden { opacity:0; pointer-events:none; transform:translateX(40px); }\n.slide.hidden-left { opacity:0; pointer-events:none; transform:translateX(-40px); }\n.inner { width:100%; max-width:680px; display:flex; flex-direction:column; gap:9px; }\n.slide#slide1 .inner { gap:8px; transform:translateY(-6px); }\n\n/* Slide title */\n.slide-icon { font-size:1.6em; text-align:center; }\n.slide-title { font-size:1.35em; font-weight:800; color:#0E7490; text-align:center; }\n.slide-sub { font-size:0.88em; color:#4a6070; text-align:center; line-height:1.5; }\n\n/* Cards */\n.card { background:#fff; border-radius:12px; border:1.5px solid #d4eaf0; padding:10px 16px; box-shadow:0 2px 10px rgba(14,116,144,.07); }\n.card-row { display:flex; align-items:flex-start; gap:12px; }\n.card-icon { font-size:1.4em; flex-shrink:0; margin-top:2px; }\n.card-title { font-weight:700; color:#0E7490; font-size:0.95em; margin-bottom:2px; }\n.card-desc { font-size:0.84em; color:#5a7a8a; line-height:1.5; }\n.card-link { font-size:0.83em; color:#0891b2; margin-top:3px; }\n.badge { display:inline-block; font-size:0.7em; font-weight:700; border-radius:4px; padding:2px 7px; margin-left:6px; vertical-align:middle; }\n.badge-free { background:#dcfce7; color:#166534; }\n.badge-paid { background:#fce7f3; color:#9d174d; }\n.badge-opt { background:#fef9c3; color:#92400e; }\n.note { background:#fff7ed; border:1px solid #fed7aa; border-radius:8px; padding:7px 12px; font-size:0.82em; color:#92400e; line-height:1.4; }\n.what-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; }\n.wc { background:#f0f9fb; border:1px solid #b8dde8; border-radius:8px; padding:10px 12px; }\n.wc-num { font-size:1.1em; font-weight:800; color:#0E7490; }\n.wc-title { font-weight:700; font-size:0.86em; color:#1e3a4a; }\n.wc-desc { font-size:0.77em; color:#6b8a99; margin-top:2px; }\n.divider { text-align:center; font-size:0.76em; color:#aaa; font-weight:700; letter-spacing:.04em; }\n.ready-big { text-align:center; font-size:3em; margin:4px 0; }\n.ready-list { display:flex; flex-direction:column; gap:8px; }\n.ready-row { display:flex; align-items:center; gap:10px; background:#fff; border:1.5px solid #d4eaf0; border-radius:10px; padding:11px 16px; }\n.ready-row .ri { font-size:1.3em; flex-shrink:0; }\n.ready-row .rt { font-size:0.88em; color:#2a4a5a; line-height:1.4; }\n.ready-row .rt b { color:#0E7490; }\n\n/* Footer nav */\n.nav { flex-shrink:0; background:#fff; border-top:1px solid #e0eaf0; padding:14px 28px; display:flex; align-items:center; justify-content:space-between; }\n.dots { display:flex; gap:8px; }\n.dot { width:8px; height:8px; border-radius:50%; background:#d0e8f0; transition:background .25s, transform .25s; }\n.dot.active { background:#0E7490; transform:scale(1.3); }\n.dot.done { background:#67c8e0; }\n.btn { border:none; border-radius:9px; padding:11px 28px; font-size:0.95em; font-weight:700; cursor:pointer; transition:all .18s; }\n.btn-back { background:#f0f4f8; color:#5a7a8a; }\n.btn-back:hover { background:#e0eaf0; }\n.btn-next { background:linear-gradient(135deg,#0e7490,#0891b2); color:#fff; box-shadow:0 3px 12px rgba(14,116,144,.3); }\n.btn-next:hover { background:linear-gradient(135deg,#0891b2,#06b6d4); box-shadow:0 5px 18px rgba(14,116,144,.45); transform:translateY(-1px); }\n.btn-start { background:linear-gradient(135deg,#0e7490,#0891b2); color:#fff; padding:11px 32px; box-shadow:0 3px 12px rgba(14,116,144,.3); }\n.btn-start:hover { background:linear-gradient(135deg,#0891b2,#06b6d4); box-shadow:0 5px 18px rgba(14,116,144,.45); transform:translateY(-1px); }\n.invisible { visibility:hidden; }\n</style>\n</head>\n<body>\n<div class=\"shell\">\n\n <!-- Header -->\n <div class=\"hdr\">\n <div>\n <div class=\"hdr-logo\">CV-Toposheet</div>\n <div class=\"hdr-sub\">AI-Powered Historical Toposheet Digitization</div>\n </div>\n <div class=\"hdr-step\" id=\"stepLabel\">Step 1 of 4</div>\n </div>\n <div class=\"prog\"><div class=\"prog-fill\" id=\"progFill\" style=\"width:25%\"></div></div>\n\n <!-- Slides -->\n <div class=\"slides\">\n\n <!-- Slide 1: What is this app -->\n <div class=\"slide\" id=\"slide0\">\n <div class=\"inner\">\n <div class=\"slide-icon\">&#128255;</div>\n <div class=\"slide-title\">Welcome to CV-Toposheet!</div>\n <div class=\"slide-sub\">Automatically extract &amp; digitize geographic feature names from scanned historical topographic maps.</div>\n <div class=\"what-grid\">\n <div class=\"wc\"><div class=\"wc-num\">1</div><div class=\"wc-title\">Upload</div><div class=\"wc-desc\">Drop a scanned toposheet (JPG / PNG / TIF)</div></div>\n <div class=\"wc\"><div class=\"wc-num\">2</div><div class=\"wc-title\">OCR</div><div class=\"wc-desc\">AI reads all text from every tile of the map</div></div>\n <div class=\"wc\"><div class=\"wc-num\">3</div><div class=\"wc-title\">Clean</div><div class=\"wc-desc\">LLM corrects errors &amp; classifies features</div></div>\n <div class=\"wc\"><div class=\"wc-num\">4</div><div class=\"wc-title\">Search</div><div class=\"wc-desc\">Query &amp; export the full results database</div></div>\n </div>\n <div class=\"note\">&#128336; This setup screen appears <b>once only</b>. Takes about 60 seconds. You can revisit instructions anytime in <b>Settings &rarr; Help</b>.</div>\n </div>\n </div>\n\n <!-- Slide 2: API Key -->\n <div class=\"slide hidden\" id=\"slide1\">\n <div class=\"inner\">\n <div class=\"slide-icon\">&#128273;</div>\n <div class=\"slide-title\">Step 1: Add an LLM API Key</div>\n <div class=\"slide-sub\">Required for feature extraction. Go to <b>&#9881; Settings</b> on the home screen and paste at least one key.</div>\n <div class=\"card\">\n <div class=\"card-row\">\n <div class=\"card-icon\">&#9889;</div>\n <div>\n <div class=\"card-title\">Groq API Key <span class=\"badge badge-free\">FREE &amp; FASTEST</span></div>\n <div class=\"card-desc\">No credit card needed. Best for first-time users.</div>\n <div class=\"card-link\">&#128279; console.groq.com &rarr; Sign up &rarr; API Keys &rarr; Create</div>\n </div>\n </div>\n </div>\n <div class=\"divider\">or</div>\n <div class=\"card\">\n <div class=\"card-row\">\n <div class=\"card-icon\">&#128171;</div>\n <div>\n <div class=\"card-title\">Gemini API Key <span class=\"badge badge-free\">FREE</span></div>\n <div class=\"card-desc\">Google Gemini Flash. Free tier is generous.</div>\n <div class=\"card-link\">&#128279; aistudio.google.com/apikey &rarr; Create API key</div>\n </div>\n </div>\n </div>\n <div class=\"divider\">or</div>\n <div class=\"card\">\n <div class=\"card-row\">\n <div class=\"card-icon\">&#128176;</div>\n <div>\n <div class=\"card-title\">Other API Keys <span class=\"badge badge-paid\">PAID</span></div>\n <div class=\"card-desc\">Claude (Anthropic), Grok (xAI), OpenAI, and more. Add via <b>&#9881; Settings</b> after setup.</div>\n <div class=\"card-link\">&#128279; anthropic.com &nbsp;|&nbsp; x.ai/api &nbsp;|&nbsp; platform.openai.com</div>\n </div>\n </div>\n </div>\n <div class=\"note\">&#9888;&#65039; <b>Without an API key</b> the pipeline runs but produces wrong results: blank Feature Types, 0.50 confidence, noise rows.</div>\n </div>\n </div>\n\n <!-- Slide 3: GCV JSON -->\n <div class=\"slide hidden\" id=\"slide2\">\n <div class=\"inner\">\n <div class=\"slide-icon\">&#9729;&#65039;</div>\n <div class=\"slide-title\">Step 2: Google Cloud Vision JSON</div>\n <div class=\"slide-sub\">Strongly recommended for best accuracy. Enables premium OCR via Google Cloud Vision API. Without it, the app uses EasyOCR (offline, slower, less accurate).</div>\n <div class=\"card\">\n <div class=\"card-title\" style=\"margin-bottom:8px;\">How to get the JSON file</div>\n <div class=\"card-desc\" style=\"line-height:1.8;\">\n 1. Go to <b>console.cloud.google.com</b><br>\n 2. Enable <b>Cloud Vision API</b> (APIs &amp; Services &rarr; Library)<br>\n 3. IAM &amp; Admin &rarr; Service Accounts &rarr; Create &rarr; Keys &rarr; Add Key &rarr; JSON<br>\n 4. File downloads automatically\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-row\">\n <div class=\"card-icon\">&#128196;</div>\n <div>\n <div class=\"card-title\">Upload in Settings</div>\n <div class=\"card-desc\">Click <b>&#9881; Settings &rarr; Google Cloud &rarr; Upload GCP Service Account JSON</b> and select the downloaded file.</div>\n </div>\n </div>\n </div>\n <div class=\"note\">&#9989; For best accuracy, set this up now. You can also add it later via <b>Settings &#8594; Help &#8594; Google Cloud</b>.</div>\n <div class=\"note\" style=\"margin-top:8px;background:#f0fdf4;border-color:#86efac;color:#166534;\">&#11088; For even better results, also enable <b>Vertex AI API</b> in Google Cloud Console (APIs &amp; Services &rarr; Library &rarr; search &ldquo;Vertex AI&rdquo; &rarr; Enable).</div>\n </div>\n </div>\n\n <!-- Slide 4: Ready -->\n <div class=\"slide hidden\" id=\"slide3\">\n <div class=\"inner\">\n <div class=\"ready-big\">&#128640;</div>\n <div class=\"slide-title\">You&rsquo;re Ready to Go!</div>\n <div class=\"slide-sub\">Here&rsquo;s how to process your first map.</div>\n <div class=\"ready-list\">\n <div class=\"ready-row\"><div class=\"ri\">&#128444;&#65039;</div><div class=\"rt\"><b>Upload:</b> Drag &amp; drop a JPG / PNG / TIF toposheet onto the home screen (up to 500 MB)</div></div>\n <div class=\"ready-row\"><div class=\"ri\">&#127917;</div><div class=\"rt\"><b>Choose Model:</b> Select a processing model from the right panel (Best Quality recommended)</div></div>\n <div class=\"ready-row\"><div class=\"ri\">&#9654;&#65039;</div><div class=\"rt\"><b>Process:</b> Click <b>Process Map</b> and watch live progress (5&ndash;30 min depending on map size)</div></div>\n <div class=\"ready-row\"><div class=\"ri\">&#128202;</div><div class=\"rt\"><b>Search &amp; Export:</b> Find results in <b>Map Database</b>. Export to CSV or Excel anytime.</div></div>\n </div>\n </div>\n </div>\n\n </div><!-- /slides -->\n\n <!-- Navigation footer -->\n <div class=\"nav\">\n <button class=\"btn btn-back invisible\" id=\"btnBack\" onclick=\"go(-1)\">&#8592; Back</button>\n <div class=\"dots\">\n <div class=\"dot active\" id=\"dot0\"></div>\n <div class=\"dot\" id=\"dot1\"></div>\n <div class=\"dot\" id=\"dot2\"></div>\n <div class=\"dot\" id=\"dot3\"></div>\n </div>\n <button class=\"btn btn-next\" id=\"btnNext\" onclick=\"go(1)\">Next &rarr;</button>\n </div>\n\n</div><!-- /shell -->\n\n<form id=\"dismissForm\" action=\"/dismiss_welcome\" method=\"post\" style=\"display:none\"></form>\n\n<script>\nvar cur = 0, total = 4;\nfunction go(dir) {\n var next = cur + dir;\n if (cur === total - 1 && dir === 1) { document.getElementById('dismissForm').submit(); return; }\n if (next < 0 || next >= total) return;\n var slides = document.querySelectorAll('.slide');\n slides[cur].classList.add(dir > 0 ? 'hidden' : 'hidden-left');\n slides[next].classList.remove('hidden', 'hidden-left');\n cur = next;\n update();\n}\nfunction update() {\n document.getElementById('stepLabel').textContent = 'Step ' + (cur+1) + ' of ' + total;\n document.getElementById('progFill').style.width = ((cur+1)/total*100) + '%';\n for (var i=0; i<total; i++) {\n var d = document.getElementById('dot'+i);\n d.className = 'dot' + (i===cur?' active':(i<cur?' done':''));\n }\n var back = document.getElementById('btnBack');\n var next = document.getElementById('btnNext');\n back.className = 'btn btn-back' + (cur===0?' invisible':'');\n if (cur === total-1) {\n next.textContent = '\\\\u{1F680} Get Started';\n next.className = 'btn btn-start';\n } else {\n next.textContent = 'Next \\\\u2192';\n next.className = 'btn btn-next';\n }\n}\n</script>\n</body>\n</html>\"\"\"\n\n\n# ── Landing page HTML ─────────────────────────────────────────────────────────\ndef _landing_html(feat_count: int, map_count: int) -> str:\n html = _LANDING_TEMPLATE\n html = html.replace('__FEAT_COUNT__', str(feat_count))\n html = html.replace('__MAP_COUNT__', str(map_count))\n return html\n\n\n_LANDING_TEMPLATE = \"\"\"<!DOCTYPE html>\n<html lang='en'>\n<head>\n<meta charset='UTF-8'>\n<meta name='viewport' content='width=device-width,initial-scale=1.0'>\n<title>CV-Toposheet - Map Digitization</title>\n<style>\n* { box-sizing:border-box; margin:0; padding:0; }\nhtml { height:100%; }\nbody { min-height:100vh; font-family:'Segoe UI', system-ui, Arial, sans-serif; background:#f0f4f8; display:flex; flex-direction:column; }\n.hdr { display:flex; align-items:center; justify-content:space-between; background:#0E7490; padding:clamp(10px,1.8vw,14px) clamp(14px,2.5vw,28px); flex-shrink:0; flex-wrap:wrap; gap:8px; }\n.hdr-logo { font-size:clamp(1em,2vw,1.25em); font-weight:700; color:#fff; letter-spacing:-.01em; }\n.hdr-logo span { color:#fff; }\n.hdr-sub { font-size:0.78em; color:rgba(255,255,255,.75); margin-top:2px; }\n.hdr-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }\n.report-btn { background:rgba(255,255,255,.15); border:1px solid rgba(255,255,255,.3); color:#fff; border-radius:7px; padding:0 16px; height:36px; font-size:0.85em; font-weight:700; cursor:pointer; transition:background .2s; display:inline-flex; align-items:center; gap:5px; box-sizing:border-box; }\n.report-btn:hover { background:rgba(255,255,255,.25); }\n.settings-btn { background:rgba(255,255,255,.15); border:1px solid rgba(255,255,255,.3); color:#fff; border-radius:7px; padding:0 16px; height:36px; font-size:0.85em; font-weight:700; cursor:pointer; transition:background .2s; display:inline-flex; align-items:center; gap:5px; box-sizing:border-box; }\n.settings-btn:hover { background:rgba(255,255,255,.25); }\n#uploadForm { flex:1 0 auto; display:flex; flex-direction:column; }\n.body { flex:1 0 auto; display:flex; flex-direction:row; gap:clamp(8px,1.5vw,16px); align-items:stretch; padding:clamp(6px,1vw,10px) clamp(4px,0.8vw,8px); }\n.left { flex:1; min-width:0; display:flex; flex-direction:column; }\n.right { width:clamp(300px,32vw,460px); min-width:0; flex-shrink:0; display:flex; flex-direction:column; padding:clamp(6px,1vw,12px); gap:0; background:#fff; border:2px solid #93c5d4; border-radius:14px; box-shadow:0 2px 16px rgba(14,116,144,.08); }\n.right-title { font-size:0.55em; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:#0E7490; margin-bottom:4px; padding-bottom:3px; border-bottom:1.5px solid #e0f2f7; flex-shrink:0; }\n.model-label { font-size:0.63em; font-weight:600; color:#6b8a99; text-transform:uppercase; letter-spacing:.08em; margin-bottom:4px; flex-shrink:0; }\n.model-cards { display:flex; flex-direction:column; gap:4px; margin-bottom:3px; flex:1; overflow-y:auto; min-height:0; padding-right:3px; scrollbar-width:thin; scrollbar-color:#93c5d4 #f0f4f8; }\n.model-cards::-webkit-scrollbar { width:6px; }\n.model-cards::-webkit-scrollbar-track { background:#f0f4f8; border-radius:3px; }\n.model-cards::-webkit-scrollbar-thumb { background:#93c5d4; border-radius:3px; }\n.model-cards::-webkit-scrollbar-thumb:hover { background:#0e7490; }\n.model-card { border:1.5px solid #ccdde3; border-radius:6px; padding:3px 8px; cursor:pointer; transition:all .2s; display:flex; align-items:center; gap:6px; background:#fff; box-shadow:0 1px 2px rgba(14,116,144,.05); flex-shrink:0; }\n.model-card:hover { border-color:#0e7490; background:#f0f9fb; box-shadow:0 2px 10px rgba(14,116,144,.15); }\n.model-card input[type=radio] { accent-color:#0e7490; margin:0; flex-shrink:0; width:13px; height:13px; }\n.mc-body { flex:1; }\n.mc-body .mc-name { font-size:0.74em; font-weight:700; color:#1e3a4a; display:flex; align-items:center; gap:4px; flex-wrap:wrap; }\n.mc-body .mc-desc { font-size:0.65em; color:#6b8a99; margin-top:0px; line-height:1.2; }\n.mc-key { display:inline; font-size:1em; font-weight:600; background:#e0f2f7; border:1px solid #b8dde8; border-radius:3px; padding:0 3px; color:#0e7490; letter-spacing:.01em; }\n.badge { display:inline-block; border-radius:3px; padding:0px 4px; font-size:0.58em; font-weight:700; }\n.badge-best { background:#d0f0f7; color:#0e7490; }\n.badge-fast { background:#fef9c3; color:#a16207; }\n.badge-gemini { background:#e8f5e9; color:#2e7d32; }\n.badge-gemini { background:#e8f5e9; color:#2e7d32; }\n.badge-offline { background:#ede9fe; color:#6d28d9; }\n.model-card.selected { border-color:#0e7490; border-width:1.5px; background:#e8f5f9; box-shadow:0 0 0 2px rgba(14,116,144,.10); }\n.divider { flex-shrink:0; min-height:4px; max-height:8px; }\n.info-strip { background:#f0f9fb; border-radius:5px; border:1px solid #b8dde8; padding:3px 7px; margin-top:4px; flex-shrink:0; font-size:0.54em; color:#4a7080; line-height:1.3; }\n.info-strip b { color:#0E7490; }\n.process-btn { width:100%; padding:7px; background:linear-gradient(135deg,#0e7490,#0891b2); color:white; border:none; border-radius:7px; font-size:0.8em; font-weight:700; cursor:pointer; transition:all .2s; box-shadow:0 2px 8px rgba(14,116,144,.3); letter-spacing:.01em; flex-shrink:0; }\n.process-btn:hover { background:linear-gradient(135deg,#0891b2,#06b6d4); box-shadow:0 5px 22px rgba(14,116,144,.55); transform:translateY(-1px); }\n.process-btn:disabled { background:#dde8ec; color:#7a9daa; cursor:not-allowed; box-shadow:none; transform:none; }\n.process-btn .btn-sub { display:block; font-size:0.68em; font-weight:400; color:rgba(255,255,255,.70); margin-top:2px; }\n.drop-zone { position:relative; border:2px dashed #93c5d4; border-radius:14px; background:#fff; flex:1; min-height:clamp(160px,25vh,300px); display:flex; flex-direction:column; align-items:center; justify-content:center; padding:clamp(14px,2.5vw,24px) clamp(10px,2vw,16px) clamp(10px,2vw,16px); transition:border .18s,background .18s; cursor:pointer; overflow:visible; }\n.drop-zone.drag-over { border-color:#0e7490; background:#f6fdff; box-shadow:0 4px 32px rgba(14,116,144,.18); }\n.dz-icon-row { display:flex; align-items:center; justify-content:center; gap:clamp(12px,2.5vw,28px); margin-bottom:10px; }\n.dz-icon-side { font-size:clamp(3.5em,6vw,6em); line-height:1; }\n.dz-icon { font-size:clamp(4em,6.5vw,6.5em); line-height:1; }\n.dz-icon-world { font-size:clamp(4em,6.5vw,6.5em); line-height:1; filter:grayscale(1) brightness(0.6); -webkit-filter:grayscale(1) brightness(0.6); opacity:0.55; }\n.dz-title { font-size:clamp(0.9em,1.5vw,1.05em); font-weight:700; color:#1e3a4a; margin-top:4px; text-align:center; }\n.dz-sub { font-size:clamp(0.68em,1.2vw,0.75em); color:#6b8a99; text-align:center; }\n.dz-badge { background:#e0f2f7; border:1px solid #93c5d4; border-radius:20px; padding:4px 12px; font-size:0.72em; color:#0e7490; letter-spacing:.06em; margin-top:6px; display:inline-block; white-space:nowrap; }\n#preview { display:flex; gap:12px; flex-wrap:wrap; justify-content:center; max-width:580px; margin-top:10px; padding:4px 8px 4px 8px; position:relative; z-index:10; pointer-events:auto; }\n.prev-thumb { width:72px; height:72px; object-fit:cover; border-radius:6px; border:2px solid #93c5d4; box-shadow:0 2px 8px rgba(0,0,0,.15); display:block; }\n.prev-name { font-size:0.68em; color:#0e7490; text-align:center; max-width:72px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }\n.prev-item { display:flex; flex-direction:column; align-items:center; gap:3px; position:relative; cursor:default; }\n.prev-remove { position:absolute; top:3px; right:3px; width:22px; height:22px; border-radius:50%; background:rgba(220,38,38,0.92); color:#fff; border:2px solid #fff; font-size:13px; line-height:1; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 1px 5px rgba(0,0,0,.4); z-index:20; font-weight:900; opacity:0; transition:opacity .15s; pointer-events:auto; }\n.prev-item:hover .prev-remove { opacity:1; }\n.drop-zone input[type=file] { position:absolute; inset:0; opacity:0; cursor:pointer; width:100%; height:100%; z-index:5; }\n/* Settings Modal */\n.key-section { border:1px solid #e0e7ef; border-radius:8px; margin-bottom:12px; background:#f8fafc; }\n.key-section-title { font-weight:700; font-size:0.92em; padding:8px 12px; background:#e0f2f7; border-radius:8px 8px 0 0; cursor:pointer; user-select:none; display:flex; align-items:center; justify-content:space-between; }\n.key-section.collapsed .key-section-title::after { content:'\\\\25BC'; font-size:0.75em; }\n.key-section:not(.collapsed) .key-section-title::after { content:'\\\\25B2'; font-size:0.75em; }\n.key-section.collapsed .key-row { display:none; }\n.key-row { display:flex; align-items:center; justify-content:space-between; padding:7px 12px; border-bottom:1px solid #e0e7ef; gap:12px; }\n.key-row:last-child { border-bottom:none; }\n.key-row label { font-size:0.82em; color:#334; min-width:160px; }\n.key-row-input { display:flex; align-items:center; gap:6px; flex:1; }\n.key-row-input input { flex:1; border:1px solid #cdd; border-radius:5px; padding:5px 8px; font-size:0.82em; }\n#key_section_models .key-row-input input { color:#6b7280; }\n#key_section_models .key-row-input input::placeholder { color:#9ca3af; }\n.eye-btn { background:none; border:none; cursor:pointer; font-size:1.1em; padding:0 2px; }\n.modal-overlay { display:none; position:fixed; top:0; left:0; width:100vw; height:100vh; background:rgba(0,0,0,.35); align-items:center; justify-content:center; z-index:1000; }\n.modal-overlay.open { display:flex; }\n.modal-card { background:#fff; border-radius:14px; box-shadow:0 6px 40px rgba(14,116,144,.22); padding:0; min-width:360px; max-width:560px; width:95vw; max-height:88vh; display:flex; flex-direction:column; }\n.modal-header { display:flex; align-items:center; justify-content:space-between; padding:18px 20px 0 20px; flex-shrink:0; }\n.modal-header h2 { font-size:1.1em; color:#0E7490; }\n.modal-close { background:none; border:none; font-size:1.3em; cursor:pointer; color:#888; line-height:1; }\n.modal-body { padding:14px 20px; overflow-y:auto; flex:1; }\n.modal-footer { display:flex; align-items:center; justify-content:flex-end; gap:12px; padding:12px 20px 16px 20px; border-top:1px solid #e0e7ef; flex-shrink:0; }\n.save-notice { color:#16a34a; font-weight:700; display:none; align-items:center; gap:6px; font-size:0.9em; margin-right:auto; }\n.btn-cancel { background:#e0e7ef; color:#0E7490; border:none; border-radius:6px; padding:8px 18px; font-size:0.95em; font-weight:700; cursor:pointer; }\n.btn-save { background:#0E7490; color:#fff; border:none; border-radius:6px; padding:8px 18px; font-size:0.95em; font-weight:700; cursor:pointer; }\n.show-all-btn { background:none; border:none; color:#0E7490; font-weight:700; cursor:pointer; font-size:0.85em; margin-bottom:10px; text-decoration:underline; }\n.upload-json-row { display:flex; flex-direction:column; gap:6px; padding:8px 12px; border-bottom:1px solid #e0e7ef; }\n.upload-json-row label { font-size:0.82em; color:#334; font-weight:600; }\n.upload-json-row .upload-hint { font-size:0.75em; color:#888; }\n.upload-json-btn { background:#0e7490; color:#fff; border:none; border-radius:5px; padding:5px 12px; font-size:0.8em; font-weight:700; cursor:pointer; }\n.upload-json-btn:hover { background:#0891b2; }\n.upload-status { font-size:0.78em; margin-top:2px; }\n.custom-key-add-row { display:flex; gap:6px; padding:8px 12px; }\n.custom-key-add-row input { flex:1; border:1px solid #cdd; border-radius:5px; padding:5px 8px; font-size:0.82em; }\n.custom-key-add-btn { background:#0e7490; color:#fff; border:none; border-radius:5px; padding:5px 12px; font-size:0.82em; font-weight:700; cursor:pointer; white-space:nowrap; }\n.custom-key-add-btn:hover { background:#0891b2; }\n.badge-premium { background:#fef3c7; color:#92400e; }\n.badge-optional { background:#f0fdf4; color:#166534; }\n.badge-claude { background:#fae8ff; color:#86198f; }\n.badge-grok { background:#fff7ed; color:#c2410c; }\n.report-help { font-size:0.8em; color:#5a7a8a; line-height:1.55; margin-bottom:12px; }\n.report-grid { display:flex; flex-direction:column; gap:10px; }\n.report-field { display:flex; flex-direction:column; gap:5px; }\n.report-field label { font-size:0.82em; color:#334; font-weight:600; }\n.report-field input, .report-field select, .report-field textarea { width:100%; border:1px solid #cdd; border-radius:6px; padding:8px 10px; font-size:0.82em; font-family:inherit; }\n.report-field textarea { min-height:90px; resize:vertical; }\n.report-readonly { background:#f8fafc; color:#5a7a8a; }\n.report-status { display:none; font-size:0.82em; line-height:1.45; margin-right:auto; max-width:60%; }\n.report-status.ok { color:#166534; display:block; }\n.report-status.err { color:#b91c1c; display:block; }\n.report-link { color:#0E7490; font-weight:700; text-decoration:underline; }\n.report-actions { display:flex; gap:10px; margin-right:auto; align-items:center; flex-wrap:wrap; }\n.report-actions.open { display:flex; }\n.report-action-btn { border:none; border-radius:6px; padding:8px 12px; font-size:0.82em; font-weight:700; cursor:pointer; }\n.report-action-copy { background:#e0f2f7; color:#0E7490; }\n.report-action-copy:hover { background:#cbe8f1; }\n.report-action-link { color:#0E7490; text-decoration:underline; font-size:0.82em; font-weight:700; }\n#reportModal .btn-cancel,\n#reportModal .btn-save { min-width:132px; display:inline-flex; align-items:center; justify-content:center; }\n/* PIN Lock */\n.pin-overlay { display:none; position:fixed; top:0; left:0; width:100vw; height:100vh; background:rgba(0,0,0,.55); align-items:center; justify-content:center; z-index:2000; }\n.pin-overlay.open { display:flex; }\n.pin-card { background:#fff; border-radius:14px; box-shadow:0 6px 40px rgba(14,116,144,.30); padding:28px 30px 24px; min-width:300px; max-width:360px; width:90vw; text-align:center; }\n.pin-card h3 { margin:0 0 6px; font-size:1.1em; color:#0E7490; }\n.pin-card p { margin:0 0 18px; font-size:0.85em; color:#555; }\n.pin-inputs { display:flex; gap:10px; justify-content:center; margin-bottom:10px; }\n.pin-inputs input { width:48px; height:54px; text-align:center; font-size:1.6em; font-weight:700; border:2px solid #c0d9e4; border-radius:8px; outline:none; transition:border-color .2s; color:#0E7490; }\n.pin-inputs input:focus { border-color:#0E7490; }\n.pin-error { color:#dc2626; font-size:0.82em; min-height:18px; margin-bottom:8px; }\n.pin-actions { display:flex; gap:10px; justify-content:center; margin-top:14px; }\n.pin-btn { border:none; border-radius:6px; padding:8px 20px; font-size:0.9em; font-weight:700; cursor:pointer; }\n.pin-btn-cancel { background:#e0e7ef; color:#0E7490; }\n.pin-btn-primary { background:#0E7490; color:#fff; }\n.pin-btn-danger { background:#fee2e2; color:#dc2626; }\n@keyframes pinShake { 0%,100%{transform:translateX(0)} 20%,60%{transform:translateX(-8px)} 40%,80%{transform:translateX(8px)} }\n.pin-shake { animation:pinShake .4s ease; }\n.modal-lock-btn { background:none; border:none; font-size:1.2em; cursor:pointer; padding:0 6px; line-height:1; opacity:.7; }\n.modal-lock-btn:hover { opacity:1; }\n.modal-info-btn { background:#0E7490; border:none; font-size:0.72em; cursor:pointer; padding:0 8px 0 6px; line-height:1; color:#fff; font-weight:700; height:22px; border-radius:20px; display:inline-flex; align-items:center; gap:4px; opacity:.85; }\n.modal-info-btn:hover { opacity:1; background:#0891b2; }\n.help-overlay { display:none; position:fixed; top:0; left:0; width:100vw; height:100vh; background:rgba(0,0,0,.55); align-items:center; justify-content:center; z-index:1200; }\n.help-overlay.open { display:flex; }\n.help-box { background:#fff; border-radius:14px; box-shadow:0 8px 40px rgba(14,116,144,.3); width:90vw; max-width:620px; max-height:85vh; display:flex; flex-direction:column; overflow:hidden; }\n.help-box-hdr { background:#0E7490; padding:13px 18px; display:flex; align-items:center; justify-content:space-between; flex-shrink:0; }\n.help-box-hdr h3 { color:#fff; font-size:0.98em; font-weight:700; }\n.help-box-close { background:none; border:none; color:#fff; font-size:1.3em; cursor:pointer; line-height:1; }\n.help-box-body { padding:18px 20px; overflow-y:auto; flex:1; }\n.help-sec-title { font-size:0.97em; font-weight:800; color:#0E7490; margin:0 0 10px; display:flex; align-items:center; gap:7px; }\n.help-sep { height:1px; background:#e0eaf0; margin:18px 0; }\n.help-what-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:10px; }\n.help-wc { background:#f0f9fb; border:1px solid #b8dde8; border-radius:8px; padding:10px 12px; }\n.help-wc-num { font-size:1em; font-weight:800; color:#0E7490; }\n.help-wc-title { font-weight:700; font-size:0.85em; color:#1e3a4a; }\n.help-wc-desc { font-size:0.76em; color:#6b8a99; margin-top:2px; }\n.help-card { background:#f8fafc; border:1.5px solid #d4eaf0; border-radius:9px; padding:11px 14px; margin-bottom:8px; }\n.help-card-title { font-weight:700; color:#0E7490; font-size:0.88em; margin-bottom:4px; }\n.help-card-desc { font-size:0.82em; color:#5a7a8a; line-height:1.65; }\n.help-card-link { font-size:0.8em; color:#0891b2; margin-top:4px; }\n.help-note { background:#fff7ed; border:1px solid #fed7aa; border-radius:8px; padding:9px 13px; font-size:0.81em; color:#92400e; line-height:1.5; margin-top:8px; }\n.help-divider { text-align:center; font-size:0.75em; color:#aaa; font-weight:700; margin:8px 0; }\n.help-badge-free { display:inline-block; font-size:0.68em; font-weight:700; border-radius:4px; padding:1px 6px; margin-left:5px; background:#dcfce7; color:#166534; vertical-align:middle; }\n.help-badge-opt { display:inline-block; font-size:0.68em; font-weight:700; border-radius:4px; padding:1px 6px; margin-left:5px; background:#fef9c3; color:#92400e; vertical-align:middle; }\n.help-ready-row { display:flex; align-items:flex-start; gap:10px; background:#f8fafc; border:1px solid #d4eaf0; border-radius:8px; padding:10px 13px; margin-bottom:6px; }\n.help-ready-row .hri { font-size:1.1em; flex-shrink:0; margin-top:1px; }\n.help-ready-row .hrt { font-size:0.83em; color:#2a4a5a; line-height:1.4; }\n.pin-confirm-label { font-size:0.82em; color:#555; margin:12px 0 6px; }\n.db-banner-btn { background:rgba(255,255,255,.15); border:1px solid rgba(255,255,255,.3); color:#fff; border-radius:7px; padding:0 clamp(8px,1.5vw,16px); height:36px; font-size:0.85em; font-weight:700; cursor:pointer; transition:background .2s; text-decoration:none; display:inline-flex; align-items:center; gap:5px; box-sizing:border-box; white-space:nowrap; }\n.db-banner-btn:hover { background:rgba(255,255,255,.25); }\n.kill-banner-btn { background:#dc2626; border:1px solid #dc2626; color:#fff; border-radius:7px; padding:0 clamp(8px,1.5vw,16px); height:36px; font-size:0.85em; font-weight:700; cursor:pointer; transition:background .2s; display:inline-flex; align-items:center; gap:5px; box-sizing:border-box; white-space:nowrap; }\n.kill-banner-btn:hover { background:#b91c1c; }\n/* ── Responsive breakpoints ── */\n@media (max-width:1100px) {\n .body { flex-direction:column; align-items:center; gap:14px; padding:14px 10px; }\n .left, .right { width:100%; max-width:720px; }\n .right { width:100%; max-width:720px; }\n .drop-zone { min-height:220px; flex:none; }\n}\n@media (max-width:768px) {\n .hdr { padding:10px 14px; }\n .hdr-sub { display:none; }\n .settings-btn, .db-banner-btn, .kill-banner-btn, .report-btn { height:32px; font-size:0.78em; }\n .body { padding:8px; gap:10px; }\n .drop-zone { min-height:180px; padding:12px 10px; }\n .dz-icon-row { gap:10px; }\n .right { border-radius:10px; }\n .modal-card { min-width:0; width:95vw; }\n}\n@media (max-width:520px) {\n .hdr-logo { font-size:0.95em; }\n .db-banner-btn .db-label { display:none; }\n .kill-banner-btn .kill-label { display:none; }\n .report-btn .report-label { display:none; }\n .settings-btn .settings-label { display:none; }\n .body { padding:6px; gap:8px; }\n .drop-zone { min-height:150px; padding:10px 8px; }\n .dz-icon-row { margin-bottom:6px; }\n .dz-badge { font-size:0.65em; padding:3px 8px; }\n .right { padding:8px; }\n .process-btn { font-size:0.85em; padding:10px; }\n}\n@media print { .modal-overlay, .hdr-actions { display:none !important; } }\n</style>\n</head>\n<body>\n\n<div class=\"hdr\">\n <div>\n <div class=\"hdr-logo\">CV-<span>Toposheet</span></div>\n <div class=\"hdr-sub\">Extracted Map Features</div>\n </div>\n <div class=\"hdr-actions\">\n <a href=\"/results\" class=\"db-banner-btn\" title=\"View all processed maps in the database\">&#128202;&nbsp;<span class=\"db-label\">Map Database</span></a>\n <button class=\"kill-banner-btn\" id=\"killBannerBtn\" onclick=\"killRunningJob()\" title=\"Kill any running job and restart the server\">&#9632;<span class=\"kill-label\"> Restart Server</span></button>\n <button type=\"button\" class=\"report-btn\" id=\"reportBtn\" title=\"Report inappropriate or unsafe AI-generated content\">&#9888;&#xFE0E;<span class=\"report-label\"> Report AI Content</span></button>\n <button class=\"settings-btn\" onclick=\"openSettings()\">&#9881;&#xFE0E;<span class=\"settings-label\"> Settings</span></button>\n </div>\n</div>\n\n<form id=\"uploadForm\" action=\"/upload\" method=\"post\" enctype=\"multipart/form-data\">\n<div class=\"body\">\n <div class=\"left\">\n <div class=\"drop-zone\" id=\"dropZone\">\n <input type=\"file\" name=\"map_file\" id=\"fileInput\" accept=\".jpg,.jpeg,.png,.tif,.tiff\" multiple>\n <div class=\"dz-icon-row\">\n <!-- Icon 1: Colourful topographic mountain map -->\n <span class=\"dz-icon-side\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 72 72\" style=\"display:inline-block;vertical-align:middle\">\n <rect x=\"4\" y=\"4\" width=\"64\" height=\"64\" rx=\"7\" fill=\"#fffde7\" stroke=\"#f59e0b\" stroke-width=\"2.5\"/>\n <!-- sky gradient band -->\n <rect x=\"5\" y=\"5\" width=\"62\" height=\"28\" rx=\"6\" fill=\"#bfecfd\"/>\n <!-- green ground -->\n <rect x=\"5\" y=\"45\" width=\"62\" height=\"22\" rx=\"0\" fill=\"#a7d994\"/>\n <rect x=\"5\" y=\"55\" width=\"62\" height=\"12\" rx=\"0\" fill=\"#86c96e\"/>\n <!-- main mountain -->\n <polygon points=\"36,12 58,48 14,48\" fill=\"#8fae6b\" stroke=\"#4a7c30\" stroke-width=\"1.8\" stroke-linejoin=\"round\"/>\n <!-- mountain shadow side -->\n <polygon points=\"36,12 58,48 36,48\" fill=\"#7a9c58\" stroke=\"none\"/>\n <!-- snow cap -->\n <polygon points=\"36,12 42,26 30,26\" fill=\"#fff\" stroke=\"#cbd5e1\" stroke-width=\"1\"/>\n <!-- contour lines (warm orange) -->\n <path d=\"M18,52 Q36,46 54,52\" fill=\"none\" stroke=\"#f59e0b\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n <path d=\"M10,58 Q36,51 62,58\" fill=\"none\" stroke=\"#f59e0b\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n <path d=\"M5,64 Q36,57 67,64\" fill=\"none\" stroke=\"#d97706\" stroke-width=\"1.4\" stroke-linecap=\"round\"/>\n <!-- border -->\n <rect x=\"4\" y=\"4\" width=\"64\" height=\"64\" rx=\"7\" fill=\"none\" stroke=\"#f59e0b\" stroke-width=\"2.5\"/>\n </svg></span>\n <!-- Icon 2: Medium grey world map -->\n <span class=\"dz-icon-side\" style=\"filter:grayscale(1) brightness(1.1) contrast(0.5);-webkit-filter:grayscale(1) brightness(1.1) contrast(0.5);opacity:0.55;\">&#128506;</span>\n <!-- Icon 3: Colourful AI laptop -->\n <span class=\"dz-icon-side\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"0.9em\" viewBox=\"0 0 80 70\" style=\"display:inline-block;vertical-align:middle\">\n <!-- screen frame -->\n <rect x=\"8\" y=\"5\" width=\"64\" height=\"42\" rx=\"5\" fill=\"#1e3a5f\" stroke=\"#3b82f6\" stroke-width=\"2.2\"/>\n <!-- screen glow -->\n <rect x=\"13\" y=\"10\" width=\"54\" height=\"32\" rx=\"2\" fill=\"#0f172a\"/>\n <!-- subtle screen gradient overlay -->\n <rect x=\"13\" y=\"10\" width=\"54\" height=\"16\" rx=\"2\" fill=\"url(#sg)\" opacity=\"0.3\"/>\n <defs><linearGradient id=\"sg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"#60a5fa\" stop-opacity=\"0.5\"/><stop offset=\"1\" stop-color=\"#0f172a\" stop-opacity=\"0\"/></linearGradient></defs>\n <!-- neural net connections -->\n <line x1=\"40\" y1=\"26\" x2=\"24\" y2=\"17\" stroke=\"#60a5fa\" stroke-width=\"1.2\" opacity=\"0.9\"/>\n <line x1=\"40\" y1=\"26\" x2=\"56\" y2=\"17\" stroke=\"#60a5fa\" stroke-width=\"1.2\" opacity=\"0.9\"/>\n <line x1=\"40\" y1=\"26\" x2=\"24\" y2=\"35\" stroke=\"#60a5fa\" stroke-width=\"1.2\" opacity=\"0.9\"/>\n <line x1=\"40\" y1=\"26\" x2=\"56\" y2=\"35\" stroke=\"#60a5fa\" stroke-width=\"1.2\" opacity=\"0.9\"/>\n <line x1=\"40\" y1=\"26\" x2=\"40\" y2=\"14\" stroke=\"#a78bfa\" stroke-width=\"1.2\" opacity=\"0.9\"/>\n <line x1=\"40\" y1=\"26\" x2=\"40\" y2=\"38\" stroke=\"#a78bfa\" stroke-width=\"1.2\" opacity=\"0.9\"/>\n <line x1=\"40\" y1=\"26\" x2=\"19\" y2=\"26\" stroke=\"#34d399\" stroke-width=\"1.2\" opacity=\"0.9\"/>\n <line x1=\"40\" y1=\"26\" x2=\"61\" y2=\"26\" stroke=\"#34d399\" stroke-width=\"1.2\" opacity=\"0.9\"/>\n <!-- outer nodes -->\n <circle cx=\"40\" cy=\"14\" r=\"3.2\" fill=\"#a78bfa\" stroke=\"#fff\" stroke-width=\"1\"/>\n <circle cx=\"40\" cy=\"38\" r=\"3.2\" fill=\"#a78bfa\" stroke=\"#fff\" stroke-width=\"1\"/>\n <circle cx=\"19\" cy=\"26\" r=\"3.2\" fill=\"#34d399\" stroke=\"#fff\" stroke-width=\"1\"/>\n <circle cx=\"61\" cy=\"26\" r=\"3.2\" fill=\"#34d399\" stroke=\"#fff\" stroke-width=\"1\"/>\n <circle cx=\"24\" cy=\"17\" r=\"2.6\" fill=\"#60a5fa\" stroke=\"#fff\" stroke-width=\"0.8\"/>\n <circle cx=\"56\" cy=\"17\" r=\"2.6\" fill=\"#60a5fa\" stroke=\"#fff\" stroke-width=\"0.8\"/>\n <circle cx=\"24\" cy=\"35\" r=\"2.6\" fill=\"#60a5fa\" stroke=\"#fff\" stroke-width=\"0.8\"/>\n <circle cx=\"56\" cy=\"35\" r=\"2.6\" fill=\"#60a5fa\" stroke=\"#fff\" stroke-width=\"0.8\"/>\n <!-- centre node glowing -->\n <circle cx=\"40\" cy=\"26\" r=\"6\" fill=\"#f59e0b\" stroke=\"#fff\" stroke-width=\"1.8\"/>\n <circle cx=\"40\" cy=\"26\" r=\"2.8\" fill=\"#fff\"/>\n <!-- laptop base -->\n <path d=\"M4,47 Q40,51 76,47 L72,61 Q40,64 8,61 Z\" fill=\"#3b82f6\" stroke=\"#1e3a5f\" stroke-width=\"1.8\"/>\n <!-- keyboard strip -->\n <rect x=\"28\" y=\"53\" width=\"24\" height=\"3.5\" rx=\"1.8\" fill=\"#93c5fd\"/>\n <!-- hinge -->\n <rect x=\"34\" y=\"46\" width=\"12\" height=\"3\" rx=\"1.5\" fill=\"#1e3a5f\"/>\n </svg></span>\n </div>\n <div class=\"dz-title\">Upload Toposheet</div>\n <div class=\"dz-sub\">Drag &amp; drop map images here, or click to browse</div>\n <div class=\"dz-badge\">JPG &middot; PNG &middot; TIF &middot; Up to 500 MB each</div>\n <div id=\"preview\"></div>\n </div>\n </div>\n\n <div class=\"right\">\n <div class=\"right-title\">Choose Model</div>\n <div class=\"model-cards\" id=\"modelCards\">\n <label class=\"model-card selected\" onclick=\"selectCard(this)\" data-model=\"best\">\n <input type=\"radio\" name=\"model_sel\" value=\"best\" checked>\n <div class=\"mc-body\">\n <div class=\"mc-name\">1. Best Quality <span class=\"badge badge-best\">RECOMMENDED</span></div>\n <div class=\"mc-desc\">GCV OCR (parallel) + Vertex AI Gemini 2.5 Flash</div>\n </div>\n </label>\n <label class=\"model-card\" onclick=\"selectCard(this)\" data-model=\"fast\">\n <input type=\"radio\" name=\"model_sel\" value=\"fast\">\n <div class=\"mc-body\">\n <div class=\"mc-name\">2. Fast <span class=\"badge badge-fast\">QUICK</span></div>\n <div class=\"mc-desc\">GCV OCR (parallel) + Groq LLaMA 3.3 70B</div>\n </div>\n </label>\n <label class=\"model-card\" onclick=\"selectCard(this)\" data-model=\"ensemble\">\n <input type=\"radio\" name=\"model_sel\" value=\"ensemble\">\n <div class=\"mc-body\">\n <div class=\"mc-name\">3. All LLMs Together <span class=\"badge badge-premium\">PREMIUM</span></div>\n <div class=\"mc-desc\">GCV OCR + all configured LLMs run in parallel</div>\n </div>\n </label>\n <label class=\"model-card\" onclick=\"selectCard(this)\" data-model=\"openai\">\n <input type=\"radio\" name=\"model_sel\" value=\"openai\">\n <div class=\"mc-body\">\n <div class=\"mc-name\">4. OpenAI GPT-4o <span class=\"badge badge-optional\">OPTIONAL</span></div>\n <div class=\"mc-desc\">GCV OCR + GPT-4o-mini</div>\n </div>\n </label>\n <label class=\"model-card\" onclick=\"selectCard(this)\" data-model=\"claude\">\n <input type=\"radio\" name=\"model_sel\" value=\"claude\">\n <div class=\"mc-body\">\n <div class=\"mc-name\">5. Claude Haiku <span class=\"badge badge-claude\">CLAUDE</span></div>\n <div class=\"mc-desc\">GCV OCR + Claude Haiku</div>\n </div>\n </label>\n <label class=\"model-card\" onclick=\"selectCard(this)\" data-model=\"grok\">\n <input type=\"radio\" name=\"model_sel\" value=\"grok\">\n <div class=\"mc-body\">\n <div class=\"mc-name\">6. Grok (xAI) <span class=\"badge badge-grok\">GROK</span></div>\n <div class=\"mc-desc\">GCV OCR + Grok-3-mini</div>\n </div>\n </label>\n <label class=\"model-card\" onclick=\"selectCard(this)\" data-model=\"gemini\">\n <input type=\"radio\" name=\"model_sel\" value=\"gemini\">\n <div class=\"mc-body\">\n <div class=\"mc-name\">7. Gemini API <span class=\"badge badge-gemini\">GEMINI KEY</span></div>\n <div class=\"mc-desc\">GCV OCR + Gemini 1.5 Flash</div>\n </div>\n </label>\n <label class=\"model-card\" onclick=\"selectCard(this)\" data-model=\"openrouter\">\n <input type=\"radio\" name=\"model_sel\" value=\"openrouter\">\n <div class=\"mc-body\">\n <div class=\"mc-name\">8. OpenRouter <span class=\"badge badge-optional\">OPTIONAL</span></div>\n <div class=\"mc-desc\">GCV OCR + OpenRouter (any model)</div>\n </div>\n </label>\n <label class=\"model-card\" onclick=\"selectCard(this)\" data-model=\"offline\">\n <input type=\"radio\" name=\"model_sel\" value=\"offline\">\n <div class=\"mc-body\">\n <div class=\"mc-name\">9. Offline <span class=\"badge badge-offline\">NO CLOUD</span></div>\n <div class=\"mc-desc\">EasyOCR (local) + Groq LLaMA 3.3 70B</div>\n </div>\n </label>\n <div class=\"info-strip\" id=\"modelInfo\">\n <b>Best Quality:</b> Uses Google Cloud Vision for OCR and Vertex AI Gemini for feature extraction. Requires internet &amp; GCP credentials.\n </div>\n </div>\n <input type=\"hidden\" name=\"model\" id=\"modelInput\" value=\"best\">\n <button type=\"submit\" class=\"process-btn\" id=\"processBtn\" disabled>\n &#9654; Process Map\n <span class=\"btn-sub\">Select a map file to continue</span>\n </button>\n </div>\n</div>\n</form>\n\n<script>\nconst MODEL_INFO = {\n best: '<b>Best Quality:</b> Parallel GCV OCR tiles + Vertex AI Gemini 2.5 Flash. Largest batches (150 items), fastest Gemini model. Requires GCP service account.',\n fast: '<b>Groq LLaMA (Fast):</b> Parallel GCV OCR + Groq LLaMA 3.1 8B. No inter-batch delays. Fastest total processing time. Free Groq API key needed.',\n ensemble: '<b>Ensemble (All LLMs):</b> Parallel GCV OCR + ALL your configured LLMs run simultaneously. Results merged by majority vote for maximum confidence. Slowest but most accurate.',\n openai: '<b>OpenAI GPT-4o:</b> Parallel GCV OCR + GPT-4o-mini. 80 items/batch. Reliable paid OpenAI API.',\n claude: '<b>Claude Haiku:</b> Parallel GCV OCR + Claude 3.5 Haiku. Strong reasoning. Anthropic API key required.',\n grok: '<b>Grok (xAI):</b> Parallel GCV OCR + Grok-3-mini. Fast xAI inference. xAI API key required.',\n gemini: '<b>Gemini API:</b> Google Cloud Vision OCR + Gemini (API key). Uses GEMINI_API_KEY from .env no Vertex AI or GCP billing needed.',\n openrouter: '<b>OpenRouter:</b> Parallel GCV OCR + any model via OpenRouter. Access 100s of models (free and paid) with one API key. OpenRouter API key required.',\n offline: '<b>Offline OCR:</b> EasyOCR runs locally on your machine. No Google Cloud Vision or GCP service account needed. Internet required for LLM cleaning. Use this mode when you have no GCP credentials.',\n};\n\nfunction killRunningJob() {\n const btn = document.getElementById('killBannerBtn');\n btn.disabled = true;\n btn.style.background = '#b91c1c';\n btn.textContent = '⏳ Stopping…';\n fetch('/restart', {method:'POST'})\n .finally(() => {\n btn.textContent = '✔ Done — going home…';\n setTimeout(() => { window.location.href = '/'; }, 800);\n });\n}\nfunction selectCard(el) {\n document.querySelectorAll('.model-card').forEach(c => c.classList.remove('selected'));\n el.classList.add('selected');\n const val = el.querySelector('input[type=radio]').value;\n document.getElementById('modelInput').value = val;\n const infoEl = document.getElementById('modelInfo');\n if (infoEl && MODEL_INFO[val]) {\n const counts = infoEl.querySelector('span');\n infoEl.innerHTML = (MODEL_INFO[val] || '') + (counts ? '<br>' + counts.outerHTML : '');\n }\n}\n\nconst fileInput = document.getElementById('fileInput');\nconst dropZone = document.getElementById('dropZone');\nconst preview = document.getElementById('preview');\nconst processBtn = document.getElementById('processBtn');\n\nfileInput.addEventListener('change', showPreviews);\ndropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });\ndropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));\ndropZone.addEventListener('drop', e => {\n e.preventDefault();\n dropZone.classList.remove('drag-over');\n // Merge dropped files into _fileList (don't replace — accumulate)\n Array.from(e.dataTransfer.files).forEach(f => {\n const already = _fileList.some(x => x.name === f.name && x.size === f.size);\n if (!already) _fileList.push(f);\n });\n _syncInputFiles();\n _renderPreviews();\n});\n\n// DataTransfer-based file list so we can remove individual files\nlet _fileList = []; // array of File objects\n\nfunction _syncInputFiles() {\n const dt = new DataTransfer();\n _fileList.forEach(f => dt.items.add(f));\n fileInput.files = dt.files;\n}\n\nfunction _renderPreviews() {\n preview.innerHTML = '';\n if (!_fileList.length) {\n processBtn.disabled = true;\n processBtn.querySelector('.btn-sub').textContent = 'Select a map file to continue';\n return;\n }\n processBtn.disabled = false;\n processBtn.querySelector('.btn-sub').textContent = _fileList.length + ' file(s) ready';\n _fileList.forEach((f, idx) => {\n const item = document.createElement('div');\n item.className = 'prev-item';\n\n // Remove (×) button\n const rm = document.createElement('button');\n rm.type = 'button';\n rm.className = 'prev-remove';\n rm.title = 'Remove ' + f.name;\n rm.textContent = '\\u00D7';\n rm.addEventListener('click', e => {\n e.preventDefault();\n e.stopPropagation();\n e.stopImmediatePropagation();\n _fileList.splice(idx, 1);\n _syncInputFiles();\n _renderPreviews();\n });\n rm.addEventListener('mouseenter', e => e.stopPropagation());\n rm.addEventListener('mouseover', e => e.stopPropagation());\n\n const img = document.createElement('img');\n img.className = 'prev-thumb';\n img.src = URL.createObjectURL(f);\n\n const name = document.createElement('div');\n name.className = 'prev-name';\n name.textContent = f.name;\n\n item.appendChild(rm);\n item.appendChild(img);\n item.appendChild(name);\n preview.appendChild(item);\n });\n}\n\nfunction showPreviews() {\n // Merge newly picked files into _fileList (don't replace — accumulate)\n const incoming = Array.from(fileInput.files);\n incoming.forEach(f => {\n const already = _fileList.some(x => x.name === f.name && x.size === f.size);\n if (!already) _fileList.push(f);\n });\n _syncInputFiles();\n _renderPreviews();\n}\n\n// Settings modal\nconst SECTION_IDS = ['llm','google','vertex','models','custom'];\n\n// ── PIN Lock ──────────────────────────────────────────────────────────────\nconst PIN_KEY = 'cvt_settings_pin';\nfunction _getPin() { return localStorage.getItem(PIN_KEY) || ''; }\nfunction _savePin(p) { localStorage.setItem(PIN_KEY, p); }\nfunction _clearPin() { localStorage.removeItem(PIN_KEY); }\n\nfunction _loadSettingsData() {\n const orInput = document.getElementById('inp_OPENROUTER_MODEL');\n const legacyOpenRouterModel = 'meta-llama/llama-3.3-70b-instruct:free';\n if (orInput) {\n const current = (orInput.value || '').trim();\n if (!current || current === legacyOpenRouterModel) orInput.value = 'openrouter/auto';\n }\n\n fetch('/get_env').then(r => {\n if (!r.ok) throw new Error('get_env failed');\n return r.json();\n }).then(data => {\n const legacyOpenRouterModel = 'meta-llama/llama-3.3-70b-instruct:free';\n const currentOpenRouterModel = (data.OPENROUTER_MODEL || '').trim();\n if (!currentOpenRouterModel || currentOpenRouterModel === legacyOpenRouterModel) {\n data.OPENROUTER_MODEL = 'openrouter/auto';\n // Persist migration so next app start stays on the new default.\n fetch('/save_env', {\n method: 'POST',\n headers: {'Content-Type':'application/json'},\n body: JSON.stringify({ OPENROUTER_MODEL: 'openrouter/auto' })\n }).catch(() => {});\n }\n for (const [k, v] of Object.entries(data)) {\n const inp = document.getElementById('inp_' + k);\n if (inp) inp.value = v || '';\n }\n }).catch(() => {});\n document.getElementById('settingsModal').classList.add('open');\n _updateLockBtn();\n}\n\nfunction openSettings() {\n if (_getPin()) { _showPinEntry(); }\n else { _loadSettingsData(); }\n}\n\nfunction closeSettings() {\n document.getElementById('settingsModal').classList.remove('open');\n}\nfunction openHelp() {\n document.getElementById('helpOverlay').classList.add('open');\n}\nfunction closeHelp() {\n document.getElementById('helpOverlay').classList.remove('open');\n}\n\nconst REPORT_GITHUB_NEW_ISSUE_URL = 'https://github.com/adityaakashbhu23-svg/CV-Toposheet-Maps/issues/new';\n\nfunction buildQuickGithubIssueUrl() {\n const model = getReportModelValue() || 'not provided';\n const title = encodeURIComponent('AI content report (quick)');\n const body = encodeURIComponent(\n 'Category: AI content concern\\\\n' +\n 'Model: ' + model + '\\\\n\\\\n' +\n 'Please describe the issue here.\\\\n'\n );\n return REPORT_GITHUB_NEW_ISSUE_URL + '?title=' + title + '&body=' + body;\n}\n\nfunction updateQuickGithubReportLink() {\n const issueLink = document.getElementById('reportIssueLink');\n if (!issueLink) return;\n issueLink.href = buildQuickGithubIssueUrl();\n issueLink.textContent = 'Report in GitHub';\n}\n\nfunction openReportModal(evt) {\n try {\n if (evt && typeof evt.preventDefault === 'function') evt.preventDefault();\n const model = document.getElementById('modelInput');\n const selectedNameEl = document.querySelector('.model-card.selected .mc-name');\n const selectedName = selectedNameEl ? selectedNameEl.textContent.replace(/\\\\s+/g, ' ').trim() : '';\n const selectedCode = model ? model.value : '';\n const modelSelect = document.getElementById('report_model');\n const modelOther = document.getElementById('report_model_other');\n const known = ['best','fast','ensemble','openai','claude','grok','gemini','openrouter','offline'];\n window._lastReportPayload = null;\n if (modelSelect) {\n if (known.includes(selectedCode)) {\n modelSelect.value = selectedCode;\n if (modelOther) modelOther.value = '';\n } else {\n modelSelect.value = 'other';\n if (modelOther) modelOther.value = selectedName || selectedCode;\n }\n }\n toggleReportModelOther();\n const catEl = document.getElementById('report_category');\n if (catEl) catEl.value = 'inappropriate-output';\n const snipEl = document.getElementById('report_snippet');\n if (snipEl) snipEl.value = '';\n const detEl = document.getElementById('report_details');\n if (detEl) detEl.value = '';\n const conEl = document.getElementById('report_contact');\n if (conEl) conEl.value = '';\n const statusEl = document.getElementById('reportStatus');\n if (statusEl) { statusEl.className = 'report-status'; statusEl.style.display = 'none'; statusEl.innerHTML = ''; }\n updateQuickGithubReportLink();\n const modal = document.getElementById('reportModal');\n if (modal) {\n modal.classList.add('open');\n } else {\n alert('Report modal not found on this page. Please go to the main page to report AI content.');\n }\n } catch (err) {\n var errBanner = document.getElementById('_reportErrBanner');\n if (!errBanner) {\n errBanner = document.createElement('div');\n errBanner.id = '_reportErrBanner';\n errBanner.style.cssText = 'position:fixed;top:10px;left:50%;transform:translateX(-50%);background:#dc2626;color:#fff;padding:12px 24px;border-radius:8px;z-index:9999;font-weight:bold;box-shadow:0 4px 12px rgba(0,0,0,.4);';\n document.body.appendChild(errBanner);\n }\n errBanner.textContent = 'Report button error: ' + err.message;\n errBanner.style.display = 'block';\n setTimeout(function(){ errBanner.style.display = 'none'; }, 8000);\n }\n}\n\nfunction closeReportModal() {\n document.getElementById('reportModal').classList.remove('open');\n}\n\nfunction toggleReportModelOther() {\n const select = document.getElementById('report_model');\n const row = document.getElementById('report_model_other_row');\n if (!select || !row) return;\n row.style.display = select.value === 'other' ? 'flex' : 'none';\n updateQuickGithubReportLink();\n}\n\nfunction getReportModelValue() {\n const select = document.getElementById('report_model');\n const other = document.getElementById('report_model_other');\n if (!select) return '';\n if (select.value === 'other') {\n return (other && other.value ? other.value.trim() : 'Other');\n }\n const opt = select.options[select.selectedIndex];\n return opt ? opt.text : select.value;\n}\n\nfunction openEmailFallback(emailUrl) {\n if (!emailUrl) return;\n try {\n // Primary path for desktop browser/webview mailto handling.\n window.location.href = emailUrl;\n // Secondary path for environments that ignore location mailto navigation.\n setTimeout(() => {\n try {\n const a = document.createElement('a');\n a.href = emailUrl;\n a.style.display = 'none';\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n } catch (_) {}\n }, 50);\n } catch (_) {}\n}\n\nfunction submitAIReport() {\n const status = document.getElementById('reportStatus');\n const submitBtn = document.getElementById('reportSubmitBtn');\n const payload = {\n category: document.getElementById('report_category').value,\n model: getReportModelValue(),\n snippet: document.getElementById('report_snippet').value.trim(),\n details: document.getElementById('report_details').value.trim(),\n contact: document.getElementById('report_contact').value.trim(),\n };\n if (document.getElementById('report_model').value === 'other' && !payload.model) {\n status.className = 'report-status err';\n status.style.display = 'block';\n status.textContent = 'Please type the model name for Other.';\n return;\n }\n if (!payload.details) {\n status.className = 'report-status err';\n status.style.display = 'block';\n status.textContent = 'Please describe the issue before submitting.';\n return;\n }\n submitBtn.disabled = true;\n submitBtn.textContent = 'Submitting...';\n fetch('/report_ai_content', {\n method: 'POST',\n headers: {'Content-Type':'application/json'},\n body: JSON.stringify(payload)\n })\n .then(async r => {\n const raw = await r.text();\n let data = null;\n if (raw) {\n try {\n data = JSON.parse(raw);\n } catch (_err) {\n data = null;\n }\n }\n return { ok: r.ok, status: r.status, data, raw };\n })\n .then(({ ok, status: httpStatus, data, raw }) => {\n if (!ok || !data || !data.ok) {\n const msg = (data && data.error)\n || (raw && raw.trim())\n || ('Submission failed (HTTP ' + httpStatus + ').');\n throw new Error(msg);\n }\n window._lastReportPayload = data;\n status.className = 'report-status ok';\n status.style.display = 'block';\n status.innerHTML = 'Saved with reference <b>' + data.report_id + '</b>.';\n const actions = document.getElementById('reportActions');\n actions.classList.add('open');\n document.getElementById('reportIssueLink').href = data.issue_url;\n document.getElementById('reportIssueLink').textContent = 'Report in GitHub';\n openEmailFallback(data.email_url);\n })\n .catch(err => {\n status.className = 'report-status err';\n status.style.display = 'block';\n status.textContent = err.message || 'Submission failed.';\n })\n .finally(() => {\n submitBtn.disabled = false;\n submitBtn.textContent = 'Submit';\n });\n}\n\nfunction copyAIReport() {\n const status = document.getElementById('reportStatus');\n const data = window._lastReportPayload;\n if (!data || !data.report_text) {\n status.className = 'report-status err';\n status.style.display = 'block';\n status.textContent = 'Submit a report first, then copy.';\n return;\n }\n navigator.clipboard.writeText(data.report_text)\n .then(() => {\n status.className = 'report-status ok';\n status.style.display = 'block';\n status.textContent = 'Report copied. You can paste it into email or support chat.';\n })\n .catch(() => {\n status.className = 'report-status err';\n status.style.display = 'block';\n status.textContent = 'Copy failed. Please use the email or GitHub link.';\n });\n}\n\nfunction saveSettings() {\n const payload = {};\n document.querySelectorAll('.key-row-input input').forEach(inp => {\n const key = inp.id.replace('inp_', '');\n payload[key] = inp.value.trim();\n });\n if (!payload.OPENROUTER_MODEL) payload.OPENROUTER_MODEL = 'openrouter/auto';\n fetch('/save_env', {\n method: 'POST',\n headers: {'Content-Type':'application/json'},\n body: JSON.stringify(payload)\n }).then(r => r.json()).then(() => {\n const notice = document.getElementById('saveNotice');\n notice.style.display = 'flex';\n setTimeout(() => { notice.style.display = 'none'; closeSettings(); }, 1200);\n });\n}\n\nfunction toggleEye(key) {\n const inp = document.getElementById('inp_' + key);\n inp.type = inp.type === 'password' ? 'text' : 'password';\n}\n\nfunction uploadJsonFile(inputId, targetName, statusId) {\n const fileInput = document.getElementById(inputId);\n const statusEl = document.getElementById(statusId);\n if (!fileInput.files.length) { statusEl.style.color='#c00'; statusEl.textContent='No file selected.'; return; }\n const fd = new FormData();\n fd.append('json_file', fileInput.files[0]);\n fd.append('target', targetName);\n statusEl.style.color='#888'; statusEl.textContent='Uploading...';\n fetch('/upload_service_account', { method:'POST', body:fd })\n .then(r => r.json())\n .then(d => {\n if (d.ok) { statusEl.style.color='#16a34a'; statusEl.textContent='\\u2713 ' + fileInput.files[0].name + ' uploaded successfully'; }\n else { statusEl.style.color='#c00'; statusEl.textContent='Error: ' + (d.error||'failed'); }\n }).catch(() => { statusEl.style.color='#c00'; statusEl.textContent='Upload failed.'; });\n}\n\nfunction addCustomKeyRow() {\n const name = document.getElementById('custom_key_name').value.trim().toUpperCase().replace(/[^A-Z0-9_]/g,'');\n const val = document.getElementById('custom_key_value').value.trim();\n if (!name) { document.getElementById('custom_key_name').focus(); return; }\n // Avoid duplicates\n if (document.getElementById('inp_' + name)) { alert('Key \"' + name + '\" already exists above.'); return; }\n const container = document.getElementById('custom_key_rows');\n const row = document.createElement('div'); row.className = 'key-row'; row.id = 'custom_row_' + name;\n row.innerHTML = `<label style=\"font-size:0.82em;min-width:160px;\">${name}</label>\n <div class=\"key-row-input\">\n <input type=\"password\" id=\"inp_${name}\" class=\"key-row-input\" value=\"${val.replace(/\"/g,'&quot;')}\" style=\"flex:1;border:1px solid #cdd;border-radius:5px;padding:5px 8px;font-size:0.82em;\">\n <button class=\"eye-btn\" onclick=\"toggleEye('${name}')\">&#128065;</button>\n <button class=\"eye-btn\" style=\"color:#c00;\" onclick=\"document.getElementById('custom_row_${name}').remove()\" title=\"Remove\">&#10005;</button>\n </div>`;\n container.appendChild(row);\n document.getElementById('custom_key_name').value = '';\n document.getElementById('custom_key_value').value = '';\n document.getElementById('custom_key_name').focus();\n}\n\nfunction toggleSection(id) {\n document.getElementById('key_section_' + id).classList.toggle('collapsed');\n}\n\nfunction showAllSections() {\n SECTION_IDS.forEach(id => {\n document.getElementById('key_section_' + id).classList.remove('collapsed');\n });\n}\n\ndocument.addEventListener('DOMContentLoaded', function() {\n var reportBtn = document.getElementById('reportBtn');\n if (reportBtn) {\n reportBtn.addEventListener('click', function(e) { openReportModal(e); });\n }\n document.getElementById('settingsModal').addEventListener('click', function(e) {\n if (e.target === this) closeSettings();\n });\n});\n\n// ── PIN helpers ──────────────────────────────────────────────────────────\nfunction _clearBoxes(prefix) {\n for (let i = 0; i < 4; i++) { const el = document.getElementById(prefix+i); if(el) el.value=''; }\n}\nfunction _readBoxes(prefix) {\n return [0,1,2,3].map(i => { const el=document.getElementById(prefix+i); return el ? el.value : ''; }).join('');\n}\nfunction _pinNav(e, prefix, idx, onComplete) {\n if (e.key === 'Backspace') {\n e.preventDefault(); e.target.value = '';\n if (idx > 0) document.getElementById(prefix+(idx-1)).focus();\n return;\n }\n if (!/^[0-9]$/.test(e.key)) { e.preventDefault(); return; }\n e.preventDefault(); e.target.value = e.key;\n if (idx < 3) { document.getElementById(prefix+(idx+1)).focus(); }\n else { setTimeout(onComplete, 60); }\n}\n\n// ── PIN Entry modal (gate before settings opens) ──────────────────────────\nfunction _showPinEntry() {\n const m = document.getElementById('pinEntryModal');\n document.body.appendChild(m);\n _clearBoxes('pe');\n document.getElementById('pinEntryError').textContent = '';\n m.classList.add('open');\n setTimeout(() => document.getElementById('pe0').focus(), 50);\n}\nfunction _closePinEntry() { document.getElementById('pinEntryModal').classList.remove('open'); }\nfunction _submitPinEntry() {\n const v = _readBoxes('pe');\n if (v.length < 4) return;\n if (v === _getPin()) { _closePinEntry(); _loadSettingsData(); }\n else {\n const c = document.getElementById('pinEntryCard');\n c.classList.add('pin-shake'); setTimeout(() => c.classList.remove('pin-shake'), 450);\n document.getElementById('pinEntryError').textContent = 'Incorrect PIN. Try again.';\n _clearBoxes('pe'); setTimeout(() => document.getElementById('pe0').focus(), 50);\n }\n}\n\n// ── PIN Setup modal (set / change / remove) ───────────────────────────────\n// _pinStep: 'verify' | 'new' | 'confirm'\nlet _pinStep = '', _pinFirst = '';\nfunction openPinSetup() {\n const m = document.getElementById('pinSetupModal');\n document.body.appendChild(m);\n _pinFirst = '';\n const hasPin = !!_getPin();\n _pinStep = hasPin ? 'verify' : 'new';\n _clearBoxes('px');\n document.getElementById('pinSetupError').textContent = '';\n document.getElementById('pinRemoveBtn').style.display = 'none';\n if (!hasPin) {\n document.getElementById('pinSetupTitle').textContent = '🔒 Set PIN Lock';\n document.getElementById('pinSetupDesc').textContent = 'Enter a 4-digit PIN';\n } else {\n document.getElementById('pinSetupTitle').textContent = '🔒 Change PIN';\n document.getElementById('pinSetupDesc').textContent = 'Enter your current PIN';\n }\n m.classList.add('open');\n setTimeout(() => document.getElementById('px0').focus(), 50);\n}\nfunction closePinSetup() { document.getElementById('pinSetupModal').classList.remove('open'); }\nfunction removePin() { _clearPin(); closePinSetup(); _updateLockBtn(); _showSettingsToast('PIN removed.'); }\nfunction _pinSetupNext() {\n const v = _readBoxes('px');\n if (v.length < 4) return;\n const err = document.getElementById('pinSetupError');\n const card = document.getElementById('pinSetupCard');\n function shake() { card.classList.add('pin-shake'); setTimeout(()=>card.classList.remove('pin-shake'),450); }\n\n if (_pinStep === 'verify') {\n if (v !== _getPin()) {\n err.textContent = 'Incorrect PIN.'; shake();\n _clearBoxes('px'); setTimeout(() => document.getElementById('px0').focus(), 50);\n return;\n }\n _pinStep = 'new'; err.textContent = '';\n _clearBoxes('px');\n document.getElementById('pinSetupTitle').textContent = '🔒 Set New PIN';\n document.getElementById('pinSetupDesc').textContent = 'Enter a new 4-digit PIN';\n document.getElementById('pinRemoveBtn').style.display = 'inline-block';\n setTimeout(() => document.getElementById('px0').focus(), 50);\n\n } else if (_pinStep === 'new') {\n _pinFirst = v; _pinStep = 'confirm'; err.textContent = '';\n _clearBoxes('px');\n document.getElementById('pinSetupTitle').textContent = '🔒 Confirm PIN';\n document.getElementById('pinSetupDesc').textContent = 'Enter the PIN again to confirm';\n setTimeout(() => document.getElementById('px0').focus(), 50);\n\n } else if (_pinStep === 'confirm') {\n if (v !== _pinFirst) {\n err.textContent = 'PINs do not match — try again.'; shake();\n _pinStep = 'new'; _pinFirst = '';\n _clearBoxes('px');\n document.getElementById('pinSetupTitle').textContent = '🔒 Set PIN Lock';\n document.getElementById('pinSetupDesc').textContent = 'Enter a 4-digit PIN';\n setTimeout(() => document.getElementById('px0').focus(), 50);\n return;\n }\n _savePin(v); closePinSetup(); _updateLockBtn(); _showSettingsToast('PIN lock set ✓');\n }\n}\nfunction _updateLockBtn() {\n const btn = document.getElementById('pinLockBtn');\n if (!btn) return;\n const has = !!_getPin();\n btn.textContent = has ? '🔒' : '🔓';\n btn.title = has ? 'PIN lock ON · click to change/remove' : 'No PIN · click to set lock';\n}\nfunction _showSettingsToast(msg) {\n const n = document.getElementById('saveNotice');\n if (!n) return;\n n.style.display = 'flex';\n const sp = n.querySelector('span');\n if (sp) sp.textContent = msg; else n.textContent = msg;\n setTimeout(() => n.style.display = 'none', 2500);\n}\n// Init lock icon on page load\nwindow.addEventListener('DOMContentLoaded', _updateLockBtn);</script>\n\n<!-- PIN Entry Modal (gate before settings opens when PIN is set) -->\n<div class=\"pin-overlay\" id=\"pinEntryModal\">\n <div class=\"pin-card\" id=\"pinEntryCard\">\n <h3>🔒 Settings Locked</h3>\n <p>Enter your 4-digit PIN to open Settings</p>\n <div class=\"pin-inputs\">\n <input type=\"password\" inputmode=\"numeric\" maxlength=\"1\" id=\"pe0\" onkeydown=\"_pinNav(event,'pe',0,_submitPinEntry)\">\n <input type=\"password\" inputmode=\"numeric\" maxlength=\"1\" id=\"pe1\" onkeydown=\"_pinNav(event,'pe',1,_submitPinEntry)\">\n <input type=\"password\" inputmode=\"numeric\" maxlength=\"1\" id=\"pe2\" onkeydown=\"_pinNav(event,'pe',2,_submitPinEntry)\">\n <input type=\"password\" inputmode=\"numeric\" maxlength=\"1\" id=\"pe3\" onkeydown=\"_pinNav(event,'pe',3,_submitPinEntry)\">\n </div>\n <div class=\"pin-error\" id=\"pinEntryError\"></div>\n <div class=\"pin-actions\">\n <button class=\"pin-btn pin-btn-cancel\" onclick=\"_closePinEntry()\">Cancel</button>\n </div>\n </div>\n</div>\n\n<!-- PIN Setup Modal (set / change / remove) -->\n<div class=\"pin-overlay\" id=\"pinSetupModal\">\n <div class=\"pin-card\" id=\"pinSetupCard\">\n <h3 id=\"pinSetupTitle\">🔒 Set PIN Lock</h3>\n <p id=\"pinSetupDesc\">Enter a 4-digit PIN</p>\n <div class=\"pin-inputs\">\n <input type=\"password\" inputmode=\"numeric\" maxlength=\"1\" id=\"px0\" onkeydown=\"_pinNav(event,'px',0,_pinSetupNext)\">\n <input type=\"password\" inputmode=\"numeric\" maxlength=\"1\" id=\"px1\" onkeydown=\"_pinNav(event,'px',1,_pinSetupNext)\">\n <input type=\"password\" inputmode=\"numeric\" maxlength=\"1\" id=\"px2\" onkeydown=\"_pinNav(event,'px',2,_pinSetupNext)\">\n <input type=\"password\" inputmode=\"numeric\" maxlength=\"1\" id=\"px3\" onkeydown=\"_pinNav(event,'px',3,_pinSetupNext)\">\n </div>\n <div class=\"pin-error\" id=\"pinSetupError\"></div>\n <div class=\"pin-actions\">\n <button class=\"pin-btn pin-btn-cancel\" onclick=\"closePinSetup()\">Cancel</button>\n <button class=\"pin-btn pin-btn-danger\" id=\"pinRemoveBtn\" onclick=\"removePin()\" style=\"display:none\">Remove PIN</button>\n </div>\n </div>\n</div>\n\n <!-- AI Report Modal -->\n <div class=\"modal-overlay\" id=\"reportModal\" onclick=\"if(event.target===this)closeReportModal()\">\n <div class=\"modal-card\">\n <div class=\"modal-header\">\n <h2>&#9888; Report AI Content</h2>\n <button class=\"modal-close\" onclick=\"closeReportModal()\">&#x2715;</button>\n </div>\n <div class=\"modal-body\">\n <p class=\"report-help\">Use this form to report inappropriate, unsafe, or clearly incorrect AI-generated content. The app saves a local report with a reference ID and gives you a direct GitHub issue link for the publisher.</p>\n <div class=\"report-grid\">\n <div class=\"report-field\">\n <label>Issue type</label>\n <select id=\"report_category\">\n <option value=\"inappropriate-output\">Inappropriate or unsafe output</option>\n <option value=\"hallucination\">Incorrect or hallucinated content</option>\n <option value=\"bias\">Biased or offensive wording</option>\n <option value=\"other\">Other AI content concern</option>\n </select>\n </div>\n <div class=\"report-field\">\n <label>Model</label>\n <select id=\"report_model\" onchange=\"toggleReportModelOther()\">\n <option value=\"best\">Best Quality (Vertex AI Gemini)</option>\n <option value=\"fast\">Fast (Groq LLaMA)</option>\n <option value=\"ensemble\">All LLMs Together (Ensemble)</option>\n <option value=\"openai\">OpenAI GPT-4o</option>\n <option value=\"claude\">Claude Haiku</option>\n <option value=\"grok\">Grok (xAI)</option>\n <option value=\"gemini\">Gemini API</option>\n <option value=\"openrouter\">OpenRouter</option>\n <option value=\"offline\">Offline (EasyOCR + Groq)</option>\n <option value=\"other\">Other (type manually)</option>\n </select>\n </div>\n <div class=\"report-field\" id=\"report_model_other_row\" style=\"display:none;\">\n <label>Other model name</label>\n <input type=\"text\" id=\"report_model_other\" placeholder=\"Type model name\" oninput=\"updateQuickGithubReportLink()\">\n </div>\n <div class=\"report-field\">\n <label>Problematic output snippet</label>\n <textarea id=\"report_snippet\" placeholder=\"Paste the AI-generated text or result that caused concern.\"></textarea>\n </div>\n <div class=\"report-field\">\n <label>What went wrong</label>\n <textarea id=\"report_details\" placeholder=\"Describe what the AI generated and why it should be reviewed.\"></textarea>\n </div>\n <div class=\"report-field\">\n <label>Contact (optional)</label>\n <input type=\"text\" id=\"report_contact\" placeholder=\"Email or other contact info\">\n </div>\n </div>\n </div>\n <div class=\"modal-footer\">\n <span class=\"report-status\" id=\"reportStatus\"></span>\n <div class=\"report-actions\" id=\"reportActions\">\n <a class=\"report-action-link\" id=\"reportIssueLink\" href=\"#\" target=\"_blank\" rel=\"noopener\">Report in GitHub</a>\n </div>\n <button type=\"button\" class=\"btn-cancel\" onclick=\"closeReportModal()\">Cancel</button>\n <button type=\"button\" class=\"btn-save\" id=\"reportSubmitBtn\" onclick=\"submitAIReport()\">Submit</button>\n </div>\n </div>\n </div>\n\n<!-- Settings Modal -->\n<div class=\"modal-overlay\" id=\"settingsModal\">\n <div class=\"modal-card\">\n <div class=\"modal-header\">\n <h2>&#9881; API Key Settings</h2>\n <div style=\"display:flex;align-items:center;gap:4px;\">\n <button class=\"modal-info-btn\" onclick=\"event.stopPropagation(); openHelp()\" title=\"Help &amp; Setup Guide\">&#8505; Info</button>\n <button class=\"modal-lock-btn\" id=\"pinLockBtn\" onclick=\"event.stopPropagation(); openPinSetup()\" title=\"Set PIN lock\">🔓</button>\n <button class=\"modal-close\" onclick=\"closeSettings()\">&#x2715;</button>\n </div>\n </div>\n <div class=\"modal-body\">\n <button class=\"show-all-btn\" onclick=\"showAllSections()\">Show All Sections</button>\n\n <div class=\"key-section\" id=\"key_section_llm\">\n <div class=\"key-section-title\" onclick=\"toggleSection('llm')\">LLM Providers</div>\n <div class=\"key-row\">\n <label>Groq API Key</label>\n <div class=\"key-row-input\">\n <input type=\"password\" id=\"inp_GROQ_API_KEY\" placeholder=\"gsk_...\">\n <button class=\"eye-btn\" onclick=\"toggleEye('GROQ_API_KEY')\">&#128065;</button>\n </div>\n </div>\n <div class=\"key-row\">\n <label>Gemini API Key</label>\n <div class=\"key-row-input\">\n <input type=\"password\" id=\"inp_GEMINI_API_KEY\" placeholder=\"AIza...\">\n <button class=\"eye-btn\" onclick=\"toggleEye('GEMINI_API_KEY')\">&#128065;</button>\n </div>\n </div>\n <div class=\"key-row\">\n <label>Gemini API Key 2</label>\n <div class=\"key-row-input\">\n <input type=\"password\" id=\"inp_GEMINI_API_KEY_2\" placeholder=\"AIza...\">\n <button class=\"eye-btn\" onclick=\"toggleEye('GEMINI_API_KEY_2')\">&#128065;</button>\n </div>\n </div>\n <div class=\"key-row\">\n <label>OpenAI API Key</label>\n <div class=\"key-row-input\">\n <input type=\"password\" id=\"inp_OPENAI_API_KEY\" placeholder=\"sk-...\">\n <button class=\"eye-btn\" onclick=\"toggleEye('OPENAI_API_KEY')\">&#128065;</button>\n </div>\n </div>\n <div class=\"key-row\">\n <label>Claude (Anthropic) API Key</label>\n <div class=\"key-row-input\">\n <input type=\"password\" id=\"inp_CLAUDE_API_KEY\" placeholder=\"sk-ant-...\">\n <button class=\"eye-btn\" onclick=\"toggleEye('CLAUDE_API_KEY')\">&#128065;</button>\n </div>\n </div>\n <div class=\"key-row\">\n <label>Grok (xAI) API Key</label>\n <div class=\"key-row-input\">\n <input type=\"password\" id=\"inp_GROK_API_KEY\" placeholder=\"xai-...\">\n <button class=\"eye-btn\" onclick=\"toggleEye('GROK_API_KEY')\">&#128065;</button>\n </div>\n </div>\n <div class=\"key-row\">\n <label>OpenRouter API Key</label>\n <div class=\"key-row-input\">\n <input type=\"password\" id=\"inp_OPENROUTER_API_KEY\" placeholder=\"sk-or-...\">\n <button class=\"eye-btn\" onclick=\"toggleEye('OPENROUTER_API_KEY')\">&#128065;</button>\n </div>\n </div>\n </div>\n\n <div class=\"key-section collapsed\" id=\"key_section_models\">\n <div class=\"key-section-title\" onclick=\"toggleSection('models')\">Model Versions</div>\n <div class=\"key-row\">\n <label>Groq Model</label>\n <div class=\"key-row-input\">\n <input type=\"text\" id=\"inp_GROQ_MODEL\" placeholder=\"llama-3.1-8b-instant\">\n </div>\n </div>\n <div class=\"key-row\">\n <label>Gemini Model</label>\n <div class=\"key-row-input\">\n <input type=\"text\" id=\"inp_GEMINI_MODEL\" placeholder=\"gemini-2.5-flash\">\n </div>\n </div>\n <div class=\"key-row\">\n <label>OpenAI Model</label>\n <div class=\"key-row-input\">\n <input type=\"text\" id=\"inp_OPENAI_MODEL\" placeholder=\"gpt-4o-mini\">\n </div>\n </div>\n <div class=\"key-row\">\n <label>Claude Model</label>\n <div class=\"key-row-input\">\n <input type=\"text\" id=\"inp_CLAUDE_MODEL\" placeholder=\"claude-haiku-4-5\">\n </div>\n </div>\n <div class=\"key-row\">\n <label>Grok Model</label>\n <div class=\"key-row-input\">\n <input type=\"text\" id=\"inp_GROK_MODEL\" placeholder=\"grok-3-mini\">\n </div>\n </div>\n <div class=\"key-row\">\n <label>Vertex AI Model</label>\n <div class=\"key-row-input\">\n <input type=\"text\" id=\"inp_VERTEX_MODEL\" placeholder=\"gemini-2.5-flash\">\n </div>\n </div>\n <div class=\"key-row\">\n <label>OpenRouter Model</label>\n <div class=\"key-row-input\">\n <input type=\"text\" id=\"inp_OPENROUTER_MODEL\" placeholder=\"openrouter/auto\">\n </div>\n </div>\n </div>\n\n <div class=\"key-section\" id=\"key_section_google\">\n <div class=\"key-section-title\" onclick=\"toggleSection('google')\">Google Cloud</div>\n <div class=\"key-row\">\n <label>Cloud Vision / Maps API Key</label>\n <div class=\"key-row-input\">\n <input type=\"password\" id=\"inp_GOOGLE_API_KEY\" placeholder=\"AIza...\">\n <button class=\"eye-btn\" onclick=\"toggleEye('GOOGLE_API_KEY')\">&#128065;</button>\n </div>\n </div>\n <div class=\"key-row\">\n <label>Service Account JSON Path</label>\n <div class=\"key-row-input\">\n <input type=\"text\" id=\"inp_GOOGLE_APPLICATION_CREDENTIALS\" placeholder=\"service_account.json\">\n </div>\n </div>\n <div class=\"upload-json-row\">\n <label>Upload GCP Service Account JSON (OCR)</label>\n <span class=\"upload-hint\">Saves as <b>service_account.json</b> - used for Google Cloud Vision OCR</span>\n <div style=\"display:flex;gap:8px;align-items:center;\">\n <input type=\"file\" id=\"gcv_json_file\" accept=\".json\" style=\"font-size:0.8em;flex:1;\">\n <button class=\"upload-json-btn\" onclick=\"uploadJsonFile('gcv_json_file','service_account.json','gcv_upload_status')\">Upload</button>\n </div>\n <span class=\"upload-status\" id=\"gcv_upload_status\"></span>\n </div>\n <div class=\"upload-json-row\">\n <label>Upload GCP Service Account JSON (Vertex AI)</label>\n <span class=\"upload-hint\">Saves as <b>service_account.json</b> - requires Vertex AI API enabled + <b>Vertex AI User</b> IAM role in GCP</span>\n <div style=\"display:flex;gap:8px;align-items:center;\">\n <input type=\"file\" id=\"vertex_json_file\" accept=\".json\" style=\"font-size:0.8em;flex:1;\">\n <button class=\"upload-json-btn\" onclick=\"uploadJsonFile('vertex_json_file','service_account.json','vertex_upload_status')\">Upload</button>\n </div>\n <span class=\"upload-status\" id=\"vertex_upload_status\"></span>\n </div>\n </div>\n\n <div class=\"key-section\" id=\"key_section_vertex\">\n <div class=\"key-section-title\" onclick=\"toggleSection('vertex')\">Google Vertex AI</div>\n <div class=\"key-row\">\n <label>Project ID</label>\n <div class=\"key-row-input\">\n <input type=\"text\" id=\"inp_VERTEX_PROJECT\" placeholder=\"my-gcp-project\">\n </div>\n </div>\n <div class=\"key-row\">\n <label>Region</label>\n <div class=\"key-row-input\">\n <input type=\"text\" id=\"inp_VERTEX_LOCATION\" placeholder=\"us-central1\">\n </div>\n </div>\n </div>\n\n <div class=\"key-section\" id=\"key_section_custom\">\n <div class=\"key-section-title\" onclick=\"toggleSection('custom')\">Custom / Extra API Keys</div>\n <div id=\"custom_key_rows\"></div>\n <div class=\"custom-key-add-row\">\n <input type=\"text\" id=\"custom_key_name\" placeholder=\"ENV_VARIABLE_NAME\" style=\"text-transform:uppercase;\">\n <input type=\"password\" id=\"custom_key_value\" placeholder=\"value\">\n <button class=\"custom-key-add-btn\" onclick=\"addCustomKeyRow()\">+ Add</button>\n </div>\n </div>\n </div>\n <div class=\"modal-footer\">\n <span class=\"save-notice\" id=\"saveNotice\">&#10003; Saved to .env</span>\n <button class=\"btn-cancel\" onclick=\"closeSettings()\">Cancel</button>\n <button class=\"btn-save\" onclick=\"saveSettings()\">Save Keys</button>\n </div>\n </div>\n</div>\n\n<!-- Help Overlay -->\n<div class=\"help-overlay\" id=\"helpOverlay\" onclick=\"if(event.target===this)closeHelp()\">\n <div class=\"help-box\">\n <div class=\"help-box-hdr\">\n <h3>&#8505;&nbsp; Help &amp; Setup Guide</h3>\n <button class=\"help-box-close\" onclick=\"closeHelp()\">&#x2715;</button>\n </div>\n <div class=\"help-box-body\">\n\n <!-- What is this app -->\n <div class=\"help-sec-title\">&#128255; What is CV-Toposheet?</div>\n <p style=\"font-size:0.84em;color:#4a6070;line-height:1.55;margin-bottom:10px;\">Automatically extract &amp; digitize geographic feature names from scanned historical topographic maps.</p>\n <div class=\"help-what-grid\">\n <div class=\"help-wc\"><div class=\"help-wc-num\">1</div><div class=\"help-wc-title\">Upload</div><div class=\"help-wc-desc\">Drop a scanned toposheet (JPG / PNG / TIF)</div></div>\n <div class=\"help-wc\"><div class=\"help-wc-num\">2</div><div class=\"help-wc-title\">OCR</div><div class=\"help-wc-desc\">AI reads all text from every tile of the map</div></div>\n <div class=\"help-wc\"><div class=\"help-wc-num\">3</div><div class=\"help-wc-title\">Clean</div><div class=\"help-wc-desc\">LLM corrects errors &amp; classifies features</div></div>\n <div class=\"help-wc\"><div class=\"help-wc-num\">4</div><div class=\"help-wc-title\">Search</div><div class=\"help-wc-desc\">Query &amp; export the full results database</div></div>\n </div>\n\n <div class=\"help-sep\"></div>\n\n <!-- LLM API Key -->\n <div class=\"help-sec-title\">&#128273; Step 1: Add an LLM API Key</div>\n <p style=\"font-size:0.83em;color:#4a6070;line-height:1.5;margin-bottom:10px;\">Required for feature extraction. Paste at least one key in <b>&#9881; Settings</b>.</p>\n <div class=\"help-card\">\n <div class=\"help-card-title\">&#9889; Groq API Key <span class=\"help-badge-free\">FREE &amp; FASTEST</span></div>\n <div class=\"help-card-desc\">No credit card needed. Best for first-time users.</div>\n <div class=\"help-card-link\">&#128279; console.groq.com &rarr; Sign up &rarr; API Keys &rarr; Create</div>\n </div>\n <div class=\"help-divider\">or</div>\n <div class=\"help-card\">\n <div class=\"help-card-title\">&#128171; Gemini API Key <span class=\"help-badge-free\">FREE</span></div>\n <div class=\"help-card-desc\">Google Gemini Flash. Free tier is generous.</div>\n <div class=\"help-card-link\">&#128279; aistudio.google.com/apikey &rarr; Create API key</div>\n </div>\n <div class=\"help-divider\">or</div>\n <div class=\"help-card\">\n <div class=\"help-card-title\">&#128176; Other API Keys <span style=\"display:inline-block;font-size:0.7em;font-weight:700;border-radius:4px;padding:2px 7px;margin-left:6px;vertical-align:middle;background:#fce7f3;color:#9d174d;\">PAID</span></div>\n <div class=\"help-card-desc\">Claude (Anthropic), Grok (xAI), OpenAI, and more. Add via <b>&#9881; Settings</b> after setup.</div>\n <div class=\"help-card-link\">&#128279; anthropic.com &nbsp;|&nbsp; x.ai/api &nbsp;|&nbsp; platform.openai.com</div>\n </div>\n <div class=\"help-note\">&#9888;&#65039; <b>Without an API key</b> the pipeline runs but produces wrong results: blank Feature Types, 0.50 confidence, noise rows.</div>\n\n <div class=\"help-sep\"></div>\n\n <!-- GCV JSON -->\n <div class=\"help-sec-title\">&#9729;&#65039; Step 2: Google Cloud Vision JSON</div>\n <p style=\"font-size:0.83em;color:#4a6070;line-height:1.5;margin-bottom:10px;\">Strongly recommended for best accuracy. Enables premium OCR via Google Cloud Vision API. Without it, the app uses EasyOCR (offline, slower, less accurate).</p>\n <div class=\"help-card\">\n <div class=\"help-card-title\">How to get the JSON file</div>\n <div class=\"help-card-desc\">1. Go to <b>console.cloud.google.com</b><br>2. Enable <b>Cloud Vision API</b> (APIs &amp; Services &rarr; Library)<br>3. IAM &amp; Admin &rarr; Service Accounts &rarr; Create &rarr; Keys &rarr; Add Key &rarr; JSON<br>4. File downloads automatically</div>\n </div>\n <div class=\"help-card\" style=\"margin-top:8px;\">\n <div class=\"help-card-title\">&#128196; Upload in Settings</div>\n <div class=\"help-card-desc\">Click <b>&#9881; Settings &rarr; Google Cloud &rarr; Upload GCP Service Account JSON</b> and select the downloaded file.</div>\n </div>\n <div class=\"help-note\" style=\"margin-top:8px;background:#f0fdf4;border-color:#86efac;color:#166534;\">&#11088; For even better results, also enable <b>Vertex AI API</b> in Google Cloud Console (APIs &amp; Services &rarr; Library &rarr; search &ldquo;Vertex AI&rdquo; &rarr; Enable).</div>\n\n <div class=\"help-sep\"></div>\n\n <!-- Model Versions -->\n <div class=\"help-sec-title\">&#9881;&#65039; Model Versions (optional)</div>\n <p style=\"font-size:0.83em;color:#4a6070;line-height:1.5;margin-bottom:10px;\">Default model versions work for most users. Only change if you want a specific version.</p>\n <div class=\"help-card\">\n <div class=\"help-card-title\">Default models</div>\n <div class=\"help-card-desc\">Groq: <b>llama-3.1-8b-instant</b> &nbsp;|&nbsp; Gemini: <b>gemini-2.5-flash</b><br>OpenAI: <b>gpt-4o-mini</b> &nbsp;|&nbsp; Claude: <b>claude-haiku-4-5</b><br>Grok: <b>grok-3-mini</b> &nbsp;|&nbsp; OpenRouter: <b>openrouter/auto</b></div>\n </div>\n <div class=\"help-note\">&#128161; To use a different model, open <b>&#9881; Settings &rarr; Model Versions</b>, type the model name and save.</div>\n\n <div class=\"help-sep\"></div>\n\n <!-- AI reporting -->\n <div class=\"help-sec-title\">&#9888;&#65039; Report AI-Generated Content</div>\n <p style=\"font-size:0.83em;color:#4a6070;line-height:1.5;margin-bottom:10px;\">If the AI generates inappropriate, unsafe, or clearly incorrect content, click <b>&#9888; Report AI Content</b> in the top bar. The app stores a report reference and opens a publisher issue link so the content can be reviewed.</p>\n\n <div class=\"help-sep\"></div>\n\n <!-- PIN Lock -->\n <div class=\"help-sec-title\">&#128274; PIN Lock (optional)</div>\n <p style=\"font-size:0.83em;color:#4a6070;line-height:1.5;margin-bottom:10px;\">Protect your API keys with a 4-digit PIN so no one else can open Settings.</p>\n <div class=\"help-card\">\n <div class=\"help-card-title\">Set a PIN</div>\n <div class=\"help-card-desc\">Open <b>&#9881; Settings</b> &rarr; click the <b>&#128275; lock icon</b> (top right of Settings) &rarr; enter a 4-digit PIN &rarr; confirm.</div>\n </div>\n <div class=\"help-card\" style=\"margin-top:8px;\">\n <div class=\"help-card-title\">Remove a PIN</div>\n <div class=\"help-card-desc\">Open <b>&#9881; Settings</b> &rarr; enter your current PIN &rarr; click <b>Remove PIN</b>.</div>\n </div>\n\n <div class=\"help-sep\"></div>\n\n <!-- How to process -->\n <div class=\"help-sec-title\">&#128640; How to Process Your First Map</div>\n <div class=\"help-ready-row\"><div class=\"hri\">&#128444;&#65039;</div><div class=\"hrt\"><b>Upload:</b> Drag &amp; drop a JPG / PNG / TIF toposheet onto the home screen (up to 500 MB)</div></div>\n <div class=\"help-ready-row\"><div class=\"hri\">&#127917;</div><div class=\"hrt\"><b>Choose Model:</b> Select a processing model from the right panel (Best Quality recommended)</div></div>\n <div class=\"help-ready-row\"><div class=\"hri\">&#9654;&#65039;</div><div class=\"hrt\"><b>Process:</b> Click <b>Process Map</b> and watch live progress (5&ndash;30 min depending on map size)</div></div>\n <div class=\"help-ready-row\"><div class=\"hri\">&#128202;</div><div class=\"hrt\"><b>Search &amp; Export:</b> Find results in <b>Map Database</b>. Export to CSV or Excel anytime.</div></div>\n\n </div>\n </div>\n</div>\n\n</body>\n</html>\"\"\"\n\n\ndef _process_html(filename: str, model: str = 'best', session_id: str = '') -> str:\n import html as _html\n enc = url_quote(filename, safe='')\n model_enc = url_quote(model, safe='')\n session_enc = url_quote(session_id, safe='')\n safe_filename = _html.escape(filename)\n\n return f\"\"\"<!DOCTYPE html>\n<html lang='en'>\n<head>\n<meta charset='UTF-8'>\n<meta name='viewport' content='width=device-width,initial-scale=1.0'>\n<title>Processing &ndash; {safe_filename}</title>\n<style>\n* {{ box-sizing:border-box; margin:0; padding:0; }}\nbody {{ height:100vh; overflow:hidden; font-family:'Segoe UI', system-ui, Arial, sans-serif; background:#f0f4f8; display:flex; flex-direction:column; }}\n.hdr {{ display:flex;align-items:center;justify-content:space-between;background:#0E7490;padding:14px 28px;flex-shrink:0; }}\n.hdr-logo {{ font-size:1.25em; font-weight:700; color:#fff; letter-spacing:-.01em; }}\n.hdr-logo span {{ color:#fff; }}\n.hdr-sub {{ font-size:0.78em; color:rgba(255,255,255,.75); margin-top:2px; }}\n.page {{ flex:1; min-height:0; overflow-y:auto; display:flex; justify-content:center; align-items:stretch; padding:10px 16px; }}\n.card {{ background:#fff; border-radius:16px; box-shadow:0 2px 24px rgba(14,116,144,.12); padding:22px 28px; max-width:680px; width:100%; display:flex; flex-direction:column; }}\n.status-row {{ display:flex; align-items:center; gap:14px; margin-bottom:14px; }}\n.spinner {{ width:28px; height:28px; border:3px solid #e0e0e0; border-top-color:#0E7490; border-radius:50%; animation:spin .8s linear infinite; flex-shrink:0; }}\n@keyframes spin {{ to {{ transform:rotate(360deg); }} }}\n.status-text {{ font-size:1.1em; font-weight:600; color:#333; }}\n.filename-tag {{ background:#e8f5f9; color:#0E7490; padding:4px 11px; border-radius:5px; font-size:0.88em; font-weight:600; margin-left:8px; word-break:break-all; }}\n.phases {{ display:flex; gap:8px; margin-bottom:14px; flex-wrap:wrap; }}\n.phase {{ padding:5px 14px; border-radius:13px; font-size:0.8em; font-weight:600; background:#f0f0f0; color:#999; transition:all .3s; }}\n.phase.active {{ background:#0E7490; color:white; }}\n.phase.done {{ background:#27ae60; color:white; }}\n.log {{ background:#1a1a2e; color:#a8d8a8; border-radius:10px; padding:14px 16px; font-family:'Consolas','Courier New',monospace; font-size:0.82em; min-height:80px; flex:1; overflow-y:auto; line-height:1.55; margin-bottom:16px; white-space:pre-wrap; word-break:break-all; }}\n.line-phase {{ color:#67d8f0; font-weight:bold; }}\n.line-done {{ color:#67f0a0; font-weight:bold; }}\n.line-warn {{ color:#f0c040; }}\n.line-err {{ color:#f06060; }}\n.btn-row {{ display:flex; gap:12px; }}\n.view-btn {{ flex:1; padding:13px; text-align:center; background:#22c55e; color:white; border:none; border-radius:8px; font-size:1em; font-weight:700; cursor:pointer; text-decoration:none; display:inline-block; transition:background .2s; opacity:0.45; pointer-events:none; }}\n.view-btn.ready {{ opacity:1; pointer-events:auto; }}\n.view-btn.ready:hover {{ background:#16a34a; }}\n.back-btn {{ flex:1; padding:13px; text-align:center; background:#f0f0f0; color:#555; border:none; border-radius:8px; font-size:1em; cursor:pointer; text-decoration:none; display:inline-block; }}\n.back-btn:hover {{ background:#e0e0e0; }}\n.kill-btn {{ background:#dc2626; border:none; color:#fff; border-radius:7px; padding:7px 16px; font-size:0.85em; font-weight:700; cursor:pointer; transition:background .2s; display:flex; align-items:center; gap:6px; }}\n.kill-btn:hover {{ background:#b91c1c; }}\n.kill-btn:disabled {{ background:#999; cursor:not-allowed; }}\n</style>\n</head>\n<body>\n\n<div class=\"hdr\">\n <div>\n <div class=\"hdr-logo\">CV-<span>Toposheet</span></div>\n <div class=\"hdr-sub\">Extracted Map Features</div>\n </div>\n <button class=\"kill-btn\" id=\"killBtn\" onclick=\"killProcess()\">&#9632;&nbsp; Cancel Job</button>\n</div>\n\n<div class=\"page\">\n <div class=\"card\">\n <div class=\"status-row\">\n <div class=\"spinner\" id=\"spinner\"></div>\n <div class=\"status-text\">\n Processing <span class=\"filename-tag\">{safe_filename}</span>\n </div>\n </div>\n\n <div class=\"phases\">\n <div class=\"phase\" id=\"p1\">1 &middot; Tiling</div>\n <div class=\"phase\" id=\"p2\">2 &middot; OCR</div>\n <div class=\"phase\" id=\"p3\">3 &middot; Grid</div>\n <div class=\"phase\" id=\"p4\">4 &middot; LLM</div>\n <div class=\"phase\" id=\"p5\">5 &middot; Database</div>\n <div class=\"phase\" id=\"p6\">Export</div>\n </div>\n\n <div class=\"log\" id=\"logBox\"></div>\n\n <div class=\"btn-row\">\n <a href=\"/results/{session_enc}\" class=\"view-btn\" id=\"viewBtn\">&#128202;&nbsp; View Results</a>\n <a href=\"/\" class=\"back-btn\">&#8592;&nbsp; Upload Another Map</a>\n </div>\n </div>\n</div>\n\n<script>\nconst logBox = document.getElementById('logBox');\nconst viewBtn = document.getElementById('viewBtn');\nconst spinner = document.getElementById('spinner');\nconst killBtn = document.getElementById('killBtn');\nlet currentPhase = null;\n\nfunction killProcess() {{\n killBtn.disabled = true;\n killBtn.textContent = '⏳ Cancelling…';\n killBtn.style.background = '#b91c1c';\n fetch('/kill', {{method:'POST'}}).finally(() => {{\n window.location.href = '/';\n }});\n}}\n\nfunction setPhase(id) {{\n if (currentPhase === id) return;\n if (currentPhase) document.getElementById(currentPhase).className = 'phase done';\n document.getElementById(id).className = 'phase active';\n currentPhase = id;\n}}\n\nfunction appendLine(msg, cls) {{\n const span = document.createElement('span');\n if (cls) span.className = cls;\n span.textContent = msg + '\\\\n';\n logBox.appendChild(span);\n logBox.scrollTop = logBox.scrollHeight;\n}}\n\nconst es = new EventSource('/stream/{enc}?model={model_enc}&session={session_enc}');\n\nes.onmessage = function(e) {{\n const data = JSON.parse(e.data);\n const msg = data.msg || '';\n\n if (/Phase 1|Tiling/i.test(msg)) {{ setPhase('p1'); appendLine(msg, 'line-phase'); }}\n else if (/Phase 2|OCR/i.test(msg)) {{ setPhase('p2'); appendLine(msg, 'line-phase'); }}\n else if (/Phase 3|Grid/i.test(msg)) {{ setPhase('p3'); appendLine(msg, 'line-phase'); }}\n else if (/Phase 4|LLM/i.test(msg)) {{ setPhase('p4'); appendLine(msg, 'line-phase'); }}\n else if (/Phase 5|Database/i.test(msg)) {{ setPhase('p5'); appendLine(msg, 'line-phase'); }}\n else if (/[Ee]xport/i.test(msg)) {{ setPhase('p6'); appendLine(msg, 'line-phase'); }}\n else if (/ERROR|Error/i.test(msg)) {{ appendLine(msg, 'line-err'); }}\n else if (/warn/i.test(msg)) {{ appendLine(msg, 'line-warn'); }}\n else if (msg.startsWith('\\\\u2705')) {{ appendLine(msg, 'line-done'); }}\n else {{ appendLine(msg, ''); }}\n\n if (data.done) {{\n spinner.style.display = 'none';\n killBtn.style.display = 'none';\n viewBtn.classList.add('ready');\n if (data.redirect) viewBtn.href = data.redirect;\n es.close();\n }}\n if (data.error) {{\n spinner.style.display = 'none';\n killBtn.style.display = 'none';\n appendLine('Processing stopped. See log above for details.', 'line-err');\n es.close();\n }}\n}};\n\n// CRITICAL: close on connection drop — without this, EventSource auto-reconnects\n// every 3s and re-spawns the pipeline subprocess in an infinite loop.\nvar _disconnected = false;\nes.onerror = function() {{\n if (_disconnected) return;\n _disconnected = true;\n es.close();\n spinner.style.display = 'none';\n killBtn.style.display = 'none';\n appendLine('[Stream disconnected — OCR/LLM processing continues in the background.]', 'line-warn');\n appendLine('[Polling for completion... View Results will activate automatically when done.]', 'line-warn');\n // Poll results endpoint every 20 s; enable View Results once HTML is available\n var _poll = setInterval(function() {{\n fetch('/results/{session_enc}', {{method:'HEAD'}})\n .then(function(r) {{\n if (r.ok) {{\n clearInterval(_poll);\n viewBtn.classList.add('ready');\n appendLine('✅ Processing complete! Click View Results.', 'line-done');\n }}\n }})\n .catch(function() {{}});\n }}, 20000);\n}};\n</script>\n</body>\n</html>\"\"\"\n\n\n# ── Entry point ───────────────────────────────────────────────────────────────\nif __name__ == '__main__':\n import logging\n # Kill any leftover processes on port 5000 before binding\n if os.name == 'nt':\n try:\n result = subprocess.check_output(\n 'netstat -ano | findstr \"127.0.0.1:5000\" | findstr LISTENING',\n shell=True, stderr=subprocess.DEVNULL\n ).decode()\n for line in result.strip().splitlines():\n parts = line.split()\n pid = parts[-1] if parts else None\n if pid and pid.isdigit() and int(pid) != os.getpid():\n subprocess.call(['taskkill', '/F', '/PID', pid],\n shell=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n except Exception:\n pass\n log = logging.getLogger('werkzeug')\n log.setLevel(logging.ERROR)\n print()\n print('=' * 52)\n print(' CV-Toposheet Map Digitization Interface')\n print(' Open your browser: http://localhost:5000')\n print('=' * 52)\n print()\n app.run(debug=False, host='127.0.0.1', port=5000, threaded=True)\n"}}}}