build-report.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. /**
  2. * build-report.mjs — HTML PPT 生成(lib 复制 + 数据注入)
  3. *
  4. * 读采集清单.json + 追问回答.json → 注入 report-template.html(skill-present 标准)→
  5. * 输出独立报告目录(report.html + 全套 lib 复制 + report-data.json)。
  6. *
  7. * 规则(borrow skill-present 报告策略):数据优先 / 证据链 / 来源标注 / 数字动画。
  8. * 缺数据段优雅降级:三问未答 → 标注【待补充:用户口述】;交付物无 URL → 虚线占位卡。
  9. *
  10. * 用法:node build-report.mjs --collect collect.json [--answers answers.json] \
  11. * [--name 姓名] [--enterprise 企业] [--out 输出目录]
  12. */
  13. import fs from 'node:fs';
  14. import path from 'node:path';
  15. import { pathToFileURL } from 'node:url';
  16. const SKILL_DIR = path.resolve(path.dirname(decodeURIComponent(new URL(import.meta.url).pathname)), '..');
  17. function argVal(flag, dflt) {
  18. const i = process.argv.indexOf(flag);
  19. return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
  20. }
  21. function esc(s) {
  22. return String(s == null ? '' : s)
  23. .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  24. .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  25. }
  26. function fmtDate(iso) {
  27. try { return iso.slice(0, 10); } catch { return iso; }
  28. }
  29. /** lib 全套独立复制(每个文件独立复制确保可用——v2.4.1 base.css 缺失教训) */
  30. function copyLib(outDir) {
  31. const srcLib = path.join(SKILL_DIR, 'lib');
  32. const dstLib = path.join(outDir, 'lib');
  33. const REQUIRED = [
  34. 'theme/tokens.css', 'theme/base.css', 'theme/theme.js',
  35. 'player/deck.js', 'player/canvas-scale.js',
  36. 'components/chat-window.css', 'components/chat-window.js',
  37. 'components/group-chat.css', 'components/group-chat.js',
  38. 'components/logic-flow.css', 'components/logic-flow.js',
  39. 'vendor/gsap/gsap.min.js',
  40. ];
  41. const copied = [];
  42. fs.mkdirSync(dstLib, { recursive: true });
  43. for (const rel of REQUIRED) {
  44. const src = path.join(srcLib, rel);
  45. const dst = path.join(dstLib, rel);
  46. if (!fs.existsSync(src)) throw new Error(`lib 缺失(安装不完整):${rel} —— 重新克隆本仓库`);
  47. fs.mkdirSync(path.dirname(dst), { recursive: true });
  48. fs.copyFileSync(src, dst);
  49. copied.push(rel);
  50. }
  51. return copied;
  52. }
  53. /* ── 各段渲染 ─────────────────────────────────────────────── */
  54. function renderDistRows(bySource) {
  55. const entries = Object.entries(bySource).sort((a, b) => b[1] - a[1]);
  56. const max = Math.max(1, ...entries.map(e => e[1]));
  57. return entries.map(([name, n]) => {
  58. const w = Math.max(2, Math.round((n / max) * 560));
  59. return `<div class="dist-row"><div class="dist-name">${esc(name)}</div><div class="dist-bar" style="width:${w}px;"></div><div class="dist-num">${n}</div></div>`;
  60. }).join('\n ');
  61. }
  62. /** 交付物墙:优先在线 URL(可 iframe 嵌套),本地文件给占位卡 */
  63. function renderWall(items) {
  64. const candidates = items
  65. .filter(i => i.type === '交付物' || /\.(html)$/i.test(i.path || ''))
  66. .slice(0, 6);
  67. if (candidates.length === 0) {
  68. return `<div class="w-card"><div class="w-nolink">本窗口未检出 HTML 交付物<br>(采集窗口内无 .html 产出)</div><div class="w-title">交付物墙占位</div><div class="w-src">缺数据段 · 优雅降级</div></div>`;
  69. }
  70. return candidates.map(i => {
  71. const online = /^https?:\/\//.test(i.url || '');
  72. if (online) {
  73. const iframeOk = i.url.includes('fmode.cn') || i.url.includes('obs.') || i.url.includes('s3.') || i.iframable === true;
  74. return `<div class="w-card">
  75. ${iframeOk ? `<iframe src="${esc(i.url)}" loading="lazy" sandbox="allow-scripts allow-same-origin"></iframe>` : `<div class="w-nolink">外域页面禁止 iframe 嵌套<br>点击标题新窗打开</div>`}
  76. <div class="w-title"><a href="${esc(i.url)}" target="_blank" rel="noopener" style="color:var(--brand-1); text-decoration:none;">${esc(i.summary.slice(0, 40))}</a></div>
  77. <div class="w-src">${esc(i.source)} · ${fmtDate(i.time)}</div>
  78. ${iframeOk ? '<div class="w-note">✓ 在线项目 · 可 iframe 嵌套展示</div>' : '<div class="w-src">仅链接 · 不强行嵌套</div>'}
  79. </div>`;
  80. }
  81. return `<div class="w-card">
  82. <div class="w-nolink">本地文件<br>${esc(path.basename(i.path || ''))}</div>
  83. <div class="w-title">${esc(i.summary.slice(0, 44))}</div>
  84. <div class="w-src">${esc(i.source)} · ${fmtDate(i.time)} · 本地路径已入附录</div>
  85. </div>`;
  86. }).join('\n ');
  87. }
  88. function renderTimeline(items, n = 7) {
  89. const picks = items.slice(0, n);
  90. if (picks.length === 0) return `<div class="tl-item"><div class="tl-time">—</div><div class="tl-text">采集窗口内无会话/文件记录</div></div>`;
  91. return picks.map(i => `<div class="tl-item">
  92. <div class="tl-time">${esc(i.time.slice(5, 16).replace('T', ' '))} · ${esc(i.source)} · ${esc(i.type)}</div>
  93. <div class="tl-text">${esc(i.summary.slice(0, 84))}</div>
  94. </div>`).join('\n ');
  95. }
  96. function renderQBlock(answer, pending, links, pendingBadgeHtml) {
  97. const body = answer
  98. ? `<div class="q-a">${esc(answer)}</div>`
  99. : `<div class="q-a" style="color:var(--text-4);">(本轮未采集到口述内容)</div>`;
  100. const ev = (links || []).map(l =>
  101. `<div class="ev">↳ 证据 [${esc(l.source)} ${fmtDate(l.time)}] ${esc(l.evidence)}</div>`).join('\n ');
  102. return body + pendingBadgeHtml + (ev ? `\n <div class="q-ev">\n ${ev}\n </div>` : '');
  103. }
  104. function renderAppendix(items, n = 14) {
  105. const rows = items.slice(0, n).map(i => `<tr>
  106. <td class="mono">${esc(i.time.slice(5, 16).replace('T', ' '))}</td>
  107. <td>${esc(i.source)}</td>
  108. <td>${esc(i.summary.slice(0, 80))}</td>
  109. <td class="mono">${esc(i.url || i.path || '—')}</td>
  110. </tr>`).join('\n ');
  111. return rows || `<tr><td colspan="4" style="color:var(--text-4);">无采集记录</td></tr>`;
  112. }
  113. /* ── 主流程 ─────────────────────────────────────────────── */
  114. export function buildReport({ collect, answers, name = '学员', enterprise = '企业 AI 落地研修', outDir = null }) {
  115. const tplPath = path.join(SKILL_DIR, 'templates', 'report-template.html');
  116. if (!fs.existsSync(tplPath)) throw new Error('模板缺失:templates/report-template.html');
  117. const tpl = fs.readFileSync(tplPath, 'utf-8');
  118. const stats = collect.stats || {};
  119. const dateRange = `${fmtDate(collect.meta.windowStart)} ~ ${fmtDate(collect.meta.generatedAt)}`;
  120. const toolList = Object.keys(stats.bySource || {}).join(' / ') || '—';
  121. const messages = collect.items.reduce((s, i) => {
  122. const m = i.summary.match(/用户消息 (\d+) 条 \/ 助手 (\d+) 条/);
  123. return s + (m ? Number(m[1]) + Number(m[2]) : 0);
  124. }, 0);
  125. const ans = (answers && answers.answers) || {};
  126. const links = (answers && answers.evidenceLinks) || {};
  127. /* 无 answers 文件 = 全部三问视为待补充;有文件时按 pending 标记 */
  128. const noAnswersAtAll = !answers;
  129. const pendingBadge = k => (noAnswersAtAll || ans[k + '_pending'])
  130. ? `<div style="margin-top:8px;"><span class="pending-badge">【待补充:用户口述】</span></div>`
  131. : '';
  132. const qHtml = key => renderQBlock(ans[key] || '', ans[key + '_pending'], links[key], pendingBadge(key));
  133. const html = tpl
  134. .replace(/\{\{REPORT_TITLE\}\}/g, esc(`${name} · 学习复盘报告`))
  135. .replace(/\{\{NAME\}\}/g, esc(name))
  136. .replace(/\{\{ENTERPRISE\}\}/g, esc(enterprise))
  137. .replace(/\{\{DATE_RANGE\}\}/g, esc(dateRange))
  138. .replace(/\{\{SESSIONS\}\}/g, String(stats.sessionCount ?? 0))
  139. .replace(/\{\{TOOLKINDS\}\}/g, String(Object.keys(stats.bySource || {}).length))
  140. .replace(/\{\{ARTIFACTS\}\}/g, String(stats.artifactCount ?? 0))
  141. .replace(/\{\{MESSAGES\}\}/g, String(messages))
  142. .replace(/\{\{HOURS\}\}/g, String(collect.meta.windowHours ?? 48))
  143. .replace(/\{\{WINDOW\}\}/g, esc(dateRange))
  144. .replace(/\{\{TOOL_LIST\}\}/g, esc(toolList))
  145. .replace(/\{\{TOOL_DIST_ROWS\}\}/g, renderDistRows(stats.bySource || {}))
  146. .replace(/\{\{ARTIFACT_WALL\}\}/g, renderWall(collect.items || []))
  147. .replace(/\{\{TIMELINE\}\}/g, renderTimeline(collect.items || []))
  148. .replace(/\{\{ANSWER_PAST\}\}/g, '')
  149. .replace(/\{\{ANSWER_PRESENT\}\}/g, '')
  150. .replace(/\{\{ANSWER_FUTURE\}\}/g, '')
  151. .replace(/\{\{EVIDENCE_PAST\}\}/g, '')
  152. .replace(/\{\{EVIDENCE_PRESENT\}\}/g, '')
  153. .replace(/\{\{EVIDENCE_FUTURE\}\}/g, '')
  154. .replace(/\{\{PAST_PENDING\}\}/g, '')
  155. .replace(/\{\{PRESENT_PENDING\}\}/g, '')
  156. .replace(/\{\{FUTURE_PENDING\}\}/g, '')
  157. .replace(/\{\{APPENDIX_ROWS\}\}/g, renderAppendix(collect.items || []))
  158. .replace(/\{\{TOTAL\}\}/g, String(stats.total ?? 0))
  159. .replace(/\{\{APPENDIX_N\}\}/g, String(Math.min(14, stats.total ?? 0)));
  160. /* 三问块在模板中带占位符嵌套,这里二次注入(避免 regex 嵌套转义地狱) */
  161. const inject = (screenId, inner, source) => {
  162. const re = new RegExp(`(<section class="screen" id="${screenId}">[\\s\\S]*?<div class="q-h">[\\s\\S]*?</div>\\n)([\\s\\S]*?)(\\n </div>\\n </div>)`);
  163. return (source || html).replace(re, (m, head, _body, tail) => head + inner + tail);
  164. };
  165. let finalHtml = html;
  166. finalHtml = inject.call(null, 'p5', qHtml('past').trim(), finalHtml);
  167. finalHtml = inject.call(null, 'p6', qHtml('present').trim(), finalHtml);
  168. finalHtml = inject.call(null, 'p7', qHtml('future').trim(), finalHtml);
  169. /* 输出目录:report.html + lib 全套 + report-data.json */
  170. const dir = outDir || path.join(process.cwd(), 'study-report-out');
  171. fs.mkdirSync(dir, { recursive: true });
  172. const copied = copyLib(dir);
  173. fs.writeFileSync(path.join(dir, 'report.html'), finalHtml);
  174. fs.writeFileSync(path.join(dir, 'report-data.json'), JSON.stringify({
  175. generatedAt: new Date().toISOString(),
  176. name, enterprise, dateRange,
  177. collect: { meta: collect.meta, stats: collect.stats, toolsStatus: collect.toolsStatus, items: collect.items, artifacts: collect.artifacts },
  178. answers: answers || null,
  179. libCopied: copied,
  180. }, null, 2));
  181. return {
  182. dir,
  183. reportPath: path.join(dir, 'report.html'),
  184. dataPath: path.join(dir, 'report-data.json'),
  185. pages: 9,
  186. stats,
  187. };
  188. }
  189. /* ── CLI ─────────────────────────────────────────────── */
  190. const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(decodeURIComponent(new URL(import.meta.url).pathname));
  191. if (isMain) {
  192. const collectFile = argVal('--collect', null);
  193. const answersFile = argVal('--answers', null);
  194. if (!collectFile || !fs.existsSync(collectFile)) {
  195. console.error('[build-report] 缺 --collect <采集清单.json>(先跑 collect.mjs --out)');
  196. process.exit(2);
  197. }
  198. const collect = JSON.parse(fs.readFileSync(collectFile, 'utf-8'));
  199. let answers = null;
  200. if (answersFile && fs.existsSync(answersFile)) {
  201. answers = JSON.parse(fs.readFileSync(answersFile, 'utf-8'));
  202. } else {
  203. console.log('[build-report] 未提供 --answers:三问标注【待补充:用户口述】,用采集数据先行出报告。');
  204. }
  205. const r = buildReport({
  206. collect,
  207. answers,
  208. name: argVal('--name', '学员'),
  209. enterprise: argVal('--enterprise', '企业 AI 落地研修'),
  210. outDir: argVal('--out', null),
  211. });
  212. console.log(`\n✅ 报告已生成:${r.reportPath}`);
  213. console.log(` 页数:${r.pages} · 会话 ${r.stats.sessionCount ?? 0} · 产出物 ${r.stats.artifactCount ?? 0}`);
  214. console.log(` lib 已复制 ${path.basename(r.dir)}/lib/(base.css 教训:逐文件校验复制)`);
  215. console.log(` 本地预览:file://${pathToFileURL(r.reportPath).href.replace('file://', '')}`);
  216. }