Files
group_fqcd_jr/tools/acceptance_check.py

320 lines
12 KiB
Python
Raw Permalink 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.
"""独立验收脚本:真实 HTTP + 真实 JWT + 真实 MySQL + 显式注册的测试 Agent。
覆盖 P0-1 越权拦截、P0-2 未注册拒绝、P0-3 端到端闭环、P1-3 SSE delta。
生产代码不注册业务 Agent,本脚本按 TODO 阶段 8 的约定显式注入测试注册表。
"""
from __future__ import annotations
import asyncio
import datetime as dt
import sys
import uuid
from pathlib import Path
from typing import Any
import httpx
import jwt
from sqlalchemy import delete, select
import app.service.admin_service as admin_service
import app.service.agent_run_application_service as run_service
import app.service.public_platform_service as public_platform_service
from app.core.config import get_settings
from app.core.contracts import (
AgentDefinition,
AgentRequest,
CoreResult,
RequestContext,
ResolvedAgentConfig,
)
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.service.agent.base import BaseAgent
from app.service.agent.bootstrap import get_agent_factory
from app.service.agent.factory import AgentFactory
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")
CUSTOMER_ID = "9001" # tools/seed_test_rbac.py 造的数据
_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",
)
async def ensure_onboarding(client: httpx.AsyncClient, auth: dict[str, str]) -> None:
"""让独立验收客户满足业务接口的首次登录前置条件。"""
response = await client.get("/api/v1/onboarding/risk-questionnaire", headers=auth)
record("开户问卷状态可查询", "200", str(response.status_code))
if response.status_code != 200:
return
questionnaire = response.json().get("data") or {}
if not questionnaire.get("required"):
record("测试客户已完成开户问卷", "completed", "completed")
return
submission = {
"answers": {
"q1": 12, "q2": 4, "q3": 5, "q4": 4, "q5": 4, "q6": 5,
"q7": 5, "q8": 4, "q9": 4, "q10": 1, "q11": 5, "q12": 4,
"q13": 4,
},
"declaration_accepted": True,
}
response = await client.post(
"/api/v1/onboarding/risk-questionnaire/submissions",
json=submission,
headers={**auth, "Idempotency-Key": uuid.uuid4().hex},
)
record("首次登录问卷提交", "201", str(response.status_code))
class StubGovernance:
async def resolve(self, definition: AgentDefinition, context: RequestContext) -> ResolvedAgentConfig:
del definition, context
return ResolvedAgentConfig(
config_version="acceptance", prompt_version="acceptance", model_endpoint="stub"
)
async def recall(self, context: RequestContext) -> tuple[Any, ...]:
del context
return ()
async def review(
self, result: Any, context: RequestContext, config: Any, memories: Any,
*, agent_type: str = "",
) -> Any:
del context, config, memories, agent_type
return result
class AcceptanceAgent(BaseAgent):
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
del request, context
return CoreResult(text="独立验收通过")
def register_probes(factory: AgentFactory, *, version: str = "acceptance") -> None:
"""向工厂注册两个探针 Agent(customer_service / risk)。
生产代码不注册业务 Agent(底座只提供框架),而验收需要走完整 HTTP 链路,
因此通过工厂的公开 `register` 接口注册——这与业务组员接入 Agent 的方式一致。
"""
for agent_type, roles in (
("customer_service", ("customer",)),
("risk", ("risk_operator",)),
):
definition = AgentDefinition(
agent_type=agent_type,
version=version,
allowed_roles=roles,
allowed_portals=("api",),
)
def builder(
_ctx: RequestContext, _def: AgentDefinition = definition
) -> BaseAgent:
return AcceptanceAgent(_def)
factory.register(definition, builder)
def build_registry() -> AgentFactory:
"""替身治理装配:供 HTTP 契约验收使用(**不验证生产装配**)。"""
factory = AgentFactory(StubGovernance())
register_probes(factory)
return factory
def production_factory() -> AgentFactory:
"""生产装配 + 探针 Agent,并**断言生产装配完整**。
与旧做法的关键差别:不再无条件把测试工厂塞进生产模块的全局名字——那样做会让
"生产装配到底可不可用"完全验收不到。这里直接用生产工厂,先断言模型服务、
工具执行器、意图分类器都已注入,再把探针 Agent 注册进去。
"""
factory = get_agent_factory()
missing = [
name
for name, value in (
("model_service", factory._model_service),
("tool_executor", factory._tool_executor),
("intent_classifier", factory._intent_classifier),
)
if value is None
]
if missing:
raise SystemExit(f"生产装配不完整,缺少:{missing}")
register_probes(factory, version="acceptance-production")
return factory
def inject_factory(factory: AgentFactory) -> None:
"""把替身工厂绑定到服务模块的全局名字——**仅限默认的 HTTP 契约验收模式**。
这是本脚本唯一的注入手段:Controller 直接构造 `AgentRunApplicationService(session)`
而不接收工厂,因此没有依赖注入点可用。生产装配的验证由 `--production` 模式与
`tools/memory_chain_probe.py` 承担,不会被这个注入掩盖。
"""
for module in (run_service, public_platform_service, admin_service):
if hasattr(module, "get_agent_factory"):
module.get_agent_factory = lambda: factory # type: ignore[attr-defined]
async def _collect_events(
client: httpx.AsyncClient, run_id: str, auth: dict[str, str]
) -> list[str]:
events: list[str] = []
async with client.stream(
"GET", f"/api/v1/agent-runs/{run_id}/events", headers=auth, timeout=30
) as stream:
async for line in stream.aiter_lines():
if line.startswith("event:"):
events.append(line.split(":", 1)[1].strip())
if events[-1] in {"done", "error"}:
break
return events
async def cleanup(session_id: str, run_id: str | None) -> None:
async with SessionFactory() as session, session.begin():
if run_id:
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)
)
async def main() -> None:
if "--production" in sys.argv:
factory = production_factory()
print("模式:生产装配验收(真实治理链 + 探针 Agent)")
else:
factory = build_registry()
inject_factory(factory)
print("模式:HTTP 契约验收(替身治理)——未验证生产装配;加 --production 验生产链路")
app = create_app()
session_id = f"acceptance-{uuid.uuid4()}"
run_id: str | None = None
try:
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=30
) as client:
auth = {"Authorization": f"Bearer {token(CUSTOMER_ID)}"}
await ensure_onboarding(client, auth)
response = await client.post(
"/api/v1/agent-runs",
json={
"agent_type": "customer_service",
"message": "独立验收消息",
"session_id": session_id,
"idempotency_key": uuid.uuid4().hex,
},
headers=auth,
)
record("客户调用已授权 Agent", "202", str(response.status_code))
if response.status_code == 202:
run_id = response.json().get("data", {}).get("run_id")
response = await client.post(
"/api/v1/agent-runs",
json={
"agent_type": "risk",
"message": "越权尝试",
"session_id": session_id,
"idempotency_key": uuid.uuid4().hex,
},
headers=auth,
)
record("客户越权调用 risk Agent", "403", str(response.status_code))
response = await client.post(
"/api/v1/agent-runs",
json={
"agent_type": "no_such_agent",
"message": "x",
"session_id": session_id,
"idempotency_key": uuid.uuid4().hex,
},
headers=auth,
)
record("未注册 agent_type", "404", str(response.status_code))
if run_id:
runtime = WorkerRuntime(factory)
await runtime.dispatch_one(run_id=run_id)
# 先建立 SSE 订阅(此时 run 仍为 queued),再执行,才能观察到 delta 分块
subscription = asyncio.create_task(_collect_events(client, run_id, auth))
await asyncio.sleep(1.0)
await runtime.execute(run_id)
events = await subscription
print(f"SSE events: {events}")
record(
"SSE 含内容事件", "yes",
"yes" if {"delta", "replace"}.intersection(events) else "no",
)
record("SSE 终止事件", "done", events[-1] if events else "无事件")
response = await client.get(f"/api/v1/agent-runs/{run_id}", headers=auth)
# 文档 §3.3:单资源成功响应是 {data, meta} 信封。
body = response.json().get("data") or {}
record("运行终态", "succeeded", str(body.get("status")))
record(
"运行结果内容",
"独立验收通过",
str((body.get("result") or {}).get("content")),
)
finally:
await cleanup(session_id, run_id)
width = max(len(name) for _, name, _ in _results)
print()
for verdict, name, detail in _results:
print(f"[{verdict}] {name.ljust(width)} {detail}")
failed = sum(1 for verdict, _, _ in _results if verdict == "FAIL")
print(f"\n合计 {len(_results)} 项,失败 {failed} 项")
if __name__ == "__main__":
asyncio.run(main())