intake-readiness-audit.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { readCsv: readHistoryCsv } = require('./history-dataset-from-csv');
  5. const { readCsv: readVideoCsv } = require('./video-resource-readiness-audit');
  6. const { readCsv: readReviewCsv } = require('./review-metrics');
  7. const HISTORY_REQUIRED = [
  8. 'brief编号',
  9. '项目名称',
  10. '类目',
  11. '客户原始Brief',
  12. '历史人工补号量基线',
  13. '参考账号或视频',
  14. '平台',
  15. '博主名称',
  16. '主页链接',
  17. '人工复核标签',
  18. '客户选择',
  19. '拒绝原因'
  20. ];
  21. const REVIEW_REQUIRED = [
  22. 'brief编号',
  23. '策略',
  24. '排名',
  25. '平台',
  26. '博主名称',
  27. '综合分',
  28. 'brief匹配分',
  29. '参考风格分',
  30. '主页证据分',
  31. '视觉质感分',
  32. '调性一致分',
  33. '证据加分',
  34. '证据风险扣分',
  35. '推荐理由',
  36. '风险提示',
  37. '主页链接',
  38. '人工复核标签',
  39. '客户选择',
  40. '归因类型',
  41. '反馈原因',
  42. '本轮人工补号量'
  43. ];
  44. const VIDEO_REQUIRED = [
  45. 'brief编号',
  46. '项目名称',
  47. '平台',
  48. '资源角色',
  49. '博主名称',
  50. '主页链接',
  51. '视频链接',
  52. '封面链接',
  53. '字幕或ASR文本',
  54. '帧图链接',
  55. '标题',
  56. '发布时间',
  57. '内容摘要',
  58. '风格调性标签',
  59. '画面人设场景信号',
  60. '风险提示',
  61. '来源接口或备注',
  62. '是否真实资源'
  63. ];
  64. const FINAL_CUSTOMER_DECISIONS = new Set(['客户选中', '客户拒绝', '已选中', '已拒绝', '选中', '拒绝', '客户通过', '客户淘汰']);
  65. const NEGATIVE_REVIEW_LABELS = new Set(['跑偏', '硬性规则违规', '硬性规则违约', '调性不符', '主页质感不符', '参考账号不像']);
  66. function main() {
  67. const args = parseArgs(process.argv.slice(2));
  68. const dataPack = args.dataPack ? path.resolve(args.dataPack) : '';
  69. const videoPack = args.videoPack ? path.resolve(args.videoPack) : '';
  70. const historyCsv = path.resolve(args.historyCsv || args.history || (dataPack ? path.join(dataPack, 'history-data-template.csv') : ''));
  71. const reviewCsv = path.resolve(args.reviewCsv || args.review || (dataPack ? path.join(dataPack, 'manual-review-template.csv') : ''));
  72. const videoCsv = path.resolve(args.videoCsv || args.video || (videoPack ? path.join(videoPack, 'video-resource-template.csv') : ''));
  73. if (!historyCsv || !reviewCsv || !videoCsv) {
  74. throw new Error('Usage: node scripts/intake-readiness-audit.js --data-pack <dir> --video-pack <dir> [--output <dir>] [--strict]');
  75. }
  76. const outputDir = path.resolve(args.output || process.env.TIHAO_INTAKE_READINESS_OUTPUT || path.join(path.dirname(historyCsv), 'intake-readiness'));
  77. const minBriefs = Number(args.minBriefs || process.env.TIHAO_INTAKE_MIN_BRIEFS || 5);
  78. const summary = buildIntakeReadinessSummary({
  79. historyCsv,
  80. reviewCsv,
  81. videoCsv,
  82. minBriefs
  83. });
  84. fs.mkdirSync(outputDir, { recursive: true });
  85. const jsonPath = path.join(outputDir, 'intake-readiness-summary.json');
  86. const reportPath = path.join(outputDir, 'intake-readiness-report.md');
  87. const repairCsvPath = path.join(outputDir, 'intake-readiness-repair-actions.csv');
  88. fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8');
  89. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  90. fs.writeFileSync(repairCsvPath, withBom(renderRepairCsv(summary.repairActions)), 'utf8');
  91. console.log(JSON.stringify({
  92. outputDir,
  93. json: jsonPath,
  94. report: reportPath,
  95. readyForHistoryAudit: summary.history.ready,
  96. readyForVideoResourceAudit: summary.video.ready,
  97. readyForReviewMetrics: summary.review.readyForReviewMetrics,
  98. readyForCustomerEffectAudit: summary.review.readyForCustomerEffectAudit,
  99. overallReady: summary.acceptance.overallReady,
  100. failureCount: summary.failureCount,
  101. repairActionCount: summary.repairActions.length
  102. }, null, 2));
  103. if (args.strict && !summary.acceptance.overallReady) process.exitCode = 2;
  104. }
  105. function buildIntakeReadinessSummary({ historyCsv, reviewCsv, videoCsv, minBriefs }) {
  106. const historyRows = safeReadCsv(readHistoryCsv, historyCsv);
  107. const reviewRows = safeReadCsv(readReviewCsv, reviewCsv);
  108. const videoRows = safeReadCsv(readVideoCsv, videoCsv);
  109. const history = auditHistory({ file: historyCsv, rows: historyRows, minBriefs });
  110. const review = auditReview({ file: reviewCsv, rows: reviewRows });
  111. const video = auditVideo({ file: videoCsv, rows: videoRows });
  112. const sections = [history, review, video];
  113. const issues = sections.flatMap(section => section.issues.map(issue => ({ section: section.id, ...issue })));
  114. const acceptance = {
  115. historyReady: history.ready,
  116. videoReady: video.ready,
  117. reviewMetricsReady: review.readyForReviewMetrics,
  118. customerEffectReady: review.readyForCustomerEffectAudit
  119. };
  120. acceptance.overallReady = acceptance.historyReady &&
  121. acceptance.videoReady &&
  122. acceptance.reviewMetricsReady &&
  123. acceptance.customerEffectReady;
  124. const files = { historyCsv, reviewCsv, videoCsv };
  125. const repairActions = buildRepairActions({ issues, files });
  126. return {
  127. generatedAt: new Date().toISOString(),
  128. minBriefs,
  129. files,
  130. acceptance,
  131. history,
  132. review,
  133. video,
  134. failureCount: issues.length,
  135. issues,
  136. repairActions,
  137. guardrails: [
  138. 'intake readiness 只判断模板是否已被真实材料替换,不替代 history:audit、video:resource-readiness、review:metrics 或 customer-effect:audit。',
  139. 'example.com、示例、候选博主、待客户反馈、人工待补等模板占位内容不得进入真实审计。',
  140. '没有客户最终选择和本轮人工补号量时,不得声明客户效果证明完成。',
  141. '没有真实参考视频和真实候选视频时,不得声明视频 A/B 可以开始。'
  142. ]
  143. };
  144. }
  145. function auditHistory({ file, rows, minBriefs }) {
  146. const issues = [];
  147. const headerOk = headerStartsWith(rows.header, HISTORY_REQUIRED);
  148. if (!rows.ok) issues.push(issue('read-error', rows.error));
  149. if (!headerOk) issues.push(issue('header', '历史数据 CSV 表头不符合 data-intake-pack 模板。'));
  150. const body = rows.body || [];
  151. const briefIds = unique(body.map(row => value(row['brief编号'])));
  152. if (briefIds.length < minBriefs) issues.push(issue('min-briefs', `真实历史 Brief 数不足:${briefIds.length}/${minBriefs}。`));
  153. if (hasPlaceholderRows(body)) issues.push(issue('placeholder', '历史数据表仍包含 example.com、示例、候选或待补等模板占位内容。'));
  154. if (!body.every(row => value(row['客户原始Brief']).length >= 20)) issues.push(issue('brief-text', '每行必须有足够具体的客户原始 Brief。'));
  155. if (!body.every(row => toNumberOrNull(row['历史人工补号量基线']) !== null)) issues.push(issue('manual-baseline', '每行必须填写历史人工补号量基线。'));
  156. if (!body.every(row => isLikelyRealReference(row['参考账号或视频']))) issues.push(issue('reference', '每行必须填写真实参考账号或参考视频链接。'));
  157. if (!body.every(row => value(row['博主名称']) && isLikelyRealUrl(row['主页链接']))) issues.push(issue('manual-list', '每行必须填写人工最终名单中的博主名称和主页链接。'));
  158. if (!body.every(row => FINAL_CUSTOMER_DECISIONS.has(value(row['客户选择'])))) issues.push(issue('customer-decision', '每行必须填写客户最终选中或客户拒绝,不能只写待客户反馈。'));
  159. const rejectedRows = body.filter(row => /拒绝|淘汰|未选/i.test(value(row['客户选择'])));
  160. if (!rejectedRows.every(row => value(row['拒绝原因']).length >= 2)) issues.push(issue('reject-reason', '客户拒绝样本必须填写拒绝原因。'));
  161. return {
  162. id: 'history',
  163. file,
  164. ready: issues.length === 0,
  165. counts: {
  166. rows: body.length,
  167. briefCount: briefIds.length,
  168. rejectedRows: rejectedRows.length
  169. },
  170. issues
  171. };
  172. }
  173. function auditReview({ file, rows }) {
  174. const issues = [];
  175. const headerOk = headerStartsWith(rows.header, REVIEW_REQUIRED);
  176. if (!rows.ok) issues.push(issue('read-error', rows.error));
  177. if (!headerOk) issues.push(issue('header', '人工复核 CSV 表头不符合 data-intake-pack 模板。'));
  178. const body = rows.body || [];
  179. if (!body.length) issues.push(issue('empty', '人工复核表没有候选行。'));
  180. if (hasPlaceholderRows(body)) issues.push(issue('placeholder', '人工复核表仍包含 example.com、示例、候选或待补等模板占位内容。'));
  181. if (!body.every(row => value(row['人工复核标签']))) issues.push(issue('review-label', '每行必须填写人工复核标签。'));
  182. const negativeRows = body.filter(row => NEGATIVE_REVIEW_LABELS.has(value(row['人工复核标签'])));
  183. if (!negativeRows.every(row => value(row['归因类型']))) issues.push(issue('attribution', '负样本必须填写归因类型。'));
  184. const hasCustomerDecision = body.some(row => FINAL_CUSTOMER_DECISIONS.has(value(row['客户选择'])));
  185. if (!hasCustomerDecision) issues.push(issue('customer-decision', '人工复核表至少需要客户最终选中/拒绝记录,才能进入客户效果审计。'));
  186. const hasCurrentManualSupplement = body.some(row => toNumberOrNull(row['本轮人工补号量']) !== null);
  187. if (!hasCurrentManualSupplement) issues.push(issue('current-manual-supplement', '缺少本轮人工补号量。'));
  188. const readyForReviewMetrics = headerOk && body.length > 0 && !hasPlaceholderRows(body) && body.every(row => value(row['人工复核标签'])) && negativeRows.every(row => value(row['归因类型']));
  189. const readyForCustomerEffectAudit = readyForReviewMetrics && hasCustomerDecision && hasCurrentManualSupplement;
  190. return {
  191. id: 'review',
  192. file,
  193. readyForReviewMetrics,
  194. readyForCustomerEffectAudit,
  195. counts: {
  196. rows: body.length,
  197. negativeRows: negativeRows.length,
  198. rowsWithCustomerDecision: body.filter(row => FINAL_CUSTOMER_DECISIONS.has(value(row['客户选择']))).length,
  199. rowsWithCurrentManualSupplement: body.filter(row => toNumberOrNull(row['本轮人工补号量']) !== null).length
  200. },
  201. issues
  202. };
  203. }
  204. function auditVideo({ file, rows }) {
  205. const issues = [];
  206. const headerOk = headerStartsWith(rows.header, VIDEO_REQUIRED);
  207. if (!rows.ok) issues.push(issue('read-error', rows.error));
  208. if (!headerOk) issues.push(issue('header', '视频资源 CSV 表头不符合 video-intake-pack 模板。'));
  209. const body = rows.body || [];
  210. if (!body.length) issues.push(issue('empty', '视频资源表没有资源行。'));
  211. if (hasPlaceholderRows(body)) issues.push(issue('placeholder', '视频资源表仍包含 example.com、示例、候选、待确认或人工待补等模板占位内容。'));
  212. const realRows = body.filter(row => isYes(row['是否真实资源']));
  213. const realReferences = realRows.filter(row => value(row['资源角色']) === '参考视频');
  214. const realCandidates = realRows.filter(row => value(row['资源角色']) === '候选视频');
  215. if (!realReferences.length) issues.push(issue('real-reference', '缺少真实参考视频资源。'));
  216. if (!realCandidates.length) issues.push(issue('real-candidate', '缺少真实候选视频资源。'));
  217. if (!realRows.some(row => isLikelyRealUrl(row['视频链接']))) issues.push(issue('video-url', '缺少真实视频 URL。'));
  218. if (!realRows.every(row => hasVideoEvidence(row))) issues.push(issue('video-evidence', '真实视频资源必须至少有视频 URL、封面、ASR、帧图或正文证据之一。'));
  219. return {
  220. id: 'video',
  221. file,
  222. ready: issues.length === 0,
  223. counts: {
  224. rows: body.length,
  225. realRows: realRows.length,
  226. realReferenceRows: realReferences.length,
  227. realCandidateRows: realCandidates.length,
  228. realVideoUrlRows: realRows.filter(row => isLikelyRealUrl(row['视频链接'])).length
  229. },
  230. issues
  231. };
  232. }
  233. function renderReport(summary) {
  234. const lines = [
  235. '# 真实材料 Intake Readiness 审计',
  236. '',
  237. `- 生成时间:${summary.generatedAt}`,
  238. `- 是否可进入后续真实审计:${summary.acceptance.overallReady ? '是' : '否'}`,
  239. `- failureCount:${summary.failureCount}`,
  240. '',
  241. '## 文件',
  242. '',
  243. `- 历史数据:${summary.files.historyCsv}`,
  244. `- 人工复核:${summary.files.reviewCsv}`,
  245. `- 视频资源:${summary.files.videoCsv}`,
  246. '',
  247. '## 门禁',
  248. '',
  249. '| 门禁 | 状态 |',
  250. '| --- | --- |',
  251. `| 历史数据可进入 history:from-csv/history:audit | ${passFail(summary.history.ready)} |`,
  252. `| 视频资源可进入 video:resource-readiness | ${passFail(summary.video.ready)} |`,
  253. `| 人工复核可进入 review:metrics | ${passFail(summary.review.readyForReviewMetrics)} |`,
  254. `| 人工复核可进入 customer-effect:audit | ${passFail(summary.review.readyForCustomerEffectAudit)} |`,
  255. '',
  256. '## 统计',
  257. '',
  258. '| 区域 | 指标 | 数量 |',
  259. '| --- | --- | ---: |',
  260. `| 历史数据 | 行数 | ${summary.history.counts.rows} |`,
  261. `| 历史数据 | Brief 数 | ${summary.history.counts.briefCount} |`,
  262. `| 人工复核 | 行数 | ${summary.review.counts.rows} |`,
  263. `| 人工复核 | 有客户选择行 | ${summary.review.counts.rowsWithCustomerDecision} |`,
  264. `| 视频资源 | 真实参考视频 | ${summary.video.counts.realReferenceRows} |`,
  265. `| 视频资源 | 真实候选视频 | ${summary.video.counts.realCandidateRows} |`,
  266. '',
  267. '## 问题',
  268. '',
  269. summary.issues.length
  270. ? '| 区域 | 类型 | 说明 |\n| --- | --- | --- |\n' + summary.issues.map(item => `| ${item.section} | ${item.type} | ${escapeCell(item.message)} |`).join('\n')
  271. : '- 暂无问题。',
  272. '',
  273. '## 修复清单',
  274. '',
  275. summary.repairActions.length
  276. ? '| 优先级 | 负责人 | 文件 | 字段 | 修复动作 | 通过标准 |\n| ---: | --- | --- | --- | --- | --- |\n' + summary.repairActions.map(item => `| ${item.priority} | ${item.owner} | ${escapeCell(item.file)} | ${escapeCell(item.field)} | ${escapeCell(item.action)} | ${escapeCell(item.acceptance)} |`).join('\n')
  277. : '- 暂无修复动作。',
  278. '',
  279. '## 后续命令',
  280. '',
  281. '```powershell',
  282. 'npm run history:from-csv -- --input <history-data-template.csv> --output <history-dataset目录>',
  283. 'npm run history:audit -- --input <history-dataset目录> --output <历史审计输出目录> --strict',
  284. 'npm run video:resource-readiness -- --input <video-resource-template.csv> --output <视频资源审计输出目录> --strict',
  285. 'npm run review:metrics -- --input <manual-review-template.csv> --output <复核指标输出目录> --strict',
  286. 'npm run customer-effect:audit -- --review-csv <manual-review-template.csv> --history-audit <historical-dataset-audit.json> --current-manual-supplement-count <本轮人工补号量> --output <客户效果输出目录> --strict',
  287. '```',
  288. '',
  289. '## 边界',
  290. '',
  291. ...summary.guardrails.map(item => `- ${item}`)
  292. ];
  293. return lines.join('\n');
  294. }
  295. function buildRepairActions({ issues, files }) {
  296. return issues.map((item, index) => {
  297. const spec = repairSpec(item);
  298. return {
  299. priority: index + 1,
  300. section: item.section,
  301. type: item.type,
  302. owner: spec.owner,
  303. file: spec.file === 'history' ? files.historyCsv : spec.file === 'review' ? files.reviewCsv : files.videoCsv,
  304. field: spec.field,
  305. action: spec.action,
  306. acceptance: spec.acceptance
  307. };
  308. });
  309. }
  310. function repairSpec(issueItem) {
  311. const key = `${issueItem.section}:${issueItem.type}`;
  312. const specs = {
  313. 'history:min-briefs': ['商务', 'history', 'brief编号', '补齐至少 5 个真实历史 Brief,覆盖不同项目或不同客户需求。', 'historyReady=true,真实历史 Brief 数 >= 5。'],
  314. 'history:placeholder': ['商务', 'history', '全表', '删除 example.com、示例、候选、待补等占位内容,替换为真实客户材料。', '预审不再出现 placeholder。'],
  315. 'history:brief-text': ['商务', 'history', '客户原始Brief', '填写足够具体的客户原始需求,至少覆盖类目、人群、平台、内容方向或禁投项。', '每行客户原始 Brief 长度和信息量达标。'],
  316. 'history:manual-baseline': ['商务', 'history', '历史人工补号量基线', '填写数字,表示历史同类需求人工额外补号数量。', '每行都能解析为非负数字。'],
  317. 'history:reference': ['商务', 'history', '参考账号或视频', '填写真实参考账号主页或参考视频链接,不只写昵称或截图说明。', '每行都有真实可识别 URL。'],
  318. 'history:manual-list': ['商务', 'history', '博主名称/主页链接', '填写人工最终名单中的真实博主名称和主页链接。', '每行博主名称非空且主页链接为真实 URL。'],
  319. 'history:customer-decision': ['商务', 'history', '客户选择', '把待反馈替换为客户最终选中或客户拒绝。', '每行客户选择为客户选中或客户拒绝。'],
  320. 'history:reject-reason': ['商务', 'history', '拒绝原因', '客户拒绝样本填写具体拒绝原因。', '所有拒绝样本拒绝原因非空。'],
  321. 'review:empty': ['商务', 'review', '全表', '把本轮 AI 候选名单粘贴到人工复核模板。', '人工复核表至少有一行候选。'],
  322. 'review:placeholder': ['商务', 'review', '全表', '删除 example.com、示例、候选、待补等占位内容,替换为真实候选和复核结果。', '预审不再出现 placeholder。'],
  323. 'review:review-label': ['商务', 'review', '人工复核标签', '为每个候选填写人工复核标签。', '每行人工复核标签非空。'],
  324. 'review:attribution': ['商务', 'review', '归因类型', '负样本填写需求解析错、隐性规则漏、召回关键词错、主页证据不足、视频证据误判、排序权重错或输出解释错。', '负样本归因覆盖率=100%。'],
  325. 'review:customer-decision': ['商务', 'review', '客户选择', '补客户最终选中或客户拒绝记录。', '至少有客户最终选择,客户效果审计字段齐全。'],
  326. 'review:current-manual-supplement': ['商务', 'review', '本轮人工补号量', '填写本轮 AI 输出后仍需人工补号的数量。', '至少一行本轮人工补号量为非负数字。'],
  327. 'video:empty': ['商务/投放', 'video', '全表', '填写真实参考视频和真实候选视频资源。', '视频资源表至少有参考视频和候选视频。'],
  328. 'video:placeholder': ['商务/投放', 'video', '全表', '删除 example.com、示例、候选、待确认或人工待补等占位内容。', '预审不再出现 placeholder。'],
  329. 'video:real-reference': ['商务/投放', 'video', '资源角色/是否真实资源', '补至少一个真实参考视频,并将是否真实资源标为是。', '真实参考视频行数 >= 1。'],
  330. 'video:real-candidate': ['商务/投放', 'video', '资源角色/是否真实资源', '补至少一个真实候选视频,并将是否真实资源标为是。', '真实候选视频行数 >= 1。'],
  331. 'video:video-url': ['商务/投放', 'video', '视频链接', '补真实视频 URL;只有主页链接或接口 200 不算视频资源。', '真实资源中至少一行有真实视频 URL。'],
  332. 'video:video-evidence': ['商务/投放', 'video', '封面链接/字幕或ASR文本/帧图链接/内容摘要', '为真实视频补封面、ASR、帧图或正文证据。', '真实资源至少有一类可审计证据。']
  333. };
  334. const fallback = specs[key] || ['技术/AI', issueItem.section === 'video' ? 'video' : issueItem.section === 'review' ? 'review' : 'history', issueItem.type, issueItem.message, '对应 issue 消失。'];
  335. return {
  336. owner: fallback[0],
  337. file: fallback[1],
  338. field: fallback[2],
  339. action: fallback[3],
  340. acceptance: fallback[4]
  341. };
  342. }
  343. function renderRepairCsv(actions) {
  344. const header = ['优先级', '负责人', '区域', '问题类型', '文件', '字段', '修复动作', '通过标准'];
  345. const rows = actions.map(item => [
  346. item.priority,
  347. item.owner,
  348. item.section,
  349. item.type,
  350. item.file,
  351. item.field,
  352. item.action,
  353. item.acceptance
  354. ]);
  355. return [header, ...rows].map(row => row.map(csvCell).join(',')).join('\n');
  356. }
  357. function csvCell(value) {
  358. const text = String(value ?? '');
  359. return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  360. }
  361. function safeReadCsv(reader, file) {
  362. try {
  363. if (!file || !fs.existsSync(file)) return { ok: false, header: [], body: [], error: `文件不存在:${file}` };
  364. const rows = reader(file);
  365. return { ok: true, header: rows.header || [], body: rows.body || [] };
  366. } catch (error) {
  367. return { ok: false, header: [], body: [], error: error.message };
  368. }
  369. }
  370. function headerStartsWith(header, required) {
  371. return required.every((key, index) => header[index] === key);
  372. }
  373. function hasPlaceholderRows(rows) {
  374. return rows.some(row => hasPlaceholderText(Object.values(row).join(' ')));
  375. }
  376. function hasPlaceholderText(text) {
  377. return /(example\.com|示例|候选博主|候选视频标题|待确认|人工待补|填写客户原始 Brief|暂时只有封面|内部鉴权|密钥)/i.test(String(text || ''));
  378. }
  379. function isLikelyRealReference(input) {
  380. return isLikelyRealUrl(input) && !hasPlaceholderText(input);
  381. }
  382. function isLikelyRealUrl(input) {
  383. const text = value(input);
  384. return /^https?:\/\/\S+/i.test(text) && !/example\.com/i.test(text);
  385. }
  386. function hasVideoEvidence(row) {
  387. return isLikelyRealUrl(row['视频链接']) ||
  388. isLikelyRealUrl(row['封面链接']) ||
  389. isLikelyRealUrl(row['帧图链接']) ||
  390. value(row['字幕或ASR文本']).length >= 12 ||
  391. value(row['内容摘要']).length >= 12;
  392. }
  393. function isYes(input) {
  394. return ['是', 'true', 'yes', '1'].includes(value(input).toLowerCase());
  395. }
  396. function issue(type, message) {
  397. return { type, message };
  398. }
  399. function value(input) {
  400. return String(input || '').trim();
  401. }
  402. function toNumberOrNull(input) {
  403. const text = value(input);
  404. if (!text) return null;
  405. const numeric = Number(text);
  406. return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
  407. }
  408. function unique(values) {
  409. return [...new Set(values.map(value).filter(Boolean))];
  410. }
  411. function passFail(value) {
  412. return value ? '通过' : '未通过';
  413. }
  414. function escapeCell(input) {
  415. return String(input || '').replace(/\|/g, '/').replace(/\r?\n/g, ' ');
  416. }
  417. function parseArgs(argv) {
  418. const args = {};
  419. for (let index = 0; index < argv.length; index += 1) {
  420. const raw = argv[index];
  421. if (!raw.startsWith('--')) continue;
  422. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  423. const next = argv[index + 1];
  424. if (!next || next.startsWith('--')) args[key] = true;
  425. else {
  426. args[key] = next;
  427. index += 1;
  428. }
  429. }
  430. return args;
  431. }
  432. function withBom(text) {
  433. return `\uFEFF${text}`;
  434. }
  435. if (require.main === module) {
  436. try {
  437. main();
  438. } catch (error) {
  439. console.error(error && error.stack ? error.stack : String(error));
  440. process.exit(1);
  441. }
  442. }
  443. module.exports = {
  444. buildIntakeReadinessSummary,
  445. renderReport
  446. };