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
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:
@@ -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"),
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user