160 lines
6.7 KiB
Python
160 lines
6.7 KiB
Python
"""`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")
|