voc_db.py 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818
  1. """Versioned, offline VOC corpus. Python 3.9+, SQLite/FTS5, no third-party modules.
  2. Builds ONLY a new database from an immutable archive. Query/get/contract open it
  3. mode=ro and query_only. This is a research library, not the workshop business DB.
  4. """
  5. import argparse
  6. import base64
  7. import collections
  8. import contextlib
  9. import datetime as dt
  10. import decimal
  11. import hashlib
  12. import json
  13. import os
  14. from pathlib import Path, PurePosixPath
  15. import re
  16. import sqlite3
  17. import sys
  18. import tempfile
  19. import time
  20. import uuid
  21. import zipfile
  22. VERSION = 'ksf.voc-library.v2'
  23. HERE = Path(__file__).resolve().parent
  24. PRIMARY = {'products': '底库商品_4635条.jsonl', 'reviews': '底库评价_2360条.jsonl',
  25. 'social': '社媒内容_3814条.jsonl', 'documents': '政策与标准_25份.jsonl',
  26. 'clauses': '政策条款与应用提示_31条.jsonl'}
  27. TABLES = {'products': 'products', 'reviews': 'reviews', 'prices': 'prices',
  28. 'social': 'social_posts', 'documents': 'policy_documents', 'clauses': 'policy_clauses'}
  29. class LibraryError(ValueError):
  30. pass
  31. def require(test, code):
  32. if not test:
  33. raise LibraryError(code)
  34. def canonical(value):
  35. return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'), allow_nan=False)
  36. def sha(value):
  37. return hashlib.sha256(value if isinstance(value, bytes) else value.encode('utf-8')).hexdigest()
  38. def file_sha256(stream):
  39. """Hash bytes in bounded memory; identical on Python 3.9+ without monkey patches."""
  40. digest = hashlib.sha256()
  41. for block in iter(lambda: stream.read(1024 * 1024), b''):
  42. digest.update(block)
  43. return digest.hexdigest()
  44. def key(*parts):
  45. return sha(canonical(parts))
  46. def now():
  47. return dt.datetime.now(dt.timezone.utc).isoformat()
  48. def utc(value):
  49. try:
  50. stamp = dt.datetime.fromisoformat(str(value).replace('Z', '+00:00'))
  51. if stamp.tzinfo is not None:
  52. return stamp.astimezone(dt.timezone.utc).isoformat()
  53. except (ValueError, TypeError):
  54. pass
  55. return None
  56. def calendar_date(value):
  57. # Calendar component only: deliberately makes no timezone assumption.
  58. m = re.match(r'^(\d{4})[-年](\d{1,2})[-月](\d{1,2})', str(value or ''))
  59. if m:
  60. try:
  61. return dt.date(*map(int, m.groups())).isoformat()
  62. except ValueError:
  63. pass
  64. return None
  65. def money(value):
  66. if value is None or isinstance(value, bool):
  67. return None
  68. text = str(value).strip()
  69. if not re.fullmatch(r'\d+(?:\.\d{1,2})?', text):
  70. return None # No '?', ranges, currency strings or coupon guesses.
  71. amount = decimal.Decimal(text)
  72. return int(amount * 100) if 0 < amount < 100000000 else None
  73. def family(value):
  74. return 'taobao' if value in ('taobao', 'taobao_tmall', 'tmall') else value
  75. def identifier(value):
  76. require(isinstance(value, (str, int)) and not isinstance(value, bool), 'ID_MUST_BE_STRING_OR_INTEGER')
  77. # JSON integers retain arbitrary precision; never route through float/JS Number.
  78. result = str(value).strip()
  79. require(bool(result), 'EMPTY_ID')
  80. return result
  81. def taxonomy():
  82. return json.loads((HERE / 'taxonomy.json').read_text(encoding='utf-8'))
  83. def brand_hits(text, tax):
  84. text = str(text or '').casefold()
  85. return [b for b in tax['brands'] if any(a.casefold() in text for a in b['aliases'])]
  86. def classify_brand(title, tax, upstream=None):
  87. hits = brand_hits(title, tax)
  88. clean = re.sub(r'^(?:\s|【[^】]*】|\[[^\]]*\])*', '', title)
  89. prefix = [b for b in hits if any(clean.casefold().startswith(a.casefold()) for a in b['aliases'])]
  90. if upstream:
  91. direct = [b for b in tax['brands'] if upstream.casefold() in [a.casefold() for a in b['aliases']]]
  92. if len(direct) == 1 and (not hits or {b['code'] for b in hits} == {direct[0]['code']}):
  93. return direct[0]['code'], 'source_attribute', None
  94. return None, 'conflicting_or_unregistered_attribute', 'BRAND_CONFLICT_OR_UNKNOWN'
  95. if len(hits) == len(prefix) == 1:
  96. return prefix[0]['code'], 'rule_title_prefix', None
  97. return None, 'unresolved_title', 'BRAND_AMBIGUOUS' if len(hits) > 1 else 'BRAND_UNCONFIRMED'
  98. def classify_category(title):
  99. # Conservative business taxonomy; platform catalog codes are NOT this taxonomy.
  100. text = str(title or '')
  101. if re.search(r'补邮|邮费|差价|运费|购物袋|餐具', text) and not re.search(r'方便面|拌面|泡面', text):
  102. return None, 'non_product_link', 'NON_PRODUCT_LINK'
  103. if re.search(r'拌面酱|拌面汁|葱油酱|调味料|面条酱|拌面专用酱|拌面调料|炸酱罐头', text):
  104. return 'sauce', 'rule_title_sauce', None
  105. if re.search(r'挂面|生面条|生鲜面条', text):
  106. return 'raw_noodle', 'rule_title_raw_noodle', None
  107. if re.search(r'火腿|午餐肉|烤肠|拌饭|米饭|饮料|蛋糕|饼干', text) and not re.search(r'方便面|拌面|泡面|拉面', text):
  108. return 'other_food', 'rule_title_other', None
  109. labels = []
  110. for code, pattern in [('mianpi', r'面皮|凉皮'), ('reganmian', r'热干面'),
  111. ('soup_noodle', r'汤面|汤达人|汤好喝'), ('dry_snack_noodle', r'干脆面|干吃面'),
  112. ('fried_noodle', r'炒面'), ('banmian', r'拌面|干拌手擀面')]:
  113. if re.search(pattern, text):
  114. labels.append(code)
  115. # Explicit subtypes take precedence over the generic marketing term 拌面.
  116. if 'banmian' in labels and any(x in labels for x in ('mianpi', 'reganmian', 'fried_noodle')):
  117. labels.remove('banmian')
  118. if len(labels) == 1:
  119. return labels[0], 'rule_title_explicit', None
  120. if len(labels) > 1:
  121. return None, 'mixed_category_title', 'CATEGORY_CONFLICT'
  122. if re.search(r'方便面|泡面|桶面|杯面|碗面|袋面', text):
  123. return 'instant_unspecified', 'rule_title_unspecified', None
  124. return None, 'unresolved_title', 'CATEGORY_UNCONFIRMED'
  125. def insert(c, table, row):
  126. # table/column names originate from code only, never from a query request.
  127. c.execute('INSERT INTO ' + table + '(' + ','.join(row) + ') VALUES(' + ','.join('?' for _ in row) + ')', list(row.values()))
  128. class Archive:
  129. def __init__(self, path):
  130. self.path = Path(path)
  131. self.z = zipfile.ZipFile(path)
  132. self.files = {}
  133. for info in self.z.infolist():
  134. if info.is_dir():
  135. continue
  136. parts = PurePosixPath(info.filename).parts
  137. require(not info.filename.startswith('/') and '..' not in parts and '\\' not in info.filename, 'UNSAFE_ARCHIVE_PATH')
  138. require(info.filename not in self.files, 'DUPLICATE_ARCHIVE_MEMBER')
  139. self.files[info.filename] = info
  140. roots = {n.split('/')[0] for n in self.files}
  141. require(len(roots) == 1, 'ARCHIVE_ROOT_AMBIGUOUS')
  142. self.root = next(iter(roots)) + '/'
  143. self.latest = self.root + '01_最新底库/'
  144. self.cache = {}
  145. def resolve(self, value):
  146. if not value:
  147. return None, 'missing'
  148. text = str(value).replace('\\', '/').lstrip('./')
  149. if text in self.files:
  150. return text, 'matched'
  151. if '/voc-research/' in text:
  152. text = 'voc-research/' + text.split('/voc-research/', 1)[1]
  153. if re.match(r'^[A-Za-z]:/', text):
  154. return None, 'missing' # Never guess by basename of an unknown path.
  155. if text not in self.cache:
  156. hits = [p for p in self.files if p.endswith('/' + text)]
  157. self.cache[text] = (hits[0], 'matched') if len(hits) == 1 else (None, 'ambiguous' if hits else 'missing')
  158. return self.cache[text]
  159. def build(archive_path, db_path):
  160. path, dest = Path(archive_path).resolve(), Path(db_path).resolve()
  161. require(path.is_file() and path != dest, 'ARCHIVE_NOT_FOUND_OR_BAD_TARGET')
  162. with path.open('rb') as f:
  163. digest = file_sha256(f)
  164. tax = taxonomy()
  165. generation = key(VERSION, digest, sha((HERE / 'schema.sql').read_bytes()), sha((HERE / 'taxonomy.json').read_bytes()), sha(Path(__file__).read_bytes()), sha((HERE / 'retrieval.py').read_bytes()))
  166. if dest.exists():
  167. with readonly(dest) as c:
  168. require(dict(c.execute('SELECT key,value FROM meta')).get('generation') == generation, 'TARGET_EXISTS_DIFFERENT_GENERATION')
  169. return {'ok': True, 'operation': 'unchanged', 'generation': generation, 'archiveSha256': digest, 'counts': table_counts(c)}
  170. dest.parent.mkdir(parents=True, exist_ok=True)
  171. temp = dest.with_name(dest.name + '.build-' + str(uuid.uuid4()))
  172. a = Archive(path)
  173. started = time.perf_counter()
  174. try:
  175. c = sqlite3.connect(temp)
  176. c.executescript((HERE / 'schema.sql').read_text(encoding='utf-8'))
  177. with c:
  178. for k, v in {'schema': VERSION, 'generation': generation, 'archive_sha256': digest, 'taxonomy': canonical(tax), 'status': 'building'}.items():
  179. insert(c, 'meta', {'key': k, 'value': v})
  180. insert(c, 'batches', {'id': digest, 'archive_name': path.name, 'archive_sha256': digest, 'imported_at': now(), 'rule_version': tax['version']})
  181. for name, info in a.files.items():
  182. with a.z.open(name) as f:
  183. h = file_sha256(f)
  184. insert(c, 'assets', {'path': name, 'sha256': h, 'byte_size': info.file_size})
  185. if name.endswith('.json') and ('/raw/' in name or '/raw-' in name):
  186. try:
  187. request = json.loads(a.z.read(name))
  188. except (ValueError, UnicodeError):
  189. continue
  190. if isinstance(request, dict) and isinstance(request.get('id'), str) and isinstance(request.get('params'), dict):
  191. response = request.get('result', {})
  192. data = response.get('data', {}) if isinstance(response, dict) else {}
  193. data = data if isinstance(data, dict) else {}
  194. safe = {k:v for k,v in request['params'].items() if k in ('keyword','q','page','pageSize','shopId','itemId','tmall','sort','orderType','maxPages','rankId')}
  195. insert(c, 'collection_requests', dict(asset_path=name, endpoint_id=request['id'], run_key=request.get('usageTraceId'),
  196. safe_parameters_json=canonical(safe), collector_status=str(request.get('status') or 'unknown'),
  197. http_status=request.get('httpStatus') if type(request.get('httpStatus')) is int else None,
  198. upstream_code=str(data['code']) if data.get('code') is not None else None, upstream_request_id=data.get('requestId'),
  199. started_at_raw=request.get('startedAt'), finished_at_raw=request.get('finishedAt'), record_time_raw=data.get('recordTime')))
  200. for b in tax['brands']:
  201. insert(c, 'brands', {'code': b['code'], 'name': b['name'], 'aliases_json': canonical(b['aliases'])})
  202. for cat in tax['categories']:
  203. insert(c, 'categories', {'code': cat['code'], 'name': cat['name'], 'parent_code': cat['parent'], 'definition': cat['definition']})
  204. def issue(entity, eid, code, detail):
  205. c.execute('INSERT OR IGNORE INTO quality_issues VALUES(?,?,?,?,?)', (key(entity, eid, code, detail), entity, eid, code, detail))
  206. def source(kind, asset, line, value, raw=None):
  207. rid = key(asset, line)
  208. insert(c, 'source_records', {'id': rid, 'kind': kind, 'asset_path': asset, 'line_number': line,
  209. 'payload_sha256': sha(raw if raw is not None else canonical(value)), 'payload_json': canonical(value)})
  210. for role in ('source_file', 'text_file', 'pdf_file'):
  211. if value.get(role):
  212. link(rid, role, value[role])
  213. for p in value.get('provenance', []):
  214. if p.get('source_file'):
  215. link(rid, 'provenance', p['source_file'])
  216. return rid
  217. def link(rid, role, original):
  218. target, resolution = a.resolve(original)
  219. c.execute('INSERT OR IGNORE INTO source_links VALUES(?,?,?,?,?)', (rid, role, original, target, resolution))
  220. if resolution != 'matched':
  221. issue('source', rid, 'SOURCE_PATH_' + resolution.upper(), role + ': ' + original)
  222. def primary(kind):
  223. asset = a.latest + PRIMARY[kind]
  224. require(asset in a.files, 'PRIMARY_FILE_MISSING:' + kind)
  225. for line, raw in enumerate(a.z.read(asset).decode('utf-8-sig').splitlines(), 1):
  226. if raw.strip():
  227. value = json.loads(raw)
  228. require(isinstance(value, dict), 'INVALID_RECORD')
  229. yield value, source(kind, asset, line, value, raw)
  230. legacy = {}
  231. dbasset = a.latest + '方便面VOC扩充底库.sqlite'
  232. if dbasset in a.files:
  233. # Treat supplied SQL/scripts as data, never execute their instructions.
  234. with tempfile.TemporaryDirectory(prefix='ksf-voc-source-', dir=dest.parent) as td:
  235. original = Path(td) / 'source.sqlite'
  236. original.write_bytes(a.z.read(dbasset))
  237. with readonly(original, check_version=False) as old:
  238. for table in ('product_details', 'prices', 'ranking_signals', 'review_coverage', 'partial_catalog'):
  239. exists = old.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
  240. legacy[table] = [(line, json.loads(raw)) for line, raw in old.execute('SELECT row_id,payload FROM ' + table)] if exists else []
  241. details = collections.defaultdict(list)
  242. for line, row in legacy.get('product_details', []):
  243. sid = source('legacy_product_detail', dbasset, line * 10 + 1, row)
  244. details[(family(row.get('platform')), str(row.get('item_id')))].append((row, sid))
  245. product_rows = list(primary('products'))
  246. # Four reviewed listings are absent from the latest product export.
  247. # Recover only exact referenced IDs from the preserved partial catalog,
  248. # count these separately, and retain its partial-coverage designation.
  249. required_review_ids = {(family(r.get('platform')), str(r.get('item_id'))) for r in map(json.loads, a.z.read(a.latest + PRIMARY['reviews']).decode('utf-8-sig').splitlines()) if r}
  250. known_ids = {(family(r.get('platform')), str(r.get('item_id'))) for r, _ in product_rows}
  251. for line, row in legacy.get('partial_catalog', []):
  252. pair = (family(row.get('platform')), str(row.get('item_id')))
  253. if pair in required_review_ids and pair not in known_ids:
  254. sid = source('legacy_partial_catalog', dbasset, line * 10 + 5, row)
  255. # The original catalog explicitly identifies this channel;
  256. # don't use the taobao_tmall alias alone as proof.
  257. market = 'tmall' if '天猫' in str(row.get('shop_name')) else 'taobao_unverified'
  258. recovered = {**row, 'marketplace':market, 'marketplace_evidence':'preserved_partial_catalog_explicit_tmall_shop' if market == 'tmall' else 'unverified_partial_catalog', 'coverage_type':'recovered_review_reference_partial_catalog'}
  259. product_rows.append((recovered, sid))
  260. known_ids.add(pair)
  261. for row, sid in product_rows:
  262. fam, iid = family(row.get('platform')), identifier(row['item_id'])
  263. require(fam in ('jd', 'taobao'), 'UNKNOWN_PRODUCT_PLATFORM')
  264. pid = fam + ':' + iid
  265. market = row.get('marketplace')
  266. require(market in (('jd',) if fam == 'jd' else ('tmall', 'taobao_unverified')), 'MARKETPLACE_FAMILY_CONFLICT')
  267. title = str(row.get('title') or '')
  268. detail_rows = details.get((fam, iid), [])
  269. upstream_brands = {r.get('product', {}).get('brandName') for r, _ in detail_rows if r.get('product', {}).get('brandName')}
  270. if row.get('brand_name'): upstream_brands.add(row['brand_name'])
  271. upstream = next(iter(upstream_brands)) if len(upstream_brands) == 1 else None
  272. brand, bbasis, bissue = classify_brand(title, tax, upstream)
  273. if len(upstream_brands) > 1:
  274. brand, bbasis, bissue = None, 'conflicting_attributes', 'BRAND_CONFLICT'
  275. category, cbasis, cissue = classify_category(title)
  276. state = 'supported' if brand and category else 'pending'
  277. insert(c, 'products', dict(id=pid, platform_family=fam, item_id=iid, marketplace=market, marketplace_basis=str(row.get('marketplace_evidence') or 'collector_marketplace_assertion'),
  278. shop_id=str(row.get('shop_id') or '') or None, shop_name=row.get('shop_name'), title=title, url=row.get('url'), brand_code=brand,
  279. category_code=category, classification_state=state, brand_basis=bbasis, category_basis=cbasis, source_record_id=sid,
  280. source_time_raw=row.get('source_time'), collected_at_raw=row.get('collected_at'), coverage_type=row.get('coverage_type')))
  281. for dimension, value, basis, evidence, field in [('brand', brand, bbasis, detail_rows[0][1] if upstream and detail_rows else sid, 'brand_name' if row.get('brand_name') else 'product.brandName' if upstream else 'title'),
  282. ('category', category, cbasis, sid, 'title')]:
  283. insert(c, 'classification_assertions', dict(id=key(pid, dimension), product_id=pid, dimension=dimension, value=value, method=basis,
  284. rule_version=tax['version'], evidence_record_id=evidence, evidence_field=field, note='来源属性或保守标题规则;不是人工复核,也不是品牌真伪验证。'))
  285. for code in (bissue, cissue):
  286. if code:
  287. issue('product', pid, code, title)
  288. prov = row.get('provenance') or [row]
  289. seen = set()
  290. for p in prov:
  291. oid = key(pid, {k: p.get(k) for k in ('source_file', 'source_time', 'collected_at', 'query', 'acquisition')})
  292. if oid not in seen:
  293. insert(c, 'product_observations', dict(id=oid, product_id=pid, source_record_id=sid, source_file=p.get('source_file'), source_time_raw=p.get('source_time'), collected_at_raw=p.get('collected_at'), query=p.get('query'), acquisition=p.get('acquisition')))
  294. seen.add(oid)
  295. matches = list(re.finditer(r'(\d+(?:\.\d+)?)\s*(?:g|克)\s*[*×xX]\s*(\d+)\s*(?:包|袋|桶|碗|盒|杯)', title, re.I))
  296. match = matches[0] if len(matches) == 1 else None
  297. insert(c, 'specs', dict(id=key(pid, 'title_spec'), product_id=pid, upstream_sku_id=iid if fam == 'jd' and detail_rows else None,
  298. spec_text=match.group(0) if match else None, net_weight_g=float(match[1]) if match and float(match[1]) > 0 else None,
  299. pack_count=int(match[2]) if match and int(match[2]) > 0 else None, total_weight_g=float(match[1]) * int(match[2]) if match and float(match[1]) * int(match[2]) > 0 else None,
  300. verification='title_candidate' if match else 'unknown', source_record_id=sid, basis='标题声明,未核净含量/赠品/面饼与调料组成;不生成每100g价格。'))
  301. if row.get('price') is not None:
  302. add_price(c, pid, row.get('price'), row.get('price_type') or 'unknown', row, sid, issue)
  303. if row.get('promotional_price') is not None:
  304. issue('product', pid, 'PROMOTIONAL_PRICE_UNRESOLVED', '促销价格口径/适用条件未映射,保留原记录,不混作搜索展示价。')
  305. for row, sid in primary('reviews'):
  306. fam, iid, reviewid = family(row.get('platform')), identifier(row['item_id']), identifier(row['review_id'])
  307. require(fam in ('jd', 'taobao'), 'UNKNOWN_REVIEW_PLATFORM')
  308. rid, pid = fam + ':' + iid + ':' + reviewid, fam + ':' + iid
  309. product = c.execute('SELECT marketplace,category_code FROM products WHERE id=?', (pid,)).fetchone()
  310. identity = 'orphan' if not product else 'mismatch' if row.get('item_identity_match') is False or row.get('observed_item_id') and str(row['observed_item_id']) != iid or product[0] != row.get('marketplace') else 'matched' if row.get('item_identity_match') is True else 'unverified'
  311. sku = str(row.get('sku') or '')
  312. scategory, _, sku_issue = classify_category(sku)
  313. category_state = 'unknown' if not product or not product[1] else 'conflict' if sku_issue == 'CATEGORY_CONFLICT' or scategory and scategory != 'instant_unspecified' and scategory != product[1] else 'consistent' if scategory == product[1] else 'listing_only'
  314. default = int(row['is_default_text']) if isinstance(row.get('is_default_text'), bool) else None
  315. insert(c, 'reviews', dict(id=rid, platform_family=fam, item_id=iid, review_id=reviewid, id_method=row.get('review_id_method') or 'unspecified_not_verified',
  316. product_id=pid if product else None, marketplace_claim=row.get('marketplace') or 'unknown', content=str(row.get('content') or ''),
  317. append_content_json=canonical(row.get('append_content')), rating_raw=str(row['rating']) if row.get('rating') is not None else None,
  318. rating_scale=row.get('rating_scale'), review_date_raw=row.get('review_date'), review_date_utc=utc(row.get('review_date')), review_calendar_date=calendar_date(row.get('review_date')),
  319. purchased_sku_text=sku, is_default=default, identity_state=identity, category_state=category_state, source_time_raw=row.get('source_time'), collected_at_raw=row.get('collected_at'), source_record_id=sid))
  320. for code, enabled in [('REVIEW_IDENTITY_' + identity.upper(), identity != 'matched'), ('REVIEW_CATEGORY_' + category_state.upper(), category_state in ('conflict', 'unknown')), ('REVIEW_DEFAULT_UNKNOWN', default is None)]:
  321. if enabled:
  322. issue('review', rid, code, '不得按标题/昵称模糊关联其他商品或平台。')
  323. for row, sid in primary('social'):
  324. platform, postid = str(row['platform']), identifier(row['id'])
  325. soid = platform + ':' + postid
  326. counts = {name: row.get(field) if isinstance(row.get(field), int) and not isinstance(row.get(field), bool) and row[field] >= 0 else None for name, field in [('likes','likes'), ('comment_count','comments'), ('collects','collects'), ('shares','shares')]}
  327. insert(c, 'social_posts', dict(id=soid, platform=platform, post_id=postid, title=row.get('title'), content=row.get('content'), url=row.get('url'),
  328. content_scope=row.get('content_scope') or 'unknown', published_at_raw=row.get('published_at'), published_at_utc=utc(row.get('published_at')),
  329. collected_at_raw=row.get('collected_at'), fulltext_available=int(bool(str(row.get('content') or '').strip())), source_record_id=sid, **counts))
  330. for b in brand_hits(str(row.get('title') or '') + '\n' + str(row.get('content') or ''), tax):
  331. insert(c, 'social_brand_mentions', dict(post_id=soid, brand_code=b['code'], basis='text_mention_only_not_product_ownership'))
  332. for row, sid in primary('documents'):
  333. matches = [p for p in (row.get('pdf_file'), row.get('text_file'), row.get('source_file')) if p]
  334. hashes = [c.execute('SELECT sha256 FROM assets WHERE path=?', (a.resolve(p)[0],)).fetchone() for p in matches]
  335. hash_basis = next((a.resolve(p)[0] + '#bytes' for p, h in zip(matches, hashes) if h and h[0] == row.get('sha256')), None)
  336. if not hash_basis and row.get('source_file'):
  337. source_path, _ = a.resolve(row['source_file'])
  338. if source_path and source_path.endswith('.json'):
  339. doc = json.loads(a.z.read(source_path))
  340. # The expansion collector hashes the decoded `text`,
  341. # whereas earlier collectors hash a complete JSON/PDF.
  342. if isinstance(doc, dict) and isinstance(doc.get('text'), str) and sha(doc['text']) == row.get('sha256'):
  343. hash_basis = source_path + '#$.text (UTF-8 decoded text)'
  344. hash_state = 'matched' if hash_basis else 'unresolved'
  345. full_text = row.get('full_text') if isinstance(row.get('full_text'), str) else None
  346. text_path, _ = a.resolve(row.get('text_file'))
  347. if text_path and full_text is None:
  348. full_text = a.z.read(text_path).decode('utf-8-sig')
  349. elif row.get('source_file') and full_text is None:
  350. json_path, _ = a.resolve(row['source_file'])
  351. if json_path and json_path.endswith('.json'):
  352. document_json = json.loads(a.z.read(json_path))
  353. if isinstance(document_json, dict) and isinstance(document_json.get('text'), str):
  354. full_text = document_json['text']
  355. insert(c, 'policy_documents', dict(id=identifier(row['document_id']), title=str(row['title']), declared_status=str(row.get('declared_status') or row.get('status_on_20261104') or 'unknown'), status_as_of=row.get('status_as_of') or ('2026-11-04' if 'status_on_20261104' in row else 'unknown'),
  356. issued_date=row.get('issued_date'), effective_date=row.get('effective_date'), source_url=row.get('source_url'), expected_sha256=row.get('sha256'),
  357. attachment_hash_state=hash_state, hash_basis=hash_basis, coverage=row.get('coverage'), full_text=full_text, source_record_id=sid))
  358. if hash_state != 'matched':
  359. issue('document', row['document_id'], 'ATTACHMENT_HASH_UNRESOLVED', '需核对声明的 SHA 对应哪份附件;不能声称完整原文已逐字核验。')
  360. for row, sid in primary('clauses'):
  361. method = str(row.get('excerpt_method') or '')
  362. insert(c, 'policy_clauses', dict(id=key(row['document_id'], row['clause']), document_id=row['document_id'], clause=str(row['clause']), pdf_page=str(row['pdf_page']) if row.get('pdf_page') is not None else None,
  363. excerpt=str(row.get('source_excerpt') or ''), excerpt_type='paraphrase_or_transcription' if '非逐字' in method or '摘要' in method else 'unverified_excerpt',
  364. application_note=row.get('application_note'), excerpt_method=method or None, source_record_id=sid))
  365. for kind in ('prices', 'ranking_signals', 'review_coverage'):
  366. for line, row in legacy.get(kind, []):
  367. sid = source('legacy_' + kind, dbasset, line * 10 + {'prices': 2, 'ranking_signals': 3, 'review_coverage': 4}[kind], row)
  368. pid = str(family(row.get('platform'))) + ':' + str(row.get('item_id'))
  369. exists = c.execute('SELECT 1 FROM products WHERE id=?', (pid,)).fetchone()
  370. if kind == 'prices':
  371. if exists:
  372. add_price(c, pid, row.get('price_raw', row.get('price_cny')), str(row.get('price_type') or 'unknown'), row, sid, issue)
  373. else:
  374. issue('source', sid, 'LEGACY_ORPHAN_PRODUCT', pid)
  375. elif kind == 'ranking_signals':
  376. insert(c, 'ranking_signals', dict(id=key(sid, kind), product_id=pid if exists else None, rank_id=str(row.get('rank_id') or ''), rank_number=row.get('rank'),
  377. title=row.get('rank_title'), scope='detail_page_badge_not_complete_ranking', source_time_raw=row.get('source_time'), source_record_id=sid))
  378. else:
  379. insert(c, 'coverage_observations', dict(id=key(sid, kind), product_id=pid if exists else None, status=row.get('status'), available_reviews=row.get('available_reviews'),
  380. reported_total=str(row.get('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))
  381. from retrieval import create_index
  382. create_index(c)
  383. c.execute("UPDATE meta SET value='ready' WHERE key='status'")
  384. require(c.execute('PRAGMA integrity_check').fetchone()[0] == 'ok', 'INTEGRITY_CHECK_FAILED')
  385. require(not c.execute('PRAGMA foreign_key_check').fetchall(), 'FOREIGN_KEY_CHECK_FAILED')
  386. c.execute('ANALYZE')
  387. c.commit()
  388. counts = table_counts(c)
  389. quality = dict(c.execute('SELECT code,count(*) FROM quality_issues GROUP BY code'))
  390. c.close()
  391. os.chmod(temp, 0o600)
  392. # No overwriting an existing database, including one created concurrently.
  393. os.link(temp, dest)
  394. temp.unlink()
  395. return {'ok': True, 'operation': 'created', 'schema': VERSION, 'generation': generation, 'archiveSha256': digest,
  396. 'counts': counts, 'quality': quality, 'durationSeconds': round(time.perf_counter() - started, 3),
  397. 'productionImported': False, 'note': '已生成本地结构库;部署到授权 Agent 后才能查询,未写生产业务库。'}
  398. finally:
  399. a.z.close()
  400. with contextlib.suppress(Exception):
  401. c.close()
  402. if temp.exists():
  403. temp.unlink()
  404. def add_price(c, pid, raw, basis, row, sid, issue):
  405. amount = money(raw)
  406. if amount is None:
  407. issue('source', sid, 'PRICE_NOT_SINGLE_NUMERIC', str(raw))
  408. # Repeated observations of the same response are lineage, not new prices.
  409. basis = {'搜索展示价':'search_listing_snapshot','详情展示价':'detail_listing_snapshot'}.get(basis, basis)
  410. asset = c.execute("SELECT asset_path FROM source_links WHERE record_id=? AND role='source_file' AND resolution='matched'", (sid,)).fetchone()
  411. priceid = key(pid, basis, amount if amount is not None else str(raw), row.get('source_time'), asset[0] if asset else row.get('source_file'))
  412. c.execute('INSERT OR IGNORE INTO prices VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
  413. (priceid, pid, 'CNY', amount, str(raw), basis, row.get('source_time'), utc(row.get('source_time')), calendar_date(row.get('source_time')),
  414. row.get('collected_at'), utc(row.get('collected_at')), None, 'unavailable', sid))
  415. c.execute('INSERT OR IGNORE INTO price_sources VALUES(?,?)', (priceid,sid))
  416. @contextlib.contextmanager
  417. def readonly(path, check_version=True):
  418. from unified_store import Reference, read_research
  419. if isinstance(path, Reference):
  420. with read_research(path) as c:
  421. if check_version:
  422. values = dict(c.execute('SELECT key,value FROM meta'))
  423. require(values.get('schema') == VERSION and values.get('status') == 'ready', 'DATABASE_VERSION_OR_STATE_MISMATCH')
  424. yield c
  425. return
  426. p = Path(path).resolve()
  427. require(p.is_file(), 'DATABASE_NOT_INSTALLED')
  428. c = sqlite3.connect(p.as_uri() + '?mode=ro&immutable=1', uri=True)
  429. c.execute('PRAGMA query_only=ON')
  430. c.execute('PRAGMA trusted_schema=OFF')
  431. c.row_factory = sqlite3.Row
  432. try:
  433. if check_version:
  434. values = dict(c.execute('SELECT key,value FROM meta'))
  435. require(values.get('schema') == VERSION and values.get('status') == 'ready', 'DATABASE_VERSION_OR_STATE_MISMATCH')
  436. yield c
  437. finally:
  438. c.close()
  439. def table_counts(c):
  440. names = ['products','reviews','social_posts','policy_documents','policy_clauses','prices','specs','product_observations','source_records','source_links','assets','collection_requests','quality_issues','ranking_signals','coverage_observations']
  441. return {t: c.execute('SELECT count(*) FROM ' + t).fetchone()[0] for t in names}
  442. def contract(c):
  443. meta = dict(c.execute('SELECT key,value FROM meta'))
  444. from retrieval import catalog
  445. from schema_catalog import catalog as schema_catalog
  446. return {'ok': True, 'schema': VERSION, 'generation': meta['generation'], 'readOnly': True, 'databaseWrites': 0,
  447. 'retrieval':catalog(c), 'recommendedCommands':['search','aggregate','facets','get','source'], 'legacyQueryNotice':'旧 query 保留三维模板兼容;一般问题请用 search,按实际需求设置条件,不要求补齐三个维度。',
  448. 'taxonomy': json.loads(meta['taxonomy']), 'entities': list(TABLES), 'queryFields': ['entity','marketplace','brand','category','shopId','itemId','keyword','limit','cursor','from','to','timeBasis','reviewScope','includeDefault','contentScope','documentId','policyStatus'],
  449. 'dataDictionary':schema_catalog(c, detailed=False),
  450. 'limits': {'default': 20, 'max': 100}, 'counts': table_counts(c),
  451. 'rules': ['优先用 search 按问题实际指定的维度过滤;不要求填齐平台/品牌/品类;旧 query 的三维模板仅保留兼容。',
  452. '全部条件 AND;不自动包含子类;同族商品精确关联评论;不按标题/昵称跨商品合并。',
  453. 'reviewScope=sku_confirmed 默认,仅用购买规格明确匹配品类的评价;listing 单独查询并标识规格未证实。',
  454. '默认排除默认评价、身份待核/错配、品类冲突;默认文本数量不等于观点数量。',
  455. 'timeBasis 必填才能用 from/to;calendar 日期含起点、不含终点,来源时区未证实;UTC 不含无时区数据。',
  456. 'social 的 brand 是文本提及,绝不是商品品牌归属;comment_count 不是已采集评论。',
  457. '政策状态仅为 2026-11-04 的声明快照;条款均未自动认定逐字引文。'],
  458. 'capabilities': {'exactScopeQuery': True, 'getCitation': True, 'activeRegistrySupported': True, 'generationGuard': True,
  459. 'upstreamCollection': False, 'businessDatabaseProjection': False, 'publicBoardConnectionVerified': False}}
  460. def resolve_code(value, rows, code_field='code'):
  461. if value == 'all':
  462. return value
  463. require(isinstance(value, str) and bool(value.strip()), 'EXPLICIT_SCOPE_REQUIRED')
  464. hits = [r[code_field] for r in rows if value.casefold() in [str(r.get(code_field)).casefold(), str(r.get('name')).casefold(), *[a.casefold() for a in r.get('aliases', [])]]]
  465. require(len(hits) == 1, 'UNKNOWN_OR_AMBIGUOUS_TAXONOMY:' + value)
  466. return hits[0]
  467. def query_plan(c, request):
  468. require(isinstance(request, dict), 'INVALID_QUERY')
  469. allowed = {'entity','marketplace','brand','category','shopId','itemId','keyword','limit','cursor','from','to','timeBasis','reviewScope','includeDefault','contentScope','documentId','policyStatus'}
  470. require(not set(request) - allowed, 'UNKNOWN_QUERY_FIELD')
  471. entity = request.get('entity')
  472. require(entity in TABLES, 'UNKNOWN_ENTITY')
  473. size = request.get('limit', 20)
  474. require(type(size) is int and 1 <= size <= 100, 'INVALID_LIMIT')
  475. tax = json.loads(c.execute("SELECT value FROM meta WHERE key='taxonomy'").fetchone()[0])
  476. normalized = dict(request)
  477. conditions, values = [], []
  478. entity_id = 'p.id' if entity == 'products' else 'r.id' if entity == 'reviews' else 'v.id'
  479. scope_conditions, scope_values = [], []
  480. excluded = {}
  481. if entity in ('products','reviews','prices'):
  482. market = request.get('marketplace')
  483. require(market == 'all' or market in tax['marketplaceAliases'], 'MARKETPLACE_REQUIRED_OR_UNKNOWN')
  484. market = 'all' if market == 'all' else tax['marketplaceAliases'][market]
  485. normalized.update(marketplace=market, brand=resolve_code(request.get('brand'), tax['brands']), category=resolve_code(request.get('category'), tax['categories']))
  486. for col, val in [('marketplace', market), ('brand_code', normalized['brand']), ('category_code', normalized['category'])]:
  487. if val != 'all':
  488. conditions.append('p.' + col + '=?')
  489. values.append(val)
  490. conditions.append("p.classification_state='supported'")
  491. for field, column in [('shopId','shop_id'),('itemId','item_id')]:
  492. if field in request:
  493. conditions.append('p.' + column + '=?')
  494. values.append(identifier(request[field]))
  495. if 'keyword' in request:
  496. require(isinstance(request['keyword'], str) and 0 < len(request['keyword']) <= 100, 'INVALID_KEYWORD')
  497. conditions.append('instr(p.title,?)>0')
  498. values.append(request['keyword'])
  499. scope_conditions, scope_values = conditions[:], values[:]
  500. scoped_products = c.execute('SELECT count(*) FROM products p WHERE ' + ' AND '.join(conditions), values).fetchone()[0]
  501. excluded['scopeProducts'] = scoped_products
  502. excluded['unclassifiedProductsInMarketplace'] = c.execute("SELECT count(*) FROM products WHERE classification_state='pending'" + (' AND marketplace=?' if market != 'all' else ''), [market] if market != 'all' else []).fetchone()[0]
  503. if entity == 'products':
  504. require(not set(request) & {'reviewScope','includeDefault','contentScope','documentId','policyStatus','from','to','timeBasis'}, 'FILTER_NOT_SUPPORTED_FOR_ENTITY')
  505. from_sql = 'products p'
  506. select = 'p.*'
  507. elif entity == 'reviews':
  508. require(not set(request) & {'contentScope','documentId','policyStatus'}, 'FILTER_NOT_SUPPORTED_FOR_ENTITY')
  509. scope = request.get('reviewScope', 'sku_confirmed')
  510. require(scope in ('sku_confirmed','listing'), 'INVALID_REVIEW_SCOPE')
  511. require(type(request.get('includeDefault', False)) is bool, 'INVALID_INCLUDE_DEFAULT')
  512. normalized.update(reviewScope=scope, includeDefault=request.get('includeDefault', False))
  513. base = ' FROM reviews r JOIN products p ON p.id=r.product_id WHERE ' + ' AND '.join(conditions)
  514. excluded['reviewCountsBeforeQualityFilters'] = {str(x[0]): x[1] for x in c.execute('SELECT r.identity_state || ":" || r.category_state || ":default=" || coalesce(r.is_default,"unknown"),count(*)' + base + ' GROUP BY 1', values)}
  515. conditions.append("r.identity_state='matched'")
  516. conditions.append("r.category_state='consistent'" if scope == 'sku_confirmed' else "r.category_state IN ('consistent','listing_only')")
  517. if not normalized['includeDefault']:
  518. conditions.append('r.is_default=0')
  519. from_sql = 'products p JOIN reviews r ON r.product_id=p.id'
  520. select = 'r.*,p.marketplace,p.brand_code,p.category_code,p.title AS product_title,p.brand_basis,p.category_basis'
  521. else:
  522. require(not set(request) & {'reviewScope','includeDefault','contentScope','documentId','policyStatus'}, 'FILTER_NOT_SUPPORTED_FOR_ENTITY')
  523. conditions.append('v.amount_minor IS NOT NULL')
  524. from_sql = 'products p JOIN prices v ON v.product_id=p.id'
  525. select = 'v.*,p.marketplace,p.brand_code,p.category_code,p.title AS product_title,p.brand_basis,p.category_basis'
  526. elif entity == 'social':
  527. require(not set(request) & {'category','shopId','itemId','reviewScope','includeDefault','documentId','policyStatus'}, 'FILTER_NOT_SUPPORTED_FOR_ENTITY')
  528. platform = request.get('marketplace')
  529. require(platform in ('bilibili','douyin','xiaohongshu','all'), 'SOCIAL_PLATFORM_REQUIRED')
  530. if platform != 'all':
  531. conditions.append('v.platform=?'); values.append(platform)
  532. scope = request.get('contentScope', 'packaged_noodles')
  533. require(scope in ('packaged_noodles','adjacent_noodle_food','unknown','all'), 'UNKNOWN_CONTENT_SCOPE')
  534. normalized['contentScope'] = scope
  535. if scope != 'all':
  536. conditions.append('v.content_scope=?'); values.append(scope)
  537. if 'brand' in request:
  538. brand = resolve_code(request['brand'], tax['brands'])
  539. normalized['brand'] = brand
  540. if brand != 'all':
  541. conditions.append('EXISTS(SELECT 1 FROM social_brand_mentions m WHERE m.post_id=v.id AND m.brand_code=?)'); values.append(brand)
  542. if 'keyword' in request:
  543. require(isinstance(request['keyword'], str) and 0 < len(request['keyword']) <= 100, 'INVALID_KEYWORD')
  544. conditions.append("instr(coalesce(v.title,'') || coalesce(v.content,''),?)>0"); values.append(request['keyword'])
  545. from_sql, select = 'social_posts v', 'v.*'
  546. else:
  547. require(not set(request) & {'marketplace','brand','category','shopId','itemId','reviewScope','includeDefault','contentScope','from','to','timeBasis'}, 'FILTER_NOT_SUPPORTED_FOR_ENTITY')
  548. from_sql, select = ('policy_documents v', 'v.*') if entity == 'documents' else ('policy_clauses v JOIN policy_documents d ON d.id=v.document_id', 'v.*,d.declared_status,d.status_as_of,d.source_url')
  549. if 'documentId' in request:
  550. conditions.append(('v.id' if entity == 'documents' else 'v.document_id') + '=?'); values.append(identifier(request['documentId']))
  551. if 'policyStatus' in request:
  552. require(request['policyStatus'] in {r[0] for r in c.execute('SELECT DISTINCT declared_status FROM policy_documents')}, 'UNKNOWN_POLICY_STATUS')
  553. conditions.append(('v' if entity == 'documents' else 'd') + '.declared_status=?'); values.append(request['policyStatus'])
  554. if 'keyword' in request:
  555. require(isinstance(request['keyword'], str) and 0 < len(request['keyword']) <= 100, 'INVALID_KEYWORD')
  556. conditions.append(('instr(v.title,?)>0' if entity == 'documents' else 'instr(v.excerpt || coalesce(v.application_note,\'\'),?)>0')); values.append(request['keyword'])
  557. if set(request) & {'from','to','timeBasis'}:
  558. time_fields = {'reviews': {'review_calendar_date':'r.review_calendar_date','review_utc':'r.review_date_utc'},
  559. 'prices': {'source_calendar_date':'v.source_calendar_date','source_utc':'v.source_time_utc'},
  560. 'social': {'published_utc':'v.published_at_utc'}}
  561. basis = request.get('timeBasis')
  562. require(basis in time_fields.get(entity, {}) and bool(set(request) & {'from','to'}), 'EXPLICIT_SUPPORTED_TIME_BASIS_REQUIRED')
  563. column = time_fields[entity][basis]
  564. before = ' AND '.join(conditions) if conditions else '1=1'
  565. excluded['unknownTimeExcluded'] = c.execute('SELECT count(*) FROM ' + from_sql + ' WHERE ' + before + ' AND ' + column + ' IS NULL', values).fetchone()[0]
  566. normalized_times = {}
  567. for field, op in [('from','>='),('to','<')]:
  568. if field in request:
  569. value = request[field]
  570. require(isinstance(value, str), 'INVALID_TIME')
  571. if basis.endswith('calendar_date'):
  572. require(bool(re.fullmatch(r'\d{4}-\d{2}-\d{2}', value)) and calendar_date(value) == value, 'INVALID_DATE')
  573. else:
  574. value = utc(value); require(value is not None, 'TIMEZONE_REQUIRED')
  575. normalized_times[field] = value
  576. conditions.append(column + op + '?'); values.append(value)
  577. require(not ('from' in normalized_times and 'to' in normalized_times) or normalized_times['from'] < normalized_times['to'], 'INVALID_TIME_RANGE')
  578. normalized.update(normalized_times)
  579. where = ' AND '.join(conditions) if conditions else '1=1'
  580. total = c.execute('SELECT count(*) FROM ' + from_sql + ' WHERE ' + where, values).fetchone()[0]
  581. generation = c.execute("SELECT value FROM meta WHERE key='generation'").fetchone()[0]
  582. context = key(generation, {k:v for k,v in normalized.items() if k not in ('cursor','limit')})
  583. if request.get('cursor'):
  584. try:
  585. cursor = json.loads(base64.urlsafe_b64decode(request['cursor']))
  586. require(set(cursor) == {'context','after'} and cursor['context'] == context and isinstance(cursor['after'], str), 'CURSOR_SCOPE_MISMATCH')
  587. except (ValueError, TypeError, KeyError):
  588. raise LibraryError('CURSOR_SCOPE_MISMATCH') from None
  589. where += ' AND ' + entity_id + '>?'; values.append(cursor['after'])
  590. sql = 'SELECT ' + select + ' FROM ' + from_sql + ' WHERE ' + where + ' ORDER BY ' + entity_id + ' LIMIT ?'
  591. return sql, values + [size + 1], normalized, context, total, excluded
  592. def citation(c, sid, expand=False):
  593. s = c.execute('SELECT id,kind,asset_path,line_number,payload_sha256 FROM source_records WHERE id=?', (sid,)).fetchone()
  594. require(s is not None, 'SOURCE_NOT_FOUND')
  595. result = dict(s)
  596. result['locatorType'] = 'sqlite_legacy_row_encoded' if s['kind'].startswith('legacy_') else 'jsonl_line_1_based'
  597. if s['kind'].startswith('legacy_'):
  598. result['legacyRowId'] = s['line_number'] // 10
  599. result['legacyTable'] = {'legacy_product_detail':'product_details','legacy_prices':'prices','legacy_ranking_signals':'ranking_signals','legacy_review_coverage':'review_coverage','legacy_partial_catalog':'partial_catalog'}[s['kind']]
  600. result['assetSha256'] = c.execute('SELECT sha256 FROM assets WHERE path=?', (s['asset_path'],)).fetchone()[0]
  601. result['archiveSha256'] = c.execute("SELECT value FROM meta WHERE key='archive_sha256'").fetchone()[0]
  602. if c.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='source_origins'").fetchone():
  603. origins=[r[0] for r in c.execute('SELECT archive_sha256 FROM source_origins WHERE source_record_id=? ORDER BY archive_sha256',(sid,))]
  604. if origins:
  605. result['archiveSha256']=origins[0]
  606. result['archiveSha256s']=origins
  607. result['upstreamSourceCount'] = c.execute('SELECT count(*) FROM source_links WHERE record_id=?', (sid,)).fetchone()[0]
  608. result['upstreamSources'] = [dict(r) for r in c.execute('SELECT l.role,l.original_path,l.asset_path,l.resolution,a.sha256 FROM source_links l LEFT JOIN assets a ON a.path=l.asset_path WHERE l.record_id=? ORDER BY CASE WHEN l.role=\'source_file\' THEN 0 ELSE 1 END,l.role,l.original_path' + ('' if expand else ' LIMIT 3'), (sid,))]
  609. for link in result['upstreamSources']:
  610. request = c.execute('SELECT endpoint_id,run_key,safe_parameters_json,collector_status,http_status,upstream_code,upstream_request_id,started_at_raw,finished_at_raw,record_time_raw FROM collection_requests WHERE asset_path=?', (link['asset_path'],)).fetchone()
  611. if request:
  612. link['collectionRequest'] = dict(request)
  613. result['upstreamSourcesTruncated'] = len(result['upstreamSources']) < result['upstreamSourceCount']
  614. return result
  615. def decorate(c, row, entity):
  616. result = dict(row)
  617. result['citation'] = citation(c, row['source_record_id'])
  618. if entity == 'products':
  619. result['classification'] = [dict(r) for r in c.execute('SELECT dimension,value,method,rule_version,evidence_record_id,evidence_field,note FROM classification_assertions WHERE product_id=? ORDER BY dimension', (row['id'],))]
  620. for assertion in result['classification']:
  621. dimension=assertion['dimension']
  622. assertion['selectedForCurrentRecord']=assertion['value']==row[dimension+'_code'] and assertion['method']==row[dimension+'_basis']
  623. result['specs'] = [dict(r) for r in c.execute('SELECT upstream_sku_id,spec_text,net_weight_g,pack_count,total_weight_g,verification,basis,source_record_id FROM specs WHERE product_id=?', (row['id'],))]
  624. if c.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_decisions'").fetchone():
  625. decisions=[dict(r) for r in c.execute('SELECT decision,reason_codes_json,batch_sha256,prior_source_record_id,incoming_source_record_id,selected_source_record_id,policy_version FROM merge_decisions WHERE entity=? AND record_id=? ORDER BY id LIMIT 11',(entity,row['id']))]
  626. for decision in decisions:decision['reasons']=json.loads(decision.pop('reason_codes_json'))
  627. result['mergeHistory']=decisions[:10];result['mergeHistoryTruncated']=len(decisions)>10
  628. if entity in ('reviews', 'prices') and row['product_id']:
  629. product = c.execute('SELECT source_record_id FROM products WHERE id=?', (row['product_id'],)).fetchone()
  630. result['productCitation'] = citation(c, product[0])
  631. return result
  632. def query(c, request, explain=False):
  633. started = time.perf_counter()
  634. sql, values, normalized, context, total, excluded = query_plan(c, request)
  635. rows = c.execute(sql, values).fetchall()
  636. size = normalized.get('limit', 20)
  637. more = len(rows) > size
  638. rows = rows[:size]
  639. cursor = base64.urlsafe_b64encode(canonical({'context':context, 'after':rows[-1]['id']}).encode()).decode() if more else None
  640. result = {'ok':True, 'schema':VERSION, 'readOnly':True, 'databaseWrites':0, 'effectiveFilters':{k:v for k,v in normalized.items() if k != 'cursor'},
  641. 'scopeCombination':'AND', 'totalMatched':total, 'records':[decorate(c, row, normalized['entity']) for row in rows], 'nextCursor':cursor,
  642. 'coverage':excluded, 'classificationNotice':'品牌/品类含保守标题规则,并非全部人工复核;未知/冲突排除,详见 basis 与证据。',
  643. 'elapsedMs':round((time.perf_counter()-started)*1000,3)}
  644. if explain:
  645. result['queryPlan'] = [r['detail'] for r in c.execute('EXPLAIN QUERY PLAN ' + sql, values)]
  646. return result
  647. def get_record(c, entity, recordid):
  648. from retrieval import ENTITIES
  649. require(entity in ENTITIES, 'UNKNOWN_ENTITY')
  650. row = c.execute('SELECT * FROM ' + ENTITIES[entity] + ' WHERE id=?', (recordid,)).fetchone()
  651. require(row is not None, 'RECORD_NOT_FOUND')
  652. return {'ok':True, 'schema':VERSION, 'readOnly':True, 'databaseWrites':0, 'record':decorate(c, row, entity)}
  653. def get_source(c, recordid):
  654. row = c.execute('SELECT payload_json FROM source_records WHERE id=?', (recordid,)).fetchone()
  655. require(row is not None, 'SOURCE_NOT_FOUND')
  656. return {'ok':True, 'schema':VERSION, 'readOnly':True, 'databaseWrites':0,
  657. 'citation':citation(c, recordid, expand=True), 'sourcePayload':json.loads(row[0])}
  658. def registry_database(root):
  659. """Resolve the same atomic active pointer used by the browser service."""
  660. root=Path(root).resolve()
  661. pointer=json.loads((root/'current.json').read_text(encoding='utf-8'))
  662. db=(root/pointer['file']).resolve()
  663. require(db.is_relative_to(root) and db.is_file(),'INVALID_LIBRARY_POINTER')
  664. return db
  665. def main():
  666. parser = argparse.ArgumentParser(description=__doc__)
  667. parser.add_argument('command', choices=['build','contract','schema','query','search','aggregate','facets','get','source','verify'])
  668. parser.add_argument('--db', default=os.environ.get('KSF_VOC_DB', '/opt/data/ksf-voc-library/library.sqlite'))
  669. parser.add_argument('--registry', default=os.environ.get('KSF_LIBRARY_REGISTRY'))
  670. parser.add_argument('--generation', help='Expected database generation from contract; fail if the active library changed')
  671. parser.add_argument('--archive')
  672. parser.add_argument('--input')
  673. from retrieval import ENTITIES
  674. parser.add_argument('--entity', choices=list(ENTITIES))
  675. parser.add_argument('--id')
  676. parser.add_argument('--receipt')
  677. parser.add_argument('--explain', action='store_true')
  678. parser.add_argument('--table', help='Exact installed table for schema inspection')
  679. parser.add_argument('--profile', action='store_true', help='Count non-null fields of the selected schema table; no row contents')
  680. args = parser.parse_args()
  681. try:
  682. require(args.command == 'schema' or not (args.table or args.profile), 'SCHEMA_OPTIONS_REQUIRE_SCHEMA_COMMAND')
  683. if args.command == 'build':
  684. require(not args.registry, 'BUILD_REJECTS_ACTIVE_REGISTRY')
  685. require(not args.generation, 'BUILD_REJECTS_GENERATION')
  686. require(args.archive, 'ARCHIVE_REQUIRED')
  687. result = build(args.archive, args.db)
  688. else:
  689. require(not args.archive and not args.receipt, 'READ_ONLY_COMMAND_REJECTS_WRITE_OPTIONS')
  690. require(not (args.registry and '--db' in sys.argv), 'AMBIGUOUS_DATABASE_LOCATION')
  691. import unified_store
  692. location = unified_store.research_reference() if unified_store.configured() and '--db' not in sys.argv else (registry_database(args.registry) if args.registry else args.db)
  693. with readonly(location) as c:
  694. generation = c.execute("SELECT value FROM meta WHERE key='generation'").fetchone()[0]
  695. require(args.generation is None or args.generation == generation, 'LIBRARY_GENERATION_CHANGED')
  696. if args.command == 'contract':
  697. result = contract(c)
  698. root = Path(args.registry) if args.registry else None
  699. unified = isinstance(location, unified_store.Reference)
  700. result['publicationBinding'] = {'mode':'parse-postgresql' if unified else ('registry' if root else 'snapshot'),
  701. 'newGenerationsSupported':bool(unified or root), 'registryWritableByCurrentProcess':False if unified else bool(root and os.access(root, os.W_OK)),
  702. 'currentGeneration':generation, 'taskAuthorizationStillRequired':True,
  703. 'note':'研究底料与业务成果共用企业数据库,按原接口发布新代际' if unified else '新数据可按已授权任务通过update_library.py合并新代际;快照模式须先由运维绑定实际发布位置'}
  704. elif args.command == 'schema':
  705. from schema_catalog import catalog as schema_catalog
  706. result = {'ok':True, 'generation':generation, **schema_catalog(c, args.table, args.profile)}
  707. elif args.command in ('query','search','aggregate','facets'):
  708. require(args.input, 'INPUT_REQUIRED')
  709. request = json.loads(sys.stdin.read() if args.input == '-' else Path(args.input).read_text(encoding='utf-8-sig'))
  710. if args.command=='query':result = query(c, request, args.explain)
  711. else:
  712. from retrieval import search, aggregate
  713. result = search(c,request,args.explain) if args.command=='search' else aggregate(c,request,facets=args.command=='facets')
  714. elif args.command == 'get':
  715. require(args.entity and args.id, 'ENTITY_AND_ID_REQUIRED')
  716. result = get_record(c, args.entity, args.id)
  717. elif args.command == 'source':
  718. require(args.id, 'SOURCE_ID_REQUIRED')
  719. result = get_source(c, args.id)
  720. else:
  721. require(c.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' and not c.execute('PRAGMA foreign_key_check').fetchall(), 'DATABASE_CHECK_FAILED')
  722. result = {'ok':True, 'schema':VERSION, 'readOnly':True, 'databaseWrites':0, 'counts':table_counts(c)}
  723. result['generation'] = generation
  724. if args.command == 'build' and args.receipt:
  725. # An operator may create a new receipt; never overwrite arbitrary files.
  726. with Path(args.receipt).open('x', encoding='utf-8') as f:
  727. f.write(canonical(result) + '\n')
  728. print(json.dumps(result, ensure_ascii=False, indent=2))
  729. except (LibraryError, sqlite3.Error, ValueError, OSError, zipfile.BadZipFile) as error:
  730. code = str(error)
  731. if os.environ.get('KSF_UNIFIED_DB_CONFIG') and not __import__('re').fullmatch('[A-Z][A-Z0-9_:.-]{0,150}', code):
  732. code = 'UNIFIED_READ_FAILED'
  733. print(json.dumps({'ok':False, 'error':{'code':code}, 'readOnly':args.command != 'build', 'databaseWrites':0 if args.command != 'build' else None}, ensure_ascii=False), file=sys.stderr)
  734. return 1
  735. except Exception:
  736. print(json.dumps({'ok':False, 'error':{'code':'UNIFIED_READ_FAILED'}, 'readOnly':args.command != 'build'}), file=sys.stderr)
  737. return 1
  738. return 0
  739. if __name__ == '__main__':
  740. sys.exit(main())