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,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