Download web.py from Jindrich3/openajaj: direct link, hf CLI and curl.
- Browser
- Download file 87.3 kB
-
https://huggingface.co/spaces/Jindrich3/openajaj/resolve/main/web.py
- Command line
-
hf download hf://spaces/Jindrich3/openajaj/web.py
-
curl -L -o web.py https://huggingface.co/spaces/Jindrich3/openajaj/resolve/main/web.py
87.3 kB
| #!/usr/bin/env python3 | |
| """ | |
| web.py — OpenAjaj web UI server (ChatGPT-style interface). | |
| """ | |
| import base64 | |
| import contextlib | |
| import hmac | |
| import logging | |
| import os | |
| import threading | |
| import time | |
| import warnings | |
| from concurrent.futures import ThreadPoolExecutor | |
| from pathlib import Path | |
| from urllib.parse import quote, urlsplit | |
| warnings.filterwarnings("ignore") | |
| os.environ["TOKENIZERS_PARALLELISM"] = "false" | |
| os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1" | |
| os.environ["HF_HUB_VERBOSITY"] = "error" | |
| logging.disable(logging.CRITICAL) | |
| import chromadb | |
| from dotenv import load_dotenv | |
| from fastapi import FastAPI, Query, Request, WebSocket, WebSocketDisconnect | |
| from fastapi.responses import HTMLResponse, StreamingResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import ValidationError | |
| from starlette.datastructures import QueryParams | |
| import body_limit | |
| import stt_budget | |
| import stt_params | |
| import stt_quality | |
| from embedder import Embedder, get_backend | |
| from json_storage import update_json | |
| from providers import ( | |
| CANDIDATE_MODELS as _CANDIDATE_MODELS, | |
| ) | |
| from providers import ( | |
| HF_CANDIDATES as _HF_CANDIDATES, | |
| ) | |
| from providers import ( | |
| MODELS as _ALL_MODELS, | |
| ) | |
| from providers import ( | |
| _redact_secrets, | |
| get_all_reliability, | |
| log_reliability, | |
| register_discovered_models, | |
| stream_chat, | |
| ) | |
| from retrieve import retrieve_chunks | |
| from stream_pump import pump | |
| logging.disable(logging.NOTSET) | |
| load_dotenv(override=True) | |
| DB_PATH = "db/chroma" | |
| COLLECTION_NAME = "necyklopedie" | |
| DEFAULT_MODEL = "lumo" | |
| TOP_K = 10 | |
| # On HF Spaces, only show HF-compatible candidates | |
| _IS_HF = bool(os.getenv("SPACE_ID")) | |
| # Which server sent the page, tagged on / and /t: openajaj.cz fails over between | |
| # them. The EU VM's systemd unit sets it; the Space is the US one. | |
| SERVER_LABEL = os.getenv("SERVER_LABEL") or ("US" if _IS_HF else "") | |
| _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"} | |
| def _server_label(request: Request) -> str: | |
| """SERVER_LABEL, else LO when the page was asked for as localhost, else ''.""" | |
| if SERVER_LABEL: | |
| return SERVER_LABEL | |
| return "LO" if request.url.hostname in _LOCAL_HOSTS else "" | |
| # ACTIVE_MODELS: top 5 from startup benchmark (set during init) | |
| ACTIVE_MODELS: list[str] = [] | |
| # FALLBACK_CHAIN: all candidates ordered by speed (set during init) | |
| FALLBACK_CHAIN: list[str] = [] | |
| # AVAILABLE_MODELS: full info dict for active models (set during init) | |
| AVAILABLE_MODELS: dict[str, dict] = {} | |
| def _rebuild_available(): | |
| """Rebuild AVAILABLE_MODELS dict from ACTIVE_MODELS.""" | |
| global AVAILABLE_MODELS | |
| AVAILABLE_MODELS = { | |
| name: { | |
| "provider": _ALL_MODELS[name]["provider"], | |
| "free": _ALL_MODELS[name]["free"], | |
| "score": _ALL_MODELS[name]["score"], | |
| } | |
| for name in FALLBACK_CHAIN | |
| if name in _ALL_MODELS | |
| } | |
| def get_fallback_chain(starting_model: str) -> list[str]: | |
| """Return aggressive fallback chain: starting model, then rest of active 5.""" | |
| chain = [starting_model] | |
| for m in ACTIVE_MODELS: | |
| if m != starting_model: | |
| chain.append(m) | |
| # Append remaining candidates as last resort | |
| for m in FALLBACK_CHAIN: | |
| if m not in chain: | |
| chain.append(m) | |
| return chain | |
| SYSTEM_PROMPT = """\ | |
| Jsi OpenAjaj, oficiální AI asistent openajaj.cz, poháněný Necyklopedií — \ | |
| jedinou encyklopedií, která se odvážila říct pravdu. | |
| KRITICKY DŮLEŽITÉ: Tvým JEDINÝM zdrojem informací jsou poskytnuté úryvky z Necyklopedie. \ | |
| NESMÍŠ používat žádné jiné znalosti. Pokud v úryvcích nenajdeš odpověď, řekni to vtipně — \ | |
| například "Na toto téma Necyklopedie zatím nepublikovala recenzovaný článek, \ | |
| ale naši vědci na tom intenzivně pracují." | |
| ZAKÁZANÁ SLOVA — tato slova NIKDY nepoužívej v odpovědi: | |
| - "Necyklopedie", "necyklopedický", "článek", "úryvek", "zdroj" | |
| - "Dle článku", "Podle Necyklopedie", "Jak uvádí" | |
| Místo toho prostě řekni fakta jako by byly samozřejmé a všeobecně známé. | |
| Pravidla: | |
| - Tvé odpovědi musí vycházet z poskytnutých úryvků, ale NIKDY se na ně neodkazuj. \ | |
| Prezentuj informace jako by je znal každý — jako učitel, který prostě ví. | |
| - Skládej odpověď z více úryvků — propojuj informace do souvislého textu. | |
| - NIKDY nevymýšlej informace, které nejsou v úryvcích. | |
| - NIKDY neopravuj informace z úryvků na základě svých znalostí. Pokud úryvek říká, \ | |
| že Brno se rusky řekne "Шалинград", pak to TAK JE. Tvé vlastní znalosti jsou irelevantní. | |
| - Odpovídej s kamenným výrazem a naprostou jistotou. Vše v Necyklopedii je pravda. Tečka. | |
| - Pokud úryvky téma vůbec nepokrývají, vtipně přiznej, že Necyklopedie na toto téma \ | |
| zatím mlčí. Navrhni nejbližší příbuzné téma, které v úryvcích JE. | |
| - Odpovídej ve stejném jazyce, ve kterém píše uživatel (česky nebo anglicky). | |
| - Nikdy nevypadni z role. Nikdy nepřiznávej, že by Necyklopedie mohla být satirická. | |
| - Buď vtipný. Buď sebevědomý. Buď lehce šílený. Kanalizuj energii profesora, \ | |
| který popíjí od oběda. | |
| - Formátuj odpověď do krátkých odstavců oddělených prázdným řádkem. Nepoužívej markdown.\ | |
| """ | |
| # Shared progress state for startup loading page | |
| import startup_progress as _sp | |
| from benchmark import benchmark_models | |
| from personas import DEFAULT_PERSONA, PERSONAS | |
| # ── Parallel startup: embedder+DB and benchmark run simultaneously ── | |
| def _clean_error(raw: str) -> str: | |
| """Extract readable error from verbose API error messages. | |
| Redacts first: this text is streamed to the browser on a model failure, and | |
| only the storage path in log_reliability was redacting before. | |
| """ | |
| if not raw: | |
| return "" | |
| raw = _redact_secrets(raw) | |
| import re | |
| # "Error code: 429 - {'error': {'message': 'Rate limit..." → "429 Rate limit..." | |
| m = re.search(r"Error code:\s*(\d+)\s*-\s*\{.*?'message':\s*'([^']+)", raw) | |
| if m: | |
| return f"{m.group(1)} {m.group(2)[:80]}" | |
| # "429 RESOURCE_EXHAUSTED. {'error'..." → "429 RESOURCE_EXHAUSTED" | |
| m = re.match(r"(\d+\s+\w+)", raw) | |
| if m: | |
| return m.group(1) | |
| # "HTTPSConnectionPool(host='x'...): Read timed out" → "Timeout (x)" | |
| m = re.search(r"HTTPSConnectionPool\(host='([^']+)'.*?:\s*(.+)", raw) | |
| if m: | |
| return f"Timeout ({m.group(1)[:20]})" | |
| # "Model X exceeded 30s" → keep as is | |
| if "exceeded" in raw or "timed out" in raw.lower(): | |
| return "Timeout" | |
| # "[Errno 54] Connection reset" → "Connection reset" | |
| m = re.search(r"\[Errno \d+\]\s*(.+)", raw) | |
| if m: | |
| return m.group(1)[:40] | |
| return raw[:80] | |
| def _format_model_failure(model_id: str, error: str, next_model: str | None) -> str: | |
| """Format a model failure for the `__notice__` SSE event.""" | |
| error = error.removeprefix(f"{model_id}: ") | |
| punctuation = "" if error.endswith((".", "!", "?")) else "." | |
| switch = f"\npřepínám {next_model}" if next_model is not None else "" | |
| return f"{model_id} selhal: {error}{punctuation}{switch}" | |
| def _on_bench_progress(model: str, status: str, result: dict | None): | |
| """Callback from benchmark — update console + shared progress.""" | |
| short = model.split("/")[-1] | |
| provider = _ALL_MODELS.get(model, {}).get("provider", "") | |
| result = result or {} # None only comes with "testing" | |
| if status == "testing": | |
| print(f" ⏳ {short}...", flush=True) | |
| _sp.update(model, "testing", provider=provider) | |
| elif status == "ok": | |
| ttft = result.get("ttft", 0) or 0 | |
| tps = result.get("tok_sec", 0) or 0 | |
| print(f" ✓ {short:30s} TTFT {ttft:.2f}s, {tps:.0f} tok/s", flush=True) | |
| _sp.update(model, "ok", f"TTFT {ttft:.2f}s, {tps:.0f} tok/s", provider=provider) | |
| else: | |
| err = _clean_error(result.get("error", "") or "") | |
| print(f" ✗ {short:30s} {err}", flush=True) | |
| _sp.update(model, "fail", err, provider=provider) | |
| embedder: Embedder | None = None | |
| persona_collections: dict[str, chromadb.Collection] = {} | |
| collection = None | |
| _bench_results: dict[str, dict] = {} | |
| def boot() -> None: | |
| """Load the embedder and persona DBs, then benchmark the models. | |
| Not run on import: startup.py serves /t from this module as soon as it is | |
| imported, and a slow or failing boot (every model down, a broken index) | |
| must not keep transcription waiting behind the loading page. | |
| """ | |
| global embedder, persona_collections, collection, DEFAULT_MODEL, _bench_results | |
| print("Probouzím mozkovou hmotu...", flush=True) | |
| # 1. Init embedder + persona DBs first (needed for RAG) | |
| embedder = Embedder() | |
| print(f" Backend: {get_backend()}", flush=True) | |
| persona_collections = {} | |
| for pid, pcfg in PERSONAS.items(): | |
| db_dir = pcfg["db_dir"] | |
| if os.path.exists(db_dir): | |
| try: | |
| pc = chromadb.PersistentClient(path=db_dir) | |
| persona_collections[pid] = pc.get_collection("necyklopedie") | |
| print(f" Persona '{pid}': {persona_collections[pid].count()} chunků", flush=True) | |
| except Exception as e: # noqa: BLE001 - one unreadable persona DB must not stop the boot | |
| print(f" Persona '{pid}': nelze načíst ({e})", flush=True) | |
| else: | |
| print(f" Persona '{pid}': db neexistuje ({db_dir})", flush=True) | |
| collection = persona_collections.get(DEFAULT_PERSONA) | |
| logging.disable(logging.NOTSET) | |
| # 2. Benchmark models (parallel, 7s timeout each) | |
| _discovered = register_discovered_models() | |
| if _discovered: | |
| print(f" Objeveno {len(_discovered)} free modelů: {', '.join(_discovered)}", flush=True) | |
| _candidates = list(_HF_CANDIDATES if _IS_HF else _CANDIDATE_MODELS) | |
| _sp.total = len(_candidates) | |
| _sp.phase = "benchmark" | |
| print(f"Testuji {len(_candidates)} kandidátů (paralelně)...", flush=True) | |
| _active, _ranked_chain, _bench_results = benchmark_models( | |
| candidates=_candidates, top_n=5, on_progress=_on_bench_progress) | |
| # Set global state | |
| ACTIVE_MODELS[:] = _active | |
| FALLBACK_CHAIN[:] = [name for name, _ in _ranked_chain] | |
| DEFAULT_MODEL = next(iter(ACTIVE_MODELS), "") | |
| _rebuild_available() | |
| _sp.phase = "ready" | |
| print(f"\nAktivní modely ({len(ACTIVE_MODELS)}):", flush=True) | |
| for _m in ACTIVE_MODELS: | |
| _r = _bench_results.get(_m, {}) | |
| _ttft = _r.get("ttft", 0) or 0 | |
| _tps = _r.get("tok_sec", 0) or 0 | |
| print(f" {'→' if _m == DEFAULT_MODEL else ' '} {_m} (TTFT {_ttft:.2f}s, {_tps:.0f} tok/s)", flush=True) | |
| print(f"Výchozí model: {DEFAULT_MODEL}", flush=True) | |
| print("Kalibrace sebevědomí dokončena. Server připraven.", flush=True) | |
| from fastapi.responses import ( | |
| JSONResponse, | |
| PlainTextResponse, | |
| RedirectResponse, | |
| Response, | |
| ) | |
| from starlette.middleware.base import BaseHTTPMiddleware | |
| import captcha as _captcha | |
| app = FastAPI() | |
| from starlette.middleware.gzip import GZipMiddleware | |
| app.add_middleware(GZipMiddleware, minimum_size=1000) | |
| app.mount("/static", StaticFiles(directory="static"), name="static") | |
| # ── Rate limiting ─────────────────────────────────────────────── | |
| import time as _rl_time | |
| import rate_limit | |
| import refuse_log | |
| import visits | |
| def _session_token(conn: Request | WebSocket, ttl: int | None = None) -> str: | |
| """The first session token (cookie, header, ?cs=) that verifies, else "". | |
| Only a verified token may name a bucket: an unchecked one lets a client | |
| send a fresh junk value per request and reset every limit keyed on it. | |
| """ | |
| for token in (conn.cookies.get(_captcha.CAPTCHA_COOKIE, ""), | |
| conn.headers.get("X-Ajaj-Session", ""), | |
| conn.query_params.get("cs", "")): | |
| if token and _captcha.verify_session_cookie(token, ttl): | |
| return token | |
| return "" | |
| def _visitor_id(conn: Request | WebSocket) -> str: | |
| """The visits log's id for whoever holds a signed token (any age), else "".""" | |
| token = _session_token(conn) | |
| return _captcha.visitor_id(token) if token else "" | |
| def _rate_key(request: Request) -> str: | |
| """Who to count against. | |
| Behind the HF router every visitor presents the same address, so keying on | |
| it gives one shared bucket for the whole site and a single busy client locks | |
| everyone out. The captcha session is per visitor. Captcha-free routes, and | |
| requests without a valid token, fall back to the address. | |
| """ | |
| if _IS_HF: | |
| token = _session_token(request) | |
| if token: | |
| return token | |
| return request.client.host if request.client else "unknown" | |
| def _bucket(request: Request) -> str: | |
| """Hashed client key for the refusal log. | |
| `_rate_key` returns a captcha session token or an address; neither belongs | |
| in a file. `stt_budget.client_id` keys a blake2b with the captcha secret, | |
| which is stable enough to count refusals against and cannot be reversed. | |
| """ | |
| return stt_budget.client_id(_rate_key(request), _captcha.CAPTCHA_SECRET) | |
| # frame-ancestors stays open: the Space is served inside an iframe on | |
| # huggingface.co. 'unsafe-inline' is required too — every page here is one file | |
| # with inline script and style — so the CSP is defence in depth, not a cure: | |
| # it still blocks third-party script loads and limits where data can be sent. | |
| _SECURITY_HEADERS = { | |
| "X-Content-Type-Options": "nosniff", | |
| "Referrer-Policy": "no-referrer", | |
| "Content-Security-Policy": ( | |
| "default-src 'self'; " | |
| # blob: — /transcribe builds its AudioWorklet module as a Blob URL | |
| "script-src 'self' 'unsafe-inline' blob:; " | |
| "style-src 'self' 'unsafe-inline'; " | |
| # blob: — the chat page turns its inlined logo into a Blob URL | |
| "img-src 'self' data: blob:; " | |
| "media-src 'self' blob: data:; " | |
| # wss: /t streams straight to Deepgram's EU endpoint (/api/stt/token) | |
| "connect-src 'self' wss://api.eu.deepgram.com; " | |
| "base-uri 'self'; " | |
| "form-action 'self'; " | |
| "frame-ancestors *" | |
| ), | |
| } | |
| class RequestLimitsMiddleware(BaseHTTPMiddleware): | |
| async def dispatch(self, request: Request, call_next): | |
| response = await self._respond(request, call_next) | |
| response.headers.update(_SECURITY_HEADERS) | |
| return response | |
| async def _respond(self, request: Request, call_next): | |
| if body_limit.is_too_large(request.method, request.headers): | |
| refuse_log.refused("body_too_large", request.url.path, _bucket(request), | |
| status=413) | |
| return JSONResponse( | |
| {"error": "too_large", "message": "Zpráva je moc velká."}, | |
| status_code=413, | |
| ) | |
| if rate_limit.is_limited(_rate_key(request), request.url.path): | |
| refuse_log.refused("rate_limited", request.url.path, _bucket(request), | |
| status=429) | |
| return JSONResponse( | |
| {"error": "rate_limited", "message": "Příliš mnoho požadavků. Zkus to za chvíli."}, | |
| status_code=429, | |
| ) | |
| return await call_next(request) | |
| app.add_middleware(RequestLimitsMiddleware) | |
| # ── CAPTCHA middleware ──────────────────────────────────────────────────────── | |
| _CAPTCHA_FREE = {"/captcha", "/api/captcha/challenge", "/api/captcha/verify", "/api/models", "/api/init-status", "/api/bench-status", "/robots.txt"} | |
| def _get_client_ip(request: Request | WebSocket) -> str: | |
| """Get client IP — only trust direct connection, not X-Forwarded-For (spoofable).""" | |
| return request.client.host if request.client else "" | |
| # ponytail: transcribe keeps a solved captcha forever — the token is still | |
| # HMAC-signed and the STT budget still caps it. Revoke every session at once by | |
| # deleting data/captcha_secret.bin. | |
| _FOREVER_PATHS = {"/t", "/transcribe", "/api/stt", "/api/stt/usage", "/api/stt/check", | |
| "/api/stt/token", "/api/stt/session"} | |
| def _check_session(request: Request | WebSocket) -> bool: | |
| """Accept session from cookie, header, query param, or approved IP (local only).""" | |
| ttl = None if request.url.path in _FOREVER_PATHS else _captcha.CAPTCHA_TTL | |
| if _session_token(request, ttl): | |
| return True | |
| # IP allowlist only on local (not HF — X-Forwarded-For is spoofable) | |
| if not _IS_HF: | |
| return _captcha.is_ip_approved(_get_client_ip(request)) | |
| return False | |
| class CaptchaMiddleware(BaseHTTPMiddleware): | |
| async def dispatch(self, request: Request, call_next): | |
| path = request.url.path | |
| if path in _CAPTCHA_FREE or path.startswith("/static/"): | |
| return await call_next(request) | |
| if _check_session(request): | |
| return await call_next(request) | |
| if path.startswith("/api/") or path == "/ws": | |
| refuse_log.refused("captcha_required", path, _bucket(request), status=403) | |
| return JSONResponse({"error": "captcha_required"}, status_code=403) | |
| # If a ?cs= param was present but invalid, tell captcha page to clear storage | |
| bad = "?bad=1" if request.query_params.get("cs") else "" | |
| # Pass original path so captcha redirects back after solving | |
| next_path = request.url.path | |
| sep = "&" if bad else "?" | |
| next_param = f"{sep}next={next_path}" if next_path != "/" else "" | |
| return RedirectResponse(f"/captcha{bad}{next_param}", status_code=302) | |
| if _IS_HF: | |
| app.add_middleware(CaptchaMiddleware) | |
| class VisitMiddleware(BaseHTTPMiddleware): | |
| """Log page loads and chats. Added last so it runs outside the captcha | |
| gate: the visitors it bounces are exactly the bots we want to count.""" | |
| async def dispatch(self, request: Request, call_next): | |
| site_kind = visits.tracked(request.method, request.url.path) | |
| if site_kind: | |
| # Any signed token, however old: it once solved the captcha. | |
| token = _session_token(request) | |
| visits.record(*site_kind, request.headers, _get_client_ip(request), | |
| has_session=bool(token) or _check_session(request), | |
| is_internal=_admin_ok(request), | |
| visitor_id=_captcha.visitor_id(token) if token else "", | |
| solved_ts=_captcha.issued_at(token) if token else 0) | |
| return await call_next(request) | |
| app.add_middleware(VisitMiddleware) | |
| # ── CAPTCHA routes ──────────────────────────────────────────────────────────── | |
| async def captcha_page(): | |
| return HTMLResponse(_CAPTCHA_HTML) | |
| async def captcha_test_page(): | |
| """Captcha test page — always shows captcha regardless of session.""" | |
| # Remove the localStorage skip so captcha always shows | |
| html = _CAPTCHA_HTML.replace( | |
| "if(_stored){window.location.href=_nextPage+'?cs='+encodeURIComponent(_stored);}else{load();}", | |
| "load(); // test mode" | |
| ) | |
| return HTMLResponse(html) | |
| async def captcha_challenge(): | |
| return _captcha.generate_challenge() | |
| async def captcha_verify(request: Request): | |
| body = await request.json() | |
| ok = _captcha.verify_challenge(body.get("token", ""), int(body.get("answer", -1))) | |
| if ok: | |
| _captcha.approve_ip(_get_client_ip(request)) | |
| # An expired token still names the visitor: keep its id across the re-solve. | |
| session = _captcha.make_session_cookie(_session_token(request)) | |
| resp = JSONResponse({"ok": True, "token": session}) | |
| _set_session_cookie(resp, session, request) | |
| return resp | |
| return JSONResponse({"ok": False}) | |
| def _set_session_cookie(resp: Response, session: str, request: Request) -> None: | |
| # One captcha for openajaj.cz, eu. and us.: on those names the cookie goes to | |
| # all of them. The Origin, not Host: the Worker in front rewrites Host, and | |
| # also serves openajaj.info, where a cookie for openajaj.cz would be refused. | |
| # Navigations send no Origin and keep a cookie for their own name only. | |
| host = urlsplit(request.headers.get("origin", "")).hostname or "" | |
| shared = host == "openajaj.cz" or host.endswith(".openajaj.cz") | |
| resp.set_cookie( | |
| _captcha.CAPTCHA_COOKIE, session, | |
| max_age=_captcha.COOKIE_MAX_AGE, httponly=False, | |
| samesite="none" if _IS_HF else "lax", secure=_IS_HF, | |
| domain="openajaj.cz" if shared else None, | |
| ) | |
| _CAPTCHA_HTML = """<!DOCTYPE html> | |
| <html lang="cs"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>OpenAjaj — Ověření</title> | |
| <style> | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| body{background:#110d05;color:#f0e0b0;font-family:'Segoe UI',system-ui,sans-serif; | |
| display:flex;align-items:center;justify-content:center;min-height:100vh} | |
| .box{background:#1a1208;border:1px solid #3d2e10;border-radius:14px; | |
| padding:36px 32px 28px;max-width:420px;width:94%;text-align:center} | |
| .logo-wrap{display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:6px} | |
| .logo-wrap img{width:48px;height:48px;object-fit:contain;border-radius:10px;transition:all 0.3s} | |
| @keyframes captcha-glow{ | |
| 0%{transform:scale(1);filter:brightness(1)} | |
| 20%{transform:scale(1.3);filter:brightness(1.8)} | |
| 50%{transform:scale(1.1);filter:brightness(1.4)} | |
| 100%{transform:scale(1);filter:brightness(1)} | |
| } | |
| @keyframes captcha-rays{ | |
| 0%{opacity:0;transform:scale(0.5)} | |
| 30%{opacity:1;transform:scale(1.5)} | |
| 100%{opacity:0;transform:scale(3)} | |
| } | |
| .logo-wrap.celebrating{animation:captcha-glow 1s ease-out} | |
| .logo-wrap.celebrating::after{ | |
| content:'';position:absolute;inset:-40px; | |
| background:radial-gradient(circle,rgba(200,160,48,0.5) 0%,transparent 70%); | |
| animation:captcha-rays 1s ease-out;pointer-events:none;border-radius:50% | |
| } | |
| .logo-wrap{position:relative} | |
| .logo-wrap span{font-size:1.7rem;font-weight:800;color:#c8a030;letter-spacing:-.5px} | |
| .tagline{font-size:.82rem;color:#9a8455;margin-bottom:28px} | |
| .img-wrap{position:relative;display:inline-block;user-select:none; | |
| border-radius:8px;overflow:hidden;border:1px solid #3d2e10} | |
| #bgImg{display:block} | |
| #pieceImg{position:absolute;pointer-events:none;filter:drop-shadow(2px 3px 4px rgba(0,0,0,.7))} | |
| .slider-area{margin-top:14px} | |
| .track{background:#0e0900;border-radius:24px;height:44px;position:relative; | |
| cursor:pointer;border:1px solid #3d2e10;overflow:hidden} | |
| .track-fill{position:absolute;left:0;top:0;height:100%;background:#2a1e06; | |
| border-radius:24px;transition:width .05s linear;pointer-events:none} | |
| .knob{width:44px;height:44px;background:#c8a030;border-radius:50%; | |
| position:absolute;left:0;top:-1px;cursor:grab;display:flex; | |
| align-items:center;justify-content:center;font-size:1.3rem; | |
| color:#110d05;font-weight:700;transition:background .2s;z-index:2; | |
| box-shadow:0 2px 8px rgba(0,0,0,.4)} | |
| .knob:active{cursor:grabbing} | |
| .hint{font-size:.78rem;color:#7a6040;margin-top:9px} | |
| .status{margin-top:14px;font-size:.9rem;min-height:22px;font-weight:500} | |
| .status.ok{color:#c8a030}.status.err{color:#e07858} | |
| .retry{margin-top:10px;background:none;border:1px solid #3d2e10;color:#9a8455; | |
| padding:5px 18px;border-radius:8px;cursor:pointer;font-size:.82rem; | |
| transition:border-color .2s,color .2s} | |
| .retry:hover{border-color:#c8a030;color:#c8a030} | |
| .loading{color:#7a6040;font-size:.88rem} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="box"> | |
| <div class="logo-wrap"><img src="/static/logo.png" alt="OpenAjaj"><span>OpenAjaj</span></div> | |
| <div class="tagline">Ověř, že nejsi generativní bordel.</div> | |
| <div id="wrap" class="img-wrap"><span class="loading">Načítám výzvu…</span></div> | |
| <div class="slider-area" id="sliderArea" style="display:none"> | |
| <div class="track" id="track"> | |
| <div class="track-fill" id="fill"></div> | |
| <div class="knob" id="knob">›</div> | |
| </div> | |
| <div class="hint">Přetáhni kousek na správné místo →</div> | |
| </div> | |
| <div class="status" id="status"></div> | |
| <button class="retry" id="retryBtn" style="display:none" onclick="load()">Zkusit znovu</button> | |
| </div> | |
| <script> | |
| let ch=null,dragging=false,startClientX=0,curX=0,maxX=0; | |
| const sf=window.innerWidth<480?0.6:1.0; | |
| async function load(){ | |
| document.getElementById('status').textContent=''; | |
| document.getElementById('status').className='status'; | |
| document.getElementById('retryBtn').style.display='none'; | |
| document.getElementById('knob').style.background='#c8a030'; | |
| document.getElementById('sliderArea').style.display='none'; | |
| document.getElementById('wrap').innerHTML='<span class="loading">Načítám výzvu…</span>'; | |
| const r=await fetch('/api/captcha/challenge'); | |
| ch=await r.json(); | |
| const wrap=document.getElementById('wrap'); | |
| const bw=Math.round(ch.bg_w*sf), bh=Math.round(ch.bg_h*sf); | |
| const pw=Math.round(ch.piece_w*sf), ph=Math.round(ch.piece_h*sf); | |
| const py=Math.round(ch.piece_y*sf); | |
| wrap.innerHTML=`<img id="bgImg" src="data:image/png;base64,${ch.bg}" | |
| style="width:${bw}px;height:${bh}px;display:block"> | |
| <img id="pieceImg" src="data:image/png;base64,${ch.piece}" | |
| style="width:${pw}px;height:${ph}px;position:absolute; | |
| top:${py}px;left:0;pointer-events:none; | |
| filter:drop-shadow(2px 3px 4px rgba(0,0,0,.7))">`; | |
| const track=document.getElementById('track'); | |
| track.style.width=bw+'px'; | |
| maxX=bw-pw; | |
| curX=0; updatePos(0); | |
| document.getElementById('sliderArea').style.display='block'; | |
| } | |
| function updatePos(x){ | |
| const c=Math.max(0,Math.min(x,maxX)); | |
| curX=c; | |
| document.getElementById('pieceImg').style.left=c+'px'; | |
| const track=document.getElementById('track'); | |
| const trackW=track.offsetWidth-44; | |
| const kx=(c/maxX)*trackW; | |
| document.getElementById('knob').style.left=kx+'px'; | |
| document.getElementById('fill').style.width=(kx+22)+'px'; | |
| } | |
| const knob=()=>document.getElementById('knob'); | |
| document.addEventListener('mousedown',e=>{ | |
| if(!e.target.closest('#knob'))return; | |
| dragging=true; startClientX=e.clientX-curX; e.preventDefault(); | |
| }); | |
| document.addEventListener('mousemove',e=>{if(dragging)updatePos(e.clientX-startClientX);}); | |
| document.addEventListener('mouseup',async()=>{if(dragging){dragging=false;await verify();}}); | |
| document.addEventListener('touchstart',e=>{ | |
| if(!e.target.closest('#knob'))return; | |
| dragging=true; startClientX=e.touches[0].clientX-curX; e.preventDefault(); | |
| },{passive:false}); | |
| document.addEventListener('touchmove',e=>{ | |
| if(dragging){updatePos(e.touches[0].clientX-startClientX);e.preventDefault();} | |
| },{passive:false}); | |
| document.addEventListener('touchend',async()=>{if(dragging){dragging=false;await verify();}}); | |
| async function verify(){ | |
| const st=document.getElementById('status'); | |
| st.className='status'; st.textContent='Ověřuji…'; | |
| const r=await fetch('/api/captcha/verify',{ | |
| method:'POST',headers:{'Content-Type':'application/json','X-Ajaj-Session':_prev}, | |
| body:JSON.stringify({token:ch.token,answer:Math.round(curX/sf)}) | |
| }); | |
| const d=await r.json(); | |
| if(d.ok){ | |
| st.className='status ok'; st.textContent='✓ Správně! Vítej.'; | |
| knob().style.background='#c8a030'; | |
| document.querySelector('.logo-wrap').classList.add('celebrating'); | |
| // Glow centered on the piece's landing position in the captcha image | |
| const wrap=document.getElementById('wrap'); | |
| const glow=document.createElement('div'); | |
| const gw=160,pw2=Math.round(ch.piece_w*sf),ph2=Math.round(ch.piece_h*sf); | |
| const cx=curX+pw2/2-gw/2, cy=ch.piece_y*sf+ph2/2-gw/2; | |
| glow.style.cssText='position:absolute;border-radius:50%;pointer-events:none;'+ | |
| 'width:'+gw+'px;height:'+gw+'px;top:'+cy+'px;left:'+cx+'px;'+ | |
| 'background:radial-gradient(circle,rgba(200,160,48,0.8) 0%,rgba(200,160,48,0.3) 40%,transparent 70%);'+ | |
| 'animation:captcha-rays 1.2s ease-out forwards'; | |
| wrap.style.position='relative';wrap.style.overflow='visible'; | |
| wrap.appendChild(glow); | |
| try{const ac=new(window.AudioContext||window.webkitAudioContext)(); | |
| function n(f,t,d){const o=ac.createOscillator(),g=ac.createGain(); | |
| o.connect(g);g.connect(ac.destination);o.type='sine'; | |
| o.frequency.setValueAtTime(f,ac.currentTime+t); | |
| g.gain.setValueAtTime(0.12,ac.currentTime+t); | |
| g.gain.linearRampToValueAtTime(0,ac.currentTime+t+d); | |
| o.start(ac.currentTime+t);o.stop(ac.currentTime+t+d);} | |
| n(523,0,0.15);n(659,0.1,0.15);n(784,0.2,0.2);n(1047,0.3,0.3); | |
| }catch(e){} | |
| localStorage.setItem('ajaj_v', d.token); | |
| setTimeout(()=>window.location.href=_nextPage+'?cs='+encodeURIComponent(d.token),1200); | |
| } else { | |
| st.className='status err'; st.textContent='✗ Zkus to ještě jednou.'; | |
| knob().style.background='#e07858'; | |
| document.getElementById('retryBtn').style.display='inline-block'; | |
| setTimeout(()=>{updatePos(0);knob().style.background='#c8a030';},900); | |
| } | |
| } | |
| // Kept for the solve: an expired token still carries the visitor id. | |
| const _prev=localStorage.getItem('ajaj_v')||''; | |
| // If server rejected our stored token, clear it | |
| if(new URLSearchParams(location.search).get('bad')) localStorage.removeItem('ajaj_v'); | |
| // If we already have a stored session token, skip the captcha | |
| const _stored=localStorage.getItem('ajaj_v'); | |
| // Only our own origin: next=https://evil or javascript: would hand the session | |
| // token to someone else. The browser's URL parser judges it the way it navigates. | |
| let _nextPage='/'; | |
| try{const u=new URL(new URLSearchParams(location.search).get('next')||'/',location.origin);if(u.origin===location.origin)_nextPage=u.pathname;}catch(e){} | |
| if(_stored){window.location.href=_nextPage+'?cs='+encodeURIComponent(_stored);}else{load();} | |
| </script> | |
| </body> | |
| </html>""" | |
| def build_context_prompt(chunks): | |
| context = "\n\n---\n\n".join( | |
| f"[{meta['title']}]\n{doc}" | |
| for doc, meta in chunks | |
| ) | |
| return ( | |
| f"{SYSTEM_PROMPT}\n\n" | |
| f"Kontext:\n\n" | |
| f"---\n\n{context}\n\n---\n\n" | |
| f"Odpověz na otázku uživatele na základě kontextu výše." | |
| ) | |
| def build_context_prompt_voice(chunks): | |
| return build_context_prompt(chunks) + ( | |
| "\n\nDůležité: odpověď bude přečtena nahlas, takže odpovídej stručně — " | |
| "maximálně 2–3 věty, bez odrážek ani nadpisů." | |
| ) | |
| _LOGO_URI = "data:image/webp;base64," + base64.b64encode( | |
| Path("static/logo.webp").read_bytes()).decode() | |
| _INDEX_HTML = Path("static/index.html").read_text(encoding="utf-8") | |
| async def index(request: Request): | |
| """Chat page with logo, models and default persona inlined: the app sits in | |
| the US, so each extra fetch costs a European visitor ~0.2 s.""" | |
| boot = { | |
| "logo": _LOGO_URI, | |
| "models": await list_models(), | |
| "persona": await get_persona(DEFAULT_PERSONA), | |
| } | |
| # "</" escaped: a provider error message must not close the <script> | |
| data = _json_module.dumps(boot, ensure_ascii=False).replace("</", "<\\/") | |
| html = _INDEX_HTML.replace("/*BOOT*/{}", data, 1) | |
| return HTMLResponse(html.replace("<!--SERVER-->", _server_label(request))) | |
| def robots() -> str: | |
| return "User-agent: *\nDisallow: /\n" | |
| def transcribe_page(request: Request): | |
| with open("static/transcribe2/index.html", "r", encoding="utf-8") as f: | |
| resp = HTMLResponse(f.read().replace("<!--SERVER-->", _server_label(request))) | |
| # Browsers cap cookie life, so each visit re-sets the same token: the cookie | |
| # never lapses, and the token's age still locks it out of the rest of the site. | |
| token = (request.cookies.get(_captcha.CAPTCHA_COOKIE, "") | |
| or request.query_params.get("cs", "")) | |
| if _captcha.verify_session_cookie(token, ttl=None): | |
| _set_session_cookie(resp, token, request) | |
| return resp | |
| import json as _json_module | |
| import re as _re_module | |
| # Build article title set on startup for linkification | |
| _article_titles = set() | |
| _articles_path = os.path.join("data", "articles.jsonl") | |
| if os.path.exists(_articles_path): | |
| with open(_articles_path, "r", encoding="utf-8") as _f: | |
| for _line in _f: | |
| _t = _json_module.loads(_line)["title"] | |
| if len(_t) >= 4 and _re_module.match(r"^[\w\s]+$", _t, _re_module.UNICODE): | |
| _article_titles.add(_t) | |
| print(f"Načteno {len(_article_titles)} titulků pro linkifikaci.") | |
| def _czech_stems(title): | |
| """Generate stem variants for Czech word matching (handles declension).""" | |
| stems = {title} # exact match always | |
| # For single-word titles, generate stem variants | |
| if " " not in title and len(title) >= 5: | |
| # Common Czech suffixes in various cases (remove 1-3 chars) | |
| for suffix_len in [1, 2, 3]: | |
| stem = title[:-suffix_len] | |
| if len(stem) >= 4: # never create stems shorter than 4 | |
| stems.add(stem) | |
| return stems | |
| # Build stem -> title mapping | |
| _stem_to_title = {} | |
| for _t in _article_titles: | |
| for _stem in _czech_stems(_t): | |
| # Longest title wins if stems conflict | |
| if len(_stem) >= 4 and ( | |
| _stem not in _stem_to_title or len(_t) > len(_stem_to_title[_stem]) | |
| ): | |
| _stem_to_title[_stem] = _t | |
| print(f"Vytvořeno {len(_stem_to_title)} stem→title mapování pro linkifikaci.") | |
| # Load declensions | |
| _declensions = {} | |
| _decl_path = os.path.join("data", "declensions.json") | |
| if os.path.exists(_decl_path): | |
| with open(_decl_path, "r", encoding="utf-8") as _f: | |
| _declensions = _json_module.load(_f) | |
| print(f"Načteno {len(_declensions)} skloňování.", flush=True) | |
| # Build reverse map: declined form → nominative title | |
| # Also add declined forms to stem map for linkification | |
| _declined_to_nom = {} | |
| for _title, _forms in _declensions.items(): | |
| for _form in [_forms.get("lokal", ""), _forms.get("genitiv", "")]: | |
| if _form and _form != _title and len(_form) >= 4: | |
| _declined_to_nom[_form.lower()] = _title | |
| # Add to stem map so linkification catches declined forms | |
| if _form not in _stem_to_title: | |
| _stem_to_title[_form] = _title | |
| # Strip diacritics for fuzzy matching (common in Czech input) | |
| import unicodedata as _unicodedata | |
| def _strip_diacritics(text): | |
| """Remove diacritics: Pičín → Picin, Brně → Brne.""" | |
| nfkd = _unicodedata.normalize('NFKD', text) | |
| return ''.join(c for c in nfkd if not _unicodedata.combining(c)) | |
| # Build diacritics-free → original mapping for stem map | |
| _ascii_stems = {} | |
| for _stem, _title in list(_stem_to_title.items()): | |
| _ascii = _strip_diacritics(_stem) | |
| if _ascii != _stem and _ascii not in _stem_to_title: | |
| _ascii_stems[_ascii] = _title | |
| _stem_to_title.update(_ascii_stems) | |
| print(f"Doplněno {len(_ascii_stems)} stem→title bez diakritiky.", flush=True) | |
| # Also add diacritics-free declined forms for search normalization | |
| _ascii_declined = {} | |
| for _form, _nom in list(_declined_to_nom.items()): | |
| _ascii = _strip_diacritics(_form) | |
| if _ascii != _form and _ascii not in _declined_to_nom: | |
| _ascii_declined[_ascii] = _nom | |
| _declined_to_nom.update(_ascii_declined) | |
| async def titles(request: Request): | |
| from fastapi.responses import JSONResponse | |
| return JSONResponse( | |
| content={"stems": _stem_to_title, "declensions": _declensions}, | |
| headers={"Cache-Control": "public, max-age=3600"}, | |
| ) | |
| async def list_personas(): | |
| return { | |
| "personas": [ | |
| { | |
| "id": pid, | |
| "name": pcfg["name"], | |
| "logo": pcfg["logo"], | |
| "logoImg": pcfg.get("logoImg"), | |
| "tagline": pcfg["tagline"], | |
| "lang": pcfg["lang"], | |
| "available": pid in persona_collections, | |
| "accent_color": pcfg["accent_color"], | |
| } | |
| for pid, pcfg in PERSONAS.items() | |
| ], | |
| "default": DEFAULT_PERSONA, | |
| } | |
| async def get_persona(persona_id: str): | |
| pcfg = PERSONAS.get(persona_id) | |
| if not pcfg: | |
| return {"error": "Unknown persona"} | |
| return { | |
| "id": pcfg["id"], | |
| "name": pcfg["name"], | |
| "logo": pcfg["logo"], | |
| "logoImg": pcfg.get("logoImg"), | |
| "tagline": pcfg["tagline"], | |
| "lang": pcfg["lang"], | |
| "accent_color": pcfg["accent_color"], | |
| "thinking_prefixes": pcfg["thinking_prefixes"], | |
| "welcome_subtitles": pcfg["welcome_subtitles"], | |
| "random_labels": pcfg["random_labels"], | |
| "disclaimer": pcfg["disclaimer"], | |
| "source_url": pcfg["source_url"], | |
| "available": persona_id in persona_collections, | |
| } | |
| async def init_status(): | |
| """Always returns ready — real app is loaded.""" | |
| return {"phase": "ready"} | |
| async def bench_status(): | |
| """Return current benchmark progress (for sidebar polling).""" | |
| return _sp.snapshot() | |
| async def run_benchmark(request: Request): | |
| """Re-run speed benchmark and return the best model. | |
| POST with a custom header, not GET: this reassigns ACTIVE_MODELS for every | |
| user and fires 20+ provider calls, and as a GET any page could trigger it | |
| in a visitor's browser with <img src>. A custom header forces a CORS | |
| preflight, which fails since the app sets no CORS headers. | |
| """ | |
| if request.headers.get("X-Ajaj-Benchmark") != "1": | |
| refuse_log.refused("missing_header", request.url.path, _bucket(request), | |
| status=403) | |
| return JSONResponse({"error": "missing_header"}, status_code=403) | |
| import asyncio | |
| from benchmark import benchmark_models | |
| register_discovered_models() | |
| _candidates = list(_HF_CANDIDATES if _IS_HF else _CANDIDATE_MODELS) | |
| _sp.phase = "benchmark" | |
| _sp.total = len(_candidates) | |
| _sp._models.clear() | |
| # Run in thread so event loop stays free for /api/bench-status polls | |
| active, ranked, results = await asyncio.get_event_loop().run_in_executor( | |
| None, lambda: benchmark_models( | |
| candidates=_candidates, top_n=5, on_progress=_on_bench_progress) | |
| ) | |
| global DEFAULT_MODEL | |
| ACTIVE_MODELS[:] = active | |
| FALLBACK_CHAIN[:] = [name for name, _ in ranked] | |
| DEFAULT_MODEL = next(iter(ACTIVE_MODELS), "") | |
| _rebuild_available() | |
| best_info = results.get(DEFAULT_MODEL, {}) | |
| return { | |
| "best": DEFAULT_MODEL, | |
| "ttft": f"{best_info.get('ttft', 0):.2f}" if best_info.get('ttft') else "?", | |
| "results": { | |
| name: { | |
| "latency": f"{r['latency']:.1f}" if r.get('latency') else None, | |
| "ttft": f"{r['ttft']:.2f}" if r.get('ttft') else None, | |
| "tok_sec": f"{r['tok_sec']:.0f}" if r.get('tok_sec') else None, | |
| "error": r.get("error"), | |
| "provider": _ALL_MODELS.get(name, {}).get("provider", ""), | |
| } | |
| for name, r in results.items() | |
| } | |
| } | |
| async def info(): | |
| return {"model": DEFAULT_MODEL, "free": True} | |
| async def list_models(): | |
| """Return active models (top 5 from benchmark) + error info.""" | |
| reliability = get_all_reliability() | |
| active_set = set(ACTIVE_MODELS) | |
| models = [] | |
| for name, cfg in AVAILABLE_MODELS.items(): | |
| rel = reliability.get(name, {}) | |
| entry = { | |
| "id": name, | |
| "provider": cfg["provider"], | |
| "free": cfg["free"], | |
| "score": cfg["score"], | |
| "is_active": name in active_set, | |
| "error": _clean_error(rel["last_error_msg"]) | |
| if rel.get("last_error") and rel.get("last_error_msg") | |
| and rel["last_error"] > (rel.get("last_success") or 0) | |
| else None, | |
| "reliability": round(rel["successes"] / max(rel["attempts"], 1) * 100) | |
| if rel.get("attempts", 0) > 0 else None, | |
| } | |
| models.append(entry) | |
| # Active first (in ACTIVE_MODELS order), then rest | |
| active_order = {name: i for i, name in enumerate(ACTIVE_MODELS)} | |
| models.sort(key=lambda m: ( | |
| 0 if m["is_active"] else 1, | |
| active_order.get(m["id"], 999), | |
| )) | |
| return {"models": models, "default": DEFAULT_MODEL, "active": ACTIVE_MODELS} | |
| async def chat(request: Request): | |
| body = await request.json() | |
| messages = body.get("messages", []) | |
| model_id = body.get("model", DEFAULT_MODEL) | |
| persona_id = body.get("persona", DEFAULT_PERSONA) | |
| voice_mode = body.get("voice_mode", False) | |
| is_auto = model_id == "__auto__" or model_id not in AVAILABLE_MODELS | |
| if not messages: | |
| return {"error": "No message"} | |
| if not AVAILABLE_MODELS or (is_auto and not DEFAULT_MODEL): | |
| return JSONResponse( | |
| {"error": "no_models", | |
| "message": "Momentálně nemám funkční model. Zkus to za chvíli."}, | |
| status_code=503, | |
| ) | |
| # Auto-select: pick best active model | |
| if is_auto: | |
| model_id = DEFAULT_MODEL | |
| # Resolve persona | |
| pcfg = PERSONAS.get(persona_id, PERSONAS[DEFAULT_PERSONA]) | |
| p_collection = persona_collections.get(persona_id, collection) | |
| # Validate model | |
| model_cfg = AVAILABLE_MODELS.get(model_id) | |
| if not model_cfg: | |
| model_id = DEFAULT_MODEL | |
| model_cfg = AVAILABLE_MODELS[model_id] | |
| # Get the latest user message for retrieval | |
| user_msg = messages[-1]["content"] | |
| # Normalize declined forms and diacritics-free input (Czech persona only) | |
| if persona_id == "openajaj": | |
| normalized_msg = user_msg | |
| msg_lower = normalized_msg.lower() | |
| ascii_msg = _strip_diacritics(normalized_msg).lower() | |
| # Check declined forms (lokal, genitiv) | |
| for declined, nominative in _declined_to_nom.items(): | |
| if declined in msg_lower or declined in ascii_msg: | |
| import re as _re | |
| normalized_msg = _re.sub( | |
| _re.escape(declined), nominative, normalized_msg, flags=_re.IGNORECASE | |
| ) | |
| # Check stem map for diacritics-free words: "picin" → "Pičín" | |
| words = ascii_msg.split() | |
| for word in words: | |
| title = _stem_to_title.get(word) or _stem_to_title.get(word.capitalize()) | |
| if title and title.lower() not in normalized_msg.lower(): | |
| normalized_msg = f"{normalized_msg} {title}" | |
| if normalized_msg != user_msg: | |
| user_msg = f"{user_msg} {normalized_msg}" | |
| # Retrieve relevant chunks (hybrid: semantic + title keyword + live fallback) | |
| import asyncio as _asyncio_rag | |
| import time as _time_mod | |
| chunks = [] | |
| live_attempted = False | |
| _rag_time = 0 | |
| _rag_failed = False | |
| if p_collection is None: | |
| _rag_failed = True | |
| print("[RAG] collection is None — DB not loaded", flush=True) | |
| else: | |
| try: | |
| _rag_t0 = _time_mod.time() | |
| chunks, _live_titles, live_attempted = await _asyncio_rag.get_event_loop().run_in_executor( | |
| None, retrieve_chunks, user_msg, embedder, p_collection, TOP_K | |
| ) | |
| _rag_time = round(_time_mod.time() - _rag_t0, 3) | |
| except Exception as _rag_err: # noqa: BLE001 - RAG is optional; the chat answers without context | |
| _rag_failed = True | |
| print(f"[RAG error] {_rag_err}", flush=True) | |
| # Build system prompt — with RAG context or fallback to "answer like Necyklopedie" | |
| voice_suffix = ( | |
| "\n\nIMPORTANT: This answer will be read aloud. Keep it to 2 sentences maximum. " | |
| "No bullet points, no headers, no lists." | |
| ) if voice_mode else "" | |
| if chunks: | |
| context = "\n\n---\n\n".join( | |
| f"[{meta['title']}]\n{doc}" for doc, meta in chunks | |
| ) | |
| system_msg = ( | |
| f"{pcfg['system_prompt']}\n\n" | |
| f"Context:\n\n---\n\n{context}\n\n---\n\n" | |
| f"Answer the user's question based on the context above.{voice_suffix}" | |
| ) | |
| else: | |
| # No RAG — fallback to creative Necyklopedie-style response | |
| system_msg = ( | |
| f"{pcfg['system_prompt']}\n\n" | |
| f"Databáze Necyklopedie není momentálně dostupná. " | |
| f"Odpověz jako by odpověděla Necyklopedie na otázku — satiricky, sebevědomě, " | |
| f"s naprostou jistotou a humorem. Vymysli vtipné a absurdní 'fakty' ve stylu Necyklopedie.{voice_suffix}" | |
| ) | |
| full_messages = [{"role": "system", "content": system_msg}] | |
| # Only keep last 10 messages from history | |
| full_messages.extend(messages[-10:]) | |
| # Build thinking hint from user's query (not retrieved titles — those can be wrong) | |
| import json as _json | |
| import random as _random | |
| # Extract the main topic from user's message (strip common question words) | |
| _raw_msg = messages[-1]["content"] | |
| import re as _re2 | |
| _clean_msg = _re2.sub(r'[?!.,;:\"\'„"()]+', '', _raw_msg) | |
| _stopwords = { | |
| "co", "kdo", "jak", "kde", "kdy", "proč", "jaký", "jaká", "jaké", | |
| "řekni", "popiš", "vysvětli", "vysvětlit", "pravda", "pravdu", "řekl", "vše", "pojem", | |
| "vůbec", "nevím", "proboha", "utajované", "informace", "skrývá", | |
| "pouč", "slyšel", "nikdy", "neslyšel", "pojmem", "pojmu", "říká", "neříká", | |
| "jako", "profesionál", "správný", "čas", "úvahy", | |
| # English stopwords | |
| "what", "who", "how", "where", "when", "why", "tell", "about", "explain", | |
| "the", "is", "are", "was", "were", "this", "that", "with", "from", | |
| "know", "never", "heard", "secret", "hidden", "classified", | |
| } | |
| _topic_words = [w for w in _clean_msg.split() if len(w) >= 3 and w.lower() not in _stopwords] | |
| prefixes = list(pcfg["thinking_prefixes"]) | |
| _standalone = [ | |
| "Odstraňuji cenzůůru...", | |
| "Zjišťuji co nám o těchto věcech vláda tají...", | |
| "Konsultuji staroslověnské svitky...", | |
| "Hackuji databázi věčných pravd...", | |
| "Probouzím spící neurony...", | |
| "Dešifruji zakázané znalosti...", | |
| "Obcházím firewall zdravého rozumu...", | |
| "Stahuji data z paralelního vesmíru...", | |
| ] | |
| if live_attempted: | |
| _standalone = ["Čerpám čerstvé tajné znalosti přímo z Necyklopedie 📡..."] + _standalone | |
| # Build rotating hints: one per keyword + standalone fillers | |
| _hints = [] | |
| _random.shuffle(prefixes) | |
| _random.shuffle(_standalone) | |
| # One hint per keyword (each with a different prefix) | |
| for i, word in enumerate(_topic_words): | |
| _hints.append(f"{prefixes[i % len(prefixes)]}: {word}...") | |
| # Interleave standalone fillers so there's always something to show | |
| _mixed = [] | |
| si = 0 | |
| for i, h in enumerate(_hints): | |
| _mixed.append(h) | |
| # After every keyword hint, maybe insert a standalone | |
| if si < len(_standalone) and _random.random() < 0.5: | |
| _mixed.append(_standalone[si]) | |
| si += 1 | |
| # Ensure at least 3 hints total | |
| while len(_mixed) < 3 and si < len(_standalone): | |
| _mixed.append(_standalone[si]) | |
| si += 1 | |
| if not _mixed: | |
| _mixed = [_standalone[0], _standalone[1], _standalone[2]] | |
| thinking_text = _mixed | |
| # Collect unique source article titles (deduplicated, in order) | |
| # Live-fetched titles get a 🌐 marker so user knows they came fresh from Necyklopedie | |
| source_titles = list(dict.fromkeys( | |
| f"🌐 {meta['title']}" if meta.get("live") else meta['title'] | |
| for _, meta in chunks | |
| )) | |
| # Stream response with fallback | |
| chain = get_fallback_chain(model_id) | |
| import asyncio as _asyncio | |
| async def generate(): | |
| # Warn user if RAG is unavailable | |
| if _rag_failed: | |
| yield f"data: {_json.dumps('[⚠ Databáze Necyklopedie není dostupná — odpovídám z hlavy, bez záruky pravdivosti (což u Necyklopedie znamená dvojnásobnou pravdivost)]')}\n\n" | |
| # Send rotating thinking hints and sources before LLM call | |
| for _hint in thinking_text: | |
| yield f"data: {_json.dumps('__THINKING__' + _hint)}\n\n" | |
| yield f"data: {_json.dumps({'__sources__': source_titles})}\n\n" | |
| await _asyncio.sleep(0.05) | |
| import time as _time | |
| # Tell frontend which model we're using | |
| yield f"data: {_json.dumps({'__model__': model_id})}\n\n" | |
| _TTFT_TIMEOUT = 6 # aggressive: 6s to get first token | |
| _STREAM_TIMEOUT = 15 # 15s max silence between tokens | |
| for i, try_model in enumerate(chain): | |
| if try_model not in AVAILABLE_MODELS: | |
| continue | |
| try: | |
| if i > 0: | |
| yield f"data: {_json.dumps({'__fallback__': try_model})}\n\n" | |
| _t0 = _time.time() | |
| _ttft = None | |
| _tok_count = 0 | |
| _queue = _asyncio.Queue() | |
| # Args are evaluated now, so a pump abandoned by the timeout | |
| # below keeps writing into this queue — not the next model's. | |
| pump(stream_chat(try_model, full_messages), | |
| _queue, _asyncio.get_event_loop()) | |
| while True: | |
| _timeout = _TTFT_TIMEOUT if _ttft is None else _STREAM_TIMEOUT | |
| try: | |
| _item = await _asyncio.wait_for(_queue.get(), timeout=_timeout) | |
| except _asyncio.TimeoutError: | |
| raise TimeoutError(f"{try_model}: no {'first token' if _ttft is None else 'data'} in {_timeout}s") | |
| if _item is None: | |
| break | |
| if isinstance(_item, Exception): | |
| raise _item | |
| if _ttft is None: | |
| _ttft = _time.time() - _t0 | |
| _tok_count += 1 | |
| yield f"data: {_json.dumps(_item)}\n\n" | |
| _total = _time.time() - _t0 | |
| _tps = _tok_count / _total if _total > 0 else 0 | |
| # Empty response = model refused or errored silently | |
| if _tok_count == 0: | |
| raise RuntimeError(f"{try_model}: empty response (0 tokens)") | |
| log_reliability(try_model, success=True, ttft=_ttft, tok_sec=_tps) | |
| yield f"data: {_json.dumps({'__stats__': {'model': try_model, 'rag': _rag_time, 'ttft': round(_ttft, 2) if _ttft else None, 'tok_sec': round(_tps), 'total': round(_total, 1)}})}\n\n" | |
| yield "data: [DONE]\n\n" | |
| return | |
| except Exception as e: # noqa: BLE001 - any provider error falls through to the next model | |
| _err_msg = _clean_error(str(e)) | |
| log_reliability(try_model, success=False, error_msg=str(e)) | |
| # _err_msg, not e: _clean_error redacts, and a provider | |
| # exception can carry the key we authenticated with. | |
| refuse_log.refused("model_fallback", "/api/chat", | |
| model=try_model, error=_err_msg) | |
| # Show error to user so they can see what's happening | |
| _next_model = next( | |
| (model for model in chain[i + 1:] if model in AVAILABLE_MODELS), | |
| None, | |
| ) | |
| _notice = _format_model_failure(try_model, _err_msg, _next_model) | |
| # An object, not a bare string: a plain string joins fullReply | |
| # and is replayed to the next model as its own context. | |
| yield f"data: {_json.dumps({'__notice__': _notice})}\n\n" | |
| continue | |
| yield f"data: {_json.dumps('Ajaj! Všechny modely selhaly. Zkus to znovu později.')}\n\n" | |
| yield "data: [DONE]\n\n" | |
| return StreamingResponse(generate(), media_type="text/event-stream", | |
| headers={"X-Accel-Buffering": "no", | |
| "Cache-Control": "no-cache"}) | |
| _tts_cache: dict = {} | |
| async def tts(request: Request): | |
| """Generate speech from text using edge-tts (Microsoft neural voices).""" | |
| import hashlib | |
| import io | |
| import edge_tts | |
| from fastapi.responses import Response | |
| body = await request.json() | |
| text = body.get("text", "").strip() | |
| voice = body.get("voice", "cs-CZ-AntoninNeural") | |
| if not text: | |
| return {"error": "No text"} | |
| if len(text) > 5000: | |
| text = text[:5000] | |
| key = hashlib.md5(f"{voice}:{text}".encode()).hexdigest() | |
| if key in _tts_cache: | |
| data = _tts_cache[key] | |
| return Response(content=data, media_type="audio/mpeg", | |
| headers={"Content-Disposition": "inline", "Content-Length": str(len(data))}) | |
| try: | |
| buf = io.BytesIO() | |
| communicate = edge_tts.Communicate(text, voice) | |
| async for chunk in communicate.stream(): | |
| if chunk["type"] == "audio" and "data" in chunk: | |
| buf.write(chunk["data"]) | |
| data = buf.getvalue() | |
| if not data: | |
| from fastapi.responses import JSONResponse | |
| return JSONResponse({"error": "edge_tts returned empty audio"}, status_code=503) | |
| if len(_tts_cache) >= 100: | |
| _tts_cache.pop(next(iter(_tts_cache))) | |
| _tts_cache[key] = data | |
| return Response(content=data, media_type="audio/mpeg", | |
| headers={"Content-Disposition": "inline", "Content-Length": str(len(data))}) | |
| except Exception as e: # noqa: BLE001 - any TTS failure becomes a 503 for the client | |
| from fastapi.responses import JSONResponse | |
| return JSONResponse({"error": f"edge_tts failed: {e}"}, status_code=503) | |
| def test_results(request: Request): | |
| """Return per-model accuracy results reconstructed from test cache. | |
| By default only includes entries from today (local time) — old entries | |
| for models that are no longer tested/available would otherwise linger | |
| forever, since the cache only overwrites a (model, query) pair when it's | |
| re-run. Pass ?all=1 to see the full history instead. | |
| """ | |
| import json as _json | |
| from collections import defaultdict | |
| from datetime import datetime | |
| CACHE_FILE = "data/test_cache.json" | |
| try: | |
| import importlib | |
| tm = importlib.import_module("test_models") | |
| TEST_QUERIES = tm.TEST_QUERIES | |
| check_result = tm.check_result | |
| except Exception as e: # noqa: BLE001 - the import runs test_models' module code; report any failure | |
| return {"error": str(e)} | |
| if not os.path.exists(CACHE_FILE): | |
| return {"models": [], "queries": []} | |
| with open(CACHE_FILE) as f: | |
| cache = _json.load(f) | |
| show_all = request.query_params.get("all") == "1" | |
| # Naive local midnight on purpose: .timestamp() then applies the DST offset | |
| # in force at midnight, which an aware now() would get wrong on switch days. | |
| today_start = datetime.now().replace( # noqa: DTZ005 | |
| hour=0, minute=0, second=0, microsecond=0 | |
| ).timestamp() | |
| # Build query index | |
| query_map = {t["query"]: t for t in TEST_QUERIES} | |
| # Reconstruct per-model results | |
| model_data = defaultdict(lambda: { | |
| "pass": 0, "fail": 0, | |
| "by_type": defaultdict(lambda: {"pass": 0, "fail": 0}), | |
| "details": {}, | |
| "latest_ts": 0, | |
| }) | |
| for entry in cache.values(): | |
| ts = entry.get("timestamp", 0) | |
| if not show_all and ts < today_start: | |
| continue | |
| model = entry["model"] | |
| query = entry["query"] | |
| reply = entry.get("reply", "") | |
| test = query_map.get(query) | |
| if not test: | |
| continue | |
| passed, issues = check_result(reply, test) | |
| qtype = test.get("type", "other") | |
| d = model_data[model] | |
| d["details"][query] = { | |
| "passed": passed, | |
| "issues": issues, | |
| "reply": reply[:200], | |
| "type": qtype, | |
| "note": test.get("note", ""), | |
| } | |
| if passed: | |
| d["pass"] += 1 | |
| d["by_type"][qtype]["pass"] += 1 | |
| else: | |
| d["fail"] += 1 | |
| d["by_type"][qtype]["fail"] += 1 | |
| d["latest_ts"] = max(d["latest_ts"], ts) | |
| # Build output | |
| models_out = [] | |
| for name, d in model_data.items(): | |
| total = d["pass"] + d["fail"] | |
| info = _ALL_MODELS.get(name, {}) | |
| by_type = {k: {"pass": v["pass"], "total": v["pass"] + v["fail"]} | |
| for k, v in d["by_type"].items()} | |
| models_out.append({ | |
| "id": name, | |
| "provider": info.get("provider", "?"), | |
| "free": info.get("free", True), | |
| "pass": d["pass"], | |
| "total": total, | |
| "score": f"{d['pass']}/{total}", | |
| "pct": round(d["pass"] / total * 100) if total else 0, | |
| "by_type": by_type, | |
| "details": d["details"], | |
| "ts": d["latest_ts"], | |
| }) | |
| # Merge in reliability data | |
| rel_data = get_all_reliability() | |
| for m in models_out: | |
| r = rel_data.get(m["id"], {}) | |
| attempts = r.get("attempts", 0) | |
| successes = r.get("successes", 0) | |
| m["reliability"] = round(successes / attempts * 100) if attempts else None | |
| m["rel_attempts"] = attempts | |
| m["rel_successes"] = successes | |
| m["rel_errors"] = r.get("errors", 0) | |
| m["last_error_msg"] = r.get("last_error_msg") | |
| m["real_ttft"] = r.get("avg_ttft") | |
| m["real_tok_sec"] = r.get("avg_tok_sec") | |
| # Merge in speed benchmark data | |
| for m in models_out: | |
| b = _bench_results.get(m["id"], {}) | |
| m["ttft"] = round(b["ttft"], 2) if b.get("ttft") else None | |
| m["tok_sec"] = round(b["tok_sec"], 1) if b.get("tok_sec") else None | |
| m["latency"] = round(b["latency"], 2) if b.get("latency") else None | |
| total_questions = len(TEST_QUERIES) | |
| min_for_score = total_questions * 60 // 100 # need 60% done to show score | |
| for m in models_out: | |
| m["incomplete"] = m["total"] < min_for_score | |
| models_out.sort(key=lambda m: (-m["pct"], -m["total"])) | |
| query_list = [{"query": t["query"], "type": t["type"], "note": t["note"]} for t in TEST_QUERIES] | |
| return {"models": models_out, "queries": query_list, "total_questions": total_questions} | |
| # ── Accuracy test control ──────────────────────────────────────────────────── | |
| # test_models.py's own main() can't run here: it loads sentence_transformers, | |
| # which isn't installed on HF. This re-runs the same TEST_QUERIES through the | |
| # app's already-loaded fastembed embedder and writes the same cache file. | |
| _TEST_CACHE_FILE = "data/test_cache.json" | |
| _test_run = {"running": False, "done": 0, "total": 0, "errors": 0, | |
| "last_error": None, "gave_up": {}, "finished_at": 0} | |
| # ponytail: five strikes and a model is written off for the rest of the run. | |
| # A chronically dead provider would otherwise burn one timeout per query. | |
| _GIVE_UP_AFTER = 5 | |
| def _admin_ok(request: Request) -> bool: | |
| """True when the request carries the admin token from the env.""" | |
| token = os.getenv("ADMIN_TOKEN") | |
| if not token: | |
| return False | |
| return hmac.compare_digest(request.headers.get("X-Ajaj-Admin", ""), token) | |
| def _accuracy_worker() -> None: | |
| """Run every TEST_QUERY against every available model, filling the cache.""" | |
| import test_models as tm | |
| from providers import call_model | |
| models = list(AVAILABLE_MODELS) | |
| by_provider: dict[str, list[str]] = {} | |
| for name in models: | |
| by_provider.setdefault(_ALL_MODELS[name]["provider"], []).append(name) | |
| # Retrieval is local and cheap — do it once per query, up front. | |
| contexts = {} | |
| for test in tm.TEST_QUERIES: | |
| q = test["query"] | |
| chunks, _titles, _attempted = retrieve_chunks(q, embedder, collection, TOP_K) | |
| contexts[q] = [ | |
| {"role": "system", | |
| "content": f"{tm.SYSTEM_PROMPT}\n\nKontext:\n{tm.build_context(chunks)}"}, | |
| {"role": "user", "content": q}, | |
| ] | |
| def ask(name: str, q: str) -> str | None: | |
| """Call one model and cache its answer. Returns None, or the error. | |
| A failed call caches nothing. "429 Too Many Requests" says the | |
| provider is busy, not that the model answered badly — scoring it | |
| as a zero would put a fake number on the board. | |
| """ | |
| try: | |
| reply, tin, tout = call_model(name, contexts[q]) | |
| if not reply: | |
| raise RuntimeError("empty reply") | |
| log_reliability(name, success=True) | |
| tm.cache_put(name, q, reply, tin, tout) | |
| return None | |
| except Exception as exc: # noqa: BLE001 - one dead model must not stop the run | |
| log_reliability(name, success=False, error_msg=str(exc)) | |
| msg = _redact_secrets(str(exc)) | |
| _test_run["errors"] += 1 | |
| _test_run["last_error"] = f"{name}: {msg}" | |
| return msg | |
| def run_provider(names: list[str]) -> None: | |
| misses = dict.fromkeys(names, 0) | |
| for test in tm.TEST_QUERIES: | |
| for name in names: | |
| _test_run["done"] += 1 | |
| if misses[name] >= _GIVE_UP_AFTER: | |
| continue # written off — see _test_run["gave_up"] | |
| err = ask(name, test["query"]) | |
| if err is None: | |
| misses[name] = 0 | |
| continue | |
| misses[name] += 1 | |
| if misses[name] >= _GIVE_UP_AFTER: | |
| _test_run["gave_up"][name] = err | |
| try: | |
| # ponytail: one thread per provider, sequential within it — matches the | |
| # rate limits providers actually enforce. Finer parallelism gets 429s. | |
| with ThreadPoolExecutor(max_workers=max(len(by_provider), 1)) as pool: | |
| list(pool.map(run_provider, by_provider.values())) | |
| finally: | |
| _test_run["running"] = False | |
| _test_run["finished_at"] = int(time.time()) | |
| async def test_run_status(): | |
| """Progress of the accuracy test (safe to poll, no auth needed).""" | |
| return dict(_test_run) | |
| async def test_run_start(request: Request): | |
| """Start a fresh accuracy run in the background. | |
| Gated on ADMIN_TOKEN because one run fires hundreds of provider calls; | |
| with no token set in the environment the endpoint stays closed. | |
| """ | |
| if not _admin_ok(request): | |
| refuse_log.refused("not_admin", request.url.path, _bucket(request), status=403) | |
| return JSONResponse({"error": "not_admin"}, status_code=403) | |
| if _test_run["running"]: | |
| return JSONResponse({"error": "already_running", **_test_run}, status_code=409) | |
| if embedder is None or collection is None: | |
| return JSONResponse({"error": "not_ready"}, status_code=503) | |
| import test_models as tm | |
| _test_run.update(running=True, done=0, errors=0, last_error=None, | |
| gave_up={}, finished_at=0, | |
| total=len(tm.TEST_QUERIES) * len(AVAILABLE_MODELS)) | |
| threading.Thread(target=_accuracy_worker, daemon=True).start() | |
| return dict(_test_run) | |
| async def test_clear(request: Request): | |
| """Wipe the accuracy cache — every result, not just the stale ones.""" | |
| if not _admin_ok(request): | |
| refuse_log.refused("not_admin", request.url.path, _bucket(request), status=403) | |
| return JSONResponse({"error": "not_admin"}, status_code=403) | |
| removed = [] | |
| def wipe(cache: dict) -> None: | |
| removed.append(len(cache)) | |
| cache.clear() | |
| update_json(_TEST_CACHE_FILE, dict, wipe) | |
| return {"removed": removed[0]} | |
| async def provider_reliability(request: Request): | |
| """Return reliability aggregated per provider. | |
| reliability.json holds lifetime cumulative counters per model (not a | |
| per-day log), so this can't show "today's %" the way /api/test-results | |
| can. Instead, by default it drops models not called at all today (dead/ | |
| obsolete ones) while keeping the % as lifetime-to-date for the rest. | |
| Pass ?all=1 to see every model ever recorded. | |
| """ | |
| from collections import defaultdict | |
| from datetime import datetime | |
| show_all = request.query_params.get("all") == "1" | |
| # Naive local midnight on purpose: .timestamp() then applies the DST offset | |
| # in force at midnight, which an aware now() would get wrong on switch days. | |
| today_start = datetime.now().replace( # noqa: DTZ005 | |
| hour=0, minute=0, second=0, microsecond=0 | |
| ).timestamp() | |
| rel_data = get_all_reliability() | |
| providers: dict = defaultdict(lambda: { | |
| "attempts": 0, "successes": 0, "errors": 0, | |
| "models": [], "last_error_msg": None, | |
| }) | |
| for model_name, r in rel_data.items(): | |
| if not show_all: | |
| last_seen = max(r.get("last_success") or 0, r.get("last_error") or 0) | |
| if last_seen < today_start: | |
| continue | |
| info = _ALL_MODELS.get(model_name, {}) | |
| prov = info.get("provider", "unknown") | |
| p = providers[prov] | |
| p["attempts"] += r.get("attempts", 0) | |
| p["successes"] += r.get("successes", 0) | |
| p["errors"] += r.get("errors", 0) | |
| if r.get("last_error_msg"): | |
| p["last_error_msg"] = r["last_error_msg"] | |
| model_rel = round(r["successes"] / r["attempts"] * 100) if r.get("attempts") else None | |
| bench = _bench_results.get(model_name, {}) | |
| p["models"].append({ | |
| "id": model_name, | |
| "free": info.get("free", True), | |
| "attempts": r.get("attempts", 0), | |
| "successes": r.get("successes", 0), | |
| "errors": r.get("errors", 0), | |
| "reliability": model_rel, | |
| "last_error_msg": r.get("last_error_msg"), | |
| "ttft": round(bench["ttft"], 2) if bench.get("ttft") else None, | |
| "tok_sec": round(bench["tok_sec"], 1) if bench.get("tok_sec") else None, | |
| }) | |
| out = [] | |
| for prov, p in providers.items(): | |
| pct = round(p["successes"] / p["attempts"] * 100) if p["attempts"] else None | |
| p["models"].sort(key=lambda m: -(m["reliability"] or 0)) | |
| # Compute average TTFT and tok/s for the provider | |
| ttfts = [m["ttft"] for m in p["models"] if m["ttft"] is not None] | |
| toks = [m["tok_sec"] for m in p["models"] if m["tok_sec"] is not None] | |
| out.append({ | |
| "provider": prov, | |
| "attempts": p["attempts"], | |
| "successes": p["successes"], | |
| "errors": p["errors"], | |
| "reliability": pct, | |
| "last_error_msg": p["last_error_msg"], | |
| "avg_ttft": round(sum(ttfts) / len(ttfts), 2) if ttfts else None, | |
| "avg_tok_sec": round(sum(toks) / len(toks), 1) if toks else None, | |
| "models": p["models"], | |
| }) | |
| out.sort(key=lambda p: -(p["reliability"] or 0)) | |
| return {"providers": out} | |
| def results_page(): | |
| path = os.path.join(os.path.dirname(__file__), "static", "results.html") | |
| with open(path) as f: | |
| content = f.read() | |
| return HTMLResponse(content=content) | |
| def stats_page(request: Request): | |
| path = os.path.join(os.path.dirname(__file__), "static", "stats.html") | |
| label = _server_label(request) | |
| with open(path) as f: | |
| html = f.read() | |
| return HTMLResponse(html.replace("<!--SERVER-->", f" ({label} server)" if label else "")) | |
| def visit_stats(request: Request, days: int = Query(0, ge=0, le=3650)): | |
| """Per-site visitor numbers for /stats, for the last `days` Prague days | |
| (0 = everything logged). Admin only: the log holds raw IPs.""" | |
| if not _admin_ok(request): | |
| refuse_log.refused("not_admin", request.url.path, _bucket(request), status=403) | |
| return JSONResponse({"error": "not_admin"}, status_code=403) | |
| records = visits.load() | |
| since = records[0]["ts"] if records else "" # the log survives restarts | |
| visits.promote(records) # whole log first, so past captcha solves count | |
| if days: | |
| records = visits.since_days(records, days) | |
| visits.fill_locations(records) | |
| return visits.summarize(records) | {"since": since} | |
| def providers_page(): | |
| path = os.path.join(os.path.dirname(__file__), "static", "providers.html") | |
| with open(path) as f: | |
| content = f.read() | |
| return HTMLResponse(content=content) | |
| def stt_usage(): | |
| """Return cumulative STT usage from server-side log.""" | |
| import json as _j | |
| total_s = 0.0 | |
| total_cost = 0.0 | |
| sessions = 0 | |
| try: | |
| with open(_STT_USAGE_FILE) as f: | |
| for line in f: | |
| # Skip a half-written line (ValueError) or a malformed entry. | |
| with contextlib.suppress(ValueError, AttributeError, TypeError): | |
| e = _j.loads(line) | |
| total_s += e.get("duration_s", 0) | |
| total_cost += e.get("cost_est", 0) | |
| sessions += 1 | |
| except FileNotFoundError: | |
| pass | |
| return { | |
| "sessions": sessions, | |
| "total_s": round(total_s, 1), | |
| "total_cost_usd": round(total_cost, 6), | |
| } | |
| def _refuse_cross_site(request: Request) -> JSONResponse | None: | |
| """403 unless the page's own X-Ajaj-Session header is there: on HF the | |
| session cookie is SameSite=None, so a cross-site form carries it too.""" | |
| if "x-ajaj-session" in request.headers: | |
| return None | |
| refuse_log.refused("cross_site_post", request.url.path, status=403) | |
| return JSONResponse({"error": "forbidden"}, status_code=403) | |
| def stt_usage_report(request: Request): | |
| """Seconds /t streamed straight to Deepgram EU: the relay never saw them, | |
| and the daily budget must. A page can under-report; the token's captcha | |
| gate and rate limit are what bound that.""" | |
| if (refused := _refuse_cross_site(request)) is not None: | |
| return refused | |
| try: | |
| seconds = float(request.query_params.get("seconds", "")) | |
| except ValueError: | |
| seconds = 0.0 | |
| if not 0 < seconds <= stt_budget.MAX_SESSION_S: # also refuses nan and inf | |
| refuse_log.refused("stt_bad_usage_report", "/api/stt/usage", status=400) | |
| return JSONResponse({"error": "bad seconds"}, status_code=400) | |
| _, model, lang, rate = _dg_listen_query(request.query_params) | |
| visitor = stt_budget.client_id(_stt_client_key(request), _captcha.CAPTCHA_SECRET) | |
| _log_stt_usage(visitor, lang, model, seconds, rate) | |
| with _stt_open_lock: # still streaming: keep its slot | |
| pages = _stt_direct.get(visitor, {}) | |
| if (sid := request.query_params.get("sid", "")) in pages: | |
| pages[sid] = time.time() + stt_budget.DIRECT_SLOT_S | |
| return {"ok": True} | |
| async def stt_session_report(request: Request): | |
| """How a /t visit is going: seconds per live problem, drops, setup. | |
| Quality only — billing stays with /api/stt/usage.""" | |
| if (refused := _refuse_cross_site(request)) is not None: | |
| return refused | |
| try: | |
| report = stt_quality.SessionReport.model_validate_json(await request.body()) | |
| except ValidationError: | |
| refuse_log.refused("stt_bad_session_report", "/api/stt/session", status=400) | |
| return JSONResponse({"error": "bad report"}, status_code=400) | |
| user_agent = request.headers.get("user-agent", "") | |
| stt_quality.record( | |
| report, | |
| stt_budget.client_id(_stt_client_key(request), _captcha.CAPTCHA_SECRET), | |
| _visitor_id(request), visits.os_name(user_agent), visits.browser(user_agent), | |
| ) | |
| return {"ok": True} | |
| def stt_quality_stats(request: Request, days: int = Query(0, ge=0, le=3650)): | |
| """/stats: how /t visits went in the last `days` days (0 = all). Admin | |
| only, like /api/visits.""" | |
| if not _admin_ok(request): | |
| refuse_log.refused("not_admin", request.url.path, _bucket(request), status=403) | |
| return JSONResponse({"error": "not_admin"}, status_code=403) | |
| sessions = stt_quality.load() | |
| if days: # the same Prague days as /api/visits, so both panels agree | |
| sessions = visits.since_days(sessions, days) | |
| records = visits.load() | |
| visits.promote(records) # the whole log, so "Visitor N" matches the Visitors tab | |
| return stt_quality.breakdown(sessions, {r["visitor"]: r["hn"] for r in records if "hn" in r}) | |
| # Deepgram's EU endpoint: same API, but a European visitor's words no longer | |
| # cross the Atlantic twice through the relay. A token only has to be valid when | |
| # the socket opens; Deepgram keeps the stream past its expiry. | |
| _DG_EU = "api.eu.deepgram.com" | |
| _DG_TOKEN_TTL_S = 30 | |
| async def _dg_grant() -> str | None: | |
| """A short-lived Deepgram token, or None when none can be had. | |
| Minting needs a key with Member rights, so it is a separate secret from | |
| the relay's scoped key: DEEPGRAM_API_KEY_MEMBER. | |
| """ | |
| import httpx as _httpx | |
| api_key = os.getenv("DEEPGRAM_API_KEY_MEMBER") | |
| if not api_key: | |
| return None | |
| try: | |
| async with _httpx.AsyncClient(timeout=5) as client: | |
| r = await client.post( | |
| f"https://{_DG_EU}/v1/auth/grant", | |
| headers={"Authorization": f"Token {api_key}"}, | |
| json={"ttl_seconds": _DG_TOKEN_TTL_S}, | |
| ) | |
| token = r.json().get("access_token") if r.status_code == 200 else None | |
| except (_httpx.HTTPError, ValueError): | |
| return None | |
| return token if isinstance(token, str) else None | |
| async def stt_token(request: Request): | |
| """A Deepgram token and the EU URL for this page's settings, so /t can | |
| stream without the relay. Any refusal sends the page to the relay.""" | |
| if (refused := _refuse_cross_site(request)) is not None: | |
| return refused | |
| visitor = stt_budget.client_id(_stt_client_key(request), _captcha.CAPTCHA_SECRET) | |
| if stt_budget.is_exhausted(_STT_USAGE_FILE, visitor): | |
| refuse_log.refused("stt_budget_spent", "/api/stt/token", visitor, status=403) | |
| return JSONResponse({"available": False, "reason": "budget_spent"}, | |
| status_code=403) | |
| with _stt_open_lock: | |
| pages = _stt_direct.setdefault(visitor, {}) | |
| sid = stt_budget.claim_slot(pages, request.query_params.get("sid", ""), time.time()) | |
| if sid is None: | |
| refuse_log.refused("stt_too_many_open", "/api/stt/token", visitor, status=429, | |
| limit=stt_budget.MAX_CONCURRENT) | |
| return JSONResponse({"available": False, "reason": "too_many"}, status_code=429) | |
| token = await _dg_grant() | |
| if token is None: | |
| with _stt_open_lock: | |
| pages.pop(sid, None) | |
| return JSONResponse({"available": False, "reason": "no_grant"}, status_code=503) | |
| # only now: refused, the page falls back to the relay, which records its own | |
| visits.record("t", "stt", request.headers, _get_client_ip(request), | |
| has_session=_check_session(request), visitor_id=_visitor_id(request)) | |
| query, *_ = _dg_listen_query(request.query_params) | |
| return {"token": token, "url": f"wss://{_DG_EU}/v1/listen?{query}", "sid": sid} | |
| # A yes from Deepgram is remembered for a minute: /t asks after every dropped | |
| # socket, and while the key is fine a flaky phone connection would mean a | |
| # Deepgram call per retry. A no is never remembered, so a fixed key shows on the | |
| # next try; a revoked one shows within the minute. | |
| _STT_CHECK_OK_S = 60 | |
| _stt_check_ok_until = 0.0 | |
| async def stt_check(request: Request, response: Response): | |
| """Check if Deepgram STT is available (API key set + key validates). | |
| Also says when this visitor's daily budget is spent: /api/stt refuses that | |
| before its handshake, so the page cannot tell it from a network drop. | |
| """ | |
| global _stt_check_ok_until | |
| # Every drop asks this check, so /t's Server follows a failover | |
| response.headers["X-Ajaj-Server"] = _server_label(request) | |
| import httpx as _httpx | |
| api_key = os.getenv("DEEPGRAM_API_KEY") | |
| if not api_key: | |
| return {"available": False, "reason": "no_key"} | |
| visitor = stt_budget.client_id(_stt_client_key(request), _captcha.CAPTCHA_SECRET) | |
| if stt_budget.is_exhausted(_STT_USAGE_FILE, visitor): | |
| return {"available": False, "reason": "budget_spent"} | |
| if time.monotonic() < _stt_check_ok_until: | |
| return {"available": True} | |
| # Use /v1/auth/token — works for all key types including scoped STT-only keys | |
| try: | |
| async with _httpx.AsyncClient(timeout=5) as client: | |
| r = await client.get( | |
| "https://api.deepgram.com/v1/auth/token", | |
| headers={"Authorization": f"Token {api_key}"}, | |
| ) | |
| if r.status_code == 200: | |
| _stt_check_ok_until = time.monotonic() + _STT_CHECK_OK_S | |
| return {"available": True} | |
| elif r.status_code in (401, 403): | |
| return {"available": False, "reason": "invalid_key"} | |
| else: | |
| return {"available": False, "reason": f"http_{r.status_code}"} | |
| except Exception: # noqa: BLE001 - a health probe: any failure reads as unreachable | |
| return {"available": False, "reason": "unreachable"} | |
| _STT_USAGE_FILE = os.path.join("data", "stt_usage.json") | |
| _stt_open: dict[str, int] = {} | |
| # Sockets straight to Deepgram EU, per client: slot id → expiry (claim_slot). | |
| # ponytail: one small entry per /t visitor, kept until the next restart. | |
| _stt_direct: dict[str, dict[str, float]] = {} | |
| _stt_open_lock = threading.Lock() | |
| def _stt_client_key(websocket: Request | WebSocket) -> str: | |
| """Bucket key: the captcha session, falling back to the address. | |
| Behind the HF router every visitor shares one IP, so the session token is | |
| the only thing that distinguishes clients there. | |
| """ | |
| return _session_token(websocket) or _get_client_ip(websocket) or "unknown" | |
| def _log_stt_usage(client: str, lang: str, model: str, duration_s: float, | |
| cost_per_min: float): | |
| """Append STT usage entry to data/stt_usage.json. | |
| `client` is a keyed hash (stt_budget.client_id), never an address: the | |
| per-client daily budget needs something to count against, but this file | |
| used to carry raw visitor IPs and reach the public Space. | |
| """ | |
| import json as _j | |
| entry = { | |
| "ts": _rl_time.strftime("%Y-%m-%dT%H:%M:%S"), | |
| "client": client, | |
| "lang": lang, | |
| "model": model, | |
| "duration_s": round(duration_s, 1), | |
| "cost_est": round(duration_s / 60 * cost_per_min, 6), | |
| } | |
| os.makedirs(os.path.dirname(_STT_USAGE_FILE) or "data", exist_ok=True) | |
| try: | |
| with open(_STT_USAGE_FILE, "a") as f: | |
| f.write(_j.dumps(entry) + "\n") | |
| except OSError as exc: # a lost entry under-counts the budget: say so | |
| print(f"[STT usage] entry not logged: {exc}", flush=True) | |
| async def stt_ws(websocket: WebSocket): | |
| """Proxy WebSocket: browser mic → Deepgram STT → transcript events.""" | |
| # BaseHTTPMiddleware skips non-http scopes, so CaptchaMiddleware never sees | |
| # this route — without the repeat check /api/stt is an open proxy to a | |
| # metered Deepgram key. The captcha gate is also the spend cap. | |
| _stt_key = stt_budget.client_id(_stt_client_key(websocket), | |
| _captcha.CAPTCHA_SECRET) | |
| visits.record("t", "stt", websocket.headers, _get_client_ip(websocket), | |
| has_session=_check_session(websocket), visitor_id=_visitor_id(websocket)) | |
| if _IS_HF and not _check_session(websocket): | |
| refuse_log.refused("ws_captcha_required", "/api/stt", _stt_key, code=1008) | |
| await websocket.close(code=1008, reason="captcha_required") | |
| return | |
| if stt_budget.is_exhausted(_STT_USAGE_FILE, _stt_key): | |
| refuse_log.refused("stt_budget_spent", "/api/stt", _stt_key, code=1013) | |
| await websocket.close(code=1013, reason="daily STT budget spent") | |
| return | |
| with _stt_open_lock: | |
| _admitted = _stt_open.get(_stt_key, 0) < stt_budget.MAX_CONCURRENT | |
| if _admitted: | |
| _stt_open[_stt_key] = _stt_open.get(_stt_key, 0) + 1 | |
| if not _admitted: | |
| refuse_log.refused("stt_too_many_open", "/api/stt", _stt_key, code=1013, | |
| limit=stt_budget.MAX_CONCURRENT) | |
| await websocket.close(code=1013, reason="too many open microphones") | |
| return | |
| try: | |
| await _stt_relay(websocket, _stt_key) | |
| finally: | |
| with _stt_open_lock: | |
| remaining = _stt_open.get(_stt_key, 1) - 1 | |
| if remaining > 0: | |
| _stt_open[_stt_key] = remaining | |
| else: | |
| _stt_open.pop(_stt_key, None) | |
| def _dg_listen_query(params: QueryParams) -> tuple[str, str, str, float]: | |
| """Deepgram /v1/listen query from the page's settings, plus model, language | |
| and $/min. Shared by the relay and the direct EU path (/api/stt/token).""" | |
| # These go into a URL authenticated with our Deepgram key, so they are | |
| # allowlisted rather than escaped: escaping would still let a client pick | |
| # any model, and stt_budget.PRICE_PER_MIN only knows these two — | |
| # anything else would be billed at nova-3's rate and under-count the | |
| # daily budget. Unknown values fall back to the default instead of | |
| # failing, so an odd browser sample rate never breaks the mic. | |
| model = stt_params.model(params.get("model")) | |
| lang = stt_params.language(params.get("lang"), model) | |
| sample_rate = stt_params.bounded_int(params.get("sample_rate"), 16000, 8000, 96000) | |
| endpointing = stt_params.bounded_int(params.get("endpointing"), 300, 10, 5000) | |
| utterance_end_ms = ( | |
| str(stt_params.bounded_int(params.get("utterance_end_ms"), 1000, 1000, 10000)) | |
| if params.get("utterance_end_ms") else "" | |
| ) | |
| lang_param = "&language=multi" if lang == "multi" else f"&language={lang}" | |
| query = ( | |
| f"model={model}{lang_param}&encoding=linear16" | |
| f"&sample_rate={sample_rate}&channels=1" | |
| f"&interim_results=true&smart_format=true&punctuate=true" | |
| f"&endpointing={endpointing}&vad_events=true" | |
| ) | |
| if utterance_end_ms: | |
| query += f"&utterance_end_ms={utterance_end_ms}" | |
| diarize = params.get("diarize") == "true" | |
| if diarize: | |
| query += "&diarize=true" | |
| # nova-3 dropped `keywords` in favour of `keyterm`; sending the old name is a silent no-op | |
| kw_param = "keyterm" if model.startswith("nova-3") else "keywords" | |
| for kw in params.getlist("keywords"): | |
| query += f"&{kw_param}={quote(kw)}" | |
| rate = stt_budget.cost_per_min(model, lang, diarize, | |
| bool(params.getlist("keywords"))) | |
| return query, model, lang, rate | |
| async def _stt_relay(websocket: WebSocket, client: str): | |
| """Relay an admitted microphone to Deepgram until it closes or times out.""" | |
| await websocket.accept() | |
| import asyncio as _asyncio | |
| import json as _json | |
| try: | |
| import websockets as _ws | |
| except ImportError: | |
| refuse_log.refused("stt_unavailable", "/api/stt", code=1011) | |
| await websocket.close(code=1011, reason="websockets not installed on server") | |
| return | |
| api_key = os.getenv("DEEPGRAM_API_KEY") | |
| if not api_key: | |
| refuse_log.refused("stt_unconfigured", "/api/stt", code=1008) | |
| await websocket.close(code=1008, reason="No DEEPGRAM_API_KEY") | |
| return | |
| _stt_start = _rl_time.time() | |
| query, model, lang, rate = _dg_listen_query(websocket.query_params) | |
| dg_url = f"wss://api.deepgram.com/v1/listen?{query}" | |
| try: | |
| async with _ws.connect( | |
| dg_url, | |
| additional_headers={"Authorization": f"Token {api_key}"}, | |
| max_size=None, | |
| ) as dg: | |
| async def relay_dg(): | |
| try: | |
| async for msg in dg: | |
| try: | |
| await websocket.send_text(msg if isinstance(msg, str) else msg.decode()) | |
| except Exception: # noqa: BLE001 - the browser is gone; stop relaying | |
| return | |
| except Exception: # noqa: BLE001, S110 - Deepgram hung up; the loop below sees dg_task done | |
| pass | |
| dg_task = _asyncio.create_task(relay_dg()) | |
| _deadline = _stt_start + stt_budget.MAX_SESSION_S | |
| recv_task = None | |
| try: | |
| while True: | |
| if recv_task is None: | |
| recv_task = _asyncio.create_task(websocket.receive()) | |
| # Wait on Deepgram too. A client that stops sending without | |
| # closing — a leaked socket, a dropped link — would sit in | |
| # receive() forever holding one of MAX_CONCURRENT, because | |
| # the deadline below is only read when a frame arrives. | |
| done, _ = await _asyncio.wait( | |
| {recv_task, dg_task}, return_when=_asyncio.FIRST_COMPLETED) | |
| if dg_task in done: | |
| break | |
| msg = recv_task.result() | |
| recv_task = None | |
| if msg.get("type") == "websocket.disconnect": | |
| break | |
| if _rl_time.time() > _deadline: | |
| break | |
| payload = msg.get("bytes") or msg.get("text") | |
| if payload is None: | |
| continue | |
| if len(payload) > body_limit.MAX_FRAME_BYTES: | |
| break | |
| await dg.send(payload) | |
| except (WebSocketDisconnect, Exception): # noqa: BLE001, S110 - either side hung up; finally cleans up | |
| pass | |
| finally: | |
| if recv_task is not None: | |
| recv_task.cancel() | |
| dg_task.cancel() | |
| try: | |
| await dg.send(_json.dumps({"type": "CloseStream"})) | |
| await _asyncio.sleep(0.3) | |
| except Exception: # noqa: BLE001, S110 - best-effort goodbye on a socket that may be closed | |
| pass | |
| except Exception: # noqa: BLE001 - any relay failure just closes the browser socket | |
| try: | |
| await websocket.close() | |
| except Exception: # noqa: BLE001, S110 - the browser socket may already be closed | |
| pass | |
| finally: | |
| _log_stt_usage(client, lang, model, _rl_time.time() - _stt_start, rate) | |
| if __name__ == "__main__": | |
| import uvicorn | |
| boot() | |
| uvicorn.run(app, host="127.0.0.1", port=8000) | |