feat(awooop): surface gateway summary in details
All checks were successful
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / tests (push) Successful in 1m4s
CD Pipeline / build-and-deploy (push) Successful in 3m47s
CD Pipeline / post-deploy-checks (push) Successful in 1m16s

This commit is contained in:
Your Name
2026-05-13 11:49:37 +08:00
parent 51528b2cf9
commit c486087294
7 changed files with 299 additions and 5 deletions

View File

@@ -28,6 +28,7 @@ from src.db.awooop_models import (
)
from src.db.base import get_db_context
from src.services.audit_sink import write_audit
from src.services.awooop_truth_chain_service import _summarize_gateway_mcp
from src.services.awooop_approval_token import issue_approval_token, record_approval
from src.services.run_state_machine import transition
@@ -240,6 +241,17 @@ def _outbound_timeline_title(
return f"{channel}{fallback}"
def _mcp_gateway_summary_row(row: AwoooPMcpGatewayAudit) -> dict[str, Any]:
"""Convert SQLAlchemy audit rows into the truth-chain summary shape."""
return {
"agent_id": row.agent_id,
"tool_name": row.tool_name,
"result_status": row.result_status,
"block_gate": row.block_gate,
"gate_result": row.gate_result or {},
}
async def get_run_detail(
run_id: str,
project_id: str | None = None,
@@ -373,18 +385,27 @@ async def get_run_detail(
for row in outbound_messages
]
mcp_items = [
{
def _mcp_item(row: AwoooPMcpGatewayAudit) -> dict[str, Any]:
gate_result = row.gate_result if isinstance(row.gate_result, dict) else {}
return {
"call_id": row.call_id,
"agent_id": row.agent_id,
"tool_name": row.tool_name,
"result_status": row.result_status,
"block_gate": row.block_gate,
"block_reason": row.block_reason,
"latency_ms": row.latency_ms,
"created_at": row.created_at,
"required_scope": gate_result.get("required_scope"),
"policy_enforced": gate_result.get("policy_enforced"),
"is_shadow": gate_result.get("is_shadow"),
"gate_result": gate_result,
}
for row in mcp_calls
]
mcp_items = [_mcp_item(row) for row in mcp_calls]
mcp_gateway_summary = _summarize_gateway_mcp([
_mcp_gateway_summary_row(row) for row in mcp_calls
])
timeline: list[dict[str, Any]] = [
_timeline_item(
@@ -433,15 +454,28 @@ async def get_run_detail(
)
)
for row in mcp_calls:
gate_result = row.gate_result if isinstance(row.gate_result, dict) else {}
scope = gate_result.get("required_scope")
policy_enforced = gate_result.get("policy_enforced")
summary = row.block_reason
if summary is None:
summary = (
f"agent={row.agent_id or 'unknown'}"
f" scope={scope or 'unknown'}"
f" policy_enforced={policy_enforced}"
)
timeline.append(
_timeline_item(
ts=row.created_at,
kind="mcp",
title=f"MCP: {row.tool_name}",
status=row.result_status,
summary=row.block_reason,
summary=summary,
metadata={
"agent_id": row.agent_id,
"block_gate": row.block_gate,
"required_scope": scope,
"policy_enforced": policy_enforced,
"latency_ms": row.latency_ms,
},
)
@@ -487,6 +521,7 @@ async def get_run_detail(
"inbound_events": inbound_items,
"outbound_messages": outbound_items,
"mcp_calls": mcp_items,
"mcp_gateway": mcp_gateway_summary,
"timeline": timeline,
"counts": {
"steps": len(step_items),

View File

@@ -71,6 +71,62 @@ _TELEGRAM_BOT_URL_RE = re.compile(r"(api\.telegram\.org/bot)[^/\s]+")
_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b")
def _top_gateway_bucket(
buckets: list[dict[str, object]],
field: str,
) -> str | None:
if not buckets:
return None
top = max(buckets, key=lambda row: int(row.get("total") or 0))
value = top.get(field)
if value is None:
return None
return f"{value} ({top.get('total', 0)})"
def _format_gateway_summary_lines(summary: dict[str, object] | None) -> list[str]:
if not summary or int(summary.get("total") or 0) <= 0:
return []
by_agent = summary.get("by_agent") if isinstance(summary.get("by_agent"), list) else []
by_tool = summary.get("by_tool") if isinstance(summary.get("by_tool"), list) else []
by_scope = summary.get("by_scope") if isinstance(summary.get("by_scope"), list) else []
blockers = summary.get("blockers") if isinstance(summary.get("blockers"), list) else []
lines = [
"",
"🛡️ <b>MCP Gateway</b>",
(
"階段: "
f"<code>{html.escape(str(summary.get('stage') or 'unknown'))}</code>"
" / "
f"<code>{html.escape(str(summary.get('stage_status') or 'unknown'))}</code>"
),
(
"治理: "
f"first-class <code>{int(summary.get('first_class_total') or 0)}</code> / "
f"policy <code>{int(summary.get('policy_enforced_total') or 0)}</code> / "
f"legacy <code>{int(summary.get('legacy_bridge_total') or 0)}</code>"
),
]
agent = _top_gateway_bucket(by_agent, "agent_id")
tool = _top_gateway_bucket(by_tool, "tool_name")
scope = _top_gateway_bucket(by_scope, "required_scope")
if agent:
lines.append(f"Agent: <code>{html.escape(agent)}</code>")
if tool:
lines.append(f"Tool: <code>{html.escape(tool)}</code>")
if scope:
lines.append(f"Scope: <code>{html.escape(scope)}</code>")
if blockers:
lines.append(
"卡點: "
+ html.escape(", ".join(str(item) for item in blockers[:3]))
)
return lines
def _sanitize_telegram_error(text: str) -> str:
"""遮蔽 Telegram Bot URL 中的 token避免例外字串污染 log / trace。"""
return _TELEGRAM_BOT_URL_RE.sub(r"\1<redacted>", text)
@@ -5064,6 +5120,25 @@ class TelegramGateway:
+ html.escape(", ".join(mismatch_codes[:4]))
)
try:
from src.services.awooop_truth_chain_service import fetch_truth_chain
truth_chain = await fetch_truth_chain(
source_id=incident_id,
project_id=getattr(incident, "project_id", None) or "awoooi",
)
gateway_summary = (
(truth_chain.get("mcp") or {})
.get("awooop_gateway")
)
lines += _format_gateway_summary_lines(gateway_summary)
except Exception as truth_exc:
logger.warning(
"incident_detail_truth_chain_summary_failed",
incident_id=incident_id,
error=str(truth_exc),
)
await self.send_notification("\n".join(lines))
except Exception as e:

View File

@@ -95,6 +95,13 @@ class TestDetailMessageFormat:
"""detail 訊息顯示嚴重度"""
assert "incident.severity" in self._read_gateway()
def test_detail_includes_truth_chain_gateway_summary(self):
"""detail 顯示 AwoooP truth-chain / MCP Gateway 摘要"""
source = self._read_gateway()
assert "fetch_truth_chain" in source
assert "_format_gateway_summary_lines" in source
assert "MCP Gateway" in source
# =============================================================================
# Test: history 訊息格式

View File

@@ -1587,6 +1587,20 @@
"count": "{count} items",
"empty": "No timeline records yet."
},
"gateway": {
"title": "MCP Gateway",
"emptyState": "No records",
"agent": "Agent",
"tool": "Tool",
"scope": "Scope",
"blockers": "Blockers",
"metrics": {
"firstClass": "First-class",
"policy": "Policy enforced",
"approvalExecutor": "Approval executor",
"legacyBridge": "Legacy bridge"
}
},
"action": {
"eyebrow": "Next Decision",
"approval": {

View File

@@ -1588,6 +1588,20 @@
"count": "{count} 筆",
"empty": "尚無時間線資料。"
},
"gateway": {
"title": "MCP Gateway",
"emptyState": "尚無紀錄",
"agent": "Agent",
"tool": "Tool",
"scope": "Scope",
"blockers": "卡點",
"metrics": {
"firstClass": "First-class",
"policy": "Policy enforced",
"approvalExecutor": "Approval executor",
"legacyBridge": "Legacy bridge"
}
},
"action": {
"eyebrow": "下一步判斷",
"approval": {

View File

@@ -60,9 +60,38 @@ interface TimelineItem {
metadata?: Record<string, unknown>;
}
interface McpGatewayBucket {
agent_id?: string;
tool_name?: string;
required_scope?: string;
total: number;
success: number;
failed: number;
blocked: number;
}
interface McpGatewaySummary {
total: number;
success: number;
failed: number;
blocked: number;
first_class_total: number;
legacy_bridge_total: number;
policy_enforced_total: number;
approval_executor_total: number;
stage: string;
stage_status: string;
needs_human: boolean;
blockers: string[];
by_agent: McpGatewayBucket[];
by_tool: McpGatewayBucket[];
by_scope: McpGatewayBucket[];
}
interface RunDetailResponse {
run: RunDetail;
timeline: TimelineItem[];
mcp_gateway?: McpGatewaySummary;
counts: {
steps: number;
inbound_events: number;
@@ -258,6 +287,85 @@ function DetailField({
);
}
function topBucket(
buckets: McpGatewayBucket[] | undefined,
field: "agent_id" | "tool_name" | "required_scope"
) {
if (!buckets || buckets.length === 0) return null;
return [...buckets].sort((a, b) => b.total - a.total)[0]?.[field] ?? null;
}
function McpGatewayPanel({
summary,
emptyLabel,
statusLabel,
}: {
summary?: McpGatewaySummary;
emptyLabel: string;
statusLabel: (status: string) => string;
}) {
const t = useTranslations("awooop.runDetail.gateway");
const hasRecords = Boolean(summary && summary.total > 0);
const toneClass = summary?.needs_human
? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
: summary?.stage_status === "success"
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]";
const agent = topBucket(summary?.by_agent, "agent_id");
const tool = topBucket(summary?.by_tool, "tool_name");
const scope = topBucket(summary?.by_scope, "required_scope");
const metrics = [
{ label: t("metrics.firstClass"), value: summary?.first_class_total ?? 0 },
{ label: t("metrics.policy"), value: summary?.policy_enforced_total ?? 0 },
{ label: t("metrics.approvalExecutor"), value: summary?.approval_executor_total ?? 0 },
{ label: t("metrics.legacyBridge"), value: summary?.legacy_bridge_total ?? 0 },
];
return (
<section className="border border-[#e0ddd4] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
</div>
<span className={cn("border px-2 py-0.5 text-xs font-semibold", hasRecords ? toneClass : statusClass("pending"))}>
{hasRecords ? statusLabel(summary?.stage_status ?? "pending") : t("emptyState")}
</span>
</div>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
{metrics.map((item) => (
<div key={item.label} className="bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
<p className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
{hasRecords ? item.value : emptyLabel}
</p>
</div>
))}
</div>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-3">
{[
{ label: t("agent"), value: agent },
{ label: t("tool"), value: tool },
{ label: t("scope"), value: scope },
].map((item) => (
<div key={item.label} className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
<p className="mt-1 truncate font-mono text-sm text-[#141413]">
{item.value ?? emptyLabel}
</p>
</div>
))}
</div>
{hasRecords && summary?.blockers && summary.blockers.length > 0 && (
<div className="border-t border-[#eee9dd] bg-[#fff0ef] px-4 py-3 text-sm text-[#9f2f25]">
<span className="font-semibold">{t("blockers")}</span>{" "}
<span className="font-mono text-xs">{summary.blockers.slice(0, 3).join(", ")}</span>
</div>
)}
</section>
);
}
function TimelineRow({
item,
locale,
@@ -438,6 +546,12 @@ export default function RunDetailPage({
<RunActionPanel run={run} counts={detail?.counts} emptyLabel={t("empty")} />
<McpGatewayPanel
summary={detail?.mcp_gateway}
emptyLabel={t("empty")}
statusLabel={statusLabel}
/>
<section className="grid gap-4 xl:grid-cols-[360px_1fr]">
<aside className="border border-[#e0ddd4] bg-white">
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">

View File

@@ -7174,3 +7174,38 @@ gateway.by_tool[0].tool_name=ssh_docker_restart
- Operator truth-chain 已能把「AwoooP Gateway 已通過」與「底層 provider 失敗」分開顯示。
- 這直接補上 Telegram 卡片看不出 MCP / Gate / provider 階段的缺口;下一步要把這份 summary 接到 Run Detail / Telegram 詳情入口,而不是只靠 pod 內部 service smoke。
### 2026-05-13 — AwoooP visibility T11Telegram 詳情與 Run Detail 顯示 MCP Gateway 摘要local green
**目的**
- 讓 Telegram 告警「詳情」與 AwoooP Run Detail 都能看到 AI 自動化是否真的經過 MCP Gateway、是否 policy enforced、是否仍落在 legacy bridge以及最後是 gate block 或 provider failure。
- 把 T10 truth-chain summary 從 pod 內部 smoke 推進到 operator 可見入口。
**變更**
- Telegram incident detail 讀取 truth-chain補上 MCP Gateway 摘要列。
- Run Detail API 回傳 `mcp_gateway` summary並把 MCP timeline metadata 補齊 `agent_id` / `required_scope` / `policy_enforced`
- AwoooP Run Detail UI 新增 MCP Gateway panel顯示 first-class / policy / approval executor / legacy bridge 與主要 agent/tool/scope/blocker。
**local verification**
```text
DATABASE_URL=postgresql+asyncpg://u:p@localhost:5432/db python -m pytest tests/test_awooop_truth_chain_service.py tests/test_telegram_adr050.py tests/test_platform_router_order.py tests/test_awooop_operator_auth.py -q
51 passed
python -m ruff check --select F821 src/services/telegram_gateway.py src/services/platform_operator_service.py tests/test_telegram_adr050.py
All checks passed
python -m py_compile src/services/telegram_gateway.py src/services/platform_operator_service.py tests/test_telegram_adr050.py
OK
python3 -m json.tool apps/web/messages/zh-TW.json >/dev/null
python3 -m json.tool apps/web/messages/en.json >/dev/null
OK
pnpm --filter @awoooi/web typecheck
success
```
**目前整體進度**:約 67%。