scroot 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. scroot/__init__.py +109 -0
  2. scroot/agents.py +345 -0
  3. scroot/audit.py +131 -0
  4. scroot/cli/__init__.py +167 -0
  5. scroot/cli/download.py +49 -0
  6. scroot/cli/eval.py +230 -0
  7. scroot/cli/model_info.py +28 -0
  8. scroot/composite.py +170 -0
  9. scroot/config/__init__.py +0 -0
  10. scroot/config/corrector.py +92 -0
  11. scroot/connectors/__init__.py +5 -0
  12. scroot/connectors/database.py +357 -0
  13. scroot/context/__init__.py +9 -0
  14. scroot/context/adapters.py +86 -0
  15. scroot/context/builder.py +514 -0
  16. scroot/context/dedup.py +99 -0
  17. scroot/context/payload.py +66 -0
  18. scroot/context/pii.py +101 -0
  19. scroot/context/tokenizer.py +42 -0
  20. scroot/core.py +349 -0
  21. scroot/corrector/__init__.py +38 -0
  22. scroot/corrector/api.py +145 -0
  23. scroot/corrector/base.py +20 -0
  24. scroot/corrector/disabled.py +13 -0
  25. scroot/corrector/local.py +112 -0
  26. scroot/corrector/models.py +69 -0
  27. scroot/dashboard/__init__.py +0 -0
  28. scroot/dashboard/__main__.py +37 -0
  29. scroot/dashboard/routers/__init__.py +0 -0
  30. scroot/dashboard/routers/analytics.py +236 -0
  31. scroot/dashboard/routers/corrector.py +230 -0
  32. scroot/dashboard/routers/export.py +150 -0
  33. scroot/dashboard/routers/guardrails.py +41 -0
  34. scroot/dashboard/routers/pipeline.py +218 -0
  35. scroot/dashboard/routers/queue.py +188 -0
  36. scroot/dashboard/routers/records.py +252 -0
  37. scroot/dashboard/routers/settings.py +291 -0
  38. scroot/dashboard/security.py +135 -0
  39. scroot/dashboard/server.py +181 -0
  40. scroot/evidence.py +228 -0
  41. scroot/exceptions.py +62 -0
  42. scroot/feedback/__init__.py +6 -0
  43. scroot/feedback/injector.py +160 -0
  44. scroot/feedback/sanitizer.py +56 -0
  45. scroot/feedback/store.py +650 -0
  46. scroot/flags.py +42 -0
  47. scroot/metrics/__init__.py +15 -0
  48. scroot/metrics/_utils.py +9 -0
  49. scroot/metrics/completeness.py +139 -0
  50. scroot/metrics/confidence.py +83 -0
  51. scroot/metrics/consistency.py +125 -0
  52. scroot/metrics/groundedness.py +193 -0
  53. scroot/metrics/relevance.py +73 -0
  54. scroot/models.py +214 -0
  55. scroot/result.py +276 -0
  56. scroot/sampling.py +306 -0
  57. scroot/text_utils.py +136 -0
  58. scroot/ui/dist/assets/index-DW1dLzDl.js +101 -0
  59. scroot/ui/dist/assets/index-WOhrVVSM.css +2 -0
  60. scroot/ui/dist/favicon.svg +27 -0
  61. scroot/ui/dist/index.html +20 -0
  62. scroot-0.2.0.dist-info/METADATA +832 -0
  63. scroot-0.2.0.dist-info/RECORD +67 -0
  64. scroot-0.2.0.dist-info/WHEEL +5 -0
  65. scroot-0.2.0.dist-info/entry_points.txt +2 -0
  66. scroot-0.2.0.dist-info/licenses/LICENSE +201 -0
  67. scroot-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,291 @@
1
+ """Settings router - /api/settings endpoints."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import time
7
+
8
+ from fastapi import APIRouter, HTTPException
9
+
10
+ from scroot.config.corrector import (
11
+ APIConfig,
12
+ CorrectorConfig,
13
+ LocalConfig,
14
+ default_config_path,
15
+ )
16
+ from scroot.corrector.models import MODEL_REGISTRY, get_model_path
17
+ from scroot.dashboard.security import mask_api_key, validate_base_url
18
+
19
+ _SETTINGS_FILE = os.path.join(os.getcwd(), ".scroot_settings.json")
20
+
21
+ DEFAULT_WEIGHTS = {
22
+ "groundedness": 0.35,
23
+ "completeness": 0.25,
24
+ "relevance": 0.20,
25
+ "consistency": 0.15,
26
+ "confidence": 0.05,
27
+ }
28
+
29
+ DEFAULT_CONFIG: dict = {
30
+ "iqs_threshold": 0.70,
31
+ "metric_weights": DEFAULT_WEIGHTS,
32
+ "provider": "none",
33
+ "model": "",
34
+ "api_key": "",
35
+ "base_url": "",
36
+ "trigger_mode": "manual",
37
+ }
38
+
39
+
40
+ def _load() -> dict:
41
+ if os.path.exists(_SETTINGS_FILE):
42
+ with open(_SETTINGS_FILE, encoding="utf-8") as f:
43
+ return {**DEFAULT_CONFIG, **json.load(f)}
44
+ return DEFAULT_CONFIG.copy()
45
+
46
+
47
+ def _save(config: dict) -> None:
48
+ # M-1: write key-bearing settings as owner-only (0600) so other local
49
+ # accounts can't read the stored API key.
50
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
51
+ fd = os.open(_SETTINGS_FILE, flags, 0o600)
52
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
53
+ json.dump(config, f, indent=2)
54
+ try:
55
+ os.chmod(_SETTINGS_FILE, 0o600) # tighten if the file pre-existed
56
+ except OSError:
57
+ pass
58
+
59
+
60
+ def _store_info(store) -> dict:
61
+ """Return record count and human-readable store size."""
62
+ if store is None:
63
+ return {"record_count": 0, "store_size": "—", "store_path": "~/.scroot/feedback.jsonl"}
64
+ try:
65
+ records = store.get_all()
66
+ count = len(records)
67
+ path = getattr(store, "_path", str(getattr(store, "path", "~/.scroot/feedback.jsonl")))
68
+ size = "—"
69
+ if os.path.exists(path):
70
+ b = os.path.getsize(path)
71
+ if b < 1024:
72
+ size = f"{b} B"
73
+ elif b < 1024 ** 2:
74
+ size = f"{b / 1024:.1f} KB"
75
+ else:
76
+ size = f"{b / 1024 ** 2:.1f} MB"
77
+ return {"record_count": count, "store_size": size, "store_path": path}
78
+ except (OSError, AttributeError):
79
+ return {"record_count": 0, "store_size": "—", "store_path": "~/.scroot/feedback.jsonl"}
80
+
81
+
82
+ def settings_router(store=None):
83
+ router = APIRouter()
84
+
85
+ # ─── Unified settings (used by Settings page) ─────────────────────
86
+
87
+ def _corrector_state() -> dict:
88
+ """Build the corrector sub-object returned in GET /settings."""
89
+ cc = CorrectorConfig.load(default_config_path())
90
+ spec = MODEL_REGISTRY.get(cc.local.model_id)
91
+ model_path = get_model_path(cc.local.model_id) if spec else None
92
+ downloaded = model_path.exists() if model_path else False
93
+ api_key = cc.api.api_key
94
+ return {
95
+ "mode": cc.mode,
96
+ "local": {
97
+ "model_id": cc.local.model_id,
98
+ "model_name": spec.name if spec else cc.local.model_id,
99
+ "model_downloaded": downloaded,
100
+ "model_size_gb": spec.size_gb if spec else 0,
101
+ "model_path": str(model_path) if downloaded else None,
102
+ },
103
+ "api": {
104
+ "api_key_set": bool(api_key),
105
+ "api_key_prefix": api_key[:6] if len(api_key) >= 6 else api_key,
106
+ "base_url": cc.api.base_url,
107
+ "model": cc.api.model,
108
+ },
109
+ }
110
+
111
+ @router.get("")
112
+ def get_settings():
113
+ cfg = _load()
114
+ info = _store_info(store)
115
+ return {
116
+ "iqs_threshold": cfg.get("iqs_threshold", 0.70),
117
+ "metric_weights": cfg.get("metric_weights", DEFAULT_WEIGHTS),
118
+ "corrector": _corrector_state(),
119
+ # Legacy field kept for any old clients. H-1: never echo the raw
120
+ # key back - expose only a masked hint and a boolean.
121
+ "llm_corrector": {
122
+ "provider": cfg.get("provider", "none"),
123
+ "api_key_set": bool(cfg.get("api_key", "")),
124
+ "api_key_hint": mask_api_key(cfg.get("api_key", "")),
125
+ "base_url": cfg.get("base_url", ""),
126
+ "model": cfg.get("model", ""),
127
+ },
128
+ **info,
129
+ }
130
+
131
+ @router.put("")
132
+ def update_settings(body: dict):
133
+ """Patch settings. Handles iqs_threshold, metric_weights, corrector, clear_all_records."""
134
+ cfg = _load()
135
+
136
+ if "iqs_threshold" in body:
137
+ cfg["iqs_threshold"] = float(body["iqs_threshold"])
138
+
139
+ if "metric_weights" in body:
140
+ cfg["metric_weights"] = body["metric_weights"]
141
+
142
+ if "corrector" in body:
143
+ cc = CorrectorConfig.load(default_config_path())
144
+ patch = body["corrector"]
145
+ if "mode" in patch:
146
+ cc.mode = patch["mode"]
147
+ if "local" in patch:
148
+ lp = patch["local"]
149
+ cc.local = LocalConfig(
150
+ model_id=lp.get("model_id", cc.local.model_id),
151
+ n_threads=lp.get("n_threads", cc.local.n_threads),
152
+ n_gpu_layers=lp.get("n_gpu_layers", cc.local.n_gpu_layers),
153
+ context_window=lp.get("context_window", cc.local.context_window),
154
+ )
155
+ if "api" in patch:
156
+ ap = patch["api"]
157
+ # Write-only key semantics (H-1): a blank/absent api_key means
158
+ # "leave the stored key unchanged" rather than wiping it. This
159
+ # also stops an unrelated edit (e.g. model only) from clearing
160
+ # the key, since the UI never reads the real key back.
161
+ new_key = ap.get("api_key")
162
+ api_key = new_key if new_key else cc.api.api_key
163
+ new_base_url = ap.get("base_url", cc.api.base_url)
164
+ # M-2: reject untrusted/internal endpoints before persisting.
165
+ try:
166
+ validate_base_url(new_base_url)
167
+ except ValueError as exc:
168
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
169
+ cc.api = APIConfig(
170
+ api_key=api_key,
171
+ base_url=new_base_url,
172
+ model=ap.get("model", cc.api.model),
173
+ system_prompt=ap.get("system_prompt", cc.api.system_prompt),
174
+ )
175
+ cc.save(default_config_path())
176
+
177
+ # Legacy llm_corrector key - still accepted
178
+ if "llm_corrector" in body:
179
+ lc = body["llm_corrector"]
180
+ cfg["provider"] = lc.get("provider", cfg.get("provider", "none"))
181
+ # Blank/absent key = leave unchanged (H-1 write-only semantics).
182
+ new_key = lc.get("api_key")
183
+ cfg["api_key"] = new_key if new_key else cfg.get("api_key", "")
184
+ new_base_url = lc.get("base_url", cfg.get("base_url", ""))
185
+ try:
186
+ validate_base_url(new_base_url) # M-2
187
+ except ValueError as exc:
188
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
189
+ cfg["base_url"] = new_base_url
190
+ cfg["model"] = lc.get("model", cfg.get("model", ""))
191
+
192
+ if body.get("clear_all_records") and store is not None:
193
+ try:
194
+ store.purge()
195
+ except Exception:
196
+ pass
197
+
198
+ _save(cfg)
199
+ return {"status": "ok"}
200
+
201
+ @router.post("/test-connection")
202
+ def test_connection():
203
+ cfg = _load()
204
+ provider = cfg.get("provider", "none")
205
+ if provider == "llm":
206
+ from .records import _detect_provider
207
+ provider = _detect_provider(cfg)
208
+
209
+ if provider == "none":
210
+ return {"status": "error", "latency_ms": 0, "message": "No provider configured"}
211
+
212
+ api_key = cfg.get("api_key", "")
213
+ base_url = cfg.get("base_url") or None
214
+ model = cfg.get("model", "")
215
+
216
+ # M-2: never send the key to an unvetted endpoint, even on a test ping.
217
+ try:
218
+ validate_base_url(base_url)
219
+ except ValueError as exc:
220
+ return {"status": "error", "latency_ms": 0, "message": str(exc)}
221
+
222
+ start = time.time()
223
+ sample = ""
224
+ status = "ok"
225
+ message = ""
226
+
227
+ try:
228
+ if provider == "anthropic":
229
+ import anthropic
230
+ client = anthropic.Anthropic(api_key=api_key, base_url=base_url)
231
+ msg = client.messages.create(
232
+ model=model or "claude-haiku-4-5-20251001",
233
+ max_tokens=16,
234
+ messages=[{"role": "user", "content": "Reply: ok"}],
235
+ )
236
+ sample = msg.content[0].text
237
+
238
+ elif provider in ("openai", "groq", "openrouter"):
239
+ import openai
240
+ client = openai.OpenAI(api_key=api_key, base_url=base_url)
241
+ resp = client.chat.completions.create(
242
+ model=model or "gpt-4o-mini",
243
+ messages=[{"role": "user", "content": "Reply: ok"}],
244
+ max_tokens=16,
245
+ )
246
+ sample = resp.choices[0].message.content
247
+
248
+ elif provider == "ollama":
249
+ import requests
250
+ url = (base_url or "http://localhost:11434") + "/api/generate"
251
+ resp = requests.post(
252
+ url,
253
+ json={"model": model or "llama3.2", "prompt": "Reply: ok", "stream": False},
254
+ timeout=10,
255
+ )
256
+ sample = resp.json().get("response", "")
257
+
258
+ except Exception as e:
259
+ status = "error"
260
+ message = str(e)
261
+
262
+ latency_ms = int((time.time() - start) * 1000)
263
+ return {"status": status, "latency_ms": latency_ms, "sample_output": sample[:200], "message": message}
264
+
265
+ # ─── Legacy llm-judge sub-routes (kept for backwards compat) ──────
266
+
267
+ @router.get("/llm-judge")
268
+ def get_llm_judge():
269
+ cfg = _load()
270
+ return {
271
+ "provider": cfg.get("provider", "none"),
272
+ "model": cfg.get("model", ""),
273
+ "trigger_mode": cfg.get("trigger_mode", "manual"),
274
+ "budget_cap_usd": cfg.get("budget_cap_usd"),
275
+ "api_key_env_var": cfg.get("api_key_env_var", ""),
276
+ }
277
+
278
+ @router.put("/llm-judge")
279
+ def save_llm_judge(body: dict):
280
+ cfg = _load()
281
+ for k in ("provider", "model", "trigger_mode", "budget_cap_usd", "api_key_env_var"):
282
+ if k in body:
283
+ cfg[k] = body[k]
284
+ _save(cfg)
285
+ return body
286
+
287
+ @router.post("/llm-judge/test")
288
+ def test_llm_judge():
289
+ return test_connection()
290
+
291
+ return router
@@ -0,0 +1,135 @@
1
+ """Shared dashboard security helpers.
2
+
3
+ Covers three hardening controls for the local Review Console:
4
+
5
+ * **H-1** - ``mask_api_key()`` so stored provider keys are never echoed back
6
+ in plaintext over the API.
7
+ * **M-2** - ``validate_base_url()`` so the server cannot be pointed at an
8
+ attacker-controlled host (key exfiltration) or used as an SSRF pivot to
9
+ internal / cloud-metadata endpoints.
10
+ * **H-2** - ``require_token`` middleware factory + ``is_loopback_host()`` so a
11
+ network-exposed dashboard can require a shared token and a non-loopback bind
12
+ warns the operator.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hmac
18
+ import ipaddress
19
+ import os
20
+ from urllib.parse import urlparse
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # H-1: API key masking
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def mask_api_key(key: str | None) -> str:
27
+ """Return a non-reversible hint for an API key (never the full value).
28
+
29
+ ``"sk-abcdefgh...wxyz"`` -> ``"sk-a…wxyz"``. Empty/short keys collapse to a
30
+ placeholder so the real value never leaves the process.
31
+ """
32
+ if not key:
33
+ return ""
34
+ if len(key) <= 8:
35
+ return "…"
36
+ return f"{key[:4]}…{key[-4:]}"
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # M-2: outbound base_url allowlist
41
+ # ---------------------------------------------------------------------------
42
+
43
+ #: Known hosted LLM provider hosts that may receive an API key.
44
+ ALLOWED_LLM_HOSTS: frozenset[str] = frozenset({
45
+ "api.openai.com",
46
+ "api.anthropic.com",
47
+ "api.groq.com",
48
+ "openrouter.ai",
49
+ "api.openrouter.ai",
50
+ "generativelanguage.googleapis.com", # Google Gemini
51
+ })
52
+
53
+ #: Loopback hosts permitted for self-hosted endpoints (e.g. local Ollama).
54
+ _LOCAL_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
55
+
56
+ #: Escape hatch for operators who deliberately use a custom gateway.
57
+ _OVERRIDE_ENV = "SCROOT_ALLOW_ANY_BASE_URL"
58
+
59
+
60
+ def validate_base_url(base_url: str | None, *, allow_local: bool = True) -> None:
61
+ """Reject base URLs that aren't a known provider or an allowed local host.
62
+
63
+ Args:
64
+ base_url: The configured endpoint. Empty/None means "use the provider
65
+ SDK's default endpoint" and is always allowed.
66
+ allow_local: Permit loopback hosts (needed for local Ollama). Set False
67
+ for hosted providers that should never be local.
68
+
69
+ Raises:
70
+ ValueError: If the host is neither an allowlisted provider nor an
71
+ allowed loopback host, and the override env var is not set.
72
+ """
73
+ if not base_url:
74
+ return
75
+ if os.environ.get(_OVERRIDE_ENV) == "1":
76
+ return
77
+
78
+ parsed = urlparse(base_url)
79
+ if parsed.scheme not in ("http", "https"):
80
+ raise ValueError(
81
+ f"base_url must use http(s), got {base_url!r}"
82
+ )
83
+
84
+ host = (parsed.hostname or "").lower()
85
+ if host in ALLOWED_LLM_HOSTS:
86
+ return
87
+ if allow_local and host in _LOCAL_HOSTS:
88
+ return
89
+
90
+ raise ValueError(
91
+ f"base_url host {host!r} is not an allowed LLM provider endpoint. "
92
+ f"This blocks pointing the server at an untrusted host (key theft) or "
93
+ f"an internal/metadata address (SSRF). Allowed providers: "
94
+ f"{', '.join(sorted(ALLOWED_LLM_HOSTS))}. To override for a trusted "
95
+ f"custom gateway, set {_OVERRIDE_ENV}=1."
96
+ )
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # H-2: bind-host inspection + token auth
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def is_loopback_host(host: str) -> bool:
104
+ """True if ``host`` is a loopback / localhost address."""
105
+ if host in ("localhost", ""):
106
+ return True
107
+ try:
108
+ return ipaddress.ip_address(host).is_loopback
109
+ except ValueError:
110
+ return False
111
+
112
+
113
+ def resolve_dashboard_token(explicit: str | None = None) -> str | None:
114
+ """Return the configured dashboard token, if any.
115
+
116
+ Precedence: explicit argument, then ``SCROOT_DASHBOARD_TOKEN`` env var.
117
+ Returns None when no token is configured (auth disabled).
118
+ """
119
+ token = explicit or os.environ.get("SCROOT_DASHBOARD_TOKEN") or ""
120
+ return token or None
121
+
122
+
123
+ def token_matches(provided: str | None, expected: str) -> bool:
124
+ """Constant-time comparison of a presented token against the expected one."""
125
+ if not provided:
126
+ return False
127
+ return hmac.compare_digest(provided, expected)
128
+
129
+
130
+ def extract_request_token(headers) -> str | None:
131
+ """Pull a token from ``Authorization: Bearer`` or ``X-Scroot-Token``."""
132
+ auth = headers.get("authorization") or headers.get("Authorization") or ""
133
+ if auth.lower().startswith("bearer "):
134
+ return auth[7:].strip()
135
+ return headers.get("x-scroot-token") or headers.get("X-Scroot-Token")
@@ -0,0 +1,181 @@
1
+ """Scroot Dashboard - FastAPI application factory."""
2
+ from __future__ import annotations
3
+
4
+ import warnings
5
+ from pathlib import Path
6
+
7
+ from fastapi import FastAPI
8
+ from fastapi.responses import JSONResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+
11
+ from scroot.feedback.store import FeedbackStore
12
+ from .security import (
13
+ extract_request_token,
14
+ is_loopback_host,
15
+ resolve_dashboard_token,
16
+ token_matches,
17
+ )
18
+ from .routers.queue import queue_router
19
+ from .routers.records import records_router
20
+ from .routers.analytics import analytics_router
21
+ from .routers.export import export_router
22
+ from .routers.settings import settings_router
23
+ from .routers.pipeline import pipeline_router
24
+ from .routers.corrector import corrector_router
25
+ from .routers.guardrails import guardrails_router
26
+
27
+ # Resolved at import time - ui/dist is built by `npm run build`
28
+ UI_DIST_PATH = str(Path(__file__).parent.parent / "ui" / "dist")
29
+
30
+ # Endpoints reachable without a token even when auth is enabled.
31
+ _UNAUTHENTICATED_PATHS = frozenset({"/api/health"})
32
+
33
+
34
+ def create_app(
35
+ store_path: str,
36
+ hosted: bool = False,
37
+ host: str = "127.0.0.1",
38
+ auth_token: str | None = None,
39
+ ) -> FastAPI:
40
+ """Create the Scroot dashboard FastAPI application.
41
+
42
+ Args:
43
+ store_path: Path to the JSONL FeedbackStore file.
44
+ hosted: Reserved for Scroot Enterprise hosted mode.
45
+ host: The interface the app will be served on. Used only to decide
46
+ whether to warn about an unauthenticated non-loopback bind (H-2).
47
+ auth_token: Optional shared token. When set (or
48
+ ``SCROOT_DASHBOARD_TOKEN`` is in the environment), every
49
+ ``/api/*`` route except the health check requires the token via an
50
+ ``Authorization: Bearer <token>`` or ``X-Scroot-Token`` header.
51
+
52
+ Raises:
53
+ NotImplementedError: If hosted=True (enterprise-only feature).
54
+ """
55
+ if hosted:
56
+ raise NotImplementedError(
57
+ "Hosted mode is available in Scroot Cloud. "
58
+ "Visit https://scroot.dev/cloud for enterprise pricing."
59
+ )
60
+
61
+ store = FeedbackStore(store_path)
62
+ token = resolve_dashboard_token(auth_token)
63
+
64
+ # H-2: the dashboard has no per-user auth. A loopback bind is single-user
65
+ # safe; binding to a routable interface exposes the full correction store
66
+ # and the corrector API key to the network. Warn unless a token is set.
67
+ if not is_loopback_host(host) and token is None:
68
+ warnings.warn(
69
+ f"Scroot dashboard is binding to a non-loopback host ({host!r}) "
70
+ f"with no authentication. The correction store and stored LLM API "
71
+ f"key would be reachable by anyone on the network. Set "
72
+ f"SCROOT_DASHBOARD_TOKEN (or pass --token) and/or run behind an "
73
+ f"authenticating reverse proxy.",
74
+ stacklevel=2,
75
+ )
76
+
77
+ app = FastAPI(
78
+ title="Scroot Review Console",
79
+ description="Local feedback loop review dashboard",
80
+ version="0.2.0",
81
+ )
82
+
83
+ # H-2: optional shared-token gate for network-exposed deployments.
84
+ if token is not None:
85
+ @app.middleware("http")
86
+ async def _require_token(request, call_next):
87
+ path = request.url.path
88
+ if path.startswith("/api/") and path not in _UNAUTHENTICATED_PATHS:
89
+ provided = extract_request_token(request.headers)
90
+ if not token_matches(provided, token):
91
+ return JSONResponse(
92
+ status_code=401,
93
+ content={"detail": "Missing or invalid dashboard token."},
94
+ )
95
+ return await call_next(request)
96
+
97
+ # CORS for Vite dev server (dev only - not needed in production)
98
+ try:
99
+ from fastapi.middleware.cors import CORSMiddleware
100
+ app.add_middleware(
101
+ CORSMiddleware,
102
+ allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
103
+ allow_methods=["*"],
104
+ allow_headers=["*"],
105
+ )
106
+ except ImportError:
107
+ pass
108
+
109
+ # API routers
110
+ app.include_router(queue_router(store), prefix="/api/queue", tags=["queue"])
111
+ app.include_router(records_router(store), prefix="/api/records", tags=["records"])
112
+ app.include_router(analytics_router(store), prefix="/api/analytics", tags=["analytics"])
113
+ app.include_router(export_router(store), prefix="/api/export", tags=["export"])
114
+ app.include_router(settings_router(store), prefix="/api/settings", tags=["settings"])
115
+ app.include_router(pipeline_router(store), prefix="/api/pipeline", tags=["pipeline"])
116
+ app.include_router(corrector_router(), prefix="/api/corrector", tags=["corrector"])
117
+ app.include_router(guardrails_router(store), prefix="/api/guardrails", tags=["guardrails"])
118
+
119
+ # Health check
120
+ @app.get("/api/health")
121
+ def health():
122
+ pending = sum(
123
+ 1 for r in store.get_all()
124
+ if getattr(r, "status", "pending") == "pending"
125
+ )
126
+ iqs_vals = [
127
+ r.scores.get("iqs", 0)
128
+ for r in store.get_all()
129
+ if isinstance(r.scores, dict)
130
+ ]
131
+ avg_iqs = round(sum(iqs_vals) / len(iqs_vals), 3) if iqs_vals else None
132
+ return {
133
+ "status": "ok",
134
+ "version": "0.2.0",
135
+ "pending_count": pending,
136
+ "avg_iqs_today": avg_iqs,
137
+ }
138
+
139
+ # Serve built React SPA - must be registered last.
140
+ dist = Path(UI_DIST_PATH)
141
+ if dist.exists():
142
+ from fastapi.responses import FileResponse
143
+
144
+ index_file = dist / "index.html"
145
+ # Hashed build assets (JS/CSS) live under /assets.
146
+ assets_dir = dist / "assets"
147
+ if assets_dir.exists():
148
+ app.mount(
149
+ "/assets",
150
+ StaticFiles(directory=str(assets_dir)),
151
+ name="assets",
152
+ )
153
+
154
+ # SPA history-API fallback: the dashboard uses BrowserRouter, so deep
155
+ # links and refreshes hit the server with a client-side path (e.g.
156
+ # /queue, /analytics). Serve a real file when one exists (favicon
157
+ # etc.), otherwise return index.html so the SPA can route. /api/* is
158
+ # never caught here (those routes are registered above).
159
+ @app.get("/{full_path:path}")
160
+ def serve_spa(full_path: str):
161
+ # Don't shadow the API: unknown /api paths get a real 404, not the SPA.
162
+ if full_path.startswith("api/"):
163
+ from fastapi import HTTPException
164
+ raise HTTPException(status_code=404, detail="Not Found")
165
+ candidate = (dist / full_path).resolve()
166
+ if (
167
+ full_path
168
+ and dist.resolve() in candidate.parents
169
+ and candidate.is_file()
170
+ ):
171
+ return FileResponse(str(candidate))
172
+ return FileResponse(str(index_file))
173
+ else:
174
+ @app.get("/")
175
+ def ui_not_built():
176
+ return {
177
+ "error": "UI not built",
178
+ "hint": "cd src/scroot/ui && npm install && npm run build",
179
+ }
180
+
181
+ return app