overnight-quality-campaign.js 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981
  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. const STATUS_EXCLUDED = '\u5df2\u5254\u9664';
  10. async function main() {
  11. const root = path.resolve(__dirname, '..');
  12. const fixturesDir = path.resolve(process.env.TIHAO_QUALITY_FIXTURES || path.join(root, 'fixtures', 'quality-briefs'));
  13. const fixtures = loadFixtures(fixturesDir);
  14. if (!fixtures.length) throw new Error(`No quality fixtures found in ${fixturesDir}`);
  15. const credentials = readCredentials();
  16. const liveEnabled = process.env.TIHAO_OVERNIGHT_LIVE === 'false'
  17. ? false
  18. : process.env.TIHAO_OVERNIGHT_LIVE === 'true' || Boolean(credentials.runtimeCredential && credentials.company);
  19. const outputRoot = path.resolve(process.env.TIHAO_OVERNIGHT_OUTPUT || path.join(root, 'outputs', `overnight-quality-${Date.now()}`));
  20. fs.mkdirSync(outputRoot, { recursive: true });
  21. const selectedFixtureIds = csvSet(process.env.TIHAO_OVERNIGHT_FIXTURES);
  22. const selectedVariantIds = csvSet(process.env.TIHAO_OVERNIGHT_VARIANTS);
  23. const activeFixtures = selectedFixtureIds.size ? fixtures.filter(item => selectedFixtureIds.has(item.id)) : fixtures;
  24. const variants = buildVariants({ liveEnabled, credentials }).filter(item => !selectedVariantIds.size || selectedVariantIds.has(item.id));
  25. const options = readRunOptions({ liveEnabled, credentials });
  26. const manifest = {
  27. startedAt: new Date().toISOString(),
  28. fixturesDir,
  29. outputRoot,
  30. liveEnabled,
  31. videoAvailable: Boolean(liveEnabled && credentials.vocSocialToken && credentials.videoToken),
  32. fixtureCount: activeFixtures.length,
  33. variants: variants.map(item => ({ id: item.id, description: item.description, gateRole: item.gateRole || 'release' })),
  34. options: publicRunOptions(options)
  35. };
  36. writeJson(path.join(outputRoot, 'manifest.json'), manifest);
  37. const runs = [];
  38. const failures = [];
  39. let attemptedRuns = 0;
  40. for (const fixture of activeFixtures) {
  41. for (const variant of variants) {
  42. if (options.maxRuns && attemptedRuns >= options.maxRuns) {
  43. process.stderr.write(`overnight-quality: max runs reached (${options.maxRuns})\n`);
  44. break;
  45. }
  46. const runDir = path.join(outputRoot, 'runs', fixture.id, variant.id);
  47. fs.mkdirSync(runDir, { recursive: true });
  48. const summaryFile = path.join(runDir, 'quality-summary.json');
  49. if (options.resume && fs.existsSync(summaryFile)) {
  50. process.stderr.write(`overnight-quality: resume ${fixture.id}/${variant.id}\n`);
  51. runs.push({ ...JSON.parse(fs.readFileSync(summaryFile, 'utf8')), gateRole: variant.gateRole || 'release' });
  52. continue;
  53. }
  54. process.stderr.write(`overnight-quality: ${fixture.id}/${variant.id}\n`);
  55. attemptedRuns += 1;
  56. try {
  57. const startedAt = Date.now();
  58. const input = buildRunInput({ fixture, variant, runDir, credentials });
  59. const result = await runSourcingWithRetries(input, options.runRetries);
  60. const summary = summarizeRun({
  61. fixture,
  62. variant,
  63. result,
  64. runDir,
  65. durationMs: Date.now() - startedAt
  66. });
  67. runs.push(summary);
  68. writeJson(summaryFile, summary);
  69. } catch (error) {
  70. const failure = {
  71. fixtureId: fixture.id,
  72. variant: variant.id,
  73. message: redactSecrets(error && error.stack ? error.stack : String(error))
  74. };
  75. failures.push(failure);
  76. writeJson(path.join(runDir, 'failure.json'), failure);
  77. }
  78. if (options.delayMs > 0) await sleep(options.delayMs);
  79. }
  80. if (options.maxRuns && attemptedRuns >= options.maxRuns) break;
  81. }
  82. const aggregate = buildAggregate({ manifest, runs, failures, fixtures: activeFixtures, variants, options });
  83. writeJson(path.join(outputRoot, 'aggregate-summary.json'), aggregate);
  84. writeJson(path.join(outputRoot, 'failures.json'), failures);
  85. writeText(path.join(outputRoot, 'aggregate-report.md'), renderAggregateReportClean(aggregate));
  86. writeText(path.join(outputRoot, 'manual-review-sample.csv'), buildManualReviewSample(runs));
  87. scanForLeaks(outputRoot, credentials.secrets);
  88. if (options.failOnGates && !aggregate.acceptance.overallPass) {
  89. process.exitCode = 2;
  90. }
  91. console.log(redactSecrets(JSON.stringify({
  92. outputRoot,
  93. liveEnabled,
  94. fixtures: activeFixtures.length,
  95. variants: variants.length,
  96. runs: runs.length,
  97. failures: failures.length,
  98. gatePass: aggregate.acceptance.overallPass,
  99. aggregateReport: path.join(outputRoot, 'aggregate-report.md')
  100. }, null, 2)));
  101. }
  102. function readCredentials() {
  103. const runtimeCredential = process.env.TIHAO_SESSION_TOKEN || process.env.VOC_ECOMMERCE_TOKEN || process.env.VOC_TOKEN || '';
  104. const company = process.env.TIHAO_COMPANY || process.env.VOC_ECOMMERCE_COMPANY || process.env.COMPANY_OBJECT_ID || '';
  105. const vocSocialToken = process.env.VOC_SOCIAL_TOKEN || runtimeCredential;
  106. const videoToken = process.env.VIDEO_ANALYSIS_TOKEN || process.env.DOUBAO_VISION_TOKEN || '';
  107. return {
  108. runtimeCredential,
  109. company,
  110. vocSocialToken,
  111. videoToken,
  112. videoBaseUrl: process.env.VIDEO_ANALYSIS_BASE_URL || process.env.DOUBAO_VISION_BASE_URL || 'https://api.fmode.cn',
  113. videoModel: process.env.VIDEO_ANALYSIS_MODEL || process.env.DOUBAO_VISION_MODEL || 'doubao-seed-2-0-pro',
  114. secrets: [runtimeCredential, vocSocialToken, videoToken].filter(Boolean)
  115. };
  116. }
  117. function readRunOptions({ liveEnabled, credentials }) {
  118. const videoAvailable = Boolean(liveEnabled && credentials.vocSocialToken && credentials.videoToken);
  119. return {
  120. maxRuns: intEnv('TIHAO_OVERNIGHT_MAX_RUNS', 0),
  121. delayMs: intEnv('TIHAO_OVERNIGHT_DELAY_MS', liveEnabled ? 1500 : 0),
  122. runRetries: intEnv('TIHAO_OVERNIGHT_RUN_RETRIES', liveEnabled ? 1 : 0),
  123. resume: process.env.TIHAO_OVERNIGHT_RESUME === 'true',
  124. failOnGates: process.env.TIHAO_OVERNIGHT_FAIL_ON_GATES === 'true',
  125. gates: {
  126. minEvidenceCoverage: numberEnv('TIHAO_GATE_MIN_EVIDENCE_COVERAGE', videoAvailable ? 0.8 : 0),
  127. maxNegativeRiskRate: numberEnv('TIHAO_GATE_MAX_NEGATIVE_RISK_RATE', numberEnv('TIHAO_GATE_MAX_OFF_TOPIC_RATE', 0.1)),
  128. maxScoreDrop: numberEnv('TIHAO_GATE_MAX_SCORE_DROP', liveEnabled ? 1 : 999),
  129. requireLiveRecall: process.env.TIHAO_GATE_REQUIRE_LIVE_RECALL === 'false' ? false : liveEnabled,
  130. requireReferenceProvider: process.env.TIHAO_GATE_REQUIRE_REFERENCE_PROVIDER === 'true',
  131. requireHomepageProvider: process.env.TIHAO_GATE_REQUIRE_HOMEPAGE_PROVIDER === 'true',
  132. requireResultFirstNoStrongDrop: process.env.TIHAO_GATE_REQUIRE_NO_STRONG_DROP === 'false' ? false : true
  133. }
  134. };
  135. }
  136. function publicRunOptions(options) {
  137. return {
  138. maxRuns: options.maxRuns,
  139. delayMs: options.delayMs,
  140. runRetries: options.runRetries,
  141. resume: options.resume,
  142. failOnGates: options.failOnGates,
  143. gates: options.gates
  144. };
  145. }
  146. async function runSourcingWithRetries(input, retries) {
  147. let lastResult = null;
  148. for (let attempt = 0; attempt <= retries; attempt++) {
  149. lastResult = await runTihaoSourcing(input);
  150. if (!shouldRetryRun(lastResult) || attempt === retries) return lastResult;
  151. await sleep(1000 * (attempt + 1));
  152. }
  153. return lastResult;
  154. }
  155. function shouldRetryRun(result) {
  156. return result && ['live_network_error'].includes(result.status);
  157. }
  158. function buildVariants({ liveEnabled, credentials }) {
  159. const mode = liveEnabled ? 'live' : 'sample';
  160. const canUseVideo = Boolean(liveEnabled && credentials.vocSocialToken && credentials.videoToken);
  161. const canUseReference = Boolean(liveEnabled && credentials.vocSocialToken);
  162. return [
  163. {
  164. id: 'baseline-live',
  165. gateRole: 'baseline',
  166. description: liveEnabled ? 'Brief-only low-volume live baseline' : 'Sample-mode baseline',
  167. input: { collectionMode: mode, keywordLimit: 1, pagesPerKeyword: 1 }
  168. },
  169. {
  170. id: 'reference-account',
  171. gateRole: 'release',
  172. description: 'Reference account/link enrichment and similarity-aware recall',
  173. input: {
  174. collectionMode: mode,
  175. keywordLimit: liveEnabled ? 2 : 1,
  176. pagesPerKeyword: 1,
  177. enableReferenceEnrichment: true,
  178. enableVocSocialReferenceEnrichment: canUseReference,
  179. useVocSocialReferenceEnrichment: canUseReference,
  180. referencePostsLimit: 20
  181. }
  182. },
  183. {
  184. id: 'homepage-evidence',
  185. gateRole: 'release',
  186. description: 'Recent homepage content evidence and quality scoring',
  187. input: {
  188. collectionMode: mode,
  189. keywordLimit: liveEnabled ? 2 : 1,
  190. pagesPerKeyword: 1,
  191. homepageEvidenceLimit: 20,
  192. recentContentsLimit: 20,
  193. homepageEvidenceCreatorsLimit: liveEnabled ? 20 : 8
  194. }
  195. },
  196. {
  197. id: 'video-enhanced',
  198. gateRole: 'diagnostic',
  199. description: 'Reference video enrichment with bounded evidence analysis',
  200. input: {
  201. collectionMode: mode,
  202. keywordLimit: liveEnabled ? 2 : 1,
  203. pagesPerKeyword: 1,
  204. evidenceCreatorsLimit: liveEnabled ? 10 : 6,
  205. resultFirstMode: liveEnabled,
  206. optimizeForResults: liveEnabled,
  207. enableVocSocialReferenceEnrichment: canUseVideo,
  208. videoEnabled: canUseVideo
  209. }
  210. },
  211. {
  212. id: 'result-first',
  213. gateRole: 'release',
  214. description: 'Quality-first broad recall and batched evidence',
  215. input: {
  216. collectionMode: mode,
  217. keywordLimit: liveEnabled ? 2 : 1,
  218. pagesPerKeyword: 1,
  219. evidenceCreatorsLimit: liveEnabled ? 10 : 6,
  220. resultFirstMode: liveEnabled,
  221. optimizeForResults: liveEnabled,
  222. enableVocSocialReferenceEnrichment: canUseVideo,
  223. videoEnabled: canUseVideo
  224. }
  225. },
  226. {
  227. id: 'result-first-risk',
  228. gateRole: 'diagnostic',
  229. description: 'Quality-first mode with evidence risk visibility',
  230. input: {
  231. collectionMode: mode,
  232. keywordLimit: liveEnabled ? 2 : 1,
  233. pagesPerKeyword: 1,
  234. evidenceCreatorsLimit: liveEnabled ? 10 : 6,
  235. resultFirstMode: liveEnabled,
  236. optimizeForResults: liveEnabled,
  237. evidenceBatchSize: 5,
  238. enableVocSocialReferenceEnrichment: canUseVideo,
  239. videoEnabled: canUseVideo
  240. }
  241. },
  242. {
  243. id: 'result-first-broad',
  244. gateRole: 'release',
  245. description: 'Quality-first mode with broader live recall',
  246. input: {
  247. collectionMode: mode,
  248. keywordLimit: liveEnabled ? 4 : 1,
  249. pagesPerKeyword: liveEnabled ? 2 : 1,
  250. resultFirstMode: liveEnabled,
  251. optimizeForResults: liveEnabled,
  252. evidenceBatchSize: 5,
  253. enableVocSocialReferenceEnrichment: canUseVideo,
  254. videoEnabled: canUseVideo
  255. }
  256. }
  257. ];
  258. }
  259. function buildRunInput({ fixture, variant, runDir, credentials }) {
  260. const input = {
  261. ...variant.input,
  262. ...(fixture.input || {}),
  263. briefText: fixture.briefText || '',
  264. referenceLinks: fixture.referenceLinks || [],
  265. targetCount: fixture.expected?.targetCount,
  266. output: runDir
  267. };
  268. if (variant.input.collectionMode === 'live') {
  269. input.tihaoToken = credentials.runtimeCredential;
  270. input.company = credentials.company;
  271. }
  272. if (variant.input.videoEnabled) {
  273. input.vocSocialToken = credentials.vocSocialToken;
  274. input.videoAnalysisBaseUrl = credentials.videoBaseUrl;
  275. input.videoAnalysisModel = credentials.videoModel;
  276. input.videoAnalysisToken = credentials.videoToken;
  277. }
  278. delete input.videoEnabled;
  279. return input;
  280. }
  281. function summarizeRun({ fixture, variant, result, runDir, durationMs }) {
  282. const data = result.data || {};
  283. const criteria = data.criteria || {};
  284. const candidates = data.candidates || [];
  285. const top10 = candidates.slice(0, 10);
  286. const recallRecords = criteria.recallRecords || [];
  287. const offTopic = top10.filter(item => hasOffTopicHit(item, fixture.expected?.offTopicTerms || []));
  288. const negativeRiskRate = ratio(offTopic.length, Math.max(top10.length, 1));
  289. const evidenceHitCount = top10.filter(item => item.evidenceCard || (item.evidenceSignals || []).length).length;
  290. const softwareTable = inspectSoftwareTable(path.join(runDir, 'tihao-sourcing-client-list.csv'));
  291. return {
  292. briefId: fixture.id,
  293. briefName: fixture.name,
  294. variant: variant.id,
  295. gateRole: variant.gateRole || 'release',
  296. status: result.status,
  297. durationMs,
  298. recallRequestCount: recallRecords.length,
  299. liveRecallCount: recallRecords.reduce((sum, item) => sum + Number(item.normalizedCount || 0), 0),
  300. uniqueCandidateCount: candidates.length,
  301. clientListCount: candidates.length,
  302. strongCount: countStatus(candidates, STATUS_STRONG),
  303. backupCount: countStatus(candidates, STATUS_BACKUP),
  304. reviewCount: countStatus(candidates, STATUS_REVIEW),
  305. excludedCount: countStatus(candidates, STATUS_EXCLUDED),
  306. top10EvidenceCoverage: ratio(evidenceHitCount, Math.max(top10.length, 1)),
  307. avgTop10BriefFitScore: avg(top10.map(item => item.briefFitScore)),
  308. avgTop10ReferenceStyleFitScore: avg(top10.map(item => item.referenceStyleFitScore || item.referenceSimilarity)),
  309. avgTop10TotalScore: avg(top10.map(item => item.score)),
  310. avgEvidenceBoost: avg(top10.map(item => item.evidenceScoreBoost)),
  311. avgEvidenceRiskPenalty: avg(top10.map(item => item.evidenceRiskPenalty)),
  312. offTopicRate: negativeRiskRate,
  313. negativeRiskRate,
  314. missingKeyConditionRate: ratio(top10.filter(item => (item.missingKeyConditions || []).length).length, Math.max(top10.length, 1)),
  315. softwareTable,
  316. providerStatus: {
  317. reference: criteria.referenceEvidenceStatus || {},
  318. fingerprint: criteria.referenceFingerprintStatus || {},
  319. evidence: criteria.evidenceStatus || {},
  320. homepage: criteria.homepageEvidenceStatus || {}
  321. },
  322. top10: top10.map(item => ({
  323. rank: item.rank,
  324. platform: item.platform,
  325. displayName: item.displayName,
  326. recommendStatus: item.recommendStatus,
  327. score: item.score,
  328. briefFitScore: item.briefFitScore,
  329. referenceStyleFitScore: item.referenceStyleFitScore || item.referenceSimilarity || 0,
  330. recentContentFitScore: item.recentContentFitScore || 0,
  331. visualQualityScore: item.visualQualityScore || 0,
  332. toneConsistencyScore: item.toneConsistencyScore || 0,
  333. evidenceScoreBoost: item.evidenceScoreBoost || 0,
  334. evidenceRiskPenalty: item.evidenceRiskPenalty || 0,
  335. homepageEvidenceHitPoints: (item.homepageEvidenceHitPoints || []).slice(0, 5),
  336. referenceStyleHitPoints: (item.referenceStyleHitPoints || []).slice(0, 5),
  337. referenceFallbackHitPoints: (item.referenceFallbackHitPoints || []).slice(0, 5),
  338. referenceEvidenceSource: item.referenceEvidenceSource || '',
  339. referenceEvidenceConcrete: Boolean(item.referenceEvidenceConcrete),
  340. reason: item.recommendReason,
  341. riskNote: item.riskNote,
  342. profileUrl: item.profileUrl || '',
  343. offTopicHits: findOffTopicHits(item, fixture.expected?.offTopicTerms || [])
  344. })),
  345. warnings: result.warnings || [],
  346. files: result.files || listRunFiles(runDir)
  347. };
  348. }
  349. function buildAggregate({ manifest, runs, failures, fixtures, variants, options }) {
  350. const byBrief = fixtures.map(fixture => {
  351. const fixtureRuns = runs.filter(item => item.briefId === fixture.id);
  352. const baseline = fixtureRuns.find(item => item.variant === 'baseline-live');
  353. const best = fixtureRuns.slice().sort((a, b) => qualityScore(b, baseline) - qualityScore(a, baseline))[0] || null;
  354. return {
  355. briefId: fixture.id,
  356. briefName: fixture.name,
  357. baseline,
  358. best,
  359. runs: fixtureRuns.map(item => ({
  360. variant: item.variant,
  361. gateRole: item.gateRole || 'release',
  362. status: item.status,
  363. strongCount: item.strongCount,
  364. uniqueCandidateCount: item.uniqueCandidateCount,
  365. liveRecallCount: item.liveRecallCount,
  366. top10EvidenceCoverage: item.top10EvidenceCoverage,
  367. avgTop10TotalScore: item.avgTop10TotalScore,
  368. avgTop10ReferenceStyleFitScore: item.avgTop10ReferenceStyleFitScore,
  369. offTopicRate: item.offTopicRate,
  370. negativeRiskRate: item.negativeRiskRate ?? item.offTopicRate,
  371. softwareDuplicateKeyCount: item.softwareTable?.duplicateKeyCount ?? 0,
  372. softwareRankContinuous: Boolean(item.softwareTable?.rankContinuous),
  373. referenceProviderStatus: item.providerStatus?.reference?.status || item.providerStatus?.reference?.providerStatus || 'missing',
  374. homepageProviderStatus: item.providerStatus?.homepage?.status || 'missing',
  375. gates: evaluateRunGates({ run: item, baseline, manifest, options })
  376. }))
  377. };
  378. });
  379. return {
  380. manifest,
  381. finishedAt: new Date().toISOString(),
  382. runCount: runs.length,
  383. failureCount: failures.length,
  384. fixtureCount: fixtures.length,
  385. variantCount: variants.length,
  386. byBrief,
  387. runs,
  388. failures,
  389. acceptance: evaluateAggregate({ byBrief, failures, manifest, options })
  390. };
  391. }
  392. function evaluateAggregate({ byBrief, failures, manifest, options }) {
  393. const comparable = byBrief.filter(item => item.baseline && item.best);
  394. const resultFirstWins = comparable.filter(item => item.best.variant !== 'baseline-live' && item.best.strongCount >= item.baseline.strongCount).length;
  395. const gates = byBrief.flatMap(brief => brief.runs.flatMap(run => run.gates || []));
  396. const failedGates = gates.filter(gate => gate.status === 'fail' && gate.gateRole !== 'diagnostic');
  397. const warningGates = gates.filter(gate => gate.status === 'warn');
  398. const diagnosticFailures = gates.filter(gate => gate.status === 'fail' && gate.gateRole === 'diagnostic');
  399. const releaseBriefs = byBrief.map(brief => {
  400. const releaseRuns = brief.runs.filter(run => run.gateRole === 'release');
  401. const bestRelease = releaseRuns.slice().sort((a, b) => runGateScore(b) - runGateScore(a))[0] || null;
  402. return {
  403. briefId: brief.briefId,
  404. bestReleaseVariant: bestRelease?.variant || '',
  405. bestReleasePass: bestRelease ? !(bestRelease.gates || []).some(gate => gate.status === 'fail') : false
  406. };
  407. });
  408. const releaseCoveragePass = releaseBriefs.every(item => item.bestReleasePass);
  409. return {
  410. noUnhandledFailures: failures.length === 0,
  411. comparableBriefs: comparable.length,
  412. resultFirstWinRate: ratio(resultFirstWins, Math.max(comparable.length, 1)),
  413. overallPass: failures.length === 0 && failedGates.length === 0 && releaseCoveragePass,
  414. releaseCoveragePass,
  415. releaseBriefs,
  416. failedGateCount: failedGates.length,
  417. warningGateCount: warningGates.length + diagnosticFailures.length,
  418. gates: {
  419. liveEnabled: manifest.liveEnabled,
  420. videoAvailable: manifest.videoAvailable,
  421. thresholds: options.gates,
  422. failed: failedGates,
  423. warnings: [...warningGates, ...diagnosticFailures.map(gate => ({ ...gate, status: 'warn', originalStatus: 'fail' }))]
  424. },
  425. tokenLeak: false,
  426. notes: ['Automated quality gates are directional; business review sample remains required before release decisions.']
  427. };
  428. }
  429. function evaluateRunGates({ run, baseline, manifest, options }) {
  430. if (!run) return [];
  431. const gates = [];
  432. const isResultFirst = /^result-first/.test(run.variant) || run.variant === 'video-enhanced';
  433. if (options.gates.requireLiveRecall && run.variant !== 'baseline-live') {
  434. gates.push(makeGate({
  435. name: 'live-recall',
  436. status: run.liveRecallCount > 0 ? 'pass' : 'fail',
  437. value: run.liveRecallCount,
  438. target: '> 0',
  439. note: 'Live variants must return real recalled candidates.'
  440. }));
  441. }
  442. if (manifest.videoAvailable && run.variant !== 'baseline-live') {
  443. gates.push(makeGate({
  444. name: 'evidence-coverage',
  445. status: run.top10EvidenceCoverage >= options.gates.minEvidenceCoverage ? 'pass' : 'fail',
  446. value: run.top10EvidenceCoverage,
  447. target: `>= ${options.gates.minEvidenceCoverage}`,
  448. note: 'Video/evidence-enabled runs should cover most top candidates.'
  449. }));
  450. }
  451. gates.push(makeGate({
  452. name: 'negative-risk-rate',
  453. status: negativeRiskRateOf(run) <= options.gates.maxNegativeRiskRate ? 'pass' : 'fail',
  454. value: negativeRiskRateOf(run),
  455. target: `<= ${options.gates.maxNegativeRiskRate}`,
  456. note: 'Top list must avoid negative samples such as off-brief, tone mismatch, weak homepage quality, or unlike reference accounts.'
  457. }));
  458. gates.push(makeGate({
  459. name: 'software-table-dedup',
  460. status: (run.softwareTable?.duplicateKeyCount || 0) === 0 ? 'pass' : 'fail',
  461. value: run.softwareTable?.duplicateKeyCount || 0,
  462. target: '= 0',
  463. note: 'Software handoff table must not contain duplicate creator keys.'
  464. }));
  465. gates.push(makeGate({
  466. name: 'software-rank-continuous',
  467. status: run.softwareTable?.rankContinuous ? 'pass' : 'fail',
  468. value: run.softwareTable?.rankSequence || '',
  469. target: '1..n',
  470. note: 'Software handoff table ranks must be continuous after dedupe and exclusion.'
  471. }));
  472. gates.push(makeGate({
  473. name: 'homepage-evidence-status',
  474. status: run.providerStatus?.homepage?.status ? 'pass' : 'fail',
  475. value: run.providerStatus?.homepage?.status || 'missing',
  476. target: 'present',
  477. note: 'Each run must expose homepage evidence provider or fallback status.'
  478. }));
  479. if (options.gates.requireReferenceProvider && run.variant === 'reference-account') {
  480. const referenceStatus = run.providerStatus?.reference?.status || run.providerStatus?.reference?.providerStatus || 'missing';
  481. gates.push(makeGate({
  482. name: 'reference-provider-ok',
  483. status: referenceStatus === 'ok' ? 'pass' : 'fail',
  484. value: referenceStatus,
  485. target: 'ok',
  486. note: 'reference-account strategy must prove real reference enrichment when strict provider gate is enabled.'
  487. }));
  488. }
  489. if (options.gates.requireHomepageProvider && run.variant === 'homepage-evidence') {
  490. const homepageStatus = run.providerStatus?.homepage?.status || 'missing';
  491. gates.push(makeGate({
  492. name: 'homepage-provider-ok',
  493. status: homepageStatus === 'ok' ? 'pass' : 'fail',
  494. value: homepageStatus,
  495. target: 'ok',
  496. note: 'homepage-evidence strategy must prove real recent homepage evidence when strict provider gate is enabled.'
  497. }));
  498. }
  499. if (baseline && isResultFirst) {
  500. if (options.gates.requireResultFirstNoStrongDrop) {
  501. gates.push(makeGate({
  502. name: 'strong-count-not-degraded',
  503. status: run.strongCount >= baseline.strongCount ? 'pass' : 'fail',
  504. value: run.strongCount,
  505. target: `>= baseline ${baseline.strongCount}`,
  506. note: 'Optimization variants must not lose strong candidates versus baseline.'
  507. }));
  508. }
  509. gates.push(makeGate({
  510. name: 'top10-score-not-degraded',
  511. status: run.avgTop10TotalScore >= baseline.avgTop10TotalScore - options.gates.maxScoreDrop ? 'pass' : 'fail',
  512. value: run.avgTop10TotalScore,
  513. target: `>= baseline ${round(baseline.avgTop10TotalScore - options.gates.maxScoreDrop)}`,
  514. note: 'Optimization variants must not buy evidence at the cost of lower list quality.'
  515. }));
  516. }
  517. return gates.map(gate => ({ ...gate, briefId: run.briefId, variant: run.variant, gateRole: run.gateRole || 'release' }));
  518. }
  519. function runGateScore(run) {
  520. if (!run) return -Infinity;
  521. const failCount = (run.gates || []).filter(gate => gate.status === 'fail').length;
  522. return run.strongCount * 100 +
  523. run.avgTop10TotalScore * 2 +
  524. run.top10EvidenceCoverage * 20 -
  525. failCount * 1000;
  526. }
  527. function makeGate({ name, status, value, target, note }) {
  528. return { name, status, value, target, note };
  529. }
  530. function renderAggregateReport(aggregate) {
  531. const lines = [
  532. '# 提号 overnight 质量验证报告',
  533. '',
  534. `- 开始时间:${aggregate.manifest.startedAt}`,
  535. `- 结束时间:${aggregate.finishedAt}`,
  536. `- 是否 live:${aggregate.manifest.liveEnabled}`,
  537. `- 运行次数:${aggregate.runCount}`,
  538. `- 程序失败数:${aggregate.failureCount}`,
  539. `- 总门禁是否通过:${aggregate.acceptance.overallPass}`,
  540. `- 发布策略覆盖是否通过:${aggregate.acceptance.releaseCoveragePass}`,
  541. `- 发布门禁失败数:${aggregate.acceptance.failedGateCount}`,
  542. `- 诊断 warning 数:${aggregate.acceptance.warningGateCount}`,
  543. `- result-first 胜率:${Math.round(aggregate.acceptance.resultFirstWinRate * 100)}%`,
  544. '',
  545. '## 验收门禁',
  546. '',
  547. `- 是否要求 live 召回:${aggregate.acceptance.gates.thresholds.requireLiveRecall}`,
  548. `- 最低证据覆盖:${pct(aggregate.acceptance.gates.thresholds.minEvidenceCoverage)}`,
  549. `- 最高负样本风险率:${pct(aggregate.acceptance.gates.thresholds.maxNegativeRiskRate)}`,
  550. `- top-10 均分最大允许下降:${aggregate.acceptance.gates.thresholds.maxScoreDrop}`,
  551. '',
  552. '| 状态 | 角色 | brief | 策略 | 门禁 | 当前值 | 目标 |',
  553. '| --- | --- | --- | --- | --- | ---: | --- |',
  554. ...renderGateRows(aggregate),
  555. '',
  556. '## 各 brief 最佳策略',
  557. '',
  558. '| Brief | 最佳策略 | 强推数 | 候选数 | 证据覆盖 | 平均分 | 平均参考风格分 | 负样本风险率 |',
  559. '| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |',
  560. ...aggregate.byBrief.map(item => {
  561. const best = item.best || {};
  562. return `| ${escapeCell(item.briefName)} | ${best.variant || 'n/a'} | ${best.strongCount || 0} | ${best.uniqueCandidateCount || 0} | ${pct(best.top10EvidenceCoverage)} | ${best.avgTop10TotalScore || 0} | ${best.avgTop10ReferenceStyleFitScore || 0} | ${pct(negativeRiskRateOf(best))} |`;
  563. }),
  564. '',
  565. '## 各策略指标',
  566. ''
  567. ];
  568. for (const brief of aggregate.byBrief) {
  569. lines.push(`### ${brief.briefName}`, '');
  570. lines.push('| 策略 | 角色 | 强推数 | 候选数 | 召回数 | 证据覆盖 | 平均分 | 平均参考分 | 负样本风险率 | 门禁失败数 |');
  571. lines.push('| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |');
  572. for (const run of aggregate.runs.filter(item => item.briefId === brief.briefId)) {
  573. const briefRun = brief.runs.find(item => item.variant === run.variant) || {};
  574. const failCount = (briefRun.gates || []).filter(gate => gate.status === 'fail').length;
  575. lines.push(`| ${run.variant} | ${run.gateRole || 'release'} | ${run.strongCount} | ${run.uniqueCandidateCount} | ${run.liveRecallCount} | ${pct(run.top10EvidenceCoverage)} | ${run.avgTop10TotalScore} | ${run.avgTop10ReferenceStyleFitScore} | ${pct(negativeRiskRateOf(run))} | ${failCount} |`);
  576. }
  577. lines.push('');
  578. }
  579. lines.push('## 程序失败', '');
  580. if (!aggregate.failures.length) lines.push('- 无');
  581. for (const failure of aggregate.failures) {
  582. lines.push(`- ${failure.fixtureId}/${failure.variant}: ${escapeCell(failure.message).slice(0, 240)}`);
  583. }
  584. lines.push('', '## 人工复核说明', '');
  585. lines.push('- 打开 `manual-review-sample.csv` 标注人工复核标签:可直接发客户 / 商务复核 / 可投但需补证 / 跑偏 / 硬性规则违规 / 调性不符 / 主页质感不符 / 参考账号不像。');
  586. lines.push('- 负样本必须填写“归因类型”,可选:需求解析错 / 隐性规则漏 / 召回关键词错 / 主页证据不足 / 视频证据误判 / 排序权重错 / 输出解释错 / 软件端表重复或排名不连续。');
  587. lines.push('- 有客户反馈时填写“客户选择”:客户选中 / 客户拒绝 / 待客户反馈。模型证据只用于辅助筛选,不能替代最终合规、报价和主页有效性确认。');
  588. return lines.join('\n');
  589. }
  590. function renderGateRows(aggregate) {
  591. const gates = [
  592. ...(aggregate.acceptance.gates.failed || []),
  593. ...(aggregate.acceptance.gates.warnings || [])
  594. ];
  595. if (!gates.length) return ['| n/a | n/a | n/a | n/a | n/a | 0 | n/a |'];
  596. return gates
  597. .slice(0, 80)
  598. .map(gate => `| ${gate.status} | ${escapeCell(gate.gateRole || 'release')} | ${escapeCell(gate.briefId)} | ${escapeCell(gate.variant)} | ${escapeCell(gate.name)} | ${escapeCell(gate.value)} | ${escapeCell(gate.target)} |`);
  599. }
  600. function renderAggregateReportClean(aggregate) {
  601. const lines = [
  602. '# 提号 overnight 质量验证报告',
  603. '',
  604. `- 开始时间:${aggregate.manifest.startedAt}`,
  605. `- 结束时间:${aggregate.finishedAt}`,
  606. `- 是否 live:${aggregate.manifest.liveEnabled}`,
  607. `- 运行次数:${aggregate.runCount}`,
  608. `- 程序失败数:${aggregate.failureCount}`,
  609. `- 总门禁是否通过:${aggregate.acceptance.overallPass}`,
  610. `- 发布策略覆盖是否通过:${aggregate.acceptance.releaseCoveragePass}`,
  611. `- 发布门禁失败数:${aggregate.acceptance.failedGateCount}`,
  612. `- warning 数:${aggregate.acceptance.warningGateCount}`,
  613. '',
  614. '## 新增硬验收',
  615. '',
  616. '- 软件端表重复键必须为 0。',
  617. '- 软件端表排名必须从 1 开始连续。',
  618. '- 每个 run 必须输出主页证据状态,状态可以是真实 provider,也可以是 fallback/not_requested。',
  619. '',
  620. '## 各 Brief 最佳策略',
  621. '',
  622. '| Brief | 最佳策略 | 强推荐数 | 候选数 | 软件重复键 | 排名连续 | 参考补证状态 | 主页证据状态 | 证据覆盖 | 平均分 | 负样本风险率 |',
  623. '| --- | --- | ---: | ---: | ---: | --- | --- | --- | ---: | ---: | ---: |'
  624. ];
  625. for (const item of aggregate.byBrief) {
  626. const best = item.best || {};
  627. lines.push(`| ${escapeCell(item.briefName)} | ${best.variant || 'n/a'} | ${best.strongCount || 0} | ${best.uniqueCandidateCount || 0} | ${best.softwareTable?.duplicateKeyCount ?? 0} | ${best.softwareTable?.rankContinuous ? '是' : '否'} | ${best.providerStatus?.reference?.status || best.providerStatus?.reference?.providerStatus || 'missing'} | ${best.providerStatus?.homepage?.status || 'missing'} | ${pct(best.top10EvidenceCoverage)} | ${best.avgTop10TotalScore || 0} | ${pct(negativeRiskRateOf(best))} |`);
  628. }
  629. lines.push('', '## 各策略指标', '');
  630. for (const brief of aggregate.byBrief) {
  631. lines.push(`### ${brief.briefName}`, '');
  632. lines.push('| 策略 | 角色 | 强推荐数 | 候选数 | 软件重复键 | 排名连续 | 参考补证状态 | 主页证据状态 | 召回数 | 证据覆盖 | 平均分 | 参考风格分 | 负样本风险率 | 失败门禁数 |');
  633. lines.push('| --- | --- | ---: | ---: | ---: | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |');
  634. for (const run of aggregate.runs.filter(item => item.briefId === brief.briefId)) {
  635. const briefRun = brief.runs.find(item => item.variant === run.variant) || {};
  636. const failCount = (briefRun.gates || []).filter(gate => gate.status === 'fail').length;
  637. lines.push(`| ${run.variant} | ${run.gateRole || 'release'} | ${run.strongCount} | ${run.uniqueCandidateCount} | ${run.softwareTable?.duplicateKeyCount ?? 0} | ${run.softwareTable?.rankContinuous ? '是' : '否'} | ${run.providerStatus?.reference?.status || run.providerStatus?.reference?.providerStatus || 'missing'} | ${run.providerStatus?.homepage?.status || 'missing'} | ${run.liveRecallCount} | ${pct(run.top10EvidenceCoverage)} | ${run.avgTop10TotalScore} | ${run.avgTop10ReferenceStyleFitScore} | ${pct(negativeRiskRateOf(run))} | ${failCount} |`);
  638. }
  639. lines.push('');
  640. }
  641. lines.push('## 门禁失败与警告', '');
  642. const gates = [...(aggregate.acceptance.gates.failed || []), ...(aggregate.acceptance.gates.warnings || [])];
  643. if (!gates.length) lines.push('- 无');
  644. for (const gate of gates.slice(0, 80)) {
  645. lines.push(`- [${gate.status}] ${gate.briefId}/${gate.variant}/${gate.name}: ${gate.value},目标 ${gate.target}`);
  646. }
  647. lines.push('', '## 程序失败', '');
  648. if (!aggregate.failures.length) lines.push('- 无');
  649. for (const failure of aggregate.failures) {
  650. lines.push(`- ${failure.fixtureId}/${failure.variant}: ${escapeCell(failure.message).slice(0, 240)}`);
  651. }
  652. lines.push('', '## 人工复核说明', '');
  653. lines.push('- 打开 `manual-review-sample.csv` 标注人工复核标签:可直接发客户 / 商务复核 / 可投但需补证 / 跑偏 / 硬性规则违规 / 调性不符 / 主页质感不符 / 参考账号不像。');
  654. lines.push('- 负样本必须填写“归因类型”,可选:需求解析错 / 隐性规则漏 / 召回关键词错 / 主页证据不足 / 视频证据误判 / 排序权重错 / 输出解释错 / 软件端表重复或排名不连续。');
  655. lines.push('- 有客户反馈时填写“客户选择”:客户选中 / 客户拒绝 / 待客户反馈。自动化门禁只证明结构和方向,真实客户选中率仍需要客户最终选择数据验证。');
  656. return lines.join('\n');
  657. }
  658. function buildManualReviewSample(runs) {
  659. const header = [
  660. 'brief编号',
  661. '策略',
  662. '排名',
  663. '平台',
  664. '博主名称',
  665. '综合分',
  666. 'brief匹配分',
  667. '参考风格分',
  668. '主页证据分',
  669. '视觉质感分',
  670. '调性一致分',
  671. '证据加分',
  672. '证据风险扣分',
  673. '推荐理由',
  674. '风险提示',
  675. '主页链接',
  676. '人工复核标签',
  677. '客户选择',
  678. '归因类型',
  679. '反馈原因'
  680. ];
  681. const best = new Map();
  682. for (const run of runs.filter(item => item.variant !== 'baseline-live')) {
  683. for (const item of (run.top10 || []).slice(0, 10)) {
  684. const platform = item.platform || '';
  685. const profileUrl = item.profileUrl || '';
  686. const displayName = item.displayName || '';
  687. const key = profileUrl
  688. ? `${run.briefId}|${platform}|url:${profileUrl.toLowerCase()}`
  689. : `${run.briefId}|${platform}|name:${displayName.toLowerCase()}`;
  690. const row = {
  691. briefId: run.briefId,
  692. variant: run.variant,
  693. platform,
  694. displayName,
  695. score: Number(item.score || 0),
  696. briefFitScore: Number(item.briefFitScore || 0),
  697. referenceStyleFitScore: Number(item.referenceStyleFitScore || 0),
  698. recentContentFitScore: Number(item.recentContentFitScore || 0),
  699. visualQualityScore: Number(item.visualQualityScore || 0),
  700. toneConsistencyScore: Number(item.toneConsistencyScore || 0),
  701. evidenceScoreBoost: Number(item.evidenceScoreBoost || 0),
  702. evidenceRiskPenalty: Number(item.evidenceRiskPenalty || 0),
  703. reason: item.reason || '',
  704. riskNote: item.riskNote || '',
  705. profileUrl
  706. };
  707. const previous = best.get(key);
  708. if (!previous || reviewRowPriority(row) > reviewRowPriority(previous)) best.set(key, row);
  709. }
  710. }
  711. const rows = rankReviewRowsWithinBrief([...best.values()]
  712. .sort((a, b) =>
  713. String(a.briefId).localeCompare(String(b.briefId)) ||
  714. b.score - a.score ||
  715. b.briefFitScore - a.briefFitScore ||
  716. String(a.displayName).localeCompare(String(b.displayName)))
  717. .slice(0, 60))
  718. .map((item) => [
  719. item.briefId,
  720. item.variant,
  721. item.rank,
  722. item.platform,
  723. item.displayName,
  724. item.score,
  725. item.briefFitScore,
  726. item.referenceStyleFitScore,
  727. item.recentContentFitScore,
  728. item.visualQualityScore,
  729. item.toneConsistencyScore,
  730. item.evidenceScoreBoost,
  731. item.evidenceRiskPenalty,
  732. item.reason,
  733. item.riskNote,
  734. item.profileUrl,
  735. '',
  736. '',
  737. '',
  738. ''
  739. ]);
  740. return [header.join(','), ...rows.map(row => row.map(csvCell).join(','))].join('\n');
  741. }
  742. function rankReviewRowsWithinBrief(rows) {
  743. const counters = new Map();
  744. return rows.map(row => {
  745. const briefId = String(row.briefId || '未命名brief');
  746. const next = (counters.get(briefId) || 0) + 1;
  747. counters.set(briefId, next);
  748. return { ...row, rank: next };
  749. });
  750. }
  751. function reviewRowPriority(row) {
  752. return Number(row.score || 0) * 10000 +
  753. Number(row.briefFitScore || 0) * 100 +
  754. Number(row.referenceStyleFitScore || 0);
  755. }
  756. function loadFixtures(dir) {
  757. return fs.readdirSync(dir)
  758. .filter(name => name.endsWith('.json'))
  759. .sort()
  760. .map(name => JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')));
  761. }
  762. function csvSet(value) {
  763. return new Set(String(value || '').split(',').map(item => item.trim()).filter(Boolean));
  764. }
  765. function intEnv(name, defaultValue) {
  766. const value = Number.parseInt(process.env[name] || '', 10);
  767. return Number.isFinite(value) && value >= 0 ? value : defaultValue;
  768. }
  769. function numberEnv(name, defaultValue) {
  770. const value = Number.parseFloat(process.env[name] || '');
  771. return Number.isFinite(value) ? value : defaultValue;
  772. }
  773. function qualityScore(run, baseline) {
  774. if (!run) return -Infinity;
  775. const baseScore = baseline ? baseline.avgTop10TotalScore : 0;
  776. return run.strongCount * 8 +
  777. run.avgTop10TotalScore * 2 +
  778. run.avgTop10ReferenceStyleFitScore +
  779. run.top10EvidenceCoverage * 20 -
  780. negativeRiskRateOf(run) * 40 +
  781. Math.max(0, run.avgTop10TotalScore - baseScore) * 3;
  782. }
  783. function negativeRiskRateOf(run) {
  784. if (!run) return 0;
  785. return run.negativeRiskRate ?? run.offTopicRate ?? 0;
  786. }
  787. function hasOffTopicHit(item, terms) {
  788. return findOffTopicHits(item, terms).length > 0;
  789. }
  790. function findOffTopicHits(item, terms) {
  791. const text = [
  792. item.displayName,
  793. item.recommendReason,
  794. item.riskNote,
  795. ...(item.contentTags || []),
  796. ...(item.personaTags || []),
  797. ...(item.evidenceSignals || []),
  798. ...(item.evidenceRiskHints || [])
  799. ].join(' ');
  800. return (terms || []).filter(term => term && text.includes(term));
  801. }
  802. function countStatus(candidates, status) {
  803. return candidates.filter(item => item.recommendStatus === status).length;
  804. }
  805. function avg(values) {
  806. const nums = values.map(Number).filter(Number.isFinite);
  807. return nums.length ? round(nums.reduce((sum, value) => sum + value, 0) / nums.length) : 0;
  808. }
  809. function ratio(numerator, denominator) {
  810. return denominator ? round(Number(numerator || 0) / Number(denominator)) : 0;
  811. }
  812. function round(value) {
  813. return Math.round(Number(value || 0) * 100) / 100;
  814. }
  815. function pct(value) {
  816. return `${Math.round(Number(value || 0) * 100)}%`;
  817. }
  818. function writeJson(file, value) {
  819. fs.mkdirSync(path.dirname(file), { recursive: true });
  820. fs.writeFileSync(file, JSON.stringify(value, null, 2));
  821. }
  822. function writeText(file, value) {
  823. fs.mkdirSync(path.dirname(file), { recursive: true });
  824. fs.writeFileSync(file, withExcelBom(file, value), 'utf8');
  825. }
  826. function withExcelBom(file, value) {
  827. const text = String(value ?? '');
  828. if (!/\.(csv|md)$/i.test(String(file || ''))) return text;
  829. return text.startsWith('\uFEFF') ? text : `\uFEFF${text}`;
  830. }
  831. function listRunFiles(dir) {
  832. if (!fs.existsSync(dir)) return [];
  833. return fs.readdirSync(dir).map(name => path.join(dir, name));
  834. }
  835. function inspectSoftwareTable(csvPath) {
  836. const expectedHeader = 'brief编号,策略,排名,平台,博主名称,综合分,brief匹配分,参考风格分,主页证据分,视觉质感分,调性一致分,证据加分,证据风险扣分,推荐理由,风险提示,主页链接,人工复核标签';
  837. if (!fs.existsSync(csvPath)) {
  838. return {
  839. exists: false,
  840. headerOk: false,
  841. rowCount: 0,
  842. duplicateKeyCount: 0,
  843. rankContinuous: false,
  844. rankSequence: ''
  845. };
  846. }
  847. const text = fs.readFileSync(csvPath, 'utf8').replace(/^\uFEFF/, '');
  848. const lines = text.trim().split(/\r?\n/).filter(Boolean);
  849. const rows = lines.slice(1).map(parseCsvLine);
  850. const seen = new Set();
  851. let duplicateKeyCount = 0;
  852. for (const row of rows) {
  853. const key = row[15] ? `${row[0]}|${row[3]}|${row[15]}` : `${row[0]}|${row[3]}|${row[4]}`;
  854. if (seen.has(key)) duplicateKeyCount += 1;
  855. seen.add(key);
  856. }
  857. const ranks = rows.map(row => Number(row[2]));
  858. const rankContinuous = ranksContinuousWithinBrief(rows);
  859. return {
  860. exists: true,
  861. headerOk: lines[0] === expectedHeader,
  862. rowCount: rows.length,
  863. duplicateKeyCount,
  864. rankContinuous,
  865. rankSequence: ranks.join(',')
  866. };
  867. }
  868. function ranksContinuousWithinBrief(rows) {
  869. const counters = new Map();
  870. for (const row of rows) {
  871. const briefId = row[0] || '未命名brief';
  872. const expected = (counters.get(briefId) || 0) + 1;
  873. if (Number(row[2]) !== expected) return false;
  874. counters.set(briefId, expected);
  875. }
  876. return true;
  877. }
  878. function parseCsvLine(line) {
  879. const cells = [];
  880. let current = '';
  881. let quoted = false;
  882. for (let i = 0; i < line.length; i += 1) {
  883. const char = line[i];
  884. if (char === '"' && quoted && line[i + 1] === '"') {
  885. current += '"';
  886. i += 1;
  887. } else if (char === '"') {
  888. quoted = !quoted;
  889. } else if (char === ',' && !quoted) {
  890. cells.push(current);
  891. current = '';
  892. } else {
  893. current += char;
  894. }
  895. }
  896. cells.push(current);
  897. return cells;
  898. }
  899. function scanForLeaks(root, secrets) {
  900. const realSecrets = [...new Set((secrets || []).filter(Boolean))];
  901. for (const file of listFiles(root)) {
  902. const text = fs.readFileSync(file, 'utf8');
  903. for (const secret of realSecrets) {
  904. if (text.includes(secret)) throw new Error(`Secret leaked into ${file}`);
  905. }
  906. if (/Authorization\s*:/i.test(text)) throw new Error(`Authorization header leaked into ${file}`);
  907. }
  908. }
  909. function listFiles(root) {
  910. return fs.readdirSync(root, { withFileTypes: true }).flatMap(entry => {
  911. const full = path.join(root, entry.name);
  912. return entry.isDirectory() ? listFiles(full) : [full];
  913. });
  914. }
  915. function csvCell(value) {
  916. const text = String(value ?? '');
  917. return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  918. }
  919. function escapeCell(value) {
  920. return String(value || '').replace(/\|/g, '/').replace(/\n/g, ' ');
  921. }
  922. function sleep(ms) {
  923. return new Promise(resolve => setTimeout(resolve, ms));
  924. }
  925. main().catch(error => {
  926. console.error(redactSecrets(error && error.stack ? error.stack : String(error)));
  927. process.exit(1);
  928. });