- 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>
196 lines
6.9 KiB
Python
196 lines
6.9 KiB
Python
"""
|
|
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,
|
|
)
|