Files
kefu/deploy/plaintext-chat-20260918/before/production/protocol_engine.py
T
2026-09-21 10:34:06 +08:00

967 lines
60 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.
"""Database receive + native original-account send, without window interaction."""
from __future__ import annotations
import hashlib
import json
import os
import re
from pathlib import Path
import threading
import time
from runtime_paths import application_data_dir
from reply_session_policy import excluded_session_reason
from contact_reply_guard import (CONTACT_REASON, conversation_contact_reason, system_contact_status, rejected_contact_reason,
contact_event_order, contact_peer, latest_contact_notice, current_customer_relation, real_customer_event)
from wecom_native_sender import NativeSender,NativeUnavailable,DeliveryUnknown,DeliveryFailed,SendCancelled,discover
def protocol_queue_path():return application_data_dir()/'pending_replies.protocol.json'
def model_context_text(context):
"""Respect saved memory settings while retaining the unanswered message batch."""
import ai_config
from datetime import datetime
messages=list(context.get('messages') or [])
if not messages:return str(context.get('text') or '')
start=len(messages)-1
while start>0 and not messages[start-1].get('is_self'):start-=1
if getattr(ai_config,'AI_CONTEXT_ENABLED',True):
rounds=max(1,min(50,int(getattr(ai_config,'AI_CONTEXT_MAX_ROUNDS',5) or 5)))
start=min(start,max(0,len(messages)-rounds*2))
lines=[]
for item in messages[start:]:
speaker='我' if item.get('is_self') else context.get('display_name','客户')
stamp=datetime.fromtimestamp(float(item.get('send_time') or time.time())).strftime('%Y/%m/%d %H:%M:%S')
lines.extend((f'{speaker} {stamp}',str(item.get('content') or '')))
return '\n'.join(lines)
def _voice_batch_fingerprint(context):
"""Only voice-containing unanswered batches need cache-content invalidation."""
from voice_messages import is_voice, unanswered_messages
batch=unanswered_messages(context)
if not any(is_voice(message) for message in batch):return ''
fields=('account','conv_id','server_id','dedup_key','content_type','content',
'voice_status','voice_transcribed','voice_binding')
payload=[[message.get(field) for field in fields] for message in batch]
return hashlib.sha256(json.dumps(payload,ensure_ascii=False,separators=(',',':')).encode('utf-8')).hexdigest()
def _is_text_message(message):
from voice_messages import verified_transcript
if verified_transcript(message):return True
try:return int(message.get('content_type') or 0) in (0,1,2)
except (TypeError,ValueError):return False
MANUAL_MEDIA_REASON = '当前未回复消息包含图片、语音或其他非文本内容,协议版需人工处理'
MANUAL_BATCH_REASON = '连续未回复消息超过 100 条,无法保证问题完整,请人工处理后继续'
MANUAL_NON_TEXT_REASON = '当前协议版只自动处理文本消息'
def _send_protection(state):
"""Use either durable field; missing/old schema must not enable a replay."""
status=str(state.get('send_state') or '')
stage=str(state.get('stage') or '')
if status=='sending' or stage=='sending':return 'sending'
if status in ('uncertain','unknown','sent_uncommitted') or stage in ('receipt_check','uncertain','unknown','sent_uncommitted'):
return 'uncertain'
if status=='failed' or stage=='failed':return 'failed'
return 'uncertain' if status else ''
def _restore_review_details(state):
# Existing persisted queues predate the desktop's explicit review fields.
# Recover presentation metadata without granting approval or replaying sends.
if state.get('awaiting_review') and not state.get('approved'):
state['review_reason'] = str(state.get('review_reason') or state.get('stage_detail') or '人工审核后点击“通过并发送”')
if state.get('stage') == 'error' and state.get('last_error') in (
MANUAL_MEDIA_REASON, MANUAL_BATCH_REASON, MANUAL_NON_TEXT_REASON):
state.update(manual_required=True, manual_reason=state['last_error'])
def _unanswered_manual_reason(context):
# A text caption after media is still a media question. Human replies and
# restored-contact notices separate the current batch from old history.
count=0
for message in reversed((context or {}).get('messages') or []):
if message.get('is_self') or system_contact_status(message)=='restored':break
count+=1
if not _is_text_message(message):return MANUAL_MEDIA_REASON
if count>100:return MANUAL_BATCH_REASON
return ''
def _session_exclusion(source):
source=source if isinstance(source,dict) else {}
return excluded_session_reason(name=source.get('display_name') or '',
conv_id=source.get('conv_id') or '',metadata=source)
def session_key(account,conversation):return hashlib.blake2b((account+'\0'+conversation).encode(),digest_size=40).hexdigest()
class ProtocolBot:
def __init__(self,*,db=None,sender=None,identity=None,path=None,generate=None,stop_event=None,reply_text='',log=None,progress=None,review_assistant=None):
self.stop_event=stop_event or threading.Event();self.log=log or print;self.reply_text=reply_text
self.progress_cb=progress;self._progress_text=''
self._completed_polls=0;self._last_poll_log=time.monotonic()
self._contact_scan_at=0.;self._contact_scan_offset=0;self._contact_logs={}
self.identity=identity or discover();self.account=self.identity['accountId'];self.pid=self.identity['pid']
self.path=Path(path) if path else protocol_queue_path();self.path.parent.mkdir(parents=True,exist_ok=True)
self._lock=threading.RLock();self._owned_db=db is None
self._review_assistant=review_assistant;self._assistant_warning=''
if db is None:
from reply_database import LiveReplyDatabase
db=LiveReplyDatabase._open_database(account=self.account)
self.db=db
if not self.db.health_check():raise NativeUnavailable('消息数据库不可读,协议监听不能启动')
self.sender=sender or NativeSender(self.db,application_data_dir()/'protocol_sends.sqlite3')
self.generate=generate or self._generate
self.message_batch_window_seconds=8.;self.send_delay_seconds=2.;self.send_mode='auto'
self.reply_count=0;self.false_pos_rows=[];self.security_verification_required=False
self._window_ready=True;self._reply_wakeup=threading.Event();self.needs_attention=False
self._since=time.time();self._pending={};self._seen={};self._contact_blocked={};self._manual_handoffs={};self._assistant_cancellations={}
if self.path.exists():
raw=json.loads(self.path.read_text(encoding='utf-8'))
if not isinstance(raw,dict) or not isinstance(raw.get('pending',{}),dict):raise NativeUnavailable('协议队列文件格式错误,已停止启动')
self._pending={k:v for k,v in raw.get('pending',{}).items() if isinstance(v,dict)}
self._manual_handoffs=raw.get('manual_handoffs',{})
if not isinstance(self._manual_handoffs,dict):raise NativeUnavailable('人工接管记录格式错误,已停止启动')
self._assistant_cancellations=raw.get('assistant_cancellations',{})
if not isinstance(self._assistant_cancellations,dict):raise NativeUnavailable('审核提醒取消记录格式错误')
self._assistant_cancellations={k:v for k,v in self._assistant_cancellations.items() if isinstance(v,dict)}
self._contact_blocked=raw.get('contactBlocked',{})
if not isinstance(self._contact_blocked,dict):raise NativeUnavailable('联系人跳过记录格式错误')
self._seen=raw.get('seen',{})
if not isinstance(self._seen,dict):raise NativeUnavailable('协议去重记录格式错误')
for key,state in list(self._pending.items()):
# Filtering old drafts must never erase an attempted send's evidence.
if state.get('account')==self.account and not _send_protection(state) and _session_exclusion(state):
if state.get('dedup_key'):self._seen[key]=state['dedup_key']
self._pending.pop(key,None)
continue
_restore_review_details(state)
lookup=getattr(self.sender,'lookup',None)
saved=lookup(state.get('dedup_key','')) if lookup else None
if saved and saved['status']=='confirmed':
self._assistant_call('record_automated_send',state,saved.get('result') or {},default=None)
self._pending.pop(key,None)
continue
if _send_protection(state) in ('sending','uncertain'):state.update(send_state='uncertain',stage='receipt_check',last_error=state.get('last_error') or '上次发送结果待核对,禁止自动重发')
elif state.get('stage')=='generating':state.update(stage='queued',ready_at=time.time())
# A restart preserves the affected conversation's no-replay protection.
self.needs_attention=any(s.get('account')==self.account and _send_protection(s)=='uncertain' for s in self._pending.values())
self._save()
def _assistant_call(self,method,*args,default=False):
"""Optional coordination cannot stop the protocol listener or approve work."""
try:
if self._review_assistant is None:
from review_assistant import ReviewAssistantCoordinator
self._review_assistant=ReviewAssistantCoordinator(
root=self.path.parent,account=self.account,pid=self.pid,db=self.db,log=self.log)
assistant=self._review_assistant
if method not in ('cancel_review','record_automated_send') and not assistant.enabled():return default
return getattr(assistant,method)(*args)
except Exception as exc:
warning=f'{method}:{type(exc).__name__}'
if warning!=self._assistant_warning:
self._assistant_warning=warning
try:self.log('[医疗助理] 协作状态暂不可用,协议监听继续;'+warning)
except Exception:pass
return default
def _queue_assistant_cancellation(self,state):
# Persist only an exact episode target, never a conversation-wide retry
# that could cancel a later independent risk review.
review_id=state.get('assistant_review_id')
if review_id:
self._assistant_cancellations[review_id]={
'account':state.get('account'), 'conv_id':state.get('conv_id'),
'assistant_review_id':review_id}
def _flush_assistant_cancellations(self):
with self._lock:items=list(self._assistant_cancellations.items())
for review_id,state in items:
if state.get('account')!=self.account:continue
if self._assistant_call('cancel_review',state) is not True:continue
with self._lock:
if self._assistant_cancellations.get(review_id) is not state:continue
self._assistant_cancellations.pop(review_id,None)
try:self._save()
except Exception:
self._assistant_cancellations[review_id]=state
raise
def _assistant_cancellation_pending(self,state):
with self._lock:
return any(item.get('account')==self.account and item.get('conv_id')==state.get('conv_id')
for item in self._assistant_cancellations.values())
def _assistant_recipient(self,conv_id):
return bool(self._assistant_call('is_recipient',conv_id))
def _assistant_pending(self,state):
# Preserve an already-registered review if coordination storage is
# temporarily unavailable; never interpret a read failure as approval.
if state.get('assistant_review_id') and not state.get('approved'):return True
return bool(self._assistant_call('has_pending',state.get('conv_id','')))
@staticmethod
def _assistant_attention(state):
# Registration is a notification decision, never permission to send or
# to clear protected delivery evidence. Explicitly stopped tasks remain
# stopped even if an old episode is still present in coordination storage.
if state.get('cancel_requested') or state.get('stage') in ('cancelled','disabled','paused'):
return False
from review_state import task_review_state
flags=task_review_state(state)
return bool(flags['awaitingReview'] or flags['needsHuman'])
def _register_assistant_review(self,key,state,context=None):
if not self._assistant_call('enabled') or state.get('account')!=self.account:return
if self._assistant_cancellation_pending(state) or not self._assistant_attention(state):return
if _send_protection(state)=='sending':return
if context is None:
context=self.db.get_conversation_context_by_id(self.account,state['conv_id'],limit=500)
review_id=self._assistant_call('ensure_review',state,context,default='')
if review_id and review_id!=state.get('assistant_review_id'):
with self._lock:
if self._pending.get(key) is state:
state['assistant_review_id']=review_id
self._save()
@staticmethod
def _message_order(message):
try:return (float(message.get('send_time') or 0),int(message.get('rowid') or 0))
except (TypeError,ValueError):return (0.0,0)
def _assistant_context(self,state,context):
# A phone reply may reach the local DB after a newer customer question.
# Only this proven resumed batch needs chronological context; normal DB
# polling remains in authoritative row order.
if not state.get('assistant_resume_evidence') or not context:return context
messages=list(context.get('messages') or [])
messages.sort(key=self._message_order)
if not messages:return context
updated={**context,'messages':messages,'last_message':messages[-1]}
updated['text']=model_context_text(updated)
return updated
def _reconcile_assistant_review(self,key,state,context=None):
if not self._assistant_call('enabled'):return False
from review_assistant import manual_completion_blocked
if (state.get('account')!=self.account or manual_completion_blocked(state)
or self._assistant_cancellation_pending(state) or state.get('cancel_requested')
or state.get('stage') in ('cancelled','disabled','paused')
or not (self._assistant_attention(state) or state.get('assistant_review_id')
or self._assistant_call('has_pending',state.get('conv_id','')))):
return False
if context is None:
context=self.db.get_conversation_context_by_id(self.account,state['conv_id'],limit=500)
self._register_assistant_review(key,state,context)
evidence=self._assistant_call('reconcile',state,context,default=None)
if not isinstance(evidence,dict) or not evidence:return False
if (evidence.get('review_id') and state.get('assistant_review_id')
and evidence['review_id']!=state['assistant_review_id']):return False
evidence_order=self._message_order(evidence)
following=[m for m in (context or {}).get('messages',[])
if m.get('account')==self.account and m.get('conv_id')==state.get('conv_id')
and m.get('is_self') is False and m.get('dedup_key')
and self._message_order(m)>evidence_order]
followup=max(following,key=self._message_order) if following else None
with self._lock:
if self._pending.get(key) is not state or manual_completion_blocked(state) or state.get('cancel_requested'):return False
previous_seen=self._seen.get(key)
previous_handoff=self._manual_handoffs.get(key)
if _send_protection(state):
# A verified later human reply finishes the customer task, not
# the ambiguous old delivery. Preserve its complete evidence;
# never rewrite the native journal or claim that draft was sent.
self._manual_handoffs[key]={
'state':json.loads(json.dumps(state,ensure_ascii=False)),
'handed_off_at':time.time(),'completed_by_human':True,
'completion_reason':'verified_human_reply','evidence':dict(evidence),
'receipt_checked_by_user':False,'delivery_confirmed':False}
if state.get('dedup_key'):self._seen[key]=state['dedup_key']
self._pending.pop(key,None)
try:
if followup:
if self._seen.get(key)==followup['dedup_key']:self._seen.pop(key,None)
self._upsert({**followup,'assistant_resume_evidence':evidence})
else:self._save()
except Exception:
self._pending[key]=state
if previous_seen is None:self._seen.pop(key,None)
else:self._seen[key]=previous_seen
if previous_handoff is None:self._manual_handoffs.pop(key,None)
else:self._manual_handoffs[key]=previous_handoff
raise
try:
from queue_log import QueueLog
label='人工已完成'
QueueLog().append(key,state.get('display_name',''),label,
'已检测到当前企微账号人工回复;本轮旧AI草稿已丢弃,后续消息重新执行风控')
self.log('[协议'+label+'] '+state.get('display_name','')+' · 已人工回复,本轮旧AI草稿已丢弃')
except Exception:pass
self._listening_progress()
return True
def _save(self):
with self._lock:
temp=self.path.with_suffix('.tmp')
with temp.open('w',encoding='utf-8') as f:
json.dump({'schemaVersion':1,'transport':'protocol','pending':self._pending,'seen':self._seen,'contactBlocked':self._contact_blocked,'manual_handoffs':self._manual_handoffs,'assistant_cancellations':self._assistant_cancellations},f,ensure_ascii=False,indent=2);f.flush();os.fsync(f.fileno())
os.replace(temp,self.path)
def _progress(self,text):
if self.stop_event.is_set() or text==self._progress_text:return
self._progress_text=text
if self.progress_cb:
try:self.progress_cb(text)
except Exception:pass # Display failures must not interrupt delivery.
def _listening_progress(self):
with self._lock:
pending=[s for s in self._pending.values() if s.get('account')==self.account]
uncertain=sum(_send_protection(s)=='uncertain' for s in pending)
self.needs_attention=bool(uncertain)
if uncertain:
self._progress(f'协议监听中 · {uncertain} 个会话发送结果待核对,其他会话继续处理')
elif any(s.get('stage') not in ('awaiting_review','error','failed','receipt_check') for s in pending):
self._progress(f'协议监听中 · 待处理 {len(pending)} 个会话')
elif any(s.get('awaiting_review') for s in pending):
count=sum(bool(s.get('awaiting_review')) for s in pending)
self._progress(f'协议监听中 · {count} 个会话待审核')
elif pending:
self._progress(f'协议监听中 · {len(pending)} 个会话待人工处理')
else:
self._progress('协议监听中 · 等待新消息')
def _stage(self,key,stage,detail='',error=''):
with self._lock:
if key not in self._pending:return
s=self._pending[key];now=time.time();s.update(stage=stage,stage_detail=detail,stage_started_at=now,updated_at=now,last_error=error)
self._save()
self._register_assistant_review(key,s)
phase={'queued':'等待消息合并','generating':'正在生成回复','awaiting_review':'等待审核',
'sending':'正在协议发送并核对回执','receipt_check':'发送结果待人工核对',
'failed':'发送失败,待人工处理','error':'处理异常,待人工处理'}.get(stage,stage)
self._progress(f"{phase} · {s.get('display_name','')}")
self.log(f"[协议处理] {s.get('display_name','')} · {detail or phase}"+(f';{error}' if error else ''))
try:
from queue_log import QueueLog
QueueLog().append(key,s.get('display_name',''),{'queued':'入队','generating':'生成回复','awaiting_review':'待审核','sending':'发送','receipt_check':'核对回执','failed':'发送失败'}.get(stage,stage),detail or error)
except Exception:pass
@staticmethod
def _contact_anchor(message):
return {field:message.get(field) for field in
('account','conv_id','send_time','rowid','server_id','dedup_key')}
def _contact_log(self,key,event,detail):
token=(event,detail)
if self._contact_logs.get(key)==token:return
self._contact_logs[key]=token
self.log('[协议联系人核验] '+detail)
def _skip_contact(self,key,display_name,event='',reason=CONTACT_REASON,*,source=None,context=None,rejected=False):
with self._lock:
old=self._contact_blocked.get(key)
state=self._pending.get(key) or {}
source=source or (latest_contact_notice(context) if context else None) or state.get('database_event') or {}
account=source.get('account') or state.get('account') or self.account
conv_id=source.get('conv_id') or state.get('conv_id') or (old.get('conv_id') if isinstance(old,dict) else '')
anchor=self._contact_anchor(source)
rejected_at=time.time() if rejected else 0.
if isinstance(old,dict):
if contact_event_order(old.get('anchor') or {})>contact_event_order(anchor):anchor=old['anchor']
rejected_at=max(rejected_at,float(old.get('rejected_at') or 0))
self._contact_blocked[key]={'schemaVersion':2,'account':account,'conv_id':conv_id,
'anchor':anchor,'rejected_at':rejected_at,'observed_at':time.time(),'skipped_event':event}
self._pending.pop(key,None)
if event:self._seen[key]=event
self._save()
self._contact_log(key,event,f'{display_name} · {reason};继续监听,新的客户消息将重新核验')
def _contact_recheck(self,message,context,blocked,*,in_flight=False):
"""Recheck one never-attempted new question; not proof friendship returned."""
account,conv_id=self.account,message.get('conv_id')
if (not isinstance(context,dict) or context.get('account')!=account or context.get('conv_id')!=conv_id
or not real_customer_event(message,account,conv_id)):
return False,None,'客户消息身份尚未核验'
last=context.get('last_message') or {}
if last.get('dedup_key')!=message.get('dedup_key') or not real_customer_event(last,account,conv_id):
return False,None,'当前消息已变化或已经人工回复'
notice=latest_contact_notice(context)
anchor={};rejected_at=0.
if isinstance(blocked,dict):
if blocked.get('account')!=account or blocked.get('conv_id')!=conv_id:
return False,None,'联系人限制记录所属账号或会话不符'
anchor=blocked.get('anchor') or {}
rejected_at=float(blocked.get('rejected_at') or 0)
elif blocked:
# Older releases stored discovery time, which can be days after the
# actual notice. Prefer the source notice; never reset its clock.
anchor={'send_time':float(blocked),'rowid':0}
if notice and system_contact_status(notice)=='blocked':anchor=self._contact_anchor(notice)
if notice and system_contact_status(notice)=='blocked':
if contact_event_order(notice)>contact_event_order(anchor):anchor=self._contact_anchor(notice)
boundary=contact_event_order(anchor);order=contact_event_order(message)
if not boundary[0] or order<=boundary or (boundary[1] and order[1]<=boundary[1]):
return False,None,'没有晚于原联系人限制的新客户消息'
if rejected_at and order[0]<=rejected_at:
return False,None,'消息早于最近一次实际拒收,禁止重试旧问题'
restored=bool(notice and system_contact_status(notice)=='restored'
and contact_event_order(notice)>boundary and contact_event_order(notice)[0]>rejected_at)
if not restored and not current_customer_relation(context):
return False,None,'当前账号客户关系尚未核验为有效'
if not in_flight:
lookup=getattr(self.sender,'lookup',None)
if not callable(lookup):return False,None,'发送记录尚不可核验,保留原限制'
try:attempt=lookup(message['dedup_key'])
except Exception:return False,None,'发送记录读取失败,保留原限制'
if attempt is not None:return False,None,'此消息已有发送记录,禁止重发'
evidence={'account':account,'conv_id':conv_id,'anchor':anchor,'rejected_at':rejected_at}
return True,evidence,''
def _contact_context(self,state,context):
evidence=state.get('contact_retry_evidence')
if not evidence or not context:return context
boundary=contact_event_order(evidence.get('anchor') or {})
rejected_at=float(evidence.get('rejected_at') or 0)
# Old unanswerable batches and their system notices are not this new
# question. Voice/manual/risk handling sees only the eligible new batch.
messages=[message for message in context.get('messages') or []
if contact_event_order(message)>boundary
and (not boundary[1] or contact_event_order(message)[1]>boundary[1])
and (not rejected_at or contact_event_order(message)[0]>rejected_at)]
if not messages:return context
updated={**context,'messages':messages,'last_message':messages[-1]}
updated['text']=model_context_text(updated)
return updated
def _recover_blocked_contacts(self):
now=time.monotonic()
if now<self._contact_scan_at:return
self._contact_scan_at=now+5.
with self._lock:items=list(self._contact_blocked.items())
if not items:return
start=self._contact_scan_offset%len(items)
self._contact_scan_offset=start+min(2,len(items))
for offset in range(min(2,len(items))):
key,blocked=items[(start+offset)%len(items)]
with self._lock:
if key in self._pending:continue
seen=str(self._seen.get(key) or '')
if isinstance(blocked,dict):
if blocked.get('account')!=self.account:continue
conv_id=blocked.get('conv_id') or ''
else:
# Legacy hashes cannot be reversed; use their exact persisted
# dedup identity, never a display-name or another account.
prefix=self.account+':'
match=re.match(r'^(M:[0-9]+|S:[0-9]+_[0-9]+):',seen[len(prefix):]) if seen.startswith(prefix) else None
conv_id=match.group(1) if match else ''
if not contact_peer(self.account,conv_id) or session_key(self.account,conv_id)!=key:continue
if self._assistant_recipient(conv_id):continue
try:
context=self.db.get_conversation_context_by_id(self.account,conv_id,limit=500)
message=(context or {}).get('last_message') or {}
allowed,_,reason=self._contact_recheck(message,context,blocked)
if allowed:self._upsert(message,contact_recovery=True)
elif real_customer_event(message,self.account,conv_id):
self._contact_log(key,message.get('dedup_key'),f'{message.get("display_name") or conv_id} · {reason}')
except Exception as exc:
self._contact_log(key,seen,'受限会话暂不可核验,其他会话继续监听;'+type(exc).__name__)
def _skip_excluded_session(self,key,source,*,expected_state=None,reason=''):
reason=reason or _session_exclusion(source)
if not reason:return False
with self._lock:
state=self._pending.get(key)
if state is not None:
if state.get('account')!=self.account or _send_protection(state):return True
if expected_state is not None and state is not expected_state:return True
event=source.get('dedup_key') or (state or {}).get('dedup_key') or ''
changed=state is not None or (event and self._seen.get(key)!=event)
if not changed:return True
self._pending.pop(key,None)
if event:self._seen[key]=event
self._save()
self.log(f"[协议跳过] {source.get('display_name') or (state or {}).get('display_name','')} · {reason}")
return True
def _upsert(self,message,*,contact_recovery=False):
if message.get('account')!=self.account:return
key=session_key(self.account,message['conv_id']);event=message.get('dedup_key','')
# Neither new messages nor contact notices resolve an unknown send.
with self._lock:
old=self._pending.get(key)
if old and (old.get('cancel_requested') or old.get('stage') in ('cancelled','disabled','paused')):
return
if old and _send_protection(old):
# Message arrival is not a delivery receipt. Only the coordinator's
# exact new-human-message evidence may resolve a finished attempt.
self._register_assistant_review(key,old)
self._reconcile_assistant_review(key,old)
return
if self._assistant_recipient(message['conv_id']):
with self._lock:
self._pending.pop(key,None)
if event:self._seen[key]=event
self._save()
return
if old and self._assistant_attention(old):
from review_state import task_review_state
if task_review_state(old)['needsHuman'] and (old.get('assistant_review_id') or self._assistant_call('enabled')):
# Keep a manual task and its original watermark until an exact
# human reply resolves it. Additional customer messages must not
# replace the task with an automatically sendable text draft.
context=self.db.get_conversation_context_by_id(self.account,message['conv_id'],limit=500)
self._register_assistant_review(key,old,context)
if self._reconcile_assistant_review(key,old,context):return
with self._lock:
if self._pending.get(key) is old and event and not message.get('is_self'):
self._seen[key]=event;self._save()
return
if old and message.get('is_self'):
context=self.db.get_conversation_context_by_id(self.account,message['conv_id'],limit=500)
if self._reconcile_assistant_review(key,old,context):return
if self._assistant_pending(old):return
if self._skip_excluded_session(key,message):return
status=system_contact_status(message)
if status=='blocked':
self._skip_contact(key,message.get('display_name',''),event,source=message);return
with self._lock:
if status=='restored':
self._contact_blocked.pop(key,None)
self._seen[key]=event;self._save();return
blocked=self._contact_blocked.get(key)
retry_evidence=None
if blocked:
if message.get('is_self'):return
if old and self._assistant_attention(old):
self._contact_log(key,event,f'{message.get("display_name") or message["conv_id"]} · 原任务仍待人工处理,联系人重新核验不会批准原任务')
return
context=self.db.get_conversation_context_by_id(self.account,message['conv_id'],limit=500)
allowed,retry_evidence,reason=self._contact_recheck(message,context,blocked)
if not allowed:
self._contact_log(key,event,f'{message.get("display_name") or message["conv_id"]} · {reason}')
return
self._contact_log(key,event,f'{message.get("display_name") or message["conv_id"]} · 当前客户关系有效,对新的客户消息重新生成并风控;不重试旧回复')
with self._lock:
old=self._pending.get(key)
if old and _send_protection(old):return
if message.get('is_self'):
if old:self._pending.pop(key,None);self._save()
return
if not event or (self._seen.get(key)==event and not (contact_recovery and retry_evidence)):return
now=time.time();self._seen[key]=event
self._pending[key]={'transport':'protocol','account':self.account,'conv_id':message['conv_id'],'display_name':message.get('display_name') or message['conv_id'],
'dedup_key':event,'created_at':old.get('created_at',now) if old else now,'updated_at':now,'ready_at':now+self.message_batch_window_seconds,
'stage':'queued','stage_started_at':now,'send_state':'','chat_text':'','last_lines':[message.get('content','')],'staged_user_text':message.get('content',''),
'detected_by':'protocol_db','detection_ts':now,'reply_text':'','awaiting_review':False,'content_type':message.get('content_type'),
'database_event':dict(message),'assistant_resume_evidence':message.get('assistant_resume_evidence'),
'contact_retry_evidence':retry_evidence}
self._save()
self.log(f"[协议收信] {message.get('display_name') or message['conv_id']} · 已加入回复队列")
self._progress('协议已收到新消息 · 等待处理')
def _isolate_task_error(self,key,error,expected_state=None):
"""Keep a failed conversation out of automatic replay, without stopping peers."""
detail='本会话处理异常,其他会话继续监听'
stage='error'
with self._lock:
state=self._pending.get(key)
if state is not None and state.get('account')==self.account and (
expected_state is None or state is expected_state):
protection=_send_protection(state)
if protection in ('sending','uncertain'):
state['send_state']='uncertain';self.needs_attention=True
stage='receipt_check'
detail='本会话发送结果待人工核对,其他会话继续监听'
elif protection=='failed':
stage='failed'
try:self._stage(key,stage,detail,str(error))
except Exception:
# _stage updates memory before persisting. A storage/logging
# failure must not let this conversation send again in this run.
state.update(stage=stage,last_error=str(error),stage_detail=detail)
try:self.log(f'[协议会话异常] {detail};{type(error).__name__}: {error}')
except Exception:pass
def poll_once(self):
if self.stop_event.is_set():return
try:self._flush_assistant_cancellations()
except Exception as exc:self._isolate_task_error(None,exc)
messages=self.db.get_new_messages(self._since)
# A malformed row/task belongs to one conversation, not the whole listener.
incoming=[]
for message in messages:
try:
if not isinstance(message,dict):raise ValueError('消息记录格式错误')
if message.get('account')!=self.account:continue
if not isinstance(message.get('conv_id'),str) or not message['conv_id']:
raise ValueError('消息缺少会话标识')
incoming.append((int(message.get('rowid',0)),message))
except Exception as exc:self._isolate_task_error(None,exc)
# Row order is authoritative even for late writes or equal timestamps.
for _,message in sorted(incoming,key=lambda item:item[0]):
if self.stop_event.is_set():return
key=session_key(self.account,message['conv_id'])
try:self._upsert(message)
except Exception as exc:self._isolate_task_error(key,exc)
self._recover_blocked_contacts()
def created_at(state):
try:return float(state.get('created_at',0) or 0)
except (TypeError,ValueError):return 0
with self._lock:tasks=sorted(self._pending.items(),key=lambda item:created_at(item[1]))
for key,state in tasks:
if self.stop_event.is_set():break
try:self._process(key)
except Exception as exc:self._isolate_task_error(key,exc,expected_state=state)
if self.stop_event.is_set():return
self._assistant_call('enqueue_notifications',default=None)
self._completed_polls+=1
self._listening_progress()
now=time.monotonic()
if now-self._last_poll_log>=60:
self._last_poll_log=now
with self._lock:pending=sum(s.get('account')==self.account for s in self._pending.values())
self.log(f'[协议监听] 消息库轮询正常;已检查 {self._completed_polls} 轮,待处理 {pending} 个会话,已确认发送 {self.reply_count} 条。')
def _current(self,key,state,*,excluded=None,before_dispatch=False):
with self._lock:
if self.stop_event.is_set() or self._pending.get(key) is not state:return False
if self._assistant_recipient(state['conv_id']):
if excluded is not None:excluded.append('配置的医疗助理会话不参与自动回复')
return False
if self._skip_excluded_session(key,state,expected_state=state):
if excluded is not None:excluded.append(_session_exclusion(state))
return False
context=self.db.get_conversation_context_by_id(self.account,state['conv_id'],limit=100)
context=self._assistant_context(state,context)
for source in (context or {},(context or {}).get('last_message') or {}):
if self._skip_excluded_session(key,source,expected_state=state):
if excluded is not None:excluded.append(_session_exclusion(source))
return False
if self._reconcile_assistant_review(key,state,context):return False
last=(context or {}).get('last_message')
if not last:return False
if last.get('is_self') and self._assistant_pending(state):return False
if last.get('is_self') or last.get('dedup_key')!=state['dedup_key']:
# A new customer message invalidates an earlier draft and its approval.
with self._lock:
if self._pending.get(key) is state and state.get('send_state')!='uncertain':
state['send_state']='';self._pending.pop(key,None)
self._seen.pop(key,None);self._save()
self._upsert(last);return False
blocked=self._contact_blocked.get(key) or state.get('contact_retry_evidence')
if blocked or conversation_contact_reason(context):
allowed,evidence,reason=self._contact_recheck(last,context,blocked,in_flight=before_dispatch and state.get('send_state')=='sending')
if not allowed:
self._skip_contact(key,state.get('display_name',''),state['dedup_key'],reason,context=context);return False
if state.get('contact_retry_evidence')!=evidence:
with self._lock:state['contact_retry_evidence']=evidence;self._save()
self._contact_log(key,state['dedup_key'],f'{state.get("display_name", "")} · 当前客户关系有效,对新的客户消息重新生成并风控;不重试旧回复')
context=self._contact_context(state,context)
voice_fingerprint=_voice_batch_fingerprint(context)
frozen_voice=state.get('voice_batch_fingerprint')
if ((frozen_voice and frozen_voice!=voice_fingerprint)
or (voice_fingerprint and state.get('reply_text') and not frozen_voice)):
with self._lock:
if self._pending.get(key) is not state:return False
protection=_send_protection(state)
# The explicit final sender guard runs before native dispatch.
# Never rewrite uncertain/failed delivery evidence on a cache change.
if protection and not (protection=='sending' and before_dispatch):return False
state.update(reply_text='',staged_reply_text='',approved=False,
judge_risk='',judge_mode='',model_task_id='',send_ready_at=0)
state.pop('voice_batch_fingerprint',None)
if not (state.get('manual_required') or state.get('cancel_requested')
or state.get('foreign_draft_text') or state.get('foreign_draft_at') is not None
or state.get('stage') in ('error','manual_takeover','cancelled','disabled','paused')):
now=time.time()
state.update(stage='queued',stage_detail='语音转文字内容已更新,等待重新生成回复',
stage_started_at=now,updated_at=now,ready_at=now)
# Keep independent manual/review flags. A new answer must pass the
# active review policy again; an approval never covers changed text.
self._save()
return False
return True
def _process(self,key):
with self._lock:state=self._pending.get(key)
if not state or state.get('account')!=self.account:return
if self._assistant_cancellation_pending(state) or state.get('cancel_requested') or state.get('stage') in ('cancelled','disabled','paused'):return
self._register_assistant_review(key,state)
if self._reconcile_assistant_review(key,state):return
if _send_protection(state):return
if self._assistant_recipient(state['conv_id']):
with self._lock:
if self._pending.get(key) is state:self._pending.pop(key,None);self._save()
return
if self._skip_excluded_session(key,state,expected_state=state):return
if state.get('stage') in ('error','manual_takeover'):return
if state.get('manual_required') or state.get('foreign_draft_text') or state.get('foreign_draft_at') is not None:return
if time.time()<float(state.get('ready_at',0)):return
if not self._current(key,state):return
# One extra row detects a current customer batch cut by the 100-message window.
context=self.db.get_conversation_context_by_id(self.account,state['conv_id'],limit=101)
context=self._assistant_context(state,context)
context=self._contact_context(state,context)
for source in (context or {},(context or {}).get('last_message') or {}):
if self._skip_excluded_session(key,source,expected_state=state):return
from voice_messages import voice_batch_status, unanswered_messages
voice = voice_batch_status(context)
manual_reason = ''
if voice['status'] == 'pending':
now = time.time()
with self._lock:
if state.get('voice_wait_key') != voice['key']:
state.update(voice_wait_key=voice['key'], voice_wait_started_at=now)
waited = now - float(state.get('voice_wait_started_at') or now)
if waited < 180:
state.update(ready_at=now+2, reply_text='', staged_reply_text='')
if waited < 180:
self._stage(key,'voice_transcribing',voice['reason']);return
manual_reason='语音转文字等待超时,尚未取得可核验文字,请人工处理'
elif voice['status'] == 'error':
manual_reason=voice['reason']
else:
state.pop('voice_wait_key',None);state.pop('voice_wait_started_at',None)
manual_reason=manual_reason or _unanswered_manual_reason(context)
current=(context or {}).get('last_message') or {}
if current.get('dedup_key')!=state.get('dedup_key') or not _is_text_message(current):
manual_reason=manual_reason or MANUAL_NON_TEXT_REASON
if not manual_reason:
# The current question/risk check and archive receive the same transcribed batch as the model.
batch=unanswered_messages(context)
state.update(staged_user_text='\n'.join(str(m.get('content') or '') for m in batch),
last_lines=[str(m.get('content') or '') for m in batch])
if manual_reason:
lines=[]
for message in reversed((context or {}).get('messages') or []):
if message.get('is_self') or system_contact_status(message)=='restored':break
lines.append(str(message.get('content') or '(非文本消息)'))
if len(lines)>=101:break
with self._lock:
state.update(reply_text='',staged_reply_text='',approved=False,awaiting_review=False,
review_reason='',manual_required=True,manual_reason=manual_reason,
chat_text=str((context or {}).get('text') or ''),last_lines=list(reversed(lines)))
self._stage(key,'error','当前消息需要人工处理',manual_reason);return
if not state.get('reply_text'):
with self._lock:state['voice_batch_fingerprint']=_voice_batch_fingerprint(context)
self._stage(key,'generating','根据当前会话生成回复')
try:
answer=self.generate(context)
if not isinstance(answer,str) or not answer.strip() or answer.strip().startswith(('__VISION_', '__NO_INCOMING_MESSAGE__', '__VOICE_NOT_TRANSCRIBED__')):raise ValueError('模型未返回有效文本')
except Exception as exc:self._stage(key,'error','模型生成失败,可重试',str(exc));return
if not self._current(key,state):return
with self._lock:
state.update(reply_text=answer.strip(),staged_reply_text=answer.strip(),chat_text=context['text'],send_ready_at=time.time()+self.send_delay_seconds)
self._save()
review,reason=self._needs_review(state)
if review and not state.get('approved'):
if not state.get('awaiting_review') or state.get('review_reason')!=reason:
with self._lock:state.update(awaiting_review=True,review_reason=reason,manual_required=False,manual_reason='')
self._stage(key,'awaiting_review',reason)
return
if not review and (state.get('awaiting_review') or state.get('review_reason')):
with self._lock:state.update(awaiting_review=False,review_reason='')
self._stage(key,'ready_to_send','审核策略已更新,等待发送前核对')
if time.time()<float(state.get('send_ready_at',0)):return
if not self._current(key,state):return
import send_lock
if not send_lock.try_acquire('protocol:'+str(threading.get_ident()),timeout=0):return
try:
if not self._current(key,state):return
self._send_ready(key,state)
finally:send_lock.release()
def _send_ready(self,key,state):
with self._lock:
if self._pending.get(key) is not state:return
state.update(send_state='sending',awaiting_review=False)
self._stage(key,'sending','原账号单聊发送')
guard_exclusions=[]
try:
receipt=self.sender.send(state['dedup_key'],self.pid,self.account,state['conv_id'],state['reply_text'],guard=lambda:self._current(key,state,excluded=guard_exclusions,before_dispatch=True))
except SendCancelled:
with self._lock:
if self._pending.get(key) is state:state.update(send_state='',stage='queued',stage_detail='发送前检查已取消,等待重新核对');self._save()
# SendCancelled proves the native call did not start. Only now may an
# exclusion discovered by the final sender guard remove this draft.
if guard_exclusions:self._skip_excluded_session(key,state,expected_state=state,reason=guard_exclusions[-1])
return
except DeliveryUnknown as exc:
# Keep the send journal's unknown outcome, but a proven contact restriction
# isolates this conversation instead of blocking all other customers.
try:context=self.db.get_conversation_context_by_id(self.account,state['conv_id'],limit=100)
except Exception:context=None
notice=latest_contact_notice(context)
if (notice and system_contact_status(notice)=='blocked'
and contact_event_order(notice)>contact_event_order(state.get('database_event') or {})):
self._skip_contact(key,state.get('display_name',''),state['dedup_key'],source=notice,rejected=True);return
with self._lock:state['send_state']='uncertain';self.needs_attention=True
self._stage(key,'receipt_check','本会话发送结果待人工核对,其他会话继续监听',str(exc));return
except DeliveryFailed as exc:
if rejected_contact_reason(str(exc)):
self._skip_contact(key,state.get('display_name',''),state['dedup_key'],str(exc),source=state.get('database_event'),rejected=True);return
with self._lock:state['send_state']='failed'
self._stage(key,'failed','客户端拒绝发送,需人工核对',str(exc));return
except NativeUnavailable as exc:
with self._lock:state['send_state']=''
self._stage(key,'error','发送前检查失败',str(exc));return
except Exception as exc:
# A callback or journal failure can escape after delivery. Do not
# turn an attempted send into a retryable task based on its type.
with self._lock:state['send_state']='uncertain';self.needs_attention=True
self._stage(key,'receipt_check','本会话发送结果待人工核对,其他会话继续监听',str(exc));return
self._assistant_call('record_automated_send',state,receipt,default=None)
self.reply_count+=1
self.log(f"[协议发送成功] {state['display_name']} · 服务器消息 {receipt['serverId']}")
try:
from conversation_store import ConversationStore
from queue_log import QueueLog
store=ConversationStore(str(application_data_dir()/'conversations.json'))
store.set_display_name(key,state['display_name'])
store.append_exchange_once(key,state['staged_user_text'],state['reply_text'],state['dedup_key'])
QueueLog().append(key,state['display_name'],'发送成功',f"协议回执 {receipt['serverId']},原账号及文本已核对")
except Exception as exc:self.log('[协议归档] '+str(exc))
with self._lock:
if self._pending.get(key) is state:self._pending.pop(key,None)
if state.get('contact_retry_evidence'):self._contact_blocked.pop(key,None)
self._save()
def _needs_review(self,state):
import ai_config
from review_policy import selective_review_reason
reason=selective_review_reason(state,state.get('staged_user_text',''),state.get('reply_text',''),
rules=getattr(ai_config,'AI_REVIEW_RULES',[]) or [])
if reason:return True,reason
if self._assistant_pending(state):
return True,state.get('review_reason') or '本会话仍有待处理审核,请由医疗助理回复客户'
if self.send_mode=='review' and not self._assistant_call('resumed',state.get('conv_id','')):
return True,'人工审核后点击“通过并发送”'
return False,''
def _generate(self,context):
import ai_config,ai_chat,model_router,uuid
key=session_key(self.account,context['conv_id']);task_id=uuid.uuid4().hex
with self._lock:
generation_state=self._pending.get(key)
if generation_state is not None:
generation_state.update(model_task_id=task_id,judge_risk='',judge_mode='')
if not ai_config.AI_ENABLED:return self.reply_text
context={**context,'text':model_context_text(context)}
if ai_chat.gateway_provider() is not None:
ai_chat.take_last_gateway_trace()
reply=ai_chat.get_ai_reply(chat_text=context['text'])
trace=ai_chat.take_last_gateway_trace()
key=session_key(self.account,context['conv_id'])
with self._lock:
if self._pending.get(key) is generation_state and generation_state is not None and generation_state.get('model_task_id')==task_id:
self._pending[key]['judge_risk']=str((trace.get('judge') or {}).get('risk') or '')
self._pending[key]['judge_mode']=str(trace.get('judge_mode') or '').strip().lower()
self._pending[key]['knowledge_trace']=trace.get('knowledge') or {}
self._pending[key]['model_task_id']=trace.get('task_id') or ''
return reply
provider=ai_chat.current_provider();judge=provider if getattr(ai_config,'AI_JUDGE_ENABLED',False) else None
if judge is None:return ai_chat.get_ai_reply(chat_text=context['text'])
from voice_messages import unanswered_messages
customer_text='\n'.join(str(message.get('content') or '') for message in unanswered_messages(context))
result=model_router.answer(chat_text=context['text'],answer_providers=[provider],judge_provider=judge,judge_mode=getattr(ai_config,'AI_JUDGE_MODE','shadow'),customer_text=customer_text)
key=session_key(self.account,context['conv_id'])
with self._lock:
if self._pending.get(key) is generation_state and generation_state is not None and generation_state.get('model_task_id')==task_id:
generation_state.update(judge_risk=str((result.get('judge') or {}).get('risk') or ''),
judge_mode=str(result.get('judge_mode') or '').strip().lower())
return result.get('reply','')
def _control(self,keys,action):
removed=[]
result={'deleted':[],'retried':[],'approved':[],'missing':[],'protected':[],'not_retryable':[],'not_pending':[],'scheduled':[]}
with self._lock:
previous_pending=dict(self._pending);previous_cancellations=dict(self._assistant_cancellations)
for key in keys:
s=self._pending.get(key)
if s is None:result['missing'].append(key);continue
if s.get('account')!=self.account:result['protected'].append(key);continue
if _send_protection(s):result['protected'].append(key);continue
if action=='delete':
self._queue_assistant_cancellation(s)
self._pending.pop(key,None);result['deleted'].append(key);removed.append(s)
elif action=='approve':
if s.get('awaiting_review') and s.get('reply_text') and not s.get('manual_required'):
now=time.time()
s.update(approved=True,awaiting_review=False,review_reason='',stage='ready_to_send',
stage_detail='审核已通过,等待发送前核对',stage_started_at=now,updated_at=now)
result['approved'].append(key)
else:result['not_pending'].append(key)
elif action=='retry':
if s.get('stage')=='error':
now=time.time()
s.update(stage='queued',stage_detail='等待重新处理',stage_started_at=now,updated_at=now,
last_error='',ready_at=now,reply_text='',staged_reply_text='',approved=False,
awaiting_review=False,review_reason='',manual_required=False,manual_reason='',
judge_risk='',judge_mode='',model_task_id='')
result['retried'].append(key)
else:result['not_retryable'].append(key)
try:self._save()
except Exception:
if action=='delete':
self._pending=previous_pending;self._assistant_cancellations=previous_cancellations
raise
self._reply_wakeup.set()
self._flush_assistant_cancellations()
for state in removed:
if not state.get('assistant_review_id'):self._assistant_call('cancel_review',state)
return result
def handoff_pending_replies(self, keys, *, confirm_uncertain=False):
"""Explicitly retire selected work to a human without claiming delivery.
Uncertain receipts remain in the send journal and the inbound dedup key
stays seen. No send/retry is scheduled by this action.
"""
result={'handed_off':[], 'deleted':[], 'missing':[], 'protected':[], 'scheduled':[], 'requires_confirmation':[]}
removed=[]
with self._lock:
previous_pending=dict(self._pending);previous_seen=dict(self._seen)
previous_handoffs=dict(self._manual_handoffs);previous_attention=self.needs_attention
previous_cancellations=dict(self._assistant_cancellations)
for key in dict.fromkeys(str(k).strip() for k in keys if str(k).strip()):
state=self._pending.get(key)
if state is None:result['missing'].append(key);continue
protection=_send_protection(state)
if state.get('account')!=self.account or protection=='sending':
result['protected'].append(key);continue
if protection=='uncertain' and not confirm_uncertain:
result['protected'].append(key);result['requires_confirmation'].append(key);continue
snapshot=json.loads(json.dumps(state,ensure_ascii=False))
self._manual_handoffs[key]={'state':snapshot,'handed_off_at':time.time(),
'receipt_checked_by_user':bool(confirm_uncertain),
'delivery_confirmed':False}
if state.get('dedup_key'):self._seen[key]=state['dedup_key']
self._queue_assistant_cancellation(state)
self._pending.pop(key,None)
result['handed_off'].append(key);result['deleted'].append(key)
removed.append((key,state))
self.needs_attention=any(s.get('account')==self.account and _send_protection(s)=='uncertain'
for s in self._pending.values())
try:self._save()
except Exception:
self._pending=previous_pending;self._seen=previous_seen
self._manual_handoffs=previous_handoffs;self.needs_attention=previous_attention
self._assistant_cancellations=previous_cancellations
raise
self._reply_wakeup.set()
self._flush_assistant_cancellations()
for key,state in removed:
if not state.get('assistant_review_id'):self._assistant_call('cancel_review',state)
self.log(f"[协议转人工] {state.get('display_name','')} · 自动队列已移交人工;发送结果未作确认,不自动重发")
try:
from queue_log import QueueLog
QueueLog().append(key,state.get('display_name',''),'转人工',
'用户已确认人工接管;保留发送流水和去重记录,不自动重发')
except Exception:pass
self._listening_progress()
return result
def cancel_pending_replies(self,keys):return self._control(keys,'delete')
def retry_pending_replies(self,keys):return self._control(keys,'retry')
def approve_pending_replies(self,keys):return self._control(keys,'approve')
def close(self):
if self._review_assistant is not None:
try:self._review_assistant.close()
except Exception:pass
self.sender.close()
if self._owned_db:self.db.close()