build-workshop-report.mjs 6.6 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
  1. /** Build an offline report from an explicitly captured public snapshot. No network requests. */
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import { fileURLToPath } from 'node:url';
  5. const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  6. const folder = path.join(root, 'docs/workshop-report');
  7. const snapshot = JSON.parse(fs.readFileSync(path.join(folder, 'snapshot-20261103.json'), 'utf8'));
  8. const args = process.argv.slice(2);
  9. if (args.length && (args.length !== 2 || args[0] !== '--out' || !path.isAbsolute(args[1]))) throw Error('Use --out ABSOLUTE_HTML_FILE');
  10. const out = args[1] || path.join(folder, 'report-20261104.html');
  11. const escape = v => String(v).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
  12. const n = v => Number(v).toLocaleString('en-US');
  13. const count = snapshot.publicSnapshot.counts;
  14. const modes = snapshot.publicSnapshot.distributions.artifact.dataMode;
  15. const base = snapshot.demo.baseline, ext = snapshot.demo.extension;
  16. const total = o => Object.values(o).reduce((sum, v) => sum + v, 0);
  17. if (total(base) !== 334 || total(ext) !== 16446 || !snapshot.publicSnapshot.complete) throw Error('UNEXPECTED_SNAPSHOT');
  18. const artifactLabels = {
  19. opportunity_card: '机会卡类成果', pain_list: '痛点清单', competitor_report: '竞品研究',
  20. concept: '产品概念', assessment_report: '验证评估', category_report: '品类研究', voc_report: '用户声音',
  21. };
  22. const baseLabels = { groups: '小组', members: '成员', ideas: '创意', sources: '来源', evidence: '基础引文', opportunities: '机会卡',
  23. projects: '项目', conceptVersions: '方案版本', reports: '报告', questions: '未决问题', validationTasks: '验证任务', assignments: '作业' };
  24. const extLabels = { voices: ['虚构用户声音', '人群、场景、口味与条件检索'], observations: ['商品观察', '产品与规格比较'],
  25. prices: ['价格快照', '同一商品多个时间与条件'], citations: ['新增引文', '同源原文与结论回查'], researchTasks: ['研究任务', '补证、负责人、执行状态'],
  26. collaborationRecords: ['协作记录', '多人分工、评论与接力'] };
  27. const skillCommands = [
  28. ['员工与会话', 'workshop-start', '绑定当前员工、Agent、小组和工作坊会话'],
  29. ['称呼', 'employee-name', '读取、保存或修改本人称呼;记住明确跳过'],
  30. ['可选通讯录', 'employee-read', '只读查询通讯录姓名;依赖管理员配置,当前未接通'],
  31. ['阶段成果', 'workshop-save', '保存报告、材料关联、未决问题,可生成机会卡'],
  32. ['项目', 'project-create / project-progress', '从机会建立项目,记录阶段推进、受阻或课堂完成'],
  33. ['概念方案', 'concept-save / project-status', '保存方案版本;回读项目成果、方案和事件'],
  34. ['资料入库', 'workshop-import', '按合同保存原料、工艺、竞品、证据等资料'],
  35. ['查重与检索', 'workshop-dedup / workshop-search', '精确查重、检索已有资料与报告'],
  36. ['回读与合同', 'workshop-read / workshop-contract', '按真实编号回读;核对服务支持字段'],
  37. ['正式报告', 'report-preview / report-submit', '建立预览;本人完成服务端确认后提交'],
  38. ['兼容入口', 'ingest / status / demo / connection', '沿用原文导入、状态、示例和连接检查'],
  39. ];
  40. const baseEntries = Object.entries(base);
  41. const localTime = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'medium', timeStyle: 'short', hour12: false }).format(new Date(snapshot.generatedAt));
  42. const replacements = {
  43. SNAPSHOT_TIME: escape(localTime + '(北京时间)'),
  44. PUBLIC_KPIS: [['artifact', '份', '阶段成果'], ['project', '个', '项目'], ['opportunity', '张', '独立机会卡'], ['source', '个', '来源'], ['session', '个', '工作坊会话'], ['insight', '条', '洞察']]
  45. .map(([key, unit, label]) => `<article class="card metric"><div class="val">${n(count[key])}<span class="unit">${unit}</span></div><div class="name">${label}</div></article>`).join('\n'),
  46. DATA_QUALITY: `本次 ${count.artifact} 份成果中,取得模式标为样例的 ${modes.sample || 0} 份、标为 live 的 ${modes.live || 0} 份,另有 ${modes['未提供'] || 0} 份公开记录未提供该字段。live 只表示记录的取得模式,不能单独证明研究结论已经核验。`,
  47. MEMBER_COUNT: snapshot.publicSnapshot.modelMembers,
  48. SOURCE_COUNT: count.source, INSIGHT_COUNT: count.insight, ARTIFACT_COUNT: count.artifact, OPPORTUNITY_COUNT: count.opportunity,
  49. PROJECT_COUNT: count.project, SESSION_COUNT: count.session,
  50. OPPORTUNITY_REPORTS: snapshot.publicSnapshot.distributions.artifact.artifactType.opportunity_card,
  51. REPORT_BARS: Object.entries(snapshot.publicSnapshot.distributions.artifact.artifactType).map(([type, amount]) => `<div class="bar-row"><span>${escape(artifactLabels[type] || type)}</span><div class="bar-track" role="img" aria-label="${escape(artifactLabels[type] || type)} ${amount} 份,占成果总量 ${Math.round(amount / count.artifact * 100)}%"><div class="bar-fill" style="--pct:${(amount / count.artifact * 100).toFixed(2)}%"></div></div><b>${amount}</b></div>`).join('\n'),
  52. BASELINE_TOTAL: n(total(base)), EXT_TOTAL: n(total(ext)), EXT_CITATIONS: ext.citations,
  53. BASELINE_ROWS: Array.from({ length: Math.ceil(baseEntries.length / 2) }, (_, i) => {
  54. const a = baseEntries[i * 2], b = baseEntries[i * 2 + 1];
  55. return `<tr><td>${escape(baseLabels[a[0]])}</td><td><b>${n(a[1])}</b></td><td>${escape(baseLabels[b[0]])}</td><td><b>${n(b[1])}</b></td></tr>`;
  56. }).join('\n'),
  57. EXT_ROWS: Object.entries(ext).map(([key, amount]) => `<tr><td>${escape(extLabels[key][0])}</td><td><b>${n(amount)}</b></td><td>${escape(extLabels[key][1])}</td></tr>`).join('\n'),
  58. SKILL_COMMANDS: skillCommands.map(([type, command, detail]) => `<tr><td>${escape(type)}</td><td><code>${escape(command)}</code></td><td>${escape(detail)}</td></tr>`).join('\n'),
  59. SNAPSHOT_JSON: JSON.stringify(snapshot).replaceAll('<', '\\u003c'),
  60. };
  61. let html = fs.readFileSync(path.join(folder, 'report.template.html'), 'utf8');
  62. html = html.replace(/__([A-Z_]+)__/g, (match, key) => {
  63. if (!Object.hasOwn(replacements, key)) throw Error('UNKNOWN_PLACEHOLDER:' + match);
  64. return String(replacements[key]);
  65. });
  66. if (/__[A-Z_]+__/.test(html) || /https?:\/\//.test(html.match(/<script>([\s\S]*?)<\/script>/)?.[1] || '')) throw Error('BAD_REPORT_OUTPUT');
  67. fs.mkdirSync(path.dirname(out), { recursive: true });
  68. fs.writeFileSync(out, html);
  69. console.log(JSON.stringify({ ok: true, file: out, bytes: Buffer.byteLength(html), sections: 10, offline: true, externalResources: 0, networkRequests: 0, productionWrites: 0 }));