From 0dca32a7c11710c249d7f7b83c884167d237f562 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 3 Jul 2026 00:03:35 +0800 Subject: [PATCH] fix(km): restore knowledge base readback --- .../src/repositories/knowledge_repository.py | 142 +++++++++++++----- apps/api/src/services/knowledge_service.py | 11 +- .../test_knowledge_repository_read_model.py | 128 ++++++++++++++++ docs/LOGBOOK.md | 16 ++ 4 files changed, 254 insertions(+), 43 deletions(-) create mode 100644 apps/api/tests/test_knowledge_repository_read_model.py diff --git a/apps/api/src/repositories/knowledge_repository.py b/apps/api/src/repositories/knowledge_repository.py index 808403a34..a557ee336 100644 --- a/apps/api/src/repositories/knowledge_repository.py +++ b/apps/api/src/repositories/knowledge_repository.py @@ -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"), ) diff --git a/apps/api/src/services/knowledge_service.py b/apps/api/src/services/knowledge_service.py index d2b3205db..f2a067666 100644 --- a/apps/api/src/services/knowledge_service.py +++ b/apps/api/src/services/knowledge_service.py @@ -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()] diff --git a/apps/api/tests/test_knowledge_repository_read_model.py b/apps/api/tests/test_knowledge_repository_read_model.py new file mode 100644 index 000000000..666d7740e --- /dev/null +++ b/apps/api/tests/test_knowledge_repository_read_model.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +import pytest +from sqlalchemy.dialects import postgresql + +from src.db.models import KnowledgeEntryRecord +from src.models.knowledge import EntrySource, EntryStatus, EntryType +from src.repositories.knowledge_repository import KnowledgeDBRepository, _enum_text + + +def _record(**overrides): + base = { + "id": "km-1", + "title": "KM readback", + "content": "Production KM entry", + "entry_type": "POSTMORTEM", + "category": "postmortem", + "tags": ["km", "readback"], + "source": "AI_EXTRACTED", + "status": "APPROVED", + "related_incident_id": None, + "related_playbook_id": None, + "related_approval_id": None, + "path_type": None, + "symptoms_hash": None, + "view_count": 7, + "created_by": "ai-agent", + "created_at": datetime(2026, 7, 2, tzinfo=UTC), + "updated_at": datetime(2026, 7, 2, tzinfo=UTC), + } + base.update(overrides) + return SimpleNamespace(**base) + + +def test_knowledge_read_model_normalizes_uppercase_enum_names() -> None: + repo = KnowledgeDBRepository(db=object()) # type: ignore[arg-type] + + entry = repo._to_model(_record()) + + assert entry.entry_type == EntryType.POSTMORTEM + assert entry.source == EntrySource.AI_EXTRACTED + assert entry.status == EntryStatus.APPROVED + + +def test_knowledge_read_model_accepts_lowercase_enum_values() -> None: + repo = KnowledgeDBRepository(db=object()) # type: ignore[arg-type] + + entry = repo._to_model( + _record( + entry_type="auto_runbook", + source="human", + status="published", + ) + ) + + assert entry.entry_type == EntryType.AUTO_RUNBOOK + assert entry.source == EntrySource.HUMAN + assert entry.status == EntryStatus.PUBLISHED + + +class _ScalarResult: + def __init__(self, value: int): + self.value = value + + def scalar(self) -> int: + return self.value + + +class _MappingResult: + def __init__(self, rows: list[dict]): + self.rows = rows + + def mappings(self): + return self + + def all(self): + return self.rows + + +class _FakeDb: + def __init__(self): + self.statements = [] + self._results = [ + _ScalarResult(1), + _MappingResult([_record().__dict__]), + ] + + async def execute(self, statement): + self.statements.append(statement) + return self._results.pop(0) + + +@pytest.mark.asyncio +async def test_list_entries_uses_enum_safe_text_filters() -> None: + db = _FakeDb() + repo = KnowledgeDBRepository(db=db) # type: ignore[arg-type] + + entries, total = await repo.list_entries( + entry_type=EntryType.POSTMORTEM, + status=EntryStatus.APPROVED, + limit=5, + ) + + assert total == 1 + assert entries[0].status == EntryStatus.APPROVED + compiled = "\n".join( + str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) + for stmt in db.statements + ).lower() + assert "lower(cast(knowledge_entries.status as varchar))" in compiled + assert "lower(cast(knowledge_entries.entry_type as varchar))" in compiled + assert "approved" in compiled + assert "postmortem" in compiled + + +def test_enum_text_helper_casts_status_to_lowercase_text() -> None: + compiled = str( + (_enum_text(KnowledgeEntryRecord.status) == EntryStatus.ARCHIVED.value).compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).lower() + + assert "lower(cast(knowledge_entries.status as varchar))" in compiled + assert "archived" in compiled diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 3bbcd52aa..3b9401d27 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,19 @@ +## 2026-07-03 — 00:01 Knowledge Base production empty readback fix + +**完成內容**: +- Production `/zh-TW/knowledge-base` 讀回 HTTP 200,但主 KM API 不帶 tenant context 時回 `401 Missing tenant context: project_id is required`;帶 `project_id=awoooi` 後回 `500 INTERNAL_ERROR`,確認不是 KM 資料歸零,而是 knowledge read path 進 DB 後炸掉。 +- `KnowledgeDBRepository` 的 list / get / search / categories / semantic read path 改為 enum-safe read model:`entry_type`、`source`、`status` 以 `LOWER(CAST(... AS VARCHAR/TEXT))` 比對並正規化,兼容歷史 lowercase schema 與 SQLAlchemy uppercase enum names。 +- `KnowledgeService.check_anti_pattern()` raw SQL 同步改為 `LOWER(CAST(entry_type/status AS TEXT))`,避免 anti-pattern 查詢走到相同 enum label drift。 +- 新增 `apps/api/tests/test_knowledge_repository_read_model.py`,鎖住 uppercase enum name、lowercase enum value 與 enum-safe list filter。 + +**驗證**: +- `python3.11 -m py_compile apps/api/src/repositories/knowledge_repository.py apps/api/src/services/knowledge_service.py apps/api/tests/test_knowledge_repository_read_model.py`:通過。 +- `DATABASE_URL=postgresql+asyncpg://test:test@localhost/test PYTHONPATH=apps/api python3.11 -m pytest apps/api/tests/test_knowledge_repository_read_model.py apps/api/tests/test_hermes_kb_growth_worker.py apps/api/tests/test_phase25_auto_harvesting.py apps/api/tests/test_km_writer_idempotent.py -q -p no:cacheprovider`:`33 passed`。 +- `python3.11 -m json.tool apps/web/messages/zh-TW.json`、`python3.11 -m json.tool apps/web/messages/en.json`、`git diff --check`:通過。 + +**仍維持**: +- 未讀 secret / token / `.env` / raw sessions / SQLite / auth;未使用 GitHub / gh;未改 production DB schema、未寫 KM 資料、未 DROP / TRUNCATE / restore / prune;下一步需推 Gitea CD 後重驗 `/api/v1/knowledge?project_id=awoooi` 與 `/zh-TW/knowledge-base` desktop / mobile。 + ## 2026-07-02 — 22:22 P0-006 reboot SLO per-blocker 告警投影 **完成內容**: