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

274 lines
12 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 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_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)
if __name__ == '__main__':
unittest.main()