retrieve-five-topic-library.mjs 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. /** Read the five requested research scopes from one fixed, current generation. */
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import {read, save, load, sha} from './lib/research-enrichment.mjs';
  5. const work = path.resolve('work/five-topic-collection-20261110');
  6. const plan = load(path.join(work, 'collection-plan.json'));
  7. const contract = load(path.join(work, 'contract-before.json'));
  8. const directory = path.join(work, 'library-pages');
  9. fs.mkdirSync(directory, {recursive: true});
  10. const stateFile = path.join(work, 'library-retrieval-state.json');
  11. const state = {startedAt: new Date().toISOString(), generation: contract.generation, pid: process.pid,
  12. status: 'running', topics: {}, productionWrites: 0};
  13. const alive = pid => {try {process.kill(pid, 0); return true;} catch {return false;}};
  14. if (fs.existsSync(stateFile)) {
  15. const previous = load(stateFile);
  16. if (previous.status === 'running' && alive(previous.pid)) throw Error('TOPIC_RETRIEVER_ALREADY_RUNNING');
  17. }
  18. const record = () => {state.updatedAt = new Date().toISOString(); save(stateFile, state);};
  19. const inScope = row => {
  20. if (['raw_noodle', 'sauce', 'other_food'].includes(row.category)) return false;
  21. if (row.entity === 'social' && row.content_scope && row.content_scope !== 'packaged_noodles') return false;
  22. if (row.entity === 'products') {
  23. const title = row.title || '';
  24. if (/挂面|生面条|鲜面条|鲜湿面|手擀面粉|面粉|拌面酱|拌面汁|堂食/.test(title)) return false;
  25. if (!/方便面|泡面|速食|即食|拉面|拌面|捞面|汤面|热干面|乌冬面|干脆面|杯面|桶面|碗面|面皮|凉皮|火鸡面|面饼/.test(title)) return false;
  26. }
  27. return true;
  28. };
  29. async function retrieve(topic) {
  30. const rows = [], seen = new Set();
  31. let cursor, page = 0, totalMatched;
  32. const progress = state.topics[topic.id] = {name: topic.name, pages: 0, retrieved: 0, status: 'running'};
  33. const groups = topic.keywords.slice(0, 20);
  34. do {
  35. page++;
  36. const file = path.join(directory, topic.id + '-' + String(page).padStart(3, '0') + '.json');
  37. const input = {entities: ['products', 'reviews', 'social'], quality: 'audit', limit: 100,
  38. text: {all: [{any: groups}]}, ...(cursor ? {cursor} : {})};
  39. const querySha256 = sha(Buffer.from(JSON.stringify({input, generation: contract.generation})));
  40. let result;
  41. if (fs.existsSync(file)) {
  42. const cached = load(file);
  43. if (cached.querySha256 === querySha256 && cached.result?.generation === contract.generation) result = cached.result;
  44. }
  45. if (!result) {
  46. result = await read('search', {input, generation: contract.generation});
  47. save(file, {querySha256, input, receivedAt: new Date().toISOString(), result});
  48. }
  49. if (result.generation !== contract.generation) throw Error('GENERATION_CHANGED');
  50. if (totalMatched === undefined) totalMatched = result.totalMatched;
  51. if (result.totalMatched !== totalMatched) throw Error('TOPIC_COUNT_CHANGED');
  52. for (const row of result.records) {
  53. const key = row.entity + ':' + row.record_id;
  54. if (seen.has(key)) throw Error('DUPLICATE_CURSOR_ROW');
  55. seen.add(key); rows.push(row);
  56. }
  57. progress.pages = page; progress.retrieved = rows.length; progress.totalMatched = totalMatched; record();
  58. cursor = result.nextCursor;
  59. } while (cursor);
  60. if (rows.length !== totalMatched) throw Error('TOPIC_CURSOR_INCOMPLETE');
  61. const accepted = [], excluded = [];
  62. for (const row of rows) {
  63. let reason;
  64. if (!inScope(row)) reason = 'OUTSIDE_PACKAGED_NOODLE_SCOPE';
  65. else if (row.entity === 'reviews' && !(row.match?.matchedTerms || []).length) reason = 'PRODUCT_TITLE_MATCH_ONLY_NOT_REVIEW_BODY';
  66. if (reason) excluded.push({entity: row.entity, id: row.record_id, reason});
  67. else accepted.push(row);
  68. }
  69. const counts = {};
  70. for (const row of accepted) counts[row.entity] = (counts[row.entity] || 0) + 1;
  71. const reviews = accepted.filter(r => r.entity === 'reviews');
  72. const reviewText = new Set(reviews.map(r => r.content_fingerprint));
  73. Object.assign(progress, {status: 'complete', accepted: accepted.length, counts,
  74. distinctReviewTexts: reviewText.size, excluded: excluded.length});
  75. save(path.join(work, 'library-' + topic.id + '.json'), {schema: 'ksf.five-topic.existing-library.v1',
  76. topic, generation: contract.generation, checkedAt: new Date().toISOString(), upstreamNewCollection: false,
  77. allPagesRetrieved: true, rawCandidates: totalMatched, counts,
  78. sourceIdsPresent: accepted.every(r => r.source_record_id && r.citation?.assetSha256),
  79. distinctReviewTexts: reviewText.size, records: accepted, excluded,
  80. limits: ['字面关键词候选;不证明功效、收入阶层或真实购买人群',
  81. '评价仅计正文或追评命中,商品标题带场景词不当成评价证言',
  82. '社媒与电商分开,不将相同原文记录当作独立消费者',
  83. '中式养生中的食材词不自动代表具有保健功效']});
  84. record();
  85. console.log(JSON.stringify({topic: topic.name, pages: page, counts, excluded: excluded.length}));
  86. }
  87. try {
  88. record();
  89. let next = 0;
  90. await Promise.all(Array.from({length: 2}, async () => {
  91. while (next < plan.topics.length) {const topic = plan.topics[next++]; await retrieve(topic);}
  92. }));
  93. const after = await read('contract');
  94. save(path.join(work, 'contract-after-library-retrieval.json'), after);
  95. if (after.generation !== contract.generation) throw Error('GENERATION_CHANGED');
  96. state.status = 'complete'; state.finishedAt = new Date().toISOString(); record();
  97. } catch (error) {
  98. state.status = 'failed'; state.errorCode = /^[A-Z][A-Z0-9_:.-]+$/.test(error.message) ? error.message : 'TOPIC_READ_FAILED';
  99. record(); console.error(JSON.stringify({ok: false, errorCode: state.errorCode})); process.exitCode = 1;
  100. }