Files
Conflict_Checker/frontend/index.html
T
woogiandClaude Opus 5 23e19f53b2
Docker Release / build-and-push (push) Successful in 1m45s
Docker Release / release (push) Skipped
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
2026-09-14 10:35:18 -05:00

857 lines
42 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<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; }
#buildTag { display:inline-block; font-size:11px; background:rgba(91,140,255,.15); color:var(--accent); padding:2px 8px; border-radius:12px; margin-left:8px; vertical-align:middle; text-transform:none; letter-spacing:.3px; }
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); }
.email-card select { width:100%; padding:10px 12px; border-radius:8px; margin-top:6px;
border:1px solid var(--line); background:#0c0e13; color:var(--text); font-size:14px; }
.email-card .field { margin-top:12px; }
.email-card .field > span { display:block; font-size:13px; color:var(--muted); margin-bottom:2px; }
.btn.full { width:100%; padding:14px; font-size:15px; margin-top:0; }
.logbox { background:#0c0e13; border:1px solid var(--line); border-radius:8px; padding:12px 14px;
margin-top:10px; max-height:320px; overflow:auto; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
color:#c6cdd8; white-space:pre-wrap; word-break:break-word; }
.logbox .empty-log { color:var(--muted); }
.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; }
.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; }
.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">build ...</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">&#9993;&#65039; 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)</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>&#129302; Pipeline</label>
<label style="display:block;font-weight:400;margin-top:6px">
<input type="radio" name="pipeline_mode" value="classic" checked>
Classic pipeline &mdash; current production workflow</label>
<label style="display:block;font-weight:400;margin-top:6px">
<input type="radio" name="pipeline_mode" value="agent">
Agent pipeline &mdash; experimental specialist-agent workflow</label>
</div>
<div class="email-card">
<label>&#9881;&#65039; 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 &mdash; all stages (fastest, paid)</label>
<div id="modelPick" style="margin-top:10px">
<div class="field">
<span>Vision model <span class="opt">(image stages)</span> <span class="opt" id="modelNote">loading...</span></span>
<select id="vision_model" disabled><option value="">Loading models&hellip;</option></select>
</div>
<div class="field">
<span>Text model <span class="opt">(non-image stages)</span></span>
<select id="text_model" disabled><option value="">Loading models&hellip;</option></select>
</div>
</div>
</div>
<button class="btn full" id="run" disabled>Run conflict check</button>
<div class="status" id="status"></div>
<div id="liveLog" style="display:none" class="note">
<b>Run log</b> <span class="opt" id="logHint">(updates live)</span>
<pre class="logbox" id="logBox"><span class="empty-log">Waiting for output&hellip;</span></pre>
</div>
<div id="results"></div>
</main>
<div id="viewer">
<div id="viewerBar">
<span id="viewerTitle"></span>
<button type="button" onclick="zoomSheet(-0.25)">&minus;</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 &times;</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'),
visionSel=document.getElementById('vision_model'),
textSel=document.getElementById('text_model'),
liveLog=document.getElementById('liveLog'),
logBox=document.getElementById('logBox'),
logHint=document.getElementById('logHint');
let chosen=null, polling=null, currentJobId=null, sheetPage={}, viewerZoom=1, reviewDirty=false;
let defaultVisionModel='', defaultTextModel='';
function modelLabel(m){
// Include per-1M-token pricing when the catalog provides it.
let s=m.name||m.id;
if(m.prompt_usd_per_mtok!=null)
s+=' — $'+m.prompt_usd_per_mtok+' / $'+m.completion_usd_per_mtok+' per 1M tok';
return s;
}
function fillSelect(sel, items, preferred){
sel.innerHTML='';
(items||[]).forEach(m=>{
const opt=document.createElement('option');
opt.value=m.id; opt.textContent=modelLabel(m);
if(m.id===preferred) opt.selected=true;
sel.appendChild(opt);
});
if(!sel.options.length){
const opt=document.createElement('option');
opt.value=preferred||''; opt.textContent=preferred||'(no models)';
sel.appendChild(opt);
}
sel.disabled=false;
}
let modelsLoaded=false;
const MODELS_CACHE_KEY='cc_models_v1';
const MODELS_CACHE_TTL=24*60*60*1000;
function loadModelsCache(){
try{
const raw=localStorage.getItem(MODELS_CACHE_KEY);
if(!raw) return null;
const parsed=JSON.parse(raw);
if(!parsed.ts || Date.now()-parsed.ts > MODELS_CACHE_TTL) return null;
return parsed.data||null;
}catch(e){ return null; }
}
function saveModelsCache(data){
try{ localStorage.setItem(MODELS_CACHE_KEY, JSON.stringify({ts:Date.now(), data})); }
catch(e){}
}
function fetchWithTimeout(url, ms){
return Promise.race([
fetch(url, {cache:'no-store'}),
new Promise((_,reject)=>setTimeout(()=>reject(new Error('timeout')), ms))
]);
}
async function loadModels(){
const note=document.getElementById('modelNote');
const cached=loadModelsCache();
if(cached){
const defs=cached.defaults||{};
fillSelect(visionSel, cached.vision, defs.vision);
fillSelect(textSel, cached.text, defs.text);
modelsLoaded=true;
note.textContent='('+(cached.text||[]).length+' text / '+(cached.vision||[]).length+' vision cached)';
return;
}
try{
note.textContent='fetching models...';
const res=await fetchWithTimeout('/models', 5000);
if(!res.ok) throw new Error('models HTTP '+res.status);
const data=await res.json();
saveModelsCache(data);
const defs=data.defaults||{};
fillSelect(visionSel, data.vision, defs.vision);
fillSelect(textSel, data.text, defs.text);
modelsLoaded=true;
note.textContent='('+(data.text||[]).length+' text / '+(data.vision||[]).length+' vision available)';
}catch(err){
const fallback=[{id:defaultVisionModel||'', name:defaultVisionModel||'(default)', prompt_usd_per_mtok:null, completion_usd_per_mtok:null}];
fillSelect(visionSel, fallback.filter(m=>m.id), defaultVisionModel);
const fallbackText=[{id:defaultTextModel||'', name:defaultTextModel||'(default)', prompt_usd_per_mtok:null, completion_usd_per_mtok:null}];
fillSelect(textSel, fallbackText.filter(m=>m.id), defaultTextModel);
visionSel.disabled=false; textSel.disabled=false;
note.textContent='using configured defaults ('+(err.message==='timeout'?'fetch timed out':'list unavailable')+')';
console.warn('Could not load models:', err);
}
}
function showLog(lines, live){
liveLog.style.display='block';
logHint.textContent=live?'(updates live)':'(saved with this job)';
const arr=lines||[];
if(!arr.length){
logBox.innerHTML='<span class="empty-log">No log lines yet&hellip;</span>';
return;
}
logBox.textContent=arr.join('\n');
logBox.scrollTop=logBox.scrollHeight;
}
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...';
showLog([], true);
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');
if(compute==='openrouter'){
// Model picks only apply to OpenRouter compute; hybrid keeps its local model.
if(visionSel.value) fd.append('vision_model', visionSel.value);
if(textSel.value) fd.append('text_model', textSel.value);
}
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> &mdash; 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();
const live=['running','queued','finalizing'].includes(job.status);
if(job.log_tail && job.log_tail.length) showLog(job.log_tail, live);
if(job.status==='running'||job.status==='queued'){
statusEl.innerHTML='<span class="spinner"></span>'+esc(job.stage||'Working...')+
' &middot; you can leave this page';
} else if(job.status==='done'){
clearInterval(polling); polling=null; runBtn.disabled=false;
if(job.log && job.log.length) showLog(job.log, false);
render(job.report);
} else if(job.status==='needs_review'||job.status==='reviewing'){
clearInterval(polling); polling=null; runBtn.disabled=false;
if(job.log && job.log.length) showLog(job.log, 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');
if(job.log && job.log.length) showLog(job.log, false);
} else if(job.status==='error'){
clearInterval(polling); polling=null; runBtn.disabled=false;
statusEl.textContent='Run failed: '+(job.error||'unknown error');
if(job.log && job.log.length) showLog(job.log, false);
else if(job.log_tail && job.log_tail.length) showLog(job.log_tail, false);
}
}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=>({'&':'&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';
const local=document.querySelector('input[name="compute"][value="local"]');
if(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();
// --- model pickers (OpenRouter compute only) ---
function syncCompute(){
const openrouter=(document.querySelector('input[name="compute"]:checked')||{}).value==='openrouter';
document.getElementById('modelPick').style.display=openrouter?'block':'none';
if(openrouter&&!modelsLoaded) loadModels();
}
document.querySelectorAll('input[name="compute"]').forEach(el=>el.addEventListener('change',syncCompute));
syncCompute();
// --- 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(', ') || '&mdash;'; }
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(); });
// --- conflicts grouped by discipline pair ---
const SEV_RANK={critical:0,high:1,medium:2,low:3};
function sevRank(c){ const r=SEV_RANK[(c.severity||'').toLowerCase()]; return r==null?4:r; }
function groupConflicts(conflicts){
// Group key: disciplines sorted alphabetically, joined ' vs ' (order-independent
// pair). Missing disciplines -> 'General'. Groups ordered by their most severe
// conflict, then name; items within a group ordered critical->high->medium->low.
const groups={};
for(const c of conflicts||[]){
const ds=(c.disciplines||[]).map(d=>String(d)).filter(Boolean).sort();
const key=ds.length?ds.join(' vs '):'General';
(groups[key]=groups[key]||[]).push(c);
}
const names=Object.keys(groups).sort((a,b)=>{
const ra=Math.min.apply(null,groups[a].map(sevRank)),
rb=Math.min.apply(null,groups[b].map(sevRank));
return (ra-rb)||a.localeCompare(b);
});
return names.map(name=>({name:name,
items:groups[name].slice().sort((x,y)=>sevRank(x)-sevRank(y))}));
}
function conflictCard(c){
let 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 '))+
' &middot; 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>'; }
return html+'</div>';
}
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 g of groupConflicts(rep.conflicts)){
html+='<details open style="margin-top:16px"><summary><b>'+esc(g.name)+' ('+g.items.length+')</b></summary>';
for(const c of g.items){ html+=conflictCard(c); }
html+='</details>';
}
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 + drawing integrity + full-set + 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>'+
(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>';
}
html+='<div class="meta" style="margin-top:14px">Full run log: <a href="/jobs/'+
esc(currentJobId)+'/log?plain=1" target="_blank" rel="noopener">/jobs/'+
esc(currentJobId)+'/log</a> (also saved as job.log on the server)</div>';
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>';
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 &mdash; '+
'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){
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>'+
'<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')));
});
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);
wireChatPanels();
loadChatHistory();
}
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((p.sheets||[]).length){
html+='<div class="meta">Sheets: '+sheetList(p.sheets)+'</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>'+
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?' &mdash; '+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:'';
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(){
// Fetch /health first: build tag for the header and default models as a
// fallback if the larger /models catalog fails or times out.
fetch('/health', {cache:'no-store'}).then(r=>r.ok?r.json():null).then(h=>{
if(h){
if(h.build) document.getElementById('buildTag').textContent=' · build '+h.build;
if(h.model) defaultVisionModel=h.model;
if(h.text_model) defaultTextModel=h.text_model;
}
}).catch(()=>{}).finally(()=>{
loadModels();
});
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>