Files
kefu/wechat_rpa/test_visual_review_assistant.py
T
2026-09-21 10:34:06 +08:00

479 lines
26 KiB
Python

"""Synthetic assistant-review integration; no GUI actions, network or real queues."""
import json
from pathlib import Path
import tempfile
import threading
import time
import unittest
from unittest import mock
from wechat_bot import WeChatBot
class VisualAssistantTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.bot = WeChatBot.__new__(WeChatBot)
self.fp = b'v' * 40
self.key = self.fp.hex()
self.bot._pending_lock = threading.RLock()
self.bot._pending_reply_path = str(self.root / 'pending.json')
self.bot._review_assistant_identity = ('account-one', 42)
self.bot._review_assistant_discarded_drafts = {}
self.bot._pending_scan_progress = {self.key: {'old': True}}
self.bot._pending_exchanges = {self.key: ('old question', 'old draft')}
self.bot._queue_log_instance = mock.Mock()
self.bot._stop_check = threading.Event()
self.bot.enqueue_detected = mock.Mock(return_value=(True, 'ok'))
self.bot._db_source = mock.Mock()
self.bot._review_assistant_settings_loader = lambda: {}
self.assistant = mock.Mock()
self.assistant.enabled.return_value = True
self.assistant.is_recipient.return_value = False
self.assistant.ensure_review.return_value = 'review-one'
self.assistant.reconcile.return_value = {
'review_id': 'review-one', 'account': 'account-one', 'conv_id': 'S:1_2',
'server_id': '11', 'rowid': 12, 'send_time': time.time(), 'dedup_key': 'human-event', 'completed_by_human': True}
self.assistant.resumed.return_value = True
self.assistant.has_pending.return_value = False
self.bot._get_review_assistant = mock.Mock(return_value=self.assistant)
self.state = {
'created_at': time.time(), 'updated_at': time.time(), 'display_name': 'Synthetic customer',
'account': 'account-one', 'conv_id': 'S:1_2', 'awaiting_review': True,
'stage': 'manual_review', 'review_origin': 'global', 'reply_text': 'old AI draft',
'staged_reply_text': 'old AI draft', 'staged_user_text': 'old question',
'last_pasted_draft': 'old AI draft', 'review_requested_at': time.time()-2,
}
self.bot._pending_reply_sessions = {self.key: self.state}
human = {'is_self': True, 'sender_id': 'account-one', 'dedup_key': 'human-event', 'server_id': '11',
'rowid': 12, 'send_time': time.time(), 'content': 'human answer'}
self.context = {'account': 'account-one', 'conv_id': 'S:1_2', 'display_name': 'Synthetic customer',
'messages': [human], 'last_message': human, 'text': 'Synthetic transcript'}
self.bot._db_source.get_conversation_context_by_id.side_effect = lambda *a, **kw: self.context
self.bot._db_source.get_conversation_context.side_effect = lambda *a, **kw: self.context
for operation in ('click', 'press', 'hotkey', 'write'):
self.enterContext(mock.patch('wechat_bot.pyautogui.' + operation,
side_effect=AssertionError('No desktop actions in assistant reconciliation')))
def poll(self):
self.bot._review_assistant_poll_at = 0
self.bot.poll_review_assistant()
def test_proven_human_reply_discards_old_task_without_approving_or_sending(self):
self.poll()
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
self.assertNotIn(self.key, self.bot._pending_exchanges)
self.assertNotIn(self.key, self.bot._pending_scan_progress)
self.assertFalse(self.state.get('review_approved'))
self.assertEqual(self.bot._queue_log_instance.append.call_args.args[2], '人工已完成')
self.bot.enqueue_detected.assert_not_called()
data = json.loads(Path(self.bot._pending_reply_path).read_text(encoding='utf-8'))
self.assertEqual(data['pending'], {})
self.assertEqual(data['assistant_discarded_drafts']['account-one\0S:1_2']['draft'], 'old AI draft')
tombstone = next(iter(data['manual_handoffs'].values()))
self.assertTrue(tombstone['completed_by_human'])
self.assertTrue(tombstone['evidence']['completed_by_human'])
self.assertEqual(tombstone['state']['stage'], 'manual_review')
self.assertTrue(tombstone['resumed_at'])
self.assertEqual(self.bot._manual_handoff_key(self.fp), '')
def test_no_evidence_preserves_review_and_persists_review_id(self):
self.assistant.reconcile.return_value = None
self.poll()
self.assertIs(self.bot._pending_reply_sessions[self.key], self.state)
self.assertTrue(self.state['awaiting_review'])
data = json.loads(Path(self.bot._pending_reply_path).read_text(encoding='utf-8'))
self.assertEqual(data[self.key]['assistant_review_id'], 'review-one')
self.assistant.enqueue_notifications.assert_called_once()
def test_wrong_account_or_conversation_or_review_evidence_cannot_clear_task(self):
for field in ('account', 'conv_id', 'review_id'):
with self.subTest(field=field):
evidence = dict(self.assistant.reconcile.return_value)
self.assistant.reconcile.return_value = {**evidence, field: 'different'}
self.poll()
self.assertIn(self.key, self.bot._pending_reply_sessions)
self.assistant.reconcile.return_value = evidence
def test_active_sending_cancel_or_unrecognized_send_state_cannot_complete(self):
for overrides in ({'send_state': 'sending'}, {'stage': 'sending'},
{'send_state': 'unexpected'}, {'stage': 'receipt_check'},
{'cancel_requested': True}, {'stage': 'cancelled'}):
with self.subTest(overrides=overrides):
original = dict(self.state)
self.state.update(overrides)
self.poll()
self.assertIn(self.key, self.bot._pending_reply_sessions)
self.state.clear()
self.state.update(original)
self.assistant.reconcile.assert_not_called()
def test_terminal_states_require_explicit_human_completion_evidence(self):
for value in ('uncertain', 'unknown', 'failed', 'sent_uncommitted'):
with self.subTest(value=value):
self.state.update(send_state=value, stage='receipt_check')
self.assistant.reconcile.return_value.pop('completed_by_human', None)
self.poll()
self.assertIn(self.key, self.bot._pending_reply_sessions)
self.assertEqual(self.assistant.reconcile.call_count, 4)
def test_all_ui_attention_states_register_and_notify(self):
from review_state import task_review_state
cases = [{'manual_required': True}, {'stage': 'error', 'task_error': 'no model answer'},
{'foreign_draft_text': 'human draft'}, {'stage': 'manual_takeover'},
{'stage': 'failed'}, {'send_state': 'failed'},
{'stage': 'receipt_check', 'send_state': 'unknown'},
{'stage': 'manual_review', 'awaiting_review': True}]
for extra in cases:
with self.subTest(extra=extra):
self.state.clear()
self.state.update(account='account-one', conv_id='S:1_2', **extra)
flags = task_review_state(self.state)
self.assertTrue(flags['awaitingReview'] or flags['needsHuman'])
self.bot._register_assistant_review(self.fp)
self.assertEqual(self.state['assistant_review_id'], 'review-one')
self.assertEqual(self.assistant.ensure_review.call_count, len(cases))
self.assertEqual(self.assistant.enqueue_notifications.call_count, len(cases))
def test_failed_review_id_save_rolls_back_and_does_not_enqueue(self):
with mock.patch.object(self.bot, '_persist_pending_replies', side_effect=[True, False]):
self.bot._register_assistant_review(self.fp)
self.assertNotIn('assistant_review_id', self.state)
self.assistant.enqueue_notifications.assert_not_called()
def test_completed_review_does_not_erase_a_concurrent_replacement(self):
replacement = {**self.state, 'model_task_id': 'new-task', 'reply_text': 'new-draft'}
def reconcile(*args):
self.bot._pending_reply_sessions[self.key] = replacement
return {'review_id': 'review-one', 'account': 'account-one', 'conv_id': 'S:1_2', 'completed_by_human': True}
self.assistant.reconcile.side_effect = reconcile
self.poll()
self.assertIs(self.bot._pending_reply_sessions[self.key], replacement)
def test_new_database_event_arriving_during_read_is_retained(self):
def reconcile(*args):
self.state['database_event'] = {'message_id': 'new-unseen-event'}
return {'review_id': 'review-one', 'account': 'account-one', 'conv_id': 'S:1_2', 'completed_by_human': True}
self.assistant.reconcile.side_effect = reconcile
self.poll()
self.assertIn(self.key, self.bot._pending_reply_sessions)
def test_persistence_failure_restores_review_and_old_exchange(self):
with mock.patch.object(self.bot, '_persist_pending_replies', side_effect=[True, True, False]):
self.poll()
self.assertIs(self.bot._pending_reply_sessions[self.key], self.state)
self.assertIn(self.key, self.bot._pending_exchanges)
self.assertEqual(self.bot._review_assistant_discarded_drafts, {})
self.assertEqual(self.bot._manual_handoffs, {})
self.bot._queue_log_instance.append.assert_not_called()
def test_new_customer_message_after_human_reply_starts_fresh_task(self):
newer = {'is_self': False, 'dedup_key': 'next-question', 'content': 'new question', 'send_time': time.time()+1, 'rowid': 13}
self.context['messages'].append(newer)
self.context['last_message'] = newer
self.poll()
self.bot.enqueue_detected.assert_called_once()
self.assertEqual(self.bot.enqueue_detected.call_args.kwargs['database_event']['message_id'], 'next-question')
self.assertEqual(self.bot.enqueue_detected.call_args.kwargs['database_event']['rowid'], 13)
self.assertNotIn('review_approved', self.bot.enqueue_detected.call_args.kwargs)
def test_retired_draft_only_matches_exact_original_and_verified_identity(self):
self.poll()
next_state = {'account': 'account-one', 'conv_id': 'S:1_2'}
self.assertTrue(self.bot._is_discarded_review_draft(next_state, 'old AI draft'))
self.assertFalse(self.bot._is_discarded_review_draft(next_state, 'edited human draft'))
self.assertFalse(self.bot._is_discarded_review_draft(next_state, ' old AI draft'))
self.assertFalse(self.bot._is_discarded_review_draft({**next_state, 'account': 'other'}, 'old AI draft'))
self.assertFalse(self.bot._is_discarded_review_draft({**next_state, 'send_state': 'uncertain'}, 'old AI draft'))
def test_retired_draft_and_review_binding_survive_restart(self):
self.assistant.reconcile.return_value = None
self.poll()
restored = self.bot._load_pending_replies()[self.key]
self.assertEqual(restored['assistant_review_id'], 'review-one')
self.assertEqual(restored['account'], 'account-one')
self.assertEqual(restored['conv_id'], 'S:1_2')
self.bot._review_assistant_discarded_drafts = {'account-one\0S:1_2': {
'account': 'account-one', 'conv_id': 'S:1_2', 'draft': 'old AI draft'}}
self.bot._persist_pending_replies()
self.bot._load_pending_replies()
self.assertTrue(self.bot._review_assistant_discarded_drafts)
def test_resumed_skips_blanket_only_and_new_risk_still_requires_review(self):
self.bot.send_mode = 'review'
self.bot._reply_needs_review = mock.Mock(return_value=(True, 'new high risk'))
decision = self.bot._reply_review_decision(self.state, 'new question', 'new answer')
self.assertEqual(decision, (False, True, 'new high risk'))
self.bot._reply_needs_review.assert_called_once_with(self.state, 'new question', 'new answer')
self.assistant.resumed.return_value = False
self.assertEqual(self.bot._reply_review_decision(self.state, 'q', 'a'), (True, True, 'new high risk'))
def test_assistant_contact_is_filtered_by_exact_conversation(self):
self.assistant.is_recipient.return_value = True
self.assertTrue(self.bot._review_assistant_recipient(self.state))
self.assistant.is_recipient.assert_called_once_with('S:1_2')
self.context['account'] = 'wrong'
self.assertFalse(self.bot._review_assistant_recipient(self.state))
def test_missing_database_and_cross_account_context_cannot_complete(self):
self.context['account'] = 'different'
self.poll()
self.assertIn(self.key, self.bot._pending_reply_sessions)
self.bot._db_source = None
self.poll()
self.assertIn(self.key, self.bot._pending_reply_sessions)
self.assistant.ensure_review.assert_not_called()
def test_disabled_config_never_imports_native_or_starts_coordinator(self):
del self.bot._get_review_assistant
self.bot.hwnd = 123
with mock.patch('wechat_bot.win32process.GetWindowThreadProcessId', side_effect=AssertionError('No native probe')):
self.assertIsNone(self.bot._get_review_assistant(verify=True))
def test_stop_suppresses_read_and_notification(self):
self.bot._stop_check.set()
self.poll()
self.bot._get_review_assistant.assert_not_called()
self.assistant.enqueue_notifications.assert_not_called()
def test_notification_registration_requires_durable_state(self):
with mock.patch.object(self.bot, '_persist_pending_replies', return_value=False):
self.bot._register_assistant_review(self.fp)
self.assistant.ensure_review.assert_not_called()
self.assistant.enqueue_notifications.assert_not_called()
def test_pending_review_is_not_bypassed_by_new_safe_message(self):
self.bot.send_mode = 'auto'
self.assistant.has_pending.return_value = True
self.bot._reply_needs_review = mock.Mock(return_value=(False, ''))
blanket, required, reason = self.bot._reply_review_decision({}, 'new question', 'safe answer')
self.assertFalse(blanket)
self.assertTrue(required)
self.assertIn('待审核', reason)
self.bot._reply_needs_review.assert_called_once()
def test_late_mobile_sync_does_not_hide_new_customer_message(self):
human = dict(self.context['last_message'])
human.update(send_time=3, rowid=5)
newer = {'is_self': False, 'dedup_key': 'next-question', 'content': 'new question',
'send_time': 4, 'rowid': 4, 'account': 'account-one', 'conv_id': 'S:1_2'}
self.context['messages'] = [newer, human]
self.context['last_message'] = human
self.assistant.reconcile.return_value.update(send_time=3, rowid=5)
self.poll()
self.assertEqual(self.bot.enqueue_detected.call_args.kwargs['database_event']['message_id'], 'next-question')
def test_send_attempt_failure_prevents_dispatch_and_local_completion_keeps_approval(self):
self.assistant.record_automated_attempt.side_effect = OSError('synthetic disk failure')
self.assertFalse(self.bot._record_assistant_automated_attempt(self.state, 'new draft'))
self.state['review_approved'] = True
self.bot._record_assistant_automated_send(self.state)
self.assistant.complete_local_review.assert_called_once_with(self.state)
self.assistant.record_automated_send.assert_not_called() # No real server receipt.
def test_discarded_draft_marker_is_consumed_once(self):
self.poll()
current = {'account': 'account-one', 'conv_id': 'S:1_2'}
self.assertTrue(self.bot._is_discarded_review_draft(current, 'old AI draft'))
self.assertTrue(self.bot._retire_discarded_review_marker(current))
self.assertFalse(self.bot._is_discarded_review_draft(current, 'old AI draft'))
self.bot._load_pending_replies()
self.assertEqual(self.bot._review_assistant_discarded_drafts, {})
def test_draft_marker_retirement_failure_retains_protection(self):
self.poll()
current = {'account': 'account-one', 'conv_id': 'S:1_2'}
with mock.patch.object(self.bot, '_persist_pending_replies', return_value=False):
self.assertFalse(self.bot._retire_discarded_review_marker(current))
self.assertTrue(self.bot._is_discarded_review_draft(current, 'old AI draft'))
class SharedVisualAssistantTests(unittest.TestCase):
"""Use the real durable coordinator, keeping all transports mocked."""
setUp = VisualAssistantTests.setUp
poll = VisualAssistantTests.poll
def use_real_coordinator(self):
from review_assistant import ReviewAssistantCoordinator
self.bot._review_assistant_identity = ('123', 42)
self.state['account'] = '123'
self.context['account'] = '123'
incoming = {'account': '123', 'conv_id': 'S:1_2', 'is_self': False, 'sender_id': '2',
'dedup_key': 'incoming-one', 'server_id': '10', 'rowid': 1,
'send_time': time.time()-1, 'content': 'original question'}
self.context['messages'] = [incoming]
self.context['last_message'] = incoming
coordinator = ReviewAssistantCoordinator(root=self.root, account='123', pid=42,
settings_loader=lambda: {'review_assistant_accounts': {'123': {
'enabled': True, 'recipient_conv_id': 'M:9'}}})
coordinator.enqueue_notifications = mock.Mock()
self.addCleanup(coordinator.close)
self.bot._get_review_assistant.return_value = coordinator
self.bot._register_assistant_review(self.fp)
self.assertTrue(self.state.get('assistant_review_id'))
return coordinator
def add_human(self, *, content='human phone reply'):
outgoing = {'account': '123', 'conv_id': 'S:1_2', 'is_self': True, 'sender_id': '123',
'dedup_key': 'human-two', 'server_id': '11', 'rowid': 2,
'send_time': time.time()+.1, 'content': content}
self.context['messages'].append(outgoing)
self.context['last_message'] = outgoing
def test_real_shared_human_completion_then_new_risk(self):
coordinator = self.use_real_coordinator()
self.add_human()
self.poll()
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
self.assertTrue(coordinator.resumed('S:1_2'))
self.bot.send_mode = 'review'
self.bot._reply_needs_review = mock.Mock(return_value=(True, 'new risk'))
self.assertEqual(self.bot._reply_review_decision({'account':'123', 'conv_id':'S:1_2'}, 'q', 'a'),
(False, True, 'new risk'))
def test_real_shared_automated_attempt_is_not_human_reply(self):
coordinator = self.use_real_coordinator()
coordinator.record_automated_attempt(self.state)
self.add_human(content='old AI draft')
self.poll()
self.assertIn(self.key, self.bot._pending_reply_sessions)
self.assertFalse(coordinator.resumed('S:1_2'))
def test_real_shared_local_approval_closes_reminder_without_resuming(self):
coordinator = self.use_real_coordinator()
self.state['review_approved'] = True
self.bot._record_assistant_automated_send(self.state)
self.assertFalse(coordinator.has_pending('S:1_2'))
self.assertFalse(coordinator.resumed('S:1_2'))
def test_real_shared_completion_retries_original_episode_after_disk_failure(self):
coordinator = self.use_real_coordinator()
review_id = self.state['assistant_review_id']
self.add_human()
with mock.patch.object(self.bot, '_persist_pending_replies', side_effect=[True, False]):
self.poll()
self.assertIn(self.key, self.bot._pending_reply_sessions)
self.assertTrue(coordinator.resumed('S:1_2'))
self.poll()
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
self.assertEqual(self.state['assistant_review_id'], review_id)
def test_real_manual_task_without_draft_completes_after_human_reply(self):
self.state.update(awaiting_review=False, manual_required=True, stage='error',
reply_text='', staged_reply_text='', last_pasted_draft='',
task_error='voice transcription unavailable')
coordinator = self.use_real_coordinator()
self.assertTrue(coordinator.has_pending('S:1_2'))
self.add_human()
self.poll()
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
self.assertTrue(coordinator.resumed('S:1_2'))
self.assertEqual(self.bot._queue_log_instance.append.call_args.args[2], '人工已完成')
self.bot.enqueue_detected.assert_not_called()
def test_real_terminal_send_requires_distinct_human_and_keeps_original_evidence(self):
coordinator = self.use_real_coordinator()
self.state.update(awaiting_review=False, stage='receipt_check', send_state='unknown',
send_dispatched_at=time.time()-2, send_id='original-send-id')
self.add_human(content='old AI draft')
self.poll()
self.assertIn(self.key, self.bot._pending_reply_sessions)
self.assertFalse(coordinator.resumed('S:1_2'))
human = dict(self.context['messages'][-1])
human.update(content='different verified human response', rowid=3,
server_id='12', dedup_key='human-three', send_time=time.time()+.2)
self.context['messages'].append(human)
self.context['last_message'] = human
self.poll()
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
data = json.loads(Path(self.bot._pending_reply_path).read_text(encoding='utf-8'))
tombstone = next(iter(data['manual_handoffs'].values()))
self.assertEqual(tombstone['state']['send_state'], 'unknown')
self.assertEqual(tombstone['state']['send_id'], 'original-send-id')
self.assertEqual(tombstone['evidence']['server_id'], '12')
self.assertFalse(tombstone['delivery_confirmed'])
self.bot._load_pending_replies()
self.assertEqual(self.bot._manual_handoff_key(self.fp), '')
self.bot.enqueue_detected.assert_not_called()
def test_real_failed_send_and_local_persistence_failure_keep_task_until_durable(self):
coordinator = self.use_real_coordinator()
self.state.update(awaiting_review=False, send_state='failed', stage='failed',
manual_required=True)
self.add_human()
with mock.patch.object(self.bot, '_persist_pending_replies', side_effect=[True, False]):
self.poll()
self.assertIn(self.key, self.bot._pending_reply_sessions)
self.assertEqual(self.bot._manual_handoffs, {})
self.assertEqual(self.bot._review_assistant_discarded_drafts, {})
self.poll()
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
tombstone = next(iter(self.bot._manual_handoffs.values()))
self.assertEqual(tombstone['state']['send_state'], 'failed')
self.assertTrue(tombstone['evidence']['completed_by_human'])
self.assertTrue(coordinator.resumed('S:1_2'))
def test_real_terminal_automated_reply_never_completes(self):
coordinator = self.use_real_coordinator()
self.state.update(awaiting_review=False, send_state='uncertain', stage='receipt_check')
coordinator.record_automated_attempt({**self.state, 'reply_text': 'automated different response'})
self.add_human(content='automated different response')
self.poll()
self.assertIn(self.key, self.bot._pending_reply_sessions)
self.assertFalse(coordinator.resumed('S:1_2'))
def test_real_assistant_conversation_cannot_complete_customer_task(self):
coordinator = self.use_real_coordinator()
self.add_human()
self.context['messages'][-1]['conv_id'] = 'M:9'
self.poll()
self.assertIn(self.key, self.bot._pending_reply_sessions)
self.assertFalse(coordinator.resumed('S:1_2'))
def test_explicit_cancel_closes_shared_episode_without_native_io(self):
coordinator = self.use_real_coordinator()
self.bot._forget_uncertain_tracking = mock.Mock()
result = self.bot.cancel_pending_replies([self.key])
self.assertEqual(result['deleted'], [self.key])
self.assertFalse(coordinator.has_pending('S:1_2'))
self.assertFalse(coordinator.resumed('S:1_2'))
def test_explicit_handoff_closes_shared_episode_without_approval(self):
coordinator = self.use_real_coordinator()
result = self.bot.handoff_pending_replies([self.key])
self.assertEqual(result['handed_off'], [self.key])
self.assertFalse(coordinator.has_pending('S:1_2'))
self.assertFalse(coordinator.resumed('S:1_2'))
def test_failed_queue_cancel_keeps_shared_episode(self):
coordinator = self.use_real_coordinator()
self.bot._forget_uncertain_tracking = mock.Mock()
with mock.patch.object(self.bot, '_persist_pending_replies_unlocked', return_value=False):
result = self.bot.cancel_pending_replies([self.key])
self.assertFalse(result['deleted'])
self.assertTrue(coordinator.has_pending('S:1_2'))
def test_stopped_client_cancel_closes_reminder_in_queue_data_directory(self):
from gui_runtime import delete_pending_reply_file
coordinator = self.use_real_coordinator()
with mock.patch('queue_log.QueueLog'):
result = delete_pending_reply_file([self.key], self.bot._pending_reply_path)
self.assertEqual(result['deleted'], [self.key])
self.assertFalse(coordinator.has_pending('S:1_2'))
def test_failed_reminder_cancel_is_reported_without_claiming_success(self):
from gui_runtime import cancel_assistant_review_for_state
with mock.patch('review_assistant.ReviewAssistantCoordinator') as factory, mock.patch('builtins.print') as log:
factory.return_value.cancel_review.return_value = False
self.assertFalse(cancel_assistant_review_for_state({'account':'123', 'assistant_review_id':'r'}, self.root))
self.assertTrue(log.called)
if __name__ == '__main__':
unittest.main()