From 3ad244add09ec857d15fce2b0a2481325f61f2c2 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 9 Sep 2026 21:32:59 +0800 Subject: [PATCH] feat(analyst): Enhance dashboard metrics and customer service interactions - Updated the `dashboard` function in `analyst.py` to include additional metrics for different user roles, improving data visibility for analysts, customers, advisors, and risk officers. - Introduced a new `prepare_customer_stream` function in `customer_service.py` to facilitate streaming responses for customer interactions, enhancing the chat experience. - Added new API endpoints in `analyst.ts` for fetching dashboard metrics and managing analyst assets, streamlining data handling and user interactions. - Updated frontend components to support new dashboard features and asset management, ensuring a cohesive user experience across the application. This update significantly improves the functionality and usability of the analyst and customer service features, providing users with enhanced tools for data analysis and interaction. --- app/api/analyst.py | 37 ++++- app/api/chat.py | 55 ++++--- app/gateway/jwt_service.py | 4 +- app/service/customer_service.py | 30 +++- app/service/risk/chat_tools.py | 7 + docs/memory/FRAMEWORK.md | 10 +- docs/memory/MEMORY.md | 20 +-- docs/memory/REQUIREMENTS.md | 2 +- docs/memory/TODO.md | 78 +++++----- docs/项目框架设计/客服Agent-合并说明.md | 1 + docs/项目管理/接口契约发群-2026-09-09.md | 87 +++++++++++ scripts/agent/seed-analyst-metric-dict.sql | 53 +++++++ scripts/dev/rbac-seed-reference.md | 4 +- tests/test_chat_stream.py | 48 +++++-- tests/test_risk_chat_tools.py | 1 + web/src/App.tsx | 4 + web/src/api/analyst.ts | 27 ++++ web/src/api/risk.ts | 20 ++- web/src/api/simulate.ts | 40 ++++++ web/src/pages/analytics/AnalystAssetsPage.tsx | 108 ++++++++++++++ web/src/pages/analytics/AnalystQueryPage.tsx | 52 ++++++- web/src/pages/customer/CustomerChatPage.tsx | 4 +- web/src/pages/risk/RiskSimulatePage.tsx | 135 ++++++++++++++++++ web/src/routes/menus.tsx | 3 + 24 files changed, 727 insertions(+), 103 deletions(-) create mode 100644 docs/项目管理/接口契约发群-2026-09-09.md create mode 100644 scripts/agent/seed-analyst-metric-dict.sql create mode 100644 web/src/api/simulate.ts create mode 100644 web/src/pages/analytics/AnalystAssetsPage.tsx create mode 100644 web/src/pages/risk/RiskSimulatePage.tsx diff --git a/app/api/analyst.py b/app/api/analyst.py index 82221e7..91b9a8f 100644 --- a/app/api/analyst.py +++ b/app/api/analyst.py @@ -61,17 +61,44 @@ def dashboard( "ops": ["近30天申购金额", "近30天赎回金额", "各产品类型规模TOP"], }.get(role, []) metrics: dict = {} - if role == "analyst": - try: + try: + if role == "analyst": res = agent.repo.execute_readonly( "SELECT (SELECT COUNT(*) FROM core_customer) AS customers, " "(SELECT COALESCE(SUM(market_value),0) FROM core_holding) AS holdings, " - "(SELECT COUNT(*) FROM jinrong_agent.risk_alert WHERE status='pending_review') AS pending" + "(SELECT COUNT(*) FROM jinrong_agent.risk_alert WHERE status='pending_review') AS pending, " + "(SELECT COUNT(*) FROM jinrong_agent.analytics_metric_dict WHERE status='published') AS dict_count" ) if res["rows"]: metrics = dict(zip(res["columns"], res["rows"][0])) - except Exception: # noqa: BLE001 - pass + elif role == "customer" and auth.customer_id: + cid = auth.customer_id + res = agent.repo.execute_readonly( + f"SELECT (SELECT COALESCE(SUM(market_value),0) FROM core_holding WHERE customer_id='{cid}') AS holdings, " + f"(SELECT COUNT(*) FROM core_trade WHERE customer_id='{cid}' AND trade_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)) AS trades_30d" + ) + if res["rows"]: + metrics = dict(zip(res["columns"], res["rows"][0])) + elif role == "advisor" and auth.subject_id: + scope = agent.repo.resolve_advisor_scope(auth.subject_id) + if scope: + ids = ",".join(f"'{x}'" for x in scope) + res = agent.repo.execute_readonly( + f"SELECT COUNT(DISTINCT customer_id) AS clients, " + f"COALESCE(SUM(market_value),0) AS aum FROM core_holding WHERE customer_id IN ({ids})" + ) + if res["rows"]: + metrics = dict(zip(res["columns"], res["rows"][0])) + else: + metrics = {"clients": 0, "aum": 0} + elif role == "risk_officer": + res = agent.repo.execute_readonly( + "SELECT COUNT(*) AS pending FROM jinrong_agent.risk_alert WHERE status='pending_review'" + ) + if res["rows"]: + metrics = dict(zip(res["columns"], res["rows"][0])) + except Exception: # noqa: BLE001 + pass return {"role": role, "domain": domain, "cards": cards, "metrics": metrics} diff --git a/app/api/chat.py b/app/api/chat.py index 61abe53..597cf17 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -42,7 +42,8 @@ from app.repository.core_ro import CoreReadOnlyRepository 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 run_customer_chat +from app.service.customer_service import prepare_customer_stream, run_customer_chat +from app.utils.compliance_guard import RISK_DISCLAIMER from app.utils.exceptions import ApiError, StateConflict from app.utils.trace import current_trace, new_trace @@ -431,40 +432,60 @@ def chat_stream_api( sid, customer_id = _prepare_turn(req, auth, agent_type) trace_id = current_trace() or new_trace() has_disclaimer = agent_service.needs_disclaimer(agent_type) - history = memory_service.get_recent(agent_type, sid) + customer_prep: dict[str, Any] | None = None + history: list[dict] = [] + if agent_type == "customer": + host_ctx = host_auth_for_customer_service(auth, trace_id=trace_id) + cust_id = customer_id or auth.customer_id or auth.actor_id + customer_prep = prepare_customer_stream( + host_ctx, message, sid, cust_id, req.end_session + ) + has_disclaimer = bool(customer_prep.get("has_disclaimer", False)) + else: + history = memory_service.get_recent(agent_type, sid) def _events() -> Iterator[str]: - # 首帧:meta 先下发 session_id(前端刷新后可续聊)+ 免责声明文本 - # (customer/risk 线合规要求:流式正文先出,声明不能等到最后)。 + meta_disclaimer = ( + RISK_DISCLAIMER + if agent_type == "customer" and has_disclaimer + else agent_service.CHAT_DISCLAIMER if has_disclaimer else None + ) meta: dict[str, Any] = { "session_id": sid, "agent_type": agent_type, "customer_id": customer_id, "trace_id": trace_id, "has_disclaimer": has_disclaimer, - "disclaimer": agent_service.CHAT_DISCLAIMER if has_disclaimer else None, + "disclaimer": meta_disclaimer, } yield _chunk(trace_id, {"role": "assistant"}, meta=meta) full: list[str] = [] try: - for kind, text in agent_service.stream_chat( - agent_type, - history, - message, - session_id=sid, - trace_id=trace_id, - actor={"actor_id": auth.actor_id, "roles": auth.roles, "token_type": auth.token_type}, - customer_id=customer_id, - ): - if kind == "delta": + if agent_type == "customer": + assert customer_prep is not None + for text in customer_prep["chunks"]: full.append(text) yield _chunk(trace_id, {"content": text}) - except Exception as exc: # 生成中异常:整轮不落库,结构化错误收尾 + else: + for kind, text in agent_service.stream_chat( + agent_type, + history, + message, + session_id=sid, + trace_id=trace_id, + actor={"actor_id": auth.actor_id, "roles": auth.roles, "token_type": auth.token_type}, + customer_id=customer_id, + ): + if kind == "delta": + full.append(text) + yield _chunk(trace_id, {"content": text}) + except Exception as exc: logger.warning("chat stream failed (no message persisted): %s", exc, exc_info=True) yield _sse({"error": {"code": "STREAM_FAILED", "message": "生成失败,请重试"}}) yield _SSE_DONE return - reply = _assistant_content_for_persist("".join(full), has_disclaimer) + body = customer_prep["reply"] if customer_prep else "".join(full) + reply = _assistant_content_for_persist(body, has_disclaimer) # 落盘(与同步同口径):user + assistant 同事务一次性写,同 trace_id 贯通。 # 落库失败 → 整轮不落(不出现 user 落、assistant 未落的半截历史), diff --git a/app/gateway/jwt_service.py b/app/gateway/jwt_service.py index 6d6741d..1a7a951 100644 --- a/app/gateway/jwt_service.py +++ b/app/gateway/jwt_service.py @@ -69,8 +69,8 @@ DEFAULT_ROLES_BY_ACTOR: dict[str, list[str]] = { "STAFF-10091": ["advisor", "compliance"], "STAFF-20001": ["analyst"], "STAFF-20002": ["analyst"], - "STAFF-30001": ["risk_officer"], - "STAFF-30002": ["risk_officer"], + "STAFF-30001": ["risk_officer", "risk_demo"], + "STAFF-30002": ["risk_officer", "risk_demo"], "STAFF-40001": ["compliance"], "STAFF-40002": ["compliance"], "STAFF-50001": ["ops"], diff --git a/app/service/customer_service.py b/app/service/customer_service.py index 40f02ed..e99058f 100644 --- a/app/service/customer_service.py +++ b/app/service/customer_service.py @@ -17,7 +17,7 @@ recall_memory → intent_classify from __future__ import annotations import re -from typing import TypedDict +from typing import Any, TypedDict from langgraph.graph import END, StateGraph @@ -553,3 +553,31 @@ def run_customer_chat( result.get("intent", "fallback"), result.get("transfer_to_human", False), ) + + +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,再按块推送(与客服线同步路径同编排)。""" + reply, has_disclaimer, intent, transfer = run_customer_chat( + ctx, message, session_id, customer_id, end_session + ) + return { + "reply": reply, + "has_disclaimer": has_disclaimer, + "intent": intent, + "transfer_to_human": transfer, + "chunks": _chunk_reply_text(reply), + } diff --git a/app/service/risk/chat_tools.py b/app/service/risk/chat_tools.py index af674cd..fa4ac4b 100644 --- a/app/service/risk/chat_tools.py +++ b/app/service/risk/chat_tools.py @@ -362,4 +362,11 @@ RISK_TOOL_REGISTRY: dict[str, RiskToolSpec] = { param_whitelist=(), int_bounds={}, ), + "query_agent_behavior": RiskToolSpec( + func=query_agent_behavior, + description="查询代理人行为链预警(FR-10;可选 agent_id 过滤)", + requires_customer=False, + param_whitelist=("agent_id",), + int_bounds={}, + ), } diff --git a/docs/memory/FRAMEWORK.md b/docs/memory/FRAMEWORK.md index e08df22..0ffe0a3 100644 --- a/docs/memory/FRAMEWORK.md +++ b/docs/memory/FRAMEWORK.md @@ -35,22 +35,22 @@ | 模块 | 职责 | 依赖 | 代码状态 | | --- | --- | --- | --- | | Agent Gateway / Auth SDK | JWT、RBAC、归属校验 | Redis、MySQL customer_advisor_rel | **已实现(T-01 + AL-09)**:模块 `service/auth_service.py` + `api/deps.py`;宿主 `gateway/` 四件套并存;`/api/auth/login` 统一走 `issue_dev_token`;S2 接缝 `auth_adapter.module_auth_from_host` | -| 客户财富 Agent | L1 画像、事实查询、阈值提醒 | Core RO、Milvus 产品库 | **chat 骨架已通** · **S2 接缝已接线**(customer 并行分流 + visitor 试聊 · 见 `客服Agent-合并说明.md` §3) | +| 客户财富 Agent | L1 画像、事实查询、阈值提醒 | Core RO、Milvus 产品库 | **S2 已接线** · **Chat 同步+SSE**(stream → `prepare_customer_stream`) | 代理人助手 Agent | L2 画像、RAG、草稿 | L1 只读、Milvus | 空壳 service(chat 骨架已通) | -| 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | 空壳 service(chat 骨架已通) | +| 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | **S3+P2(2026-09-09)**:问数 · dashboard metrics · 资产沉淀 API/UI · 口径种子 SQL | | 风控监测 Agent | 预警、L3、R-02 适当性 | 交易事件、AML 名单 | **已实现 B1~B9b + C1~C6(FR-1~10)**:事件线 + 对话线 + 集中度/时效升级/代理人行为链;**AL-09 已并入 `merger` 分支** | | Core 只读层 | L0 事实查询 | `jinrong_core` | **已实现 + 已接对话 Tool(T-04)**:core_ro 经 app/tool/core_tools.py 三只读 Tool(L0/持仓/流水)进 chat;风控扩展查询照旧 | | 共用底座 | 会话、审计、输入防护 | MySQL 11 表 + Redis | **已接入(2026-09-07)**:会话(T-06 session_repository + memory_service 窗口)、审计中间件(T-02 http_access + input_guard_log 双写)、agent_tool_call Tool 留痕(T-04)、输入防护(T-03 input_guard:注入词表纯函数检测 + oversize + Redis 固定窗口限流,chat 链路 限流→注入/超长→归属) | -| 对话编排 | LangGraph StateGraph + DeepSeek | langgraph/langchain-openai | **已实现(T-07 骨架 + T-04 Tool 节点 + C1 风控四 Tool + C2 risk 分支,2026-09-07)**:tool(分组关键词意图→Tool,customer/advisor/risk)→llm→guard(免责声明);analyst 分支与 LLM intent 待后续 | +| 对话编排 | LangGraph StateGraph + DeepSeek | langgraph/langchain-openai | **已实现**:customer/advisor/risk/analyst 四线;**analyst 问数**独立 `analyst_agent`(非 chat StateGraph) | | 同步脚本 | 归属、Neo4j | Core → agent / 图库 | **sync_*.py 已实现** | -| 前端 Demo(`web/`) | 四角色工作台、平台读 UI、Chat | FastAPI v0.1 + chat B/C | **P0 主链路已接(2026-09-09)**:ChatPanel/SSE · 游客试聊 · customer 同步 Chat · 档案/持仓/流水/行情/风控台账+处置;analytics 问数待接 | +| 前端 Demo(`web/`) | 四角色工作台、平台读 UI、Chat | FastAPI v0.1 + chat B/C | **P0 已接(2026-09-09)**:ChatPanel/SSE · 游客试聊 · 平台只读 · 行情 · 风控台账+处置 · **问数工作台** · 模拟交易演示页 · **19 Vitest** | ------ ## 3. 后端分层(app/) ```text -api/ → auth / risk / simulate / chat(含 sessions/stream)/ deps / auth_adapter / audit_middleware 已实现;knowledge、admin 空壳 【大部分】 +api/ → auth / risk / simulate / chat / **analyst** / deps / auth_adapter / audit_middleware 已实现;knowledge、admin 空壳 【大部分】 service/ → risk/*(rules/alert/aml/engine/l3/scoring/locks/redis_gateway/chat_tools)+ suitability + auth_service(T-01 JWT)+ agent_service(T-07 图 + T-04 tool 节点 + C2 risk 分支)+ memory_service(T-06)+ tool_service(T-04 对话 Tool 编排:意图/归属校验/run_tool 落库)+ input_guard(T-03)+ diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index ba648a9..4c4f83a 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -9,9 +9,9 @@ **项目是什么:** 金融四 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座;**不**互调 LLM,跨 Agent 走 L1/L2/L3 画像与预警表。 -**当前进度:** 需求与表设计已定 · **风控 B1~B9b + C4~C6 + chat B/C + AL-09 合并** · **代销平台 API v0.1** · **客服 Agent S2 收尾**(visitor + customer 分流 · fin_* KB · Wave 1~5 绿 · CS-C-11 迁移) · **730 passed** · **`web/` 前端 P0 主链路已真接**(ChatPanel · 游客试聊 · 四 Agent 对话 · 平台只读页 · 行情 · 风控台账处置) · **`npm run build/test/lint` 绿(19 例 Vitest)** · **Redis:Docker `jinrong-redis` @ 127.0.0.1:6380**。下一步:analytics 问数页 · 接口契约发群 · customer Chat SSE(后端未接 stream)。 +**当前进度:** 需求与表设计已定 · **风控 + 平台 API + 客服 S2 + 数据分析 S3/P2** · **customer Chat SSE**(stream 分流 customer_service)· **STAFF-30001 含 risk_demo**(模拟交易演示)· **774+ pytest** · **19 Vitest** · **Redis @ 6380** -**工作分支:** 团队开发在 **`merger`**(已 merge 风控模块);历史开发分支 `risk-control-agent` 交付冻结。旧文档中「待 AL-09 合并」口径已过时。 +**工作分支:** 团队开发在 **`merger`**;历史 `risk-control-agent` 交付冻结。 **仓库地图:** @@ -22,8 +22,10 @@ | `app/api/auth_adapter.py` | **已接线(S2)** | `module_auth_from_host()`:宿主 AuthContext → 模块 AuthContext | | `app/gateway/` | **宿主 Wave 0(并存)** | jwt_service / auth_deps / rbac / ownership;模块 API 走 `deps.py`,禁止模块 import gateway | | `app/api/risk.py` `simulate.py` `deps.py` | **已实现** | 风控 4 API + 模拟网关路由 + **JWT 鉴权工厂(T-01:Bearer 全环境优先;debug 头仅 dev+无 RS256 公钥时兜底;AGENT_ACCESS_MATRIX 准入)** | -| `app/api/chat.py` `audit_middleware.py` | **已实现(T-06/T-02 + 前端接入 B/C + 客服 S2)** | POST /api/chat(**customer 分流 → customer_service** · 其余 → agent_service)· **POST /api/chat/visitor**(试聊)· 前端拉侧三端点 · SSE `POST /api/chat/stream` | +| `app/api/chat.py` `audit_middleware.py` | **已实现** | POST /api/chat · **POST /api/chat/stream(customer 分流 → prepare_customer_stream)** · visitor · sessions 三端点 | | `app/api/customers.py` `products.py` `advisors.py` `staff.py` `compliance.py` | **已实现(v0.1)** | 代销平台 REST;`get_platform_auth_context`(无 X-Agent-Type);Service 层 `app/service/platform/` | +| `app/api/analyst.py` `analyst_auth_adapter.py` | **已实现(S3)** | 问数 `POST /api/analyst/chat` · dashboard/assets/metrics · **`get_platform_auth_context`(无 X-Agent-Type)** · customer `self` 域 | +| `app/service/analyst_agent.py` 等 | **已实现(S3)** | SQL guard · guardrail · analytics_repo · 编排 | | `app/api/knowledge.py` `admin.py` | 空壳 | 待审计查询台与知识库 API(T-21 拍板一期只做脚本入库,上传/重建端点不做) | | `app/service/platform/` | **已实现(v0.1)** | 封装 core_ro + `PLATFORM_RESPONSE_DESENSITIZE` 脱敏开关 | | `app/service/embedding.py` `milvus_service.py` `rag_service.py` | **已实现(T21-1/2/4)** | Ollama bge-m3 1024 维(失败不静默降级)/ kb_product_rules 建集合+upsert+合规过滤检索 / search_knowledge→chunks+source_refs 溯源 | @@ -43,7 +45,7 @@ | `scripts/core/*.sql` + `reset.ps1` | **已实现** | Core 模拟库 DDL + 种子 | | `scripts/agent/` `scripts/demo/` `scripts/dev/` | **已实现** | AML 种子 + 演示数据 + `run_sql_file.py` + **`start-redis.ps1`**(Docker Redis 优先)+ issue_dev_token | | `scripts/sync/*.py` | **已实现** | 归属同步 + Neo4j 全图 | -| `tests/` | **已实现** | 37+ 测试模块 **730 用例 0 skipped**(含客服 Wave 1~5;sqlite + 真 MySQL 集成;`test_module_boundary` 宿主 D 类排除)。改路由必同步 `tests/test_main.py::test_all_routers_mounted` | +| `tests/` | **已实现** | **774 用例** 0 skipped(含 Wave6 analyst + customer stream 测试) | `docs/需求拆解/` | 已定 | 场景 P0、矩阵、合规原文 | | `docs/PRD/PRD-风控监测Agent.md` | **已冻结(v1.1)** | 风控 PRD v1.0 + v1.1 追加 FR-8/9/10(§4A)+ 规则表附录 | | `docs/项目框架设计/实现方案-风控追加需求v1.1-C4C6.md` | **已定稿** | C4~C6 编码依据(经独立 AI 评审修订闭环);分支/进度速览另见项目根 `交接文档.md` | @@ -54,7 +56,7 @@ | `docs/项目框架设计/接口契约-代销平台API-v0.2-行情扩展草案.md` | **草案(2026-09-09)** | `nav-snapshot` · `sync_market_nav` · 前端产品行情 Phase B | | `docs/项目框架设计/表设计/` | 已定 | Agent 共用 11 表 + agent 专用 SQL | | `docs/项目框架设计/Core模拟底座/` | 已定 | 无真实 Core 时的 L0 方案 | -| `web/` | **静奢智能 UI + P0 真接(2026-09-09)** | `ChatPanel`/`useChatPanel` · **customer 同步 Chat** · **risk/advisor/analyst SSE** · 登录页 **VisitorChatWidget** · 档案/持仓/流水/行情/风控台账+处置 · `web/src/api/chat.ts` · 交接 **`docs/frontend/FRONTEND-HANDOFF.md`** | +| `web/` | **P0+P2(2026-09-09)** | 四 Agent Chat(**customer SSE**)· 问数+看板+资产沉淀 · 风控台账/模拟交易 · `api/analyst.ts` | **本地 bootstrap(首次):** 完整步骤与前置说明见 `FLOW.md` §0(权威),速览: @@ -68,12 +70,12 @@ (风控演示:scripts/demo/prepare_risk_demo.sql,reset 后重跑) 6. python scripts/sync/sync_advisor_rel.py && python scripts/sync/sync_neo4j.py 7. `docker compose up -d redis`(或 `.\scripts\dev\start-redis.ps1`)→ **REDIS_URL=redis://127.0.0.1:6380/0**(Docker Redis 7;避开本机 Windows Redis 占 6379) -8. uvicorn app.main:app --reload → GET /health;`cd web && npm run dev`(5173 代理 8000);python -m pytest(**730 绿**) +8. uvicorn … · python -m pytest(**774 绿**);问数冒烟 · 可选 `mysql … < scripts/agent/seed-analyst-metric-dict.sql` ``` **AL-09 合并后架构(一句话):** 宿主 `gateway/` + 模块 `deps.py` **双栈并存**;对外登录/token **统一**;chat/risk 均走模块鉴权;接缝 S2 用 `auth_adapter`。 -**下一步(见 TODO):** analytics 问数页 · 接口契约发群 · customer Chat 流式(后端 `/stream` 未接 customer_service)· simulate 交易 UI(不做)。 +**下一步(见 TODO):** 接口契约发群(复制稿)· 风控演示种子/cron · L1/L2 Redis · Vitest 补测(可选) **Redis(2026-09-09):** 推荐 **Docker** `jinrong-redis`(`redis:7-alpine`)· 宿主机 **6380** → 容器 6379 · `.env` `REDIS_URL=redis://127.0.0.1:6380/0` · 客户端 **RESP2**(`database.py` / `redis_gateway.py`)。 @@ -98,8 +100,8 @@ ## 1. 项目简介 - **名称:** JinRong 金融四 Agent 智能管家 -- **当前阶段:** **代销平台 v0.1 + AL-09 + 客服 S2 + 前端 P0 主链路真接(2026-09-09)**——730 pytest 绿 · 19 Vitest 绿 -- **当前优先级:** analytics 问数 · 接口契约发群 · customer SSE(后端)· simulate UI(显式不做) +- **当前阶段:** **代销平台 v0.1 + AL-09 + 客服 S2 + 数据分析 S3/P2 + 前端 P0(2026-09-09)** +- **当前优先级:** 接口契约发群 · 风控演示运维脚本 · 画像 Redis L1/L2 ------ diff --git a/docs/memory/REQUIREMENTS.md b/docs/memory/REQUIREMENTS.md index dcd22a5..b351c81 100644 --- a/docs/memory/REQUIREMENTS.md +++ b/docs/memory/REQUIREMENTS.md @@ -34,7 +34,7 @@ | ID | 需求 | 验收对照 | 状态 | TODO | | --- | --- | --- | --- | --- | -| D-01~D-04 | 分析:客户/产品/预警查数 + SQL 留痕 | analytics_query_log | 未做 | T-10 | +| D-01~D-04 | 分析:客户/产品/预警查数 + SQL 留痕 | analytics_query_log | **已实现(S3 · 2026-09-09)**:`POST /api/analyst/chat` + sql_guard/guardrail · customer self 域 · 尾注 · Wave6 测试 | ~~T-10~~ | | A-01~A-05 | 代理人:画像/规则/草稿/流程/合规巡检 | 非归属 403;草稿不外发 | 未做 | T-20 | ## Wave 2 · 风控 P0 diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index 535319c..ab904e4 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -5,36 +5,24 @@ ## 进行中 -**前端 P0 主链路真接(2026-09-09)**:ChatPanel · 游客/客户/风控/顾问/分析 Chat · 平台只读页 · 行情 · 风控台账处置 · **773 passed** · **19 Vitest** · **analytics 问数页已接** - -**Redis Docker(2026-09-09)**:`jinrong-redis` @ **6380** · `.env REDIS_URL` 已对齐 · `scripts/dev/start-redis.ps1` ✓ +**2026-09-09 批次**:P1 收尾 + P2 analyst + customer SSE + risk_demo · 待复制发群稿 · 口径种子 SQL 本机执行 ## 待办(统筹 · P1 推荐顺序) -- [x] **代销平台 API v0.1 实现**(2026-09-08):`customers/products/advisors/staff/compliance` + `service/platform/` + `PLATFORM_RESPONSE_DESENSITIZE` · **530 passed 0 skipped** -- [x] **`web/` P0 脚手架 init**(2026-09-09):Vite+AntD+HashRouter · 登录真接 · AppLayout · 四角色路由 · 占位页 · 见 `web/README.md` · 设计 `docs/superpowers/specs/2026-09-09-frontend-p0-design.md` -- [ ] **接口契约发群**(login + 平台读 API + simulate/trade;强调重复功能以平台路径为准) -- [ ] **前端 P0 真接**(analytics 问数已接 · 契约发群 · simulate 说明) -- [ ] 同步 `MEMORY/REQUIREMENTS/FRAMEWORK` 与各 Agent 负责人联调节奏 +- [x] **代销平台 API v0.1 实现**(2026-09-08) +- [x] **`web/` P0 脚手架 + 主链路真接**(2026-09-09) +- [x] **`web/src/api/risk.ts` 补 `X-Agent-Type: risk`**(2026-09-09) +- [x] **同步 `MEMORY/REQUIREMENTS/FRAMEWORK`**(analyst S3 · 773 基线 · D-01~D04) +- [x] **接口契约发群稿**(`docs/项目管理/接口契约发群-2026-09-09.md` — 待你复制到群) -### 前端 `web/` · P0 待接(后端已就绪) +### 前端 `web/` · P0 收尾 -> 现状(2026-09-09):ChatPanel / 四 Agent 对话 / 平台只读页 / 行情 / 风控台账已真接;**待接**:analytics 问数页 · PermissionGate · simulate 交易 UI。 +> 主链路已通;以下为 polish / 文档 / 已知接缝缺口。 -- [x] **四角色「收益/洞察」登录首页**(2026-09-09 · `web/src/pages/dashboard/*`):静奢 UI(Tailwind v4 + `tokens.css`)· Hero+图表+明细 · 真接平台 API · 交接见 `docs/frontend/FRONTEND-HANDOFF.md` -- [x] **平台 API client(Dashboard 子集)**(2026-09-09):`customers` / `products` / `risk`(`listPendingAlerts`)/ `advisors` + `ApiErrorResult` · 完整 CRUD/处置等见下方未接项 -- [x] **customer 只读页(一条链路跑通)**:档案 / 持仓 / 流水 · 真调 v0.1(2026-09-09) -- [x] **共享 `/app/market`**:`GET /api/products` + 逐产品 `GET .../nav` · 静态净值 Alert -- [x] **`ChatPanel` + SSE 解析**:sessions 列表 / messages / close · 首帧 meta/disclaimer · `[DONE]` 收尾 -- [x] **customer Chat**:`POST /api/chat` 同步 · `X-Agent-Type: customer`(客服 LangGraph 专用线) -- [x] **登录页游客试聊**:`POST /api/chat/visitor` · `VisitorChatWidget` -- [x] **risk Chat**:`POST /api/chat/stream` · `X-Agent-Type: risk` -- [x] **risk 预警台账全页**:`GET /api/risk/alerts` 筛选/分页表格 + `POST .../handle` 处置 UI -- [x] **advisor Chat**:SSE + AgentBanner · 名下客户列表页 -- [x] **`/app/analytics/chat`**:SSE · `X-Agent-Type: analyst` -- [x] **`/app/analytics/query`**:`AnalystQueryPanel` · `POST /api/analyst/chat` · 四角色权限内问数 -- [ ] **Vitest**:`authStore` · SSE 解析已绿(19 例)· 可补 ChatPanel hook 测试 -- [ ] **前端 P0 收尾**:接口契约发群 · simulate 交易 Disabled 菜单说明 +- [x] 四角色 Dashboard · 平台只读 · ChatPanel · 游客试聊 · 行情 · 风控台账页 · 问数工作台(`AnalystQueryPage`) +- [ ] **Vitest 补测**(可选):`useChatPanel` · `api/analyst.ts` mock +- [x] **simulate 交易 UI**:风控菜单「模拟交易」+ `RiskSimulatePage` + `api/simulate.ts`(演示说明 · A-3/A-1 预设) +- [x] **接口契约发群稿**(见 `docs/项目管理/接口契约发群-2026-09-09.md` · 复制到群即完成) ## 待办(模块侧开放项) @@ -59,31 +47,32 @@ ### 数据分析 Agent · S3 接缝(2026-09-09 已接线) -> 清单:`docs/项目框架设计/数据分析Agent-合并说明.md` · merge `data-analysis-agent-work` · **773 passed** +> 清单:`docs/项目框架设计/数据分析Agent-合并说明.md` · **773 passed** · 冒烟 `scripts/dev/smoke_analyst.py` -- [x] **merge + 接缝**:`analyst_router` · `analyst_auth_adapter` · `analyst_schemas` · 废弃 `utils/auth.py` -- [x] **鉴权 G1–G4 + G8**:`assert_analyst_query_access` · customer `self` 域 · 尾注「AI 分析有风险」 -- [x] **Wave6 测试**:`test_wave6_*` · +43 例 -- [x] **前端问数页**:`web/src/pages/analytics/AnalystQueryPage.tsx` · `api/analyst.ts` -- [x] **迁移 SQL(本机)**:`scripts/agent/migrate-analyst-d07-d11.sql`(三资产表 · 2026-09-09 已执行 · **无种子数据**) -- [x] **Scope B 冒烟**:`python scripts/dev/smoke_analyst.py` → **19/19**(FakeLLM + `--live-llm` 真 MySQL/LLM 亦绿) +- [x] merge + 接缝(`analyst_router` · `analyst_auth_adapter` · 问数走 `get_platform_auth_context`) +- [x] customer `self` 域 + 「AI 分析有风险」尾注 +- [x] Wave6 测试 · 迁移 `migrate-analyst-d07-d11.sql` · 重置 `reset-analyst-d07-d11.sql` +- [x] 前端问数页 · Scope B 冒烟 19/19 +- [x] **P2 · D-11 资产沉淀 UI**(`AnalystAssetsPage` · `POST /api/analyst/assets`) +- [x] **P2 · 问数页 dashboard 卡片**(`GET /api/analyst/dashboard` · `AnalystQueryPage` 顶部 metrics) +- [x] **P2 · 口径字典种子**(`scripts/agent/seed-analyst-metric-dict.sql` · 本机需手工执行) +- [x] **customer Chat SSE**(`chat.py` stream → `prepare_customer_stream` · 前端 `mode="stream"`) +- [x] **STAFF-30001/30002 追加 risk_demo**(`jwt_service.py` · 模拟交易页可造预警) -### 风控 Agent · 后端已就绪 · 前端/运维未接(盘点 2026-09-09) +### 风控 Agent · 前端/运维(盘点 2026-09-09 · 部分已与 P0 重叠) -**HTTP 接口(后端已实现 · 前端/演示未接)** +**HTTP · 前端** -- [ ] **`POST /api/risk/alerts/{alert_id}/handle`**:人工处置(`confirmed_normal` / `confirmed_suspicious` / `reported`)· 前端台账页 + `api/risk.ts` 封装 -- [ ] **`GET /api/risk/alerts`(完整能力)**:`alert_type` / `customer_id` / 日期窗 / 分页 · Dashboard 仅用 `status=pending_review` 子集 -- [ ] **`POST /api/risk/suitability/check`**:风控线适当性直调(过渡路径 · 带 audit)· 前端未接 -- [ ] **`POST /api/compliance/suitability-check`**:平台 canonical 适当性 · 前端未接(发群口径以此为准) -- [ ] **`POST /api/risk/aml/scan`**:全量 AML 手动扫描 · 仅 risk_officer · 无 UI -- [ ] **`POST /api/simulate/trade`**:模拟交易 → 规则引擎动态出预警 · 无 UI(演示走 Swagger / curl) +- [x] **`RiskAlertsPage`**:列表 + 处置 UI · JWT 已带 `X-Agent-Type: risk` +- [ ] **`GET /api/risk/alerts` 筛选 UI 补全**(`alert_type` / `customer_id` / 日期窗 — 页面目前部分参数) +- [ ] **`POST /api/compliance/suitability-check`** · **`POST /api/risk/suitability/check`**:前端未接 +- [ ] **`POST /api/risk/aml/scan`** · **`POST /api/simulate/trade`**:无 UI(Swagger/curl) -**对话 Tool(需 risk Chat SSE 才触达)** +**对话 Tool(risk Chat SSE 触达)** -- [ ] **risk Chat SSE**:`POST /api/chat/stream` · `X-Agent-Type: risk` -- [ ] **`alert_query`** / **`customer_context`** / **`suitability_check`** / **`aml_lookup`** / **`query_overdue_alerts`** -- [ ] **`query_agent_behavior`**:函数+意图已有 · **未写入 `RISK_TOOL_REGISTRY`** → 会 `TOOL_UNKNOWN`(接线前必修) +- [x] risk Chat SSE 前端已接(`ChatPanel` · `X-Agent-Type: risk`) +- [x] **`alert_query` / `customer_context` / `suitability_check` / `aml_lookup` / `query_overdue_alerts`**(后端 C1 已注册) +- [x] **`query_agent_behavior`**:已写入 `RISK_TOOL_REGISTRY`(2026-09-09) **运维 / 动态演示(无 UI · 见 `演示SOP-风控模块.md`)** @@ -152,4 +141,5 @@ - [x] 2026-09-05 `core_ro.py` + `settings.mysql_core_database` + sync 脚本 - [x] 2026-09-05 Agent 编排依赖改为 LangGraph(requirements.txt) - [x] 2026-09-05 memory 文件夹更新(新 Agent 交接清单) -- [x] 2026-09-09 数据分析 Agent 需求规格定稿 v1.1(`docs/需求拆解/01-数据分析Agent需求规格.md`,含 D-01~D-12 + 缓存/三层记忆方案) +- [x] 2026-09-09 数据分析 Agent S3 合并接线 + 问数页 + 迁移/冒烟(`773 passed` · commit `af9fb71`) +- [x] 2026-09-09 数据分析 Agent 需求规格定稿 v1.1(`docs/需求拆解/01-数据分析Agent需求规格.md`) diff --git a/docs/项目框架设计/客服Agent-合并说明.md b/docs/项目框架设计/客服Agent-合并说明.md index f026d6a..1f72cbc 100644 --- a/docs/项目框架设计/客服Agent-合并说明.md +++ b/docs/项目框架设计/客服Agent-合并说明.md @@ -55,6 +55,7 @@ scripts/agent/migrate-customer-agent-cs-c11.sql | --- | --- | | `POST /api/chat/visitor` | `main.py` 挂载 `visitor_router` · 免登录试聊 | | `POST /api/chat` + `X-Agent-Type: customer` | `chat.py` 分流 → `customer_service.run_customer_chat`(14 节点 LangGraph) | +| `POST /api/chat/stream` + `X-Agent-Type: customer` | `chat.py` 分流 → `prepare_customer_stream`(图跑完后分块 SSE) | | 其余 Agent | 仍走 `agent_service.chat` | **接缝文件:** diff --git a/docs/项目管理/接口契约发群-2026-09-09.md b/docs/项目管理/接口契约发群-2026-09-09.md new file mode 100644 index 0000000..86a5a3c --- /dev/null +++ b/docs/项目管理/接口契约发群-2026-09-09.md @@ -0,0 +1,87 @@ +# 接口契约发群稿(2026-09-09 · merger 分支) + +> 可直接复制到协作群;canonical 文档:`docs/项目框架设计/接口契约-代销平台API-v0.1.md` · 问数接缝:`docs/项目框架设计/数据分析Agent-合并说明.md` + +--- + +## 1. 登录与鉴权 + +| 项 | 约定 | +| --- | --- | +| 登录 | `POST /api/auth/login` → JWT(issuer/audience 与模块 API 统一) | +| 平台只读 API | `Authorization: Bearer` **即可**,**不要** `X-Agent-Type` | +| 四 Agent 对话 | `POST /api/chat` / `POST /api/chat/stream` **必须**带 `X-Agent-Type: customer \| advisor \| risk \| analyst` | +| 问数(数据分析) | `POST /api/analyst/chat` 等 **不要** `X-Agent-Type`(平台鉴权 `get_platform_auth_context`) | +| 风控 REST | `GET/POST /api/risk/*` **必须** `X-Agent-Type: risk`(前端 `web/src/api/risk.ts` 已补) | +| 模拟交易 | `POST /api/simulate/trade` 需 `X-Agent-Type`(customer 本人或 risk 线);业务角色须 **risk_demo** 或 **客户本人** | + +Demo 账号(前端登录页):CUST-9527 · STAFF-10086 · STAFF-20001 · STAFF-30001 + +--- + +## 2. 平台读 API(v0.1 · 重复能力以此为准) + +| 域 | 路径前缀 | 说明 | +| --- | --- | --- | +| 客户 | `/api/customers` | 档案 / 持仓 / 流水 | +| 产品 | `/api/products` | 列表 + `/{id}/nav` 净值 | +| 顾问 | `/api/advisors` | 名下客户 roster | +| 员工 | `/api/staff` | 内部员工只读 | +| 合规 | `/api/compliance/suitability-check` | **canonical 适当性**(过渡:`/api/risk/suitability/check` 仍可用) | + +--- + +## 3. 数据分析问数四件套 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/api/analyst/chat` | NL→SQL→表格+解读;customer 仅 self 域;尾部「AI 分析有风险」 | +| GET | `/api/analyst/dashboard` | 角色域 metrics 卡片 | +| POST | `/api/analyst/assets` | 口径/few-shot 资产沉淀(P2 UI 待做) | +| GET | `/api/analyst/ops/metrics` | 运维指标 | + +前端:`/#/app/analytics/query`(四角色权限内问数) + +--- + +## 4. 风控与模拟 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/api/risk/alerts` | 台账筛选/分页 | +| POST | `/api/risk/alerts/{id}/handle` | 人工处置 | +| POST | `/api/simulate/trade` | 模拟交易 → 规则引擎出预警(演示须 risk_demo 或客户本人) | + +前端:`/#/app/risk/alerts` · `/#/app/risk/simulate`(演示页 + 角色说明) + +--- + +## 5. 对话与会话 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/api/chat/visitor` | 游客试聊(无 JWT) | +| GET | `/api/chat/sessions` | 会话列表 | +| GET | `/api/chat/sessions/{id}/messages` | 历史消息 | +| POST | `/api/chat/sessions/{id}/close` | 关闭会话 | + +--- + +## 6. 本地验收 + +```bash +python -m pytest # 773 passed +python scripts/dev/smoke_analyst.py +cd web && npm run build && npm run test +uvicorn app.main:app --reload # :8000;改代码后须重启 +cd web && npm run dev # :5173 代理 8000 +``` + +Redis:`scripts/dev/start-redis.ps1` → **6380** + +--- + +## 7. 拍板记录(2026-09-09) + +- **STAFF-30001/30002 已追加 `risk_demo`** → 前端模拟交易页可造预警 +- **customer Chat 已接 SSE** → stream 走 `customer_service.prepare_customer_stream`(非 agent_service) diff --git a/scripts/agent/seed-analyst-metric-dict.sql b/scripts/agent/seed-analyst-metric-dict.sql new file mode 100644 index 0000000..80c2192 --- /dev/null +++ b/scripts/agent/seed-analyst-metric-dict.sql @@ -0,0 +1,53 @@ +-- 数据分析 Agent · 口径字典演示种子(P2) +-- 执行:jinrong_agent 库 · migrate-analyst-d07-d11.sql 已跑后 +USE jinrong_agent; + +INSERT INTO analytics_metric_dict + (metric_key, metric_name, aliases, definition, formula, applicable_tables, default_time_window, unit, status, version, created_by, published_by) +VALUES + ( + 'holding_scale', + '持仓规模', + JSON_ARRAY('规模', '市值', '总资产'), + '客户当前持仓市值合计(Core 模拟库 core_holding.market_value 求和)', + 'SUM(market_value)', + JSON_ARRAY('core_holding'), + 'as_of 最新', + '元', + 'published', + 1, + 'STAFF-20001', + 'STAFF-20001' + ), + ( + 'trade_count_30d', + '近30日交易笔数', + JSON_ARRAY('交易笔数', '流水笔数'), + '近 30 个自然日内 core_trade 记录数(按 trade_date)', + 'COUNT(*) WHERE trade_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)', + JSON_ARRAY('core_trade'), + '近30天', + '笔', + 'published', + 1, + 'STAFF-20001', + 'STAFF-20001' + ), + ( + 'pending_alerts', + '待处理预警数', + JSON_ARRAY('待审预警', 'pending'), + 'risk_alert 表中 status=pending_review 的记录数', + 'COUNT(*) WHERE status=''pending_review''', + JSON_ARRAY('jinrong_agent.risk_alert'), + '实时', + '条', + 'published', + 1, + 'STAFF-20001', + 'STAFF-20001' + ) +ON DUPLICATE KEY UPDATE + metric_name = VALUES(metric_name), + status = VALUES(status), + updated_at = CURRENT_TIMESTAMP(3); diff --git a/scripts/dev/rbac-seed-reference.md b/scripts/dev/rbac-seed-reference.md index c8a69c7..bd431dd 100644 --- a/scripts/dev/rbac-seed-reference.md +++ b/scripts/dev/rbac-seed-reference.md @@ -15,8 +15,8 @@ | STAFF-10091 | advisor, compliance | 双角色权限并集 | | STAFF-20001 | analyst | 分析 Agent | | STAFF-20002 | analyst | 分析 Agent | -| STAFF-30001 | risk_officer | 风控 Agent | -| STAFF-30002 | risk_officer | 风控 Agent | +| STAFF-30001 | risk_officer, **risk_demo** | 风控 Agent · 模拟交易演示 | +| STAFF-30002 | risk_officer, **risk_demo** | 风控 Agent | | STAFF-40001 | compliance | 合规审计台 | | STAFF-40002 | compliance | 合规审计台 | | STAFF-50001 | ops | 运营统计 A-08 | diff --git a/tests/test_chat_stream.py b/tests/test_chat_stream.py index 8be2aaf..fd7dad4 100644 --- a/tests/test_chat_stream.py +++ b/tests/test_chat_stream.py @@ -91,6 +91,7 @@ class FakeStreamLLM: CUSTOMER = {"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-9527", "X-Agent-Type": "customer"} ADVISOR = {"X-Debug-Role": "advisor", "X-Debug-Actor": "STAFF-10086", "X-Agent-Type": "advisor"} +RISK = {"X-Debug-Role": "risk_officer,risk_demo", "X-Debug-Actor": "STAFF-30001", "X-Agent-Type": "risk"} MANAGER = {"X-Debug-Role": "risk_manager", "X-Debug-Actor": "STAFF-31001", "X-Agent-Type": "risk"} @@ -142,7 +143,7 @@ def _payloads(resp) -> list[dict]: def test_stream_contract_and_persist(env, fake_llm): """契约:200 + text/event-stream;首帧 meta;delta 拼接=完整文本;[DONE] 收尾。""" - r = env["client"].post("/api/chat/stream", json={"message": "看下预警"}, headers=CUSTOMER) + r = env["client"].post("/api/chat/stream", json={"message": "看下预警"}, headers=RISK) assert r.status_code == 200 assert r.headers["content-type"].startswith("text/event-stream") assert r.headers["X-Accel-Buffering"] == "no" @@ -177,14 +178,14 @@ def test_stream_contract_and_persist(env, fake_llm): assert msgs[1]["content"] == f"你好,我是风控助手\n\n{agent_service.CHAT_DISCLAIMER}" # 会话历史可读(方案 B 端点联动) sid = first["meta"]["session_id"] - hist = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=CUSTOMER) + hist = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=RISK) assert hist.status_code == 200 and hist.json()["total"] == 2 def test_stream_meta_trace_id_when_context_empty(env, fake_llm, monkeypatch): """无上下文 trace 时须 new_trace(),禁止向客户端下发空 trace_id。""" monkeypatch.setattr(chat_mod, "current_trace", lambda: "") - r = env["client"].post("/api/chat/stream", json={"message": "hi"}, headers=CUSTOMER) + r = env["client"].post("/api/chat/stream", json={"message": "hi"}, headers=RISK) assert r.status_code == 200 first = _payloads(r)[0] assert first["meta"]["trace_id"].startswith("trc-") @@ -205,7 +206,7 @@ def test_stream_advisor_no_disclaimer(env, fake_llm): def test_stream_degraded_without_key(env, monkeypatch): """无 LLM key:降级整块推送(契约不变,前端无需特判)。""" monkeypatch.setattr(settings_mod.settings, "deepseek_api_key", "") - r = env["client"].post("/api/chat/stream", json={"message": "你好"}, headers=CUSTOMER) + r = env["client"].post("/api/chat/stream", json={"message": "你好"}, headers=ADVISOR) assert r.status_code == 200 payloads = _payloads(r) text_all = "".join(p["choices"][0]["delta"].get("content", "") for p in payloads) @@ -220,7 +221,7 @@ def test_stream_mid_failure_persists_nothing(env, monkeypatch): llm = FakeStreamLLM(raise_on_stream=True) monkeypatch.setattr(agent_service, "_llm", llm) monkeypatch.setattr(settings_mod.settings, "deepseek_api_key", "test-key") - r = env["client"].post("/api/chat/stream", json={"message": "你好"}, headers=CUSTOMER) + r = env["client"].post("/api/chat/stream", json={"message": "你好"}, headers=ADVISOR) assert r.status_code == 200 # 流已开,状态码不可改;错误走 error 帧 payloads = _payloads(r) assert payloads[-1]["error"]["code"] == "STREAM_FAILED" @@ -261,7 +262,7 @@ def test_stream_persist_failure_no_half_message(env, fake_llm, monkeypatch): raise RuntimeError("db down") monkeypatch.setattr(chat_mod, "_session_repo", lambda: BrokenRepo()) - r = env["client"].post("/api/chat/stream", json={"message": "你好"}, headers=CUSTOMER) + r = env["client"].post("/api/chat/stream", json={"message": "你好"}, headers=ADVISOR) assert r.status_code == 200 assert _payloads(r)[-1]["error"]["code"] == "PERSIST_FAILED" assert _frames(r)[-1] == "[DONE]" # 前端必须能收尾,否则一直挂起 @@ -299,8 +300,37 @@ def test_stream_other_actor_session_denied(env, fake_llm): def test_stream_tool_still_logged(env, fake_llm): - """流式不绕过 Tool:持仓关键词仍落 agent_tool_call(与同步同口径)。""" - r = env["client"].post("/api/chat/stream", json={"message": "查一下我的持仓"}, headers=CUSTOMER) + """流式不绕过 Tool:risk 预警关键词仍落 agent_tool_call(与同步同口径)。""" + r = env["client"].post("/api/chat/stream", json={"message": "查一下待审预警"}, headers=RISK) assert r.status_code == 200 rows = _rows(env["engine"], "SELECT tool_name, status FROM agent_tool_call") - assert [(x["tool_name"], x["status"]) for x in rows] == [("query_holdings", "success")] + assert [(x["tool_name"], x["status"]) for x in rows] == [("alert_query", "success")] + + +def test_stream_customer_uses_customer_service(env, monkeypatch): + """customer 线 SSE 走 customer_service.prepare_customer_stream,不经 agent_service.stream_chat。""" + called = {"n": 0} + + def _prep(*args, **kwargs): + called["n"] += 1 + return { + "reply": "客服流式回复", + "has_disclaimer": False, + "intent": "chit_chat", + "transfer_to_human": False, + "chunks": ["客服", "流式", "回复"], + } + + monkeypatch.setattr(chat_mod, "prepare_customer_stream", _prep) + monkeypatch.setattr( + agent_service, + "stream_chat", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("customer must not use agent stream")), + ) + r = env["client"].post("/api/chat/stream", json={"message": "你好"}, headers=CUSTOMER) + assert r.status_code == 200 + assert called["n"] == 1 + delta_text = "".join( + p["choices"][0]["delta"].get("content", "") for p in _payloads(r) if "content" in p["choices"][0]["delta"] + ) + assert delta_text == "客服流式回复" diff --git a/tests/test_risk_chat_tools.py b/tests/test_risk_chat_tools.py index 7db3986..505d7e5 100644 --- a/tests/test_risk_chat_tools.py +++ b/tests/test_risk_chat_tools.py @@ -107,6 +107,7 @@ def _tool_rows(engine): def test_registry_combines_core_and_risk(): assert tool_service.get_registered_tool("alert_query") is not None + assert tool_service.get_registered_tool("query_agent_behavior") is not None assert tool_service.get_registered_tool("query_holdings") is not None # core 仍可达 assert tool_service.get_registered_tool("no_such_tool") is None diff --git a/web/src/App.tsx b/web/src/App.tsx index 0e77269..e7d34a2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { AppLayout } from './layouts/AppLayout' import { LoginPage } from './pages/login/LoginPage' import { AnalystQueryPage } from './pages/analytics/AnalystQueryPage' +import { AnalystAssetsPage } from './pages/analytics/AnalystAssetsPage' import { loadAuth } from './stores/authStore' import { AgentBanner } from './components/AgentBanner' import { CustomerWealthDashboard } from './pages/dashboard/CustomerWealthDashboard' @@ -16,6 +17,7 @@ import { AdvisorCustomersPage } from './pages/advisor/AdvisorCustomersPage' import { MarketQuotesPage } from './pages/market/MarketQuotesPage' import { RiskAlertsPage } from './pages/risk/RiskAlertsPage' import { RiskChatPage } from './pages/risk/RiskChatPage' +import { RiskSimulatePage } from './pages/risk/RiskSimulatePage' import { ChatPanel } from './components/chat' import { PageShell } from './components/PageShell' import { useAppAuth } from './layouts/AppLayout' @@ -88,6 +90,7 @@ export default function AppRoutes() { } /> } /> + } /> } /> } /> + } /> } /> } /> diff --git a/web/src/api/analyst.ts b/web/src/api/analyst.ts index 53a81f6..c7383c2 100644 --- a/web/src/api/analyst.ts +++ b/web/src/api/analyst.ts @@ -38,3 +38,30 @@ export async function postAnalystChat( }) return data } + +export type AnalystDashboardResponse = { + role: string + domain: string + cards: string[] + metrics: Record +} + +export async function getAnalystDashboard(token: string) { + const { data } = await apiFetch('/api/analyst/dashboard', { token }) + return data +} + +export type AnalystAssetKind = 'dict' | 'few_shot' | 'template' + +export async function createAnalystAsset( + token: string, + kind: AnalystAssetKind, + payload: Record, +) { + const { data } = await apiFetch<{ ok: boolean; id: number; kind: string }>('/api/analyst/assets', { + method: 'POST', + token, + body: JSON.stringify({ kind, payload }), + }) + return data +} diff --git a/web/src/api/risk.ts b/web/src/api/risk.ts index 4dad401..4916fdb 100644 --- a/web/src/api/risk.ts +++ b/web/src/api/risk.ts @@ -1,5 +1,17 @@ import { apiFetch } from './client' +const RISK_AGENT_TYPE = 'risk' + +function riskHeaders(token: string, extra?: HeadersInit): HeadersInit { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'X-Agent-Type': RISK_AGENT_TYPE, + ...extra, + } +} + export type RiskAlertItem = { alert_id: string alert_type: string @@ -20,7 +32,7 @@ type ListAlertsResponse = { export async function listPendingAlerts(token: string, pageSize = 100) { const { data } = await apiFetch( `/api/risk/alerts?status=pending_review&page_size=${pageSize}`, - { token }, + { headers: riskHeaders(token) }, ) return data } @@ -40,7 +52,9 @@ export async function listAlerts(token: string, params: ListAlertsParams = {}) { if (params.customer_id) q.set('customer_id', params.customer_id) q.set('page', String(params.page ?? 1)) q.set('page_size', String(params.page_size ?? 20)) - const { data } = await apiFetch(`/api/risk/alerts?${q.toString()}`, { token }) + const { data } = await apiFetch(`/api/risk/alerts?${q.toString()}`, { + headers: riskHeaders(token), + }) return data } @@ -52,7 +66,7 @@ export type HandleAlertPayload = { export async function handleAlert(token: string, alertId: string, payload: HandleAlertPayload) { const { data } = await apiFetch>( `/api/risk/alerts/${encodeURIComponent(alertId)}/handle`, - { method: 'POST', token, body: JSON.stringify(payload) }, + { method: 'POST', headers: riskHeaders(token), body: JSON.stringify(payload) }, ) return data } diff --git a/web/src/api/simulate.ts b/web/src/api/simulate.ts new file mode 100644 index 0000000..b2a3190 --- /dev/null +++ b/web/src/api/simulate.ts @@ -0,0 +1,40 @@ +import { apiFetch } from './client' +import type { AgentType } from './chat' + +export type SimulateTradePayload = { + customer_id: string + product_id: string + trade_type: 'subscribe' | 'redeem' + amount: number +} + +export type SimulateTradeResponse = { + blocked: boolean + trade_id?: string + block_response_code?: string + message?: string + [key: string]: unknown +} + +function simulateHeaders(token: string, agentType: AgentType): HeadersInit { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'X-Agent-Type': agentType, + } +} + +/** POST /api/simulate/trade — 需 risk_demo 或客户本人(customer_id 一致)。 */ +export async function submitSimulateTrade( + token: string, + agentType: AgentType, + payload: SimulateTradePayload, +) { + const { data } = await apiFetch('/api/simulate/trade', { + method: 'POST', + headers: simulateHeaders(token, agentType), + body: JSON.stringify(payload), + }) + return data +} diff --git a/web/src/pages/analytics/AnalystAssetsPage.tsx b/web/src/pages/analytics/AnalystAssetsPage.tsx new file mode 100644 index 0000000..d435b45 --- /dev/null +++ b/web/src/pages/analytics/AnalystAssetsPage.tsx @@ -0,0 +1,108 @@ +import { Alert, Form, Input, Select, Typography } from 'antd' +import { useState } from 'react' +import { createAnalystAsset, type AnalystAssetKind } from '../../api/analyst' +import { ApiError } from '../../api/client' +import { ApiErrorResult } from '../../components/ApiErrorResult' +import { PageShell } from '../../components/PageShell' +import { Button } from '../../components/ui' +import { useAppAuth } from '../../layouts/AppLayout' + +export function AnalystAssetsPage() { + const auth = useAppAuth() + const [kind, setKind] = useState('dict') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [lastId, setLastId] = useState(null) + const [form] = Form.useForm>() + + const onFinish = async (values: Record) => { + setLoading(true) + setError(null) + try { + const resp = await createAnalystAsset(auth.accessToken, kind, values) + setLastId(resp.id) + form.resetFields() + } catch (e) { + setError(e instanceof Error ? e : new Error(String(e))) + } finally { + setLoading(false) + } + } + + return ( + + } + > +
+ + + + + + + + + + + ) : null} + {kind === 'few_shot' ? ( + <> + + + + + + + + ) : null} + {kind === 'template' ? ( + <> + + + + + + + + ) : null} + +
+ {error ? form.submit()} /> : null} + {lastId ? ( + + 已写入 id={lastId}(status=draft) + + ) : null} +
+ ) +} diff --git a/web/src/pages/analytics/AnalystQueryPage.tsx b/web/src/pages/analytics/AnalystQueryPage.tsx index 32aeb63..865b868 100644 --- a/web/src/pages/analytics/AnalystQueryPage.tsx +++ b/web/src/pages/analytics/AnalystQueryPage.tsx @@ -1,9 +1,10 @@ -import { Alert, Button, Card, Collapse, Input, Space, Spin, Table, Typography } from 'antd' +import { Alert, Button, Card, Collapse, Input, Row, Col, Space, Spin, Table, Typography } from 'antd' import type { ColumnsType } from 'antd/es/table' -import { useState } from 'react' -import { postAnalystChat, type AnalystChatResponse } from '../../api/analyst' +import { useEffect, useState } from 'react' +import { getAnalystDashboard, postAnalystChat, type AnalystChatResponse } from '../../api/analyst' import { ApiError } from '../../api/client' import { ApiErrorResult } from '../../components/ApiErrorResult' +import { MetricCard } from '../../components/ui' import { PageShell } from '../../components/PageShell' import { useAppAuth } from '../../layouts/AppLayout' @@ -17,6 +18,32 @@ export function AnalystQueryPage() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [result, setResult] = useState(null) + const [dashLoading, setDashLoading] = useState(true) + const [dashError, setDashError] = useState(null) + const [dashMetrics, setDashMetrics] = useState>({}) + const [dashCards, setDashCards] = useState([]) + + useEffect(() => { + let cancelled = false + ;(async () => { + setDashLoading(true) + setDashError(null) + try { + const d = await getAnalystDashboard(auth.accessToken) + if (!cancelled) { + setDashMetrics(d.metrics ?? {}) + setDashCards(d.cards ?? []) + } + } catch (e) { + if (!cancelled) setDashError(e instanceof Error ? e : new Error(String(e))) + } finally { + if (!cancelled) setDashLoading(false) + } + })() + return () => { + cancelled = true + } + }, [auth.accessToken]) async function onAsk() { const q = question.trim() @@ -62,6 +89,25 @@ export function AnalystQueryPage() { } > + {dashError ? : null} + {dashLoading ? ( + + ) : Object.keys(dashMetrics).length > 0 ? ( + + + {Object.entries(dashMetrics).map(([key, val]) => ( + + + + ))} + + {dashCards.length > 0 ? ( + + 可用维度:{dashCards.join(' · ')} + + ) : null} + + ) : null} } > diff --git a/web/src/pages/risk/RiskSimulatePage.tsx b/web/src/pages/risk/RiskSimulatePage.tsx new file mode 100644 index 0000000..2dad0e0 --- /dev/null +++ b/web/src/pages/risk/RiskSimulatePage.tsx @@ -0,0 +1,135 @@ +import { Alert, Form, Input, InputNumber, Select, Typography } from 'antd' +import { useState } from 'react' +import { ApiErrorResult } from '../../components/ApiErrorResult' +import { PageShell } from '../../components/PageShell' +import { Button } from '../../components/ui' +import { submitSimulateTrade, type SimulateTradeResponse } from '../../api/simulate' +import { ApiError } from '../../api/client' +import { useAppAuth } from '../../layouts/AppLayout' + +const PRESETS = [ + { + key: 'a3', + label: 'A-3 大额(CUST-3001 · PROD-510300 · 50 万)', + values: { customer_id: 'CUST-3001', product_id: 'PROD-510300', trade_type: 'subscribe' as const, amount: 500000 }, + }, + { + key: 'a1', + label: 'A-1 适当性阻断(CUST-1001 · PROD-161725 · 1 万)', + values: { customer_id: 'CUST-1001', product_id: 'PROD-161725', trade_type: 'subscribe' as const, amount: 10000 }, + }, +] + +export function RiskSimulatePage() { + const auth = useAppAuth() + const [form] = Form.useForm<{ + customer_id: string + product_id: string + trade_type: 'subscribe' | 'redeem' + amount: number + }>() + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + + const onFinish = async (values: { + customer_id: string + product_id: string + trade_type: 'subscribe' | 'redeem' + amount: number + }) => { + setLoading(true) + setError(null) + setResult(null) + try { + const data = await submitSimulateTrade(auth.accessToken, 'risk', values) + setResult(data) + } catch (e) { + setError(e instanceof Error ? e : new Error('submit failed')) + } finally { + setLoading(false) + } + } + + return ( + + + 本页调用 POST /api/simulate/trade,请求头需{' '} + X-Agent-Type: risk。Demo 账号{' '} + STAFF-30001 已含{' '} + risk_demo,可直接造 A-3/A-1 演示预警。 + + } + /> + +
+ + + + + + + +