structured_batch.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. """Build a source-verified v2 research batch for all nine domain entities."""
  2. import collections
  3. import hashlib
  4. import json
  5. import math
  6. import os
  7. from pathlib import Path, PurePosixPath
  8. import sqlite3
  9. import tempfile
  10. import zipfile
  11. import voc_db as v
  12. from intake_contract import VERSION, ENTITIES
  13. from product_insights import ANNOTATION_DDL, THEMES, append_text, text_hash
  14. def typed(value, kind, required=False):
  15. from agent_batch import identifier
  16. if value is None:
  17. v.require(not required, 'REQUIRED_VALUE_MISSING'); return
  18. if kind == 'id': identifier(value)
  19. elif kind == 'text': v.require(isinstance(value,str) and (not required or value.strip()), 'TEXT_REQUIRED')
  20. elif kind == 'date': v.require(isinstance(value,str) and v.calendar_date(value)==value, 'ISO_DATE_REQUIRED')
  21. elif kind == 'bool': v.require(type(value) is bool, 'BOOLEAN_REQUIRED')
  22. elif kind in ('count','integer'): v.require(type(value) is int and value >= (1 if kind=='integer' else 0), 'NONNEGATIVE_COUNT_OR_POSITIVE_INTEGER_REQUIRED')
  23. elif kind == 'positive': v.require(type(value) in (int,float) and math.isfinite(value) and value>0, 'POSITIVE_NUMBER_REQUIRED')
  24. elif kind == 'amount': v.require(isinstance(value,str) or type(value) in (int,float) and math.isfinite(value), 'RAW_AMOUNT_REQUIRED')
  25. elif kind == 'json': v.require(isinstance(value,(str,dict,list)), 'TEXT_OR_JSON_REQUIRED')
  26. def load(batch_path):
  27. from agent_batch import identifier, pointer
  28. path = Path(batch_path).absolute()
  29. v.require(path.is_file() and not any(p.is_symlink() for p in (path,*path.parents)), 'BATCH_NOT_FOUND_OR_REDIRECTED')
  30. raw_manifest = path.read_bytes(); v.require(len(raw_manifest)<=20000000, 'BATCH_TOO_LARGE')
  31. batch=json.loads(raw_manifest.decode('utf-8-sig'))
  32. v.require(isinstance(batch,dict) and batch.get('schema')==VERSION and not set(batch)-({'schema','batchId','producer','sources','attachments','themes'}|set(ENTITIES)), 'INVALID_BATCH_SCHEMA_OR_FIELDS')
  33. identifier(batch.get('batchId')); identifier(batch.get('producer'))
  34. v.require(isinstance(batch.get('sources'),list) and 0<len(batch['sources'])<=1000, 'SOURCES_REQUIRED')
  35. sources={}; assets={}
  36. for source in batch['sources']:
  37. v.require(isinstance(source,dict) and not set(source)-{'id','path','sha256','platform','marketplace','requestItemId','observedAt','collectedAt','url'}, 'INVALID_SOURCE_FIELDS')
  38. sid=identifier(source.get('id')); rel=source.get('path')
  39. v.require(sid not in sources, 'DUPLICATE_SOURCE_ID')
  40. v.require(isinstance(rel,str) and rel.startswith('raw/') and '\\' not in rel and ':' not in rel and '..' not in PurePosixPath(rel).parts, 'INVALID_SOURCE_PATH')
  41. p=path.parent/rel
  42. v.require(p.is_file() and not any(x.is_symlink() for x in (p,*p.parents)) and p.resolve().is_relative_to(path.parent.resolve()), 'SOURCE_MISSING_OR_REDIRECTED')
  43. v.require(p.stat().st_size<=50000000, 'SOURCE_TOO_LARGE')
  44. raw=p.read_bytes(); h=hashlib.sha256(raw).hexdigest()
  45. v.require(h==source.get('sha256'), 'SOURCE_HASH_MISMATCH')
  46. identifier(source.get('platform'))
  47. for name in ('observedAt','collectedAt','url'): typed(source.get(name),'text')
  48. if source.get('requestItemId') is not None: identifier(source['requestItemId'])
  49. v.require(rel not in assets or assets[rel][1]==h, 'SOURCE_PATH_HASH_CONFLICT')
  50. assets[rel]=(p,h); sources[sid]=(source,json.loads(raw.decode('utf-8-sig')))
  51. attachments=batch.get('attachments',[])
  52. v.require(isinstance(attachments,list) and len(attachments)<=1000, 'INVALID_ATTACHMENTS')
  53. for entry in attachments:
  54. v.require(isinstance(entry,dict) and set(entry)=={'path','sha256'}, 'INVALID_ATTACHMENT_FIELDS')
  55. rel=entry['path']
  56. v.require(isinstance(rel,str) and rel.startswith('raw/') and '\\' not in rel and ':' not in rel and '..' not in PurePosixPath(rel).parts and rel not in assets, 'INVALID_ATTACHMENT_PATH')
  57. p=path.parent/rel
  58. v.require(p.is_file() and not any(x.is_symlink() for x in (p,*p.parents)) and p.resolve().is_relative_to(path.parent.resolve()) and p.stat().st_size<=50000000, 'ATTACHMENT_MISSING_OR_REDIRECTED')
  59. h=hashlib.sha256(p.read_bytes()).hexdigest();v.require(h==entry['sha256'], 'ATTACHMENT_HASH_MISMATCH')
  60. assets[rel]=(p,h)
  61. rows={name:[] for name in ENTITIES}; keys=collections.defaultdict(set)
  62. for entity,(_,required,fields) in ENTITIES.items():
  63. records=batch.get(entity,[])
  64. v.require(isinstance(records,list) and len(records)<=100000, 'INVALID_BATCH_ROWS')
  65. for record in records:
  66. v.require(isinstance(record,dict) and set(record)=={'sourceId','pointer','fields'} and record['sourceId'] in sources, 'INVALID_RECORD_MAPPING')
  67. mapping=record['fields']; v.require(isinstance(mapping,dict) and required<=set(mapping) and not set(mapping)-set(fields), 'INVALID_FIELD_MAPPING')
  68. source,payload=sources[record['sourceId']]; original=pointer(payload,record['pointer'])
  69. values={name:pointer(original,loc) for name,loc in mapping.items()}
  70. for name,value in values.items(): typed(value, fields[name][1], name in required)
  71. row={fields[name][0]:value for name,value in values.items()}
  72. row.update(platform=source['platform'],source_file=source['path'],source_time=row.get('source_time',source.get('observedAt')),
  73. collected_at=source.get('collectedAt'),source_locator=record['pointer'],source_field_map=mapping,producer=batch['producer'])
  74. if entity in ('products','reviews','prices','specs','rankings','coverage'):
  75. v.require(source['platform'] in ('jd','taobao') and source.get('marketplace') in (('jd',) if source['platform']=='jd' else ('tmall','taobao_unverified')), 'SOURCE_PLATFORM_MISMATCH')
  76. row['marketplace']=source['marketplace']; pid=source['platform']+':'+row['item_id']
  77. if entity!='reviews': v.require(source.get('requestItemId') in (None,row['item_id']), 'PRODUCT_REQUEST_ID_MISMATCH')
  78. if entity=='products': key=pid
  79. elif entity=='reviews':
  80. key=pid+':'+row['review_id']; row.update(observed_item_id=source.get('requestItemId'),
  81. item_identity_match=(row['item_id']==source['requestItemId']) if source.get('requestItemId') else None,review_id_method='source_field')
  82. else: key=v.key(entity,pid,source['path'],record['pointer'])
  83. elif entity=='social':
  84. v.require(source['platform'] not in ('jd','taobao','web','document'), 'SOCIAL_PLATFORM_REQUIRED')
  85. v.require(bool(row.get('title') or row.get('content')), 'SOCIAL_TEXT_REQUIRED'); key=source['platform']+':'+row['id']
  86. elif entity=='documents':
  87. v.require(not row.get('declared_status') or row['declared_status']=='unknown' or row.get('status_as_of'), 'POLICY_STATUS_AS_OF_REQUIRED')
  88. v.require(all(not row.get(k) or row[k] in assets for k in ('pdf_file','text_file')), 'DECLARED_ATTACHMENT_NOT_REGISTERED')
  89. key=row['document_id']
  90. else: key=v.key(row['document_id'],row['clause'])
  91. v.require(key not in keys[entity], 'DUPLICATE_RECORD_ID'); keys[entity].add(key)
  92. row['_record_key']=key
  93. if entity=='prices': v.require(row.get('currency') in (None,'CNY'), 'UNSUPPORTED_CURRENCY')
  94. if entity=='specs':
  95. v.require(row.get('verification') in (None,'source_attribute','title_candidate','unknown'), 'INVALID_SPEC_VERIFICATION')
  96. if any(row.get(k) is not None for k in ('net_weight_g','pack_count','total_weight_g')):
  97. v.require(bool(row.get('basis')), 'SPEC_BASIS_REQUIRED')
  98. rows[entity].append(row)
  99. v.require(any(rows.values()), 'EMPTY_BATCH')
  100. for entity in ('reviews','prices','specs','rankings','coverage'):
  101. v.require(all(r['platform']+':'+r['item_id'] in keys['products'] for r in rows[entity]), 'PRODUCT_CONTEXT_REQUIRED')
  102. v.require(all(r['document_id'] in keys['documents'] for r in rows['clauses']), 'DOCUMENT_CONTEXT_REQUIRED')
  103. tags=batch.get('themes',[]); v.require(isinstance(tags,list), 'INVALID_THEMES')
  104. by_review={r['_record_key']:r for r in rows['reviews']}; seen=set()
  105. for tag in tags:
  106. v.require(isinstance(tag,dict) and set(tag)=={'reviewId','theme','quote','quoteField'}, 'INVALID_THEME_FIELDS')
  107. v.require(tag['reviewId'] in by_review and tag['theme'] in THEMES and tag['quoteField'] in ('content','append') and isinstance(tag['quote'],str) and 0<len(tag['quote'])<=500, 'INVALID_THEME_EVIDENCE')
  108. key=(tag['reviewId'],tag['theme']); v.require(key not in seen, 'DUPLICATE_REVIEW_THEME'); seen.add(key)
  109. r=by_review[tag['reviewId']]; text=r['content'] if tag['quoteField']=='content' else append_text(v.canonical(r.get('append_content')))
  110. v.require(tag['quote'] in text, 'THEME_QUOTE_NOT_VERBATIM')
  111. return path,raw_manifest,batch,rows,assets
  112. def build(batch_path, output, apply=False):
  113. from agent_batch import digest
  114. path,manifest,batch,rows,assets=load(batch_path)
  115. target=Path(output).absolute(); archive=target.with_suffix('.source.zip')
  116. v.require(target.suffix=='.sqlite' and not any(p.is_symlink() for p in (target,*target.parents)) and not target.exists() and not archive.exists() and not archive.is_symlink(), 'NEW_DATABASE_TARGET_REQUIRED')
  117. receipt={'ok':True,'schema':VERSION,'batchId':batch['batchId'],'readOnly':not apply,'applied':False,
  118. 'counts':{k:len(x) for k,x in rows.items()},'sourceFilesVerified':len(assets),'themeAnnotations':len(batch.get('themes',[])),
  119. 'databaseWrites':0,'networkRequests':0,'activeLibraryModified':False,'productionImported':False}
  120. if not apply: return receipt
  121. target.parent.mkdir(parents=True,exist_ok=True,mode=0o700)
  122. created=[]
  123. with tempfile.TemporaryDirectory(prefix='.structured-batch-',dir=target.parent) as td:
  124. td=Path(td); packed=td/'source.zip'; staged=td/'staging.sqlite'
  125. with zipfile.ZipFile(packed,'x',zipfile.ZIP_DEFLATED) as z:
  126. def write(name,body):
  127. info=zipfile.ZipInfo('agent-batch/'+name,(2026,1,1,0,0,0));info.external_attr=0o100600<<16
  128. z.writestr(info,body,compress_type=zipfile.ZIP_DEFLATED)
  129. for entity,filename in v.PRIMARY.items():write('01_最新底库/'+filename,'\n'.join(v.canonical(r) for r in rows[entity]))
  130. for entity in ('prices','specs','rankings','coverage'):write('01_最新底库/agent-'+entity+'.jsonl','\n'.join(v.canonical(r) for r in rows[entity]))
  131. v.require(path.read_bytes()==manifest,'BATCH_CHANGED_DURING_BUILD');write('batch.json',manifest)
  132. for rel,(p,h) in sorted(assets.items()):
  133. raw=p.read_bytes();v.require(hashlib.sha256(raw).hexdigest()==h,'SOURCE_CHANGED_DURING_BUILD');write('02_采集档案/'+rel,raw)
  134. built=v.build(packed,staged)
  135. c=sqlite3.connect(staged);c.row_factory=sqlite3.Row
  136. try:
  137. c.execute('PRAGMA foreign_keys=ON');c.executescript(ANNOTATION_DDL)
  138. def issue(entity,eid,code,detail):
  139. c.execute('INSERT OR IGNORE INTO quality_issues VALUES(?,?,?,?,?)',(v.key(entity,eid,code,detail),entity,eid,code,detail))
  140. for entity in ('prices','specs','rankings','coverage'):
  141. for line,row in enumerate(rows[entity],1):
  142. asset='agent-batch/01_最新底库/agent-'+entity+'.jsonl';raw=v.canonical(row);sid=v.key(asset,line)
  143. v.insert(c,'source_records',dict(id=sid,kind='agent_'+entity,asset_path=asset,line_number=line,payload_sha256=v.sha(raw),payload_json=raw))
  144. source='agent-batch/02_采集档案/'+row['source_file']
  145. v.insert(c,'source_links',dict(record_id=sid,role='source_file',original_path=row['source_file'],asset_path=source,resolution='matched'))
  146. pid=row['platform']+':'+row['item_id']; rid=row['_record_key']
  147. if entity=='prices':v.add_price(c,pid,row['amount'],row['price_type'],row,sid,issue)
  148. elif entity=='specs':
  149. v.insert(c,'specs',dict(id=rid,product_id=pid,upstream_sku_id=row.get('upstream_sku_id'),spec_text=row['spec_text'],
  150. net_weight_g=row.get('net_weight_g'),pack_count=row.get('pack_count'),total_weight_g=row.get('total_weight_g'),
  151. verification=row.get('verification') or 'unknown',source_record_id=sid,basis=row.get('basis') or '仅保留来源规格原文,计量依据待核对'))
  152. elif entity=='rankings':v.insert(c,'ranking_signals',dict(id=rid,product_id=pid,rank_id=row.get('rank_id'),rank_number=row.get('rank_number'),title=row.get('title'),scope=row['scope'],source_time_raw=row.get('source_time'),source_record_id=sid))
  153. else:v.insert(c,'coverage_observations',dict(id=rid,product_id=pid,status=row['status'],available_reviews=row.get('available_reviews'),reported_total=str(row['reported_total']) if row.get('reported_total') is not None else None,historical_total_complete=int(row.get('historical_total_complete') is True),source_record_id=sid))
  154. for tag in batch.get('themes',[]):
  155. r=c.execute('SELECT * FROM reviews WHERE id=?',(tag['reviewId'],)).fetchone()
  156. v.insert(c,'review_theme_annotations',dict(id=v.key(tag,batch['producer']),review_id=r['id'],product_id=r['product_id'],
  157. theme_code=tag['theme'],quote=tag['quote'],quote_field=tag['quoteField'],text_sha256=text_hash(r),method='agent',producer=batch['producer']))
  158. c.execute('DROP TABLE retrieval_fts');c.execute('DROP TABLE retrieval_documents')
  159. import retrieval
  160. retrieval.create_index(c)
  161. generation=v.key(built['generation'],digest(Path(__file__)),digest(Path(__file__).with_name('intake_contract.py')),
  162. digest(Path(__file__).with_name('product_insights.py')),batch.get('themes',[]))
  163. c.execute("UPDATE meta SET value=? WHERE key='generation'",(generation,));c.commit()
  164. finally:c.close()
  165. from update_library import verified
  166. verified(staged)
  167. try:
  168. for src,dest in ((packed,archive),(staged,target)):
  169. os.chmod(src,0o600);os.link(src,dest);created.append((dest,digest(src)))
  170. except Exception:
  171. for p,h in reversed(created):
  172. if p.is_file() and digest(p)==h:p.unlink()
  173. raise
  174. return {**receipt,'applied':True,'readOnly':False,'databaseWrites':'staging-only','generation':generation,
  175. 'database':str(target),'databaseSha256':digest(target),'sourceArchive':str(archive),'archiveSha256':digest(archive)}