"""Feedback labels: one label artifact per human-review decision, for metrics. Labels are written twice: job-locally under ``/review/`` (the auditable record for that run) and, via ``append_shared_feedback``, to the cross-job store at ``config.REVIEW_FEEDBACK_DIR``. The shared store is append-only and nothing reads it yet - it exists so that a later pass can prime a run with what reviewers corrected on previous sets without having to walk every job directory. """ import json import os from datetime import datetime, timezone from backend import config def _as_dict(value) -> dict: return value if isinstance(value, dict) else {} def decision_to_label(queue_item: dict, decision: dict, job: dict) -> dict: """Build one feedback label from a queue item, its decision, and the job. All field access is defensive: missing fields degrade to None (or [] for models_used) rather than raising. """ queue_item = _as_dict(queue_item) decision = _as_dict(decision) job = _as_dict(job) payload = _as_dict(queue_item.get("payload")) summary = _as_dict(_as_dict(job.get("report")).get("summary")) return { "kind": "review_decision", "review_item_id": queue_item.get("review_item_id"), "job_id": job.get("job_id"), "pipeline_mode": job.get("pipeline_mode"), "source_stage": payload.get("source_stage"), "category": payload.get("category"), "severity": payload.get("severity"), "confidence": payload.get("confidence"), "decision": decision.get("decision"), "reason_code": decision.get("reason_code"), # The reviewer's structured corrections. Carried here (and into the # cross-job store) so "wrong category" survives as data rather than # only as free text on the suppressed issue. "category_correction": decision.get("category_correction"), "severity_correction": decision.get("severity_correction"), "location": payload.get("location"), "disciplines": payload.get("disciplines"), "sheets": payload.get("sheets"), "drawing_type": payload.get("drawing_type"), "models_used": summary.get("models_used") or [], "created_at": datetime.now(timezone.utc).isoformat(), } def write_label(out_dir: str, label: dict) -> None: """Append one label job-locally and to the cross-job store. Never raises on I/O failure: a lost label must not fail the save that produced it. """ append_shared_feedback(label) try: review_dir = os.path.join(out_dir, "review") os.makedirs(review_dir, exist_ok=True) path = os.path.join(review_dir, "feedback_labels.jsonl") with open(path, "a", encoding="utf-8") as f: f.write(json.dumps(label) + "\n") except OSError as e: print(f"[Review] feedback label write failed: {e}") def append_shared_feedback(record: dict) -> None: """Append one record to the cross-job feedback store; never raises. One JSONL file per record ``kind`` so a reader can pick up decisions and chat turns independently. Failure here is logged and swallowed: the cross-job roll-up is a convenience, and losing a line must never fail the review action that produced it. """ try: kind = str(record.get("kind") or "misc") os.makedirs(config.REVIEW_FEEDBACK_DIR, exist_ok=True) name = "chat_turns.jsonl" if kind == "review_chat_turn" else "decisions.jsonl" path = os.path.join(config.REVIEW_FEEDBACK_DIR, name) with open(path, "a", encoding="utf-8") as f: f.write(json.dumps(record) + "\n") except OSError as e: print(f"[Review] shared feedback write failed: {e}") def read_shared_feedback(kind: str = "review_decision") -> list: """Read the cross-job store for one record kind, oldest first. Corrupt lines are skipped so a partial write cannot hide the rest. """ name = "chat_turns.jsonl" if kind == "review_chat_turn" else "decisions.jsonl" path = os.path.join(config.REVIEW_FEEDBACK_DIR, name) if not os.path.isfile(path): return [] records = [] try: with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: value = json.loads(line) except json.JSONDecodeError: continue if isinstance(value, dict): records.append(value) except OSError: return [] return records