573 lines
30 KiB
Python
573 lines
30 KiB
Python
"""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}
|