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