feat(投顾): 方案交付落点、可视化图表与推荐依据的大模型增强
交付落点
- 新增 GET /api/v1/users/me/advisor-contents(客户读**自己**已发布方案):
「发送给客户」原先只改数据状态、客户端没有任何页面或接口能读到它
- 客户端新增「我的投顾方案」页与导航入口
可视化(投顾结果区与客户页**共用** common/advisor-plan-view.js,避免两处漂移)
- 净值折线图(带坐标轴与网格)、组合业绩等权合成曲线(含区间收益与最大回撤)、
资产配置环形图与图例、组合构成条
- 修 num(null)=0 的假 0:Number(null)/Number('') 会得 0,导致「没数据」被渲染成 0.00%;
现一律显示「--」。同理管理费/起投未维护时按没数据处理,不显示 0
- 涨跌口径为「涨红跌绿」(A 股习惯),由 CSS 变量 --plan-up / --plan-down 集中定义
推荐依据接入大模型(可选,失败即回退)
- 新增 AdvisorReasonService:**只改文案,不参与选品**(候选池与排序在它之前已固定)
- 输入只允许是已算出的真实参数(风险等级、排序得分、区间收益、最大回撤、期限与流动性)
- 命中收益承诺词(保本/保证收益/稳赚/无风险…)整条丢弃并回退规则文案
- 未启用 / 缺密钥 / 超时 / 解析失败一律回退,推荐主流程不因模型不可用而失败
- 前端标注来源(AI 生成 / 规则生成)
数据与权限
- 客户角色补齐:绑 customer 角色、补建缺失的账户与交易段权限码(9060-9065)
- 净值全量同步(20 只产品),行情同步脚本按 --codes 分块(全量一次会被超时终止)
测试
- 新增 tests/unit/service/test_advisor_reason_service.py(10 项,专测三条合规边界)
- 前端模块自检纳入 service-request-module;补「两处共用同一渲染」回归测试
This commit is contained in:
@@ -39,6 +39,7 @@ async def test_portal_root_redirects_to_public_home() -> None:
|
||||
"/portal/customer/orders/",
|
||||
"/portal/customer/transactions/",
|
||||
"/portal/customer/cash-ledger/",
|
||||
"/portal/customer/advisor-plans/",
|
||||
"/portal/customer/risk-questionnaire/",
|
||||
"/portal/employee-console/login/",
|
||||
"/portal/employee-console/workspace/",
|
||||
@@ -172,20 +173,59 @@ def test_advisor_workspace_registers_documented_operation_endpoints() -> None:
|
||||
encoding="utf-8"
|
||||
)
|
||||
for endpoint_id in (
|
||||
"ADVISOR_PUBLISHED", "ADVISOR_GOAL", "ADVISOR_ANALYSIS",
|
||||
"ADVISOR_PUBLISHED", "ADVISOR_HISTORY", "ADVISOR_GOAL", "ADVISOR_ANALYSIS",
|
||||
"ADVISOR_ALLOCATION", "ADVISOR_RECOMMEND", "ADVISOR_CREATE_GOAL",
|
||||
"ADVISOR_REVIEW_RECOMMENDATION", "ADVISOR_PUBLISH_RECOMMENDATION",
|
||||
"ADVISOR_DELETE_RECOMMENDATION",
|
||||
):
|
||||
assert f"{endpoint_id}:" in source
|
||||
for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标"):
|
||||
for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标", "历史方案记录"):
|
||||
assert label in dashboard
|
||||
|
||||
|
||||
def test_advisor_plan_view_is_shared_by_both_surfaces() -> None:
|
||||
"""推荐方案的可视化渲染必须**只有一份**,投顾工作台与客户页共用。
|
||||
|
||||
两处都是同一份 `advisor_recommendation_plan`,各写一套必然漂移
|
||||
(改了一边忘了另一边)。这条测试同时守住「共享模块存在」与「两处都在用它」。
|
||||
"""
|
||||
view = (PORTAL / "common" / "advisor-plan-view.js").read_text(encoding="utf-8")
|
||||
assert "export function renderPlanProducts" in view
|
||||
assert "export async function hydratePlanView" in view
|
||||
assert "P002" in view
|
||||
for page in (
|
||||
"employee-advisor/dashboard/actions-module.js",
|
||||
"customer/advisor-plans/advisor-plans.js",
|
||||
):
|
||||
source = (PORTAL / page).read_text(encoding="utf-8")
|
||||
assert "advisor-plan-view.js" in source, page
|
||||
|
||||
|
||||
def test_advisor_history_module_exposes_plan_actions() -> None:
|
||||
"""历史方案记录里,推荐方案卡片要带审核/驳回/发送/删除四个操作。
|
||||
|
||||
四个动作对应三个端点(审核与驳回共用一个 reviews 端点,用 `decision` 区分)。
|
||||
少了 `ADVISOR_DELETE_RECOMMENDATION` 就只剩"能看不能删"。
|
||||
"""
|
||||
source = (
|
||||
PORTAL / "employee-advisor" / "dashboard" / "history-module.js"
|
||||
).read_text(encoding="utf-8")
|
||||
for label in ("审核通过", "驳回", "发送给客户", "删除"):
|
||||
assert label in source
|
||||
for endpoint in (
|
||||
"ADVISOR_REVIEW_RECOMMENDATION",
|
||||
"ADVISOR_PUBLISH_RECOMMENDATION",
|
||||
"ADVISOR_DELETE_RECOMMENDATION",
|
||||
):
|
||||
assert endpoint in source
|
||||
|
||||
|
||||
def test_advisor_dashboard_is_composed_from_feature_modules() -> None:
|
||||
source = (PORTAL / "employee-advisor" / "dashboard" / "dashboard.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "./actions-module.js" in source
|
||||
assert "./published-module.js" in source
|
||||
assert "./history-module.js" in source
|
||||
config = (PORTAL / "employee-advisor" / "dashboard" / "advisor-config.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""推荐依据 LLM 增强的守卫测试。
|
||||
|
||||
重点不是"能不能调通模型",而是**三条合规边界**:
|
||||
1. 收益承诺类表述必须被丢弃;
|
||||
2. 未启用 / 缺密钥 / 调用失败一律回退(返回空字典),不得影响推荐主流程;
|
||||
3. 只接受自己请求过的 product_code(模型返回别的代码不得采纳)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.service import advisor_reason_service as module
|
||||
from app.service.advisor_reason_service import (
|
||||
PROHIBITED_PHRASES,
|
||||
AdvisorReasonService,
|
||||
build_prompt,
|
||||
is_compliant,
|
||||
parse_items,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, response=None, error=None):
|
||||
self.response = response
|
||||
self.error = error
|
||||
self.calls = 0
|
||||
|
||||
async def post(self, *args, **kwargs):
|
||||
self.calls += 1
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.response
|
||||
|
||||
|
||||
def _settings(**overrides):
|
||||
values = {
|
||||
"advisor_reason_llm_enabled": True,
|
||||
"advisor_reason_llm_base_url": "https://example.invalid",
|
||||
"advisor_reason_llm_model": "deepseek-chat",
|
||||
"advisor_reason_llm_timeout_seconds": 5.0,
|
||||
"deepseek_api_key": "sk-test",
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def test_is_compliant_rejects_every_prohibited_phrase() -> None:
|
||||
for phrase in PROHIBITED_PHRASES:
|
||||
text = f"该产品风险等级 R3,与您的风险承受能力匹配,{phrase},可长期持有。"
|
||||
assert not is_compliant(text), phrase
|
||||
|
||||
|
||||
def test_is_compliant_rejects_out_of_range_length() -> None:
|
||||
assert not is_compliant("太短")
|
||||
assert not is_compliant("风" * 200)
|
||||
|
||||
|
||||
def test_is_compliant_accepts_grounded_text() -> None:
|
||||
text = (
|
||||
"该产品为 R3 中等风险,与您的风险承受能力匹配;近 60 个交易日最大回撤 -8.71%,"
|
||||
"建议作为组合的一部分配置。"
|
||||
)
|
||||
assert is_compliant(text)
|
||||
|
||||
|
||||
def test_parse_items_tolerates_code_fence_and_noise() -> None:
|
||||
raw = '结果如下:\n```json\n{"items":[{"product_code":"159329","reason":"x"}]}\n```'
|
||||
assert parse_items(raw) == {"159329": "x"}
|
||||
|
||||
|
||||
def test_parse_items_returns_empty_on_bad_shape() -> None:
|
||||
assert parse_items("not json at all") == {}
|
||||
assert parse_items('{"items": "oops"}') == {}
|
||||
|
||||
|
||||
def test_build_prompt_carries_real_numbers() -> None:
|
||||
prompt = build_prompt(
|
||||
{"risk_level": 4, "horizon_months": 60, "liquidity": "30 日后可使用"},
|
||||
[{
|
||||
"product_code": "159329", "product_name": "沙特ETF南方",
|
||||
"product_category": "ETF", "risk_level": "R5", "score": 0.96,
|
||||
"return_20d_pct": -0.7126, "return_60d_pct": -1.8213,
|
||||
"max_drawdown_60d_pct": -4.9219, "rule_reason": "规则文案",
|
||||
}],
|
||||
)
|
||||
assert "-0.71%" in prompt
|
||||
assert "-4.92%" in prompt
|
||||
assert "30 日后可使用" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enhance_drops_non_compliant_items(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(module, "get_settings", lambda: _settings())
|
||||
client = _FakeClient(_FakeResponse({"choices": [{"message": {"content": (
|
||||
'{"items":['
|
||||
'{"product_code":"159329","reason":"该产品风险等级 R5,与您的风险承受能力匹配,'
|
||||
'建议作为组合分散配置的一部分。"},'
|
||||
'{"product_code":"159382","reason":"这只保证收益,稳赚,放心买。"},'
|
||||
'{"product_code":"999999","reason":"该产品风险等级 R3,与您的风险承受能力匹配,'
|
||||
'建议作为组合的一部分配置。"}'
|
||||
']}'
|
||||
)}}]}))
|
||||
accepted = await AdvisorReasonService(client=client).enhance(
|
||||
customer={}, products=[{"product_code": "159329"}, {"product_code": "159382"}]
|
||||
)
|
||||
# 命中收益承诺的 159382 被丢弃;未请求过的 999999 也不采纳。
|
||||
assert set(accepted) == {"159329"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enhance_returns_empty_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
module, "get_settings", lambda: _settings(advisor_reason_llm_enabled=False)
|
||||
)
|
||||
client = _FakeClient(_FakeResponse({"choices": []}))
|
||||
result = await AdvisorReasonService(client=client).enhance(
|
||||
customer={}, products=[{"product_code": "159329"}]
|
||||
)
|
||||
assert result == {}
|
||||
assert client.calls == 0 # 未启用时**根本不发请求**
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enhance_returns_empty_without_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(module, "get_settings", lambda: _settings(deepseek_api_key=""))
|
||||
client = _FakeClient(_FakeResponse({"choices": []}))
|
||||
assert await AdvisorReasonService(client=client).enhance(
|
||||
customer={}, products=[{"product_code": "159329"}]
|
||||
) == {}
|
||||
assert client.calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enhance_returns_empty_on_upstream_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(module, "get_settings", lambda: _settings())
|
||||
client = _FakeClient(error=RuntimeError("boom"))
|
||||
assert await AdvisorReasonService(client=client).enhance(
|
||||
customer={}, products=[{"product_code": "159329"}]
|
||||
) == {}
|
||||
Reference in New Issue
Block a user