feat(api): Knowledge Base Phase 1 後端四層架構
Some checks failed
CD Pipeline / build-and-deploy (push) Successful in 7m0s
E2E Health Check / e2e-health (push) Successful in 17s
Type Sync Check / check-type-sync (push) Failing after 30s

- models/knowledge.py: Pydantic Schema (EntryType/Source/Status/CRUD)
- db/models.py: KnowledgeEntryRecord ORM (PostgreSQL)
- repositories/interfaces.py: IKnowledgeRepository Protocol
- repositories/knowledge_repository.py: PostgreSQL CRUD 實作
- services/knowledge_service.py: 業務邏輯 (get_db_context 內部管理 session)
- api/v1/knowledge.py: REST Router (get_knowledge_service,無直接 DB 存取)
- main.py: 掛載 Knowledge Base Router
- k8s/jobs/migrate-knowledge-entries.yaml: DB Migration Job

API 端點: GET/POST / | GET/PATCH/DELETE /{id} | POST /{id}/approve
         GET /search | GET /categories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-02 00:55:56 +08:00
parent 077e1cd637
commit d8be78b135
8 changed files with 837 additions and 0 deletions

View File

@@ -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")

View File

@@ -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"),
)

View File

@@ -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 (舊版)

View File

@@ -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)

View File

@@ -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):
"""

View File

@@ -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,
)

View File

@@ -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)