refactor: migrate workflow to v2 core and archive legacy modules
201
legacy/agent_pre_rules.py
Normal 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
@@ -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
@@ -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
|
||||
181
legacy/batch_quote_helpers.py
Normal 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
432
legacy/collection_intent_helpers.py
Normal 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
@@ -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 ""
|
||||
95
legacy/conversation_state_store.py
Normal 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)
|
||||
889
legacy/customer_db/customers.json
Normal 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
@@ -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
@@ -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
@@ -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]
|
||||
279
legacy/designer_roster_db.py
Normal 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
|
||||
2
legacy/evolution/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Self-evolution MVP utilities for the customer service agent."""
|
||||
|
||||
591
legacy/evolution/mvp.py
Normal 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],
|
||||
}
|
||||
101
legacy/features/price_negotiation_with_registration.md
Normal 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 注册
|
||||
129
legacy/features/risk_customer_detection.md
Normal 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 个+信号) | 直接拒绝 |
|
||||
| 敏感内容 | 直接拒绝 |
|
||||
|
||||
45
legacy/features/self_evolution_mvp.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 自我进化 MVP(可控版)
|
||||
|
||||
目标:让客服 agent 持续变聪明,同时避免“自动改坏线上”。
|
||||
|
||||
## 1. 已落地能力
|
||||
|
||||
- 失败样本采集:从 `db/chat_log_db/chats.db` 抽取近 N 小时客服问答对。
|
||||
- 离线评测:自动识别高风险未转人工、低置信度兜底、慢回复等问题。
|
||||
- 改进建议生成:输出可执行的模块级 proposal(prompt/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/` 回归,再扩大流量。
|
||||
158
legacy/features/text_surcharge.md
Normal 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: 有文字的图跟没文字的价格不一样,已经是最低价了
|
||||
```
|
||||
|
||||
### 示例 2:normal 图含文字
|
||||
|
||||
```
|
||||
客户:这个多少钱?
|
||||
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. **坚持原则**:客户嫌贵也要说明原因
|
||||
|
||||
137
legacy/features/tuhui_upload.md
Normal 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
@@ -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)
|
||||
55
legacy/image_workflow_router.py
Normal 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
@@ -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
331
legacy/mail/email_receiver.py
Normal 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
@@ -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()
|
||||
113
legacy/message_orchestrator.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
112
legacy/reply_finalize_flow.py
Normal 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,
|
||||
)
|
||||
BIN
legacy/results/20260225211854.jpg
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
legacy/results/debug_7debc0124b0441da9945feaeceef93b1.jpg
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
legacy/results/pfix_final_73bc9c0c4bed4be198b200158be6f813.jpg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
legacy/results/pfix_final_7debc0124b0441da9945feaeceef93b1.jpg
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
legacy/results/pfix_final_b3dd76cbc37e403ca9425ece8ba2ebcd.jpg
Normal file
|
After Width: | Height: | Size: 855 KiB |
BIN
legacy/results/pfix_final_bc3c45fd447749f38f62dbb87a942aba.jpg
Normal file
|
After Width: | Height: | Size: 810 KiB |
BIN
legacy/results/pfix_final_d9679c27640b43c18b9f590047e6c2dd.jpg
Normal file
|
After Width: | Height: | Size: 883 KiB |
BIN
legacy/results/resize_95152a96618146738c3e6a12a6a6d9d8.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
legacy/results/resize_d9ef87fa8de14b0b8d030067d0de163e.jpg
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
legacy/results/result_2d5b47961e7b42eabe2fd7beb8c9be1f.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
legacy/results/result_3e60b204f3a748eabb41a05cc28e1a11.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
legacy/results/result_4cd07206b2d24c21a81c3d45a3c4e16f.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
legacy/results/result_5c19d435fc8e4b2caa03c589f53d61ac.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
legacy/results/result_90eaf777934445af81abbd60fe4778c5.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
71
legacy/risk_text_helpers.py
Normal 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
@@ -0,0 +1,3 @@
|
||||
from .engine import Rule, RuleContext, RuleEngine, RuleResult
|
||||
|
||||
__all__ = ["Rule", "RuleContext", "RuleEngine", "RuleResult"]
|
||||
59
legacy/rules/engine.py
Normal 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")
|
||||
362
legacy/scripts/chat_log_viewer.py
Normal 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}请提供客户ID:python 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
@@ -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,'<').replace(/>/g,'>');
|
||||
return esc;
|
||||
});
|
||||
return segs.join('<br>');
|
||||
}
|
||||
const escaped = msg.replace(/</g,'<').replace(/>/g,'>');
|
||||
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,'<').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)})
|
||||
95
legacy/scripts/evolution_cycle.py
Normal 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())
|
||||
45
legacy/scripts/init_designer_roster.py
Normal 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 # 查看当前数据")
|
||||
175
legacy/scripts/migrate_chat_logs_to_mysql.py
Normal 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()
|
||||
103
legacy/scripts/migrate_customers_json_to_mysql.py
Normal 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()
|
||||
109
legacy/scripts/migrate_remaining_sqlite_to_mysql.py
Normal 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()
|
||||
209
legacy/scripts/multi_process_launcher.py
Normal 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()
|
||||
8
legacy/scripts/run_test_ai_chat.ps1
Normal 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
|
||||
53
legacy/websocket_agent_reply_flow.py
Normal 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),
|
||||
)
|
||||
100
legacy/websocket_auto_quote_flow.py
Normal 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))
|
||||
311
legacy/websocket_brain_flow.py
Normal 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": "",
|
||||
},
|
||||
)
|
||||
)
|
||||
48
legacy/websocket_callback_flow.py
Normal 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
@@ -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) # 已回复消息ID,FIFO去重
|
||||
|
||||
# 消息防抖:同一客户连续发消息时,等待 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已停止")
|
||||
|
||||
45
legacy/websocket_customer_profile_flow.py
Normal 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)
|
||||
265
legacy/websocket_debounce_flow.py
Normal 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
|
||||
36
legacy/websocket_dispatch_flow.py
Normal 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)}
|
||||
181
legacy/websocket_followup_flow.py
Normal 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
|
||||
128
legacy/websocket_helpers_flow.py
Normal 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)
|
||||
11
legacy/websocket_image_entry_flow.py
Normal 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))
|
||||
123
legacy/websocket_inbound_flow.py
Normal 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}")
|
||||
98
legacy/websocket_message_utils_flow.py
Normal 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})")
|
||||
84
legacy/websocket_misc_rules_flow.py
Normal 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)
|
||||
130
legacy/websocket_outbound_arbiter_flow.py
Normal 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"
|
||||
285
legacy/websocket_outbound_flow.py
Normal 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}"
|
||||
128
legacy/websocket_quote_flow.py
Normal 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)
|
||||
23
legacy/websocket_summary_flow.py
Normal 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)
|
||||
143
legacy/websocket_system_inquiry_flow.py
Normal 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
|
||||
83
legacy/websocket_transfer_flow.py
Normal 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)
|
||||
64
legacy/websocket_workflow_flow.py
Normal 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
@@ -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
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 生产环境部署
|
||||
|
||||
### 方式 1:systemd 服务(推荐)
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### 方式 2:Docker 部署
|
||||
|
||||
```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 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
476
legacy/项目功能汇总.md
Normal 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 | 图片上传 |
|
||||