wip: 客服Agent + RAG + 画像收尾(基于 6516ccb)
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
"""画像版本生成服务(`docs/00` §6.4.6 的生成器实现)。
|
||||
|
||||
## 为什么需要它
|
||||
|
||||
底座**规划了** `profile_snapshots` 版本投影表,也**写明了规则**,但**没有生成器**:
|
||||
|
||||
> 「画像生成只允许读取已提交的业务事实和状态为"有效"的中期记忆。新画像和两条同步事件
|
||||
> 必须在同一个 MySQL 事务中写入,旧画像的 `is_current` 同时置为 0。」
|
||||
> —— `docs/00` §6.4.6
|
||||
|
||||
本服务把这条规则落成代码。**在它之前**,`profile_snapshots` 里的数据只能由种子脚本直接写,
|
||||
"画像怎么生成的"没有可运行答案。
|
||||
|
||||
## 落地的四条规则
|
||||
|
||||
1. **同事务**:新画像版本 + **两条**跨存储同步事件(MILVUS / NEO4J)一起提交,任一失败整体回滚。
|
||||
2. **旧版本同时置 0**:`is_current` 是 `profile_snapshots` 的唯一当前标记
|
||||
(`current_customer_id` 上有唯一键),必须先清旧再插新。
|
||||
3. **幂等**:`snapshot_hash` 未变化时**不产生新版本**(返回 `changed=False`),
|
||||
避免每次调用都把版本号 +1、并往 outbox 灌重复事件。
|
||||
4. **只读已提交事实**:数据来源限定为 `fin_customer_profile` 与 `fin_risk_assessment`
|
||||
(以及有效中期记忆的口径由 `generation_basis` 记录),不读取未提交状态。
|
||||
|
||||
## 与读取侧的关系
|
||||
|
||||
生成的 `snapshot` JSON 结构**必须与 `app/core/profile_projection.py` 的白名单字段一致** ——
|
||||
投影是只读白名单,写侧多写字段不会被对外暴露,但白名单要求的字段**必须存在**,
|
||||
否则端点会返回空画像。这一点由 `test_profile_generation_service.py` 固定。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.errors import ValidationAgentError
|
||||
from app.core.profile_projection import PROFILE_FIELD_POLICY
|
||||
from app.repository.profile_repository import ProfileRepository
|
||||
|
||||
#: `memory_sync_outbox` 的两个目标存储(`docs/00` §6.4.6 的 `target_store` 取值)。
|
||||
TARGET_MILVUS = "MILVUS"
|
||||
TARGET_NEO4J = "NEO4J"
|
||||
SYNC_TARGETS: tuple[str, ...] = (TARGET_MILVUS, TARGET_NEO4J)
|
||||
|
||||
#: 同步操作与状态取值(与 `memory_sync_outbox` DDL 的语义一致)。
|
||||
SYNC_OPERATION_UPSERT = "UPSERT"
|
||||
SYNC_STATUS_PENDING = "待处理"
|
||||
AGGREGATE_TYPE_PROFILE = "profile"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProfileGenerationResult:
|
||||
"""生成结果;`changed=False` 表示快照未变化、**未写任何行**。"""
|
||||
|
||||
customer_id: int
|
||||
version: int
|
||||
profile_uuid: str
|
||||
snapshot_hash: str
|
||||
changed: bool
|
||||
sync_events: int
|
||||
|
||||
|
||||
def build_snapshot(
|
||||
profile_row: Mapping[str, Any],
|
||||
assessment_row: Mapping[str, Any] | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""把权威事实投影成画像快照(字段与读取侧白名单对齐)。
|
||||
|
||||
- `assessment_valid_until` / `assessment_expired` **由测评行实时推出**,不沿用旧快照的布尔值;
|
||||
没有测评行时两者都**不写入**(读取侧据此表现为"无测评信息",而不是伪造一个有效期)。
|
||||
- 时间字段统一用 ISO 字符串,保证 JSON 稳定、`snapshot_hash` 可复现。
|
||||
"""
|
||||
current = now or datetime.now(UTC)
|
||||
snapshot: dict[str, object] = {
|
||||
"investor_type": profile_row["investor_type"],
|
||||
"investment_horizon": profile_row["investment_horizon"],
|
||||
"trading_frequency": profile_row["trading_frequency"],
|
||||
"preferred_asset_class": _as_list(profile_row["preferred_asset_class"]),
|
||||
"risk_tags": _as_list(profile_row["risk_tags"]),
|
||||
"behavior_score": profile_row["behavior_score"],
|
||||
"total_asset": _as_money(profile_row["total_asset"]),
|
||||
}
|
||||
if profile_row["last_active_at"] is not None:
|
||||
snapshot["last_active_at"] = _iso(profile_row["last_active_at"])
|
||||
|
||||
if assessment_row is not None and assessment_row["valid_until"] is not None:
|
||||
valid_until = _instant(assessment_row["valid_until"])
|
||||
snapshot["assessment_valid_until"] = valid_until.isoformat()
|
||||
snapshot["assessment_expired"] = valid_until <= current
|
||||
return snapshot
|
||||
|
||||
|
||||
def compute_hash(snapshot: dict[str, object]) -> str:
|
||||
"""`snapshot_hash`:对**键排序后**的规范化 JSON 求 SHA-256。
|
||||
|
||||
排序是为了让"同一内容"总是得到同一哈希 —— 它是幂等判据,不能被字典插入顺序影响。
|
||||
"""
|
||||
canonical = json.dumps(snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class ProfileGenerationService:
|
||||
"""生成客户画像版本;**不 commit**,事务归调用方。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
self._repo = ProfileRepository(session)
|
||||
|
||||
async def generate(
|
||||
self, customer_id: int, *, now: datetime | None = None
|
||||
) -> ProfileGenerationResult:
|
||||
current = now or datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
profile_row = await self._repo.profile_row(customer_id)
|
||||
if profile_row is None:
|
||||
# 失败关闭:没有客户主表行就不该凭空造画像。
|
||||
raise ValidationAgentError(f"客户不存在或缺少画像主表行:{customer_id}")
|
||||
assessment_row = await self._repo.latest_assessment(customer_id)
|
||||
|
||||
snapshot = build_snapshot(profile_row, assessment_row, now=current.replace(tzinfo=UTC))
|
||||
snapshot_hash = compute_hash(snapshot)
|
||||
|
||||
existing = await self._repo.current_snapshot(customer_id)
|
||||
if existing is not None and str(existing["snapshot_hash"] or "") == snapshot_hash:
|
||||
# 幂等:内容没变就不升版本、不投事件。
|
||||
return ProfileGenerationResult(
|
||||
customer_id=customer_id,
|
||||
version=int(existing["version"]),
|
||||
profile_uuid=str(existing["profile_uuid"] or ""),
|
||||
snapshot_hash=snapshot_hash,
|
||||
changed=False,
|
||||
sync_events=0,
|
||||
)
|
||||
|
||||
# `profile_uuid` 上有**唯一键**(跨版本也唯一),所以每个版本必须用新 uuid ——
|
||||
# 不能沿用以表示"同一实体"(实测会撞 `Duplicate entry ... for key profile_uuid`)。
|
||||
# 待接入投影消费者时,若 Milvus/Neo4j 需要**跨版本稳定的实体标识**,
|
||||
# 应另加一列(例如 `entity_uuid`)区分"版本标识"与"实体标识",不要复用本列。
|
||||
profile_uuid = str(uuid4())
|
||||
version = await self._repo.next_version(customer_id)
|
||||
|
||||
# 顺序要紧:先清旧当前标记(唯一键约束),再插新版本。
|
||||
await self._repo.clear_current(customer_id, now=current)
|
||||
await self._repo.insert_snapshot(
|
||||
customer_id=customer_id,
|
||||
version=version,
|
||||
profile_uuid=profile_uuid,
|
||||
snapshot_json=json.dumps(snapshot, ensure_ascii=False, sort_keys=True),
|
||||
generation_basis_json=json.dumps(
|
||||
self._generation_basis(assessment_row), ensure_ascii=False, sort_keys=True
|
||||
),
|
||||
snapshot_hash=snapshot_hash,
|
||||
now=current,
|
||||
)
|
||||
|
||||
# 两条同步事件共用同一个 event_uuid(唯一键是 (event_uuid, target_store))。
|
||||
event_uuid = str(uuid4())
|
||||
payload = {
|
||||
"aggregate_uuid": profile_uuid,
|
||||
"customer_id": str(customer_id),
|
||||
"version": version,
|
||||
"snapshot_hash": snapshot_hash,
|
||||
"snapshot": snapshot,
|
||||
}
|
||||
for target in SYNC_TARGETS:
|
||||
self._repo.add_sync_event(
|
||||
event_uuid=event_uuid,
|
||||
target_store=target,
|
||||
aggregate_uuid=profile_uuid,
|
||||
aggregate_version=version,
|
||||
payload=payload,
|
||||
now=current,
|
||||
status=SYNC_STATUS_PENDING,
|
||||
operation=SYNC_OPERATION_UPSERT,
|
||||
)
|
||||
|
||||
return ProfileGenerationResult(
|
||||
customer_id=customer_id,
|
||||
version=version,
|
||||
profile_uuid=profile_uuid,
|
||||
snapshot_hash=snapshot_hash,
|
||||
changed=True,
|
||||
sync_events=len(SYNC_TARGETS),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generation_basis(assessment_row: Mapping[str, Any] | None) -> dict[str, object]:
|
||||
"""记录这次画像**依据了什么**(`docs/00`:使用的测评版本、交易窗口和记忆版本列表)。
|
||||
|
||||
当前只接入了测评版本;记忆与交易窗口待接入时补键,不臆造。
|
||||
"""
|
||||
basis: dict[str, object] = {"sources": ["fin_customer_profile", "fin_risk_assessment"]}
|
||||
if assessment_row is not None:
|
||||
basis["questionnaire_version"] = assessment_row["questionnaire_version"]
|
||||
basis["assessment_id"] = int(assessment_row["id"])
|
||||
return basis
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 取值归一化
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _as_list(value: object) -> list[object]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return list(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except ValueError:
|
||||
return [value]
|
||||
return list(parsed) if isinstance(parsed, list) else [value]
|
||||
return [value]
|
||||
|
||||
|
||||
def _as_money(value: object) -> str:
|
||||
"""金额统一成字符串(与读取侧 `public()` 的口径一致,避免浮点误差)。"""
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _instant(value: object) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
return datetime.fromisoformat(str(value)).replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _iso(value: object) -> str:
|
||||
return _instant(value).isoformat()
|
||||
|
||||
|
||||
#: 导出以便测试断言"写侧字段覆盖了读取侧白名单所需项"。
|
||||
REQUIRED_SNAPSHOT_FIELDS: tuple[str, ...] = tuple(
|
||||
field for field in PROFILE_FIELD_POLICY if field != "customer_tier"
|
||||
)
|
||||
Reference in New Issue
Block a user