交付落点
- 新增 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;补「两处共用同一渲染」回归测试
192 lines
8.2 KiB
Python
192 lines
8.2 KiB
Python
"""投顾「推荐依据」的 LLM 增强(**可选**,任何一步失败都回退到确定性文案)。
|
||
|
||
## 为什么要有它
|
||
|
||
投顾工作台账推方案里,每只产品的「推荐依据」原先是一句**所有产品都一样**的套话,
|
||
客户看不出"为什么选这一只"。这里用大模型把**已经算出来的真实参数**
|
||
(风险等级、排序得分、区间收益、最大回撤、客户的期限与流动性要求)写成
|
||
一段面向客户的说明。
|
||
|
||
## 合规边界(三条,都在代码里强制执行)
|
||
|
||
1. **只用给定数据**:提示词里明确禁止编造数字/业绩/奖项/排名/基金经理信息;
|
||
2. **禁止收益承诺**:产出命中 `PROHIBITED_PHRASES`(保本/保证收益/稳赚/无风险…)
|
||
即**整条丢弃** —— 与 `investment_goal_service._PROHIBITED_GOAL_PHRASES` 同一口径;
|
||
3. **失败即回退**:未启用、缺密钥、超时、HTTP 错误、JSON 解析失败、字段缺失,
|
||
一律返回空字典,由调用方保留确定性文案。**推荐流程绝不因模型不可用而失败**。
|
||
|
||
## 哪些不算数
|
||
|
||
本服务**不参与选品**,只改文案。选品仍然是 `ProductRecommendationService` 的
|
||
硬约束 + 适当性 + 排序,模型看不到也改不了候选池。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from app.core.config import get_settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
#: 收益承诺/绝对化表述 —— 命中即丢弃该条 LLM 文案。
|
||
PROHIBITED_PHRASES: tuple[str, ...] = (
|
||
"保本", "保证收益", "保收益", "稳赚", "稳赢", "无风险", "零风险",
|
||
"收益承诺", "包赚", "必赚", "稳赚不赔", "绝对收益", "确保收益", "锁定收益",
|
||
)
|
||
|
||
#: 文案长度边界:太短没信息量、太长在卡片里读不完。
|
||
MIN_REASON_CHARS = 20
|
||
MAX_REASON_CHARS = 160
|
||
|
||
SYSTEM_PROMPT = """你是南方基金的投顾文案助手,为**已通过合规校验**的推荐产品撰写「推荐依据」。
|
||
|
||
硬性要求:
|
||
1. 只能使用我提供的数据,**严禁编造**任何数字、业绩、奖项、排名或基金经理信息;
|
||
2. **严禁**出现承诺收益或绝对化表述,例如:保本、保证收益、稳赚、无风险、零风险、收益承诺、包赚、必赚;
|
||
3. 每条 45~80 个汉字,面向个人客户,专业克制、可读,说明"为什么这只产品适合这位客户";
|
||
4. 必须点出该产品的风险等级,并说明它与客户风险承受能力、投资期限或流动性要求的匹配关系;
|
||
5. 只输出 JSON,不要 Markdown 代码块、不要任何解释文字。
|
||
|
||
输出格式(严格):
|
||
{"items": [{"product_code": "159329", "reason": "……"}]}"""
|
||
|
||
|
||
def _pct(value: Any) -> str:
|
||
if not isinstance(value, (int, float)):
|
||
return "暂无"
|
||
return f"{value:+.2f}%"
|
||
|
||
|
||
def build_prompt(customer: dict[str, Any], products: list[dict[str, Any]]) -> str:
|
||
"""把客户约束与每只产品的**真实参数**摊平成提示词。"""
|
||
lines = [
|
||
"【客户约束】",
|
||
f"- 风险承受等级:{customer.get('risk_level') or '未知'}",
|
||
f"- 投资期限:{customer.get('horizon_months') or '未知'} 个月",
|
||
f"- 流动性要求:{customer.get('liquidity') or '未知'}",
|
||
"",
|
||
"【待写依据的产品】",
|
||
]
|
||
for product in products:
|
||
lines.extend([
|
||
f"- product_code={product.get('product_code')}",
|
||
f" 名称:{product.get('product_name')}({product.get('product_category')})",
|
||
f" 风险等级:{product.get('risk_level')}",
|
||
f" 排序得分:{product.get('score')}(0~1,越高表示与客户越匹配)",
|
||
f" 近 20 个交易日区间收益:{_pct(product.get('return_20d_pct'))}",
|
||
f" 近 60 个交易日区间收益:{_pct(product.get('return_60d_pct'))}",
|
||
f" 近 60 个交易日最大回撤:{_pct(product.get('max_drawdown_60d_pct'))}",
|
||
f" 系统当前给出的依据(可改写得更易读,但事实不得改变):{product.get('rule_reason')}",
|
||
])
|
||
lines.append("")
|
||
lines.append("请为上面每一只产品各写一条 reason,product_code 必须原样返回。")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _strip_code_fence(raw: str) -> str:
|
||
text = raw.strip()
|
||
if text.startswith("```"):
|
||
text = text.split("\n", 1)[-1] if "\n" in text else text
|
||
text = text.rsplit("```", 1)[0]
|
||
return text.strip()
|
||
|
||
|
||
def parse_items(raw: str) -> dict[str, str]:
|
||
"""从模型输出里解出 `{product_code: reason}`;结构不符一律返回空字典。"""
|
||
text = _strip_code_fence(raw)
|
||
start = text.find("{")
|
||
end = text.rfind("}")
|
||
if start == -1 or end <= start:
|
||
return {}
|
||
try:
|
||
payload = json.loads(text[start : end + 1])
|
||
except (ValueError, TypeError):
|
||
return {}
|
||
items = payload.get("items") if isinstance(payload, dict) else None
|
||
if not isinstance(items, list):
|
||
return {}
|
||
parsed: dict[str, str] = {}
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
code = item.get("product_code")
|
||
reason = item.get("reason")
|
||
if isinstance(code, str) and isinstance(reason, str) and reason.strip():
|
||
parsed[code.strip()] = reason.strip()
|
||
return parsed
|
||
|
||
|
||
def is_compliant(text: str) -> bool:
|
||
"""合规守卫:长度合理 + 不含收益承诺类表述。"""
|
||
if not (MIN_REASON_CHARS <= len(text) <= MAX_REASON_CHARS):
|
||
return False
|
||
return not any(phrase in text for phrase in PROHIBITED_PHRASES)
|
||
|
||
|
||
class AdvisorReasonService:
|
||
"""调用 OpenAI-compatible `/chat/completions` 生成推荐依据;失败返回空字典。"""
|
||
|
||
def __init__(self, client: httpx.AsyncClient | None = None) -> None:
|
||
self.client = client
|
||
|
||
async def enhance(
|
||
self, *, customer: dict[str, Any], products: list[dict[str, Any]]
|
||
) -> dict[str, str]:
|
||
settings = get_settings()
|
||
if not settings.advisor_reason_llm_enabled or not products:
|
||
return {}
|
||
api_key = settings.deepseek_api_key
|
||
if not api_key:
|
||
logger.warning("推荐依据 LLM 已启用但缺少 DEEPSEEK_API_KEY,回退到规则文案")
|
||
return {}
|
||
url = settings.advisor_reason_llm_base_url.rstrip("/") + "/chat/completions"
|
||
payload = {
|
||
"model": settings.advisor_reason_llm_model,
|
||
"messages": [
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": build_prompt(customer, products)},
|
||
],
|
||
"temperature": 0,
|
||
"max_tokens": 1500,
|
||
}
|
||
owns_client = self.client is None
|
||
client = self.client or httpx.AsyncClient()
|
||
try:
|
||
response = await client.post(
|
||
url,
|
||
headers={"Authorization": f"Bearer {api_key}",
|
||
"Content-Type": "application/json"},
|
||
json=payload,
|
||
timeout=httpx.Timeout(settings.advisor_reason_llm_timeout_seconds),
|
||
)
|
||
response.raise_for_status()
|
||
body: Any = response.json()
|
||
message = (body.get("choices") or [{}])[0].get("message") or {}
|
||
# 推理型模型把正文放在 `reasoning_content`,`content` 可能为空 —— 兜底读一次。
|
||
content = message.get("content") or message.get("reasoning_content") or ""
|
||
parsed = parse_items(str(content))
|
||
except Exception: # noqa: BLE001 — 模型不可用绝不能影响推荐主流程
|
||
logger.warning("推荐依据 LLM 调用失败,回退到规则文案", exc_info=True)
|
||
return {}
|
||
finally:
|
||
if owns_client:
|
||
await client.aclose()
|
||
|
||
allowed_codes = {str(product.get("product_code")) for product in products}
|
||
accepted: dict[str, str] = {}
|
||
for code, reason in parsed.items():
|
||
if code not in allowed_codes:
|
||
continue
|
||
if not is_compliant(reason):
|
||
logger.warning(
|
||
"推荐依据 LLM 文案未通过合规守卫,丢弃(product_code=%s)", code
|
||
)
|
||
continue
|
||
accepted[code] = reason
|
||
return accepted
|