feat(awooop): show inbound event dossiers
All checks were successful
Code Review / ai-code-review (push) Successful in 17s
CD Pipeline / tests (push) Successful in 1m19s
CD Pipeline / build-and-deploy (push) Successful in 3m30s
CD Pipeline / post-deploy-checks (push) Successful in 1m34s

This commit is contained in:
Your Name
2026-05-13 21:59:16 +08:00
parent eb73591286
commit 77aace7515
6 changed files with 546 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ 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.platform_operator_service import list_recent_channel_events
router = APIRouter()
@@ -35,6 +36,70 @@ class RecentEventsResponse(BaseModel):
limit: int
class ChannelEventDossierItem(BaseModel):
event_id: UUID
project_id: str
channel_type: str
provider: str | None
stage: str
provider_event_id: str
content_preview: str | None
content_redacted: str | None
has_redacted_content: bool
redaction_version: str | None
source_url: str | None
content_sha256: str | None
content_length: int | None
source_refs: dict[str, Any]
source_ref_count: int
log_correlation: dict[str, Any]
alertname: str | None
severity: str | None
namespace: str | None
target_resource: str | None
fingerprint: str | None
is_duplicate: bool
provider_ts: datetime | None
received_at: datetime
class ChannelEventDossierSummary(BaseModel):
source_count: int
duplicate_total: int
redacted_total: int
source_ref_total: int
class ChannelEventDossierResponse(BaseModel):
events: list[ChannelEventDossierItem]
total: int
limit: int
summary: ChannelEventDossierSummary
@router.get(
"/events/dossier",
response_model=ChannelEventDossierResponse,
summary="查詢 Channel Event 來源卷宗",
description=(
"返回 redacted inbound source envelope供 AwoooP Run Detail 顯示"
"告警來源、source refs、Sentry / SignOz / Alertmanager 關聯與去重狀態。"
),
)
async def get_event_dossier(
project_id: str | None = Query(None, description="租戶 ID可選"),
run_id: UUID | None = Query(None, description="Run ID可選"),
provider_event_id: str | None = Query(None, description="provider_event_id可選"),
limit: int = Query(20, ge=1, le=50, description="最多返回筆數"),
) -> dict[str, Any]:
return await fetch_channel_event_dossier(
project_id=project_id,
run_id=run_id,
provider_event_id=provider_event_id,
limit=limit,
)
@router.get(
"/events/recent",
response_model=RecentEventsResponse,

View File

@@ -0,0 +1,133 @@
"""AwoooP inbound channel event dossier service.
T15c: converts redacted inbound source envelopes into an Operator Console DTO.
The service is read-only and does not mutate incident, run, approval, or
automation state.
"""
from __future__ import annotations
from typing import Any
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import text
from src.db.base import get_db_context
_MAX_DOSSIER_EVENTS = 50
def _as_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _compact_ref_count(source_refs: dict[str, Any]) -> int:
total = 0
for value in source_refs.values():
if isinstance(value, list):
total += len(value)
elif value:
total += 1
return total
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"))
source_refs = _as_dict(envelope.get("source_refs"))
log_correlation = _as_dict(envelope.get("log_correlation"))
content_redacted = row.get("content_redacted")
content_preview = row.get("content_preview")
return {
"event_id": row.get("event_id"),
"project_id": row.get("project_id"),
"channel_type": row.get("channel_type"),
"provider": envelope.get("provider") or row.get("channel_type"),
"stage": envelope.get("stage") or "received",
"provider_event_id": row.get("provider_event_id"),
"content_preview": content_preview,
"content_redacted": content_redacted,
"has_redacted_content": bool(content_redacted),
"redaction_version": row.get("redaction_version"),
"source_url": envelope.get("source_url"),
"content_sha256": envelope.get("content_sha256") or row.get("content_hash"),
"content_length": envelope.get("content_length"),
"source_refs": source_refs,
"source_ref_count": _compact_ref_count(source_refs),
"log_correlation": log_correlation,
"alertname": log_correlation.get("alertname"),
"severity": log_correlation.get("severity"),
"namespace": log_correlation.get("namespace"),
"target_resource": log_correlation.get("target_resource"),
"fingerprint": log_correlation.get("fingerprint"),
"is_duplicate": row.get("is_duplicate"),
"provider_ts": row.get("provider_ts"),
"received_at": row.get("received_at"),
}
async def fetch_channel_event_dossier(
*,
project_id: str | None,
run_id: UUID | None,
provider_event_id: str | None,
limit: int,
) -> dict[str, Any]:
"""Fetch redacted source envelopes for a run or provider event id."""
if run_id is None and not provider_event_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="run_id or provider_event_id is required",
)
effective_project_id = project_id or "awoooi"
safe_limit = max(1, min(limit, _MAX_DOSSIER_EVENTS))
async with get_db_context(effective_project_id) as db:
result = await db.execute(
text("""
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 project_id = :project_id
AND (:run_id IS NULL OR run_id = :run_id)
AND (:provider_event_id IS NULL OR provider_event_id = :provider_event_id)
ORDER BY received_at ASC
LIMIT :limit
"""),
{
"project_id": effective_project_id,
"run_id": run_id,
"provider_event_id": provider_event_id,
"limit": safe_limit,
},
)
rows = [dict(row) for row in result.mappings().all()]
events = [build_dossier_event(row) for row in rows]
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"))
return {
"events": events,
"total": len(events),
"limit": safe_limit,
"summary": {
"source_count": len(events),
"duplicate_total": duplicate_total,
"redacted_total": redacted_total,
"source_ref_total": sum(int(event.get("source_ref_count") or 0) for event in events),
},
}

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import pytest
from fastapi import HTTPException
from src.services.channel_event_dossier_service import (
build_dossier_event,
fetch_channel_event_dossier,
)
def test_build_dossier_event_summarizes_source_envelope() -> None:
event = build_dossier_event({
"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_url": "https://sentry.example.invalid/issues/issue-1",
"content_sha256": "a" * 64,
"content_length": 42,
"source_refs": {
"event_ids": ["issue-1"],
"sentry_issue_ids": ["issue-1", "sentry:received:issue-1"],
"fingerprints": ["sentry-issue-1"],
},
"log_correlation": {
"alertname": "Sentry Issue",
"severity": "error",
"namespace": "sentry",
"target_resource": "frontend",
"fingerprint": "sentry-issue-1",
},
},
"is_duplicate": False,
"provider_ts": None,
"received_at": "2026-05-13T13:46:00",
})
assert event["provider"] == "sentry"
assert event["stage"] == "received"
assert event["alertname"] == "Sentry Issue"
assert event["severity"] == "error"
assert event["source_ref_count"] == 4
assert event["has_redacted_content"] is True
assert event["content_sha256"] == "a" * 64
@pytest.mark.asyncio
async def test_fetch_channel_event_dossier_requires_source() -> None:
with pytest.raises(HTTPException) as exc_info:
await fetch_channel_event_dossier(
project_id="awoooi",
run_id=None,
provider_event_id=None,
limit=20,
)
assert exc_info.value.status_code == 422

View File

@@ -1656,6 +1656,40 @@
"legacyBridge": "Legacy bridge"
}
},
"dossier": {
"title": "Source Event Dossier",
"empty": "This run is not linked to replayable inbound source events yet.",
"content": "Redacted Content",
"sourceRefs": "Source References",
"duplicate": "Duplicate",
"firstSeen": "First seen",
"status": {
"visible": "Recorded in truth-chain",
"empty": "No source"
},
"metrics": {
"sources": "Sources",
"refs": "References",
"redacted": "Redacted",
"duplicates": "Duplicates"
},
"fields": {
"stage": "Stage",
"severity": "Risk",
"namespace": "Namespace",
"target": "Target",
"hash": "Hash"
},
"refs": {
"alertIds": "Alert",
"approvalIds": "Approval",
"eventIds": "Event",
"fingerprints": "Fingerprint",
"incidentIds": "Incident",
"sentryIssueIds": "Sentry",
"signozAlerts": "SignOz"
}
},
"action": {
"eyebrow": "Next Decision",
"approval": {

View File

@@ -1657,6 +1657,40 @@
"legacyBridge": "Legacy bridge"
}
},
"dossier": {
"title": "來源事件卷宗",
"empty": "此 Run 尚未連到可回放的入站來源事件。",
"content": "Redacted 內容",
"sourceRefs": "來源關聯",
"duplicate": "重複事件",
"firstSeen": "首次事件",
"status": {
"visible": "已寫入 truth-chain",
"empty": "尚無來源"
},
"metrics": {
"sources": "來源事件",
"refs": "關聯索引",
"redacted": "Redacted",
"duplicates": "重複"
},
"fields": {
"stage": "階段",
"severity": "風險",
"namespace": "命名空間",
"target": "目標",
"hash": "Hash"
},
"refs": {
"alertIds": "Alert",
"approvalIds": "Approval",
"eventIds": "Event",
"fingerprints": "Fingerprint",
"incidentIds": "Incident",
"sentryIssueIds": "Sentry",
"signozAlerts": "SignOz"
}
},
"action": {
"eyebrow": "下一步判斷",
"approval": {

View File

@@ -15,6 +15,8 @@ import {
ArrowRight,
CheckCircle2,
Clock,
FileSearch,
Link2,
MessageSquareText,
RefreshCw,
Route,
@@ -60,6 +62,43 @@ interface TimelineItem {
metadata?: Record<string, unknown>;
}
interface ChannelEventDossierItem {
event_id: string;
project_id: string;
channel_type: string;
provider?: string | null;
stage: string;
provider_event_id: string;
content_preview?: string | null;
content_redacted?: string | null;
has_redacted_content: boolean;
redaction_version?: string | null;
source_url?: string | null;
content_sha256?: string | null;
content_length?: number | null;
source_refs: Record<string, unknown>;
source_ref_count: number;
log_correlation: Record<string, unknown>;
alertname?: string | null;
severity?: string | null;
namespace?: string | null;
target_resource?: string | null;
fingerprint?: string | null;
is_duplicate: boolean;
provider_ts?: string | null;
received_at: string;
}
interface ChannelEventDossierResponse {
events: ChannelEventDossierItem[];
summary: {
source_count: number;
duplicate_total: number;
redacted_total: number;
source_ref_total: number;
};
}
interface McpGatewayBucket {
agent_id?: string;
tool_name?: string;
@@ -136,6 +175,16 @@ const STATUS_TRANSLATION_KEYS: Record<string, string> = {
waiting_approval: "statuses.waitingApproval",
};
const SOURCE_REF_LABEL_KEYS: Record<string, string> = {
alert_ids: "refs.alertIds",
approval_ids: "refs.approvalIds",
event_ids: "refs.eventIds",
fingerprints: "refs.fingerprints",
incident_ids: "refs.incidentIds",
sentry_issue_ids: "refs.sentryIssueIds",
signoz_alerts: "refs.signozAlerts",
};
const MANUAL_STATES = new Set(["blocked", "cancelled", "error", "failed", "timeout"]);
function formatTime(value: string | null | undefined, locale: string, emptyLabel: string) {
@@ -162,6 +211,22 @@ function itemIcon(kind: string) {
return Route;
}
function firstSourceRefs(sourceRefs: Record<string, unknown>, limit = 6) {
const refs: Array<{ key: string; value: string }> = [];
Object.entries(sourceRefs).forEach(([key, value]) => {
const values = Array.isArray(value) ? value : value ? [value] : [];
values.slice(0, 3).forEach((entry) => {
refs.push({ key, value: String(entry) });
});
});
return refs.slice(0, limit);
}
function shortDigest(value?: string | null) {
if (!value) return null;
return value.length > 16 ? `${value.slice(0, 12)}...${value.slice(-4)}` : value;
}
function RunActionPanel({
run,
counts,
@@ -268,6 +333,142 @@ function RunActionPanel({
);
}
function EventDossierPanel({
dossier,
locale,
emptyLabel,
}: {
dossier?: ChannelEventDossierResponse | null;
locale: string;
emptyLabel: string;
}) {
const t = useTranslations("awooop.runDetail.dossier");
const events = dossier?.events ?? [];
const summary = dossier?.summary;
const metrics = [
{ label: t("metrics.sources"), value: summary?.source_count ?? 0 },
{ label: t("metrics.refs"), value: summary?.source_ref_total ?? 0 },
{ label: t("metrics.redacted"), value: summary?.redacted_total ?? 0 },
{ label: t("metrics.duplicates"), value: summary?.duplicate_total ?? 0 },
];
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">
<FileSearch className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
</div>
<span className={cn("border px-2 py-0.5 text-xs font-semibold", events.length > 0 ? statusClass("received") : statusClass("pending"))}>
{events.length > 0 ? t("status.visible") : t("status.empty")}
</span>
</div>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
{metrics.map((item) => (
<div key={item.label} className="bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
<p className="mt-2 font-mono text-2xl font-semibold text-[#141413]">{item.value}</p>
</div>
))}
</div>
{events.length === 0 ? (
<div className="px-4 py-8 text-sm text-[#77736a]">{t("empty")}</div>
) : (
<div className="divide-y divide-[#eee9dd]">
{events.map((event) => {
const refs = firstSourceRefs(event.source_refs);
const digest = shortDigest(event.content_sha256);
return (
<article key={event.event_id} className="grid gap-4 px-4 py-4 xl:grid-cols-[240px_1fr]">
<div className="space-y-3">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5 font-mono text-xs font-semibold text-[#5f5b52]">
{event.provider ?? event.channel_type}
</span>
<span className={cn("border px-2 py-0.5 text-xs font-semibold", statusClass(event.is_duplicate ? "pending" : "received"))}>
{event.is_duplicate ? t("duplicate") : t("firstSeen")}
</span>
</div>
<h4 className="mt-2 text-sm font-semibold text-[#141413]">
{event.alertname || event.provider_event_id}
</h4>
<p className="mt-1 font-mono text-xs text-[#77736a]">
{formatTime(event.received_at, locale, emptyLabel)}
</p>
</div>
<div className="space-y-2 text-xs">
<DetailLine label={t("fields.stage")} value={event.stage} emptyLabel={emptyLabel} />
<DetailLine label={t("fields.severity")} value={event.severity} emptyLabel={emptyLabel} />
<DetailLine label={t("fields.namespace")} value={event.namespace} emptyLabel={emptyLabel} />
<DetailLine label={t("fields.target")} value={event.target_resource} emptyLabel={emptyLabel} />
<DetailLine label={t("fields.hash")} value={digest} emptyLabel={emptyLabel} />
</div>
</div>
<div className="min-w-0 space-y-3">
<div className="rounded-none border border-[#eee9dd] bg-[#faf9f3] p-3">
<div className="mb-2 text-xs font-semibold text-[#77736a]">{t("content")}</div>
<p className="max-h-28 overflow-hidden whitespace-pre-line break-words text-sm leading-6 text-[#5f5b52]">
{event.content_redacted || event.content_preview || emptyLabel}
</p>
</div>
<div>
<div className="mb-2 text-xs font-semibold text-[#77736a]">{t("sourceRefs")}</div>
{refs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{refs.map((ref) => {
const labelKey = SOURCE_REF_LABEL_KEYS[ref.key];
const label = labelKey ? t(labelKey as never) : ref.key;
return (
<span key={`${ref.key}:${ref.value}`} className="inline-flex max-w-full items-center gap-1 border border-[#d8d3c7] bg-white px-2 py-1 text-xs text-[#5f5b52]">
<span className="shrink-0 font-semibold">{label}</span>
<span className="truncate font-mono">{ref.value}</span>
</span>
);
})}
</div>
) : (
<p className="text-sm text-[#77736a]">{emptyLabel}</p>
)}
</div>
{event.source_url && (
<a
href={event.source_url}
target="_blank"
rel="noreferrer"
className="inline-flex max-w-full items-center gap-2 border border-[#d8d3c7] bg-white px-3 py-2 text-sm text-[#5f5b52] hover:border-[#d97757]"
>
<Link2 className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="truncate">{event.source_url}</span>
</a>
)}
</div>
</article>
);
})}
</div>
)}
</section>
);
}
function DetailLine({
label,
value,
emptyLabel,
}: {
label: string;
value?: string | number | null;
emptyLabel: string;
}) {
return (
<div className="grid grid-cols-[84px_1fr] gap-2">
<span className="font-semibold text-[#77736a]">{label}</span>
<span className="truncate font-mono text-[#141413]">{value ?? emptyLabel}</span>
</div>
);
}
function DetailField({
label,
value,
@@ -427,6 +628,7 @@ export default function RunDetailPage({
const projectId = searchParams.get("project_id") ?? "";
const [detail, setDetail] = useState<RunDetailResponse | null>(null);
const [dossier, setDossier] = useState<ChannelEventDossierResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
@@ -441,6 +643,17 @@ export default function RunDetailPage({
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: RunDetailResponse = await res.json();
setDetail(data);
const dossierProjectId = projectId || data.run?.project_id;
const dossierQuery = new URLSearchParams();
dossierQuery.set("run_id", run_id);
dossierQuery.set("limit", "20");
if (dossierProjectId) dossierQuery.set("project_id", dossierProjectId);
try {
const dossierRes = await fetch(`${API_BASE}/api/v1/platform/events/dossier?${dossierQuery.toString()}`);
setDossier(dossierRes.ok ? await dossierRes.json() as ChannelEventDossierResponse : null);
} catch {
setDossier(null);
}
setLastRefresh(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : t("errors.loadFailed"));
@@ -552,6 +765,8 @@ export default function RunDetailPage({
statusLabel={statusLabel}
/>
<EventDossierPanel dossier={dossier} locale={locale} emptyLabel={t("empty")} />
<section className="grid gap-4 xl:grid-cols-[360px_1fr]">
<aside className="border border-[#e0ddd4] bg-white">
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">