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