Files
awoooi/apps/api/src/repositories/knowledge_repository.py
ogt f37bd611af
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 1m6s
CD Pipeline / build-and-deploy (push) Failing after 10m25s
CD Pipeline / post-deploy-checks (push) Has been skipped
AWOOOI Harbor 110 Local Repair / workflow-shape (push) Successful in 1s
AWOOOI Harbor 110 Local Repair / harbor-110-local-repair (push) Successful in 15s
fix(km): read back mixed enum labels safely
2026-07-14 16:03:42 +08:00

675 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Knowledge Repository - PostgreSQL 實作
=======================================
Knowledge Base Phase 1: CRUD + 搜尋
建立時間: 2026-04-02 (台北時區)
建立者: Claude Code (Knowledge Base Phase 1)
遵循 leWOOOgo 積木化原則:
- 實作 IKnowledgeRepository Protocol
- 只做資料存取,業務邏輯在 Service 層
"""
import json
import structlog
from sqlalchemy import String, func, or_, select, update
from sqlalchemy import text as sa_text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models import KnowledgeEntryRecord
from src.models.knowledge import (
EntrySource,
EntryStatus,
EntryType,
KnowledgeEntry,
KnowledgeEntryCreate,
)
from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
_DEFAULT_CATEGORY = "general"
_ASSET_TAXONOMY_KEYS = (
"project",
"product",
"website",
"service",
"package",
"tool",
"log",
"alert",
"playbook",
"rag",
"mcp",
"schedule",
)
_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"),
}
_ASSET_TAXONOMY_CATEGORY_HINTS: dict[str, tuple[str, ...]] = {
"website": ("external_site",),
"service": (
"infrastructure",
"application",
"ai_system",
"database",
"host_resource",
"kubernetes",
"alert_handling",
),
"tool": ("devops_tool", "AI自動化/Ansible受控修復"),
"alert": ("alert_handling",),
"playbook": ("auto_repair",),
}
def _enum_text(column):
"""Return a lowercase text view for enum/varchar columns across old KM schemas."""
return func.lower(column.cast(String))
_ENUM_FALLBACKS = {
EntryType: EntryType.BEST_PRACTICE,
EntrySource: EntrySource.AI_EXTRACTED,
EntryStatus: EntryStatus.REVIEW,
}
def _knowledge_enum_db_label(value):
"""Match the mixed-case labels in the deployed PostgreSQL enums."""
if isinstance(value, EntryStatus) and value is EntryStatus.PUBLISHED:
return value.value
if isinstance(value, EntryType | EntrySource | EntryStatus):
return value.name
return value
def _knowledge_enum_db_value(value):
if isinstance(value, EntryStatus) and value is EntryStatus.PUBLISHED:
return sa_text("'published'::entrystatus")
return _knowledge_enum_db_label(value)
def _enum_member(value, enum_cls):
if value is None:
return _ENUM_FALLBACKS.get(enum_cls)
raw = str(value.value if hasattr(value, "value") else value)
normalized = raw.lower()
for member in enum_cls:
if normalized in {member.value.lower(), member.name.lower()}:
return member
logger.warning(
"knowledge_read_model_unknown_enum",
enum=enum_cls.__name__,
value=raw,
fallback=getattr(_ENUM_FALLBACKS.get(enum_cls), "value", None),
)
return _ENUM_FALLBACKS.get(enum_cls)
def _active_status_filter():
return _enum_text(KnowledgeEntryRecord.status) != EntryStatus.ARCHIVED.value
def _normalize_category(value: object) -> str:
"""Keep legacy KM rows with NULL/blank category from breaking read models."""
category = str(value or "").strip()
return category or _DEFAULT_CATEGORY
def _normalize_tags(value: object) -> list[str]:
"""Accept legacy JSON/string tag shapes without breaking KM list readback."""
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 _normalize_int(value: object, *, fallback: int = 0) -> int:
try:
return int(value or fallback)
except (TypeError, ValueError):
return fallback
def _normalized_category_expr():
return func.coalesce(
func.nullif(func.trim(KnowledgeEntryRecord.category), ""),
_DEFAULT_CATEGORY,
)
def _knowledge_entry_columns():
return (
KnowledgeEntryRecord.id.label("id"),
KnowledgeEntryRecord.title.label("title"),
KnowledgeEntryRecord.content.label("content"),
KnowledgeEntryRecord.entry_type.cast(String).label("entry_type"),
_normalized_category_expr().label("category"),
KnowledgeEntryRecord.tags.label("tags"),
KnowledgeEntryRecord.source.cast(String).label("source"),
KnowledgeEntryRecord.status.cast(String).label("status"),
KnowledgeEntryRecord.related_incident_id.label("related_incident_id"),
KnowledgeEntryRecord.related_playbook_id.label("related_playbook_id"),
KnowledgeEntryRecord.related_approval_id.label("related_approval_id"),
KnowledgeEntryRecord.path_type.label("path_type"),
KnowledgeEntryRecord.symptoms_hash.label("symptoms_hash"),
KnowledgeEntryRecord.view_count.label("view_count"),
KnowledgeEntryRecord.created_by.label("created_by"),
KnowledgeEntryRecord.created_at.label("created_at"),
KnowledgeEntryRecord.updated_at.label("updated_at"),
)
def _asset_taxonomy_condition(key: str):
"""Build a broad-but-readable taxonomy matcher for AI automation surfaces."""
category_expr = _normalized_category_expr()
category_text = func.lower(category_expr.cast(String))
text_columns = (
KnowledgeEntryRecord.title,
KnowledgeEntryRecord.content,
KnowledgeEntryRecord.tags.cast(String),
category_expr.cast(String),
)
conditions = []
category_hints = tuple(
hint.lower() for hint in _ASSET_TAXONOMY_CATEGORY_HINTS.get(key, ())
)
if category_hints:
conditions.append(category_text.in_(category_hints))
for term in _ASSET_TAXONOMY_TERMS.get(key, ()):
pattern = f"%{term.lower()}%"
conditions.extend(func.lower(column).like(pattern) for column in text_columns)
if key == "playbook":
conditions.append(KnowledgeEntryRecord.related_playbook_id.is_not(None))
if not conditions:
return category_text == key
return or_(*conditions)
class KnowledgeDBRepository:
"""
Knowledge Repository - PostgreSQL 實作
實作 IKnowledgeRepository Protocol
"""
def __init__(self, db: AsyncSession):
self.db = db
async def create(self, data: KnowledgeEntryCreate) -> KnowledgeEntry:
"""
建立知識條目。
P1-1 M3 2026-04-28 ogt + Claude Sonnet 4.6
- 若 data.path_type + data.related_incident_id 均非 None
使用 INSERT ... ON CONFLICT DO UPDATEUPSERT避免重複條目
實現 KMWriter 承諾的 (related_incident_id, path_type) 冪等 key。
- 其餘路徑(無 path_type 或無 incident_id仍用原始 INSERT。
"""
# --- UPSERT 路徑(有冪等 key 時)---
if data.path_type and data.related_incident_id:
values = {
"title": data.title,
"content": data.content,
"entry_type": _knowledge_enum_db_label(data.entry_type),
"category": data.category,
"tags": data.tags,
"source": _knowledge_enum_db_label(data.source),
"status": _knowledge_enum_db_value(data.status),
"related_incident_id": data.related_incident_id,
"related_playbook_id": data.related_playbook_id,
"related_approval_id": data.related_approval_id,
"path_type": data.path_type,
"symptoms_hash": data.symptoms_hash,
"created_by": data.created_by,
}
# RETURNING id只取 id 標量(最安全,不依賴 ORM RETURNING 行為)
stmt = (
pg_insert(KnowledgeEntryRecord)
.values(**values)
.on_conflict_do_update(
index_elements=["related_incident_id", "path_type"],
# ON CONFLICT condition 需與 partial index 一致WHERE both NOT NULL
index_where=sa_text(
"related_incident_id IS NOT NULL AND path_type IS NOT NULL"
),
set_={
"title": data.title,
"content": data.content,
"tags": data.tags,
"status": _knowledge_enum_db_value(data.status),
"related_approval_id": data.related_approval_id,
},
)
.returning(KnowledgeEntryRecord.id)
)
result = await self.db.execute(stmt)
entry_id: str = result.scalar_one()
# Read through the enum-safe projection because production keeps
# both legacy uppercase labels and the lowercase published label.
fetch_result = await self.db.execute(
select(*_knowledge_entry_columns()).where(
KnowledgeEntryRecord.id == entry_id
)
)
record = fetch_result.mappings().one()
logger.info(
"knowledge_entry_upserted",
entry_id=record["id"],
title=record["title"],
path_type=data.path_type,
incident_id=data.related_incident_id,
)
return self._to_model(record)
# --- 一般 INSERT 路徑(無冪等 key 時)---
record = KnowledgeEntryRecord(
title=data.title,
content=data.content,
entry_type=_knowledge_enum_db_label(data.entry_type),
category=data.category,
tags=data.tags,
source=_knowledge_enum_db_label(data.source),
# 2026-04-04 ogt: Phase 25 P1 — 支援指定 statusANTI_PATTERN 直接 PUBLISHED
status=_knowledge_enum_db_value(data.status),
related_incident_id=data.related_incident_id,
related_playbook_id=data.related_playbook_id,
related_approval_id=data.related_approval_id,
# P1-1 M3: path_type此路徑為 None不觸發冪等
path_type=data.path_type,
# 2026-04-04 ogt: Phase 25 P1 — Anti-Pattern 閉環用症狀 hash
symptoms_hash=data.symptoms_hash,
created_by=data.created_by,
)
self.db.add(record)
await self.db.flush()
entry_id = str(record.id)
fetch_result = await self.db.execute(
select(*_knowledge_entry_columns()).where(
KnowledgeEntryRecord.id == entry_id
)
)
readback = fetch_result.mappings().one()
logger.info(
"knowledge_entry_created",
entry_id=readback["id"],
title=readback["title"],
)
return self._to_model(readback)
async def get_by_id(self, entry_id: str) -> KnowledgeEntry | None:
"""根據 ID 取得知識條目(排除 archived"""
result = await self.db.execute(
select(*_knowledge_entry_columns()).where(
KnowledgeEntryRecord.id == entry_id,
_active_status_filter(),
)
)
record = result.mappings().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(*_knowledge_entry_columns()).where(_active_status_filter())
count_query = select(func.count()).select_from(KnowledgeEntryRecord).where(
_active_status_filter()
)
if category:
normalized_category = _normalize_category(category)
query = query.where(_normalized_category_expr() == normalized_category)
count_query = count_query.where(
_normalized_category_expr() == normalized_category
)
if entry_type:
query = query.where(
_enum_text(KnowledgeEntryRecord.entry_type) == entry_type.value
)
count_query = count_query.where(
_enum_text(KnowledgeEntryRecord.entry_type) == entry_type.value
)
if status:
query = query.where(_enum_text(KnowledgeEntryRecord.status) == status.value)
count_query = count_query.where(_enum_text(KnowledgeEntryRecord.status) == status.value)
if tags:
for tag in tags:
tag_filter = _json_string_array_has_tag(tag)
query = query.where(tag_filter)
count_query = count_query.where(tag_filter)
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.mappings().all()
return [self._to_model(r) for r in records], total
async def get_categories(self) -> list[tuple[str, int]]:
"""取得分類統計"""
category_expr = _normalized_category_expr()
result = await self.db.execute(
select(
category_expr.label("category"),
func.count().label("cnt"),
)
.where(_active_status_filter())
.group_by(category_expr)
.order_by(func.count().desc())
)
return [(_normalize_category(row.category), row.cnt) for row in result.all()]
async def get_asset_taxonomy_counts(self) -> list[tuple[str, int]]:
"""取得 AI 自動化資產維度統計。
這不是替代原始 category而是讓 UI / Agent 可以用穩定 taxonomy
將 KM 依專案、產品、網站、服務、套件、工具、Log、Alert、PlayBook、
RAG、MCP 與排程分群。
"""
result = await self.db.execute(
select(
*(
func.count()
.filter(_asset_taxonomy_condition(key))
.label(key)
for key in _ASSET_TAXONOMY_KEYS
)
)
.select_from(KnowledgeEntryRecord)
.where(_active_status_filter())
)
row = result.mappings().one()
return [(key, int(row.get(key) or 0)) for key in _ASSET_TAXONOMY_KEYS]
async def search(self, query: str, limit: int = 20) -> list[KnowledgeEntry]:
"""關鍵字搜尋 (title + content + tags)"""
like_q = f"%{query}%"
result = await self.db.execute(
select(*_knowledge_entry_columns())
.where(
_active_status_filter(),
or_(
KnowledgeEntryRecord.title.ilike(like_q),
KnowledgeEntryRecord.content.ilike(like_q),
KnowledgeEntryRecord.tags.cast(String).ilike(like_q),
),
)
.order_by(KnowledgeEntryRecord.view_count.desc())
.limit(limit)
)
records = result.mappings().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
async def list_unembedded_entries(self) -> list[tuple[str, str, str]]:
"""列出尚未產生 embedding 的條目 [(id, title, content)]"""
from sqlalchemy import text as sa_text
result = await self.db.execute(
sa_text(
"SELECT id, title, content FROM knowledge_entries "
"WHERE embedding IS NULL "
"AND LOWER(CAST(status AS TEXT)) != :archived"
),
{"archived": EntryStatus.ARCHIVED.value},
)
return [(row.id, row.title, row.content) for row in result.fetchall()]
async def save_embedding(self, entry_id: str, embedding: list[float]) -> bool:
"""儲存向量 embedding (768 維)
注意: asyncpg 不支援 :param::type 語法,必須用 CAST(:param AS vector)
"""
from sqlalchemy import text as sa_text
result = await self.db.execute(
sa_text(
"UPDATE knowledge_entries SET embedding = CAST(:emb AS vector) WHERE id = :id"
),
{"emb": str(embedding), "id": entry_id},
)
return result.rowcount > 0
async def semantic_search(
self,
query_embedding: list[float],
limit: int = 10,
threshold: float = 0.5,
) -> list[tuple[KnowledgeEntry, float]]:
"""
語意搜尋 — cosine similarity (pgvector)
Returns:
list of (entry, similarity_score) 已按分數降序排列
"""
from sqlalchemy import text as sa_text
sql = sa_text("""
SELECT id, 1 - (embedding <=> CAST(:emb AS vector)) AS score
FROM knowledge_entries
WHERE LOWER(CAST(status AS TEXT)) != :archived
AND embedding IS NOT NULL
AND 1 - (embedding <=> CAST(:emb AS vector)) >= :threshold
ORDER BY embedding <=> CAST(:emb AS vector)
LIMIT :limit
""")
rows = await self.db.execute(
sql,
{
"emb": str(query_embedding),
"threshold": threshold,
"limit": limit,
"archived": EntryStatus.ARCHIVED.value,
},
)
rows = rows.fetchall()
if not rows:
return []
# 批次取得完整 entry
ids = [r[0] for r in rows]
scores = {r[0]: float(r[1]) for r in rows}
result = await self.db.execute(
select(*_knowledge_entry_columns()).where(KnowledgeEntryRecord.id.in_(ids))
)
records = {r["id"]: r for r in result.mappings().all()}
return [
(self._to_model(records[entry_id]), scores[entry_id])
for entry_id in ids
if entry_id in records
]
def _to_model(self, record) -> KnowledgeEntry:
"""ORM Record → Pydantic Model"""
if hasattr(record, "_mapping"):
record = record._mapping
def get(name: str):
if isinstance(record, dict):
return record.get(name)
if hasattr(record, "__getitem__") and name in record:
return record[name]
return getattr(record, name)
return KnowledgeEntry(
id=str(get("id")),
title=str(get("title") or "Untitled KM entry"),
content=str(get("content") or ""),
entry_type=_enum_member(get("entry_type"), EntryType),
category=_normalize_category(get("category")),
tags=_normalize_tags(get("tags")),
source=_enum_member(get("source"), EntrySource),
status=_enum_member(get("status"), EntryStatus),
related_incident_id=get("related_incident_id"),
related_playbook_id=get("related_playbook_id"),
related_approval_id=get("related_approval_id"),
# P1-1 M3 2026-04-28 ogt + Claude Sonnet 4.6: 冪等 key
path_type=get("path_type"),
symptoms_hash=get("symptoms_hash"),
view_count=_normalize_int(get("view_count")),
created_by=get("created_by"),
created_at=get("created_at") or now_taipei(),
updated_at=get("updated_at") or get("created_at") or now_taipei(),
)
def _json_string_array_has_tag(tag: str):
"""建立 JSON/JSONB 皆相容的 tag filter。
production 的 knowledge_entries.tags 目前是 JSON 欄位,不支援 json @> text。
這裡改用帶引號的字串比對,避免把 tag 片段誤判成完整 tag。
"""
escaped = (
tag
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
return KnowledgeEntryRecord.tags.cast(String).ilike(f'%"{escaped}"%', escape="\\")