Cross-discipline design-contradiction checker for construction drawing sets. Standalone tool broken out from Iron_Bid; a pipeline stage may later fold back into Iron_Bid. Pipeline: PDF->images -> per-sheet assertion extraction -> deterministic clustering by location -> per-cluster reasoning -> report. Includes CLI (cli/run_check.py) and web UI (backend/main.py). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
301 lines
16 KiB
HTML
301 lines
16 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); }
|
|
.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</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>⚙️ 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;
|
|
|
|
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');
|
|
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==='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])); }
|
|
|
|
// --- 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':'')+')';
|
|
}
|
|
statusEl.textContent='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(!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){
|
|
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></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>'; }
|
|
|
|
// If opened from an email link (/?job=<id>), load that job's results directly.
|
|
(function init(){
|
|
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>
|