diff --git a/apps/api/src/api/v1/knowledge.py b/apps/api/src/api/v1/knowledge.py new file mode 100644 index 000000000..db72faf13 --- /dev/null +++ b/apps/api/src/api/v1/knowledge.py @@ -0,0 +1,124 @@ +""" +Knowledge Base API Router +========================== +Knowledge Base Phase 1: REST API + +端點: /api/v1/knowledge + +建立時間: 2026-04-02 (台北時區) +建立者: Claude Code (Knowledge Base Phase 1) + +遵循 leWOOOgo 積木化原則: +- Router 層只做 HTTP 轉發 +- 業務邏輯在 Service 層 +- 禁止直接存取 DB/Redis +""" + +import structlog +from fastapi import APIRouter, HTTPException, Query + +from src.models.knowledge import ( + EntryStatus, + EntryType, + KnowledgeEntry, + KnowledgeEntryCreate, + KnowledgeEntryUpdate, + KnowledgeListResponse, +) +from src.services.knowledge_service import get_knowledge_service + +logger = structlog.get_logger(__name__) + +router = APIRouter(prefix="/knowledge", tags=["Knowledge Base"]) + + +# ============================================================================= +# Endpoints +# ============================================================================= + +@router.get("/", response_model=KnowledgeListResponse) +async def list_entries( + category: str | None = Query(None, description="篩選分類"), + entry_type: EntryType | None = Query(None, description="篩選類型"), + status: EntryStatus | None = Query(None, description="篩選狀態"), + q: str | None = Query(None, description="關鍵字搜尋"), + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), +) -> KnowledgeListResponse: + """列出知識條目""" + service = get_knowledge_service() + return await service.list_entries( + category=category, + entry_type=entry_type, + status=status, + q=q, + limit=limit, + offset=offset, + ) + + +@router.get("/search", response_model=list[KnowledgeEntry]) +async def search_entries( + q: str = Query(..., min_length=1, description="搜尋關鍵字"), + limit: int = Query(20, ge=1, le=100), +) -> list[KnowledgeEntry]: + """關鍵字搜尋 (title + content + tags)""" + service = get_knowledge_service() + return await service.search(q, limit) + + +@router.get("/categories") +async def get_categories() -> list[dict]: + """取得分類樹 (含各類數量)""" + service = get_knowledge_service() + result = await service.list_entries(limit=0) + return [cat.model_dump() for cat in result.categories] + + +@router.get("/{entry_id}", response_model=KnowledgeEntry) +async def get_entry(entry_id: str) -> KnowledgeEntry: + """取得單筆知識條目 (view_count +1)""" + service = get_knowledge_service() + entry = await service.get_entry(entry_id) + if not entry: + raise HTTPException(status_code=404, detail="Knowledge entry not found") + return entry + + +@router.post("/", response_model=KnowledgeEntry, status_code=201) +async def create_entry(data: KnowledgeEntryCreate) -> KnowledgeEntry: + """建立新知識條目""" + service = get_knowledge_service() + return await service.create_entry(data) + + +@router.patch("/{entry_id}", response_model=KnowledgeEntry) +async def update_entry( + entry_id: str, + data: KnowledgeEntryUpdate, +) -> KnowledgeEntry: + """更新知識條目""" + service = get_knowledge_service() + entry = await service.update_entry(entry_id, data) + if not entry: + raise HTTPException(status_code=404, detail="Knowledge entry not found") + return entry + + +@router.post("/{entry_id}/approve", response_model=KnowledgeEntry) +async def approve_entry(entry_id: str) -> KnowledgeEntry: + """審核通過""" + service = get_knowledge_service() + entry = await service.approve_entry(entry_id) + if not entry: + raise HTTPException(status_code=404, detail="Knowledge entry not found") + return entry + + +@router.delete("/{entry_id}", status_code=204) +async def archive_entry(entry_id: str) -> None: + """封存知識條目 (軟刪除)""" + service = get_knowledge_service() + success = await service.archive_entry(entry_id) + if not success: + raise HTTPException(status_code=404, detail="Knowledge entry not found") diff --git a/apps/api/src/db/models.py b/apps/api/src/db/models.py index eabb78bd8..90a132305 100644 --- a/apps/api/src/db/models.py +++ b/apps/api/src/db/models.py @@ -30,6 +30,7 @@ from sqlalchemy.orm import Mapped, mapped_column from src.db.base import Base from src.models.approval import ApprovalStatus, RiskLevel from src.models.incident import IncidentStatus, Severity +from src.models.knowledge import EntrySource, EntryStatus, EntryType # ============================================================================= # Helper Functions @@ -454,3 +455,100 @@ class IncidentRecord(Base): Index("ix_incident_created_at", "created_at"), Index("ix_incident_resolved_at", "resolved_at"), ) + + +# ============================================================================= +# KnowledgeEntry - Knowledge Base Phase 1 +# ============================================================================= + +class KnowledgeEntryRecord(Base): + """ + 知識庫條目 - Knowledge Base Phase 1 + + 兩層架構: + - KnowledgeEntry: 知識條目 (此表) + - Playbook: 獨立 Redis,透過 related_playbook_id 關聯 + + 建立時間: 2026-04-02 (台北時區) + 建立者: Claude Code (Knowledge Base Phase 1) + """ + __tablename__ = "knowledge_entries" + + # Primary Key + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=generate_uuid, + ) + + # Core Fields + title: Mapped[str] = mapped_column(String(255), nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + entry_type: Mapped[str] = mapped_column( + SQLEnum(EntryType), + nullable=False, + comment="incident_case / runbook / best_practice / postmortem", + ) + category: Mapped[str] = mapped_column( + String(100), + nullable=False, + comment="分類樹節點 (基礎設施/應用層/AI系統/安全合規)", + ) + tags: Mapped[list[dict[str, Any]]] = mapped_column( + JSON, + default=list, + nullable=False, + comment="標籤列表 (JSONB)", + ) + + # Source & Status + source: Mapped[str] = mapped_column( + SQLEnum(EntrySource), + nullable=False, + comment="ai_extracted / human", + ) + status: Mapped[str] = mapped_column( + SQLEnum(EntryStatus), + default=EntryStatus.DRAFT, + nullable=False, + comment="draft / review / approved / archived", + ) + + # Relations (soft references, not FK) + related_incident_id: Mapped[str | None] = mapped_column( + String(30), + nullable=True, + comment="關聯 Incident ID", + ) + related_playbook_id: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + comment="關聯 Playbook Redis Key", + ) + + # Metrics + view_count: Mapped[int] = mapped_column( + Integer, + default=0, + nullable=False, + ) + + # Metadata + created_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + onupdate=utc_now, + ) + + # Indexes + __table_args__ = ( + Index("ix_knowledge_entry_type", "entry_type"), + Index("ix_knowledge_category", "category"), + Index("ix_knowledge_status", "status"), + Index("ix_knowledge_created_at", "created_at"), + ) diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 5b67acd38..383dbd07f 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -46,6 +46,7 @@ from src.api.v1 import ( # Import API routers from src.api.v1 import health as health_v1 from src.api.v1 import incidents as incidents_v1 # Phase 6.4: Decision Proposal +from src.api.v1 import knowledge as knowledge_v1 # KB Phase 1: Knowledge Base from src.api.v1 import learning as learning_v1 # Phase D-G P0: Learning API from src.api.v1 import metrics as metrics_v1 # Phase 7: Gold Metrics (真實血脈) from src.api.v1 import playbooks as playbooks_v1 # #7: Playbook 萃取 @@ -425,6 +426,9 @@ app.include_router( app.include_router( learning_v1.router, prefix="/api/v1", tags=["Learning"] ) # Phase D-G P0: 學習系統 API +app.include_router( + knowledge_v1.router, prefix="/api/v1", tags=["Knowledge Base"] +) # KB Phase 1: Knowledge Base API app.include_router( proposals_router.router, tags=["Proposals (Legacy)"] ) # Phase 6.4g: lewooogo-brain (舊版) diff --git a/apps/api/src/models/knowledge.py b/apps/api/src/models/knowledge.py new file mode 100644 index 000000000..bfece7c82 --- /dev/null +++ b/apps/api/src/models/knowledge.py @@ -0,0 +1,109 @@ +""" +Knowledge Base Models +===================== +Phase KB-1: 知識庫資料模型 + +兩層架構: +- KnowledgeEntry: 知識條目 (incident_case / runbook / best_practice / postmortem) +- Playbook: 獨立系統,透過 related_playbook_id 關聯 + +建立時間: 2026-04-02 (台北時區) +建立者: Claude Code (Knowledge Base Phase 1) + +遵循 leWOOOgo 積木化原則: +- Pydantic BaseModel 定義 +- PostgreSQL Episodic Memory +""" + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field + +from src.utils.timezone import now_taipei + + +# ============================================================================= +# Enums +# ============================================================================= + +class EntryType(str, Enum): + """知識條目類型""" + INCIDENT_CASE = "incident_case" # AI 從 Incident 萃取的案例分析 + RUNBOOK = "runbook" # 手動建立的操作手冊 + BEST_PRACTICE = "best_practice" # 最佳實踐文章 + POSTMORTEM = "postmortem" # 事後分析報告 + + +class EntrySource(str, Enum): + """知識來源""" + AI_EXTRACTED = "ai_extracted" # AI 自動萃取 + HUMAN = "human" # 人工建立 + + +class EntryStatus(str, Enum): + """知識條目狀態""" + DRAFT = "draft" # 草稿 + REVIEW = "review" # 審核中 + APPROVED = "approved" # 已批准 + ARCHIVED = "archived" # 已封存 + + +# ============================================================================= +# Pydantic Models +# ============================================================================= + +class KnowledgeEntryCreate(BaseModel): + """建立知識條目 Request""" + title: str = Field(..., min_length=1, max_length=255) + content: str = Field(..., min_length=1) + entry_type: EntryType + category: str = Field(..., min_length=1, max_length=100) + tags: list[str] = Field(default_factory=list) + source: EntrySource = EntrySource.HUMAN + related_incident_id: str | None = None + related_playbook_id: str | None = None + created_by: str | None = None + + +class KnowledgeEntryUpdate(BaseModel): + """更新知識條目 Request""" + title: str | None = None + content: str | None = None + entry_type: EntryType | None = None + category: str | None = None + tags: list[str] | None = None + status: EntryStatus | None = None + + +class KnowledgeEntry(BaseModel): + """知識條目完整模型""" + id: str + title: str + content: str + entry_type: EntryType + category: str + tags: list[str] = Field(default_factory=list) + source: EntrySource + status: EntryStatus = EntryStatus.DRAFT + related_incident_id: str | None = None + related_playbook_id: str | None = None + view_count: int = 0 + created_by: str | None = None + created_at: datetime = Field(default_factory=now_taipei) + updated_at: datetime = Field(default_factory=now_taipei) + + model_config = {"from_attributes": True} + + +class CategoryCount(BaseModel): + """分類統計""" + category: str + count: int + + +class KnowledgeListResponse(BaseModel): + """列表回應""" + items: list[KnowledgeEntry] + total: int + categories: list[CategoryCount] = Field(default_factory=list) diff --git a/apps/api/src/repositories/interfaces.py b/apps/api/src/repositories/interfaces.py index 1a5c920b0..b702a8407 100644 --- a/apps/api/src/repositories/interfaces.py +++ b/apps/api/src/repositories/interfaces.py @@ -18,6 +18,12 @@ from uuid import UUID from src.models.approval import ApprovalRequest, ApprovalRequestCreate, ApprovalStatus from src.models.incident import Incident +from src.models.knowledge import ( + EntryStatus, + EntryType, + KnowledgeEntry, + KnowledgeEntryCreate, +) from src.models.playbook import ( Playbook, PlaybookStatus, @@ -213,6 +219,60 @@ class IMetricsRepository(Protocol): ... +@runtime_checkable +class IKnowledgeRepository(Protocol): + """ + Knowledge Repository Protocol + + 職責: KnowledgeEntry CRUD 操作 (PostgreSQL) + 實作: KnowledgeDBRepository + + 建立: 2026-04-02 (台北時區) + 建立者: Claude Code (Knowledge Base Phase 1) + """ + + async def create(self, data: KnowledgeEntryCreate) -> KnowledgeEntry: + """建立知識條目""" + ... + + async def get_by_id(self, entry_id: str) -> KnowledgeEntry | None: + """根據 ID 取得知識條目""" + ... + + async def update(self, entry_id: str, data: dict) -> KnowledgeEntry | None: + """更新知識條目""" + ... + + async def delete(self, entry_id: str) -> bool: + """軟刪除知識條目 (status → archived)""" + ... + + 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, + ) -> tuple[list[KnowledgeEntry], int]: + """列出知識條目 (支援篩選)""" + ... + + async def get_categories(self) -> list[tuple[str, int]]: + """取得分類統計 [(category, count)]""" + ... + + async def search(self, query: str, limit: int = 20) -> list[KnowledgeEntry]: + """關鍵字搜尋 (title + content + tags)""" + ... + + async def increment_view_count(self, entry_id: str) -> bool: + """view_count +1""" + ... + + @runtime_checkable class IPlaybookRepository(Protocol): """ diff --git a/apps/api/src/repositories/knowledge_repository.py b/apps/api/src/repositories/knowledge_repository.py new file mode 100644 index 000000000..b65264062 --- /dev/null +++ b/apps/api/src/repositories/knowledge_repository.py @@ -0,0 +1,195 @@ +""" +Knowledge Repository - PostgreSQL 實作 +======================================= +Knowledge Base Phase 1: CRUD + 搜尋 + +建立時間: 2026-04-02 (台北時區) +建立者: Claude Code (Knowledge Base Phase 1) + +遵循 leWOOOgo 積木化原則: +- 實作 IKnowledgeRepository Protocol +- 只做資料存取,業務邏輯在 Service 層 +""" + +import structlog +from sqlalchemy import func, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models import KnowledgeEntryRecord +from src.models.knowledge import ( + EntryStatus, + EntryType, + KnowledgeEntry, + KnowledgeEntryCreate, +) + +logger = structlog.get_logger(__name__) + + +class KnowledgeDBRepository: + """ + Knowledge Repository - PostgreSQL 實作 + + 實作 IKnowledgeRepository Protocol + """ + + def __init__(self, db: AsyncSession): + self.db = db + + async def create(self, data: KnowledgeEntryCreate) -> KnowledgeEntry: + """建立知識條目""" + record = KnowledgeEntryRecord( + title=data.title, + content=data.content, + entry_type=data.entry_type, + category=data.category, + tags=data.tags, + source=data.source, + related_incident_id=data.related_incident_id, + related_playbook_id=data.related_playbook_id, + created_by=data.created_by, + ) + self.db.add(record) + await self.db.flush() + logger.info("knowledge_entry_created", entry_id=record.id, title=record.title) + return self._to_model(record) + + async def get_by_id(self, entry_id: str) -> KnowledgeEntry | None: + """根據 ID 取得知識條目""" + result = await self.db.execute( + select(KnowledgeEntryRecord).where(KnowledgeEntryRecord.id == entry_id) + ) + record = result.scalar_one_or_none() + return self._to_model(record) if record else None + + async def update(self, entry_id: str, data: dict) -> KnowledgeEntry | None: + """更新知識條目""" + result = await self.db.execute( + select(KnowledgeEntryRecord).where(KnowledgeEntryRecord.id == entry_id) + ) + record = result.scalar_one_or_none() + if not record: + return None + + for key, value in data.items(): + if value is not None and hasattr(record, key): + setattr(record, key, value) + + await self.db.flush() + logger.info("knowledge_entry_updated", entry_id=entry_id) + return self._to_model(record) + + async def delete(self, entry_id: str) -> bool: + """軟刪除 → status = archived""" + result = await self.db.execute( + update(KnowledgeEntryRecord) + .where(KnowledgeEntryRecord.id == entry_id) + .values(status=EntryStatus.ARCHIVED) + ) + return result.rowcount > 0 + + 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, + ) -> tuple[list[KnowledgeEntry], int]: + """列出知識條目 (支援篩選)""" + query = select(KnowledgeEntryRecord).where( + KnowledgeEntryRecord.status != EntryStatus.ARCHIVED + ) + count_query = select(func.count()).select_from(KnowledgeEntryRecord).where( + KnowledgeEntryRecord.status != EntryStatus.ARCHIVED + ) + + if category: + query = query.where(KnowledgeEntryRecord.category == category) + count_query = count_query.where(KnowledgeEntryRecord.category == category) + if entry_type: + query = query.where(KnowledgeEntryRecord.entry_type == entry_type) + count_query = count_query.where(KnowledgeEntryRecord.entry_type == entry_type) + if status: + query = query.where(KnowledgeEntryRecord.status == status) + count_query = count_query.where(KnowledgeEntryRecord.status == status) + if q: + like_q = f"%{q}%" + filter_cond = or_( + KnowledgeEntryRecord.title.ilike(like_q), + KnowledgeEntryRecord.content.ilike(like_q), + ) + query = query.where(filter_cond) + count_query = count_query.where(filter_cond) + + total_result = await self.db.execute(count_query) + total = total_result.scalar() or 0 + + query = query.order_by(KnowledgeEntryRecord.updated_at.desc()) + query = query.limit(limit).offset(offset) + + result = await self.db.execute(query) + records = result.scalars().all() + + return [self._to_model(r) for r in records], total + + async def get_categories(self) -> list[tuple[str, int]]: + """取得分類統計""" + result = await self.db.execute( + select( + KnowledgeEntryRecord.category, + func.count().label("cnt"), + ) + .where(KnowledgeEntryRecord.status != EntryStatus.ARCHIVED) + .group_by(KnowledgeEntryRecord.category) + .order_by(func.count().desc()) + ) + return [(row.category, row.cnt) for row in result.all()] + + async def search(self, query: str, limit: int = 20) -> list[KnowledgeEntry]: + """關鍵字搜尋 (title + content)""" + like_q = f"%{query}%" + result = await self.db.execute( + select(KnowledgeEntryRecord) + .where( + KnowledgeEntryRecord.status != EntryStatus.ARCHIVED, + or_( + KnowledgeEntryRecord.title.ilike(like_q), + KnowledgeEntryRecord.content.ilike(like_q), + ), + ) + .order_by(KnowledgeEntryRecord.view_count.desc()) + .limit(limit) + ) + records = result.scalars().all() + return [self._to_model(r) for r in records] + + async def increment_view_count(self, entry_id: str) -> bool: + """view_count +1""" + result = await self.db.execute( + update(KnowledgeEntryRecord) + .where(KnowledgeEntryRecord.id == entry_id) + .values(view_count=KnowledgeEntryRecord.view_count + 1) + ) + return result.rowcount > 0 + + def _to_model(self, record: KnowledgeEntryRecord) -> KnowledgeEntry: + """ORM Record → Pydantic Model""" + return KnowledgeEntry( + id=record.id, + title=record.title, + content=record.content, + entry_type=record.entry_type, + category=record.category, + tags=record.tags or [], + source=record.source, + status=record.status, + related_incident_id=record.related_incident_id, + related_playbook_id=record.related_playbook_id, + view_count=record.view_count, + created_by=record.created_by, + created_at=record.created_at, + updated_at=record.updated_at, + ) diff --git a/apps/api/src/services/knowledge_service.py b/apps/api/src/services/knowledge_service.py new file mode 100644 index 000000000..73ca199c9 --- /dev/null +++ b/apps/api/src/services/knowledge_service.py @@ -0,0 +1,134 @@ +""" +Knowledge Service - 業務邏輯層 +=============================== +Knowledge Base Phase 1: CRUD + 狀態流轉 + 搜尋 + +建立時間: 2026-04-02 (台北時區) +建立者: Claude Code (Knowledge Base Phase 1) + +遵循 leWOOOgo 積木化原則: +- Service 層封裝業務邏輯 +- 依賴 IKnowledgeRepository Protocol +- Router 層禁止直接存取 DB +""" + +import structlog + +from src.db.base import get_db_context +from src.models.knowledge import ( + CategoryCount, + EntryStatus, + EntryType, + KnowledgeEntry, + KnowledgeEntryCreate, + KnowledgeEntryUpdate, + KnowledgeListResponse, +) +from src.repositories.knowledge_repository import KnowledgeDBRepository + +logger = structlog.get_logger(__name__) + +# ============================================================================= +# Singleton +# ============================================================================= + +_knowledge_service: "KnowledgeService | None" = None + + +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 業務邏輯""" + + async def create_entry(self, data: KnowledgeEntryCreate) -> KnowledgeEntry: + """建立知識條目""" + async with get_db_context() as db: + repo = KnowledgeDBRepository(db) + entry = await repo.create(data) + logger.info( + "knowledge_entry_created", + entry_id=entry.id, + entry_type=entry.entry_type, + source=entry.source, + ) + return entry + + async def get_entry(self, entry_id: str) -> KnowledgeEntry | None: + """取得知識條目 (view_count +1)""" + async with get_db_context() as db: + repo = 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 = 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 = 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 = KnowledgeDBRepository(db) + return await repo.delete(entry_id) + + 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: + """列出知識條目 + 分類統計""" + async with get_db_context() as db: + repo = 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 + ) + + async def search(self, query: str, limit: int = 20) -> list[KnowledgeEntry]: + """關鍵字搜尋""" + async with get_db_context() as db: + repo = KnowledgeDBRepository(db) + return await repo.search(query, limit) diff --git a/k8s/jobs/migrate-knowledge-entries.yaml b/k8s/jobs/migrate-knowledge-entries.yaml new file mode 100644 index 000000000..65255f582 --- /dev/null +++ b/k8s/jobs/migrate-knowledge-entries.yaml @@ -0,0 +1,113 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate-knowledge-entries + namespace: awoooi-prod + labels: + app: awoooi-migration + phase: "kb-1" +spec: + ttlSecondsAfterFinished: 300 + backoffLimit: 1 + template: + spec: + restartPolicy: Never + containers: + - name: migrate + image: postgres:15-alpine + command: + - /bin/sh + - -c + - | + echo "==========================================" + echo "Knowledge Base Phase 1 - CREATE TABLE" + echo "==========================================" + + DB_HOST=$(echo $DATABASE_URL | sed 's/.*@\([^:]*\):.*/\1/') + DB_PORT=$(echo $DATABASE_URL | sed 's/.*:\([0-9]*\)\/.*/\1/') + DB_NAME=$(echo $DATABASE_URL | sed 's/.*\/\([^?]*\).*/\1/') + DB_USER=$(echo $DATABASE_URL | sed 's/.*\/\/\([^:]*\):.*/\1/') + DB_PASS=$(echo $DATABASE_URL | sed 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/') + + echo "Connecting to: $DB_HOST:$DB_PORT/$DB_NAME" + + export PGPASSWORD="$DB_PASS" + + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" <<'EOSQL' + + -- 建立 Enum types (IF NOT EXISTS) + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'entrytype') THEN + CREATE TYPE entrytype AS ENUM ('incident_case', 'runbook', 'best_practice', 'postmortem'); + RAISE NOTICE 'Created: entrytype enum'; + ELSE + RAISE NOTICE 'Exists: entrytype enum'; + END IF; + END $$; + + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'entrysource') THEN + CREATE TYPE entrysource AS ENUM ('ai_extracted', 'human'); + RAISE NOTICE 'Created: entrysource enum'; + ELSE + RAISE NOTICE 'Exists: entrysource enum'; + END IF; + END $$; + + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'entrystatus') THEN + CREATE TYPE entrystatus AS ENUM ('draft', 'review', 'approved', 'archived'); + RAISE NOTICE 'Created: entrystatus enum'; + ELSE + RAISE NOTICE 'Exists: entrystatus enum'; + END IF; + END $$; + + -- 建立 knowledge_entries table + CREATE TABLE IF NOT EXISTS knowledge_entries ( + id VARCHAR(36) PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + entry_type entrytype NOT NULL, + category VARCHAR(100) NOT NULL, + tags JSONB DEFAULT '[]'::jsonb NOT NULL, + source entrysource NOT NULL, + status entrystatus NOT NULL DEFAULT 'draft', + related_incident_id VARCHAR(30), + related_playbook_id VARCHAR(255), + view_count INTEGER NOT NULL DEFAULT 0, + created_by VARCHAR(100), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + -- 建立 Indexes + CREATE INDEX IF NOT EXISTS ix_knowledge_entry_type ON knowledge_entries(entry_type); + CREATE INDEX IF NOT EXISTS ix_knowledge_category ON knowledge_entries(category); + CREATE INDEX IF NOT EXISTS ix_knowledge_status ON knowledge_entries(status); + CREATE INDEX IF NOT EXISTS ix_knowledge_created_at ON knowledge_entries(created_at); + + -- 驗證 + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_name = 'knowledge_entries' + ORDER BY ordinal_position; + + EOSQL + + echo "==========================================" + echo "Knowledge Base migration completed!" + echo "==========================================" + envFrom: + - secretRef: + name: awoooi-secrets + resources: + requests: + memory: "64Mi" + cpu: "100m" + limits: + memory: "128Mi" + cpu: "200m"