feat: enforce full AI outbound generation and reduce template replies

This commit is contained in:
2026-03-02 11:09:26 +08:00
parent 6433708597
commit 9d0276be41
5 changed files with 415 additions and 34 deletions

View File

@@ -70,10 +70,9 @@ class AgentPreRuleService:
) )
async def _rule_pred_meaningless_short_text(self, ctx: RuleContext) -> bool: async def _rule_pred_meaningless_short_text(self, ctx: RuleContext) -> bool:
from core.pydantic_ai_agent import _is_meaningless_short_text
message = ctx.get("message") message = ctx.get("message")
return _is_meaningless_short_text(message.msg) state = ctx.get("state")
return self.agent._should_handle_as_meaningless_short_text(state, message.msg)
async def _rule_act_meaningless_short_text(self, ctx: RuleContext) -> RuleResult: async def _rule_act_meaningless_short_text(self, ctx: RuleContext) -> RuleResult:
from core.pydantic_ai_agent import AgentResponse from core.pydantic_ai_agent import AgentResponse

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Optional, Tuple
from core.post_ops import negotiation_strategy_reply from core.post_ops import negotiation_strategy_reply
logger = logging.getLogger("cs_agent") logger = logging.getLogger("cs_agent")
@@ -10,7 +10,43 @@ if TYPE_CHECKING:
from core.pydantic_ai_agent import AgentDeps, ConversationState, CustomerMessage, CustomerServiceAgent from core.pydantic_ai_agent import AgentDeps, ConversationState, CustomerMessage, CustomerServiceAgent
def select_target_agent(agent: "CustomerServiceAgent", message: "CustomerMessage", state: "ConversationState"): def _select_agent_by_intent(
agent: "CustomerServiceAgent",
message: "CustomerMessage",
state: "ConversationState",
) -> Tuple[Optional[Any], str]:
"""
AI 意图优先路由;识别不到时返回 (None, "intent:none"),由关键词兜底。
"""
try:
from utils.intent_analyzer import detect_intent
decision = detect_intent(message.msg or "")
intent = (decision.intent or "").strip()
source = decision.source or "none"
score = float(decision.score or 0.0)
except Exception:
intent, source, score = "", "error", 0.0
if not intent:
return None, "intent:none"
if intent in ("询价", "砍价"):
return agent.agent_pricing, f"intent:{intent}|src:{source}|score:{score:.3f}"
if intent in ("修改", "加急"):
return agent.agent_processing, f"intent:{intent}|src:{source}|score:{score:.3f}"
if intent == "售后":
return agent.agent_after_sale, f"intent:{intent}|src:{source}|score:{score:.3f}"
if intent == "转接":
return agent.agent_after_sale, f"intent:{intent}|src:{source}|score:{score:.3f}"
if intent in ("打招呼", "批量", "发图"):
target = agent.agent_after_sale if state.stage == "售后" else agent.agent
return target, f"intent:{intent}|src:{source}|score:{score:.3f}"
return None, f"intent:unmapped:{intent}|src:{source}|score:{score:.3f}"
def select_target_agent(agent: "CustomerServiceAgent", message: "CustomerMessage", state: "ConversationState") -> Tuple[Any, str]:
msg_lower = message.msg.lower() msg_lower = message.msg.lower()
pricing_kw = ["多少钱", "多少一张", "报价", "给个价", "几块", "价位", "能便宜点吗"] pricing_kw = ["多少钱", "多少一张", "报价", "给个价", "几块", "价位", "能便宜点吗"]
processing_kw = ["安排", "处理一下", "开始做", "做一下", "尽快", "加急", "付款了", "已付款"] processing_kw = ["安排", "处理一下", "开始做", "做一下", "尽快", "加急", "付款了", "已付款"]
@@ -45,18 +81,23 @@ def select_target_agent(agent: "CustomerServiceAgent", message: "CustomerMessage
"卫星地图", "卫星地图",
] ]
target_agent = agent.agent_after_sale if state.stage == "售后" else agent.agent target_agent = agent.agent_after_sale if state.stage == "售后" else agent.agent
ai_target, ai_reason = _select_agent_by_intent(agent, message, state)
if ai_target is not None:
return ai_target, ai_reason
risk_hit = any(k in msg_lower for k in risk_kw) or agent._is_political_inquiry(message.msg) or agent._is_map_inquiry(message.msg) risk_hit = any(k in msg_lower for k in risk_kw) or agent._is_political_inquiry(message.msg) or agent._is_map_inquiry(message.msg)
if risk_hit: if risk_hit:
return agent.agent_risk return agent.agent_risk, "keyword:risk"
if any(k in message.msg for k in order_markers): if any(k in message.msg for k in order_markers):
return agent.agent_order return agent.agent_order, "keyword:order"
if any(k in msg_lower for k in processing_kw): if any(k in msg_lower for k in processing_kw):
return agent.agent_processing return agent.agent_processing, "keyword:processing"
if any(k in msg_lower for k in pricing_kw): if any(k in msg_lower for k in pricing_kw):
return agent.agent_pricing return agent.agent_pricing, "keyword:pricing"
if any(k in msg_lower for k in similar_kw): if any(k in msg_lower for k in similar_kw):
return agent.agent_similar return agent.agent_similar, "keyword:similar"
return target_agent return target_agent, "fallback:default"
async def execute_ai_turn( async def execute_ai_turn(
@@ -68,7 +109,8 @@ async def execute_ai_turn(
deps: "AgentDeps", deps: "AgentDeps",
history: list, history: list,
) -> str: ) -> str:
target_agent = select_target_agent(agent, message, state) target_agent, route_reason = select_target_agent(agent, message, state)
logger.info("[路由] %s", route_reason)
result = await target_agent.run(user_prompt, deps=deps, message_history=history) result = await target_agent.run(user_prompt, deps=deps, message_history=history)
agent.message_histories[message.from_id] = result.all_messages()[-30:] agent.message_histories[message.from_id] = result.all_messages()[-30:]
reply_text = agent._colloquialize_reply(agent._normalize_reply_text(result.output)) reply_text = agent._colloquialize_reply(agent._normalize_reply_text(result.output))

View File

@@ -490,23 +490,9 @@ class CustomerServiceAgent:
""" """
收图阶段回复默认走 AI 改写,失败时回退到固定模板。 收图阶段回复默认走 AI 改写,失败时回退到固定模板。
""" """
# 首张收图先承接“我看一下”,避免机械地立刻催“发完统一报价”。 first_image_ack = "收到,我先看一下哈,稍等哈。"
if scene == "collect_ack" and len(state.pending_image_urls) == 1: if scene == "collect_ack" and len(state.pending_image_urls) == 1:
first_ack = [ fallback = first_image_ack
"收到了,我先看一下哈,稍等哈",
"这张我收到了,我先看下,稍等我一下哈",
"收到这张了,我先过一眼,稍等哈",
"我先看这张哈,稍等我一下",
"图我收到了,我先看一眼,稍等我回你哈",
"这张先记上了,我先看下细节,稍等哈",
"收到哈,我先过一遍这张,稍等我会儿",
"我先看这张效果,稍等一下哈",
"图到了,我先看下清晰度,稍等哈",
"这张我先看着,稍等我一下就回你",
"收到这张了,我先核一下细节,稍等哈",
"我先把这张看完,稍等我一会儿哈",
]
return random.choice(first_ack)
if not self.dynamic_collection_replies: if not self.dynamic_collection_replies:
return fallback return fallback
try: try:
@@ -525,7 +511,7 @@ class CustomerServiceAgent:
f"客户原话: {message.msg}\n" f"客户原话: {message.msg}\n"
f"当前已收图片数: {len(state.pending_image_urls)}\n" f"当前已收图片数: {len(state.pending_image_urls)}\n"
f"当前需求摘要: {pending_req}\n" f"当前需求摘要: {pending_req}\n"
"输出要求: 不超过2句话像真人店主聊天。" "输出要求: 不超过2句话像真人店主聊天;避免复用固定模板句"
) )
result = await self.agent_natural_reply.run(user_prompt, deps=deps, message_history=history) result = await self.agent_natural_reply.run(user_prompt, deps=deps, message_history=history)
self.message_histories[message.from_id] = result.all_messages()[-30:] self.message_histories[message.from_id] = result.all_messages()[-30:]
@@ -695,6 +681,44 @@ class CustomerServiceAgent:
clean = msg.strip().rstrip("!?。.~") clean = msg.strip().rstrip("!?。.~")
return clean in self._COOLDOWN_PATTERNS return clean in self._COOLDOWN_PATTERNS
def _should_handle_as_meaningless_short_text(self, state: ConversationState, msg: str) -> bool:
"""
无意义短句仅在“非业务处理中”生效,避免误拦截真实推进消息。
例如:已在收图/待报价阶段时,客户发“好的/在吗”不应直接 ping。
"""
customer_text, _ = self._split_customer_text(msg or "")
text = (customer_text or "").strip()
if not _is_meaningless_short_text(text):
return False
if self._extract_image_urls(text):
return False
if (getattr(state, "pending_image_urls", None) or []):
return False
if getattr(state, "quote_phase", "idle") in {"collecting", "ready_to_quote", "waiting_result"}:
return False
return True
async def build_auto_quote_reply(self, state: ConversationState, message: CustomerMessage) -> AgentResponse:
"""
自动报价内部入口:不走 process_message避免伪造客户语句污染上下文。
"""
quote_res = await self._quote_pending_images(state, message)
reply_text = self._colloquialize_reply(quote_res.get("reply", ""))
reply_text = await self._rewrite_reply_with_ai(
message=message,
state=state,
reply=reply_text,
scene="batch_quote_reply",
)
need_transfer = bool(quote_res.get("need_transfer"))
state.last_reply_at = datetime.now()
return AgentResponse(
reply=reply_text,
should_reply=not need_transfer,
need_transfer=need_transfer,
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
)
async def process_message(self, message: CustomerMessage) -> AgentResponse: async def process_message(self, message: CustomerMessage) -> AgentResponse:
"""处理客户消息并生成回复。""" """处理客户消息并生成回复。"""
return await process_incoming_message(self, message) return await process_incoming_message(self, message)

View File

@@ -8,7 +8,7 @@ import secrets
import time import time
import hashlib import hashlib
from collections import deque from collections import deque
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from utils.observability import emit_activity, build_trace_id from utils.observability import emit_activity, build_trace_id
@@ -170,6 +170,8 @@ class QingjianAPIClient:
self._last_reply_sent_at: dict = {} # customer_key -> monotonic ts self._last_reply_sent_at: dict = {} # customer_key -> monotonic ts
self._outbound_semantic_seen: dict = {} # customer_key -> {semantic_key: ts} self._outbound_semantic_seen: dict = {} # customer_key -> {semantic_key: ts}
self._outbound_class_seen: dict = {} # customer_key -> {reply_class: ts} self._outbound_class_seen: dict = {} # customer_key -> {reply_class: ts}
self._outbound_template_seen: dict = {} # customer_key -> {template_family: ts}
self._unreplied_followup_sent: dict = {} # customer_key -> monotonic ts补偿消息节流
self._inbound_log_seen: dict = {} # signature -> monotonic ts防重复写入 self._inbound_log_seen: dict = {} # signature -> monotonic ts防重复写入
self._outbound_log_seen: dict = {} # signature -> monotonic ts防重复写入 self._outbound_log_seen: dict = {} # signature -> monotonic ts防重复写入
self._tianwang_callback_url = ( self._tianwang_callback_url = (
@@ -179,6 +181,7 @@ class QingjianAPIClient:
self._tianwang_agent_name = os.getenv("TIANWANG_AGENT_NAME", "终结者").strip() or "终结者" self._tianwang_agent_name = os.getenv("TIANWANG_AGENT_NAME", "终结者").strip() or "终结者"
self._reply_guard_enabled = os.getenv("AI_REPLY_GUARD_ENABLED", "true").lower() in ("1", "true", "yes") self._reply_guard_enabled = os.getenv("AI_REPLY_GUARD_ENABLED", "true").lower() in ("1", "true", "yes")
self._reply_guard_verbose = os.getenv("AI_REPLY_GUARD_VERBOSE", "false").lower() in ("1", "true", "yes") self._reply_guard_verbose = os.getenv("AI_REPLY_GUARD_VERBOSE", "false").lower() in ("1", "true", "yes")
self._force_ai_generate_reply = os.getenv("FORCE_AI_GENERATE_ALL_REPLIES", "true").lower() in ("1", "true", "yes")
# 延迟加载任务模块(避免循环导入) # 延迟加载任务模块(避免循环导入)
self.task_scheduler = None self.task_scheduler = None
@@ -429,6 +432,22 @@ class QingjianAPIClient:
return "ack" return "ack"
return "general" return "general"
@staticmethod
def _template_family(reply: str) -> str:
"""识别高频模板家族,做疲劳抑制。"""
s = (reply or "").strip()
if not s:
return ""
if "需求我记上了" in s and "继续发图" in s:
return "collect_remind"
if ("这批图过一遍" in s or "收齐了" in s or "收好了" in s) and ("总价" in s or "报价" in s):
return "quote_defer"
if "图片收到了" in s and "继续发" in s:
return "collect_ack"
if "好嘞,你稍等下,我这边看一下" in s:
return "fallback_ack"
return ""
def _outbound_arbiter(self, original_msg: dict, reply_content: str, trace_id: str) -> tuple[bool, str]: def _outbound_arbiter(self, original_msg: dict, reply_content: str, trace_id: str) -> tuple[bool, str]:
""" """
统一出站裁决层: 统一出站裁决层:
@@ -447,11 +466,17 @@ class QingjianAPIClient:
class_window = max(20, int(os.getenv("AI_OUTBOUND_CLASS_DEDUPE_SECONDS", "90"))) class_window = max(20, int(os.getenv("AI_OUTBOUND_CLASS_DEDUPE_SECONDS", "90")))
except Exception: except Exception:
class_window = 90 class_window = 90
try:
template_window = max(120, int(os.getenv("AI_OUTBOUND_TEMPLATE_FATIGUE_SECONDS", "600")))
except Exception:
template_window = 600
sem_bucket = self._outbound_semantic_seen.setdefault(key, {}) sem_bucket = self._outbound_semantic_seen.setdefault(key, {})
cls_bucket = self._outbound_class_seen.setdefault(key, {}) cls_bucket = self._outbound_class_seen.setdefault(key, {})
tpl_bucket = self._outbound_template_seen.setdefault(key, {})
self._prune_seen(sem_bucket, now_mono, ttl_sec=max(sem_window * 2, 240)) self._prune_seen(sem_bucket, now_mono, ttl_sec=max(sem_window * 2, 240))
self._prune_seen(cls_bucket, now_mono, ttl_sec=max(class_window * 2, 180)) self._prune_seen(cls_bucket, now_mono, ttl_sec=max(class_window * 2, 180))
self._prune_seen(tpl_bucket, now_mono, ttl_sec=max(template_window * 2, 1200))
if sem_key and (now_mono - sem_bucket.get(sem_key, 0.0)) < sem_window: if sem_key and (now_mono - sem_bucket.get(sem_key, 0.0)) < sem_window:
self._activity_log( self._activity_log(
@@ -466,6 +491,19 @@ class QingjianAPIClient:
) )
return False, "semantic_duplicate" return False, "semantic_duplicate"
template_family = self._template_family(reply_content)
if template_family and (now_mono - tpl_bucket.get(template_family, 0.0)) < template_window:
self._activity_log(
"outbound_arbiter_block",
trace_id=trace_id,
acc_id=original_msg.get("acc_id", ""),
customer_id=original_msg.get("from_id", ""),
reason="template_fatigue",
template_family=template_family,
msg=reply_content,
)
return False, "template_fatigue"
if reply_class in {"quote", "collect", "ack"} and (now_mono - cls_bucket.get(reply_class, 0.0)) < class_window: if reply_class in {"quote", "collect", "ack"} and (now_mono - cls_bucket.get(reply_class, 0.0)) < class_window:
self._activity_log( self._activity_log(
"outbound_arbiter_block", "outbound_arbiter_block",
@@ -481,16 +519,199 @@ class QingjianAPIClient:
if sem_key: if sem_key:
sem_bucket[sem_key] = now_mono sem_bucket[sem_key] = now_mono
cls_bucket[reply_class] = now_mono cls_bucket[reply_class] = now_mono
if template_family:
tpl_bucket[template_family] = now_mono
self._activity_log( self._activity_log(
"outbound_arbiter_pass", "outbound_arbiter_pass",
trace_id=trace_id, trace_id=trace_id,
acc_id=original_msg.get("acc_id", ""), acc_id=original_msg.get("acc_id", ""),
customer_id=original_msg.get("from_id", ""), customer_id=original_msg.get("from_id", ""),
reply_class=reply_class, reply_class=reply_class,
template_family=template_family,
semantic_key=sem_key[:80] if sem_key else "", semantic_key=sem_key[:80] if sem_key else "",
) )
return True, "pass" return True, "pass"
async def _unreplied_followup_loop(self):
"""
定时补偿:对“最后一条是客户消息且长时间未回复”的会话,补发一次自然跟进。
"""
if not self.enable_agent or not self.agent:
return
while self.running:
try:
await asyncio.sleep(max(30, int(os.getenv("UNREPLIED_FOLLOWUP_SCAN_SECONDS", "90"))))
await self._scan_and_send_unreplied_followups()
except asyncio.CancelledError:
break
except Exception as e:
self._activity_log("unreplied_followup_loop_error", error=str(e))
async def _scan_and_send_unreplied_followups(self):
from db import chat_log_db as cdb
try:
idle_minutes = max(5, int(os.getenv("UNREPLIED_FOLLOWUP_IDLE_MINUTES", "12")))
max_age_minutes = max(idle_minutes, int(os.getenv("UNREPLIED_FOLLOWUP_MAX_AGE_MINUTES", "180")))
followup_cd = max(300, int(os.getenv("UNREPLIED_FOLLOWUP_COOLDOWN_SECONDS", "3600")))
limit = max(10, int(os.getenv("UNREPLIED_FOLLOWUP_LIMIT", "40")))
except Exception:
idle_minutes, max_age_minutes, followup_cd, limit = 12, 180, 3600, 40
now = datetime.now()
window_start = (now - timedelta(minutes=max_age_minutes)).strftime("%Y-%m-%d %H:%M:%S")
conn = None
try:
conn = cdb._get_conn()
rows = conn.execute(
cdb._sql(
"""
SELECT acc_id, customer_id, MAX(id) AS last_id
FROM chat_logs
WHERE timestamp >= ?
GROUP BY acc_id, customer_id
ORDER BY MAX(id) DESC
LIMIT ?
"""
),
(window_start, limit * 6),
).fetchall()
sessions = [dict(r) for r in rows]
sent = 0
for s in sessions:
if sent >= limit:
break
acc_id = str(s.get("acc_id", "") or "")
cid = str(s.get("customer_id", "") or "")
if not acc_id or not cid:
continue
ckey = f"{acc_id}:{cid}"
if not self._is_owned_by_this_worker(ckey):
continue
last = conn.execute(
cdb._sql(
"""
SELECT id, direction, message, timestamp, customer_name, acc_id, platform
FROM chat_logs
WHERE acc_id = ? AND customer_id = ?
ORDER BY id DESC
LIMIT 1
"""
),
(acc_id, cid),
).fetchone()
if not last:
continue
last = dict(last)
if str(last.get("direction", "")) != "in":
continue
last_ts = last.get("timestamp")
if isinstance(last_ts, datetime):
last_dt = last_ts
else:
last_dt = datetime.strptime(str(last_ts)[:19], "%Y-%m-%d %H:%M:%S")
idle_s = (now - last_dt).total_seconds()
if idle_s < idle_minutes * 60 or idle_s > max_age_minutes * 60:
continue
now_mono = time.monotonic()
if (now_mono - self._unreplied_followup_sent.get(ckey, 0.0)) < followup_cd:
continue
# 避免对明显结束语/确认语再次打扰
last_msg = str(last.get("message", "") or "").strip().lower()
if last_msg in {"好的", "", "ok", "收到", "", ""}:
continue
followup = await self._compose_ai_scene_reply(
original_msg={
"acc_id": acc_id,
"from_id": cid,
"from_name": self.to_chinese(last.get("customer_name", "") or cid),
"acc_type": str(last.get("platform", "") or "AliWorkbench"),
"msg": str(last.get("message", "") or ""),
},
scene="unreplied_followup",
intent_hint="客户上一条消息还没接上,先自然承接并请对方补一句当前要处理的图或要求。",
fallback="刚看到你消息了,我在的。你把要处理的图或要求再发我一下,我马上接着看。",
)
fake = {
"acc_id": acc_id,
"from_id": cid,
"from_name": self.to_chinese(last.get("customer_name", "") or cid),
"cy_id": cid,
"cy_name": self.to_chinese(last.get("customer_name", "") or cid),
"acc_type": str(last.get("platform", "") or "AliWorkbench"),
"msg": str(last.get("message", "") or ""),
"msg_type": 0,
}
await self.send_reply(fake, followup)
self._unreplied_followup_sent[ckey] = now_mono
sent += 1
self._activity_log(
"unreplied_followup_sent",
acc_id=acc_id,
customer_id=cid,
idle_seconds=int(idle_s),
last_msg=str(last.get("message", "") or "")[:120],
reply=followup,
)
finally:
try:
if conn:
conn.close()
except Exception:
pass
async def _compose_ai_scene_reply(
self,
*,
original_msg: dict,
scene: str,
intent_hint: str,
fallback: str,
) -> str:
"""
场景化 AI 直接生成回复(不依赖固定模板)。
"""
if not self.enable_agent or not self.agent or not AgentDeps:
return fallback
try:
deps = AgentDeps(
msg_id=str(original_msg.get("msg_id", "") or f"{scene}_gen"),
acc_id=str(original_msg.get("acc_id", "") or ""),
from_id=str(original_msg.get("from_id", "") or ""),
platform=str(original_msg.get("acc_type", "") or ""),
)
customer_msg = self.to_chinese(str(original_msg.get("msg", "") or ""))
prompt = (
"你是淘宝客服,直接生成一条发给客户的话。\n"
f"场景: {scene}\n"
f"意图: {intent_hint}\n"
f"客户原话: {customer_msg}\n"
"要求: 1-2句自然口语不要模板腔不要新增价格/承诺;只输出最终回复。\n"
)
result = await self.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
out = str(getattr(result, "output", "") or "").strip()
if not out:
return fallback
if out.startswith("话术|") or "[转移会话]" in out or "TRANSFER_REQUESTED" in out:
return fallback
self._activity_log(
"ai_scene_reply_generated",
acc_id=str(original_msg.get("acc_id", "") or ""),
customer_id=str(original_msg.get("from_id", "") or ""),
scene=scene,
generated=out[:160],
)
return out
except Exception as e:
self._activity_log(
"ai_scene_reply_error",
acc_id=str(original_msg.get("acc_id", "") or ""),
customer_id=str(original_msg.get("from_id", "") or ""),
scene=scene,
error=str(e),
)
return fallback
async def receive_messages(self): async def receive_messages(self):
"""持续接收消息""" """持续接收消息"""
try: try:
@@ -1296,7 +1517,7 @@ class QingjianAPIClient:
return return
# 标记本批次已自动触发,避免同内容循环“马上报价”。 # 标记本批次已自动触发,避免同内容循环“马上报价”。
self._auto_quote_done_sig[capture_key] = capture_sig self._auto_quote_done_sig[capture_key] = capture_sig
# 直接置为可报价,然后走“发完了,报价吧”触发既有报价链路 # 直接置为可报价,走内部自动报价入口(不伪造客户语句)。
self.agent._mark_quote_ready(st) self.agent._mark_quote_ready(st)
self.agent._sync_pending_quote_state(capture_cid, st) self.agent._sync_pending_quote_state(capture_cid, st)
self._activity_log( self._activity_log(
@@ -1308,7 +1529,7 @@ class QingjianAPIClient:
notify_msg = CustomerMessage( notify_msg = CustomerMessage(
msg_id="auto_quote_idle_trigger", msg_id="auto_quote_idle_trigger",
acc_id=capture_data.get('acc_id', ''), acc_id=capture_data.get('acc_id', ''),
msg="发完了,报价吧", msg="__AUTO_QUOTE_INTERNAL_TRIGGER__",
from_id=capture_cid, from_id=capture_cid,
from_name=self.to_chinese(capture_data.get('from_name', '') or capture_data.get('cy_name', '')), from_name=self.to_chinese(capture_data.get('from_name', '') or capture_data.get('cy_name', '')),
cy_id=capture_data.get('cy_id', ''), cy_id=capture_data.get('cy_id', ''),
@@ -1318,7 +1539,7 @@ class QingjianAPIClient:
goods_name=self.to_chinese(capture_data.get('goods_name', '')) if capture_data.get('goods_name') else None, goods_name=self.to_chinese(capture_data.get('goods_name', '')) if capture_data.get('goods_name') else None,
goods_order=self.to_chinese(capture_data.get('goods_order', '')) if capture_data.get('goods_order') else None, goods_order=self.to_chinese(capture_data.get('goods_order', '')) if capture_data.get('goods_order') else None,
) )
response = await self.agent.process_message(notify_msg) response = await self.agent.build_auto_quote_reply(st, notify_msg)
if response.should_reply and response.reply and not response.need_transfer: if response.should_reply and response.reply and not response.need_transfer:
await self.send_reply(capture_data, response.reply) await self.send_reply(capture_data, response.reply)
self._activity_log( self._activity_log(
@@ -1636,7 +1857,12 @@ class QingjianAPIClient:
logger.info(f"系统询单命中 | 店铺:{acc_id} | 客户:{customer_id} | action:{action}") logger.info(f"系统询单命中 | 店铺:{acc_id} | 客户:{customer_id} | action:{action}")
if action == "reply": if action == "reply":
reply = policy.get("reply") or "您好,这边已收到询单消息,稍后由人工客服跟进处理。" reply = await self._compose_ai_scene_reply(
original_msg=data,
scene="system_inquiry_reply",
intent_hint="这是系统客服询单消息,简短确认已收到并说明会跟进即可。",
fallback=(policy.get("reply") or "您好,这边已收到询单消息,稍后由人工客服跟进处理。"),
)
await self.send_reply(data, reply) await self.send_reply(data, reply)
metrics_emit("system_inquiry_auto_reply", customer_id=customer_id, acc_id=acc_id) metrics_emit("system_inquiry_auto_reply", customer_id=customer_id, acc_id=acc_id)
return True return True
@@ -1976,6 +2202,10 @@ class QingjianAPIClient:
return return
reply_content = self._colloquialize_outbound_reply(reply_content) reply_content = self._colloquialize_outbound_reply(reply_content)
reply_content = await self._ai_generate_outbound_reply(
original_msg=original_msg,
reply_content=str(reply_content or ""),
)
# 同一客户外发限流N 秒内最多 1 条 # 同一客户外发限流N 秒内最多 1 条
try: try:
@@ -2058,6 +2288,62 @@ class QingjianAPIClient:
reply["_trace_id"] = trace_id reply["_trace_id"] = trace_id
await self.send_message(reply) await self.send_message(reply)
async def _ai_generate_outbound_reply(self, original_msg: dict, reply_content: str) -> str:
"""
强制全量 AI 出站生成层:
- 所有普通文本外发先由 AI 生成最终话术;
- 控制命令/纯链接/转接指令直接绕过。
"""
text = (reply_content or "").strip()
if not text:
return text
if text.startswith("话术|") or "[转移会话]" in text or "TRANSFER_REQUESTED" in text:
return text
if re.fullmatch(r"https?://\S+", text):
return text
if not self._force_ai_generate_reply or not self.enable_agent or not self.agent or not AgentDeps:
return text
try:
deps = AgentDeps(
msg_id=str(original_msg.get("msg_id", "") or "outbound_generate"),
acc_id=str(original_msg.get("acc_id", "") or ""),
from_id=str(original_msg.get("from_id", "") or ""),
platform=str(original_msg.get("acc_type", "") or ""),
)
customer_msg = self.to_chinese(str(original_msg.get("msg", "") or ""))
prompt = (
"你是淘宝客服外发文案生成器。请根据“回复意图草稿”生成最终发给客户的话。\n"
"要求:\n"
"1) 保留原意,不新增价格/承诺/流程;\n"
"2) 自然像真人聊天,不用固定模板句;\n"
"3) 1-2句\n"
"4) 只输出最终回复文本。\n\n"
f"客户原话: {customer_msg}\n"
f"回复意图草稿: {text}\n"
)
result = await self.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
out = str(getattr(result, "output", "") or "").strip()
if not out:
return text
if out.startswith("话术|") or "[转移会话]" in out:
return text
self._activity_log(
"ai_generate_reply",
acc_id=str(original_msg.get("acc_id", "") or ""),
customer_id=str(original_msg.get("from_id", "") or ""),
draft=text[:160],
generated=out[:160],
)
return out
except Exception as e:
self._activity_log(
"ai_generate_reply_error",
acc_id=str(original_msg.get("acc_id", "") or ""),
customer_id=str(original_msg.get("from_id", "") or ""),
error=str(e),
)
return text
def _colloquialize_outbound_reply(self, text: Any) -> Any: def _colloquialize_outbound_reply(self, text: Any) -> Any:
"""统一外发口语化处理,避免机械话术。""" """统一外发口语化处理,避免机械话术。"""
if not isinstance(text, str): if not isinstance(text, str):
@@ -2393,6 +2679,11 @@ class QingjianAPIClient:
except Exception as e: except Exception as e:
logger.info(f"[{self.get_time()}] 企微启动消息模块加载失败: {e}") logger.info(f"[{self.get_time()}] 企微启动消息模块加载失败: {e}")
# 未回复会话补偿(可关闭)
if os.getenv("UNREPLIED_FOLLOWUP_ENABLED", "true").lower() in ("1", "true", "yes"):
tasks.append(self._unreplied_followup_loop())
logger.info(f"[{self.get_time()}] 未回复会话补偿任务已启动")
await asyncio.gather(*tasks) await asyncio.gather(*tasks)

View File

@@ -432,6 +432,31 @@ class RegressionPipelineTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(resp.should_reply) self.assertTrue(resp.should_reply)
self.assertIn(resp.reply, ("嗯咯", "嗯啦", "", "")) self.assertIn(resp.reply, ("嗯咯", "嗯啦", "", ""))
async def test_meaningless_short_text_not_ping_when_collecting(self):
agent = CustomerServiceAgent()
st = agent._get_conversation_state(self.customer_id)
st.pending_image_urls = ["https://img.alicdn.com/a.jpg"]
st.quote_phase = "collecting"
agent._sync_pending_quote_state(self.customer_id, st)
msg = CustomerMessage(
msg_id="m-meaningless-collecting",
acc_id="test_shop",
msg="好的",
from_id=self.customer_id,
from_name="t",
cy_id=self.customer_id,
acc_type="AliWorkbench",
msg_type=0,
cy_name="t",
goods_name="专业找图",
goods_order="",
)
resp = await agent.process_message(msg)
self.assertTrue(resp.should_reply)
self.assertTrue((resp.reply or "").strip())
self.assertNotIn(resp.reply, ("嗯咯", "嗯啦", "", ""))
def tearDown(self): def tearDown(self):
db.clear_pending_quote_state(self.customer_id) db.clear_pending_quote_state(self.customer_id)