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
|