Files
group_fqcd_jr/app/service/advisor_reason_service.py
T
张胜宇 9675df8453 chore(sync): zsy_developcc 全量同步至 qyqy_develop(W26 口径)
- 分支内容对齐 qyqy_develop b6ec3aa,树完全一致(同步后 git diff 为空)
- 覆盖本轮全部交付:客服 Agent 重构(安全路由 / 五出口 / 记忆与画像 / RAG 全链路)
  + 开发文档 62 份编号体系(D1.1 v1.17 索引)
  + 新增 D2.10-客服Agent端到端答辩文档-2026-09-21.html
- 基线:e239eb7(2026-09-17 品牌口径统一快照),本提交为其直接后继
2026-09-21 21:26:30 +08:00

192 lines
8.2 KiB
Python
Raw 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