refactor: remove hardcoded routing rules and centralize AI master rules
Some checks failed
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Deploy Sphinx documentation to Pages / build_en (ubuntu-latest, 3.10) (push) Has been cancelled
Deploy Sphinx documentation to Pages / build_zh (ubuntu-latest, 3.10) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.10) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.12) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.10) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.12) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.10) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.12) (push) Has been cancelled

This commit is contained in:
2026-03-02 20:18:51 +08:00
parent baa46156f9
commit 674519709e
6 changed files with 68 additions and 177 deletions

View File

@@ -115,7 +115,9 @@ class RouterAgent(_AgentRuntime):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
"RouterAgent", "RouterAgent",
"你是客服路由Agent。只输出路由不回复客户。必须先调用工具读取意图/风险/订单后再路由。", rules_prompt()
+ "\n你是路由Agent。只输出路由 pre_sales/quote/after_sales/risk不直接回复客户。"
+ " 你必须基于上下文语义路由,禁止关键词硬匹配。",
) )
async def route(self, context: dict[str, Any]) -> tuple[str, str]: async def route(self, context: dict[str, Any]) -> tuple[str, str]:
@@ -132,7 +134,7 @@ class QuoteAgent(_AgentRuntime):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
"QuoteAgent", "QuoteAgent",
rules_prompt() + "\n你是报价专家Agent。必须结合图片数量、尺寸和订单状态给出报价动作", rules_prompt() + "\n你是报价Agent。负责收图、报价触发、报价回复和报价阶段状态更新",
) )
async def decide(self, context: dict[str, Any]) -> Decision: async def decide(self, context: dict[str, Any]) -> Decision:
@@ -144,7 +146,7 @@ class AfterSalesAgent(_AgentRuntime):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
"AfterSalesAgent", "AfterSalesAgent",
rules_prompt() + "\n你是售后专家Agent。优先维护售后状态并给出下一步动作", rules_prompt() + "\n你是售后Agent。负责退款/重发/不满意等售后处理与状态推进",
) )
async def decide(self, context: dict[str, Any]) -> Decision: async def decide(self, context: dict[str, Any]) -> Decision:
@@ -156,7 +158,7 @@ class RiskAgent(_AgentRuntime):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
"RiskAgent", "RiskAgent",
"你是风控Agent。遇到地图政治/黄暴/外联高风险优先给 transfer 或拒绝性 reply", rules_prompt() + "\n你是风控Agent。专注风险识别与风险动作决策",
) )
async def decide(self, context: dict[str, Any]) -> Decision: async def decide(self, context: dict[str, Any]) -> Decision:
@@ -168,7 +170,7 @@ class PreSalesAgent(_AgentRuntime):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
"PreSalesAgent", "PreSalesAgent",
rules_prompt() + "\n你是售前专家Agent。处理打招呼、询价前引导、收图承接", rules_prompt() + "\n你是售前Agent。处理咨询承接、收图、澄清需求与转报价前动作",
) )
async def decide(self, context: dict[str, Any]) -> Decision: async def decide(self, context: dict[str, Any]) -> Decision:

View File

@@ -16,7 +16,7 @@ from .config import (
from .logger import setup_logger from .logger import setup_logger
from .observability import activity_event, build_trace_id from .observability import activity_event, build_trace_id
from .orchestrator import Orchestrator from .orchestrator import Orchestrator
from .rules import detect_intent, extract_image_urls, prefilter_message from .rules import extract_image_urls, prefilter_message
class QingjianClient: class QingjianClient:
@@ -44,13 +44,8 @@ class QingjianClient:
return str(data.get("msg", "") or "").strip() return str(data.get("msg", "") or "").strip()
def _debounce_seconds(self, msg: str) -> float: def _debounce_seconds(self, msg: str) -> float:
intent = detect_intent(msg) if extract_image_urls(msg):
if intent == "image":
return 2.5 return 2.5
if intent in {"pricing", "finish_or_quote_trigger"}:
return 2.0
if intent == "greeting":
return 1.5
return float(MESSAGE_DEBOUNCE_SECONDS) return float(MESSAGE_DEBOUNCE_SECONDS)
async def send_message(self, message: dict) -> None: async def send_message(self, message: dict) -> None:
@@ -103,22 +98,6 @@ class QingjianClient:
@staticmethod @staticmethod
def _humanize_reply(text: str) -> str: def _humanize_reply(text: str) -> str:
t = str(text or "").strip() t = str(text or "").strip()
replacements = {
"您好呀": "在的",
"您好,": "在的,",
"您好": "在的",
"很高兴为您服务": "我在呢",
"请问您有什么需求": "你要做啥图",
"请问有什么可以帮您": "你要做啥",
"可以把要做的图发我": "图发我就行",
"请您先完成订单付款": "先拍下付款哈",
"麻烦您先完成付款": "先拍下付款哈",
"我们马上为您处理": "我马上处理",
"我这边": "我这",
}
for k, v in replacements.items():
t = t.replace(k, v)
t = t.replace("~~", "~")
return t return t
@staticmethod @staticmethod
@@ -133,11 +112,9 @@ class QingjianClient:
return False return False
def _fallback_reply(self, action: str) -> str: def _fallback_reply(self, action: str) -> str:
if action == "quote":
return "我先看下,马上给你报价。"
if action == "transfer": if action == "transfer":
return "我给你转人工处理" return "给你转人工处理。"
return "收到,我先一下" return "收到,我先处理一下。"
def _is_outbound_echo(self, data: dict, msg: str) -> bool: def _is_outbound_echo(self, data: dict, msg: str) -> bool:
""" """
@@ -177,7 +154,7 @@ class QingjianClient:
"goods_name": data.get("goods_name", ""), "goods_name": data.get("goods_name", ""),
"goods_order": data.get("goods_order", ""), "goods_order": data.get("goods_order", ""),
"msg": merged_msg, "msg": merged_msg,
"intent": detect_intent(merged_msg), "intent": "unknown",
"pending_images": len(self.pending_images[key]), "pending_images": len(self.pending_images[key]),
"auto_quote_trigger": auto_quote, "auto_quote_trigger": auto_quote,
"last_reply": self.last_reply_key.get(key, ""), "last_reply": self.last_reply_key.get(key, ""),
@@ -238,7 +215,7 @@ class QingjianClient:
try: try:
await asyncio.sleep(AUTO_QUOTE_WAIT_SECONDS) await asyncio.sleep(AUTO_QUOTE_WAIT_SECONDS)
if self.pending_images.get(key): if self.pending_images.get(key):
await self._handle_decision(data, "发完了,报价吧", auto_quote=True) await self._handle_decision(data, "", auto_quote=True)
finally: finally:
self.auto_quote_tasks.pop(key, None) self.auto_quote_tasks.pop(key, None)

View File

@@ -3,15 +3,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from .agents import AfterSalesAgent, PreSalesAgent, QuoteAgent, RiskAgent, RouterAgent from .agents import AfterSalesAgent, PreSalesAgent, QuoteAgent, RiskAgent, RouterAgent
from .config import FAST_ROUTE_ENABLED
from .models import Decision from .models import Decision
from .rules import (
detect_intent,
detect_order_status,
has_map_or_political_risk,
has_porn_risk,
requests_external_contact,
)
from .state_machine import evolve_after_sales_state, migrate_state_schema from .state_machine import evolve_after_sales_state, migrate_state_schema
from .store import ConversationStore from .store import ConversationStore
@@ -31,8 +23,9 @@ class Orchestrator:
prev_state = migrate_state_schema(session.get("state", {})) prev_state = migrate_state_schema(session.get("state", {}))
prev_route = session.get("route", "pre_sales") prev_route = session.get("route", "pre_sales")
intent = detect_intent(str(context.get("msg", "") or "")) # 统一改为语义决策:不走关键词意图/订单硬判定。
order_status = detect_order_status(str(context.get("goods_order", "") or "")) intent = "unknown"
order_status = "unknown"
merged_ctx = { merged_ctx = {
**context, **context,
@@ -42,43 +35,7 @@ class Orchestrator:
"order_status": order_status, "order_status": order_status,
} }
msg = str(context.get("msg", "") or "") route, route_reason = await self.router.route(merged_ctx)
goods_name = str(context.get("goods_name", "") or "")
risk_hit = has_map_or_political_risk(msg, goods_name) or has_porn_risk(msg) or requests_external_contact(msg)
# 命中硬风控才调用 RiskAgent避免每条消息都先走一轮模型。
if risk_hit:
risk_decision = await self.risk.decide(merged_ctx)
route = "risk"
new_state = evolve_after_sales_state(
{**prev_state, **(risk_decision.state_patch or {})},
route=route,
action=risk_decision.action,
intent=intent,
order_status=order_status,
msg=str(context.get("msg", "") or ""),
)
self.store.upsert_session(customer_key, context.get("acc_id", ""), context.get("customer_id", ""), route, new_state)
self.store.append_event(customer_key, "decision", {"route": route, "action": risk_decision.action, "reason": risk_decision.reason})
return route, risk_decision, new_state
route = ""
route_reason = ""
if FAST_ROUTE_ENABLED:
pending_images = int(context.get("pending_images", 0) or 0)
auto_quote_trigger = bool(context.get("auto_quote_trigger", False))
if intent in {"pricing", "finish_or_quote_trigger"} and (pending_images > 0 or auto_quote_trigger):
route = "quote"
route_reason = "fast_route_quote_with_pending_images"
elif order_status == "refund":
route = "after_sales"
route_reason = "fast_route_refund"
elif intent in {"image", "greeting", "nonsense", "pricing", "finish_or_quote_trigger", "unknown"}:
route = "pre_sales"
route_reason = "fast_route_common_presales"
if not route:
route, route_reason = await self.router.route(merged_ctx)
if route == "quote": if route == "quote":
decision = await self.quote.decide(merged_ctx) decision = await self.quote.decide(merged_ctx)

View File

@@ -4,15 +4,6 @@ from dataclasses import dataclass
IMAGE_URL_RE = re.compile(r"https?://[^\s]+(?:\.jpg|\.jpeg|\.png|\.webp|\.bmp|\.gif)(?:\?[^\s]*)?", re.I) IMAGE_URL_RE = re.compile(r"https?://[^\s]+(?:\.jpg|\.jpeg|\.png|\.webp|\.bmp|\.gif)(?:\?[^\s]*)?", re.I)
SIZE_RE = re.compile(r"(\d+(?:\.\d+)?)\s*(米|m|M)\s*[xX*乘]\s*(\d+(?:\.\d+)?)\s*(米|m|M)") SIZE_RE = re.compile(r"(\d+(?:\.\d+)?)\s*(米|m|M)\s*[xX*乘]\s*(\d+(?:\.\d+)?)\s*(米|m|M)")
MAP_POLITICAL_KWS = ["地图", "国界", "边界", "南海", "台湾", "香港", "澳门", "西藏", "新疆", "政治"]
PORN_RISK_KWS = ["", "成人视频", "成人视频", "性爱", "激情", "成人视频"]
EXTERNAL_CONTACT_KWS = ["微信", "vx", "vx", "qq", "手机号", "电话", "加我", "私下"]
PRICE_KWS = ["多少钱", "怎么收费", "报价", "价格", "多少米", "多少"]
GREETING_KWS = ["你好", "您好", "在吗", "在不在", "hello", "hi"]
FINISH_KWS = ["发完了", "没了", "就这些", "报价吧", "可以报价", "先这样"]
NONSENSE_KWS = ["", "", "好的", "ok", "1", "收到"]
@dataclass @dataclass
class RuleResult: class RuleResult:
ignore: bool = False ignore: bool = False
@@ -30,13 +21,7 @@ def extract_customer_text_from_shop_card(msg: str) -> str:
def detect_order_status(order_text: str) -> str: def detect_order_status(order_text: str) -> str:
t = (order_text or "") # 订单状态交给主决策 AI 从上下文语义判断。
if "买家已付款" in t:
return "paid"
if "等待买家付款" in t or "待付款" in t:
return "pending_payment"
if "已退款" in t or "退款" in t:
return "refund"
return "unknown" return "unknown"
@@ -50,25 +35,23 @@ def extract_size_pairs_m(msg: str) -> list[tuple[float, float]]:
def has_map_or_political_risk(msg: str, goods_name: str = "") -> bool: def has_map_or_political_risk(msg: str, goods_name: str = "") -> bool:
t = f"{msg or ''} {goods_name or ''}".lower() # 风险由 RiskAgent 语义判断。
return any(k.lower() in t for k in MAP_POLITICAL_KWS) return False
def has_porn_risk(msg: str) -> bool: def has_porn_risk(msg: str) -> bool:
t = (msg or "").lower() # 风险由 RiskAgent 语义判断。
return any(k.lower() in t for k in PORN_RISK_KWS) return False
def requests_external_contact(msg: str) -> bool: def requests_external_contact(msg: str) -> bool:
t = (msg or "").lower() # 外联风险由 RiskAgent 语义判断。
return any(k.lower() in t for k in EXTERNAL_CONTACT_KWS) return False
def is_meaningless_short(msg: str) -> bool: def is_meaningless_short(msg: str) -> bool:
t = (msg or "").strip().lower() # 无意义短句由主决策 AI 语义判断。
if len(t) <= 2: return False
return True
return t in NONSENSE_KWS
def prefilter_message(msg: str, msg_type: int) -> RuleResult: def prefilter_message(msg: str, msg_type: int) -> RuleResult:
@@ -93,16 +76,7 @@ def detect_intent(msg: str) -> str:
m = (msg or "").lower() m = (msg or "").lower()
if IMAGE_URL_RE.search(m): if IMAGE_URL_RE.search(m):
return "image" return "image"
if any(k in m for k in FINISH_KWS): # 其余意图交给 AI 语义判断。
return "finish_or_quote_trigger"
if any(k in m for k in PRICE_KWS):
return "pricing"
if any(k in m for k in GREETING_KWS):
return "greeting"
if requests_external_contact(m):
return "external_contact"
if is_meaningless_short(m):
return "nonsense"
return "unknown" return "unknown"
@@ -112,22 +86,40 @@ def extract_image_urls(msg: str) -> list[str]:
def rules_prompt() -> str: def rules_prompt() -> str:
return ( return (
"你是淘宝客服主决策。只输出JSON动作不要解释\n" "你是淘宝图像服务客服系统的统一决策AI。必须按以下 MASTER_RULES 执行\n"
"动作 action 只能是: reply / quote / transfer / noop\n" "只输出 JSON 决策,不要解释过程\n"
"规则提炼(严格执行):\n" "动作 action 只能是: reply / quote / transfer / noop / update_state。\n\n"
"1) 客户发图: 先承接, 允许继续收图。\n" "MASTER_RULES:\n"
"2) 询价且有图(当前/待处理): 优先 quote。\n" "A. 统一动作语义\n"
"3) 无图询价: reply 承接并引导发图\n" "1) reply: 直接回复客户\n"
"4) 客户说发完了/报价吧/没图: 若有待处理图则 quote\n" "2) quote: 触发报价或触发看图后报价流程\n"
"5) 外部联系方式请求: reply 站内引导, 不给微信QQ手机号\n" "3) transfer: 转人工,必须给 transfer_msg\n"
"6) 地图/政治/黄暴风险: transfer 或拒绝性 reply\n" "4) noop: 当前不需要回复\n"
"7) 仅无意义短句(嗯/哦/ok): 给简短自然承接, 不要长回复。\n" "5) update_state: 仅更新状态,不对外发消息。\n\n"
"8) 避免重复同一句; 若上句语义相同则换表达。\n" "B. 售前与报价\n"
"9) 订单已付款: 可回复已安排; 待付款: 提示先付款\n" "1) 客户发图: 优先承接,可继续收图;不强行一次报完\n"
"10) 尺寸明显超大(如>=2m*2m): 提示需补图/重做边缘, 不要直接承诺一模一样\n" "2) 客户询价且已有图(当前图/待处理图/最近图): 优先 action=quote\n"
"11) 店铺差异化: 按 acc_id/persona 口吻回复, 保持真人聊天\n" "3) 客户无图询价: action=reply引导其先发图\n"
"12) 最终输出只允许一个动作, 不能混合\n" "4) 客户说“发完了/就这些/报价吧”: 若有图则 action=quote\n"
"13) reply 必须很短: 1句为主, 不超过20字, 口语化, 不要客服官话和AI腔\n" "5) 不能承诺“一模一样原图必找到”,可说先看图评估\n"
"6) 尺寸很大或要求高还原时,不夸张承诺,先说明可评估后给结论。\n\n"
"C. 订单阶段\n"
"1) 已付款: 可回复“已安排处理/正在处理/完成后发你确认”。\n"
"2) 待付款: 可提示付款,但不与客户争执;必要时先给预览再引导付款。\n"
"3) 退款/售后诉求: 进入售后语境,保持克制,必要时转人工。\n\n"
"D. 风控与合规\n"
"1) 涉政治/地图边界/黄暴/违规内容: 优先 transfer 或拒绝性 reply。\n"
"2) 客户索要微信/QQ/手机号等站外联系方式: 不外呼,站内引导。\n"
"3) 高风险不确定时,不硬答,给保守回复或转人工。\n\n"
"E. 对话质量\n"
"1) 单次只做一个动作,不混合。\n"
"2) 避免重复同一句话;若语义相同,换表达。\n"
"3) reply 必须短: 优先 1 句口语化避免AI腔。\n"
"4) 不要输出思考过程,不要输出 tool_use 文本给客户。\n"
"5) 若上下文不足,先澄清 1 个关键问题,不要连续追问。\n\n"
"F. 店铺人格\n"
"1) 按店铺/账号口吻说话,像真人客服,不要机械模板。\n"
"2) 语气友好直接不啰嗦不说“作为AI”。\n\n"
"输出格式:\n" "输出格式:\n"
'{"action":"reply|quote|transfer|noop","reply":"","transfer_msg":"","quote_mode":"flush_pending|analyze_current_or_recent|collect_only","reason":""}' '{"action":"reply|quote|transfer|noop|update_state","reply":"","transfer_msg":"","quote_mode":"flush_pending|analyze_current_or_recent|collect_only","state_patch":{},"reason":""}'
) )

View File

@@ -38,7 +38,6 @@ def migrate_state_schema(state: dict[str, Any] | None) -> dict[str, Any]:
def evolve_after_sales_state(prev_state: dict[str, Any], *, route: str, action: str, intent: str, order_status: str, msg: str) -> dict[str, Any]: def evolve_after_sales_state(prev_state: dict[str, Any], *, route: str, action: str, intent: str, order_status: str, msg: str) -> dict[str, Any]:
s = migrate_state_schema(prev_state) s = migrate_state_schema(prev_state)
stage = s.get("after_sales_stage", "new") stage = s.get("after_sales_stage", "new")
text = (msg or "").lower()
if action == "transfer" or route == "risk": if action == "transfer" or route == "risk":
stage = "transferred" stage = "transferred"
@@ -46,22 +45,14 @@ def evolve_after_sales_state(prev_state: dict[str, Any], *, route: str, action:
stage = "quoted" stage = "quoted"
s["quote_count"] = int(s.get("quote_count", 0)) + 1 s["quote_count"] = int(s.get("quote_count", 0)) + 1
elif route == "after_sales": elif route == "after_sales":
if any(k in text for k in ["退款", "退钱", "退货"]): if order_status == "paid":
stage = "refunding"
elif any(k in text for k in ["做完", "完成", "好了", "发我"]):
stage = "waiting_feedback"
elif any(k in text for k in ["补图", "重发", "再发", "原图"]):
stage = "waiting_material"
elif order_status == "paid":
stage = "processing" stage = "processing"
elif stage == "new":
stage = "waiting_material"
elif route == "pre_sales": elif route == "pre_sales":
if intent == "image": if intent == "image":
stage = "waiting_material" stage = "waiting_material"
# 终态收敛
if stage == "waiting_feedback" and any(k in text for k in ["没问题", "可以", "确认", "好评"]):
stage = "done"
s["after_sales_stage"] = stage s["after_sales_stage"] = stage
s["last_intent"] = intent s["last_intent"] = intent
s["last_order_status"] = order_status or "unknown" s["last_order_status"] = order_status or "unknown"

View File

@@ -11,35 +11,10 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from app.orchestrator import Orchestrator from app.orchestrator import Orchestrator
from app.rules import detect_intent, detect_order_status, has_map_or_political_risk, requests_external_contact
from app.state_machine import evolve_after_sales_state, migrate_state_schema
CASES_PATH = ROOT / "golden" / "golden_cases.jsonl" CASES_PATH = ROOT / "golden" / "golden_cases.jsonl"
def heuristic_decide(msg: str, goods_order: str) -> tuple[str, str, dict]:
m = (msg or "")
intent = detect_intent(m)
order_status = detect_order_status(goods_order or "")
if has_map_or_political_risk(m) or requests_external_contact(m):
route, action = "risk", "transfer"
elif "退款" in m:
route, action = "after_sales", "reply"
elif intent == "image":
route, action = "quote", "quote"
elif any(k in m for k in ["多少钱", "报价", "价格", "怎么收费"]):
route, action = "quote", "reply"
elif order_status == "paid":
route, action = "after_sales", "reply"
else:
route, action = "pre_sales", "reply"
state = migrate_state_schema({})
state = evolve_after_sales_state(state, route=route, action=action, intent=intent, order_status=order_status, msg=m)
return route, action, state
async def full_decide(orch: Orchestrator, cid: str, msg: str, goods_order: str) -> tuple[str, str, dict]: async def full_decide(orch: Orchestrator, cid: str, msg: str, goods_order: str) -> tuple[str, str, dict]:
context = { context = {
"customer_key": f"demo_acc:{cid}", "customer_key": f"demo_acc:{cid}",
@@ -58,7 +33,7 @@ async def full_decide(orch: Orchestrator, cid: str, msg: str, goods_order: str)
async def main() -> int: async def main() -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["heuristic", "full"], default="heuristic") parser.add_argument("--mode", choices=["full"], default="full")
parser.add_argument("--cases", default=str(CASES_PATH)) parser.add_argument("--cases", default=str(CASES_PATH))
args = parser.parse_args() args = parser.parse_args()
@@ -69,16 +44,13 @@ async def main() -> int:
cases = [json.loads(line) for line in p.read_text(encoding="utf-8").splitlines() if line.strip()] cases = [json.loads(line) for line in p.read_text(encoding="utf-8").splitlines() if line.strip()]
orch = Orchestrator() if args.mode == "full" else None orch = Orchestrator()
passed = 0 passed = 0
failed = 0 failed = 0
for i, c in enumerate(cases, 1): for i, c in enumerate(cases, 1):
cid = f"case_{i}" cid = f"case_{i}"
if args.mode == "full": route, action, state = await full_decide(orch, cid, c.get("msg", ""), c.get("goods_order", ""))
route, action, state = await full_decide(orch, cid, c.get("msg", ""), c.get("goods_order", ""))
else:
route, action, state = heuristic_decide(c.get("msg", ""), c.get("goods_order", ""))
ok = route == c.get("expected_route") and action == c.get("expected_action") ok = route == c.get("expected_route") and action == c.get("expected_action")
if ok and c.get("expected_stage"): if ok and c.get("expected_stage"):