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
+153
-2
@@ -82,6 +82,29 @@
|
||||
border:1px solid var(--line); border-radius:6px; padding:6px 8px; font-size:13px; margin-top:6px; }
|
||||
.review-controls input[type=text] { width:100%; }
|
||||
.review-controls .hidden { display:none; }
|
||||
.chat { margin-top:10px; padding-top:10px; border-top:1px solid var(--line); font-size:13px; }
|
||||
.chat-toggle { background:none; border:0; color:var(--accent); cursor:pointer; padding:0;
|
||||
font-size:13px; text-decoration:underline dotted; }
|
||||
.chat-body { margin-top:10px; }
|
||||
.chat-body.hidden, .chat .hidden { display:none; }
|
||||
.chat-turns { max-height:340px; overflow-y:auto; margin-bottom:8px; }
|
||||
.chat-q, .chat-a { border-radius:8px; padding:8px 10px; margin:6px 0; }
|
||||
.chat-q { background:#161b25; }
|
||||
.chat-q b { color:var(--accent); }
|
||||
.chat-a { background:#11141a; }
|
||||
.chat-a ul { margin:6px 0 0; padding-left:18px; }
|
||||
.chat-a li { margin:2px 0; }
|
||||
.chat-cite { color:var(--muted); font-size:12px; margin-top:6px; }
|
||||
.chat-cite code { color:var(--accent); }
|
||||
.chat-tags { color:var(--muted); font-size:12px; margin-top:6px; }
|
||||
.chat-row { display:flex; gap:8px; align-items:flex-start; }
|
||||
.chat-row textarea { flex:1; background:#0c0e13; color:var(--text); border:1px solid var(--line);
|
||||
border-radius:6px; padding:8px; font-size:13px; font-family:inherit; resize:vertical;
|
||||
min-height:38px; }
|
||||
.chat-row button { white-space:nowrap; }
|
||||
.chat-hint { color:var(--muted); font-size:12px; margin-top:6px; }
|
||||
.chat-err { color:var(--hi); font-size:12px; margin-top:6px; }
|
||||
.btn.sm { padding:8px 14px; font-size:13px; margin-top:0; }
|
||||
.pill.blocking { background:rgba(255,93,87,.15); color:var(--hi); }
|
||||
.pill.audit { background:rgba(91,140,255,.15); color:var(--accent); }
|
||||
.pill.critical { background:rgba(255,93,87,.28); color:#fff; }
|
||||
@@ -536,6 +559,12 @@ async function renderReview(job){
|
||||
esc(prog.completed||0)+' of '+esc(prog.required||0)+' required items decided.'+
|
||||
((prog.remaining||0)>0?' Decide all blocking items, save, then finalize.':
|
||||
' All required items decided \u2014 you can finalize.')+'</div>';
|
||||
html+='<div class="conflict" style="border-left-color:var(--accent)">'+
|
||||
'<div class="row"><span class="cat">Ask about this run</span></div>'+
|
||||
'<div class="meta">Questions about coverage or about the run as a whole — '+
|
||||
'e.g. "why didn\'t it pick up the Civil set?". Answers are explanations only; '+
|
||||
'they never change a finding.</div>'+
|
||||
chatPanelHtml('','Ask a question about this run',true)+'</div>';
|
||||
const blocking=queue.filter(i=>i.blocking), audit=queue.filter(i=>!i.blocking);
|
||||
blocking.forEach((item,i)=>{ html+=reviewItemHtml(item,'b'+i,prior[item.review_item_id]); });
|
||||
if(audit.length){
|
||||
@@ -548,7 +577,11 @@ async function renderReview(job){
|
||||
'<button class="btn" id="saveReviewBtn">Save decisions</button> '+
|
||||
'<button class="btn" id="finalizeBtn"'+((prog.remaining||0)===0?'':' disabled')+
|
||||
'>Finalize & send report</button></div>'+
|
||||
'<div class="status" id="reviewMsg"></div>';
|
||||
'<div class="status" id="reviewMsg"></div>'+
|
||||
'<div class="meta" style="margin-top:10px">Chat transcript: '+
|
||||
'<a href="/jobs/'+esc(jobId)+'/review-chat/log" target="_blank" rel="noopener">'+
|
||||
'/jobs/'+esc(jobId)+'/review-chat/log</a> '+
|
||||
'(also saved as review/chat_log.jsonl on the server)</div>';
|
||||
results.innerHTML=html;
|
||||
results.querySelectorAll('.review-item input[type=radio]').forEach(r=>{
|
||||
r.addEventListener('change',()=>syncReviewControls(r.closest('.review-item')));
|
||||
@@ -559,6 +592,8 @@ async function renderReview(job){
|
||||
});
|
||||
document.getElementById('saveReviewBtn').addEventListener('click',saveReviewDecisions);
|
||||
document.getElementById('finalizeBtn').addEventListener('click',finalizeReview);
|
||||
wireChatPanels();
|
||||
loadChatHistory();
|
||||
}
|
||||
|
||||
function reviewItemHtml(item,uid,prev){
|
||||
@@ -600,10 +635,126 @@ function reviewItemHtml(item,uid,prev){
|
||||
'<input type="text" class="comment" placeholder="Comment (optional)" value="'+escAttr(prev.comment||'')+'">'+
|
||||
'<input type="text" class="clar'+(prev.decision==='needs_clarification'?'':' hidden')+
|
||||
'" placeholder="Clarification answer" value="'+escAttr(prev.clarification_answer||'')+'">'+
|
||||
'</div></div>';
|
||||
'</div>'+
|
||||
chatPanelHtml(item.review_item_id,'Ask about this finding')+
|
||||
'</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// --- review chat (read-only run explainer) ---
|
||||
// One panel per review item plus one run-scope panel. Panels are keyed by
|
||||
// review_item_id ('' = run scope); history for every panel is fetched once.
|
||||
|
||||
function chatPanelHtml(key,label,openByDefault){
|
||||
const open=!!openByDefault;
|
||||
return '<div class="chat" data-chat="'+escAttr(key||'')+'">'+
|
||||
'<button type="button" class="chat-toggle">'+esc(label)+'</button>'+
|
||||
'<div class="chat-body'+(open?'':' hidden')+'">'+
|
||||
'<div class="chat-turns"></div>'+
|
||||
'<div class="chat-row">'+
|
||||
'<textarea rows="2" placeholder="Why did it conclude that?"></textarea>'+
|
||||
'<button type="button" class="btn sm chat-send">Ask</button>'+
|
||||
'</div>'+
|
||||
'<div class="chat-hint">Explains what the run did, from its own artifacts. '+
|
||||
'It cannot change this finding or your decision.</div>'+
|
||||
'<div class="chat-err"></div>'+
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function chatTurnHtml(turn){
|
||||
let html='<div class="chat-q"><b>You:</b> '+esc(turn.question||'')+'</div>'+
|
||||
'<div class="chat-a">'+esc(turn.answer||'');
|
||||
if((turn.findings||[]).length){
|
||||
html+='<ul>'+turn.findings.map(f=>'<li>'+esc(f)+'</li>').join('')+'</ul>';
|
||||
}
|
||||
(turn.evidence_cited||[]).forEach(c=>{
|
||||
html+='<div class="chat-cite"><code>'+esc(c.artifact||'?')+'</code>'+
|
||||
(c.sheet?' ('+esc(c.sheet)+')':'')+
|
||||
(c.quote?': "'+esc(c.quote)+'"':'')+
|
||||
(c.why_it_matters?' — '+esc(c.why_it_matters):'')+'</div>';
|
||||
});
|
||||
const tags=[];
|
||||
if(turn.answerable&&turn.answerable!=='yes') tags.push('answerable: '+turn.answerable);
|
||||
if(turn.assessment_of_finding&&turn.assessment_of_finding!=='not_applicable')
|
||||
tags.push(turn.assessment_of_finding.replace(/_/g,' '));
|
||||
if(turn.confidence) tags.push('confidence: '+turn.confidence);
|
||||
if(turn.missing_information) tags.push('missing: '+turn.missing_information);
|
||||
if(turn.suggested_category_correction)
|
||||
tags.push('correction noted: '+turn.suggested_category_correction);
|
||||
if(tags.length) html+='<div class="chat-tags">'+esc(tags.join(' \u00b7 '))+'</div>';
|
||||
return html+'</div>';
|
||||
}
|
||||
|
||||
function renderChatTurns(panel,turns){
|
||||
const box=panel.querySelector('.chat-turns');
|
||||
box.innerHTML=turns.length?turns.map(chatTurnHtml).join('')
|
||||
:'<div class="meta">No questions asked yet.</div>';
|
||||
box.scrollTop=box.scrollHeight;
|
||||
}
|
||||
|
||||
async function loadChatHistory(){
|
||||
let data;
|
||||
try{
|
||||
const res=await fetch('/jobs/'+currentJobId+'/review-chat');
|
||||
if(!res.ok) return; // chat unavailable for this job: leave panels empty
|
||||
data=await res.json();
|
||||
}catch(err){ return; }
|
||||
const byKey={};
|
||||
(data.turns||[]).forEach(t=>{
|
||||
const k=t.review_item_id||'';
|
||||
(byKey[k]=byKey[k]||[]).push(t);
|
||||
});
|
||||
results.querySelectorAll('.chat').forEach(panel=>{
|
||||
const key=panel.getAttribute('data-chat')||'';
|
||||
renderChatTurns(panel,byKey[key]||[]);
|
||||
});
|
||||
}
|
||||
|
||||
function wireChatPanels(){
|
||||
results.querySelectorAll('.chat').forEach(panel=>{
|
||||
panel.querySelector('.chat-toggle').addEventListener('click',()=>{
|
||||
panel.querySelector('.chat-body').classList.toggle('hidden');
|
||||
});
|
||||
const send=panel.querySelector('.chat-send');
|
||||
const box=panel.querySelector('textarea');
|
||||
send.addEventListener('click',()=>askChat(panel));
|
||||
// Enter sends, Shift+Enter newlines - the questions are usually one line.
|
||||
box.addEventListener('keydown',e=>{
|
||||
if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); askChat(panel); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function askChat(panel){
|
||||
const box=panel.querySelector('textarea');
|
||||
const send=panel.querySelector('.chat-send');
|
||||
const err=panel.querySelector('.chat-err');
|
||||
const question=box.value.trim();
|
||||
err.textContent='';
|
||||
if(!question) return;
|
||||
const key=panel.getAttribute('data-chat')||'';
|
||||
const body={question:question};
|
||||
if(key) body.review_item_id=key;
|
||||
send.disabled=true; send.textContent='Asking...';
|
||||
try{
|
||||
const res=await fetch('/jobs/'+currentJobId+'/review-chat',
|
||||
{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body)});
|
||||
if(!res.ok){
|
||||
const e=await res.json().catch(()=>({detail:res.statusText}));
|
||||
const detail=typeof e.detail==='string'?e.detail:JSON.stringify(e.detail);
|
||||
throw new Error(detail||'Request failed');
|
||||
}
|
||||
const data=await res.json();
|
||||
const turnsBox=panel.querySelector('.chat-turns');
|
||||
if(turnsBox.querySelector('.meta')) turnsBox.innerHTML='';
|
||||
turnsBox.insertAdjacentHTML('beforeend',chatTurnHtml(data.turn||{}));
|
||||
turnsBox.scrollTop=turnsBox.scrollHeight;
|
||||
box.value='';
|
||||
}catch(e){ err.textContent=e.message; }
|
||||
finally{ send.disabled=false; send.textContent='Ask'; }
|
||||
}
|
||||
|
||||
function syncReviewControls(el){
|
||||
const sel=el.querySelector('input[type=radio]:checked');
|
||||
const v=sel?sel.value:'';
|
||||
|
||||
Reference in new issue
Block a user