472 lines
22 KiB
Python
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_active_send_evidence_is_never_auto_completed(self):
|
|
for status in ('sending',):
|
|
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()
|