fix(km): restore knowledge base readback

This commit is contained in:
Your Name
2026-07-03 00:03:35 +08:00
parent 33f6009b56
commit 0dca32a7c1
4 changed files with 254 additions and 43 deletions

View File

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

View File

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