fix(km): restore knowledge base readback
This commit is contained in:
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models import KnowledgeEntryRecord
|
||||
from src.models.knowledge import (
|
||||
EntrySource,
|
||||
EntryStatus,
|
||||
EntryType,
|
||||
KnowledgeEntry,
|
||||
@@ -27,6 +28,48 @@ from src.models.knowledge import (
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _enum_text(column):
|
||||
"""Return a lowercase text view for enum/varchar columns across old KM schemas."""
|
||||
return func.lower(column.cast(String))
|
||||
|
||||
|
||||
def _enum_member(value, enum_cls):
|
||||
if value is None:
|
||||
return value
|
||||
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
|
||||
return normalized
|
||||
|
||||
|
||||
def _active_status_filter():
|
||||
return _enum_text(KnowledgeEntryRecord.status) != EntryStatus.ARCHIVED.value
|
||||
|
||||
|
||||
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"),
|
||||
KnowledgeEntryRecord.category.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"),
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeDBRepository:
|
||||
"""
|
||||
Knowledge Repository - PostgreSQL 實作
|
||||
@@ -127,12 +170,12 @@ class KnowledgeDBRepository:
|
||||
async def get_by_id(self, entry_id: str) -> KnowledgeEntry | None:
|
||||
"""根據 ID 取得知識條目(排除 archived)"""
|
||||
result = await self.db.execute(
|
||||
select(KnowledgeEntryRecord).where(
|
||||
select(*_knowledge_entry_columns()).where(
|
||||
KnowledgeEntryRecord.id == entry_id,
|
||||
KnowledgeEntryRecord.status != EntryStatus.ARCHIVED,
|
||||
_active_status_filter(),
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
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:
|
||||
@@ -172,22 +215,24 @@ class KnowledgeDBRepository:
|
||||
offset: int = 0,
|
||||
) -> tuple[list[KnowledgeEntry], int]:
|
||||
"""列出知識條目 (支援篩選)"""
|
||||
query = select(KnowledgeEntryRecord).where(
|
||||
KnowledgeEntryRecord.status != EntryStatus.ARCHIVED
|
||||
)
|
||||
query = select(*_knowledge_entry_columns()).where(_active_status_filter())
|
||||
count_query = select(func.count()).select_from(KnowledgeEntryRecord).where(
|
||||
KnowledgeEntryRecord.status != EntryStatus.ARCHIVED
|
||||
_active_status_filter()
|
||||
)
|
||||
|
||||
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)
|
||||
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(KnowledgeEntryRecord.status == status)
|
||||
count_query = count_query.where(KnowledgeEntryRecord.status == 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)
|
||||
@@ -209,7 +254,7 @@ class KnowledgeDBRepository:
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
records = result.scalars().all()
|
||||
records = result.mappings().all()
|
||||
|
||||
return [self._to_model(r) for r in records], total
|
||||
|
||||
@@ -220,7 +265,7 @@ class KnowledgeDBRepository:
|
||||
KnowledgeEntryRecord.category,
|
||||
func.count().label("cnt"),
|
||||
)
|
||||
.where(KnowledgeEntryRecord.status != EntryStatus.ARCHIVED)
|
||||
.where(_active_status_filter())
|
||||
.group_by(KnowledgeEntryRecord.category)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
@@ -230,9 +275,9 @@ class KnowledgeDBRepository:
|
||||
"""關鍵字搜尋 (title + content + tags)"""
|
||||
like_q = f"%{query}%"
|
||||
result = await self.db.execute(
|
||||
select(KnowledgeEntryRecord)
|
||||
select(*_knowledge_entry_columns())
|
||||
.where(
|
||||
KnowledgeEntryRecord.status != EntryStatus.ARCHIVED,
|
||||
_active_status_filter(),
|
||||
or_(
|
||||
KnowledgeEntryRecord.title.ilike(like_q),
|
||||
KnowledgeEntryRecord.content.ilike(like_q),
|
||||
@@ -242,7 +287,7 @@ class KnowledgeDBRepository:
|
||||
.order_by(KnowledgeEntryRecord.view_count.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
records = result.scalars().all()
|
||||
records = result.mappings().all()
|
||||
return [self._to_model(r) for r in records]
|
||||
|
||||
async def increment_view_count(self, entry_id: str) -> bool:
|
||||
@@ -260,8 +305,10 @@ class KnowledgeDBRepository:
|
||||
result = await self.db.execute(
|
||||
sa_text(
|
||||
"SELECT id, title, content FROM knowledge_entries "
|
||||
"WHERE embedding IS NULL AND status != 'ARCHIVED'"
|
||||
)
|
||||
"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()]
|
||||
|
||||
@@ -295,7 +342,7 @@ class KnowledgeDBRepository:
|
||||
sql = sa_text("""
|
||||
SELECT id, 1 - (embedding <=> CAST(:emb AS vector)) AS score
|
||||
FROM knowledge_entries
|
||||
WHERE status != 'ARCHIVED'
|
||||
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)
|
||||
@@ -303,7 +350,12 @@ class KnowledgeDBRepository:
|
||||
""")
|
||||
rows = await self.db.execute(
|
||||
sql,
|
||||
{"emb": str(query_embedding), "threshold": threshold, "limit": limit},
|
||||
{
|
||||
"emb": str(query_embedding),
|
||||
"threshold": threshold,
|
||||
"limit": limit,
|
||||
"archived": EntryStatus.ARCHIVED.value,
|
||||
},
|
||||
)
|
||||
rows = rows.fetchall()
|
||||
|
||||
@@ -315,9 +367,9 @@ class KnowledgeDBRepository:
|
||||
scores = {r[0]: float(r[1]) for r in rows}
|
||||
|
||||
result = await self.db.execute(
|
||||
select(KnowledgeEntryRecord).where(KnowledgeEntryRecord.id.in_(ids))
|
||||
select(*_knowledge_entry_columns()).where(KnowledgeEntryRecord.id.in_(ids))
|
||||
)
|
||||
records = {r.id: r for r in result.scalars().all()}
|
||||
records = {r["id"]: r for r in result.mappings().all()}
|
||||
|
||||
return [
|
||||
(self._to_model(records[entry_id]), scores[entry_id])
|
||||
@@ -325,27 +377,37 @@ class KnowledgeDBRepository:
|
||||
if entry_id in records
|
||||
]
|
||||
|
||||
def _to_model(self, record: KnowledgeEntryRecord) -> KnowledgeEntry:
|
||||
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=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,
|
||||
related_approval_id=getattr(record, "related_approval_id", None),
|
||||
id=get("id"),
|
||||
title=get("title"),
|
||||
content=get("content"),
|
||||
entry_type=_enum_member(get("entry_type"), EntryType),
|
||||
category=get("category"),
|
||||
tags=get("tags") or [],
|
||||
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=getattr(record, "path_type", None),
|
||||
symptoms_hash=getattr(record, "symptoms_hash", None),
|
||||
view_count=record.view_count,
|
||||
created_by=record.created_by,
|
||||
created_at=record.created_at,
|
||||
updated_at=record.updated_at,
|
||||
path_type=get("path_type"),
|
||||
symptoms_hash=get("symptoms_hash"),
|
||||
view_count=get("view_count"),
|
||||
created_by=get("created_by"),
|
||||
created_at=get("created_at"),
|
||||
updated_at=get("updated_at"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -252,13 +252,18 @@ class KnowledgeService:
|
||||
result = await db.execute(
|
||||
sa_text(
|
||||
"SELECT id FROM knowledge_entries "
|
||||
"WHERE entry_type = 'anti_pattern' "
|
||||
"WHERE LOWER(CAST(entry_type AS TEXT)) = :entry_type "
|
||||
"AND symptoms_hash = :hash "
|
||||
"AND created_at >= :cutoff "
|
||||
"AND status != 'ARCHIVED' "
|
||||
"AND LOWER(CAST(status AS TEXT)) != :archived "
|
||||
"ORDER BY created_at DESC LIMIT 5"
|
||||
),
|
||||
{"hash": symptoms_hash, "cutoff": cutoff},
|
||||
{
|
||||
"entry_type": EntryType.ANTI_PATTERN.value,
|
||||
"hash": symptoms_hash,
|
||||
"cutoff": cutoff,
|
||||
"archived": EntryStatus.ARCHIVED.value,
|
||||
},
|
||||
)
|
||||
entry_ids = [row.id for row in result.fetchall()]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user