feat(governance): link work items to event history
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 3m35s
CD Pipeline / build-and-deploy (push) Successful in 3m50s
CD Pipeline / post-deploy-checks (push) Successful in 1m42s

This commit is contained in:
Your Name
2026-05-20 11:03:52 +08:00
parent 4a24d3e4fc
commit 739a8e0f78
9 changed files with 193 additions and 11 deletions

View File

@@ -54,6 +54,7 @@ router = APIRouter()
@router.get("/ai/governance/events", response_model=GovernanceEventsResponse)
async def get_governance_events(
event_id: Annotated[list[str] | None, Query(alias="event_id")] = None,
event_type: Annotated[list[str] | None, Query(alias="event_type")] = None,
from_: Annotated[datetime | None, Query(alias="from")] = None,
to: Annotated[datetime | None, Query(alias="to")] = None,
@@ -66,6 +67,7 @@ async def get_governance_events(
查詢 AI 治理事件列表(分頁)。
- event_type: 多值過濾(可重複傳)
- event_id: 多值精準過濾(可重複傳),供 Telegram 詳情 / 歷史與 Work Items 錨點回看
- from / to: ISO 8601 時間範圍URL 傳 from 參數)
- status: resolved / unresolved
- severity: critical / warning / info由 event_type 映射決定)
@@ -74,6 +76,7 @@ async def get_governance_events(
"""
logger.debug(
"governance_events_request",
event_ids=event_id,
event_types=event_type,
from_=from_,
to=to,
@@ -83,6 +86,7 @@ async def get_governance_events(
size=size,
)
return await query_governance_events(
event_ids=event_id,
event_types=event_type,
from_dt=from_,
to_dt=to,

View File

@@ -195,6 +195,7 @@ async def _load_dispatch_ids_for_events(event_ids: list[str]) -> dict[str, list[
async def query_governance_events(
*,
event_ids: list[str] | None = None,
event_types: list[str] | None = None,
from_dt: datetime | None = None,
to_dt: datetime | None = None,
@@ -212,6 +213,14 @@ async def query_governance_events(
async with get_db_context() as db:
stmt = select(AiGovernanceEvent)
normalized_event_ids = [
event_id.strip()
for event_id in (event_ids or [])
if isinstance(event_id, str) and event_id.strip()
]
if normalized_event_ids:
stmt = stmt.where(AiGovernanceEvent.id.in_(normalized_event_ids))
if event_types:
stmt = stmt.where(AiGovernanceEvent.event_type.in_(event_types))

View File

@@ -176,6 +176,25 @@ class TestEventsEndpoint:
assert r.status_code == 200
assert captured["severity"] == "critical"
def test_event_id_filter_passed(self, client):
"""event_id query param 供 Telegram 詳情 / 歷史精準回看."""
fake_response = GovernanceEventsResponse(items=[], total=0, page=1, size=20)
captured: dict = {}
async def mock_query(**kwargs):
captured.update(kwargs)
return fake_response
with patch("src.api.v1.ai_governance.query_governance_events", new=mock_query):
r = client.get(
"/api/v1/ai/governance/events"
"?event_id=evt-001&event_id=evt-002&status=unresolved"
)
assert r.status_code == 200
assert captured["event_ids"] == ["evt-001", "evt-002"]
assert captured["status"] == "unresolved"
def test_invalid_severity_rejected(self, client):
"""非法 severity 值應被拒絕422."""
r = client.get("/api/v1/ai/governance/events?severity=bad_value")

View File

@@ -1526,6 +1526,8 @@
"dateRange": "Date Range",
"status": "Status",
"severity": "Severity",
"eventId": "Event ID",
"eventIdPlaceholder": "Paste governance_event_id",
"clearAll": "Clear All",
"allStatuses": "All Statuses",
"resolved": "Resolved",
@@ -1553,6 +1555,14 @@
"noDispatch": "No dispatch records"
},
"eventType": {
"slo_violation": "SLO Violation",
"governance_slo_data_gap": "SLO Data Gap",
"knowledge_degradation": "KM Needs Update",
"kb_stale": "Stale KM",
"execution_blast_radius": "Execution Blast Radius",
"conservative_mode": "Conservative Mode",
"replay_degraded": "Replay Degraded",
"self_demotion": "AI Self-demotion",
"slo_breach": "SLO Breach",
"accuracy_drop": "Accuracy Drop",
"km_stall": "KM Stall",
@@ -1914,6 +1924,7 @@
"archiveProposal": "Archive candidates: {count} duplicate drafts",
"ownerAction": "Owner action: {action}",
"readOnlyPlan": "Writes on read: {writes}; archive blocked before review: {blocked}",
"openEventHistory": "Open Event History",
"ownerActions": {
"owner_review_canonical_then_archive_duplicates": "Review the canonical draft, then archive duplicates",
"review_canonical_and_archive_duplicate_drafts": "Review canonical and archive duplicate drafts",

View File

@@ -1527,6 +1527,8 @@
"dateRange": "時間範圍",
"status": "狀態",
"severity": "嚴重度",
"eventId": "事件 ID",
"eventIdPlaceholder": "貼上 governance_event_id",
"clearAll": "清除全部",
"allStatuses": "全部狀態",
"resolved": "已解決",
@@ -1554,6 +1556,14 @@
"noDispatch": "暫無派遣記錄"
},
"eventType": {
"slo_violation": "SLO 違反",
"governance_slo_data_gap": "SLO 資料缺口",
"knowledge_degradation": "KM 需要更新",
"kb_stale": "KM 陳舊",
"execution_blast_radius": "執行爆炸半徑",
"conservative_mode": "保守模式",
"replay_degraded": "回放品質下降",
"self_demotion": "AI 自我降級",
"slo_breach": "SLO 違反",
"accuracy_drop": "準確率下降",
"km_stall": "KM 停滯",
@@ -1915,6 +1925,7 @@
"archiveProposal": "封存候選:{count} 份重複草稿",
"ownerAction": "Owner 動作:{action}",
"readOnlyPlan": "讀取不寫入:{writes};未審核不封存:{blocked}",
"openEventHistory": "開啟事件歷史",
"ownerActions": {
"owner_review_canonical_then_archive_duplicates": "審核 canonical 草稿後封存 duplicates",
"review_canonical_and_archive_duplicate_drafts": "審核 canonical 並封存重複草稿",

View File

@@ -856,6 +856,10 @@ function formatStaleRatio(value: number) {
return `${(value * 100).toFixed(1)}%`;
}
function governanceEventHistoryHref(eventId: string) {
return `/governance?tab=events&event_id=${encodeURIComponent(eventId)}`;
}
function buildWorkItems(
telemetry: Telemetry,
t: ReturnType<typeof useTranslations>
@@ -1889,6 +1893,13 @@ function KnowledgeGovernancePanel({
)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Link
href={governanceEventHistoryHref(group.governance_event_id)}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8]"
>
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
{t("openEventHistory")}
</Link>
<button
type="button"
onClick={() => previewArchiveDuplicates(group)}

View File

@@ -13,8 +13,13 @@
*/
import { useEffect, useState, useCallback } from 'react'
import { useSearchParams } from 'next/navigation'
import { EventsFilterBar, type EventsFilter } from '@/components/governance/events-filter-bar'
import { EventsTable, type GovernanceEvent } from '@/components/governance/events-table'
import {
EventsTable,
type DispatchRecord,
type GovernanceEvent,
} from '@/components/governance/events-table'
// =============================================================================
// Config
@@ -27,11 +32,28 @@ const PAGE_SIZE = 20
// API response type
// =============================================================================
interface GovernanceEventApiItem {
id: string
event_type: string
severity?: 'critical' | 'warning' | 'info'
triggered_at: string
resolved?: boolean
resolved_at?: string | null
status?: 'resolved' | 'unresolved'
impact?: string
impact_summary?: string
details?: Record<string, unknown>
raw_data?: Record<string, unknown>
remediation?: string | null
dispatch_ids?: string[]
dispatch_records?: DispatchRecord[]
}
interface EventsApiResponse {
items?: GovernanceEvent[]
items?: GovernanceEventApiItem[]
total?: number
page?: number
page_size?: number
size?: number
event_types?: string[]
}
@@ -42,7 +64,9 @@ interface EventsApiResponse {
function buildQueryString(filter: EventsFilter, page: number): string {
const params = new URLSearchParams()
params.set('page', String(page))
params.set('page_size', String(PAGE_SIZE))
params.set('size', String(PAGE_SIZE))
const eventId = filter.eventId.trim()
if (eventId) params.append('event_id', eventId)
if (filter.eventTypes.length > 0) {
filter.eventTypes.forEach(t => params.append('event_type', t))
}
@@ -53,11 +77,36 @@ function buildQueryString(filter: EventsFilter, page: number): string {
return params.toString()
}
function toGovernanceEvent(item: GovernanceEventApiItem): GovernanceEvent {
const resolved = item.status
? item.status === 'resolved'
: Boolean(item.resolved)
const dispatchRecords = item.dispatch_records
?? (item.dispatch_ids ?? []).map((dispatchId) => ({
created_at: item.triggered_at,
action: dispatchId,
status: 'pending' as const,
}))
return {
id: item.id,
event_type: item.event_type,
triggered_at: item.triggered_at,
status: resolved ? 'resolved' : 'unresolved',
severity: item.severity,
impact_summary: item.impact_summary ?? item.impact,
raw_data: item.raw_data ?? item.details,
remediation: item.remediation ?? undefined,
dispatch_records: dispatchRecords,
}
}
// =============================================================================
// Component
// =============================================================================
const DEFAULT_FILTER: EventsFilter = {
eventId: '',
eventTypes: [],
status: 'all',
severity: 'all',
@@ -66,6 +115,8 @@ const DEFAULT_FILTER: EventsFilter = {
}
export function EventsTab() {
const searchParams = useSearchParams()
const eventIdFromUrl = searchParams.get('event_id')?.trim() ?? ''
const [filter, setFilter] = useState<EventsFilter>(DEFAULT_FILTER)
const [page, setPage] = useState(1)
const [events, setEvents] = useState<GovernanceEvent[]>([])
@@ -80,7 +131,7 @@ export function EventsTab() {
fetch(`${API_BASE}/api/v1/ai/governance/events?${qs}`)
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then((d: EventsApiResponse) => {
setEvents(d.items ?? [])
setEvents((d.items ?? []).map(toGovernanceEvent))
setTotal(d.total ?? 0)
if (d.event_types && d.event_types.length > 0) {
setAvailableEventTypes(d.event_types)
@@ -96,6 +147,14 @@ export function EventsTab() {
fetchEvents()
}, [fetchEvents])
useEffect(() => {
setFilter(current => {
if (current.eventId === eventIdFromUrl) return current
return { ...current, eventId: eventIdFromUrl }
})
setPage(1)
}, [eventIdFromUrl])
// Reset page when filter changes
const handleFilterChange = (newFilter: EventsFilter) => {
setFilter(newFilter)

View File

@@ -10,7 +10,7 @@
*/
import { useState, useRef, useEffect } from 'react'
import { Filter, Calendar, ChevronDown, X } from 'lucide-react'
import { Filter, Calendar, ChevronDown, Search, X } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { GlassCard } from '@/components/ui/glass-card'
@@ -19,6 +19,7 @@ import { GlassCard } from '@/components/ui/glass-card'
// =============================================================================
export interface EventsFilter {
eventId: string
eventTypes: string[]
status: 'all' | 'resolved' | 'unresolved'
severity: 'all' | 'critical' | 'warning' | 'info'
@@ -156,6 +157,14 @@ export function EventsFilterBar({ filter, onChange, availableEventTypes = [] }:
const tType = useTranslations('governance.events.eventType')
const eventTypeLabels: Record<string, string> = {
slo_violation: tType('slo_violation'),
governance_slo_data_gap: tType('governance_slo_data_gap'),
knowledge_degradation: tType('knowledge_degradation'),
kb_stale: tType('kb_stale'),
execution_blast_radius: tType('execution_blast_radius'),
conservative_mode: tType('conservative_mode'),
replay_degraded: tType('replay_degraded'),
self_demotion: tType('self_demotion'),
slo_breach: tType('slo_breach'),
accuracy_drop: tType('accuracy_drop'),
km_stall: tType('km_stall'),
@@ -164,6 +173,7 @@ export function EventsFilterBar({ filter, onChange, availableEventTypes = [] }:
}
const hasActiveFilter =
filter.eventId.trim() !== '' ||
filter.eventTypes.length > 0 ||
filter.status !== 'all' ||
filter.severity !== 'all' ||
@@ -171,6 +181,7 @@ export function EventsFilterBar({ filter, onChange, availableEventTypes = [] }:
filter.dateTo !== ''
const clearAll = () => onChange({
eventId: '',
eventTypes: [],
status: 'all',
severity: 'all',
@@ -216,6 +227,28 @@ export function EventsFilterBar({ filter, onChange, availableEventTypes = [] }:
labelMap={eventTypeLabels}
/>
{/* Event ID exact search */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Search size={11} style={{ color: '#87867f', flexShrink: 0 }} />
<input
type="search"
value={filter.eventId}
onChange={e => onChange({ ...filter, eventId: e.target.value })}
placeholder={t('eventIdPlaceholder')}
aria-label={t('eventId')}
style={{
padding: '4px 8px',
border: '0.5px solid #e0ddd4',
borderRadius: 6,
background: '#fff',
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#141413',
minWidth: 190,
}}
/>
</div>
{/* Date from */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Calendar size={11} style={{ color: '#87867f', flexShrink: 0 }} />

View File

@@ -10,7 +10,7 @@
* @created 2026-05-02 Claude Sonnet 4.6 — governance PR 3-5
*/
import { useState } from 'react'
import { Fragment, useState } from 'react'
import { ChevronDown, ChevronLeft, ChevronRight, AlertTriangle, ShieldCheck } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { StatusOrb } from '@/components/ui/status-orb'
@@ -54,6 +54,11 @@ interface EventsTableProps {
// =============================================================================
const EVENT_TYPE_COLORS: Record<string, { bg: string; text: string }> = {
slo_violation: { bg: 'rgba(255,51,0,0.08)', text: '#FF3300' },
governance_slo_data_gap: { bg: 'rgba(255,51,0,0.08)', text: '#FF3300' },
knowledge_degradation: { bg: 'rgba(245,158,11,0.10)', text: '#d97010' },
kb_stale: { bg: 'rgba(245,158,11,0.10)', text: '#d97010' },
execution_blast_radius: { bg: 'rgba(245,158,11,0.10)', text: '#d97010' },
slo_breach: { bg: 'rgba(255,51,0,0.08)', text: '#FF3300' },
accuracy_drop: { bg: 'rgba(245,158,11,0.10)', text: '#d97010' },
km_stall: { bg: 'rgba(74,144,217,0.10)', text: '#2563EB' },
@@ -61,6 +66,22 @@ const EVENT_TYPE_COLORS: Record<string, { bg: string; text: string }> = {
trust_degradation: { bg: 'rgba(236,72,153,0.10)', text: '#DB2777' },
}
const EVENT_TYPE_LABEL_KEYS = new Set([
'slo_violation',
'governance_slo_data_gap',
'knowledge_degradation',
'kb_stale',
'execution_blast_radius',
'conservative_mode',
'replay_degraded',
'self_demotion',
'slo_breach',
'accuracy_drop',
'km_stall',
'mcp_failure',
'trust_degradation',
])
function getEventTypeStyle(type: string) {
return EVENT_TYPE_COLORS[type] ?? { bg: 'rgba(135,134,127,0.10)', text: '#87867f' }
}
@@ -114,6 +135,11 @@ export function EventsTable({
whiteSpace: 'nowrap',
}
const eventTypeLabel = (eventType: string) => {
if (!EVENT_TYPE_LABEL_KEYS.has(eventType)) return eventType
return tType(eventType as Parameters<typeof tType>[0])
}
return (
<div style={{ overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
@@ -207,9 +233,8 @@ export function EventsTable({
const typeStyle = getEventTypeStyle(event.event_type)
return (
<>
<Fragment key={event.id}>
<tr
key={event.id}
style={{
borderBottom: isExpanded ? 'none' : '0.5px solid #e0ddd4',
transition: 'background 0.12s',
@@ -232,7 +257,7 @@ export function EventsTable({
letterSpacing: '0.3px',
whiteSpace: 'nowrap',
}}>
{tType(event.event_type as Parameters<typeof tType>[0]) ?? event.event_type}
{eventTypeLabel(event.event_type)}
</span>
</td>
@@ -298,7 +323,7 @@ export function EventsTable({
{/* Inline expand drawer */}
{isExpanded && <EventDetailDrawer key={`detail-${event.id}`} event={event} />}
</>
</Fragment>
)
})}
</tbody>