proof-gap-operator-pack.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const ROOT = path.resolve(__dirname, '..');
  5. const DEFAULT_OUTPUT = path.join(ROOT, 'outputs', 'proof-gap-operator-pack-latest');
  6. function main() {
  7. const args = parseArgs(process.argv.slice(2));
  8. const outputsDir = path.resolve(args.outputs || path.join(ROOT, 'outputs'));
  9. const outputDir = path.resolve(args.output || DEFAULT_OUTPUT);
  10. const summary = buildOperatorPack({ root: ROOT, outputsDir });
  11. fs.mkdirSync(outputDir, { recursive: true });
  12. const summaryPath = path.join(outputDir, 'proof-gap-operator-pack-summary.json');
  13. const reportPath = path.join(outputDir, 'proof-gap-operator-pack.md');
  14. const csvPath = path.join(outputDir, 'proof-gap-operator-pack.csv');
  15. const ownerArtifacts = writeOwnerArtifacts(outputDir, summary.ownerGroups, summary.rows);
  16. summary.ownerArtifacts = ownerArtifacts;
  17. summary.ownerArtifactCount = ownerArtifacts.length;
  18. summary.files.byOwnerDir = 'outputs/proof-gap-operator-pack-latest/by-owner';
  19. fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
  20. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  21. fs.writeFileSync(csvPath, withBom(renderCsv(summary.rows)), 'utf8');
  22. console.log(JSON.stringify({
  23. outputDir,
  24. summary: summaryPath,
  25. report: reportPath,
  26. csv: csvPath,
  27. passed: summary.passed,
  28. openCount: summary.openCount,
  29. rowCount: summary.rowCount
  30. }, null, 2));
  31. if (args.strict && !summary.passed) process.exitCode = 1;
  32. }
  33. function buildOperatorPack({ root, outputsDir }) {
  34. const closure = readJson(path.join(pickDir(outputsDir, 'proof-gap-closure-latest', /^proof-gap-closure-/), 'proof-gap-closure-summary.json'));
  35. const checklist = readJson(path.join(pickDir(outputsDir, 'intake-field-checklist-latest', /^intake-field-checklist-/), 'intake-field-checklist-summary.json'));
  36. const nextActions = readJson(path.join(pickDir(outputsDir, 'business-proof-next-actions-latest', /^business-proof-next-actions-/), 'business-proof-next-actions-summary.json'));
  37. const planStatus = readJson(path.join(pickDir(outputsDir, 'plan-execution-status-latest', /^plan-execution-status-/), 'plan-execution-status-summary.json'));
  38. const rows = buildRows({ closure, checklist, nextActions });
  39. const openRows = rows.filter(row => row.status !== 'closed');
  40. const ownerGroups = groupByOwner(openRows);
  41. const gapIds = rows.map(row => row.gapId).filter(Boolean);
  42. const fillFiles = Array.from(new Set(rows.flatMap(row => row.fillFiles || [])));
  43. const recheckCommands = Array.from(new Set(rows.flatMap(row => row.recheckCommands || [])));
  44. const evidenceRowCount = rows.filter(row => String(row.evidence || '').trim()).length;
  45. const nextActionCount = rows.filter(row => String(row.nextAction || '').trim()).length;
  46. const missingProofRequirementCount = rows.reduce((sum, row) => sum + (Array.isArray(row.missingProofRequirements) ? row.missingProofRequirements.length : 0), 0);
  47. const passed = rows.length >= 5 &&
  48. openRows.length >= 1 &&
  49. rows.every(hasRunnableBoundary) &&
  50. rows.some(row => row.gapId === 'historical-dataset') &&
  51. rows.some(row => row.gapId === 'video-real-candidate') &&
  52. rows.some(row => row.gapId === 'manual-review-and-customer-effect');
  53. return {
  54. generatedAt: new Date().toISOString(),
  55. root,
  56. outputsDir,
  57. passed,
  58. complete: openRows.length === 0,
  59. directCustomerProof: false,
  60. proofLevel: 'not_business_proof',
  61. openCount: openRows.length,
  62. rowCount: rows.length,
  63. gapIds,
  64. fillFileCount: fillFiles.length,
  65. recheckCommandCount: recheckCommands.length,
  66. ownerGroupCount: ownerGroups.length,
  67. ownerArtifactCount: 0,
  68. evidenceRowCount,
  69. nextActionCount,
  70. missingProofRequirementCount,
  71. sourceState: {
  72. proofGapClosureOpenCount: Number(closure?.openCount || 0),
  73. planProofOpenCount: Number(planStatus?.proofOpenCount || 0),
  74. intakeFieldItemCount: Number(checklist?.itemCount || 0),
  75. nextActionCount: Array.isArray(nextActions?.actions) ? nextActions.actions.length : 0
  76. },
  77. files: {
  78. summary: 'outputs/proof-gap-operator-pack-latest/proof-gap-operator-pack-summary.json',
  79. report: 'outputs/proof-gap-operator-pack-latest/proof-gap-operator-pack.md',
  80. csv: 'outputs/proof-gap-operator-pack-latest/proof-gap-operator-pack.csv',
  81. byOwnerDir: 'outputs/proof-gap-operator-pack-latest/by-owner'
  82. },
  83. ownerGroups,
  84. ownerArtifacts: [],
  85. guardrails: [
  86. '本操作包只用于安排补证,不证明客户效果。',
  87. '所有模板、example.com、候选、待补内容都必须替换为真实客户材料。',
  88. 'proofOpenCount 不为 0 时,不得宣称命中率提升、客户效果达标或人工补号量下降。',
  89. '不得在表格、报告、命令或日志中写入 sessionToken、鉴权头、模型 token 或 npm token。'
  90. ],
  91. rows
  92. };
  93. }
  94. function hasRunnableBoundary(row) {
  95. return Boolean(row.owner) &&
  96. Array.isArray(row.fillFiles) &&
  97. row.fillFiles.length > 0 &&
  98. Array.isArray(row.recheckCommands) &&
  99. row.recheckCommands.length > 0 &&
  100. Boolean(String(row.evidence || '').trim()) &&
  101. Boolean(String(row.nextAction || '').trim()) &&
  102. Boolean(row.acceptance) &&
  103. /不.*证明|不得宣称|不等同于/.test(String(row.boundary || ''));
  104. }
  105. function buildRows({ closure, checklist, nextActions }) {
  106. const closureRows = Array.isArray(closure?.rows) ? closure.rows : [];
  107. const actionByGap = new Map((Array.isArray(nextActions?.actions) ? nextActions.actions : []).map(action => [
  108. normalizeActionGapId(action),
  109. action
  110. ]));
  111. const fieldMap = fieldsBySection(checklist);
  112. const known = [
  113. {
  114. gapId: 'historical-dataset',
  115. title: '真实历史 Brief 数据集',
  116. owner: '商务',
  117. priority: 1,
  118. fillFiles: ['outputs/data-intake-pack-latest/history-data-template.csv'],
  119. fields: fieldsFor(fieldMap, 'history', ['客户原始 Brief', '参考账号或视频', '博主名称', '主页链接', '客户选择', '历史人工补号量基线']),
  120. recheckCommands: [
  121. 'npm run intake:readiness -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --output outputs\\intake-readiness-latest',
  122. 'npm run history:from-csv -- --input <历史数据CSV> --output <history-dataset目录>',
  123. 'npm run history:audit -- --input <history-dataset目录> --output <历史审计输出目录> --strict'
  124. ],
  125. acceptance: 'historyReady=true;真实历史 Brief 数 >=5;readyForCustomerEffectProof=true',
  126. boundary: '补齐历史材料不证明客户效果。'
  127. },
  128. {
  129. gapId: 'video-real-candidate',
  130. title: '真实候选视频资源',
  131. owner: '商务/投放',
  132. priority: 2,
  133. fillFiles: ['outputs/video-intake-pack-latest/video-resource-template.csv'],
  134. fields: fieldsFor(fieldMap, 'video', ['资源角色', '博主名称', '主页链接', '视频链接', '标题', '内容摘要', '风格调性标签']),
  135. recheckCommands: [
  136. 'npm run video:resource-readiness -- --input outputs\\video-intake-pack-latest\\video-resource-template.csv --output outputs\\video-resource-readiness-latest --strict'
  137. ],
  138. acceptance: 'readyForVideoAbPreflight=true;failureCount=0;至少有真实参考视频和真实候选视频。',
  139. boundary: '补齐视频资源只证明 A/B 前置资源齐备,不证明客户效果。'
  140. },
  141. {
  142. gapId: 'video-ab-live-proof',
  143. title: '真实视频 A/B live proof',
  144. owner: '技术/AI',
  145. priority: 3,
  146. fillFiles: ['outputs/video-intake-pack-latest/video-resource-template.csv'],
  147. fields: ['proofContext.mode=live', 'proofContext.collectionMode=live', 'requiresVocSocialProvider=true', 'requiresVideoAnalysisProvider=true'],
  148. recheckCommands: ['npm run acceptance:video-ab'],
  149. acceptance: '真实 provider、真实参考视频和真实候选视频下通过;proofContext 标记为 live。',
  150. boundary: 'sample 或接口 200 不证明视频分析提升提号率。'
  151. },
  152. {
  153. gapId: 'live-provider-overnight-proof',
  154. title: '真实 live/provider 长跑证明',
  155. owner: '技术/AI',
  156. priority: 4,
  157. fillFiles: ['outputs/data-intake-pack-latest/history-data-template.csv', 'outputs/video-intake-pack-latest/video-resource-template.csv'],
  158. fields: ['liveEnabled=true', 'overallPass=true', 'failureCount=0', 'failedGateCount=0'],
  159. recheckCommands: [
  160. 'npm run longrun:readiness -- --mode full-matrix --output outputs\\long-run-readiness-latest',
  161. 'npm run overnight:quality'
  162. ],
  163. acceptance: 'longrun ready=true;overnight aggregate liveEnabled=true、overallPass=true、failureCount=0。',
  164. boundary: '长跑通过只证明 provider/live 流程稳定,不单独证明客户效果。'
  165. },
  166. {
  167. gapId: 'manual-review-and-customer-effect',
  168. title: '商务复核和客户效果证明',
  169. owner: '商务',
  170. priority: 5,
  171. fillFiles: ['outputs/data-intake-pack-latest/manual-review-template.csv'],
  172. fields: fieldsFor(fieldMap, 'review', ['客户选择', '归因类型', '反馈原因', '本轮人工补号量', '历史人工补号量基线']),
  173. recheckCommands: [
  174. 'npm run review:metrics -- --input <已标注CSV> --output <复核指标输出目录> --strict',
  175. 'npm run customer-effect:audit -- --review-csv <已标注CSV> --history-audit <historical-dataset-audit.json> --current-manual-supplement-count <本轮人工补号量> --output <客户效果输出目录> --strict'
  176. ],
  177. acceptance: 'review:metrics overallPass=true;customer-effect:audit overallPass=true。',
  178. boundary: '缺客户选择、历史基线或本轮人工补号量时,不得宣称客户效果。'
  179. }
  180. ];
  181. return known.map(item => {
  182. const closureRow = closureRows.find(row => row.id === item.gapId);
  183. const action = actionByGap.get(item.gapId);
  184. const status = closureRow?.status || action?.status || 'open';
  185. return {
  186. ...item,
  187. primaryFillFile: item.fillFiles[0] || '',
  188. primaryRecheckCommand: item.recheckCommands[0] || '',
  189. status,
  190. evidence: closureRow?.evidence || action?.evidence || '',
  191. missingProofRequirements: Array.isArray(closureRow?.missingProofRequirements) ? closureRow.missingProofRequirements : [],
  192. nextAction: status === 'closed'
  193. ? (closureRow?.next || '保持证据链并纳入 evidence:index。')
  194. : (action?.next || closureRow?.next || item.recheckCommands[0])
  195. };
  196. });
  197. }
  198. function normalizeActionGapId(action) {
  199. const id = String(action?.id || '');
  200. if (id.includes('video-ab-live-proof')) return 'video-ab-live-proof';
  201. if (id.includes('live-provider-overnight-proof')) return 'live-provider-overnight-proof';
  202. const title = String(action?.title || '');
  203. if (/历史|Brief/i.test(title)) return 'historical-dataset';
  204. if (/视频资源|候选视频/i.test(title)) return 'video-real-candidate';
  205. if (/复核|客户效果/i.test(title)) return 'manual-review-and-customer-effect';
  206. return id;
  207. }
  208. function fieldsBySection(checklist) {
  209. const result = {};
  210. for (const item of Array.isArray(checklist?.items) ? checklist.items : []) {
  211. const section = item.section || 'unknown';
  212. if (!result[section]) result[section] = [];
  213. const field = item.field || item.issueType || '';
  214. if (field && !result[section].includes(field)) result[section].push(field);
  215. }
  216. return result;
  217. }
  218. function fieldsFor(fieldMap, section, fallback) {
  219. const fields = fieldMap[section] || [];
  220. return fields.length ? fields.slice(0, 8) : fallback;
  221. }
  222. function groupByOwner(rows) {
  223. const groups = new Map();
  224. for (const row of rows) {
  225. if (!groups.has(row.owner)) groups.set(row.owner, []);
  226. groups.get(row.owner).push(row);
  227. }
  228. return Array.from(groups.entries()).map(([owner, items]) => ({
  229. owner,
  230. actionCount: items.length,
  231. topPriority: Math.min(...items.map(item => Number(item.priority || 99))),
  232. fillFiles: Array.from(new Set(items.flatMap(item => item.fillFiles))),
  233. recheckCommands: Array.from(new Set(items.flatMap(item => item.recheckCommands))),
  234. gapIds: items.map(item => item.gapId)
  235. }));
  236. }
  237. function renderReport(summary) {
  238. return [
  239. '# 提号补证操作包',
  240. '',
  241. `- 生成时间:${summary.generatedAt}`,
  242. `- 是否完成:${summary.complete ? '是' : '否'}`,
  243. `- 证明缺口 openCount:${summary.openCount}`,
  244. `- 行数:${summary.rowCount}`,
  245. `- 缺口 ID:${summary.gapIds.join(';')}`,
  246. `- 填写文件数:${summary.fillFileCount}`,
  247. `- 复验命令数:${summary.recheckCommandCount}`,
  248. `- 当前证据行数:${summary.evidenceRowCount}`,
  249. `- 下一步动作数:${summary.nextActionCount}`,
  250. `- 缺失证明要求数:${summary.missingProofRequirementCount}`,
  251. '- 用途:把真实补证缺口压成负责人、填写文件、字段、复验命令和通过标准;不证明客户效果。',
  252. '',
  253. '## 负责人视图',
  254. '',
  255. '| 负责人 | 动作数 | 最高优先级 | 填写文件 | 复验命令 | 缺口 |',
  256. '| --- | ---: | ---: | --- | --- | --- |',
  257. ...summary.ownerGroups.map(group => tableRow([
  258. group.owner,
  259. group.actionCount,
  260. group.topPriority,
  261. group.fillFiles.join('; '),
  262. group.recheckCommands.join('; '),
  263. group.gapIds.join('; ')
  264. ])),
  265. '',
  266. '## 负责人文件',
  267. '',
  268. '| 负责人 | 动作数 | 最高优先级 | Markdown | CSV | 缺口 |',
  269. '| --- | ---: | ---: | --- | --- | --- |',
  270. ...summary.ownerArtifacts.map(item => tableRow([
  271. item.owner,
  272. item.actionCount,
  273. item.topPriority,
  274. item.markdown,
  275. item.csv,
  276. item.gapIds.join('; ')
  277. ])),
  278. '',
  279. '## 缺口操作表',
  280. '',
  281. '| 优先级 | 缺口 | 负责人 | 状态 | 当前证据 | 填写文件 | 待补字段/证明 | 下一步 | 复验命令 | 通过标准 | 边界 |',
  282. '| ---: | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |',
  283. ...summary.rows.map(row => tableRow([
  284. row.priority,
  285. `${row.title} (${row.gapId})`,
  286. row.owner,
  287. row.status,
  288. row.evidence,
  289. row.fillFiles.join('; '),
  290. uniqueList(row.fields.concat(row.missingProofRequirements || [])).join('; '),
  291. row.nextAction,
  292. row.recheckCommands.join('; '),
  293. row.acceptance,
  294. row.boundary
  295. ])),
  296. '',
  297. '## 防误报边界',
  298. '',
  299. ...summary.guardrails.map(item => `- ${item}`)
  300. ].join('\n');
  301. }
  302. function writeOwnerArtifacts(outputDir, ownerGroups, rows) {
  303. const ownerDir = path.join(outputDir, 'by-owner');
  304. fs.mkdirSync(ownerDir, { recursive: true });
  305. const artifacts = [];
  306. for (const group of ownerGroups) {
  307. const ownerRows = rows.filter(row => row.owner === group.owner);
  308. const slug = slugOwner(group.owner);
  309. const markdownRel = path.join('by-owner', `${slug}.md`).replace(/\\/g, '/');
  310. const csvRel = path.join('by-owner', `${slug}.csv`).replace(/\\/g, '/');
  311. fs.writeFileSync(path.join(outputDir, markdownRel), withBom(renderOwnerReport(group, ownerRows)), 'utf8');
  312. fs.writeFileSync(path.join(outputDir, csvRel), withBom(renderCsv(ownerRows)), 'utf8');
  313. artifacts.push({
  314. owner: group.owner,
  315. actionCount: group.actionCount,
  316. topPriority: group.topPriority,
  317. markdown: markdownRel,
  318. csv: csvRel,
  319. gapIds: group.gapIds,
  320. evidenceRowCount: ownerRows.filter(row => String(row.evidence || '').trim()).length,
  321. nextActionCount: ownerRows.filter(row => String(row.nextAction || '').trim()).length
  322. });
  323. }
  324. return artifacts;
  325. }
  326. function renderOwnerReport(group, rows) {
  327. return [
  328. `# ${group.owner}提号补证操作包`,
  329. '',
  330. `- 动作数:${group.actionCount}`,
  331. `- 最高优先级:${group.topPriority}`,
  332. `- 缺口:${group.gapIds.join(';')}`,
  333. '- 用途:只展示归属本负责人的真实证明缺口、当前证据、下一步、填写文件和复验命令;不证明客户效果。',
  334. '',
  335. '## 操作明细',
  336. '',
  337. '| 优先级 | 缺口 | 状态 | 当前证据 | 填写文件 | 待补字段/证明 | 下一步 | 复验命令 | 通过标准 | 边界 |',
  338. '| ---: | --- | --- | --- | --- | --- | --- | --- | --- | --- |',
  339. ...rows.map(row => tableRow([
  340. row.priority,
  341. `${row.title} (${row.gapId})`,
  342. row.status,
  343. row.evidence,
  344. row.fillFiles.join('; '),
  345. uniqueList(row.fields.concat(row.missingProofRequirements || [])).join('; '),
  346. row.nextAction,
  347. row.recheckCommands.join('; '),
  348. row.acceptance,
  349. row.boundary
  350. ])),
  351. '',
  352. '## 边界',
  353. '',
  354. '- 本文件只用于按负责人补证派工,不证明提号率提升、客户效果达标或人工补号量下降。',
  355. '- 不要在补证材料、命令、日志或报告中写入 sessionToken、Authorization、模型 token 或 npm token。'
  356. ].join('\n');
  357. }
  358. function renderCsv(rows) {
  359. const headers = ['优先级', '缺口ID', '缺口', '负责人', '状态', '当前证据', '主填写文件', '首个复验命令', '填写文件', '待补字段/证明', '下一步', '复验命令', '通过标准', '边界'];
  360. return [
  361. headers.join(','),
  362. ...rows.map(row => [
  363. row.priority,
  364. row.gapId,
  365. row.title,
  366. row.owner,
  367. row.status,
  368. row.evidence,
  369. row.primaryFillFile,
  370. row.primaryRecheckCommand,
  371. row.fillFiles.join('; '),
  372. uniqueList(row.fields.concat(row.missingProofRequirements || [])).join('; '),
  373. row.nextAction,
  374. row.recheckCommands.join('; '),
  375. row.acceptance,
  376. row.boundary
  377. ].map(csvCell).join(','))
  378. ].join('\n');
  379. }
  380. function pickDir(outputsDir, latestName, pattern) {
  381. const latest = path.join(outputsDir, latestName);
  382. if (fs.existsSync(latest)) return latest;
  383. if (!fs.existsSync(outputsDir)) return latest;
  384. const dirs = fs.readdirSync(outputsDir, { withFileTypes: true })
  385. .filter(entry => entry.isDirectory() && pattern.test(entry.name))
  386. .map(entry => path.join(outputsDir, entry.name))
  387. .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
  388. return dirs[0] || latest;
  389. }
  390. function readJson(file) {
  391. if (!fs.existsSync(file)) return null;
  392. return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  393. }
  394. function tableRow(cells) {
  395. return `| ${cells.map(escapeCell).join(' | ')} |`;
  396. }
  397. function escapeCell(value) {
  398. return String(value ?? '').replace(/\|/g, '/').replace(/\r?\n/g, ' ');
  399. }
  400. function uniqueList(values) {
  401. return Array.from(new Set((values || []).map(value => String(value || '').trim()).filter(Boolean)));
  402. }
  403. function slugOwner(owner) {
  404. const map = {
  405. '商务': 'business',
  406. '投放/商务': 'media-business',
  407. '商务/投放': 'business-media',
  408. '技术/AI': 'tech-ai'
  409. };
  410. if (map[owner]) return map[owner];
  411. const ascii = String(owner || 'owner').replace(/[^\w]+/g, '-').replace(/^-|-$/g, '').toLowerCase();
  412. return ascii || Buffer.from(String(owner || 'owner')).toString('hex').slice(0, 12);
  413. }
  414. function csvCell(value) {
  415. const text = String(value ?? '');
  416. return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  417. }
  418. function parseArgs(argv) {
  419. const args = {};
  420. for (let index = 0; index < argv.length; index += 1) {
  421. const raw = argv[index];
  422. if (!raw.startsWith('--')) continue;
  423. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  424. const next = argv[index + 1];
  425. if (!next || next.startsWith('--')) args[key] = true;
  426. else {
  427. args[key] = next;
  428. index += 1;
  429. }
  430. }
  431. return args;
  432. }
  433. function withBom(text) {
  434. return `\uFEFF${text}`;
  435. }
  436. if (require.main === module) main();
  437. module.exports = {
  438. buildOperatorPack
  439. };