research-enrichment.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import {isDeepStrictEqual} from 'node:util';
  5. import {adminHeaders, functionsUrl} from '../../server/scripts/platform-client.mjs';
  6. export const work=path.resolve('work/database-enrichment-20261110');
  7. export const load=p=>JSON.parse(fs.readFileSync(p,'utf8').replace(/^\uFEFF/,''));
  8. export function save(p,j){const temp=p+'.next';fs.writeFileSync(temp,JSON.stringify(j,null,2));fs.renameSync(temp,p);}
  9. export const sha=b=>crypto.createHash('sha256').update(b).digest('hex');
  10. export function unwrap(response){
  11. let j=response.body??response;
  12. for(let i=0;i<5&&j;i++){if(typeof j.ok==='boolean')return j;j=j.result??j.data;}
  13. throw Error('UNRECOGNIZED_BRIDGE_RESPONSE');
  14. }
  15. export async function read(command,params={}){
  16. const r=await fetch(functionsUrl,{method:'POST',headers:adminHeaders,redirect:'error',signal:AbortSignal.timeout(150000),
  17. body:JSON.stringify({path:'/ksfResearchLibrary',params:{channel:{schema:'ksf.library-channel.v1',action:'read',command,params}}})});
  18. const j=unwrap(await r.json());if(!r.ok||!j.ok)throw Error(j.error||j.errorCode||'RESEARCH_READ_FAILED');return j;
  19. }
  20. export async function publicApi(body){
  21. const r=await fetch('https://kangshifu.fmode.cn/api/library',{method:'POST',redirect:'error',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),signal:AbortSignal.timeout(150000)});
  22. const j=await r.json();if(!r.ok||!j.ok)throw Error(j.error||'PUBLIC_READBACK_FAILED');return j;
  23. }
  24. export function checkPublication(result,receipt){
  25. if(!result.ok||!result.applied||!result.allRowsVerified||result.archiveSha256!==receipt.archiveSha256||
  26. result.dataset!=='research'||result.businessRowsChanged!==0||!result.sourceArchivePreserved)
  27. throw Error('PUBLICATION_NOT_VERIFIED');
  28. if(Object.keys(result.delta?.changed||{}).length||result.delta?.mergeReview?.conflictRecords>0)
  29. throw Error('PUBLICATION_UNEXPECTED_CHANGES');
  30. }
  31. export function checkHistoricalPlan(planned,receipt){
  32. const merge=planned?.mergePlan,delta=merge?.delta;
  33. if(!planned?.ok||!merge?.ok||merge.dataset!=='research'||merge.businessRowsChanged!==0||!delta||
  34. Object.keys(delta.changed||{}).length||delta.mergeReview?.conflictRecords||
  35. (delta.added?.products||0)!==0||(delta.added?.prices||0)!==0||
  36. (delta.added?.specs||0)!==receipt.expectedProductionAddedSpecs||
  37. (delta.added?.reviews||0)!==receipt.expectedProductionAddedReviews)throw Error('HISTORICAL_PLAN_REQUIRES_REVIEW');
  38. return merge;
  39. }
  40. export function needsProductEnrichment(product,coverage){
  41. if(!coverage)return true;
  42. return Boolean(product.missingReviews&&!coverage.reviews||product.missingVerifiedSpec&&!coverage.specs||product.missingPrice&&!coverage.prices);
  43. }
  44. export async function verifyBatch(name){
  45. const directory=path.join(work,'batches',name),receipt=load(path.join(directory,'receipt.json'));
  46. if(receipt.kind==='verified_historical_completion_not_new_collection')return verifyHistoricalBatch(name);
  47. if(sha(fs.readFileSync(path.join(directory,'transport.zip')))!==receipt.archiveSha256)throw Error('SEALED_ARCHIVE_CHANGED');
  48. const result=unwrap(load(path.join(directory,'apply-result.json')));checkPublication(result,receipt);
  49. const contract=await read('contract'),summary=await publicApi({op:'summary',generation:contract.generation});
  50. if(summary.generation!==contract.generation)throw Error('PUBLIC_GENERATION_MISMATCH');
  51. // Current generation may include a later serial batch. Reconcile exact counts
  52. // against the current contract, and separately retain this batch's generation.
  53. for(const [table,count] of Object.entries(contract.counts)){
  54. if(table in summary.counts&&summary.counts[table]!==count)throw Error('PUBLIC_COUNT_MISMATCH:'+table);
  55. }
  56. const adapters=fs.readdirSync(path.join(directory,'raw')).filter(n=>/^adapter-.*\.json$/.test(n)).map(n=>load(path.join(directory,'raw',n)));
  57. const expected=adapters.flatMap(a=>a.reviews);
  58. const families=new Map();
  59. for(const a of adapters)for(const p of a.products)families.set(p.itemId,p.originRecordId.split(':')[0]);
  60. const selected=[];
  61. const add=r=>{if(r&&!selected.some(x=>x.itemId===r.itemId&&x.reviewId===r.reviewId))selected.push(r);};
  62. for(const a of adapters)add(a.reviews.find(r=>!r.isDefault)||a.reviews[0]);
  63. add(expected.find(r=>r.isDefault));add(expected.find(r=>r.appendContent&&Object.keys(r.appendContent).length));
  64. const reviewChecks=[];
  65. for(const row of selected){
  66. const productId=families.get(row.itemId)+':'+row.itemId,id=productId+':'+row.reviewId;
  67. const response=await read('get',{entity:'reviews',id,generation:contract.generation}),record=response.record;
  68. if(record.id!==id||record.product_id!==productId||record.content!==row.content||record.review_date_raw!==(row.reviewDate??null)||
  69. String(record.review_id)!==row.reviewId||!isDeepStrictEqual(JSON.parse(record.append_content_json||'null'),row.appendContent??null))throw Error('REVIEW_FIELD_READBACK_FAILED:'+id);
  70. const source=await read('source',{id:record.source_record_id,generation:contract.generation});
  71. if(source.sourcePayload.content!==row.content||String(source.sourcePayload.review_id)!==row.reviewId||!source.citation?.assetSha256)throw Error('SOURCE_READBACK_FAILED:'+id);
  72. const adapterFile=source.sourcePayload.source_file;
  73. if(!/^raw\/adapter-[a-z_]+\.json$/.test(adapterFile||'')||
  74. !source.citation.upstreamSources?.some(s=>s.sha256===sha(fs.readFileSync(path.join(directory,adapterFile)))))throw Error('ADAPTER_SOURCE_HASH_READBACK_FAILED:'+id);
  75. const original=path.join(directory,row.origin.path);
  76. if(sha(fs.readFileSync(original))!==row.origin.sha256)throw Error('ORIGINAL_RESPONSE_HASH_MISMATCH');
  77. save(path.join(directory,'verified-review-'+reviewChecks.length+'.json'),{record:response,source,original:{...row.origin,shaVerified:true}});
  78. reviewChecks.push({id,productId,fieldsVerified:true,sourceVerified:true,originalSha256:row.origin.sha256});
  79. }
  80. const productChecks=[];
  81. for(const row of receipt.products.slice(0,2)){
  82. const record=await publicApi({op:'detail',entity:'products',id:row.id,generation:contract.generation});
  83. if(row.specs&&!record.record.specs.some(s=>s.verification==='source_attribute'))throw Error('SPEC_READBACK_FAILED');
  84. if(row.reviews){const reviews=await publicApi({op:'browse',entity:'reviews',quality:'audit',limit:1,filters:{productId:row.id},generation:contract.generation});if(!reviews.totalMatched)throw Error('REVIEW_READBACK_FAILED');}
  85. productChecks.push({productId:row.id,ok:true});
  86. }
  87. const verified={ok:true,checkedAt:new Date().toISOString(),batchGeneration:result.generation,generation:contract.generation,allRowsVerified:result.allRowsVerified,
  88. publicReadbackPassed:true,sourceCitationReadbackPassed:reviewChecks.length>0,summary,checks:productChecks,reviewChecks};
  89. save(path.join(directory,'pipeline-readback.json'),verified);return verified;
  90. }
  91. async function parallelMap(rows,fn,workers=2){
  92. let index=0;const results=new Array(rows.length);
  93. await Promise.all(Array.from({length:workers},async()=>{while(index<rows.length){const i=index++;results[i]=await fn(rows[i],i);}}));
  94. return results;
  95. }
  96. export async function verifyHistoricalBatch(name){
  97. const directory=path.join(work,'batches',name),receipt=load(path.join(directory,'receipt.json')),
  98. result=unwrap(load(path.join(directory,'apply-result.json'))),expected=load(path.join(directory,'expected-records.json'));
  99. checkPublication(result,receipt);
  100. if(sha(fs.readFileSync(path.join(directory,'transport.zip')))!==receipt.archiveSha256)throw Error('SEALED_ARCHIVE_CHANGED');
  101. const contract=await read('contract'),summary=await publicApi({op:'summary',generation:contract.generation});
  102. if(summary.generation!==contract.generation)throw Error('PUBLIC_GENERATION_MISMATCH');
  103. for(const [table,count] of Object.entries(contract.counts))if(table in summary.counts&&summary.counts[table]!==count)throw Error('PUBLIC_COUNT_MISMATCH');
  104. const checks=[];
  105. for(const entity of ['specs','reviews']){
  106. const rows=expected[entity];
  107. for(let start=0;start<rows.length;start+=100){
  108. const group=rows.slice(start,start+100),found=new Map();let cursor;
  109. do{
  110. const page=await publicApi({op:'browse',entity,quality:'audit',limit:100,filters:{recordId:group.map(r=>r.id)},generation:contract.generation,...(cursor?{cursor}:{})});
  111. for(const row of page.records)found.set(row.record_id,row);
  112. cursor=page.nextCursor;
  113. }while(cursor);
  114. if(found.size!==group.length)throw Error('HISTORICAL_ROW_MISSING:'+entity);
  115. for(const row of group){
  116. const actual=found.get(row.id);
  117. if(actual.product_id!==row.product_id)throw Error('HISTORICAL_PRODUCT_ID_MISMATCH');
  118. if(entity==='specs'&&(actual.spec_text!==row.spec_text||actual.spec_verification!=='source_attribute'||
  119. actual.net_weight_g!==row.net_weight_g||actual.pack_count!==row.pack_count||actual.total_weight_g!==row.total_weight_g))throw Error('HISTORICAL_SPEC_FIELD_MISMATCH');
  120. if(entity==='reviews'&&(actual.content!==row.content||actual.review_date_raw!==row.review_date_raw||actual.rating_raw!==row.rating_raw))throw Error('HISTORICAL_REVIEW_FIELD_MISMATCH');
  121. checks.push({entity,id:row.id,productId:row.product_id,fieldsVerified:true,sourceRecordId:actual.source_record_id});
  122. }
  123. }
  124. }
  125. // All review objects, including append fields and unknown flags/scales, are
  126. // checked by their exact record IDs; specifications are fully checked above.
  127. const detailRows=expected.reviews.map(r=>({entity:'reviews',row:r}));
  128. if(expected.specs.length)for(const i of [...new Set([0,Math.floor(expected.specs.length/2),expected.specs.length-1])])detailRows.push({entity:'specs',row:expected.specs[i]});
  129. const details=await parallelMap(detailRows,async({entity,row},i)=>{
  130. const response=await read('get',{entity,id:row.id,generation:contract.generation}),actual=response.record;
  131. const fields=entity==='reviews'?['product_id','review_id','content','review_date_raw','rating_raw','rating_scale','is_default','purchased_sku_text']:['product_id','spec_text','upstream_sku_id','net_weight_g','pack_count','total_weight_g','verification','basis'];
  132. for(const field of fields)if(actual[field]!==row[field])throw Error('HISTORICAL_DETAIL_MISMATCH:'+field);
  133. if(entity==='reviews'&&!isDeepStrictEqual(JSON.parse(actual.append_content_json||'null'),JSON.parse(row.append_content_json||'null')))throw Error('HISTORICAL_APPEND_MISMATCH');
  134. save(path.join(directory,'historical-detail-'+i+'.json'),response);
  135. return {entity,id:row.id,record:actual};
  136. });
  137. const sourceChecks=await parallelMap(details,async(detail,index)=>{
  138. const source=await read('source',{id:detail.record.source_record_id,generation:contract.generation}),payload=source.sourcePayload;
  139. const file=path.resolve(directory,payload.source_file||'');
  140. if(!file.startsWith(path.resolve(directory,'raw')+path.sep)||!fs.existsSync(file)||
  141. !source.citation.upstreamSources?.some(s=>s.sha256===sha(fs.readFileSync(file))))throw Error('HISTORICAL_SOURCE_SHA_MISMATCH');
  142. if(detail.entity==='reviews'&&(payload.content!==detail.record.content||String(payload.review_id)!==detail.record.review_id))throw Error('HISTORICAL_SOURCE_CONTENT_MISMATCH');
  143. if(detail.entity==='specs'&&payload.spec_text!==detail.record.spec_text)throw Error('HISTORICAL_SOURCE_SPEC_MISMATCH');
  144. const adapter=load(file),originals=detail.entity==='specs'?[adapter.original]:
  145. adapter.records.filter(r=>detail.record.item_id===r.itemId&&detail.record.review_id===r.reviewId).map(r=>r.origin);
  146. if(originals.length!==1)throw Error('HISTORICAL_ORIGINAL_ORIGIN_REQUIRED');
  147. const original=originals[0],originalPath=path.resolve(directory,original.path);
  148. if(!originalPath.startsWith(path.resolve(directory,'raw')+path.sep)||sha(fs.readFileSync(originalPath))!==original.sha256)throw Error('HISTORICAL_ORIGINAL_HASH_MISMATCH');
  149. save(path.join(directory,'historical-source-'+index+'.json'),{...source,original:{...original,shaVerified:true}});
  150. return {entity:detail.entity,id:detail.id,sourceVerified:true,sha256:sha(fs.readFileSync(file)),originalSha256:original.sha256};
  151. });
  152. const verified={ok:true,checkedAt:new Date().toISOString(),batchGeneration:result.generation,generation:contract.generation,
  153. allRowsVerified:result.allRowsVerified,publicReadbackPassed:true,sourceCitationReadbackPassed:sourceChecks.length>0,
  154. exactRowsVerified:checks.length,checks,sourceChecks,reviewObjectsVerified:expected.reviews.length,summary};
  155. save(path.join(directory,'pipeline-readback.json'),verified);return verified;
  156. }
  157. export function updateProgress(summary,state){
  158. const batches=[],productIds=new Set();
  159. const priorProgress=fs.existsSync(path.join(work,'latest-verified.json'))?load(path.join(work,'latest-verified.json')):null;
  160. for(const name of fs.readdirSync(path.join(work,'batches'))){
  161. const d=path.join(work,'batches',name),file=path.join(d,'apply-result.json');if(!fs.existsSync(file))continue;
  162. const result=unwrap(load(file));if(!result.ok||!result.applied)continue;
  163. const receipt=load(path.join(d,'receipt.json')),v=path.join(d,'pipeline-readback.json');
  164. for(const row of receipt.products)productIds.add(row.id);
  165. const previous=priorProgress?.batches.find(b=>b.batch===name);
  166. batches.push({batch:name,archiveSha256:receipt.archiveSha256,products:receipt.products.length,verifiedSpecRows:receipt.accepted.specs||0,
  167. addedReviews:result.delta.added.reviews||0,addedPrices:result.delta.added.prices||0,allRowsVerified:result.allRowsVerified,generation:result.generation,businessRowsChanged:result.businessRowsChanged,
  168. publicReadbackPassed:fs.existsSync(v)?load(v).publicReadbackPassed===true:previous?.allRowsVerified===true,
  169. sourceCitationReadbackPassed:fs.existsSync(v)?load(v).sourceCitationReadbackPassed===true:previous?.sourceCitationReadbackPassed===true||Boolean(previous&&priorProgress?.sourceCitationReadbackPassed)});
  170. }
  171. const total=k=>batches.reduce((s,b)=>s+(b[k]||0),0);
  172. const stopFile=path.join(work,'collection-stop-reason.json'),stop=fs.existsSync(stopFile)?load(stopFile):null;
  173. const terminal=state.status!=='running';
  174. const status=!terminal?'running-not-complete':state.status==='stopped-for-review'?'stopped-for-review':stop?'stopped-upstream-with-verified-results':state.status;
  175. save(path.join(work,'latest-verified.json'),{schema:'ksf.enrichment.verified-progress.v1',checkedAt:new Date().toISOString(),status,pipelineStatus:state.status,
  176. productionGeneration:summary.generation,batches,totals:{productsProcessed:productIds.size,productBatchOccurrences:total('products'),reviewsAdded:total('addedReviews'),sourceVerifiedSpecsAdded:total('verifiedSpecRows'),priceObservationsAdded:total('addedPrices')},
  177. productionCounts:summary.counts,productionQuality:summary.quality,allRowsVerified:batches.every(b=>b.allRowsVerified),publicReadbackPassed:batches.every(b=>b.publicReadbackPassed),sourceCitationReadbackPassed:batches.every(b=>b.sourceCitationReadbackPassed),
  178. remaining:{wholeQueueInProgress:state.status==='running',upstreamCollectionStop:stop,
  179. socialBodiesStillMissing:fs.existsSync(path.join(work,'social-body-audit.json'))?load(path.join(work,'social-body-audit.json')).summary.postsStillMissingBody:379,reviewCollectionScope:'one available page per product that had no stored reviews; not all platform reviews',unknownWeightsAndMaskedPricesNotInferred:true},
  180. pipelineStateFile:path.relative(process.cwd(),path.join(work,'pipeline-state.json'))});
  181. }