"""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}