audit-voc-generations.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. """Compare immutable registry versions without importing deployed code or reading secrets.
  2. Only the new --out file is written. Review prose, account data and raw source
  3. payloads are excluded; selected quality flags, IDs and fingerprints are reported.
  4. Uses the Python 3.9 standard library.
  5. """
  6. import argparse
  7. import collections
  8. import hashlib
  9. import json
  10. import os
  11. from pathlib import Path
  12. import re
  13. import sqlite3
  14. QUALITY_FIELDS = ('is_default', 'identity_state', 'category_state', 'rating_scale')
  15. REVIEW_FIELDS = QUALITY_FIELDS + ('product_id', 'source_record_id', 'source_time_raw')
  16. MODULES = ('voc_db.py', 'update_library.py', 'schema_catalog.py',
  17. 'library_receiver.py', 'board_library.py', 'retrieval.py', 'product_insights.py')
  18. def require(condition, code):
  19. if not condition:
  20. raise ValueError(code)
  21. def digest(path):
  22. h = hashlib.sha256()
  23. with path.open('rb') as f:
  24. for block in iter(lambda: f.read(1024 * 1024), b''):
  25. h.update(block)
  26. return h.hexdigest()
  27. def value_digest(value):
  28. return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True,
  29. separators=(',', ':')).encode()).hexdigest()
  30. def plain(path):
  31. path = Path(path).absolute()
  32. require(not any(p.is_symlink() for p in (path, *path.parents)), 'REDIRECTED_PATH')
  33. return path
  34. def qualified(row, strict=False):
  35. return (row['is_default'] == 0 and row['identity_state'] == 'matched'
  36. and row['category_state'] is not None
  37. and row['category_state'] != 'conflict'
  38. and (not strict or row['category_state'] == 'consistent'))
  39. def snapshot(path, product_id):
  40. before = digest(path)
  41. c = sqlite3.connect(path.as_uri() + '?mode=ro', uri=True)
  42. c.row_factory = sqlite3.Row
  43. try:
  44. c.execute('PRAGMA query_only=ON')
  45. meta = dict(c.execute('SELECT key,value FROM meta'))
  46. require(meta.get('generation') == path.stem, 'GENERATION_FILENAME_MISMATCH')
  47. products = {r['id']: dict(r) for r in c.execute('SELECT * FROM products')}
  48. reviews = {r['id']: dict(r) for r in c.execute('SELECT * FROM reviews')}
  49. strict = {rid: r for rid, r in reviews.items()
  50. if (lambda p: p.get('marketplace') == 'jd'
  51. and p.get('brand_code') == 'ksf' and p.get('category_code') == 'banmian')
  52. (products.get(r['product_id'], {}))}
  53. tables = {r[0] for r in c.execute("SELECT name FROM sqlite_master WHERE type='table'")}
  54. indexed = None
  55. if 'retrieval_documents' in tables:
  56. indexed = dict(c.execute("""SELECT
  57. count(*) AS audit,
  58. sum(is_default=0 AND identity_state='matched' AND category_state!='conflict') AS analysis,
  59. sum(platform='jd' AND brand='ksf' AND category='banmian') AS strictAudit,
  60. sum(platform='jd' AND brand='ksf' AND category='banmian'
  61. AND is_default=0 AND identity_state='matched' AND category_state='consistent') AS strictAnalysis
  62. FROM retrieval_documents WHERE entity='reviews'""").fetchone())
  63. selected = []
  64. for rid, row in reviews.items():
  65. if row['product_id'] != product_id:
  66. continue
  67. source = c.execute('SELECT kind,payload_json,payload_sha256 FROM source_records WHERE id=?',
  68. (row['source_record_id'],)).fetchone()
  69. payload = json.loads(source['payload_json']) if source else None
  70. flag = payload.get('is_default_text') if isinstance(payload, dict) else None
  71. selected.append({'id': rid, **{k: row.get(k) for k in REVIEW_FIELDS},
  72. 'contentSha256': value_digest(row['content']),
  73. 'purchasedSkuSha256': value_digest(row.get('purchased_sku_text')),
  74. 'sourceKind': source['kind'] if source else None,
  75. 'sourcePayloadSha256': source['payload_sha256'] if source else None,
  76. 'normalizedSourceDefault': flag if type(flag) is bool else None,
  77. 'normalizedSourceDefaultIsBoolean': type(flag) is bool})
  78. report = {'generation': meta['generation'], 'databaseSha256': before,
  79. 'counts': {'products': len(products), 'reviews': len(reviews)},
  80. 'reviews': {'analysis': sum(qualified(r) for r in reviews.values()),
  81. 'strictAudit': len(strict),
  82. 'strictAnalysis': sum(qualified(r, True) for r in strict.values())},
  83. 'retrievalIndexCounts': indexed,
  84. 'selectedProductReviews': sorted(selected, key=lambda r: r['id']),
  85. 'selectedProductDefaultGroups': dict(collections.Counter(
  86. str(r['is_default']) for r in reviews.values() if r['product_id'] == product_id)),
  87. 'mergeDecisionsTablePresent': 'merge_decisions' in tables}
  88. if 'merge_decisions' in tables:
  89. report['selectedReviewDecisions'] = [dict(r) for r in c.execute('''SELECT
  90. record_id,decision,reason_codes_json,prior_source_record_id,
  91. incoming_source_record_id,selected_source_record_id,batch_sha256
  92. FROM merge_decisions WHERE entity='reviews' AND record_id IN
  93. (SELECT id FROM reviews WHERE product_id=?) ORDER BY record_id''', (product_id,))]
  94. finally:
  95. c.close()
  96. require(digest(path) == before, 'VERSION_FILE_CHANGED')
  97. report['sha256Unchanged'] = True
  98. return report, products, reviews
  99. def changes(old, new, fields):
  100. common = old.keys() & new.keys()
  101. modified, lost = [], []
  102. for rid in sorted(common):
  103. a, b = old[rid], new[rid]
  104. if a == b:
  105. continue
  106. diff = {k: {'before': a.get(k), 'after': b.get(k)} for k in fields if a.get(k) != b.get(k)}
  107. modified.append({'id': rid, 'fields': diff,
  108. 'beforeRowSha256': value_digest(a), 'afterRowSha256': value_digest(b)})
  109. missing = [k for k in fields if a.get(k) is not None and a.get(k) != ''
  110. and (b.get(k) is None or b.get(k) == '')]
  111. if missing:
  112. lost.append({'id': rid, 'fields': missing})
  113. return {'added': len(new.keys() - old.keys()), 'removed': len(old.keys() - new.keys()),
  114. 'changed': len(modified), 'knownToUnknown': lost[:100],
  115. 'knownToUnknownCount': len(lost), 'changes': modified[:100],
  116. 'changesTruncated': len(modified) > 100, 'knownToUnknownTruncated': len(lost) > 100}
  117. def audit(registry, generations, expected_generation, product_id, module_dirs=()):
  118. registry = plain(registry)
  119. raw = (registry / 'current.json').read_bytes()
  120. pointer = json.loads(raw)
  121. require(re.fullmatch('[0-9a-f]{64}', expected_generation), 'INVALID_GENERATION')
  122. require(pointer.get('generation') == expected_generation, 'LIBRARY_GENERATION_CHANGED')
  123. require(pointer.get('file') == 'versions/' + expected_generation + '.sqlite',
  124. 'UNEXPECTED_CURRENT_VERSION_PATH')
  125. require(bool(generations), 'GENERATIONS_REQUIRED')
  126. require(len(generations) == len(set(generations)), 'DUPLICATE_GENERATION')
  127. require(generations[-1] == expected_generation, 'CURRENT_VERSION_MUST_BE_LAST')
  128. reports, transitions = [], []
  129. previous = None
  130. for generation in generations:
  131. require(re.fullmatch('[0-9a-f]{64}', generation), 'INVALID_GENERATION')
  132. path = plain(registry / 'versions' / (generation + '.sqlite'))
  133. require(path.is_file(), 'VERSION_NOT_FOUND:' + generation)
  134. report, products, reviews = snapshot(path, product_id)
  135. if previous:
  136. old_report, old_products, old_reviews = previous
  137. transitions.append({'from': old_report['generation'], 'to': generation,
  138. 'products': changes(old_products, products, ('marketplace', 'shop_id',
  139. 'brand_code', 'category_code', 'brand_basis', 'category_basis')),
  140. 'reviews': changes(old_reviews, reviews, REVIEW_FIELDS)})
  141. reports.append(report)
  142. previous = report, products, reviews
  143. modules = []
  144. for folder in module_dirs:
  145. folder = plain(folder)
  146. modules.append({'directory': str(folder), 'files': {
  147. name: digest(plain(folder / name)) if (folder / name).is_file() else None for name in MODULES}})
  148. require((registry / 'current.json').read_bytes() == raw, 'CONCURRENT_POINTER_CHANGE')
  149. return {'schema': 'ksf.voc-generation-audit.v1', 'ok': True, 'readOnly': True,
  150. 'databaseWrites': 0, 'networkRequests': 0, 'sourcePayloadsReturned': False,
  151. 'reviewProseReturned': False, 'filesWritten': 'new-report-only',
  152. 'currentGeneration': expected_generation, 'pointerSha256': hashlib.sha256(raw).hexdigest(),
  153. 'pointerUnchanged': True, 'productId': product_id,
  154. 'versions': reports, 'transitions': transitions, 'moduleDirectories': modules}
  155. def main():
  156. p = argparse.ArgumentParser(description=__doc__)
  157. p.add_argument('--registry', type=Path, required=True)
  158. p.add_argument('--generation', action='append', required=True)
  159. p.add_argument('--expected-generation', required=True)
  160. p.add_argument('--product-id', default='jd:100005702440')
  161. p.add_argument('--module-dir', type=Path, action='append', default=[])
  162. p.add_argument('--out', type=Path, required=True)
  163. a = p.parse_args()
  164. out = plain(a.out)
  165. require(not out.exists() and out.parent.is_dir(), 'NEW_REPORT_IN_EXISTING_DIRECTORY_REQUIRED')
  166. report = audit(a.registry, a.generation, a.expected_generation, a.product_id, a.module_dir)
  167. fd = os.open(str(out), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
  168. with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as f:
  169. json.dump(report, f, ensure_ascii=False, indent=2)
  170. f.write('\n')
  171. print(json.dumps({'ok': True, 'out': str(out), 'sha256': digest(out),
  172. 'databaseWrites': 0, 'networkRequests': 0,
  173. 'versionCounts': [{'generation': v['generation'], **v['reviews']} for v in report['versions']]},
  174. ensure_ascii=False))
  175. if __name__ == '__main__':
  176. main()