package-single-sku-validation.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. """Build an offline handoff for one SKU using existing local evidence only."""
  2. import argparse
  3. import base64
  4. import hashlib
  5. import html
  6. import json
  7. from pathlib import Path
  8. import shutil
  9. import struct
  10. import zipfile
  11. ROOT = Path(__file__).resolve().parents[1]
  12. DOCS = ROOT / 'docs/single-sku-validation-20261107-r1'
  13. DEFAULT_SOURCE = Path(
  14. 'D:/下载/WXWork/1688857450794210/Cache/File/2026-11/'
  15. 'ksf-label-evidence-20261107-r1.zip'
  16. )
  17. EXPECTED_ARCHIVE_SHA = '6292c2303a391a33a4e6f393527c433f46a82c31b12643c877acfc6a1114429a'
  18. EXPECTED_GENERATION = '82b104fc024b49a75d2f5376bf9778ced6f63a9433b5fc5a6daa3c458d05d5ea'
  19. PRODUCT_ID = 'jd:100297147639'
  20. REVIEW_ID = PRODUCT_ID + ':106531290235443828'
  21. IMAGE_NAMES = (
  22. 'images/img1-包装正面.jpg',
  23. 'images/img2-营养成分表与冲调说明.jpg',
  24. 'images/img3-配料表.jpg',
  25. )
  26. def sha(raw):
  27. return hashlib.sha256(raw).hexdigest()
  28. def write_json(path, data):
  29. path.parent.mkdir(parents=True, exist_ok=True)
  30. path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  31. def jpeg_size(raw):
  32. """Read dimensions from a JPEG SOF marker without an imaging dependency."""
  33. if raw[:2] != b'\xff\xd8':
  34. raise ValueError('IMAGE_NOT_JPEG')
  35. pos = 2
  36. while pos < len(raw):
  37. if raw[pos] != 0xFF:
  38. raise ValueError('INVALID_JPEG_MARKER')
  39. while raw[pos] == 0xFF:
  40. pos += 1
  41. marker = raw[pos]
  42. pos += 1
  43. if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
  44. continue
  45. length = struct.unpack('>H', raw[pos:pos + 2])[0]
  46. if marker in (0xC0, 0xC1, 0xC2):
  47. height, width = struct.unpack('>HH', raw[pos + 3:pos + 7])
  48. return width, height
  49. if marker == 0xDA:
  50. break
  51. pos += length
  52. raise ValueError('JPEG_DIMENSIONS_NOT_FOUND')
  53. def main():
  54. parser = argparse.ArgumentParser(description=__doc__)
  55. parser.add_argument('--source', type=Path, default=DEFAULT_SOURCE)
  56. parser.add_argument('--out', type=Path,
  57. default=ROOT / 'artifacts/ksf-single-sku-validation-20261107-r1/package')
  58. args = parser.parse_args()
  59. out = args.out.resolve()
  60. if out.exists():
  61. raise ValueError('NEW_OUTPUT_REQUIRED: existing output will not be overwritten')
  62. archive_raw = args.source.read_bytes()
  63. if sha(archive_raw) != EXPECTED_ARCHIVE_SHA:
  64. raise ValueError('SOURCE_ARCHIVE_CHANGED')
  65. with zipfile.ZipFile(args.source) as archive:
  66. checks = []
  67. for line in archive.read('FILES.sha256').decode('utf-8-sig').splitlines():
  68. if not line.strip():
  69. continue
  70. expected, name = line.split(None, 1)
  71. name = name.strip()
  72. actual = sha(archive.read(name))
  73. if actual != expected:
  74. raise ValueError('SOURCE_MANIFEST_MISMATCH:' + name)
  75. checks.append({'path': name, 'sha256': actual, 'ok': True})
  76. if len(checks) != 7 or len(archive.namelist()) != 8:
  77. raise ValueError('SOURCE_MANIFEST_SCOPE_CHANGED')
  78. images = {name: archive.read(name) for name in IMAGE_NAMES}
  79. review_page_path = ROOT / 'work/opportunity-denominator-20261107-r1/page-5.json'
  80. review_page_raw = review_page_path.read_bytes()
  81. review_page = json.loads(review_page_raw)
  82. if review_page['generation'] != EXPECTED_GENERATION:
  83. raise ValueError('CACHED_GENERATION_CHANGED')
  84. matches = [r for r in review_page['records'] if r['record_id'] == REVIEW_ID]
  85. if len(matches) != 1 or matches[0]['product_id'] != PRODUCT_ID:
  86. raise ValueError('EXACT_REVIEW_NOT_FOUND')
  87. review = matches[0]
  88. if review['is_default'] is not None or review['rating_scale'] is not None:
  89. raise ValueError('REVIEW_UNKNOWN_STATES_CHANGED')
  90. dimensions = {name: jpeg_size(raw) for name, raw in images.items()}
  91. if set(dimensions.values()) != {(608, 1080)}:
  92. raise ValueError('PHOTO_DIMENSIONS_CHANGED')
  93. response = json.loads((DOCS / 'current-label-response.template.json').read_text('utf-8'))
  94. if any(value is not None for value in response['currentLabel'].values()):
  95. raise ValueError('CURRENT_LABEL_TEMPLATE_MUST_BE_UNKNOWN')
  96. out.mkdir(parents=True, exist_ok=False)
  97. (out / 'images').mkdir()
  98. (out / 'sources').mkdir()
  99. for source, name in (
  100. ('README.md', 'README.md'),
  101. ('product-material-request.md', '产品部资料需求.md'),
  102. ('comprehension-protocol.md', '说明理解测试方案.md'),
  103. ('current-label-response.template.json', 'current-label-response.template.json'),
  104. ):
  105. shutil.copyfile(DOCS / source, out / name)
  106. for name, raw in images.items():
  107. (out / name).write_bytes(raw)
  108. evidence = {
  109. 'schema': 'ksf.single-sku-validation-evidence.v1',
  110. 'preparedAt': '2026-11-07',
  111. 'productId': PRODUCT_ID,
  112. 'reviewId': REVIEW_ID,
  113. 'evidenceGeneration': EXPECTED_GENERATION,
  114. 'scope': 'one SKU; existing local evidence; no participant results',
  115. 'status': {
  116. 'currentLabel': 'NOT_RECEIVED',
  117. 'versionComparison': 'NOT_STARTED',
  118. 'productRequest': 'DRAFT_NOT_SENT',
  119. 'participantTest': 'NOT_STARTED',
  120. 'participantsObserved': 0,
  121. },
  122. 'consumerReview': {
  123. 'date': review['review_date_raw'],
  124. 'photoCaptureDate': None,
  125. 'reportedAction': '少放调料',
  126. 'statedReason': '可能盐摄入超标(消费者顾虑,非检测结论)',
  127. 'isDefault': review['is_default'],
  128. 'ratingScale': review['rating_scale'],
  129. 'identityState': review['identity_state'],
  130. 'categoryState': review['category_state'],
  131. 'ratingRaw': review['rating_raw'],
  132. 'sourceRecordId': review['source_record_id'],
  133. },
  134. 'historicalPhotoLabel': {
  135. 'verification': 'read_from_consumer_photo; not current-version confirmation',
  136. 'nutrition': {
  137. 'servingSizeG': 96,
  138. 'servingSizeBasis': '每份(96克)',
  139. 'sodiumMgPerServing': 2414,
  140. 'sodiumNrvPercent': 121,
  141. 'derivedSodiumMgPer100g': 2414 / 96 * 100,
  142. 'displayDerivedSodiumMgPer100g': 2515,
  143. 'derivation': '2414 / 96 * 100; calculated, not printed per100g or laboratory measured',
  144. 'netWeightG': None,
  145. 'servingsPerPackage': None,
  146. 'sourceImage': IMAGE_NAMES[1],
  147. },
  148. 'preparation': {
  149. 'methodVisible': '方法二:泡面',
  150. 'waterMlVisible': 460,
  151. 'waterTemperatureVisible': '沸水',
  152. 'stepsSummary': '面饼置碗,依次加入粉包、酱包、菜包;后加水',
  153. 'timeVisibleFragment': '静置4…',
  154. 'completeMinutes': None,
  155. 'completeInstructions': None,
  156. 'doseRatio': None,
  157. 'allPreparationMethods': None,
  158. 'sourceImage': IMAGE_NAMES[1],
  159. },
  160. 'packagingCodeVisible': 'WXFC-1A',
  161. 'standardCodeVisible': 'GB 17400',
  162. 'shelfLifeMonthsVisible': 6,
  163. 'productionDate': None,
  164. 'batch': None,
  165. 'isCurrentVersion': None,
  166. 'ingredientObservation': '面饼、粉包、酱包均列有食用盐;不可由半包粉包推算整份钠减半',
  167. 'sourceImageForCodeAndIngredients': IMAGE_NAMES[2],
  168. },
  169. 'skuCorrespondence': {
  170. 'flavor': 'photo and purchased SKU are consistent',
  171. 'titleDeclaredG': 96,
  172. 'titleDeclaredPackCount': 24,
  173. 'photoServingSizeAgreesWithTitleNumber': True,
  174. 'boxCountVerifiedByPhoto': False,
  175. 'netWeightVerifiedByPhoto': False,
  176. 'currentVersionVerified': False,
  177. },
  178. 'firstRoundPlan': {
  179. 'plannedParticipants': 5,
  180. 'observedParticipants': 0,
  181. 'recruitmentStarted': False,
  182. 'method': 'packaging comprehension interview; no preparation or tasting',
  183. 'results': [],
  184. 'screeningRule': 'at least 2 of 5 showing the same key misunderstanding is a revision signal, not a prevalence estimate',
  185. 'secondRoundConditional': '5 different participants after an evidence-backed wording revision',
  186. },
  187. 'otherMissingSkuIds': [
  188. 'jd:100015905411', 'jd:100018673006', 'jd:100061767875',
  189. 'jd:100001974969', 'jd:5590761', 'jd:5532971', 'jd:6982483',
  190. ],
  191. 'photos': [{'path': name, 'sha256': sha(raw), 'bytes': len(raw),
  192. 'width': dimensions[name][0], 'height': dimensions[name][1]}
  193. for name, raw in images.items()],
  194. 'boundaries': {
  195. 'paidCallsThisBuild': 0,
  196. 'networkRequestsThisBuild': 0,
  197. 'productionWritesThisBuild': 0,
  198. 'externalMessagesSent': 0,
  199. 'ledgerCarriedForward': {'chargedCny': 46.20, 'pendingCny': 0, 'protectedCny': 3.80},
  200. 'eightStoreV3Changed': False,
  201. 'opportunityRankingChanged': False,
  202. 'formulationDecisionMade': False,
  203. 'labelPublicationAuthorizedByThisPackage': False,
  204. },
  205. }
  206. write_json(out / 'evidence.json', evidence)
  207. write_json(out / 'sources/review.json', {
  208. 'generation': EXPECTED_GENERATION,
  209. 'cachedPage': review_page_path.relative_to(ROOT).as_posix(),
  210. 'cachedPageSha256': sha(review_page_raw),
  211. 'readbackPreviouslySaved': True,
  212. 'record': review,
  213. })
  214. write_json(out / 'sources/archive-verification.json', {
  215. 'sourceArchive': args.source.name,
  216. 'bytes': len(archive_raw),
  217. 'sha256': sha(archive_raw),
  218. 'archiveEntries': 8,
  219. 'manifestEntriesChecked': 7,
  220. 'manifestSelfHashed': False,
  221. 'allChecksPassed': True,
  222. 'checks': checks,
  223. 'selectedOriginalPhotos': list(IMAGE_NAMES),
  224. 'note': 'source report prose is not copied; corrected facts are separately recorded in evidence.json',
  225. })
  226. captions = ('p1 · 包装正面:口味', 'p2 · 营养表与冲调局部', 'p3 · 配料表与包装代码')
  227. gallery = []
  228. for name, caption in zip(IMAGE_NAMES, captions):
  229. encoded = base64.b64encode(images[name]).decode('ascii')
  230. gallery.append(
  231. '<figure><img src="data:image/jpeg;base64,' + encoded + '" '
  232. 'width="608" height="1080" alt="' + html.escape(caption, quote=True) + '">'
  233. '<figcaption>' + html.escape(caption) + '<br><a href="' + html.escape(name, quote=True) +
  234. '" target="_blank" rel="noopener">打开原图</a></figcaption></figure>'
  235. )
  236. page = (DOCS / 'index.template.html').read_text('utf-8')
  237. page = page.replace('%%REVIEW_QUOTE%%', html.escape(review['content']))
  238. page = page.replace('%%GALLERY%%', ''.join(gallery))
  239. page = page.replace('%%RESPONSE_TEMPLATE%%',
  240. json.dumps(response, ensure_ascii=False).replace('<', '\\u003c'))
  241. if '%%' in page:
  242. raise ValueError('UNRESOLVED_TEMPLATE_TOKEN')
  243. (out / 'index.html').write_text(page, encoding='utf-8')
  244. print(json.dumps({'ok': True, 'packageDirectory': str(out),
  245. 'state': 'BUILT_AWAITING_LOCAL_RENDER_VERIFICATION',
  246. 'paidCalls': 0, 'productionWrites': 0}, ensure_ascii=False))
  247. if __name__ == '__main__':
  248. main()