diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index 7ddac51e..357b9015 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -97,7 +97,7 @@ class DecideApprovalResponse(BaseModel): response_model=ListRunsResponse, summary="列出 Runs", description=( - "返回 awooop_run_state 記錄,支援 project_id / state filter 與分頁。\n\n" + "返回 awooop_run_state 記錄,支援 project_id / state / remediation_status filter 與分頁。\n\n" "- 按 created_at DESC 排序\n" "- 注意:此路徑為 /runs/list 以避免與 runs.py 的 /runs/{run_id} 衝突" ), @@ -105,11 +105,19 @@ class DecideApprovalResponse(BaseModel): async def list_runs( project_id: str | None = Query(None, description="租戶 ID(可選)"), state: str | None = Query(None, description="Run 狀態 filter(可選)"), + remediation_status: str | None = Query( + None, + description="AI 補救證據狀態 filter(no_evidence/read_only_dry_run/write_observed/blocked/observed)", + ), page: int = Query(1, ge=1, description="頁碼,從 1 開始"), per_page: int = Query(_DEFAULT_PER_PAGE, ge=1, le=_MAX_PER_PAGE, description="每頁筆數"), ) -> dict[str, Any]: return await list_runs_svc( - project_id=project_id, state=state, page=page, per_page=per_page + project_id=project_id, + state=state, + remediation_status=remediation_status, + page=page, + per_page=per_page, ) @@ -140,8 +148,16 @@ async def get_run_detail( async def list_approvals( project_id: str | None = Query(None, description="租戶 ID(可選)"), run_id: str | None = Query(None, description="Run ID(可選,M8 詳情頁查單筆)"), + remediation_status: str | None = Query( + None, + description="AI 補救證據狀態 filter(no_evidence/read_only_dry_run/write_observed/blocked/observed)", + ), ) -> dict[str, Any]: - return await list_approvals_svc(project_id=project_id, run_id=run_id) + return await list_approvals_svc( + project_id=project_id, + run_id=run_id, + remediation_status=remediation_status, + ) @router.post( diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 98de6015..2b80335f 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -45,6 +45,13 @@ _MAX_LIST_CONTEXT_ROWS = 500 _MAX_STEP_SUMMARY_CHARS = 128 _REMEDIATION_HISTORY_LIMIT = 20 _INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b") +_REMEDIATION_STATUS_FILTERS = { + "no_evidence", + "read_only_dry_run", + "write_observed", + "blocked", + "observed", +} # ============================================================================= # Tenants @@ -133,10 +140,13 @@ async def list_contracts( async def list_runs( project_id: str | None, state: str | None, + remediation_status: str | None, page: int, per_page: int, ) -> dict[str, Any]: - """列出 runs,支援 project_id、state filter 與分頁。""" + """列出 runs,支援 project_id、state、remediation_status filter 與分頁。""" + _validate_remediation_status_filter(remediation_status) + async with get_db_context("awoooi") as db: stmt = select(AwoooPRunState).order_by(AwoooPRunState.created_at.desc()) if project_id is not None: @@ -144,22 +154,40 @@ async def list_runs( if state is not None: stmt = stmt.where(AwoooPRunState.state == state) - count_stmt = select(func.count()).select_from(stmt.subquery()) - total_result = await db.execute(count_stmt) - total = total_result.scalar_one() - offset = (page - 1) * per_page - stmt = stmt.offset(offset).limit(per_page) - result = await db.execute(stmt) - rows = list(result.scalars().all()) + if remediation_status: + result = await db.execute(stmt) + candidate_rows = list(result.scalars().all()) + inbound_by_run, outbound_by_run = await _load_run_message_context(db, candidate_rows) + remediation_summaries = await _build_run_remediation_summaries( + runs=candidate_rows, + inbound_by_run=inbound_by_run, + outbound_by_run=outbound_by_run, + ) + filtered_rows = [ + row + for row in candidate_rows + if _remediation_summary_matches_status( + remediation_summaries.get(row.run_id), + remediation_status, + ) + ] + total = len(filtered_rows) + rows = filtered_rows[offset : offset + per_page] + else: + count_stmt = select(func.count()).select_from(stmt.subquery()) + total_result = await db.execute(count_stmt) + total = total_result.scalar_one() - inbound_by_run, outbound_by_run = await _load_run_message_context(db, rows) - - remediation_summaries = await _build_run_remediation_summaries( - runs=rows, - inbound_by_run=inbound_by_run, - outbound_by_run=outbound_by_run, - ) + stmt = stmt.offset(offset).limit(per_page) + result = await db.execute(stmt) + rows = list(result.scalars().all()) + inbound_by_run, outbound_by_run = await _load_run_message_context(db, rows) + remediation_summaries = await _build_run_remediation_summaries( + runs=rows, + inbound_by_run=inbound_by_run, + outbound_by_run=outbound_by_run, + ) runs = [ { @@ -471,6 +499,25 @@ def _run_remediation_list_summary( } +def _validate_remediation_status_filter(value: str | None) -> None: + if value is None: + return + if value not in _REMEDIATION_STATUS_FILTERS: + allowed = ", ".join(sorted(_REMEDIATION_STATUS_FILTERS)) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"remediation_status 必須是: {allowed}", + ) + + +def _remediation_summary_matches_status( + summary: dict[str, Any] | None, + remediation_status: str, +) -> bool: + status_value = str((summary or {}).get("status") or "no_evidence") + return status_value == remediation_status + + async def _build_run_remediation_summaries( *, runs: list[AwoooPRunState], @@ -984,8 +1031,11 @@ async def list_recent_channel_events( async def list_approvals( project_id: str | None, run_id: str | None = None, + remediation_status: str | None = None, ) -> dict[str, Any]: - """列出 waiting_approval runs,可依 project_id 或 run_id 篩選。""" + """列出 waiting_approval runs,可依 project_id / run_id / remediation_status 篩選。""" + _validate_remediation_status_filter(remediation_status) + run_uuid: UUID | None = None if run_id: try: @@ -1021,6 +1071,16 @@ async def list_approvals( inbound_by_run=inbound_by_run, outbound_by_run=outbound_by_run, ) + if remediation_status: + rows = [ + row + for row in rows + if _remediation_summary_matches_status( + remediation_summaries.get(row.run_id), + remediation_status, + ) + ] + total = len(rows) items = [ { diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index aef55c60..d38bbab1 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -5,6 +5,7 @@ from src.services.platform_operator_service import ( _collect_run_incident_ids, _outbound_timeline_title, _run_remediation_list_summary, + _remediation_summary_matches_status, _remediation_timeline_summary, _timeline_sort_key, ) @@ -158,6 +159,18 @@ def test_run_remediation_list_summary_flags_write_observed() -> None: assert summary["writes_incident_state"] is True +def test_remediation_summary_matches_status_filter() -> None: + assert _remediation_summary_matches_status( + {"status": "read_only_dry_run"}, + "read_only_dry_run", + ) + assert not _remediation_summary_matches_status( + {"status": "write_observed"}, + "read_only_dry_run", + ) + assert _remediation_summary_matches_status(None, "no_evidence") + + def test_timeline_sort_key_normalizes_datetime_and_iso_string() -> None: fallback = datetime(2026, 5, 14, 10, 0, 0) keys = [ diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 287a28d7..6dbaa7d1 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1802,6 +1802,10 @@ "route": "MCP: {route}", "emptyShort": "No remediation dry-run linked", "manualGate": "Next: human approval", + "filters": { + "label": "AI evidence filter", + "all": "All AI evidence" + }, "statuses": { "noEvidence": "No dry-run yet", "readOnlyDryRun": "AI dry-run: read-only", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index a0eb3d90..16df25e9 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1803,6 +1803,10 @@ "route": "MCP:{route}", "emptyShort": "尚未連到補救試跑", "manualGate": "下一步:人工審批", + "filters": { + "label": "AI 證據篩選", + "all": "所有 AI 證據" + }, "statuses": { "noEvidence": "尚無試跑", "readOnlyDryRun": "AI 已試跑:只讀", diff --git a/apps/web/src/app/[locale]/awooop/approvals/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/page.tsx index 8839e952..e12d4204 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/page.tsx @@ -13,6 +13,8 @@ import { AlertCircle, Clock, ArrowRight, + ChevronDown, + Filter, ListChecks, SearchCheck, TriangleAlert, @@ -124,6 +126,13 @@ const REMEDIATION_STATUS_CONFIG: Record< className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", }, }; +const REMEDIATION_FILTER_OPTIONS: RemediationStatus[] = [ + "read_only_dry_run", + "write_observed", + "blocked", + "observed", + "no_evidence", +]; function normalizeRemediationStatus(summary?: RemediationSummary | null): RemediationStatus { const statusValue = summary?.status; @@ -288,12 +297,18 @@ export default function ApprovalsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); + const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>(""); const intervalRef = useRef | null>(null); const fetchApprovals = useCallback(async () => { try { setError(null); - const res = await fetch(`${API_BASE}/api/v1/platform/approvals`); + const params = new URLSearchParams(); + if (evidenceFilter) params.set("remediation_status", evidenceFilter); + const qs = params.toString(); + const res = await fetch( + `${API_BASE}/api/v1/platform/approvals${qs ? `?${qs}` : ""}` + ); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); setApprovals(Array.isArray(data.items) ? data.items : []); @@ -303,7 +318,7 @@ export default function ApprovalsPage() { } finally { setLoading(false); } - }, []); + }, [evidenceFilter]); useEffect(() => { fetchApprovals(); @@ -439,6 +454,27 @@ export default function ApprovalsPage() { })} +
+
+ {/* Error State */} {error && (
diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index bd480414..49a4b9af 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -259,6 +259,13 @@ const REMEDIATION_STATUS_CONFIG: Record< className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", }, }; +const REMEDIATION_FILTER_OPTIONS: RemediationStatus[] = [ + "read_only_dry_run", + "write_observed", + "blocked", + "observed", + "no_evidence", +]; function getRunLane(state: RunState): RunLane { if (state === "pending") return "intake"; @@ -510,6 +517,7 @@ export default function RunsPage() { const [error, setError] = useState(null); const [projectFilter, setProjectFilter] = useState(""); const [statusFilter, setStatusFilter] = useState(""); + const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>(""); const [page, setPage] = useState(1); const [lastRefresh, setLastRefresh] = useState(null); const intervalRef = useRef | null>(null); @@ -531,6 +539,7 @@ export default function RunsPage() { const params = new URLSearchParams(); if (projectFilter) params.set("project_id", projectFilter); if (statusFilter) params.set("state", statusFilter); + if (evidenceFilter) params.set("remediation_status", evidenceFilter); params.set("page", String(page)); params.set("per_page", String(PER_PAGE)); @@ -563,7 +572,7 @@ export default function RunsPage() { } finally { setLoading(false); } - }, [projectFilter, statusFilter, page]); + }, [projectFilter, statusFilter, evidenceFilter, page]); // 初次載入 useEffect(() => { @@ -761,6 +770,27 @@ export default function RunsPage() {
+ + {/* AI Evidence Filter */} +
+ +
{/* Error State */}