feat: add online evolution loop and 5% gray risk-policy rollout

This commit is contained in:
2026-02-28 22:03:30 +08:00
parent fec5aaf8f3
commit d497e8d42a
9 changed files with 948 additions and 0 deletions

View File

@@ -11,6 +11,8 @@ import asyncio
import random
import hashlib
import re
import json
from pathlib import Path
from typing import Optional, Dict, List, Any, Tuple
from datetime import datetime
from pydantic import BaseModel, Field
@@ -162,6 +164,7 @@ class CustomerServiceAgent:
C_TOOL = "\033[93m" # yellow
C_REPLY = "\033[92m" # green
C_MUTED = "\033[90m" # gray
_DEFAULT_EVOLUTION_CANDIDATE = Path("config") / "evolution_candidate.json"
def __init__(self, skills_dir: str = "skills"):
self.api_key = os.getenv("OPENAI_API_KEY")
@@ -175,6 +178,7 @@ class CustomerServiceAgent:
self.conversations: Dict[str, ConversationState] = {}
# 多轮对话历史PydanticAI ModelMessage 列表按客户ID存储
self.message_histories: Dict[str, list] = {}
self.evolution_candidate = self._load_evolution_candidate()
# 加载 skills 内容
self.skills_content = load_skill_md(skills_dir)
@@ -230,6 +234,64 @@ class CustomerServiceAgent:
# 注册工具
self._register_tools()
def _load_evolution_candidate(self) -> Dict[str, Any]:
"""读取自我进化候选配置(灰度策略),读取失败时返回空。"""
try:
path = Path(os.getenv("EVOLUTION_CANDIDATE_PATH", str(self._DEFAULT_EVOLUTION_CANDIDATE)))
if not path.exists():
return {}
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return {}
return data
except Exception:
return {}
def _evolution_gray_percent(self) -> int:
"""灰度比例,默认 5%"""
try:
env_pct = os.getenv("EVOLUTION_GRAY_PERCENT", "").strip()
if env_pct:
pct = int(float(env_pct))
else:
pct = int(((self.evolution_candidate or {}).get("gray_percent", 5)))
return max(0, min(100, pct))
except Exception:
return 5
def _evolution_enabled_for_customer(self, customer_id: str) -> bool:
"""按客户哈希稳定灰度命中,命中后启用候选策略。"""
cand = self.evolution_candidate or {}
if str(cand.get("status", "")).strip() != "ready_for_gray_5_percent":
return False
if not customer_id:
return False
pct = self._evolution_gray_percent()
if pct <= 0:
return False
digest = hashlib.md5(customer_id.encode("utf-8")).hexdigest()
bucket = int(digest[:8], 16) % 100
hit = bucket < pct
if hit:
metrics_emit("evolution_gray_hit", customer_id=customer_id, percent=pct, version=str(cand.get("version", "")))
return hit
def _evolution_has_proposal(self, proposal_id: str) -> bool:
cand = self.evolution_candidate or {}
for p in cand.get("proposals", []) or []:
if str((p or {}).get("id", "")).strip() == proposal_id:
return True
return False
@staticmethod
def _is_service_risk_inquiry(text: str) -> bool:
"""识别退款/投诉等服务风险场景。"""
s = (text or "").strip().lower()
if not s:
return False
kw = ("退款", "退货", "投诉", "差评", "举报", "欺骗", "骗人", "起诉", "法院", "生气", "不满意")
return any(k in s for k in kw)
@staticmethod
def _log_block(title: str, content: str):
"""统一的控制台分层日志输出。"""
@@ -1637,6 +1699,17 @@ class CustomerServiceAgent:
transfer_msg = TRANSFER_MESSAGE
metrics_emit("transfer_to_human", customer_id=message.from_id, acc_id=message.acc_id)
# 自我进化候选策略灰度(默认 5%):风险投诉场景强制转人工,并补安抚话术
evo_hit = self._evolution_enabled_for_customer(message.from_id)
if evo_hit and self._is_service_risk_inquiry(message.msg):
if self._evolution_has_proposal("policy-risk-transfer"):
need_transfer = True
transfer_msg = TRANSFER_MESSAGE
metrics_emit("evolution_force_transfer", customer_id=message.from_id, acc_id=message.acc_id)
if self._evolution_has_proposal("tone-empathy-pack"):
reply_text = "抱歉让您不舒服了,这边先为您转接人工专员马上处理。"
metrics_emit("evolution_empathy_reply", customer_id=message.from_id, acc_id=message.acc_id)
# 未成交记录:客户表达放弃且已报价过(转人工不记录)
customer_text, _ = self._split_customer_text(message.msg)
no_convert_keywords = ["算了", "不要了", "不做了", "下次再说", "先不弄了"]
@@ -1649,6 +1722,8 @@ class CustomerServiceAgent:
# 需要转接时不把原始回复发给客户
should_reply = bool(reply_text and reply_text.strip()) and not need_transfer
if evo_hit and need_transfer and self._evolution_has_proposal("tone-empathy-pack"):
should_reply = True
# 记录本次回复时间,供冷却期判断
if should_reply: