fix(km): tolerate legacy knowledge rows
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 1m14s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-07-09 17:43:29 +08:00
parent a4dd24b6e0
commit f50acd45db
2 changed files with 93 additions and 30 deletions

View File

@@ -11,8 +11,11 @@ Knowledge Base Phase 1: CRUD + 搜尋
- 只做資料存取,業務邏輯在 Service 層
"""
import json
import structlog
from sqlalchemy import String, func, or_, select, text as sa_text, update
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
@@ -142,15 +145,28 @@ def _enum_text(column):
return func.lower(column.cast(String))
_ENUM_FALLBACKS = {
EntryType: EntryType.BEST_PRACTICE,
EntrySource: EntrySource.AI_EXTRACTED,
EntryStatus: EntryStatus.REVIEW,
}
def _enum_member(value, enum_cls):
if value is None:
return value
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
return normalized
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():
@@ -163,6 +179,26 @@ def _normalize_category(value: object) -> str:
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 _normalized_category_expr():
return func.coalesce(
func.nullif(func.trim(KnowledgeEntryRecord.category), ""),
@@ -240,21 +276,21 @@ class KnowledgeDBRepository:
"""
# --- UPSERT 路徑(有冪等 key 時)---
if data.path_type and data.related_incident_id:
values = dict(
title=data.title,
content=data.content,
entry_type=data.entry_type.value if hasattr(data.entry_type, "value") else data.entry_type,
category=data.category,
tags=data.tags,
source=data.source.value if hasattr(data.source, "value") else data.source,
status=data.status.value if hasattr(data.status, "value") else 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,
)
values = {
"title": data.title,
"content": data.content,
"entry_type": data.entry_type.value if hasattr(data.entry_type, "value") else data.entry_type,
"category": data.category,
"tags": data.tags,
"source": data.source.value if hasattr(data.source, "value") else data.source,
"status": data.status.value if hasattr(data.status, "value") else 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)
@@ -265,13 +301,13 @@ class KnowledgeDBRepository:
index_where=sa_text(
"related_incident_id IS NOT NULL AND path_type IS NOT NULL"
),
set_=dict(
title=data.title,
content=data.content,
tags=data.tags,
status=data.status.value if hasattr(data.status, "value") else data.status,
related_approval_id=data.related_approval_id,
),
set_={
"title": data.title,
"content": data.content,
"tags": data.tags,
"status": data.status.value if hasattr(data.status, "value") else data.status,
"related_approval_id": data.related_approval_id,
},
)
.returning(KnowledgeEntryRecord.id)
)
@@ -562,12 +598,12 @@ class KnowledgeDBRepository:
return getattr(record, name)
return KnowledgeEntry(
id=get("id"),
title=get("title"),
content=get("content"),
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=get("tags") or [],
tags=_normalize_tags(get("tags")),
source=_enum_member(get("source"), EntrySource),
status=_enum_member(get("status"), EntryStatus),
related_incident_id=get("related_incident_id"),
@@ -576,7 +612,7 @@ class KnowledgeDBRepository:
# P1-1 M3 2026-04-28 ogt + Claude Sonnet 4.6: 冪等 key
path_type=get("path_type"),
symptoms_hash=get("symptoms_hash"),
view_count=get("view_count"),
view_count=int(get("view_count") or 0),
created_by=get("created_by"),
created_at=get("created_at"),
updated_at=get("updated_at"),

View File

@@ -65,6 +65,33 @@ def test_knowledge_read_model_accepts_lowercase_enum_values() -> None:
assert entry.status == EntryStatus.PUBLISHED
def test_knowledge_read_model_falls_back_for_unknown_legacy_enum_values() -> None:
repo = KnowledgeDBRepository(db=object()) # type: ignore[arg-type]
entry = repo._to_model(
_record(
entry_type="analysis_note",
source="system_import",
status="needs_ai_review",
)
)
assert entry.entry_type == EntryType.BEST_PRACTICE
assert entry.source == EntrySource.AI_EXTRACTED
assert entry.status == EntryStatus.REVIEW
def test_knowledge_read_model_normalizes_legacy_tag_shapes_and_null_counts() -> None:
repo = KnowledgeDBRepository(db=object()) # type: ignore[arg-type]
json_tag_entry = repo._to_model(_record(tags='["telegram", " ai_loop "]', view_count=None))
scalar_tag_entry = repo._to_model(_record(tags="telegram"))
assert json_tag_entry.tags == ["telegram", "ai_loop"]
assert json_tag_entry.view_count == 0
assert scalar_tag_entry.tags == ["telegram"]
def test_knowledge_read_model_defaults_null_or_blank_category() -> None:
repo = KnowledgeDBRepository(db=object()) # type: ignore[arg-type]