Files
Conflict_Checker/backend/review/feedback.py
T
woogiandClaude Opus 5 23e19f53b2
Docker Release / build-and-push (push) Successful in 1m45s
Docker Release / release (push) Skipped
feat: review chat — ask the run why it concluded a finding
Read-only Q&A on the review screen, per finding and per run, answered from
the job's own artifacts (evidence, cluster, extraction, verification, Brain
merge, sheet index, cover reconciliation, job.log). It never mutates findings,
decisions, or the report.

Turns are logged job-locally (review/chat_log.jsonl, transcript at
/jobs/{id}/review-chat/log) and to a cross-job feedback store
(REVIEW_FEEDBACK_DIR), which now also receives review decisions with their
category/severity corrections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115gGtrSxXE9DKvS9XPFSoT
2026-09-14 10:35:18 -05:00

119 lines
4.5 KiB
Python

"""Feedback labels: one label artifact per human-review decision, for metrics.
Labels are written twice: job-locally under ``<out_dir>/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