"""`query_customer_profile`:客户画像的**公共只读工具**。 ## 为什么是工具而不是让 Agent 直接查库 `AGENTS.md` 第 7 条要求业务 Agent 必须由 `AgentFactory` 创建,且**不得绕过公共鉴权、记忆、 模型路由、工具、合规、审计和事件流程**。Agent 属 Service 层,不得直接建 Session 或查 Repository/Model。所以画像读取必须收口成一个公共工具,由底座统一注入鉴权、数据范围与审计。 ## 实现的范式(照搬 `SuitabilityService`,逐条对齐) | 关注点 | 做法 | |---|---| | 鉴权 | `AuthorizationService.require(context, "memory:read:self" / "memory:read:customer")` | | 数据范围 | 客户只能读自己;他人需 `memory:read:customer` 且落在 `own_customers` / `all` 范围内 | | 失败关闭 | 客户无当前画像 / 快照非法 → **抛错,不返回空画像**(空画像会被误读成"该客户无偏好") | | 审计 | 与读取**同一事务**写 `interaction_audit`(`memory.profile_read`),与 HTTP 端点同口径 | | 字段策略 | 复用 `app/core/profile_projection.py` 的白名单投影,**不返回 PII** | ## 与 HTTP 端点(M001/M002)的关系 两者**共用同一套字段策略与数据范围判定**,只是入口不同:HTTP 给前端,工具给 Agent。 **不重复实现投影逻辑**,避免"两个入口返回不同字段"的漂移。 """ from __future__ import annotations from datetime import UTC, datetime from typing import Any from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession from app.core.contracts import RequestContext from app.core.errors import ForbiddenAgentError, GenericResourceNotFoundError from app.core.profile_projection import project_profile from app.infrastructure.db import SessionFactory from app.model.audit import InteractionAudit from app.repository.platform_repository import PlatformRepository from app.service.authorization_service import AuthorizationService #: 只读工具名(与 `bootstrap` 注册、Agent 的 `allowed_tools`、发布配置三处必须一致)。 TOOL_NAME = "query_customer_profile" REQUIRED_PERMISSION_SELF = "memory:read:self" REQUIRED_PERMISSION_CUSTOMER = "memory:read:customer" AUDIT_ACTION = "memory.profile_read" #: 可查询的画像字段;`None` 表示"全部白名单字段"(便于 Agent 只取所需)。 ALLOWED_FIELDS: frozenset[str] = frozenset({ "investor_type", "investment_horizon", "trading_frequency", "preferred_asset_class", "risk_tags", "customer_tier", "behavior_score", "total_asset", "assessment_valid_until", "assessment_expired", }) class CustomerProfileQuery(BaseModel): """工具入参:只能声明"查谁",**不能**指定权限范围或数据来源。""" model_config = ConfigDict(extra="forbid", frozen=True) customer_id: str = Field(min_length=1, max_length=20, pattern=r"^[0-9]+$") fields: tuple[str, ...] = Field(default=(), max_length=10) class CustomerProfileView(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) customer_id: str version: str | None = None profile: dict[str, Any] available_fields: tuple[str, ...] = () class CustomerProfileService: """只读画像服务:鉴权 → 范围 → 读当前快照 → 字段投影 → 审计。""" def __init__(self, *, session_factory: Any | None = None) -> None: self._session_factory = session_factory or SessionFactory async def load( self, request: CustomerProfileQuery, context: RequestContext, *, now: datetime | None = None ) -> CustomerProfileView: current = now or datetime.now(UTC) permission = await self._authorize(request.customer_id, context) async with self._session_factory() as session, session.begin(): return await self._read( session, request, context, permission=permission, now=current ) @staticmethod async def _authorize(customer_id: str, context: RequestContext) -> str: """鉴权 + 数据范围;不通过时**不泄露**目标客户是否存在。""" own = customer_id == context.user_id permission = REQUIRED_PERMISSION_SELF if own else REQUIRED_PERMISSION_CUSTOMER await AuthorizationService.require(context, permission) if own: return permission scope = context.permission_scopes.get(permission, "self") if scope == "all": return permission if scope == "own_customers" and customer_id in context.customer_ids: return permission # 与 HTTP 端点同口径:越范围一律按"不存在"处理,避免枚举客户。 raise GenericResourceNotFoundError("客户不可访问") @staticmethod async def _read( session: AsyncSession, request: CustomerProfileQuery, context: RequestContext, *, permission: str, now: datetime, ) -> CustomerProfileView: customer_id = int(request.customer_id) rows = await PlatformRepository(session).rows( "profile_snapshots", {"customer_id": customer_id, "is_current": 1}, limit=1 ) session.add(InteractionAudit( actor_type="agent", actor_id=int(context.user_id), target_customer_id=customer_id, portal=context.portal, action_type=AUDIT_ACTION, detail={"trace_id": context.trace_id, "permission": permission}, created_at=now, )) if not rows: # 失败关闭:没有当前画像不得返回 `{}` —— 空画像会被下游误读成"该客户无偏好"。 raise GenericResourceNotFoundError("该客户暂无当前画像") snapshot = rows[0].get("snapshot") profile = project_profile(snapshot, now=now) if not profile: raise GenericResourceNotFoundError("该客户画像快照为空或格式非法") if request.fields: unknown = [f for f in request.fields if f not in ALLOWED_FIELDS] if unknown: raise ForbiddenAgentError(f"不支持查询画像字段:{sorted(unknown)}") profile = {k: v for k, v in profile.items() if k in request.fields} return CustomerProfileView( customer_id=str(customer_id), version=str(rows[0]["version"]) if rows[0].get("version") is not None else None, profile=profile, available_fields=tuple(sorted(ALLOWED_FIELDS)), ) async def query_customer_profile_tool( arguments: CustomerProfileQuery, context: RequestContext ) -> dict[str, Any]: """ToolExecutor 入口:返回 JSON 可序列化的画像视图。""" view = await CustomerProfileService().load(arguments, context) return view.model_dump(mode="json")