evidence-index.js 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869
  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_EVIDENCE_INDEX_OUTPUT || path.join(OUTPUTS, `evidence-index-${Date.now()}`));
  9. const summary = buildEvidenceIndex({ root: ROOT, outputsDir: path.resolve(args.outputs || OUTPUTS) });
  10. fs.mkdirSync(outputDir, { recursive: true });
  11. const jsonPath = path.join(outputDir, 'evidence-index-summary.json');
  12. const reportPath = path.join(outputDir, 'evidence-index-report.md');
  13. fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8');
  14. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  15. console.log(JSON.stringify({
  16. outputDir,
  17. json: jsonPath,
  18. report: reportPath,
  19. total: summary.total,
  20. realEvidence: summary.counts.real_evidence || 0,
  21. smokeOrLocal: summary.counts.smoke_or_local || 0,
  22. notProof: summary.counts.not_business_proof || 0
  23. }, null, 2));
  24. }
  25. function buildEvidenceIndex({ root, outputsDir }) {
  26. const dirs = fs.existsSync(outputsDir)
  27. ? fs.readdirSync(outputsDir, { withFileTypes: true }).filter(item => item.isDirectory()).map(item => path.join(outputsDir, item.name))
  28. : [];
  29. const entries = dirs.flatMap(dir => classifyDir(root, dir)).sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
  30. return {
  31. generatedAt: new Date().toISOString(),
  32. root,
  33. outputsDir,
  34. total: entries.length,
  35. counts: countBy(entries, 'proofLevel'),
  36. byType: countBy(entries, 'type'),
  37. entries
  38. };
  39. }
  40. function classifyDir(root, dir) {
  41. const entries = [];
  42. const aggregate = readJson(path.join(dir, 'aggregate-summary.json'));
  43. if (aggregate) entries.push(classifyOvernight(root, dir, aggregate));
  44. const status = readJson(path.join(dir, 'optimization-status-summary.json'));
  45. if (status) entries.push(classifyStatus(root, dir, status));
  46. const readiness = readJson(path.join(dir, 'long-run-readiness-summary.json'));
  47. if (readiness) entries.push(classifyReadiness(root, dir, readiness));
  48. const pipeline = readJson(path.join(dir, 'optimization-pipeline-summary.json'));
  49. if (pipeline) entries.push(classifyPipeline(root, dir, pipeline));
  50. const completion = readJson(path.join(dir, 'optimization-completion-summary.json'));
  51. if (completion) entries.push(classifyOptimizationCompletion(root, dir, completion));
  52. const handoff = readJson(path.join(dir, 'optimization-handoff-summary.json'));
  53. if (handoff) entries.push(classifyHandoff(root, dir, handoff));
  54. const proofGap = readJson(path.join(dir, 'proof-gap-request-summary.json'));
  55. if (proofGap) entries.push(classifyProofGap(root, dir, proofGap));
  56. const proofGapClosure = readJson(path.join(dir, 'proof-gap-closure-summary.json'));
  57. if (proofGapClosure) entries.push(classifyProofGapClosure(root, dir, proofGapClosure));
  58. const proofGapOperatorPack = readJson(path.join(dir, 'proof-gap-operator-pack-summary.json'));
  59. if (proofGapOperatorPack) entries.push(classifyProofGapOperatorPack(root, dir, proofGapOperatorPack));
  60. const realProofIntakeBundle = readJson(path.join(dir, 'real-proof-intake-bundle-summary.json'));
  61. if (realProofIntakeBundle) entries.push(classifyRealProofIntakeBundle(root, dir, realProofIntakeBundle));
  62. const realProofClosureWorkOrder = readJson(path.join(dir, 'real-proof-closure-work-order-summary.json'));
  63. if (realProofClosureWorkOrder) entries.push(classifyRealProofClosureWorkOrder(root, dir, realProofClosureWorkOrder));
  64. const realGapMaterialAudit = readJson(path.join(dir, 'real-gap-material-audit-summary.json'));
  65. if (realGapMaterialAudit) entries.push(classifyRealGapMaterialAudit(root, dir, realGapMaterialAudit));
  66. const localSeedMaterialIndex = readJson(path.join(dir, 'local-seed-material-index-summary.json'));
  67. if (localSeedMaterialIndex) entries.push(classifyLocalSeedMaterialIndex(root, dir, localSeedMaterialIndex));
  68. const localSeedToIntakeWorklist = readJson(path.join(dir, 'local-seed-to-intake-worklist-summary.json'));
  69. if (localSeedToIntakeWorklist) entries.push(classifyLocalSeedToIntakeWorklist(root, dir, localSeedToIntakeWorklist));
  70. const experienceTranscriptIndex = readJson(path.join(dir, 'experience-transcript-index-summary.json'));
  71. if (experienceTranscriptIndex) entries.push(classifyExperienceTranscriptIndex(root, dir, experienceTranscriptIndex));
  72. const feedbackLoopClosure = readJson(path.join(dir, 'feedback-loop-closure-summary.json'));
  73. if (feedbackLoopClosure) entries.push(classifyFeedbackLoopClosure(root, dir, feedbackLoopClosure));
  74. const homepageEvidenceReadiness = readJson(path.join(dir, 'homepage-evidence-readiness-summary.json'));
  75. if (homepageEvidenceReadiness) entries.push(classifyHomepageEvidenceReadiness(root, dir, homepageEvidenceReadiness));
  76. const proofGapSoftwareForm = readJson(path.join(dir, 'tihao-proof-gap-software-form-summary.json'));
  77. if (proofGapSoftwareForm) entries.push(classifyProofGapSoftwareForm(root, dir, proofGapSoftwareForm));
  78. const latestFormIndex = readJson(path.join(dir, 'latest-form-index-summary.json'));
  79. if (latestFormIndex) entries.push(classifyLatestFormIndex(root, dir, latestFormIndex));
  80. const roundDeposition = readJson(path.join(dir, 'round-deposition-summary.json'));
  81. if (roundDeposition) entries.push(classifyRoundDeposition(root, dir, roundDeposition));
  82. const businessProofProgress = readJson(path.join(dir, 'business-proof-progress-summary.json'));
  83. if (businessProofProgress) entries.push(classifyBusinessProofProgress(root, dir, businessProofProgress));
  84. const businessProofNextActions = readJson(path.join(dir, 'business-proof-next-actions-summary.json'));
  85. if (businessProofNextActions) entries.push(classifyBusinessProofNextActions(root, dir, businessProofNextActions));
  86. const businessExecutionIndex = readJson(path.join(dir, 'business-execution-index-summary.json'));
  87. if (businessExecutionIndex) entries.push(classifyBusinessExecutionIndex(root, dir, businessExecutionIndex));
  88. const intakeReadiness = readJson(path.join(dir, 'intake-readiness-summary.json'));
  89. if (intakeReadiness) entries.push(classifyIntakeReadiness(root, dir, intakeReadiness));
  90. const intakeFieldChecklist = readJson(path.join(dir, 'intake-field-checklist-summary.json'));
  91. if (intakeFieldChecklist) entries.push(classifyIntakeFieldChecklist(root, dir, intakeFieldChecklist));
  92. if (isDataIntakePack(dir)) entries.push(classifyDataIntakePack(root, dir));
  93. if (isVideoIntakePack(dir)) entries.push(classifyVideoIntakePack(root, dir));
  94. const preflight = readJson(path.join(dir, 'live-preflight-summary.json'));
  95. if (preflight) entries.push(classifyPreflight(root, dir, preflight));
  96. const review = readJson(path.join(dir, 'review-metrics-summary.json'));
  97. if (review) entries.push(classifyReview(root, dir, review));
  98. const customerEffect = readJson(path.join(dir, 'customer-effect-summary.json')) ||
  99. readJson(path.join(dir, 'customer-effect-audit', 'customer-effect-summary.json'));
  100. if (customerEffect) entries.push(classifyCustomerEffect(root, dir, customerEffect, { pipeline }));
  101. const history = readJson(path.join(dir, 'historical-dataset-audit.json'));
  102. if (history) entries.push(classifyHistory(root, dir, history));
  103. const video = readJson(path.join(dir, 'video-hit-rate-summary.json'));
  104. if (video) entries.push(classifyVideo(root, dir, video));
  105. const videoRuntimePreflight = readJson(path.join(dir, 'video-hit-rate-preflight-summary.json'));
  106. if (videoRuntimePreflight) entries.push(classifyVideoRuntimePreflight(root, dir, videoRuntimePreflight));
  107. const videoResourceReadiness = readJson(path.join(dir, 'video-resource-readiness-summary.json'));
  108. if (videoResourceReadiness) entries.push(classifyVideoResourceReadiness(root, dir, videoResourceReadiness));
  109. if (fs.existsSync(path.join(dir, 'intake-readiness-repair-actions.csv'))) {
  110. entries.push(classifyRepairActions(root, dir, 'intake-repair-actions', 'intake-readiness-repair-actions.csv'));
  111. }
  112. if (fs.existsSync(path.join(dir, 'video-resource-repair-actions.csv'))) {
  113. entries.push(classifyRepairActions(root, dir, 'video-resource-repair-actions', 'video-resource-repair-actions.csv'));
  114. }
  115. if (fs.existsSync(path.join(dir, 'homepage-evidence-repair-actions.csv'))) {
  116. entries.push(classifyRepairActions(root, dir, 'homepage-evidence-repair-actions', 'homepage-evidence-repair-actions.csv'));
  117. }
  118. return entries;
  119. }
  120. function classifyOvernight(root, dir, aggregate) {
  121. const liveProof = Boolean(aggregate.manifest?.liveEnabled) &&
  122. aggregate.acceptance?.overallPass === true &&
  123. Number(aggregate.failureCount || 0) === 0 &&
  124. Number(aggregate.acceptance?.failedGateCount ?? aggregate.failedGateCount ?? 0) === 0;
  125. return {
  126. type: 'overnight-quality',
  127. dir: rel(root, dir),
  128. updatedAt: mtime(dir),
  129. proofLevel: liveProof ? 'real_evidence' : aggregate.manifest?.liveEnabled ? 'not_business_proof' : 'smoke_or_local',
  130. status: aggregate.acceptance?.overallPass ? 'pass' : 'not_passed',
  131. liveEnabled: Boolean(aggregate.manifest?.liveEnabled),
  132. runCount: aggregate.runCount || 0,
  133. failureCount: aggregate.failureCount || 0,
  134. failedGateCount: aggregate.acceptance?.failedGateCount ?? aggregate.failedGateCount ?? 0,
  135. summary: `fixtures=${aggregate.fixtureCount || 0}, variants=${aggregate.variantCount || 0}, runs=${aggregate.runCount || 0}`,
  136. next: liveProof
  137. ? '可作为 live 自动化证据;仍需人工复核和客户选择数据证明真实业务效果。'
  138. : aggregate.manifest?.liveEnabled
  139. ? 'live 产物未通过全部门槛,不能作为业务证明。'
  140. : 'sample/smoke 只能证明结构和本地门槛。'
  141. };
  142. }
  143. function classifyStatus(root, dir, status) {
  144. return {
  145. type: 'optimization-status',
  146. dir: rel(root, dir),
  147. updatedAt: mtime(dir),
  148. proofLevel: status.complete ? 'real_evidence' : 'not_business_proof',
  149. status: status.complete ? 'complete' : 'incomplete',
  150. summary: `passed=${status.counts?.passed || 0}, ready_not_proven=${status.counts?.ready_not_proven || 0}, blocked_by_external_data=${status.counts?.blocked_by_external_data || 0}`,
  151. next: status.complete ? '状态审计已完成。' : '仍有 ready_not_proven 或 blocked_by_external_data,不能宣布长期目标完成。'
  152. };
  153. }
  154. function classifyReadiness(root, dir, readiness) {
  155. return {
  156. type: 'long-run-readiness',
  157. dir: rel(root, dir),
  158. updatedAt: mtime(dir),
  159. proofLevel: readiness.ready ? 'smoke_or_local' : 'not_business_proof',
  160. status: readiness.ready ? 'ready' : 'not_ready',
  161. mode: readiness.mode,
  162. summary: `mode=${readiness.mode}, fail=${readiness.counts?.fail || 0}, warn=${readiness.counts?.warn || 0}`,
  163. next: readiness.ready ? '可作为启动前置检查;不等同于 live 或客户效果证明。' : '先补齐失败项,再启动对应长跑。'
  164. };
  165. }
  166. function classifyPipeline(root, dir, pipeline) {
  167. const allStepsPass = Array.isArray(pipeline.results) && pipeline.results.length > 0 &&
  168. pipeline.results.every(item => item.status === 'pass' || item.status === 'planned');
  169. const closure = pipeline.proofGapClosure || {};
  170. const closureComplete = Boolean(closure.complete);
  171. const readyForClaim = Boolean(pipeline.readyForClaim) && closureComplete;
  172. return {
  173. type: 'optimization-pipeline',
  174. dir: rel(root, dir),
  175. updatedAt: mtime(dir),
  176. proofLevel: allStepsPass ? 'smoke_or_local' : 'not_business_proof',
  177. status: allStepsPass ? (pipeline.dryRun ? 'planned' : 'pass') : 'not_passed',
  178. summary: `steps=${pipeline.results?.length || 0}, pass=${pipeline.counts?.pass || 0}, planned=${pipeline.counts?.planned || 0}, fail=${pipeline.counts?.fail || 0}, nextActions=${pipeline.nextActions?.length || 0}, readyForClaim=${readyForClaim}, closureComplete=${closureComplete}, closureOpen=${closure.openCount ?? 'unknown'}`,
  179. next: readyForClaim
  180. ? 'pipeline 已跑通并且 proof-gap:closure complete=true;仍需回看 customer-effect、video A/B 和 live/provider 证据细项。'
  181. : 'pipeline 只能证明编排链路或局部审计完成;proof-gap:closure 未关闭前不能宣称业务效果。'
  182. };
  183. }
  184. function classifyOptimizationCompletion(root, dir, completion) {
  185. const readyForClaim = Boolean(completion.complete && completion.readyForClaim && Number(completion.proofGapOpenCount || 0) === 0);
  186. return {
  187. type: 'optimization-completion',
  188. dir: rel(root, dir),
  189. updatedAt: mtime(dir),
  190. proofLevel: readyForClaim ? 'real_evidence' : 'not_business_proof',
  191. status: readyForClaim ? 'ready_for_claim' : 'incomplete',
  192. summary: `complete=${Boolean(completion.complete)}, readyForClaim=${Boolean(completion.readyForClaim)}, proofGapOpen=${completion.proofGapOpenCount ?? 'unknown'}, blockers=${completion.blockingReasons?.length ?? 'unknown'}`,
  193. next: readyForClaim
  194. ? '完成度审计允许进入业务效果声明;仍需回看具体客户效果、视频 A/B 和 live/provider 证据。'
  195. : '完成度审计显示仍有阻塞项,不能宣称提号率提升、客户效果达标或人工补号量下降。'
  196. };
  197. }
  198. function classifyDataIntakePack(root, dir) {
  199. return {
  200. type: 'data-intake-pack',
  201. dir: rel(root, dir),
  202. updatedAt: mtime(dir),
  203. proofLevel: 'smoke_or_local',
  204. status: 'ready',
  205. summary: 'history template, manual review template, and README are present',
  206. next: '交给商务填写真实历史 Brief、客户选择、历史人工补号基线和本轮人工补号量;模板本身不证明业务效果。'
  207. };
  208. }
  209. function classifyVideoIntakePack(root, dir) {
  210. return {
  211. type: 'video-intake-pack',
  212. dir: rel(root, dir),
  213. updatedAt: mtime(dir),
  214. proofLevel: 'smoke_or_local',
  215. status: 'ready',
  216. summary: 'video resource template and README are present',
  217. next: '交给商务补齐真实参考视频和候选视频的 URL、封面、ASR、帧图或正文证据;模板本身不证明视频 A/B 通过。'
  218. };
  219. }
  220. function classifyHandoff(root, dir, handoff) {
  221. return {
  222. type: 'optimization-handoff',
  223. dir: rel(root, dir),
  224. updatedAt: mtime(dir),
  225. proofLevel: 'smoke_or_local',
  226. status: handoff.complete ? 'complete_reported' : 'incomplete_reported',
  227. summary: `ready=${handoff.readyCapabilities?.length || 0}, readyNotProven=${handoff.readyNotProven?.length || 0}, blockers=${handoff.externalBlockers?.length || 0}`,
  228. next: handoff.complete
  229. ? '交接摘要声称完成时必须回看 optimization:status 和客户效果证据。'
  230. : '可用于下一轮 AI 或商务交接;不证明客户效果。'
  231. };
  232. }
  233. function classifyProofGap(root, dir, proofGap) {
  234. return {
  235. type: 'proof-gap-request',
  236. dir: rel(root, dir),
  237. updatedAt: mtime(dir),
  238. proofLevel: 'smoke_or_local',
  239. status: proofGap.unresolvedCount ? 'gaps_requested' : 'no_gaps_reported',
  240. summary: `gaps=${proofGap.rows?.length || 0}, unresolved=${proofGap.unresolvedCount || 0}`,
  241. next: '可作为商务/下一轮 AI 补真实证明数据的请求清单;不证明业务效果。'
  242. };
  243. }
  244. function classifyProofGapClosure(root, dir, closure) {
  245. return {
  246. type: 'proof-gap-closure',
  247. dir: rel(root, dir),
  248. updatedAt: mtime(dir),
  249. proofLevel: closure.complete ? 'real_evidence' : 'not_business_proof',
  250. status: closure.complete ? 'all_closed' : 'open_gaps',
  251. summary: `closed=${closure.closedCount || 0}, open=${closure.openCount || 0}`,
  252. next: closure.complete
  253. ? '所有真实证明缺口均已关闭;仍需回看 optimization:status 是否 complete。'
  254. : '仍有 proof gap 未关闭,不能宣布长期优化目标完成。'
  255. };
  256. }
  257. function classifyProofGapOperatorPack(root, dir, pack) {
  258. return {
  259. type: 'proof-gap-operator-pack',
  260. dir: rel(root, dir),
  261. updatedAt: mtime(dir),
  262. proofLevel: 'not_business_proof',
  263. complete: Boolean(pack.complete),
  264. directCustomerProof: false,
  265. summary: `openCount=${Number(pack.openCount || 0)}, rowCount=${Number(pack.rowCount || 0)}`,
  266. next: '按负责人、填写文件、复验命令和边界补齐真实证明;不证明客户效果。'
  267. };
  268. }
  269. function classifyRealProofIntakeBundle(root, dir, bundle) {
  270. return {
  271. type: 'real-proof-intake-bundle',
  272. dir: rel(root, dir),
  273. updatedAt: mtime(dir),
  274. proofLevel: 'not_business_proof',
  275. status: bundle.complete ? 'all_real_proof_closed' : 'materials_requested',
  276. complete: Boolean(bundle.complete),
  277. directCustomerProof: false,
  278. summary: `proofOpen=${Number(bundle.proofOpenCount || 0)}, fillRows=${Number(bundle.fillRowCount || 0)}, proofGaps=${Number(bundle.proofGapRowCount || 0)}, commands=${Number(bundle.recheckCommandCount || 0)}`,
  279. next: bundle.complete
  280. ? '真实材料总包显示 proofOpenCount=0;仍需回看 customer-effect、video A/B 和 live/provider 证据细项。'
  281. : '按总包中的模板、字段缺口、修复清单和复验命令补齐真实材料;不证明客户效果。'
  282. };
  283. }
  284. function classifyRealProofClosureWorkOrder(root, dir, summary) {
  285. return {
  286. type: 'real-proof-closure-work-order',
  287. dir: rel(root, dir),
  288. updatedAt: mtime(dir),
  289. proofLevel: 'not_business_proof',
  290. status: summary.complete ? 'all_work_orders_closed' : 'work_orders_open',
  291. complete: Boolean(summary.complete),
  292. directCustomerProof: false,
  293. canCloseProofGap: false,
  294. summary: `workOrders=${Number(summary.workOrderCount || 0)}, owners=${Number(summary.ownerGroupCount || 0)}, proofOpen=${summary.sourceState?.proofGapOpenCount ?? 'unknown'}, intakeFailures=${summary.sourceState?.intakeFailureCount ?? 'unknown'}, realCandidateRows=${summary.sourceState?.realCandidateRows ?? 'unknown'}`,
  295. next: summary.complete
  296. ? '闭环工单显示无待补项;仍需回看 proof-gap:closure 和 optimization:completion 是否允许完成声明。'
  297. : '按负责人附件逐字段补真实历史 Brief、真实候选视频、客户选择和人工补号量;工单本身不证明客户效果。'
  298. };
  299. }
  300. function classifyRealGapMaterialAudit(root, dir, summary) {
  301. return {
  302. type: 'real-gap-material-audit',
  303. dir: rel(root, dir),
  304. updatedAt: mtime(dir),
  305. proofLevel: 'not_business_proof',
  306. status: summary.canCloseProofGap ? 'claimable' : 'open_gaps_have_materials',
  307. complete: Boolean(summary.complete),
  308. directCustomerProof: false,
  309. canCloseProofGap: false,
  310. summary: `gaps=${Number(summary.gapCount || 0)}, materialFound=${Number(summary.materialFoundCount || 0)}, open=${Number(summary.openGapCount || 0)}`,
  311. next: 'Use this audit to prove project materials were found for the three open gaps; do not use it as customer-effect, video A/B, or manual-supplement business proof.'
  312. };
  313. }
  314. function classifyProofGapSoftwareForm(root, dir, form) {
  315. return {
  316. type: 'proof-gap-software-form',
  317. dir: rel(root, dir),
  318. updatedAt: mtime(dir),
  319. proofLevel: form.passed ? 'smoke_or_local' : 'not_business_proof',
  320. status: form.passed ? 'pass' : 'not_passed',
  321. summary: `rows=${form.rowCount || 0}, duplicateId=${form.duplicateIdCount || 0}, header=${Boolean(form.headerMatches)}, rowWidth=${Boolean(form.rowWidthOk)}`,
  322. next: '用于软件端/商务补证任务表;不证明客户效果或命中率提升。'
  323. };
  324. }
  325. function classifyLatestFormIndex(root, dir, index) {
  326. return {
  327. type: 'latest-form-index',
  328. dir: rel(root, dir),
  329. updatedAt: mtime(dir),
  330. proofLevel: index.passed ? 'smoke_or_local' : 'not_business_proof',
  331. status: index.passed ? 'pass' : 'not_passed',
  332. summary: `actions=${index.nextActions?.actionCount || 0}, proofOpen=${index.nextActions?.proofOpenCount ?? 'unknown'}, primaryForm=${Boolean(index.primaryForm?.csv)}, clientList=${Boolean(index.clientList?.csv)}`,
  333. next: '用于快速定位最新补证表单、候选名单和负责人行动文件;不证明客户效果或命中率提升。'
  334. };
  335. }
  336. function classifyRoundDeposition(root, dir, roundDeposition) {
  337. return {
  338. type: 'round-deposition',
  339. dir: rel(root, dir),
  340. updatedAt: mtime(dir),
  341. proofLevel: roundDeposition.passed ? 'smoke_or_local' : 'not_business_proof',
  342. status: roundDeposition.passed ? 'pass' : 'not_passed',
  343. summary: `pass=${roundDeposition.counts?.pass || 0}, warn=${roundDeposition.counts?.warn || 0}, fail=${roundDeposition.counts?.fail || 0}`,
  344. next: roundDeposition.passed
  345. ? '可证明本轮交接沉淀完整;仍不证明客户效果或命中率提升。'
  346. : '先补齐实施日志、证据台账、状态审计或交接摘要,再进入下一轮。'
  347. };
  348. }
  349. function classifyBusinessProofProgress(root, dir, progress) {
  350. return {
  351. type: 'business-proof-progress',
  352. dir: rel(root, dir),
  353. updatedAt: mtime(dir),
  354. proofLevel: 'smoke_or_local',
  355. status: progress.complete ? 'all_steps_pass' : 'in_progress',
  356. summary: `pass=${progress.counts?.pass || 0}, fail=${progress.counts?.fail || 0}, pending=${progress.counts?.pending || 0}, blocked=${progress.counts?.blocked_by_external_data || 0}`,
  357. next: progress.complete
  358. ? '15 步补证进度均为 pass;仍需回看 proof-gap:closure 和 customer-effect:audit 是否真实通过。'
  359. : '用于实时同步补证步骤进度;不证明客户效果或命中率提升。'
  360. };
  361. }
  362. function classifyBusinessProofNextActions(root, dir, nextActions) {
  363. return {
  364. type: 'business-proof-next-actions',
  365. dir: rel(root, dir),
  366. updatedAt: mtime(dir),
  367. proofLevel: 'smoke_or_local',
  368. status: nextActions.complete ? 'no_actions_needed' : 'actions_open',
  369. summary: `actions=${nextActions.actions?.length || 0}, progressComplete=${Boolean(nextActions.sourceState?.progressComplete)}, proofGapOpen=${nextActions.sourceState?.proofGapOpenCount ?? 'unknown'}`,
  370. next: nextActions.complete
  371. ? '行动队列显示无待办;仍需回看 proof-gap:closure 和 customer-effect:audit 是否真实通过。'
  372. : '用于把 blocked/fail/pending 转成可分派任务;不证明客户效果或命中率提升。'
  373. };
  374. }
  375. function classifyBusinessExecutionIndex(root, dir, index) {
  376. return {
  377. type: 'business-execution-index',
  378. dir: rel(root, dir),
  379. updatedAt: mtime(dir),
  380. proofLevel: 'smoke_or_local',
  381. status: index.passed ? (index.proofOpenCount > 0 ? 'actions_open' : 'ready_for_claim_audit') : 'not_passed',
  382. summary: `rows=${index.rowCount || 0}, actions=${index.actionRowCount || 0}, repairs=${index.repairRowCount || 0}, proofOpen=${index.proofOpenCount ?? 'unknown'}`,
  383. next: '用于商务集中定位补证表单、负责人文件和修复清单;不证明客户效果或命中率提升。'
  384. };
  385. }
  386. function classifyLocalSeedMaterialIndex(root, dir, summary) {
  387. return {
  388. type: 'local-seed-material-index',
  389. dir: rel(root, dir),
  390. updatedAt: mtime(dir),
  391. proofLevel: 'not_business_proof',
  392. status: summary.existingMaterialCount > 0 ? 'seed_materials_available' : 'seed_materials_not_found',
  393. complete: false,
  394. directCustomerProof: false,
  395. canCloseProofGap: false,
  396. summary: `materials=${summary.existingMaterialCount || 0}/${summary.materialCount || 0}, liveCandidates=${summary.promisingLiveAggregateCount || 0}, canCloseProofGap=false`,
  397. next: '可用于补真实 Brief、候选池和视频资源模板;不能替代客户选择、人工补号量、真实候选视频或 proof-gap:closure。'
  398. };
  399. }
  400. function classifyLocalSeedToIntakeWorklist(root, dir, summary) {
  401. return {
  402. type: 'local-seed-to-intake-worklist',
  403. dir: rel(root, dir),
  404. updatedAt: mtime(dir),
  405. proofLevel: 'not_business_proof',
  406. status: Number(summary.counts?.historyDraftRows || 0) > 0 ? 'seed_worklist_ready' : 'seed_worklist_empty',
  407. complete: false,
  408. directCustomerProof: false,
  409. canCloseProofGap: false,
  410. summary: `historyDraft=${summary.counts?.historyDraftRows || 0}, references=${summary.counts?.referenceSeedRows || 0}, candidates=${summary.counts?.candidateSeedRows || 0}, videoWorklist=${summary.counts?.videoWorklistRows || 0}, canCloseProofGap=false`,
  411. next: '可作为商务/投放把本地 DHA 种子转写进真实 intake 模板的补表清单;不能替代客户选择、人工补号量、真实候选视频或 proof-gap:closure。'
  412. };
  413. }
  414. function classifyExperienceTranscriptIndex(root, dir, summary) {
  415. return {
  416. type: 'experience-transcript-index',
  417. dir: rel(root, dir),
  418. updatedAt: mtime(dir),
  419. proofLevel: 'not_business_proof',
  420. status: Number(summary.transcriptCount || 0) > 0 ? 'experience_seed_available' : 'experience_seed_missing',
  421. complete: false,
  422. directCustomerProof: false,
  423. canCloseProofGap: false,
  424. summary: `transcripts=${summary.transcriptCount || 0}, rules=${summary.coveredRuleCount || 0}/${summary.ruleCount || 0}, canCloseProofGap=false`,
  425. next: '可用于追踪提号经验来源和校准规则实现;不能替代真实历史 Brief、客户选择、人工补号量或 customer-effect:audit。'
  426. };
  427. }
  428. function classifyFeedbackLoopClosure(root, dir, closure) {
  429. return {
  430. type: 'feedback-loop-closure',
  431. dir: rel(root, dir),
  432. updatedAt: mtime(dir),
  433. proofLevel: closure.directCustomerProof ? 'real_evidence' : closure.passed ? 'smoke_or_local' : 'not_business_proof',
  434. status: closure.directCustomerProof ? 'direct_customer_proof' : closure.passed ? 'local_loop_passed' : 'missing_or_open',
  435. summary: `negative=${closure.negativeFeedbackCount || 0}, blocked=${closure.blockedCreatorCount || 0}, leaked=${closure.leakedCount || 0}, directCustomerProof=${Boolean(closure.directCustomerProof)}`,
  436. next: closure.directCustomerProof
  437. ? '反馈二轮闭环已有真实客户反馈和真实二轮结果;仍需 customer-effect:audit 证明客户效果。'
  438. : '用于证明反馈剔除/降权闭环是否执行;缺真实客户反馈或真实二轮结果时不能证明客户效果。'
  439. };
  440. }
  441. function classifyHomepageEvidenceReadiness(root, dir, readiness) {
  442. return {
  443. type: 'homepage-evidence-readiness',
  444. dir: rel(root, dir),
  445. updatedAt: mtime(dir),
  446. proofLevel: readiness.ready ? 'smoke_or_local' : 'not_business_proof',
  447. status: readiness.ready ? 'ready' : 'not_ready',
  448. summary: `candidates=${readiness.counts?.candidates || 0}, providerEvidence=${readiness.counts?.providerEvidenceCreators || 0}, posts=${readiness.counts?.creatorsWithPosts || 0}, failureCount=${readiness.failureCount || 0}`,
  449. next: readiness.ready
  450. ? '可作为主页近期内容证据进入强推荐复核;仍需真实人工复核和客户选择证明命中率。'
  451. : '先补齐最近 10/20 篇内容、封面、标题、互动、发布时间和风险信号,不能把 fallback 当成强推荐证据。'
  452. };
  453. }
  454. function classifyIntakeReadiness(root, dir, intakeReadiness) {
  455. const ready = intakeReadiness.acceptance?.overallReady === true &&
  456. Number(intakeReadiness.failureCount || 0) === 0;
  457. return {
  458. type: 'intake-readiness',
  459. dir: rel(root, dir),
  460. updatedAt: mtime(dir),
  461. proofLevel: ready ? 'smoke_or_local' : 'not_business_proof',
  462. status: ready ? 'ready' : 'not_ready',
  463. summary: `history=${intakeReadiness.acceptance?.historyReady ? 'ready' : 'not_ready'}, video=${intakeReadiness.acceptance?.videoReady ? 'ready' : 'not_ready'}, review=${intakeReadiness.acceptance?.reviewMetricsReady ? 'ready' : 'not_ready'}, customerEffect=${intakeReadiness.acceptance?.customerEffectReady ? 'ready' : 'not_ready'}, failureCount=${intakeReadiness.failureCount || 0}`,
  464. next: ready
  465. ? '可进入 history:audit、video:resource-readiness、review:metrics 和 customer-effect:audit;仍不等同于业务效果证明。'
  466. : '先把模板占位替换为真实历史 Brief、真实视频资源、客户选择和本轮人工补号量。'
  467. };
  468. }
  469. function classifyIntakeFieldChecklist(root, dir, checklist) {
  470. return {
  471. type: 'intake-field-checklist',
  472. dir: rel(root, dir),
  473. updatedAt: mtime(dir),
  474. proofLevel: 'smoke_or_local',
  475. status: Number(checklist.itemCount || 0) > 0 ? 'actions_open' : 'empty',
  476. summary: `items=${checklist.itemCount || 0}, missingRequired=${checklist.missingRequiredCount || 0}, placeholders=${checklist.placeholderCount || 0}`,
  477. next: '用于指导商务/投放逐字段补齐真实历史 Brief、人工复核、客户选择和视频资源;不证明客户效果。'
  478. };
  479. }
  480. function classifyPreflight(root, dir, preflight) {
  481. const failureCount = Array.isArray(preflight.checks) ? preflight.checks.filter(item => item.status === 'fail').length : 0;
  482. const warnCount = Array.isArray(preflight.checks) ? preflight.checks.filter(item => item.status === 'warn').length : 0;
  483. return {
  484. type: 'live-preflight',
  485. dir: rel(root, dir),
  486. updatedAt: mtime(dir),
  487. proofLevel: preflight.readyForLiveAcceptance ? 'smoke_or_local' : 'not_business_proof',
  488. status: preflight.readyForLiveAcceptance ? 'ready' : 'not_ready',
  489. readyForVideoAb: Boolean(preflight.readyForVideoAb),
  490. summary: `live=${preflight.readyForLiveAcceptance ? 'ready' : 'not_ready'}, videoAb=${preflight.readyForVideoAb ? 'ready' : 'not_ready'}, fail=${failureCount}, warn=${warnCount}`,
  491. next: preflight.readyForLiveAcceptance
  492. ? preflight.readyForVideoAb
  493. ? '可作为 live/video A/B 启动前置证据;仍不等同于业务效果证明。'
  494. : '可启动 live 子集;视频 A/B 还需补齐视频分析配置。'
  495. : '缺 sessionToken、company 或 provider 关键配置时,不能启动 live 长跑。'
  496. };
  497. }
  498. function classifyReview(root, dir, review) {
  499. return {
  500. type: 'review-metrics',
  501. dir: rel(root, dir),
  502. updatedAt: mtime(dir),
  503. proofLevel: review.acceptance?.customerSelectedRatePass === true ? 'real_evidence' : 'not_business_proof',
  504. status: review.acceptance?.overallPass ? 'pass' : 'not_passed',
  505. summary: `rows=${review.total || 0}, usable=${pct(review.businessUsableRate)}, negative=${pct(review.offTargetHardFailRate)}, selected=${review.selectedCount ? pct(review.customerSelectedRate) : 'unmeasured'}`,
  506. next: review.selectedCount ? '可用于客户选择效果复盘。' : '缺客户选择字段时不能证明客户选中率。'
  507. };
  508. }
  509. function classifyCustomerEffect(root, dir, customerEffect, context = {}) {
  510. const effectProof = customerEffect.acceptance?.overallPass === true &&
  511. customerEffect.acceptance?.customerSelectedRate30Pass === true &&
  512. customerEffect.acceptance?.referenceCustomerSelectedRate40Pass !== false &&
  513. customerEffect.acceptance?.historyReadyForCustomerEffectProof === true &&
  514. customerEffect.acceptance?.manualSupplementBaselineAvailable === true &&
  515. customerEffect.acceptance?.currentManualSupplementAvailable === true &&
  516. customerEffect.acceptance?.manualSupplementReduction50Pass === true;
  517. const pipelineBlocker = getPipelineScopedCustomerEffectBlocker(context.pipeline);
  518. const acceptedProof = effectProof && !pipelineBlocker;
  519. return {
  520. type: 'customer-effect',
  521. dir: rel(root, dir),
  522. updatedAt: mtime(dir),
  523. proofLevel: acceptedProof ? 'real_evidence' : 'not_business_proof',
  524. status: customerEffect.acceptance?.overallPass ? 'pass' : 'not_passed',
  525. pipelineScoped: Boolean(context.pipeline),
  526. pipelineClaimable: context.pipeline ? !pipelineBlocker : null,
  527. summary: `selected=${customerEffect.acceptance?.customerSelectedRateMeasured ? pct(customerEffect.customerSelectedRate) : 'unmeasured'}, reference=${customerEffect.acceptance?.referenceCustomerSelectedRateMeasured ? pct(customerEffect.referenceCustomerSelectedRate) : 'unmeasured'}, supplementReduction=${customerEffect.manualSupplementReductionRate === null || customerEffect.manualSupplementReductionRate === undefined ? 'unmeasured' : pct(customerEffect.manualSupplementReductionRate)}${pipelineBlocker ? `, pipelineBlocked=${pipelineBlocker.reasons.join('+')}` : ''}`,
  528. next: acceptedProof
  529. ? '可作为客户效果证明:客户选中率、参考链路和人工补号减少门槛已通过。'
  530. : pipelineBlocker
  531. ? '该 customer-effect 位于未完成或不可声明的 optimization-pipeline 中;必须先刷新当前 proof-gap closure 和 completion,不能用旧 pipeline 内嵌产物证明客户效果完成。'
  532. : '缺客户选择、历史补号基线、本轮补号量或减少率未达标时,不能证明客户效果完成。'
  533. };
  534. }
  535. function getPipelineScopedCustomerEffectBlocker(pipeline) {
  536. if (!pipeline) return null;
  537. const openCount = Number(pipeline.proofGapClosure?.openCount || 0);
  538. const closureComplete = pipeline.proofGapClosure?.complete === true && openCount === 0;
  539. if (pipeline.readyForClaim === true && closureComplete) return null;
  540. const reasons = [];
  541. if (pipeline.readyForClaim !== true) reasons.push('readyForClaim=false');
  542. if (pipeline.proofGapClosure?.complete !== true) reasons.push('proofGapClosure.complete=false');
  543. if (openCount !== 0) reasons.push(`proofGapClosure.openCount=${openCount}`);
  544. return { reasons };
  545. }
  546. function classifyHistory(root, dir, history) {
  547. const missingCustomerEffectProof = getHistoryCustomerEffectProofMissingRequirements(history);
  548. const readyForCustomerEffectProof = missingCustomerEffectProof.length === 0;
  549. const readyForLongRun = readyForCustomerEffectProof || isHistoryReadyForLongRun(history);
  550. return {
  551. type: 'historical-dataset',
  552. dir: rel(root, dir),
  553. updatedAt: mtime(dir),
  554. proofLevel: readyForCustomerEffectProof ? 'real_evidence' : readyForLongRun ? 'smoke_or_local' : 'not_business_proof',
  555. status: readyForLongRun ? 'ready_for_long_run' : 'not_ready',
  556. summary: `briefs=${history.briefCount || 0}, categories=${history.categoryCount || 0}, customerDecision=${history.withCustomerDecision || 0}, missing=${missingCustomerEffectProof.length}`,
  557. missingProofRequirements: missingCustomerEffectProof,
  558. next: readyForCustomerEffectProof ? '可用于客户效果证明前置数据。' : '缺客户选择、拒绝原因、人工补号量基线或可审计明细时不能证明客户效果。'
  559. };
  560. }
  561. function isHistoryReadyForLongRun(history) {
  562. if (!history) return false;
  563. const acceptance = history.acceptance || {};
  564. return Boolean(
  565. acceptance.readyForLongRun === true &&
  566. acceptance.parseOk === true &&
  567. acceptance.minBriefsMet === true &&
  568. acceptance.allHaveBriefText === true &&
  569. acceptance.allHaveManualFinalList === true &&
  570. acceptance.allHaveCustomerDecision === true &&
  571. acceptance.allHaveFeedbackReason === true &&
  572. acceptance.categoryCoverageMet === true &&
  573. Number(history.briefCount || 0) >= 5 &&
  574. Array.isArray(history.items) &&
  575. history.items.length >= 5 &&
  576. Number(history.missingCriticalCount || 0) === 0
  577. );
  578. }
  579. function getHistoryCustomerEffectProofMissingRequirements(history) {
  580. if (!history) return ['missing historical-dataset-audit.json'];
  581. const missing = [];
  582. const acceptance = history.acceptance || {};
  583. if (acceptance.readyForCustomerEffectProof !== true) missing.push('acceptance.readyForCustomerEffectProof=true');
  584. if (Number(history.briefCount || 0) < 5) missing.push('briefCount>=5');
  585. if (acceptance.parseOk !== true) missing.push('acceptance.parseOk=true');
  586. if (acceptance.minBriefsMet !== true) missing.push('acceptance.minBriefsMet=true');
  587. if (acceptance.allHaveBriefText !== true) missing.push('acceptance.allHaveBriefText=true');
  588. if (acceptance.allHaveManualFinalList !== true) missing.push('acceptance.allHaveManualFinalList=true');
  589. if (acceptance.allHaveCustomerDecision !== true) missing.push('acceptance.allHaveCustomerDecision=true');
  590. if (acceptance.allHaveFeedbackReason !== true) missing.push('acceptance.allHaveFeedbackReason=true');
  591. if (acceptance.allHaveManualSupplementBaseline !== true) missing.push('acceptance.allHaveManualSupplementBaseline=true');
  592. if (acceptance.categoryCoverageMet !== true) missing.push('acceptance.categoryCoverageMet=true');
  593. if (Number(history.missingCriticalCount || 0) !== 0) missing.push('missingCriticalCount=0');
  594. if (!Array.isArray(history.items) || history.items.length < 5) missing.push('items.length>=5');
  595. if (Number(history.withManualFinalList || 0) < 5) missing.push('withManualFinalList>=5');
  596. if (Number(history.withCustomerDecision || 0) < 5) missing.push('withCustomerDecision>=5');
  597. if (Number(history.withRejectionReason || 0) < 1) missing.push('withRejectionReason>=1');
  598. if (Number(history.withManualSupplementBaseline || 0) < 5) missing.push('withManualSupplementBaseline>=5');
  599. return uniqueStrings(missing);
  600. }
  601. function classifyVideo(root, dir, video) {
  602. const videoProof = video.acceptance?.passed === true &&
  603. video.acceptance?.realReferenceResourceLoaded === true &&
  604. video.acceptance?.realVideoResourceLoaded === true &&
  605. video.acceptance?.coverOrFrameOrAsrLoaded === true &&
  606. video.acceptance?.evidenceCardsNotPending === true &&
  607. video.acceptance?.evidenceCardsHaveSignals === true &&
  608. video.acceptance?.strongNotDegraded === true &&
  609. video.acceptance?.top10EvidenceImproved === true;
  610. return {
  611. type: 'video-ab',
  612. dir: rel(root, dir),
  613. updatedAt: mtime(dir),
  614. proofLevel: videoProof ? 'real_evidence' : 'not_business_proof',
  615. status: video.acceptance?.passed ? 'pass' : 'not_passed',
  616. summary: `strongDelta=${video.delta?.strong ?? 'n/a'}, evidenceDelta=${video.delta?.top10EvidenceHitCandidates ?? 'n/a'}`,
  617. next: videoProof ? '可作为视频证据提升的 A/B 证明。' : '不能宣称视频分析提升命中率。'
  618. };
  619. }
  620. function classifyVideoRuntimePreflight(root, dir, preflight) {
  621. return {
  622. type: 'video-ab-runtime-preflight',
  623. dir: rel(root, dir),
  624. updatedAt: mtime(dir),
  625. proofLevel: 'not_business_proof',
  626. status: preflight.readyForVideoAb ? 'ready_to_run' : 'not_ready',
  627. summary: `readyForVideoAb=${Boolean(preflight.readyForVideoAb)}, failureCount=${preflight.failureCount || 0}, generatedBy=${preflight.proofContext?.generatedBy || 'unknown'}`,
  628. next: preflight.readyForVideoAb
  629. ? '只表示运行环境可启动 acceptance:video-ab;必须生成 video-hit-rate-summary.json 才能进入 proof-gap closure。'
  630. : '补齐运行时凭证、company、VOC social provider 和视频分析 provider 后再运行 acceptance:video-ab,并生成 live video-hit-rate-summary.json;preflight 失败不证明视频提升。'
  631. };
  632. }
  633. function renderReport(summary) {
  634. const latest = pickLatestUseful(summary.entries);
  635. const lines = [
  636. '# 提号优化证据台账',
  637. '',
  638. `- 生成时间:${summary.generatedAt}`,
  639. `- 扫描目录:${summary.outputsDir}`,
  640. `- 证据条目:${summary.total}`,
  641. '',
  642. '## 汇总',
  643. '',
  644. ...Object.entries(summary.counts).map(([key, value]) => `- ${key}: ${value}`),
  645. '',
  646. '## 最新可用证据',
  647. '',
  648. '| 类型 | 证明等级 | 状态 | 目录 | 摘要 | 下一步 |',
  649. '| --- | --- | --- | --- | --- | --- |',
  650. ...latest.map(item => row(item)),
  651. '',
  652. '## 全部条目',
  653. '',
  654. '| 类型 | 证明等级 | 状态 | 更新时间 | 目录 | 摘要 | 下一步 |',
  655. '| --- | --- | --- | --- | --- | --- | --- |',
  656. ...summary.entries.map(item => row(item, true)),
  657. '',
  658. '## 说明',
  659. '',
  660. '- `real_evidence` 表示该产物包含真实 live、客户选择、历史数据或视频 A/B 证据,且关键门槛通过。',
  661. '- `smoke_or_local` 表示可证明结构、门槛或启动前置条件,但不能证明真实客户效果。',
  662. '- `not_business_proof` 表示该产物明确显示仍缺真实数据或状态未完成。'
  663. ];
  664. return lines.join('\n');
  665. }
  666. function classifyVideoResourceReadiness(root, dir, readiness) {
  667. return {
  668. type: 'video-resource-readiness',
  669. dir: rel(root, dir),
  670. updatedAt: mtime(dir),
  671. proofLevel: readiness.acceptance?.readyForVideoAbPreflight ? 'smoke_or_local' : 'not_business_proof',
  672. status: readiness.acceptance?.readyForVideoAbPreflight ? 'ready' : 'not_ready',
  673. summary: `realReference=${readiness.counts?.realReferenceRows || 0}, realCandidate=${readiness.counts?.realCandidateRows || 0}, videoUrl=${readiness.counts?.videoUrlRows || 0}, failureCount=${readiness.failureCount || 0}`,
  674. next: readiness.acceptance?.readyForVideoAbPreflight
  675. ? '可作为视频 A/B 启动前资源证明;仍需运行 acceptance:video-ab 证明 provider、证据卡和 Top10 非退化。'
  676. : '先补齐真实参考视频、候选视频、视频 URL、封面/ASR/帧图或正文证据。'
  677. };
  678. }
  679. function classifyRepairActions(root, dir, type, fileName) {
  680. const csvPath = path.join(dir, fileName);
  681. const actionCount = countCsvRows(csvPath);
  682. const isVideo = type === 'video-resource-repair-actions';
  683. const isHomepage = type === 'homepage-evidence-repair-actions';
  684. return {
  685. type,
  686. dir: rel(root, dir),
  687. updatedAt: fs.statSync(csvPath).mtime.toISOString(),
  688. proofLevel: 'smoke_or_local',
  689. status: actionCount > 0 ? 'actions_open' : 'empty',
  690. summary: `csv=${fileName}, actions=${actionCount}`,
  691. next: isHomepage
  692. ? '用于指导商务/投放补齐主页近期内容、封面、发布时间、互动和风险信号;不证明客户效果。'
  693. : isVideo
  694. ? '用于指导商务/投放补齐真实视频 URL、封面、ASR、帧图或正文证据;不证明视频 A/B 或命中率提升。'
  695. : '用于指导商务补齐真实历史 Brief、客户选择、人工补号基线和复核字段;不证明客户效果。'
  696. };
  697. }
  698. function pickLatestUseful(entries) {
  699. const byType = new Map();
  700. for (const entry of entries) {
  701. if (!byType.has(entry.type)) byType.set(entry.type, entry);
  702. }
  703. return [...byType.values()];
  704. }
  705. function row(item, includeTime = false) {
  706. const cells = includeTime
  707. ? [item.type, item.proofLevel, item.status, item.updatedAt, item.dir, item.summary, item.next]
  708. : [item.type, item.proofLevel, item.status, item.dir, item.summary, item.next];
  709. return `| ${cells.map(escapeCell).join(' | ')} |`;
  710. }
  711. function readJson(file) {
  712. if (!fs.existsSync(file)) return null;
  713. try {
  714. return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  715. } catch {
  716. return null;
  717. }
  718. }
  719. function isDataIntakePack(dir) {
  720. return fs.existsSync(path.join(dir, 'history-data-template.csv')) &&
  721. fs.existsSync(path.join(dir, 'manual-review-template.csv')) &&
  722. fs.existsSync(path.join(dir, 'README.md'));
  723. }
  724. function isVideoIntakePack(dir) {
  725. return fs.existsSync(path.join(dir, 'video-resource-template.csv')) &&
  726. fs.existsSync(path.join(dir, 'README.md'));
  727. }
  728. function countBy(entries, key) {
  729. return entries.reduce((acc, item) => {
  730. acc[item[key]] = (acc[item[key]] || 0) + 1;
  731. return acc;
  732. }, {});
  733. }
  734. function mtime(dir) {
  735. return fs.statSync(dir).mtime.toISOString();
  736. }
  737. function rel(root, file) {
  738. return path.relative(root, file).replace(/\\/g, '/');
  739. }
  740. function pct(value) {
  741. return `${Math.round(Number(value || 0) * 100)}%`;
  742. }
  743. function countCsvRows(file) {
  744. const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '').trim();
  745. if (!text) return 0;
  746. return Math.max(0, text.split(/\r?\n/).length - 1);
  747. }
  748. function uniqueStrings(values) {
  749. return [...new Set((values || []).filter(Boolean).map(value => String(value)))];
  750. }
  751. function parseArgs(argv) {
  752. const args = {};
  753. for (let index = 0; index < argv.length; index += 1) {
  754. const raw = argv[index];
  755. if (!raw.startsWith('--')) continue;
  756. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  757. const next = argv[index + 1];
  758. if (!next || next.startsWith('--')) args[key] = true;
  759. else {
  760. args[key] = next;
  761. index += 1;
  762. }
  763. }
  764. return args;
  765. }
  766. function escapeCell(value) {
  767. return String(value ?? '').replace(/\|/g, '/').replace(/\r?\n/g, ' ');
  768. }
  769. function withBom(text) {
  770. return `\uFEFF${text}`;
  771. }
  772. if (require.main === module) main();
  773. module.exports = {
  774. buildEvidenceIndex,
  775. classifyDir
  776. };