test_portable.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. import copy
  2. import json
  3. from pathlib import Path
  4. import tempfile
  5. import unittest
  6. from unittest.mock import patch
  7. from src.step2_knowledge_ingest.ingest import CONTRACT, DIM, KINDS, canonical, content_parts, index_text, load, route, sha
  8. from src.step2_knowledge_ingest.portable import export, import_archive, verify_archive
  9. class FakeClient:
  10. def __init__(self, data):
  11. self.data = data
  12. self.database = None
  13. def query(self, collection, **_):
  14. return [{'count(*)': len(self.data[self.database][collection])}]
  15. def upsert(self, collection, data, **_):
  16. table = self.data[self.database].setdefault(collection, {})
  17. for row in data:
  18. table[row['id']] = copy.deepcopy(row)
  19. def flush(self, *_args, **_kwargs):
  20. pass
  21. def close(self):
  22. pass
  23. class FakeStore:
  24. data = None
  25. def __init__(self, settings):
  26. self.settings = settings
  27. self.timeout = 1
  28. self.client = FakeClient(self.data)
  29. def use(self, kind, create):
  30. name = self.settings['MILVUS_DB_PREFIX'] + '_' + kind
  31. if create:
  32. self.data.setdefault(name, {})
  33. elif name not in self.data:
  34. raise ValueError('missing_database')
  35. self.client.database = name
  36. return name
  37. def ensure(self, name, content=False):
  38. self.data[self.client.database].setdefault(name, {})
  39. def get(self, name, ids):
  40. table = self.data[self.client.database][name]
  41. return [copy.deepcopy(table[i]) for i in ids if i in table]
  42. def verify(self, records, content_name, index_name):
  43. for record in records:
  44. for part in content_parts(record):
  45. self.assert_row(content_name, part)
  46. if index_text(record) is not None:
  47. self.assert_row(index_name, {'id': record['id'], 'record_hash': sha(canonical(record))})
  48. def assert_row(self, name, expected):
  49. row = self.data[self.client.database][name][expected['id']]
  50. for key, value in expected.items():
  51. if row[key] != value:
  52. raise ValueError('fake_verify_mismatch')
  53. def make_record(kind, ordinal):
  54. record = {'id': 'k_' + format(ordinal, '024x'), 'kind': kind, 'title': '合成标题',
  55. 'data': {'question': '合成问题', 'body': '完整正文' + '字' * 13000},
  56. 'source': {'url': 'https://example.invalid/' + str(ordinal)}}
  57. if kind == 'qa':
  58. record.update(summary_status='not_required_qa', retrieval_text='合成问题')
  59. else:
  60. record.update(summary='合成摘要', summary_status='generated', retrieval_text='合成标题\n合成摘要')
  61. return record
  62. class PortableTests(unittest.TestCase):
  63. def setUp(self):
  64. self.tmp = tempfile.TemporaryDirectory()
  65. self.addCleanup(self.tmp.cleanup)
  66. self.root = Path(self.tmp.name)
  67. self.prepared = self.root / 'prepared'
  68. self.prepared.mkdir()
  69. records = []
  70. for i, kind in enumerate(('policy', 'finance', 'general', 'qa'), 1):
  71. record = make_record(kind, i)
  72. records.append(record)
  73. source = {k: v for k, v in record.items() if k not in ('summary', 'summary_status', 'retrieval_text')}
  74. (self.prepared / f'{kind}.jsonl').write_text(canonical(source) + '\n', encoding='utf-8')
  75. (self.prepared / f'{kind}.with_summary.jsonl').write_text(canonical(record) + '\n', encoding='utf-8')
  76. model_hash = sha(canonical({}))
  77. snapshot = sha(canonical({'contract': CONTRACT, 'model': model_hash, 'records': records}))
  78. self.manifest = {'complete': True, 'snapshot': snapshot, 'contract': CONTRACT,
  79. 'model_hash': model_hash, 'model_files': {}, 'dimension': DIM,
  80. 'counts': {route(r): 1 for r in records}, 'databases': {}}
  81. self.source_data = {}
  82. for record in records:
  83. kind = route(record)
  84. content, index = 'content_' + snapshot[:20], 'index_' + snapshot[:20]
  85. database = 'demo_' + kind
  86. self.manifest['databases'][kind] = {'database': database, 'content_collection': content,
  87. 'index_collection': index}
  88. self.source_data[database] = {content: {p['id']: p for p in content_parts(record)},
  89. index: {record['id']: {'id': record['id'],
  90. 'vector': [1.0] + [0.0] * (DIM - 1),
  91. 'index_text': index_text(record),
  92. 'record_hash': sha(canonical(record))}}}
  93. ingest_dir = self.prepared / 'ingestion'
  94. ingest_dir.mkdir()
  95. (ingest_dir / 'manifest.json').write_text(canonical(self.manifest), encoding='utf-8')
  96. self.settings = {'MILVUS_DB_PREFIX': 'demo', 'EMBEDDING_MODEL_PATH': self.root / 'model'}
  97. self.archive = self.root / 'snapshot.zip'
  98. def test_export_verify_and_restore(self):
  99. FakeStore.data = self.source_data
  100. snapshot, count = export(self.prepared, self.archive, self.settings, FakeStore)
  101. self.assertEqual((snapshot, count), (self.manifest['snapshot'], 4))
  102. self.assertEqual(verify_archive(self.archive)[1], 4)
  103. target = self.root / 'target'
  104. FakeStore.data = {}
  105. with patch('src.step2_knowledge_ingest.portable.model_matches_manifest', return_value=True):
  106. restored, count = import_archive(self.archive, target, self.settings, FakeStore)
  107. self.assertEqual((restored, count), (snapshot, 4))
  108. self.assertEqual(sum(len(db[next(k for k in db if k.startswith('index_'))]) for db in FakeStore.data.values()), 4)
  109. self.assertEqual(json.loads((target / 'ingestion/manifest.json').read_text(encoding='utf-8'))['snapshot'], snapshot)
  110. def test_missing_remote_part_rejects_and_leaves_no_archive(self):
  111. FakeStore.data = self.source_data
  112. first = next(iter(self.source_data.values()))
  113. content = next(k for k in first if k.startswith('content_'))
  114. first[content].pop(next(iter(first[content])))
  115. with self.assertRaises(ValueError):
  116. export(self.prepared, self.archive, self.settings, FakeStore)
  117. self.assertFalse(self.archive.exists())
  118. self.assertFalse((self.root / 'snapshot.zip.partial').exists())
  119. def test_corrupt_archive_rejected_before_target_write(self):
  120. FakeStore.data = self.source_data
  121. export(self.prepared, self.archive, self.settings, FakeStore)
  122. from zipfile import ZipFile
  123. with ZipFile(self.archive, 'a') as archive:
  124. archive.writestr('untrusted.txt', 'bad')
  125. with self.assertRaises(ValueError):
  126. verify_archive(self.archive)
  127. FakeStore.data = {}
  128. with patch('src.step2_knowledge_ingest.portable.model_matches_manifest', return_value=True):
  129. with self.assertRaises(ValueError):
  130. import_archive(self.archive, self.root / 'target', self.settings, FakeStore)
  131. self.assertEqual(FakeStore.data, {})
  132. def version(self, records, name, database=None, model_files=None):
  133. """Produce independently versioned synthetic packages with real hashing."""
  134. prepared = self.root / (name + '-source')
  135. prepared.mkdir()
  136. ordered = []
  137. for kind in KINDS:
  138. subset = [r for r in records if r['kind'] == kind]
  139. ordered.extend(subset)
  140. raw = [{k: v for k, v in r.items() if k not in ('summary', 'summary_status', 'retrieval_text')} for r in subset]
  141. for suffix, rows in (('', raw), ('.with_summary', subset)):
  142. (prepared / f'{kind}{suffix}.jsonl').write_text(''.join(canonical(r) + '\n' for r in rows), encoding='utf-8')
  143. from collections import Counter
  144. model_hash = sha(canonical(model_files or {}))
  145. snapshot = sha(canonical({'contract': CONTRACT, 'model': model_hash, 'records': ordered}))
  146. manifest = {**self.manifest, 'model_hash': model_hash, 'model_files': model_files or {},
  147. 'snapshot': snapshot, 'counts': dict(Counter(route(r) for r in records)), 'databases': {}}
  148. data = {}
  149. for record in records:
  150. kind = route(record)
  151. content, index = 'content_' + snapshot[:20], 'index_' + snapshot[:20]
  152. db = 'demo_' + kind
  153. manifest['databases'][kind] = {'database': db, 'content_collection': content, 'index_collection': index}
  154. tables = data.setdefault(db, {content: {}, index: {}})
  155. tables[content].update({p['id']: p for p in content_parts(record)})
  156. tables[index][record['id']] = {'id':record['id'], 'vector':[1.0] + [0.0] * (DIM-1),
  157. 'index_text':index_text(record), 'record_hash':sha(canonical(record))}
  158. (prepared/'ingestion').mkdir()
  159. (prepared/'ingestion/manifest.json').write_text(canonical(manifest), encoding='utf-8')
  160. package = self.root / (name + '.zip')
  161. FakeStore.data = data
  162. export(prepared, package, self.settings, FakeStore, database=database)
  163. return package
  164. def six_records(self):
  165. rows = load(self.prepared)
  166. guide = make_record('general', 5)
  167. guide['collection'] = '办事指南'
  168. service = make_record('general', 6)
  169. service['collection'] = '企呼我应六大服务板块'
  170. return rows + [guide, service]
  171. def restore_package(self, package, target, data, database=None, store=FakeStore):
  172. FakeStore.data = data
  173. with patch('src.step2_knowledge_ingest.portable.model_matches_manifest', return_value=True):
  174. return import_archive(package, target, self.settings, store, database=database)
  175. def test_full_update_different_version_and_reimport_preserves_backups(self):
  176. rows = self.six_records()
  177. first = self.version(rows, 'first')
  178. second = self.version(rows + [make_record('general', 9)], 'second')
  179. target, data = self.root/'target', {}
  180. self.restore_package(first, target, data)
  181. old = (target/'ingestion/manifest.json').read_bytes()
  182. old_collections = {db:set(tables) for db,tables in data.items()}
  183. new_snapshot, count = self.restore_package(second, target, data)
  184. self.assertEqual(count, 7)
  185. self.assertEqual(len(load(target)), 7)
  186. self.assertTrue(any((p/'ingestion/manifest.json').read_bytes() == old for p in self.root.glob('target.previous-*')))
  187. for db, names in old_collections.items():
  188. self.assertTrue(names <= data[db].keys())
  189. self.assertEqual(self.restore_package(second, target, data), (new_snapshot, 7))
  190. self.assertEqual(len(load(target)), 7)
  191. def test_single_library_replaces_removed_rows_and_preserves_other_five(self):
  192. rows = self.six_records() + [make_record('general', 9)]
  193. first = self.version(rows, 'first')
  194. replacement = make_record('general', 10)
  195. second = self.version([replacement], 'article-only', database='article')
  196. target, data = self.root/'target', {}
  197. self.restore_package(first, target, data)
  198. before = copy.deepcopy(data)
  199. previous = json.loads((target/'ingestion/manifest.json').read_text())
  200. snapshot, imported = self.restore_package(second, target, data, 'article')
  201. current = json.loads((target/'ingestion/manifest.json').read_text())
  202. actual = load(target)
  203. self.assertEqual(imported, 1)
  204. self.assertEqual([r for r in actual if route(r)=='article'], [replacement])
  205. self.assertEqual(len(actual), 6)
  206. for kind in previous['databases']:
  207. if kind != 'article':
  208. self.assertEqual(data['demo_'+kind], before['demo_'+kind])
  209. self.assertEqual(current['databases'][kind]['content_collection'], previous['databases'][kind]['content_collection'])
  210. # A mixed live snapshot can be exported to a standalone full package,
  211. # then imported on a fresh server using the original package contract.
  212. mixed = self.root/'mixed.zip'
  213. FakeStore.data = data
  214. export(target, mixed, self.settings, FakeStore)
  215. self.assertEqual(verify_archive(mixed)[0]['snapshot'], snapshot)
  216. self.restore_package(mixed, self.root/'fresh', {})
  217. self.assertEqual(load(self.root/'fresh'), actual)
  218. self.assertEqual(self.restore_package(second, target, data, 'article'), (snapshot, 1))
  219. # The independent verifier and online reader must follow each library's
  220. # pinned collection, rather than inventing a shared collection name.
  221. from argparse import Namespace
  222. from src.step2_knowledge_ingest import ingest
  223. from src.step3_question_answer.retrieval import Retriever, RetrievalError
  224. FakeStore.data = data
  225. with patch.object(ingest, 'config', return_value=self.settings), \
  226. patch.object(ingest, 'model_fingerprint', return_value={}), \
  227. patch.object(ingest, 'model_matches_manifest', return_value=True), \
  228. patch.object(ingest, 'Store', FakeStore):
  229. ingest.execute(Namespace(prepared=target, env_file=self.root/'missing.env', verify_only=True, dry_run=False))
  230. with patch('src.step3_question_answer.retrieval.config', return_value=self.settings):
  231. reader = Retriever(self.root/'missing.env', target/'ingestion/manifest.json')
  232. self.assertEqual(reader.manifest['databases']['article'], current['databases']['article'])
  233. bad = copy.deepcopy(current)
  234. bad['databases']['article']['snapshot'] = '../wrong'
  235. (target/'ingestion/manifest.json').write_text(canonical(bad), encoding='utf-8')
  236. with self.assertRaises(RetrievalError):
  237. Retriever(self.root/'missing.env', target/'ingestion/manifest.json')
  238. def test_scope_and_model_fail_before_writing(self):
  239. full = self.version(self.six_records(), 'full')
  240. single = self.version([make_record('general', 10)], 'single', database='article')
  241. wrong_model = self.version([make_record('general', 11)], 'model', database='article', model_files={'weights.bin':'f'*64})
  242. target, data = self.root/'target', {}
  243. self.restore_package(full, target, data)
  244. before = copy.deepcopy(data)
  245. original = (target/'ingestion/manifest.json').read_bytes()
  246. for package, library in ((single,None),(single,'policy'),(full,'article'),(single,'unknown'),(wrong_model,'article')):
  247. with self.subTest(library=library,package=package.name), self.assertRaises(ValueError):
  248. self.restore_package(package, target, data, library)
  249. self.assertEqual(data,before)
  250. self.assertEqual((target/'ingestion/manifest.json').read_bytes(),original)
  251. with self.assertRaisesRegex(ValueError,'single_library_requires_existing_snapshot'):
  252. self.restore_package(single,self.root/'empty',{},'article')
  253. def test_every_library_can_be_updated_without_touching_other_databases(self):
  254. rows = self.six_records()
  255. full = self.version(rows, 'six')
  256. for ordinal, original in enumerate(rows, 20):
  257. library = route(original)
  258. replacement = copy.deepcopy(original)
  259. replacement['id'] = 'k_' + format(ordinal,'024x')
  260. package = self.version([replacement], 'only-'+library, database=library)
  261. with self.subTest(library=library):
  262. target, data = self.root/('target-'+library), {}
  263. self.restore_package(full,target,data)
  264. before=copy.deepcopy(data)
  265. self.restore_package(package,target,data,library)
  266. actual=load(target)
  267. self.assertEqual([r for r in actual if route(r)==library],[replacement])
  268. self.assertEqual({r['id']:r for r in actual if route(r)!=library},
  269. {r['id']:r for r in rows if route(r)!=library})
  270. self.assertEqual({k:v for k,v in data.items() if k!='demo_'+library},
  271. {k:v for k,v in before.items() if k!='demo_'+library})
  272. def test_import_refuses_concurrent_publication(self):
  273. from src.step2_knowledge_ingest.ingest import publication_lock
  274. target = self.root/'target'
  275. with publication_lock(target):
  276. with self.assertRaisesRegex(ValueError,'another_knowledge_operation_is_active'):
  277. self.restore_package(self.root/'not-needed.zip',target,{})
  278. self.assertFalse(target.exists())
  279. def test_remote_failure_and_publication_failure_preserve_live_snapshot(self):
  280. first = self.version(self.six_records(), 'first')
  281. second = self.version([make_record('general', 10)], 'single', database='article')
  282. target, data = self.root/'target', {}
  283. self.restore_package(first,target,data)
  284. original = {p.relative_to(target):p.read_bytes() for p in target.rglob('*') if p.is_file()}
  285. class Failing(FakeStore):
  286. def verify(self,*args):
  287. raise ValueError('synthetic_remote_failure')
  288. with self.assertRaisesRegex(ValueError,'synthetic_remote_failure'):
  289. self.restore_package(second,target,data,'article',Failing)
  290. self.assertEqual(original,{p.relative_to(target):p.read_bytes() for p in target.rglob('*') if p.is_file()})
  291. rename = Path.rename
  292. def fail_publish(path,dest):
  293. if path.name=='prepared' and '-import-' in str(path.parent):
  294. raise OSError('synthetic_publish_failure')
  295. return rename(path,dest)
  296. with patch.object(Path,'rename',fail_publish), self.assertRaisesRegex(OSError,'synthetic_publish_failure'):
  297. self.restore_package(second,target,data,'article')
  298. self.assertEqual(original,{p.relative_to(target):p.read_bytes() for p in target.rglob('*') if p.is_file()})
  299. self.restore_package(second,target,data,'article')
  300. def test_cross_library_id_collision_and_corrupt_raw_are_rejected(self):
  301. rows=self.six_records()
  302. first=self.version(rows,'first')
  303. colliding=make_record('general',1) # ID already belongs to policy.
  304. single=self.version([colliding],'collision',database='article')
  305. target,data=self.root/'target',{}
  306. self.restore_package(first,target,data)
  307. before=copy.deepcopy(data)
  308. with self.assertRaisesRegex(ValueError,'invalid_id_or_kind'):
  309. self.restore_package(single,target,data,'article')
  310. self.assertEqual(data,before)
  311. # Rehashing the envelope cannot legitimize raw fields differing from
  312. # the enhanced record that will actually be indexed.
  313. from zipfile import ZipFile
  314. import hashlib
  315. with ZipFile(first) as z:
  316. members={n:z.read(n) for n in z.namelist()}
  317. name='prepared/policy.jsonl'
  318. raw=json.loads(members[name]);raw['title']='tampered'
  319. members[name]=(canonical(raw)+'\n').encode('utf-8')
  320. meta=json.loads(members['archive.json'])
  321. meta['sha256'][name]=hashlib.sha256(members[name]).hexdigest()
  322. members['archive.json']=canonical(meta).encode('utf-8')
  323. bad=self.root/'bad.zip'
  324. with ZipFile(bad,'w') as z:
  325. for n,b in members.items():z.writestr(n,b)
  326. with self.assertRaisesRegex(ValueError,'archive_original_mismatch'):
  327. verify_archive(bad)
  328. if __name__ == '__main__':
  329. unittest.main()