update-workshop-dialogue-manual.mjs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import assert from 'node:assert/strict';
  5. import { pathToFileURL } from 'node:url';
  6. export const manualFile = 'docs/workshop-quality-r13-20261110/final-康师傅工作坊操作手册.html';
  7. export const bankFile = 'docs/workshop-quality-r13-20261110/manual/workshop-dialogue-prompts.json';
  8. const evidenceDir = 'docs/workshop-dialogue-guide-20261111';
  9. const sha = text => crypto.createHash('sha256').update(text).digest('hex');
  10. export function compileDialogue(bank, steps) {
  11. assert.equal(bank.schema, 'ksf.workshop-dialogue-prompts.v1');
  12. assert.deepEqual(Object.keys(bank.steps).sort(), steps.map(step => step.id).sort(), '提示词与手册环节必须一一对应');
  13. return Object.fromEntries(steps.map(step => {
  14. const section = bank.steps[step.id];
  15. assert.equal(section.title, step.title);
  16. assert.ok(section.rounds.length >= 2 && section.rounds.length <= 4);
  17. return [step.id, section.rounds.map((round, index) => {
  18. assert.ok(round.title && round.task && round.feedback);
  19. const researchStart = section.rounds.findIndex(item => item.purpose !== 'account-binding');
  20. const topicStart = step.id === 'topic' && index === researchStart;
  21. const topicEnd = step.id === 'topic' && index === section.rounds.length - 1;
  22. return {
  23. title: round.title,
  24. feedback: round.feedback,
  25. prerequisite: round.prerequisite || null,
  26. prompt: [topicStart ? bank.shared.opening : topicEnd ? bank.shared.continuation : null,
  27. `当前环节:${step.title};第${index + 1}轮:${round.title}。`,
  28. round.task,
  29. topicStart ? '本次工作坊按多轮对话推进:' + bank.shared.stop : null,
  30. topicEnd ? '以下约定适用于本课题后续各轮,无需每次重述:\n' + bank.shared.evidence + '\n\n' + bank.shared.delivery : null
  31. ].filter(Boolean).join('\n\n')
  32. };
  33. })];
  34. }));
  35. }
  36. export function updateManual() {
  37. let html = fs.readFileSync(manualFile, 'utf8');
  38. const dataMatch = html.match(/window\.WORKSHOP_MANUAL_DATA = (\{[\s\S]*?\n\});\r?\n<\/script>/);
  39. assert.ok(dataMatch, '手册数据结构变化,停止修改');
  40. const data = JSON.parse(dataMatch[1]);
  41. const bank = JSON.parse(fs.readFileSync(bankFile, 'utf8'));
  42. const map = compileDialogue(bank, data.steps);
  43. const oldMap = html.match(/ const promptMap = (\{[^\r\n]+\});/);
  44. const currentMap = html.match(/ const dialogueMap = (\{[^\r\n]+\});/);
  45. assert.ok(oldMap || currentMap, '未找到手册实际使用的提示词');
  46. fs.mkdirSync(evidenceDir, { recursive: true });
  47. const beforeFile = path.join(evidenceDir, 'manual-before.html');
  48. if (!fs.existsSync(beforeFile)) {
  49. fs.writeFileSync(beforeFile, html);
  50. fs.writeFileSync(path.join(evidenceDir, 'before.json'), JSON.stringify({
  51. manualSha256: sha(html), contentVersion: data.release.contentVersion,
  52. steps: data.steps.length, promptCount: oldMap ? Object.keys(JSON.parse(oldMap[1])).length : null,
  53. mode: oldMap ? 'single-prompt-per-step' : 'multi-round'
  54. }, null, 2) + '\n');
  55. }
  56. data.release.contentVersion = bank.revision;
  57. data.release.deploymentStatus = 'local-pending-deployment';
  58. data.release.productionCutoverConfirmed = false;
  59. data.common = '01首轮确认称呼与账号,随后确定课题;研究通用约定集中在课题研究的开场与收尾,后续各轮沿用Skill规则。';
  60. const topic = data.steps.find(step => step.id === 'topic');
  61. if (bank.steps.topic.rounds[0]?.purpose === 'account-binding') {
  62. topic.action = '先复制第一轮确认称呼与登录账号,再依次复制第二、三轮确定本组课题;每轮发送后先看结果再继续。';
  63. topic.prompt = map.topic[0].prompt;
  64. }
  65. html = html.replace(dataMatch[0], 'window.WORKSHOP_MANUAL_DATA = ' + JSON.stringify(data, null, 2) + ';\n</script>');
  66. const serialized = JSON.stringify(map).replace(/</g, '\\u003c');
  67. if (oldMap) {
  68. html = html.replace(oldMap[0], ' const dialogueMap = ' + serialized + ';');
  69. html = html.replace(' const fullPrompt = step => promptMap[step.id];', ` function renderPrompts(step) {
  70. const rounds = dialogueMap[step.id];
  71. return '<p class="dialogue-intro">每次只复制一轮,发送后先看结果,再反馈或发送下一轮。也可直接回复“继续”;需要业务选择时先回答,不必重复粘贴前文。</p>' +
  72. rounds.map((round, index) => {
  73. const promptId = 'prompt-' + step.id + '-' + index;
  74. return \`<section class="dialogue-round" aria-labelledby="round-title-\${index}">
  75. <h2 class="round-title" id="round-title-\${index}"><span>第\${index + 1}轮 / \${rounds.length}</span> \${escape(round.title)}</h2>
  76. \${round.prerequisite ? '<p class="round-prerequisite">' + escape(round.prerequisite) + '</p>' : ''}
  77. <div class="prompt-box"><div class="prompt-top"><b>发给数字人</b><button type="button" class="copy-button" data-copy-round="\${index}" aria-label="复制第\${index + 1}轮:\${escape(round.title)}">复制本轮提示词</button></div><pre class="prompt-text" id="\${promptId}">\${escape(round.prompt)}</pre></div>
  78. <p class="round-feedback"><b>看完怎样反馈:</b>\${escape(round.feedback)}</p>
  79. </section>\`;
  80. }).join('');
  81. }`);
  82. const oldBox = '<div class="prompt-box"><div class="prompt-top"><b>任务提示词</b><button class="copy-button" id="copy-prompt">复制提示词</button></div><pre class="prompt-text" id="prompt-text">${escape(fullPrompt(step))}</pre></div>';
  83. assert.ok(html.includes(oldBox), '旧复制区域变化,停止修改');
  84. html = html.replace(oldBox, '${renderPrompts(step)}');
  85. const oldListener = " byId('copy-prompt')?.addEventListener('click', () => copy(fullPrompt(step)));";
  86. assert.ok(html.includes(oldListener));
  87. html = html.replace(oldListener, ` document.querySelectorAll('[data-copy-round]').forEach(button => button.addEventListener('click', () => {
  88. const index = Number(button.dataset.copyRound);
  89. copy(dialogueMap[step.id][index].prompt, byId('prompt-' + step.id + '-' + index));
  90. }));`);
  91. html = html.replace('async function copy(text) {', 'async function copy(text, element) {')
  92. .replace("range.selectNodeContents(byId('prompt-text'));", 'range.selectNodeContents(element);')
  93. .replace("toast('完整提示词已复制,可发送给数字人');", "toast('本轮提示词已复制,发送后先看结果再反馈');")
  94. .replace("toast('请复制已选中的完整提示词');", "toast('请复制已选中的本轮提示词');");
  95. const css = `.dialogue-intro{font-size:13px;color:var(--muted);margin:14px 0 18px}.dialogue-round+.dialogue-round{border-top:1px solid var(--line);margin-top:22px;padding-top:19px}.round-title{font-size:16px;line-height:1.7;margin:0 0 9px}.round-title>span{font-size:12px;color:var(--red);font-weight:500;margin-right:7px;white-space:nowrap}.round-feedback{font-size:13px;color:var(--muted);margin:9px 0 0;overflow-wrap:anywhere}.round-feedback>b{color:var(--green);font-weight:500}.round-prerequisite{background:#fff5df;border:1px solid #ead6a2;border-radius:6px;padding:8px 11px;font-size:13px;margin:0 0 10px}.dialogue-round{min-width:0}.prompt-top{flex-wrap:wrap}.prompt-top .copy-button{margin-left:auto}@media(max-width:540px){.round-title{font-size:15px}.dialogue-intro,.round-feedback,.round-prerequisite{font-size:12px}}`;
  96. html = html.replace('</style>', css + '\n</style>');
  97. } else {
  98. html = html.replace(currentMap[0], ' const dialogueMap = ' + serialized + ';');
  99. }
  100. assert.ok(!html.includes('const promptMap =') && html.includes('copy(dialogueMap[step.id][index].prompt,'));
  101. fs.writeFileSync(manualFile, html);
  102. const result = { manual: manualFile, contentVersion: bank.revision,
  103. promptBank: bankFile, promptBankSha256: sha(fs.readFileSync(bankFile)),
  104. manualSha256: sha(html), stepCount: data.steps.length,
  105. roundCount: Object.values(map).reduce((n, rounds) => n + rounds.length, 0),
  106. rounds: Object.fromEntries(Object.entries(map).map(([key, rounds]) => [key, rounds.map(round => ({ title: round.title, characters: round.prompt.length }))])),
  107. productionPublished: false };
  108. fs.writeFileSync(path.join(evidenceDir, 'manual-build.json'), JSON.stringify(result, null, 2) + '\n');
  109. return result;
  110. }
  111. if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
  112. const result = updateManual();
  113. console.log(JSON.stringify({ manual: result.manual, stepCount: result.stepCount, roundCount: result.roundCount, productionPublished: false }));
  114. }