Initial commit: Conflict Checker

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>
This commit is contained in:
2026-07-03 00:22:02 +00:00
co-authored by Claude Opus 4.8
commit 1d248a8808
262 changed files with 8209 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
<!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">&#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 &mdash; 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>&#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>
<label style="display:block;font-weight:400;margin-top:6px">
<input type="radio" name="compute" value="local"> Hybrid &mdash; 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)">&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');
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> &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();
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; 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=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[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(', ') || '&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(); });
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 '))+
' &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>'; }
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)+' &middot; '+esc(c.category)+'</span>'+
'<span class="pill '+esc(c.severity)+'">'+esc(c.severity)+
(c.risk_score!=null?(' &middot; risk '+esc(c.risk_score)):'')+'</span></div>'+
'<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>