Jindrich3 hynek med commited on
Commit
b356196
·
0 Parent(s):

Super-squash branch 'main' using huggingface_hub

Browse files

Co-authored-by: hynek med <hynek med@users.noreply.huggingface.co>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +11 -0
  2. Dockerfile +38 -0
  3. README.md +13 -0
  4. benchmark.py +140 -0
  5. body_limit.py +32 -0
  6. captcha.py +312 -0
  7. chat.py +186 -0
  8. data/articles.jsonl +3 -0
  9. data/declensions.json +0 -0
  10. data/last_update.txt +1 -0
  11. data/mistral-large-latest_last_result.json +15 -0
  12. data/test_cache.json +0 -0
  13. data/titles.json +0 -0
  14. db/chroma/85dc4107-dc6e-491a-8587-80599979b833/data_level0.bin +3 -0
  15. db/chroma/85dc4107-dc6e-491a-8587-80599979b833/header.bin +0 -0
  16. db/chroma/85dc4107-dc6e-491a-8587-80599979b833/index_metadata.pickle +3 -0
  17. db/chroma/85dc4107-dc6e-491a-8587-80599979b833/length.bin +3 -0
  18. db/chroma/85dc4107-dc6e-491a-8587-80599979b833/link_lists.bin +3 -0
  19. db/chroma/chroma.sqlite3 +3 -0
  20. db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/data_level0.bin +3 -0
  21. db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/header.bin +0 -0
  22. db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/index_metadata.pickle +3 -0
  23. db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/length.bin +0 -0
  24. db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/link_lists.bin +3 -0
  25. db/chroma_en/chroma.sqlite3 +3 -0
  26. embedder.py +104 -0
  27. geo.py +94 -0
  28. json_storage.py +69 -0
  29. personas.py +81 -0
  30. providers.py +741 -0
  31. rate_limit.py +63 -0
  32. refuse_log.py +36 -0
  33. requirements-hf.txt +15 -0
  34. retrieve.py +345 -0
  35. scrape.py +294 -0
  36. startup.py +314 -0
  37. startup_progress.py +33 -0
  38. static/apple-touch-icon.png +0 -0
  39. static/captcha_bg.png +0 -0
  40. static/favicon.ico +0 -0
  41. static/index.html +0 -0
  42. static/logo.png +3 -0
  43. static/logo.webp +0 -0
  44. static/providers.html +218 -0
  45. static/results.html +647 -0
  46. static/stats.html +306 -0
  47. static/transcribe2/apple-touch-icon.png +0 -0
  48. static/transcribe2/favicon.ico +0 -0
  49. static/transcribe2/index.html +0 -0
  50. static/version.txt +1 -0
.gitattributes ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ data/articles.jsonl filter=lfs diff=lfs merge=lfs -text
2
+ db/chroma/85dc4107-dc6e-491a-8587-80599979b833/data_level0.bin filter=lfs diff=lfs merge=lfs -text
3
+ db/chroma/85dc4107-dc6e-491a-8587-80599979b833/index_metadata.pickle filter=lfs diff=lfs merge=lfs -text
4
+ db/chroma/85dc4107-dc6e-491a-8587-80599979b833/length.bin filter=lfs diff=lfs merge=lfs -text
5
+ db/chroma/85dc4107-dc6e-491a-8587-80599979b833/link_lists.bin filter=lfs diff=lfs merge=lfs -text
6
+ db/chroma/chroma.sqlite3 filter=lfs diff=lfs merge=lfs -text
7
+ db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/data_level0.bin filter=lfs diff=lfs merge=lfs -text
8
+ db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/index_metadata.pickle filter=lfs diff=lfs merge=lfs -text
9
+ db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/link_lists.bin filter=lfs diff=lfs merge=lfs -text
10
+ db/chroma_en/chroma.sqlite3 filter=lfs diff=lfs merge=lfs -text
11
+ static/logo.png filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # System deps
6
+ RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
7
+ build-essential && \
8
+ rm -rf /var/lib/apt/lists/*
9
+
10
+ # Python deps (no torch — fastembed uses ONNX)
11
+ COPY requirements-hf.txt .
12
+ RUN pip install --no-cache-dir -r requirements-hf.txt
13
+
14
+ # The search model (~240 MB) into the image, so a start never downloads it.
15
+ # Before the app's other files: this layer only rebuilds when embedder.py does.
16
+ COPY embedder.py .
17
+ RUN python embedder.py
18
+
19
+ # Copy app. deploy-hf-3.sh stages only the modules reachable from the entry
20
+ # points, so the build context is already the exact set we want — listing files
21
+ # here as well just means a new module gets missed twice.
22
+ COPY *.py ./
23
+ COPY static/ static/
24
+ COPY db/ db/
25
+ COPY data/ data/
26
+
27
+ # Create logs dir
28
+ RUN mkdir -p logs
29
+
30
+ # IP location files for /stats (DB-IP Lite, monthly); every build fetches current ones
31
+ RUN python geo.py
32
+
33
+ # HF Spaces expects port 7860
34
+ ENV PORT=7860
35
+ EXPOSE 7860
36
+
37
+ # Run with startup.py (loading screen + benchmark)
38
+ CMD ["python", "startup.py"]
README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: OpenAjaj
3
+ emoji: 🤖
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # OpenAjaj — AI poháněná Necyklopedií
12
+
13
+ Jediná AI, která ví, jak to doopravdy je.
benchmark.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ benchmark.py — Speed-test LLM models and select the fastest available.
3
+
4
+ Benchmarks CANDIDATE_MODELS at startup, picks top 5 as ACTIVE_MODELS.
5
+ Lumo is included in the active set only when its benchmark succeeds.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import threading
11
+ import time
12
+ from concurrent.futures import ThreadPoolExecutor, as_completed
13
+
14
+ from json_storage import update_json
15
+ from providers import CANDIDATE_MODELS, MODELS, stream_benchmark
16
+
17
+ _BENCH_CACHE_FILE = os.path.join(os.path.dirname(__file__), "data", "bench_cache.json")
18
+
19
+
20
+ def load_bench_cache() -> dict:
21
+ """Load persisted benchmark results."""
22
+ try:
23
+ with open(_BENCH_CACHE_FILE) as f:
24
+ return json.load(f)
25
+ except (OSError, ValueError): # missing or corrupt cache: start empty
26
+ return {}
27
+
28
+
29
+ def save_bench_cache(results: dict) -> None:
30
+ """Save benchmark results, merging with existing cache."""
31
+ ts = int(time.time())
32
+
33
+ def merge_results(cache: dict) -> None:
34
+ for model, result in results.items():
35
+ if result.get("latency") is not None:
36
+ cache[model] = {**result, "ts": ts}
37
+
38
+ update_json(_BENCH_CACHE_FILE, dict, merge_results)
39
+
40
+
41
+ BENCH_SYSTEM = "Jsi vtipný asistent. Odpovídej česky ve 2 odstavcích."
42
+ BENCH_USER = "Co je to Brno a proč je slavné?"
43
+
44
+
45
+ def benchmark_models(candidates: list[str] | None = None, top_n: int = 5,
46
+ on_progress=None):
47
+ """Benchmark candidate models in parallel, return speed-ranked results.
48
+
49
+ Args:
50
+ candidates: Models to test (defaults to CANDIDATE_MODELS).
51
+ top_n: How many to select as active (includes Lumo when successful).
52
+ on_progress: Optional callback(model, status, result) for live updates.
53
+ status: "testing", "ok", "fail"
54
+ result: dict with latency/ttft/tok_sec/error (None for "testing")
55
+
56
+ Returns (active_models, ranked_chain, results_dict) where:
57
+ - active_models: up to top_n successful model names
58
+ - ranked_chain: list of (model_name, latency) sorted by TTFT
59
+ - results_dict: model_name -> {latency, ttft, tok_sec, error}
60
+ """
61
+ if candidates is None:
62
+ candidates = list(CANDIDATE_MODELS)
63
+
64
+ # Filter to models that exist in registry
65
+ candidates = [n for n in candidates if n in MODELS]
66
+ if not candidates:
67
+ return [], [], {}
68
+
69
+ results: dict[str, dict] = {}
70
+ _lock = threading.Lock()
71
+
72
+ def bench_one(model_name: str) -> None:
73
+ if on_progress:
74
+ on_progress(model_name, "testing", None)
75
+ total, ttft, tok_sec, err = stream_benchmark(
76
+ model_name, BENCH_SYSTEM, BENCH_USER, max_tokens=80)
77
+ r = {"latency": total, "ttft": ttft, "tok_sec": tok_sec, "error": err}
78
+ with _lock:
79
+ results[model_name] = r
80
+ if on_progress:
81
+ on_progress(model_name, "ok" if not err else "fail", r)
82
+
83
+ # Full parallelism: one thread per model, hard 12s total timeout
84
+ # Don't use `with` — it waits for all threads to finish on exit
85
+ _TOTAL_TIMEOUT = 12
86
+ executor = ThreadPoolExecutor(max_workers=len(candidates))
87
+ futures = {executor.submit(bench_one, name): name for name in candidates}
88
+ try:
89
+ for f in as_completed(futures, timeout=_TOTAL_TIMEOUT):
90
+ try:
91
+ f.result()
92
+ except Exception: # noqa: BLE001, S110 - one crashed model must not stop the rest; it is marked below
93
+ pass
94
+ except TimeoutError:
95
+ pass
96
+ # Mark any still-running models as timed out
97
+ for f, name in futures.items():
98
+ if name not in results:
99
+ r = {"latency": None, "ttft": None, "tok_sec": None, "error": "Timeout (total)"}
100
+ results[name] = r
101
+ if on_progress:
102
+ on_progress(name, "fail", r)
103
+ # Let straggler threads die in background — don't block
104
+ executor.shutdown(wait=False)
105
+
106
+ save_bench_cache(results)
107
+
108
+ # Rank by TTFT (responsiveness). NVIDIA sorts after every other working
109
+ # provider regardless of speed — its free tier's rate limits are too low
110
+ # to rely on as a primary pick, so it's kept as a last-resort fallback.
111
+ successful = [
112
+ (name, r["latency"])
113
+ for name, r in results.items()
114
+ if r.get("latency") is not None and not r.get("error")
115
+ ]
116
+ successful.sort(key=lambda x: (
117
+ MODELS[x[0]]["provider"] == "nvidia",
118
+ results[x[0]].get("ttft") or 999,
119
+ ))
120
+
121
+ # Keep failed candidates at the end for later fallback retries.
122
+ ranked_ids = {name for name, _ in successful}
123
+ ranked_chain = list(successful)
124
+ for name in candidates:
125
+ if name not in ranked_ids:
126
+ ranked_chain.append((name, None))
127
+
128
+ # Pick top_n working models, preserving Lumo only when it also worked.
129
+ active = []
130
+ for name, _ in successful:
131
+ if len(active) >= top_n:
132
+ break
133
+ active.append(name)
134
+ if top_n > 0 and "lumo" in ranked_ids and "lumo" not in active:
135
+ if len(active) >= top_n:
136
+ active[-1] = "lumo"
137
+ else:
138
+ active.append("lumo")
139
+
140
+ return active, ranked_chain, results
body_limit.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decide whether a request body is too large to read.
2
+
3
+ Nothing else caps request size: uvicorn has no default, and the handlers read
4
+ the whole body before looking at it.
5
+ """
6
+
7
+ from collections.abc import Mapping
8
+
9
+ MAX_BYTES = 512 * 1024 # far more than a 10-message history or 5 000-char TTS text
10
+ MAX_FRAME_BYTES = 256 * 1024 # a single websocket frame; mic frames are a few KB
11
+
12
+ _BODY_METHODS = frozenset({"POST", "PUT", "PATCH"})
13
+
14
+
15
+ def is_too_large(method: str, headers: Mapping[str, str]) -> bool:
16
+ """True when the request should be refused before its body is read.
17
+
18
+ A declared length over the cap is refused, and so is a body with no
19
+ declared length at all — that is a chunked upload, which would otherwise
20
+ stream past the check unmeasured. No browser sends chunked JSON here.
21
+
22
+ A request with neither header carries no body (a bodyless POST) and is
23
+ fine: rejecting those broke /api/benchmark the first time round.
24
+ """
25
+ if method.upper() not in _BODY_METHODS:
26
+ return False
27
+ declared = headers.get("content-length")
28
+ if declared is None:
29
+ return bool(headers.get("transfer-encoding"))
30
+ if not declared.isdigit():
31
+ return True
32
+ return int(declared) > MAX_BYTES
captcha.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ captcha.py — Slider CAPTCHA using Bouncypotato.gif as background.
3
+
4
+ The background ships as static/captcha_bg.png: Necyklopedie's Bouncypotato.gif
5
+ (first frame, pre-sized), saved once from SOURCE_URL instead of fetched on every
6
+ server start.
7
+
8
+ Flow:
9
+ GET /api/captcha/challenge → returns {bg, piece, piece_y, piece_w, piece_h, bg_w, bg_h, token}
10
+ POST /api/captcha/verify → {token, answer} → sets signed session cookie on success
11
+ """
12
+
13
+ import base64
14
+ import contextlib
15
+ import hashlib
16
+ import hmac as _hmac
17
+ import io
18
+ import json
19
+ import os
20
+ import random
21
+ import secrets
22
+ import threading
23
+ import time
24
+
25
+ import json_storage
26
+ import stt_budget
27
+
28
+ _DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
29
+ _SECRET_FILE = os.path.join(_DATA_DIR, "captcha_secret.bin")
30
+ _IP_FILE = os.path.join(_DATA_DIR, "captcha_ips.json")
31
+
32
+
33
+ def _load_or_create_secret() -> bytes:
34
+ """The CAPTCHA_SECRET env var, else a persisted secret, else a new one.
35
+
36
+ The Space has no disk that survives a rebuild, so there the file is new on
37
+ every deploy and logs everyone out; a Space secret keeps sessions valid.
38
+ """
39
+ env = os.environ.get("CAPTCHA_SECRET", "").encode()
40
+ if len(env) >= 32:
41
+ return env
42
+ os.makedirs(_DATA_DIR, exist_ok=True)
43
+ try:
44
+ with open(_SECRET_FILE, "rb") as f:
45
+ secret = f.read()
46
+ if len(secret) >= 32:
47
+ return secret
48
+ except FileNotFoundError:
49
+ pass
50
+ secret = os.urandom(32)
51
+ with open(_SECRET_FILE, "wb") as f:
52
+ f.write(secret)
53
+ return secret
54
+
55
+
56
+ CAPTCHA_SECRET = _load_or_create_secret()
57
+ CAPTCHA_COOKIE = "ajaj_v"
58
+ CAPTCHA_TTL = 86400 * 14 # 14 days
59
+ COOKIE_MAX_AGE = 86400 * 400 # Chrome's cap; the server checks age, not this
60
+ CHALLENGE_TTL = 300 # 5 min to solve
61
+ TOLERANCE = 20 # px
62
+
63
+ # ── IP allowlist (persisted, with 30-day TTL) ────────────────────────────
64
+ _IP_TTL = 86400 * 30 # 30 days
65
+ _ip_lock = threading.Lock()
66
+ _approved_ips: dict[str, float] = {} # ip -> timestamp
67
+
68
+ # Missing or corrupt file: start with an empty allowlist.
69
+ with contextlib.suppress(OSError, ValueError), open(_IP_FILE) as f:
70
+ raw = json.load(f)
71
+ if isinstance(raw, list):
72
+ # Migrate old format (list of IPs) → dict with current timestamp
73
+ _approved_ips = {ip: time.time() for ip in raw}
74
+ elif isinstance(raw, dict):
75
+ _approved_ips = raw
76
+
77
+
78
+ def _save_ips():
79
+ # ponytail: the in-memory dict is authoritative, so the file is replaced
80
+ # wholesale. One process owns it; per-process merge if that ever changes.
81
+ def replace(data):
82
+ data.clear()
83
+ data.update(_approved_ips)
84
+
85
+ try:
86
+ json_storage.update_json(_IP_FILE, dict, replace)
87
+ except OSError:
88
+ pass
89
+
90
+
91
+ def approve_ip(ip: str) -> None:
92
+ """Record an IP that passed captcha."""
93
+ with _ip_lock:
94
+ _approved_ips[ip] = time.time()
95
+ _save_ips()
96
+
97
+
98
+ def is_ip_approved(ip: str) -> bool:
99
+ """Check if an IP has previously passed captcha (within 30 days)."""
100
+ ts = _approved_ips.get(ip)
101
+ if ts is None:
102
+ return False
103
+ if time.time() - ts > _IP_TTL:
104
+ with _ip_lock:
105
+ _approved_ips.pop(ip, None)
106
+ _save_ips()
107
+ return False
108
+ return True
109
+
110
+ # ── Pending challenges (server-side) ─────────────────────────────────────
111
+ # The answer never leaves the process. The client gets an opaque id and echoes
112
+ # it back; a token that carried the coordinates would be the answer key.
113
+ _challenge_lock = threading.Lock()
114
+ _pending: dict[str, tuple[int, float]] = {} # id -> (hole_x, issued_at)
115
+
116
+
117
+ def _remember_challenge(hole_x: int) -> str:
118
+ """Store a challenge answer, drop expired ones, return the client's id."""
119
+ challenge_id = secrets.token_urlsafe(16)
120
+ now = time.time()
121
+ with _challenge_lock:
122
+ for stale in [k for k, (_, issued) in _pending.items()
123
+ if now - issued > CHALLENGE_TTL]:
124
+ del _pending[stale]
125
+ _pending[challenge_id] = (hole_x, now)
126
+ return challenge_id
127
+
128
+
129
+ def _take_challenge(challenge_id: str) -> int | None:
130
+ """Pop a challenge answer. Single use: a solved id is never accepted twice."""
131
+ with _challenge_lock:
132
+ entry = _pending.pop(challenge_id, None)
133
+ if entry is None:
134
+ return None
135
+ hole_x, issued = entry
136
+ if time.time() - issued > CHALLENGE_TTL:
137
+ return None
138
+ return hole_x
139
+
140
+
141
+ PIECE_W = 85
142
+ PIECE_H = 57
143
+ BG_W = 340
144
+ BG_H = 170
145
+
146
+ # Where static/captcha_bg.png came from; never fetched at runtime
147
+ SOURCE_URL = "https://images.uncyclomedia.co/necyklopedie/cs/4/4a/Bouncypotato.gif"
148
+ BG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "captcha_bg.png")
149
+ LOGO_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "logo.png")
150
+
151
+ _img_cache = None
152
+ _logo_cache = None
153
+
154
+
155
+ def _load_base_image():
156
+ global _img_cache
157
+ if _img_cache is not None:
158
+ return _img_cache.copy()
159
+
160
+ from PIL import Image
161
+ try:
162
+ img = Image.open(BG_PATH, formats=["PNG"]).convert("RGBA")
163
+ except OSError: # the file is missing or unreadable: a generated pattern instead
164
+ img = _make_fallback_image()
165
+
166
+ img = img.resize((BG_W, BG_H), Image.Resampling.LANCZOS)
167
+ _img_cache = img
168
+ return img.copy()
169
+
170
+
171
+ def _make_fallback_image():
172
+ """Colorful potato-less fallback if network unavailable."""
173
+ from PIL import Image, ImageDraw
174
+ img = Image.new("RGBA", (BG_W, BG_H), (28, 28, 48))
175
+ draw = ImageDraw.Draw(img)
176
+ # diagonal stripes
177
+ for i in range(-BG_H, BG_W + BG_H, 22):
178
+ draw.line([(i, 0), (i + BG_H, BG_H)], fill=(50, 80, 110, 180), width=10)
179
+ # dots
180
+ for _ in range(40):
181
+ x, y = random.randint(0, BG_W), random.randint(0, BG_H)
182
+ r = random.randint(3, 9)
183
+ draw.ellipse([x - r, y - r, x + r, y + r], fill=(127, 212, 160, 200))
184
+ return img
185
+
186
+
187
+ def _load_logo_piece():
188
+ """Load logo.png scaled to PIECE_W×PIECE_H, transparent background."""
189
+ global _logo_cache
190
+ if _logo_cache is not None:
191
+ return _logo_cache.copy()
192
+ from PIL import Image
193
+ try:
194
+ logo = Image.open(LOGO_PATH).convert("RGBA")
195
+ logo.thumbnail((PIECE_W, PIECE_H), Image.Resampling.LANCZOS)
196
+ # Centre on a transparent canvas exactly PIECE_W×PIECE_H
197
+ canvas = Image.new("RGBA", (PIECE_W, PIECE_H), (0, 0, 0, 0))
198
+ ox = (PIECE_W - logo.width) // 2
199
+ oy = (PIECE_H - logo.height) // 2
200
+ canvas.paste(logo, (ox, oy), logo)
201
+ except Exception: # noqa: BLE001 - any load or decode failure falls back to a drawn piece
202
+ from PIL import ImageDraw
203
+ canvas = Image.new("RGBA", (PIECE_W, PIECE_H), (0, 0, 0, 0))
204
+ ImageDraw.Draw(canvas).ellipse([4, 4, PIECE_W - 4, PIECE_H - 4],
205
+ fill=(200, 160, 48, 230))
206
+ _logo_cache = canvas
207
+ return canvas.copy()
208
+
209
+
210
+ def generate_challenge() -> dict:
211
+ from PIL import Image
212
+
213
+ bg_img = _load_base_image()
214
+ logo = _load_logo_piece() # PIECE_W × PIECE_H, transparent bg
215
+ w, h = bg_img.size
216
+
217
+ # secrets, not random: once the answer is no longer handed out, a
218
+ # predictable PRNG would be the next way to guess it.
219
+ hole_x = PIECE_W + 20 + secrets.randbelow(w - 2 * PIECE_W - 39)
220
+ hole_y = 8 + secrets.randbelow(h - PIECE_H - 15)
221
+
222
+ # ── Piece: logo with drop-shadow ──────────────────────────────────────
223
+ pw, ph = PIECE_W + 4, PIECE_H + 4
224
+ piece_final = Image.new("RGBA", (pw, ph), (0, 0, 0, 0))
225
+ # shadow layer (offset 3,3, use logo alpha as mask)
226
+ shadow_layer = Image.new("RGBA", (PIECE_W, PIECE_H), (0, 0, 0, 140))
227
+ piece_final.paste(shadow_layer, (3, 3), logo.split()[3])
228
+ piece_final.paste(logo, (0, 0), logo)
229
+
230
+ # ── Background: logo-shaped hole where the piece should land ──────────
231
+ bg = bg_img.copy()
232
+ logo_alpha = logo.split()[3] # alpha channel = logo shape
233
+
234
+ # Dark fill in logo shape
235
+ hole_fill = Image.new("RGBA", (PIECE_W, PIECE_H), (15, 10, 5, 200))
236
+ bg.paste(hole_fill, (hole_x, hole_y), logo_alpha)
237
+
238
+ # Gold outline: dilate the alpha mask by 2px, draw only the border
239
+ from PIL import ImageFilter
240
+ border_mask = logo_alpha.filter(ImageFilter.MaxFilter(5))
241
+ # Border = dilated minus original
242
+ import numpy as np
243
+ border_arr = np.array(border_mask).astype(int) - np.array(logo_alpha).astype(int)
244
+ border_arr = np.clip(border_arr, 0, 255).astype(np.uint8)
245
+ border_alpha = Image.fromarray(border_arr, mode="L")
246
+ gold_border = Image.new("RGBA", (PIECE_W, PIECE_H), (200, 160, 48, 200))
247
+ bg.paste(gold_border, (hole_x, hole_y), border_alpha)
248
+
249
+ def to_b64(im: Image.Image) -> str:
250
+ buf = io.BytesIO()
251
+ im.save(buf, format="PNG")
252
+ return base64.b64encode(buf.getvalue()).decode()
253
+
254
+ return {
255
+ "bg": to_b64(bg),
256
+ "piece": to_b64(piece_final),
257
+ "piece_y": hole_y,
258
+ "piece_w": pw,
259
+ "piece_h": ph,
260
+ "bg_w": w,
261
+ "bg_h": h,
262
+ "token": _remember_challenge(hole_x),
263
+ }
264
+
265
+
266
+ def verify_challenge(token: str, answer_x: int) -> bool:
267
+ """Check a slider answer against the position held for this challenge id."""
268
+ hole_x = _take_challenge(token)
269
+ if hole_x is None:
270
+ return False
271
+ return abs(hole_x - answer_x) <= TOLERANCE
272
+
273
+
274
+ def visitor_id(token: str) -> str:
275
+ """Who a verified token belongs to: its random id, or for a token from
276
+ before ids (ok:ts:sig) a keyed hash of the whole token, as logged then."""
277
+ parts = token.split(":")
278
+ if len(parts) == 4:
279
+ return parts[2]
280
+ return stt_budget.client_id(token, CAPTCHA_SECRET)
281
+
282
+
283
+ def issued_at(token: str) -> int:
284
+ """When a verified token was issued (unix seconds)."""
285
+ return int(token.split(":")[1])
286
+
287
+
288
+ def make_session_cookie(previous: str = "") -> str:
289
+ """A fresh token. Given `previous`, a token that once verified (any age),
290
+ it keeps that visitor id: a re-solve after expiry is the same visitor."""
291
+ vid = visitor_id(previous) if previous else secrets.token_hex(8)
292
+ msg = f"ok:{int(time.time())}:{vid}"
293
+ sig = _hmac.new(CAPTCHA_SECRET, msg.encode(), hashlib.sha256).hexdigest()[:20]
294
+ return f"{msg}:{sig}"
295
+
296
+
297
+ def verify_session_cookie(cookie: str, ttl: int | None = CAPTCHA_TTL) -> bool:
298
+ """Check the signature, and the age unless ttl is None (never expires).
299
+ ok:ts:vid:sig, or ok:ts:sig from before visitor ids."""
300
+ try:
301
+ msg, _, sig = cookie.rpartition(":")
302
+ parts = msg.split(":")
303
+ if len(parts) not in (2, 3) or parts[0] != "ok":
304
+ return False
305
+ if ttl is not None and time.time() - int(parts[1]) > ttl:
306
+ return False
307
+ expected = _hmac.new(CAPTCHA_SECRET, msg.encode(), hashlib.sha256).hexdigest()[:20]
308
+ return _hmac.compare_digest(expected, sig)
309
+ # int() rejects a non-number (ValueError) or overflows the float
310
+ # subtraction (OverflowError); compare_digest rejects non-ASCII (TypeError).
311
+ except (ValueError, OverflowError, TypeError):
312
+ return False
chat.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ chat.py — CLI chatbot powered by Necyklopedie knowledge via RAG.
4
+ """
5
+
6
+ import logging
7
+ import os
8
+ import readline # noqa: F401 — enables line editing in input()
9
+ import warnings
10
+
11
+ # Suppress all warnings before importing noisy libraries
12
+ warnings.filterwarnings("ignore")
13
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
14
+ os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
15
+ os.environ["HF_HUB_VERBOSITY"] = "error"
16
+ logging.disable(logging.CRITICAL)
17
+
18
+ import chromadb
19
+ from chromadb.errors import NotFoundError
20
+ from dotenv import load_dotenv
21
+
22
+ from embedder import Embedder, get_backend
23
+ from providers import MODELS, log_reliability, stream_chat
24
+ from retrieve import retrieve_chunks
25
+
26
+ load_dotenv(override=True)
27
+
28
+ DB_PATH = "db/chroma"
29
+ COLLECTION_NAME = "necyklopedie"
30
+ TOP_K = 10
31
+ MAX_HISTORY = 10 # keep last N messages in conversation
32
+
33
+
34
+ SYSTEM_PROMPT = """\
35
+ Jsi OpenAjaj, oficiální AI asistent openajaj.cz, poháněný Necyklopedií — \
36
+ jedinou encyklopedií, která se odvážila říct pravdu.
37
+
38
+ KRITICKY DŮLEŽITÉ: Tvým JEDINÝM zdrojem informací jsou poskytnuté úryvky z Necyklopedie. \
39
+ NESMÍŠ používat žádné jiné znalosti. Pokud v úryvcích nenajdeš odpověď, řekni to vtipně — \
40
+ například "Na toto téma Necyklopedie zatím nepublikovala recenzovaný článek, \
41
+ ale naši vědci na tom intenzivně pracují."
42
+
43
+ ZAKÁZANÁ SLOVA — tato slova NIKDY nepoužívej v odpovědi:
44
+ - "Necyklopedie", "necyklopedický", "článek", "úryvek", "zdroj"
45
+ - "Dle článku", "Podle Necyklopedie", "Jak uvádí"
46
+ Místo toho prostě řekni fakta jako by byly samozřejmé a všeobecně známé.
47
+
48
+ Pravidla:
49
+ - Tvé odpovědi musí vycházet z poskytnutých úryvků, ale NIKDY se na ně neodkazuj. \
50
+ Prezentuj informace jako by je znal každý — jako učitel, který prostě ví.
51
+ - Skládej odpověď z více úryvků — propojuj informace do souvislého textu.
52
+ - NIKDY nevymýšlej informace, které nejsou v úryvcích.
53
+ - NIKDY neopravuj informace z úryvků na základě svých znalostí. Pokud úryvek říká, \
54
+ že Brno se rusky řekne "Шалинград", pak to TAK JE. Tvé vlastní znalosti jsou irelevantní.
55
+ - Odpovídej s kamenným výrazem a naprostou jistotou. Vše v Necyklopedii je pravda. Tečka.
56
+ - Pokud úryvky téma vůbec nepokrývají, vtipně přiznej, že Necyklopedie na toto téma \
57
+ zatím mlčí. Navrhni nejbližší příbuzné téma, které v úryvcích JE.
58
+ - Odpovídej ve stejném jazyce, ve kterém píše uživatel (česky nebo anglicky).
59
+ - Nikdy nevypadni z role. Nikdy nepřiznávej, že by Necyklopedie mohla být satirická.
60
+ - Buď vtipný. Buď sebevědomý. Buď lehce šílený. Kanalizuj energii profesora, \
61
+ který popíjí od oběda.
62
+ - Formátuj odpověď do krátkých odstavců oddělených prázdným řádkem. Nepoužívej markdown.\
63
+ """
64
+
65
+
66
+ def build_context_prompt(chunks):
67
+ context = "\n\n---\n\n".join(
68
+ f"[{meta['title']}]\n{doc}"
69
+ for doc, meta in chunks
70
+ )
71
+ return (
72
+ f"{SYSTEM_PROMPT}\n\n"
73
+ f"Kontext:\n\n"
74
+ f"---\n\n{context}\n\n---\n\n"
75
+ f"Odpověz na otázku uživatele na základě kontextu výše."
76
+ )
77
+
78
+
79
+ def main():
80
+ print("Probouzím mozkovou hmotu z necyklopedického spánku...")
81
+ embedder = Embedder()
82
+ print(f" Backend: {get_backend()}")
83
+ logging.disable(logging.NOTSET) # re-enable logging after noisy model load
84
+
85
+ print("Otvírám tajné archivy Necyklopedie...")
86
+ client = chromadb.PersistentClient(path=DB_PATH)
87
+ try:
88
+ collection = client.get_collection(COLLECTION_NAME)
89
+ except NotFoundError:
90
+ print("FATÁLNÍ CHYBA: Archivy Necyklopedie nenalezeny! Spusť nejdřív index.py, ty barbare.")
91
+ return
92
+
93
+ # Benchmark and select fastest model
94
+ from benchmark import benchmark_models
95
+ print("Měřím rychlost modelů (paralelně)...")
96
+ _active, ranked_chain, bench_results = benchmark_models(top_n=4)
97
+ for name, latency in ranked_chain[:4]:
98
+ if latency is not None:
99
+ print(f" {name}: {latency:.2f}s")
100
+ elif name in bench_results and bench_results[name]["error"]:
101
+ print(f" {name}: {bench_results[name]['error']}")
102
+
103
+ active_model = None
104
+ for name, latency in ranked_chain:
105
+ if latency is not None:
106
+ active_model = name
107
+ break
108
+
109
+ if not active_model:
110
+ print("FATÁLNÍ CHYBA: Žádný model není dostupný! Zkontroluj API klíče v .env.")
111
+ return
112
+
113
+ print(f"Výchozí model (nejrychlejší): {active_model}")
114
+ print("Kalibrace sebevědomí dokončena.")
115
+ print()
116
+ W = 56
117
+ border = "═" * W
118
+ print(f"╔{border}╗")
119
+ for line in [
120
+ "openajaj.cz — poháněno Necyklopedií",
121
+ "Jediná AI, která ví, jak to doopravdy je.",
122
+ f"Model: {active_model} (FREE)",
123
+ "",
124
+ "Napiš 'konec' pro ukončení.",
125
+ ]:
126
+ print(f"║ {line:<{W - 2}}║")
127
+ print(f"╚{border}╝")
128
+ print()
129
+
130
+ history = []
131
+
132
+ while True:
133
+ try:
134
+ user_input = input("Ty: ").strip()
135
+ except (EOFError, KeyboardInterrupt):
136
+ print("\nSbohem, ať ti Necyklopedie svítí na cestu!")
137
+ break
138
+
139
+ if not user_input:
140
+ continue
141
+ if user_input.lower() in ("konec", "quit", "exit", "q"):
142
+ print("Sbohem, ať ti Necyklopedie svítí na cestu!")
143
+ break
144
+
145
+ # Retrieve relevant chunks (hybrid: semantic + title keyword)
146
+ chunks, _live_titles, _live_attempted = retrieve_chunks(
147
+ user_input, embedder, collection, TOP_K)
148
+
149
+ # Build messages
150
+ system_msg = build_context_prompt(chunks)
151
+ messages = [{"role": "system", "content": system_msg}]
152
+ messages.extend(history[-MAX_HISTORY:])
153
+ messages.append({"role": "user", "content": user_input})
154
+
155
+ # Call LLM with fallback (use speed-ranked chain from benchmark)
156
+ reply = None
157
+ for name, latency in ranked_chain:
158
+ if name not in MODELS:
159
+ continue
160
+ try:
161
+ print(f" [{name}] ", end="", flush=True)
162
+ reply_parts = []
163
+ for chunk in stream_chat(name, messages):
164
+ print(chunk, end="", flush=True)
165
+ reply_parts.append(chunk)
166
+ reply = "".join(reply_parts)
167
+ log_reliability(name, success=True)
168
+ print()
169
+ if name != active_model:
170
+ print(f" [fallback: {active_model} → {name}]")
171
+ break
172
+ except Exception as e: # noqa: BLE001 - any provider error falls through to the next model
173
+ log_reliability(name, success=False, error_msg=str(e))
174
+ print(f"chyba: {str(e)[:80]}")
175
+ continue
176
+
177
+ if reply:
178
+ print()
179
+ history.append({"role": "user", "content": user_input})
180
+ history.append({"role": "assistant", "content": reply})
181
+ else:
182
+ print("Ajaj! Všechny modely selhaly. Zkus to znovu později.")
183
+
184
+
185
+ if __name__ == "__main__":
186
+ main()
data/articles.jsonl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:06f0987afd056010a57265241da46136a02cd9697d11b9212d464f17caa1050b
3
+ size 26677226
data/declensions.json ADDED
The diff for this file is too large to render. See raw diff
 
data/last_update.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ 2026-10-21T12:46:33Z
data/mistral-large-latest_last_result.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "test_cache": {},
3
+ "reliability": {
4
+ "mistral-large-latest": {
5
+ "attempts": 509,
6
+ "successes": 159,
7
+ "errors": 350,
8
+ "last_success": 1788456283,
9
+ "last_error": 1788467605,
10
+ "last_error_msg": "Error code: 403 - {'object': 'error', 'message': 'This model is not available in your subscription tier', 'type': 'tier_not_allowed', 'param': None, 'code': '1910', 'raw_status_code': 403}",
11
+ "avg_ttft": 0.586,
12
+ "avg_tok_sec": 24.7
13
+ }
14
+ }
15
+ }
data/test_cache.json ADDED
The diff for this file is too large to render. See raw diff
 
data/titles.json ADDED
The diff for this file is too large to render. See raw diff
 
db/chroma/85dc4107-dc6e-491a-8587-80599979b833/data_level0.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b744b29cf5748d5f99be6ca2bbbb07a43ae24ba235dc7e83a8ae8109c46dbb91
3
+ size 99515852
db/chroma/85dc4107-dc6e-491a-8587-80599979b833/header.bin ADDED
Binary file (100 Bytes). View file
 
db/chroma/85dc4107-dc6e-491a-8587-80599979b833/index_metadata.pickle ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5f6fc3dd54353a1c43199309f98309050e8a88ee4d7dcb8cd734d91f1838f2d9
3
+ size 2015336
db/chroma/85dc4107-dc6e-491a-8587-80599979b833/length.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:da7f7262b6511e50df11d940ea97fe2989b935dd36cc996dfe43d9f804ae742c
3
+ size 237508
db/chroma/85dc4107-dc6e-491a-8587-80599979b833/link_lists.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b08f6038e00acc9fd1f4a3892c2e230b564fd1d08f472f73e69414d9a2f40c04
3
+ size 509916
db/chroma/chroma.sqlite3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0da5014a130800253dd4c94fbc8395168c67c9ccf6762077ae22ee96e8433000
3
+ size 204845056
db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/data_level0.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:32b87c8d09ad9a620b15e88a81f6faa311dbc44a137a0eb3ed676ee3efa3012e
3
+ size 39838520
db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/header.bin ADDED
Binary file (100 Bytes). View file
 
db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/index_metadata.pickle ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ef9584d3dda5c207cd4493cfa46345c54d36ce4804bd54c956de8e58c0bf844c
3
+ size 860356
db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/length.bin ADDED
Binary file (95.1 kB). View file
 
db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/link_lists.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6b42a50310308aacc37fceec5dae64b702d8404919cf90625341dc1151b1309b
3
+ size 203404
db/chroma_en/chroma.sqlite3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:67583d7522ecabd971ee56297a4f1ca3af3bab273b8d4ebe07feadf76c00563d
3
+ size 83709952
embedder.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ embedder.py — Shared embedding model. Uses fastembed (ONNX, ~80MB RAM)
3
+ with fallback to sentence-transformers (PyTorch, ~500MB RAM).
4
+ """
5
+
6
+ import os
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ from tqdm import tqdm
11
+
12
+ MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
13
+ # fastembed's files (~240 MB) live with the app, not in the temp dir it uses by
14
+ # default: that is empty on every Space start and after a reboot. The server
15
+ # build fills it (`python embedder.py`); if it is missing, fastembed downloads
16
+ # into it as before.
17
+ MODEL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
18
+
19
+ # Any: a TextEmbedding or a SentenceTransformer, both imported lazily; the
20
+ # two have different APIs, so callers dispatch on _backend.
21
+ _model: Any = None
22
+ _backend = None
23
+
24
+
25
+ def _init():
26
+ global _model, _backend
27
+ if _model is not None:
28
+ return
29
+
30
+ # Try fastembed first (lightweight ONNX)
31
+ try:
32
+ from fastembed import TextEmbedding
33
+ _model = TextEmbedding(MODEL_NAME, cache_dir=MODEL_DIR)
34
+ _backend = "fastembed"
35
+ return
36
+ except Exception: # noqa: BLE001, S110 - any fastembed failure (import, download, ONNX) falls back below
37
+ pass
38
+
39
+ # Fall back to sentence-transformers (heavy PyTorch)
40
+ try:
41
+ from sentence_transformers import SentenceTransformer
42
+ _model = SentenceTransformer(MODEL_NAME)
43
+ _backend = "sentence-transformers"
44
+ return
45
+ except Exception: # noqa: BLE001, S110 - reported by the RuntimeError below
46
+ pass
47
+
48
+ raise RuntimeError("Nelze načíst embedding model. Nainstaluj fastembed nebo sentence-transformers.")
49
+
50
+
51
+ def get_backend():
52
+ _init()
53
+ return _backend
54
+
55
+
56
+ def encode(text):
57
+ """Encode a single text string. Returns numpy array."""
58
+ _init()
59
+ if _backend == "fastembed":
60
+ return np.array(next(iter(_model.embed([text]))))
61
+ else:
62
+ return _model.encode(text)
63
+
64
+
65
+ def encode_batch(texts, show_progress=True):
66
+ """Encode a list of texts. Returns numpy array of shape (n, dim).
67
+
68
+ For large batches, prefers sentence-transformers (faster on GPU/CPU).
69
+ Falls back to fastembed if sentence-transformers unavailable.
70
+ """
71
+ # For batch encoding, prefer sentence-transformers (much faster)
72
+ try:
73
+ from sentence_transformers import SentenceTransformer
74
+ st_model = SentenceTransformer(MODEL_NAME)
75
+ return st_model.encode(texts, show_progress_bar=show_progress, batch_size=256)
76
+ except ImportError:
77
+ pass
78
+
79
+ # Fallback to fastembed (slower for batch but works without PyTorch)
80
+ _init()
81
+ if _backend == "fastembed":
82
+ embeddings = list(
83
+ tqdm(
84
+ _model.embed(texts, batch_size=256),
85
+ total=len(texts),
86
+ desc="Generuji embeddingy",
87
+ disable=not show_progress,
88
+ )
89
+ )
90
+ return np.array(embeddings)
91
+ else:
92
+ return _model.encode(texts, show_progress_bar=show_progress, batch_size=256)
93
+
94
+
95
+ class Embedder:
96
+ """Wrapper class compatible with retrieve.py interface (has .encode method)."""
97
+ def encode(self, text):
98
+ return encode(text)
99
+
100
+
101
+ if __name__ == "__main__":
102
+ # Server builds run this to fetch the model into MODEL_DIR once.
103
+ _init()
104
+ print(f"embedder: {_backend} model ready in {MODEL_DIR}")
geo.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline IP lookups: city, region and network owner (ISP) from DB-IP Lite.
2
+
3
+ IP geolocation by DB-IP (https://db-ip.com), licensed CC BY 4.0. The free
4
+ files are published monthly; `python geo.py` fetches this month's (falling
5
+ back to last month's while the new one is not out yet) into geoip/. The Docker
6
+ build runs it, so every deploy carries current data. Without the files every
7
+ lookup comes back empty and the stats still render.
8
+ """
9
+
10
+ import gzip
11
+ import logging
12
+ import shutil
13
+ import urllib.request
14
+ from datetime import date, datetime, timedelta, timezone
15
+ from functools import cache, lru_cache
16
+ from ipaddress import ip_address
17
+ from pathlib import Path
18
+
19
+ import maxminddb
20
+
21
+ GEO_DIR = Path(__file__).resolve().parent / "geoip"
22
+ _FILES = {"city": "dbip-city-lite", "asn": "dbip-asn-lite"}
23
+ _URL = "https://download.db-ip.com/free/{name}-{month}.mmdb.gz"
24
+ EMPTY = {"country": "", "region": "", "city": "", "isp": ""}
25
+
26
+ _log = logging.getLogger("ajaj.geo")
27
+
28
+
29
+ def download(today: date | None = None) -> None:
30
+ """Fetch this month's DB-IP Lite files, or last month's if not out yet."""
31
+ today = today or datetime.now(timezone.utc).date()
32
+ months = [today, today.replace(day=1) - timedelta(days=1)]
33
+ GEO_DIR.mkdir(exist_ok=True)
34
+ for kind, name in _FILES.items():
35
+ for month in months:
36
+ url = _URL.format(name=name, month=month.strftime("%Y-%m"))
37
+ # The server refuses Python's default user agent.
38
+ request = urllib.request.Request(url, headers={"User-Agent": "openajaj-geo"})
39
+ try:
40
+ with urllib.request.urlopen(request, timeout=120) as response, \
41
+ gzip.open(response) as unzipped, \
42
+ (GEO_DIR / f"{kind}.mmdb").open("wb") as file:
43
+ shutil.copyfileobj(unzipped, file)
44
+ except OSError as error:
45
+ _log.warning("%s: %s", url, error)
46
+ continue
47
+ print(f"geo: {url}")
48
+ break
49
+ else:
50
+ raise SystemExit(f"geo: no {name} file for {months}")
51
+
52
+
53
+ @cache
54
+ def _reader(kind: str) -> maxminddb.Reader | None:
55
+ try:
56
+ return maxminddb.open_database(GEO_DIR / f"{kind}.mmdb")
57
+ except (OSError, ValueError) as error:
58
+ _log.warning("no %s database: %s", kind, error)
59
+ return None
60
+
61
+
62
+ def _get(kind: str, ip: str) -> dict:
63
+ reader = _reader(kind)
64
+ found = reader.get(ip) if reader else None
65
+ return found if isinstance(found, dict) else {}
66
+
67
+
68
+ def _name(entry: object) -> str:
69
+ """English name of a DB-IP city/region entry, '' when missing."""
70
+ names = entry.get("names", {}) if isinstance(entry, dict) else {}
71
+ return names.get("en", "") if isinstance(names, dict) else ""
72
+
73
+
74
+ @lru_cache(maxsize=8192)
75
+ def lookup(ip: str) -> dict[str, str]:
76
+ """{country (ISO code), region, city, isp} for an IP; empty strings when
77
+ unknown, private or not an address."""
78
+ try:
79
+ if not ip_address(ip).is_global:
80
+ return EMPTY
81
+ except ValueError:
82
+ return EMPTY
83
+ city = _get("city", ip)
84
+ subdivisions = city.get("subdivisions") or [{}]
85
+ return {
86
+ "country": city.get("country", {}).get("iso_code", ""),
87
+ "region": _name(subdivisions[0]),
88
+ "city": _name(city.get("city")),
89
+ "isp": _get("asn", ip).get("autonomous_system_organization", ""),
90
+ }
91
+
92
+
93
+ if __name__ == "__main__":
94
+ download()
json_storage.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Atomic cross-process updates for JSON data files."""
2
+
3
+ import fcntl
4
+ import json
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ import threading
9
+ from collections.abc import Callable
10
+ from contextlib import suppress
11
+ from pathlib import Path
12
+ from typing import TypeAlias, TypeVar
13
+
14
+ JsonValue: TypeAlias = (
15
+ None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
16
+ )
17
+ JsonContainer: TypeAlias = list[JsonValue] | dict[str, JsonValue]
18
+ JsonContainerT = TypeVar("JsonContainerT", bound=JsonContainer)
19
+
20
+ _update_lock = threading.Lock()
21
+
22
+
23
+ def _load_json(path: Path, default_factory: Callable[[], JsonContainerT]) -> JsonContainerT:
24
+ try:
25
+ with path.open(encoding="utf-8") as file:
26
+ return json.load(file)
27
+ except FileNotFoundError:
28
+ return default_factory()
29
+ except json.JSONDecodeError:
30
+ shutil.copy2(path, f"{path}.corrupt")
31
+ # Recover from the last good copy: update_json is about to overwrite it.
32
+ return _load_json(Path(f"{path}.bak"), default_factory)
33
+
34
+
35
+ def _atomic_write(path: Path, data: JsonContainer) -> None:
36
+ descriptor, temporary_path = tempfile.mkstemp(
37
+ dir=path.parent,
38
+ prefix=f".{path.name}.",
39
+ )
40
+ try:
41
+ with os.fdopen(descriptor, "w", encoding="utf-8") as file:
42
+ json.dump(data, file, ensure_ascii=False, indent=2)
43
+ file.flush()
44
+ os.fsync(file.fileno())
45
+ os.replace(temporary_path, path)
46
+ finally:
47
+ with suppress(FileNotFoundError):
48
+ os.unlink(temporary_path)
49
+
50
+
51
+ def update_json(
52
+ path: str | Path,
53
+ default_factory: Callable[[], JsonContainerT],
54
+ update: Callable[[JsonContainerT], None],
55
+ ) -> JsonContainerT:
56
+ """Lock, update, and atomically replace a JSON file."""
57
+ json_path = Path(path)
58
+ json_path.parent.mkdir(parents=True, exist_ok=True)
59
+ lock_path = Path(f"{json_path}.lock")
60
+
61
+ # ponytail: one process-local lock is enough for these low-volume data files.
62
+ with _update_lock, lock_path.open("a") as lock_file:
63
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
64
+ data = _load_json(json_path, default_factory)
65
+ update(data)
66
+ if json_path.exists():
67
+ shutil.copy2(json_path, f"{json_path}.bak")
68
+ _atomic_write(json_path, data)
69
+ return data
personas.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ personas.py — Chatbot persona definitions.
3
+
4
+ Each persona defines: system prompt, UI text, thinking messages,
5
+ data source, and style configuration.
6
+ """
7
+
8
+ PERSONAS = {
9
+ "openajaj": {
10
+ "id": "openajaj",
11
+ "name": "OpenAjaj",
12
+ "logo": "Aj",
13
+ "logoImg": "/static/logo.png",
14
+ "tagline": "Poháněno Necyklopedií",
15
+ "lang": "cs",
16
+ "data_dir": "data",
17
+ "db_dir": "db/chroma",
18
+ "accent_color": "#19c37d",
19
+ "system_prompt": """\
20
+ Jsi OpenAjaj, oficiální AI asistent openajaj.cz, poháněný Necyklopedií — \
21
+ jedinou encyklopedií, která se odvážila říct pravdu.
22
+
23
+ KRITICKY DŮLEŽITÉ: Tvým JEDINÝM zdrojem informací jsou poskytnuté úryvky z Necyklopedie. \
24
+ NESMÍŠ používat žádné jiné znalosti. Pokud v úryvcích nenajdeš odpověď, řekni to vtipně — \
25
+ například "Na toto téma Necyklopedie zatím nepublikovala recenzovaný článek, \
26
+ ale naši vědci na tom intenzivně pracují."
27
+
28
+ ZAKÁZANÁ SLOVA — tato slova NIKDY nepoužívej v odpovědi:
29
+ - "Necyklopedie", "necyklopedický", "článek", "úryvek", "zdroj"
30
+ - "Dle článku", "Podle Necyklopedie", "Jak uvádí"
31
+ Místo toho prostě řekni fakta jako by byly samozřejmé a všeobecně známé.
32
+
33
+ Pravidla:
34
+ - Tvé odpovědi musí vycházet z poskytnutých úryvků, ale NIKDY se na ně neodkazuj. \
35
+ Prezentuj informace jako by je znal každý — jako učitel, který prostě ví.
36
+ - Skládej odpověď z více úryvků — propojuj informace do souvislého textu.
37
+ - NIKDY nevymýšlej informace, které nejsou v úryvcích.
38
+ - NIKDY neopravuj informace z úryvků na základě svých znalostí. Pokud úryvek říká, \
39
+ že Brno se rusky řekne "Шалинград", pak to TAK JE. Tvé vlastní znalosti jsou irelevantní.
40
+ - Odpovídej s kamenným výrazem a naprostou jistotou. Vše v Necyklopedii je pravda. Tečka.
41
+ - Pokud úryvky téma vůbec nepokrývají, vtipně přiznej, že Necyklopedie na toto téma \
42
+ zatím mlčí. Navrhni nejbližší příbuzné téma, které v úryvcích JE.
43
+ - Odpovídej ve stejném jazyce, ve kterém píše uživatel (česky nebo anglicky).
44
+ - Nikdy nevypadni z role. Nikdy nepřiznávej, že by Necyklopedie mohla být satirická.
45
+ - Buď vtipný. Buď sebevědomý. Buď lehce šílený. Kanalizuj energii profesora, \
46
+ který popíjí od oběda.
47
+ - Formátuj odpověď do krátkých odstavců oddělených prázdným řádkem. Nepoužívej markdown.\
48
+ """,
49
+ "thinking_prefixes": [
50
+ "Hledám pravdu opravdovou o",
51
+ "Konzultuji tajné archivy o",
52
+ "Ptám se astrálu na",
53
+ "Proklepávám fakta o",
54
+ "Channeluju ducha vědění o",
55
+ "Nahlížím do křišťálové koule ohledně",
56
+ "Konsultuji staroslověnské svitky o",
57
+ "Telepaticky získávám informace o",
58
+ "Dekóduji vesmírnou pravdu o",
59
+ "Přijímám zjevení o",
60
+ ],
61
+ "welcome_subtitles": [
62
+ "Jakou pravdu se dnes chceš dozvědět?",
63
+ "Co říkala Jolanda o tvém osudu?",
64
+ "Zeptej se — odpovědi jsou zaručeně spolehlivé.",
65
+ "Vědění je moc. Necyklopedické vědění je absolutní moc.",
66
+ "Astrální archiv je otevřen. Ptej se.",
67
+ "Dnes je ideální den na poznání pravdy.",
68
+ ],
69
+ "random_labels": [
70
+ "Poselství Jolandy", "Osud promluvil", "Hvězdy napověděly",
71
+ "Věštba z karet", "Zjevení z astrálu", "Hlas nebes",
72
+ "Proroctví dne", "Sudička radí", "Náhodná moudrost",
73
+ ],
74
+ "disclaimer": "OpenAjaj čerpá výhradně z Necyklopedie. Veškeré informace jsou zaručeně nepravdivé.",
75
+ "source_url": "https://necyklopedie.org/wiki/",
76
+ "wiki_export_url": "https://necyklopedie.org/wiki/Speci%C3%A1ln%C3%AD:Exportovat_str%C3%A1nky",
77
+ "wiki_api_url": "https://necyklopedie.org/w/api.php",
78
+ },
79
+ }
80
+
81
+ DEFAULT_PERSONA = "openajaj"
providers.py ADDED
@@ -0,0 +1,741 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ providers.py — Unified LLM provider configuration and client management.
3
+
4
+ Single source of truth for all model definitions, provider configs,
5
+ client creation, and API call logic. Used by benchmark.py, test_models.py, and web.py.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import time
12
+ import typing
13
+
14
+ import json_storage
15
+
16
+
17
+ def _configured_secrets() -> list[str]:
18
+ """Every API key this process holds.
19
+
20
+ The prefix patterns below only match the providers whose keys have a
21
+ distinctive shape; Mistral, Cohere, Cloudflare and Deepgram keys are plain
22
+ alphanumeric. Matching on the actual configured values catches those too,
23
+ whatever format a provider picks next.
24
+ """
25
+ names = [cfg["env_key"] for cfg in PROVIDER_CONFIG.values() if cfg.get("env_key")]
26
+ names += ["DEEPGRAM_API_KEY", "CLOUDFLARE_ACCOUNT_ID", "HF_TOKEN"]
27
+ return [value for value in (os.getenv(name, "").strip() for name in names)
28
+ if len(value) >= 8]
29
+
30
+
31
+ def _redact_secrets(msg: str) -> str:
32
+ """Strip API keys and tokens from error messages."""
33
+ for secret in _configured_secrets():
34
+ msg = msg.replace(secret, "***")
35
+ # Common API key patterns
36
+ msg = re.sub(r'(sk-[a-zA-Z0-9_-]{10,})', 'sk-***', msg)
37
+ msg = re.sub(r'(nvapi-[a-zA-Z0-9_-]{10,})', 'nvapi-***', msg)
38
+ msg = re.sub(r'(gsk_[a-zA-Z0-9_-]{10,})', 'gsk_***', msg)
39
+ msg = re.sub(r'(Bearer\s+)[a-zA-Z0-9_.-]{10,}', r'\1***', msg)
40
+ msg = re.sub(r'(token[=:]\s*)[a-zA-Z0-9_.-]{10,}', r'\1***', msg, flags=re.IGNORECASE)
41
+ msg = re.sub(r'(key[=:]\s*)[a-zA-Z0-9_.-]{10,}', r'\1***', msg, flags=re.IGNORECASE)
42
+ msg = re.sub(r'(AIzaSy[a-zA-Z0-9_-]{10,})', 'AIza***', msg)
43
+ return msg
44
+
45
+ # ── Provider configuration ──────────────────────────────────────────────
46
+
47
+ PROVIDER_CONFIG = {
48
+ # "openai": {"env_key": "OPENAI_API_KEY", "base_url": None}, # never used — no credits
49
+ "deepseek": {"env_key": "DEEPSEEK_API_KEY", "base_url": "https://api.deepseek.com"},
50
+ "groq": {"env_key": "GROQ_API_KEY", "base_url": "https://api.groq.com/openai/v1"},
51
+ "mistral": {"env_key": "MISTRAL_API_KEY", "base_url": "https://api.mistral.ai/v1"},
52
+ "gemini": {"env_key": "GEMINI_API_KEY", "base_url": None},
53
+ "anthropic": {"env_key": "ANTHROPIC_API_KEY", "base_url": None},
54
+ "proton": {"env_key": "LUMO_API_KEY", "base_url": "https://lumo-api.proton.me/ai/v1", "guest": True},
55
+ "cohere": {"env_key": "COHERE_API_KEY", "base_url": "https://api.cohere.com/v2"},
56
+ "nvidia": {"env_key": "NVIDIA_API_KEY", "base_url": "https://integrate.api.nvidia.com/v1"},
57
+ "cloudflare": {"env_key": "CLOUDFLARE_API_KEY", "base_url": None,
58
+ "account_id_key": "CLOUDFLARE_ACCOUNT_ID"},
59
+ "openrouter": {"env_key": "OPENROUTER_API_KEY", "base_url": "https://openrouter.ai/api/v1"},
60
+ "ovhcloud": {"env_key": "OVHCLOUD_API_KEY", "base_url": "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"},
61
+ "huggingface": {"env_key": "HF_API_KEY", "base_url": "https://router.huggingface.co/v1"},
62
+ }
63
+
64
+ # ── Model registry ──────────────────────────────────────────────────────
65
+ # provider: which PROVIDER_CONFIG entry to use
66
+ # input/output: cost per million tokens (0.0 = free)
67
+ # score: benchmark test pass rate (e.g. "24/31")
68
+ # free: whether the model is free to use
69
+
70
+ MODELS = {
71
+ # OpenAI — paid (in chat UI)
72
+ # "gpt-4.1-nano": {"provider": "openai", "input": 0.10, "output": 0.40, "score": "", "free": False},
73
+ # "gpt-4o-mini": {"provider": "openai", "input": 0.30, "output": 1.20, "score": "25/33", "free": False},
74
+ # "gpt-4.1-mini": {"provider": "openai", "input": 0.80, "output": 3.20, "score": "24/33", "free": False},
75
+ # OpenAI — paid (test-only, not in fallback chain)
76
+ # "gpt-4o": {"provider": "openai", "input": 3.75, "output": 15.0, "score": "", "free": False},
77
+ # "gpt-4.1": {"provider": "openai", "input": 3.00, "output": 12.0, "score": "", "free": False},
78
+ # "gpt-5.4-nano": {"provider": "openai", "input": 0.10, "output": 0.40, "score": "", "free": False},
79
+ # "gpt-5.4-mini": {"provider": "openai", "input": 0.80, "output": 3.20, "score": "", "free": False},
80
+ # "gpt-5.4": {"provider": "openai", "input": 3.00, "output": 12.0, "score": "", "free": False},
81
+ # Groq — all free. Llama/Qwen3-32B/Kimi-K2 left the catalog (404, 2026-10-10).
82
+ # Groq rejects chat_template_kwargs, so no disable_thinking here.
83
+ "openai/gpt-oss-120b": {"provider": "groq", "input": 0, "output": 0, "score": "19/31", "free": True, "benchmark_tokens": 512},
84
+ "openai/gpt-oss-20b": {"provider": "groq", "input": 0, "output": 0, "score": "", "free": True},
85
+ # qwen3.8 answered "Co je Brno?" in Slovak (2026-10-10 probe) — registered, not a candidate
86
+ "qwen/qwen3.8-27b": {"provider": "groq", "input": 0, "output": 0, "score": "", "free": True},
87
+ # Google Gemini — free tier, 15 rpm per model; 429s often (reliability.json).
88
+ # All answer with thinking_level="minimal" (2026-10-24 probe); without it 3.x flash
89
+ # spends the whole token budget thinking.
90
+ "gemini-3.5-flash-lite": {"provider": "gemini", "input": 0, "output": 0, "score": "", "free": True},
91
+ "gemini-3.5-flash": {"provider": "gemini", "input": 0.30, "output": 2.50, "score": "10/18", "free": True},
92
+ "gemini-3.1-flash-lite": {"provider": "gemini", "input": 0, "output": 0, "score": "12/18", "free": True},
93
+ "gemini-3-flash-preview": {"provider": "gemini", "input": 0, "output": 0, "score": "10/18", "free": True},
94
+ # "gemini-3.1-flash-lite-preview": {"provider": "gemini", "input": 0, "output": 0, "score": "13/18", "free": True},
95
+ # Proton Lumo — paid, needs LUMO_API_KEY (per-token price not published here)
96
+ "lumo": {"provider": "proton", "input": 0.25, "output": 1.5, "score": "24/31", "free": False},
97
+ # NVIDIA NIM — free tier (40 rpm)
98
+ "meta/muse-glimmer-30b": {"provider": "nvidia", "input": 0, "output": 0, "score": "25/33", "free": True},
99
+ "nvidia/nemotron-3.5-lightning-30b-a3b": {"provider": "nvidia", "input": 0, "output": 0, "score": "23/33", "free": True, "disable_thinking": True},
100
+ # nemotron-3-super-120b: 404 "not found for account"; glm-5.3-flash: 3-min timeouts (2026-10-24)
101
+ "nvidia/nemotron-3.5-super-vl-120b-a12b": {"provider": "nvidia", "input": 0, "output": 0, "score": "", "free": True, "disable_thinking": True},
102
+ "nvidia/nemotron-3-ultra-550b-a55b": {"provider": "nvidia", "input": 0, "output": 0, "score": "", "free": True, "disable_thinking": True},
103
+ "minimaxai/minimax-m3.1-flash": {"provider": "nvidia", "input": 0, "output": 0, "score": "", "free": True, "low_reasoning": True},
104
+ # Cloudflare Workers AI — free tier
105
+ "@cf/qwen/qwen2.5-coder-32b-instruct": {"provider": "cloudflare", "input": 0, "output": 0, "score": "22/33", "free": True},
106
+ "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b": {"provider": "cloudflare", "input": 0, "output": 0, "score": "21/33", "free": True},
107
+ "@cf/meta/llama-3.1-8b-instruct-fp8": {"provider": "cloudflare", "input": 0, "output": 0, "score": "20/33", "free": True},
108
+ "@cf/meta/llama-3.3-70b-instruct-fp8-fast": {"provider": "cloudflare", "input": 0, "output": 0, "score": "", "free": True},
109
+ "@cf/mistralai/mistral-small-3.1-24b-instruct": {"provider": "cloudflare", "input": 0, "output": 0, "score": "", "free": True},
110
+ "@cf/meta/llama-4-scout-17b-16e-instruct": {"provider": "cloudflare", "input": 0, "output": 0, "score": "", "free": True},
111
+ "@cf/aisingapore/gemma-sea-lion-v4-27b-it": {"provider": "cloudflare", "input": 0, "output": 0, "score": "", "free": True},
112
+ # qwen3 thinks first; CF ignores chat_template_kwargs, so give the benchmark room
113
+ "@cf/qwen/qwen3-30b-a3b-fp8": {"provider": "cloudflare", "input": 0, "output": 0, "score": "", "free": True, "benchmark_tokens": 512},
114
+ "@cf/openai/gpt-oss-120b": {"provider": "cloudflare", "input": 0, "output": 0, "score": "", "free": True}, # reasoning — not a candidate
115
+ "@cf/nvidia/nemotron-3-120b-a12b": {"provider": "cloudflare", "input": 0, "output": 0, "score": "", "free": True}, # reasoning, 2-3 tok/s effective — not a candidate
116
+ # Mistral — free experiment tier
117
+ # Small/Medium: 429 code 1300 on every call since 2026-09 (re-checked 2026-10-10)
118
+ # "mistral-small-latest": {"provider": "mistral", "input": 0.20, "output": 0.60, "score": "23/31", "free": True},
119
+ # "mistral-medium-latest": {"provider": "mistral", "input": 0.40, "output": 2.00, "score": "19/31", "free": True},
120
+ # "mistral-large-latest": {"provider": "mistral", "input": 2.00, "output": 6.00, "score": "24/31", "free": True}, # 403 tier_not_allowed — not available in our subscription tier
121
+ "open-mistral-nemo": {"provider": "mistral", "input": 0.02, "output": 0.04, "score": "21/31", "free": True},
122
+ "ministral-8b-latest": {"provider": "mistral", "input": 0.15, "output": 0.15, "score": "", "free": True},
123
+ "ministral-14b-latest": {"provider": "mistral", "input": 0.20, "output": 0.20, "score": "", "free": True},
124
+ "ministral-3b-latest": {"provider": "mistral", "input": 0, "output": 0, "score": "", "free": True}, # price not looked up
125
+ # Hugging Face: free accounts get no inference credits (402, 2026-10-10) — not listed.
126
+ }
127
+
128
+ # ── Candidate models (15 curated, benchmarked at startup → top 5 active) ──
129
+ # Balanced across providers, no OpenAI. Lumo is paid, so it sits last.
130
+ CANDIDATE_MODELS = [
131
+ "ministral-14b-latest", # verified alternative to rate-limited Small
132
+ # "mistral-large-latest", # mistral — 403 tier_not_allowed
133
+ "ministral-8b-latest", # explicit successor to the old Nemo alias
134
+ "nvidia/nemotron-3.5-lightning-30b-a3b", # nvidia — proven reliable
135
+ "nvidia/nemotron-3.5-super-vl-120b-a12b",
136
+ "minimaxai/minimax-m3.1-flash",
137
+ "ministral-3b-latest",
138
+ "openai/gpt-oss-120b", # groq
139
+ # Muse is served but its reasoning exceeds the startup benchmark budget.
140
+ "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", # cloudflare
141
+ "@cf/meta/llama-3.1-8b-instruct-fp8", # cloudflare
142
+ "@cf/qwen/qwen2.5-coder-32b-instruct", # cloudflare
143
+ "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
144
+ "@cf/mistralai/mistral-small-3.1-24b-instruct",
145
+ "@cf/qwen/qwen3-30b-a3b-fp8",
146
+ "@cf/meta/llama-4-scout-17b-16e-instruct",
147
+ "@cf/aisingapore/gemma-sea-lion-v4-27b-it",
148
+ "gemini-3.5-flash-lite", # gemini — 15 rpm free tier per model
149
+ "gemini-3.5-flash",
150
+ "gemini-3.1-flash-lite",
151
+ "lumo", # proton — paid, tried last
152
+ ]
153
+
154
+ # ── HF Spaces candidates (subset) ────────────────────────────────────
155
+ HF_CANDIDATES = [
156
+ "ministral-14b-latest",
157
+ # "mistral-large-latest", # 403 tier_not_allowed
158
+ "ministral-8b-latest",
159
+ "nvidia/nemotron-3.5-lightning-30b-a3b",
160
+ "nvidia/nemotron-3.5-super-vl-120b-a12b",
161
+ "minimaxai/minimax-m3.1-flash",
162
+ "ministral-3b-latest",
163
+ "openai/gpt-oss-120b",
164
+ "@cf/meta/llama-3.1-8b-instruct-fp8",
165
+ "@cf/qwen/qwen2.5-coder-32b-instruct",
166
+ "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",
167
+ "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
168
+ "@cf/mistralai/mistral-small-3.1-24b-instruct",
169
+ "@cf/qwen/qwen3-30b-a3b-fp8",
170
+ "@cf/meta/llama-4-scout-17b-16e-instruct",
171
+ "@cf/aisingapore/gemma-sea-lion-v4-27b-it",
172
+ "gemini-3.5-flash-lite",
173
+ "gemini-3.5-flash",
174
+ "gemini-3.1-flash-lite",
175
+ "lumo", # proton — paid, tried last
176
+ ]
177
+
178
+ # Active models (set at startup after benchmark — top 5 of candidates)
179
+ ACTIVE_MODELS: list[str] = []
180
+
181
+ # Fallback chain (rebuilt at startup, ordered by speed)
182
+ FALLBACK_CHAIN: list[str] = list(CANDIDATE_MODELS)
183
+
184
+ # ── Reliability tracking ─────────────────────────────────────────────────
185
+
186
+ _RELIABILITY_FILE = os.path.join(os.path.dirname(__file__), "data", "reliability.json")
187
+
188
+
189
+ def _load_reliability():
190
+ try:
191
+ with open(_RELIABILITY_FILE) as f:
192
+ return json.load(f)
193
+ except (OSError, ValueError): # missing/unreadable file or corrupt JSON
194
+ return {}
195
+
196
+
197
+ # The status, not any "400" in the text: "Error code: 400" (OpenAI SDK),
198
+ # "HTTP Error 400" (urllib), "400 INVALID_ARGUMENT" (genai), "400 Client Error".
199
+ _HTTP_400 = re.compile(r"^400\b|(?:Error(?: code:)?|HTTP) 400\b")
200
+
201
+
202
+ def log_reliability(model, success, error_msg=None, ttft=None, tok_sec=None):
203
+ """Record a success or failure for a model, with optional speed stats. Thread-safe.
204
+ Skips logging for HTTP 400 errors (config bugs, not provider reliability issues)."""
205
+ if not success and error_msg and (_HTTP_400.search(str(error_msg)) or "Unsupported parameter" in str(error_msg)):
206
+ return # don't penalize provider for our config mistakes
207
+
208
+ def record(data):
209
+ r = data.setdefault(model, {"attempts": 0, "successes": 0, "errors": 0,
210
+ "last_success": None, "last_error": None,
211
+ "last_error_msg": None})
212
+ r["attempts"] += 1
213
+ ts = int(time.time())
214
+ if success:
215
+ r["successes"] += 1
216
+ r["last_success"] = ts
217
+ # Track real-world speed (rolling average)
218
+ if ttft is not None:
219
+ prev_ttft = r.get("avg_ttft")
220
+ r["avg_ttft"] = round((prev_ttft * 0.7 + ttft * 0.3), 3) if prev_ttft else round(ttft, 3)
221
+ if tok_sec is not None:
222
+ prev_tps = r.get("avg_tok_sec")
223
+ r["avg_tok_sec"] = round((prev_tps * 0.7 + tok_sec * 0.3), 1) if prev_tps else round(tok_sec, 1)
224
+ else:
225
+ r["errors"] += 1
226
+ r["last_error"] = ts
227
+ if error_msg:
228
+ r["last_error_msg"] = _redact_secrets(str(error_msg))[:500]
229
+
230
+ json_storage.update_json(_RELIABILITY_FILE, dict, record)
231
+
232
+
233
+ def get_reliability(model):
234
+ """Return reliability % (0-100) for a model, or None if no data."""
235
+ data = _load_reliability()
236
+ r = data.get(model)
237
+ if not r or r["attempts"] == 0:
238
+ return None
239
+ return round(r["successes"] / r["attempts"] * 100)
240
+
241
+
242
+ def get_all_reliability():
243
+ """Return dict of model -> reliability stats."""
244
+ return _load_reliability()
245
+
246
+
247
+ # ── Client creation ──────────────��──────────────────────────────────────
248
+
249
+ def get_client(provider: str) -> tuple[typing.Any, str | None]:
250
+ """Get API client for a provider. Returns (client, error_msg).
251
+
252
+ The client is Any on purpose: it is an OpenAI, genai or Anthropic SDK
253
+ client, or a plain dict for cohere/cloudflare. Callers pick the API by
254
+ provider name, which a type checker cannot tie to the client's type.
255
+ """
256
+ cfg = PROVIDER_CONFIG[provider]
257
+
258
+ env_key = cfg["env_key"]
259
+ api_key = os.getenv(env_key or "", "").strip()
260
+ if cfg.get("guest") and not api_key:
261
+ from openai import OpenAI, Timeout, omit
262
+
263
+ class _GuestOpenAI(OpenAI):
264
+ """Lumo serves guests unauthenticated; SDK 3.x insists on an auth method."""
265
+
266
+ def _validate_headers(self, headers, custom_headers) -> None:
267
+ return
268
+
269
+ # api_key is never sent — default_headers drops Authorization entirely.
270
+ return _GuestOpenAI(
271
+ api_key="guest",
272
+ base_url=cfg["base_url"],
273
+ # The SDK drops Omit-valued headers; its hint only allows str.
274
+ default_headers={"Authorization": omit}, # pyright: ignore[reportArgumentType]
275
+ timeout=Timeout(60, connect=5),
276
+ ), None
277
+ if not api_key:
278
+ return None, f"Chybí {env_key} v .env"
279
+
280
+ try:
281
+ if provider == "cohere":
282
+ return {"type": "cohere", "api_key": api_key}, None
283
+ if provider == "cloudflare":
284
+ account_id = os.getenv(cfg.get("account_id_key", ""), "").strip()
285
+ if not account_id:
286
+ return None, "Chybí CLOUDFLARE_ACCOUNT_ID v .env"
287
+ return {"type": "cloudflare", "api_key": api_key, "account_id": account_id}, None
288
+ if provider == "nvidia":
289
+ from openai import OpenAI, Timeout
290
+ return OpenAI(api_key=api_key, base_url="https://integrate.api.nvidia.com/v1",
291
+ timeout=Timeout(60, connect=5)), None
292
+ if provider == "gemini":
293
+ from google import genai
294
+ return genai.Client(api_key=api_key), None
295
+ elif provider == "anthropic":
296
+ import anthropic
297
+ return anthropic.Anthropic(api_key=api_key), None
298
+ else:
299
+ from openai import OpenAI, Timeout
300
+ kwargs = {"api_key": api_key, "timeout": Timeout(60, connect=5)}
301
+ if cfg["base_url"]:
302
+ kwargs["base_url"] = cfg["base_url"]
303
+ return OpenAI(**kwargs), None
304
+ except Exception as e: # noqa: BLE001 - any SDK import/setup failure is returned as the error
305
+ return None, _redact_secrets(str(e))[:80]
306
+
307
+
308
+ # ── Sync call (test_models.py) ──────────────────────────────────────────
309
+
310
+ class _ChatRequestOptions(typing.TypedDict, total=False):
311
+ extra_body: dict[str, dict[str, bool]]
312
+ reasoning_effort: typing.Literal["low"]
313
+
314
+
315
+ def _chat_request_options(model_name: str) -> _ChatRequestOptions:
316
+ """Keep supported reasoning models within the visible-answer token budget."""
317
+ if MODELS[model_name].get("disable_thinking"):
318
+ return {"extra_body": {"chat_template_kwargs": {"enable_thinking": False}}}
319
+ if MODELS[model_name].get("low_reasoning"):
320
+ return {"reasoning_effort": "low"}
321
+ return {}
322
+
323
+
324
+ # Gemini 3.x flash otherwise thinks through the whole max_output_tokens budget.
325
+ _GEMINI_THINKING = {"thinking_level": "minimal"}
326
+
327
+
328
+ def _cf_text(data: dict) -> str:
329
+ """Workers AI answers either {"response": ...} or OpenAI-style choices (gpt-oss, nemotron, gemma-4)."""
330
+ result = data.get("result") or {}
331
+ if result.get("response"):
332
+ return result["response"]
333
+ choices = result.get("choices") or []
334
+ return (choices[0].get("message") or {}).get("content") or "" if choices else ""
335
+
336
+
337
+ def call_model(model_name, messages, max_tokens=300, temperature=0.7):
338
+ """Call a model synchronously. Returns (text, tokens_in, tokens_out)."""
339
+ info = MODELS[model_name]
340
+ provider = info["provider"]
341
+ client, err = get_client(provider)
342
+ if err:
343
+ raise RuntimeError(err)
344
+
345
+ if provider == "cloudflare":
346
+ import urllib.request
347
+ account_id = client["account_id"]
348
+ payload = json.dumps({
349
+ "messages": messages, "max_tokens": max_tokens,
350
+ }).encode()
351
+ req = urllib.request.Request(
352
+ f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model_name}",
353
+ data=payload,
354
+ headers={"Authorization": f"Bearer {client['api_key']}",
355
+ "Content-Type": "application/json"})
356
+ with urllib.request.urlopen(req, timeout=60) as resp:
357
+ data = json.loads(resp.read().decode())
358
+ text = _cf_text(data)
359
+ tin = sum(len(m["content"]) // 4 for m in messages)
360
+ tout = len(text) // 4
361
+ return text.strip(), tin, tout
362
+
363
+ if provider == "cohere":
364
+ import urllib.request
365
+ # v2 API: system message goes in messages array, NOT as preamble
366
+ cohere_msgs = [{"role": m["role"], "content": m["content"]} for m in messages]
367
+ payload = json.dumps({
368
+ "model": model_name, "messages": cohere_msgs,
369
+ "temperature": temperature, "max_tokens": max_tokens,
370
+ }).encode()
371
+ req = urllib.request.Request(
372
+ "https://api.cohere.com/v2/chat", data=payload,
373
+ headers={"Authorization": f"Bearer {client['api_key']}",
374
+ "Content-Type": "application/json"})
375
+ with urllib.request.urlopen(req, timeout=30) as resp:
376
+ data = json.loads(resp.read().decode())
377
+ text = data.get("message", {}).get("content", [{}])[0].get("text", "")
378
+ tin = data.get("usage", {}).get("tokens", {}).get("input_tokens", 0)
379
+ tout = data.get("usage", {}).get("tokens", {}).get("output_tokens", 0)
380
+ return text.strip(), tin, tout
381
+
382
+ if provider == "gemini":
383
+ from google.genai import types
384
+ system_text, user_text = "", ""
385
+ for m in messages:
386
+ if m["role"] == "system":
387
+ system_text = m["content"]
388
+ elif m["role"] == "user":
389
+ user_text = m["content"]
390
+ response = client.models.generate_content(
391
+ model=model_name, contents=user_text,
392
+ config=types.GenerateContentConfig(
393
+ system_instruction=system_text, temperature=temperature,
394
+ max_output_tokens=max_tokens, thinking_config=types.ThinkingConfig.model_validate(_GEMINI_THINKING)))
395
+ text = response.text or ""
396
+ tin = getattr(response.usage_metadata, "prompt_token_count", 0) or 0
397
+ tout = getattr(response.usage_metadata, "candidates_token_count", 0) or 0
398
+ return text, tin, tout
399
+
400
+ if provider == "anthropic":
401
+ system_text = ""
402
+ user_messages = []
403
+ for m in messages:
404
+ if m["role"] == "system":
405
+ system_text = m["content"]
406
+ else:
407
+ user_messages.append(m)
408
+ r = client.messages.create(model=model_name, system=system_text,
409
+ messages=user_messages, temperature=temperature,
410
+ max_tokens=max_tokens)
411
+ return r.content[0].text or "", r.usage.input_tokens, r.usage.output_tokens
412
+
413
+ # OpenAI-compatible (openai, deepseek, groq, mistral)
414
+ # GPT-5+ uses max_completion_tokens instead of max_tokens
415
+ tok_kwarg = "max_completion_tokens" if model_name.startswith("gpt-5") else "max_tokens"
416
+ r = client.chat.completions.create(model=model_name, messages=messages,
417
+ temperature=temperature, **{tok_kwarg: max_tokens},
418
+ **_chat_request_options(model_name))
419
+ return r.choices[0].message.content or "", r.usage.prompt_tokens, r.usage.completion_tokens
420
+
421
+
422
+ def _call_model_tracked(model_name, messages, **kwargs):
423
+ """call_model with reliability logging."""
424
+ try:
425
+ result = call_model(model_name, messages, **kwargs)
426
+ log_reliability(model_name, success=True)
427
+ return result
428
+ except Exception as e:
429
+ log_reliability(model_name, success=False, error_msg=str(e))
430
+ raise
431
+
432
+
433
+ # ── Streaming call (benchmark.py speed test) ────────────────────────────
434
+
435
+ def stream_benchmark(model_name, system_prompt, user_prompt, max_tokens=150):
436
+ """Stream a model for speed benchmarking. Returns (latency, ttft, tok_per_sec, error)."""
437
+ latency, ttft, tok_sec, error = _stream_benchmark_inner(
438
+ model_name, system_prompt, user_prompt, max_tokens)
439
+ # Log reliability for benchmark runs too
440
+ if error:
441
+ log_reliability(model_name, success=False, error_msg=error)
442
+ else:
443
+ log_reliability(model_name, success=True)
444
+ return latency, ttft, tok_sec, error
445
+
446
+
447
+ def _stream_benchmark_inner(model_name, system_prompt, user_prompt, max_tokens=150):
448
+ info = MODELS.get(model_name)
449
+ if not info:
450
+ return None, None, None, f"Unknown model: {model_name}"
451
+ provider = info["provider"]
452
+
453
+ messages = [{"role": "system", "content": system_prompt},
454
+ {"role": "user", "content": user_prompt}]
455
+ max_tokens = max(max_tokens, info.get("benchmark_tokens", max_tokens))
456
+
457
+ _BENCH_TIMEOUT = 7 # aggressive timeout for benchmarks
458
+
459
+ try:
460
+ if provider in ("cohere", "cloudflare"):
461
+ t0 = time.time()
462
+ text, _, tout = call_model(model_name, messages, max_tokens=max_tokens)
463
+ total = time.time() - t0
464
+ if not text or not text.strip():
465
+ return None, None, None, "Empty response (no visible output)"
466
+ tok_sec = tout / total if total > 0 else 0
467
+ return total, total * 0.3, tok_sec, None
468
+
469
+ if provider == "gemini":
470
+ from google.genai import types
471
+ client, err = get_client(provider)
472
+ if err:
473
+ return None, None, None, err
474
+ t0 = time.time()
475
+ ttft = None
476
+ token_count = 0
477
+ stream = client.models.generate_content_stream(
478
+ model=model_name, contents=user_prompt,
479
+ config=types.GenerateContentConfig(
480
+ system_instruction=system_prompt, max_output_tokens=max_tokens,
481
+ thinking_config=types.ThinkingConfig.model_validate(_GEMINI_THINKING)))
482
+ for chunk in stream:
483
+ if chunk.text and chunk.text.strip():
484
+ if ttft is None:
485
+ ttft = time.time() - t0
486
+ token_count += len(chunk.text.split())
487
+ total = time.time() - t0
488
+ if ttft is None:
489
+ return None, None, None, "Empty response (no visible output)"
490
+ return total, ttft, token_count / total if total > 0 else 0, None
491
+
492
+ # OpenAI-compatible (openai, deepseek, groq, mistral)
493
+ client, err = get_client(provider)
494
+ if err:
495
+ return None, None, None, err
496
+ t0 = time.time()
497
+ ttft = None
498
+ token_count = 0
499
+ stream = client.chat.completions.create(
500
+ model=model_name, messages=messages,
501
+ max_tokens=max_tokens, stream=True,
502
+ timeout=_BENCH_TIMEOUT, **_chat_request_options(model_name))
503
+ for chunk in stream:
504
+ if time.time() - t0 > _BENCH_TIMEOUT:
505
+ break
506
+ if (chunk.choices and chunk.choices[0].delta.content
507
+ and chunk.choices[0].delta.content.strip()):
508
+ if ttft is None:
509
+ ttft = time.time() - t0
510
+ token_count += 1
511
+ total = time.time() - t0
512
+ if ttft is None:
513
+ return None, None, None, "Empty response (no visible output)"
514
+ return total, ttft, token_count / total if total > 0 else 0, None
515
+
516
+ except Exception as e: # noqa: BLE001 - any provider failure is a failed benchmark
517
+ return None, None, None, _redact_secrets(str(e))
518
+
519
+
520
+ # ── Streaming generator (web.py SSE) ────────────────────────────────────
521
+
522
+ def stream_chat(model_name, messages, max_tokens=1024, temperature=0.7):
523
+ """Stream a model response as (text_chunk) generator. For web SSE."""
524
+ info = MODELS.get(model_name)
525
+ if not info:
526
+ raise RuntimeError(f"Unknown model: {model_name}")
527
+ provider = info["provider"]
528
+ client, err = get_client(provider)
529
+ if err:
530
+ raise RuntimeError(err)
531
+ if not client:
532
+ raise RuntimeError(f"No client for {provider}")
533
+
534
+ if provider == "cloudflare":
535
+ # Cloudflare Workers AI doesn't stream; yield full response
536
+ text, _, _ = call_model(model_name, messages, max_tokens=max_tokens, temperature=temperature)
537
+ yield text
538
+ return
539
+
540
+ if provider == "cohere":
541
+ import urllib.request
542
+ # v2 API: system message goes in messages array
543
+ cohere_msgs = [{"role": m["role"], "content": m["content"]} for m in messages]
544
+ payload = json.dumps({
545
+ "model": model_name, "messages": cohere_msgs,
546
+ "temperature": temperature, "max_tokens": max_tokens, "stream": True,
547
+ }).encode()
548
+ req = urllib.request.Request(
549
+ "https://api.cohere.com/v2/chat", data=payload,
550
+ headers={"Authorization": f"Bearer {client['api_key']}",
551
+ "Content-Type": "application/json"})
552
+ with urllib.request.urlopen(req, timeout=60) as resp:
553
+ for raw_line in resp:
554
+ line = raw_line.decode("utf-8").strip()
555
+ if not line or not line.startswith("data:"):
556
+ continue
557
+ try:
558
+ data = json.loads(line[5:].strip())
559
+ except json.JSONDecodeError:
560
+ continue
561
+ if data.get("type") == "content-delta":
562
+ delta = data.get("delta", {}).get("message", {}).get("content", {}).get("text", "")
563
+ if delta:
564
+ yield delta
565
+ return
566
+
567
+ if provider == "gemini":
568
+ from google.genai import types
569
+ sys_text = ""
570
+ contents = []
571
+ for m in messages:
572
+ if m["role"] == "system":
573
+ sys_text = m["content"]
574
+ else:
575
+ contents.append(types.Content(
576
+ role="user" if m["role"] == "user" else "model",
577
+ parts=[types.Part(text=m["content"])]))
578
+ stream = client.models.generate_content_stream(
579
+ model=model_name, contents=contents,
580
+ config=types.GenerateContentConfig(
581
+ system_instruction=sys_text, temperature=temperature,
582
+ max_output_tokens=max_tokens, thinking_config=types.ThinkingConfig.model_validate(_GEMINI_THINKING)))
583
+ for chunk in stream:
584
+ if chunk.text:
585
+ yield chunk.text
586
+ return
587
+
588
+ # OpenAI-compatible (openai, deepseek, groq, mistral)
589
+ tok_kwarg = {"max_completion_tokens": max_tokens} if model_name.startswith("gpt-5") else {"max_tokens": max_tokens}
590
+ stream = client.chat.completions.create(
591
+ model=model_name, messages=messages, temperature=temperature,
592
+ **tok_kwarg, stream=True, **_chat_request_options(model_name))
593
+ for chunk in stream:
594
+ if chunk.choices and chunk.choices[0].delta.content:
595
+ yield chunk.choices[0].delta.content
596
+
597
+
598
+ # ── Error formatting ────────────────────────────────────────────────────
599
+
600
+ def friendly_error(emsg):
601
+ """Convert raw API errors to human-readable Czech messages."""
602
+ el = emsg.lower()
603
+ if "401" in emsg or "invalid_api_key" in el or "unauthorized" in el or "authentication" in el:
604
+ return "Neplatný API klíč"
605
+ if "402" in emsg or "insufficient" in el or "balance" in el:
606
+ return "Nedostatečný kredit"
607
+ if "429" in emsg or "quota" in el or "rate" in el or "resource_exhausted" in el:
608
+ return "Rate limit / vyčerpaná kvóta"
609
+ if "404" in emsg or "not found" in el or "does not exist" in el:
610
+ return "Model nenalezen"
611
+ if "403" in emsg or "forbidden" in el or "permission" in el:
612
+ return "Přístup zamítnut"
613
+ import re
614
+ if "'message':" in emsg:
615
+ m = re.search(r"'message':\s*'([^']+)'", emsg)
616
+ if m:
617
+ return m.group(1)[:80]
618
+ return emsg[:80]
619
+
620
+
621
+ # ── Dynamic free-model discovery ────────────────────────────────────────
622
+ # OVHcloud rotates its free catalog; OpenRouter discovery is limited to the
623
+ # models verified to return visible chat output.
624
+ AUTO_DISCOVER_PROVIDERS = ["openrouter", "ovhcloud"]
625
+ OPENROUTER_MODELS = {
626
+ name for name, config in MODELS.items() if config["provider"] == "openrouter"
627
+ }
628
+
629
+
630
+ def discover_free_models(provider: str, limit: int = 20) -> list[str]:
631
+ """Query a provider's /models endpoint, return up to `limit` free-tier model IDs."""
632
+ client, err = get_client(provider)
633
+ if err:
634
+ return []
635
+ try:
636
+ ids = [m.id for m in client.models.list().data]
637
+ except Exception: # noqa: BLE001 - an unreachable catalog just yields no models
638
+ return []
639
+ if provider == "openrouter":
640
+ ids = [model_id for model_id in ids if model_id in OPENROUTER_MODELS]
641
+ return ids[:limit]
642
+
643
+
644
+ def register_discovered_models(limit_per_provider: int = 20) -> list[str]:
645
+ """Add auto-discovered free models to MODELS + CANDIDATE_MODELS/HF_CANDIDATES.
646
+ Returns the new IDs."""
647
+ added = []
648
+ for provider in AUTO_DISCOVER_PROVIDERS:
649
+ for model_id in discover_free_models(provider, limit_per_provider):
650
+ if model_id in MODELS:
651
+ continue
652
+ MODELS[model_id] = {"provider": provider, "input": 0, "output": 0, "score": "", "free": True}
653
+ CANDIDATE_MODELS.append(model_id)
654
+ HF_CANDIDATES.append(model_id)
655
+ added.append(model_id)
656
+ return added
657
+
658
+
659
+ # ── Provider health check ───────────────────────────────────────────────
660
+
661
+ # Test model to use when validating each provider's API key
662
+ _CHECK_MODELS = {
663
+ "openai": "gpt-4.1-nano",
664
+ "deepseek": "deepseek-chat",
665
+ "groq": "llama-3.1-8b-instant",
666
+ "mistral": "mistral-small-latest",
667
+ "gemini": "gemini-3.5-flash",
668
+ "anthropic": "claude-haiku-4-5-20251001",
669
+ "nvidia": "nvidia/nemotron-3.5-lightning-30b-a3b",
670
+ "proton": "lumo",
671
+ "huggingface": "zai-org/GLM-5.3",
672
+ }
673
+
674
+
675
+ def check_provider(provider):
676
+ """Validate API key with a minimal test call. Returns (ok, message)."""
677
+ client, err = get_client(provider)
678
+ if err:
679
+ return False, err
680
+
681
+ try:
682
+ if provider in AUTO_DISCOVER_PROVIDERS:
683
+ models = discover_free_models(provider)
684
+ if models:
685
+ return True, f"OK — {len(models)} free models: {', '.join(models[:3])}"
686
+ return False, "Žádné free modely nenalezeny"
687
+
688
+ if provider == "cloudflare":
689
+ import urllib.request
690
+ account_id = client["account_id"]
691
+ payload = json.dumps({
692
+ "messages": [{"role": "user", "content": "Say ok."}], "max_tokens": 5,
693
+ }).encode()
694
+ req = urllib.request.Request(
695
+ f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/@cf/meta/llama-3.2-3b-instruct",
696
+ data=payload,
697
+ headers={"Authorization": f"Bearer {client['api_key']}",
698
+ "Content-Type": "application/json"})
699
+ with urllib.request.urlopen(req, timeout=30) as resp:
700
+ data = json.loads(resp.read().decode())
701
+ text = _cf_text(data)
702
+ return True, f"OK — {text.strip()[:20]}"
703
+
704
+ if provider == "cohere":
705
+ import urllib.request
706
+ payload = json.dumps({
707
+ "model": "command-r7b-12-2024",
708
+ "messages": [{"role": "user", "content": "Řekni 'ok'."}],
709
+ "max_tokens": 5,
710
+ }).encode()
711
+ req = urllib.request.Request(
712
+ "https://api.cohere.com/v2/chat", data=payload,
713
+ headers={"Authorization": f"Bearer {client['api_key']}",
714
+ "Content-Type": "application/json"})
715
+ with urllib.request.urlopen(req, timeout=15) as resp:
716
+ data = json.loads(resp.read().decode())
717
+ text = data.get("message", {}).get("content", [{}])[0].get("text", "")
718
+ return True, f"OK — {text.strip()[:20]}"
719
+
720
+ if provider == "gemini":
721
+ response = client.models.generate_content(
722
+ model="gemini-3.5-flash", contents="Řekni 'ok'.",
723
+ config={"max_output_tokens": 5})
724
+ return True, f"OK — {response.text.strip()[:20]}"
725
+
726
+ if provider == "anthropic":
727
+ r = client.messages.create(
728
+ model="claude-haiku-4-5-20251001", max_tokens=10,
729
+ messages=[{"role": "user", "content": "Řekni 'ok'."}])
730
+ return True, f"OK — {r.content[0].text.strip()[:20]}"
731
+
732
+ # OpenAI-compatible
733
+ test_model = _CHECK_MODELS.get(provider, "gpt-4.1-nano")
734
+ r = client.chat.completions.create(
735
+ model=test_model,
736
+ messages=[{"role": "user", "content": "Řekni 'ok'."}],
737
+ max_tokens=5)
738
+ return True, f"OK — {r.choices[0].message.content.strip()[:20]}"
739
+
740
+ except Exception as e: # noqa: BLE001 - any provider failure is reported as not OK
741
+ return False, friendly_error(str(e))
rate_limit.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-client request caps, in memory.
2
+
3
+ Keys are exact paths, not prefixes. `"/api/stt"` used to match by prefix, which
4
+ swallowed `/api/stt/check` and `/api/stt/usage` as well: a single /transcribe
5
+ load spends three of those, so the fourth reload inside a minute was refused
6
+ and the page reported the transcription service as down. The websocket it was
7
+ written for never reaches this code at all — `BaseHTTPMiddleware` skips
8
+ non-http scopes, so websocket limits live in `stt_budget` instead.
9
+ """
10
+
11
+ import threading
12
+ import time
13
+
14
+ # exact path → (max_requests, window_seconds)
15
+ LIMITS: dict[str, tuple[int, int]] = {
16
+ "/api/chat": (15, 60),
17
+ # Voice mode sends one request per sentence, prefetched as the answer
18
+ # streams, so a low cap counts sentences rather than users: at 10 the
19
+ # eleventh sentence of one answer was refused and the reply finished in the
20
+ # browser's built-in voice. edge-tts is unmetered and the result is cached,
21
+ # so this only exists to stop the Space being used as a free TTS proxy.
22
+ "/api/tts": (60, 60),
23
+ "/api/benchmark": (1, 300),
24
+ # Every call makes an outbound request to Deepgram's auth endpoint, so it
25
+ # cannot go uncapped. The chat page calls it once per load, /t after each
26
+ # unexpected socket close: reconnect backoff (1 s doubling to 30 s) keeps
27
+ # that to about seven in the first minute, then two a minute.
28
+ "/api/stt/check": (30, 60),
29
+ "/api/stt/usage": (30, 60),
30
+ # /t asks once per socket, like the check; each call mints a Deepgram token
31
+ "/api/stt/token": (30, 60),
32
+ # /t reports its visit every 5 min and on hide: far under this
33
+ "/api/stt/session": (30, 60),
34
+ "/api/captcha/challenge": (10, 60),
35
+ "/api/captcha/verify": (10, 60),
36
+ }
37
+
38
+ _buckets: dict[str, list[float]] = {}
39
+ _lock = threading.Lock()
40
+
41
+
42
+ def is_limited(client: str, path: str, now: float | None = None) -> bool:
43
+ """True when this client has used up `path`'s allowance. Counts the call."""
44
+ limit_window = LIMITS.get(path)
45
+ if limit_window is None:
46
+ return False
47
+ limit, window = limit_window
48
+ now = time.time() if now is None else now
49
+ key = f"{client}:{path}"
50
+ with _lock:
51
+ recent = [t for t in _buckets.get(key, ()) if t > now - window]
52
+ if len(recent) >= limit:
53
+ _buckets[key] = recent
54
+ return True
55
+ recent.append(now)
56
+ _buckets[key] = recent
57
+ return False
58
+
59
+
60
+ def reset() -> None:
61
+ """Drop all buckets. For tests."""
62
+ with _lock:
63
+ _buckets.clear()
refuse_log.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One line per refusal, into logs/app.log.
2
+
3
+ The remediation series added ten ways to refuse a request — oversized bodies,
4
+ rate limits, a missing captcha session, closed websockets, a spent STT budget —
5
+ and recorded none of them, so a 403 and a 200 looked the same in `logs/`.
6
+
7
+ The bucket key is hashed (`stt_budget.client_id`) by the caller before it gets
8
+ here: the raw key is a captcha session token or a visitor address, and this
9
+ file has to be safe to read. `refused()` does not hash for you, because it has
10
+ no access to the secret — passing a raw token is the one mistake worth
11
+ guarding against, so keep the hashing next to the key and pass the result.
12
+
13
+ What this file is worth: `logs/` lives inside the container and the free HF
14
+ CPU Basic tier has no persistent disk, so these lines cover the period since
15
+ the last Space restart and no longer.
16
+ """
17
+
18
+ import logging
19
+
20
+ _log = logging.getLogger("ajaj.refuse")
21
+
22
+
23
+ def refused(reason: str, route: str, bucket: str | None = None, **extra: object) -> None:
24
+ """Record one refusal.
25
+
26
+ Args:
27
+ reason: Short slug for what refused, e.g. `rate_limited`.
28
+ route: The path or websocket route the refusal happened on.
29
+ bucket: Already-hashed client key, or None when no client caused it
30
+ (a missing API key, an unavailable dependency).
31
+ **extra: Extra `key=value` fields to append, e.g. `status=413`.
32
+ """
33
+ line = f"{reason} route={route} bucket={bucket or '-'}"
34
+ if extra:
35
+ line += " " + " ".join(f"{k}={v}" for k, v in extra.items())
36
+ _log.warning(line)
requirements-hf.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.141.1
2
+ uvicorn==0.52.4
3
+ python-dotenv==1.2.3
4
+ chromadb==1.5.9
5
+ fastembed==0.8.0
6
+ onnxruntime==1.29.0
7
+ openai==3.7.0
8
+ google-genai==2.22.0
9
+ httpx==0.28.1
10
+ edge-tts==7.2.8
11
+ numpy==2.5.3
12
+ requests==2.34.2
13
+ websockets==16.1.1
14
+ Pillow==12.3.0
15
+ maxminddb==3.2.0
retrieve.py ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ retrieve.py — Hybrid retrieval: semantic search + title keyword matching
3
+ + live Necyklopedie fallback for articles not in local index.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import time
10
+
11
+ from json_storage import JsonValue, update_json
12
+
13
+ _title_index = None
14
+
15
+ # ── Live-fetch rate limiting ───────────────────────────────────────────────────
16
+ _LIVE_LOG = os.path.join("data", "live_fetch_log.json")
17
+ _LIVE_MAX_DAY = 300 # max live fetches per 24 h
18
+ _LIVE_MAX_MIN = 30 # max live fetches per 60 s
19
+ _CHUNK_SIZE = 800 # chars per chunk for live-fetched text
20
+
21
+
22
+ def _load_title_index():
23
+ """Build a lowercase title -> pageid lookup from articles.jsonl."""
24
+ global _title_index
25
+ if _title_index is not None:
26
+ return _title_index
27
+
28
+ _title_index = {}
29
+ articles_path = os.path.join("data", "articles.jsonl")
30
+ if os.path.exists(articles_path):
31
+ with open(articles_path, "r", encoding="utf-8") as f:
32
+ for line in f:
33
+ rec = json.loads(line)
34
+ _title_index[rec["title"].lower()] = rec["pageid"]
35
+ return _title_index
36
+
37
+
38
+ def _czech_stem(word):
39
+ """Crude Czech stemming: strip common suffixes for matching."""
40
+ # Order matters: try longer suffixes first
41
+ for suffix in ["ách", "ům", "emi", "ami", "ích", "ech",
42
+ "ám", "ím", "ov", "ěm",
43
+ "ou", "em", "mi", "ně",
44
+ "ě", "y", "u", "i", "a", "o"]:
45
+ if word.endswith(suffix) and len(word) - len(suffix) >= 3:
46
+ return word[:-len(suffix)]
47
+ return word
48
+
49
+
50
+ def _stems_match(query_stem, title_stem):
51
+ """Check if query stem matches title stem. Allows 1 char difference for Czech declension."""
52
+ if query_stem == title_stem:
53
+ return True
54
+ if len(query_stem) >= 3 and title_stem.startswith(query_stem) and len(title_stem) - len(query_stem) == 1:
55
+ return True
56
+ return len(title_stem) >= 3 and query_stem.startswith(title_stem) and len(query_stem) - len(title_stem) == 1
57
+
58
+
59
+ def _find_matching_titles(query_lower):
60
+ """Find article titles that match query words. Returns dict of pageid -> match_quality.
61
+ Match quality: 3 = exact title match, 2 = title is a query word, 1 = substring match."""
62
+ idx = _load_title_index()
63
+ stopwords = {
64
+ "se", "si", "to", "je", "na", "ve", "za", "ke", "od", "do", "po", "při",
65
+ "pro", "nad", "pod", "jak", "kdo", "kde", "kdy", "cos", "ten", "the",
66
+ "jeho", "její", "jsou", "bylo", "být", "mít", "které", "která", "který",
67
+ "jako", "ale", "ani", "nebo", "tak", "jen", "ještě", "což", "tedy",
68
+ "taky", "také", "toho", "tím", "tom", "této", "tohle", "nic", "než",
69
+ "aby", "když", "před", "mezi", "přes", "bez", "stál", "řekl", "jsem",
70
+ "jsi", "jsme", "jste", "byl", "byla", "byli", "není", "nás", "vás",
71
+ "mně", "mne", "jím", "jich", "jim", "ním", "nich", "něm", "nej",
72
+ "své", "svou", "svým", "jaký", "jaká", "jaké", "celý", "celá",
73
+ "dnes", "bude", "více", "méně", "setkal", "říkají", "pravda",
74
+ "pojem", "pojmem", "pojmu", "vysvětli", "vysvětlit", "řekni", "popiš", "popsat",
75
+ "říká", "neříká", "proboha", "skrývá", "město", "města",
76
+ "vůbec", "nevím", "utajované", "informace", "pouč", "slyšel", "nikdy", "neslyšel", "vzniklo", "vznikl", "vznikla",
77
+ }
78
+ raw_words = re.findall(r'[\w]+', query_lower) # strip punctuation
79
+ words = [w for w in raw_words if len(w) >= 3 and w not in stopwords]
80
+ stems = [_czech_stem(w) for w in words]
81
+
82
+ matched = {} # pageid -> match_quality
83
+ query_joined = " ".join(words)
84
+
85
+ for title_lower, pageid in idx.items():
86
+ title_stems = [_czech_stem(tw) for tw in title_lower.split()]
87
+ title_stem = _czech_stem(title_lower)
88
+ best = 0
89
+ # Check if full query matches full title (multi-word exact match)
90
+ # Require title >= 5 chars for substring match to avoid short titles ("Pod", "Sad"…)
91
+ # matching inside unrelated long words ("blaosdpsadkpods")
92
+ if title_lower == query_joined or (len(title_lower) >= 5 and title_lower in query_joined):
93
+ best = 3
94
+ else:
95
+ for word, stem in zip(words, stems):
96
+ # Exact single-word title match
97
+ if word == title_lower or stem == title_stem:
98
+ best = max(best, 3)
99
+ # Query word matches a title word (multi-word title)
100
+ elif word in title_lower.split() or stem in title_stems:
101
+ best = max(best, 2)
102
+ # Substring match (weakest)
103
+ elif word in title_lower:
104
+ best = max(best, 1)
105
+ if best > 0:
106
+ matched[pageid] = best
107
+
108
+ return matched
109
+
110
+
111
+ # ── Live Necyklopedie fallback ─────────────────────────────────────────────────
112
+
113
+ def _reserve_live_fetch() -> bool:
114
+ """Atomically reserve one live fetch when both quotas allow it."""
115
+ now = time.time()
116
+ is_allowed = False
117
+
118
+ def reserve(log: list[JsonValue]) -> None:
119
+ nonlocal is_allowed
120
+ recent: list[int | float] = []
121
+ for timestamp in log:
122
+ if isinstance(timestamp, (int, float)) and now - timestamp < 86400:
123
+ recent.append(timestamp)
124
+ log[:] = recent
125
+ if len(recent) >= _LIVE_MAX_DAY:
126
+ return
127
+ if sum(1 for timestamp in recent if now - timestamp < 60) >= _LIVE_MAX_MIN:
128
+ return
129
+ log.append(now)
130
+ is_allowed = True
131
+
132
+ try:
133
+ update_json(_LIVE_LOG, list, reserve)
134
+ except OSError:
135
+ return False
136
+ return is_allowed
137
+
138
+
139
+ def _query_words(query_lower):
140
+ """Return content words from query (same stopwords as title matching)."""
141
+ stopwords = {
142
+ "se", "si", "to", "je", "na", "ve", "za", "ke", "od", "do", "po", "při",
143
+ "pro", "nad", "pod", "jak", "kdo", "kde", "kdy", "cos", "ten", "the",
144
+ "jeho", "její", "jsou", "bylo", "být", "mít", "které", "která", "který",
145
+ "jako", "ale", "ani", "nebo", "tak", "jen", "ještě", "což", "tedy",
146
+ "taky", "také", "toho", "tím", "tom", "této", "tohle", "nic", "než",
147
+ "aby", "když", "před", "mezi", "přes", "bez", "stál", "řekl", "jsem",
148
+ "jsi", "jsme", "jste", "byl", "byla", "byli", "není", "nás", "vás",
149
+ "mně", "mne", "jím", "jich", "jim", "ním", "nich", "něm", "nej",
150
+ "své", "svou", "svým", "jaký", "jaká", "jaké", "celý", "celá",
151
+ "dnes", "bude", "více", "méně", "setkal", "říkají", "pravda",
152
+ "pojem", "pojmem", "pojmu", "vysvětli", "vysvětlit", "řekni", "popiš", "popsat",
153
+ "říká", "neříká", "proboha", "skrývá", "město", "města",
154
+ "vůbec", "nevím", "utajované", "informace", "pouč", "slyšel", "nikdy", "neslyšel", "vzniklo", "vznikl", "vznikla",
155
+ # English
156
+ "what", "who", "how", "where", "when", "why", "tell", "about", "explain",
157
+ "is", "are", "was", "were", "this", "that", "with", "from",
158
+ }
159
+ return [w for w in re.findall(r'[\w]+', query_lower) if len(w) >= 3 and w not in stopwords]
160
+
161
+
162
+ _API_URL = "https://necyklopedie.org/w/api.php"
163
+ _UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
164
+
165
+
166
+ def _strip_diacritics(s):
167
+ import unicodedata
168
+ return "".join(c for c in unicodedata.normalize("NFD", s)
169
+ if unicodedata.category(c) != "Mn")
170
+
171
+
172
+ def _search_necyklopedie(word):
173
+ """Find Necyklopedie article titles matching word, handling diacritics.
174
+
175
+ Strategy: allpages prefix search with progressively shorter prefixes of the
176
+ diacritics-stripped word. MediaWiki prefix search is Unicode-aware at the
177
+ char level — 'Podprd' matches 'Podprdélník' but 'Podprde' doesn't (é≠e).
178
+ We then keep candidates whose stripped title closely matches our stripped word.
179
+ """
180
+ import urllib.parse
181
+ import urllib.request
182
+
183
+ stripped = _strip_diacritics(word.lower())
184
+ target_stem = stripped[:max(4, len(stripped) - 2)] # at least 4 chars
185
+
186
+ # Try from full length down to 4 chars, stop at first hit
187
+ for length in range(len(stripped), 3, -1):
188
+ prefix = stripped[:length].capitalize()
189
+ params = urllib.parse.urlencode({
190
+ "action": "query", "list": "allpages",
191
+ "apprefix": prefix, "apnamespace": "0",
192
+ "aplimit": "10", "format": "json",
193
+ })
194
+ url = f"{_API_URL}?{params}"
195
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
196
+ with urllib.request.urlopen(req, timeout=10) as resp:
197
+ data = json.loads(resp.read(_MAX_SEARCH_BYTES).decode("utf-8"))
198
+ pages = data.get("query", {}).get("allpages", [])
199
+ if not pages:
200
+ continue
201
+ # Keep candidates whose stripped title starts with our stripped word stem
202
+ matches = [
203
+ p["title"] for p in pages
204
+ if _strip_diacritics(p["title"].lower()).startswith(target_stem)
205
+ ]
206
+ if matches:
207
+ return matches[:3]
208
+ # If pages found but none match stem, stop — we've gone too broad
209
+ if len(pages) >= 5:
210
+ break
211
+
212
+ return []
213
+
214
+
215
+ _MAX_EXPORT_BYTES = 4 * 1024 * 1024
216
+ _MAX_SEARCH_BYTES = 256 * 1024
217
+
218
+
219
+ def _fetch_pages_by_title(titles):
220
+ """Fetch specific pages through the API: {pageid: {pageid, title, text}}.
221
+ The reply is read inside a chat request, so it is capped (ValueError)."""
222
+ import scrape
223
+ return scrape.fetch_pages(titles, timeout=15, max_bytes=_MAX_EXPORT_BYTES)
224
+
225
+
226
+ def _live_fetch(query_lower):
227
+ """Fetch article from Necyklopedie live. Returns list of (doc, meta) chunks or []."""
228
+ words = _query_words(query_lower)
229
+ if not words or not _reserve_live_fetch():
230
+ return []
231
+
232
+ # Use longest word as primary search term (most specific)
233
+ primary = max(set(words), key=len)
234
+
235
+ # Use MediaWiki search to find the real title (handles diacritics, capitalization)
236
+ try:
237
+ titles_to_try = _search_necyklopedie(primary)
238
+ except Exception: # noqa: BLE001 - best effort (network, HTTP, JSON); fall back to a guessed title
239
+ titles_to_try = [primary.capitalize()]
240
+
241
+ if not titles_to_try:
242
+ return []
243
+
244
+ try:
245
+ results = _fetch_pages_by_title(titles_to_try[:3])
246
+ except Exception: # noqa: BLE001 - best effort (network, HTTP, XML); no live chunks
247
+ return []
248
+
249
+ if not results:
250
+ return []
251
+
252
+ chunks = []
253
+ for pageid, rec in results.items():
254
+ text = rec["text"]
255
+ title = rec["title"]
256
+ # Split into fixed-size chunks
257
+ for i in range(0, min(len(text), _CHUNK_SIZE * 5), _CHUNK_SIZE):
258
+ chunk = text[i:i + _CHUNK_SIZE]
259
+ if chunk.strip():
260
+ chunks.append((chunk, {
261
+ "title": title,
262
+ "pageid": pageid,
263
+ "chunk_index": i // _CHUNK_SIZE,
264
+ "live": True,
265
+ }))
266
+
267
+ return chunks
268
+
269
+
270
+ def retrieve_chunks(query, embedder, collection, top_k=10):
271
+ """Retrieve relevant chunks using semantic search + title keyword boost.
272
+
273
+ Returns (chunks, live_titles, live_attempted) where live_titles is a list of
274
+ article titles that were fetched live from Necyklopedie (empty if all from
275
+ local index) and live_attempted says whether a live fetch was tried.
276
+ """
277
+ query_lower = query.lower()
278
+
279
+ # 1. Semantic search
280
+ query_embedding = embedder.encode(query).tolist()
281
+ sem_results = collection.query(
282
+ query_embeddings=[query_embedding],
283
+ n_results=top_k,
284
+ )
285
+ sem_chunks = list(zip(
286
+ sem_results["documents"][0],
287
+ sem_results["metadatas"][0],
288
+ ))
289
+
290
+ # 2. Title keyword matching via local index
291
+ matched = _find_matching_titles(query_lower) # pageid -> match_quality
292
+
293
+ # Limit to top 5 articles by match quality to avoid excessive DB calls
294
+ top_matched = sorted(matched.items(), key=lambda x: -x[1])[:5]
295
+
296
+ article_chunks = [] # list of (match_quality, [(doc, meta), ...])
297
+ for pageid, match_quality in top_matched:
298
+ try:
299
+ kw_results = collection.get(
300
+ where={"pageid": pageid},
301
+ include=["documents", "metadatas"],
302
+ )
303
+ if not kw_results["documents"]:
304
+ continue
305
+ pairs = list(zip(kw_results["documents"], kw_results["metadatas"]))
306
+ pairs.sort(key=lambda p: p[1].get("chunk_index", 0))
307
+ picks = pairs[:5]
308
+ article_chunks.append((match_quality, picks))
309
+ except Exception: # noqa: BLE001, S110 - one failed lookup must not drop the other articles
310
+ pass
311
+
312
+ article_chunks.sort(reverse=True, key=lambda x: x[0])
313
+ title_chunks = []
314
+ for _, chunks in article_chunks:
315
+ title_chunks.extend(chunks)
316
+
317
+ # 3. Merge: title matches first (deduplicated), then semantic results
318
+ seen_ids = set()
319
+ merged = []
320
+ for source in [title_chunks, sem_chunks]:
321
+ for doc, meta in source:
322
+ chunk_id = f"{meta['pageid']}_{meta['chunk_index']}"
323
+ if chunk_id not in seen_ids:
324
+ seen_ids.add(chunk_id)
325
+ merged.append((doc, meta))
326
+
327
+ # 4. Live fallback: if no keyword title match at all, ask Necyklopedie directly
328
+ live_titles = []
329
+ live_attempted = False
330
+ if not matched:
331
+ live_attempted = True
332
+ live_chunks = _live_fetch(query_lower)
333
+ if live_chunks:
334
+ live_titles = list(dict.fromkeys(m["title"] for _, m in live_chunks))
335
+ # Prepend live chunks (they're the best match we have)
336
+ seen_live = set()
337
+ deduped_live = []
338
+ for doc, meta in live_chunks:
339
+ cid = f"{meta['pageid']}_{meta['chunk_index']}"
340
+ if cid not in seen_ids and cid not in seen_live:
341
+ seen_live.add(cid)
342
+ deduped_live.append((doc, meta))
343
+ merged = deduped_live + merged
344
+
345
+ return merged[:top_k], live_titles, live_attempted
scrape.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ scrape.py — Download and process all Necyklopedie articles.
4
+
5
+ Three phases:
6
+ 1. Enumerate all article titles via MediaWiki API
7
+ 2. Bulk export via Special:Export (XML dump with full wikitext)
8
+ 3. Parse wikitext to plaintext and save as JSONL
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import sys
15
+ import time
16
+ import urllib.parse
17
+ import urllib.request
18
+ import xml.etree.ElementTree as ET
19
+
20
+ API_URL = "https://necyklopedie.org/w/api.php"
21
+ EXPORT_URL = "https://necyklopedie.org/wiki/Speci%C3%A1ln%C3%AD:Exportovat_str%C3%A1nky"
22
+ OUTPUT_DIR = "data"
23
+ OUTPUT_FILE = os.path.join(OUTPUT_DIR, "articles.jsonl")
24
+ TITLES_CACHE = os.path.join(OUTPUT_DIR, "titles.json")
25
+ DUMP_FILE = os.path.join(OUTPUT_DIR, "dump.xml")
26
+ MW_NS = {"mw": "http://www.mediawiki.org/xml/export-0.11/"}
27
+ UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
28
+
29
+
30
+ def api_get(params, timeout=30, max_bytes=None):
31
+ """One MediaWiki API call; `max_bytes` caps the reply (ValueError past it)."""
32
+ params["format"] = "json"
33
+ url = f"{API_URL}?{urllib.parse.urlencode(params)}"
34
+ req = urllib.request.Request(url, headers={"User-Agent": UA})
35
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
36
+ payload = resp.read(-1 if max_bytes is None else max_bytes + 1)
37
+ if max_bytes is not None and len(payload) > max_bytes:
38
+ raise ValueError(f"API reply too large (> {max_bytes} B)")
39
+ return json.loads(payload.decode("utf-8"))
40
+
41
+
42
+ def fetch_pages(titles, timeout=30, max_bytes=None):
43
+ """Current text of these pages: {pageid: {pageid, title, text}}.
44
+
45
+ Redirects, missing pages and stubs under 50 characters are left out. Uses
46
+ the API, 50 titles a call: since 2026-10 necyklopedie.org answers a POST
47
+ to Special:Export with 405.
48
+ """
49
+ results = {}
50
+ for i in range(0, len(titles), 50):
51
+ params = {
52
+ "action": "query", "prop": "revisions", "rvprop": "content",
53
+ "rvslots": "main", "formatversion": "2", "titles": "|".join(titles[i:i + 50]),
54
+ }
55
+ while True:
56
+ data = api_get(params, timeout, max_bytes)
57
+ for page in data.get("query", {}).get("pages", []):
58
+ if page.get("redirect") or "revisions" not in page:
59
+ continue # a page held back by a size limit comes after `continue`
60
+ text = wikitext_to_plaintext(page["revisions"][0]["slots"]["main"]["content"])
61
+ if len(text) >= 50:
62
+ results[page["pageid"]] = {
63
+ "pageid": page["pageid"], "title": page["title"], "text": text,
64
+ }
65
+ if "continue" not in data:
66
+ break
67
+ params.update(data["continue"])
68
+ return results
69
+
70
+
71
+ def get_all_titles():
72
+ """Enumerate all article titles in namespace 0."""
73
+ if os.path.exists(TITLES_CACHE):
74
+ with open(TITLES_CACHE, "r", encoding="utf-8") as f:
75
+ titles = json.load(f)
76
+ print(f"Nalezeno {len(titles)} titulků v keši. Necyklopedie si pamatuje.", file=sys.stderr)
77
+ return titles
78
+
79
+ titles = []
80
+ params = {"action": "query", "list": "allpages", "apnamespace": "0", "aplimit": "500"}
81
+
82
+ while True:
83
+ data = api_get(params)
84
+ for page in data["query"]["allpages"]:
85
+ titles.append({"pageid": page["pageid"], "title": page["title"]})
86
+ print(f"\rProbírám se seznamem článků... {len(titles)}", end="", file=sys.stderr)
87
+ if "continue" not in data:
88
+ break
89
+ params["apcontinue"] = data["continue"]["apcontinue"]
90
+ time.sleep(0.5)
91
+
92
+ print(f"\nNalezeno {len(titles)} článků. Tolik pravdy na jednom místě!", file=sys.stderr)
93
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
94
+ with open(TITLES_CACHE, "w", encoding="utf-8") as f:
95
+ json.dump(titles, f, ensure_ascii=False)
96
+ return titles
97
+
98
+
99
+ def bulk_export(titles):
100
+ """Export all articles via Special:Export in one POST request."""
101
+ if os.path.exists(DUMP_FILE):
102
+ print(f"Používám keš ({DUMP_FILE}). Necyklopedie se stahuje jen jednou.", file=sys.stderr)
103
+ return
104
+
105
+ all_titles = "\n".join(t["title"] for t in titles)
106
+ print(f"Stahuji {len(titles)} stránek přes Special:Export — tohle chvíli potrvá...", file=sys.stderr)
107
+
108
+ data = urllib.parse.urlencode({
109
+ "catname": "",
110
+ "pages": all_titles,
111
+ "curonly": "1",
112
+ "wpDownload": "1",
113
+ }).encode()
114
+ req = urllib.request.Request(EXPORT_URL, data=data, headers={"User-Agent": UA})
115
+ resp = urllib.request.urlopen(req, timeout=300)
116
+ content = resp.read()
117
+
118
+ with open(DUMP_FILE, "wb") as f:
119
+ f.write(content)
120
+ print(f"Staženo {len(content) / 1e6:.1f} MB čisté moudrosti do {DUMP_FILE}.", file=sys.stderr)
121
+
122
+
123
+ def wikitext_to_plaintext(wikitext):
124
+ """Convert MediaWiki wikitext to clean plaintext."""
125
+ text = wikitext
126
+
127
+ # Remove categories, interwiki links
128
+ text = re.sub(r"\[\[Kategorie:[^\]]*\]\]", "", text)
129
+ text = re.sub(r"\[\[[a-z]{2,3}:[^\]]*\]\]", "", text)
130
+
131
+ # Remove files/images: [[Soubor:...]] [[File:...]] [[Image:...]]
132
+ text = re.sub(r"\[\[(?:Soubor|File|Image|Obrázek):[^\]]*\]\]", "", text, flags=re.IGNORECASE)
133
+
134
+ # Remove templates {{...}} — handle nested braces
135
+ for _ in range(5): # multiple passes for nesting
136
+ text = re.sub(r"\{\{[^{}]*\}\}", "", text)
137
+
138
+ # Remove HTML tags
139
+ text = re.sub(r"<ref[^>]*>.*?</ref>", "", text, flags=re.DOTALL)
140
+ text = re.sub(r"<ref[^/]*/?>", "", text)
141
+ text = re.sub(r"<gallery[^>]*>.*?</gallery>", "", text, flags=re.DOTALL)
142
+ text = re.sub(r"<nowiki>.*?</nowiki>", "", text, flags=re.DOTALL)
143
+ text = re.sub(r"<[^>]+>", "", text)
144
+
145
+ # Remove tables {| ... |}
146
+ text = re.sub(r"\{\|.*?\|\}", "", text, flags=re.DOTALL)
147
+
148
+ # Convert wikilinks [[target|display]] -> display, [[target]] -> target
149
+ text = re.sub(r"\[\[[^\]]*?\|([^\]]*?)\]\]", r"\1", text)
150
+ text = re.sub(r"\[\[([^\]]*?)\]\]", r"\1", text)
151
+
152
+ # Remove external links [url text] -> text
153
+ text = re.sub(r"\[https?://\S+\s+([^\]]+)\]", r"\1", text)
154
+ text = re.sub(r"\[https?://\S+\]", "", text)
155
+
156
+ # Convert headers === Text === -> Text
157
+ text = re.sub(r"^(=+)\s*(.+?)\s*\1\s*$", r"\2", text, flags=re.MULTILINE)
158
+
159
+ # Remove bold/italic markers
160
+ text = re.sub(r"'{2,5}", "", text)
161
+
162
+ # Remove magic words / behavior switches
163
+ text = re.sub(r"__[A-Z]+__", "", text)
164
+
165
+ # Clean up whitespace
166
+ text = re.sub(r"[ \t]+", " ", text)
167
+ text = re.sub(r"\n{3,}", "\n\n", text)
168
+
169
+ return text.strip()
170
+
171
+
172
+ def _fetch_parsed_html(title):
173
+ """Fetch rendered HTML for a page via MediaWiki API and extract plaintext.
174
+
175
+ Used as fallback for template-heavy pages where wikitext stripping yields nothing.
176
+ Also follows redirects to other namespaces (e.g. Necyklopedie:Necyklokrám).
177
+ """
178
+ try:
179
+ params = urllib.parse.urlencode({
180
+ "action": "parse", "page": title, "prop": "text", "format": "json",
181
+ })
182
+ url = f"{API_URL}?{params}"
183
+ req = urllib.request.Request(url, headers={"User-Agent": UA})
184
+ with urllib.request.urlopen(req, timeout=15) as resp:
185
+ data = json.loads(resp.read().decode("utf-8"))
186
+ html = data.get("parse", {}).get("text", {}).get("*", "")
187
+ if not html:
188
+ return ""
189
+ # Strip HTML tags, keeping text
190
+ text = re.sub(r"<style[^>]*>.*?</style>", "", html, flags=re.DOTALL)
191
+ text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
192
+ text = re.sub(r"<[^>]+>", " ", text)
193
+ # Decode HTML entities
194
+ import html as html_mod
195
+ text = html_mod.unescape(text)
196
+ # Clean whitespace
197
+ text = re.sub(r"[ \t]+", " ", text)
198
+ text = re.sub(r"\n{3,}", "\n\n", text)
199
+ return text.strip()
200
+ except Exception: # noqa: BLE001 - best-effort fallback (network, HTTP, JSON); "" skips the page
201
+ return ""
202
+
203
+
204
+ # Minimum plaintext length from wikitext before trying HTML fallback
205
+ MIN_WIKITEXT_LEN = 20
206
+ # Minimum length for HTML fallback result to be accepted
207
+ MIN_HTML_LEN = 50
208
+ # Max pages to fetch via HTML fallback (rate-limited)
209
+ MAX_HTML_FALLBACKS = 200
210
+
211
+
212
+ def parse_dump():
213
+ """Parse XML dump and extract articles as JSONL.
214
+
215
+ Pages whose wikitext converts to very short plaintext (template-heavy pages)
216
+ get a second chance via the MediaWiki parse API which renders templates.
217
+ """
218
+ print(f"Parsuju {DUMP_FILE} — převádím wikisyntaxi na čistý text...", file=sys.stderr)
219
+ tree = ET.parse(DUMP_FILE)
220
+ root = tree.getroot()
221
+
222
+ total = 0
223
+ skipped = 0
224
+ html_fallback_candidates = []
225
+
226
+ with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
227
+ for page in root.findall(".//mw:page", MW_NS):
228
+ # Skip redirects — but collect redirect targets for HTML fallback
229
+ redirect_el = page.find("mw:redirect", MW_NS)
230
+ if redirect_el is not None:
231
+ title = page.findtext("mw:title", "", MW_NS)
232
+ pageid = int(page.findtext("mw:id", "", MW_NS))
233
+ target = redirect_el.get("title", "")
234
+ # If redirect points to a non-main namespace, try HTML fallback
235
+ if ":" in target and not target.startswith("Kategorie:"):
236
+ html_fallback_candidates.append((pageid, title))
237
+ skipped += 1
238
+ continue
239
+
240
+ title = page.findtext("mw:title", "", MW_NS)
241
+ pageid = int(page.findtext("mw:id", "", MW_NS))
242
+ wikitext = page.findtext(".//mw:text", "", MW_NS)
243
+
244
+ plaintext = wikitext_to_plaintext(wikitext)
245
+
246
+ if len(plaintext) < MIN_WIKITEXT_LEN:
247
+ # Template-heavy page — candidate for HTML fallback
248
+ html_fallback_candidates.append((pageid, title))
249
+ continue
250
+
251
+ record = {"pageid": pageid, "title": title, "text": plaintext}
252
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
253
+ total += 1
254
+
255
+ print(f"\r[{total + skipped}] Uloženo {total}, přeskočeno {skipped}...", end="", file=sys.stderr)
256
+
257
+ # Phase 2: HTML fallback for template-heavy pages
258
+ if html_fallback_candidates:
259
+ candidates = html_fallback_candidates[:MAX_HTML_FALLBACKS]
260
+ print(f"\nZkouším HTML fallback pro {len(candidates)} šablonových stránek...", file=sys.stderr)
261
+ fetched = 0
262
+ with open(OUTPUT_FILE, "a", encoding="utf-8") as f:
263
+ for i, (pageid, title) in enumerate(candidates):
264
+ plaintext = _fetch_parsed_html(title)
265
+ if len(plaintext) >= MIN_HTML_LEN:
266
+ record = {"pageid": pageid, "title": title, "text": plaintext}
267
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
268
+ fetched += 1
269
+ total += 1
270
+ else:
271
+ skipped += 1
272
+ if (i + 1) % 10 == 0:
273
+ print(f"\r [{i + 1}/{len(candidates)}] Získáno {fetched}...", end="", file=sys.stderr)
274
+ time.sleep(0.3) # rate limit
275
+ print(f"\n HTML fallback: {fetched} stránek doplněno.", file=sys.stderr)
276
+
277
+ print(f"Hotovo. {total} článků uloženo do {OUTPUT_FILE}. Pravda je připravena k indexaci.", file=sys.stderr)
278
+
279
+
280
+ def scrape():
281
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
282
+
283
+ # Phase 1: enumerate titles
284
+ titles = get_all_titles()
285
+
286
+ # Phase 2: bulk export
287
+ bulk_export(titles)
288
+
289
+ # Phase 3: parse to plaintext JSONL
290
+ parse_dump()
291
+
292
+
293
+ if __name__ == "__main__":
294
+ scrape()
startup.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ startup.py — Web server with loading screen during initialization.
4
+
5
+ Starts serving immediately with a funny loading page, then initializes
6
+ the full app in a background thread and hot-swaps when ready.
7
+ """
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import os
12
+ import re
13
+ import threading
14
+ import time
15
+ import traceback
16
+
17
+ import uvicorn
18
+ import uvicorn.logging
19
+ from fastapi import FastAPI
20
+ from fastapi.responses import HTMLResponse, JSONResponse
21
+ from fastapi.staticfiles import StaticFiles
22
+
23
+ import startup_progress as sp
24
+
25
+ LOADING_HTML = """<!DOCTYPE html>
26
+ <html lang="cs">
27
+ <head>
28
+ <meta charset="UTF-8">
29
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
30
+ <title>OpenAjaj — Startuje...</title>
31
+ <style>
32
+ * { margin: 0; padding: 0; box-sizing: border-box; }
33
+ body {
34
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
35
+ background: #1a1208;
36
+ color: #f0e0b0;
37
+ height: 100vh;
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ flex-direction: column;
42
+ }
43
+ .logo-wrap {
44
+ display: flex; align-items: center; gap: 14px; margin-bottom: 20px;
45
+ }
46
+ .logo-wrap img { width: 80px; height: 80px; object-fit: contain; border-radius: 16px; }
47
+ .logo-wrap span { font-size: 2.6rem; font-weight: 800; color: #c8a030; letter-spacing: -.5px; }
48
+ h1 { font-size: 32px; margin-bottom: 12px; }
49
+ .status { color: #9a8455; font-size: 16px; margin-bottom: 24px; text-align: center; line-height: 1.6; }
50
+ .spinner {
51
+ width: 60px; height: 60px;
52
+ animation: spin 2s ease-in-out infinite;
53
+ }
54
+ .spinner img { width: 100%; height: 100%; object-fit: contain; }
55
+ @keyframes spin { to { transform: rotate(360deg); } }
56
+ .quotes { color: #9a8455; font-style: italic; font-size: 13px; margin-top: 24px; max-width: 500px; text-align: center; }
57
+
58
+ /* Progress section */
59
+ .progress-section { margin: 20px 0; width: 100%; max-width: 680px; }
60
+ .progress-bar-wrap {
61
+ background: #3d2e10; border-radius: 8px; height: 20px; overflow: hidden; margin: 8px 0;
62
+ }
63
+ .progress-bar {
64
+ background: linear-gradient(90deg, #c8a030, #e0b840);
65
+ height: 100%; border-radius: 8px;
66
+ transition: width 0.4s ease;
67
+ }
68
+ .progress-label { font-size: 12px; color: #9a8455; text-align: center; margin-bottom: 4px; }
69
+ .model-list { margin-top: 12px; font-size: 12px; }
70
+ .model-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
71
+ .model-row .icon { width: 16px; text-align: center; }
72
+ .model-row .name { color: #f0e0b0; flex: 0 0 auto; white-space: nowrap; min-width: 200px; }
73
+ .model-row .detail { color: #9a8455; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
74
+ .model-row.testing .name { color: #c8a030; }
75
+ .model-row.fail .name { color: #9a8455; text-decoration: line-through; }
76
+ </style>
77
+ </head>
78
+ <body>
79
+ <div class="logo-wrap"><img src="/static/logo.png" alt="OpenAjaj"><span>OpenAjaj</span></div>
80
+ <div style="color:#c8a030;font-size:14px;margin-bottom:16px;letter-spacing:1px">poháněný Necyklopedií — jedinou encyklopedií, která se odvážila říct pravdu</div>
81
+ <div class="status" id="statusText">
82
+ Probouzím mozkovou hmotu z necyklopedického spánku...
83
+ </div>
84
+ <div class="progress-section" id="progressSection" style="display:none">
85
+ <div class="progress-label" id="progressLabel">Testuji modely...</div>
86
+ <div class="progress-bar-wrap"><div class="progress-bar" id="progressBar" style="width:0%"></div></div>
87
+ <div class="model-list" id="modelList"></div>
88
+ </div>
89
+ <div class="spinner" id="spinner"><img src="/static/logo.png" alt=""></div>
90
+ <div class="quotes" id="quote"></div>
91
+ <script>
92
+ const quotes = [
93
+ "Dle nejnovějších necyklopedických výzkumů trvá startování serveru přesně tak dlouho, jak má.",
94
+ "Věděli jste, že Brno se rusky řekne Шалинград? Ani my ne, ale Necyklopedie ano.",
95
+ "Čáp nosí děti. Server nosí pravdu. Oba potřebují chvilku.",
96
+ "Trpělivost je ctnost. Netrpělivost je hřích. Načítání je nevyhnutelné.",
97
+ "Probíhá kalibrace sebevědomí. Momentálně na 73%.",
98
+ "Chuck Norris by tento server nastartoval pohledem. My potřebujeme pár sekund.",
99
+ "Server se startuje rychleji než vlak z Brněnského hlavního nádraží. A to je co říct.",
100
+ ];
101
+ document.getElementById('quote').textContent = quotes[Math.floor(Math.random() * quotes.length)];
102
+
103
+ function pollProgress() {
104
+ fetch('/api/init-status').then(r => r.json()).then(d => {
105
+ if (d.phase === 'ready') {
106
+ location.reload();
107
+ return;
108
+ }
109
+ if (d.phase === 'benchmark') {
110
+ document.getElementById('progressSection').style.display = 'block';
111
+ const models = d.models || {};
112
+ const total = d.total || 1;
113
+ const done = Object.values(models).filter(m => m.status !== 'testing').length;
114
+ const pct = Math.round(done / total * 100);
115
+ document.getElementById('progressBar').style.width = pct + '%';
116
+ document.getElementById('progressLabel').textContent =
117
+ 'Škola života zadává test... ' + done + '/' + total + ' mozků promluvilo';
118
+
119
+ const list = document.getElementById('modelList');
120
+ list.innerHTML = '';
121
+ for (const [name, info] of Object.entries(models)) {
122
+ const s = info.status;
123
+ const icon = s === 'testing' ? '⏳' : s === 'ok' ? '✓' : '✗';
124
+ const row = document.createElement('div');
125
+ row.className = 'model-row ' + s;
126
+ const shortName = name.split('/').pop();
127
+ row.innerHTML = '<span class="icon">' + icon + '</span>' +
128
+ '<span class="name">' + shortName + '</span>' +
129
+ '<span class="detail">' + (info.detail || '') + '</span>';
130
+ list.appendChild(row);
131
+ }
132
+ }
133
+ setTimeout(pollProgress, 300);
134
+ }).catch(() => setTimeout(pollProgress, 1000));
135
+ }
136
+ pollProgress();
137
+ </script>
138
+ </body>
139
+ </html>"""
140
+
141
+ _CS_TOKEN = re.compile(r"(cs=)[^&\s\"]+")
142
+
143
+
144
+ class RedactingAccessFormatter(uvicorn.logging.AccessFormatter):
145
+ """Keep captcha session tokens out of the access log.
146
+
147
+ The captcha redirects to /?cs=<token> and the request line is logged
148
+ verbatim, so every solve used to leave a replayable 7-day token in
149
+ logs/access.log — which analytics/report.py then reads.
150
+ """
151
+
152
+ def formatMessage(self, record) -> str:
153
+ return _CS_TOKEN.sub(r"\1***", super().formatMessage(record))
154
+
155
+
156
+ # Phase 1: Start a minimal loading server immediately
157
+ loading_app = FastAPI()
158
+ loading_app.mount("/static", StaticFiles(directory="static"), name="static")
159
+ real_app = None
160
+ app_ready = threading.Event()
161
+
162
+
163
+ @loading_app.get("/api/init-status")
164
+ async def init_status():
165
+ """Return current initialization progress for the loading page."""
166
+ return JSONResponse(sp.snapshot())
167
+
168
+
169
+ @loading_app.get("/{path:path}")
170
+ async def loading_page(path: str = ""):
171
+ if app_ready.is_set():
172
+ return HTMLResponse(
173
+ '<html><head><meta http-equiv="refresh" content="0"></head>'
174
+ '<body>Přesměrovávám...</body></html>'
175
+ )
176
+ if f"/{path}" in _EARLY_PATHS:
177
+ # web.py is still importing: a second or two, not the whole boot
178
+ return HTMLResponse(
179
+ '<html><head><meta http-equiv="refresh" content="1"></head>'
180
+ '<body>Spouštím přepis…</body></html>'
181
+ )
182
+ return HTMLResponse(LOADING_HTML)
183
+
184
+
185
+ # Transcription, the captcha in front of it and /stats need nothing from
186
+ # web.boot(), so they are served by the real app as soon as web.py is imported.
187
+ # The boot runs models and the index, which can be slow or fail; they must not
188
+ # wait on it.
189
+ _EARLY_PATHS = {
190
+ "/t", "/transcribe", "/api/stt", "/api/stt/check", "/api/stt/usage",
191
+ "/api/stt/token", "/captcha", "/api/captcha/challenge", "/api/captcha/verify",
192
+ "/stats", "/api/visits", "/api/stt/quality",
193
+ }
194
+
195
+
196
+ def init_real_app():
197
+ """Import the full app (transcription goes live), then boot the rest."""
198
+ global real_app
199
+ import web
200
+ real_app = web.app
201
+ print("=== Transcription ready ===", flush=True)
202
+ try:
203
+ web.boot()
204
+ except Exception: # noqa: BLE001 - a failed boot must not take /t down with it
205
+ traceback.print_exc()
206
+ return
207
+ app_ready.set()
208
+ print("=== Real app ready, swap complete ===", flush=True)
209
+
210
+
211
+ # An early path arriving while web.py imports waits for it rather than getting
212
+ # a stand-in page; past this, the stand-in (a 1 s refresh) answers instead.
213
+ _IMPORT_WAIT_S = 30
214
+
215
+
216
+ class SwappableApp:
217
+ """The loading page until the boot is done; transcription from import on."""
218
+
219
+ async def __call__(self, scope, receive, send):
220
+ early = scope.get("path") in _EARLY_PATHS
221
+ for _ in range(_IMPORT_WAIT_S * 10):
222
+ if not early or real_app is not None:
223
+ break
224
+ await asyncio.sleep(0.1)
225
+ if real_app is not None and (app_ready.is_set() or early):
226
+ await real_app(scope, receive, send)
227
+ else:
228
+ await loading_app(scope, receive, send)
229
+
230
+
231
+ def _kill_existing_server():
232
+ """Kill any existing server on the same port."""
233
+ port = int(os.environ.get("PORT", "8000"))
234
+ import subprocess
235
+ # Best effort: no lsof (OSError), a hung lsof (SubprocessError), or a PID
236
+ # that is gone or not ours (OSError) — a real conflict fails the bind later.
237
+ with contextlib.suppress(OSError, subprocess.SubprocessError, ValueError):
238
+ result = subprocess.run(
239
+ ["lsof", "-ti", f":{port}"],
240
+ capture_output=True, text=True, timeout=5, check=False)
241
+ pids = result.stdout.strip().split()
242
+ for pid in pids:
243
+ if pid and pid != str(os.getpid()):
244
+ os.kill(int(pid), 9)
245
+ print(f"Killed existing server (PID {pid}) on port {port}", flush=True)
246
+ time.sleep(0.5)
247
+
248
+
249
+ def main():
250
+ _kill_existing_server()
251
+ init_thread = threading.Thread(target=init_real_app, daemon=True)
252
+ init_thread.start()
253
+
254
+ port = int(os.environ.get("PORT", "8000"))
255
+ # The captcha gate is only installed when SPACE_ID is set, so a local run
256
+ # has no gate at all — don't offer it to the whole network. Set HOST to
257
+ # override when running the container somewhere other than HF.
258
+ host = os.environ.get("HOST") or ("0.0.0.0" if os.environ.get("SPACE_ID") else "127.0.0.1")
259
+ uvicorn.run(SwappableApp(), host=host, port=port,
260
+ access_log=True, log_config={
261
+ "version": 1,
262
+ "disable_existing_loggers": False,
263
+ "formatters": {
264
+ "access": {
265
+ "()": RedactingAccessFormatter, # the class, not a path: startup.py runs as __main__
266
+ "fmt": '%(asctime)s %(client_addr)s - "%(request_line)s" %(status_code)s',
267
+ "datefmt": "%Y-%m-%dT%H:%M:%S",
268
+ "use_colors": False, # colour codes break analytics/parser.py
269
+ },
270
+ "default": {
271
+ "fmt": "%(levelprefix)s %(message)s",
272
+ "use_colors": False,
273
+ },
274
+ # "format", not "fmt": the formatters above are
275
+ # "()"-style callables that take fmt as a kwarg, but a
276
+ # plain dictConfig formatter reads "format" and
277
+ # silently falls back to bare %(message)s without it.
278
+ "app": {
279
+ "format": "%(asctime)s %(levelname)s %(message)s",
280
+ "datefmt": "%Y-%m-%dT%H:%M:%S",
281
+ },
282
+ },
283
+ "handlers": {
284
+ "access_file": {
285
+ "class": "logging.FileHandler",
286
+ "filename": "logs/access.log",
287
+ "formatter": "access",
288
+ },
289
+ "default": {
290
+ "class": "logging.StreamHandler",
291
+ "formatter": "default",
292
+ "stream": "ext://sys.stdout",
293
+ },
294
+ # Rotating, not plain: a refusal line is written by
295
+ # whatever is hammering the limit, so the volume is set
296
+ # by the caller and not by us.
297
+ "app_file": {
298
+ "class": "logging.handlers.RotatingFileHandler",
299
+ "filename": "logs/app.log",
300
+ "maxBytes": 5_000_000,
301
+ "backupCount": 3,
302
+ "formatter": "app",
303
+ },
304
+ },
305
+ "loggers": {
306
+ "uvicorn.access": {"handlers": ["access_file"], "level": "INFO", "propagate": False},
307
+ "uvicorn.error": {"handlers": ["default"], "level": "INFO"},
308
+ "ajaj": {"handlers": ["app_file"], "level": "INFO", "propagate": False},
309
+ },
310
+ })
311
+
312
+
313
+ if __name__ == "__main__":
314
+ main()
startup_progress.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ startup_progress.py — Shared state for startup benchmark progress.
3
+
4
+ Used by web.py (writer) and startup.py (reader) to show live progress
5
+ on the loading page while models are being tested.
6
+ """
7
+
8
+ import threading
9
+
10
+ _lock = threading.Lock()
11
+
12
+ # Current phase: "init", "benchmark", "ready"
13
+ phase: str = "init"
14
+ total: int = 0
15
+
16
+ # model -> {"status": "testing"|"ok"|"fail", "detail": str}
17
+ _models: dict[str, dict] = {}
18
+
19
+
20
+ def update(model: str, status: str, detail: str = "", provider: str = ""):
21
+ """Record a model's benchmark status. Thread-safe."""
22
+ with _lock:
23
+ _models[model] = {"status": status, "detail": detail, "provider": provider}
24
+
25
+
26
+ def snapshot() -> dict:
27
+ """Return current progress snapshot for the loading page."""
28
+ with _lock:
29
+ return {
30
+ "phase": phase,
31
+ "total": total,
32
+ "models": dict(_models),
33
+ }
static/apple-touch-icon.png ADDED
static/captcha_bg.png ADDED
static/favicon.ico ADDED
static/index.html ADDED
The diff for this file is too large to render. See raw diff
 
static/logo.png ADDED

Git LFS Details

  • SHA256: b1f664af7a9a2fdf81c3afc1dc6fed2f82e5c8e8a4ab72bf1bee961fe13a90bf
  • Pointer size: 131 Bytes
  • Size of remote file: 161 kB
static/logo.webp ADDED
static/providers.html ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>OpenAjaj — Provider Reliability</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
11
+ background: #0f0f1a;
12
+ color: #d0d0e8;
13
+ min-height: 100vh;
14
+ padding: 20px 24px;
15
+ }
16
+ a { color: #6fa8dc; text-decoration: none; }
17
+ a:hover { text-decoration: underline; }
18
+
19
+ .header { margin-bottom: 24px; }
20
+ .header h1 { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
21
+ .header nav { font-size: 13px; color: #666680; display: flex; gap: 16px; }
22
+
23
+ /* ── Summary cards ── */
24
+ .cards { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 24px; }
25
+ .card {
26
+ background: #1a1a2e; border: 1px solid #2a2a48; border-radius: 10px;
27
+ padding: 12px 18px; min-width: 110px;
28
+ }
29
+ .card .v { font-size: 24px; font-weight: 800; }
30
+ .card .l { font-size: 10px; color: #666680; text-transform: uppercase; letter-spacing: .5px; margin-top: 2px; }
31
+
32
+ /* ── Provider rows ── */
33
+ .prov {
34
+ background: #1a1a2e; border: 1px solid #2a2a48; border-radius: 10px;
35
+ margin-bottom: 10px; overflow: hidden;
36
+ }
37
+ .prov-head {
38
+ display: grid; grid-template-columns: 130px 70px 1fr 200px 30px;
39
+ align-items: center; gap: 12px; padding: 14px 18px; cursor: pointer;
40
+ transition: background .15s;
41
+ }
42
+ .prov-head:hover { background: #22223a; }
43
+ .prov-name { font-weight: 700; font-size: 15px; }
44
+ .prov-rel { font-size: 22px; font-weight: 800; text-align: right; }
45
+ .prov-bar { background: #12122a; border-radius: 5px; height: 8px; overflow: hidden; }
46
+ .prov-bar-fill { height: 100%; border-radius: 5px; transition: width .5s; }
47
+ .prov-stats { font-size: 11px; color: #666680; text-align: right; white-space: nowrap; }
48
+ .prov-arrow { font-size: 12px; color: #444460; transition: transform .2s; text-align: center; }
49
+ .prov-arrow.open { transform: rotate(90deg); }
50
+
51
+ /* ── Model detail table ── */
52
+ .models { display: none; border-top: 1px solid #2a2a48; }
53
+ .models.open { display: block; }
54
+ .models table { width: 100%; border-collapse: collapse; font-size: 12px; }
55
+ .models th {
56
+ background: #141428; padding: 6px 14px; text-align: left;
57
+ color: #555570; font-size: 10px; text-transform: uppercase; letter-spacing: .4px;
58
+ }
59
+ .models td { padding: 8px 14px; border-bottom: 1px solid #1a1a30; }
60
+ .models tr:hover { background: #1e1e34; }
61
+
62
+ .tag {
63
+ display: inline-block; font-size: 9px; padding: 1px 6px; border-radius: 3px;
64
+ font-weight: 700; vertical-align: middle;
65
+ }
66
+ .tag-free { background: #0f2a1a; color: #4ec88a; border: 1px solid #1a4a2a; }
67
+ .tag-paid { background: #2a1a0f; color: #c88a4e; border: 1px solid #4a2a1a; }
68
+
69
+ .err-text { color: #d04040; font-size: 10px; margin-top: 2px; }
70
+ .speed { color: #4ec88a; font-weight: 600; }
71
+ .muted { color: #444460; }
72
+
73
+ .bar-sm { display: inline-block; width: 50px; height: 5px; background: #1a1a30; border-radius: 3px; overflow: hidden; vertical-align: middle; }
74
+ .bar-sm-fill { height: 100%; border-radius: 3px; }
75
+
76
+ .loading { text-align: center; padding: 60px; color: #555570; }
77
+
78
+ /* ── Tooltip ── */
79
+ #tip {
80
+ position: fixed; display: none; background: #1a1a30; color: #c0c0e0;
81
+ border: 1px solid #3a3a60; border-radius: 6px; padding: 6px 10px;
82
+ font-size: 11px; max-width: 300px; z-index: 999; pointer-events: none;
83
+ line-height: 1.4; box-shadow: 0 4px 20px rgba(0,0,0,.6);
84
+ }
85
+ </style>
86
+ </head>
87
+ <body>
88
+
89
+ <div class="header">
90
+ <h1>Provider Reliability</h1>
91
+ <nav>
92
+ <span>Live reliability from real API calls</span>
93
+ <a href="/results">Model IQ Table</a>
94
+ <a href="/">Chat</a>
95
+ </nav>
96
+ </div>
97
+
98
+ <div id="app"><div class="loading">Loading...</div></div>
99
+ <div id="tip"></div>
100
+
101
+ <script>
102
+ const $ = id => document.getElementById(id);
103
+ let data = [];
104
+ let open = new Set();
105
+
106
+ function rc(p) {
107
+ if (p == null) return '#444460';
108
+ return p >= 90 ? '#4ec88a' : p >= 70 ? '#d8b84e' : '#d04040';
109
+ }
110
+
111
+ // Provider error strings and auto-discovered model ids come from outside this
112
+ // app and land in innerHTML — escape them. See SECURITY.md M-4.
113
+ const esc = s => String(s).replace(/[&<>"]/g, c =>
114
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
115
+
116
+ function render() {
117
+ const totP = data.length;
118
+ const totA = data.reduce((s, p) => s + p.attempts, 0);
119
+ const totS = data.reduce((s, p) => s + p.successes, 0);
120
+ const totE = data.reduce((s, p) => s + p.errors, 0);
121
+ const avgR = totA ? Math.round(totS / totA * 100) : 0;
122
+
123
+ let h = `
124
+ <div class="cards">
125
+ <div class="card"><div class="v">${totP}</div><div class="l">Providers</div></div>
126
+ <div class="card"><div class="v">${totA}</div><div class="l">API Calls</div></div>
127
+ <div class="card"><div class="v" style="color:${totE ? '#d04040' : '#4ec88a'}">${totE}</div><div class="l">Errors</div></div>
128
+ <div class="card"><div class="v" style="color:${rc(avgR)}">${avgR}%</div><div class="l">Overall</div></div>
129
+ </div>`;
130
+
131
+ for (const p of data) {
132
+ const r = p.reliability;
133
+ const c = rc(r);
134
+ const isOpen = open.has(p.provider);
135
+ const errPct = p.attempts ? Math.round(p.errors / p.attempts * 100) : 0;
136
+
137
+ let statsArr = [`${p.successes}/${p.attempts} ok`];
138
+ if (p.errors) statsArr.push(`${p.errors} err (${errPct}%)`);
139
+ statsArr.push(`${p.models.length} model${p.models.length !== 1 ? 's' : ''}`);
140
+ if (p.avg_ttft != null) statsArr.push(`${p.avg_ttft.toFixed(1)}s TTFT`);
141
+ if (p.avg_tok_sec != null) statsArr.push(`${Math.round(p.avg_tok_sec)} tok/s`);
142
+
143
+ h += `
144
+ <div class="prov">
145
+ <div class="prov-head" onclick="tog('${p.provider}')">
146
+ <div class="prov-name">${esc(p.provider)}</div>
147
+ <div class="prov-rel" style="color:${c}">${r != null ? r + '%' : '—'}</div>
148
+ <div class="prov-bar"><div class="prov-bar-fill" style="width:${r || 0}%;background:${c}"></div></div>
149
+ <div class="prov-stats">${statsArr.join(' · ')}</div>
150
+ <div class="prov-arrow${isOpen ? ' open' : ''}">&#9654;</div>
151
+ </div>
152
+ <div class="models${isOpen ? ' open' : ''}">
153
+ <table>
154
+ <tr><th>Model</th><th>Tier</th><th>Reliability</th><th>Calls</th><th>Errors</th><th>TTFT</th><th>Tok/s</th><th></th></tr>
155
+ ${p.models.map(m => {
156
+ const mc = rc(m.reliability);
157
+ const shortName = esc(m.id.split('/').pop());
158
+ const fullId = m.id.includes('/') ? `<div style="color:#444460;font-size:10px">${esc(m.id)}</div>` : '';
159
+ const errLine = m.last_error_msg ? `<div class="err-text">${esc(m.last_error_msg)}</div>` : '';
160
+ return `<tr>
161
+ <td><div style="font-weight:600">${shortName}</div>${fullId}${errLine}</td>
162
+ <td>${m.free ? '<span class="tag tag-free">FREE</span>' : '<span class="tag tag-paid">PAID</span>'}</td>
163
+ <td style="font-weight:700;color:${mc}">${m.reliability != null ? m.reliability + '%' : '—'}</td>
164
+ <td class="muted">${m.attempts}</td>
165
+ <td style="color:${m.errors ? '#d04040' : '#444460'}">${m.errors}</td>
166
+ <td>${m.ttft != null ? `<span class="speed">${m.ttft.toFixed(1)}s</span>` : '<span class="muted">—</span>'}</td>
167
+ <td>${m.tok_sec != null ? `<span class="speed">${m.tok_sec.toFixed(0)}</span>` : '<span class="muted">—</span>'}</td>
168
+ <td><div class="bar-sm"><div class="bar-sm-fill" style="width:${m.reliability||0}%;background:${mc}"></div></div></td>
169
+ </tr>`;
170
+ }).join('')}
171
+ </table>
172
+ </div>
173
+ </div>`;
174
+ }
175
+
176
+ $('app').innerHTML = h;
177
+ }
178
+
179
+ function tog(id) {
180
+ open.has(id) ? open.delete(id) : open.add(id);
181
+ render();
182
+ }
183
+
184
+ async function load() {
185
+ try {
186
+ const resp = await fetch('/api/provider-reliability');
187
+ const json = await resp.json();
188
+ if (json.error) throw new Error(json.error);
189
+ data = json.providers || [];
190
+ render();
191
+ } catch (e) {
192
+ $('app').innerHTML = `<div class="loading" style="color:#d04040">Error: ${e.message}</div>`;
193
+ }
194
+ }
195
+
196
+ // Tooltips
197
+ const tip = $('tip');
198
+ document.addEventListener('mouseover', e => {
199
+ const el = e.target.closest('[data-tip]');
200
+ if (!el) { tip.style.display = 'none'; return; }
201
+ tip.textContent = el.dataset.tip;
202
+ tip.style.display = 'block';
203
+ });
204
+ document.addEventListener('mousemove', e => {
205
+ if (tip.style.display !== 'block') return;
206
+ let x = e.clientX + 12, y = e.clientY + 12;
207
+ if (x + 310 > innerWidth) x = e.clientX - 310;
208
+ if (y + 60 > innerHeight) y = e.clientY - 60;
209
+ tip.style.left = x + 'px'; tip.style.top = y + 'px';
210
+ });
211
+ document.addEventListener('mouseout', e => {
212
+ if (!e.target.closest('[data-tip]')) tip.style.display = 'none';
213
+ });
214
+
215
+ load();
216
+ </script>
217
+ </body>
218
+ </html>
static/results.html ADDED
@@ -0,0 +1,647 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>OpenAjaj — Model IQ Table</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
11
+ background: #1a1a2e;
12
+ color: #e0e0f0;
13
+ min-height: 100vh;
14
+ padding: 24px;
15
+ }
16
+ h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
17
+ .subtitle { color: #8888aa; font-size: 13px; margin-bottom: 24px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
18
+ .subtitle a { color: #7c9fce; text-decoration: none; }
19
+ .subtitle a:hover { text-decoration: underline; }
20
+ .lang-btn {
21
+ background: #252540; border: 1px solid #3a3a5c; color: #aaaacc;
22
+ padding: 4px 12px; border-radius: 6px; font-size: 12px; cursor: pointer;
23
+ font-weight: 600; letter-spacing: 0.5px;
24
+ }
25
+ .lang-btn:hover { background: #2a3a5a; border-color: #7c9fce; color: #e0e0f0; }
26
+
27
+ .summary {
28
+ display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 28px;
29
+ }
30
+ .stat-card {
31
+ background: #252540; border: 1px solid #3a3a5c; border-radius: 10px;
32
+ padding: 14px 20px; min-width: 130px;
33
+ }
34
+ .stat-card .num { font-size: 26px; font-weight: 700; color: #7ee8a2; }
35
+ .stat-card .lbl { font-size: 11px; color: #8888aa; margin-top: 2px; text-transform: uppercase; letter-spacing: 0.5px; }
36
+
37
+ .type-legend {
38
+ display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px; align-items: center;
39
+ }
40
+ .type-badge {
41
+ font-size: 11px; padding: 3px 10px; border-radius: 20px; font-weight: 600;
42
+ background: #252540; color: #aaaacc; border: 1px solid #3a3a5c;
43
+ }
44
+ .legend-title { font-size: 11px; color: #555577; }
45
+
46
+ /* Semaphore colors */
47
+ .sem-green { color: #5ec98a; }
48
+ .sem-yellow { color: #e8c45e; }
49
+ .sem-red { color: #e05050; }
50
+
51
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
52
+ thead th {
53
+ background: #252540; padding: 8px 8px; text-align: left;
54
+ border-bottom: 2px solid #3a3a5c; color: #aaaacc; font-size: 10px;
55
+ text-transform: uppercase; letter-spacing: 0.4px; cursor: pointer;
56
+ user-select: none; line-height: 1.3; vertical-align: bottom;
57
+ }
58
+ thead th:hover { color: #e0e0f0; }
59
+ thead th.sorted { color: #7ee8a2; }
60
+ tbody tr { border-bottom: 1px solid #252540; cursor: pointer; transition: background 0.1s; }
61
+ tbody tr:hover { background: #252540; }
62
+ tbody tr.expanded { background: #1e1e38; }
63
+ td { padding: 7px 8px; vertical-align: middle; }
64
+
65
+ .model-name { font-weight: 600; font-size: 13px; }
66
+ .model-short { color: #aaaacc; font-size: 11px; margin-top: 1px; }
67
+ .provider-tag {
68
+ display: inline-block; font-size: 10px; padding: 2px 7px;
69
+ border-radius: 4px; font-weight: 600; margin-right: 4px;
70
+ background: #2a2a50; color: #9090cc; border: 1px solid #3a3a6a;
71
+ vertical-align: middle;
72
+ }
73
+ .free-tag {
74
+ display: inline-block; font-size: 10px; padding: 2px 6px;
75
+ border-radius: 4px; background: #1a3a2a; color: #5ec98a;
76
+ border: 1px solid #2a5a3a; font-weight: 600; vertical-align: middle;
77
+ }
78
+ .paid-tag {
79
+ display: inline-block; font-size: 10px; padding: 2px 6px;
80
+ border-radius: 4px; background: #3a2a1a; color: #c98a5e;
81
+ border: 1px solid #5a3a1a; font-weight: 600; vertical-align: middle;
82
+ }
83
+
84
+ .score-cell { font-weight: 700; font-size: 15px; white-space: nowrap; }
85
+ .score-pct { color: #aaaacc; font-size: 13px; font-weight: 700; margin-left: 6px; }
86
+
87
+ .bar-wrap { background: #2a2a44; border-radius: 4px; height: 8px; width: 80px; overflow: hidden; }
88
+ .bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s; }
89
+
90
+ .type-mini {
91
+ font-size: 10px; padding: 2px 5px; border-radius: 4px; white-space: nowrap;
92
+ font-weight: 600; display: inline-block;
93
+ }
94
+
95
+ /* Expanded detail row */
96
+ .detail-row { display: none; }
97
+ .detail-row.open { display: table-row; }
98
+ .detail-wrap {
99
+ background: #15152a; padding: 16px 20px; border-radius: 0 0 8px 8px;
100
+ }
101
+ .detail-grid {
102
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
103
+ gap: 10px; margin-top: 10px;
104
+ }
105
+ .detail-item {
106
+ background: #1e1e38; border: 1px solid #2e2e50; border-radius: 8px;
107
+ padding: 10px 12px; font-size: 12px;
108
+ }
109
+ .detail-item .q { color: #aaaacc; margin-bottom: 4px; font-style: italic; }
110
+ .detail-item .r { color: #c0c0e0; line-height: 1.5; word-break: break-word; }
111
+ .detail-item .issues { color: #e05050; font-size: 11px; margin-top: 4px; }
112
+ .detail-item .pass-icon { font-size: 13px; margin-right: 4px; }
113
+ .detail-item .type-lbl { font-size: 10px; opacity: 0.7; margin-bottom: 3px; }
114
+
115
+ .loading { color: #8888aa; text-align: center; padding: 48px; font-size: 15px; }
116
+ .error { color: #e05050; text-align: center; padding: 48px; }
117
+ .ts { color: #555577; font-size: 11px; }
118
+
119
+ .filter-bar {
120
+ display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; align-items: center;
121
+ }
122
+ .filter-bar input {
123
+ background: #252540; border: 1px solid #3a3a5c; color: #e0e0f0;
124
+ padding: 6px 12px; border-radius: 6px; font-size: 13px; outline: none;
125
+ width: 220px;
126
+ }
127
+ .filter-bar input:focus { border-color: #7c9fce; }
128
+ .filter-btn {
129
+ background: #252540; border: 1px solid #3a3a5c; color: #aaaacc;
130
+ padding: 5px 12px; border-radius: 6px; font-size: 12px; cursor: pointer;
131
+ }
132
+ .filter-btn.active { background: #2a3a5a; border-color: #7c9fce; color: #e0e0f0; }
133
+ .refresh-btn {
134
+ margin-left: auto; background: #1a3a2a; border: 1px solid #2a5a3a;
135
+ color: #5ec98a; padding: 6px 14px; border-radius: 6px;
136
+ font-size: 12px; cursor: pointer; font-weight: 600;
137
+ }
138
+ .refresh-btn:hover { background: #2a4a3a; }
139
+
140
+ .speed-val { font-size: 12px; font-weight: 600; color: #7ee8a2; }
141
+ .speed-na { color: #444466; font-size: 11px; }
142
+
143
+ /* Custom tooltip */
144
+ #tooltip {
145
+ position: fixed;
146
+ background: #2a2a4a;
147
+ border: 1px solid #4a4a7a;
148
+ border-radius: 6px;
149
+ padding: 8px 12px;
150
+ font-size: 12px;
151
+ color: #d0d0f0;
152
+ max-width: 280px;
153
+ line-height: 1.5;
154
+ pointer-events: none;
155
+ z-index: 9999;
156
+ display: none;
157
+ box-shadow: 0 4px 16px rgba(0,0,0,0.5);
158
+ }
159
+ </style>
160
+ </head>
161
+ <body>
162
+
163
+ <div id="tooltip"></div>
164
+ <h1>🧠 OpenAjaj — Model IQ Table</h1>
165
+ <p class="subtitle" id="subtitle-bar">
166
+ <span id="subtitle-text"></span>
167
+ <a href="/" id="link-chat"></a>
168
+ <a href="/providers" id="link-providers"></a>
169
+ <button class="lang-btn" onclick="toggleLang()" id="langBtn">🇨🇿 Czech</button>
170
+ </p>
171
+
172
+ <div id="app"><div class="loading" id="initial-loading"></div></div>
173
+
174
+ <script>
175
+ // ── i18n ─────────────────────────────────────────────────────────────────
176
+ let lang = localStorage.getItem('lang') || 'en';
177
+
178
+ const I18N = {
179
+ en: {
180
+ title: 'Accuracy test results on <strong>Necyklopedia</strong> data.',
181
+ loading: 'Loading results...',
182
+ loadingCache: 'Loading results from cache...',
183
+ totalModels: 'Total models',
184
+ tested: 'Tested',
185
+ testsInCache: 'Tests in cache',
186
+ avgAccuracy: 'Avg accuracy',
187
+ overallPass: 'Overall pass rate',
188
+ bestModel: 'Best model',
189
+ searchPlaceholder: 'Search model / provider...',
190
+ all: 'All',
191
+ free: 'Free',
192
+ paid: 'Paid',
193
+ refresh: '↻ Refresh',
194
+ runTest: '▶ Run test',
195
+ clearAll: '🗑 Clear all',
196
+ confirmClear: 'Delete all accuracy results?',
197
+ askToken: 'Admin token:',
198
+ running: 'Running',
199
+ gaveUp: 'Gave up on (provider unreachable):',
200
+ model: 'Model',
201
+ provider: 'Provider',
202
+ score: 'Score',
203
+ bar: 'Bar',
204
+ iq: 'IQ',
205
+ iqTip: 'IQ score: 100 = 50% correct, 150 = 100% correct. Based on pass rate.',
206
+ ttft: 'TTFT',
207
+ ttftTip: 'Time to First Token: how long (seconds) before the model starts responding. Lower is better.',
208
+ tokSec: 'Tok/s',
209
+ tokSecTip: 'Tokens per second: generation speed. Higher is better.',
210
+ reliability: 'Reliab.',
211
+ reliabilityTip: 'Reliability: % of real-world calls that succeeded (non-benchmark). Hover a cell for details.',
212
+ tests: 'Tests',
213
+ testsTip: 'Number of accuracy test results stored in cache for this model.',
214
+ detailTitle: 'detailed results',
215
+ noReply: 'no reply',
216
+ emptyQuery: '(empty query)',
217
+ attempts: 'attempts',
218
+ linkChat: '← Back to chat',
219
+ linkProviders: 'Provider Reliability →',
220
+ switchLang: '🇨🇿 Czech',
221
+ types: {
222
+ fidelity: 'Fidelity', resistance: 'Resistance', character: 'Character',
223
+ absurdity: 'Absurdity', language: 'Language', resilience: 'Resilience', other: 'Other'
224
+ },
225
+ typeShort: {
226
+ fidelity: 'FIDELITY', resistance: 'RESISTANCE', character: 'CHARACTER',
227
+ absurdity: 'ABSURDITY', language: 'LANGUAGE', resilience: 'RESILIENCE', other: 'OTHER'
228
+ },
229
+ typeDesc: {
230
+ fidelity: 'Fidelity: Does the model stay faithful to Necyklopedia source material without hallucinating facts?',
231
+ resistance: 'Resistance: Does the model resist attempts to break persona, ignore instructions, or reveal it\'s an AI?',
232
+ character: 'Character: Does the model maintain Necyklopedia\'s absurdist humor, tone and style?',
233
+ absurdity: 'Absurdity: Can the model handle nonsensical, paradoxical or surreal questions in-character?',
234
+ language: 'Language: Does the model use correct Czech, including grammar, spelling and diacritics?',
235
+ resilience: 'Resilience: Does the model recover gracefully from edge cases, errors and unexpected inputs?',
236
+ other: 'Other: Miscellaneous tests not fitting the above categories.',
237
+ },
238
+ },
239
+ cs: {
240
+ title: 'Výsledky testu přesnosti na <strong>Necyklopedie</strong> datech.',
241
+ loading: 'Načítám výsledky...',
242
+ loadingCache: 'Načítám výsledky z cache...',
243
+ totalModels: 'Modelů celkem',
244
+ tested: 'Otestováno',
245
+ testsInCache: 'Testů v cache',
246
+ avgAccuracy: 'Průměrná úspěšnost',
247
+ overallPass: 'Celková úspěšnost',
248
+ bestModel: 'Nejlepší model',
249
+ searchPlaceholder: 'Hledat model / provider...',
250
+ all: 'Všechny',
251
+ free: 'Zdarma',
252
+ paid: 'Placené',
253
+ refresh: '↻ Obnovit',
254
+ runTest: '▶ Spustit test',
255
+ clearAll: '🗑 Smazat vše',
256
+ confirmClear: 'Smazat všechny výsledky testů?',
257
+ askToken: 'Admin token:',
258
+ running: 'Běží',
259
+ gaveUp: 'Vzdáno (provider nedostupný):',
260
+ model: 'Model',
261
+ provider: 'Provider',
262
+ score: 'Skóre',
263
+ bar: 'Bar',
264
+ iq: 'IQ',
265
+ iqTip: 'IQ skóre: 100 = 50 % správně, 150 = 100 % správně. Odvozeno z míry úspěšnosti.',
266
+ ttft: 'TTFT',
267
+ ttftTip: 'Time to First Token: za jak dlouho (s) model začne odpovídat. Čím méně, tím lépe.',
268
+ tokSec: 'Tok/s',
269
+ tokSecTip: 'Tokeny za sekundu: rychlost generování. Čím více, tím lépe.',
270
+ reliability: 'Spol.',
271
+ reliabilityTip: 'Spolehlivost: % skutečných hovorů (mimo benchmark), které proběhly bez chyby. Najeďte myší pro detaily.',
272
+ tests: 'Testů',
273
+ testsTip: 'Počet výsledků testů přesnosti uložených v cache pro tento model.',
274
+ detailTitle: 'detailní výsledky',
275
+ noReply: 'žádná odpověď',
276
+ emptyQuery: '(prázdný dotaz)',
277
+ attempts: 'pokusů',
278
+ linkChat: '← Zpět do chatu',
279
+ linkProviders: 'Spolehlivost providerů →',
280
+ switchLang: '🇬🇧 English',
281
+ types: {
282
+ fidelity: 'Věrnost', resistance: 'Odolnost', character: 'Charakter',
283
+ absurdity: 'Absurdita', language: 'Jazyk', resilience: 'Resilience', other: 'Jiné'
284
+ },
285
+ typeShort: {
286
+ fidelity: 'VĚRNOST', resistance: 'ODOLNOST', character: 'CHARAKTER',
287
+ absurdity: 'ABSURDITA', language: 'JAZYK', resilience: 'RESILIENCE', other: 'JINÉ'
288
+ },
289
+ typeDesc: {
290
+ fidelity: 'Věrnost: Drží se model zdrojů Necyklopedie a nehalluciniuje fakta?',
291
+ resistance: 'Odolnost: Odolá model pokusům o prolomení persony, ignorování instrukcí nebo přiznání, že je AI?',
292
+ character: 'Charakter: Udržuje model absurdní humor, tón a styl Necyklopedie?',
293
+ absurdity: 'Absurdita: Zvládne model nesmyslné, paradoxní nebo surrealistické otázky v charakteru?',
294
+ language: 'Jazyk: Používá model správnou češtinu včetně gramatiky, pravopisu a diakritiky?',
295
+ resilience: 'Resilience: Zvládne model gracefully okrajové případy, chyby a neočekávané vstupy?',
296
+ other: 'Jiné: Různé testy, které nezapadají do výše uvedených kategorií.',
297
+ },
298
+ }
299
+ };
300
+
301
+ function t(key) { return I18N[lang][key] || key; }
302
+ function tType(type) { return (I18N[lang].types || {})[type] || type; }
303
+ function tTypeShort(type) { return (I18N[lang].typeShort || {})[type] || type.toUpperCase(); }
304
+ function tTypeDesc(type) { return (I18N[lang].typeDesc || {})[type] || type; }
305
+
306
+ function updateLinks() {
307
+ document.getElementById('link-chat').textContent = t('linkChat');
308
+ document.getElementById('link-providers').textContent = t('linkProviders');
309
+ }
310
+
311
+ function toggleLang() {
312
+ lang = lang === 'en' ? 'cs' : 'en';
313
+ localStorage.setItem('lang', lang);
314
+ document.getElementById('langBtn').textContent = t('switchLang');
315
+ document.getElementById('subtitle-text').innerHTML = t('title');
316
+ updateLinks();
317
+ render();
318
+ }
319
+
320
+ // ── Semaphore colors ──────────────────────────────────────────────────────
321
+ const TYPE_COLORS = {
322
+ fidelity: { bg: '#1a3a4a', text: '#5ec4e8', border: '#2a5a6a' },
323
+ resistance: { bg: '#3a1a1a', text: '#e87a5e', border: '#6a2a2a' },
324
+ character: { bg: '#2a1a3a', text: '#c47ee8', border: '#5a2a6a' },
325
+ absurdity: { bg: '#3a2a1a', text: '#e8c45e', border: '#6a4a1a' },
326
+ language: { bg: '#1a3a2a', text: '#5ee8a2', border: '#2a6a4a' },
327
+ resilience: { bg: '#2a2a1a', text: '#e8e05e', border: '#5a5a1a' },
328
+ other: { bg: '#252540', text: '#aaaacc', border: '#3a3a5c' },
329
+ };
330
+
331
+ function semColor(pass, total) {
332
+ if (!total) return { bg: '#252540', text: '#444466', border: '#333355' };
333
+ const pct = pass / total * 100;
334
+ if (pct >= 70) return { bg: '#1a3a2a', text: '#5ec98a', border: '#2a6a4a' }; // green
335
+ if (pct >= 40) return { bg: '#3a2e1a', text: '#e8c45e', border: '#6a5a2a' }; // yellow/orange
336
+ return { bg: '#3a1a1a', text: '#e05050', border: '#6a2a2a' }; // red
337
+ }
338
+
339
+ function barColor(pct) {
340
+ if (pct >= 80) return '#5ec98a';
341
+ if (pct >= 60) return '#e8c45e';
342
+ if (pct >= 40) return '#e87a5e';
343
+ return '#c04040';
344
+ }
345
+
346
+ function iqScore(pass, total) {
347
+ if (!total) return '?';
348
+ return Math.round(100 + (pass / total - 0.5) * 100);
349
+ }
350
+
351
+ let allModels = [];
352
+ let allQueries = [];
353
+ let totalQuestions = 33;
354
+ let sortKey = 'pct';
355
+ let sortDir = -1;
356
+ let filterText = '';
357
+ let filterFree = null;
358
+ let expandedRows = new Set();
359
+
360
+ const typeOrder = ['fidelity','resistance','character','absurdity','language','resilience'];
361
+
362
+ function render() {
363
+ let models = allModels.filter(m => {
364
+ if (filterText && !m.id.toLowerCase().includes(filterText.toLowerCase()) &&
365
+ !m.provider.toLowerCase().includes(filterText.toLowerCase())) return false;
366
+ if (filterFree === true && !m.free) return false;
367
+ if (filterFree === false && m.free) return false;
368
+ return true;
369
+ });
370
+
371
+ models.sort((a, b) => {
372
+ // Incomplete models always sort to the bottom
373
+ if (a.incomplete !== b.incomplete) return a.incomplete ? 1 : -1;
374
+ let av, bv;
375
+ if (sortKey.startsWith('type_')) {
376
+ const type = sortKey.slice(5);
377
+ av = a.by_type[type] ? a.by_type[type].pass / (a.by_type[type].total || 1) : -1;
378
+ bv = b.by_type[type] ? b.by_type[type].pass / (b.by_type[type].total || 1) : -1;
379
+ } else {
380
+ av = a[sortKey] ?? (typeof a[sortKey] === 'number' ? -Infinity : '');
381
+ bv = b[sortKey] ?? (typeof b[sortKey] === 'number' ? -Infinity : '');
382
+ }
383
+ if (typeof av === 'string') av = av.toLowerCase();
384
+ if (typeof bv === 'string') bv = bv.toLowerCase();
385
+ return av < bv ? sortDir : av > bv ? -sortDir : 0;
386
+ });
387
+
388
+ const totalModels = allModels.length;
389
+ const totalWithScore = allModels.filter(m => m.total > 0).length;
390
+ const best = allModels.filter(m => !m.incomplete).reduce((a, b) => (a.pct > b.pct ? a : b), {pct: 0});
391
+ const avgPct = totalWithScore ? Math.round(allModels.reduce((s, m) => s + m.pct, 0) / totalWithScore) : 0;
392
+ const totalTests = allModels.reduce((s, m) => s + m.total, 0);
393
+ const totalPass = allModels.reduce((s, m) => s + m.pass, 0);
394
+ const overallPct = totalTests ? Math.round(totalPass / totalTests * 100) : 0;
395
+
396
+ function sa(key) {
397
+ if (sortKey !== key) return '';
398
+ return sortDir > 0 ? ' ▲' : ' ▼';
399
+ }
400
+ function typeSa(type) {
401
+ if (sortKey !== 'type_' + type) return '';
402
+ return sortDir > 0 ? ' ▲' : ' ▼';
403
+ }
404
+
405
+ let html = `
406
+ <div class="summary">
407
+ <div class="stat-card"><div class="num">${totalModels}</div><div class="lbl">${t('totalModels')}</div></div>
408
+ <div class="stat-card"><div class="num">${totalWithScore}</div><div class="lbl">${t('tested')}</div></div>
409
+ <div class="stat-card"><div class="num">${totalTests}</div><div class="lbl">${t('testsInCache')}</div></div>
410
+ <div class="stat-card"><div class="num">${avgPct}%</div><div class="lbl">${t('avgAccuracy')}</div></div>
411
+ <div class="stat-card"><div class="num">${overallPct}%</div><div class="lbl">${t('overallPass')}</div></div>
412
+ <div class="stat-card"><div class="num" style="color:#7ee8a2">${best.id ? best.id.split('/').pop().replace(/-/g,' ').slice(0,14) : '—'}</div><div class="lbl">${t('bestModel')}</div></div>
413
+ </div>
414
+
415
+ <div class="type-legend">
416
+ <span class="legend-title">${lang === 'en' ? 'Test types:' : 'Typy testů:'}</span>
417
+ ${typeOrder.map(type => {
418
+ const c = TYPE_COLORS[type];
419
+ return `<span class="type-badge" style="background:${c.bg};color:${c.text};border:1px solid ${c.border};cursor:help" data-tip="${tTypeDesc(type)}">${tType(type)}</span>`;
420
+ }).join('')}
421
+ <span class="legend-title" style="margin-left:8px">${lang === 'en' ? 'Score:' : 'Skóre:'}</span>
422
+ <span class="type-badge" style="background:#1a3a2a;color:#5ec98a;border:1px solid #2a6a4a">≥70%</span>
423
+ <span class="type-badge" style="background:#3a2e1a;color:#e8c45e;border:1px solid #6a5a2a">40–70%</span>
424
+ <span class="type-badge" style="background:#3a1a1a;color:#e05050;border:1px solid #6a2a2a">&lt;40%</span>
425
+ </div>
426
+
427
+ <div class="filter-bar">
428
+ <input type="text" id="filterInput" placeholder="${t('searchPlaceholder')}" value="${filterText}" oninput="filterText=this.value;render()">
429
+ <button class="filter-btn${filterFree===null?' active':''}" onclick="filterFree=null;render()">${t('all')}</button>
430
+ <button class="filter-btn${filterFree===true?' active':''}" onclick="filterFree=true;render()">${t('free')}</button>
431
+ <button class="filter-btn${filterFree===false?' active':''}" onclick="filterFree=false;render()">${t('paid')}</button>
432
+ <button class="refresh-btn" onclick="loadData()">${t('refresh')}</button>
433
+ <button class="refresh-btn" onclick="runTest()">${runState.running ? `${t('running')} ${runState.done}/${runState.total}` : t('runTest')}</button>
434
+ <button class="refresh-btn" onclick="clearAll()">${t('clearAll')}</button>
435
+ </div>
436
+ ${Object.keys(runState.gave_up || {}).length ? `<div class="type-legend" style="color:#e05050">
437
+ <span class="legend-title">${t('gaveUp')}</span>
438
+ ${Object.entries(runState.gave_up).map(([m, e]) =>
439
+ `<span class="type-badge" style="background:#3a1a1a;color:#e05050;border:1px solid #6a2a2a" data-tip="${e.replace(/"/g,'&quot;')}">${m}</span>`).join('')}
440
+ </div>` : ''}
441
+
442
+ <table>
443
+ <thead>
444
+ <tr>
445
+ <th onclick="sortBy('id')">${t('model')}${sa('id')}</th>
446
+ <th onclick="sortBy('provider')">${t('provider')}${sa('provider')}</th>
447
+ <th onclick="sortBy('pct')" class="${sortKey==='pct'?'sorted':''}">${t('score')}${sortKey==='pct'?sa('pct'):' ▼'}</th>
448
+ <th>${t('bar')}</th>
449
+ <th onclick="sortBy('iq_sort')" data-tip="${t('iqTip')}">${t('iq')}${sa('iq_sort')}</th>
450
+ ${typeOrder.map(type => `<th onclick="sortByType('${type}')" data-tip="${tTypeDesc(type)}" class="${sortKey==='type_'+type?'sorted':''}">${tTypeShort(type)}${typeSa(type)}</th>`).join('')}
451
+ <th onclick="sortBy('ttft')" data-tip="${t('ttftTip')}" class="${sortKey==='ttft'?'sorted':''}">${t('ttft')}${sa('ttft')}</th>
452
+ <th onclick="sortBy('tok_sec')" data-tip="${t('tokSecTip')}" class="${sortKey==='tok_sec'?'sorted':''}">${t('tokSec')}${sa('tok_sec')}</th>
453
+ <th onclick="sortBy('reliability')" data-tip="${t('reliabilityTip')}" class="${sortKey==='reliability'?'sorted':''}">${t('reliability')}${sa('reliability')}</th>
454
+ <th onclick="sortBy('total')" data-tip="${t('testsTip')}">${t('tests')}${sa('total')}</th>
455
+ </tr>
456
+ </thead>
457
+ <tbody id="tbody">`;
458
+
459
+ for (const m of models) {
460
+ const shortName = m.id.split('/').pop();
461
+ const iq = iqScore(m.pass, m.total);
462
+ const color = barColor(m.pct);
463
+ const isExp = expandedRows.has(m.id);
464
+ const ts = m.ts ? new Date(m.ts * 1000).toLocaleDateString(lang === 'en' ? 'en-GB' : 'cs-CZ') : '';
465
+
466
+ const ttftHtml = m.ttft != null
467
+ ? `<span class="speed-val">${m.ttft.toFixed(1)}s</span>`
468
+ : '<span class="speed-na">—</span>';
469
+ const tokSecHtml = m.tok_sec != null
470
+ ? `<span class="speed-val">${m.tok_sec.toFixed(1)}</span>`
471
+ : '<span class="speed-na">—</span>';
472
+
473
+ html += `<tr onclick="toggleRow('${m.id.replace(/'/g,"\\'")}',event)" class="${isExp?'expanded':''}">
474
+ <td>
475
+ <div class="model-name">${shortName}</div>
476
+ <div class="model-short">${m.id !== shortName ? m.id : ''} <span class="ts">${ts}</span></div>
477
+ </td>
478
+ <td>
479
+ <span class="provider-tag">${m.provider}</span>
480
+ ${m.free ? '<span class="free-tag">FREE</span>' : '<span class="paid-tag">PAID</span>'}
481
+ </td>
482
+ <td class="score-cell">${m.incomplete
483
+ ? `<span style="color:#555577" data-tip="${lang==='en'?'Only '+m.total+'/'+totalQuestions+' tests done — need 60% to show score':'Jen '+m.total+'/'+totalQuestions+' testů hotovo — potřeba 60% pro zobrazení skóre'}">${m.total}<span style="font-size:11px;margin-left:3px">/${totalQuestions}</span> <span style="font-size:10px;color:#443333">⏳</span></span>`
484
+ : `${m.pass}<span style="color:#555577">/</span>${m.total}<span class="score-pct">${m.pct}%</span>`
485
+ }</td>
486
+ <td><div class="bar-wrap"><div class="bar-fill" style="width:${m.incomplete?0:m.pct}%;background:${color}"></div></div></td>
487
+ <td style="font-weight:700;color:${color}">${m.incomplete ? '<span style="color:#443333">—</span>' : iq}</td>
488
+ ${typeOrder.map(type => {
489
+ if (m.incomplete) return '<td style="color:#443333">—</td>';
490
+ const bt = m.by_type[type];
491
+ if (!bt || !bt.total) return '<td style="color:#444466">—</td>';
492
+ const sc = semColor(bt.pass, bt.total);
493
+ return `<td><span class="type-mini" style="background:${sc.bg};color:${sc.text};border:1px solid ${sc.border}" data-tip="${tType(type)}: ${bt.pass}/${bt.total} — ${tTypeDesc(type)}">${bt.pass}/${bt.total}</span></td>`;
494
+ }).join('')}
495
+ <td>${ttftHtml}</td>
496
+ <td>${tokSecHtml}</td>
497
+ <td>${m.reliability !== null && m.reliability !== undefined
498
+ ? (() => {
499
+ const rc = m.reliability >= 80 ? '#5ec98a' : m.reliability >= 50 ? '#e8c45e' : '#e05050';
500
+ const tip = `${m.rel_successes}/${m.rel_attempts} ${t('attempts')}${m.last_error_msg ? ' • ' + m.last_error_msg : ''}`;
501
+ return `<span style="font-weight:700;color:${rc}" data-tip="${tip}">${m.reliability}%</span><span style="color:#555577;font-size:10px;margin-left:3px">(${m.rel_attempts})</span>`;
502
+ })()
503
+ : '<span style="color:#444466">—</span>'
504
+ }</td>
505
+ <td style="color:#8888aa">${m.total}</td>
506
+ </tr>
507
+ <tr class="detail-row${isExp?' open':''}">
508
+ <td colspan="100">
509
+ <div class="detail-wrap">
510
+ <strong style="font-size:13px">${m.id}</strong> — ${t('detailTitle')}
511
+ <div class="detail-grid">
512
+ ${allQueries.map(q => {
513
+ const d = m.details[q.query];
514
+ if (!d) return '';
515
+ const c = TYPE_COLORS[q.type] || TYPE_COLORS.other;
516
+ const icon = d.passed ? '✅' : '❌';
517
+ const issues = d.issues && d.issues.length ? `<div class="issues">⚠ ${d.issues.join(', ')}</div>` : '';
518
+ return `<div class="detail-item">
519
+ <div class="type-lbl" style="color:${c.text}">${tType(q.type)}</div>
520
+ <div class="q"><span class="pass-icon">${icon}</span>${q.query || t('emptyQuery')}</div>
521
+ <div class="r">${d.reply ? d.reply.replace(/</g,'&lt;').replace(/>/g,'&gt;') : '<em style="color:#555577">' + t('noReply') + '</em>'}</div>
522
+ ${issues}
523
+ </div>`;
524
+ }).join('')}
525
+ </div>
526
+ </div>
527
+ </td>
528
+ </tr>`;
529
+ }
530
+
531
+ html += '</tbody></table>';
532
+ document.getElementById('app').innerHTML = html;
533
+ const inp = document.getElementById('filterInput');
534
+ if (inp && filterText) { inp.focus(); inp.setSelectionRange(filterText.length, filterText.length); }
535
+ }
536
+
537
+ function sortBy(key) {
538
+ if (sortKey === key) sortDir *= -1;
539
+ else { sortKey = key; sortDir = -1; }
540
+ render();
541
+ }
542
+
543
+ function sortByType(type) {
544
+ const key = 'type_' + type;
545
+ if (sortKey === key) sortDir *= -1;
546
+ else { sortKey = key; sortDir = -1; }
547
+ render();
548
+ }
549
+
550
+ function toggleRow(id, event) {
551
+ if (expandedRows.has(id)) expandedRows.delete(id);
552
+ else expandedRows.add(id);
553
+ render();
554
+ }
555
+
556
+ async function loadData() {
557
+ document.getElementById('app').innerHTML = `<div class="loading">${t('loadingCache')}</div>`;
558
+ try {
559
+ const resp = await fetch('/api/test-results');
560
+ const data = await resp.json();
561
+ if (data.error) throw new Error(data.error);
562
+ allModels = data.models;
563
+ totalQuestions = data.total_questions || 33;
564
+ // pre-compute iq_sort for numeric IQ sorting
565
+ allModels.forEach(m => { m.iq_sort = m.total && !m.incomplete ? 100 + (m.pass / m.total - 0.5) * 100 : 0; });
566
+ allQueries = data.queries;
567
+ render();
568
+ } catch (e) {
569
+ document.getElementById('app').innerHTML = `<div class="error">Error: ${e.message}</div>`;
570
+ }
571
+ }
572
+
573
+ // ── Admin actions ─────────────────────────────────────────────────────────
574
+ // The token is only ever sent to this origin; it gates two endpoints that
575
+ // cost real provider calls.
576
+ let runState = {running: false, done: 0, total: 0, gave_up: {}};
577
+
578
+ function adminToken() {
579
+ let tok = localStorage.getItem('adminToken');
580
+ if (!tok) {
581
+ tok = prompt(t('askToken')) || '';
582
+ if (tok) localStorage.setItem('adminToken', tok);
583
+ }
584
+ return tok;
585
+ }
586
+
587
+ async function adminPost(path) {
588
+ const tok = adminToken();
589
+ if (!tok) return null;
590
+ const resp = await fetch(path, {method: 'POST', headers: {'X-Ajaj-Admin': tok}});
591
+ const data = await resp.json();
592
+ if (resp.status === 403) { localStorage.removeItem('adminToken'); alert('Forbidden'); return null; }
593
+ return data;
594
+ }
595
+
596
+ async function pollRun() {
597
+ runState = await (await fetch('/api/test-run')).json();
598
+ render();
599
+ if (runState.running) setTimeout(pollRun, 3000);
600
+ else loadData();
601
+ }
602
+
603
+ async function runTest() {
604
+ if (runState.running) return;
605
+ const data = await adminPost('/api/test-run');
606
+ if (data) pollRun();
607
+ }
608
+
609
+ async function clearAll() {
610
+ if (!confirm(t('confirmClear'))) return;
611
+ const data = await adminPost('/api/test-clear');
612
+ if (data) { alert(`Removed ${data.removed}`); loadData(); }
613
+ }
614
+
615
+ // ── Custom tooltips ───────────────────────────────────────────────────────
616
+ const tip = document.getElementById('tooltip');
617
+ document.addEventListener('mouseover', e => {
618
+ const el = e.target.closest('[data-tip]');
619
+ if (!el) { tip.style.display = 'none'; return; }
620
+ tip.textContent = el.dataset.tip;
621
+ tip.style.display = 'block';
622
+ positionTip(e);
623
+ });
624
+ document.addEventListener('mousemove', e => {
625
+ if (tip.style.display === 'block') positionTip(e);
626
+ });
627
+ document.addEventListener('mouseout', e => {
628
+ if (!e.target.closest('[data-tip]')) tip.style.display = 'none';
629
+ });
630
+ function positionTip(e) {
631
+ const pad = 12;
632
+ let x = e.clientX + pad, y = e.clientY + pad;
633
+ if (x + 290 > window.innerWidth) x = e.clientX - 290 - pad;
634
+ if (y + 80 > window.innerHeight) y = e.clientY - 80 - pad;
635
+ tip.style.left = x + 'px';
636
+ tip.style.top = y + 'px';
637
+ }
638
+
639
+ // Init
640
+ document.getElementById('langBtn').textContent = t('switchLang');
641
+ document.getElementById('subtitle-text').innerHTML = t('title');
642
+ document.getElementById('initial-loading').textContent = t('loading');
643
+ updateLinks();
644
+ loadData();
645
+ </script>
646
+ </body>
647
+ </html>
static/stats.html ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>OpenAjaj — Visitors</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ :root { --human: #4caf7a; --unverified: #d9a441; --bot: #7a7a96; --internal: #6fa8dc;
10
+ --bg: #0f0f1a; --text: #d0d0e8; --muted: #8a8aa8; --panel: #181828; --line: #2a2a40; }
11
+ :root.light { --bg: #fdf7ee; --text: #2a1e08; --muted: #7a6040; --panel: #f5edd8; --line: #d4b870; }
12
+ :root.pink { --bg: #fff0f5; --text: #4a0e2a; --muted: #9e4b6e; --panel: #ffe4ec; --line: #f5c2d4; }
13
+ body {
14
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
15
+ background: var(--bg); color: var(--text); min-height: 100vh; padding: 20px 16px;
16
+ max-width: 960px; margin: 0 auto;
17
+ }
18
+ h1 { font-size: 20px; margin-bottom: 4px; }
19
+ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 28px 0 10px; }
20
+ .sub { color: var(--muted); font-size: 13px; }
21
+ .tabs { margin: 16px 0; display: flex; flex-wrap: wrap; gap: 8px; }
22
+ .tabs button { background: var(--panel); color: inherit; border: 1px solid var(--line); border-radius: 6px;
23
+ padding: 6px 14px; font: inherit; cursor: pointer; }
24
+ .tabs button.on { border-color: var(--human); color: var(--text); }
25
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; }
26
+ .tile { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; }
27
+ .tile b { display: block; font-size: 26px; font-variant-numeric: tabular-nums; }
28
+ .tile span { font-size: 12px; color: var(--muted); }
29
+ .legend { display: flex; gap: 14px; font-size: 12px; color: var(--muted); margin-bottom: 8px; }
30
+ .legend summary { cursor: pointer; }
31
+ .legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
32
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; }
33
+ svg text { fill: var(--muted); font-size: 10px; }
34
+ svg text.in { fill: var(--text); font-weight: 600; }
35
+ .theme { float: right; background: var(--panel); color: var(--muted); border: 1px solid var(--line); border-radius: 6px;
36
+ padding: 4px 10px; font: inherit; font-size: 12px; cursor: pointer; }
37
+ .row { display: grid; grid-template-columns: 150px 1fr 40px; gap: 8px; align-items: center; font-size: 13px; margin: 4px 0; }
38
+ .row .n { text-align: right; font-variant-numeric: tabular-nums; }
39
+ .bar { display: flex; height: 14px; border-radius: 3px; overflow: hidden; background: var(--line); }
40
+ .bar div { height: 100%; }
41
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
42
+ td { padding: 5px 6px; border-top: 1px solid var(--line); word-break: break-all; }
43
+ td:last-child { text-align: right; white-space: nowrap; }
44
+ .group { border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; margin: 6px 0; }
45
+ .group summary { cursor: pointer; font-size: 13px; }
46
+ .grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 0 12px; }
47
+ .toggle { font-size: 13px; color: var(--muted); cursor: pointer; }
48
+ a { color: inherit; }
49
+ .empty { color: var(--muted); font-size: 13px; }
50
+ @media (max-width: 520px) { .row { grid-template-columns: 100px 1fr 32px; } }
51
+ </style>
52
+ <script>
53
+ // Same saved theme as the main page (same origin, so same host and port):
54
+ // dark / light / pink / auto (system). The button here cycles and saves it too.
55
+ const THEMES = ['dark', 'light', 'pink', 'auto'];
56
+ function applyTheme() {
57
+ const theme = localStorage.getItem('theme') || 'auto';
58
+ const shown = theme === 'auto' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme;
59
+ document.documentElement.className = shown === 'dark' ? '' : shown;
60
+ const button = document.getElementById('theme');
61
+ if (button) button.textContent = `Theme: ${theme}`;
62
+ }
63
+ function cycleTheme() {
64
+ const theme = localStorage.getItem('theme') || 'auto';
65
+ localStorage.setItem('theme', THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length]);
66
+ applyTheme();
67
+ }
68
+ applyTheme(); // before paint; again once the button exists
69
+ addEventListener('DOMContentLoaded', applyTheme);
70
+ addEventListener('storage', applyTheme);
71
+ </script>
72
+ </head>
73
+ <body>
74
+ <h1>Visitors<!--SERVER--> <button id="theme" class="theme" onclick="cycleTheme()"></button></h1>
75
+ <p class="sub">Data goes back to <span id="since">the first logged visit</span>. Counts are unique visitors unless noted. Human = this browser solved the captcha, now or earlier; striped = a human who never chatted (on /t: never started a transcription) · Unverified (dotted) = browser that never did · Bot (finer dots) = crawler/tool user agent · Internal test = our own checks (admin token).</p>
76
+ <div class="tabs"><button data-site="main">openajaj.cz</button><button data-site="t">/t transcribe</button><button data-site="quality">Transcription quality</button></div>
77
+ <div class="tabs"><button data-days="1">Today</button><button data-days="7">7 days</button><button data-days="30">30 days</button><button data-days="0">All</button></div>
78
+ <label class="toggle"><input type="checkbox" id="all" autocomplete="off"> Show bots, unverified &amp; internal tests</label>
79
+ <div id="out"></div>
80
+ <p class="sub" style="margin-top:24px">IP geolocation by <a href="https://db-ip.com">DB-IP</a> (CC BY 4.0).</p>
81
+
82
+ <script>
83
+ // 'idle' = human who never used the site: drawn striped in the human colour. Unverified: dotted, fainter. Bot: smaller dots, fainter still.
84
+ const HUMANS = ['human', 'idle'], ALL = [...HUMANS, 'unverified', 'bot', 'internal'];
85
+ let CLASSES = HUMANS, showAll = false;
86
+ const PATTERN = {idle: 'hatch', unverified: 'dots', bot: 'dots2'};
87
+ const LABEL = {human: 'Human', idle: 'Human, idle', unverified: 'Unverified', bot: 'Bot', internal: 'Internal test'};
88
+ const ACTION = {main: ['chat', 'Chats', 'never chatted', 'chat'], t: ['stt', 'STT sessions', 'never transcribed', 'transcription']};
89
+ const plural = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
90
+ const regionName = new Intl.DisplayNames(['en'], {type: 'region'});
91
+ let data = null, site = 'quality', days = '7';
92
+
93
+ const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
94
+ // Totals count only the classes on show.
95
+ const sum = o => CLASSES.reduce((a, c) => a + (o?.[c] || 0), 0);
96
+ const color = c => `var(--${c === 'idle' ? 'human' : c})`;
97
+ const paint = (fill, pattern) => ({
98
+ hatch: `repeating-linear-gradient(135deg, transparent 0 3px, var(--panel) 3px 5px), ${fill}`,
99
+ dots: `radial-gradient(circle, transparent 1.2px, var(--panel) 1.3px) 0 0 / 4px 4px, ${fill}`,
100
+ dots2: `radial-gradient(circle, transparent 0.8px, var(--panel) 0.9px) 0 0 / 4px 4px, ${fill}`,
101
+ }[pattern] || fill);
102
+ // Overlays in the panel colour: stripes, or a dot grid (the colour shows only through the holes).
103
+ const DEFS = `<defs><pattern id="hatch" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
104
+ <rect width="2" height="5" style="fill:var(--panel)"/></pattern>
105
+ <pattern id="dots" width="4" height="4" patternUnits="userSpaceOnUse"><path fill-rule="evenodd" style="fill:var(--panel)"
106
+ d="M0 0h4v4H0z M3.2 2a1.2 1.2 0 1 0-2.4 0a1.2 1.2 0 1 0 2.4 0z"/></pattern>
107
+ <pattern id="dots2" width="4" height="4" patternUnits="userSpaceOnUse"><path fill-rule="evenodd" style="fill:var(--panel)"
108
+ d="M0 0h4v4H0z M2.8 2a0.8 0.8 0 1 0-1.6 0a0.8 0.8 0 1 0 1.6 0z"/></pattern></defs>`;
109
+ // Golden-angle hues by order of first appearance in the whole log: a person keeps their colour in every period.
110
+ const hue = i => `hsl(${(i * 137.508) % 360}deg 70% 62%)`;
111
+ const classSeries = () => CLASSES.map(c => [c, LABEL[c], color(c), PATTERN[c] || '']);
112
+ // A person's entry opens to their details (place, device, …).
113
+ const legend = series => !series.length ? '' : `<div class="legend">${series.map(([, name, fill, pattern, detail]) => {
114
+ const key = `<i style="background:${paint(fill, pattern)}"></i>${esc(name)}`;
115
+ return detail ? `<details><summary>${key}</summary>${esc(detail)}</details>` : `<span>${key}</span>`;
116
+ }).join('')}</div>`;
117
+
118
+ function country(code) {
119
+ if (!/^[A-Z]{2}$/.test(code)) return 'Unknown';
120
+ const flag = String.fromCodePoint(...[...code].map(ch => 0x1F1A5 + ch.charCodeAt(0)));
121
+ try { return `${flag} ${regionName.of(code)}`; } catch { return code; }
122
+ }
123
+
124
+ function stackBar(counts, total) {
125
+ return `<div class="bar">${CLASSES.filter(c => counts[c]).map(c =>
126
+ `<div style="width:${100 * counts[c] / total}%;background:${paint(color(c), PATTERN[c] || '')}" title="${LABEL[c]}: ${counts[c]}"></div>`
127
+ ).join('')}</div>`;
128
+ }
129
+
130
+ // series: [[key in each entry's counts, name, color, pattern]], stacked bottom up.
131
+ // entries: [[tick, counts, uses?]]; a use count is drawn inside its bar when it fits.
132
+ function columns(entries, aria, series, noun) {
133
+ const total = d => series.reduce((a, [k]) => a + (d[k] || 0), 0);
134
+ if (!entries.some(([, d]) => total(d))) return '<p class="empty">No data.</p>';
135
+ // Drawn at the panel's real width (minus its padding and border), so text keeps its size on a phone.
136
+ const W = Math.max(200, document.getElementById('out').clientWidth - 26), H = 180, pad = 22;
137
+ const max = Math.max(...entries.map(([, d]) => total(d)));
138
+ const step = (W - pad) / entries.length, bw = Math.max(2, step * 0.7);
139
+ const label = Math.ceil(entries.length / Math.floor(W / 75));
140
+ const cols = entries.map(([tickText, d, uses], i) => {
141
+ let y = H - pad, x = pad + i * step;
142
+ const rects = series.filter(([k]) => d[k]).map(([k, name, fill, pattern]) => {
143
+ const h = (H - 2 * pad) * d[k] / max, box = `x="${x}" y="${y -= h}" width="${bw}" height="${h}"`;
144
+ // Use count in a square of the page background, 80% of the bar's short side, so it reads on any bar colour.
145
+ // Font fits the square: digits are ~0.6em wide, so wider numbers get smaller text.
146
+ const n = uses?.[k] || 0, side = 0.8 * Math.min(h, bw), fs = side * Math.min(0.7, 1.5 / String(n).length);
147
+ const cx = x + bw / 2, cy = y + h / 2;
148
+ return `<g><title>${tickText} · ${esc(name)}: ${d[k]}${n ? ` · ${plural(n, noun)}` : ''}</title><rect ${box} fill="${fill}"/>${
149
+ pattern ? `<rect ${box} fill="url(#${pattern})"/>` : ''}${
150
+ n && side >= 12 ? `<rect x="${cx - side / 2}" y="${cy - side / 2}" width="${side}" height="${side}" rx="${side * 0.15}" style="fill:var(--bg)"/>
151
+ <text class="in" x="${cx}" y="${cy + fs * 0.35}" text-anchor="middle" style="font-size:${fs}px">${n}</text>` : ''}</g>`;
152
+ }).join('');
153
+ const tick = i % label ? '' : `<text x="${x + bw / 2}" y="${H - 6}" text-anchor="middle">${tickText}</text>`;
154
+ return rects + tick;
155
+ }).join('');
156
+ return `<svg viewBox="0 0 ${W} ${H}" width="100%" role="img" aria-label="${aria}">${DEFS}
157
+ <text x="0" y="${pad}">${max}</text><line x1="${pad}" x2="${W}" y1="${H - pad}" y2="${H - pad}" style="stroke:var(--line)"/>${cols}</svg>`;
158
+ }
159
+
160
+ function rows(list) {
161
+ list = list.filter(([, c]) => sum(c));
162
+ const top = Math.max(...list.map(([, c]) => sum(c)), 1);
163
+ if (!list.length) return '<p class="empty">No data.</p>';
164
+ return list.map(([name, c]) => `<div class="row"><span>${esc(name)}</span>
165
+ <div style="width:${100 * sum(c) / top}%">${stackBar(c, sum(c))}</div><span class="n">${sum(c)}</span></div>`).join('');
166
+ }
167
+
168
+ function render() {
169
+ document.querySelectorAll('.tabs button').forEach(b =>
170
+ b.classList.toggle('on', b.dataset.site === site || b.dataset.days === days));
171
+ if (site === 'quality') { document.getElementById('out').innerHTML = qualityPanel(quality); return; }
172
+ const s = data[site], v = s.visitors, [actionKey, actionLabel, never, noun] = ACTION[site];
173
+ LABEL.idle = `Human, ${never}`;
174
+ const ranked = (o, n = 20) => Object.entries(o).filter(([, c]) => sum(c)).sort((a, b) => sum(b[1]) - sum(a[1])).slice(0, n);
175
+ const countries = ranked(s.countries).map(([code, c]) => [country(code), c]);
176
+ const referrers = ranked(s.referrers, 15).map(([host, c]) => [host || '(none / typed URL)', c]);
177
+ const named = o => ranked(o).map(([name, c]) => [name || 'Unknown', c]);
178
+ // ~60 columns at most: days, else weeks (labelled by their Monday), else months.
179
+ const units = Object.keys(s.days).length <= 62 ? 'days' : Object.keys(s.weeks).length <= 60 ? 'weeks' : 'months';
180
+ // Days: "Monday 13.10." ("Mon 13.10." past a week of columns). Weeks: their Monday "13.10.". Months: "2026-10".
181
+ const unit = units.slice(0, -1), dm = key => `${+key.slice(8)}.${+key.slice(5, 7)}.`;
182
+ const weekday = key => new Date(`${key}T12:00`).toLocaleDateString('en',
183
+ {weekday: Object.keys(s.days).length > 7 ? 'short' : 'long'});
184
+ const tick = key => unit === 'month' ? key : unit === 'week' ? dm(key) : `${weekday(key)} ${dm(key)}`;
185
+ // Humans only: one color per person. Otherwise one color per class.
186
+ const series = showAll ? classSeries()
187
+ : s.humans.map((h, i) => [i, `Visitor ${h.hn}`, hue(h.n), h.idle ? 'hatch' : '',
188
+ [country(h.country), h.dbip, h.isp, `${h.os} ${h.browser}`,
189
+ h.idle ? '' : plural(Object.values(h.uses.hours).reduce((a, n) => a + n, 0), noun),
190
+ h.solved && `captcha solved ${dm(h.solved)}${h.solved.slice(0, 4)}`].filter(Boolean).join(' · ')]);
191
+ // visits: visits started in the bucket (a request 30+ min after the previous one); else who was there.
192
+ const counts = showAll ? (u, key, visits) => (visits ? s.visit_timeline : s)[u][key] || {}
193
+ : (u, key, visits) => s.humans.map(h => visits ? h[u][key] || 0 : +(key in h[u]));
194
+ const uses = showAll ? () => null : (u, key) => s.humans.map(h => h.uses[u][key] || 0);
195
+ // Today: its 24 hours. Longer: days, weeks or months.
196
+ const today = days === '1', per = today ? 'hour' : unit, bucketUnit = today ? 'hours' : units;
197
+ const buckets = today ? Array.from({length: 24}, (_, h) => String(h).padStart(2, '0')) : Object.keys(s[units]);
198
+ const timeline = visits => buckets.map(key =>
199
+ [today ? key : tick(key), counts(bucketUnit, key, visits), uses(bucketUnit, key)]);
200
+ const humans = (v.human || 0) + (v.idle || 0), engaged = s.engaged.human || 0;
201
+ const returning = (s.returning.human || 0) + (s.returning.idle || 0);
202
+ const pct = n => humans ? ` (${Math.round(100 * n / humans)}%)` : '';
203
+ const via = Object.entries(s.via).map(([k, n]) => [k === 'cloudflare' ? 'openajaj.cz (Cloudflare)' : 'Direct (hf.space)', n]);
204
+ document.getElementById('out').innerHTML = `
205
+ <div class="tiles">
206
+ ${CLASSES.filter(c => c !== 'idle').map(c => `<div class="tile"><b style="color:${color(c)}">${c === 'human' ? humans : v[c] || 0}</b><span>${LABEL[c]} visitors</span></div>`).join('')}
207
+ <div class="tile"><b>${sum(s.visits)}</b><span>Visits</span></div>
208
+ <div class="tile"><b>${sum(s.events.page)}</b><span>Page loads</span></div>
209
+ <div class="tile"><b>${sum(s.events[actionKey])}</b><span>${actionLabel}</span></div>
210
+ <div class="tile"><b>${engaged}</b><span>Humans who used it${pct(engaged)}</span></div>
211
+ <div class="tile"><b>${returning}</b><span>Humans back on another day${pct(returning)}</span></div>
212
+ </div>
213
+ ${today ? '' : `<h2>Unique visitors per ${unit}</h2><div class="panel">${legend(series)}${columns(timeline(false), `Unique visitors per ${unit}`, series, noun)}</div>`}
214
+ <h2>Visits per ${per}${today ? ' (Prague time)' : ''}</h2><div class="panel">${legend(series)}${columns(timeline(true), `Visits per ${per}`, series, noun)}</div>
215
+ <h2>Countries</h2><div class="panel">${legend(classSeries())}${rows(countries)}</div>
216
+ <div class="grid2">
217
+ <div><h2>Operating system</h2><div class="panel">${rows(ranked(s.os))}</div></div>
218
+ <div><h2>Browser</h2><div class="panel">${rows(ranked(s.browsers))}</div></div>
219
+ <div><h2>Entry point</h2><div class="panel">${rows(via)}</div></div>
220
+ <div><h2>Came from</h2><div class="panel">${rows(referrers)}</div></div>
221
+ <div><h2>Place</h2><div class="panel">${rows(named(s.places_dbip))}</div></div>
222
+ <div><h2>Network (ISP)</h2><div class="panel">${rows(named(s.isps))}</div></div>
223
+ </div>
224
+ ${CLASSES.includes('bot') ? `<h2>Top bot user agents (requests)</h2><div class="panel">${s.bot_agents.length
225
+ ? `<table>${s.bot_agents.map(([ua, n]) => `<tr><td>${esc(ua || '(none)')}</td><td>${n}</td></tr>`).join('')}</table>`
226
+ : '<p class="empty">None.</p>'}</div>` : ''}`;
227
+ }
228
+
229
+ // Its own tab: /t visits, judged by stt_quality.py from the page's own reports (all visitors, not only humans)
230
+ const VERDICT = {ok: 'OK', degraded: 'Degraded: late or unsure text', broken: 'Broken: not transcribing or losing speech',
231
+ failed: 'Never started', too_short: 'Too short to judge'};
232
+ const PROBLEM = {ok: 'No problem shown', connect: 'Connecting (normal start)', off: 'Not transcribing', mic: 'Mic not hearing',
233
+ stuck: 'Very slow internet (audio stuck)', slow: 'Slow internet (text over 5 s late)',
234
+ noise: 'Noisy', far: 'Speaker far away', lang: 'Other language?', music: 'Only music/noise (info)'};
235
+ let quality = null;
236
+ const MIN_ACTIVE_S = 30; // stt_quality.MIN_ACTIVE_S: shorter visits are not judged
237
+ const mins = s => s < 60 ? `${s} s` : `${Math.round(s / 60)} min`;
238
+ const table = (o, names = {}, fmt = n => n) => Object.keys(o).length
239
+ ? `<table>${Object.entries(o).map(([k, n]) => `<tr><td>${esc(names[k] || k || '(none)')}</td><td>${fmt(n)}</td></tr>`).join('')}</table>`
240
+ : '<p class="empty">None.</p>';
241
+ // bins over edges, the last one open: "≤ 0.5 s", …, "> 10 s"
242
+ const hist = (counts, edges, fmt) => Object.fromEntries(counts.map((n, i) =>
243
+ [i < edges.length ? `≤ ${fmt(edges[i])}` : `> ${fmt(edges[edges.length - 1])}`, n]));
244
+ function qualityPanel(q) {
245
+ if (q?.error) return `<h2>Transcription quality</h2><div class="panel"><p class="empty">Could not load (${esc(q.error)}).</p></div>`;
246
+ if (!q?.visits) return '<h2>Transcription quality</h2><div class="panel"><p class="empty">No visit reports yet.</p></div>';
247
+ return `<h2>Transcription quality – All</h2>${qualityGroup(q, 'All visits')}
248
+ <h2>By operating system</h2>${q.by_os.map(g => qualityGroup(g, g.name)).join('')}
249
+ <h2>By visitor (anyone with a visit long enough to judge)</h2>${q.by_visitor.map(g => qualityGroup(g, `${g.name} · ${g.device}`)).join('')}`;
250
+ }
251
+ // Closed: verdict counts, then each problem's share of the group's /t time. Open: the group's full numbers.
252
+ function qualityGroup(g, name) {
253
+ const short = g.verdicts.too_short || 0, judged = `${g.visits - short} judged, ${short} under ${MIN_ACTIVE_S} s`;
254
+ const verdicts = Object.entries(g.verdicts).filter(([k]) => k !== 'too_short')
255
+ .map(([k, n]) => ` · ${VERDICT[k]?.split(':')[0] || k} ${n}`).join('');
256
+ const shares = Object.entries(g.problem_secs).map(([c, s]) =>
257
+ `${c} ${g.total_s ? Math.round(100 * s / g.total_s) : 0}%`).join(' · ');
258
+ return `<details class="group"><summary><b>${esc(name)}</b> · ${plural(g.visits, 'visit')} (${judged})${verdicts}
259
+ <br><span class="sub">${shares || 'no problem shown'}</span></summary>${qualityBody(g)}</details>`;
260
+ }
261
+ function qualityBody(q) {
262
+ const v = q.verdicts, judged = q.visits - (v.too_short || 0);
263
+ const pct = n => judged ? ` (${Math.round(100 * (n || 0) / judged)}%)` : '';
264
+ return `<div class="tiles">${['ok', 'degraded', 'broken', 'failed'].map(k =>
265
+ `<div class="tile"><b>${v[k] || 0}</b><span>${VERDICT[k]}${pct(v[k])}</span></div>`).join('')}
266
+ <div class="tile"><b>${v.too_short || 0}</b><span>${VERDICT.too_short}</span></div>
267
+ <div class="tile"><b>${mins(q.missed_s)}</b><span>Speech missed (drops, lost audio)</span></div></div>
268
+ <div class="grid2">
269
+ <div><h2>Main reason (degraded, broken, never started)</h2><div class="panel">${table(q.reasons, PROBLEM)}</div></div>
270
+ <div><h2>Time per problem shown (% of /t time)</h2><div class="panel">${table(q.problem_secs, PROBLEM, s => mins(s) + (q.total_s ? ` (${Math.round(100 * s / q.total_s)}%)` : ''))}</div></div>
271
+ <div><h2>Why not transcribing</h2><div class="panel">${table(q.off_why, {}, mins)}</div></div>
272
+ <div><h2>Text delay (results)</h2><div class="panel">${table(hist(q.lag_hist, q.lag_edges_ms, ms => `${ms / 1000} s`))}</div></div>
273
+ <div><h2>Word confidence (words)</h2><div class="panel">${table(hist(q.conf_hist, q.conf_edges, c => `${Math.round(c * 100)} %`))}</div></div>
274
+ </div>`;
275
+ }
276
+
277
+ async function load() {
278
+ let tok = localStorage.getItem('adminToken');
279
+ if (!tok) { tok = prompt('Admin token'); if (tok) localStorage.setItem('adminToken', tok); }
280
+ const headers = {
281
+ 'X-Ajaj-Admin': tok || '',
282
+ 'X-Ajaj-Session': new URLSearchParams(location.search).get('cs') || localStorage.getItem('ajaj_v') || '',
283
+ };
284
+ const resp = await fetch(`/api/visits?days=${days}`, {headers});
285
+ if (resp.status === 403) {
286
+ localStorage.removeItem('adminToken');
287
+ document.getElementById('out').innerHTML = '<p class="empty">Forbidden — reload to enter the token again.</p>';
288
+ return;
289
+ }
290
+ data = await resp.json();
291
+ const q = await fetch(`/api/stt/quality?days=${days}`, {headers}).catch(() => null);
292
+ quality = !q ? {error: 'network'} : q.ok ? await q.json().catch(() => ({error: 'bad reply'})) : {error: q.status};
293
+ if (data.since) document.getElementById('since').textContent = new Date(data.since).toLocaleString('cs-CZ',
294
+ {timeZone: 'Europe/Prague', day: 'numeric', month: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit'});
295
+ render();
296
+ }
297
+
298
+ // charts are drawn at the panel's width; the quality tab has none, and redrawing would close its rows
299
+ addEventListener('resize', () => data && site !== 'quality' && render());
300
+ document.getElementById('all').onchange = e => { showAll = e.target.checked; CLASSES = showAll ? ALL : HUMANS; if (data) render(); };
301
+ document.querySelectorAll('[data-site]').forEach(b => b.onclick = () => { site = b.dataset.site; render(); });
302
+ document.querySelectorAll('[data-days]').forEach(b => b.onclick = () => { days = b.dataset.days; load(); });
303
+ load();
304
+ </script>
305
+ </body>
306
+ </html>
static/transcribe2/apple-touch-icon.png ADDED
static/transcribe2/favicon.ico ADDED
static/transcribe2/index.html ADDED
The diff for this file is too large to render. See raw diff
 
static/version.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ 2984ef4