test_retrieval.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import contextlib
  2. import json
  3. from pathlib import Path
  4. import shutil
  5. import sqlite3
  6. import tempfile
  7. import unittest
  8. import test_voc_db as fixture
  9. import retrieval as r
  10. class RetrievalTests(unittest.TestCase):
  11. @classmethod
  12. def setUpClass(cls):
  13. fixture.VocLibraryTests.setUpClass()
  14. cls.db=fixture.VocLibraryTests.db
  15. @classmethod
  16. def tearDownClass(cls):fixture.VocLibraryTests.tearDownClass()
  17. def search(self,**request):
  18. with fixture.v.readonly(self.db) as c:return r.search(c,request,True)
  19. def test_no_compulsory_three_dimensions(self):
  20. result=self.search(entities=['products'],filters={'brand':'康师傅'})
  21. self.assertTrue(any(x['category'] is None for x in result['records']))
  22. self.assertTrue(all(x['brand']=='ksf' for x in result['records']))
  23. self.assertNotIn('platform',result['effectiveQuery']['filters'])
  24. def test_category_only_can_find_unknown_brand(self):
  25. result=self.search(entities=['products'],filters={'category':'拌面'})
  26. self.assertIn('jd:unknown',{x['record_id'] for x in result['records']})
  27. self.assertTrue(all(x['category']=='banmian' for x in result['records']))
  28. def test_product_id_is_sufficient_and_cross_platform_safe(self):
  29. result=self.search(entities=['reviews'],filters={'productId':'jd:same'})
  30. self.assertEqual({x['product_id'] for x in result['records']},{'jd:same'})
  31. self.assertEqual(result['totalMatched'],2) # includes unclear SKU absent a category constraint
  32. self.assertEqual(self.search(entities=['reviews'],filters={'productId':'taobao:same'})['totalMatched'],0)
  33. def test_actual_constraints_are_preserved(self):
  34. result=self.search(entities=['products'],filters={'platform':'京东','brand':'白象','category':'拌面'})
  35. self.assertEqual([x['record_id'] for x in result['records']],['jd:other-brand'])
  36. def test_explicit_scope_and_provenance_across_product_relations(self):
  37. for entity in ('products','reviews','prices','specs'):
  38. with self.subTest(entity=entity):
  39. result=self.search(entities=[entity],filters={'platform':'京东','brand':'康师傅','category':'拌面'},limit=100)
  40. self.assertGreater(result['totalMatched'],0)
  41. for row in result['records']:
  42. self.assertEqual((row['platform'],row['brand'],row['category']),('jd','ksf','banmian'))
  43. self.assertEqual(row['product_id'],'jd:same')
  44. self.assertTrue(all(row['citation'][key] for key in ('id','asset_path','line_number','payload_sha256','assetSha256','archiveSha256')))
  45. with fixture.v.readonly(self.db) as c:
  46. source=fixture.v.get_source(c,row['source_record_id'])
  47. self.assertEqual(source['citation']['id'],row['source_record_id'])
  48. if entity=='reviews':
  49. self.assertTrue(all(row['category_state']=='consistent' and row['identity_state']=='matched' and not row['is_default'] for row in result['records']))
  50. def test_review_sort_keeps_all_products_and_paginates_equal_counts(self):
  51. for sort in ('reviews_desc','reviews_asc'):
  52. query={'entities':['products'],'sort':sort,'limit':2}
  53. rows=[];cursor=None
  54. while True:
  55. page=self.search(**query,**({'cursor':cursor} if cursor else {}))
  56. self.assertEqual(page['totalMatched'],9);rows+=page['records'];cursor=page['nextCursor']
  57. if not cursor:break
  58. self.assertEqual(len(rows),9);self.assertEqual(len({x['record_id'] for x in rows}),9)
  59. self.assertEqual(rows[0 if sort=='reviews_desc' else -1]['record_id'],'jd:same')
  60. first=self.search(entities=['products'],sort='reviews_desc',limit=2)
  61. with self.assertRaises(ValueError):self.search(entities=['products'],sort='reviews_asc',cursor=first['nextCursor'])
  62. with self.assertRaises(ValueError):self.search(entities=['products'],cursor=first['nextCursor'])
  63. with self.assertRaises(ValueError):self.search(entities=['reviews'],sort='reviews_desc')
  64. with self.assertRaises(ValueError):self.search(entities=['products'],sort='price; DROP TABLE products')
  65. def test_price_sort_uses_lowest_observation_and_keeps_unknown_prices_last(self):
  66. with tempfile.TemporaryDirectory() as tmp:
  67. db=Path(tmp)/'prices.sqlite';shutil.copyfile(self.db,db)
  68. with contextlib.closing(sqlite3.connect(db)) as c:
  69. c.row_factory=sqlite3.Row
  70. price=dict(c.execute("SELECT * FROM prices WHERE product_id='jd:same' LIMIT 1").fetchone())
  71. price.update(id='second-observation',amount_minor=9990,raw_amount='99.90')
  72. c.execute('INSERT INTO prices('+','.join(price)+') VALUES('+','.join('?' for _ in price)+')',list(price.values()))
  73. c.execute("UPDATE prices SET amount_minor=NULL WHERE product_id IN ('jd:other-brand','jd:sauce')")
  74. c.execute("UPDATE prices SET amount_minor=4900 WHERE product_id='jd:soup'")
  75. c.commit()
  76. with fixture.v.readonly(db) as c:
  77. for sort in ('price_asc','price_desc'):
  78. ids=[];cursor=None
  79. while True:
  80. page=r.search(c,{'entities':['products'],'sort':sort,'limit':1,**({'cursor':cursor} if cursor else {})})
  81. self.assertEqual(page['totalMatched'],9)
  82. ids += [x['record_id'] for x in page['records']];cursor=page['nextCursor']
  83. if not cursor:break
  84. self.assertEqual(len(ids),9);self.assertEqual(len(set(ids)),9)
  85. self.assertEqual(set(ids[-2:]),{'jd:other-brand','jd:sauce'})
  86. self.assertEqual(ids[6 if sort=='price_asc' else 0],'jd:soup')
  87. self.assertEqual(ids.index('jd:same')<ids.index('jd:soup'),sort=='price_asc')
  88. def test_literal_review_body_not_product_title(self):
  89. result=self.search(entities=['reviews'],text={'all':['面量']})
  90. self.assertGreater(result['totalMatched'],0)
  91. self.assertTrue(all('面量' in x['match']['text'] for x in result['records']))
  92. self.assertEqual(self.search(entities=['reviews'],text={'all':['110g']})['totalMatched'],0)
  93. def test_cjk_two_character_fulltext_and_fts_plan(self):
  94. result=self.search(entities=['reviews'],text={'all':['面量']})
  95. self.assertTrue(any('VIRTUAL TABLE INDEX' in step for step in result['queryPlan']))
  96. self.assertTrue(all('面量' in x['match']['matchedTerms'] for x in result['records']))
  97. def test_synonyms_visible_and_not_a_new_fact(self):
  98. result=self.search(entities=['reviews'],text={'concepts':['portion_size']})
  99. self.assertIn('面量',result['effectiveQuery']['text']['conceptExpansion']['portion_size'])
  100. self.assertTrue(result['records'][0]['match']['literalMatchOnly'])
  101. def test_or_within_group_and_between_groups(self):
  102. result=self.search(entities=['products'],text={'all':[{'any':['康师傅','白象']},'干拌面']})
  103. self.assertTrue(all('干拌面' in x['match']['text'] and ('康师傅' in x['match']['text'] or '白象' in x['match']['text']) for x in result['records']))
  104. result=self.search(entities=['products'],text={'all':['干拌面'],'exclude':['白象']})
  105. self.assertTrue(all('白象' not in x['match']['text'] for x in result['records']))
  106. def test_multientity_counts_and_social_mention_relationship(self):
  107. result=self.search(entities=['products','social'],filters={'brand':'康师傅'})
  108. self.assertIn('social',result['countsByEntity'])
  109. social=next(x for x in result['records'] if x['entity']=='social')
  110. self.assertEqual(social['brandRelationship'],'text_mention_only_not_product_ownership')
  111. def test_nonapplicable_filter_never_ignored(self):
  112. result=self.search(entities=['reviews','social'],filters={'productId':'jd:same'})
  113. self.assertNotIn('social',result['countsByEntity'])
  114. with self.assertRaises(ValueError):self.search(entities=['social'],filters={'productId':'jd:same'})
  115. def test_unknown_dimension_audit_and_not_forced_drop(self):
  116. result=self.search(entities=['products'],filters={'brand':{'exists':False}})
  117. self.assertGreater(result['totalMatched'],0)
  118. self.assertTrue(all(x['brand'] is None for x in result['records']))
  119. with self.assertRaises(ValueError):self.search(entities=['reviews'],filters={'isDefault':True})
  120. audit=self.search(entities=['reviews'],filters={'isDefault':True},quality='audit')
  121. self.assertEqual(audit['totalMatched'],1)
  122. def test_price_and_spec_range_units(self):
  123. result=self.search(entities=['prices'],filters={'priceMinor':{'min':1900,'max':2000},'productId':'jd:same'})
  124. self.assertEqual(result['totalMatched'],1)
  125. specs=self.search(entities=['specs'],filters={'netWeightG':{'min':100,'max':120},'specVerification':'title_candidate'})
  126. self.assertGreater(specs['totalMatched'],0)
  127. self.assertTrue(all(x['spec_verification']=='title_candidate' for x in specs['records']))
  128. def test_dates_filters_are_real_fields_not_import_time(self):
  129. result=self.search(entities=['reviews'],filters={'reviewDate':{'min':'2026-11-04','max':'2026-11-04'}})
  130. self.assertEqual(result['totalMatched'],2)
  131. with self.assertRaises(ValueError):self.search(entities=['reviews'],filters={'reviewDate':{'min':'2026-02-31'}})
  132. def test_aggregate_distinguishes_records_products(self):
  133. with fixture.v.readonly(self.db) as c:
  134. result=r.aggregate(c,dict(entities=['reviews'],groupBy=['platform']))
  135. row=result['groups']['platform']['rows'][0]
  136. self.assertEqual(row['recordCount'],2)
  137. self.assertEqual(row['linkedProductCount'],1)
  138. self.assertEqual(row['distinctTextCount'],1)
  139. def test_facets_in_scope(self):
  140. with fixture.v.readonly(self.db) as c:
  141. result=r.aggregate(c,dict(entities=['products'],filters={'platform':'jd'},groupBy=['brand']),True)
  142. self.assertTrue(all(x['recordCount']>0 for x in result['groups']['brand']['rows']))
  143. def test_pagination_scope_generation(self):
  144. first=self.search(entities=['products'],limit=2)
  145. second=self.search(entities=['products'],limit=2,cursor=first['nextCursor'])
  146. self.assertFalse({x['record_id'] for x in first['records']}&{x['record_id'] for x in second['records']})
  147. with self.assertRaises(ValueError):self.search(entities=['products'],filters={'brand':'ksf'},cursor=first['nextCursor'])
  148. def test_no_sql_or_fts_query_language_execution(self):
  149. self.assertEqual(self.search(entities=['reviews'],text={'all':['面量 OR 1=1']})['totalMatched'],0)
  150. self.assertEqual(self.search(entities=['products'],filters={'itemId':"same' OR 1=1 --"})['totalMatched'],0)
  151. with self.assertRaises(ValueError):self.search(entities=['products'],filters={'customSql':'1=1'})
  152. def test_catalog_and_detail_path_for_new_entities(self):
  153. with fixture.v.readonly(self.db) as c:
  154. catalog=r.catalog(c)
  155. self.assertIn('specs',catalog['entities']);self.assertIn('rankings',catalog['entities'])
  156. result=r.search(c,dict(entities=['specs'],filters={'productId':'jd:same'}))
  157. row=fixture.v.get_record(c,'specs',result['records'][0]['record_id'])
  158. self.assertEqual(row['record']['verification'],'title_candidate')
  159. def test_rating_requires_platform_and_original_scale(self):
  160. with self.assertRaises(ValueError):self.search(entities=['reviews'],filters={'ratingValue':{'max':2}})
  161. result=self.search(entities=['reviews'],filters={'ratingValue':{'min':5},'ratingScale':'jd原始星级','platform':'jd'})
  162. self.assertEqual(result['totalMatched'],2)
  163. def test_policy_analyst_note_is_labelled(self):
  164. result=self.search(entities=['clauses'],text={'all':['应用建议']})
  165. self.assertEqual(result['totalMatched'],1)
  166. match=result['records'][0]['match']
  167. self.assertEqual(match['matchedFields'],['application_note'])
  168. self.assertFalse(match['applicationNoteIsRegulation'])
  169. def test_social_brand_existence_is_mentions_not_null_projection(self):
  170. self.assertEqual(self.search(entities=['social'],filters={'brand':{'exists':True}})['totalMatched'],1)
  171. self.assertEqual(self.search(entities=['social'],filters={'brand':{'exists':False}})['totalMatched'],0)
  172. with fixture.v.readonly(self.db) as c:
  173. with self.assertRaises(ValueError):r.aggregate(c,dict(entities=['social'],groupBy=['brand']))
  174. if __name__=='__main__':unittest.main()