Files
group_fqcd_jr/app/service/advisor_reason_service.py
T

192 lines
8.2 KiB
Python
Raw Normal View History

"""投顾「推荐依据」的 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