From 213523c77d883ac0475541eeeb87d4ece74ce93e Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 18 May 2026 19:01:28 +0800 Subject: [PATCH] feat(awooop): surface source dossier coverage --- apps/api/src/api/v1/platform/events.py | 61 +++++- .../services/channel_event_dossier_service.py | 154 +++++++++++++ .../test_channel_event_dossier_service.py | 128 +++++++++++ apps/web/messages/en.json | 30 +++ apps/web/messages/zh-TW.json | 30 +++ .../web/src/app/[locale]/awooop/runs/page.tsx | 206 ++++++++++++++++++ 6 files changed, 608 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/v1/platform/events.py b/apps/api/src/api/v1/platform/events.py index d8d7b41c..9c5284dc 100644 --- a/apps/api/src/api/v1/platform/events.py +++ b/apps/api/src/api/v1/platform/events.py @@ -13,7 +13,10 @@ from uuid import UUID from fastapi import APIRouter, Query from pydantic import BaseModel -from src.services.channel_event_dossier_service import fetch_channel_event_dossier +from src.services.channel_event_dossier_service import ( + fetch_channel_event_dossier, + fetch_channel_event_dossier_coverage, +) from src.services.platform_operator_service import list_recent_channel_events router = APIRouter() @@ -77,6 +80,41 @@ class ChannelEventDossierResponse(BaseModel): summary: ChannelEventDossierSummary +class ChannelEventProviderCoverage(BaseModel): + provider: str + total: int + duplicate_total: int + redacted_total: int + source_ref_total: int + missing_source_refs_total: int + sentry_ref_total: int + signoz_ref_total: int + alert_ref_total: int + latest_received_at: datetime | None + + +class ChannelEventDossierCoverageSummary(BaseModel): + source_count: int + source_envelope_total: int + missing_source_envelope_total: int + with_source_refs_total: int + missing_source_refs_total: int + duplicate_total: int + redacted_total: int + source_ref_total: int + sentry_ref_total: int + signoz_ref_total: int + alert_ref_total: int + latest_received_at: datetime | None + + +class ChannelEventDossierCoverageResponse(BaseModel): + project_id: str + limit: int + summary: ChannelEventDossierCoverageSummary + providers: list[ChannelEventProviderCoverage] + + @router.get( "/events/dossier", response_model=ChannelEventDossierResponse, @@ -100,6 +138,27 @@ async def get_event_dossier( ) +@router.get( + "/events/dossier/coverage", + response_model=ChannelEventDossierCoverageResponse, + summary="查詢 Channel Event 來源卷宗覆蓋率", + description=( + "返回近期 inbound event 的 source_envelope / source_refs / 去重 / " + "Sentry / SignOz 關聯覆蓋率,供 AwoooP Run List 顯示告警是否已入庫。" + ), +) +async def get_event_dossier_coverage( + project_id: str | None = Query(None, description="租戶 ID(可選)"), + provider: str | None = Query(None, description="provider(可選,如 sentry / signoz)"), + limit: int = Query(100, ge=1, le=200, description="最多納入統計筆數"), +) -> dict[str, Any]: + return await fetch_channel_event_dossier_coverage( + project_id=project_id, + provider=provider, + limit=limit, + ) + + @router.get( "/events/recent", response_model=RecentEventsResponse, diff --git a/apps/api/src/services/channel_event_dossier_service.py b/apps/api/src/services/channel_event_dossier_service.py index 0c101870..2b9c8069 100644 --- a/apps/api/src/services/channel_event_dossier_service.py +++ b/apps/api/src/services/channel_event_dossier_service.py @@ -16,6 +16,7 @@ from sqlalchemy import text from src.db.base import get_db_context _MAX_DOSSIER_EVENTS = 50 +_MAX_COVERAGE_EVENTS = 200 def _as_dict(value: Any) -> dict[str, Any]: @@ -32,6 +33,106 @@ def _compact_ref_count(source_refs: dict[str, Any]) -> int: return total +def _ref_count(source_refs: dict[str, Any], key: str) -> int: + value = source_refs.get(key) + if isinstance(value, list): + return len(value) + return 1 if value else 0 + + +def build_dossier_coverage( + rows: list[dict[str, Any]], + *, + project_id: str, + limit: int, +) -> dict[str, Any]: + """Summarize recent inbound source envelopes for console coverage checks.""" + events = [build_dossier_event(row) for row in rows] + provider_map: dict[str, dict[str, Any]] = {} + source_envelope_total = 0 + with_source_refs_total = 0 + sentry_ref_total = 0 + signoz_ref_total = 0 + alert_ref_total = 0 + + for row, event in zip(rows, events, strict=False): + envelope = _as_dict(row.get("source_envelope")) + if envelope: + source_envelope_total += 1 + + source_refs = _as_dict(event.get("source_refs")) + source_ref_count = int(event.get("source_ref_count") or 0) + if source_ref_count > 0: + with_source_refs_total += 1 + + provider = str(event.get("provider") or row.get("channel_type") or "unknown") + provider_item = provider_map.setdefault( + provider, + { + "provider": provider, + "total": 0, + "duplicate_total": 0, + "redacted_total": 0, + "source_ref_total": 0, + "missing_source_refs_total": 0, + "sentry_ref_total": 0, + "signoz_ref_total": 0, + "alert_ref_total": 0, + "latest_received_at": None, + }, + ) + provider_item["total"] += 1 + provider_item["source_ref_total"] += source_ref_count + if event.get("is_duplicate"): + provider_item["duplicate_total"] += 1 + if event.get("has_redacted_content"): + provider_item["redacted_total"] += 1 + if source_ref_count <= 0: + provider_item["missing_source_refs_total"] += 1 + + event_sentry_refs = _ref_count(source_refs, "sentry_issue_ids") + event_signoz_refs = _ref_count(source_refs, "signoz_alerts") + event_alert_refs = _ref_count(source_refs, "alert_ids") + sentry_ref_total += event_sentry_refs + signoz_ref_total += event_signoz_refs + alert_ref_total += event_alert_refs + provider_item["sentry_ref_total"] += event_sentry_refs + provider_item["signoz_ref_total"] += event_signoz_refs + provider_item["alert_ref_total"] += event_alert_refs + provider_item["latest_received_at"] = ( + provider_item["latest_received_at"] or event.get("received_at") + ) + + duplicate_total = sum(1 for event in events if event.get("is_duplicate")) + redacted_total = sum(1 for event in events if event.get("has_redacted_content")) + source_ref_total = sum(int(event.get("source_ref_count") or 0) for event in events) + missing_source_refs_total = len(events) - with_source_refs_total + missing_source_envelope_total = len(events) - source_envelope_total + + return { + "project_id": project_id, + "limit": limit, + "summary": { + "source_count": len(events), + "source_envelope_total": source_envelope_total, + "missing_source_envelope_total": missing_source_envelope_total, + "with_source_refs_total": with_source_refs_total, + "missing_source_refs_total": missing_source_refs_total, + "duplicate_total": duplicate_total, + "redacted_total": redacted_total, + "source_ref_total": source_ref_total, + "sentry_ref_total": sentry_ref_total, + "signoz_ref_total": signoz_ref_total, + "alert_ref_total": alert_ref_total, + "latest_received_at": events[0].get("received_at") if events else None, + }, + "providers": sorted( + provider_map.values(), + key=lambda item: (-int(item.get("total") or 0), str(item.get("provider") or "")), + ), + } + + def build_dossier_event(row: dict[str, Any]) -> dict[str, Any]: """Normalize a DB row into the front-end event dossier shape.""" envelope = _as_dict(row.get("source_envelope")) @@ -136,3 +237,56 @@ async def fetch_channel_event_dossier( "source_ref_total": sum(int(event.get("source_ref_count") or 0) for event in events), }, } + + +async def fetch_channel_event_dossier_coverage( + *, + project_id: str | None, + provider: str | None, + limit: int, +) -> dict[str, Any]: + """Fetch a read-only coverage summary for recent inbound channel events.""" + effective_project_id = project_id or "awoooi" + safe_limit = max(1, min(limit, _MAX_COVERAGE_EVENTS)) + where_clauses = ["project_id = :project_id"] + params: dict[str, Any] = { + "project_id": effective_project_id, + "limit": safe_limit, + } + if provider: + where_clauses.append( + "COALESCE(NULLIF(source_envelope->>'provider', ''), " + "split_part(provider_event_id, ':', 1), channel_type) = :provider" + ) + params["provider"] = provider + + async with get_db_context(effective_project_id) as db: + result = await db.execute( + text(f""" + SELECT + event_id, + project_id, + channel_type, + provider_event_id, + content_hash, + content_preview, + content_redacted, + redaction_version, + source_envelope, + is_duplicate, + provider_ts, + received_at + FROM awooop_conversation_event + WHERE {" AND ".join(where_clauses)} + ORDER BY received_at DESC + LIMIT :limit + """), + params, + ) + rows = [dict(row) for row in result.mappings().all()] + + return build_dossier_coverage( + rows, + project_id=effective_project_id, + limit=safe_limit, + ) diff --git a/apps/api/tests/test_channel_event_dossier_service.py b/apps/api/tests/test_channel_event_dossier_service.py index dee22bab..3a3545b1 100644 --- a/apps/api/tests/test_channel_event_dossier_service.py +++ b/apps/api/tests/test_channel_event_dossier_service.py @@ -7,7 +7,9 @@ from uuid import UUID from src.services import channel_event_dossier_service from src.services.channel_event_dossier_service import ( build_dossier_event, + build_dossier_coverage, fetch_channel_event_dossier, + fetch_channel_event_dossier_coverage, ) @@ -54,6 +56,85 @@ def test_build_dossier_event_summarizes_source_envelope() -> None: assert event["content_sha256"] == "a" * 64 +def test_build_dossier_coverage_summarizes_recent_sources() -> None: + coverage = build_dossier_coverage( + [ + { + "event_id": "event-1", + "project_id": "awoooi", + "channel_type": "internal", + "provider_event_id": "sentry:received:issue-1", + "content_hash": "h" * 64, + "content_preview": "Sentry issue", + "content_redacted": "Sentry issue redacted", + "redaction_version": "audit_sink_v1", + "source_envelope": { + "provider": "sentry", + "stage": "received", + "source_refs": { + "sentry_issue_ids": ["issue-1"], + "alert_ids": ["sentry:received:issue-1"], + "fingerprints": ["fingerprint-1"], + }, + }, + "is_duplicate": False, + "provider_ts": None, + "received_at": "2026-05-13T13:46:00", + }, + { + "event_id": "event-2", + "project_id": "awoooi", + "channel_type": "internal", + "provider_event_id": "signoz:received:alert-1", + "content_hash": "i" * 64, + "content_preview": "SignOz alert", + "content_redacted": None, + "redaction_version": "audit_sink_v1", + "source_envelope": { + "provider": "signoz", + "stage": "received", + "source_refs": { + "signoz_alerts": ["alert-1"], + "alert_ids": ["signoz:received:alert-1"], + }, + }, + "is_duplicate": True, + "provider_ts": None, + "received_at": "2026-05-13T13:45:00", + }, + { + "event_id": "event-3", + "project_id": "awoooi", + "channel_type": "telegram", + "provider_event_id": "telegram:callback:1", + "content_hash": None, + "content_preview": "Callback", + "content_redacted": None, + "redaction_version": "audit_sink_v1", + "source_envelope": {}, + "is_duplicate": False, + "provider_ts": None, + "received_at": "2026-05-13T13:44:00", + }, + ], + project_id="awoooi", + limit=100, + ) + + assert coverage["project_id"] == "awoooi" + assert coverage["summary"]["source_count"] == 3 + assert coverage["summary"]["source_envelope_total"] == 2 + assert coverage["summary"]["missing_source_envelope_total"] == 1 + assert coverage["summary"]["with_source_refs_total"] == 2 + assert coverage["summary"]["missing_source_refs_total"] == 1 + assert coverage["summary"]["duplicate_total"] == 1 + assert coverage["summary"]["redacted_total"] == 1 + assert coverage["summary"]["sentry_ref_total"] == 1 + assert coverage["summary"]["signoz_ref_total"] == 1 + assert coverage["summary"]["alert_ref_total"] == 2 + assert coverage["providers"][0]["provider"] == "sentry" + + @pytest.mark.asyncio async def test_fetch_channel_event_dossier_requires_source() -> None: with pytest.raises(HTTPException) as exc_info: @@ -114,3 +195,50 @@ async def test_fetch_channel_event_dossier_uses_typed_run_filter(monkeypatch) -> "run_id": str(run_id), "limit": 20, } + + +@pytest.mark.asyncio +async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeMappings: + def all(self) -> list[dict[str, object]]: + return [] + + class FakeResult: + def mappings(self) -> FakeMappings: + return FakeMappings() + + class FakeDb: + async def execute(self, statement, params): # noqa: ANN001 + captured["sql"] = str(statement) + captured["params"] = params + return FakeResult() + + class FakeContext: + async def __aenter__(self) -> FakeDb: + return FakeDb() + + async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001 + return None + + monkeypatch.setattr( + channel_event_dossier_service, + "get_db_context", + lambda _project_id: FakeContext(), + ) + + result = await fetch_channel_event_dossier_coverage( + project_id=None, + provider="sentry", + limit=500, + ) + + assert result["project_id"] == "awoooi" + assert result["limit"] == 200 + assert "source_envelope->>'provider'" in str(captured["sql"]) + assert captured["params"] == { + "project_id": "awoooi", + "provider": "sentry", + "limit": 200, + } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 953a4b52..f2e651e5 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1871,6 +1871,36 @@ "approvalNoEvidenceDetail": "Approval still lacks AI evidence; inspect Run Timeline" } }, + "sourceDossierCoverage": { + "title": "Source Dossier Coverage", + "subtitle": "Inbound alert dossiers, dedupe, and Sentry / SignOz references", + "total": "{count} items", + "empty": "No recent source event dossiers.", + "error": "Source dossier coverage failed to load: {error}", + "metrics": { + "sources": "Source events", + "refs": "Reference index", + "missingRefs": "Missing refs", + "duplicates": "Duplicate events", + "sentry": "Sentry refs", + "signoz": "SignOz refs" + }, + "details": { + "latest": "Latest {time}", + "withRefs": "{count} items with source refs", + "missingEnvelope": "{count} items missing source envelope", + "redacted": "{count} items redacted", + "alertRefs": "{count} alert refs", + "limit": "Latest {count} item window" + }, + "provider": { + "latest": "Latest {time}", + "refs": "Refs {count}", + "missing": "Missing {count}", + "redacted": "Redacted {count}", + "duplicates": "Duplicates {count}" + } + }, "callbackReply": { "count": "{total} items; fallback {fallback}; failed {failed}", "emptyShort": "No detail / history callback yet", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index bf61da60..2756cadc 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1872,6 +1872,36 @@ "approvalNoEvidenceDetail": "審批前仍缺 AI 證據,需進 Run Timeline 檢查" } }, + "sourceDossierCoverage": { + "title": "來源事件覆蓋率", + "subtitle": "入站告警卷宗、去重與 Sentry / SignOz 關聯狀態", + "total": "{count} 筆", + "empty": "目前沒有近期來源事件卷宗。", + "error": "來源卷宗覆蓋率載入失敗:{error}", + "metrics": { + "sources": "來源事件", + "refs": "關聯索引", + "missingRefs": "缺關聯", + "duplicates": "重複事件", + "sentry": "Sentry refs", + "signoz": "SignOz refs" + }, + "details": { + "latest": "最新 {time}", + "withRefs": "{count} 筆含 source refs", + "missingEnvelope": "{count} 筆缺 source envelope", + "redacted": "{count} 筆已 redacted", + "alertRefs": "{count} 個 alert refs", + "limit": "最近 {count} 筆視窗" + }, + "provider": { + "latest": "最新 {time}", + "refs": "Refs {count}", + "missing": "缺 {count}", + "redacted": "Redacted {count}", + "duplicates": "重複 {count}" + } + }, "callbackReply": { "count": "{total} 筆;fallback {fallback};失敗 {failed}", "emptyShort": "尚無詳情 / 歷史 callback", diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index 09704c82..7e8b6d1c 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -141,6 +141,41 @@ interface RecentEventsResponse { limit: number; } +interface DossierCoverageSummary { + source_count: number; + source_envelope_total: number; + missing_source_envelope_total: number; + with_source_refs_total: number; + missing_source_refs_total: number; + duplicate_total: number; + redacted_total: number; + source_ref_total: number; + sentry_ref_total: number; + signoz_ref_total: number; + alert_ref_total: number; + latest_received_at?: string | null; +} + +interface DossierCoverageProvider { + provider: string; + total: number; + duplicate_total: number; + redacted_total: number; + source_ref_total: number; + missing_source_refs_total: number; + sentry_ref_total: number; + signoz_ref_total: number; + alert_ref_total: number; + latest_received_at?: string | null; +} + +interface DossierCoverageResponse { + project_id: string; + limit: number; + summary: DossierCoverageSummary; + providers: DossierCoverageProvider[]; +} + interface CallbackReplyEvent { message_id: string; run_id: string; @@ -700,6 +735,155 @@ function RunRow({ run }: { run: Run }) { ); } +function SourceDossierCoveragePanel({ + coverage, + error, +}: { + coverage: DossierCoverageResponse | null; + error: string | null; +}) { + const t = useTranslations("awooop.sourceDossierCoverage"); + const summary = coverage?.summary; + const providers = coverage?.providers ?? []; + const latestAt = summary?.latest_received_at + ? new Date(summary.latest_received_at).toLocaleTimeString("zh-TW", { + hour: "2-digit", + minute: "2-digit", + }) + : "--"; + const metrics = [ + { + label: t("metrics.sources"), + value: summary?.source_count ?? 0, + detail: t("details.latest", { time: latestAt }), + className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + }, + { + label: t("metrics.refs"), + value: summary?.source_ref_total ?? 0, + detail: t("details.withRefs", { count: summary?.with_source_refs_total ?? 0 }), + className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + }, + { + label: t("metrics.missingRefs"), + value: summary?.missing_source_refs_total ?? 0, + detail: t("details.missingEnvelope", { + count: summary?.missing_source_envelope_total ?? 0, + }), + className: (summary?.missing_source_refs_total ?? 0) > 0 + ? "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]" + : "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + }, + { + label: t("metrics.duplicates"), + value: summary?.duplicate_total ?? 0, + detail: t("details.redacted", { count: summary?.redacted_total ?? 0 }), + className: "border-[#d8d3c7] bg-white text-[#5f5b52]", + }, + { + label: t("metrics.sentry"), + value: summary?.sentry_ref_total ?? 0, + detail: t("details.alertRefs", { count: summary?.alert_ref_total ?? 0 }), + className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + }, + { + label: t("metrics.signoz"), + value: summary?.signoz_ref_total ?? 0, + detail: t("details.limit", { count: coverage?.limit ?? 0 }), + className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + }, + ]; + + return ( +
+
+
+
+ + {t("total", { count: summary?.source_count ?? 0 })} + +
+ + {error ? ( +
+ {t("error", { error })} +
+ ) : ( + <> +
+ {metrics.map((item) => ( +
+
+
+

{item.label}

+
+ {item.value} +
+
+ + +
+

{item.detail}

+
+ ))} +
+ + {providers.length === 0 ? ( +
+ {t("empty")} +
+ ) : ( +
+ {providers.slice(0, 4).map((provider) => { + const providerLatest = provider.latest_received_at + ? new Date(provider.latest_received_at).toLocaleTimeString("zh-TW", { + hour: "2-digit", + minute: "2-digit", + }) + : "--"; + return ( +
+
+
+

+ {provider.provider} +

+

+ {t("provider.latest", { time: providerLatest })} +

+
+ + {provider.total} + +
+
+

{t("provider.refs", { count: provider.source_ref_total })}

+

{t("provider.missing", { count: provider.missing_source_refs_total })}

+

{t("provider.redacted", { count: provider.redacted_total })}

+

{t("provider.duplicates", { count: provider.duplicate_total })}

+
+
+ ); + })} +
+ )} + + )} +
+ ); +} + function GroupedAlertEventsPanel({ events }: { events: PlatformEvent[] }) { return (
@@ -867,6 +1051,8 @@ export default function RunsPage() { const tCallback = useTranslations("awooop.callbackReply"); const [runs, setRuns] = useState([]); const [groupedEvents, setGroupedEvents] = useState([]); + const [dossierCoverage, setDossierCoverage] = useState(null); + const [dossierCoverageError, setDossierCoverageError] = useState(null); const [callbackEvents, setCallbackEvents] = useState([]); const [callbackEventsTotal, setCallbackEventsTotal] = useState(0); const [callbackEventsError, setCallbackEventsError] = useState(null); @@ -949,6 +1135,21 @@ export default function RunsPage() { setGroupedEvents(Array.isArray(eventsData.events) ? eventsData.events : []); } + const dossierCoverageParams = new URLSearchParams(); + dossierCoverageParams.set("limit", "100"); + if (projectFilter) dossierCoverageParams.set("project_id", projectFilter); + const dossierCoverageRes = await fetch( + `${API_BASE}/api/v1/platform/events/dossier/coverage?${dossierCoverageParams.toString()}` + ); + if (dossierCoverageRes.ok) { + const dossierCoverageData: DossierCoverageResponse = await dossierCoverageRes.json(); + setDossierCoverage(dossierCoverageData); + setDossierCoverageError(null); + } else { + setDossierCoverage(null); + setDossierCoverageError(`HTTP ${dossierCoverageRes.status}`); + } + const callbackParams = new URLSearchParams(); callbackParams.set("per_page", "6"); if (projectFilter) callbackParams.set("project_id", projectFilter); @@ -1161,6 +1362,11 @@ export default function RunsPage() { })}
+ +