verify-five-topic-supplement.mjs 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. /** Read every supplemental article and its citation, then report exact new rows. */
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import assert from 'node:assert/strict';
  5. import {read, publicApi, load, save, sha, unwrap, checkPublication} from './lib/research-enrichment.mjs';
  6. async function retryRead(operation) {
  7. for (let attempt = 0; ; attempt++) {
  8. try { return await operation(); }
  9. catch (error) {
  10. const code = error.cause?.code || error.code || error.name;
  11. if (attempt >= 2 || !['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'TimeoutError'].includes(code)) throw error;
  12. console.log(JSON.stringify({stage: 'read-only-network-retry', attempt: attempt + 1, code}));
  13. await new Promise(resolve => setTimeout(resolve, 1200));
  14. }
  15. }
  16. }
  17. const [workArg, batchArg, ...priorBatchArgs] = process.argv.slice(2);
  18. const work = path.resolve(workArg || 'work/five-topic-collection-20261110/supplement-01');
  19. const batchName = batchArg || 'five-topics-public-02';
  20. const priorBatchNames = priorBatchArgs.length ? priorBatchArgs : ['five-topics-public-01'];
  21. assert([batchName, ...priorBatchNames].every(name => /^five-topics-public-\d+$/.test(name)));
  22. const directory = path.resolve('work/database-enrichment-20261110/batches', batchName);
  23. const expected = load(path.join(directory, 'expected-records.json'));
  24. const receipt = load(path.join(directory, 'receipt.json'));
  25. const applied = unwrap(load(path.join(directory, 'apply-result.json')));
  26. const planned = unwrap(load(path.join(directory, 'plan-result.json'))).mergePlan;
  27. const status = unwrap(load(path.join(directory, 'status-result.json')));
  28. checkPublication(applied, receipt);
  29. assert.equal(applied.generation, planned.plannedGeneration);
  30. assert(status.ok && status.publicationFound);
  31. assert.equal(status.publishedGeneration, applied.generation);
  32. assert.equal(sha(fs.readFileSync(path.join(directory, 'transport.zip'))), receipt.archiveSha256);
  33. assert.equal(applied.delta.added.social_posts, expected.length);
  34. for (const entity of ['products', 'reviews', 'specs', 'prices']) assert.equal(applied.delta.added[entity] || 0, 0);
  35. const before = load(path.join(work, 'contract-before.json'));
  36. const contract = await retryRead(() => read('contract'));
  37. if (contract.generation === applied.generation) {
  38. for (const [table, count] of Object.entries(before.counts)) {
  39. assert.equal(contract.counts[table], count + (applied.delta.added[table] || 0), 'COUNT_MISMATCH:' + table);
  40. }
  41. }
  42. const generation = contract.generation;
  43. const checks = [];
  44. let next = 0;
  45. await Promise.all(Array.from({length: 2}, async () => {
  46. while (next < expected.length) {
  47. const index = next++, row = expected[index];
  48. const checkpointPath = path.join(directory, 'verified-source-' + index + '.json');
  49. const checkpoint = fs.existsSync(checkpointPath) ? load(checkpointPath) : null;
  50. const cached = checkpoint?.record?.generation === generation && checkpoint?.source?.generation === generation;
  51. const response = cached ? checkpoint.record : await retryRead(() => read('get', {entity: 'social', id: row.id, generation}));
  52. const actual = response.record;
  53. assert.equal(actual.id, row.id);
  54. assert.equal(actual.post_id, row.postId);
  55. assert.equal(actual.fulltext_available, 1);
  56. for (const field of ['platform', 'content_scope', 'title', 'content', 'url', 'published_at_raw']) {
  57. assert.equal(actual[field], row[field], 'ARTICLE_READBACK_MISMATCH:' + row.id + ':' + field);
  58. }
  59. const source = cached ? checkpoint.source : await retryRead(() => read('source', {id: actual.source_record_id, generation}));
  60. assert.equal(source.citation.id, actual.source_record_id);
  61. assert.equal(source.sourcePayload.content, row.content);
  62. assert.equal(source.sourcePayload.id, row.postId);
  63. assert(source.citation.upstreamSources.some(item => item.sha256 === row.adapterSha256));
  64. const adapter = load(path.join(directory, row.adapterPath));
  65. assert.equal(adapter.original.sha256, row.originalSha256);
  66. assert.equal(sha(fs.readFileSync(path.join(directory, row.originalPath))), row.originalSha256);
  67. assert.deepEqual(adapter.researchTopics, row.topicEvidence);
  68. assert(row.topicEvidence.every(topic => topic.passages.every(quote => actual.content.includes(quote))));
  69. save(checkpointPath, {record: response, source,
  70. original: {...adapter.original, shaVerified: true}, topicEvidence: row.topicEvidence});
  71. checks.push({id: row.id, fieldsVerified: true, sourceVerified: true, originalSha256: row.originalSha256});
  72. save(path.join(work, 'verification-state.json'), {stage: 'get-source', completed: checks.length,
  73. target: expected.length, generation, updatedAt: new Date().toISOString()});
  74. console.log(JSON.stringify({verified: checks.length, target: expected.length, id: row.id}));
  75. }
  76. }));
  77. const previous = priorBatchNames.flatMap(name =>
  78. load(path.resolve('work/database-enrichment-20261110/batches', name, 'expected-records.json')));
  79. const allIds = [...previous, ...expected].map(row => row.id);
  80. assert.equal(new Set(allIds).size, allIds.length);
  81. const summary = await retryRead(() => publicApi({op: 'summary', generation}));
  82. assert.equal(summary.generation, generation);
  83. for (const [table, count] of Object.entries(contract.counts)) assert.equal(summary.counts[table], count);
  84. const page = await retryRead(() => publicApi({op: 'browse', entity: 'social', quality: 'audit', limit: 100,
  85. filters: {recordId: allIds}, generation}));
  86. assert.equal(page.totalMatched, allIds.length);
  87. assert.equal(page.records.length, allIds.length);
  88. assert.deepEqual(new Set(page.records.map(row => row.record_id)), new Set(allIds));
  89. assert(page.records.every(row => row.fulltext_available === 1));
  90. const names = {wellness: '中式养生', student: '学生(宿舍与夜宵)', premium: '高端升级(中产)', stockpile: '居家囤货', novelty: '潮流尝鲜'};
  91. const topics = Object.entries(names).map(([id, name]) => ({id, name,
  92. previousArticles: previous.filter(row => row.topics.includes(id)).length,
  93. newArticles: expected.filter(row => row.topics.includes(id)).length,
  94. totalArticles: [...previous, ...expected].filter(row => row.topics.includes(id)).length,
  95. newRecordIds: expected.filter(row => row.topics.includes(id)).map(row => row.id),
  96. relationCounts: expected.flatMap(row => row.topicEvidence.filter(item => item.topic === id))
  97. .reduce((result, item) => ({...result, [item.relation]: (result[item.relation] || 0) + 1}), {})}));
  98. const sourceTypes = [...previous, ...expected].reduce((result, row) => {
  99. const key = row.sourceType === 'editorial_purchase_guide' ? 'editorial_purchase_guide' :
  100. /^(smzdm|sspai):/.test(row.id) ? 'community_experience' : 'industry_reporting_or_opinion';
  101. result[key] = (result[key] || 0) + 1;
  102. return result;
  103. }, {});
  104. const publication = {ok: true, checkedAt: new Date().toISOString(), generation, batchGeneration: applied.generation,
  105. allRowsVerified: true, exactArticleObjectsVerified: checks.length, sourceCitationReadbackPassed: true,
  106. publicReadbackPassed: true, checks, summary};
  107. save(path.join(directory, 'pipeline-readback.json'), publication);
  108. const result = {schema: 'ksf.five-topic.supplement-verified.v1', checkedAt: publication.checkedAt,
  109. generation, batchGeneration: applied.generation, topics, newArticles: expected.length,
  110. previousArticles: previous.length, totalUniqueArticles: allIds.length, sourceTypes,
  111. batches: [...priorBatchNames, batchName],
  112. allRowsVerified: true, publicReadbackPassed: true, sourceCitationReadbackPassed: true,
  113. newProducts: 0, newEcommerceReviews: 0, productionCounts: contract.counts,
  114. limits: load(path.join(work, 'reviewed-selection.json')).limits,
  115. crossTopicMemberships: topics.reduce((sum, row) => sum + row.newArticles, 0)};
  116. save(path.join(work, 'verified-results.json'), result);
  117. save(path.resolve(work, '../latest-verified.json'), result);
  118. save(path.join(work, 'verification-state.json'), {stage: 'complete', completed: checks.length,
  119. target: expected.length, generation, publicReadbackPassed: true, updatedAt: publication.checkedAt});
  120. const table = ['| 课题 | 上轮已入库 | 本轮补入 | 累计关联资料 |', '|---|---:|---:|---:|',
  121. ...topics.map(row => `| ${row.name} | ${row.previousArticles} | ${row.newArticles} | ${row.totalArticles} |`)];
  122. fs.writeFileSync(path.join(work, '补采数量.md'), `# 五课题补采与入库数量\n\n核验时间:${result.checkedAt}\n\n${table.join('\n')}\n\n本轮去重新增${expected.length}篇,累计去重共${allIds.length}篇;课题间允许交叉,表内行数不能直接相加。累计社区体验${sourceTypes.community_experience}篇,行业报道或评论${sourceTypes.industry_reporting_or_opinion}篇,编辑采购指南${sourceTypes.editorial_purchase_guide || 0}篇。\n\n本轮${checks.length}/${expected.length}具体记录、来源与原件SHA校验通过;看板接口${allIds.length}/${allIds.length}均可读。新代际${applied.generation}。\n\n本轮未新增电商商品、购买评价、价格和规格。资料保留原日期、原HTML、提取定位、主题对应原句及来源。\n\n${result.limits.map(text => '- ' + text).join('\n')}\n`, 'utf8');
  123. console.log(JSON.stringify({ok: true, generation, newArticles: result.newArticles,
  124. totalUniqueArticles: result.totalUniqueArticles, sourceTypes, topics}));