diff --git a/apps/api/src/repositories/knowledge_repository.py b/apps/api/src/repositories/knowledge_repository.py index bff9a8d35..9a65ee033 100644 --- a/apps/api/src/repositories/knowledge_repository.py +++ b/apps/api/src/repositories/knowledge_repository.py @@ -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"), diff --git a/apps/api/tests/test_knowledge_repository_read_model.py b/apps/api/tests/test_knowledge_repository_read_model.py index 6f0053f01..6d04486c0 100644 --- a/apps/api/tests/test_knowledge_repository_read_model.py +++ b/apps/api/tests/test_knowledge_repository_read_model.py @@ -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]