feat(api): Knowledge Base Phase 1 後端四層架構
- 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:
124
apps/api/src/api/v1/knowledge.py
Normal file
124
apps/api/src/api/v1/knowledge.py
Normal 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")
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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 (舊版)
|
||||
|
||||
109
apps/api/src/models/knowledge.py
Normal file
109
apps/api/src/models/knowledge.py
Normal 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)
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
195
apps/api/src/repositories/knowledge_repository.py
Normal file
195
apps/api/src/repositories/knowledge_repository.py
Normal 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,
|
||||
)
|
||||
134
apps/api/src/services/knowledge_service.py
Normal file
134
apps/api/src/services/knowledge_service.py
Normal 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)
|
||||
Reference in New Issue
Block a user