video-hit-rate-audit.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { runTihaoSourcing } = require('../mcp/src/features/tihao-sourcing/sourcing-workflow');
  5. const { redactSecrets } = require('../mcp/src/core/result-envelope');
  6. const STATUS_STRONG = '\u5f3a\u63a8\u8350';
  7. const STATUS_BACKUP = '\u5907\u9009';
  8. const STATUS_REVIEW = '\u9700\u590d\u6838';
  9. async function main() {
  10. const config = collectRuntimeConfig(process.env);
  11. const outputRoot = path.resolve(process.env.TIHAO_AB_OUTPUT || path.join(__dirname, '..', 'outputs', `video-hit-rate-audit-${Date.now()}`));
  12. fs.mkdirSync(outputRoot, { recursive: true });
  13. const runtimePreflight = buildRuntimePreflight(config, outputRoot);
  14. if (!runtimePreflight.readyForVideoAb) {
  15. const files = writeRuntimePreflight(runtimePreflight, outputRoot);
  16. console.error(redactSecrets(JSON.stringify({
  17. readyForVideoAb: false,
  18. failureCount: runtimePreflight.failureCount,
  19. summaryFile: files.summaryFile,
  20. reportFile: files.reportFile,
  21. nextActions: runtimePreflight.nextActions
  22. }, null, 2)));
  23. process.exitCode = 2;
  24. return;
  25. }
  26. const baseInput = {
  27. collectionMode: 'live',
  28. brief: config.brief,
  29. referenceLinks: [{ url: config.referenceUrl, platform: 'xiaohongshu', contentType: 'video' }],
  30. keywordLimit: Number(process.env.TIHAO_AB_KEYWORD_LIMIT || 1),
  31. pagesPerKeyword: Number(process.env.TIHAO_AB_PAGES_PER_KEYWORD || 1),
  32. tihaoToken: config.runtimeCredential,
  33. company: config.company
  34. };
  35. if (process.env.TIHAO_AB_EVIDENCE_CREATORS_LIMIT) {
  36. baseInput.evidenceCreatorsLimit = Number(process.env.TIHAO_AB_EVIDENCE_CREATORS_LIMIT);
  37. }
  38. console.error('video-hit-rate-audit: running baseline live sourcing');
  39. const baseline = await withoutProviderEnv(() => runTihaoSourcing({
  40. ...baseInput,
  41. output: path.join(outputRoot, 'A-no-video-detail')
  42. }));
  43. console.error('video-hit-rate-audit: running VOC social + Doubao enhanced sourcing');
  44. const enhanced = await runTihaoSourcing({
  45. ...baseInput,
  46. output: path.join(outputRoot, 'B-voc-social-doubao'),
  47. vocSocialToken: config.vocSocialToken,
  48. enableVocSocialReferenceEnrichment: true,
  49. resultFirstMode: config.resultFirstMode,
  50. optimizeForResults: config.resultFirstMode,
  51. videoAnalysisBaseUrl: config.videoBaseUrl,
  52. videoAnalysisModel: config.videoModel,
  53. videoAnalysisToken: config.videoToken
  54. });
  55. const summary = {
  56. outputRoot,
  57. brief: config.brief,
  58. referenceUrl: config.referenceUrl,
  59. resultFirstMode: config.resultFirstMode,
  60. proofContext: {
  61. mode: 'live',
  62. collectionMode: 'live',
  63. requiresRuntimeCredential: true,
  64. requiresVocSocialProvider: true,
  65. requiresVideoAnalysisProvider: true,
  66. videoAnalysisBaseUrl: config.videoBaseUrl,
  67. videoAnalysisModel: config.videoModel,
  68. generatedBy: 'acceptance:video-ab'
  69. },
  70. baseline: summarizeRun(baseline),
  71. enhanced: summarizeRun(enhanced)
  72. };
  73. summary.delta = {
  74. candidates: summary.enhanced.counts.candidates - summary.baseline.counts.candidates,
  75. strong: summary.enhanced.counts.strong - summary.baseline.counts.strong,
  76. evidenceCards: summary.enhanced.counts.evidenceCards - summary.baseline.counts.evidenceCards,
  77. referenceResources: summary.enhanced.counts.referenceResources - summary.baseline.counts.referenceResources,
  78. top10AvgReferenceStyleFitScore: round(summary.enhanced.top10.avgReferenceStyleFitScore - summary.baseline.top10.avgReferenceStyleFitScore),
  79. top10AvgScore: round(summary.enhanced.top10.avgScore - summary.baseline.top10.avgScore),
  80. top10EvidenceHitCandidates: summary.enhanced.top10.evidenceHitCandidates - summary.baseline.top10.evidenceHitCandidates
  81. };
  82. summary.efficiency = {
  83. enhancedEvidenceCards: summary.enhanced.counts.evidenceCards,
  84. strongLiftPerEvidenceCard: ratio(summary.delta.strong, summary.enhanced.counts.evidenceCards),
  85. scoreLiftPerEvidenceCard: ratio(summary.delta.top10AvgScore, summary.enhanced.counts.evidenceCards),
  86. referenceScoreLiftPerEvidenceCard: ratio(summary.delta.top10AvgReferenceStyleFitScore, summary.enhanced.counts.evidenceCards)
  87. };
  88. summary.acceptance = computeVideoAcceptance(summary);
  89. summary.gates = buildAcceptanceGates(summary);
  90. const file = path.join(outputRoot, 'video-hit-rate-summary.json');
  91. const reportFile = path.join(outputRoot, 'video-hit-rate-report.md');
  92. fs.writeFileSync(file, JSON.stringify(summary, null, 2));
  93. fs.writeFileSync(reportFile, withBom(renderVideoAbReport(summary)), 'utf8');
  94. scanForLeaks(outputRoot, [config.runtimeCredential, config.vocSocialToken, config.videoToken]);
  95. console.log(redactSecrets(JSON.stringify({
  96. summaryFile: file,
  97. reportFile,
  98. baseline: summary.baseline.counts,
  99. enhanced: summary.enhanced.counts,
  100. delta: summary.delta,
  101. efficiency: summary.efficiency,
  102. acceptance: summary.acceptance
  103. }, null, 2)));
  104. assert(summary.acceptance.passed, `Reference-video A/B did not pass. See ${file}`);
  105. }
  106. async function withoutProviderEnv(fn) {
  107. const keys = [
  108. 'VOC_SOCIAL_TOKEN',
  109. 'VOC_SOCIAL_BASE_URL',
  110. 'ENABLE_VOC_SOCIAL_REFERENCE_ENRICHMENT',
  111. 'TIHAO_ENABLE_VOC_SOCIAL_REFERENCE_ENRICHMENT',
  112. 'VIDEO_ANALYSIS_BASE_URL',
  113. 'VIDEO_ANALYSIS_MODEL',
  114. 'VIDEO_ANALYSIS_TOKEN',
  115. 'VIDEO_ANALYSIS_PATH',
  116. 'VIDEO_ANALYSIS_MODE',
  117. 'DOUBAO_VISION_BASE_URL',
  118. 'DOUBAO_VISION_MODEL',
  119. 'DOUBAO_VISION_TOKEN',
  120. 'DOUBAO_VISION_PATH',
  121. 'DOUBAO_VISION_MODE',
  122. 'TIHAO_EVIDENCE_BASE_URL',
  123. 'TIHAO_EVIDENCE_TOKEN',
  124. 'MULTIMODAL_EVIDENCE_TOKEN'
  125. ];
  126. const snapshot = Object.fromEntries(keys.map(key => [key, process.env[key]]));
  127. for (const key of keys) delete process.env[key];
  128. try {
  129. return await fn();
  130. } finally {
  131. for (const [key, value] of Object.entries(snapshot)) {
  132. if (value === undefined) delete process.env[key];
  133. else process.env[key] = value;
  134. }
  135. }
  136. }
  137. function collectRuntimeConfig(env = process.env) {
  138. const runtimeCredential = env.TIHAO_SESSION_TOKEN || env.VOC_ECOMMERCE_TOKEN || env.VOC_TOKEN || '';
  139. const company = env.TIHAO_COMPANY || env.VOC_ECOMMERCE_COMPANY || env.COMPANY_OBJECT_ID || '';
  140. return {
  141. runtimeCredential,
  142. company,
  143. vocSocialToken: env.VOC_SOCIAL_TOKEN || runtimeCredential,
  144. videoToken: env.VIDEO_ANALYSIS_TOKEN || env.DOUBAO_VISION_TOKEN || '',
  145. videoBaseUrl: env.VIDEO_ANALYSIS_BASE_URL || env.DOUBAO_VISION_BASE_URL || 'https://api.fmode.cn',
  146. videoModel: env.VIDEO_ANALYSIS_MODEL || env.DOUBAO_VISION_MODEL || 'doubao-seed-2-0-pro',
  147. brief: env.TIHAO_AB_BRIEF || path.resolve(__dirname, '..', '..', 'dha_brief.xlsx'),
  148. referenceUrl: env.TIHAO_AB_REFERENCE_URL || 'http://xhslink.com/o/2fg3MzG2Iau',
  149. resultFirstMode: env.TIHAO_AB_RESULT_FIRST === 'true' || env.TIHAO_RESULT_FIRST_MODE === 'true'
  150. };
  151. }
  152. function buildRuntimePreflight(config, outputRoot = '') {
  153. const checks = [
  154. runtimeCheck(
  155. 'runtime-credential-present',
  156. Boolean(config.runtimeCredential),
  157. '配置 TIHAO_SESSION_TOKEN / VOC_ECOMMERCE_TOKEN / VOC_TOKEN 中任一运行时凭证。',
  158. 'proofContext.requiresRuntimeCredential=true'
  159. ),
  160. runtimeCheck(
  161. 'company-resolved',
  162. Boolean(config.company),
  163. '配置 TIHAO_COMPANY / VOC_ECOMMERCE_COMPANY / COMPANY_OBJECT_ID,或先用 live:preflight 解析 sessionToken 上的 company。',
  164. 'company resolved for live run'
  165. ),
  166. runtimeCheck(
  167. 'voc-social-provider-token-present',
  168. Boolean(config.vocSocialToken),
  169. '配置 VOC_SOCIAL_TOKEN,或确认运行时凭证可用于 VOC social provider。',
  170. 'proofContext.requiresVocSocialProvider=true'
  171. ),
  172. runtimeCheck(
  173. 'video-analysis-token-present',
  174. Boolean(config.videoToken),
  175. '配置 VIDEO_ANALYSIS_TOKEN / DOUBAO_VISION_TOKEN。',
  176. 'proofContext.requiresVideoAnalysisProvider=true'
  177. ),
  178. runtimeCheck(
  179. 'video-analysis-model-doubao-seed-2-0-pro',
  180. config.videoModel === 'doubao-seed-2-0-pro',
  181. '配置 VIDEO_ANALYSIS_MODEL / DOUBAO_VISION_MODEL 为 doubao-seed-2-0-pro。',
  182. 'videoAnalysisModel=doubao-seed-2-0-pro'
  183. )
  184. ];
  185. const failures = checks.filter(item => item.status === 'fail');
  186. return {
  187. generatedAt: new Date().toISOString(),
  188. materialType: 'video_ab_runtime_preflight',
  189. directCustomerProof: false,
  190. proofLevel: 'not_business_proof',
  191. canCloseProofGap: false,
  192. readyForVideoAb: failures.length === 0,
  193. failureCount: failures.length,
  194. outputRoot,
  195. proofContext: {
  196. mode: failures.length ? 'not_run' : 'live-ready',
  197. collectionMode: failures.length ? 'not_run' : 'live-ready',
  198. generatedBy: 'acceptance:video-ab-preflight',
  199. requiresRuntimeCredential: true,
  200. requiresVocSocialProvider: true,
  201. requiresVideoAnalysisProvider: true,
  202. videoAnalysisBaseUrl: config.videoBaseUrl,
  203. videoAnalysisModel: config.videoModel
  204. },
  205. runtime: {
  206. runtimeCredentialPresent: Boolean(config.runtimeCredential),
  207. companyResolved: Boolean(config.company),
  208. vocSocialProviderTokenPresent: Boolean(config.vocSocialToken),
  209. videoAnalysisTokenPresent: Boolean(config.videoToken),
  210. videoAnalysisBaseUrlPresent: Boolean(config.videoBaseUrl),
  211. videoAnalysisModel: config.videoModel,
  212. brief: config.brief,
  213. referenceUrl: config.referenceUrl,
  214. resultFirstMode: config.resultFirstMode
  215. },
  216. checks,
  217. missingProofRequirements: failures.map(item => item.missingProofRequirement),
  218. nextActions: buildRuntimePreflightNextActions(failures)
  219. };
  220. }
  221. function runtimeCheck(id, pass, action, missingProofRequirement) {
  222. return {
  223. id,
  224. status: pass ? 'pass' : 'fail',
  225. action: pass ? '' : action,
  226. missingProofRequirement: pass ? '' : missingProofRequirement
  227. };
  228. }
  229. function buildRuntimePreflightNextActions(failures) {
  230. if (!failures.length) return ['运行 npm run acceptance:video-ab,生成带 live proofContext 的 video-hit-rate-summary.json。'];
  231. const actions = failures.map(item => item.action);
  232. actions.push('补齐后先运行 npm run live:preflight -- --strict,再运行 npm run acceptance:video-ab。');
  233. actions.push('preflight 失败产物不能关闭 video-ab-live-proof,也不能证明提号率提升。');
  234. return [...new Set(actions)];
  235. }
  236. function writeRuntimePreflight(summary, outputRoot) {
  237. const summaryFile = path.join(outputRoot, 'video-hit-rate-preflight-summary.json');
  238. const reportFile = path.join(outputRoot, 'video-hit-rate-preflight-report.md');
  239. fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2), 'utf8');
  240. fs.writeFileSync(reportFile, withBom(renderRuntimePreflightReport(summary)), 'utf8');
  241. return { summaryFile, reportFile };
  242. }
  243. function renderRuntimePreflightReport(summary) {
  244. const lines = [
  245. '# 提号参考视频 A/B 运行前置检查',
  246. '',
  247. `- readyForVideoAb:${summary.readyForVideoAb}`,
  248. `- failureCount:${summary.failureCount}`,
  249. `- proofLevel:${summary.proofLevel}`,
  250. `- canCloseProofGap:${summary.canCloseProofGap}`,
  251. '',
  252. '## 检查项',
  253. '',
  254. '| 状态 | 检查项 | 缺失证明 | 下一步 |',
  255. '| --- | --- | --- | --- |',
  256. ...summary.checks.map(item => `| ${item.status} | ${escapeCell(item.id)} | ${escapeCell(item.missingProofRequirement)} | ${escapeCell(item.action)} |`),
  257. '',
  258. '## 下一步',
  259. '',
  260. ...summary.nextActions.map(item => `- ${item}`),
  261. '',
  262. '## 边界',
  263. '',
  264. '- 本文件只是运行前置检查,不是 video-hit-rate-summary.json。',
  265. '- 只有 acceptance:video-ab 在真实 provider、真实视频资源和 live proofContext 下通过,才能关闭 video-ab-live-proof。'
  266. ];
  267. return lines.join('\n');
  268. }
  269. function summarizeRun(result) {
  270. const data = result.data || {};
  271. const criteria = data.criteria || {};
  272. const candidates = data.candidates || [];
  273. const top10 = candidates.slice(0, 10);
  274. return {
  275. status: result.status,
  276. counts: {
  277. candidates: candidates.length,
  278. strong: countStatus(candidates, STATUS_STRONG),
  279. backup: countStatus(candidates, STATUS_BACKUP),
  280. review: countStatus(candidates, STATUS_REVIEW),
  281. referenceFingerprints: (criteria.referenceStyleFingerprints || []).length,
  282. referenceResources: (criteria.referenceResources || []).length,
  283. evidenceCards: (criteria.evidenceCards || []).length,
  284. recallRecords: (criteria.recallRecords || []).length
  285. },
  286. provider: {
  287. reference: criteria.referenceEvidenceStatus || {},
  288. fingerprint: criteria.referenceFingerprintStatus || {},
  289. evidence: criteria.evidenceStatus || {}
  290. },
  291. resourceEvidence: summarizeResourceEvidence(criteria),
  292. evidenceQuality: summarizeEvidenceQuality(criteria),
  293. top10: {
  294. avgScore: avg(top10.map(item => item.score)),
  295. avgReferenceStyleFitScore: avg(top10.map(item => item.referenceStyleFitScore)),
  296. evidenceHitCandidates: top10.filter(item => (item.evidenceSignals || []).length || item.evidenceCard).length,
  297. names: top10.map(item => ({
  298. name: item.displayName,
  299. status: item.recommendStatus,
  300. score: item.score,
  301. referenceStyleFitScore: item.referenceStyleFitScore,
  302. evidenceSignals: (item.evidenceSignals || []).slice(0, 5),
  303. referenceStyleHitPoints: (item.referenceStyleHitPoints || []).slice(0, 5)
  304. }))
  305. },
  306. files: result.files || {}
  307. };
  308. }
  309. function summarizeResourceEvidence(criteria) {
  310. const resources = criteria.referenceResources || [];
  311. return {
  312. referenceResources: resources.length,
  313. videoUrlCount: resources.filter(item => item.videoUrl || item.coverOrVideoResource || /\.mp4|\.mov|\.m3u8|video/i.test(String(item.url || ''))).length,
  314. coverUrlCount: resources.filter(item => item.coverUrl || item.cover || item.coverImage).length,
  315. asrTextCount: resources.filter(item => item.asrText || item.transcript || item.subtitleText).length,
  316. frameUrlCount: resources.filter(item => Array.isArray(item.frameUrls) && item.frameUrls.length).length,
  317. sample: resources.slice(0, 5).map(item => ({
  318. platform: item.platform || '',
  319. contentType: item.contentType || '',
  320. title: item.title || '',
  321. hasVideoUrl: Boolean(item.videoUrl || item.coverOrVideoResource),
  322. hasCoverUrl: Boolean(item.coverUrl || item.cover || item.coverImage),
  323. hasAsrText: Boolean(item.asrText || item.transcript || item.subtitleText),
  324. hasFrameUrls: Boolean(Array.isArray(item.frameUrls) && item.frameUrls.length)
  325. }))
  326. };
  327. }
  328. function summarizeEvidenceQuality(criteria) {
  329. const cards = criteria.evidenceCards || [];
  330. const verified = cards.filter(item => item.sourceLevel && !/pending|待补|fallback/i.test(String(item.sourceLevel || '')));
  331. const cardsWithSignals = cards.filter(item => {
  332. const content = item.contentEvidence || {};
  333. return (content.textSignals || []).length ||
  334. (content.asrSignals || []).length ||
  335. (content.visualSignals || []).length ||
  336. (content.frameEvidence || []).length;
  337. });
  338. return {
  339. cardCount: cards.length,
  340. verifiedCardCount: verified.length,
  341. cardsWithSignals: cardsWithSignals.length
  342. };
  343. }
  344. function buildAcceptanceGates(summary) {
  345. return [
  346. gate('真实参考资源已加载', summary.acceptance.realReferenceResourceLoaded, summary.enhanced.resourceEvidence.referenceResources, '> 0'),
  347. gate('真实视频 URL 已加载', summary.acceptance.realVideoResourceLoaded, summary.enhanced.resourceEvidence.videoUrlCount, '> 0'),
  348. gate('封面/ASR/帧图至少一类已加载', summary.acceptance.coverOrFrameOrAsrLoaded, {
  349. coverUrlCount: summary.enhanced.resourceEvidence.coverUrlCount,
  350. asrTextCount: summary.enhanced.resourceEvidence.asrTextCount,
  351. frameUrlCount: summary.enhanced.resourceEvidence.frameUrlCount
  352. }, '任一 > 0'),
  353. gate('视频分析 provider 正常', summary.acceptance.videoAnalysisProviderOk, summary.enhanced.provider.evidence.providerStatus, 'ok'),
  354. gate('证据卡已生成', summary.acceptance.evidenceCardsLoaded, summary.enhanced.counts.evidenceCards, '> 0'),
  355. gate('证据卡不是待补占位', summary.acceptance.evidenceCardsNotPending, summary.enhanced.evidenceQuality.verifiedCardCount, '> 0'),
  356. gate('证据卡包含可解释信号', summary.acceptance.evidenceCardsHaveSignals, summary.enhanced.evidenceQuality.cardsWithSignals, '> 0'),
  357. gate('Top10 证据命中提升且分数不下降', summary.acceptance.top10EvidenceImproved, summary.delta, 'evidence + score/reference >= 0'),
  358. gate('强推荐数量不下降', summary.acceptance.strongNotDegraded, summary.delta.strong, '>= 0')
  359. ];
  360. }
  361. function computeVideoAcceptance(summary) {
  362. const acceptance = {
  363. realReferenceResourceLoaded: summary.enhanced.resourceEvidence.referenceResources > 0,
  364. realVideoResourceLoaded: summary.enhanced.resourceEvidence.videoUrlCount > 0,
  365. coverOrFrameOrAsrLoaded: summary.enhanced.resourceEvidence.coverUrlCount > 0 ||
  366. summary.enhanced.resourceEvidence.asrTextCount > 0 ||
  367. summary.enhanced.resourceEvidence.frameUrlCount > 0,
  368. videoAnalysisProviderOk: summary.enhanced.provider.evidence.providerStatus === 'ok',
  369. evidenceCardsLoaded: summary.enhanced.counts.evidenceCards > 0,
  370. evidenceCardsNotPending: summary.enhanced.evidenceQuality.verifiedCardCount > 0,
  371. evidenceCardsHaveSignals: summary.enhanced.evidenceQuality.cardsWithSignals > 0,
  372. top10EvidenceImproved: summary.delta.top10EvidenceHitCandidates > 0 &&
  373. summary.delta.top10AvgScore >= 0 &&
  374. summary.delta.top10AvgReferenceStyleFitScore >= 0,
  375. strongNotDegraded: summary.delta.strong >= 0
  376. };
  377. acceptance.passed = Object.values(acceptance).every(Boolean);
  378. return acceptance;
  379. }
  380. function gate(name, pass, value, target) {
  381. return { name, status: pass ? 'pass' : 'fail', value, target };
  382. }
  383. function renderVideoAbReport(summary) {
  384. const lines = [
  385. '# 提号参考视频 A/B 验收报告',
  386. '',
  387. `- 运行目录:${summary.outputRoot}`,
  388. `- Brief:${summary.brief}`,
  389. `- 参考视频:${summary.referenceUrl}`,
  390. `- result-first:${summary.resultFirstMode}`,
  391. `- 总体验收:${summary.acceptance.passed ? '通过' : '未通过'}`,
  392. '',
  393. '## 核心差异',
  394. '',
  395. '| 指标 | A 组 baseline | B 组视频增强 | 差值 |',
  396. '| --- | ---: | ---: | ---: |',
  397. `| 候选数 | ${summary.baseline.counts.candidates} | ${summary.enhanced.counts.candidates} | ${summary.delta.candidates} |`,
  398. `| 强推荐数 | ${summary.baseline.counts.strong} | ${summary.enhanced.counts.strong} | ${summary.delta.strong} |`,
  399. `| 证据卡数 | ${summary.baseline.counts.evidenceCards} | ${summary.enhanced.counts.evidenceCards} | ${summary.delta.evidenceCards} |`,
  400. `| 参考资源数 | ${summary.baseline.counts.referenceResources} | ${summary.enhanced.counts.referenceResources} | ${summary.delta.referenceResources} |`,
  401. `| Top10 平均分 | ${summary.baseline.top10.avgScore} | ${summary.enhanced.top10.avgScore} | ${summary.delta.top10AvgScore} |`,
  402. `| Top10 平均参考风格分 | ${summary.baseline.top10.avgReferenceStyleFitScore} | ${summary.enhanced.top10.avgReferenceStyleFitScore} | ${summary.delta.top10AvgReferenceStyleFitScore} |`,
  403. `| Top10 证据命中候选 | ${summary.baseline.top10.evidenceHitCandidates} | ${summary.enhanced.top10.evidenceHitCandidates} | ${summary.delta.top10EvidenceHitCandidates} |`,
  404. '',
  405. '## 视频资源证据',
  406. '',
  407. `- 参考资源数:${summary.enhanced.resourceEvidence.referenceResources}`,
  408. `- 视频 URL 数:${summary.enhanced.resourceEvidence.videoUrlCount}`,
  409. `- 封面 URL 数:${summary.enhanced.resourceEvidence.coverUrlCount}`,
  410. `- ASR/字幕数:${summary.enhanced.resourceEvidence.asrTextCount}`,
  411. `- 帧图资源数:${summary.enhanced.resourceEvidence.frameUrlCount}`,
  412. '',
  413. '## 验收门禁',
  414. '',
  415. '| 状态 | 门禁 | 当前值 | 目标 |',
  416. '| --- | --- | --- | --- |',
  417. ...summary.gates.map(item => `| ${item.status} | ${item.name} | ${escapeCell(typeof item.value === 'object' ? JSON.stringify(item.value) : item.value)} | ${escapeCell(item.target)} |`),
  418. '',
  419. '## 边界说明',
  420. '',
  421. '- 只有本报告总体验收通过,才可以说本次样本中视频分析对提号结果有正向证据。',
  422. '- 即使通过,也仍需用更多真实 Brief 和客户选中数据验证长期命中率。'
  423. ];
  424. return lines.join('\n');
  425. }
  426. function countStatus(candidates, status) {
  427. return candidates.filter(item => item.recommendStatus === status).length;
  428. }
  429. function avg(values) {
  430. const nums = values.map(Number).filter(value => Number.isFinite(value));
  431. return nums.length ? round(nums.reduce((sum, value) => sum + value, 0) / nums.length) : 0;
  432. }
  433. function round(value) {
  434. return Math.round(Number(value || 0) * 10) / 10;
  435. }
  436. function ratio(numerator, denominator) {
  437. return denominator ? Math.round((Number(numerator || 0) / Number(denominator)) * 100) / 100 : 0;
  438. }
  439. function scanForLeaks(root, secrets) {
  440. const realSecrets = [...new Set(secrets.filter(Boolean))];
  441. for (const file of listFiles(root)) {
  442. const text = fs.readFileSync(file, 'utf8');
  443. for (const secret of realSecrets) {
  444. assert(!text.includes(secret), `Secret leaked into ${file}`);
  445. }
  446. assert(!/Authorization\s*:/i.test(text), `Authorization header leaked into ${file}`);
  447. }
  448. }
  449. function withBom(text) {
  450. return `\uFEFF${text}`;
  451. }
  452. function escapeCell(value) {
  453. return String(value || '').replace(/\|/g, '/').replace(/\n/g, ' ');
  454. }
  455. function safeErrorMessage(error) {
  456. return redactSecrets(error && error.message ? error.message : String(error)).slice(0, 240);
  457. }
  458. function listFiles(root) {
  459. return fs.readdirSync(root, { withFileTypes: true }).flatMap(entry => {
  460. const full = path.join(root, entry.name);
  461. return entry.isDirectory() ? listFiles(full) : [full];
  462. });
  463. }
  464. function assert(condition, message) {
  465. if (!condition) throw new Error(message);
  466. }
  467. if (require.main === module) {
  468. main().catch(error => {
  469. console.error(redactSecrets(JSON.stringify({
  470. error: 'acceptance:video-ab failed',
  471. message: safeErrorMessage(error),
  472. nextActions: [
  473. '先运行 npm run live:preflight -- --strict 查看运行环境是否就绪。',
  474. '不要把失败输出或 preflight 产物当作 video-ab-live-proof。'
  475. ]
  476. }, null, 2)));
  477. process.exit(1);
  478. });
  479. }
  480. module.exports = {
  481. buildAcceptanceGates,
  482. buildRuntimePreflight,
  483. collectRuntimeConfig,
  484. computeVideoAcceptance,
  485. renderRuntimePreflightReport,
  486. summarizeEvidenceQuality,
  487. summarizeResourceEvidence,
  488. summarizeRun
  489. };