verify-product-completion.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. """Build fresh private staging DBs, then independently verify their actual rows."""
  2. import argparse
  3. from collections import Counter
  4. from datetime import datetime, timezone
  5. import hashlib
  6. import importlib.util
  7. import json
  8. from pathlib import Path
  9. import sqlite3
  10. import subprocess
  11. import sys
  12. ROOT = Path(__file__).resolve().parents[1]
  13. sys.path.insert(0, str(ROOT / 'server/voc-library'))
  14. import library_channel
  15. import update_library
  16. spec = importlib.util.spec_from_file_location('recovery', ROOT / 'scripts/recover-product-completion.py')
  17. recovery = importlib.util.module_from_spec(spec)
  18. spec.loader.exec_module(recovery)
  19. WORK, DOCS = recovery.WORK, recovery.DOCS
  20. def read(path):
  21. return json.loads(Path(path).read_bytes())
  22. def sha(raw):
  23. return hashlib.sha256(raw).hexdigest()
  24. def stage():
  25. plan = read(WORK / 'batch-plan.json')
  26. directories = [WORK / b['directory'] for b in plan['batches']]
  27. assert all(not (d / 'staging.sqlite').exists() for d in directories), 'NEW_STAGING_ONLY'
  28. for applying in (False, True):
  29. for directory in directories:
  30. command = [sys.executable, '-X', 'utf8', '-B', str(ROOT / 'server/voc-library/agent_batch.py'),
  31. '--batch', str(directory / 'batch.json'), '--output', str(directory / 'staging.sqlite')]
  32. if applying:
  33. command.append('--apply')
  34. result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
  35. name = 'staging-build' if applying else 'preflight'
  36. (directory / (name + '.stdout.json')).write_bytes(result.stdout)
  37. (directory / (name + '.stderr.txt')).write_bytes(result.stderr)
  38. recovery.save(directory / (name + '.exit.json'), {'exitCode': result.returncode, 'command': command})
  39. if result.returncode:
  40. raise ValueError(name.upper() + '_FAILED:' + directory.name + ':' + result.stderr.decode('utf8', 'replace')[:1000])
  41. receipt = json.loads(result.stdout)
  42. assert receipt['ok'] and receipt['activeLibraryModified'] is False and receipt['productionImported'] is False
  43. print(json.dumps({'batch': directory.name, 'step': name, 'counts': receipt['counts']}, ensure_ascii=False), flush=True)
  44. def verify():
  45. plan = read(WORK / 'batch-plan.json')
  46. baseline = read(ROOT / 'work/industry-monitoring-20261107-r1/snapshot.json')
  47. old_review_ids = {r['record_id'] for r in baseline['entities']['reviews']}
  48. expected_specs = {r['productId']: r['specText'] for r in read(DOCS / 'spec-recovery-plan.json')['candidates']}
  49. expected_reviews = {r['recordId']: r for r in read(DOCS / 'review-recovery-audit.json')['missingMeaningful']}
  50. total, verified_specs, verified_reviews, reports = Counter(), {}, {}, []
  51. current_specs = {r['record_id']: r for r in baseline['entities']['specs']}
  52. # Context products cause the frozen importer to regenerate title-spec rows.
  53. # Prove these are duplicates of existing rows, not additional discoveries.
  54. old_database = ROOT / 'work/library-preview/registry/versions/7e0df42395d9d2ea39dd5b537d17ae35ac910cc29721ed92a680b7fe6b25c92c.sqlite'
  55. with sqlite3.connect(old_database.resolve().as_uri() + '?mode=ro', uri=True) as old:
  56. old.row_factory = sqlite3.Row
  57. old_specs = {r['id']: dict(r) for r in old.execute('SELECT * FROM specs')}
  58. auto_specs, live_contexts = [], []
  59. for part in plan['batches']:
  60. directory = WORK / part['directory']
  61. batch = read(directory / 'batch.json')
  62. build = read(directory / 'staging-build.stdout.json')
  63. assert sha((directory / 'staging.sqlite').read_bytes()) == build['databaseSha256']
  64. assert sha((directory / 'staging.source.zip').read_bytes()) == build['archiveSha256']
  65. meta = update_library.verified(directory / 'staging.sqlite')
  66. assert meta['generation'] == build['generation']
  67. packed, archive_sha = library_channel.pack(directory / 'batch.json')
  68. # Persist the exact channel archive fingerprint for dry/apply/status reconciliation.
  69. (directory / 'channel-batch.zip').write_bytes(packed)
  70. counts = {}
  71. review_adapters = {}
  72. if part.get('reviews'):
  73. review_adapters = {r['itemId'] + ':' + r['reviewId']: r for r in read(directory / 'raw/parsed/reviews.json')['records']}
  74. with sqlite3.connect((directory / 'staging.sqlite').resolve().as_uri() + '?mode=ro', uri=True) as c:
  75. c.row_factory = sqlite3.Row
  76. for table in ('products', 'reviews', 'specs', 'prices'):
  77. counts[table] = c.execute('SELECT count(*) FROM ' + table).fetchone()[0]
  78. total[table] += counts[table]
  79. assert counts['products'] == part['products'] and counts['specs'] == part['specs'] + part['products']
  80. assert counts['reviews'] == part.get('reviews', 0) and counts['prices'] == 0
  81. assert {r[0] for r in c.execute('SELECT id FROM products')} == set(part['productIds'])
  82. for row in c.execute('SELECT * FROM specs'):
  83. pid = row['product_id']
  84. if row['verification'] != 'source_attribute':
  85. current = current_specs[row['id']]
  86. assert all(row[k] == current[k] for k in ('product_id', 'spec_text', 'net_weight_g', 'pack_count', 'total_weight_g'))
  87. assert row['verification'] == current['spec_verification']
  88. prior = old_specs.get(row['id'])
  89. if prior and prior['source_record_id'] == current['source_record_id']:
  90. assert all(row[k] == prior[k] for k in row.keys() if k != 'source_record_id')
  91. origin = 'SAME_EXISTING_SOURCE_RECORD_AND_ALL_BUSINESS_FIELDS'
  92. else:
  93. path = WORK / 'current-spec-details' / (row['id'] + '.json')
  94. if path.exists():
  95. detail = read(path)
  96. else:
  97. detail = recovery.links.api({'op': 'detail', 'entity': 'specs', 'id': row['id'], 'generation': plan['generation']})
  98. recovery.save(path, detail)
  99. assert detail['generation'] == plan['generation']
  100. actual = detail['record']
  101. assert all(row[k] == actual[k] for k in row.keys() if k != 'source_record_id')
  102. live_contexts.append(row['id'])
  103. origin = 'CURRENT_GENERATION_FULL_RECORD_READBACK'
  104. auto_specs.append({'recordId': row['id'], 'productId': pid, 'verification': row['verification'],
  105. 'existingSourceRecordId': current['source_record_id'], 'duplicateProof': origin})
  106. continue
  107. assert row['spec_text'] == expected_specs[pid]
  108. assert row['verification'] == 'source_attribute'
  109. assert all(row[f] is None for f in ('net_weight_g', 'pack_count', 'total_weight_g'))
  110. adapter = read(directory / ('raw/parsed/' + pid.replace(':', '-') + '.json'))
  111. original = adapter['original']
  112. raw = (directory / original['path']).read_bytes()
  113. actual = recovery.select_spec(raw, pid.split(':')[1], original['sha256'])
  114. assert actual['payload'] == adapter['literal'] and actual['specText'] == row['spec_text']
  115. assert original['observedAt'].startswith('2026-11-06')
  116. assert pid not in verified_specs
  117. verified_specs[pid] = row['id']
  118. for row in c.execute('SELECT * FROM reviews'):
  119. rid = row['id']
  120. assert rid not in old_review_ids and rid not in verified_reviews
  121. expected = expected_reviews[rid]
  122. mapped = review_adapters[':'.join(rid.split(':')[1:])]
  123. original_bytes = (directory / mapped['origin']['path']).read_bytes()
  124. assert sha(original_bytes) == mapped['origin']['sha256']
  125. original = recovery.unbound.at(json.loads(original_bytes), mapped['origin']['pointer'])
  126. actual_append = original.get('afterComment') if rid.startswith('jd:') else original.get('appendList') or original.get('append')
  127. assert mapped['appendContent'] == actual_append
  128. assert row['content'] == expected['content']
  129. assert row['is_default'] is None and row['rating_scale'] is None
  130. assert row['product_id'] == rid.rsplit(':', 1)[0]
  131. append = json.loads(row['append_content_json']) if row['append_content_json'] else None
  132. assert append == actual_append
  133. assert row['review_date_raw'] == mapped['reviewDate']
  134. assert row['rating_raw'] == str(mapped['rating'])
  135. assert row['purchased_sku_text'] == mapped['purchasedSku']
  136. verified_reviews[rid] = {'reviewDateRaw': row['review_date_raw'], 'ratingRaw': row['rating_raw'],
  137. 'sourceRecordId': row['source_record_id'], 'hasAppend': bool(append)}
  138. # Product context must carry no new observation timestamp or fabricated price.
  139. assert c.execute('SELECT count(*) FROM products WHERE source_time_raw IS NOT NULL').fetchone()[0] == 0
  140. report = {'batch': directory.name, 'ok': True, 'counts': counts, 'stagingGeneration': build['generation'],
  141. 'batchManifestSha256': sha((directory / 'batch.json').read_bytes()),
  142. 'channelArchiveSha256': archive_sha, 'channelArchiveBytes': len(packed),
  143. 'channelArchiveExpandedBytes': len((directory / 'batch.json').read_bytes()) + sum((directory / p).stat().st_size for p in
  144. {s['path'] for s in batch['sources']} | {a['path'] for a in batch.get('attachments', [])}),
  145. 'databaseSha256': build['databaseSha256'], 'sourceArchiveSha256': build['archiveSha256']}
  146. recovery.save(directory / 'readback.json', report)
  147. reports.append(report)
  148. assert set(verified_specs) == set(expected_specs)
  149. assert set(verified_reviews) == set(expected_reviews)
  150. summary = recovery.links.api({'op': 'summary'})
  151. assert summary['generation'] == plan['generation'], 'PRODUCTION_GENERATION_CHANGED'
  152. recovery.save(DOCS / 'generated-title-spec-duplicates.json', {'generation': plan['generation'], 'rows': auto_specs,
  153. 'count': len(auto_specs), 'fullLiveReads': len(live_contexts), 'newResearchFacts': 0})
  154. result = {'ok': True, 'checkedAt': datetime.now(timezone.utc).isoformat(), 'baselineGeneration': plan['generation'],
  155. 'counts': dict(total), 'uniqueProductsWithVerifiedSpecs': len(verified_specs),
  156. 'explicitNewSpecRecords': len(verified_specs), 'generatedExistingTitleSpecs': len(auto_specs),
  157. 'expectedProductionAddedSpecs': len(verified_specs), 'expectedProductionAddedReviews': len(verified_reviews),
  158. 'verifiedSpecRecordIds': verified_specs, 'verifiedReviewRecords': verified_reviews, 'batches': reports,
  159. 'productionGenerationUnchanged': True, 'productionCountsAfterLocalStaging': summary['counts'],
  160. 'numericWeightFieldsInferredInNewAttributes': 0, 'defaultFlagsInvented': 0, 'paidCalls': 0,
  161. 'databaseWrites': 'new-private-staging-only', 'productionImported': False}
  162. recovery.save(DOCS / 'staging-readback.json', result)
  163. print(json.dumps({k: v for k, v in result.items() if k not in ('verifiedSpecRecordIds', 'verifiedReviewRecords', 'batches')}, ensure_ascii=False))
  164. if __name__ == '__main__':
  165. p = argparse.ArgumentParser(description=__doc__)
  166. p.add_argument('command', choices=['stage', 'verify'])
  167. (stage if p.parse_args().command == 'stage' else verify)()