2026-09-09 18:32:00 +08:00
|
|
|
|
"""已注册客户 Agent 编排:LangGraph 14 节点 + DeepSeek(CS Wave 3)。
|
|
|
|
|
|
|
|
|
|
|
|
流程(方案 §5.2):
|
|
|
|
|
|
recall_memory → intent_classify
|
|
|
|
|
|
├─ RAG 3 类 → rag_search → generate
|
|
|
|
|
|
├─ 数据查询 4 类 → param_extract → tool_call → interpret
|
|
|
|
|
|
├─ chit_chat → chitchat
|
|
|
|
|
|
└─ reject / transfer_human / fallback → 静态话术
|
|
|
|
|
|
→ save_memory → profile_maybe_extract(每 5 轮节流)→ archive_check(显式结束/懒扫描超时)→ END
|
|
|
|
|
|
|
|
|
|
|
|
铁律:
|
|
|
|
|
|
1. 数值不经过 LLM 生成:数据查询结果 100% 来自 core_ro_tool 的 fact_text,LLM 只解读;
|
|
|
|
|
|
2. customer_id 一律来自 JWT 解析(chat.py 已 resolve),不取用户消息中的任何客户号;
|
|
|
|
|
|
3. 画像抽槽与归档在后台线程执行,失败不影响对话响应。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
2026-09-09 21:32:59 +08:00
|
|
|
|
from typing import Any, TypedDict
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
from langgraph.graph import END, StateGraph
|
|
|
|
|
|
|
|
|
|
|
|
from app.config.settings import settings
|
|
|
|
|
|
from app.model.schemas import AuthContext
|
|
|
|
|
|
from app.service.customer_prompts import (
|
2026-09-10 22:30:07 +08:00
|
|
|
|
CHITCHAT_DEGRADED_TEXT,
|
2026-09-09 18:32:00 +08:00
|
|
|
|
CHITCHAT_SYSTEM,
|
|
|
|
|
|
CHITCHAT_USER_TEMPLATE,
|
|
|
|
|
|
DATA_ERROR_TEXT,
|
|
|
|
|
|
FALLBACK_TEXT,
|
|
|
|
|
|
GENERATE_SYSTEM,
|
|
|
|
|
|
GENERATE_USER_TEMPLATE,
|
|
|
|
|
|
INTENT_SYSTEM,
|
|
|
|
|
|
INTENT_USER_TEMPLATE,
|
|
|
|
|
|
INTERPRET_SYSTEM,
|
|
|
|
|
|
INTERPRET_USER_TEMPLATE,
|
|
|
|
|
|
NOTE_SAVED_TEXT,
|
|
|
|
|
|
PARAM_EXTRACT_SYSTEM,
|
|
|
|
|
|
PARAM_EXTRACT_USER_TEMPLATE,
|
|
|
|
|
|
REJECT_BY_KIND,
|
|
|
|
|
|
REJECT_GENERAL_TEXT,
|
|
|
|
|
|
TRANSFER_TEXT,
|
|
|
|
|
|
VALID_INTENTS,
|
|
|
|
|
|
keyword_route,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.service.note_service import (
|
|
|
|
|
|
render_notes_context,
|
|
|
|
|
|
save_note_from_message,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.service.profile_service import (
|
|
|
|
|
|
CustomerMemoryService,
|
|
|
|
|
|
ProfileHotCache,
|
|
|
|
|
|
_spawn,
|
|
|
|
|
|
archive_idle_sessions,
|
|
|
|
|
|
archive_session,
|
|
|
|
|
|
extract_profile,
|
|
|
|
|
|
memory_kind_for,
|
|
|
|
|
|
parse_llm_json,
|
|
|
|
|
|
record_intent,
|
|
|
|
|
|
render_profile_context,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.service.rag_service import VisitorRagService
|
|
|
|
|
|
from app.tool.core_ro_tool import (
|
|
|
|
|
|
query_holdings,
|
2026-09-10 10:57:40 +08:00
|
|
|
|
query_product_nav,
|
2026-09-09 18:32:00 +08:00
|
|
|
|
query_risk_profile,
|
|
|
|
|
|
query_suitability,
|
|
|
|
|
|
query_trades,
|
|
|
|
|
|
)
|
2026-09-10 10:57:40 +08:00
|
|
|
|
from app.utils.compliance_guard import RISK_DISCLAIMER, should_add_disclaimer
|
2026-09-13 17:45:04 +08:00
|
|
|
|
from app.service.trade_action_service import (
|
|
|
|
|
|
TRADE_MODAL_HINT,
|
|
|
|
|
|
TRADE_PARSE_FAIL_HINT,
|
|
|
|
|
|
pending_trade_from_draft,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.service.trade_action_service import should_use_suitability_instead_of_trade
|
|
|
|
|
|
from app.service.trade_flow_service import (
|
|
|
|
|
|
clear_trade_flow,
|
|
|
|
|
|
process_trade_turn,
|
|
|
|
|
|
trade_dialogue_should_continue,
|
|
|
|
|
|
)
|
2026-09-10 10:57:40 +08:00
|
|
|
|
from app.utils.sanitize_postprocess import finalize_sanitized_reply
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# LLM 调用
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
from langchain_openai import ChatOpenAI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_llm() -> ChatOpenAI:
|
|
|
|
|
|
return ChatOpenAI(
|
|
|
|
|
|
model=settings.deepseek_model,
|
|
|
|
|
|
api_key=settings.deepseek_api_key,
|
|
|
|
|
|
base_url=settings.deepseek_base_url,
|
|
|
|
|
|
temperature=settings.deepseek_temperature,
|
|
|
|
|
|
max_tokens=settings.deepseek_max_tokens,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _invoke(system: str, user: str) -> str:
|
|
|
|
|
|
llm = _build_llm()
|
|
|
|
|
|
resp = llm.invoke([
|
|
|
|
|
|
{"role": "system", "content": system},
|
|
|
|
|
|
{"role": "user", "content": user},
|
|
|
|
|
|
])
|
|
|
|
|
|
content = resp if isinstance(resp, str) else resp.content
|
|
|
|
|
|
return content or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# LangGraph State
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
class CustomerState(TypedDict):
|
|
|
|
|
|
session_id: str
|
|
|
|
|
|
trace_id: str
|
|
|
|
|
|
customer_id: str
|
|
|
|
|
|
message: str
|
|
|
|
|
|
end_session: bool
|
|
|
|
|
|
intent: str
|
|
|
|
|
|
reject_kind: str
|
|
|
|
|
|
chitchat_memory: list[dict]
|
|
|
|
|
|
consult_memory: list[dict]
|
|
|
|
|
|
profile_context: str
|
|
|
|
|
|
notes_context: str
|
|
|
|
|
|
params: dict
|
|
|
|
|
|
tool_result: dict | None
|
|
|
|
|
|
rag_context: str
|
|
|
|
|
|
rag_sources: list[dict]
|
|
|
|
|
|
reply: str
|
|
|
|
|
|
has_disclaimer: bool
|
|
|
|
|
|
transfer_to_human: bool
|
2026-09-13 17:45:04 +08:00
|
|
|
|
pending_trade: dict | None
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_TOOL_BY_INTENT = {
|
|
|
|
|
|
"holding_query": query_holdings,
|
|
|
|
|
|
"transaction_query": query_trades,
|
|
|
|
|
|
"risk_assessment_query": query_risk_profile,
|
|
|
|
|
|
"suitability_check": query_suitability,
|
2026-09-10 10:57:40 +08:00
|
|
|
|
"nav_query": query_product_nav,
|
2026-09-09 18:32:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 10:57:40 +08:00
|
|
|
|
_DATA_QUERY_INTENTS = frozenset(_TOOL_BY_INTENT.keys())
|
|
|
|
|
|
|
2026-09-09 18:32:00 +08:00
|
|
|
|
_R_LEVEL_FULL_RE = re.compile(r"[Rr]\s*([1-5])")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 17:45:04 +08:00
|
|
|
|
def _session_memory_context(state: CustomerState) -> str:
|
|
|
|
|
|
"""合并 consult/chitchat 短期记忆,供交易续轮与意图判断。"""
|
|
|
|
|
|
merged: list[dict] = []
|
|
|
|
|
|
merged.extend(state.get("consult_memory") or [])
|
|
|
|
|
|
merged.extend(state.get("chitchat_memory") or [])
|
|
|
|
|
|
lines: list[str] = []
|
|
|
|
|
|
for item in merged[-16:]:
|
|
|
|
|
|
role = item.get("role") or "user"
|
|
|
|
|
|
content = (item.get("content") or "").strip()
|
|
|
|
|
|
if content:
|
|
|
|
|
|
lines.append(f"{role}: {content}")
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _trade_context_window(state: CustomerState) -> str:
|
|
|
|
|
|
"""交易续轮上下文:Redis 双线记忆 + MySQL 会话落库(与 chat.insert_turn 对齐)。
|
|
|
|
|
|
|
|
|
|
|
|
仅依赖 Redis 时,若 Redis 未写入/过期而前端仍展示 MySQL 历史,回复「1」会误判 fallback。
|
|
|
|
|
|
"""
|
|
|
|
|
|
ctx = _session_memory_context(state)
|
|
|
|
|
|
if ctx.strip():
|
|
|
|
|
|
return ctx
|
|
|
|
|
|
sid = state.get("session_id") or ""
|
|
|
|
|
|
if not sid:
|
|
|
|
|
|
return ctx
|
|
|
|
|
|
try:
|
|
|
|
|
|
ctx = CustomerMemoryService().recall_window(sid)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
ctx = ""
|
|
|
|
|
|
if ctx.strip():
|
|
|
|
|
|
return ctx
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.repository.session_repository import SessionRepository
|
|
|
|
|
|
|
|
|
|
|
|
msgs = SessionRepository().list_messages(sid, limit=16)
|
|
|
|
|
|
lines: list[str] = []
|
|
|
|
|
|
for m in msgs:
|
|
|
|
|
|
role = m.get("role") or "user"
|
|
|
|
|
|
lines.append(f"{role}: {(m.get('content') or '').strip()}")
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 18:32:00 +08:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 节点 1:读取短期记忆 + 画像热缓存
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def recall_memory(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
sid, cid = state["session_id"], state["customer_id"]
|
|
|
|
|
|
mem = CustomerMemoryService()
|
|
|
|
|
|
profile_context = ""
|
|
|
|
|
|
notes_context = ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
if cid:
|
2026-09-11 15:18:56 +08:00
|
|
|
|
profile_context = render_profile_context(ProfileHotCache().get_style_tags_lazy(cid))
|
2026-09-09 18:32:00 +08:00
|
|
|
|
notes_context = render_notes_context(cid)
|
|
|
|
|
|
chitchat_memory = mem.recall(sid, "chitchat")
|
|
|
|
|
|
consult_memory = mem.recall(sid, "consult")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
chitchat_memory = []
|
|
|
|
|
|
consult_memory = []
|
|
|
|
|
|
return {
|
|
|
|
|
|
"chitchat_memory": chitchat_memory,
|
|
|
|
|
|
"consult_memory": consult_memory,
|
|
|
|
|
|
"profile_context": profile_context,
|
|
|
|
|
|
"notes_context": notes_context,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 节点 2:意图分类(关键词快路由优先,未命中 DeepSeek)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def intent_classify(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
msg = state["message"]
|
|
|
|
|
|
|
2026-09-13 17:45:04 +08:00
|
|
|
|
sid = state.get("session_id") or ""
|
|
|
|
|
|
mem_ctx = _trade_context_window(state)
|
|
|
|
|
|
if should_use_suitability_instead_of_trade(msg):
|
|
|
|
|
|
clear_trade_flow(sid)
|
|
|
|
|
|
elif trade_dialogue_should_continue(sid, msg, mem_ctx):
|
|
|
|
|
|
return {"intent": "trade_action"}
|
|
|
|
|
|
|
2026-09-09 18:32:00 +08:00
|
|
|
|
hit = keyword_route(msg)
|
|
|
|
|
|
if hit:
|
|
|
|
|
|
intent, kind = hit
|
|
|
|
|
|
if intent == "reject":
|
|
|
|
|
|
return {"intent": "reject", "reject_kind": kind}
|
|
|
|
|
|
return {"intent": intent}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
content = _invoke(INTENT_SYSTEM, INTENT_USER_TEMPLATE.format(message=msg))
|
|
|
|
|
|
intent = content.strip().lower()
|
|
|
|
|
|
if intent not in VALID_INTENTS:
|
|
|
|
|
|
intent = "fallback"
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
intent = "fallback"
|
|
|
|
|
|
return {"intent": intent}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 节点 3:RAG 检索(RAG 3 类)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def rag_search(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
try:
|
|
|
|
|
|
context, sources = VisitorRagService().retrieve(state["intent"], state["message"])
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
context, sources = "", []
|
|
|
|
|
|
|
|
|
|
|
|
if not context:
|
|
|
|
|
|
return {"rag_context": "", "rag_sources": [], "reply": FALLBACK_TEXT, "intent": "fallback"}
|
|
|
|
|
|
return {"rag_context": context, "rag_sources": sources}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 节点 4:参数抽取(仅 transaction_query / suitability_check 需要)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def param_extract(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
intent = state["intent"]
|
2026-09-10 10:57:40 +08:00
|
|
|
|
if intent not in ("transaction_query", "suitability_check", "nav_query"):
|
2026-09-09 18:32:00 +08:00
|
|
|
|
return {"params": {}}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
content = _invoke(
|
|
|
|
|
|
PARAM_EXTRACT_SYSTEM,
|
|
|
|
|
|
PARAM_EXTRACT_USER_TEMPLATE.format(message=state["message"]),
|
|
|
|
|
|
)
|
|
|
|
|
|
data = parse_llm_json(content) or {}
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return {"params": {}}
|
|
|
|
|
|
|
|
|
|
|
|
params: dict = {}
|
|
|
|
|
|
months = data.get("months")
|
|
|
|
|
|
if months is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
params["months"] = max(1, min(36, int(months)))
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
kw = data.get("product_keyword")
|
|
|
|
|
|
if isinstance(kw, str) and kw.strip():
|
|
|
|
|
|
params["product_keyword"] = kw.strip()[:32]
|
|
|
|
|
|
rl = data.get("risk_level")
|
|
|
|
|
|
if isinstance(rl, str):
|
|
|
|
|
|
m = _R_LEVEL_FULL_RE.fullmatch(rl.strip())
|
|
|
|
|
|
if m:
|
|
|
|
|
|
params["risk_level"] = f"R{m.group(1)}"
|
|
|
|
|
|
return {"params": params}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 节点 5:Core 只读工具调用(customer_id 强制来自 state,即 JWT 解析值)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def tool_call(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
cid = state["customer_id"]
|
|
|
|
|
|
intent = state["intent"]
|
|
|
|
|
|
params = state.get("params") or {}
|
|
|
|
|
|
fn = _TOOL_BY_INTENT[intent]
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
if intent == "transaction_query":
|
|
|
|
|
|
result = fn(cid, months=params.get("months"))
|
|
|
|
|
|
elif intent == "suitability_check":
|
|
|
|
|
|
result = fn(
|
|
|
|
|
|
cid,
|
|
|
|
|
|
product_keyword=params.get("product_keyword"),
|
|
|
|
|
|
risk_level=params.get("risk_level"),
|
2026-09-13 17:45:04 +08:00
|
|
|
|
user_message=state.get("message"),
|
2026-09-09 18:32:00 +08:00
|
|
|
|
)
|
2026-09-10 10:57:40 +08:00
|
|
|
|
elif intent == "nav_query":
|
|
|
|
|
|
result = fn(cid, product_keyword=params.get("product_keyword"))
|
2026-09-09 18:32:00 +08:00
|
|
|
|
else:
|
|
|
|
|
|
result = fn(cid)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return {"tool_result": None, "reply": DATA_ERROR_TEXT}
|
|
|
|
|
|
|
|
|
|
|
|
result = dict(result)
|
|
|
|
|
|
if not result.get("ok"):
|
|
|
|
|
|
return {"tool_result": result, "reply": DATA_ERROR_TEXT}
|
|
|
|
|
|
return {"tool_result": result}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 节点 6:工具结果解读(数值原样引用,铁律在 INTERPRET_SYSTEM)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def interpret(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
tr = state.get("tool_result") or {}
|
|
|
|
|
|
if not tr.get("ok"):
|
|
|
|
|
|
return {"reply": state.get("reply") or DATA_ERROR_TEXT}
|
|
|
|
|
|
fact_text = tr.get("fact_text", "")
|
|
|
|
|
|
if not fact_text:
|
|
|
|
|
|
return {"reply": DATA_ERROR_TEXT}
|
|
|
|
|
|
|
2026-09-13 17:45:04 +08:00
|
|
|
|
# 产品选购清单含序号,禁止 LLM 改写为「能买几只」类汇总
|
|
|
|
|
|
if "请回复上方序号" in fact_text and "【R" in fact_text:
|
|
|
|
|
|
reply = fact_text
|
|
|
|
|
|
reply, need_transfer = finalize_sanitized_reply(
|
|
|
|
|
|
reply, intent=state.get("intent"), fact_text=fact_text,
|
|
|
|
|
|
)
|
|
|
|
|
|
if need_transfer:
|
|
|
|
|
|
return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False}
|
|
|
|
|
|
return {"reply": reply, "has_disclaimer": False}
|
|
|
|
|
|
|
2026-09-09 18:32:00 +08:00
|
|
|
|
try:
|
|
|
|
|
|
mem = CustomerMemoryService().as_prompt_text(state["session_id"], "consult")
|
|
|
|
|
|
content = _invoke(
|
|
|
|
|
|
INTERPRET_SYSTEM,
|
|
|
|
|
|
INTERPRET_USER_TEMPLATE.format(
|
|
|
|
|
|
fact_text=fact_text,
|
|
|
|
|
|
notes_context=state.get("notes_context", ""),
|
|
|
|
|
|
memory=mem,
|
|
|
|
|
|
message=state["message"],
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
reply = content.strip() or fact_text
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
reply = fact_text # LLM 不可用时直接返回脱敏事实文本
|
|
|
|
|
|
|
2026-09-10 10:57:40 +08:00
|
|
|
|
reply, need_transfer = finalize_sanitized_reply(
|
|
|
|
|
|
reply, intent=state.get("intent"), fact_text=fact_text,
|
|
|
|
|
|
)
|
2026-09-09 18:32:00 +08:00
|
|
|
|
if need_transfer:
|
|
|
|
|
|
return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False}
|
|
|
|
|
|
return {"reply": reply, "has_disclaimer": False}
|
|
|
|
|
|
|
|
|
|
|
|
def generate(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
if not state.get("rag_context"):
|
|
|
|
|
|
return {"reply": state.get("reply") or FALLBACK_TEXT, "has_disclaimer": False}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
mem = CustomerMemoryService().as_prompt_text(state["session_id"], "consult")
|
|
|
|
|
|
content = _invoke(
|
|
|
|
|
|
GENERATE_SYSTEM,
|
|
|
|
|
|
GENERATE_USER_TEMPLATE.format(
|
|
|
|
|
|
rag_context=state["rag_context"],
|
|
|
|
|
|
profile_context=state.get("profile_context", ""),
|
|
|
|
|
|
memory=mem,
|
|
|
|
|
|
message=state["message"],
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
reply = content.strip()
|
|
|
|
|
|
if not reply:
|
|
|
|
|
|
return {"reply": FALLBACK_TEXT, "intent": "fallback"}
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return {"reply": FALLBACK_TEXT, "intent": "fallback"}
|
|
|
|
|
|
|
2026-09-10 10:57:40 +08:00
|
|
|
|
reply, need_transfer = finalize_sanitized_reply(reply, intent=state.get("intent"))
|
2026-09-09 18:32:00 +08:00
|
|
|
|
if need_transfer:
|
|
|
|
|
|
return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False}
|
|
|
|
|
|
|
|
|
|
|
|
has_disclaimer = should_add_disclaimer(state["intent"])
|
|
|
|
|
|
if has_disclaimer:
|
|
|
|
|
|
reply = f"{reply}\n\n{RISK_DISCLAIMER}"
|
|
|
|
|
|
return {"reply": reply, "has_disclaimer": has_disclaimer}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 节点 8:闲聊(注入画像语境)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def chitchat(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
try:
|
|
|
|
|
|
mem = CustomerMemoryService().as_prompt_text(state["session_id"], "chitchat")
|
|
|
|
|
|
content = _invoke(
|
|
|
|
|
|
CHITCHAT_SYSTEM,
|
|
|
|
|
|
CHITCHAT_USER_TEMPLATE.format(
|
|
|
|
|
|
profile_context=state.get("profile_context", ""),
|
|
|
|
|
|
notes_context=state.get("notes_context", ""),
|
|
|
|
|
|
memory=mem,
|
|
|
|
|
|
message=state["message"],
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
reply = content.strip()
|
|
|
|
|
|
if not reply:
|
2026-09-10 22:30:07 +08:00
|
|
|
|
return {"reply": CHITCHAT_DEGRADED_TEXT, "intent": "chit_chat"}
|
2026-09-09 18:32:00 +08:00
|
|
|
|
except Exception:
|
2026-09-10 22:30:07 +08:00
|
|
|
|
return {"reply": CHITCHAT_DEGRADED_TEXT, "intent": "chit_chat"}
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
2026-09-10 10:57:40 +08:00
|
|
|
|
reply, need_transfer = finalize_sanitized_reply(reply, intent=state.get("intent"))
|
2026-09-09 18:32:00 +08:00
|
|
|
|
if need_transfer:
|
|
|
|
|
|
return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False}
|
|
|
|
|
|
return {"reply": reply, "has_disclaimer": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 节点 9~11:静态话术
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def reject(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
kind = state.get("reject_kind") or ""
|
|
|
|
|
|
return {"reply": REJECT_BY_KIND.get(kind, REJECT_GENERAL_TEXT)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def transfer_human(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
return {"reply": TRANSFER_TEXT, "transfer_to_human": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fallback(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
return {"reply": FALLBACK_TEXT}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 节点 11:显式备注(用户主动要求记忆;调 LLM 抽取 → 写库 → 固定回复)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def save_note(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
cid = state.get("customer_id") or ""
|
|
|
|
|
|
sid = state.get("session_id") or ""
|
|
|
|
|
|
trace_id = state.get("trace_id") or ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
_content, _cat, reply = save_note_from_message(
|
|
|
|
|
|
cid, sid, trace_id, state["message"]
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
reply = NOTE_SAVED_TEXT
|
|
|
|
|
|
return {"reply": reply}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 节点 12:保存短期记忆(Redis 双线;MySQL 落库由 chat.py 负责)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-09-13 17:45:04 +08:00
|
|
|
|
def trade_prepare(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
"""交易意图:只生成 pending_trade,不调网关。"""
|
|
|
|
|
|
cid = state["customer_id"]
|
|
|
|
|
|
sid = state.get("session_id") or ""
|
|
|
|
|
|
mem_ctx = _trade_context_window(state)
|
|
|
|
|
|
draft = process_trade_turn(sid, cid, state["message"], mem_ctx)
|
|
|
|
|
|
if not draft.get("ok"):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"pending_trade": None,
|
|
|
|
|
|
"reply": draft.get("hint") or TRADE_PARSE_FAIL_HINT,
|
|
|
|
|
|
"has_disclaimer": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
"pending_trade": pending_trade_from_draft(draft),
|
|
|
|
|
|
"reply": TRADE_MODAL_HINT,
|
|
|
|
|
|
"has_disclaimer": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 18:32:00 +08:00
|
|
|
|
def save_memory(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
intent = state.get("intent", "fallback")
|
|
|
|
|
|
kind = memory_kind_for(intent)
|
|
|
|
|
|
try:
|
|
|
|
|
|
mem = CustomerMemoryService()
|
|
|
|
|
|
mem.append(state["session_id"], kind, "user", state["message"])
|
|
|
|
|
|
mem.append(state["session_id"], kind, "assistant", state["reply"])
|
|
|
|
|
|
if state.get("customer_id"):
|
|
|
|
|
|
record_intent(state["customer_id"], intent)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 节点 13:画像抽槽节流(每 5 轮触发,后台线程)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def profile_maybe_extract(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
sid, cid = state["session_id"], state["customer_id"]
|
|
|
|
|
|
if not sid or not cid:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.config.database import get_redis_client
|
|
|
|
|
|
|
|
|
|
|
|
r = get_redis_client()
|
|
|
|
|
|
key = f"customer:{sid}:rounds"
|
|
|
|
|
|
count = r.incr(key)
|
|
|
|
|
|
r.expire(key, settings.customer_session_ttl)
|
|
|
|
|
|
if count >= settings.profile_extract_every_rounds:
|
|
|
|
|
|
r.delete(key)
|
|
|
|
|
|
window = CustomerMemoryService().recall_window(sid)
|
|
|
|
|
|
_spawn(extract_profile, cid, window, state["trace_id"])
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 节点 14:归档检查(显式结束 + 懒扫描超时会话,后台线程)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def archive_check(state: CustomerState) -> CustomerState:
|
|
|
|
|
|
trace_id = state["trace_id"]
|
|
|
|
|
|
sid, cid = state["session_id"], state["customer_id"]
|
|
|
|
|
|
|
|
|
|
|
|
if state.get("end_session") and sid and cid:
|
|
|
|
|
|
_spawn(archive_session, sid, cid, trace_id, "explicit")
|
|
|
|
|
|
|
|
|
|
|
|
# 懒扫描:顺带归档其他空闲超时的客户会话(不含当前会话)
|
|
|
|
|
|
_spawn(archive_idle_sessions, trace_id, sid)
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 图构建
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _build_graph():
|
|
|
|
|
|
g = StateGraph(CustomerState)
|
|
|
|
|
|
|
|
|
|
|
|
g.add_node("recall_memory", recall_memory)
|
|
|
|
|
|
g.add_node("intent_classify", intent_classify)
|
|
|
|
|
|
g.add_node("rag_search", rag_search)
|
|
|
|
|
|
g.add_node("param_extract", param_extract)
|
|
|
|
|
|
g.add_node("tool_call", tool_call)
|
|
|
|
|
|
g.add_node("interpret", interpret)
|
|
|
|
|
|
g.add_node("generate", generate)
|
|
|
|
|
|
g.add_node("chitchat", chitchat)
|
|
|
|
|
|
g.add_node("reject", reject)
|
|
|
|
|
|
g.add_node("transfer_human", transfer_human)
|
|
|
|
|
|
g.add_node("fallback", fallback)
|
|
|
|
|
|
g.add_node("save_note", save_note)
|
2026-09-13 17:45:04 +08:00
|
|
|
|
g.add_node("trade_prepare", trade_prepare)
|
2026-09-09 18:32:00 +08:00
|
|
|
|
g.add_node("save_memory", save_memory)
|
|
|
|
|
|
g.add_node("profile_maybe_extract", profile_maybe_extract)
|
|
|
|
|
|
g.add_node("archive_check", archive_check)
|
|
|
|
|
|
|
|
|
|
|
|
g.set_entry_point("recall_memory")
|
|
|
|
|
|
g.add_edge("recall_memory", "intent_classify")
|
|
|
|
|
|
|
|
|
|
|
|
def _route(state: CustomerState) -> str:
|
|
|
|
|
|
intent = state["intent"]
|
2026-09-13 17:45:04 +08:00
|
|
|
|
if intent == "trade_action":
|
|
|
|
|
|
return "trade_prepare"
|
2026-09-09 18:32:00 +08:00
|
|
|
|
if intent in _TOOL_BY_INTENT:
|
|
|
|
|
|
return "param_extract"
|
|
|
|
|
|
if intent in ("product_consult", "policy_interpret", "faq"):
|
|
|
|
|
|
return "rag_search"
|
|
|
|
|
|
if intent == "chit_chat":
|
|
|
|
|
|
return "chitchat"
|
|
|
|
|
|
if intent == "reject":
|
|
|
|
|
|
return "reject"
|
|
|
|
|
|
if intent == "transfer_human":
|
|
|
|
|
|
return "transfer_human"
|
|
|
|
|
|
if intent == "save_note":
|
|
|
|
|
|
return "save_note"
|
|
|
|
|
|
return "fallback"
|
|
|
|
|
|
|
|
|
|
|
|
g.add_conditional_edges("intent_classify", _route, {
|
2026-09-13 17:45:04 +08:00
|
|
|
|
"trade_prepare": "trade_prepare",
|
2026-09-09 18:32:00 +08:00
|
|
|
|
"param_extract": "param_extract",
|
|
|
|
|
|
"rag_search": "rag_search",
|
|
|
|
|
|
"chitchat": "chitchat",
|
|
|
|
|
|
"reject": "reject",
|
|
|
|
|
|
"transfer_human": "transfer_human",
|
|
|
|
|
|
"save_note": "save_note",
|
|
|
|
|
|
"fallback": "fallback",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
g.add_edge("param_extract", "tool_call")
|
|
|
|
|
|
g.add_edge("tool_call", "interpret")
|
|
|
|
|
|
g.add_edge("rag_search", "generate")
|
2026-09-13 17:45:04 +08:00
|
|
|
|
g.add_edge("trade_prepare", "save_memory")
|
2026-09-09 18:32:00 +08:00
|
|
|
|
for node in ("generate", "interpret", "chitchat", "reject", "transfer_human", "fallback", "save_note"):
|
|
|
|
|
|
g.add_edge(node, "save_memory")
|
|
|
|
|
|
g.add_edge("save_memory", "profile_maybe_extract")
|
|
|
|
|
|
g.add_edge("profile_maybe_extract", "archive_check")
|
|
|
|
|
|
g.add_edge("archive_check", END)
|
|
|
|
|
|
return g.compile()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_GRAPH = _build_graph()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 编排入口(chat.py 按 X-Agent-Type: customer 分流到此处)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def run_customer_chat(
|
|
|
|
|
|
ctx: AuthContext,
|
|
|
|
|
|
message: str,
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
customer_id: str,
|
|
|
|
|
|
end_session: bool = False,
|
|
|
|
|
|
) -> tuple[str, bool, str, bool]:
|
|
|
|
|
|
"""客户对话入口,返回 (reply, has_disclaimer, intent, transfer_to_human)。"""
|
|
|
|
|
|
state: CustomerState = {
|
|
|
|
|
|
"session_id": session_id,
|
|
|
|
|
|
"trace_id": ctx.trace_id,
|
|
|
|
|
|
"customer_id": customer_id,
|
|
|
|
|
|
"message": message,
|
|
|
|
|
|
"end_session": end_session,
|
|
|
|
|
|
"intent": "",
|
|
|
|
|
|
"reject_kind": "",
|
|
|
|
|
|
"chitchat_memory": [],
|
|
|
|
|
|
"consult_memory": [],
|
|
|
|
|
|
"profile_context": "",
|
|
|
|
|
|
"params": {},
|
|
|
|
|
|
"tool_result": None,
|
|
|
|
|
|
"rag_context": "",
|
|
|
|
|
|
"rag_sources": [],
|
|
|
|
|
|
"reply": "",
|
|
|
|
|
|
"has_disclaimer": False,
|
|
|
|
|
|
"transfer_to_human": False,
|
2026-09-13 17:45:04 +08:00
|
|
|
|
"pending_trade": None,
|
2026-09-09 18:32:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
result = _GRAPH.invoke(state)
|
|
|
|
|
|
return (
|
|
|
|
|
|
result["reply"],
|
|
|
|
|
|
result.get("has_disclaimer", False),
|
|
|
|
|
|
result.get("intent", "fallback"),
|
|
|
|
|
|
result.get("transfer_to_human", False),
|
2026-09-13 17:45:04 +08:00
|
|
|
|
result.get("pending_trade"),
|
2026-09-09 18:32:00 +08:00
|
|
|
|
)
|
2026-09-09 21:32:59 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _chunk_reply_text(reply: str) -> list[str]:
|
|
|
|
|
|
"""将完整回复切成 SSE 块(图跑完后推送,Tool/RAG 仍同步完成)。"""
|
|
|
|
|
|
if not reply:
|
|
|
|
|
|
return []
|
|
|
|
|
|
step = max(1, len(reply) // 16)
|
|
|
|
|
|
return [reply[i : i + step] for i in range(0, len(reply), step)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def prepare_customer_stream(
|
|
|
|
|
|
ctx: AuthContext,
|
|
|
|
|
|
message: str,
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
customer_id: str,
|
|
|
|
|
|
end_session: bool = False,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""客户流式:先跑完 LangGraph,再按块推送(与客服线同步路径同编排)。"""
|
2026-09-13 17:45:04 +08:00
|
|
|
|
reply, has_disclaimer, intent, transfer, pending_trade = run_customer_chat(
|
2026-09-09 21:32:59 +08:00
|
|
|
|
ctx, message, session_id, customer_id, end_session
|
|
|
|
|
|
)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"reply": reply,
|
|
|
|
|
|
"has_disclaimer": has_disclaimer,
|
|
|
|
|
|
"intent": intent,
|
|
|
|
|
|
"transfer_to_human": transfer,
|
2026-09-13 17:45:04 +08:00
|
|
|
|
"pending_trade": pending_trade,
|
2026-09-09 21:32:59 +08:00
|
|
|
|
"chunks": _chunk_reply_text(reply),
|
|
|
|
|
|
}
|