"""Knowledge lifecycle, tenant isolation, resumability and source invalidation.""" import json import tempfile import time from pathlib import Path from unittest import TestCase, mock from fastapi.testclient import TestClient import admin_api import admin_backend from archive_store import ArchiveStore from knowledge_processing import redact from knowledge_retriever import KnowledgeRetriever, inject_references from knowledge_store import KnowledgeStore, day_boundary from knowledge_worker import KnowledgeWorker class KnowledgeTest(TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.db = admin_backend.Database(Path(self.temp.name) / 'test.db') self.db.initialize('InitialAdmin123') self.actor = self.db.authenticate('admin', 'InitialAdmin123')['id'] self.store = KnowledgeStore(self.db) self.store.initialize() self.archive = ArchiveStore(self.db) self.env = mock.patch.dict('os.environ', {'KNOWLEDGE_QDRANT_URL': '', 'KNOWLEDGE_EMBEDDING_URL': '', 'KNOWLEDGE_EMBEDDING_MODEL': ''}) self.env.start() def tearDown(self): self.env.stop() self.temp.cleanup() def payload(self, tenant='default', messages=None, conversation='S:customer'): contents = messages or [('inbound', '请问预约挂号需要准备什么材料?'), ('outbound', '预约挂号请先提供就诊日期和科室,再确认可预约时段。')] return {'tenant_id': tenant, 'source_account': {'external_account_id': 'staff-01', 'display_name': '客服'}, 'messages': [{'source_message_id': str(i), 'conversation': {'external_id': conversation}, 'sender': {'external_id': 'staff-01' if direction == 'outbound' else 'customer-01', 'display_name': '客服' if direction == 'outbound' else '张三', 'identity_type': 'wecom_local_uid', 'scope_id': 'staff-01'}, 'direction': direction, 'message_type': 'text', 'sent_at': 1724472000 + i, 'content': content} for i, (direction, content) in enumerate(contents)]} def start_job(self, tenant='default', **options): with self.db.connect() as db: account = db.execute('SELECT id FROM archive_source_account WHERE tenant_id=?', (tenant,)).fetchone()[0] return self.store.create_job(tenant, {'source_account_id': account, 'engine': 'rules', 'max_messages': 10000, 'max_model_calls': 100, 'staff_confirmed': True, **options}, self.actor, '') def process(self, tenant='default', payload=None): self.archive.import_messages(payload or self.payload(tenant), self.actor, '') job = self.start_job(tenant) worker = KnowledgeWorker(self.store) while worker.step(): pass return self.store.job(tenant, job['id']) def first(self, tenant='default'): return self.store.list_items(tenant)['items'][0] def publish(self, item=None, tenant='default'): item = item or self.first(tenant) item['conditions'] = '适用于普通门诊预约咨询;具体号源以实时查询为准。' item = self.store.save(tenant, item['id'], item, self.actor, '') self.store.transition(tenant, item['id'], item['revision'], 'approve', self.actor, '', True) return self.store.publish(tenant, item['id'], item['revision'], self.actor, '') def test_lifecycle_only_published_knowledge_is_retrievable(self): job = self.process() self.assertEqual((job['status'], job['created_items']), ('completed', 1)) retriever = KnowledgeRetriever(self.store) self.assertFalse(retriever.search('default', '预约挂号材料')['hits']) item = self.publish() hits = retriever.search('default', '预约挂号材料')['hits'] self.assertEqual(hits[0]['id'], item['id']) self.assertEqual(len(self.store.detail('default', item['id'])['sources']), 2) self.store.transition('default', item['id'], item['revision'], 'disable', self.actor, '') self.assertFalse(retriever.search('default', '预约挂号材料')['hits']) def test_edits_remove_publication_and_stale_edit_is_rejected(self): self.process() item = self.publish() saved = self.store.save('default', item['id'], item, self.actor, '') self.assertEqual(saved['status'], 'draft') with self.assertRaises(ValueError): self.store.save('default', item['id'], item, self.actor, '') self.assertFalse(KnowledgeRetriever(self.store).search('default', '预约挂号')['hits']) def test_source_revision_invalidates_knowledge_and_cached_references(self): self.process() item = self.publish() self.store.set_enabled('default', True, self.actor, '') retriever = KnowledgeRetriever(self.store) hits = retriever.search('default', '预约挂号')['hits'] self.assertTrue(retriever.references_current('default', hits)) payload = self.payload() payload['messages'][1]['status'] = 'revoked' self.archive.import_messages(payload, self.actor, '') self.assertEqual(self.first()['status'], 'stale') self.assertFalse(retriever.search('default', '预约挂号')['hits']) self.assertFalse(retriever.references_current('default', hits)) with self.assertRaises(ValueError): self.store.save('default', item['id'], item, self.actor, '') def test_reprocessing_is_idempotent(self): self.process() job = self.start_job() while KnowledgeWorker(self.store).step(): pass self.assertEqual(self.store.job('default', job['id'])['duplicates'], 1) self.assertEqual(self.store.list_items('default')['total'], 1) def test_tenants_cannot_read_or_publish_each_others_knowledge(self): self.process('tenant_a') item = self.publish(tenant='tenant_a') self.assertEqual(self.store.list_items('tenant_b')['total'], 0) self.assertFalse(KnowledgeRetriever(self.store).search('tenant_b', '预约挂号')['hits']) with self.assertRaises(KeyError): self.store.publish('tenant_b', item['id'], item['revision'], self.actor, '') def test_cross_page_answers_are_preserved_and_pause_resumes(self): content = [('unknown', '系统消息')] * 198 + [('inbound', '预约挂号需要什么材料?'), ('outbound', '请先确认就诊日期。'), ('outbound', '还需要确认就诊科室。')] self.archive.import_messages(self.payload(messages=content), self.actor, '') job = self.start_job() worker = KnowledgeWorker(self.store) worker.step() self.assertEqual(self.store.list_items('default')['total'], 0) self.store.job_action('default', job['id'], 'pause', self.actor, '') self.assertFalse(worker.step()) self.store.job_action('default', job['id'], 'resume', self.actor, '') while KnowledgeWorker(self.store).step(): pass self.assertIn('确认就诊日期', self.first()['answer']) self.assertIn('确认就诊科室', self.first()['answer']) def test_sample_limit_does_not_publish_half_answer(self): payload = self.payload(messages=[('inbound', '预约挂号需要什么材料?'), ('outbound', '请先确认就诊日期。'), ('outbound', '还需要确认就诊科室。')]) self.archive.import_messages(payload, self.actor, '') self.start_job(max_messages=2) KnowledgeWorker(self.store).step() self.assertEqual(self.store.list_items('default')['total'], 0) def test_snapshot_uses_old_version_and_marks_changed_source_stale(self): self.archive.import_messages(self.payload(), self.actor, '') self.start_job() payload = self.payload() payload['messages'][1]['content'] = '新的业务口径请咨询前台工作人员。' self.archive.import_messages(payload, self.actor, '') with self.db.connect() as db: db.execute("UPDATE archive_message_version SET created_at='2099-01-01T00:00:00.000+00:00' WHERE version_no=2") while KnowledgeWorker(self.store).step(): pass item = self.first() self.assertIn('就诊日期', item['answer']) self.assertEqual(item['status'], 'stale') def test_group_and_unverified_outbound_are_excluded(self): self.process(payload=self.payload(conversation='R:group')) self.assertEqual(self.store.list_items('default')['total'], 0) payload = self.payload() payload['messages'][1]['sender']['external_id'] = 'unknown-employee' self.process(payload=payload) self.assertEqual(self.store.list_items('default')['total'], 0) def test_names_and_contact_details_are_redacted_before_model(self): self.process(payload=self.payload(messages=[('inbound', '我是张三,手机号13800138000,怎么预约挂号?'), ('outbound', '张三,预约挂号请先确认就诊日期。联系13800138000。')])) detail = self.store.detail('default', self.first()['id']) text = json.dumps(detail, ensure_ascii=False) self.assertNotIn('张三', text) self.assertNotIn('13800138000', text) self.assertIn('[姓名]', text) def test_expired_knowledge_not_returned(self): self.process() item = self.first() item['valid_until'] = '2099-01-01' self.publish(item) with mock.patch.object(KnowledgeRetriever, '_today', return_value='2099-01-02'): self.assertFalse(KnowledgeRetriever(self.store).search('default', '预约挂号')['hits']) def test_abandoned_lease_is_recovered(self): self.archive.import_messages(self.payload(), self.actor, '') job = self.start_job() worker = KnowledgeWorker(self.store) worker.claim() self.assertFalse(KnowledgeWorker(self.store).step()) with self.db.connect() as db: db.execute('UPDATE knowledge_job SET lease_until=0 WHERE id=?', (job['id'],)) while KnowledgeWorker(self.store).step(): pass self.assertEqual(self.store.list_items('default')['total'], 1) def test_vector_stale_revision_or_wrong_tenant_is_not_returned(self): self.process() item = self.publish() with mock.patch('knowledge_retriever.VectorIndex') as index: index.return_value.configured = True index.return_value.profile = 'test' index.return_value.search.return_value = [{'score': 0.99, 'payload': { 'tenant_id': 'default', 'item_id': item['id'], 'revision': 999}}] self.assertFalse(KnowledgeRetriever(self.store).search('default', '完全不同的问题')['hits']) index.return_value.search.return_value[0]['payload']['tenant_id'] = 'tenant_b' self.assertFalse(KnowledgeRetriever(self.store).search('default', '完全不同的问题')['hits']) def test_incremental_watch_enqueues_only_changed_conversation(self): self.archive.import_messages(self.payload(), self.actor, '') self.start_job(auto_watch=True) while KnowledgeWorker(self.store).step(): pass payload = self.payload() payload['messages'][1]['content'] = '预约挂号请携带既往就诊记录并确认就诊日期。' self.archive.import_messages(payload, self.actor, '') with self.db.connect() as db: self.assertEqual(db.execute('SELECT COUNT(*) FROM knowledge_dirty').fetchone()[0], 1) self.assertFalse(KnowledgeWorker(self.store).step(), 'Active conversations should wait for a quiet interval') with self.db.connect() as db: db.execute("UPDATE knowledge_dirty SET changed_at='2000-01-01T00:00:00.000+00:00'") while KnowledgeWorker(self.store).step(): pass items = self.store.list_items('default')['items'] self.assertEqual(len(items), 2) self.assertEqual({item['status'] for item in items}, {'draft', 'stale'}) self.assertEqual(len(self.store.jobs('default')), 2) def test_publish_checks_source_again_after_vector_upload(self): self.process() item = self.first() item['conditions'] = '通用预约流程' item = self.store.save('default', item['id'], item, self.actor, '') self.store.transition('default', item['id'], item['revision'], 'approve', self.actor, '', True) def changed(_item): payload = self.payload() payload['messages'][1]['status'] = 'revoked' self.archive.import_messages(payload, self.actor, '') return 'profile' with mock.patch('knowledge_retriever.VectorIndex') as index: index.return_value.configured = True index.return_value.publish.side_effect = changed with self.assertRaises(ValueError): self.store.publish('default', item['id'], item['revision'], self.actor, '') self.assertFalse(KnowledgeRetriever(self.store).search('default', '预约挂号')['hits']) def test_model_call_budget_is_durable_on_failure(self): self.archive.import_messages(self.payload(), self.actor, '') with mock.patch('knowledge_models.resolve_model', side_effect=lambda db, options: options.update(model_provider_id='test-model')): job = self.start_job(engine='model', max_model_calls=1) with mock.patch('knowledge_worker.enhance', side_effect=ValueError('模型整理失败')) as enhance: KnowledgeWorker(self.store).step() failed = self.store.job('default', job['id']) self.assertEqual((failed['status'], failed['model_calls']), ('failed', 1)) self.store.job_action('default', job['id'], 'resume', self.actor, '') KnowledgeWorker(self.store).step() self.assertEqual(enhance.call_count, 1) def test_reindex_changes_only_current_publications(self): from knowledge_worker import reindex self.process() item = self.publish() with mock.patch('knowledge_retriever.VectorIndex') as index: index.return_value.configured = True index.return_value.publish.return_value = 'profile-new' self.assertEqual(reindex(self.store, 'default'), 1) self.assertEqual(self.first()['vector_profile'], 'profile-new') def disable(_item): self.store.transition('default', item['id'], item['revision'], 'disable', self.actor, '') return 'profile-after-disable' with mock.patch('knowledge_retriever.VectorIndex') as index: index.return_value.configured = True index.return_value.publish.side_effect = disable self.assertEqual(reindex(self.store, 'default'), 0) self.assertEqual(self.first()['vector_profile'], 'profile-new') def test_query_uses_tail_customer_bubbles_not_old_history(self): from knowledge_processing import latest_query chat = '客服 10:00\n旧话题\n客户 10:01\n我要预约\n客户 10:02\n需要什么材料?' self.assertEqual(latest_query(chat), '我要预约\n需要什么材料?') self.assertEqual(latest_query('[语音] 00:05'), '[语音] 00:05') def test_dates_use_business_timezone_and_prompt_does_not_mutate_input(self): self.assertEqual(day_boundary('2026-09-16'), '2026-09-15T16:00:00.000+00:00') messages = [{'role': 'user', 'content': '怎么预约'}] changed = inject_references(messages, []) self.assertEqual(messages[0]['content'], '怎么预约') self.assertIn('不要编造', changed[0]['content']) def test_preview_is_read_only_scoped_and_counts_only_direct_conversations(self): self.archive.import_messages(self.payload(), self.actor, '') self.archive.import_messages(self.payload('tenant_b'), self.actor, '') group = self.payload(conversation='R:group') for row in group['messages']: row['source_message_id'] = 'group-' + row['source_message_id'] row['conversation']['conversation_type'] = 'group' self.archive.import_messages(group, self.actor, '') with self.db.connect() as db: source = db.execute("SELECT id FROM archive_source_account WHERE tenant_id='default'").fetchone()[0] options = {'source_account_id': source, 'max_messages': 1} preview = self.store.preview_job('default', options) self.assertEqual((preview['available_messages'], preview['total_messages'], preview['conversations']), (2, 1, 1)) self.assertEqual(preview['eligible_text_messages'], 2) self.assertTrue(preview['limited']) self.assertEqual(self.store.jobs('default'), []) with self.assertRaises(ValueError): self.store.preview_job('tenant_b', options) with self.assertRaises(ValueError): self.store.preview_job('default', {**options, 'date_from': '2026-09-16', 'date_to': '2026-09-15'}) empty = self.store.preview_job('default', {**options, 'date_from': '2099-01-01'}) self.assertEqual(empty['total_messages'], 0) def test_exact_limit_preserves_complete_final_answer_and_progress(self): self.archive.import_messages(self.payload(), self.actor, '') job = self.start_job(max_messages=2) self.assertEqual((job['total_messages'], job['progress_percent']), (2, 0)) KnowledgeWorker(self.store).step() finished = self.store.job('default', job['id']) self.assertEqual((finished['processed'], finished['progress_percent'], finished['completion_reason']), (2, 100, 'source_exhausted')) self.assertEqual(finished['created_items'], 1) def test_real_progress_and_limit_reason(self): rows = [('unknown', '系统消息')] * 401 self.archive.import_messages(self.payload(messages=rows), self.actor, '') job = self.start_job(max_messages=201) worker = KnowledgeWorker(self.store) worker.step() partial = self.store.job('default', job['id']) self.assertEqual((partial['processed'], partial['total_messages'], partial['progress_percent']), (200, 201, 99)) worker.step() final = self.store.job('default', job['id']) self.assertEqual((final['processed'], final['completion_reason']), (201, 'message_limit')) def test_cancel_fences_an_inflight_batch_and_allows_replacement(self): self.archive.import_messages(self.payload(), self.actor, '') job = self.start_job() with self.assertRaisesRegex(ValueError, '已有未完成任务'): self.start_job() worker = KnowledgeWorker(self.store) original = worker.rows def cancel_after_read(row, size): rows = original(row, size) self.store.job_action('default', job['id'], 'cancel', self.actor, '') return rows with mock.patch.object(worker, 'rows', side_effect=cancel_after_read): worker.step() self.assertEqual(self.store.job('default', job['id'])['status'], 'cancelled') self.assertEqual(self.store.list_items('default')['total'], 0) with self.assertRaises(ValueError): self.store.job_action('default', job['id'], 'resume', self.actor, '') replacement = self.start_job() worker.step() self.assertEqual(self.store.job('default', replacement['id'])['created_items'], 1) def test_enabling_requires_current_unexpired_publication(self): with self.assertRaises(ValueError): self.store.set_enabled('default', True, self.actor, '') self.process() item = self.publish() self.store.set_enabled('default', True, self.actor, '') self.store.transition('default', item['id'], item['revision'], 'disable', self.actor, '') self.store.set_enabled('default', False, self.actor, '') with self.assertRaises(ValueError): self.store.set_enabled('default', True, self.actor, '') def test_review_and_publication_validate_expiry_and_state(self): self.process() item = self.first() with self.assertRaises(ValueError): self.store.transition('default', item['id'], item['revision'], 'disable', self.actor, '') item['conditions'], item['valid_until'] = '通用预约流程', '2000-01-01' item = self.store.save('default', item['id'], item, self.actor, '') with self.assertRaisesRegex(ValueError, '有效期'): self.store.transition('default', item['id'], item['revision'], 'approve', self.actor, '', True) item['valid_until'] = '2099-01-01' item = self.store.save('default', item['id'], item, self.actor, '') self.store.transition('default', item['id'], item['revision'], 'approve', self.actor, '', True) with mock.patch.object(KnowledgeRetriever, '_today', return_value='2099-01-02'): with self.assertRaisesRegex(ValueError, '有效期'): self.store.publish('default', item['id'], item['revision'], self.actor, '') def test_preview_eligible_text_matches_worker_identity_checks(self): payload = self.payload(messages=[('inbound', '预约挂号需要什么材料?'), ('outbound', '请先确认日期。'), ('unknown', '无法确认角色')]) payload['messages'][1]['sender']['external_id'] = 'other-person' self.archive.import_messages(payload, self.actor, '') with self.db.connect() as db: source = db.execute('SELECT id FROM archive_source_account').fetchone()[0] preview = self.store.preview_job('default', {'source_account_id': source}) self.assertEqual((preview['available_messages'], preview['eligible_text_messages']), (3, 1)) class KnowledgeApiTest(TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.db = admin_backend.Database(Path(self.temp.name) / 'test.db') self.db.initialize('InitialAdmin123') actor = self.db.authenticate('admin', 'InitialAdmin123')['id'] self.db.change_password(actor, 'InitialAdmin123', 'ChangedAdmin@2026', '') self.client = TestClient(admin_api.create_app(self.db.path)) response = self.client.post('/api/v2/auth/login', json={'username': 'admin', 'password': 'ChangedAdmin@2026'}) self.headers = {'Authorization': 'Bearer ' + response.json()['access_token']} def tearDown(self): self.client.close() self.temp.cleanup() def test_api_authentication_and_all_scope_write_rejection(self): self.assertEqual(self.client.get('/api/v2/knowledge/overview').status_code, 401) self.assertEqual(self.client.get('/api/v2/knowledge/overview', headers=self.headers).status_code, 200) result = self.client.put('/api/v2/knowledge/settings?account_id=-1', headers=self.headers, json={'enabled': True}) self.assertEqual(result.status_code, 400) result = self.client.post('/api/v2/knowledge/search', headers=self.headers, json={'query': '预约挂号'}) self.assertEqual(result.status_code, 200) self.assertEqual(result.json()['hits'], []) def test_empty_export_is_valid_and_audited(self): response = self.client.get('/api/v2/knowledge/export', headers=self.headers) self.assertEqual(response.status_code, 200) self.assertEqual(response.text, '') with self.db.connect() as db: self.assertIsNotNone(db.execute("SELECT 1 FROM audit_log WHERE action='knowledge.export'").fetchone()) def test_preview_auth_scope_and_enable_empty_library(self): actor = self.db.authenticate('admin', 'ChangedAdmin@2026')['id'] ArchiveStore(self.db).import_messages(KnowledgeTest.payload(self), actor, '') with self.db.connect() as db: source = db.execute('SELECT id FROM archive_source_account').fetchone()[0] body = {'source_account_id': source} url = '/api/v2/knowledge/jobs/preview' self.assertEqual(self.client.post(url, json=body).status_code, 401) self.assertEqual(self.client.post(url + '?account_id=-1', json=body, headers=self.headers).status_code, 400) response = self.client.post(url, json=body, headers=self.headers) self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.json()['total_messages'], 2) enabled = self.client.put('/api/v2/knowledge/settings', json={'enabled': True}, headers=self.headers) self.assertEqual(enabled.status_code, 400) self.db.create_user('reviewer', 'InitialReader123', 'viewer', actor, '') reader = self.db.authenticate('reviewer', 'InitialReader123')['id'] self.db.change_password(reader, 'InitialReader123', 'ChangedReader@2026', '') with self.db.connect() as db: db.execute("INSERT OR IGNORE INTO role_permissions VALUES ('viewer','knowledge:write')") db.execute("DELETE FROM role_permissions WHERE role_code='viewer' AND permission_code='im:content:read'") login = self.client.post('/api/v2/auth/login', json={'username': 'reviewer', 'password': 'ChangedReader@2026'}) headers = {'Authorization': 'Bearer ' + login.json()['access_token']} self.assertEqual(self.client.post(url, json=body, headers=headers).status_code, 403) class KnowledgeGatewayTest(TestCase): from test_model_gateway import GatewayEndpointTest as _fixture setUp = _fixture.setUp _post_then_drain = _fixture._post_then_drain _post = _fixture._post _upstream = _fixture._upstream def tearDown(self): import shutil assert self.root.parent.resolve() == Path(tempfile.gettempdir()).resolve() shutil.rmtree(self.root) def seed(self): self.db = admin_backend.Database(self.db_path) with self.db.connect() as db: self.actor = db.execute("SELECT id FROM users WHERE username='admin'").fetchone()[0] self.store = KnowledgeStore(self.db) self.archive = ArchiveStore(self.db) tenant = self.desktop_account['tenant_id'] self.archive.import_messages(KnowledgeTest.payload(self, tenant), self.actor, '') KnowledgeTest.start_job(self, tenant) while KnowledgeWorker(self.store).step(): pass item = KnowledgeTest.first(self, tenant) item['conditions'] = '通用门诊预约' item = self.store.save(tenant, item['id'], item, self.actor, '') self.store.transition(tenant, item['id'], item['revision'], 'approve', self.actor, '', True) self.store.publish(tenant, item['id'], item['revision'], self.actor, '') self.store.set_enabled(tenant, True, self.actor, '') return item def test_reply_receives_evidence_and_records_version(self): import httpx item = self.seed() sent = [] def handler(request): sent.append(json.loads(request.content.decode())) return httpx.Response(200, json={'choices': [{'message': {'content': '请先确认就诊日期和科室。'}}]}) response, _ = self._post_then_drain(handler, body={'customer_text': '预约挂号需要什么材料?', 'task_id': 'rag-1'}) self.assertEqual(response.status_code, 200) self.assertEqual(response.json()['knowledge']['hits'][0]['id'], item['id']) self.assertTrue(any('已审核知识参考' in json.dumps(body, ensure_ascii=False) for body in sent)) with self.db.connect() as db: log = db.execute("SELECT hits_json FROM knowledge_retrieval_log WHERE task_id='rag-1'").fetchone() self.assertEqual(json.loads(log[0])[0]['revision'], item['revision']) def test_internal_purpose_skips_retrieval(self): self.seed() response, _ = self._post_then_drain(self._upstream(), body={'customer_text': '预约挂号', 'task_id': 'guard-rag', 'purpose': 'guard'}) self.assertFalse(response.json()['knowledge']['enabled']) def test_disabled_reference_is_not_served_from_idempotency_cache(self): item = self.seed() body = {'customer_text': '预约挂号', 'task_id': 'cache-rag'} headers = {'X-Idempotency-Key': 'cache-rag-key'} response = self._post(self._upstream(), headers=headers, body=body) self.assertTrue(response.json()['knowledge']['hits']) self.store.transition(self.desktop_account['tenant_id'], item['id'], item['revision'], 'disable', self.actor, '') response = self._post(self._upstream(), headers=headers, body=body) self.assertEqual(response.json()['knowledge']['hits'], [])