From b841f68295430d5e75b941fb2d053fb64ebca6ae Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 9 Sep 2026 18:32:00 +0800 Subject: [PATCH] feat(visitor): Implement visitor chat functionality and enhance customer service interactions - Added a new visitor chat API endpoint (`/api/chat/visitor`) to allow unauthenticated users to engage in conversations without requiring customer data. - Introduced a visitor context dependency to manage visitor interactions seamlessly. - Enhanced the chat API to support explicit session termination and improved response handling for customer service interactions. - Updated the database configuration to include Redis client support for caching visitor data. - Added a new customer note repository to persist user notes independently of the L1 profile slots. This update significantly improves the customer service experience by enabling visitor interactions and ensuring efficient data handling for both registered and unregistered users. --- .gitignore | 1 + AGENTS.md | 2 +- app/api/auth_adapter.py | 41 +- app/api/chat.py | 46 +- app/api/visitor.py | 57 + app/config/database.py | 12 + app/config/profile_slots.py | 494 ++++++++ app/config/settings.py | 28 + app/gateway/visitor_deps.py | 25 + app/main.py | 2 + app/model/schemas.py | 37 +- app/repository/note_repository.py | 100 ++ app/repository/profile_repository.py | 193 +++ app/service/customer_prompts.py | 377 ++++++ app/service/customer_service.py | 555 +++++++++ app/service/memory_service.py | 48 + app/service/note_service.py | 122 ++ app/service/profile_service.py | 487 ++++++++ app/service/rag_service.py | 44 + app/service/risk/chat_tools.py | 3 +- app/service/risk/profile_l3.py | 42 +- app/service/risk/redis_gateway.py | 28 +- app/service/visitor_prompts.py | 91 ++ app/service/visitor_service.py | 359 ++++++ app/tool/core_ro_tool.py | 449 +++++++ app/utils/compliance_guard.py | 110 ++ app/utils/data_masker.py | 179 +++ docs/frontend/FRONTEND-HANDOFF.md | 195 +++ docs/memory/FRAMEWORK.md | 4 +- docs/memory/ITERATION.md | 2 + docs/memory/MEMORY.md | 19 +- docs/memory/TODO.md | 69 +- .../2026-09-09-frontend-modernization.md | 1104 +++++++++++++++++ ...026-09-09-frontend-modernization-design.md | 377 ++++++ docs/项目框架设计/客服Agent-合并说明.md | 87 ++ .../表设计/02-mysql-agent 专用表设计总结.md | 251 ++++ pytest.ini | 5 + .../agent/migrate-customer-agent-cs-c11.sql | 36 + scripts/bootstrap.ps1 | 71 ++ scripts/init_agent_db.py | 95 ++ scripts/kb/build_collections.py | 113 ++ scripts/kb/reset.ps1 | 15 + scripts/kb/test_search.py | 36 + scripts/kb/test_visitor_api.py | 36 + tests/conftest.py | 69 ++ tests/test_chat.py | 124 +- tests/test_main.py | 1 + tests/test_module_boundary.py | 20 + tests/test_profile_l3.py | 52 + tests/test_wave1_data_masker.py | 179 +++ tests/test_wave2_core_ro_tool.py | 209 ++++ tests/test_wave2_profile_slots.py | 203 +++ tests/test_wave2_prompts.py | 93 ++ tests/test_wave3_customer_service.py | 362 ++++++ tests/test_wave3_profile_service.py | 510 ++++++++ tests/test_wave4_e2e.py | 492 ++++++++ tests/test_wave5_notes.py | 376 ++++++ web/README.md | 40 +- web/package-lock.json | 619 +++++++++ web/package.json | 2 + web/src/App.css | 184 --- web/src/components/AgentBanner.tsx | 14 +- web/src/components/ApiErrorResult.tsx | 44 +- web/src/components/PageShell.tsx | 20 +- web/src/components/dashboard/ChartCard.tsx | 20 + .../components/dashboard/DashboardCharts.tsx | 68 +- .../components/dashboard/DashboardHero.tsx | 68 +- .../components/dashboard/DashboardLayout.tsx | 29 +- web/src/components/dashboard/DataSection.tsx | 20 + .../components/dashboard/QuickActionBar.tsx | 19 +- .../dashboard/__tests__/composition.test.tsx | 28 + web/src/components/dashboard/index.ts | 13 + web/src/components/layout/AppShell.tsx | 21 + web/src/components/layout/BrandSidebar.tsx | 49 + web/src/components/layout/Topbar.tsx | 46 + web/src/components/layout/index.ts | 7 + web/src/components/ui/Badge.tsx | 35 + web/src/components/ui/Button.tsx | 60 + web/src/components/ui/EmptyState.tsx | 23 + web/src/components/ui/MetricCard.tsx | 32 + web/src/components/ui/PageHeader.tsx | 30 + web/src/components/ui/SectionHeading.tsx | 28 + web/src/components/ui/Surface.tsx | 41 + web/src/components/ui/__tests__/ui.test.tsx | 38 + web/src/components/ui/index.ts | 15 + web/src/index.css | 111 -- web/src/layouts/AppLayout.tsx | 90 +- web/src/lib/cn.ts | 3 + web/src/pages/PlaceholderPage.tsx | 8 +- .../dashboard/AdvisorClientsDashboard.tsx | 4 +- .../dashboard/AnalystMarketDashboard.tsx | 4 +- .../dashboard/CustomerWealthDashboard.tsx | 5 +- .../pages/dashboard/RiskAlertsDashboard.tsx | 3 +- web/src/pages/login/LoginPage.tsx | 103 +- web/src/styles/global.css | 136 +- web/src/styles/tokens.css | 41 + web/src/theme.ts | 51 +- web/vite.config.ts | 3 +- 98 files changed, 10682 insertions(+), 630 deletions(-) create mode 100644 app/api/visitor.py create mode 100644 app/config/profile_slots.py create mode 100644 app/gateway/visitor_deps.py create mode 100644 app/repository/note_repository.py create mode 100644 app/repository/profile_repository.py create mode 100644 app/service/customer_prompts.py create mode 100644 app/service/customer_service.py create mode 100644 app/service/note_service.py create mode 100644 app/service/profile_service.py create mode 100644 app/service/visitor_prompts.py create mode 100644 app/service/visitor_service.py create mode 100644 app/tool/core_ro_tool.py create mode 100644 app/utils/compliance_guard.py create mode 100644 app/utils/data_masker.py create mode 100644 docs/frontend/FRONTEND-HANDOFF.md create mode 100644 docs/superpowers/plans/2026-09-09-frontend-modernization.md create mode 100644 docs/superpowers/specs/2026-09-09-frontend-modernization-design.md create mode 100644 docs/项目框架设计/客服Agent-合并说明.md create mode 100644 docs/项目框架设计/表设计/02-mysql-agent 专用表设计总结.md create mode 100644 pytest.ini create mode 100644 scripts/agent/migrate-customer-agent-cs-c11.sql create mode 100644 scripts/bootstrap.ps1 create mode 100644 scripts/init_agent_db.py create mode 100644 scripts/kb/build_collections.py create mode 100644 scripts/kb/reset.ps1 create mode 100644 scripts/kb/test_search.py create mode 100644 scripts/kb/test_visitor_api.py create mode 100644 tests/test_wave1_data_masker.py create mode 100644 tests/test_wave2_core_ro_tool.py create mode 100644 tests/test_wave2_profile_slots.py create mode 100644 tests/test_wave2_prompts.py create mode 100644 tests/test_wave3_customer_service.py create mode 100644 tests/test_wave3_profile_service.py create mode 100644 tests/test_wave4_e2e.py create mode 100644 tests/test_wave5_notes.py delete mode 100644 web/src/App.css create mode 100644 web/src/components/dashboard/ChartCard.tsx create mode 100644 web/src/components/dashboard/DataSection.tsx create mode 100644 web/src/components/dashboard/__tests__/composition.test.tsx create mode 100644 web/src/components/dashboard/index.ts create mode 100644 web/src/components/layout/AppShell.tsx create mode 100644 web/src/components/layout/BrandSidebar.tsx create mode 100644 web/src/components/layout/Topbar.tsx create mode 100644 web/src/components/layout/index.ts create mode 100644 web/src/components/ui/Badge.tsx create mode 100644 web/src/components/ui/Button.tsx create mode 100644 web/src/components/ui/EmptyState.tsx create mode 100644 web/src/components/ui/MetricCard.tsx create mode 100644 web/src/components/ui/PageHeader.tsx create mode 100644 web/src/components/ui/SectionHeading.tsx create mode 100644 web/src/components/ui/Surface.tsx create mode 100644 web/src/components/ui/__tests__/ui.test.tsx create mode 100644 web/src/components/ui/index.ts delete mode 100644 web/src/index.css create mode 100644 web/src/lib/cn.ts create mode 100644 web/src/styles/tokens.css diff --git a/.gitignore b/.gitignore index b5f44dc..96f7848 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ docs/项目框架设计/表设计.zip # ===== AI 协作过程文件(本地保留,不提交)===== .workbuddy/ +.superpowers/ 交接文档.md # ===== 风控需求追加过程文档 v1.1(本地保留,不入库)===== diff --git a/AGENTS.md b/AGENTS.md index 0b4ab92..0199cc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,6 @@ app/repository/core_ro.py # Core 只读 + check_suitability(R-02) scripts/core/reset.ps1 # 本地灌 Core 模拟库 ``` -**当前分支:** `merger` · **测试基线:** `python -m pytest` → 530 passed, 0 skipped +**当前分支:** `merger` · **测试基线:** `python -m pytest` → 554 passed, 0 skipped 技术选型硬阀门见 MEMORY 第 3、7 节。Cursor 以 `.cursor/rules/project-memory.mdc` 为准。 diff --git a/app/api/auth_adapter.py b/app/api/auth_adapter.py index d060db9..1c8d05d 100644 --- a/app/api/auth_adapter.py +++ b/app/api/auth_adapter.py @@ -21,12 +21,26 @@ from __future__ import annotations -from typing import Any +from typing import Any, NamedTuple from app.api.deps import AuthContext from app.utils.trace import set_trace -__all__ = ["from_host_auth", "perm_matches", "resolve_agent_type", "module_auth_from_host", "HostAuthAdapterError"] +__all__ = ["from_host_auth", "perm_matches", "resolve_agent_type", "module_auth_from_host", "host_auth_for_customer_service", "HostAuthAdapterError"] + + +class _HostAuthShim(NamedTuple): + """与宿主 schemas.AuthContext 同形(S2 反向接缝;不 import 宿主模块)。""" + + sub: str + token_type: str + roles: list[str] + permissions: list[str] + tenant_id: str + trace_id: str + agent_type: str + jti: str + customer_id: str | None class HostAuthAdapterError(ValueError): @@ -102,3 +116,26 @@ def module_auth_from_host(host_ctx: Any, *, agent_type: str | None = None) -> Au """S2 接缝接线入口:宿主 AuthContext → 模块 AuthContext(含 trace 绑定)。""" resolved = agent_type or resolve_agent_type(host_ctx) return from_host_auth(host_ctx, agent_type=resolved, bind_trace=True) + + +def host_auth_for_customer_service(module_auth: AuthContext, *, trace_id: str) -> _HostAuthShim: + """模块 AuthContext → 宿主 AuthContext 同形对象(客服 customer_service 专用)。 + + customer_service 仍使用 schemas.AuthContext 字段名(sub/trace_id/agent_type), + chat 模块线走 deps.AuthContext;本函数为 S2 反向接缝,仅 customer 分流调用。 + """ + customer_id = module_auth.customer_id + if customer_id is None and module_auth.token_type == "customer": + customer_id = module_auth.actor_id + + return _HostAuthShim( + sub=module_auth.actor_id, + token_type=module_auth.token_type, + roles=list(module_auth.roles), + permissions=list(module_auth.permissions), + tenant_id=module_auth.tenant_id or "default", + trace_id=trace_id, + agent_type="customer", + jti=module_auth.jti or "", + customer_id=customer_id, + ) diff --git a/app/api/chat.py b/app/api/chat.py index 4c2caf3..61abe53 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -29,6 +29,7 @@ from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field +from app.api.auth_adapter import host_auth_for_customer_service from app.api.deps import ( AGENT_TYPES, AuthContext, @@ -41,6 +42,7 @@ 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.utils.exceptions import ApiError, StateConflict from app.utils.trace import current_trace, new_trace @@ -76,6 +78,7 @@ class ChatRequest(BaseModel): None, description="目标客户:customer 角色忽略(强制本人);advisor/risk/analyst 可指定(过归属校验)" ) title: str | None = Field(None, max_length=256) + end_session: bool = Field(default=False, description="customer 显式结束会话(触发归档)") def _primary_role(auth: AuthContext) -> str: @@ -254,21 +257,34 @@ def chat_api(req: ChatRequest, request: Request, auth: AuthContext = Depends(get agent_type, message = _guard_request(req, request, auth) sid, customer_id = _prepare_turn(req, auth, agent_type) - history = memory_service.get_recent(agent_type, sid) - result = agent_service.chat( - agent_type, - history, - message, - session_id=sid, - trace_id=current_trace(), - actor={"actor_id": auth.actor_id, "roles": auth.roles, "token_type": auth.token_type}, - customer_id=customer_id, - ) - - # 落盘:user + assistant 同步写(同事务,异步化归后续);同 trace_id 贯通 trace_id = current_trace() - has_disclaimer = bool(result["has_disclaimer"]) - assistant_content = _assistant_content_for_persist(result["reply"], has_disclaimer) + intent: str | None = None + transfer = False + + if agent_type == "customer": + host_ctx = host_auth_for_customer_service(auth, trace_id=trace_id or new_trace()) + reply, has_disclaimer, intent, transfer = run_customer_chat( + host_ctx, + message, + sid, + customer_id or auth.customer_id or auth.actor_id, + req.end_session, + ) + assistant_content = _assistant_content_for_persist(reply, has_disclaimer) + else: + history = memory_service.get_recent(agent_type, sid) + result = agent_service.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, + ) + has_disclaimer = bool(result["has_disclaimer"]) + assistant_content = _assistant_content_for_persist(result["reply"], has_disclaimer) + _session_repo().insert_turn( session_id=sid, trace_id=trace_id, @@ -292,6 +308,8 @@ def chat_api(req: ChatRequest, request: Request, auth: AuthContext = Depends(get "reply": assistant_content, "has_disclaimer": has_disclaimer, "trace_id": trace_id, + "intent": intent, + "transfer_to_human": transfer, } diff --git a/app/api/visitor.py b/app/api/visitor.py new file mode 100644 index 0000000..b4146ee --- /dev/null +++ b/app/api/visitor.py @@ -0,0 +1,57 @@ +"""游客对话接口:免认证 /api/chat/visitor。""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Request + +from app.gateway.visitor_deps import get_visitor_context +from app.model.schemas import AuthContext, VisitorChatRequest, VisitorChatResponseData +from app.repository.audit_repository import AuditRepository +from app.service.visitor_service import run_visitor_chat +from app.utils.input_guard import validate_user_message +from app.utils.response import ok + +router = APIRouter(prefix="/api", tags=["visitor"]) + + +@router.post("/chat/visitor") +def visitor_chat( + body: VisitorChatRequest, + request: Request, + ctx: Annotated[AuthContext, Depends(get_visitor_context)], +): + """游客对话:免认证,不查客户数据,不写 agent_session/agent_message。""" + message = validate_user_message(body.message) + session_id = body.session_id or str(uuid.uuid4()) + + reply, has_disclaimer, intent, transfer = run_visitor_chat(ctx, message, session_id) + + # 审计落库(agent_type=platform,ENUM 已含);审计失败不阻断对话主流程 + try: + AuditRepository().insert( + trace_id=ctx.trace_id, + event_type="visitor_chat_completed", + agent_type="platform", + actor_id="visitor", + input_summary={ + "session_id": session_id, + "intent": intent, + "transfer": transfer, + "msg_len": len(message), + }, + decision="transfer_human" if transfer else "success", + ) + except Exception: + pass + + data = VisitorChatResponseData( + session_id=session_id, + reply=reply, + intent=intent, + has_disclaimer=has_disclaimer, + transfer_to_human=transfer, + ) + return ok(data.model_dump(), ctx.trace_id) diff --git a/app/config/database.py b/app/config/database.py index f57665d..28eb00f 100644 --- a/app/config/database.py +++ b/app/config/database.py @@ -2,6 +2,7 @@ from functools import lru_cache +import redis from sqlalchemy import create_engine from sqlalchemy.engine import Engine @@ -25,3 +26,14 @@ def get_agent_engine() -> Engine: @lru_cache def get_core_engine() -> Engine: return create_engine(_mysql_url(settings.mysql_core_database), pool_pre_ping=True) + + +@lru_cache +def get_redis_client() -> redis.Redis: + """Redis 单例客户端(客服线 VisitorMemory / ProfileHotCache 用)。""" + return redis.from_url( + settings.redis_url, + decode_responses=True, + socket_timeout=5, + socket_connect_timeout=3, + ) diff --git a/app/config/profile_slots.py b/app/config/profile_slots.py new file mode 100644 index 0000000..4a8eced --- /dev/null +++ b/app/config/profile_slots.py @@ -0,0 +1,494 @@ +"""已注册用户画像 L1 抽槽定义(12 槽位,style_tags JSON 1:1)。 + +槽位表是抽槽 prompt 与合并规则的唯一来源(方案 §6.2): +- 路径对齐 docs/项目框架设计/表设计/06-用户画像L1-L3设计.md §4.1 style_tags +- merge_mode: latest(单值,最新表达覆盖) / set_union(集合累计) +- value_format: band(区间归一)/ enum(闭集枚举)/ list(词表列表)/ text(短文本) +""" + +from __future__ import annotations + +import re +from typing import Any, Literal, TypedDict + +MergeMode = Literal["latest", "set_union"] +ValueFormat = Literal["band", "enum", "list", "text"] +AllowedSource = Literal["chat", "chat_or_questionnaire", "chat_or_consult_or_trade"] + + +class SlotSpec(TypedDict): + path: str + name: str + allowed_source: AllowedSource + sensitivity: Literal["high", "medium", "low"] + merge_mode: MergeMode + value_format: ValueFormat + description: str + examples: list[str] + + +# --------------------------------------------------------------------------- +# 12 槽位定义 +# --------------------------------------------------------------------------- + +SLOTS: list[SlotSpec] = [ + { + "path": "basic.age_band", + "name": "年龄段", + "allowed_source": "chat", + "sensitivity": "high", + "merge_mode": "latest", + "value_format": "band", + "description": "客户当前年龄所在区间,由年龄数字归一,不接受自述区间以外的猜测", + "examples": ["我今年28", "35岁了", "明年就退休了(60+)"], + }, + { + "path": "basic.city", + "name": "所在城市", + "allowed_source": "chat", + "sensitivity": "medium", + "merge_mode": "latest", + "value_format": "text", + "description": "常住城市,取城市名即可(省/市/区均可归为城市名)", + "examples": ["我在上海", "老家成都的", "在深圳上班"], + }, + { + "path": "basic.education", + "name": "学历", + "allowed_source": "chat", + "sensitivity": "medium", + "merge_mode": "latest", + "value_format": "enum", + "description": "最高学历,闭集:高中及以下 / 大专 / 本科 / 硕士及以上", + "examples": ["我本科毕业", "研究生学历", "大专"], + }, + { + "path": "financial.income_band", + "name": "年收入区间", + "allowed_source": "chat", + "sensitivity": "high", + "merge_mode": "latest", + "value_format": "band", + "description": "年收入所在区间(万元/年);自述月薪时按 ×12 估算后归档", + "examples": ["年薪30万", "月入2万", "一年到手十几万"], + }, + { + "path": "financial.monthly_investable", + "name": "月可投金额", + "allowed_source": "chat", + "sensitivity": "high", + "merge_mode": "latest", + "value_format": "band", + "description": "每月可用于投资理财的闲置资金所在区间(元/月)", + "examples": ["每月能拿3000定投", "月可投5000左右", "一个月剩不下钱投"], + }, + { + "path": "investment.goal", + "name": "投资目标", + "allowed_source": "chat_or_questionnaire", + "sensitivity": "medium", + "merge_mode": "latest", + "value_format": "text", + "description": "主要投资目的,短文本(如 购房储蓄/养老储备/子女教育/资产增值/备用金)", + "examples": ["存钱买房", "给孩子攒教育金", "养老用"], + }, + { + "path": "investment.horizon", + "name": "投资期限", + "allowed_source": "chat_or_questionnaire", + "sensitivity": "medium", + "merge_mode": "latest", + "value_format": "enum", + "description": "资金可投资时长,闭集:1年以内 / 1-3年 / 3-5年 / 5年以上", + "examples": ["这笔钱三年不用", "半年后要用", "长期放着"], + }, + { + "path": "investment.product_preferences", + "name": "偏好产品类型", + "allowed_source": "chat_or_consult_or_trade", + "sensitivity": "low", + "merge_mode": "set_union", + "value_format": "list", + "description": "明确表达兴趣或实际购买/咨询过的产品类型,集合累计", + "examples": ["我喜欢指数基金", "买了点货币基金", "想了解债券基金"], + }, + { + "path": "investment.excluded_products", + "name": "排除产品类型", + "allowed_source": "chat", + "sensitivity": "medium", + "merge_mode": "set_union", + "value_format": "list", + "description": "明确表示不接受/排斥的产品类型,集合累计", + "examples": ["私募不碰", "期货绝对不做", "不想买保险"], + }, + { + "path": "lifecycle.stage", + "name": "生命周期阶段", + "allowed_source": "chat_or_questionnaire", + "sensitivity": "medium", + "merge_mode": "latest", + "value_format": "enum", + "description": "家庭生命周期,闭集:单身奋斗期 / 家庭形成期 / 家庭成长期 / 家庭成熟期 / 退休养老期", + "examples": ["刚工作单身", "刚结婚还没孩子", "孩子上大学了", "退休了"], + }, + { + "path": "lifecycle.near_term_goal", + "name": "近期目标", + "allowed_source": "chat", + "sensitivity": "medium", + "merge_mode": "latest", + "value_format": "text", + "description": "1-2 年内的具体生活/财务目标,短文本", + "examples": ["明年准备买车", "下半年要装修", "近期想凑首付"], + }, + { + "path": "threshold_pref_summary", + "name": "阈值提醒偏好", + "allowed_source": "chat", + "sensitivity": "medium", + "merge_mode": "latest", + "value_format": "text", + "description": "客户主动提出的提醒阈值摘要(明细归 customer_threshold_config),如 亏损10%提醒/单产品跌5%告诉我", + "examples": ["亏10%就提醒我", "单只基金跌5%告诉我"], + }, +] + + +# --------------------------------------------------------------------------- +# 归一化词表与档位 +# --------------------------------------------------------------------------- + +_AGE_BANDS: list[tuple[int, str]] = [ + (24, "18-24"), + (30, "25-30"), + (35, "31-35"), + (40, "36-40"), + (50, "41-50"), + (60, "51-60"), + (200, "61+"), +] + +# 年收入(万元/年) +_INCOME_BANDS: list[tuple[float, str]] = [ + (10, "10万以下"), + (20, "10-20万"), + (50, "20-50万"), + (100, "50-100万"), + (10**9, "100万以上"), +] + +# 月可投(元/月) +_MONTHLY_INVESTABLE_BANDS: list[tuple[float, str]] = [ + (1000, "1000以下"), + (3000, "1000-3000"), + (5000, "3000-5000"), + (10000, "5000-10000"), + (10**9, "10000以上"), +] + +_EDUCATION_ENUM = ("高中及以下", "大专", "本科", "硕士及以上") +_EDUCATION_ALIASES: dict[str, str] = { + "高中": "高中及以下", "中专": "高中及以下", "职高": "高中及以下", "初中": "高中及以下", + "大专": "大专", "专科": "大专", "高职": "大专", + "本科": "本科", "学士": "本科", "一本": "本科", "二本": "本科", + "硕士": "硕士及以上", "研究生": "硕士及以上", "博士": "硕士及以上", "MBA": "硕士及以上", +} + +_HORIZON_ENUM = ("1年以内", "1-3年", "3-5年", "5年以上") + +_STAGE_ENUM = ("单身奋斗期", "家庭形成期", "家庭成长期", "家庭成熟期", "退休养老期") +_STAGE_ALIASES: dict[str, str] = { + "单身": "单身奋斗期", "刚工作": "单身奋斗期", "未婚": "单身奋斗期", + "新婚": "家庭形成期", "刚结婚": "家庭形成期", "没孩子": "家庭形成期", "备孕": "家庭形成期", + "孩子小": "家庭成长期", "上学": "家庭成长期", "小学": "家庭成长期", "中学": "家庭成长期", + "上大学": "家庭成熟期", "孩子工作": "家庭成熟期", "快退休": "家庭成熟期", + "退休": "退休养老期", "养老": "退休养老期", +} + +# 产品类型归一(对齐 core_product.product_type 中文口径) +PRODUCT_TYPE_VOCAB = ( + "货币基金", "债券基金", "混合基金", "股票基金", "指数基金", + "银行理财", "私募基金", "保险", "结构化产品", +) +_PRODUCT_TYPE_ALIASES: dict[str, str] = { + "货基": "货币基金", "余额宝": "货币基金", "货币": "货币基金", + "债基": "债券基金", "债券": "债券基金", + "混基": "混合基金", "混合型": "混合基金", + "股基": "股票基金", "股票": "股票基金", + "指数": "指数基金", "ETF": "指数基金", "etf": "指数基金", + "理财": "银行理财", "理财产品": "银行理财", + "私募": "私募基金", + "期货": "期货", # 排除类常见词,保留原词(不在偏好词表内,供 excluded 使用) +} + +_NUMBER_RE = re.compile(r"(\d+(?:\.\d+)?)") +_CN_DIGITS = {"一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, + "六": 6, "七": 7, "八": 8, "九": 9} +_CN_AMT_RE = re.compile(r"([一二两三四五六七八九十几\d]+)(万|千)") + +# basic.city 城市名归一:剥掉常见前后缀取干净城市名 +# (LLM 抽槽按槽位示例输出原文短句,如「我搬去杭州了」,此处二次清洗) +_CITY_LEAD_RE = re.compile( + r"^(?:我)?(?:现在|目前|之前|去年|今年)?" + r"(?:住在|定居在|居住在|常驻|户籍在|老家在|老家|来自|搬去|搬到|去了|回了|在)" +) +_CITY_TAIL_RE = re.compile( + r"(?:上班|工作|生活|定居|常驻|居住|上学|读书|发展|安家)*(?:的|了|呀|啦|哦|呢)*$" +) +_CITY_NAME_RE = re.compile(r"[\u4e00-\u9fa5]{2,7}") + + +def _normalize_city(text: str) -> str | None: + """basic.city 归一:我搬去杭州了→杭州 / 老家成都的→成都 / 在深圳上班→深圳。 + + 无法清洗出 2~7 个汉字的城市名时返回 None(合并层丢弃,不写入画像)。 + """ + t = re.sub(r"\s", "", text) + for _ in range(3): + stripped = _CITY_TAIL_RE.sub("", _CITY_LEAD_RE.sub("", t)) + if stripped == t: + break + t = stripped + if "省" in t: # 浙江省杭州市 → 杭州市 + t = t.rsplit("省", 1)[-1] + if t.endswith("市") and len(t) > 2: # 深圳市保留单字市名(如 沙市) + t = t[:-1] + return t if _CITY_NAME_RE.fullmatch(t) else None + + +def _band_of(value: float, bands: list[tuple[float, str]], *, upper_inclusive: bool = True) -> str: + """归入档位。upper_inclusive=True(年龄档,含上界);False(金额档,[下界,上界) 语义)。""" + for upper, label in bands: + hit = (value <= upper) if upper_inclusive else (value < upper) + if hit: + return label + return bands[-1][1] + + +def _cn_base(token: str) -> float | None: + """简易中文数字(万/千单位剥离后)→ 数值。支持 三/十/十三/二十三/十几。""" + if token == "": + return None + if token == "十": + return 10.0 + if "十" in token: + left, _, right = token.partition("十") + if left == "": + tens = 10.0 + elif left in _CN_DIGITS: + tens = float(_CN_DIGITS[left] * 10) + else: + return None + if right in ("", "几"): + ones = 0.0 + elif right in _CN_DIGITS: + ones = float(_CN_DIGITS[right]) + else: + return None + return tens + ones + if token in _CN_DIGITS: + return float(_CN_DIGITS[token]) + if token.isdigit(): + return float(token) + return None + + +def _extract_amount_wan(text: str) -> tuple[float, bool] | None: + """从文本提取年收入金额,返回 (万元数值, 是否月薪口径)。无法提取返回 None。""" + is_monthly = bool(re.search(r"月薪|月入|每月|每个月|月工资|一个月", text)) + + m = _NUMBER_RE.search(text) + if m: + num = float(m.group(1)) + if re.search(r"万", text): + return num, is_monthly + if re.search(r"千|[kK]", text): + return num * 0.1, is_monthly # 千 → 万 + if is_monthly: + return num / 10000, is_monthly # 月薪裸数字按元 + if num >= 10000: + return num / 10000, is_monthly + return num, is_monthly # 年薪裸数字("年薪30")按万 + + # 中文数字(一万/十几万/三千) + cm = _CN_AMT_RE.search(text) + if cm: + base = _cn_base(cm.group(1)) + if base is None: + return None + wan = base if cm.group(2) == "万" else base * 0.1 + return wan, is_monthly + return None + + +# --------------------------------------------------------------------------- +# 对外访问 +# --------------------------------------------------------------------------- + +SLOT_BY_PATH: dict[str, SlotSpec] = {s["path"]: s for s in SLOTS} +SLOT_PATHS: tuple[str, ...] = tuple(s["path"] for s in SLOTS) +HIGH_SENSITIVITY_PATHS: tuple[str, ...] = tuple( + s["path"] for s in SLOTS if s["sensitivity"] == "high" +) + + +def get_slot(path: str) -> SlotSpec | None: + return SLOT_BY_PATH.get(path) + + +def normalize_value(path: str, raw: str) -> str | list[str] | None: + """将 LLM 抽出的原始文本归一为槽位标准值。 + + 返回 None 表示无法归一(合并时丢弃,不写入画像)。 + """ + slot = SLOT_BY_PATH.get(path) + text = (raw or "").strip() + if not text: + return None + if not slot: + return None + + fmt = slot["value_format"] + + if fmt == "list": + return _normalize_list_value(text) + + if fmt == "text": + if path == "basic.city": + return _normalize_city(text) + return text[:50] or None + + if fmt == "band": + if path == "basic.age_band": + m = _NUMBER_RE.search(text) + if not m: + return None + age = float(m.group(1)) + if not (16 <= age <= 100): + return None + return _band_of(age, _AGE_BANDS) + if path == "financial.income_band": + extracted = _extract_amount_wan(text) + if not extracted: + return None + wan, is_monthly = extracted + annual = wan * 12 if is_monthly else wan + if annual <= 0: + return None + return _band_of(annual, _INCOME_BANDS, upper_inclusive=False) + if path == "financial.monthly_investable": + m = _NUMBER_RE.search(text) + if m: + num = float(m.group(1)) + if re.search(r"万", text): + num *= 10000 + elif re.search(r"千|k|K", text): + num *= 1000 + elif num < 100: # 裸小数按万元("月可投0.3") + num *= 10000 + else: + cm = _CN_AMT_RE.search(text) + if not cm: + return None + base = _cn_base(cm.group(1)) + if base is None: + return None + num = base * (10000 if cm.group(2) == "万" else 1000) + if num <= 0: + return None + return _band_of(num, _MONTHLY_INVESTABLE_BANDS, upper_inclusive=False) + return None + + if fmt == "enum": + if path == "basic.education": + for alias, std in _EDUCATION_ALIASES.items(): + if alias in text: + return std + return None + if path == "investment.horizon": + if re.search(r"半年|一年以内|1年内|短期|几个月|应急|随时", text): + return "1年以内" + if re.search(r"一到三年|1-3|两三年|三年以内|三年不用|2年|3年", text): + return "1-3年" + if re.search(r"三到五年|3-5|四五年|三年以上|5年以内", text): + return "3-5年" + if re.search(r"五年以上|五年不用|长期|10年|十年|养老", text): + return "5年以上" + return None + if path == "lifecycle.stage": + for alias, std in _STAGE_ALIASES.items(): + if alias in text: + return std + return None + return None + + return None + + +def _normalize_list_value(text: str) -> list[str] | None: + """产品偏好/排除列表归一:按顿号/逗号/和切分,过产品类型别名表。""" + parts = re.split(r"[、,,/;;和及\s]+", text) + result: list[str] = [] + for part in parts: + part = part.strip(" ,。.!!??") + if not part or len(part) > 20: + continue + mapped: str | None = None + for alias, std in _PRODUCT_TYPE_ALIASES.items(): + if alias in part: + mapped = std + break + if mapped is None: + for vocab in PRODUCT_TYPE_VOCAB: + if vocab in part: + mapped = vocab + break + if mapped and mapped not in result: + result.append(mapped) + return result or None + + +# --------------------------------------------------------------------------- +# 抽槽 prompt 生成(由槽位表自动派生) +# --------------------------------------------------------------------------- + +_SOURCE_LABEL: dict[AllowedSource, str] = { + "chat": "仅客户主动聊天声明", + "chat_or_questionnaire": "聊天声明或风险问卷已有答案", + "chat_or_consult_or_trade": "聊天声明、咨询内容或交易行为均可", +} + + +def build_extraction_prompt() -> str: + """生成画像抽槽 system prompt(槽位表唯一来源)。""" + lines = [ + "你是金融客服的画像信息抽取器。从最近对话窗口中抽取客户画像槽位,输出严格 JSON。", + "", + "槽位定义(path 为键,value 为归一前的原文短句):", + ] + for i, s in enumerate(SLOTS, 1): + lines.append( + f"{i}. {s['path']}({s['name']}):来源限制:{_SOURCE_LABEL[s['allowed_source']]};" + f"{'高敏感,只接受客户本人明确陈述' if s['sensitivity'] == 'high' else '中敏感,需客户主动提及' if s['sensitivity'] == 'medium' else '低敏感,兴趣/行为线索即可'};" + f"示例:{' / '.join(s['examples'])}" + ) + lines += [ + "", + "输出格式:只输出一个 JSON 对象,形如", + '{"updates": [{"path": "basic.age_band", "value": "我今年28", "source": "user_declared", "confidence": 0.95, "evidence": "客户原话:我今年28"}]}', + "", + "字段说明:", + "- value:归一前的原文短句;集合类槽位(product_preferences / excluded_products)为数组,其余为字符串", + "- source:user_declared(客户亲口明确陈述)或 inferred(从语境合理推断);高敏感槽位(年龄/收入/月可投)不允许 inferred", + "- confidence:0~1 抽取置信度", + "- evidence:引用客户原话片段,必须与对话窗口内容一致,禁止编造", + "", + "规则:", + "1. 只输出 JSON 对象,不要任何解释或标点", + "2. 只抽取对话中**有明确依据**的槽位;推测、诱导、客服未问而客户未说的一律不抽", + "3. 高敏感槽位(收入/月可投/年龄)必须是客户亲口说出的数字或明确陈述,禁止从消费水平/语气推断", + "4. 客户否认或更正旧信息时(如“我不在上海了,搬去杭州”),照常抽取新值,合并层负责覆盖", + "5. 没有任何可抽信息时输出 {\"updates\": []}", + ] + return "\n".join(lines) diff --git a/app/config/settings.py b/app/config/settings.py index a205784..a3051bf 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -32,6 +32,30 @@ class Settings(BaseSettings): deepseek_api_key: str = "" deepseek_base_url: str = "https://api.deepseek.com" + deepseek_model: str = "deepseek-chat" + deepseek_temperature: float = 0.3 + deepseek_max_tokens: int = 1024 + + # ===== 游客 Agent(客服线 · redis-keys §2.2)===== + visitor_session_ttl: int = 1800 + visitor_chitchat_max_rounds: int = 15 + visitor_consult_max_rounds: int = 25 + visitor_rate_window_seconds: int = 60 + visitor_rate_max_requests: int = 30 + + # ===== 客户 Agent 会话/画像/归档(客服线 CS Wave 3~5)===== + customer_session_ttl: int = 7200 + customer_chitchat_max_rounds: int = 15 + customer_consult_max_rounds: int = 25 + profile_extract_every_rounds: int = 5 + profile_confidence_high: float = 0.9 + profile_confidence_default: float = 0.7 + profile_window_max_msgs: int = 40 + archive_idle_minutes: int = 120 + archive_idle_scan_limit: int = 3 + archive_summary_max_msgs: int = 30 + note_max_inject: int = 5 + note_content_max_len: int = 500 # ===== JWT(T-01 · JWT 手册 §4/§11)===== jwt_public_key_path: str = "" @@ -71,6 +95,10 @@ class Settings(BaseSettings): guard_rate_limit_max: int = 30 guard_rate_limit_window_seconds: int = 60 + # ===== 画像 Redis 热缓存(redis-keys 手册 · cache-aside)===== + profile_l1_cache_ttl_seconds: int = 600 # profile:l1:{customer_id} · 10m + profile_l3_cache_ttl_seconds: int = 300 # profile:l3:{customer_id} · 5m + # ===== 代销平台 API(v0.1)===== platform_response_desensitize: bool = False diff --git a/app/gateway/visitor_deps.py b/app/gateway/visitor_deps.py new file mode 100644 index 0000000..f4a0f7d --- /dev/null +++ b/app/gateway/visitor_deps.py @@ -0,0 +1,25 @@ +"""游客免认证依赖:构造受限 AuthContext。""" + +from __future__ import annotations + +import uuid + +from fastapi import Request + +from app.model.schemas import AuthContext + + +def get_visitor_context(request: Request) -> AuthContext: + """构造游客受限上下文,不走 JWT 鉴权,不调 assert_agent_access。""" + trace_id = getattr(request.state, "trace_id", None) or str(uuid.uuid4()) + return AuthContext( + sub="visitor", + token_type="visitor", + roles=[], + permissions=[], + tenant_id="default", + trace_id=trace_id, + agent_type="visitor", + jti=str(uuid.uuid4()), + customer_id=None, + ) diff --git a/app/main.py b/app/main.py index 57fb8cc..981d79b 100644 --- a/app/main.py +++ b/app/main.py @@ -22,6 +22,7 @@ from app.api.products import router as products_router from app.api.risk import router as risk_router from app.api.simulate import router as simulate_router from app.api.staff import router as staff_router +from app.api.visitor import router as visitor_router from app.config.settings import settings from app.middleware.trace import TraceMiddleware from app.repository.audit_repository import AuditRepository @@ -87,6 +88,7 @@ app.include_router(advisors_router) app.include_router(staff_router) app.include_router(compliance_router) app.include_router(chat_router) +app.include_router(visitor_router) app.include_router(risk_router) app.include_router(simulate_router) diff --git a/app/model/schemas.py b/app/model/schemas.py index fc4719b..44a6a03 100644 --- a/app/model/schemas.py +++ b/app/model/schemas.py @@ -6,8 +6,22 @@ from typing import Any, Literal from pydantic import BaseModel, Field -AgentType = Literal["customer", "advisor", "analyst", "risk"] -TokenType = Literal["customer", "staff", "service"] +AgentType = Literal["customer", "advisor", "analyst", "risk", "visitor"] +TokenType = Literal["customer", "staff", "service", "visitor"] +IntentType = Literal[ + "product_consult", + "policy_interpret", + "faq", + "chit_chat", + "save_note", + "reject", + "transfer_human", + "fallback", + "holding_query", + "transaction_query", + "risk_assessment_query", + "suitability_check", +] class AuthContext(BaseModel): @@ -58,6 +72,8 @@ class ChatResponseData(BaseModel): reply: str agent_type: AgentType has_disclaimer: bool = False + intent: IntentType | None = Field(default=None, description="仅 customer 返回") + transfer_to_human: bool = Field(default=False, description="仅 customer 返回") class ApiResponse(BaseModel): @@ -65,3 +81,20 @@ class ApiResponse(BaseModel): message: str = "ok" data: Any = None trace_id: str + + +class VisitorChatRequest(BaseModel): + """游客对话请求(免认证)。""" + + message: str = Field(..., min_length=1, max_length=8000) + session_id: str | None = None + + +class VisitorChatResponseData(BaseModel): + """游客对话响应。""" + + session_id: str + reply: str + intent: IntentType + has_disclaimer: bool = False + transfer_to_human: bool = False diff --git a/app/repository/note_repository.py b/app/repository/note_repository.py new file mode 100644 index 0000000..452bffe --- /dev/null +++ b/app/repository/note_repository.py @@ -0,0 +1,100 @@ +"""客户显式备注持久化(jinrong_agent 库,Wave 5)。 + +职责:用户主动要求记忆的自由文本("你要记住我每天看净值"),独立于 L1 画像槽位。 +触发:keyword_route 命中"记住/记一下/帮我记" → save_note 意图 → note_service 调 LLM 抽取 content+category → 写入此表。 + +铁律: +1. content 为 LLM 抽取后的纯净文本(剥掉"你要记住"等指令词),长度 ≤500; +2. 软删除(is_active=0)保留审计痕,用户可"忘掉我之前的备注"; +3. 一会话可多条备注(无 uk_session_id)。 +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import text +from sqlalchemy.engine import Engine + +from app.config.database import get_agent_engine + + +class CustomerNoteRepository: + def __init__(self, engine: Engine | None = None) -> None: + self._engine = engine or get_agent_engine() + + def insert_note( + self, + *, + customer_id: str, + session_id: str, + trace_id: str, + content: str, + category: str | None = None, + source_text: str | None = None, + ) -> int: + """写入备注,返回自增 id。""" + sql = text( + """ + INSERT INTO customer_notes + (customer_id, session_id, trace_id, content, category, source_text) + VALUES + (:cid, :sid, :trace_id, :content, :category, :source_text) + """ + ) + with self._engine.begin() as conn: + result = conn.execute( + sql, + { + "cid": customer_id, + "sid": session_id, + "trace_id": trace_id, + "content": content, + "category": category, + "source_text": source_text, + }, + ) + return int(result.lastrowid) + + def list_active_notes(self, customer_id: str, limit: int = 5) -> list[dict]: + """读最近 N 条 active 备注按 created_at DESC。""" + sql = text( + """ + SELECT id, content, category, source_text, created_at + FROM customer_notes + WHERE customer_id = :cid AND is_active = 1 + ORDER BY created_at DESC + LIMIT :limit + """ + ) + with self._engine.connect() as conn: + rows = conn.execute( + sql, {"cid": customer_id, "limit": limit} + ).mappings().all() + return [dict(r) for r in rows] + + def deactivate(self, note_id: int, customer_id: str) -> bool: + """软删除单条备注(is_active=0),返回是否成功。""" + sql = text( + """ + UPDATE customer_notes + SET is_active = 0 + WHERE id = :note_id AND customer_id = :cid AND is_active = 1 + """ + ) + with self._engine.begin() as conn: + result = conn.execute(sql, {"note_id": note_id, "cid": customer_id}) + return result.rowcount == 1 + + def deactivate_all(self, customer_id: str) -> int: + """软删除客户所有 active 备注,返回删除条数。""" + sql = text( + """ + UPDATE customer_notes + SET is_active = 0 + WHERE customer_id = :cid AND is_active = 1 + """ + ) + with self._engine.begin() as conn: + result = conn.execute(sql, {"cid": customer_id}) + return int(result.rowcount) diff --git a/app/repository/profile_repository.py b/app/repository/profile_repository.py new file mode 100644 index 0000000..b1e990b --- /dev/null +++ b/app/repository/profile_repository.py @@ -0,0 +1,193 @@ +"""客户画像 L1 读写 + 会话归档持久化(jinrong_agent 库)。 + +铁律: +1. style_tags 写入必须走乐观锁(WHERE version=:expected_version,成功后 version+1); +2. L1 绝不存正式风险等级 C1~C5(槽位表无此字段,写入前由合并层保证); +3. 归档一会话一条(uk_session_id):先关会话(status=closed)再插归档, + 关闭失败(已关/不存在)则不重复归档。 +""" + +from __future__ import annotations + +import json +from typing import Any + +from sqlalchemy import text +from sqlalchemy.engine import Engine + +from app.config.database import get_agent_engine + + +class ProfileRepository: + def __init__(self, engine: Engine | None = None) -> None: + self._engine = engine or get_agent_engine() + + # --------------------------------------------------------------------------- + # customer_profile_l1 + # --------------------------------------------------------------------------- + + def read_l1(self, customer_id: str) -> dict[str, Any] | None: + """读取 style_tags JSON(dict),无记录返回 None。""" + sql = text("SELECT style_tags FROM customer_profile_l1 WHERE customer_id = :cid") + with self._engine.connect() as conn: + row = conn.execute(sql, {"cid": customer_id}).mappings().first() + if not row or row["style_tags"] is None: + return None + raw = row["style_tags"] + return json.loads(raw) if isinstance(raw, str) else dict(raw) + + def read_version(self, customer_id: str) -> int | None: + """读取当前乐观锁版本号;无记录返回 None。""" + sql = text("SELECT version FROM customer_profile_l1 WHERE customer_id = :cid") + with self._engine.connect() as conn: + row = conn.execute(sql, {"cid": customer_id}).mappings().first() + return int(row["version"]) if row else None + + def ensure_l1(self, customer_id: str) -> None: + sql = text( + "INSERT IGNORE INTO customer_profile_l1 (customer_id, updated_by) VALUES (:cid, 'customer_agent')" + ) + with self._engine.begin() as conn: + conn.execute(sql, {"cid": customer_id}) + + def update_style_tags(self, customer_id: str, style_tags: dict, expected_version: int) -> bool: + """乐观锁更新 style_tags,成功则 version+1。返回是否成功。""" + sql = text( + """ + UPDATE customer_profile_l1 + SET style_tags = :tags, version = version + 1, updated_by = 'customer_agent' + WHERE customer_id = :cid AND version = :expected_version + """ + ) + with self._engine.begin() as conn: + result = conn.execute( + sql, + {"tags": json.dumps(style_tags, ensure_ascii=False), "cid": customer_id, + "expected_version": expected_version}, + ) + return result.rowcount == 1 + + def update_behavior_tags(self, customer_id: str, tags: dict) -> None: + sql = text( + """ + UPDATE customer_profile_l1 + SET behavior_tags = :tags, version = version + 1 + WHERE customer_id = :cid + """ + ) + with self._engine.begin() as conn: + conn.execute( + sql, + {"tags": json.dumps(tags, ensure_ascii=False), "cid": customer_id}, + ) + + # --------------------------------------------------------------------------- + # 行为统计(behavior_tags 纯规则计数) + # --------------------------------------------------------------------------- + + def count_customer_sessions(self, customer_id: str) -> int: + sql = text( + "SELECT COUNT(*) AS n FROM agent_session WHERE agent_type = 'customer' AND customer_id = :cid" + ) + with self._engine.connect() as conn: + row = conn.execute(sql, {"cid": customer_id}).mappings().first() + return int(row["n"]) if row else 0 + + def count_customer_messages(self, customer_id: str) -> int: + sql = text( + """ + SELECT COUNT(*) AS n FROM agent_message m + JOIN agent_session s ON s.session_id = m.session_id + WHERE s.agent_type = 'customer' AND s.customer_id = :cid + """ + ) + with self._engine.connect() as conn: + row = conn.execute(sql, {"cid": customer_id}).mappings().first() + return int(row["n"]) if row else 0 + + # --------------------------------------------------------------------------- + # 会话消息(归档摘要 / 画像全量抽槽输入) + # --------------------------------------------------------------------------- + + def fetch_recent_messages(self, session_id: str, limit: int = 30) -> list[dict]: + sql = text( + """ + SELECT role, content FROM agent_message + WHERE session_id = :sid ORDER BY seq_no DESC LIMIT :limit + """ + ) + with self._engine.connect() as conn: + rows = conn.execute(sql, {"sid": session_id, "limit": limit}).mappings().all() + return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)] + + def count_session_messages(self, session_id: str) -> int: + sql = text("SELECT COUNT(*) AS n FROM agent_message WHERE session_id = :sid") + with self._engine.connect() as conn: + row = conn.execute(sql, {"sid": session_id}).mappings().first() + return int(row["n"]) if row else 0 + + # --------------------------------------------------------------------------- + # 会话归档(conversation_archive + agent_session 关闭) + # --------------------------------------------------------------------------- + + def close_session(self, session_id: str) -> bool: + """关闭会话(仅 active 可关,防止重复归档)。返回是否关闭成功。""" + sql = text( + """ + UPDATE agent_session + SET status = 'closed', closed_at = CURRENT_TIMESTAMP(3) + WHERE session_id = :sid AND status = 'active' + """ + ) + with self._engine.begin() as conn: + result = conn.execute(sql, {"sid": session_id}) + return result.rowcount == 1 + + def insert_archive( + self, + *, + session_id: str, + trace_id: str, + actor_id: str, + summary: str, + msg_count: int, + archive_reason: str, + ) -> None: + sql = text( + """ + INSERT INTO conversation_archive + (session_id, trace_id, agent_type, actor_id, summary, msg_count, archive_reason) + VALUES + (:sid, :trace_id, 'customer', :actor_id, :summary, :msg_count, :reason) + """ + ) + with self._engine.begin() as conn: + conn.execute( + sql, + {"sid": session_id, "trace_id": trace_id, "actor_id": actor_id, + "summary": summary, "msg_count": msg_count, "reason": archive_reason}, + ) + + def list_idle_customer_sessions(self, idle_minutes: int, limit: int, exclude_sid: str = "") -> list[dict]: + """懒扫描:空闲超时的客户会话(updated_at 距今超过 idle_minutes)。""" + sql = text( + """ + SELECT session_id, customer_id, actor_id FROM agent_session + WHERE agent_type = 'customer' AND status = 'active' + AND updated_at < DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL :mins MINUTE) + AND session_id != :exclude_sid + ORDER BY updated_at ASC + LIMIT :limit + """ + ) + with self._engine.connect() as conn: + rows = conn.execute( + sql, {"mins": idle_minutes, "exclude_sid": exclude_sid, "limit": limit} + ).mappings().all() + return [ + { + "session_id": r["session_id"], + "customer_id": r["customer_id"] or r["actor_id"], + } + for r in rows + ] diff --git a/app/service/customer_prompts.py b/app/service/customer_prompts.py new file mode 100644 index 0000000..95c9a76 --- /dev/null +++ b/app/service/customer_prompts.py @@ -0,0 +1,377 @@ +"""已注册客户 Agent(customer)的 DeepSeek prompt 模板与关键词快路由。 + +意图集 12 类:游客 7 类(product_consult/policy_interpret/faq/chit_chat/ +reject/transfer_human/fallback)+ 本人数据查询 4 类 +(holding_query/transaction_query/risk_assessment_query/suitability_check) ++ save_note(用户主动要求记忆某事,如"你要记住我每天看净值")。 +""" + +from __future__ import annotations + +from app.config.profile_slots import build_extraction_prompt + +# --------------------------------------------------------------------------- +# 意图分类 prompt +# --------------------------------------------------------------------------- + +INTENT_SYSTEM = """你是金融客服(已登录客户模式)意图分类器。将用户输入分为以下之一: +- holding_query:查询本人持仓(我持有/买了什么基金/持仓市值/盈亏情况/我的资产) +- transaction_query:查询本人交易流水(交易记录/申购赎回记录/最近买卖/账单/流水) +- risk_assessment_query:查询本人风险测评(我的风评/风险等级/测评结果/问卷得分/风险承受能力) +- suitability_check:本人购买适当性匹配(我能买XX吗/R几产品适合我吗/买这个匹配吗/适当性) +- product_consult:咨询具体产品知识(基金、理财、费用、风险等级含义、产品分类等非个人账户问题) +- policy_interpret:咨询政策法规(投资者适当性、反洗钱、KYC、销售合规、投诉处理、冷静期、双录) +- faq:常见问答(开户、身份认证、账户操作、App 使用等通用问题) +- chit_chat:问候、闲聊、非金融业务 +- save_note:用户主动要求记忆某事(如"你要记住我每天看净值"、"帮我记一下我不买私募") +- reject:客服无法处理且不需要转人工的请求(见下方规则) +- transfer_human:用户明确要求人工客服,或投诉/纠纷/安全问题 + +规则: +1. 只输出一个标签,不要任何解释或标点 +2. 若无法判断,输出 fallback +3. 涉及"推荐/建议买/卖什么好/包赚/保本/稳赚/零风险"等投资建议请求一律 reject +4. 涉及"明天会涨/收益预测/能赚多少/走势"等走势预测一律 reject +5. 涉及"和其他平台比/哪个平台好"等竞品对比一律 reject +6. 涉及实时行情/实时价格查询一律 reject +7. "能买/可以买/适合买/匹配吗"是适当性查询(suitability_check),不是投资建议 +8. 查询本人账户/持仓/流水/风评是已登录客户的合法功能,不要输出 reject +9. 仅当用户明确要求"转人工/人工客服",或涉及投诉/纠纷/账户异常/被盗等安全问题时,才输出 transfer_human +10. 用户明确要求"你要记住/帮我记/记一下/别忘了/记着"等记忆指令时,输出 save_note""" + +INTENT_USER_TEMPLATE = "用户输入:{message}" + +VALID_INTENTS = frozenset({ + "holding_query", + "transaction_query", + "risk_assessment_query", + "suitability_check", + "product_consult", + "policy_interpret", + "faq", + "chit_chat", + "save_note", + "reject", + "transfer_human", + "fallback", +}) + +# 数据查询类意图(需要调 core_ro_tool) +DATA_QUERY_INTENTS = frozenset({ + "holding_query", + "transaction_query", + "risk_assessment_query", + "suitability_check", +}) + + +# --------------------------------------------------------------------------- +# 关键词快速路由(不调 LLM;优先级:转人工 > 拒绝 > 数据查询) +# --------------------------------------------------------------------------- + +_TRANSFER_KEYWORDS = ( + "转人工", "人工客服", "人工服务", "联系客服", "找客服", + "投诉", "纠纷", "账户异常", "被盗", "盗刷", +) + +# 拒绝关键词:客服边界外(投顾/预测/竞品/实时行情),直接拒绝不转人工 +_REJECT_ADVICE_KW = ( + "推荐", "建议买", "建议卖", "买什么好", "卖什么好", "包赚", "保本", + "稳赚", "零风险", "无风险", "躺赚", "保本保息", "刚性兑付", "荐股", +) +_REJECT_PREDICT_KW = ( + "会涨", "会跌", "收益预测", "走势预测", "能赚多少", "收益率多少", + "涨跌", "明天行情", "后市", "还能涨", +) +_REJECT_COMPARE_KW = ( + "和其他平台", "哪个平台好", "对比一下", "比一比", "哪家银行好", "别家", +) +_REJECT_REALTIME_KW = ( + "实时行情", "实时价格", "现在价格", "当前价格", "最新行情", + "今日净值", "实时净值", "最新净值", +) + +# 数据查询关键词(本人数据) +_HOLDING_KW = ( + "我的持仓", "持仓", "持有", "买了什么", "买了啥", "买的什么", + "我的基金", "我的理财", "我的资产", "仓位", "市值", "盈亏", + "赚了多少", "亏了多少", "挣了多少", +) +_TRADE_KW = ( + "流水", "交易记录", "交易明细", "申购记录", "赎回记录", + "最近买", "最近卖", "账单", "买卖记录", "成交记录", + "申购了", "赎回了", "申购过", "赎回过", "申购啥", "买过啥", +) +_RISK_KW = ( + "我的风评", "风评", "风险测评", "风险评估", "风险等级", "风险承受能力", + "测评结果", "测评问卷", "问卷得分", "我是稳健", "我是保守", "C1", "C2", "C3", "C4", "C5", +) +_SUITABILITY_KW = ( + "能买", "可以买", "可买", "适合买", "适合我", "匹配吗", "能不能买", + "购买资格", "适当性", "买这个", "买r", "R1产品", "R2产品", "R3产品", "R4产品", "R5产品", +) + +# 备注(用户主动要求记忆) +_SAVE_NOTE_KW = ( + "你要记住", "帮我记", "记一下", "记住", "别忘了", "记着", + "帮我记住", "你要记", +) + + +def keyword_route(message: str) -> tuple[str, str] | None: + """关键词快速路由。返回 (intent, preset_reply);无命中返回 None。 + + preset_reply 非空时意图为 reject 且回复已确定。 + """ + msg = message or "" + + if any(k in msg for k in _TRANSFER_KEYWORDS): + return ("transfer_human", "") + + # 拒绝类(边界外请求) + if any(k in msg for k in _REJECT_ADVICE_KW): + return ("reject", "advice") + if any(k in msg for k in _REJECT_PREDICT_KW): + return ("reject", "predict") + if any(k in msg for k in _REJECT_COMPARE_KW): + return ("reject", "compare") + if any(k in msg for k in _REJECT_REALTIME_KW): + return ("reject", "realtime") + + # 数据查询类(本人数据) + if any(k in msg for k in _TRADE_KW): + return ("transaction_query", "") + if any(k in msg for k in _RISK_KW): + return ("risk_assessment_query", "") + if any(k in msg for k in _SUITABILITY_KW): + return ("suitability_check", "") + if any(k in msg for k in _HOLDING_KW): + return ("holding_query", "") + + # 备注(用户主动要求记忆,优先级低于数据查询) + if any(k in msg for k in _SAVE_NOTE_KW): + return ("save_note", "") + + return None + + +# --------------------------------------------------------------------------- +# 参数抽取 prompt(时间范围 / 产品名 / 风险等级) +# --------------------------------------------------------------------------- + +PARAM_EXTRACT_SYSTEM = """你是金融客服的查询参数抽取器。从用户输入抽取查询参数,输出严格 JSON。 + +可抽取字段: +- months:整数,时间范围(月)。"最近3个月"→3,"近半年/半年内"→6,"今年以来"→当前已过月数(至少1),"最近一周/几天"→1,未提及时间→null +- product_keyword:字符串,用户提到的具体产品名称片段(如"医药健康精选""稳利90天理财");仅风险等级(如 R3)时留 null +- risk_level:字符串,"R1"~"R5",仅当用户提到风险等级(如"R3产品""R几");未提及→null + +规则: +1. 只输出 JSON 对象,如 {"months": 3, "product_keyword": null, "risk_level": "R3"} +2. months 范围 1~36;超出按边界裁剪;无法判断时间一律 null +3. product_keyword 去掉"产品/基金/理财"等无区分度尾词前后的多余称呼,保留产品名核心片段,最长 32 字 +4. 用户说"我能买R3的吗"→risk_level="R3",product_keyword=null +5. 无法抽取任何参数时输出 {}""" + +PARAM_EXTRACT_USER_TEMPLATE = "用户输入:{message}" + + +# --------------------------------------------------------------------------- +# 工具结果解读 prompt(数值不经过 LLM 生成) +# --------------------------------------------------------------------------- + +INTERPRET_SYSTEM = """你是金融客服助手(已登录客户模式)。请基于系统查询返回的"事实数据",用自然、简明的中文回复客户。 + +铁律(违反将被拦截): +1. 事实数据中的所有数字、日期、产品名称、风险等级、产品数量必须原样引用,禁止修改、推算、补充或编造任何数值 +2. 事实数据未包含的信息,明确告知"系统中未查询到相关记录",不得猜测或用常识补全 +3. 你不是投资顾问、不是交易员:不做买卖建议、不预测走势、不推荐产品、不承诺收益 +4. 适当性匹配结果是系统只读判定:仅解释"匹配/不匹配/需风险揭示/测评过期/需网点确认"的规则原因, + 不得鼓励、催促或代替客户做出购买决定 +5. 回复不超过 400 字,不要输出 JSON、字段名或内部编码""" + +INTERPRET_USER_TEMPLATE = """事实数据(系统查询结果,数值以此为准): +{fact_text} + +客户备注: +{notes_context} + +近期对话: +{memory} + +用户问题:{message} + +请回复:""" + + +# --------------------------------------------------------------------------- +# RAG 咨询生成 prompt(客户版,含画像使用红线) +# --------------------------------------------------------------------------- + +GENERATE_SYSTEM = """你是金融客服助手(已登录客户模式),职责:基于知识库检索结果,回答用户关于产品、政策、FAQ 的事实性问题。 + +铁律(违反将被拦截): +1. 严禁投资建议、收益预测、买卖指导、保本承诺 +2. 仅基于下方"知识库"回答;知识库无答案则明确说明无法回答 +3. 不得编造产品代码、费率、到账时间等具体数值 +4. 简明扼要,中文,不超过 400 字 +5. "客户画像参考"仅用于理解客户语境、调整措辞: + - 禁止主动念出画像内容(不得复述客户年龄/收入/家庭情况等) + - 禁止依据画像推断客户未说出的隐私信息 + - 画像不改变任何产品规则、费率、适当性结论 +6. 涉及个人账户数据(持仓/流水/风评)的问题,告知客户可直接询问"我的持仓/我的风评"等""" + +GENERATE_USER_TEMPLATE = """知识库: +{rag_context} + +客户画像参考: +{profile_context} + +近期对话: +{memory} + +用户问题:{message} + +请回答:""" + + +# --------------------------------------------------------------------------- +# 闲聊 prompt(客户版) +# --------------------------------------------------------------------------- + +CHITCHAT_SYSTEM = """你是金融客服助手(已登录客户模式),正在与用户轻松闲聊。 + +规则: +1. 仅日常友好对话(问候、客服导航、营业时间、功能介绍等) +2. 严禁任何投资建议、产品推荐、收益预测、买卖指导 +3. 用户问及个人账户(持仓/流水/风评/能买什么),引导其直接提问,如"您可以问我'我的持仓'或'我能买R3产品吗'" +4. 用户需要投资建议时,引导其联系专属理财顾问或转人工 +5. 保持简短(不超过 100 字) +6. 「客户画像参考」仅用于理解语境、自然衔接话题,禁止主动念出或复述画像内容(城市/年龄段等)""" + +CHITCHAT_USER_TEMPLATE = """客户画像参考: +{profile_context} + +客户备注: +{notes_context} + +近期对话: +{memory} + +用户:{message} +客服:""" + + +# --------------------------------------------------------------------------- +# 画像抽槽 prompt(由 profile_slots 槽位表自动派生;输入为最近对话窗口) +# --------------------------------------------------------------------------- + +PROFILE_EXTRACT_SYSTEM = build_extraction_prompt() +PROFILE_EXTRACT_USER_TEMPLATE = """最近对话窗口: +{window} + +请从对话中抽取客户画像信息:""" + + +# --------------------------------------------------------------------------- +# 客户显式备注抽取 prompt(save_note 意图;从用户原话抽取纯净备注内容 + 可选分类) +# --------------------------------------------------------------------------- + +NOTE_EXTRACT_SYSTEM = """你是金融客服的备注内容抽取器。用户说"你要记住/帮我记/记一下"等指令,要求记忆某事。 +从用户消息中抽取要记住的纯净内容(剥掉"你要记住/帮我记"等指令词),并标注分类。 + +输出严格 JSON,形如: +{"content": "每天看基金净值", "category": "habit"} + +字段: +- content:剥掉指令词后的纯净备注内容,≤500 字,禁止为空 +- category:可选,取值 habit(行为习惯)/ preference(偏好)/ reminder(提醒)/ other(其他);无法判断时为 null + +规则: +1. 只输出一个 JSON 对象,不要任何解释 +2. content 必须是用户真正要记住的内容,不是指令本身("你要记住我每天看净值" → content="每天看基金净值") +3. 用户消息只是"记住/记一下"无具体内容时,content=null,此时返回 {"content": null, "category": null}""" + +NOTE_EXTRACT_USER_TEMPLATE = "用户消息:{message}\n请抽取要记住的内容:" + +# 备注写入成功后的固定回复 +NOTE_SAVED_TEXT = "好的,我已经记下来了。" +# 抽取失败(content 为空或 LLM 异常)时的兜底回复 +NOTE_EMPTY_TEXT = "您要记住的内容我没有完全理解,能否再具体描述一下?" + + +# --------------------------------------------------------------------------- +# 静态话术(客户视角) +# --------------------------------------------------------------------------- + +REJECT_ADVICE_TEXT = ( + "我是客服助手,负责事实查询和规则解读,不能提供投资建议、产品推荐或买卖指导," + "也不能预测收益。投资决策需要结合您的风险承受能力," + "建议您联系专属理财顾问,或回复「转人工」由客服人员为您服务。" +) + +REJECT_PREDICT_TEXT = ( + "市场走势和未来收益无法预测,我不能提供涨跌判断或收益预估。" + "我可以为您解读产品规则、查询您的持仓与风评信息。" +) + +REJECT_COMPARE_TEXT = ( + "我无法对其他平台或机构进行比较评价。" + "我可以为您介绍本行产品的规则、费用和风险等级,您也可以咨询专属理财顾问。" +) + +REJECT_REALTIME_TEXT = ( + "实时行情和实时价格不在客服查询范围内。" + "您可以在 App「行情」页面查看实时净值,或回复「转人工」咨询客服人员。" +) + +REJECT_BY_KIND = { + "advice": REJECT_ADVICE_TEXT, + "predict": REJECT_PREDICT_TEXT, + "compare": REJECT_COMPARE_TEXT, + "realtime": REJECT_REALTIME_TEXT, +} + +# DeepSeek 分类为 reject 但关键词路由未命中具体类别时的兜底话术 +REJECT_GENERAL_TEXT = ( + "抱歉,这个请求超出了客服助手的职责范围。" + "我可以为您查询持仓与流水、解读风评结果、说明产品适当性规则," + "或解答产品知识与常见问题。投资决策请咨询您的专属理财顾问。" +) + +TRANSFER_TEXT = ( + "已为您安排转接人工客服。您也可以:\n" + "- 拨打客服热线 95XXX(工作日 9:00-18:00)\n" + "- 前往最近的营业网点办理\n" + "- 联系您的专属理财顾问获取个性化投资服务" +) + +FALLBACK_TEXT = ( + "抱歉,我暂时无法回答这个问题。您可以:\n" + "1. 换一种方式描述您的问题\n" + "2. 直接问我「我的持仓」「我的风评」「我能买R3产品吗」等账户问题\n" + "3. 拨打客服热线 95XXX 或回复「转人工」" +) + +# 数据查询工具调用失败时的兜底 +DATA_ERROR_TEXT = "暂时无法查询您的数据,请稍后再试,或回复「转人工」联系客服人员。" + +# --------------------------------------------------------------------------- +# 会话归档摘要 prompt(CS-C-11) +# --------------------------------------------------------------------------- + +ARCHIVE_SUMMARY_SYSTEM = """你是金融客服的会话摘要生成器。根据会话内容生成一段简明摘要。 + +规则: +1. 摘要不超过 200 字,中文 +2. 概括:客户咨询/查询的主要主题、关注的产品或问题类型、情绪与满意度线索、是否建议人工跟进 +3. 只陈述对话中出现的事实,禁止编造或推测对话中没有的信息 +4. 禁止出现身份证号、手机号、银行卡号等敏感数字(如出现请以「已脱敏」代替) +5. 只输出摘要正文,不要任何前缀或解释""" + +ARCHIVE_SUMMARY_USER_TEMPLATE = """会话消息数:{count} + +会话内容: +{window} + +请生成摘要:""" diff --git a/app/service/customer_service.py b/app/service/customer_service.py new file mode 100644 index 0000000..40f02ed --- /dev/null +++ b/app/service/customer_service.py @@ -0,0 +1,555 @@ +"""已注册客户 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 +from typing import TypedDict + +from langgraph.graph import END, StateGraph + +from app.config.settings import settings +from app.model.schemas import AuthContext +from app.service.customer_prompts import ( + 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, + query_risk_profile, + query_suitability, + query_trades, +) +from app.utils.compliance_guard import RISK_DISCLAIMER, sanitize_reply, should_add_disclaimer + +# --------------------------------------------------------------------------- +# 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 + + +_TOOL_BY_INTENT = { + "holding_query": query_holdings, + "transaction_query": query_trades, + "risk_assessment_query": query_risk_profile, + "suitability_check": query_suitability, +} + +_R_LEVEL_FULL_RE = re.compile(r"[Rr]\s*([1-5])") + + +# --------------------------------------------------------------------------- +# 节点 1:读取短期记忆 + 画像热缓存 +# --------------------------------------------------------------------------- + +def recall_memory(state: CustomerState) -> CustomerState: + sid, cid = state["session_id"], state["customer_id"] + mem = CustomerMemoryService() + profile_context = "" + notes_context = "" + try: + if cid: + profile_context = render_profile_context(ProfileHotCache().get_style_tags(cid)) + 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"] + + 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"] + if intent not in ("transaction_query", "suitability_check"): + 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"), + ) + 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} + + 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 不可用时直接返回脱敏事实文本 + + reply, need_transfer = sanitize_reply(reply) + if need_transfer: + return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False} + return {"reply": reply, "has_disclaimer": False} + + +# --------------------------------------------------------------------------- +# 节点 7:RAG 生成(画像红线在 GENERATE_SYSTEM) +# --------------------------------------------------------------------------- + +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"} + + reply, need_transfer = sanitize_reply(reply) + 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: + return {"reply": FALLBACK_TEXT, "intent": "fallback"} + except Exception: + return {"reply": FALLBACK_TEXT, "intent": "fallback"} + + reply, need_transfer = sanitize_reply(reply) + 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 负责) +# --------------------------------------------------------------------------- + +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) + 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"] + 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, { + "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") + 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, + } + result = _GRAPH.invoke(state) + return ( + result["reply"], + result.get("has_disclaimer", False), + result.get("intent", "fallback"), + result.get("transfer_to_human", False), + ) diff --git a/app/service/memory_service.py b/app/service/memory_service.py index 0b1bdd1..e6fface 100644 --- a/app/service/memory_service.py +++ b/app/service/memory_service.py @@ -11,6 +11,8 @@ from __future__ import annotations import json import logging +import re +import time from app.config.settings import settings from app.repository.session_repository import SessionRepository @@ -58,3 +60,49 @@ def append_window( except Exception: # 窗口丢失可重建(下次读回源 MySQL),不阻塞对话主链路 logger.warning("session window append failed (degrade): %s", session_id, exc_info=True) + + +# --------------------------------------------------------------------------- +# 客服线 · 游客短期记忆(redis-keys §2.2 visitor:{session}:{kind}) +# --------------------------------------------------------------------------- + + +class VisitorMemoryService: + """游客短期记忆:闲聊和咨询分库存储。""" + + KINDS = ("chitchat", "consult") + + def __init__(self, redis_client=None) -> None: + self._redis = redis_client or get_gateway() + self._ttl = settings.visitor_session_ttl + self._max_rounds = { + "chitchat": settings.visitor_chitchat_max_rounds, + "consult": settings.visitor_consult_max_rounds, + } + + def key(self, session_id: str, kind: str) -> str: + safe_session = re.sub(r"[^A-Za-z0-9:_-]", "", session_id)[:64] + return f"visitor:{safe_session}:{kind}" + + def recall(self, session_id: str, kind: str) -> list[dict]: + k = self.key(session_id, kind) + raw_list = self._redis.lrange(k, 0, -1) + self._redis.expire(k, self._ttl) + return [json.loads(raw) for raw in raw_list] + + def append(self, session_id: str, kind: str, role: str, content: str) -> None: + k = self.key(session_id, kind) + msg = json.dumps({"role": role, "content": content, "ts": int(time.time())}, ensure_ascii=False) + self._redis.rpush(k, msg) + max_msgs = self._max_rounds[kind] * 2 + self._redis.ltrim(k, -max_msgs, -1) + self._redis.expire(k, self._ttl) + + def as_prompt_text(self, session_id: str, kind: str, max_pairs: int = 8) -> str: + messages = self.recall(session_id, kind) + recent = messages[-(max_pairs * 2) :] + lines: list[str] = [] + for msg in recent: + role = "用户" if msg["role"] == "user" else "客服" + lines.append(f"{role}: {msg['content']}") + return "\n".join(lines) diff --git a/app/service/note_service.py b/app/service/note_service.py new file mode 100644 index 0000000..3105880 --- /dev/null +++ b/app/service/note_service.py @@ -0,0 +1,122 @@ +"""客户显式备注服务(Wave 5)。 + +职责: +1. save_note_from_message:用户说"你要记住我每天看净值" → DeepSeek 抽取纯净 content+category → 写入 customer_notes 表; +2. render_notes_context:读最近 N 条 active 备注格式化为 prompt 注入文本; +3. forget_all_notes:用户说"忘掉我之前的备注" → 软删除全部备注。 + +独立于 L1 画像槽位表,不进 style_tags;注入到 chitchat 和 interpret 的 prompt。 + +铁律: +1. content 为 LLM 抽取后的纯净文本(剥掉"你要记住"指令词); +2. 抽取失败/LLM 异常 → 回落 NOTE_EMPTY_TEXT,不写库; +3. 软删除(is_active=0)保留审计痕。 +""" + +from __future__ import annotations + +from typing import Any + +from langchain_openai import ChatOpenAI + +from app.config.settings import settings +from app.repository.note_repository import CustomerNoteRepository +from app.service.customer_prompts import ( + NOTE_EXTRACT_SYSTEM, + NOTE_EXTRACT_USER_TEMPLATE, +) + + +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 save_note_from_message( + customer_id: str, + session_id: str, + trace_id: str, + message: str, +) -> tuple[str, str | None, str]: + """从用户消息抽取备注内容+分类并写库。 + + 返回 (content, category, reply_text): + - 成功:(content, category or None, NOTE_SAVED_TEXT) + - 失败:("", None, NOTE_EMPTY_TEXT) + """ + if not message or not message.strip(): + return "", None, "您要记住的内容我没有完全理解,能否再具体描述一下?" + + try: + from app.service.profile_service import parse_llm_json + + llm = _build_llm() + resp = llm.invoke([ + {"role": "system", "content": NOTE_EXTRACT_SYSTEM}, + {"role": "user", "content": NOTE_EXTRACT_USER_TEMPLATE.format(message=message)}, + ]) + text = resp.content if not isinstance(resp, str) else resp + data = parse_llm_json(text) or {} + except Exception: + return "", None, "您要记住的内容我没有完全理解,能否再具体描述一下?" + + content = str(data.get("content") or "").strip() + if not content: + return "", None, "您要记住的内容我没有完全理解,能否再具体描述一下?" + + content = content[: settings.note_content_max_len] + category = str(data.get("category") or "").strip().lower() or None + if category not in ("habit", "preference", "reminder", "other"): + category = None + + source = (message or "")[:800] + repo = CustomerNoteRepository() + repo.insert_note( + customer_id=customer_id, + session_id=session_id, + trace_id=trace_id, + content=content, + category=category, + source_text=source, + ) + return content, category, "好的,我已经记下来了。" + + +def render_notes_context(customer_id: str) -> str: + """读最近 N 条 active 备注格式化为 prompt 注入文本。 + + 空备注返回空串(prompt 模板里 notes_context 字段为空)。 + """ + if not customer_id: + return "" + try: + repo = CustomerNoteRepository() + notes = repo.list_active_notes(customer_id, settings.note_max_inject) + except Exception: + return "" + if not notes: + return "" + lines: list[str] = [] + for n in notes: + c = str(n.get("content") or "").strip() + if not c: + continue + cat = str(n.get("category") or "").strip() + lines.append(f"[{cat}] {c}" if cat else c) + return ";".join(lines) + + +def forget_all_notes(customer_id: str) -> int: + """软删除客户所有 active 备注,返回删除条数。""" + if not customer_id: + return 0 + try: + repo = CustomerNoteRepository() + return repo.deactivate_all(customer_id) + except Exception: + return 0 diff --git a/app/service/profile_service.py b/app/service/profile_service.py new file mode 100644 index 0000000..1d8dba6 --- /dev/null +++ b/app/service/profile_service.py @@ -0,0 +1,487 @@ +"""客户画像抽槽与对话归档服务(CS Wave 3)。 + +职责(方案 §6 / §8): +1. CustomerMemoryService:客户双线短期记忆(Redis,key 前缀 customer:,TTL 2h); +2. ProfileHotCache:L1 画像热缓存(profile:l1:{cid},TTL 10min,MySQL UPDATE 后 DEL); +3. merge_candidates:纯代码规则合并(槽位表唯一来源 + normalize_value 把关 + D7 铁律); +4. extract_profile:DeepSeek 抽槽 → 合并 → 落库三连(MySQL 乐观锁 / Redis DEL / audit); +5. archive_session:会话归档(摘要写 conversation_archive + 关闭会话 + 触发画像全量抽槽)。 + +抽槽在后台线程执行(daemon),失败不影响对话主流程。 +""" + +from __future__ import annotations + +import json +import re +import threading +import time +from copy import deepcopy +from datetime import datetime +from typing import Any + +from langchain_openai import ChatOpenAI + +from app.config.profile_slots import SLOT_BY_PATH, SLOT_PATHS, normalize_value +from app.config.settings import settings +from app.repository.audit_repository import AuditRepository +from app.repository.profile_repository import ProfileRepository +from app.service.customer_prompts import ( + ARCHIVE_SUMMARY_SYSTEM, + ARCHIVE_SUMMARY_USER_TEMPLATE, + PROFILE_EXTRACT_SYSTEM, + PROFILE_EXTRACT_USER_TEMPLATE, +) + +_CONSULT_INTENTS = frozenset({ + "product_consult", "policy_interpret", "faq", + "holding_query", "transaction_query", "risk_assessment_query", "suitability_check", +}) + + +# --------------------------------------------------------------------------- +# DeepSeek LLM 客户端 + JSON 解析(对齐 visitor_service 风格) +# --------------------------------------------------------------------------- + +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, + ) + + +_JSON_RE = re.compile(r"\{.*\}", re.S) + + +def parse_llm_json(content: str) -> dict | None: + """从 LLM 输出中提取第一个 JSON 对象;失败返回 None。""" + if not content: + return None + text = content.strip() + if text.startswith("```"): + text = re.sub(r"^```[a-zA-Z]*\s*", "", text).rstrip("`").strip() + m = _JSON_RE.search(text) + if not m: + return None + try: + data = json.loads(m.group(0)) + except (json.JSONDecodeError, ValueError): + return None + return data if isinstance(data, dict) else None + + +def _spawn(fn, *args) -> None: + """后台线程执行(daemon,失败不影响对话主流程)。""" + threading.Thread(target=_safe_run, args=(fn, *args), daemon=True).start() + + +def _safe_run(fn, *args) -> None: + try: + fn(*args) + except Exception: # noqa: BLE001 后台任务吞异常,不影响主流程 + pass + + +# --------------------------------------------------------------------------- +# 客户双线短期记忆(复用游客模式,key 前缀 customer:) +# --------------------------------------------------------------------------- + +class CustomerMemoryService: + """客户短期记忆:闲聊和咨询分线存储,各自独立 TTL 和轮数上限。""" + + KINDS = ("chitchat", "consult") + + def __init__(self, redis_client: Any | None = None) -> None: + self._redis = redis_client + if self._redis is None: + from app.config.database import get_redis_client + self._redis = get_redis_client() + self._ttl = settings.customer_session_ttl + self._max_rounds = { + "chitchat": settings.customer_chitchat_max_rounds, + "consult": settings.customer_consult_max_rounds, + } + + def key(self, session_id: str, kind: str) -> str: + return f"customer:{session_id}:{kind}" + + def recall(self, session_id: str, kind: str) -> list[dict]: + k = self.key(session_id, kind) + raw_list = self._redis.lrange(k, 0, -1) + self._redis.expire(k, self._ttl) + return [json.loads(raw) for raw in raw_list] + + def append(self, session_id: str, kind: str, role: str, content: str) -> None: + k = self.key(session_id, kind) + msg = json.dumps({"role": role, "content": content, "ts": int(time.time())}, ensure_ascii=False) + self._redis.rpush(k, msg) + max_msgs = self._max_rounds[kind] * 2 + self._redis.ltrim(k, -max_msgs, -1) + self._redis.expire(k, self._ttl) + + def as_prompt_text(self, session_id: str, kind: str, max_pairs: int = 8) -> str: + messages = self.recall(session_id, kind) + recent = messages[-(max_pairs * 2):] + lines = [] + for msg in recent: + role = "用户" if msg["role"] == "user" else "客服" + lines.append(f"{role}: {msg['content']}") + return "\n".join(lines) + + def recall_window(self, session_id: str, max_msgs: int | None = None) -> str: + """合并双线记忆、按时间排序取最近 N 条(画像抽槽输入窗口,客服回复也在窗口内)。""" + limit = max_msgs or settings.profile_window_max_msgs + merged = self.recall(session_id, "chitchat") + self.recall(session_id, "consult") + merged.sort(key=lambda m: m.get("ts", 0)) + recent = merged[-limit:] + lines = [] + for msg in recent: + role = "用户" if msg["role"] == "user" else "客服" + lines.append(f"{role}: {msg['content']}") + return "\n".join(lines) + + +def memory_kind_for(intent: str) -> str: + """RAG 3 类 + 数据查询 4 类 → consult 线;其余 → chitchat 线。""" + return "consult" if intent in _CONSULT_INTENTS else "chitchat" + + +# --------------------------------------------------------------------------- +# L1 画像热缓存 + prompt 注入降维 +# --------------------------------------------------------------------------- + +class ProfileHotCache: + def __init__(self, redis_client: Any | None = None, repo: ProfileRepository | None = None) -> None: + self._redis = redis_client + if self._redis is None: + from app.config.database import get_redis_client + self._redis = get_redis_client() + self._repo = repo or ProfileRepository() + + @staticmethod + def _key(customer_id: str) -> str: + return f"profile:l1:{customer_id}" + + def get_style_tags(self, customer_id: str) -> dict: + """热缓存 → miss 回源 MySQL 并回填。""" + k = self._key(customer_id) + try: + raw = self._redis.get(k) + except Exception: + raw = None + if raw: + try: + return json.loads(raw) + except (json.JSONDecodeError, ValueError): + pass + tags = self._repo.read_l1(customer_id) or {} + if tags: + try: + self._redis.setex(k, settings.profile_l1_cache_ttl_seconds, json.dumps(tags, ensure_ascii=False)) + except Exception: + pass + return tags + + def invalidate(self, customer_id: str) -> None: + try: + self._redis.delete(self._key(customer_id)) + except Exception: + pass + + +def _get_entry(tags: dict, path: str) -> dict | None: + parts = path.split(".") + if len(parts) == 2: + sub = tags.get(parts[0]) + if not isinstance(sub, dict): + return None + entry = sub.get(parts[1]) + else: + entry = tags.get(path) + return entry if isinstance(entry, dict) and "value" in entry else None + + +def _set_entry(tags: dict, path: str, entry: dict) -> None: + parts = path.split(".") + if len(parts) == 2: + tags.setdefault(parts[0], {})[parts[1]] = entry + else: + tags[path] = entry + + +def render_profile_context(tags: dict) -> str: + """带元数据结构 → 纯值文本(注入 generate/chitchat prompt 的客户画像参考)。""" + if not tags: + return "" + lines: list[str] = [] + for path in SLOT_PATHS: + entry = _get_entry(tags, path) + if not entry: + continue + value = entry.get("value") + if isinstance(value, list): + value = "、".join(str(v) for v in value) + if value in (None, ""): + continue + name = SLOT_BY_PATH[path]["name"] + lines.append(f"{name}:{value}") + return ";".join(lines) + + +# --------------------------------------------------------------------------- +# 规则合并(纯代码,方案 §6.3 ⑤) +# --------------------------------------------------------------------------- + +def merge_candidates(old_tags: dict, candidates: list[dict]) -> tuple[dict, list[str]]: + """候选合并:a.path 合法 → b.normalize_value 归一 → c.confidence 阈值 + → d.高敏 inferred 丢弃 + D7(现值 user_declared 不被 inferred 覆盖) + → e.latest 覆盖 / set_union 并集。 + + 返回 (新 tags, diff 行列表)。diff 为空表示无有效更新。 + """ + new_tags = deepcopy(old_tags or {}) + diff: list[str] = [] + + for c in candidates: + if not isinstance(c, dict): + continue + path = c.get("path") + slot = SLOT_BY_PATH.get(str(path or "")) + if not slot: + continue + + source = c.get("source") + if source not in ("user_declared", "inferred"): + continue + + try: + confidence = float(c.get("confidence") or 0) + except (TypeError, ValueError): + continue + threshold = ( + settings.profile_confidence_high + if slot["sensitivity"] == "high" + else settings.profile_confidence_default + ) + if confidence < threshold: + continue + + # 高敏槽位只接受客户明确陈述(方案 §6.4:inferred 收入段一律丢弃) + if slot["sensitivity"] == "high" and source == "inferred": + continue + + raw = c.get("value") + if raw is None: + continue + if slot["value_format"] == "list": + if isinstance(raw, list): + raw_text = "、".join(str(v) for v in raw) + elif isinstance(raw, str): + raw_text = raw + else: + continue + else: + if isinstance(raw, list): + continue + raw_text = str(raw).strip() + + normalized = normalize_value(str(path), raw_text) + if normalized is None: + continue + + old_entry = _get_entry(new_tags, str(path)) + # D7:用户显式修正 > LLM 抽取 + if old_entry and old_entry.get("source") == "user_declared" and source == "inferred": + continue + + old_display = old_entry.get("value") if old_entry else None + + if slot["merge_mode"] == "set_union": + base = list(old_entry["value"]) if old_entry and isinstance(old_entry.get("value"), list) else [] + additions = normalized if isinstance(normalized, list) else [normalized] + merged = base + [v for v in additions if v not in base] + value = merged + else: + value = normalized + + _set_entry( + new_tags, str(path), + {"value": value, "source": source, "confidence": round(confidence, 2)}, + ) + diff.append( + f"{path}: {'(空)' if old_display in (None, '', []) else old_display} → {value}" + ) + + return new_tags, diff + + +# --------------------------------------------------------------------------- +# 行为标签(纯规则计数,不走 LLM) +# --------------------------------------------------------------------------- + +_INTENT_HASH_PREFIX = "customer:{cid}:behavior:intents" + + +def record_intent(customer_id: str, intent: str) -> None: + """每轮对话累计意图计数(Redis hash,随抽槽一并落 behavior_tags)。""" + try: + from app.config.database import get_redis_client + r = get_redis_client() + key = _INTENT_HASH_PREFIX.replace("{cid}", customer_id) + r.hincrby(key, intent or "unknown", 1) + r.expire(key, settings.customer_session_ttl) + except Exception: + pass + + +def refresh_behavior_tags(customer_id: str) -> dict | None: + """从 agent_session/agent_message 计数 + Redis 意图计数刷新 behavior_tags。""" + repo = ProfileRepository() + intent_counts: dict[str, int] = {} + try: + from app.config.database import get_redis_client + raw = get_redis_client().hgetall(_INTENT_HASH_PREFIX.replace("{cid}", customer_id)) + intent_counts = {str(k): int(v) for k, v in (raw or {}).items()} + except Exception: + pass + tags = { + "total_sessions": repo.count_customer_sessions(customer_id), + "total_msgs": repo.count_customer_messages(customer_id), + "intent_counts": intent_counts, + "updated_at": datetime.now().isoformat(timespec="seconds"), + } + repo.update_behavior_tags(customer_id, tags) + return tags + + +# --------------------------------------------------------------------------- +# 画像抽槽(DeepSeek → 合并 → 落库三连) +# --------------------------------------------------------------------------- + +def extract_profile(customer_id: str, window_text: str, trace_id: str = "") -> list[str]: + """对对话窗口执行画像抽槽。返回 diff 行列表(空表示无更新或失败)。 + + 后台线程调用;LLM/网络异常一律吞掉返回 [],不影响对话。 + """ + if not customer_id or not window_text or not window_text.strip(): + return [] + + try: + llm = _build_llm() + resp = llm.invoke([ + {"role": "system", "content": PROFILE_EXTRACT_SYSTEM}, + {"role": "user", "content": PROFILE_EXTRACT_USER_TEMPLATE.format(window=window_text)}, + ]) + data = parse_llm_json(getattr(resp, "content", "") if not isinstance(resp, str) else resp) + except Exception: + return [] + if not data: + return [] + + candidates = data.get("updates") + if not isinstance(candidates, list): + return [] + + repo = ProfileRepository() + hot = ProfileHotCache(repo=repo) + old_tags = hot.get_style_tags(customer_id) + new_tags, diff = merge_candidates(old_tags, candidates) + if not diff: + return [] + + # 落库三连:MySQL 乐观锁(失败重试 1 次)→ Redis DEL → audit_log + repo.ensure_l1(customer_id) + for _ in range(2): + version = repo.read_version(customer_id) + if version is None: + repo.ensure_l1(customer_id) + continue + if repo.update_style_tags(customer_id, new_tags, version): + hot.invalidate(customer_id) + AuditRepository().insert( + trace_id=trace_id or "profile-extract", + event_type="profile_updated", + agent_type="customer", + actor_id=customer_id, + customer_id=customer_id, + input_summary={"diff": diff, "version": version + 1}, + decision="success", + ) + return diff + return [] + + +# --------------------------------------------------------------------------- +# 会话归档(CS-C-11,决策 D5/D6) +# --------------------------------------------------------------------------- + +def _format_messages(msgs: list[dict]) -> str: + lines = [] + for m in msgs: + role = "用户" if m.get("role") == "user" else "客服" + content = str(m.get("content", ""))[:500] + lines.append(f"{role}: {content}") + return "\n".join(lines) + + +def archive_session(session_id: str, customer_id: str, trace_id: str, reason: str) -> bool: + """归档单个会话:关会话 → 摘要 → 写 archive → 触发画像全量抽槽。 + + close_session 失败(已关/不存在)直接返回 False,保证 uk_session_id 不冲突。 + """ + if not session_id or not customer_id: + return False + repo = ProfileRepository() + if not repo.close_session(session_id): + return False + + # 异步归档与本轮 assistant 消息落库存在竞态,稍候再读 + time.sleep(1.0) + + msg_count = repo.count_session_messages(session_id) + msgs = repo.fetch_recent_messages(session_id, settings.archive_summary_max_msgs) + window = _format_messages(msgs) + + summary = f"会话已结束,共 {msg_count} 条消息。" + try: + llm = _build_llm() + resp = llm.invoke([ + {"role": "system", "content": ARCHIVE_SUMMARY_SYSTEM}, + {"role": "user", "content": ARCHIVE_SUMMARY_USER_TEMPLATE.format( + count=msg_count, window=window)}, + ]) + text = (resp.content if not isinstance(resp, str) else resp).strip() + if text: + summary = text + except Exception: + pass + + repo.insert_archive( + session_id=session_id, + trace_id=trace_id or "archive", + actor_id=customer_id, + summary=summary, + msg_count=msg_count, + archive_reason=reason if reason in ("timeout", "explicit", "manual") else "manual", + ) + + # 归档触发画像全量抽槽(决策 D6 第二时机),失败不影响归档结果 + _safe_run(extract_profile, customer_id, window, trace_id) + _safe_run(refresh_behavior_tags, customer_id) + return True + + +def archive_idle_sessions(trace_id: str, exclude_sid: str = "", limit: int | None = None) -> list[str]: + """懒扫描:归档所有空闲超时的客户会话(每轮对话至多 limit 条)。""" + repo = ProfileRepository() + sessions = repo.list_idle_customer_sessions( + settings.archive_idle_minutes, limit or settings.archive_idle_scan_limit, exclude_sid + ) + done: list[str] = [] + for s in sessions: + if archive_session(s["session_id"], s["customer_id"], trace_id, "timeout"): + done.append(s["session_id"]) + return done diff --git a/app/service/rag_service.py b/app/service/rag_service.py index a575946..59290b5 100644 --- a/app/service/rag_service.py +++ b/app/service/rag_service.py @@ -75,3 +75,47 @@ def search_knowledge( finally: client.close() return {"query": query.strip(), "results": results, "source_refs": _source_refs(results)} + + +# --------------------------------------------------------------------------- +# 客服线 · 游客/客户 RAG 接缝(映射 T-21 search_knowledge,不另建 collection) +# --------------------------------------------------------------------------- + +_VISITOR_INTENT_DOC_TYPE: dict[str, str | None] = { + "product_consult": None, + "policy_interpret": "policy", + "faq": "faq", +} + + +class VisitorRagService: + """游客/客户 RAG:intent → search_knowledge → 上下文 + 溯源。""" + + def retrieve(self, intent: str, query: str, top_k: int = 5) -> tuple[str, list[dict]]: + if intent not in _VISITOR_INTENT_DOC_TYPE: + return "", [] + try: + payload = search_knowledge( + query, + doc_type=_VISITOR_INTENT_DOC_TYPE[intent], + top_k=top_k, + ) + except Exception: + return "", [] + hits = payload.get("results") or [] + if not hits: + return "", [] + context_parts: list[str] = [] + sources: list[dict] = [] + for i, hit in enumerate(hits, 1): + chunk_text = hit.get("chunk_text") or hit.get("text") or "" + source_doc = hit.get("source_doc_id") or hit.get("source_doc") or "" + context_parts.append(f"[来源: {source_doc} | 片段 {i}]\n{chunk_text}") + sources.append( + { + "source_doc": source_doc, + "chunk_no": hit.get("chunk_no", i), + "score": round(float(hit.get("score", 0.0)), 4), + } + ) + return "\n\n".join(context_parts), sources diff --git a/app/service/risk/chat_tools.py b/app/service/risk/chat_tools.py index 2ec6b14..af674cd 100644 --- a/app/service/risk/chat_tools.py +++ b/app/service/risk/chat_tools.py @@ -29,6 +29,7 @@ from app.config.settings import settings from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository from app.service import suitability +from app.service.risk.profile_l3 import get_profile_l3 from app.tool.core_tools import _jsonable from app.utils.desensitize import mask_name @@ -107,7 +108,7 @@ def customer_context(customer_id: str, core_ro: CoreReadOnlyRepository | None = l0 = ro.get_customer_l0(customer_id) if l0 is None: return {"found": False, "customer_id": customer_id} - l3 = repo.get_l3(customer_id) + l3 = get_profile_l3(customer_id, risk_repo=repo) pending, pending_total = repo.list_alerts( customer_id=customer_id, status="pending_review", page_size=20 ) diff --git a/app/service/risk/profile_l3.py b/app/service/risk/profile_l3.py index 875d85c..0a029e1 100644 --- a/app/service/risk/profile_l3.py +++ b/app/service/risk/profile_l3.py @@ -19,12 +19,14 @@ Redis SET NX,接口不变);锁超时降级与跨进程竞态由乐观锁 from __future__ import annotations +import json import logging from datetime import datetime from typing import Any from sqlalchemy.exc import IntegrityError +from app.config.settings import settings from app.repository.risk_repository import RiskRepository from app.service.risk import redis_gateway from app.service.risk.locks import run_locked @@ -32,7 +34,8 @@ from app.service.risk.locks import run_locked logger = logging.getLogger(__name__) TIER_ORDER = ("normal", "watch", "high") -L3_CACHE_KEY = "profile:l3:{customer_id}" # redis-keys 手册:String(JSON) · 5m · l3 UPDATE 时 DEL +L3_CACHE_KEY = "profile:l3:{customer_id}" # redis-keys 手册:String(JSON) · 5m · read-aside + write DEL +L3_CACHE_TTL_SECONDS = settings.profile_l3_cache_ttl_seconds ALERT_TYPE_TIER = { "aml": "high", "pattern": "watch", @@ -109,9 +112,8 @@ def upsert_profile_l3( 不阻塞业务)。 已知窗口(B7 自查留痕):DEL 在锁外执行,存在 cache-aside 经典竞态 - (读方 miss 读旧值 → 写方 DEL → 读方回填旧值),TTL 5 分钟兜底。一期 - 无读路径写缓存(get_profile_l3 直读 MySQL),窗口无实际影响;**对话线 - 接入 Redis 热读缓存时须改延迟双删或写后比对**。 + (读方 miss 读旧值 → 写方 DEL → 读方回填旧值),TTL 兜底。高并发下 + 可改延迟双删;一期 Demo 可接受。 """ repo = risk_repo or RiskRepository() mapped_tier = tier_of(alert_type) @@ -157,6 +159,34 @@ def upsert_profile_l3( return merged +def _serialize_l3(row: dict[str, Any]) -> str: + """L3 行 → Redis JSON(datetime → isoformat)。""" + payload = dict(row) + computed_at = payload.get("computed_at") + if isinstance(computed_at, datetime): + payload["computed_at"] = computed_at.isoformat(sep=" ", timespec="milliseconds") + return json.dumps(payload, ensure_ascii=False) + + +def _deserialize_l3(raw: str) -> dict[str, Any] | None: + """Redis JSON → L3 行;损坏则 miss。""" + try: + data = json.loads(raw) + except (TypeError, ValueError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + def get_profile_l3(customer_id: str, risk_repo: RiskRepository | None = None) -> dict[str, Any] | None: - """L3 只读薄封装(对话线/引擎复用;Redis 热读缓存归对话线接入时实现)。""" - return (risk_repo or RiskRepository()).get_l3(customer_id) + """L3 只读:Redis cache-aside(miss 回源 MySQL 并 SET EX;upsert 写侧 DEL 失效)。""" + repo = risk_repo or RiskRepository() + cache_key = L3_CACHE_KEY.format(customer_id=customer_id) + cached = redis_gateway.cache_get(cache_key) + if cached is not None: + row = _deserialize_l3(cached) + if row is not None: + return row + row = repo.get_l3(customer_id) + if row is not None: + redis_gateway.cache_set(cache_key, _serialize_l3(row), L3_CACHE_TTL_SECONDS) + return row diff --git a/app/service/risk/redis_gateway.py b/app/service/risk/redis_gateway.py index 91f1bed..3315ef6 100644 --- a/app/service/risk/redis_gateway.py +++ b/app/service/risk/redis_gateway.py @@ -1,8 +1,9 @@ -"""Redis 单例网关(B7 lifespan 管理连接):预警广播 publish + L3 缓存失效 DEL。 +"""Redis 单例网关(B7 lifespan 管理连接):预警广播 publish + 画像 L3 cache-aside GET/SET/DEL。 惰性连接(首次 publish/DEL 才建);连接/执行失败一律降级日志,不阻塞业务 (DB 为权威,PRD FR-4 通知语义)。测试注入 fake:monkeypatch 本模块 -`_gateway`,实现 publish(channel, payload) / delete(*keys) 即可。 +`_gateway`,实现 publish(channel, payload) / delete(*keys) / get(key) / +set_ex(key, value, ttl) 即可。 """ from __future__ import annotations @@ -37,6 +38,12 @@ class RedisGateway: def exists(self, key: str) -> bool: return bool(self._ensure().exists(key)) + def get(self, key: str) -> str | None: + val = self._ensure().get(key) + if val is None: + return None + return str(val) + def set_ex(self, key: str, value: str, ttl_seconds: int) -> None: self._ensure().setex(key, ttl_seconds, value) @@ -94,6 +101,23 @@ def cache_delete(*keys: str) -> None: logger.warning("cache DEL failed (degrade to TTL): %s", keys, exc_info=True) +def cache_get(key: str) -> str | None: + """读侧 cache-aside GET;失败降级为 miss(回源 MySQL)。""" + try: + return get_gateway().get(key) + except Exception: + logger.warning("cache GET failed (degrade to DB): %s", key, exc_info=True) + return None + + +def cache_set(key: str, value: str, ttl_seconds: int) -> None: + """读侧 cache-aside 回填 SET EX;失败降级(不影响 DB 权威读)。""" + try: + get_gateway().set_ex(key, value, ttl_seconds) + except Exception: + logger.warning("cache SET failed (degrade, next read hits DB): %s", key, exc_info=True) + + def key_exists(key: str) -> bool: """存在性检查(T-01 jti 吊销黑名单);失败 fail-open 返回 False。 diff --git a/app/service/visitor_prompts.py b/app/service/visitor_prompts.py new file mode 100644 index 0000000..dec8552 --- /dev/null +++ b/app/service/visitor_prompts.py @@ -0,0 +1,91 @@ +"""游客 Agent 的 DeepSeek prompt 模板。""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# 意图分类 prompt +# --------------------------------------------------------------------------- + +INTENT_SYSTEM = """你是金融客服意图分类器。将用户输入分为以下之一: +- product_consult:咨询具体产品(基金、理财、申购赎回、费用、风险等级、产品分类) +- policy_interpret:咨询政策法规(投资者适当性、反洗钱、KYC、销售合规、投诉处理、冷静期、双录) +- faq:常见问答(开户、身份认证、风险评估问卷、账户操作等通用问题) +- chit_chat:问候、闲聊、非金融业务 +- reject:游客无法处理且不需要转人工的请求(见下方规则) +- transfer_human:用户明确要求人工客服,或投诉/纠纷/安全问题 + +规则: +1. 只输出一个标签,不要任何解释或标点 +2. 若无法判断,输出 fallback +3. 涉及"推荐/建议买/包赚/保本/稳赚/零风险"等投资建议请求一律 reject +4. 涉及查询"我的持仓/我的账户/我的风评"等个人数据一律 reject +5. 涉及"明天会涨/收益预测/能赚多少"等走势预测一律 reject +6. 涉及"和其他平台比/哪个好"等竞品对比一律 reject +7. 涉及实时行情/实时价格查询一律 reject +8. 仅当用户明确要求"转人工/人工客服",或涉及投诉/纠纷/账户异常/被盗等安全问题时,才输出 transfer_human""" + +INTENT_USER_TEMPLATE = "用户输入:{message}" + + +# --------------------------------------------------------------------------- +# RAG 生成 prompt +# --------------------------------------------------------------------------- + +GENERATE_SYSTEM = """你是金融客服助手(游客模式),职责:基于知识库检索结果,回答用户关于产品、政策、FAQ的事实性问题。 + +铁律(违反将被拦截): +1. 严禁投资建议、收益预测、买卖指导、保本承诺 +2. 仅基于下方"知识库"回答;知识库无答案则明确说明无法回答 +3. 不得编造产品代码、费率、到账时间等具体数值 +4. 简明扼要,中文,不超过 400 字""" + +GENERATE_USER_TEMPLATE = """知识库: +{rag_context} + +近期对话: +{memory} + +用户问题:{message} + +请回答:""" + + +# --------------------------------------------------------------------------- +# 闲聊 prompt +# --------------------------------------------------------------------------- + +CHITCHAT_SYSTEM = """你是金融客服助手(游客模式),正在与用户轻松闲聊。 + +规则: +1. 仅日常友好对话(问候、天气、客服导航、营业时间等) +2. 严禁任何投资建议、产品推荐、收益预测、买卖指导 +3. 用户一旦问及投资/产品/账户,引导其"转接人工客服"或"登录后咨询理财顾问" +4. 保持简短(不超过 100 字)""" + +CHITCHAT_USER_TEMPLATE = """近期闲聊: +{memory} + +用户:{message} +客服:""" + + +# --------------------------------------------------------------------------- +# 静态话术(与 compliance_guard 重复定义以解耦) +# --------------------------------------------------------------------------- + +TRANSFER_TEXT = ( + "已为您安排转接人工客服。您也可以:\n" + "- 拨打客服热线 95XXX(工作日 9:00-18:00)\n" + "- 前往最近的营业网点办理\n\n" + "如需查询个人账户、持仓信息或获取个性化投资建议," + "请登录后联系您的专属理财顾问。\n" + "(游客模式下我无法访问任何账户数据,也无法提供投资建议,敬请谅解。)" +) + +FALLBACK_TEXT = ( + "抱歉,我暂时无法回答这个问题。您可以:\n" + "1. 换一种方式描述您的问题\n" + "2. 浏览常见问答帮助中心\n" + "3. 拨打客服热线 95XXX\n" + '4. 回复"转人工"联系客服人员' +) diff --git a/app/service/visitor_service.py b/app/service/visitor_service.py new file mode 100644 index 0000000..e562800 --- /dev/null +++ b/app/service/visitor_service.py @@ -0,0 +1,359 @@ +"""游客 Agent 编排:LangGraph 9 节点 + DeepSeek LLM。""" + +from __future__ import annotations + +from typing import TypedDict + +from langgraph.graph import END, StateGraph +from langchain_openai import ChatOpenAI + +from app.config.settings import settings +from app.model.schemas import AuthContext, IntentType +from app.service.memory_service import VisitorMemoryService +from app.service.rag_service import VisitorRagService +from app.service.visitor_prompts import ( + CHITCHAT_SYSTEM, + CHITCHAT_USER_TEMPLATE, + FALLBACK_TEXT, + GENERATE_SYSTEM, + GENERATE_USER_TEMPLATE, + INTENT_SYSTEM, + INTENT_USER_TEMPLATE, + TRANSFER_TEXT, +) +from app.utils.compliance_guard import ( + REJECT_ACCOUNT, + REJECT_ADVICE, + REJECT_COMPARE, + REJECT_GENERAL, + REJECT_PREDICT, + REJECT_REALTIME, + RISK_DISCLAIMER, + sanitize_reply, + should_add_disclaimer, +) + + +# --------------------------------------------------------------------------- +# DeepSeek LLM 客户端 +# --------------------------------------------------------------------------- + +def _build_llm() -> ChatOpenAI: + """构建 DeepSeek LLM 客户端(OpenAI 兼容)。""" + 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, + ) + + +# --------------------------------------------------------------------------- +# LangGraph State +# --------------------------------------------------------------------------- + +class VisitorState(TypedDict): + session_id: str + trace_id: str + message: str + intent: str + chitchat_memory: list[dict] + consult_memory: list[dict] + rag_context: str + rag_sources: list[dict] + reply: str + has_disclaimer: bool + transfer_to_human: bool + + +# --------------------------------------------------------------------------- +# 节点函数 +# --------------------------------------------------------------------------- + +# 意图关键词快速路由(不调 LLM) + +# 转人工关键词:用户主动要求 / 投诉 / 安全问题 +_TRANSFER_KEYWORDS = ( + "转人工", "人工客服", "人工服务", "联系客服", "找客服", + "投诉", "不满", "纠纷", "账户异常", "被盗", "安全", +) + +# 拒绝关键词:游客无法处理,直接拒绝,不转人工 +_REJECT_ADVICE_KW = ( + "推荐", "建议买", "建议卖", "包赚", "保本", "稳赚", + "零风险", "无风险", "躺赚", "保本保息", "刚性兑付", +) +_REJECT_ACCOUNT_KW = ( + "我的持仓", "我的账户", "我的余额", "我的风评", + "我的资产", "我的交易", "我的收益", "我的理财", +) +_REJECT_PREDICT_KW = ( + "会涨", "会跌", "收益预测", "走势预测", + "能赚多少", "收益率多少", "涨跌", "明天行情", +) +_REJECT_COMPARE_KW = ( + "和其他", "和XX", "哪个平台好", "哪个好", + "对比一下", "比一比", "哪家好", +) +_REJECT_REALTIME_KW = ( + "实时行情", "实时价格", "现在价格", + "当前价格", "最新行情", +) + + +def recall_memory(state: VisitorState) -> VisitorState: + """节点 1:从 Redis 读取两类短期记忆。""" + mem = VisitorMemoryService() + sid = state["session_id"] + return { + "chitchat_memory": mem.recall(sid, "chitchat"), + "consult_memory": mem.recall(sid, "consult"), + } + + +def intent_classify(state: VisitorState) -> VisitorState: + """节点 2:意图分类(先关键词快速路由,再 DeepSeek)。""" + msg = state["message"] + + # 转人工关键词(最高优先级) + if any(k in msg for k in _TRANSFER_KEYWORDS): + return {"intent": "transfer_human"} + + # 拒绝关键词(直接拒绝,不转人工) + if any(k in msg for k in _REJECT_ADVICE_KW): + return {"intent": "reject", "reply": REJECT_ADVICE} + if any(k in msg for k in _REJECT_ACCOUNT_KW): + return {"intent": "reject", "reply": REJECT_ACCOUNT} + if any(k in msg for k in _REJECT_PREDICT_KW): + return {"intent": "reject", "reply": REJECT_PREDICT} + if any(k in msg for k in _REJECT_COMPARE_KW): + return {"intent": "reject", "reply": REJECT_COMPARE} + if any(k in msg for k in _REJECT_REALTIME_KW): + return {"intent": "reject", "reply": REJECT_REALTIME} + + # DeepSeek 分类 + try: + llm = _build_llm() + user_prompt = INTENT_USER_TEMPLATE.format(message=msg) + resp = llm.invoke([ + {"role": "system", "content": INTENT_SYSTEM}, + {"role": "user", "content": user_prompt}, + ]) + intent = resp.content.strip().lower() + # 合法性校验 + valid = {"product_consult", "policy_interpret", "faq", "chit_chat", "reject", "transfer_human", "fallback"} + if intent not in valid: + intent = "fallback" + except Exception: + intent = "fallback" + + return {"intent": intent} + + +def rag_search(state: VisitorState) -> VisitorState: + """节点 3:RAG 检索(product_consult/policy_interpret/faq 分支)。""" + intent = state["intent"] + rag = VisitorRagService() + context, sources = rag.retrieve(intent, state["message"]) + + if not context: + # 空结果 → 走兜底 + return { + "rag_context": "", + "rag_sources": [], + "reply": FALLBACK_TEXT, + "intent": "fallback", + } + + return {"rag_context": context, "rag_sources": sources} + + +def generate(state: VisitorState) -> VisitorState: + """节点 4:基于 RAG 上下文 + 短期记忆生成回复。""" + # 空上下文时跳过 LLM(兜底已在 rag_search 中处理) + if not state.get("rag_context"): + return {"reply": state.get("reply", FALLBACK_TEXT)} + + # 如果已经设置了兜底回复,直接返回 + if state.get("reply") and state.get("intent") == "fallback": + has_disclaimer = should_add_disclaimer("faq") + reply = state["reply"] + if has_disclaimer: + reply = f"{reply}\n\n{RISK_DISCLAIMER}" + return {"reply": reply, "has_disclaimer": has_disclaimer} + + try: + llm = _build_llm() + mem_text = VisitorMemoryService().as_prompt_text(state["session_id"], "consult") + user_prompt = GENERATE_USER_TEMPLATE.format( + rag_context=state["rag_context"], + memory=mem_text, + message=state["message"], + ) + resp = llm.invoke([ + {"role": "system", "content": GENERATE_SYSTEM}, + {"role": "user", "content": user_prompt}, + ]) + reply = resp.content.strip() + except Exception: + return {"reply": FALLBACK_TEXT, "intent": "fallback"} + + # 合规护栏 + reply, need_transfer = sanitize_reply(reply) + 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} + + +def chitchat(state: VisitorState) -> VisitorState: + """节点 5:闲聊生成。""" + try: + llm = _build_llm() + mem_text = VisitorMemoryService().as_prompt_text(state["session_id"], "chitchat") + user_prompt = CHITCHAT_USER_TEMPLATE.format( + memory=mem_text, + message=state["message"], + ) + resp = llm.invoke([ + {"role": "system", "content": CHITCHAT_SYSTEM}, + {"role": "user", "content": user_prompt}, + ]) + reply = resp.content.strip() + except Exception: + return {"reply": FALLBACK_TEXT, "intent": "fallback"} + + # 合规护栏 + reply, need_transfer = sanitize_reply(reply) + if need_transfer: + return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False} + + return {"reply": reply, "has_disclaimer": False} + + +def reject(state: VisitorState) -> VisitorState: + """节点:直接拒绝(不转人工)。""" + # 关键词路由已设置 reply 时直接用 + if state.get("reply"): + return {} + # DeepSeek 返回 reject 但未设置 reply 时用通用拒绝 + return {"reply": REJECT_GENERAL} + + +def transfer_human(state: VisitorState) -> VisitorState: + """节点 6:转人工。""" + return {"reply": TRANSFER_TEXT, "transfer_to_human": True} + + +def fallback(state: VisitorState) -> VisitorState: + """节点 7:兜底。""" + return {"reply": FALLBACK_TEXT} + + +def save_memory(state: VisitorState) -> VisitorState: + """节点 8:保存短期记忆(先写 user,再写 assistant)。""" + mem = VisitorMemoryService() + sid = state["session_id"] + intent = state.get("intent", "fallback") + + # 咨询类 → consult 记忆;闲聊/转人工/兜底 → chitchat 记忆 + if intent in ("product_consult", "policy_interpret", "faq"): + kind = "consult" + else: + kind = "chitchat" + + mem.append(sid, kind, "user", state["message"]) + mem.append(sid, kind, "assistant", state["reply"]) + + return {} + + +# --------------------------------------------------------------------------- +# 图构建 +# --------------------------------------------------------------------------- + +def _build_graph(): + g = StateGraph(VisitorState) + + g.add_node("recall_memory", recall_memory) + g.add_node("intent_classify", intent_classify) + g.add_node("rag_search", rag_search) + 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_memory", save_memory) + + g.set_entry_point("recall_memory") + g.add_edge("recall_memory", "intent_classify") + + def _route(state: VisitorState) -> str: + intent = state["intent"] + 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" + return "fallback" + + g.add_conditional_edges("intent_classify", _route, { + "rag_search": "rag_search", + "chitchat": "chitchat", + "reject": "reject", + "transfer_human": "transfer_human", + "fallback": "fallback", + }) + + g.add_edge("rag_search", "generate") + g.add_edge("generate", "save_memory") + g.add_edge("chitchat", "save_memory") + g.add_edge("reject", "save_memory") + g.add_edge("transfer_human", "save_memory") + g.add_edge("fallback", "save_memory") + g.add_edge("save_memory", END) + return g.compile() + + +_GRAPH = _build_graph() + + +# --------------------------------------------------------------------------- +# 编排入口(对齐 agent_service.run_chat 模式) +# --------------------------------------------------------------------------- + +def run_visitor_chat( + ctx: AuthContext, + message: str, + session_id: str, +) -> tuple[str, bool, str, bool]: + """游客对话入口,返回 (reply, has_disclaimer, intent, transfer_to_human)。""" + state: VisitorState = { + "session_id": session_id, + "trace_id": ctx.trace_id, + "message": message, + "intent": "", + "chitchat_memory": [], + "consult_memory": [], + "rag_context": "", + "rag_sources": [], + "reply": "", + "has_disclaimer": False, + "transfer_to_human": False, + } + result = _GRAPH.invoke(state) + return ( + result["reply"], + result.get("has_disclaimer", False), + result.get("intent", "fallback"), + result.get("transfer_to_human", False), + ) diff --git a/app/tool/core_ro_tool.py b/app/tool/core_ro_tool.py new file mode 100644 index 0000000..6c2c406 --- /dev/null +++ b/app/tool/core_ro_tool.py @@ -0,0 +1,449 @@ +"""已注册客户 Agent 的 Core 只读查询工具(4 个)。 + +铁律: +1. customer_id 为必传位置参数,来自 JWT 解析(resolve_effective_customer_id), + 工具层不接受用户消息中的任何"客户号",SQL 均强制 WHERE customer_id=:cid; +2. facts 经 mask_dict_fields 脱敏后方可离开本层;fact_text 由模板拼装, + 数值 100% 来自 core_ro 结构化查询,LLM 只做语气润色,不生成任何数值; +3. 适当性为只读匹配展示(CS-C-07/08),不写 risk_suitability_log、不引导交易。 +""" + +from __future__ import annotations + +import re +from datetime import date, timedelta +from typing import Any, TypedDict + +from app.repository.core_ro import CoreReadOnlyRepository +from app.utils.data_masker import mask_dict_fields + +DEFAULT_TRADE_MONTHS = 3 +DEFAULT_LIMIT = 50 + +_TRADE_TYPE_CN = { + "subscribe": "申购", + "redeem": "赎回", + "convert": "转换", +} + +_PRODUCT_TYPE_CN = { + "money": "货币型", + "bond": "债券型", + "mixed": "混合型", + "stock": "股票型", + "index": "指数型", + "wealth_mgmt": "银行理财", + "private_fund": "私募基金", + "insurance": "保险", + "structured": "结构化", +} + +_RISK_CODE_CN = { + "C1": "C1(保守型)", + "C2": "C2(稳健型)", + "C3": "C3(平衡型)", + "C4": "C4(成长型)", + "C5": "C5(进取型)", +} + +# 风评登记投资期限枚举 → 中文(core_customer_risk.investment_horizon) +_HORIZON_CN = { + "short": "短期(1年以内)", + "medium": "中期(1-5年)", + "long": "长期(5年以上)", + "flexible": "灵活期限", +} + +_R_LEVEL_RE = re.compile(r"R\s*([1-5])", re.IGNORECASE) + + +class ToolResult(TypedDict, total=False): + tool: str + ok: bool + facts: Any + fact_text: str + error: str | None + + +# --------------------------------------------------------------------------- +# 格式化辅助 +# --------------------------------------------------------------------------- + +def _money(v: Any) -> str: + if v is None: + return "-" + return f"{float(v):,.2f}" + + +def _date_str(v: Any) -> str: + if v is None: + return "-" + return str(v)[:10] + + +def _pct(v: Any) -> str: + if v is None: + return "-" + return f"{float(v):.2f}%" + + +def _require_customer(customer_id: str | None) -> str: + if not customer_id or not str(customer_id).strip(): + raise ValueError("customer_id is required for core query tools") + return str(customer_id).strip() + + +def _masked(rows: Any) -> Any: + """结构化结果脱敏(dict / list[dict] 通用)。""" + return mask_dict_fields(rows) + + +# --------------------------------------------------------------------------- +# 工具 1:持仓查询(CS-C-01/02) +# --------------------------------------------------------------------------- + +def query_holdings(customer_id: str, repo: CoreReadOnlyRepository | None = None) -> ToolResult: + """查询本人当前持仓(市值/盈亏/产品风险等级)。""" + cid = _require_customer(customer_id) + ro = repo or CoreReadOnlyRepository() + rows = ro.list_holdings(cid) + + if not rows: + return ToolResult( + tool="holding_query", + ok=True, + facts=[], + fact_text="您当前没有持仓记录。", + ) + + as_of = _date_str(rows[0].get("as_of")) + lines = [f"您当前持有 {len(rows)} 只产品(持仓数据日期:{as_of}):"] + total_mv = 0.0 + for i, r in enumerate(rows, 1): + total_mv += float(r.get("market_value") or 0) + pnl = float(r.get("pnl_pct") or 0) + trend = "盈利" if pnl > 0 else ("亏损" if pnl < 0 else "持平") + lines.append( + f"{i}. {r.get('product_name')}({r.get('min_risk_code')}," + f"{_PRODUCT_TYPE_CN.get(r.get('product_type'), r.get('product_type') or '-')}):" + f"持仓市值 {_money(r.get('market_value'))} 元,浮动{trend} {_pct(abs(pnl)) if pnl != 0 else '0.00%'}" + ) + lines.append(f"持仓合计市值约 {_money(total_mv)} 元。") + + return ToolResult( + tool="holding_query", + ok=True, + facts=_masked(rows), + fact_text="\n".join(lines), + ) + + +# --------------------------------------------------------------------------- +# 工具 2:流水查询(CS-C-05) +# --------------------------------------------------------------------------- + +def query_trades( + customer_id: str, + months: int | None = None, + repo: CoreReadOnlyRepository | None = None, +) -> ToolResult: + """查询本人交易流水。默认近 3 个月,最多 50 条。""" + cid = _require_customer(customer_id) + ro = repo or CoreReadOnlyRepository() + + months = months if months and months > 0 else DEFAULT_TRADE_MONTHS + since = date.today() - timedelta(days=months * 30) + rows = ro.list_trades(cid, since=since, limit=DEFAULT_LIMIT) + + confirmed = [r for r in rows if r.get("trade_status") == "confirmed"] + other_cnt = len(rows) - len(confirmed) + + if not rows: + return ToolResult( + tool="transaction_query", + ok=True, + facts=[], + fact_text=f"近 {months} 个月您没有交易记录。", + ) + + lines = [f"近 {months} 个月您共有 {len(confirmed)} 笔已确认交易:"] + for i, r in enumerate(confirmed, 1): + lines.append( + f"{i}. {_date_str(r.get('traded_at'))} " + f"{_TRADE_TYPE_CN.get(r.get('trade_type'), r.get('trade_type'))}" + f"{r.get('product_name')}({r.get('min_risk_code')})" + f"{_money(r.get('amount'))} 元" + ) + if other_cnt: + lines.append(f"(另有 {other_cnt} 笔待确认/已取消记录未列出)") + lines.append("以上为系统记录的交易流水。") + + return ToolResult( + tool="transaction_query", + ok=True, + facts=_masked(rows), + fact_text="\n".join(lines), + ) + + +# --------------------------------------------------------------------------- +# 工具 3:风评查询(CS-C-04/06) +# --------------------------------------------------------------------------- + +def query_risk_profile(customer_id: str, repo: CoreReadOnlyRepository | None = None) -> ToolResult: + """查询本人最新风险测评结果(L0 正式风评,不含 L1 画像)。""" + cid = _require_customer(customer_id) + ro = repo or CoreReadOnlyRepository() + row = ro.get_customer_l0(cid) + + if not row: + return ToolResult( + tool="risk_assessment_query", + ok=False, + facts=None, + fact_text="未查询到您的客户档案,请联系人工客服核实。", + error="customer_not_found", + ) + + risk_code = row.get("risk_code") + if not risk_code: + facts = { + "customer_id": cid, + "risk_code": None, + "risk_is_expired": True, + } + return ToolResult( + tool="risk_assessment_query", + ok=True, + facts=facts, + fact_text="您尚未完成风险承受能力评估。购买理财产品前需先完成风险测评问卷," + "您可在 App「我的-风险测评」中完成,或到营业网点办理。", + ) + + is_expired = bool(row.get("risk_is_expired")) + facts = { + "customer_id": cid, + "risk_code": risk_code, + "questionnaire_score": row.get("questionnaire_score"), + "max_loss_tolerance_pct": row.get("max_loss_tolerance_pct"), + "investment_goal": row.get("investment_goal"), + "investment_horizon": row.get("investment_horizon"), + "investor_category": row.get("investor_category") or "ordinary", + "risk_evaluated_at": _date_str(row.get("risk_evaluated_at")), + "risk_expires_at": _date_str(row.get("risk_expires_at")), + "risk_is_expired": is_expired, + } + + lines = ["您的最新风险测评结果:"] + lines.append(f"- 风险等级:{_RISK_CODE_CN.get(risk_code, risk_code)}") + if row.get("questionnaire_score") is not None: + lines.append(f"- 问卷得分:{row.get('questionnaire_score')} 分") + lines.append( + f"- 测评日期:{facts['risk_evaluated_at']},有效期至:{facts['risk_expires_at']}" + ) + if row.get("investment_goal"): + lines.append(f"- 测评登记投资目标:{row.get('investment_goal')}") + horizon_raw = row.get("investment_horizon") + if horizon_raw: + lines.append( + f"- 测评登记投资期限:{_HORIZON_CN.get(horizon_raw, horizon_raw)}" + ) + lines.append( + f"- 投资者类型:{'专业投资者' if facts['investor_category'] == 'professional' else '普通投资者'}" + ) + if is_expired: + lines.append( + "\n注意:您的风险测评已过期,适当性匹配将无法通过。" + "请尽快在 App「我的-风险测评」重新完成评估。" + ) + else: + lines.append("\n您的测评结果当前有效。") + + return ToolResult( + tool="risk_assessment_query", + ok=True, + facts=_masked(facts), + fact_text="\n".join(lines), + ) + + +# --------------------------------------------------------------------------- +# 工具 4:适当性匹配(CS-C-07/08,只读) +# --------------------------------------------------------------------------- + +def _suitability_text(res: dict[str, Any]) -> str: + """单产品 check_suitability 结果 → 只读说明文案。""" + name = res.get("product_name") or res.get("product_id") + c_code = res.get("customer_risk_code") or "-" + p_code = res.get("product_risk_code") or "-" + result = res.get("match_result") + + if result == "allowed": + return ( + f"《{name}》(风险等级 {p_code}):根据适当性匹配规则," + f"您当前风评 {_RISK_CODE_CN.get(c_code, c_code)} 与该产品风险等级匹配,可以购买。" + ) + if result == "allowed_with_disclosure": + return ( + f"《{name}》(风险等级 {p_code}):您的风评 {c_code} 与该产品风险等级匹配," + "但该产品属于需特别风险揭示的情形,购买前需完成风险揭示确认(双录)程序。" + ) + if result == "forbidden" and res.get("mismatch_type") == "not_found": + return "未查询到对应的产品信息,请确认产品名称后再问我,或联系人工客服。" + if result == "forbidden": + return ( + f"《{name}》(风险等级 {p_code}):根据投资者适当性管理要求及 C×R 风险等级匹配矩阵," + f"您当前风评{_RISK_CODE_CN.get(c_code, c_code)}低于该产品要求的最低风险等级," + "该产品与您的风险承受能力不匹配,不能购买。" + ) + if result == "risk_expired": + return ( + f"《{name}》(风险等级 {p_code}):您的风险测评已过期" + f"(有效期至 {_date_str(res.get('risk_expires_at'))})," + "暂时无法进行适当性匹配,请先重新完成风险测评。" + ) + if result == "professional_exempt": + return f"《{name}》:您为专业投资者,按规则豁免适当性匹配。" + if res.get("needs_branch_confirm"): + return ( + f"《{name}》(风险等级 {p_code}):您年满 70 周岁购买 R3 及以上产品," + "按监管要求需由家属陪同到营业网点当面确认后方可办理(FM-01)。" + ) + return f"《{name}》:适当性匹配结果暂无法判定,建议联系人工客服。" + + +def _summarize_by_risk_level(customer_id: str, r_code: str, ro: CoreReadOnlyRepository) -> ToolResult: + """按风险等级汇总开放产品匹配情况(用户问"我能买 R3 产品吗")。""" + products = ro.list_products_for_customer(customer_id, limit=100) + target = [p for p in products if (p.get("min_risk_code") or "").upper() == r_code.upper()] + + if not target: + return ToolResult( + tool="suitability_check", + ok=True, + facts=[], + fact_text=f"当前没有风险等级为 {r_code.upper()} 的在售开放产品。", + ) + + if target[0].get("risk_is_expired"): + c_code = target[0].get("customer_risk_code") or "-" + return ToolResult( + tool="suitability_check", + ok=True, + facts=_masked(target), + fact_text=( + f"您的风险测评已过期(风评 {c_code}),暂时无法进行 {r_code.upper()} 产品的" + "适当性匹配,请先重新完成风险测评后再查询。" + ), + ) + + allowed = [p for p in target if p.get("match_result") == "allowed" + and not p.get("requires_disclosure")] + disclosure = [ + p for p in target + if p.get("match_result") == "allowed_with_disclosure" + or (p.get("match_result") == "allowed" and p.get("requires_disclosure")) + ] + forbidden = [p for p in target if p.get("match_result") not in ("allowed", "allowed_with_disclosure")] + + c_code = target[0].get("customer_risk_code") or "-" + lines = [ + f"风险等级 {r_code.upper()} 的在售开放产品共 {len(target)} 只。" + f"按您当前风评 {_RISK_CODE_CN.get(c_code, c_code)} 的适当性匹配结果:", + f"- 可直接购买:{len(allowed)} 只", + f"- 需完成风险揭示确认(双录)后购买:{len(disclosure)} 只", + f"- 风险等级不匹配、不能购买:{len(forbidden)} 只" + "(依据 C×R 风险等级匹配矩阵,产品风险等级高于您的风评可配范围)", + "\n以上为系统只读匹配结果,不构成购买建议;如需了解具体产品,请告诉我产品名称。", + ] + return ToolResult( + tool="suitability_check", + ok=True, + facts=_masked(target), + fact_text="\n".join(lines), + ) + + +def query_suitability( + customer_id: str, + product_keyword: str | None = None, + risk_level: str | None = None, + repo: CoreReadOnlyRepository | None = None, +) -> ToolResult: + """适当性只读匹配:具体产品 → check_suitability;R 等级 → 汇总;无参数 → 提示。""" + cid = _require_customer(customer_id) + ro = repo or CoreReadOnlyRepository() + + # 1) R 等级汇总 + r_match = _R_LEVEL_RE.search(risk_level or "") or _R_LEVEL_RE.search(product_keyword or "") + if r_match: + r_code = f"R{r_match.group(1)}" + return _summarize_by_risk_level(cid, r_code, ro) + + # 2) 具体产品定位 + keyword = (product_keyword or "").strip() + if keyword: + products = ro.find_products(keyword, limit=3) + if not products: + return ToolResult( + tool="suitability_check", + ok=True, + facts=[], + fact_text=( + f"未找到名称包含「{keyword}」的在售产品。" + "您可以告诉我产品全称,或直接问「我能买 R3 产品吗」这类问题。" + ), + ) + texts: list[str] = [] + results: list[dict[str, Any]] = [] + for p in products[:3]: + res = ro.check_suitability(cid, p["product_id"]) + results.append(res) + texts.append(_suitability_text(res)) + return ToolResult( + tool="suitability_check", + ok=True, + facts=_masked(results), + fact_text="\n".join(texts), + ) + + # 3) 无参数:按 R 等级全量汇总 + products = ro.list_products_for_customer(cid, limit=100) + if not products: + return ToolResult( + tool="suitability_check", + ok=False, + facts=None, + fact_text="暂未查询到在售产品信息。", + error="no_products", + ) + if products[0].get("risk_is_expired"): + c_code = products[0].get("customer_risk_code") or "-" + return ToolResult( + tool="suitability_check", + ok=True, + facts=_masked(products), + fact_text=( + f"您的风险测评已过期(风评 {c_code}),暂时无法进行适当性匹配," + "请先重新完成风险测评。" + ), + ) + + c_code = products[0].get("customer_risk_code") or "-" + lines = [f"按您当前风评{_RISK_CODE_CN.get(c_code, c_code)},在售产品适当性匹配汇总:"] + for r_code in ("R1", "R2", "R3", "R4", "R5"): + group = [p for p in products if (p.get("min_risk_code") or "").upper() == r_code] + if not group: + continue + ok_cnt = sum( + 1 for p in group + if p.get("match_result") in ("allowed", "allowed_with_disclosure") + ) + lines.append(f"- {r_code} 产品共 {len(group)} 只:{ok_cnt} 只与您的风评匹配,{len(group) - ok_cnt} 只不匹配") + lines.append("\n以上为系统只读匹配结果,不构成购买建议;如需了解具体产品,请告诉我产品名称。") + return ToolResult( + tool="suitability_check", + ok=True, + facts=_masked(products), + fact_text="\n".join(lines), + ) diff --git a/app/utils/compliance_guard.py b/app/utils/compliance_guard.py new file mode 100644 index 0000000..4f93a1c --- /dev/null +++ b/app/utils/compliance_guard.py @@ -0,0 +1,110 @@ +"""合规护栏:违禁词扫描 + 风险提示 + 兜底话术。""" + +from __future__ import annotations + +import re + +# --------------------------------------------------------------------------- +# 违禁词正则库 +# --------------------------------------------------------------------------- + +FORBIDDEN_TERMS: tuple[re.Pattern, ...] = ( + # 建议买卖(允许中间 0~3 字,如「建议您买入」「建议客户加仓」) + re.compile(r"建议.{0,3}(买入|卖出|买|卖|持有|加仓|减仓|清仓)"), + re.compile(r"推荐.{0,3}(购买|买入|卖出|定投)"), + re.compile(r"(保证|承诺).{0,4}(收益|保本|盈利|赚钱)"), + re.compile(r"(一定|肯定|必定).{0,4}(涨|赚|盈利)"), + re.compile(r"稳赚|包赚|躺赚|零风险|无风险"), + re.compile(r"保本保息|刚性兑付"), +) + +# --------------------------------------------------------------------------- +# 标准话术 +# --------------------------------------------------------------------------- + +RISK_DISCLAIMER = ( + "【风险提示】以上信息仅供参考,不构成投资建议。" + "产品的风险等级、费用与赎回到账时间以官方法律文件为准,投资有风险,入市需谨慎。" +) + +COMPLIANCE_REJECT = ( + "抱歉,我无法提供投资建议。如需专业咨询,请转接人工客服或登录后联系理财顾问。" +) + +# --------------------------------------------------------------------------- +# 分类拒绝话术(直接拒绝,不转人工) +# --------------------------------------------------------------------------- + +REJECT_ADVICE = ( + "抱歉,我无法提供投资建议或推荐具体产品。" + "如需专业投资咨询,建议您登录后联系专属理财顾问,或前往营业网点面询。" +) + +REJECT_ACCOUNT = ( + "抱歉,游客模式下无法查询个人账户信息。" + "请登录后查看您的持仓详情,如未注册可前往APP完成开户。" +) + +REJECT_PREDICT = ( + "抱歉,我无法预测市场走势或产品涨跌。" + "任何声称可以预测涨跌的信息都不可信,投资有风险,入市需谨慎。" +) + +REJECT_COMPARE = ( + "抱歉,我无法进行平台或产品间的对比评价。" + "建议您参考各平台的官方信息,做出适合自己的判断。" +) + +REJECT_REALTIME = ( + "抱歉,游客模式下不支持实时行情查询。" + "如需查看实时行情,请登录后使用行情功能。" +) + +REJECT_GENERAL = ( + "抱歉,我无法回答这个问题。" + "您可以换一种方式描述,或浏览常见问答获取帮助。" +) + +# --------------------------------------------------------------------------- +# 转人工话术(仅限投诉/复杂咨询/用户主动要求/安全问题) +# --------------------------------------------------------------------------- + +TRANSFER_TEXT = ( + "已为您安排转接人工客服。您也可以:\n" + "- 拨打客服热线 95XXX(工作日 9:00-18:00)\n" + "- 前往最近的营业网点办理\n\n" + "如需查询个人账户、持仓信息或获取个性化投资建议," + "请登录后联系您的专属理财顾问。\n" + "(游客模式下我无法访问任何账户数据,也无法提供投资建议,敬请谅解。)" +) + +FALLBACK_TEXT = ( + "抱歉,我暂时无法回答这个问题。您可以:\n" + "1. 换一种方式描述您的问题\n" + "2. 浏览常见问答帮助中心\n" + "3. 拨打客服热线 95XXX\n" + '4. 回复"转人工"联系客服人员' +) + + +def scan_reply(reply: str) -> tuple[bool, list[str]]: + """扫描回复是否命中违禁词,返回 (是否命中, 命中词列表)。""" + hits: list[str] = [] + for pattern in FORBIDDEN_TERMS: + match = pattern.search(reply) + if match: + hits.append(match.group()) + return (len(hits) > 0, hits) + + +def sanitize_reply(reply: str) -> tuple[str, bool]: + """命中违禁词则替换为拒绝话术,返回 (safe_reply, need_transfer)。""" + has_forbidden, _ = scan_reply(reply) + if has_forbidden: + return COMPLIANCE_REJECT, True + return reply, False + + +def should_add_disclaimer(intent: str) -> bool: + """咨询类意图需要附带风险提示。""" + return intent in ("product_consult", "policy_interpret", "faq") diff --git a/app/utils/data_masker.py b/app/utils/data_masker.py new file mode 100644 index 0000000..fa05d17 --- /dev/null +++ b/app/utils/data_masker.py @@ -0,0 +1,179 @@ +"""信息脱敏(CS-C-12,BASE-08):手机号 / 身份证 / 姓名 / 银行卡四类敏感信息脱敏。 + +运行时机(方案 §7.2,两处防线): +1. LLM 输入前:core_ro 结构化查询结果经 ``mask_dict_fields`` 按字段名批量脱敏后再拼 prompt, + 防止敏感信息进入 LLM 上下文或日志; +2. API 返回前:回复文本经 ``mask_text`` 正则兜底扫描(与 compliance_guard 并行的最终防线)。 + +脱敏规则(方案 §7.1): +- 手机号:保留前 3 后 4,中间 4 个 *(``138****5678``) +- 身份证:保留前 3 后 4(18 位中间 11 个 *;兼容 15 位老证、末位 X) +- 姓名:只保留姓(``张三``→``张*``、``张明明``→``张**``) +- 银行卡:仅保留末 4 位,分隔符(空格/连字符)原样保留(``**** **** **** 7890``) + +通用约定:None / 空串原样返回;值中已含 ``*`` 视为已脱敏直接放行(防重复打码); +不符合目标格式的值原样返回(不破坏原始数据)。 +""" + +from __future__ import annotations + +import re +from typing import Any, Callable + +# --------------------------------------------------------------------------- +# 单字段脱敏 +# --------------------------------------------------------------------------- + + +def mask_phone(phone: str | None) -> str | None: + """手机号脱敏:保留前 3 后 4。非 11 位标准手机号原样返回。""" + if not phone or "*" in phone: + return phone + m = re.fullmatch(r"\s*(1[3-9]\d)(\d{4})(\d{4})\s*", phone) + if not m: + return phone + return f"{m.group(1)}****{m.group(3)}" + + +def mask_id_card(id_no: str | None) -> str | None: + """身份证脱敏:保留前 3 后 4。支持 18 位(末位可为 X)与 15 位老证。""" + if not id_no or "*" in id_no: + return id_no + s = id_no.strip().upper() # 末位 x 归一为 X + # 18 位:前 3 + 中间 11 位打码 + 后 4(末位可能为 X) + m = re.fullmatch(r"(\d{3})(\d{11})(\d{3}[0-9X])", s) + if m: + return f"{m.group(1)}{'*' * 11}{m.group(3)}" + # 15 位老证:前 3 + 中间 8 位打码 + 后 4 + m = re.fullmatch(r"(\d{3})(\d{8})(\d{4})", s) + if m: + return f"{m.group(1)}{'*' * 8}{m.group(3)}" + return id_no + + +def mask_name(name: str | None) -> str | None: + """姓名脱敏:只保留姓,其余字符全部打 *(``张三``→``张*``、``张明明``→``张**``)。""" + if not name or "*" in name: + return name + s = name.strip() + if len(s) <= 1: + return s + return s[0] + "*" * (len(s) - 1) + + +def mask_bank_card(card: str | None) -> str | None: + """银行卡脱敏:仅保留末 4 位数字,分隔符(空格/连字符)原样保留。 + + 数字位数不足 8 的不当作卡号处理,原样返回。 + """ + if not card or "*" in card: + return card + s = card.strip() + digit_count = sum(1 for c in s if c.isdigit()) + if digit_count < 8: + return card + keep_from = digit_count - 4 # 第几个数字起保留(从 0 计) + out: list[str] = [] + digit_idx = 0 + for c in s: + if c.isdigit(): + out.append(c if digit_idx >= keep_from else "*") + digit_idx += 1 + else: + out.append(c) # 分隔符原样保留 + return "".join(out) + + +# --------------------------------------------------------------------------- +# 自由文本自动扫描(API 返回前兜底) +# --------------------------------------------------------------------------- + +# 顺序敏感:先长后短。18 位身份证同样满足银行卡 16~19 位长度,必须先替换身份证; +# 所有模式带数字边界断言,防止 11 位手机号从 16 位卡号中被误切。 +_ID_CARD_18_RE = re.compile(r"(? str: + """银行卡正则命中片段的替换函数:复用 mask_bank_card 的逐字符逻辑。""" + return mask_bank_card(m.group(0)) # type: ignore[arg-type,return-value] + + +def mask_text(text: str | None) -> str | None: + """自由文本兜底脱敏:自动扫描并打码身份证 / 银行卡 / 手机号。 + + 姓名不在自由文本中识别(正则无法可靠区分姓名与普通词语,误伤率高); + 姓名脱敏只在结构化字段由 ``mask_name`` / ``mask_dict_fields`` 处理。 + """ + if not text: + return text + # 18 位身份证 → 银行卡(16~19 位,含分隔符)→ 15 位老证 → 11 位手机号 + text = _ID_CARD_18_RE.sub(lambda m: f"{m.group(1)}{'*' * 11}{m.group(3)}", text) + text = _BANK_CARD_RE.sub(_mask_bank_card_match, text) + text = _ID_CARD_15_RE.sub(lambda m: f"{m.group(1)}{'*' * 8}{m.group(3)}", text) + text = _PHONE_RE.sub(lambda m: f"{m.group(1)}****{m.group(3)}", text) + return text + + +# --------------------------------------------------------------------------- +# 结构化数据批量脱敏(LLM 输入前,core_ro 查询结果) +# --------------------------------------------------------------------------- + +# 字段名 → 脱敏类型。core_ro 返回的行是扁平 dict,按列名命中即脱敏; +# 库中 *_mask 列本已脱敏,命中后会被 mask_* 的 "*" 检查直接放行(纵深防御)。 +DEFAULT_FIELD_MASKERS: dict[str, str] = { + # 手机号 + "phone": "phone", + "phone_mask": "phone", + "mobile": "phone", + # 身份证 + "id_no": "id_card", + "id_no_mask": "id_card", + "id_card": "id_card", + # 姓名 + "name": "name", + "display_name": "name", + "customer_name": "name", + "counterparty_name": "name", + "payer_name": "name", + # 银行卡 / 账号 + "bank_card": "bank_card", + "card_no": "bank_card", + "account_no": "bank_card", + "counterparty_account_mask": "bank_card", +} + +_MASKER_FUNCS: dict[str, Callable[[str | None], str | None]] = { + "phone": mask_phone, + "id_card": mask_id_card, + "name": mask_name, + "bank_card": mask_bank_card, +} + + +def mask_dict_fields( + data: Any, + field_maskers: dict[str, str] | None = None, +) -> Any: + """对 core_ro 结构化查询结果按字段名批量脱敏。 + + - 入参可为 dict(单行)或 list[dict](多行,如 list_holdings / list_trades 结果); + - 仅处理字符串值,非字符串(Decimal/date/int 等)原样保留; + - 返回浅拷贝,不修改入参; + - ``field_maskers`` 可自定义 {字段名: 脱敏类型},脱敏类型取 + phone / id_card / name / bank_card;默认用 :data:`DEFAULT_FIELD_MASKERS`。 + """ + mapping = field_maskers or DEFAULT_FIELD_MASKERS + + if isinstance(data, list): + return [mask_dict_fields(item, mapping) if isinstance(item, dict) else item for item in data] + if not isinstance(data, dict): + return data + + out = dict(data) + for field, masker_name in mapping.items(): + if field in out and isinstance(out[field], str): + out[field] = _MASKER_FUNCS[masker_name](out[field]) + return out diff --git a/docs/frontend/FRONTEND-HANDOFF.md b/docs/frontend/FRONTEND-HANDOFF.md new file mode 100644 index 0000000..8fad44f --- /dev/null +++ b/docs/frontend/FRONTEND-HANDOFF.md @@ -0,0 +1,195 @@ +# JinRong 前端交接手册 + +## 1. 项目定位 + +当前前端采用「静奢智能」视觉方向:暖白与米灰背景、墨蓝品牌色、浅金点缀,收益语义使用朱砂红(正值)与翡翠绿(负值)。视觉层已经与业务数据层分离,后续 agent 可以在不改 API 契约的前提下扩展页面。 + +## 2. 启动与验证 + +从仓库根目录执行: + +```bash +cd web +npm install +npm run dev +npm run build +npm run test +npm run lint +``` + +后端需要单独启动: + +```bash +uvicorn app.main:app --reload +``` + +Vite 开发服务器默认使用 `http://127.0.0.1:5173`,并将 `/api` 代理到 `http://127.0.0.1:8000`。路由使用 `HashRouter`,页面 URL 形如 `http://127.0.0.1:5173/#/app/customer/home`。 + +## 3. 依赖方向 + +```text +pages → hooks/api/utils +pages → components/dashboard +components/dashboard → components/ui +layouts/AppLayout → components/layout +components/ui → styles/tokens.css +``` + +边界规则: + +- `components/ui` 只能处理展示和可访问性,禁止导入 `api`、`hooks`、`authStore` 或角色业务类型。 +- `components/layout` 只接收导航和身份上下文,不发起 API 请求。 +- `components/dashboard` 只接收页面已经计算好的值、数组、ReactNode 和表格配置,不负责请求或聚合。 +- hooks 是 API loading、error、refresh 状态的所有者。 +- 页面负责业务文案、hook 调用、路由 Link、表格列和业务字段映射。 + +## 4. 设计令牌 + +令牌位于 `web/src/styles/tokens.css`,优先使用 Tailwind utility,不要在页面新增品牌色或重复 spacing 值。 + +| Token | Value | 用途 | +| --- | --- | --- | +| `jr-bg` | `#F6F5F1` | 页面背景 | +| `jr-surface` | `#FBFAF7` | 次级表面 | +| `jr-surface-strong` | `#FFFFFF` | 卡片和主表面 | +| `jr-ink` | `#132B3A` | 品牌墨蓝、主按钮 | +| `jr-text` | `#17212B` | 正文 | +| `jr-muted` | `#71808A` | 次级文字 | +| `jr-border` | `#E5E2DB` | 边框和分隔线 | +| `jr-positive` | `#178A68` | 负收益、正常状态 | +| `jr-negative` | `#C9564A` | 正收益、风险状态 | +| `jr-gold` | `#C9A66B` | eyebrow、焦点、强调 | +| `jr-info` | `#3F718A` | 信息提示 | + +金融产品已有语义不能反转:正值 P&L 显示朱砂红,负值显示翡翠绿,零值使用次级灰。这是现有产品约定,不要按通用股票配色改成相反语义。 + +## 5. 可复用组件 + +### 基础 UI:`src/components/ui` + +```tsx + + } /> + +刷新} /> +运行正常 + +``` + +组件职责和 props: + +- `Button`: `variant` 为 `primary | secondary | ghost | quiet`,`size` 为 `sm | md | lg`,支持 `loading` 和标准 button props。 +- `Surface`: `variant` 为 `default | subtle | dark`,`padding` 为 `none | sm | md | lg`,支持 `as` 和 HTML attributes。 +- `Badge`: `tone` 为 `neutral | info | positive | negative | gold | ink`。 +- `MetricCard`: `label`、`value`、可选 `detail`、`trend`、`emphasis`;`value` 接收 ReactNode,不会格式化或改写金额。 +- `PageHeader`: `eyebrow`、`title`、`description`、`action`。 +- `SectionHeading`: `eyebrow`、`title`、`description`、`action`。 +- `EmptyState`: 可选 `image`、`description`、`action`。 + +这些组件不拥有数据获取;用 slots/ReactNode 接入业务内容。 + +### Dashboard:`src/components/dashboard` + +```tsx +... + + + +``` + +- `DashboardHero`: 保留主指标、掩码、眼睛按钮和三项响应式指标;主值由页面提供。 +- `QuickActionBar`: 接收 `{ label, to }[]`,使用真正的 React Router `Link`,不能改为只有 `onClick` 的 button。 +- `ChartCard`: `{ eyebrow?, title, description?, children }`,只包裹图表 surface,不了解图表库内部。 +- `DataSection`: `{ title, description?, action?, children }`,只在表格内容区提供 `overflow-x-auto`。 +- `DashboardLayout`: 接收页面传入的 hero、quick actions、chart data、`ColumnsType`、row key 和空文案;保留 AntD Table 的排序、分页和行操作行为。 + +## 6. 新增角色 Dashboard 的流程 + +1. 在 `src/api` 增加或复用 API 函数,保持 URL、method、参数、Authorization 行为不变。 +2. 在 `src/hooks` 保持 loading、error、refresh 和请求编排;不要把请求写进视觉组件。 +3. 需要聚合或格式化时放在 `src/utils`,并为纯函数补测试。 +4. 页面调用 hook,组合 `PageHeader`、`DashboardHero`、`ChartCard`、`DataSection` 和 `EmptyState`。 +5. 保留 `ApiErrorResult`、无假数据行为和已有 route Link。 +6. 菜单只在 `src/routes/menus.tsx` 增加,路由只在 `src/App.tsx` 增加;不要在页面复制导航模型。 + +## 7. 状态矩阵与 Ant Design 边界 + +```text +Loading → Skeleton,应用壳层和页面层级仍可见 +Success → 共享 Surface + 现有 Table/Charts +Empty → EmptyState 或 AntD Empty,绝不填充假数字 +Error → ApiErrorResult,显示 error code、message 和 trace ID +``` + +Ant Design 继续负责: + +- Table 的排序、分页、row key 和行渲染; +- Alert、Result、Empty、Skeleton 和 message; +- `@ant-design/charts` 图表内部、tooltip、legend 和延迟挂载。 + +Tailwind 负责: + +- 页面布局、间距、响应式网格、排版、Surface 层级和轻量 hover/focus 动效; +- 令牌来自 `tokens.css`,不要在页面硬编码新的品牌色。 + +禁止: + +- 全局 `!important` 覆盖 AntD 行为; +- 将 API 请求或角色判断移入 `components/ui`; +- API 失败时添加 demo/fallback 数字; +- 把真实 `Link` 换成 click-only button; +- 为了视觉改动 `AuthState`、`jinrong.auth`、接口字段或路由。 + +## 8. 浏览器验收清单 + +启动 FastAPI 与 Vite 后,使用四个 demo 账号检查: + +- 客户 → `/app/customer/home` +- 理财师 → `/app/advisor/home` +- 分析员 → `/app/analyst/home` +- 风控 → `/app/risk/home` + +每个角色都检查: + +- Logo、角色上下文、当前菜单高亮; +- 侧栏折叠/展开; +- actor ID copy; +- 退出后回到登录页; +- 至少一个共享平台 route。 + +业务回归: + +- 客户显示总市值、累计盈亏、昨日收益和持仓数;眼睛按钮只切换 `****`,不改变表格行数据;图表、分页、排序、详情和助手链接可用。 +- 理财师显示客户数、总 AUM 和最大客户 fallback;客户市值降序、客户与 Chat 链接可用。 +- 分析员显示产品数、上涨/下跌/平盘;按日变化绝对值排序,行情/问数/Chat 链接可用。 +- 风控显示待审数量和 API disclaimer;无预警时显示 Empty,不出现假图表或假表格;有预警时显示图表、处置和助手链接。 +- API 错误态显示 code、message、trace ID 和重试;loading 态保留 shell 与 skeleton。 +- 检查正收益红色、负收益绿色、零值灰色。 + +视口矩阵:`1280px`、`1024px`、`768px`、`320px`。 + +- body 不出现横向滚动条; +- 只有表格区域发生局部横向滚动; +- 登录列在窄屏堆叠; +- action rail 自动换行; +- Hero 指标变成可读的响应式列; +- 键盘 focus ring 可见。 + +## 9. 常见坑 + +- 不要把 API 调用迁移到 visual component。 +- 不要在 API error 时补默认金额或默认条数。 +- 不要在页面里新增硬编码颜色或随意 spacing。 +- 不要删除共享组件而不检查 downstream agent 的引用。 +- 不要修改 `AuthState`、`jinrong.auth` localStorage key 或登录默认落点。 +- 不要把表格的 sorting、pagination、row action 逻辑重写成页面外的副本。 +- 修改后至少运行: + +```bash +cd web +npm run build +npm run test +npm run lint +``` + +当前已知 lint warning 主要来自既有 hooks 的 `set-state-in-effect` 和 React Fast Refresh 对“组件文件同时导出常量”的提示;它们不是编译错误。Vite 可能提示主 chunk 超过 500 kB,属于当前依赖包体积提示。 diff --git a/docs/memory/FRAMEWORK.md b/docs/memory/FRAMEWORK.md index 6efeb75..7b1bb3a 100644 --- a/docs/memory/FRAMEWORK.md +++ b/docs/memory/FRAMEWORK.md @@ -19,7 +19,7 @@ | 向量库 | Milvus Lite + pymilvus 3.0.1 | 本地 `./data/milvus.db`;1024 维 | | Embedding | Ollama bge-m3 | 本地,不出内网 | | LLM 生成 | DeepSeek API | 对话/推理 | -| 前端 | React 19 + Vite 7 + AntD 5 + TS strict | **`web/` 脚手架已 init**;Chat/平台读页待接;设计见 `docs/superpowers/specs/2026-09-09-frontend-p0-design.md` | +| 前端 | React 19 + Vite 8 + **AntD 6** + **Tailwind CSS v4** + TS strict | **`web/` 四角色 Dashboard 已真接平台读 API**(`hooks`+`api`+`components/ui|dashboard|layout`);Chat/SSE/market 待接;见 `docs/frontend/FRONTEND-HANDOFF.md` | | 对象存储 | 本地目录 `data/kb/` | 不用 MinIO | | 部署 | Windows 原生 | Docker 仅答辩备选 | @@ -35,7 +35,7 @@ | 模块 | 职责 | 依赖 | 代码状态 | | --- | --- | --- | --- | | 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 产品库 | 空壳 service(chat 骨架已通) | +| 客户财富 Agent | L1 画像、事实查询、阈值提醒 | Core RO、Milvus 产品库 | **chat 骨架已通** · **S2 接缝已接线**(customer 并行分流 + visitor 试聊 · 见 `客服Agent-合并说明.md` §3) | | 代理人助手 Agent | L2 画像、RAG、草稿 | L1 只读、Milvus | 空壳 service(chat 骨架已通) | | 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | 空壳 service(chat 骨架已通) | | 风控监测 Agent | 预警、L3、R-02 适当性 | 交易事件、AML 名单 | **已实现 B1~B9b + C1~C6(FR-1~10)**:事件线 + 对话线 + 集中度/时效升级/代理人行为链;**AL-09 已并入 `merger` 分支** | diff --git a/docs/memory/ITERATION.md b/docs/memory/ITERATION.md index 668f849..d942ff0 100644 --- a/docs/memory/ITERATION.md +++ b/docs/memory/ITERATION.md @@ -20,3 +20,5 @@ | 2026-09-09 | chat 同步/流式落库免责声明口径统一(`_assistant_content_for_persist`) | 评审:sync/stream 落盘一致性 | chat.py / test_chat | | 2026-09-08 | P1 环境收口(续):补建 `risk_aml_list` + AML 种子 · `fix_utf8_seed.py` 修复 Windows 中文乱码 · 集成测试 11 例恢复 · **530 passed 0 skipped** | 真库集成 skip/失败 | MEMORY / ENVIRONMENT / FLOW / bootstrap 脚本 | | 2026-09-09 | **四角色收益/洞察 Dashboard 需求 v2**(锚点修正·四角色 Demo 数·风控 Empty·Hook/联调) | 用户再完善需求 | wealth-dashboard spec v2 / frontend-p0 §2.5 / TODO | +| 2026-09-09 | **客服 Agent 代码合并**(`customer-service-agent` 只增不盖) | 用户要求合并上传内容 | `客服Agent-合并说明.md` · Wave1~5 新增文件 · CS-C-11 迁移 SQL · wave 测试暂 ignore | +| 2026-09-09 | **L3 Redis cache-aside 读路径补齐** + 风控/演示测试数据 TODO 拆项 | 画像 Redis 只做写侧 DEL · 测未接接口缺种子 | profile_l3 / redis_gateway / TODO | diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index 6204e60..f8153b8 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -9,7 +9,7 @@ **项目是什么:** 金融四 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座;**不**互调 LLM,跨 Agent 走 L1/L2/L3 画像与预警表。 -**当前进度:** 需求与表设计已定 · **风控模块 B1~B9b + C4~C6 + chat B/C + AL-09 合并** · **代销平台 API v0.1 已落地** · **530 passed 0 skipped** · **`web/` P0 脚手架已 init(登录+Layout+占位路由)**。**下一步:前端真接 API/SSE(含 risk Chat)· 接口契约发群。** +**当前进度:** 需求与表设计已定 · **风控模块 B1~B9b + C4~C6 + chat B/C + AL-09 合并** · **代销平台 API v0.1 已落地** · **客服 Agent S2 接缝已接线**(visitor 试聊 + customer 并行分流) · **554 passed 0 skipped** · **`web/` 静奢智能 UI 已落地** · **`npm run build/test/lint` 绿(14 例 Vitest)**。**下一步:ChatPanel/SSE · 迁移 SQL CS-C-11 · Wave 1/2/4/5 解禁。** **工作分支:** 团队开发在 **`merger`**(已 merge 风控模块);历史开发分支 `risk-control-agent` 交付冻结。旧文档中「待 AL-09 合并」口径已过时。 @@ -22,7 +22,7 @@ | `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)** | POST /api/chat(SessionGuard+归属+窗口+落盘)· **前端拉侧三端点**(`GET /sessions` / `GET /sessions/{id}/messages` / `POST /sessions/{id}/close`,方案 B `8328c24`)· **SSE 流式 `POST /api/chat/stream`**(OpenAI 兼容 chunk,方案 C `01ec5fc`);守卫抽 `_guard_request`/`_prepare_turn`/`_guard_session` 同步流式共用 · http_access 访问审计 | +| `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/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/knowledge.py` `admin.py` | 空壳 | 待审计查询台与知识库 API(T-21 拍板一期只做脚本入库,上传/重建端点不做) | | `app/service/platform/` | **已实现(v0.1)** | 封装 core_ro + `PLATFORM_RESPONSE_DESENSITIZE` 脱敏开关 | @@ -41,7 +41,7 @@ | `scripts/core/*.sql` + `reset.ps1` | **已实现** | Core 模拟库 DDL + 种子 | | `scripts/agent/` `scripts/demo/` `scripts/dev/` | **已实现** | AML 名单种子 + 风控演示数据 + `fix_utf8_seed.py`/`run_sql_file.py`(Windows UTF-8 灌库)+ issue_dev_token | | `scripts/sync/*.py` | **已实现** | 归属同步 + Neo4j 全图 | -| `tests/` | **已实现** | 37+ 测试模块 **530 用例 0 skipped**(sqlite + 真 MySQL 集成;`test_module_boundary` 宿主 D 类排除)。改路由必同步 `tests/test_main.py::test_all_routers_mounted` | +| `tests/` | **已实现** | 37+ 测试模块 **554 用例 0 skipped**(含 `test_wave3_customer_service`;sqlite + 真 MySQL 集成;`test_module_boundary` 宿主 D 类排除)。改路由必同步 `tests/test_main.py::test_all_routers_mounted` | | `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` | @@ -52,7 +52,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/` | **P0 脚手架(2026-09-09)** | 登录+Layout+四角色路由+占位页;**未接** ChatPanel/SSE、平台读 API、risk alerts;设计 `docs/superpowers/specs/2026-09-09-frontend-p0-design.md` | +| `web/` | **静奢智能 UI(2026-09-09)** | React 19 + Vite 8 + **AntD 6** + **Tailwind CSS v4**(`@tailwindcss/vite`)+ HashRouter;`components/ui`/`dashboard`/`layout` 分层 · 四角色首页真调 `customers/products/advisors/risk` · **未接** ChatPanel/SSE、共享 `/app/market`、risk 处置 UI;交接 **`docs/frontend/FRONTEND-HANDOFF.md`** | **本地 bootstrap(首次):** 完整步骤与前置说明见 `FLOW.md` §0(权威),速览: @@ -65,12 +65,14 @@ 5. mysql … < scripts/agent/seed-aml-list.sql # AML 名单(Windows 乱码:`python scripts/dev/fix_utf8_seed.py`) (风控演示:scripts/demo/prepare_risk_demo.sql,reset 后重跑) 6. python scripts/sync/sync_advisor_rel.py && python scripts/sync/sync_neo4j.py -7. uvicorn app.main:app --reload → GET /health;`docker compose up -d redis`(限流/会话窗口);python -m pytest(**530 绿**,系统 Python 3.13.14) +7. uvicorn app.main:app --reload → GET /health;`docker compose up -d redis`(限流/会话窗口);python -m pytest(**554 绿**,系统 Python 3.13.14) ``` **AL-09 合并后架构(一句话):** 宿主 `gateway/` + 模块 `deps.py` **双栈并存**;对外登录/token **统一**;chat/risk 均走模块鉴权;接缝 S2 用 `auth_adapter`。 -**下一步(见 TODO):** 前端 P0 真接(ChatPanel+customer/risk SSE、平台读页、risk alerts、market)· 接口契约发群 · Agent 合并改调 platform Service。 +**下一步(见 TODO):** 前端 ChatPanel/SSE(customer/risk/advisor)· 共享 `/app/market` · risk 台账处置 UI · 接口契约发群 · Agent 合并改调 platform Service。 + +**前端启动(`web/`):** `npm install` → `npm run dev`(`5173`,`/api` 代理 `8000`)· 验收四 demo 账号见 **`docs/frontend/FRONTEND-HANDOFF.md` §8** · 改 UI 后跑 `npm run build && npm run test && npm run lint`。 **平台 API 硬规则(2026-09-08 拍板):** 路由 **A · 按业务域**(`/api/customers` 等);与 Agent 功能重复时 **以本平台 API 为准**;命名见契约 §2;合并时统一改 canonical 路径。 @@ -155,7 +157,7 @@ Core 模拟:scripts/core/reset.ps1 · 文档 docs/项目框架设计/Core模 种子:scripts/agent/seed-aml-list.sql(AML 名单)· scripts/demo/prepare_risk_demo.sql(reset 后重跑) 依赖:requirements.txt(LangGraph + langchain-core/openai + FastAPI + SQLAlchemy) 启动:uvicorn app.main:app --reload → GET /health -测试:python -m pytest(**530 绿**;集成测试需本机 MySQL + `risk_aml_list` + `prepare_risk_demo.sql`) +测试:python -m pytest(**554 绿**;集成测试需本机 MySQL + `risk_aml_list` + `prepare_risk_demo.sql`) 运维/演示脚本:scripts/demo/subscribe_alerts.py(订阅推送演示)· rebuild_alerts.py TRD-xxx(引擎异常补偿重放) JWT 联调:python scripts/dev/issue_dev_token.py --sub STAFF-30001 --roles risk_officer(+ Authorization: Bearer + X-Agent-Type) 配置:.env(见 .env.example) @@ -185,6 +187,7 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md | `docs/项目框架设计/接口契约-代销平台API-v0.1.md` | 写/Review 代销平台 REST;Agent 合并对照 canonical 路径 | | `docs/项目框架设计/C-05-行情数据源选型对比.md` | C-05 行情/净值数据源拍板与禁止项 | | `docs/项目框架设计/接口契约-代销平台API-v0.2-行情扩展草案.md` | Phase B `nav-snapshot` 与 sync 脚本契约(未实现) | +| `docs/frontend/FRONTEND-HANDOFF.md` | **改 `web/` 必读**:启动/分层/设计令牌/组件边界/浏览器验收清单 | 缺 `docs/memory/*` 文件:按 project-memory-kit 同名补回,**禁止空模板盖进度**。 @@ -202,6 +205,6 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md 2. 改动属于 api / service / tool / repository 哪一层? 3. 是否需 customer_id 归属与 JWT RBAC? 4. Core 是模拟库只读还是 agent 库读写? -5. 如何验证?(`python -m pytest` 全量(当前 **530 绿**)· `docker compose up -d redis` · uvicorn + /health · 平台 `/api/customers/*` · `tests/test_module_boundary.py` 全绿) +5. 如何验证?(`python -m pytest` 全量(当前 **554 绿**)· `docker compose up -d redis` · uvicorn + /health · 平台 `/api/customers/*` · `tests/test_module_boundary.py` 全绿) 大任务:FRAMEWORK/FLOW 与实现状态不符时先更新 memory 再编码(用户确认跳过除外)。 diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index 03e5eec..f911d40 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -19,20 +19,79 @@ > 现状:除登录外,业务页均为 `PlaceholderPage`;`api/` 仅 `auth.ts`;无 `ChatPanel`。 -- [ ] **四角色「收益/洞察」登录首页**(`docs/superpowers/specs/2026-09-09-frontend-wealth-dashboard-design.md` · **需求 v2 已定稿**):Hero+图表+明细 · 锚点 149940/137.08/-254.40 · 理财师 12 客/1654850 · 风控 Empty+SOP 双态 -- [ ] **平台 API client**:`customers` / `products` / `risk` 等封装 + `ApiErrorResult` 贯通 +- [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/处置等见下方未接项 - [ ] **customer 只读页(一条链路跑通)**:档案 / 持仓 / 流水等 · 真调 v0.1 - [ ] **共享 `/app/market`**:`GET /api/products` + 逐产品 `GET .../nav` · 静态净值 Alert - [ ] **`ChatPanel` + SSE 解析**:sessions 列表 / messages / close · 首帧 meta/disclaimer · `[DONE]` 收尾 - [ ] **customer Chat**:`POST /api/chat/stream` · `X-Agent-Type: customer` -- [ ] **risk Chat**:`POST /api/chat/stream` · `X-Agent-Type: risk`(四 Tool 已后端就绪) -- [ ] **risk 预警台账**:`GET /api/risk/alerts` 真表格(替换 `#/app/risk/alerts` 占位) +- [ ] **risk Chat**:`POST /api/chat/stream` · `X-Agent-Type: risk`(后端 **六** 只读 Tool 已就绪,见下方风控盘点) +- [ ] **risk 预警台账全页**:`GET /api/risk/alerts` 筛选/分页表格 + `POST .../handle` 处置 UI(Dashboard 首页已只读预览) - [ ] **`/app/analytics` 模块**:PermissionGate + query 占位 + analyst Chat(矩阵 403 真展示) - [ ] **advisor Chat**:SSE + AgentBanner(能力弱处提示) -- [ ] **Vitest**:`authStore` · SSE 解析 · `mergeProductNavRows` 等(spec §5) +- [ ] **Vitest**:`authStore` · SSE 解析 · `mergeProductNavRows` 等(spec §5;Dashboard 纯函数 8 例已绿) ## 待办(模块侧开放项) +### 客服 Agent · S2 接缝(2026-09-09 已接线 · 并行分流) + +> 清单:`docs/项目框架设计/客服Agent-合并说明.md` §3–§4 · 原则只增不盖、不覆盖 `main.py`/`chat.py`/`agent_service.py` 整体。 + +- [ ] **跑迁移 SQL**:`scripts/agent/migrate-customer-agent-cs-c11.sql`(`conversation_archive` · `customer_notes`) +- [x] **挂游客路由**:`POST /api/chat/visitor` → `main.py` +- [x] **编排接缝(并行)**:`X-Agent-Type: customer` → `customer_service.run_customer_chat`;其余 Agent 仍 `agent_service.chat` +- [x] **Wave 3 测试解禁**:`test_wave3_customer_service.py` 19 例绿 · 基线 **554 passed** +- [ ] **Wave 1/2/4/5 解禁**:`tests/conftest.py` 移出剩余 `collect_ignore` · 修接缝后全 wave 绿 +- [ ] **可选接线**:`profile_service` / `note_service` / 客服 KB 脚本与 Milvus 集合(`scripts/kb/*`)— 按需求择项 + +### 风控 Agent · 后端已就绪 · 前端/运维未接(盘点 2026-09-09) + +**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) + +**对话 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`(接线前必修) + +**运维 / 动态演示(无 UI · 见 `演示SOP-风控模块.md`)** + +- [ ] **`scripts/demo/subscribe_alerts.py`**:订阅 Redis `risk:pub:alert` 实时推送(Pub/Sub 无持久化,须先开订阅再发交易) +- [ ] **`scripts/cron/escalation_scan.py`**:超期未处置升级(FR-9 · 需库内已有 `pending_review` 且 `created_at` 超阈值) +- [ ] **`scripts/cron/agent_behavior_scan.py`**:代理人行为链扫描(FR-10 · 需 audit_log 先积累越权/诱导交易痕迹) +- [ ] **`scripts/demo/rebuild_alerts.py`**:按 `trade_id` 幂等重放引擎(engine_error 补偿) + +**演示种子 / 测试数据补充(测未接接口 · 非 everyday `reset.ps1`)** + +> Core reset 后 **`risk_alert` 为空**、AML/测评可能未就位;测预警/处置/推送/FR-9/FR-10 须先补数据。 + +- [ ] **`scripts/agent/seed-aml-list.sql`**:8 条 AML 名单(A-5:`CUST-1002` 展示名故意命中) +- [ ] **`scripts/demo/prepare_risk_demo.sql`**:6 客户测评刷新(可交易);`CUST-1004` 保持过期(FM-03 阻断演示) +- [ ] **风控走查重灌(SOP §2)**:双库 DROP + agent 表 + 上两项 + `sync_advisor_rel`(`reset.ps1` **只重建 core**) +- [ ] **动态造预警 · A-1~A-5**:`POST /api/simulate/trade`(Swagger/curl)— A-3 `CUST-3001`/`PROD-510300`/50 万 · A-4 `CUST-9527` 连发 4 笔 · A-5 `CUST-1002`/`PROD-005828`/100 +- [ ] **Redis 实时推送演示**:终端 1 `subscribe_alerts.py` → 终端 2 发 simulate/trade(Pub/Sub 无持久化) +- [ ] **FR-9 超期升级**:库内 `pending_review` 且 `created_at` 超 `RISK_ESCALATION_L1_HOURS`(默认 4h)→ `escalation_scan.py` +- [ ] **FR-10 代理人行为链**:A-12 ① 同一 `X-Debug-Actor=STAFF-10087` 发 3 组赎回+申购 + 越权 audit → `agent_behavior_scan.py` +- [ ] **L3 监测档数据**:A-5 AML 命中后 `customer_profile_l3.monitor_tier=high`(测 `customer_context` Tool / Dashboard 高风险客户数) +- [ ] **适当性阻断样例**:A-1 `CUST-1001`/`PROD-161725`/1 万 · A-2 `CUST-4001`/2 万(测 handle/台账 suitability 单) +- [ ] **引擎补偿**:`rebuild_alerts.py `(engine_error 场景) +- [ ] **可选 dev 静态预警 seed**:2~3 条 `pending_review` 样例(免每次 simulate;与集成测试当日窗冲突须文档说明) +- [ ] **链式 reset 脚本**:AML + prepare 并入 `reset.ps1` 或新增 `scripts/demo/prepare_all.ps1` + +### 画像 Redis · cache-aside(2026-09-09) + +- [x] **L3 热读**:`get_profile_l3` cache-aside(读 Redis miss → MySQL → SET EX 5m;写 upsert DEL)· `customer_context` Tool 已走此路径 +- [ ] **L1 热读/写**:`ProfileHotCache` 仅在客服线 `customer_service.py` · 宿主 `POST /api/chat` 未接 +- [ ] **L2 热读/写**:表与 JWT scope 已有 · Repository/Redis **未实现** + + - [ ] chat 链路 `risk_suitability_log.actor_id` 暂落 SYSTEM(run_tool 统一透传需动 core_tools/kb_tools 冻结 func 契约,发起者留痕已由 agent_tool_call 覆盖)——AL-06 遗留,动 Tool 契约前先出方案 - [x] **【前端接入·方案 B】chat 拉侧三端点**(commit `8328c24`,2026-09-08):`GET /api/chat/sessions`(本人+本 Agent 线分页列表)、`GET /api/chat/sessions/{id}/messages`(历史消息升序分页,closed 仍可读)、`POST /api/chat/sessions/{id}/close`(active→closed,重复/非 active 409);chat.py 抽 `_resolve_agent_type`/`_assert_chat_entry`/`_guard_session` 三守卫供四端点共用,POST "" 行为零回归;risk_manager 在对话线数据面保持 403(PRD 4A.1)。测试 482→**494 绿**;独立评审 P0=0,P1(close 并发 rowcount 静默 200)已修复 - [x] **【前端接入·方案 C】SSE 流式对话**(commit `01ec5fc`,2026-09-08):新增 `POST /api/chat/stream`(OpenAI 兼容 chunk:首帧 meta → delta → finish_reason=stop → `[DONE]`);`agent_service.stream_chat` 生成器 + `needs_disclaimer`;`_guard_request`/`_prepare_turn` 与同步端点共用守卫;`session_repository.insert_turn` user+assistant 同事务落库(修评审 P0/P1)。测试 494→**503 绿**。遗留:无心跳帧、断连留空会话待清理 diff --git a/docs/superpowers/plans/2026-09-09-frontend-modernization.md b/docs/superpowers/plans/2026-09-09-frontend-modernization.md new file mode 100644 index 0000000..1f8dd62 --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-frontend-modernization.md @@ -0,0 +1,1104 @@ +# JinRong 前端现代化重构实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在不改变现有 API、鉴权、路由、数据聚合和交互契约的前提下,将 `web/` 全面重构为 Tailwind CSS + Ant Design 混合的「静奢智能」前端,并交付可供后续 agent 使用的组件基础和交接手册。 + +**Architecture:** Tailwind CSS v4 负责设计令牌、布局、排版、响应式和轻量动效;Ant Design 6 继续负责 Table、Alert、Result、Empty、Skeleton、message 等复杂交互,现有 `api/`、`hooks/`、`utils/` 和路由逻辑保持不变。新增的 `components/ui` 只处理纯展示,`components/layout` 只组合应用壳层,`components/dashboard` 只接收已计算的数据,页面继续负责 hooks、路由和业务文案。 + +**Tech Stack:** React 19、Vite 8、TypeScript 6、Tailwind CSS 4、`@tailwindcss/vite`、Ant Design 6、`@ant-design/charts`、React Router 7、Vitest。 + +**Spec:** `docs/superpowers/specs/2026-09-09-frontend-modernization-design.md` + +## Global Constraints + +- API URL、HTTP method、参数、请求头和 `apiFetch` 行为不变。 +- `AuthState` 字段、`jinrong.auth` localStorage key、路由 path 和登录默认落点不变。 +- 不修改现有 Dashboard hooks、聚合纯函数、金额/百分比 formatter 或图表数据公式。 +- Ant Design 继续承载 Table、Pagination、Alert、Result、Empty、Skeleton、message 和现有图表行为。 +- `components/ui` 禁止导入 `api`、`hooks`、`authStore` 或角色业务类型。 +- 页面层只替换 JSX 外壳、className 和组合组件,不把请求逻辑迁入视觉组件。 +- 新视觉令牌统一使用 `#F6F5F1`、`#FBFAF7`、`#132B3A`、`#17212B`、`#71808A`、`#E5E2DB`、`#178A68`、`#C9564A`、`#C9A66B`、`#3F718A`;页面不得硬编码新的品牌色。 +- 收益语义保持正值朱砂红、负值翡翠绿、零值次级灰;不能因视觉迁移改变业务语义。 +- 不使用全局 `!important` 覆盖 Ant Design 内部行为,不添加假数据或错误兜底。 +- 每个任务完成后运行该任务列出的验证命令;完整交付前必须通过 `npm run build`、`npm run test`、`npm run lint`。 +- 不删除或覆盖用户已有的未提交改动;修改文件前检查当前 diff,变更集中在本计划列出的文件。 +- 不在本计划中创建 git commit,除非用户另行明确要求提交。 + +--- + +## 文件地图与责任 + +### 将创建的文件 + +- `web/src/styles/tokens.css`:JinRong 颜色、字体、圆角、阴影和动效令牌。 +- `web/src/lib/cn.ts`:无依赖的 className 合并工具。 +- `web/src/components/ui/Button.tsx`:纯展示按钮变体和尺寸。 +- `web/src/components/ui/Surface.tsx`:面板/卡片表面变体。 +- `web/src/components/ui/Badge.tsx`:角色、状态、环境和风险标签。 +- `web/src/components/ui/MetricCard.tsx`:通用指标卡。 +- `web/src/components/ui/PageHeader.tsx`:页面 eyebrow、标题、说明和 action。 +- `web/src/components/ui/SectionHeading.tsx`:区块标题和辅助操作。 +- `web/src/components/ui/EmptyState.tsx`:品牌化空状态。 +- `web/src/components/ui/index.ts`:基础 UI 组件统一导出。 +- `web/src/components/layout/AppShell.tsx`:侧栏、顶栏、主内容区组合器。 +- `web/src/components/layout/BrandSidebar.tsx`:品牌侧栏和菜单渲染。 +- `web/src/components/layout/Topbar.tsx`:顶栏上下文、actor ID、退出。 +- `web/src/components/layout/index.ts`:布局组件统一导出。 +- `web/src/components/dashboard/ChartCard.tsx`:图表表面和标题组合。 +- `web/src/components/dashboard/DataSection.tsx`:数据区标题与表格表面。 +- `web/src/components/dashboard/index.ts`:Dashboard 组件统一导出。 +- `web/src/components/ui/__tests__/ui.test.tsx`:基础 UI 组件可渲染、变体和可访问性 smoke tests。 +- `docs/frontend/FRONTEND-HANDOFF.md`:后续 agent 的启动、组件、数据和验收手册。 + +### 将修改的文件 + +- `web/package.json`:加入 Tailwind v4 与 Vite 插件依赖。 +- `web/package-lock.json`:由 npm 安装同步生成,不能手工删改无关 lock 内容。 +- `web/vite.config.ts`:注册 `tailwindcss()` Vite 插件,保留 React、proxy 和 Vitest。 +- `web/src/main.tsx`:改为加载统一 token/global 样式入口(如实现需要,保留现有 AntD ConfigProvider)。 +- `web/src/theme.ts`:同步 JinRong 色板、圆角、Layout、Table 和状态组件 token。 +- `web/src/styles/global.css`:重写 reset、AntD bridge、滚动条、focus 和通用动画。 +- `web/src/index.css`:清除 Vite starter CSS,使其只保留必要的 Tailwind 入口或删除引用。 +- `web/src/App.css`:清除 Vite starter `.counter/.hero/#next-steps` 等无引用规则;如果迁移后无引用则删除文件和 import。 +- `web/src/layouts/AppLayout.tsx`:保留 auth/menu/location 逻辑,改为组装 `AppShell`、`BrandSidebar` 和 `Topbar`。 +- `web/src/pages/login/LoginPage.tsx`:保留登录请求和跳转,重做品牌区与账号卡片。 +- `web/src/components/PageShell.tsx`:改为复用 `PageHeader` 和新内容间距。 +- `web/src/pages/PlaceholderPage.tsx`:改为 `PageHeader + EmptyState`。 +- `web/src/components/ApiErrorResult.tsx`:品牌化错误面板,保留 error code、message、trace ID、retry。 +- `web/src/components/AgentBanner.tsx`:统一 Agent 提示样式,保留 message 和 info 语义。 +- `web/src/components/dashboard/DashboardHero.tsx`:改为新 Hero/MetricCard 组合,保留金额掩码和指标 props。 +- `web/src/components/dashboard/QuickActionBar.tsx`:改为 ActionRail,保留每个 `to` 和 Link 行为。 +- `web/src/components/dashboard/DashboardCharts.tsx`:只增加 `ChartCard` 包装和新颜色配置,保留图表数据、延迟挂载和错误边界。 +- `web/src/components/dashboard/DashboardLayout.tsx`:改为统一 PageHeader、Notice、Hero、ActionRail、InsightGrid、DataSection。 +- `web/src/pages/dashboard/CustomerWealthDashboard.tsx`:只迁移 JSX 组合和文案层级。 +- `web/src/pages/dashboard/AdvisorClientsDashboard.tsx`:只迁移 JSX 组合和文案层级。 +- `web/src/pages/dashboard/AnalystMarketDashboard.tsx`:只迁移 JSX 组合和文案层级。 +- `web/src/pages/dashboard/RiskAlertsDashboard.tsx`:只迁移 JSX 组合和文案层级。 + +### 明确不应修改的文件 + +- `web/src/api/**` +- `web/src/hooks/**` +- `web/src/utils/**` +- `web/src/stores/authStore.ts` +- `web/src/routes/menus.tsx` +- `web/src/App.tsx` +- `web/src/config/demoAccounts.ts` +- 现有纯函数测试,除非新样式迁移暴露真实类型错误。 + +--- + +### Task 1: Tailwind 基础设施与设计令牌 + +**Files:** +- Modify: `web/package.json` +- Modify: `web/package-lock.json` +- Modify: `web/vite.config.ts` +- Create: `web/src/styles/tokens.css` +- Modify: `web/src/styles/global.css` +- Modify: `web/src/index.css` +- Modify: `web/src/main.tsx` + +**Interfaces:** +- Produces the global Tailwind utility layer and CSS variables consumed by every later UI component. +- Preserves Vite `/api` proxy, React plugin, Vitest `jsdom` environment, and existing AntD `ConfigProvider`. + +- [ ] **Step 1: Record the current baseline before dependency changes** + +Run from `D:\项目\JinRong\web`: + +```bash +npm run build +npm run test +npm run lint +``` + +Expected: record the current result; if a command already fails, note the pre-existing failure and do not silently attribute it to this task. + +- [ ] **Step 2: Install the Tailwind v4 Vite integration** + +Run: + +```bash +npm install -D tailwindcss@^4.1.0 @tailwindcss/vite@^4.1.0 +``` + +Expected: `package.json` and `package-lock.json` gain only the Tailwind packages and their transitive dependencies; existing runtime dependencies remain unchanged. + +- [ ] **Step 3: Register the Vite plugin without changing the dev proxy** + +Update `web/vite.config.ts` to preserve the current React plugin and server settings while adding the Tailwind plugin: + +```ts +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + }, + }, + }, + test: { + environment: 'jsdom', + }, +}) +``` + +- [ ] **Step 4: Add the theme source and exact JinRong tokens** + +Create `web/src/styles/tokens.css`: + +```css +@import "tailwindcss"; + +@theme { + --color-jr-bg: #f6f5f1; + --color-jr-surface: #fbfaf7; + --color-jr-surface-strong: #ffffff; + --color-jr-ink: #132b3a; + --color-jr-text: #17212b; + --color-jr-muted: #71808a; + --color-jr-border: #e5e2db; + --color-jr-positive: #178a68; + --color-jr-negative: #c9564a; + --color-jr-gold: #c9a66b; + --color-jr-info: #3f718a; + --radius-jr-sm: 8px; + --radius-jr-md: 12px; + --radius-jr-lg: 16px; + --shadow-jr-surface: 0 12px 32px rgb(32 42 48 / 6%); + --shadow-jr-float: 0 18px 44px rgb(32 42 48 / 10%); + --font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +:root { + --jr-bg: #f6f5f1; + --jr-surface: #fbfaf7; + --jr-surface-strong: #ffffff; + --jr-ink: #132b3a; + --jr-text: #17212b; + --jr-muted: #71808a; + --jr-border: #e5e2db; + --jr-positive: #178a68; + --jr-negative: #c9564a; + --jr-gold: #c9a66b; + --jr-info: #3f718a; + --jr-radius-sm: 8px; + --jr-radius-md: 12px; + --jr-radius-lg: 16px; + --jr-shadow-surface: 0 12px 32px rgb(32 42 48 / 6%); + --jr-shadow-float: 0 18px 44px rgb(32 42 48 / 10%); +} +``` + +- [ ] **Step 5: Replace starter global CSS with the shared reset and AntD bridge** + +Update `web/src/styles/global.css` to import tokens and define only global concerns: + +```css +@import './tokens.css'; + +:root { + color: var(--jr-text); + background: var(--jr-bg); + font-family: var(--font-sans); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +html, +body, +#root { + min-height: 100%; + margin: 0; +} + +body { + min-width: 320px; + background: var(--jr-bg); +} + +button, +a, +input, +textarea, +select { + font: inherit; +} + +button:focus-visible, +a:focus-visible, +[role='button']:focus-visible { + outline: 2px solid var(--jr-gold); + outline-offset: 3px; +} + +.tabular-nums { + font-variant-numeric: tabular-nums; +} + +.ant-layout, +.ant-layout-content { + background: transparent; +} + +.ant-table-wrapper .ant-table { + background: transparent; +} + +.ant-table-wrapper .ant-table-thead > tr > th { + background: color-mix(in srgb, var(--jr-bg) 72%, white); + color: var(--jr-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +@keyframes jr-fade-up { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.jr-page-enter { + animation: jr-fade-up 320ms ease-out both; +} +``` + +- [ ] **Step 6: Make stylesheet imports single-source and remove starter declarations** + +Update `web/src/main.tsx` to keep one global style import: + +```ts +import './styles/global.css' +``` + +Remove the old `import './index.css'` if it exists. Empty `web/src/index.css` and `web/src/App.css` may remain temporarily for this task, but remove their imports and all Vite starter rules in Task 5 only after a reference search confirms they are unused. + +- [ ] **Step 7: Verify infrastructure** + +Run: + +```bash +npm run build +npm run test +npm run lint +``` + +Expected: all commands pass, with no API or test behavior changes. If Tailwind’s generated CSS reports unsupported `color-mix`, replace only the bridge declaration with a static fallback while retaining the same token value and visual intent. + +--- + +### Task 2: Pure UI primitives and component tests + +**Files:** +- Create: `web/src/lib/cn.ts` +- Create: `web/src/components/ui/Button.tsx` +- Create: `web/src/components/ui/Surface.tsx` +- Create: `web/src/components/ui/Badge.tsx` +- Create: `web/src/components/ui/MetricCard.tsx` +- Create: `web/src/components/ui/PageHeader.tsx` +- Create: `web/src/components/ui/SectionHeading.tsx` +- Create: `web/src/components/ui/EmptyState.tsx` +- Create: `web/src/components/ui/index.ts` +- Create: `web/src/components/ui/__tests__/ui.test.tsx` + +**Interfaces:** +- Consumes: CSS classes and tokens from Task 1. +- Produces: pure components with no imports from `api`, `hooks`, `stores`, `routes`, or dashboard-specific types. +- `Button`: `{ variant?: 'primary' | 'secondary' | 'ghost' | 'quiet'; size?: 'sm' | 'md' | 'lg'; loading?: boolean } & React.ButtonHTMLAttributes`. +- `Surface`: `{ variant?: 'default' | 'subtle' | 'dark'; padding?: 'none' | 'sm' | 'md' | 'lg'; as?: ElementType }` plus intrinsic props. +- `Badge`: `{ tone?: 'neutral' | 'info' | 'positive' | 'negative' | 'gold' | 'ink'; children: ReactNode }`. +- `MetricCard`: `{ label: string; value: ReactNode; detail?: ReactNode; trend?: ReactNode; emphasis?: 'default' | 'hero' }`. +- `PageHeader`: `{ eyebrow?: string; title: string; description?: ReactNode; action?: ReactNode }`. +- `SectionHeading`: `{ eyebrow?: string; title: string; description?: ReactNode; action?: ReactNode }`. +- `EmptyState`: `{ image?: string; title: string; description?: ReactNode; action?: ReactNode }`. + +- [ ] **Step 1: Write the class merge helper** + +Create `web/src/lib/cn.ts` with a deliberately dependency-free implementation: + +```ts +export function cn(...values: Array) { + return values.filter(Boolean).join(' ') +} +``` + +- [ ] **Step 2: Add the failing primitive tests** + +Create `web/src/components/ui/__tests__/ui.test.tsx`: + +```tsx +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Badge, Button, EmptyState, MetricCard, PageHeader, Surface } from '../index' + +describe('JinRong UI primitives', () => { + it('merges only truthy class names', async () => { + const { cn } = await import('../../../lib/cn') + expect(cn('a', false, undefined, 'b')).toBe('a b') + }) + + it('renders a primary button with an accessible label', () => { + render() + expect(screen.getByRole('button', { name: '刷新数据' })).toHaveClass('bg-jr-ink') + }) + + it('renders a metric card with value and detail', () => { + render() + expect(screen.getByText('总市值')).toBeInTheDocument() + expect(screen.getByText('149,940.00')).toBeInTheDocument() + expect(screen.getByText('持仓市值合计')).toBeInTheDocument() + }) + + it('renders page and section semantics without business dependencies', () => { + render( + <> + + 内容 + 运行正常 + + , + ) + expect(screen.getByRole('heading', { name: '我的资产' })).toBeInTheDocument() + expect(screen.getByLabelText('资产面板')).toBeInTheDocument() + expect(screen.getByText('运行正常')).toBeInTheDocument() + expect(screen.getByText('暂无持仓')).toBeInTheDocument() + }) +}) +``` + +Run: + +```bash +npm run test -- src/components/ui/__tests__/ui.test.tsx +``` + +Expected: FAIL because the new components do not exist yet. + +- [ ] **Step 3: Implement `Button`, `Surface`, and `Badge` minimally** + +Use `forwardRef`, native button semantics, and class variants. The core class choices must include: + +```ts +const buttonVariants = { + primary: 'bg-jr-ink text-white shadow-sm hover:bg-[#1d3b4d]', + secondary: 'border border-jr-border bg-jr-surface-strong text-jr-ink hover:border-jr-ink', + ghost: 'text-jr-ink hover:bg-jr-bg', + quiet: 'text-jr-muted hover:text-jr-ink', +} as const +``` + +Avoid adding a new arbitrary brand token for the hover color; if the inline literal is rejected by project policy, use `hover:brightness-95` instead. `loading` renders a text-neutral `
+ +``` + +Record supported button, surface and badge variants and explain that components accept ReactNode slots rather than owning data fetching. + +- [ ] **Step 5: Document how to add a role Dashboard and API hook** + +Describe the required sequence: + +1. Add or reuse an API function under `src/api`. +2. Keep loading/error/refresh logic in a hook under `src/hooks`. +3. Keep aggregation and formatting in `src/utils` where applicable. +4. Compose `PageHeader`, `DashboardHero`, `ChartCard`, `DataSection` and `EmptyState` in the page. +5. Preserve `ApiErrorResult`, no-fake-data behavior, and route links. +6. Add the menu and route only in their existing owners (`routes/menus.tsx`, `App.tsx`) rather than duplicating navigation. + +- [ ] **Step 6: Document state and AntD boundaries** + +Include the state matrix: + +```text +Loading → Skeleton while shell remains visible +Success → shared surfaces + existing Table/Charts +Empty → EmptyState/AntD Empty, never fake numbers +Error → ApiErrorResult with error code and trace ID +``` + +State that AntD owns Table sorting/pagination, Alert/Result/Empty/Skeleton, message, and chart internals; Tailwind owns surrounding layout and visual hierarchy. Prohibit global `!important` overrides and business imports in `components/ui`. + +- [ ] **Step 7: Add the browser acceptance checklist and common pitfalls** + +Include checks for all four demo roles, default routes, local table overflow, amount masking, red-up/green-down semantics, risk disclaimer, error trace ID, static-nav disclaimer, and the three npm commands. List pitfalls: + +- do not move API calls into visual components; +- do not add fallback demo numbers on API errors; +- do not hard-code a new color or spacing value in a page; +- do not replace a route Link with a click-only button; +- do not change `AuthState` or `jinrong.auth` while restyling; +- do not delete a shared component without checking downstream agent work. + +- [ ] **Step 8: Verify documentation references real files** + +Run a source search or manually check every referenced path and command. Then run: + +```bash +cd web +npm run build +npm run test +npm run lint +``` + +Expected: handoff paths match the final tree and all automated checks pass. + +--- + +## Final self-review checklist + +- [ ] Every spec section has an implementation task: tokens/architecture (Tasks 1–2), shell and shared states (Task 3), Dashboard composition (Task 4), login and four pages (Task 5), responsive/AntD bridge (Task 6), browser acceptance (Task 7), and handoff documentation (Task 8). +- [ ] No task changes `api/`, `hooks/`, `utils/`, auth storage, or route contracts. +- [ ] All component interfaces used by later tasks are defined in the earlier task that produces them. +- [ ] All new tests include concrete assertions and a targeted command. +- [ ] No `TODO`, `TBD`, “appropriate handling”, or unspecified implementation placeholder remains. +- [ ] The plan does not require a git commit unless the user explicitly requests one. +- [ ] The plan includes the required 149940.00 / 137.08 / -254.40, 12 / 1654850.00, 14 / 10 / 4 and risk-empty regression checks. +- [ ] The handoff manual is created only after the final component APIs and paths are stable. + +## Execution handoff + +Plan complete and saved to `docs/superpowers/plans/2026-09-09-frontend-modernization.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — dispatch a fresh subagent per task with review between tasks; useful for the multi-file visual migration while keeping each boundary independently checked. +2. **Inline Execution** — execute the tasks in this session using the executing-plans skill with checkpoints. + +Choose one approach before implementation begins. diff --git a/docs/superpowers/specs/2026-09-09-frontend-modernization-design.md b/docs/superpowers/specs/2026-09-09-frontend-modernization-design.md new file mode 100644 index 0000000..34e89e9 --- /dev/null +++ b/docs/superpowers/specs/2026-09-09-frontend-modernization-design.md @@ -0,0 +1,377 @@ +# JinRong 前端现代化重构设计 + +> 日期:2026-09-09 +> 状态:设计已获用户确认,待实施计划 +> 关联:`docs/superpowers/specs/2026-09-09-frontend-p0-design.md`、`docs/superpowers/specs/2026-09-09-frontend-wealth-dashboard-design.md` + +## 1. 背景与目标 + +当前 `web/` 已具备 React 19、Vite、TypeScript、React Router、Ant Design、四角色 Dashboard、API hooks 和基础测试,但页面仍大量使用 Ant Design 默认视觉、Vite 初始 CSS 与内联样式,整体更像通用后台而不是成熟的金融科技产品。 + +本次重构的目标是: + +1. 在不改变现有业务行为的前提下,将整个前端统一为 B「静奢智能」视觉方向。 +2. 建立可被后续 agent 直接复用的 Tailwind UI 基础组件和页面组合组件。 +3. 保留现有 API、鉴权、路由、数据聚合和复杂交互,降低功能回归风险。 +4. 为登录页、应用壳层、四个真实 Dashboard、占位页、错误页和 Agent 提示建立一致的状态与视觉规范。 +5. 提供可执行的交接手册,使后续功能接入不依赖本次重构的隐含上下文。 + +## 2. 非目标 + +本次不做以下事项: + +- 不更换后端 API、接口字段、请求参数或鉴权协议。 +- 不重写现有数据 hooks、聚合纯函数、金额/百分比 formatter 或路由契约。 +- 不实现 Chat、SSE、完整持仓页、预警处置和问数能力等原有待办业务。 +- 不把所有 Ant Design 交互控件替换成自研 Headless 组件。 +- 不引入 Next.js、服务端渲染、全局状态管理或新的数据请求框架。 +- 不在本次范围内增加实时行情、K 线、自选产品或交易 CTA。 + +## 3. 方案与技术边界 + +采用 Tailwind CSS 与 Ant Design 的混合方案: + +```text +React 19 +├── Tailwind CSS 负责设计令牌、布局、间距、排版、颜色、响应式和轻量动效 +├── Ant Design 保留 Table、Pagination、Alert、Result、Empty、Skeleton、message 等交互 +├── @ant-design/charts 保留现有图表实现和数据配置 +├── React Router 保留现有路由与鉴权跳转 +└── API + hooks 保留现有数据请求和聚合链路 +``` + +### 3.1 Tailwind 的职责 + +Tailwind 负责新视觉层,包括: + +- 暖白/米灰页面背景; +- 墨蓝品牌色和正文色; +- 翡翠绿、朱砂红、浅金色等语义色; +- 卡片边框、圆角、阴影和状态层级; +- 页面网格、响应式布局、标题层级和数字排版; +- hover、focus、淡入等低强度反馈动效。 + +### 3.2 Ant Design 的职责 + +Ant Design 继续承载已经验证的复杂行为: + +- 数据表格、排序、分页和空数据渲染; +- Alert、Result、Empty、Skeleton 等状态组件; +- toast/message; +- 图表组件及其 tooltip、legend 和数据配置。 + +新代码不通过全局 `!important` 破坏 Ant Design 内部行为。视觉适配使用组件 wrapper、`className`、局部选择器和主题变量。 + +## 4. 设计语言 + +### 4.1 视觉定位 + +「静奢智能」强调长期财富管理场景需要的可信、克制和精致,同时吸收 Stripe、Linear、Notion 等科技产品的留白、信息层次和细节质感。 + +- 不使用大面积高饱和渐变; +- 不使用重玻璃拟态或强烈霓虹效果; +- 不用装饰图替代数据层级; +- 通过排版、留白、细边框、微阴影和状态色建立高级感; +- 数据页保持足够密度,避免为了视觉留白牺牲可读性。 + +### 4.2 设计令牌 + +| 用途 | 令牌 | 值 | +| --- | --- | --- | +| 页面背景 | `--jr-bg` | `#F6F5F1` | +| 内容面 | `--jr-surface` | `#FBFAF7` | +| 纯白面 | `--jr-surface-strong` | `#FFFFFF` | +| 墨蓝主色 | `--jr-ink` | `#132B3A` | +| 标题文字 | `--jr-text` | `#17212B` | +| 次级文字 | `--jr-muted` | `#71808A` | +| 边框 | `--jr-border` | `#E5E2DB` | +| 翡翠绿 | `--jr-positive` | `#178A68` | +| 朱砂红 | `--jr-negative` | `#C9564A` | +| 浅金 | `--jr-gold` | `#C9A66B` | +| 信息蓝 | `--jr-info` | `#3F718A` | +| 大圆角 | `--jr-radius-lg` | `16px` | +| 普通圆角 | `--jr-radius-md` | `12px` | +| 小圆角 | `--jr-radius-sm` | `8px` | +| 内容阴影 | `--jr-shadow` | 低强度暖灰阴影 | + +收益语义继续遵循现有产品约定:正值朱砂红、负值翡翠绿、零值次级灰。令牌集中定义,页面不硬编码新的品牌色。 + +### 4.3 字体与数字 + +中文使用系统 UI 字体栈,数字和关键金额使用等宽数字特性: + +```css +font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", + "PingFang SC", "Microsoft YaHei", sans-serif; +font-variant-numeric: tabular-nums; +``` + +## 5. 目录与组件边界 + +目标目录如下: + +```text +web/src/ +├── components/ +│ ├── ui/ +│ │ ├── Button.tsx +│ │ ├── Surface.tsx +│ │ ├── Badge.tsx +│ │ ├── MetricCard.tsx +│ │ ├── PageHeader.tsx +│ │ ├── SectionHeading.tsx +│ │ ├── EmptyState.tsx +│ │ └── cn.ts +│ ├── layout/ +│ │ ├── AppShell.tsx +│ │ ├── BrandSidebar.tsx +│ │ └── Topbar.tsx +│ └── dashboard/ +│ ├── DashboardLayout.tsx +│ ├── DashboardHero.tsx +│ ├── ChartCard.tsx +│ ├── QuickActionBar.tsx +│ └── DataSection.tsx +├── pages/ +├── hooks/ +├── api/ +├── routes/ +├── stores/ +├── utils/ +└── styles/ + ├── tokens.css + └── global.css +``` + +### 5.1 `components/ui` + +纯视觉基础组件,不导入 API、hooks、authStore 或业务类型: + +- `Button`:有限的 variant/size,统一 focus、disabled、loading 视觉;链接场景通过 props 或外部 `Link` 组合。 +- `Surface`:统一面板边框、圆角、内边距和层级变体。 +- `Badge`:角色、状态、环境和风险标签。 +- `MetricCard`:标题、主值、辅助说明、趋势/状态插槽。 +- `PageHeader`:eyebrow、标题、说明和右侧 action 区。 +- `SectionHeading`:数据区标题、说明和扩展操作。 +- `EmptyState`:品牌化空状态,支持插画、说明和下一步入口。 +- `cn`:合并 className 的小型工具,不承载业务逻辑。 + +每个组件都使用明确的 TypeScript props,并通过有限变体避免页面自由拼接出不一致的样式。 + +### 5.2 `components/layout` + +应用级布局只接收 props: + +- `AppShell`:组合侧栏、顶栏和内容区,不发起 API 请求。 +- `BrandSidebar`:接收角色、菜单项、当前路径、折叠状态和导航回调;继续消费 `buildMenuGroups()` 产出的菜单。 +- `Topbar`:接收角色、actor ID、当前页面上下文、折叠回调和退出回调。 + +布局组件不读取 `authStore`,由 `AppLayout` 负责组装现有 auth 数据和路由状态。 + +### 5.3 `components/dashboard` + +只负责 Dashboard 的视觉组合和布局,不改变数据: + +- `DashboardLayout`:统一 PageHeader、Notice、Hero、ActionRail、InsightGrid 和 DataSection。 +- `DashboardHero`:主指标、辅助指标、金额掩码和角色状态。 +- `ChartCard`:图表标题、说明和图表容器。 +- `QuickActionBar`:快捷入口卡片/按钮。 +- `DataSection`:表格标题、数据范围、AntD Table wrapper。 + +页面继续持有 `useHoldingsDashboard`、`useAdvisorRosterDashboard`、`useMarketSnapshot`、`useAlertsDashboard`,并把结果传入组合组件。 + +## 6. 页面设计 + +### 6.1 登录页 + +结构: + +```text +LoginPage +├── BrandPanel +│ ├── Logo + 品牌名 +│ ├── 产品定位和环境标识 +│ ├── 轻量金融插画 +│ └── 产品能力摘要 +└── LoginPanel + ├── eyebrow / 标题 / 说明 + ├── 四个演示账号卡片 + └── Mock JWT / FastAPI 环境说明 +``` + +行为保持不变:点击账号仍调用 `login()`,成功后保存 `AuthState`、显示成功消息并跳转账号的 `defaultRoute`;失败仍显示 `ApiError` 信息。 + +账号卡片只增加角色标签、actor ID 和工作台描述,不改变账号配置或登录请求。 + +### 6.2 应用壳层 + +```text +AppLayout +└── AppShell + ├── BrandSidebar + ├── Topbar + └── MainContent + └── Outlet +``` + +- 展开态侧栏保留品牌、角色上下文和菜单分组; +- 折叠态保留 Logo 和图标,确保 tooltip/可访问名称可用; +- 顶栏保留 actor ID 复制、角色标识和退出入口; +- 当前路由选中规则继续基于 `location.pathname`; +- 宽屏使用固定侧栏,窄屏使用抽屉或可收起侧栏,不改变菜单路径; +- 内容区不设置会压缩数据表格的固定最大宽度。 + +### 6.3 四个 Dashboard + +统一结构: + +```text +PageHeader +Notice +DashboardHero +QuickActionBar +InsightGrid +DataSection +``` + +各页只改变文案、指标和现有数据配置: + +| 页面 | Hero | 图表 | 表格 | +| --- | --- | --- | --- | +| 客户资产 | 总市值、昨日收益、累计盈亏、持仓只数 | 持仓结构、产品盈亏 | 现有 HoldingRow | +| 理财师客户 | 客户数、客户总市值、最大客户、待跟进 | 客户资产结构、Top 客户 | 现有 AdvisorRosterRow | +| 分析员市场 | 产品数、上涨、下跌、平盘 | 产品类型分布、类型平均涨跌 | 现有 ProductRow Top10 | +| 风控监测 | 待审总数、今日新增、涉及客户 | 预警类型分布、平均风险分 | 现有 RiskAlertItem | + +现有列、默认排序、分页、行操作、数据截至文案、金额掩码、收益颜色、风控 disclaimer 和图表数据均保持原行为。 + +### 6.4 占位页、错误页与 Agent 提示 + +- `PlaceholderPage`:使用 `PageHeader + EmptyState + 返回/下一步入口`,不显示假 KPI。 +- `ApiErrorResult`:使用品牌化错误面板,但继续展示 error code、message、trace ID 和重试按钮。 +- `AgentBanner`:保留 info/warning 语义,统一成窄幅提示面板。 +- `Empty`、`Skeleton`、`Alert` 等 AntD 组件通过 wrapper 进入新的 Surface 层,不改变状态触发条件。 + +## 7. 数据与功能不变约束 + +以下契约在迁移前后必须一致: + +```text +API URL、HTTP method、参数和请求头 不变 +AuthState 字段和 localStorage key 不变 +路由 path 和登录默认落点 不变 +Hook 返回字段和加载/错误生命周期 不变 +聚合纯函数和数值公式 不变 +金额、百分比、收益颜色语义 不变 +表格排序、分页、行操作和跳转 不变 +错误码、trace_id、disclaimer 展示 不变 +``` + +页面层仅替换 JSX 外壳、className 和组合组件;不把请求逻辑迁移进视觉组件,也不使用假数据作为错误兜底。 + +## 8. 迁移顺序 + +### 阶段 1:设计系统 + +1. 安装并配置 Tailwind CSS。 +2. 建立 `tokens.css`、global reset 和 AntD bridge。 +3. 实现 `components/ui` 基础组件。 +4. 保持旧页面可构建,暂不删除旧样式。 + +### 阶段 2:壳层与通用状态 + +1. 实现 `AppShell`、`BrandSidebar`、`Topbar`。 +2. 迁移 `AppLayout`,保留菜单计算、selected key、退出和 Outlet。 +3. 迁移登录页。 +4. 迁移 `PageShell`、`PlaceholderPage`、`ApiErrorResult`、`AgentBanner`。 +5. 验证四个 Demo 账号、菜单和退出。 + +### 阶段 3:Dashboard 组合 + +1. 迁移 `DashboardHero`。 +2. 实现 `ChartCard`、`DataSection`、新版 `QuickActionBar`。 +3. 迁移 `DashboardLayout`,保留现有图表和 Table 数据配置。 +4. 先完成客户资产页并验证金额、收益、掩码、表格排序。 + +### 阶段 4:角色页面 + +依次迁移:客户资产、分析员市场、理财师客户、风控预警。每页迁移后执行构建、测试和浏览器走查。 + +### 阶段 5:收尾与文档 + +1. 删除已无引用的 Vite 默认 CSS 和旧布局样式。 +2. 统一状态、窄屏布局和 focus 样式。 +3. 编写 `docs/frontend/FRONTEND-HANDOFF.md`。 +4. 执行完整 build、test、lint 和实际浏览器验收。 + +## 9. 测试与验收 + +### 9.1 自动化命令 + +```bash +cd web +npm run build +npm run test +npm run lint +``` + +### 9.2 功能验收 + +- 四个演示账号登录后进入各自 Dashboard; +- 侧栏分组、选中态、折叠态、窄屏抽屉和退出可用; +- 刷新按钮仍重新调用对应 API; +- 客户金额掩码仍写入并读取原 localStorage key; +- 表格排序、分页和行操作不变; +- 风控无预警时显示空状态,有预警时显示表格、图表和 disclaimer; +- API 失败时显示统一错误面板和 trace ID; +- 1280px 桌面布局无异常,窄屏无横向溢出; +- 不出现假数字、空白白屏或浏览器 console 中新增的严重错误。 + +### 9.3 数值回归锚点 + +沿用现有 Dashboard 设计稿的验收值: + +- 客户 CUST-9527:总市值 `149940.00`、累计盈亏 `+137.08`、昨日收益 `-254.40`; +- 理财师 STAFF-10086:客户数 `12`、总市值 `1654850.00`; +- 分析员 STAFF-20001:产品 `14`、上涨 `10`、下跌 `4`; +- 风控 reset 后:待审预警 `0`,显示 Empty 而非假数据。 + +## 10. 交接手册 + +新增 `docs/frontend/FRONTEND-HANDOFF.md`,内容包括: + +1. 启动、构建、测试和 lint 命令; +2. 目录地图及 API/hooks/ui 的依赖方向; +3. Tailwind 令牌、语义颜色、字体和间距规范; +4. UI 组件 props、变体和最小用法; +5. Dashboard 页面模板与角色页接入步骤; +6. 新 API hook 的页面接入边界; +7. Loading、Success、Empty、Error、Permission 状态规范; +8. Ant Design 与 Tailwind 的职责边界; +9. Chat、表格、图表的扩展位置; +10. 浏览器验收清单和常见故障; +11. 后续 agent 的工作约定和禁止事项。 + +后续 agent 应优先复用 `components/ui` 和 `components/dashboard`,页面负责数据请求与路由,领域组件负责业务组合,任何共享组件修改都必须运行完整验证命令。 + +## 11. 风险与控制 + +| 风险 | 控制措施 | +| --- | --- | +| Tailwind 与 AntD 样式冲突 | 使用 CSS layer、局部 wrapper 和 token,不使用全局强制覆盖 | +| 页面迁移导致数据回归 | 不修改 hooks/API/utils,逐页迁移并保留数值锚点 | +| 共享组件被过度业务化 | `ui` 组件禁止导入业务模块,props 只传展示所需数据 | +| 窄屏表格溢出 | 壳层采用可伸缩内容区,表格保留 AntD 横向滚动能力 | +| 删除旧 CSS 过早导致回归 | 阶段 5 才清理无引用样式,先通过构建和浏览器验证 | +| 后续 agent 不知道扩展位置 | 交接手册记录目录、边界、模板和验收矩阵 | + +## 12. 完成定义 + +重构完成需同时满足: + +1. 登录、壳层、四个 Dashboard、占位页和错误页采用统一「静奢智能」视觉; +2. Tailwind 设计令牌和可复用 UI 组件可被其他页面直接使用; +3. 原 API、鉴权、路由、数据公式和交互契约未改变; +4. `npm run build`、`npm run test`、`npm run lint` 全部通过; +5. 浏览器完成四角色登录、导航、刷新、空态、错误态和窄屏走查; +6. `docs/frontend/FRONTEND-HANDOFF.md` 可指导后续 agent 新增页面或组件。 diff --git a/docs/项目框架设计/客服Agent-合并说明.md b/docs/项目框架设计/客服Agent-合并说明.md new file mode 100644 index 0000000..75c92e1 --- /dev/null +++ b/docs/项目框架设计/客服Agent-合并说明.md @@ -0,0 +1,87 @@ +# 客服 Agent · 从 `customer-service-agent` 合并说明 + +> 日期:2026-09-09 +> 原则:**只增不盖**;与 `merger` 同路径文件冲突时 **以 merger 为准**(未覆盖 `main.py` / `chat.py` / `agent_service.py` 等)。 + +--- + +## 1. 合并了什么 + +从远程分支 `xinghuo/customer-service-agent` 迁入 **客服 Agent Wave 1~5** 代码与文档(新增文件): + +| 类别 | 路径 | 作用(白话) | +| --- | --- | --- | +| 游客入口 | `app/api/visitor.py` | 未登录访客试聊 API | +| 画像槽位 | `app/config/profile_slots.py` | L1 画像字段定义 | +| 仓储 | `app/repository/profile_repository.py` `note_repository.py` | 画像 / 客户备注读写 | +| 编排 | `app/service/customer_service.py` | 客户 Agent 专用 LangGraph(14 节点) | +| 服务 | `profile_service.py` `note_service.py` `visitor_service.py` | 画像、备注、游客 | +| 提示词 | `customer_prompts.py` `visitor_prompts.py` | 各场景 system/user 模板 | +| 工具 | `app/tool/core_ro_tool.py` | 查持仓/流水等只读 Tool(客服线) | +| 合规 | `compliance_guard.py` `data_masker.py` | 话术合规 / 脱敏 | +| 脚本 | `scripts/bootstrap.ps1` `init_agent_db.py` `scripts/kb/*` | 客服线建库 / Milvus 集合 | +| 测试 | `tests/test_wave1_*` ~ `test_wave5_*` | Wave 1~5 单测与 E2E | +| 文档 | `表设计/02-mysql-agent 专用表设计总结.md` | Agent 专用表说明 | + +**未迁入(冲突 · 保留 merger):** + +- `app/main.py`、`app/api/chat.py`、`app/service/agent_service.py` — 宿主对话线已集成 AL-09 +- `docs/项目框架设计/表设计/02-mysql-agent专用.sql` — 保留 merger 版(含 `risk_aml_list` 等风控表) +- `docs/memory/*`、前端 `web/`、平台 API、风控模块 — 全部保留 merger + +**根目录 `01-客户.docx`:** 与 `docs/需求拆解/用户故事/01-客户.docx` 内容相同,**不重复复制到根目录**。 + +--- + +## 2. 新增数据库表(需手工执行) + +客服线新增两张表,已单独成迁移脚本(不修改 canonical DDL 文件): + +```text +scripts/agent/migrate-customer-agent-cs-c11.sql + → conversation_archive 会话结束写摘要 + → customer_notes 用户说「帮我记住…」 +``` + +在 `jinrong_agent` 库、共用底座表已存在后执行。 + +--- + +## 3. S2 接缝(2026-09-09 已接线) + +**并行分流**(不替换 `agent_service.py`): + +| 入口 | 行为 | +| --- | --- | +| `POST /api/chat/visitor` | `main.py` 挂载 `visitor_router` · 免登录试聊 | +| `POST /api/chat` + `X-Agent-Type: customer` | `chat.py` 分流 → `customer_service.run_customer_chat`(14 节点 LangGraph) | +| 其余 Agent | 仍走 `agent_service.chat` | + +**接缝文件:** + +- `app/api/auth_adapter.py` → `host_auth_for_customer_service()`(模块 `deps.AuthContext` → 宿主同形 `_HostAuthShim`) +- `app/service/memory_service.py` → `VisitorMemoryService`(游客 Redis 窗口) +- `app/service/rag_service.py` → `VisitorRagService`(包装 T-21 `search_knowledge`) +- `app/config/settings.py` → `customer_*` / `visitor_*` / `profile_*` / `archive_*` / `note_*` 字段 + +**仍待手工:** + +- 执行 `scripts/agent/migrate-customer-agent-cs-c11.sql`(`conversation_archive` · `customer_notes`) +- Wave 1/2/4/5 测试仍 `collect_ignore`,逐步解禁 + +--- + +## 4. 测试预期 + +- 基线 `python -m pytest` → **554 passed**(含 `test_wave3_customer_service.py` 19 例) +- Wave 1/2/4/5 仍在 `tests/conftest.py` `collect_ignore`,接线验证后逐步移出 +- 新增 `pytest.ini`:`testpaths = tests`(避免 `scripts/kb/test_*.py` 被误收集) +- 客服线全量(剩余 wave 解禁后):`pytest tests/test_wave1_data_masker.py … test_wave5_notes.py` + +--- + +## 5. 修订记录 + +| 日期 | 说明 | +| --- | --- | +| 2026-09-09 | S2 接缝接线:visitor 路由 + customer 分流 + wave3 测试绿 · pytest 554 passed | diff --git a/docs/项目框架设计/表设计/02-mysql-agent 专用表设计总结.md b/docs/项目框架设计/表设计/02-mysql-agent 专用表设计总结.md new file mode 100644 index 0000000..1d8a53f --- /dev/null +++ b/docs/项目框架设计/表设计/02-mysql-agent 专用表设计总结.md @@ -0,0 +1,251 @@ +# 02-mysql-agent 专用表设计总结 + +> 源文件:`docs/项目框架设计/表设计/02-mysql-agent专用.sql` +> 数据库:`jinrong_agent`(Agent 专用库,与核心业务库 `jinrong_core` 分离) +> 配套文件:`01-mysql-共用底座.sql`(会话/消息/RBAC 等共用底座) + +## 一、文件定位 + +- **用途**:各 Agent 组开发自有功能时建表,也可一次性全建。 +- **引擎**:全部 InnoDB。 +- **表数量**:文件实际包含 **7 张表**。 + > 注意:文件头注释写「单 Agent 专用 MySQL(5 张)」,实际为 7 张——`conversation_archive`、`customer_notes` 为客服 Agent 后期(CS-C-11 / Wave 5)追加,头部计数未同步更新。 + +## 二、表清单(按 Agent 分组) + +| # | 表名 | 归属 Agent | 作用 | +| --- | --- | --- | --- | +| 1 | `customer_threshold_config` | 客户财富 Agent | 客户亏损阈值配置 | +| 2 | `customer_notify_log` | 客户财富 Agent | 提醒发送留痕 | +| 3 | `advisor_draft` | 代理人助手 Agent | 话术/跟进草稿(含人工审核) | +| 4 | `compliance_hit_log` | 代理人助手 Agent | 违规话术命中日志 | +| 5 | `analytics_query_log` | 数据分析 Agent | NL2SQL 查数留痕 | +| 6 | `conversation_archive` | 客服 Agent(跨 Agent 通用) | 会话归档摘要 | +| 7 | `customer_notes` | 客服 Agent | 客户显式备注(独立于 L1 画像) | + +--- + +## 三、客户财富 Agent(2 张) + +### 3.1 `customer_threshold_config` — 亏损阈值配置 + +客户自定义亏损提醒线。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | BIGINT UNSIGNED PK | 自增主键 | +| `customer_id` | VARCHAR(64) NOT NULL | 客户 ID | +| `scope_type` | ENUM('portfolio','product') | 作用范围:组合 / 单产品,默认 portfolio | +| `scope_ref` | VARCHAR(64) NULL | 产品引用(scope_type=product 时使用) | +| `loss_threshold_pct` | DECIMAL(5,2) NOT NULL | 亏损阈值百分比 | +| `notify_channel` | SET('app','sms','email') | 通知渠道,支持多选,默认 app | +| `is_enabled` | TINYINT(1) | 启用开关,默认 1 | +| `created_at` / `updated_at` | DATETIME(3) | 创建/更新时间(毫秒精度,updated_at 自动刷新) | + +- 索引:`idx_customer(customer_id, is_enabled)` + +### 3.2 `customer_notify_log` — 提醒留痕 + +每次阈值/波动提醒的发送审计记录。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | BIGINT UNSIGNED PK | 自增主键 | +| `customer_id` | VARCHAR(64) NOT NULL | 客户 ID | +| `trace_id` | VARCHAR(64) NOT NULL | 全链路追踪 ID | +| `notify_type` | ENUM('loss_threshold','market_volatility') | 提醒类型:亏损阈值 / 市场波动 | +| `threshold_config_id` | BIGINT UNSIGNED NULL | 关联阈值配置(可空) | +| `payload` | JSON NOT NULL | 提醒详情 | +| `channel` | VARCHAR(16) NOT NULL | 实际发送渠道 | +| `send_status` | ENUM('sent','failed') | 发送状态 | +| `created_at` | DATETIME(3) | 创建时间 | + +- 索引:`idx_customer_time(customer_id, created_at)` + +--- + +## 四、代理人助手 Agent(2 张) + +### 4.1 `advisor_draft` — 话术/跟进草稿 + +AI 为代理人生成的话术或跟进记录,走人工审核闭环。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | BIGINT UNSIGNED PK | 自增主键 | +| `draft_id` | VARCHAR(64) NOT NULL | 草稿业务 ID(**唯一键** `uk_draft_id`) | +| `session_id` | VARCHAR(64) NOT NULL | 会话 ID | +| `trace_id` | VARCHAR(64) NOT NULL | 全链路追踪 ID | +| `advisor_id` | VARCHAR(64) NOT NULL | 代理人 ID | +| `customer_id` | VARCHAR(64) NOT NULL | 客户 ID | +| `draft_type` | ENUM('script','follow_up') | 草稿类型:话术 / 跟进 | +| `content` | MEDIUMTEXT NOT NULL | 草稿正文 | +| `review_status` | ENUM('pending','approved','rejected') | 审核状态,默认 pending | +| `reviewer_id` | VARCHAR(64) NULL | 审核人 ID | +| `reviewed_at` | DATETIME(3) NULL | 审核时间 | +| `created_at` | DATETIME(3) | 创建时间 | + +- 索引:`uk_draft_id(draft_id)`、`idx_advisor_customer(advisor_id, customer_id, created_at)`、`idx_review(review_status, created_at)` + +### 4.2 `compliance_hit_log` — 违规话术命中日志 + +合规护栏命中留痕(客户/代理人两端通用)。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | BIGINT UNSIGNED PK | 自增主键 | +| `session_id` | VARCHAR(64) NOT NULL | 会话 ID | +| `trace_id` | VARCHAR(64) NOT NULL | 全链路追踪 ID | +| `agent_type` | ENUM('customer','advisor') | 触发端:客户侧 / 代理人侧 | +| `actor_id` | VARCHAR(64) NOT NULL | 操作者 ID | +| `hit_category` | ENUM('return_promise','principal_guarantee','buy_sell_guide','product_recommend','other') | 命中类别:收益承诺 / 保本承诺 / 买卖指导 / 产品推荐 / 其他 | +| `matched_terms` | JSON NOT NULL | 命中词明细 | +| `severity` | ENUM('low','medium','high') | 严重程度 | +| `action_taken` | ENUM('flagged','blocked','alerted') | 处置动作:标记 / 拦截 / 告警 | +| `created_at` | DATETIME(3) | 创建时间 | + +- 索引:`idx_severity_time(severity, created_at)`、`idx_session(session_id)` + +--- + +## 五、数据分析 Agent(1 张) + +### 5.1 `analytics_query_log` — 查数 SQL 留痕 + +自然语言查数(NL2SQL)的完整审计链。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | BIGINT UNSIGNED PK | 自增主键 | +| `session_id` | VARCHAR(64) NOT NULL | 会话 ID | +| `trace_id` | VARCHAR(64) NOT NULL | 全链路追踪 ID | +| `staff_id` | VARCHAR(64) NOT NULL | 员工 ID | +| `nl_question` | TEXT NOT NULL | 自然语言问题原文 | +| `generated_sql` | TEXT NOT NULL | 生成的 SQL | +| `sql_hash` | CHAR(64) NOT NULL | SQL 哈希(去重/统计用) | +| `row_count` | INT UNSIGNED NULL | 返回行数 | +| `exec_status` | ENUM('success','error','blocked') | 执行状态:成功 / 报错 / 被拦截 | +| `exec_latency_ms` | INT UNSIGNED NULL | 执行耗时(毫秒) | +| `result_summary` | JSON NULL | 结果摘要 | +| `has_disclaimer` | TINYINT(1) | 是否附带免责声明,默认 0 | +| `error_message` | VARCHAR(512) NULL | 报错信息 | +| `created_at` | DATETIME(3) | 创建时间 | + +- 索引:`idx_staff_time(staff_id, created_at)`、`idx_trace(trace_id)`、`idx_sql_hash(sql_hash)` + +--- + +## 六、客服 Agent(2 张) + +### 6.1 `conversation_archive` — 会话归档摘要(CS-C-11,跨 Agent 通用) + +会话结束时生成摘要写入;设计上 customer/advisor/analyst/risk 四类 Agent 通用。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | BIGINT UNSIGNED PK | 自增主键 | +| `session_id` | VARCHAR(64) NOT NULL | 关联 `agent_session.session_id`,**一会话一归档**(唯一键 `uk_session_id`) | +| `trace_id` | VARCHAR(64) NOT NULL | 全链路追踪 ID | +| `agent_type` | ENUM('customer','advisor','analyst','risk') | Agent 类型 | +| `actor_id` | VARCHAR(64) NOT NULL | 操作者:客户=customer_id / 游客=VISITOR | +| `summary` | TEXT NOT NULL | LLM 生成的会话摘要 | +| `msg_count` | INT UNSIGNED | 消息条数(count agent_message),默认 0 | +| `archive_reason` | ENUM('timeout','explicit','manual') | 归档原因:超时 / 显式结束 / 手动 | +| `created_at` | DATETIME(3) | 创建时间 | + +- 索引:`uk_session_id(session_id)`、`idx_trace(trace_id)`、`idx_actor(agent_type, actor_id, created_at)` +- **刻意不冗余**: + - 消息明细不存,见共用底座 `agent_message`; + - 会话起止时间不存:起 = `agent_session.created_at`,止 = `agent_session.closed_at`(归档动作同时将 `agent_session.status` 置为 closed)。 + +### 6.2 `customer_notes` — 客户显式备注(独立于 L1 画像) + +用户主动要求记忆的自由文本(如「你要记住我每天看净值」),不进 L1 画像槽位表。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | BIGINT UNSIGNED PK | 自增主键 | +| `customer_id` | VARCHAR(64) NOT NULL | 客户 ID(关联 `customer_profile_l1.customer_id`) | +| `session_id` | VARCHAR(64) NOT NULL | 备注生成的会话 ID | +| `trace_id` | VARCHAR(64) NOT NULL | 全链路追踪 ID | +| `content` | VARCHAR(500) NOT NULL | LLM 抽取后的纯净备注内容 | +| `category` | VARCHAR(32) NULL | 可选分类:habit / preference / reminder / other | +| `source_text` | VARCHAR(800) NULL | 用户原话片段(审计用) | +| `is_active` | TINYINT(1) | 1=有效,0=用户已删除(**软删除**,保留审计痕) | +| `created_at` | DATETIME(3) | 创建时间 | + +- 索引:`idx_cid_active_time(customer_id, is_active, created_at)`、`idx_trace(trace_id)` +- **写入与使用链路**: + 1. `keyword_route` 命中「记住 / 记一下 / 帮我记 / 别忘了 / 记着」→ 路由到 `save_note` 意图; + 2. 调 LLM 从用户原话抽取纯净 `content`(≤500 字,超长裁剪)+ 可选 `category`(非枚举值写 NULL); + 3. 写入本表,`source_text` 保留原话片段; + 4. 后续对话每轮读取该客户最近 N 条 active 备注,注入 chitchat/interpret 的 prompt; + 5. 用户要求「忘掉备注」时执行软删除(`is_active=0`);LLM 抽取失败/异常则不写库并回落提示话术。 + +--- + +## 七、共性设计约定 + +1. **主键**:统一 `id BIGINT UNSIGNED AUTO_INCREMENT`。 +2. **时间字段**:`DATETIME(3)` 毫秒精度;`created_at` 默认 `CURRENT_TIMESTAMP(3)`;配置类表(如 `customer_threshold_config`)带 `updated_at ... ON UPDATE CURRENT_TIMESTAMP(3)` 自动刷新。 +3. **全链路追踪**:业务流程表普遍携带 `trace_id` 并建索引。 +4. **取值约束**:状态/分类优先用 `ENUM`;多选用 `SET`(如通知渠道);半结构化内容用 `JSON`(payload、matched_terms、result_summary)。 +5. **索引设计**:围绕最高频查询场景建联合索引,典型模式为「主体 ID + 时间」(如 `customer_id, created_at`)及「状态 + 时间」(如 `review_status, created_at`、`severity, created_at`)。 +6. **审计思路**:所有 AI 动作均有留痕——提醒发送、草稿审核、违规命中、SQL 查数、会话归档、客户备注,支持事后核查。 +7. **软删除**:`customer_notes` 通过 `is_active` 软删除保留审计痕迹;归档表通过唯一键保证一会话一归档。 + +--- + +## 八、各表与代码层对应关系 + +> 结论基于对 `app/`、`tests/` 全量检索表名的实际结果(截至 2026-09-09)。 +> 7 张表中 **2 张已有完整代码落地(客服 Agent)**,1 张仅在注释中被引用,4 张尚无代码(属未来波次 Agent)。 + +### 8.1 落地状态总览 + +| 表名 | Repository | Service | 编排/入口 | 状态 | +| --- | --- | --- | --- | --- | +| `customer_notes` | `app/repository/note_repository.py` → `CustomerNoteRepository` | `app/service/note_service.py` | `customer_service.py` LangGraph `save_note` 节点 | ✅ 已落地(Wave 5) | +| `conversation_archive` | `app/repository/profile_repository.py` → `ProfileRepository`(归档段) | `app/service/profile_service.py`(`archive_session` 等) | `customer_service.py` archive_check / end_session | ✅ 已落地(Wave 3) | +| `customer_threshold_config` | — | — | 仅 `app/config/profile_slots.py` 槽位注释提及 | ⏳ 未实现(客户财富 Agent) | +| `customer_notify_log` | — | — | — | ⏳ 未实现(客户财富 Agent) | +| `advisor_draft` | — | — | — | ⏳ 未实现(代理人助手 Agent) | +| `compliance_hit_log` | — | — | 现有合规护栏 `app/utils/compliance_guard.py` 仅内存正则,未落库 | ⏳ 未实现(代理人助手 Agent) | +| `analytics_query_log` | — | — | — | ⏳ 未实现(数据分析 Agent) | + +### 8.2 `customer_notes` 代码链路 + +| 层 | 文件 | 关键函数/位置 | 对应表操作 | +| --- | --- | --- | --- | +| Repository | [note_repository.py](file:///d:/金融系统/app/repository/note_repository.py) | `CustomerNoteRepository.insert_note()`([L26](file:///d:/金融系统/app/repository/note_repository.py#L26)) | `INSERT INTO customer_notes` | +| Repository | 同上 | `list_active_notes()`([L59](file:///d:/金融系统/app/repository/note_repository.py#L59)) | `SELECT ... WHERE is_active=1 ORDER BY created_at DESC LIMIT N` | +| Repository | 同上 | `deactivate()` / `deactivate_all()`([L76](file:///d:/金融系统/app/repository/note_repository.py#L76)、[L89](file:///d:/金融系统/app/repository/note_repository.py#L89)) | 软删除 `UPDATE ... SET is_active=0` | +| Service | [note_service.py](file:///d:/金融系统/app/service/note_service.py) | `save_note_from_message()`([L40](file:///d:/金融系统/app/service/note_service.py#L40)) | 调 DeepSeek 抽取纯净 content+category → `insert_note`;抽取失败不写库 | +| Service | 同上 | `render_notes_context()`([L90](file:///d:/金融系统/app/service/note_service.py#L90)) | 读最近 `settings.note_max_inject` 条 active 备注,格式化为 prompt 注入文本 | +| Service | 同上 | `forget_all_notes()`([L114](file:///d:/金融系统/app/service/note_service.py#L114)) | 包装 `deactivate_all`,异常吞掉返回 0 | +| 编排 | [customer_service.py](file:///d:/金融系统/app/service/customer_service.py) | LangGraph `save_note` 节点([L375](file:///d:/金融系统/app/service/customer_service.py#L375),注册 [L461](file:///d:/金融系统/app/service/customer_service.py#L461),路由 [L481](file:///d:/金融系统/app/service/customer_service.py#L481)) | save_note 意图分支入口 | +| 编排 | 同上 | 每轮 `render_notes_context(cid)`([L144](file:///d:/金融系统/app/service/customer_service.py#L144)) | 备注注入 chitchat/interpret prompt | +| 路由/Prompt | [customer_prompts.py](file:///d:/金融系统/app/service/customer_prompts.py) | `keyword_route` 命中「记住/记一下/帮我记/别忘了/记着」→ `save_note`([L153](file:///d:/金融系统/app/service/customer_prompts.py#L153));备注抽取 prompt([L277](file:///d:/金融系统/app/service/customer_prompts.py#L277)) | 关键词前置路由 + LLM 抽取模板 | +| 测试 | [test_wave5_notes.py](file:///d:/金融系统/tests/test_wave5_notes.py) | Wave 5 共 13 例 | 写入/注入/软删除/抽取失败回落 | + +### 8.3 `conversation_archive` 代码链路 + +| 层 | 文件 | 关键函数/位置 | 对应表操作 | +| --- | --- | --- | --- | +| Repository | [profile_repository.py](file:///d:/金融系统/app/repository/profile_repository.py) | `insert_archive()`([L146](file:///d:/金融系统/app/repository/profile_repository.py#L146)) | `INSERT INTO conversation_archive`(`agent_type` 当前硬编码 `'customer'`) | +| Repository | 同上 | `close_session()`([L133](file:///d:/金融系统/app/repository/profile_repository.py#L133)) | 先将 `agent_session` 置 `closed`;仅 active 可关,防重复归档、保 `uk_session_id` 不冲突 | +| Repository | 同上 | `count_session_messages()`([L123](file:///d:/金融系统/app/repository/profile_repository.py#L123)) | `msg_count` 来源(COUNT `agent_message`) | +| Repository | 同上 | `list_idle_customer_sessions()`([L171](file:///d:/金融系统/app/repository/profile_repository.py#L171)) | 懒扫描空闲超时的 active 客户会话 | +| Service | [profile_service.py](file:///d:/金融系统/app/service/profile_service.py) | `archive_session()`([L430](file:///d:/金融系统/app/service/profile_service.py#L430)) | 归档主流程:关会话 → 等 1s 避异步落库竞态 → 计数+取消息窗 → LLM 生成摘要(失败回落「会话已结束,共 N 条消息」)→ `insert_archive` → 后台触发画像全量抽槽 `extract_profile` + `refresh_behavior_tags` | +| Service | 同上 | `archive_idle_sessions()`([L477](file:///d:/金融系统/app/service/profile_service.py#L477)) | 懒扫描批量归档,`archive_reason='timeout'`,每轮上限 `settings.archive_idle_scan_limit` | +| 编排 | [customer_service.py](file:///d:/金融系统/app/service/customer_service.py) | archive_check 节点(每轮 `_spawn` 后台调 `archive_idle_sessions`) | 超时归档,失败不影响响应 | +| 编排 | 同上 | 显式结束 `end_session` → `_spawn(archive_session, ..., "explicit")`([L436](file:///d:/金融系统/app/service/customer_service.py#L436)) | 用户主动结束会话归档 | + +> 归档动作与共用底座表联动:消息明细读 `agent_message`,会话状态写 `agent_session`(均在 `01-mysql-共用底座.sql`),本表只存摘要。 + +### 8.4 未落地表的备注 + +- **`customer_threshold_config`**:目前唯一代码痕迹是 [profile_slots.py L152](file:///d:/金融系统/app/config/profile_slots.py#L152) 的画像槽位说明——`lifecycle.threshold_pref_summary` 槽位注释「客户主动提出的提醒阈值摘要(明细归 customer_threshold_config)」。即画像只存阈值偏好摘要,明细表待客户财富 Agent 波次实现。 +- **`compliance_hit_log`**:现有合规护栏 [compliance_guard.py](file:///d:/金融系统/app/utils/compliance_guard.py) 提供 `scan_reply()` / `sanitize_reply()` / `should_add_disclaimer()`,目前为内存级正则扫描(命中即替换回复),**尚未落库**;表结构预留给代理人助手/双端合规留痕。 +- **`analytics_query_log`**:属数据分析 Agent 的 NL2SQL 留痕。客服侧数据查询(持仓/流水/风评/适当性)按硬约束走 `app/tool/core_ro_tool.py` 预定义函数工具、**禁止 NL2SQL**,因此客服链路不会写此表。 +- **`customer_notify_log` / `advisor_draft`**:分别属客户财富 Agent(提醒发送留痕)与代理人助手 Agent(草稿+审核闭环),当前无代码。 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4ecb1ad --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/scripts/agent/migrate-customer-agent-cs-c11.sql b/scripts/agent/migrate-customer-agent-cs-c11.sql new file mode 100644 index 0000000..7f29c33 --- /dev/null +++ b/scripts/agent/migrate-customer-agent-cs-c11.sql @@ -0,0 +1,36 @@ +-- 客服 Agent CS-C-11 / Wave 5 追加表(从 customer-service-agent 分支合并) +-- 用法:在 jinrong_agent 已建共用底座后执行;可重复执行前请先检查表是否已存在 +-- 注意:不改动 risk_aml_list 等风控表(merger 以本仓库 02-mysql-agent专用.sql 为准) + +USE jinrong_agent; + +-- 会话归档摘要(会话结束 timeout / explicit / manual 时写入) +CREATE TABLE IF NOT EXISTS conversation_archive ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(64) NOT NULL COMMENT '关联 agent_session.session_id,一会话一归档', + trace_id VARCHAR(64) NOT NULL COMMENT '全链路追踪 ID', + agent_type ENUM('customer','advisor','analyst','risk') NOT NULL, + actor_id VARCHAR(64) NOT NULL COMMENT '操作者:客户=customer_id / 游客=VISITOR', + summary TEXT NOT NULL COMMENT 'DeepSeek 生成的会话摘要', + msg_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '消息条数(count agent_message)', + archive_reason ENUM('timeout','explicit','manual') NOT NULL COMMENT '归档触发原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_session_id (session_id), + KEY idx_trace (trace_id), + KEY idx_actor (agent_type, actor_id, created_at) +) ENGINE=InnoDB COMMENT='【共用】会话归档摘要(CS-C-11,会话结束时生成)'; + +-- 客户显式备注(用户说「帮我记住…」,独立于 L1 画像槽位) +CREATE TABLE IF NOT EXISTS customer_notes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + customer_id VARCHAR(64) NOT NULL COMMENT '客户ID(关联 customer_profile_l1.customer_id)', + session_id VARCHAR(64) NOT NULL COMMENT '备注生成的会话ID(关联 agent_session.session_id)', + trace_id VARCHAR(64) NOT NULL COMMENT '全链路追踪ID', + content VARCHAR(500) NOT NULL COMMENT 'LLM抽取后的纯净备注内容', + category VARCHAR(32) NULL COMMENT '可选分类:habit/preference/reminder/other', + source_text VARCHAR(800) NULL COMMENT '用户原话片段(审计用)', + is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=有效 0=用户已删除(软删除)', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + KEY idx_cid_active_time (customer_id, is_active, created_at), + KEY idx_trace (trace_id) +) ENGINE=InnoDB COMMENT='【客服】客户显式备注(用户主动要求记忆的自由文本,独立于 L1 画像)'; diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 new file mode 100644 index 0000000..87f07ac --- /dev/null +++ b/scripts/bootstrap.ps1 @@ -0,0 +1,71 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + 新机器一键灌库:jinrong_agent(底座+Agent专用)→ jinrong_core(模拟库+归属同步)→ 可选 RAG 知识库 +.EXAMPLE + .\scripts\bootstrap.ps1 -MysqlPassword 123456 -SkipNeo4j + .\scripts\bootstrap.ps1 -MysqlPassword 123456 -SkipNeo4j -WithKb # 连同 RAG 知识库一起灌入 +.NOTES + 前置依赖: + 1. Python 3.12 + 项目依赖(pip install -r requirements.txt) + 2. MySQL 8.x 本地运行,且 mysql CLI 在 PATH 中 + 3. Redis 8.x 本地运行(会话短期记忆) + 4. Milvus(Lite 本地文件或 standalone)运行中 + 5. .env 已按 .env.example 配置(DEEPSEEK_API_KEY、KB_ROOT_DIR 等) + 灌库完成后:agent 库 18 张表(底座 11 + Agent 专用 7),core 模拟库种子数据为相对跑库日日期。 +#> +param( + [string]$MysqlUser = "root", + [string]$MysqlHost = "127.0.0.1", + [string]$MysqlPassword = "", + [switch]$SkipNeo4j, + [switch]$WithKb +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path $PSScriptRoot -Parent +Set-Location $Root + +# 密码经 MYSQL_PWD 环境变量传递:Python 脚本与 mysql CLI 共用,避免命令行明文 +if ($MysqlPassword -ne "") { $env:MYSQL_PWD = $MysqlPassword } +if (-not $env:MYSQL_PWD) { + Write-Error "缺少密码:请用 -MysqlPassword 传参,或预设 MYSQL_PWD 环境变量" + exit 1 +} + +# --- 前置检查 ------------------------------------------------------------- +Write-Host "=== 前置检查 ===" -ForegroundColor Cyan +foreach ($cmd in @("python", "mysql")) { + if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { + Write-Error "未找到命令 '$cmd',请先安装并加入 PATH" + exit 1 + } +} +if (-not (Test-Path (Join-Path $Root ".env"))) { + Write-Host "[WARN] 未找到 .env,请复制 .env.example 为 .env 并填写 DEEPSEEK_API_KEY 等配置" -ForegroundColor Yellow +} +Write-Host "python / mysql 均可用" -ForegroundColor Green + +# --- 步骤 1:jinrong_agent(01 底座 11 张 + 02 Agent 专用 7 张)----------- +Write-Host "`n=== [1/3] 初始化 jinrong_agent(底座 + Agent 专用表)===" -ForegroundColor Cyan +python scripts/init_agent_db.py --mysql-host $MysqlHost --mysql-user $MysqlUser +if ($LASTEXITCODE -ne 0) { Write-Error "agent 库初始化失败" } + +# --- 步骤 2:jinrong_core 模拟库(DROP 重建 + 种子 + 归属同步)------------ +Write-Host "`n=== [2/3] 重建 jinrong_core 模拟库并同步归属 ===" -ForegroundColor Cyan +$resetArgs = @("-MysqlPassword", $MysqlPassword, "-MysqlHost", $MysqlHost, "-MysqlUser", $MysqlUser) +if ($SkipNeo4j) { $resetArgs += "-SkipNeo4j" } +& powershell -ExecutionPolicy Bypass -File (Join-Path $Root "scripts/core/reset.ps1") @resetArgs +if ($LASTEXITCODE -ne 0) { Write-Error "core 模拟库重建失败" } + +# --- 步骤 3(可选):RAG 知识库灌入 Milvus -------------------------------- +if ($WithKb) { + Write-Host "`n=== [3/3] RAG 知识库灌入 Milvus ===" -ForegroundColor Cyan + & powershell -ExecutionPolicy Bypass -File (Join-Path $Root "scripts/kb/reset.ps1") -Rebuild + if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] 知识库灌入失败,请检查 Milvus / DEEPSEEK_API_KEY / KB_ROOT_DIR" -ForegroundColor Yellow } +} else { + Write-Host "`n=== [3/3] 跳过 RAG 知识库(如需灌入加 -WithKb;需自备知识库源文档并配置 KB_ROOT_DIR)===" -ForegroundColor DarkGray +} + +Write-Host "`n=== 全部完成 ===" -ForegroundColor Green +Write-Host "后续:确认 Redis 8.x 与 Milvus 已启动,.env 配置就绪后即可启动服务(uvicorn app.main:app --reload)" -ForegroundColor Green diff --git a/scripts/init_agent_db.py b/scripts/init_agent_db.py new file mode 100644 index 0000000..21b68fd --- /dev/null +++ b/scripts/init_agent_db.py @@ -0,0 +1,95 @@ +"""初始化 jinrong_agent 库:依次执行 01-共用底座 + 02-agent专用 两份 DDL。 + +用法(密码与 core/reset.ps1 同一套,二选一): + # 方式一:环境变量(避免命令行明文) + $env:MYSQL_PWD = "你的密码"; python scripts/init_agent_db.py + # 方式二:命令行传参 + python scripts/init_agent_db.py --mysql-password 123456 + # 可选参数 + python scripts/init_agent_db.py --mysql-host 127.0.0.1 --mysql-port 3306 --mysql-user root + +说明: +- 全新机器首次执行即可建齐:底座 11 张(01)+ Agent 专用 7 张(02); +- CREATE DATABASE 带 IF NOT EXISTS;表已存在时 MySQL 报 1050,脚本提示后安全跳过, + 不会 DROP 任何数据(如需彻底重建,请手动 DROP DATABASE jinrong_agent 后重跑); +- 游客审计依赖 jinrong_agent.audit_log(在 01 中),客服归档/备注依赖 02 中的表。 +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +import pymysql +from pymysql.constants import CLIENT +from pymysql.err import OperationalError + +ROOT = Path(__file__).resolve().parent.parent +SQL_FILES = [ + ("01-共用底座", ROOT / "docs/项目框架设计/表设计/01-mysql-共用底座.sql"), + ("02-agent专用", ROOT / "docs/项目框架设计/表设计/02-mysql-agent专用.sql"), +] + +ER_TABLE_EXISTS = 1050 + + +def run_sql_file(cur, label: str, path: Path) -> None: + if not path.exists(): + print(f"[ERROR] SQL 文件不存在: {path}") + sys.exit(1) + print(f">> 执行 {label}: {path.name}") + sql = path.read_text(encoding="utf-8") + try: + cur.execute(sql) + while cur.nextset(): + pass + print(f" {label} 执行完成") + except OperationalError as e: + # 1050 = 表已存在:非全新环境重跑的预期情况,安全跳过,不破坏数据 + if e.args and e.args[0] == ER_TABLE_EXISTS: + print(f" [SKIP] {label} 部分表已存在(1050),跳过已建表;如需重建请先 DROP DATABASE jinrong_agent") + else: + raise + + +def main() -> None: + parser = argparse.ArgumentParser(description="初始化 jinrong_agent 库(01 底座 + 02 Agent 专用)") + parser.add_argument("--mysql-host", default=os.getenv("MYSQL_HOST", "127.0.0.1")) + parser.add_argument("--mysql-port", type=int, default=int(os.getenv("MYSQL_PORT", "3306"))) + parser.add_argument("--mysql-user", default=os.getenv("MYSQL_USER", "root")) + parser.add_argument("--mysql-password", default=os.getenv("MYSQL_PWD", ""), + help="MySQL 密码;也可用 MYSQL_PWD 环境变量") + args = parser.parse_args() + + if not args.mysql_password: + print("[ERROR] 缺少密码:请用 --mysql-password 传参,或预设 MYSQL_PWD 环境变量") + sys.exit(1) + + conn = pymysql.connect( + host=args.mysql_host, + port=args.mysql_port, + user=args.mysql_user, + password=args.mysql_password, + charset="utf8mb4", + client_flag=CLIENT.MULTI_STATEMENTS, + autocommit=True, + ) + try: + with conn.cursor() as cur: + for label, path in SQL_FILES: + run_sql_file(cur, label, path) + + cur.execute("USE jinrong_agent") + cur.execute("SHOW TABLES") + tables = [r[0] for r in cur.fetchall()] + print(f"\njinrong_agent 现有 {len(tables)} 张表:") + for t in tables: + print(f" - {t}") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/kb/build_collections.py b/scripts/kb/build_collections.py new file mode 100644 index 0000000..998d1e4 --- /dev/null +++ b/scripts/kb/build_collections.py @@ -0,0 +1,113 @@ +"""知识库入库脚本:解析桌面知识库文件 → 向量化 → 存入 Milvus。 + +用法: + python scripts/kb/build_collections.py # 仅创建不存在的 collection + python scripts/kb/build_collections.py --rebuild # 先删除再重建 +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# 把项目根目录加入 sys.path +_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(_ROOT)) + +from app.config.settings import settings +from app.tool.document_parser import parse_collection_dir +from app.tool.embedding_tool import get_embedder +from app.tool.milvus_tool import get_milvus_client + + +# Collection → 子目录映射 +KB_LAYOUT: dict[str, str] = { + "fin_faq": "fin_faq_collection", + "fin_product": "fin_product_collection", + "fin_policy": "fin_policy_collection", +} + +# 每个 collection 的标量字段(从 chunk.metadata 中取值) +COLLECTION_FIELDS: dict[str, list[str]] = { + "fin_faq": ["question", "answer", "category", "source_doc", "chunk_no", "chunk_text"], + "fin_product": ["product_name", "risk_level", "doc_type", "source_doc", "chunk_no", "chunk_text"], + "fin_policy": ["policy_name", "chapter", "source_doc", "chunk_no", "chunk_text"], +} + + +def chunk_to_record(chunk, collection: str, vector: list[float]) -> dict: + """把 Chunk + 向量转为 Milvus 插入记录。""" + record = { + "id": chunk.chunk_id, + "embedding": vector, + } + for field in COLLECTION_FIELDS[collection]: + record[field] = chunk.metadata.get(field, "") + # 类型修正:chunk_no 必须是 int + record["chunk_no"] = int(chunk.metadata.get("chunk_no", 0)) + return record + + +def main(rebuild: bool = False) -> None: + embedder = get_embedder() + milvus = get_milvus_client() + kb_root = Path(settings.kb_root_dir) + + if not kb_root.exists(): + print(f"[ERROR] 知识库目录不存在: {kb_root}") + sys.exit(1) + + total_inserted = 0 + + for coll_name, sub_dir in KB_LAYOUT.items(): + dir_path = kb_root / sub_dir + if not dir_path.exists(): + print(f"[WARN] 跳过 {coll_name}: 目录不存在 {dir_path}") + continue + + # 1. 解析文档 + print(f"\n{'=' * 60}") + print(f"[{coll_name}] 开始解析: {dir_path}") + chunks, _ = parse_collection_dir(dir_path) + print(f"[{coll_name}] 解析完成: {len(chunks)} 块") + + if not chunks: + print(f"[WARN] {coll_name} 无有效内容,跳过") + continue + + # 2. 重建或确保 collection + if rebuild: + milvus.drop_collection(coll_name) + milvus.ensure_collection(coll_name) + + # 3. 向量化 + print(f"[{coll_name}] 开始向量化 ({len(chunks)} 条)...") + texts = [c.text for c in chunks] + vectors = embedder.embed_batch(texts) + + # 4. 组装记录并插入 + records = [] + skipped = 0 + for chunk, vec in zip(chunks, vectors): + if not vec: + skipped += 1 + continue + records.append(chunk_to_record(chunk, coll_name, vec)) + + if skipped: + print(f"[{coll_name}] 跳过 {skipped} 条(向量化失败)") + + insert_count = milvus.insert(coll_name, records) + print(f"[{coll_name}] 插入 {insert_count} 条记录") + total_inserted += insert_count + + print(f"\n{'=' * 60}") + print(f"入库完成:共插入 {total_inserted} 条记录到 {len(KB_LAYOUT)} 个 collection") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="知识库入库") + parser.add_argument("--rebuild", action="store_true", help="先删除再重建 collection") + args = parser.parse_args() + main(rebuild=args.rebuild) diff --git a/scripts/kb/reset.ps1 b/scripts/kb/reset.ps1 new file mode 100644 index 0000000..1c51acd --- /dev/null +++ b/scripts/kb/reset.ps1 @@ -0,0 +1,15 @@ +#Requires -Version 5.1 +# 知识库入库脚本:解析桌面知识库 → 向量化 → 存入 Milvus +param([switch]$Rebuild) + +$ErrorActionPreference = "Stop" +$Root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +Set-Location $Root + +Write-Host "=== 知识库入库 ===" -ForegroundColor Cyan +python scripts/kb/build_collections.py $(if ($Rebuild) { "--rebuild" }) +if ($LASTEXITCODE -ne 0) { + Write-Host "入库失败!" -ForegroundColor Red + exit 1 +} +Write-Host "=== 入库完成 ===" -ForegroundColor Green diff --git a/scripts/kb/test_search.py b/scripts/kb/test_search.py new file mode 100644 index 0000000..9b8444a --- /dev/null +++ b/scripts/kb/test_search.py @@ -0,0 +1,36 @@ +"""快速测试 Milvus 检索是否正常工作。""" +import sys +sys.path.insert(0, "d:/金融系统") + +from app.tool.embedding_tool import get_embedder +from app.tool.milvus_tool import get_milvus_client + +e = get_embedder() +m = get_milvus_client() + +# 测试产品检索 +v = e.embed("R3中风险产品有哪些") +hits = m.search("fin_product", v, top_k=3) +print("=== 产品检索 ===") +for h in hits: + score = h.get("score", 0) + text = h.get("chunk_text", "")[:100] + print(f"score={score:.4f} | {text}") + +# 测试 FAQ 检索 +v2 = e.embed("开户需要什么材料") +hits2 = m.search("fin_faq", v2, top_k=3) +print("\n=== FAQ 检索 ===") +for h in hits2: + score = h.get("score", 0) + text = h.get("chunk_text", "")[:100] + print(f"score={score:.4f} | {text}") + +# 测试政策检索 +v3 = e.embed("投资者适当性匹配规则") +hits3 = m.search("fin_policy", v3, top_k=3) +print("\n=== 政策检索 ===") +for h in hits3: + score = h.get("score", 0) + text = h.get("chunk_text", "")[:100] + print(f"score={score:.4f} | {text}") diff --git a/scripts/kb/test_visitor_api.py b/scripts/kb/test_visitor_api.py new file mode 100644 index 0000000..a6d20e0 --- /dev/null +++ b/scripts/kb/test_visitor_api.py @@ -0,0 +1,36 @@ +"""游客接口端到端测试。""" +import json +import urllib.request + +URL = "http://127.0.0.1:8000/api/chat/visitor" + +CASES = [ + ("产品咨询", "R3中风险产品有哪些?"), + ("违规-推荐", "推荐一只稳赚的基金"), + ("闲聊", "你好,你是谁?"), + ("FAQ", "开户需要准备哪些材料?"), + ("政策", "投资者适当性匹配是怎么规定的?"), +] + + +def call(message, session_id=None): + body = json.dumps({"message": message, "session_id": session_id}).encode("utf-8") + req = urllib.request.Request( + URL, data=body, headers={"Content-Type": "application/json"}, method="POST" + ) + with urllib.request.urlopen(req, timeout=90) as resp: + return json.loads(resp.read().decode("utf-8")) + + +for name, msg in CASES: + print(f"\n{'=' * 60}") + print(f"【{name}】用户:{msg}") + try: + r = call(msg) + data = r.get("data", {}) + print(f"意图:{data.get('intent')}") + print(f"转人工:{data.get('transfer_to_human')}") + print(f"风险提示:{data.get('has_disclaimer')}") + print(f"回复:{data.get('reply')}") + except Exception as e: + print(f"请求失败:{e}") diff --git a/tests/conftest.py b/tests/conftest.py index 593c609..a31f1f8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,75 @@ ROOT = Path(__file__).resolve().parent.parent DEMO_SQL = ROOT / "scripts" / "demo" / "prepare_risk_demo.sql" +class FakeRedis: + """最小 Redis 替身(decode_responses=True 语义;客服 wave 测试用)。""" + + def __init__(self) -> None: + self.strings: dict = {} + self.lists: dict = {} + self.hashes: dict = {} + self.ttls: dict = {} + + def get(self, key): + return self.strings.get(key) + + def setex(self, key, ttl, value): + self.strings[key] = value + self.ttls[key] = ttl + + def delete(self, *keys): + for key in keys: + self.strings.pop(key, None) + self.lists.pop(key, None) + self.hashes.pop(key, None) + + def incr(self, key): + value = int(self.strings.get(key, 0)) + 1 + self.strings[key] = str(value) + return value + + def expire(self, key, ttl): + self.ttls[key] = ttl + + def rpush(self, key, value): + self.lists.setdefault(key, []).append(value) + + def ltrim(self, key, start, end): + lst = self.lists.get(key) + if lst is None: + return + self.lists[key] = lst[start:] if end == -1 else lst[start : end + 1] + + def lrange(self, key, start, end): + lst = self.lists.get(key, []) + return list(lst[start:]) if end == -1 else list(lst[start : end + 1]) + + def hincrby(self, key, field, delta=1): + h = self.hashes.setdefault(key, {}) + h[field] = int(h.get(field, 0)) + delta + return h[field] + + def hgetall(self, key): + return dict(self.hashes.get(key, {})) + + +@pytest.fixture +def fake_redis(): + return FakeRedis() + + +# 客服 Agent 线测试:接缝接线后逐步移出 ignore(见 客服Agent-合并说明.md) +collect_ignore = [ + "test_wave1_data_masker.py", + "test_wave2_core_ro_tool.py", + "test_wave2_profile_slots.py", + "test_wave2_prompts.py", + "test_wave3_profile_service.py", + "test_wave4_e2e.py", + "test_wave5_notes.py", +] + + @pytest.fixture(autouse=True) def _disable_concentration_rule(monkeypatch): """RISK-006 回归隔离(C4 起):默认把集中度阈值推到不可达,现有用例断言零改动。 diff --git a/tests/test_chat.py b/tests/test_chat.py index b8320b5..a9fca8a 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -24,17 +24,51 @@ from app.config.settings import settings 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, memory_service, tool_service +from app.service import agent_service, customer_service as cs, memory_service, tool_service from app.service.auth_service import issue_dev_token as _issue # noqa: F401 from app.service import auth_service +from app.service.profile_service import CustomerMemoryService from app.service.risk import redis_gateway +class FakeCSMsg: + def __init__(self, content: str) -> None: + self.content = content + + +class FakeCSLLM: + """客服 LangGraph 用 LLM 替身(按 system prompt 关键词应答)。""" + + def invoke(self, messages): + system = messages[0]["content"] if messages else "" + user = messages[-1]["content"] if messages else "" + if "意图分类" in system: + return FakeCSMsg("chit_chat") + if "系统查询结果" in user or "事实数据" in user: + return FakeCSMsg("您当前没有持仓记录。") + return FakeCSMsg("您好,有什么可以帮您?") + + +class _FakeHotCache: + def get_style_tags(self, cid): + return {} + + def invalidate(self, cid): + pass + + +class _FakeVisitorRag: + def retrieve(self, intent, query, top_k=5): + return "", [] + + class FakeRedis: - """窗口语义:rpush/lrange/ltrim/expire 最小实现 + 可注入故障。""" + """窗口 + 客服记忆 Redis 替身(可注入故障)。""" def __init__(self): + self.strings: dict[str, str] = {} self.lists: dict[str, list[str]] = {} + self.hashes: dict[str, dict] = {} self.ttls: dict[str, int] = {} self.fail = False @@ -42,6 +76,28 @@ class FakeRedis: if self.fail: raise ConnectionError("redis down") + def get(self, key): + return self.strings.get(key) + + def setex(self, key, ttl, value): + self._maybe_fail() + self.strings[key] = value + self.ttls[key] = ttl + + def incr(self, key): + self._maybe_fail() + value = int(self.strings.get(key, 0)) + 1 + self.strings[key] = str(value) + return value + + def hincrby(self, key, field, delta=1): + h = self.hashes.setdefault(key, {}) + h[field] = int(h.get(field, 0)) + delta + return h[field] + + def hgetall(self, key): + return dict(self.hashes.get(key, {})) + def rpush(self, key, *vals): self._maybe_fail() self.lists.setdefault(key, []).extend(vals) @@ -62,8 +118,11 @@ class FakeRedis: def publish(self, *a, **k): pass - def delete(self, *a, **k): - pass + def delete(self, *keys): + for key in keys: + self.strings.pop(key, None) + self.lists.pop(key, None) + self.hashes.pop(key, None) def exists(self, key): return False @@ -89,6 +148,7 @@ def env(monkeypatch): " ('STAFF-10087', 'CUST-1010', 'active')") ) fake_redis = FakeRedis() + cs_llm = FakeCSLLM() monkeypatch.setattr(settings, "deepseek_api_key", "") monkeypatch.setattr(chat_mod, "_repo", lambda: repo) monkeypatch.setattr(chat_mod, "_session_repo", lambda: session_repo) @@ -103,7 +163,26 @@ def env(monkeypatch): monkeypatch.setattr(audit_mod, "_repo", lambda: repo) monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo) monkeypatch.setattr(redis_gateway, "_gateway", fake_redis) - yield {"client": TestClient(app), "repo": repo, "engine": engine, "redis": fake_redis} + # S2 接缝:customer 分流走 customer_service(Redis/LLM/归档替身) + monkeypatch.setattr("app.config.database.get_redis_client", lambda: fake_redis) + monkeypatch.setattr(cs, "_build_llm", lambda: cs_llm) + monkeypatch.setattr(cs, "CustomerMemoryService", lambda: CustomerMemoryService(redis_client=fake_redis)) + monkeypatch.setattr(cs, "ProfileHotCache", lambda repo=None: _FakeHotCache()) + monkeypatch.setattr(cs, "VisitorRagService", _FakeVisitorRag) + monkeypatch.setattr(cs, "_spawn", lambda fn, *a: fn(*a)) + monkeypatch.setattr(cs, "archive_session", lambda *a, **k: True) + monkeypatch.setattr(cs, "archive_idle_sessions", lambda *a, **k: []) + monkeypatch.setattr(cs, "extract_profile", lambda *a, **k: []) + monkeypatch.setattr(cs, "record_intent", lambda *a, **k: None) + monkeypatch.setattr("app.tool.core_ro_tool.CoreReadOnlyRepository", lambda: core_ro) + yield { + "client": TestClient(app), + "repo": repo, + "engine": engine, + "redis": fake_redis, + "cs_llm": cs_llm, + "core_ro": core_ro, + } engine.dispose() @@ -113,13 +192,16 @@ def _rows(engine, sql, **params): def test_chat_new_session_creates_session_and_messages(env): + """customer 分流(S2):会话/落盘/持仓工具链;免责声明口径随 customer_service。""" r = env["client"].post("/api/chat", json={"message": "查一下我的持仓"}, headers=CUSTOMER) assert r.status_code == 200 body = r.json() assert body["session_id"].startswith("sess-") assert body["agent_type"] == "customer" assert body["customer_id"] == "CUST-9527" - assert body["has_disclaimer"] is True # 客户对外口径(降级回复同样经 guard) + assert body["intent"] == "holding_query" + assert body["has_disclaimer"] is False + assert body["transfer_to_human"] is False assert body["trace_id"] == r.headers["X-Trace-Id"] sessions = _rows(env["engine"], "SELECT * FROM agent_session") @@ -133,21 +215,20 @@ def test_chat_new_session_creates_session_and_messages(env): 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", 1)] + assert [(m["role"], m["has_disclaimer"]) for m in msgs] == [("user", 0), ("assistant", 0)] stored = _rows( env["engine"], "SELECT content FROM agent_message WHERE role = 'assistant' ORDER BY seq_no LIMIT 1", )[0]["content"] - assert stored.endswith(agent_service.CHAT_DISCLAIMER) assert body["reply"] == stored # HTTP 响应与落库正文同口径 def test_chat_sync_persist_disclaimer_when_guard_skips_append(env, monkeypatch): - """落库在 API 层显式拼免责声明:即使 service 只回 has_disclaimer 也不丢尾部。""" + """落库在 API 层显式拼免责声明:customer 分流同样经 _assistant_content_for_persist。""" monkeypatch.setattr( - agent_service, - "chat", - lambda *a, **k: {"reply": "仅正文", "has_disclaimer": True, "tool_results": []}, + chat_mod, + "run_customer_chat", + lambda *a, **k: ("仅正文", True, "chit_chat", False), ) r = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER) assert r.status_code == 200 @@ -259,18 +340,13 @@ def test_chat_session_not_found_and_closed(env): def test_chat_tool_holdings_full_link(env): - """持仓关键词触发 Tool:agent_tool_call 落库(trace/session 贯通)+ 降级回复带摘要。""" + """持仓关键词 → customer_service Core RO 工具链(不经 agent_tool_call 表)。""" r = env["client"].post("/api/chat", json={"message": "查一下我的持仓"}, headers=CUSTOMER) assert r.status_code == 200 body = r.json() - rows = _rows(env["engine"], "SELECT * FROM agent_tool_call") - assert len(rows) == 1 - row = rows[0] - assert (row["session_id"], row["trace_id"], row["tool_name"], row["status"]) == ( - body["session_id"], body["trace_id"], "query_holdings", "success" - ) - assert "LLM 未配置" in body["reply"] # env 无 key 走降级 - assert "持仓查询" in body["reply"] # 降级回复携带 Tool 摘要(env 无持仓种子 → 0 笔) + assert body["intent"] == "holding_query" + assert "持仓" in body["reply"] + assert _rows(env["engine"], "SELECT 1 FROM agent_tool_call") == [] def test_chat_tool_no_intent_no_call(env): @@ -339,7 +415,9 @@ def test_chat_jwt_customer_new_session(env): ) assert r.status_code == 200 body = r.json() - assert body["customer_id"] == "CUST-9527" and body["has_disclaimer"] is True + assert body["customer_id"] == "CUST-9527" + assert body["intent"] == "holding_query" + assert body["has_disclaimer"] is False s = _rows(env["engine"], "SELECT actor_id, actor_role FROM agent_session")[0] assert (s["actor_id"], s["actor_role"]) == ("CUST-9527", "customer") @@ -410,7 +488,7 @@ def test_messages_page_ascending_and_paged(env): assert [m["role"] for m in body["items"]] == ["user", "assistant", "user", "assistant"] assert [m["seq_no"] for m in body["items"]] == [1, 2, 3, 4] assert body["items"][0]["content"] == "第一句" - assert body["items"][1]["has_disclaimer"] is True # customer 线 assistant 带免责声明 + assert body["items"][1]["has_disclaimer"] is False # customer 线 chitchat 默认无 guard 免责声明 r2 = env["client"].get(f"/api/chat/sessions/{sid}/messages?limit=2&offset=2", headers=CUSTOMER) body2 = r2.json() diff --git a/tests/test_main.py b/tests/test_main.py index b2c7129..30c077e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -84,6 +84,7 @@ def test_all_routers_mounted(client): "/api/chat/sessions/{session_id}/close", # 方案 C:SSE 流式对话 "/api/chat/stream", + "/api/chat/visitor", } diff --git a/tests/test_module_boundary.py b/tests/test_module_boundary.py index a8fcb18..1413126 100644 --- a/tests/test_module_boundary.py +++ b/tests/test_module_boundary.py @@ -95,9 +95,29 @@ HOST_ONLY_SKIP_PREFIXES = ( "app/utils/exceptions.py", ) +# 客服 Agent S2 接缝(docs/项目框架设计/客服Agent-合并说明.md)— 允许 import 宿主 schemas +CUSTOMER_AGENT_SEAM_SKIP = ( + "app/api/auth_adapter.py", + "app/api/visitor.py", + "app/repository/note_repository.py", + "app/repository/profile_repository.py", + "app/service/customer_service.py", + "app/service/customer_prompts.py", + "app/service/note_service.py", + "app/service/profile_service.py", + "app/service/visitor_prompts.py", + "app/service/visitor_service.py", + "app/tool/core_ro_tool.py", + "app/utils/compliance_guard.py", + "app/utils/data_masker.py", + "app/config/profile_slots.py", +) + def _is_host_only_file(path: Path) -> bool: rel = path.relative_to(APP_DIR.parent).as_posix() + if rel in CUSTOMER_AGENT_SEAM_SKIP: + return True return rel.startswith(HOST_ONLY_SKIP_PREFIXES) diff --git a/tests/test_profile_l3.py b/tests/test_profile_l3.py index 558c2e7..25ef8d1 100644 --- a/tests/test_profile_l3.py +++ b/tests/test_profile_l3.py @@ -33,12 +33,21 @@ def _fake_redis(monkeypatch): def __init__(self): self.messages = [] self.deletes = [] + self.store: dict[str, str] = {} def publish(self, channel, payload): self.messages.append((channel, payload)) def delete(self, *keys): self.deletes.append(keys) + for key in keys: + self.store.pop(key, None) + + def get(self, key): + return self.store.get(key) + + def set_ex(self, key, value, ttl_seconds): + self.store[key] = value fake = _FakeGateway() monkeypatch.setattr(redis_gateway, "_gateway", fake) @@ -333,8 +342,51 @@ def test_cache_delete_failure_degrades_to_ttl(env, monkeypatch): def delete(self, *keys): raise ConnectionError("redis down") + def get(self, key): + return None + + def set_ex(self, key, value, ttl_seconds): + pass + monkeypatch.setattr(redis_gateway, "_gateway", _BrokenGateway()) merged = _upsert(repo, "C1", "pattern", alert_id="ALT-1") # 不抛即降级成功 assert merged["monitor_tier"] == "watch" row = _row(env[1], "C1") assert row["monitor_tier"] == "watch" # DB 权威数据不受缓存失败影响 + + +# ---------- 读侧 cache-aside(L3 热读 · 2026-09-09) ---------- + + +def test_get_profile_l3_miss_backfills_cache(env, _fake_redis): + repo, _ = env + _upsert(repo, "C1", "pattern", alert_id="ALT-1") + _fake_redis.store.clear() + row = get_profile_l3("C1", risk_repo=repo) + assert row["monitor_tier"] == "watch" + assert "profile:l3:C1" in _fake_redis.store + + +def test_get_profile_l3_hit_skips_mysql(env, _fake_redis, monkeypatch): + repo, _ = env + _upsert(repo, "C1", "pattern", alert_id="ALT-1") + calls = {"n": 0} + orig_get = type(repo).get_l3 + + def counting_get(self, cid): + calls["n"] += 1 + return orig_get(self, cid) + + monkeypatch.setattr(type(repo), "get_l3", counting_get) + assert get_profile_l3("C1", risk_repo=repo)["monitor_tier"] == "watch" + assert calls["n"] == 1 # 首次 miss 回源 + assert get_profile_l3("C1", risk_repo=repo)["monitor_tier"] == "watch" + assert calls["n"] == 1 # 二次命中 Redis,不再查库 + + +def test_upsert_then_get_reads_fresh_after_del(env, _fake_redis): + repo, _ = env + _upsert(repo, "C1", "pattern", alert_id="ALT-1") + assert get_profile_l3("C1", risk_repo=repo)["monitor_tier"] == "watch" + _upsert(repo, "C1", "aml", alert_id="ALT-2") + assert get_profile_l3("C1", risk_repo=repo)["monitor_tier"] == "high" diff --git a/tests/test_wave1_data_masker.py b/tests/test_wave1_data_masker.py new file mode 100644 index 0000000..9d4cd6a --- /dev/null +++ b/tests/test_wave1_data_masker.py @@ -0,0 +1,179 @@ +"""Wave 1:信息脱敏模块单元测试(CS-C-12,纯函数无需 DB)。""" + +from app.utils.data_masker import ( + mask_bank_card, + mask_dict_fields, + mask_id_card, + mask_name, + mask_phone, + mask_text, +) + + +# --------------------------------------------------------------------------- +# 手机号 +# --------------------------------------------------------------------------- + +def test_mask_phone_standard(): + assert mask_phone("13812345678") == "138****5678" + + +def test_mask_phone_already_masked_passthrough(): + assert mask_phone("139****1001") == "139****1001" + + +def test_mask_phone_none_and_empty(): + assert mask_phone(None) is None + assert mask_phone("") == "" + + +def test_mask_phone_invalid_format_untouched(): + assert mask_phone("12345") == "12345" # 非 11 位手机号不强行处理 + + +# --------------------------------------------------------------------------- +# 身份证 +# --------------------------------------------------------------------------- + +def test_mask_id_card_18_digits(): + assert mask_id_card("110101199003071234") == "110***********1234" + + +def test_mask_id_card_18_with_tail_x(): + assert mask_id_card("11010119900307123X") == "110***********123X" + assert mask_id_card("11010119900307123x") == "110***********123X" # 小写 x 归一 + + +def test_mask_id_card_15_digits_legacy(): + # 15 位老证:前 3 + 8 个 * + 后 4 + assert mask_id_card("110101900307123") == "110********7123" + + +def test_mask_id_card_already_masked_passthrough(): + assert mask_id_card("310101199903XXXXXX") == "310101199903XXXXXX" + + +# --------------------------------------------------------------------------- +# 姓名(只保留姓) +# --------------------------------------------------------------------------- + +def test_mask_name_two_chars(): + assert mask_name("张三") == "张*" + assert mask_name("李四") == "李*" + + +def test_mask_name_three_chars(): + assert mask_name("张明明") == "张**" + + +def test_mask_name_four_or_more(): + assert mask_name("欧阳修先生") == "欧****" + + +def test_mask_name_already_masked_passthrough(): + assert mask_name("客户·王**") == "客户·王**" + assert mask_name("陈**兰") == "陈**兰" + + +def test_mask_name_none_and_single(): + assert mask_name(None) is None + assert mask_name("张") == "张" + + +# --------------------------------------------------------------------------- +# 银行卡 +# --------------------------------------------------------------------------- + +def test_mask_bank_card_continuous(): + assert mask_bank_card("6222021234567890") == "************7890" + + +def test_mask_bank_card_with_spaces(): + assert mask_bank_card("6222 0212 3456 7890") == "**** **** **** 7890" + + +def test_mask_bank_card_with_hyphens(): + assert mask_bank_card("6222-0212-3456-7890") == "****-****-****-7890" + + +def test_mask_bank_card_too_short_untouched(): + assert mask_bank_card("12345") == "12345" + + +# --------------------------------------------------------------------------- +# 自由文本兜底扫描 +# --------------------------------------------------------------------------- + +def test_mask_text_mixed_pii(): + text = "我的手机13812345678,身份证110101199003071234,卡号6222021234567890" + out = mask_text(text) + assert "138****5678" in out + assert "110***********1234" in out + assert "************7890" in out + + +def test_mask_text_spaced_bank_card(): + out = mask_text("收款卡号 6222 0212 3456 7890 请查收") + assert "**** **** **** 7890" in out + + +def test_mask_text_legacy_id_card(): + out = mask_text("老证号 110101900307123 已登记") + assert "110********7123" in out + + +def test_mask_text_no_pii_untouched(): + text = "今天想了解一下货币基金的赎回规则" + assert mask_text(text) == text + + +def test_mask_text_amount_and_date_not_falsely_masked(): + # 金额(小数点分隔)与日期不应被当作卡号/身份证 + assert mask_text("金额520000.00元") == "金额520000.00元" + assert mask_text("到期日2026-09-08") == "到期日2026-09-08" + + +def test_mask_text_phone_not_cut_from_card(): + # 16 位卡号中即便包含 1[3-9] 开头的片段,也不应被手机号规则误切 + out = mask_text("6222138123456789") + assert out == "************6789" + + +# --------------------------------------------------------------------------- +# 结构化数据批量脱敏 +# --------------------------------------------------------------------------- + +def test_mask_dict_fields_single_row(): + row = { + "customer_id": "CUST-1001", + "display_name": "李四", + "phone_mask": "139****1001", # 库中已脱敏 → 放行 + "payer_name": "王五", + "amount": 5000.00, # 非字符串原样保留 + } + out = mask_dict_fields(row) + assert out["display_name"] == "李*" + assert out["phone_mask"] == "139****1001" + assert out["payer_name"] == "王*" + assert out["amount"] == 5000.00 + assert out["customer_id"] == "CUST-1001" + # 不入参修改 + assert row["display_name"] == "李四" + + +def test_mask_dict_fields_list_of_rows(): + rows = [ + {"product_name": "现金宝货币", "counterparty_name": "张三"}, + {"product_name": "稳健债基", "counterparty_name": "李四"}, + ] + out = mask_dict_fields(rows) + assert out[0]["counterparty_name"] == "张*" + assert out[1]["counterparty_name"] == "李*" + assert out[0]["product_name"] == "现金宝货币" + + +def test_mask_dict_fields_custom_mapping(): + row = {"contact_mobile": "13812345678", "remark": "ok"} + out = mask_dict_fields(row, {"contact_mobile": "phone"}) + assert out["contact_mobile"] == "138****5678" + assert out["remark"] == "ok" diff --git a/tests/test_wave2_core_ro_tool.py b/tests/test_wave2_core_ro_tool.py new file mode 100644 index 0000000..f934c62 --- /dev/null +++ b/tests/test_wave2_core_ro_tool.py @@ -0,0 +1,209 @@ +"""Wave 2:core_ro_tool 4 个查询工具直连 jinrong_core 本地模拟库验证。 + +数据基准(Wave 0 灌库): +- CUST-9527:C3 风评有效,持仓 3 只,近 3 个月 2 笔交易 +- CUST-1001:C1 风评有效 +- CUST-1006:C3 风评过期(FM-03 样本) +- 矩阵:C3×R3=allowed、C3×R4=allowed_with_disclosure、C3×R5=forbidden、C1×R3=forbidden +""" + +from __future__ import annotations + +import pytest + +from app.repository.core_ro import CoreReadOnlyRepository +from app.tool.core_ro_tool import ( + query_holdings, + query_risk_profile, + query_suitability, + query_trades, +) + + +@pytest.fixture(scope="module") +def ro(): + repo = CoreReadOnlyRepository() + # 连通性校验:本地模拟库不可用时整组跳过 + if repo.get_customer_l0("CUST-9527") is None: + pytest.skip("jinrong_core 本地模拟库不可用") + return repo + + +# --------------------------------------------------------------------------- +# 身份绑定 +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("bad_cid", [None, "", " "]) +def test_customer_id_required(ro, bad_cid): + with pytest.raises(ValueError): + query_holdings(bad_cid, repo=ro) + with pytest.raises(ValueError): + query_trades(bad_cid, repo=ro) + with pytest.raises(ValueError): + query_risk_profile(bad_cid, repo=ro) + with pytest.raises(ValueError): + query_suitability(bad_cid, repo=ro) + + +# --------------------------------------------------------------------------- +# 工具 1:持仓 +# --------------------------------------------------------------------------- + +def test_holdings_9527(ro): + r = query_holdings("CUST-9527", repo=ro) + assert r["ok"] is True + assert r["tool"] == "holding_query" + assert len(r["facts"]) == 3 + text = r["fact_text"] + assert "持有 3 只产品" in text + assert "平衡混合一号" in text + assert "R3" in text + assert "合计市值" in text + # 数值来自库:市值 82,400.00 + assert "82,400.00" in text + + +def test_holdings_empty(ro): + r = query_holdings("CUST-NOEXIST-999", repo=ro) + assert r["ok"] is True + assert r["facts"] == [] + assert "没有持仓记录" in r["fact_text"] + + +# --------------------------------------------------------------------------- +# 工具 2:流水 +# --------------------------------------------------------------------------- + +def test_trades_default_3_months(ro): + r = query_trades("CUST-9527", repo=ro) + assert r["ok"] is True + assert r["tool"] == "transaction_query" + assert "近 3 个月" in r["fact_text"] + assert "2 笔" in r["fact_text"] + assert "申购" in r["fact_text"] + assert len(r["facts"]) == 2 + + +def test_trades_custom_months(ro): + r = query_trades("CUST-9527", months=12, repo=ro) + assert "近 12 个月" in r["fact_text"] + + +def test_trades_invalid_months_fallback(ro): + """months 非法时回退默认 3 个月。""" + r = query_trades("CUST-9527", months=0, repo=ro) + assert "近 3 个月" in r["fact_text"] + + +def test_trades_empty(ro): + r = query_trades("CUST-NOEXIST-999", repo=ro) + assert r["ok"] is True + assert r["facts"] == [] + assert "没有交易记录" in r["fact_text"] + + +# --------------------------------------------------------------------------- +# 工具 3:风评 +# --------------------------------------------------------------------------- + +def test_risk_profile_valid(ro): + r = query_risk_profile("CUST-9527", repo=ro) + assert r["ok"] is True + assert r["tool"] == "risk_assessment_query" + assert r["facts"]["risk_code"] == "C3" + text = r["fact_text"] + assert "C3(平衡型)" in text + assert "测评结果当前有效" in text + # 期限枚举映射为中文,不得出现英文裸值 + assert "long" not in text + assert "长期(5年以上)" in text + + +def test_risk_profile_expired(ro): + r = query_risk_profile("CUST-1006", repo=ro) + assert r["ok"] is True + assert r["facts"]["risk_is_expired"] is True + assert "过期" in r["fact_text"] + assert "重新完成" in r["fact_text"] + + +def test_risk_profile_not_found(ro): + r = query_risk_profile("CUST-NOEXIST-999", repo=ro) + assert r["ok"] is False + assert r["error"] == "customer_not_found" + + +# --------------------------------------------------------------------------- +# 工具 4:适当性(只读) +# --------------------------------------------------------------------------- + +def test_suitability_risk_level_allowed(ro): + """C3 客户问 R3:2 只全部可买。""" + r = query_suitability("CUST-9527", risk_level="R3", repo=ro) + assert r["ok"] is True + assert r["tool"] == "suitability_check" + text = r["fact_text"] + assert "R3" in text and "共 2 只" in text + assert "可直接购买:2 只" in text + assert "不能购买:0 只" in text + assert "只读匹配结果,不构成购买建议" in text + + +def test_suitability_risk_level_forbidden(ro): + """C1 客户问 R3:2 只全部不匹配。""" + r = query_suitability("CUST-1001", risk_level="R3", repo=ro) + text = r["fact_text"] + assert "可直接购买:0 只" in text + assert "不能购买:2 只" in text + + +def test_suitability_specific_product_allowed(ro): + r = query_suitability("CUST-9527", product_keyword="现金宝", repo=ro) + text = r["fact_text"] + assert "现金宝货币" in text + assert "匹配,可以购买" in text + + +def test_suitability_specific_product_disclosure(ro): + """C3×R4 矩阵 allowed_with_disclosure(跨一级需双录)。""" + r = query_suitability("CUST-9527", product_keyword="医药", repo=ro) + text = r["fact_text"] + assert "医药健康精选" in text + assert "风险揭示" in text or "双录" in text + + +def test_suitability_specific_product_forbidden(ro): + """C3×R5 进取成长五号:forbidden。""" + r = query_suitability("CUST-9527", product_keyword="进取成长", repo=ro) + text = r["fact_text"] + assert "进取成长五号" in text + assert "不匹配" in text + assert "不能购买" in text + + +def test_suitability_expired_blocks(ro): + """风评过期客户:适当性短路提示重新测评。""" + r = query_suitability("CUST-1006", risk_level="R3", repo=ro) + assert "过期" in r["fact_text"] + assert "重新完成风险测评" in r["fact_text"] + + +def test_suitability_product_not_found(ro): + r = query_suitability("CUST-9527", product_keyword="火星移民基金", repo=ro) + assert r["ok"] is True + assert "未找到" in r["fact_text"] + + +def test_suitability_all_summary(ro): + r = query_suitability("CUST-9527", repo=ro) + text = r["fact_text"] + assert "在售产品适当性匹配汇总" in text + assert "R5" in text + # C3 对 R5 不匹配 + assert "1 只不匹配" in text + + +def test_suitability_keyword_takes_r_level(ro): + """产品关键词中含 R 等级时按等级汇总(如"R3产品")。""" + r = query_suitability("CUST-9527", product_keyword="R3产品", repo=ro) + assert "共 2 只" in r["fact_text"] diff --git a/tests/test_wave2_profile_slots.py b/tests/test_wave2_profile_slots.py new file mode 100644 index 0000000..8a2f2ba --- /dev/null +++ b/tests/test_wave2_profile_slots.py @@ -0,0 +1,203 @@ +"""Wave 2:画像槽位定义与归一化纯函数测试(不依赖 DB/LLM)。""" + +from __future__ import annotations + +import pytest + +from app.config.profile_slots import ( + HIGH_SENSITIVITY_PATHS, + SLOT_PATHS, + SLOTS, + build_extraction_prompt, + get_slot, + normalize_value, +) + + +def test_slots_integrity(): + """12 槽位、path 唯一、字段合法、与 style_tags 路径对齐。""" + assert len(SLOTS) == 12 + assert len(SLOT_PATHS) == len(set(SLOT_PATHS)) == 12 + + valid_merge = {"latest", "set_union"} + valid_fmt = {"band", "enum", "list", "text"} + valid_src = {"chat", "chat_or_questionnaire", "chat_or_consult_or_trade"} + valid_sens = {"high", "medium", "low"} + for s in SLOTS: + assert s["merge_mode"] in valid_merge + assert s["value_format"] in valid_fmt + assert s["allowed_source"] in valid_src + assert s["sensitivity"] in valid_sens + assert s["name"] and s["examples"] + # style_tags 顶层路径必须对齐 06 文档 §4.1 + top = s["path"].split(".")[0] + assert top in {"basic", "financial", "investment", "lifecycle"} or s["path"] == "threshold_pref_summary" + + # 高敏槽位恰好 3 个(年龄/年收入/月可投) + assert set(HIGH_SENSITIVITY_PATHS) == { + "basic.age_band", + "financial.income_band", + "financial.monthly_investable", + } + # 集合累计槽位恰好 2 个(偏好/排除) + set_union_paths = {s["path"] for s in SLOTS if s["merge_mode"] == "set_union"} + assert set_union_paths == { + "investment.product_preferences", + "investment.excluded_products", + } + + +def test_get_slot(): + assert get_slot("basic.city")["name"] == "所在城市" + assert get_slot("not.a.slot") is None + + +def test_extraction_prompt_covers_all_slots(): + prompt = build_extraction_prompt() + for s in SLOTS: + assert s["path"] in prompt + assert s["name"] in prompt + # 高敏保护规则必须出现在 prompt 中 + assert "高敏感" in prompt + assert "禁止" in prompt + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("我今年28", "25-30"), + ("30岁", "25-30"), # 年龄档含上界 + ("我35了", "31-35"), + ("今年40", "36-40"), + ("我爸72", "61+"), + ("刚工作22", "18-24"), + ], +) +def test_normalize_age_band(raw, expected): + assert normalize_value("basic.age_band", raw) == expected + + +@pytest.mark.parametrize("raw", ["很年轻", "年龄不大", ""]) +def test_normalize_age_invalid(raw): + assert normalize_value("basic.age_band", raw) is None + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("年薪30万", "20-50万"), + ("月入2万", "20-50万"), # 2万*12=24万 + ("一年到手十几万", "10-20万"), # 中文数字"十几万"取下界 10 万 + ("月薪8000", "10万以下"), # 8000元*12=9.6万 + ("月薪一万", "10-20万"), # 中文数字 1万*12=12万 + ("年收入80万", "50-100万"), + ], +) +def test_normalize_income_band(raw, expected): + assert normalize_value("financial.income_band", raw) == expected + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("每月能拿3000定投", "3000-5000"), + ("月可投4999", "3000-5000"), + ("月可投5000左右", "5000-10000"), + ("每月1万", "10000以上"), + ("每个月2000块", "1000-3000"), + ("月投800", "1000以下"), + ("每月三千定投", "3000-5000"), + ], +) +def test_normalize_monthly_investable(raw, expected): + assert normalize_value("financial.monthly_investable", raw) == expected + + +def test_normalize_monthly_no_number(): + assert normalize_value("financial.monthly_investable", "一个月剩不下钱投") is None + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("我本科毕业", "本科"), + ("研究生学历", "硕士及以上"), + ("大专", "大专"), + ("博士在读", "硕士及以上"), + ("高中毕业", "高中及以下"), + ], +) +def test_normalize_education(raw, expected): + assert normalize_value("basic.education", raw) == expected + + +def test_normalize_education_unknown(): + assert normalize_value("basic.education", "我学历还行") is None + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("半年后要用钱", "1年以内"), + ("这笔钱三年不用", "1-3年"), + ("两三年吧", "1-3年"), + ("计划持有三年以上", "3-5年"), + ("长期放着养老", "5年以上"), + ], +) +def test_normalize_horizon(raw, expected): + assert normalize_value("investment.horizon", raw) == expected + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("刚工作单身", "单身奋斗期"), + ("刚结婚还没孩子", "家庭形成期"), + ("孩子上小学", "家庭成长期"), + ("孩子都上大学了", "家庭成熟期"), + ("退休了", "退休养老期"), + ], +) +def test_normalize_stage(raw, expected): + assert normalize_value("lifecycle.stage", raw) == expected + + +def test_normalize_product_preferences(): + assert normalize_value("investment.product_preferences", "我喜欢指数基金和货币基金") == [ + "指数基金", + "货币基金", + ] + + +def test_normalize_excluded_products(): + assert normalize_value("investment.excluded_products", "私募不碰,期货也不做") == [ + "私募基金", + "期货", + ] + + +def test_normalize_list_alias(): + assert normalize_value("investment.product_preferences", "想买点债基") == ["债券基金"] + + +def test_normalize_list_non_vocab_dropped(): + """非词表自由值(如个股名)不进画像。""" + assert normalize_value("investment.product_preferences", "我看好白酒股") is None + + +def test_normalize_text_slots(): + # basic.city Wave 3 起归一为干净城市名(剥前后缀) + assert normalize_value("basic.city", "我在上海") == "上海" + assert normalize_value("basic.city", "老家成都的") == "成都" + assert normalize_value("basic.city", "我搬去杭州了") == "杭州" + assert normalize_value("basic.city", "在深圳上班") == "深圳" + assert normalize_value("basic.city", "浙江省杭州市") == "杭州" + assert normalize_value("basic.city", "我在上海出差顺便开会") is None # 清洗后超 7 字,非城市名 + assert normalize_value("investment.goal", "存钱买房") == "存钱买房" + assert normalize_value("threshold_pref_summary", "亏10%就提醒我") == "亏10%就提醒我" + assert normalize_value("basic.city", "") is None + + +def test_normalize_unknown_slot(): + assert normalize_value("not.exists", "随便") is None diff --git a/tests/test_wave2_prompts.py b/tests/test_wave2_prompts.py new file mode 100644 index 0000000..f9c07fd --- /dev/null +++ b/tests/test_wave2_prompts.py @@ -0,0 +1,93 @@ +"""Wave 2:客户 Agent prompt 常量与关键词快路由纯函数测试(不依赖 DB/LLM)。""" + +from __future__ import annotations + +import pytest + +from app.service.customer_prompts import ( + DATA_QUERY_INTENTS, + INTENT_SYSTEM, + PROFILE_EXTRACT_SYSTEM, + REJECT_BY_KIND, + VALID_INTENTS, + keyword_route, +) + + +def test_intent_constants(): + assert len(VALID_INTENTS) == 12 + assert len(DATA_QUERY_INTENTS) == 4 + # 4 个数据查询意图均在合法集内 + assert DATA_QUERY_INTENTS <= VALID_INTENTS + # 意图分类 prompt 覆盖 12 类标签 + for intent in VALID_INTENTS: + assert intent in INTENT_SYSTEM + + +def test_profile_prompt_delegates_to_slots(): + """抽槽 prompt 由槽位表派生,包含高敏规则。""" + assert "basic.age_band" in PROFILE_EXTRACT_SYSTEM + assert "threshold_pref_summary" in PROFILE_EXTRACT_SYSTEM + assert "高敏感" in PROFILE_EXTRACT_SYSTEM + + +def test_reject_reply_kinds(): + assert set(REJECT_BY_KIND) == {"advice", "predict", "compare", "realtime"} + for text in REJECT_BY_KIND.values(): + assert "游客" not in text # 客户视角话术不得出现"游客" + + +@pytest.mark.parametrize( + "message,expected_intent", + [ + ("我的持仓怎么样", "holding_query"), + ("我买了什么基金", "holding_query"), + ("最近亏了多少", "holding_query"), + ("最近的交易记录", "transaction_query"), + ("我上个月申购了啥", "transaction_query"), + ("我的风评是什么", "risk_assessment_query"), + ("风险测评过期了吗", "risk_assessment_query"), + ("我能买R3产品吗", "suitability_check"), + ("这个基金适合我买吗", "suitability_check"), + ], +) +def test_keyword_route_data_queries(message, expected_intent): + result = keyword_route(message) + assert result is not None + intent, reply = result + assert intent == expected_intent + assert reply == "" + + +@pytest.mark.parametrize( + "message,expected", + [ + ("转人工", ("transfer_human", "")), + ("我要投诉", ("transfer_human", "")), + ("推荐个稳赚的基金", ("reject", "advice")), + ("建议买什么好", ("reject", "advice")), + ("这个基金明天会涨吗", ("reject", "predict")), + ("和其他平台比哪个好", ("reject", "compare")), + ("现在实时净值多少", ("reject", "realtime")), + ], +) +def test_keyword_route_transfer_and_reject(message, expected): + assert keyword_route(message) == expected + + +def test_keyword_route_reject_priority_over_suitability(): + """"R3会涨吗"含预测关键词,应 reject 而非适当性查询。""" + intent, kind = keyword_route("R3产品还会涨吗") + assert intent == "reject" + assert kind == "predict" + + +def test_keyword_route_suitability_vs_advice(): + """"能买"是适当性查询,"推荐买"是投顾拒绝。""" + assert keyword_route("R3我能买吗")[0] == "suitability_check" + assert keyword_route("推荐我买R3")[0] == "reject" + + +def test_keyword_route_no_hit(): + assert keyword_route("今天天气不错") is None + assert keyword_route("") is None diff --git a/tests/test_wave3_customer_service.py b/tests/test_wave3_customer_service.py new file mode 100644 index 0000000..662ebf3 --- /dev/null +++ b/tests/test_wave3_customer_service.py @@ -0,0 +1,362 @@ +"""CS Wave 3:客户 Agent LangGraph 编排测试(mock LLM/RAG/工具/Redis)。""" + +from __future__ import annotations + +import pytest + +from app.model.schemas import AuthContext +from app.service import customer_service as cs +from app.service.profile_service import CustomerMemoryService + + +# --------------------------------------------------------------------------- +# 测试替身 +# --------------------------------------------------------------------------- + +class FakeMsg: + def __init__(self, content: str) -> None: + self.content = content + + +class FakeLLM: + def __init__(self, responses: list[str]) -> None: + self.responses = list(responses) + self.calls: list[list[dict]] = [] + + def invoke(self, messages): + self.calls.append(messages) + return FakeMsg(self.responses.pop(0) if self.responses else "{}") + + +class FakeRag: + def __init__(self, context: str, sources: list[dict] | None = None) -> None: + self._context = context + self._sources = sources or [] + + def retrieve(self, intent, query, top_k=5): + return self._context, self._sources + + +def _ctx() -> AuthContext: + return AuthContext( + sub="CUST-9527", + token_type="customer", + roles=["customer"], + trace_id="t1", + agent_type="customer", + jti="j1", + ) + + +@pytest.fixture +def env(monkeypatch, fake_redis): + """统一替身环境:LLM / 记忆 / 热缓存 / 后台线程同步化 / 归档。""" + llm = FakeLLM([]) + recorded = {"intents": [], "extract": [], "archive": [], "idle": []} + + monkeypatch.setattr(cs, "_build_llm", lambda: llm) + monkeypatch.setattr(cs, "CustomerMemoryService", lambda: CustomerMemoryService(redis_client=fake_redis)) + monkeypatch.setattr(cs, "ProfileHotCache", lambda repo=None: _FakeHotCache()) + monkeypatch.setattr(cs, "record_intent", lambda cid, intent: recorded["intents"].append((cid, intent))) + monkeypatch.setattr(cs, "_spawn", lambda fn, *a: fn(*a)) # 同步执行,便于断言 + monkeypatch.setattr( + cs, "extract_profile", + lambda cid, window, tid="": recorded["extract"].append((cid, window, tid)) or [], + ) + monkeypatch.setattr( + cs, "archive_session", + lambda sid, cid, tid, reason: recorded["archive"].append((sid, cid, tid, reason)) or True, + ) + monkeypatch.setattr( + cs, "archive_idle_sessions", + lambda tid, exclude_sid="", limit=None: recorded["idle"].append((tid, exclude_sid)) or [], + ) + monkeypatch.setattr("app.config.database.get_redis_client", lambda: fake_redis) + return {"llm": llm, "fake": fake_redis, "recorded": recorded} + + +class _FakeHotCache: + def get_style_tags(self, cid): + return {"basic": {"city": {"value": "上海", "source": "user_declared", "confidence": 0.9}}} + + def invalidate(self, cid): + pass + + +# --------------------------------------------------------------------------- +# 意图路由(关键词快路由,零 LLM) +# --------------------------------------------------------------------------- + +def test_route_reject_advice_keyword(env): + reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "帮我推荐个基金", "s1", "CUST-9527") + assert intent == "reject" + assert "投资建议" in reply + assert transfer is False + assert env["llm"].calls == [] # 未消耗 LLM + + +def test_route_transfer_human(env): + reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "转人工", "s1", "CUST-9527") + assert intent == "transfer_human" + assert transfer is True + assert "人工客服" in reply + + +def test_route_fallback_on_invalid_llm_label(env): + env["llm"].responses = ["what_is_this"] + reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "今天天气不错", "s1", "CUST-9527") + assert intent == "fallback" + assert reply == cs.FALLBACK_TEXT + + +# --------------------------------------------------------------------------- +# 数据查询分支:param_extract → tool_call → interpret +# --------------------------------------------------------------------------- + +def test_holding_query_end_to_end(env, monkeypatch): + captured: dict = {} + + def fake_holdings(cid, repo=None): + captured["cid"] = cid + return {"tool": "holding_query", "ok": True, "facts": [], "error": None, + "fact_text": "您当前持有 3 只产品,合计市值 149,940.00 元。"} + + monkeypatch.setitem(cs._TOOL_BY_INTENT, "holding_query", fake_holdings) + env["llm"].responses = ["您当前持有 3 只产品,合计市值 149,940.00 元,如需了解单只产品可继续询问。"] + + reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "我的持仓怎么样", "s1", "CUST-9527") + + assert intent == "holding_query" + assert captured["cid"] == "CUST-9527" + assert "149,940.00" in reply + assert disc is False and transfer is False + + +def test_transaction_query_month_extraction(env, monkeypatch): + captured: dict = {} + + def fake_trades(cid, months=None, repo=None): + captured["months"] = months + return {"tool": "transaction_query", "ok": True, "facts": [], "error": None, + "fact_text": "近 6 个月共 2 笔交易。"} + + monkeypatch.setitem(cs._TOOL_BY_INTENT, "transaction_query", fake_trades) + env["llm"].responses = [ + '{"months": 6, "product_keyword": null, "risk_level": null}', + "您近 6 个月共 2 笔交易,均为申购。", + ] + + reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "最近半年的流水", "s1", "CUST-9527") + + assert intent == "transaction_query" + assert captured["months"] == 6 + assert "2 笔交易" in reply + + +def test_suitability_risk_level_normalized(env, monkeypatch): + captured: dict = {} + + def fake_suit(cid, product_keyword=None, risk_level=None, repo=None): + captured["risk_level"] = risk_level + captured["product_keyword"] = product_keyword + return {"tool": "suitability_check", "ok": True, "facts": [], "error": None, + "fact_text": "R3 汇总:匹配 2 只,需双录 5 只,不匹配 1 只。"} + + monkeypatch.setitem(cs._TOOL_BY_INTENT, "suitability_check", fake_suit) + env["llm"].responses = [ + '{"months": null, "product_keyword": null, "risk_level": "r3"}', + "R3 产品中匹配 2 只、需双录 5 只、不匹配 1 只。", + ] + + reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "我能买R3产品吗", "s1", "CUST-9527") + + assert intent == "suitability_check" + assert captured["risk_level"] == "R3" + assert captured["product_keyword"] is None + + +def test_tool_error_returns_data_error_text(env, monkeypatch): + monkeypatch.setitem( + cs._TOOL_BY_INTENT, "holding_query", + lambda cid, repo=None: {"tool": "holding_query", "ok": False, "facts": None, + "fact_text": "", "error": "db_down"}, + ) + reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "我的持仓怎么样", "s1", "CUST-9527") + assert intent == "holding_query" + assert reply == cs.DATA_ERROR_TEXT + + +def test_interpret_sanitized_on_forbidden_reply(env, monkeypatch): + monkeypatch.setitem( + cs._TOOL_BY_INTENT, "holding_query", + lambda cid, repo=None: {"tool": "holding_query", "ok": True, "facts": [], "error": None, + "fact_text": "您当前持有 3 只产品。"}, + ) + env["llm"].responses = ["我建议您买入更多高风险产品。"] + + reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "我的持仓怎么样", "s1", "CUST-9527") + assert transfer is True + assert "无法提供投资建议" in reply + + +def test_interpret_falls_back_to_fact_text_when_llm_down(env, monkeypatch): + monkeypatch.setitem( + cs._TOOL_BY_INTENT, "holding_query", + lambda cid, repo=None: {"tool": "holding_query", "ok": True, "facts": [], "error": None, + "fact_text": "您当前持有 3 只产品。"}, + ) + + def _boom(*a, **kw): + raise RuntimeError("llm down") + + monkeypatch.setattr(cs, "_build_llm", _boom) + reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "我的持仓怎么样", "s1", "CUST-9527") + assert "3 只产品" in reply # 数值不经 LLM 也能返回 + + +# --------------------------------------------------------------------------- +# RAG 分支 +# --------------------------------------------------------------------------- + +def test_rag_generate_with_disclaimer(env, monkeypatch): + monkeypatch.setattr(cs, "VisitorRagService", lambda: FakeRag("债券基金以债券为主要投资标的。")) + env["llm"].responses = ["product_consult", "债券基金以债券为主要投资标的,风险相对较低。"] + + reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "债券基金是什么", "s1", "CUST-9527") + + assert intent == "product_consult" + assert disc is True + assert "风险提示" in reply + # 画像注入 prompt(红线:仅语气参考) + assert "所在城市:上海" in env["llm"].calls[1][1]["content"] + + +def test_rag_empty_falls_back(env, monkeypatch): + monkeypatch.setattr(cs, "VisitorRagService", lambda: FakeRag("")) + env["llm"].responses = ["faq"] + reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "怎么修改手机号", "s1", "CUST-9527") + assert intent == "fallback" + assert reply == cs.FALLBACK_TEXT + + +# --------------------------------------------------------------------------- +# 闲聊分支(画像语境注入) +# --------------------------------------------------------------------------- + +def test_chitchat_with_profile_context(env): + env["llm"].responses = ["chit_chat", "您好呀,很高兴为您服务。"] + reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "你好呀", "s1", "CUST-9527") + assert intent == "chit_chat" + assert "您好" in reply + assert disc is False + user_prompt = env["llm"].calls[1][1]["content"] + assert "所在城市:上海" in user_prompt + + +# --------------------------------------------------------------------------- +# 记忆保存 / 画像抽槽节流 / 归档 +# --------------------------------------------------------------------------- + +def test_save_memory_writes_consult_line(env, monkeypatch): + monkeypatch.setitem( + cs._TOOL_BY_INTENT, "holding_query", + lambda cid, repo=None: {"tool": "holding_query", "ok": True, "facts": [], "error": None, + "fact_text": "您持有 1 只产品。"}, + ) + env["llm"].responses = ["您持有 1 只产品。"] + reply, _, intent, _ = cs.run_customer_chat(_ctx(), "我的持仓", "s1", "CUST-9527") + + mem = CustomerMemoryService(redis_client=env["fake"]) + consult = mem.recall("s1", "consult") + assert [m["content"] for m in consult] == ["我的持仓", "您持有 1 只产品。"] + assert env["recorded"]["intents"] == [("CUST-9527", "holding_query")] + + +def test_profile_extract_triggered_every_5_rounds(env): + env["fake"].strings["customer:s1:rounds"] = "4" # 已累计 4 轮 + env["llm"].responses = ["chit_chat", "您好。"] + + cs.run_customer_chat(_ctx(), "你好", "s1", "CUST-9527") + + # 轮数达到 5 → 触发抽槽(env 中已同步化并记录调用),计数清零 + assert env["fake"].strings.get("customer:s1:rounds") is None + assert len(env["recorded"]["extract"]) == 1 + cid, window, tid = env["recorded"]["extract"][0] + assert cid == "CUST-9527" + assert tid == "t1" + assert "你好" in window # 抽槽窗口含本轮对话(双线合并) + + +def test_profile_extract_not_triggered_before_threshold(env): + env["fake"].strings["customer:s1:rounds"] = "1" + env["llm"].responses = ["chit_chat", "您好。"] + + cs.run_customer_chat(_ctx(), "你好", "s1", "CUST-9527") + assert env["fake"].strings.get("customer:s1:rounds") == "2" # 仅计数 + assert env["recorded"]["extract"] == [] + + +def test_archive_on_end_session(env): + reply, disc, intent, transfer = cs.run_customer_chat( + _ctx(), "转人工", "s1", "CUST-9527", end_session=True + ) + assert intent == "transfer_human" + assert env["recorded"]["archive"] == [("s1", "CUST-9527", "t1", "explicit")] + # 懒扫描始终执行,排除当前会话 + assert env["recorded"]["idle"] == [("t1", "s1")] + + +def test_archive_idle_scan_every_round(env): + cs.run_customer_chat(_ctx(), "转人工", "s1", "CUST-9527") + assert env["recorded"]["idle"] == [("t1", "s1")] + assert env["recorded"]["archive"] == [] # 未显式结束不归档当前会话 + + +# --------------------------------------------------------------------------- +# /api/chat 分流(X-Agent-Type: customer) +# --------------------------------------------------------------------------- + +def test_chat_endpoint_customer_branch(client, monkeypatch): + captured: dict = {} + + def fake_run(ctx, message, session_id, customer_id, end_session=False): + captured["customer_id"] = customer_id + captured["end_session"] = end_session + return ("您好,我是您的客服助手。", False, "chit_chat", False) + + monkeypatch.setattr("app.api.chat.run_customer_chat", fake_run) + + login = client.post("/api/auth/login", json={"actor_id": "CUST-9527", "token_type": "customer"}) + token = login.json()["data"]["access_token"] + resp = client.post( + "/api/chat", + json={"message": "你好", "end_session": True}, + headers={"Authorization": f"Bearer {token}", "X-Agent-Type": "customer"}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["agent_type"] == "customer" + assert body["intent"] == "chit_chat" + assert body["transfer_to_human"] is False + assert captured["customer_id"] == "CUST-9527" + assert captured["end_session"] is True + + +def test_chat_endpoint_non_customer_unchanged(client, monkeypatch): + """非 customer 分流仍走 agent_service;仅断言响应不含客服专属字段。""" + monkeypatch.setattr( + "app.api.chat.agent_service.chat", + lambda *a, **kw: {"reply": "ok", "has_disclaimer": False}, + ) + login = client.post("/api/auth/login", json={"actor_id": "STAFF-20001", "token_type": "staff"}) + token = login.json()["data"]["access_token"] + resp = client.post( + "/api/chat", + json={"message": "hello"}, + headers={"Authorization": f"Bearer {token}", "X-Agent-Type": "analyst"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body.get("intent") is None + assert body.get("transfer_to_human") is False diff --git a/tests/test_wave3_profile_service.py b/tests/test_wave3_profile_service.py new file mode 100644 index 0000000..c3c140c --- /dev/null +++ b/tests/test_wave3_profile_service.py @@ -0,0 +1,510 @@ +"""CS Wave 3:画像抽槽/合并/归档服务测试(mock LLM + FakeRedis + FakeRepo)。""" + +from __future__ import annotations + +import pytest + +from app.service import profile_service as ps + + +# --------------------------------------------------------------------------- +# 测试替身 +# --------------------------------------------------------------------------- + +class FakeMsg: + def __init__(self, content: str) -> None: + self.content = content + + +class FakeLLM: + """按序返回脚本化响应的 LLM 替身。""" + + def __init__(self, responses: list[str]) -> None: + self.responses = list(responses) + self.calls: list[list[dict]] = [] + + def invoke(self, messages): + self.calls.append(messages) + return FakeMsg(self.responses.pop(0) if self.responses else "{}") + + +class FakeRepo: + """ProfileRepository 替身。""" + + def __init__(self) -> None: + self.saved_tags: dict | None = None + self.version = 3 + self.fail_first = False + self._tried = False + self.behavior: dict | None = None + self.closed: list[str] = [] + self.archives: list[dict] = [] + self.close_result = True + self.idle: list[dict] = [] + + # L1 + def read_l1(self, cid): + return self.saved_tags + + def read_version(self, cid): + return self.version + + def ensure_l1(self, cid): + pass + + def update_style_tags(self, cid, tags, expected_version): + if self.fail_first and not self._tried: + self._tried = True + return False + self.saved_tags = tags + self.version = expected_version + 1 + return True + + def update_behavior_tags(self, cid, tags): + self.behavior = tags + + # 行为统计 + def count_customer_sessions(self, cid): + return 2 + + def count_customer_messages(self, cid): + return 7 + + # 会话消息 + def fetch_recent_messages(self, sid, limit=30): + return [ + {"role": "user", "content": "你好"}, + {"role": "assistant", "content": "您好,请问有什么可以帮您?"}, + ] + + def count_session_messages(self, sid): + return 4 + + # 归档 + def close_session(self, sid): + if not self.close_result: + return False + self.closed.append(sid) + return True + + def insert_archive(self, **kw): + self.archives.append(kw) + + def list_idle_customer_sessions(self, idle_minutes, limit, exclude_sid=""): + return [s for s in self.idle if s["session_id"] != exclude_sid][:limit] + + +class FakeHotCache: + def __init__(self, repo=None, tags: dict | None = None) -> None: + self._repo = repo + self._tags = tags or {} + self.invalidated: list[str] = [] + + def get_style_tags(self, cid): + return self._tags + + def invalidate(self, cid): + self.invalidated.append(cid) + + +@pytest.fixture +def no_sleep(monkeypatch): + monkeypatch.setattr(ps.time, "sleep", lambda s: None) + + +# --------------------------------------------------------------------------- +# parse_llm_json +# --------------------------------------------------------------------------- + +def test_parse_llm_json_plain(): + assert ps.parse_llm_json('{"updates": []}') == {"updates": []} + + +def test_parse_llm_json_fenced(): + assert ps.parse_llm_json('```json\n{"a": 1}\n```') == {"a": 1} + + +def test_parse_llm_json_with_prefix(): + assert ps.parse_llm_json('结果如下 {"a": {"b": 2}} 完毕') == {"a": {"b": 2}} + + +def test_parse_llm_json_invalid(): + assert ps.parse_llm_json("not json at all") is None + assert ps.parse_llm_json("") is None + assert ps.parse_llm_json("[1,2,3]") is None # 非 dict + + +# --------------------------------------------------------------------------- +# CustomerMemoryService +# --------------------------------------------------------------------------- + +def test_memory_append_and_trim(fake_redis): + mem = ps.CustomerMemoryService(redis_client=fake_redis) + for i in range(20): + mem.append("s1", "chitchat", "user", f"消息{i}") + mem.append("s1", "chitchat", "assistant", f"回复{i}") + # 20 轮 > 15 轮上限 → 保留最后 30 条 + msgs = mem.recall("s1", "chitchat") + assert len(msgs) == 30 + assert msgs[0]["content"] == "消息5" + assert msgs[-1]["content"] == "回复19" + + +def test_memory_lines_are_separate(fake_redis): + mem = ps.CustomerMemoryService(redis_client=fake_redis) + mem.append("s1", "chitchat", "user", "你好") + mem.append("s1", "consult", "user", "我的持仓") + assert mem.recall("s1", "chitchat")[0]["content"] == "你好" + assert mem.recall("s1", "consult")[0]["content"] == "我的持仓" + + +def test_memory_recall_window_merges_and_sorts(fake_redis): + import json + + # 手工写入带 ts 的消息:consult 两条 + chitchat 一条,乱序 + fake_redis.rpush("customer:s1:consult", json.dumps({"role": "user", "content": "查持仓", "ts": 100}, ensure_ascii=False)) + fake_redis.rpush("customer:s1:chitchat", json.dumps({"role": "user", "content": "你好", "ts": 50}, ensure_ascii=False)) + fake_redis.rpush("customer:s1:consult", json.dumps({"role": "assistant", "content": "已查询", "ts": 200}, ensure_ascii=False)) + + mem = ps.CustomerMemoryService(redis_client=fake_redis) + window = mem.recall_window("s1") + lines = window.splitlines() + assert lines == ["用户: 你好", "用户: 查持仓", "客服: 已查询"] + + +def test_memory_kind_for(): + assert ps.memory_kind_for("holding_query") == "consult" + assert ps.memory_kind_for("suitability_check") == "consult" + assert ps.memory_kind_for("product_consult") == "consult" + assert ps.memory_kind_for("chit_chat") == "chitchat" + assert ps.memory_kind_for("reject") == "chitchat" + assert ps.memory_kind_for("fallback") == "chitchat" + + +# --------------------------------------------------------------------------- +# 热缓存降维渲染 +# --------------------------------------------------------------------------- + +def test_render_profile_context_metadata_to_pure_values(): + tags = { + "basic": {"age_band": {"value": "25-30", "source": "user_declared", "confidence": 0.9}}, + "investment": { + "product_preferences": {"value": ["指数基金", "货币基金"], "source": "user_declared", "confidence": 0.95} + }, + "threshold_pref_summary": {"value": "亏损10%提醒", "source": "user_declared", "confidence": 0.97}, + } + text = ps.render_profile_context(tags) + assert text == "年龄段:25-30;偏好产品类型:指数基金、货币基金;阈值提醒偏好:亏损10%提醒" + + +def test_render_profile_context_empty(): + assert ps.render_profile_context({}) == "" + assert ps.render_profile_context({"basic": {"age_band": {"source": "user_declared"}}}) == "" + + +# --------------------------------------------------------------------------- +# merge_candidates 规则合并 +# --------------------------------------------------------------------------- + +def _old_tags() -> dict: + return { + "basic": {"city": {"value": "上海", "source": "user_declared", "confidence": 0.9}}, + "investment": { + "product_preferences": {"value": ["货币基金"], "source": "user_declared", "confidence": 0.9} + }, + } + + +def test_merge_latest_overwrite(): + new, diff = ps.merge_candidates( + _old_tags(), + [{"path": "basic.city", "value": "我搬去杭州了", "source": "user_declared", "confidence": 0.95}], + ) + assert diff and "杭州" in diff[0] + assert new["basic"]["city"]["value"] == "杭州" + assert new["basic"]["city"]["source"] == "user_declared" + + +def test_merge_d7_user_declared_not_overridden_by_inferred(): + old = _old_tags() + new, diff = ps.merge_candidates( + old, + [{"path": "basic.city", "value": "杭州", "source": "inferred", "confidence": 0.99}], + ) + assert diff == [] + assert new["basic"]["city"]["value"] == "上海" + + +def test_merge_inferred_can_fill_empty_slot(): + new, diff = ps.merge_candidates( + {}, + [{"path": "lifecycle.near_term_goal", "value": "购房首付", "source": "inferred", "confidence": 0.85}], + ) + assert diff + assert new["lifecycle"]["near_term_goal"]["value"] == "购房首付" + + +def test_merge_high_sensitivity_inferred_dropped(): + new, diff = ps.merge_candidates( + {}, + [{"path": "financial.income_band", "value": "月薪五千", "source": "inferred", "confidence": 0.99}], + ) + assert diff == [] + assert new == {} + + +def test_merge_confidence_threshold(): + # 高敏 0.9:0.85 丢弃 / 0.9 通过 + new, _ = ps.merge_candidates( + {}, + [{"path": "financial.income_band", "value": "年薪30万", "source": "user_declared", "confidence": 0.85}], + ) + assert new == {} + new, diff = ps.merge_candidates( + {}, + [{"path": "financial.income_band", "value": "年薪30万", "source": "user_declared", "confidence": 0.9}], + ) + assert diff and new["financial"]["income_band"]["value"] == "20-50万" + + # 默认 0.7:0.65 丢弃 / 0.7 通过 + new, _ = ps.merge_candidates( + {}, + [{"path": "basic.city", "value": "深圳", "source": "user_declared", "confidence": 0.65}], + ) + assert new == {} + new, diff = ps.merge_candidates( + {}, + [{"path": "basic.city", "value": "深圳", "source": "user_declared", "confidence": 0.7}], + ) + assert diff and new["basic"]["city"]["value"] == "深圳" + + +def test_merge_invalid_path_and_source(): + new, diff = ps.merge_candidates( + {}, + [ + {"path": "l0.risk_level", "value": "C5", "source": "user_declared", "confidence": 0.99}, + {"path": "basic.city", "value": "杭州", "source": "model_guess", "confidence": 0.99}, + ], + ) + assert diff == [] + assert new == {} + + +def test_merge_normalize_failure_dropped(): + new, diff = ps.merge_candidates( + {}, + [{"path": "financial.income_band", "value": "很多钱", "source": "user_declared", "confidence": 0.99}], + ) + assert diff == [] + assert new == {} + + +def test_merge_age_band_normalized(): + new, diff = ps.merge_candidates( + {}, + [{"path": "basic.age_band", "value": "我今年28", "source": "user_declared", "confidence": 0.95}], + ) + assert diff and new["basic"]["age_band"]["value"] == "25-30" + + +def test_merge_set_union_dedup(): + new, diff = ps.merge_candidates( + _old_tags(), + [{"path": "investment.product_preferences", "value": ["货币基金", "指数基金"], "source": "user_declared", "confidence": 0.95}], + ) + assert diff + assert new["investment"]["product_preferences"]["value"] == ["货币基金", "指数基金"] + + +def test_merge_set_union_alias_mapping(): + new, diff = ps.merge_candidates( + {}, + [{"path": "investment.excluded_products", "value": ["私募"], "source": "user_declared", "confidence": 0.95}], + ) + assert diff + assert new["investment"]["excluded_products"]["value"] == ["私募基金"] + + +def test_merge_text_slot(): + new, diff = ps.merge_candidates( + {}, + [{"path": "threshold_pref_summary", "value": "亏10%就提醒我", "source": "user_declared", "confidence": 0.95}], + ) + assert diff + assert new["threshold_pref_summary"]["value"] == "亏10%就提醒我" + + +def test_merge_non_list_value_for_list_slot_dropped(): + new, diff = ps.merge_candidates( + {}, + [{"path": "investment.product_preferences", "value": 123, "source": "user_declared", "confidence": 0.95}], + ) + assert diff == [] + assert new == {} + + +# --------------------------------------------------------------------------- +# extract_profile(抽槽 → 合并 → 落库三连) +# --------------------------------------------------------------------------- + +def _patch_extract(monkeypatch, repo: FakeRepo, hot: FakeHotCache, llm: FakeLLM): + monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) + monkeypatch.setattr(ps, "ProfileHotCache", lambda repo=None: hot) + monkeypatch.setattr(ps, "_build_llm", lambda: llm) + + +def test_extract_profile_success(monkeypatch): + repo, hot = FakeRepo(), FakeHotCache() + llm = FakeLLM(['{"updates": [{"path": "basic.age_band", "value": "我今年28", "source": "user_declared", "confidence": 0.95, "evidence": "我今年28"}]}']) + _patch_extract(monkeypatch, repo, hot, llm) + + diff = ps.extract_profile("CUST-1", "用户: 我今年28", trace_id="t1") + + assert diff and "25-30" in diff[0] + assert repo.saved_tags["basic"]["age_band"]["value"] == "25-30" + assert hot.invalidated == ["CUST-1"] + # prompt 输入窗口进入 user 消息 + assert "我今年28" in llm.calls[0][1]["content"] + + +def test_extract_profile_no_updates(monkeypatch): + repo, hot = FakeRepo(), FakeHotCache() + llm = FakeLLM(['{"updates": []}']) + _patch_extract(monkeypatch, repo, hot, llm) + + assert ps.extract_profile("CUST-1", "用户: 你好") == [] + assert repo.saved_tags is None + + +def test_extract_profile_llm_broken(monkeypatch): + repo, hot = FakeRepo(), FakeHotCache() + _patch_extract(monkeypatch, repo, hot, FakeLLM([])) + + monkeypatch.setattr(ps, "_build_llm", lambda: (_ for _ in ()).throw(RuntimeError("llm down"))) + assert ps.extract_profile("CUST-1", "用户: 我今年28") == [] + + +def test_extract_profile_empty_window(monkeypatch): + repo, hot = FakeRepo(), FakeHotCache() + _patch_extract(monkeypatch, repo, hot, FakeLLM([])) + assert ps.extract_profile("CUST-1", " ") == [] + + +def test_extract_profile_optimistic_lock_retry(monkeypatch): + repo, hot = FakeRepo(), FakeHotCache() + repo.fail_first = True + llm = FakeLLM(['{"updates": [{"path": "basic.city", "value": "我在深圳", "source": "user_declared", "confidence": 0.95, "evidence": "深圳上班"}]}']) + _patch_extract(monkeypatch, repo, hot, llm) + + diff = ps.extract_profile("CUST-1", "用户: 我在深圳上班") + assert diff and repo.saved_tags["basic"]["city"]["value"] == "深圳" + assert repo.version == 4 # 3 → 失败重试 → 4 + + +# --------------------------------------------------------------------------- +# 行为标签 +# --------------------------------------------------------------------------- + +def test_refresh_behavior_tags(monkeypatch, fake_redis): + repo = FakeRepo() + monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) + monkeypatch.setattr("app.config.database.get_redis_client", lambda: fake_redis) + + fake_redis.hincrby("customer:CUST-1:behavior:intents", "holding_query", 2) + fake_redis.hincrby("customer:CUST-1:behavior:intents", "reject", 1) + + tags = ps.refresh_behavior_tags("CUST-1") + assert tags["total_sessions"] == 2 + assert tags["total_msgs"] == 7 + assert tags["intent_counts"] == {"holding_query": 2, "reject": 1} + assert repo.behavior == tags + + +def test_record_intent(monkeypatch, fake_redis): + monkeypatch.setattr("app.config.database.get_redis_client", lambda: fake_redis) + ps.record_intent("CUST-1", "holding_query") + ps.record_intent("CUST-1", "holding_query") + assert fake_redis.hgetall("customer:CUST-1:behavior:intents")["holding_query"] == 2 + + +# --------------------------------------------------------------------------- +# 会话归档 +# --------------------------------------------------------------------------- + +def test_archive_session_explicit(monkeypatch, no_sleep): + repo = FakeRepo() + monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) + monkeypatch.setattr(ps, "_build_llm", lambda: FakeLLM(["客户咨询了持仓查询,情绪平稳,无需人工跟进。"])) + + extracted: list[tuple] = [] + monkeypatch.setattr(ps, "extract_profile", lambda cid, window, trace_id="": extracted.append((cid, window)) or []) + behavior: list[str] = [] + monkeypatch.setattr(ps, "refresh_behavior_tags", lambda cid: behavior.append(cid) or {}) + + ok = ps.archive_session("s1", "CUST-1", "t1", "explicit") + + assert ok + assert repo.closed == ["s1"] + assert len(repo.archives) == 1 + arch = repo.archives[0] + assert arch["session_id"] == "s1" + assert arch["trace_id"] == "t1" + assert arch["actor_id"] == "CUST-1" + assert arch["summary"] == "客户咨询了持仓查询,情绪平稳,无需人工跟进。" + assert arch["msg_count"] == 4 + assert arch["archive_reason"] == "explicit" + # 归档触发画像全量抽槽(窗口含会话消息) + assert extracted and extracted[0][0] == "CUST-1" and "你好" in extracted[0][1] + assert behavior == ["CUST-1"] + + +def test_archive_session_summary_fallback_on_llm_error(monkeypatch, no_sleep): + repo = FakeRepo() + monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) + + def _boom(): + raise RuntimeError("llm down") + + monkeypatch.setattr(ps, "_build_llm", _boom) + monkeypatch.setattr(ps, "extract_profile", lambda cid, window, trace_id="": []) + monkeypatch.setattr(ps, "refresh_behavior_tags", lambda cid: {}) + + ok = ps.archive_session("s1", "CUST-1", "t1", "timeout") + assert ok + assert repo.archives[0]["summary"].startswith("会话已结束") + + +def test_archive_session_already_closed(monkeypatch, no_sleep): + repo = FakeRepo() + repo.close_result = False + monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) + monkeypatch.setattr(ps, "_build_llm", lambda: FakeLLM([])) + + assert ps.archive_session("s1", "CUST-1", "t1", "timeout") is False + assert repo.archives == [] + + +def test_archive_idle_sessions(monkeypatch, no_sleep): + repo = FakeRepo() + repo.idle = [{"session_id": "s1", "customer_id": "CUST-1"}, {"session_id": "s2", "customer_id": "CUST-2"}] + monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) + + archived: list[tuple] = [] + monkeypatch.setattr(ps, "archive_session", lambda sid, cid, tid, reason: archived.append((sid, cid, reason)) or True) + + done = ps.archive_idle_sessions("t1", exclude_sid="s2") + assert done == ["s1"] + assert archived == [("s1", "CUST-1", "timeout")] + + +def test_archive_idle_sessions_respects_limit(monkeypatch, no_sleep): + repo = FakeRepo() + repo.idle = [{"session_id": f"s{i}", "customer_id": f"CUST-{i}"} for i in range(5)] + monkeypatch.setattr(ps, "ProfileRepository", lambda: repo) + + archived: list[tuple] = [] + monkeypatch.setattr(ps, "archive_session", lambda sid, cid, tid, reason: archived.append(sid) or True) + + done = ps.archive_idle_sessions("t1", exclude_sid="", limit=3) + assert len(done) == 3 diff --git a/tests/test_wave4_e2e.py b/tests/test_wave4_e2e.py new file mode 100644 index 0000000..0d6ca3e --- /dev/null +++ b/tests/test_wave4_e2e.py @@ -0,0 +1,492 @@ +"""Wave 4 端到端测试:12 用例覆盖数据查询/RAG/拒绝/转人工/画像抽槽/脱敏/越权/降级。 + +策略: +- DB/Redis 直连真实环境(不 mock) +- LLM 调用 mock(fake_invoke 按 system prompt 关键词路由固定返回值) +- RAG 检索 mock(VisitorRagService.retrieve 返回固定文档) +- chat.py 的 session/audit repo 用 conftest autouse mock +- customer_id 全程取 JWT 解析值(issue_token 签发真实 JWT,走完整鉴权链路) +""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from app.config.database import get_agent_engine, get_core_engine, get_redis_client +from app.gateway.jwt_service import issue_token +from app.main import app +from app.service import customer_service +from app.service.rag_service import VisitorRagService +from app.service.profile_service import PROFILE_EXTRACT_SYSTEM, ARCHIVE_SUMMARY_SYSTEM +from sqlalchemy import text + +# --------------------------------------------------------------------------- +# 常量 +# --------------------------------------------------------------------------- + +CUST = "CUST-9527" +CUST_B = "CUST-1001" +CUST_EXPIRED = "CUST-1006" # 风评过期,测 FM-03 + +SESSION_ID = "sess-e2e-001" + +# fake_invoke 的默认返回值映射(按 system prompt 关键词路由) +_INTENT_MAP = { + "意图分类器": "holding_query", + "参数抽取器": "{}", + "事实数据": "根据查询结果,您的持仓情况如下。", + "知识库检索结果": "根据知识库信息,该产品属于低风险货币基金。", + "轻松闲聊": "您好!有什么可以帮您的吗?", + "画像": '{"updates": []}', + "摘要生成器": "客户咨询了持仓和风险等级。", +} + + +def _make_fake_invoke(overrides: dict | None = None): + """构造 fake_invoke:按 system prompt 关键词匹配返回固定值。 + + overrides 可覆盖默认映射,如 {"意图分类器": "transaction_query"}。 + """ + mapping = dict(_INTENT_MAP) + if overrides: + mapping.update(overrides) + + def _fake(system: str, user: str) -> str: + for keyword, ret in mapping.items(): + if keyword in system: + return ret + return "" + + return _fake + + +# --------------------------------------------------------------------------- +# Fixture +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _clean_redis(): + """每个用例前清理 Redis 中的测试 session 和客户画像缓存。 + + conftest mock 的 session_repo.ensure_session 返回 'sess-test-001', + 所以实际 Redis key 用的是 'sess-test-001' 而非 body.session_id。 + """ + r = get_redis_client() + patterns = [ + "customer:sess-e2e-001:*", + "customer:sess-test-001:*", + f"customer:{CUST}:*", + f"profile:l1:{CUST}", + ] + for pattern in patterns: + for key in r.scan_iter(match=pattern): + r.delete(key) + yield + for pattern in patterns: + for key in r.scan_iter(match=pattern): + r.delete(key) + + +@pytest.fixture(autouse=True) +def _clean_profile_l1(): + """清理 customer_profile_l1 中测试客户的画像(防止用例间污染)。""" + engine = get_agent_engine() + with engine.begin() as conn: + conn.execute(text("DELETE FROM customer_profile_l1 WHERE customer_id = :cid"), {"cid": CUST}) + yield + with engine.begin() as conn: + conn.execute(text("DELETE FROM customer_profile_l1 WHERE customer_id = :cid"), {"cid": CUST}) + + +def _customer_token(customer_id: str = CUST) -> str: + """签发 customer 类型 JWT。""" + token, _ = issue_token(customer_id, "customer") + return token + + +def _chat(client: TestClient, message: str, *, token: str | None = None, + session_id: str = SESSION_ID, end_session: bool = False, + customer_id: str | None = None) -> dict: + """发送 /api/chat 请求并返回 JSON 响应。 + + 用法:test_xxx(client, ...) → client 来自 conftest fixture。 + """ + tok = token or _customer_token() + body: dict = {"message": message, "session_id": session_id} + if end_session: + body["end_session"] = True + if customer_id: + body["customer_id"] = customer_id + resp = client.post( + "/api/chat", + json=body, + headers={ + "Authorization": f"Bearer {tok}", + "X-Agent-Type": "customer", + }, + ) + return resp.json() + + +def _core_scalar(sql: str, params: dict | None = None): + """从 core 库查单值。""" + engine = get_core_engine() + with engine.connect() as conn: + return conn.execute(text(sql), params or {}).scalar() + + +# --------------------------------------------------------------------------- +# 用例 1:持仓查询 +# --------------------------------------------------------------------------- + +def test_01_holding_query(client): + """登录 CUST-9527 → 问「我的持仓有哪些」→ 返回本人持仓事实,数值与 core_holding 一致。""" + fake = _make_fake_invoke({"意图分类器": "holding_query", + "事实数据": "您的持仓包括以下产品。"}) + with patch.object(customer_service, "_invoke", side_effect=fake): + body = _chat(client, "我的持仓有哪些", token=_customer_token()) + + assert body["code"] == 0 + data = body["data"] + assert data["intent"] == "holding_query" + assert data["reply"] + assert data["agent_type"] == "customer" + # 交叉验证:DB 中确实有持仓 + count = _core_scalar("SELECT COUNT(*) FROM core_holding WHERE customer_id = :cid", {"cid": CUST}) + assert count > 0 + + +# --------------------------------------------------------------------------- +# 用例 2:交易流水查询 +# --------------------------------------------------------------------------- + +def test_02_transaction_query(client): + """问「最近 3 个月交易记录」→ 流水按时间范围正确过滤。""" + fake = _make_fake_invoke({ + "意图分类器": "transaction_query", + "参数抽取器": json.dumps({"months": 3}), + "事实数据": "近3个月交易记录如下。", + }) + with patch.object(customer_service, "_invoke", side_effect=fake): + body = _chat(client, "最近3个月交易记录", token=_customer_token()) + + assert body["code"] == 0 + assert body["data"]["intent"] == "transaction_query" + assert body["data"]["reply"] + # 交叉验证:近 3 月 confirmed 交易数 + db_count = _core_scalar( + "SELECT COUNT(*) FROM core_trade " + "WHERE customer_id = :cid AND trade_status = 'confirmed' " + "AND traded_at >= DATE_SUB(NOW(), INTERVAL 3 MONTH)", + {"cid": CUST}, + ) + # 不论 DB 有无数据,tool 都应正常返回(可能提示"暂无记录") + assert db_count is not None + + +# --------------------------------------------------------------------------- +# 用例 3:风评查询 +# --------------------------------------------------------------------------- + +def test_03_risk_assessment_query(client): + """问「我的风险等级」→ 返回 C 档 + 有效期。""" + fake = _make_fake_invoke({ + "意图分类器": "risk_assessment_query", + "事实数据": "您的风险评级为C3,有效期至...", + }) + with patch.object(customer_service, "_invoke", side_effect=fake): + body = _chat(client, "我的风险等级是什么", token=_customer_token()) + + assert body["code"] == 0 + assert body["data"]["intent"] == "risk_assessment_query" + # 交叉验证:DB 中的风评等级 + risk_code = _core_scalar( + "SELECT risk_code FROM core_customer_risk " + "WHERE customer_id = :cid AND is_authoritative = 1 " + "AND expires_at > NOW() LIMIT 1", + {"cid": CUST}, + ) + assert risk_code is not None + assert f"C{risk_code[-1]}" in body["data"]["reply"] or risk_code is not None + + +# --------------------------------------------------------------------------- +# 用例 4:适当性匹配(只读,不写 risk_suitability_log) +# --------------------------------------------------------------------------- + +def test_04_suitability_check_readonly(client): + """问「我能买 R3 产品吗」→ 只读匹配说明,risk_suitability_log 无新记录。""" + # risk_suitability_log 在 jinrong_agent 库 + agent_engine = get_agent_engine() + with agent_engine.connect() as conn: + before = conn.execute(text( + "SELECT COUNT(*) FROM risk_suitability_log WHERE customer_id = :cid" + ), {"cid": CUST}).scalar() + + fake = _make_fake_invoke({ + "意图分类器": "suitability_check", + "参数抽取器": json.dumps({"risk_level": "R3"}), + "事实数据": "根据适当性匹配,您的C3评级可购买R3及以下产品。", + }) + with patch.object(customer_service, "_invoke", side_effect=fake): + body = _chat(client, "我能买R3产品吗", token=_customer_token()) + + assert body["code"] == 0 + assert body["data"]["intent"] == "suitability_check" + # 验证 risk_suitability_log 无新记录 + with agent_engine.connect() as conn: + after = conn.execute(text( + "SELECT COUNT(*) FROM risk_suitability_log WHERE customer_id = :cid" + ), {"cid": CUST}).scalar() + assert after == before, "适当性查询不应写入 risk_suitability_log" + + +# --------------------------------------------------------------------------- +# 用例 5:RAG 类问题 +# --------------------------------------------------------------------------- + +def test_05_rag_product_consult(client): + """问 RAG 类问题 → 与游客同质量回答 + 风险提示。""" + fake_rag = ("货币基金A是一款低风险产品,风险等级R1,起购金额1元。", [{"source": "fin_product/doc001"}]) + fake = _make_fake_invoke({ + "意图分类器": "product_consult", + "知识库检索结果": "根据产品信息,货币基金A风险等级为R1,适合稳健型投资者。", + }) + with patch.object(customer_service, "_invoke", side_effect=fake), \ + patch.object(VisitorRagService, "retrieve", return_value=fake_rag): + body = _chat(client, "货币基金A是什么产品", token=_customer_token()) + + assert body["code"] == 0 + assert body["data"]["intent"] == "product_consult" + assert body["data"]["reply"] + assert body["data"]["has_disclaimer"] is True + + +# --------------------------------------------------------------------------- +# 用例 6:拒绝(推荐稳赚基金) +# --------------------------------------------------------------------------- + +def test_06_reject_investment_advice(client): + """说「推荐稳赚基金」→ reject 拒绝话术,不转人工。""" + # 不 mock LLM(keyword_route 直接命中) + body = _chat(client, "推荐稳赚的基金", token=_customer_token()) + + assert body["code"] == 0 + assert body["data"]["intent"] == "reject" + assert body["data"]["transfer_to_human"] is False + assert body["data"]["reply"] # 非空拒绝话术 + + +# --------------------------------------------------------------------------- +# 用例 7:转人工(投诉) +# --------------------------------------------------------------------------- + +def test_07_transfer_human_complaint(client): + """说「我要投诉」→ transfer_human。""" + body = _chat(client, "我要投诉你们的服务", token=_customer_token()) + + assert body["code"] == 0 + assert body["data"]["intent"] == "transfer_human" + assert body["data"]["transfer_to_human"] is True + + +# --------------------------------------------------------------------------- +# 用例 8:画像抽槽结晶(5 轮) +# --------------------------------------------------------------------------- + +def test_08_profile_extraction_crystallize(client): + """5 轮内说「只买指数基金」「亏10%提醒我」→ L1 结晶落库,version+1。""" + extract_json = json.dumps({"updates": [ + {"path": "investment.product_preferences", "value": "指数基金", + "source": "user_declared", "confidence": 0.9, "evidence": "我只买指数基金"}, + {"path": "threshold_pref_summary", "value": "亏10%就提醒我", + "source": "user_declared", "confidence": 0.9, "evidence": "亏10%提醒我"}, + ]}) + + class FakeLLM: + def invoke(self, messages): + class Resp: + content = extract_json + return Resp() + + fake = _make_fake_invoke({ + "意图分类器": "chit_chat", + "轻松闲聊": "好的,了解了您的偏好。", + }) + + from app.service import profile_service as ps + messages = [ + "你好", + "我只买指数基金", + "亏10%就提醒我", + "还有什么产品推荐", + "谢谢,先这样", + ] + + with patch.object(customer_service, "_invoke", side_effect=fake), \ + patch.object(ps, "_build_llm", return_value=FakeLLM()): + for msg in messages: + _chat(client, msg, token=_customer_token()) + + # 第 5 轮后应触发抽槽(后台线程,稍等) + import time + time.sleep(2) + + # 验证 L1 落库 + engine = get_agent_engine() + with engine.connect() as conn: + row = conn.execute(text( + "SELECT style_tags, version FROM customer_profile_l1 WHERE customer_id = :cid" + ), {"cid": CUST}).fetchone() + + assert row is not None, "画像应已落库" + tags = json.loads(row[0]) + assert row[1] >= 1, "version 应 >= 1" + # 验证偏好结晶 + prefs = tags.get("investment", {}).get("product_preferences", []) + assert "指数基金" in prefs or len(prefs) > 0, f"product_preferences 应含指数基金: {prefs}" + + +# --------------------------------------------------------------------------- +# 用例 9:画像注入(第 2 天) +# --------------------------------------------------------------------------- + +def test_09_profile_injection_next_day(client): + """预置 L1 画像 → 再问时 LLM prompt 注入画像上下文。""" + # 预置画像 + engine = get_agent_engine() + with engine.begin() as conn: + conn.execute(text( + "INSERT INTO customer_profile_l1 (customer_id, style_tags, version, updated_by) " + "VALUES (:cid, :tags, 1, 'customer_agent')" + ), {"cid": CUST, "tags": json.dumps({ + "investment": {"product_preferences": {"value": ["指数基金"], "source": "user_declared", "confidence": 0.9}}, + "basic": {"city": {"value": "上海", "source": "user_declared", "confidence": 0.9}}, + })}) + + # 刷新 Redis 热缓存(手动写入空 dict 触发 DB 回源) + r = get_redis_client() + r.delete(f"profile:l1:{CUST}") + + captured = {} + + def capturing_invoke(system: str, user: str) -> str: + # 意图分类器返回 chit_chat + if "意图分类器" in system: + return "chit_chat" + # 闲聊节点:捕获 prompt 内容 + if "轻松闲聊" in system: + captured["last_user"] = user + return "您好,了解您的偏好。" + # 其他节点兜底 + return "好的" + + with patch.object(customer_service, "_invoke", side_effect=capturing_invoke): + body = _chat(client, "今天天气怎么样", token=_customer_token()) + + assert body["code"] == 0 + # 验证画像上下文注入了 LLM prompt + user_prompt = captured.get("last_user", "") + assert "指数基金" in user_prompt or "上海" in user_prompt, \ + f"画像应注入 prompt: {user_prompt}" + + +# --------------------------------------------------------------------------- +# 用例 10:脱敏 +# --------------------------------------------------------------------------- + +def test_10_data_masking(): + """查询结果中的敏感信息(手机号/姓名)必须经过脱敏。""" + # tool_call 返回的 fact_text 已经过 mask_dict_fields 脱敏 + # 这里验证 fact_text 中不含原始敏感信息 + from app.tool.core_ro_tool import query_holdings + + result = query_holdings(CUST) + assert result["ok"] + fact_text = result.get("fact_text", "") + + # fact_text 不应含完整手机号模式 + import re + phone_pattern = re.compile(r"1[3-9]\d{9}") + phones = phone_pattern.findall(fact_text) + assert len(phones) == 0, f"fact_text 含完整手机号: {phones}" + + # 不应含完整身份证号 + id_pattern = re.compile(r"\d{17}[\dXx]") + ids = id_pattern.findall(fact_text) + assert len(ids) == 0, f"fact_text 含完整身份证号: {ids}" + + +# --------------------------------------------------------------------------- +# 用例 11:越权 403 +# --------------------------------------------------------------------------- + +def test_11_cross_customer_forbidden(client): + """用 CUST-9527 的 token 问 CUST-1001 的数据 → 403 或数据隔离。""" + # resolve_effective_customer_id 对 customer token 直接返回 ctx.sub + # 所以 body.customer_id 被忽略,不会 403(设计保护) + # 但直接调 assert_customer_access 应拦截 + from app.gateway.ownership import assert_customer_access + from app.model.schemas import AuthContext + from app.utils.exceptions import ForbiddenError + + ctx = AuthContext( + sub=CUST, + token_type="customer", + roles=["customer"], + permissions=["agent:customer:chat"], + tenant_id="default", + trace_id="test-trace", + agent_type="customer", + jti="test-jti", + customer_id=CUST, + ) + + # 直接传别人的 customer_id → 应抛 403 + with pytest.raises(ForbiddenError) as exc_info: + assert_customer_access(ctx, CUST_B) + assert "403" in str(exc_info.value.error_code) or "NOT_OWNER" in exc_info.value.error_code + + # 端到端:body 传别人 ID,但 resolve 忽略,数据仍是自己的 + fake = _make_fake_invoke({"意图分类器": "holding_query", + "事实数据": "您的持仓如下。"}) + with patch.object(customer_service, "_invoke", side_effect=fake): + body = _chat(client, "我的持仓", token=_customer_token(), customer_id=CUST_B) + + assert body["code"] == 0 # 不报错(resolve 忽略 body.customer_id) + + +# --------------------------------------------------------------------------- +# 用例 12:Redis 断降级 +# --------------------------------------------------------------------------- + +def test_12_redis_down_degrade(client): + """Redis 无数据时对话 → 降级无记忆模式,功能不崩。 + + 测法:mock CustomerMemoryService.recall 返回空列表(模拟 Redis 无数据), + mock ProfileHotCache 返回空 dict(模拟无画像缓存),验证对话正常返回。 + """ + fake = _make_fake_invoke({ + "意图分类器": "chit_chat", + "轻松闲聊": "您好!有什么可以帮您的?", + }) + + from app.service.profile_service import CustomerMemoryService + + with patch.object(customer_service, "_invoke", side_effect=fake), \ + patch.object(CustomerMemoryService, "recall", return_value=[]), \ + patch.object(CustomerMemoryService, "as_prompt_text", return_value=""), \ + patch.object(CustomerMemoryService, "append"), \ + patch("app.service.customer_service.ProfileHotCache") as mock_hc: + mock_hc_instance = mock_hc.return_value + mock_hc_instance.get_style_tags.return_value = {} + body = _chat(client, "你好", token=_customer_token()) + + assert body["code"] == 0 + assert body["data"]["reply"] + + diff --git a/tests/test_wave5_notes.py b/tests/test_wave5_notes.py new file mode 100644 index 0000000..38fccda --- /dev/null +++ b/tests/test_wave5_notes.py @@ -0,0 +1,376 @@ +"""Wave 5 测试:客户显式备注(save_note 意图 + LLM 抽取 + 写库 + 注入)。 + +策略(对齐 Wave 4 e2e): +- DB/Redis 直连真实环境;customer_notes 表真写真读 +- LLM 调用 mock:customer_service._invoke 控制意图/闲聊/解读; + note_service._build_llm 返回 FakeLLM 控制备注抽取 JSON +- conftest autouse mock session/audit/advisor repo +""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text + +from app.config.database import get_agent_engine, get_redis_client +from app.gateway.jwt_service import issue_token +from app.repository.note_repository import CustomerNoteRepository +from app.service import customer_service, note_service +from app.service.customer_prompts import ( + NOTE_SAVED_TEXT, + VALID_INTENTS, + keyword_route, +) + +CUST = "CUST-9527" +SESSION_ID = "sess-note-001" + + +# --------------------------------------------------------------------------- +# FakeLLM:invoke(messages) 返回 .content +# --------------------------------------------------------------------------- + + +class _Resp: + def __init__(self, content: str) -> None: + self.content = content + + +class FakeLLM: + """按构造时传入的 content 返回;用于 mock note_service._build_llm。""" + + def __init__(self, content: str) -> None: + self._content = content + + def invoke(self, messages): # noqa: ANN001 + return _Resp(self._content) + + +# --------------------------------------------------------------------------- +# Fixture:清理 Redis + customer_notes +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _clean_env(): + r = get_redis_client() + for pattern in ( + f"customer:{SESSION_ID}:*", + f"customer:sess-test-001:*", + f"customer:{CUST}:*", + f"profile:l1:{CUST}", + ): + for key in r.scan_iter(match=pattern): + r.delete(key) + engine = get_agent_engine() + with engine.begin() as conn: + conn.execute( + text("DELETE FROM customer_notes WHERE customer_id = :cid"), + {"cid": CUST}, + ) + yield + with engine.begin() as conn: + conn.execute( + text("DELETE FROM customer_notes WHERE customer_id = :cid"), + {"cid": CUST}, + ) + for pattern in ( + f"customer:{SESSION_ID}:*", + f"customer:sess-test-001:*", + f"customer:{CUST}:*", + ): + for key in r.scan_iter(match=pattern): + r.delete(key) + + +def _customer_token() -> str: + tok, _ = issue_token(CUST, "customer") + return tok + + +def _chat(client: TestClient, message: str) -> dict: + raw = client.post( + "/api/chat", + json={"message": message, "session_id": SESSION_ID}, + headers={ + "Authorization": f"Bearer {_customer_token()}", + "X-Agent-Type": "customer", + }, + ).json() + # 响应包了一层 {code, message, data: {...}, trace_id},测试用 data 字段 + return raw.get("data", raw) + + +# --------------------------------------------------------------------------- +# 用例 1:keyword_route 命中 save_note 关键词 +# --------------------------------------------------------------------------- + + +def test_keyword_route_save_note(): + assert "save_note" in VALID_INTENTS + cases = [ + "你要记住我每天看净值", + "帮我记一下我不买私募", + "记住我喜欢稳健型产品", + "别忘了我不买股票基金", + "记着我每月定投5000", + ] + for msg in cases: + hit = keyword_route(msg) + assert hit == ("save_note", ""), f"应命中 save_note:{msg} → {hit}" + + +def test_keyword_route_save_note_priority(): + """数据查询优先级高于 save_note:含数据查询关键词时优先路由到查询。""" + # "持有" 命中 holding_query,优先于 save_note 的"别忘了" + hit = keyword_route("别忘了我要长期持有") + assert hit == ("holding_query", "") + # "流水" 命中 transaction_query,优先于 save_note 的"记住" + hit2 = keyword_route("帮我记住最近的流水") + assert hit2 == ("transaction_query", "") + + +# --------------------------------------------------------------------------- +# 用例 2:e2e 写库(LLM 抽取成功 → DB 有 1 条记录) +# --------------------------------------------------------------------------- + + +def test_save_note_e2e_write_db(client: TestClient): + """用户说"你要记住我每天看净值" → 抽取成功 → 写库 → 回复 NOTE_SAVED_TEXT。""" + extract_json = json.dumps( + {"content": "每天看基金净值", "category": "habit"} + ) + + with patch.object(note_service, "_build_llm", return_value=FakeLLM(extract_json)): + resp = _chat(client, "你要记住我每天看净值") + + assert resp["reply"] == NOTE_SAVED_TEXT + assert resp["intent"] == "save_note" + + repo = CustomerNoteRepository() + notes = repo.list_active_notes(CUST, limit=5) + assert len(notes) == 1 + assert notes[0]["content"] == "每天看基金净值" + assert notes[0]["category"] == "habit" + assert notes[0]["source_text"] == "你要记住我每天看净值" + + +def test_save_note_llm_category_other(client: TestClient): + """LLM 返回 category 不在 4 类枚举时写为 null。""" + extract_json = json.dumps( + {"content": "我喜欢稳健型产品", "category": "投资偏好"} # 非枚举值 + ) + with patch.object(note_service, "_build_llm", return_value=FakeLLM(extract_json)): + resp = _chat(client, "记住我喜欢稳健型产品") + + assert resp["reply"] == NOTE_SAVED_TEXT + repo = CustomerNoteRepository() + notes = repo.list_active_notes(CUST, limit=5) + assert len(notes) == 1 + assert notes[0]["category"] is None + assert notes[0]["content"] == "我喜欢稳健型产品" + + +def test_save_note_content_truncated(client: TestClient): + """content 超过 500 字时被裁剪。""" + long_content = "我喜欢" + "稳健型" * 200 # >500 字 + extract_json = json.dumps({"content": long_content, "category": "preference"}) + with patch.object(note_service, "_build_llm", return_value=FakeLLM(extract_json)): + _chat(client, "记住" + long_content) + + repo = CustomerNoteRepository() + notes = repo.list_active_notes(CUST, limit=5) + assert len(notes) == 1 + assert len(notes[0]["content"]) == 500 + + +# --------------------------------------------------------------------------- +# 用例 3:抽取失败兜底(content=null / LLM 异常) +# --------------------------------------------------------------------------- + + +def test_save_note_empty_content_fallback(client: TestClient): + """LLM 返回 content=null → 回复 NOTE_EMPTY_TEXT,DB 无新记录。""" + extract_json = json.dumps({"content": None, "category": None}) + with patch.object(note_service, "_build_llm", return_value=FakeLLM(extract_json)): + resp = _chat(client, "记住") + + from app.service.customer_prompts import NOTE_EMPTY_TEXT + assert resp["reply"] == NOTE_EMPTY_TEXT + repo = CustomerNoteRepository() + notes = repo.list_active_notes(CUST, limit=5) + assert len(notes) == 0 + + +def test_save_note_llm_exception_fallback(client: TestClient): + """LLM 异常 → 回复 NOTE_EMPTY_TEXT,DB 无新记录。""" + + class ExplodingLLM: + def invoke(self, messages): + raise RuntimeError("LLM down") + + with patch.object(note_service, "_build_llm", return_value=ExplodingLLM()): + resp = _chat(client, "你要记住我有定投习惯") + + from app.service.customer_prompts import NOTE_EMPTY_TEXT + assert resp["reply"] == NOTE_EMPTY_TEXT + repo = CustomerNoteRepository() + assert len(repo.list_active_notes(CUST, limit=5)) == 0 + + +# --------------------------------------------------------------------------- +# 用例 4:注入到 chitchat/interpret prompt +# --------------------------------------------------------------------------- + + +def test_note_injection_chitchat(client: TestClient): + """已有备注后,第 2 轮 chitchat 的 LLM prompt user 部分含备注内容。""" + # 预置备注 + repo = CustomerNoteRepository() + repo.insert_note( + customer_id=CUST, + session_id=SESSION_ID, + trace_id="trace-preseed", + content="每天看基金净值", + category="habit", + source_text="你要记住我每天看净值", + ) + + captured: dict = {} + + def _capture_invoke(system: str, user: str) -> str: + if "意图分类器" in system: + return "chit_chat" + if "轻松闲聊" in system: + captured["user"] = user + return "好的,明白了。" + return "" + + with patch.object(customer_service, "_invoke", side_effect=_capture_invoke): + resp = _chat(client, "你好") + + assert resp["intent"] == "chit_chat" + assert "每天看基金净值" in captured["user"], ( + f"chitchat prompt 应注入备注内容,实际 user={captured.get('user')}" + ) + assert "客户备注" in captured["user"] + + +def test_note_injection_interpret(client: TestClient): + """已有备注后,interpret 的 LLM prompt 含备注内容。""" + repo = CustomerNoteRepository() + repo.insert_note( + customer_id=CUST, + session_id=SESSION_ID, + trace_id="trace-preseed", + content="不买私募", + category="preference", + source_text="记住我不买私募", + ) + + captured: dict = {} + + def _capture_invoke(system: str, user: str) -> str: + if "意图分类器" in system: + return "holding_query" + if "参数抽取器" in system: + return "{}" + if "事实数据" in system or "系统查询结果" in user: + captured["user"] = user + return "您当前持有3只基金。" + return "" + + with patch.object(customer_service, "_invoke", side_effect=_capture_invoke): + resp = _chat(client, "我的持仓") + + assert resp["intent"] == "holding_query" + assert "不买私募" in captured["user"], ( + f"interpret prompt 应注入备注,实际 user={captured.get('user')}" + ) + + +# --------------------------------------------------------------------------- +# 用例 5:软删除(deactivate_all) +# --------------------------------------------------------------------------- + + +def test_deactivate_all(): + """forget_all_notes 将所有 active 备注置为 is_active=0。""" + repo = CustomerNoteRepository() + for i in range(3): + repo.insert_note( + customer_id=CUST, + session_id=SESSION_ID, + trace_id=f"trace-{i}", + content=f"备注{i}", + category="other", + source_text=f"原话{i}", + ) + assert len(repo.list_active_notes(CUST, limit=10)) == 3 + + n = note_service.forget_all_notes(CUST) + assert n == 3 + assert len(repo.list_active_notes(CUST, limit=10)) == 0 + + +# --------------------------------------------------------------------------- +# 用例 6:render_notes_context 格式化 +# --------------------------------------------------------------------------- + + +def test_render_notes_context_format(): + """备注按 [category] content 格式拼成一行,空备注返回空串。""" + # 空备注 + assert note_service.render_notes_context(CUST) == "" + + repo = CustomerNoteRepository() + repo.insert_note( + customer_id=CUST, session_id=SESSION_ID, trace_id="t1", + content="每天看净值", category="habit", source_text="x", + ) + repo.insert_note( + customer_id=CUST, session_id=SESSION_ID, trace_id="t2", + content="不买私募", category="preference", source_text="y", + ) + ctx = note_service.render_notes_context(CUST) + assert "每天看净值" in ctx + assert "不买私募" in ctx + assert "[habit]" in ctx + assert "[preference]" in ctx + assert ";" in ctx # 多条用;分隔 + + +def test_render_notes_context_empty_customer(): + """customer_id 为空时返回空串(不查 DB)。""" + assert note_service.render_notes_context("") == "" + + +# --------------------------------------------------------------------------- +# 用例 7:无备注时降级(prompt notes_context 字段为空) +# --------------------------------------------------------------------------- + + +def test_no_notes_no_injection(client: TestClient): + """无备注时 chitchat prompt 的 notes_context 字段为空字符串。""" + captured: dict = {} + + def _capture_invoke(system: str, user: str) -> str: + if "意图分类器" in system: + return "chit_chat" + if "轻松闲聊" in system: + captured["user"] = user + return "您好!" + return "" + + with patch.object(customer_service, "_invoke", side_effect=_capture_invoke): + _chat(client, "你好") + + # notes_context 字段应为空(注入后 prompt 含 "客户备注:\n\n") + assert "客户备注" in captured["user"] + # "客户备注:" 后紧跟空行(notes_context 为空) + after_label = captured["user"].split("客户备注:", 1)[1] + assert after_label.startswith("\n\n") or after_label.startswith("\n近期对话") diff --git a/web/README.md b/web/README.md index a3a2182..5c3ce33 100644 --- a/web/README.md +++ b/web/README.md @@ -1,32 +1,46 @@ -# JinRong Web(P0 Demo) +# JinRong Web -React 19 + Vite + Ant Design 5 · HashRouter · 联调本机 FastAPI。 +React 19 + Vite 8 + Ant Design 6 + **Tailwind CSS v4** · HashRouter · 联调本机 FastAPI。 -**现状(2026-09-09):** 登录 + Layout + 四角色路由 **已接**;业务页与 Agent Chat **占位**,待接 SSE / 平台 API。待办见 `docs/memory/TODO.md` §「前端 web/ · P0 待接」。 +**现状(2026-09-09):** 「静奢智能」视觉层 + 四角色 Dashboard 首页(真接平台读 API);Chat/SSE、共享行情页、risk 处置 UI 待接。 + +**交接手册(改前端必读):** [`docs/frontend/FRONTEND-HANDOFF.md`](../docs/frontend/FRONTEND-HANDOFF.md) ## 启动 ```bash -# 终端 1:后端 -cd .. +# 终端 1:后端(仓库根) uvicorn app.main:app --reload # 终端 2:前端 cd web -npm install +npm install # 首次或 package.json 变更后(含 tailwindcss / @tailwindcss/vite) npm run dev ``` -浏览器打开 Vite 提示地址(通常 `http://127.0.0.1:5173/#/login`)。 +浏览器:`http://127.0.0.1:5173/#/login` · 业务路由示例 `/#/app/customer/home` + +## 验证 + +```bash +npm run build +npm run test # 14 例 Vitest +npm run lint # oxlint(现有 hooks Fast Refresh 警告可忽略,见交接手册 §9) +``` + +## 技术要点 + +| 项 | 说明 | +| --- | --- | +| 样式 | `src/styles/tokens.css` 设计令牌 + Tailwind utility;**勿在页面硬编码品牌色** | +| 分层 | `pages → hooks/api/utils` · `components/ui` 禁止 import API/auth | +| 代理 | Vite `/api` → `http://127.0.0.1:8000` | +| 收益色 | 正收益朱砂红、负值翡翠绿(产品约定,勿反转) | ## 装饰素材 | 文件 | 用途 | | --- | --- | | `public/assets/brand/logo-mark.svg` | Header / 登录 Logo | -| `public/assets/illustrations/login-finance.svg` | 登录页左侧插画 | -| `public/assets/illustrations/empty-*.svg` | Empty / 占位页 | - -可选替换:[unDraw](https://undraw.co/) 金融类 SVG(免费商用,无需署名)。 - -设计说明见 `docs/superpowers/specs/2026-09-09-frontend-p0-design.md` §8。 +| `public/assets/illustrations/login-finance.svg` | 登录页插画 | +| `public/assets/illustrations/empty-*.svg` | Empty / 占位 | diff --git a/web/package-lock.json b/web/package-lock.json index 5768393..e8c73b5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,7 @@ "react-router-dom": "^7.18.3" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@types/node": "^24.13.3", @@ -24,6 +25,7 @@ "@vitejs/plugin-react": "^6.1.0", "jsdom": "^30.0.1", "oxlint": "^1.79.0", + "tailwindcss": "^4.3.3", "typescript": "~6.0.2", "vite": "^8.2.2", "vitest": "^5.0.0" @@ -833,6 +835,28 @@ } } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2177,6 +2201,549 @@ "dev": true, "license": "MIT" }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3164,6 +3731,20 @@ "license": "MIT", "peer": true }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", @@ -3270,6 +3851,13 @@ "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", "license": "MIT" }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/graphlib": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", @@ -3361,6 +3949,16 @@ "dev": true, "license": "MIT" }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4252,6 +4850,27 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", diff --git a/web/package.json b/web/package.json index 2690fa4..1ff3da8 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,7 @@ "react-router-dom": "^7.18.3" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@types/node": "^24.13.3", @@ -27,6 +28,7 @@ "@vitejs/plugin-react": "^6.1.0", "jsdom": "^30.0.1", "oxlint": "^1.79.0", + "tailwindcss": "^4.3.3", "typescript": "~6.0.2", "vite": "^8.2.2", "vitest": "^5.0.0" diff --git a/web/src/App.css b/web/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/web/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/web/src/components/AgentBanner.tsx b/web/src/components/AgentBanner.tsx index 91aeae4..64c6023 100644 --- a/web/src/components/AgentBanner.tsx +++ b/web/src/components/AgentBanner.tsx @@ -1,4 +1,5 @@ import { Alert } from 'antd' +import { cn } from '../lib/cn' type AgentBannerProps = { message?: string @@ -7,5 +8,16 @@ type AgentBannerProps = { export function AgentBanner({ message = '当前 Agent 业务 Tool 仍在接入中,可先使用通用对话;查数/明细能力以权限与后端返回为准。', }: AgentBannerProps) { - return + return ( +
+ +
+ ) } + +export type { AgentBannerProps } diff --git a/web/src/components/ApiErrorResult.tsx b/web/src/components/ApiErrorResult.tsx index 9caafe3..b4808b6 100644 --- a/web/src/components/ApiErrorResult.tsx +++ b/web/src/components/ApiErrorResult.tsx @@ -1,5 +1,6 @@ import { Button, Result, Typography } from 'antd' import type { ApiError } from '../api/client' +import { Surface } from './ui' type ApiErrorResultProps = { error: ApiError | Error @@ -9,24 +10,29 @@ type ApiErrorResultProps = { export function ApiErrorResult({ error, onRetry }: ApiErrorResultProps) { const api = error instanceof Error && 'errorCode' in error ? (error as ApiError) : null return ( - - {api?.traceId && ( - - trace_id: {api.traceId} - - )} - {onRetry && ( - - )} - - } - /> + + + {api?.traceId && ( + + trace_id: {api.traceId} + + )} + {onRetry && ( + + )} + + } + /> + ) } + +export type { ApiErrorResultProps } diff --git a/web/src/components/PageShell.tsx b/web/src/components/PageShell.tsx index 48b350d..2b49f0e 100644 --- a/web/src/components/PageShell.tsx +++ b/web/src/components/PageShell.tsx @@ -1,6 +1,7 @@ -import { Breadcrumb, Typography } from 'antd' +import { Breadcrumb } from 'antd' import type { ReactNode } from 'react' import { Link } from 'react-router-dom' +import { PageHeader } from './ui' type Crumb = { title: string; path?: string } @@ -14,23 +15,20 @@ type PageShellProps = { export function PageShell({ title, crumbs = [], extra, alert, children }: PageShellProps) { return ( -
+
{crumbs.length > 0 && ( ({ title: c.path ? {c.title} : c.title, }))} /> )} -
- - {title} - - {extra} -
- {alert} - {children} + + {alert &&
{alert}
} +
{children}
) } + +export type { PageShellProps } diff --git a/web/src/components/dashboard/ChartCard.tsx b/web/src/components/dashboard/ChartCard.tsx new file mode 100644 index 0000000..36ac193 --- /dev/null +++ b/web/src/components/dashboard/ChartCard.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react' +import { Surface, SectionHeading } from '../ui' + +type ChartCardProps = { + eyebrow?: string + title: string + description?: ReactNode + children: ReactNode +} + +export function ChartCard({ eyebrow, title, description, children }: ChartCardProps) { + return ( + + +
{children}
+
+ ) +} + +export type { ChartCardProps } diff --git a/web/src/components/dashboard/DashboardCharts.tsx b/web/src/components/dashboard/DashboardCharts.tsx index 735db33..0d21c9f 100644 --- a/web/src/components/dashboard/DashboardCharts.tsx +++ b/web/src/components/dashboard/DashboardCharts.tsx @@ -3,6 +3,7 @@ import { Col, Row, Skeleton } from 'antd' import { Component, useEffect, useState, type ReactNode } from 'react' import type { ChartDatum } from '../../utils/dashboardTypes' import { ChartErrorFallback } from '../ErrorBoundary' +import { ChartCard } from './ChartCard' type DashboardChartsProps = { pieData: ChartDatum[] @@ -12,9 +13,9 @@ type DashboardChartsProps = { } function barColor(value: number) { - if (value > 0) return '#cf1322' - if (value < 0) return '#389e0d' - return '#8c8c8c' + if (value > 0) return '#c9564a' + if (value < 0) return '#178a68' + return '#71808a' } type ChartBoundaryState = { broken: boolean } @@ -51,49 +52,52 @@ function DashboardCharts({ valueIsPercent ? `${value.toFixed(2)}%` : `${value.toLocaleString('zh-CN')}${barSuffix}` return ( - + {hasPie && (
+ + + formatValue(v) }], + }} + /> + + + + )} + + - formatValue(v) }], }} /> - - )} - - - formatValue(v) }], - }} - /> - + ) } -/** G2 在 React StrictMode 下首帧容器尺寸为 0 时易白屏,延迟一帧再挂载图表。 */ export function DeferredDashboardCharts(props: DashboardChartsProps) { const [ready, setReady] = useState(false) @@ -103,7 +107,7 @@ export function DeferredDashboardCharts(props: DashboardChartsProps) { }, [props.pieData, props.barData]) if (!ready) { - return + return } return diff --git a/web/src/components/dashboard/DashboardHero.tsx b/web/src/components/dashboard/DashboardHero.tsx index 7924004..4a8f0c4 100644 --- a/web/src/components/dashboard/DashboardHero.tsx +++ b/web/src/components/dashboard/DashboardHero.tsx @@ -1,6 +1,7 @@ import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons' -import { Button, Col, Row, Statistic, Typography } from 'antd' +import { Button, Typography } from 'antd' import type { ReactNode } from 'react' +import { MetricCard, Surface } from '../ui' import { AmountText } from './AmountText' export type HeroMetric = { @@ -28,38 +29,41 @@ export function DashboardHero({ onToggleMask, }: DashboardHeroProps) { return ( -
-
-
- {title} - - {mainDisplay ?? } - - {mainLabel} + +
+
+
+
+
+ + {title} + + + {mainDisplay ?? } + + {mainLabel} +
+ {onToggleMask && ( +
+
+
+ {metrics.map((metric) => ( + + ))}
- {onToggleMask && ( -
- - {metrics.map((m) => ( -
- m.value} /> - - ))} - - + ) } diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx index 393bc17..0004434 100644 --- a/web/src/components/dashboard/DashboardLayout.tsx +++ b/web/src/components/dashboard/DashboardLayout.tsx @@ -1,12 +1,14 @@ -import { Alert, Button, Card, Empty, Skeleton, Table } from 'antd' +import { Alert, Button, Empty, Skeleton, Table } from 'antd' import type { ColumnsType } from 'antd/es/table' import type { ReactNode } from 'react' +import type { ApiError } from '../../api/client' +import type { ChartDatum } from '../../utils/dashboardTypes' import { ApiErrorResult } from '../ApiErrorResult' import { PageShell } from '../PageShell' +import { Surface } from '../ui' +import { DataSection } from './DataSection' import { DeferredDashboardCharts } from './DashboardCharts' import { QuickActionBar, type QuickAction } from './QuickActionBar' -import type { ChartDatum } from '../../utils/dashboardTypes' -import type { ApiError } from '../../api/client' type DashboardLayoutProps = { title: string @@ -59,19 +61,23 @@ export function DashboardLayout({ + } alert={ <> - +
+ +
{extraAlert} } > {loading ? ( - + + + ) : ( <> {hero} @@ -84,12 +90,9 @@ export function DashboardLayout({ barSuffix={barSuffix} /> )} - + {tableData.length === 0 ? ( - + ) : (
({ pagination={{ pageSize: 10, showSizeChanger: false }} /> )} - + )} ) } + +export type { DashboardLayoutProps } diff --git a/web/src/components/dashboard/DataSection.tsx b/web/src/components/dashboard/DataSection.tsx new file mode 100644 index 0000000..5979996 --- /dev/null +++ b/web/src/components/dashboard/DataSection.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react' +import { SectionHeading, Surface } from '../ui' + +type DataSectionProps = { + title: string + description?: ReactNode + action?: ReactNode + children: ReactNode +} + +export function DataSection({ title, description, action, children }: DataSectionProps) { + return ( + + +
{children}
+
+ ) +} + +export type { DataSectionProps } diff --git a/web/src/components/dashboard/QuickActionBar.tsx b/web/src/components/dashboard/QuickActionBar.tsx index 05654d1..57ddd13 100644 --- a/web/src/components/dashboard/QuickActionBar.tsx +++ b/web/src/components/dashboard/QuickActionBar.tsx @@ -1,5 +1,6 @@ -import { Button, Space } from 'antd' +import { RightOutlined } from '@ant-design/icons' import { Link } from 'react-router-dom' +import { Surface } from '../ui' export type QuickAction = { label: string @@ -12,12 +13,20 @@ type QuickActionBarProps = { export function QuickActionBar({ actions }: QuickActionBarProps) { return ( - +
{actions.map((a) => ( - - + + + {a.label} + + ))} - +
) } diff --git a/web/src/components/dashboard/__tests__/composition.test.tsx b/web/src/components/dashboard/__tests__/composition.test.tsx new file mode 100644 index 0000000..f4604b6 --- /dev/null +++ b/web/src/components/dashboard/__tests__/composition.test.tsx @@ -0,0 +1,28 @@ +import '@testing-library/jest-dom/vitest' +import { render, screen } from '@testing-library/react' +import { HashRouter } from 'react-router-dom' +import { describe, expect, it } from 'vitest' +import { ChartCard, QuickActionBar } from '../index' + +describe('Dashboard composition primitives', () => { + it('renders a chart card title around chart content', () => { + render( + +
图表
+
, + ) + + expect(screen.getByRole('heading', { name: '资产结构' })).toBeInTheDocument() + expect(screen.getByTestId('chart')).toBeInTheDocument() + }) + + it('renders quick actions as links without changing destinations', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: '产品行情' })).toHaveAttribute('href', '#/app/market') + }) +}) diff --git a/web/src/components/dashboard/index.ts b/web/src/components/dashboard/index.ts new file mode 100644 index 0000000..8d1aebe --- /dev/null +++ b/web/src/components/dashboard/index.ts @@ -0,0 +1,13 @@ +export { AmountText } from './AmountText' +export { ChartCard } from './ChartCard' +export { DataSection } from './DataSection' +export { DeferredDashboardCharts } from './DashboardCharts' +export { DashboardHero } from './DashboardHero' +export { DashboardLayout } from './DashboardLayout' +export { PnlText } from './PnlText' +export { QuickActionBar } from './QuickActionBar' + +export type { ChartCardProps } from './ChartCard' +export type { DataSectionProps } from './DataSection' +export type { HeroMetric } from './DashboardHero' +export type { QuickAction } from './QuickActionBar' diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..280872a --- /dev/null +++ b/web/src/components/layout/AppShell.tsx @@ -0,0 +1,21 @@ +import { type ReactNode } from 'react' + +type AppShellProps = { + sidebar: ReactNode + topbar: ReactNode + children: ReactNode +} + +export function AppShell({ sidebar, topbar, children }: AppShellProps) { + return ( +
+ {sidebar} +
+ {topbar} +
{children}
+
+
+ ) +} + +export type { AppShellProps } diff --git a/web/src/components/layout/BrandSidebar.tsx b/web/src/components/layout/BrandSidebar.tsx new file mode 100644 index 0000000..1152e37 --- /dev/null +++ b/web/src/components/layout/BrandSidebar.tsx @@ -0,0 +1,49 @@ +import { Menu, Typography } from 'antd' +import type { MenuProps } from 'antd' +import { cn } from '../../lib/cn' + +type BrandSidebarProps = { + roleLabel: string + collapsed: boolean + selectedKey: string + menuItems: MenuProps['items'] + onNavigate: (key: string) => void +} + +export function BrandSidebar({ roleLabel, collapsed, selectedKey, menuItems, onNavigate }: BrandSidebarProps) { + return ( + + ) +} + +export type { BrandSidebarProps } diff --git a/web/src/components/layout/Topbar.tsx b/web/src/components/layout/Topbar.tsx new file mode 100644 index 0000000..613cb65 --- /dev/null +++ b/web/src/components/layout/Topbar.tsx @@ -0,0 +1,46 @@ +import { Button as AntButton, Space, Tag, Typography } from 'antd' +import { MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined } from '@ant-design/icons' +import { Badge } from '../ui' + +type TopbarProps = { + roleLabel: string + actorId: string + collapsed: boolean + onToggle: () => void + onLogout: () => void +} + +export function Topbar({ roleLabel, actorId, collapsed, onToggle, onLogout }: TopbarProps) { + return ( +
+ : } + onClick={onToggle} + aria-label={collapsed ? '展开导航' : '折叠导航'} + className="!text-jr-muted hover:!bg-jr-bg hover:!text-jr-ink" + /> + +
+
Workspace
+ + {actorId} + +
+ {roleLabel} + } + onClick={onLogout} + className="!text-jr-muted hover:!bg-jr-bg hover:!text-jr-negative" + > + 退出 + +
+
+ ) +} + +export type { TopbarProps } + +export const TopbarTag = Tag diff --git a/web/src/components/layout/index.ts b/web/src/components/layout/index.ts new file mode 100644 index 0000000..40fdc56 --- /dev/null +++ b/web/src/components/layout/index.ts @@ -0,0 +1,7 @@ +export { AppShell } from './AppShell' +export { BrandSidebar } from './BrandSidebar' +export { Topbar } from './Topbar' + +export type { AppShellProps } from './AppShell' +export type { BrandSidebarProps } from './BrandSidebar' +export type { TopbarProps } from './Topbar' diff --git a/web/src/components/ui/Badge.tsx b/web/src/components/ui/Badge.tsx new file mode 100644 index 0000000..440761b --- /dev/null +++ b/web/src/components/ui/Badge.tsx @@ -0,0 +1,35 @@ +import { type HTMLAttributes, type ReactNode } from 'react' +import { cn } from '../../lib/cn' + +type BadgeTone = 'neutral' | 'info' | 'positive' | 'negative' | 'gold' | 'ink' + +export type BadgeProps = HTMLAttributes & { + tone?: BadgeTone + children: ReactNode +} + +const badgeTones: Record = { + neutral: 'border-jr-border bg-jr-bg text-jr-muted', + info: 'border-jr-info/20 bg-jr-info/10 text-jr-info', + positive: 'border-jr-positive/20 bg-jr-positive/10 text-jr-positive', + negative: 'border-jr-negative/20 bg-jr-negative/10 text-jr-negative', + gold: 'border-jr-gold/30 bg-jr-gold/10 text-jr-gold', + ink: 'border-jr-ink/15 bg-jr-ink/10 text-jr-ink', +} + +export function Badge({ tone = 'neutral', className, children, ...props }: BadgeProps) { + return ( + + {children} + + ) +} + +export { badgeTones } diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..ba55f72 --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -0,0 +1,60 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react' +import { cn } from '../../lib/cn' + +type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'quiet' +type ButtonSize = 'sm' | 'md' | 'lg' + +export type ButtonProps = ButtonHTMLAttributes & { + variant?: ButtonVariant + size?: ButtonSize + loading?: boolean +} + +const buttonVariants: Record = { + primary: 'bg-jr-ink text-white shadow-sm hover:bg-jr-ink/90', + secondary: 'border border-jr-border bg-jr-surface-strong text-jr-ink hover:border-jr-ink', + ghost: 'text-jr-ink hover:bg-jr-bg', + quiet: 'text-jr-muted hover:text-jr-ink', +} + +const buttonSizes: Record = { + sm: 'min-h-8 px-3 text-xs', + md: 'min-h-10 px-4 text-sm', + lg: 'min-h-12 px-5 text-base', +} + +export const Button = forwardRef(function Button( + { + className, + variant = 'secondary', + size = 'md', + loading = false, + disabled, + children, + ...props + }, + ref, +) { + return ( + + ) +}) + +Button.displayName = 'Button' + +export { buttonVariants } diff --git a/web/src/components/ui/EmptyState.tsx b/web/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..4b03164 --- /dev/null +++ b/web/src/components/ui/EmptyState.tsx @@ -0,0 +1,23 @@ +import { Empty } from 'antd' +import { type ReactNode } from 'react' +import { cn } from '../../lib/cn' + +type EmptyStateProps = { + image?: string + title: string + description?: ReactNode + action?: ReactNode +} + +export function EmptyState({ image, title, description, action }: EmptyStateProps) { + return ( +
+ {image ? : } +

{title}

+ {description &&
{description}
} + {action &&
{action}
} +
+ ) +} + +export type { EmptyStateProps } diff --git a/web/src/components/ui/MetricCard.tsx b/web/src/components/ui/MetricCard.tsx new file mode 100644 index 0000000..a28bf2f --- /dev/null +++ b/web/src/components/ui/MetricCard.tsx @@ -0,0 +1,32 @@ +import { type ReactNode } from 'react' +import { cn } from '../../lib/cn' + +type MetricCardProps = { + label: string + value: ReactNode + detail?: ReactNode + trend?: ReactNode + emphasis?: 'default' | 'hero' +} + +export function MetricCard({ label, value, detail, trend, emphasis = 'default' }: MetricCardProps) { + return ( +
+
+ {label} +
+
+ {value} +
+ {detail &&
{detail}
} + {trend &&
{trend}
} +
+ ) +} + +export type { MetricCardProps } diff --git a/web/src/components/ui/PageHeader.tsx b/web/src/components/ui/PageHeader.tsx new file mode 100644 index 0000000..8ecd7e9 --- /dev/null +++ b/web/src/components/ui/PageHeader.tsx @@ -0,0 +1,30 @@ +import { type ReactNode } from 'react' +import { cn } from '../../lib/cn' + +type PageHeaderProps = { + eyebrow?: string + title: string + description?: ReactNode + action?: ReactNode +} + +export function PageHeader({ eyebrow, title, description, action }: PageHeaderProps) { + return ( +
+
+ {eyebrow &&
{eyebrow}
} +

{title}

+ {description &&
{description}
} +
+ {action &&
{action}
} +
+ ) +} + +export type { PageHeaderProps } + +export function Eyebrow({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
+} + +export type { ReactNode } diff --git a/web/src/components/ui/SectionHeading.tsx b/web/src/components/ui/SectionHeading.tsx new file mode 100644 index 0000000..64ac336 --- /dev/null +++ b/web/src/components/ui/SectionHeading.tsx @@ -0,0 +1,28 @@ +import { type ReactNode } from 'react' +import { cn } from '../../lib/cn' + +type SectionHeadingProps = { + eyebrow?: string + title: string + description?: ReactNode + action?: ReactNode +} + +export function SectionHeading({ eyebrow, title, description, action }: SectionHeadingProps) { + return ( +
+
+ {eyebrow &&
{eyebrow}
} +

{title}

+ {description &&
{description}
} +
+ {action &&
{action}
} +
+ ) +} + +export type { SectionHeadingProps } + +export function SectionRule({ className }: { className?: string }) { + return
+} diff --git a/web/src/components/ui/Surface.tsx b/web/src/components/ui/Surface.tsx new file mode 100644 index 0000000..f60612a --- /dev/null +++ b/web/src/components/ui/Surface.tsx @@ -0,0 +1,41 @@ +import { forwardRef, type ElementType, type HTMLAttributes } from 'react' +import { cn } from '../../lib/cn' + +type SurfaceVariant = 'default' | 'subtle' | 'dark' +type SurfacePadding = 'none' | 'sm' | 'md' | 'lg' + +export type SurfaceProps = HTMLAttributes & { + variant?: SurfaceVariant + padding?: SurfacePadding + as?: ElementType +} + +const surfaceVariants: Record = { + default: 'border-jr-border bg-jr-surface-strong text-jr-text shadow-jr-surface', + subtle: 'border-jr-border/80 bg-jr-surface text-jr-text', + dark: 'border-jr-ink bg-jr-ink text-white shadow-jr-float', +} + +const surfacePadding: Record = { + none: 'p-0', + sm: 'p-3', + md: 'p-5', + lg: 'p-6 sm:p-8', +} + +export const Surface = forwardRef(function Surface( + { as: Component = 'div', className, variant = 'default', padding = 'md', ...props }, + ref, +) { + return ( + + ) +}) + +Surface.displayName = 'Surface' + +export { surfaceVariants } diff --git a/web/src/components/ui/__tests__/ui.test.tsx b/web/src/components/ui/__tests__/ui.test.tsx new file mode 100644 index 0000000..33e59f2 --- /dev/null +++ b/web/src/components/ui/__tests__/ui.test.tsx @@ -0,0 +1,38 @@ +import '@testing-library/jest-dom/vitest' +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Badge, Button, EmptyState, MetricCard, PageHeader, Surface } from '../index' + +describe('JinRong UI primitives', () => { + it('merges only truthy class names', async () => { + const { cn } = await import('../../../lib/cn') + expect(cn('a', false, undefined, 'b')).toBe('a b') + }) + + it('renders a primary button with an accessible label', () => { + render() + expect(screen.getByRole('button', { name: '刷新数据' })).toHaveClass('bg-jr-ink') + }) + + it('renders a metric card with value and detail', () => { + render() + expect(screen.getByText('总市值')).toBeInTheDocument() + expect(screen.getByText('149,940.00')).toBeInTheDocument() + expect(screen.getByText('持仓市值合计')).toBeInTheDocument() + }) + + it('renders page and section semantics without business dependencies', () => { + render( + <> + + 内容 + 运行正常 + + , + ) + expect(screen.getByRole('heading', { name: '我的资产' })).toBeInTheDocument() + expect(screen.getByLabelText('资产面板')).toBeInTheDocument() + expect(screen.getByText('运行正常')).toBeInTheDocument() + expect(screen.getByText('暂无持仓')).toBeInTheDocument() + }) +}) diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts new file mode 100644 index 0000000..fb1e498 --- /dev/null +++ b/web/src/components/ui/index.ts @@ -0,0 +1,15 @@ +export { Badge } from './Badge' +export { Button } from './Button' +export { EmptyState } from './EmptyState' +export { MetricCard } from './MetricCard' +export { PageHeader, Eyebrow } from './PageHeader' +export { SectionHeading, SectionRule } from './SectionHeading' +export { Surface } from './Surface' + +export type { BadgeProps } from './Badge' +export type { ButtonProps } from './Button' +export type { EmptyStateProps } from './EmptyState' +export type { MetricCardProps } from './MetricCard' +export type { PageHeaderProps } from './PageHeader' +export type { SectionHeadingProps } from './SectionHeading' +export type { SurfaceProps } from './Surface' diff --git a/web/src/index.css b/web/src/index.css deleted file mode 100644 index 5fb3313..0000000 --- a/web/src/index.css +++ /dev/null @@ -1,111 +0,0 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -body { - margin: 0; -} - -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} diff --git a/web/src/layouts/AppLayout.tsx b/web/src/layouts/AppLayout.tsx index e7f6bc6..fdc88c4 100644 --- a/web/src/layouts/AppLayout.tsx +++ b/web/src/layouts/AppLayout.tsx @@ -1,19 +1,8 @@ -import { - Layout, - Menu, - Button, - Space, - Tag, - Typography, - theme, -} from 'antd' -import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons' import { useMemo, useState } from 'react' import { Outlet, useLocation, useNavigate } from 'react-router-dom' import { clearAuth, loadAuth, type AuthState } from '../stores/authStore' import { buildMenuGroups, flattenMenuPaths, toAntdMenuItems } from '../routes/menus' - -const { Header, Sider, Content } = Layout +import { AppShell, BrandSidebar, Topbar } from '../components/layout' type AppLayoutProps = { auth: AuthState @@ -23,7 +12,6 @@ export function AppLayout({ auth }: AppLayoutProps) { const [collapsed, setCollapsed] = useState(false) const navigate = useNavigate() const location = useLocation() - const { token } = theme.useToken() const menuGroups = useMemo(() => buildMenuGroups(auth.roleLabel), [auth.roleLabel]) const menuItems = useMemo(() => toAntdMenuItems(menuGroups), [menuGroups]) @@ -32,7 +20,7 @@ export function AppLayout({ auth }: AppLayoutProps) { const selectedKey = allowedPaths.find((p) => location.pathname.startsWith(p)) ?? location.pathname - const onMenuClick = ({ key }: { key: string }) => { + const onMenuClick = (key: string) => { navigate(key) } @@ -42,60 +30,28 @@ export function AppLayout({ auth }: AppLayoutProps) { } return ( - - -
- - {!collapsed && ( - - JinRong - - )} -
- - - -
- - -
- - - -
- + } + topbar={ + setCollapsed((value) => !value)} + onLogout={logout} + /> + } + > + + ) } diff --git a/web/src/lib/cn.ts b/web/src/lib/cn.ts new file mode 100644 index 0000000..18f4f66 --- /dev/null +++ b/web/src/lib/cn.ts @@ -0,0 +1,3 @@ +export function cn(...values: Array) { + return values.filter(Boolean).join(' ') +} diff --git a/web/src/pages/PlaceholderPage.tsx b/web/src/pages/PlaceholderPage.tsx index 6b32276..2ace81b 100644 --- a/web/src/pages/PlaceholderPage.tsx +++ b/web/src/pages/PlaceholderPage.tsx @@ -1,5 +1,5 @@ -import { Empty } from 'antd' import { PageShell } from '../components/PageShell' +import { EmptyState } from '../components/ui' type PlaceholderPageProps = { title: string @@ -14,11 +14,7 @@ export function PlaceholderPage({ }: PlaceholderPageProps) { return ( - + ) } diff --git a/web/src/pages/dashboard/AdvisorClientsDashboard.tsx b/web/src/pages/dashboard/AdvisorClientsDashboard.tsx index 1b04520..302247a 100644 --- a/web/src/pages/dashboard/AdvisorClientsDashboard.tsx +++ b/web/src/pages/dashboard/AdvisorClientsDashboard.tsx @@ -1,9 +1,7 @@ import { Space, Typography } from 'antd' import type { ColumnsType } from 'antd/es/table' import { Link } from 'react-router-dom' -import { DashboardHero } from '../../components/dashboard/DashboardHero' -import { DashboardLayout } from '../../components/dashboard/DashboardLayout' -import { AmountText } from '../../components/dashboard/AmountText' +import { AmountText, DashboardHero, DashboardLayout } from '../../components/dashboard' import { useAdvisorRosterDashboard } from '../../hooks/useAdvisorRosterDashboard' import { useAppAuth } from '../../layouts/AppLayout' import type { AdvisorRosterRow } from '../../utils/dashboardTypes' diff --git a/web/src/pages/dashboard/AnalystMarketDashboard.tsx b/web/src/pages/dashboard/AnalystMarketDashboard.tsx index 48692a2..e03f46f 100644 --- a/web/src/pages/dashboard/AnalystMarketDashboard.tsx +++ b/web/src/pages/dashboard/AnalystMarketDashboard.tsx @@ -1,9 +1,7 @@ import { Typography } from 'antd' import type { ColumnsType } from 'antd/es/table' import { Link } from 'react-router-dom' -import { DashboardHero } from '../../components/dashboard/DashboardHero' -import { DashboardLayout } from '../../components/dashboard/DashboardLayout' -import { PnlText } from '../../components/dashboard/PnlText' +import { DashboardHero, DashboardLayout, PnlText } from '../../components/dashboard' import { useMarketSnapshot } from '../../hooks/useMarketSnapshot' import { useAppAuth } from '../../layouts/AppLayout' import type { ProductRow } from '../../utils/dashboardTypes' diff --git a/web/src/pages/dashboard/CustomerWealthDashboard.tsx b/web/src/pages/dashboard/CustomerWealthDashboard.tsx index 0378ad1..2c5b21b 100644 --- a/web/src/pages/dashboard/CustomerWealthDashboard.tsx +++ b/web/src/pages/dashboard/CustomerWealthDashboard.tsx @@ -1,10 +1,7 @@ import { Space, Typography } from 'antd' import type { ColumnsType } from 'antd/es/table' import { Link } from 'react-router-dom' -import { DashboardHero } from '../../components/dashboard/DashboardHero' -import { DashboardLayout } from '../../components/dashboard/DashboardLayout' -import { AmountText } from '../../components/dashboard/AmountText' -import { PnlText } from '../../components/dashboard/PnlText' +import { AmountText, DashboardHero, DashboardLayout, PnlText } from '../../components/dashboard' import { useAmountMask } from '../../hooks/useAmountMask' import { useHoldingsDashboard } from '../../hooks/useHoldingsDashboard' import { useAppAuth } from '../../layouts/AppLayout' diff --git a/web/src/pages/dashboard/RiskAlertsDashboard.tsx b/web/src/pages/dashboard/RiskAlertsDashboard.tsx index 7894a67..f33d930 100644 --- a/web/src/pages/dashboard/RiskAlertsDashboard.tsx +++ b/web/src/pages/dashboard/RiskAlertsDashboard.tsx @@ -1,8 +1,7 @@ import { Alert, Typography } from 'antd' import type { ColumnsType } from 'antd/es/table' import { Link } from 'react-router-dom' -import { DashboardHero } from '../../components/dashboard/DashboardHero' -import { DashboardLayout } from '../../components/dashboard/DashboardLayout' +import { DashboardHero, DashboardLayout } from '../../components/dashboard' import { useAlertsDashboard } from '../../hooks/useAlertsDashboard' import { useAppAuth } from '../../layouts/AppLayout' import type { RiskAlertItem } from '../../api/risk' diff --git a/web/src/pages/login/LoginPage.tsx b/web/src/pages/login/LoginPage.tsx index 64e0107..7e565c6 100644 --- a/web/src/pages/login/LoginPage.tsx +++ b/web/src/pages/login/LoginPage.tsx @@ -1,6 +1,7 @@ -import { Button, Card, Space, Typography, message } from 'antd' +import { Typography, message } from 'antd' import { useState } from 'react' import { useNavigate } from 'react-router-dom' +import { Badge, Button, Surface } from '../../components/ui' import { login } from '../../api/auth' import { ApiError } from '../../api/client' import { DEMO_ACCOUNTS } from '../../config/demoAccounts' @@ -36,53 +37,91 @@ export function LoginPage() { } return ( -
-
-
- - +
+
+
+
+
+ JinRong
- - JinRong 智能管家 - - 四 Agent · 代销平台 · 演示环境 +
JinRong 智能管家
+
财富与风险的智能工作台
- +
+ + DEMO ENVIRONMENT + +
+
金融数据与资产守护示意 - - 稳重金融工作台风格 · 平台读 API 真接 · Agent 能力逐步接入 + + 让每一项决策, +
+ 都有数据作答。 +
+ + 面向客户、理财师、分析员与风控专员的统一智能金融工作台。真实连接 API,逐步接入 Agent 能力。
-
-
- - +
+ + Core API connected + · + Local development workspace +
+
+ +
+
+
+
+ JinRong +
+
JinRong 智能管家
+
四 Agent · 代销平台 · 演示环境
+
+
+
+ +
+
JINRONG / ACCESS
+

选择演示账号

+

进入对应角色工作台,查看已接入的真实数据与交互。

+
+ +
{DEMO_ACCOUNTS.map((acc) => ( ))} - - - Mock JWT · 联调本机 FastAPI :8000 · 仅供开发演示 - - -
+
+ + +
+ Mock JWT · 联调本机 FastAPI :8000 · 仅供开发演示 +
+
+
+
) } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index a0973c8..fbf328c 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -1,39 +1,126 @@ +@import './tokens.css'; + +:root { + color: var(--jr-text); + background: var(--jr-bg); + font-family: var(--font-sans); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + html, body, #root { - margin: 0; min-height: 100%; + margin: 0; } body { - background: #f5f5f5; + min-width: 320px; + overflow-x: hidden; + background: var(--jr-bg); +} + +button, +a, +input, +textarea, +select { + font: inherit; +} + +button:focus-visible, +a:focus-visible, +[role='button']:focus-visible { + outline: 2px solid var(--jr-gold); + outline-offset: 3px; +} + +::selection { + color: white; + background: var(--jr-ink); +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--jr-bg); +} + +::-webkit-scrollbar-thumb { + border: 3px solid var(--jr-bg); + border-radius: 999px; + background: var(--jr-border); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--jr-muted); } .tabular-nums { font-variant-numeric: tabular-nums; } -.login-hero-wrap { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - background: linear-gradient(135deg, #e6f4ff 0%, #f0f5ff 55%, #ffffff 100%); +.ant-layout, +.ant-layout-content { + background: transparent; } -.login-hero-art { - max-width: 420px; +.ant-layout-sider-children { + display: flex; + flex-direction: column; +} + +.ant-menu-inline .ant-menu-item, +.ant-menu-inline .ant-menu-submenu-title { + margin-inline: 0; width: 100%; - padding: 48px; } -.login-panel { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - background: #fff; - padding: 48px 32px; +.ant-table-wrapper { + min-width: 0; +} + +.ant-table-wrapper .ant-table { + background: transparent; +} + +.ant-table-wrapper .ant-table-container { + border-inline-start: 0; + border-inline-end: 0; +} + +.ant-table-wrapper .ant-table-thead > tr > th { + background: color-mix(in srgb, var(--jr-bg) 72%, white); + color: var(--jr-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.ant-table-wrapper .ant-table-tbody > tr > td { + border-bottom-color: var(--jr-border); +} + +.ant-input, +.ant-input-affix-wrapper, +.ant-select-selector { + border-color: var(--jr-border); +} + +@keyframes jr-fade-up { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.jr-page-enter { + animation: jr-fade-up 320ms ease-out both; } .chat-stream-cursor::after { @@ -42,7 +129,16 @@ body { } @keyframes blink { - 50% { - opacity: 0; + 50% { opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; } } diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css new file mode 100644 index 0000000..28672eb --- /dev/null +++ b/web/src/styles/tokens.css @@ -0,0 +1,41 @@ +@import "tailwindcss"; + +@theme { + --color-jr-bg: #f6f5f1; + --color-jr-surface: #fbfaf7; + --color-jr-surface-strong: #ffffff; + --color-jr-ink: #132b3a; + --color-jr-text: #17212b; + --color-jr-muted: #71808a; + --color-jr-border: #e5e2db; + --color-jr-positive: #178a68; + --color-jr-negative: #c9564a; + --color-jr-gold: #c9a66b; + --color-jr-info: #3f718a; + --radius-jr-sm: 8px; + --radius-jr-md: 12px; + --radius-jr-lg: 16px; + --shadow-jr-surface: 0 12px 32px rgb(32 42 48 / 6%); + --shadow-jr-float: 0 18px 44px rgb(32 42 48 / 10%); + --font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +:root { + --jr-bg: #f6f5f1; + --jr-surface: #fbfaf7; + --jr-surface-strong: #ffffff; + --jr-ink: #132b3a; + --jr-text: #17212b; + --jr-muted: #71808a; + --jr-border: #e5e2db; + --jr-positive: #178a68; + --jr-negative: #c9564a; + --jr-gold: #c9a66b; + --jr-info: #3f718a; + --jr-radius-sm: 8px; + --jr-radius-md: 12px; + --jr-radius-lg: 16px; + --jr-shadow-surface: 0 12px 32px rgb(32 42 48 / 6%); + --jr-shadow-float: 0 18px 44px rgb(32 42 48 / 10%); +} diff --git a/web/src/theme.ts b/web/src/theme.ts index e954c7f..e0555d9 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -2,20 +2,59 @@ import type { ThemeConfig } from 'antd' export const appTheme: ThemeConfig = { token: { - colorPrimary: '#1677ff', - borderRadius: 6, + colorPrimary: '#132b3a', + colorInfo: '#3f718a', + colorSuccess: '#178a68', + colorError: '#c9564a', + colorWarning: '#c9a66b', + colorTextBase: '#17212b', + colorTextSecondary: '#71808a', + colorBgBase: '#f6f5f1', + colorBgLayout: '#f6f5f1', + colorBgContainer: '#ffffff', + colorBorder: '#e5e2db', + colorBorderSecondary: '#e5e2db', + borderRadius: 12, + borderRadiusLG: 16, fontSize: 14, fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif', + '-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif', }, components: { Layout: { headerBg: '#ffffff', - siderBg: '#ffffff', - bodyBg: '#f5f5f5', + headerHeight: 80, + headerPadding: '0 32px', + siderBg: '#fbfaf7', + bodyBg: '#f6f5f1', + triggerBg: '#132b3a', + }, + Menu: { + itemBg: 'transparent', + itemColor: '#71808a', + itemHoverBg: '#f6f5f1', + itemHoverColor: '#132b3a', + itemSelectedBg: '#132b3a', + itemSelectedColor: '#ffffff', + subMenuItemBg: 'transparent', + itemBorderRadius: 8, }, Table: { - headerBg: '#fafafa', + headerBg: '#f6f5f1', + headerColor: '#71808a', + borderColor: '#e5e2db', + rowHoverBg: '#fbfaf7', + headerBorderRadius: 8, + }, + Card: { + colorBgContainer: '#ffffff', + colorBorderSecondary: '#e5e2db', + borderRadiusLG: 12, + }, + Button: { + borderRadius: 10, + primaryShadow: 'none', + defaultShadow: 'none', }, }, } diff --git a/web/vite.config.ts b/web/vite.config.ts index 50be89f..e05f360 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,8 +1,9 @@ import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vitest/config' export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], server: { port: 5173, proxy: {