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
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:
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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":""}'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
Reference in New Issue
Block a user