1 line
133 KiB
JSON
1 line
133 KiB
JSON
{"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=\"/\">← 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\">📿</div>\n <div class=\"slide-title\">Welcome to CV-Toposheet!</div>\n <div class=\"slide-sub\">Automatically extract & 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 & 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 & export the full results database</div></div>\n </div>\n <div class=\"note\">🕐 This setup screen appears <b>once only</b>. Takes about 60 seconds. You can revisit instructions anytime in <b>Settings → 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\">🔑</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>⚙ 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\">⚡</div>\n <div>\n <div class=\"card-title\">Groq API Key <span class=\"badge badge-free\">FREE & FASTEST</span></div>\n <div class=\"card-desc\">No credit card needed. Best for first-time users.</div>\n <div class=\"card-link\">🔗 console.groq.com → Sign up → API Keys → 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\">💫</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\">🔗 aistudio.google.com/apikey → 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\">💰</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>⚙ Settings</b> after setup.</div>\n <div class=\"card-link\">🔗 anthropic.com | x.ai/api | platform.openai.com</div>\n </div>\n </div>\n </div>\n <div class=\"note\">⚠️ <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\">☁️</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 & Services → Library)<br>\n 3. IAM & Admin → Service Accounts → Create → Keys → Add Key → 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\">📄</div>\n <div>\n <div class=\"card-title\">Upload in Settings</div>\n <div class=\"card-desc\">Click <b>⚙ Settings → Google Cloud → Upload GCP Service Account JSON</b> and select the downloaded file.</div>\n </div>\n </div>\n </div>\n <div class=\"note\">✅ For best accuracy, set this up now. You can also add it later via <b>Settings → Help → Google Cloud</b>.</div>\n <div class=\"note\" style=\"margin-top:8px;background:#f0fdf4;border-color:#86efac;color:#166534;\">⭐ For even better results, also enable <b>Vertex AI API</b> in Google Cloud Console (APIs & Services → Library → search “Vertex AI” → 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\">🚀</div>\n <div class=\"slide-title\">You’re Ready to Go!</div>\n <div class=\"slide-sub\">Here’s how to process your first map.</div>\n <div class=\"ready-list\">\n <div class=\"ready-row\"><div class=\"ri\">🖼️</div><div class=\"rt\"><b>Upload:</b> Drag & drop a JPG / PNG / TIF toposheet onto the home screen (up to 500 MB)</div></div>\n <div class=\"ready-row\"><div class=\"ri\">🎭</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\">▶️</div><div class=\"rt\"><b>Process:</b> Click <b>Process Map</b> and watch live progress (5–30 min depending on map size)</div></div>\n <div class=\"ready-row\"><div class=\"ri\">📊</div><div class=\"rt\"><b>Search & 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)\">← 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 →</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\">📊 <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\">■<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\">⚠︎<span class=\"report-label\"> Report AI Content</span></button>\n <button class=\"settings-btn\" onclick=\"openSettings()\">⚙︎<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;\">🗺</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 & drop map images here, or click to browse</div>\n <div class=\"dz-badge\">JPG · PNG · TIF · 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 & 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 ▶ 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,'"')}\" 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}')\">👁</button>\n <button class=\"eye-btn\" style=\"color:#c00;\" onclick=\"document.getElementById('custom_row_${name}').remove()\" title=\"Remove\">✕</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>⚠ Report AI Content</h2>\n <button class=\"modal-close\" onclick=\"closeReportModal()\">✕</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>⚙ 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 & Setup Guide\">ℹ 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()\">✕</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')\">👁</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')\">👁</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')\">👁</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')\">👁</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')\">👁</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')\">👁</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')\">👁</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')\">👁</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\">✓ 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>ℹ Help & Setup Guide</h3>\n <button class=\"help-box-close\" onclick=\"closeHelp()\">✕</button>\n </div>\n <div class=\"help-box-body\">\n\n <!-- What is this app -->\n <div class=\"help-sec-title\">📿 What is CV-Toposheet?</div>\n <p style=\"font-size:0.84em;color:#4a6070;line-height:1.55;margin-bottom:10px;\">Automatically extract & 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 & 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 & 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\">🔑 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>⚙ Settings</b>.</p>\n <div class=\"help-card\">\n <div class=\"help-card-title\">⚡ Groq API Key <span class=\"help-badge-free\">FREE & 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\">🔗 console.groq.com → Sign up → API Keys → Create</div>\n </div>\n <div class=\"help-divider\">or</div>\n <div class=\"help-card\">\n <div class=\"help-card-title\">💫 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\">🔗 aistudio.google.com/apikey → Create API key</div>\n </div>\n <div class=\"help-divider\">or</div>\n <div class=\"help-card\">\n <div class=\"help-card-title\">💰 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>⚙ Settings</b> after setup.</div>\n <div class=\"help-card-link\">🔗 anthropic.com | x.ai/api | platform.openai.com</div>\n </div>\n <div class=\"help-note\">⚠️ <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\">☁️ 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 & Services → Library)<br>3. IAM & Admin → Service Accounts → Create → Keys → Add Key → JSON<br>4. File downloads automatically</div>\n </div>\n <div class=\"help-card\" style=\"margin-top:8px;\">\n <div class=\"help-card-title\">📄 Upload in Settings</div>\n <div class=\"help-card-desc\">Click <b>⚙ Settings → Google Cloud → 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;\">⭐ For even better results, also enable <b>Vertex AI API</b> in Google Cloud Console (APIs & Services → Library → search “Vertex AI” → Enable).</div>\n\n <div class=\"help-sep\"></div>\n\n <!-- Model Versions -->\n <div class=\"help-sec-title\">⚙️ 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> | Gemini: <b>gemini-2.5-flash</b><br>OpenAI: <b>gpt-4o-mini</b> | Claude: <b>claude-haiku-4-5</b><br>Grok: <b>grok-3-mini</b> | OpenRouter: <b>openrouter/auto</b></div>\n </div>\n <div class=\"help-note\">💡 To use a different model, open <b>⚙ Settings → 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\">⚠️ 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>⚠ 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\">🔒 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>⚙ Settings</b> → click the <b>🔓 lock icon</b> (top right of Settings) → enter a 4-digit PIN → 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>⚙ Settings</b> → enter your current PIN → 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\">🚀 How to Process Your First Map</div>\n <div class=\"help-ready-row\"><div class=\"hri\">🖼️</div><div class=\"hrt\"><b>Upload:</b> Drag & 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\">🎭</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\">▶️</div><div class=\"hrt\"><b>Process:</b> Click <b>Process Map</b> and watch live progress (5–30 min depending on map size)</div></div>\n <div class=\"help-ready-row\"><div class=\"hri\">📊</div><div class=\"hrt\"><b>Search & 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 – {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()\">■ 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 · Tiling</div>\n <div class=\"phase\" id=\"p2\">2 · OCR</div>\n <div class=\"phase\" id=\"p3\">3 · Grid</div>\n <div class=\"phase\" id=\"p4\">4 · LLM</div>\n <div class=\"phase\" id=\"p5\">5 · 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\">📊 View Results</a>\n <a href=\"/\" class=\"back-btn\">← 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"}}}} |