feat(awooop): filter runs by remediation evidence
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 1m4s
CD Pipeline / build-and-deploy (push) Successful in 4m9s
CD Pipeline / post-deploy-checks (push) Successful in 1m26s

This commit is contained in:
Your Name
2026-05-17 21:13:54 +08:00
parent 171443ee94
commit 665e72ba33
7 changed files with 185 additions and 22 deletions

View File

@@ -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 補救證據狀態 filterno_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 補救證據狀態 filterno_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(

View File

@@ -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 = [
{

View File

@@ -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 = [

View File

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

View File

@@ -1803,6 +1803,10 @@
"route": "MCP{route}",
"emptyShort": "尚未連到補救試跑",
"manualGate": "下一步:人工審批",
"filters": {
"label": "AI 證據篩選",
"all": "所有 AI 證據"
},
"statuses": {
"noEvidence": "尚無試跑",
"readOnlyDryRun": "AI 已試跑:只讀",

View File

@@ -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<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>("");
const intervalRef = useRef<ReturnType<typeof setInterval> | 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() {
})}
</section>
<div className="flex flex-wrap items-center gap-3 border border-[#e0ddd4] bg-white p-4">
<Filter className="w-4 h-4 text-muted-foreground flex-shrink-0" aria-hidden="true" />
<span className="text-sm text-muted-foreground">{tEvidence("filters.label")}</span>
<div className="relative">
<select
value={evidenceFilter}
onChange={(e) => setEvidenceFilter(e.target.value as "" | RemediationStatus)}
className="appearance-none pl-3 pr-8 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-brand-accent/50 cursor-pointer"
aria-label={tEvidence("filters.label")}
>
<option value="">{tEvidence("filters.all")}</option>
{REMEDIATION_FILTER_OPTIONS.map((status) => (
<option key={status} value={status}>
{tEvidence(REMEDIATION_STATUS_CONFIG[status].labelKey)}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" aria-hidden="true" />
</div>
</div>
{/* Error State */}
{error && (
<div className="flex items-start gap-3 border border-[#e2a29b] bg-[#fff0ef] p-4">

View File

@@ -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<string | null>(null);
const [projectFilter, setProjectFilter] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>("");
const [page, setPage] = useState(1);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | 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() {
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" aria-hidden="true" />
</div>
{/* AI Evidence Filter */}
<div className="relative">
<select
value={evidenceFilter}
onChange={(e) => {
setEvidenceFilter(e.target.value as "" | RemediationStatus);
setPage(1);
}}
className="appearance-none pl-3 pr-8 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-brand-accent/50 cursor-pointer"
aria-label={tEvidence("filters.label")}
>
<option value="">{tEvidence("filters.all")}</option>
{REMEDIATION_FILTER_OPTIONS.map((status) => (
<option key={status} value={status}>
{tEvidence(REMEDIATION_STATUS_CONFIG[status].labelKey)}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" aria-hidden="true" />
</div>
</div>
{/* Error State */}