board_library.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. """Read-only, bounded browser projection using the same retrieval rules as Agents."""
  2. import sys
  3. sys.dont_write_bytecode = True
  4. import json
  5. from pathlib import Path
  6. import voc_db
  7. import retrieval
  8. from product_insights import overview as product_overview
  9. LABELS = {'products':'商品与竞品','reviews':'电商评价','social':'社媒内容','documents':'政策文件',
  10. 'clauses':'条款与应用','prices':'价格快照','specs':'商品规格','rankings':'榜单线索','coverage':'采集覆盖'}
  11. def active_database(config):
  12. if config.get('registry'):
  13. root = Path(config['registry']).resolve()
  14. pointer = json.loads((root/'current.json').read_text(encoding='utf-8'))
  15. p = (root/pointer['file']).resolve()
  16. voc_db.require(p.is_relative_to(root) and p.is_file(), 'INVALID_LIBRARY_POINTER')
  17. return p, pointer
  18. return Path(config['db']).resolve(), None
  19. def query_request(p):
  20. entity = p.get('entity','products')
  21. voc_db.require(entity in LABELS, 'UNKNOWN_ENTITY')
  22. filters = p.get('filters', {})
  23. voc_db.require(isinstance(filters,dict), 'INVALID_FILTERS')
  24. q = {'entities':[entity], 'filters':filters, 'quality':p.get('quality','analysis'), 'limit':p.get('limit',20)}
  25. if p.get('keyword'): q['text'] = {'all':[p['keyword']]}
  26. if p.get('cursor'): q['cursor'] = p['cursor']
  27. if p.get('sort'): q['sort'] = p['sort']
  28. return q
  29. def summary(c, pointer):
  30. meta = dict(c.execute('SELECT * FROM meta'))
  31. counts = voc_db.table_counts(c)
  32. totals = {}
  33. for entity in LABELS:
  34. where,args,_ = retrieval.prepare(c, {'entities':[entity]})
  35. totals[entity] = c.execute('SELECT count(*) FROM retrieval_documents d WHERE '+where,args).fetchone()[0]
  36. quality = {
  37. 'defaultReviews':c.execute('SELECT count(*) FROM reviews WHERE is_default=1').fetchone()[0],
  38. 'pendingProducts':c.execute("SELECT count(*) FROM products WHERE classification_state='pending'").fetchone()[0],
  39. 'verifiedSpecs':c.execute("SELECT count(*) FROM specs WHERE verification='source_attribute'").fetchone()[0],
  40. 'datedPrices':c.execute('SELECT count(*) FROM prices WHERE source_calendar_date IS NOT NULL').fetchone()[0],
  41. 'socialFulltext':c.execute('SELECT count(*) FROM social_posts WHERE fulltext_available=1').fetchone()[0],
  42. 'documentFulltext':c.execute("SELECT count(*) FROM policy_documents WHERE length(trim(coalesce(full_text,'')))>0").fetchone()[0],
  43. 'brands':c.execute('SELECT count(DISTINCT brand_code) FROM products').fetchone()[0],
  44. 'shops':c.execute("SELECT count(DISTINCT marketplace||':'||shop_id) FROM products WHERE shop_id IS NOT NULL").fetchone()[0],
  45. }
  46. updates=[]
  47. for item in (pointer.get('history',[]) if pointer else []):
  48. entry=dict(item);delta=entry.get('delta',{})
  49. if delta:
  50. entry['summary']='、'.join(LABELS[e]+'新增'+str(delta.get('added',{}).get(t,0))+'/变更'+str(delta.get('changed',{}).get(t,0)) for e,t in retrieval.ENTITIES.items() if delta.get('added',{}).get(t,0) or delta.get('changed',{}).get(t,0)) or '重复资料,无实体新增'
  51. updates.append(entry)
  52. return {'schema':meta['schema'],'generation':meta['generation'], 'counts':counts,'analysisCounts':totals,'quality':quality,
  53. 'taxonomy':json.loads(meta['taxonomy']), 'entities':[{'id':k,'name':v,'count':counts[retrieval.ENTITIES[k]],'analysisCount':totals[k]} for k,v in LABELS.items()],
  54. 'batches':[dict(r) for r in c.execute('SELECT * FROM batches ORDER BY imported_at DESC')],
  55. 'updates':updates,'updatedAt':pointer.get('updatedAt') if pointer else None}
  56. def browse(c,p):
  57. q = query_request(p)
  58. result = retrieval.search(c,q)
  59. # Display prose only, never raw upstream payloads, author IDs, or private filesystem paths.
  60. for row in result['records']:
  61. row.pop('citation',None)
  62. if row['entity']=='reviews':
  63. raw=c.execute('SELECT content,purchased_sku_text,rating_raw,review_date_raw FROM reviews WHERE id=?',(row['record_id'],)).fetchone()
  64. row.update(dict(raw));row['preview']=raw['content'][:230]
  65. body=c.execute("SELECT body FROM retrieval_documents WHERE entity='reviews' AND record_id=?",(row['record_id'],)).fetchone()[0]
  66. row['append_content']=body[len(raw['content']):].strip() if body.startswith(raw['content']) else ''
  67. elif row['entity']=='products':
  68. raw=c.execute('SELECT shop_name FROM products WHERE id=?',(row['record_id'],)).fetchone()
  69. row['shop_name']=raw['shop_name']
  70. row['preview']='店铺:'+raw['shop_name'] if raw['shop_name'] else ''
  71. elif row['entity']=='social' and not row['fulltext_available']:row['preview']=''
  72. else: row['preview']=row['match']['text'][:230]
  73. if row['entity'] in ('specs','prices','rankings','coverage'):
  74. fields={'specs':'spec_text,net_weight_g,pack_count,total_weight_g',
  75. 'prices':'amount_minor,raw_amount,price_basis,price_per_100g,unit_price_verification,source_time_raw',
  76. 'rankings':'title,rank_number,scope,source_time_raw',
  77. 'coverage':'status,available_reviews,reported_total,historical_total_complete'}[row['entity']]
  78. raw=c.execute('SELECT '+fields+' FROM '+retrieval.ENTITIES[row['entity']]+' WHERE id=?',(row['record_id'],)).fetchone()
  79. row.update(dict(raw))
  80. if p.get('entity','products')=='products' and result['records']:
  81. # One grouped read per page, scoped by exact product IDs; titles never join records.
  82. ids=[row['record_id'] for row in result['records']]
  83. counts={pid:{} for pid in ids}
  84. for row in c.execute('SELECT product_id,entity,count(*) AS n FROM retrieval_documents WHERE product_id IN ('+','.join('?' for _ in ids)+') GROUP BY product_id,entity',ids):
  85. counts[row['product_id']][row['entity']]=row['n']
  86. prices={row['product_id']:{'minMinor':row['low'],'maxMinor':row['high'],'recordCount':row['n']}
  87. for row in c.execute('SELECT product_id,min(amount_minor) low,max(amount_minor) high,count(*) n FROM prices WHERE amount_minor IS NOT NULL AND product_id IN ('+','.join('?' for _ in ids)+') GROUP BY product_id',ids)}
  88. for row in result['records']:
  89. row['relatedCounts']=counts[row['record_id']]
  90. row['priceRange']=prices.get(row['record_id'])
  91. fields=[f for f in ('platform','brand','category','shopId','policyStatus','contentScope','specVerification','priceBasis') if f in retrieval.ENTITY_FIELDS[p.get('entity','products')]]
  92. facets={}
  93. for field in fields:
  94. fq={k:v for k,v in q.items() if k not in ('cursor','limit')}
  95. fq['filters']={k:v for k,v in q['filters'].items() if k!=field}
  96. # Social brands are mentions, not product ownership; enumerate distinct mentions explicitly.
  97. if field=='brand' and p.get('entity')=='social':
  98. where,args,_=retrieval.prepare(c,fq)
  99. rows=[dict(r) for r in c.execute('SELECT m.brand_code AS brand,count(DISTINCT d.record_id) AS recordCount FROM retrieval_documents d JOIN social_brand_mentions m ON m.post_id=d.record_id WHERE '+where+' GROUP BY m.brand_code ORDER BY recordCount DESC LIMIT 101',args)]
  100. facets[field]={'rows':rows[:100],'truncated':len(rows)>100}
  101. else:
  102. fq.update(groupBy=[field],limit=100)
  103. facets[field]=retrieval.aggregate(c,fq,facets=True)['groups'][field]
  104. if field=='shopId':
  105. for row in facets[field]['rows']:
  106. names=[r[0] for r in c.execute('SELECT DISTINCT shop_name FROM products WHERE shop_id=? AND shop_name IS NOT NULL',(row.get('shop_id'),))]
  107. row['shop_name']=' / '.join(names)
  108. result['facets']=facets
  109. where,args,_=retrieval.prepare(c,q)
  110. result['coverage']={}
  111. for field in ('sourceDate','reviewDate','publishedDate'):
  112. if field in retrieval.ENTITY_FIELDS[p.get('entity','products')]:
  113. col=retrieval.FIELDS[field][0]
  114. r=c.execute('SELECT min('+col+'),max('+col+'),sum('+col+' IS NULL) FROM retrieval_documents d WHERE '+where,args).fetchone()
  115. result['coverage'][field]={'min':r[0],'max':r[1],'unknown':r[2] or 0}
  116. result['pendingCount']=None
  117. if q['quality']=='analysis':
  118. aq={**q,'quality':'audit'}
  119. aw,av,_=retrieval.prepare(c,aq)
  120. result['pendingCount']=c.execute('SELECT count(*) FROM retrieval_documents d WHERE '+aw,av).fetchone()[0]-result['totalMatched']
  121. return result
  122. def detail(c,p):
  123. entity=p.get('entity');rid=p.get('id')
  124. voc_db.require(entity in LABELS and isinstance(rid,str) and len(rid)<=300,'INVALID_RECORD')
  125. record=voc_db.get_record(c,entity,rid)['record']
  126. if entity=='reviews':
  127. body=c.execute("SELECT body FROM retrieval_documents WHERE entity='reviews' AND record_id=?",(rid,)).fetchone()[0]
  128. record['append_content_json']=body[len(record['content']):].strip() if body.startswith(record['content']) else ''
  129. citation=record.pop('citation')
  130. record.pop('productCitation',None)
  131. for row in record.get('classification',[]):row.pop('evidence_record_id',None)
  132. # Keep source locator and hashes; omit private upstream paths and raw JSON.
  133. record['source']={k:citation[k] for k in ('id','kind','asset_path','line_number','payload_sha256','assetSha256','archiveSha256','archiveSha256s','locatorType') if k in citation}
  134. if 'asset_path' in record['source']: record['source']['asset_path']=record['source']['asset_path'].replace('\\','/').split('/')[-1]
  135. issues=[dict(r) for r in c.execute('SELECT code,detail FROM quality_issues WHERE entity_id=?',(rid,))]
  136. # Issue detail can contain original private paths. Show the rule code only.
  137. record['issues']=[r['code'] for r in issues]
  138. relations=[]
  139. if entity=='products':
  140. record['overview'] = product_overview(c, record)
  141. for related in ('reviews','prices','specs','rankings','coverage'):
  142. q={'entities':[related],'filters':{'productId':rid},'quality':'audit'}
  143. where,args,_=retrieval.prepare(c,q)
  144. n=c.execute('SELECT count(*) FROM retrieval_documents d WHERE '+where,args).fetchone()[0]
  145. relations.append({'entity':related,'productId':rid,'count':n,'name':LABELS[related]})
  146. if entity=='documents':
  147. clauses=[{'id':r['id'],'name':r['clause']} for r in c.execute('SELECT id,clause FROM policy_clauses WHERE document_id=? ORDER BY id',(rid,))]
  148. record['clauses']=clauses
  149. if record.get('product_id'):
  150. parent=c.execute('SELECT id,title,url,marketplace,shop_name,brand_code,category_code FROM products WHERE id=?',(record['product_id'],)).fetchone()
  151. if parent:record['parentProduct']=dict(parent)
  152. if entity=='clauses':
  153. parent=c.execute('SELECT id,title,source_url FROM policy_documents WHERE id=?',(record['document_id'],)).fetchone()
  154. if parent:record['parentDocument']=dict(parent)
  155. return {'record':record,'relations':relations}
  156. def run(config,p):
  157. voc_db.require(isinstance(p,dict) and p.get('op') in ('summary','browse','detail','version'),'READ_COMMAND_REQUIRED')
  158. voc_db.require(not set(p)-{'op','entity','filters','quality','limit','keyword','cursor','id','generation','sort'},'UNKNOWN_REQUEST_FIELD')
  159. db,pointer=active_database(config)
  160. with voc_db.readonly(db) as c:
  161. generation=c.execute("SELECT value FROM meta WHERE key='generation'").fetchone()[0]
  162. voc_db.require(not pointer or pointer['generation']==generation,'POINTER_VERSION_MISMATCH')
  163. voc_db.require(not p.get('generation') or p['generation']==generation,'LIBRARY_VERSION_CHANGED')
  164. if p['op']=='summary':data=summary(c,pointer)
  165. elif p['op']=='version':data={'updatedAt':pointer.get('updatedAt') if pointer else None}
  166. elif p['op']=='browse':data=browse(c,p)
  167. else:data=detail(c,p)
  168. return {'ok':True,'generation':generation,'readOnly':True,'databaseWrites':0,**data}
  169. if __name__=='__main__':
  170. try:
  171. payload=json.loads(sys.stdin.read())
  172. print(json.dumps(run(payload['config'],payload['request']),ensure_ascii=False))
  173. except Exception as e:
  174. code=str(e) if isinstance(e,voc_db.LibraryError) else 'LIBRARY_READ_FAILED'
  175. print(json.dumps({'ok':False,'error':{'code':code}},ensure_ascii=False));sys.exit(1)