Files
Mutual_Fund/service/memory/composed_profile.py

437 lines
18 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.
"""最终用户画像整合服务:fin_customer_profile + memory_unit + 持仓 三源加权。
- 权重语义是 LLM 整合时的注意力权重(配置于 sys_config:profile.weight.*),
不做算术加权——三源是异构数据。
- 只做派生视图:结果缓存在 Redis(成功 30 分钟 / 降级 5 分钟),
不落库、不回写 fin_customer_profile,适当性校验仍以问卷风评原值为准。
- LLM 失败时降级为规则拼接摘要,沿项目 warnings 机制上报,绝不阻塞调用方。
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from repositories.sys_config import SysConfigRepo
from tool.confidence_rank import FinalConfidenceRankTool
from .holdings import CustomerHoldingsMemory
from .long_term import LongTermMemoryService
from .profile import CustomerProfileMemory
logger = logging.getLogger(__name__)
class ComposedProfileService:
"""拉取三路数据,调用统一 LLM 整合为最终用户画像。"""
CACHE_TTL = 20 * 60
DEGRADED_CACHE_TTL = 20 * 60
RESPONSE_CACHE_TTL = 20 * 60
RESPONSE_CACHE_PREFIX = "composed_profile_response"
DEFAULT_WEIGHTS = {"profile": 0.5, "memory": 0.2, "holdings": 0.3}
WEIGHT_KEYS = {
"profile": "profile.weight.profile",
"memory": "profile.weight.memory",
"holdings": "profile.weight.holdings",
}
ENABLED_KEY = "profile.composed.enabled"
MEMORY_TOP_K_KEY = "profile.composed.memory_top_k"
DEFAULT_MEMORY_TOP_K = 10
SYSTEM_PROMPT = """
你是用户画像整合器。根据三路客户数据生成最终用户画像 JSON,只输出 JSON,不要 Markdown:
{{"summary": "一段话画像结论", "risk_tendency": "风险偏好结论",
"preference_tags": ["标签"], "behavior_traits": ["行为特征"], "advice_focus": "服务建议"}}
权重指引:结构化画像信息可信度最高(权重 {w_profile});持仓反映真实风险行为(权重 {w_holdings});
对话记忆是口头表达补充(权重 {w_memory})。三源冲突时按权重取舍,且必须在 summary 中显式说明冲突。
status 为 candidate 的记忆只能作参考,不能作为确定性结论。
只能基于输入事实归纳,禁止编造;数据缺失的字段留空数组或空字符串,不要推测。
禁止在结果中出现内部字段名、ID、SQL 或证据引用。
""".strip()
def __init__(
self,
*,
profile: CustomerProfileMemory | None = None,
long_term: LongTermMemoryService | None = None,
holdings: CustomerHoldingsMemory | None = None,
redis=None,
llm_client=None,
rank_tool: FinalConfidenceRankTool | None = None,
config_repo_cls=SysConfigRepo,
):
from config.database.redis import client as redis_client
from tool.llm import llm as default_llm
self.profile = profile or CustomerProfileMemory()
self.long_term = long_term or LongTermMemoryService()
self.holdings = holdings or CustomerHoldingsMemory()
self.redis = redis or redis_client()
self.llm_client = llm_client or default_llm
self.rank_tool = rank_tool or FinalConfidenceRankTool()
self.config_repo_cls = config_repo_cls
@staticmethod
def cache_key(customer_id: int) -> str:
"""生成画像负载缓存 Key。"""
return f"composed_profile:{customer_id}"
@staticmethod
def response_cache_key(customer_id: int) -> str:
"""生成接口完整响应缓存 Key。"""
return f"{ComposedProfileService.RESPONSE_CACHE_PREFIX}:{customer_id}"
async def compose(
self,
db: AsyncSession,
*,
customer_id: int,
query: str | None = None,
profile: dict[str, Any] | None = None,
memories: list[dict[str, Any]] | None = None,
holdings_summary: dict[str, Any] | None = None,
) -> tuple[dict[str, Any] | None, list[str]]:
"""整合最终画像;可传入 facade 已召回的数据避免重复取数。"""
warnings: list[str] = []
enabled = await self._enabled(db, warnings)
if not enabled:
return None, warnings
cached = await self._cache_get(customer_id)
if cached is not None:
return cached, warnings
weights, weight_warnings = await self._load_weights(db)
warnings.extend(weight_warnings)
top_k = await self._memory_top_k(db, warnings)
if profile is None:
profile, profile_warnings = await self.profile.get(db, customer_id)
warnings.extend(profile_warnings)
if memories is None:
memories = await self._recall_memories(db, customer_id, query, top_k, warnings)
if holdings_summary is None:
holdings_summary, holdings_warnings = await self.holdings.summary(db, customer_id)
warnings.extend(holdings_warnings)
slim_memories = self._slim_memories(memories, top_k)
if profile is None and not slim_memories and holdings_summary is None:
return None, warnings
try:
result = await self._integrate(weights, profile, slim_memories, holdings_summary)
cache_ttl = self.CACHE_TTL
except Exception as exc: # noqa: BLE001 降级不阻塞
warnings.append(f"composed_profile_llm_failed:{type(exc).__name__}")
result = self._degraded(profile, slim_memories, holdings_summary)
cache_ttl = self.DEGRADED_CACHE_TTL
payload = {
"profile": result,
"weights": weights,
"generated_at": datetime.now().isoformat(timespec="seconds"),
}
await self._cache_set(customer_id, payload, cache_ttl)
return payload, warnings
async def invalidate(self, customer_id: int) -> list[str]:
"""删除画像负载与接口响应两级缓存,供画像/持仓变更后调用。"""
warnings: list[str] = []
for key in (self.cache_key(customer_id), self.response_cache_key(customer_id)):
try:
await self.redis.delete(key)
except Exception as exc: # noqa: BLE001
warnings.append(f"composed_profile_cache_invalidate_failed:{type(exc).__name__}")
return warnings
async def compose_response(
self, db: AsyncSession, *, customer_id: int
) -> tuple[dict[str, Any] | None, list[str]]:
"""接口层入口:完整响应体先查 Redis(20 分钟过期),未命中才整合。
返回结构即接口 data:
{"customer_id": ..., "profile": <画像负载或 None>, "warnings": [...]}
profile 为 None(功能关闭或无任何输入)时不缓存,便于开关立即生效。
"""
key = self.response_cache_key(customer_id)
try:
raw = await self.redis.get(key)
if raw:
return json.loads(raw), []
except Exception: # noqa: BLE001 缓存读取失败视为未命中
pass
payload, warnings = await self.compose(db, customer_id=customer_id)
data = {
"customer_id": customer_id,
"profile": payload,
"warnings": warnings,
}
if payload is not None:
try:
await self.redis.set(
key,
json.dumps(data, ensure_ascii=False, default=str),
ex=self.RESPONSE_CACHE_TTL,
)
except Exception: # noqa: BLE001 缓存写入失败不影响返回
pass
return data, warnings
# ---- 配置 -----------------------------------------------------------
async def _enabled(self, db: AsyncSession, warnings: list[str]) -> bool:
"""读取功能开关;配置读取失败时默认开启,保证功能可用。"""
try:
value = await self.config_repo_cls(db).get_value(self.ENABLED_KEY, "true")
except Exception as exc: # noqa: BLE001
warnings.append(f"composed_config_failed:{type(exc).__name__}")
return True
return str(value).strip().lower() not in {"false", "0", "off"}
async def _load_weights(self, db: AsyncSession) -> tuple[dict[str, float], list[str]]:
"""读取三源权重;缺失用默认值,非法或权重和不为 1 时整体回退默认。"""
warnings: list[str] = []
try:
repo = self.config_repo_cls(db)
raw = {
name: await repo.get_value(key, str(default))
for name, key, default in (
(name, key, self.DEFAULT_WEIGHTS[name])
for name, key in self.WEIGHT_KEYS.items()
)
}
except Exception as exc: # noqa: BLE001
return dict(self.DEFAULT_WEIGHTS), [f"composed_config_failed:{type(exc).__name__}"]
weights: dict[str, float] = {}
valid = True
for name, value in raw.items():
try:
parsed = float(value)
except (TypeError, ValueError):
valid = False
break
if not 0.0 <= parsed <= 1.0:
valid = False
break
weights[name] = parsed
if not valid or abs(sum(weights.values()) - 1.0) > 0.01:
warnings.append("composed_weight_invalid")
return dict(self.DEFAULT_WEIGHTS), warnings
return weights, warnings
async def _memory_top_k(self, db: AsyncSession, warnings: list[str]) -> int:
"""读取进入整合的记忆条数上限,非法回退默认。"""
try:
raw = await self.config_repo_cls(db).get_value(
self.MEMORY_TOP_K_KEY, str(self.DEFAULT_MEMORY_TOP_K)
)
top_k = int(str(raw))
except Exception: # noqa: BLE001
return self.DEFAULT_MEMORY_TOP_K
if top_k <= 0:
warnings.append("composed_top_k_invalid")
return self.DEFAULT_MEMORY_TOP_K
return top_k
# ---- 数据与整合 ------------------------------------------------------
async def _recall_memories(
self,
db: AsyncSession,
customer_id: int,
query: str | None,
top_k: int,
warnings: list[str],
) -> list[dict[str, Any]]:
"""召回长期记忆并按综合置信分重排;失败降级为空列表。"""
try:
dtos, memory_warnings = await self.long_term.recall(
db, customer_id, limit=max(top_k, 10), query=query
)
warnings.extend(memory_warnings)
ranked = self.rank_tool.rank(
[dto.model_dump(mode="json") for dto in dtos], top_k=top_k
)
return ranked
except Exception as exc: # noqa: BLE001
warnings.append(f"composed_memory_recall_failed:{type(exc).__name__}")
return []
@staticmethod
def _slim_memories(
memories: list[dict[str, Any]] | None, top_k: int
) -> list[dict[str, Any]]:
"""裁剪记忆字段:只保留整合所需的最小集合,不带证据引用。"""
slim = []
for item in (memories or [])[:top_k]:
slim.append(
{
"tag": item.get("tag"),
"memory_type": item.get("memory_type"),
"content": item.get("content"),
"status": item.get("status"),
"confidence": item.get("confidence"),
}
)
return slim
async def _integrate(
self,
weights: dict[str, float],
profile: dict[str, Any] | None,
memories: list[dict[str, Any]],
holdings: dict[str, Any] | None,
) -> dict[str, Any]:
"""调用统一 LLM 整合三源数据,解析失败时重试一次。"""
prompt = [
{
"role": "system",
"content": self.SYSTEM_PROMPT.format(
w_profile=weights["profile"],
w_memory=weights["memory"],
w_holdings=weights["holdings"],
),
},
{
"role": "user",
"content": json.dumps(
{
"weights": weights,
"profile": profile,
"memories": memories,
"holdings": holdings,
},
ensure_ascii=False,
default=str,
),
},
]
last_error: Exception | None = None
for _ in range(2):
try:
# max_tokens 传 None:沿用全局 LLM_MAX_TOKENS。思考型模型(如
# qwen3)会先输出推理段,限太小会截断 JSON 导致解析失败。
text = await self.llm_client.chat(
prompt, temperature=0, max_tokens=None
)
result = self._parse_json(text)
except Exception as exc: # noqa: BLE001 网络或解析失败都重试一次
last_error = exc
logger.warning(
"composed profile integrate attempt failed: %s: %s; raw=%r",
type(exc).__name__,
exc,
(text if "text" in locals() else "")[:300],
)
continue
if isinstance(result.get("summary"), str) and result["summary"].strip():
return result
last_error = ValueError("整合结果缺少 summary 字段")
raise last_error or ValueError("LLM 整合结果非法")
_THINK_PATTERN = re.compile(r"<think>.*?</think>", re.S | re.I)
@classmethod
def _parse_json(cls, text: str) -> dict[str, Any]:
"""解析 LLM JSON 输出:剥离思考标签和围栏,容忍正文夹杂说明文字。"""
payload = text.strip()
payload = cls._THINK_PATTERN.sub("", payload).strip()
fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", payload, re.S | re.I)
if fenced:
payload = fenced.group(1).strip()
try:
data = json.loads(payload)
except json.JSONDecodeError:
# 模型可能在 JSON 前后加了说明文字:提取首个 { 到最后一个 } 的片段重试
start = payload.find("{")
end = payload.rfind("}")
if start < 0 or end <= start:
raise
data = json.loads(payload[start : end + 1])
if not isinstance(data, dict):
raise ValueError("整合结果必须是 JSON 对象")
return data
@staticmethod
def _degraded(
profile: dict[str, Any] | None,
memories: list[dict[str, Any]],
holdings: dict[str, Any] | None,
) -> dict[str, Any]:
"""LLM 失败时的规则拼接摘要,保证接口始终有可用输出。"""
parts: list[str] = []
if profile:
parts.append(
f"画像:风险等级 {profile.get('risk_level') or '未知'}"
f"(评分 {profile.get('risk_score') or '未知'})"
)
if memories:
confirmed = [m["content"] for m in memories if m.get("status") == "confirmed"]
chosen = confirmed or [m["content"] for m in memories]
parts.append("记忆:" + ";".join(chosen[:3]))
if holdings:
parts.append(
f"持仓:{holdings.get('holding_count', 0)} 只在持"
f",总市值 {holdings.get('total_market_value', 0)}"
)
return {
"summary": "。".join(parts) if parts else "暂无可用客户信息",
"risk_tendency": (profile or {}).get("risk_level") or "",
"preference_tags": [m.get("tag") for m in memories if m.get("tag")][:5],
"behavior_traits": [],
"advice_focus": "",
"degraded": True,
"sources": {
"profile": profile is not None,
"memory": bool(memories),
"holdings": holdings is not None,
},
}
# ---- 缓存 -------------------------------------------------------------
async def _cache_get(self, customer_id: int) -> dict[str, Any] | None:
"""读取缓存;读取或解析失败视为未命中。"""
try:
raw = await self.redis.get(self.cache_key(customer_id))
if raw:
return json.loads(raw)
except Exception: # noqa: BLE001
return None
return None
async def _cache_set(
self, customer_id: int, payload: dict[str, Any], ttl: int
) -> None:
"""写入缓存;失败静默(缓存只是加速手段)。"""
try:
await self.redis.set(
self.cache_key(customer_id),
json.dumps(payload, ensure_ascii=False, default=str),
ex=ttl,
)
except Exception: # noqa: BLE001
return
async def invalidate_composed_profile(customer_id: int) -> None:
"""轻量失效入口:画像回写、申购/赎回落账后调用,静默失败不阻塞业务。"""
from config.database.redis import client as redis_client
try:
redis = redis_client()
await redis.delete(ComposedProfileService.cache_key(customer_id))
await redis.delete(ComposedProfileService.response_cache_key(customer_id))
except Exception: # noqa: BLE001 缓存失效失败只影响新鲜度,不阻塞业务事务
return
__all__ = ["ComposedProfileService", "invalidate_composed_profile"]