Преглед изворни кода

fix:1、处理前端展示bug;2、新增代码注释

yangxiaolong пре 1 месец
родитељ
комит
1a3290ce8f

+ 24 - 0
backend/src/zbt/api/v1/agent.py

@@ -1,3 +1,9 @@
+"""Agent HTTP 接口。
+
+本模块只处理请求参数、登录身份和 HTTP/SSE 响应。
+会话归属、限流、模型调用与持久化统一交给 ``AgentThreadService``。
+"""
+
 from typing import Annotated, Any
 
 from fastapi import APIRouter, Depends, Request, status
@@ -15,15 +21,21 @@ agent_bearer_scheme = HTTPBearer(auto_error=False)
 
 
 class CreateThreadRequest(BaseModel):
+    """创建会话时允许客户端提交的字段。"""
+
     title: str | None = Field(default=None, max_length=128)
 
 
 class TextContent(BaseModel):
+    """当前阶段只支持文本消息。"""
+
     type: str = Field(pattern="^text$")
     text: str = Field(min_length=1, max_length=4000)
 
 
 class SendMessageRequest(BaseModel):
+    """发送消息的请求体;客户端消息 ID 用于标识一次提交。"""
+
     content: TextContent
     page_context: dict[str, Any] | None = None
     client_message_id: str = Field(min_length=1, max_length=64)
@@ -34,6 +46,8 @@ def create_agent_router(
     h5_auth_service: H5AuthService,
     admin_auth_service: AdminAuthService,
 ) -> APIRouter:
+    """创建 Agent 路由,并使用应用启动时装配好的服务对象。"""
+
     router = APIRouter(tags=["agent"])
 
     def current_h5_user(
@@ -42,6 +56,8 @@ def create_agent_router(
             Depends(agent_bearer_scheme),
         ],
     ) -> H5User:
+        """FastAPI 依赖:解析 Bearer Token,得到当前 H5 用户。"""
+
         if credentials is None:
             raise AppError("AUTH_REQUIRED", "请先登录", 401)
         return h5_auth_service.authenticate_access(credentials.credentials)
@@ -52,6 +68,8 @@ def create_agent_router(
             Depends(agent_bearer_scheme),
         ],
     ) -> AdminUser:
+        """FastAPI 依赖:解析 Bearer Token,得到当前后台账号。"""
+
         if credentials is None:
             raise AppError("AUTH_REQUIRED", "请先登录后台", 401)
         return admin_auth_service.authenticate_access(credentials.credentials)
@@ -92,6 +110,8 @@ def create_agent_router(
         payload: SendMessageRequest,
         user: Annotated[H5User, Depends(current_h5_user)],
     ) -> StreamingResponse:
+        """以 SSE 持续返回运行事件,无需等待整次模型调用结束。"""
+
         events = thread_service.stream_customer_message(
             user,
             thread_id=thread_id,
@@ -133,6 +153,8 @@ def create_agent_router(
         run_id: str,
         user: Annotated[H5User, Depends(current_h5_user)],
     ) -> StreamingResponse:
+        """重放已保存事件,供页面刷新或网络恢复后继续展示进度。"""
+
         events = thread_service.list_customer_run_events(user, run_id=run_id)
         return StreamingResponse(
             (event.to_sse() for event in events),
@@ -201,6 +223,8 @@ def create_agent_router(
         user: Annotated[AdminUser, Depends(current_admin)],
         limit: int = 20,
     ) -> dict[str, Any]:
+        """返回当前后台账号自己的运行历史,并限制最大查询数量。"""
+
         return success_response(
             request,
             thread_service.list_operation_runs(user, limit=min(max(limit, 1), 100)),

+ 42 - 0
backend/src/zbt/api/v1/enrollment.py

@@ -1,3 +1,9 @@
+"""H5 投保 HTTP 接口。
+
+接口负责校验请求结构和当前登录用户,再把确定性业务交给 EnrollmentService。
+模型不会直接调用这些交易接口,必须由用户在页面上确认后提交。
+"""
+
 from typing import Annotated, Any
 
 from fastapi import APIRouter, Depends, Header, Request, Response, status
@@ -15,12 +21,16 @@ enrollment_bearer_scheme = HTTPBearer(auto_error=False)
 
 
 class InsuredInput(BaseModel):
+    """报价时需要的被保险人基本条件。"""
+
     age: int = Field(ge=0, le=120)
     region_code: str = Field(min_length=6, max_length=12)
     occupation_code: str = Field(min_length=1, max_length=32)
 
 
 class CreateQuoteRequest(BaseModel):
+    """创建报价的请求体。"""
+
     product_id: str = Field(min_length=26, max_length=26)
     plan_id: str = Field(min_length=26, max_length=26)
     insured: InsuredInput
@@ -28,15 +38,21 @@ class CreateQuoteRequest(BaseModel):
 
 
 class PersonInput(BaseModel):
+    """投保人或被保险人的实名信息。"""
+
     name: str = Field(min_length=1, max_length=64)
     id_no: str = Field(min_length=6, max_length=32)
 
 
 class ContactInput(BaseModel):
+    """用于接收投保通知的联系方式。"""
+
     mobile: str = Field(min_length=11, max_length=11)
 
 
 class CreateDraftRequest(BaseModel):
+    """保存投保草稿的请求体。"""
+
     quote_id: str = Field(min_length=26, max_length=26)
     applicant: PersonInput
     insured: PersonInput
@@ -44,11 +60,15 @@ class CreateDraftRequest(BaseModel):
 
 
 class CreateOrderRequest(BaseModel):
+    """用户确认后创建订单的请求体。"""
+
     draft_id: str = Field(min_length=26, max_length=26)
     confirmation_token: str = Field(min_length=32, max_length=128)
 
 
 class MockPaymentCallbackRequest(BaseModel):
+    """本地模拟支付协议的数据结构,不代表真实支付渠道。"""
+
     callback_no: str = Field(min_length=1, max_length=64)
     provider_transaction_no: str = Field(min_length=1, max_length=64)
     payment_no: str = Field(min_length=1, max_length=40)
@@ -62,6 +82,8 @@ def create_enrollment_router(
     h5_auth_service: H5AuthService,
     settings: Settings,
 ) -> APIRouter:
+    """创建投保路由,并绑定已装配好的业务服务与认证服务。"""
+
     router = APIRouter(tags=["enrollment"])
 
     def current_h5_user(
@@ -70,6 +92,8 @@ def create_enrollment_router(
             Depends(enrollment_bearer_scheme),
         ],
     ) -> H5User:
+        """FastAPI 依赖:验证 Token 并返回当前 H5 用户。"""
+
         if credentials is None:
             raise AppError("AUTH_REQUIRED", "请先登录", 401)
         return h5_auth_service.authenticate_access(credentials.credentials)
@@ -80,6 +104,8 @@ def create_enrollment_router(
         request: Request,
         user: Annotated[H5User, Depends(current_h5_user)],
     ) -> dict[str, Any]:
+        """创建确定性报价。"""
+
         return success_response(
             request,
             service.create_quote(
@@ -99,6 +125,8 @@ def create_enrollment_router(
         request: Request,
         user: Annotated[H5User, Depends(current_h5_user)],
     ) -> dict[str, Any]:
+        """保存投保资料草稿。"""
+
         return success_response(
             request,
             service.create_draft(
@@ -119,6 +147,8 @@ def create_enrollment_router(
         request: Request,
         user: Annotated[H5User, Depends(current_h5_user)],
     ) -> dict[str, Any]:
+        """确认草稿并返回短时有效的确认令牌。"""
+
         return success_response(request, service.confirm_draft(user, draft_id))
 
     @router.post("/h5/orders", status_code=status.HTTP_201_CREATED)
@@ -132,6 +162,8 @@ def create_enrollment_router(
             Header(alias="Idempotency-Key", min_length=1, max_length=64),
         ],
     ) -> dict[str, Any]:
+        """使用确认令牌创建订单;幂等键由请求头提供。"""
+
         data, created = service.create_order(
             user,
             draft_id=payload.draft_id,
@@ -155,6 +187,8 @@ def create_enrollment_router(
             Header(alias="Idempotency-Key", min_length=1, max_length=64),
         ],
     ) -> dict[str, Any]:
+        """为待支付订单创建本地模拟支付流水。"""
+
         data, created = service.create_payment(
             user,
             order_id=order_id,
@@ -169,6 +203,8 @@ def create_enrollment_router(
         request: Request,
         user: Annotated[H5User, Depends(current_h5_user)],
     ) -> dict[str, Any]:
+        """本地开发入口:完成模拟支付并推进出单流程。"""
+
         if not settings.dev_tools_enabled or settings.app_env == "production":
             raise AppError("DEV_TOOL_DISABLED", "本地模拟支付未启用", 404)
         return success_response(
@@ -185,6 +221,8 @@ def create_enrollment_router(
             Header(alias="X-Mock-Pay-Signature", min_length=1, max_length=128),
         ],
     ) -> dict[str, Any]:
+        """本地模拟协议入口:校验签名后处理支付结果。"""
+
         return success_response(
             request,
             service.handle_mock_callback(payload.model_dump(), signature),
@@ -195,6 +233,8 @@ def create_enrollment_router(
         request: Request,
         user: Annotated[H5User, Depends(current_h5_user)],
     ) -> dict[str, Any]:
+        """查询当前用户自己的电子保单。"""
+
         return success_response(request, service.list_policies(user))
 
     @router.get("/h5/orders")
@@ -202,6 +242,8 @@ def create_enrollment_router(
         request: Request,
         user: Annotated[H5User, Depends(current_h5_user)],
     ) -> dict[str, Any]:
+        """查询当前用户自己的投保订单。"""
+
         return success_response(request, service.list_orders(user.id))
 
     return router

+ 12 - 0
backend/src/zbt/domains/agent/models.py

@@ -1,3 +1,9 @@
+"""Agent 领域对象。
+
+这些 dataclass 描述会话、消息和一次运行的稳定业务字段,
+不包含数据库或 HTTP 细节。
+"""
+
 from dataclasses import dataclass
 from datetime import datetime
 from typing import Any
@@ -5,6 +11,8 @@ from typing import Any
 
 @dataclass(frozen=True)
 class AgentThread:
+    """一段属于某个登录用户、固定使用一种人格的会话。"""
+
     id: str
     owner_type: str
     owner_id: str
@@ -17,6 +25,8 @@ class AgentThread:
 
 @dataclass(frozen=True)
 class AgentMessage:
+    """会话中的一条用户或助手消息。"""
+
     id: str
     thread_id: str
     role: str
@@ -27,6 +37,8 @@ class AgentMessage:
 
 @dataclass(frozen=True)
 class AgentRun:
+    """处理一条用户消息产生的一次 Agent 运行记录。"""
+
     id: str
     request_id: str
     thread_id: str

+ 10 - 0
backend/src/zbt/domains/agent/repository.py

@@ -1,9 +1,17 @@
+"""Agent 数据仓储协议与内存实现。
+
+领域服务只依赖 ``AgentRepository``。真实运行由 MySQL 实现,
+单元测试可以使用本文件的内存实现。
+"""
+
 from typing import Protocol
 
 from zbt.domains.agent.models import AgentMessage, AgentRun, AgentThread
 
 
 class AgentRepository(Protocol):
+    """会话、消息和 Run 持久化所需的最小接口。"""
+
     def save_thread(self, thread: AgentThread) -> None: ...
 
     def get_thread(self, thread_id: str) -> AgentThread | None: ...
@@ -22,6 +30,8 @@ class AgentRepository(Protocol):
 
 
 class InMemoryAgentRepository:
+    """使用 Python 容器保存数据的轻量实现,仅用于测试。"""
+
     def __init__(self) -> None:
         self._threads: dict[str, AgentThread] = {}
         self._messages: dict[str, AgentMessage] = {}

+ 6 - 1
backend/src/zbt/domains/agent/runtime.py

@@ -1,6 +1,7 @@
 """Agent 运行时端口。
 
-实际 LangChain/DeepSeek 实现在 ``app.harness.kernel``,领域服务只依赖此协议。
+会话服务只知道“提交一次调用并得到回复”,不依赖 LangChain 或具体模型。
+真实环境使用 AgentKernel;未配置模型时使用明确失败的占位实现。
 """
 
 from typing import Protocol
@@ -18,10 +19,14 @@ __all__ = [
 
 
 class AgentRuntime(Protocol):
+    """所有 Agent 运行时必须实现的最小接口。"""
+
     def reply(self, invocation: AgentInvocation) -> AgentReply: ...
 
 
 class UnavailableAgentRuntime:
+    """没有配置模型密钥时使用,调用时返回清晰的可重试错误。"""
+
     def reply(self, invocation: AgentInvocation) -> AgentReply:
         del invocation
         raise AppError(

+ 59 - 1
backend/src/zbt/domains/agent/service.py

@@ -1,3 +1,10 @@
+"""Agent 会话应用服务。
+
+这里负责会话、消息、Run 和 SSE 事件的生命周期,不负责模型推理本身。
+一次请求会依次经过:校验归属 -> 限流 -> 保存用户消息 -> 调用 Runtime
+-> 保存助手消息和 Run -> 记录可重放事件。
+"""
+
 from collections.abc import Callable, Iterator
 from dataclasses import dataclass
 from datetime import datetime
@@ -21,6 +28,8 @@ from zbt.infrastructure.redis.agent_state import (
 
 @dataclass(frozen=True)
 class PreparedMessage:
+    """完成身份校验和历史装载后,等待交给 Agent Runtime 的请求快照。"""
+
     thread: AgentThread
     text: str
     run_id: str
@@ -32,7 +41,10 @@ class PreparedMessage:
 
 
 class AgentThreadService:
-    """客户与运营人格共用的会话、消息、运行事件和限流服务。"""
+    """客户与运营人格共用的会话、消息、运行事件和限流服务。
+
+    Router 只处理 HTTP;本类负责真正的应用流程和用户隔离。
+    """
 
     def __init__(
         self,
@@ -41,6 +53,8 @@ class AgentThreadService:
         runtime: AgentRuntime,
         state_store: AgentStateStore | None = None,
     ) -> None:
+        """注入持久化仓储、时钟、Agent Runtime 和运行状态存储。"""
+
         self._repository = repository
         self._clock = clock
         self._runtime = runtime
@@ -52,6 +66,8 @@ class AgentThreadService:
         *,
         title: str | None,
     ) -> dict[str, Any]:
+        """为当前 H5 用户创建客户顾问会话。"""
+
         return self._create_thread(
             owner_type="H5_USER",
             owner_id=user.id,
@@ -65,6 +81,8 @@ class AgentThreadService:
         *,
         title: str | None,
     ) -> dict[str, Any]:
+        """为当前后台账号创建运营分析会话。"""
+
         return self._create_thread(
             owner_type="ADMIN_USER",
             owner_id=user.id,
@@ -79,6 +97,8 @@ class AgentThreadService:
         thread_id: str,
         text: str,
     ) -> dict[str, Any]:
+        """同步执行客户消息,适合不需要逐步展示事件的调用方。"""
+
         prepared = self._prepare_message(
             self._owned_thread("H5_USER", user.id, thread_id),
             text,
@@ -93,6 +113,11 @@ class AgentThreadService:
         thread_id: str,
         text: str,
     ) -> Iterator[RuntimeEvent]:
+        """执行客户消息,并按发生顺序逐条产出 SSE 事件。
+
+        这是生成器函数:每次 ``yield`` 都会立即把一条事件交给 HTTP 响应。
+        """
+
         prepared = self._prepare_message(
             self._owned_thread("H5_USER", user.id, thread_id),
             text,
@@ -154,6 +179,8 @@ class AgentThreadService:
         thread_id: str,
         text: str,
     ) -> dict[str, Any]:
+        """执行运营人员消息;会话归属和工具权限与客户会话完全隔离。"""
+
         prepared = self._prepare_message(
             self._owned_thread("ADMIN_USER", user.id, thread_id),
             text,
@@ -196,6 +223,8 @@ class AgentThreadService:
         *,
         limit: int = 20,
     ) -> dict[str, Any]:
+        """只列出当前后台账号自己会话下的运行记录。"""
+
         threads = self._repository.list_threads("ADMIN_USER", user.id)
         runs = self._repository.list_runs(
             tuple(thread.id for thread in threads),
@@ -226,6 +255,8 @@ class AgentThreadService:
         *,
         run_id: str,
     ) -> list[RuntimeEvent]:
+        """返回已保存事件,用于断线后回放运行过程。"""
+
         self._get_run("H5_USER", user.id, run_id)
         return self._state_store.list_events(run_id)
 
@@ -237,6 +268,8 @@ class AgentThreadService:
         persona: str,
         title: str,
     ) -> dict[str, Any]:
+        """创建并持久化会话,同时在状态存储中登记会话归属。"""
+
         now = self._clock()
         thread = AgentThread(
             id=new_ulid(),
@@ -270,6 +303,8 @@ class AgentThreadService:
         h5_user: H5User | None = None,
         admin_user: AdminUser | None = None,
     ) -> PreparedMessage:
+        """在调用模型前完成身份、限流、历史消息和用户消息持久化。"""
+
         principal = h5_user or admin_user
         if principal is None:
             raise AppError("AGENT_PRINCIPAL_REQUIRED", "Agent缺少登录身份", 401)
@@ -280,6 +315,7 @@ class AgentThreadService:
                 429,
                 retryable=True,
             )
+        # 模型只看到最近 12 条历史,数据库仍保留完整消息。
         history = tuple(
             ConversationTurn(
                 role="assistant" if message.role == "ASSISTANT" else "user",
@@ -289,6 +325,7 @@ class AgentThreadService:
         )
         now = self._clock()
         run_id = new_ulid()
+        # 业务侧展示更友好的服务流水号;Run ID 仍使用全局唯一 ULID。
         service_no = f"ZBT-{now:%Y%m%d}-{randbelow(10_000):04d}"
         self._repository.save_message(
             AgentMessage(
@@ -318,6 +355,8 @@ class AgentThreadService:
         )
 
     def _execute(self, prepared: PreparedMessage) -> dict[str, Any]:
+        """同步执行公共流程,并把关键节点写入事件存储。"""
+
         self._emit(
             prepared,
             "run.started",
@@ -366,6 +405,8 @@ class AgentThreadService:
         return result
 
     def _invoke(self, prepared: PreparedMessage) -> AgentReply:
+        """调用 Agent Runtime,并把未知异常转换为稳定业务错误。"""
+
         try:
             return self._runtime.reply(
                 AgentInvocation(
@@ -391,6 +432,8 @@ class AgentThreadService:
         prepared: PreparedMessage,
         reply: AgentReply,
     ) -> dict[str, Any]:
+        """保存成功回答和 Run,并组装 API 返回结果。"""
+
         assistant_message = {
             "type": "text",
             "text": reply.text,
@@ -447,6 +490,8 @@ class AgentThreadService:
         prepared: PreparedMessage,
         error: AppError,
     ) -> None:
+        """即使模型调用失败也保存 Run,便于运行中心定位问题。"""
+
         self._repository.save_run(
             AgentRun(
                 id=prepared.run_id,
@@ -470,6 +515,8 @@ class AgentThreadService:
         sequence: int,
         data: dict[str, Any],
     ) -> RuntimeEvent:
+        """创建事件、写入状态存储并返回同一个事件对象。"""
+
         runtime_event = RuntimeEvent.model_validate(
             {
                 "event": event,
@@ -483,6 +530,8 @@ class AgentThreadService:
         return runtime_event
 
     def _message_data(self, thread_id: str) -> dict[str, Any]:
+        """把领域消息对象转换为 API 可序列化字典。"""
+
         messages = self._repository.list_messages(thread_id)
         return {
             "items": [
@@ -503,6 +552,8 @@ class AgentThreadService:
         owner_id: str,
         run_id: str,
     ) -> dict[str, Any]:
+        """读取 Run,并先确认其所属会话属于当前用户。"""
+
         run = self._repository.get_run(run_id)
         if run is None:
             raise AppError("AGENT_RUN_NOT_FOUND", "未找到智能体运行", 404)
@@ -521,6 +572,8 @@ class AgentThreadService:
         }
 
     def _resolve_trace_url(self, run: AgentRun) -> str | None:
+        """优先使用已保存地址;没有时再向 Runtime 查询刚同步的 Trace。"""
+
         trace_url = run.output.get("trace_url") if run.output is not None else None
         if not trace_url and run.trace_id:
             resolver = getattr(self._runtime, "trace_url", None)
@@ -535,6 +588,11 @@ class AgentThreadService:
         owner_id: str,
         thread_id: str,
     ) -> AgentThread:
+        """校验会话归属。
+
+        查不到和不属于当前用户都返回同一个 404,避免泄露其他用户会话是否存在。
+        """
+
         thread = self._repository.get_thread(thread_id)
         if thread is None or thread.owner_type != owner_type or thread.owner_id != owner_id:
             raise AppError("AGENT_THREAD_NOT_FOUND", "未找到会话", 404)

+ 47 - 1
backend/src/zbt/domains/agent/tools.py

@@ -1,4 +1,8 @@
-"""智保通第一阶段 Agent 可调用的确定性业务工具。"""
+"""智保通第一阶段 Agent 可调用的确定性业务工具。
+
+工具是模型与真实业务之间的唯一通道。模型负责决定“何时需要什么信息”,
+工具负责参数校验、身份检查、调用领域服务并返回可信的结构化结果。
+"""
 
 from typing import Any, Literal, Protocol
 
@@ -21,12 +25,16 @@ from zbt.harness.tooling import ToolDefinition, ToolExecutionContext, ToolRegist
 
 
 class AttributionMetricsProvider(Protocol):
+    """推广指标查询协议,便于测试时替换实现。"""
+
     def performance(self, admin_user_id: str | None = None) -> dict[str, Any]: ...
 
     def order_ids(self, admin_user_id: str | None = None) -> set[str]: ...
 
 
 class EmptyAttributionMetrics:
+    """未装配推广模块时使用的空实现,保证工具仍可稳定返回。"""
+
     def performance(self, admin_user_id: str | None = None) -> dict[str, Any]:
         del admin_user_id
         return {
@@ -44,6 +52,8 @@ class EmptyAttributionMetrics:
 
 
 class ToolArguments(BaseModel):
+    """所有工具参数的基类;拒绝模型偷偷增加未声明字段。"""
+
     model_config = ConfigDict(extra="forbid")
 
 
@@ -80,6 +90,11 @@ def build_agent_tool_registry(
     enrollment: EnrollmentService,
     attribution: AttributionMetricsProvider | None = None,
 ) -> ToolRegistry:
+    """创建并注册第一阶段全部 Agent 工具。
+
+    嵌套函数是真正的工具处理器;函数末尾的 ``definitions`` 声明哪些人格可见。
+    """
+
     registry = ToolRegistry()
     metrics = attribution or EmptyAttributionMetrics()
 
@@ -87,6 +102,8 @@ def build_agent_tool_registry(
         context: ToolExecutionContext,
         arguments: BaseModel,
     ) -> HarnessToolResult:
+        """查询真实在售产品,推荐产品前必须先调用。"""
+
         del context
         args = _arguments(arguments, ListProductsArgs)
         products = catalog.list_available(category=args.category)
@@ -99,6 +116,8 @@ def build_agent_tool_registry(
         context: ToolExecutionContext,
         arguments: BaseModel,
     ) -> HarnessToolResult:
+        """按业务规则进行资格校验和保费测算,而不是让模型计算金额。"""
+
         args = _arguments(arguments, CalculateQuoteArgs)
         user = context.h5_user
         if user is None:
@@ -108,6 +127,7 @@ def build_agent_tool_registry(
             args.product_code,
             args.plan_code,
         )
+        # 资格不通过是可展示的业务结果;其他错误继续向上抛出。
         try:
             quote = enrollment.create_quote(
                 user,
@@ -176,6 +196,8 @@ def build_agent_tool_registry(
         context: ToolExecutionContext,
         arguments: BaseModel,
     ) -> HarnessToolResult:
+        """生成受控的投保页面入口,不在对话中收集证件等敏感信息。"""
+
         if context.h5_user is None:
             raise AppError("AGENT_CUSTOMER_REQUIRED", "投保引导需要H5用户身份", 401)
         args = _arguments(arguments, PrepareEnrollmentArgs)
@@ -206,6 +228,8 @@ def build_agent_tool_registry(
         context: ToolExecutionContext,
         arguments: BaseModel,
     ) -> HarnessToolResult:
+        """只查询当前 H5 用户自己的订单,并裁剪为安全展示字段。"""
+
         del arguments
         user = context.h5_user
         if user is None:
@@ -230,6 +254,8 @@ def build_agent_tool_registry(
         context: ToolExecutionContext,
         arguments: BaseModel,
     ) -> HarnessToolResult:
+        """只查询当前 H5 用户自己的电子保单。"""
+
         del arguments
         user = context.h5_user
         if user is None:
@@ -254,6 +280,8 @@ def build_agent_tool_registry(
         context: ToolExecutionContext,
         arguments: BaseModel,
     ) -> HarnessToolResult:
+        """计算运营总览;SELF 数据范围只聚合当前运营人员归属的数据。"""
+
         del arguments
         orders = enrollment.list_orders()
         policies = enrollment.list_all_policies()
@@ -285,6 +313,8 @@ def build_agent_tool_registry(
         context: ToolExecutionContext,
         arguments: BaseModel,
     ) -> HarnessToolResult:
+        """按后台账号的数据范围返回近期订单。"""
+
         args = _arguments(arguments, RecentItemsArgs)
         result = enrollment.list_orders()
         if context.admin_user and context.admin_user.data_scope == "SELF":
@@ -312,6 +342,8 @@ def build_agent_tool_registry(
         context: ToolExecutionContext,
         arguments: BaseModel,
     ) -> HarnessToolResult:
+        """按后台账号的数据范围返回近期保单。"""
+
         args = _arguments(arguments, RecentItemsArgs)
         result = enrollment.list_all_policies()
         if context.admin_user and context.admin_user.data_scope == "SELF":
@@ -339,6 +371,8 @@ def build_agent_tool_registry(
         context: ToolExecutionContext,
         arguments: BaseModel,
     ) -> HarnessToolResult:
+        """查询推广访问、线索、归因订单和保费指标。"""
+
         del arguments
         admin = context.admin_user
         if admin is None:
@@ -373,6 +407,8 @@ def build_agent_tool_registry(
             actions=[AgentAction(type="open_attribution", label="查看推广明细")],
         )
 
+    # 工具定义同时声明参数 Schema、自然语言说明、人格范围和副作用级别。
+    # Agent Kernel 只会把当前人格有权使用的定义交给模型。
     definitions = (
         ToolDefinition(
             name="list_available_products",
@@ -460,6 +496,8 @@ def build_agent_tool_registry(
 
 
 def _arguments(value: BaseModel, expected: type[ToolArguments]) -> Any:
+    """确保包装层传入了预期的参数模型。"""
+
     if not isinstance(value, expected):
         raise AppError("AGENT_TOOL_ARGUMENTS_INVALID", "工具参数类型错误", 500)
     return value
@@ -470,6 +508,8 @@ def _resolve_product_plan(
     product_code: str,
     plan_code: str,
 ) -> tuple[dict[str, Any], dict[str, Any]]:
+    """从真实在售目录中解析产品和计划,不接受模型虚构的编码。"""
+
     product = next(
         (item for item in catalog.list_available() if item["product_code"] == product_code),
         None,
@@ -486,6 +526,8 @@ def _resolve_product_plan(
 
 
 def _safe_order_item(item: dict[str, Any]) -> dict[str, Any]:
+    """只保留允许交给模型和前端的订单字段。"""
+
     return {
         "order_id": item["order_id"],
         "order_no": item["order_no"],
@@ -501,6 +543,8 @@ def _safe_order_item(item: dict[str, Any]) -> dict[str, Any]:
 
 
 def _safe_policy_item(item: dict[str, Any]) -> dict[str, Any]:
+    """只保留允许交给模型和前端的保单字段。"""
+
     return {
         "policy_id": item["policy_id"],
         "policy_no": item["policy_no"],
@@ -522,5 +566,7 @@ def _filter_business_result(
     allowed_ids: set[str],
     id_field: str,
 ) -> dict[str, Any]:
+    """按照允许的业务 ID 过滤列表,并重新计算总数。"""
+
     items = [item for item in result["items"] if str(item.get(id_field, "")) in allowed_ids]
     return {"items": items, "total": len(items)}

+ 49 - 0
backend/src/zbt/domains/enrollment/service.py

@@ -1,3 +1,9 @@
+"""投保交易领域服务。
+
+该服务实现“报价 -> 草稿 -> 用户确认 -> 订单 -> 模拟支付 -> 出单”的确定性流程。
+Agent 只能调用其中的查询、报价和投保准备能力,金额和状态变化不能由模型决定。
+"""
+
 import hmac
 import json
 from collections.abc import Callable
@@ -26,10 +32,14 @@ from zbt.domains.identity.models import H5User
 
 
 class OrderAttributionRecorder(Protocol):
+    """订单创建后记录推广归因所需的接口。"""
+
     def record_order(self, order: EnrollmentOrder) -> None: ...
 
 
 class EnrollmentService:
+    """集中实现投保、支付和保单签发规则。"""
+
     def __init__(
         self,
         repository: EnrollmentRepository,
@@ -39,6 +49,8 @@ class EnrollmentService:
         attribution_recorder: OrderAttributionRecorder | None = None,
         task_dispatcher: TaskDispatcher | None = None,
     ) -> None:
+        """注入数据仓储、产品目录、时钟和本地支付配置。"""
+
         self._repository = repository
         self._catalog = catalog_service
         self._clock = clock
@@ -60,6 +72,8 @@ class EnrollmentService:
         occupation_code: str,
         relationship: str,
     ) -> dict[str, Any]:
+        """校验产品与计划,并按固定规则生成 30 分钟有效的报价。"""
+
         product = next(
             (item for item in self._catalog.list_available() if item["product_id"] == product_id),
             None,
@@ -73,6 +87,7 @@ class EnrollmentService:
         if plan is None:
             raise AppError("PLAN_NOT_AVAILABLE", "保障计划当前不可投保", 404)
 
+        # 保费来自确定性规则和产品配置,不调用大模型。
         premium = self._deterministic_premium(
             str(product["product_code"]),
             str(plan["code"]),
@@ -126,6 +141,8 @@ class EnrollmentService:
         insured: dict[str, Any],
         contact: dict[str, Any],
     ) -> dict[str, Any]:
+        """保存投保人、被保险人和联系方式,形成可继续编辑的草稿。"""
+
         quote = self._repository.get_quote(quote_id)
         now = self._clock()
         if (
@@ -155,6 +172,8 @@ class EnrollmentService:
         }
 
     def confirm_draft(self, user: H5User, draft_id: str) -> dict[str, Any]:
+        """生成短时有效的一次性确认令牌,证明用户确认了投保信息。"""
+
         draft = self._repository.get_draft(draft_id)
         now = self._clock()
         if (
@@ -189,6 +208,12 @@ class EnrollmentService:
         confirmation_token: str,
         idempotency_key: str,
     ) -> tuple[dict[str, Any], bool]:
+        """消费确认令牌并创建待支付订单。
+
+        返回值中的布尔值表示是否本次新建。重复幂等键会返回已有订单。
+        """
+
+        # 幂等键防止用户重复点击或网络重试时创建多笔订单。
         existing = self._repository.find_order_by_idempotency(user.id, idempotency_key)
         if existing is not None:
             if existing.draft_id != draft_id:
@@ -251,6 +276,8 @@ class EnrollmentService:
         order_id: str,
         idempotency_key: str,
     ) -> tuple[dict[str, Any], bool]:
+        """为待支付订单创建本地模拟支付流水,并保证重复请求幂等。"""
+
         order = self._repository.get_order(order_id)
         if order is None or order.user_id != user.id:
             raise AppError("ORDER_NOT_FOUND", "未找到订单", 404)
@@ -284,6 +311,11 @@ class EnrollmentService:
         *,
         payment_id: str,
     ) -> dict[str, Any]:
+        """在本地模拟收银台中完成支付。
+
+        这里内部生成一份模拟结果并复用统一处理函数,不连接真实支付渠道。
+        """
+
         payment = self._repository.get_payment(payment_id)
         if payment is None or payment.user_id != user.id:
             raise AppError("PAYMENT_NOT_FOUND", "未找到支付流水", 404)
@@ -304,6 +336,11 @@ class EnrollmentService:
         payload: dict[str, Any],
         signature: str,
     ) -> dict[str, Any]:
+        """处理本地模拟支付结果并触发保单签发。
+
+        这是项目内部的模拟协议,用于展示支付后的状态流转,不代表真实渠道接入。
+        """
+
         expected_signature = self.sign_mock_callback(payload)
         if not hmac.compare_digest(signature, expected_signature):
             raise AppError("INVALID_CALLBACK_SIGNATURE", "支付回调签名无效", 401)
@@ -317,6 +354,7 @@ class EnrollmentService:
         order = self._repository.get_order(payment.order_id)
         if order is None:
             raise AppError("ORDER_NOT_FOUND", "未找到支付对应订单", 404)
+        # 同一订单已经完成时直接返回原结果,保证重复处理不会生成第二张保单。
         existing_policy = self._repository.get_policy_by_order(order.id)
         if payment.status == "SUCCEEDED" and existing_policy is not None:
             return self._payment_completion_data(payment, order, existing_policy)
@@ -332,6 +370,7 @@ class EnrollmentService:
         self._repository.save_payment(succeeded_payment)
         self._repository.save_order(paid_order)
 
+        # 支付成功先落事件和任务,再由任务分发器签发保单。
         event = OutboxEvent(
             id=new_ulid(),
             aggregate_type="PAYMENT",
@@ -362,6 +401,8 @@ class EnrollmentService:
         return self._payment_completion_data(succeeded_payment, issued_order, policy)
 
     def sign_mock_callback(self, payload: dict[str, Any]) -> str:
+        """为本地模拟支付结果计算 HMAC,确保内容没有被改写。"""
+
         canonical = json.dumps(
             payload,
             ensure_ascii=False,
@@ -375,16 +416,22 @@ class EnrollmentService:
         ).hexdigest()
 
     def list_policies(self, user: H5User) -> dict[str, Any]:
+        """查询当前用户自己的保单。"""
+
         policies = self._repository.list_policies(user.id)
         items = [self._policy_data(policy) for policy in policies]
         return {"items": items, "total": len(items)}
 
     def list_all_policies(self) -> dict[str, Any]:
+        """查询全部保单,仅供后台授权接口和运营工具使用。"""
+
         policies = self._repository.list_policies()
         items = [self._policy_data(policy) for policy in policies]
         return {"items": items, "total": len(items)}
 
     def list_orders(self, user_id: str | None = None) -> dict[str, Any]:
+        """按用户查询订单;不传用户 ID 时查询全部订单。"""
+
         orders = self._repository.list_orders(user_id)
         items = [self._order_data(order) for order in orders]
         return {"items": items, "total": len(items)}
@@ -518,6 +565,8 @@ class EnrollmentService:
         configured_min_age: int = 0,
         configured_max_age: int = 100,
     ) -> int:
+        """按产品配置和固定费率因子计算保费,结果不受模型输出影响。"""
+
         if region_code != "510100":
             raise AppError(
                 "ELIGIBILITY_REJECTED",

+ 12 - 1
backend/src/zbt/harness/events.py

@@ -1,4 +1,8 @@
-"""Agent Runtime Event 与 SSE 传输协议。"""
+"""Agent Runtime Event 与 SSE 传输协议。
+
+一次 Agent 运行可能持续数秒。后端通过事件告诉前端当前处于开始、
+工具完成、UI 就绪、运行完成或失败中的哪一步。
+"""
 
 import json
 from datetime import datetime
@@ -8,6 +12,8 @@ from pydantic import BaseModel, ConfigDict, Field
 
 
 class RuntimeEvent(BaseModel):
+    """一条有顺序号、可持久化、可重放的运行事件。"""
+
     model_config = ConfigDict(extra="forbid")
 
     event: Literal[
@@ -23,6 +29,11 @@ class RuntimeEvent(BaseModel):
     data: dict[str, Any] = Field(default_factory=dict)
 
     def to_sse(self) -> str:
+        """按照 SSE 文本协议序列化事件。
+
+        每条事件以空行结尾,浏览器才能把它识别为一个完整消息。
+        """
+
         payload = self.model_dump(mode="json")
         return (
             f"id: {self.run_id}:{self.sequence}\n"

+ 52 - 1
backend/src/zbt/harness/kernel.py

@@ -1,4 +1,15 @@
-"""统一 Agent 内核:同一编排能力承载客户与运营两种人格。"""
+"""统一 Agent 内核。
+
+这个模块负责一次 Agent 调用的完整编排:
+
+1. 根据 ``persona`` 选择客户顾问或运营助手。
+2. 组合该人格的 Prompt、Skills 和工具白名单。
+3. 调用大模型,让模型按需选择工具。
+4. 收集工具产生的结构化卡片和页面操作。
+5. 返回文本、卡片、操作按钮以及 Trace 信息。
+
+它不直接查询数据库,也不直接计算保费;真实业务事实必须通过受控工具获取。
+"""
 
 from dataclasses import dataclass
 from typing import Any, cast
@@ -26,12 +37,20 @@ from zbt.harness.tooling import ToolExecutionContext, ToolRegistry
 
 @dataclass(frozen=True)
 class ConversationTurn:
+    """一轮历史对话;对象创建后不可修改。"""
+
     role: str
     content: str
 
 
 @dataclass(frozen=True)
 class AgentInvocation:
+    """调用 Agent Kernel 所需的全部输入。
+
+    客户端调用时传 ``h5_user``,运营端调用时传 ``admin_user``。
+    当前登录身份会继续传给工具层,用于权限和数据范围检查。
+    """
+
     persona: str
     message: str
     h5_user: H5User | None = None
@@ -40,6 +59,8 @@ class AgentInvocation:
 
 
 class AgentKernel:
+    """把模型、人格、提示词、技能、工具和权限策略装配成一次 Agent 运行。"""
+
     def __init__(
         self,
         *,
@@ -51,6 +72,8 @@ class AgentKernel:
         personas: PersonaRegistry | None = None,
         policy: HarnessPolicyEngine | None = None,
     ) -> None:
+        """保存可替换组件,并按配置决定是否启用 LangSmith Trace。"""
+
         self._settings = settings
         self._model_gateway = model_gateway
         self._tools = tools
@@ -68,8 +91,14 @@ class AgentKernel:
         )
 
     def reply(self, invocation: AgentInvocation) -> AgentReply:
+        """执行一次完整的 Agent 对话并返回前端可直接使用的结果。"""
+
+        # 人格决定使用哪份 Prompt、加载哪些 Skills,以及允许调用哪些工具。
         persona = self._personas.get(invocation.persona)
         system_prompt = self._compose_prompt(persona.prompt_id, persona.skill_ids)
+
+        # 上下文保存当前登录身份和本次已经发生的工具调用。
+        # 每个工具执行前都会读取这里的身份,再经过 Policy 校验。
         context = ToolExecutionContext(
             persona=persona.id,
             h5_user=invocation.h5_user,
@@ -80,6 +109,9 @@ class AgentKernel:
             context=context,
             policy_engine=self._policy,
         )
+
+        # ``create_agent`` 创建“模型思考 -> 调用工具 -> 继续思考”的执行循环。
+        # 工具参数由模型生成,但会先经过 Pydantic 和权限策略校验。
         agent = create_agent(
             model=self._model_gateway.chat_model(persona.id),
             tools=tools,
@@ -87,6 +119,8 @@ class AgentKernel:
             name=f"zhibaotong-{persona.id}",
         )
         trace_id = uuid4()
+
+        # 只携带最近 12 轮,防止会话越长、模型输入无限增长。
         messages = [
             {"role": turn.role, "content": turn.content} for turn in invocation.history[-12:]
         ]
@@ -102,11 +136,14 @@ class AgentKernel:
                 "principal_id": context.principal_id,
             },
         ):
+            # run_id 同时作为本地 Trace ID,便于本地运行记录与 LangSmith 对照。
             run_config: RunnableConfig = {"run_id": trace_id}
             result = agent.invoke(
                 cast(Any, {"messages": messages}),
                 config=run_config,
             )
+        # 模型文本用于阅读;卡片和 Action 来自受控工具。
+        # 前端不需要依赖正则表达式解析模型的自然语言。
         structured = self._structured_output(
             cast(dict[str, Any], result),
             context,
@@ -138,6 +175,8 @@ class AgentKernel:
         prompt_id: str,
         skill_ids: tuple[str, ...],
     ) -> str:
+        """把基础 Prompt 与多个文件化 Skill 合并为最终系统提示词。"""
+
         prompt = self._prompts.get(prompt_id)
         skill_sections = []
         for skill_id in skill_ids:
@@ -150,6 +189,11 @@ class AgentKernel:
         result: dict[str, Any],
         context: ToolExecutionContext,
     ) -> AgentStructuredOutput:
+        """提取最终回答,并识别回答中实际选中的产品编码。
+
+        这里只整理模型输出,不允许模型自由构造产品详情。
+        """
+
         messages = result.get("messages", [])
         final = messages[-1] if messages else None
         text = (
@@ -181,6 +225,8 @@ class AgentKernel:
         context: ToolExecutionContext,
         selected_codes: list[str],
     ) -> ProductRecommendationsBlock | None:
+        """根据模型选中的编码,从工具数据中生成可信的推荐卡片。"""
+
         if not selected_codes:
             return None
         product_items: list[dict[str, Any]] = []
@@ -215,6 +261,8 @@ class AgentKernel:
         return ProductRecommendationsBlock(items=views)
 
     def _trace_url(self, trace_id: UUID) -> str | None:
+        """尝试获取 LangSmith 页面地址;Trace 尚未同步时返回 ``None``。"""
+
         if self._langsmith_client is None:
             return None
         try:
@@ -228,6 +276,8 @@ class AgentKernel:
             return None
 
     def trace_url(self, trace_id: str) -> str | None:
+        """供会话服务在稍后重新查询 Trace 地址。"""
+
         try:
             return self._trace_url(UUID(trace_id))
         except ValueError:
@@ -235,6 +285,7 @@ class AgentKernel:
 
 
 def _deduplicate_actions(actions: list[AgentAction]) -> list[AgentAction]:
+    """按 Action 内容去重,避免多个工具返回相同按钮。"""
     unique: list[AgentAction] = []
     seen: set[tuple[str, str]] = set()
     for action in actions:

+ 12 - 1
backend/src/zbt/harness/model_gateway.py

@@ -1,4 +1,8 @@
-"""模型供应商适配层,Agent Kernel 不直接依赖 DeepSeek SDK 配置。"""
+"""模型供应商适配层。
+
+Agent Kernel 只依赖 ``ModelGateway`` 协议,不关心 API 地址、密钥或具体 SDK。
+如果以后更换文本模型,只需要增加新的网关实现。
+"""
 
 from typing import Protocol
 
@@ -10,6 +14,8 @@ from zbt.core.config import Settings
 
 
 class ModelGateway(Protocol):
+    """文本模型网关必须提供的最小接口。"""
+
     def chat_model(self, persona: str) -> BaseChatModel: ...
 
 
@@ -20,6 +26,11 @@ class DeepSeekModelGateway:
         self._settings = settings
 
     def chat_model(self, persona: str) -> BaseChatModel:
+        """创建 LangChain 能直接调用的聊天模型。
+
+        ``persona`` 当前不改变模型参数,但保留它便于以后按人格选择不同模型。
+        """
+
         del persona
         return ChatOpenAI(
             model=self._settings.deepseek_model,

+ 13 - 1
backend/src/zbt/harness/policy.py

@@ -1,4 +1,7 @@
-"""Tool 调用策略:人格白名单、权限和副作用边界。"""
+"""Tool 调用策略:人格白名单、后台权限和副作用边界。
+
+策略会执行两次:构建工具列表时隐藏无权工具,真正调用前再次强制鉴权。
+"""
 
 from dataclasses import dataclass
 from typing import Literal
@@ -8,12 +11,16 @@ from zbt.core.errors import AppError
 
 @dataclass(frozen=True)
 class ToolPolicy:
+    """每个工具随代码声明的安全规则。"""
+
     personas: tuple[str, ...]
     effect: Literal["read", "draft"]
     required_permissions: tuple[str, ...] = ()
 
 
 class HarnessPolicyEngine:
+    """集中判断某个人格能否使用某个工具。"""
+
     def can_use(
         self,
         *,
@@ -23,10 +30,13 @@ class HarnessPolicyEngine:
         policy: ToolPolicy,
         permissions: tuple[str, ...],
     ) -> bool:
+        """返回工具是否应该对本次模型调用可见。"""
+
         if tool_name not in allowed_by_persona or persona not in policy.personas:
             return False
         if persona == "operation" and policy.effect != "read":
             return False
+        # ``*`` 代表拥有全部权限;否则至少命中一个 required_permissions。
         return not (
             policy.required_permissions
             and "*" not in permissions
@@ -42,6 +52,8 @@ class HarnessPolicyEngine:
         policy: ToolPolicy,
         permissions: tuple[str, ...],
     ) -> None:
+        """在工具实际执行前强制检查;不满足时抛出可识别的业务错误。"""
+
         if tool_name not in allowed_by_persona or persona not in policy.personas:
             raise AppError(
                 "AGENT_TOOL_NOT_ALLOWED",

+ 35 - 1
backend/src/zbt/harness/registries.py

@@ -1,4 +1,13 @@
-"""Prompt、Skill 与 Persona 注册中心。"""
+"""Prompt、Skill 与 Persona 注册中心。
+
+注册中心统一回答三个问题:
+
+- Prompt 文件存放在哪里;
+- 一个 Skill 包含哪些业务方法和可用工具;
+- 一个 Agent 人格可以加载哪些 Prompt、Skills 和工具。
+
+Agent Kernel 只通过 ID 读取配置,不需要关心文件发现和解析细节。
+"""
 
 from dataclasses import dataclass
 from pathlib import Path
@@ -10,6 +19,8 @@ ASSET_ROOT = Path(__file__).resolve().parent / "assets"
 
 @dataclass(frozen=True)
 class SkillDefinition:
+    """从一个 ``SKILL.md`` 文件解析出的技能定义。"""
+
     id: str
     name: str
     personas: tuple[str, ...]
@@ -20,6 +31,8 @@ class SkillDefinition:
 
 @dataclass(frozen=True)
 class PersonaDefinition:
+    """一个 Agent 人格的配置;人格本质上是能力组合,而不是另一个模型。"""
+
     id: str
     name: str
     prompt_id: str
@@ -28,13 +41,18 @@ class PersonaDefinition:
 
 
 class PromptRegistry:
+    """发现并按文件名加载 Markdown 格式的系统提示词。"""
+
     def __init__(self, root: Path | None = None) -> None:
         self._root = root or ASSET_ROOT / "prompts"
+        # 文件名就是 Prompt ID,例如 customer.md 对应 customer。
         self._items = {
             path.stem: path.read_text(encoding="utf-8").strip() for path in self._root.glob("*.md")
         }
 
     def get(self, prompt_id: str) -> str:
+        """读取 Prompt;配置缺失时立即报错,避免 Agent 带着空提示词运行。"""
+
         prompt = self._items.get(prompt_id)
         if prompt is None:
             raise AppError(
@@ -55,6 +73,7 @@ class SkillRegistry:
         self._root = root or ASSET_ROOT / "skills"
         self._items: dict[str, SkillDefinition] = {}
         for path in self._root.glob("*/SKILL.md"):
+            # 每个技能一个目录,目录中使用统一名称 SKILL.md。
             skill = self._parse(path)
             if skill.id in self._items:
                 raise AppError(
@@ -65,6 +84,8 @@ class SkillRegistry:
             self._items[skill.id] = skill
 
     def get(self, skill_id: str) -> SkillDefinition:
+        """按 ID 读取已经解析的 Skill。"""
+
         skill = self._items.get(skill_id)
         if skill is None:
             raise AppError(
@@ -79,6 +100,11 @@ class SkillRegistry:
 
     @staticmethod
     def _parse(path: Path) -> SkillDefinition:
+        """解析 Skill 的元数据和正文指令。
+
+        文件开头两个 ``---`` 之间是元数据,后面的正文会被加入系统提示词。
+        """
+
         raw = path.read_text(encoding="utf-8").strip()
         if not raw.startswith("---\n"):
             raise AppError(
@@ -110,8 +136,11 @@ class SkillRegistry:
 
 
 class PersonaRegistry:
+    """保存第一阶段允许使用的两种 Agent 人格。"""
+
     def __init__(self) -> None:
         self._items = {
+            # 客户人格可以查询和准备投保,但不能访问后台经营数据。
             "customer": PersonaDefinition(
                 id="customer",
                 name="客户保障顾问",
@@ -125,6 +154,7 @@ class PersonaRegistry:
                     "list_my_policies",
                 ),
             ),
+            # 运营人格使用只读分析工具,不执行投保交易。
             "operation": PersonaDefinition(
                 id="operation",
                 name="运营数据助手",
@@ -141,6 +171,8 @@ class PersonaRegistry:
         }
 
     def get(self, persona_id: str) -> PersonaDefinition:
+        """查询人格配置;不允许临时使用未注册人格。"""
+
         persona = self._items.get(persona_id)
         if persona is None:
             raise AppError(
@@ -152,4 +184,6 @@ class PersonaRegistry:
 
 
 def _csv(value: str) -> list[str]:
+    """把逗号分隔的元数据转换为去除空白的列表。"""
+
     return [item.strip() for item in value.split(",") if item.strip()]

+ 27 - 2
backend/src/zbt/harness/schemas.py

@@ -1,4 +1,8 @@
-"""Agent 与双端前端之间的稳定结构化协议。"""
+"""Agent、工具和前端之间的稳定结构化协议。
+
+自然语言可以变化,但前端卡片、按钮和业务字段必须遵守固定 Schema。
+这样前端无需从模型文本中猜测产品、金额或下一步操作。
+"""
 
 from dataclasses import dataclass, field
 from typing import Annotated, Any, Literal
@@ -13,6 +17,8 @@ class StrictModel(BaseModel):
 
 
 class ProductPlanView(StrictModel):
+    """推荐卡片中的保障计划视图。"""
+
     id: str
     code: str
     name: str
@@ -24,6 +30,8 @@ class ProductPlanView(StrictModel):
 
 
 class ProductView(StrictModel):
+    """推荐卡片中的产品及其可选计划。"""
+
     product_id: str
     product_code: str
     name: str
@@ -34,6 +42,8 @@ class ProductView(StrictModel):
 
 
 class ProductRecommendationsBlock(StrictModel):
+    """前端“产品推荐”区块,第一阶段最多展示两个产品。"""
+
     type: Literal["product_recommendations"] = "product_recommendations"
     version: Literal["1.0"] = "1.0"
     title: str = "为你匹配的保障方案"
@@ -41,6 +51,8 @@ class ProductRecommendationsBlock(StrictModel):
 
 
 class QuoteView(StrictModel):
+    """一次确定性保费试算的展示字段。"""
+
     eligible: bool
     quote_id: str | None = None
     product_id: str | None = None
@@ -54,6 +66,8 @@ class QuoteView(StrictModel):
 
 
 class QuoteBlock(StrictModel):
+    """前端“报价结果”区块。"""
+
     type: Literal["quote"] = "quote"
     version: Literal["1.0"] = "1.0"
     title: str = "保费测算结果"
@@ -61,6 +75,8 @@ class QuoteBlock(StrictModel):
 
 
 class BusinessListBlock(StrictModel):
+    """订单、保单、产品或推广数据的通用列表区块。"""
+
     type: Literal["business_list"] = "business_list"
     version: Literal["1.0"] = "1.0"
     title: str
@@ -70,6 +86,8 @@ class BusinessListBlock(StrictModel):
 
 
 class MetricItem(StrictModel):
+    """运营指标中的一个数值。"""
+
     label: str
     value: int | float | str
     unit: str = ""
@@ -77,12 +95,15 @@ class MetricItem(StrictModel):
 
 
 class MetricsBlock(StrictModel):
+    """运营 Agent 返回的一组指标卡片。"""
+
     type: Literal["metrics"] = "metrics"
     version: Literal["1.0"] = "1.0"
     title: str
     items: list[MetricItem]
 
 
+# ``type`` 是联合类型的判别字段,Pydantic 会据此选择正确的区块模型。
 UiBlock = Annotated[
     ProductRecommendationsBlock | QuoteBlock | BusinessListBlock | MetricsBlock,
     Field(discriminator="type"),
@@ -115,7 +136,11 @@ class AgentStructuredOutput(StrictModel):
 
 
 class HarnessToolResult(StrictModel):
-    """单个 Tool 的统一结果包络。"""
+    """单个 Tool 的统一结果包络。
+
+    ``summary`` 给模型阅读,``data`` 保存业务数据,
+    ``blocks`` 和 ``actions`` 直接供前端渲染。
+    """
 
     summary: str
     data: dict[str, Any] = Field(default_factory=dict)

+ 29 - 1
backend/src/zbt/harness/tooling.py

@@ -1,4 +1,8 @@
-"""Tool 注册、上下文注入与执行审计。"""
+"""Tool 注册、上下文注入与执行审计。
+
+模型看到的是 LangChain Tool,但真实业务函数还需要登录身份、权限和执行记录。
+这个模块负责把普通 Python 函数包装成模型可调用、系统可审计的受控工具。
+"""
 
 from collections.abc import Callable
 from dataclasses import dataclass, field
@@ -17,6 +21,8 @@ ToolHandler = Callable[["ToolExecutionContext", BaseModel], HarnessToolResult]
 
 @dataclass(frozen=True)
 class ToolDefinition:
+    """一个工具的静态说明:名称、参数、权限策略和真正的处理函数。"""
+
     name: str
     description: str
     arguments: type[BaseModel]
@@ -26,12 +32,19 @@ class ToolDefinition:
 
 @dataclass(frozen=True)
 class ToolExecution:
+    """一次已经完成的工具调用,用于生成卡片、Action 和运行记录。"""
+
     name: str
     result: HarnessToolResult
 
 
 @dataclass
 class ToolExecutionContext:
+    """一次 Agent 运行期间共享的工具上下文。
+
+    模型无法修改这里的登录用户;它只能生成工具参数。
+    """
+
     persona: str
     h5_user: H5User | None = None
     admin_user: AdminUser | None = None
@@ -39,6 +52,8 @@ class ToolExecutionContext:
 
     @property
     def principal_id(self) -> str:
+        """返回当前调用者 ID;没有登录身份时拒绝继续执行。"""
+
         principal = self.h5_user or self.admin_user
         if principal is None:
             raise AppError("AGENT_PRINCIPAL_REQUIRED", "Agent缺少登录身份", 401)
@@ -50,10 +65,14 @@ class ToolExecutionContext:
 
 
 class ToolRegistry:
+    """保存所有工具定义,并按人格白名单构建本次可见的工具列表。"""
+
     def __init__(self) -> None:
         self._items: dict[str, ToolDefinition] = {}
 
     def register(self, definition: ToolDefinition) -> None:
+        """注册一个工具;名称重复通常意味着装配错误,因此直接报错。"""
+
         if definition.name in self._items:
             raise AppError(
                 "HARNESS_TOOL_DUPLICATED",
@@ -72,6 +91,8 @@ class ToolRegistry:
         context: ToolExecutionContext,
         policy_engine: HarnessPolicyEngine,
     ) -> list[BaseTool]:
+        """把本次人格有权使用的工具转换为 LangChain Tool。"""
+
         tools: list[BaseTool] = []
         for name in names:
             definition = self._items.get(name)
@@ -81,6 +102,7 @@ class ToolRegistry:
                     f"未找到Tool:{name}",
                     500,
                 )
+            # 构建阶段先过滤一次,模型根本看不到无权使用的工具。
             if not policy_engine.can_use(
                 persona=context.persona,
                 allowed_by_persona=names,
@@ -106,7 +128,10 @@ class ToolRegistry:
         context: ToolExecutionContext,
         policy_engine: HarnessPolicyEngine,
     ) -> BaseTool:
+        """创建真正交给模型的工具包装器。"""
+
         def invoke(**kwargs: Any) -> str:
+            # 执行前再次鉴权,防止绕过“工具可见性”直接调用处理函数。
             policy_engine.authorize(
                 persona=context.persona,
                 allowed_by_persona=allowed_names,
@@ -114,8 +139,11 @@ class ToolRegistry:
                 policy=definition.policy,
                 permissions=context.permissions,
             )
+            # 模型生成的字典是不可信输入,必须先按 Pydantic Schema 校验。
             arguments = definition.arguments.model_validate(kwargs)
             result = definition.handler(context, arguments)
+
+            # 保存执行结果,Kernel 稍后会从中收集卡片、按钮和工具名称。
             context.executions.append(ToolExecution(name=definition.name, result=result))
             return result.model_dump_json(exclude_none=True)
 

+ 15 - 0
frontend/apps/admin/src/App.vue

@@ -1,6 +1,8 @@
 <script setup lang="ts">
 import { computed, onMounted, ref } from "vue";
 
+// ---------- 后端接口返回的数据结构 ----------
+// 将接口字段写成类型后,模板和函数使用错误字段时会在构建阶段被发现。
 type Profile = {
   id: string;
   username: string;
@@ -224,6 +226,8 @@ type ViewKey =
   | "users"
   | "permissions";
 
+// ---------- 页面响应式状态 ----------
+// 本项目将多个后台模块放在同一个演示入口中,activeView 决定当前显示哪个模块。
 const apiBase =
   import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8000/api/v1";
 const username = ref("");
@@ -308,6 +312,8 @@ const operationLoading = ref(false);
 const agentRuns = ref<AgentRunView[]>([]);
 const agentWorkspaceTab = ref<"analysis" | "runs">("analysis");
 
+// ---------- 派生数据 ----------
+// computed() 根据已有状态计算展示值,本身不保存第二份业务数据。
 const currentViewLabel = computed(
   () =>
     ({
@@ -669,6 +675,8 @@ function agentToolLabel(tool: string) {
   );
 }
 
+// ---------- 运营 Agent ----------
+// 运营人格只能使用后台只读工具;返回的卡片直接按结构化协议渲染。
 async function sendOperationMessage() {
   const text = operationInput.value.trim();
   if (!text || operationLoading.value) return;
@@ -731,6 +739,7 @@ function executeOperationAction(action: AgentAction) {
   if (action.type === "open_attribution") selectView("attribution");
 }
 
+// ---------- 用户、角色与权限维护 ----------
 function openOrder(item: Order) {
   selectedOrder.value = item;
 }
@@ -890,6 +899,8 @@ async function saveRole() {
   }
 }
 
+// ---------- HTTP 请求与登录态 ----------
+// authorizedFetch 统一添加 Token,并在访问令牌过期时尝试刷新。
 async function apiFetch(path: string, init: RequestInit = {}) {
   return fetch(`${apiBase}${path}`, {
     ...init,
@@ -1007,6 +1018,7 @@ async function loadAgentRuns() {
   agentRuns.value = (await response.json()).data.items;
 }
 
+// ---------- 用户数据加载 ----------
 async function loadManagedUsers() {
   const response = await authorizedFetch("/admin/users");
   if (!response.ok) return;
@@ -1015,6 +1027,8 @@ async function loadManagedUsers() {
   customers.value = data.customers;
 }
 
+// ---------- 产品管理 ----------
+// 产品、版本和保障计划分层维护,避免直接修改已发布版本的历史数据。
 async function loadManagedProducts() {
   const response = await authorizedFetch("/admin/products");
   if (!response.ok) return;
@@ -1388,6 +1402,7 @@ async function logout() {
   }
 }
 
+// 页面初始化时尝试恢复登录态;成功后再加载当前账号有权查看的数据。
 onMounted(async () => {
   try {
     if (await refreshAccessToken()) {

+ 16 - 32
frontend/apps/h5/src/App.vue

@@ -1,6 +1,8 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
 
+// ---------- 后端接口返回的数据结构 ----------
+// TypeScript 类型只在开发和构建时检查,不会出现在浏览器运行结果中。
 type Product = {
   product_id: string;
   product_version_id: string;
@@ -81,6 +83,8 @@ type Policy = {
   issued_at: string;
 };
 
+// ---------- 页面响应式状态 ----------
+// ref() 包装的值变化后,Vue 会自动更新模板中使用它的区域。
 const apiBase =
   import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8000/api/v1";
 const mobile = ref("");
@@ -144,6 +148,8 @@ const displayedProducts = computed(() => {
   ];
 });
 
+// ---------- 纯展示辅助函数 ----------
+// 这些函数只做格式化和安全展示,不发起请求,也不修改后端业务状态。
 function money(cents: number) {
   return `¥${(cents / 100).toFixed(2)}`;
 }
@@ -157,6 +163,8 @@ function escapeHtml(value: string) {
     .replaceAll("'", "&#039;");
 }
 
+// Agent 返回 Markdown 文本;渲染前先转义 HTML,再转换允许的少量 Markdown 语法。
+// 这样既保留排版,也避免模型回复直接注入任意 HTML。
 function renderInlineMarkdown(value: string) {
   return escapeHtml(value)
     .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
@@ -257,14 +265,6 @@ function dateTime(value: string) {
   }).format(new Date(value));
 }
 
-function orderStatus(status: string) {
-  return {
-    PENDING_PAYMENT: "待支付",
-    ISSUED: "已承保",
-    CANCELLED: "已取消",
-  }[status] ?? status;
-}
-
 function policyStatus(status: string) {
   return {
     ACTIVE: "保障中",
@@ -332,6 +332,8 @@ function validateMobile() {
   return true;
 }
 
+// ---------- HTTP 请求与登录态 ----------
+// authorizedFetch 会自动附带 Token;遇到登录过期时只尝试刷新一次。
 async function apiFetch(path: string, init: RequestInit = {}) {
   return fetch(`${apiBase}${path}`, {
     ...init,
@@ -465,6 +467,8 @@ async function loadProducts(allowRefresh = true) {
   products.value = body.data.items;
 }
 
+// ---------- 客户保障顾问 Agent ----------
+// 先创建/复用会话,再通过 SSE 接收运行开始、工具完成、UI 就绪和最终回答。
 async function sendAgentMessage() {
   const text = agentInput.value.trim();
   if (!text || agentLoading.value) return;
@@ -560,6 +564,7 @@ async function consumeAgentStream(response: Response) {
   return completed;
 }
 
+// Action 不是模型生成的任意链接,而是后端 Harness 返回的受控动作。
 async function executeAgentAction(
   action: AgentAction,
   context?: ChatMessage["enrollmentContext"],
@@ -653,6 +658,8 @@ function resetEnrollmentFlow() {
   policy.value = null;
 }
 
+// ---------- 确定性投保流程 ----------
+// 报价、草稿、确认、订单、支付和出单都调用普通业务接口,不由模型直接改状态。
 async function setEnrollmentStep(step: number) {
   enrollmentStep.value = step;
   await nextTick();
@@ -843,14 +850,6 @@ async function closeEnrollment() {
   await loadBusiness();
 }
 
-async function resumePayment() {
-  if (!order.value) return;
-  preQuoteConfirming.value = false;
-  enrollmentOpen.value = true;
-  await setEnrollmentStep(4);
-  if (!payment.value) await startPayment();
-}
-
 async function viewCoverageAfterPayment() {
   enrollmentOpen.value = false;
   activeTab.value = "coverage";
@@ -933,6 +932,7 @@ async function logout() {
   }
 }
 
+// 页面初始化时恢复登录态并加载产品、订单和保单;组件销毁时清理验证码计时器。
 onMounted(async () => {
   createImageCode();
   // 清理早期版本遗留的持久化Access Token,当前版本只在内存中保存。
@@ -1271,22 +1271,6 @@ onBeforeUnmount(() => {
             </article>
           </div>
 
-          <div v-if="order" class="order-summary">
-            <div class="summary-heading"><h3>最近订单</h3><span>{{ orderStatus(order.status) }}</span></div>
-            <dl>
-              <div><dt>订单编号</dt><dd>{{ order.order_no }}</dd></div>
-              <div><dt>订单金额</dt><dd>{{ money(order.amount_cents) }}</dd></div>
-            </dl>
-            <button
-              v-if="order.status === 'PENDING_PAYMENT'"
-              class="primary-action"
-              :disabled="businessLoading"
-              @click="resumePayment"
-            >
-              继续支付
-            </button>
-          </div>
-
           <div v-if="!order && policies.length === 0" class="coverage-empty">
             <span class="empty-shield">◇</span>
             <h3>还没有保障记录</h3>

+ 2 - 16
frontend/apps/h5/src/style.css

@@ -1011,22 +1011,8 @@ h3 { margin: 7px 0; font-size: 20px; font-weight: 580; }
   border: 1px solid #ffffff30;
   font-size: 11px;
 }
-.order-summary {
-  margin-top: 15px;
-  padding: 18px;
-  background: #ffffff;
-  border: 1px solid var(--line);
-}
-.summary-heading { display: flex; align-items: center; justify-content: space-between; }
-.summary-heading h3 { font-size: 16px; }
-.summary-heading span {
-  padding: 5px 8px;
-  color: var(--blue);
-  background: #edf5ff;
-  font-size: 10px;
-}
-.order-summary dl, .confirm-list { margin: 12px 0; }
-.order-summary dl div, .confirm-list div {
+.confirm-list { margin: 12px 0; }
+.confirm-list div {
   display: flex;
   justify-content: space-between;
   gap: 12px;