Files
group_fqcd_jr/tools/demo_agent_e2e.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

302 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""示例业务 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())