Files
kefu/wechat_rpa/tests/test_review_manual_handoff.py
T
2026-09-21 10:34:06 +08:00

247 lines
12 KiB
Python

"""Synthetic manual/review lifecycle coverage; no real queues, credentials or I/O."""
import hashlib
import json
import sqlite3
import time
from pathlib import Path
import pytest
from review_assistant import (
ReviewAssistantCoordinator, _fingerprint, manual_completion_blocked,
notification_summary, notification_text,
)
ACCOUNT = '168800000000001'
CONV = f'S:{ACCOUNT}_101'
ASSISTANT = f'S:{ACCOUNT}_202'
def message(row=1, own=False, **values):
result = dict(account=ACCOUNT, conv_id=CONV, rowid=row, is_self=own,
sender_id=ACCOUNT if own else '101', server_id=str(100 + row),
dedup_key=f'{CONV}:server:{100 + row}', content='人工答复' if own else '客户问题',
send_time=time.time(), content_type=2)
result.update(values)
return result
def context(*messages):
return dict(account=ACCOUNT, conv_id=CONV, display_name='合成客户',
messages=list(messages), last_message=messages[-1])
def make_service(tmp_path, **changes):
settings = {'review_assistant_accounts': {ACCOUNT: {'enabled': True, 'recipient_conv_id': ASSISTANT}}}
service = ReviewAssistantCoordinator(tmp_path, lambda: settings, ACCOUNT)
state = dict(account=ACCOUNT, conv_id=CONV, display_name='合成客户',
stage='error', manual_required=True, manual_reason='语音暂未转写',
send_state='', reply_text='')
state.update(changes)
return service, state, settings
def episode(service):
with service._connect() as conn:
return json.loads(conn.execute('SELECT data FROM episodes ORDER BY rowid DESC LIMIT 1').fetchone()[0])
def register(service, state, *messages):
state['assistant_review_id'] = service.ensure_review(state, context(*messages))
assert state['assistant_review_id']
return episode(service)
@pytest.mark.parametrize('changes', [
{'manual_required': True},
{'manual_required': False, 'stage': 'error'},
{'manual_required': False, 'stage': 'failed'},
{'manual_required': False, 'stage': 'reading', 'foreign_draft_text': '人工未发送草稿'},
{'manual_required': False, 'stage': 'receipt_check', 'send_state': 'uncertain'},
{'manual_required': False, 'stage': 'unknown', 'send_state': 'unknown'},
{'manual_required': False, 'stage': 'failed', 'send_state': 'failed'},
{'manual_required': False, 'stage': 'receipt_check', 'send_state': 'sent_uncommitted'},
{'manual_required': False, 'stage': 'awaiting_review', 'awaiting_review': True},
])
def test_every_ui_needs_human_case_creates_one_detailed_episode(tmp_path, changes):
service, state, _ = make_service(tmp_path, **changes)
incoming = message(content='', content_type=14)
item = register(service, state, incoming)
assert item['handling_kind'] == 'manual'
assert '客户需人工处理' in item['notification_text']
assert '处理原因' in item['notification_text']
assert '暂无草稿' in item['notification_text']
assert '非文本或暂未解析' in item['notification_text']
assert '人工已完成' in item['notification_text']
assert len(item['notification_text'].encode('utf-8')) <= 4000
assert service.ensure_review(state, context(incoming)) == item['id']
with service._connect() as conn:
assert conn.execute('SELECT COUNT(*) FROM episodes').fetchone()[0] == 1
def test_ordinary_queued_task_does_not_create_a_reminder(tmp_path):
service, state, _ = make_service(tmp_path, manual_required=False, stage='queued', manual_reason='')
assert not service.ensure_review(state, context(message()))
assert not service.path.exists()
def test_review_to_manual_preserves_id_and_baseline_updates_only_unattempted_text(tmp_path):
service, state, _ = make_service(tmp_path, manual_required=False, stage='awaiting_review',
awaiting_review=True, manual_reason='', review_reason='初次风控', reply_text='初始AI草稿')
incoming = message()
before = register(service, state, incoming)
state.update(manual_required=True, manual_reason='需人工确认图片', stage='error', awaiting_review=False)
later = message(2, content='新增图片', content_type=3)
assert service.ensure_review(state, context(incoming, later)) == before['id']
after = episode(service)
for field in ('created', 'baseline_row', 'baseline_time', 'evidence_since', 'trigger', 'recipient', 'attempts'):
assert after[field] == before[field]
assert after['handling_kind'] == 'manual' and after['reason'] == '需人工确认图片'
assert after['notification_text'] != before['notification_text']
assert not service._notification_guard(before) # Stale worker snapshot cannot dispatch old content.
assert service._notification_guard(after)
@pytest.mark.parametrize('notification', ['sending', 'sent', 'unknown', 'retry'])
def test_attempted_or_delivered_notification_never_changes_payload_or_requeues(tmp_path, notification):
service, state, _ = make_service(tmp_path, manual_required=False, stage='awaiting_review',
awaiting_review=True, manual_reason='', review_reason='审核原因', reply_text='草稿')
incoming = message()
before = register(service, state, incoming)
service._notification_update(before, notification=notification, attempts=1)
state.update(manual_required=True, manual_reason='转人工处理', stage='error', awaiting_review=False)
assert service.ensure_review(state, context(incoming)) == before['id']
after = episode(service)
assert after['handling_kind'] == 'manual'
assert after['notification_text'] == before['notification_text']
assert after['notification'] == notification and after['attempts'] == 1
@pytest.mark.parametrize('send_state', ['unknown', 'uncertain', 'failed', 'sent_uncommitted'])
def test_terminal_delivery_accepts_only_proven_distinct_human_reply_and_preserves_journal(tmp_path, send_state):
service, state, _ = make_service(tmp_path, send_state=send_state, stage='receipt_check', reply_text='旧AI草稿')
incoming = message()
register(service, state, incoming)
journal = tmp_path / 'protocol_sends.sqlite3'
with sqlite3.connect(journal) as db:
db.execute('CREATE TABLE sends(request_id,fingerprint,status,result,created)')
db.execute('INSERT INTO sends VALUES(?,?,?,?,?)',
('old-attempt', _fingerprint(ACCOUNT, CONV, '旧AI草稿'), 'unknown', '{}', time.time()))
before = hashlib.sha256(journal.read_bytes()).hexdigest()
assert not service.reconcile(state, context(incoming, message(2, True, content='旧AI草稿')))
human = message(3, True, content='我已人工处理,请按这个答复')
evidence = service.reconcile(state, context(incoming, human))
assert evidence['completed_by_human'] is True
assert evidence['previous_send_state'] == send_state
assert evidence['previous_stage'] == 'receipt_check'
assert service.resumed(CONV) and not service.has_pending(CONV)
assert hashlib.sha256(journal.read_bytes()).hexdigest() == before
assert notification_summary(tmp_path, ACCOUNT)['message'].startswith('人工已完成')
def test_same_old_draft_without_journal_is_still_not_human_proof(tmp_path):
service, state, _ = make_service(tmp_path, send_state='unknown', stage='receipt_check', staged_reply_text='旧AI草稿')
incoming = message()
register(service, state, incoming)
assert not service.reconcile(state, context(incoming, message(2, True, content='旧AI草稿')))
assert service.has_pending(CONV)
def test_unreadable_outbox_fails_closed_even_for_distinct_reply(tmp_path):
service, state, _ = make_service(tmp_path, send_state='unknown', stage='receipt_check')
incoming = message()
register(service, state, incoming)
(tmp_path / 'protocol_sends.sqlite3').write_bytes(b'not a sqlite database')
assert not service.reconcile(state, context(incoming, message(2, True)))
assert service.has_pending(CONV)
@pytest.mark.parametrize('changes', [
{'send_state': 'sending'}, {'stage': 'sending'}, {'stage': 'receipt_check', 'send_state': ''},
{'send_state': 'new_unrecognized_state'}, {'cancel_requested': True}, {'stage': 'cancelled'},
])
def test_active_unknown_or_cancelled_state_cannot_complete(tmp_path, changes):
service, state, _ = make_service(tmp_path)
incoming = message()
register(service, state, incoming)
state.update(changes)
assert manual_completion_blocked(state)
assert not service.reconcile(state, context(incoming, message(2, True)))
assert service.has_pending(CONV)
def test_exact_restored_trigger_event_can_recognize_already_arrived_later_phone_reply(tmp_path):
service, state, _ = make_service(tmp_path)
incoming = message(send_time=time.time() - 100)
human = message(2, True, send_time=time.time() - 50)
state['database_event'] = dict(incoming)
item = register(service, state, incoming, human)
assert item['baseline_source'] == 'task_event'
assert item['baseline_row'] == incoming['rowid']
assert service.reconcile(state, context(incoming, human))['server_id'] == human['server_id']
@pytest.mark.parametrize('broken', [{'account': '999'}, {'conv_id': 'M:999'}, {'dedup_key': ''},
{'rowid': 999}, {'send_time': 1}])
def test_inexact_restore_event_does_not_lower_registration_baseline(tmp_path, broken):
service, state, _ = make_service(tmp_path)
incoming = message(send_time=time.time() - 100)
human = message(2, True, send_time=time.time() - 50)
state['database_event'] = dict(incoming, **broken)
item = register(service, state, incoming, human)
assert item['baseline_source'] == 'registration'
assert item['baseline_row'] == human['rowid']
assert not service.reconcile(state, context(incoming, human))
def test_restored_baseline_still_excludes_historical_automated_attempt(tmp_path):
service, state, _ = make_service(tmp_path)
incoming = message(send_time=time.time() - 100)
automated = message(2, True, send_time=time.time() - 50, content='自动发送内容')
state['database_event'] = dict(incoming)
register(service, state, incoming, automated)
with service._connect() as conn:
conn.execute('INSERT INTO automated VALUES(?,?,?,?,?)',
(ACCOUNT, CONV, 'attempt:old', _fingerprint(ACCOUNT, CONV, automated['content']), time.time() - 60))
assert not service.reconcile(state, context(incoming, automated))
def test_manual_completion_is_idempotent_new_risk_uses_new_episode(tmp_path):
service, state, _ = make_service(tmp_path)
incoming = message()
first = register(service, state, incoming)
human = message(2, True)
evidence = service.reconcile(state, context(incoming, human))
assert service.reconcile(state, context(incoming, human)) == evidence
fresh = dict(state, assistant_review_id='', manual_reason='新一轮风控')
followup = message(3)
new_id = service.ensure_review(fresh, context(incoming, human, followup))
assert new_id and new_id != first['id']
assert service.has_pending(CONV) and not service.resumed(CONV)
def test_explicit_cancel_and_disabled_settings_never_auto_enable_or_complete(tmp_path):
service, state, settings = make_service(tmp_path)
incoming = message()
register(service, state, incoming)
settings['review_assistant_accounts'][ACCOUNT]['enabled'] = False
assert not service.ensure_review(state, context(incoming))
assert not service.reconcile(state, context(incoming, message(2, True)))
assert service.cancel_review(state)
assert settings['review_assistant_accounts'][ACCOUNT]['enabled'] is False
settings['review_assistant_accounts'][ACCOUNT]['enabled'] = True
old_id = state['assistant_review_id']
assert service.ensure_review(state, context(incoming)) == old_id
assert episode(service)['status'] == 'cancelled'
assert not service.resumed(CONV) and not service.has_pending(CONV)
def test_assistant_reply_and_notification_copy_do_not_finish_customer_task(tmp_path):
service, state, _ = make_service(tmp_path)
incoming = message()
register(service, state, incoming)
assistant = message(2, True, conv_id=ASSISTANT)
assert not service.reconcile(state, dict(context(assistant), conv_id=ASSISTANT))
copied = message(2, True, content='【客户需人工处理】#old-copy')
assert not service.reconcile(state, context(incoming, copied))
assert service.has_pending(CONV)