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")
|