274 lines
12 KiB
Python
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()
|