""" Knowledge Service - 業務邏輯層 =============================== Knowledge Base Phase 1: CRUD + 狀態流轉 + 搜尋 建立時間: 2026-04-02 (台北時區) 建立者: Claude Code (Knowledge Base Phase 1) 遵循 leWOOOgo 積木化原則: - Service 層封裝業務邏輯 - 依賴 IKnowledgeRepository Protocol - Router 層禁止直接存取 DB """ import asyncio import json from collections.abc import Mapping from typing import Any, cast import structlog from src.core.config import settings from src.core.context import get_current_project_id from src.db.base import get_db_context from src.models.knowledge import ( CategoryCount, EntrySource, EntryStatus, EntryType, KnowledgeAssetTaxonomyCount, KnowledgeEntry, KnowledgeEntryCreate, KnowledgeEntryUpdate, KnowledgeListResponse, ) from src.repositories.interfaces import IKnowledgeRepository from src.repositories.knowledge_repository import KnowledgeDBRepository from src.services.embedding_service import OllamaEmbeddingService from src.services.model_registry import get_model as _get_model from src.utils.timezone import now_taipei logger = structlog.get_logger(__name__) _DEGRADED_CATEGORY_FALLBACKS = ( "project", "product", "website", "service", "package", "tool", "log", "alert", "playbook", "rag", "mcp", "schedule", "general", ) _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 _PRIMARY_KM_DIRECT_CONNECT_TIMEOUT_SECONDS = 1.5 _PRIMARY_KM_DIRECT_QUERY_TIMEOUT_SECONDS = 3.0 _PRIMARY_KM_DIRECT_CLOSE_TIMEOUT_SECONDS = 0.5 _PRIMARY_KM_DIRECT_STATEMENT_TIMEOUT_MS = 3500 _ASSET_TAXONOMY_CATEGORY_HINTS: dict[str, tuple[str, ...]] = { "service": ( "infrastructure", "application", "ai_system", "database", "host_resource", "kubernetes", "alert_handling", ), "tool": ("devops_tool", "AI自動化/Ansible受控修復"), "alert": ("alert_handling",), "playbook": ("auto_repair",), } _ASSET_TAXONOMY_TERMS: dict[str, tuple[str, ...]] = { "project": ( "project", "repo", "repository", "gitea", "branch", "workflow", "source_control", ), "product": ( "product", "awoooi", "awooop", "iwooos", "stockplatform", "momo", "awooogo", "agent-bounty", "agent_bounty", "tsenyang", "vibework", "2026fifa", ), "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"), } _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"], }, ) def _classify_knowledge_readback_degraded_reason(reason: str) -> str: normalized = reason.lower() 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" 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" def _knowledge_readback_exception_reason(exc: BaseException) -> str: if isinstance(exc, TimeoutError): return "TimeoutError" return str(exc) or type(exc).__name__ def _current_knowledge_project_id() -> str | None: project_id = get_current_project_id() return str(project_id).strip() if project_id else None def _normalize_direct_tags(value: object) -> list[str]: if value is None: return [] if isinstance(value, list | tuple | set): return [str(tag).strip() for tag in value if str(tag).strip()] if isinstance(value, str): stripped = value.strip() if not stripped: return [] try: parsed = json.loads(stripped) except json.JSONDecodeError: parsed = None if isinstance(parsed, list): return [str(tag).strip() for tag in parsed if str(tag).strip()] return [stripped] return [str(value).strip()] if str(value).strip() else [] def _direct_enum_member(value: object, enum_cls: type[Any], fallback: Any) -> Any: raw = str(getattr(value, "value", value) or "").lower() for member in enum_cls: if raw in {member.value.lower(), member.name.lower()}: return member return fallback def _direct_knowledge_entry_from_row(row: Mapping[str, Any]) -> KnowledgeEntry: return KnowledgeEntry( id=str(row.get("id")), title=str(row.get("title") or "Untitled KM entry"), content=str(row.get("content") or ""), entry_type=_direct_enum_member( row.get("entry_type"), EntryType, EntryType.BEST_PRACTICE, ), category=str(row.get("category") or "general").strip() or "general", tags=_normalize_direct_tags(row.get("tags")), source=_direct_enum_member( row.get("source"), EntrySource, EntrySource.AI_EXTRACTED, ), status=_direct_enum_member( row.get("status"), EntryStatus, EntryStatus.REVIEW, ), related_incident_id=row.get("related_incident_id"), related_playbook_id=row.get("related_playbook_id"), related_approval_id=row.get("related_approval_id"), path_type=row.get("path_type"), symptoms_hash=row.get("symptoms_hash"), view_count=int(row.get("view_count") or 0), created_by=row.get("created_by"), created_at=row.get("created_at") or now_taipei(), updated_at=row.get("updated_at") or row.get("created_at") or now_taipei(), ) def _direct_tag_needle(tag: str) -> str: return f'"{tag.replace(chr(34), chr(92) + chr(34))}"' def _direct_base_where(project_id: str) -> tuple[list[str], list[Any]]: return [ "project_id = $1", "lower(status::text) != 'archived'", ], [project_id] def _append_direct_filter( clauses: list[str], params: list[Any], *, category: str | None = None, entry_type: EntryType | None = None, status: EntryStatus | None = None, tags: list[str] | None = None, q: str | None = None, ) -> None: if category: params.append(str(category).strip() or "general") clauses.append( "coalesce(nullif(trim(category), ''), 'general') = " f"${len(params)}" ) if entry_type: params.append(entry_type.value) clauses.append(f"lower(entry_type::text) = ${len(params)}") if status: params.append(status.value) clauses.append(f"lower(status::text) = ${len(params)}") for tag in tags or []: params.append(_direct_tag_needle(tag)) clauses.append(f"strpos(tags::text, ${len(params)}) > 0") if q: params.append(f"%{q}%") index = len(params) clauses.append( "(" f"title ILIKE ${index} OR " f"content ILIKE ${index} OR " f"tags::text ILIKE ${index} OR " f"coalesce(nullif(trim(category), ''), 'general') ILIKE ${index}" ")" ) def _direct_where_sql(clauses: list[str]) -> str: return " WHERE " + " AND ".join(clauses) def _sql_literal(value: str) -> str: return "'" + value.replace("'", "''") + "'" def _asset_taxonomy_direct_condition(key: str) -> str: category_expr = "coalesce(nullif(trim(category), ''), 'general')" category_text = f"lower({category_expr}::text)" conditions: list[str] = [] category_hints = _ASSET_TAXONOMY_CATEGORY_HINTS.get(key, ()) if category_hints: conditions.append( f"{category_text} IN (" + ", ".join(_sql_literal(hint.lower()) for hint in category_hints) + ")" ) for term in _ASSET_TAXONOMY_TERMS.get(key, ()): pattern = _sql_literal(f"%{term.lower()}%") conditions.extend( [ f"lower(coalesce(title, '')) LIKE {pattern}", f"lower(coalesce(content, '')) LIKE {pattern}", f"lower(coalesce(tags::text, '')) LIKE {pattern}", f"{category_text} LIKE {pattern}", ] ) if key == "playbook": conditions.append("related_playbook_id IS NOT NULL") return " OR ".join(conditions) if conditions else f"{category_text} = {_sql_literal(key)}" # ============================================================================= # Singleton # ============================================================================= _knowledge_service: "KnowledgeService | None" = None 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=page, total=total, 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" ), 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 cast(list[object], 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=cast(EntryType, 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 entry.category.lower() in { hint.lower() for hint in _ASSET_TAXONOMY_CATEGORY_HINTS.get(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 if _knowledge_service is None: _knowledge_service = KnowledgeService() return _knowledge_service class KnowledgeService: """Knowledge Base 業務邏輯""" def __init__(self) -> None: # I2: 注入 embedding service,避免每次呼叫 new 實例 # D1 集中化 2026-04-11: 從 models.json providers.ollama.models.embedding 讀取 self._embed_svc = OllamaEmbeddingService(model=_get_model("ollama", "embedding"), timeout=15.0) # I1: 持有背景 Task 引用,防止 GC 提前回收 self._pending_tasks: set[asyncio.Task] = set() # type: ignore[type-arg] async def create_entry(self, data: KnowledgeEntryCreate) -> KnowledgeEntry: """建立知識條目,建立後背景自動產生 embedding""" async with get_db_context() as db: repo: IKnowledgeRepository = KnowledgeDBRepository(db) entry = await repo.create(data) logger.info( "knowledge_entry_created", entry_id=entry.id, entry_type=entry.entry_type, source=entry.source, ) # 背景產生 embedding (不阻塞回應);持有引用防 GC 回收 task = asyncio.create_task(self._embed_entry(entry.id, data.title, data.content)) self._pending_tasks.add(task) task.add_done_callback(self._pending_tasks.discard) return entry async def _embed_entry(self, entry_id: str, title: str, content: str) -> None: """背景任務:產生並儲存 embedding""" try: text = f"search_document: {title}\n\n{content[:2000]}" embedding = await self._embed_svc.embed_text(text) if not embedding: logger.warning("knowledge_embedding_empty", entry_id=entry_id) return async with get_db_context() as db: repo = KnowledgeDBRepository(db) await repo.save_embedding(entry_id, embedding) logger.info("knowledge_embedding_saved", entry_id=entry_id) except Exception as e: logger.warning("knowledge_embedding_failed", entry_id=entry_id, error=str(e)) async def get_entry(self, entry_id: str) -> KnowledgeEntry | None: """取得知識條目 (view_count +1)""" async with get_db_context() as db: repo: IKnowledgeRepository = KnowledgeDBRepository(db) entry = await repo.get_by_id(entry_id) if entry: await repo.increment_view_count(entry_id) entry.view_count += 1 return entry async def update_entry( self, entry_id: str, data: KnowledgeEntryUpdate ) -> KnowledgeEntry | None: """更新知識條目""" update_data = data.model_dump(exclude_none=True) async with get_db_context() as db: repo: IKnowledgeRepository = KnowledgeDBRepository(db) if not update_data: return await repo.get_by_id(entry_id) return await repo.update(entry_id, update_data) async def approve_entry(self, entry_id: str) -> KnowledgeEntry | None: """審核通過 (draft/review → approved)""" async with get_db_context() as db: repo: IKnowledgeRepository = KnowledgeDBRepository(db) entry = await repo.get_by_id(entry_id) if not entry: return None if entry.status == EntryStatus.APPROVED: return entry return await repo.update(entry_id, {"status": EntryStatus.APPROVED}) async def archive_entry(self, entry_id: str) -> bool: """封存 (軟刪除)""" async with get_db_context() as db: repo: IKnowledgeRepository = KnowledgeDBRepository(db) return await repo.delete(entry_id) async def _list_entries_from_primary( self, *, category: str | None, entry_type: EntryType | None, status: EntryStatus | None, tags: list[str] | None, q: str | None, limit: int, offset: int, ) -> KnowledgeListResponse: 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, ) 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", ) readback_status = "ready" degraded_reason_code: str | None = None operator_stage: str | None = None next_step: str | None = None try: categories = await asyncio.wait_for( self._read_primary_categories(), timeout=_PRIMARY_KM_TAXONOMY_TIMEOUT_SECONDS, ) except Exception as exc: # noqa: BLE001 - primary entries must stay visible logger.warning( "knowledge_list_categories_readback_degraded", error=str(exc), total=total, limit=limit, offset=offset, ) categories = _source_category_counts(items) readback_status = "ready_partial_taxonomy_degraded" degraded_reason_code = "primary_km_taxonomy_readback_degraded" operator_stage = "knowledge_primary_entries_ready_taxonomy_degraded" next_step = "repair_primary_km_taxonomy_readback_without_hiding_entries" try: asset_taxonomy = await asyncio.wait_for( self._read_primary_asset_taxonomy(), timeout=_PRIMARY_KM_TAXONOMY_TIMEOUT_SECONDS, ) except Exception as exc: # noqa: BLE001 - primary entries must stay visible logger.warning( "knowledge_list_asset_taxonomy_readback_degraded", error=str(exc), total=total, limit=limit, offset=offset, ) asset_taxonomy = _source_asset_taxonomy_counts(items) readback_status = "ready_partial_taxonomy_degraded" degraded_reason_code = "primary_km_taxonomy_readback_degraded" operator_stage = "knowledge_primary_entries_ready_taxonomy_degraded" next_step = "repair_primary_km_taxonomy_readback_without_hiding_entries" return KnowledgeListResponse( items=items, total=total, categories=categories, asset_taxonomy=asset_taxonomy, readback_status=readback_status, primary_readback_ready=True, degraded_reason_code=degraded_reason_code, operator_stage=operator_stage, next_step=next_step, ) async def _read_primary_categories(self) -> list[CategoryCount]: 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 ] return categories async def _read_primary_asset_taxonomy(self) -> list[KnowledgeAssetTaxonomyCount]: async with get_db_context() as db: repo: IKnowledgeRepository = KnowledgeDBRepository(db) asset_taxonomy_raw = await repo.get_asset_taxonomy_counts() return [ KnowledgeAssetTaxonomyCount(key=key, count=count) 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_from_direct( self, *, category: str | None, entry_type: EntryType | None, status: EntryStatus | None, tags: list[str] | None, q: str | None, limit: int, offset: int, ) -> KnowledgeListResponse | None: """Pool 壓力下以短連線只讀救回 primary KM,不把 727 筆誤降成 13 筆。""" project_id = _current_knowledge_project_id() if not project_id: return None db_url = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://") try: import asyncpg # type: ignore[import-untyped] conn = await asyncio.wait_for( asyncpg.connect(db_url), timeout=_PRIMARY_KM_DIRECT_CONNECT_TIMEOUT_SECONDS, ) try: await asyncio.wait_for( conn.execute("SELECT set_config('app.project_id', $1, TRUE)", project_id), timeout=_PRIMARY_KM_DIRECT_QUERY_TIMEOUT_SECONDS, ) await asyncio.wait_for( conn.execute( "SET statement_timeout = " f"'{int(_PRIMARY_KM_DIRECT_STATEMENT_TIMEOUT_MS)}ms'" ), timeout=_PRIMARY_KM_DIRECT_QUERY_TIMEOUT_SECONDS, ) clauses, params = _direct_base_where(project_id) _append_direct_filter( clauses, params, category=category, entry_type=entry_type, status=status, tags=tags, q=q, ) where_sql = _direct_where_sql(clauses) total = int( await asyncio.wait_for( conn.fetchval( f"SELECT count(*)::int FROM knowledge_entries{where_sql}", *params, ), timeout=_PRIMARY_KM_DIRECT_QUERY_TIMEOUT_SECONDS, ) or 0 ) if total == 0: return build_knowledge_list_readback_degraded_response( "knowledge_db_empty_direct_connection", category=category, entry_type=entry_type, status=status, tags=tags, q=q, limit=limit, offset=offset, readback_status="source_backed_db_empty_direct_connection", ) list_params = [*params, limit, offset] limit_index = len(params) + 1 offset_index = len(params) + 2 rows = await asyncio.wait_for( conn.fetch( f""" SELECT id::text AS id, title, content, entry_type::text AS entry_type, coalesce(nullif(trim(category), ''), 'general') AS category, tags, source::text AS source, status::text AS status, related_incident_id, related_playbook_id, related_approval_id, path_type, symptoms_hash, coalesce(view_count, 0)::int AS view_count, created_by, created_at, updated_at FROM knowledge_entries {where_sql} ORDER BY updated_at DESC NULLS LAST, created_at DESC NULLS LAST LIMIT ${limit_index} OFFSET ${offset_index} """, *list_params, ), timeout=_PRIMARY_KM_DIRECT_QUERY_TIMEOUT_SECONDS, ) items = [_direct_knowledge_entry_from_row(dict(row)) for row in rows] categories = await self._read_categories_direct_with_conn( conn, project_id=project_id, ) asset_taxonomy = await self._read_asset_taxonomy_direct_with_conn( conn, project_id=project_id, ) return KnowledgeListResponse( items=items, total=total, categories=categories or _source_category_counts(items), asset_taxonomy=asset_taxonomy or _source_asset_taxonomy_counts(items), readback_status="ready_direct_connection_after_session_timeout", primary_readback_ready=True, operator_stage="knowledge_readback_direct_connection_recovered", next_step="repair_session_pool_readback_without_hiding_primary_km", writes_on_read=False, manual_review_required=False, ) finally: try: await asyncio.wait_for( conn.close(), timeout=_PRIMARY_KM_DIRECT_CLOSE_TIMEOUT_SECONDS, ) except Exception as close_exc: # pragma: no cover - live cleanup logger.warning( "knowledge_direct_readback_close_failed", project_id=project_id, error_type=type(close_exc).__name__, ) except Exception as exc: # pragma: no cover - live DB pressure logger.warning( "knowledge_direct_readback_failed", project_id=project_id, error_type=type(exc).__name__, ) return None async def _read_categories_direct(self) -> list[CategoryCount] | None: project_id = _current_knowledge_project_id() if not project_id: return None db_url = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://") try: import asyncpg # type: ignore[import-untyped] conn = await asyncio.wait_for( asyncpg.connect(db_url), timeout=_PRIMARY_KM_DIRECT_CONNECT_TIMEOUT_SECONDS, ) try: await asyncio.wait_for( conn.execute("SELECT set_config('app.project_id', $1, TRUE)", project_id), timeout=_PRIMARY_KM_DIRECT_QUERY_TIMEOUT_SECONDS, ) await asyncio.wait_for( conn.execute( "SET statement_timeout = " f"'{int(_PRIMARY_KM_DIRECT_STATEMENT_TIMEOUT_MS)}ms'" ), timeout=_PRIMARY_KM_DIRECT_QUERY_TIMEOUT_SECONDS, ) return await self._read_categories_direct_with_conn( conn, project_id=project_id, ) finally: await asyncio.wait_for( conn.close(), timeout=_PRIMARY_KM_DIRECT_CLOSE_TIMEOUT_SECONDS, ) except Exception as exc: # pragma: no cover - live DB pressure logger.warning( "knowledge_categories_direct_readback_failed", project_id=project_id, error_type=type(exc).__name__, ) return None async def _read_asset_taxonomy_direct(self) -> list[KnowledgeAssetTaxonomyCount] | None: project_id = _current_knowledge_project_id() if not project_id: return None db_url = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://") try: import asyncpg # type: ignore[import-untyped] conn = await asyncio.wait_for( asyncpg.connect(db_url), timeout=_PRIMARY_KM_DIRECT_CONNECT_TIMEOUT_SECONDS, ) try: await asyncio.wait_for( conn.execute("SELECT set_config('app.project_id', $1, TRUE)", project_id), timeout=_PRIMARY_KM_DIRECT_QUERY_TIMEOUT_SECONDS, ) await asyncio.wait_for( conn.execute( "SET statement_timeout = " f"'{int(_PRIMARY_KM_DIRECT_STATEMENT_TIMEOUT_MS)}ms'" ), timeout=_PRIMARY_KM_DIRECT_QUERY_TIMEOUT_SECONDS, ) return await self._read_asset_taxonomy_direct_with_conn( conn, project_id=project_id, ) finally: await asyncio.wait_for( conn.close(), timeout=_PRIMARY_KM_DIRECT_CLOSE_TIMEOUT_SECONDS, ) except Exception as exc: # pragma: no cover - live DB pressure logger.warning( "knowledge_asset_taxonomy_direct_readback_failed", project_id=project_id, error_type=type(exc).__name__, ) return None async def _read_categories_direct_with_conn( self, conn: Any, *, project_id: str, ) -> list[CategoryCount]: rows = await asyncio.wait_for( conn.fetch( """ SELECT coalesce(nullif(trim(category), ''), 'general') AS category, count(*)::int AS cnt FROM knowledge_entries WHERE project_id = $1 AND lower(status::text) != 'archived' GROUP BY 1 ORDER BY count(*) DESC, category ASC """, project_id, ), timeout=_PRIMARY_KM_DIRECT_QUERY_TIMEOUT_SECONDS, ) return [ CategoryCount( category=str(row["category"] or "general"), count=int(row["cnt"] or 0), ) for row in rows ] async def _read_asset_taxonomy_direct_with_conn( self, conn: Any, *, project_id: str, ) -> list[KnowledgeAssetTaxonomyCount]: select_columns = ",\n".join( ( "count(*) FILTER (WHERE " f"({_asset_taxonomy_direct_condition(key)}))::int AS {key}" ) for key in _ASSET_TAXONOMY_FALLBACKS ) row = await asyncio.wait_for( conn.fetchrow( f""" SELECT {select_columns} FROM knowledge_entries WHERE project_id = $1 AND lower(status::text) != 'archived' """, project_id, ), timeout=_PRIMARY_KM_DIRECT_QUERY_TIMEOUT_SECONDS, ) if row is None: return [] return [ KnowledgeAssetTaxonomyCount(key=key, count=int(row[key] or 0)) for key in _ASSET_TAXONOMY_FALLBACKS ] async def list_entries( self, 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, ) -> KnowledgeListResponse: """列出知識條目 + 分類統計""" try: return await self._list_entries_from_primary_bounded( category=category, entry_type=entry_type, status=status, tags=tags, 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 = _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(_PRIMARY_KM_RETRY_DELAY_SECONDS) retry_response = await self._list_entries_from_primary_bounded( category=category, entry_type=entry_type, status=status, tags=tags, 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=retry_reason, degraded_reason_code=_classify_knowledge_readback_degraded_reason(retry_reason), q=q, limit=limit, offset=offset, ) direct_response = await self._list_entries_from_direct( category=category, entry_type=entry_type, status=status, tags=tags, q=q, limit=limit, offset=offset, ) if direct_response is not None: return direct_response logger.warning( "knowledge_list_readback_degraded", error=degraded_reason, degraded_reason_code=degraded_reason_code, category=category, entry_type=entry_type.value if entry_type else None, status=status.value if status else None, q=q, limit=limit, offset=offset, ) return build_knowledge_list_readback_degraded_response( degraded_reason, 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 自動化資產維度統計。""" try: 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 reason = _knowledge_readback_exception_reason(exc) if _classify_knowledge_readback_degraded_reason(reason) == "primary_km_db_timeout_or_pool_exhausted": direct_taxonomy = await self._read_asset_taxonomy_direct() if direct_taxonomy and any(row.count > 0 for row in direct_taxonomy): return direct_taxonomy 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: 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 reason = _knowledge_readback_exception_reason(exc) if _classify_knowledge_readback_degraded_reason(reason) == "primary_km_db_timeout_or_pool_exhausted": direct_categories = await self._read_categories_direct() if direct_categories and any(row.count > 0 for row in direct_categories): return direct_categories 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: 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=reason, q=query, limit=limit, degraded_reason_code=_classify_knowledge_readback_degraded_reason(reason), ) return _filter_source_backed_entries(q=query)[:limit] async def semantic_search( self, query: str, limit: int = 10, threshold: float = 0.5, ) -> list[tuple[KnowledgeEntry, float]]: """ 語意搜尋 (pgvector cosine similarity) Returns: list of (entry, score) 已按相似度降序排列 """ query_text = f"search_query: {query}" embedding = await self._embed_svc.embed_text(query_text) if not embedding: logger.warning("semantic_search_embedding_failed", query=query) return [] async with get_db_context() as db: repo: IKnowledgeRepository = KnowledgeDBRepository(db) return await repo.semantic_search(embedding, limit=limit, threshold=threshold) async def embed_all_entries(self) -> dict[str, int]: """ 批次為所有未 embed 的條目產生 embedding (管理用) Returns: {"total": N, "success": N, "failed": N} """ # C2 修復: 透過 Repository 取得資料,Service 不直接執行 raw SQL async with get_db_context() as db: repo: IKnowledgeRepository = KnowledgeDBRepository(db) rows = await repo.list_unembedded_entries() success = failed = 0 for entry_id, title, content in rows: try: text = f"search_document: {title}\n\n{content[:2000]}" embedding = await self._embed_svc.embed_text(text) if embedding: async with get_db_context() as db: repo = KnowledgeDBRepository(db) await repo.save_embedding(entry_id, embedding) success += 1 else: logger.warning("embed_all_empty_vector", entry_id=entry_id) failed += 1 except Exception as e: logger.warning("embed_all_failed", entry_id=entry_id, error=str(e)) failed += 1 logger.info("embed_all_complete", total=len(rows), success=success, failed=failed) return {"total": len(rows), "success": success, "failed": failed} async def check_anti_pattern( self, symptoms_hash: str, days: int = 7, ) -> list[KnowledgeEntry]: """ 2026-04-04 Claude Code: Phase 25 P1 — Anti-Pattern 閉環閘門 根據 symptoms_hash 查找近期失敗案例,供 auto_repair decide() 攔截用 Args: symptoms_hash: SymptomPattern.compute_hash() 的 16 字元 hash days: 查找幾天內的記錄(預設 7 天) Returns: 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) async with get_db_context() as db: result = await db.execute( sa_text( "SELECT id FROM knowledge_entries " "WHERE LOWER(CAST(entry_type AS TEXT)) = :entry_type " "AND symptoms_hash = :hash " "AND created_at >= :cutoff " "AND LOWER(CAST(status AS TEXT)) != :archived " "ORDER BY created_at DESC LIMIT 5" ), { "entry_type": EntryType.ANTI_PATTERN.value, "hash": symptoms_hash, "cutoff": cutoff, "archived": EntryStatus.ARCHIVED.value, }, ) entry_ids = [row.id for row in result.fetchall()] if not entry_ids: return [] entries = [] for eid in entry_ids: entry = await self.get_entry(eid) if entry: entries.append(entry) logger.info( "anti_pattern_check", symptoms_hash=symptoms_hash, days=days, found=len(entries), ) return entries