feat(awooop): filter runs by remediation evidence
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1803,6 +1803,10 @@
|
||||
"route": "MCP:{route}",
|
||||
"emptyShort": "尚未連到補救試跑",
|
||||
"manualGate": "下一步:人工審批",
|
||||
"filters": {
|
||||
"label": "AI 證據篩選",
|
||||
"all": "所有 AI 證據"
|
||||
},
|
||||
"statuses": {
|
||||
"noEvidence": "尚無試跑",
|
||||
"readOnlyDryRun": "AI 已試跑:只讀",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user