business-proof-next-actions.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786
  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_BUSINESS_PROOF_NEXT_ACTIONS_OUTPUT || path.join(OUTPUTS, `business-proof-next-actions-${Date.now()}`));
  9. const summary = buildNextActions({
  10. root: ROOT,
  11. outputsDir: path.resolve(args.outputs || OUTPUTS),
  12. limit: Number(args.limit || 8)
  13. });
  14. fs.mkdirSync(outputDir, { recursive: true });
  15. const jsonPath = path.join(outputDir, 'business-proof-next-actions-summary.json');
  16. const reportPath = path.join(outputDir, 'business-proof-next-actions-report.md');
  17. const csvPath = path.join(outputDir, 'business-proof-next-actions.csv');
  18. const ownerArtifacts = writeOwnerArtifacts(outputDir, summary.ownerGroups, summary.actions, summary.ownerRepairArtifacts);
  19. summary.ownerArtifacts = ownerArtifacts;
  20. summary.ownerArtifactCount = ownerArtifacts.length;
  21. fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8');
  22. fs.writeFileSync(reportPath, withBom(renderReport(summary, ownerArtifacts)), 'utf8');
  23. fs.writeFileSync(csvPath, withBom(renderCsv(summary.actions)), 'utf8');
  24. console.log(JSON.stringify({
  25. outputDir,
  26. json: jsonPath,
  27. report: reportPath,
  28. csv: csvPath,
  29. ownerArtifacts: ownerArtifacts.length,
  30. complete: summary.complete,
  31. actionCount: summary.actionCount,
  32. topAction: summary.topActions[0]?.title || ''
  33. }, null, 2));
  34. if (!summary.complete && args.strict) process.exitCode = 1;
  35. }
  36. function buildNextActions({ root, outputsDir, limit = 8 }) {
  37. const progress = readJson(latestFile(outputsDir, 'business-proof-progress-summary.json'));
  38. const closure = readJson(latestFile(outputsDir, 'proof-gap-closure-summary.json'));
  39. const intake = readJson(latestFile(outputsDir, 'intake-readiness-summary.json'));
  40. const videoReadiness = readJson(latestFile(outputsDir, 'video-resource-readiness-summary.json'));
  41. const homepageReadiness = readJson(latestFile(outputsDir, 'homepage-evidence-readiness-summary.json'));
  42. const pipeline = readJson(latestFile(outputsDir, 'optimization-pipeline-summary.json'));
  43. const completion = readJson(latestFile(outputsDir, 'optimization-completion-summary.json'));
  44. const pipelineFreshness = getPipelineFreshnessFromCompletion(completion);
  45. const candidates = [];
  46. const primarySections = new Set();
  47. const primaryGapIds = new Set();
  48. for (const row of progress?.rows || []) {
  49. if (row.status === 'pass') continue;
  50. const order = Number(row.order);
  51. if (order === 3) {
  52. primarySections.add('history');
  53. primaryGapIds.add('historical-dataset');
  54. }
  55. if (order === 4) {
  56. primarySections.add('video');
  57. primaryGapIds.add('video-real-candidate');
  58. }
  59. if (order === 5) primaryGapIds.add('intake-readiness');
  60. if (order === 12) {
  61. primarySections.add('review');
  62. primaryGapIds.add('manual-review-and-customer-effect');
  63. }
  64. if (order === 14) primaryGapIds.add('manual-review-and-customer-effect');
  65. candidates.push(actionFromProgressRow(row, { intake, videoReadiness }));
  66. }
  67. for (const row of closure?.rows || []) {
  68. if (row.status !== 'open') continue;
  69. if (primaryGapIds.has(row.id)) {
  70. mergeClosureRowIntoPrimaryAction(candidates, row);
  71. continue;
  72. }
  73. candidates.push(actionFromClosureRow(row));
  74. }
  75. for (const issue of intake?.issues || []) {
  76. if (primarySections.has(issue.section)) continue;
  77. candidates.push(actionFromIntakeIssue(issue));
  78. }
  79. if (!pipelineFreshness.stale) {
  80. mergePipelineNextActions(candidates, pipeline, { closure, videoReadiness });
  81. }
  82. const deduped = compactBusinessGapActions(dedupeActions(candidates))
  83. .sort((a, b) => a.priority - b.priority || a.order - b.order)
  84. .slice(0, limit);
  85. const ownerGroups = groupByOwner(deduped);
  86. const ownerRepairArtifacts = buildOwnerRepairActionArtifacts({
  87. root,
  88. outputsDir,
  89. intake,
  90. videoReadiness,
  91. homepageReadiness
  92. });
  93. return {
  94. generatedAt: new Date().toISOString(),
  95. root,
  96. outputsDir,
  97. complete: Boolean(progress?.complete) && Number(closure?.openCount || 0) === 0,
  98. actionCount: deduped.length,
  99. ownerGroupCount: ownerGroups.length,
  100. ownerRepairArtifactCount: ownerRepairArtifacts.length,
  101. topActions: summarizeTopActions(deduped),
  102. sourceState: {
  103. progressComplete: Boolean(progress?.complete),
  104. progressCounts: progress?.counts || {},
  105. proofGapOpenCount: closure ? Number(closure.openCount || 0) : null,
  106. intakeFailureCount: intake ? Number(intake.failureCount || 0) : null,
  107. videoFailureCount: videoReadiness ? Number(videoReadiness.failureCount || 0) : null,
  108. homepageFailureCount: homepageReadiness ? Number(homepageReadiness.failureCount || 0) : null,
  109. pipelineReadyForClaim: pipeline ? Boolean(pipeline.readyForClaim) : null,
  110. pipelineFailCount: pipeline ? Number(pipeline.counts?.fail || 0) : null,
  111. pipelineNextActionCount: pipeline && !pipelineFreshness.stale ? Number(pipeline.nextActions?.length || 0) : 0,
  112. pipelineRawNextActionCount: pipeline ? Number(pipeline.nextActions?.length || 0) : null,
  113. pipelineSuppressedNextActionCount: pipelineFreshness.stale && pipeline ? Number(pipeline.nextActions?.length || 0) : 0,
  114. pipelineStale: pipelineFreshness.stale,
  115. pipelineStaleReason: pipelineFreshness.reason,
  116. pipelineStaleMissingProofRequirements: pipelineFreshness.missingProofRequirements
  117. },
  118. guardrails: [
  119. '行动队列只用于安排下一步补证,不证明客户效果完成。',
  120. '不能把 sample、smoke、模板、provider fallback 或接口 200 当成业务证明。',
  121. '补证动作不得包含 sessionToken、Authorization、模型 token 或 npm token。',
  122. '补齐真实材料后优先运行 npm run round:refresh -- --output-root outputs --strict,按顺序刷新 proof-gap、业务补证进度、证据台账、交接摘要和沉淀审计。',
  123. 'stale optimization-pipeline nextActions are not current dispatch evidence; rerun optimization:pipeline after real materials are ready.'
  124. ],
  125. ownerGroups,
  126. ownerRepairArtifacts,
  127. actions: deduped
  128. };
  129. }
  130. function summarizeTopActions(actions, limit = 5) {
  131. if (!Array.isArray(actions)) return [];
  132. return actions.slice(0, limit).map(action => ({
  133. id: action.id || '',
  134. source: action.source || '',
  135. order: Number(action.order || 0),
  136. priority: Number(action.priority || 0),
  137. owner: action.owner || '',
  138. title: action.title || '',
  139. status: action.status || '',
  140. evidence: action.evidence || '',
  141. command: action.command || '',
  142. expectedArtifact: action.expectedArtifact || '',
  143. acceptance: action.acceptance || '',
  144. missingProofRequirementCount: Array.isArray(action.missingProofRequirements) ? action.missingProofRequirements.length : 0,
  145. missingProofRequirements: Array.isArray(action.missingProofRequirements) ? action.missingProofRequirements : []
  146. }));
  147. }
  148. function getPipelineFreshnessFromCompletion(completion) {
  149. const blocker = Array.isArray(completion?.blockingReasons)
  150. ? completion.blockingReasons.find(item => item.id === 'optimization-pipeline')
  151. : null;
  152. const missingProofRequirements = Array.isArray(blocker?.missingProofRequirements)
  153. ? blocker.missingProofRequirements.filter(Boolean).map(item => String(item))
  154. : [];
  155. const evidence = String(blocker?.evidence || '');
  156. const stale = evidence.includes('stale=true') ||
  157. missingProofRequirements.includes('optimizationPipeline.generatedAt>=currentProofSources');
  158. return {
  159. stale,
  160. reason: stale ? (evidence || 'optimization pipeline is stale') : '',
  161. missingProofRequirements
  162. };
  163. }
  164. function mergePipelineNextActions(candidates, pipeline, context = {}) {
  165. if (!pipeline || !Array.isArray(pipeline.nextActions)) return;
  166. for (const pipelineAction of pipeline.nextActions) {
  167. if (shouldSkipPipelineAction(pipelineAction, context)) continue;
  168. const mappedId = pipelineStepActionId(pipelineAction.step, candidates);
  169. const existing = candidates.find(action => action.id === mappedId);
  170. if (!existing) {
  171. candidates.push(actionFromPipelineNextAction(pipelineAction));
  172. continue;
  173. }
  174. existing.source = `${existing.source}+optimization-pipeline`;
  175. const keepPrimaryAction = ['step-03', 'step-04', 'step-12'].includes(existing.id);
  176. if (!keepPrimaryAction) {
  177. existing.owner = pipelineAction.owner || existing.owner;
  178. existing.title = pipelineAction.title || existing.title;
  179. existing.priority = Math.min(existing.priority || 99, Number(pipelineAction.priority || 99));
  180. existing.command = pipelineAction.command || existing.command;
  181. existing.expectedArtifact = pipelineAction.expectedArtifact || existing.expectedArtifact;
  182. existing.acceptance = pipelineAction.acceptance || existing.acceptance;
  183. }
  184. existing.next = appendIssueMessages(existing.next, 'pipeline 当前动作', [pipelineAction.command]);
  185. }
  186. }
  187. function shouldSkipPipelineAction(pipelineAction, { closure, videoReadiness } = {}) {
  188. if (pipelineAction?.step !== 'video-resource-readiness') return false;
  189. const videoReady = Boolean(videoReadiness?.acceptance?.readyForVideoAbPreflight) &&
  190. Number(videoReadiness?.failureCount || 0) === 0;
  191. const videoGapClosed = (Array.isArray(closure?.rows) ? closure.rows : [])
  192. .some(row => row.id === 'video-real-candidate' && row.status === 'closed');
  193. return videoReady || videoGapClosed;
  194. }
  195. function pipelineStepActionId(step, candidates = []) {
  196. if (step === 'history-audit' && hasAction(candidates, 'step-03')) return 'step-03';
  197. if (step === 'video-resource-readiness' && hasAction(candidates, 'step-04')) return 'step-04';
  198. if (step === 'customer-effect-audit' && hasAction(candidates, 'step-12')) return 'step-12';
  199. const map = {
  200. 'intake-readiness': 'step-05',
  201. 'history-from-csv': 'step-06',
  202. 'history-audit': 'step-07',
  203. 'video-resource-readiness': 'step-08',
  204. 'longrun-readiness': 'step-09',
  205. 'review-metrics': 'step-13',
  206. 'customer-effect-audit': 'step-14',
  207. 'evidence-index': 'step-15'
  208. };
  209. return map[step] || '';
  210. }
  211. function hasAction(candidates, id) {
  212. return candidates.some(action => action.id === id);
  213. }
  214. function actionFromPipelineNextAction(action) {
  215. return {
  216. id: `pipeline-${action.step || action.title}`,
  217. source: 'optimization-pipeline',
  218. order: 300,
  219. priority: Number(action.priority || 30),
  220. owner: action.owner || '技术/AI',
  221. title: action.title || 'Pipeline 下一步动作',
  222. status: 'blocked_by_external_data',
  223. evidence: `pipeline step=${action.step || 'unknown'}`,
  224. next: action.command || '',
  225. command: action.command || '',
  226. expectedArtifact: action.expectedArtifact || '',
  227. acceptance: action.acceptance || 'pipeline 对应 nextAction 完成。'
  228. };
  229. }
  230. function actionFromProgressRow(row, { intake, videoReadiness }) {
  231. const base = {
  232. id: `step-${String(row.order).padStart(2, '0')}`,
  233. source: 'business-proof-progress',
  234. order: Number(row.order || 999),
  235. owner: row.owner || '待分配',
  236. title: row.title || `补证步骤 ${row.order}`,
  237. status: row.status,
  238. evidence: row.evidence || '',
  239. next: row.next || '',
  240. command: commandForStep(row.order),
  241. expectedArtifact: artifactForStep(row.order),
  242. acceptance: acceptanceForStep(row.order)
  243. };
  244. base.priority = priorityForStep(row.order, row.status);
  245. if (row.order === 3 && intake?.history?.issues?.length) {
  246. base.next = appendIssueMessages(base.next, '当前历史数据问题', [
  247. ...intake.history.issues.map(item => item.message),
  248. ...sectionIssueMessages(intake, 'history')
  249. ]);
  250. }
  251. if (row.order === 4) {
  252. base.next = appendIssueMessages(base.next, '当前视频资源问题', [
  253. ...(videoReadiness?.issues || []).map(item => item.message),
  254. ...sectionIssueMessages(intake, 'video')
  255. ]);
  256. }
  257. if (row.order === 12) {
  258. base.next = appendIssueMessages(base.next, '当前人工复核问题', sectionIssueMessages(intake, 'review'));
  259. }
  260. return base;
  261. }
  262. function sectionIssueMessages(intake, section) {
  263. return (intake?.issues || [])
  264. .filter(issue => issue.section === section)
  265. .map(issue => issue.message)
  266. .filter(Boolean);
  267. }
  268. function appendIssueMessages(next, label, messages) {
  269. const uniqueMessages = Array.from(new Set(messages.filter(Boolean)));
  270. if (!uniqueMessages.length) return next;
  271. return `${next} ${label}:${uniqueMessages.join(';')}`;
  272. }
  273. function actionFromClosureRow(row) {
  274. const missingProofRequirements = Array.isArray(row.missingProofRequirements) ? row.missingProofRequirements.filter(Boolean) : [];
  275. return {
  276. id: `gap-${row.id}`,
  277. source: 'proof-gap-closure',
  278. order: 100 + gapOrder(row.id),
  279. priority: 20 + gapOrder(row.id),
  280. owner: ownerForGap(row.id),
  281. title: titleForGap(row.id, row.title),
  282. status: row.status,
  283. evidence: row.evidence || '',
  284. missingProofRequirements,
  285. next: appendIssueMessages(row.next || row.command || '', '缺失证明', missingProofRequirements),
  286. command: row.command || '',
  287. expectedArtifact: row.expectedArtifact || '',
  288. acceptance: row.required || '对应 proof gap status=closed。'
  289. };
  290. }
  291. function mergeClosureRowIntoPrimaryAction(candidates, row) {
  292. const targetId = primaryActionIdForGap(row.id);
  293. const target = candidates.find(action => action.id === targetId);
  294. if (!target) return;
  295. const gapAction = actionFromClosureRow(row);
  296. target.source = mergeSource(target.source, gapAction.source);
  297. target.evidence = mergeText(target.evidence || '', gapAction.evidence || '');
  298. target.next = appendIssueMessages(target.next, '缺失证明', gapAction.missingProofRequirements || []);
  299. target.next = appendIssueMessages(target.next, 'proof-gap 当前证据', [gapAction.evidence]);
  300. target.acceptance = mergeText(target.acceptance, gapAction.acceptance);
  301. target.missingProofRequirements = mergeList(target.missingProofRequirements, gapAction.missingProofRequirements);
  302. if (!target.expectedArtifact.includes(gapAction.expectedArtifact || '__missing__')) {
  303. target.expectedArtifact = [target.expectedArtifact, gapAction.expectedArtifact].filter(Boolean).join(';');
  304. }
  305. }
  306. function primaryActionIdForGap(id) {
  307. const map = {
  308. 'intake-readiness': 'step-05',
  309. 'historical-dataset': 'step-03',
  310. 'video-real-candidate': 'step-04',
  311. 'manual-review-and-customer-effect': 'step-12'
  312. };
  313. return map[id] || '';
  314. }
  315. function actionFromIntakeIssue(issue) {
  316. return {
  317. id: `intake-${issue.section}-${issue.type}`,
  318. source: 'intake-readiness',
  319. order: 200,
  320. priority: issue.section === 'history' ? 1 : issue.section === 'video' ? 2 : 3,
  321. owner: issue.section === 'video' ? '商务/投放' : '商务',
  322. title: `修复 intake ${issue.section}:${issue.type}`,
  323. status: 'blocked_by_external_data',
  324. evidence: issue.message || '',
  325. next: issue.message || '',
  326. command: 'npm run intake:readiness -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --output outputs\\intake-readiness-latest',
  327. expectedArtifact: 'outputs/intake-readiness-latest/intake-readiness-summary.json',
  328. acceptance: 'intake readiness 对应 issue 消失,overallReady=true 且 failureCount=0。'
  329. };
  330. }
  331. function commandForStep(order) {
  332. const commands = {
  333. 3: '填写 outputs\\data-intake-pack-latest\\history-data-template.csv',
  334. 4: '填写 outputs\\video-intake-pack-latest\\video-resource-template.csv',
  335. 5: 'npm run intake:readiness -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --output outputs\\intake-readiness-latest',
  336. 6: 'npm run history:from-csv -- --input <历史数据CSV> --output <history-dataset目录>',
  337. 7: 'npm run history:audit -- --input <history-dataset目录> --output <历史审计输出目录> --strict',
  338. 8: 'npm run video:resource-readiness -- --input <video-resource-template.csv> --output <视频资源审计输出目录> --strict',
  339. 9: 'npm run longrun:readiness -- --mode full-matrix --intake-readiness outputs\\intake-readiness-latest\\intake-readiness-summary.json --proof-gap outputs\\proof-gap-request-latest\\proof-gap-request-summary.json --proof-gap-closure outputs\\proof-gap-closure-latest\\proof-gap-closure-summary.json --output outputs\\long-run-readiness-latest',
  340. 10: 'npm run optimization:pipeline -- --history-csv <历史数据CSV> --review-csv <已标注CSV> --current-manual-supplement-count <本轮人工补号量> --output <输出目录> --strict',
  341. 12: '填写 outputs\\data-intake-pack-latest\\manual-review-template.csv',
  342. 13: 'npm run review:metrics -- --input <已标注CSV> --output <复核指标输出目录> --strict',
  343. 14: 'npm run customer-effect:audit -- --review-csv <已标注CSV> --history-audit <historical-dataset-audit.json> --current-manual-supplement-count <本轮人工补号量> --output <客户效果输出目录> --strict'
  344. };
  345. return commands[order] || '';
  346. }
  347. function artifactForStep(order) {
  348. const artifacts = {
  349. 3: 'outputs/data-intake-pack-latest/history-data-template.csv',
  350. 4: 'outputs/video-intake-pack-latest/video-resource-template.csv',
  351. 5: 'outputs/intake-readiness-latest/intake-readiness-summary.json',
  352. 6: '<history-dataset目录>/*.json',
  353. 7: '<历史审计输出目录>/historical-dataset-audit.json',
  354. 8: '<视频资源审计输出目录>/video-resource-readiness-summary.json',
  355. 9: 'outputs/long-run-readiness-latest/long-run-readiness-summary.json',
  356. 10: '<输出目录>/optimization-pipeline-summary.json',
  357. 12: 'outputs/data-intake-pack-latest/manual-review-template.csv',
  358. 13: '<复核指标输出目录>/review-metrics-summary.json',
  359. 14: '<客户效果输出目录>/customer-effect-summary.json'
  360. };
  361. return artifacts[order] || '';
  362. }
  363. function acceptanceForStep(order) {
  364. const rules = {
  365. 3: 'historyReady=true,真实历史 Brief 数 >= 5,且无模板占位。',
  366. 4: 'videoReady=true,至少有真实参考视频和真实候选视频。',
  367. 5: 'overallReady=true 且 failureCount=0。',
  368. 6: '历史数据集 JSON 成功生成。',
  369. 7: 'readyForCustomerEffectProof=true。',
  370. 8: 'readyForVideoAbPreflight=true 且 failureCount=0。',
  371. 9: 'ready=true 且 fail=0。',
  372. 10: 'readyForClaim=true,且软件端重复键为 0、排名连续。',
  373. 12: '客户选择、负样本归因和本轮人工补号量齐全。',
  374. 13: 'review:metrics overallPass=true,负样本归因覆盖率=100%。',
  375. 14: 'customer-effect:audit overallPass=true。'
  376. };
  377. return rules[order] || '对应步骤状态为 pass。';
  378. }
  379. function priorityForStep(order, status) {
  380. if (order === 3) return 1;
  381. if (order === 4) return 2;
  382. if (order === 5) return 3;
  383. if (order === 12) return 4;
  384. if (status === 'fail') return 10 + Number(order || 99);
  385. if (status === 'blocked_by_external_data') return 20 + Number(order || 99);
  386. return 40 + Number(order || 99);
  387. }
  388. function gapOrder(id) {
  389. const order = {
  390. 'intake-readiness': 1,
  391. 'historical-dataset': 2,
  392. 'video-real-candidate': 3,
  393. 'video-ab-live-proof': 4,
  394. 'live-provider-overnight-proof': 5,
  395. 'manual-review-and-customer-effect': 6
  396. };
  397. return order[id] || 99;
  398. }
  399. function ownerForGap(id) {
  400. if (id === 'video-real-candidate') return '商务/投放';
  401. if (id === 'video-ab-live-proof') return '技术/AI';
  402. if (id.includes('manual') || id.includes('customer') || id.includes('historical')) return '商务';
  403. return '技术/AI';
  404. }
  405. function titleForGap(id, fallback) {
  406. const titles = {
  407. 'intake-readiness': '真实材料预审',
  408. 'historical-dataset': '真实历史 Brief 数据集',
  409. 'video-real-candidate': '真实视频资源表',
  410. 'video-ab-live-proof': '真实视频 A/B 验收',
  411. 'live-provider-overnight-proof': '真实 live/provider 长跑证明',
  412. 'manual-review-and-customer-effect': '商务复核和客户效果'
  413. };
  414. return titles[id] || fallback || id;
  415. }
  416. function dedupeActions(actions) {
  417. const seen = new Set();
  418. const result = [];
  419. for (const action of actions.filter(Boolean)) {
  420. const key = `${action.owner}|${action.title}|${action.command}`;
  421. if (seen.has(key)) continue;
  422. seen.add(key);
  423. result.push(action);
  424. }
  425. return result;
  426. }
  427. function compactBusinessGapActions(actions) {
  428. const byId = new Map(actions.map(action => [action.id, action]));
  429. mergeActionInto(byId, 'step-03', 'step-05', '统一预审');
  430. mergeActionInto(byId, 'step-03', 'step-06', '转换动作');
  431. mergeActionInto(byId, 'step-03', 'step-07', '验证动作');
  432. mergeActionInto(byId, 'step-04', 'step-08', '验证动作');
  433. mergeActionInto(byId, 'step-12', 'step-14', '验证动作');
  434. mergeActionInto(byId, 'step-12', 'step-13', '复核指标');
  435. mergeActionInto(byId, 'gap-live-provider-overnight-proof', 'step-09', '前置门禁');
  436. mergeActionInto(byId, 'gap-live-provider-overnight-proof', 'step-10', '执行动作');
  437. mergeActionInto(byId, 'gap-video-ab-live-proof', 'step-11', '验收动作');
  438. const result = [];
  439. const usedGapKeys = new Set();
  440. for (const action of actions) {
  441. if (isMergedTechnicalAction(action.id)) continue;
  442. const gapKey = businessGapKey(action);
  443. if (gapKey) {
  444. if (usedGapKeys.has(gapKey)) continue;
  445. usedGapKeys.add(gapKey);
  446. }
  447. result.push(action);
  448. }
  449. return result;
  450. }
  451. function mergeActionInto(byId, targetId, sourceId, label) {
  452. const target = byId.get(targetId);
  453. const source = byId.get(sourceId);
  454. if (!target || !source) return;
  455. target.source = mergeSource(target.source, source.source);
  456. target.next = appendIssueMessages(target.next, label, [
  457. `${source.title}:${source.next || source.command || source.acceptance || ''}`
  458. ]);
  459. target.acceptance = mergeText(target.acceptance, source.acceptance);
  460. target.missingProofRequirements = mergeList(target.missingProofRequirements, source.missingProofRequirements);
  461. if (!target.expectedArtifact.includes(source.expectedArtifact || '__missing__')) {
  462. target.expectedArtifact = [target.expectedArtifact, source.expectedArtifact].filter(Boolean).join(';');
  463. }
  464. }
  465. function mergeSource(left, right) {
  466. return Array.from(new Set(String(`${left}+${right}`).split('+').filter(Boolean))).join('+');
  467. }
  468. function mergeText(left, right) {
  469. if (!right || left.includes(right)) return left;
  470. return `${left};${right}`;
  471. }
  472. function mergeList(left, right) {
  473. return Array.from(new Set([
  474. ...(Array.isArray(left) ? left : []),
  475. ...(Array.isArray(right) ? right : [])
  476. ].filter(Boolean)));
  477. }
  478. function isMergedTechnicalAction(id) {
  479. return ['step-01', 'step-02', 'step-05', 'step-06', 'step-07', 'step-08', 'step-09', 'step-10', 'step-11', 'step-13', 'step-14', 'step-15'].includes(id);
  480. }
  481. function businessGapKey(action) {
  482. if (['step-03', 'gap-historical-dataset'].includes(action.id)) return 'historical-dataset';
  483. if (['step-04', 'gap-video-real-candidate'].includes(action.id)) return 'video-real-candidate';
  484. if (['step-12', 'gap-manual-review-and-customer-effect'].includes(action.id)) return 'manual-review-and-customer-effect';
  485. if (['gap-live-provider-overnight-proof'].includes(action.id)) return 'live-provider-overnight-proof';
  486. if (['gap-video-ab-live-proof'].includes(action.id)) return 'video-ab-live-proof';
  487. return '';
  488. }
  489. function renderReport(summary, ownerArtifacts = summary.ownerArtifacts || []) {
  490. const lines = [
  491. '# 提号补证下一步行动队列',
  492. '',
  493. `- 生成时间:${summary.generatedAt}`,
  494. `- 是否完成:${summary.complete ? '是' : '否'}`,
  495. `- 行动数:${summary.actions.length}`,
  496. `- progress.complete:${summary.sourceState.progressComplete ? 'true' : 'false'}`,
  497. `- proofGap.openCount:${summary.sourceState.proofGapOpenCount ?? 'unknown'}`,
  498. `- intake.failureCount:${summary.sourceState.intakeFailureCount ?? 'unknown'}`,
  499. `- video.failureCount:${summary.sourceState.videoFailureCount ?? 'unknown'}`,
  500. `- homepage.failureCount:${summary.sourceState.homepageFailureCount ?? 'unknown'}`,
  501. `- pipeline.readyForClaim:${summary.sourceState.pipelineReadyForClaim ?? 'unknown'}`,
  502. `- pipeline.failCount:${summary.sourceState.pipelineFailCount ?? 'unknown'}`,
  503. `- pipeline.nextActionCount:${summary.sourceState.pipelineNextActionCount ?? 'unknown'}`,
  504. `- pipeline.rawNextActionCount:${summary.sourceState.pipelineRawNextActionCount ?? 'unknown'}`,
  505. `- pipeline.suppressedNextActionCount:${summary.sourceState.pipelineSuppressedNextActionCount ?? 0}`,
  506. `- pipeline.stale:${summary.sourceState.pipelineStale ? 'true' : 'false'}`,
  507. summary.sourceState.pipelineStaleReason ? `- pipeline.staleReason:${summary.sourceState.pipelineStaleReason}` : '',
  508. '',
  509. '## 按负责人汇总',
  510. '',
  511. '| 负责人 | 行动数 | 最高优先级 | 重点动作 |',
  512. '| --- | --- | --- | --- |',
  513. ...summary.ownerGroups.map(group => `| ${escapeCell(group.owner)} | ${group.actionCount} | ${group.topPriority} | ${escapeCell(group.topTitles.join(';'))} |`),
  514. '',
  515. '## 负责人文件',
  516. '',
  517. '| 负责人 | Markdown | CSV | 修复清单附件 |',
  518. '| --- | --- | --- | --- |',
  519. ...ownerArtifacts.map(item => `| ${escapeCell(item.owner)} | ${escapeCell(item.markdown)} | ${escapeCell(item.csv)} | ${escapeCell((item.repairArtifacts || []).map(artifact => `${artifact.title}:${artifact.csv}`).join(';') || '无')} |`),
  520. '',
  521. '## 行动队列',
  522. '',
  523. '同一份业务材料的多个字段缺口会合并到主动作的下一步说明中,避免把“填一张表”拆成多条重复任务。',
  524. '',
  525. '| 优先级 | 负责人 | 动作 | 来源 | 当前证据 | 下一步说明 | 执行命令 | 预期产物 | 通过标准 |',
  526. '| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
  527. ...summary.actions.map(action => `| ${action.priority} | ${escapeCell(action.owner)} | ${escapeCell(action.title)} | ${escapeCell(action.source)} | ${escapeCell(action.evidence)} | ${escapeCell(action.next)} | \`${escapeCell(action.command)}\` | ${escapeCell(action.expectedArtifact)} | ${escapeCell(action.acceptance)} |`),
  528. '',
  529. '## 边界',
  530. '',
  531. ...summary.guardrails.map(item => `- ${item}`)
  532. ];
  533. return lines.join('\n');
  534. }
  535. function writeOwnerArtifacts(outputDir, ownerGroups, actions, ownerRepairArtifacts = []) {
  536. const ownerDir = path.join(outputDir, 'by-owner');
  537. fs.mkdirSync(ownerDir, { recursive: true });
  538. const artifacts = [];
  539. for (const group of ownerGroups) {
  540. const ownerActions = actions.filter(action => action.owner === group.owner);
  541. const repairArtifacts = ownerRepairArtifacts.filter(item => item.owner === group.owner);
  542. const slug = slugOwner(group.owner);
  543. const markdownRel = path.join('by-owner', `${slug}.md`).replace(/\\/g, '/');
  544. const csvRel = path.join('by-owner', `${slug}.csv`).replace(/\\/g, '/');
  545. fs.writeFileSync(path.join(outputDir, markdownRel), withBom(renderOwnerReport(group, ownerActions, repairArtifacts)), 'utf8');
  546. fs.writeFileSync(path.join(outputDir, csvRel), withBom(renderCsv(ownerActions)), 'utf8');
  547. artifacts.push({
  548. owner: group.owner,
  549. actionCount: group.actionCount,
  550. topPriority: group.topPriority,
  551. markdown: markdownRel,
  552. csv: csvRel,
  553. repairArtifacts
  554. });
  555. }
  556. return artifacts;
  557. }
  558. function renderOwnerReport(group, actions, repairArtifacts = []) {
  559. const lines = [
  560. `# ${group.owner}补证行动`,
  561. '',
  562. `- 行动数:${group.actionCount}`,
  563. `- 最高优先级:${group.topPriority}`,
  564. '',
  565. '## 修复清单附件',
  566. '',
  567. repairArtifacts.length
  568. ? renderRepairArtifactTable(repairArtifacts)
  569. : '- 当前没有单独归属到本负责人的修复清单附件。',
  570. '',
  571. '## 行动明细',
  572. '',
  573. '| 优先级 | 动作 | 当前证据 | 下一步说明 | 执行命令 | 预期产物 | 通过标准 |',
  574. '| --- | --- | --- | --- | --- | --- | --- |',
  575. ...actions.map(action => `| ${action.priority} | ${escapeCell(action.title)} | ${escapeCell(action.evidence)} | ${escapeCell(action.next)} | \`${escapeCell(action.command)}\` | ${escapeCell(action.expectedArtifact)} | ${escapeCell(action.acceptance)} |`),
  576. '',
  577. '## 边界',
  578. '',
  579. '- 本文件只用于安排下一步补证,不证明客户效果完成。',
  580. '- 不要在补证材料中写入 sessionToken、Authorization、模型 token 或 npm token。'
  581. ];
  582. return lines.join('\n');
  583. }
  584. function groupByOwner(actions) {
  585. const map = new Map();
  586. for (const action of actions) {
  587. const owner = action.owner || '待分配';
  588. if (!map.has(owner)) {
  589. map.set(owner, {
  590. owner,
  591. actionCount: 0,
  592. topPriority: action.priority,
  593. topTitles: []
  594. });
  595. }
  596. const group = map.get(owner);
  597. group.actionCount += 1;
  598. group.topPriority = Math.min(group.topPriority, action.priority);
  599. if (group.topTitles.length < 3) group.topTitles.push(action.title);
  600. }
  601. return Array.from(map.values()).sort((a, b) => a.topPriority - b.topPriority || b.actionCount - a.actionCount);
  602. }
  603. function buildOwnerRepairActionArtifacts({ root, outputsDir, intake, videoReadiness, homepageReadiness }) {
  604. const artifacts = [];
  605. addRepairActionArtifacts(artifacts, {
  606. root,
  607. csv: latestFile(outputsDir, 'intake-readiness-repair-actions.csv'),
  608. title: '历史数据/商务复核修复清单',
  609. source: 'intake-readiness',
  610. repairActions: intake?.repairActions || []
  611. });
  612. addRepairActionArtifacts(artifacts, {
  613. root,
  614. csv: latestFile(outputsDir, 'video-resource-repair-actions.csv'),
  615. title: '视频资源修复清单',
  616. source: 'video-resource-readiness',
  617. repairActions: videoReadiness?.repairActions || []
  618. });
  619. addRepairActionArtifacts(artifacts, {
  620. root,
  621. csv: latestFile(outputsDir, 'homepage-evidence-repair-actions.csv'),
  622. title: '主页近期内容证据修复清单',
  623. source: 'homepage-evidence-readiness',
  624. repairActions: homepageReadiness?.repairActions || []
  625. });
  626. return artifacts.sort((a, b) => a.topPriority - b.topPriority || a.owner.localeCompare(b.owner, 'zh-CN'));
  627. }
  628. function addRepairActionArtifacts(artifacts, { root, csv, title, source, repairActions }) {
  629. if (!csv || !fs.existsSync(csv) || !Array.isArray(repairActions) || repairActions.length === 0) return;
  630. const byOwner = new Map();
  631. for (const action of repairActions) {
  632. const owner = action.owner || '待分配';
  633. if (!byOwner.has(owner)) {
  634. byOwner.set(owner, {
  635. owner,
  636. title,
  637. source,
  638. csv: path.relative(root, csv).replace(/\\/g, '/'),
  639. actionCount: 0,
  640. topPriority: Number(action.priority || 999),
  641. fields: []
  642. });
  643. }
  644. const item = byOwner.get(owner);
  645. item.actionCount += 1;
  646. item.topPriority = Math.min(item.topPriority, Number(action.priority || 999));
  647. if (action.field && item.fields.length < 5 && !item.fields.includes(action.field)) item.fields.push(action.field);
  648. }
  649. artifacts.push(...byOwner.values());
  650. }
  651. function renderRepairArtifactTable(repairArtifacts) {
  652. return [
  653. '| 修复清单 | CSV | 动作数 | 最高优先级 | 重点字段 |',
  654. '| --- | --- | ---: | ---: | --- |',
  655. ...repairArtifacts.map(item => `| ${escapeCell(item.title)} | ${escapeCell(item.csv)} | ${item.actionCount} | ${item.topPriority} | ${escapeCell(item.fields.join(';') || '见 CSV')} |`)
  656. ].join('\n');
  657. }
  658. function renderCsv(actions) {
  659. const header = ['优先级', '负责人', '动作', '来源', '当前状态', '当前证据', '执行命令', '预期产物', '通过标准', '下一步说明'];
  660. const rows = actions.map(action => [
  661. action.priority,
  662. action.owner,
  663. action.title,
  664. action.source,
  665. action.status,
  666. action.evidence,
  667. action.command,
  668. action.expectedArtifact,
  669. action.acceptance,
  670. action.next
  671. ]);
  672. return [header, ...rows].map(row => row.map(csvCell).join(',')).join('\n');
  673. }
  674. function latestFile(outputsDir, fileName) {
  675. if (!fs.existsSync(outputsDir)) return '';
  676. return fs.readdirSync(outputsDir, { withFileTypes: true })
  677. .filter(entry => entry.isDirectory())
  678. .map(entry => path.join(outputsDir, entry.name, fileName))
  679. .filter(file => fs.existsSync(file))
  680. .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] || '';
  681. }
  682. function readJson(file) {
  683. if (!file || !fs.existsSync(file)) return null;
  684. try {
  685. return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  686. } catch {
  687. return null;
  688. }
  689. }
  690. function parseArgs(argv) {
  691. const args = {};
  692. for (let index = 0; index < argv.length; index += 1) {
  693. const raw = argv[index];
  694. if (!raw.startsWith('--')) continue;
  695. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  696. const next = argv[index + 1];
  697. if (!next || next.startsWith('--')) args[key] = true;
  698. else {
  699. args[key] = next;
  700. index += 1;
  701. }
  702. }
  703. return args;
  704. }
  705. function escapeCell(value) {
  706. return String(value ?? '').replace(/\|/g, '/').replace(/\r?\n/g, ' ');
  707. }
  708. function csvCell(value) {
  709. const text = String(value ?? '');
  710. return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  711. }
  712. function slugOwner(owner) {
  713. const map = {
  714. '商务': 'business',
  715. '投放/商务': 'media-business',
  716. '商务/投放': 'business-media',
  717. '技术/AI': 'tech-ai'
  718. };
  719. if (map[owner]) return map[owner];
  720. const ascii = String(owner || 'owner').replace(/[^\w]+/g, '-').replace(/^-|-$/g, '').toLowerCase();
  721. return ascii || Buffer.from(String(owner || 'owner')).toString('hex').slice(0, 12);
  722. }
  723. function withBom(text) {
  724. return `\uFEFF${text}`;
  725. }
  726. if (require.main === module) main();
  727. module.exports = {
  728. buildNextActions,
  729. renderReport
  730. };