build-expanded-review-intake.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. """Build small source-verified review batches from the second collection round.
  2. Only exact request/returned identities and non-placeholder texts are retained.
  3. Likes stay in evidence, and absent default/rating-scale fields stay unknown.
  4. """
  5. import argparse
  6. from collections import Counter
  7. import importlib.util
  8. import json
  9. from pathlib import Path
  10. import shutil
  11. import sys
  12. import zipfile
  13. ROOT = Path(__file__).resolve().parents[1]
  14. sys.path.insert(0, str(ROOT / 'server/voc-library'))
  15. import agent_batch
  16. import board_library
  17. import library_channel
  18. import structured_batch
  19. import update_library
  20. import voc_db
  21. spec = importlib.util.spec_from_file_location('sample_summary', ROOT / 'scripts/summarize-product-review-samples.py')
  22. samples = importlib.util.module_from_spec(spec)
  23. spec.loader.exec_module(samples)
  24. def save(path, value):
  25. path.parent.mkdir(parents=True, exist_ok=True)
  26. with path.open('x', encoding='utf-8', newline='\n') as f:
  27. json.dump(value, f, ensure_ascii=False, indent=2)
  28. f.write('\n')
  29. def copy_source(source, destination):
  30. destination.parent.mkdir(parents=True, exist_ok=True)
  31. shutil.copyfile(source, destination)
  32. return {'path': destination, 'sha256': agent_batch.digest(destination)}
  33. def build_group(records, ordinal, plan, target):
  34. target.mkdir(parents=True, mode=0o700)
  35. batch = {'schema': 'ksf.voc-agent-batch.v2', 'batchId': 'review-coverage-20261106-r2-' + str(ordinal).zfill(2),
  36. 'producer': 'Codex: direct review coverage expansion; exact product identity; observed likes retained',
  37. 'sources': [], 'attachments': [], 'products': [], 'reviews': []}
  38. chosen = []
  39. diagnostics = []
  40. for item in records:
  41. pid = item['productId']
  42. family, iid = pid.split(':')
  43. label = item['label']
  44. folder = ROOT / 'work/product-reviews/live-r2' / label
  45. envelope_file = folder / 'mcp-result.json'
  46. envelope = json.loads(envelope_file.read_text(encoding='utf-8'))
  47. reservation = json.loads((folder / 'reservation.json').read_text(encoding='utf-8'))
  48. detail = json.loads((folder / 'product-context.json').read_text(encoding='utf-8'))
  49. if reservation['catalogGeneration'] != plan['generation'] or detail['generation'] != plan['generation']:
  50. raise ValueError('CONTEXT_GENERATION_MISMATCH')
  51. context = detail['record']
  52. if context['id'] != pid or context['item_id'] != iid:
  53. raise ValueError('PRODUCT_CONTEXT_ID_MISMATCH')
  54. candidate = next(r for r in plan['candidates'] if r['productId'] == pid)
  55. name = 'raw/context/' + label + '.json'
  56. copy_source(folder / 'product-context.json', target / name)
  57. sid = 'product-' + label
  58. batch['sources'].append({'id': sid, 'path': name, 'sha256': agent_batch.digest(target / name),
  59. 'platform': family, 'marketplace': context['marketplace'], 'requestItemId': iid,
  60. 'observedAt': context.get('source_time_raw'), 'url': context.get('url')})
  61. batch['products'].append({'sourceId': sid, 'pointer': '/record', 'fields': {
  62. 'itemId': '/item_id', 'title': '/title', 'url': '/url', 'shopId': '/shop_id',
  63. 'shopName': '/shop_name', 'sourceTime': '/source_time_raw'}})
  64. # Retain exact MCP envelopes plus media URL mapping. Already-downloaded
  65. # buyer images/avatars remain in private live output; they are not
  66. # product main images and are unnecessary for the textual review batch.
  67. source_files = [folder / 'mcp-result.json', folder / 'reservation.json']
  68. source_files.extend(f for f in (folder / 'tool-output').rglob('*.json') if f.is_file())
  69. for source in source_files:
  70. relative = 'raw/live/' + label + '/' + source.relative_to(folder).as_posix()
  71. copy_source(source, target / relative)
  72. batch['attachments'].append({'path': relative, 'sha256': agent_batch.digest(target / relative)})
  73. volume = candidate['volumeEvidence']
  74. vname = 'raw/volume/' + Path(volume['file']).name
  75. if not (target / vname).exists():
  76. copy_source(ROOT / 'work/product-reviews/round2' / volume['file'], target / vname)
  77. batch['attachments'].append({'path': vname, 'sha256': agent_batch.digest(target / vname)})
  78. payload = envelope['response']['structuredContent']['data']['result']
  79. upstream = payload['data'] if family == 'jd' else payload['comments']
  80. meaningful = [r for r in item['records'] if r['matchesRequestedProduct'] and not r['isPlaceholder'] and r['content'].strip()]
  81. meaningful.sort(key=lambda r: r['likes'] if type(r['likes']) is int else -1, reverse=True)
  82. seen = set()
  83. selected = []
  84. for inspected in meaningful:
  85. rid = inspected['reviewId']
  86. if rid in seen:
  87. continue
  88. seen.add(rid)
  89. original = upstream[inspected['index']]
  90. original_id = original['commentId'] if family == 'jd' else original['rateId']
  91. if type(original_id) is int and original_id > 2 ** 53 - 1:
  92. raise ValueError('UNSAFE_NUMERIC_REVIEW_ID')
  93. pointer = '/response/structuredContent/data/result/' + ('data/' if family == 'jd' else 'comments/') + str(inspected['index'])
  94. if agent_batch.pointer(envelope, pointer) != original:
  95. raise ValueError('SOURCE_POINTER_MISMATCH')
  96. selected.append({'itemId': iid, 'reviewId': rid, 'content': inspected['content'],
  97. 'appendContent': original.get('afterComment') if family == 'jd' else original.get('appendList'),
  98. 'reviewDate': original.get('commentDate') if family == 'jd' else original.get('date'),
  99. 'purchasedSku': original.get('productColor') if family == 'jd' else original.get('auction', {}).get('sku'),
  100. 'rating': original.get('commentScore') if family == 'jd' else original.get('rate'),
  101. 'ratingScale': None, 'isDefault': None, 'sourceTime': item['sourceTime'], 'likeCount': inspected['likes'],
  102. 'productId': pid, 'marketplace': context['marketplace'],
  103. 'origin': {'tool': 'voc_api_call', 'endpoint': reservation['endpoint'],
  104. 'sourceFile': 'raw/live/' + label + '/mcp-result.json', 'sourcePointer': pointer,
  105. 'sourceSha256': agent_batch.digest(envelope_file), 'usageTraceId': reservation['usageTraceId'],
  106. 'mediaUrlsRewrittenByExistingMcp': True},
  107. 'selection': {'page': reservation['params']['page'], 'order': reservation['params'].get('orderType', 'upstream_default'),
  108. 'scope': 'observed_likes_within_one_returned_page_not_platform_global_top',
  109. 'historicalVolume': volume, 'rawDefaultFlag': 'not_provided_unknown'}})
  110. if len(selected) == 20:
  111. break
  112. chosen.extend(selected)
  113. diagnostics.append({k: v for k, v in item.items() if k != 'records'})
  114. if not chosen:
  115. raise ValueError('NO_QUALIFYING_REVIEWS')
  116. save(target / 'raw/derived/reviews.json', {'method': 'exact_identity_non_placeholder_reviews_sorted_by_observed_likes',
  117. 'maxPerProduct': 20, 'globalTopLikesVerified': False, 'records': chosen})
  118. digest = agent_batch.digest(target / 'raw/derived/reviews.json')
  119. for index, row in enumerate(chosen):
  120. family, iid = row['productId'].split(':')
  121. sid = 'review-' + str(index)
  122. batch['sources'].append({'id': sid, 'path': 'raw/derived/reviews.json', 'sha256': digest,
  123. 'platform': family, 'marketplace': row['marketplace'], 'requestItemId': iid, 'observedAt': row['sourceTime']})
  124. fields = {name: '/' + name for name in ['itemId', 'reviewId', 'content', 'reviewDate', 'purchasedSku',
  125. 'rating', 'ratingScale', 'isDefault', 'sourceTime']}
  126. if row['appendContent'] is not None:
  127. fields['appendContent'] = '/appendContent'
  128. batch['reviews'].append({'sourceId': sid, 'pointer': '/records/' + str(index), 'fields': fields})
  129. save(target / 'batch.json', batch)
  130. dry = structured_batch.build(target / 'batch.json', target / 'staging.sqlite')
  131. built = structured_batch.build(target / 'batch.json', target / 'staging.sqlite', True)
  132. meta = update_library.verified(target / 'staging.sqlite')
  133. with voc_db.readonly(target / 'staging.sqlite') as c:
  134. staged = {r['id']: dict(r) for r in c.execute('SELECT * FROM reviews')}
  135. if len(staged) != len(chosen):
  136. raise ValueError('DUPLICATE_REVIEW_IDS')
  137. for row in chosen:
  138. current = staged[row['productId'] + ':' + row['reviewId']]
  139. if current['content'] != row['content'] or current['product_id'] != row['productId'] or current['is_default'] is not None:
  140. raise ValueError('READBACK_CONTENT_IDENTITY_OR_UNKNOWN_STATE_MISMATCH')
  141. issues = {r[0]: r[1] for r in c.execute('SELECT code,count(*) FROM quality_issues GROUP BY code')}
  142. wire, fingerprint = library_channel.pack(target / 'batch.json')
  143. (target / 'channel-source.zip').write_bytes(wire)
  144. report = {'ok': True, 'batchId': batch['batchId'], 'expectedProductionGeneration': plan['generation'],
  145. 'products': len({r['productId'] for r in chosen}), 'reviews': len(chosen),
  146. 'verifiedReviewReadback': len(staged), 'reviewCounts': dict(Counter(r['productId'] for r in chosen)),
  147. 'positiveLikeReviews': sum(type(r['likeCount']) is int and r['likeCount'] > 0 for r in chosen),
  148. 'maxObservedLikes': max((r['likeCount'] for r in chosen if type(r['likeCount']) is int), default=None),
  149. 'unknownDefaultStatesRetained': len(chosen), 'issues': issues, 'stagingGeneration': meta['generation'],
  150. 'channelArchiveSha256': fingerprint, 'channelArchiveBytes': len(wire),
  151. 'sourceFilesVerified': dry['sourceFilesVerified'], 'paidCollectionCallsInBuild': 0,
  152. 'productionImported': False, 'preflight': dry, 'staging': built, 'collectionDiagnostics': diagnostics}
  153. save(target / 'verification.json', report)
  154. return report
  155. def main():
  156. p = argparse.ArgumentParser(description=__doc__)
  157. p.add_argument('--out', type=Path, required=True)
  158. args = p.parse_args()
  159. if args.out.exists():
  160. raise ValueError('NEW_OUTPUT_DIRECTORY_REQUIRED')
  161. root = args.out.absolute()
  162. plan = json.loads((ROOT / 'work/product-reviews/round2/plan-expanded.json').read_text(encoding='utf-8'))
  163. collection = samples.summarize(ROOT / 'work/product-reviews/live-r2')
  164. if any(r['status'] == 'pending' for r in collection['records']):
  165. raise ValueError('PENDING_REQUEST_NEEDS_STATUS_CHECK')
  166. qualifying = [r for r in collection['records'] if r['status'] == 'ok' and r['meaningfulMatchingReviews'] > 0]
  167. reports = []
  168. for start in range(0, len(qualifying), 10):
  169. ordinal = len(reports) + 1
  170. reports.append(build_group(qualifying[start:start + 10], ordinal, plan, root / ('batch-' + str(ordinal).zfill(2))))
  171. report = {'ok': True, 'expectedProductionGeneration': plan['generation'], 'plannedCandidates': plan['candidateCount'],
  172. 'collectionCalls': collection['collectionCalls'], 'successfulCalls': collection['successfulCalls'],
  173. 'confirmedNewCostCny': collection['confirmedCostCny'], 'unsettledNewReservationCny': collection['unsettledReservationCny'],
  174. 'sourceVerifiedProducts': sum(r['products'] for r in reports), 'selectedReviews': sum(r['reviews'] for r in reports),
  175. 'positiveLikeReviews': sum(r['positiveLikeReviews'] for r in reports),
  176. 'maxObservedLikes': max((r['maxObservedLikes'] for r in reports if r['maxObservedLikes'] is not None), default=None),
  177. 'batches': [{k: r[k] for k in ['batchId', 'products', 'reviews', 'reviewCounts', 'channelArchiveSha256', 'channelArchiveBytes']} for r in reports],
  178. 'productionImported': False, 'publication': 'prepared_not_published'}
  179. root.mkdir(parents=True, exist_ok=True)
  180. save(root / 'verification.json', report)
  181. save(root / 'collection-audit.json', {**collection, 'records': [{k: v for k, v in r.items() if k != 'records'} for r in collection['records']]})
  182. print(json.dumps(report, ensure_ascii=False, indent=2))
  183. if __name__ == '__main__':
  184. main()