| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119 |
- import fs from 'node:fs';
- import path from 'node:path';
- import crypto from 'node:crypto';
- import assert from 'node:assert/strict';
- import { pathToFileURL } from 'node:url';
- export const manualFile = 'docs/workshop-quality-r13-20261110/final-康师傅工作坊操作手册.html';
- export const bankFile = 'docs/workshop-quality-r13-20261110/manual/workshop-dialogue-prompts.json';
- const evidenceDir = 'docs/workshop-dialogue-guide-20261111';
- const sha = text => crypto.createHash('sha256').update(text).digest('hex');
- export function compileDialogue(bank, steps) {
- assert.equal(bank.schema, 'ksf.workshop-dialogue-prompts.v1');
- assert.deepEqual(Object.keys(bank.steps).sort(), steps.map(step => step.id).sort(), '提示词与手册环节必须一一对应');
- return Object.fromEntries(steps.map(step => {
- const section = bank.steps[step.id];
- assert.equal(section.title, step.title);
- assert.ok(section.rounds.length >= 2 && section.rounds.length <= 4);
- return [step.id, section.rounds.map((round, index) => {
- assert.ok(round.title && round.task && round.feedback);
- const researchStart = section.rounds.findIndex(item => item.purpose !== 'account-binding');
- const topicStart = step.id === 'topic' && index === researchStart;
- const topicEnd = step.id === 'topic' && index === section.rounds.length - 1;
- return {
- title: round.title,
- feedback: round.feedback,
- prerequisite: round.prerequisite || null,
- prompt: [topicStart ? bank.shared.opening : topicEnd ? bank.shared.continuation : null,
- `当前环节:${step.title};第${index + 1}轮:${round.title}。`,
- round.task,
- topicStart ? '本次工作坊按多轮对话推进:' + bank.shared.stop : null,
- topicEnd ? '以下约定适用于本课题后续各轮,无需每次重述:\n' + bank.shared.evidence + '\n\n' + bank.shared.delivery : null
- ].filter(Boolean).join('\n\n')
- };
- })];
- }));
- }
- export function updateManual() {
- let html = fs.readFileSync(manualFile, 'utf8');
- const dataMatch = html.match(/window\.WORKSHOP_MANUAL_DATA = (\{[\s\S]*?\n\});\r?\n<\/script>/);
- assert.ok(dataMatch, '手册数据结构变化,停止修改');
- const data = JSON.parse(dataMatch[1]);
- const bank = JSON.parse(fs.readFileSync(bankFile, 'utf8'));
- const map = compileDialogue(bank, data.steps);
- const oldMap = html.match(/ const promptMap = (\{[^\r\n]+\});/);
- const currentMap = html.match(/ const dialogueMap = (\{[^\r\n]+\});/);
- assert.ok(oldMap || currentMap, '未找到手册实际使用的提示词');
- fs.mkdirSync(evidenceDir, { recursive: true });
- const beforeFile = path.join(evidenceDir, 'manual-before.html');
- if (!fs.existsSync(beforeFile)) {
- fs.writeFileSync(beforeFile, html);
- fs.writeFileSync(path.join(evidenceDir, 'before.json'), JSON.stringify({
- manualSha256: sha(html), contentVersion: data.release.contentVersion,
- steps: data.steps.length, promptCount: oldMap ? Object.keys(JSON.parse(oldMap[1])).length : null,
- mode: oldMap ? 'single-prompt-per-step' : 'multi-round'
- }, null, 2) + '\n');
- }
- data.release.contentVersion = bank.revision;
- data.release.deploymentStatus = 'local-pending-deployment';
- data.release.productionCutoverConfirmed = false;
- data.common = '01首轮确认称呼与账号,随后确定课题;研究通用约定集中在课题研究的开场与收尾,后续各轮沿用Skill规则。';
- const topic = data.steps.find(step => step.id === 'topic');
- if (bank.steps.topic.rounds[0]?.purpose === 'account-binding') {
- topic.action = '先复制第一轮确认称呼与登录账号,再依次复制第二、三轮确定本组课题;每轮发送后先看结果再继续。';
- topic.prompt = map.topic[0].prompt;
- }
- html = html.replace(dataMatch[0], 'window.WORKSHOP_MANUAL_DATA = ' + JSON.stringify(data, null, 2) + ';\n</script>');
- const serialized = JSON.stringify(map).replace(/</g, '\\u003c');
- if (oldMap) {
- html = html.replace(oldMap[0], ' const dialogueMap = ' + serialized + ';');
- html = html.replace(' const fullPrompt = step => promptMap[step.id];', ` function renderPrompts(step) {
- const rounds = dialogueMap[step.id];
- return '<p class="dialogue-intro">每次只复制一轮,发送后先看结果,再反馈或发送下一轮。也可直接回复“继续”;需要业务选择时先回答,不必重复粘贴前文。</p>' +
- rounds.map((round, index) => {
- const promptId = 'prompt-' + step.id + '-' + index;
- return \`<section class="dialogue-round" aria-labelledby="round-title-\${index}">
- <h2 class="round-title" id="round-title-\${index}"><span>第\${index + 1}轮 / \${rounds.length}</span> \${escape(round.title)}</h2>
- \${round.prerequisite ? '<p class="round-prerequisite">' + escape(round.prerequisite) + '</p>' : ''}
- <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>
- <p class="round-feedback"><b>看完怎样反馈:</b>\${escape(round.feedback)}</p>
- </section>\`;
- }).join('');
- }`);
- 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>';
- assert.ok(html.includes(oldBox), '旧复制区域变化,停止修改');
- html = html.replace(oldBox, '${renderPrompts(step)}');
- const oldListener = " byId('copy-prompt')?.addEventListener('click', () => copy(fullPrompt(step)));";
- assert.ok(html.includes(oldListener));
- html = html.replace(oldListener, ` document.querySelectorAll('[data-copy-round]').forEach(button => button.addEventListener('click', () => {
- const index = Number(button.dataset.copyRound);
- copy(dialogueMap[step.id][index].prompt, byId('prompt-' + step.id + '-' + index));
- }));`);
- html = html.replace('async function copy(text) {', 'async function copy(text, element) {')
- .replace("range.selectNodeContents(byId('prompt-text'));", 'range.selectNodeContents(element);')
- .replace("toast('完整提示词已复制,可发送给数字人');", "toast('本轮提示词已复制,发送后先看结果再反馈');")
- .replace("toast('请复制已选中的完整提示词');", "toast('请复制已选中的本轮提示词');");
- 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}}`;
- html = html.replace('</style>', css + '\n</style>');
- } else {
- html = html.replace(currentMap[0], ' const dialogueMap = ' + serialized + ';');
- }
- assert.ok(!html.includes('const promptMap =') && html.includes('copy(dialogueMap[step.id][index].prompt,'));
- fs.writeFileSync(manualFile, html);
- const result = { manual: manualFile, contentVersion: bank.revision,
- promptBank: bankFile, promptBankSha256: sha(fs.readFileSync(bankFile)),
- manualSha256: sha(html), stepCount: data.steps.length,
- roundCount: Object.values(map).reduce((n, rounds) => n + rounds.length, 0),
- rounds: Object.fromEntries(Object.entries(map).map(([key, rounds]) => [key, rounds.map(round => ({ title: round.title, characters: round.prompt.length }))])),
- productionPublished: false };
- fs.writeFileSync(path.join(evidenceDir, 'manual-build.json'), JSON.stringify(result, null, 2) + '\n');
- return result;
- }
- if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
- const result = updateManual();
- console.log(JSON.stringify({ manual: result.manual, stepCount: result.stepCount, roundCount: result.roundCount, productionPublished: false }));
- }
|