Files
group_fqcd_jr/app/service/advisor_reason_service.py
Windows 74b7d00dff 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;补「两处共用同一渲染」回归测试
2026-09-16 18:17:47 +08:00

192 lines
8.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""投顾「推荐依据」的 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