Commit ·
b356196
0
Parent(s):
Super-squash branch 'main' using huggingface_hub
Browse filesCo-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
- .gitattributes +11 -0
- Dockerfile +38 -0
- README.md +13 -0
- benchmark.py +140 -0
- body_limit.py +32 -0
- captcha.py +312 -0
- chat.py +186 -0
- data/articles.jsonl +3 -0
- data/declensions.json +0 -0
- data/last_update.txt +1 -0
- data/mistral-large-latest_last_result.json +15 -0
- data/test_cache.json +0 -0
- data/titles.json +0 -0
- db/chroma/85dc4107-dc6e-491a-8587-80599979b833/data_level0.bin +3 -0
- db/chroma/85dc4107-dc6e-491a-8587-80599979b833/header.bin +0 -0
- db/chroma/85dc4107-dc6e-491a-8587-80599979b833/index_metadata.pickle +3 -0
- db/chroma/85dc4107-dc6e-491a-8587-80599979b833/length.bin +3 -0
- db/chroma/85dc4107-dc6e-491a-8587-80599979b833/link_lists.bin +3 -0
- db/chroma/chroma.sqlite3 +3 -0
- db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/data_level0.bin +3 -0
- db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/header.bin +0 -0
- db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/index_metadata.pickle +3 -0
- db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/length.bin +0 -0
- db/chroma_en/454aa3f3-9d1a-4584-a699-c1db2734e1da/link_lists.bin +3 -0
- db/chroma_en/chroma.sqlite3 +3 -0
- embedder.py +104 -0
- geo.py +94 -0
- json_storage.py +69 -0
- personas.py +81 -0
- providers.py +741 -0
- rate_limit.py +63 -0
- refuse_log.py +36 -0
- requirements-hf.txt +15 -0
- retrieve.py +345 -0
- scrape.py +294 -0
- startup.py +314 -0
- startup_progress.py +33 -0
- static/apple-touch-icon.png +0 -0
- static/captcha_bg.png +0 -0
- static/favicon.ico +0 -0
- static/index.html +0 -0
- static/logo.png +3 -0
- static/logo.webp +0 -0
- static/providers.html +218 -0
- static/results.html +647 -0
- static/stats.html +306 -0
- static/transcribe2/apple-touch-icon.png +0 -0
- static/transcribe2/favicon.ico +0 -0
- static/transcribe2/index.html +0 -0
- 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
|
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 |
+
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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' : ''}">▶</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"><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,'"')}">${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,'<').replace(/>/g,'>') : '<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 & 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 => ({'&': '&', '<': '<', '>': '>', '"': '"'}[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
|