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