#!/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 ──────────────────────────────────────────────────────────── @app.get("/captcha", response_class=HTMLResponse) async def captcha_page(): return HTMLResponse(_CAPTCHA_HTML) @app.get("/captcha-test", response_class=HTMLResponse) 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) @app.get("/api/captcha/challenge") async def captcha_challenge(): return _captcha.generate_challenge() @app.post("/api/captcha/verify") 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 = """
OpenAjaj