misbar 1.0.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.
- misbar/__init__.py +38 -0
- misbar/api.py +629 -0
- misbar/capture.py +273 -0
- misbar/cli.py +105 -0
- misbar/db.py +879 -0
- misbar/lineage.py +131 -0
- misbar/runner.py +476 -0
- misbar/schema.sql +146 -0
- misbar/tracing.py +451 -0
- misbar/ui_assets/app.js +349 -0
- misbar/ui_assets/index.html +14 -0
- misbar/ui_assets/primitives.js +185 -0
- misbar/ui_assets/rtl.css +357 -0
- misbar-1.0.0.dist-info/METADATA +71 -0
- misbar-1.0.0.dist-info/RECORD +19 -0
- misbar-1.0.0.dist-info/WHEEL +4 -0
- misbar-1.0.0.dist-info/entry_points.txt +2 -0
- misbar-1.0.0.dist-info/licenses/LICENSE +176 -0
- misbar-1.0.0.dist-info/licenses/NOTICE +6 -0
misbar/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""misbar (مسبار) — foundational workflow spine, tracing, and human review toolkit."""
|
|
2
|
+
|
|
3
|
+
from misbar.capture import JsonlRecorder
|
|
4
|
+
from misbar.db import MisbarStore
|
|
5
|
+
from misbar.lineage import apply_payload_patch, canonical_json, compute_input_digest, sha256_digest
|
|
6
|
+
from misbar.runner import PartitionedStepResult, Pipeline, StepContext
|
|
7
|
+
from misbar.tracing import (
|
|
8
|
+
CompositeSink,
|
|
9
|
+
JsonlSink,
|
|
10
|
+
MlflowSink,
|
|
11
|
+
NoopSink,
|
|
12
|
+
SqliteSink,
|
|
13
|
+
TraceSink,
|
|
14
|
+
ingest_capture_bundle,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__ = "1.0.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CompositeSink",
|
|
21
|
+
"JsonlRecorder",
|
|
22
|
+
"JsonlSink",
|
|
23
|
+
"MisbarStore",
|
|
24
|
+
"MlflowSink",
|
|
25
|
+
"NoopSink",
|
|
26
|
+
"PartitionedStepResult",
|
|
27
|
+
"Pipeline",
|
|
28
|
+
"SqliteSink",
|
|
29
|
+
"StepContext",
|
|
30
|
+
"TraceSink",
|
|
31
|
+
"__version__",
|
|
32
|
+
"apply_payload_patch",
|
|
33
|
+
"canonical_json",
|
|
34
|
+
"compute_input_digest",
|
|
35
|
+
"ingest_capture_bundle",
|
|
36
|
+
"sha256_digest",
|
|
37
|
+
]
|
|
38
|
+
|
misbar/api.py
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
"""FastAPI router and server-enforced blindness gates for misbar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import random
|
|
7
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, FastAPI, HTTPException, Query
|
|
14
|
+
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from misbar.db import MisbarStore
|
|
18
|
+
from misbar.tracing import MlflowSink, TraceSink
|
|
19
|
+
|
|
20
|
+
BlindMode = Literal["full_blind", "reveal_after_submit", "ab_randomized", "unblinded"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class QueueSpec:
|
|
25
|
+
"""Declarative queue configuration enforcing blindness and action boundaries."""
|
|
26
|
+
|
|
27
|
+
queue_key: str
|
|
28
|
+
task_key: str
|
|
29
|
+
artifact_key: str
|
|
30
|
+
title: str = ""
|
|
31
|
+
blind_mode: BlindMode = "reveal_after_submit"
|
|
32
|
+
allow_mode_override: bool = False
|
|
33
|
+
secret_meta_keys: tuple[str, ...] = ("silver", "gold", "reference", "teacher_score")
|
|
34
|
+
ab_candidate_keys: tuple[str, str] = ("A", "B")
|
|
35
|
+
verdict_options: tuple[str, ...] = ("yes", "no")
|
|
36
|
+
allow_revisions: bool = False
|
|
37
|
+
revision_artifact_key: str | None = None
|
|
38
|
+
base_filter: Callable[[dict[str, Any]], bool] | None = None
|
|
39
|
+
filter_presets: dict[str, Callable[[dict[str, Any]], bool]] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compute_ab_permutation(
|
|
43
|
+
reviewer_id: str,
|
|
44
|
+
item_key: str,
|
|
45
|
+
candidate_keys: tuple[str, str],
|
|
46
|
+
) -> dict[str, str]:
|
|
47
|
+
"""Deterministic 50/50 slot assignment per `(reviewer_id, item_key)`."""
|
|
48
|
+
seed_hex = hashlib.sha256(f"{reviewer_id}:{item_key}".encode("utf-8")).hexdigest()
|
|
49
|
+
rng = random.Random(int(seed_hex[:16], 16))
|
|
50
|
+
keys = [candidate_keys[0], candidate_keys[1]]
|
|
51
|
+
rng.shuffle(keys)
|
|
52
|
+
return {"slot_1": keys[0], "slot_2": keys[1]}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sanitize_item_for_client(
|
|
56
|
+
item_row: Mapping[str, Any],
|
|
57
|
+
spec: QueueSpec,
|
|
58
|
+
*,
|
|
59
|
+
effective_mode: BlindMode,
|
|
60
|
+
reviewer_id: str = "local",
|
|
61
|
+
mlflow_sink: MlflowSink | None = None,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
"""Strip model predictions and secret metadata on the server when blinded."""
|
|
64
|
+
ann = item_row.get("annotation")
|
|
65
|
+
has_ann = ann is not None
|
|
66
|
+
raw_meta = dict(item_row.get("meta") or {})
|
|
67
|
+
safe_meta = {k: v for k, v in raw_meta.items() if k not in spec.secret_meta_keys}
|
|
68
|
+
raw_output = item_row.get("output")
|
|
69
|
+
|
|
70
|
+
base = {
|
|
71
|
+
"item_row_id": item_row["item_row_id"],
|
|
72
|
+
"artifact_version_id": item_row["artifact_version_id"],
|
|
73
|
+
"run_id": item_row.get("run_id"),
|
|
74
|
+
"item_key": item_row["item_key"],
|
|
75
|
+
"partition_key": item_row.get("partition_key"),
|
|
76
|
+
"input_digest": item_row["input_digest"],
|
|
77
|
+
"input": item_row["input"],
|
|
78
|
+
"annotation": ann,
|
|
79
|
+
"annotation_status": item_row.get("annotation_status", "ungraded"),
|
|
80
|
+
"blind_mode": effective_mode,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if effective_mode == "full_blind":
|
|
84
|
+
return {
|
|
85
|
+
**base,
|
|
86
|
+
"blinded": True,
|
|
87
|
+
"output": None,
|
|
88
|
+
"meta": safe_meta,
|
|
89
|
+
"item_trace_id": None,
|
|
90
|
+
"item_span_id": None,
|
|
91
|
+
"mlflow_url": None,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if effective_mode == "reveal_after_submit":
|
|
95
|
+
if not has_ann:
|
|
96
|
+
return {
|
|
97
|
+
**base,
|
|
98
|
+
"blinded": True,
|
|
99
|
+
"output": None,
|
|
100
|
+
"meta": safe_meta,
|
|
101
|
+
"item_trace_id": None,
|
|
102
|
+
"item_span_id": None,
|
|
103
|
+
"mlflow_url": None,
|
|
104
|
+
}
|
|
105
|
+
trace_id = item_row.get("item_trace_id")
|
|
106
|
+
return {
|
|
107
|
+
**base,
|
|
108
|
+
"blinded": False,
|
|
109
|
+
"output": raw_output,
|
|
110
|
+
"meta": raw_meta,
|
|
111
|
+
"item_trace_id": trace_id,
|
|
112
|
+
"item_span_id": item_row.get("item_span_id"),
|
|
113
|
+
"mlflow_url": mlflow_sink.trace_ui_url(trace_id) if mlflow_sink else None,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if effective_mode == "ab_randomized":
|
|
117
|
+
perm = compute_ab_permutation(reviewer_id, str(item_row["item_key"]), spec.ab_candidate_keys)
|
|
118
|
+
candidates_src = (
|
|
119
|
+
raw_output.get("candidates", raw_output)
|
|
120
|
+
if isinstance(raw_output, Mapping)
|
|
121
|
+
else {}
|
|
122
|
+
)
|
|
123
|
+
ab_slots = {
|
|
124
|
+
"slot_1": candidates_src.get(perm["slot_1"]) if isinstance(candidates_src, Mapping) else None,
|
|
125
|
+
"slot_2": candidates_src.get(perm["slot_2"]) if isinstance(candidates_src, Mapping) else None,
|
|
126
|
+
}
|
|
127
|
+
if not has_ann:
|
|
128
|
+
return {
|
|
129
|
+
**base,
|
|
130
|
+
"blinded": True,
|
|
131
|
+
"output": {"ab_slots": ab_slots},
|
|
132
|
+
"meta": safe_meta,
|
|
133
|
+
"item_trace_id": None,
|
|
134
|
+
"item_span_id": None,
|
|
135
|
+
"mlflow_url": None,
|
|
136
|
+
}
|
|
137
|
+
trace_id = item_row.get("item_trace_id")
|
|
138
|
+
revealed_out = dict(raw_output) if isinstance(raw_output, Mapping) else {"raw": raw_output}
|
|
139
|
+
revealed_out["ab_slots"] = ab_slots
|
|
140
|
+
revealed_out["ab_mapping"] = perm
|
|
141
|
+
return {
|
|
142
|
+
**base,
|
|
143
|
+
"blinded": False,
|
|
144
|
+
"output": revealed_out,
|
|
145
|
+
"meta": raw_meta,
|
|
146
|
+
"item_trace_id": trace_id,
|
|
147
|
+
"item_span_id": item_row.get("item_span_id"),
|
|
148
|
+
"mlflow_url": mlflow_sink.trace_ui_url(trace_id) if mlflow_sink else None,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# unblinded
|
|
152
|
+
trace_id = item_row.get("item_trace_id")
|
|
153
|
+
return {
|
|
154
|
+
**base,
|
|
155
|
+
"blinded": False,
|
|
156
|
+
"output": raw_output,
|
|
157
|
+
"meta": raw_meta,
|
|
158
|
+
"item_trace_id": trace_id,
|
|
159
|
+
"item_span_id": item_row.get("item_span_id"),
|
|
160
|
+
"mlflow_url": mlflow_sink.trace_ui_url(trace_id) if mlflow_sink else None,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class AnnotationRequest(BaseModel):
|
|
165
|
+
mutation_id: str | None = None
|
|
166
|
+
queue_key: str | None = None
|
|
167
|
+
task_key: str | None = None
|
|
168
|
+
item_key: str
|
|
169
|
+
input_digest: str
|
|
170
|
+
target_artifact_version_id: str | None = None
|
|
171
|
+
reviewer_id: str = "local"
|
|
172
|
+
blind_mode: BlindMode | None = None
|
|
173
|
+
verdict: str | None = None
|
|
174
|
+
scores: dict[str, Any] = Field(default_factory=dict)
|
|
175
|
+
corrected_output: Any = None
|
|
176
|
+
note: str | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class RevisionRequest(BaseModel):
|
|
180
|
+
mutation_id: str | None = None
|
|
181
|
+
queue_key: str | None = None
|
|
182
|
+
base_artifact_version_id: str
|
|
183
|
+
patch: dict[str, Any]
|
|
184
|
+
reason: str | None = None
|
|
185
|
+
source_annotation_id: str | None = None
|
|
186
|
+
created_by: str = "local"
|
|
187
|
+
trigger_rerun: bool = False
|
|
188
|
+
parent_run_id: str | None = None
|
|
189
|
+
config_overrides: dict[str, Any] | None = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def create_misbar_router(
|
|
193
|
+
store: MisbarStore,
|
|
194
|
+
*,
|
|
195
|
+
queues: Sequence[QueueSpec] | None = None,
|
|
196
|
+
sink: TraceSink | None = None,
|
|
197
|
+
mlflow_sink: MlflowSink | None = None,
|
|
198
|
+
rerun_handler: Callable[[str, str, dict[str, Any] | None], dict[str, Any]] | None = None,
|
|
199
|
+
) -> APIRouter:
|
|
200
|
+
"""Create the FastAPI `/api` router backed by `MisbarStore`."""
|
|
201
|
+
router = APIRouter(prefix="/api")
|
|
202
|
+
queue_map: dict[str, QueueSpec] = {q.queue_key: q for q in (queues or [])}
|
|
203
|
+
|
|
204
|
+
def _resolve_queues() -> dict[str, QueueSpec]:
|
|
205
|
+
if queue_map:
|
|
206
|
+
return queue_map
|
|
207
|
+
# Dynamic default queue discovery for zero-config `misbar serve`
|
|
208
|
+
discovered: dict[str, QueueSpec] = {}
|
|
209
|
+
seen_keys: set[str] = set()
|
|
210
|
+
for av in store.list_artifact_versions():
|
|
211
|
+
ak = av["artifact_key"]
|
|
212
|
+
if ak in seen_keys or av["item_count"] == 0:
|
|
213
|
+
continue
|
|
214
|
+
seen_keys.add(ak)
|
|
215
|
+
discovered[ak] = QueueSpec(
|
|
216
|
+
queue_key=ak,
|
|
217
|
+
task_key=ak,
|
|
218
|
+
artifact_key=ak,
|
|
219
|
+
title=f"{ak} (default)",
|
|
220
|
+
blind_mode="reveal_after_submit",
|
|
221
|
+
allow_mode_override=True,
|
|
222
|
+
allow_revisions=True,
|
|
223
|
+
)
|
|
224
|
+
return discovered
|
|
225
|
+
|
|
226
|
+
def _get_queue_and_av(
|
|
227
|
+
queue_key: str,
|
|
228
|
+
artifact_version_id: str | None = None,
|
|
229
|
+
) -> tuple[QueueSpec, dict[str, Any]]:
|
|
230
|
+
qmap = _resolve_queues()
|
|
231
|
+
if queue_key not in qmap:
|
|
232
|
+
raise HTTPException(status_code=404, detail=f"Unknown queue '{queue_key}'")
|
|
233
|
+
spec = qmap[queue_key]
|
|
234
|
+
av = (
|
|
235
|
+
store.get_artifact_version(artifact_version_id)
|
|
236
|
+
if artifact_version_id
|
|
237
|
+
else store.get_latest_artifact_version(spec.artifact_key)
|
|
238
|
+
)
|
|
239
|
+
if not av:
|
|
240
|
+
raise HTTPException(
|
|
241
|
+
status_code=404,
|
|
242
|
+
detail=f"No artifact versions found for '{spec.artifact_key}'",
|
|
243
|
+
)
|
|
244
|
+
return spec, av
|
|
245
|
+
|
|
246
|
+
def _effective_mode(spec: QueueSpec, requested: BlindMode | None) -> BlindMode:
|
|
247
|
+
if requested and spec.allow_mode_override:
|
|
248
|
+
return requested
|
|
249
|
+
return spec.blind_mode
|
|
250
|
+
|
|
251
|
+
@router.get("/health")
|
|
252
|
+
def health() -> dict[str, Any]:
|
|
253
|
+
runs = store.list_runs(limit=5)
|
|
254
|
+
return {
|
|
255
|
+
"status": "ok",
|
|
256
|
+
"db_path": str(store.db_path),
|
|
257
|
+
"offline_ready": True,
|
|
258
|
+
"recent_runs": len(runs),
|
|
259
|
+
"mlflow_configured": mlflow_sink is not None,
|
|
260
|
+
"mlflow_ui_url": mlflow_sink.ui_base_url if mlflow_sink else None,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@router.get("/runs")
|
|
264
|
+
def list_runs(pipeline_key: str | None = None, limit: int = 50) -> dict[str, Any]:
|
|
265
|
+
runs = store.list_runs(pipeline_key=pipeline_key, limit=limit)
|
|
266
|
+
return {"runs": runs}
|
|
267
|
+
|
|
268
|
+
@router.get("/runs/{run_id}")
|
|
269
|
+
def get_run(run_id: str) -> dict[str, Any]:
|
|
270
|
+
run = store.get_run(run_id)
|
|
271
|
+
if not run:
|
|
272
|
+
raise HTTPException(status_code=404, detail=f"Unknown run '{run_id}'")
|
|
273
|
+
return {"run": run, "steps": store.list_steps(run_id)}
|
|
274
|
+
|
|
275
|
+
@router.get("/artifacts")
|
|
276
|
+
def list_artifacts(artifact_key: str | None = None) -> dict[str, Any]:
|
|
277
|
+
return {"artifact_versions": store.list_artifact_versions(artifact_key)}
|
|
278
|
+
|
|
279
|
+
@router.get("/queues")
|
|
280
|
+
def list_queues(reviewer_id: str = "local") -> dict[str, Any]:
|
|
281
|
+
qmap = _resolve_queues()
|
|
282
|
+
summaries = []
|
|
283
|
+
for qk, spec in qmap.items():
|
|
284
|
+
av = store.get_latest_artifact_version(spec.artifact_key)
|
|
285
|
+
counts = {"total": 0, "ungraded": 0, "graded_this_version": 0, "carried_forward": 0}
|
|
286
|
+
if av:
|
|
287
|
+
_, counts = store.query_queue_items(
|
|
288
|
+
artifact_version_id=av["artifact_version_id"],
|
|
289
|
+
task_key=spec.task_key,
|
|
290
|
+
reviewer_id=reviewer_id,
|
|
291
|
+
include_graded=True,
|
|
292
|
+
filter_fn=spec.base_filter,
|
|
293
|
+
)
|
|
294
|
+
rev_av = (
|
|
295
|
+
store.get_latest_artifact_version(spec.revision_artifact_key)
|
|
296
|
+
if spec.revision_artifact_key
|
|
297
|
+
else av
|
|
298
|
+
)
|
|
299
|
+
summaries.append(
|
|
300
|
+
{
|
|
301
|
+
"queue_key": qk,
|
|
302
|
+
"title": spec.title or qk,
|
|
303
|
+
"task_key": spec.task_key,
|
|
304
|
+
"artifact_key": spec.artifact_key,
|
|
305
|
+
"latest_artifact_version_id": av["artifact_version_id"] if av else None,
|
|
306
|
+
"latest_version_num": av["version_num"] if av else None,
|
|
307
|
+
"blind_mode": spec.blind_mode,
|
|
308
|
+
"allow_mode_override": spec.allow_mode_override,
|
|
309
|
+
"verdict_options": list(spec.verdict_options),
|
|
310
|
+
"allow_revisions": spec.allow_revisions,
|
|
311
|
+
"revision_artifact_key": spec.revision_artifact_key or spec.artifact_key,
|
|
312
|
+
"revision_base_artifact_version_id": (
|
|
313
|
+
rev_av["artifact_version_id"] if rev_av else None
|
|
314
|
+
),
|
|
315
|
+
"filter_presets": sorted(spec.filter_presets.keys()),
|
|
316
|
+
"counts": counts,
|
|
317
|
+
}
|
|
318
|
+
)
|
|
319
|
+
return {"queues": summaries}
|
|
320
|
+
|
|
321
|
+
@router.get("/queues/{queue_key}/items")
|
|
322
|
+
def get_queue_items(
|
|
323
|
+
queue_key: str,
|
|
324
|
+
reviewer_id: str = "local",
|
|
325
|
+
artifact_version_id: str | None = None,
|
|
326
|
+
include_graded: bool = True,
|
|
327
|
+
preset: str | None = None,
|
|
328
|
+
search: str | None = None,
|
|
329
|
+
blind_mode: BlindMode | None = None,
|
|
330
|
+
offset: int = Query(default=0, ge=0),
|
|
331
|
+
limit: int = Query(default=100, ge=1, le=500),
|
|
332
|
+
) -> dict[str, Any]:
|
|
333
|
+
spec, av = _get_queue_and_av(queue_key, artifact_version_id)
|
|
334
|
+
mode = _effective_mode(spec, blind_mode)
|
|
335
|
+
|
|
336
|
+
preset_fn = None
|
|
337
|
+
if preset:
|
|
338
|
+
if preset not in spec.filter_presets:
|
|
339
|
+
raise HTTPException(status_code=400, detail=f"Unknown preset '{preset}'")
|
|
340
|
+
# Disallow output-dependent presets when queue is blinded to prevent side-channel leaks
|
|
341
|
+
if mode in ("full_blind", "reveal_after_submit"):
|
|
342
|
+
raise HTTPException(
|
|
343
|
+
status_code=403,
|
|
344
|
+
detail="Output-based filter presets are disabled on blind queues",
|
|
345
|
+
)
|
|
346
|
+
preset_fn = spec.filter_presets[preset]
|
|
347
|
+
|
|
348
|
+
def combined_filter(row: dict[str, Any]) -> bool:
|
|
349
|
+
if spec.base_filter is not None and not spec.base_filter(row):
|
|
350
|
+
return False
|
|
351
|
+
if preset_fn is not None and not preset_fn(row):
|
|
352
|
+
return False
|
|
353
|
+
if search:
|
|
354
|
+
needle = search.lower()
|
|
355
|
+
hay = f"{row['item_key']} {row['input']}".lower()
|
|
356
|
+
if needle not in hay:
|
|
357
|
+
return False
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
rows, counts = store.query_queue_items(
|
|
361
|
+
artifact_version_id=av["artifact_version_id"],
|
|
362
|
+
task_key=spec.task_key,
|
|
363
|
+
reviewer_id=reviewer_id,
|
|
364
|
+
include_graded=include_graded,
|
|
365
|
+
filter_fn=combined_filter if (spec.base_filter or preset_fn or search) else None,
|
|
366
|
+
)
|
|
367
|
+
sliced = rows[offset : offset + limit]
|
|
368
|
+
sanitized = [
|
|
369
|
+
sanitize_item_for_client(
|
|
370
|
+
r,
|
|
371
|
+
spec,
|
|
372
|
+
effective_mode=mode,
|
|
373
|
+
reviewer_id=reviewer_id,
|
|
374
|
+
mlflow_sink=mlflow_sink,
|
|
375
|
+
)
|
|
376
|
+
for r in sliced
|
|
377
|
+
]
|
|
378
|
+
return {
|
|
379
|
+
"queue_key": queue_key,
|
|
380
|
+
"artifact_version": av,
|
|
381
|
+
"blind_mode": mode,
|
|
382
|
+
"counts": counts,
|
|
383
|
+
"offset": offset,
|
|
384
|
+
"limit": limit,
|
|
385
|
+
"items": sanitized,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
@router.get("/queues/{queue_key}/items/{item_key}/spans")
|
|
389
|
+
def get_item_spans(
|
|
390
|
+
queue_key: str,
|
|
391
|
+
item_key: str,
|
|
392
|
+
reviewer_id: str = "local",
|
|
393
|
+
artifact_version_id: str | None = None,
|
|
394
|
+
blind_mode: BlindMode | None = None,
|
|
395
|
+
) -> dict[str, Any]:
|
|
396
|
+
spec, av = _get_queue_and_av(queue_key, artifact_version_id)
|
|
397
|
+
mode = _effective_mode(spec, blind_mode)
|
|
398
|
+
rows, _ = store.query_queue_items(
|
|
399
|
+
artifact_version_id=av["artifact_version_id"],
|
|
400
|
+
task_key=spec.task_key,
|
|
401
|
+
reviewer_id=reviewer_id,
|
|
402
|
+
include_graded=True,
|
|
403
|
+
filter_fn=lambda r: r["item_key"] == item_key,
|
|
404
|
+
)
|
|
405
|
+
if not rows:
|
|
406
|
+
raise HTTPException(status_code=404, detail=f"Item '{item_key}' not found")
|
|
407
|
+
row = rows[0]
|
|
408
|
+
sanitized = sanitize_item_for_client(
|
|
409
|
+
row,
|
|
410
|
+
spec,
|
|
411
|
+
effective_mode=mode,
|
|
412
|
+
reviewer_id=reviewer_id,
|
|
413
|
+
mlflow_sink=mlflow_sink,
|
|
414
|
+
)
|
|
415
|
+
if sanitized["blinded"]:
|
|
416
|
+
raise HTTPException(
|
|
417
|
+
status_code=403,
|
|
418
|
+
detail="Execution traces are hidden until human judgment is submitted",
|
|
419
|
+
)
|
|
420
|
+
spans = store.get_item_spans(
|
|
421
|
+
item_key,
|
|
422
|
+
run_id=row.get("run_id"),
|
|
423
|
+
trace_id=row.get("item_trace_id"),
|
|
424
|
+
)
|
|
425
|
+
return {
|
|
426
|
+
"item_key": item_key,
|
|
427
|
+
"run_id": row.get("run_id"),
|
|
428
|
+
"mlflow_url": sanitized.get("mlflow_url"),
|
|
429
|
+
"spans": spans,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
@router.post("/annotations")
|
|
433
|
+
def post_annotation(req: AnnotationRequest) -> dict[str, Any]:
|
|
434
|
+
"""Mode A: Record human evaluation/gold ONLY — never mutates artifacts or triggers runs."""
|
|
435
|
+
spec: QueueSpec | None = None
|
|
436
|
+
av: dict[str, Any] | None = None
|
|
437
|
+
if req.queue_key:
|
|
438
|
+
spec, av = _get_queue_and_av(req.queue_key, req.target_artifact_version_id)
|
|
439
|
+
|
|
440
|
+
task_key = req.task_key or (spec.task_key if spec else None)
|
|
441
|
+
if not task_key:
|
|
442
|
+
raise HTTPException(status_code=400, detail="Either queue_key or task_key is required")
|
|
443
|
+
|
|
444
|
+
mode: BlindMode = (
|
|
445
|
+
_effective_mode(spec, req.blind_mode)
|
|
446
|
+
if spec
|
|
447
|
+
else (req.blind_mode or "unblinded")
|
|
448
|
+
)
|
|
449
|
+
target_avid = req.target_artifact_version_id or (av["artifact_version_id"] if av else None)
|
|
450
|
+
|
|
451
|
+
permutation = None
|
|
452
|
+
if spec and mode == "ab_randomized":
|
|
453
|
+
permutation = compute_ab_permutation(
|
|
454
|
+
req.reviewer_id, req.item_key, spec.ab_candidate_keys
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
ann = store.save_annotation(
|
|
458
|
+
mutation_id=req.mutation_id,
|
|
459
|
+
task_key=task_key,
|
|
460
|
+
item_key=req.item_key,
|
|
461
|
+
input_digest=req.input_digest,
|
|
462
|
+
target_artifact_version_id=target_avid,
|
|
463
|
+
reviewer_id=req.reviewer_id,
|
|
464
|
+
blind_mode=mode,
|
|
465
|
+
verdict=req.verdict,
|
|
466
|
+
scores=req.scores,
|
|
467
|
+
corrected_output=req.corrected_output,
|
|
468
|
+
permutation=permutation,
|
|
469
|
+
note=req.note,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
revealed_item: dict[str, Any] | None = None
|
|
473
|
+
updated_counts: dict[str, int] | None = None
|
|
474
|
+
if spec and av:
|
|
475
|
+
raw_it = store.get_item(av["artifact_version_id"], req.item_key)
|
|
476
|
+
if raw_it:
|
|
477
|
+
enriched = {
|
|
478
|
+
**raw_it,
|
|
479
|
+
"annotation": ann,
|
|
480
|
+
"annotation_status": "graded_this_version",
|
|
481
|
+
}
|
|
482
|
+
revealed_item = sanitize_item_for_client(
|
|
483
|
+
enriched,
|
|
484
|
+
spec,
|
|
485
|
+
effective_mode=mode,
|
|
486
|
+
reviewer_id=req.reviewer_id,
|
|
487
|
+
mlflow_sink=mlflow_sink,
|
|
488
|
+
)
|
|
489
|
+
if sink is not None and raw_it.get("item_trace_id") and req.verdict is not None:
|
|
490
|
+
sink.log_feedback(
|
|
491
|
+
trace_id=raw_it["item_trace_id"],
|
|
492
|
+
name=task_key,
|
|
493
|
+
value=req.verdict,
|
|
494
|
+
rationale=req.note,
|
|
495
|
+
reviewer_id=req.reviewer_id,
|
|
496
|
+
metadata={"annotation_id": ann["annotation_id"]},
|
|
497
|
+
)
|
|
498
|
+
_, updated_counts = store.query_queue_items(
|
|
499
|
+
artifact_version_id=av["artifact_version_id"],
|
|
500
|
+
task_key=spec.task_key,
|
|
501
|
+
reviewer_id=req.reviewer_id,
|
|
502
|
+
include_graded=True,
|
|
503
|
+
filter_fn=spec.base_filter,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
return {"annotation": ann, "item": revealed_item, "counts": updated_counts}
|
|
507
|
+
|
|
508
|
+
@router.post("/revisions")
|
|
509
|
+
def post_revision(req: RevisionRequest) -> dict[str, Any]:
|
|
510
|
+
"""Mode B: Mint explicit `artifact_version = v+1` and optionally rerun dirty downstream."""
|
|
511
|
+
if req.queue_key:
|
|
512
|
+
spec, _ = _get_queue_and_av(req.queue_key)
|
|
513
|
+
if not spec.allow_revisions:
|
|
514
|
+
raise HTTPException(
|
|
515
|
+
status_code=403,
|
|
516
|
+
detail=f"Queue '{req.queue_key}' does not allow artifact revisions",
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
rev, new_av = store.create_revision(
|
|
521
|
+
mutation_id=req.mutation_id,
|
|
522
|
+
base_artifact_version_id=req.base_artifact_version_id,
|
|
523
|
+
patch=req.patch,
|
|
524
|
+
reason=req.reason,
|
|
525
|
+
source_annotation_id=req.source_annotation_id,
|
|
526
|
+
created_by=req.created_by,
|
|
527
|
+
)
|
|
528
|
+
except KeyError as exc:
|
|
529
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
530
|
+
|
|
531
|
+
rerun_result: dict[str, Any] | None = None
|
|
532
|
+
if req.trigger_rerun:
|
|
533
|
+
if rerun_handler is None:
|
|
534
|
+
raise HTTPException(
|
|
535
|
+
status_code=400,
|
|
536
|
+
detail="No rerun_handler is configured on this server",
|
|
537
|
+
)
|
|
538
|
+
parent_run_id = req.parent_run_id
|
|
539
|
+
if not parent_run_id:
|
|
540
|
+
runs = store.list_runs(limit=1)
|
|
541
|
+
if not runs:
|
|
542
|
+
raise HTTPException(
|
|
543
|
+
status_code=400,
|
|
544
|
+
detail="parent_run_id is required when no prior runs exist",
|
|
545
|
+
)
|
|
546
|
+
parent_run_id = runs[0]["run_id"]
|
|
547
|
+
rerun_result = rerun_handler(
|
|
548
|
+
parent_run_id, rev["revision_id"], req.config_overrides
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
"revision": rev,
|
|
553
|
+
"new_artifact_version": new_av,
|
|
554
|
+
"rerun": rerun_result,
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
@router.get("/exports/{task_key}/gold.jsonl", response_class=PlainTextResponse)
|
|
558
|
+
def export_gold_jsonl(task_key: str, reviewer_id: str | None = None) -> str:
|
|
559
|
+
import json
|
|
560
|
+
|
|
561
|
+
rows = store.export_gold(task_key, reviewer_id=reviewer_id)
|
|
562
|
+
return "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows)
|
|
563
|
+
|
|
564
|
+
@router.get("/exports/{task_key}/preferences.jsonl", response_class=PlainTextResponse)
|
|
565
|
+
def export_preferences_jsonl(
|
|
566
|
+
task_key: str,
|
|
567
|
+
artifact_version_id: str,
|
|
568
|
+
reviewer_id: str | None = None,
|
|
569
|
+
) -> str:
|
|
570
|
+
import json
|
|
571
|
+
|
|
572
|
+
rows = store.export_preferences(
|
|
573
|
+
task_key, artifact_version_id, reviewer_id=reviewer_id
|
|
574
|
+
)
|
|
575
|
+
return "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows)
|
|
576
|
+
|
|
577
|
+
return router
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def mount_builtin_micro_ui(app: FastAPI, *, custom_ui_dir: str | Path | None = None) -> None:
|
|
581
|
+
"""Serve zero-build reviewer assets (`/`, `/assets/rtl.css`, `/assets/primitives.js`)."""
|
|
582
|
+
ui_pkg = resources.files("misbar").joinpath("ui_assets")
|
|
583
|
+
custom_dir = Path(custom_ui_dir) if custom_ui_dir else None
|
|
584
|
+
|
|
585
|
+
def _read_asset(filename: str) -> str:
|
|
586
|
+
if custom_dir and (custom_dir / filename).exists():
|
|
587
|
+
return (custom_dir / filename).read_text(encoding="utf-8")
|
|
588
|
+
return ui_pkg.joinpath(filename).read_text(encoding="utf-8")
|
|
589
|
+
|
|
590
|
+
@app.get("/", response_class=HTMLResponse)
|
|
591
|
+
def index_page() -> str:
|
|
592
|
+
return _read_asset("index.html")
|
|
593
|
+
|
|
594
|
+
@app.get("/assets/{asset_name}")
|
|
595
|
+
def asset_file(asset_name: str) -> Response:
|
|
596
|
+
if asset_name not in {"rtl.css", "primitives.js", "app.js"}:
|
|
597
|
+
if not (custom_dir and (custom_dir / asset_name).is_file()):
|
|
598
|
+
raise HTTPException(status_code=404, detail="Asset not found")
|
|
599
|
+
media = (
|
|
600
|
+
"text/css; charset=utf-8"
|
|
601
|
+
if asset_name.endswith(".css")
|
|
602
|
+
else "application/javascript; charset=utf-8"
|
|
603
|
+
)
|
|
604
|
+
return Response(content=_read_asset(asset_name), media_type=media)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def create_misbar_app(
|
|
608
|
+
store: MisbarStore,
|
|
609
|
+
*,
|
|
610
|
+
title: str = "misbar Reviewer Workbench",
|
|
611
|
+
queues: Sequence[QueueSpec] | None = None,
|
|
612
|
+
sink: TraceSink | None = None,
|
|
613
|
+
mlflow_sink: MlflowSink | None = None,
|
|
614
|
+
rerun_handler: Callable[[str, str, dict[str, Any] | None], dict[str, Any]] | None = None,
|
|
615
|
+
custom_ui_dir: str | Path | None = None,
|
|
616
|
+
) -> FastAPI:
|
|
617
|
+
"""Create a complete FastAPI app with `/api` routes and zero-build micro-UI."""
|
|
618
|
+
app = FastAPI(title=title)
|
|
619
|
+
router = create_misbar_router(
|
|
620
|
+
store,
|
|
621
|
+
queues=queues,
|
|
622
|
+
sink=sink,
|
|
623
|
+
mlflow_sink=mlflow_sink,
|
|
624
|
+
rerun_handler=rerun_handler,
|
|
625
|
+
)
|
|
626
|
+
app.include_router(router)
|
|
627
|
+
mount_builtin_micro_ui(app, custom_ui_dir=custom_ui_dir)
|
|
628
|
+
return app
|
|
629
|
+
|