diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 7d392183..f852e77b 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -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), diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index b9fe0b67..87546067 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -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 = [ + "", + "🛡️ MCP Gateway", + ( + "階段: " + f"{html.escape(str(summary.get('stage') or 'unknown'))}" + " / " + f"{html.escape(str(summary.get('stage_status') or 'unknown'))}" + ), + ( + "治理: " + f"first-class {int(summary.get('first_class_total') or 0)} / " + f"policy {int(summary.get('policy_enforced_total') or 0)} / " + f"legacy {int(summary.get('legacy_bridge_total') or 0)}" + ), + ] + + 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: {html.escape(agent)}") + if tool: + lines.append(f"Tool: {html.escape(tool)}") + if scope: + lines.append(f"Scope: {html.escape(scope)}") + 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", 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: diff --git a/apps/api/tests/test_telegram_adr050.py b/apps/api/tests/test_telegram_adr050.py index b82d9ccb..73f5e004 100644 --- a/apps/api/tests/test_telegram_adr050.py +++ b/apps/api/tests/test_telegram_adr050.py @@ -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 訊息格式 diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index add753bb..38eedb72 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 7444873a..3393d8b3 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -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": { diff --git a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx index 5dc9f091..e46f2bc7 100644 --- a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx @@ -60,9 +60,38 @@ interface TimelineItem { metadata?: Record; } +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 ( +
+
+
+
+ + {hasRecords ? statusLabel(summary?.stage_status ?? "pending") : t("emptyState")} + +
+
+ {metrics.map((item) => ( +
+

{item.label}

+

+ {hasRecords ? item.value : emptyLabel} +

+
+ ))} +
+
+ {[ + { label: t("agent"), value: agent }, + { label: t("tool"), value: tool }, + { label: t("scope"), value: scope }, + ].map((item) => ( +
+

{item.label}

+

+ {item.value ?? emptyLabel} +

+
+ ))} +
+ {hasRecords && summary?.blockers && summary.blockers.length > 0 && ( +
+ {t("blockers")}{" "} + {summary.blockers.slice(0, 3).join(", ")} +
+ )} +
+ ); +} + function TimelineRow({ item, locale, @@ -438,6 +546,12 @@ export default function RunDetailPage({ + +