prepare-five-topic-multisource-20261111.py 6.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. """Seal reviewed HTML originals, precise excerpts and government documents."""
  2. from datetime import datetime,timezone
  3. import hashlib
  4. import importlib.util
  5. import json
  6. from pathlib import Path
  7. import sqlite3
  8. import sys
  9. from lxml import html
  10. ROOT=Path(__file__).resolve().parents[1]
  11. WORK=ROOT/(sys.argv[1] if len(sys.argv)>1 else 'work/five-topic-research-20261111')
  12. BATCH=ROOT/'work/database-enrichment-20261110/batches'/(sys.argv[2] if len(sys.argv)>2 else 'five-topics-multisource-20261111-01')
  13. BATCH_ID=sys.argv[3] if len(sys.argv)>3 else 'ksf-five-topics-multisource-20261111-r1'
  14. sys.path.insert(0,str(ROOT/'server/voc-library'))
  15. import structured_batch
  16. import library_channel
  17. def sha(raw):return hashlib.sha256(raw).hexdigest()
  18. def save(file,value):file.write_text(json.dumps(value,ensure_ascii=False,indent=2),encoding='utf8')
  19. def main():
  20. review=json.loads((WORK/'reviewed-selection.json').read_bytes())
  21. dedup=json.loads((WORK/'dedup-result.json').read_bytes());assert dedup['ok']
  22. selected=dedup['selected'];assert selected and not BATCH.exists()
  23. spec=importlib.util.spec_from_file_location('replay_source',ROOT/'scripts/collect-five-topic-public.py')
  24. base=importlib.util.module_from_spec(spec);spec.loader.exec_module(base)
  25. (BATCH/'raw/original').mkdir(parents=True)
  26. batch={'schema':'ksf.voc-agent-batch.v2','batchId':BATCH_ID,
  27. 'producer':'Codex reviewed multi-source topic collection with explicit source boundaries',
  28. 'sources':[],'attachments':[],'social':[],'documents':[],'clauses':[]}
  29. expectations={name:[] for name in ['social','documents','clauses']}
  30. source_metadata={}
  31. def attach(name,raw):
  32. (BATCH/name).write_bytes(raw);batch['attachments'].append({'path':name,'sha256':sha(raw)})
  33. for i,r in enumerate(selected):
  34. original=(WORK/r['collectionDirectory']/r['httpReceipt']['responseFile']).read_bytes()
  35. assert sha(original)==r['httpReceipt']['sha256']
  36. tree=html.fromstring(original.decode(r['sourceEncoding']));nodes=tree.xpath(r['bodyExtractionSelector']);assert len(nodes)==1
  37. body=base.paragraphs(nodes[0])
  38. if 'paragraphIndexes' in r:
  39. lines=body.splitlines();body='\n'.join(lines[n] for n in r['paragraphIndexes'])
  40. assert body==r['body'] and sha(body.encode())==r['bodySha256'],'SOURCE_REPLAY_FAILED'
  41. assert all(quote in body for t in r['topics'] for quote in t['passages'])
  42. stem=str(i+1).zfill(2)+'-'+r['id'].replace(':','-')
  43. op='raw/original/'+stem+'.html';attach(op,original)
  44. hp='raw/original/'+stem+'.http.json';attach(hp,json.dumps(r['httpReceipt'],ensure_ascii=False,indent=2).encode())
  45. if r['entity']=='social':
  46. record={'postId':r['postId'],'title':r['title'],'content':body,'url':r['url'],
  47. 'publishedAt':r['publishedAtRaw'],'contentScope':r['contentScope']}
  48. else:
  49. tp='raw/original/'+stem+'.txt';attach(tp,body.encode())
  50. record={'documentId':r['id'],'title':r['title'],'sourceUrl':r['url'],
  51. 'declaredStatus':r['declaredStatus'],'statusAsOf':datetime.now(timezone.utc).date().isoformat(),
  52. 'issuedDate':r['publishedAtRaw'],'fullText':body,'expectedSha256':sha(body.encode()),'textFile':tp,
  53. 'coverage':json.dumps({'sourceType':r['sourceType'],'topics':r['topics'],'dateEvidence':r['dateEvidence'],
  54. 'limits':r['limits']+review['limits']},ensure_ascii=False)}
  55. adapter={'kind':'explicit_html_extraction_adapter_not_upstream_json','record':record,
  56. 'original':{'path':op,'sha256':sha(original),'sourceUrl':r['httpReceipt']['requestedUrl'],
  57. 'resolvedUrl':r['httpReceipt']['finalUrl'],'encoding':r['sourceEncoding'],'selector':r['bodyExtractionSelector'],
  58. 'paragraphIndexes':r.get('paragraphIndexes'),'collectedAt':r['httpReceipt']['collectedAt']},
  59. 'researchTopics':r['topics'],'sourceType':r['sourceType'],'reviewNote':r['reviewNote'],
  60. 'limits':r['limits']+review['limits']}
  61. if r['entity']=='documents':
  62. adapter['clauses']=[{'documentId':r['id'],'clause':c['name'],'excerpt':c['excerpt'],
  63. 'excerptMethod':'HTML正文逐字摘录;XPath '+r['bodyExtractionSelector']+';非PDF逐字核验',
  64. 'applicationNote':c['application']} for c in r['clauses']]
  65. ap='raw/adapter-'+stem+'.json';save(BATCH/ap,adapter);sid='source-'+stem
  66. batch['sources'].append({'id':sid,'path':ap,'sha256':sha((BATCH/ap).read_bytes()),
  67. 'platform':r['platform'],'url':r['url'],'collectedAt':r['httpReceipt']['collectedAt']})
  68. batch[r['entity']].append({'sourceId':sid,'pointer':'/record','fields':{k:'/'+k for k in record}})
  69. for j,c in enumerate(adapter.get('clauses',[])):
  70. batch['clauses'].append({'sourceId':sid,'pointer':'/clauses/'+str(j),'fields':{k:'/'+k for k in c}})
  71. source_metadata[ap]={'adapterPath':ap,'adapterSha256':sha((BATCH/ap).read_bytes()),'originalPath':op,'originalSha256':sha(original),
  72. 'topics':[t['topic'] for t in r['topics']],'topicEvidence':r['topics'],'sourceType':r['sourceType'],'reviewNote':r['reviewNote']}
  73. save(BATCH/'batch.json',batch)
  74. dry=structured_batch.build(BATCH/'batch.json',BATCH/'staging.sqlite',False);save(BATCH/'validation.json',dry)
  75. assert dry['ok'] and dry['networkRequests']==0 and dry['databaseWrites']==0
  76. built=structured_batch.build(BATCH/'batch.json',BATCH/'staging.sqlite',True)
  77. with sqlite3.connect(BATCH/'staging.sqlite') as c:
  78. c.row_factory=sqlite3.Row
  79. for entity,table in [('social','social_posts'),('documents','policy_documents'),('clauses','policy_clauses')]:
  80. for record in c.execute('SELECT * FROM '+table):
  81. actual=dict(record)
  82. payload=json.loads(c.execute('SELECT payload_json FROM source_records WHERE id=?',(actual['source_record_id'],)).fetchone()[0])
  83. ap=payload['source_file'];meta=source_metadata[ap]
  84. assert entity!='documents' or actual['attachment_hash_state']=='matched'
  85. assert entity!='social' or actual['fulltext_available']==1
  86. expectations[entity].append({'entity':entity,'id':actual['id'],'record':actual,**meta})
  87. transport,fingerprint=library_channel.pack(BATCH/'batch.json');(BATCH/'transport.zip').write_bytes(transport)
  88. receipt={'kind':'five_topic_reviewed_multisource','batchId':batch['batchId'],'archiveSha256':fingerprint,'archiveBytes':len(transport),
  89. 'accepted':{k:len(v) for k,v in expectations.items()},'products':[],'sourceTopics':dedup['countsByTopic'],'validation':built,
  90. 'createdAt':datetime.now(timezone.utc).isoformat(),'productionImported':False}
  91. save(BATCH/'receipt.json',receipt);save(BATCH/'expected-records.json',expectations)
  92. save(BATCH/'reviewed-source-index.json',selected)
  93. print(json.dumps({'ok':True,'batch':BATCH.name,'accepted':receipt['accepted'],'archiveBytes':len(transport)},ensure_ascii=False))
  94. if __name__=='__main__':main()