Add job run logs, OpenRouter model picker, and discipline grouping.
- 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:
+76
-14
@@ -120,6 +120,10 @@
|
||||
<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 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)+' — $'+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 '))+
|
||||
' · 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 '))+
|
||||
' · 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||[];
|
||||
|
||||
Reference in New Issue
Block a user