diff --git a/apps/api/src/models/knowledge.py b/apps/api/src/models/knowledge.py index 40c294dcb..bc6b1c7fd 100644 --- a/apps/api/src/models/knowledge.py +++ b/apps/api/src/models/knowledge.py @@ -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 diff --git a/apps/api/src/services/knowledge_service.py b/apps/api/src/services/knowledge_service.py index f2a067666..df95ba0ee 100644 --- a/apps/api/src/services/knowledge_service.py +++ b/apps/api/src/services/knowledge_service.py @@ -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)""" diff --git a/apps/api/tests/test_knowledge_service_readback_degraded.py b/apps/api/tests/test_knowledge_service_readback_degraded.py new file mode 100644 index 000000000..fde492668 --- /dev/null +++ b/apps/api/tests/test_knowledge_service_readback_degraded.py @@ -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 diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f94b44c97..3f3396d8b 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 55a997c55..11f920bd2 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -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": { diff --git a/apps/web/src/app/[locale]/knowledge-base/page.tsx b/apps/web/src/app/[locale]/knowledge-base/page.tsx index 2efc13f9c..842a39b27 100644 --- a/apps/web/src/app/[locale]/knowledge-base/page.tsx +++ b/apps/web/src/app/[locale]/knowledge-base/page.tsx @@ -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> = { + 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 = { + 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 = { 'AI自動化/Ansible受控修復': , AI治理: , @@ -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() rows.forEach(row => { @@ -393,6 +442,7 @@ export default function KnowledgeBasePage({ const [categories, setCategories] = useState([]) const [loading, setLoading] = useState(true) const [entryFetchError, setEntryFetchError] = useState(null) + const [entryReadback, setEntryReadback] = useState | null>(null) const [governanceTelemetry, setGovernanceTelemetry] = useState({ staleCandidates: null, ownerReviews: null, @@ -405,6 +455,7 @@ export default function KnowledgeBasePage({ // Filters const [selectedCategory, setSelectedCategory] = useState(null) const [selectedType, setSelectedType] = useState(null) + const [selectedAssetLens, setSelectedAssetLens] = useState(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 => Boolean(row)) + }, [baseDisplayedEntries, governanceTelemetry.ragStats]) const qualityRows = useMemo(() => { const loaded = displayedEntries.length @@ -1121,10 +1166,13 @@ export default function KnowledgeBasePage({ {/* All */} +
+
+ {t('assetLens.title')} + {t('assetLens.scope')} +
+
+ {assetLensRows.map(row => { + const Icon = row.icon + const isActive = selectedAssetLens === row.key + return ( + + ) + })} +
+

{t('assetLens.filterHelp')}

+
+
{t('rail.categoryTitle')} @@ -1170,26 +1254,26 @@ export default function KnowledgeBasePage({
-
- {t('assetLens.title')} - {t('assetLens.scope')} -
-
- {assetLensRows.map(row => { - const Icon = row.icon - return ( -
-
- {t(`assetLens.${row.key}` as never)} - -
-

- {(row.key === 'rag' ? governanceLoading : !knowledgeReadbackReady) ? '--' : formatCount(row.count)} -

-
- ) - })} +
+ {t('assetLens.activeTitle')} + {selectedAssetLens && ( + + )}
+

+ {selectedAssetLens ? t(`assetLens.${selectedAssetLens}` as never) : t('allCategories')} +

+

+ {selectedAssetLens + ? t(`assetLens.detail.${selectedAssetLens}` as never) + : t('assetLens.activeDescription')} +

{[ @@ -1284,16 +1368,21 @@ export default function KnowledgeBasePage({
- {entryFetchError && ( + {(entryFetchError || isKnowledgeListDegraded) && (

- {t('dataChain.errorDescription', { reason: entryFetchError })} + {entryFetchError + ? t('dataChain.errorDescription', { reason: entryFetchError }) + : t('dataChain.degradedDescription', { + stage: entryReadback?.operator_stage ?? '--', + next: entryReadback?.next_step ?? '--', + })}