feat(awooop): summarize gateway usage in truth chain
This commit is contained in:
@@ -346,6 +346,122 @@ def _summarize_mcp(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(value: Any) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return str(value).lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _counter_bucket(
|
||||||
|
buckets: dict[str, dict[str, Any]],
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return buckets.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
label: key,
|
||||||
|
"total": 0,
|
||||||
|
"success": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"blocked": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_gateway_mcp(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
by_agent: dict[str, dict[str, Any]] = {}
|
||||||
|
by_tool: dict[str, dict[str, Any]] = {}
|
||||||
|
by_scope: dict[str, dict[str, Any]] = {}
|
||||||
|
success_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
blocked_count = 0
|
||||||
|
first_class_count = 0
|
||||||
|
bridge_count = 0
|
||||||
|
policy_enforced_count = 0
|
||||||
|
approval_executor_count = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
gate_result = row.get("gate_result") if isinstance(row.get("gate_result"), dict) else {}
|
||||||
|
gateway_path = str(gate_result.get("gateway_path") or "")
|
||||||
|
schema_version = str(gate_result.get("schema_version") or "")
|
||||||
|
policy_enforced = _as_bool(gate_result.get("policy_enforced"))
|
||||||
|
required_scope = str(gate_result.get("required_scope") or "unknown")
|
||||||
|
status = str(row.get("result_status") or "unknown").lower()
|
||||||
|
is_blocked = status == "blocked" or row.get("block_gate") is not None
|
||||||
|
is_success = status == "success"
|
||||||
|
is_failed = status == "failed"
|
||||||
|
agent_id = str(row.get("agent_id") or "unknown")
|
||||||
|
tool_name = str(row.get("tool_name") or "unknown")
|
||||||
|
|
||||||
|
if gateway_path == "awooop_mcp_gateway" and policy_enforced:
|
||||||
|
first_class_count += 1
|
||||||
|
if schema_version == "legacy_mcp_bridge_v1" or policy_enforced is False:
|
||||||
|
bridge_count += 1
|
||||||
|
if policy_enforced:
|
||||||
|
policy_enforced_count += 1
|
||||||
|
if agent_id == "approval_executor":
|
||||||
|
approval_executor_count += 1
|
||||||
|
if is_success:
|
||||||
|
success_count += 1
|
||||||
|
if is_failed:
|
||||||
|
failed_count += 1
|
||||||
|
if is_blocked:
|
||||||
|
blocked_count += 1
|
||||||
|
|
||||||
|
for bucket in (
|
||||||
|
_counter_bucket(by_agent, agent_id, label="agent_id"),
|
||||||
|
_counter_bucket(by_tool, tool_name, label="tool_name"),
|
||||||
|
_counter_bucket(by_scope, required_scope, label="required_scope"),
|
||||||
|
):
|
||||||
|
bucket["total"] += 1
|
||||||
|
if is_success:
|
||||||
|
bucket["success"] += 1
|
||||||
|
if is_failed:
|
||||||
|
bucket["failed"] += 1
|
||||||
|
if is_blocked:
|
||||||
|
bucket["blocked"] += 1
|
||||||
|
|
||||||
|
blockers: list[str] = []
|
||||||
|
if blocked_count:
|
||||||
|
stage = "gateway_blocked"
|
||||||
|
stage_status = "blocked"
|
||||||
|
blockers.append("mcp_gateway_blocked")
|
||||||
|
elif first_class_count and failed_count:
|
||||||
|
stage = "provider_failed_after_gateway"
|
||||||
|
stage_status = "failed"
|
||||||
|
blockers.append("provider_failed_after_gateway")
|
||||||
|
elif success_count:
|
||||||
|
stage = "gateway_execution_succeeded"
|
||||||
|
stage_status = "success"
|
||||||
|
elif bridge_count and not first_class_count:
|
||||||
|
stage = "legacy_bridge_only"
|
||||||
|
stage_status = "observed"
|
||||||
|
blockers.append("legacy_bridge_not_policy_enforced")
|
||||||
|
else:
|
||||||
|
stage = "no_gateway_records"
|
||||||
|
stage_status = "missing"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": len(rows),
|
||||||
|
"success": success_count,
|
||||||
|
"failed": failed_count,
|
||||||
|
"blocked": blocked_count,
|
||||||
|
"first_class_total": first_class_count,
|
||||||
|
"legacy_bridge_total": bridge_count,
|
||||||
|
"policy_enforced_total": policy_enforced_count,
|
||||||
|
"approval_executor_total": approval_executor_count,
|
||||||
|
"stage": stage,
|
||||||
|
"stage_status": stage_status,
|
||||||
|
"needs_human": bool(blocked_count or (first_class_count and failed_count)),
|
||||||
|
"blockers": blockers,
|
||||||
|
"by_agent": list(by_agent.values()),
|
||||||
|
"by_tool": list(by_tool.values()),
|
||||||
|
"by_scope": list(by_scope.values()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[str, Any]:
|
async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[str, Any]:
|
||||||
"""Return a read-only truth chain for an incident, drift report, or run id."""
|
"""Return a read-only truth chain for an incident, drift report, or run id."""
|
||||||
async with get_db_context(project_id) as db:
|
async with get_db_context(project_id) as db:
|
||||||
@@ -683,6 +799,7 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[
|
|||||||
|
|
||||||
source_type = _source_type(source_id, incident, drift)
|
source_type = _source_type(source_id, incident, drift)
|
||||||
legacy_mcp_summary = _summarize_mcp(legacy_mcp_rows)
|
legacy_mcp_summary = _summarize_mcp(legacy_mcp_rows)
|
||||||
|
gateway_mcp_summary = _summarize_gateway_mcp(gateway_mcp_rows)
|
||||||
truth_status = _truth_status(
|
truth_status = _truth_status(
|
||||||
incident=incident,
|
incident=incident,
|
||||||
approvals=approvals,
|
approvals=approvals,
|
||||||
@@ -694,6 +811,13 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[
|
|||||||
legacy_mcp_total=legacy_mcp_summary["total"],
|
legacy_mcp_total=legacy_mcp_summary["total"],
|
||||||
outbound_visible_total=len(outbound_rows),
|
outbound_visible_total=len(outbound_rows),
|
||||||
)
|
)
|
||||||
|
if incident is None and drift is None and not runs and gateway_mcp_rows:
|
||||||
|
truth_status = {
|
||||||
|
"current_stage": gateway_mcp_summary["stage"],
|
||||||
|
"stage_status": gateway_mcp_summary["stage_status"],
|
||||||
|
"needs_human": gateway_mcp_summary["needs_human"],
|
||||||
|
"blockers": gateway_mcp_summary["blockers"],
|
||||||
|
}
|
||||||
reconciliation = build_incident_reconciliation(
|
reconciliation = build_incident_reconciliation(
|
||||||
incident=incident,
|
incident=incident,
|
||||||
approvals=approvals,
|
approvals=approvals,
|
||||||
@@ -713,7 +837,7 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[
|
|||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"source_id": source_id,
|
"source_id": source_id,
|
||||||
"source_type": source_type,
|
"source_type": source_type,
|
||||||
"found": incident is not None or drift is not None or bool(runs),
|
"found": incident is not None or drift is not None or bool(runs) or bool(gateway_mcp_rows),
|
||||||
"truth_status": truth_status,
|
"truth_status": truth_status,
|
||||||
"linked_ids": {
|
"linked_ids": {
|
||||||
"incident_id": incident.get("incident_id") if incident else None,
|
"incident_id": incident.get("incident_id") if incident else None,
|
||||||
@@ -734,7 +858,7 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[
|
|||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"awooop_gateway": {
|
"awooop_gateway": {
|
||||||
"total": len(gateway_mcp_rows),
|
**gateway_mcp_summary,
|
||||||
"records": gateway_mcp_rows,
|
"records": gateway_mcp_rows,
|
||||||
},
|
},
|
||||||
"legacy": {
|
"legacy": {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from src.services.awooop_ansible_audit_service import (
|
|||||||
from src.services.awooop_truth_chain_service import (
|
from src.services.awooop_truth_chain_service import (
|
||||||
build_incident_reconciliation,
|
build_incident_reconciliation,
|
||||||
_clean_row,
|
_clean_row,
|
||||||
|
_summarize_gateway_mcp,
|
||||||
_truth_status,
|
_truth_status,
|
||||||
)
|
)
|
||||||
from src.services.drift_repeat_state import (
|
from src.services.drift_repeat_state import (
|
||||||
@@ -78,6 +79,38 @@ def test_truth_status_marks_repeated_pending_drift_as_human_needed() -> None:
|
|||||||
assert "drift_ai_confidence_zero" in status["blockers"]
|
assert "drift_ai_confidence_zero" in status["blockers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_summary_surfaces_first_class_approval_execution() -> None:
|
||||||
|
summary = _summarize_gateway_mcp([
|
||||||
|
{
|
||||||
|
"agent_id": "approval_executor",
|
||||||
|
"tool_name": "ssh_docker_restart",
|
||||||
|
"result_status": "failed",
|
||||||
|
"block_gate": None,
|
||||||
|
"gate_result": {
|
||||||
|
"schema_version": "awooop_mcp_gateway_audit_v1",
|
||||||
|
"gateway_path": "awooop_mcp_gateway",
|
||||||
|
"policy_enforced": True,
|
||||||
|
"required_scope": "write",
|
||||||
|
"is_shadow": False,
|
||||||
|
"gate5_approval": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
assert summary["total"] == 1
|
||||||
|
assert summary["first_class_total"] == 1
|
||||||
|
assert summary["legacy_bridge_total"] == 0
|
||||||
|
assert summary["policy_enforced_total"] == 1
|
||||||
|
assert summary["approval_executor_total"] == 1
|
||||||
|
assert summary["stage"] == "provider_failed_after_gateway"
|
||||||
|
assert summary["stage_status"] == "failed"
|
||||||
|
assert summary["needs_human"] is True
|
||||||
|
assert summary["by_agent"][0]["agent_id"] == "approval_executor"
|
||||||
|
assert summary["by_tool"][0]["tool_name"] == "ssh_docker_restart"
|
||||||
|
assert summary["by_scope"][0]["required_scope"] == "write"
|
||||||
|
assert summary["by_scope"][0]["failed"] == 1
|
||||||
|
|
||||||
|
|
||||||
def _drift_item(
|
def _drift_item(
|
||||||
*,
|
*,
|
||||||
resource_name: str = "awoooi-api",
|
resource_name: str = "awoooi-api",
|
||||||
|
|||||||
@@ -7104,3 +7104,37 @@ provider_error=Host '192.0.2.1' not in SSH_MCP_ALLOWED_HOSTS
|
|||||||
- T9 已完成:已批准 SSH execution 會進 first-class `McpGateway`,不再是 `legacy_direct_provider`。
|
- T9 已完成:已批准 SSH execution 會進 first-class `McpGateway`,不再是 `legacy_direct_provider`。
|
||||||
- smoke 使用 `192.0.2.1` 保留位址,故 provider failure 是預期安全結果;重點是五閘門與 audit 已真實通過到 provider 前一層。
|
- smoke 使用 `192.0.2.1` 保留位址,故 provider failure 是預期安全結果;重點是五閘門與 audit 已真實通過到 provider 前一層。
|
||||||
- 目前整體進度更新:約 65%。
|
- 目前整體進度更新:約 65%。
|
||||||
|
|
||||||
|
### 2026-05-13 — AwoooP truth-chain T10:MCP Gateway 使用狀態摘要(local green)
|
||||||
|
|
||||||
|
**目的**:
|
||||||
|
|
||||||
|
- 讓 Operator 不只看到 Gateway raw records,也能直接判斷「是否真的經過 AwoooP MCP Gateway」、「是不是 legacy bridge」、「哪個 agent/tool/scope」、「卡在 gate 還是 provider」。
|
||||||
|
- 對應 Telegram 告警看不出 AI 自動化流程進度的問題,truth-chain 要提供可查、可聚合的狀態面。
|
||||||
|
|
||||||
|
**變更**:
|
||||||
|
|
||||||
|
- `awooop_truth_chain_service.py` 新增 `_summarize_gateway_mcp()`。
|
||||||
|
- `mcp.awooop_gateway` 現在包含:
|
||||||
|
- `first_class_total`
|
||||||
|
- `legacy_bridge_total`
|
||||||
|
- `policy_enforced_total`
|
||||||
|
- `approval_executor_total`
|
||||||
|
- `stage` / `stage_status` / `needs_human` / `blockers`
|
||||||
|
- `by_agent` / `by_tool` / `by_scope`
|
||||||
|
- 只用 Gateway trace id 查詢時,`found=true`,並以 Gateway summary 推導 truth status。
|
||||||
|
|
||||||
|
**local verification**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python -m pytest tests/test_awooop_truth_chain_service.py tests/test_platform_router_order.py tests/test_awooop_operator_auth.py -q
|
||||||
|
19 passed
|
||||||
|
|
||||||
|
python -m ruff check --select F821 src/services/awooop_truth_chain_service.py tests/test_awooop_truth_chain_service.py
|
||||||
|
All checks passed
|
||||||
|
|
||||||
|
python -m py_compile src/services/awooop_truth_chain_service.py tests/test_awooop_truth_chain_service.py
|
||||||
|
OK
|
||||||
|
```
|
||||||
|
|
||||||
|
**目前整體進度**:約 66%。
|
||||||
|
|||||||
Reference in New Issue
Block a user