688 lines
34 KiB
HTML
688 lines
34 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; }
|
|
.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">✉️ 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>
|
|
<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…</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…</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…</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)">−</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'),
|
|
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;
|
|
|
|
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){}
|
|
}
|
|
|
|
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{
|
|
const res=await fetch('/models');
|
|
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){
|
|
visionSel.innerHTML='<option value="">(default)</option>';
|
|
textSel.innerHTML='<option value="">(default)</option>';
|
|
visionSel.disabled=false; textSel.disabled=false;
|
|
note.textContent='using configured defaults (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…</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> — 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...')+
|
|
' · 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=>({'&':'&','<':'<','>':'>'}[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();
|
|
|
|
// --- 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(', ') || '—'; }
|
|
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 '))+
|
|
' · 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 + 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>';
|
|
}
|
|
|
|
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>';
|
|
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((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></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(()=>{});
|
|
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>
|