retrieval.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. """Typed retrieval across VOC entities; literal evidence + bounded term expansion.
  2. No natural-language/semantic understanding is claimed here: the Agent translates
  3. the question using the discoverable contract. FTS generates indexed candidates;
  4. literal phrases are checked again and returned with original-text snippets.
  5. """
  6. import base64
  7. import collections
  8. import hashlib
  9. import json
  10. import math
  11. import re
  12. import unicodedata
  13. VERSION = 'ksf.voc-retrieval.v2'
  14. AGENT_RULES_VERSION = 'ksf.agent-data-rules.20261106.v1'
  15. ENTITIES = {'products':'products', 'reviews':'reviews', 'prices':'prices', 'specs':'specs',
  16. 'social':'social_posts', 'documents':'policy_documents', 'clauses':'policy_clauses',
  17. 'rankings':'ranking_signals', 'coverage':'coverage_observations'}
  18. DEFAULT_ENTITIES = ['products','reviews','social','documents','clauses']
  19. CONCEPTS = {
  20. 'portion_size': {'name':'份量', 'terms':['份量','分量','面量','量少','不够吃','吃不饱','量太少']},
  21. 'preparation': {'name':'制作便利', 'terms':['煮面','冲泡','不能泡','必须煮','沥水','免煮','煮不熟','煮了']},
  22. 'taste': {'name':'口味体验', 'terms':['口味','味道','太辣','太咸','太淡','好吃','难吃','口感']},
  23. 'packaging': {'name':'包装体验', 'terms':['包装','漏油','破损','破包','封口','餐具']},
  24. 'value': {'name':'价格与价值', 'terms':['价格','性价比','太贵','便宜','划算','涨价']},
  25. 'nutrition_label': {'name':'营养与标签', 'terms':['营养','标签','配料','钠含量','脂肪','能量','蛋白质']},
  26. }
  27. FIELDS = {
  28. 'platform':('platform','enum'), 'brand':('brand','enum'), 'category':('category','enum'),
  29. 'shopId':('shop_id','id'), 'productId':('product_id','id'), 'itemId':('item_id','id'), 'recordId':('record_id','id'),
  30. 'sourceId':('source_record_id','id'), 'sourceDate':('source_day','date'), 'reviewDate':('review_day','date'),
  31. 'publishedDate':('published_day','date'), 'priceMinor':('price_minor','number'), 'priceBasis':('price_basis','text'),
  32. 'netWeightG':('net_weight_g','number'), 'packCount':('pack_count','number'), 'specVerification':('spec_verification','text'),
  33. 'policyStatus':('policy_status','text'), 'documentId':('document_id','id'), 'contentScope':('content_scope','text'),
  34. 'fulltextAvailable':('fulltext_available','bool'), 'isDefault':('is_default','bool'), 'identityState':('identity_state','text'),
  35. 'ratingValue':('rating_value','number'), 'ratingScale':('rating_scale','text'),
  36. 'categoryState':('category_state','text'), 'classificationState':('classification_state','text'),
  37. 'rankNumber':('rank_number','number'), 'rankScope':('rank_scope','text'),
  38. }
  39. ENTITY_FIELDS = {
  40. 'products':{'platform','brand','category','shopId','productId','itemId','sourceDate','classificationState'},
  41. 'reviews':{'platform','brand','category','shopId','productId','itemId','sourceDate','reviewDate','isDefault','identityState','categoryState','classificationState','ratingValue','ratingScale'},
  42. 'prices':{'platform','brand','category','shopId','productId','itemId','sourceDate','priceMinor','priceBasis','classificationState'},
  43. 'specs':{'platform','brand','category','shopId','productId','itemId','sourceDate','netWeightG','packCount','specVerification','classificationState'},
  44. 'social':{'platform','brand','publishedDate','contentScope','fulltextAvailable'},
  45. 'documents':{'sourceDate','documentId','policyStatus','fulltextAvailable'},
  46. 'clauses':{'sourceDate','documentId','policyStatus'},
  47. 'rankings':{'platform','brand','category','shopId','productId','itemId','sourceDate','rankNumber','rankScope','classificationState'},
  48. 'coverage':{'platform','brand','category','shopId','productId','itemId','classificationState'},
  49. }
  50. COMMON_FIELDS = {'recordId','sourceId'}
  51. PROJECTION_COLUMNS = ['entity','record_id','title','body','normalized_text','tokens','content_fingerprint','platform','brand','category','shop_id','product_id','item_id','source_record_id',
  52. 'source_day','review_day','published_day','price_minor','price_basis','net_weight_g','pack_count','spec_verification','policy_status','document_id',
  53. 'content_scope','fulltext_available','is_default','identity_state','category_state','classification_state','rank_number','rank_scope','rating_value','rating_scale']
  54. def norm(text):
  55. return unicodedata.normalize('NFKC', str(text or '')).casefold()
  56. def tokens(text):
  57. out=set()
  58. for word in re.findall(r'[\u3400-\u9fff]+|[^\W_]+', norm(text), re.UNICODE):
  59. if re.fullmatch(r'[\u3400-\u9fff]+',word):
  60. out.update(word)
  61. out.update(word[i:i+2] for i in range(len(word)-1))
  62. else:
  63. out.add(word)
  64. return sorted(out)
  65. def canonical(value):
  66. return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'), allow_nan=False)
  67. def require(value, code):
  68. if not value:
  69. from voc_db import LibraryError
  70. raise LibraryError(code)
  71. def create_index(c):
  72. c.executescript('''
  73. CREATE TABLE retrieval_documents(
  74. doc_id INTEGER PRIMARY KEY, entity TEXT NOT NULL, record_id TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL,
  75. normalized_text TEXT NOT NULL, tokens TEXT NOT NULL, content_fingerprint TEXT NOT NULL, platform TEXT, brand TEXT, category TEXT, shop_id TEXT, product_id TEXT, item_id TEXT,
  76. source_record_id TEXT NOT NULL REFERENCES source_records(id), source_day TEXT, review_day TEXT, published_day TEXT,
  77. price_minor INTEGER, price_basis TEXT, net_weight_g REAL, pack_count INTEGER, spec_verification TEXT, policy_status TEXT, document_id TEXT,
  78. content_scope TEXT, fulltext_available INTEGER, is_default INTEGER, identity_state TEXT, category_state TEXT, classification_state TEXT,
  79. rank_number INTEGER, rank_scope TEXT, rating_value REAL, rating_scale TEXT, UNIQUE(entity,record_id));
  80. CREATE INDEX idx_retrieval_scope ON retrieval_documents(entity,platform,brand,category,doc_id);
  81. CREATE INDEX idx_retrieval_brand ON retrieval_documents(brand,entity,doc_id);
  82. CREATE INDEX idx_retrieval_category ON retrieval_documents(category,entity,doc_id);
  83. CREATE INDEX idx_retrieval_product ON retrieval_documents(product_id,entity,doc_id);
  84. CREATE INDEX idx_retrieval_item ON retrieval_documents(item_id,entity,doc_id);
  85. CREATE INDEX idx_retrieval_shop ON retrieval_documents(shop_id,entity,doc_id);
  86. CREATE INDEX idx_retrieval_document ON retrieval_documents(document_id,entity,doc_id);
  87. CREATE INDEX idx_retrieval_policy ON retrieval_documents(policy_status,entity,doc_id);
  88. CREATE INDEX idx_retrieval_content ON retrieval_documents(entity,content_fingerprint);
  89. CREATE INDEX idx_retrieval_review_date ON retrieval_documents(entity,review_day,doc_id);
  90. CREATE INDEX idx_retrieval_source_date ON retrieval_documents(entity,source_day,doc_id);
  91. CREATE INDEX idx_retrieval_published_date ON retrieval_documents(entity,published_day,doc_id);
  92. CREATE VIRTUAL TABLE retrieval_fts USING fts5(tokens,content='retrieval_documents',content_rowid='doc_id',tokenize='unicode61');
  93. ''')
  94. c.row_factory = __import__('sqlite3').Row
  95. products = {r['id']:dict(r) for r in c.execute('SELECT * FROM products')}
  96. docs = {r['id']:dict(r) for r in c.execute('SELECT * FROM policy_documents')}
  97. from voc_db import calendar_date
  98. def add(entity, row, parent=None):
  99. p=parent or {}
  100. data={col:None for col in PROJECTION_COLUMNS}
  101. data.update(entity=entity,record_id=row['id'],title='',body='',source_record_id=row['source_record_id'])
  102. if entity=='products':p=row
  103. if entity in ('products','reviews','prices','specs','rankings','coverage'):
  104. data.update(platform=p.get('marketplace'),brand=p.get('brand_code'),category=p.get('category_code'),shop_id=p.get('shop_id'),product_id=p.get('id'),item_id=p.get('item_id'),classification_state=p.get('classification_state'),title=p.get('title',''))
  105. if entity=='products':
  106. data['body']=row['title']
  107. elif entity=='reviews':
  108. # Initial review and append are separate; only decoded textual append
  109. # values are indexed, never usernames/ratings/whole JSON as prose.
  110. appendix=json.loads(row.get('append_content_json') or 'null')
  111. parts=[]
  112. if isinstance(appendix,str):parts.append(appendix)
  113. elif isinstance(appendix,dict):parts.extend(str(appendix[k]) for k in ('content','comment','appendComment') if isinstance(appendix.get(k),str))
  114. elif isinstance(appendix,list):
  115. for part in appendix:
  116. if isinstance(part,dict):parts.extend(part[k] for k in ('content','comment') if isinstance(part.get(k),str))
  117. data.update(body=row['content']+'\n'+'\n'.join(parts),review_day=row.get('review_calendar_date'),is_default=row.get('is_default'),identity_state=row['identity_state'],category_state=row['category_state'])
  118. raw_rating=str(row.get('rating_raw') or '')
  119. data.update(rating_value=float(raw_rating) if re.fullmatch(r'-?\d+(?:\.\d+)?',raw_rating) else None,rating_scale=row.get('rating_scale'))
  120. elif entity=='prices':data.update(body=row['raw_amount']+' '+row['price_basis'],price_minor=row['amount_minor'],price_basis=row['price_basis'])
  121. elif entity=='specs':data.update(body=row.get('spec_text') or '',net_weight_g=row['net_weight_g'],pack_count=row['pack_count'],spec_verification=row['verification'])
  122. elif entity=='social':
  123. data.update(platform=row['platform'],title=row.get('title') or '',body=row.get('content') or '',content_scope=row['content_scope'],published_day=calendar_date(row['published_at_raw']),fulltext_available=row['fulltext_available'])
  124. elif entity=='documents':
  125. data.update(title=row['title'],body=row.get('full_text') or '',document_id=row['id'],policy_status=row['declared_status'],fulltext_available=int(bool(row.get('full_text'))))
  126. elif entity=='clauses':
  127. data.update(title=(p.get('title') or '')+' '+row['clause'],body=row['excerpt']+'\n'+str(row.get('application_note') or ''),document_id=row['document_id'],policy_status=p.get('declared_status'))
  128. elif entity=='rankings':data.update(body=str(row.get('title') or ''),rank_number=row.get('rank_number'),rank_scope=row['scope'])
  129. elif entity=='coverage':data.update(body=str(row.get('status') or ''))
  130. data['source_day']=calendar_date(row.get('source_time_raw'))
  131. if entity=='reviews':
  132. # Avoid a product title making a review falsely match a pain point.
  133. searchable=data['body']
  134. else:
  135. searchable=data['title']+'\n'+data['body']
  136. data['normalized_text']=norm(searchable)
  137. data['content_fingerprint']=hashlib.sha256(data['normalized_text'].encode('utf-8')).hexdigest()
  138. data['tokens']=' '.join(tokens(searchable))
  139. c.execute('INSERT INTO retrieval_documents('+','.join(PROJECTION_COLUMNS)+') VALUES('+','.join('?' for _ in PROJECTION_COLUMNS)+')',[data[k] for k in PROJECTION_COLUMNS])
  140. for entity,table in ENTITIES.items():
  141. for row in c.execute('SELECT * FROM '+table+' ORDER BY id').fetchall():
  142. row=dict(row)
  143. parent=products.get(row.get('product_id')) if entity not in ('products','documents','clauses','social') else docs.get(row.get('document_id')) if entity=='clauses' else None
  144. add(entity,row,parent)
  145. c.execute("INSERT INTO retrieval_fts(retrieval_fts) VALUES('rebuild')")
  146. c.execute("INSERT INTO retrieval_fts(retrieval_fts) VALUES('optimize')")
  147. def catalog(c):
  148. tax=json.loads(c.execute("SELECT value FROM meta WHERE key='taxonomy'").fetchone()[0])
  149. return {'ok':True,'retrievalContract':VERSION,'agentRulesVersion':AGENT_RULES_VERSION,'readOnly':True,'databaseWrites':0,
  150. 'scopeContract':{'filterCombination':'AND','explicitDimensionsMustMatch':True,'unrequestedDimensionsMayBeUnknown':True,
  151. 'automaticCategoryExpansion':False,'reviewJoin':'platform_family + item_id → product_id',
  152. 'reviewAnalysis':'matched identity, non-default, no SKU conflict; explicit category additionally requires consistent purchased SKU'},
  153. 'entities':{e:{'count':c.execute('SELECT count(*) FROM '+t).fetchone()[0],'fields':sorted(ENTITY_FIELDS[e]|COMMON_FIELDS)} for e,t in ENTITIES.items()},
  154. 'fields':{k:{'type':typ,'operators':['eq','in','exists']+(['min','max'] if typ in ('number','date') else []),'unit':'CNY分' if k=='priceMinor' else 'g' if k=='netWeightG' else '原文日历日期;时区未统一' if typ=='date' else None} for k,(_,typ) in FIELDS.items()},
  155. 'productSorts':{'reviews_asc':'已收录评论从少到多(含默认及待核)','reviews_desc':'已收录评论从多到少(含默认及待核)',
  156. 'price_asc':'最低历史观察价从低到高,未知置后','price_desc':'最低历史观察价从高到低,未知置后'},
  157. 'taxonomy':tax,'concepts':CONCEPTS,
  158. 'relationships':['products.id → reviews/prices/specs/rankings/coverage.product_id','documents.id → clauses.document_id','all.source_record_id → source_records.id → source_links/assets/collection_requests','social品牌仅为提及关系,不能推为商品归属'],
  159. 'questionRoutes':[
  160. {'question':'查某品牌全部资料/找某个商品','entities':['products','reviews','prices','specs'],'method':'只填问题实际约束;商品/平台ID优先精确关联;品牌未知、品类未知不影响未限定该维度的查询'},
  161. {'question':'份量/便利性/口味/包装等消费反馈','entities':['reviews','social'],'method':'正文概念检索+同义表达+原句回读;平台与商品条件继续AND;社媒与购买评价分层计数'},
  162. {'question':'价格带/指定SKU价格/规格/每100g价格','entities':['prices','specs'],'method':'按商品关联、时间和价格口径筛选;价格快照数≠商品数;未核净重不比较单位价格'},
  163. {'question':'政策依据/适用条款/营养标签要求','entities':['documents','clauses'],'method':'标题+正文+条款检索;读官方原文;应用提示与法规分开;状态按声明截至日'},
  164. {'question':'榜单、新品、增长趋势','entities':['rankings','products','coverage'],'method':'检查是否只有详情页标识及抽样;无完整榜单或连续快照时明确无法证明排名/增长趋势'},
  165. {'question':'是否采过、缺什么、来源能否复用','entities':['coverage','products'],'method':'查覆盖与实际记录,并回溯来源请求;失败与无记录分开'},
  166. {'question':'已做报告/机会卡/项目/配方/工艺','entities':[],'method':'转ksf-workshop-runtime查询业务库;当前资料缺结构化配方/工艺事实时不得由营销词补造'}],
  167. 'unsupported':['不提供自然语言自动理解保证或任意语义向量检索;Agent负责问题拆解,后端返回实际展开词','不执行网络采集、不证明全行业覆盖、不从标题猜配方/净重/功效','看板与Agent是否共享当前代际须核实际registry配置,不能由本地库存在推断已接入生产'],
  168. 'commands':['search','aggregate','facets','get','source'],'defaultEntities':DEFAULT_ENTITIES,
  169. 'rules':['仅约束用户实际指定的维度;不得擅自添加/删除条件','正文检索与字段筛选同时满足;概念同义组内部OR,组间AND','facets只枚举可用数据,不能用其结果自动改变原需求','跨类型返回分开计数;不把社媒评论量、观点数、来源数、商品数混算','来源指纹一致才复用,时效不足标缺口']}
  170. def normalize_scalar(c, field, value):
  171. col,typ=FIELDS[field]
  172. if typ=='bool':require(type(value) is bool,'BOOLEAN_REQUIRED:'+field);return int(value)
  173. if typ=='number':require(type(value) in (int,float) and math.isfinite(value),'NUMBER_REQUIRED:'+field);return value
  174. require(isinstance(value,str) and bool(value.strip()),'TEXT_REQUIRED:'+field)
  175. value=value.strip()
  176. if typ=='date':
  177. from voc_db import calendar_date
  178. require(bool(re.fullmatch(r'\d{4}-\d{2}-\d{2}',value)) and calendar_date(value)==value,'ISO_DATE_REQUIRED:'+field)
  179. elif field in ('brand','category'):
  180. from voc_db import resolve_code
  181. tax=json.loads(c.execute("SELECT value FROM meta WHERE key='taxonomy'").fetchone()[0])
  182. value=resolve_code(value,tax['brands' if field=='brand' else 'categories'])
  183. require(value!='all','OMIT_UNCONSTRAINED_FIELD_INSTEAD_OF_ALL:'+field)
  184. elif field=='platform':
  185. aliases={'京东':'jd','天猫':'tmall','淘宝或待核':'taobao_unverified','小红书':'xiaohongshu','抖音':'douyin','哔哩哔哩':'bilibili'}
  186. value=aliases.get(value,value)
  187. require(value in ('jd','tmall','taobao_unverified','xiaohongshu','douyin','bilibili'),'UNKNOWN_PLATFORM')
  188. return value
  189. def text_groups(request):
  190. text=request.get('text',{})
  191. require(isinstance(text,dict) and not set(text)-{'all','any','exclude','concepts'},'INVALID_TEXT_QUERY')
  192. groups=[];expanded={};excluded=[]
  193. for section in ('all','any','exclude','concepts'):
  194. require(isinstance(text.get(section,[]),list) and len(text.get(section,[]))<=12,'INVALID_TEXT_GROUPS')
  195. for entry in text.get('all',[]):
  196. group=[entry] if isinstance(entry,str) else entry.get('any') if isinstance(entry,dict) and set(entry)=={'any'} else None
  197. require(isinstance(group,list) and 1<=len(group)<=20,'INVALID_TEXT_GROUP')
  198. groups.append(group)
  199. if text.get('any'):groups.append(text['any'])
  200. for concept in text.get('concepts',[]):
  201. require(isinstance(concept,str) and concept in CONCEPTS,'UNKNOWN_CONCEPT')
  202. group=CONCEPTS[concept]['terms'];groups.append(group);expanded[concept]=group
  203. for group in groups+[text.get('exclude',[])]:
  204. for term in group:require(isinstance(term,str) and 0<len(term.strip())<=80 and bool(tokens(term)),'INVALID_LITERAL_TERM')
  205. return [[norm(t.strip()) for t in group] for group in groups],[norm(t.strip()) for t in text.get('exclude',[])],expanded
  206. def prepare(c,request):
  207. require(isinstance(request,dict),'INVALID_SEARCH_REQUEST')
  208. allowed={'entities','filters','text','quality','limit','cursor','groupBy','sort'}
  209. require(not set(request)-allowed,'UNKNOWN_SEARCH_FIELD')
  210. entities=request.get('entities',DEFAULT_ENTITIES)
  211. require(isinstance(entities,list) and 0<len(entities)<=len(ENTITIES) and all(isinstance(e,str) and e in ENTITIES for e in entities) and len(entities)==len(set(entities)),'UNKNOWN_OR_DUPLICATE_ENTITY')
  212. filters=request.get('filters',{})
  213. require(isinstance(filters,dict) and not set(filters)-set(FIELDS),'UNKNOWN_FILTER')
  214. quality=request.get('quality','analysis')
  215. require(quality in ('analysis','audit'),'UNKNOWN_QUALITY_MODE')
  216. if quality=='analysis':require(not set(filters)&{'isDefault','identityState','categoryState'},'USE_AUDIT_MODE_FOR_REVIEW_QUALITY_FILTERS')
  217. if 'ratingValue' in filters:require('ratingScale' in filters and 'platform' in filters,'RATING_PLATFORM_AND_SCALE_REQUIRED')
  218. clauses=['d.entity IN ('+','.join('?' for _ in entities)+')'];values=list(entities);normalized={}
  219. for field,spec in filters.items():
  220. applicable=[e for e in entities if field in ENTITY_FIELDS[e]|COMMON_FIELDS]
  221. require(bool(applicable),'FILTER_NOT_APPLICABLE:'+field)
  222. # A hard filter never gets silently ignored for an entity without it.
  223. col,typ=FIELDS[field];column='d.'+col
  224. op=spec if isinstance(spec,dict) else {'in':spec} if isinstance(spec,list) else {'eq':spec}
  225. require(bool(op) and not set(op)-{'eq','in','exists','min','max'},'UNKNOWN_FILTER_OPERATOR:'+field)
  226. require(not ('eq' in op and 'in' in op),'CONFLICTING_FILTER_OPERATOR:'+field)
  227. if 'min' in op or 'max' in op:require(typ in ('date','number'),'RANGE_NOT_SUPPORTED:'+field)
  228. n={}
  229. for operator,value in op.items():
  230. if operator=='exists':
  231. require(type(value) is bool,'BOOLEAN_REQUIRED:'+field)
  232. if field=='brand' and 'social' in applicable:
  233. exists="EXISTS(SELECT 1 FROM social_brand_mentions m WHERE m.post_id=d.record_id)"
  234. clauses.append('('+column+' IS NOT NULL'+" OR (d.entity='social' AND "+exists+'))' if value else '('+column+" IS NULL AND (d.entity!='social' OR NOT "+exists+'))')
  235. else:clauses.append(column+(' IS NOT NULL' if value else ' IS NULL'))
  236. n[operator]=value
  237. elif operator=='in':
  238. require(isinstance(value,list) and 1<=len(value)<=100,'INVALID_IN_FILTER:'+field)
  239. vals=[normalize_scalar(c,field,v) for v in value]
  240. if field=='brand' and 'social' in applicable:
  241. marks=','.join('?' for _ in vals)
  242. clauses.append('('+column+' IN ('+marks+") OR (d.entity='social' AND EXISTS(SELECT 1 FROM social_brand_mentions m WHERE m.post_id=d.record_id AND m.brand_code IN ("+marks+'))))');values+=vals+vals
  243. else:clauses.append(column+' IN ('+','.join('?' for _ in vals)+')');values+=vals
  244. n[operator]=vals
  245. else:
  246. val=normalize_scalar(c,field,value)
  247. operator_sql={'eq':'=','min':'>=','max':'<='}[operator]
  248. if field=='brand' and operator=='eq' and 'social' in applicable:
  249. clauses.append('('+column+"=? OR (d.entity='social' AND EXISTS(SELECT 1 FROM social_brand_mentions m WHERE m.post_id=d.record_id AND m.brand_code=?)))");values += [val,val]
  250. else:clauses.append(column+operator_sql+'?');values.append(val)
  251. n[operator]=val
  252. require(not ('min' in n and 'max' in n) or n['min']<=n['max'],'INVALID_RANGE:'+field)
  253. # Restrict to applicable entity types, including exists:false.
  254. clauses.append('d.entity IN ('+','.join('?' for _ in applicable)+')');values+=applicable
  255. normalized[field]=n
  256. if quality=='analysis':
  257. clauses.append("(d.entity!='reviews' OR (d.is_default=0 AND d.identity_state='matched' AND d.category_state!='conflict'))")
  258. if 'category' in filters:
  259. clauses.append("(d.entity!='reviews' OR d.category_state='consistent')")
  260. if any(e in entities for e in ('prices',)):
  261. clauses.append("(d.entity!='prices' OR d.price_minor IS NOT NULL)")
  262. groups,excluded,expansion=text_groups(request)
  263. if groups:
  264. fts_groups=[]
  265. for group in groups:
  266. alternatives=[]
  267. for term in group:
  268. alternatives.append('('+' AND '.join('"'+t.replace('"','""')+'"' for t in tokens(term))+')')
  269. fts_groups.append('('+' OR '.join(alternatives)+')')
  270. clauses.append('d.doc_id IN (SELECT rowid FROM retrieval_fts WHERE retrieval_fts MATCH ?)');values.append(' AND '.join(fts_groups))
  271. for group in groups:
  272. clauses.append('('+' OR '.join('instr(d.normalized_text,?)>0' for _ in group)+')');values+=group
  273. for term in excluded:clauses.append('instr(d.normalized_text,?)=0');values.append(term)
  274. effective={'entities':entities,'filters':normalized,'text':{'groupsAll':groups,'exclude':excluded,'conceptExpansion':expansion},'quality':quality,
  275. 'defaultReviewPolicy':'非默认+商品身份匹配;指定品类时购买规格也须明确,未指定品类时不强制猜品类' if quality=='analysis' else '审计模式,包含默认/待核/冲突;不得当有效观点样本'}
  276. return ' AND '.join(clauses),values,effective
  277. def snippet(text,groups):
  278. # Normalize only for locating; return the original string and an explicit
  279. # matching field. No generative summaries masquerade as source quotations.
  280. text=str(text or '');n=norm(text);hits=[t for g in groups for t in g if t in n]
  281. index=min((n.find(t) for t in hits),default=0)
  282. return {'text':text[max(0,index-55):index+160],'matchedTerms':sorted(set(hits)),'literalMatchOnly':True}
  283. def search(c,request,explain=False):
  284. where,values,effective=prepare(c,request)
  285. limit=request.get('limit',20)
  286. require(type(limit) is int and 1<=limit<=100,'INVALID_LIMIT')
  287. sort=request.get('sort')
  288. sorts={
  289. 'reviews_asc':('(SELECT count(*) FROM reviews r WHERE r.product_id=d.record_id)','ASC'),
  290. 'reviews_desc':('(SELECT count(*) FROM reviews r WHERE r.product_id=d.record_id)','DESC'),
  291. 'price_asc':('(SELECT min(p.amount_minor) FROM prices p WHERE p.product_id=d.record_id)','ASC'),
  292. 'price_desc':('(SELECT min(p.amount_minor) FROM prices p WHERE p.product_id=d.record_id)','DESC'),
  293. }
  294. require(sort is None or isinstance(sort,str) and sort in sorts,'INVALID_SORT')
  295. require(sort is None or effective['entities']==['products'],'SORT_NOT_APPLICABLE')
  296. expression,direction=sorts[sort] if sort else ('d.doc_id','ASC')
  297. if sort:effective={**effective,'sort':sort}
  298. generation=c.execute("SELECT value FROM meta WHERE key='generation'").fetchone()[0]
  299. context=hashlib.sha256(canonical([generation,effective]).encode()).hexdigest()
  300. total=c.execute('SELECT count(*) FROM retrieval_documents d WHERE '+where,values).fetchone()[0]
  301. totals={r[0]:r[1] for r in c.execute('SELECT entity,count(*) FROM retrieval_documents d WHERE '+where+' GROUP BY entity',values)}
  302. text_counts={r[0]:r[1] for r in c.execute('SELECT entity,count(DISTINCT content_fingerprint) FROM retrieval_documents d WHERE '+where+' GROUP BY entity',values)}
  303. matched_products=c.execute('SELECT count(DISTINCT product_id) FROM retrieval_documents d WHERE '+where,values).fetchone()[0]
  304. if request.get('cursor'):
  305. try:
  306. cursor=json.loads(base64.urlsafe_b64decode(request['cursor']))
  307. require(set(cursor)==({'context','after','value'} if sort else {'context','after'}) and cursor['context']==context and type(cursor['after']) is int,'CURSOR_SCOPE_MISMATCH')
  308. if sort:require(cursor['value'] is None or type(cursor['value']) in (int,float) and math.isfinite(cursor['value']),'CURSOR_SCOPE_MISMATCH')
  309. except (ValueError,TypeError,KeyError):
  310. require(False,'CURSOR_SCOPE_MISMATCH')
  311. if not sort:
  312. where+=' AND d.doc_id>?';values.append(cursor['after'])
  313. elif cursor['value'] is None:
  314. where+=' AND ('+expression+' IS NULL AND d.doc_id>?)';values.append(cursor['after'])
  315. else:
  316. comparison='>' if direction=='ASC' else '<'
  317. where+=' AND ('+expression+' IS NULL OR '+expression+comparison+'? OR ('+expression+'=? AND d.doc_id>?))'
  318. values += [cursor['value'],cursor['value'],cursor['after']]
  319. order=expression+' IS NULL ASC,'+expression+' '+direction+',d.doc_id ASC' if sort else 'd.doc_id'
  320. sql='SELECT d.*'+(','+expression+' AS _sort_value' if sort else '')+' FROM retrieval_documents d WHERE '+where+' ORDER BY '+order+' LIMIT ?'
  321. rows=c.execute(sql,values+[limit+1]).fetchall();has_more=len(rows)>limit;rows=rows[:limit]
  322. next_cursor=None
  323. if has_more:
  324. last=dict(context=context,after=rows[-1]['doc_id'])
  325. if sort:last['value']=rows[-1]['_sort_value']
  326. next_cursor=base64.urlsafe_b64encode(canonical(last).encode()).decode()
  327. from voc_db import citation
  328. records=[]
  329. for row in rows:
  330. r=dict(row);r.pop('tokens');r.pop('normalized_text');r.pop('_sort_value',None);body=r.pop('body')
  331. r['match']=snippet(body if r['entity']=='reviews' else r['title']+'\n'+body,effective['text']['groupsAll'])
  332. if r['entity']=='clauses':
  333. raw=c.execute('SELECT excerpt,application_note,excerpt_type FROM policy_clauses WHERE id=?',(r['record_id'],)).fetchone()
  334. r['match']['matchedFields']=[name for name in ('excerpt','application_note') if any(term in norm(raw[name]) for group in effective['text']['groupsAll'] for term in group)]
  335. r['match']['excerptType']=raw['excerpt_type']
  336. r['match']['applicationNoteIsRegulation']=False
  337. if r['entity']=='documents':r['policyStatusAsOf']='2026-11-04'
  338. r['citation']=citation(c,r['source_record_id']);r['detailCommand']={'entity':r['entity'],'id':r['record_id']}
  339. if r['entity']=='social':r['brandRelationship']='text_mention_only_not_product_ownership'
  340. records.append(r)
  341. result={'ok':True,'retrievalContract':VERSION,'generation':generation,'readOnly':True,'databaseWrites':0,
  342. 'effectiveQuery':effective,'totalMatched':total,'countsByEntity':totals,'distinctTextFingerprintsByEntity':text_counts,'distinctLinkedProducts':matched_products,'records':records,'nextCursor':next_cursor,
  343. 'coverageNotice':'仅为当前资料及实际筛选命中;同义词为显示的有限展开,不能保证所有语义表达均命中。未知的未限定维度保留;指定维度未知则无法满足该条件。'}
  344. if explain:result['queryPlan']=[r['detail'] for r in c.execute('EXPLAIN QUERY PLAN '+sql,values+[limit+1])]
  345. return result
  346. def aggregate(c,request,facets=False):
  347. where,values,effective=prepare(c,request)
  348. fields=request.get('groupBy',['platform','brand','category'] if facets else ['platform'])
  349. require(isinstance(fields,list) and 1<=len(fields)<=4 and all(isinstance(f,str) and f in FIELDS for f in fields) and len(fields)==len(set(fields)),'INVALID_GROUP_BY')
  350. require(not ('brand' in fields and 'social' in effective['entities']),'SOCIAL_BRAND_IS_MENTION_NOT_GROUPABLE_OWNERSHIP')
  351. limit=request.get('limit',50);require(type(limit) is int and 1<=limit<=100,'INVALID_LIMIT')
  352. out={}
  353. groups=[[field] for field in fields] if facets else [fields]
  354. for group in groups:
  355. columns=[FIELDS[f][0] for f in group]
  356. select=','.join('d.'+col for col in columns)
  357. sql='SELECT '+select+',count(*) AS recordCount,count(DISTINCT d.product_id) AS linkedProductCount,count(DISTINCT d.content_fingerprint) AS distinctTextCount FROM retrieval_documents d WHERE '+where+' GROUP BY '+select+' ORDER BY recordCount DESC,'+select+' LIMIT ?'
  358. rows=[dict(row) for row in c.execute(sql,values+[limit+1])]
  359. out[','.join(group)]={'rows':rows[:limit],'truncated':len(rows)>limit}
  360. return {'ok':True,'retrievalContract':VERSION,'readOnly':True,'databaseWrites':0,'effectiveQuery':effective,'groups':out,
  361. 'countingBasis':'recordCount是记录数;linkedProductCount是关联商品去重数;distinctTextCount按归一化文字去重,不等于独立消费者/来源或真实购买数;社媒品牌仅提及。'}