项目文件夹

文件
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

254 行
13 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* Shared rendering for the PBL v2 A/B comparison viewer.
*
* Two consumers:
* - `build-compare.ts` / the runner → `buildCompareHtml(runDir)` writes a
* self-contained static `compare.html` (data embedded).
* - `serve.ts` → a small dev server that lists every run under `results/`
* and loads each run's JSON on demand (data fetched).
*
* Both share `VIEWER_CSS` + `CLIENT_RENDER_JS` so the rendering stays in one
* place. `collectRunData(runDir)` reads `projects/<case>__<variant>.json`
* plus the optional `results.json` (judge scores) into one blob.
*/
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
import { join } from 'path';
export interface EmbeddedVariant {
project?: unknown;
result?: unknown;
}
export interface EmbeddedCase {
id: string;
variants: Record<string, EmbeddedVariant>;
}
export interface EmbeddedData {
runDir: string;
cases: EmbeddedCase[];
}
function safeReadJson(path: string): unknown | undefined {
try {
return JSON.parse(readFileSync(path, 'utf-8'));
} catch {
return undefined;
}
}
/** Collect `<case>__<variant>.json` + results.json for a run into one blob. */
export function collectRunData(runDir: string): EmbeddedData {
const projectsDir = join(runDir, 'projects');
const cases = new Map<string, EmbeddedCase>();
if (existsSync(projectsDir)) {
for (const file of readdirSync(projectsDir)) {
if (!file.endsWith('.json')) continue;
const base = file.slice(0, -'.json'.length);
const sep = base.lastIndexOf('__');
if (sep === -1) continue;
const caseId = base.slice(0, sep);
const variant = base.slice(sep + 2);
if (!cases.has(caseId)) cases.set(caseId, { id: caseId, variants: {} });
cases.get(caseId)!.variants[variant] = {
...cases.get(caseId)!.variants[variant],
project: safeReadJson(join(projectsDir, file)),
};
}
}
const results = safeReadJson(join(runDir, 'results.json'));
if (Array.isArray(results)) {
for (const r of results as Array<Record<string, unknown>>) {
const caseId = String(r.caseId ?? '');
const variant = String(r.variant ?? '');
if (!caseId || !variant) continue;
if (!cases.has(caseId)) cases.set(caseId, { id: caseId, variants: {} });
const v = cases.get(caseId)!.variants[variant] ?? {};
v.result = r;
cases.get(caseId)!.variants[variant] = v;
}
}
return {
runDir,
cases: [...cases.values()].sort((a, b) => a.id.localeCompare(b.id)),
};
}
/** Embed JSON safely inside a <script> tag. */
function embed(data: unknown): string {
return JSON.stringify(data).replace(/</g, '\\u003c');
}
export const VIEWER_CSS = `
:root { --bg:#0f1115; --panel:#171a21; --border:#2a2f3a; --fg:#e6e9ef; --muted:#9aa3b2;
--accent:#6ea8fe; --good:#3fb950; --warn:#d29922; --bad:#f85149; }
* { box-sizing: border-box; }
body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
background:var(--bg); color:var(--fg); }
header { padding:10px 16px; border-bottom:1px solid var(--border); display:flex; gap:14px; align-items:center; flex-wrap:wrap; }
header h1 { font-size:15px; margin:0; font-weight:600; }
header .meta { color:var(--muted); font-size:12px; }
select { background:var(--panel); color:var(--fg); border:1px solid var(--border); border-radius:6px; padding:5px 8px; font-size:12px; max-width:60vw; }
.layout { display:flex; height:calc(100vh - 50px); }
nav { width:240px; border-right:1px solid var(--border); overflow:auto; flex:0 0 auto; }
nav button { display:block; width:100%; text-align:left; background:none; border:none; color:var(--fg);
padding:10px 14px; cursor:pointer; border-bottom:1px solid var(--border); font-size:13px; }
nav button:hover { background:var(--panel); }
nav button.active { background:#1f2530; border-left:3px solid var(--accent); }
nav .ov { float:right; color:var(--muted); font-variant-numeric:tabular-nums; }
main { flex:1; overflow:auto; padding:16px; }
.cols { display:grid; grid-template-columns:1fr 1fr; gap:16px; align-items:start; }
.col { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:14px; }
.col h2 { margin:0 0 4px; font-size:14px; }
.judge { font-size:12px; color:var(--muted); margin:6px 0 10px; border:1px solid var(--border);
border-radius:6px; padding:8px; background:#10131a; }
.judge .dims { display:grid; grid-template-columns:1fr auto 1fr auto; gap:2px 10px; margin-top:6px; }
.pill { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; font-weight:600; }
.pill.good { background:rgba(63,185,80,.15); color:var(--good); }
.pill.warn { background:rgba(210,153,34,.15); color:var(--warn); }
.pill.bad { background:rgba(248,81,73,.15); color:var(--bad); }
.redlines { margin-top:6px; }
.redlines .pill { margin:2px 4px 0 0; background:rgba(248,81,73,.15); color:var(--bad); }
.proj-title { font-size:15px; font-weight:600; margin:2px 0; }
.desc { color:var(--muted); margin:2px 0 8px; }
.kv { font-size:12px; color:var(--muted); margin:2px 0; }
.ms { border:1px solid var(--border); border-radius:6px; margin:8px 0; }
.ms > summary { cursor:pointer; padding:8px 10px; font-weight:600; list-style:none; }
.ms > summary::-webkit-details-marker { display:none; }
.ms[open] > summary { border-bottom:1px solid var(--border); }
.ms .body { padding:8px 10px; }
.script { font-size:12px; margin:3px 0; }
.script b { color:var(--accent); }
.core { font-size:12px; color:var(--warn); margin:4px 0; }
.mt { border-left:2px solid var(--border); padding:4px 0 4px 10px; margin:8px 0; }
.mt .t { font-weight:600; }
.mt .d { color:var(--muted); font-size:13px; }
.mt ul { margin:4px 0 0; padding-left:18px; }
.mt li { color:#c8cfdb; font-size:12.5px; }
.doc { font-size:12px; color:var(--muted); margin-top:6px; }
.missing { color:var(--bad); }
`;
/** Client-side render functions. Exposes `renderRun(data)` which fills
* #meta, #nav and #main. Pure DOM, no framework. */
export const CLIENT_RENDER_JS = `
const VARIANTS = ['loop','single-call'];
const DIMS = ['projectNotLecture','taskEvaluability','typeFit','granularity','coherence','topicFidelity',
'singleConcreteOutcome','difficultyProgressionAndFit','learnerAgency','authenticWorkflow','stageIntegrity','closureAndConsolidation'];
const esc = s => String(s ?? '').replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
function overallPill(o){
if (o == null) return '';
const cls = o >= 4 ? 'good' : o >= 3 ? 'warn' : 'bad';
return '<span class="pill '+cls+'">overall '+o+'</span>';
}
function completabilityBlock(result){
const c = result && result.completability;
if (!c) return '<div class="judge">可完成性未评分(生成失败、gate 未过或旧 run</div>';
const cls = c.pass ? 'good' : 'bad';
const blockers = c.blockers || [];
const blockerHtml = blockers.length
? '<div class="redlines">blockers '+blockers.map(x=>'<span class="pill">'+esc(x)+'</span>').join('')+'</div>'
: '<div class="redlines"><span class="pill good">无 blocker</span></div>';
return '<div class="judge"><span class="pill '+cls+'">complete '+(c.pass?'PASS':'FAIL')+' '+c.score+'</span>'
+ '<span class="pill '+(c.riskLevel==='low'?'good':c.riskLevel==='medium'?'warn':'bad')+'" style="margin-left:4px">risk '+esc(c.riskLevel||'?')+'</span>'
+ blockerHtml
+ '<div style="margin-top:6px">'+esc(c.rationale||'')+'</div></div>';
}
function judgeBlock(result){
const j = result && result.judge;
if (!j) return '<div class="judge">未评分(生成失败或 gate 未过)</div>';
let dims = '<div class="dims">';
for (const d of DIMS){ const v=j.scores?.[d]; dims += '<span>'+d+'</span><span>'+(v??'-')+'</span>'; }
dims += '</div>';
const rl = (j.redLines||[]);
const rlHtml = rl.length
? '<div class="redlines">红线 '+rl.map(c=>'<span class="pill">'+esc(c)+'</span>').join('')+'</div>'
: '<div class="redlines"><span class="pill good">无红线</span></div>';
const dur = result.durationMs!=null ? ' · '+(result.durationMs/1000).toFixed(1)+'s' : '';
return '<div class="judge">'+overallPill(j.overall)+rlHtml
+ '<div style="margin-top:6px">'+esc(j.rationale||'')+'</div>'+dims
+ '<div class="kv" style="margin-top:6px">'+(result.milestoneCount??'?')+' milestones · '
+ (result.microtaskCount??'?')+' microtasks'+dur+'</div></div>';
}
function renderProject(p){
if (!p) return '<div class="missing">(无此 variant 的生成结果)</div>';
const inst = p.roles && p.roles.find(r=>r.type==='instructor');
let h = '<div class="proj-title">'+esc(p.title)+'</div>';
h += '<div class="desc">'+esc(p.description)+'</div>';
if (p.learningObjective) h += '<div class="kv">🎯 '+esc(p.learningObjective)+'</div>';
h += '<div class="kv">tier: '+esc(p.proficiency||'?')+'</div>';
if (inst) h += '<div class="kv">👤 '+esc(inst.name)+(inst.description?' — '+esc(inst.description):'')+'</div>';
for (const m of (p.milestones||[])){
h += '<details class="ms"><summary>'+esc(m.title)+' <span class="kv">('+(m.microtasks||[]).length+' 任务)</span></summary><div class="body">';
if (m.description) h += '<div class="desc">'+esc(m.description)+'</div>';
if (m.briefing) h += '<div class="script"><b>briefing</b> '+esc(m.briefing)+'</div>';
if (m.completionCriteria) h += '<div class="script"><b>done</b> '+esc(m.completionCriteria)+'</div>';
if (m.debrief) h += '<div class="script"><b>debrief</b> '+esc(m.debrief)+'</div>';
if (m.synthesisCheck?.coreConcept) h += '<div class="core">★ coreConcept: '+esc(m.synthesisCheck.coreConcept)+'</div>';
for (const t of (m.microtasks||[])){
h += '<div class="mt"><div class="t">'+esc(t.title)+'</div>';
if (t.description) h += '<div class="d">'+esc(t.description)+'</div>';
if (t.learnerBrief) h += '<div class="script"><b>learnerBrief</b> '+esc(t.learnerBrief)+'</div>';
if (t.successWhen) h += '<div class="script"><b>successWhen</b> '+esc(t.successWhen)+'</div>';
if (t.characterObjective) h += '<div class="script"><b>characterObjective</b> '+esc(t.characterObjective)+'</div>';
if (t.skillFocus) h += '<div class="script"><b>skillFocus</b> '+esc(t.skillFocus)+'</div>';
if (t.narration) h += '<div class="script"><b>narration</b> '+esc(t.narration)+'</div>';
if (t.hints && t.hints.length){ h += '<ul>'+t.hints.map(x=>'<li>'+esc(x)+'</li>').join('')+'</ul>'; }
h += '</div>';
}
for (const d of (m.documents||[])){ h += '<div class="doc">📄 '+esc(d.title)+'</div>'; }
h += '</div></details>';
}
return h;
}
function renderCase(c){
document.getElementById('main').innerHTML = '<div class="cols">' + VARIANTS.map(v => {
const e = c.variants[v] || {};
return '<div class="col"><h2>'+v+'</h2>'+completabilityBlock(e.result)+judgeBlock(e.result)+renderProject(e.project)+'</div>';
}).join('') + '</div>';
}
function navLabel(c){
const o = VARIANTS.map(v => {
const cpl=c.variants[v]?.result?.completability;
if (cpl) return (cpl.pass?'P':'F')+cpl.score;
const j=c.variants[v]?.result?.judge;
return j? j.overall : null;
});
return esc(c.id) + '<span class="ov">'+o.map(x=>x==null?'-':x).join('/')+'</span>';
}
function renderRun(data){
const metaEl = document.getElementById('meta'); if (metaEl) metaEl.textContent = data.runDir || '';
const nav = document.getElementById('nav'); nav.innerHTML='';
document.getElementById('main').innerHTML='';
if (!data.cases || !data.cases.length){ document.getElementById('main').innerHTML='<p class="missing">此 run 无数据。</p>'; return; }
data.cases.forEach((c,i)=>{
const b=document.createElement('button');
b.innerHTML = navLabel(c);
b.onclick=()=>{ [...nav.children].forEach(x=>x.classList.remove('active')); b.classList.add('active'); renderCase(c); };
nav.appendChild(b);
if (i===0){ b.classList.add('active'); renderCase(c); }
});
}
`;
function staticTemplate(data: EmbeddedData): string {
return `<!doctype html>
<html lang="zh"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PBL v2 — loop vs single-call</title><style>${VIEWER_CSS}</style></head>
<body>
<header><h1>PBL v2 — loop vs single-call</h1><span class="meta" id="meta"></span></header>
<div class="layout"><nav id="nav"></nav><main id="main"></main></div>
<script>${CLIENT_RENDER_JS}\nrenderRun(${embed(data)});</script>
</body></html>`;
}
export function buildCompareHtml(runDir: string): string {
const data = collectRunData(runDir);
const outPath = join(runDir, 'compare.html');
writeFileSync(outPath, staticTemplate(data), 'utf-8');
return outPath;
}