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

573 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Durable review episodes, private reminders and verified manual takeover.
The engine owns customer tasks. This service never approves or sends a customer
draft. All native notification I/O belongs to a separate worker thread.
"""
from __future__ import annotations
from contextlib import contextmanager
import hashlib
import json
import math
import re
import sqlite3
import threading
import time
import uuid
from pathlib import Path
from review_assistant_contacts import recipient_peer
from review_state import task_review_state
SETTINGS_KEY = 'review_assistant_accounts'
MAX_NOTIFICATION_BYTES = 4000
# Keep the shared service importable in the visual-only source distribution.
# These are WeCom system notice types, not human text/media messages.
SYSTEM_MESSAGE_TYPES = frozenset({11, 38, 101, 132, 1002, 1011, 1012, 1017, 1022, 1025, 1043, 1988})
def _text(value):
return str(value or '').replace('\0', '').strip()
def _number(value):
try:
number = float(value or 0)
return number if math.isfinite(number) else 0.0
except (TypeError, ValueError):
return 0.0
def _fingerprint(account, conv, text):
return hashlib.sha256(json.dumps([account, conv, text], ensure_ascii=False).encode()).hexdigest()
def _clip(text, byte_limit):
text = _text(text)
raw = text.encode('utf-8')
return text if len(raw) <= byte_limit else raw[:max(0, byte_limit - 18)].decode('utf-8', 'ignore') + '…(已截取)'
def manual_completion_blocked(state):
"""Only completed attempts may be superseded by a proven later human reply."""
state = state if isinstance(state, dict) else {}
stage, send_state = _text(state.get('stage')), _text(state.get('send_state'))
terminal = {'uncertain', 'unknown', 'failed', 'sent_uncommitted'}
if state.get('cancel_requested') or stage in {'cancelled', 'canceled'}:
return True
if send_state == 'sending' or stage == 'sending':
return True
if send_state and send_state not in terminal:
return True
# In protocol mode receipt_check + uncertain means the native call returned
# without a definite receipt; a bare receipt_check can still be in flight.
return stage == 'receipt_check' and send_state not in terminal
def _handling_details(state):
flags = task_review_state(state)
kind = 'manual' if flags['needsHuman'] else 'review'
reason = _text(flags['reviewReason'] or state.get('task_error'))
if state.get('task_error') and not any(state.get(key) for key in
('manual_reason', 'review_reason', 'stage_error', 'last_error', 'stage_detail')):
reason = _text(state['task_error'])
return flags, kind, reason
def _context_excerpt(messages):
return '\n'.join(('客服:' if m.get('is_self') else '客户:') +
_clip(m.get('content') or '【非文本或暂未解析的消息,请打开客户会话查看】', 700)
for m in messages[-4:])
def notification_text(episode):
"""Keep identifying fields and handling instructions even for very long chats."""
manual = episode.get('handling_kind') == 'manual'
label = '客户需人工处理' if manual else '客户待人工审核'
reason_label = '处理原因' if manual else '审核原因'
footer = ('\n处理方式:请用上述托管企业微信账号,在手机端直接回复该客户。'
'检测到该账号人工回复后,任务标记为人工已完成,旧 AI 草稿作废,后续新消息恢复自动回复;'
'再次触发风控仍会转人工。此助理联系人仅接收提醒,回复本提醒不会审批客户消息。')
head = (f'【{label}】#{episode["id"][:8]}\n'
f'托管账号:{episode["account"]}\n'
f'客户:{_clip(episode.get("name"), 180)}\n'
f'会话:{_clip(episode["conv"], 160)}\n'
f'时间:{time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(episode["created"]))}\n'
f'{reason_label}:{_clip(episode.get("reason") or "当前任务需要人工确认", 500)}\n')
body = ('最近对话(节选):\n' + _clip(episode.get('context'), 1350) +
'\nAI 草稿(尚未发送):\n' + _clip(episode.get('draft') or '暂无草稿,请直接查看客户会话并处理', 1150))
return head + _clip(body, MAX_NOTIFICATION_BYTES - len((head + footer).encode())) + footer
class ReviewAssistantCoordinator:
def __init__(self, root=None, settings_loader=None, account='', pid=0, db=None, log=None):
if root is None:
from runtime_paths import application_data_dir
root = application_data_dir()
self.root = Path(root)
self.path = self.root / 'review_assistant.sqlite3'
self.account = _text(account)
self.pid = int(pid or 0)
self.db = db
self.log = log
self.settings_loader = settings_loader or self._load_settings
self._lock = threading.RLock()
self._wake = threading.Event()
self._stop = threading.Event()
self._thread = None
self._last_error = ''
def _load_settings(self):
try:
return json.loads((self.root / 'app_settings.json').read_text(encoding='utf-8-sig'))
except (OSError, ValueError):
return {}
def _config(self):
try:
settings = self.settings_loader()
config = settings.get(SETTINGS_KEY, {}).get(self.account, {})
if not self.account.isdigit() or not isinstance(config, dict):
return {}
if config.get('enabled') is not True or not recipient_peer(self.account, _text(config.get('recipient_conv_id'))):
return {}
return config
except Exception:
return {}
def enabled(self):
return bool(self._config()) and not self._stop.is_set()
def is_recipient(self, conv_id):
return bool(self.enabled() and _text(conv_id) == self._config().get('recipient_conv_id'))
def _report(self, message):
if message == self._last_error:
return
self._last_error = message
if callable(self.log):
try:
self.log('审核助理:' + message)
except Exception:
pass
@contextmanager
def _connect(self):
self.root.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.path, timeout=2)
try:
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA synchronous=FULL')
conn.executescript('''
CREATE TABLE IF NOT EXISTS episodes (
id TEXT PRIMARY KEY, account TEXT NOT NULL, conv TEXT NOT NULL,
status TEXT NOT NULL, data TEXT NOT NULL);
CREATE UNIQUE INDEX IF NOT EXISTS pending_episode ON episodes(account,conv) WHERE status='pending';
CREATE TABLE IF NOT EXISTS resumed (
account TEXT NOT NULL, conv TEXT NOT NULL, episode TEXT NOT NULL,
PRIMARY KEY(account,conv));
CREATE TABLE IF NOT EXISTS automated (
account TEXT NOT NULL, conv TEXT NOT NULL, server_id TEXT NOT NULL,
fingerprint TEXT NOT NULL, created REAL NOT NULL, PRIMARY KEY(account,conv,server_id));
''')
with conn:
yield conn
finally:
conn.close()
@staticmethod
def _write(conn, item):
conn.execute('INSERT OR REPLACE INTO episodes VALUES(?,?,?,?,?)',
(item['id'], item['account'], item['conv'], item['status'], json.dumps(item, ensure_ascii=False)))
def _read_episode(self, conn, state, conv):
episode_id = _text(state.get('assistant_review_id'))
if episode_id:
row = conn.execute('SELECT data FROM episodes WHERE id=? AND account=? AND conv=?',
(episode_id, self.account, conv)).fetchone()
else:
row = conn.execute("SELECT data FROM episodes WHERE account=? AND conv=? AND status='pending'",
(self.account, conv)).fetchone()
return json.loads(row['data']) if row else None
def _context(self, state, context):
event = state.get('database_event') or {}
account = _text(state.get('account') or event.get('account') or (context or {}).get('account'))
conv = _text(state.get('conv_id') or event.get('conv_id') or (context or {}).get('conv_id'))
if account != self.account or not re.fullmatch(r'(?:M:[0-9]+|S:[0-9]+_[0-9]+)', conv) or self.is_recipient(conv):
return None
if context is None and self.db is not None:
context = self.db.get_conversation_context_by_id(account, conv, limit=100)
if not isinstance(context, dict) or context.get('account') != account or context.get('conv_id') != conv:
return None
from reply_session_policy import excluded_session_reason
if excluded_session_reason(state.get('display_name', ''), conv, context):
return None
return conv, context
def ensure_review(self, state, context=None):
flags, handling_kind, reason = _handling_details(state)
if (not self.enabled() or state.get('cancel_requested')
or not (flags['awaitingReview'] or flags['needsHuman'])):
return ''
try:
scoped = self._context(state, context)
if not scoped:
return ''
conv, context = scoped
messages = context.get('messages') or []
valid = [m for m in messages if m.get('account') == self.account and m.get('conv_id') == conv]
incoming = [m for m in valid if m.get('is_self') is False and m.get('is_system') is not True
and _number(m.get('content_type')) not in SYSTEM_MESSAGE_TYPES]
if not incoming:
return ''
with self._lock, self._connect() as conn:
current = self._read_episode(conn, state, conv) or self._read_episode(conn, {}, conv)
details = dict(handling_kind=handling_kind, reason=reason,
name=_text(state.get('display_name') or context.get('display_name')),
context=_context_excerpt(valid),
draft=_text(state.get('staged_reply_text') or state.get('reply_text')))
if current:
if current['status'] == 'pending' and any(current.get(key) != value for key, value in details.items()):
current.update(details)
# A persisted/attempted notification keeps its exact payload
# and request ID. A review-to-manual transition is not a resend.
if current.get('notification') == 'pending' and not current.get('attempts'):
current['notification_text'] = notification_text(current)
self._write(conn, current)
return current['id']
anchor = incoming[-1]
created = time.time()
baseline_row = max(_number(m.get('rowid')) for m in valid)
baseline_time = max(_number(m.get('send_time')) for m in valid)
evidence_since, baseline_source = created - 2, 'registration'
event = state.get('database_event') or {}
# Restored tasks can already have a later phone reply in the DB.
# Only a complete exact triggering DB event allows an earlier
# baseline; a title, task timestamp or guessed latest row does not.
for candidate in incoming:
if (event.get('account') == self.account and event.get('conv_id') == conv
and _text(event.get('dedup_key')) and event.get('dedup_key') == candidate.get('dedup_key')
and _number(event.get('rowid')) > 0 and _number(event.get('rowid')) == _number(candidate.get('rowid'))
and _number(event.get('send_time')) > 0 and _number(event.get('send_time')) == _number(candidate.get('send_time'))):
anchor = candidate
baseline_row = _number(candidate['rowid'])
baseline_time = _number(candidate['send_time'])
evidence_since, baseline_source = baseline_time - 2, 'task_event'
break
item = dict(id=uuid.uuid4().hex, account=self.account, conv=conv, status='pending',
created=created, trigger=_text(anchor.get('dedup_key')),
baseline_row=baseline_row, baseline_time=baseline_time,
evidence_since=evidence_since, baseline_source=baseline_source,
**details, recipient=self._config()['recipient_conv_id'],
notification='pending', attempts=0, next_attempt=0)
item['notification_text'] = notification_text(item)
self._write(conn, item)
conn.execute('DELETE FROM resumed WHERE account=? AND conv=?', (self.account, conv))
return item['id']
except Exception as exc:
self._report('登记待审核失败,保留原审核任务(' + type(exc).__name__ + ')')
return ''
def has_pending(self, conv_id):
if not self.enabled() or not self.path.exists():
return False
try:
with self._lock, self._connect() as conn:
return bool(conn.execute("SELECT 1 FROM episodes WHERE account=? AND conv=? AND status='pending'",
(self.account, conv_id)).fetchone())
except sqlite3.Error:
return True # Failure must not silently bypass an existing review.
def resumed(self, conv_id):
if not self.enabled() or not self.path.exists():
return False
try:
with self._lock, self._connect() as conn:
return bool(conn.execute('SELECT 1 FROM resumed WHERE account=? AND conv=?', (self.account, conv_id)).fetchone())
except sqlite3.Error:
return False
def _automated(self, conn, conv, message, since):
sid = _text(message.get('server_id'))
fp = _fingerprint(self.account, conv, _text(message.get('content')))
if conn.execute("SELECT 1 FROM automated WHERE account=? AND conv=? AND (server_id=? OR (server_id LIKE 'attempt:%' AND fingerprint=? AND created>=?))",
(self.account, conv, sid, fp, since)).fetchone():
return True
journal = self.root / 'protocol_sends.sqlite3'
if journal.exists():
# Read-only connection is local to this thread. Unknown native sends
# are excluded by fingerprint too, even when no receipt was stored.
try:
from contextlib import closing
with closing(sqlite3.connect(journal.as_uri() + '?mode=ro', uri=True, timeout=2)) as native:
if native.execute("SELECT 1 FROM sends WHERE fingerprint=? AND status!='confirmed' AND created>=?", (fp, since)).fetchone():
return True
for (result,) in native.execute("SELECT result FROM sends WHERE status='confirmed'"):
receipt = json.loads(result)
if (_text(receipt.get('serverId')) == sid and receipt.get('accountId') == self.account
and receipt.get('conversationId') == conv):
return True
except (sqlite3.Error, ValueError, OSError):
return True # Cannot prove a reply is human while journal is unreadable.
return _text(message.get('content')).startswith(('【客户待人工审核】#', '【客户需人工处理】#'))
def reconcile(self, state, context=None):
if not self.enabled() or not self.path.exists():
return None
# A terminal uncertain attempt is not proof of a human reply. It can
# only finish through the full later-message and automated-outbox checks.
if manual_completion_blocked(state):
return None
try:
scoped = self._context(state, context)
if not scoped:
return None
conv, context = scoped
with self._lock, self._connect() as conn:
item = self._read_episode(conn, state, conv)
if not item:
return None
if item['status'] == 'completed':
return item.get('evidence')
if item['status'] != 'pending':
return None
evidence_since = _number(item.get('evidence_since', item['created'] - 2))
unresolved = _text(state.get('send_state')) in {'uncertain', 'unknown', 'failed', 'sent_uncommitted'}
old_drafts = {_text(state.get(key)) for key in ('reply_text', 'staged_reply_text', 'last_pasted_draft')}
old_drafts.discard('')
for message in context.get('messages') or []:
if message.get('is_system') is True or _number(message.get('content_type')) in SYSTEM_MESSAGE_TYPES:
continue
if unresolved and _text(message.get('content')) in old_drafts:
continue
sid = _text(message.get('server_id'))
if (message.get('account') != self.account or message.get('conv_id') != conv or
message.get('is_self') is not True or _text(message.get('sender_id')) != self.account or
not re.fullmatch(r'[1-9][0-9]*', sid) or not _text(message.get('dedup_key')) or
_number(message.get('rowid')) <= item['baseline_row'] or
_number(message.get('send_time')) < max(item['baseline_time'], evidence_since) or
not _text(message.get('content')) or self._automated(conn, conv, message, evidence_since)):
continue
evidence = {key: message.get(key) for key in ('account', 'conv_id', 'server_id', 'rowid', 'send_time', 'dedup_key', 'content')}
evidence.update(review_id=item['id'], completed_by_human=True,
previous_send_state=_text(state.get('send_state')),
previous_stage=_text(state.get('stage')))
item.update(status='completed', evidence=evidence, completed=time.time())
self._write(conn, item)
conn.execute('INSERT OR REPLACE INTO resumed VALUES(?,?,?)', (self.account, conv, item['id']))
return evidence
except Exception as exc:
self._report('人工回复核验暂不可用,保留原审核任务(' + type(exc).__name__ + ')')
return None
def record_automated_send(self, state, receipt):
if not self.enabled() and not self.path.exists():
return
try:
event = state.get('database_event') or {}
account = _text(receipt.get('accountId') or state.get('account') or event.get('account'))
conv = _text(receipt.get('conversationId') or state.get('conv_id') or event.get('conv_id'))
sid = _text(receipt.get('serverId') or receipt.get('server_id'))
if account != self.account or not conv or not sid or receipt.get('status') != 'confirmed':
return
with self._lock, self._connect() as conn:
fp = _fingerprint(account, conv, _text(state.get('reply_text')))
conn.execute('INSERT OR REPLACE INTO automated VALUES(?,?,?,?,?)',
(account, conv, sid, fp, time.time()))
conn.execute("DELETE FROM automated WHERE account=? AND conv=? AND server_id LIKE 'attempt:%' AND fingerprint=?", (account, conv, fp))
item = self._read_episode(conn, state, conv)
if item and item['status'] == 'pending' and (state.get('approved') or state.get('review_approved')):
item.update(status='locally_approved', completed=time.time())
self._write(conn, item)
except Exception as exc:
self._report('自动发送凭证记录失败(' + type(exc).__name__ + ')')
def record_automated_attempt(self, state):
"""Persist visual automation intent before dispatch, including uncertainty."""
if not self.enabled():
return
event = state.get('database_event') or {}
account = _text(state.get('account') or event.get('account'))
conv = _text(state.get('conv_id') or event.get('conv_id'))
if account != self.account or not conv or not _text(state.get('reply_text')):
return
with self._lock, self._connect() as conn:
fp = _fingerprint(account, conv, _text(state['reply_text']))
conn.execute('INSERT OR REPLACE INTO automated VALUES(?,?,?,?,?)',
(account, conv, 'attempt:' + fp, fp, time.time()))
def complete_local_review(self, state):
"""A local approval send closes reminders but never grants auto-resume."""
if not self.path.exists() or not (state.get('approved') or state.get('review_approved')):
return
event = state.get('database_event') or {}
account = _text(state.get('account') or event.get('account'))
conv = _text(state.get('conv_id') or event.get('conv_id'))
if account != self.account or not conv:
return
with self._lock, self._connect() as conn:
item = self._read_episode(conn, state, conv)
if item and item['status'] == 'pending':
item.update(status='locally_approved', completed=time.time())
self._write(conn, item)
def cancel_review(self, state):
"""Explicit dismissal is idempotent and never approves another episode."""
if not self.path.exists():
return True
event = state.get('database_event') or {}
account = _text(state.get('account') or event.get('account'))
conv = _text(state.get('conv_id') or event.get('conv_id'))
if account != self.account or not conv:
return False
try:
with self._lock, self._connect() as conn:
item = self._read_episode(conn, state, conv)
if item and item['status'] == 'pending':
item.update(status='cancelled', completed=time.time())
self._write(conn, item)
conn.execute('DELETE FROM resumed WHERE account=? AND conv=? AND episode=?',
(account, conv, item['id']))
return True
except (OSError, sqlite3.Error, ValueError):
self._report('取消提醒暂未落盘,等待任务队列重试')
return False
def enqueue_notifications(self):
if not self.enabled() or not self.path.exists():
return
with self._lock:
if self._thread is None or not self._thread.is_alive():
self._thread = threading.Thread(target=self._worker, name='review-assistant-notify', daemon=True)
self._thread.start()
self._wake.set()
def _worker(self):
while not self._stop.is_set():
self._wake.wait(5)
self._wake.clear()
if self._stop.is_set():
break
if not self.enabled():
continue
try:
with self._lock, self._connect() as conn:
items = [json.loads(row[0]) for row in conn.execute(
"SELECT data FROM episodes WHERE account=? AND status='pending'", (self.account,))]
for item in items:
if self._stop.is_set():
break
if item['notification'] == 'sending':
self._notification_update(item, notification='unknown', notification_error='上次提醒发送中断,请人工核对;不会自动重发')
elif item['notification'] in ('pending', 'retry') and item.get('next_attempt', 0) <= time.time():
self._notify(item)
except Exception as exc:
self._report('提醒暂不可用,客户审核任务保留(' + type(exc).__name__ + ')')
def _notification_update(self, item, **updates):
with self._lock, self._connect() as conn:
current = self._read_episode(conn, {'assistant_review_id': item['id']}, item['conv'])
if current:
current.update(updates)
self._write(conn, current)
def _notification_guard(self, item):
if not self.enabled() or self._config().get('recipient_conv_id') != item['recipient'] or item['recipient'] == item['conv']:
return False
with self._lock, self._connect() as conn:
current = self._read_episode(conn, {'assistant_review_id': item['id']}, item['conv'])
return bool(current and current['status'] == 'pending'
and current.get('notification_text') == item.get('notification_text'))
def _notify(self, item):
if not self._notification_guard(item):
return
sender = worker_db = None
locked = False
attempted = False
try:
import send_lock
from reply_database import LiveReplyDatabase
from wecom_native_sender import NativeSender, discover
from review_assistant_contacts import validate_recipient
identity = discover()
if identity.get('accountId') != self.account:
raise RuntimeError('托管账号已切换')
validation = validate_recipient(self.account, item['recipient'])
if validation.get('ok') is not True:
raise RuntimeError(validation.get('reason') or '通知接收人校验失败')
worker_db = LiveReplyDatabase._open_database(account=self.account, initialize=False)
if worker_db is None:
raise RuntimeError('精确账号消息库不可读')
sender = NativeSender(worker_db, self.root / 'protocol_sends.sqlite3')
locked = send_lock.try_acquire('review-assistant', 0)
if not locked:
return
if not self._notification_guard(item):
return
self._notification_update(item, notification='sending', attempts=item.get('attempts', 0) + 1)
attempted = True
def recipient_guard():
# A contact can be removed, or the configured recipient changed,
# while the native sender is preparing its final send. Recheck
# the current account's customer relation at each native gate.
if not self._notification_guard(item):
return False
try:
valid = validate_recipient(self.account, item['recipient']).get('ok') is True
except Exception:
return False
return valid and self._notification_guard(item)
receipt = sender.send('review-assistant:' + item['id'], identity['pid'], self.account,
item['recipient'], item['notification_text'], guard=recipient_guard)
self._notification_update(item, notification='sent', notification_receipt=receipt)
self._report('已向指定医疗助理发送审核提醒')
except Exception as exc:
# NativeUnavailable/SendCancelled prove the native send was not
# invoked; all other attempted outcomes remain at-most-once.
safe_retry = not attempted or type(exc).__name__ in ('NativeUnavailable', 'SendCancelled')
attempts = item.get('attempts', 0) + 1
self._notification_update(item, notification='retry' if safe_retry else 'unknown', attempts=attempts,
next_attempt=time.time() + min(300, 10 * 2 ** min(attempts, 5)),
notification_error=_clip(str(exc), 300))
self._report('提醒未完成;审核任务仍保留:' + _clip(str(exc), 180))
finally:
if locked:
send_lock.release()
if sender is not None:
sender.close()
if worker_db is not None and hasattr(worker_db, 'close'):
worker_db.close()
def close(self):
self._stop.set()
self._wake.set()
if self._thread and self._thread is not threading.current_thread():
self._thread.join(timeout=0.2)
def notification_summary(root, account):
"""Read-only status for an explicit UI refresh; never expose customer text."""
path = Path(root) / 'review_assistant.sqlite3'
if not path.exists():
return {'status': 'idle', 'message': '暂无审核提醒', 'updated_at': 0}
from contextlib import closing
try:
with closing(sqlite3.connect(path.as_uri() + '?mode=ro', uri=True, timeout=1)) as conn:
row = conn.execute('SELECT data FROM episodes WHERE account=? ORDER BY rowid DESC LIMIT 1', (str(account),)).fetchone()
if not row:
return {'status': 'idle', 'message': '当前账号暂无审核提醒', 'updated_at': 0}
item = json.loads(row[0])
status = item['notification'] if item['status'] == 'pending' else item['status']
messages = {'pending': '审核提醒等待发送', 'retry': '提醒暂未发送,稍后重试;客户审核任务保留',
'sending': '提醒发送中;如进程中断需人工核对', 'sent': '已发送提醒,等待人工直接回复客户',
'unknown': '提醒结果未确认,不会自动重发,请核对助理会话',
'completed': '人工已完成,后续新消息恢复自动回复', 'locally_approved': '已在客户端通过审核', 'cancelled': '任务已取消或转为人工接管,本次提醒结束'}
return {'status': status, 'message': messages.get(status, '请查看任务详情'),
'updated_at': item.get('completed') or item.get('created', 0)}
except (OSError, ValueError, sqlite3.Error):
return {'status': 'unavailable', 'message': '提醒状态暂不可读,原审核任务保留', 'updated_at': 0}