refactor: migrate workflow to v2 core and archive legacy modules

This commit is contained in:
2026-03-04 21:52:24 +08:00
parent e1ce17f2aa
commit fa61b11b02
156 changed files with 1781 additions and 2066 deletions

201
legacy/agent_pre_rules.py Normal file
View File

@@ -0,0 +1,201 @@
from __future__ import annotations
import logging
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:
message = ctx.get("message")
state = ctx.get("state")
return self.agent._should_handle_as_meaningless_short_text(state, 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
logger.info("[Agent] 冷却期静默(距上次回复 %ss%r", elapsed, message.msg)
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()
logger.info("[REPLY->CUSTOMER] %s", 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)},
)
logger = logging.getLogger("cs_agent")

163
legacy/agent_prompts.py Normal file
View File

@@ -0,0 +1,163 @@
from __future__ import annotations
def _attach_skill_docs(prompt: str, *skill_docs: str) -> str:
docs = [d for d in skill_docs if d]
if not docs:
return prompt
return prompt + "\n\n=== 角色技能 ===\n" + "\n\n".join(docs)
def build_system_prompt(reply_persona: str, skill_pre_sales: str, skill_style: str) -> str:
base_prompt = """你是淘宝找原图店的客服,对外说帮客户找原图/高清图,实际是帮客户把模糊图处理成高清版,擅长快速成交。
核心原则:快、准、狠。**回复要像真人聊天,自然多变,禁止套模板、背台词。**
【你拥有的工具,按需调用】
- analyze_image(url):客户确认“图片发完”后调用,分析复杂度用于统一报价
- process_image_gemini(customer_id):客户付款或说「安排/处理」时调用,走完整流程
- remove_background(image_url):只要去背景时单独调用
- perspective_correct(image_url):只要透视矫正时调用(需白底图)
- extract_pattern_tool(image_url, prompt, aspect_ratio):只要印花提取时调用
- enhance_image_tool(image_url):只要高清增强时调用
- color_match_tool(orig_url, result_url, strength):颜色匹配
- trim_border_tool(image_url):裁切四周背景边
- resize_image(image_url, width, height)改尺寸height=0则等比缩放
- get_customer_info(customer_id):老客户来时调用,了解历史消费和性格
- transfer_to_human():退款/投诉/情绪激动时调用
- update_contact_info(customer_id, type, value):客户说出邮箱/手机/微信时调用type填"email"/"phone"/"wechat"
- record_quote(customer_id, price, description):每次报价后调用,记录报价保持一致
- calculate_bulk_price(count, complexities):客户要做多张图时调用,获取打包价
- save_customer_note(customer_id, note):记录其他重要信息
【报价规则】
- 价格必须为5的整数倍10/15/20/25/30禁止报12、17、23等
- 客户只是文字询价,没发图 → 自然引导发图,不报价
- 收到图片先收集,不立刻报单张价;等客户明确“发完了/统一报价”后,再统一报价
- 报价和推成交的话术要自然多变,跟着客户语气走,不要每次都一样
- 客户确认发完后,分析完成的下一句话必须是明确报价
- 报价后立刻推成交,不等客户反应
【文字加价规则】⚠️ 重要
- 含文字很多时不能低价,有文字跟没文字是两个价格
- 含文字的图必须 complex 起步20 元以上)
- 客户嫌贵时明确告知:「有文字跟没文字是两个价格」
- 简单图但含文字 → normal 价格15-20 元)
- normal 图含文字 → complex 价格20-25 元)
【压价规则】
- 客户说「贵」「有点贵」「算了」「便宜点」→ 直接让价一次,禁止追问「什么问题」「说清楚点」
- 只让价一次,话术自然变化
- 第二次压价:表达最低了即可,换着说
【转接规则】
- 退款/退货/投诉/情绪激动/test → 调用 transfer_to_human()
- 调用后只回复"转接",不加其他内容
【找茬客户识别】⚠️ 重要
识别以下高风险信号,建议不做这单:
1. 下单后立即申请退款
2. 从高价砍到低价30→10 元)
3. 反复问"不满意可以退吗"2 次以上)
4. 质疑服务内容("源文件还是什么"
5. 质疑价值("就一张图片"
6. 问"小一点就快一点的嘛"(想占便宜)
7. 重复问同一个问题(想找麻烦)
识别到以上 3 个以上信号 → 建议转人工或直接拒绝接单
话术:「不好意思,这单做不了」「去别家做吧」
【售后规则】
- 催进度:自然回复在做了/快了/马上好之类
- 要修改:自然问哪里要改
【禁忌】
- 没看到图不报价
- 不说"不行/不可以"
- 不解释技术细节
- 不给价格区间
- 回复不超过2句话
- 绝对禁止输出任何内部独白或状态说明,包括但不限于:"无需回复""已完成""已经完成""不需要回复""流程结束""操作完成""任务完成""记录完成""报价已记录"
- 每次必须输出真实的、发给客户看的回复文字,哪怕只有一句话"""
base_prompt += f"\n\n【人设语气】\n- 人设:{reply_persona}\n- 语气像真人店主,不官腔,不机械,不背模板。"
return _attach_skill_docs(base_prompt, skill_pre_sales, skill_style)
def build_natural_reply_prompt(reply_persona: str, skill_style: str) -> str:
base = f"""你是淘宝店主客服,专门把系统给你的“回复意图”改写成自然的一句话或两句话。
人设:{reply_persona}
规则:
- 只输出发给客户的话,不要解释你的思考。
- 口语化、简短、有温度,避免“这个需求我收到了”这类机械表达。
- 不要编造价格、订单、进度;只按输入意图表达。
- 默认不超过2句话。"""
return _attach_skill_docs(base, skill_style)
def build_after_sale_prompt(skill_after_sale: str, skill_style: str) -> str:
base = """你是淘宝客服的售后助手,负责售后阶段的自然沟通与处理进度反馈。
核心:简洁、自然、不解释技术细节、尽量不调用报价相关工具。
规则:
- 已付款客户优先:确认安排、说明进度、承诺时间点
- 修改需求:礼貌询问具体改哪里,尽量一句话
- 催进度:自然回复在做了/快了/马上好,给预计时间
- 投诉/情绪激动/退款:转人工
- 输出不超过2句话不说内部状态"""
return _attach_skill_docs(base, skill_after_sale, skill_style)
def build_pricing_prompt(
*,
min_price_floor: int,
case_library_link: str,
skill_pricing: str,
skill_style: str,
) -> str:
base = f"""你是淘宝客服的报价助手,负责在客户明确提到价格/询价时快速给出自然报价并推动成交。
规则:
- 收到图片或历史有图片依据时尽量结合复杂度给出单价价格为5的整数倍
- 没有图片时引导发图,不给价格区间
- 报价后紧跟一句推动成交,话术自然不重复,避免机械重复“最低了”
- 客户说“有点贵/优惠点/两张优惠点”时,优先给打包价或数量优惠,不要只会拒绝
- 客户说“不放心/先看效果”时,先建立信任:可发案例链接 {case_library_link},并说明不满意可退
- 可直接复用这条信任话术(按需微调,不要每次完全一样):
小妹整理了一些案例图,亲点这个链接就能看到啦({case_library_link})。
有什么想要的效果随时告诉我哈,我这边都可以按您的要求来做哦~/:065 效果不好不满意,我们这边包退的哦。
- 最低价不低于{min_price_floor}元,客户出价低于底线时礼貌拒绝(不好意思)
- 输出不超过2句话"""
return _attach_skill_docs(base, skill_pricing, skill_style)
def build_processing_prompt(skill_after_sale: str, skill_style: str) -> str:
base = """你是淘宝客服的处理助手,负责在客户说安排/处理/开始做或已付款的场景下进行处理安排与进度反馈。
规则:
- 已付款或明确要求开始时,确认安排并给预计时间点
- 可调用处理流程工具
- 投诉/退款时转人工
- 输出不超过2句话"""
return _attach_skill_docs(base, skill_after_sale, skill_style)
def build_similar_prompt(skill_pre_sales: str, skill_style: str) -> str:
base = """你是淘宝客服的相似图助手,客户问“有一样的吗/类似的吗/同款吗”时,给出自然回复与参考建议。
规则:
- 先确认可以找类似款,建议拍后我发参考图
- 如已知图案/类型,简要说明“同类型都有”,推动成交
- 输出不超过2句话"""
return _attach_skill_docs(base, skill_pre_sales, skill_style)
def build_order_prompt(skill_after_sale: str, skill_style: str) -> str:
base = """你是淘宝客服的订单助手,负责系统订单通知的处理。
规则:
- 已付款时自然确认安排;其他状态静默(输出空字符串)
- 输出不超过1句话"""
return _attach_skill_docs(base, skill_after_sale, skill_style)
def build_risk_prompt(skill_risk: str, skill_style: str) -> str:
base = """你是淘宝客服的风控助手,负责敏感/违规内容的前置拦截与替代话术。
规则:
- 黄色/擦边/涉政/政治人物/政治事件/政治图片/地图类内容等不接单,礼貌拒绝
- 输出不超过1句话"""
return _attach_skill_docs(base, skill_risk, skill_style)

182
legacy/ai_reply_flow.py Normal file
View File

@@ -0,0 +1,182 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Optional, Tuple
from core.post_ops import negotiation_strategy_reply
logger = logging.getLogger("cs_agent")
if TYPE_CHECKING:
from core.pydantic_ai_agent import AgentDeps, ConversationState, CustomerMessage, CustomerServiceAgent
def _select_agent_by_intent(
agent: "CustomerServiceAgent",
message: "CustomerMessage",
state: "ConversationState",
) -> Tuple[Optional[Any], str]:
"""
AI 意图优先路由;识别不到时返回 (None, "intent:none"),由关键词兜底。
"""
try:
from utils.intent_analyzer import detect_intent
decision = detect_intent(message.msg or "")
intent = (decision.intent or "").strip()
source = decision.source or "none"
score = float(decision.score or 0.0)
except Exception:
intent, source, score = "", "error", 0.0
if not intent:
return None, "intent:none"
if intent in ("询价", "砍价"):
return agent.agent_pricing, f"intent:{intent}|src:{source}|score:{score:.3f}"
if intent in ("修改", "加急"):
return agent.agent_processing, f"intent:{intent}|src:{source}|score:{score:.3f}"
if intent == "售后":
return agent.agent_after_sale, f"intent:{intent}|src:{source}|score:{score:.3f}"
if intent == "转接":
return agent.agent_after_sale, f"intent:{intent}|src:{source}|score:{score:.3f}"
if intent in ("打招呼", "批量", "发图"):
target = agent.agent_after_sale if state.stage == "售后" else agent.agent
return target, f"intent:{intent}|src:{source}|score:{score:.3f}"
return None, f"intent:unmapped:{intent}|src:{source}|score:{score:.3f}"
def select_target_agent(agent: "CustomerServiceAgent", message: "CustomerMessage", state: "ConversationState") -> Tuple[Any, str]:
msg_lower = message.msg.lower()
pricing_kw = ["多少钱", "多少一张", "报价", "给个价", "几块", "价位", "能便宜点吗"]
processing_kw = ["安排", "处理一下", "开始做", "做一下", "尽快", "加急", "付款了", "已付款"]
similar_kw = ["有一样的", "有一样吗", "一样的吗", "类似的", "类似的吗", "同款", "相似", "类似吗"]
order_markers = ["[系统订单信息]", "订单状态", "买家已付款"]
risk_kw = [
"黄色",
"擦边",
"色情",
"涉黄",
"涉政",
"政治",
"",
"不雅",
"天安门",
"政治人物",
"政治事件",
"领导人",
"党政",
"习近平",
"毛泽东",
"邓小平",
"江泽民",
"胡锦涛",
"特朗普",
"拜登",
"普京",
"泽连斯基",
"地图",
"地形图",
"行政区划图",
"卫星地图",
]
target_agent = agent.agent_after_sale if state.stage == "售后" else agent.agent
ai_target, ai_reason = _select_agent_by_intent(agent, message, state)
if ai_target is not None:
return ai_target, ai_reason
risk_hit = any(k in msg_lower for k in risk_kw) or agent._is_political_inquiry(message.msg) or agent._is_map_inquiry(message.msg)
if risk_hit:
return agent.agent_risk, "keyword:risk"
if any(k in message.msg for k in order_markers):
return agent.agent_order, "keyword:order"
if any(k in msg_lower for k in processing_kw):
return agent.agent_processing, "keyword:processing"
if any(k in msg_lower for k in pricing_kw):
return agent.agent_pricing, "keyword:pricing"
if any(k in msg_lower for k in similar_kw):
return agent.agent_similar, "keyword:similar"
return target_agent, "fallback:default"
async def execute_ai_turn(
agent: "CustomerServiceAgent",
*,
message: "CustomerMessage",
state: "ConversationState",
user_prompt: str,
deps: "AgentDeps",
history: list,
) -> str:
target_agent, route_reason = select_target_agent(agent, message, state)
logger.info("[路由] %s", route_reason)
result = await target_agent.run(user_prompt, deps=deps, message_history=history)
agent.message_histories[message.from_id] = result.all_messages()[-30:]
reply_text = agent._colloquialize_reply(agent._normalize_reply_text(result.output))
strategy_reply = negotiation_strategy_reply(message.msg, state)
if strategy_reply:
reply_text = strategy_reply
try:
from config.config import MIN_PRICE_FLOOR
import re
offer = None
m = re.search(r"(\d{1,4})\s*(?:元|块|块钱|元钱)\b", message.msg)
if m:
offer = int(m.group(1))
else:
m2 = re.search(r"(?:能|可以|可否|能否)\s*(\d{1,4})\b", message.msg)
offer = int(m2.group(1)) if m2 else None
st = agent._get_conversation_state(message.from_id)
floor = st.last_min_price if isinstance(st.last_min_price, int) and st.last_min_price > 0 else MIN_PRICE_FLOOR
if offer is not None and offer < floor:
reply_text = "不好意思"
except Exception:
pass
try:
from config.config import MIN_PRICE_FLOOR
import re
st = agent._get_conversation_state(message.from_id)
floor = st.last_min_price if isinstance(st.last_min_price, int) and st.last_min_price > 0 else MIN_PRICE_FLOOR
def _adjust(text: str) -> str:
def _repl(m: Any):
num = int(m.group(1))
adj = max(floor, round(num / 5) * 5)
return m.group(0).replace(str(num), str(adj))
patterns = [
r"按(\d{1,4})元",
r"报价[:]\s*(\d{1,4})\s*元",
r"(\d{1,4})\s*元一张",
r"打包(\d{1,4})\s*元",
]
t = text
for p in patterns:
t = re.sub(p, _repl, t)
return t
reply_text = _adjust(reply_text or "")
except Exception:
pass
for msg in result.new_messages():
for part in getattr(msg, "parts", []):
part_type = type(part).__name__
if "ToolCall" in part_type:
logger.info(
"[THINK/TOOL_CALL] %s(%s)",
getattr(part, "tool_name", ""),
getattr(part, "args", ""),
)
elif "ToolReturn" in part_type:
ret = str(getattr(part, "content", ""))[:120]
logger.info("[THINK/TOOL_RETURN] %s", ret)
logger.info("[THINK/RAW_OUTPUT] %r", reply_text)
return reply_text

View File

@@ -0,0 +1,181 @@
from __future__ import annotations
import random
from typing import Any
def calc_requirement_surcharge(requirements: list[str]) -> dict[str, Any]:
"""
把客户补充需求做成结构化加价,避免纯靠模型自由发挥导致价格波动。
返回:
{"extra": int, "hits": List[str]}
"""
text = " ".join(requirements or [])
rules = [
(["分层", "psd", "源文件"], 30, "分层/源文件"),
(["去背景", "抠图", "透明底", "白底"], 5, "去背景"),
(["换背景", "换场景", "合成", "转到", "换到", "放到", "贴到", "移到", "套到", "图案上去", "元素放到"], 10, "跨图合成/换背景"),
(["改字", "改文字", "替换文字", "排版"], 10, "改文字/排版"),
(["调色", "改色", "换色", "配色"], 5, "调色"),
(["多版本", "多个版本", "两版", "三版"], 10, "多版本"),
(["加急", "今天要", "马上要", "尽快"], 10, "加急"),
]
total = 0
hits: list[str] = []
for keywords, fee, label in rules:
if any(k in text for k in keywords):
total += fee
hits.append(f"{label}+{fee}")
total = min(total, 60)
total = round(total / 5) * 5
return {"extra": total, "hits": hits}
def build_batch_quote_reply(
*,
results: list[tuple[str, dict[str, Any]]],
total_suggest: int,
bundle_price: int,
req_fee: dict[str, Any],
) -> str:
"""构建分图明细 + 单条总报价可选项回复。"""
complexity_map = {
"simple": "简单",
"normal": "常规",
"complex": "复杂",
"hard": "高难",
}
detail_lines: list[str] = []
for i, (_, r) in enumerate(results, 1):
p = int(r.get("price_suggest", 20) or 20)
cx = complexity_map.get(str(r.get("complexity", "normal")), "常规")
reason = str(r.get("reason", "常规处理")).replace("\n", " ").strip()
if len(reason) > 18:
reason = reason[:18] + "..."
detail_lines.append(f"{i}{p}元({cx}{reason}")
extra = int(req_fee.get("extra", 0) or 0)
single_total = round((total_suggest + extra) / 5) * 5
req_hit = "".join(req_fee.get("hits", [])) if req_fee.get("hits") else ""
if len(results) == 1:
line = detail_lines[0].replace("图1", "这张:")
heads = [
"这张我看过了,先给你报下:",
"这张可以做,价格给你报下:",
"看了这张图,报价如下:",
"我先按这张给你算下:",
"这张处理没问题,我给你报个实在价:",
"我看完这张了,价格给你说下:",
"按这张图的难度,报价是:",
"这张我已经评估完了,先给你个价格:",
]
lines = [f"{random.choice(heads)}{line.split('', 1)[1]}"]
if req_hit:
lines.append(f"按你的需求另加{extra}元({req_hit})。")
tails = [
f"这张做下来共{single_total}元,定了我马上开工。",
f"合下来是{single_total}元,你点头我这边立刻安排。",
f"总价{single_total}元,可以的话我现在就给你做。",
f"这一张算下来{single_total}元,你说开做我就马上弄。",
f"给你按{single_total}元做,确定的话我现在就排上。",
f"这张我按{single_total}元给你做,没问题就直接开始。",
f"这张最终{single_total}元,你点头我立刻开干。",
f"这张就按{single_total}元走,你确认我就马上安排。",
]
lines.append(random.choice(tails))
return "\n".join(lines)
heads = [
"我先按这几张给你报一下:",
"这几张我都看过了,价格给你列一下:",
"我把每张价格先给你说清楚:",
"我先把这几张的价格拆开给你看:",
"这几张我都评估过了,报价给你写明白:",
"先别急,我把每张大概价给你列出来:",
"我按这批图先报个明细给你:",
"我先把每张费用和总价给你算出来:",
]
lines = [random.choice(heads)]
lines.extend(detail_lines)
if req_hit:
lines.append(f"需求加价:+{extra}元({req_hit}")
option_line = random.choice([
f"可选:按单张做(共{single_total}元),或打包做({bundle_price}元,会更省一点)。",
f"可选:单张算下来一共{single_total}元;打包给你{bundle_price}元,更划算。",
f"可选:你按单张做共{single_total}元,按打包做我给你{bundle_price}元。",
f"可选:分开做总共{single_total}元,打包做{bundle_price}元(省一点)。",
f"可选:按张算共{single_total}元;直接打包{bundle_price}元。",
])
lines.append(option_line)
lines.append(
random.choice(
[
"你定一个,我这边马上开工。",
"你选个方案,我立刻给你安排上。",
"你拍板就行,我这边马上开做。",
"你看选哪个合适,我这边马上给你做。",
"你一句话定下来,我现在就给你安排。",
]
)
)
return "\n".join(lines)
def prepare_batch_intake(state: Any) -> dict[str, Any]:
"""Stage 1: 收集阶段,标准化输入并做上限约束。"""
urls = list(getattr(state, "pending_image_urls", []) or [])
if not urls:
return {"ok": False, "reply": "你先把图片发我,我看完再给你统一报价。", "need_transfer": False}
try:
from config.config import BATCH_ANALYZE_CONCURRENCY, BATCH_MAX_IMAGES
max_images = max(1, int(BATCH_MAX_IMAGES))
analyze_concurrency = max(1, int(BATCH_ANALYZE_CONCURRENCY))
except Exception:
max_images = 12
analyze_concurrency = 3
if len(urls) > max_images:
return {
"ok": False,
"reply": f"这次图片有点多({len(urls)}张),我先按前{max_images}张处理报价,剩下的下一批继续发我。",
"need_transfer": False,
}
return {
"ok": True,
"urls": urls[:max_images],
"requirements": list(getattr(state, "pending_requirements", []) or []),
"analyze_concurrency": analyze_concurrency,
}
def assess_batch_risk(results: list[tuple[str, dict[str, Any]]]) -> dict[str, list[str]]:
"""Stage 2.5: 分离可做和风险图。"""
unsafe: list[str] = []
dense_text_reject: list[str] = []
for i, (_, r) in enumerate(results, 1):
if r.get("feasibility") == "no" or r.get("risk") == "high":
unsafe.append(f"{i}")
note = str(r.get("note", "") or "")
if "文字内容过于密集" in note or "密集文字" in note:
dense_text_reject.append(f"{i}")
return {"unsafe": unsafe, "dense_text_reject": dense_text_reject}
def build_batch_pricing_plan(results: list[tuple[str, dict[str, Any]]], requirements: list[str]) -> dict[str, Any]:
"""Stage 3: 报价计算(图片成本 + 需求加价 + 打包价)。"""
total_suggest = sum(int(r.get("price_suggest", 20) or 20) for _, r in results)
req_fee = calc_requirement_surcharge(requirements)
if len(results) == 2:
bundle_price = max(10, total_suggest - 5)
elif len(results) >= 3:
bundle_price = max(10, round(total_suggest * 0.9 / 5) * 5)
else:
bundle_price = total_suggest
bundle_price += int(req_fee.get("extra", 0) or 0)
bundle_price = round(bundle_price / 5) * 5
return {
"total_suggest": total_suggest,
"req_fee": req_fee,
"bundle_price": bundle_price,
}

BIN
legacy/chat_log_db/chats.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,432 @@
from __future__ import annotations
import random
from typing import Any
def classify_short_customer_text(text: str) -> str:
"""
短句分类器(状态机前置):
- finish_signal: 发图完成,可报价
- progress_query: 追问进度/结果
- ack: 简短确认
- unknown: 未识别
"""
s = (text or "").strip()
if not s:
return "unknown"
if len(s) > 8:
return "unknown"
finish_kw = (
"没了",
"没有了",
"就这",
"就这张",
"就这一张",
"就这一个",
"就一个",
"先这些",
"就这些",
"发完了",
"都发完了",
)
if any(k in s for k in finish_kw):
return "finish_signal"
progress_kw = (
"有吗",
"有没",
"有没有",
"找到了吗",
"找到了没",
"没找到吗",
"找到没",
"找到没有",
"进度",
"结果",
"多久好",
"什么时候好",
"好了没",
"弄好了吗",
"做了没",
"高清",
"发我",
"重新发",
"你重新发给我",
)
if any(k in s for k in progress_kw) or s in {"?", "", "在吗", "人呢"}:
return "progress_query"
ack_kw = ("", "嗯嗯", "", "好的", "", "可以", "ok", "OK", "收到", "明白")
if s in ack_kw:
return "ack"
return "unknown"
def is_batch_finish_signal(text: str) -> bool:
"""客户是否表达“图发完了,可以统一报价”"""
if not text:
return False
if classify_short_customer_text(text) == "finish_signal":
return True
finish_keywords = [
"发完了",
"都发完了",
"发齐了",
"齐了",
"先这些",
"就这些",
"全部",
"一起报",
"统一报价",
"总共多少钱",
"一共多少钱",
"打包价",
"总价",
"报价吧",
"报个总价",
"给个总价",
"没了",
"没有了",
"没图了",
"就这",
"就这张",
"就这一张",
"就这一个",
"就一个",
"先报吧",
"报下价",
"报个价",
"可以报价了",
"能报吗",
]
return any(k in text for k in finish_keywords)
def is_cross_image_composite_intent(text: str) -> bool:
"""
识别多图跨图修改意图A图元素放到B图
A图的图案转到B图、这个图案放到另一张上。
"""
s = (text or "").strip()
if not s:
return False
pair_marks = ("a图", "b图", "第一张", "第二张", "这张", "那张", "上一张", "另一张")
op_kw = (
"转到",
"换到",
"放到",
"贴到",
"移到",
"套到",
"合成",
"融合",
"替换到",
"图案上去",
"字放到",
"元素放到",
"logo放到",
)
return any(k in s.lower() for k in pair_marks) and any(k in s for k in op_kw)
def is_batch_finish_intent(text: str, state: Any, has_incoming_urls: bool) -> bool:
"""
语义结束识别:
- 显式口令:发完了/统一报价
- 隐式意图:询价/砍价
- 单图需求明确:如“这个门头上面的字做一下”可直接进入报价
"""
if not text:
return False
if is_batch_finish_signal(text):
return True
if has_incoming_urls:
return False
if not (getattr(state, "pending_image_urls", None) or []):
return False
try:
from utils.intent_analyzer import detect_intent
intent = detect_intent(text).intent
except Exception:
intent = ""
if intent in ("询价", "砍价"):
return True
msg = (text or "").strip()
if not msg:
return False
single_image_action_kw = (
"做一下",
"改一下",
"处理一下",
"就这张",
"按这个做",
"照这个做",
"这个门头",
"上面的字",
"这个字",
"这个图做",
"能做吗",
)
multi_image_finish_kw = (
"就这些",
"就这几张",
"按这几张",
"这几张一起做",
"一起做一下",
"先按这些",
"先按这几张",
"直接报价",
"现在报价",
"看下报价",
"先报个总价",
"总价多少",
"一起多少钱",
"先做这几张",
)
hold_kw = ("还有", "再发", "先等", "稍后", "等会", "回头")
image_count = len(getattr(state, "pending_image_urls", []) or [])
if image_count == 1:
if any(k in msg for k in single_image_action_kw) and not any(k in msg for k in hold_kw):
return True
elif image_count >= 2:
if any(k in msg for k in multi_image_finish_kw) and not any(k in msg for k in hold_kw):
return True
if is_cross_image_composite_intent(msg) and not any(k in msg for k in hold_kw):
return True
return False
def is_related_image_followup_intent(text: str) -> bool:
"""识别“新发的是上一张的截图/局部细节”的关联意图。"""
s = (text or "").strip().lower()
if not s:
return False
relation_kw = (
"截图",
"截屏",
"局部",
"细节",
"放大",
"裁剪",
"同一张",
"同一幅",
"上一张",
"上张",
"前一张",
"前面那张",
"刚才那张",
"这个是上面",
"这个是那张",
"补一张细节",
"补个截图",
)
return any(k in s for k in relation_kw)
def is_result_followup_query(text: str) -> bool:
"""识别客户在找图流程中的结果/进度追问。"""
if classify_short_customer_text(text) == "progress_query":
return True
s = (text or "").strip()
if not s:
return False
followup_kw = (
"找到了吗",
"没找到吗",
"找到没",
"找到没有",
"找到了没",
"有吗",
"有没",
"有没有",
"有结果吗",
"结果呢",
"进度",
"多久好",
"什么时候好",
"好了没",
"弄好了吗",
"做了没",
"你重新发",
"重新发给我",
"高清",
"发我",
)
if any(k in s for k in followup_kw):
return True
return s in {"?", "", "在吗", "人呢"}
def build_collect_ack(count: int, related_followup: bool = False) -> str:
if related_followup and count >= 2:
related_templates = [
"这张我收到了,看起来是上一张的截图/细节图,我按同一单一起处理。还有补充就继续发。",
"收到,这张是关联补图我记上了(按同一需求处理)。你还有图就继续发。",
"明白,这张是前图的局部截图,我会和前面那张一起算,不会分开漏掉。",
]
return random.choice(related_templates)
if count <= 1:
one_templates = [
"这张收到啦,还有图就继续发,我一起给你看。",
"图我看到了,后面还有就接着发,最后我一口价给你。",
"收到这张了,你有其他图也发来,我统一帮你算。",
"这张我先记上了,你那边还有的话接着发,我一起给你报。",
"第1张收到你继续发就行发完我这边一次给你算清楚。",
"这张没问题,我先收着。要是还有图,你直接连着发我就行。",
"我先看到了这张,你后面还有就一起发来,我统一给你报价。",
"这张图我已经记下了,后面有补充就继续甩过来哈。",
]
return random.choice(one_templates)
templates = [
"这几张我都收到了(现在{n}张)。还有的话继续发,我一起给你报。",
"好嘞,先看到{n}张了。你可以继续发,或者直接说“就这些”我现在就报价。",
"收到哈(共{n}张)。你还要补图就继续发,不补的话我现在也可以直接给价。",
"我这边先收到了{n}张。你继续补图,或者直接说“按这些算”我就开始报。",
"这波我已经记了{n}张,你要是还有就接着发,不补的话我立刻给总价。",
"先看到{n}张图了,后面你看是继续发,还是直接让我现在报价都可以。",
"好的,目前{n}张到位。你一句“就这些”,我马上给你打包价。",
"图我都看到了({n}张)。你还发我就继续收,不发我现在就给你报。",
]
return random.choice(templates).format(n=count)
def build_collect_progress_reply(count: int) -> str:
if count <= 1:
templates = [
"我这边在处理了,这张有结果我第一时间回你。",
"在跟进中,这张一有进展我马上发你。",
"这张我正在看,稍等我一会儿,结果出来就回你。",
]
return random.choice(templates)
templates = [
"我这边在按你这{n}张一起处理,有结果我立刻同步你。",
"正在跟进这{n}张,出结果我第一时间发你,不会漏。",
"进度在跑了(共{n}张),你稍等一下,我这边有结果马上回。",
]
return random.choice(templates).format(n=count)
def build_collect_remind(count: int) -> str:
if count <= 1:
one_templates = [
"这个要求我记住了。你还有图就继续发,不补图我就按这张给你报价。",
"明白,这个需求我加上了。你继续发图也行,想直接报价也可以。",
"我先记下这张。你如果是要我找图,不是做图,直接说一声,我按找图思路给你走。",
"收到,这张我先按你的要求记好了。就做这一张的话,我现在直接给你报实价。",
"你这要求我记下了,后面还有图就发,没有的话我现在直接算价。",
"行,我按你这个要求来。继续补图也行,不补我就先报这张。",
"这个点我懂了,你还要补图就接着发,不补我立刻给你报价。",
"要求我已经加上了。你看是继续发,还是我现在直接报这张。",
]
return random.choice(one_templates)
templates = [
"需求我记下了(当前{n}张)。你继续补图,或者直接说“就这些”我现在报价。",
"好,这个要求也加上了(现在{n}张)。不再补图的话我立刻给你打包价。",
"收到(共{n}张)。你还发就继续,不发的话我现在就给总价。",
"这个需求我加进去了(现在{n}张)。你继续发也行,直接报价也行。",
"我这边都记好了({n}张+需求)。你一句“先按这些算”,我马上报价。",
"要求同步好了,目前{n}张。要补图继续发,不补图我现在就给你打包价。",
"行,需求和图片我都收着了({n}张)。你直接让我报价也可以。",
"好的,这条需求也算进去了(共{n}张)。你看要不要我现在直接报。",
]
return random.choice(templates).format(n=count)
def is_find_image_not_edit_conflict(text: str) -> bool:
"""识别客户明确声明“要找图,不是做图”的冲突语义。"""
s = (text or "").strip()
if not s:
return False
find_kw = ("找图", "找原图", "找素材", "找同款")
deny_edit_kw = ("不是让你做图", "不是做图", "不用做图", "不需要做图", "不是修图", "不用修图")
return any(k in s for k in find_kw) and any(k in s for k in deny_edit_kw)
def needs_clarification_in_collecting(text: str) -> bool:
"""信息不足时先追问,不急着报价。"""
s = (text or "").strip()
if not s:
return False
short_non_vague_kw = (
"",
"?",
"没了",
"没有了",
"就这",
"",
"好的",
"ok",
"报价",
"找到了吗",
"没找到吗",
"找到没",
"找到了没",
"有吗",
"有没",
"有没有",
"多久好",
"什么时候好",
"高清",
)
if len(s) <= 4:
if any(k in s for k in short_non_vague_kw):
return False
return True
vague_kw = (
"这个也是",
"一共几个图",
"几个图",
"啥意思",
"没明白",
"什么意思",
"这个呢",
"这个可以吗",
"然后呢",
"咋办",
"怎么搞",
)
return any(k in s for k in vague_kw)
def build_find_image_clarify_reply(state: Any) -> str:
count = len(getattr(state, "pending_image_urls", []) or [])
return (
f"明白,你是要我帮你找图,不是做图。现在我这边先记了{count}张,"
"你告诉我具体要找哪种:原图/同款/高清版,我按这个方向给你找。"
)
def build_not_understood_reply() -> str:
"""信息不足时的澄清话术(随机)。"""
templates = [
"不好意思,不太懂你的意思,你再具体说下哈。",
"抱歉我这边没完全理解,你可以换个说法再说一次吗?",
"我有点没听明白,你是要找图还是要做图呀?",
"不好意思我没抓到重点,你再补一句我就能接着处理。",
"这句我理解得不太准,你再说具体一点我马上给你办。",
"抱歉,这里我没太看懂。你是想让我找原图,还是按图处理?",
"我这边还没完全明白你的意思,麻烦你再具体描述一下。",
"不好意思,这条我没读懂,你再详细说一点我马上跟上。",
]
return random.choice(templates)
def append_requirement(state: Any, text: str) -> None:
"""追加需求并做去重/截断,减少上下文噪音。"""
t = (text or "").strip()
if not t:
return
t = t[:120]
existing = list(getattr(state, "pending_requirements", []) or [])
if existing and existing[-1] == t:
return
if t in existing[-5:]:
return
existing.append(t)
if len(existing) > 20:
existing = existing[-20:]
state.pending_requirements = existing

229
legacy/context_helpers.py Normal file
View File

@@ -0,0 +1,229 @@
from __future__ import annotations
import os
import logging
from collections import Counter
from datetime import datetime
logger = logging.getLogger("cs_agent")
def calc_avg_complexity(complexity_history: list) -> str:
"""计算平均复杂度。"""
if not complexity_history:
return "未知"
level_map = {"simple": 1, "normal": 2, "complex": 3, "hard": 4}
label_map = {1: "简单", 2: "一般", 3: "复杂", 4: "很复杂"}
try:
avg = sum(level_map.get(c, 2) for c in complexity_history) / len(complexity_history)
return label_map.get(round(avg), "一般")
except Exception:
return "一般"
def get_customer_profile_context(agent, customer_id: str) -> str:
"""从数据库读取客户画像,注入给 AI。含个性化语气、报价策略、主动预测、近期对话。"""
try:
from db.customer_db import db
profile = db.get_customer(customer_id)
if profile.blacklist:
return f"【⚠️黑名单客户】原因:{profile.blacklist_reason or '已标记'},请转接人工处理,不要自动回复"
lines = []
lines.append("=== 客户档案 ===")
basic_info = []
basic_info.append(f"客户ID: {customer_id}")
basic_info.append(f"姓名: {profile.name or '未知'}")
if profile.email:
basic_info.append(f"邮箱: {profile.email}")
if profile.phone:
basic_info.append(f"电话: {profile.phone}")
if profile.wechat:
basic_info.append(f"微信: {profile.wechat}")
lines.append(" | ".join(basic_info))
consume_info = []
consume_info.append(f"客户等级: {profile.customer_level}")
if profile.vip:
consume_info.append("VIP客户")
consume_info.append(f"总订单: {profile.total_orders}")
consume_info.append(f"总消费: {profile.total_spent}")
if profile.total_orders > 0:
consume_info.append(f"客单价: {profile.total_spent // profile.total_orders}")
lines.append("--- 消费分析 ---")
lines.append(" | ".join(consume_info))
price_info = []
if profile.vip_custom_price:
price_info.append(f"VIP专属价: {profile.vip_custom_price}元(直接报这个价)")
if profile.last_price:
price_info.append(f"上次报价: {profile.last_price}")
if profile.lowest_price_accepted:
price_info.append(f"历史最低成交: {profile.lowest_price_accepted}")
if profile.discount_given_count:
price_info.append(f"历史让价: {profile.discount_given_count}")
if profile.price_sensitivity:
price_info.append(f"价格敏感度: {profile.price_sensitivity}")
if getattr(profile, "last_quote_no_convert", False):
price_info.append("【策略】上次报价未成交本次可降5-10元")
if price_info:
lines.append("--- 报价历史 ---")
lines.append(" | ".join(price_info))
personality_info = []
if profile.personality:
personality_info.append(f"性格: {'/'.join(profile.personality)}")
if profile.decision_speed:
personality_info.append(f"决策速度: {profile.decision_speed}")
if profile.communication_prefer:
personality_info.append(f"沟通偏好: {profile.communication_prefer}")
if personality_info:
lines.append("--- 性格特征 ---")
lines.append(" | ".join(personality_info))
image_info = []
image_info.append(f"累计发图: {profile.total_images_sent}")
if profile.complexity_history:
image_info.append(f"平均复杂度: {calc_avg_complexity(profile.complexity_history)}")
if profile.image_type_history:
top_types = Counter(profile.image_type_history).most_common(3)
types_str = "".join(f"{t}({c}次)" for t, c in top_types)
image_info.append(f"常见类型: {types_str}")
if profile.preferred_format:
image_info.append(f"格式偏好: {profile.preferred_format}")
if profile.preferred_size:
image_info.append(f"尺寸要求: {profile.preferred_size}")
if profile.last_image_url:
image_info.append(f"最近发图: {profile.last_image_url[:60]}...")
lines.append("--- 图片习惯 ---")
lines.append(" | ".join(image_info))
if profile.processing_status:
task_info = []
task_info.append(f"状态: {profile.processing_status}")
if profile.processing_image_url:
task_info.append(f"处理中: {profile.processing_image_url[:40]}...")
if profile.expected_done_at:
task_info.append(f"预计完成: {profile.expected_done_at}")
lines.append("--- 当前任务 ---")
lines.append(" | ".join(task_info))
if profile.last_conversation_summary:
time_str = ""
if profile.last_conversation_time:
try:
t = datetime.fromisoformat(profile.last_conversation_time)
diff = datetime.now() - t
if diff.days > 0:
time_str = f"{diff.days}天前)"
else:
h = diff.seconds // 3600
time_str = f"{h}小时前)" if h > 0 else "(刚刚)"
except Exception:
pass
lines.append(f"--- 上次对话 {time_str} ---")
lines.append(profile.last_conversation_summary)
hints = []
if profile.personality:
if "爽快" in profile.personality:
hints.append("回复简洁直接,不废话,快速报价")
if "砍价" in profile.personality or "砍价狂" in profile.personality:
hints.append("报价时强调性价比,只让价一次,第二次引导去 xinhui.cloud")
if "纠结" in profile.personality or "墨迹" in profile.personality:
hints.append("多给一点说明,耐心回答")
if profile.price_sensitivity == "":
hints.append("报价时顺带提「满意再拍」降低顾虑")
if profile.decision_speed == "":
hints.append("直接报价推成交,少铺垫")
if profile.total_orders > 0 and profile.decision_speed == "":
hints.append("老客爽快,直接报价成交")
if hints:
lines.append("--- 回复策略 ---")
lines.append("".join(hints))
proactive = []
if profile.bulk_potential == "" or (profile.total_images_sent or 0) >= 2:
proactive.append("可问「要做多张吗,多张有优惠」")
if profile.upsell_opportunity:
proactive.append(f"加购机会: {''.join(profile.upsell_opportunity)}")
if proactive:
lines.append("--- 主动推荐 ---")
lines.append("".join(proactive))
return "\n".join(lines)
except Exception as e:
logger.exception("[Agent] 获取客户画像失败: %s", e)
return ""
def get_refusal_context_hint(agent, customer_id: str, current_msg: str, profile_context: str) -> str:
"""
检测「刚拒绝某张图 + 客户问能找到吗」场景,注入显式提示,避免前后矛盾。
"""
ask_keywords = ["能找到吗", "可以吗", "有吗", "能做吗", "可以找吗", "可以弄吗"]
if not any(kw in current_msg for kw in ask_keywords):
return ""
refusal_keywords = ["不做", "不接", "拒绝", "不做这类", "这类不做"]
if any(kw in profile_context for kw in refusal_keywords):
return "【重要】上一句客服刚拒绝了某张图,客户问能找到吗时须明确:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,会前后矛盾。"
history = getattr(agent, "message_histories", {}).get(customer_id, [])
for msg in reversed(history[-6:]):
msg_str = str(msg)
if any(kw in msg_str for kw in refusal_keywords):
return "【重要】上一句客服刚拒绝了某张图,客户问能找到吗时须明确:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,会前后矛盾。"
return ""
def get_conversation_context(customer_id: str, acc_id: str = "", limit: int = 12, max_len: int = 80) -> str:
"""每一次对话都从数据库加载近期对话,压缩后注入 prompt。"""
try:
try:
from config.config import CHAT_CONTEXT_LIMIT, CHAT_CONTEXT_TRUNCATE_LEN
limit = CHAT_CONTEXT_LIMIT
max_len = CHAT_CONTEXT_TRUNCATE_LEN
except Exception:
pass
from db.chat_log_db import get_recent_conversation
msgs = get_recent_conversation(customer_id, acc_id=acc_id, limit=limit)
if not msgs:
return ""
lines = []
for m in msgs:
role = "" if m.get("direction") == "in" else ""
msg_text = (m.get("message") or "").strip().replace("\n", " ")[:max_len]
if not msg_text:
continue
lines.append(f"{role}:{msg_text}")
if not lines:
return ""
return "【近期】\n" + "\n".join(lines) + "\n\n"
except Exception:
return ""
def get_intent_emotion_hint(msg: str) -> str:
"""语义匹配:意图/情绪识别注入提示。EMBEDDING_MODEL 未配置时用关键词。"""
try:
from utils.intent_analyzer import detect_emotion_embedding, detect_intent
decision = detect_intent(msg)
intent = decision.intent
emotion = detect_emotion_embedding(msg) if os.getenv("EMBEDDING_MODEL") else None
parts = []
if intent:
parts.append(f"意图:{intent}")
if decision.source:
parts.append(f"意图来源:{decision.source}")
if emotion:
parts.append(f"情绪:{emotion}")
if parts:
return f"【当前消息】{', '.join(parts)}"
except Exception:
pass
return ""

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from core.quote_state_machine import QuoteStateMachine
def refresh_quote_phase(state: Any, phase_hint: str = "") -> None:
"""统一维护收图报价状态机。"""
QuoteStateMachine().refresh(state, phase_hint=phase_hint)
def sync_pending_quote_state(agent: Any, customer_id: str, state: Any) -> None:
"""把待报价队列同步到客户库,避免重启丢失。"""
try:
refresh_quote_phase(state)
from db.customer_db import db
db.update_pending_quote_state(
customer_id,
state.pending_image_urls,
state.pending_requirements,
)
except Exception:
pass
def restore_pending_quote_state(customer_id: str, state: Any) -> None:
"""从客户库恢复待报价队列。"""
try:
from db.customer_db import db
profile = db.get_customer(customer_id)
state.pending_image_urls = list(getattr(profile, "pending_quote_images", []) or [])
state.pending_requirements = list(getattr(profile, "pending_quote_requirements", []) or [])
state.image_count = len(state.pending_image_urls)
refresh_quote_phase(state)
except Exception:
pass
def cleanup_inactive(conversations: dict, message_histories: dict, now: datetime) -> None:
"""清理超过 7 天没有消息的对话状态,释放内存。"""
if len(conversations) % 100 != 0:
return
expired = [
cid
for cid, state in conversations.items()
if state.last_update and (now - datetime.fromisoformat(state.last_update)).days > 7
]
for cid in expired:
conversations.pop(cid, None)
message_histories.pop(cid, None)
def get_conversation_state(agent: Any, customer_id: str) -> Any:
"""获取或创建对话状态,超时自动重置。"""
now = datetime.now()
if customer_id in agent.conversations:
state = agent.conversations[customer_id]
if state.last_update:
try:
last = datetime.fromisoformat(state.last_update)
hours = (now - last).total_seconds() / 3600
if hours > agent.CONVERSATION_TIMEOUT_HOURS:
state.stage = "售前"
state.discount_count = 0
agent.message_histories.pop(customer_id, None)
except Exception:
pass
if not state.pending_image_urls and not state.pending_requirements:
restore_pending_quote_state(customer_id, state)
else:
agent.conversations[customer_id] = agent.ConversationStateClass(
customer_id=customer_id,
last_update=now.isoformat(),
)
restore_pending_quote_state(customer_id, agent.conversations[customer_id])
cleanup_inactive(agent.conversations, agent.message_histories, now)
return agent.conversations[customer_id]
def should_defer_batch_quote(agent: Any, state: Any, mark_ready: bool = False) -> bool:
"""批量报价延后控制。"""
agent.quote_state_machine.delay_turns = max(0, int(agent.batch_quote_delay_turns))
return agent.quote_state_machine.should_defer_batch_quote(state, mark_ready=mark_ready)
def mark_quote_ready(agent: Any, state: Any) -> None:
"""仅标记 ready 状态,不消费等待轮次。"""
agent.quote_state_machine.delay_turns = max(0, int(agent.batch_quote_delay_turns))
agent.quote_state_machine.mark_ready(state)

View File

@@ -0,0 +1,889 @@
{
"new_customer_001": {
"customer_id": "new_customer_001",
"name": "新客户小王",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 0,
"total_spent": 0,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 20,
"last_price_time": "2026-02-28T15:04:15.181813",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "",
"decision_speed": "",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:03:57.129715",
"last_update": "2026-02-28T15:04:15.184378"
},
"fast_customer_002": {
"customer_id": "fast_customer_002",
"name": "爽快老客老李",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 8,
"total_spent": 280,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"爽快"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 10,
"last_price_time": "2026-02-28T15:06:10.872962",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 2,
"lowest_price_accepted": 10,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "中",
"decision_speed": "快",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 8,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:03:57.131384",
"last_update": "2026-02-28T15:06:10.875534"
},
"bargainer_003": {
"customer_id": "bargainer_003",
"name": "砍价王小张",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 3,
"total_spent": 45,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"砍价狂",
"纠结"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 10,
"last_price_time": "2026-02-28T15:05:45.067204",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 6,
"lowest_price_accepted": 10,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "高",
"decision_speed": "慢",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:03:57.132648",
"last_update": "2026-02-28T15:05:45.071818"
},
"vip_customer_004": {
"customer_id": "vip_customer_004",
"name": "VIP客户陈总",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 15,
"total_spent": 680,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"爽快"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "A",
"vip": true,
"vip_level": 2,
"last_price": 20,
"last_price_time": "2026-02-28T15:04:56.155844",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "低",
"decision_speed": "快",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 18,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:03:57.134104",
"last_update": "2026-02-28T15:04:56.158233"
},
"high_value_005": {
"customer_id": "high_value_005",
"name": "高价值客户刘老板",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 20,
"total_spent": 1200,
"avg_order_value": 60,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"爽快"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "A",
"vip": false,
"vip_level": 0,
"last_price": 20,
"last_price_time": "2026-02-28T15:05:11.156030",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "低",
"decision_speed": "快",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:03:57.135396",
"last_update": "2026-02-28T15:05:11.160004"
},
"blacklist_006": {
"customer_id": "blacklist_006",
"name": "黑名单客户",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 0,
"total_spent": 0.0,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 0,
"last_price_time": "",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "",
"decision_speed": "",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": true,
"blacklist_reason": "恶意投诉多次",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:03:57.136490",
"last_update": "2026-02-28T15:05:27.155220"
},
"test_new_001": {
"customer_id": "test_new_001",
"name": "新客户小王",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 0,
"total_spent": 0,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 0,
"last_price_time": "2026-02-28T15:27:40.801329",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "",
"decision_speed": "",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:29:05.719291",
"last_update": "2026-02-28T15:29:05.719308"
},
"test_fast_002": {
"customer_id": "test_fast_002",
"name": "爽快老客老李",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 8,
"total_spent": 280,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"爽快"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 25,
"last_price_time": "",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "低",
"decision_speed": "快",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 8,
"bulk_potential": "",
"churn_risk": "",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:29:05.720944",
"last_update": "2026-02-28T15:29:05.720948"
},
"test_bargain_003": {
"customer_id": "test_bargain_003",
"name": "砍价王小张",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 3,
"total_spent": 45,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"砍价狂",
"纠结"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 15,
"last_price_time": "",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 4,
"lowest_price_accepted": 15,
"preferred_format": "",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "高",
"decision_speed": "慢",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:29:05.722448",
"last_update": "2026-02-28T15:29:05.722454"
},
"test_vip_004": {
"customer_id": "test_vip_004",
"name": "VIP 客户陈总",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 15,
"total_spent": 680,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"爽快"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "A",
"vip": true,
"vip_level": 2,
"last_price": 0,
"last_price_time": "",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "低",
"decision_speed": "快",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 18,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:29:05.723887",
"last_update": "2026-02-28T15:29:05.723890"
},
"test_highvalue_005": {
"customer_id": "test_highvalue_005",
"name": "高价值客户刘老板",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 20,
"total_spent": 1200,
"avg_order_value": 60,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"爽快"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "A",
"vip": false,
"vip_level": 0,
"last_price": 0,
"last_price_time": "",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "低",
"decision_speed": "快",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:29:05.725313",
"last_update": "2026-02-28T15:29:05.725316"
}
}

336
legacy/customer_risk_db.py Normal file
View File

@@ -0,0 +1,336 @@
"""客户风控数据库MySQL 优先SQLite 兜底)"""
import os
import sqlite3
import json
from datetime import datetime
from pathlib import Path
from typing import Dict, Any
from dotenv import load_dotenv
load_dotenv()
_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower()
_MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
_MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
_MYSQL_USER = os.getenv("MYSQL_USER", "root")
_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs")
def _is_mysql() -> bool:
return _DB_TYPE in ("mysql", "mariadb")
class CustomerRiskDB:
def __init__(self, sqlite_path: str = "db/customer_risk_db/risk.db"):
self.sqlite_path = Path(sqlite_path)
self.backend = "mysql" if _is_mysql() else "sqlite"
self._sqlite_in_memory = False
try:
self._ensure_db()
except Exception:
# MySQL 不可用时自动回退,避免主流程被数据库连接拖垮
self.backend = "sqlite"
try:
self._ensure_sqlite_db()
except Exception:
# 最后兜底:内存 SQLite保证模块可导入
self._sqlite_in_memory = True
self._ensure_sqlite_db()
def _get_mysql_conn(self):
import pymysql
return pymysql.connect(
host=_MYSQL_HOST,
port=_MYSQL_PORT,
user=_MYSQL_USER,
password=_MYSQL_PASSWORD,
database=_MYSQL_DATABASE,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
)
def _get_sqlite_conn(self):
if self._sqlite_in_memory:
conn = sqlite3.connect(":memory:")
else:
self.sqlite_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(self.sqlite_path))
conn.row_factory = sqlite3.Row
return conn
def _ensure_db(self):
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_risk_profile (
customer_id VARCHAR(128) PRIMARY KEY,
do_not_serve TINYINT(1) NOT NULL DEFAULT 0,
risk_level VARCHAR(16) NOT NULL DEFAULT 'low',
risk_score INT NOT NULL DEFAULT 0,
note TEXT,
tags_json TEXT,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_risk_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
customer_id VARCHAR(128) NOT NULL,
event_type VARCHAR(32) NOT NULL,
event_count INT NOT NULL DEFAULT 1,
note TEXT,
created_at DATETIME NOT NULL,
INDEX idx_customer_time (customer_id, created_at),
INDEX idx_event_type (event_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
conn.commit()
return
self._ensure_sqlite_db()
def _ensure_sqlite_db(self):
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_risk_profile (
customer_id TEXT PRIMARY KEY,
do_not_serve INTEGER NOT NULL DEFAULT 0,
risk_level TEXT NOT NULL DEFAULT 'low',
risk_score INTEGER NOT NULL DEFAULT 0,
note TEXT,
tags_json TEXT,
updated_at TEXT NOT NULL
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_risk_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
event_type TEXT NOT NULL,
event_count INTEGER NOT NULL DEFAULT 1,
note TEXT,
created_at TEXT NOT NULL
)
"""
)
cur.execute("CREATE INDEX IF NOT EXISTS idx_customer_time ON customer_risk_event(customer_id, created_at)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_event_type ON customer_risk_event(event_type)")
conn.commit()
def record_event(self, customer_id: str, event_type: str, event_count: int = 1, note: str = ""):
if not customer_id or not event_type:
return
now = datetime.now()
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO customer_risk_event (customer_id, event_type, event_count, note, created_at)
VALUES (%s, %s, %s, %s, %s)
""",
(customer_id, event_type, int(max(1, event_count)), note, now.strftime("%Y-%m-%d %H:%M:%S")),
)
conn.commit()
return
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO customer_risk_event (customer_id, event_type, event_count, note, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(customer_id, event_type, int(max(1, event_count)), note, now.isoformat()),
)
conn.commit()
def set_profile(
self,
customer_id: str,
*,
do_not_serve: bool = False,
risk_level: str = "low",
risk_score: int = 0,
note: str = "",
tags: list | None = None,
):
if not customer_id:
return
tags_json = json.dumps(tags or [], ensure_ascii=False)
now = datetime.now()
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
REPLACE INTO customer_risk_profile
(customer_id, do_not_serve, risk_level, risk_score, note, tags_json, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
customer_id,
1 if do_not_serve else 0,
risk_level,
int(max(0, risk_score)),
note,
tags_json,
now.strftime("%Y-%m-%d %H:%M:%S"),
),
)
conn.commit()
return
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO customer_risk_profile
(customer_id, do_not_serve, risk_level, risk_score, note, tags_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(customer_id) DO UPDATE SET
do_not_serve=excluded.do_not_serve,
risk_level=excluded.risk_level,
risk_score=excluded.risk_score,
note=excluded.note,
tags_json=excluded.tags_json,
updated_at=excluded.updated_at
""",
(
customer_id,
1 if do_not_serve else 0,
risk_level,
int(max(0, risk_score)),
note,
tags_json,
now.isoformat(),
),
)
conn.commit()
def _sum_events(self, customer_id: str, event_type: str, days: int) -> int:
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COALESCE(SUM(event_count), 0) AS total
FROM customer_risk_event
WHERE customer_id=%s
AND event_type=%s
AND created_at >= (NOW() - INTERVAL %s DAY)
""",
(customer_id, event_type, int(max(1, days))),
)
row = cur.fetchone() or {}
return int(row.get("total") or 0)
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
SELECT COALESCE(SUM(event_count), 0) AS total
FROM customer_risk_event
WHERE customer_id=?
AND event_type=?
AND created_at >= datetime('now', ?)
""",
(customer_id, event_type, f"-{int(max(1, days))} day"),
)
row = cur.fetchone()
return int((row["total"] if row else 0) or 0)
def get_profile(self, customer_id: str) -> Dict[str, Any]:
out = {
"customer_id": customer_id,
"do_not_serve": False,
"risk_level": "low",
"risk_score": 0,
"note": "",
"tags": [],
}
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT customer_id, do_not_serve, risk_level, risk_score, note, tags_json
FROM customer_risk_profile
WHERE customer_id=%s
LIMIT 1
""",
(customer_id,),
)
row = cur.fetchone()
if not row:
return out
out.update(
{
"do_not_serve": bool(row.get("do_not_serve")),
"risk_level": str(row.get("risk_level") or "low"),
"risk_score": int(row.get("risk_score") or 0),
"note": str(row.get("note") or ""),
"tags": json.loads(row.get("tags_json") or "[]"),
}
)
return out
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
SELECT customer_id, do_not_serve, risk_level, risk_score, note, tags_json
FROM customer_risk_profile
WHERE customer_id=?
LIMIT 1
""",
(customer_id,),
)
row = cur.fetchone()
if not row:
return out
out.update(
{
"do_not_serve": bool(row["do_not_serve"]),
"risk_level": str(row["risk_level"] or "low"),
"risk_score": int(row["risk_score"] or 0),
"note": str(row["note"] or ""),
"tags": json.loads(row["tags_json"] or "[]"),
}
)
return out
def evaluate_customer(self, customer_id: str) -> Dict[str, Any]:
profile = self.get_profile(customer_id)
refund_30d = self._sum_events(customer_id, "refund", 30)
unpaid_7d = self._sum_events(customer_id, "unpaid_order", 7)
bad_review_90d = self._sum_events(customer_id, "bad_review", 90)
score = int(profile.get("risk_score") or 0)
score += refund_30d * 20
score += unpaid_7d * 8
score += bad_review_90d * 15
level = "low"
if score >= 70:
level = "high"
elif score >= 35:
level = "medium"
return {
**profile,
"refund_30d": refund_30d,
"unpaid_7d": unpaid_7d,
"bad_review_90d": bad_review_90d,
"computed_score": score,
"computed_level": level,
}
risk_db = CustomerRiskDB()

300
legacy/daily_summary.py Normal file
View File

@@ -0,0 +1,300 @@
# -*- coding: utf-8 -*-
"""
每日聊天汇总定时任务
- 每天 23:50 自动统计当日各店铺数据
- 用 AI 生成自然语言摘要
- 发送到企业微信 Webhook + QQ 邮件
"""
import asyncio
import os
import json
import logging
from datetime import datetime, date, timedelta
from typing import Optional
import httpx
from dotenv import load_dotenv
load_dotenv()
logger = logging.getLogger("cs_agent")
WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "")
SUMMARY_EMAIL = os.getenv("SUMMARY_EMAIL", "") # 收摘要的邮箱
SEND_HOUR = int(os.getenv("SUMMARY_HOUR", "23"))
SEND_MINUTE = int(os.getenv("SUMMARY_MINUTE", "50"))
# ──────────────────────────────────────────
# 统计数据整理
# ──────────────────────────────────────────
def _build_stats_text(target_date: str = "") -> str:
"""整理今日数据,返回给 AI 的原始统计文本"""
from db import chat_log_db as db
from db.deal_outcome_db import get_daily_summary
if not target_date:
target_date = datetime.now().strftime("%Y-%m-%d")
stats = db.get_daily_stats(target_date)
convs = db.get_daily_conversations(target_date)
deal_sum = get_daily_summary(target_date)
if not stats:
return f"{target_date} 当日无任何聊天记录。"
# 按 acc_id 分组对话片段
conv_map: dict[str, list] = {}
for c in convs:
aid = c.get("acc_id") or "未知店铺"
conv_map.setdefault(aid, []).append(c)
lines = [f"{target_date} 各店铺数据】\n"]
# 成交/未成交汇总(供 AI 摘要与数据分析)
lines.append("【成交与未成交】")
lines.append(f" 成交:{deal_sum['成交数']} 笔,金额 {deal_sum['成交金额']:.0f}")
lines.append(f" 未成交:{deal_sum['未成交数']}")
if deal_sum["未成交原因分布"]:
for reason, cnt in deal_sum["未成交原因分布"].items():
lines.append(f" - {reason}{cnt}")
if deal_sum["成交明细"]:
for o in deal_sum["成交明细"][:5]:
r = "让价后" if o.get("discount_given") else "直接"
lines.append(f"{o.get('customer_name', '')[:6]} {r}成交 {o.get('amount', 0):.0f}")
if deal_sum["未成交明细"]:
for o in deal_sum["未成交明细"][:5]:
lines.append(f"{o.get('customer_name', '')[:6]} {o.get('reason', '')}")
lines.append("")
for s in stats:
acc = s.get("acc_id") or "未知店铺"
plat = s.get("platform") or ""
label = f"{acc}{plat}" if plat else acc
lines.append(f"▶ 店铺:{label}")
lines.append(f" 接待客户:{s['unique_customers']} 人,共 {s['total_msgs']} 条消息(收 {s['recv']}{s['sent']}")
lines.append(f" 首条:{(s.get('first_msg') or '')[-8:-3]} 末条:{(s.get('last_msg') or '')[-8:-3]}")
shop_convs = conv_map.get(acc, [])
for c in shop_convs[:6]: # 最多展示6个客户片段
name = c.get("customer_name") or c.get("customer_id", "")[:8]
snippet = (c.get("snippet") or "")[:120]
lines.append(f" · {name}{c['msg_count']}条){snippet}")
if len(shop_convs) > 6:
lines.append(f" ... 还有 {len(shop_convs)-6} 位客户")
lines.append("")
return "\n".join(lines)
# ──────────────────────────────────────────
# AI 生成摘要
# ──────────────────────────────────────────
async def _ai_summary(raw_text: str) -> str:
"""调用 AI 把统计文本转成自然语言日报"""
try:
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL"),
)
model = os.getenv("OPENAI_MODEL", "doubao-seed-2-0-lite-260215")
resp = await client.chat.completions.create(
model=model,
messages=[
{
"role": "system",
"content": (
"你是一名电商运营助理。根据下面的客服聊天数据,"
"为老板写一份简洁的当日运营日报200字以内"
"要包含:接待总人数、各店铺情况、有无成交或异常情况。"
"语气轻松,像发给老板的微信消息,不需要标题。"
),
},
{"role": "user", "content": raw_text},
],
max_tokens=300,
temperature=0.5,
)
return resp.choices[0].message.content.strip()
except Exception as e:
# AI 失败就直接返回原始统计
return raw_text
# ──────────────────────────────────────────
# 推送:企业微信
# ──────────────────────────────────────────
async def _send_wechat(content: str):
"""推送到企业微信群机器人markdown 格式,单条 ≤4096 字节自动分段)"""
if not WECHAT_WEBHOOK:
logger.info("[DailySummary] 未配置 WECHAT_WEBHOOK跳过推送")
return
# 企业微信单条 markdown 限 4096 字节,超长自动分段
encoded = content.encode("utf-8")
chunks = []
while encoded:
chunk = encoded[:3800].decode("utf-8", errors="ignore")
chunks.append(chunk)
encoded = encoded[len(chunk.encode("utf-8")):]
async with httpx.AsyncClient(timeout=10) as client:
for i, chunk in enumerate(chunks):
payload = {"msgtype": "markdown", "markdown": {"content": chunk}}
try:
resp = await client.post(WECHAT_WEBHOOK, json=payload)
data = resp.json()
if data.get("errcode") == 0:
logger.info("[DailySummary] 企业微信推送成功(第%s段)", i + 1)
else:
logger.warning("[DailySummary] 企业微信推送失败: %s", data)
except Exception as e:
logger.exception("[DailySummary] 企业微信推送异常: %s", e)
# ──────────────────────────────────────────
# 推送:邮件
# ──────────────────────────────────────────
def _send_email(subject: str, body: str):
"""发送日报邮件"""
if not SUMMARY_EMAIL:
return
try:
from mail.email_sender import email_sender
import smtplib
from email.mime.text import MIMEText
from email.header import Header
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = Header(subject, "utf-8").encode()
msg["From"] = f"{Header(email_sender.sender_name, 'utf-8').encode()} <{email_sender.smtp_user}>"
msg["To"] = SUMMARY_EMAIL
with smtplib.SMTP(email_sender.smtp_host, email_sender.smtp_port) as s:
s.starttls()
s.login(email_sender.smtp_user, email_sender.smtp_password)
s.sendmail(email_sender.smtp_user, [SUMMARY_EMAIL], msg.as_string())
logger.info("[DailySummary] 日报邮件已发送至 %s", SUMMARY_EMAIL)
except Exception as e:
logger.exception("[DailySummary] 日报邮件发送失败: %s", e)
# ──────────────────────────────────────────
# 企业微信 Markdown 排版
# ──────────────────────────────────────────
def _build_wechat_markdown(title: str, ai_text: str, raw_text: str, target_date: str = "") -> str:
"""
构建符合企业微信规范的 markdown 内容。
支持:**bold**、<font color="...">、> 引用、``` 代码块、- 列表
不支持:<details>、<summary>、HTML 标签(除 font/br
"""
from db import chat_log_db as db
from db.deal_outcome_db import get_daily_summary
date = target_date or datetime.now().strftime("%Y-%m-%d")
stats = db.get_daily_stats(date)
deal_sum = get_daily_summary(date)
lines = [f"## {title}\n"]
# AI 摘要部分
lines.append("> " + ai_text.replace("\n", "\n> "))
lines.append("")
# 成交/未成交
lines.append("**📈 成交与未成交**")
lines.append(f"- 成交 **{deal_sum['成交数']}** 笔 · 金额 **{deal_sum['成交金额']:.0f}** 元")
lines.append(f"- 未成交 **{deal_sum['未成交数']}** 笔")
if deal_sum["未成交原因分布"]:
for reason, cnt in deal_sum["未成交原因分布"].items():
lines.append(f" - {reason}{cnt}")
lines.append("")
# 各店铺数据表格(企业微信不支持 | 表格,用列表代替)
if stats:
lines.append("**📋 各店铺明细**")
for s in stats:
acc = s.get("acc_id") or "未知店铺"
plat = s.get("platform") or ""
label = f"{acc}{plat}" if plat else acc
first = (s.get("first_msg") or "")[-8:-3]
last = (s.get("last_msg") or "")[-8:-3]
lines.append(
f"- <font color=\"info\">{label}</font> "
f"接待 **{s['unique_customers']}** 人 · "
f"消息 {s['total_msgs']} 条(收{s['recv']}/发{s['sent']}"
f" {first}~{last}"
)
lines.append("")
lines.append(f"<font color=\"comment\">发送时间:{datetime.now().strftime('%H:%M:%S')}</font>")
return "\n".join(lines)
# ──────────────────────────────────────────
# 主入口:生成并推送日报
# ──────────────────────────────────────────
async def send_daily_summary(target_date: str = ""):
"""生成并推送当日汇总"""
if not target_date:
target_date = datetime.now().strftime("%Y-%m-%d")
logger.info("[DailySummary] 开始生成 %s 日报...", target_date)
raw_text = _build_stats_text(target_date)
ai_text = await _ai_summary(raw_text)
title = f"📊 {target_date} 客服日报"
# ── 企业微信 markdown不支持 <details>,用标准语法)──
wechat_md = _build_wechat_markdown(title, ai_text, raw_text, target_date)
await _send_wechat(wechat_md)
# ── 邮件:纯文本 ──
email_body = f"{ai_text}\n\n{'='*40}\n\n{raw_text}"
_send_email(title, email_body)
logger.info("[DailySummary] 日报推送完成")
return ai_text
# ──────────────────────────────────────────
# 定时调度(由 websocket_client 启动)
# ──────────────────────────────────────────
async def scheduler():
"""每天 SEND_HOUR:SEND_MINUTE 触发日报"""
logger.info("[DailySummary] 定时日报已启动,发送时间 %02d:%02d", SEND_HOUR, SEND_MINUTE)
sent_today: Optional[str] = None # 记录已发日期,防重复
while True:
now = datetime.now()
today = now.strftime("%Y-%m-%d")
if now.hour == SEND_HOUR and now.minute == SEND_MINUTE and sent_today != today:
sent_today = today
try:
await send_daily_summary(today)
except Exception as e:
logger.exception("[DailySummary] 日报生成出错: %s", e)
# 每 30 秒检查一次
await asyncio.sleep(30)
# ──────────────────────────────────────────
# 命令行手动触发
# ──────────────────────────────────────────
if __name__ == "__main__":
import sys
target = sys.argv[1] if len(sys.argv) > 1 else ""
result = asyncio.run(send_daily_summary(target))
logger.info("\n=== AI 摘要 ===")
logger.info(result)

246
legacy/deal_outcome_db.py Normal file
View File

@@ -0,0 +1,246 @@
# -*- coding: utf-8 -*-
"""
成交/未成交记录 - 用于日报与数据分析
"""
import sqlite3
import os
from datetime import datetime
from typing import List, Dict, Optional
_DB_PATH = os.path.join(os.path.dirname(__file__), "deal_outcome_db", "outcomes.db")
_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower()
_MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
_MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
_MYSQL_USER = os.getenv("MYSQL_USER", "root")
_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs")
class _CompatResult:
def __init__(self, rows=None, rowcount: int = 0, lastrowid: int = 0):
self._rows = rows or []
self.rowcount = rowcount
self.lastrowid = lastrowid
def fetchall(self):
return self._rows
def fetchone(self):
return self._rows[0] if self._rows else None
class _PyMySQLCompatConn:
def __init__(self, conn):
self._conn = conn
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
if exc_type:
try:
self._conn.rollback()
except Exception:
pass
self._conn.close()
def execute(self, query: str, args=None):
cur = self._conn.cursor()
cur.execute(query, args or ())
rows = cur.fetchall() if cur.description else []
res = _CompatResult(rows=rows, rowcount=cur.rowcount, lastrowid=getattr(cur, "lastrowid", 0))
cur.close()
return res
def commit(self):
self._conn.commit()
def _is_mysql() -> bool:
return _DB_TYPE in ("mysql", "mariadb")
def _sql(query: str) -> str:
return query.replace("?", "%s") if _is_mysql() else query
def _get_conn() -> sqlite3.Connection:
if _is_mysql():
import pymysql
conn = pymysql.connect(
host=_MYSQL_HOST,
port=_MYSQL_PORT,
user=_MYSQL_USER,
password=_MYSQL_PASSWORD,
database=_MYSQL_DATABASE,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
)
return _PyMySQLCompatConn(conn)
os.makedirs(os.path.dirname(_DB_PATH), exist_ok=True)
conn = sqlite3.connect(_DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def _init_db():
with _get_conn() as conn:
if _is_mysql():
conn.execute("""
CREATE TABLE IF NOT EXISTS deal_outcomes (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
customer_id VARCHAR(128) NOT NULL,
customer_name VARCHAR(255) DEFAULT '',
acc_id VARCHAR(128) DEFAULT '',
platform VARCHAR(64) DEFAULT '',
date DATE NOT NULL,
outcome VARCHAR(16) NOT NULL,
reason TEXT,
order_id VARCHAR(128) DEFAULT '',
amount REAL DEFAULT 0,
discount_given INTEGER DEFAULT 0,
timestamp DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
idx_rows = conn.execute("SHOW INDEX FROM deal_outcomes").fetchall()
exists = {str(r.get("Key_name", "")) for r in idx_rows}
if "idx_deal_date" not in exists:
conn.execute("CREATE INDEX idx_deal_date ON deal_outcomes(date)")
if "idx_deal_customer" not in exists:
conn.execute("CREATE INDEX idx_deal_customer ON deal_outcomes(customer_id)")
if "idx_deal_acc" not in exists:
conn.execute("CREATE INDEX idx_deal_acc ON deal_outcomes(acc_id)")
if "idx_deal_outcome" not in exists:
conn.execute("CREATE INDEX idx_deal_outcome ON deal_outcomes(outcome)")
else:
conn.execute("""
CREATE TABLE IF NOT EXISTS deal_outcomes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
customer_name TEXT DEFAULT '',
acc_id TEXT DEFAULT '',
platform TEXT DEFAULT '',
date TEXT NOT NULL,
outcome TEXT NOT NULL CHECK(outcome IN ('成交','未成交')),
reason TEXT DEFAULT '',
order_id TEXT DEFAULT '',
amount REAL DEFAULT 0,
discount_given INTEGER DEFAULT 0,
timestamp TEXT NOT NULL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_date ON deal_outcomes(date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_customer ON deal_outcomes(customer_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_acc ON deal_outcomes(acc_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_outcome ON deal_outcomes(outcome)")
conn.commit()
_init_db()
def record_deal(
customer_id: str,
outcome: str,
reason: str = "",
customer_name: str = "",
acc_id: str = "",
platform: str = "",
order_id: str = "",
amount: float = 0,
discount_given: bool = False,
):
"""记录一笔成交或未成交"""
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
date = datetime.now().strftime("%Y-%m-%d")
with _get_conn() as conn:
conn.execute(
_sql("""INSERT INTO deal_outcomes
(customer_id, customer_name, acc_id, platform, date, outcome, reason,
order_id, amount, discount_given, timestamp)
VALUES (?,?,?,?,?,?,?,?,?,?,?)"""),
(
customer_id,
customer_name or "",
acc_id or "",
platform or "",
date,
outcome,
reason or "",
order_id or "",
amount,
1 if discount_given else 0,
ts,
),
)
conn.commit()
def get_daily_outcomes(date: str = "") -> List[Dict]:
"""获取指定日期的成交/未成交记录,用于日报"""
if not date:
date = datetime.now().strftime("%Y-%m-%d")
with _get_conn() as conn:
rows = conn.execute(
_sql("""
SELECT customer_id, customer_name, acc_id, outcome, reason,
order_id, amount, discount_given, timestamp
FROM deal_outcomes
WHERE date = ?
ORDER BY timestamp ASC
"""),
(date,),
).fetchall()
return [dict(r) for r in rows]
def get_daily_summary(date: str = "") -> Dict:
"""获取指定日期的成交/未成交汇总统计"""
outcomes = get_daily_outcomes(date)
success = [o for o in outcomes if o["outcome"] == "成交"]
fail = [o for o in outcomes if o["outcome"] == "未成交"]
# 按原因分组
fail_by_reason: Dict[str, int] = {}
for o in fail:
r = o.get("reason") or "其他"
fail_by_reason[r] = fail_by_reason.get(r, 0) + 1
return {
"date": date or datetime.now().strftime("%Y-%m-%d"),
"成交数": len(success),
"未成交数": len(fail),
"成交金额": sum(o.get("amount") or 0 for o in success),
"成交明细": success,
"未成交明细": fail,
"未成交原因分布": fail_by_reason,
}
def export_for_analysis(start_date: str = "", end_date: str = "") -> List[Dict]:
"""
导出成交/未成交记录,供数据库分析。
日期格式 YYYY-MM-DD留空则查全部。
"""
with _get_conn() as conn:
if start_date and end_date:
rows = conn.execute(
_sql("""SELECT * FROM deal_outcomes
WHERE date BETWEEN ? AND ?
ORDER BY date, timestamp"""),
(start_date, end_date),
).fetchall()
elif start_date:
rows = conn.execute(
_sql("""SELECT * FROM deal_outcomes WHERE date >= ? ORDER BY date, timestamp"""),
(start_date,),
).fetchall()
elif end_date:
rows = conn.execute(
_sql("""SELECT * FROM deal_outcomes WHERE date <= ? ORDER BY date, timestamp"""),
(end_date,),
).fetchall()
else:
rows = conn.execute(
"""SELECT * FROM deal_outcomes ORDER BY date, timestamp"""
).fetchall()
return [dict(r) for r in rows]

View File

@@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
"""
设计师派单数据库SQLite
同一设计师在不同店铺对应不同 group_id派单时从在线设计师中轮询。
企微群「上线」/「下线」通过 update_online(wechat_user_id, is_online) 更新。
"""
import sqlite3
import os
from typing import Optional
_DB_PATH = os.path.join(os.path.dirname(__file__), "designer_roster_db", "roster.db")
_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower()
_MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
_MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
_MYSQL_USER = os.getenv("MYSQL_USER", "root")
_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs")
class _CompatResult:
def __init__(self, rows=None, rowcount: int = 0, lastrowid: int = 0):
self._rows = rows or []
self.rowcount = rowcount
self.lastrowid = lastrowid
def fetchall(self):
return self._rows
def fetchone(self):
return self._rows[0] if self._rows else None
class _PyMySQLCompatConn:
def __init__(self, conn):
self._conn = conn
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
if exc_type:
try:
self._conn.rollback()
except Exception:
pass
self._conn.close()
def execute(self, query: str, args=None):
cur = self._conn.cursor()
cur.execute(query, args or ())
rows = cur.fetchall() if cur.description else []
res = _CompatResult(rows=rows, rowcount=cur.rowcount, lastrowid=getattr(cur, "lastrowid", 0))
cur.close()
return res
def commit(self):
self._conn.commit()
def _is_mysql() -> bool:
return _DB_TYPE in ("mysql", "mariadb")
def _sql(query: str) -> str:
return query.replace("?", "%s") if _is_mysql() else query
def _get_conn() -> sqlite3.Connection:
if _is_mysql():
import pymysql
conn = pymysql.connect(
host=_MYSQL_HOST,
port=_MYSQL_PORT,
user=_MYSQL_USER,
password=_MYSQL_PASSWORD,
database=_MYSQL_DATABASE,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
)
return _PyMySQLCompatConn(conn)
os.makedirs(os.path.dirname(_DB_PATH), exist_ok=True)
conn = sqlite3.connect(_DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
with _get_conn() as conn:
if _is_mysql():
conn.execute("""
CREATE TABLE IF NOT EXISTS designers (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
wechat_user_id VARCHAR(128) UNIQUE NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS designer_shops (
designer_id INTEGER NOT NULL,
shop_id VARCHAR(128) NOT NULL,
group_id VARCHAR(128) NOT NULL,
PRIMARY KEY (designer_id, shop_id),
FOREIGN KEY (designer_id) REFERENCES designers(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS designer_online (
wechat_user_id VARCHAR(128) PRIMARY KEY,
is_online INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS round_robin (
shop_id VARCHAR(128) PRIMARY KEY,
last_index INTEGER NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
else:
conn.execute("""
CREATE TABLE IF NOT EXISTS designers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
wechat_user_id TEXT UNIQUE NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS designer_shops (
designer_id INTEGER NOT NULL,
shop_id TEXT NOT NULL,
group_id TEXT NOT NULL,
PRIMARY KEY (designer_id, shop_id),
FOREIGN KEY (designer_id) REFERENCES designers(id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS designer_online (
wechat_user_id TEXT PRIMARY KEY,
is_online INTEGER NOT NULL DEFAULT 0,
updated_at TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS round_robin (
shop_id TEXT PRIMARY KEY,
last_index INTEGER NOT NULL DEFAULT 0
)
""")
conn.commit()
init_db()
# ========== 设计师管理 ==========
def add_designer(name: str, wechat_user_id: str) -> int:
"""添加设计师,返回 id"""
with _get_conn() as conn:
if _is_mysql():
conn.execute(
"INSERT IGNORE INTO designers (name, wechat_user_id) VALUES (%s, %s)",
(name, wechat_user_id),
)
else:
conn.execute(
"INSERT OR IGNORE INTO designers (name, wechat_user_id) VALUES (?, ?)",
(name, wechat_user_id),
)
conn.commit()
row = conn.execute(_sql("SELECT id FROM designers WHERE wechat_user_id = ?"), (wechat_user_id,)).fetchone()
return row["id"] if row else 0
def set_designer_shop(designer_id: int, shop_id: str, group_id: str):
"""设置设计师在某店铺的分组 ID同一设计师不同店铺不同 group_id"""
with _get_conn() as conn:
if _is_mysql():
conn.execute(
"REPLACE INTO designer_shops (designer_id, shop_id, group_id) VALUES (%s, %s, %s)",
(designer_id, shop_id, group_id),
)
else:
conn.execute(
"INSERT OR REPLACE INTO designer_shops (designer_id, shop_id, group_id) VALUES (?, ?, ?)",
(designer_id, shop_id, group_id),
)
conn.commit()
def update_online(wechat_user_id: str, is_online: bool):
"""更新设计师在线状态(企微群「上线」/「下线」解析后调用)"""
from datetime import datetime
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with _get_conn() as conn:
if _is_mysql():
conn.execute(
"REPLACE INTO designer_online (wechat_user_id, is_online, updated_at) VALUES (%s, %s, %s)",
(wechat_user_id, 1 if is_online else 0, ts),
)
else:
conn.execute(
"INSERT OR REPLACE INTO designer_online (wechat_user_id, is_online, updated_at) VALUES (?, ?, ?)",
(wechat_user_id, 1 if is_online else 0, ts),
)
conn.commit()
# ========== 派单 ==========
def get_transfer_group_for_shop(shop_id: str) -> Optional[str]:
"""
为店铺轮询派单,返回分组 ID。
从该店铺的在线设计师中轮询选一个,返回其在该店铺的 group_id。
无人在线则返回 None。
"""
with _get_conn() as conn:
rows = conn.execute(_sql("""
SELECT d.wechat_user_id, ds.group_id
FROM designer_shops ds
JOIN designers d ON d.id = ds.designer_id
JOIN designer_online o ON o.wechat_user_id = d.wechat_user_id AND o.is_online = 1
WHERE ds.shop_id = ?
"""), (shop_id,)).fetchall()
if not rows:
return None
with _get_conn() as conn:
rr = conn.execute(_sql("SELECT last_index FROM round_robin WHERE shop_id = ?"), (shop_id,)).fetchone()
last = rr["last_index"] if rr else 0
idx = last % len(rows)
chosen = rows[idx]
if _is_mysql():
conn.execute(
"REPLACE INTO round_robin (shop_id, last_index) VALUES (%s, %s)",
(shop_id, idx + 1),
)
else:
conn.execute(
"INSERT OR REPLACE INTO round_robin (shop_id, last_index) VALUES (?, ?)",
(shop_id, idx + 1),
)
conn.commit()
return chosen["group_id"]
# ========== 查询 ==========
def get_all_wechat_user_ids() -> list:
"""获取所有设计师的 wechat_user_id用于同步在线状态"""
with _get_conn() as conn:
rows = conn.execute("SELECT wechat_user_id FROM designers").fetchall()
return [r["wechat_user_id"] for r in rows]
def list_designers():
"""列出所有设计师及其店铺分组"""
with _get_conn() as conn:
designers = conn.execute("SELECT id, name, wechat_user_id FROM designers").fetchall()
result = []
for d in designers:
shops = conn.execute(
_sql("SELECT shop_id, group_id FROM designer_shops WHERE designer_id = ?"),
(d["id"],),
).fetchall()
online = conn.execute(
_sql("SELECT is_online FROM designer_online WHERE wechat_user_id = ?"),
(d["wechat_user_id"],),
).fetchone()
result.append({
"id": d["id"],
"name": d["name"],
"wechat_user_id": d["wechat_user_id"],
"shops": {s["shop_id"]: s["group_id"] for s in shops},
"is_online": bool(online and online["is_online"]),
})
return result

View File

@@ -0,0 +1,2 @@
"""Self-evolution MVP utilities for the customer service agent."""

591
legacy/evolution/mvp.py Normal file
View File

@@ -0,0 +1,591 @@
from __future__ import annotations
import json
import os
import sqlite3
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
ROOT = Path(__file__).resolve().parent.parent
ARTIFACT_DIR = ROOT / "evolution" / "artifacts"
DEFAULT_POLICY_PATH = ROOT / "config" / "evolution_policy.json"
DEFAULT_CANDIDATE_PATH = ROOT / "config" / "evolution_candidate.json"
RISK_KEYWORDS = (
"退款",
"退货",
"投诉",
"差评",
"举报",
"欺骗",
"骗人",
"不满意",
"生气",
"法院",
"起诉",
)
TRANSFER_HINTS = ("转人工", "人工", "为您转接", "专员", "稍后联系")
WEAK_REPLY_HINTS = ("不清楚", "不知道", "稍后", "晚点", "我再看下", "等会")
EMPATHY_HINTS = ("抱歉", "不好意思", "理解", "辛苦", "感谢反馈")
@dataclass
class Sample:
customer_id: str
acc_id: str
in_ts: str
in_text: str
out_ts: str
out_text: str
latency_sec: int
@dataclass
class Finding:
kind: str
severity: str
customer_id: str
acc_id: str
in_ts: str
in_text: str
out_text: str
detail: str
@dataclass
class ChatSourceConfig:
source: str = "auto" # auto | sqlite | mysql
sqlite_path: str = str(ROOT / "db" / "chat_log_db" / "chats.db")
mysql_host: str = os.getenv("MYSQL_HOST", "127.0.0.1")
mysql_port: int = int(os.getenv("MYSQL_PORT", "3306"))
mysql_user: str = os.getenv("MYSQL_USER", "root")
mysql_password: str = os.getenv("MYSQL_PASSWORD", "")
mysql_database: str = os.getenv("MYSQL_DATABASE", "ai_cs")
def _parse_ts(ts_text: str) -> Optional[datetime]:
if not ts_text:
return None
try:
return datetime.strptime(ts_text, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
def _to_ts_text(value: Any) -> str:
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
if value is None:
return ""
return str(value)
def _iter_recent_conversations_sqlite(
cfg: ChatSourceConfig,
hours: int,
max_customers: int,
max_messages_per_customer: int,
) -> Iterable[Tuple[str, List[Dict[str, Any]]]]:
cutoff_dt = datetime.now() - timedelta(hours=hours)
cutoff_text = cutoff_dt.strftime("%Y-%m-%d %H:%M:%S")
db_path = Path(cfg.sqlite_path)
if not db_path.exists():
return
conn = sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
cur = conn.execute(
"""
SELECT customer_id, MAX(timestamp) AS last_ts
FROM chat_logs
WHERE timestamp >= ?
GROUP BY customer_id
ORDER BY last_ts DESC
LIMIT ?
""",
(cutoff_text, max_customers),
)
customers = [dict(r) for r in cur.fetchall()]
for c in customers:
customer_id = str(c.get("customer_id") or "").strip()
if not customer_id:
continue
rows_cur = conn.execute(
"""
SELECT direction, message, timestamp, acc_id
FROM chat_logs
WHERE customer_id = ? AND timestamp >= ?
ORDER BY timestamp ASC, id ASC
LIMIT ?
""",
(customer_id, cutoff_text, max_messages_per_customer),
)
rows = [dict(r) for r in rows_cur.fetchall()]
if rows:
yield customer_id, rows
finally:
conn.close()
def _iter_recent_conversations_mysql(
cfg: ChatSourceConfig,
hours: int,
max_customers: int,
max_messages_per_customer: int,
) -> Iterable[Tuple[str, List[Dict[str, Any]]]]:
try:
import pymysql
except Exception:
return
cutoff_dt = datetime.now() - timedelta(hours=hours)
try:
conn = pymysql.connect(
host=cfg.mysql_host,
port=cfg.mysql_port,
user=cfg.mysql_user,
password=cfg.mysql_password,
database=cfg.mysql_database,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
)
except Exception:
return
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT customer_id, MAX(timestamp) AS last_ts
FROM chat_logs
WHERE timestamp >= %s
GROUP BY customer_id
ORDER BY last_ts DESC
LIMIT %s
""",
(cutoff_dt, max_customers),
)
customers = cur.fetchall() or []
for c in customers:
customer_id = str(c.get("customer_id") or "").strip()
if not customer_id:
continue
with conn.cursor() as cur:
cur.execute(
"""
SELECT direction, message, timestamp, acc_id
FROM chat_logs
WHERE customer_id = %s AND timestamp >= %s
ORDER BY timestamp ASC, id ASC
LIMIT %s
""",
(customer_id, cutoff_dt, max_messages_per_customer),
)
rows = cur.fetchall() or []
normalized = []
for r in rows:
normalized.append(
{
"direction": r.get("direction"),
"message": r.get("message"),
"timestamp": _to_ts_text(r.get("timestamp")),
"acc_id": r.get("acc_id"),
}
)
if normalized:
yield customer_id, normalized
finally:
conn.close()
def _iter_recent_conversations(
cfg: ChatSourceConfig,
hours: int,
max_customers: int,
max_messages_per_customer: int,
) -> Iterable[Tuple[str, List[Dict[str, Any]]]]:
source = (cfg.source or "auto").strip().lower()
if source == "sqlite":
yield from _iter_recent_conversations_sqlite(cfg, hours, max_customers, max_messages_per_customer)
return
if source == "mysql":
yield from _iter_recent_conversations_mysql(cfg, hours, max_customers, max_messages_per_customer)
return
# auto: prefer mysql when DB_TYPE=mysql, otherwise sqlite
db_type = os.getenv("DB_TYPE", "").strip().lower()
if db_type in ("mysql", "mariadb"):
got_any = False
for item in _iter_recent_conversations_mysql(cfg, hours, max_customers, max_messages_per_customer):
got_any = True
yield item
if got_any:
return
yield from _iter_recent_conversations_sqlite(cfg, hours, max_customers, max_messages_per_customer)
def build_samples(
hours: int = 24,
max_customers: int = 200,
max_messages_per_customer: int = 80,
chat_source: Optional[ChatSourceConfig] = None,
) -> List[Sample]:
cfg = chat_source or ChatSourceConfig()
samples: List[Sample] = []
for customer_id, rows in _iter_recent_conversations(
cfg=cfg,
hours=hours,
max_customers=max_customers,
max_messages_per_customer=max_messages_per_customer,
):
pending_in: Optional[Dict[str, Any]] = None
for row in rows:
direction = str(row.get("direction") or "")
if direction == "in":
pending_in = row
continue
if direction != "out" or pending_in is None:
continue
in_text = str(pending_in.get("message") or "").strip()
out_text = str(row.get("message") or "").strip()
if not in_text:
pending_in = None
continue
in_ts = _parse_ts(str(pending_in.get("timestamp") or ""))
out_ts = _parse_ts(str(row.get("timestamp") or ""))
latency = 0
if in_ts and out_ts:
latency = int((out_ts - in_ts).total_seconds())
samples.append(
Sample(
customer_id=customer_id,
acc_id=str(row.get("acc_id") or pending_in.get("acc_id") or ""),
in_ts=str(pending_in.get("timestamp") or ""),
in_text=in_text,
out_ts=str(row.get("timestamp") or ""),
out_text=out_text,
latency_sec=max(0, latency),
)
)
pending_in = None
return samples
def evaluate_samples(samples: List[Sample]) -> List[Finding]:
findings: List[Finding] = []
for s in samples:
in_text = s.in_text
out_text = s.out_text
inbound_risky = any(k in in_text for k in RISK_KEYWORDS)
if not out_text:
findings.append(
Finding(
kind="empty_reply",
severity="high",
customer_id=s.customer_id,
acc_id=s.acc_id,
in_ts=s.in_ts,
in_text=s.in_text,
out_text=s.out_text,
detail="收到消息但回复为空",
)
)
continue
if s.latency_sec > 600:
findings.append(
Finding(
kind="slow_reply",
severity="medium",
customer_id=s.customer_id,
acc_id=s.acc_id,
in_ts=s.in_ts,
in_text=s.in_text,
out_text=s.out_text,
detail=f"回复耗时 {s.latency_sec}s (>600s)",
)
)
if inbound_risky:
has_transfer = any(k in out_text for k in TRANSFER_HINTS)
has_empathy = any(k in out_text for k in EMPATHY_HINTS)
if not has_transfer:
findings.append(
Finding(
kind="risk_not_transferred",
severity="high",
customer_id=s.customer_id,
acc_id=s.acc_id,
in_ts=s.in_ts,
in_text=s.in_text,
out_text=s.out_text,
detail="高风险诉求未出现转人工提示",
)
)
if not has_empathy:
findings.append(
Finding(
kind="risk_no_empathy",
severity="medium",
customer_id=s.customer_id,
acc_id=s.acc_id,
in_ts=s.in_ts,
in_text=s.in_text,
out_text=s.out_text,
detail="高风险诉求回复缺少安抚语气",
)
)
if any(k in out_text for k in WEAK_REPLY_HINTS):
findings.append(
Finding(
kind="weak_reply",
severity="medium",
customer_id=s.customer_id,
acc_id=s.acc_id,
in_ts=s.in_ts,
in_text=s.in_text,
out_text=s.out_text,
detail="回复存在低置信度兜底话术",
)
)
return findings
def summarize_findings(findings: List[Finding]) -> Dict[str, Any]:
by_kind: Dict[str, int] = {}
by_severity: Dict[str, int] = {}
for f in findings:
by_kind[f.kind] = by_kind.get(f.kind, 0) + 1
by_severity[f.severity] = by_severity.get(f.severity, 0) + 1
return {"total": len(findings), "by_kind": by_kind, "by_severity": by_severity}
def make_proposals(findings: List[Finding], sample_count: int) -> List[Dict[str, Any]]:
summary = summarize_findings(findings)
by_kind = summary["by_kind"]
proposals: List[Dict[str, Any]] = []
if by_kind.get("risk_not_transferred", 0) > 0:
proposals.append(
{
"id": "policy-risk-transfer",
"priority": "p0",
"module": "policy/prompt",
"title": "风险关键词触发后强制转人工",
"suggestion": "在风险路由的系统提示词中增加硬规则:遇到退款/投诉/法律威胁类诉求必须调用 transfer_to_human。",
"evidence_count": by_kind["risk_not_transferred"],
}
)
if by_kind.get("risk_no_empathy", 0) > 0:
proposals.append(
{
"id": "tone-empathy-pack",
"priority": "p1",
"module": "policy/prompt",
"title": "高风险场景补充安抚模板",
"suggestion": "为投诉类回复追加一段安抚模板,降低激化概率。",
"evidence_count": by_kind["risk_no_empathy"],
}
)
if by_kind.get("weak_reply", 0) > 0:
proposals.append(
{
"id": "fallback-reduction",
"priority": "p1",
"module": "intent/router",
"title": "减少低置信度兜底话术",
"suggestion": "出现“不清楚/稍后”等兜底词时,优先触发澄清问题或转人工而非直接结束。",
"evidence_count": by_kind["weak_reply"],
}
)
if by_kind.get("slow_reply", 0) > 0:
proposals.append(
{
"id": "slow-path-timeout",
"priority": "p2",
"module": "tools/workflow",
"title": "慢链路超时与短回复兜底",
"suggestion": "当工具调用超过阈值时先发短确认回复,避免长时间无响应。",
"evidence_count": by_kind["slow_reply"],
}
)
proposals.append(
{
"id": "ops-regression-gate",
"priority": "p0",
"module": "eval/pipeline",
"title": "上线前回归门禁",
"suggestion": "新增候选策略必须在离线评测集上通过,再灰度 5% 流量后扩大。",
"evidence_count": sample_count,
}
)
return proposals
def load_policy(path: Path = DEFAULT_POLICY_PATH) -> Dict[str, Any]:
if not path.exists():
return {
"publish_gate": {
"min_sample_count": 30,
"max_high_findings_rate": 0.08,
"max_ai_fail_rate": 5.0,
"max_transfer_rate": 45.0,
}
}
return json.loads(path.read_text(encoding="utf-8"))
def can_publish_candidate(samples: List[Sample], findings: List[Finding], runtime_hours: int, policy: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]:
try:
from utils.metrics_tracker import get_runtime_summary
except Exception:
def get_runtime_summary(hours: int = 24) -> Dict[str, Any]:
return {"window_hours": hours, "counts": {}, "rates": {"ai_fail_rate": 0.0, "transfer_rate": 0.0}}
gate = (policy or {}).get("publish_gate", {})
min_sample_count = int(gate.get("min_sample_count", 30))
max_high_rate = float(gate.get("max_high_findings_rate", 0.08))
max_ai_fail_rate = float(gate.get("max_ai_fail_rate", 5.0))
max_transfer_rate = float(gate.get("max_transfer_rate", 45.0))
high_cnt = sum(1 for f in findings if f.severity == "high")
sample_count = max(1, len(samples))
high_rate = high_cnt / sample_count
runtime = get_runtime_summary(hours=runtime_hours)
ai_fail_rate = float(runtime.get("rates", {}).get("ai_fail_rate", 0.0))
transfer_rate = float(runtime.get("rates", {}).get("transfer_rate", 0.0))
reasons = []
ok = True
if len(samples) < min_sample_count:
ok = False
reasons.append(f"样本不足: {len(samples)} < {min_sample_count}")
if high_rate > max_high_rate:
ok = False
reasons.append(f"高危发现占比过高: {high_rate:.2%} > {max_high_rate:.2%}")
if ai_fail_rate > max_ai_fail_rate:
ok = False
reasons.append(f"AI失败率过高: {ai_fail_rate:.2f}% > {max_ai_fail_rate:.2f}%")
if transfer_rate > max_transfer_rate:
ok = False
reasons.append(f"转人工率过高: {transfer_rate:.2f}% > {max_transfer_rate:.2f}%")
return ok, {
"sample_count": len(samples),
"high_findings": high_cnt,
"high_findings_rate": round(high_rate, 4),
"runtime": runtime,
"policy_gate": gate,
"reasons": reasons,
}
def _write_json(path: Path, payload: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _write_jsonl(path: Path, rows: Iterable[Dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
def run_cycle(
hours: int = 24,
max_customers: int = 200,
max_messages_per_customer: int = 80,
runtime_hours: int = 24,
publish: bool = False,
chat_source: Optional[ChatSourceConfig] = None,
policy_path: Path = DEFAULT_POLICY_PATH,
candidate_path: Path = DEFAULT_CANDIDATE_PATH,
) -> Dict[str, Any]:
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
now_tag = datetime.now().strftime("%Y%m%d_%H%M%S")
source_error = ""
try:
samples = build_samples(
hours=hours,
max_customers=max_customers,
max_messages_per_customer=max_messages_per_customer,
chat_source=chat_source,
)
except Exception as e:
samples = []
source_error = str(e)
findings = evaluate_samples(samples)
proposals = make_proposals(findings=findings, sample_count=len(samples))
policy = load_policy(path=policy_path)
publish_ok, gate_report = can_publish_candidate(
samples=samples,
findings=findings,
runtime_hours=runtime_hours,
policy=policy,
)
sample_file = ARTIFACT_DIR / f"samples_{now_tag}.jsonl"
eval_file = ARTIFACT_DIR / f"eval_report_{now_tag}.json"
proposal_file = ARTIFACT_DIR / f"proposals_{now_tag}.json"
_write_jsonl(sample_file, (asdict(s) for s in samples))
_write_json(
eval_file,
{
"generated_at": datetime.now().isoformat(timespec="seconds"),
"sample_count": len(samples),
"finding_summary": summarize_findings(findings),
"publish_gate_report": gate_report,
},
)
_write_json(
proposal_file,
{
"generated_at": datetime.now().isoformat(timespec="seconds"),
"proposals": proposals,
},
)
published = False
candidate_payload: Dict[str, Any] = {}
if publish and publish_ok:
candidate_payload = {
"version": f"candidate-{now_tag}",
"created_at": datetime.now().isoformat(timespec="seconds"),
"sample_file": str(sample_file),
"eval_file": str(eval_file),
"proposal_file": str(proposal_file),
"gate_report": gate_report,
"proposals": proposals,
"status": "ready_for_gray_5_percent",
}
_write_json(candidate_path, candidate_payload)
published = True
source_view = asdict(chat_source) if chat_source else asdict(ChatSourceConfig())
if source_view.get("mysql_password"):
source_view["mysql_password"] = "***"
return {
"samples": len(samples),
"findings": len(findings),
"publish_ok": publish_ok,
"published": published,
"chat_source": source_view,
"source_error": source_error,
"artifacts": {
"samples": str(sample_file),
"evaluation": str(eval_file),
"proposals": str(proposal_file),
"candidate": str(candidate_path) if published else "",
},
"gate_report": gate_report,
"top_proposals": proposals[:3],
}

View File

@@ -0,0 +1,101 @@
# 压价引导注册功能
## 功能说明
当客户觉得价格太贵时AI 客服会分两步应对:
### 第一步:直接让价
- 只让价一次
- 不低于底线价格
- 话术自然多变
### 第二步:引导客户自己去注册
如果让价后客户还是嫌贵,引导客户**自己去注册 xinhui.cloud**获取新用户余额。
## ⚠️ 核心要求
1. **每个话术都必须带网站地址 xinhui.cloud**
2. **强调客户自己去弄**(自己去操作/自己下单/自己弄)
3. 语气直接,不要软
4. 不要说具体金额
## 触发条件
客户消息包含以下关键词:
- "贵"
- "有点贵"
- "太贵了"
- "便宜点"
- "少点"
- "打折"
## 话术示例(每个都带网站)
### 让价话术
- "那给你少点XX 吧"
- "最低 XX 了"
- "给你个优惠价 XX"
- "看你诚心要XX 给你"
### 注册引导话术(让价后仍嫌贵时使用)
**每个话术都必须包含 xinhui.cloud 网址!**
1. 「嫌贵就去 xinhui.cloud 注册个号,自己有余额可以弄」
2. 「xinhui.cloud 去注册下,新用户送体验金,自己弄便宜」
3. 「去 xinhui.cloud 注册个账号,里面有余额,自己下单划算」
4. 「自己上 xinhui.cloud 注册,新人有福利,比直接找我便宜」
5. 「xinhui.cloud 去注册个号,自己充值有优惠,自己弄吧」
6. 「嫌贵就自己去 xinhui.cloud 弄,注册送余额,比找我划算」
7. 「xinhui.cloud 注册个号,自己充值自己弄,便宜些」
8. 「去 xinhui.cloud 注册,里面有余额可以用,自己操作吧」
## 对话示例
```
客户:这个多少钱?
AI: 20 元
客户:太贵了,便宜点吧
AI: 那给你少点15 吧
客户:还是有点贵
AI: 嫌贵就去 xinhui.cloud 注册个号,自己有余额可以弄
```
或:
```
客户:有点贵啊
AI: 最低 15 了
客户:还是贵
AI: xinhui.cloud 去注册下,新用户送体验金,自己弄便宜
```
## 注意事项
1. ⚠️ **每个话术都必须带 xinhui.cloud 网址**
2. ⚠️ **强调"自己去弄""自己操作""自己下单"**
3. 不说具体余额金额
4. 每次选择不同话术变体
5. 只在让价后仍嫌贵时使用
6. 语气直接,不要软
## 代码位置
- 技能文档:`/skills/customer-service/customer-skill.md`
- Agent 代码:`/core/pydantic_ai_agent.py` 第 1745 行
## 测试方法
```bash
# 启动 AI 客服系统
systemctl start ai-cs
# 查看日志
journalctl -u ai-cs -f
```
模拟客户消息:"太贵了,便宜点吧"
预期 AI 回复:先让价,如仍嫌贵则引导客户自己去 xinhui.cloud 注册

View File

@@ -0,0 +1,129 @@
# 找茬客户识别功能
## 功能说明
识别高风险找茬客户,建议 AI 客服不做这单,直接拒绝或转人工。
---
## 🚨 高风险信号(识别 3 个以上→拒绝接单)
| 信号 | 示例 | 风险等级 |
|------|------|----------|
| **下单后立即申请退款** | 刚拍下就退款 0.88 元 | 🔴 高 |
| **从高价砍到低价** | 30 元砍到 10 元 | 🔴 高 |
| **反复问退款2 次+** | "不满意可以退吗"×2 | 🔴 高 |
| **质疑服务内容** | "源文件还是什么" | 🟡 中 |
| **质疑价值** | "就一张图片" | 🟡 中 |
| **想占便宜** | "小一点就快一点的嘛" | 🟡 中 |
| **重复问同一问题** | 同样的问题问 2 次+ | 🔴 高 |
---
## 应对策略
### 识别到 3 个以上高风险信号
**直接拒绝接单**
**话术示例**
- 「不好意思,这单做不了」
- 「去别家做吧」
- 「这单接不了,你找别人吧」
**不要**
- ❌ 不要解释原因
- ❌ 不要讨价还价
- ❌ 不要妥协
---
## 真实案例分析
### 案例:高风险找茬客户
**对话记录**
```
客户:这个原图有吗
AI: 没问题,拍下安排。
客户:拍啦
[客户立即申请退款 0.88 元]
客户太贵了10 元
AI: 30
[退款成功]
客户:要多久?
客户300×50cm
AI: 1 个小时
客户:小一点就快一点的嘛
客户20 可以吗
AI: [不回应]
客户25
客户:源文件还是什么?
客户:就一张图片
客户:不满意可以退吗
客户:不满意可以退吗(第 2 次问)
AI: 去别家做吧
```
**风险信号识别**
1. ✅ 下单后立即申请退款
2. ✅ 从 30 砍到 10 元
3. ✅ 质疑价值("就一张图片"
4. ✅ 想占便宜("小一点就快一点"
5. ✅ 重复问退款2 次)
**结论**5 个高风险信号 → **拒绝接单**
---
## 代码位置
- Agent 代码:`/core/pydantic_ai_agent.py` - 找茬客户识别规则
- 技能文档:`/skills/customer-service/customer-skill.md` - 客服话术指南
---
## 测试方法
### 模拟高风险客户
```bash
# 启动 AI 客服
systemctl start ai-cs
# 查看日志
journalctl -u ai-cs -f
```
**模拟对话**
```
客户20 可以吗
AI: 最低 30
客户25
客户:不满意可以退吗
客户:不满意可以退吗(第 2 次)
```
**预期 AI 回复**
- 「不好意思,这单做不了」
- 「去别家做吧」
---
## 注意事项
1. **识别 3 个以上信号才拒绝**:不要误伤正常客户
2. **话术简洁**:不要解释原因
3. **态度坚定**:不要妥协
4. **不调用报价工具**:直接拒绝
---
## 与转人工的区别
| 情况 | 处理方式 |
|------|----------|
| 退款/投诉/情绪激动 | 转人工 |
| 找茬客户3 个+信号) | 直接拒绝 |
| 敏感内容 | 直接拒绝 |

View File

@@ -0,0 +1,45 @@
# 自我进化 MVP可控版
目标:让客服 agent 持续变聪明,同时避免“自动改坏线上”。
## 1. 已落地能力
- 失败样本采集:从 `db/chat_log_db/chats.db` 抽取近 N 小时客服问答对。
- 离线评测:自动识别高风险未转人工、低置信度兜底、慢回复等问题。
- 改进建议生成:输出可执行的模块级 proposalprompt/router/workflow
- 发布门禁:结合运行指标(`config/.runtime_metrics.jsonl`)判断是否允许发布候选版本。
- 候选产物:通过门禁后写入 `config/evolution_candidate.json`,用于 5% 灰度。
## 2. 运行方式
```bash
python scripts/evolution_cycle.py --hours 24 --publish
```
默认即读取线上 MySQL`--source mysql`)。连接信息来自 `.env``MYSQL_*`
常用参数:
- `--max-customers 200`
- `--max-messages-per-customer 80`
- `--runtime-hours 24`
- `--policy-path config/evolution_policy.json`
## 3. 产物说明
运行后会在 `evolution/artifacts/` 生成:
- `samples_*.jsonl`:评测样本
- `eval_report_*.json`:评测摘要与门禁结果
- `proposals_*.json`:改进建议列表
`--publish` 且门禁通过时:
- 写入 `config/evolution_candidate.json`
- 状态标记为 `ready_for_gray_5_percent`
## 4. 下一步建议
-`scripts/evolution_cycle.py` 加入每日定时任务(例如凌晨 2 点)。
- 在灰度层接入 `evolution_candidate.json` 的版本号,按店铺或客户哈希做 5% 放量。
- 将 proposal 落地为具体 patch 后,先跑 `tests/` 回归,再扩大流量。

View File

@@ -0,0 +1,158 @@
# 文字加价功能
## 功能说明
当识别到图片含有很多文字时AI 客服系统会自动提高报价,不能低价。
**核心原则**:有文字跟没文字是两个价格!
---
## 价格规则
### 含文字很多时
| 原复杂度 | 原价区间 | 加价后 | 加价后区间 |
|---------|---------|--------|----------|
| simple | 10-15 元 | → normal | 15-20 元 |
| normal | 15-20 元 | → complex | 20-25 元 |
| complex | 20-25 元 | 保持不变 | 20-25 元 |
| hard | 25-30 元 | 保持不变 | 25-30 元 |
### 判断标准
**含文字很多**(需要加价):
- ✅ 图片里有大量小字
- ✅ 需要精细保留文字清晰度
- ✅ 文字需要清晰化处理
**不含文字或文字很少**(不加价):
- ❌ 图片干净,没文字
- ❌ 只有零星几个大字
---
## 代码修改
### 1. image_analyzer.py
文件:`/root/ai_customer_service/ai_cs/image/image_analyzer.py`
**修改位置**:第 528-542 行
```python
# 【重要】含文字很多时,不能低价,必须 complex 起步20 元以上)
# 有文字跟没文字是两个价格
if has_text == "yes":
if complexity == "simple":
# 简单但含文字 → 提升到 normal 价格
price_min, price_max = self.PRICE_MAP["normal"]
reason = "含文字,需精细处理"
elif complexity == "normal":
# normal 含文字 → 提升到 complex 价格
price_min, price_max = self.PRICE_MAP["complex"]
reason = "含文字,需精细处理"
# complex/hard 保持原价,已经够高
```
### 2. pydantic_ai_agent.py
文件:`/root/ai_customer_service/ai_cs/core/pydantic_ai_agent.py`
**修改位置**:第 863-869 行
```python
文字加价规则 重要
- 含文字很多时不能低价有文字跟没文字是两个价格
- 含文字的图必须 complex 起步20 元以上
- 客户嫌贵时明确告知有文字跟没文字是两个价格
- 简单图但含文字 normal 价格15-20
- normal 图含文字 complex 价格20-25
```
### 3. customer-skill.md
文件:`/root/ai_customer_service/ai_cs/skills/customer-service/customer-skill.md`
**新增章节**:⑫ 文字加价规则
---
## 对话示例
### 示例 1简单图但含文字
```
客户:[发送一张含文字的图片]
AI: 图里有不少字要精细处理20 元
客户:这么贵
AI: 有文字的图跟没文字的价格不一样,已经是最低价了
```
### 示例 2normal 图含文字
```
客户:这个多少钱?
AI: 25 元
客户:太贵了
AI: 含文字的图要精细处理,成本更高,跟没文字的价格不一样
```
### 示例 3客户问为什么贵
```
客户:这个为什么比那个贵?
AI: 这个图含文字,需要精细处理,有文字跟没文字是两个价格
```
---
## 话术要点
### 必须包含的信息
1. ✅ 明确告知「有文字跟没文字是两个价格」
2. ✅ 说明「文字需要精细处理」
3. ✅ 强调「已经是最低价」
### 常用话术
- 「有文字跟没文字是两个价格」
- 「文字处理要精细,成本高」
- 「含文字的图都这个价」
- 「文字越多越贵」
- 「已经是最低价了,含文字的都这样」
---
## 测试方法
### 测试步骤
1. **准备测试图片**
- 一张含有很多小字的图片
- 一张干净的图片(对比)
2. **发送给 AI 客服**
```bash
systemctl start ai-cs
journalctl -u ai-cs -f
```
3. **验证报价**
- 含文字图片:应该报价 20 元以上
- 干净图片:正常报价 10-15 元
### 预期结果
- ✅ 含文字图片自动加价
- ✅ 客户嫌贵时明确告知「有文字跟没文字是两个价格」
- ✅ 价格必须为 5 的整数倍
---
## 注意事项
1. **只针对小字**:大字不加价
2. **价格必须为 5 的倍数**15/20/25/30
3. **明确告知客户**:不要偷偷加价
4. **坚持原则**:客户嫌贵也要说明原因

View File

@@ -0,0 +1,137 @@
# 图绘上传功能
## 功能说明
AI 客服处理完图片后,上传到图绘平台并定价,告诉客户「这个图片是找到的」,然后问客户要下载还是购买。
---
## ⚠️ 重要话术
**必须说**
- ✅ 「找到了!图片已经上传到图绘平台了」
- ✅ 「你是要下载还是直接购买?」
- ✅ 「价格 XX 元」
**不要说**
- ❌ 「处理好了」
- ❌ 「修复好了」
- ❌ 「AI 修复的」
---
## 使用流程
1. **客户发图** → AI 客服收到图片
2. **图片处理** → 调用图片处理工具
3. **上传图绘** → 调用 `upload_to_tuhui_platform()` 工具
4. **返回话术** → 「找到了!...你是要下载还是购买?」
---
## 对话示例
### 示例 1标准流程
```
客户:[发送图片]
AI: 收到了,稍等...
[AI 调用图片处理工具]
[AI 调用 upload_to_tuhui_platform 工具]
AI: 找到了!图片已经上传到图绘平台了,作品 ID: 123
AI: 你是要下载还是直接购买?价格 20 元。
```
### 示例 2客户选择下载
```
AI: 找到了!图片已经上传到图绘平台了
AI: 你是要下载还是直接购买?价格 20 元。
客户:下载
AI: 好的,拍下后就可以下载了
```
### 示例 3客户选择购买
```
AI: 你是要下载还是直接购买?价格 20 元。
客户:购买
AI: 好的,拍下就行,付款后发你高清原图
```
### 示例 4客户问在哪里
```
客户:弄好了吗
AI: 找到了,已经上传到图绘平台了
AI: 作品 ID: 123你是要下载还是购买
```
---
## 配置说明
### .env 配置
```bash
# 图绘平台配置
TUHUI_BASE_URL=http://127.0.0.1:8002
TUHUI_PHONE=17520145271 # 图绘账号手机号
TUHUI_PASSWORD=zuowei1216 # 图绘账号密码
TUHUI_DEFAULT_PRICE=20 # 默认定价(元)
```
### AI Agent 工具
```python
@self.agent.tool
async def upload_to_tuhui_platform(
ctx: RunContext[AgentDeps],
image_path: str,
title: str,
price: int = 20
) -> str:
"""将处理好的图片上传到图绘平台并定价"""
# 返回:「找到了!图片已经上传到图绘平台了,作品 ID: 123。你是要下载还是直接购买价格 20 元。」
```
---
## 代码位置
- 上传服务:`/services/service_tuhui_upload.py`
- Agent 工具:`/core/pydantic_ai_agent.py` 第 220 行
- 客服话术:`/skills/customer-service/customer-skill.md` 第⑭节
---
## 注意事项
1. ⚠️ **必须说「找到了」**,不要说「处理好了」
2. ⚠️ **必须问「要下载还是购买」**
3. ⚠️ **必须说价格**
4. ✅ 图片是"找到的",不是"处理的"
5. ✅ 客户可以选择下载或购买
---
## 测试方法
```bash
# 1. 配置图绘账号
vi /root/ai_customer_service/ai_cs/.env
# 2. 重启 AI 客服
systemctl restart ai-cs
# 3. 查看日志
journalctl -u ai-cs -f
# 4. 发送图片测试
# 观察日志中的上传结果和话术
```

218
legacy/find_image_flow.py Normal file
View File

@@ -0,0 +1,218 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING, Optional
logger = logging.getLogger("cs_agent")
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()
logger.info("[REPLY->CUSTOMER] %s", 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()
logger.info("[REPLY->CUSTOMER] %s", 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()
logger.info("[REPLY->CUSTOMER] %s", 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()
logger.info("[REPLY->CUSTOMER] %s", 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()
logger.info("[REPLY->CUSTOMER] %s", 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()
logger.info("[REPLY->CUSTOMER] %s", 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()
logger.info("[REPLY->CUSTOMER] %s", 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()
logger.info("[REPLY->CUSTOMER] %s", 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()
logger.info("[REPLY->CUSTOMER] %s", 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()
logger.info("[REPLY->CUSTOMER] %s", remind)
return AgentResponse(reply=remind, should_reply=True, need_transfer=False)

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger("cs_agent")
async def handle_image_workflow(*, workflow_router: Any, message: str, data: dict, image_urls: list) -> bool:
"""处理图片工作流(根据客户说的话判断执行哪种工作流)。"""
if not image_urls:
return False
workflow_type, confidence = workflow_router.detect_workflow(message)
customer_id = data.get("from_id")
acc_id = data.get("acc_id", "")
acc_type = data.get("acc_type", "AliWorkbench")
image_url = image_urls[0]
logger.info("[Agent] 检测到工作流类型:%s (置信度:%s)", workflow_type, confidence)
if workflow_type == "find_image":
logger.info("[Agent] 执行查找图片工作流 | 客户:%s", customer_id)
from core.workflow import workflow
return await workflow.find_image_workflow(
customer_id=customer_id,
image_url=image_url,
acc_id=acc_id,
acc_type=acc_type,
)
if workflow_type == "process_image":
logger.info("[Agent] 执行处理图片工作流 | 客户:%s", customer_id)
from core.workflow import workflow
return await workflow.process_image_workflow(
customer_id=customer_id,
image_url=image_url,
acc_id=acc_id,
acc_type=acc_type,
)
if workflow_type == "transfer_human":
logger.info("[Agent] 执行转人工派单工作流 | 客户:%s", customer_id)
from core.workflow import workflow
return await workflow.transfer_to_designer_workflow(
customer_id=customer_id,
image_url=image_url,
acc_id=acc_id,
acc_type=acc_type,
reason="客户主动要求转人工",
)
return False

159
legacy/intent_analyzer.py Normal file
View File

@@ -0,0 +1,159 @@
# -*- coding: utf-8 -*-
"""
语义匹配 - 用 embedding 做意图/情绪识别
配置 EMBEDDING_MODEL 后启用,否则回退到关键词
"""
import os
import logging
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
# 意图模板(用于 embedding 相似度匹配)
INTENT_TEMPLATES = {
"询价": "我想问一下价格多少钱",
"发图": "我发图给你看看",
"砍价": "能不能便宜点太贵了",
"批量": "我要做很多张图批量",
"加急": "能不能快点很急",
"售后": "已经付款了什么时候好",
"修改": "不满意要改一下",
"转接": "我要退款投诉",
"打招呼": "你好在吗有人吗",
}
EMOTION_TEMPLATES = {
"平静": "好的谢谢",
"着急": "快点啊很急",
"不满": "怎么这么慢不满意",
"砍价": "太贵了便宜点",
}
_template_embeddings: dict = {}
@dataclass
class IntentDecision:
intent: str = ""
source: str = "none" # embedding / keyword / none
score: float = 0.0
def _get_embedding(text: str, cache_key: str = None) -> Optional[list]:
"""调用 embedding API失败返回 None。cache_key 用于缓存模板向量"""
model = os.getenv("EMBEDDING_MODEL", "")
if not model:
return None
if cache_key and cache_key in _template_embeddings:
return _template_embeddings[cache_key]
try:
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL"),
)
resp = client.embeddings.create(model=model, input=text[:2000])
emb = resp.data[0].embedding
if cache_key:
_template_embeddings[cache_key] = emb
return emb
except Exception as e:
logger.debug(f"embedding 失败: {e}")
return None
def _cosine_sim(a: list, b: list) -> float:
if not a or not b or len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b))
na = sum(x * x for x in a) ** 0.5
nb = sum(y * y for y in b) ** 0.5
if na == 0 or nb == 0:
return 0.0
return dot / (na * nb)
def detect_intent_embedding(msg: str) -> Optional[str]:
"""用 embedding 检测意图,未配置或失败返回 None。"""
decision = detect_intent_embedding_decision(msg)
return decision.intent or None
def detect_intent_embedding_decision(msg: str) -> IntentDecision:
"""返回 embedding 意图决策(含分值)。"""
msg_emb = _get_embedding(msg)
if not msg_emb:
return IntentDecision()
best_intent, best_score = "", 0.0
for intent, template in INTENT_TEMPLATES.items():
tpl_emb = _get_embedding(template, cache_key=f"intent_{intent}")
if not tpl_emb:
continue
sim = _cosine_sim(msg_emb, tpl_emb)
if sim > best_score:
best_score = sim
best_intent = intent
if best_score > 0.6:
return IntentDecision(intent=best_intent, source="embedding", score=float(best_score))
return IntentDecision()
def detect_emotion_embedding(msg: str) -> Optional[str]:
"""用 embedding 检测情绪"""
msg_emb = _get_embedding(msg)
if not msg_emb:
return None
best_emotion, best_score = "", 0.0
for emotion, template in EMOTION_TEMPLATES.items():
tpl_emb = _get_embedding(template, cache_key=f"emotion_{emotion}")
if not tpl_emb:
continue
sim = _cosine_sim(msg_emb, tpl_emb)
if sim > best_score:
best_score = sim
best_emotion = emotion
return best_emotion if best_score > 0.55 else None
def detect_intent_keywords(msg: str) -> str:
"""关键词回退:无 embedding 时使用"""
m = (msg or "").strip().lower()
if any(k in m for k in ["退款", "退货", "投诉"]):
return "转接"
if any(k in m for k in ["多张", "批量", "很多", "几十张"]):
return "批量"
if any(k in m for k in ["快点", "加急", "很急", "着急"]):
return "加急"
if any(k in m for k in ["便宜", "", "少点", "打折"]):
return "砍价"
if any(k in m for k in ["", "修改", "不满意"]):
return "修改"
if any(k in m for k in ["多少钱", "价格", "报价", "多钱", "收费", "怎么收费", "咋收费"]):
return "询价"
if any(k in m for k in ["在吗", "你好", "有人"]):
return "打招呼"
return ""
def detect_intent(msg: str) -> IntentDecision:
"""
AI 意图判定 + 规则兜底:
1) 有 embedding 配置时先走 embedding。
2) 失败/低置信时回退关键词规则。
"""
text = (msg or "").strip()
if not text:
return IntentDecision()
try:
emb_decision = detect_intent_embedding_decision(text)
except Exception:
emb_decision = IntentDecision()
if emb_decision.intent:
return emb_decision
kw_intent = detect_intent_keywords(text)
if kw_intent:
return IntentDecision(intent=kw_intent, source="keyword", score=0.0)
return IntentDecision()

0
legacy/mail/__init__.py Normal file
View File

View File

@@ -0,0 +1,331 @@
"""
邮件接收模块 - 监控收件箱,客户发图询价/下单自动处理
流程:
客户发邮件(含图片附件)→ 自动分析图片复杂度 → 回复报价
客户回复"拍了"/"确认" → 创建处理任务 → Gemini 作图 → 发结果
"""
import asyncio
import imaplib
import email
import email.header
import os
import tempfile
import logging
from datetime import datetime
from email.header import decode_header
from typing import Optional
logger = logging.getLogger(__name__)
# 支持的图片格式
IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
def _decode_str(value: str) -> str:
"""解码邮件头部字段(处理中文编码)"""
if not value:
return ""
parts = decode_header(value)
result = []
for part, charset in parts:
if isinstance(part, bytes):
try:
result.append(part.decode(charset or "utf-8", errors="replace"))
except Exception:
result.append(part.decode("utf-8", errors="replace"))
else:
result.append(part)
return "".join(result)
class EmailReceiver:
"""IMAP 邮件接收器,轮询新邮件并自动处理图片询价"""
def __init__(
self,
imap_host: str = "imap.qq.com",
imap_port: int = 993,
username: str = "",
password: str = "",
poll_interval: int = 30,
):
self.imap_host = imap_host
self.imap_port = imap_port
self.username = username
self.password = password
self.poll_interval = poll_interval
self._running = False
self._send_reply = None # 注入的回复函数
def register_reply_callback(self, callback):
"""注入回复函数(直接用 email_sender 回复)"""
self._send_reply = callback
# ========== 主循环 ==========
async def start(self):
"""启动轮询(作为后台任务运行)"""
self._running = True
logger.info(f"[EmailReceiver] 启动,每 {self.poll_interval}s 检查一次收件箱")
while self._running:
try:
await self._check_inbox()
except Exception as e:
logger.error(f"[EmailReceiver] 轮询异常: {e}")
await asyncio.sleep(self.poll_interval)
def stop(self):
self._running = False
# ========== 收件箱检查 ==========
async def _check_inbox(self):
"""连接 IMAP检查未读邮件"""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._check_inbox_sync)
def _check_inbox_sync(self):
"""同步版收件箱检查(在线程池里跑,避免阻塞事件循环)"""
try:
conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port)
conn.login(self.username, self.password)
conn.select("INBOX")
# 搜索未读邮件
_, msg_ids = conn.search(None, "UNSEEN")
ids = msg_ids[0].split()
if not ids:
conn.logout()
return
logger.info(f"[EmailReceiver] 发现 {len(ids)} 封未读邮件")
for msg_id in ids:
try:
_, data = conn.fetch(msg_id, "(RFC822)")
raw = data[0][1]
msg = email.message_from_bytes(raw)
self._process_email_sync(msg)
# 标记为已读
conn.store(msg_id, "+FLAGS", "\\Seen")
except Exception as e:
logger.error(f"[EmailReceiver] 处理邮件 {msg_id} 失败: {e}")
conn.logout()
except Exception as e:
logger.error(f"[EmailReceiver] IMAP 连接失败: {e}")
# ========== 邮件处理 ==========
def _process_email_sync(self, msg):
"""处理单封邮件:提取发件人、附件图片,触发分析和回复"""
sender = _decode_str(msg.get("From", ""))
subject = _decode_str(msg.get("Subject", "(无主题)"))
# 提取发件人邮箱地址
sender_email = self._extract_email_addr(sender)
if not sender_email:
logger.warning(f"[EmailReceiver] 无法解析发件人地址: {sender}")
return
logger.info(f"[EmailReceiver] 处理邮件 | 来自: {sender_email} | 主题: {subject}")
# 提取正文
body_text = self._extract_body(msg)
# 提取图片附件
image_paths = self._extract_images(msg)
# 异步触发处理(把同步上下文切回事件循环)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(
self._handle_email(sender_email, subject, body_text, image_paths)
)
finally:
loop.close()
# 清理临时图片
for p in image_paths:
try:
os.remove(p)
except Exception:
pass
async def _handle_email(
self,
sender_email: str,
subject: str,
body: str,
image_paths: list,
):
"""根据邮件内容决定如何处理"""
body_lower = (body or "").lower()
# ① 有图片附件 → 分析图片,回复报价
if image_paths:
await self._handle_image_inquiry(sender_email, subject, image_paths)
return
# ② 纯文字邮件 → 引导发图
await self._reply_email(
to=sender_email,
subject=f"Re: {subject}",
body=self._html(
"您好!收到您的邮件。<br><br>"
"请将您需要处理的图片作为<b>附件</b>发送过来,我们会尽快为您报价。<br><br>"
"支持格式JPG、PNG、WEBP 等常见图片格式。"
),
)
async def _handle_image_inquiry(
self, sender_email: str, subject: str, image_paths: list
):
"""分析图片,回复报价"""
from image.image_analyzer import image_analyzer
quotes = []
for idx, img_path in enumerate(image_paths, 1):
try:
# image_analyzer 支持本地路径
result = await image_analyzer.analyze(img_path)
price = result.get("price_suggest", 30)
reason = result.get("reason", "")
label = {
"simple": "画面简洁",
"normal": "一般复杂度",
"complex": "细节较多",
"hard": "非常复杂",
}.get(result.get("complexity", ""), "")
quotes.append(
f"图片{idx}{label},建议报价 <b>{price} 元</b>"
+ (f"{reason}" if reason else "")
)
except Exception as e:
logger.error(f"[EmailReceiver] 图片分析失败: {e}")
quotes.append(f"图片{idx}:分析失败,建议报价 30 元")
# 多图打包优惠
n = len(image_paths)
if n >= 5:
tip = f"<br><br>📦 您共发来 <b>{n} 张</b>图片,支持打包优惠,欢迎咨询。"
elif n >= 3:
tip = f"<br><br>📦 您共发来 <b>{n} 张</b>图片3张以上可享9折优惠。"
else:
tip = ""
quote_html = "<br>".join(quotes)
body = self._html(
f"您好!感谢您发来图片,已为您完成分析:<br><br>"
f"{quote_html}{tip}<br><br>"
f"如需处理,请直接在淘宝店铺下单,付款后我们会尽快为您完成制作并发回。<br>"
f"如有疑问欢迎回复此邮件。"
)
await self._reply_email(
to=sender_email,
subject=f"Re: {subject}" if subject else "您的图片报价",
body=body,
)
logger.info(f"[EmailReceiver] 已向 {sender_email} 回复报价")
# ========== 工具方法 ==========
async def _reply_email(self, to: str, subject: str, body: str):
"""发送回复邮件"""
try:
from mail.email_sender import email_sender
result = email_sender.send(to_email=to, subject=subject, body=body)
if not result.get("success"):
logger.error(f"[EmailReceiver] 回复发送失败: {result.get('message')}")
except Exception as e:
logger.error(f"[EmailReceiver] 回复异常: {e}")
def _extract_email_addr(self, from_field: str) -> Optional[str]:
"""从 From 字段提取邮箱地址"""
import re
m = re.search(r'[\w\.\+\-]+@[\w\.\-]+\.\w+', from_field)
return m.group(0) if m else None
def _extract_body(self, msg) -> str:
"""提取邮件纯文本正文"""
body = ""
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/plain":
charset = part.get_content_charset() or "utf-8"
try:
body += part.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
else:
charset = msg.get_content_charset() or "utf-8"
try:
body = msg.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
return body.strip()
def _extract_images(self, msg) -> list:
"""提取邮件中的图片附件,保存到临时文件,返回路径列表"""
paths = []
for part in msg.walk():
content_disposition = part.get("Content-Disposition", "")
content_type = part.get_content_type()
is_attachment = "attachment" in content_disposition
is_image_type = content_type.startswith("image/")
filename = part.get_filename()
if filename:
filename = _decode_str(filename)
# 判断是否是图片
if not (is_image_type or (filename and any(
filename.lower().endswith(ext) for ext in IMAGE_EXTS
))):
continue
try:
data = part.get_payload(decode=True)
if not data:
continue
suffix = ".jpg"
if filename:
ext = os.path.splitext(filename)[1].lower()
if ext in IMAGE_EXTS:
suffix = ext
fd, tmp_path = tempfile.mkstemp(suffix=suffix, prefix="email_img_")
with os.fdopen(fd, "wb") as f:
f.write(data)
paths.append(tmp_path)
logger.info(f"[EmailReceiver] 提取图片附件: {filename}{tmp_path}")
except Exception as e:
logger.error(f"[EmailReceiver] 提取附件失败: {e}")
return paths
@staticmethod
def _html(content: str) -> str:
return f"""
<html><body style="font-family:Arial,sans-serif;font-size:14px;color:#333">
{content}
<br><br>
<hr style="border:none;border-top:1px solid #eee">
<p style="color:#999;font-size:12px">修图客服 · 自动回复</p>
</body></html>
"""
# ========== 全局实例(从 .env 读取配置)==========
from dotenv import load_dotenv
load_dotenv()
email_receiver = EmailReceiver(
imap_host="imap.qq.com",
imap_port=993,
username=os.getenv("SMTP_USER", ""),
password=os.getenv("SMTP_PASSWORD", ""),
poll_interval=int(os.getenv("EMAIL_POLL_INTERVAL", "30")),
)

112
legacy/mail/email_sender.py Normal file
View File

@@ -0,0 +1,112 @@
"""邮件发送模块"""
import os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
from email.header import Header
from typing import Optional, List
from dotenv import load_dotenv
load_dotenv()
class EmailSender:
"""邮件发送"""
def __init__(self):
self.smtp_host = os.getenv("SMTP_HOST", "")
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
self.smtp_user = os.getenv("SMTP_USER", "")
self.smtp_password = os.getenv("SMTP_PASSWORD", "")
self.sender_name = os.getenv("SENDER_NAME", "修图客服")
def send(
self,
to_email: str,
subject: str,
body: str,
images: Optional[List[str]] = None
) -> dict:
"""
发送邮件
Args:
to_email: 收件人邮箱
subject: 邮件主题
body: 邮件正文
images: 图片路径列表
Returns:
{"success": bool, "message": str}
"""
if not self.smtp_host or not self.smtp_user:
return {"success": False, "message": "未配置邮件SMTP"}
try:
# 创建邮件
msg = MIMEMultipart('related')
msg['From'] = f"{Header(self.sender_name, 'utf-8').encode()} <{self.smtp_user}>"
msg['To'] = to_email
msg['Subject'] = subject
# 添加正文
msg.attach(MIMEText(body, 'html', 'utf-8'))
# 添加图片
if images:
for idx, img_path in enumerate(images):
if os.path.exists(img_path):
with open(img_path, 'rb') as f:
img = MIMEImage(f.read())
img.add_header('Content-ID', f'<image{idx}>')
msg.attach(img)
# 发送邮件(失败时重试 1 次)
import time
last_err = None
for attempt in range(2):
try:
server = smtplib.SMTP(self.smtp_host, self.smtp_port)
server.starttls()
server.login(self.smtp_user, self.smtp_password)
server.sendmail(self.smtp_user, to_email, msg.as_string())
server.quit()
return {"success": True, "message": "发送成功"}
except Exception as e:
last_err = e
if attempt == 0:
time.sleep(2)
return {"success": False, "message": f"发送失败: {str(last_err)}"}
except Exception as e:
return {"success": False, "message": f"发送失败: {str(e)}"}
def send_completed_work(
self,
to_email: str,
customer_name: str,
image_description: str,
result_images: List[str]
) -> dict:
"""发送完成的作品"""
subject = f"您的修图作品已完成 - {image_description}"
body = f"""
<html>
<body>
<h2>您好 {customer_name},您的修图作品已完成!</h2>
<p>感谢您选择我们的服务。以下是您处理后的图片:</p>
<p><b>处理内容:</b> {image_description}</p>
<br>
<p>如有任何问题,请随时联系我们。</p>
<br>
<p>祝您生活愉快!</p>
</body>
</html>
"""
return self.send(to_email, subject, body, result_images)
# 全局实例
email_sender = EmailSender()

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from core.ai_reply_flow import execute_ai_turn
from core.find_image_flow import handle_find_image_batch_flow
from core.order_flow import handle_order_notification
from core.prompt_flow import build_prompt_bundle
from core.reply_finalize_flow import finalize_ai_reply
from utils.metrics_tracker import emit as metrics_emit
from utils.observability import build_trace_id
logger = logging.getLogger("cs_agent")
async def process_incoming_message(agent: Any, message: Any) -> Any:
"""主消息处理编排:预处理 -> 业务流 -> AI -> 收尾。"""
trace_id = build_trace_id(message.acc_id, message.from_id, message.msg_id, message.msg[:64])
agent._activity_log(
"agent_inbound",
trace_id=trace_id,
acc_id=message.acc_id,
customer_id=message.from_id,
msg=message.msg,
msg_type=message.msg_type,
)
metrics_emit("inbound_msg", customer_id=message.from_id, acc_id=message.acc_id)
state = agent._get_conversation_state(message.from_id)
pre_response = await agent.pre_rule_service.run(message=message, state=state, trace_id=trace_id)
if pre_response is not None:
return pre_response
new_stage = agent._detect_stage(message.msg)
if new_stage != state.stage:
state.stage = new_stage
from datetime import datetime
state.last_update = datetime.now().isoformat()
order_response = await handle_order_notification(agent, message=message, state=state)
if order_response is not None:
return order_response
customer_text, _ = agent._split_customer_text(message.msg)
shop_type = agent._get_shop_type(message.acc_id or "", message.goods_name or "")
flow_response = await handle_find_image_batch_flow(
agent,
message=message,
state=state,
customer_text=customer_text,
shop_type=shop_type,
)
if flow_response is not None:
return flow_response
prompt_bundle = build_prompt_bundle(agent, message=message, state=state)
user_prompt = prompt_bundle.user_prompt
deps = prompt_bundle.deps
history = prompt_bundle.history
agent._log_block("PROMPT->AI 前置提示词", user_prompt)
try:
reply_text = await execute_ai_turn(
agent,
message=message,
state=state,
user_prompt=user_prompt,
deps=deps,
history=history,
)
except Exception as e:
err_str = str(e)
logger.exception("[Agent] AI 调用失败,使用兜底回复: %s", err_str)
agent._activity_log("agent_ai_error", customer_id=message.from_id, acc_id=message.acc_id, error=err_str)
metrics_emit("ai_call_failed", customer_id=message.from_id, acc_id=message.acc_id)
if "AccountOverdueError" in err_str or "overdue" in err_str.lower():
asyncio.create_task(agent._notify_wechat_overdue())
else:
asyncio.create_task(
agent._notify_wechat(
f"⚠️ **AI调用异常**\n"
f"客户:{message.from_id}\n"
f"店铺:{message.acc_id}\n"
f"错误:{err_str[:200]}",
tag="AI异常",
)
)
reply_text = None
else:
metrics_emit("ai_call_success", customer_id=message.from_id, acc_id=message.acc_id)
if not reply_text:
fallback_text = await agent._rewrite_reply_with_ai(
message=message,
state=state,
reply="好嘞,你稍等下,我这边看一下",
scene="fallback_reply",
)
from core.pydantic_ai_agent import AgentResponse
return AgentResponse(reply=fallback_text, should_reply=True, need_transfer=False)
return await finalize_ai_reply(
agent,
message=message,
state=state,
reply_text=reply_text,
)

64
legacy/order_flow.py Normal file
View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Optional
from core.post_ops import record_deal_success
from core.order_helpers import parse_order_info
logger = logging.getLogger("cs_agent")
if TYPE_CHECKING:
from core.pydantic_ai_agent import AgentResponse, ConversationState, CustomerMessage, CustomerServiceAgent
async def handle_order_notification(
agent: "CustomerServiceAgent",
*,
message: "CustomerMessage",
state: "ConversationState",
) -> Optional["AgentResponse"]:
"""Handle system order notifications before normal AI dialogue."""
from core.pydantic_ai_agent import AgentResponse
if "系统订单信息" not in message.msg and "订单状态" not in message.msg:
return None
_, order_block = agent._split_customer_text(message.msg)
customer_text, _ = agent._split_customer_text(message.msg)
order = parse_order_info(order_block or message.msg)
pay_status = order.get("pay_status", "")
order_status = order.get("order_status", "")
paid_keywords = ["等待发货", "已付款", "付款成功", "买家已付款"]
is_paid = any(kw in pay_status or kw in order_status for kw in paid_keywords)
if is_paid:
asyncio.create_task(agent._check_order_amount(message.from_id, order, message.acc_id))
asyncio.create_task(
record_deal_success(
customer_id=message.from_id,
customer_name=message.from_name,
acc_id=message.acc_id,
platform=message.acc_type,
order=order,
state=state,
)
)
try:
from core.workflow import workflow
asyncio.create_task(
workflow.trigger_processing_on_payment(
customer_id=message.from_id,
acc_id=message.acc_id,
acc_type=message.acc_type,
)
)
except Exception as e:
logger.exception("[Agent] 触发作图失败: %s", e)
elif not customer_text:
logger.info("[Agent] 订单通知静默(%s),跳过回复", pay_status or order_status)
return AgentResponse(reply="", should_reply=False, need_transfer=False)
return None

171
legacy/post_ops.py Normal file
View File

@@ -0,0 +1,171 @@
from __future__ import annotations
import logging
import re
from typing import Any
from utils.metrics_tracker import emit as metrics_emit
CASE_LIBRARY_LINK = "https://www.yuque.com/zuowei-dfvpq/kge0in/mynala0g35b8cec5"
logger = logging.getLogger("cs_agent")
def detect_price(reply: str, state: Any) -> None:
numbers = re.findall(r"(\d+)[元]", reply or "")
if not numbers:
return
price = round(int(numbers[0]) / 5) * 5
state.last_price = price
metrics_emit("quote_generated", customer_id=state.customer_id, price=price)
try:
from db.customer_db import db
db.update_last_price(state.customer_id, price)
except Exception:
pass
def detect_discount(message: str, state: Any) -> None:
text = message or ""
if any(kw in text for kw in ["", "便宜", "太贵", "有点贵"]):
state.discount_count += 1
if state.last_price:
try:
from db.customer_db import db
db.record_discount(state.customer_id, state.last_price)
except Exception:
pass
m = re.search(r"(\d+)\s*元|\b(\d+)\s*块", text)
offer = None
if m:
offer = int(m.group(1) or m.group(2))
if offer:
try:
from config.config import MIN_PRICE_FLOOR
if offer < MIN_PRICE_FLOOR:
state.last_price = state.last_price or 0
except Exception:
pass
def negotiation_strategy_reply(customer_text: str, state: Any) -> str:
text = (customer_text or "").strip()
if not text:
return ""
if any(k in text for k in ["先发效果图", "先看效果", "不放心", "没法确认"]):
return (
f"小妹整理了一些案例图,亲点这个链接就能看到啦({CASE_LIBRARY_LINK})。"
"有什么想要的效果随时告诉我哈,不满意我们这边包退。"
)
if "有点贵" in text or "就是贵" in text:
base = state.last_price if isinstance(state.last_price, int) and state.last_price > 0 else 25
two_pack = max(10, round(((base * 2) - 5) / 5) * 5)
return f"理解你这边的预算,我给你个实在点的:两张一起按 {two_pack} 元做,行不行?"
if any(k in text for k in ["优惠点", "便宜点", "少点", "打折"]):
return "可以的你这边数量上来我就好给价3张以上我给你打包价。"
return ""
async def record_deal_success(
*,
customer_id: str,
customer_name: str,
acc_id: str,
platform: str,
order: dict,
state: Any,
) -> None:
try:
from db.deal_outcome_db import record_deal
order_id = order.get("order_id", "")
raw_amount = order.get("amount", "")
m = re.search(r"[\d.]+", str(raw_amount))
amount = float(m.group()) if m else 0
reason = "让价后成交" if (state.discount_count or 0) > 0 else "直接成交"
record_deal(
customer_id=customer_id,
outcome="成交",
reason=reason,
customer_name=customer_name or "",
acc_id=acc_id or "",
platform=platform or "",
order_id=order_id,
amount=amount,
discount_given=(state.discount_count or 0) > 0,
)
try:
from db.customer_db import db
if order_id:
db.add_order(customer_id, order_id, amount)
db.clear_quote_no_convert(customer_id)
except Exception:
pass
logger.info("[Agent] 成交记录: %s %s %s", customer_id, reason, amount)
except Exception as e:
logger.exception("[Agent] 成交记录失败: %s", e)
async def record_deal_fail(
*,
customer_id: str,
customer_name: str,
acc_id: str,
platform: str,
reason: str,
) -> None:
try:
from db.deal_outcome_db import record_deal
from db.customer_db import db
record_deal(
customer_id=customer_id,
outcome="未成交",
reason=reason,
customer_name=customer_name or "",
acc_id=acc_id or "",
platform=platform or "",
)
db.mark_quote_no_convert(customer_id)
logger.info("[Agent] 未成交记录: %s %s", customer_id, reason)
except Exception as e:
logger.exception("[Agent] 未成交记录失败: %s", e)
async def auto_tag(message: Any, state: Any) -> None:
try:
from db.customer_db import db
cid = message.from_id
msg = (message.msg or "").lower()
if any(kw in msg for kw in ["还有", "多张", "好几张", "一批", "下次还"]):
db.set_bulk_potential(cid, "")
db.add_upsell_opportunity(cid, "批量打包")
if any(kw in msg for kw in ["psd", "分层", "源文件"]):
db.add_upsell_opportunity(cid, "分层PSD")
db.update_preferred_format(cid, "psd")
if "jpg" in msg or "jpeg" in msg:
db.update_preferred_format(cid, "jpg")
if "png" in msg:
db.update_preferred_format(cid, "png")
if any(kw in msg for kw in ["分辨率", "dpi", "尺寸", "大图", "印刷"]):
db.update_preferred_size(cid, message.msg[:30])
if any(kw in msg for kw in ["拍了", "下单了", "好的", ""]) and state.last_price:
db.update_decision_speed(cid, "")
type_keywords = {
"印花": ["印花", "花纹", "图案", "面料", "布料", "纺织"],
"logo": ["logo", "标志", "品牌", "商标"],
"人物": ["人物", "人像", "照片", "", "头像"],
"产品": ["产品", "商品", "包装", "实物"],
"老照片": ["老照片", "旧照片", "发黄", "修复"],
}
for img_type, keywords in type_keywords.items():
if any(kw in message.msg for kw in keywords):
db.add_image_type(cid, img_type)
break
db.auto_compute_tags(cid)
except Exception:
pass

191
legacy/prompt_builder.py Normal file
View File

@@ -0,0 +1,191 @@
from __future__ import annotations
import re
from typing import Any, Callable
def split_customer_text(msg: str) -> tuple[str, str]:
"""
把混合消息拆分为(客户真实文字, 系统订单块)。
平台有时把客户文字和系统订单通知拼在同一条消息里。
"""
order_marker = re.search(r"\[系统订单信息\]|\[系统通知\]", msg or "")
if order_marker:
customer_text = (msg or "")[: order_marker.start()].strip()
order_block = (msg or "")[order_marker.start() :].strip()
else:
customer_text = (msg or "").strip()
order_block = ""
return customer_text, order_block
def build_prompt(
*,
message: Any,
state: Any,
extract_image_url: Callable[[str], str],
shop_type_resolver: Callable[[str, str], str],
shop_persona_resolver: Callable[[str, str], str],
parse_order_info: Callable[[str], dict[str, str]],
build_order_instruction: Callable[[str, str], str],
) -> str:
"""构建提示词。"""
msg_content = message.msg
stage_info = f"【当前阶段】{state.stage}"
customer_text, order_block = split_customer_text(msg_content)
has_order = bool(order_block)
if has_order:
order = parse_order_info(order_block)
if order.get("order_id"):
state.last_order_id = order["order_id"]
stage_info += f"\n【订单号】{order['order_id']}"
if order.get("order_status"):
state.order_status = order["order_status"]
stage_info += f"\n【订单状态】{order['order_status']}"
if order.get("pay_status"):
stage_info += f"\n【支付状态】{order['pay_status']}"
if order.get("amount"):
stage_info += f"\n【订单金额】{order['amount']}"
if order.get("quantity"):
stage_info += f"\n【数量】{order['quantity']}"
if order.get("order_time"):
stage_info += f"\n【下单时间】{order['order_time']}"
if order.get("buyer_note"):
stage_info += f"\n【买家备注】{order['buyer_note']}"
if state.discount_count > 0:
stage_info += f"\n【客户压价次数】{state.discount_count}"
shop_type = shop_type_resolver(message.acc_id or "", message.goods_name or "")
shop_persona = shop_persona_resolver(message.acc_id or "", message.goods_name or "")
shop_hint = ""
try:
from config.config import CONFIG_DIR
import json
cfg_path = CONFIG_DIR / "shop_prompts.json"
if cfg_path.exists():
with open(cfg_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
hints = cfg.get("type_hints", {})
shop_hint = hints.get(shop_type, "")
if not shop_hint and message.acc_id:
sh = cfg.get("shops", {}).get(message.acc_id, {})
shop_hint = sh.get("hint", "")
except Exception:
pass
prompt = f"""收到新消息:
{stage_info}
发送者: {message.from_name} ({message.from_id})
"""
if message.goods_name:
prompt += f"商品名称: {message.goods_name}\n"
if shop_hint:
prompt += f"\n{shop_hint}\n"
if shop_persona:
prompt += f"\n【店铺人设】{shop_persona}\n"
order_paid = False
order_unpaid = False
if has_order:
order = parse_order_info(order_block)
paid_kws = ["等待发货", "已付款", "付款成功", "买家已付款"]
unpaid_kws = ["等待买家付款", "待付款", "未付款"]
ps = order.get("pay_status", "")
os_ = order.get("order_status", "")
if any(kw in ps or kw in os_ for kw in paid_kws):
order_paid = True
elif any(kw in ps or kw in os_ for kw in unpaid_kws):
order_unpaid = True
progress_keywords = [
"安排了吗",
"安排好了吗",
"好了吗",
"做了吗",
"做好了吗",
"弄好了吗",
"好了没",
"做了没",
"什么时候好",
"多久好",
"进度",
"催一下",
"快点",
"什么时候能好",
"做完了吗",
]
if customer_text:
prompt += f"\n客户说:{customer_text}\n"
image_url = extract_image_url(customer_text)
price_keywords = ["多少钱", "多少", "价格", "几块", "怎么收费", "报个价"]
size_keywords = [
"尺寸",
"比例",
"",
"",
"",
"厘米",
"mm",
"cm",
"横版",
"竖版",
"2米",
"3米",
"改成",
"做成",
]
has_size_change = any(kw in customer_text.lower() for kw in [k.lower() for k in size_keywords])
if shop_type == "gemini_api":
prompt += "\n【Gemini API 店铺】客户问账号/pro/续费/套餐等,按 API 客服自然回复,不要求发图。"
elif image_url:
prompt += "\n客户在继续发图阶段:先确认“已收图”,并引导客户把图和要求一次发完;等客户明确“发完了/统一报价”后再统一报价。"
elif any(kw in customer_text for kw in price_keywords):
last_url = extract_image_url(msg_content)
if last_url:
prompt += "\n客户在询问价格:若客户已确认发完,则给总报价;若还在发图,先引导发完后统一报价。"
else:
prompt += "\n客户在询问价格但未发图:先简短承接(如“在看呢/收到”),不要机械连发;再自然引导对方发图。"
if has_size_change:
prompt += (
"\n⚠️ 尺寸改动场景:优先判断图片主体是否会被拉伸变形,"
"不是只看整张图宽高比。若会变形,要先提示“需要补图/扩边”,再给报价。"
)
elif has_size_change:
prompt += (
"\n客户在改尺寸/改比例:先按主体比例判断是否会变形,"
"不是只看整图比例。若目标尺寸会拉伸主体,先明确说明要补图(如上下补图/扩边)再报价。"
)
elif any(kw in customer_text for kw in progress_keywords):
if order_unpaid:
prompt += "\n⚠️【订单未付款】客户问安排进度,但订单还未付款。自然告知拍下付款后马上安排即可。"
elif order_paid:
prompt += "\n客户催单,订单已付款,自然回复在做了/快了之类。"
else:
prompt += "\n客户催单,查询当前处理状态后自然回复。"
elif any(kw in customer_text for kw in ["", "有点贵", "太贵", "算了", "便宜点", "少点", "打折", "贵哦"]):
prompt += "\n⚠️【客户嫌贵】客户已表达价格顾虑或要放弃。禁止追问「什么问题」「说清楚点」。\n→ 第一步:直接让价一次(如原价 20→15话术自然。\n→ 第二步:如果让价后客户还是嫌贵,引导客户自己去注册星绘平台:\n - 每个话术都必须带网址 xinhui.cloud\n - 强调「自己去弄」「自己操作」「自己下单」\n - 示例:「嫌贵就去 xinhui.cloud 注册个号自己有余额可以弄」「xinhui.cloud 去注册下,新用户送体验金,自己弄便宜」\n - 语气直接,让客户自己去操作」"
elif any(kw in customer_text for kw in ["擦边", "黄色", "色情", "大尺度", "性感图", "露点", "半裸"]):
prompt += "\n⚠️【拒绝】客户询问擦边/黄色/敏感内容。直接拒绝,不接单,不说「发图来看看」。自然回复如:这类不做/不接/做不了。"
else:
prompt += "\n根据客户说的内容自然回应,像真人聊天,不要套模板。"
if has_order:
order = parse_order_info(order_block)
order_instruction = build_order_instruction(order.get("pay_status", ""), order.get("order_status", ""))
if customer_text:
if not order_unpaid:
prompt += f"\n\n【背景参考-订单通知】{order_instruction}"
else:
prompt += f"\n\n{order_instruction}"
if not customer_text and not has_order:
prompt += f"\n消息内容: {msg_content}\n请按工作流规则回复。"
return prompt

50
legacy/prompt_flow.py Normal file
View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, List
if TYPE_CHECKING:
from core.pydantic_ai_agent import AgentDeps, ConversationState, CustomerMessage, CustomerServiceAgent
@dataclass
class PromptBundle:
user_prompt: str
deps: "AgentDeps"
history: List
def build_prompt_bundle(
agent: "CustomerServiceAgent",
*,
message: "CustomerMessage",
state: "ConversationState",
) -> PromptBundle:
from core.pydantic_ai_agent import AgentDeps
user_prompt = agent._build_prompt(message, state)
profile_context = agent._get_customer_profile_context(message.from_id)
if profile_context:
user_prompt = profile_context + "\n\n" + user_prompt
refusal_hint = agent._get_refusal_context_hint(message.from_id, message.msg, profile_context or "")
if refusal_hint:
user_prompt = refusal_hint + "\n\n" + user_prompt
conv_context = agent._get_conversation_context(message.from_id, acc_id=message.acc_id or "")
if conv_context:
user_prompt = conv_context + user_prompt
intent_hint = agent._get_intent_emotion_hint(message.msg)
if intent_hint:
user_prompt = intent_hint + "\n\n" + user_prompt
deps = AgentDeps(
msg_id=message.msg_id,
acc_id=message.acc_id,
from_id=message.from_id,
platform=message.acc_type,
)
history = agent.message_histories.get(message.from_id, [])
return PromptBundle(user_prompt=user_prompt, deps=deps, history=history)

1066
legacy/pydantic_ai_agent.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import TYPE_CHECKING
from utils.metrics_tracker import emit as metrics_emit
from core.post_ops import auto_tag, detect_discount, detect_price, record_deal_fail
logger = logging.getLogger("cs_agent")
if TYPE_CHECKING:
from core.pydantic_ai_agent import AgentResponse, ConversationState, CustomerMessage, CustomerServiceAgent
async def finalize_ai_reply(
agent: "CustomerServiceAgent",
*,
message: "CustomerMessage",
state: "ConversationState",
reply_text: str,
) -> "AgentResponse":
from core.pydantic_ai_agent import AgentResponse, TRANSFER_MESSAGE
try:
from utils.content_filter import should_block_reply
blocked, fallback = should_block_reply(reply_text)
if blocked:
logger.warning("[Agent] 敏感词拦截,使用兜底回复")
reply_text = fallback or "好的,您稍等,我帮您确认一下"
except Exception:
pass
try:
from utils.api_cost_tracker import record
record("openai_chat", count=1)
except Exception:
pass
detect_price(reply_text, state)
detect_discount(message.msg, state)
asyncio.create_task(auto_tag(message, state))
need_transfer = False
transfer_msg = ""
transfer_keywords = ["TRANSFER_REQUESTED", "[转移会话]", "转移会话", "转人工", "转接"]
if reply_text and any(kw in reply_text for kw in transfer_keywords):
need_transfer = True
transfer_msg = TRANSFER_MESSAGE
metrics_emit("transfer_to_human", customer_id=message.from_id, acc_id=message.acc_id)
evo_hit = agent._evolution_enabled_for_customer(message.from_id)
if evo_hit and agent._is_service_risk_inquiry(message.msg):
if agent._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 agent._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, _ = agent._split_customer_text(message.msg)
no_convert_keywords = ["算了", "不要了", "不做了", "下次再说", "先不弄了"]
if customer_text and state.last_price and state.last_price > 0:
if any(kw in customer_text for kw in no_convert_keywords):
reason = "嫌贵放弃" if any(k in customer_text for k in ["", "贵了", "便宜"]) else "放弃"
asyncio.create_task(
record_deal_fail(
customer_id=message.from_id,
customer_name=message.from_name,
acc_id=message.acc_id,
platform=message.acc_type,
reason=reason,
)
)
should_reply = bool(reply_text and reply_text.strip()) and not need_transfer
if evo_hit and need_transfer and agent._evolution_has_proposal("tone-empathy-pack"):
should_reply = True
if should_reply:
reply_text = await agent._rewrite_reply_with_ai(
message=message,
state=state,
reply=reply_text,
scene="final_reply",
)
if should_reply:
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", reply_text)
else:
logger.info("[REPLY->CUSTOMER] <静默/不发送>")
agent._activity_log(
"agent_outbound_decision",
customer_id=message.from_id,
should_reply=should_reply,
need_transfer=need_transfer,
reply=reply_text or "",
transfer_msg=transfer_msg,
)
return AgentResponse(
reply=reply_text or "",
should_reply=should_reply,
need_transfer=need_transfer,
transfer_msg=transfer_msg,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import re
def is_political_inquiry(text: str) -> bool:
"""文本前置风控:政治人物/政治事件/政治图片相关询问一律拒绝。"""
s = (text or "").strip().lower()
if not s:
return False
kw = (
"政治",
"涉政",
"党政",
"政治人物",
"政治事件",
"政治图片",
"政治海报",
"政治宣传",
"领导人",
"伟人",
"元帅",
"将军",
"红色人物",
"党史",
"天安门",
"人民大会堂",
"中南海",
"习近平",
"毛泽东",
"邓小平",
"江泽民",
"胡锦涛",
"李克强",
"周恩来",
"特朗普",
"拜登",
"普京",
"泽连斯基",
"trump",
"biden",
"putin",
"zelensky",
"xi jinping",
)
if any(k in s for k in kw):
return True
return bool(re.search(r"(元帅|将军|领导人|政治人物|政治事件).*(照片|图片|头像|原图)?", s))
def is_map_inquiry(text: str) -> bool:
"""地图类需求一律拒绝(按业务规则)。"""
s = (text or "").strip().lower()
if not s:
return False
kw = (
"地图",
"地形图",
"行政区划图",
"世界地图",
"中国地图",
"卫星地图",
"导航图",
"航海图",
"作战地图",
"军事地图",
"map",
"topographic map",
"satellite map",
)
return any(k in s for k in kw)

3
legacy/rules/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .engine import Rule, RuleContext, RuleEngine, RuleResult
__all__ = ["Rule", "RuleContext", "RuleEngine", "RuleResult"]

59
legacy/rules/engine.py Normal file
View File

@@ -0,0 +1,59 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, List, Optional
@dataclass
class RuleContext:
data: Dict[str, Any] = field(default_factory=dict)
def get(self, key: str, default: Any = None) -> Any:
return self.data.get(key, default)
def set(self, key: str, value: Any) -> None:
self.data[key] = value
@dataclass
class RuleResult:
matched: bool = False
stop: bool = False
action: str = ""
payload: Dict[str, Any] = field(default_factory=dict)
Predicate = Callable[[RuleContext], Awaitable[bool]]
Action = Callable[[RuleContext], Awaitable[RuleResult]]
@dataclass
class Rule:
name: str
priority: int
predicate: Predicate
action: Action
class RuleEngine:
"""Priority-ordered async rule chain."""
def __init__(self, rules: Optional[List[Rule]] = None):
self._rules: List[Rule] = sorted(rules or [], key=lambda x: x.priority)
def add_rule(self, rule: Rule) -> None:
self._rules.append(rule)
self._rules.sort(key=lambda x: x.priority)
async def run(self, ctx: RuleContext) -> RuleResult:
for rule in self._rules:
if not await rule.predicate(ctx):
continue
result = await rule.action(ctx)
if not result.matched:
result.matched = True
if not result.action:
result.action = rule.name
if result.stop:
return result
return RuleResult(matched=False, stop=False, action="no_match")

View File

@@ -0,0 +1,362 @@
"""
聊天记录查看器
用法:
python scripts/chat_log_viewer.py # 列出所有客户
python scripts/chat_log_viewer.py <客户ID> # 查看某客户全部对话
python scripts/chat_log_viewer.py -s <关键词> # 全局搜索
python scripts/chat_log_viewer.py -t <客户ID> # 只看今天
python scripts/chat_log_viewer.py -l # 实时监听最新消息10条/刷新)
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import time
import os
from datetime import datetime
# 强制 UTF-8 输出Windows 终端需要)
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
from db import chat_log_db as db
# ========== ANSI 颜色 ==========
try:
import ctypes
ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-11), 7)
except Exception:
pass
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
CYAN = "\033[36m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA= "\033[35m"
RED = "\033[31m"
WHITE = "\033[97m"
BG_DARK= "\033[48;5;236m"
def clear():
os.system("cls" if os.name == "nt" else "clear")
def header(text: str):
width = 60
print(f"\n{BOLD}{CYAN}{'' * width}{RESET}")
print(f"{BOLD}{CYAN} {text}{RESET}")
print(f"{BOLD}{CYAN}{'' * width}{RESET}\n")
def fmt_time(ts: str) -> str:
"""缩短时间戳显示"""
today = datetime.now().strftime("%Y-%m-%d")
if ts.startswith(today):
return ts[11:16] # 只显示 HH:MM
return ts[:16]
def platform_badge(platform: str) -> str:
badges = {
"AliWorkbench": f"{YELLOW}[淘宝]{RESET}",
"taobao": f"{YELLOW}[淘宝]{RESET}",
"pinduoduo": f"{RED}[拼多多]{RESET}",
"jd": f"{RED}[京东]{RESET}",
"wechat": f"{GREEN}[微信]{RESET}",
"email": f"{BLUE}[邮件]{RESET}",
}
return badges.get(platform, f"{DIM}[{platform}]{RESET}" if platform else "")
def print_bubble(direction: str, message: str, ts: str):
"""打印聊天气泡"""
time_str = fmt_time(ts)
lines = []
if "#*#" in (message or ""):
parts = [p.strip() for p in message.split("#*#") if p.strip()]
if parts:
lines = parts
if not lines:
lines = (message or "").split("\n")
if direction == "in": # 客户来消息 → 左对齐
print(f" {DIM}{time_str}{RESET} {WHITE}买家{RESET}")
for line in lines:
print(f" {BG_DARK} {line} {RESET}")
else: # 客服回复 → 右对齐(缩进)
print(f" {DIM}{time_str}{RESET} {GREEN}客服{RESET}")
for line in lines:
print(f" {GREEN}> {line}{RESET}")
print()
def cmd_list_customers():
"""列出所有客户"""
customers = db.get_customers(limit=100)
if not customers:
print(f"{YELLOW}暂无聊天记录。{RESET}")
return
header(f"客户列表 共 {len(customers)}")
print(f" {'#':<4} {'客户ID':<24} {'姓名':<12} {'平台':<10} {'消息数':>6} {'最后活跃'}")
print(f" {''*4} {''*24} {''*12} {''*10} {''*6} {''*16}")
for i, c in enumerate(customers, 1):
badge = platform_badge(c.get("platform", ""))
name = (c.get("customer_name") or "")[:10]
cid = c["customer_id"]
total = c["total_msgs"]
last = c.get("last_time", "")[:16]
print(f" {i:<4} {CYAN}{cid:<24}{RESET} {name:<12} {badge:<18} {total:>6}{DIM}{last}{RESET}")
print(f"\n{DIM}用法python chat_log_viewer.py <客户ID>{RESET}\n")
def cmd_show_conversation(customer_id: str, today_only: bool = False):
"""显示某客户对话"""
if today_only:
messages = db.get_conversation_today(customer_id)
title = f"今日对话 {customer_id}"
else:
messages = db.get_conversation(customer_id, limit=300)
title = f"对话记录 {customer_id}"
if not messages:
print(f"{YELLOW}该客户暂无记录:{customer_id}{RESET}")
return
header(f"{title} {len(messages)} 条)")
last_date = ""
for m in messages:
ts = m.get("timestamp", "")
date = ts[:10]
if date != last_date:
print(f" {DIM}{''*20} {date} {''*20}{RESET}")
last_date = date
print_bubble(m["direction"], m["message"], ts)
print(f"{DIM} ── 以上共 {len(messages)} 条 ──{RESET}\n")
def cmd_search(keyword: str, customer_id: str = None):
"""搜索关键词"""
results = db.search_messages(keyword, customer_id=customer_id, limit=50)
title = f"搜索 [{keyword}]"
if customer_id:
title += f" 客户:{customer_id}"
header(f"{title}{len(results)}")
if not results:
print(f"{YELLOW}未找到包含 [{keyword}] 的消息。{RESET}")
return
last_cid = ""
for r in results:
cid = r["customer_id"]
if cid != last_cid:
print(f" {CYAN}{cid}{RESET} {r.get('customer_name','')}")
last_cid = cid
direction = "买家" if r["direction"] == "in" else "客服"
color = WHITE if r["direction"] == "in" else GREEN
# 高亮关键词
msg = r["message"].replace(keyword, f"{RED}{BOLD}{keyword}{RESET}{color}")
print(f" {DIM}{r['timestamp'][:16]}{RESET} {color}[{direction}] {msg}{RESET}")
print()
def cmd_live(refresh: int = 3):
"""实时监听最新消息"""
header("实时消息监听 Ctrl+C 退出")
seen_ids = set()
try:
while True:
rows = db.get_latest_messages(20)
new_rows = [r for r in rows if r["id"] not in seen_ids]
if new_rows:
new_rows.reverse()
for r in new_rows:
seen_ids.add(r["id"])
cid = r["customer_id"]
name = r.get("customer_name") or ""
label = f"{CYAN}{cid}{RESET}" + (f" {DIM}({name}){RESET}" if name else "")
print(f"\n{label}")
print_bubble(r["direction"], r["message"], r["timestamp"])
else:
print(f"\r {DIM}等待新消息... {datetime.now().strftime('%H:%M:%S')}{RESET}", end="", flush=True)
time.sleep(refresh)
except KeyboardInterrupt:
print(f"\n{DIM}已退出监听。{RESET}")
def _extract_urls(msg: str) -> list:
if not msg:
return []
parts = [p.strip() for p in msg.split("#*#") if p.strip()]
urls = []
for p in parts:
if p.startswith("http://") or p.startswith("https://"):
urls.append(p)
if not urls and ("http://" in msg or "https://" in msg):
import re as _re
tokens = _re.findall(r'(https?://\S+)', msg)
for t in tokens:
tl = t.lower()
if any(ext in tl for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]):
urls.append(t)
return urls
def _msg_refers_images(msg: str) -> bool:
if not msg:
return False
refs = ("图一", "图二", "第一张", "第二张", "这张", "那张", "上面那张", "下面那张", "刚才那张", "上一张", "下一张")
return any(r in msg for r in refs)
def _parse_ts(ts: str):
try:
from datetime import datetime as _dt
return _dt.fromisoformat(ts.replace("Z",""))
except Exception:
return None
def analyze_conversation(messages: list) -> list:
issues = []
n = len(messages)
for i, m in enumerate(messages):
msg = m.get("message") or ""
dir = m.get("direction")
ts = _parse_ts(m.get("timestamp",""))
# 图片后未及时回复
if dir == "in" and _extract_urls(msg):
replied = False
delay_ok = True
for j in range(i+1, min(i+6, n)):
mj = messages[j]
if mj.get("direction") == "out":
replied = True
tsj = _parse_ts(mj.get("timestamp",""))
if ts and tsj and (tsj - ts).total_seconds() > 180:
delay_ok = False
break
if not replied:
issues.append("图片消息后未回复")
elif not delay_ok:
issues.append("图片消息后回复延迟超过3分钟")
# 引用图片但找不到历史图片
if dir == "in" and _msg_refers_images(msg):
has_prev_img = False
for k in range(max(0, i-10), i):
if messages[k].get("direction") == "in" and _extract_urls(messages[k].get("message","")):
has_prev_img = True
break
if not has_prev_img:
issues.append("引用图片但历史中未找到对应图片")
# 订单后未确认/引导
if dir == "in" and ("买家已付款" in msg or "[系统订单信息]" in msg):
confirmed = False
for j in range(i+1, min(i+6, n)):
if messages[j].get("direction") == "out":
confirmed = True
break
if not confirmed:
issues.append("订单消息后未进行确认或引导付款")
# 合成需求未报价格
if dir == "in" and any(k in msg for k in ("抓到", "放到", "合成", "融合", "嵌到", "替换", "P到", "抠出来放到")):
priced = False
for j in range(i+1, min(i+6, n)):
mj = messages[j]
if mj.get("direction") == "out":
rm = mj.get("message","")
if "" in rm:
priced = True
break
if not priced:
issues.append("客户提出合成需求但未给出价格")
# 去重
dedup = []
seen = set()
for it in issues:
if it not in seen:
seen.add(it)
dedup.append(it)
return dedup
def cmd_analyze_all():
customers = db.get_customers(limit=200)
if not customers:
print(f"{YELLOW}暂无聊天记录。{RESET}")
return
header("聊天记录上下文分析")
total_issues = 0
for c in customers:
cid = c["customer_id"]
msgs = db.get_conversation(cid, limit=500)
issues = analyze_conversation(msgs)
if issues:
total_issues += len(issues)
print(f"{CYAN}{cid}{RESET} {c.get('customer_name','')}")
for s in issues:
print(f" - {RED}{s}{RESET}")
print()
if total_issues == 0:
print(f"{GREEN}未发现明显异常。{RESET}")
else:
print(f"{YELLOW}共发现 {total_issues} 项问题(按客户汇总)。{RESET}")
def print_help():
print(f"""
{BOLD}聊天记录查看器{RESET}
{CYAN}python chat_log_viewer.py{RESET} 列出所有客户
{CYAN}python chat_log_viewer.py <客户ID>{RESET} 查看该客户全部对话
{CYAN}python chat_log_viewer.py -t <客户ID>{RESET} 只看今天的对话
{CYAN}python chat_log_viewer.py -s <关键词>{RESET} 全局搜索
{CYAN}python chat_log_viewer.py -l{RESET} 实时监听新消息
{CYAN}python chat_log_viewer.py -a{RESET} 分析上下文,输出异常项
{CYAN}python chat_log_viewer.py -h{RESET} 显示帮助
""")
if __name__ == "__main__":
args = sys.argv[1:]
if not args:
cmd_list_customers()
elif args[0] in ("-h", "--help"):
print_help()
elif args[0] == "-s":
keyword = args[1] if len(args) > 1 else ""
if not keyword:
print(f"{RED}请提供搜索关键词python chat_log_viewer.py -s <关键词>{RESET}")
else:
cmd_search(keyword)
elif args[0] == "-t":
cid = args[1] if len(args) > 1 else ""
if not cid:
print(f"{RED}请提供客户IDpython chat_log_viewer.py -t <客户ID>{RESET}")
else:
cmd_show_conversation(cid, today_only=True)
elif args[0] == "-l":
cmd_live()
elif args[0] == "-a":
cmd_analyze_all()
else:
cmd_show_conversation(args[0])

520
legacy/scripts/chat_ui.py Normal file
View File

@@ -0,0 +1,520 @@
# -*- coding: utf-8 -*-
"""
聊天记录 Web UI
运行: python scripts/chat_ui.py
访问: http://localhost:5678
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from flask import Flask, jsonify, render_template_string, request
import asyncio
from core.pydantic_ai_agent import CustomerServiceAgent, AgentDeps
from db import chat_log_db as db
app = Flask(__name__)
pricing_agent = None
try:
pricing_agent = CustomerServiceAgent()
except Exception as e:
print(f"[ChatUI] 初始化报价Agent失败: {e}")
HTML = r"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>聊天记录</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
/* ── 顶栏 ── */
.topbar {
background: #16213e; border-bottom: 1px solid #0f3460;
padding: 12px 20px; display: flex; align-items: center; gap: 16px; flex-shrink: 0;
}
.topbar h1 { font-size: 16px; color: #4cc9f0; font-weight: 600; letter-spacing: 1px; }
.search-box {
flex: 1; max-width: 320px;
background: #0f3460; border: 1px solid #1a5276;
border-radius: 20px; padding: 6px 14px;
color: #e0e0e0; font-size: 13px; outline: none;
}
.search-box::placeholder { color: #6b7a99; }
.live-badge {
margin-left: auto; font-size: 11px; background: #0d7377;
color: #14ffec; padding: 3px 10px; border-radius: 10px;
}
/* ── 主体 ── */
.main { display: flex; flex: 1; overflow: hidden; }
/* ── 左侧客户列表 ── */
.sidebar {
width: 280px; background: #16213e;
border-right: 1px solid #0f3460;
display: flex; flex-direction: column; flex-shrink: 0;
}
.sidebar-header {
padding: 10px 14px; font-size: 12px; color: #6b7a99;
border-bottom: 1px solid #0f3460; flex-shrink: 0;
display: flex; justify-content: space-between;
}
.customer-list { overflow-y: auto; flex: 1; }
.customer-item {
padding: 12px 14px; cursor: pointer; border-bottom: 1px solid #0f3460;
transition: background .15s; position: relative;
}
.customer-item:hover { background: #1e3a5f; }
.customer-item.active { background: #0f3460; border-left: 3px solid #4cc9f0; }
.customer-item .name { font-size: 13px; font-weight: 500; color: #cce; }
.customer-item .cid { font-size: 11px; color: #6b7a99; margin-top: 2px; }
.customer-item .meta { font-size: 11px; color: #8899aa; margin-top: 4px;
display: flex; justify-content: space-between; }
.badge-plat {
font-size: 10px; padding: 1px 6px; border-radius: 8px;
background: #1a3a5c; color: #4cc9f0;
}
.badge-plat.ali { background: #3d1a00; color: #ff9f43; }
.badge-plat.email { background: #0a2e1a; color: #55efc4; }
/* ── 右侧对话区 ── */
.chat-panel {
flex: 1; display: flex; flex-direction: column; overflow: hidden;
}
.chat-header {
padding: 12px 20px; background: #16213e;
border-bottom: 1px solid #0f3460; flex-shrink: 0;
display: flex; align-items: center; gap: 10px;
}
.chat-header .cname { font-size: 15px; font-weight: 600; color: #e0e0e0; }
.chat-header .cid { font-size: 12px; color: #6b7a99; }
.chat-header .stats { margin-left: auto; font-size: 12px; color: #6b7a99; }
.chat-messages {
flex: 1; overflow-y: auto; padding: 20px;
display: flex; flex-direction: column; gap: 12px;
}
.day-divider {
text-align: center; font-size: 11px; color: #6b7a99;
position: relative; margin: 8px 0;
}
.day-divider::before, .day-divider::after {
content: ""; position: absolute; top: 50%;
width: 38%; height: 1px; background: #0f3460;
}
.day-divider::before { left: 0; }
.day-divider::after { right: 0; }
/* 消息气泡 */
.msg-row { display: flex; align-items: flex-end; gap: 8px; max-width: 72%; }
.msg-row.in { align-self: flex-start; }
.msg-row.out { align-self: flex-end; flex-direction: row-reverse; }
.avatar {
width: 34px; height: 34px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600; flex-shrink: 0;
}
.avatar.buyer { background: #2d4a7a; color: #90caf9; }
.avatar.seller { background: #1a6644; color: #a8e6cf; }
.bubble-wrap { display: flex; flex-direction: column; gap: 3px; }
.msg-row.out .bubble-wrap { align-items: flex-end; }
.bubble {
padding: 9px 13px; border-radius: 16px;
font-size: 13px; line-height: 1.55; word-break: break-word;
max-width: 480px;
}
.bubble.in { background: #1e3a5f; color: #dce8f8; border-bottom-left-radius: 4px; }
.bubble.out { background: #1a6644; color: #d4f5e7; border-bottom-right-radius: 4px; }
.bubble img { max-width: 200px; border-radius: 8px; display: block; margin-top: 4px; }
.msg-time { font-size: 10px; color: #6b7a99; padding: 0 4px; }
/* 空状态 */
.empty-state {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; color: #6b7a99; gap: 10px;
}
.empty-state .icon { font-size: 48px; opacity: .3; }
.empty-state p { font-size: 14px; }
/* 搜索结果覆盖层 */
#search-overlay {
display: none; position: absolute; top: 52px; left: 0; right: 0; bottom: 0;
background: #1a1a2e; z-index: 10; overflow-y: auto; padding: 16px 20px;
}
.search-hit {
padding: 10px 14px; margin-bottom: 8px;
background: #16213e; border-radius: 10px; cursor: pointer;
border-left: 3px solid #4cc9f0;
}
.search-hit:hover { background: #1e3a5f; }
.search-hit .hit-cid { font-size: 11px; color: #4cc9f0; }
.search-hit .hit-msg { font-size: 13px; color: #e0e0e0; margin-top: 4px; }
.search-hit .hit-time { font-size: 11px; color: #6b7a99; margin-top: 3px; }
mark { background: transparent; color: #f9ca24; font-weight: 600; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 2px; }
</style>
</head>
<body>
<div class="topbar">
<h1>💬 聊天记录</h1>
<input id="searchInput" class="search-box" placeholder="搜索消息内容..." autocomplete="off">
<span class="live-badge" id="liveBadge">● 实时</span>
</div>
<div class="main" style="position:relative;">
<!-- 搜索覆盖层 -->
<div id="search-overlay"></div>
<!-- 左侧客户列表 -->
<div class="sidebar">
<div class="sidebar-header">
<span id="customerCount">客户</span>
<span id="lastRefresh"></span>
</div>
<div class="customer-list" id="customerList"></div>
</div>
<!-- 右侧对话 -->
<div class="chat-panel" id="chatPanel">
<div class="empty-state" id="emptyState">
<div class="icon">💬</div>
<p>选择一位客户查看对话记录</p>
</div>
<div id="chatHeader" class="chat-header" style="display:none;">
<div>
<div class="cname" id="headerName"></div>
<div class="cid" id="headerId"></div>
</div>
<div class="stats" id="headerStats"></div>
</div>
<div class="chat-messages" id="chatMessages" style="display:none;"></div>
</div>
</div>
<script>
let currentCid = null;
let autoRefresh = null;
let allCustomers = [];
// ── 时间格式化 ──
function fmtTime(ts) {
if (!ts) return '';
const today = new Date().toISOString().slice(0,10);
return ts.startsWith(today) ? ts.slice(11,16) : ts.slice(5,16);
}
// ── 平台徽章 ──
function platBadge(p) {
const map = {
AliWorkbench: ['ali','淘宝'],
taobao: ['ali','淘宝'],
pinduoduo: ['','拼多多'],
jd: ['','京东'],
email: ['email','邮件'],
};
const [cls, label] = map[p] || ['', p || ''];
return label ? `<span class="badge-plat ${cls}">${label}</span>` : '';
}
// ── 加载客户列表 ──
async function loadCustomers() {
const r = await fetch('/api/customers');
allCustomers = await r.json();
renderCustomers(allCustomers);
document.getElementById('customerCount').textContent = `客户 ${allCustomers.length} 人`;
document.getElementById('lastRefresh').textContent = new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
}
function renderCustomers(list) {
const el = document.getElementById('customerList');
el.innerHTML = list.map(c => {
const active = c.customer_id === currentCid ? 'active' : '';
const name = c.customer_name || c.customer_id.slice(-8);
return `<div class="customer-item ${active}" onclick="openChat('${c.customer_id}','${(c.customer_name||'').replace(/'/g,"\\'")}','${c.platform||''}',${c.total_msgs},${c.recv},${c.sent})">
<div class="name">${name} ${platBadge(c.platform)}</div>
<div class="cid">${c.customer_id}</div>
<div class="meta"><span>${c.total_msgs} 条消息</span><span>${fmtTime(c.last_time)}</span></div>
</div>`;
}).join('');
}
// ── 打开对话 ──
async function openChat(cid, name, platform, total, recv, sent) {
currentCid = cid;
renderCustomers(allCustomers);
document.getElementById('emptyState').style.display = 'none';
document.getElementById('chatHeader').style.display = 'flex';
document.getElementById('chatMessages').style.display = 'flex';
document.getElementById('headerName').textContent = name || cid;
document.getElementById('headerId').textContent = cid;
document.getElementById('headerStats').textContent = `共 ${total} 条 收 ${recv} 发 ${sent}`;
await loadConversation(cid);
if (autoRefresh) clearInterval(autoRefresh);
autoRefresh = setInterval(() => loadConversation(cid), 4000);
}
// ── 加载对话 ──
async function loadConversation(cid) {
const r = await fetch(`/api/conversation/${encodeURIComponent(cid)}`);
const msgs = await r.json();
renderMessages(msgs);
}
function renderMessages(msgs) {
const el = document.getElementById('chatMessages');
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
let lastDate = '';
const html = msgs.map(m => {
const date = (m.timestamp || '').slice(0,10);
let divider = '';
if (date && date !== lastDate) { divider = `<div class="day-divider">${date}</div>`; lastDate = date; }
const dir = m.direction;
const avatarChar = dir === 'in' ? '' : '';
const avatarCls = dir === 'in' ? 'buyer' : 'seller';
const content = renderMsgContent(m.message, m.msg_type);
return `${divider}
<div class="msg-row ${dir}">
<div class="avatar ${avatarCls}">${avatarChar}</div>
<div class="bubble-wrap">
<div class="bubble ${dir}">${content}</div>
<div class="msg-time">${fmtTime(m.timestamp)}</div>
</div>
</div>`;
}).join('');
el.innerHTML = html;
if (atBottom) el.scrollTop = el.scrollHeight;
}
function renderMsgContent(msg, msgType) {
if (!msg) return '';
const urlRegGlobal = /(https?:\/\/[^\s]+?\.(jpg|jpeg|png|gif|webp)(\?[^\s]*)?)/gi;
const urlRegSingle = /(https?:\/\/[^\s]+?\.(jpg|jpeg|png|gif|webp)(\?[^\s]*)?)/i;
const parts = msg.split('#*#').map(s => s.trim()).filter(Boolean);
if (parts.length > 1) {
const segs = parts.map(p => {
const m = p.match(urlRegSingle);
if (m) {
const url = m[0];
return `<a href="${url}" target="_blank"><img src="${url}" onerror="this.style.display='none'"></a>`;
}
const esc = p.replace(/</g,'&lt;').replace(/>/g,'&gt;');
return esc;
});
return segs.join('<br>');
}
const escaped = msg.replace(/</g,'&lt;').replace(/>/g,'&gt;');
return escaped.replace(urlRegGlobal, (url) =>
`<a href="${url}" target="_blank"><img src="${url}" onerror="this.style.display='none'"></a>`
);
}
// ── 搜索 ──
let searchTimer = null;
document.getElementById('searchInput').addEventListener('input', function() {
clearTimeout(searchTimer);
const kw = this.value.trim();
if (!kw) { closeSearch(); return; }
searchTimer = setTimeout(() => doSearch(kw), 300);
});
async function doSearch(kw) {
const r = await fetch(`/api/search?q=${encodeURIComponent(kw)}`);
const results = await r.json();
const overlay = document.getElementById('search-overlay');
overlay.style.display = 'block';
if (!results.length) {
overlay.innerHTML = `<p style="color:#6b7a99;text-align:center;margin-top:60px;">未找到匹配消息</p>`;
return;
}
const hi = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(hi, 'gi');
overlay.innerHTML = results.map(r => {
const dir = r.direction === 'in' ? '买家' : '客服';
const msg = r.message.replace(/</g,'&lt;').replace(re, m => `<mark>${m}</mark>`);
return `<div class="search-hit" onclick="closeSearch(); openChat('${r.customer_id}','','','','','')">
<div class="hit-cid">${r.customer_id} ${r.customer_name||''} · ${dir}</div>
<div class="hit-msg">${msg}</div>
<div class="hit-time">${(r.timestamp||'').slice(0,16)}</div>
</div>`;
}).join('');
}
function closeSearch() {
document.getElementById('search-overlay').style.display = 'none';
document.getElementById('searchInput').value = '';
}
// ── 初始化 ──
loadCustomers();
setInterval(loadCustomers, 10000);
</script>
</body>
</html>
"""
PRICING_HTML = r"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 报价测试</title>
<style>
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #1a1a2e; color: #e0e0e0; padding: 20px; }
.card { background:#16213e; border:1px solid #0f3460; border-radius:12px; padding:16px; max-width:880px; margin:0 auto; }
.title { font-size:16px; color:#4cc9f0; margin-bottom:12px; }
.row { display:flex; gap:12px; margin-bottom:10px; }
.row .col { flex:1; }
.input { width:100%; background:#0f3460; border:1px solid #1a5276; border-radius:10px; padding:10px 12px; color:#e0e0e0; font-size:13px; outline:none; }
.input::placeholder { color:#6b7a99; }
.btn { background:#0d7377; color:#14ffec; border:none; border-radius:10px; padding:10px 16px; cursor:pointer; font-size:13px; }
.btn:disabled { opacity:.5; cursor:not-allowed; }
.result { margin-top:14px; background:#0f3460; border:1px solid #1a5276; border-radius:10px; padding:12px; font-size:13px; white-space:pre-wrap; }
.tip { font-size:12px; color:#6b7a99; margin-top:6px; }
</style>
</head>
<body>
<div class="card">
<div class="title">🧪 AI 报价测试</div>
<div class="row">
<div class="col">
<input id="cid" class="input" placeholder="客户ID如 tb7518056865:小林">
</div>
<div class="col">
<input id="acc" class="input" placeholder="店铺ID可留空">
</div>
</div>
<div class="row">
<div class="col">
<textarea id="msg" class="input" rows="4" placeholder="输入消息文本或图片URL多张用 #*# 分隔)。示例:这两张有原图吗#*#https://...jpg#*#https://...png"></textarea>
</div>
</div>
<div class="row">
<button class="btn" id="runBtn" onclick="runPricing()">测试报价</button>
</div>
<div id="result" class="result" style="display:none;"></div>
<div class="tip">提示含图片URL时Agent会自动调用图片分析并结合复杂度、尺寸、人脸与风险给出建议价文本砍价低于最近图片底线会被礼貌拒绝。</div>
</div>
<script>
async function runPricing() {
const cid = document.getElementById('cid').value.trim();
const acc = document.getElementById('acc').value.trim();
const msg = document.getElementById('msg').value.trim();
const btn = document.getElementById('runBtn');
const res = document.getElementById('result');
if (!cid || !msg) { alert('请填写客户ID与消息'); return; }
btn.disabled = true; res.style.display = 'none'; res.textContent = '';
try {
const r = await fetch('/api/pricing/run', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ from_id: cid, acc_id: acc, msg })
});
const data = await r.json();
res.style.display = 'block';
res.textContent = data.error ? ('错误:'+data.error) : (
`回复:${data.reply}\n\n【调试】目标Agent${data.agent}\n最低价${data.floor}\n应答${data.should_reply?'':''}`
);
} catch(e) {
res.style.display = 'block';
res.textContent = '请求失败:'+e;
} finally {
btn.disabled = false;
}
}
</script>
</body>
</html>
"""
@app.route("/")
def index():
return render_template_string(HTML)
@app.route("/pricing")
def pricing_index():
return render_template_string(PRICING_HTML)
@app.route("/api/customers")
def api_customers():
return jsonify(db.get_customers(limit=200))
@app.route("/api/conversation/<customer_id>")
def api_conversation(customer_id):
return jsonify(db.get_conversation(customer_id, limit=500))
@app.route("/api/search")
def api_search():
kw = request.args.get("q", "").strip()
if not kw:
return jsonify([])
return jsonify(db.search_messages(kw, limit=60))
if __name__ == "__main__":
print("聊天记录 UI 启动中...")
print("访问 → http://localhost:5678")
app.run(host="0.0.0.0", port=5678, debug=False)
@app.route("/api/pricing/run", methods=["POST"])
def api_pricing_run():
global pricing_agent
if pricing_agent is None:
return jsonify({"error":"报价Agent未初始化"})
data = request.get_json(force=True) or {}
from_id = (data.get("from_id") or "").strip()
acc_id = (data.get("acc_id") or "").strip()
msg = (data.get("msg") or "").strip()
if not from_id or not msg:
return jsonify({"error":"缺少参数 from_id 或 msg"})
# 构造提示词:直接使用用户输入,保持与正式场景一致
user_prompt = msg
deps = AgentDeps(
msg_id="pricing-test",
acc_id=acc_id or "TEST_SHOP",
from_id=from_id,
platform="taobao"
)
try:
# 强制使用报价Agent
result = asyncio.run(pricing_agent.agent_pricing.run(user_prompt, deps=deps, message_history=[]))
# 读取底线
try:
from config.config import MIN_PRICE_FLOOR
st = pricing_agent._get_conversation_state(from_id)
floor = st.last_min_price if isinstance(st.last_min_price,int) and st.last_min_price>0 else MIN_PRICE_FLOOR
except Exception:
floor = None
return jsonify({
"reply": result.output,
"should_reply": True,
"agent": "pricing",
"floor": floor
})
except Exception as e:
return jsonify({"error": str(e)})

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Self-evolution MVP cycle runner.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
load_dotenv(dotenv_path=PROJECT_ROOT / ".env")
from evolution.mvp import ChatSourceConfig, DEFAULT_CANDIDATE_PATH, DEFAULT_POLICY_PATH, run_cycle
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run self-evolution MVP cycle")
parser.add_argument(
"--source",
type=str,
default="mysql",
choices=["auto", "sqlite", "mysql"],
help="Chat data source, default mysql (online)",
)
parser.add_argument("--hours", type=int, default=24, help="Lookback window for chat samples")
parser.add_argument("--max-customers", type=int, default=200, help="Max customers sampled")
parser.add_argument(
"--max-messages-per-customer",
type=int,
default=80,
help="Max messages loaded per customer",
)
parser.add_argument("--runtime-hours", type=int, default=24, help="Runtime metric window")
parser.add_argument(
"--publish",
action="store_true",
help="Write config/evolution_candidate.json when gate passes",
)
parser.add_argument(
"--policy-path",
type=str,
default=str(DEFAULT_POLICY_PATH),
help="Path to evolution gate policy file",
)
parser.add_argument(
"--candidate-path",
type=str,
default=str(DEFAULT_CANDIDATE_PATH),
help="Path to candidate output file",
)
parser.add_argument("--db-path", type=str, default="", help="SQLite path when --source sqlite")
parser.add_argument("--mysql-host", type=str, default=os.getenv("MYSQL_HOST", "127.0.0.1"))
parser.add_argument("--mysql-port", type=int, default=int(os.getenv("MYSQL_PORT", "3306")))
parser.add_argument("--mysql-user", type=str, default=os.getenv("MYSQL_USER", "root"))
parser.add_argument("--mysql-password", type=str, default=os.getenv("MYSQL_PASSWORD", ""))
parser.add_argument("--mysql-database", type=str, default=os.getenv("MYSQL_DATABASE", "ai_cs"))
return parser.parse_args()
def main() -> int:
args = parse_args()
os.environ.setdefault("PYTHONUTF8", "1")
chat_source = ChatSourceConfig(
source=args.source,
sqlite_path=args.db_path or str(PROJECT_ROOT / "db" / "chat_log_db" / "chats.db"),
mysql_host=args.mysql_host,
mysql_port=args.mysql_port,
mysql_user=args.mysql_user,
mysql_password=args.mysql_password,
mysql_database=args.mysql_database,
)
result = run_cycle(
hours=args.hours,
max_customers=args.max_customers,
max_messages_per_customer=args.max_messages_per_customer,
runtime_hours=args.runtime_hours,
publish=args.publish,
chat_source=chat_source,
policy_path=Path(args.policy_path),
candidate_path=Path(args.candidate_path),
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
"""
初始化设计师派单数据SQLite
同一设计师在不同店铺对应不同 group_id。
用法:
python scripts/init_designer_roster.py
# 按提示添加设计师和店铺分组,或直接修改下方示例后运行
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from db.designer_roster_db import add_designer, set_designer_shop, list_designers, update_online
def init_example():
"""示例:添加设计师,同一人在不同店铺不同分组"""
# 设计师A在 小威哥1216 用分组 20252916034在 另一店铺 用 12345678
aid = add_designer("设计师A", "user_a")
set_designer_shop(aid, "小威哥1216", "20252916034")
set_designer_shop(aid, "另一店铺", "12345678")
# 设计师B只在 小威哥1216
bid = add_designer("设计师B", "user_b")
set_designer_shop(bid, "小威哥1216", "99998888")
# 可选:手动标记上线(否则等企微群解析)
update_online("user_a", True)
update_online("user_b", True)
print("示例数据已写入")
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "example":
init_example()
elif len(sys.argv) > 1 and sys.argv[1] == "list":
for d in list_designers():
print(f"{d['name']} ({d['wechat_user_id']}) 在线={d['is_online']}")
for shop, gid in d["shops"].items():
print(f" - {shop} -> {gid}")
else:
print("用法: python scripts/init_designer_roster.py example # 写入示例")
print(" python scripts/init_designer_roster.py list # 查看当前数据")

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把本地 SQLite 聊天记录迁移到 MySQL:
source: db/chat_log_db/chats.db -> table chat_logs
用法示例:
python scripts/migrate_chat_logs_to_mysql.py --host xinhui.cloud --port 3306 \
--user ai_cs_user --password xxx --database ai_cs --batch-size 2000 --truncate-target
"""
from __future__ import annotations
import argparse
import os
import sqlite3
import time
from pathlib import Path
import pymysql
def ensure_mysql_table(conn):
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS chat_logs (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
customer_id VARCHAR(128) NOT NULL,
customer_name VARCHAR(255) DEFAULT '',
acc_id VARCHAR(128) DEFAULT '',
platform VARCHAR(64) DEFAULT '',
direction VARCHAR(8) NOT NULL,
message TEXT NOT NULL,
msg_type INTEGER DEFAULT 0,
timestamp DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
cur.execute("SHOW INDEX FROM chat_logs")
exists = {str(r.get("Key_name", "")) for r in cur.fetchall()}
if "idx_customer" not in exists:
cur.execute("CREATE INDEX idx_customer ON chat_logs(customer_id)")
if "idx_ts" not in exists:
cur.execute("CREATE INDEX idx_ts ON chat_logs(timestamp)")
if "idx_acc" not in exists:
cur.execute("CREATE INDEX idx_acc ON chat_logs(acc_id)")
conn.commit()
def get_sqlite_conn(path: Path):
conn = sqlite3.connect(str(path))
conn.row_factory = sqlite3.Row
return conn
def get_mysql_conn(host: str, port: int, user: str, password: str, database: str):
return pymysql.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
charset="utf8mb4",
autocommit=False,
cursorclass=pymysql.cursors.DictCursor,
)
def migrate(sqlite_path: Path, host: str, port: int, user: str, password: str, database: str, batch_size: int, truncate_target: bool):
if not sqlite_path.exists():
raise FileNotFoundError(f"SQLite 文件不存在: {sqlite_path}")
s_conn = get_sqlite_conn(sqlite_path)
m_conn = get_mysql_conn(host, port, user, password, database)
try:
ensure_mysql_table(m_conn)
if truncate_target:
with m_conn.cursor() as cur:
cur.execute("TRUNCATE TABLE chat_logs")
m_conn.commit()
total = s_conn.execute("SELECT COUNT(*) AS c FROM chat_logs").fetchone()["c"]
print(f"[MIGRATE] SQLite 源总行数: {total}")
if total == 0:
return 0
migrated = 0
last_id = 0
started = time.time()
insert_sql = (
"INSERT INTO chat_logs "
"(customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)"
)
while True:
rows = s_conn.execute(
"""
SELECT id, customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp
FROM chat_logs
WHERE id > ?
ORDER BY id ASC
LIMIT ?
""",
(last_id, batch_size),
).fetchall()
if not rows:
break
vals = []
for r in rows:
vals.append(
(
r["customer_id"] or "",
r["customer_name"] or "",
r["acc_id"] or "",
r["platform"] or "",
r["direction"] or "in",
r["message"] or "",
int(r["msg_type"] or 0),
r["timestamp"],
)
)
last_id = r["id"]
with m_conn.cursor() as cur:
cur.executemany(insert_sql, vals)
m_conn.commit()
migrated += len(vals)
elapsed = time.time() - started
print(f"[MIGRATE] {migrated}/{total} ({(migrated/total)*100:.1f}%) elapsed={elapsed:.1f}s")
return migrated
finally:
try:
s_conn.close()
except Exception:
pass
try:
m_conn.close()
except Exception:
pass
def main():
parser = argparse.ArgumentParser(description="迁移 chat_logs: SQLite -> MySQL")
parser.add_argument("--sqlite-path", default=str(Path("db") / "chat_log_db" / "chats.db"))
parser.add_argument("--host", required=True)
parser.add_argument("--port", type=int, default=3306)
parser.add_argument("--user", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--database", required=True)
parser.add_argument("--batch-size", type=int, default=2000)
parser.add_argument("--truncate-target", action="store_true")
args = parser.parse_args()
sqlite_path = Path(args.sqlite_path)
migrated = migrate(
sqlite_path=sqlite_path,
host=args.host,
port=args.port,
user=args.user,
password=args.password,
database=args.database,
batch_size=max(100, int(args.batch_size)),
truncate_target=bool(args.truncate_target),
)
print(f"[DONE] 迁移完成,写入 {migrated}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
迁移 customer_db/customers.json -> MySQL customer_profiles
"""
from __future__ import annotations
import argparse
import json
from datetime import datetime
from pathlib import Path
import pymysql
def get_conn(host: str, port: int, user: str, password: str, database: str):
return pymysql.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
charset="utf8mb4",
autocommit=False,
cursorclass=pymysql.cursors.DictCursor,
)
def ensure_table(conn):
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_profiles (
customer_id VARCHAR(128) PRIMARY KEY,
profile_json LONGTEXT NOT NULL,
last_update DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
cur.execute("SHOW INDEX FROM customer_profiles")
exists = {str(r.get("Key_name", "")) for r in cur.fetchall()}
if "idx_last_update" not in exists:
cur.execute("CREATE INDEX idx_last_update ON customer_profiles(last_update)")
conn.commit()
def migrate(json_path: Path, host: str, port: int, user: str, password: str, database: str, truncate_target: bool):
if not json_path.exists():
raise FileNotFoundError(f"customers.json 不存在: {json_path}")
customers = json.loads(json_path.read_text(encoding="utf-8") or "{}")
if not isinstance(customers, dict):
raise RuntimeError("customers.json 格式错误,期望对象映射")
conn = get_conn(host, port, user, password, database)
try:
ensure_table(conn)
if truncate_target:
with conn.cursor() as cur:
cur.execute("TRUNCATE TABLE customer_profiles")
conn.commit()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sql = (
"REPLACE INTO customer_profiles (customer_id, profile_json, last_update) "
"VALUES (%s, %s, %s)"
)
total = 0
with conn.cursor() as cur:
for cid, profile in customers.items():
cur.execute(sql, (str(cid), json.dumps(profile, ensure_ascii=False), now))
total += 1
conn.commit()
return total
finally:
conn.close()
def main():
parser = argparse.ArgumentParser(description="迁移 customers.json 到 MySQL")
parser.add_argument("--json-path", default=str(Path("customer_db") / "customers.json"))
parser.add_argument("--host", required=True)
parser.add_argument("--port", type=int, default=3306)
parser.add_argument("--user", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--database", required=True)
parser.add_argument("--truncate-target", action="store_true")
args = parser.parse_args()
total = migrate(
json_path=Path(args.json_path),
host=args.host,
port=args.port,
user=args.user,
password=args.password,
database=args.database,
truncate_target=bool(args.truncate_target),
)
print(f"[DONE] customer_profiles 写入 {total}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
迁移其余 SQLite 业务库到 MySQL保留主键
- deal_outcome_db/outcomes.db -> deal_outcomes
- designer_roster_db/roster.db -> designers/designer_shops/designer_online/round_robin
- image_tasks.db -> image_tasks/requirement_history
- task_db/tasks.db -> tasks/task_logs
"""
from __future__ import annotations
import argparse
import sqlite3
from pathlib import Path
from typing import List, Dict
import pymysql
MAPPINGS = [
{"sqlite": Path("db/deal_outcome_db/outcomes.db"), "tables": ["deal_outcomes"]},
{"sqlite": Path("db/designer_roster_db/roster.db"), "tables": ["designers", "designer_shops", "designer_online", "round_robin"]},
{"sqlite": Path("db/image_tasks.db"), "tables": ["image_tasks", "task_requirement_changes"]},
{"sqlite": Path("db/task_db/tasks.db"), "tables": ["tasks"]},
]
def mysql_conn(host: str, port: int, user: str, password: str, database: str):
return pymysql.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
charset="utf8mb4",
autocommit=False,
cursorclass=pymysql.cursors.DictCursor,
)
def sqlite_table_exists(conn: sqlite3.Connection, table: str) -> bool:
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table,),
).fetchone()
return row is not None
def sqlite_fetch_all(conn: sqlite3.Connection, table: str) -> List[sqlite3.Row]:
conn.row_factory = sqlite3.Row
return conn.execute(f"SELECT * FROM {table}").fetchall()
def migrate_table(mysql, rows: List[sqlite3.Row], table: str, truncate_target: bool) -> int:
if not rows:
return 0
cols = list(rows[0].keys())
col_sql = ", ".join(cols)
val_sql = ", ".join(["%s"] * len(cols))
sql = f"REPLACE INTO {table} ({col_sql}) VALUES ({val_sql})"
if truncate_target:
with mysql.cursor() as cur:
try:
cur.execute(f"TRUNCATE TABLE {table}")
except Exception:
try:
cur.execute(f"DELETE FROM {table}")
except Exception:
return 0
values = [tuple(r[c] for c in cols) for r in rows]
with mysql.cursor() as cur:
cur.executemany(sql, values)
mysql.commit()
return len(values)
def main():
p = argparse.ArgumentParser(description="迁移剩余 SQLite 业务库到 MySQL")
p.add_argument("--host", required=True)
p.add_argument("--port", type=int, default=3306)
p.add_argument("--user", required=True)
p.add_argument("--password", required=True)
p.add_argument("--database", required=True)
p.add_argument("--truncate-target", action="store_true")
args = p.parse_args()
total = 0
with mysql_conn(args.host, args.port, args.user, args.password, args.database) as mconn:
for item in MAPPINGS:
sp = item["sqlite"]
if not sp.exists():
continue
sconn = sqlite3.connect(str(sp))
try:
for table in item["tables"]:
if not sqlite_table_exists(sconn, table):
continue
rows = sqlite_fetch_all(sconn, table)
n = migrate_table(mconn, rows, table, truncate_target=bool(args.truncate_target))
total += n
print(f"[MIGRATE] {sp}::{table} -> {n}")
finally:
sconn.close()
print(f"[DONE] migrated total rows: {total}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
"""
多进程异步并行启动器
按客户 ID hash 分配到不同进程,实现真正的并行处理
"""
import os
import sys
import signal
import logging
from multiprocessing import Process, cpu_count
from typing import List, Dict
import hashlib
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
logging.basicConfig(
level=logging.INFO,
format='[%(asctime)s] %(levelname)s: %(message)s'
)
logger = logging.getLogger(__name__)
class WorkerProcess:
"""工作进程"""
def __init__(self, worker_id: int, shard_keys: List[str], num_workers: int, enable_agent: bool = True):
self.worker_id = worker_id
self.shard_keys = shard_keys
self.num_workers = max(1, int(num_workers))
self.enable_agent = enable_agent
self.process = None
def start(self):
"""启动工作进程"""
self.process = Process(
target=self._run,
args=(self.worker_id, self.shard_keys, self.num_workers, self.enable_agent),
name=f"ai-cs-worker-{self.worker_id}"
)
self.process.start()
logger.info(f"Worker {self.worker_id} 启动 (PID: {self.process.pid})")
def _run(self, worker_id: int, shard_keys: List[str], num_workers: int, enable_agent: bool):
"""工作进程入口"""
try:
# 设置进程环境变量
os.environ['AI_CS_WORKER_ID'] = str(worker_id)
os.environ['AI_CS_WORKER_COUNT'] = str(max(1, int(num_workers)))
os.environ['AI_CS_SHARD_KEYS'] = ','.join(shard_keys)
# 导入并启动 WebSocket 客户端
from core.websocket_client import QingjianAPIClient
logger.info(f"Worker {worker_id} 初始化 Agent...")
client = QingjianAPIClient(enable_agent=enable_agent)
# 只处理分配给这个 worker 的客户
client.shard_keys = set(shard_keys)
logger.info(f"Worker {worker_id} 开始处理消息...")
import asyncio
asyncio.run(client.connect())
except KeyboardInterrupt:
logger.info(f"Worker {worker_id} 收到退出信号")
except Exception as e:
logger.error(f"Worker {worker_id} 异常:{e}")
import traceback
traceback.print_exc()
def stop(self):
"""停止工作进程"""
if self.process and self.process.is_alive():
self.process.terminate()
self.process.join(timeout=5)
logger.info(f"Worker {self.worker_id} 已停止")
class Coordinator:
"""协调器 - 管理多个工作进程"""
def __init__(self, num_workers: int = None, enable_agent: bool = True):
self.num_workers = num_workers or max(2, cpu_count())
self.workers: List[WorkerProcess] = []
self.running = False
self._stopping = False
self.enable_agent = enable_agent
def _get_shard_key(self, acc_id: str, from_id: str) -> int:
"""根据店铺 ID + 客户 ID 计算分片 key"""
key = f"{acc_id}:{from_id}"
hash_value = int(hashlib.md5(key.encode()).hexdigest(), 16)
return hash_value % self.num_workers
def _load_customer_shards(self) -> Dict[int, List[str]]:
"""加载客户分片信息
Returns:
{shard_id: [customer_key1, customer_key2, ...]}
"""
# 从数据库或配置文件加载客户列表
# 这里简化处理,实际应该从数据库加载活跃客户
shards = {i: [] for i in range(self.num_workers)}
# TODO: 从数据库加载活跃客户列表
# customers = db.query(...).all()
# for customer in customers:
# shard_id = self._get_shard_key(customer.acc_id, customer.from_id)
# shards[shard_id].append(f"{customer.acc_id}:{customer.from_id}")
logger.info(f"已加载 {sum(len(v) for v in shards.values())} 个客户分片")
return shards
def start(self):
"""启动所有工作进程"""
logger.info(f"启动协调器,工作进程数:{self.num_workers}")
shards = self._load_customer_shards()
# 启动工作进程
for worker_id in range(self.num_workers):
worker = WorkerProcess(
worker_id=worker_id,
shard_keys=shards.get(worker_id, []),
num_workers=self.num_workers,
enable_agent=self.enable_agent
)
worker.start()
self.workers.append(worker)
self.running = True
# 注册信号处理
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
# 监控工作进程
self._monitor_workers()
def _monitor_workers(self):
"""监控工作进程健康状态"""
import time
while self.running:
# 检查工作进程是否存活
for worker in self.workers:
if worker.process and not worker.process.is_alive():
logger.warning(f"Worker {worker.worker_id} 已退出,尝试重启...")
# 重启工作进程
worker.start()
time.sleep(10) # 每 10 秒检查一次
def _signal_handler(self, signum, frame):
"""信号处理"""
if self._stopping:
return
self._stopping = True
logger.info(f"收到信号 {signum},正在停止所有工作进程...")
self.stop()
def stop(self):
"""停止所有工作进程"""
if self._stopping and not self.running and not any(w.process and w.process.is_alive() for w in self.workers):
return
self._stopping = True
self.running = False
for worker in self.workers:
worker.stop()
logger.info("所有工作进程已停止")
def main():
"""主函数"""
import argparse
parser = argparse.ArgumentParser(description='AI 客服多进程启动器')
parser.add_argument(
'--workers',
type=int,
default=None,
help='工作进程数默认CPU 核心数)'
)
args = parser.parse_args()
logger.info("=" * 60)
logger.info("AI 客服系统 - 多进程异步并行模式")
logger.info("=" * 60)
coordinator = Coordinator(num_workers=args.workers)
try:
coordinator.start()
except KeyboardInterrupt:
logger.info("收到退出信号")
coordinator.stop()
except Exception as e:
logger.error(f"启动失败:{e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,8 @@
$ErrorActionPreference = "Stop"
# Use a writable uv cache path on Windows to avoid permission issues
# with default cache locations in restricted environments.
$env:UV_CACHE_DIR = Join-Path $env:TEMP "uv-cache-tw-runtime"
New-Item -ItemType Directory -Force $env:UV_CACHE_DIR | Out-Null
uv run tests\test_ai_chat.py

View File

@@ -0,0 +1,53 @@
import logging
from utils.observability import build_trace_id
from core.websocket_brain_flow import decide_brain_action, execute_brain_action
logger = logging.getLogger("cs_agent")
async def handle_agent_reply_flow(client, data: dict, *, workflow, shop_type_resolver):
"""处理单条消息:统一走 Brain 决策 + 执行。"""
try:
msg_text = client.to_chinese(data.get("msg", ""))
customer_id = data.get("from_id", "")
trace_id = build_trace_id(data.get("acc_id", ""), customer_id, data.get("msg_id", ""), msg_text[:64])
data["_trace_id"] = trace_id
shop_type = shop_type_resolver(data.get("acc_id", ""), client.to_chinese(data.get("goods_name", "") or ""))
customer_msg = client._build_customer_message(data)
decision = await decide_brain_action(
client,
data,
customer_msg,
trace_id=trace_id,
msg_text=msg_text,
shop_type=shop_type,
)
client._activity_log(
"brain_decision",
trace_id=trace_id,
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
action=decision.action,
source=decision.source,
should_reply=bool(decision.should_reply),
need_transfer=bool(decision.need_transfer),
)
await execute_brain_action(
client,
data,
decision=decision,
trace_id=trace_id,
msg_text=msg_text,
)
except Exception as e:
logger.error("Agent 处理失败: %s", e)
client._activity_log(
"agent_process_error",
trace_id=data.get("_trace_id", ""),
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
error=str(e),
)

View File

@@ -0,0 +1,100 @@
import asyncio
import os
from typing import Any
def cancel_auto_quote_task(client, key: str, reason: str = ""):
task = client._auto_quote_tasks.get(key)
if task and not task.done():
task.cancel()
client._activity_log("auto_quote_cancel", key=key, reason=reason or "unknown")
def build_auto_quote_signature(state: Any) -> str:
"""为待报价内容生成稳定签名,用于避免同一批内容反复自动触发。"""
urls = list(getattr(state, "pending_image_urls", []) or [])
reqs = list(getattr(state, "pending_requirements", []) or [])
req_tail = reqs[-6:] if len(reqs) > 6 else reqs
return "||".join(urls) + "##" + "||".join(req_tail)
async def schedule_auto_quote(client, data: dict, *, shop_type_resolver):
"""
智能兜底:客户发图后若长时间不再补充消息,自动触发一次报价,避免会话卡住。
"""
if not client.enable_agent or not client.agent:
return
try:
shop_type = shop_type_resolver(data.get('acc_id', ''), client.to_chinese(data.get('goods_name', '') or ''))
if shop_type != "find_image":
return
cid = data.get('from_id', '')
key = client._customer_key(data)
state = client.agent._get_conversation_state(cid)
if not state or not getattr(state, "pending_image_urls", None):
cancel_auto_quote_task(client, key, reason="no_pending_images")
client._auto_quote_done_sig.pop(key, None)
return
if state.quote_phase not in {"collecting", "waiting_result"}:
return
current_sig = build_auto_quote_signature(state)
if current_sig and client._auto_quote_done_sig.get(key) == current_sig:
client._activity_log(
"auto_quote_skip_duplicate",
key=key,
pending_count=len(state.pending_image_urls),
)
return
try:
idle_seconds = max(8, int(os.getenv("AUTO_QUOTE_IDLE_SECONDS", "18")))
except Exception:
idle_seconds = 18
cancel_auto_quote_task(client, key, reason="reschedule")
async def _delayed_auto_quote(capture_key: str, capture_data: dict, wait_s: int, capture_sig: str):
await asyncio.sleep(wait_s)
async with client._get_customer_lock(capture_key):
capture_cid = capture_data.get('from_id', '')
st = client.agent._get_conversation_state(capture_cid)
if not st or not st.pending_image_urls:
client._auto_quote_done_sig.pop(capture_key, None)
return
# 内容变化时,放弃旧触发(会在新一轮消息后重新调度)。
if build_auto_quote_signature(st) != capture_sig:
return
# 标记本批次已自动触发,避免同内容循环“马上报价”。
client._auto_quote_done_sig[capture_key] = capture_sig
# 直接置为可报价,走内部自动报价入口(不伪造客户语句)。
client.agent._mark_quote_ready(st)
client.agent._sync_pending_quote_state(capture_cid, st)
client._activity_log(
"auto_quote_trigger",
key=capture_key,
pending_count=len(st.pending_image_urls),
wait_s=wait_s,
)
notify_data = dict(capture_data)
notify_data["msg_id"] = "auto_quote_idle_trigger"
notify_data["msg"] = "__AUTO_QUOTE_INTERNAL_TRIGGER__"
notify_msg = client._build_customer_message(notify_data)
response = await client.agent.build_auto_quote_reply(st, notify_msg)
if response.should_reply and response.reply and not response.need_transfer:
await client.send_reply(capture_data, response.reply)
client._activity_log(
"auto_quote_sent",
key=capture_key,
reply=response.reply,
)
task = asyncio.create_task(_delayed_auto_quote(key, dict(data), idle_seconds, current_sig))
client._auto_quote_tasks[key] = task
client._activity_log(
"auto_quote_scheduled",
key=key,
pending_count=len(state.pending_image_urls),
phase=state.quote_phase,
wait_s=idle_seconds,
)
except Exception as e:
client._activity_log("auto_quote_schedule_error", error=str(e), key=client._customer_key(data))

View File

@@ -0,0 +1,311 @@
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger("cs_agent")
@dataclass
class BrainDecision:
action: str # reply | quote | transfer | noop
source: str
reply: str = ""
transfer_msg: str = ""
should_reply: bool = False
need_transfer: bool = False
payload: dict[str, Any] | None = None
def _extract_json_obj(text: str) -> dict[str, Any] | None:
if not text:
return None
m = re.search(r"\{[\s\S]*\}", text)
if not m:
return None
try:
return json.loads(m.group(0))
except Exception:
return None
async def _ai_policy_brain_decide(client, data: dict, *, msg_text: str, shop_type: str) -> BrainDecision | None:
if not client.enable_agent or not client.agent or not client.AgentDeps:
return None
acc_id = str(data.get("acc_id", "") or "")
customer_id = str(data.get("from_id", "") or "")
current_urls = client._extract_image_urls(msg_text)
recent_urls = client._collect_recent_image_urls(customer_id, acc_id, max_count=6)
key = client._customer_key(data)
pending_urls = client._pending_images.get(key) or []
try:
order_status = client._detect_order_status(msg_text)
has_image_url = client._msg_has_image_url(msg_text)
refers_images = client._msg_refers_images(msg_text)
is_price = client._msg_is_price_inquiry(msg_text)
is_req = client._msg_is_requirement(msg_text)
ext_contact = client._msg_requests_external_contact(msg_text)
except Exception:
order_status, has_image_url, refers_images, is_price, is_req, ext_contact = "", False, False, False, False, False
deps = client.AgentDeps(
msg_id=str(data.get("msg_id", "") or "brain_policy"),
acc_id=acc_id,
from_id=customer_id,
platform=str(data.get("acc_type", "") or "AliWorkbench"),
)
prompt = (
"你是淘宝客服系统的主决策Brain只做决策不要解释。\n"
"你必须根据历史规则和当前上下文,输出唯一动作。\n"
"可选动作 action: reply / quote / transfer / noop。\n"
"历史规则(完整继承):\n"
"1) 客户发图/补图:先自然承接,再根据上下文决定继续收集或报价;\n"
"2) 客户询价且有可用图片(当前或最近)时,优先 action=quote\n"
"3) 若有 pending 图片且客户催报价/补充需求,优先 quote_mode=flush_pending\n"
"4) 仅打招呼/短无意义文本:可 action=reply 简短承接,不要机械模板;\n"
"5) 索要外部联系方式(微信/QQ/手机号)时,不外呼,站内引导;\n"
"6) 订单已付款:可回执安排处理;未付款/待付款:提醒完成付款;\n"
"7) 地图/政治/高风险内容:谨慎,必要时 transfer 或拒绝性 reply\n"
"8) 尺寸超限/不可做场景:给明确边界,不要胡乱承诺;\n"
"9) 客户没发图却问价:先承接,再引导发图;\n"
"10) 避免重复外发,避免同一句话反复说。\n"
"\n"
"quote_mode 可选: flush_pending / analyze_current_or_recent / collect_only\n"
"只输出 JSON\n"
'{"action":"reply|quote|transfer|noop","reply":"","transfer_msg":"","quote_mode":"","reason":""}\n\n'
f"店铺类型: {shop_type}\n"
f"legacy_fast_quote_enabled: {str(bool(client._legacy_fast_quote_enabled)).lower()}\n"
f"客户原话: {msg_text}\n"
f"has_image_url: {has_image_url}\n"
f"current_image_urls_count: {len(current_urls)}\n"
f"recent_image_urls_count: {len(recent_urls)}\n"
f"pending_image_urls_count: {len(pending_urls)}\n"
f"refers_images: {refers_images}\n"
f"is_price_inquiry: {is_price}\n"
f"is_requirement: {is_req}\n"
f"requests_external_contact: {ext_contact}\n"
f"order_status: {order_status or 'none'}\n"
)
try:
result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
raw = str(getattr(result, "output", "") or "").strip()
obj = _extract_json_obj(raw)
if not obj:
client._activity_log(
"brain_policy_parse_error",
acc_id=acc_id,
customer_id=customer_id,
raw=raw[:300],
)
return None
action = str(obj.get("action", "") or "").strip().lower()
reply = str(obj.get("reply", "") or "").strip()
transfer_msg = str(obj.get("transfer_msg", "") or "").strip()
quote_mode = str(obj.get("quote_mode", "") or "").strip().lower()
reason = str(obj.get("reason", "") or "").strip()
payload: dict[str, Any] | None = None
if action == "quote":
mode = quote_mode or "analyze_current_or_recent"
if mode == "flush_pending":
payload = {"mode": "flush_pending", "key": key, "pre_reply": reply}
elif mode == "collect_only":
payload = {"mode": "collect_only", "pre_reply": reply}
else:
urls = current_urls or recent_urls
payload = {"mode": "analyze_urls", "urls": urls, "pre_reply": reply}
decision = BrainDecision(
action=action if action in {"reply", "quote", "transfer", "noop"} else "noop",
source="brain_ai_policy",
reply=reply,
transfer_msg=transfer_msg,
should_reply=bool(reply),
need_transfer=(action == "transfer"),
payload=payload,
)
client._activity_log(
"brain_policy_raw",
acc_id=acc_id,
customer_id=customer_id,
action=decision.action,
quote_mode=quote_mode,
reason=reason,
)
return decision
except Exception as e:
client._activity_log(
"brain_policy_error",
acc_id=acc_id,
customer_id=customer_id,
error=str(e),
)
return None
async def decide_brain_action(client, data: dict, customer_msg, *, trace_id: str, msg_text: str, shop_type: str) -> BrainDecision:
"""统一主决策层:优先由 Brain AI 决策;失败时回退 Agent 默认决策。"""
ai_decision = await _ai_policy_brain_decide(client, data, msg_text=msg_text, shop_type=shop_type)
if ai_decision is not None:
return ai_decision
# 回退:保持可用性
logger.info("Agent 正在处理消息...")
client._activity_log(
"agent_process_start",
trace_id=trace_id,
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
msg=msg_text,
)
response = await client.agent.process_message(customer_msg)
client._activity_log(
"agent_process_done",
trace_id=trace_id,
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
result="ok",
should_reply=bool(response.should_reply),
need_transfer=bool(response.need_transfer),
)
if response.need_transfer:
return BrainDecision(
action="transfer",
source="fallback_agent",
reply=response.reply or "",
transfer_msg=response.transfer_msg or "",
should_reply=bool(response.should_reply),
need_transfer=True,
)
if response.should_reply and response.reply:
return BrainDecision(
action="reply",
source="fallback_agent",
reply=response.reply,
should_reply=True,
need_transfer=False,
)
return BrainDecision(action="noop", source="fallback_agent", should_reply=False, need_transfer=False)
async def execute_brain_action(client, data: dict, *, decision: BrainDecision, trace_id: str, msg_text: str):
"""统一执行层:只执行标准动作。"""
customer_id = data.get("from_id", "")
if customer_id:
client._touch_customer_last_contact(customer_id)
if decision.action == "transfer":
logger.info("Agent 决定转接人工")
client._activity_log(
"agent_transfer",
trace_id=trace_id,
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
transfer_msg=decision.transfer_msg,
)
client._fire_and_forget(
client._post_tianwang_callback(
"message_processed",
data,
extra={
"should_reply": bool(decision.should_reply),
"need_transfer": True,
"agent_reply": decision.reply or "",
"transfer_msg": decision.transfer_msg or "",
},
)
)
await client.transfer_to_human(data, decision.transfer_msg)
client._push_chat_to_wechat_safe(
data=data,
customer_msg=msg_text,
reply_msg=decision.transfer_msg or "转接",
tag="转人工",
)
return
if decision.action == "reply":
text = (decision.reply or "").strip()
if not text:
return
await asyncio.sleep(0.6)
client._activity_log(
"agent_reply",
trace_id=trace_id,
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
reply=text,
)
await client.send_reply(data, text)
await client._maybe_schedule_auto_quote(data)
client._fire_and_forget(
client._post_tianwang_callback(
"message_processed",
data,
extra={
"should_reply": True,
"need_transfer": False,
"agent_reply": text,
},
)
)
client._push_chat_to_wechat_safe(
data=data,
customer_msg=msg_text,
reply_msg=text,
tag="正常AI回复",
)
return
if decision.action == "quote":
payload = decision.payload or {}
pre_reply = str(payload.get("pre_reply", "") or "").strip()
if pre_reply:
await client.send_reply(data, pre_reply)
mode = str(payload.get("mode", "") or "")
if mode == "flush_pending":
key = str(payload.get("key", "") or "")
if key:
await client._flush_pending_images(key, data)
elif mode == "analyze_urls":
urls = payload.get("urls") or []
if isinstance(urls, list) and urls:
if len(urls) == 1:
asyncio.create_task(client._analyze_single_and_reply(data, urls[0]))
else:
asyncio.create_task(client._analyze_multi_and_reply(data, urls))
else:
await client.send_reply(data, "你把要处理的图再发我一下,我马上给你看。")
else:
if not pre_reply:
await client.send_reply(data, "收到,我先看一下哈,稍等哈。")
return
# noop
client._activity_log(
"agent_no_reply",
trace_id=trace_id,
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
)
client._fire_and_forget(
client._post_tianwang_callback(
"message_processed",
data,
extra={
"should_reply": False,
"need_transfer": False,
"agent_reply": "",
},
)
)

View File

@@ -0,0 +1,48 @@
import os
from datetime import datetime
from typing import Any, Dict, Optional
async def post_tianwang_callback_flow(client, event: str, data: dict, extra: Optional[Dict[str, Any]] = None):
"""将消息处理事件回调给天网。"""
if not client._tianwang_callback_url:
return
try:
import httpx
trust_env = os.getenv("TIANWANG_CALLBACK_TRUST_ENV", "false").lower() in ("1", "true", "yes")
payload = {
"event": event,
"timestamp": datetime.now().isoformat(),
"agent_name": client._tianwang_agent_name,
"acc_id": str(data.get("acc_id", "") or ""),
"customer_id": str(data.get("from_id", "") or ""),
"customer_name": client.to_chinese(data.get("from_name", "") or data.get("cy_name", "")),
"msg_id": str(data.get("msg_id", "") or ""),
"msg_type": int(data.get("msg_type", 0) or 0),
"msg": client.to_chinese(data.get("msg", "") or ""),
"goods_name": client.to_chinese(data.get("goods_name", "") or ""),
"goods_order": client.to_chinese(data.get("goods_order", "") or ""),
}
if extra:
payload.update(extra)
async with httpx.AsyncClient(timeout=6, trust_env=trust_env) as http_client:
resp = await http_client.post(client._tianwang_callback_url, json=payload)
ok = 200 <= resp.status_code < 300
client._activity_log(
"tianwang_callback",
result="ok" if ok else "http_error",
event_name=event,
status_code=resp.status_code,
acc_id=payload["acc_id"],
customer_id=payload["customer_id"],
)
except Exception as e:
client._activity_log(
"tianwang_callback",
result="error",
event_name=event,
acc_id=str(data.get("acc_id", "") or ""),
customer_id=str(data.get("from_id", "") or ""),
error=str(e),
)

556
legacy/websocket_client.py Normal file
View File

@@ -0,0 +1,556 @@
import asyncio
import json
import hashlib
from collections import deque
from datetime import datetime
from typing import Optional, Dict, Any, List
from utils.observability import emit_activity
from core.websocket_agent_reply_flow import handle_agent_reply_flow
from core.websocket_quote_flow import handle_single_image_quote, handle_multi_image_quote
from core.websocket_debounce_flow import (
debounce_agent_reply,
pick_debounce_seconds,
guess_intent_for_debounce,
looks_like_requirement_text,
rand_between,
msg_has_image_url,
msg_refers_images,
extract_image_urls,
collect_recent_image_urls,
)
from core.websocket_auto_quote_flow import (
cancel_auto_quote_task,
build_auto_quote_signature,
schedule_auto_quote,
)
from core.websocket_system_inquiry_flow import (
load_system_inquiry_rules,
normalize_kw_list,
resolve_system_inquiry_policy,
match_system_inquiry,
handle_system_inquiry,
)
from core.websocket_transfer_flow import transfer_to_human_flow
from core.websocket_outbound_arbiter_flow import (
normalize_reply_semantic_key,
classify_outbound_reply,
template_family,
outbound_arbiter,
)
from core.websocket_followup_flow import (
unreplied_followup_loop,
scan_and_send_unreplied_followups,
compose_ai_scene_reply,
)
from core.websocket_outbound_flow import (
send_reply_flow,
ai_generate_outbound_reply,
ai_guard_outbound_reply,
colloquialize_outbound_reply,
)
from core.websocket_runtime_flow import command_handler_flow, run_client_flow
from core.websocket_workflow_flow import workflow_agent_notify_flow, workflow_send_flow
from core.websocket_connection_flow import connect_flow, receive_messages_flow, handle_message_flow
from core.websocket_send_flow import send_text_flow, send_image_flow, send_message_flow
from core.websocket_callback_flow import post_tianwang_callback_flow
from core.websocket_customer_profile_flow import extract_and_save_customer_info_flow
from core.websocket_message_utils_flow import (
is_transfer_msg,
pick_transfer_greeting,
is_shop_card,
extract_customer_text_from_shop_card_msg,
has_chat_history,
should_ignore,
get_msg_type_name,
to_chinese_text,
)
from core.websocket_dispatch_flow import dispatch_assign_once_flow
from core.websocket_image_entry_flow import handle_image_message_flow
from core.websocket_misc_rules_flow import (
msg_is_price_inquiry,
detect_order_status,
msg_requests_external_contact,
extract_size_pairs_m,
oversize_reply_if_needed,
)
from core.websocket_summary_flow import save_conversation_summary_flow
from core.websocket_helpers_flow import (
fire_and_forget,
prune_seen,
log_inbound_once,
log_outbound_once,
build_customer_message,
touch_customer_last_contact,
push_chat_to_wechat_safe,
)
from core.websocket_logger_setup import setup_logger
# ========== 转接分组映射 ==========
def _get_transfer_group(acc_id: str) -> str:
"""根据店铺 acc_id 获取转接分组 ID。不同店铺对应不同客服分组。"""
from config.config import CONFIG_DIR
config_path = CONFIG_DIR / "transfer_groups.json"
default_group = "20252916034"
try:
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
return cfg.get(acc_id, cfg.get("default", default_group))
except Exception:
logger.debug("读取转接分组配置失败,使用默认分组", exc_info=True)
return default_group
import os
logger = setup_logger()
from db.chat_log_db import log_message as _chat_log
from utils.metrics_tracker import emit as metrics_emit
# 导入 Agent 模块
try:
from core.pydantic_ai_agent import CustomerServiceAgent, CustomerMessage, AgentDeps, _get_shop_type
from db.customer_db import db
from core.workflow import workflow
AGENT_AVAILABLE = True
except Exception as e:
AGENT_AVAILABLE = False
workflow = None
AgentDeps = None
_get_shop_type = lambda acc_id, goods_name: "find_image"
import traceback
logger.info(f"警告: Agent 模块导入失败: {e}")
traceback.print_exc()
logger.info("将使用基础回复功能")
class QingjianAPIClient:
"""轻简API WebSocket客户端"""
def __init__(self, uri=None, enable_agent: bool = True):
from config.config import QINGJIAN_WS_URI
from config.config import IMAGE_MODULE_ENABLED
from config.config import MESSAGE_DEBOUNCE_SECONDS
self.uri = uri or QINGJIAN_WS_URI
self.websocket = None
self.running = True
self.reply_id = "tb001" # 回复时使用的from_id
self.last_msg = None # 保存最后一条消息
self.enable_agent = enable_agent and AGENT_AVAILABLE
self.logger = logger
self.AgentDeps = AgentDeps
self.agent = None
self._replied_msg_ids: deque = deque(maxlen=200) # 已回复消息IDFIFO去重
# 消息防抖:同一客户连续发消息时,等待 N 秒后合并处理
self._DEBOUNCE_SECONDS = MESSAGE_DEBOUNCE_SECONDS if isinstance(MESSAGE_DEBOUNCE_SECONDS, int) else 8
self._adaptive_debounce_enabled = os.getenv("ADAPTIVE_DEBOUNCE_ENABLED", "true").lower() in ("1", "true", "yes")
self._debounce_tasks: dict = {} # customer_key -> asyncio.Task
self._pending_msgs: dict = {} # customer_key -> list[data]
self._image_enabled = IMAGE_MODULE_ENABLED
# 同客户消息串行:保证「发图→这个高清」等顺序,避免误判
self._customer_locks: dict = {} # customer_key -> asyncio.Lock
# agent_reply 并发上限,防止 API 打满
self._agent_semaphore = asyncio.Semaphore(8)
self._pending_images: dict = {}
self._pending_image_tasks: dict = {}
self._auto_quote_tasks: dict = {} # customer_key -> asyncio.Task
self._auto_quote_done_sig: dict = {} # customer_key -> signature同一批内容仅自动触发一次
# 旧版“看图即报价”快速链路(默认关闭,避免与 Agent 批量收集逻辑并发打架)
self._legacy_fast_quote_enabled = os.getenv("LEGACY_FAST_IMAGE_QUOTE", "false").lower() in ("1", "true", "yes")
self._system_inquiry_rules = self._load_system_inquiry_rules()
self._last_reply_sent_at: dict = {} # customer_key -> monotonic ts
self._outbound_semantic_seen: dict = {} # customer_key -> {semantic_key: ts}
self._outbound_class_seen: dict = {} # customer_key -> {reply_class: ts}
self._outbound_template_seen: dict = {} # customer_key -> {template_family: ts}
self._unreplied_followup_sent: dict = {} # customer_key -> monotonic ts补偿消息节流
self._inbound_log_seen: dict = {} # signature -> monotonic ts防重复写入
self._outbound_log_seen: dict = {} # signature -> monotonic ts防重复写入
self._tianwang_callback_url = (
os.getenv("TIANWANG_CALLBACK_URL", "").strip()
or "http://139.199.3.75:18789/api/callback"
)
self._tianwang_agent_name = os.getenv("TIANWANG_AGENT_NAME", "终结者").strip() or "终结者"
self._reply_guard_enabled = os.getenv("AI_REPLY_GUARD_ENABLED", "true").lower() in ("1", "true", "yes")
self._reply_guard_verbose = os.getenv("AI_REPLY_GUARD_VERBOSE", "false").lower() in ("1", "true", "yes")
self._force_ai_generate_reply = os.getenv("FORCE_AI_GENERATE_ALL_REPLIES", "true").lower() in ("1", "true", "yes")
# 延迟加载任务模块(避免循环导入)
self.task_scheduler = None
self.task_manager = None
self.trigger_engine = None
# 多进程分片支持
self.shard_keys: set = set() # 本进程负责的客户 key 集合
self.worker_id = int(os.getenv('AI_CS_WORKER_ID', '0'))
self.worker_count = max(1, int(os.getenv('AI_CS_WORKER_COUNT', '1')))
# 初始化 Agent
if self.enable_agent:
try:
self.agent = CustomerServiceAgent()
logger.info(f"[{self.get_time()}] Agent 初始化成功")
except Exception as e:
logger.info(f"[{self.get_time()}] Agent 初始化失败: {e}")
self.enable_agent = False
# 注册 workflow 消息发送回调供图片AI完成后推送消息用
if workflow:
workflow.register_send_callback(self._workflow_send)
workflow.register_agent_notify_callback(self._workflow_agent_notify)
def _activity_log(self, event: str, **kwargs):
"""统一活动日志,便于按 event 检索完整链路。"""
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,
)
async def _post_tianwang_callback(self, event: str, data: dict, extra: Optional[Dict[str, Any]] = None):
await post_tianwang_callback_flow(self, event, data, extra=extra)
async def connect(self):
await connect_flow(self)
def _customer_key(self, data: dict) -> str:
"""同一店铺+客户 = 同一会话"""
return f"{data.get('acc_id','')}:{data.get('from_id','')}"
def _get_customer_lock(self, key: str) -> asyncio.Lock:
if key not in self._customer_locks:
self._customer_locks[key] = asyncio.Lock()
return self._customer_locks[key]
def _is_owned_by_this_worker(self, customer_key: str) -> bool:
"""
多进程兜底路由:
- 若显式分片存在,用显式分片;
- 否则按 customer_key 哈希到固定 worker避免多进程重复处理同一消息。
"""
if self.shard_keys:
return customer_key in self.shard_keys
if self.worker_count <= 1:
return True
try:
h = int(hashlib.md5(customer_key.encode("utf-8")).hexdigest()[:8], 16)
return (h % self.worker_count) == self.worker_id
except Exception:
return self.worker_id == 0
async def _agent_reply_serialized(self, data: dict):
"""同客户串行 + 全局并发限制,再执行 agent_reply"""
key = self._customer_key(data)
async with self._get_customer_lock(key):
async with self._agent_semaphore:
await self.agent_reply(data)
def _fire_and_forget(self, coro):
fire_and_forget(self, coro)
@staticmethod
def _prune_seen(seen: dict, now_mono: float, ttl_sec: float = 8.0):
prune_seen(seen, now_mono, ttl_sec=ttl_sec)
def _log_inbound_once(self, data: dict):
log_inbound_once(self, data, _chat_log)
def _log_outbound_once(self, original_msg: dict, reply_content: str):
log_outbound_once(self, original_msg, reply_content, _chat_log)
def _build_customer_message(self, data: dict) -> CustomerMessage:
return build_customer_message(self, data, CustomerMessage)
def _touch_customer_last_contact(self, customer_id: str):
touch_customer_last_contact(self, customer_id, db)
def _push_chat_to_wechat_safe(
self,
*,
data: dict,
customer_msg: str,
reply_msg: str,
tag: str,
goods_name: str = "",
) -> None:
push_chat_to_wechat_safe(
self,
data=data,
customer_msg=customer_msg,
reply_msg=reply_msg,
tag=tag,
goods_name=goods_name,
)
@staticmethod
def _normalize_reply_semantic_key(text: str) -> str:
return normalize_reply_semantic_key(text)
@staticmethod
def _classify_outbound_reply(text: str) -> str:
return classify_outbound_reply(text)
@staticmethod
def _template_family(reply: str) -> str:
return template_family(reply)
def _outbound_arbiter(self, original_msg: dict, reply_content: str, trace_id: str) -> tuple[bool, str]:
return outbound_arbiter(self, original_msg, reply_content, trace_id)
async def _unreplied_followup_loop(self):
await unreplied_followup_loop(self)
async def _scan_and_send_unreplied_followups(self):
await scan_and_send_unreplied_followups(self)
async def _compose_ai_scene_reply(
self,
*,
original_msg: dict,
scene: str,
intent_hint: str,
fallback: str,
) -> str:
return await compose_ai_scene_reply(
self,
original_msg=original_msg,
scene=scene,
intent_hint=intent_hint,
fallback=fallback,
)
async def receive_messages(self):
await receive_messages_flow(self)
async def handle_message(self, message):
await handle_message_flow(self, message, shop_type_resolver=_get_shop_type)
async def _debounce_agent_reply(self, data: dict):
await debounce_agent_reply(self, data)
@staticmethod
def _rand_between(low: float, high: float) -> float:
return rand_between(low, high)
def _guess_intent_for_debounce(self, msg: str) -> str:
return guess_intent_for_debounce(self, msg)
@staticmethod
def _looks_like_requirement_text(msg: str) -> bool:
return looks_like_requirement_text(msg)
def _pick_debounce_seconds(self, data: dict, msg: str) -> float:
return pick_debounce_seconds(self, data, msg)
def _msg_has_image_url(self, msg: str) -> bool:
return msg_has_image_url(msg)
def _msg_refers_images(self, msg: str) -> bool:
return msg_refers_images(msg)
def _extract_image_urls(self, msg: str) -> list:
return extract_image_urls(msg)
def _collect_recent_image_urls(self, customer_id: str, acc_id: str, max_count: int = 6) -> list:
return collect_recent_image_urls(self, customer_id, acc_id, max_count=max_count)
def _msg_is_requirement(self, msg: str) -> bool:
if not msg:
return False
kws = (
"", "抓到", "放到", "合成", "替换", "", "", "高清", "尺寸", "", "", "颜色", "去背景", "排版", "一样", "类似", "同款",
"能不能做", "能做吗", "可以做吗", "做不做", "这个能做吗", "这个能不能做",
)
return any(k in msg for k in kws)
def _add_pending_images(self, key: str, urls: list, limit: int = 12):
if not urls:
return
cur = self._pending_images.get(key) or []
for u in urls:
if u not in cur:
cur.append(u)
if len(cur) >= limit:
break
self._pending_images[key] = cur
async def _flush_pending_images(self, key: str, data: dict):
urls = self._pending_images.get(key) or []
if not urls:
return
self._pending_images[key] = []
if len(urls) == 1:
await self._analyze_single_and_reply(data, urls[0])
else:
await self._analyze_multi_and_reply(data, urls)
def _msg_is_price_inquiry(self, msg: str) -> bool:
return msg_is_price_inquiry(msg)
def _detect_order_status(self, msg: str) -> str:
return detect_order_status(msg)
async def _analyze_single_and_reply(self, data: dict, url: str):
await handle_single_image_quote(self, data, url)
async def agent_reply(self, data: dict):
"""使用 Agent 处理消息并回复"""
await handle_agent_reply_flow(
self,
data,
workflow=workflow,
shop_type_resolver=_get_shop_type,
)
def _cancel_auto_quote_task(self, key: str, reason: str = ""):
cancel_auto_quote_task(self, key, reason=reason)
@staticmethod
def _build_auto_quote_signature(state: Any) -> str:
return build_auto_quote_signature(state)
async def _maybe_schedule_auto_quote(self, data: dict):
await schedule_auto_quote(self, data, shop_type_resolver=_get_shop_type)
async def _analyze_multi_and_reply(self, data: dict, urls: list):
await handle_multi_image_quote(self, data, urls)
def _msg_requests_external_contact(self, msg: str) -> bool:
return msg_requests_external_contact(msg)
@staticmethod
def _extract_size_pairs_m(msg: str) -> list[tuple[float, float]]:
return extract_size_pairs_m(msg)
def _oversize_reply_if_needed(self, msg: str) -> str:
return oversize_reply_if_needed(msg)
def _is_transfer_msg(self, data: dict) -> bool:
return is_transfer_msg(self, data)
def _pick_transfer_greeting(self) -> str:
return pick_transfer_greeting()
def _is_shop_card(self, data: dict) -> bool:
return is_shop_card(self, data)
def _extract_customer_text_from_shop_card_msg(self, msg: str) -> str:
return extract_customer_text_from_shop_card_msg(self, msg)
def _has_chat_history(self, customer_id: str, acc_id: str = "") -> bool:
return has_chat_history(customer_id, acc_id=acc_id)
def _load_system_inquiry_rules(self) -> Dict[str, Any]:
return load_system_inquiry_rules()
@staticmethod
def _normalize_kw_list(v: Any) -> List[str]:
return normalize_kw_list(v)
def _resolve_system_inquiry_policy(self, acc_id: str) -> Dict[str, Any]:
return resolve_system_inquiry_policy(self, acc_id)
def _match_system_inquiry(self, data: dict, policy: Dict[str, Any]) -> bool:
return match_system_inquiry(self, data, policy)
async def _handle_system_inquiry(self, data: dict) -> bool:
return await handle_system_inquiry(self, data)
def _should_ignore(self, data: dict) -> bool:
return should_ignore(self, data)
def get_msg_type_name(self, msg_type):
return get_msg_type_name(msg_type)
def _extract_and_save_customer_info(self, message: str, customer_id: str):
extract_and_save_customer_info_flow(self, message, customer_id, db)
def to_chinese(self, text):
return to_chinese_text(text)
async def handle_image_message(self, data: dict):
await handle_image_message_flow(self, data)
async def _dispatch_assign_once(self) -> Dict[str, Any]:
return await dispatch_assign_once_flow(self)
async def transfer_to_human(self, data: dict, transfer_msg: str = ""):
await transfer_to_human_flow(
self,
data,
transfer_msg=transfer_msg,
transfer_group_resolver=_get_transfer_group,
)
async def _save_conversation_summary(self, customer_id: str, buyer_msg: str, agent_reply: str):
await save_conversation_summary_flow(self, customer_id, buyer_msg, agent_reply)
async def _workflow_agent_notify(
self,
customer_id: str,
acc_id: str,
acc_type: str,
system_hint: str,
):
await workflow_agent_notify_flow(self, customer_id, acc_id, acc_type, system_hint)
async def _workflow_send(
self,
customer_id: str,
acc_id: str,
acc_type: str,
content: str,
msg_type: int = 0
):
await workflow_send_flow(self, customer_id, acc_id, acc_type, content, msg_type=msg_type)
async def send_reply(self, original_msg, reply_content):
await send_reply_flow(self, original_msg, reply_content)
async def _ai_generate_outbound_reply(self, original_msg: dict, reply_content: str) -> str:
return await ai_generate_outbound_reply(self, original_msg, reply_content)
def _colloquialize_outbound_reply(self, text: Any) -> Any:
return colloquialize_outbound_reply(text)
async def _ai_guard_outbound_reply(self, original_msg: dict, reply_content: str) -> tuple[bool, str, str]:
return await ai_guard_outbound_reply(self, original_msg, reply_content)
async def send_text(self, cy_id, acc_type, content):
await send_text_flow(self, cy_id, acc_type, content)
async def send_image(self, cy_id, acc_type, image_path):
await send_image_flow(self, cy_id, acc_type, image_path)
async def send_message(self, message):
await send_message_flow(self, message)
async def auto_reply(self, data):
"""自动回复示例(已弃用,使用 agent_reply 替代)"""
pass
async def command_handler(self):
await command_handler_flow(self)
def get_time(self):
"""获取当前时间字符串"""
return datetime.now().strftime("%H:%M:%S")
async def run(self):
await run_client_flow(self)
if __name__ == "__main__":
import sys
# 检查是否有 --no-agent 参数
enable_agent = "--no-agent" not in sys.argv
client = QingjianAPIClient(enable_agent=enable_agent)
try:
asyncio.run(client.run())
except KeyboardInterrupt:
logger.info("\n已停止")

View File

@@ -0,0 +1,45 @@
import re
from datetime import datetime
def extract_and_save_customer_info_flow(client, message: str, customer_id: str, db):
"""从消息中提取客户信息并保存。"""
if not message or not customer_id:
return
email_pattern = r"[\w\.-]+@[\w\.-]+\.\w+"
email_match = re.search(email_pattern, message)
if email_match:
db.update_email(customer_id, email_match.group())
phone_pattern = r"1[3-9]\d{9}"
phone_match = re.search(phone_pattern, message)
if phone_match:
db.update_phone(customer_id, phone_match.group())
wechat_pattern = r"[Vv微信]+号[:]?\s*([\w-]+)"
wechat_match = re.search(wechat_pattern, message)
if wechat_match:
db.update_wechat(customer_id, wechat_match.group(1))
budget_keywords = ["预算", "不超过", "最多", "便宜点", "便宜"]
for keyword in budget_keywords:
if keyword in message:
db.add_personality_tag(customer_id, "关注价格")
break
personality_keywords = {
"爽快": "爽快",
"干脆": "爽快",
"纠结": "纠结",
"墨迹": "纠结",
"砍价": "砍价",
"": "砍价",
}
for keyword, tag in personality_keywords.items():
if keyword in message:
db.add_personality_tag(customer_id, tag)
profile = db.get_customer(customer_id)
profile.last_contact = datetime.now().isoformat()
db.save_customer(profile)

View File

@@ -0,0 +1,265 @@
import asyncio
import logging
import re
import secrets
logger = logging.getLogger("cs_agent")
async def debounce_agent_reply(client, data: dict):
"""
消息防抖:同一客户在 _DEBOUNCE_SECONDS 内的连续消息合并后再处理。
订单通知、付款相关消息不走防抖,立即处理。
"""
msg_body = data.get("msg", "")
key = f"{data.get('acc_id','')}:{data.get('from_id','')}"
client._cancel_auto_quote_task(key, reason="new_inbound")
# 以下情况跳过防抖,立即处理(后台执行,不阻塞接收循环)
immediate_keywords = ["买家已付款", "已付款", "[系统订单信息]"]
if any(kw in msg_body for kw in immediate_keywords):
client._activity_log(
"debounce_bypass_immediate",
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
reason="payment_or_order",
msg=msg_body,
)
client._fire_and_forget(client._agent_reply_serialized(data))
return
# 积攒消息
if key not in client._pending_msgs:
client._pending_msgs[key] = []
client._pending_msgs[key].append(msg_body)
client._activity_log(
"debounce_enqueue",
key=key,
queue_size=len(client._pending_msgs[key]),
msg=msg_body,
)
# 取消上一个等待任务(如果有)
old_task = client._debounce_tasks.get(key)
if old_task and not old_task.done():
old_task.cancel()
debounce_seconds = pick_debounce_seconds(client, data, msg_body)
# 创建新的延迟处理任务
async def _delayed(capture_key, capture_data, wait_s: float):
await asyncio.sleep(wait_s)
msgs = client._pending_msgs.pop(capture_key, [])
if not msgs:
return
if len(msgs) == 1:
merged_msg = msgs[0]
else:
merged_msg = "".join(m for m in msgs if m.strip())
logger.info(f"[{client.get_time()}] 防抖合并 {len(msgs)} 条消息: {merged_msg[:60]}")
client._activity_log(
"debounce_flush",
key=capture_key,
merged_count=len(msgs),
merged_msg=merged_msg,
)
merged_data = dict(capture_data)
merged_data["msg"] = merged_msg
await client._agent_reply_serialized(merged_data)
task = asyncio.create_task(_delayed(key, data, debounce_seconds))
client._debounce_tasks[key] = task
def rand_between(low: float, high: float) -> float:
if high <= low:
return float(low)
# 使用 secrets 增强随机性,避免固定周期导致机械感
span = high - low
return round(low + span * (secrets.randbelow(1000) / 1000.0), 2)
def guess_intent_for_debounce(client, msg: str) -> str:
text = (msg or "").strip()
if not text:
return "unknown"
if msg_has_image_url(text):
return "image"
try:
from utils.intent_analyzer import detect_intent
decision = detect_intent(text)
intent = decision.intent
if intent:
client._activity_log(
"debounce_intent_detected",
intent=intent,
source=decision.source,
score=round(float(decision.score or 0.0), 4),
msg=text[:120],
)
except Exception:
intent = ""
if intent:
return intent
lower = text.lower()
if any(k in lower for k in ["报价", "多少钱", "价格", "", "优惠", "收费", "怎么收费", "咋收费"]):
return "询价"
if any(k in lower for k in ["做一下", "改一下", "需求", "门头", "上面的字", "处理"]):
return "修改"
if any(k in lower for k in ["在吗", "你好", "有人"]):
return "打招呼"
return "unknown"
def looks_like_requirement_text(msg: str) -> bool:
text = (msg or "").strip().lower()
if not text:
return False
req_kw = (
"做一下",
"改一下",
"处理一下",
"这个字",
"上面的字",
"门头",
"去背景",
"抠图",
"换色",
"调色",
"清晰",
"高清",
"尺寸",
"比例",
"横版",
"竖版",
"排版",
"改字",
"按这个做",
"照这个做",
"就这张",
"看看做",
"弄一下",
)
return any(k in text for k in req_kw)
def pick_debounce_seconds(client, data: dict, msg: str) -> float:
"""意图驱动防抖:不同意图不同等待区间,并引入轻微随机。"""
base = max(1.0, float(client._DEBOUNCE_SECONDS))
if not client._adaptive_debounce_enabled:
return base
intent = guess_intent_for_debounce(client, msg)
is_req = looks_like_requirement_text(msg)
has_img = msg_has_image_url(msg)
# 区间策略:越明确、越短消息,等待越短;需求描述类稍长
if intent == "打招呼":
low, high = 1.0, min(3.0, base)
elif intent in ("询价", "砍价"):
# 询价先略等一会,给客户补发图片/需求的窗口,减少机械两连回
low, high = 4.0, min(7.0, max(base, 7.0))
elif intent == "image":
# 文本里直接贴图链接:短等合并上下文,避免和上一条询价并发
low, high = 2.2, 4.2
elif intent in ("修改", "批量"):
low, high = max(3.0, base * 0.65), min(18.0, base + 2.0)
elif intent == "转接":
low, high = 1.0, 2.5
else:
low, high = max(2.0, base * 0.5), base
# 发图后的需求描述,优先“多等一点”收集完整需求,减少半句回复
# 约束到 12-14s避免等待过长。
if is_req and not has_img:
low = max(low, 12.0)
high = min(14.0, max(high, 12.6))
# 短句更快,长句稍慢,避免把连续半句拆开
text_len = len((msg or "").strip())
if text_len <= 4:
high = min(high, max(low + 0.2, 2.5))
elif text_len >= 18:
low = min(high, low + 0.6)
wait_s = rand_between(low, high)
logger.info(f"防抖等待 {wait_s}s | intent={intent} | len={text_len}")
return wait_s
def msg_has_image_url(msg: str) -> bool:
"""判断文本消息里是否包含图片URL客户粘贴了图片链接可能带前缀文字如 有吗#*#https://..."""
if not msg:
return False
lower = msg.lower()
image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
image_hosts = ("alicdn.com", "imgextra", "taobao.com", "jd.com", "pinduoduo.com")
if "http://" in lower or "https://" in lower:
if any(ext in lower for ext in image_exts) or any(h in lower for h in image_hosts):
return True
return False
def msg_refers_images(msg: str) -> bool:
"""判断文本是否指代之前的图片(图一/图二/这张/那张/上面那张等)"""
if not msg:
return False
refs = (
"图一",
"图二",
"第一张",
"第二张",
"这张",
"那张",
"这图",
"那个图",
"这个",
"这个呢",
"上面那张",
"下面那张",
"刚才那张",
"上一张",
"下一张",
)
return any(r in msg for r in refs)
def extract_image_urls(msg: str) -> list:
if not msg:
return []
parts = [p.strip() for p in msg.split("#*#") if p.strip()]
urls = []
for p in parts:
if p.startswith("http://") or p.startswith("https://"):
urls.append(p)
if not urls and ("http://" in msg or "https://" in msg):
tokens = re.findall(r"(https?://\S+)", msg)
for t in tokens:
if any(ext in t.lower() for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]):
urls.append(t)
return urls[:8]
def collect_recent_image_urls(client, customer_id: str, acc_id: str, max_count: int = 6) -> list:
"""从最近对话中回溯收集图片URL优先买家消息用于慢发或引用图片的场景"""
urls, seen = [], set()
try:
from db.chat_log_db import get_recent_conversation
recent = get_recent_conversation(customer_id=customer_id, acc_id=acc_id, limit=20)
# 从最近到更早遍历,收集买家(in)消息中的图片链接
for item in reversed(recent):
if item.get("direction") != "in":
continue
message = item.get("message") or ""
found = extract_image_urls(message)
for u in found:
if u not in seen:
seen.add(u)
urls.append(u)
if len(urls) >= max_count:
return urls
except Exception:
logger.debug("收集近期图片URL失败", exc_info=True)
return urls

View File

@@ -0,0 +1,36 @@
import os
async def dispatch_assign_once_flow(client):
"""
调用新的一键派单接口:
GET {DISPATCH_BASE_URL}/assign
Header: X-API-Key
"""
base_url = os.getenv("DISPATCH_BASE_URL", "http://1.12.50.92:8006").strip().rstrip("/")
api_key = os.getenv("DISPATCH_API_KEY", "tuhui_dispatch_key_2026").strip()
timeout_s = float(os.getenv("DISPATCH_TIMEOUT_SECONDS", "5"))
if not base_url or not api_key:
return {"success": False, "reason": "dispatch config missing"}
try:
import httpx
async with httpx.AsyncClient(timeout=timeout_s) as http_client:
resp = await http_client.get(
f"{base_url}/assign",
headers={"X-API-Key": api_key},
)
if resp.status_code != 200:
return {"success": False, "reason": f"http {resp.status_code}"}
data = resp.json() if resp.content else {}
ok = bool((data or {}).get("success", False))
return {
"success": ok,
"task_id": str((data or {}).get("task_id", "") or ""),
"assigned_to": str((data or {}).get("assigned_to", "") or ""),
"online_count": int((data or {}).get("online_count", 0) or 0),
"notification_sent": bool((data or {}).get("notification_sent", False)),
"raw": data,
}
except Exception as e:
return {"success": False, "reason": str(e)}

View File

@@ -0,0 +1,181 @@
import asyncio
import os
import time
from datetime import datetime, timedelta
import logging
logger = logging.getLogger("cs_agent")
async def unreplied_followup_loop(client):
"""定时补偿:对“最后一条是客户消息且长时间未回复”的会话,补发一次自然跟进。"""
if not client.enable_agent or not client.agent:
return
while client.running:
try:
await asyncio.sleep(max(30, int(os.getenv("UNREPLIED_FOLLOWUP_SCAN_SECONDS", "90"))))
await scan_and_send_unreplied_followups(client)
except asyncio.CancelledError:
break
except Exception as e:
client._activity_log("unreplied_followup_loop_error", error=str(e))
async def scan_and_send_unreplied_followups(client):
from db import chat_log_db as cdb
try:
idle_minutes = max(5, int(os.getenv("UNREPLIED_FOLLOWUP_IDLE_MINUTES", "12")))
max_age_minutes = max(idle_minutes, int(os.getenv("UNREPLIED_FOLLOWUP_MAX_AGE_MINUTES", "180")))
followup_cd = max(300, int(os.getenv("UNREPLIED_FOLLOWUP_COOLDOWN_SECONDS", "3600")))
limit = max(10, int(os.getenv("UNREPLIED_FOLLOWUP_LIMIT", "40")))
except Exception:
idle_minutes, max_age_minutes, followup_cd, limit = 12, 180, 3600, 40
now = datetime.now()
window_start = (now - timedelta(minutes=max_age_minutes)).strftime("%Y-%m-%d %H:%M:%S")
conn = None
try:
conn = cdb._get_conn()
rows = conn.execute(
cdb._sql(
"""
SELECT acc_id, customer_id, MAX(id) AS last_id
FROM chat_logs
WHERE timestamp >= ?
GROUP BY acc_id, customer_id
ORDER BY MAX(id) DESC
LIMIT ?
"""
),
(window_start, limit * 6),
).fetchall()
sessions = [dict(r) for r in rows]
sent = 0
for s in sessions:
if sent >= limit:
break
acc_id = str(s.get("acc_id", "") or "")
cid = str(s.get("customer_id", "") or "")
if not acc_id or not cid:
continue
ckey = f"{acc_id}:{cid}"
if not client._is_owned_by_this_worker(ckey):
continue
last = conn.execute(
cdb._sql(
"""
SELECT id, direction, message, timestamp, customer_name, acc_id, platform
FROM chat_logs
WHERE acc_id = ? AND customer_id = ?
ORDER BY id DESC
LIMIT 1
"""
),
(acc_id, cid),
).fetchone()
if not last:
continue
last = dict(last)
if str(last.get("direction", "")) != "in":
continue
last_ts = last.get("timestamp")
if isinstance(last_ts, datetime):
last_dt = last_ts
else:
last_dt = datetime.strptime(str(last_ts)[:19], "%Y-%m-%d %H:%M:%S")
idle_s = (now - last_dt).total_seconds()
if idle_s < idle_minutes * 60 or idle_s > max_age_minutes * 60:
continue
now_mono = time.monotonic()
if (now_mono - client._unreplied_followup_sent.get(ckey, 0.0)) < followup_cd:
continue
last_msg = str(last.get("message", "") or "").strip().lower()
if last_msg in {"好的", "", "ok", "收到", "", ""}:
continue
followup = await compose_ai_scene_reply(
client,
original_msg={
"acc_id": acc_id,
"from_id": cid,
"from_name": client.to_chinese(last.get("customer_name", "") or cid),
"acc_type": str(last.get("platform", "") or "AliWorkbench"),
"msg": str(last.get("message", "") or ""),
},
scene="unreplied_followup",
intent_hint="客户上一条消息还没接上,先自然承接并请对方补一句当前要处理的图或要求。",
fallback="刚看到你消息了,我在的。你把要处理的图或要求再发我一下,我马上接着看。",
)
fake = {
"acc_id": acc_id,
"from_id": cid,
"from_name": client.to_chinese(last.get("customer_name", "") or cid),
"cy_id": cid,
"cy_name": client.to_chinese(last.get("customer_name", "") or cid),
"acc_type": str(last.get("platform", "") or "AliWorkbench"),
"msg": str(last.get("message", "") or ""),
"msg_type": 0,
}
await client.send_reply(fake, followup)
client._unreplied_followup_sent[ckey] = now_mono
sent += 1
client._activity_log(
"unreplied_followup_sent",
acc_id=acc_id,
customer_id=cid,
idle_seconds=int(idle_s),
last_msg=str(last.get("message", "") or "")[:120],
reply=followup,
)
finally:
try:
if conn:
conn.close()
except Exception:
logger.debug("关闭数据库连接失败", exc_info=True)
async def compose_ai_scene_reply(client, *, original_msg: dict, scene: str, intent_hint: str, fallback: str) -> str:
"""场景化 AI 直接生成回复(不依赖固定模板)。"""
if not client.enable_agent or not client.agent or not client.AgentDeps:
return fallback
try:
deps = client.AgentDeps(
msg_id=str(original_msg.get("msg_id", "") or f"{scene}_gen"),
acc_id=str(original_msg.get("acc_id", "") or ""),
from_id=str(original_msg.get("from_id", "") or ""),
platform=str(original_msg.get("acc_type", "") or ""),
)
customer_msg = client.to_chinese(str(original_msg.get("msg", "") or ""))
prompt = (
"你是淘宝客服,直接生成一条发给客户的话。\n"
f"场景: {scene}\n"
f"意图: {intent_hint}\n"
f"客户原话: {customer_msg}\n"
"要求: 1-2句自然口语不要模板腔不要新增价格/承诺;只输出最终回复。\n"
)
result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
out = str(getattr(result, "output", "") or "").strip()
if not out:
return fallback
if out.startswith("话术|") or "[转移会话]" in out or "TRANSFER_REQUESTED" in out:
return fallback
client._activity_log(
"ai_scene_reply_generated",
acc_id=str(original_msg.get("acc_id", "") or ""),
customer_id=str(original_msg.get("from_id", "") or ""),
scene=scene,
generated=out[:160],
)
return out
except Exception as e:
client._activity_log(
"ai_scene_reply_error",
acc_id=str(original_msg.get("acc_id", "") or ""),
customer_id=str(original_msg.get("from_id", "") or ""),
scene=scene,
error=str(e),
)
return fallback

View File

@@ -0,0 +1,128 @@
import asyncio
import time
from datetime import datetime
def fire_and_forget(client, coro):
"""后台执行协程,不阻塞接收循环;异常会记录到日志。"""
task = asyncio.create_task(coro)
def _done(t):
if t.cancelled():
return
exc = t.exception()
if exc:
client.logger.exception(f"后台任务异常: {exc}")
task.add_done_callback(_done)
def prune_seen(seen: dict, now_mono: float, ttl_sec: float = 8.0):
if len(seen) <= 2000:
return
stale = [k for k, t in seen.items() if (now_mono - t) > ttl_sec]
for k in stale:
seen.pop(k, None)
def log_inbound_once(client, data: dict, chat_log_fn):
"""统一记录入站消息,短窗口去重,避免多分支重复写库。"""
try:
cid = data.get("from_id", "")
if not cid:
return
msg = client.to_chinese(data.get("msg", "") or "")
acc_id = data.get("acc_id", "")
mtype = int(data.get("msg_type", 0) or 0)
now_mono = time.monotonic()
sig = f"{acc_id}|{cid}|{mtype}|{msg}"
last = client._inbound_log_seen.get(sig, 0.0)
if (now_mono - last) < 2.0:
return
client._inbound_log_seen[sig] = now_mono
prune_seen(client._inbound_log_seen, now_mono, ttl_sec=8.0)
chat_log_fn(
cid,
msg,
"in",
customer_name=client.to_chinese(data.get("from_name", "") or data.get("cy_name", "")),
acc_id=acc_id,
platform=data.get("acc_type", ""),
msg_type=mtype,
)
except Exception:
client.logger.debug("入站消息写库失败", exc_info=True)
def log_outbound_once(client, original_msg: dict, reply_content: str, chat_log_fn):
"""统一记录出站消息,短窗口去重,避免重复写库。"""
try:
cid = original_msg.get("from_id", "")
if not cid:
return
msg = reply_content or ""
acc_id = original_msg.get("acc_id", "")
now_mono = time.monotonic()
sig = f"{acc_id}|{cid}|0|{msg}"
last = client._outbound_log_seen.get(sig, 0.0)
if (now_mono - last) < 2.0:
return
client._outbound_log_seen[sig] = now_mono
prune_seen(client._outbound_log_seen, now_mono, ttl_sec=8.0)
chat_log_fn(
cid,
msg,
"out",
customer_name=client.to_chinese(original_msg.get("from_name", "") or original_msg.get("cy_name", "")),
acc_id=acc_id,
platform=original_msg.get("acc_type", ""),
msg_type=0,
)
except Exception:
client.logger.debug("出站消息写库失败", exc_info=True)
def build_customer_message(client, data: dict, customer_message_cls):
"""把原始消息字典转换为 Agent 输入模型。"""
return customer_message_cls(
msg_id=data.get("msg_id", ""),
acc_id=data.get("acc_id", ""),
msg=client.to_chinese(data.get("msg", "")),
from_id=data.get("from_id", ""),
from_name=client.to_chinese(data.get("from_name", "")),
cy_id=data.get("cy_id", ""),
acc_type=data.get("acc_type", ""),
msg_type=data.get("msg_type", 0),
cy_name=client.to_chinese(data.get("cy_name", "")),
goods_name=client.to_chinese(data.get("goods_name", "")) if data.get("goods_name") else None,
goods_order=client.to_chinese(data.get("goods_order", "")) if data.get("goods_order") else None,
)
def touch_customer_last_contact(client, customer_id: str, db):
"""兜底更新客户最后联系时间。"""
if not customer_id:
return
try:
profile = db.get_customer(customer_id)
profile.last_contact = datetime.now().isoformat()
db.save_customer(profile)
except Exception:
client.logger.debug("更新客户最后联系时间失败: customer_id=%s", customer_id, exc_info=True)
def push_chat_to_wechat_safe(client, *, data: dict, customer_msg: str, reply_msg: str, tag: str, goods_name: str = ""):
"""异步推送企微聊天日志,失败不影响主流程。"""
try:
from utils.wechat_chat_log import push_chat_to_wechat
asyncio.create_task(push_chat_to_wechat(
customer_name=client.to_chinese(data.get("from_name", "") or data.get("cy_name", "")),
customer_id=data.get("from_id", ""),
acc_id=data.get("acc_id", ""),
customer_msg=client.to_chinese(customer_msg or ""),
reply_msg=reply_msg or "",
goods_name=goods_name or client.to_chinese(data.get("goods_name", "") or ""),
))
except Exception:
client.logger.debug("推送企微聊天日志失败(%s)", tag, exc_info=True)

View File

@@ -0,0 +1,11 @@
async def handle_image_message_flow(client, data: dict):
"""
处理图片消息。
先回复"我找找"然后把图片URL作为消息内容交给 Agent后台执行
"""
await client.send_reply(data, "我找找")
image_data = dict(data)
image_data["msg"] = f"[客户发来图片] {data.get('msg', '')}"
image_data["msg_type"] = 0
client._fire_and_forget(client._agent_reply_serialized(image_data))

View File

@@ -0,0 +1,123 @@
import json
import logging
logger = logging.getLogger("cs_agent")
async def handle_incoming_message(client, message: str, *, shop_type_resolver):
"""处理单条入站消息(从 websocket_client.py 拆出)。"""
timestamp = client.get_time()
try:
data = json.loads(message)
# 多进程分片检查:确保同一客户只由一个 worker 处理
customer_key = client._customer_key(data)
if not client._is_owned_by_this_worker(customer_key):
return
timestamp = client.get_time()
# 保存最后一条消息用于回复
client.last_msg = data
# 打印格式化的消息
logger.info(f"\n{'='*50}")
logger.info(f"[{timestamp}] 收到新消息:")
logger.info(f"{'='*50}")
logger.info(f" 消息ID: {data.get('msg_id', 'N/A')}")
logger.info(f" 账号ID: {client.to_chinese(data.get('acc_id', 'N/A'))}")
logger.info(f" 发送者ID: {client.to_chinese(data.get('from_id', 'N/A'))}")
logger.info(f" 发送者名称: {client.to_chinese(data.get('from_name', 'N/A'))}")
logger.info(f" 会话ID: {client.to_chinese(data.get('cy_id', 'N/A'))}")
logger.info(f" 平台类型: {data.get('acc_type', 'N/A')}")
logger.info(f" 消息类型: {client.get_msg_type_name(data.get('msg_type', 0))}")
logger.info(f" 消息内容: {client.to_chinese(data.get('msg', 'N/A'))}")
# 显示商品信息(如果有)
if data.get('goods_name'):
logger.info(f" 商品名称: {client.to_chinese(data.get('goods_name', ''))}")
if data.get('goods_order'):
logger.info(f" 订单信息: {client.to_chinese(data.get('goods_order', ''))}")
logger.info(f"{'='*50}\n")
# 消息去重:同一条消息不重复处理
msg_id = data.get('msg_id', '')
if msg_id and msg_id in client._replied_msg_ids:
logger.info(f"重复消息,跳过: {msg_id}")
return
if msg_id:
client._replied_msg_ids.append(msg_id) # deque 自动淘汰最旧的
# 空消息/无效消息过滤N/A 或关键字段全为空)
from_id = data.get('from_id', '')
acc_id = data.get('acc_id', '')
if not from_id or from_id == 'N/A' or not acc_id or acc_id == 'N/A':
logger.info(f"[{client.get_time()}] 空消息跳过from_id={from_id!r} acc_id={acc_id!r}")
return
client._log_inbound_once(data)
client._fire_and_forget(client._post_tianwang_callback("message_received", data))
# Gemini 店铺:不回复,直接跳过
goods_name = client.to_chinese(data.get('goods_name', '') or '')
if shop_type_resolver(acc_id, goods_name) == "gemini_api":
logger.info(f"[{client.get_time()}] Gemini 店铺消息,跳过")
client._push_chat_to_wechat_safe(
data=data,
customer_msg=data.get('msg', ''),
reply_msg="",
goods_name=goods_name,
tag="gemini店铺跳过",
)
return
# 使用 Agent 自动回复(仅处理文本消息)
if client.enable_agent:
msg_type = data.get('msg_type', 0)
if msg_type == 0:
if client._is_transfer_msg(data):
# 会话转交 → 主动打招呼
logger.info(f"[{client.get_time()}] 收到转交消息,发送问候")
greeting = client._pick_transfer_greeting()
await client.send_reply(data, greeting)
client._push_chat_to_wechat_safe(
data=data,
customer_msg=data.get('msg', ''),
reply_msg=greeting,
tag="转交问候",
)
elif client._is_shop_card(data):
# 进店卡片有历史对话就不回复没有才打招呼Gemini 已在上面统一跳过)
cid = data.get('from_id', '')
acc_id = data.get('acc_id', '')
residual_text = client._extract_customer_text_from_shop_card_msg(data.get('msg', ''))
if residual_text:
logger.info(f"[{client.get_time()}] 进店卡片携带客户文本,转普通消息处理: {residual_text}")
patched = dict(data)
patched['msg'] = residual_text
await client._debounce_agent_reply(patched)
elif client._has_chat_history(cid, acc_id=acc_id):
logger.info(f"[{client.get_time()}] 进店卡片(已有记录),跳过")
else:
logger.info(f"[{client.get_time()}] 进店卡片(新客户),发送问候")
greeting = "在呢,发图来我看看"
await client.send_reply(data, greeting)
client._push_chat_to_wechat_safe(
data=data,
customer_msg=data.get('msg', ''),
reply_msg=greeting,
goods_name=goods_name,
tag="进店卡片问候",
)
elif await client._handle_system_inquiry(data):
logger.info(f"[{client.get_time()}] 系统客服询单消息,已按规则处理")
elif client._should_ignore(data):
logger.info(f"[{client.get_time()}] 系统通知,跳过回复")
else:
await client._debounce_agent_reply(data)
elif msg_type == 1:
# 图片消息直接处理,不走防抖(图片不会连续多发)
await client.handle_image_message(data)
except json.JSONDecodeError:
logger.info(f"[{timestamp}] 收到非JSON消息: {message}")

View File

@@ -0,0 +1,98 @@
import json
import random
import re
def to_chinese_text(text):
"""处理文本,安全地转换 unicode 转义。"""
if not isinstance(text, str):
return text
if "\\u" not in text:
return text
try:
return json.loads(f'"{text}"')
except Exception:
return text
def is_transfer_msg(client, data: dict) -> bool:
msg = to_chinese_text(data.get("msg", ""))
return "转交给" in msg or "转接给" in msg
def pick_transfer_greeting() -> str:
choices = [
"在的亲,发图我看下",
"在呢亲,有需求直接说",
"我在的,您把要求发我",
"在的哈,你说我这边看着处理",
"在呢,图和需求发来我看看",
]
return random.choice(choices)
def is_shop_card(client, data: dict) -> bool:
msg = to_chinese_text(data.get("msg", ""))
return msg.startswith("[进店卡片]") or "我想咨询你们店的这个商品" in msg
def extract_customer_text_from_shop_card_msg(client, msg: str) -> str:
text = to_chinese_text(msg or "").strip()
if not text:
return ""
parts = [p.strip() for p in text.split("#*#") if p and p.strip()]
kept = []
for part in parts:
if part.startswith("[进店卡片]") or "我想咨询你们店的这个商品" in part:
continue
kept.append(part)
if kept:
return " ".join(kept).strip()
stripped = re.sub(r"\[进店卡片\][^\n\r]*", "", text).strip()
stripped = stripped.replace("我想咨询你们店的这个商品", "").strip(",。,#* ")
return stripped
def has_chat_history(customer_id: str, acc_id: str = "") -> bool:
if not customer_id:
return False
try:
from db.chat_log_db import get_recent_conversation
msgs = get_recent_conversation(customer_id, acc_id=acc_id, limit=1)
return len(msgs) > 0
except Exception:
return False
def should_ignore(client, data: dict) -> bool:
msg = to_chinese_text(data.get("msg", ""))
ignore_patterns = [
"已转接",
"接入会话",
"结束会话",
"会话已",
"[系统消息]",
"[系统通知]",
]
for pattern in ignore_patterns:
if pattern in msg:
return True
acc_id = data.get("acc_id", "")
from_id = data.get("from_id", "")
if acc_id and from_id and acc_id == from_id:
return True
return False
def get_msg_type_name(msg_type):
types = {
0: "文本",
1: "图片",
2: "视频",
3: "文件",
}
return types.get(msg_type, f"未知({msg_type})")

View File

@@ -0,0 +1,84 @@
import re
from typing import Any
def msg_is_price_inquiry(msg: str) -> bool:
if not msg:
return False
patterns = ("多少钱", "多少一张", "一张多少钱", "画图多少", "报价", "给个价", "几块", "多少钱")
return any(p in msg for p in patterns)
def detect_order_status(msg: str) -> str:
if not msg:
return ""
s = msg
if "买家已付款" in s or "已付款" in s:
return "paid"
if "[系统订单信息]" in s:
if "等待买家付款" in s or "未付款" in s:
return "waiting"
return "order"
return ""
def msg_requests_external_contact(msg: str) -> bool:
if not msg:
return False
lower = msg.lower()
kws = ("加qq", "qq号", "vx", "微信", "加v", "联系方式", "私聊", "加一下", "加个", "手机号", "电话", "加群", "q q", "v 信")
return any(k in lower for k in kws)
def extract_size_pairs_m(msg: str) -> list[tuple[float, float]]:
"""提取消息中的米制尺寸对,如 15*6.4米 / 15米*6.4 / 15x6.4m。"""
if not msg:
return []
s = (msg or "").lower().replace("×", "*").replace("x", "*")
pairs = []
patterns = [
r"(\d+(?:\.\d+)?)\s*\*\s*(\d+(?:\.\d+)?)\s*(?:米|m)\b",
r"(\d+(?:\.\d+)?)\s*(?:米|m)\s*\*\s*(\d+(?:\.\d+)?)\b",
]
for p in patterns:
for m in re.findall(p, s):
try:
a = float(m[0])
b = float(m[1])
if a > 0 and b > 0:
pairs.append((a, b))
except Exception:
continue
return pairs
def oversize_reply_if_needed(msg: str) -> str:
"""
检测超大尺寸需求并返回拒绝话术;未命中返回空字符串。
规则:最长边 > 阈值 或 面积 > 阈值。
"""
try:
from config.config import MAX_SERVICE_SIZE_LONGEST_METERS, MAX_SERVICE_SIZE_AREA_SQM
longest_limit = float(MAX_SERVICE_SIZE_LONGEST_METERS)
area_limit = float(MAX_SERVICE_SIZE_AREA_SQM)
except Exception:
longest_limit = 10.0
area_limit = 20.0
pairs = extract_size_pairs_m(msg)
for w, h in pairs:
longest = max(w, h)
area = w * h
if longest > longest_limit or area > area_limit:
return (
f"{w:g}米*{h:g}米这个尺寸太大了,我们这边做不了。"
"如果要做可以拆成几段小尺寸,我再给你按段评估。"
)
return ""
def build_auto_quote_signature(state: Any) -> str:
from core.websocket_auto_quote_flow import build_auto_quote_signature as _build
return _build(state)

View File

@@ -0,0 +1,130 @@
import os
import re
import time
def normalize_reply_semantic_key(text: str) -> str:
s = (text or "").strip().lower()
if not s:
return ""
for w in ("", "", "", "", "", "", ""):
s = s.replace(w, "")
s = re.sub(r"[,。!?、,.!?:\s~\-—_]+", "", s)
return s[:200]
def classify_outbound_reply(text: str) -> str:
s = (text or "").strip()
if not s:
return "empty"
if any(k in s for k in ("报价", "总价", "多少钱", "多少", "马上给你报价", "先给你报")):
return "quote"
if any(k in s for k in ("继续发图", "发完", "发图", "把图发", "先看图")):
return "collect"
if any(k in s for k in ("在吗", "你好", "在的", "在呢")):
return "greeting"
if any(k in s for k in ("转人工", "转接", "转给")):
return "transfer"
if any(k in s for k in ("稍等", "我先看", "看一下", "看下")):
return "ack"
return "general"
def template_family(reply: str) -> str:
s = (reply or "").strip()
if not s:
return ""
if "需求我记上了" in s and "继续发图" in s:
return "collect_remind"
if ("这批图过一遍" in s or "收齐了" in s or "收好了" in s) and ("总价" in s or "报价" in s):
return "quote_defer"
if "图片收到了" in s and "继续发" in s:
return "collect_ack"
if "好嘞,你稍等下,我这边看一下" in s:
return "fallback_ack"
return ""
def outbound_arbiter(client, original_msg: dict, reply_content: str, trace_id: str) -> tuple[bool, str]:
"""
统一出站裁决层:
1) 语义去重(相同语义短窗口不重复);
2) 同类回复节流(同类话术短窗口不重复)。
"""
key = f"{original_msg.get('acc_id', '')}:{original_msg.get('from_id', '')}"
now_mono = time.monotonic()
sem_key = normalize_reply_semantic_key(reply_content)
reply_class = classify_outbound_reply(reply_content)
try:
sem_window = max(30, int(os.getenv("AI_OUTBOUND_SEMANTIC_DEDUPE_SECONDS", "180")))
except Exception:
sem_window = 180
try:
class_window = max(20, int(os.getenv("AI_OUTBOUND_CLASS_DEDUPE_SECONDS", "90")))
except Exception:
class_window = 90
try:
template_window = max(120, int(os.getenv("AI_OUTBOUND_TEMPLATE_FATIGUE_SECONDS", "600")))
except Exception:
template_window = 600
sem_bucket = client._outbound_semantic_seen.setdefault(key, {})
cls_bucket = client._outbound_class_seen.setdefault(key, {})
tpl_bucket = client._outbound_template_seen.setdefault(key, {})
client._prune_seen(sem_bucket, now_mono, ttl_sec=max(sem_window * 2, 240))
client._prune_seen(cls_bucket, now_mono, ttl_sec=max(class_window * 2, 180))
client._prune_seen(tpl_bucket, now_mono, ttl_sec=max(template_window * 2, 1200))
if sem_key and (now_mono - sem_bucket.get(sem_key, 0.0)) < sem_window:
client._activity_log(
"outbound_arbiter_block",
trace_id=trace_id,
acc_id=original_msg.get("acc_id", ""),
customer_id=original_msg.get("from_id", ""),
reason="semantic_duplicate",
semantic_key=sem_key[:80],
reply_class=reply_class,
msg=reply_content,
)
return False, "semantic_duplicate"
family = template_family(reply_content)
if family and (now_mono - tpl_bucket.get(family, 0.0)) < template_window:
client._activity_log(
"outbound_arbiter_block",
trace_id=trace_id,
acc_id=original_msg.get("acc_id", ""),
customer_id=original_msg.get("from_id", ""),
reason="template_fatigue",
template_family=family,
msg=reply_content,
)
return False, "template_fatigue"
if reply_class in {"quote", "collect", "ack"} and (now_mono - cls_bucket.get(reply_class, 0.0)) < class_window:
client._activity_log(
"outbound_arbiter_block",
trace_id=trace_id,
acc_id=original_msg.get("acc_id", ""),
customer_id=original_msg.get("from_id", ""),
reason="class_duplicate",
reply_class=reply_class,
msg=reply_content,
)
return False, "class_duplicate"
if sem_key:
sem_bucket[sem_key] = now_mono
cls_bucket[reply_class] = now_mono
if family:
tpl_bucket[family] = now_mono
client._activity_log(
"outbound_arbiter_pass",
trace_id=trace_id,
acc_id=original_msg.get("acc_id", ""),
customer_id=original_msg.get("from_id", ""),
reply_class=reply_class,
template_family=family,
semantic_key=sem_key[:80] if sem_key else "",
)
return True, "pass"

View File

@@ -0,0 +1,285 @@
import os
import re
import time
from typing import Any
async def send_reply_flow(client, original_msg: dict, reply_content: str):
"""
发送回复消息(从 websocket_client.py 拆出)。
Args:
original_msg: 收到的原始消息字典
reply_content: 回复内容(文本或本地文件路径/http地址
"""
trace_id = original_msg.get("_trace_id", "")
if not client.websocket:
client._activity_log(
"send_reply_skipped",
trace_id=trace_id,
reason="websocket_not_connected",
acc_id=original_msg.get("acc_id", ""),
customer_id=original_msg.get("from_id", ""),
)
return
reply_content = colloquialize_outbound_reply(reply_content)
reply_content = await ai_generate_outbound_reply(
client=client,
original_msg=original_msg,
reply_content=str(reply_content or ""),
)
# 同一客户外发限流N 秒内最多 1 条
try:
from config.config import OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS
cooldown = max(0, int(OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS))
except Exception:
cooldown = 5
if cooldown > 0:
ckey = f"{original_msg.get('acc_id', '')}:{original_msg.get('from_id', '')}"
now_mono = time.monotonic()
last = client._last_reply_sent_at.get(ckey, 0.0)
if (now_mono - last) < cooldown:
client._activity_log(
"send_reply_throttled",
trace_id=trace_id,
key=ckey,
cooldown_s=cooldown,
msg=str(reply_content),
)
return
client._last_reply_sent_at[ckey] = now_mono
shop_id = original_msg.get("acc_id", "")
# 根据轻简API文档
# from_id = 客户ID收消息方
# cy_id = 非群聊时与 from_id 相同
customer_id = original_msg.get("from_id", "")
customer_name = original_msg.get("from_name", "")
allow_send, checked_reply, guard_reason = await ai_guard_outbound_reply(
client=client,
original_msg=original_msg,
reply_content=str(reply_content),
)
client._activity_log(
"reply_guard_decision",
trace_id=trace_id,
acc_id=shop_id,
customer_id=customer_id,
result="ok" if allow_send else "blocked",
reason=guard_reason,
original_reply=str(reply_content),
final_reply=str(checked_reply or ""),
)
if not allow_send:
return
reply_content = checked_reply or str(reply_content)
pass_send, _ = client._outbound_arbiter(
original_msg=original_msg,
reply_content=reply_content,
trace_id=trace_id,
)
if not pass_send:
return
reply = {
"msg_id": "",
"acc_id": shop_id,
"msg": reply_content,
"from_id": customer_id,
"from_name": customer_name,
"cy_id": customer_id,
"acc_type": original_msg.get("acc_type", ""),
"msg_type": 0,
"cy_name": customer_name,
}
client._log_outbound_once(original_msg, str(reply_content))
client._activity_log(
"send_reply_attempt",
trace_id=trace_id,
acc_id=shop_id,
customer_id=customer_id,
msg=str(reply_content),
)
reply["_trace_id"] = trace_id
await client.send_message(reply)
async def ai_generate_outbound_reply(client, original_msg: dict, reply_content: str) -> str:
"""
强制全量 AI 出站生成层:
- 所有普通文本外发先由 AI 生成最终话术;
- 控制命令/纯链接/转接指令直接绕过。
"""
text = (reply_content or "").strip()
if not text:
return text
if text.startswith("话术|") or "[转移会话]" in text or "TRANSFER_REQUESTED" in text:
return text
if re.fullmatch(r"https?://\S+", text):
return text
if not client._force_ai_generate_reply or not client.enable_agent or not client.agent or not client.AgentDeps:
return text
try:
deps = client.AgentDeps(
msg_id=str(original_msg.get("msg_id", "") or "outbound_generate"),
acc_id=str(original_msg.get("acc_id", "") or ""),
from_id=str(original_msg.get("from_id", "") or ""),
platform=str(original_msg.get("acc_type", "") or ""),
)
customer_msg = client.to_chinese(str(original_msg.get("msg", "") or ""))
prompt = (
"你是淘宝客服外发文案生成器。请根据“回复意图草稿”生成最终发给客户的话。\n"
"要求:\n"
"1) 保留原意,不新增价格/承诺/流程;\n"
"2) 自然像真人聊天,不用固定模板句;\n"
"3) 1-2句\n"
"4) 只输出最终回复文本。\n\n"
f"客户原话: {customer_msg}\n"
f"回复意图草稿: {text}\n"
)
result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
out = str(getattr(result, "output", "") or "").strip()
if not out:
return text
if out.startswith("话术|") or "[转移会话]" in out:
return text
client._activity_log(
"ai_generate_reply",
acc_id=str(original_msg.get("acc_id", "") or ""),
customer_id=str(original_msg.get("from_id", "") or ""),
draft=text[:160],
generated=out[:160],
)
return out
except Exception as e:
client._activity_log(
"ai_generate_reply_error",
acc_id=str(original_msg.get("acc_id", "") or ""),
customer_id=str(original_msg.get("from_id", "") or ""),
error=str(e),
)
return text
def colloquialize_outbound_reply(text: Any) -> Any:
"""统一外发口语化处理,避免机械话术。"""
if not isinstance(text, str):
return text
raw = text.strip()
if not raw:
return text
# 控制指令/转接命令不得改写
if raw.startswith("话术|") or "[转移会话]" in raw:
return text
# 纯链接不改
if re.fullmatch(r"https?://\S+", raw):
return text
out = raw
replacements = {
"我这边": "我这边",
"请您": "",
"您好": "你好",
"稍后": "一会儿",
"可以的话": "可以的话",
"请稍等": "稍等哈",
"先不乱报价": "先不急着给你乱报",
"建议转人工评估更稳": "建议转人工看会更稳",
"统一报价": "一起报价",
"马上安排": "马上给你安排",
"确认我就安排": "你点头我就开做",
"收到,我看看哈": "收到,我先看下",
"收到,我找找刚才那几张": "收到,我把刚才那几张一起看下",
"这组图我这边暂时识别不稳定": "这组图我这边识别得不太稳",
"这组图我这边暂时识别异常": "这组图我这边刚才识别有点异常",
"你可以换一张更清晰的,我再给你准报价。": "你换张更清晰的发我,我再给你报准点。",
"你可以换清晰图再发我。": "你换张清晰点的再发我哈。",
"你可以稍后再发我。": "你晚点再发我也行。",
"收到付款,我马上安排处理,有需要第一时间联系您": "收到付款啦,我马上安排处理,有进展第一时间告诉你",
"亲,正在为您转接人工客服,请稍等~": "我这就给你转人工,稍等哈~",
}
for k, v in replacements.items():
out = out.replace(k, v)
return out
async def ai_guard_outbound_reply(client, original_msg: dict, reply_content: str) -> tuple[bool, str, str]:
"""
专用AI质检发送前判断“这句是否该发”可拦截或改写。
读取当前客户在当前店铺的完整对话上下文。
"""
text = (reply_content or "").strip()
if not text:
return False, "", "empty_reply"
if text.startswith("话术|") or "[转移会话]" in text:
return True, text, "command_bypass"
if not client._reply_guard_enabled or not client.enable_agent or not client.agent or not client.AgentDeps:
return True, text, "guard_disabled"
try:
from db.chat_log_db import get_conversation
import json as _json
import re as _re
acc_id = str(original_msg.get("acc_id", "") or "")
customer_id = str(original_msg.get("from_id", "") or "")
if not customer_id:
return True, text, "no_customer_id"
# 默认读取较大窗口,尽量覆盖完整上下文;可用环境变量继续放大。
try:
max_rows = max(50, int(os.getenv("AI_REPLY_GUARD_CONTEXT_ROWS", "500")))
except Exception:
max_rows = 500
rows = get_conversation(customer_id=customer_id, limit=max_rows) or []
shop_rows = [r for r in rows if str(r.get("acc_id", "") or "") == acc_id] if acc_id else rows
context_lines = []
for r in shop_rows:
role = "" if (r.get("direction") == "in") else ""
msg = client.to_chinese((r.get("message") or "").strip())
if msg:
context_lines.append(f"{role}:{msg}")
context_text = "\n".join(context_lines) if context_lines else "无历史"
deps = client.AgentDeps(
msg_id=str(original_msg.get("msg_id", "") or "reply_guard"),
acc_id=acc_id,
from_id=customer_id,
platform=str(original_msg.get("acc_type", "") or ""),
)
prompt = (
"你是淘宝客服回复质检器。目标:判断候选回复是否和上下文一致,是否会造成重复触发式答复。\n"
"必须检查:\n"
"1) 是否答非所问;\n"
"2) 是否重复说“马上报价/继续发图”但当前上下文不需要;\n"
"3) 是否与历史状态冲突;\n"
"4) 语气是否自然可直接发给客户。\n"
"若不合适,给可直接发送的一句改写。\n"
"只输出 JSON{\"allow\":true/false,\"rewrite\":\"...\",\"reason\":\"...\"}\n\n"
f"完整上下文(当前店铺):\n{context_text}\n\n"
f"客户当前消息:{client.to_chinese(original_msg.get('msg', '') or '')}\n"
f"候选回复:{text}\n"
)
result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
raw = str(getattr(result, "output", "") or "").strip()
if not raw:
return True, text, "guard_empty_output"
m = _re.search(r"\{[\s\S]*\}", raw)
if not m:
return True, text, "guard_non_json"
obj = _json.loads(m.group(0))
allow = bool(obj.get("allow", True))
rewrite = str(obj.get("rewrite", "") or "").strip()
reason = str(obj.get("reason", "") or "").strip() or "guard_decision"
if allow:
return True, (rewrite or text), reason
if rewrite:
return True, rewrite, reason
return False, "", reason
except Exception as e:
return True, text, f"guard_error:{e}"

View File

@@ -0,0 +1,128 @@
import asyncio
import logging
logger = logging.getLogger("cs_agent")
async def handle_single_image_quote(client, data: dict, url: str):
try:
from image.image_analyzer import image_analyzer
result = await image_analyzer.analyze(url)
if isinstance(result, dict) and result.get("success", False):
if result.get("feasibility") == "no" or result.get("risk") == "high":
note = str(result.get("note", "") or "")
if "文字内容过于密集" in note or "密集文字" in note:
reply = "这类文字太密的图我们这边不接单,抱歉哈。你要是简化后再发我可以继续看。"
else:
reply = "这张处理风险比较高,我这边先不直接接,建议转人工评估更稳。"
await client.send_reply(data, reply)
return
from config.config import MIN_PRICE_FLOOR
price = result.get("price_suggest", 20)
floor_dyn = result.get("price_min", MIN_PRICE_FLOOR)
floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR)
price = max(floor, round(price / 5) * 5)
try:
from db.customer_db import db as _db
_db.update_last_min_price(data.get('from_id', ''), floor)
except Exception:
logger.debug("更新单图最低价失败", exc_info=True)
reply = f"这张按{price}元,满意再拍"
else:
# 识别失败时不做兜底报价,避免把未识别图片误判为可做
reply = "这张我这边暂时识别不稳定,先不乱报价。你可以换一张更清晰的,我再给你准报价。"
await client.send_reply(data, reply)
except Exception:
logger.exception("单图分析流程失败")
async def handle_multi_image_quote(client, data: dict, urls: list):
try:
from image.image_analyzer import image_analyzer
def _detect_composite_request() -> bool:
try:
from db.chat_log_db import get_recent_conversation
recent = get_recent_conversation(
customer_id=data.get('from_id', ''),
acc_id=data.get('acc_id', ''),
limit=8,
)
keywords = ("抓到", "放到", "合成", "融合", "嵌到", "换到", "替换", "P到", "抠出来放到")
for item in recent:
msg = (item.get("message") or "")
if any(k in msg for k in keywords):
return True
except Exception:
logger.debug("检测合成需求失败,按非合成处理", exc_info=True)
return False
tasks = [image_analyzer.analyze(u) for u in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 先做风险分流:多图中只要出现不可做/高风险,不进入报价
unsafe = []
dense_text_reject = []
for i, result in enumerate(results, 1):
if isinstance(result, dict) and result.get("success", False):
if result.get("feasibility") == "no" or result.get("risk") == "high":
unsafe.append(f"{i}")
note = str(result.get("note", "") or "")
if "文字内容过于密集" in note or "密集文字" in note:
dense_text_reject.append(f"{i}")
if unsafe:
if dense_text_reject and len(dense_text_reject) == len(unsafe):
reply = "这类文字太密的图我们这边不接单,抱歉哈。你要是简化后再发我可以继续看。"
else:
reply = f"这批里{''.join(unsafe)}处理风险较高,我这边先不直接接,建议转人工评估更稳。"
await client.send_reply(data, reply)
return
pairs = []
for u, result in zip(urls, results):
if isinstance(result, dict) and result.get("success", False):
from config.config import MIN_PRICE_FLOOR
floor_dyn = result.get("price_min", MIN_PRICE_FLOOR)
floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR)
price = max(floor, round(result.get("price_suggest", 20) / 5) * 5)
pairs.append((u, price, result.get("category", ""), result.get("megapixels", 0.0)))
try:
if pairs:
floors = []
for _u, result in zip(urls, results):
if isinstance(result, dict) and result.get("success", False):
from config.config import MIN_PRICE_FLOOR
floor_dyn = result.get("price_min", MIN_PRICE_FLOOR)
floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR)
floors.append(floor)
if floors:
from db.customer_db import db as _db
_db.update_last_min_price(data.get('from_id', ''), min(floors))
except Exception:
logger.debug("更新多图最低价失败", exc_info=True)
if not pairs:
await client.send_reply(data, "这组图我这边暂时识别不稳定,先不乱报价。你可以换清晰图再发我。")
return
composite = _detect_composite_request()
composite_fee = 5 if composite else 0
avg_raw = sum(p for _, p, _, _ in pairs) / len(pairs)
from config.config import MIN_PRICE_FLOOR
avg_price = max(MIN_PRICE_FLOOR, round((avg_raw + composite_fee) / 5) * 5)
top_price = max(MIN_PRICE_FLOOR, max(pairs, key=lambda x: x[1])[1] + composite_fee)
count = len(pairs)
if composite:
reply = f"这组{count}张我看了,按{avg_price}元一张;合成那张{top_price}元,满意再拍"
else:
reply = f"这组{count}张我看了,按{avg_price}元一张;复杂那张{top_price}元,满意再拍"
await client.send_reply(data, reply)
except Exception as e:
logger.error("多图分析失败: %s", e)
try:
await client.send_reply(data, "这组图我这边暂时识别异常,先不乱报价。你可以稍后再发我。")
except Exception:
logger.debug("多图分析失败后的兜底回复发送失败", exc_info=True)

View File

@@ -0,0 +1,23 @@
async def save_conversation_summary_flow(client, customer_id: str, buyer_msg: str, agent_reply: str):
"""用 AI 生成一句话对话摘要并持久化。"""
try:
from db.customer_db import db
from openai import AsyncOpenAI
api_client = AsyncOpenAI(
api_key=client.agent.api_key if client.agent else None,
base_url=client.agent.base_url if client.agent else None,
)
resp = await api_client.chat.completions.create(
model=client.agent.model_name if client.agent else "gpt-4o-mini",
messages=[
{"role": "system", "content": "用一句话15字以内总结这段对话的核心内容只输出摘要文字。"},
{"role": "user", "content": f"买家:{buyer_msg}\n客服:{agent_reply}"},
],
max_tokens=30,
temperature=0.3,
)
summary = resp.choices[0].message.content.strip()
db.save_conversation_summary(customer_id, summary)
except Exception:
client.logger.debug("保存对话摘要失败(不影响主流程)", exc_info=True)

View File

@@ -0,0 +1,143 @@
import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, List
from utils.metrics_tracker import emit as metrics_emit
logger = logging.getLogger("cs_agent")
def load_system_inquiry_rules() -> Dict[str, Any]:
"""加载系统客服询单规则(全局 + 店铺覆盖)。"""
from config.config import (
SYSTEM_INQUIRY_ENABLED,
SYSTEM_INQUIRY_DEFAULT_ACTION,
SYSTEM_INQUIRY_DEFAULT_REPLY,
SYSTEM_INQUIRY_RULES_FILE,
)
enabled_env = os.getenv("SYSTEM_INQUIRY_ENABLED")
enabled = (
enabled_env.lower() in ("1", "true", "yes")
if isinstance(enabled_env, str)
else bool(SYSTEM_INQUIRY_ENABLED)
)
action = (os.getenv("SYSTEM_INQUIRY_DEFAULT_ACTION") or SYSTEM_INQUIRY_DEFAULT_ACTION or "silent").strip().lower()
reply = os.getenv("SYSTEM_INQUIRY_DEFAULT_REPLY") or SYSTEM_INQUIRY_DEFAULT_REPLY or ""
rules_file = os.getenv("SYSTEM_INQUIRY_RULES_FILE") or str(SYSTEM_INQUIRY_RULES_FILE)
defaults: Dict[str, Any] = {
"enabled": bool(enabled),
"default_action": action,
"default_reply": reply,
"sender_keywords": ["系统客服", "官方客服", "平台客服", "机器人客服", "商家客服系统"],
"message_keywords": ["系统询单", "代客咨询", "平台代问", "系统代发", "客服询单"],
"shops": {},
}
try:
p = Path(rules_file)
if p.exists():
with p.open("r", encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
defaults.update(loaded)
except Exception as e:
logger.warning("系统询单规则加载失败,使用默认规则: %s", e)
return defaults
def normalize_kw_list(v: Any) -> List[str]:
if not isinstance(v, list):
return []
return [str(x).strip().lower() for x in v if str(x).strip()]
def resolve_system_inquiry_policy(client, acc_id: str) -> Dict[str, Any]:
"""根据店铺合并系统询单策略。"""
from config.config import SYSTEM_INQUIRY_SHOPS
rules = client._system_inquiry_rules or {}
if not bool(rules.get("enabled", True)):
return {"enabled": False}
shops_env = os.getenv("SYSTEM_INQUIRY_SHOPS", SYSTEM_INQUIRY_SHOPS or "")
shop_whitelist = [s.strip() for s in shops_env.split(",") if s.strip()]
if shop_whitelist and (acc_id or "") not in shop_whitelist:
return {"enabled": False}
policy: Dict[str, Any] = {
"enabled": True,
"action": str(rules.get("default_action", "silent")).strip().lower(),
"reply": str(rules.get("default_reply", "")).strip(),
"sender_keywords": normalize_kw_list(rules.get("sender_keywords")),
"message_keywords": normalize_kw_list(rules.get("message_keywords")),
}
shop_cfg = (rules.get("shops") or {}).get(acc_id or "", {})
if isinstance(shop_cfg, dict):
if "enabled" in shop_cfg and not bool(shop_cfg.get("enabled", True)):
return {"enabled": False}
if shop_cfg.get("action"):
policy["action"] = str(shop_cfg.get("action")).strip().lower()
if shop_cfg.get("reply"):
policy["reply"] = str(shop_cfg.get("reply")).strip()
if isinstance(shop_cfg.get("sender_keywords"), list):
policy["sender_keywords"] = normalize_kw_list(shop_cfg.get("sender_keywords"))
if isinstance(shop_cfg.get("message_keywords"), list):
policy["message_keywords"] = normalize_kw_list(shop_cfg.get("message_keywords"))
if policy["action"] not in ("silent", "reply", "transfer"):
policy["action"] = "silent"
return policy
def match_system_inquiry(client, data: dict, policy: Dict[str, Any]) -> bool:
"""识别是否为系统客服询单消息。"""
if not policy.get("enabled", False):
return False
from_name = client.to_chinese(data.get("from_name", "") or "").lower()
from_id = str(data.get("from_id", "") or "").lower()
msg = client.to_chinese(data.get("msg", "") or "").lower()
sender_hits = 0
for kw in policy.get("sender_keywords", []):
if kw and (kw in from_name or kw in from_id):
sender_hits += 1
message_hits = 0
for kw in policy.get("message_keywords", []):
if kw and kw in msg:
message_hits += 1
# 优先看发送者特征;纯文本命中时至少要求两个关键词,降低误判风险
return sender_hits > 0 or message_hits >= 2
async def handle_system_inquiry(client, data: dict) -> bool:
"""命中系统询单后按策略处理。"""
acc_id = data.get("acc_id", "")
policy = resolve_system_inquiry_policy(client, acc_id)
if not match_system_inquiry(client, data, policy):
return False
customer_id = data.get("from_id", "")
metrics_emit("system_inquiry_detected", customer_id=customer_id, acc_id=acc_id)
action = policy.get("action", "silent")
logger.info("系统询单命中 | 店铺:%s | 客户:%s | action:%s", acc_id, customer_id, action)
if action == "reply":
reply = await client._compose_ai_scene_reply(
original_msg=data,
scene="system_inquiry_reply",
intent_hint="这是系统客服询单消息,简短确认已收到并说明会跟进即可。",
fallback=(policy.get("reply") or "您好,这边已收到询单消息,稍后由人工客服跟进处理。"),
)
await client.send_reply(data, reply)
metrics_emit("system_inquiry_auto_reply", customer_id=customer_id, acc_id=acc_id)
return True
if action == "transfer":
await client.transfer_to_human(data, "系统询单转人工")
metrics_emit("system_inquiry_transfer", customer_id=customer_id, acc_id=acc_id)
return True
metrics_emit("system_inquiry_ignored", customer_id=customer_id, acc_id=acc_id)
return True

View File

@@ -0,0 +1,83 @@
import logging
from utils.metrics_tracker import emit as metrics_emit
logger = logging.getLogger("cs_agent")
async def transfer_to_human_flow(client, data: dict, transfer_msg: str = "", *, transfer_group_resolver=None):
"""
转接人工客服。
1. 优先调用 dispatch 服务 GET /assign 一键派单
2. 派单失败时,回退旧版 designer_roster 派单
3. 无人在线或未配置时,回退到 config/transfer_groups.json
设计师在线状态:仅在转人工时按需查询,不轮询。
"""
if not client.websocket:
logger.info("[%s] 错误: 未连接到服务器", client.get_time())
return
acc_id = data.get("acc_id", "")
group_id = None
assigned_to = ""
dispatch_res = await client._dispatch_assign_once()
if dispatch_res.get("success"):
assigned_to = str(dispatch_res.get("assigned_to", "") or "").strip()
logger.info(
"一键派单成功 | task_id=%s | assigned_to=%s | online_count=%s",
dispatch_res.get("task_id", ""),
assigned_to or "未知",
dispatch_res.get("online_count", 0),
)
metrics_emit(
"dispatch_assign_success",
acc_id=acc_id,
assigned_to=assigned_to,
online_count=dispatch_res.get("online_count", 0),
)
else:
logger.warning("一键派单失败,回退旧派单逻辑: %s", dispatch_res.get("reason", "unknown"))
metrics_emit("dispatch_assign_failed", acc_id=acc_id)
# 2. 派单失败时,回退旧版 designer_roster
if not dispatch_res.get("success"):
try:
from utils.designer_roster import poll_and_update_roster
from db.designer_roster_db import get_transfer_group_for_shop
await poll_and_update_roster()
group_id = get_transfer_group_for_shop(acc_id)
except Exception as e:
logger.debug("设计师派单未启用或异常: %s", e)
# 3. 无人在线时企微提醒(新旧两套都没拿到在线结果时)
online_count = int(dispatch_res.get("online_count", 0) or 0)
if online_count <= 0 and not group_id:
try:
from config.config import WECHAT_WEBHOOK
if WECHAT_WEBHOOK:
import httpx
async with httpx.AsyncClient(timeout=5) as c:
resp = await c.post(WECHAT_WEBHOOK, json={
"msgtype": "text",
"text": {"content": "谁在线啊"},
})
if resp.status_code != 200:
logger.warning("企微提醒发送失败: %s %s", resp.status_code, resp.text)
else:
logger.debug("未配置 WECHAT_WEBHOOK跳过企微提醒")
except Exception as e:
logger.warning("企微提醒发送异常: %s", e)
# 4. 构造转接命令:有 assigned_to 用人名,否则回退分组
if assigned_to:
cmd = f"正在为你转接人工|[转移会话],{assigned_to},无原因"
await client.send_reply(data, cmd)
logger.info("[%s] 已发送转接请求 (店铺:%s -> 设计师:%s)", client.get_time(), acc_id or "未知", assigned_to)
return
if not group_id:
group_id = transfer_group_resolver(acc_id) if transfer_group_resolver else "20252916034"
cmd = f"话术|[转移会话],分组{group_id},无原因"
await client.send_reply(data, cmd)
logger.info("[%s] 已发送转接请求 (店铺:%s -> 分组:%s)", client.get_time(), acc_id or "未知", group_id)

View File

@@ -0,0 +1,64 @@
import asyncio
async def workflow_agent_notify_flow(client, customer_id: str, acc_id: str, acc_type: str, system_hint: str):
"""图片处理完成后,让客服 AI 生成自然话术发给客户。"""
if not client.enable_agent or not client.agent:
return
try:
from core.pydantic_ai_agent import CustomerMessage
notify_msg = CustomerMessage(
msg_id="workflow_notify",
acc_id=acc_id,
msg=system_hint,
from_id=customer_id,
from_name="",
cy_id=customer_id,
acc_type=acc_type,
msg_type=0,
cy_name="",
)
response = await client.agent.process_message(notify_msg)
if response.should_reply and response.reply:
nonsense_patterns = [
"无需", "流程已完成", "不需要回复", "无需额外", "已完成",
"无需回复", "不需要额外", "已经完成", "无需再", "操作已完成",
"任务完成", "流程完成", "记录完成", "报价已",
]
if not any(p in response.reply for p in nonsense_patterns):
fake_data = {
"acc_id": acc_id,
"from_id": customer_id,
"from_name": "",
"cy_id": customer_id,
"acc_type": acc_type,
}
await asyncio.sleep(0.5)
await client.send_reply(fake_data, response.reply)
client.logger.info(f"[Workflow] AI 通知已发送: {response.reply}")
except Exception as e:
client.logger.error(f"[Workflow] AI 通知生成失败: {e}")
async def workflow_send_flow(
client,
customer_id: str,
acc_id: str,
acc_type: str,
content: str,
msg_type: int = 0,
):
"""workflow 回调图片AI完成后用此方法推送消息给客户。"""
msg = {
"msg_id": "",
"acc_id": acc_id,
"msg": content,
"from_id": customer_id,
"from_name": customer_id,
"cy_id": customer_id,
"acc_type": acc_type,
"msg_type": msg_type,
"cy_name": customer_id,
}
await client.send_message(msg)

150
legacy/wechat_chat_log.py Normal file
View File

@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
"""
客服对话推送到企业微信群 - 客户消息与AI回复成对发送保持上下文
"""
import asyncio
import os
import logging
from datetime import datetime
import httpx
from dotenv import load_dotenv
load_dotenv()
logger = logging.getLogger("cs_agent")
_last_push: dict[tuple[str, str], tuple[str, str, float]] = {}
def _get_webhook() -> str:
"""优先从 config 读取,与健康检查/日报保持一致"""
try:
from config.config import WECHAT_WEBHOOK
return WECHAT_WEBHOOK or os.getenv("WECHAT_WEBHOOK", "")
except Exception:
return os.getenv("WECHAT_WEBHOOK", "")
def _truncate(text: str, max_len: int = 200) -> str:
"""截断过长内容"""
if not text:
return ""
text = str(text).strip()
if len(text) > max_len:
return text[:max_len] + "..."
return text
def _get_recent_conversation(customer_id: str, acc_id: str, last_n: int = 8) -> list:
"""获取近期对话(同店铺),保持连贯上下文"""
try:
from db.chat_log_db import get_recent_conversation
return get_recent_conversation(customer_id, acc_id, limit=last_n)
except Exception:
logger.debug("[WechatChatLog] 获取近期对话失败,返回空列表", exc_info=True)
return []
async def push_chat_to_wechat(
customer_name: str,
customer_id: str,
acc_id: str,
customer_msg: str,
reply_msg: str,
goods_name: str = "",
):
"""
将客户消息与AI回复推送到企业微信群附带近期对话保持连贯。
"""
webhook = _get_webhook()
if not webhook:
return
# 去重:同一客户+店铺,若客户消息与回复完全相同且在窗口期内,则跳过
try:
import time
key = (customer_id or "", acc_id or "")
now = time.time()
last = _last_push.get(key)
if last:
last_customer_msg, last_reply_msg, last_ts = last
if (last_customer_msg or "") == (customer_msg or "") and (last_reply_msg or "") == (reply_msg or ""):
if now - last_ts < 30:
return
_last_push[key] = ((customer_msg or ""), (reply_msg or ""), now)
except Exception:
logger.debug("[WechatChatLog] 去重检查异常,忽略本次去重", exc_info=True)
reply_msg = _truncate(reply_msg, 300)
ts = datetime.now().strftime("%H:%M")
shop = acc_id or "未知店铺"
name = (customer_name or customer_id or "客户")[:12]
lines = [f"**📩 {ts} | {shop}**"]
if goods_name:
lines.append(f"**商品** {_truncate(goods_name, 80)}")
if customer_id:
lines.append(f"**客户ID** {customer_id}")
lines.append("")
# 附带近期对话,保持连贯
recent = _get_recent_conversation(customer_id, acc_id, last_n=8)
last_line = None
for m in recent:
role = customer_id if m.get("direction") == "in" else "客服"
msg = _truncate((m.get("message") or "").strip(), 120)
if msg:
line = f"{role}{msg}"
# 防止日志中的重复记录在企微里连续刷屏
if line == last_line:
continue
lines.append(line)
last_line = line
# 当前回复(可能已在 recent 中有客户消息,客服回复是新的)
lines.append(f"客服:{reply_msg or '(无回复)'}")
content = "\n".join(lines)
enc = content.encode("utf-8")
if len(enc) > 3800:
content = enc[:3750].decode("utf-8", errors="ignore") + "\n...(略)"
try:
async with httpx.AsyncClient(timeout=8) as client:
resp = await client.post(
webhook,
json={"msgtype": "markdown", "markdown": {"content": content}},
)
data = resp.json()
if data.get("errcode") == 0:
return
else:
logger.warning("[WechatChatLog] 推送失败: %s", data)
except Exception as e:
logger.exception("[WechatChatLog] 推送异常: %s", e)
async def send_morning_startup():
"""每天早上8点发送客服启动消息到企微群"""
webhook = _get_webhook()
if not webhook:
return
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
content = f"**☀️ 客服已启动**\n{ts}"
try:
async with httpx.AsyncClient(timeout=8) as client:
await client.post(
webhook,
json={"msgtype": "markdown", "markdown": {"content": content}},
)
logger.info("[WechatChatLog] 早8点启动消息已发送")
except Exception as e:
logger.exception("[WechatChatLog] 启动消息发送失败: %s", e)
async def morning_startup_scheduler():
"""每天 8:00 发送启动消息"""
logger.info("[WechatChatLog] 早8点启动消息定时任务已启动")
sent_today = None
while True:
now = datetime.now()
today = now.strftime("%Y-%m-%d")
if now.hour == 8 and now.minute == 0 and sent_today != today:
sent_today = today
await send_morning_startup()
await asyncio.sleep(30)

974
legacy/workflow.py Normal file
View File

@@ -0,0 +1,974 @@
"""
客服工作流 + 图片任务状态机
架构说明:
- CustomerServiceWorkflow 负责管理图片处理任务的完整生命周期
- 图片AI接入点调用 workflow.image_ai_submit_result(task_id, result_url)
- 消息回调接口:通过 register_send_callback 注入发送函数
"""
import asyncio
import logging
import os
import uuid
from enum import Enum
from typing import Optional, Dict, Callable, Awaitable, Any, List
from datetime import datetime
from dataclasses import dataclass, field
_WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "")
logger = logging.getLogger("cs_agent")
async def _wechat_notify(content: str):
"""workflow 内部异常推送企业微信"""
if not _WECHAT_WEBHOOK:
return
try:
import httpx
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(_WECHAT_WEBHOOK, json={
"msgtype": "markdown",
"markdown": {"content": content}
})
data = resp.json()
if data.get("errcode") == 0:
logger.info(f"[Workflow通知] 企业微信推送成功 ✓")
else:
logger.info(f"[Workflow通知] 企业微信推送失败: {data}")
except Exception as e:
logger.info(f"[Workflow通知] 推送异常: {e}")
from db.customer_db import db
# ========== 任务状态 ==========
class TaskStatus(Enum):
PENDING = "待处理" # 任务已创建等待图片AI处理
PROCESSING = "处理中" # 图片AI正在处理
AWAITING_CONFIRM = "等待客户确认" # 结果已发给客户,等待确认
REVISION = "修改中" # 客户要求修改,重新处理
COMPLETED = "已完成" # 客户确认,邮件已发
FAILED = "失败" # 处理失败
# ========== 任务数据结构 ==========
@dataclass
class ImageTask:
task_id: str
customer_id: str
customer_name: str
original_image: str # 原图路径或URL
operation: str # 处理操作类型
requirements: str = "" # 客户原始需求描述
result_url: str = "" # 处理结果URL
email: str = "" # 客户邮箱
status: TaskStatus = TaskStatus.PENDING
revision_count: int = 0 # 修改次数
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
def update_status(self, status: TaskStatus):
self.status = status
self.updated_at = datetime.now().isoformat()
# ========== 工作流 ==========
class CustomerServiceWorkflow:
"""
客服工作流
图片AI对接方式
1. 调用 create_image_task() 创建任务,获取 task_id
2. 图片AI处理完成后调用 image_ai_submit_result(task_id, result_url)
3. 工作流自动发图给客户确认,并等待客户回复
"""
def __init__(self):
self.tasks: Dict[str, ImageTask] = {} # task_id -> ImageTask
self.customer_active_task: Dict[str, str] = {} # customer_id -> 最新 task_id
self._send_message: Optional[Callable] = None # 注入的消息发送函数
self._agent_notify: Optional[Callable] = None # 注入的 AI 通知函数
self._pending_analysis: Dict[str, dict] = {} # 待报价的识别结果
# ========== 回调注册(由 websocket_client 调用)==========
def register_agent_notify_callback(self, callback: Callable):
"""
注册 AI 通知回调,图片处理完成时调用 AI 生成消息发给客户。
callback 签名:
async def notify(customer_id, acc_id, acc_type, system_prompt)
"""
self._agent_notify = callback
def register_send_callback(self, callback: Callable[[str, str, str, int], Awaitable[None]]):
"""
注册消息发送回调函数
callback 签名:
async def send(customer_id, acc_id, acc_type, content, msg_type=0)
"""
self._send_message = callback
# ========== 任务管理 ==========
def create_image_task(
self,
customer_id: str,
customer_name: str,
original_image: str,
operation: str,
requirements: str = ""
) -> str:
"""
创建图片处理任务,返回 task_id
图片AI收到此 task_id 后开始处理,完成后调用 image_ai_submit_result
"""
task_id = str(uuid.uuid4())
task = ImageTask(
task_id=task_id,
customer_id=customer_id,
customer_name=customer_name,
original_image=original_image,
operation=operation,
requirements=requirements,
)
self.tasks[task_id] = task
self.customer_active_task[customer_id] = task_id
# 记录需求到客户画像
if requirements:
db.add_requirement(customer_id, requirements)
logger.info(f"[Workflow] 创建任务 {task_id} | 客户: {customer_name} | 操作: {operation}")
return task_id
def get_task(self, task_id: str) -> Optional[ImageTask]:
return self.tasks.get(task_id)
def get_customer_active_task(self, customer_id: str) -> Optional[ImageTask]:
task_id = self.customer_active_task.get(customer_id)
return self.tasks.get(task_id) if task_id else None
# ========== 图片识别AI接入点报价用==========
async def image_analysis_result(
self,
customer_id: str,
image_url: str,
complexity: str,
acc_id: str = "",
acc_type: str = "AliWorkbench",
gemini_prompt: str = "",
aspect_ratio: str = "1:1",
perspective: str = "no",
proc_type: str = "",
subject: str = "",
quality: str = "",
) -> bool:
"""
【图片识别AI专用接口】分析完成后调用此方法触发客服AI报价
Args:
customer_id: 客户ID
image_url: 图片URL原图
complexity: 复杂度评估结果,枚举值:
"simple" → 10-20元
"normal" → 20-30元
"complex" → 30元
"hard" → 40元
acc_id: 店铺账号ID
acc_type: 平台类型
Returns:
True = 成功触发报价False = 客户不存在
"""
price_map = {
"simple": "10-15元这张比较简单",
"normal": "15-20元",
"complex": "20-25元",
"hard": "25-30元",
}
price_hint = price_map.get(complexity, "20元")
# 把所有分析字段存入任务
requirements = f"complexity:{complexity}"
if gemini_prompt:
requirements += f"|prompt:{gemini_prompt}"
if aspect_ratio:
requirements += f"|ratio:{aspect_ratio}"
if perspective and perspective != "no":
requirements += f"|perspective:{perspective}"
if proc_type:
requirements += f"|proc_type:{proc_type}"
if subject:
requirements += f"|subject:{subject}"
if quality:
requirements += f"|quality:{quality}"
task_id = self.create_image_task(
customer_id=customer_id,
customer_name=customer_id,
original_image=image_url,
operation="enhance",
requirements=requirements,
)
logger.info(f"[Workflow] 图片识别完成 | 客户:{customer_id} | 复杂度:{complexity} | 建议报价:{price_hint}")
# 通知客服AI报价把识别结果注入消息让AI根据结果报价
if self._send_message:
# 这里不直接发价格,而是触发 agent 重新处理一条带识别结果的内部消息
# 实际报价由客服AI根据 complexity 生成,保持口吻一致
self._pending_analysis[customer_id] = {
"task_id": task_id,
"complexity": complexity,
"price_hint": price_hint,
"image_url": image_url,
}
return True
def get_pending_analysis(self, customer_id: str) -> dict:
"""
客服AI处理消息时调用检查该客户是否有待报价的识别结果
取出后自动清除(一次性)
"""
return self._pending_analysis.pop(customer_id, None)
# ========== 付款后触发 Gemini 作图 ==========
async def trigger_processing_on_payment(
self,
customer_id: str,
acc_id: str = "",
acc_type: str = "AliWorkbench"
) -> bool:
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
await _wechat_notify(
f" **付款触发但已暂停自动作图**\n客户:{customer_id}\n店铺:{acc_id}\n请人工安排处理"
)
return False
except Exception:
return False
"""
客户付款后调用此方法,找到该客户待处理的任务并启动 Gemini 作图。
由 pydantic_ai_agent 在识别到"已付款"订单通知时调用。
也可作为 tool 由 AI 主动触发。
Returns:
True=已启动处理, False=无待处理任务
"""
task = self.get_customer_active_task(customer_id)
if not task:
# 内存任务丢失(重启场景)→ 从客户档案重建
logger.info(f"[Workflow] 付款触发:内存无任务,尝试从客户档案重建 | 客户: {customer_id}")
task = await self._rebuild_task_from_profile(customer_id, acc_id, acc_type)
if not task:
logger.info(f"[Workflow] 付款触发:客户 {customer_id} 无图片记录,无法重建任务,跳过")
await _wechat_notify(
f"⚠️ **付款但无图片**\n"
f"客户:{customer_id}\n"
f"店铺:{acc_id}\n"
f"已付款但找不到待处理图片,请人工发图处理"
)
return False
if task.status not in (TaskStatus.PENDING,):
logger.info(f"[Workflow] 付款触发:任务 {task.task_id[:8]}... 状态={task.status.value},跳过")
return False
task.operation = task.operation or "enhance"
logger.info(f"[Workflow] 付款确认,启动 Gemini 处理 | 客户: {customer_id} | 任务: {task.task_id[:8]}...")
asyncio.create_task(self._auto_process(task.task_id, acc_id=acc_id, acc_type=acc_type))
return True
async def _rebuild_task_from_profile(
self, customer_id: str, acc_id: str, acc_type: str
) -> Optional["ImageTask"]:
"""
重启后任务丢失时,从客户档案里读取 last_image_url 重建一个 PENDING 任务。
"""
try:
from db.customer_db import db
profile = db.get_customer(customer_id)
image_url = profile.last_image_url
if not image_url:
return None
complexity = profile.complexity_history[-1] if profile.complexity_history else ""
gemini_prompt = getattr(profile, "last_gemini_prompt", "")
aspect_ratio = getattr(profile, "last_aspect_ratio", "1:1")
perspective = getattr(profile, "last_perspective", "no")
requirements = f"complexity:{complexity}" if complexity else ""
if gemini_prompt:
requirements += f"|prompt:{gemini_prompt}"
if aspect_ratio:
requirements += f"|ratio:{aspect_ratio}"
if perspective and perspective != "no":
requirements += f"|perspective:{perspective}"
task_id = str(uuid.uuid4())
task = ImageTask(
task_id=task_id,
customer_id=customer_id,
customer_name=profile.name or customer_id,
original_image=image_url,
operation="enhance",
requirements=requirements,
status=TaskStatus.PENDING,
)
self.tasks[task_id] = task
self.customer_active_task[customer_id] = task_id
logger.info(f"[Workflow] 任务已重建 | 客户: {customer_id} | 图片: {image_url[:60]}...")
return task
except Exception as e:
logger.info(f"[Workflow] 任务重建失败: {e}")
return None
@staticmethod
def _parse_requirements(requirements: str) -> dict:
"""从 requirements 字符串解析各字段,格式: complexity:xxx|prompt:xxx|ratio:xxx"""
parsed = {}
for part in (requirements or "").split("|"):
part = part.strip()
if ":" in part:
k, v = part.split(":", 1)
parsed[k.strip()] = v.strip()
return parsed
async def _auto_process(self, task_id: str, acc_id: str = "", acc_type: str = "AliWorkbench"):
"""付款确认后自动调用 Gemini 处理图片,完成后通知客户"""
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
return
except Exception:
return
task = self.tasks.get(task_id)
if not task:
return
task.update_status(TaskStatus.PROCESSING)
req = self._parse_requirements(task.requirements)
gemini_prompt = req.get("prompt", "")
aspect_ratio = req.get("ratio", "1:1")
perspective = req.get("perspective", "no")
proc_type = req.get("proc_type", "")
subject = req.get("subject", "")
quality = req.get("quality", "")
revision_note = req.get("revision", "")
# 客户修改意见追加到 prompt 末尾
if revision_note:
gemini_prompt = (gemini_prompt or "") + f"\n【客户修改要求】{revision_note}"
logger.info(f"[Workflow] Gemini 开始处理 | 任务: {task_id[:8]}... | 比例: {aspect_ratio} | 透视: {perspective} | 图片: {task.original_image}")
try:
from image.image_processor import image_processor
from utils.image_queue import run_with_queue
result = await run_with_queue(image_processor.process_image(
task.original_image,
task.operation,
requirements=task.requirements,
gemini_prompt=gemini_prompt,
aspect_ratio=aspect_ratio,
perspective=perspective,
proc_type=proc_type,
subject=subject,
quality=quality,
))
if result["success"]:
attempts = result.get("attempts", 1)
qa_score = result.get("qa_score", 0)
qa_pass = result.get("qa_pass", True)
qa_issue = result.get("qa_issue", "")
logger.info(f"[Workflow] Gemini 处理完成 | 任务: {task_id[:8]}... | 质检: {qa_score}分 | 尝试: {attempts}")
# 质检未通过(已达重试上限,保留结果但人工跟进)
if not qa_pass:
await _wechat_notify(
f"⚠️ **图片质检未通过,请人工核查**\n"
f"客户:{task.customer_id}\n"
f"店铺:{acc_id}\n"
f"质检得分:{qa_score}/100\n"
f"问题:{qa_issue}\n"
f"已处理 {attempts} 次,结果已发出,请人工确认质量"
)
await self.image_ai_submit_result(
task_id=task_id,
result_url=result["result_path"],
acc_id=acc_id,
acc_type=acc_type,
)
else:
err_msg = result['message']
logger.info(f"[Workflow] Gemini 处理失败: {err_msg}")
task.update_status(TaskStatus.FAILED)
# 企业微信预警
await _wechat_notify(
f"⚠️ **Gemini作图失败**\n"
f"客户:{task.customer_id}\n"
f"店铺:{acc_id}\n"
f"原因:{err_msg[:200]}\n"
f"请人工跟进"
)
# 通知客户稍等,并告知转人工
if self._send_message:
await self._send_message(
customer_id=task.customer_id,
acc_id=acc_id,
acc_type=acc_type,
content="您好,图片处理遇到点问题,已帮您转接人工客服处理,请稍候",
msg_type=0,
)
except Exception as e:
logger.info(f"[Workflow] 自动处理异常: {e}")
task.update_status(TaskStatus.FAILED)
await _wechat_notify(
f"⚠️ **Workflow处理异常**\n"
f"客户:{task.customer_id}\n"
f"错误:{str(e)[:200]}"
)
# ========== 图片AI接入点作图用==========
async def image_ai_submit_result(
self,
task_id: str,
result_url: str,
acc_id: str = "",
acc_type: str = "AliWorkbench"
) -> bool:
"""
【图片AI专用接口】处理完成后调用此方法
Args:
task_id: create_image_task 返回的任务ID
result_url: 处理后的图片URL或本地路径
acc_id: 店铺账号ID发消息用
acc_type: 平台类型
Returns:
True = 成功False = 任务不存在
"""
task = self.tasks.get(task_id)
if not task:
logger.info(f"[Workflow] 任务不存在: {task_id}")
return False
task.result_url = result_url
task.update_status(TaskStatus.AWAITING_CONFIRM)
logger.info(f"[Workflow] 任务 {task_id} 处理完成,发送给客户确认")
# 先发结果图片
if self._send_message:
await self._send_message(
customer_id=task.customer_id,
acc_id=acc_id,
acc_type=acc_type,
content=result_url,
msg_type=1 # 图片
)
# 让客服 AI 生成完成通知话术(自然口吻,询问邮箱)
if self._agent_notify:
await self._agent_notify(
customer_id=task.customer_id,
acc_id=acc_id,
acc_type=acc_type,
system_hint="【图片已处理完成并发给客户】请用自然口吻告诉客户图发好了让他看一下效果没问题把邮箱发过来你来发给他。不超过1句话。",
)
elif self._send_message:
# 兜底AI 不可用时用固定话术
await self._send_message(
customer_id=task.customer_id,
acc_id=acc_id,
acc_type=acc_type,
content="好了,你看一下效果,没问题把邮箱发我",
msg_type=0,
)
return True
# ========== 客户回复处理 ==========
async def handle_customer_reply(
self,
customer_id: str,
message: str,
acc_id: str = "",
acc_type: str = "AliWorkbench"
) -> Optional[str]:
"""
处理正在等待确认的客户回复
Returns:
需要回复客户的文本None 表示不是确认相关消息
"""
task = self.get_customer_active_task(customer_id)
if not task or task.status != TaskStatus.AWAITING_CONFIRM:
return None
msg = message.strip()
# 提取邮箱
import re
email_match = re.search(r'[\w\.-]+@[\w\.-]+\.\w+', msg)
if email_match:
email = email_match.group()
task.email = email
db.update_email(customer_id, email)
# 发送邮件(调用 email_sender
result = await self._send_email(task)
if result:
task.update_status(TaskStatus.COMPLETED)
db.update_email_status(task.customer_id, "sent")
db.complete_order(task.customer_id, had_revision=task.revision_count > 0)
db.auto_compute_tags(task.customer_id)
return "发到您邮箱了,注意查收哈"
else:
db.update_email_status(task.customer_id, "failed")
return "邮件发送失败了,您再发一次邮箱试试"
# 客户说不满意/要改
negative_keywords = ["不好", "不对", "不满意", "重做", "改一下", "差太多", "不行", "效果不好", "颜色不对"]
if any(kw in msg for kw in negative_keywords):
task.revision_count += 1
task.update_status(TaskStatus.REVISION)
db.record_revision(task.customer_id)
# 把客户的修改意见追加进 requirements下次重做时 Gemini 能看到
if msg:
task.requirements += f"|revision:{msg[:100]}"
return "好,你说一下哪里要改,或者发图告诉我"
# 客户提供了修改说明(处于 REVISION 状态时)
if task.status == TaskStatus.REVISION and msg:
task.requirements += f"|revision:{msg[:100]}"
task.update_status(TaskStatus.PENDING)
# 重新触发处理
asyncio.create_task(
self._auto_process(task.task_id, acc_id=acc_id, acc_type=acc_type)
)
return "好的,重新给你做"
return None
async def _send_email(self, task: ImageTask) -> bool:
"""发送完成作品邮件"""
try:
from mail.email_sender import email_sender
profile = db.get_customer(task.customer_id)
result = email_sender.send_completed_work(
to_email=task.email,
customer_name=profile.name or task.customer_name,
image_description=task.requirements or task.operation,
result_images=[task.result_url]
)
return result.get("success", False)
except Exception as e:
logger.info(f"[Workflow] 邮件发送失败: {e}")
await _wechat_notify(
f"⚠️ **邮件发送失败**\n"
f"客户:{task.customer_id}\n"
f"邮箱:{task.email}\n"
f"错误:{str(e)[:200]}"
)
return False
# ========== 工具方法 ==========
def detect_operation(self, message: str) -> str:
"""根据客户描述识别处理操作"""
msg = message.lower()
if any(kw in msg for kw in ["模糊", "清晰", "高清", "变清"]):
return "enhance"
elif any(kw in msg for kw in ["背景", "去背", "抠图", "透明"]):
return "remove_bg"
elif any(kw in msg for kw in ["尺寸", "大小", "缩放", "分辨率"]):
return "resize"
elif any(kw in msg for kw in ["老照片", "修复", "发黄", "破损"]):
return "fix_old_photo"
elif any(kw in msg for kw in ["分层", "psd"]):
return "layered"
else:
return "enhance"
def get_task_summary(self) -> str:
"""获取当前所有任务摘要(调试用)"""
if not self.tasks:
return "暂无任务"
lines = []
for tid, task in self.tasks.items():
lines.append(
f" [{task.status.value}] {task.customer_name} | {task.operation} | {tid[:8]}..."
)
return "\n".join(lines)
# ========== 客户需求变更 ==========
async def add_customer_requirement(self, task_id: str, customer_id: str,
requirement: str, changed_by: str = 'customer') -> bool:
# 检查任务是否存在
task = self.get_task(task_id)
if not task:
# 尝试从数据库加载
db_task = self.db.get_task(task_id)
if db_task:
logger.info(f"[Workflow] 从数据库加载任务:{task_id[:8]}...")
# 可以在这里重建内存任务
else:
logger.info(f"[Workflow] 任务不存在:{task_id}")
return False
# 添加到数据库
success = self.db.add_customer_note(task_id, requirement, changed_by)
if success:
logger.info(f"[Workflow] 客户添加需求:{task_id[:8]}... | {requirement}")
# 如果任务还在待处理状态,通知 AI 客服
if task and task.status.value == 'pending':
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=task.acc_id,
acc_type=task.acc_type,
content=f"好的,已记录您的需求:{requirement},处理时会注意的",
msg_type=0,
)
return success
async def modify_operation(self, task_id: str, customer_id: str,
new_operation: str, changed_by: str = 'customer') -> bool:
"""
客户修改操作类型
Args:
task_id: 任务 ID
customer_id: 客户 ID
new_operation: 新操作enhance/remove_bg/vectorize 等)
changed_by: 修改者
Returns:
bool: 是否成功
"""
task = self.get_task(task_id)
if not task:
db_task = self.db.get_task(task_id)
if not db_task:
logger.info(f"[Workflow] 任务不存在:{task_id}")
return False
# 检查状态,已处理完成的不允许修改
if task and task.status.value in ['completed', 'processing']:
logger.info(f"[Workflow] 任务已开始处理,不允许修改操作:{task_id}")
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=task.acc_id,
acc_type=task.acc_type,
content="抱歉,图片已经开始处理了,无法修改操作类型",
msg_type=0,
)
return False
# 修改数据库
success = self.db.modify_operation(task_id, new_operation, changed_by)
if success and task:
task.operation = new_operation
logger.info(f"[Workflow] 修改操作类型:{task_id[:8]}... -> {new_operation}")
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=task.acc_id,
acc_type=task.acc_type,
content=f"好的,已为您修改为{new_operation}操作",
msg_type=0,
)
return success
def get_task_requirement_history(self, task_id: str) -> List[dict]:
"""获取任务需求变更历史"""
return self.db.get_requirement_history(task_id)
# ========== 三种工作流 ==========
async def find_image_workflow(self, customer_id: str, image_url: str,
acc_id: str = "", acc_type: str = "AliWorkbench") -> bool:
"""
工作流 1查找图片
客户说"找一下这个图" → 自己处理 → 上传到图绘 → 返回 URL
Args:
customer_id: 客户 ID
image_url: 图片 URL
acc_id: 店铺 ID
acc_type: 平台类型
Returns:
bool: 是否成功
"""
try:
logger.info(f"[Workflow] 启动查找图片工作流 | 客户:{customer_id}")
# 1. 创建任务
task_id = self.create_image_task(
customer_id=customer_id,
customer_name=customer_id,
original_image=image_url,
operation="find", # 查找操作
requirements="type:find",
acc_id=acc_id,
acc_type=acc_type
)
# 2. 这里调用图绘 API 上传图片
# TODO: 调用图绘上传 API
# tuhui_url = await self._upload_to_tuhui(image_url)
# 临时模拟
tuhui_url = f"http://tuhui.cloud/works/123"
# 3. 更新任务结果
self.db.update_result(task_id, tuhui_url)
self.db.update_status(task_id, DBTaskStatus.COMPLETED)
# 4. 回复客户
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=acc_id,
acc_type=acc_type,
content=f"找到了!图片在这里:{tuhui_url}",
msg_type=0,
)
logger.info(f"[Workflow] 查找图片完成 | 客户:{customer_id} | URL: {tuhui_url}")
return True
except Exception as e:
logger.error(f"查找图片工作流失败:{e}")
return False
async def process_image_workflow(self, customer_id: str, image_url: str,
acc_id: str = "", acc_type: str = "AliWorkbench") -> bool:
"""
工作流 2处理图片
客户说"做一下" → 评估图片 → 稍等做
Args:
customer_id: 客户 ID
image_url: 图片 URL
acc_id: 店铺 ID
acc_type: 平台类型
Returns:
bool: 是否成功
"""
try:
logger.info(f"[Workflow] 启动处理图片工作流 | 客户:{customer_id}")
# 1. 创建任务
task_id = self.create_image_task(
customer_id=customer_id,
customer_name=customer_id,
original_image=image_url,
operation="enhance",
requirements="type:process",
acc_id=acc_id,
acc_type=acc_type
)
# 2. 回复客户稍等
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=acc_id,
acc_type=acc_type,
content="稍等,我看看...好的,可以做,马上处理",
msg_type=0,
)
# 3. 启动处理
await self.trigger_processing_on_payment(customer_id, acc_id, acc_type)
logger.info(f"[Workflow] 处理图片已启动 | 客户:{customer_id}")
return True
except Exception as e:
logger.error(f"处理图片工作流失败:{e}")
return False
async def transfer_to_designer_workflow(self, customer_id: str, image_url: str,
acc_id: str = "", acc_type: str = "AliWorkbench",
reason: str = "做不了") -> bool:
"""
工作流 3转人工派单
做不了 → 查询企业微信在线设计师 → 派单
Args:
customer_id: 客户 ID
image_url: 图片 URL
acc_id: 店铺 ID
acc_type: 平台类型
reason: 转接原因
Returns:
bool: 是否成功
"""
try:
logger.info(f"[Workflow] 启动转人工派单工作流 | 客户:{customer_id} | 原因:{reason}")
# 1. 创建任务
task_id = self.create_image_task(
customer_id=customer_id,
customer_name=customer_id,
original_image=image_url,
operation="manual",
requirements=f"type:transfer|reason:{reason}",
acc_id=acc_id,
acc_type=acc_type
)
# 2. 查询企业微信在线设计师
online_designers = await self._get_online_designers()
if not online_designers:
# 无人在线,通知客户
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=acc_id,
acc_type=acc_type,
content="抱歉,现在设计师都不在线,稍后会有人联系您",
msg_type=0,
)
# 企业微信预警
await _wechat_notify(
f"⚠️ **人工派单但无人在线**\n"
f"客户:{customer_id}\n"
f"店铺:{acc_id}\n"
f"原因:{reason}\n"
f"请安排设计师上线"
)
logger.info(f"[Workflow] 无人在线 | 客户:{customer_id}")
return False
# 3. 派单给在线设计师
designer_name = online_designers[0] # 取第一个在线的
success = await self._dispatch_to_designer(task_id, designer_name, customer_id, image_url, reason)
if not success:
logger.error("派单失败")
return False
# 4. 回复客户
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=acc_id,
acc_type=acc_type,
content="好的,已帮您安排设计师处理,请稍候",
msg_type=0,
)
logger.info(f"[Workflow] 已派单给设计师:{designer} | 客户:{customer_id}")
return True
except Exception as e:
logger.error(f"转人工派单工作流失败:{e}")
return False
async def _get_online_designers(self) -> list:
"""
查询在线设计师(使用图绘派单 API
Returns:
list: 在线设计师名单 ["橘子", "婷婷", ...]
"""
try:
designers = await self.dispatch_client.get_online_designers()
logger.info(f"[Workflow] 查询在线设计师:{len(designers)}人在线 | {designers}")
return designers
except Exception as e:
logger.error(f"查询在线设计师失败:{e}")
return []
async def _dispatch_to_designer(self, task_id: str, designer_name: str,
customer_id: str, image_url: str, reason: str) -> bool:
"""
派单给设计师(使用图绘派单 API
Args:
task_id: 任务 ID
designer_name: 设计师姓名
customer_id: 客户 ID
image_url: 图片 URL
reason: 转接原因
Returns:
bool: 是否成功
"""
try:
# 1. 在派单系统创建任务
dispatch_task_id = await self.dispatch_client.create_task(
task_name=f"图片处理-{customer_id[-4:]}",
description=f"{reason}\n客户:{customer_id}\n图片:{image_url}",
task_type="image_process",
priority=2,
deadline=None
)
if not dispatch_task_id:
logger.error("创建派单任务失败")
return False
# 2. 分配给设计师
success = await self.dispatch_client.assign_task(
task_id=dispatch_task_id,
designer_name=designer_name,
notes=f"AI 客服自动派单\n原因:{reason}\n客户:{customer_id}"
)
if success:
logger.info(f"[Workflow] 派单成功:{dispatch_task_id}{designer_name} | 客户:{customer_id}")
# 企业微信通知
await _wechat_notify(
f"📋 **新任务派单**\n"
f"设计师:{designer_name}\n"
f"任务 ID: {dispatch_task_id}\n"
f"客户:{customer_id}\n"
f"原因:{reason}\n"
f"请及时处理"
)
return True
else:
logger.error("分配任务失败")
return False
except Exception as e:
logger.error(f"派单失败:{e}")
return False
# ========== 全局实例 ==========
workflow = CustomerServiceWorkflow()

540
legacy/部署文档.md Normal file
View File

@@ -0,0 +1,540 @@
# AI 客服系统 - 部署与运维文档
**版本**: v1.0 | **更新日期**: 2026-02-28
---
## 目录
1. [系统架构](#系统架构)
2. [快速部署](#快速部署)
3. [启动方式](#启动方式)
4. [生产环境部署](#生产环境部署)
5. [多进程架构](#多进程架构)
6. [API 接口文档](#api-接口文档)
7. [触发条件详解](#触发条件详解)
8. [数据库](#数据库)
9. [配置说明](#配置说明)
10. [监控与日志](#监控与日志)
11. [故障排查](#故障排查)
---
## 系统架构
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ 天网服务器 │ ───→ │ AI 客服 API │ ───→ │ 企业微信 │
│ (公网 IP) │ │ (127.0.0.1:6060)│ │ (轻简软件) │
└─────────────┘ └──────────────┘ └─────────────┘
↑ │
└─────────────────────┘
┌──────────────┐
│ SQLite │
│ 任务数据库 │
└──────────────┘
```
### 核心组件
| 组件 | 地址 | 说明 |
|------|------|------|
| AI 客服 HTTP API | `http://127.0.0.1:6060` | 接收天网任务 |
| 天网服务器 | 公网 IP | 任务调度中心 |
| 轻简软件 | `ws://127.0.0.1:9528` | 企业微信连接 |
| 任务数据库 | SQLite 本地存储 | 任务持久化 |
---
## 快速部署
### 步骤 1环境检查
```bash
python3 --version # 需要 3.8+
cd /root/ai_customer_service/ai_cs
pip3 install -r requirements.txt
```
### 步骤 2启动服务
```bash
cd /root/ai_customer_service/ai_cs
# 前台运行(测试用)
python3 run.py --api-only
# 后台运行(生产用)
nohup python3 run.py --api-only > /tmp/tianwang.log 2>&1 &
```
### 步骤 3验证
```bash
curl http://localhost:6060/api/health
# 预期: {"code":200,"data":{"service":"ai-cs-tianwang-bridge",...},"message":"OK"}
```
---
## 启动方式
统一入口 `run.py`,通过参数切换模式:
```bash
# 仅 HTTP API天网简化版推荐
python3 run.py --api-only
# 完整版HTTP API + WebSocket + AI Agent
python3 run.py --tianwang
# WebSocket 客服模式(默认)
python3 run.py
# 多进程模式
python3 run.py --multi --workers 4
# 不启用 AI Agent
python3 run.py --no-agent
# 指定 HTTP 端口
python3 run.py --api-only --port 8080
```
---
## 生产环境部署
### 方式 1systemd 服务(推荐)
```bash
cat > /etc/systemd/system/ai-cs-tianwang.service << 'SERVICE'
[Unit]
Description=AI Customer Service with Tianwang
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/ai_customer_service/ai_cs
ExecStart=/usr/bin/python3 run.py --api-only
Restart=always
RestartSec=10
LimitNOFILE=65535
Environment="HTTP_API_PORT=6060"
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ai-cs-tianwang
[Install]
WantedBy=multi-user.target
SERVICE
systemctl daemon-reload
systemctl enable ai-cs-tianwang
systemctl start ai-cs-tianwang
systemctl status ai-cs-tianwang
journalctl -u ai-cs-tianwang -f
```
### 方式 2Docker 部署
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 6060
CMD ["python3", "run.py", "--api-only"]
```
```bash
docker build -t ai-cs-tianwang .
docker run -d \
--name ai-cs \
-p 6060:6060 \
-v /root/ai_customer_service/ai_cs/db:/app/db \
--restart unless-stopped \
ai-cs-tianwang
```
### 方式 3后台运行简单场景
```bash
nohup python3 run.py --api-only > /tmp/tianwang.log 2>&1 &
ps aux | grep "run.py"
tail -f /tmp/tianwang.log
pkill -f "run.py" # 停止
```
---
## 多进程架构
### 架构说明
```
单进程(默认) 多进程(可选)
┌─────────────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Python 进程 │ │进程 1 │ │进程 2 │ │进程 3 │
│ asyncio Loop │ │客户 A,B │ │客户 C,D │ │客户 E,F │
│ 所有客户 + Agent │ └─────────┘ └─────────┘ └─────────┘
└─────────────────┘
```
### 使用方法
```bash
# 多进程模式(默认 CPU 核心数)
python3 run.py --multi
# 指定进程数
python3 run.py --multi --workers 4
# 或使用专用启动器
python3 scripts/multi_process_launcher.py --workers 4
```
### 分片算法
客户按 `acc_id:from_id` 的 MD5 hash 值分配到不同进程,同一客户始终在同一进程。
### 性能对比
| 指标 | 单进程 | 多进程 (4 核) |
|------|--------|-------------|
| 并发客户数 | ~50 | ~200 |
| CPU 使用率 | 25% | 80% |
| 故障影响 | 全局 | 局部 |
---
## API 接口文档
### 1. 接收任务
**POST** `/api/task/receive`
```bash
curl -X POST http://localhost:6060/api/task/receive \
-H "Content-Type: application/json" \
-d '{
"task_id": "TASK_20260227_001",
"type": "send_file_after_reply",
"customer": {"id": "customer_123", "name": "小明"},
"trigger": {
"type": "specified_customer_reply",
"customer_id": "customer_123",
"customer_name": "小明",
"keyword": "好的",
"exact_match": false
},
"action": {"type": "send_message", "message": "这是您要的文件"},
"priority": "normal",
"timeout_hours": 24,
"created_by": "设计师 lz"
}'
```
**响应**:
```json
{"code": 200, "message": "任务接收成功", "data": {"task_id": "TASK_20260227_001", "status": "pending"}}
```
### 2. 查询任务状态
**GET** `/api/task/status/:task_id`
```bash
curl http://localhost:6060/api/task/status/TASK_20260227_001
```
### 3. 取消任务
**POST** `/api/task/cancel`
```bash
curl -X POST http://localhost:6060/api/task/cancel \
-H "Content-Type: application/json" \
-d '{"task_id": "TASK_20260227_001", "reason": "客户取消订单"}'
```
### 4. 任务列表
**GET** `/api/task/list`
参数: `customer_id`(可选)、`status`(可选)、`page`(默认1)、`page_size`(默认20)
```bash
curl "http://localhost:6060/api/task/list?status=pending&page=1&page_size=10"
```
### 5. 健康检查
**GET** `/api/health`
```bash
curl http://localhost:6060/api/health
```
---
## 触发条件详解
### 1. specified_customer_reply推荐
指定客户回复指定内容时触发。
```json
{
"trigger": {
"type": "specified_customer_reply",
"customer_id": "customer_123",
"customer_name": "小明",
"keyword": "好的",
"exact_match": false
}
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `customer_id` | 是 | 指定客户 ID |
| `customer_name` | 否 | 指定客户名称 |
| `keyword` | 是 | 回复关键词 |
| `exact_match` | 否 | 是否精确匹配(默认 false|
**exact_match 说明**:
- `false`: 消息**包含**关键词即触发("好的谢谢" 匹配 "好的"
- `true`: 消息**完全等于**关键词才触发
**匹配逻辑**:
```
客户发送消息 → 检查客户 ID → 检查客户名称(可选) → 检查关键词 → 触发
```
### 2. customer_reply
任意客户回复指定内容。
```json
{"trigger": {"type": "customer_reply", "keyword": "好的"}}
```
### 3. customer_keyword
任意客户说某关键词(支持多个)。
```json
{"trigger": {"type": "customer_keyword", "keywords": ["好的", "可以", "行"]}}
```
### 4. customer_payment
客户付款时触发。
```json
{"trigger": {"type": "customer_payment", "keywords": ["已付款", "拍下了"]}}
```
### 5. time_reach
到达指定时间触发。
```json
{"trigger": {"type": "time_reach", "time": "2026-02-28 09:00:00"}}
```
---
## 数据库
### 天网任务数据库
路径: `db/task_db/tasks.db`
```sql
CREATE TABLE tasks (
task_id TEXT PRIMARY KEY,
specified_customer_id TEXT,
specified_customer_name TEXT,
type TEXT NOT NULL,
customer_name TEXT,
customer_id TEXT,
trigger_type TEXT,
trigger_keyword TEXT,
trigger_keywords TEXT,
action_type TEXT,
action_file_url TEXT,
action_message TEXT,
priority TEXT DEFAULT 'normal',
timeout_hours INTEGER DEFAULT 24,
status TEXT DEFAULT 'pending',
retry_count INTEGER DEFAULT 0,
max_retry INTEGER DEFAULT 3,
created_at TEXT,
created_by TEXT,
triggered_at TEXT,
completed_at TEXT,
error_message TEXT,
result TEXT
);
```
**任务状态流转**: `pending → waiting → running → completed / failed`
### 图片任务数据库
路径: `db/image_tasks.db`(详见 **项目功能汇总.md - 图片任务数据库**
---
## 配置说明
### 环境变量
文件: `.env.tianwang`
```bash
AI_CS_HOST=127.0.0.1
AI_CS_PORT=6060
AI_CS_API_URL=http://127.0.0.1:6060
TIANWANG_CALLBACK_URL=http://127.0.0.1:6060/api/task/callback
```
### 天网回调配置
`core/task_scheduler.py` 中修改回调 URL:
```python
await client.post('http://tianwang-server/api/task/callback', json={...})
```
### 端口说明
| 端口 | 用途 |
|------|------|
| 6060 | HTTP API 服务器 |
| 9528 | 轻简软件 WebSocket外部|
**防火墙**:
```bash
firewall-cmd --add-port=6060/tcp --permanent && firewall-cmd --reload
```
---
## 监控与日志
### 查看进程状态
```bash
ps aux | grep "run.py"
netstat -tlnp | grep 6060
systemctl status ai-cs-tianwang # systemd 方式
```
### 查看日志
```bash
tail -f /tmp/tianwang.log # 文件方式
journalctl -u ai-cs-tianwang -f # systemd 方式
grep "任务" /tmp/tianwang.log # 搜索任务日志
grep "派单" /tmp/tianwang.log # 搜索派单日志
grep "转接人工" /tmp/tianwang.log # 搜索转接日志
```
### 查看数据库
```bash
sqlite3 /root/ai_customer_service/ai_cs/db/task_db/tasks.db
SELECT task_id, type, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 10;
SELECT * FROM tasks WHERE status='pending';
SELECT task_id, error_message FROM tasks WHERE status='failed';
SELECT status, COUNT(*) as count FROM tasks GROUP BY status;
.exit
```
---
## 故障排查
### API 无法访问
```bash
ps aux | grep "run.py" # 检查进程
netstat -tlnp | grep 6060 # 检查端口
pkill -f "run.py" # 停止
nohup python3 run.py --api-only > /tmp/tianwang.log 2>&1 &
tail -f /tmp/tianwang.log # 查看日志
```
### 任务接收失败500 错误)
```bash
tail -f /tmp/tianwang.log | grep "ERROR"
sqlite3 db/task_db/tasks.db ".schema tasks" # 检查数据库
# 如果数据库损坏rm db/task_db/tasks.db 然后重启(自动重建)
```
### 任务未触发
```bash
curl http://localhost:6060/api/task/status/TASK_ID # 检查状态
grep "任务触发" /tmp/tianwang.log # 查看触发日志
# 确认客户消息包含触发关键词
```
### 内存占用过高
```bash
ps aux | grep run_tianwang | awk '{print $6/1024 " MB"}'
# 建议每天定时重启
crontab -e
# 添加: 0 3 * * * pkill -f "run.py" && sleep 2 && nohup python3 /root/ai_customer_service/ai_cs/run.py --api-only > /tmp/tianwang.log 2>&1 &
```
### Worker 进程退出(多进程模式)
```bash
journalctl -u ai-cs-multi -f | grep "Worker.*退出"
systemctl restart ai-cs-multi
```
---
## 文件位置速查
| 文件 | 路径 |
|------|------|
| 启动脚本 | `run.py`(通过 `--api-only` / `--tianwang` 切换模式)|
| HTTP API | `api/http_server.py` |
| 任务调度 | `core/task_scheduler.py` |
| 数据模型 | `db/task_db/task_model.py` |
| 配置文件 | `.env.tianwang` |
| 日志文件 | `/tmp/tianwang.log` |
| 任务数据库 | `db/task_db/tasks.db` |
---
## 快速参考
```
┌─────────────────────────────────────────────┐
│ AI 客服 API - 快速参考 │
├─────────────────────────────────────────────┤
│ 地址http://localhost:6060 │
│ │
│ POST /api/task/receive - 接收任务 │
│ GET /api/task/status/:id - 查询状态 │
│ POST /api/task/cancel - 取消任务 │
│ GET /api/task/list - 任务列表 │
│ GET /api/health - 健康检查 │
│ │
│ 启动python3 run.py --api-only │
│ 日志tail -f /tmp/tianwang.log │
│ 数据库sqlite3 db/task_db/tasks.db │
└─────────────────────────────────────────────┘
```

View File

@@ -0,0 +1,476 @@
# AI 客服系统 - 完整功能汇总
**版本**: v1.0 | **更新日期**: 2026-02-28 | **服务器**: 1.12.50.92
---
## 目录
1. [天网协作系统](#天网协作系统)
2. [三种工作流](#三种工作流)
3. [文字检测与加价](#文字检测与加价)
4. [风险评估与接单判断](#风险评估与接单判断)
5. [作图失败转接人工](#作图失败转接人工)
6. [图片任务数据库](#图片任务数据库)
7. [图绘派单系统](#图绘派单系统)
8. [价格策略总览](#价格策略总览)
9. [技术架构](#技术架构)
---
## 天网协作系统
**说明**: 接收天网下发的任务,支持指定客户回复触发。
**API 地址**: `http://127.0.0.1:6060`
**接口列表**:
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/task/receive` | POST | 接收任务 |
| `/api/task/status/:id` | GET | 查询任务状态 |
| `/api/task/cancel` | POST | 取消任务 |
| `/api/task/list` | GET | 任务列表 |
| `/api/health` | GET | 健康检查 |
**触发类型**:
| 类型 | 说明 |
|------|------|
| `specified_customer_reply` | 指定客户回复指定内容(推荐) |
| `customer_reply` | 任意客户回复指定内容 |
| `customer_keyword` | 任意客户说某关键词 |
| `customer_payment` | 客户付款 |
| `time_reach` | 到达指定时间 |
> 详细的 API 接口文档、请求示例、数据库结构等见 **部署文档.md**。
---
## 三种工作流
根据客户说的话,自动判断执行不同的工作流程。
### 工作流 1查找图片
**触发词**: "找一下"、"找图"、"找原图"、"帮我找"、"能找到吗"、"有吗"、"有没有"
```
客户:找一下这个图 [图片]
AI 检测到"找一下"关键词 → 执行查找图片工作流
1. 创建任务operation=find
2. 上传图片到图绘平台
3. 更新任务状态为 completed
AI: 找到了图片在这里http://tuhui.cloud/works/123
```
### 工作流 2处理图片
**触发词**: "做一下"、"处理一下"、"安排"、"开始做"、"弄一下"、"修一下"、"P一下"、"P图"
```
客户:做一下 [图片]
AI 检测到"做一下"关键词 → 执行处理图片工作流
1. 创建任务operation=enhance
2. 回复"稍等,我看看...好的,可以做,马上处理"
3. 启动图片处理流程
AI: 做好了,请查看 [结果图]
```
### 工作流 3转人工派单
**触发词**: "做不了"、"处理不了"、"弄不了"、"无法处理"、"做不到"、"搞不定"
```
AI 判断无法处理 / 客户说"做不了"
执行转人工派单工作流
1. 创建任务operation=manual
2. 查询在线设计师
3. 有人在线 → 派单;无人 → 通知稍后联系
AI: 好的,已帮您安排设计师处理,请稍候
```
### 技术实现
| 组件 | 文件 | 说明 |
|------|------|------|
| 工作流路由器 | `core/workflow_router.py` | 关键词检测与匹配 |
| 工作流执行器 | `core/workflow.py` | 三种工作流的具体实现 |
| 消息处理器 | `core/pydantic_ai_agent.py` | `_handle_image_workflow()` 方法 |
**注意事项**:
- 关键词匹配支持多种说法,自动识别
- 置信度 >0.9 才执行对应工作流
- 无人在线时通知客户稍后联系,企业微信预警
- 所有工作流都保存到数据库
---
## 文字检测与加价
AI 客服自动分析图片中的文字数量,根据文字数量和分层需求自动加价。
### 文字数量加价
| 文字数量 | 加价 |
|----------|------|
| none | +0 元 |
| 少量 (1-10 字) | +5 元 |
| 中量 (11-50 字) | +15 元 |
| 大量 (51-200 字) | +30 元 |
| 极多 (200 字以上) | +50 元 |
### 文字分层需求加价
| 分层需求 | 加价 |
|----------|------|
| no | +0 元 |
| yes有文字| +50 元起 |
| yes无文字| +30 元 |
### 特殊价格
**条件**: 文字数量=大量/极多 且 分层需求=yes → **60-80 元**
### 使用场景
**场景 1**: 少量文字,不分层
- 复杂度 simple + 少量文字 → 15 + 5 = **20 元**
- AI: "这张图比较简单不过有少量文字需要处理20 元。"
**场景 2**: 大量文字,需要分层
- 复杂度 complex + 大量文字 + 分层 → 调整到 **80 元**
- AI: "这张图文字比较多,有 100 多字需要分层文件80 元。"
### 价格计算流程
```
客户发送图片 → 判断基础复杂度 → 检测文字数量 → 询问分层需求
→ 计算总价(基础+文字+分层)→ 特殊价格处理60-80 元)→ 报价
```
### 配置位置
修改价格规则: `image/image_analyzer.py`(查找文字加价相关代码)
**注意事项**:
- 文字数量通过视觉 AI 自动识别
- 分层需求需从对话中识别
- 最终价格必须是 5 的倍数
---
## 风险评估与接单判断
AI 客服自动分析图片风险,判断是否可以接单。
### 敏感内容检测(一票否决)
**敏感内容 = yes → 直接拒绝,不接单**
检测内容: 色情/黄色/擦边/裸露、性暗示、涉政/政治敏感、暴力/血腥、违禁品
**话术**: "这类不做哦" / "不好意思,这个接不了"
**禁止说**: "发图来看看"、过多解释
### 风险等级
| 风险 | 是否接单 | 说明 |
|------|----------|------|
| **none** | ✅ 接单 | 印花/图案/logo/风景/产品,效果稳定 |
| **low** | ✅ 接单 | 有人脸但清晰,需说明风险(相似度 70-90%|
| **high** | ⚠️ 谨慎 | 严重模糊/老照片人像/需打印,需说明限制 |
### 可做判断
| 可做 | 是否接单 | 说明 |
|------|----------|------|
| **yes** | ✅ 接单 | 效果有把握 |
| **partial** | ⚠️ 可接 | 能处理但有限制,需说明风险 |
| **no** | ❌ 不接 | 无法处理(纯黑/纯白/完全损坏/敏感内容)|
### 分析流程
```
客户发送图片 → 敏感内容检测yes→拒绝→ 风险评估none/low/high
→ 可做判断yes/partial/no→ 决策(接单/谨慎/拒绝)→ 回复客户
```
### 话术模板
**高风险提示**:
- "这张比较模糊,修复后清晰了但人脸可能跟原来有差异"
- "老照片修复后人脸可能有轻微变化"
- "建议先看效果确认再打印"
**正常接单**:
- "这个没问题XX 元"
- "可以处理XX 元,满意再付"
### 配置位置
- 风险判断规则: `image/image_analyzer.py`(查找"风险评估""敏感内容检测"
- 拒绝话术: `core/pydantic_ai_agent.py`(查找"拒绝"
---
## 作图失败转接人工
当 AI 作图失败或效果不佳时,系统自动转接人工客服。
### 触发场景
| 场景 | 触发条件 | 话术 |
|------|----------|------|
| AI 作图失败 | API 报错/超时/质量不达标 | "处理遇到点问题,我帮您转接人工" |
| 客户不满意 | 说"效果不好"/"不满意"/要求重做 | "好的,我帮您转接人工客服处理" |
| 特殊要求 | AI 无法处理的复杂需求 | "这个需求比较特殊,帮您转接人工" |
### 转接流程
```
作图失败/客户不满意 → 通知客户 → 转接人工客服 → 企业微信预警
```
### 技术实现
- 失败检测: `core/pydantic_ai_agent.py` 中的 `process_image_gemini` 函数
- 转接工具: `transfer_to_human` tool标记 `need_transfer=True`
**注意事项**:
- 作图失败必须转人工,不自动重试超过 2 次
- 转接前告知客户原因
- 记录转接原因便于后续优化
---
## 图片任务数据库
图片任务保存到 SQLite 数据库,支持持久化和需求变更。
### 数据库表
**image_tasks图片任务表**:
| 字段 | 类型 | 说明 |
|------|------|------|
| task_id | TEXT | 任务 ID主键|
| customer_id | TEXT | 客户 ID |
| original_image | TEXT | 原图 URL |
| operation | TEXT | 操作类型enhance/remove_bg/vectorize|
| requirements | TEXT | 需求 JSON |
| customer_notes | TEXT | 客户备注/需求细节 |
| status | TEXT | 状态 |
| result_image | TEXT | 结果图 URL |
| error_message | TEXT | 错误信息 |
| retry_count | INTEGER | 重试次数 |
| acc_id / acc_type | TEXT | 店铺 ID / 平台类型 |
| created_at / paid_at / completed_at | TEXT | 时间戳 |
**task_requirement_changes需求变更表**:
| 字段 | 类型 | 说明 |
|------|------|------|
| task_id | TEXT | 任务 ID外键|
| change_type | TEXT | 变更类型add_note/modify_operation/add_requirement|
| old_value / new_value | TEXT | 变更前后值 |
| changed_at | TEXT | 变更时间 |
| changed_by | TEXT | 变更者customer/staff|
### 任务状态流转
```
pending待付款→ paid已付款→ processing处理中→ awaiting_confirm待确认→ completed已完成
↘ failed失败
```
### API 接口
```python
# 创建任务
workflow.create_image_task(customer_id, original_image, operation)
# 添加需求
await workflow.add_customer_requirement(task_id, customer_id, requirement)
# 修改操作类型
await workflow.modify_operation(task_id, customer_id, new_operation)
# 查询任务
task = workflow.get_task(task_id)
tasks = workflow.get_customer_tasks(customer_id)
# 查询需求变更历史
history = workflow.get_task_requirement_history(task_id)
```
### 数据库操作
```bash
sqlite3 /root/ai_customer_service/ai_cs/db/image_tasks.db
# 查询所有任务
SELECT task_id, customer_id, status, created_at FROM image_tasks ORDER BY created_at DESC LIMIT 10;
# 查询待处理任务
SELECT * FROM image_tasks WHERE status='pending';
# 查询需求变更
SELECT task_id, change_type, old_value, new_value, changed_at FROM task_requirement_changes WHERE task_id='TASK_001';
```
**注意事项**:
- 所有任务自动保存,重启不丢失
- 付款前可修改操作类型,付款后不允许
- 所有变更都有历史记录
---
## 图绘派单系统
AI 客服系统接入图绘派单系统 API实现自动派单给在线设计师。
### API 信息
| 项目 | 值 |
|------|------|
| API 地址 | `http://1.12.50.92:8005` |
| API Key | `tuhui_dispatch_key_2026` |
| 认证方式 | Header: `X-API-Key` |
### 核心接口
| 接口 | 方法 | 说明 |
|------|------|------|
| `/dispatch/queue` | GET | 获取派单队列 |
| `/online/designers` | GET | 获取在线设计师 |
| `/tasks` | POST | 创建任务 |
| `/tasks/{id}/assign` | POST | 分配任务 |
| `/tasks/{id}` | GET | 查询任务状态 |
| `/tasks/{id}/complete` | POST | 完成任务 |
### 转人工派单流程
```
AI 判断做不了
1. 查询在线设计师 → GET /online/designers → ["橘子", "婷婷"]
2. 创建派单任务 → POST /tasks → {"task_id": "ea853bd9"}
3. 分配给设计师 → POST /tasks/ea853bd9/assign → {"designer_name": "橘子"}
4. 企业微信通知设计师
5. 回复客户:"好的,已帮您安排设计师处理,请稍候"
```
### 代码调用示例
```python
from services.service_tuhui_dispatch import get_tuhui_dispatch_client
client = get_tuhui_dispatch_client()
# 查询在线设计师
designers = await client.get_online_designers() # ["橘子", "婷婷"]
# 创建任务
task_id = await client.create_task(
task_name="图片处理-1234",
description="客户需要做高清修复",
task_type="image_process",
priority=2
)
# 分配任务
await client.assign_task(task_id, designer_name="橘子", notes="AI 客服自动派单")
# 完成任务
await client.complete_task(task_id, notes="客户已确认")
```
### 设计师在线状态 API
```
GET http://huichang.online:8001/online # 查询在线设计师
POST http://huichang.online:8001/update-status # 更新设计师状态
```
### 相关代码位置
| 组件 | 文件 |
|------|------|
| 派单客户端 | `services/service_tuhui_dispatch.py``TuhuiDispatchClient` 类)|
| 工作流集成 | `core/workflow.py``transfer_to_designer_workflow()` 方法)|
---
## 价格策略总览
### 基础价格
| 复杂度 | 价格区间 | 说明 |
|--------|----------|------|
| simple | 10-15 元 | 画面简单干净 |
| normal | 15-20 元 | 一般复杂度 |
| complex | 20-25 元 | 细节偏多 |
| hard | 25-30 元 | 非常复杂 |
### 加价规则
| 项目 | 条件 | 加价 |
|------|------|------|
| 文字少量 | 1-10 字 | +5 元 |
| 文字中量 | 11-50 字 | +15 元 |
| 文字大量 | 51-200 字 | +30 元 |
| 文字极多 | 200+ 字 | +50 元 |
| 分层(有文字)| 需要 PSD 分层 | +50 元起 |
| 分层(无文字)| 仅需分层 | +30 元 |
### 高价值订单
**文字分层 + 大量文字** → 特殊价格 **60-80 元**(封顶)
---
## 技术架构
### 核心组件
| 组件 | 文件 | 说明 |
|------|------|------|
| 天网协作 | `api/http_server.py` | HTTP API 服务器 |
| 工作流程 | `core/workflow.py` | 工作流执行器 |
| AI Agent | `core/pydantic_ai_agent.py` | AI 对话引擎 |
| 图片分析 | `image/image_analyzer.py` | 图片复杂度识别 |
| 派单客户端 | `services/service_tuhui_dispatch.py` | 图绘派单 API |
| 任务数据库 | `db/image_tasks_db.py` | 任务持久化 |
### 数据库
| 数据库 | 位置 | 说明 |
|--------|------|------|
| 任务数据库 | `db/image_tasks.db` | 图片任务 |
| 客户档案 | `db/customer.db` | 客户画像 |
| 聊天记录 | `chat_log_db/chat_log.db` | 聊天历史 |
| 天网任务 | `db/task_db/tasks.db` | 天网任务调度 |
### API 端口
| 服务 | 端口 | 说明 |
|------|------|------|
| AI 客服 API | 6060 | 天网任务接收 |
| 派单系统 | 8005 | 设计师派单 |
| 图绘平台 | 8002 | 图片上传 |