verify-five-topic-multisource-20261111.mjs 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. /** Verify every new record, field, source hash and public board index. */
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import assert from 'node:assert/strict';
  5. import {read as bridgeRead,save,load,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','PUBLIC_TRANSIENT_HTTP'].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 read=(...args)=>retryRead(()=>bridgeRead(...args));
  18. const publicApi=body=>retryRead(async()=>{
  19. const r=await fetch('https://kangshifu.fmode.cn/api/library',{method:'POST',redirect:'error',
  20. headers:{'Content-Type':'application/json'},body:JSON.stringify(body),signal:AbortSignal.timeout(150000)});
  21. const raw=await r.text();
  22. let value;try{value=JSON.parse(raw);}catch{
  23. const error=Error('PUBLIC_NON_JSON_RESPONSE:'+r.status);if(r.status>=500)error.code='PUBLIC_TRANSIENT_HTTP';throw error;
  24. }
  25. if(!r.ok||!value.ok){
  26. save(path.join(work,'public-error-'+body.op+'.json'),{request:body,httpStatus:r.status,response:value,checkedAt:new Date().toISOString()});
  27. const error=Error(JSON.stringify(value.error||'PUBLIC_READBACK_FAILED'));
  28. error.code=r.status>=500?'PUBLIC_TRANSIENT_HTTP':value.error?.code;throw error;
  29. }
  30. return value;
  31. });
  32. const work=process.argv[2]||'work/five-topic-research-20261111';
  33. const directory=path.join('work/database-enrichment-20261110/batches',process.argv[3]||'five-topics-multisource-20261111-01');
  34. const expected=load(path.join(directory,'expected-records.json'));
  35. const receipt=load(path.join(directory,'receipt.json'));
  36. const applied=unwrap(load(path.join(directory,'apply-result.json')));
  37. const plan=unwrap(load(path.join(directory,'plan-result.json'))).mergePlan;
  38. const status=unwrap(load(path.join(directory,'status-result.json')));
  39. checkPublication(applied,receipt);
  40. assert.equal(applied.generation,plan.plannedGeneration);
  41. assert(status.ok&&status.publicationFound);
  42. assert.equal(status.publishedGeneration,applied.generation);
  43. assert.equal(sha(fs.readFileSync(path.join(directory,'transport.zip'))),receipt.archiveSha256);
  44. for(const [entity,table] of [['social','social_posts'],['documents','policy_documents'],['clauses','policy_clauses']]){
  45. assert.equal(applied.delta.added[table]||0,expected[entity].length);
  46. }
  47. for(const table of ['products','reviews','specs','prices'])assert.equal(applied.delta.added[table]||0,0);
  48. const before=unwrap(load(path.join(directory,'apply-contract-before.json')));
  49. const contract=await read('contract');
  50. assert.equal(contract.generation,applied.generation,'GENERATION_CHANGED_DURING_REVIEW');
  51. for(const [table,count] of Object.entries(before.counts))assert.equal(contract.counts[table],count+(applied.delta.added[table]||0));
  52. const generation=contract.generation,items=Object.values(expected).flat();
  53. const checks=[];let next=0;
  54. await Promise.all(Array.from({length:4},async()=>{
  55. while(next<items.length){
  56. const i=next++,row=items[i];
  57. const file=path.join(directory,'verified-record-'+i+'.json');
  58. const cached=fs.existsSync(file)?load(file):null;
  59. const response=cached?.record?.generation===generation?cached.record:await publicApi({op:'detail',entity:row.entity,id:row.id,generation});
  60. const actual=response.record;
  61. for(const [field,value] of Object.entries(row.record)){
  62. // Source IDs are recomputed under the published archive's namespace.
  63. if(field==='source_record_id'||field==='hash_basis')continue;
  64. assert.deepEqual(actual[field],value,'FIELD_MISMATCH:'+row.entity+':'+row.id+':'+field);
  65. }
  66. const source=cached?.source?.generation===generation?cached.source:await read('source',{id:actual.source_record_id,generation});
  67. assert(source.citation.upstreamSources.some(s=>s.sha256===row.adapterSha256),'ADAPTER_SOURCE_HASH_MISSING');
  68. const adapter=load(path.join(directory,row.adapterPath));
  69. assert.equal(sha(fs.readFileSync(path.join(directory,row.originalPath))),row.originalSha256);
  70. assert.equal(adapter.original.sha256,row.originalSha256);
  71. const text=row.entity==='social'?actual.content:row.entity==='documents'?actual.full_text:actual.excerpt;
  72. assert.equal(text,row.entity==='social'?source.sourcePayload.content:row.entity==='documents'?source.sourcePayload.full_text:source.sourcePayload.source_excerpt);
  73. if(row.entity!=='clauses')assert(row.topicEvidence.every(t=>t.passages.every(q=>text.includes(q))));
  74. if(row.entity==='documents')assert.equal(actual.attachment_hash_state,'matched');
  75. if(row.entity==='clauses')assert(adapter.record.fullText.includes(actual.excerpt));
  76. save(file,{record:response,source,originalShaVerified:true});
  77. checks.push({entity:row.entity,id:row.id,fieldsVerified:true,sourceVerified:true,originalSha256:row.originalSha256});
  78. console.log(JSON.stringify({checked:checks.length,target:items.length,entity:row.entity,id:row.id}));
  79. }
  80. }));
  81. const recentProbeFile=path.join(work,'public-summary-probe.json');
  82. const recentProbe=fs.existsSync(recentProbeFile)?load(recentProbeFile):null;
  83. const summary=recentProbe?.ok&&recentProbe.generation===generation?recentProbe:await publicApi({op:'summary',generation});
  84. assert.equal(summary.generation,generation);
  85. for(const [table,count] of Object.entries(contract.counts))assert.equal(summary.counts[table],count);
  86. const publicChecks=[];
  87. for(const [entity,rows] of Object.entries(expected)){
  88. if(!rows.length)continue;
  89. const page=await publicApi({op:'browse',entity,quality:'audit',filters:{recordId:rows.map(r=>r.id)},limit:100,generation});
  90. assert.equal(page.totalMatched,rows.length);
  91. assert.deepEqual(new Set(page.records.map(r=>r.record_id)),new Set(rows.map(r=>r.id)));
  92. save(path.join(directory,'public-browse-'+entity+'.json'),page);
  93. for(const r of page.records)assert(r.preview||r.match?.text||r.title);
  94. publicChecks.push({entity,records:page.totalMatched,allIdsFound:true});
  95. }
  96. // The board's actual detail endpoint must deliver the new content too.
  97. for(const entity of ['social','documents']){
  98. const row=expected[entity][0];
  99. if(!row)continue;
  100. const detail=await publicApi({op:'detail',entity,id:row.id,generation});
  101. assert.equal(detail.record.id,row.id);
  102. assert.equal(entity==='social'?detail.record.content:detail.record.full_text,entity==='social'?row.record.content:row.record.full_text);
  103. save(path.join(directory,'public-detail-'+entity+'.json'),detail);
  104. }
  105. const reviewed=load(path.join(directory,'reviewed-source-index.json'));
  106. const sourceTypes={};for(const row of reviewed)sourceTypes[row.sourceType]=(sourceTypes[row.sourceType]||0)+1;
  107. const topicTypes=Object.fromEntries(['wellness','student','premium','stockpile','novelty'].map(t=>[t,{
  108. community:reviewed.filter(r=>r.topics.some(x=>x.topic===t)&&r.sourceType==='community_experience').length,
  109. industry:reviewed.filter(r=>r.topics.some(x=>x.topic===t)&&r.entity==='social'&&r.sourceType!=='community_experience').length,
  110. official:reviewed.filter(r=>r.topics.some(x=>x.topic===t)&&r.entity==='documents').length}]));
  111. const verified={schema:'ksf.five-topic.multisource-final.v1',ok:true,checkedAt:new Date().toISOString(),generation,
  112. allRowsVerified:applied.allRowsVerified,allExactRecordsVerified:checks.length===items.length,
  113. publicReadbackPassed:true,sourceCitationReadbackPassed:true,
  114. sourceDocuments:reviewed.length,added:{communityExperiences:sourceTypes.community_experience||0,
  115. industryArticles:reviewed.filter(r=>r.entity==='social'&&r.sourceType!=='community_experience').length,
  116. officialDocuments:expected.documents.length,documentExcerpts:expected.clauses.length,products:0,purchaseReviews:0},
  117. topicTypes,sourceTypes,checks,publicChecks,counts:contract.counts,
  118. limits:load(path.join(work,'reviewed-selection.json')).limits};
  119. save(path.join(directory,'pipeline-readback.json'),verified);
  120. save(path.join(work,'final-receipt.json'),verified);
  121. save(path.join(work,'contract-after.json'),contract);
  122. save(path.join(work,'summary-after.json'),summary);
  123. console.log(JSON.stringify({ok:true,generation,added:verified.added,topicTypes,publicReadbackPassed:true}));