967 lines
60 KiB
Python
967 lines
60 KiB
Python
"""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() |