fix(km): expose degraded readback reason
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m25s
CD Pipeline / build-and-deploy (push) Successful in 5m0s
AWOOOI Harbor 110 Local Repair / workflow-shape (push) Successful in 1s
AWOOOI Harbor 110 Local Repair / harbor-110-local-repair (push) Failing after 1m48s
CD Pipeline / post-deploy-checks (push) Has been cancelled

This commit is contained in:
ogt
2026-07-09 18:10:20 +08:00
parent 57046c9360
commit 2997e2d9ca
3 changed files with 55 additions and 4 deletions

View File

@@ -22,7 +22,6 @@ from pydantic import BaseModel, Field
from src.utils.timezone import now_taipei
# =============================================================================
# Enums
# =============================================================================
@@ -135,6 +134,8 @@ class KnowledgeListResponse(BaseModel):
categories: list[CategoryCount] = Field(default_factory=list)
asset_taxonomy: list[KnowledgeAssetTaxonomyCount] = Field(default_factory=list)
readback_status: str = "ready"
primary_readback_ready: bool = True
degraded_reason_code: str | None = None
operator_stage: str | None = None
next_step: str | None = None
writes_on_read: bool = False

View File

@@ -210,6 +210,19 @@ _SOURCE_BACKED_KNOWLEDGE_SPECS: tuple[dict[str, object], ...] = (
},
)
def _classify_knowledge_readback_degraded_reason(reason: str) -> str:
normalized = reason.lower()
if "pool" in normalized or "timeout" in normalized or "timed out" in normalized:
return "primary_km_db_timeout_or_pool_exhausted"
if "missing tenant context" in normalized or "project_id" in normalized or "unauthorized" in normalized:
return "primary_km_project_context_missing"
if "undefinedcolumn" in normalized or "does not exist" in normalized or "missing column" in normalized:
return "primary_km_schema_mismatch"
if "validation" in normalized or "pydantic" in normalized:
return "primary_km_legacy_row_validation"
return "primary_km_readback_exception"
# =============================================================================
# Singleton
# =============================================================================
@@ -245,6 +258,8 @@ def build_knowledge_list_readback_degraded_response(
categories=_source_category_counts(entries),
asset_taxonomy=_source_asset_taxonomy_counts(entries),
readback_status=readback_status,
primary_readback_ready=False,
degraded_reason_code=_classify_knowledge_readback_degraded_reason(reason),
operator_stage="knowledge_readback_source_backed_ai_controlled_repair",
next_step=(
"repair_primary_km_db_readback_then_promote_source_backed_receipts_to_persistent_km"
@@ -496,6 +511,7 @@ class KnowledgeService:
total=total,
categories=categories,
asset_taxonomy=asset_taxonomy,
primary_readback_ready=True,
)
except Exception as exc: # noqa: BLE001 - production readback must fail soft
logger.warning(
@@ -555,9 +571,19 @@ class KnowledgeService:
async def search(self, query: str, limit: int = 20) -> list[KnowledgeEntry]:
"""關鍵字搜尋"""
async with get_db_context() as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
return await repo.search(query, limit)
try:
async with get_db_context() as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
return await repo.search(query, limit)
except Exception as exc: # noqa: BLE001 - KM search must not 500 the UI
logger.warning(
"knowledge_search_readback_degraded",
error=str(exc),
q=query,
limit=limit,
degraded_reason_code=_classify_knowledge_readback_degraded_reason(str(exc)),
)
return _filter_source_backed_entries(q=query)[:limit]
async def semantic_search(
self,
@@ -630,7 +656,9 @@ class KnowledgeService:
list[KnowledgeEntry] — ANTI_PATTERN 條目,空表示無已知失敗案例
"""
from datetime import timedelta
from sqlalchemy import text as sa_text
from src.utils.timezone import now_taipei
cutoff = now_taipei() - timedelta(days=days)

View File

@@ -60,6 +60,8 @@ async def test_knowledge_list_entries_fails_soft_when_readback_breaks(monkeypatc
]
assert all(row.count >= 1 for row in response.asset_taxonomy)
assert response.readback_status == "source_backed_degraded"
assert response.primary_readback_ready is False
assert response.degraded_reason_code == "primary_km_db_timeout_or_pool_exhausted"
assert response.operator_stage == "knowledge_readback_source_backed_ai_controlled_repair"
assert response.next_step == "repair_primary_km_db_readback_then_promote_source_backed_receipts_to_persistent_km"
assert response.writes_on_read is False
@@ -83,6 +85,26 @@ async def test_knowledge_list_entries_source_backed_filter_and_search(monkeypatc
assert response.asset_taxonomy[[row.key for row in response.asset_taxonomy].index("alert")].count == 1
@pytest.mark.asyncio
async def test_knowledge_search_fails_soft_to_source_backed_entries(monkeypatch) -> None:
monkeypatch.setattr(
knowledge_service_module,
"get_db_context",
lambda: _BrokenDbContext(),
)
service = KnowledgeService.__new__(KnowledgeService)
entries = await service.search("Telegram", limit=10)
entry_ids = {entry.id for entry in entries}
assert all(entry.id.startswith("source-backed-") for entry in entries)
assert {
"source-backed-service-telegram-alert-receipts",
"source-backed-alert-telegram-monitoring-coverage",
"source-backed-schedule-report-monitoring",
}.issubset(entry_ids)
@pytest.mark.asyncio
async def test_knowledge_categories_fails_soft_when_readback_breaks(monkeypatch) -> None:
monkeypatch.setattr(