173 lines
9.5 KiB
Python
173 lines
9.5 KiB
Python
"""Manual handoff preserves uncertain delivery evidence without real sends."""
|
|
import json
|
|
import unittest
|
|
from unittest import mock
|
|
import test_visual_review_visibility as fixtures
|
|
|
|
|
|
class VisualHandoffTest(unittest.TestCase):
|
|
setUp = fixtures.VisualReviewVisibilityTest.setUp
|
|
|
|
def uncertain(self):
|
|
self.state.update(stage='uncertain', send_state='uncertain',
|
|
send_dispatched_at=123.0, generation_surface_signature=b'surface',
|
|
send_baseline_blocks=[['客户甲 9/16 10:00:00', '旧问题']],
|
|
last_lines=['客户甲 9/16 10:00:00', '旧问题'],
|
|
customer_speaker='客户甲')
|
|
return self.state
|
|
|
|
def handoff(self):
|
|
self.uncertain()
|
|
result = self.bot.handoff_pending_replies([self.key], confirm_uncertain=True)
|
|
self.assertEqual(result['handed_off'], [self.key])
|
|
return result
|
|
|
|
def test_uncertain_requires_confirmation_and_keeps_evidence(self):
|
|
self.uncertain()
|
|
for confirm in (False, None, 'true', 1):
|
|
result = self.bot.handoff_pending_replies([self.key], confirm_uncertain=confirm)
|
|
self.assertEqual(result['requires_confirmation'], [self.key])
|
|
self.assertEqual(result['protected'], [self.key])
|
|
self.assertFalse(result['deleted'])
|
|
self.assertIs(self.bot._pending_reply_sessions[self.key], self.state)
|
|
|
|
def test_every_sending_stage_is_protected_even_when_confirmed(self):
|
|
for stage, send_state in [('sending', ''), ('sending', 'uncertain'), ('queued', 'sending')]:
|
|
self.state.update(stage=stage, send_state=send_state)
|
|
result = self.bot.handoff_pending_replies([self.key], confirm_uncertain=True)
|
|
self.assertEqual(result['protected'], [self.key])
|
|
self.assertFalse(result['deleted'])
|
|
|
|
def test_receipt_unknown_states_always_require_confirmation(self):
|
|
for stage, send_state in [('receipt_check', ''), ('unknown', ''), ('queued', 'unknown'),
|
|
('queued', 'sent_uncommitted')]:
|
|
self.state.update(stage=stage, send_state=send_state)
|
|
result = self.bot.handoff_pending_replies([self.key])
|
|
self.assertEqual(result['requires_confirmation'], [self.key])
|
|
|
|
def test_confirmed_handoff_preserves_serialized_evidence_not_delivery(self):
|
|
self.handoff()
|
|
saved = json.loads(self.path.read_text(encoding='utf-8'))
|
|
self.assertEqual(saved['pending'], {})
|
|
entry = saved['manual_handoffs'][self.key]
|
|
self.assertEqual(entry['state']['send_state'], 'uncertain')
|
|
self.assertEqual(entry['state']['generation_surface_signature'], b'surface'.hex())
|
|
self.assertTrue(entry['receipt_checked_by_user'])
|
|
self.assertIs(entry['delivery_confirmed'], False)
|
|
self.assertEqual(entry['state']['staged_reply_text'], '合成待审草稿')
|
|
self.assertTrue(self.bot._reply_task_cancelled(self.fp))
|
|
|
|
def test_restart_and_unread_scan_cannot_resend_original(self):
|
|
self.handoff()
|
|
self.bot._cancelled_reply_sessions = set()
|
|
self.bot._pending_reply_sessions = self.bot._load_pending_replies()
|
|
self.assertEqual(self.bot._pending_reply_sessions, {})
|
|
self.assertTrue(self.bot._reply_task_cancelled(self.fp))
|
|
self.assertFalse(self.bot._begin_reply_pending_from_unread(self.fp, confirmed_unread=True))
|
|
self.assertEqual(self.bot._pending_reply_sessions, {})
|
|
self.assertTrue(self.bot._persist_pending_replies())
|
|
self.assertIn(self.key, json.loads(self.path.read_text(encoding='utf-8'))['manual_handoffs'])
|
|
|
|
def test_failed_handoff_save_rolls_back_pending_tombstone_and_evidence(self):
|
|
self.uncertain()
|
|
self.bot._cancelled_reply_sessions = {'other'}
|
|
with mock.patch.object(self.bot, '_persist_pending_replies_unlocked', return_value=False):
|
|
result = self.bot.handoff_pending_replies([self.key], confirm_uncertain=True)
|
|
self.assertIn('error', result)
|
|
self.assertFalse(result['deleted'])
|
|
self.assertIs(self.bot._pending_reply_sessions[self.key], self.state)
|
|
self.assertEqual(self.bot._cancelled_reply_sessions, {'other'})
|
|
self.assertEqual(self.bot._manual_handoffs, {})
|
|
|
|
def test_generating_work_is_cancelled_without_approving_or_sending(self):
|
|
self.state.update(stage='generating', awaiting_review=False)
|
|
self.bot._pending_exchanges = {self.key: {'reply': '尚未生成完成'}}
|
|
result = self.bot.handoff_pending_replies([self.key])
|
|
self.assertEqual(result['handed_off'], [self.key])
|
|
self.assertTrue(self.bot._reply_task_cancelled(self.fp))
|
|
self.assertNotIn(self.key, self.bot._pending_exchanges)
|
|
self.assertFalse(self.bot._mark_reply_pending(self.fp))
|
|
self.assertFalse(self.state.get('review_approved'))
|
|
|
|
def test_old_transcript_or_staff_reply_does_not_unlock_handoff(self):
|
|
self.handoff()
|
|
self.bot._known_agent_speakers = {'客服甲'}
|
|
for text in ('客户甲 9/16 10:00:00\n旧问题',
|
|
'客户甲 9/16 10:00:00\n旧问题\n客服甲 9/16 10:01:00\n人工回答',
|
|
'客户甲 9/16 10:02:00\n无旧基线的新片段'):
|
|
self.assertFalse(self.bot._resume_handoff_for_new_message(self.fp, text))
|
|
self.assertTrue(self.bot._reply_task_cancelled(self.fp))
|
|
|
|
def test_proven_new_customer_block_resumes_but_retains_old_evidence(self):
|
|
self.handoff()
|
|
text = '客户甲 9/16 10:00:00\n旧问题\n客户甲 9/16 10:02:00\n追加的新问题'
|
|
self.assertTrue(self.bot._resume_handoff_for_new_message(self.fp, text))
|
|
self.assertFalse(self.bot._reply_task_cancelled(self.fp))
|
|
entry = json.loads(self.path.read_text(encoding='utf-8'))['manual_handoffs'][self.key]
|
|
self.assertGreater(entry['resumed_at'], 0)
|
|
self.assertEqual(entry['state']['send_state'], 'uncertain')
|
|
self.assertFalse(entry['delivery_confirmed'])
|
|
|
|
def test_resume_does_not_take_effect_if_disk_save_fails(self):
|
|
self.handoff()
|
|
text = '客户甲 9/16 10:00:00\n旧问题\n客户甲 9/16 10:02:00\n追加的新问题'
|
|
with mock.patch.object(self.bot, '_persist_pending_replies_unlocked', return_value=False):
|
|
self.assertFalse(self.bot._resume_handoff_for_new_message(self.fp, text))
|
|
self.assertTrue(self.bot._reply_task_cancelled(self.fp))
|
|
|
|
def test_db_message_must_be_newer_and_same_conversation(self):
|
|
self.state['database_event'] = {'account': 'A', 'conv_id': 'C', 'message_id': 'old', 'send_time': 100}
|
|
self.handoff()
|
|
old = dict(self.state['database_event'])
|
|
for event in (old, {**old, 'message_id': 'new', 'send_time': 99},
|
|
{**old, 'message_id': 'new', 'send_time': 101, 'conv_id': 'other'}):
|
|
self.assertFalse(self.bot._resume_handoff_for_new_message(self.fp, database_event=event))
|
|
self.assertTrue(self.bot._resume_handoff_for_new_message(
|
|
self.fp, database_event={**old, 'message_id': 'new', 'send_time': 101}))
|
|
self.assertFalse(self.bot._reply_task_cancelled(self.fp))
|
|
|
|
def test_old_database_event_is_not_requeued_after_restart(self):
|
|
event = {'account': 'A', 'conv_id': 'C', 'message_id': 'old', 'send_time': 100}
|
|
self.state['database_event'] = event
|
|
self.handoff()
|
|
self.bot._cancelled_reply_sessions = set()
|
|
self.bot._pending_reply_sessions = self.bot._load_pending_replies()
|
|
okay, reason = self.bot.enqueue_detected(self.key, display_name='客户甲', database_event=event)
|
|
self.assertFalse(okay)
|
|
self.assertIn('转人工', reason)
|
|
self.assertEqual(self.bot._pending_reply_sessions, {})
|
|
|
|
def test_offline_flat_handoff_metadata_loads_without_becoming_a_task(self):
|
|
entry = {'state': dict(self.state), 'handed_off_at': 1, 'delivery_confirmed': False}
|
|
self.path.write_text(json.dumps({'manual_handoffs': {self.key: entry}}), encoding='utf-8')
|
|
self.assertEqual(self.bot._load_pending_replies(), {})
|
|
self.assertTrue(self.bot._reply_task_cancelled(self.fp))
|
|
|
|
def test_plain_cancel_cannot_bypass_receipt_or_sending_guard(self):
|
|
for stage, send_state in [('receipt_check', ''), ('uncertain', ''), ('unknown', ''),
|
|
('sending', ''), ('queued', 'unknown')]:
|
|
self.state.update(stage=stage, send_state=send_state)
|
|
result = self.bot.cancel_pending_replies([self.key])
|
|
self.assertEqual(result['protected'], [self.key])
|
|
self.assertFalse(result['deleted'])
|
|
|
|
def test_new_task_cannot_receive_an_old_model_result_after_handoff(self):
|
|
self.state.update(stage='generating', awaiting_review=False)
|
|
self.bot._consume_database_update = mock.Mock()
|
|
self.bot.report_progress = mock.Mock()
|
|
def old_model_returns(*args, **kwargs):
|
|
self.bot.handoff_pending_replies([self.key])
|
|
# A subsequent proven customer message is a different task object.
|
|
self.bot._manual_handoffs[self.key]['resumed_at'] = 1
|
|
self.bot._cancelled_reply_sessions.clear()
|
|
self.bot._pending_reply_sessions[self.key] = {'stage': 'queued', 'staged_user_text': '后续新问题'}
|
|
return '旧模型回复'
|
|
self.bot._generate_ai_reply_impl = mock.Mock(side_effect=old_model_returns)
|
|
self.assertIsNone(self.bot._generate_ai_reply(self.fp, '旧问题'))
|
|
self.assertNotIn('staged_reply_text', self.bot._pending_reply_sessions[self.key])
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|