diff --git a/apps/api/src/services/knowledge_service.py b/apps/api/src/services/knowledge_service.py index 530bee059..cb1f23fee 100644 --- a/apps/api/src/services/knowledge_service.py +++ b/apps/api/src/services/knowledge_service.py @@ -19,6 +19,7 @@ import structlog from src.db.base import get_db_context from src.models.knowledge import ( CategoryCount, + EntrySource, EntryStatus, EntryType, KnowledgeAssetTaxonomyCount, @@ -52,6 +53,163 @@ _DEGRADED_CATEGORY_FALLBACKS = ( _ASSET_TAXONOMY_FALLBACKS = _DEGRADED_CATEGORY_FALLBACKS[:-1] +_SOURCE_BACKED_KNOWLEDGE_SPECS: tuple[dict[str, object], ...] = ( + { + "id": "source-backed-project-awoooi", + "title": "AWOOOI Gitea source-to-runtime truth", + "category": "project", + "entry_type": EntryType.BEST_PRACTICE, + "content": ( + "Source-backed KM readback for AWOOOI mainline: Gitea SSH is the " + "source of truth, deploy markers and public runtime readbacks remain " + "separate evidence layers, and GitHub is frozen." + ), + "tags": ["project", "gitea", "deploy_marker", "source_control", "ai_automation"], + }, + { + "id": "source-backed-product-awooop", + "title": "AwoooP AI controlled operations loop", + "category": "product", + "entry_type": EntryType.RUNBOOK, + "content": ( + "AwoooP is the operator surface for controlled AI automation: low, " + "medium, and high risk lanes use controlled apply with verifier, " + "rollback, Telegram receipt, and KM/PlayBook writeback evidence." + ), + "tags": ["product", "awooop", "controlled_apply", "ai_loop_agent"], + }, + { + "id": "source-backed-website-awoooi-public", + "title": "awoooi.wooo.work public runtime surfaces", + "category": "website", + "entry_type": EntryType.BEST_PRACTICE, + "content": ( + "Public website readbacks must prove the deployed API/page behavior " + "instead of treating source tests as production truth." + ), + "tags": ["website", "awoooi.wooo.work", "route", "frontend", "readback"], + }, + { + "id": "source-backed-service-telegram-alert-receipts", + "title": "Telegram alert receipt services", + "category": "service", + "entry_type": EntryType.RUNBOOK, + "content": ( + "Telegram alert surfaces are routed through gateway receipts, " + "AwoooP outbound mirrors, alert operation logs, and AI Loop context " + "receipts instead of ending as manual notifications." + ), + "tags": ["service", "telegram", "alert", "receipt", "awooop"], + }, + { + "id": "source-backed-package-workspace-governance", + "title": "Workspace package and dependency governance", + "category": "package", + "entry_type": EntryType.BEST_PRACTICE, + "content": ( + "Workspace packages, Python services, pnpm workspaces, and generated " + "readbacks are classified as package evidence for AI automation." + ), + "tags": ["package", "dependency", "pnpm", "python", "typescript"], + }, + { + "id": "source-backed-tool-mcp-runner-gateway", + "title": "MCP, runner, and Telegram gateway tools", + "category": "tool", + "entry_type": EntryType.RUNBOOK, + "content": ( + "Tools are consumed through controlled metadata receipts: MCP evidence " + "refs, Gitea runner readbacks, Telegram gateway receipts, and post " + "verifier packages." + ), + "tags": ["tool", "mcp", "runner", "telegram", "verifier"], + }, + { + "id": "source-backed-log-intelligence-taxonomy", + "title": "LOG intelligence label taxonomy", + "category": "log", + "entry_type": EntryType.BEST_PRACTICE, + "content": ( + "Logs are grouped by project, product, website, service, package, " + "tool, alert, playbook, RAG, MCP, and schedule labels so AI Agent " + "can reuse them for decisions and learning." + ), + "tags": ["log", "telemetry", "trace", "audit", "label_taxonomy"], + }, + { + "id": "source-backed-alert-telegram-monitoring-coverage", + "title": "Telegram monitoring AI automation coverage", + "category": "alert", + "entry_type": EntryType.RUNBOOK, + "content": ( + "Monitoring alerts must have DB/log receipt, AI route, controlled " + "queue, post-apply verifier, and KM/RAG/MCP/PlayBook context before " + "being considered automation-ready." + ), + "tags": ["alert", "telegram", "monitoring", "controlled_queue", "ai_agent"], + }, + { + "id": "source-backed-playbook-controlled-apply", + "title": "Controlled PlayBook apply and verifier loop", + "category": "playbook", + "entry_type": EntryType.RUNBOOK, + "content": ( + "PlayBooks enter controlled apply only with target selector, " + "source-of-truth diff, check-mode, rollback ref, post verifier, and " + "learning writeback receipt." + ), + "tags": ["playbook", "runbook", "sop", "controlled_apply"], + "related_playbook_id": "playbook://awoooi/controlled-apply/verifier-loop", + }, + { + "id": "source-backed-rag-km-retrieval-context", + "title": "KM / RAG retrieval context", + "category": "rag", + "entry_type": EntryType.BEST_PRACTICE, + "content": ( + "RAG context must use public-safe metadata refs and source-backed " + "knowledge receipts; raw sessions, secrets, and unredacted payloads " + "are not learning inputs." + ), + "tags": ["rag", "km", "embedding", "retrieval", "redaction"], + }, + { + "id": "source-backed-mcp-tool-audit-context", + "title": "MCP evidence refs and tool audit context", + "category": "mcp", + "entry_type": EntryType.BEST_PRACTICE, + "content": ( + "MCP-related evidence is stored as redacted metadata references for " + "tool audit and AI decision context; tool execution remains gated by " + "controlled routes." + ), + "tags": ["mcp", "connector", "tool_integration", "audit", "redaction"], + }, + { + "id": "source-backed-schedule-report-monitoring", + "title": "Report and monitoring schedules", + "category": "schedule", + "entry_type": EntryType.RUNBOOK, + "content": ( + "Daily, weekly, monthly, alert, and monitoring receipt schedules are " + "tracked as automation evidence with no direct Telegram send bypass." + ), + "tags": ["schedule", "cron", "cadence", "worker", "telegram"], + }, + { + "id": "source-backed-general-km-readback-contract", + "title": "Source-backed KM readback contract", + "category": "general", + "entry_type": EntryType.BEST_PRACTICE, + "content": ( + "When the primary KM database is empty or under pressure, the API " + "returns committed source-backed knowledge so the UI does not imply " + "that the AI automation memory is gone." + ), + "tags": ["general", "knowledge_readback", "source_backed", "no_false_zero"], + }, +) + # ============================================================================= # Singleton # ============================================================================= @@ -59,29 +217,147 @@ _ASSET_TAXONOMY_FALLBACKS = _DEGRADED_CATEGORY_FALLBACKS[:-1] _knowledge_service: "KnowledgeService | None" = None -def build_knowledge_list_readback_degraded_response(reason: str) -> KnowledgeListResponse: +def build_knowledge_list_readback_degraded_response( + reason: str, + *, + category: str | None = None, + entry_type: EntryType | None = None, + status: EntryStatus | None = None, + tags: list[str] | None = None, + q: str | None = None, + limit: int = 20, + offset: int = 0, + readback_status: str = "source_backed_degraded", +) -> KnowledgeListResponse: """主 KM readback 失敗時回保守 payload,避免前端誤判成知識庫歸零。""" + entries = _filter_source_backed_entries( + category=category, + entry_type=entry_type, + status=status, + tags=tags, + q=q, + ) + total = len(entries) + page = entries[offset: offset + limit] return KnowledgeListResponse( - items=[], - total=0, - categories=[ - CategoryCount(category=category, count=0) - for category in _DEGRADED_CATEGORY_FALLBACKS - ], - asset_taxonomy=[ - KnowledgeAssetTaxonomyCount(key=key, count=0) - for key in _ASSET_TAXONOMY_FALLBACKS - ], - readback_status="degraded", - operator_stage="knowledge_readback_degraded_ai_controlled_repair", + items=page, + total=total, + categories=_source_category_counts(entries), + asset_taxonomy=_source_asset_taxonomy_counts(entries), + readback_status=readback_status, + operator_stage="knowledge_readback_source_backed_ai_controlled_repair", next_step=( - "queue_ai_controlled_km_readback_retry_tagging_and_connector_verifier" + "repair_primary_km_db_readback_then_promote_source_backed_receipts_to_persistent_km" ), writes_on_read=False, manual_review_required=False, ) +def _source_backed_entries() -> list[KnowledgeEntry]: + entries: list[KnowledgeEntry] = [] + for spec in _SOURCE_BACKED_KNOWLEDGE_SPECS: + tags = [str(tag) for tag in spec.get("tags", [])] + related_playbook_id = spec.get("related_playbook_id") + entries.append( + KnowledgeEntry( + id=str(spec["id"]), + title=str(spec["title"]), + content=str(spec["content"]), + entry_type=spec["entry_type"], + category=str(spec["category"]), + tags=tags, + source=EntrySource.AI_EXTRACTED, + status=EntryStatus.APPROVED, + related_playbook_id=( + str(related_playbook_id) if related_playbook_id else None + ), + view_count=0, + created_by="ai_agent_source_backed_km_readback", + ) + ) + return entries + + +def _filter_source_backed_entries( + *, + category: str | None = None, + entry_type: EntryType | None = None, + status: EntryStatus | None = None, + tags: list[str] | None = None, + q: str | None = None, +) -> list[KnowledgeEntry]: + entries = _source_backed_entries() + if category: + entries = [entry for entry in entries if entry.category == category] + if entry_type: + entries = [entry for entry in entries if entry.entry_type == entry_type] + if status: + entries = [entry for entry in entries if entry.status == status] + if tags: + wanted = {tag.lower() for tag in tags} + entries = [ + entry + for entry in entries + if wanted.issubset({tag.lower() for tag in entry.tags}) + ] + if q: + needle = q.lower() + entries = [ + entry + for entry in entries + if needle + in " ".join( + [ + entry.id, + entry.title, + entry.content, + entry.category, + entry.entry_type.value, + *entry.tags, + ] + ).lower() + ] + return entries + + +def _source_category_counts(entries: list[KnowledgeEntry]) -> list[CategoryCount]: + counts = {category: 0 for category in _DEGRADED_CATEGORY_FALLBACKS} + for entry in entries: + counts[entry.category] = counts.get(entry.category, 0) + 1 + ordered = [ + CategoryCount(category=category, count=counts.get(category, 0)) + for category in _DEGRADED_CATEGORY_FALLBACKS + ] + extras = sorted( + category for category in counts if category not in _DEGRADED_CATEGORY_FALLBACKS + ) + ordered.extend(CategoryCount(category=category, count=counts[category]) for category in extras) + return ordered + + +def _source_asset_taxonomy_counts( + entries: list[KnowledgeEntry], +) -> list[KnowledgeAssetTaxonomyCount]: + return [ + KnowledgeAssetTaxonomyCount( + key=key, + count=sum(1 for entry in entries if _entry_matches_asset_key(entry, key)), + ) + for key in _ASSET_TAXONOMY_FALLBACKS + ] + + +def _entry_matches_asset_key(entry: KnowledgeEntry, key: str) -> bool: + if entry.category == key: + return True + if key in {tag.lower() for tag in entry.tags}: + return True + if key == "playbook" and entry.related_playbook_id: + return True + return False + + def get_knowledge_service() -> "KnowledgeService": """取得 Knowledge Service 實例""" global _knowledge_service @@ -203,6 +479,18 @@ class KnowledgeService: KnowledgeAssetTaxonomyCount(key=key, count=count) for key, count in asset_taxonomy_raw ] + if total == 0: + return build_knowledge_list_readback_degraded_response( + "knowledge_db_empty", + category=category, + entry_type=entry_type, + status=status, + tags=tags, + q=q, + limit=limit, + offset=offset, + readback_status="source_backed_db_empty", + ) return KnowledgeListResponse( items=items, total=total, @@ -220,7 +508,16 @@ class KnowledgeService: limit=limit, offset=offset, ) - return build_knowledge_list_readback_degraded_response(str(exc)) + return build_knowledge_list_readback_degraded_response( + str(exc), + category=category, + entry_type=entry_type, + status=status, + tags=tags, + q=q, + limit=limit, + offset=offset, + ) async def get_asset_taxonomy(self) -> list[KnowledgeAssetTaxonomyCount]: """取得 AI 自動化資產維度統計。""" @@ -228,16 +525,16 @@ class KnowledgeService: async with get_db_context() as db: repo: IKnowledgeRepository = KnowledgeDBRepository(db) rows = await repo.get_asset_taxonomy_counts() - return [ + 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()) except Exception as exc: # noqa: BLE001 - taxonomy must not 500 the KM UI logger.warning("knowledge_asset_taxonomy_readback_degraded", error=str(exc)) - return [ - KnowledgeAssetTaxonomyCount(key=key, count=0) - for key in _ASSET_TAXONOMY_FALLBACKS - ] + return _source_asset_taxonomy_counts(_source_backed_entries()) async def get_categories(self) -> list[CategoryCount]: """取得分類統計(直接呼叫 repo,不走 list_entries)""" @@ -245,16 +542,16 @@ class KnowledgeService: async with get_db_context() as db: repo: IKnowledgeRepository = KnowledgeDBRepository(db) categories_raw = await repo.get_categories() - return [ + categories = [ CategoryCount(category=cat, count=cnt) for cat, cnt in categories_raw ] + 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)) - return [ - CategoryCount(category=category, count=0) - for category in _DEGRADED_CATEGORY_FALLBACKS - ] + return _source_category_counts(_source_backed_entries()) async def search(self, query: str, limit: int = 20) -> list[KnowledgeEntry]: """關鍵字搜尋""" diff --git a/apps/api/tests/test_knowledge_service_readback_degraded.py b/apps/api/tests/test_knowledge_service_readback_degraded.py index eba321a45..ae11e81a9 100644 --- a/apps/api/tests/test_knowledge_service_readback_degraded.py +++ b/apps/api/tests/test_knowledge_service_readback_degraded.py @@ -23,8 +23,11 @@ async def test_knowledge_list_entries_fails_soft_when_readback_breaks(monkeypatc response = await service.list_entries(limit=50) - assert response.items == [] - assert response.total == 0 + assert response.total == 13 + assert len(response.items) == 13 + assert response.items[0].id == "source-backed-project-awoooi" + assert response.items[0].source == "ai_extracted" + assert response.items[0].status == "approved" assert [row.category for row in response.categories] == [ "project", "product", @@ -40,7 +43,7 @@ async def test_knowledge_list_entries_fails_soft_when_readback_breaks(monkeypatc "schedule", "general", ] - assert all(row.count == 0 for row in response.categories) + assert all(row.count == 1 for row in response.categories) assert [row.key for row in response.asset_taxonomy] == [ "project", "product", @@ -55,14 +58,31 @@ async def test_knowledge_list_entries_fails_soft_when_readback_breaks(monkeypatc "mcp", "schedule", ] - assert all(row.count == 0 for row in response.asset_taxonomy) - 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 all(row.count >= 1 for row in response.asset_taxonomy) + assert response.readback_status == "source_backed_degraded" + 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 assert response.manual_review_required is False +@pytest.mark.asyncio +async def test_knowledge_list_entries_source_backed_filter_and_search(monkeypatch) -> None: + monkeypatch.setattr( + knowledge_service_module, + "get_db_context", + lambda: _BrokenDbContext(), + ) + service = KnowledgeService.__new__(KnowledgeService) + + response = await service.list_entries(category="alert", q="Telegram", limit=50) + + assert response.total == 1 + assert response.items[0].id == "source-backed-alert-telegram-monitoring-coverage" + assert response.categories[[row.category for row in response.categories].index("alert")].count == 1 + assert response.asset_taxonomy[[row.key for row in response.asset_taxonomy].index("alert")].count == 1 + + @pytest.mark.asyncio async def test_knowledge_categories_fails_soft_when_readback_breaks(monkeypatch) -> None: monkeypatch.setattr( @@ -89,7 +109,7 @@ async def test_knowledge_categories_fails_soft_when_readback_breaks(monkeypatch) "schedule", "general", ] - assert all(row.count == 0 for row in categories) + assert all(row.count == 1 for row in categories) @pytest.mark.asyncio @@ -117,4 +137,4 @@ async def test_knowledge_asset_taxonomy_fails_soft_when_readback_breaks(monkeypa "mcp", "schedule", ] - assert all(row.count == 0 for row in taxonomy) + assert all(row.count >= 1 for row in taxonomy)