Files
group_fqcd_jr/tools/chat_console.py
lzf_0626 33fbb0eb01 feat(customer-service): 接入适当性裁决,回答"我这个等级能不能买它"
客户实测反馈:「c1客户能买它吗」答的是 C1 的通用规则,没回答"能不能买季季盈90天"。
根因是这个问题需要**组合两个事实**——产品的风险等级(R2)与客户档案等级能不能匹配——
而检索只能给出"最像的那段原文",给不出结论。基座早就有了 check_suitability 裁决工具,
接口留好了但客服没接线(这个项目里第三次遇到同一类情况)。

改动三处:
1. 新增 suitability_check 意图,意图码三处对齐(AgentDefinition.supported_intents、
   agent_intent_config 的 active 行、发布版 agent_tools 白名单)。
2. 新出口 _answer_suitability:产品风险等级**从知识库查出来**(不猜、也不采信问句里
   出现的"R2"字样),产品名只取上一轮回答里的主语(来自知识块字段,可信),客户等级
   交给 check_suitability 按档案解析——**不采信客户自称**。任何一步拿不到确定值就转人工:
   这个出口会给出"能不能买"的结论,宁可答不了也不能答错。
3. 发布脚本加意图注册与工具白名单,并做成幂等(重跑不会因为"已经审过了"而 409)。

实测:意图正确路由到新出口,裁决链路走通。9001 因为在 fin_risk_assessment 里没有测评
记录,系统给出"暂时无法购买 + 您目前没有在有效期内的风险测评结果"——这正是适当性管理
要求的行为,不是故障:不能卖给一个没有有效测评结果的客户。措辞也据此改过,不写
"您的等级为未记录"这种客户看不懂的句子。

顺带修了前端一处误导标记:它用"是否含客服热线"判断"已引导人工",而正常的适当性回答
里也会建议拨打客服热线,于是"已经给出结论"被误报成"已引导人工"。
2026-09-10 22:42:17 +08:00

234 lines
9.3 KiB
Python

"""本地客服控制台:一个能直接聊天的最小调试前端。
**为什么是独立进程,而不是给底座加接口**:底座目前没有登录接口(按计划推迟)。任何
"让浏览器直接拿令牌"的做法——无论是一个 dev token 端点还是把私钥给前端——都等于把
"任意身份"开放给任何能访问服务的人。这里把令牌签发与调用全部留在**本进程内**
(私钥不出进程),前端只跟本控制台说话,底座代码一行不改、也没有新增任何后门路由。
跑法:
```powershell
D:\\conda\\envs\\jr_py313\\python.exe tools\\chat_console.py
# 浏览器打开 http://127.0.0.1:8098
```
**运行前请停掉常驻 Worker**:本控制台自己驱动这一条 run 执行(`WorkerRuntime().execute`),
而常驻 Worker 与它共享 `agent_run` 队列,会抢走任务,导致页面一直转圈。
"""
import argparse
import datetime as dt
import sys
import uuid
from pathlib import Path
from typing import Any
import httpx
import jwt
import uvicorn
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from app.core.config import get_settings
from app.main import create_app
from app.worker.runtime import WorkerRuntime
sys.stdout.reconfigure(errors="replace")
AGENT_TYPE = "customer_service"
# 兜底话术的固定开头。不能用"客服热线"这类词判断——正常的适当性回答里也会建议客户
# 拨打客服热线,那样会把"已经给出了结论"误报成"已引导人工"。
FALLBACK_MARK = "抱歉,这个问题我暂时无法给出准确答复"
PAGE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>南方科技 · 智能客服(本地调试)</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
background: #f2f4f7; display: flex; justify-content: center; height: 100vh; }
.wrap { width: min(760px, 100%); display: flex; flex-direction: column; height: 100vh; }
header { padding: 14px 18px; background: #1f2d3d; color: #fff; font-size: 15px; }
header small { opacity: .65; margin-left: 8px; font-size: 12px; }
#log { flex: 1; overflow-y: auto; padding: 18px; }
.row { display: flex; margin-bottom: 14px; }
.row.user { justify-content: flex-end; }
.bubble { max-width: 78%; padding: 11px 14px; border-radius: 10px; line-height: 1.6;
white-space: pre-wrap; word-break: break-word; font-size: 14px; }
.user .bubble { background: #2f6fed; color: #fff; border-bottom-right-radius: 3px; }
.bot .bubble { background: #fff; color: #1f2d3d; border-bottom-left-radius: 3px;
box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.meta { font-size: 11px; color: #8894a4; margin: 5px 2px 0; }
.meta .tag { display: inline-block; padding: 1px 6px; border-radius: 4px;
background: #e6ebf2; margin-right: 6px; }
.meta .tag.warn { background: #fde8e8; color: #b42318; }
footer { padding: 12px 18px 18px; background: #fff; border-top: 1px solid #e4e9f0;
display: flex; gap: 10px; }
input { flex: 1; padding: 12px 14px; border: 1px solid #d5dbe4; border-radius: 8px;
font-size: 14px; outline: none; }
input:focus { border-color: #2f6fed; }
button { padding: 12px 22px; border: 0; border-radius: 8px; background: #2f6fed; color: #fff;
font-size: 14px; cursor: pointer; }
button:disabled { background: #a9bbe0; cursor: default; }
.hint { font-size: 12px; color: #8894a4; padding: 0 18px 8px; }
</style>
</head>
<body>
<div class="wrap">
<header>南方科技 · 智能客服 <small>本地调试控制台(回答全部来自公司资料库)</small></header>
<div id="log"></div>
<div class="hint">可以直接试:<em>基金赎回几天到账</em> · <em>季季盈90天起投多少</em> ·
<em>C1 客户能买什么</em> · 然后再问 <em>那它风险高吗</em>(试试多轮指代)</div>
<footer>
<input id="box" placeholder="说点什么…" autocomplete="off">
<button id="send">发送</button>
</footer>
</div>
<script>
const log = document.getElementById('log');
const box = document.getElementById('box');
const send = document.getElementById('send');
const sessionId = 'web-' + Math.random().toString(36).slice(2, 10);
let busy = false;
function add(cls, text, meta) {
const row = document.createElement('div');
row.className = 'row ' + cls;
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.textContent = text;
const holder = document.createElement('div');
holder.appendChild(bubble);
if (meta) {
const m = document.createElement('div');
m.className = 'meta';
m.innerHTML = meta;
holder.appendChild(m);
}
row.appendChild(holder);
log.appendChild(row);
log.scrollTop = log.scrollHeight;
}
async function ask() {
const message = box.value.trim();
if (!message || busy) return;
busy = true; send.disabled = true;
add('user', message);
box.value = '';
add('bot', '正在查询公司资料…');
const pending = log.lastElementChild;
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, session_id: sessionId }),
});
const data = await res.json();
pending.remove();
const tagIntent = '<span class="tag">意图 ' + (data.intent || '?') + '</span>';
const tagGuide = data.guided ? '<span class="tag warn">已引导人工</span>' : '';
add('bot', data.answer || '(空回答)', tagIntent + tagGuide);
} catch (err) {
pending.remove();
add('bot', '控制台出错:' + err, '');
} finally {
busy = false; send.disabled = false; box.focus();
}
}
send.onclick = ask;
box.addEventListener('keydown', (e) => { if (e.key === 'Enter') ask(); });
box.focus();
</script>
</body>
</html>
"""
def token(subject: str) -> str:
settings = get_settings()
private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8")
now = dt.datetime.now(dt.UTC)
return jwt.encode(
{
"sub": subject, "iss": settings.jwt_issuer, "aud": settings.jwt_audience,
"exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5),
"jti": str(uuid.uuid4()),
},
private_key,
algorithm="RS256",
)
def build_console(user_id: str) -> FastAPI:
console = FastAPI(title="客服本地控制台")
# 底座以 in-process 方式挂载:不经过网络、不新增任何路由,私钥只在签发时用到
base_app = create_app()
auth = {"Authorization": f"Bearer {token(user_id)}"}
@console.get("/", response_class=HTMLResponse)
async def index() -> HTMLResponse:
return HTMLResponse(PAGE)
@console.post("/api/chat")
async def chat(payload: dict[str, Any]) -> dict[str, Any]:
message = str(payload.get("message") or "").strip()
session_id = str(payload.get("session_id") or f"web-{uuid.uuid4().hex[:8]}")
if not message:
return {"answer": "请先输入内容。", "intent": "", "guided": False}
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=base_app), base_url="http://base", timeout=120
) as client:
accepted = await client.post(
"/api/v1/agent-runs",
json={
"agent_type": AGENT_TYPE, "message": message,
"session_id": session_id, "idempotency_key": uuid.uuid4().hex,
},
headers=auth,
)
if accepted.status_code != 202:
body = accepted.text[:200]
return {"answer": f"受理失败({accepted.status_code}):{body}",
"intent": "", "guided": False}
run_id = accepted.json()["data"]["run_id"]
# 本控制台自己驱动这一条 run;若常驻 Worker 同时在跑会与它抢队列
await WorkerRuntime().execute(run_id)
detail = (await client.get(
f"/api/v1/agent-runs/{run_id}", headers=auth
)).json()["data"]
result = detail.get("result") or {}
answer = str(result.get("content") or "")
if detail.get("status") != "succeeded":
answer = answer or f"运行未成功:{detail.get('status')} {detail.get('error_code')}"
# intent 在结果里可能是 {"intent": ..., "confidence": ...},也可能已是字符串
raw_intent = result.get("intent")
intent = raw_intent.get("intent") if isinstance(raw_intent, dict) else raw_intent
return {
"answer": answer,
"intent": intent or "",
"guided": FALLBACK_MARK in answer,
}
return console
def main() -> None:
parser = argparse.ArgumentParser(description="客服本地控制台")
parser.add_argument("--port", type=int, default=8098)
parser.add_argument("--user", default="9001", help="以哪个客户身份对话(默认 9001)")
args = parser.parse_args()
print(f"控制台:http://127.0.0.1:{args.port} 身份={args.user}")
print("提示:运行前请停掉常驻 Worker,否则它会抢走任务、页面会一直转圈。")
uvicorn.run(build_console(args.user), host="127.0.0.1", port=args.port, log_level="warning")
if __name__ == "__main__":
main()