feat(governance): link work items to event history
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 並封存重複草稿",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user