agent_batch.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. """Validate Agent source mappings, build a new private batch, never modify the active library.
  2. V2 supports every domain entity and generates provenance/index/quality tables.
  3. V1 product/review batches remain compatible. No network, credentials or paid calls.
  4. """
  5. import argparse
  6. import collections
  7. import hashlib
  8. import json
  9. import os
  10. from pathlib import Path, PurePosixPath
  11. import sqlite3
  12. import sys
  13. import tempfile
  14. import zipfile
  15. import voc_db
  16. from product_insights import ANNOTATION_DDL, THEMES, text_hash
  17. SCHEMA = 'ksf.voc-agent-batch.v1'
  18. PRODUCT_FIELDS = {'itemId': 'item_id', 'title': 'title', 'url': 'url', 'shopId': 'shop_id', 'shopName': 'shop_name',
  19. 'price': 'price', 'priceBasis': 'price_type', 'sourceTime': 'source_time'}
  20. REVIEW_FIELDS = {'itemId': 'item_id', 'reviewId': 'review_id', 'content': 'content', 'appendContent': 'append_content',
  21. 'rating': 'rating', 'ratingScale': 'rating_scale', 'reviewDate': 'review_date', 'purchasedSku': 'sku',
  22. 'isDefault': 'is_default_text', 'sourceTime': 'source_time'}
  23. SOURCE_FIELDS = {'id', 'path', 'sha256', 'platform', 'marketplace', 'requestItemId', 'observedAt', 'collectedAt', 'url'}
  24. def require(value, code):
  25. voc_db.require(value, code)
  26. def digest(p):
  27. with Path(p).open('rb') as f: return voc_db.file_sha256(f)
  28. def pointer(value, path):
  29. require(isinstance(path, str) and (not path or path.startswith('/')), 'INVALID_JSON_POINTER')
  30. for token in path.split('/')[1:]:
  31. token = token.replace('~1', '/').replace('~0', '~')
  32. if isinstance(value, list):
  33. require(token.isdigit() and int(token) < len(value), 'SOURCE_POINTER_NOT_FOUND')
  34. value = value[int(token)]
  35. else:
  36. require(isinstance(value, dict) and token in value, 'SOURCE_POINTER_NOT_FOUND')
  37. value = value[token]
  38. return value
  39. def identifier(value):
  40. require(isinstance(value, str) and value.strip() == value and 0 < len(value) <= 200 and not any(ord(c) < 32 for c in value), 'ID_MUST_BE_NONEMPTY_STRING')
  41. return value
  42. def load(batch_path):
  43. path = Path(batch_path).absolute()
  44. require(path.is_file() and not path.is_symlink(), 'BATCH_NOT_FOUND')
  45. require(path.stat().st_size <= 20000000, 'BATCH_MANIFEST_TOO_LARGE')
  46. batch = json.loads(path.read_text(encoding='utf-8-sig'))
  47. require(isinstance(batch, dict) and not set(batch) - {'schema', 'batchId', 'producer', 'sources', 'products', 'reviews', 'themes'}, 'INVALID_BATCH_FIELDS')
  48. require(batch.get('schema') == SCHEMA, 'UNSUPPORTED_BATCH_SCHEMA')
  49. identifier(batch.get('batchId'))
  50. require(isinstance(batch.get('producer'), str) and 0 < len(batch['producer']) <= 200, 'PRODUCER_REQUIRED')
  51. require(isinstance(batch.get('sources'), list) and 0 < len(batch['sources']) <= 1000, 'SOURCES_REQUIRED')
  52. sources, assets = {}, {}
  53. for source in batch['sources']:
  54. require(isinstance(source, dict) and not set(source) - SOURCE_FIELDS, 'INVALID_SOURCE_FIELDS')
  55. sid = identifier(source.get('id'))
  56. require(sid not in sources, 'DUPLICATE_SOURCE_ID')
  57. rel = source.get('path')
  58. require(isinstance(rel, str) and '\\' not in rel and ':' not in rel and not PurePosixPath(rel).is_absolute()
  59. and '..' not in PurePosixPath(rel).parts and rel.startswith('raw/'), 'INVALID_SOURCE_PATH')
  60. p = path.parent / rel
  61. require(not any(x.is_symlink() for x in (p, *p.parents)) and p.resolve().is_relative_to(path.parent.resolve()) and p.is_file(), 'SOURCE_MISSING_OR_REDIRECTED')
  62. require(p.stat().st_size <= 50000000, 'SOURCE_TOO_LARGE')
  63. raw = p.read_bytes()
  64. require(hashlib.sha256(raw).hexdigest() == source.get('sha256'), 'SOURCE_HASH_MISMATCH')
  65. require(source.get('platform') in ('jd', 'taobao') and source.get('marketplace') in
  66. (('jd',) if source['platform'] == 'jd' else ('tmall', 'taobao_unverified')), 'SOURCE_PLATFORM_MISMATCH')
  67. for field in ('observedAt', 'collectedAt', 'url'):
  68. require(source.get(field) is None or isinstance(source[field], str), 'INVALID_SOURCE_METADATA')
  69. if source.get('requestItemId') is not None: identifier(source['requestItemId'])
  70. sources[sid] = (source, json.loads(raw.decode('utf-8-sig')))
  71. require(rel not in assets or assets[rel][1] == source['sha256'], 'SOURCE_PATH_HASH_CONFLICT')
  72. assets[rel] = (p, source['sha256'])
  73. result = {'products': [], 'reviews': [], 'social': [], 'documents': [], 'clauses': []}
  74. identifiers = collections.defaultdict(set)
  75. for entity, mapping, mandatory in [('products', PRODUCT_FIELDS, {'itemId', 'title'}), ('reviews', REVIEW_FIELDS, {'itemId', 'reviewId', 'content'})]:
  76. rows = batch.get(entity, [])
  77. require(isinstance(rows, list) and len(rows) <= 100000, 'INVALID_BATCH_ROWS')
  78. for record in rows:
  79. require(isinstance(record, dict) and set(record) == {'sourceId', 'pointer', 'fields'}, 'INVALID_RECORD_MAPPING')
  80. require(record['sourceId'] in sources and isinstance(record['fields'], dict) and mandatory <= set(record['fields'])
  81. and not set(record['fields']) - set(mapping), 'INVALID_FIELD_MAPPING')
  82. source, payload = sources[record['sourceId']]
  83. original = pointer(payload, record['pointer'])
  84. values = {name: pointer(original, loc) for name, loc in record['fields'].items()}
  85. iid = identifier(values['itemId'])
  86. require(all(value is None or isinstance(value, (str, int, float, bool)) or name == 'appendContent' for name, value in values.items()), 'INVALID_MAPPED_FIELD_TYPE')
  87. require('isDefault' not in values or values['isDefault'] is None or type(values['isDefault']) is bool, 'DEFAULT_STATE_MUST_BE_BOOLEAN_OR_NULL')
  88. require('appendContent' not in values or values['appendContent'] is None or isinstance(values['appendContent'], (str, dict, list)), 'INVALID_APPEND_CONTENT')
  89. for field in ('title', 'content', 'purchasedSku', 'ratingScale', 'reviewDate', 'sourceTime', 'priceBasis', 'url', 'shopName'):
  90. require(field not in values or values[field] is None or isinstance(values[field], str), 'TEXT_FIELD_REQUIRED')
  91. if entity == 'products':
  92. require(isinstance(values['title'], str) and bool(values['title'].strip()), 'PRODUCT_TITLE_REQUIRED')
  93. key = source['platform'] + ':' + iid
  94. require(source.get('requestItemId') in (None, iid), 'PRODUCT_REQUEST_ID_MISMATCH')
  95. else:
  96. key = source['platform'] + ':' + iid + ':' + identifier(values['reviewId'])
  97. require(isinstance(values['content'], str), 'REVIEW_CONTENT_REQUIRED')
  98. require(key not in identifiers[entity], 'DUPLICATE_RECORD_ID')
  99. identifiers[entity].add(key)
  100. row = {mapping[k]: v for k, v in values.items()}
  101. row.update(platform=source['platform'], marketplace=source['marketplace'], source_file=source['path'],
  102. source_time=values.get('sourceTime', source.get('observedAt')), collected_at=source.get('collectedAt'),
  103. source_locator=record['pointer'], source_field_map=record['fields'], producer=batch['producer'])
  104. if entity == 'reviews':
  105. row.update(observed_item_id=source.get('requestItemId'),
  106. item_identity_match=(iid == source['requestItemId']) if source.get('requestItemId') else None,
  107. review_id_method='source_field')
  108. result[entity].append(row)
  109. require(result['products'], 'PRODUCT_CONTEXT_REQUIRED')
  110. require(all(r['platform'] + ':' + r['item_id'] in identifiers['products'] for r in result['reviews']), 'REVIEW_PRODUCT_CONTEXT_MISSING')
  111. themes = batch.get('themes', [])
  112. require(isinstance(themes, list) and len(themes) <= 100000, 'INVALID_THEMES')
  113. from product_insights import append_text
  114. by_review = {r['platform'] + ':' + r['item_id'] + ':' + r['review_id']: r for r in result['reviews']}
  115. seen = set()
  116. for tag in themes:
  117. require(isinstance(tag, dict) and set(tag) == {'reviewId', 'theme', 'quote', 'quoteField'}, 'INVALID_THEME_FIELDS')
  118. require(tag['reviewId'] in by_review and tag['theme'] in THEMES and tag['quoteField'] in ('content', 'append')
  119. and isinstance(tag['quote'], str) and 0 < len(tag['quote']) <= 500, 'INVALID_THEME_EVIDENCE')
  120. key = tag['reviewId'], tag['theme']
  121. require(key not in seen, 'DUPLICATE_REVIEW_THEME')
  122. seen.add(key)
  123. review = by_review[tag['reviewId']]
  124. original = review['content'] if tag['quoteField'] == 'content' else append_text(voc_db.canonical(review.get('append_content')))
  125. require(tag['quote'] in original, 'THEME_QUOTE_NOT_VERBATIM')
  126. return path, batch, result, assets
  127. def build_batch(batch_path, output, apply=False):
  128. probe = json.loads(Path(batch_path).read_text(encoding='utf-8-sig'))
  129. from intake_contract import VERSION
  130. if isinstance(probe, dict) and probe.get('schema') == VERSION:
  131. from structured_batch import build
  132. return build(batch_path, output, apply)
  133. path, batch, rows, assets = load(batch_path)
  134. target = Path(output).absolute()
  135. require(target.suffix == '.sqlite' and target != path and not target.exists() and not target.is_symlink(), 'NEW_DATABASE_TARGET_REQUIRED')
  136. require(not any(p.is_symlink() for p in target.parents), 'REDIRECTED_TARGET')
  137. archive = target.with_suffix('.source.zip')
  138. require(not archive.exists() and not archive.is_symlink(), 'SOURCE_ARCHIVE_ALREADY_EXISTS')
  139. receipt = {'ok': True, 'readOnly': not apply, 'applied': False, 'schema': SCHEMA, 'batchId': batch['batchId'],
  140. 'counts': {k: len(v) for k, v in rows.items()}, 'sourceFilesVerified': len(assets),
  141. 'themeAnnotations': len(batch.get('themes', [])), 'networkRequests': 0, 'productionImported': False,
  142. 'databaseWrites': 0, 'activeLibraryModified': False}
  143. if not apply: return receipt
  144. target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
  145. created = []
  146. with tempfile.TemporaryDirectory(prefix='.agent-batch-', dir=target.parent) as temp:
  147. temp = Path(temp)
  148. packed, staged = temp / 'source.zip', temp / 'batch.sqlite'
  149. with zipfile.ZipFile(packed, 'x', zipfile.ZIP_DEFLATED) as z:
  150. def write(name, body):
  151. info = zipfile.ZipInfo('agent-batch/' + name, (2026, 1, 1, 0, 0, 0))
  152. info.external_attr = 0o100600 << 16
  153. z.writestr(info, body, compress_type=zipfile.ZIP_DEFLATED)
  154. for entity, filename in voc_db.PRIMARY.items():
  155. write('01_最新底库/' + filename, '\n'.join(voc_db.canonical(r) for r in rows[entity]))
  156. manifest = path.read_bytes()
  157. require(json.loads(manifest.decode('utf-8-sig')) == batch, 'BATCH_CHANGED_DURING_BUILD')
  158. write('batch.json', manifest)
  159. for rel, (p, expected) in sorted(assets.items()):
  160. raw = p.read_bytes()
  161. require(hashlib.sha256(raw).hexdigest() == expected, 'SOURCE_CHANGED_DURING_BUILD')
  162. write('02_采集档案/' + rel, raw)
  163. built = voc_db.build(packed, staged)
  164. if batch.get('themes'):
  165. c = sqlite3.connect(staged); c.row_factory = sqlite3.Row
  166. try:
  167. c.executescript(ANNOTATION_DDL)
  168. for tag in batch['themes']:
  169. r = c.execute('SELECT * FROM reviews WHERE id=?', (tag['reviewId'],)).fetchone()
  170. voc_db.insert(c, 'review_theme_annotations', {'id': voc_db.key(tag, batch['producer']), 'review_id': r['id'],
  171. 'product_id': r['product_id'], 'theme_code': tag['theme'], 'quote': tag['quote'], 'quote_field': tag['quoteField'],
  172. 'text_sha256': text_hash(r), 'method': 'agent', 'producer': batch['producer']})
  173. generation = voc_db.key(built['generation'], digest(Path(__file__)), digest(Path(__file__).with_name('product_insights.py')), batch['themes'])
  174. c.execute("UPDATE meta SET value=? WHERE key='generation'", (generation,)); c.commit()
  175. finally: c.close()
  176. from update_library import verified
  177. meta = verified(staged)
  178. try:
  179. for src, dest in ((packed, archive), (staged, target)):
  180. os.chmod(src, 0o600); os.link(src, dest); created.append((dest, digest(src)))
  181. except Exception:
  182. for p, h in reversed(created):
  183. if p.is_file() and digest(p) == h: p.unlink()
  184. raise
  185. return {**receipt, 'readOnly': False, 'applied': True, 'databaseWrites': 'staging-only', 'generation': meta['generation'],
  186. 'database': str(target), 'databaseSha256': digest(target), 'sourceArchive': str(archive), 'archiveSha256': digest(archive)}
  187. def main():
  188. p = argparse.ArgumentParser(description=__doc__)
  189. p.add_argument('--batch'); p.add_argument('--output')
  190. p.add_argument('--contract', action='store_true', help='Describe all supported entity mappings without a database')
  191. p.add_argument('--apply', action='store_true')
  192. args = p.parse_args()
  193. try:
  194. require(not args.contract or not (args.batch or args.output or args.apply), 'CONTRACT_REJECTS_WRITE_OPTIONS')
  195. if args.contract:
  196. from intake_contract import contract
  197. result = contract()
  198. else:
  199. require(args.batch and args.output, 'BATCH_AND_OUTPUT_REQUIRED')
  200. result = build_batch(args.batch, args.output, args.apply)
  201. print(json.dumps(result, ensure_ascii=False, indent=2))
  202. except Exception as e:
  203. code = str(e) if isinstance(e, voc_db.LibraryError) else 'AGENT_BATCH_FAILED'
  204. print(json.dumps({'ok': False, 'error': {'code': code}, 'productionImported': False}, ensure_ascii=False), file=sys.stderr)
  205. return 1
  206. return 0
  207. if __name__ == '__main__': raise SystemExit(main())