Files
group_fqcd_jr/tools/chat_console.py
张胜宇 eb1822d802 docs(W18): D2.4 索引口径更正为 AUTOINDEX(v1.6)+ D2.9 手动对话测试用例成文 + 空白消息 500 修复
- D2.4 v1.3 -> v1.6:索引统一 AUTOINDEX(设计初稿 HNSW/IVF_FLAT 未落地,实库直查为据)
  更正 9 处 + 新增 §4.1 索引口径落地注;语料 628 -> 675 块并补 fin_basic_collection 说明
  (三集合仍是唯一默认检索面,basic 并入会使 M-1 100% -> 91.3%)
- 新增 客服agent/D2.9-客服Agent手动对话测试用例-2026-09-20.md:
  46 条金标逐条可问 + 11 条边界 Z 组 + 判分四问 + M-1~M-10 手动汇总 + 真 HTTP 核验配方
  实测登记 3 项缺口:空白消息 500(已修)/ 语料档位口径矛盾(待裁定)/ 重复问句漂移(待裁定)
- 修修复:message 纯空白 500 -> 422 AGENT_INPUT_INVALID(入口补判据 + 回归测试)
- tools/chat_console.py 提示语去掉已下线产品名(季季盈)
- 落档:D1.1 §27(60 -> 61 份 / 客服agent 8 -> 9 份 / 注入校验 56 -> 57)、D2.1 v6.33、D1.6 §4.46
- 门禁:check_authoritative_docs 通过、_consistency.py GATE PASS、pytest 1915 passed / 0 failed
2026-09-20 17:31:40 +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>基金申购和赎回有哪些费率</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()