fix(km): bound knowledge readbacks under pool pressure
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m19s
CD Pipeline / build-and-deploy (push) Successful in 4m56s
CD Pipeline / post-deploy-checks (push) Has been cancelled

This commit is contained in:
ogt
2026-07-09 19:34:29 +08:00
parent eb1c5849ca
commit 0f3f4af907
5 changed files with 215 additions and 65 deletions

View File

@@ -52,6 +52,10 @@ _DEGRADED_CATEGORY_FALLBACKS = (
)
_ASSET_TAXONOMY_FALLBACKS = _DEGRADED_CATEGORY_FALLBACKS[:-1]
_PRIMARY_KM_LIST_TIMEOUT_SECONDS = 2.0
_PRIMARY_KM_LIST_RETRY_TIMEOUT_SECONDS = 1.0
_PRIMARY_KM_RETRY_DELAY_SECONDS = 0.1
_PRIMARY_KM_SIDE_READ_TIMEOUT_SECONDS = 1.2
_PRIMARY_KM_TAXONOMY_TIMEOUT_SECONDS = 1.5
_ASSET_TAXONOMY_CATEGORY_HINTS: dict[str, tuple[str, ...]] = {
"service": (
@@ -228,7 +232,13 @@ _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:
if (
not normalized
or "pool" in normalized
or "timeout" in normalized
or "timed out" in normalized
or "timeouterror" 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"
@@ -238,6 +248,12 @@ def _classify_knowledge_readback_degraded_reason(reason: str) -> str:
return "primary_km_legacy_row_validation"
return "primary_km_readback_exception"
def _knowledge_readback_exception_reason(exc: BaseException) -> str:
if isinstance(exc, TimeoutError):
return "TimeoutError"
return str(exc) or type(exc).__name__
# =============================================================================
# Singleton
# =============================================================================
@@ -589,6 +605,40 @@ class KnowledgeService:
for key, count in asset_taxonomy_raw
]
async def _search_primary_entries(
self,
query: str,
limit: int,
) -> list[KnowledgeEntry]:
async with get_db_context() as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
return await repo.search(query, limit)
async def _list_entries_from_primary_bounded(
self,
*,
category: str | None,
entry_type: EntryType | None,
status: EntryStatus | None,
tags: list[str] | None,
q: str | None,
limit: int,
offset: int,
timeout: float,
) -> KnowledgeListResponse:
return await asyncio.wait_for(
self._list_entries_from_primary(
category=category,
entry_type=entry_type,
status=status,
tags=tags,
q=q,
limit=limit,
offset=offset,
),
timeout=timeout,
)
async def list_entries(
self,
category: str | None = None,
@@ -601,7 +651,7 @@ class KnowledgeService:
) -> KnowledgeListResponse:
"""列出知識條目 + 分類統計"""
try:
return await self._list_entries_from_primary(
return await self._list_entries_from_primary_bounded(
category=category,
entry_type=entry_type,
status=status,
@@ -609,13 +659,15 @@ class KnowledgeService:
q=q,
limit=limit,
offset=offset,
timeout=_PRIMARY_KM_LIST_TIMEOUT_SECONDS,
)
except Exception as exc: # noqa: BLE001 - production readback must fail soft
degraded_reason_code = _classify_knowledge_readback_degraded_reason(str(exc))
degraded_reason = _knowledge_readback_exception_reason(exc)
degraded_reason_code = _classify_knowledge_readback_degraded_reason(degraded_reason)
if degraded_reason_code == "primary_km_db_timeout_or_pool_exhausted":
try:
await asyncio.sleep(0.25)
retry_response = await self._list_entries_from_primary(
await asyncio.sleep(_PRIMARY_KM_RETRY_DELAY_SECONDS)
retry_response = await self._list_entries_from_primary_bounded(
category=category,
entry_type=entry_type,
status=status,
@@ -623,21 +675,23 @@ class KnowledgeService:
q=q,
limit=limit,
offset=offset,
timeout=_PRIMARY_KM_LIST_RETRY_TIMEOUT_SECONDS,
)
retry_response.operator_stage = "knowledge_readback_primary_retry_recovered"
return retry_response
except Exception as retry_exc: # noqa: BLE001 - keep source-backed fallback
retry_reason = _knowledge_readback_exception_reason(retry_exc)
logger.warning(
"knowledge_list_readback_retry_degraded",
error=str(retry_exc),
degraded_reason_code=_classify_knowledge_readback_degraded_reason(str(retry_exc)),
error=retry_reason,
degraded_reason_code=_classify_knowledge_readback_degraded_reason(retry_reason),
q=q,
limit=limit,
offset=offset,
)
logger.warning(
"knowledge_list_readback_degraded",
error=str(exc),
error=degraded_reason,
degraded_reason_code=degraded_reason_code,
category=category,
entry_type=entry_type.value if entry_type else None,
@@ -647,7 +701,7 @@ class KnowledgeService:
offset=offset,
)
return build_knowledge_list_readback_degraded_response(
str(exc),
degraded_reason,
category=category,
entry_type=entry_type,
status=status,
@@ -660,50 +714,56 @@ class KnowledgeService:
async def get_asset_taxonomy(self) -> list[KnowledgeAssetTaxonomyCount]:
"""取得 AI 自動化資產維度統計。"""
try:
async with get_db_context() as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
rows = await repo.get_asset_taxonomy_counts()
taxonomy = [
KnowledgeAssetTaxonomyCount(key=key, count=count)
for key, count in rows
]
if taxonomy and any(row.count > 0 for row in taxonomy):
return taxonomy
return _source_asset_taxonomy_counts(_source_backed_entries())
taxonomy = await asyncio.wait_for(
self._read_primary_asset_taxonomy(),
timeout=_PRIMARY_KM_SIDE_READ_TIMEOUT_SECONDS,
)
if taxonomy and any(row.count > 0 for row in taxonomy):
return taxonomy
return _source_asset_taxonomy_counts(_source_backed_entries())
except Exception as exc: # noqa: BLE001 - taxonomy must not 500 the KM UI
logger.warning("knowledge_asset_taxonomy_readback_degraded", error=str(exc))
reason = _knowledge_readback_exception_reason(exc)
logger.warning(
"knowledge_asset_taxonomy_readback_degraded",
error=reason,
degraded_reason_code=_classify_knowledge_readback_degraded_reason(reason),
)
return _source_asset_taxonomy_counts(_source_backed_entries())
async def get_categories(self) -> list[CategoryCount]:
"""取得分類統計(直接呼叫 repo不走 list_entries"""
try:
async with get_db_context() as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
categories_raw = await repo.get_categories()
categories = [
CategoryCount(category=cat, count=cnt)
for cat, cnt in categories_raw
]
if categories:
return categories
return _source_category_counts(_source_backed_entries())
categories = await asyncio.wait_for(
self._read_primary_categories(),
timeout=_PRIMARY_KM_SIDE_READ_TIMEOUT_SECONDS,
)
if categories:
return categories
return _source_category_counts(_source_backed_entries())
except Exception as exc: # noqa: BLE001 - categories must not 500 the KM UI
logger.warning("knowledge_categories_readback_degraded", error=str(exc))
reason = _knowledge_readback_exception_reason(exc)
logger.warning(
"knowledge_categories_readback_degraded",
error=reason,
degraded_reason_code=_classify_knowledge_readback_degraded_reason(reason),
)
return _source_category_counts(_source_backed_entries())
async def search(self, query: str, limit: int = 20) -> list[KnowledgeEntry]:
"""關鍵字搜尋"""
try:
async with get_db_context() as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
return await repo.search(query, limit)
return await asyncio.wait_for(
self._search_primary_entries(query, limit),
timeout=_PRIMARY_KM_SIDE_READ_TIMEOUT_SECONDS,
)
except Exception as exc: # noqa: BLE001 - KM search must not 500 the UI
reason = _knowledge_readback_exception_reason(exc)
logger.warning(
"knowledge_search_readback_degraded",
error=str(exc),
error=reason,
q=query,
limit=limit,
degraded_reason_code=_classify_knowledge_readback_degraded_reason(str(exc)),
degraded_reason_code=_classify_knowledge_readback_degraded_reason(reason),
)
return _filter_source_backed_entries(q=query)[:limit]

View File

@@ -1,3 +1,5 @@
import asyncio
import pytest
from src.models.knowledge import (
@@ -122,6 +124,30 @@ async def test_knowledge_list_entries_retries_transient_pool_timeout(monkeypatch
assert response.operator_stage == "knowledge_readback_primary_retry_recovered"
@pytest.mark.asyncio
async def test_knowledge_list_entries_bounds_primary_timeout_to_source_backed(monkeypatch) -> None:
service = KnowledgeService.__new__(KnowledgeService)
calls = 0
async def never_finishes_primary(**_kwargs):
nonlocal calls
calls += 1
await asyncio.Event().wait()
monkeypatch.setattr(service, "_list_entries_from_primary", never_finishes_primary)
monkeypatch.setattr(knowledge_service_module, "_PRIMARY_KM_LIST_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(knowledge_service_module, "_PRIMARY_KM_LIST_RETRY_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(knowledge_service_module, "_PRIMARY_KM_RETRY_DELAY_SECONDS", 0)
response = await service.list_entries(limit=50)
assert calls == 2
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.total == 13
@pytest.mark.asyncio
async def test_knowledge_list_entries_keeps_primary_items_when_taxonomy_degrades(monkeypatch) -> None:
primary_entry = KnowledgeEntry(
@@ -247,3 +273,54 @@ async def test_knowledge_asset_taxonomy_fails_soft_when_readback_breaks(monkeypa
"schedule",
]
assert all(row.count >= 1 for row in taxonomy)
@pytest.mark.asyncio
async def test_knowledge_side_readbacks_bound_timeouts_to_source_backed(monkeypatch) -> None:
service = KnowledgeService.__new__(KnowledgeService)
async def never_finishes(*_args):
await asyncio.Event().wait()
monkeypatch.setattr(knowledge_service_module, "_PRIMARY_KM_SIDE_READ_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(service, "_read_primary_categories", never_finishes)
monkeypatch.setattr(service, "_read_primary_asset_taxonomy", never_finishes)
monkeypatch.setattr(service, "_search_primary_entries", never_finishes)
categories = await service.get_categories()
taxonomy = await service.get_asset_taxonomy()
entries = await service.search("Telegram", limit=10)
assert [row.category for row in categories] == [
"project",
"product",
"website",
"service",
"package",
"tool",
"log",
"alert",
"playbook",
"rag",
"mcp",
"schedule",
"general",
]
assert [row.key for row in taxonomy] == [
"project",
"product",
"website",
"service",
"package",
"tool",
"log",
"alert",
"playbook",
"rag",
"mcp",
"schedule",
]
assert {entry.id for entry in entries} >= {
"source-backed-service-telegram-alert-receipts",
"source-backed-alert-telegram-monitoring-coverage",
}

View File

@@ -2217,8 +2217,10 @@
}
},
"dataChain": {
"errorTitle": "知識條目資料鏈路異常",
"errorDescription": "知識條目 API 未成功回應:{reason}。下方治理軌道仍會顯示 Hermes 受控覆核與陳舊 KM 狀態,避免誤判成知識庫真的歸零。",
"errorTitle": "知識條目入口需重讀",
"errorDescription": "知識條目入口回應:{reason}。頁面會保留 AI source-backed KM、Hermes 受控覆核與陳舊 KM 狀態,避免誤判成知識庫歸零。",
"sourceBackedTitle": "AI source-backed KM 已接管",
"sourceBackedDescription": "Primary KM DB readback 正在受控修復;目前顯示 source-backed 條目、分類與資產 taxonomy不是知識庫歸零。原因{reason};階段:{stage};下一步:{next}。",
"degradedTitle": "知識條目讀取降級",
"degradedDescription": "主知識條目 API 已回 degraded{stage};下一步:{next}。目前列表不視為真實歸零AI 受控修復隊列與 RAG / PlayBook readback 仍會保留。",
"retry": "重新讀取"

View File

@@ -2217,8 +2217,10 @@
}
},
"dataChain": {
"errorTitle": "知識條目資料鏈路異常",
"errorDescription": "知識條目 API 未成功回應:{reason}。下方治理軌道仍會顯示 Hermes 受控覆核與陳舊 KM 狀態,避免誤判成知識庫真的歸零。",
"errorTitle": "知識條目入口需重讀",
"errorDescription": "知識條目入口回應:{reason}。頁面會保留 AI source-backed KM、Hermes 受控覆核與陳舊 KM 狀態,避免誤判成知識庫歸零。",
"sourceBackedTitle": "AI source-backed KM 已接管",
"sourceBackedDescription": "Primary KM DB readback 正在受控修復;目前顯示 source-backed 條目、分類與資產 taxonomy不是知識庫歸零。原因{reason};階段:{stage};下一步:{next}。",
"degradedTitle": "知識條目讀取降級",
"degradedDescription": "主知識條目 API 已回 degraded{stage};下一步:{next}。目前列表不視為真實歸零AI 受控修復隊列與 RAG / PlayBook readback 仍會保留。",
"retry": "重新讀取"

View File

@@ -64,6 +64,7 @@ interface ListResponse {
categories: CategoryCount[]
asset_taxonomy?: AssetTaxonomyCount[]
readback_status?: 'ready' | 'degraded' | string
degraded_reason_code?: string | null
operator_stage?: string | null
next_step?: string | null
writes_on_read?: boolean
@@ -496,7 +497,7 @@ export default function KnowledgeBasePage({
const [assetTaxonomy, setAssetTaxonomy] = useState<AssetTaxonomyCount[]>(ASSET_TAXONOMY_FALLBACK_COUNTS)
const [loading, setLoading] = useState(true)
const [entryFetchError, setEntryFetchError] = useState<string | null>(null)
const [entryReadback, setEntryReadback] = useState<Pick<ListResponse, 'readback_status' | 'operator_stage' | 'next_step'> | null>(null)
const [entryReadback, setEntryReadback] = useState<Pick<ListResponse, 'readback_status' | 'degraded_reason_code' | 'operator_stage' | 'next_step'> | null>(null)
const [governanceTelemetry, setGovernanceTelemetry] = useState<KnowledgeGovernanceTelemetry>({
staleCandidates: null,
ownerReviews: null,
@@ -541,6 +542,7 @@ export default function KnowledgeBasePage({
setAssetTaxonomy(data.asset_taxonomy?.length ? data.asset_taxonomy : ASSET_TAXONOMY_FALLBACK_COUNTS)
setEntryReadback({
readback_status: data.readback_status ?? 'ready',
degraded_reason_code: data.degraded_reason_code ?? null,
operator_stage: data.operator_stage ?? null,
next_step: data.next_step ?? null,
})
@@ -574,23 +576,15 @@ export default function KnowledgeBasePage({
setGovernanceLoading(true)
try {
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ''
const [staleCandidates, ownerReviews, burnDown, completionQueue, ragStats] = await Promise.all([
fetch(`${apiBase}/api/v1/ai/governance/km-stale-candidates?project_id=awoooi&limit=5`)
.then(res => res.ok ? res.json() : null)
.catch(() => null),
fetch(`${apiBase}/api/v1/ai/governance/km-stale-owner-reviews?project_id=awoooi&dispatch_status=pending&limit=5`)
.then(res => res.ok ? res.json() : null)
.catch(() => null),
fetch(`${apiBase}/api/v1/ai/governance/km-stale-owner-review-burndown?project_id=awoooi&limit=5`)
.then(res => res.ok ? res.json() : null)
.catch(() => null),
fetch(`${apiBase}/api/v1/ai/governance/km-stale-owner-review-completion-queue?project_id=awoooi&status_bucket=all&limit=5`)
.then(res => res.ok ? res.json() : null)
.catch(() => null),
fetch(`${apiBase}/api/v1/knowledge/rag/stats?project_id=${encodeURIComponent(KNOWLEDGE_PROJECT_ID)}`)
.then(res => res.ok ? res.json() : null)
.catch(() => null),
])
const fetchOptionalJson = async (path: string) => fetch(`${apiBase}${path}`)
.then(res => res.ok ? res.json() : null)
.catch(() => null)
const staleCandidates = await fetchOptionalJson('/api/v1/ai/governance/km-stale-candidates?project_id=awoooi&limit=5')
const ownerReviews = await fetchOptionalJson('/api/v1/ai/governance/km-stale-owner-reviews?project_id=awoooi&dispatch_status=pending&limit=5')
const burnDown = await fetchOptionalJson('/api/v1/ai/governance/km-stale-owner-review-burndown?project_id=awoooi&limit=5')
const completionQueue = await fetchOptionalJson('/api/v1/ai/governance/km-stale-owner-review-completion-queue?project_id=awoooi&status_bucket=all&limit=5')
const ragStats = await fetchOptionalJson(`/api/v1/knowledge/rag/stats?project_id=${encodeURIComponent(KNOWLEDGE_PROJECT_ID)}`)
setGovernanceTelemetry({
staleCandidates,
@@ -694,8 +688,11 @@ export default function KnowledgeBasePage({
)
const totalCount = categories.reduce((sum, c) => sum + c.count, 0)
const isKnowledgeListUnavailable = Boolean(entryFetchError)
const isKnowledgeListDegraded = entryReadback?.readback_status === 'degraded'
const knowledgeReadbackReady = !loading && !isKnowledgeListUnavailable && !isKnowledgeListDegraded
const readbackStatus = entryReadback?.readback_status ?? null
const isKnowledgeSourceBacked = Boolean(readbackStatus?.startsWith('source_backed'))
const isKnowledgeListDegraded = Boolean(readbackStatus && readbackStatus !== 'ready')
const hasKnowledgePayload = entries.length > 0 || total > 0 || categories.some(category => category.count > 0)
const knowledgeReadbackReady = !loading && !isKnowledgeListUnavailable && (!isKnowledgeListDegraded || hasKnowledgePayload)
const localeCode = params.locale === 'en' ? 'en-US' : 'zh-TW'
const formatCount = useCallback(
(value: number) => value.toLocaleString(localeCode),
@@ -1430,15 +1427,27 @@ export default function KnowledgeBasePage({
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-label text-status-warning">
<TriangleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>{entryFetchError ? t('dataChain.errorTitle') : t('dataChain.degradedTitle')}</span>
<span>
{entryFetchError
? t('dataChain.errorTitle')
: isKnowledgeSourceBacked
? t('dataChain.sourceBackedTitle')
: t('dataChain.degradedTitle')}
</span>
</div>
<p className="mt-1 text-xs font-body leading-5 text-secondary">
{entryFetchError
? t('dataChain.errorDescription', { reason: entryFetchError })
: t('dataChain.degradedDescription', {
stage: entryReadback?.operator_stage ?? '--',
next: entryReadback?.next_step ?? '--',
})}
: isKnowledgeSourceBacked
? t('dataChain.sourceBackedDescription', {
reason: entryReadback?.degraded_reason_code ?? '--',
stage: entryReadback?.operator_stage ?? '--',
next: entryReadback?.next_step ?? '--',
})
: t('dataChain.degradedDescription', {
stage: entryReadback?.operator_stage ?? '--',
next: entryReadback?.next_step ?? '--',
})}
</p>
</div>
<button