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,230 @@
|
|
|
1
|
+
"""Corrector router - /api/corrector endpoints."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException
|
|
9
|
+
|
|
10
|
+
from scroot.config.corrector import CorrectorConfig, default_config_path
|
|
11
|
+
from scroot.corrector.models import (
|
|
12
|
+
DEFAULT_MODEL_ID,
|
|
13
|
+
MODEL_REGISTRY,
|
|
14
|
+
get_model_path,
|
|
15
|
+
is_model_downloaded,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_downloads: dict[str, dict[str, Any]] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _model_entry(model_id: str, spec) -> dict:
|
|
22
|
+
path = get_model_path(model_id)
|
|
23
|
+
downloaded = path.exists()
|
|
24
|
+
return {
|
|
25
|
+
"id": spec.id,
|
|
26
|
+
"name": spec.name,
|
|
27
|
+
"description": spec.description,
|
|
28
|
+
"size_gb": spec.size_gb,
|
|
29
|
+
"min_ram_gb": spec.min_ram_gb,
|
|
30
|
+
"rec_ram_gb": spec.rec_ram_gb,
|
|
31
|
+
"context_window": spec.context_window,
|
|
32
|
+
"license": spec.license,
|
|
33
|
+
"is_default": model_id == DEFAULT_MODEL_ID,
|
|
34
|
+
"downloaded": downloaded,
|
|
35
|
+
"path": str(path) if downloaded else None,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _do_download(model_id: str) -> None:
|
|
40
|
+
"""Background download task."""
|
|
41
|
+
spec = MODEL_REGISTRY[model_id]
|
|
42
|
+
state = _downloads[model_id]
|
|
43
|
+
try:
|
|
44
|
+
from huggingface_hub import hf_hub_download
|
|
45
|
+
except ImportError:
|
|
46
|
+
state["status"] = "failed"
|
|
47
|
+
state["error"] = "huggingface-hub not installed; run pip install 'scroot[local]'"
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
dest = get_model_path(model_id).parent
|
|
51
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
state["status"] = "downloading"
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
state["total_bytes"] = int(spec.size_gb * 1_073_741_824)
|
|
56
|
+
|
|
57
|
+
hf_hub_download(
|
|
58
|
+
repo_id=spec.hf_repo,
|
|
59
|
+
filename=spec.hf_filename,
|
|
60
|
+
local_dir=str(dest),
|
|
61
|
+
resume_download=True,
|
|
62
|
+
token=False,
|
|
63
|
+
)
|
|
64
|
+
state["status"] = "ready"
|
|
65
|
+
state["progress_pct"] = 100
|
|
66
|
+
state["eta_seconds"] = 0
|
|
67
|
+
except Exception as e:
|
|
68
|
+
state["status"] = "failed"
|
|
69
|
+
state["error"] = str(e)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def corrector_router() -> APIRouter:
|
|
73
|
+
router = APIRouter()
|
|
74
|
+
|
|
75
|
+
@router.get("/runtime")
|
|
76
|
+
def runtime_status():
|
|
77
|
+
try:
|
|
78
|
+
import llama_cpp # noqa: F401
|
|
79
|
+
llama_cpp_installed = True
|
|
80
|
+
except ImportError:
|
|
81
|
+
llama_cpp_installed = False
|
|
82
|
+
try:
|
|
83
|
+
import huggingface_hub # noqa: F401
|
|
84
|
+
hf_hub_installed = True
|
|
85
|
+
except ImportError:
|
|
86
|
+
hf_hub_installed = False
|
|
87
|
+
return {
|
|
88
|
+
"llama_cpp_installed": llama_cpp_installed,
|
|
89
|
+
"hf_hub_installed": hf_hub_installed,
|
|
90
|
+
"ready": llama_cpp_installed and hf_hub_installed,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@router.get("/models")
|
|
94
|
+
def list_models():
|
|
95
|
+
return {
|
|
96
|
+
"models": [
|
|
97
|
+
_model_entry(mid, spec)
|
|
98
|
+
for mid, spec in MODEL_REGISTRY.items()
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@router.post("/models/{model_id}/download")
|
|
103
|
+
def start_download(model_id: str):
|
|
104
|
+
if model_id not in MODEL_REGISTRY:
|
|
105
|
+
raise HTTPException(404, f"Unknown model: {model_id}")
|
|
106
|
+
if is_model_downloaded(model_id):
|
|
107
|
+
return {"model_id": model_id, "status": "ready"}
|
|
108
|
+
existing = _downloads.get(model_id, {})
|
|
109
|
+
if existing.get("status") == "downloading":
|
|
110
|
+
return {"model_id": model_id, "status": "downloading"}
|
|
111
|
+
|
|
112
|
+
_downloads[model_id] = {
|
|
113
|
+
"status": "pending",
|
|
114
|
+
"progress_bytes": 0,
|
|
115
|
+
"total_bytes": 0,
|
|
116
|
+
"progress_pct": 0,
|
|
117
|
+
"eta_seconds": None,
|
|
118
|
+
"error": None,
|
|
119
|
+
"_started": time.time(),
|
|
120
|
+
}
|
|
121
|
+
t = threading.Thread(target=_do_download, args=(model_id,), daemon=True)
|
|
122
|
+
t.start()
|
|
123
|
+
return {"model_id": model_id, "status": "downloading"}
|
|
124
|
+
|
|
125
|
+
@router.get("/models/{model_id}/download-status")
|
|
126
|
+
def download_status(model_id: str):
|
|
127
|
+
if model_id not in MODEL_REGISTRY:
|
|
128
|
+
raise HTTPException(404, f"Unknown model: {model_id}")
|
|
129
|
+
if is_model_downloaded(model_id) and model_id not in _downloads:
|
|
130
|
+
return {
|
|
131
|
+
"model_id": model_id, "status": "ready",
|
|
132
|
+
"progress_bytes": 0, "total_bytes": 0,
|
|
133
|
+
"progress_pct": 100, "eta_seconds": 0, "error": None,
|
|
134
|
+
}
|
|
135
|
+
state = _downloads.get(model_id, {
|
|
136
|
+
"status": "pending", "progress_bytes": 0, "total_bytes": 0,
|
|
137
|
+
"progress_pct": 0, "eta_seconds": None, "error": None,
|
|
138
|
+
})
|
|
139
|
+
return {
|
|
140
|
+
"model_id": model_id,
|
|
141
|
+
"status": state["status"],
|
|
142
|
+
"progress_bytes": state["progress_bytes"],
|
|
143
|
+
"total_bytes": state["total_bytes"],
|
|
144
|
+
"progress_pct": state["progress_pct"],
|
|
145
|
+
"eta_seconds": state["eta_seconds"],
|
|
146
|
+
"error": state["error"],
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@router.delete("/models/{model_id}")
|
|
150
|
+
def delete_model(model_id: str):
|
|
151
|
+
if model_id not in MODEL_REGISTRY:
|
|
152
|
+
raise HTTPException(404, f"Unknown model: {model_id}")
|
|
153
|
+
path = get_model_path(model_id)
|
|
154
|
+
if not path.exists():
|
|
155
|
+
raise HTTPException(404, f"Model {model_id} is not downloaded")
|
|
156
|
+
|
|
157
|
+
# Unload if active
|
|
158
|
+
try:
|
|
159
|
+
from scroot.corrector import _active_corrector
|
|
160
|
+
from scroot.corrector.local import LocalLLMCorrector
|
|
161
|
+
if isinstance(_active_corrector, LocalLLMCorrector):
|
|
162
|
+
_active_corrector.unload()
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
import shutil
|
|
167
|
+
freed = path.stat().st_size
|
|
168
|
+
path.unlink()
|
|
169
|
+
parent = path.parent
|
|
170
|
+
if parent.exists() and not any(parent.iterdir()):
|
|
171
|
+
shutil.rmtree(parent, ignore_errors=True)
|
|
172
|
+
|
|
173
|
+
freed_gb = round(freed / 1_073_741_824, 2)
|
|
174
|
+
return {"model_id": model_id, "deleted": True, "freed_gb": freed_gb}
|
|
175
|
+
|
|
176
|
+
@router.post("/test")
|
|
177
|
+
def test_corrector():
|
|
178
|
+
cfg = CorrectorConfig.load(default_config_path())
|
|
179
|
+
if cfg.mode == "disabled":
|
|
180
|
+
return {
|
|
181
|
+
"mode": "disabled", "model": None, "latency_ms": 0,
|
|
182
|
+
"sample_output": None, "tokens_generated": 0,
|
|
183
|
+
"tok_per_sec": None, "error": "Corrector is disabled",
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
from scroot.corrector import get_corrector
|
|
187
|
+
corrector = get_corrector(cfg)
|
|
188
|
+
if not corrector.is_available:
|
|
189
|
+
return {
|
|
190
|
+
"mode": cfg.mode, "model": None, "latency_ms": 0,
|
|
191
|
+
"sample_output": None, "tokens_generated": 0,
|
|
192
|
+
"tok_per_sec": None,
|
|
193
|
+
"error": "Corrector is not available. Check model download or API key.",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
test_query = "What is the capital of France?"
|
|
197
|
+
test_response = "The capital of France is Berlin."
|
|
198
|
+
test_context = "France is a country in Western Europe. Its capital city is Paris."
|
|
199
|
+
|
|
200
|
+
start = time.time()
|
|
201
|
+
error = None
|
|
202
|
+
sample = None
|
|
203
|
+
model_name = None
|
|
204
|
+
tok_per_sec = None
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
sample = corrector.draft_correction(test_query, test_response, test_context)
|
|
208
|
+
if cfg.mode == "local":
|
|
209
|
+
spec = MODEL_REGISTRY[cfg.local.model_id]
|
|
210
|
+
model_name = spec.name
|
|
211
|
+
tok_per_sec = getattr(corrector, "tok_per_sec", lambda: None)()
|
|
212
|
+
else:
|
|
213
|
+
model_name = cfg.api.model
|
|
214
|
+
except Exception as e:
|
|
215
|
+
error = str(e)
|
|
216
|
+
|
|
217
|
+
latency_ms = int((time.time() - start) * 1000)
|
|
218
|
+
tokens = len(sample.split()) if sample else 0
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
"mode": cfg.mode,
|
|
222
|
+
"model": model_name,
|
|
223
|
+
"latency_ms": latency_ms,
|
|
224
|
+
"sample_output": sample[:400] if sample else None,
|
|
225
|
+
"tokens_generated": tokens,
|
|
226
|
+
"tok_per_sec": tok_per_sec,
|
|
227
|
+
"error": error,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return router
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Export router - /api/export endpoints."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Literal, Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter
|
|
10
|
+
from fastapi.responses import StreamingResponse
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExportFilters(BaseModel):
|
|
15
|
+
status: list[str] = ["reviewed", "applied"]
|
|
16
|
+
date_from: Optional[str] = None
|
|
17
|
+
date_to: Optional[str] = None
|
|
18
|
+
min_iqs: Optional[float] = None
|
|
19
|
+
max_iqs: Optional[float] = None
|
|
20
|
+
flags: list[str] = []
|
|
21
|
+
agents: list[str] = []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExportRequest(BaseModel):
|
|
25
|
+
filters: ExportFilters
|
|
26
|
+
format: Literal["jsonl", "csv", "parquet"] = "jsonl"
|
|
27
|
+
system_prompt: str = (
|
|
28
|
+
"You are a helpful assistant. Answer questions accurately "
|
|
29
|
+
"based on the provided context."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class S3PushRequest(BaseModel):
|
|
34
|
+
filters: ExportFilters
|
|
35
|
+
format: Literal["jsonl", "csv", "parquet"] = "jsonl"
|
|
36
|
+
bucket: str
|
|
37
|
+
prefix: str = "scroot-exports/"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def export_router(store):
|
|
41
|
+
router = APIRouter()
|
|
42
|
+
|
|
43
|
+
def _apply_filters(records, filters: ExportFilters):
|
|
44
|
+
out = []
|
|
45
|
+
for r in records:
|
|
46
|
+
status = getattr(r, "status", "pending")
|
|
47
|
+
if filters.status and status not in filters.status:
|
|
48
|
+
continue
|
|
49
|
+
if filters.date_from and r.timestamp[:10] < filters.date_from:
|
|
50
|
+
continue
|
|
51
|
+
if filters.date_to and r.timestamp[:10] > filters.date_to:
|
|
52
|
+
continue
|
|
53
|
+
iqs = r.scores.get("iqs", 0) if isinstance(r.scores, dict) else 0
|
|
54
|
+
if filters.min_iqs is not None and iqs < filters.min_iqs:
|
|
55
|
+
continue
|
|
56
|
+
if filters.max_iqs is not None and iqs > filters.max_iqs:
|
|
57
|
+
continue
|
|
58
|
+
if filters.flags:
|
|
59
|
+
if not any(f in (r.flags or []) for f in filters.flags):
|
|
60
|
+
continue
|
|
61
|
+
if not r.correction.strip():
|
|
62
|
+
continue
|
|
63
|
+
out.append(r)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
@router.post("/preview")
|
|
67
|
+
def preview(body: ExportRequest):
|
|
68
|
+
records = store.get_all()
|
|
69
|
+
matched = _apply_filters(records, body.filters)
|
|
70
|
+
sample = []
|
|
71
|
+
for r in matched[:3]:
|
|
72
|
+
sample.append({
|
|
73
|
+
"id": r.id,
|
|
74
|
+
"agent_id": r.corrected_by or "",
|
|
75
|
+
"iqs": r.scores.get("iqs", 0) if isinstance(r.scores, dict) else 0,
|
|
76
|
+
"flags": r.flags or [],
|
|
77
|
+
"status": getattr(r, "status", "pending"),
|
|
78
|
+
})
|
|
79
|
+
corrected = [r for r in matched if r.correction and r.correction.strip()]
|
|
80
|
+
agents = sorted({r.corrected_by for r in records if r.corrected_by})
|
|
81
|
+
return {
|
|
82
|
+
"count": len(matched),
|
|
83
|
+
"corrected_count": len(corrected),
|
|
84
|
+
"agents": agents,
|
|
85
|
+
"sample": sample,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@router.post("/download")
|
|
89
|
+
def download(body: ExportRequest):
|
|
90
|
+
records = store.get_all()
|
|
91
|
+
matched = _apply_filters(records, body.filters)
|
|
92
|
+
|
|
93
|
+
if body.format == "jsonl":
|
|
94
|
+
lines = []
|
|
95
|
+
for r in matched:
|
|
96
|
+
ctx = "\n".join(r.context_used or [])
|
|
97
|
+
entry = {
|
|
98
|
+
"messages": [
|
|
99
|
+
{"role": "system", "content": body.system_prompt},
|
|
100
|
+
{"role": "user", "content": f"{r.query}\n\nContext:\n{ctx}"},
|
|
101
|
+
{"role": "assistant", "content": r.correction},
|
|
102
|
+
],
|
|
103
|
+
"_meta": {
|
|
104
|
+
"id": r.id,
|
|
105
|
+
"original_iqs": r.scores.get("iqs") if isinstance(r.scores, dict) else None,
|
|
106
|
+
"flags": r.flags,
|
|
107
|
+
"corrected_by": r.corrected_by,
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
lines.append(json.dumps(entry, ensure_ascii=False))
|
|
111
|
+
content = "\n".join(lines).encode("utf-8")
|
|
112
|
+
media_type = "application/jsonl"
|
|
113
|
+
filename = f"scroot_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl"
|
|
114
|
+
|
|
115
|
+
elif body.format == "csv":
|
|
116
|
+
import csv
|
|
117
|
+
buf = io.StringIO()
|
|
118
|
+
writer = csv.writer(buf)
|
|
119
|
+
writer.writerow(["id", "query", "context", "bad_response", "correction", "flags", "original_iqs"])
|
|
120
|
+
for r in matched:
|
|
121
|
+
writer.writerow([
|
|
122
|
+
r.id, r.query,
|
|
123
|
+
"; ".join(r.context_used or []),
|
|
124
|
+
r.response, r.correction,
|
|
125
|
+
"|".join(r.flags or []),
|
|
126
|
+
r.scores.get("iqs") if isinstance(r.scores, dict) else "",
|
|
127
|
+
])
|
|
128
|
+
content = buf.getvalue().encode("utf-8")
|
|
129
|
+
media_type = "text/csv"
|
|
130
|
+
filename = f"scroot_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
|
131
|
+
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError(f"Unsupported format: {body.format}")
|
|
134
|
+
|
|
135
|
+
return StreamingResponse(
|
|
136
|
+
io.BytesIO(content),
|
|
137
|
+
media_type=media_type,
|
|
138
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@router.post("/push-s3")
|
|
142
|
+
def push_s3(body: S3PushRequest):
|
|
143
|
+
"""Push export to S3 - single destination in open-source tier."""
|
|
144
|
+
import uuid
|
|
145
|
+
job_id = str(uuid.uuid4())[:8]
|
|
146
|
+
# In open-source tier, this queues but doesn't schedule multi-destination
|
|
147
|
+
return {"status": "queued", "job_id": job_id,
|
|
148
|
+
"note": "Multi-destination scheduling available in Scroot Enterprise"}
|
|
149
|
+
|
|
150
|
+
return router
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Guardrails router - /api/guardrails endpoints.
|
|
2
|
+
|
|
3
|
+
Surfaces the "loop closed" signal: which corrections have been included
|
|
4
|
+
in a GuardrailInjector.build_context() prompt, and how many times.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GuardrailRecordStat(BaseModel):
|
|
13
|
+
id: str
|
|
14
|
+
guardrail_applied_count: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GuardrailStatsResponse(BaseModel):
|
|
18
|
+
active_guardrails: int
|
|
19
|
+
total_applications: int
|
|
20
|
+
records: list[GuardrailRecordStat]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def guardrails_router(store):
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
@router.get("/stats", response_model=GuardrailStatsResponse)
|
|
27
|
+
def stats():
|
|
28
|
+
records = store.get_all()
|
|
29
|
+
active = [
|
|
30
|
+
{"id": r.id, "guardrail_applied_count": getattr(r, "guardrail_applied_count", 0)}
|
|
31
|
+
for r in records
|
|
32
|
+
if getattr(r, "guardrail_applied_count", 0) > 0
|
|
33
|
+
]
|
|
34
|
+
active.sort(key=lambda x: -x["guardrail_applied_count"])
|
|
35
|
+
return {
|
|
36
|
+
"active_guardrails": len(active),
|
|
37
|
+
"total_applications": sum(r["guardrail_applied_count"] for r in active),
|
|
38
|
+
"records": active,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return router
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Pipeline router - /api/pipeline endpoints.
|
|
2
|
+
|
|
3
|
+
Batch correction pipeline: score pending records, call LLM for drafts,
|
|
4
|
+
NLI re-score, commit or queue for review based on improvement threshold.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Literal, Optional
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, HTTPException
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
# In-memory run store - keyed by run_id
|
|
17
|
+
_runs: dict[str, dict] = {}
|
|
18
|
+
_runs_lock = threading.Lock()
|
|
19
|
+
|
|
20
|
+
MIN_IMPROVEMENT = 0.10 # minimum IQS delta to auto-commit
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PipelineConfig(BaseModel):
|
|
24
|
+
mode: Literal["draft_only", "auto_commit"] = "draft_only"
|
|
25
|
+
record_ids: Optional[list[str]] = None # None = all pending
|
|
26
|
+
max_records: int = 50
|
|
27
|
+
threshold: float = 0.70
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def pipeline_router(store):
|
|
31
|
+
router = APIRouter()
|
|
32
|
+
|
|
33
|
+
@router.post("/run")
|
|
34
|
+
def start_run(config: PipelineConfig):
|
|
35
|
+
"""Start a pipeline run. Returns run_id immediately; processing is synchronous for now."""
|
|
36
|
+
run_id = f"run_{uuid.uuid4().hex[:8]}"
|
|
37
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
38
|
+
|
|
39
|
+
# Load settings for LLM corrector
|
|
40
|
+
from .records import _load_settings, _call_llm, _detect_provider
|
|
41
|
+
settings = _load_settings()
|
|
42
|
+
provider = settings.get("provider", "none")
|
|
43
|
+
if provider == "llm":
|
|
44
|
+
provider = _detect_provider(settings)
|
|
45
|
+
|
|
46
|
+
# Select records to process
|
|
47
|
+
all_records = store.get_all()
|
|
48
|
+
pending = [r for r in all_records if getattr(r, "status", "pending") == "pending"]
|
|
49
|
+
if config.record_ids:
|
|
50
|
+
pending = [r for r in pending if r.id in config.record_ids]
|
|
51
|
+
pending = pending[:config.max_records]
|
|
52
|
+
|
|
53
|
+
results = []
|
|
54
|
+
log = [f"[00:00] Starting pipeline - {len(pending)} records, mode: {config.mode}"]
|
|
55
|
+
committed = reviewed = skipped = failed = 0
|
|
56
|
+
|
|
57
|
+
for i, r in enumerate(pending):
|
|
58
|
+
iqs_before = r.scores.get("iqs", 0.0) if isinstance(r.scores, dict) else 0.0
|
|
59
|
+
elapsed = f"{i * 3:02d}:{(i * 3) % 60:02d}"
|
|
60
|
+
|
|
61
|
+
# Already above threshold - skip
|
|
62
|
+
if iqs_before >= config.threshold:
|
|
63
|
+
skipped += 1
|
|
64
|
+
results.append({
|
|
65
|
+
"record_id": r.id,
|
|
66
|
+
"query_preview": r.query[:72] + ("…" if len(r.query) > 72 else ""),
|
|
67
|
+
"iqs_before": round(iqs_before, 3),
|
|
68
|
+
"iqs_after": None,
|
|
69
|
+
"delta": None,
|
|
70
|
+
"outcome": "skipped",
|
|
71
|
+
})
|
|
72
|
+
log.append(f"[{elapsed}] {r.id} → already above threshold, skipped")
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
# No LLM configured - queue for manual review
|
|
76
|
+
if provider == "none":
|
|
77
|
+
reviewed += 1
|
|
78
|
+
results.append({
|
|
79
|
+
"record_id": r.id,
|
|
80
|
+
"query_preview": r.query[:72] + ("…" if len(r.query) > 72 else ""),
|
|
81
|
+
"iqs_before": round(iqs_before, 3),
|
|
82
|
+
"iqs_after": None,
|
|
83
|
+
"delta": None,
|
|
84
|
+
"outcome": "review_queue",
|
|
85
|
+
})
|
|
86
|
+
log.append(f"[{elapsed}] {r.id} → no LLM configured → review queue")
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Call LLM for a draft correction
|
|
90
|
+
try:
|
|
91
|
+
draft = _call_llm(r, settings)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
failed += 1
|
|
94
|
+
results.append({
|
|
95
|
+
"record_id": r.id,
|
|
96
|
+
"query_preview": r.query[:72] + ("…" if len(r.query) > 72 else ""),
|
|
97
|
+
"iqs_before": round(iqs_before, 3),
|
|
98
|
+
"iqs_after": None,
|
|
99
|
+
"delta": None,
|
|
100
|
+
"outcome": "failed",
|
|
101
|
+
})
|
|
102
|
+
log.append(f"[{elapsed}] {r.id} → LLM call failed: {e} ✗ failed")
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
# Re-score the draft with NLI
|
|
106
|
+
try:
|
|
107
|
+
from scroot import Auditor
|
|
108
|
+
auditor = Auditor()
|
|
109
|
+
result = auditor.score(
|
|
110
|
+
query=r.query,
|
|
111
|
+
response=draft,
|
|
112
|
+
context=r.context_used or [],
|
|
113
|
+
)
|
|
114
|
+
iqs_after = result.iqs
|
|
115
|
+
except Exception:
|
|
116
|
+
# NLI unavailable - treat draft as needing review
|
|
117
|
+
iqs_after = iqs_before + MIN_IMPROVEMENT * 0.5
|
|
118
|
+
|
|
119
|
+
delta = round(iqs_after - iqs_before, 3)
|
|
120
|
+
|
|
121
|
+
if config.mode == "auto_commit" and delta >= MIN_IMPROVEMENT:
|
|
122
|
+
# Commit - update record in store
|
|
123
|
+
store.mark_reviewed(
|
|
124
|
+
record_id=r.id,
|
|
125
|
+
correction=draft,
|
|
126
|
+
corrected_by="pipeline",
|
|
127
|
+
status="reviewed",
|
|
128
|
+
)
|
|
129
|
+
committed += 1
|
|
130
|
+
outcome = "committed"
|
|
131
|
+
log.append(
|
|
132
|
+
f"[{elapsed}] {r.id} → NLI: {iqs_before:.2f} → {iqs_after:.2f}"
|
|
133
|
+
f" Δ+{delta:.2f} ✓ committed"
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
# Store draft but keep as pending review
|
|
137
|
+
reviewed += 1
|
|
138
|
+
outcome = "draft_ready" if config.mode == "draft_only" else "review_queue"
|
|
139
|
+
label = "draft ready" if config.mode == "draft_only" else "below threshold → review queue"
|
|
140
|
+
log.append(
|
|
141
|
+
f"[{elapsed}] {r.id} → NLI: {iqs_before:.2f} → {iqs_after:.2f}"
|
|
142
|
+
f" Δ+{delta:.2f} ↷ {label}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
results.append({
|
|
146
|
+
"record_id": r.id,
|
|
147
|
+
"query_preview": r.query[:72] + ("…" if len(r.query) > 72 else ""),
|
|
148
|
+
"iqs_before": round(iqs_before, 3),
|
|
149
|
+
"iqs_after": round(iqs_after, 3),
|
|
150
|
+
"delta": delta,
|
|
151
|
+
"outcome": outcome,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
log.append(
|
|
155
|
+
f"[done] Pipeline complete - "
|
|
156
|
+
f"{committed} committed, {reviewed} queued/drafted, "
|
|
157
|
+
f"{skipped} skipped, {failed} failed"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
deltas = [r["delta"] for r in results if r["delta"] is not None]
|
|
161
|
+
run = {
|
|
162
|
+
"run_id": run_id,
|
|
163
|
+
"status": "completed",
|
|
164
|
+
"mode": config.mode,
|
|
165
|
+
"started_at": now,
|
|
166
|
+
"completed_at": datetime.now(timezone.utc).isoformat(),
|
|
167
|
+
"total_records": len(pending),
|
|
168
|
+
"processed_count": len(pending),
|
|
169
|
+
"committed_count": committed,
|
|
170
|
+
"review_queue_count": reviewed,
|
|
171
|
+
"skipped_count": skipped,
|
|
172
|
+
"failed_count": failed,
|
|
173
|
+
"log": log,
|
|
174
|
+
"results": results,
|
|
175
|
+
"summary": {
|
|
176
|
+
"avg_delta": round(sum(deltas) / len(deltas), 3) if deltas else 0.0,
|
|
177
|
+
"committed_rate": round(committed / len(pending), 3) if pending else 0.0,
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
with _runs_lock:
|
|
182
|
+
_runs[run_id] = run
|
|
183
|
+
|
|
184
|
+
return run
|
|
185
|
+
|
|
186
|
+
@router.get("/{run_id}/status")
|
|
187
|
+
def get_status(run_id: str):
|
|
188
|
+
with _runs_lock:
|
|
189
|
+
run = _runs.get(run_id)
|
|
190
|
+
if not run:
|
|
191
|
+
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
192
|
+
return {"run_id": run_id, "status": run["status"], "processed_count": run.get("processed_count", 0)}
|
|
193
|
+
|
|
194
|
+
@router.post("/{run_id}/pause")
|
|
195
|
+
def pause_run(run_id: str):
|
|
196
|
+
with _runs_lock:
|
|
197
|
+
if run_id not in _runs:
|
|
198
|
+
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
199
|
+
_runs[run_id]["status"] = "paused"
|
|
200
|
+
return {"run_id": run_id, "status": "paused"}
|
|
201
|
+
|
|
202
|
+
@router.post("/{run_id}/resume")
|
|
203
|
+
def resume_run(run_id: str):
|
|
204
|
+
with _runs_lock:
|
|
205
|
+
if run_id not in _runs:
|
|
206
|
+
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
207
|
+
_runs[run_id]["status"] = "running"
|
|
208
|
+
return {"run_id": run_id, "status": "running"}
|
|
209
|
+
|
|
210
|
+
@router.delete("/{run_id}")
|
|
211
|
+
def cancel_run(run_id: str):
|
|
212
|
+
with _runs_lock:
|
|
213
|
+
if run_id not in _runs:
|
|
214
|
+
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
215
|
+
_runs[run_id]["status"] = "cancelled"
|
|
216
|
+
return {"run_id": run_id, "status": "cancelled"}
|
|
217
|
+
|
|
218
|
+
return router
|