real-proof-closure-work-order.js 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822
  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', 'real-proof-closure-work-order-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 = buildRealProofClosureWorkOrder({ root: ROOT, outputsDir });
  11. fs.mkdirSync(outputDir, { recursive: true });
  12. const ownerArtifacts = writeOwnerArtifacts(outputDir, summary.workOrders);
  13. summary.ownerArtifacts = ownerArtifacts;
  14. const summaryPath = path.join(outputDir, 'real-proof-closure-work-order-summary.json');
  15. const reportPath = path.join(outputDir, 'real-proof-closure-work-order.md');
  16. const csvPath = path.join(outputDir, 'real-proof-closure-work-order.csv');
  17. fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
  18. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  19. fs.writeFileSync(csvPath, withBom(renderCsv(summary.workOrders)), 'utf8');
  20. console.log(JSON.stringify({
  21. outputDir,
  22. summary: summaryPath,
  23. report: reportPath,
  24. csv: csvPath,
  25. ownerArtifacts: ownerArtifacts.length,
  26. passed: summary.passed,
  27. complete: summary.complete,
  28. workOrderCount: summary.workOrderCount,
  29. proofGapOpenCount: summary.sourceState.proofGapOpenCount,
  30. canCloseProofGap: summary.canCloseProofGap
  31. }, null, 2));
  32. if (args.strict && !summary.passed) process.exitCode = 1;
  33. }
  34. function buildRealProofClosureWorkOrder({ root, outputsDir }) {
  35. const sources = {
  36. intakeReadiness: latestSummary(outputsDir, 'intake-readiness-summary.json', 'intake-readiness-latest'),
  37. videoReadiness: latestSummary(outputsDir, 'video-resource-readiness-summary.json', 'video-resource-readiness-latest'),
  38. proofGapClosure: latestSummary(outputsDir, 'proof-gap-closure-summary.json', 'proof-gap-closure-latest'),
  39. optimizationCompletion: latestSummary(outputsDir, 'optimization-completion-summary.json', 'optimization-completion-latest'),
  40. localSeedWorklist: latestSummary(outputsDir, 'local-seed-to-intake-worklist-summary.json', 'local-seed-to-intake-worklist-latest'),
  41. nextActions: latestSummary(outputsDir, 'business-proof-next-actions-summary.json', 'business-proof-next-actions-latest')
  42. };
  43. const intake = sources.intakeReadiness.json || {};
  44. const video = sources.videoReadiness.json || {};
  45. const closure = sources.proofGapClosure.json || {};
  46. const completion = sources.optimizationCompletion.json || {};
  47. const seed = sources.localSeedWorklist.json || {};
  48. const nextActions = sources.nextActions.json || {};
  49. const workOrders = dedupeWorkOrders([
  50. ...workOrdersFromIntake(intake, seed),
  51. ...workOrdersFromVideoReadiness(video, seed),
  52. ...workOrdersFromProofGapClosure(closure, nextActions, seed),
  53. ...workOrdersFromOptimizationCompletion(completion)
  54. ]).sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));
  55. const ownerGroups = groupByOwner(workOrders);
  56. const proofGapOpenCount = Number(closure.openCount ?? completion.proofGapOpenCount ?? nextActions.sourceState?.proofGapOpenCount ?? 0);
  57. const complete = proofGapOpenCount === 0 &&
  58. Boolean(closure.complete) &&
  59. Boolean(completion.complete) &&
  60. Boolean(completion.readyForClaim);
  61. const passed = workOrders.every(item =>
  62. item.owner &&
  63. item.targetFile &&
  64. item.targetFields.length &&
  65. item.requiredAction &&
  66. item.acceptanceCommand &&
  67. item.acceptance &&
  68. item.requiresHumanRealMaterial === true &&
  69. item.directCustomerProof === false &&
  70. item.canCloseProofGap === false
  71. ) && (
  72. complete ||
  73. workOrders.length >= Math.max(5, Number(intake.failureCount || 0), Number(closure.openCount || 0))
  74. );
  75. return {
  76. generatedAt: new Date().toISOString(),
  77. root,
  78. outputsDir,
  79. passed,
  80. complete,
  81. proofLevel: 'not_business_proof',
  82. materialType: 'real_proof_closure_work_order',
  83. directCustomerProof: false,
  84. canCloseProofGap: false,
  85. requiresHumanRealMaterial: true,
  86. workOrderCount: workOrders.length,
  87. ownerGroupCount: ownerGroups.length,
  88. seedSuggestionCount: workOrders.filter(item => item.seedSuggestion).length,
  89. targetFiles: Array.from(new Set(workOrders.map(item => item.targetFile).filter(Boolean))),
  90. ownerGroups,
  91. sourceState: {
  92. intakeOverallReady: Boolean(intake.acceptance?.overallReady),
  93. intakeFailureCount: Number(intake.failureCount || 0),
  94. videoReadyForAbPreflight: Boolean(video.acceptance?.readyForVideoAbPreflight),
  95. videoFailureCount: Number(video.failureCount || 0),
  96. realCandidateRows: Number(video.counts?.realCandidateRows ?? intake.video?.counts?.realCandidateRows ?? 0),
  97. proofGapComplete: Boolean(closure.complete),
  98. proofGapOpenCount,
  99. proofGapClosedCount: Number(closure.closedCount || 0),
  100. optimizationReadyForClaim: Boolean(completion.readyForClaim),
  101. optimizationBlockingReasonCount: Number(completion.blockingReasons?.length || completion.blockingReasonCount || 0),
  102. localSeedCanCloseProofGap: Boolean(seed.canCloseProofGap),
  103. localSeedDirectCustomerProof: Boolean(seed.directCustomerProof),
  104. localSeedHistoryDraftRows: countRows(seed.historyDraftRows, seed.counts?.historyDraftRows),
  105. localSeedCandidateSeedRows: countRows(seed.candidateSeedRows, seed.counts?.candidateSeedRows),
  106. localSeedVideoWorklistRows: countRows(seed.videoWorklistRows, seed.counts?.videoWorklistRows)
  107. },
  108. files: {
  109. summary: 'outputs/real-proof-closure-work-order-latest/real-proof-closure-work-order-summary.json',
  110. report: 'outputs/real-proof-closure-work-order-latest/real-proof-closure-work-order.md',
  111. csv: 'outputs/real-proof-closure-work-order-latest/real-proof-closure-work-order.csv',
  112. byOwnerDir: 'outputs/real-proof-closure-work-order-latest/by-owner'
  113. },
  114. sourceFiles: Object.fromEntries(Object.entries(sources).map(([key, value]) => [key, value.path ? rel(root, value.path) : ''])),
  115. guardrails: [
  116. '本工单只把未关闭证明缺口拆成可填写字段、行范围和复验命令,不写入正式 intake 模板。',
  117. '本工单不证明客户效果,也不证明提号率提升或人工补号量下降。',
  118. '本地种子和 PoC 候选只能作为转写线索;客户选择、拒绝原因、历史人工补号量和本轮人工补号量必须由商务补真实记录。',
  119. '真实候选视频必须由投放补视频 URL、封面、ASR/字幕、帧图或正文证据;参考视频不能替代候选视频。',
  120. 'proof-gap.openCount 不为 0 或 optimization-completion.readyForClaim=false 时,不得宣称提号率提升、客户效果达标或人工补号量下降。',
  121. '不得在工单、CSV、报告或命令中写入 sessionToken、鉴权头、模型 token 或 npm token。'
  122. ],
  123. workOrders
  124. };
  125. }
  126. function workOrdersFromIntake(intake, seed) {
  127. const issues = Array.isArray(intake.issues) ? intake.issues : [];
  128. return issues.map((issue, index) => {
  129. const spec = intakeSpec(issue, seed);
  130. return makeWorkOrder({
  131. id: `intake-${issue.section || 'unknown'}-${issue.type || index + 1}`,
  132. priority: spec.priority || index + 1,
  133. owner: spec.owner,
  134. source: 'intake-readiness',
  135. gapId: spec.gapId,
  136. section: issue.section || spec.section,
  137. issueType: issue.type || '',
  138. title: spec.title,
  139. targetFile: spec.targetFile,
  140. targetFields: spec.targetFields,
  141. rowSelector: spec.rowSelector,
  142. currentEvidence: issue.message || spec.currentEvidence,
  143. missingProofRequirements: spec.missingProofRequirements,
  144. seedSuggestion: spec.seedSuggestion,
  145. requiredAction: spec.requiredAction,
  146. acceptanceCommand: spec.acceptanceCommand,
  147. acceptance: spec.acceptance
  148. });
  149. });
  150. }
  151. function workOrdersFromVideoReadiness(video, seed) {
  152. const issues = Array.isArray(video.issues) ? video.issues : [];
  153. return issues.map((issue, index) => {
  154. const spec = videoSpec(issue, seed);
  155. return makeWorkOrder({
  156. id: `video-${issue.type || index + 1}`,
  157. priority: spec.priority || 30 + index,
  158. owner: spec.owner,
  159. source: 'video-resource-readiness',
  160. gapId: 'video-real-candidate',
  161. section: 'video',
  162. issueType: issue.type || '',
  163. title: spec.title,
  164. targetFile: 'outputs/video-intake-pack-latest/video-resource-template.csv',
  165. targetFields: spec.targetFields,
  166. rowSelector: spec.rowSelector,
  167. currentEvidence: issue.message || '',
  168. missingProofRequirements: [
  169. 'acceptance.readyForVideoAbPreflight=true',
  170. 'failureCount=0',
  171. 'counts.realCandidateRows>=1'
  172. ],
  173. seedSuggestion: videoSeedSuggestion(seed),
  174. requiredAction: spec.requiredAction,
  175. acceptanceCommand: 'npm run video:resource-readiness -- --input outputs\\video-intake-pack-latest\\video-resource-template.csv --output outputs\\video-resource-readiness-latest --strict',
  176. acceptance: 'readyForVideoAbPreflight=true、failureCount=0、realCandidateRows>=1。'
  177. });
  178. });
  179. }
  180. function workOrdersFromProofGapClosure(closure, nextActions, seed) {
  181. const rows = Array.isArray(closure.rows) ? closure.rows : [];
  182. return rows
  183. .filter(row => row.status === 'open')
  184. .map((row, index) => {
  185. const spec = gapSpec(row, nextActions, seed);
  186. return makeWorkOrder({
  187. id: `gap-${row.id || index + 1}`,
  188. priority: spec.priority,
  189. owner: spec.owner,
  190. source: 'proof-gap-closure',
  191. gapId: row.id || '',
  192. section: spec.section,
  193. issueType: 'open-proof-gap',
  194. title: spec.title,
  195. targetFile: spec.targetFile,
  196. targetFields: spec.targetFields,
  197. rowSelector: spec.rowSelector,
  198. currentEvidence: row.evidence || '',
  199. missingProofRequirements: normalizeList(row.missingProofRequirements),
  200. seedSuggestion: spec.seedSuggestion,
  201. requiredAction: spec.requiredAction || row.next || '',
  202. acceptanceCommand: row.command || spec.acceptanceCommand,
  203. acceptance: spec.acceptance || row.required || ''
  204. });
  205. });
  206. }
  207. function workOrdersFromOptimizationCompletion(completion) {
  208. const reasons = Array.isArray(completion.blockingReasons) ? completion.blockingReasons : [];
  209. return reasons
  210. .filter(reason => ['optimization-pipeline', 'handoff-summary', 'business-proof-progress', 'longrun-readiness'].includes(reason.id))
  211. .map((reason, index) => makeWorkOrder({
  212. id: `completion-${reason.id || index + 1}`,
  213. priority: 80 + index,
  214. owner: '技术/AI',
  215. source: 'optimization-completion',
  216. gapId: reason.id || '',
  217. section: 'audit',
  218. issueType: 'blocked-gate',
  219. title: `复验完成门槛:${reason.title || reason.id}`,
  220. targetFile: 'outputs/optimization-completion-latest/optimization-completion-summary.json',
  221. targetFields: ['complete', 'readyForClaim', 'proofGapOpenCount', 'blockingReasons'],
  222. rowSelector: reason.id || '',
  223. currentEvidence: reason.evidence || '',
  224. missingProofRequirements: normalizeList(reason.missingProofRequirements),
  225. requiredAction: '真实材料补齐并通过对应审计后,刷新 optimization:completion 和 round:refresh;不要用 smoke 或旧 pipeline 结果替代。',
  226. acceptanceCommand: reason.command || 'npm run optimization:completion -- --output outputs\\optimization-completion-latest',
  227. acceptance: reason.required || 'complete=true、readyForClaim=true、proofGapOpenCount=0。'
  228. }));
  229. }
  230. function intakeSpec(issue, seed) {
  231. const section = issue.section || '';
  232. const type = issue.type || '';
  233. if (section === 'history') {
  234. const base = {
  235. owner: '商务',
  236. section,
  237. gapId: 'historical-dataset',
  238. targetFile: 'outputs/data-intake-pack-latest/history-data-template.csv',
  239. acceptanceCommand: 'npm run intake:readiness -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --output outputs\\intake-readiness-latest',
  240. acceptance: 'historyReady=true、真实历史 Brief 数 >=5、无模板占位;随后 history:audit readyForCustomerEffectProof=true。',
  241. seedSuggestion: historySeedSuggestion(seed),
  242. missingProofRequirements: ['acceptance.historyReady=true', 'history:audit readyForCustomerEffectProof=true']
  243. };
  244. const specs = {
  245. 'min-briefs': {
  246. priority: 1,
  247. title: '补足真实历史 Brief 数量',
  248. targetFields: ['brief编号', '项目名称', '类目', '客户原始Brief'],
  249. rowSelector: '新增至少 4 行真实历史 Brief,使 briefCount>=5。',
  250. requiredAction: '补 5-10 个真实历史 Brief;DHA seed 只能作草稿,不足以关闭历史数据缺口。'
  251. },
  252. placeholder: {
  253. priority: 2,
  254. title: '替换历史数据模板占位内容',
  255. targetFields: ['整行'],
  256. rowSelector: '所有包含 example.com、示例、候选、待补的行。',
  257. requiredAction: '把示例项目、example.com 和待补文字替换为真实客户材料;不能用未知、0 或占位值凑数。'
  258. },
  259. reference: {
  260. priority: 3,
  261. title: '补真实参考账号或参考视频',
  262. targetFields: ['参考账号或视频'],
  263. rowSelector: 'history-data-template.csv 每个 Brief 行。',
  264. requiredAction: '填真实参考账号主页或参考视频链接;可参考本地 reference seed,但需人工确认可访问且属于真实业务材料。',
  265. seedSuggestion: referenceSeedSuggestion(seed)
  266. },
  267. 'manual-list': {
  268. priority: 4,
  269. title: '补人工最终名单博主和主页',
  270. targetFields: ['博主名称', '主页链接'],
  271. rowSelector: 'history-data-template.csv 每个 Brief 行。',
  272. requiredAction: '填历史人工最终提报名单中的真实博主名称和可访问主页链接;PoC 候选只能作为找回线索。'
  273. },
  274. 'manual-baseline': {
  275. priority: 5,
  276. title: '补历史人工补号量基线',
  277. targetFields: ['历史人工补号量基线'],
  278. rowSelector: 'history-data-template.csv 每个 Brief 行。',
  279. requiredAction: '填写每个历史 Brief 当时人工补号量基线;缺该字段不能证明人工补号量下降。'
  280. },
  281. 'customer-decision': {
  282. priority: 6,
  283. title: '补历史客户最终选择',
  284. targetFields: ['客户选择', '拒绝原因'],
  285. rowSelector: 'history-data-template.csv 每个 Brief 行。',
  286. requiredAction: '填写客户最终选中/拒绝;拒绝样本必须有拒绝原因。'
  287. }
  288. };
  289. return { ...base, ...(specs[type] || specs.placeholder) };
  290. }
  291. if (section === 'review') {
  292. return {
  293. priority: type === 'customer-decision' ? 12 : 11,
  294. owner: '商务',
  295. section,
  296. gapId: 'manual-review-and-customer-effect',
  297. title: type === 'customer-decision' ? '补客户最终选中/拒绝记录' : '替换人工复核模板占位内容',
  298. targetFile: 'outputs/data-intake-pack-latest/manual-review-template.csv',
  299. targetFields: type === 'customer-decision'
  300. ? ['客户选择', '归因类型', '反馈原因', '本轮人工补号量']
  301. : ['整行', '博主名称', '主页链接', '人工复核标签'],
  302. rowSelector: type === 'customer-decision'
  303. ? 'manual-review-template.csv 至少一批真实候选行。'
  304. : '所有包含 example.com、示例、候选、待客户反馈的行。',
  305. seedSuggestion: candidateSeedSuggestion(seed),
  306. requiredAction: type === 'customer-decision'
  307. ? '补客户最终选中/拒绝、负样本归因、反馈原因和本轮人工补号量。'
  308. : '把 PoC 候选转成真实人工复核记录,补主页链接和人工复核标签。',
  309. missingProofRequirements: ['review:metrics overallPass=true', 'customer-effect:audit overallPass=true'],
  310. acceptanceCommand: 'npm run intake:readiness -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --output outputs\\intake-readiness-latest',
  311. acceptance: 'reviewMetricsReady=true、customerEffectReady=true;随后 customer-effect:audit overallPass=true。'
  312. };
  313. }
  314. return {
  315. priority: 20,
  316. owner: '商务/投放',
  317. section: 'video',
  318. gapId: 'video-real-candidate',
  319. title: '补视频资源真实材料',
  320. targetFile: 'outputs/video-intake-pack-latest/video-resource-template.csv',
  321. targetFields: ['资源角色', '是否真实资源', '视频链接', '封面链接', '字幕或ASR文本', '帧图链接', '内容摘要'],
  322. rowSelector: 'video-resource-template.csv 参考视频和候选视频行。',
  323. seedSuggestion: videoSeedSuggestion(seed),
  324. requiredAction: '替换视频模板占位内容,补真实参考视频和真实候选视频。',
  325. missingProofRequirements: ['acceptance.videoReady=true', 'video:resource-readiness readyForVideoAbPreflight=true'],
  326. acceptanceCommand: 'npm run video:resource-readiness -- --input outputs\\video-intake-pack-latest\\video-resource-template.csv --output outputs\\video-resource-readiness-latest --strict',
  327. acceptance: 'readyForVideoAbPreflight=true、failureCount=0。'
  328. };
  329. }
  330. function videoSpec(issue, seed) {
  331. if (issue.type === 'missing-real-candidate' || issue.type === 'real-candidate') {
  332. return {
  333. priority: 21,
  334. owner: '商务/投放',
  335. title: '补真实候选视频资源',
  336. targetFields: ['资源角色', '博主名称', '主页链接', '视频链接', '封面链接', '字幕或ASR文本', '帧图链接', '内容摘要', '是否真实资源'],
  337. rowSelector: '新增或更新至少 1 行资源角色=候选视频、是否真实资源=是。',
  338. requiredAction: '为候选账号补真实视频 URL 和至少一种封面/ASR/帧图/正文证据;参考视频不能替代候选视频。'
  339. };
  340. }
  341. if (issue.type === 'missing-video-url' || issue.type === 'video-url') {
  342. return {
  343. priority: 22,
  344. owner: '商务/投放',
  345. title: '补真实视频 URL',
  346. targetFields: ['视频链接'],
  347. rowSelector: '所有标为真实资源的参考视频和候选视频行。',
  348. requiredAction: '填真实可访问视频 URL;不能只用封面、标题、接口 200 或 404 链接。'
  349. };
  350. }
  351. return {
  352. priority: 23,
  353. owner: '商务/投放',
  354. title: '补视频资源证据字段',
  355. targetFields: ['封面链接', '字幕或ASR文本', '帧图链接', '标题', '内容摘要', '风格调性标签', '画面人设场景信号'],
  356. rowSelector: 'video-resource-template.csv 真实视频资源行。',
  357. requiredAction: '补足封面、ASR/字幕、帧图、标题或正文证据,避免把空资源标成真实。'
  358. };
  359. }
  360. function gapSpec(row, nextActions, seed) {
  361. const id = row.id || '';
  362. const action = findNextActionForGap(nextActions, id);
  363. const common = {
  364. priority: 40,
  365. owner: ownerForGap(id),
  366. title: row.title || id,
  367. targetFile: 'outputs/proof-gap-closure-latest/proof-gap-closure-summary.json',
  368. targetFields: ['status', 'missingProofRequirements'],
  369. rowSelector: id,
  370. seedSuggestion: '',
  371. requiredAction: action?.next || row.next || '',
  372. acceptanceCommand: row.command || '',
  373. acceptance: row.required || ''
  374. };
  375. const specs = {
  376. 'historical-dataset': {
  377. priority: 31,
  378. section: 'history',
  379. targetFile: 'outputs/data-intake-pack-latest/history-data-template.csv',
  380. targetFields: ['客户原始Brief', '历史人工补号量基线', '参考账号或视频', '博主名称', '主页链接', '客户选择', '拒绝原因'],
  381. rowSelector: '至少 5 个真实历史 Brief 行。',
  382. seedSuggestion: historySeedSuggestion(seed),
  383. requiredAction: '补 5-10 个真实 Brief、人工名单、客户选择、拒绝原因和历史人工补号量基线。',
  384. acceptanceCommand: 'npm run history:from-csv -- --input <历史数据CSV> --output <history-dataset目录> && npm run history:audit -- --input <history-dataset目录> --output <历史审计输出目录> --strict',
  385. acceptance: 'historical-dataset-audit readyForCustomerEffectProof=true。'
  386. },
  387. 'video-real-candidate': {
  388. priority: 32,
  389. section: 'video',
  390. targetFile: 'outputs/video-intake-pack-latest/video-resource-template.csv',
  391. targetFields: ['资源角色', '博主名称', '主页链接', '视频链接', '封面链接', '字幕或ASR文本', '帧图链接', '是否真实资源'],
  392. rowSelector: '至少 1 行真实候选视频。',
  393. seedSuggestion: videoSeedSuggestion(seed),
  394. requiredAction: '补真实候选视频 URL、封面、ASR/字幕、帧图或正文证据。',
  395. acceptanceCommand: 'npm run video:resource-readiness -- --input outputs\\video-intake-pack-latest\\video-resource-template.csv --output outputs\\video-resource-readiness-latest --strict',
  396. acceptance: 'readyForVideoAbPreflight=true、failureCount=0、realCandidateRows>=1。'
  397. },
  398. 'manual-review-and-customer-effect': {
  399. priority: 33,
  400. section: 'review',
  401. targetFile: 'outputs/data-intake-pack-latest/manual-review-template.csv',
  402. targetFields: ['人工复核标签', '客户选择', '归因类型', '反馈原因', '本轮人工补号量'],
  403. rowSelector: '真实人工复核候选行。',
  404. seedSuggestion: candidateSeedSuggestion(seed),
  405. requiredAction: '补商务复核标签、负样本归因、客户选择和本轮人工补号量。',
  406. acceptanceCommand: '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',
  407. acceptance: 'review:metrics overallPass=true、customer-effect:audit overallPass=true。'
  408. },
  409. 'video-ab-live-proof': {
  410. priority: 60,
  411. section: 'video-ab',
  412. targetFile: 'outputs/video-intake-pack-latest/video-resource-template.csv',
  413. targetFields: ['proofContext.mode', 'proofContext.collectionMode', 'requiresVocSocialProvider', 'requiresVideoAnalysisProvider'],
  414. rowSelector: '真实视频 A/B 输出目录。',
  415. requiredAction: '真实视频资源和真实 provider 就绪后运行 acceptance:video-ab,不得使用 sample 或 unknown proofContext。',
  416. acceptanceCommand: 'npm run acceptance:video-ab',
  417. acceptance: 'proofContext.mode=live、collectionMode=live、generatedBy=acceptance:video-ab。'
  418. },
  419. 'live-provider-overnight-proof': {
  420. priority: 61,
  421. section: 'longrun',
  422. targetFile: '<overnight输出目录>/aggregate-summary.json',
  423. targetFields: ['manifest.liveEnabled', 'acceptance.overallPass', 'failureCount', 'failedGateCount'],
  424. rowSelector: '真实 live/provider overnight aggregate。',
  425. requiredAction: 'longrun:readiness ready=true 后再跑真实 provider overnight:quality。',
  426. acceptanceCommand: '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',
  427. acceptance: 'longrun ready=true;overnight aggregate liveEnabled=true、overallPass=true、failureCount=0。'
  428. }
  429. };
  430. return { ...common, ...(specs[id] || {}) };
  431. }
  432. function makeWorkOrder(item) {
  433. return {
  434. id: item.id,
  435. priority: Number(item.priority || 99),
  436. owner: item.owner || '待分配',
  437. source: item.source || '',
  438. gapId: item.gapId || '',
  439. section: item.section || '',
  440. issueType: item.issueType || '',
  441. title: item.title || item.id,
  442. targetFile: item.targetFile || '',
  443. targetFields: normalizeList(item.targetFields),
  444. rowSelector: item.rowSelector || '',
  445. currentEvidence: item.currentEvidence || '',
  446. missingProofRequirements: normalizeList(item.missingProofRequirements),
  447. seedSuggestion: item.seedSuggestion || '',
  448. requiredAction: item.requiredAction || '',
  449. acceptanceCommand: item.acceptanceCommand || '',
  450. acceptance: item.acceptance || '',
  451. requiresHumanRealMaterial: true,
  452. directCustomerProof: false,
  453. canCloseProofGap: false,
  454. proofLevel: 'not_business_proof',
  455. boundary: '本工单只指导补真实材料和复验,不证明客户效果。'
  456. };
  457. }
  458. function historySeedSuggestion(seed) {
  459. const row = firstRow(seed.historyDraftRows);
  460. if (!row) return '';
  461. return `可参考本地 history 草稿 ${row.briefId || ''}:${truncate(row.customerBrief || row.projectName || '', 90)};仍缺 ${normalizeList(row.missingForProof).join(';')}`;
  462. }
  463. function referenceSeedSuggestion(seed) {
  464. const refs = normalizeRows(seed.referenceSeedRows).filter(row => row.finalLink && !/\/404\b/.test(row.finalLink));
  465. if (!refs.length) return historySeedSuggestion(seed);
  466. return refs.slice(0, 3).map(row => `${row.authorName || row.referenceType || '参考'} ${row.finalLink}`).join(';');
  467. }
  468. function candidateSeedSuggestion(seed) {
  469. const rows = normalizeRows(seed.candidateSeedRows).slice(0, 5);
  470. if (!rows.length) return '';
  471. return rows.map(row => `${row.creatorName || '候选'}(${row.xhsId || '缺 xhsId'},${row.suggestedFormat || '形式待定'})`).join(';');
  472. }
  473. function videoSeedSuggestion(seed) {
  474. const rows = normalizeRows(seed.videoWorklistRows);
  475. const candidates = rows.filter(row => row.role === '候选视频');
  476. const picked = (candidates.length ? candidates : rows).slice(0, 4);
  477. if (!picked.length) return candidateSeedSuggestion(seed);
  478. return picked.map(row => `${row.role || '视频'}:${row.creatorName || '缺博主'},${row.resourceStatus || '状态待复核'},${row.videoLink || '缺视频 URL'}`).join(';');
  479. }
  480. function findNextActionForGap(nextActions, gapId) {
  481. const actions = Array.isArray(nextActions.actions) ? nextActions.actions : [];
  482. return actions.find(action => String(action.id || '').includes(gapId) || String(action.gapId || '') === gapId) || null;
  483. }
  484. function ownerForGap(id) {
  485. if (id === 'historical-dataset' || id === 'manual-review-and-customer-effect') return '商务';
  486. if (id === 'video-real-candidate') return '商务/投放';
  487. return '技术/AI';
  488. }
  489. function dedupeWorkOrders(rows) {
  490. const seen = new Map();
  491. for (const row of rows.filter(Boolean)) {
  492. const key = `${row.source}:${row.id}`;
  493. if (!seen.has(key)) {
  494. seen.set(key, row);
  495. continue;
  496. }
  497. const existing = seen.get(key);
  498. existing.currentEvidence = joinUnique([existing.currentEvidence, row.currentEvidence]);
  499. existing.missingProofRequirements = Array.from(new Set([...existing.missingProofRequirements, ...row.missingProofRequirements]));
  500. }
  501. return [...seen.values()];
  502. }
  503. function groupByOwner(rows) {
  504. const map = new Map();
  505. for (const row of rows) {
  506. if (!map.has(row.owner)) map.set(row.owner, []);
  507. map.get(row.owner).push(row);
  508. }
  509. return [...map.entries()].map(([owner, ownerRows]) => ({
  510. owner,
  511. workOrderCount: ownerRows.length,
  512. topPriority: Math.min(...ownerRows.map(row => row.priority)),
  513. targetFiles: Array.from(new Set(ownerRows.map(row => row.targetFile).filter(Boolean))),
  514. gapIds: Array.from(new Set(ownerRows.map(row => row.gapId).filter(Boolean))),
  515. missingProofRequirementCount: ownerRows.reduce((total, row) => total + row.missingProofRequirements.length, 0)
  516. }));
  517. }
  518. function writeOwnerArtifacts(outputDir, rows) {
  519. const byOwnerDir = path.join(outputDir, 'by-owner');
  520. fs.mkdirSync(byOwnerDir, { recursive: true });
  521. return groupByOwner(rows).map(group => {
  522. const ownerRows = rows.filter(row => row.owner === group.owner);
  523. const slug = ownerSlug(group.owner);
  524. const markdownPath = path.join(byOwnerDir, `${slug}.md`);
  525. const csvPath = path.join(byOwnerDir, `${slug}.csv`);
  526. fs.writeFileSync(markdownPath, withBom(renderOwnerReport(group.owner, ownerRows)), 'utf8');
  527. fs.writeFileSync(csvPath, withBom(renderCsv(ownerRows)), 'utf8');
  528. return {
  529. owner: group.owner,
  530. workOrderCount: ownerRows.length,
  531. topPriority: group.topPriority,
  532. markdown: path.join('outputs', 'real-proof-closure-work-order-latest', 'by-owner', `${slug}.md`).replace(/\\/g, '/'),
  533. csv: path.join('outputs', 'real-proof-closure-work-order-latest', 'by-owner', `${slug}.csv`).replace(/\\/g, '/'),
  534. targetFiles: group.targetFiles,
  535. gapIds: group.gapIds
  536. };
  537. });
  538. }
  539. function renderReport(summary) {
  540. const lines = [
  541. '# 真实证明闭环工单',
  542. '',
  543. `- 生成时间:${summary.generatedAt}`,
  544. `- passed:${summary.passed}`,
  545. `- complete:${summary.complete}`,
  546. `- proofLevel:${summary.proofLevel}`,
  547. `- workOrderCount:${summary.workOrderCount}`,
  548. `- proofGapOpenCount:${summary.sourceState.proofGapOpenCount}`,
  549. `- intakeFailureCount:${summary.sourceState.intakeFailureCount}`,
  550. `- videoFailureCount:${summary.sourceState.videoFailureCount}`,
  551. `- realCandidateRows:${summary.sourceState.realCandidateRows}`,
  552. `- canCloseProofGap:${summary.canCloseProofGap}`,
  553. '',
  554. '## 负责人汇总',
  555. '',
  556. '| 负责人 | 工单数 | 最高优先级 | 目标文件 | 缺口 |',
  557. '| --- | ---: | ---: | --- | --- |',
  558. ...summary.ownerGroups.map(group => tableRow([
  559. group.owner,
  560. group.workOrderCount,
  561. group.topPriority,
  562. group.targetFiles.join(';'),
  563. group.gapIds.join(';')
  564. ])),
  565. '',
  566. '## 负责人附件',
  567. '',
  568. '| 负责人 | 工单数 | Markdown | CSV |',
  569. '| --- | ---: | --- | --- |',
  570. ...(summary.ownerArtifacts || []).map(item => tableRow([item.owner, item.workOrderCount, item.markdown, item.csv])),
  571. '',
  572. '## 工单明细',
  573. '',
  574. '| 优先级 | 负责人 | 缺口 | 标题 | 目标文件 | 字段 | 行范围 | 当前证据 | 缺失证明要求 | 种子线索 | 补齐动作 | 复验命令 | 验收 |',
  575. '| ---: | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |',
  576. ...summary.workOrders.map(row => tableRow([
  577. row.priority,
  578. row.owner,
  579. row.gapId,
  580. row.title,
  581. row.targetFile,
  582. row.targetFields.join(';'),
  583. row.rowSelector,
  584. row.currentEvidence,
  585. row.missingProofRequirements.join(';'),
  586. row.seedSuggestion,
  587. row.requiredAction,
  588. row.acceptanceCommand,
  589. row.acceptance
  590. ])),
  591. '',
  592. '## 源文件',
  593. '',
  594. ...Object.entries(summary.sourceFiles).map(([key, value]) => `- ${key}:${value || '缺失'}`),
  595. '',
  596. '## 边界',
  597. '',
  598. ...summary.guardrails.map(item => `- ${item}`)
  599. ];
  600. return lines.join('\n');
  601. }
  602. function renderOwnerReport(owner, rows) {
  603. return [
  604. `# 真实证明闭环工单 - ${owner}`,
  605. '',
  606. `- 工单数:${rows.length}`,
  607. '- 边界:本附件只指导补真实材料和复验,不证明客户效果。',
  608. '',
  609. '| 优先级 | 缺口 | 标题 | 目标文件 | 字段 | 当前证据 | 缺失证明要求 | 补齐动作 | 复验命令 | 验收 |',
  610. '| ---: | --- | --- | --- | --- | --- | --- | --- | --- | --- |',
  611. ...rows.map(row => tableRow([
  612. row.priority,
  613. row.gapId,
  614. row.title,
  615. row.targetFile,
  616. row.targetFields.join(';'),
  617. row.currentEvidence,
  618. row.missingProofRequirements.join(';'),
  619. row.requiredAction,
  620. row.acceptanceCommand,
  621. row.acceptance
  622. ]))
  623. ].join('\n');
  624. }
  625. function renderCsv(rows) {
  626. const header = [
  627. 'id',
  628. '优先级',
  629. '负责人',
  630. '来源',
  631. '缺口ID',
  632. '区域',
  633. '问题类型',
  634. '标题',
  635. '目标文件',
  636. '目标字段',
  637. '行范围',
  638. '当前证据',
  639. '缺失证明要求',
  640. '种子线索',
  641. '补齐动作',
  642. '复验命令',
  643. '验收',
  644. '需要人工真实材料',
  645. '直接客户证明',
  646. '可关闭proof-gap',
  647. '证明等级',
  648. '边界'
  649. ];
  650. return [
  651. header.join(','),
  652. ...rows.map(row => [
  653. row.id,
  654. row.priority,
  655. row.owner,
  656. row.source,
  657. row.gapId,
  658. row.section,
  659. row.issueType,
  660. row.title,
  661. row.targetFile,
  662. row.targetFields.join(';'),
  663. row.rowSelector,
  664. row.currentEvidence,
  665. row.missingProofRequirements.join(';'),
  666. row.seedSuggestion,
  667. row.requiredAction,
  668. row.acceptanceCommand,
  669. row.acceptance,
  670. row.requiresHumanRealMaterial,
  671. row.directCustomerProof,
  672. row.canCloseProofGap,
  673. row.proofLevel,
  674. row.boundary
  675. ].map(csvCell).join(','))
  676. ].join('\n');
  677. }
  678. function latestSummary(outputsDir, fileName, fallbackDir) {
  679. const candidates = [];
  680. const fallbackPath = path.join(outputsDir, fallbackDir, fileName);
  681. if (fs.existsSync(fallbackPath)) candidates.push(fallbackPath);
  682. for (const dir of listDirs(outputsDir)) {
  683. const file = path.join(dir, fileName);
  684. if (fs.existsSync(file)) candidates.push(file);
  685. }
  686. const picked = candidates.sort((a, b) => mtimeMs(b) - mtimeMs(a))[0] || fallbackPath;
  687. return { path: picked, json: readJson(picked) };
  688. }
  689. function listDirs(dir) {
  690. if (!fs.existsSync(dir)) return [];
  691. return fs.readdirSync(dir, { withFileTypes: true })
  692. .filter(item => item.isDirectory())
  693. .map(item => path.join(dir, item.name));
  694. }
  695. function readJson(file) {
  696. try {
  697. return JSON.parse(fs.readFileSync(file, 'utf8'));
  698. } catch {
  699. return null;
  700. }
  701. }
  702. function firstRow(value) {
  703. const rows = normalizeRows(value);
  704. return rows[0] || null;
  705. }
  706. function normalizeRows(value) {
  707. return Array.isArray(value) ? value : [];
  708. }
  709. function normalizeList(value) {
  710. if (Array.isArray(value)) return value.map(item => String(item || '').trim()).filter(Boolean);
  711. if (!value) return [];
  712. return [String(value).trim()].filter(Boolean);
  713. }
  714. function countRows(rows, fallback = 0) {
  715. if (Array.isArray(rows)) return rows.length;
  716. return Number(fallback || 0);
  717. }
  718. function joinUnique(values) {
  719. return Array.from(new Set(values.filter(Boolean))).join(';');
  720. }
  721. function truncate(value, maxLength) {
  722. const text = String(value || '');
  723. return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
  724. }
  725. function tableRow(cells) {
  726. return `| ${cells.map(escapeCell).join(' | ')} |`;
  727. }
  728. function escapeCell(value) {
  729. return String(value ?? '').replace(/\r?\n/g, '<br>').replace(/\|/g, '\\|');
  730. }
  731. function csvCell(value) {
  732. const text = String(value ?? '').replace(/\r?\n/g, ' ');
  733. if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
  734. return text;
  735. }
  736. function withBom(text) {
  737. return `\uFEFF${text}`;
  738. }
  739. function ownerSlug(owner) {
  740. if (owner === '商务') return 'business';
  741. if (owner === '商务/投放') return 'business-media';
  742. if (owner === '技术/AI') return 'tech-ai';
  743. return String(owner || 'unknown').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'unknown';
  744. }
  745. function rel(root, file) {
  746. return path.relative(root, file).replace(/\\/g, '/');
  747. }
  748. function mtimeMs(file) {
  749. try {
  750. return fs.statSync(file).mtimeMs;
  751. } catch {
  752. return 0;
  753. }
  754. }
  755. function parseArgs(argv) {
  756. const result = {};
  757. for (let index = 0; index < argv.length; index += 1) {
  758. const arg = argv[index];
  759. if (!arg.startsWith('--')) continue;
  760. const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  761. const next = argv[index + 1];
  762. if (!next || next.startsWith('--')) result[key] = true;
  763. else {
  764. result[key] = next;
  765. index += 1;
  766. }
  767. }
  768. return result;
  769. }
  770. if (require.main === module) main();
  771. module.exports = {
  772. buildRealProofClosureWorkOrder
  773. };