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