finalize-research-enrichment.mjs 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
  1. /** Preserve an honest queue state after a collector stops and its publisher drains. */
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import {work,load,save} from './lib/research-enrichment.mjs';
  5. const alive=pid=>{if(!pid)return false;try{process.kill(pid,0);return true;}catch{return false;}};
  6. const stateFile=path.join(work,'pipeline-state.json'),state=load(stateFile);
  7. if(alive(state.pid)||alive(state.collectorPid))throw Error('PIPELINE_STILL_ACTIVE');
  8. for(const name of fs.readdirSync(path.join(work,'batches'))){
  9. const d=path.join(work,'batches',name),attempt=path.join(d,'apply-attempt.json');
  10. if(!fs.existsSync(attempt))continue;
  11. const file=path.join(d,'apply-result.json'),verified=path.join(d,'pipeline-readback.json');
  12. if(!fs.existsSync(file)||!load(file).body?.result?.allRowsVerified||!fs.existsSync(verified)||!load(verified).ok)
  13. throw Error('UNRESOLVED_PUBLICATION_REQUIRES_REVIEW');
  14. }
  15. const stopFile=path.join(work,'collection-stop-reason.json');
  16. if(fs.existsSync(stopFile)){
  17. save(path.join(work,'pipeline-state.before-stop-reconcile-'+Date.now()+'.json'),state);
  18. state.status='stopped-upstream-with-verified-results';state.stage='collected-material-verified';
  19. delete state.errorCode;
  20. state.collection={...state.collection,stopReason:load(stopFile),queueComplete:false};
  21. state.updatedAt=new Date().toISOString();save(stateFile,state);
  22. }
  23. const coverage=new Map();
  24. for(const name of fs.readdirSync(path.join(work,'batches'))){
  25. const directory=path.join(work,'batches',name),file=path.join(directory,'apply-result.json');if(!fs.existsSync(file))continue;
  26. const result=load(file).body.result;if(!result.ok||!result.applied||!result.allRowsVerified)continue;
  27. for(const row of load(path.join(directory,'receipt.json')).products){
  28. const known=coverage.get(row.id)||{reviews:0,specs:0,prices:0,batches:[]};
  29. for(const field of ['reviews','specs','prices'])known[field]+=row[field]||0;
  30. known.batches.push(name);coverage.set(row.id,known);
  31. }
  32. }
  33. // A published product can still lack reviews, specifications or a numeric price.
  34. // This manifest retains those individual gaps for the next recovery pass.
  35. const queue=load(path.join(work,'product-queue.json')),remaining=[];
  36. for(const p of queue){
  37. const known=coverage.get(p.productId)||{reviews:0,specs:0,prices:0,batches:[]};
  38. const gaps={reviews:p.missingReviews&&!known.reviews,sourceVerifiedSpecs:p.missingVerifiedSpec&&!known.specs,price:p.missingPrice&&!known.prices};
  39. if(Object.values(gaps).some(Boolean))remaining.push({productId:p.productId,gaps,published:known,
  40. recheckRequired:'retry original linked record; successful request alone does not guarantee valid rows or all fields'});
  41. }
  42. const manifest={schema:'ksf.enrichment.pending-recovery.v1',checkedAt:new Date().toISOString(),
  43. status:state.status,upstreamStop:state.collection?.stopReason||null,productsWithRemainingFields:remaining.length,
  44. missingReviews:remaining.filter(r=>r.gaps.reviews).length,missingSourceVerifiedSpecs:remaining.filter(r=>r.gaps.sourceVerifiedSpecs).length,
  45. missingPrices:remaining.filter(r=>r.gaps.price).length,products:remaining};
  46. save(path.join(work,'pending-recovery.json'),manifest);
  47. console.log(JSON.stringify({status:state.status,productsWithRemainingFields:manifest.productsWithRemainingFields,
  48. missingReviews:manifest.missingReviews,missingSourceVerifiedSpecs:manifest.missingSourceVerifiedSpecs,missingPrices:manifest.missingPrices}));