512 lines
28 KiB
Python
512 lines
28 KiB
Python
"""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'], [])
|