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
This commit is contained in:
1 parent
46db871152
commit
23e19f53b2
15 files changed
+1598
-9
No files matched your search
@@ -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)."""
|
||||
|
||||
Reference in new issue
Block a user