feat(awooop): add AI alert card delivery readback
This commit is contained in:
@@ -43,6 +43,9 @@ from src.services.platform_operator_service import (
|
||||
from src.services.platform_operator_service import (
|
||||
list_callback_replies as list_callback_replies_svc,
|
||||
)
|
||||
from src.services.platform_operator_service import (
|
||||
list_ai_alert_card_delivery_readback as list_ai_alert_card_delivery_readback_svc,
|
||||
)
|
||||
from src.services.platform_operator_service import (
|
||||
list_runs as list_runs_svc,
|
||||
)
|
||||
@@ -112,6 +115,59 @@ class CallbackReplyItem(BaseModel):
|
||||
run_detail_href: str | None = None
|
||||
|
||||
|
||||
class AiAlertCardDeliveryItem(BaseModel):
|
||||
message_id: UUID
|
||||
run_id: UUID
|
||||
project_id: str
|
||||
event_at: datetime | None = None
|
||||
channel_type: str
|
||||
message_type: str
|
||||
send_status: str
|
||||
send_error: str | None = None
|
||||
provider_message_id: str | None = None
|
||||
triggered_by_state: str | None = None
|
||||
event_type: str
|
||||
lane: str
|
||||
target: str
|
||||
gates: list[str]
|
||||
runtime_write_gate_count: int
|
||||
runtime_write_allowed: bool
|
||||
candidate_only: bool
|
||||
delivery_receipt_readback_required: bool
|
||||
source_refs: dict[str, Any]
|
||||
run_state: str | None = None
|
||||
agent_id: str | None = None
|
||||
run_created_at: datetime | None = None
|
||||
run_detail_href: str | None = None
|
||||
|
||||
|
||||
class AiAlertCardDeliverySummary(BaseModel):
|
||||
schema_version: str
|
||||
project_id: str
|
||||
event_type: str | None = None
|
||||
lane: str | None = None
|
||||
status: str
|
||||
total: int
|
||||
sent_total: int
|
||||
failed_total: int
|
||||
pending_total: int
|
||||
shadow_total: int
|
||||
delivery_receipt_required_total: int
|
||||
runtime_write_gate_open_count: int
|
||||
runtime_write_allowed: bool
|
||||
latest_sent_at: datetime | None = None
|
||||
latest_queued_at: datetime | None = None
|
||||
production_write_count: int = 0
|
||||
|
||||
|
||||
class ListAiAlertCardsResponse(BaseModel):
|
||||
items: list[AiAlertCardDeliveryItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
summary: AiAlertCardDeliverySummary
|
||||
|
||||
|
||||
class OutboundReplyMarkupGapPrefix(BaseModel):
|
||||
prefix: str
|
||||
total: int
|
||||
@@ -331,6 +387,33 @@ async def list_callback_replies(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/runs/ai-alert-cards",
|
||||
response_model=ListAiAlertCardsResponse,
|
||||
summary="列出 AI 自動化事件卡送達讀回",
|
||||
description=(
|
||||
"從 AwoooP outbound mirror 查詢 ai_automation_alert_card_v1 的"
|
||||
"結構化送達讀回;只讀,不送 Telegram、不修改 incident、run 或 Wazuh 狀態。"
|
||||
),
|
||||
)
|
||||
async def list_ai_alert_card_delivery_readback(
|
||||
project_id: str | None = Query("awoooi", description="租戶 ID"),
|
||||
event_type: str | None = Query(None, description="事件類型 filter"),
|
||||
lane: str | None = Query(None, description="AIOps lane filter"),
|
||||
page: int = Query(1, ge=1, description="頁碼,從 1 開始"),
|
||||
per_page: int = Query(20, ge=1, le=_MAX_PER_PAGE, description="每頁筆數"),
|
||||
refresh: bool = Query(False, description="略過短 TTL 快取並重新聚合"),
|
||||
) -> dict[str, Any]:
|
||||
return await list_ai_alert_card_delivery_readback_svc(
|
||||
project_id=project_id,
|
||||
event_type=event_type,
|
||||
lane=lane,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
refresh=refresh,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/cicd/events",
|
||||
response_model=ListCicdEventsResponse,
|
||||
|
||||
@@ -86,6 +86,9 @@ _ADR100_GATE5_PROJECTION_TRIGGER = "adr100_runtime_replay_gate5"
|
||||
_CALLBACK_REPLY_CACHE_TTL_SECONDS = int(
|
||||
os.getenv("AWOOOP_CALLBACK_REPLY_CACHE_TTL_SECONDS", "20")
|
||||
)
|
||||
_AI_ALERT_CARD_CACHE_TTL_SECONDS = int(
|
||||
os.getenv("AWOOOP_AI_ALERT_CARD_CACHE_TTL_SECONDS", "20")
|
||||
)
|
||||
_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b")
|
||||
_REMEDIATION_STATUS_FILTERS = {
|
||||
"mcp_observed",
|
||||
@@ -1271,6 +1274,247 @@ async def list_callback_replies(
|
||||
)
|
||||
|
||||
|
||||
async def list_ai_alert_card_delivery_readback(
|
||||
*,
|
||||
project_id: str | None = None,
|
||||
event_type: str | None = None,
|
||||
lane: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
refresh: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Read-only AwoooP delivery readback for AI automation alert cards."""
|
||||
normalized_project_id = project_id or "awoooi"
|
||||
normalized_event_type = str(event_type or "").strip()
|
||||
normalized_lane = str(lane or "").strip()
|
||||
normalized_page = max(int(page or 1), 1)
|
||||
normalized_per_page = min(max(int(per_page or 20), 1), _MAX_PER_PAGE)
|
||||
|
||||
cache_key = {
|
||||
"project_id": normalized_project_id,
|
||||
"event_type": normalized_event_type,
|
||||
"lane": normalized_lane,
|
||||
"page": normalized_page,
|
||||
"per_page": normalized_per_page,
|
||||
}
|
||||
if not refresh:
|
||||
cached_response = await get_cached_operator_summary_async(
|
||||
"ai_alert_card_delivery_readback",
|
||||
cache_key,
|
||||
ttl_seconds=_AI_ALERT_CARD_CACHE_TTL_SECONDS,
|
||||
)
|
||||
if cached_response is not None:
|
||||
logger.info(
|
||||
"operator_ai_alert_card_delivery_readback_cache_hit",
|
||||
project_id=normalized_project_id,
|
||||
event_type=normalized_event_type,
|
||||
lane=normalized_lane,
|
||||
page=normalized_page,
|
||||
per_page=normalized_per_page,
|
||||
ttl_seconds=_AI_ALERT_CARD_CACHE_TTL_SECONDS,
|
||||
)
|
||||
return cached_response
|
||||
|
||||
where_clauses = [
|
||||
"m.project_id = :project_id",
|
||||
"m.channel_type = 'telegram'",
|
||||
"m.source_envelope ? 'ai_automation_alert_card'",
|
||||
]
|
||||
params: dict[str, Any] = {
|
||||
"project_id": normalized_project_id,
|
||||
"limit": normalized_per_page,
|
||||
"offset": (normalized_page - 1) * normalized_per_page,
|
||||
}
|
||||
if normalized_event_type:
|
||||
where_clauses.append(
|
||||
"m.source_envelope #>> '{ai_automation_alert_card,event_type}' = :event_type"
|
||||
)
|
||||
params["event_type"] = normalized_event_type
|
||||
if normalized_lane:
|
||||
where_clauses.append(
|
||||
"m.source_envelope #>> '{ai_automation_alert_card,lane}' = :lane"
|
||||
)
|
||||
params["lane"] = normalized_lane
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
summary_sql = text(f"""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE m.send_status = 'sent') AS sent_total,
|
||||
COUNT(*) FILTER (WHERE m.send_status = 'failed') AS failed_total,
|
||||
COUNT(*) FILTER (WHERE m.send_status = 'pending') AS pending_total,
|
||||
COUNT(*) FILTER (WHERE m.send_status = 'shadow') AS shadow_total,
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(
|
||||
m.source_envelope #>>
|
||||
'{{ai_automation_alert_card,delivery_receipt_readback_required}}',
|
||||
''
|
||||
) = 'true'
|
||||
) AS delivery_receipt_required_total,
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(
|
||||
m.source_envelope #>>
|
||||
'{{ai_automation_alert_card,runtime_write_gate_count}}',
|
||||
'0'
|
||||
) <> '0'
|
||||
) AS runtime_write_gate_open_count,
|
||||
MAX(m.sent_at) AS latest_sent_at,
|
||||
MAX(m.queued_at) AS latest_queued_at
|
||||
FROM awooop_outbound_message m
|
||||
WHERE {where_sql}
|
||||
""")
|
||||
list_sql = text(f"""
|
||||
SELECT
|
||||
m.message_id,
|
||||
m.project_id,
|
||||
m.run_id,
|
||||
m.channel_type,
|
||||
m.message_type,
|
||||
m.provider_message_id,
|
||||
m.send_status,
|
||||
m.send_error,
|
||||
m.queued_at,
|
||||
m.sent_at,
|
||||
m.triggered_by_state,
|
||||
m.source_envelope -> 'ai_automation_alert_card' AS alert_card,
|
||||
m.source_envelope -> 'source_refs' AS source_refs,
|
||||
r.agent_id,
|
||||
r.state AS run_state,
|
||||
r.created_at AS run_created_at
|
||||
FROM awooop_outbound_message m
|
||||
LEFT JOIN awooop_run_state r
|
||||
ON r.project_id = m.project_id
|
||||
AND r.run_id = m.run_id
|
||||
WHERE {where_sql}
|
||||
ORDER BY COALESCE(m.sent_at, m.queued_at) DESC, m.message_id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
|
||||
async with get_db_context(normalized_project_id) as db:
|
||||
summary_result = await db.execute(summary_sql, params)
|
||||
summary_row = summary_result.mappings().first() or {}
|
||||
rows_result = await db.execute(list_sql, params)
|
||||
rows = list(rows_result.mappings().all())
|
||||
|
||||
summary = _ai_alert_card_delivery_summary_from_row(
|
||||
summary_row,
|
||||
project_id=normalized_project_id,
|
||||
event_type=normalized_event_type or None,
|
||||
lane=normalized_lane or None,
|
||||
)
|
||||
response = {
|
||||
"items": [_ai_alert_card_delivery_item(row) for row in rows],
|
||||
"total": summary["total"],
|
||||
"page": normalized_page,
|
||||
"per_page": normalized_per_page,
|
||||
"summary": summary,
|
||||
}
|
||||
logger.info(
|
||||
"operator_ai_alert_card_delivery_readback_fetched",
|
||||
project_id=normalized_project_id,
|
||||
event_type=normalized_event_type,
|
||||
lane=normalized_lane,
|
||||
page=normalized_page,
|
||||
per_page=normalized_per_page,
|
||||
total=summary["total"],
|
||||
cache_status="miss",
|
||||
cache_ttl_seconds=_AI_ALERT_CARD_CACHE_TTL_SECONDS,
|
||||
)
|
||||
return await store_operator_summary_async(
|
||||
"ai_alert_card_delivery_readback",
|
||||
cache_key,
|
||||
response,
|
||||
ttl_seconds=_AI_ALERT_CARD_CACHE_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _ai_alert_card_delivery_summary_from_row(
|
||||
row: Mapping[str, Any],
|
||||
*,
|
||||
project_id: str,
|
||||
event_type: str | None,
|
||||
lane: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Normalize AI alert card delivery summary counts."""
|
||||
total = _safe_int(row.get("total"))
|
||||
sent_total = _safe_int(row.get("sent_total"))
|
||||
failed_total = _safe_int(row.get("failed_total"))
|
||||
pending_total = _safe_int(row.get("pending_total"))
|
||||
shadow_total = _safe_int(row.get("shadow_total"))
|
||||
runtime_write_gate_open_count = _safe_int(
|
||||
row.get("runtime_write_gate_open_count")
|
||||
)
|
||||
status_value = "no_delivery_receipt" if total == 0 else "observed"
|
||||
if failed_total > 0:
|
||||
status_value = "delivery_failure_observed"
|
||||
elif pending_total > 0:
|
||||
status_value = "delivery_pending_observed"
|
||||
|
||||
return {
|
||||
"schema_version": "awooop_ai_alert_card_delivery_readback_v1",
|
||||
"project_id": project_id,
|
||||
"event_type": event_type,
|
||||
"lane": lane,
|
||||
"status": status_value,
|
||||
"total": total,
|
||||
"sent_total": sent_total,
|
||||
"failed_total": failed_total,
|
||||
"pending_total": pending_total,
|
||||
"shadow_total": shadow_total,
|
||||
"delivery_receipt_required_total": _safe_int(
|
||||
row.get("delivery_receipt_required_total")
|
||||
),
|
||||
"runtime_write_gate_open_count": runtime_write_gate_open_count,
|
||||
"runtime_write_allowed": runtime_write_gate_open_count > 0,
|
||||
"latest_sent_at": row.get("latest_sent_at"),
|
||||
"latest_queued_at": row.get("latest_queued_at"),
|
||||
"production_write_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def _ai_alert_card_delivery_item(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Convert one AI alert-card outbound mirror row into delivery evidence."""
|
||||
alert_card = _as_dict(row.get("alert_card"))
|
||||
source_refs = _as_dict(row.get("source_refs"))
|
||||
run_id = row.get("run_id")
|
||||
project_id = str(row.get("project_id") or "")
|
||||
runtime_write_gate_count = _safe_int(
|
||||
alert_card.get("runtime_write_gate_count")
|
||||
)
|
||||
event_at = row.get("sent_at") or row.get("queued_at")
|
||||
return {
|
||||
"message_id": row.get("message_id"),
|
||||
"run_id": run_id,
|
||||
"project_id": project_id,
|
||||
"event_at": event_at,
|
||||
"channel_type": row.get("channel_type"),
|
||||
"message_type": row.get("message_type"),
|
||||
"send_status": row.get("send_status"),
|
||||
"send_error": row.get("send_error"),
|
||||
"provider_message_id": row.get("provider_message_id"),
|
||||
"triggered_by_state": row.get("triggered_by_state"),
|
||||
"event_type": str(alert_card.get("event_type") or ""),
|
||||
"lane": str(alert_card.get("lane") or ""),
|
||||
"target": str(alert_card.get("target") or ""),
|
||||
"gates": alert_card.get("gates") if isinstance(alert_card.get("gates"), list) else [],
|
||||
"runtime_write_gate_count": runtime_write_gate_count,
|
||||
"runtime_write_allowed": runtime_write_gate_count > 0,
|
||||
"candidate_only": bool(alert_card.get("candidate_only")),
|
||||
"delivery_receipt_readback_required": bool(
|
||||
alert_card.get("delivery_receipt_readback_required")
|
||||
),
|
||||
"source_refs": source_refs,
|
||||
"run_state": row.get("run_state"),
|
||||
"agent_id": row.get("agent_id"),
|
||||
"run_created_at": row.get("run_created_at"),
|
||||
"run_detail_href": (
|
||||
f"/awooop/runs/{run_id}?project_id={project_id}"
|
||||
if run_id and project_id
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_callback_reply_audit_summary(
|
||||
db: Any,
|
||||
*,
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi import HTTPException
|
||||
import src.services.platform_operator_service as platform_operator_service
|
||||
from src.api.v1.platform.operator_runs import (
|
||||
AiRouteStatusResponse,
|
||||
ListAiAlertCardsResponse,
|
||||
ListApprovalsResponse,
|
||||
ListCallbackRepliesResponse,
|
||||
ListCicdEventsResponse,
|
||||
@@ -31,6 +32,8 @@ from src.services.platform_operator_service import (
|
||||
_cicd_duration_seconds,
|
||||
_cicd_event_item_from_row,
|
||||
_collect_run_incident_ids,
|
||||
_ai_alert_card_delivery_item,
|
||||
_ai_alert_card_delivery_summary_from_row,
|
||||
_is_source_correlation_applied_link,
|
||||
_iter_run_context_batches,
|
||||
_legacy_mcp_timeline_status,
|
||||
@@ -908,6 +911,151 @@ def test_list_callback_replies_response_preserves_callback_evidence() -> None:
|
||||
assert dumped["summary"]["snapshot_status"] == "partial"
|
||||
|
||||
|
||||
def test_ai_alert_card_delivery_item_uses_metadata_without_raw_content() -> None:
|
||||
run_id = UUID("5c0306e0-591a-5445-9a33-80f499426b38")
|
||||
message_id = UUID("66cdb6ad-46a4-48f5-9d3b-b1ac9c0b2e92")
|
||||
item = _ai_alert_card_delivery_item({
|
||||
"message_id": message_id,
|
||||
"run_id": run_id,
|
||||
"project_id": "awoooi",
|
||||
"channel_type": "telegram",
|
||||
"message_type": "final",
|
||||
"provider_message_id": "13152",
|
||||
"send_status": "sent",
|
||||
"send_error": None,
|
||||
"queued_at": datetime(2026, 6, 25, 9, 40, 0),
|
||||
"sent_at": datetime(2026, 6, 25, 9, 40, 5),
|
||||
"triggered_by_state": "legacy_gateway",
|
||||
"alert_card": {
|
||||
"schema_version": "ai_automation_alert_card_mirror_v1",
|
||||
"card_schema": "ai_automation_alert_card_v1",
|
||||
"event_type": "wazuh_dashboard_api_readback_degraded",
|
||||
"lane": "siem_observability_readback_degraded",
|
||||
"target": "wazuh_dashboard_api",
|
||||
"gates": ["candidate_only", "runtime_write_gate=0"],
|
||||
"candidate_only": True,
|
||||
"runtime_write_gate_count": 0,
|
||||
"delivery_receipt_readback_required": True,
|
||||
},
|
||||
"source_refs": {
|
||||
"alert_ids": ["wazuh_dashboard_api_readback_degraded"],
|
||||
"fingerprints": [
|
||||
"ai_automation_alert_card:wazuh_dashboard_api_readback_degraded:siem_observability_readback_degraded"
|
||||
],
|
||||
},
|
||||
"run_state": "completed",
|
||||
"agent_id": "legacy-telegram-gateway",
|
||||
"run_created_at": datetime(2026, 6, 25, 9, 39, 0),
|
||||
})
|
||||
|
||||
assert item["event_type"] == "wazuh_dashboard_api_readback_degraded"
|
||||
assert item["lane"] == "siem_observability_readback_degraded"
|
||||
assert item["target"] == "wazuh_dashboard_api"
|
||||
assert item["runtime_write_gate_count"] == 0
|
||||
assert item["runtime_write_allowed"] is False
|
||||
assert item["delivery_receipt_readback_required"] is True
|
||||
assert item["source_refs"]["alert_ids"] == [
|
||||
"wazuh_dashboard_api_readback_degraded"
|
||||
]
|
||||
assert "content_preview" not in item
|
||||
assert "content_redacted" not in item
|
||||
assert item["run_detail_href"].endswith("project_id=awoooi")
|
||||
|
||||
|
||||
def test_ai_alert_card_delivery_summary_keeps_no_false_green_status() -> None:
|
||||
summary = _ai_alert_card_delivery_summary_from_row(
|
||||
{
|
||||
"total": 2,
|
||||
"sent_total": 1,
|
||||
"failed_total": 1,
|
||||
"pending_total": 0,
|
||||
"shadow_total": 0,
|
||||
"delivery_receipt_required_total": 2,
|
||||
"runtime_write_gate_open_count": 0,
|
||||
"latest_sent_at": datetime(2026, 6, 25, 9, 40, 5),
|
||||
"latest_queued_at": datetime(2026, 6, 25, 9, 40, 0),
|
||||
},
|
||||
project_id="awoooi",
|
||||
event_type="wazuh_dashboard_api_readback_degraded",
|
||||
lane="siem_observability_readback_degraded",
|
||||
)
|
||||
|
||||
assert summary["schema_version"] == "awooop_ai_alert_card_delivery_readback_v1"
|
||||
assert summary["status"] == "delivery_failure_observed"
|
||||
assert summary["delivery_receipt_required_total"] == 2
|
||||
assert summary["runtime_write_gate_open_count"] == 0
|
||||
assert summary["runtime_write_allowed"] is False
|
||||
assert summary["production_write_count"] == 0
|
||||
|
||||
|
||||
def test_list_ai_alert_cards_response_preserves_delivery_metadata() -> None:
|
||||
run_id = UUID("5c0306e0-591a-5445-9a33-80f499426b38")
|
||||
message_id = UUID("66cdb6ad-46a4-48f5-9d3b-b1ac9c0b2e92")
|
||||
response = ListAiAlertCardsResponse.model_validate({
|
||||
"items": [
|
||||
{
|
||||
"message_id": message_id,
|
||||
"run_id": run_id,
|
||||
"project_id": "awoooi",
|
||||
"event_at": datetime(2026, 6, 25, 9, 40, 5),
|
||||
"channel_type": "telegram",
|
||||
"message_type": "final",
|
||||
"send_status": "sent",
|
||||
"send_error": None,
|
||||
"provider_message_id": "13152",
|
||||
"triggered_by_state": "legacy_gateway",
|
||||
"event_type": "wazuh_dashboard_api_readback_degraded",
|
||||
"lane": "siem_observability_readback_degraded",
|
||||
"target": "wazuh_dashboard_api",
|
||||
"gates": ["candidate_only", "runtime_write_gate=0"],
|
||||
"runtime_write_gate_count": 0,
|
||||
"runtime_write_allowed": False,
|
||||
"candidate_only": True,
|
||||
"delivery_receipt_readback_required": True,
|
||||
"source_refs": {
|
||||
"alert_ids": ["wazuh_dashboard_api_readback_degraded"],
|
||||
},
|
||||
"run_state": "completed",
|
||||
"agent_id": "legacy-telegram-gateway",
|
||||
"run_created_at": datetime(2026, 6, 25, 9, 39, 0),
|
||||
"run_detail_href": (
|
||||
"/awooop/runs/5c0306e0-591a-5445-9a33-80f499426b38"
|
||||
"?project_id=awoooi"
|
||||
),
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"summary": {
|
||||
"schema_version": "awooop_ai_alert_card_delivery_readback_v1",
|
||||
"project_id": "awoooi",
|
||||
"event_type": "wazuh_dashboard_api_readback_degraded",
|
||||
"lane": "siem_observability_readback_degraded",
|
||||
"status": "observed",
|
||||
"total": 1,
|
||||
"sent_total": 1,
|
||||
"failed_total": 0,
|
||||
"pending_total": 0,
|
||||
"shadow_total": 0,
|
||||
"delivery_receipt_required_total": 1,
|
||||
"runtime_write_gate_open_count": 0,
|
||||
"runtime_write_allowed": False,
|
||||
"latest_sent_at": datetime(2026, 6, 25, 9, 40, 5),
|
||||
"latest_queued_at": datetime(2026, 6, 25, 9, 40, 0),
|
||||
"production_write_count": 0,
|
||||
},
|
||||
})
|
||||
|
||||
dumped = response.model_dump(mode="json")
|
||||
assert dumped["items"][0]["event_type"] == (
|
||||
"wazuh_dashboard_api_readback_degraded"
|
||||
)
|
||||
assert dumped["items"][0]["runtime_write_allowed"] is False
|
||||
assert dumped["summary"]["delivery_receipt_required_total"] == 1
|
||||
assert dumped["summary"]["production_write_count"] == 0
|
||||
|
||||
|
||||
def test_list_callback_replies_keeps_audit_summary_separate_from_km_summary() -> None:
|
||||
source = inspect.getsource(platform_operator_service.list_callback_replies)
|
||||
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
## 2026-06-25|AwoooP AI 事件卡 delivery readback API
|
||||
|
||||
**背景**:上一段已讓 Telegram outbound mirror 保存 `ai_automation_alert_card_mirror_v1` metadata,但尚未有正式 readback API 可查「Wazuh Dashboard/API 讀回退化事件卡是否已送出、是否失敗、是否仍只停在 source-side」。本輪補只讀 API contract,不實發 Telegram、不修改 Wazuh、不寫 incident。
|
||||
|
||||
**完成**:
|
||||
- 新增 `GET /api/v1/platform/runs/ai-alert-cards`,支援 `project_id`、`event_type`、`lane`、`page`、`per_page`、`refresh`。
|
||||
- service 層只查 `awooop_outbound_message.source_envelope ? 'ai_automation_alert_card'`,不新增 DB migration。
|
||||
- 回傳 `awooop_ai_alert_card_delivery_readback_v1` summary:`sent_total`、`failed_total`、`pending_total`、`shadow_total`、`delivery_receipt_required_total`、`runtime_write_gate_open_count`、`production_write_count=0`。
|
||||
- item 只回 metadata、source refs、send status、run ref,不回完整 Telegram text、raw alert、raw Wazuh payload、內網 URL 或主機路徑。
|
||||
- AwoooP 通知模型與 Wazuh agent disappearance readback 文件已同步 API 路徑與禁止解讀。
|
||||
|
||||
**驗證**:
|
||||
- `DATABASE_URL=postgresql://postgres:postgres@localhost:5432/awoooi_test pytest apps/api/tests/test_awooop_operator_timeline_labels.py apps/api/tests/test_telegram_gateway_error_sanitizer.py apps/api/tests/test_telegram_message_templates.py -q`:`144 passed`。
|
||||
- `python3 -m py_compile apps/api/src/services/platform_operator_service.py apps/api/src/api/v1/platform/operator_runs.py apps/api/src/services/telegram_gateway.py`:通過。
|
||||
- `python3 scripts/security/security-mirror-progress-guard.py --root .`:通過。
|
||||
- `git diff --check`:通過。
|
||||
|
||||
**完成度同步**:
|
||||
- AwoooP AI 事件卡 delivery readback API source-side:`100%`。
|
||||
- Wazuh P0-D alert card / delivery readiness:`82% -> 88%` source-side、`0%` production receipt。
|
||||
- SOC / Wazuh no-false-green 納管:`56% -> 60%`。
|
||||
- production deploy、live outbound readback、IwoooS 前台顯示、Wazuh manager registry 驗收、Dashboard stored API 修復:仍維持 `0%`。
|
||||
|
||||
**邊界**:本輪沒有送 Telegram、沒有 DB migration、沒有 runtime deploy、沒有 Wazuh / 112 / host / Nginx / Docker / firewall / secret 寫入,也沒有 active scan。
|
||||
|
||||
## 2026-06-25|AwoooP mirror 事件卡 metadata 讀回基礎
|
||||
|
||||
**背景**:Wazuh Dashboard/API 讀回退化事件卡已能把 raw 429/500 轉成 `ai_automation_alert_card_v1`,但若 AwoooP outbound mirror 只保存一般文字,後續 delivery receipt 與 timeline 仍難以用結構化方式查詢 Wazuh lane / gate。
|
||||
|
||||
@@ -163,6 +163,7 @@ Live 2026-05-12 evidence shows this gate is not yet green:
|
||||
- `append_incident_update()` 對相同的「AI 自動修復失敗 / AI 診斷工具失敗」摘要增加 10 分鐘跨 incident 去重;每個 incident 仍會移除原卡危險按鈕,但 Telegram 不再重複 reply 同一個失敗摘要。
|
||||
- `TelegramGateway._send_request()` 對成功送出的 legacy `sendMessage` 增加 AwoooP `awooop_outbound_message` 鏡像。鏡像失敗只記錄 `telegram_outbound_mirror_failed`,不能影響 Telegram 正常送達。
|
||||
- `ai_automation_alert_card_v1` 送出後,AwoooP mirror envelope 需帶 `ai_automation_alert_card_mirror_v1` metadata,至少包含 `event_type`、`lane`、`target`、`gates`、`runtime_write_gate_count=0` 與 `delivery_receipt_readback_required=true`;不得把 raw alert、完整 Telegram text、token、內網 URL、主機路徑或 raw Wazuh payload 放進 envelope。
|
||||
- `GET /api/v1/platform/runs/ai-alert-cards` 只讀查詢 `ai_automation_alert_card_v1` delivery readback,可用 `project_id`、`event_type`、`lane` 篩選;此路徑只回 metadata、source refs 與 send status,不回完整 Telegram text,也不代表 Telegram 已實發或 Wazuh registry 已驗收。
|
||||
- 成本告警、審批執行結果、自愈 rollback 提案已由 direct Bot API 改走 `TelegramGateway._send_request()`,避免繞過 outbound mirror。
|
||||
- `telegram_gateway.py` 內部歷史直打 `sendMessage` 路徑已收斂;多 Bot `_send_as_bot()` 因需指定 token 保留 direct HTTP,但成功後同樣鏡像到 `awooop_outbound_message`。
|
||||
- 既有 `詳情 / 重診 / 歷史` 按鈕保留,讓 Telegram 保持輕量,細節回到控制台。
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
| P0-A | Wazuh manager agent registry 只讀驗收 | owner 提供脫敏 `agent_total / active / disconnected / last_seen` ref,或經 server-side secret metadata 啟用 IwoooS 只讀 API | `40%` |
|
||||
| P0-B | Dashboard stored API / rate-limit / TLS trust 修復 gate | 查明 `/api/check-stored-api` 429/500 根因;維修前有 owner、rollback、postcheck;維修後 Dashboard 與 API count 一致 | `35%` |
|
||||
| P0-C | IwoooS live metadata route 正式部署 | `/api/iwooos/wazuh` 不再 404,回傳 schema `iwooos_wazuh_readonly_status_v1`,不洩漏 agent identity / internal IP / secret | `55%` source-side、`0%` production |
|
||||
| P0-D | Wazuh agent disappearance alert card | 產出 `ai_automation_alert_card_v1`,包含 agent count delta、Dashboard API status、manager health、next gate、owner;本輪已新增 `wazuh_dashboard_api_readback_degraded` formatter / test / guard | `70%` source-side、`0%` delivery receipt |
|
||||
| P0-D | Wazuh agent disappearance alert card | 產出 `ai_automation_alert_card_v1`,包含 agent count delta、Dashboard API status、manager health、next gate、owner;本輪已新增 `wazuh_dashboard_api_readback_degraded` formatter / test / guard 與 AwoooP `/runs/ai-alert-cards` delivery readback contract | `88%` source-side、`0%` production receipt |
|
||||
| P0-E | 112/Wazuh owner response | 回覆 owner role/team、decision、reason、affected scope、redacted evidence refs、rollback owner、followup owner | `0%` |
|
||||
| P1-A | 110/188 agent receipt heartbeat | 每台 host 定期只讀確認 service active、manager target、1514 established、last evidence ref | `45%` |
|
||||
| P1-B | Dashboard no-false-green | Dashboard 429/500 或 Wazuh API check failure 要進 IwoooS incident,不可顯示綠燈 | `15%` |
|
||||
@@ -84,7 +84,7 @@
|
||||
1. 請 Wazuh/112 owner 補脫敏 agent registry evidence:`agent_total`、`active`、`disconnected`、`never_connected`、`last_seen` 時間窗,不提供密碼或 raw payload。
|
||||
2. 啟用 IwoooS `/api/iwooos/wazuh` 前,先完成 production route readback、server-side env owner、secret source metadata、readonly account scope 與 rollback owner。
|
||||
3. 若 owner 批准維修 Dashboard stored API,必須先做 read-only preflight:rate-limit 現況、stored API 指向、TLS trust、API user scope、Dashboard 與 manager 版本、回滾方式。
|
||||
4. 補 IwoooS AI 事件卡正式 readback:source-side formatter 已能把 Dashboard/API mismatch 分類為 `wazuh_dashboard_api_readback_degraded`;下一步需接 delivery receipt、AwoooP timeline 與 IwoooS 前台 readback。
|
||||
4. 補 IwoooS AI 事件卡正式 readback:source-side formatter 已能把 Dashboard/API mismatch 分類為 `wazuh_dashboard_api_readback_degraded`,AwoooP 已有 `/api/v1/platform/runs/ai-alert-cards` 只讀 delivery readback contract;下一步需 production deploy、live outbound readback、AwoooP timeline 顯示與 IwoooS 前台 readback。
|
||||
|
||||
## 7. 完成度
|
||||
|
||||
@@ -92,5 +92,5 @@
|
||||
- 真正 agent registry 驗收:`0%`。
|
||||
- IwoooS live readback production:`0%`。
|
||||
- Dashboard stored API 修復:`0%`。
|
||||
- SOC / Wazuh no-false-green 納管:`52%`。
|
||||
- SOC / Wazuh no-false-green 納管:`60%`。
|
||||
- active response / host write / auto block:`0%`,保持關閉。
|
||||
|
||||
Reference in New Issue
Block a user