Files
group_fqcd_jr/app/service/profile_generation_service.py
T
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
  P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
  转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
  禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
  (输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)

二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
  knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数

三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
  ({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"

四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
  (端点与账号均已不存在,此前稳定报 3 条假红)

五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期

六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
  过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
  _chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
2026-09-20 14:33:30 +08:00

304 lines
14 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.
"""画像版本生成服务(`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` 的两个目标存储。
#:
#: ⚠️ **一律小写**:消费端按 `target_store` 的值分派 handler
#: (`MemorySyncOutboxWorker.handlers.get(event.target_store)`),且
#: `graph_projection_worker` / `projection_reconciliation_service` 的领取与重放
#: 都以 `status in {"pending","failed"}` + `"processed"` 为准。全仓(包含投顾线两处生产者)
#: 统一使用小写 `milvus`/`neo4j`、`upsert`、`pending`。
#:
#: 历史说明:`docs/00` §6.4.6 该栏曾写作大写 `MILVUS`/`NEO4J`、`UPSERT` 与中文 `待处理`,
#: 与上述实现从未对齐;本模块原先照文档写,是**全仓唯一的异类**,导致自己写的事件
#: 任何消费者都领不到。现统一为小写,并已把历史 2 行就地改齐(主键/唯一键不变)。
TARGET_MILVUS = "milvus"
TARGET_NEO4J = "neo4j"
SYNC_TARGETS: tuple[str, ...] = (TARGET_MILVUS, TARGET_NEO4J)
#: 同步操作与状态取值。
SYNC_OPERATION_UPSERT = "upsert"
SYNC_STATUS_PENDING = "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"]),
# 客户分层:权威列在 `sys_user.customer_tier`(见 `ProfileRepository._SQL_PROFILE`)。
# 它必须进快照,否则读取侧白名单里的 `customer_tier` 永远取不到值 ——
# 实测症状就是「我够哪一档?」只能拿风险等级顶包。
"customer_tier": profile_row["customer_tier"],
"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,
# 别名:投影适配器契约用 `profile_version`。两个键都写,避免消费端
# 因生产者用词不同而取不到值(本仓三种 payload 形状的历史遗留)。
"profile_version": version,
"snapshot_hash": snapshot_hash,
"snapshot": snapshot,
"memory_sources": await self._memory_sources(customer_id),
}
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),
)
async def _memory_sources(self, customer_id: int) -> list[dict[str, Any]]:
"""组装投影适配器要的 `memory_sources`(数据源:`memory_unit` 中 `status='active'`)。
为什么由生产端组装而不是消费端回查:消费端到时那条记忆可能已改版本,
让它自己去查会把"投递的是哪一版"变成不确定。事件里带上当时的确定快照,
投递语义才与 `aggregate_version` 一致(幂等判据也才有意义)。
为什么是 `memory_unit` 而不是 `user_facts`:两者用途不同——`user_facts` 是
**已确认的结构化事实**,喂画像与图投影;`memory_unit` 是**长期记忆条目**,
正是长期记忆向量集合要存的东西。这不与"图投影只读 user_facts"的不变式冲突,
因为 Milvus 存的就是记忆本身的向量,不是画像事实的另一种说法。
"""
rows = await self._repo.active_memories(customer_id)
return [
{
"memory_uuid": str(row["memory_uuid"]),
"memory_key": str(row["memory_key"]),
"content": str(row["content"]),
"memory_type": str(row["memory_type"]),
"confidence": float(row["confidence"]),
"version": int(row["version"]),
"valid_until": (
row["valid_until"].isoformat() if row["valid_until"] else None
),
}
for row in rows
]
@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]
if parsed is None:
# JSON 列里存着**字面量** "null"(`ProfileAssemblyService._as_text` 曾把 Python
# `None` 序列化成这个字符串写进列里)。它是"没有值",不是"有一个叫 null 的偏好"。
return []
if isinstance(parsed, list):
return list(parsed)
# JSON 字符串:`json.loads` 已把序列化时的**外层引号**剥掉,要保留剥过的值 ——
# 用原始的 `value` 会让标签长成 `"\"自述:preference:...\""`(实测踩到)。
# 与 `ProfileAssemblyService._as_text` 的脱引号口径保持一致。
return [parsed] if isinstance(parsed, (str, int, float, bool)) 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()
#: 导出以便测试断言"写侧字段覆盖了读取侧白名单所需项"。
#:
#: **2026-09-19 取消 `customer_tier` 例外**:此前这里写着
#: `if field != "customer_tier"`,等于承认"读取侧白名单要它、写侧却拿不到它"。
#: 例外一旦存在,白名单与写侧就会长期分叉(实测后果:客服画像问答里分层缺席)。
#: 取数已在 `ProfileRepository._SQL_PROFILE` 补齐,例外随之取消 —— 白名单与写侧必须**等集**。
REQUIRED_SNAPSHOT_FIELDS: tuple[str, ...] = PROFILE_FIELD_POLICY