generate-proof-gap-request.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const ROOT = path.resolve(__dirname, '..');
  5. const OUTPUTS = path.join(ROOT, 'outputs');
  6. function main() {
  7. const args = parseArgs(process.argv.slice(2));
  8. const outputDir = path.resolve(args.output || process.env.TIHAO_PROOF_GAP_OUTPUT || path.join(OUTPUTS, `proof-gap-request-${Date.now()}`));
  9. const summary = buildProofGapRequest({
  10. root: ROOT,
  11. handoffPath: args.handoff ? path.resolve(args.handoff) : latestFile(OUTPUTS, 'optimization-handoff-summary.json'),
  12. evidencePath: args.evidence ? path.resolve(args.evidence) : latestFile(OUTPUTS, 'evidence-index-summary.json'),
  13. videoReadinessPath: args.videoReadiness ? path.resolve(args.videoReadiness) : latestFile(OUTPUTS, 'video-resource-readiness-summary.json'),
  14. closurePath: args.closure ? path.resolve(args.closure) : latestFile(OUTPUTS, 'proof-gap-closure-summary.json')
  15. });
  16. fs.mkdirSync(outputDir, { recursive: true });
  17. const jsonPath = path.join(outputDir, 'proof-gap-request-summary.json');
  18. const reportPath = path.join(outputDir, 'proof-gap-request-report.md');
  19. const csvPath = path.join(outputDir, 'proof-gap-request-table.csv');
  20. fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8');
  21. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  22. writeCsv(csvPath, summary.rows);
  23. console.log(JSON.stringify({
  24. outputDir,
  25. json: jsonPath,
  26. report: reportPath,
  27. csv: csvPath,
  28. complete: summary.complete,
  29. gapCount: summary.rows.length,
  30. unresolvedCount: summary.unresolvedCount
  31. }, null, 2));
  32. }
  33. function buildProofGapRequest({ root, handoffPath, evidencePath, videoReadinessPath, closurePath }) {
  34. const handoff = readJson(handoffPath) || {};
  35. const evidence = readJson(evidencePath) || {};
  36. const videoReadiness = readJson(videoReadinessPath) || {};
  37. const closure = readJson(closurePath) || {};
  38. const rows = buildRows({ handoff, evidence, videoReadiness, closure });
  39. return {
  40. generatedAt: new Date().toISOString(),
  41. root,
  42. complete: false,
  43. sources: {
  44. handoff: rel(root, handoffPath),
  45. evidence: rel(root, evidencePath),
  46. videoReadiness: rel(root, videoReadinessPath),
  47. closure: rel(root, closurePath)
  48. },
  49. observed: {
  50. statusCounts: handoff.statusCounts || {},
  51. evidenceCounts: evidence.counts || handoff.evidenceCounts || {},
  52. videoReadinessReady: Boolean(videoReadiness.acceptance?.readyForVideoAbPreflight),
  53. videoReadinessFailureCount: videoReadiness.failureCount ?? null,
  54. proofGapClosureComplete: Boolean(closure.complete),
  55. proofGapClosureOpenCount: closure.openCount ?? null
  56. },
  57. unresolvedCount: rows.filter(row => row.status !== 'ready').length,
  58. rows,
  59. guardrails: [
  60. '不要在 CSV、报告或聊天中写入 sessionToken、模型 token、Authorization、npm token 或内部鉴权参数。',
  61. 'sample、smoke、模板、dry-run 和 provider fallback 不能作为客户效果证明。',
  62. '视频资源就绪只代表可以启动视频 A/B,不能替代 acceptance:video-ab。',
  63. '客户效果必须由 customer-effect:audit 证明,缺客户选择或人工补号基线时不得宣称完成。'
  64. ]
  65. };
  66. }
  67. function buildRows({ handoff, evidence, videoReadiness, closure }) {
  68. const statusCounts = handoff.statusCounts || {};
  69. const videoReady = Boolean(videoReadiness.acceptance?.readyForVideoAbPreflight);
  70. const realCandidateRows = Number(videoReadiness.counts?.realCandidateRows || 0);
  71. const rows = [
  72. row({
  73. id: 'historical-dataset',
  74. owner: '商务',
  75. status: 'missing_real_data',
  76. gap: '真实历史 Brief、人工最终名单、客户选择、拒绝原因和历史人工补号量基线未完成独立审计。',
  77. required: '按 data-intake-pack 填写 history-data-template.csv,并包含 5-10 个真实历史 Brief。',
  78. templateCommand: 'npm run data:intake-template -- --output outputs\\data-intake-pack-latest',
  79. templateArtifact: 'outputs\\data-intake-pack-latest\\history-data-template.csv;outputs\\data-intake-pack-latest\\manual-review-template.csv',
  80. verify: 'npm run history:from-csv -- --input <历史数据CSV> --output <history-dataset目录>;npm run history:audit -- --input <history-dataset目录> --output <历史审计输出目录> --strict',
  81. pass: 'history:audit 显示 readyForCustomerEffectProof=true,且客户选择、拒绝原因、历史人工补号量基线齐全。'
  82. }),
  83. row({
  84. id: 'video-real-candidate',
  85. owner: '商务/投放',
  86. status: videoReady ? 'ready' : 'missing_real_data',
  87. gap: videoReady ? '视频 A/B 前置资源已具备。' : `视频资源 readiness 未通过;当前真实候选视频行=${realCandidateRows}。`,
  88. required: '补齐至少一个真实参考视频和一个真实候选视频,提供视频 URL,并补封面、ASR、帧图或正文证据之一。',
  89. templateCommand: 'npm run video:intake-template -- --output outputs\\video-intake-pack-latest',
  90. templateArtifact: 'outputs\\video-intake-pack-latest\\video-resource-template.csv',
  91. verify: 'npm run video:resource-readiness -- --input <video-resource-template.csv> --output <视频资源审计输出目录> --strict',
  92. pass: 'readyForVideoAbPreflight=true,failureCount=0。'
  93. }),
  94. row({
  95. id: 'video-ab-live-proof',
  96. owner: '技术/AI',
  97. status: 'ready_not_proven',
  98. gap: '视频 A/B 脚本和 smoke 已就绪,但缺真实 provider + 真实视频资源下的 live 结果。',
  99. required: '配置真实 sessionToken、company、VOC social provider、视频分析 provider、doubao-seed-2-0-pro 模型和真实参考视频链接。',
  100. templateCommand: 'npm run video:intake-template -- --output outputs\\video-intake-pack-latest',
  101. templateArtifact: 'outputs\\video-intake-pack-latest\\video-resource-template.csv',
  102. verify: 'npm run acceptance:video-ab',
  103. pass: '真实参考资源、真实视频 URL、封面/ASR/帧图、provider ok、证据卡、Top10 非退化、强推荐不下降全部通过。'
  104. }),
  105. row({
  106. id: 'live-provider-overnight-proof',
  107. owner: '技术/AI',
  108. status: 'blocked_by_external_data',
  109. gap: '真实 live/provider 长跑尚无完整通过证明。',
  110. required: '准备有效 sessionToken、company、provider 配置、真实历史数据和长跑策略矩阵。',
  111. templateCommand: 'npm run data:intake-template -- --output outputs\\data-intake-pack-latest',
  112. templateArtifact: 'outputs\\data-intake-pack-latest\\history-data-template.csv',
  113. verify: 'npm run longrun:readiness;npm run optimization:pipeline 或 overnight live 矩阵命令',
  114. pass: 'liveEnabled=true,failureCount=0,failedGateCount=0,overallPass=true,且严格 provider 门禁按需开启。'
  115. }),
  116. row({
  117. id: 'manual-review-and-customer-effect',
  118. owner: '商务',
  119. status: 'blocked_by_external_data',
  120. gap: '缺本轮商务复核、客户最终选择和本轮人工补号量,无法证明客户效果。',
  121. required: '按 manual-review-template.csv 标注人工复核标签、客户选择、归因类型、反馈原因、本轮人工补号量。',
  122. templateCommand: 'npm run data:intake-template -- --output outputs\\data-intake-pack-latest',
  123. templateArtifact: 'outputs\\data-intake-pack-latest\\manual-review-template.csv',
  124. verify: 'npm run review:metrics -- --input <已标注CSV> --output <复核指标输出目录> --strict;npm run customer-effect:audit -- --review-csv <已标注CSV> --history-audit <historical-dataset-audit.json> --current-manual-supplement-count <本轮人工补号量> --output <客户效果输出目录> --strict',
  125. pass: '客户选中率、参考链路客户选中率、历史人工补号基线、本轮人工补号量和人工补号减少率全部通过。'
  126. })
  127. ];
  128. if (Number(statusCounts.ready_not_proven || 0) === 0 && Number(statusCounts.blocked_by_external_data || 0) === 0) {
  129. rows.push(row({
  130. id: 'status-audit',
  131. owner: 'AI',
  132. status: 'ready',
  133. gap: '状态审计没有 ready_not_proven 或 blocked_by_external_data。',
  134. required: '维持当前证据链。',
  135. templateCommand: '无需模板',
  136. templateArtifact: '无',
  137. verify: 'npm run optimization:status',
  138. pass: 'complete=true。'
  139. }));
  140. }
  141. return applyClosureStatuses(rows, closure);
  142. }
  143. function applyClosureStatuses(rows, closure) {
  144. const closureRows = Array.isArray(closure?.rows) ? closure.rows : [];
  145. if (!closureRows.length) return rows;
  146. const closedById = new Map(
  147. closureRows
  148. .filter(row => row && row.status === 'closed' && row.id)
  149. .map(row => [row.id, row])
  150. );
  151. return rows.map(item => {
  152. const closed = closedById.get(item.id);
  153. if (!closed) return item;
  154. return {
  155. ...item,
  156. status: 'ready',
  157. gap: `proof-gap:closure 已关闭:${closed.evidence || item.gap}`,
  158. required: `${item.required} 当前 closure 状态为 closed,保留本行作为证据链入口。`
  159. };
  160. });
  161. }
  162. function row(value) {
  163. return {
  164. id: value.id,
  165. owner: value.owner,
  166. status: value.status,
  167. gap: value.gap,
  168. required: value.required,
  169. templateCommand: value.templateCommand || '',
  170. templateArtifact: value.templateArtifact || '',
  171. verify: value.verify,
  172. pass: value.pass
  173. };
  174. }
  175. function renderReport(summary) {
  176. const lines = [
  177. '# 提号真实证明缺口请求包',
  178. '',
  179. `- 生成时间:${summary.generatedAt}`,
  180. `- 是否完成长期目标:否`,
  181. `- 未解决缺口数:${summary.unresolvedCount}`,
  182. '',
  183. '## 当前观测',
  184. '',
  185. `- status.passed:${summary.observed.statusCounts.passed || 0}`,
  186. `- status.ready_not_proven:${summary.observed.statusCounts.ready_not_proven || 0}`,
  187. `- status.blocked_by_external_data:${summary.observed.statusCounts.blocked_by_external_data || 0}`,
  188. `- evidence.real_evidence:${summary.observed.evidenceCounts.real_evidence || 0}`,
  189. `- evidence.smoke_or_local:${summary.observed.evidenceCounts.smoke_or_local || 0}`,
  190. `- evidence.not_business_proof:${summary.observed.evidenceCounts.not_business_proof || 0}`,
  191. `- video.readyForVideoAbPreflight:${summary.observed.videoReadinessReady ? 'true' : 'false'}`,
  192. `- video.failureCount:${summary.observed.videoReadinessFailureCount ?? 'unknown'}`,
  193. `- closure.complete:${summary.observed.proofGapClosureComplete ? 'true' : 'false'}`,
  194. `- closure.openCount:${summary.observed.proofGapClosureOpenCount ?? 'unknown'}`,
  195. '',
  196. '## 请求明细',
  197. '',
  198. '| ID | 负责人 | 状态 | 缺口 | 需要补齐 | 模板命令 | 模板产物 | 验证命令 | 通过标准 |',
  199. '| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
  200. ...summary.rows.map(item => `| ${item.id} | ${item.owner} | ${item.status} | ${escapeCell(item.gap)} | ${escapeCell(item.required)} | \`${escapeCell(item.templateCommand)}\` | ${escapeCell(item.templateArtifact)} | \`${escapeCell(item.verify)}\` | ${escapeCell(item.pass)} |`),
  201. '',
  202. '## 边界',
  203. '',
  204. ...summary.guardrails.map(item => `- ${item}`)
  205. ];
  206. return lines.join('\n');
  207. }
  208. function writeCsv(file, rows) {
  209. const header = ['缺口ID', '负责人', '状态', '当前缺口', '需要补齐', '模板命令', '模板产物', '验证命令', '通过标准'];
  210. const body = rows.map(item => [item.id, item.owner, item.status, item.gap, item.required, item.templateCommand, item.templateArtifact, item.verify, item.pass]);
  211. fs.writeFileSync(file, withBom([header, ...body].map(row => row.map(csvCell).join(',')).join('\n')), 'utf8');
  212. }
  213. function latestFile(outputsDir, fileName) {
  214. if (!fs.existsSync(outputsDir)) return '';
  215. return fs.readdirSync(outputsDir, { withFileTypes: true })
  216. .filter(entry => entry.isDirectory())
  217. .map(entry => path.join(outputsDir, entry.name, fileName))
  218. .filter(file => fs.existsSync(file))
  219. .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] || '';
  220. }
  221. function readJson(file) {
  222. if (!file || !fs.existsSync(file)) return null;
  223. try {
  224. return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  225. } catch {
  226. return null;
  227. }
  228. }
  229. function parseArgs(argv) {
  230. const args = {};
  231. for (let index = 0; index < argv.length; index += 1) {
  232. const raw = argv[index];
  233. if (!raw.startsWith('--')) continue;
  234. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  235. const next = argv[index + 1];
  236. if (!next || next.startsWith('--')) args[key] = true;
  237. else {
  238. args[key] = next;
  239. index += 1;
  240. }
  241. }
  242. return args;
  243. }
  244. function rel(root, file) {
  245. return file ? path.relative(root, file).replace(/\\/g, '/') : '';
  246. }
  247. function escapeCell(value) {
  248. return String(value ?? '').replace(/\|/g, '/').replace(/\r?\n/g, ' ');
  249. }
  250. function csvCell(value) {
  251. const text = String(value ?? '');
  252. return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  253. }
  254. function withBom(text) {
  255. return `\uFEFF${text}`;
  256. }
  257. if (require.main === module) main();
  258. module.exports = {
  259. buildProofGapRequest,
  260. renderReport
  261. };