1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富 统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」; 同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。 2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本), 新增《文档规整方案与开发前待决事项-2026-09-17》。 3) 客服agent 四份交付文档首次纳入本分支。
302 lines
15 KiB
Python
302 lines
15 KiB
Python
"""示例业务 Agent(fund_query_demo)的端到端接入验证脚本。
|
||
|
||
用途:机器化复现 docs/19 的接入步骤——发布配置 → 真实 JWT 受理 → Worker 执行 →
|
||
核对工具调用与审计 → **反证**(撤掉工具白名单后工具必须被拒)→ 恢复配置 → 清理测试数据。
|
||
其中意图配置会走完 `draft → approved → active`,因为运行期只读 `status='active'`。
|
||
|
||
用法(在仓库根目录、用项目解释器执行):
|
||
|
||
python tools/demo_agent_e2e.py # 全流程并清理 run 数据
|
||
python tools/demo_agent_e2e.py --keep-run # 保留 run/审计数据便于排查
|
||
|
||
前置:数据库与配置中心已就绪;JWT 私钥存在(配置项 `JWT_PRIVATE_KEY_PATH`,默认
|
||
`config/jwt/dev/jwt-private.pem`,没有就先跑 `tools/generate_jwt_keys.py`);
|
||
**运行前请停掉常驻 Worker**,否则它会抢走本次 run 并使用同一队列的执行路径。
|
||
|
||
复核方式:配置发布保留"提交审核 → 审核 → 激活"状态机,但**不再要求审核人不是创建人**,
|
||
因此 9003 一个 admin 身份即可完成发布,无需再临时创建第二个复核身份。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import datetime as dt
|
||
import json
|
||
import sys
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import jwt
|
||
from sqlalchemy import delete, select, text
|
||
|
||
from app.core.config import get_settings
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.main import create_app
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.conversation import ConversationMessage
|
||
from app.model.platform import AgentRun, DomainEventOutbox, OutboxDelivery, RequestIdempotency
|
||
from app.worker.runtime import WorkerRuntime
|
||
|
||
# 密钥路径统一从配置读(.env 的 JWT_PRIVATE_KEY_PATH),换密钥只改配置,不用改脚本。
|
||
PRIVATE_KEY = Path(get_settings().jwt_private_key_path).read_text(encoding="utf-8")
|
||
ADMIN = "9003"
|
||
CUSTOMER = "9001"
|
||
AGENT_TYPE = "fund_query_demo"
|
||
INTENT = "fund_quote"
|
||
TOOL = "query_fund_quote"
|
||
MESSAGE = "帮我看下 159382 这只场内基金的行情"
|
||
|
||
_results: list[tuple[str, str, str]] = []
|
||
|
||
|
||
def record(name: str, expected: str, actual: str) -> None:
|
||
_results.append(("PASS" if expected == actual else "FAIL", name, f"期望 {expected} / 实际 {actual}"))
|
||
|
||
|
||
def token(sub: str) -> str:
|
||
now = dt.datetime.now(dt.UTC)
|
||
return jwt.encode(
|
||
{
|
||
"sub": sub, "iss": "jr-local", "aud": "jr-agent-platform",
|
||
"exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5),
|
||
"jti": str(uuid.uuid4()),
|
||
},
|
||
PRIVATE_KEY, algorithm="RS256",
|
||
)
|
||
|
||
|
||
def idempotency_key() -> str:
|
||
return uuid.uuid4().hex
|
||
|
||
|
||
async def etag_of(client: httpx.AsyncClient, release_id: int, auth: dict[str, str]) -> str:
|
||
response = await client.get(f"/api/v1/admin/config-releases/{release_id}", headers=auth)
|
||
if response.status_code != 200:
|
||
raise SystemExit(f"读取发布版本失败:{response.status_code} {response.text}")
|
||
etag = response.headers.get("ETag")
|
||
if not etag:
|
||
raise SystemExit("发布版本缺少 ETag")
|
||
return etag
|
||
|
||
|
||
async def post(client: httpx.AsyncClient, path: str, *, auth: dict[str, str],
|
||
payload: dict[str, Any] | None = None, if_match: str | None = None) -> httpx.Response:
|
||
headers = {**auth, "Idempotency-Key": idempotency_key()}
|
||
if if_match is not None:
|
||
headers["If-Match"] = if_match
|
||
return await client.post(path, json=payload if payload is not None else {}, headers=headers)
|
||
|
||
|
||
async def activate_release(
|
||
client: httpx.AsyncClient, admin_auth: dict[str, str], *, with_whitelist: bool,
|
||
) -> int:
|
||
"""走管理 API 完成一次完整发布:建版本 → 写配置 → 提交复核 → 自审 → 激活。
|
||
|
||
审核仍走 `reviews` 状态机节点,但审核人就是创建人(已取消双人复核约束)。
|
||
"""
|
||
created = await post(client, "/api/v1/admin/config-releases", auth=admin_auth, payload={
|
||
"release_no": f"demo-fund-{uuid.uuid4().hex[:12]}",
|
||
"title": f"示例 Agent {AGENT_TYPE} 接入配置",
|
||
"change_summary": "为示例 Agent 发布意图工具白名单",
|
||
})
|
||
if created.status_code != 201:
|
||
raise SystemExit(f"创建发布版本失败:{created.status_code} {created.text}")
|
||
release_id = int(created.json()["data"]["id"])
|
||
|
||
if with_whitelist:
|
||
item = await post(client, f"/api/v1/admin/config-releases/{release_id}/platform-config-items",
|
||
auth=admin_auth, payload={
|
||
"namespace": "agent_tools",
|
||
"item_key": f"{AGENT_TYPE}:{INTENT}",
|
||
"value_json": {"allowed_tools": [TOOL]},
|
||
"schema_version": "1",
|
||
})
|
||
if item.status_code != 201:
|
||
raise SystemExit(f"写入工具白名单失败:{item.status_code} {item.text}")
|
||
await ensure_intent_config(client, admin_auth)
|
||
|
||
submitted = await post(client, f"/api/v1/admin/config-releases/{release_id}/validations",
|
||
auth=admin_auth, if_match=await etag_of(client, release_id, admin_auth))
|
||
reviewed = await post(client, f"/api/v1/admin/config-releases/{release_id}/reviews",
|
||
auth=admin_auth, payload={"decision": "approved", "comment": "创建人自审"},
|
||
if_match=await etag_of(client, release_id, admin_auth))
|
||
activated = await post(client, f"/api/v1/admin/config-releases/{release_id}/activations",
|
||
auth=admin_auth, if_match=await etag_of(client, release_id, admin_auth))
|
||
print(f"[发布配置] release={release_id} 白名单={'有' if with_whitelist else '无'} "
|
||
f"submit={submitted.status_code} review={reviewed.status_code} "
|
||
f"activate={activated.status_code} status={activated.json()['data']['status']}")
|
||
return release_id
|
||
|
||
|
||
async def etag_of_row(
|
||
client: httpx.AsyncClient, path: str, row_id: int, auth: dict[str, str]
|
||
) -> str:
|
||
response = await client.get(f"{path}/{row_id}", headers=auth)
|
||
if response.status_code != 200:
|
||
raise SystemExit(f"读取资源失败:{response.status_code} {response.text}")
|
||
etag = response.headers.get("ETag")
|
||
if not etag:
|
||
raise SystemExit("资源缺少 ETag")
|
||
return etag
|
||
|
||
|
||
async def ensure_intent_config(client: httpx.AsyncClient, admin_auth: dict[str, str]) -> None:
|
||
"""意图配置属于交付物的一部分:不存在则创建,然后走"审核 → 生效"状态流转。
|
||
|
||
运行期只读 `status='active'` 的意图配置(`RuntimeConfigService.active_intents`),
|
||
因此这里必须把 `draft` 推成 `active`,否则配置只是存了一张表、分类链路看不到。
|
||
"""
|
||
path = "/api/v1/admin/agent-intent-configs"
|
||
listed = await client.get(f"{path}?limit=100", headers=admin_auth)
|
||
rows = listed.json().get("data", []) if listed.status_code == 200 else []
|
||
existing = next(
|
||
(row for row in rows
|
||
if row.get("agent_type") == AGENT_TYPE and row.get("intent_code") == INTENT),
|
||
None,
|
||
)
|
||
status = str(existing.get("status")) if existing else ""
|
||
if status == "active":
|
||
print(f"[意图配置] id={existing['id']} 已生效,跳过")
|
||
return
|
||
if existing is not None and status in {"draft", "approved"}:
|
||
config_id = int(existing["id"])
|
||
else:
|
||
# 没有历史行或历史行已归档:新版本号(该表 agent_type+intent_code+version 唯一)。
|
||
version = int(existing.get("version", 0)) + 1 if existing else 1
|
||
created = await post(client, path, auth=admin_auth, payload={
|
||
"agent_type": AGENT_TYPE, "intent_code": INTENT, "intent_name": "场内基金行情查询",
|
||
"description": "查询场内基金最新净值与涨跌信息",
|
||
"examples": ["帮我看看 159382 的行情", "查一下这只基金净值"],
|
||
"confidence_threshold": "0.6000", "allowed_tools": [TOOL], "version": version,
|
||
})
|
||
if created.status_code != 201:
|
||
raise SystemExit(f"创建意图配置失败:{created.status_code} {created.text}")
|
||
config_id = int(created.json()["data"]["id"])
|
||
for action, payload in (
|
||
("reviews", {"decision": "approved", "comment": "创建人自审"}),
|
||
("activations", None),
|
||
):
|
||
response = await post(
|
||
client, f"{path}/{config_id}/{action}", auth=admin_auth, payload=payload,
|
||
if_match=await etag_of_row(client, path, config_id, admin_auth),
|
||
)
|
||
if response.status_code != 200:
|
||
raise SystemExit(f"意图配置 {action} 失败:{response.status_code} {response.text}")
|
||
status = str(response.json()["data"]["status"])
|
||
print(f"[意图配置] id={config_id} 已生效(status={status},运行期按 status='active' 读取)")
|
||
|
||
|
||
async def audit_rows(trace_id: str) -> list[dict[str, Any]]:
|
||
async with SessionFactory() as session:
|
||
rows = (await session.execute(text(
|
||
"SELECT id, action_type, detail FROM interaction_audit"
|
||
" WHERE JSON_UNQUOTE(JSON_EXTRACT(detail,'$.trace_id'))=:trace ORDER BY id"),
|
||
{"trace": trace_id})).mappings().all()
|
||
audits: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
detail = row["detail"]
|
||
if isinstance(detail, str):
|
||
detail = json.loads(detail)
|
||
audits.append({"id": row["id"], "action_type": row["action_type"], "detail": detail})
|
||
return audits
|
||
|
||
|
||
async def run_case(client: httpx.AsyncClient, *, label: str) -> tuple[str, str, dict[str, Any]]:
|
||
"""受理一次真实 run 并手动执行,返回(run_id, session_id, 证据字典)。"""
|
||
session_id = f"demo-fund-{uuid.uuid4()}"
|
||
auth = {"Authorization": f"Bearer {token(CUSTOMER)}"}
|
||
accepted = await client.post("/api/v1/agent-runs", json={
|
||
"agent_type": AGENT_TYPE, "message": MESSAGE, "session_id": session_id,
|
||
"idempotency_key": idempotency_key(),
|
||
}, headers=auth)
|
||
record(f"{label}:受理返回 202", "202", str(accepted.status_code))
|
||
if accepted.status_code != 202:
|
||
raise SystemExit(f"{label} 受理失败:{accepted.text}")
|
||
# 文档 §3.3/§6.2:受理响应也是 {data, meta} 信封。
|
||
run_id = str(accepted.json()["data"]["run_id"])
|
||
trace_id = str(accepted.json()["data"]["trace_id"])
|
||
|
||
await WorkerRuntime().execute(run_id)
|
||
# 文档 §3.3/§6.3:查询运行是 {data, meta} 信封,业务字段在 data 里。
|
||
body = (await client.get(f"/api/v1/agent-runs/{run_id}", headers=auth)).json()
|
||
detail: dict[str, Any] = body.get("data") or {}
|
||
core: dict[str, Any] = detail.get("result") or {}
|
||
rows = await audit_rows(trace_id)
|
||
tool_rows = [row for row in rows if row["action_type"] == "agent.tool_executed"]
|
||
print(f"[{label}] run={run_id} status={detail.get('status')} "
|
||
f"error_code={detail.get('error_code')}")
|
||
print(f" 工具调用={core.get('tool_calls')} 来源引用={core.get('source_references')}")
|
||
print(f" 审计 {len(rows)} 条:{[(row['action_type'], row['detail'].get('status')) for row in rows]}")
|
||
return run_id, session_id, {"detail": detail, "tool_rows": tool_rows, "core": core}
|
||
|
||
|
||
async def cleanup_run(session_id: str, run_id: str) -> None:
|
||
async with SessionFactory() as session, session.begin():
|
||
event_ids = select(DomainEventOutbox.event_id).where(
|
||
DomainEventOutbox.aggregate_id == run_id)
|
||
await session.execute(delete(OutboxDelivery).where(
|
||
OutboxDelivery.event_id.in_(event_ids)))
|
||
await session.execute(delete(DomainEventOutbox).where(
|
||
DomainEventOutbox.aggregate_id == run_id))
|
||
await session.execute(delete(InteractionAudit).where(
|
||
InteractionAudit.session_id == session_id))
|
||
await session.execute(delete(AgentRun).where(AgentRun.session_id == session_id))
|
||
await session.execute(delete(RequestIdempotency).where(
|
||
RequestIdempotency.session_id == session_id))
|
||
await session.execute(delete(ConversationMessage).where(
|
||
ConversationMessage.session_id == session_id))
|
||
print(f"[清理] 已删除 session={session_id} run={run_id} 及其消息/事件/审计/幂等回执")
|
||
|
||
|
||
async def main() -> None:
|
||
keep_run = "--keep-run" in sys.argv
|
||
app = create_app()
|
||
admin_auth = {"Authorization": f"Bearer {token(ADMIN)}"}
|
||
async with httpx.AsyncClient(
|
||
transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=60
|
||
) as client:
|
||
print("== 1. 发布并激活带工具白名单的版本 ==")
|
||
await activate_release(client, admin_auth, with_whitelist=True)
|
||
|
||
print("\n== 2. 正常路径:工具白名单生效 ==")
|
||
run_id, session_id, evidence = await run_case(client, label="正常路径")
|
||
detail, core = evidence["detail"], evidence["core"]
|
||
record("正常路径:run 终态", "succeeded", str(detail.get("status")))
|
||
calls = (core.get("tool_calls") or {}).get("calls") or []
|
||
record("正常路径:工具调用记录", "query_fund_quote/succeeded",
|
||
f"{calls[0].get('tool_name')}/{calls[0].get('status')}" if calls else "无")
|
||
references = core.get("source_references") or []
|
||
record("正常路径:来源引用", "tool", str(references[0].get("source_type")) if references else "无")
|
||
record("正常路径:工具审计", "succeeded",
|
||
str(evidence["tool_rows"][0]["detail"].get("status")) if evidence["tool_rows"] else "无")
|
||
if not keep_run:
|
||
await cleanup_run(session_id, run_id)
|
||
|
||
print("\n== 3. 反证:撤掉工具白名单后必须失败关闭 ==")
|
||
await activate_release(client, admin_auth, with_whitelist=False)
|
||
denied_run, denied_session, denied = await run_case(client, label="反证空白名单")
|
||
record("反证:run 终态", "failed", str(denied["detail"].get("status")))
|
||
record("反证:错误码", "AGENT_PERMISSION_DENIED", str(denied["detail"].get("error_code")))
|
||
record("反证:工具审计为 denied", "denied",
|
||
str(denied["tool_rows"][0]["detail"].get("status")) if denied["tool_rows"] else "无")
|
||
if not keep_run:
|
||
await cleanup_run(denied_session, denied_run)
|
||
|
||
print("\n== 4. 恢复带白名单的配置 ==")
|
||
await activate_release(client, admin_auth, with_whitelist=True)
|
||
|
||
width = max(len(name) for _, name, _ in _results)
|
||
print()
|
||
for verdict, name, detail_text in _results:
|
||
print(f"[{verdict}] {name.ljust(width)} {detail_text}")
|
||
failed = sum(1 for verdict, _, _ in _results if verdict == "FAIL")
|
||
print(f"\n合计 {len(_results)} 项,失败 {failed} 项")
|
||
if failed:
|
||
raise SystemExit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|