build-industry-policy-usage.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. """Build a source-bound policy review from local originals; never writes a database."""
  2. import argparse
  3. from collections import Counter
  4. from datetime import datetime, timezone
  5. import hashlib
  6. from html.parser import HTMLParser
  7. import json
  8. from pathlib import Path
  9. import re
  10. import shutil
  11. import sqlite3
  12. ROOT = Path(__file__).resolve().parents[1]
  13. GENERATION = '82b104fc024b49a75d2f5376bf9778ced6f63a9433b5fc5a6daa3c458d05d5ea'
  14. def sha(raw):
  15. return hashlib.sha256(raw).hexdigest()
  16. def norm(value):
  17. return re.sub(r'\s+', '', value)
  18. class PageText(HTMLParser):
  19. def __init__(self):
  20. super().__init__(convert_charrefs=True)
  21. self.parts, self.hidden = [], []
  22. def handle_starttag(self, tag, attrs):
  23. if tag in ('script', 'style'):
  24. self.hidden.append(tag)
  25. def handle_endtag(self, tag):
  26. if self.hidden and self.hidden[-1] == tag:
  27. self.hidden.pop()
  28. def handle_data(self, text):
  29. if not self.hidden:
  30. self.parts.append(text)
  31. def text_of(raw):
  32. p = PageText()
  33. p.feed(raw.decode('utf-8-sig'))
  34. return re.sub(r'\s+', ' ', ' '.join(p.parts)).strip()
  35. def save(path, data):
  36. path.parent.mkdir(parents=True, exist_ok=True)
  37. path.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
  38. # Page citations below were visually read from the exact, hash-matched PDFs.
  39. # Table values remain structured cells rather than invented contiguous quotations.
  40. NOTES = {
  41. 1: dict(topic='工艺分类', applies='符合GB17400范围的方便面;实际干燥工艺需生产资料确认',
  42. inputs=['干燥工艺记录', '产品配方与食用方法'],
  43. steps=['先核范围及产品定义,再据生产记录区分油炸和非油炸。', '分别保存面饼与调料包的对象;非油炸标签不能替代营养含量证据。'],
  44. limits='工艺定义不证明低脂或低能量。'),
  45. 2: dict(topic='面饼理化检验', applies='油炸/非油炸面饼;酸价和过氧化值为油炸面饼、以脂肪计',
  46. inputs=['已核干燥工艺', '面饼样品与批次', '检测报告及方法版本'],
  47. steps=['按工艺核水分上限:油炸10.0、非油炸14.0 g/100g。', '油炸面饼核酸价≤1.8 mg KOH/g、过氧化值≤0.25 g/100g,保留以脂肪计。', '分别回查表中GB5009.3、GB5009.229、GB5009.227及适用版本。'],
  48. limits='本条只核表2,不代替整份产品标准或商品检测。'),
  49. 3: dict(topic='污染物和致病菌的交叉引用', applies='GB17400产品;须继续核目标标准中的食品类别、对象及采样条件',
  50. inputs=['配方及食用方式', '检验对象和采样方案', '引用标准原文及适用版本'],
  51. steps=['回查GB2762中带馅(料)面米制品的对应项目。', '回查GB29921中方便面米制品的项目及适用范围。', '保存目标标准页码、限量、单位、采样n/c/m/M;未补齐时不出合格结论。'],
  52. limits='引用标准号不是完成了目标标准逐项检验。'),
  53. 4: dict(topic='添加剂与营养强化剂', applies='具体配方、食品类别和物质;面饼与料包的对象须明确',
  54. inputs=['完整配方与各包组成', '添加剂通用名及功能', '用量/残留量与质量规格'],
  55. steps=['添加剂查GB2760:先定本标准食品类别,再查附录A对应物质、用量、限制及带入条件。', '强化剂查GB14880:同时核附录A使用范围和量、附录B化合物来源;特殊膳食另核适用规定。', '将物质、食品类别、工艺、用量、目标表页码逐项留档。'],
  56. limits='不把新品种公告标题或其他食品的许可当方便面配方许可。'),
  57. 5: dict(topic='钠含量声称', applies='采用GB28050-2011核查的预包装食品;先核生产日期及适用版本',
  58. inputs=['营养表/检测证据', '每份质量或体积', '可食部及干品/冲调后、料包范围'],
  59. steps=['记录原标示及单位,统一每100g或每100mL基准。', '分别核无钠≤5、极低钠≤40、低钠≤120 mg/100g或100mL。', '保留同页表注:以份计量也须符合每100g(mL)的要求;符合钠声称时可按表述用盐字替代钠。'],
  60. limits='减小每份量不会自动达到含量声称要求;偏咸评价不是钠检测证据。'),
  61. 6: dict(topic='膳食纤维声称', applies='采用GB28050-2011核查的预包装食品;固体/液体/能量基准分别记录',
  62. inputs=['纤维检测或营养表', '固体/液体状态', '每100g、每100mL或每420kJ基准'],
  63. steps=['来源/含有阈值为3g/100g(固体)、1.5g/100mL(液体)或1.5g/420kJ。', '高/富含/良好来源阈值为6g/100g(固体)、3g/100mL(液体)或3g/420kJ。', '核限制性条件:总膳食纤维,或可溶性、不溶性、单体成分任一项符合;保留检测对象与选用基准。', '使用份标示时仍保留表下注释,不靠缩小份量获得声称。'],
  64. limits='全谷物、粗粮或杂粮标题不能证明纤维含量。'),
  65. 7: dict(topic='减少钠的比较声称', applies='同类或同一属类、消费者熟知且容易理解的参考食品比较',
  66. inputs=['参考食品身份及同类依据', '双方同基准含量证据', '比较日期及食品状态'],
  67. steps=['保留参考食品和候选食品的原件与计量基准。', '按(参考值−候选值)÷参考值×100%记录差异,核表C.3的减少25%以上要求。', '含量声称与比较声称分别留档,另核拟使用版本和措辞。'],
  68. limits='减少钠不等于低钠;半包粉包不等于全产品钠减半。'),
  69. 8: dict(topic='标签换版:无/不含等措辞', applies='GB7718-2025的解释资料;原标准封面实施日2027-03-16',
  70. inputs=['目标生产日期', '拟用标签及词语', '实施与过渡安排'],
  71. steps=['定位问答33列明的三类对象,并回读GB7718-2025第4.4.2.1条。', '分别保存食品添加剂、污染物、禁止添加/不应存在物质的词语核查。', '按生产批次和实施安排确定标签方案。'],
  72. limits='问答是解释资料;不把未来版当作2026年所有商品已强制实施。'),
  73. 9: dict(topic='标签换版:配料、图片和致敏物', applies='GB7718-2025问答31、35、38对应情形;先核版本和例外',
  74. inputs=['配料比例及来源', '标签图片与真实配料关系', '八类致敏物质清单'],
  75. steps=['问答31:核特别强调配料/成分时的定量标示与例外。', '问答35:区分真实配料与仅香精香料调味,核照片/图案及相邻提示。', '问答38:核直接作为配料使用的八类致敏物及标示形式;与可能交叉带入的预防性提示分开。'],
  76. limits='归档摘要只作索引;实际标签逐项回读正式标准和解释条件。'),
  77. 10: dict(topic='全谷物创新背景', applies='面向成人膳食结构的指导信息,不是单份方便面的配方比例',
  78. inputs=['原料完整籽粒结构证据', '配方比例', '口感与保存验证'],
  79. steps=['先核全谷物定义,区分完整结构与仅粗粮/杂粮名称。', '50—100g指成人每日全谷物摄入建议,不能直接写成单份产品目标。', '把日常搭配、口感、保存条件列为研发验证项。'],
  80. limits='全谷物指导不能替代高纤维等营养声称标准。'),
  81. 11: dict(topic='营养标签选择背景', applies='蔬菜水果章节中的加工制品选择信息;推广到方便面属于研究解释',
  82. inputs=['章节语境', '营养表原件', '同基准糖/钠/脂肪含量'],
  83. steps=['保留第8条前句“鼓励多食新鲜的蔬菜、水果”及后句加工制品语境。', '将营养成分作为可研究比较维度,具体声称回到适用标准。'],
  84. limits='原摘要省略蔬菜水果语境;本次补充适用说明,不改线上原条款。'),
  85. 12: dict(topic='三减研发和标签识读背景', applies='营养指导员在餐饮与食品健康服务中的工作指导',
  86. inputs=['服务对象及场景', '食品营养信息', '研发与消费者沟通验证计划'],
  87. steps=['区分餐饮油盐糖用量管理、协助企业三减创新和标签识读三类工作。', '将其用于研发议题与教育设计;单品数值、声称与安全判断另查产品标准及证据。'],
  88. limits='指导员服务规范不提供方便面减盐比例或单品营养功效许可。'),
  89. }
  90. HTML_SECTIONS = {
  91. 8: [('三十三、', '三十四、')],
  92. 9: [('三十一、', '三十二、'), ('三十五、', '三十六、'), ('三十八、', '三十九、')],
  93. 10: [('12.', '13.'), ('14.', '15.'), ('16.', '17.'), ('20.', '21.')],
  94. 11: [('8.', '9.')],
  95. 12: [('3. 指导餐厅', '4. 协助食品'), ('4. 协助食品', '5. 在营养'), ('6. 提供食物选购指导', '(五)')],
  96. }
  97. MANUAL_QUOTES = {
  98. 1: ['采用油炸工艺干燥的方便面,包括泡面、干吃面和煮面。',
  99. '采用除油炸以外的其他工艺(如微波、真空和热风等)干燥的方便面,包括泡面、干吃面和煮面。'],
  100. 3: ['污染物限量应符合GB 2762中带馅(料)面米制品的规定。',
  101. '致病菌限量应符合GB 29921中方便面米制品的规定。'],
  102. 4: ['食品添加剂的使用应符合GB 2760的规定。', '食品营养强化剂的使用应符合GB 14880的规定。'],
  103. }
  104. def main():
  105. parser = argparse.ArgumentParser(description=__doc__)
  106. parser.add_argument('--work', type=Path, default=ROOT/'work/industry-policy-usage-20261108-r1')
  107. parser.add_argument('--out', type=Path, required=True)
  108. args = parser.parse_args()
  109. assert not args.out.exists(), 'OUTPUT_ALREADY_EXISTS'
  110. snapshot_path = ROOT/'work/industry-monitoring-20261107-r1/snapshot.json'
  111. snapshot_sha = sha(snapshot_path.read_bytes())
  112. assert snapshot_sha == '45b3d7f4cda3d70b4e3398da10fc8c21055d80a8c3ff2e33997ba79f06719408'
  113. snapshot = json.loads(snapshot_path.read_bytes())
  114. assert snapshot['generation'] == GENERATION
  115. docs = {d['id']: d for d in snapshot['entities']['documents']}
  116. inventory = json.loads((args.work/'pdf-inventory.json').read_bytes())
  117. registry = ROOT/'artifacts/ksf-industry-monitor-20261107-r11/package/data/monitoring-registry'
  118. db = registry/'monitoring.sqlite'
  119. db_sha = sha(db.read_bytes())
  120. args.out.mkdir(parents=True)
  121. sources, pages, html_texts = {}, {}, {}
  122. document_rows = []
  123. for row in inventory['rows']:
  124. ident = row['documentId']
  125. pdf = Path(row['pdfPath'])
  126. assert sha(pdf.read_bytes()) == row['sha256'] == docs[ident]['expected_sha256']
  127. rel = 'sources/'+ident+'.pdf'
  128. (args.out/'sources').mkdir(exist_ok=True)
  129. shutil.copyfile(pdf, args.out/rel)
  130. pages[ident] = json.loads(Path(row['textFile']).read_bytes())
  131. assert len(pages[ident]) == row['pages']
  132. for p in pages[ident]:
  133. assert sha(p['text'].encode()) == p['textSha256']
  134. sources[ident] = {'file': rel, 'sha256': row['sha256'], 'sourceUrl': docs[ident]['source_url']}
  135. render = args.work/'renders'/(ident+'-p1.png')
  136. dest = args.out/'previews'/render.name
  137. dest.parent.mkdir(exist_ok=True)
  138. shutil.copyfile(render, dest)
  139. document_rows.append({'documentId': ident, 'title': docs[ident]['title'], **sources[ident],
  140. 'pdfPages': row['pages'], 'coverVisualVerified': True,
  141. 'coverRender': 'previews/'+render.name, 'coverRenderSha256': sha(render.read_bytes()),
  142. 'issuedDateOnCover': docs[ident]['issued_date'], 'effectiveDateOnCover': docs[ident]['effective_date'],
  143. 'notYetEffectiveOnReviewDate': docs[ident]['effective_date'] > '2026-11-08',
  144. 'declaredStatusInOldSnapshot': docs[ident]['declared_status'],
  145. 'declaredStatusAsOf': docs[ident]['status_as_of'], 'currentLegalStatusRecheckedThisRun': False,
  146. 'textLayerPresent': any(p['text'].strip() for p in pages[ident]),
  147. 'substantivePagesReviewedThisRun': [3, 4, 5] if ident == 'GB17400-2015' else [3, 4, 10] if ident == 'GB28050-2011' else [3,4] if ident=='GB14880-2012' else [4,5,249] if ident=='GB2760-2024' else [5] if ident=='GB7718-2025' else []})
  148. with sqlite3.connect(db.resolve().as_uri()+'?mode=ro', uri=True) as con:
  149. pubs = [json.loads(r[0]) for r in con.execute('SELECT payload FROM policy_publications')]
  150. for pub in pubs:
  151. ident = pub['documentId']
  152. raw = (registry/'assets'/(pub['archiveWrapperSha256']+'.json')).read_bytes()
  153. assert sha(raw) == docs[ident]['expected_sha256'] == pub['archiveWrapperSha256']
  154. wrapper = json.loads(raw)
  155. assert wrapper['url'] == docs[ident]['source_url']
  156. body = wrapper['html'].encode()
  157. assert sha(body) == pub['pageSha256']
  158. text = text_of(body)
  159. assert norm(docs[ident]['title']) in norm(text)
  160. html_texts[ident] = text
  161. rel = 'sources/'+ident+'.html'
  162. (args.out/rel).write_bytes(body)
  163. (args.out/'sources'/(ident+'.wrapper.json')).write_bytes(raw)
  164. sources[ident] = {'file': rel, 'sha256': sha(body), 'sourceUrl': wrapper['url'],
  165. 'wrapperSha256': sha(raw), 'observedAtOriginal': wrapper['collectedAt']}
  166. visible = re.search(r'发布时间[::]\s*(\d{4}-\d{2}-\d{2})', text)
  167. visible_day = visible[1] if visible else None
  168. document_rows.append({'documentId': ident, 'title': docs[ident]['title'], **sources[ident],
  169. 'publicationIdentityMatched': True, 'metadataPublishedAtRaw': pub['publishedAtRaw'],
  170. 'visiblePublishedDate': visible_day,
  171. 'publicationDateConflict': visible_day is not None and visible_day != pub['publishedAtRaw'][:10],
  172. 'currentLegalStatusRecheckedThisRun': False, 'declaredStatusInOldSnapshot': docs[ident]['declared_status']})
  173. for ident, doc in docs.items():
  174. if ident not in sources:
  175. document_rows.append({'documentId': ident, 'title': doc['title'], 'sourceUrl': doc['source_url'],
  176. 'expectedSha256': doc['expected_sha256'], 'originalAvailableInThisReview': False,
  177. 'archiveTextAvailable': bool(doc.get('full_text')), 'declaredStatusInOldSnapshot': doc['declared_status'],
  178. 'currentLegalStatusRecheckedThisRun': False})
  179. clauses = []
  180. for old in sorted(snapshot['entities']['clauses'], key=lambda c: c['source']['line_number']):
  181. n, ident = old['source']['line_number'], old['document_id']
  182. r = {'clauseId': old['id'], 'sourceLine': n, 'documentId': ident, 'clause': old['clause'],
  183. 'archivedExcerpt': old['excerpt'], 'archivedExcerptType': old['excerpt_type'],
  184. 'archivedApplicationNote': old['application_note'], 'sourceRecordId': old['source_record_id'],
  185. 'sourceUrl': docs[ident]['source_url'], 'declaredDocumentStatus': docs[ident]['declared_status'],
  186. 'currentLegalStatusRecheckedThisRun': False, 'productComplianceDetermined': False,
  187. 'interpretationIsRegulation': False, 'sourceReferences': []}
  188. if n <= 7:
  189. page = int(old['pdf_page'])
  190. render = args.work/'renders'/(ident+'-p'+str(page)+'.png')
  191. assert render.is_file()
  192. dest = args.out/'previews'/render.name
  193. shutil.copyfile(render, dest)
  194. ref = {**sources[ident], 'pdfPage': page,
  195. 'printedPage': str(page-2 if ident == 'GB17400-2015' else page-1),
  196. 'renderFile': 'previews/'+render.name, 'renderSha256': sha(render.read_bytes()),
  197. 'visuallyVerified': True}
  198. if n in MANUAL_QUOTES:
  199. ref.update(quoteMethod='manual_transcription_from_visual_page_layout_normalized',
  200. quotes=MANUAL_QUOTES[n])
  201. elif n == 2:
  202. ref.update(quoteMethod='manual_table_cell_transcription', cells=[
  203. {'object': '油炸面饼', 'metric': '水分', 'max': '10.0', 'unit': 'g/100g', 'testMethod': 'GB5009.3'},
  204. {'object': '非油炸面饼', 'metric': '水分', 'max': '14.0', 'unit': 'g/100g', 'testMethod': 'GB5009.3'},
  205. {'object': '油炸面饼', 'metric': '酸价(以脂肪计)', 'max': '1.8', 'unit': 'mg KOH/g', 'testMethod': 'GB5009.229'},
  206. {'object': '油炸面饼', 'metric': '过氧化值(以脂肪计)', 'max': '0.25', 'unit': 'g/100g', 'testMethod': 'GB5009.227'}])
  207. else:
  208. ref.update(quoteMethod='pdf_text_layer_with_visual_table_check',
  209. pageText=pages[ident][page-1]['text'], textSha256=pages[ident][page-1]['textSha256'])
  210. r['sourceReferences'].append(ref)
  211. r['verification'] = 'PDF_PAGE_VERIFIED'
  212. elif n <= 12:
  213. text = html_texts[ident]
  214. for begin, end in HTML_SECTIONS[n]:
  215. start = text.index(begin)
  216. stop = text.index(end, start+len(begin))
  217. r['sourceReferences'].append({**sources[ident], 'headingStart': begin, 'headingEndExclusive': end,
  218. 'textStart': start, 'textEndExclusive': stop, 'quote': text[start:stop],
  219. 'extraction': 'HTMLParser visible text, whitespace collapsed; character offsets in extracted text'})
  220. r['verification'] = 'ARCHIVED_OFFICIAL_HTML_VERIFIED'
  221. else:
  222. r.update(verification='PENDING_ORIGINAL', topic='资料待核',
  223. applies='原件及条款适用条件未复核', inputs=['与库内期望SHA一致的原件', '附件及其实施日期、适用范围'],
  224. steps=['先找回原件并核SHA、标题和定位。', '公告目录回查对应标准正文;三新食品回查批准附件、食品类别、限量、适用人群和产品类型。', '取得原件后逐条补引用;现有摘要保留为查找线索。'],
  225. limits='该条不能直接给出配方、用量、标签或合规结论。')
  226. if 13 <= n <= 17:
  227. r['topic'] = '数字标签资料待核'
  228. r['steps'] = ['找回数字标签公告原件并核发布信息和适用条件。', '分别核营销信息边界、可读性、变更追溯和实体标签保留义务。']
  229. elif 19 <= n <= 23:
  230. r['topic'] = '新标准目录线索'
  231. elif n in (25,29):
  232. r['topic'] = '历史征集或征求意见线索'
  233. if n in NOTES:
  234. r.update(NOTES[n])
  235. if n in (4,8,9):
  236. support = ([('GB2760-2024',5),('GB2760-2024',249),('GB14880-2012',4)] if n==4 else [('GB7718-2025',5)])
  237. r['supportingSourceReferences']=[]
  238. for docid,page in support:
  239. text=pages[docid][page-1]['text']
  240. render=args.work/'renders'/(docid+'-p'+str(page)+'.png')
  241. assert render.is_file()
  242. shutil.copyfile(render,args.out/'previews'/render.name)
  243. r['supportingSourceReferences'].append({**sources[docid],'pdfPage':page,
  244. 'printedPage':str(page-3 if docid=='GB2760-2024' else page-2),
  245. 'pageText':text,'textSha256':sha(text.encode()),'visuallyVerified':True,
  246. 'renderFile':'previews/'+render.name,'renderSha256':sha(render.read_bytes())})
  247. r['reviewNote'] = ('补回蔬菜水果章节语境,原摘要不改写为逐字引文。' if n==11 else
  248. '保留表注与限制性条件,PDF页码与印刷页码分别记录。' if n in (5,6,7) else
  249. '正文核验不等于已核今天法律状态或商品符合性。')
  250. clauses.append(r)
  251. assert len(clauses) == 31 and len({r['clauseId'] for r in clauses}) == 31
  252. counts = dict(Counter(r['verification'] for r in clauses))
  253. assert counts == {'PDF_PAGE_VERIFIED': 7, 'ARCHIVED_OFFICIAL_HTML_VERIFIED': 5, 'PENDING_ORIGINAL': 19}
  254. workflows = []
  255. for name, nums in [('process-quality',[1,2,3]),('additives-fortification',[4]),('sodium-claims',[5,7]),
  256. ('fiber',[6]),('whole-grain',[10]),('nutrition-context',[11,12]),
  257. ('label-transition',[8,9]),('digital-label-and-new-ingredients',list(range(13,32)))]:
  258. selected = [r for r in clauses if r['sourceLine'] in nums]
  259. workflows.append({'id':name, 'clauseIds':[r['clauseId'] for r in selected],
  260. 'sourceVerificationComplete':all(r['verification']!='PENDING_ORIGINAL' for r in selected),
  261. 'methodIsRegulation':False, 'requiredDecision':'先核输入、适用日期及原文条件;资料不足保持待核。'})
  262. result = {'schema':'ksf.policy-usage-review.v1', 'generation':GENERATION, 'reviewDate':'2026-11-08',
  263. 'createdAt':datetime.now(timezone.utc).isoformat(), 'snapshotSha256':snapshot_sha,
  264. 'sourceScope':'local frozen originals; no new official or upstream fetch',
  265. 'summary':{'documents':25, 'pdfOriginalsHashMatched':9, 'pdfCoversVisuallyChecked':9,
  266. 'officialArchivedHtmlOriginalsMatched':4, 'documentsMissingOriginal':12,
  267. 'clauses':31, 'clauseVerification':counts,
  268. 'htmlPublicationDateConflicts':sum(r.get('publicationDateConflict',False) for r in document_rows)},
  269. 'documents':document_rows, 'clauses':clauses, 'workflows':workflows,
  270. 'upstreamCalls':0, 'paidCalls':0, 'researchDatabaseWrites':0, 'productionChanges':0,
  271. 'currentLegalStatusesVerifiedThisRun':0, 'all31ClausesVerified':False}
  272. save(args.out/'policy-usage.json',result)
  273. assert sha(db.read_bytes()) == db_sha and sha(snapshot_path.read_bytes()) == snapshot_sha
  274. save(args.out/'policy-review-verification.json',{'ok':True, 'generation':GENERATION,
  275. 'frozenLedgerSha256':db_sha, 'frozenLedgerUnchanged':True,'snapshotUnchanged':True,
  276. 'sourceDocumentsVerified':13, 'clausesVerified':12, 'clausesPending':19,
  277. 'scannedPdfTextIsNotInvented':True, 'paidCalls':0, 'researchDatabaseWrites':0})
  278. print(json.dumps(result['summary'],ensure_ascii=False))
  279. if __name__ == '__main__':
  280. main()