Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
307c18bd77 | ||
|
|
6c6c3891a8 | ||
|
|
cad34dcef2 |
+2
-2
@@ -7,7 +7,7 @@ MYSQL_PORT=3306
|
||||
MYSQL_DATABASE=jinrong_agent
|
||||
MYSQL_CORE_DATABASE=jinrong_core
|
||||
MYSQL_USER=root
|
||||
MYSQL_PASSWORD=
|
||||
MYSQL_PASSWORD=123456
|
||||
|
||||
# Redis(Docker:docker compose up -d redis · 宿主机 6380 → 容器 6379 · 避开 Windows Redis 3.x 占 6379)
|
||||
REDIS_URL=redis://127.0.0.1:6380/0
|
||||
@@ -29,7 +29,7 @@ OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
EMBED_MODEL=bge-m3
|
||||
|
||||
# DeepSeek LLM
|
||||
DEEPSEEK_API_KEY=
|
||||
DEEPSEEK_API_KEY=sk-542ebe0a13d748a0a571818ee4c2c0ea
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
|
||||
# JWT (T-01): production uses RS256 public key from IdP; empty path -> HS256 dev secret
|
||||
|
||||
@@ -30,6 +30,7 @@ data/kb/*
|
||||
node_modules/
|
||||
dist/
|
||||
web/dist/
|
||||
web/.npm-cache/
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
|
||||
@@ -43,6 +43,7 @@ from app.repository.risk_repository import RiskRepository
|
||||
from app.repository.session_repository import SessionRepository
|
||||
from app.service import agent_service, input_guard, memory_service
|
||||
from app.service.customer_service import prepare_customer_stream, run_customer_chat
|
||||
from app.service.subject_resolution import resolve_deictic_subject
|
||||
from app.utils.compliance_guard import RISK_DISCLAIMER
|
||||
from app.utils.exceptions import ApiError, StateConflict
|
||||
from app.utils.trace import current_trace, new_trace
|
||||
@@ -469,6 +470,8 @@ def chat_stream_api(
|
||||
has_disclaimer = bool(customer_prep.get("has_disclaimer", False))
|
||||
else:
|
||||
history = memory_service.get_recent(agent_type, sid)
|
||||
if resolve_deictic_subject(message).kind in ("assistant", "identity"):
|
||||
has_disclaimer = False
|
||||
|
||||
def _events() -> Iterator[str]:
|
||||
meta_disclaimer = (
|
||||
|
||||
@@ -35,6 +35,11 @@ from app.service.trade_action_service import (
|
||||
should_use_suitability_instead_of_trade,
|
||||
)
|
||||
from app.service.trade_flow_service import trade_dialogue_should_continue
|
||||
from app.service.subject_resolution import (
|
||||
ASSISTANT_IDENTITY_REPLY,
|
||||
ASSISTANT_SUBJECT_CLARIFICATION,
|
||||
resolve_deictic_subject,
|
||||
)
|
||||
|
||||
# 客户/对外口径的固定免责声明(随回复文本尾部输出;G-08 阻断响应另有两要素)
|
||||
CHAT_DISCLAIMER = "以上内容由 AI 生成,仅供业务参考,不构成投资建议。"
|
||||
@@ -138,6 +143,8 @@ def tool_node(state: ChatState) -> dict[str, Any]:
|
||||
为 True 的 Tool 仍需绑定客户(无则空转,由 run_tool 判
|
||||
TOOL_BLOCKED_NO_CUSTOMER)。
|
||||
"""
|
||||
if resolve_deictic_subject(state.get("user_message") or "").kind in ("assistant", "identity"):
|
||||
return {"tool_results": []}
|
||||
if not (state.get("session_id") and state.get("actor")):
|
||||
return {"tool_results": []}
|
||||
ctx = _chat_history_context(state.get("history") or [])
|
||||
@@ -294,6 +301,20 @@ def chat(
|
||||
T-04:session 上下文(session_id/trace_id/actor/customer_id)可选传入;
|
||||
缺省时 Tool 节点空转(既有用例与纯闲聊不受影响)。
|
||||
"""
|
||||
subject = resolve_deictic_subject(user_message)
|
||||
if subject.kind in ("assistant", "identity"):
|
||||
reply = (
|
||||
ASSISTANT_IDENTITY_REPLY
|
||||
if subject.kind == "identity"
|
||||
else ASSISTANT_SUBJECT_CLARIFICATION
|
||||
)
|
||||
return {
|
||||
"reply": reply,
|
||||
"has_disclaimer": False,
|
||||
"tool_results": [],
|
||||
"pending_trade": None,
|
||||
}
|
||||
|
||||
final = _get_graph().invoke(
|
||||
_base_state(
|
||||
agent_type,
|
||||
@@ -382,6 +403,17 @@ def stream_chat(
|
||||
异常上抛由路由层转 SSE error 事件,保证前端拿到的是结构化错误而非
|
||||
断流。
|
||||
"""
|
||||
subject = resolve_deictic_subject(user_message)
|
||||
if subject.kind in ("assistant", "identity"):
|
||||
reply = (
|
||||
ASSISTANT_IDENTITY_REPLY
|
||||
if subject.kind == "identity"
|
||||
else ASSISTANT_SUBJECT_CLARIFICATION
|
||||
)
|
||||
yield ("delta", reply)
|
||||
yield ("done", reply)
|
||||
return
|
||||
|
||||
state = _base_state(
|
||||
agent_type,
|
||||
history,
|
||||
|
||||
@@ -43,6 +43,11 @@ from app.service.analyst_chart import (
|
||||
parse_analyze_json,
|
||||
validate_chart_spec,
|
||||
)
|
||||
from app.service.subject_resolution import (
|
||||
ASSISTANT_IDENTITY_REPLY,
|
||||
ASSISTANT_SUBJECT_CLARIFICATION,
|
||||
resolve_deictic_subject,
|
||||
)
|
||||
|
||||
SQL_GEN_SYSTEM = (
|
||||
"你是金融数据查询助手。根据给定表结构与口径,把用户问题翻译成【一条】只读 SELECT SQL。"
|
||||
@@ -224,6 +229,14 @@ class AnalystAgent:
|
||||
resp = self._deny(exc.error_code, exc.message, trace_id)
|
||||
return self._audit_terminal(question, auth, session_id, trace_id, resp)
|
||||
|
||||
subject = resolve_deictic_subject(question)
|
||||
if subject.kind == "identity":
|
||||
resp = self._reply_assistant_identity(trace_id)
|
||||
return self._audit_terminal(question, auth, session_id, trace_id, resp)
|
||||
if subject.kind == "assistant":
|
||||
resp = self._clarify_assistant_subject(trace_id)
|
||||
return self._audit_terminal(question, auth, session_id, trace_id, resp)
|
||||
|
||||
question_for_sql = _nl_sql_hints(question)
|
||||
|
||||
scope: list[str] = resolve_analyst_scope(auth, domain, self.repo)
|
||||
@@ -790,6 +803,16 @@ class AnalystAgent:
|
||||
clarify=clarify,
|
||||
)
|
||||
|
||||
def _clarify_assistant_subject(self, trace_id: str) -> AnalystResponse:
|
||||
return AnalystResponse(
|
||||
answer=ASSISTANT_SUBJECT_CLARIFICATION,
|
||||
status="clarify",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
def _reply_assistant_identity(self, trace_id: str) -> AnalystResponse:
|
||||
return AnalystResponse(answer=ASSISTANT_IDENTITY_REPLY, trace_id=trace_id)
|
||||
|
||||
def _deny(self, code: str, msg: str, trace_id: str, domain: str = "") -> AnalystResponse:
|
||||
suggestions = {
|
||||
"AUTH_403_NOT_ASSIGNED": ["你仅能查名下客户的数据,可尝试问自己名下客户的持仓、风险分布等。"],
|
||||
|
||||
@@ -61,6 +61,11 @@ from app.service.profile_service import (
|
||||
render_profile_context,
|
||||
)
|
||||
from app.service.rag_service import VisitorRagService
|
||||
from app.service.subject_resolution import (
|
||||
ASSISTANT_IDENTITY_REPLY,
|
||||
ASSISTANT_SUBJECT_CLARIFICATION,
|
||||
resolve_deictic_subject,
|
||||
)
|
||||
from app.tool.core_ro_tool import (
|
||||
query_holdings,
|
||||
query_product_nav,
|
||||
@@ -664,6 +669,12 @@ def run_customer_chat(
|
||||
end_session: bool = False,
|
||||
) -> tuple[str, bool, str, bool]:
|
||||
"""客户对话入口,返回 (reply, has_disclaimer, intent, transfer_to_human)。"""
|
||||
subject = resolve_deictic_subject(message)
|
||||
if subject.kind == "identity":
|
||||
return ASSISTANT_IDENTITY_REPLY, False, "chit_chat", False, None
|
||||
if subject.kind == "assistant":
|
||||
return ASSISTANT_SUBJECT_CLARIFICATION, False, "clarify", False, None
|
||||
|
||||
state: CustomerState = {
|
||||
"session_id": session_id,
|
||||
"trace_id": ctx.trace_id,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Resolve first- and second-person references in business data questions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
ASSISTANT_SUBJECT_CLARIFICATION = (
|
||||
"我没有个人名下的持仓、账户或客户数据。"
|
||||
"您是想查询您本人名下的数据,还是指定某位客户/账户?"
|
||||
)
|
||||
ASSISTANT_IDENTITY_REPLY = (
|
||||
"我是本系统的智能金融助手,可以协助您查询账户、持仓、交易和产品相关信息。"
|
||||
)
|
||||
|
||||
_BUSINESS_DATA_NOUNS = (
|
||||
r"持仓|持有|账户|帐户|资产|基金|理财|仓位|市值|盈亏|"
|
||||
r"交易(?:记录|流水|明细)?|风险(?:测评|等级|评估)?|风评|客户(?:名单)?"
|
||||
)
|
||||
|
||||
_ASSISTANT_SUBJECT_RE = re.compile(
|
||||
rf"(?:你|您)(?:本人)?\s*名下|"
|
||||
rf"(?:你|您)(?:的)?\s*(?:{_BUSINESS_DATA_NOUNS})|"
|
||||
rf"(?:你|您)\s*(?:有|持有|管理|负责)\s*(?:{_BUSINESS_DATA_NOUNS})"
|
||||
)
|
||||
_ACTOR_SUBJECT_RE = re.compile(
|
||||
rf"(?:我|本人)\s*名下|"
|
||||
rf"(?:我的|本人(?:的)?|我)\s*(?:{_BUSINESS_DATA_NOUNS})|"
|
||||
rf"(?:我|本人)\s*(?:有|持有|管理|负责)\s*(?:{_BUSINESS_DATA_NOUNS})"
|
||||
)
|
||||
_ASSISTANT_IDENTITY_RE = re.compile(
|
||||
r"^\s*(?:请问|想问一下|我想问一下)?\s*(?:你|您)\s*(?:是|叫)?\s*谁"
|
||||
r"\s*(?:呀|啊|呢)?[??!!。]*\s*$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubjectResolution:
|
||||
kind: Literal["actor", "assistant", "identity", "explicit", "unspecified"]
|
||||
|
||||
|
||||
def resolve_deictic_subject(message: str) -> SubjectResolution:
|
||||
"""Classify only data-bearing first-/second-person business references.
|
||||
|
||||
Explicit customer/account parsing remains owned by the existing authorization
|
||||
and query paths. This guard prevents second-person wording from inheriting
|
||||
the current login subject.
|
||||
"""
|
||||
text = message or ""
|
||||
if _ASSISTANT_IDENTITY_RE.search(text):
|
||||
return SubjectResolution(kind="identity")
|
||||
if _ASSISTANT_SUBJECT_RE.search(text):
|
||||
return SubjectResolution(kind="assistant")
|
||||
if _ACTOR_SUBJECT_RE.search(text):
|
||||
return SubjectResolution(kind="actor")
|
||||
return SubjectResolution(kind="unspecified")
|
||||
@@ -0,0 +1,174 @@
|
||||
# 数据分析 Agent 答辩复盘材料
|
||||
|
||||
> 适用角色:数据分析 Agent 负责人
|
||||
>
|
||||
> 证据基线:`merger` 分支,提交 `2296a4a`。本文只描述当前仓库已有代码和文档;运行时测试状态与环境前置单独标明。
|
||||
|
||||
## 1. 30 秒开场
|
||||
|
||||
本模块解决的是金融代销场景中“业务人员会提问题,但不会写 SQL,也不能越权查数据”的问题。用户用自然语言提问后,系统先识别口径和访问范围,再优先命中已发布模板;未命中时才由 LLM 生成 SQL。SQL 在执行前经过只读、单语句、表白名单和角色数据域校验,执行后记录可追溯审计。查询与解读分离,解读中的数字还要和结果表逐项核对,避免模型“看表说错数”。
|
||||
|
||||
一句话定位:**不是让大模型直接查库,而是把自然语言查询放进可控、可审计、可降级的数据分析流水线。**
|
||||
|
||||
## 2. 业务问题与范围
|
||||
|
||||
| 问题 | 设计回应 | 仓库证据 |
|
||||
| --- | --- | --- |
|
||||
| 非技术人员难以获取经营数据 | 自然语言问数,返回 SQL、表格和可选解释/图表 | `POST /api/analyst/chat`、`/interpret`、`/analyze` |
|
||||
| 指标口径容易歧义 | 指标字典、结构化 clarify、多义问题不猜测 | `MetricRegistry`、`ClarifyPayload` |
|
||||
| 数据权限不能靠提示词约束 | 认证上下文映射数据域,SQL Guard 强制检查 | `app/api/analyst_auth_adapter.py`、`app/service/sql_guard.py` |
|
||||
| LLM 输出不稳定或成本较高 | 已发布模板优先、few-shot 受控入库、结果缓存 | `TemplateService`、`CacheService` |
|
||||
| 解释可能说错数字 | 结果快照解读,数字报栏校验,失败后降级为表格 | `app/service/guardrail.py` |
|
||||
| 分析经验不能沉淀 | 口径字典、few-shot、参数化模板支持发布和热加载 | `/api/analyst/assets*` |
|
||||
|
||||
本期明确不做:自动交易、投资建议、跨 Agent 调用 LLM、非受控的任意 SQL、多轮分析追问闭环和看板下钻。
|
||||
|
||||
## 3. 功能清单
|
||||
|
||||
| 能力 | 接口/页面 | 答辩要点 |
|
||||
| --- | --- | --- |
|
||||
| 问数 | `POST /api/analyst/chat`,`web/src/pages/analytics/AnalystQueryPage.tsx` | 默认只返回 SQL 与表格,避免每次问数都触发解释生成 |
|
||||
| 按需解读 | `POST /api/analyst/interpret` | 使用上一轮快照,不重新查库,降低口径漂移风险 |
|
||||
| 图表或文字分析 | `POST /api/analyst/analyze` | `ChartSpec` 校验列名、图表类型和字段关系,输出 line/bar/column/pie/none |
|
||||
| 模糊口径澄清 | 结构化 `clarify` 响应 | 如“近 30 天交易流水”先要求选择笔数、金额或明细,不擅自猜测 |
|
||||
| 模板与缓存 | `/template-prompts`,结果 `meta` | 模板命中跳过 LLM 生成 SQL;缓存键含权限指纹、SQL 和表世代 |
|
||||
| 抽样溯源与转人工 | `GET /query/{trace_id}/sample`、`POST /escalate` | 聚合结果可抽样复核;超时或失败可留痕升级给人工 |
|
||||
| 指标资产治理 | `/assets`、`/assets/{kind}/{id}/publish`、`/dict/ambiguity-check` | 仅 analyst 可写入和发布字典/few-shot/模板,发布后热加载 |
|
||||
| 看数与运营指标 | `/dashboard`、`/ops/metrics` | 根据角色返回指标卡;运营接口仅对 analyst 开放 |
|
||||
|
||||
## 4. 核心架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[分析员/客户/理财师/风控] --> W[React 问数工作台]
|
||||
W --> A[FastAPI /api/analyst]
|
||||
A --> X[认证适配与数据域判定]
|
||||
X --> T{模板命中?}
|
||||
T -->|是| SQL[参数化 SQL]
|
||||
T -->|否| LLM[LLM 生成只读 SQL]
|
||||
LLM --> G[SQL Guard]
|
||||
SQL --> G
|
||||
G --> R[AnalyticsRepo 只读执行]
|
||||
R --> C[结果缓存与查询留痕]
|
||||
C --> O[SQL + 表格 + 元数据]
|
||||
O --> I[按需 interpret/analyze]
|
||||
I --> B[数字报栏 + ChartSpec 校验]
|
||||
B --> W
|
||||
```
|
||||
|
||||
数据分层:`jinrong_core` 保存客户、产品、持仓、流水等事实数据;`jinrong_agent` 保存 `analytics_query_log`、指标字典、few-shot 和模板等 Agent 资产。分析模块的业务查询为只读,审计与资产沉淀写入 Agent 库,避免污染 Core 账务事实。
|
||||
|
||||
## 5. 关键设计与可答追问
|
||||
|
||||
### 5.1 为什么不让 LLM 直接执行 SQL?
|
||||
|
||||
因为提示词不是安全边界。生成的 SQL 必须经过 `sql_guard.validate()`:
|
||||
|
||||
1. 仅允许一条 `SELECT` 或 `WITH ... SELECT`。
|
||||
2. 拒绝 DDL/DML、危险关键字和多语句。
|
||||
3. 仅允许定义在白名单中的表。
|
||||
4. 按角色校验数据域:客户只能查本人、理财师只能查名下客户、运营只能查聚合结果、风控可读全量台账、分析员可在授权范围内全量分析。
|
||||
5. 记录 `analytics_query_log`,包括自然语言问题、生成 SQL、哈希、执行状态、耗时、结果摘要与 trace。
|
||||
|
||||
可直接回答:**模型负责理解语言,规则负责决定能否执行。权限不交给模型判断。**
|
||||
|
||||
### 5.2 模板、few-shot 与缓存分别解决什么?
|
||||
|
||||
| 机制 | 解决的问题 | 关键约束 |
|
||||
| --- | --- | --- |
|
||||
| 参数化模板 | 高频固定口径的准确性、速度和成本 | 命中后直接渲染 SQL,跳过 LLM 生成 |
|
||||
| few-shot | 复杂查询的示范结构 | 只读取已发布且数据库内受控的示例 |
|
||||
| 指标字典 | 指标定义和别名统一 | 发布前可做歧义扫描 |
|
||||
| 结果缓存 | 同权限、同 SQL 的重复查询 | 缓存键包含权限指纹、SQL 哈希和表世代;写侧变更可 bump 世代失效 |
|
||||
|
||||
### 5.3 如何防止“AI 解释正确 SQL,却把数字说错”?
|
||||
|
||||
问数与解读拆分。`interpret`/`analyze` 只接收当轮 SQL、结果表和元数据快照,不再查询数据库;`guardrail.verify()` 从表格提取合法数字、列汇总和行数,再比对模型文本中的数字。校验失败后重试一次,仍失败则返回 `degrade`,保留表格而不输出不可信解释。
|
||||
|
||||
### 5.4 为什么客户也能进入数据分析页?
|
||||
|
||||
客户不是得到分析员权限,而是得到 `self` 数据域:涉及客户明细必须带本人 `customer_id` 过滤;解释文本还附带“AI 分析有风险”的提示,并禁止输出投资建议、收益承诺和买卖时点。该设计实现了“同一个能力入口,不同的数据域和表达边界”。
|
||||
|
||||
## 6. 5 分钟 Demo 脚本
|
||||
|
||||
### 演示前置
|
||||
|
||||
1. 配置 `.env` 中的 MySQL、Redis 与 `DEEPSEEK_API_KEY`。当前本机若未设置 `MYSQL_PASSWORD`,业务接口会被 MySQL 拒绝,不能作为答辩环境。
|
||||
2. 初始化分析种子:`scripts/dev/seed_analyst.ps1`,或分别执行指标字典与查询模板种子 SQL。
|
||||
3. 启动后端 `uvicorn app.main:app --reload --port 8000`,前端在 `web/` 执行 `npm run dev`。
|
||||
4. 以分析员 Demo 账户进入“数据分析/问数”页面。
|
||||
|
||||
### 推荐动作与口述
|
||||
|
||||
| 时间 | 操作 | 看到的信号 | 口述 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0:00-0:30 | 打开问数工作台 | 输入框、模板提示 | “这是独立于通用 Chat 的问数流水线,默认先给可审计的 SQL 和表。” |
|
||||
| 0:30-1:20 | 提问“客户总数是多少” | `customer_total_count` 模板命中标签 | “高频口径命中受控模板,SQL 不由模型临时生成。” |
|
||||
| 1:20-1:45 | 原样再问一次 | 结果缓存标签 | “缓存按权限指纹和表世代隔离,不会把别人的结果复用给当前用户。” |
|
||||
| 1:45-2:30 | 点击“分析该数据” | 解读文字或图表 | “解读只使用本轮结果快照,并经过数字报栏,不重新查库。” |
|
||||
| 2:30-3:15 | 提问“近 30 天交易流水是多少” | clarify 选项 | “系统发现流水口径不唯一,先澄清笔数、金额或明细,不猜测。” |
|
||||
| 3:15-4:00 | 展示抽样溯源或失败转人工 | sample/escalate | “聚合结果支持抽样复核;执行超时不会伪造结果,而是保留 trace 后转人工。” |
|
||||
| 4:00-5:00 | 说明角色边界 | 客户 self、理财师 assigned 的 403 结果 | “权限约束同时在认证和 SQL 审核层落实,越权 SQL 不会到数据库执行。” |
|
||||
|
||||
备用演示:提问“近 30 天申购金额”展示模板;提问“涨幅最高和最低的产品”说明双极值使用 `UNION ALL`,避免单个 `LIMIT 1` 丢失另一端结果。
|
||||
|
||||
## 7. 质量证据与可追溯性
|
||||
|
||||
| 证据 | 覆盖内容 | 位置 |
|
||||
| --- | --- | --- |
|
||||
| 分析 Agent 单元测试 | 成功查询、按需解读、错误数字降级、越权拒绝、客户 self、模板命中、clarify、图表、超时升级、抽样和审计 | `tests/test_wave6_analyst_agent.py` |
|
||||
| ChartSpec 测试 | 图表类型、未知列、饼图约束、JSON 解析与容错 | `tests/test_wave6_analyst_chart.py` |
|
||||
| 缓存测试 | SQL 哈希、权限指纹、TTL、世代失效、无关表不失效 | `tests/test_wave6_analyst_cache.py` |
|
||||
| 仓储测试 | 聚合查询、理财师范围、数据日期、查询日志回写 | `tests/test_wave6_analytics_repo.py` |
|
||||
| 答辩 SOP | 问数模板、缓存、clarify、抽样、转人工的操作路径 | `docs/答辩/DEMO-SOP-问数.md` |
|
||||
|
||||
仓库记忆文档记录的历史基线为:全量 `926 passed / 65 failed / 1 skipped`,排除 `test_sprint*` 的 merger 回归为 `896 passed`。该数字不是本次工作区复测结果:当前默认 Python 为 3.14 且未安装 pytest,项目文档要求的 Python 3.13 也不在本机,因此答辩前必须在标准环境重新执行测试并记录实际结果。
|
||||
|
||||
## 8. 诚实复盘:已知边界与改进计划
|
||||
|
||||
| 边界/风险 | 当前状态 | 后续动作 |
|
||||
| --- | --- | --- |
|
||||
| 多轮分析追问 D-09 | 未完成闭环 | 设计澄清会话状态、确认口径后复用安全快照 |
|
||||
| 看板下钻 D-12 | 仅开放指标卡 | 增加从指标卡到受控问数的跳转,不直接暴露明细 |
|
||||
| 真实 E2E | 测试中明确依赖真实 MySQL 与 DeepSeek,当前为 skip | 固化标准 `.env`、种子与可复现 smoke/battery 报告 |
|
||||
| LLM 可用性 | 无 Key 时模板问数仍可用,解释能力降级 | 对高频问法继续沉淀模板;把模型不可用状态前端显式化 |
|
||||
| SQL Guard | 属规则白名单方案,不是通用 SQL 语义证明 | 持续补充表、敏感列、聚合语义和绕过用例的回归测试 |
|
||||
| 本地环境 | 当前工作区尚未配置 MySQL 密码,业务读接口会返回 500 | 答辩前完成 `.env`、双库、Redis 与种子校验,使用 `/api/ready` 外加真实问数 smoke 验收 |
|
||||
|
||||
## 9. 高频问答
|
||||
|
||||
**Q:NL2SQL 最大风险是什么?**
|
||||
|
||||
A:不是“生成得不对”这么简单,而是越权、写操作、口径漂移和错误解释。对应地,我们用数据域、SQL Guard、指标字典/模板和数字报栏做四层控制,并把每次执行写入分析查询日志。
|
||||
|
||||
**Q:为什么不把所有问题都做成模板?**
|
||||
|
||||
A:模板适合高频、口径稳定的问题,成本低且确定性高;分析员仍要处理长尾探索问题,因此保留 LLM 生成,但放在 SQL Guard 和审计之后。两者是分层,而不是二选一。
|
||||
|
||||
**Q:缓存会不会造成数据不新鲜或越权泄露?**
|
||||
|
||||
A:缓存键包含权限指纹和 SQL 哈希,写入交易、预警和 L3 等相关表时通过世代 bump 失效;因此缓存服务于同权限、同查询、同数据版本,不跨用户复用结果。
|
||||
|
||||
**Q:图表由模型生成,怎么避免乱画?**
|
||||
|
||||
A:模型只产出声明式 `ChartSpec`,后端检查图表类型、x/y/series 字段是否存在于结果列,再由前端渲染;模型不直接构造图表数据。
|
||||
|
||||
**Q:你在这个模块中最核心的工程贡献是什么?**
|
||||
|
||||
A:把“自然语言到结论”拆成可验证的节点:模板/LLM 生成 SQL、SQL 安全校验、只读执行、审计、快照解读、数字报栏和人工升级。每个节点都有清晰输入输出和可单测的实现,而不是把逻辑堆在一个聊天接口里。
|
||||
|
||||
## 10. 收尾陈述
|
||||
|
||||
数据分析 Agent 的价值不在于“能生成 SQL”,而在于它把金融数据查询中的权限、口径、审计和可信解释做成了系统能力。当前版本已经覆盖从问数到解释、图表、治理资产和异常升级的最小闭环;下一阶段会补齐多轮追问、看板下钻和标准化真实环境验收。
|
||||
|
||||
## 11. 主要源码索引
|
||||
|
||||
- API:`app/api/analyst.py`
|
||||
- 鉴权与数据域:`app/api/analyst_auth_adapter.py`
|
||||
- 编排:`app/service/analyst_agent.py`
|
||||
- SQL 安全:`app/service/sql_guard.py`
|
||||
- 数字报栏:`app/service/guardrail.py`
|
||||
- 数据仓储与资产:`app/service/analytics_repo.py`
|
||||
- 图表契约:`app/service/analyst_chart.py`、`app/model/analyst_schemas.py`
|
||||
- 前端问数页:`web/src/pages/analytics/AnalystQueryPage.tsx`
|
||||
- 现有专项 SOP:`docs/答辩/DEMO-SOP-问数.md`
|
||||
@@ -203,6 +203,41 @@ def test_stream_advisor_no_disclaimer(env, fake_llm):
|
||||
assert [(m["role"], m["has_disclaimer"]) for m in msgs] == [("user", 0), ("assistant", 0)]
|
||||
|
||||
|
||||
def test_stream_second_person_advisor_question_skips_tool_and_llm(env, fake_llm):
|
||||
r = env["client"].post(
|
||||
"/api/chat/stream",
|
||||
json={"message": "你名下客户有哪些持仓产品?"},
|
||||
headers=ADVISOR,
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
text_all = "".join(
|
||||
p["choices"][0]["delta"].get("content", "") for p in _payloads(r)
|
||||
)
|
||||
assert "我没有个人名下的持仓" in text_all
|
||||
assert fake_llm.calls == []
|
||||
assert _rows(env["engine"], "SELECT 1 FROM agent_tool_call") == []
|
||||
|
||||
|
||||
def test_stream_assistant_identity_question_skips_llm_and_disclaimer(env, fake_llm):
|
||||
r = env["client"].post(
|
||||
"/api/chat/stream",
|
||||
json={"message": "你是谁?"},
|
||||
headers=RISK,
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
payloads = _payloads(r)
|
||||
assert payloads[0]["meta"]["has_disclaimer"] is False
|
||||
text_all = "".join(
|
||||
p["choices"][0]["delta"].get("content", "") for p in payloads
|
||||
)
|
||||
assert "智能金融助手" in text_all
|
||||
assert fake_llm.calls == []
|
||||
msgs = _rows(env["engine"], "SELECT role, has_disclaimer FROM agent_message ORDER BY seq_no")
|
||||
assert [(m["role"], m["has_disclaimer"]) for m in msgs] == [("user", 0), ("assistant", 0)]
|
||||
|
||||
|
||||
def test_stream_degraded_without_key(env, monkeypatch):
|
||||
"""无 LLM key:降级整块推送(契约不变,前端无需特判)。"""
|
||||
monkeypatch.setattr(settings_mod.settings, "deepseek_api_key", "")
|
||||
|
||||
@@ -350,6 +350,39 @@ def test_graph_no_intent_skips_tool(fake_llm, tool_env):
|
||||
assert _tool_rows(tool_env["engine"]) == []
|
||||
|
||||
|
||||
def test_graph_second_person_advisor_question_skips_tool_and_llm(fake_llm, tool_env):
|
||||
out = agent_service.chat(
|
||||
"advisor",
|
||||
[],
|
||||
"你名下客户有哪些持仓产品?",
|
||||
session_id="sess-g2-subject",
|
||||
actor=ACTOR_ADVISOR,
|
||||
customer_id="CUST-9527",
|
||||
)
|
||||
|
||||
assert "我没有个人名下的持仓" in out["reply"]
|
||||
assert out["tool_results"] == []
|
||||
assert fake_llm.calls == []
|
||||
assert _tool_rows(tool_env["engine"]) == []
|
||||
|
||||
|
||||
def test_graph_assistant_identity_question_skips_tool_and_llm(fake_llm, tool_env):
|
||||
out = agent_service.chat(
|
||||
"advisor",
|
||||
[],
|
||||
"你是谁?",
|
||||
session_id="sess-g2-identity",
|
||||
actor=ACTOR_ADVISOR,
|
||||
customer_id="CUST-9527",
|
||||
)
|
||||
|
||||
assert "智能金融助手" in out["reply"]
|
||||
assert out["has_disclaimer"] is False
|
||||
assert out["tool_results"] == []
|
||||
assert fake_llm.calls == []
|
||||
assert _tool_rows(tool_env["engine"]) == []
|
||||
|
||||
|
||||
def test_graph_blocked_result_visible_to_llm(fake_llm, tool_env):
|
||||
"""归属拒绝以 blocked 结果注入(对话内呈现,非 403)。"""
|
||||
agent_service.chat(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from app.service.subject_resolution import resolve_deictic_subject
|
||||
|
||||
|
||||
def test_second_person_business_data_is_assistant_subject():
|
||||
assert resolve_deictic_subject("你名下有哪些持仓产品?").kind == "assistant"
|
||||
assert resolve_deictic_subject("您持有的账户有哪些?").kind == "assistant"
|
||||
|
||||
|
||||
def test_first_person_business_data_is_actor_subject():
|
||||
assert resolve_deictic_subject("我名下有哪些持仓产品?").kind == "actor"
|
||||
assert resolve_deictic_subject("我的持仓怎么样?").kind == "actor"
|
||||
|
||||
|
||||
def test_assistant_identity_question_is_classified_separately_from_business_subject():
|
||||
assert resolve_deictic_subject("你是谁?").kind == "identity"
|
||||
assert resolve_deictic_subject("请问您是谁呀").kind == "identity"
|
||||
@@ -112,6 +112,17 @@ def test_route_fallback_on_invalid_llm_label(env):
|
||||
assert reply == cs.FALLBACK_TEXT
|
||||
|
||||
|
||||
def test_assistant_identity_question_uses_fixed_reply_without_llm(env):
|
||||
reply, disc, intent, transfer, _pending = cs.run_customer_chat(
|
||||
_ctx(), "你是谁?", "s1", "CUST-9527"
|
||||
)
|
||||
|
||||
assert "智能金融助手" in reply
|
||||
assert intent == "chit_chat"
|
||||
assert disc is False and transfer is False
|
||||
assert env["llm"].calls == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 数据查询分支:param_extract → tool_call → interpret
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -135,6 +146,22 @@ def test_holding_query_end_to_end(env, monkeypatch):
|
||||
assert disc is False and transfer is False
|
||||
|
||||
|
||||
def test_second_person_holding_question_skips_customer_tool(env, monkeypatch):
|
||||
def unexpected_tool(*args, **kwargs):
|
||||
raise AssertionError("second-person question must not call the customer tool")
|
||||
|
||||
monkeypatch.setitem(cs._TOOL_BY_INTENT, "holding_query", unexpected_tool)
|
||||
|
||||
reply, disc, intent, transfer, pending = cs.run_customer_chat(
|
||||
_ctx(), "你名下有哪些持仓产品?", "s1", "CUST-9527"
|
||||
)
|
||||
|
||||
assert intent == "clarify"
|
||||
assert "我没有个人名下的持仓" in reply
|
||||
assert disc is False and transfer is False and pending is None
|
||||
assert env["llm"].calls == []
|
||||
|
||||
|
||||
def test_transaction_query_month_extraction(env, monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
|
||||
@@ -29,11 +29,13 @@ class FakeRepo:
|
||||
self.columns = list(columns)
|
||||
self.scope = scope or []
|
||||
self.logged = []
|
||||
self.executed_sql = []
|
||||
|
||||
def resolve_advisor_scope(self, sid):
|
||||
return self.scope
|
||||
|
||||
def execute_readonly(self, sql):
|
||||
self.executed_sql.append(sql)
|
||||
return {"columns": self.columns, "rows": self.rows}
|
||||
|
||||
def get_data_as_of(self):
|
||||
@@ -185,6 +187,58 @@ class TestAgentOrchestration(unittest.TestCase):
|
||||
self.assertEqual(resp.status, "success")
|
||||
self.assertIn("AI 分析有风险", resp.answer)
|
||||
|
||||
def test_second_person_business_data_clarifies_before_sql(self):
|
||||
auths = [
|
||||
ctx(
|
||||
["customer"],
|
||||
"CUST-9527",
|
||||
token_type="customer",
|
||||
customer_id="CUST-9527",
|
||||
),
|
||||
ctx(["advisor"], "STAFF-B"),
|
||||
ctx(["analyst"]),
|
||||
ctx(["risk_officer"], "STAFF-R"),
|
||||
ctx(["ops"], "STAFF-O"),
|
||||
]
|
||||
for auth in auths:
|
||||
with self.subTest(roles=auth.roles):
|
||||
repo = FakeRepo(scope=["CUST-9527"])
|
||||
llm = FakeLLM("SELECT * FROM core_holding", [])
|
||||
resp = AnalystAgent(llm=llm, repo=repo).run(
|
||||
"你名下有哪些持仓产品?", auth
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, "clarify")
|
||||
self.assertIn("我没有个人名下的持仓", resp.answer)
|
||||
self.assertEqual(llm.calls, 0)
|
||||
self.assertEqual(repo.executed_sql, [])
|
||||
self.assertEqual(len(repo.logged), 1)
|
||||
|
||||
def test_assistant_identity_uses_fixed_reply_before_sql(self):
|
||||
auths = [
|
||||
ctx(
|
||||
["customer"],
|
||||
"CUST-9527",
|
||||
token_type="customer",
|
||||
customer_id="CUST-9527",
|
||||
),
|
||||
ctx(["advisor"], "STAFF-B"),
|
||||
ctx(["analyst"]),
|
||||
ctx(["risk_officer"], "STAFF-R"),
|
||||
ctx(["ops"], "STAFF-O"),
|
||||
]
|
||||
for auth in auths:
|
||||
with self.subTest(roles=auth.roles):
|
||||
repo = FakeRepo(scope=["CUST-9527"])
|
||||
llm = FakeLLM("SELECT * FROM core_holding", [])
|
||||
resp = AnalystAgent(llm=llm, repo=repo).run("你是谁?", auth)
|
||||
|
||||
self.assertEqual(resp.status, "success")
|
||||
self.assertIn("智能金融助手", resp.answer)
|
||||
self.assertEqual(llm.calls, 0)
|
||||
self.assertEqual(repo.executed_sql, [])
|
||||
self.assertEqual(len(repo.logged), 1)
|
||||
|
||||
def test_template_hit_skips_llm_sql(self):
|
||||
tpl = QueryTemplate(
|
||||
template_key="customer_total_count",
|
||||
|
||||
@@ -383,6 +383,11 @@ export function AnalystQueryPage() {
|
||||
)}
|
||||
</Space>
|
||||
) : null}
|
||||
{(result.status === 'success' || result.status === 'degrade') &&
|
||||
result.answer.trim() &&
|
||||
result.table.rows.length === 0 ? (
|
||||
<Alert type="info" showIcon message={result.answer} />
|
||||
) : null}
|
||||
{['success', 'degrade', 'clarify', 'deny', 'error', 'escalate'].includes(result.status) ? (
|
||||
<Tooltip title={canAnalyze ? undefined : '请先问数并得到非空表格'}>
|
||||
<Button disabled={!canAnalyze} onClick={() => setAnalyzeOpen(true)}>
|
||||
|
||||
Reference in New Issue
Block a user