Add job run logs, OpenRouter model picker, and discipline grouping.
Docker Release / build-and-push (push) Successful in 55s
Docker Release / release (push) Skipped

- Job logs: each job's stdout/stderr is teed into outputs/<id>/job.log
  (survives restarts) and served at GET /jobs/{id}/log as text/plain, so
  full run logs can be shared for debugging and refinement.
- Model picker: GET /models proxies OpenRouter's public model list with
  per-1M-token pricing (1h cache, 502 on failure); the UI shows a model
  dropdown with costs when OpenRouter compute is selected, and the pick
  overrides vision+text models for that job (Classic and Agent modes).
- Conflicts in the report view are grouped by discipline pair
  (collapsible sections, severity-ordered within groups) instead of one
  flat severity-only list.
This commit is contained in:
John Wilganowski
2026-07-28 21:23:42 +00:00
parent 4ecc7c5cef
commit afa1089311
8 changed files with 441 additions and 55 deletions
+76 -14
View File
@@ -120,6 +120,10 @@
<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 id="modelPick" style="margin-top:10px">
<label for="model" style="font-weight:400">Model <span class="opt" id="modelNote">loading...</span></label>
<select id="model" style="width:100%;margin-top:6px;padding:10px;border-radius:8px;border:1px solid var(--line);background:#0c0e13;color:var(--text)"></select>
</div>
</div>
<button class="btn full" id="run" disabled>Run conflict check</button>
<div class="status" id="status"></div>
@@ -163,6 +167,10 @@ runBtn.addEventListener('click',async e=>{
});
const compute=(document.querySelector('input[name="compute"]:checked')||{}).value;
fd.append('text_local', compute==='local' ? 'true' : 'false');
if(compute==='openrouter'){
const modelSel=document.getElementById('model');
if(modelSel.value) fd.append('model', modelSel.value);
}
const pipelineMode=(document.querySelector('input[name="pipeline_mode"]:checked')||{}).value||'classic';
fd.append('pipeline_mode',pipelineMode);
try{
@@ -222,6 +230,33 @@ function syncPipelineOptions(){
document.querySelectorAll('input[name="pipeline_mode"]').forEach(el=>el.addEventListener('change',syncPipelineOptions));
syncPipelineOptions();
// --- model picker (OpenRouter compute only) ---
let modelList=null;
async function loadModels(){
const note=document.getElementById('modelNote'), sel=document.getElementById('model');
try{
const res=await fetch('/models');
if(!res.ok) throw new Error('list unavailable');
const data=await res.json();
modelList=data.models||[];
sel.innerHTML=modelList.map(m=>
'<option value="'+escAttr(m.id)+'"'+(m.id===data.default?' selected':'')+'>'+
esc(m.name||m.id)+' &mdash; $'+esc(m.prompt_usd_per_mtok)+' / $'+esc(m.completion_usd_per_mtok)+
' per 1M tok</option>').join('');
note.textContent='('+modelList.length+' available)';
}catch(e){
sel.innerHTML='';
note.textContent='using configured default (list unavailable)';
}
}
function syncCompute(){
const openrouter=(document.querySelector('input[name="compute"]:checked')||{}).value==='openrouter';
document.getElementById('modelPick').style.display=openrouter?'block':'none';
if(openrouter&&!modelList) 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){
@@ -248,6 +283,43 @@ function closeSheet(){ document.getElementById('viewer').classList.remove('open'
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');
@@ -279,20 +351,10 @@ function render(rep){
} else if(!rep.conflicts.length){
html+='<div class="empty">No cross-discipline conflicts detected.</div>';
}
for(const c of rep.conflicts){
html+='<div class="conflict '+esc(c.severity)+'">'+
'<div class="row"><span class="cat">'+esc(c.category)+'</span>'+
'<span class="pill '+esc(c.severity)+'">'+esc(c.severity)+'</span></div>'+
'<div class="loc">'+esc(c.location)+'</div>'+
'<div class="meta">'+esc((c.disciplines||[]).join(' vs '))+
' &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>';
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||[];