package-industry-details.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. """Freeze r7 on immutable r6, applying only the product-detail evidence changes."""
  2. import argparse
  3. import ast
  4. from datetime import datetime, timezone
  5. import difflib
  6. import json
  7. from pathlib import Path
  8. import shutil
  9. import sqlite3
  10. import zipfile
  11. import hashlib
  12. ROOT = Path(__file__).resolve().parents[1]
  13. BASE = ROOT / 'artifacts/ksf-industry-monitor-20261107-r6/package'
  14. WORK = ROOT / 'work/industry-monitoring-20261107-r1'
  15. RELEASE = 'ksf-industry-monitor-20261107-r7'
  16. def sha(data):
  17. return hashlib.sha256(data).hexdigest()
  18. def once(text, old, new):
  19. if text.count(old) != 1:
  20. raise ValueError('FROZEN_INTEGRATION_ANCHOR_CHANGED:' + old[:100])
  21. return text.replace(old, new)
  22. def function(text, name):
  23. node = next(n for n in ast.walk(ast.parse(text)) if isinstance(n, ast.FunctionDef) and n.name == name)
  24. return ''.join(text.splitlines(True)[node.lineno - 1:node.end_lineno])
  25. def build(out):
  26. if out.exists():
  27. raise ValueError('RELEASE_DIRECTORY_MUST_BE_NEW')
  28. for line in (BASE / 'FILES.sha256').read_text('utf-8').splitlines():
  29. digest, name = line.split(' ', 1)
  30. if sha((BASE / name).read_bytes()) != digest:
  31. raise ValueError('R6_FROZEN_BASELINE_CHANGED')
  32. shutil.copytree(BASE, out)
  33. patch = []
  34. def save(relative, before, after):
  35. (out / relative).write_text(after, 'utf-8')
  36. patch.extend(difflib.unified_diff(before.splitlines(True), after.splitlines(True), fromfile='r6/' + relative, tofile='r7/' + relative))
  37. relative = 'server/voc-library/industry_monitor.py'
  38. before = (BASE / relative).read_text('utf-8')
  39. current = (ROOT / relative).read_text('utf-8')
  40. old_method, new_method = function(before, 'listings'), function(current, 'listings')
  41. # Prove the copied method contains only the explicit detail-evidence additions.
  42. stripped = once(new_method, ', page=1, detail_query=None):', ', page=1):')
  43. stripped = once(stripped, " from monitoring_details import projection as detail_projection\n detail_evidence = detail_projection(self.task_store, self.data['generation'], ids, detail_query, TITLE_RULES)\n", '')
  44. stripped = once(stripped, ", 'detailEvidence': detail_evidence", '')
  45. if stripped != old_method:
  46. raise ValueError('UNRELATED_LISTINGS_METHOD_CHANGES')
  47. after = once(before, old_method, new_method)
  48. after = once(after, "'listingPage', 'taskState'", "'listingPage', 'detailQuery', 'taskState'")
  49. after = once(after, "{'titleClaim', 'listingPage'}.intersection(request)", "{'titleClaim', 'listingPage', 'detailQuery'}.intersection(request)")
  50. after = once(after, "request.get('listingPage', 1))", "request.get('listingPage', 1), request.get('detailQuery'))")
  51. contract = " result['detailQuery'] = " + current.split(" result['detailQuery'] = ", 1)[1].split('\n elif', 1)[0] + '\n'
  52. after = once(after, " elif op == 'overview': result = monitor.overview()", contract + " elif op == 'overview': result = monitor.overview()")
  53. save(relative, before, after)
  54. relative = 'payload/src/pages/view-industry.js'
  55. before = (BASE / relative).read_text('utf-8')
  56. current_ui = (ROOT / 'src/pages/view-industry.js').read_text('utf-8')
  57. after = once(before, "import { rankingDiscoveryView } from './industry-ranking-view.js';", "import { rankingDiscoveryView } from './industry-ranking-view.js';\nimport { detailEvidenceView, DETAIL_CSS } from './industry-detail-view.js';")
  58. anchor = " if (claim) claim.value = this.routeQuery.titleClaim || '';"
  59. after = once(after, anchor, anchor + "\n const detailState = this.renderRoot.querySelector('select[data-detail-state]');\n if (detailState) detailState.value = this.routeQuery.detailState || '';")
  60. old = " if (Object.keys(patch).some(k => ['listingQ', 'titleClaim'].includes(k))) patch = { ...patch, listingPage: '' };"
  61. new = current_ui.split(" if (Object.keys(patch).some(k => ['listingQ', 'titleClaim']", 1)[1].split(" if (Object.keys(patch).some(k => ['reviewQ'", 1)[0]
  62. after = once(after, old + '\n', " if (Object.keys(patch).some(k => ['listingQ', 'titleClaim']" + new)
  63. anchor = ' if (this.routeQuery.listingPage) request.listingPage = Number(this.routeQuery.listingPage);'
  64. detail_request = current_ui.split(' request.detailQuery = {};', 1)[1].split('\n }', 1)[0]
  65. after = once(after, anchor, anchor + '\n request.detailQuery = {};' + detail_request)
  66. reset = next(line for line in current_ui.splitlines() if "if (this._tab === 'listings') return html`<button" in line)
  67. after = once(after, ' _researchResetView() {', ' _researchResetView() {\n' + reset)
  68. after = once(after, 'return html`${this._listingRecordsView(d)}<section', 'return html`${this._listingRecordsView(d)}${detailEvidenceView(this, d.detailEvidence)}<section')
  69. after = once(after, 'static styles = [VIEW_CSS, css`', 'static styles = [VIEW_CSS, DETAIL_CSS, css`')
  70. save(relative, before, after)
  71. relative = 'payload/src/services/industry-service.js'
  72. before = (BASE / relative).read_text('utf-8')
  73. additions = '\n'.join(line for line in (ROOT / 'src/services/industry-service.js').read_text('utf-8').splitlines() if line.startswith(' DETAIL_')) + '\n'
  74. save(relative, before, once(before, 'const MESSAGES = {\n', 'const MESSAGES = {\n' + additions))
  75. for name in ('server/voc-library/monitoring_details.py', 'server/voc-library/test_monitoring_details.py',
  76. 'scripts/register-industry-details.py'):
  77. shutil.copyfile(ROOT / name, out / name)
  78. shutil.copyfile(ROOT / 'src/pages/industry-detail-view.js', out / 'payload/src/pages/industry-detail-view.js')
  79. shutil.copyfile(ROOT / 'docs/industry-monitoring-20261107/detail-evidence.md', out / 'docs/detail-evidence.md')
  80. shutil.copyfile(WORK / 'detail-bootstrap.json', out / 'evidence/detail-bootstrap.json')
  81. (out / 'evidence/detail-evidence.patch').write_text(''.join(patch), 'utf-8')
  82. # Copy only the auxiliary registry; complete detail originals stay in the private local archive.
  83. with sqlite3.connect((WORK / 'monitoring-registry/monitoring.sqlite').resolve().as_uri() + '?mode=ro', uri=True) as source:
  84. with sqlite3.connect(out / 'data/monitoring-registry/monitoring.sqlite') as c:
  85. source.backup(c)
  86. c.execute("UPDATE pipeline_workers SET state='ARCHIVED_COPY'")
  87. c.execute("UPDATE dispatch_audits SET state='ARCHIVED_COPY'")
  88. c.commit()
  89. for file in (WORK / 'monitoring-registry/assets').iterdir():
  90. dest = out / 'data/monitoring-registry/assets' / file.name
  91. if not dest.exists():
  92. shutil.copyfile(file, dest)
  93. if (out / 'data/monitoring-registry/detail-originals').exists():
  94. raise ValueError('PRIVATE_DETAIL_ORIGINALS_MUST_NOT_BE_PACKAGED')
  95. manifest_path = out / 'deploy/industry-ui-files.json'
  96. manifest = json.loads(manifest_path.read_text('utf-8'))
  97. manifest['release'] = RELEASE
  98. manifest['files'].append({'path': 'src/pages/industry-detail-view.js', 'allowMissing': True,
  99. 'priorNormalizedSha256': [], 'observedProductionSha256': None})
  100. for entry in manifest['files']:
  101. previous = BASE / 'payload' / entry['path']
  102. if previous.is_file():
  103. prior = sha(previous.read_bytes().replace(b'\r\n', b'\n'))
  104. if prior not in entry['priorNormalizedSha256']:
  105. entry['priorNormalizedSha256'].append(prior)
  106. entry['sha256'] = sha((out / 'payload' / entry['path']).read_bytes())
  107. manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', 'utf-8')
  108. publisher = out / 'scripts/publish-industry-ui.mjs'
  109. script = publisher.read_text('utf-8').replace('ksf-industry-monitor-20261107-r6', RELEASE)
  110. old = next(line for line in script.splitlines() if line.startswith('export const FILES='))
  111. script = once(script, old, 'export const FILES=Object.freeze(' + json.dumps([e['path'] for e in manifest['files']]) + ');')
  112. publisher.write_text(script, 'utf-8')
  113. for directory in ('docs', 'deploy'):
  114. for path in (out / directory).iterdir():
  115. if path.is_file() and path.suffix in ('.md', '.example'):
  116. path.write_text(path.read_text('utf-8-sig').replace('ksf-industry-monitor-20261107-r6', RELEASE), 'utf-8')
  117. with (out / 'docs/README.md').open('a', encoding='utf-8') as stream:
  118. stream.write('\n\n## r7:商品详情证据\n\n新增详情原文、字段空值/缺失、图片顺序、同响应主题组合候选及Agent读取合同。操作与覆盖见[detail-evidence.md](detail-evidence.md)。4份历史原件均无详情卖点文字,不称全量覆盖。包内仅含字段投影,完整详情原件留在本机受限档案。\n')
  119. release_path = out / 'RELEASE.json'
  120. release = json.loads(release_path.read_text('utf-8'))
  121. release.update(release=RELEASE, createdAt=datetime.now(timezone.utc).isoformat(), builtFromFrozenRelease='ksf-industry-monitor-20261107-r6',
  122. detailEvidenceImplemented=True, detailObservations=4, productsWithDetailText=0,
  123. privateDetailOriginalsIncluded=False, overallComplete=False)
  124. release['backendFiles'].append('server/voc-library/monitoring_details.py')
  125. release['staticFiles'].append('src/pages/industry-detail-view.js')
  126. release_path.write_text(json.dumps(release, ensure_ascii=False, indent=2) + '\n', 'utf-8')
  127. print(json.dumps({'ok': True, 'candidate': str(out), 'sealed': False, 'paidCalls': 0}))
  128. def seal(out):
  129. receipt = json.loads((out / 'evidence/implementation-receipt-v7.json').read_text('utf-8'))
  130. if not receipt.get('browserPassed') or receipt.get('tests', {}).get('python') != 71 or not receipt.get('packageVerificationPassed'):
  131. raise ValueError('CANDIDATE_ACCEPTANCE_REQUIRED')
  132. for name, digest in receipt['testedFilesSha256'].items():
  133. if sha((out / name).read_bytes()) != digest:
  134. raise ValueError('TESTED_SOURCE_CHANGED')
  135. archive = out.parent / (RELEASE + '.zip')
  136. if archive.exists():
  137. raise ValueError('SEALED_ARCHIVE_ALREADY_EXISTS')
  138. files = sorted(p for p in out.rglob('*') if p.is_file() and p != out / 'FILES.sha256')
  139. (out / 'FILES.sha256').write_text(''.join(sha(p.read_bytes()) + ' ' + p.relative_to(out).as_posix() + '\n' for p in files), 'utf-8')
  140. with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED) as z:
  141. for path in sorted(out.rglob('*')):
  142. if path.is_file():
  143. z.write(path, path.relative_to(out).as_posix())
  144. print(json.dumps({'ok': True, 'archive': str(archive), 'bytes': archive.stat().st_size,
  145. 'sha256': sha(archive.read_bytes()), 'productionDeployed': False, 'paidCalls': 0}))
  146. if __name__ == '__main__':
  147. p = argparse.ArgumentParser(description=__doc__)
  148. p.add_argument('--out', type=Path, required=True)
  149. p.add_argument('--seal', action='store_true')
  150. args = p.parse_args()
  151. (seal if args.seal else build)(args.out.resolve())