feat(awooop): surface source dossier coverage
All checks were successful
Code Review / ai-code-review (push) Successful in 9s
CD Pipeline / tests (push) Successful in 1m8s
CD Pipeline / build-and-deploy (push) Successful in 3m52s
CD Pipeline / post-deploy-checks (push) Successful in 1m34s

This commit is contained in:
Your Name
2026-05-18 19:01:28 +08:00
parent fbde48438b
commit 213523c77d
6 changed files with 608 additions and 1 deletions

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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,
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<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">
<ListChecks className="h-4 w-4 text-[#1f5b9b]" aria-hidden="true" />
<div>
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
<p className="text-xs text-[#77736a]">{t("subtitle")}</p>
</div>
</div>
<span className="border border-[#9bb6d9] bg-[#eef5ff] px-2 py-0.5 text-xs font-semibold text-[#1f5b9b]">
{t("total", { count: summary?.source_count ?? 0 })}
</span>
</div>
{error ? (
<div className="px-4 py-4 text-sm text-[#9f2f25]">
{t("error", { error })}
</div>
) : (
<>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-3 xl:grid-cols-6">
{metrics.map((item) => (
<div key={item.label} className="bg-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
<div className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
{item.value}
</div>
</div>
<span
className={cn(
"flex h-8 w-8 items-center justify-center border",
item.className
)}
>
<SearchCheck className="h-4 w-4" aria-hidden="true" />
</span>
</div>
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">{item.detail}</p>
</div>
))}
</div>
{providers.length === 0 ? (
<div className="px-4 py-4 text-sm text-[#5f5b52]">
{t("empty")}
</div>
) : (
<div className="grid gap-px bg-[#eee9dd] md:grid-cols-2 xl:grid-cols-4">
{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 (
<article key={provider.provider} className="bg-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-mono text-xs font-semibold text-[#141413]">
{provider.provider}
</p>
<p className="mt-1 text-xs text-[#77736a]">
{t("provider.latest", { time: providerLatest })}
</p>
</div>
<span className="shrink-0 border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5 font-mono text-xs text-[#5f5b52]">
{provider.total}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs leading-5 text-[#5f5b52]">
<p>{t("provider.refs", { count: provider.source_ref_total })}</p>
<p>{t("provider.missing", { count: provider.missing_source_refs_total })}</p>
<p>{t("provider.redacted", { count: provider.redacted_total })}</p>
<p>{t("provider.duplicates", { count: provider.duplicate_total })}</p>
</div>
</article>
);
})}
</div>
)}
</>
)}
</section>
);
}
function GroupedAlertEventsPanel({ events }: { events: PlatformEvent[] }) {
return (
<section className="border border-[#e0ddd4] bg-white">
@@ -867,6 +1051,8 @@ export default function RunsPage() {
const tCallback = useTranslations("awooop.callbackReply");
const [runs, setRuns] = useState<Run[]>([]);
const [groupedEvents, setGroupedEvents] = useState<PlatformEvent[]>([]);
const [dossierCoverage, setDossierCoverage] = useState<DossierCoverageResponse | null>(null);
const [dossierCoverageError, setDossierCoverageError] = useState<string | null>(null);
const [callbackEvents, setCallbackEvents] = useState<CallbackReplyEvent[]>([]);
const [callbackEventsTotal, setCallbackEventsTotal] = useState(0);
const [callbackEventsError, setCallbackEventsError] = useState<string | null>(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() {
})}
</section>
<SourceDossierCoveragePanel
coverage={dossierCoverage}
error={dossierCoverageError}
/>
<GroupedAlertEventsPanel events={groupedEvents} />
<CallbackReplyEvidencePanel