Files
kefu/deploy/review-assistant-20260917/test_protocol_review_assistant.final.py
T
2026-09-21 10:34:06 +08:00

472 lines
22 KiB
Python

"""Protocol review integration with isolated queues and an inert coordinator."""
import json
from pathlib import Path
import tempfile
import unittest
from unittest import mock
from protocol_engine import ProtocolBot, session_key
from test_native_protocol_pipeline import ACCOUNT, CONV, Database, Sender, message
from wecom_native_sender import DeliveryUnknown
ASSISTANT = 'M:assistant-fixture'
class Coordinator:
def __init__(self):
self.active = True
self.pending = {}
self.reviews = {}
self.completed = {}
self.resume = set()
self.own = set()
self.notifications = 0
self.closed = False
self.error = None
def enabled(self): return self.active
def is_recipient(self, conv): return conv == ASSISTANT
def has_pending(self, conv): return conv in self.pending
def resumed(self, conv): return conv in self.resume
def enqueue_notifications(self): self.notifications += 1
def close(self): self.closed = True
def cancel_review(self, state):
conv = state.get('conv_id')
if self.pending.get(conv) == state.get('assistant_review_id'):
self.pending.pop(conv, None)
return True
def ensure_review(self, state, context):
existing = state.get('assistant_review_id')
if existing in self.completed: return existing
conv = state['conv_id']
if conv not in self.pending:
key = 'review-' + str(len(self.reviews) + 1)
trigger = state.get('database_event') or next((m for m in context['messages'] if m['dedup_key'] == state['dedup_key']), {})
self.reviews[key] = {'account': state['account'], 'conv': conv, 'rowid': trigger.get('rowid', 0), 'send_time': trigger.get('send_time', 0)}
self.pending[conv] = key
return self.pending[conv]
def reconcile(self, state, context):
if self.error: raise self.error
key = state.get('assistant_review_id') or self.pending.get(state['conv_id'])
if key in self.completed: return self.completed[key]
baseline = self.reviews.get(key)
if not baseline: return None
for m in context.get('messages', []):
if (m.get('account') == baseline['account'] and m.get('conv_id') == baseline['conv']
and m.get('sender_id') == baseline['account'] and m.get('is_self')
and str(m.get('server_id')) not in ('', '0', 'None')
and m.get('rowid', 0) > baseline['rowid'] and m.get('send_time', 0) >= baseline['send_time']
and (m['conv_id'], str(m['server_id'])) not in self.own):
evidence = {'review_id': key, 'server_id': m['server_id'], 'rowid': m['rowid'], 'send_time': m['send_time']}
self.completed[key] = evidence
self.pending.pop(state['conv_id'], None)
self.resume.add(state['conv_id'])
return evidence
return None
def record_automated_send(self, state, receipt):
self.own.add((state['conv_id'], str(receipt['serverId'])))
class ProtocolReviewAssistantTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='protocol-review-assistant-')
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.db, self.coordinator = Database(), Coordinator()
self.sender = Sender(self.db)
self.generate = mock.Mock(return_value='synthetic draft')
self.logs = []
for patcher in (
mock.patch('protocol_engine.application_data_dir', return_value=self.root),
mock.patch('queue_log.application_data_dir', return_value=self.root),
mock.patch('ai_config.AI_REVIEW_RULES', []),
mock.patch('review_policy.current_judge_mode', return_value='shadow'),
mock.patch('send_lock.try_acquire', return_value=True),
mock.patch('send_lock.release'),
mock.patch('protocol_engine.NativeSender', side_effect=AssertionError('real send forbidden')),
):
self.enterContext(patcher)
self.bot = self.make_bot()
self.key = session_key(ACCOUNT, CONV)
def make_bot(self):
bot = ProtocolBot(db=self.db, sender=self.sender, identity={'accountId': ACCOUNT, 'pid': 1},
path=self.root/'pending.json', generate=self.generate, log=self.logs.append,
review_assistant=self.coordinator)
bot.message_batch_window_seconds = bot.send_delay_seconds = 0
bot.send_mode = 'review'
return bot
def review(self):
self.db.add(message(2))
self.bot.poll_once()
return self.bot._pending[self.key]
def human(self, **changes):
self.db.add({**message(3, self=True, text='manual reply'), **changes})
self.bot.poll_once()
def test_registers_one_review_with_persisted_id_and_trigger(self):
state = self.review()
self.bot.poll_once()
self.assertEqual(len(self.coordinator.reviews), 1)
saved = json.loads(self.bot.path.read_text(encoding='utf-8'))['pending'][self.key]
self.assertEqual(saved['assistant_review_id'], state['assistant_review_id'])
self.assertEqual(saved['database_event']['rowid'], 2)
self.assertTrue(state['awaiting_review'])
self.assertFalse(self.sender.calls)
self.assertGreaterEqual(self.coordinator.notifications, 1)
def test_private_assistant_chat_is_never_auto_replied_to(self):
self.db.add(message(2, conv=ASSISTANT))
self.bot.poll_once()
self.generate.assert_not_called()
self.assertFalse(self.bot._pending)
self.assertFalse(self.sender.calls)
def test_human_reply_completes_review_discards_draft_and_keeps_seen(self):
old = self.review()
self.human()
self.assertNotIn(self.key, self.bot._pending)
self.assertEqual(self.bot._seen[self.key], old['dedup_key'])
self.assertTrue(self.coordinator.resumed(CONV))
self.assertFalse(self.sender.calls)
self.assertTrue(any('已审核' in entry for entry in self.logs))
def test_next_message_resumes_automatic_reply_despite_global_review_mode(self):
self.review(); self.human()
self.db.add(message(4, text='new safe question'))
self.bot.poll_once()
self.assertEqual(len(self.sender.calls), 1)
self.assertEqual(self.sender.calls[0][0], message(4)['dedup_key'])
self.assertIn((CONV, '999'), self.coordinator.own)
def test_new_model_risk_still_requires_a_new_review_after_resumption(self):
self.review(); self.human()
def risky(_):
self.bot._pending[self.key].update(judge_risk='high', judge_mode='arbitrate')
return 'new high risk draft'
self.bot.generate = risky
self.db.add(message(4))
self.bot.poll_once()
state = self.bot._pending[self.key]
self.assertTrue(state['awaiting_review'])
self.assertFalse(state.get('approved'))
self.assertEqual(state['review_reason'], '模型裁判评估为高风险')
self.assertEqual(len(self.coordinator.reviews), 2)
self.assertFalse(self.sender.calls)
def test_selective_review_always_precedes_resumed_global_mode(self):
self.coordinator.resume.add(CONV)
with mock.patch('review_policy.selective_review_reason', return_value='new rule hit'):
state = self.review()
self.assertTrue(state['awaiting_review'])
self.assertEqual(state['review_reason'], 'new rule hit')
self.assertFalse(self.sender.calls)
def test_automated_outbound_does_not_complete_pending_review(self):
self.review()
self.coordinator.own.add((CONV, '3'))
self.human()
self.assertIn(self.key, self.bot._pending)
self.assertFalse(self.coordinator.resumed(CONV))
self.assertFalse(self.sender.calls)
def test_old_synced_outbound_and_missing_server_receipt_do_not_complete(self):
for changes in ({'send_time': 1}, {'server_id': ''}, {'sender_id': 'other-account'}):
with self.subTest(changes=changes):
self.review()
self.human(**changes)
self.assertIn(self.key, self.bot._pending)
self.assertFalse(self.coordinator.resumed(CONV))
self.db.messages = [message()]
self.db.events.clear()
def test_new_inbound_while_old_review_is_pending_keeps_review_and_new_draft(self):
previous = self.review()
self.bot.send_mode = 'auto'
self.db.add(message(3, text='additional customer message'))
self.bot.poll_once()
state = self.bot._pending[self.key]
self.assertIsNot(state, previous)
self.assertFalse(state.get('approved'))
self.assertTrue(state['awaiting_review'])
self.assertEqual(state['assistant_review_id'], previous['assistant_review_id'])
self.assertFalse(self.sender.calls)
def test_restart_reconciles_human_reply_then_keeps_subsequent_customer_message(self):
self.review()
self.db.messages.extend([message(3,self=True), message(4)])
self.db.events.clear()
restored = self.make_bot()
restored.poll_once()
self.assertEqual(restored._pending[self.key]['dedup_key'], message(4)['dedup_key'])
restored.poll_once()
self.assertEqual(len(self.sender.calls), 1)
self.assertEqual(self.sender.calls[0][0], message(4)['dedup_key'])
def test_late_phone_sync_does_not_swallow_newer_already_queued_question(self):
self.review()
self.db.add(message(4,text='new question after manual reply time'))
self.bot.poll_once()
self.assertTrue(self.bot._pending[self.key]['awaiting_review'])
self.db.add({**message(5,self=True,text='manual reply'), 'send_time':3})
self.bot.poll_once()
self.assertEqual(len(self.sender.calls),1)
self.assertEqual(self.sender.calls[0][0],message(4)['dedup_key'])
self.assertEqual(self.generate.call_args.args[0]['last_message']['rowid'],4)
def test_sending_unknown_and_failed_evidence_are_never_auto_completed(self):
for status in ('sending', 'uncertain', 'failed'):
with self.subTest(status=status):
state = self.review()
state['send_state'] = status
self.human()
self.assertIs(self.bot._pending[self.key], state)
self.assertNotIn(state['assistant_review_id'], self.coordinator.completed)
state['send_state'] = ''
self.db.messages = [message()]
self.db.events.clear()
def test_coordination_read_error_cannot_clear_a_registered_review(self):
self.review()
self.coordinator.error = OSError('synthetic unavailable store')
self.human()
self.assertIn(self.key, self.bot._pending)
self.assertFalse(self.sender.calls)
self.assertFalse(self.bot.stop_event.is_set())
def test_completion_save_failure_rolls_back_queue_and_can_retry(self):
state = self.review()
self.db.messages.append(message(3, self=True))
with mock.patch.object(self.bot, '_save', side_effect=OSError('synthetic full disk')):
with self.assertRaises(OSError):
self.bot._reconcile_assistant_review(self.key, state)
self.assertIs(self.bot._pending[self.key], state)
self.assertTrue(self.bot._reconcile_assistant_review(self.key, state))
self.assertNotIn(self.key, self.bot._pending)
def test_busy_send_lock_yields_without_error_then_sends_next_round(self):
self.bot.send_mode = 'auto'
self.db.add(message(2))
with mock.patch('send_lock.try_acquire', return_value=False), mock.patch('send_lock.release') as release:
self.bot.poll_once()
release.assert_not_called()
state = self.bot._pending[self.key]
self.assertNotIn(state['stage'], ('error','failed','receipt_check','sending'))
self.assertEqual(state['send_state'], '')
self.assertFalse(self.sender.calls)
with mock.patch('send_lock.release') as release:
self.bot.poll_once()
release.assert_called_once()
self.assertEqual(len(self.sender.calls), 1)
def test_uncertain_send_releases_shared_lock_and_preserves_receipt_check(self):
self.bot.send_mode = 'auto'
self.sender.error = DeliveryUnknown('synthetic unconfirmed delivery')
self.db.add(message(2))
with mock.patch('send_lock.release') as release:
self.bot.poll_once()
release.assert_called_once()
self.assertEqual(self.bot._pending[self.key]['send_state'], 'uncertain')
def test_disabled_coordinator_cannot_override_global_review(self):
self.coordinator.active = False
self.coordinator.resume.add(CONV)
self.assertTrue(self.review()['awaiting_review'])
self.assertFalse(self.coordinator.reviews)
self.assertFalse(self.sender.calls)
def test_close_stops_coordinator(self):
self.bot.close()
self.assertTrue(self.coordinator.closed)
class ProtocolSharedCoordinatorTests(unittest.TestCase):
"""Actual durable coordinator + protocol queue, with no notification worker."""
make_bot = ProtocolReviewAssistantTests.make_bot
def setUp(self):
import time
from review_assistant import ReviewAssistantCoordinator
ProtocolReviewAssistantTests.setUp(self)
self.base_time = time.time()
self.db.messages[0]['send_time'] = self.base_time - 10
settings = {'review_assistant_accounts': {ACCOUNT: {
'enabled': True, 'recipient_conv_id': 'M:300'}}}
self.settings = settings
self.coordinator = ReviewAssistantCoordinator(
root=self.root, account=ACCOUNT, pid=1, db=self.db,
settings_loader=lambda: settings, log=self.logs.append)
self.addCleanup(self.coordinator.close)
self.enterContext(mock.patch.object(self.coordinator, 'enqueue_notifications'))
self.bot = self.make_bot()
def add(self, rowid, *, self_reply=False, seconds=None, text='synthetic question'):
item = message(rowid, self=self_reply, text=text)
item['send_time'] = self.base_time + (rowid if seconds is None else seconds)
self.db.add(item)
return item
def review(self):
self.add(2)
self.bot.poll_once()
state = self.bot._pending[self.key]
self.assertTrue(state['assistant_review_id'])
return state
def test_durable_manual_resolution_and_new_risk_get_distinct_episode(self):
original_id = self.review()['assistant_review_id']
self.add(3, self_reply=True, text='phone manual answer')
self.bot.poll_once()
self.assertFalse(self.coordinator.has_pending(CONV))
self.assertTrue(self.coordinator.resumed(CONV))
self.assertNotIn(self.key, self.bot._pending)
def risky(context):
self.bot._pending[self.key].update(judge_risk='high', judge_mode='arbitrate')
return 'new risky answer'
self.bot.generate = risky
self.add(4)
self.bot.poll_once()
state = self.bot._pending[self.key]
self.assertTrue(state['awaiting_review'])
self.assertFalse(state.get('approved'))
self.assertNotEqual(state['assistant_review_id'], original_id)
self.assertTrue(self.coordinator.has_pending(CONV))
self.assertFalse(self.coordinator.resumed(CONV))
self.assertFalse(self.sender.calls)
def test_durable_completion_survives_protocol_save_failure_without_new_episode(self):
import sqlite3
state = self.review()
original_id = state['assistant_review_id']
self.add(3, self_reply=True, text='phone manual answer')
self.db.events.clear()
with mock.patch.object(self.bot, '_save', side_effect=OSError('synthetic full disk')):
with self.assertRaises(OSError):
self.bot._reconcile_assistant_review(self.key, state)
self.assertIs(self.bot._pending[self.key], state)
self.assertTrue(self.coordinator.resumed(CONV))
restored = self.make_bot()
restored.poll_once()
self.assertNotIn(self.key, restored._pending)
from contextlib import closing
with closing(sqlite3.connect(self.coordinator.path)) as conn:
rows = conn.execute('SELECT id,status FROM episodes').fetchall()
self.assertEqual(rows, [(original_id, 'completed')])
self.assertFalse(self.sender.calls)
def test_actual_store_late_phone_sync_preserves_newer_question(self):
self.review()
newer = self.add(4, text='newer customer question')
self.bot.poll_once()
self.assertTrue(self.bot._pending[self.key]['awaiting_review'])
self.add(5, self_reply=True, seconds=3, text='late synced phone reply')
self.bot.poll_once()
self.assertEqual(len(self.sender.calls), 1)
self.assertEqual(self.sender.calls[0][0], newer['dedup_key'])
self.assertEqual(self.generate.call_args.args[0]['last_message']['rowid'], 4)
self.assertTrue(self.coordinator.resumed(CONV))
def test_local_approval_closes_pending_without_future_global_review_bypass(self):
self.review()
self.bot.approve_pending_replies([self.key])
self.bot.poll_once()
self.assertEqual(len(self.sender.calls), 1)
self.assertFalse(self.coordinator.has_pending(CONV))
self.assertFalse(self.coordinator.resumed(CONV))
self.add(1000)
self.bot.poll_once()
self.assertTrue(self.bot._pending[self.key]['awaiting_review'])
self.assertEqual(len(self.sender.calls), 1)
def test_real_store_bot_receipt_is_not_a_manual_approval(self):
state = self.review()
self.coordinator.record_automated_send(state, {'status': 'confirmed', 'serverId': '3'})
self.add(3, self_reply=True, text='synthetic draft')
self.bot.poll_once()
self.assertTrue(self.coordinator.has_pending(CONV))
self.assertFalse(self.coordinator.resumed(CONV))
self.assertIs(self.bot._pending[self.key], state)
self.assertFalse(self.sender.calls)
def test_explicit_cancel_closes_old_reminder_without_auto_resume(self):
state = self.review()
old_id = state['assistant_review_id']
result = self.bot.cancel_pending_replies([self.key])
self.assertEqual(result['deleted'], [self.key])
self.assertFalse(self.coordinator.has_pending(CONV))
self.assertFalse(self.coordinator.resumed(CONV))
self.assertFalse(self.bot._assistant_cancellations)
self.add(3)
self.bot.poll_once()
self.assertTrue(self.bot._pending[self.key]['awaiting_review'])
self.assertNotEqual(self.bot._pending[self.key]['assistant_review_id'], old_id)
self.assertFalse(self.sender.calls)
def test_explicit_handoff_closes_reminder_and_preserves_evidence(self):
state = self.review()
result = self.bot.handoff_pending_replies([self.key])
self.assertEqual(result['handed_off'], [self.key])
self.assertFalse(self.coordinator.has_pending(CONV))
self.assertFalse(self.coordinator.resumed(CONV))
self.assertEqual(self.bot._manual_handoffs[self.key]['state']['assistant_review_id'], state['assistant_review_id'])
self.assertFalse(self.sender.calls)
def test_cancel_queue_save_failure_does_not_cancel_existing_review(self):
state = self.review()
with mock.patch.object(self.bot, '_save', side_effect=OSError('synthetic full disk')):
with self.assertRaises(OSError):
self.bot.cancel_pending_replies([self.key])
self.assertIs(self.bot._pending[self.key], state)
self.assertTrue(self.coordinator.has_pending(CONV))
self.assertFalse(self.bot._assistant_cancellations)
def test_cancel_store_failure_retries_after_restart_before_new_question(self):
state = self.review()
self.bot.send_mode = 'auto'
with mock.patch.object(self.coordinator, 'cancel_review', return_value=False):
self.bot.cancel_pending_replies([self.key])
self.assertIn(state['assistant_review_id'], self.bot._assistant_cancellations)
self.add(3, text='customer after explicit cancellation')
self.bot.poll_once()
self.assertEqual(self.bot._pending[self.key]['stage'], 'queued')
self.assertFalse(self.sender.calls)
restored = self.make_bot()
restored.send_mode = 'auto'
restored.poll_once()
self.assertFalse(restored._assistant_cancellations)
self.assertFalse(self.coordinator.has_pending(CONV))
self.assertFalse(self.coordinator.resumed(CONV))
self.assertEqual(len(self.sender.calls), 1)
def test_cancel_works_after_configuration_is_disabled(self):
self.review()
self.settings['review_assistant_accounts'][ACCOUNT]['enabled'] = False
self.bot.cancel_pending_replies([self.key])
self.assertFalse(self.bot._assistant_cancellations)
self.settings['review_assistant_accounts'][ACCOUNT]['enabled'] = True
self.assertFalse(self.coordinator.has_pending(CONV))
def test_recovered_confirmed_local_approval_closes_old_reminder(self):
state = self.review()
self.bot.approve_pending_replies([self.key])
state['send_state'] = 'sending'
self.bot._save()
self.sender.lookup = mock.Mock(return_value={'status': 'confirmed', 'result': {
'status': 'confirmed', 'serverId': '789'}})
restored = self.make_bot()
self.assertNotIn(self.key, restored._pending)
self.assertFalse(self.coordinator.has_pending(CONV))
self.assertFalse(self.coordinator.resumed(CONV))
self.assertFalse(self.sender.calls)
if __name__ == '__main__':
unittest.main()