refactor: extract pre-rules and find-image quote flow from agent
This commit is contained in:
200
core/agent_pre_rules.py
Normal file
200
core/agent_pre_rules.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from core.rules import Rule, RuleContext, RuleEngine, RuleResult
|
||||||
|
from services.risk_service import RiskService
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from core.pydantic_ai_agent import (
|
||||||
|
AgentResponse,
|
||||||
|
ConversationState,
|
||||||
|
CustomerMessage,
|
||||||
|
CustomerServiceAgent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentPreRuleService:
|
||||||
|
"""Pre-processing rule chain for short replies, cooldown, and text risk."""
|
||||||
|
|
||||||
|
def __init__(self, agent: "CustomerServiceAgent", risk_service: RiskService):
|
||||||
|
self.agent = agent
|
||||||
|
self.risk_service = risk_service
|
||||||
|
self.engine = self._build_engine()
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
message: "CustomerMessage",
|
||||||
|
state: "ConversationState",
|
||||||
|
trace_id: str,
|
||||||
|
) -> Optional["AgentResponse"]:
|
||||||
|
ctx = RuleContext(data={"message": message, "state": state, "trace_id": trace_id})
|
||||||
|
result = await self.engine.run(ctx)
|
||||||
|
if not result.stop:
|
||||||
|
return None
|
||||||
|
response = result.payload.get("response")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _build_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:
|
||||||
|
from core.pydantic_ai_agent import _is_meaningless_short_text
|
||||||
|
|
||||||
|
message = ctx.get("message")
|
||||||
|
return _is_meaningless_short_text(message.msg)
|
||||||
|
|
||||||
|
async def _rule_act_meaningless_short_text(self, ctx: RuleContext) -> RuleResult:
|
||||||
|
from core.pydantic_ai_agent import AgentResponse
|
||||||
|
|
||||||
|
message = ctx.get("message")
|
||||||
|
state = ctx.get("state")
|
||||||
|
trace_id = ctx.get("trace_id", "")
|
||||||
|
ping = random.choice(("嗯咯", "嗯啦", "嗯", "哦"))
|
||||||
|
state.last_reply_at = datetime.now()
|
||||||
|
self.agent._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 = ctx.get("message")
|
||||||
|
state = ctx.get("state")
|
||||||
|
return self.agent._in_cooldown(state, message.msg)
|
||||||
|
|
||||||
|
async def _rule_act_cooldown_silent(self, ctx: RuleContext) -> RuleResult:
|
||||||
|
from core.pydantic_ai_agent import AgentResponse
|
||||||
|
|
||||||
|
message = ctx.get("message")
|
||||||
|
state = 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.agent._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 = 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:
|
||||||
|
from core.pydantic_ai_agent import AgentResponse, TRANSFER_MESSAGE
|
||||||
|
|
||||||
|
message = ctx.get("message")
|
||||||
|
trace_id = ctx.get("trace_id", "")
|
||||||
|
decision = ctx.get("manual_risk_decision")
|
||||||
|
self.agent._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 = ctx.get("message")
|
||||||
|
decision = await self.risk_service.check_text_block(
|
||||||
|
message.msg,
|
||||||
|
political_detector=self.agent._is_political_inquiry,
|
||||||
|
map_detector=self.agent._is_map_inquiry,
|
||||||
|
)
|
||||||
|
ctx.set("text_risk_decision", decision)
|
||||||
|
return decision.blocked
|
||||||
|
|
||||||
|
async def _rule_act_text_risk_block(self, ctx: RuleContext) -> RuleResult:
|
||||||
|
from core.pydantic_ai_agent import AgentResponse
|
||||||
|
|
||||||
|
message = ctx.get("message")
|
||||||
|
state = 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.agent._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.agent._rewrite_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
reply=reject_text,
|
||||||
|
scene="risk_reject",
|
||||||
|
)
|
||||||
|
state.last_reply_at = datetime.now()
|
||||||
|
print(f"{self.agent.C_REPLY}[REPLY->CUSTOMER]{self.agent.C_RESET} {reply}")
|
||||||
|
self.agent._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)},
|
||||||
|
)
|
||||||
215
core/find_image_flow.py
Normal file
215
core/find_image_flow.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from core.pydantic_ai_agent import AgentResponse, ConversationState, CustomerMessage, CustomerServiceAgent
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_find_image_batch_flow(
|
||||||
|
agent: "CustomerServiceAgent",
|
||||||
|
*,
|
||||||
|
message: "CustomerMessage",
|
||||||
|
state: "ConversationState",
|
||||||
|
customer_text: str,
|
||||||
|
shop_type: str,
|
||||||
|
) -> Optional["AgentResponse"]:
|
||||||
|
"""Handle find-image collecting/quote flow. Return response when handled."""
|
||||||
|
from core.pydantic_ai_agent import AgentResponse, TRANSFER_MESSAGE
|
||||||
|
|
||||||
|
if not (shop_type == "find_image" and agent._is_batch_quote_enabled(message.from_id, message.acc_id)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
incoming_urls = agent._extract_image_urls(customer_text)
|
||||||
|
text_without_urls = agent._strip_urls_from_text(customer_text)
|
||||||
|
short_intent = agent._classify_short_customer_text(text_without_urls)
|
||||||
|
|
||||||
|
if incoming_urls:
|
||||||
|
is_related_followup = bool(text_without_urls and agent._is_related_image_followup_intent(text_without_urls))
|
||||||
|
for u in incoming_urls:
|
||||||
|
if u not in state.pending_image_urls:
|
||||||
|
state.pending_image_urls.append(u)
|
||||||
|
if text_without_urls:
|
||||||
|
agent._append_requirement(state, text_without_urls)
|
||||||
|
if is_related_followup:
|
||||||
|
agent._append_requirement(state, "与上一张相关(截图/局部细节)")
|
||||||
|
state.image_count = len(state.pending_image_urls)
|
||||||
|
agent._refresh_quote_phase(state, "collecting")
|
||||||
|
agent._sync_pending_quote_state(message.from_id, state)
|
||||||
|
|
||||||
|
if agent._is_batch_finish_intent(
|
||||||
|
text=customer_text,
|
||||||
|
state=state,
|
||||||
|
has_incoming_urls=bool(incoming_urls),
|
||||||
|
):
|
||||||
|
should_defer = agent._should_defer_batch_quote(state, mark_ready=True)
|
||||||
|
agent._sync_pending_quote_state(message.from_id, state)
|
||||||
|
if should_defer:
|
||||||
|
defer_fallback = "图片和需求我都收齐了,我先整理下,马上给你报总价。"
|
||||||
|
defer_reply = await agent._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="quote_defer_notice",
|
||||||
|
intent_hint="确认已收齐图片与需求,先承接,告知稍后马上报价。",
|
||||||
|
fallback=defer_fallback,
|
||||||
|
)
|
||||||
|
state.last_reply_at = datetime.now()
|
||||||
|
print(f"{agent.C_REPLY}[REPLY->CUSTOMER]{agent.C_RESET} {defer_reply}")
|
||||||
|
return AgentResponse(reply=defer_reply, should_reply=True, need_transfer=False)
|
||||||
|
quote_res = await agent._quote_pending_images(state, message)
|
||||||
|
reply_text = agent._colloquialize_reply(quote_res.get("reply", ""))
|
||||||
|
reply_text = await agent._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()
|
||||||
|
print(f"{agent.C_REPLY}[REPLY->CUSTOMER]{agent.C_RESET} {reply_text}")
|
||||||
|
return AgentResponse(
|
||||||
|
reply=reply_text,
|
||||||
|
should_reply=not need_transfer,
|
||||||
|
need_transfer=need_transfer,
|
||||||
|
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
ack_fallback = "图片收到了,你有补充就继续发,我这边一起看。"
|
||||||
|
ack_intent = (
|
||||||
|
"告知图片已收到;如果客户继续发图就继续收,发完可统一报价。"
|
||||||
|
if not is_related_followup
|
||||||
|
else "告知这是和上一张相关的截图/局部图,已按同一需求一起处理。"
|
||||||
|
)
|
||||||
|
ack = await agent._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="collect_ack",
|
||||||
|
intent_hint=ack_intent,
|
||||||
|
fallback=ack_fallback,
|
||||||
|
)
|
||||||
|
state.last_reply_at = datetime.now()
|
||||||
|
print(f"{agent.C_REPLY}[REPLY->CUSTOMER]{agent.C_RESET} {ack}")
|
||||||
|
return AgentResponse(reply=ack, should_reply=True, need_transfer=False)
|
||||||
|
|
||||||
|
if not state.pending_image_urls:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if text_without_urls:
|
||||||
|
if short_intent == "finish_signal":
|
||||||
|
agent._mark_quote_ready(state)
|
||||||
|
elif short_intent == "progress_query":
|
||||||
|
if state.quote_phase != "ready_to_quote":
|
||||||
|
agent._refresh_quote_phase(state, "waiting_result")
|
||||||
|
elif short_intent == "ack":
|
||||||
|
if state.quote_phase != "ready_to_quote":
|
||||||
|
agent._refresh_quote_phase(state, "collecting")
|
||||||
|
else:
|
||||||
|
agent._append_requirement(state, text_without_urls)
|
||||||
|
agent._refresh_quote_phase(state, "collecting")
|
||||||
|
agent._sync_pending_quote_state(message.from_id, state)
|
||||||
|
if agent._is_find_image_not_edit_conflict(text_without_urls):
|
||||||
|
clarify_fallback = "明白你是要找图,不是做图。你说下要找原图、同款还是高清版,我按这个给你找。"
|
||||||
|
clarify = await agent._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="find_not_edit_clarify",
|
||||||
|
intent_hint="确认客户要找图不是做图,并追问是找原图/同款/高清版。",
|
||||||
|
fallback=clarify_fallback,
|
||||||
|
)
|
||||||
|
state.last_reply_at = datetime.now()
|
||||||
|
print(f"{agent.C_REPLY}[REPLY->CUSTOMER]{agent.C_RESET} {clarify}")
|
||||||
|
return AgentResponse(reply=clarify, should_reply=True, need_transfer=False)
|
||||||
|
|
||||||
|
if state.quote_phase == "ready_to_quote" and state.quote_ready_turns <= 0 and short_intent in {"progress_query", "ack", "finish_signal"}:
|
||||||
|
quote_res = await agent._quote_pending_images(state, message)
|
||||||
|
reply_text = agent._colloquialize_reply(quote_res.get("reply", ""))
|
||||||
|
reply_text = await agent._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()
|
||||||
|
print(f"{agent.C_REPLY}[REPLY->CUSTOMER]{agent.C_RESET} {reply_text}")
|
||||||
|
return AgentResponse(
|
||||||
|
reply=reply_text,
|
||||||
|
should_reply=not need_transfer,
|
||||||
|
need_transfer=need_transfer,
|
||||||
|
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
if short_intent == "progress_query" or agent._is_result_followup_query(text_without_urls):
|
||||||
|
progress_fallback = "我这边在跟进了,一有结果马上发你。"
|
||||||
|
progress = await agent._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="collect_progress",
|
||||||
|
intent_hint="承接客户的进度/结果追问,简短说明正在跟进,有结果会第一时间回复。",
|
||||||
|
fallback=progress_fallback,
|
||||||
|
)
|
||||||
|
state.last_reply_at = datetime.now()
|
||||||
|
print(f"{agent.C_REPLY}[REPLY->CUSTOMER]{agent.C_RESET} {progress}")
|
||||||
|
return AgentResponse(reply=progress, should_reply=True, need_transfer=False)
|
||||||
|
|
||||||
|
if agent._needs_clarification_in_collecting(text_without_urls):
|
||||||
|
ask_fallback = "你再补一句具体要什么效果,我马上按你的要求来。"
|
||||||
|
ask = await agent._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="collect_clarify",
|
||||||
|
intent_hint="客户表达不清,礼貌请对方补充一句关键需求,不要机械,不要生硬。",
|
||||||
|
fallback=ask_fallback,
|
||||||
|
)
|
||||||
|
state.last_reply_at = datetime.now()
|
||||||
|
print(f"{agent.C_REPLY}[REPLY->CUSTOMER]{agent.C_RESET} {ask}")
|
||||||
|
return AgentResponse(reply=ask, should_reply=True, need_transfer=False)
|
||||||
|
if agent._is_batch_finish_intent(
|
||||||
|
text=customer_text,
|
||||||
|
state=state,
|
||||||
|
has_incoming_urls=False,
|
||||||
|
):
|
||||||
|
should_defer = agent._should_defer_batch_quote(state, mark_ready=True)
|
||||||
|
agent._sync_pending_quote_state(message.from_id, state)
|
||||||
|
if should_defer:
|
||||||
|
defer_fallback = "收到,我先把这批图过一遍,马上给你总价。"
|
||||||
|
defer_reply = await agent._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="quote_defer_notice",
|
||||||
|
intent_hint="确认已收齐,先承接并告知稍后马上报价。",
|
||||||
|
fallback=defer_fallback,
|
||||||
|
)
|
||||||
|
state.last_reply_at = datetime.now()
|
||||||
|
print(f"{agent.C_REPLY}[REPLY->CUSTOMER]{agent.C_RESET} {defer_reply}")
|
||||||
|
return AgentResponse(reply=defer_reply, should_reply=True, need_transfer=False)
|
||||||
|
quote_res = await agent._quote_pending_images(state, message)
|
||||||
|
reply_text = agent._colloquialize_reply(quote_res.get("reply", ""))
|
||||||
|
reply_text = await agent._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()
|
||||||
|
print(f"{agent.C_REPLY}[REPLY->CUSTOMER]{agent.C_RESET} {reply_text}")
|
||||||
|
return AgentResponse(
|
||||||
|
reply=reply_text,
|
||||||
|
should_reply=not need_transfer,
|
||||||
|
need_transfer=need_transfer,
|
||||||
|
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
remind_fallback = "需求我记上了,你继续发图,或者让我直接给你报价都行。"
|
||||||
|
remind = await agent._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="collect_remind",
|
||||||
|
intent_hint="确认需求已记录,引导客户继续补图或直接让你报价。",
|
||||||
|
fallback=remind_fallback,
|
||||||
|
)
|
||||||
|
state.last_reply_at = datetime.now()
|
||||||
|
print(f"{agent.C_REPLY}[REPLY->CUSTOMER]{agent.C_RESET} {remind}")
|
||||||
|
return AgentResponse(reply=remind, should_reply=True, need_transfer=False)
|
||||||
@@ -24,8 +24,9 @@ from dotenv import load_dotenv
|
|||||||
from utils.metrics_tracker import emit as metrics_emit
|
from utils.metrics_tracker import emit as metrics_emit
|
||||||
from utils.observability import emit_activity, build_trace_id
|
from utils.observability import emit_activity, build_trace_id
|
||||||
from core.quote_state_machine import QuoteStateMachine
|
from core.quote_state_machine import QuoteStateMachine
|
||||||
from core.rules import Rule, RuleContext, RuleEngine, RuleResult
|
|
||||||
from services.risk_service import RiskService
|
from services.risk_service import RiskService
|
||||||
|
from core.agent_pre_rules import AgentPreRuleService
|
||||||
|
from core.find_image_flow import handle_find_image_batch_flow
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -235,7 +236,7 @@ class CustomerServiceAgent:
|
|||||||
self.batch_quote_delay_turns = 1
|
self.batch_quote_delay_turns = 1
|
||||||
self.quote_state_machine = QuoteStateMachine(delay_turns=self.batch_quote_delay_turns)
|
self.quote_state_machine = QuoteStateMachine(delay_turns=self.batch_quote_delay_turns)
|
||||||
self.risk_service = RiskService()
|
self.risk_service = RiskService()
|
||||||
self._pre_rule_engine = self._build_pre_rule_engine()
|
self.pre_rule_service = AgentPreRuleService(self, self.risk_service)
|
||||||
|
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
raise ValueError("请设置 OPENAI_API_KEY 环境变量")
|
raise ValueError("请设置 OPENAI_API_KEY 环境变量")
|
||||||
@@ -1764,157 +1765,6 @@ class CustomerServiceAgent:
|
|||||||
clean = msg.strip().rstrip("!!??。.~~")
|
clean = msg.strip().rstrip("!!??。.~~")
|
||||||
return clean in self._COOLDOWN_PATTERNS
|
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:
|
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])
|
trace_id = build_trace_id(message.acc_id, message.from_id, message.msg_id, message.msg[:64])
|
||||||
@@ -1929,12 +1779,9 @@ class CustomerServiceAgent:
|
|||||||
metrics_emit("inbound_msg", customer_id=message.from_id, acc_id=message.acc_id)
|
metrics_emit("inbound_msg", customer_id=message.from_id, acc_id=message.acc_id)
|
||||||
# 获取或创建对话状态
|
# 获取或创建对话状态
|
||||||
state = self._get_conversation_state(message.from_id)
|
state = self._get_conversation_state(message.from_id)
|
||||||
pre_ctx = RuleContext(data={"message": message, "state": state, "trace_id": trace_id})
|
pre_response = await self.pre_rule_service.run(message=message, state=state, trace_id=trace_id)
|
||||||
pre_result = await self._pre_rule_engine.run(pre_ctx)
|
if isinstance(pre_response, AgentResponse):
|
||||||
if pre_result.stop:
|
return pre_response
|
||||||
response = pre_result.payload.get("response")
|
|
||||||
if isinstance(response, AgentResponse):
|
|
||||||
return response
|
|
||||||
|
|
||||||
# 检测售前/售后
|
# 检测售前/售后
|
||||||
new_stage = self._detect_stage(message.msg)
|
new_stage = self._detect_stage(message.msg)
|
||||||
@@ -1979,205 +1826,17 @@ class CustomerServiceAgent:
|
|||||||
print(f"[Agent] 订单通知静默({pay_status or order_status}),跳过回复")
|
print(f"[Agent] 订单通知静默({pay_status or order_status}),跳过回复")
|
||||||
return AgentResponse(reply="", should_reply=False, need_transfer=False)
|
return AgentResponse(reply="", should_reply=False, need_transfer=False)
|
||||||
|
|
||||||
# 找图店:先收集图片和需求,等客户确认“发完”后统一报价
|
|
||||||
customer_text, _ = self._split_customer_text(message.msg)
|
customer_text, _ = self._split_customer_text(message.msg)
|
||||||
shop_type = _get_shop_type(message.acc_id or "", message.goods_name or "")
|
shop_type = _get_shop_type(message.acc_id or "", message.goods_name or "")
|
||||||
if shop_type == "find_image" and self._is_batch_quote_enabled(message.from_id, message.acc_id):
|
flow_response = await handle_find_image_batch_flow(
|
||||||
incoming_urls = self._extract_image_urls(customer_text)
|
self,
|
||||||
text_without_urls = self._strip_urls_from_text(customer_text)
|
message=message,
|
||||||
short_intent = self._classify_short_customer_text(text_without_urls)
|
state=state,
|
||||||
|
customer_text=customer_text,
|
||||||
if incoming_urls:
|
shop_type=shop_type,
|
||||||
is_related_followup = bool(text_without_urls and self._is_related_image_followup_intent(text_without_urls))
|
)
|
||||||
for u in incoming_urls:
|
if isinstance(flow_response, AgentResponse):
|
||||||
if u not in state.pending_image_urls:
|
return flow_response
|
||||||
state.pending_image_urls.append(u)
|
|
||||||
if text_without_urls:
|
|
||||||
self._append_requirement(state, text_without_urls)
|
|
||||||
if is_related_followup:
|
|
||||||
self._append_requirement(state, "与上一张相关(截图/局部细节)")
|
|
||||||
state.image_count = len(state.pending_image_urls)
|
|
||||||
self._refresh_quote_phase(state, "collecting")
|
|
||||||
self._sync_pending_quote_state(message.from_id, state)
|
|
||||||
|
|
||||||
if self._is_batch_finish_intent(
|
|
||||||
text=customer_text,
|
|
||||||
state=state,
|
|
||||||
has_incoming_urls=bool(incoming_urls),
|
|
||||||
):
|
|
||||||
should_defer = self._should_defer_batch_quote(state, mark_ready=True)
|
|
||||||
self._sync_pending_quote_state(message.from_id, state)
|
|
||||||
if should_defer:
|
|
||||||
defer_fallback = "图片和需求我都收齐了,我先整理下,马上给你报总价。"
|
|
||||||
defer_reply = await self._render_collection_reply_with_ai(
|
|
||||||
message=message,
|
|
||||||
state=state,
|
|
||||||
scene="quote_defer_notice",
|
|
||||||
intent_hint="确认已收齐图片与需求,先承接,告知稍后马上报价。",
|
|
||||||
fallback=defer_fallback,
|
|
||||||
)
|
|
||||||
state.last_reply_at = datetime.now()
|
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {defer_reply}")
|
|
||||||
return AgentResponse(reply=defer_reply, should_reply=True, need_transfer=False)
|
|
||||||
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()
|
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
|
|
||||||
return AgentResponse(
|
|
||||||
reply=reply_text,
|
|
||||||
should_reply=not need_transfer,
|
|
||||||
need_transfer=need_transfer,
|
|
||||||
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
|
||||||
)
|
|
||||||
|
|
||||||
ack_fallback = "图片收到了,你有补充就继续发,我这边一起看。"
|
|
||||||
ack_intent = (
|
|
||||||
"告知图片已收到;如果客户继续发图就继续收,发完可统一报价。"
|
|
||||||
if not is_related_followup
|
|
||||||
else "告知这是和上一张相关的截图/局部图,已按同一需求一起处理。"
|
|
||||||
)
|
|
||||||
ack = await self._render_collection_reply_with_ai(
|
|
||||||
message=message,
|
|
||||||
state=state,
|
|
||||||
scene="collect_ack",
|
|
||||||
intent_hint=ack_intent,
|
|
||||||
fallback=ack_fallback,
|
|
||||||
)
|
|
||||||
state.last_reply_at = datetime.now()
|
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {ack}")
|
|
||||||
return AgentResponse(reply=ack, should_reply=True, need_transfer=False)
|
|
||||||
|
|
||||||
if state.pending_image_urls:
|
|
||||||
if text_without_urls:
|
|
||||||
# 短句先分类再路由,避免误追加为需求导致上下文漂移
|
|
||||||
if short_intent == "finish_signal":
|
|
||||||
self._mark_quote_ready(state)
|
|
||||||
elif short_intent == "progress_query":
|
|
||||||
if state.quote_phase != "ready_to_quote":
|
|
||||||
self._refresh_quote_phase(state, "waiting_result")
|
|
||||||
elif short_intent == "ack":
|
|
||||||
if state.quote_phase != "ready_to_quote":
|
|
||||||
self._refresh_quote_phase(state, "collecting")
|
|
||||||
else:
|
|
||||||
self._append_requirement(state, text_without_urls)
|
|
||||||
self._refresh_quote_phase(state, "collecting")
|
|
||||||
self._sync_pending_quote_state(message.from_id, state)
|
|
||||||
# 客户明确“找图,不是做图”时,先澄清意图,不继续报价链路
|
|
||||||
if self._is_find_image_not_edit_conflict(text_without_urls):
|
|
||||||
clarify_fallback = "明白你是要找图,不是做图。你说下要找原图、同款还是高清版,我按这个给你找。"
|
|
||||||
clarify = await self._render_collection_reply_with_ai(
|
|
||||||
message=message,
|
|
||||||
state=state,
|
|
||||||
scene="find_not_edit_clarify",
|
|
||||||
intent_hint="确认客户要找图不是做图,并追问是找原图/同款/高清版。",
|
|
||||||
fallback=clarify_fallback,
|
|
||||||
)
|
|
||||||
state.last_reply_at = datetime.now()
|
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {clarify}")
|
|
||||||
return AgentResponse(reply=clarify, should_reply=True, need_transfer=False)
|
|
||||||
|
|
||||||
# 已到报价就绪阶段且等待轮次结束:对“有吗/进度”等追问直接报价
|
|
||||||
if state.quote_phase == "ready_to_quote" and state.quote_ready_turns <= 0 and short_intent in {"progress_query", "ack", "finish_signal"}:
|
|
||||||
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()
|
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
|
|
||||||
return AgentResponse(
|
|
||||||
reply=reply_text,
|
|
||||||
should_reply=not need_transfer,
|
|
||||||
need_transfer=need_transfer,
|
|
||||||
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 客户在追问“找到了吗/没找到吗/多久好”时,优先给进度承接,不走“没听懂”
|
|
||||||
if short_intent == "progress_query" or self._is_result_followup_query(text_without_urls):
|
|
||||||
progress_fallback = "我这边在跟进了,一有结果马上发你。"
|
|
||||||
progress = await self._render_collection_reply_with_ai(
|
|
||||||
message=message,
|
|
||||||
state=state,
|
|
||||||
scene="collect_progress",
|
|
||||||
intent_hint="承接客户的进度/结果追问,简短说明正在跟进,有结果会第一时间回复。",
|
|
||||||
fallback=progress_fallback,
|
|
||||||
)
|
|
||||||
state.last_reply_at = datetime.now()
|
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {progress}")
|
|
||||||
return AgentResponse(reply=progress, should_reply=True, need_transfer=False)
|
|
||||||
|
|
||||||
# 信息不足时先追问,避免误判为“直接报价”
|
|
||||||
if self._needs_clarification_in_collecting(text_without_urls):
|
|
||||||
ask_fallback = "你再补一句具体要什么效果,我马上按你的要求来。"
|
|
||||||
ask = await self._render_collection_reply_with_ai(
|
|
||||||
message=message,
|
|
||||||
state=state,
|
|
||||||
scene="collect_clarify",
|
|
||||||
intent_hint="客户表达不清,礼貌请对方补充一句关键需求,不要机械,不要生硬。",
|
|
||||||
fallback=ask_fallback,
|
|
||||||
)
|
|
||||||
state.last_reply_at = datetime.now()
|
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {ask}")
|
|
||||||
return AgentResponse(reply=ask, should_reply=True, need_transfer=False)
|
|
||||||
if self._is_batch_finish_intent(
|
|
||||||
text=customer_text,
|
|
||||||
state=state,
|
|
||||||
has_incoming_urls=False,
|
|
||||||
):
|
|
||||||
should_defer = self._should_defer_batch_quote(state, mark_ready=True)
|
|
||||||
self._sync_pending_quote_state(message.from_id, state)
|
|
||||||
if should_defer:
|
|
||||||
defer_fallback = "收到,我先把这批图过一遍,马上给你总价。"
|
|
||||||
defer_reply = await self._render_collection_reply_with_ai(
|
|
||||||
message=message,
|
|
||||||
state=state,
|
|
||||||
scene="quote_defer_notice",
|
|
||||||
intent_hint="确认已收齐,先承接并告知稍后马上报价。",
|
|
||||||
fallback=defer_fallback,
|
|
||||||
)
|
|
||||||
state.last_reply_at = datetime.now()
|
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {defer_reply}")
|
|
||||||
return AgentResponse(reply=defer_reply, should_reply=True, need_transfer=False)
|
|
||||||
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()
|
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
|
|
||||||
return AgentResponse(
|
|
||||||
reply=reply_text,
|
|
||||||
should_reply=not need_transfer,
|
|
||||||
need_transfer=need_transfer,
|
|
||||||
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
|
||||||
)
|
|
||||||
|
|
||||||
remind_fallback = "需求我记上了,你继续发图,或者让我直接给你报价都行。"
|
|
||||||
remind = await self._render_collection_reply_with_ai(
|
|
||||||
message=message,
|
|
||||||
state=state,
|
|
||||||
scene="collect_remind",
|
|
||||||
intent_hint="确认需求已记录,引导客户继续补图或直接让你报价。",
|
|
||||||
fallback=remind_fallback,
|
|
||||||
)
|
|
||||||
state.last_reply_at = datetime.now()
|
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {remind}")
|
|
||||||
return AgentResponse(reply=remind, should_reply=True, need_transfer=False)
|
|
||||||
|
|
||||||
# 构建提示词(包含对话状态 + 客户画像)
|
# 构建提示词(包含对话状态 + 客户画像)
|
||||||
user_prompt = self._build_prompt(message, state)
|
user_prompt = self._build_prompt(message, state)
|
||||||
|
|||||||
Reference in New Issue
Block a user