real-gap-material-audit.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { buildLocalSeedMaterialIndex, sanitizePublicUrl, sanitizeCell } = require('./local-seed-material-index');
  5. const { buildLocalSeedToIntakeWorklist } = require('./local-seed-to-intake-worklist');
  6. const { buildExperienceTranscriptIndex } = require('./experience-transcript-index');
  7. const ROOT = path.resolve(__dirname, '..');
  8. const DEFAULT_OUTPUT = path.join(ROOT, 'outputs', 'real-gap-material-audit-latest');
  9. function main() {
  10. const args = parseArgs(process.argv.slice(2));
  11. const workspaceRoot = path.resolve(args.workspaceRoot || path.join(ROOT, '..'));
  12. const outputsDir = path.resolve(args.outputs || path.join(ROOT, 'outputs'));
  13. const transcriptsDir = path.resolve(args.transcriptsDir || path.join(workspaceRoot, 'docs', '提号经验'));
  14. const outputDir = path.resolve(args.output || DEFAULT_OUTPUT);
  15. const summary = buildRealGapMaterialAudit({ root: ROOT, workspaceRoot, outputsDir, transcriptsDir });
  16. fs.mkdirSync(outputDir, { recursive: true });
  17. const summaryPath = path.join(outputDir, 'real-gap-material-audit-summary.json');
  18. const reportPath = path.join(outputDir, 'real-gap-material-audit.md');
  19. const csvPath = path.join(outputDir, 'real-gap-material-audit.csv');
  20. fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
  21. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  22. fs.writeFileSync(csvPath, withBom(renderCsv(summary)), 'utf8');
  23. console.log(JSON.stringify({
  24. outputDir,
  25. summary: summaryPath,
  26. report: reportPath,
  27. csv: csvPath,
  28. gapCount: summary.gapCount,
  29. materialFoundCount: summary.materialFoundCount,
  30. openGapCount: summary.openGapCount,
  31. canCloseProofGap: summary.canCloseProofGap
  32. }, null, 2));
  33. if (args.strict && summary.canCloseProofGap !== false) process.exitCode = 1;
  34. }
  35. function buildRealGapMaterialAudit({ root = ROOT, workspaceRoot = path.join(root, '..'), outputsDir = path.join(root, 'outputs'), transcriptsDir = path.join(workspaceRoot, 'docs', '提号经验') } = {}) {
  36. const seedIndex = buildLocalSeedMaterialIndex({ root, workspaceRoot, outputsDir });
  37. const worklist = buildLocalSeedToIntakeWorklist({ root, seedIndex });
  38. const transcriptIndex = buildExperienceTranscriptIndex({ root, transcriptsDir });
  39. const proofGapClosure = readJson(path.join(outputsDir, 'proof-gap-closure-latest', 'proof-gap-closure-summary.json')) ||
  40. readJson(latestFile(outputsDir, 'proof-gap-closure-summary.json'));
  41. const intakeReadiness = readJson(path.join(outputsDir, 'intake-readiness-latest', 'intake-readiness-summary.json')) ||
  42. readJson(latestFile(outputsDir, 'intake-readiness-summary.json'));
  43. const videoReadiness = readJson(path.join(outputsDir, 'video-resource-readiness-latest', 'video-resource-readiness-summary.json')) ||
  44. readJson(latestFile(outputsDir, 'video-resource-readiness-summary.json'));
  45. const videoAbPreflight = readJson(path.join(outputsDir, 'video-hit-rate-preflight-latest', 'video-hit-rate-preflight-summary.json')) ||
  46. readJson(latestFile(outputsDir, 'video-hit-rate-preflight-summary.json'));
  47. const candidateVideoNotes = extractCandidateVideoNotes(path.join(workspaceRoot, 'output', 'dha-tihao-poc', 'candidate_details_top18.json'));
  48. const transcriptEvidence = {
  49. historical: findTranscriptHits(transcriptsDir, [
  50. /DHA.*(提报|反馈|选新的号)/,
  51. /没有给我反馈/,
  52. /没有选新的号/,
  53. /五六十个/,
  54. /六七个主页/
  55. ]),
  56. video: findTranscriptHits(transcriptsDir, [
  57. /视频.*(内容|点进去|每一帧|解析)/,
  58. /封面/,
  59. /首图/,
  60. /前十.*笔记/,
  61. /20篇/
  62. ]),
  63. manualCustomer: findTranscriptHits(transcriptsDir, [
  64. /选中率/,
  65. /10%.*30%/,
  66. /50%以上/,
  67. /客户.*满意/,
  68. /六七个主页/,
  69. /人工.*看/
  70. ])
  71. };
  72. const rowsById = Object.fromEntries((proofGapClosure?.rows || []).map(row => [row.id, row]));
  73. const gaps = [
  74. buildHistoricalGap({
  75. root,
  76. workspaceRoot,
  77. worklist,
  78. seedIndex,
  79. transcriptIndex,
  80. transcriptEvidence: transcriptEvidence.historical,
  81. intakeReadiness,
  82. proofRow: rowsById['historical-dataset']
  83. }),
  84. buildVideoAbGap({
  85. root,
  86. worklist,
  87. videoReadiness,
  88. videoAbPreflight,
  89. candidateVideoNotes,
  90. transcriptEvidence: transcriptEvidence.video,
  91. proofRow: rowsById['video-ab-live-proof']
  92. }),
  93. buildManualCustomerGap({
  94. root,
  95. worklist,
  96. transcriptIndex,
  97. transcriptEvidence: transcriptEvidence.manualCustomer,
  98. proofRow: rowsById['manual-review-and-customer-effect']
  99. })
  100. ];
  101. const materialFoundCount = gaps.filter(item => item.projectMaterialFound).length;
  102. const openGapCount = gaps.filter(item => item.currentProofStatus !== 'closed').length;
  103. return {
  104. generatedAt: new Date().toISOString(),
  105. root,
  106. workspaceRoot,
  107. outputsDir,
  108. proofLevel: 'not_business_proof',
  109. materialType: 'three_real_gap_material_audit',
  110. directCustomerProof: false,
  111. canCloseProofGap: false,
  112. complete: false,
  113. gapCount: gaps.length,
  114. materialFoundCount,
  115. openGapCount,
  116. sourceState: {
  117. proofGapClosureComplete: Boolean(proofGapClosure?.complete),
  118. proofGapOpenCount: Number(proofGapClosure?.openCount ?? gaps.length),
  119. intakeOverallReady: Boolean(intakeReadiness?.acceptance?.overallReady),
  120. videoResourceReady: Boolean(videoReadiness?.acceptance?.readyForVideoAbPreflight),
  121. videoAbPreflightReady: Boolean(videoAbPreflight?.readyForVideoAb),
  122. experienceRuleCoverage: `${transcriptIndex.coveredRuleCount}/${transcriptIndex.ruleCount}`,
  123. localSeedHistoryDraftRows: Number(worklist.counts?.historyDraftRows || 0),
  124. localSeedReviewDraftRows: Number(worklist.counts?.reviewDraftRows || 0),
  125. candidateVideoNoteCount: candidateVideoNotes.length
  126. },
  127. gaps,
  128. guardrails: [
  129. '本审计证明“项目资料已找到并接入到补证工作流”,不证明业务效果已经达标。',
  130. 'DHA PoC、候选视频资源和逐字稿经验可以关闭“找不到资料”的问题,但不能自动关闭客户效果、历史数据或 live A/B 证明缺口。',
  131. '视频资源 readiness=true 只说明 A/B 前置资源够用;video-ab-live-proof 仍必须由 acceptance:video-ab 的 live proofContext 关闭。',
  132. '逐字稿里的“客户没反馈/没选新号”是重要业务事实,但不是逐条客户选中率、拒绝原因或人工补号量审计。',
  133. '任一 open gap 存在时,不得宣称提号率提升、客户效果达标或人工补号量下降。'
  134. ],
  135. nextActions: [
  136. '把 real-gap-material-audit-latest 作为三缺口事实底稿,避免后续再说“项目里没有资料”。',
  137. '把 DHA worklist 中可转写字段晋升到正式 intake 前,必须补齐客户选择、拒绝原因和人工补号量,不得用占位值代替。',
  138. 'provider token/company/video analysis token 就绪后,先跑 acceptance:video-ab,再重跑 proof-gap:closure 和 optimization:completion。',
  139. '客户效果字段补齐后,重跑 review:metrics、history:audit、customer-effect:audit,再刷新 round:refresh。'
  140. ]
  141. };
  142. }
  143. function buildHistoricalGap({ root, workspaceRoot, worklist, seedIndex, transcriptIndex, transcriptEvidence, intakeReadiness, proofRow }) {
  144. const dhaMaterials = (seedIndex.materials || []).filter(item => item.exists && /dha/i.test(item.id));
  145. const historyDraftRows = Number(worklist.counts?.historyDraftRows || 0);
  146. const customerNoFeedback = transcriptEvidence.some(item => /没有给我反馈|没有选新的号/.test(item.snippet));
  147. const missing = uniqueStrings([
  148. ...(proofRow?.missingProofRequirements || []),
  149. ...(intakeReadiness?.history?.issues || []).map(item => item.message)
  150. ]);
  151. return {
  152. id: 'historical-dataset',
  153. title: '真实历史 Brief 数据集',
  154. currentProofStatus: proofRow?.status || 'open',
  155. projectMaterialFound: dhaMaterials.length > 0 || historyDraftRows > 0 || transcriptEvidence.length > 0,
  156. canCloseNow: false,
  157. materialSignals: {
  158. dhaMaterialCount: dhaMaterials.length,
  159. historyDraftRows,
  160. transcriptEvidenceCount: transcriptEvidence.length,
  161. customerNoFeedbackObserved: customerNoFeedback,
  162. currentBriefCount: extractNumber(proofRow?.evidence, /briefCount=(\d+)/),
  163. minBriefsRequired: 5
  164. },
  165. connectedArtifacts: [
  166. 'outputs/local-seed-to-intake-worklist-latest/history-intake-draft.csv',
  167. 'outputs/local-seed-history-audit-latest/historical-dataset-audit.json',
  168. 'outputs/real-gap-material-audit-latest/real-gap-material-audit-summary.json'
  169. ],
  170. projectMaterials: [
  171. ...dhaMaterials.map(item => ({
  172. type: 'local_seed_workbook',
  173. path: item.path,
  174. contribution: '提供 DHA 小红书真实 Brief、PoC 候选名单、参考账号和数据字段。',
  175. closureBoundary: '只有一个 DHA/PoC 种子,且缺客户选择、拒绝原因和历史人工补号量。'
  176. })),
  177. ...transcriptEvidence.slice(0, 6).map(item => ({
  178. type: 'experience_transcript',
  179. path: `${item.path}:${item.lineNumber}`,
  180. contribution: item.snippet,
  181. closureBoundary: '只能证明业务过程和经验事实,不能替代结构化历史审计字段。'
  182. }))
  183. ],
  184. blockingRequirements: missing.length ? missing : ['briefCount>=5', 'withCustomerDecision>=5', 'withManualSupplementBaseline>=5'],
  185. recommendedPromotion: [
  186. 'DHA worklist 可作为 1 条历史 Brief 种子保留。',
  187. '还需补至少 4 个真实历史 Brief,且每条必须有人工最终名单、客户选中/拒绝、拒绝原因或复核标签、历史人工补号量基线。',
  188. '逐字稿里“客户没反馈/没选新号”应作为 no-feedback 事实记录,不应伪装成客户选中。'
  189. ]
  190. };
  191. }
  192. function buildVideoAbGap({ root, worklist, videoReadiness, videoAbPreflight, candidateVideoNotes, transcriptEvidence, proofRow }) {
  193. const realCandidateRows = Number(videoReadiness?.counts?.realCandidateRows || 0);
  194. const realReferenceRows = Number(videoReadiness?.counts?.realReferenceRows || 0);
  195. const runtimeChecks = (videoAbPreflight?.checks || []).filter(item => item.status === 'fail');
  196. const missing = uniqueStrings([
  197. ...(proofRow?.missingProofRequirements || []),
  198. ...runtimeChecks.map(item => item.missingProofRequirement || item.action)
  199. ]);
  200. const topVideoNotes = candidateVideoNotes.slice(0, 6).map(item => ({
  201. type: 'candidate_video_note',
  202. path: item.sourcePath,
  203. contribution: `${item.creatorName} / ${item.title || item.noteId} / ${item.videoUrl}`,
  204. closureBoundary: '可作为候选视频资源线索;仍需 live A/B 跑出 proofContext。'
  205. }));
  206. return {
  207. id: 'video-ab-live-proof',
  208. title: '真实视频 A/B live proof',
  209. currentProofStatus: proofRow?.status || 'open',
  210. projectMaterialFound: realCandidateRows > 0 || candidateVideoNotes.length > 0 || Number(worklist.counts?.videoWorklistRows || 0) > 0,
  211. canCloseNow: false,
  212. materialSignals: {
  213. videoResourceReady: Boolean(videoReadiness?.acceptance?.readyForVideoAbPreflight),
  214. realCandidateRows,
  215. realReferenceRows,
  216. candidateVideoNoteCount: candidateVideoNotes.length,
  217. transcriptEvidenceCount: transcriptEvidence.length,
  218. preflightReadyForVideoAb: Boolean(videoAbPreflight?.readyForVideoAb),
  219. preflightFailureCount: Number(videoAbPreflight?.failureCount ?? runtimeChecks.length)
  220. },
  221. connectedArtifacts: [
  222. 'outputs/video-intake-pack-latest/video-resource-template.csv',
  223. 'outputs/video-resource-readiness-latest/video-resource-readiness-summary.json',
  224. 'outputs/video-hit-rate-preflight-latest/video-hit-rate-preflight-summary.json',
  225. 'outputs/real-gap-material-audit-latest/real-gap-material-audit-summary.json'
  226. ],
  227. projectMaterials: [
  228. {
  229. type: 'video_resource_readiness',
  230. path: rel(root, videoReadiness?.inputPath || path.join(root, 'outputs', 'video-intake-pack-latest', 'video-resource-template.csv')),
  231. contribution: `readyForVideoAbPreflight=${Boolean(videoReadiness?.acceptance?.readyForVideoAbPreflight)}, realCandidateRows=${realCandidateRows}, realReferenceRows=${realReferenceRows}`,
  232. closureBoundary: '资源 readiness 不等于 acceptance:video-ab live proof。'
  233. },
  234. ...topVideoNotes,
  235. ...transcriptEvidence.slice(0, 4).map(item => ({
  236. type: 'experience_transcript',
  237. path: `${item.path}:${item.lineNumber}`,
  238. contribution: item.snippet,
  239. closureBoundary: '证明视频/封面深看规则有经验来源,不替代 live provider 验收。'
  240. }))
  241. ],
  242. blockingRequirements: missing.length ? missing : ['proofContext.mode=live', 'proofContext.generatedBy=acceptance:video-ab'],
  243. recommendedPromotion: [
  244. '视频资源已到位,下一步不是继续找候选视频,而是补 runtime/company/VOC social/video analysis provider。',
  245. 'provider 就绪后运行 npm run acceptance:video-ab,并要求 video-hit-rate-summary.json 带 live proofContext。',
  246. '只有 acceptance:video-ab 输出通过,才能关闭 video-ab-live-proof。'
  247. ]
  248. };
  249. }
  250. function buildManualCustomerGap({ root, worklist, transcriptIndex, transcriptEvidence, proofRow }) {
  251. const reviewDraftRows = Number(worklist.counts?.reviewDraftRows || 0);
  252. const customerEffectRule = (transcriptIndex.ruleCoverage || []).find(item => item.id === 'customer-effect-goal');
  253. const missing = uniqueStrings(proofRow?.missingProofRequirements || []);
  254. return {
  255. id: 'manual-review-and-customer-effect',
  256. title: '商务复核和客户效果证明',
  257. currentProofStatus: proofRow?.status || 'open',
  258. projectMaterialFound: reviewDraftRows > 0 || transcriptEvidence.length > 0 || Boolean(customerEffectRule?.hitCount),
  259. canCloseNow: false,
  260. materialSignals: {
  261. reviewDraftRows,
  262. transcriptEvidenceCount: transcriptEvidence.length,
  263. customerEffectRuleHitCount: Number(customerEffectRule?.hitCount || 0),
  264. currentEvidence: proofRow?.evidence || ''
  265. },
  266. connectedArtifacts: [
  267. 'outputs/local-seed-to-intake-worklist-latest/manual-review-draft.csv',
  268. 'outputs/experience-transcript-index-latest/experience-transcript-index-summary.json',
  269. 'outputs/optimization-pipeline-latest/customer-effect-audit/customer-effect-summary.json',
  270. 'outputs/real-gap-material-audit-latest/real-gap-material-audit-summary.json'
  271. ],
  272. projectMaterials: [
  273. {
  274. type: 'manual_review_draft',
  275. path: 'outputs/local-seed-to-intake-worklist-latest/manual-review-draft.csv',
  276. contribution: `DHA PoC 候选已转为 ${reviewDraftRows} 行商务复核草表。`,
  277. closureBoundary: '草表仍缺人工复核标签、客户选择、反馈原因和本轮人工补号量。'
  278. },
  279. ...(customerEffectRule ? [{
  280. type: 'experience_rule',
  281. path: 'outputs/experience-transcript-index-latest/experience-transcript-index-summary.json',
  282. contribution: customerEffectRule.practicalRule,
  283. closureBoundary: '经验规则说明验收方向,不等于客户效果统计。'
  284. }] : []),
  285. ...transcriptEvidence.slice(0, 6).map(item => ({
  286. type: 'experience_transcript',
  287. path: `${item.path}:${item.lineNumber}`,
  288. contribution: item.snippet,
  289. closureBoundary: '只能作为复核规则和效果目标来源,不能替代客户明细字段。'
  290. }))
  291. ],
  292. blockingRequirements: missing.length ? missing : [
  293. 'customerSelectedRateMeasured=true',
  294. 'referenceCustomerSelectedRateMeasured=true',
  295. 'currentManualSupplement.available=true'
  296. ],
  297. recommendedPromotion: [
  298. '把候选草表交给商务/投放复核,补“可直接发客户/负样本/待补证”等真实标签。',
  299. '逐条补客户选中/拒绝、归因类型、反馈原因和本轮人工补号量。',
  300. '绑定通过的 history:audit 后再跑 customer-effect:audit,不能只用经验目标宣称 30%/40%/50% 达标。'
  301. ]
  302. };
  303. }
  304. function extractCandidateVideoNotes(file) {
  305. const data = readJson(file);
  306. if (!Array.isArray(data)) return [];
  307. const rows = [];
  308. for (const item of data) {
  309. const profile = item.profile || item.search || {};
  310. for (const [key, rate] of Object.entries({
  311. note_rate_video: item.note_rate_video,
  312. note_rate_photo: item.note_rate_photo
  313. })) {
  314. const notes = Array.isArray(rate?.notes) ? rate.notes : [];
  315. for (const note of notes) {
  316. if (!note?.noteId) continue;
  317. const isVideo = key === 'note_rate_video' || Number(note.type) === 2;
  318. if (!isVideo) continue;
  319. rows.push({
  320. creatorName: sanitizeCell(item.name || profile.name || ''),
  321. redId: sanitizeCell(profile.redId || item.search?.redId || ''),
  322. noteId: sanitizeCell(note.noteId),
  323. title: sanitizeCell(note.title || ''),
  324. type: Number(note.type || 0),
  325. readNum: Number(note.readNum || 0),
  326. canJump: Boolean(note.canJump),
  327. coverUrl: sanitizePublicUrl(note.imgUrl || ''),
  328. videoUrl: sanitizePublicUrl(`https://www.xiaohongshu.com/discovery/item/${note.noteId}`),
  329. sourcePath: rel(ROOT, file)
  330. });
  331. }
  332. }
  333. }
  334. return uniqueBy(rows, item => item.noteId)
  335. .sort((a, b) => Number(b.readNum || 0) - Number(a.readNum || 0));
  336. }
  337. function findTranscriptHits(transcriptsDir, patterns) {
  338. if (!fs.existsSync(transcriptsDir)) return [];
  339. const files = fs.readdirSync(transcriptsDir, { withFileTypes: true })
  340. .filter(entry => entry.isFile() && /\.md$|\.txt$/.test(entry.name))
  341. .map(entry => path.join(transcriptsDir, entry.name))
  342. .sort((a, b) => a.localeCompare(b, 'zh-CN'));
  343. const rows = [];
  344. for (const file of files) {
  345. const lines = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/);
  346. for (let index = 0; index < lines.length; index += 1) {
  347. const line = lines[index].trim();
  348. if (!line) continue;
  349. if (!patterns.some(pattern => pattern.test(line))) continue;
  350. rows.push({
  351. path: rel(ROOT, file),
  352. lineNumber: index + 1,
  353. snippet: sanitizeSnippet(line)
  354. });
  355. }
  356. }
  357. return uniqueBy(rows, item => `${item.path}:${item.lineNumber}`).slice(0, 24);
  358. }
  359. function sanitizeSnippet(value) {
  360. return sanitizeCell(String(value || '')
  361. .replace(/\*\*/g, '')
  362. .replace(/^#+\s*/, '')
  363. .replace(/\s+/g, ' ')
  364. .slice(0, 180));
  365. }
  366. function renderReport(summary) {
  367. return [
  368. '# 三个真实缺口材料审计',
  369. '',
  370. `- 生成时间:${summary.generatedAt}`,
  371. `- proofLevel:${summary.proofLevel}`,
  372. `- directCustomerProof:${summary.directCustomerProof}`,
  373. `- canCloseProofGap:${summary.canCloseProofGap}`,
  374. `- 找到项目材料的缺口:${summary.materialFoundCount}/${summary.gapCount}`,
  375. `- 仍打开的缺口:${summary.openGapCount}`,
  376. '',
  377. '## 当前源状态',
  378. '',
  379. ...Object.entries(summary.sourceState).map(([key, value]) => `- ${key}:${value}`),
  380. '',
  381. '## 缺口对照',
  382. '',
  383. '| Gap | 当前状态 | 项目资料 | 关键材料信号 | 仍缺证明 | 下一步 |',
  384. '| --- | --- | --- | --- | --- | --- |',
  385. ...summary.gaps.map(gap => tableRow([
  386. gap.id,
  387. gap.currentProofStatus,
  388. gap.projectMaterialFound,
  389. formatSignals(gap.materialSignals),
  390. gap.blockingRequirements.join(';'),
  391. gap.recommendedPromotion.join(';')
  392. ])),
  393. '',
  394. '## 材料明细',
  395. '',
  396. ...summary.gaps.flatMap(gap => [
  397. `### ${gap.title}`,
  398. '',
  399. ...gap.projectMaterials.map(item => `- ${item.type}|${item.path}|${item.contribution}|边界:${item.closureBoundary}`),
  400. ''
  401. ]),
  402. '## 边界',
  403. '',
  404. ...summary.guardrails.map(item => `- ${item}`),
  405. '',
  406. '## 下一步',
  407. '',
  408. ...summary.nextActions.map(item => `- ${item}`),
  409. ''
  410. ].join('\n');
  411. }
  412. function renderCsv(summary) {
  413. const rows = [
  414. ['gap', 'currentProofStatus', 'projectMaterialFound', 'canCloseNow', 'materialSignals', 'blockingRequirements', 'recommendedPromotion'],
  415. ...summary.gaps.map(gap => [
  416. gap.id,
  417. gap.currentProofStatus,
  418. gap.projectMaterialFound,
  419. gap.canCloseNow,
  420. formatSignals(gap.materialSignals),
  421. gap.blockingRequirements.join(';'),
  422. gap.recommendedPromotion.join(';')
  423. ]),
  424. [],
  425. ['gap', 'materialType', 'path', 'contribution', 'closureBoundary'],
  426. ...summary.gaps.flatMap(gap => gap.projectMaterials.map(item => [
  427. gap.id,
  428. item.type,
  429. item.path,
  430. item.contribution,
  431. item.closureBoundary
  432. ]))
  433. ];
  434. return rows.map(row => row.map(csvCell).join(',')).join('\n');
  435. }
  436. function formatSignals(signals) {
  437. return Object.entries(signals || {})
  438. .map(([key, value]) => `${key}=${Array.isArray(value) ? value.join('/') : value}`)
  439. .join(';');
  440. }
  441. function latestFile(outputsDir, fileName) {
  442. if (!fs.existsSync(outputsDir)) return '';
  443. const result = [];
  444. const stack = [outputsDir];
  445. while (stack.length) {
  446. const current = stack.pop();
  447. for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
  448. const full = path.join(current, entry.name);
  449. if (entry.isDirectory()) stack.push(full);
  450. else if (entry.isFile() && entry.name === fileName) result.push(full);
  451. }
  452. }
  453. return result.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] || '';
  454. }
  455. function readJson(file) {
  456. try {
  457. if (!file || !fs.existsSync(file)) return null;
  458. return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  459. } catch (_) {
  460. return null;
  461. }
  462. }
  463. function uniqueBy(rows, keyFn) {
  464. const seen = new Set();
  465. const result = [];
  466. for (const row of rows || []) {
  467. const key = keyFn(row);
  468. if (!key || seen.has(key)) continue;
  469. seen.add(key);
  470. result.push(row);
  471. }
  472. return result;
  473. }
  474. function uniqueStrings(values) {
  475. return Array.from(new Set((values || []).map(value => String(value || '').trim()).filter(Boolean)));
  476. }
  477. function extractNumber(value, pattern) {
  478. const match = pattern.exec(String(value || ''));
  479. return match ? Number(match[1]) : null;
  480. }
  481. function rel(root, file) {
  482. if (!file) return '';
  483. const relative = path.relative(root, file).replace(/\\/g, '/');
  484. return relative || '.';
  485. }
  486. function parseArgs(argv) {
  487. const result = {};
  488. for (let index = 0; index < argv.length; index += 1) {
  489. const arg = argv[index];
  490. if (!arg.startsWith('--')) continue;
  491. const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  492. const next = argv[index + 1];
  493. if (!next || next.startsWith('--')) result[key] = true;
  494. else {
  495. result[key] = next;
  496. index += 1;
  497. }
  498. }
  499. return result;
  500. }
  501. function withBom(text) {
  502. return `\uFEFF${text}`;
  503. }
  504. function tableRow(values) {
  505. return `| ${values.map(value => escapeCell(value)).join(' | ')} |`;
  506. }
  507. function escapeCell(value) {
  508. return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
  509. }
  510. function csvCell(value) {
  511. return `"${String(value ?? '').replace(/"/g, '""')}"`;
  512. }
  513. if (require.main === module) main();
  514. module.exports = {
  515. buildRealGapMaterialAudit,
  516. extractCandidateVideoNotes,
  517. findTranscriptHits
  518. };