feat: review chat — ask the run why it concluded a finding
Docker Release / build-and-push (push) Successful in 1m45s
Docker Release / release (push) Skipped

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
This commit is contained in:
woogiandClaude Opus 5 committed 2026-09-14 10:35:18 -05:00
1 parent 46db871152
commit 23e19f53b2
15 files changed
+1598 -9

No files matched your search

+62
View File
@@ -23,6 +23,7 @@ import backend.jobs
from backend import config, llm
from backend.jobs import PIPELINE_MODES, create_job, get_job, _set
from backend.pipeline.pdf_processor import render_page_jpeg
from backend.review import chat as review_chat
from backend.review.feedback import decision_to_label, write_label
from backend.review.finalizer import finalize_review
from backend.review.store import ReviewStore
@@ -252,6 +253,67 @@ def finalize_review_endpoint(job_id: str):
return {"status": "finalizing"}
# Statuses in which the review chat may be used. The chat is read-only, so it
# stays available after finalization - a reviewer often asks "why did it say
# that?" about a report they have already sent.
_CHAT_STATES = ("needs_review", "reviewing", "finalizing", "done", "finalization_error")
def _chat_out_dir(job_id: str) -> str:
"""Resolve a job's output dir for a chat request, or raise an HTTP error."""
job = get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if job.get("status") not in _CHAT_STATES:
raise HTTPException(status_code=409, detail={
"detail": f"review chat is not available for a job in status {job.get('status')}",
})
return job.get("out_dir") or os.path.join(config.OUTPUT_DIR, job_id)
@app.post("/jobs/{job_id}/review-chat")
def review_chat_ask(job_id: str, payload: dict):
"""Ask one question about a finding, or about the run as a whole.
Read-only: this answers from the job's artifacts and appends to the chat
log. It never changes a finding, a decision, or the report.
"""
out_dir = _chat_out_dir(job_id)
store = ReviewStore(out_dir, create=False)
try:
turn = review_chat.ask(
job_id=job_id,
out_dir=out_dir,
question=payload.get("question"),
review_item_id=payload.get("review_item_id") or None,
queue=store.read_queue(),
decisions=store.read_decisions(),
)
except review_chat.ChatError as e:
raise HTTPException(status_code=422, detail=str(e))
except Exception as e:
# A failed model call is an upstream problem, not a bad request; the
# review screen shows it inline and the reviewer can retry.
raise HTTPException(status_code=502, detail=f"review chat failed: {e}")
return {"turn": turn}
@app.get("/jobs/{job_id}/review-chat")
def review_chat_history(job_id: str, review_item_id: Optional[str] = None):
"""Logged chat turns, oldest first. Without review_item_id, all threads."""
out_dir = _chat_out_dir(job_id)
turns = review_chat.read_log(out_dir, review_item_id=review_item_id)
return {"turns": turns, "enabled": config.ENABLE_REVIEW_CHAT}
@app.get("/jobs/{job_id}/review-chat/log")
def review_chat_log(job_id: str):
"""The chat log as a readable transcript: issue, questions, findings."""
out_dir = _chat_out_dir(job_id)
markdown = review_chat.render_log_markdown(review_chat.read_log(out_dir))
return Response(content=markdown, media_type="text/markdown; charset=utf-8")
@app.get("/jobs/{job_id}/sheet-image/{page}")
def sheet_image(job_id: str, page: int):
"""Render one page of a completed job's source PDF as JPEG (sheet viewer)."""