perf: fast-route orchestration and short-reply guard for qingjian
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:
@@ -15,7 +15,7 @@ from .agent_tools import (
|
|||||||
tool_extract_size_pairs,
|
tool_extract_size_pairs,
|
||||||
tool_is_meaningless_short,
|
tool_is_meaningless_short,
|
||||||
)
|
)
|
||||||
from .config import OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL_NAME
|
from .config import AGENT_MAX_ITERS, OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL_NAME
|
||||||
from .models import Decision, DecisionModel, RouteModel
|
from .models import Decision, DecisionModel, RouteModel
|
||||||
from .rules import rules_prompt
|
from .rules import rules_prompt
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ class _AgentRuntime:
|
|||||||
formatter=OpenAIChatFormatter(),
|
formatter=OpenAIChatFormatter(),
|
||||||
toolkit=toolkit,
|
toolkit=toolkit,
|
||||||
memory=InMemoryMemory(),
|
memory=InMemoryMemory(),
|
||||||
max_iters=8,
|
max_iters=max(1, AGENT_MAX_ITERS),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
from .callbacks import post_tianwang_callback
|
from .callbacks import post_tianwang_callback
|
||||||
from .config import AUTO_QUOTE_WAIT_SECONDS, MESSAGE_DEBOUNCE_SECONDS, QINGJIAN_WS_URI
|
from .config import (
|
||||||
|
AUTO_QUOTE_WAIT_SECONDS,
|
||||||
|
MESSAGE_DEBOUNCE_SECONDS,
|
||||||
|
QINGJIAN_WS_URI,
|
||||||
|
SHORT_REPLY_MAX_CHARS,
|
||||||
|
)
|
||||||
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
|
||||||
@@ -27,7 +33,7 @@ class QingjianClient:
|
|||||||
self.pending_images: dict[str, list[str]] = defaultdict(list)
|
self.pending_images: dict[str, list[str]] = defaultdict(list)
|
||||||
self.auto_quote_tasks: dict[str, asyncio.Task] = {}
|
self.auto_quote_tasks: dict[str, asyncio.Task] = {}
|
||||||
self.last_reply_key: dict[str, str] = {}
|
self.last_reply_key: dict[str, str] = {}
|
||||||
self.recent_outbound: dict[str, tuple[str, float]] = {}
|
self.recent_outbound: list[tuple[str, str, str, float]] = []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _customer_key(data: dict) -> str:
|
def _customer_key(data: dict) -> str:
|
||||||
@@ -57,6 +63,7 @@ class QingjianClient:
|
|||||||
text = str(text or "").strip()
|
text = str(text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
text = self._shorten_reply(text)
|
||||||
msg = {
|
msg = {
|
||||||
"msg_id": "",
|
"msg_id": "",
|
||||||
"acc_id": data.get("acc_id", ""),
|
"acc_id": data.get("acc_id", ""),
|
||||||
@@ -70,22 +77,47 @@ class QingjianClient:
|
|||||||
}
|
}
|
||||||
activity_event(self.logger, "send_reply_attempt", trace_id=trace_id, customer_id=data.get("from_id", "-"), msg=text)
|
activity_event(self.logger, "send_reply_attempt", trace_id=trace_id, customer_id=data.get("from_id", "-"), msg=text)
|
||||||
await self.send_message(msg)
|
await self.send_message(msg)
|
||||||
self.recent_outbound[self._customer_key(data)] = (text, time.monotonic())
|
self.recent_outbound.append((str(data.get("acc_id", "")), str(data.get("from_id", "")), text, time.monotonic()))
|
||||||
|
if len(self.recent_outbound) > 200:
|
||||||
|
self.recent_outbound = self.recent_outbound[-200:]
|
||||||
activity_event(self.logger, "send_reply_success", trace_id=trace_id, customer_id=data.get("from_id", "-"), msg=text)
|
activity_event(self.logger, "send_reply_success", trace_id=trace_id, customer_id=data.get("from_id", "-"), msg=text)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_text(text: str) -> str:
|
||||||
|
t = str(text or "").strip()
|
||||||
|
t = re.sub(r"\s+", "", t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
def _shorten_reply(self, text: str) -> str:
|
||||||
|
max_len = max(8, int(SHORT_REPLY_MAX_CHARS))
|
||||||
|
t = str(text or "").strip()
|
||||||
|
if len(t) <= max_len:
|
||||||
|
return t
|
||||||
|
parts = re.split(r"[。!?!?]", t)
|
||||||
|
head = next((p.strip() for p in parts if p and p.strip()), t)
|
||||||
|
if len(head) > max_len:
|
||||||
|
head = head[:max_len].rstrip(",,;;:: ")
|
||||||
|
return head or t[:max_len]
|
||||||
|
|
||||||
def _is_outbound_echo(self, data: dict, msg: str) -> bool:
|
def _is_outbound_echo(self, data: dict, msg: str) -> bool:
|
||||||
"""
|
"""
|
||||||
轻简可能会把我方刚发送文本回推为“收到消息”。
|
轻简可能会把我方刚发送文本回推为“收到消息”。
|
||||||
对同 customer_key 的“短时间完全相同文本”做回环拦截,避免无限对话。
|
对“短时间完全相同文本”做回环拦截,兼容 acc/from 对调回推,避免无限对话。
|
||||||
"""
|
"""
|
||||||
key = self._customer_key(data)
|
in_acc = str(data.get("acc_id", ""))
|
||||||
last = self.recent_outbound.get(key)
|
in_from = str(data.get("from_id", ""))
|
||||||
if not last:
|
in_msg = self._clean_text(msg)
|
||||||
|
now = time.monotonic()
|
||||||
|
if not in_msg:
|
||||||
return False
|
return False
|
||||||
last_msg, ts = last
|
for out_acc, out_to, out_msg, ts in reversed(self.recent_outbound):
|
||||||
if (time.monotonic() - ts) > 120:
|
if (now - ts) > 120:
|
||||||
return False
|
break
|
||||||
return str(msg or "").strip() == last_msg
|
if self._clean_text(out_msg) != in_msg:
|
||||||
|
continue
|
||||||
|
if (out_acc == in_acc and out_to == in_from) or (out_acc == in_from and out_to == in_acc):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
async def _handle_decision(self, data: dict, merged_msg: str, *, auto_quote: bool = False) -> None:
|
async def _handle_decision(self, data: dict, merged_msg: str, *, auto_quote: bool = False) -> None:
|
||||||
key = self._customer_key(data)
|
key = self._customer_key(data)
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ OPENAI_MODEL_NAME = os.getenv("OPENAI_MODEL_NAME", "doubao-seed-2-0-pro-260215")
|
|||||||
|
|
||||||
MESSAGE_DEBOUNCE_SECONDS = int(os.getenv("MESSAGE_DEBOUNCE_SECONDS", "6"))
|
MESSAGE_DEBOUNCE_SECONDS = int(os.getenv("MESSAGE_DEBOUNCE_SECONDS", "6"))
|
||||||
AUTO_QUOTE_WAIT_SECONDS = int(os.getenv("AUTO_QUOTE_WAIT_SECONDS", "18"))
|
AUTO_QUOTE_WAIT_SECONDS = int(os.getenv("AUTO_QUOTE_WAIT_SECONDS", "18"))
|
||||||
|
AGENT_MAX_ITERS = int(os.getenv("AGENT_MAX_ITERS", "3"))
|
||||||
|
FAST_ROUTE_ENABLED = os.getenv("FAST_ROUTE_ENABLED", "1").strip() in {"1", "true", "True", "yes", "on"}
|
||||||
|
SHORT_REPLY_MAX_CHARS = int(os.getenv("SHORT_REPLY_MAX_CHARS", "28"))
|
||||||
|
|
||||||
STORE_BACKEND = os.getenv("STORE_BACKEND", "sqlite").strip().lower()
|
STORE_BACKEND = os.getenv("STORE_BACKEND", "sqlite").strip().lower()
|
||||||
STORE_SQLITE_PATH = os.getenv("STORE_SQLITE_PATH", "").strip()
|
STORE_SQLITE_PATH = os.getenv("STORE_SQLITE_PATH", "").strip()
|
||||||
|
|||||||
@@ -16,5 +16,7 @@ def setup_logger() -> logging.Logger:
|
|||||||
logging.getLogger("agentscope").setLevel(logging.ERROR)
|
logging.getLogger("agentscope").setLevel(logging.ERROR)
|
||||||
logging.getLogger("agentscope.formatter").setLevel(logging.ERROR)
|
logging.getLogger("agentscope.formatter").setLevel(logging.ERROR)
|
||||||
logging.getLogger("agentscope.agent").setLevel(logging.ERROR)
|
logging.getLogger("agentscope.agent").setLevel(logging.ERROR)
|
||||||
|
logging.getLogger("_openai_formatter").setLevel(logging.ERROR)
|
||||||
|
logging.getLogger("_react_agent").setLevel(logging.ERROR)
|
||||||
|
|
||||||
return logger
|
return logger
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ 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
|
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
|
||||||
|
|
||||||
@@ -35,9 +42,13 @@ class Orchestrator:
|
|||||||
"order_status": order_status,
|
"order_status": order_status,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 先风控
|
msg = str(context.get("msg", "") or "")
|
||||||
risk_decision = await self.risk.decide(merged_ctx)
|
goods_name = str(context.get("goods_name", "") or "")
|
||||||
if risk_decision.action in {"transfer"}:
|
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"
|
route = "risk"
|
||||||
new_state = evolve_after_sales_state(
|
new_state = evolve_after_sales_state(
|
||||||
{**prev_state, **(risk_decision.state_patch or {})},
|
{**prev_state, **(risk_decision.state_patch or {})},
|
||||||
@@ -51,7 +62,23 @@ class Orchestrator:
|
|||||||
self.store.append_event(customer_key, "decision", {"route": route, "action": risk_decision.action, "reason": risk_decision.reason})
|
self.store.append_event(customer_key, "decision", {"route": route, "action": risk_decision.action, "reason": risk_decision.reason})
|
||||||
return route, risk_decision, new_state
|
return route, risk_decision, new_state
|
||||||
|
|
||||||
route, route_reason = await self.router.route(merged_ctx)
|
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)
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ def rules_prompt() -> str:
|
|||||||
"10) 尺寸明显超大(如>=2m*2m): 提示需补图/重做边缘, 不要直接承诺一模一样。\n"
|
"10) 尺寸明显超大(如>=2m*2m): 提示需补图/重做边缘, 不要直接承诺一模一样。\n"
|
||||||
"11) 店铺差异化: 按 acc_id/persona 口吻回复, 保持真人聊天。\n"
|
"11) 店铺差异化: 按 acc_id/persona 口吻回复, 保持真人聊天。\n"
|
||||||
"12) 最终输出只允许一个动作, 不能混合。\n"
|
"12) 最终输出只允许一个动作, 不能混合。\n"
|
||||||
|
"13) reply 必须简短: 优先 1 句, 一般不超过 28 个汉字, 禁止长段解释。\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","reply":"","transfer_msg":"","quote_mode":"flush_pending|analyze_current_or_recent|collect_only","reason":""}'
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user