fix(knowledge): classify assets and fail soft list readback
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / tests (push) Has been cancelled

This commit is contained in:
Your Name
2026-07-03 09:00:42 +08:00
parent cc5b508ac5
commit 77daf55cdd
7 changed files with 266 additions and 75 deletions

View File

@@ -127,3 +127,8 @@ class KnowledgeListResponse(BaseModel):
items: list[KnowledgeEntry]
total: int
categories: list[CategoryCount] = Field(default_factory=list)
readback_status: str = "ready"
operator_stage: str | None = None
next_step: str | None = None
writes_on_read: bool = False
manual_review_required: bool = False

View File

@@ -40,6 +40,22 @@ logger = structlog.get_logger(__name__)
_knowledge_service: "KnowledgeService | None" = None
def build_knowledge_list_readback_degraded_response(reason: str) -> KnowledgeListResponse:
"""主 KM readback 失敗時回保守 payload避免前端誤判成知識庫歸零。"""
return KnowledgeListResponse(
items=[],
total=0,
categories=[],
readback_status="degraded",
operator_stage="knowledge_readback_degraded_ai_controlled_repair",
next_step=(
"queue_ai_controlled_km_readback_retry_tagging_and_connector_verifier"
),
writes_on_read=False,
manual_review_required=False,
)
def get_knowledge_service() -> "KnowledgeService":
"""取得 Knowledge Service 實例"""
global _knowledge_service
@@ -140,24 +156,37 @@ class KnowledgeService:
offset: int = 0,
) -> KnowledgeListResponse:
"""列出知識條目 + 分類統計"""
async with get_db_context() as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
items, total = await repo.list_entries(
try:
async with get_db_context() as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
items, total = await repo.list_entries(
category=category,
entry_type=entry_type,
status=status,
tags=tags,
q=q,
limit=limit,
offset=offset,
)
categories_raw = await repo.get_categories()
categories = [
CategoryCount(category=cat, count=cnt) for cat, cnt in categories_raw
]
return KnowledgeListResponse(
items=items, total=total, categories=categories
)
except Exception as exc: # noqa: BLE001 - production readback must fail soft
logger.warning(
"knowledge_list_readback_degraded",
error=str(exc),
category=category,
entry_type=entry_type,
status=status,
tags=tags,
entry_type=entry_type.value if entry_type else None,
status=status.value if status else None,
q=q,
limit=limit,
offset=offset,
)
categories_raw = await repo.get_categories()
categories = [
CategoryCount(category=cat, count=cnt) for cat, cnt in categories_raw
]
return KnowledgeListResponse(
items=items, total=total, categories=categories
)
return build_knowledge_list_readback_degraded_response(str(exc))
async def get_categories(self) -> list[CategoryCount]:
"""取得分類統計(直接呼叫 repo不走 list_entries"""

View File

@@ -0,0 +1,33 @@
import pytest
from src.services import knowledge_service as knowledge_service_module
from src.services.knowledge_service import KnowledgeService
class _BrokenDbContext:
async def __aenter__(self):
raise RuntimeError("db pool exhausted")
async def __aexit__(self, exc_type, exc, tb):
return False
@pytest.mark.asyncio
async def test_knowledge_list_entries_fails_soft_when_readback_breaks(monkeypatch) -> None:
monkeypatch.setattr(
knowledge_service_module,
"get_db_context",
lambda: _BrokenDbContext(),
)
service = KnowledgeService.__new__(KnowledgeService)
response = await service.list_entries(limit=50)
assert response.items == []
assert response.total == 0
assert response.categories == []
assert response.readback_status == "degraded"
assert response.operator_stage == "knowledge_readback_degraded_ai_controlled_repair"
assert response.next_step == "queue_ai_controlled_km_readback_retry_tagging_and_connector_verifier"
assert response.writes_on_read is False
assert response.manual_review_required is False

View File

@@ -2169,6 +2169,10 @@
"assetLens": {
"title": "資產維度",
"scope": "自動更新",
"filterHelp": "點選任一資產維度即可過濾目前列表;原始 API category 仍保留在下方。",
"activeTitle": "目前分類範圍",
"activeDescription": "顯示全部 KM可切到專案、產品、網站、服務、套件、工具、Log、Alert、PlayBook、RAG、MCP 或排程。",
"clear": "清除",
"matrixTitle": "全域分類 / 資產矩陣",
"matrixSubtitle": "用目前 KM readback 與 RAG stats把專案、產品、網站、服務、套件、工具、Log、Alert、PlayBook、RAG、MCP 與排程分群顯示。",
"autoUpdated": "API 驅動",
@@ -2203,6 +2207,8 @@
"dataChain": {
"errorTitle": "知識條目資料鏈路異常",
"errorDescription": "主知識條目 API 未成功回應:{reason}。下方治理軌道仍會顯示 Hermes 受控覆核與陳舊 KM 狀態,避免誤判成知識庫真的歸零。",
"degradedTitle": "知識條目讀取降級",
"degradedDescription": "主知識條目 API 已回 degraded{stage};下一步:{next}。目前列表不視為真實歸零AI 受控修復隊列與 RAG / PlayBook readback 仍會保留。",
"retry": "重新讀取"
},
"decision": {

View File

@@ -2169,6 +2169,10 @@
"assetLens": {
"title": "資產維度",
"scope": "自動更新",
"filterHelp": "點選任一資產維度即可過濾目前列表;原始 API category 仍保留在下方。",
"activeTitle": "目前分類範圍",
"activeDescription": "顯示全部 KM可切到專案、產品、網站、服務、套件、工具、Log、Alert、PlayBook、RAG、MCP 或排程。",
"clear": "清除",
"matrixTitle": "全域分類 / 資產矩陣",
"matrixSubtitle": "用目前 KM readback 與 RAG stats把專案、產品、網站、服務、套件、工具、Log、Alert、PlayBook、RAG、MCP 與排程分群顯示。",
"autoUpdated": "API 驅動",
@@ -2203,6 +2207,8 @@
"dataChain": {
"errorTitle": "知識條目資料鏈路異常",
"errorDescription": "主知識條目 API 未成功回應:{reason}。下方治理軌道仍會顯示 Hermes 受控覆核與陳舊 KM 狀態,避免誤判成知識庫真的歸零。",
"degradedTitle": "知識條目讀取降級",
"degradedDescription": "主知識條目 API 已回 degraded{stage};下一步:{next}。目前列表不視為真實歸零AI 受控修復隊列與 RAG / PlayBook readback 仍會保留。",
"retry": "重新讀取"
},
"decision": {

View File

@@ -57,6 +57,11 @@ interface ListResponse {
items: KnowledgeEntry[]
total: number
categories: CategoryCount[]
readback_status?: 'ready' | 'degraded' | string
operator_stage?: string | null
next_step?: string | null
writes_on_read?: boolean
manual_review_required?: boolean
}
interface KnowledgeStaleCandidate {
@@ -150,6 +155,43 @@ type AssetLensKey =
// Category Config
// =============================================================================
const ASSET_LENS_KEYS: AssetLensKey[] = [
'project',
'product',
'website',
'service',
'package',
'tool',
'log',
'alert',
'playbook',
'rag',
'mcp',
'schedule',
]
const ASSET_LENS_CATEGORY_HINTS: Partial<Record<AssetLensKey, string[]>> = {
website: ['external_site'],
service: ['infrastructure', 'application', 'ai_system', 'database', 'host_resource', 'kubernetes', 'alert_handling'],
tool: ['devops_tool'],
alert: ['alert_handling'],
}
const ASSET_LENS_TERMS: Record<AssetLensKey, string[]> = {
project: ['project', 'repo', 'repository', 'gitea', 'branch', 'workflow', 'source_control'],
product: ['product', 'awoooi', 'awooop', 'iwooos', 'vibework', 'stockplatform', 'momo', 'awooogo', 'agent-bounty', 'tsenyang'],
website: ['website', 'site', 'nginx', 'ssl', 'domain', 'route', 'frontend'],
service: ['service', 'daemon', 'api', 'worker', 'runtime', 'container', 'pod'],
package: ['package', 'dependency', 'npm', 'pnpm', 'node', 'python', 'pip', 'prisma', 'next', 'library'],
tool: ['tool', 'ansible', 'mcp', 'playbook', 'telegram', 'wazuh', 'kali', 'sentry', 'signoz', 'runner'],
log: ['log', 'logs', 'event', 'timeline', 'trace', 'audit', 'callback', 'conversation_event', 'telemetry'],
alert: ['alert', 'telegram', 'sentry', 'signoz', 'notification', 'warning', 'critical'],
playbook: ['playbook', 'runbook', 'sop'],
rag: ['rag', 'vector', 'embedding', 'semantic', 'retrieval'],
mcp: ['mcp', 'connector', 'gateway', 'tool integration', 'tool-integration'],
schedule: ['schedule', 'cron', 'job', 'worker', 'patrol', 'recurrence', 'cadence'],
}
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
'AI自動化/Ansible受控修復': <Bot className="w-4 h-4" />,
AI治理: <Bot className="w-4 h-4" />,
@@ -368,6 +410,13 @@ const entryMatchesAny = (entry: KnowledgeEntry, candidates: string[]) => {
return candidates.some(candidate => text.includes(candidate.toLowerCase()))
}
const entryMatchesAssetLens = (entry: KnowledgeEntry, lens: AssetLensKey) => {
if (lens === 'playbook' && entry.related_playbook_id) return true
const categoryHints = ASSET_LENS_CATEGORY_HINTS[lens] ?? []
if (categoryHints.includes(entry.category)) return true
return entryMatchesAny(entry, ASSET_LENS_TERMS[lens])
}
const mergeCategoryCounts = (rows: CategoryCount[]) => {
const merged = new Map<string, number>()
rows.forEach(row => {
@@ -393,6 +442,7 @@ export default function KnowledgeBasePage({
const [categories, setCategories] = useState<CategoryCount[]>([])
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 [governanceTelemetry, setGovernanceTelemetry] = useState<KnowledgeGovernanceTelemetry>({
staleCandidates: null,
ownerReviews: null,
@@ -405,6 +455,7 @@ export default function KnowledgeBasePage({
// Filters
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [selectedType, setSelectedType] = useState<string | null>(null)
const [selectedAssetLens, setSelectedAssetLens] = useState<AssetLensKey | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [semanticMode, setSemanticMode] = useState(false)
const [semanticResults, setSemanticResults] = useState<(KnowledgeEntry & { score: number })[]>([])
@@ -433,18 +484,25 @@ export default function KnowledgeBasePage({
setEntries(data.items)
setTotal(data.total)
setCategories(data.categories)
setEntryReadback({
readback_status: data.readback_status ?? 'ready',
operator_stage: data.operator_stage ?? null,
next_step: data.next_step ?? null,
})
return
}
const errorBody = await res.json().catch(() => null) as { detail?: string; message?: string } | null
setEntries([])
setTotal(0)
setCategories([])
setEntryReadback(null)
setEntryFetchError(errorBody?.detail ?? errorBody?.message ?? `${res.status} ${res.statusText}`)
} catch (err) {
console.error('Failed to fetch knowledge entries', err)
setEntries([])
setTotal(0)
setCategories([])
setEntryReadback(null)
setEntryFetchError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
@@ -570,10 +628,17 @@ export default function KnowledgeBasePage({
}
}, [selectedEntry, actionLoading, fetchEntries])
const displayedEntries = semanticMode ? semanticResults : entries
const baseDisplayedEntries = semanticMode ? semanticResults : entries
const displayedEntries = useMemo(
() => selectedAssetLens
? baseDisplayedEntries.filter(entry => entryMatchesAssetLens(entry, selectedAssetLens))
: baseDisplayedEntries,
[baseDisplayedEntries, selectedAssetLens],
)
const totalCount = categories.reduce((sum, c) => sum + c.count, 0)
const isKnowledgeListUnavailable = Boolean(entryFetchError)
const knowledgeReadbackReady = !loading && !isKnowledgeListUnavailable
const isKnowledgeListDegraded = entryReadback?.readback_status === 'degraded'
const knowledgeReadbackReady = !loading && !isKnowledgeListUnavailable && !isKnowledgeListDegraded
const localeCode = params.locale === 'en' ? 'en-US' : 'zh-TW'
const formatCount = useCallback(
(value: number) => value.toLocaleString(localeCode),
@@ -650,8 +715,8 @@ export default function KnowledgeBasePage({
const assetLensRows = useMemo(() => {
const ragChunks = governanceTelemetry.ragStats?.total_chunks ?? 0
const countWhere = (candidates: string[]) =>
displayedEntries.filter(entry => entryMatchesAny(entry, candidates)).length
const countWhere = (lens: AssetLensKey) =>
baseDisplayedEntries.filter(entry => entryMatchesAssetLens(entry, lens)).length
const rows: Array<{
key: AssetLensKey
icon: LucideIcon
@@ -662,99 +727,79 @@ export default function KnowledgeBasePage({
{
key: 'project',
icon: GitBranch,
count: countWhere([
'project', 'repo', 'repository', 'gitea', 'branch', 'workflow', 'source_control',
]),
count: countWhere('project'),
tone: 'text-claw-blue',
},
{
key: 'product',
icon: Package,
count: countWhere([
'product', 'awoooi', 'awooop', 'iwooos', 'vibework', 'stockplatform', 'momo', 'awooogo', 'agent-bounty', 'tsenyang',
]),
count: countWhere('product'),
tone: 'text-purple-600',
},
{
key: 'website',
icon: Globe2,
count: displayedEntries.filter(entry =>
entry.category === 'external_site'
|| entryMatchesAny(entry, ['website', 'site', 'nginx', 'ssl', 'domain', 'route', 'frontend']),
).length,
count: countWhere('website'),
tone: 'text-status-healthy',
},
{
key: 'service',
icon: Server,
count: displayedEntries.filter(entry =>
['infrastructure', 'application', 'ai_system', 'database', 'host_resource', 'kubernetes', 'alert_handling'].includes(entry.category)
|| entryMatchesAny(entry, ['service', 'daemon', 'api', 'worker', 'runtime', 'container', 'pod']),
).length,
count: countWhere('service'),
tone: 'text-primary',
},
{
key: 'package',
icon: Package,
count: countWhere([
'package', 'dependency', 'npm', 'pnpm', 'node', 'python', 'pip', 'prisma', 'next', 'library',
]),
count: countWhere('package'),
tone: 'text-status-warning',
},
{
key: 'tool',
icon: Wrench,
count: displayedEntries.filter(entry =>
entry.category === 'devops_tool'
|| entryMatchesAny(entry, ['tool', 'ansible', 'mcp', 'playbook', 'telegram', 'wazuh', 'kali', 'sentry', 'signoz', 'runner']),
).length,
count: countWhere('tool'),
tone: 'text-status-critical',
},
{
key: 'log',
icon: FileText,
count: countWhere(['log', 'logs', 'event', 'timeline', 'trace', 'audit', 'callback', 'conversation_event', 'telemetry']),
count: countWhere('log'),
tone: 'text-primary',
},
{
key: 'alert',
icon: TriangleAlert,
count: displayedEntries.filter(entry =>
entry.category === 'alert_handling'
|| entryMatchesAny(entry, ['alert', 'telegram', 'sentry', 'signoz', 'notification', 'warning', 'critical']),
).length,
count: countWhere('alert'),
tone: 'text-status-warning',
},
{
key: 'playbook',
icon: ClipboardList,
count: displayedEntries.filter(entry =>
Boolean(entry.related_playbook_id) || entryMatchesAny(entry, ['playbook', 'runbook', 'sop']),
).length,
count: countWhere('playbook'),
tone: 'text-claw-blue',
},
{
key: 'rag',
icon: FileSearch,
count: ragChunks || countWhere(['rag', 'vector', 'embedding', 'semantic', 'retrieval']),
count: ragChunks || countWhere('rag'),
tone: ragChunks > 0 ? 'text-status-healthy' : 'text-status-critical',
global: ragChunks > 0,
},
{
key: 'mcp',
icon: Wrench,
count: countWhere(['mcp', 'connector', 'gateway', 'tool integration', 'tool-integration']),
count: countWhere('mcp'),
tone: 'text-purple-600',
},
{
key: 'schedule',
icon: Clock3,
count: countWhere(['schedule', 'cron', 'job', 'worker', 'patrol', 'recurrence', 'cadence']),
count: countWhere('schedule'),
tone: 'text-status-healthy',
},
]
return rows
}, [displayedEntries, governanceTelemetry.ragStats])
return ASSET_LENS_KEYS.map(key => rows.find(row => row.key === key)).filter((row): row is NonNullable<typeof row> => Boolean(row))
}, [baseDisplayedEntries, governanceTelemetry.ragStats])
const qualityRows = useMemo(() => {
const loaded = displayedEntries.length
@@ -1121,10 +1166,13 @@ export default function KnowledgeBasePage({
{/* All */}
<button
onClick={() => setSelectedCategory(null)}
onClick={() => {
setSelectedCategory(null)
setSelectedAssetLens(null)
}}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm font-body transition-colors',
!selectedCategory
!selectedCategory && !selectedAssetLens
? 'bg-claw-blue/8 text-claw-blue font-medium'
: 'text-secondary hover:bg-nothing-gray-100'
)}
@@ -1134,6 +1182,42 @@ export default function KnowledgeBasePage({
<span className="text-xs text-muted">{knowledgeReadbackReady ? formatCount(total) : '--'}</span>
</button>
<div className="mt-3 rounded-md border border-nothing-gray-200 bg-white/75 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-[10px] font-label uppercase tracking-wider text-muted">{t('assetLens.title')}</span>
<span className="shrink-0 text-[10px] font-body text-muted">{t('assetLens.scope')}</span>
</div>
<div className="grid grid-cols-2 gap-1">
{assetLensRows.map(row => {
const Icon = row.icon
const isActive = selectedAssetLens === row.key
return (
<button
key={row.key}
type="button"
onClick={() => setSelectedAssetLens(isActive ? null : row.key)}
className={cn(
'rounded border px-2 py-1.5 text-left transition-colors',
isActive
? 'border-claw-blue/30 bg-claw-blue/8'
: 'border-nothing-gray-100 bg-nothing-gray-50 hover:border-claw-blue/20 hover:bg-white',
)}
title={t(`assetLens.detail.${row.key}` as never)}
>
<div className="flex items-center justify-between gap-1">
<span className="truncate text-[10px] font-body text-secondary">{t(`assetLens.${row.key}` as never)}</span>
<Icon className={cn('h-3 w-3 shrink-0', row.tone)} aria-hidden={true} />
</div>
<p className={cn('mt-1 text-sm font-heading font-semibold tabular-nums', row.tone)}>
{(row.key === 'rag' ? governanceLoading : !knowledgeReadbackReady) ? '--' : formatCount(row.count)}
</p>
</button>
)
})}
</div>
<p className="mt-2 text-[10px] font-body leading-4 text-muted">{t('assetLens.filterHelp')}</p>
</div>
<div className="mt-3 flex items-center justify-between px-2">
<span className="text-[10px] font-label uppercase tracking-wider text-muted">{t('rail.categoryTitle')}</span>
<span className="text-[10px] font-body tabular-nums text-muted">
@@ -1170,26 +1254,26 @@ export default function KnowledgeBasePage({
</div>
<div className="mt-4 rounded-md border border-nothing-gray-200 bg-white/75 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-[10px] font-label uppercase tracking-wider text-muted">{t('assetLens.title')}</span>
<span className="shrink-0 text-[10px] font-body text-muted">{t('assetLens.scope')}</span>
</div>
<div className="grid grid-cols-2 gap-1">
{assetLensRows.map(row => {
const Icon = row.icon
return (
<div key={row.key} className="rounded border border-nothing-gray-100 bg-nothing-gray-50 px-2 py-1.5">
<div className="flex items-center justify-between gap-1">
<span className="truncate text-[10px] font-body text-secondary">{t(`assetLens.${row.key}` as never)}</span>
<Icon className={cn('h-3 w-3 shrink-0', row.tone)} aria-hidden={true} />
</div>
<p className={cn('mt-1 text-sm font-heading font-semibold tabular-nums', row.tone)}>
{(row.key === 'rag' ? governanceLoading : !knowledgeReadbackReady) ? '--' : formatCount(row.count)}
</p>
</div>
)
})}
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[10px] font-label uppercase tracking-wider text-muted">{t('assetLens.activeTitle')}</span>
{selectedAssetLens && (
<button
type="button"
onClick={() => setSelectedAssetLens(null)}
className="shrink-0 rounded border border-nothing-gray-200 bg-white px-2 py-0.5 text-[10px] font-label text-muted transition-colors hover:border-claw-blue/30 hover:text-claw-blue"
>
{t('assetLens.clear')}
</button>
)}
</div>
<p className="mt-1 text-sm font-heading font-semibold text-primary">
{selectedAssetLens ? t(`assetLens.${selectedAssetLens}` as never) : t('allCategories')}
</p>
<p className="mt-1 text-[10px] font-body leading-4 text-muted">
{selectedAssetLens
? t(`assetLens.detail.${selectedAssetLens}` as never)
: t('assetLens.activeDescription')}
</p>
</div>
<div className="mt-4 grid grid-cols-2 gap-2 lg:grid-cols-1">
{[
@@ -1284,16 +1368,21 @@ export default function KnowledgeBasePage({
</button>
</div>
{entryFetchError && (
{(entryFetchError || isKnowledgeListDegraded) && (
<div className="border-b border-status-warning/20 bg-status-warning/10 px-4 py-3">
<div className="flex flex-col gap-2 rounded-md border border-status-warning/25 bg-white/75 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<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>{t('dataChain.errorTitle')}</span>
<span>{entryFetchError ? t('dataChain.errorTitle') : t('dataChain.degradedTitle')}</span>
</div>
<p className="mt-1 text-xs font-body leading-5 text-secondary">
{t('dataChain.errorDescription', { reason: entryFetchError })}
{entryFetchError
? t('dataChain.errorDescription', { reason: entryFetchError })
: t('dataChain.degradedDescription', {
stage: entryReadback?.operator_stage ?? '--',
next: entryReadback?.next_step ?? '--',
})}
</p>
</div>
<button

View File

@@ -53942,3 +53942,26 @@ production browser smoke:
**下一步**
- commit / push 聚合告警修正;再套用新版 Prometheus rule確認 `BackupAlertReceiptStageMissing` pending/firing 數量聚合成 110 / 188 host-level summary而不是逐 stage 噪音。
## 2026-07-03 — Knowledge Base 資產分類與 AI 自動化資訊架構
**完成內容**
- `/zh-TW/knowledge-base` 左側分類從單一 API category 清單提升為「資產維度 + 動態分類」雙層 IA新增可點選過濾的專案、產品、網站、服務、套件、工具、Log / 事件、Alert / 告警、PlayBook、RAG / 向量、MCP / Connector、排程 / Worker。
- 新增 `entryMatchesAssetLens` 與固定 taxonomy hints先用 title / category / entry_type / source / tags / playbook 關聯完成 UI 分群,不等待後端 schema 先改完;後續可把同一 taxonomy 回寫到 ingest/tagger。
- 左側新增目前分類範圍摘要與清除按鈕,避免使用者只看到「基礎設施 / 應用層 / AI 系統 / 安全」少數粗分類而誤判 KM 資料不完整。
- `KnowledgeListResponse` 新增 `readback_status / operator_stage / next_step / writes_on_read / manual_review_required``KnowledgeService.list_entries` 讀取失敗時改回 `readback_status=degraded``operator_stage=knowledge_readback_degraded_ai_controlled_repair` 與空 items不再讓 `/api/v1/knowledge` 間歇性 500 使前端誤判 KM 歸零。
- 前端收到 degraded 會顯示「知識條目讀取降級」KPI 不把 0 當真實完成值,並保留 AI 受控修復隊列與 RAG / PlayBook readback。
**本地驗證結果**
- `python3 -m json.tool apps/web/messages/zh-TW.json``python3 -m json.tool apps/web/messages/en.json`:通過。
- `pnpm --dir apps/web typecheck`:通過。
- `DATABASE_URL=postgresql+asyncpg://test:test@localhost/test PYTHONPATH=apps/api python3.11 -m pytest apps/api/tests/test_knowledge_service_readback_degraded.py apps/api/tests/test_knowledge_repository_read_model.py -q -p no:cacheprovider``5 passed`
- `python3.11 -m py_compile apps/api/src/models/knowledge.py apps/api/src/services/knowledge_service.py apps/api/tests/test_knowledge_service_readback_degraded.py`:通過。
**仍維持**
- 沒有讀 secret / token / `.env` / raw sessions / SQLite / auth。
- 沒有使用 GitHub / gh / GitHub API / GitHub Actions。
- 沒有重啟主機,沒有 Docker / Nginx / K3s / DB / firewall restart沒有 workflow_dispatch沒有 DROP / TRUNCATE / restore / prune。
**下一步**
- 跑 web build、UI density、runner pressure guard 與 Knowledge Base desktop / mobile smoke通過後 commit / push 到 Gitea main等 deploy marker 再驗證正式頁面顯示新的資產分類與主知識 API fail-soft。