437 lines
18 KiB
Python
437 lines
18 KiB
Python
"""最终用户画像整合服务: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"]
|