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.
- scroot/__init__.py +109 -0
- scroot/agents.py +345 -0
- scroot/audit.py +131 -0
- scroot/cli/__init__.py +167 -0
- scroot/cli/download.py +49 -0
- scroot/cli/eval.py +230 -0
- scroot/cli/model_info.py +28 -0
- scroot/composite.py +170 -0
- scroot/config/__init__.py +0 -0
- scroot/config/corrector.py +92 -0
- scroot/connectors/__init__.py +5 -0
- scroot/connectors/database.py +357 -0
- scroot/context/__init__.py +9 -0
- scroot/context/adapters.py +86 -0
- scroot/context/builder.py +514 -0
- scroot/context/dedup.py +99 -0
- scroot/context/payload.py +66 -0
- scroot/context/pii.py +101 -0
- scroot/context/tokenizer.py +42 -0
- scroot/core.py +349 -0
- scroot/corrector/__init__.py +38 -0
- scroot/corrector/api.py +145 -0
- scroot/corrector/base.py +20 -0
- scroot/corrector/disabled.py +13 -0
- scroot/corrector/local.py +112 -0
- scroot/corrector/models.py +69 -0
- scroot/dashboard/__init__.py +0 -0
- scroot/dashboard/__main__.py +37 -0
- scroot/dashboard/routers/__init__.py +0 -0
- scroot/dashboard/routers/analytics.py +236 -0
- scroot/dashboard/routers/corrector.py +230 -0
- scroot/dashboard/routers/export.py +150 -0
- scroot/dashboard/routers/guardrails.py +41 -0
- scroot/dashboard/routers/pipeline.py +218 -0
- scroot/dashboard/routers/queue.py +188 -0
- scroot/dashboard/routers/records.py +252 -0
- scroot/dashboard/routers/settings.py +291 -0
- scroot/dashboard/security.py +135 -0
- scroot/dashboard/server.py +181 -0
- scroot/evidence.py +228 -0
- scroot/exceptions.py +62 -0
- scroot/feedback/__init__.py +6 -0
- scroot/feedback/injector.py +160 -0
- scroot/feedback/sanitizer.py +56 -0
- scroot/feedback/store.py +650 -0
- scroot/flags.py +42 -0
- scroot/metrics/__init__.py +15 -0
- scroot/metrics/_utils.py +9 -0
- scroot/metrics/completeness.py +139 -0
- scroot/metrics/confidence.py +83 -0
- scroot/metrics/consistency.py +125 -0
- scroot/metrics/groundedness.py +193 -0
- scroot/metrics/relevance.py +73 -0
- scroot/models.py +214 -0
- scroot/result.py +276 -0
- scroot/sampling.py +306 -0
- scroot/text_utils.py +136 -0
- scroot/ui/dist/assets/index-DW1dLzDl.js +101 -0
- scroot/ui/dist/assets/index-WOhrVVSM.css +2 -0
- scroot/ui/dist/favicon.svg +27 -0
- scroot/ui/dist/index.html +20 -0
- scroot-0.2.0.dist-info/METADATA +832 -0
- scroot-0.2.0.dist-info/RECORD +67 -0
- scroot-0.2.0.dist-info/WHEEL +5 -0
- scroot-0.2.0.dist-info/entry_points.txt +2 -0
- scroot-0.2.0.dist-info/licenses/LICENSE +201 -0
- 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
|