refactor: add rule engine, risk service, quote state machine, and replay tests
This commit is contained in:
@@ -22,6 +22,10 @@ from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
from dotenv import load_dotenv
|
||||
from utils.metrics_tracker import emit as metrics_emit
|
||||
from utils.observability import emit_activity, build_trace_id
|
||||
from core.quote_state_machine import QuoteStateMachine
|
||||
from core.rules import Rule, RuleContext, RuleEngine, RuleResult
|
||||
from services.risk_service import RiskService
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -209,16 +213,14 @@ class CustomerServiceAgent:
|
||||
|
||||
@staticmethod
|
||||
def _activity_log(event: str, **kwargs):
|
||||
safe = {}
|
||||
for k, v in kwargs.items():
|
||||
if isinstance(v, str):
|
||||
safe[k] = v[:240]
|
||||
else:
|
||||
safe[k] = v
|
||||
try:
|
||||
logger.info(f"[ACTIVITY] event={event} data={json.dumps(safe, ensure_ascii=False)}")
|
||||
except Exception:
|
||||
logger.info(f"[ACTIVITY] event={event} data={safe}")
|
||||
emit_activity(
|
||||
logger,
|
||||
event=event,
|
||||
trace_id=str(kwargs.pop("trace_id", "")),
|
||||
customer_id=str(kwargs.pop("customer_id", "")),
|
||||
result=str(kwargs.pop("result", "ok")),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def __init__(self, skills_dir: str = "skills"):
|
||||
self.api_key = os.getenv("OPENAI_API_KEY")
|
||||
@@ -231,6 +233,9 @@ class CustomerServiceAgent:
|
||||
self.batch_quote_delay_turns = max(0, int(os.getenv("BATCH_QUOTE_DELAY_TURNS", "1")))
|
||||
except Exception:
|
||||
self.batch_quote_delay_turns = 1
|
||||
self.quote_state_machine = QuoteStateMachine(delay_turns=self.batch_quote_delay_turns)
|
||||
self.risk_service = RiskService()
|
||||
self._pre_rule_engine = self._build_pre_rule_engine()
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("请设置 OPENAI_API_KEY 环境变量")
|
||||
@@ -430,7 +435,7 @@ class CustomerServiceAgent:
|
||||
收图阶段回复默认走 AI 改写,失败时回退到固定模板。
|
||||
"""
|
||||
# 首张收图先承接“我看一下”,避免机械地立刻催“发完统一报价”。
|
||||
if scene == "collect_ack" and len(state.pending_image_urls) <= 1:
|
||||
if scene == "collect_ack" and len(state.pending_image_urls) == 1:
|
||||
first_ack = [
|
||||
"收到了,我先看一下哈,稍等哈",
|
||||
"这张我收到了,我先看下,等我一下哈",
|
||||
@@ -1272,21 +1277,7 @@ class CustomerServiceAgent:
|
||||
@staticmethod
|
||||
def _refresh_quote_phase(state: ConversationState, phase_hint: str = ""):
|
||||
"""统一维护收图报价状态机。"""
|
||||
if phase_hint in {"idle", "collecting", "ready_to_quote", "waiting_result"}:
|
||||
state.quote_phase = phase_hint
|
||||
if phase_hint == "idle":
|
||||
state.quote_ready_turns = 0
|
||||
return
|
||||
if not state.pending_image_urls:
|
||||
state.quote_phase = "idle"
|
||||
state.quote_ready_turns = 0
|
||||
return
|
||||
if state.quote_phase in {"ready_to_quote", "waiting_result"}:
|
||||
return
|
||||
if state.pending_image_urls and state.pending_requirements:
|
||||
state.quote_phase = "collecting"
|
||||
return
|
||||
state.quote_phase = "collecting"
|
||||
QuoteStateMachine().refresh(state, phase_hint=phase_hint)
|
||||
|
||||
def _should_defer_batch_quote(self, state: ConversationState, mark_ready: bool = False) -> bool:
|
||||
"""
|
||||
@@ -1294,19 +1285,13 @@ class CustomerServiceAgent:
|
||||
- 首次进入 ready_to_quote 时按配置等待 N 轮
|
||||
- 等待轮次归零后,本轮即可报价
|
||||
"""
|
||||
if mark_ready and state.quote_phase != "ready_to_quote":
|
||||
state.quote_phase = "ready_to_quote"
|
||||
state.quote_ready_turns = max(0, int(self.batch_quote_delay_turns))
|
||||
if state.quote_phase == "ready_to_quote" and state.quote_ready_turns > 0:
|
||||
state.quote_ready_turns -= 1
|
||||
return True
|
||||
return False
|
||||
self.quote_state_machine.delay_turns = max(0, int(self.batch_quote_delay_turns))
|
||||
return self.quote_state_machine.should_defer_batch_quote(state, mark_ready=mark_ready)
|
||||
|
||||
def _mark_quote_ready(self, state: ConversationState):
|
||||
"""仅标记 ready 状态,不消费等待轮次。"""
|
||||
if state.quote_phase != "ready_to_quote":
|
||||
state.quote_phase = "ready_to_quote"
|
||||
state.quote_ready_turns = max(0, int(self.batch_quote_delay_turns))
|
||||
self.quote_state_machine.delay_turns = max(0, int(self.batch_quote_delay_turns))
|
||||
self.quote_state_machine.mark_ready(state)
|
||||
|
||||
def _build_reject_message(self, reason: str = "") -> str:
|
||||
templates = [
|
||||
@@ -1779,10 +1764,163 @@ class CustomerServiceAgent:
|
||||
clean = msg.strip().rstrip("!!??。.~~")
|
||||
return clean in self._COOLDOWN_PATTERNS
|
||||
|
||||
def _build_pre_rule_engine(self) -> RuleEngine:
|
||||
return RuleEngine(
|
||||
rules=[
|
||||
Rule(
|
||||
name="meaningless_short_text",
|
||||
priority=10,
|
||||
predicate=self._rule_pred_meaningless_short_text,
|
||||
action=self._rule_act_meaningless_short_text,
|
||||
),
|
||||
Rule(
|
||||
name="cooldown_silent",
|
||||
priority=20,
|
||||
predicate=self._rule_pred_cooldown_silent,
|
||||
action=self._rule_act_cooldown_silent,
|
||||
),
|
||||
Rule(
|
||||
name="manual_risk_block",
|
||||
priority=30,
|
||||
predicate=self._rule_pred_manual_risk_block,
|
||||
action=self._rule_act_manual_risk_block,
|
||||
),
|
||||
Rule(
|
||||
name="text_risk_block",
|
||||
priority=40,
|
||||
predicate=self._rule_pred_text_risk_block,
|
||||
action=self._rule_act_text_risk_block,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def _rule_pred_meaningless_short_text(self, ctx: RuleContext) -> bool:
|
||||
message: CustomerMessage = ctx.get("message")
|
||||
return _is_meaningless_short_text(message.msg)
|
||||
|
||||
async def _rule_act_meaningless_short_text(self, ctx: RuleContext) -> RuleResult:
|
||||
message: CustomerMessage = ctx.get("message")
|
||||
state: ConversationState = ctx.get("state")
|
||||
trace_id = ctx.get("trace_id", "")
|
||||
ping = random.choice(("嗯咯", "嗯啦", "嗯", "哦"))
|
||||
state.last_reply_at = datetime.now()
|
||||
self._activity_log(
|
||||
"agent_ping_reply",
|
||||
trace_id=trace_id,
|
||||
customer_id=message.from_id,
|
||||
msg=message.msg,
|
||||
reply=ping,
|
||||
)
|
||||
return RuleResult(
|
||||
matched=True,
|
||||
stop=True,
|
||||
action="agent_ping_reply",
|
||||
payload={"response": AgentResponse(reply=ping, should_reply=True, need_transfer=False)},
|
||||
)
|
||||
|
||||
async def _rule_pred_cooldown_silent(self, ctx: RuleContext) -> bool:
|
||||
message: CustomerMessage = ctx.get("message")
|
||||
state: ConversationState = ctx.get("state")
|
||||
return self._in_cooldown(state, message.msg)
|
||||
|
||||
async def _rule_act_cooldown_silent(self, ctx: RuleContext) -> RuleResult:
|
||||
message: CustomerMessage = ctx.get("message")
|
||||
state: ConversationState = ctx.get("state")
|
||||
trace_id = ctx.get("trace_id", "")
|
||||
elapsed = int((datetime.now() - state.last_reply_at).total_seconds()) if state.last_reply_at else 0
|
||||
print(f"[Agent] 冷却期静默(距上次回复 {elapsed}s):{message.msg!r}")
|
||||
self._activity_log(
|
||||
"agent_cooldown_silent",
|
||||
trace_id=trace_id,
|
||||
customer_id=message.from_id,
|
||||
elapsed_s=elapsed,
|
||||
)
|
||||
return RuleResult(
|
||||
matched=True,
|
||||
stop=True,
|
||||
action="agent_cooldown_silent",
|
||||
payload={"response": AgentResponse(reply="", should_reply=False, need_transfer=False)},
|
||||
)
|
||||
|
||||
async def _rule_pred_manual_risk_block(self, ctx: RuleContext) -> bool:
|
||||
message: CustomerMessage = ctx.get("message")
|
||||
decision = self.risk_service.check_manual_block(message.from_id)
|
||||
ctx.set("manual_risk_decision", decision)
|
||||
return decision.blocked
|
||||
|
||||
async def _rule_act_manual_risk_block(self, ctx: RuleContext) -> RuleResult:
|
||||
message: CustomerMessage = ctx.get("message")
|
||||
trace_id = ctx.get("trace_id", "")
|
||||
decision = ctx.get("manual_risk_decision")
|
||||
self._activity_log(
|
||||
"agent_manual_risk_reject",
|
||||
trace_id=trace_id,
|
||||
customer_id=message.from_id,
|
||||
risk=(decision.profile if decision else {}),
|
||||
)
|
||||
return RuleResult(
|
||||
matched=True,
|
||||
stop=True,
|
||||
action="agent_manual_risk_reject",
|
||||
payload={
|
||||
"response": AgentResponse(
|
||||
reply="这边无法继续为你处理该类需求,给你转人工专员对接。",
|
||||
should_reply=True,
|
||||
need_transfer=True,
|
||||
transfer_msg=TRANSFER_MESSAGE,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
async def _rule_pred_text_risk_block(self, ctx: RuleContext) -> bool:
|
||||
message: CustomerMessage = ctx.get("message")
|
||||
decision = await self.risk_service.check_text_block(
|
||||
message.msg,
|
||||
political_detector=self._is_political_inquiry,
|
||||
map_detector=self._is_map_inquiry,
|
||||
)
|
||||
ctx.set("text_risk_decision", decision)
|
||||
return decision.blocked
|
||||
|
||||
async def _rule_act_text_risk_block(self, ctx: RuleContext) -> RuleResult:
|
||||
message: CustomerMessage = ctx.get("message")
|
||||
state: ConversationState = ctx.get("state")
|
||||
trace_id = ctx.get("trace_id", "")
|
||||
decision = ctx.get("text_risk_decision")
|
||||
state.pending_image_urls.clear()
|
||||
state.pending_requirements.clear()
|
||||
self._sync_pending_quote_state(message.from_id, state)
|
||||
|
||||
reject_text = self.risk_service.build_reject_text(decision.category if decision else "other")
|
||||
reply = await self._rewrite_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
reply=reject_text,
|
||||
scene="risk_reject",
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply}")
|
||||
self._activity_log(
|
||||
"agent_risk_reject",
|
||||
trace_id=trace_id,
|
||||
customer_id=message.from_id,
|
||||
risk_category=(decision.category if decision else "other"),
|
||||
risk_source=(decision.source if decision else "unknown"),
|
||||
reply=reply,
|
||||
)
|
||||
return RuleResult(
|
||||
matched=True,
|
||||
stop=True,
|
||||
action="agent_risk_reject",
|
||||
payload={"response": AgentResponse(reply=reply, should_reply=True, need_transfer=False)},
|
||||
)
|
||||
|
||||
async def process_message(self, message: CustomerMessage) -> AgentResponse:
|
||||
"""处理客户消息并生成回复"""
|
||||
trace_id = build_trace_id(message.acc_id, message.from_id, message.msg_id, message.msg[:64])
|
||||
self._activity_log(
|
||||
"agent_inbound",
|
||||
trace_id=trace_id,
|
||||
acc_id=message.acc_id,
|
||||
customer_id=message.from_id,
|
||||
msg=message.msg,
|
||||
@@ -1791,97 +1929,12 @@ class CustomerServiceAgent:
|
||||
metrics_emit("inbound_msg", customer_id=message.from_id, acc_id=message.acc_id)
|
||||
# 获取或创建对话状态
|
||||
state = self._get_conversation_state(message.from_id)
|
||||
|
||||
# 无意义短句承接:单独回一句口语,不进入复杂决策
|
||||
if _is_meaningless_short_text(message.msg):
|
||||
ping = random.choice(("嗯咯", "嗯啦", "嗯", "哦"))
|
||||
state.last_reply_at = datetime.now()
|
||||
self._activity_log("agent_ping_reply", customer_id=message.from_id, msg=message.msg, reply=ping)
|
||||
return AgentResponse(reply=ping, should_reply=True, need_transfer=False)
|
||||
|
||||
# 冷却期检测:近期已回复 + 纯打招呼 → 静默
|
||||
if self._in_cooldown(state, message.msg):
|
||||
elapsed = int((datetime.now() - state.last_reply_at).total_seconds())
|
||||
print(f"[Agent] 冷却期静默(距上次回复 {elapsed}s):{message.msg!r}")
|
||||
self._activity_log("agent_cooldown_silent", customer_id=message.from_id, elapsed_s=elapsed)
|
||||
return AgentResponse(reply="", should_reply=False, need_transfer=False)
|
||||
|
||||
# 前置风控:客户文本一旦命中政治/敏感询问,直接拒绝,避免“发图我看看”类答非所问
|
||||
try:
|
||||
# 人工风控:标记为不接单的客户直接转人工
|
||||
manual_risk = risk_db.evaluate_customer(message.from_id)
|
||||
if bool(manual_risk.get("do_not_serve")):
|
||||
self._activity_log(
|
||||
"agent_manual_risk_reject",
|
||||
customer_id=message.from_id,
|
||||
risk=manual_risk,
|
||||
)
|
||||
return AgentResponse(
|
||||
reply="这边无法继续为你处理该类需求,给你转人工专员对接。",
|
||||
should_reply=True,
|
||||
need_transfer=True,
|
||||
transfer_msg=TRANSFER_MESSAGE,
|
||||
)
|
||||
|
||||
from utils.content_filter import should_block_customer_smart
|
||||
risk_hit, risk_category, _risk_reason = await should_block_customer_smart(message.msg)
|
||||
map_hit = self._is_map_inquiry(message.msg) or (risk_category == "map")
|
||||
political_hit = self._is_political_inquiry(message.msg) or (risk_category == "political")
|
||||
if risk_hit or political_hit or map_hit:
|
||||
# 命中敏感询问时清空待报价队列,避免旧图残留污染后续会话
|
||||
state.pending_image_urls.clear()
|
||||
state.pending_requirements.clear()
|
||||
self._sync_pending_quote_state(message.from_id, state)
|
||||
reject_text = "地图这类不做哈,这边不接地图相关需求。"
|
||||
if risk_category == "sexual":
|
||||
reject_text = "这类不做哈,涉黄擦边内容都不接。"
|
||||
elif risk_category == "violent":
|
||||
reject_text = "这类不做哈,暴力血腥相关都不接。"
|
||||
elif political_hit and not map_hit:
|
||||
reject_text = "这类不做哈,政治相关图片和人物都不接。"
|
||||
reply = await self._rewrite_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
reply=reject_text,
|
||||
scene="risk_reject",
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply}")
|
||||
self._activity_log(
|
||||
"agent_risk_reject",
|
||||
customer_id=message.from_id,
|
||||
map_hit=map_hit,
|
||||
political_hit=political_hit,
|
||||
risk_category=risk_category,
|
||||
reply=reply,
|
||||
)
|
||||
return AgentResponse(reply=reply, should_reply=True, need_transfer=False)
|
||||
except Exception:
|
||||
map_hit = self._is_map_inquiry(message.msg)
|
||||
political_hit = self._is_political_inquiry(message.msg)
|
||||
if political_hit or map_hit:
|
||||
state.pending_image_urls.clear()
|
||||
state.pending_requirements.clear()
|
||||
self._sync_pending_quote_state(message.from_id, state)
|
||||
reject_text = "地图这类不做哈,这边不接地图相关需求。"
|
||||
if political_hit and not map_hit:
|
||||
reject_text = "这类不做哈,政治相关图片和人物都不接。"
|
||||
reply = await self._rewrite_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
reply=reject_text,
|
||||
scene="risk_reject",
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply}")
|
||||
self._activity_log(
|
||||
"agent_risk_reject",
|
||||
customer_id=message.from_id,
|
||||
map_hit=map_hit,
|
||||
political_hit=political_hit,
|
||||
reply=reply,
|
||||
)
|
||||
return AgentResponse(reply=reply, should_reply=True, need_transfer=False)
|
||||
pre_ctx = RuleContext(data={"message": message, "state": state, "trace_id": trace_id})
|
||||
pre_result = await self._pre_rule_engine.run(pre_ctx)
|
||||
if pre_result.stop:
|
||||
response = pre_result.payload.get("response")
|
||||
if isinstance(response, AgentResponse):
|
||||
return response
|
||||
|
||||
# 检测售前/售后
|
||||
new_stage = self._detect_stage(message.msg)
|
||||
|
||||
Reference in New Issue
Block a user