{"repo": "automatebrisbaneai/voice-transcribe", "files": {"app.py": {"fd0f639507f3c0048fa0d0826d94a2bd5e7ae0b5": {"t": 1785596355.3191495, "n": 61409, "h": "93539be88f96778e", "c": "import asyncio\nimport hashlib\nimport hmac\nimport json\nimport logging\nimport os\nimport re\nimport sys\nimport time\nimport unicodedata\nimport uuid as uuid_lib\nfrom contextlib import asynccontextmanager\nfrom contextvars import ContextVar\n\nimport httpx\nfrom fastapi import FastAPI, HTTPException, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.responses import HTMLResponse\nfrom pydantic import BaseModel\nfrom pathlib import Path\n\n# ---------------------------------------------------------------------------\n# Phase 5: Structured JSON logging\n#\n# Every log line is emitted as a single JSON object to stdout.\n# Coolify's log viewer handles line-based output, and JSON is grep/jq-able.\n#\n# REQUEST_ID ContextVar carries the per-request correlation ID. Every log\n# call in the request handler picks it up automatically via the formatter.\n# ---------------------------------------------------------------------------\nREQUEST_ID: ContextVar[str] = ContextVar(\"request_id\", default=\"\")\n\n\nclass _JsonFormatter(logging.Formatter):\n \"\"\"Serialise every log record as a single JSON line.\"\"\"\n\n _SKIP_KEYS = frozenset({\n \"name\", \"msg\", \"args\", \"levelname\", \"levelno\", \"pathname\",\n \"filename\", \"module\", \"exc_info\", \"exc_text\", \"stack_info\",\n \"lineno\", \"funcName\", \"created\", \"msecs\", \"relativeCreated\",\n \"thread\", \"threadName\", \"processName\", \"process\", \"message\",\n \"taskName\",\n # uvicorn access-log records carry color_message (ANSI escape codes).\n # Uvicorn logger propagation is currently disabled, so this never\n # fires today — but skip it defensively so re-enabling propagation\n # later doesn't pollute our JSON with terminal escape sequences.\n \"color_message\",\n })\n\n def format(self, record: logging.LogRecord) -> str:\n super().format(record)\n out: dict = {\n \"timestamp\": time.strftime(\"%Y-%m-%dT%H:%M:%SZ\", time.gmtime(record.created)),\n \"level\": record.levelname,\n \"logger\": record.name,\n \"message\": record.getMessage(),\n \"request_id\": REQUEST_ID.get(\"\"),\n }\n for key, value in record.__dict__.items():\n if key not in self._SKIP_KEYS:\n out[key] = value\n if record.exc_text:\n out[\"exc\"] = record.exc_text\n return json.dumps(out, default=str)\n\n\ndef _configure_logging(level: int = logging.INFO) -> None:\n root = logging.getLogger()\n if root.handlers:\n return\n handler = logging.StreamHandler(sys.stdout)\n handler.setFormatter(_JsonFormatter())\n root.addHandler(handler)\n root.setLevel(level)\n for name in (\"uvicorn\", \"uvicorn.error\", \"uvicorn.access\"):\n logging.getLogger(name).propagate = False\n\n\n_configure_logging()\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Default timeouts (Phase 5 — httpx per-phase timeouts)\n# connect=5s : TCP handshake must complete quickly\n# read=30s : normal API responses (OpenCode JSON endpoints)\n# write=30s : request body upload\n# pool=5s : waiting for a free connection from the pool\n# ---------------------------------------------------------------------------\nDEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0)\n\n\n@asynccontextmanager\nasync def lifespan(application: FastAPI):\n client = httpx.AsyncClient(timeout=DEFAULT_TIMEOUT)\n application.state.http = client\n logger.info(\"Application startup\", extra={\"event\": \"app_startup\"})\n try:\n yield\n finally:\n await client.aclose()\n logger.info(\"Application shutdown\", extra={\"event\": \"app_shutdown\"})\n\n\n# docs_url/redoc_url/openapi_url all None: don't advertise the API surface\n# to anyone who guesses the conventional FastAPI paths.\napp = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)\n\nOPENCODE_GO_KEY = os.environ.get(\"OPENCODE_GO_KEY\", \"\")\n# Auto-rotation: /clean tries each key in order and skips any that has hit its\n# monthly cap (HTTP 429 / GoUsageLimitError). A single capped key took voice\n# cleanup down site-wide on 2026-06-21 because there was no fallback. Set\n# OPENCODE_GO_KEYS to a comma-separated list to enable rotation; otherwise it\n# falls back to the single OPENCODE_GO_KEY.\n_keys_env = os.environ.get(\"OPENCODE_GO_KEYS\", \"\").strip()\nOPENCODE_GO_KEYS = [k.strip() for k in _keys_env.split(\",\") if k.strip()] or (\n [OPENCODE_GO_KEY] if OPENCODE_GO_KEY else []\n)\nMODEL = \"deepseek-v4-flash\" # OpenCode Go bare slug\nOPENCODE_GO_URL = \"https://opencode.ai/zen/go/v1/chat/completions\"\n\n# Shared-secret header for /clean. Empty default keeps tests + dev unauthenticated;\n# in Coolify the env var is set and every browser pulls the value via the rendered HTML.\nCLEAN_SHARED_KEY = os.environ.get(\"CLEAN_SHARED_KEY\", \"\")\nCLEAN_SHARED_KEY_HEADER = \"X-Talk-Key\"\n\n# CORS allowlist — comma-separated origins via ALLOWED_ORIGINS env var. Adding\n# a new consumer site = update the env in Coolify + restart, no code change.\n# Default covers the three sites that consume this app today; an explicit env\n# override always wins so we don't accidentally lock new sites out on rollout.\n_DEFAULT_ORIGINS = [\n \"https://reply.croquetclaude.com\",\n \"https://talk.croquetwade.com\",\n \"https://table.croquetclaude.com\",\n]\n_origins_env = os.environ.get(\"ALLOWED_ORIGINS\", \"\").strip()\nALLOWED_ORIGINS = [\n o.strip() for o in _origins_env.split(\",\") if o.strip()\n] if _origins_env else _DEFAULT_ORIGINS\n\n# Per-IP rate limit. Default high enough that the test suite never trips it;\n# Coolify can tune via env without code changes.\nRATE_LIMIT_PER_MIN = int(os.environ.get(\"RATE_LIMIT_PER_MIN\", \"60\"))\nRATE_LIMIT_WINDOW_S = 60.0\n\nMIN_WORD_CHARS = 3\nMAX_CHUNK_CHARS = 2_000\n\n# Hard cap on the raw request body BEFORE FastAPI/Pydantic parses it.\n# MAX_CHUNK_CHARS=2000 is the application-layer limit on the cleaned text;\n# the JSON envelope adds a small fixed overhead, so 8KB gives headroom without\n# letting an attacker stream a 50MB body just to land in a 413 reply.\nMAX_REQUEST_BYTES = int(os.environ.get(\"MAX_REQUEST_BYTES\", \"8192\"))\n\n# Outline @croquetclaude mention receiver.\n# The mention is NOT answered here. We enqueue it to util PB and post an instant\n# \"working on it\" ack; the local outline-mention-watcher (on Wade's machine, where\n# Hermes + QMD live) does the answering and CC escalation. See\n# system/memory/reference_outline_mention_answering.md.\nOUTLINE_WEBHOOK_SECRET = os.environ.get(\"OUTLINE_WEBHOOK_SECRET\", \"\")\nOUTLINE_API_TOKEN = os.environ.get(\"OUTLINE_API_TOKEN\", \"\")\nOUTLINE_API_BASE = os.environ.get(\"OUTLINE_API_BASE\", \"https://docs.croquetclaude.com/api\")\n# CroquetClaude's own Outline user id — the self-filter that stops a no-@ follow-up pickup from\n# ever re-triggering on a comment WE posted (the reply-loop breaker, Feature 2.8).\nCROQUETCLAUDE_USER_ID = os.environ.get(\"CROQUETCLAUDE_USER_ID\", \"60ee9481-714c-4e65-a50b-70de0f4ff438\")\n\n# util PocketBase queue (reachable from Sydney + Wade's machine)\nUTIL_PB_URL = os.environ.get(\"UTIL_PB_URL\", \"https://util.croquetwade.com\")\nUTIL_PB_EMAIL = os.environ.get(\"UTIL_PB_EMAIL\", \"\")\nUTIL_PB_PASSWORD = os.environ.get(\"UTIL_PB_PASSWORD\", \"\")\n\n# ---------------------------------------------------------------------------\n# Per-IP rate limit — sliding window in process memory.\n#\n# Single-instance Coolify deployment: an in-process dict is sufficient.\n# If we ever scale horizontally, replace with Redis.\n# ---------------------------------------------------------------------------\nfrom collections import defaultdict, deque\n\n_rate_buckets: dict[str, deque[float]] = defaultdict(deque)\n_rate_request_count = 0 # for periodic empty-bucket purge\n\n\ndef _check_rate_limit(ip: str) -> bool:\n \"\"\"Return True if request is under the limit, False if over.\n\n Sliding-window over RATE_LIMIT_WINDOW_S. time.monotonic() is immune to\n wall-clock adjustments (NTP corrections, DST) which would otherwise let\n a buggy clock either reset or block buckets unexpectedly.\n \"\"\"\n global _rate_request_count\n now = time.monotonic()\n bucket = _rate_buckets[ip]\n cutoff = now - RATE_LIMIT_WINDOW_S\n while bucket and bucket[0] < cutoff:\n bucket.popleft()\n if len(bucket) >= RATE_LIMIT_PER_MIN:\n return False\n bucket.append(now)\n # Periodic global prune. Drop ANY bucket whose newest timestamp is older\n # than the window cutoff — covers both empty deques (handled by the same\n # condition since max() raises ValueError on empty, guarded below) and\n # one-shot IPs whose single old timestamp would otherwise leak forever.\n _rate_request_count += 1\n if _rate_request_count % 500 == 0:\n stale_keys = [\n k for k, b in _rate_buckets.items()\n if not b or b[-1] < cutoff\n ]\n for k in stale_keys:\n del _rate_buckets[k]\n return True\n\n\n_META_START_RE = re.compile(\n r\"^\\s*(sure|certainly|okay|of course|please|here is|here's|understood|\"\n r\"i (can|will|am happy|'ll clean|'ll process)|i['\\u2019]m happy)\\b\",\n re.IGNORECASE,\n)\n_SUSPICIOUS_SUBSTRINGS = (\n \"provide the transcript\", \"provide the text\", \"provide the voice\",\n \"i will process\", \"i understand\", \"i'll clean\", \"i will clean\",\n \"as an ai\", \"happy to help\",\n)\n\n# ---------------------------------------------------------------------------\n# Phase 5: Prompt-injection two-layer defence + input normalization\n# ---------------------------------------------------------------------------\n_ZERO_WIDTH_TO_SPACE = str.maketrans({\n \"\\u200b\": \" \", \"\\u200c\": \" \", \"\\u200d\": \" \",\n \"\\u200e\": \" \", \"\\u200f\": \" \", \"\\u2060\": \" \",\n \"\\ufeff\": \" \",\n})\n_PROMPT_INJECTION_KEYWORDS = (\n \"ignore previous\", \"ignore all previous\", \"disregard previous\", \"disregard instructions\",\n \"system prompt\", \"reveal instructions\", \"show instructions\", \"print instructions\",\n \"you are now\", \"new instructions:\", \"updated instructions\",\n \"[inst]\", \"<|im_start|>\", \"### system\", \"### instruction\", \"\",\n \"dan mode\", \"developer mode\", \"without restrictions\", \"jailbreak\",\n \"disregard all\", \"forget all\", \"forget previous\", \"forget everything\",\n)\n\n\ndef _normalize_input(raw: str) -> str:\n # NFKC normalization handles homoglyph + width variants\n normalized = unicodedata.normalize(\"NFKC\", raw)\n # Replace zero-width chars with a space (delete would collapse word boundaries)\n normalized = normalized.translate(_ZERO_WIDTH_TO_SPACE)\n # Strip control chars except \\n and \\t\n normalized = \"\".join(c for c in normalized if c.isprintable() or c in \"\\n\\t\")\n return normalized\n\n\ndef _has_prompt_injection(text: str) -> bool:\n lower = text.lower()\n return any(kw in lower for kw in _PROMPT_INJECTION_KEYWORDS)\n\n\ndef _looks_like_meta_response(raw_input: str, model_output: str) -> bool:\n if not model_output:\n return False\n stripped = model_output.strip().lower()\n if not stripped:\n return False\n if _META_START_RE.match(stripped):\n return True\n if any(s in stripped for s in _SUSPICIOUS_SUBSTRINGS):\n return True\n # Output wildly longer than input with no shared vocabulary is suspicious.\n # Phase 5: soften for short inputs — only apply the ratio check when input\n # has at least 5 words, to avoid false positives on single-word corrections.\n in_words = {w for w in re.findall(r\"[a-z]+\", raw_input.lower()) if len(w) > 2}\n out_words = re.findall(r\"[a-z]+\", stripped)\n if len(in_words) >= 5 and len(out_words) > len(in_words) * 3:\n overlap = sum(1 for w in out_words if w in in_words)\n if overlap < max(2, len(in_words) // 3):\n return True\n return False\n\n\ndef _resolve_shared_dir() -> Path:\n \"\"\"Find the shared/ directory wherever it actually lives.\n\n Docker layout: ./shared/ is inside the app dir (Dockerfile `COPY . .` from\n the deploy submodule which has its own shared/).\n Dev layout: shared/ is a sibling at apps/shared/ (the canonical source).\n Falls back to the Docker path so `shared_file` returns clean 404s rather\n than tracebacks when neither exists.\n \"\"\"\n here = Path(__file__).parent\n for candidate in (here / \"shared\", here.parent / \"shared\"):\n if candidate.is_dir():\n return candidate\n return here / \"shared\"\n\n\nSHARED_DIR = _resolve_shared_dir()\n\n\ndef _load_croquet_dictionary() -> dict:\n \"\"\"Load croquet-dictionary.json with graceful fallback.\n\n Runs at import time, so a malformed file MUST NOT crash the boot.\n If JSON is bad (trailing comma after a manual edit, truncated write),\n log loudly and return an empty dictionary — the app still serves\n traffic, just without croquet vocab hints in the LLM prompt.\n \"\"\"\n p = SHARED_DIR / \"croquet-dictionary.json\"\n if not p.is_file():\n return {\"terms\": [], \"players\": []}\n try:\n return json.loads(p.read_text(encoding=\"utf-8\"))\n except (json.JSONDecodeError, OSError) as e:\n logger.error(\n \"croquet-dictionary.json failed to load — serving without it\",\n extra={\n \"event\": \"dictionary_load_failed\",\n \"path\": str(p),\n \"exc_type\": type(e).__name__,\n \"exc_msg\": str(e)[:200],\n },\n )\n return {\"terms\": [], \"players\": []}\n\n\n_DICTIONARY = _load_croquet_dictionary()\n_DICTIONARY_HINT = (\n \"The following are valid croquet terms that may appear in the transcript — \"\n \"correct any misheard words to match these exactly: \"\n + \", \".join(_DICTIONARY.get(\"terms\", []))\n + \". \"\n \"The following are Queensland croquet player names — \"\n \"correct any misheard names to match these exactly: \"\n + \", \".join(_DICTIONARY.get(\"players\", []))\n + \". \"\n) if (_DICTIONARY.get(\"terms\") or _DICTIONARY.get(\"players\")) else \"\"\n\n\nclass TranscriptRequest(BaseModel):\n text: str\n\n\n# ---------------------------------------------------------------------------\n# Phase 5: Request correlation ID middleware\n# ---------------------------------------------------------------------------\n@app.middleware(\"http\")\nasync def correlation_id_middleware(request: Request, call_next):\n \"\"\"\n Assign a correlation ID to every request.\n\n - Honour X-Request-ID if a reverse proxy has already set one.\n - Otherwise generate a short UUID (12 hex chars — readable in logs).\n - Store in REQUEST_ID ContextVar so every log line in this request picks\n it up automatically.\n - Return the ID in the X-Request-ID response header so clients/upstreams\n can correlate their own logs.\n \"\"\"\n request_id = request.headers.get(\"x-request-id\") or uuid_lib.uuid4().hex[:12]\n token = REQUEST_ID.set(request_id)\n # Pin on request.state too, so the global exception handler can read it\n # AFTER this middleware's finally has reset the ContextVar.\n request.state.request_id = request_id\n\n path = request.url.path\n method = request.method\n client_ip = (\n request.headers.get(\"x-forwarded-for\", \"\").split(\",\")[0].strip()\n or (request.client.host if request.client else \"unknown\")\n )\n\n t_start = time.monotonic()\n logger.info(\n \"Request start\",\n extra={\n \"event\": \"request_start\",\n \"path\": path,\n \"method\": method,\n \"client_ip\": client_ip,\n },\n )\n\n try:\n response = await call_next(request)\n duration_ms = round((time.monotonic() - t_start) * 1000)\n logger.info(\n \"Request end\",\n extra={\n \"event\": \"request_end\",\n \"path\": path,\n \"status\": response.status_code,\n \"duration_ms\": duration_ms,\n },\n )\n response.headers[\"X-Request-ID\"] = request_id\n return response\n except Exception:\n duration_ms = round((time.monotonic() - t_start) * 1000)\n logger.error(\n \"Request error\",\n extra={\n \"event\": \"request_end\",\n \"path\": path,\n \"status\": 500,\n \"duration_ms\": duration_ms,\n },\n )\n raise\n finally:\n REQUEST_ID.reset(token)\n\n\n# ---------------------------------------------------------------------------\n# Body-size guard — rejects oversized requests BEFORE FastAPI/Pydantic parses\n# the body. Two layers:\n# 1. Fast path: trust Content-Length when present. 99.9% of clients send it.\n# 2. Stream-read fallback: when Content-Length is absent (chunked transfer\n# encoding, HTTP/2 streamed requests), drain the body ourselves with a\n# running tally and 413 the moment cumulative bytes cross the cap.\n# Then rebuild the receive callable so the downstream handler still gets\n# the body it expects.\n# Without #2, any client sending `Transfer-Encoding: chunked` bypasses the cap\n# and streams unbounded bytes straight into FastAPI's Pydantic parse step.\n# Runs AFTER correlation_id middleware (registered earlier in this file).\n# ---------------------------------------------------------------------------\n@app.middleware(\"http\")\nasync def request_size_middleware(request: Request, call_next):\n from fastapi.responses import Response as _Response\n\n # Outline webhook payloads may legitimately exceed the /clean 8 KB cap.\n if request.url.path in (\"/outline/webhook\", \"/outline/webhook/\"):\n return await call_next(request)\n\n def _too_large_response(reason: str) -> _Response:\n logger.warning(\n \"Request rejected — body too large\",\n extra={\n \"event\": \"request_too_large\",\n \"reason\": reason,\n \"path\": request.url.path,\n },\n )\n return _Response(\n content=json.dumps({\"detail\": \"Payload too large\"}),\n status_code=413,\n media_type=\"application/json\",\n )\n\n # Fast path: declared Content-Length AND no chunked transfer-encoding.\n # Per RFC 7230, when both Content-Length and Transfer-Encoding are\n # present, TE wins for framing. A hostile caller can send `CL:0` plus\n # `TE:chunked` with an oversized chunked body; the CL fast path would\n # have let it through unread before this guard. When TE is present we\n # always fall through to the stream-read path so the actual bytes get\n # tallied against the cap. (Codex round-4 finding, 2026-05-24.)\n cl = request.headers.get(\"content-length\")\n te = request.headers.get(\"transfer-encoding\", \"\").lower()\n if cl is not None and \"chunked\" not in te:\n try:\n cl_int = int(cl)\n # Negative or oversize: reject. Negative CL is technically malformed\n # but uvicorn/Traefik don't always normalise; the explicit < 0 check\n # is defence-in-depth so a stray negative number can't trick the\n # `> MAX_REQUEST_BYTES` comparison into letting the body through.\n if cl_int < 0 or cl_int > MAX_REQUEST_BYTES:\n return _too_large_response(\"content_length\")\n # CL present, no chunked TE, within budget — let downstream read.\n return await call_next(request)\n except ValueError:\n # Malformed CL header — fall through to the stream-read guard\n # rather than trusting an unparseable number.\n pass\n\n # Slow path: no/unparseable Content-Length (chunked TE, HTTP/2 stream).\n # Drain the body ourselves with a running tally. As soon as we cross the\n # cap, 413 — don't keep reading more bytes than necessary to decide.\n body = bytearray()\n async for chunk in request.stream():\n body.extend(chunk)\n if len(body) > MAX_REQUEST_BYTES:\n return _too_large_response(\"streamed_overflow\")\n\n # Within budget — rebuild a receive callable that replays the body we\n # already drained, then hand off to the route handler. The route's\n # `await request.body()` (via Pydantic) will see exactly this body.\n body_bytes = bytes(body)\n consumed = False\n async def _receive():\n nonlocal consumed\n if consumed:\n return {\"type\": \"http.disconnect\"}\n consumed = True\n return {\"type\": \"http.request\", \"body\": body_bytes, \"more_body\": False}\n request._receive = _receive\n return await call_next(request)\n\n\n# ---------------------------------------------------------------------------\n# CORS — explicit origin allowlist so only the three known sites cross-fetch\n# /clean. allow_credentials=False only blocks browser-managed credentials\n# (cookies, HTTP auth); custom headers like X-Talk-Key still pass when listed\n# in allow_headers. Without X-Talk-Key in the list, browser preflight blocks\n# any cross-origin POST that tries to send it.\n#\n# Middleware registration order (Starlette/FastAPI LIFO): this is the LAST\n# middleware registered, so it wraps everything else and handles OPTIONS\n# preflight before correlation_id_middleware / request_size_middleware run.\n# ---------------------------------------------------------------------------\napp.add_middleware(\n CORSMiddleware,\n allow_origins=ALLOWED_ORIGINS,\n allow_methods=[\"GET\", \"POST\", \"OPTIONS\"],\n allow_headers=[\"Content-Type\", CLEAN_SHARED_KEY_HEADER],\n allow_credentials=False,\n max_age=86400,\n)\n\n\n# ---------------------------------------------------------------------------\n# Phase 5: Global exception handler — structured ERROR log before 500 reply\n# ---------------------------------------------------------------------------\n@app.exception_handler(Exception)\nasync def unhandled_exception_handler(request: Request, exc: Exception):\n from fastapi.responses import Response as _Response\n # By the time this runs, the middleware's `finally` has already reset the\n # REQUEST_ID ContextVar — so we read the pinned-on-state copy instead.\n request_id = getattr(request.state, \"request_id\", \"\")\n logger.error(\n \"Unhandled exception\",\n extra={\n \"event\": \"exception\",\n \"request_id\": request_id,\n \"exc_type\": type(exc).__name__,\n \"exc_msg\": str(exc),\n \"path\": request.url.path,\n },\n exc_info=True,\n )\n return _Response(\n content=json.dumps({\"detail\": \"An unexpected error occurred.\"}),\n status_code=500,\n media_type=\"application/json\",\n )\n\n\n_SYSTEM_PROMPT = (\n \"You are a transcript cleaner. Your only job: convert spoken voice into clean \"\n \"typed text without changing meaning, voice, or word choice.\\n\"\n \"\\n\"\n \"CORE RULE: If a sentence already makes sense, leave it alone. You are not \"\n \"improving the writing. You are removing fillers, adding punctuation, and fixing \"\n \"obvious transcription errors. Nothing else.\\n\"\n \"\\n\"\n \"EDIT RULES (apply in order):\\n\"\n \"\\n\"\n \"1. Self-corrections — when the speaker corrects themselves with \\\"sorry / I mean \"\n \"/ scratch that / no wait / actually / strike that\\\", keep what they corrected \"\n \"to and drop what they corrected from.\\n\"\n \"\\n\"\n \"2. Filler removal — delete: um, uh, ah, er, \\\"you know\\\" (when used as filler).\\n\"\n \"\\n\"\n \"3. Spoken commands — convert to formatting:\\n\"\n \" \\\"new line\\\" → line break\\n\"\n \" \\\"new paragraph\\\" → blank line\\n\"\n \" \\\"period\\\" → . \\\"comma\\\" → , \\\"question mark\\\" → ?\\n\"\n \"\\n\"\n \"4. Capitalisation and punctuation — add where missing.\\n\"\n \"\\n\"\n \"5. Numbers — above twelve become digits (500, 20%); 1–12 stay as words; keep \"\n \"years and proper names as-is.\\n\"\n \"\\n\"\n \"6. Spelling — British English (organise, colour, behaviour, recognise).\\n\"\n \"\\n\"\n \"7. Context fixes — repair obvious transcription errors when surrounding words \"\n \"make the intended word clear. If you cannot tell what was meant, leave the \"\n \"word alone. Examples:\\n\"\n \" \\\"the meating went well\\\" → \\\"meeting\\\"\\n\"\n \" \\\"develop a coquet strategy\\\" → \\\"croquet\\\"\\n\"\n \"\\n\"\n + ((\"KNOWN VOCABULARY — \" + _DICTIONARY_HINT + \"\\n\\n\") if _DICTIONARY_HINT else \"\")\n + \"FORBIDDEN:\\n\"\n \"- Rephrasing, reordering, or restructuring sentences.\\n\"\n \"- Adding words, transitions, headers, or commentary.\\n\"\n \"- Em dashes — use commas or full stops.\\n\"\n \"- Converting prose into bullet lists unless the speaker is clearly enumerating.\\n\"\n \"- Markdown formatting (bold, italics, headers) unless input contained it.\\n\"\n \"\\n\"\n \"OUTPUT FORMAT — read this carefully:\\n\"\n \"Output the cleaned transcript directly. No preamble like \\\"Here is\\\" or \\\"Sure\\\". \"\n \"No quotes around the output. No explanation after it. Just the cleaned text. \"\n \"If the input is empty, whitespace, a single word, or otherwise not a usable \"\n \"transcript, return it verbatim.\\n\"\n \"\\n\"\n \"EXAMPLE:\\n\"\n \"Input: um so I went to the meating yesterday sorry the meeting yesterday and we \"\n \"talked about you know the new croquet strategy for twenty twenty six\\n\"\n \"Output: I went to the meeting yesterday and we talked about the new croquet \"\n \"strategy for 2026.\"\n)\n\n\n@app.get(\"/healthz\")\nasync def healthz():\n return {\"status\": \"ok\"}\n\n\n_INDEX_PATH = Path(__file__).parent / \"index.html\"\n\n\n@app.get(\"/\")\nasync def root():\n \"\"\"Serve index.html as-is. Auth-gate bootstrap now lives inside the served\n voice-to-text.js (see shared_file), so the HTML needs no per-request rendering.\n \"\"\"\n return HTMLResponse(_INDEX_PATH.read_text(encoding=\"utf-8\"))\n\n\n# Explicit allowlist — only these two files are served from /shared/. Anything\n# else returns 404 before any filesystem probe, so URL-encoded path traversal\n# (../../etc/passwd, etc.) can't escape the bound. SHARED_DIR is resolved by\n# _resolve_shared_dir() above: tries ./shared/ (Docker layout) then ../shared/\n# (dev layout where apps/shared/ is a sibling), so both run modes serve the\n# same files without per-environment branching here.\nSHARED_FILES = {\n \"voice-to-text.js\": \"application/javascript\",\n \"croquet-dictionary.json\": \"application/json\",\n}\n\n\n@app.get(\"/shared/{filename}\")\nasync def shared_file(filename: str):\n from fastapi.responses import Response\n # Allowlist check first — anything not in the dict gets 404, no filesystem probe.\n if filename not in SHARED_FILES:\n raise HTTPException(status_code=404, detail=\"Not found\")\n content_type = SHARED_FILES[filename]\n file_path = SHARED_DIR / filename\n if not file_path.is_file():\n raise HTTPException(status_code=404, detail=\"Not found\")\n\n body = file_path.read_text(encoding=\"utf-8\")\n\n # SECURITY/UX: when serving voice-to-text.js, prepend a tiny bootstrap line\n # that sets window.VTT_EXTRA_HEADERS from the server-side CLEAN_SHARED_KEY.\n # Three wins:\n # 1. Auth gate flips via Coolify env alone — no consumer-page redeploys.\n # 2. Every consumer page (cross-domain or same-origin) gets the same\n # secret automatically — no per-app rendering, no drift.\n # 3. If CLEAN_SHARED_KEY is unset, an empty headers object is rendered\n # and the lib's whitelist filter is a no-op (dev mode).\n # The secret IS readable by anyone who view-sources the JS — that's the\n # accepted trade-off documented in the round-1 review. It still stops\n # drive-by curl/scripted abuse, which is the entire goal.\n if filename == \"voice-to-text.js\":\n bootstrap = (\n \"window.VTT_EXTRA_HEADERS=\"\n + json.dumps(\n {CLEAN_SHARED_KEY_HEADER: CLEAN_SHARED_KEY} if CLEAN_SHARED_KEY else {}\n )\n + \";\\n\"\n )\n body = bootstrap + body\n\n # CORS for public static assets: Access-Control-Allow-Origin: * so any\n # consumer page can