"""Visual orchestration must retain compact current-turn knowledge references.""" import tempfile import threading import unittest from pathlib import Path from unittest import mock import ai_chat from wechat_bot import WeChatBot class VisualKnowledgeTraceAuditTest(unittest.TestCase): def setUp(self): self.fp = b'k' * 40 self.bot = WeChatBot.__new__(WeChatBot) self.bot._pending_lock = threading.RLock() self.bot._pending_reply_sessions = {self.fp.hex(): {'confirmed_unread': True}} self.bot._strict_visual_actions = False self.bot._pending_reply_path = '' self.bot.report_operation = mock.Mock() self.bot._forget_uncertain_tracking = mock.Mock() ai_chat.take_last_gateway_trace() self.addCleanup(ai_chat.take_last_gateway_trace) def trace(self): return {'task_id': 'current-model-task', 'chosen': 'answer-model', 'knowledge': {'enabled': True, 'mode': 'keyword', 'elapsed_ms': 4, 'hits': [{'id': 'kb-7', 'revision': 3, 'score': .91}]}} def test_visual_gateway_preserves_history_and_knowledge_without_judge(self): history = [{'role': 'user', 'content': '先前问题'}, {'role': 'assistant', 'content': '先前回答'}] def generate(**kwargs): self.assertEqual(kwargs['history'], history) self.assertEqual(kwargs['image_bytes'], b'synthetic-image') ai_chat._TRACE.last = self.trace() return '按已审核知识回答' with mock.patch.object(ai_chat, 'gateway_provider', return_value=object()), mock.patch.object(ai_chat, 'get_ai_reply', side_effect=generate), mock.patch('backend_client.report_model_call') as report: result = self.bot._orchestrated_reply(self.fp, chat_text='后续问题', history=history, image_bytes=b'synthetic-image', force_vision=True) self.assertEqual(result, '按已审核知识回答') state = self.bot._pending_reply_sessions[self.fp.hex()] self.assertEqual(state['knowledge_trace'], self.trace()['knowledge']) self.assertEqual(state['model_task_id'], 'current-model-task') report.assert_not_called() def test_knowledge_metadata_survives_pending_state_roundtrip(self): with tempfile.TemporaryDirectory() as temporary: self.bot._pending_reply_path = str(Path(temporary) / 'pending.json') self.bot._record_model_call(self.fp, self.trace()) restored = self.bot._load_pending_replies()[self.fp.hex()] self.assertEqual(restored['knowledge_trace'], self.trace()['knowledge']) self.assertEqual(restored['model_task_id'], 'current-model-task') def test_knowledge_body_and_unexpected_nested_data_are_not_stored(self): value = self.trace() value['knowledge']['content'] = 'private full text' * 1000 value['knowledge']['hits'][0]['answer'] = 'large answer' * 1000 value['knowledge']['hits'][0]['source'] = {'chat': 'private conversation'} self.bot._record_model_call(self.fp, value) self.assertEqual(self.bot._pending_reply_sessions[self.fp.hex()]['knowledge_trace'], self.trace()['knowledge']) def test_new_customer_turn_clears_previous_knowledge_reference(self): state = self.bot._pending_reply_sessions[self.fp.hex()] state['knowledge_trace'] = self.trace()['knowledge'] state['model_task_id'] = 'previous-task' self.bot._reset_pending_batch(self.fp) self.assertNotIn('knowledge_trace', state) self.assertNotIn('model_task_id', state) def test_internal_old_trace_is_not_attributed_to_safe_fallback_reply(self): ai_chat._TRACE.last = self.trace() with mock.patch.object(ai_chat, 'gateway_provider', return_value=object()), mock.patch.object(ai_chat, 'get_ai_reply', return_value='请补充文字'), mock.patch.object(self.bot, '_record_model_call') as record: self.bot._orchestrated_reply(self.fp, chat_text='') record.assert_not_called() self.assertEqual(ai_chat.take_last_gateway_trace(), {}) def test_next_model_result_with_no_knowledge_clears_previous_reference(self): self.bot._record_model_call(self.fp, self.trace()) self.bot._record_model_call(self.fp, {'task_id': 'next-task', 'knowledge': {}}) state = self.bot._pending_reply_sessions[self.fp.hex()] self.assertEqual(state['knowledge_trace'], {}) self.assertEqual(state['model_task_id'], 'next-task') if __name__ == '__main__': unittest.main()