APP_BASE_URL default (config, .env.example, both compose files) is now https://conchecker.scoutitsystems.com with no port, so review-required and final-report email links use the public site. CI bakes the short commit SHA into the image as APP_BUILD via a Docker build-arg; /health returns version+build and the site header shows the build so it's easy to confirm which image is deployed. Local runs default to 'dev'.
517 lines
27 KiB
HTML
517 lines
27 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Conflict Checker</title>
|
|
<style>
|
|
:root {
|
|
--bg:#0f1115; --panel:#181b22; --line:#272b34; --text:#e6e8ec; --muted:#9aa3b2;
|
|
--hi:#ff5d57; --med:#ffb020; --lo:#3da5ff; --accent:#5b8cff;
|
|
}
|
|
* { box-sizing:border-box; }
|
|
body { margin:0; background:var(--bg); color:var(--text);
|
|
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
|
|
header { padding:24px 28px; border-bottom:1px solid var(--line); }
|
|
h1 { margin:0; font-size:20px; letter-spacing:.2px; }
|
|
.sub { color:var(--muted); font-size:13px; margin-top:4px; }
|
|
main { max-width:920px; margin:0 auto; padding:28px; }
|
|
.drop { border:1.5px dashed var(--line); border-radius:12px; padding:36px; text-align:center;
|
|
background:var(--panel); transition:border-color .15s; cursor:pointer; }
|
|
.drop.drag { border-color:var(--accent); }
|
|
.drop input { display:none; }
|
|
.btn { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:10px 18px;
|
|
font-size:14px; cursor:pointer; margin-top:14px; }
|
|
.btn:disabled { opacity:.5; cursor:default; }
|
|
.status { margin:18px 0; color:var(--muted); font-size:14px; min-height:20px; }
|
|
.spinner { display:inline-block; width:14px; height:14px; border:2px solid var(--line);
|
|
border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite;
|
|
vertical-align:-2px; margin-right:8px; }
|
|
@keyframes spin { to { transform:rotate(360deg); } }
|
|
.summary { display:flex; gap:18px; flex-wrap:wrap; margin:20px 0; }
|
|
.stat { background:var(--panel); border:1px solid var(--line); border-radius:10px;
|
|
padding:12px 16px; min-width:96px; }
|
|
.stat b { display:block; font-size:22px; }
|
|
.stat span { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.4px; }
|
|
.conflict { background:var(--panel); border:1px solid var(--line); border-left-width:4px;
|
|
border-radius:10px; padding:16px 18px; margin:12px 0; }
|
|
.conflict.high { border-left-color:var(--hi); }
|
|
.conflict.medium { border-left-color:var(--med); }
|
|
.conflict.low { border-left-color:var(--lo); }
|
|
.row { display:flex; justify-content:space-between; align-items:baseline; gap:12px; }
|
|
.cat { font-size:12px; text-transform:uppercase; letter-spacing:.5px; color:var(--muted); }
|
|
.pill { font-size:11px; padding:2px 8px; border-radius:20px; text-transform:uppercase; letter-spacing:.4px; }
|
|
.pill.high { background:rgba(255,93,87,.15); color:var(--hi); }
|
|
.pill.medium { background:rgba(255,176,32,.15); color:var(--med); }
|
|
.pill.low { background:rgba(61,165,255,.15); color:var(--lo); }
|
|
.loc { font-size:16px; font-weight:600; margin:4px 0 6px; }
|
|
.desc { margin:6px 0; }
|
|
.meta { color:var(--muted); font-size:13px; }
|
|
.ev { margin:8px 0 0; padding:10px 12px; background:#11141a; border-radius:8px; font-size:13px; }
|
|
.ev div { margin:2px 0; }
|
|
.ev .d { color:var(--accent); }
|
|
.reso { margin-top:8px; font-size:13px; color:var(--muted); }
|
|
.empty { text-align:center; color:var(--muted); padding:40px; }
|
|
.email-card { background:var(--panel); border:1px solid var(--line); border-left:4px solid var(--accent);
|
|
border-radius:12px; padding:18px 20px; margin:16px 0; text-align:left; }
|
|
.email-card label { display:block; font-size:15px; font-weight:600; color:var(--text); margin-bottom:10px; }
|
|
.email-card label .opt { color:var(--muted); font-weight:400; font-size:13px; }
|
|
.email-card input[type=email] { width:100%; padding:13px 14px; border-radius:8px;
|
|
border:1px solid var(--line); background:#0c0e13; color:var(--text); font-size:16px; outline:none; }
|
|
.email-card input[type=email]::placeholder { color:#6b7280; }
|
|
.email-card input[type=email]:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(91,140,255,.18); }
|
|
.btn.full { width:100%; padding:14px; font-size:15px; margin-top:0; }
|
|
.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;
|
|
flex-direction:column; align-items:center; z-index:1000; }
|
|
#viewer.open { display:flex; }
|
|
#viewerBar { color:#fff; padding:10px 14px; display:flex; gap:14px; align-items:center; font-size:14px; }
|
|
#viewerBar button { background:#1d2230; color:#fff; border:1px solid #3a4255; border-radius:6px;
|
|
padding:6px 12px; cursor:pointer; font-size:14px; }
|
|
#viewerImgWrap { overflow:auto; max-width:97vw; max-height:84vh; border:1px solid #3a4255; background:#fff; }
|
|
#viewerImg { transform-origin:top left; display:block; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Conflict Checker</h1>
|
|
<div class="sub">Cross-discipline design contradiction review for construction drawing sets<span id="buildTag"></span></div>
|
|
</header>
|
|
<main>
|
|
<div class="drop" id="drop">
|
|
<input type="file" id="file" accept="application/pdf" />
|
|
<div id="dropLabel" style="cursor:pointer">Drop a PDF drawing set here, or click to choose</div>
|
|
</div>
|
|
<div class="email-card">
|
|
<label for="email">✉️ Email me the report when it's done <span class="opt">(optional)</span></label>
|
|
<input type="email" id="email" placeholder="you@firm.com" />
|
|
</div>
|
|
<details class="email-card" id="intake">
|
|
<summary style="cursor:pointer">Project details <span class="opt">(optional — improves code/ADA review)</span></summary>
|
|
<input type="text" id="project_name" placeholder="Project name" style="width:100%;margin-top:8px" />
|
|
<input type="text" id="address" placeholder="Project address" style="width:100%;margin-top:8px" />
|
|
<input type="text" id="occupancy" placeholder="Occupancy (e.g. Business, Assembly)" style="width:100%;margin-top:8px" />
|
|
<input type="text" id="work_type" placeholder="Work type (new building, remodel, TI, addition)" style="width:100%;margin-top:8px" />
|
|
</details>
|
|
<div class="email-card">
|
|
<label>🤖 Pipeline</label>
|
|
<label style="display:block;font-weight:400;margin-top:6px">
|
|
<input type="radio" name="pipeline_mode" value="classic" checked>
|
|
Classic pipeline — current production workflow</label>
|
|
<label style="display:block;font-weight:400;margin-top:6px">
|
|
<input type="radio" name="pipeline_mode" value="agent">
|
|
Agent pipeline — experimental specialist-agent workflow</label>
|
|
</div>
|
|
<div class="email-card">
|
|
<label>⚙️ Compute <span class="opt">(text stages; vision always runs on the API)</span></label>
|
|
<label style="display:block;font-weight:400;margin-top:6px">
|
|
<input type="radio" name="compute" value="openrouter" checked> OpenRouter — all stages (fastest, paid)</label>
|
|
<label style="display:block;font-weight:400;margin-top:6px">
|
|
<input type="radio" name="compute" value="local"> Hybrid — text stages on local LLM (cheaper, slower)</label>
|
|
</div>
|
|
<button class="btn full" id="run" disabled>Run conflict check</button>
|
|
<div class="status" id="status"></div>
|
|
<div id="results"></div>
|
|
</main>
|
|
<div id="viewer">
|
|
<div id="viewerBar">
|
|
<span id="viewerTitle"></span>
|
|
<button type="button" onclick="zoomSheet(-0.25)">−</button>
|
|
<button type="button" onclick="zoomSheet(0.25)">+</button>
|
|
<button type="button" onclick="zoomSheet(0)">Fit</button>
|
|
<a id="viewerOpen" href="#" target="_blank" rel="noopener"><button type="button">Open full</button></a>
|
|
<button type="button" onclick="closeSheet()">Close ×</button>
|
|
</div>
|
|
<div id="viewerImgWrap"><img id="viewerImg" alt="sheet" /></div>
|
|
</div>
|
|
<script>
|
|
const drop=document.getElementById('drop'), fileInput=document.getElementById('file'),
|
|
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, 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; }
|
|
dropLabel.addEventListener('click',()=>fileInput.click());
|
|
fileInput.addEventListener('change',e=>setFile(e.target.files[0]));
|
|
['dragover','dragenter'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('drag');}));
|
|
['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('drag');}));
|
|
drop.addEventListener('drop',e=>{ if(e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]); });
|
|
|
|
runBtn.addEventListener('click',async e=>{
|
|
e.preventDefault();
|
|
if(!chosen) return;
|
|
runBtn.disabled=true; results.innerHTML='';
|
|
statusEl.innerHTML='<span class="spinner"></span>Uploading...';
|
|
const fd=new FormData(); fd.append('file',chosen);
|
|
const email=(emailEl.value||'').trim(); if(email) fd.append('notification_email',email);
|
|
['project_name','address','occupancy','work_type'].forEach(id=>{
|
|
const v=(document.getElementById(id).value||'').trim(); if(v) fd.append(id,v);
|
|
});
|
|
const compute=(document.querySelector('input[name="compute"]:checked')||{}).value;
|
|
fd.append('text_local', compute==='local' ? 'true' : 'false');
|
|
const pipelineMode=(document.querySelector('input[name="pipeline_mode"]:checked')||{}).value||'classic';
|
|
fd.append('pipeline_mode',pipelineMode);
|
|
try{
|
|
const res=await fetch('/check',{method:'POST',body:fd});
|
|
if(!res.ok){ const err=await res.json().catch(()=>({detail:res.statusText}));
|
|
throw new Error(err.detail||'Request failed'); }
|
|
const {job_id}=await res.json();
|
|
history.replaceState(null,'','/?job='+job_id);
|
|
if(email){
|
|
results.innerHTML='<div class="note">Your check is running in the background. '+
|
|
'You can <b>close this page</b> — we\'ll email <b>'+esc(email)+
|
|
'</b> when it\'s done. This page will also update live if you stay.</div>';
|
|
}
|
|
poll(job_id);
|
|
}catch(err){ statusEl.textContent='Error: '+err.message; runBtn.disabled=false; }
|
|
});
|
|
|
|
function poll(jobId){
|
|
currentJobId=jobId;
|
|
if(polling) clearInterval(polling);
|
|
const tick=async()=>{
|
|
try{
|
|
const res=await fetch('/jobs/'+jobId);
|
|
if(!res.ok) throw new Error('job not found');
|
|
const job=await res.json();
|
|
if(job.status==='running'||job.status==='queued'){
|
|
statusEl.innerHTML='<span class="spinner"></span>'+esc(job.stage||'Working...')+
|
|
' · 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');
|
|
}
|
|
}catch(err){ clearInterval(polling); polling=null; runBtn.disabled=false;
|
|
statusEl.textContent='Error: '+err.message; }
|
|
};
|
|
tick(); polling=setInterval(tick,3000);
|
|
}
|
|
|
|
function esc(s){ return (s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); }
|
|
function escAttr(s){ return esc(s).replace(/"/g,'"'); }
|
|
|
|
function syncPipelineOptions(){
|
|
const agent=(document.querySelector('input[name="pipeline_mode"]:checked')||{}).value==='agent';
|
|
const local=document.querySelector('input[name="compute"][value="local"]');
|
|
local.disabled=agent;
|
|
if(agent&&local.checked) document.querySelector('input[name="compute"][value="openrouter"]').checked=true;
|
|
}
|
|
document.querySelectorAll('input[name="pipeline_mode"]').forEach(el=>el.addEventListener('change',syncPipelineOptions));
|
|
syncPipelineOptions();
|
|
|
|
// --- sheet viewer ---
|
|
function pageFor(num){ return sheetPage[num] || sheetPage[(num||'').toUpperCase()] || null; }
|
|
function sheetSpan(num){
|
|
const t=esc(num); const p=pageFor(num);
|
|
if(!p || !currentJobId) return t;
|
|
return '<span class="sheetlink" onclick="openSheet('+p+',\''+t.replace(/'/g,'')+'\')">'+t+'</span>';
|
|
}
|
|
function sheetList(arr){ return (arr||[]).map(sheetSpan).join(', ') || '—'; }
|
|
function openSheet(page,label){
|
|
viewerZoom=1;
|
|
document.getElementById('viewerTitle').textContent='Sheet '+label+' (page '+page+')';
|
|
const url='/jobs/'+currentJobId+'/sheet-image/'+page;
|
|
const img=document.getElementById('viewerImg');
|
|
img.src=url; img.style.transform='scale(1)';
|
|
document.getElementById('viewerOpen').href=url;
|
|
document.getElementById('viewer').classList.add('open');
|
|
}
|
|
function zoomSheet(d){
|
|
viewerZoom = d===0 ? 1 : Math.min(5, Math.max(0.25, viewerZoom+d));
|
|
document.getElementById('viewerImg').style.transform='scale('+viewerZoom+')';
|
|
}
|
|
function closeSheet(){ document.getElementById('viewer').classList.remove('open');
|
|
document.getElementById('viewerImg').src=''; }
|
|
document.getElementById('viewer').addEventListener('click',e=>{ if(e.target.id==='viewer') closeSheet(); });
|
|
document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeSheet(); });
|
|
|
|
function render(rep){
|
|
const s=rep.summary;
|
|
if(!currentJobId) currentJobId=new URLSearchParams(location.search).get('job');
|
|
sheetPage={}; (rep.sheets||[]).forEach(sh=>{ if(sh.sheet_number) sheetPage[sh.sheet_number]=sh.page_number; });
|
|
const mu=s.models_used||{};
|
|
const visionModel=(mu.vision||[]).join(', ');
|
|
const textModel=(mu.text_local&&mu.text_local.length)?mu.text_local.join(', '):(mu.text_cloud||[]).join(', ');
|
|
const fallbacks=mu.fallback_count||0;
|
|
let modelLine='';
|
|
if(visionModel||textModel){
|
|
modelLine=' · vision: '+esc(visionModel||'?');
|
|
if(textModel) modelLine+=' · text: '+esc(textModel);
|
|
if(fallbacks) modelLine+=' ('+fallbacks+' cloud fallback'+(fallbacks>1?'s':'')+')';
|
|
}
|
|
const mode=s.pipeline_mode||'classic';
|
|
statusEl.textContent=(mode==='agent'?'Agent':'Classic')+' pipeline analyzed '+s.sheets_analyzed+
|
|
' sheets ('+(s.disciplines.join(', ')||'none')+')'+modelLine+'.';
|
|
let html='<div class="summary">'+
|
|
stat(s.conflicts_found,'conflicts')+
|
|
stat(s.by_severity.high,'high')+
|
|
stat(s.by_severity.medium,'medium')+
|
|
stat(s.by_severity.low,'low')+
|
|
stat(s.assertions_extracted,'facts')+
|
|
stat(s.clusters_checked,'clusters')+
|
|
(s.cost_usd!=null?stat('$'+Number(s.cost_usd).toFixed(2),'cost'):'')+'</div>';
|
|
if(s.agent_status==='skeleton'){
|
|
html+='<div class="note"><b>Agent pipeline skeleton:</b> routing and artifacts are active; '+
|
|
'specialist analysis is added in the next implementation phases.</div>';
|
|
} else if(!rep.conflicts.length){
|
|
html+='<div class="empty">No cross-discipline conflicts detected.</div>';
|
|
}
|
|
for(const c of rep.conflicts){
|
|
html+='<div class="conflict '+esc(c.severity)+'">'+
|
|
'<div class="row"><span class="cat">'+esc(c.category)+'</span>'+
|
|
'<span class="pill '+esc(c.severity)+'">'+esc(c.severity)+'</span></div>'+
|
|
'<div class="loc">'+esc(c.location)+'</div>'+
|
|
'<div class="meta">'+esc((c.disciplines||[]).join(' vs '))+
|
|
' · sheets '+sheetList(c.sheets)+'</div>'+
|
|
'<div class="desc">'+esc(c.description)+'</div>';
|
|
if(c.evidence&&c.evidence.length){
|
|
html+='<div class="ev">'+c.evidence.map(e=>
|
|
'<div><span class="d">'+esc(e.discipline)+'</span> ('+sheetSpan(e.sheet)+'): "'+esc(e.source_text)+'"</div>').join('')+'</div>';
|
|
}
|
|
if(c.recommended_resolution){ html+='<div class="reso">Resolution: '+esc(c.recommended_resolution)+'</div>'; }
|
|
html+='</div>';
|
|
}
|
|
|
|
const issues=rep.validated_issues||[];
|
|
if(issues.length){
|
|
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)+' · '+esc(c.category)+'</span>'+
|
|
'<span class="pill '+esc(c.severity)+'">'+esc(c.severity)+
|
|
(c.risk_score!=null?(' · 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>';
|
|
if(c.code_reference){ html+='<div class="meta">Code: '+esc(c.code_reference)+'</div>'; }
|
|
if(c.recommended_resolution){ html+='<div class="reso">Resolution: '+esc(c.recommended_resolution)+'</div>'; }
|
|
html+='</div>';
|
|
}
|
|
html+='</details>';
|
|
}
|
|
|
|
const rfis=rep.rfis||[];
|
|
if(rfis.length){
|
|
html+='<details style="margin-top:16px"><summary><b>Draft RFIs ('+rfis.length+')</b></summary>';
|
|
for(const r of rfis){
|
|
html+='<div class="conflict">'+
|
|
'<div class="row"><span class="cat">'+esc(r.title||'RFI')+'</span>'+
|
|
'<span class="pill '+esc(r.priority)+'">'+esc(r.priority)+'</span></div>'+
|
|
'<div class="desc">'+esc(r.question)+'</div>';
|
|
if(r.background){ html+='<div class="meta">'+esc(r.background)+'</div>'; }
|
|
if((r.sheets_referenced||[]).length){ html+='<div class="meta">Sheets: '+sheetList(r.sheets_referenced)+'</div>'; }
|
|
html+='</div>';
|
|
}
|
|
html+='</details>';
|
|
}
|
|
|
|
results.innerHTML=html;
|
|
}
|
|
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 — 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 & 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||'')+' · '+
|
|
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(){
|
|
// Show the deployed build in the header so it's obvious which version is up.
|
|
fetch('/health').then(r=>r.ok?r.json():null).then(h=>{
|
|
if(h&&h.build) document.getElementById('buildTag').textContent=' · build '+h.build;
|
|
}).catch(()=>{});
|
|
const jobId=new URLSearchParams(location.search).get('job');
|
|
if(jobId){ statusEl.innerHTML='<span class="spinner"></span>Loading job '+esc(jobId)+'...'; poll(jobId); }
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|