finalize-five-topic-targets-20261111.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. /** Exact current record IDs, genre-separated counts and explicit missing facts. */
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import assert from 'node:assert/strict';
  5. import {read as bridgeRead,publicApi,load,save,sha,unwrap} from './lib/research-enrichment.mjs';
  6. async function read(...args){
  7. for(let attempt=0;;attempt++){
  8. try{return await bridgeRead(...args);}
  9. catch(error){
  10. const code=error.cause?.code||error.code||error.name;
  11. if(attempt>=2||!['ECONNRESET','ETIMEDOUT','UND_ERR_SOCKET','UND_ERR_CONNECT_TIMEOUT','TimeoutError'].includes(code))throw error;
  12. console.log(JSON.stringify({stage:'contract-read-only-retry',attempt:attempt+1,code}));
  13. await new Promise(resolve=>setTimeout(resolve,1000));
  14. }
  15. }
  16. }
  17. const work='work/five-topic-bulk-20261111';
  18. const names=['five-topics-public-01','five-topics-public-02','five-topics-public-03',
  19. 'five-topics-multisource-20261111-01','five-topics-bulk-20261111-01'];
  20. const topics=['wellness','student','premium','stockpile','novelty'];
  21. const labels=['中式养生','学生(宿舍与夜宵)','高端升级(中产)','居家囤货','潮流尝鲜'];
  22. const records=new Map();
  23. const sourceIntegrities=load(path.join(work,'local-source-integrity.json'));
  24. for(const name of names){
  25. const directory=path.join('work/database-enrichment-20261110/batches',name);
  26. const applied=unwrap(load(path.join(directory,'apply-result.json'))),receipt=load(path.join(directory,'receipt.json'));
  27. assert(applied.ok&&applied.applied&&applied.allRowsVerified&&applied.archiveSha256===receipt.archiveSha256);
  28. assert.equal(sha(fs.readFileSync(path.join(directory,'transport.zip'))),receipt.archiveSha256);
  29. for(const source of load(path.join(directory,'batch.json')).sources){
  30. const file=path.join(directory,source.path),adapter=load(file),r=adapter.record;
  31. assert.equal(sha(fs.readFileSync(file)),source.sha256);
  32. assert.equal(sha(fs.readFileSync(path.join(directory,adapter.original.path))),adapter.original.sha256);
  33. const isDoc=!!r.documentId,id=isDoc?r.documentId:source.platform+':'+r.postId;
  34. const text=isDoc?r.fullText:r.content;
  35. assert(text?.trim()&&(isDoc?r.sourceUrl:r.url)&&(isDoc?r.issuedDate:r.publishedAt));
  36. const type=isDoc?'official':['smzdm','sspai'].includes(source.platform)?'community':'industry';
  37. assert(!records.has(id),'DUPLICATE_SOURCE_ID:'+id);
  38. records.set(id,{entity:isDoc?'documents':'social',id,title:r.title,url:isDoc?r.sourceUrl:r.url,
  39. publishedAtRaw:isDoc?r.issuedDate:r.publishedAt,body:text,bodySha256:sha(Buffer.from(text)),type,
  40. topics:adapter.researchTopics,limits:adapter.limits,sourceArchiveSha256:receipt.archiveSha256,
  41. adapterSha256:source.sha256,originalSha256:adapter.original.sha256,batch:name});
  42. }
  43. }
  44. // These are additional uses of existing full originals, not newly collected posts.
  45. for(const [id,phrase] of [['smzdm:a6p37nn0','想换个口味'],['smzdm:a6p3l48e','另外一个味道试一试']]){
  46. const row=records.get(id),passage=row.body.split('\n').find(p=>p.includes(phrase));assert(passage);
  47. if(!row.topics.some(t=>t.topic==='novelty'))row.topics.push({topic:'novelty',relation:'首次换口味及菌菇风味兑现体验',passages:[passage]});
  48. }
  49. for(const row of records.values())for(const t of row.topics){
  50. assert(topics.includes(t.topic)&&t.passages.length);
  51. for(const q of t.passages)assert(row.body.includes(q),'QUOTE_NOT_IN_BODY:'+row.id);
  52. }
  53. const contract=await read('contract'),generation=contract.generation;
  54. save(path.join(work,'target-contract-current.json'),contract);
  55. const candidates=load(path.join(work,'product-candidates.json')).topics;
  56. const oldAudit=load(path.join(work,'product-target-audit.json'));
  57. const oldChecks=new Map(oldAudit.checks.map(r=>[r.id,r]));
  58. const selected=Object.fromEntries(topics.map(t=>[t,candidates[t]
  59. .filter(r=>r.brandCode&&r.brandCode!=='unknown'&&r.shopId&&r.shopName)
  60. .sort((a,b)=>(b.existingReviewCount>0?20:0)+(oldChecks.get(b.id)?.essentialFieldsPresent?8:0)+b.score-
  61. (a.existingReviewCount>0?20:0)-(oldChecks.get(a.id)?.essentialFieldsPresent?8:0)-a.score)
  62. .slice(0,100)]));
  63. for(const t of topics)assert.equal(selected[t].length,100,'NOT_ENOUGH_REAL_PRODUCTS:'+t);
  64. const products=[...new Map(Object.values(selected).flat().map(r=>[r.id,r])).values()];
  65. const requests=[...products.map(r=>({entity:'products',id:r.id,row:r})),...Array.from(records.values()).map(r=>({entity:r.entity,id:r.id,row:r}))];
  66. const cache=path.join(work,'target-current-details');fs.mkdirSync(cache,{recursive:true});
  67. const checks=new Map();let next=0,done=0;
  68. async function fetchDetail(entity,id){
  69. const filename=path.join(cache,sha(Buffer.from(entity+':'+id)).slice(0,24)+'.json');
  70. if(fs.existsSync(filename)){const d=load(filename);if(d.generation===generation&&d.ok)return d;}
  71. for(let attempt=0;;attempt++){
  72. try{const d=await publicApi({op:'detail',entity,id,generation});save(filename,d);return d;}
  73. catch(error){if(attempt>=2)throw error;await new Promise(resolve=>setTimeout(resolve,1000));}
  74. }
  75. }
  76. await Promise.all(Array.from({length:2},async()=>{
  77. while(next<requests.length){
  78. const item=requests[next++],data=await fetchDetail(item.entity,item.id),r=data.record;
  79. assert.equal(data.generation,generation);assert.equal(r.id,item.id);assert(r.source?.assetSha256&&r.source?.archiveSha256);
  80. if(item.entity==='products'){
  81. const c=item.row;assert.equal(r.title,c.title);assert.equal(r.url,c.url);assert.equal(r.shop_id,c.shopId);assert.equal(r.brand_code,c.brandCode);
  82. const missing=[];
  83. if(!r.specs?.some(s=>s.verification==='source_attribute'&&s.spec_text?.trim()))missing.push('sourceVerifiedSpec');
  84. if(!r.specs?.some(s=>s.verification==='source_attribute'&&s.net_weight_g>0))missing.push('verifiedNetWeight');
  85. const reviews=data.relations.find(x=>x.entity==='reviews')?.count??null;
  86. if(reviews===null)missing.push('reviewRelationUnavailable');else if(reviews===0)missing.push('reviewContent');
  87. if(!data.relations.some(x=>x.entity==='prices'&&x.count>0))missing.push('priceObservation');
  88. if(!c.imageMetadataPresent)missing.push('imageMetadata');
  89. checks.set(item.entity+':'+item.id,{id:r.id,title:r.title,url:r.url,brandCode:r.brand_code,shopId:r.shop_id,
  90. shopName:r.shop_name,sourceRecordId:r.source_record_id,sourceSha256:r.source.assetSha256,
  91. relevanceBasis:'标题中的明确汤底、食材、产品或场景词;不证明功效或购买者身份',titleTerms:c.titleTerms,
  92. basicIdentityAndSourceVerified:true,missing,reviewRecords:reviews,sourceSpecRows:r.specs.filter(s=>s.verification==='source_attribute').length,
  93. complete:missing.length===0});
  94. }else{
  95. const wanted=item.row,body=item.entity==='social'?r.content:r.full_text;
  96. assert.equal(body,wanted.body,'BODY_MISMATCH:'+item.id);
  97. assert.equal(r.title,wanted.title);assert.equal(item.entity==='social'?r.url:r.source_url,wanted.url);
  98. assert.equal(item.entity==='social'?r.published_at_raw:r.issued_date,wanted.publishedAtRaw);
  99. // Transport ZIP and the server-built source archive have distinct hashes.
  100. // Compare the actual normalized payload and source asset against staging.
  101. const expectedSource=sourceIntegrities[item.entity+':'+item.id];assert(expectedSource);
  102. assert.equal(r.source.payload_sha256,expectedSource.payloadSha256,'SOURCE_PAYLOAD_SHA_MISMATCH:'+item.id);
  103. assert.equal(r.source.assetSha256,expectedSource.assetSha256,'SOURCE_ASSET_SHA_MISMATCH:'+item.id);
  104. if(item.entity==='social')assert.equal(r.fulltext_available,1);
  105. else assert.equal(r.attachment_hash_state,'matched');
  106. checks.set(item.entity+':'+item.id,{id:item.id,entity:item.entity,bodyVerified:true,sourcePayloadAndAssetShaVerified:true,
  107. publishedSourceArchiveSha256:r.source.archiveSha256,
  108. originalBytesShaVerified:true,sourceRecordId:r.source_record_id,sourceSha256:r.source.assetSha256});
  109. }
  110. done++;if(done%25===0){save(path.join(work,'target-verification-progress.json'),{generation,done,target:requests.length});console.log(JSON.stringify({verified:done,target:requests.length}));}
  111. }
  112. }));
  113. const result={schema:'ksf.five-topic.minimum-targets.v2',generation,checkedAt:new Date().toISOString(),
  114. minimum:{products:100,social:10,other:5},quantityCriteria:'不同在库商品ID;社媒只计社区原文,其他计行业研究与官方资料,不跨体裁重复计数',
  115. sourceDocuments:records.size,uniqueProducts:products.length,
  116. newCollection:{products:0,community:5,industryResearch:20},topics:{},
  117. limits:['这是已经入库并逐条回读的课题资料清单,不是搜索结果数量。',
  118. '每课题100件商品为已有在库商品整理,跨课题关联允许;全局商品总数不因整理增加。',
  119. '商品缺规格、净含量、价格或评价时保留缺项,不以商品标题或运输重量补齐。',
  120. '社区体验不等于平台购买评价;行业营销数字、健康和工艺陈述不作为已验证结论。',
  121. '已保存图片元数据不等于本轮重新逐张解码验图。']};
  122. for(let i=0;i<topics.length;i++){
  123. const t=topics[i],pr=selected[t].map(p=>checks.get('products:'+p.id));
  124. const material=Array.from(records.values()).filter(r=>r.topics.some(x=>x.topic===t)).map(r=>({...r,verification:checks.get(r.entity+':'+r.id)}));
  125. const social=material.filter(r=>r.type==='community'),other=material.filter(r=>r.type!=='community');
  126. const missingFieldCounts={};for(const p of pr)for(const f of p.missing)missingFieldCounts[f]=(missingFieldCounts[f]||0)+1;
  127. result.topics[t]={name:labels[i],counts:{products:pr.length,social:social.length,other:other.length,
  128. industryResearch:other.filter(r=>r.type==='industry').length,official:other.filter(r=>r.type==='official').length,
  129. productsWithReviews:pr.filter(r=>r.reviewRecords>0).length,completeProductDossiers:pr.filter(r=>r.complete).length},
  130. quantityMinimumMet:pr.length>=100&&social.length>=10&&other.length>=5,
  131. completeProductDossiers:pr.every(r=>r.complete),missingFieldCounts,products:pr,social,other};
  132. }
  133. result.allQuantityMinimumsMet=Object.values(result.topics).every(t=>t.quantityMinimumMet);
  134. result.allProductDossiersComplete=Object.values(result.topics).every(t=>t.completeProductDossiers);
  135. result.allSourceDocumentsReadbackVerified=true;
  136. result.allRequestedWorkComplete=result.allQuantityMinimumsMet&&result.allProductDossiersComplete;
  137. const after=await read('contract');assert.equal(after.generation,generation,'GENERATION_CHANGED_DURING_TARGET_AUDIT');
  138. save(path.join(work,'topic-material-catalogue.json'),result);
  139. save(path.join(work,'product-enrichment-remaining.json'),{
  140. schema:'ksf.five-topic.product-gaps.v1',generation,checkedAt:result.checkedAt,
  141. uniqueProducts:products.length,
  142. products:products.map(p=>({...checks.get('products:'+p.id),topics:topics.filter(t=>selected[t].some(r=>r.id===p.id))})).filter(p=>p.missing.length),
  143. sourceRules:['按原商品ID和原链接核对;主评、追评和精确评价ID分别保留。',
  144. '净含量必须来自所选SKU的明确属性或包装标示,不能使用运输重量和标题推定。',
  145. '平台验证或无有效返回属于采集受限,不自动判断商品不存在。'],
  146. currentLimit:'本轮电商接口未取得有效商品响应,京东公开评价接口返回非JSON;已保存原始回执。'});
  147. const lines=['# 五课题资料核验(2026-11-11)','',
  148. '|课题|商品|社媒原文|其他资料|有评价商品|','|---|---:|---:|---:|---:|'];
  149. for(const t of Object.values(result.topics))lines.push(`|${t.name}|${t.counts.products}|${t.counts.social}|${t.counts.other}|${t.counts.productsWithReviews}|`);
  150. lines.push('',`全局去重:${products.length}件商品、${records.size}份独立资料。新增5篇社区体验、20篇行业研究,商品新增0。`,
  151. '',`数量门槛:${result.allQuantityMinimumsMet?'达到':'未全部达到'};商品完整档案:${result.allProductDossiersComplete?'通过':'未全部通过'}。`,
  152. '全部资料正文、原始字节SHA与当前代际的具体记录和来源归档均通过核对。',
  153. '商品已核基础身份、店铺、品牌、原链接和来源;净含量等缺项另列,不据标题或运输重量推算。','',`研究代际:${generation}`);
  154. fs.writeFileSync(path.join(work,'采集与完整性核验.md'),lines.join('\n'));
  155. console.log(JSON.stringify({ok:true,generation,uniqueProducts:products.length,sourceDocuments:records.size,
  156. topics:Object.fromEntries(Object.entries(result.topics).map(([k,v])=>[k,v.counts])),allQuantityMinimumsMet:result.allQuantityMinimumsMet,
  157. allProductDossiersComplete:result.allProductDossiersComplete}));