feat(awooop): surface gateway summary in details
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 訊息格式
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 T11:Telegram 詳情與 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%。
|
||||
|
||||
Reference in New Issue
Block a user