Add required human review gate to the Agent pipeline.
Docker Release / build-and-push (push) Successful in 1m10s
Docker Release / release (push) Skipped

Agent web jobs now stop after Brain consolidation and enter needs_review
with a persisted review queue (blocking: high-severity, low-confidence,
sensitive-category findings; audit sample of clean clusters). Humans
decide confirm/reject/unsure/needs_clarification via new review API and
frontend queue; a finalizer applies decisions (rejections suppressed with
reason codes), performs bounded targeted reruns for clarifications,
drafts RFIs only for kept issues, and only then marks the job done and
sends the final email. Two-phase email (review-required, then final
report), per-decision feedback labels with redacted aggregate metrics,
restart recovery from job artifacts, and CLI --no-review bypass.
Classic pipeline unchanged. 65 non-LLM tests.
This commit is contained in:
John Wilganowski
2026-07-28 19:23:57 +00:00
parent ac328d34fd
commit 1c1d2ff21b
27 changed files with 3344 additions and 22 deletions
+187 -2
View File
@@ -64,6 +64,14 @@
.note { background:var(--panel); border:1px solid var(--line); border-radius:10px;
padding:16px 18px; margin:18px 0; }
.note b { color:var(--text); }
.review-controls { margin-top:10px; padding-top:10px; border-top:1px solid var(--line); font-size:13px; }
.review-controls label { margin-right:14px; cursor:pointer; white-space:nowrap; }
.review-controls select, .review-controls input[type=text] { background:#0c0e13; color:var(--text);
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; }
.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; }
.sheetlink { color:var(--accent); cursor:pointer; text-decoration:underline dotted; }
#viewer { position:fixed; inset:0; background:rgba(0,0,0,.88); display:none;
@@ -133,7 +141,7 @@ const drop=document.getElementById('drop'), fileInput=document.getElementById('f
runBtn=document.getElementById('run'), statusEl=document.getElementById('status'),
results=document.getElementById('results'), dropLabel=document.getElementById('dropLabel'),
emailEl=document.getElementById('email');
let chosen=null, polling=null, currentJobId=null, sheetPage={}, viewerZoom=1;
let chosen=null, polling=null, currentJobId=null, sheetPage={}, viewerZoom=1, reviewDirty=false;
function setFile(f){ chosen=f; dropLabel.textContent=f?('Selected: '+f.name):'Drop a PDF drawing set here, or click to choose';
runBtn.disabled=!f; }
@@ -185,6 +193,13 @@ function poll(jobId){
' · you can leave this page';
} else if(job.status==='done'){
clearInterval(polling); polling=null; runBtn.disabled=false; render(job.report);
} else if(job.status==='needs_review'||job.status==='reviewing'){
clearInterval(polling); polling=null; runBtn.disabled=false; renderReview(job);
} else if(job.status==='finalizing'){
statusEl.innerHTML='<span class="spinner"></span>Finalizing reviewed report...';
} else if(job.status==='finalization_error'){
clearInterval(polling); polling=null; runBtn.disabled=false;
statusEl.textContent='Finalization failed: '+(job.error||'unknown error');
} else if(job.status==='error'){
clearInterval(polling); polling=null; runBtn.disabled=false;
statusEl.textContent='Run failed: '+(job.error||'unknown error');
@@ -196,6 +211,7 @@ function poll(jobId){
}
function esc(s){ return (s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
function escAttr(s){ return esc(s).replace(/"/g,'&quot;'); }
function syncPipelineOptions(){
const agent=(document.querySelector('input[name="pipeline_mode"]:checked')||{}).value==='agent';
@@ -284,10 +300,13 @@ function render(rep){
html+='<details open style="margin-top:24px"><summary><b>QAQC issues ('+issues.length+')</b> '+
'<span class="opt">conflicts + full-set + code/ADA + constructability, deduplicated</span></summary>';
for(const c of issues){
const rs=c.review_state;
html+='<div class="conflict '+esc(c.severity)+'">'+
'<div class="row"><span class="cat">'+esc(c.source_stage)+' &middot; '+esc(c.category)+'</span>'+
'<span class="pill '+esc(c.severity)+'">'+esc(c.severity)+
(c.risk_score!=null?(' &middot; risk '+esc(c.risk_score)):'')+'</span></div>'+
(c.risk_score!=null?(' &middot; risk '+esc(c.risk_score)):'')+'</span>'+
(rs&&['unsure','clarified','clarification_failed'].includes(rs)?
' <span class="pill audit">'+esc(rs.replace(/_/g,' '))+'</span>':'')+'</div>'+
'<div class="loc">'+esc(c.location)+'</div>'+
((c.sheets||[]).length?('<div class="meta">Sheets: '+sheetList(c.sheets)+'</div>'):'')+
'<div class="desc">'+esc(c.description)+'</div>';
@@ -317,6 +336,172 @@ function render(rep){
}
function stat(v,l){ return '<div class="stat"><b>'+esc(v)+'</b><span>'+esc(l)+'</span></div>'; }
// --- human review queue (agent pipeline) ---
const REVIEW_REASON_CODES=['wrong_cluster_link','same_value_different_representation',
'not_a_contradiction','missing_evidence','extraction_misread','code_path_not_applicable',
'duplicate','severity_too_high','severity_too_low','other'];
const REVIEW_DECISIONS=['confirm','reject','unsure','needs_clarification'];
async function renderReview(job){
const jobId=job.job_id||currentJobId;
currentJobId=jobId;
statusEl.textContent='Analysis complete \u2014 human review required.';
let data;
try{
const res=await fetch('/jobs/'+jobId+'/review');
if(!res.ok) throw new Error('could not load review queue');
data=await res.json();
}catch(err){ statusEl.textContent='Error: '+err.message; return; }
const queue=data.queue||[], prog=data.progress||{}, prior=data.decisions||{};
let html='<div class="note"><b>Analysis complete \u2014 human review required.</b><br>'+
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>';
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){
html+='<details style="margin-top:16px"><summary><b>Audit items ('+audit.length+')</b> '+
'<span class="opt">non-blocking &mdash; decisions optional</span></summary>';
audit.forEach((item,i)=>{ html+=reviewItemHtml(item,'a'+i,prior[item.review_item_id]); });
html+='</details>';
}
html+='<div style="margin:18px 0">'+
'<button class="btn" id="saveReviewBtn">Save decisions</button> '+
'<button class="btn" id="finalizeBtn"'+((prog.remaining||0)===0?'':' disabled')+
'>Finalize &amp; send report</button></div>'+
'<div class="status" id="reviewMsg"></div>';
results.innerHTML=html;
results.querySelectorAll('.review-item input[type=radio]').forEach(r=>{
r.addEventListener('change',()=>syncReviewControls(r.closest('.review-item')));
});
reviewDirty=false;
results.querySelectorAll('.review-item input,.review-item select').forEach(el=>{
el.addEventListener('change',()=>{ reviewDirty=true; });
});
document.getElementById('saveReviewBtn').addEventListener('click',saveReviewDecisions);
document.getElementById('finalizeBtn').addEventListener('click',finalizeReview);
}
function reviewItemHtml(item,uid,prev){
prev=prev||{};
const p=item.payload||{};
const sev=p.severity||'medium';
let html='<div class="conflict '+escAttr(sev)+' review-item" data-id="'+escAttr(item.review_item_id)+'">'+
'<div class="row"><span class="cat">'+esc(p.category||item.kind)+'</span>'+
'<span><span class="pill '+(item.blocking?'blocking':'audit')+'">'+
(item.blocking?'blocking':'audit')+'</span> '+
(p.severity?'<span class="pill '+escAttr(sev)+'">'+esc(sev)+'</span>':'')+'</span></div>';
if(item.kind==='clean_cluster'){
html+='<div class="loc">'+esc(p.location||p.key||'(cluster)')+'</div>'+
'<div class="meta">Cluster '+esc(p.key||'')+' &middot; '+
esc((p.assertions||[]).length)+' assertions</div>';
}else{
html+='<div class="loc">'+esc(p.location||'')+'</div>'+
'<div class="desc">'+esc(p.description||'')+'</div>'+
(p.confidence?'<div class="meta">Confidence: '+esc(p.confidence)+'</div>':'');
if(p.evidence&&p.evidence.length){
html+='<div class="ev">'+p.evidence.map(e=>
'<div><span class="d">'+esc(e.discipline)+'</span> ('+sheetSpan(e.sheet)+'): "'+
esc(e.source_text)+'"</div>').join('')+'</div>';
}
}
if((item.reasons||[]).length){
html+='<div class="meta">Review triggers: '+esc(item.reasons.join(', '))+'</div>';
}
html+='<div class="review-controls">'+
REVIEW_DECISIONS.map(d=>'<label><input type="radio" name="dec-'+uid+'" value="'+d+'"'+
(prev.decision===d?' checked':'')+'> '+esc(d.replace(/_/g,' '))+'</label>').join('')+
'<select class="reason'+(prev.decision==='reject'?'':' hidden')+'">'+
'<option value="">Reason code (required for reject)...</option>'+
REVIEW_REASON_CODES.map(c=>'<option value="'+c+'"'+(prev.reason_code===c?' selected':'')+
'>'+esc(c.replace(/_/g,' '))+'</option>').join('')+'</select>'+
'<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>';
return html;
}
function syncReviewControls(el){
const sel=el.querySelector('input[type=radio]:checked');
const v=sel?sel.value:'';
el.querySelector('.reason').classList.toggle('hidden',v!=='reject');
if(v!=='reject') el.querySelector('.reason').value='';
el.querySelector('.clar').classList.toggle('hidden',v!=='needs_clarification');
}
function reviewMsg(m,isErr){
const el=document.getElementById('reviewMsg');
if(el){ el.style.color=isErr?'var(--hi)':'var(--muted)'; el.textContent=m; }
}
function collectReviewDecisions(){
const decisions=[], missingReason=[];
results.querySelectorAll('.review-item').forEach(el=>{
const id=el.getAttribute('data-id');
const sel=el.querySelector('input[type=radio]:checked');
if(!sel) return;
const reason=el.querySelector('.reason').value;
if(sel.value==='reject'&&!reason){ missingReason.push(id); return; }
const d={review_item_id:id, decision:sel.value,
comment:el.querySelector('.comment').value.trim()};
if(sel.value==='reject') d.reason_code=reason;
else if(reason) d.reason_code=reason;
if(sel.value==='needs_clarification')
d.clarification_answer=el.querySelector('.clar').value.trim()||null;
decisions.push(d);
});
return {decisions, missingReason};
}
async function postReviewDecisions(decisions){
const res=await fetch('/jobs/'+currentJobId+'/review-decisions',
{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({decisions})});
if(!res.ok){
const err=await res.json().catch(()=>({detail:res.statusText}));
const detail=typeof err.detail==='string'?err.detail:JSON.stringify(err.detail);
throw new Error(detail||'Request failed');
}
}
async function saveReviewDecisions(){
const {decisions,missingReason}=collectReviewDecisions();
if(missingReason.length){
reviewMsg('Reject requires a reason code: '+missingReason.join(', '),true); return;
}
if(!decisions.length){ reviewMsg('No decisions set yet.',true); return; }
try{
await postReviewDecisions(decisions);
renderReview({job_id:currentJobId});
}catch(err){ reviewMsg('Save failed: '+err.message,true); }
}
async function finalizeReview(){
reviewMsg('');
try{
if(reviewDirty){
// Auto-save unsaved control edits so they aren't lost at finalize.
const {decisions,missingReason}=collectReviewDecisions();
if(missingReason.length){
reviewMsg('Reject requires a reason code: '+missingReason.join(', '),true); return;
}
if(decisions.length) await postReviewDecisions(decisions);
reviewDirty=false;
}
const res=await fetch('/jobs/'+currentJobId+'/finalize-review',{method:'POST'});
if(res.status===409){
const err=await res.json().catch(()=>({}));
const d=err.detail||{};
const prog=d.progress?(' ('+(d.progress.remaining||0)+' required items undecided)'):'';
reviewMsg('Cannot finalize: '+(d.detail||'conflict')+prog,true); return;
}
if(!res.ok) throw new Error('Request failed ('+res.status+')');
statusEl.innerHTML='<span class="spinner"></span>Finalizing reviewed report...';
poll(currentJobId);
}catch(err){ reviewMsg('Finalize failed: '+err.message,true); }
}
// If opened from an email link (/?job=<id>), load that job's results directly.
(function init(){
const jobId=new URLSearchParams(location.search).get('job');