fix(knowledge): bind canonical project readback

This commit is contained in:
Your Name
2026-07-22 17:35:18 +08:00
parent 783e111801
commit a9d85d0d62
3 changed files with 301 additions and 25 deletions

View File

@@ -32,6 +32,8 @@ logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/knowledge", tags=["Knowledge Base"])
CANONICAL_KNOWLEDGE_PROJECT_ID = "awoooi"
# =============================================================================
# Endpoints
@@ -49,6 +51,7 @@ async def list_entries(
"""列出知識條目"""
service = get_knowledge_service()
return await service.list_entries(
project_id=CANONICAL_KNOWLEDGE_PROJECT_ID,
category=category,
entry_type=entry_type,
status=status,
@@ -65,7 +68,11 @@ async def search_entries(
) -> list[KnowledgeEntry]:
"""關鍵字搜尋 (title + content + tags)"""
service = get_knowledge_service()
return await service.search(q, limit)
return await service.search(
q,
limit,
project_id=CANONICAL_KNOWLEDGE_PROJECT_ID,
)
@router.get("/semantic-search")
@@ -102,7 +109,9 @@ async def embed_all_entries() -> dict:
async def get_categories() -> list[dict]:
"""取得分類樹 (含各類數量)"""
service = get_knowledge_service()
cats = await service.get_categories()
cats = await service.get_categories(
project_id=CANONICAL_KNOWLEDGE_PROJECT_ID,
)
return [cat.model_dump() for cat in cats]
@@ -110,7 +119,9 @@ async def get_categories() -> list[dict]:
async def get_asset_taxonomy() -> list[KnowledgeAssetTaxonomyCount]:
"""取得 AI 自動化資產維度統計(只讀,不寫入)。"""
service = get_knowledge_service()
return await service.get_asset_taxonomy()
return await service.get_asset_taxonomy(
project_id=CANONICAL_KNOWLEDGE_PROJECT_ID,
)
@router.get("/{entry_id}", response_model=KnowledgeEntry)

View File

@@ -345,6 +345,21 @@ def _current_knowledge_project_id() -> str | None:
return str(project_id).strip() if project_id else None
def _resolve_knowledge_project_id(project_id: str | None) -> str | None:
"""Prefer an explicit tenant binding; never replace an explicit blank."""
if project_id is not None:
normalized = str(project_id).strip()
return normalized or None
return _current_knowledge_project_id()
def _knowledge_db_context(project_id: str | None):
"""Keep legacy context inheritance while allowing a canonical read binding."""
if project_id is None:
return get_db_context()
return get_db_context(str(project_id).strip())
def _normalize_direct_tags(value: object) -> list[str]:
if value is None:
return []
@@ -787,6 +802,7 @@ class KnowledgeService:
async def _list_entries_from_primary(
self,
*,
project_id: str | None = None,
category: str | None,
entry_type: EntryType | None,
status: EntryStatus | None,
@@ -795,7 +811,7 @@ class KnowledgeService:
limit: int,
offset: int,
) -> KnowledgeListResponse:
async with get_db_context() as db:
async with _knowledge_db_context(project_id) as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
items, total = await repo.list_entries(
category=category,
@@ -826,7 +842,7 @@ class KnowledgeService:
try:
categories = await asyncio.wait_for(
self._read_primary_categories(),
self._read_primary_categories(project_id=project_id),
timeout=_PRIMARY_KM_TAXONOMY_TIMEOUT_SECONDS,
)
except Exception as exc: # noqa: BLE001 - primary entries must stay visible
@@ -845,7 +861,7 @@ class KnowledgeService:
try:
asset_taxonomy = await asyncio.wait_for(
self._read_primary_asset_taxonomy(),
self._read_primary_asset_taxonomy(project_id=project_id),
timeout=_PRIMARY_KM_TAXONOMY_TIMEOUT_SECONDS,
)
except Exception as exc: # noqa: BLE001 - primary entries must stay visible
@@ -874,8 +890,12 @@ class KnowledgeService:
next_step=next_step,
)
async def _read_primary_categories(self) -> list[CategoryCount]:
async with get_db_context() as db:
async def _read_primary_categories(
self,
*,
project_id: str | None = None,
) -> list[CategoryCount]:
async with _knowledge_db_context(project_id) as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
categories_raw = await repo.get_categories()
categories = [
@@ -883,8 +903,12 @@ class KnowledgeService:
]
return categories
async def _read_primary_asset_taxonomy(self) -> list[KnowledgeAssetTaxonomyCount]:
async with get_db_context() as db:
async def _read_primary_asset_taxonomy(
self,
*,
project_id: str | None = None,
) -> list[KnowledgeAssetTaxonomyCount]:
async with _knowledge_db_context(project_id) as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
asset_taxonomy_raw = await repo.get_asset_taxonomy_counts()
return [
@@ -896,14 +920,17 @@ class KnowledgeService:
self,
query: str,
limit: int,
*,
project_id: str | None = None,
) -> list[KnowledgeEntry]:
async with get_db_context() as db:
async with _knowledge_db_context(project_id) as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
return await repo.search(query, limit)
async def _list_entries_from_primary_bounded(
self,
*,
project_id: str | None = None,
category: str | None,
entry_type: EntryType | None,
status: EntryStatus | None,
@@ -915,6 +942,7 @@ class KnowledgeService:
) -> KnowledgeListResponse:
return await asyncio.wait_for(
self._list_entries_from_primary(
project_id=project_id,
category=category,
entry_type=entry_type,
status=status,
@@ -929,6 +957,7 @@ class KnowledgeService:
async def _list_entries_from_direct(
self,
*,
project_id: str | None = None,
category: str | None,
entry_type: EntryType | None,
status: EntryStatus | None,
@@ -938,7 +967,7 @@ class KnowledgeService:
offset: int,
) -> KnowledgeListResponse | None:
"""Pool 壓力下以短連線只讀救回 primary KM不把 727 筆誤降成 13 筆。"""
project_id = _current_knowledge_project_id()
project_id = _resolve_knowledge_project_id(project_id)
if not project_id:
return None
@@ -1070,8 +1099,12 @@ class KnowledgeService:
)
return None
async def _read_categories_direct(self) -> list[CategoryCount] | None:
project_id = _current_knowledge_project_id()
async def _read_categories_direct(
self,
*,
project_id: str | None = None,
) -> list[CategoryCount] | None:
project_id = _resolve_knowledge_project_id(project_id)
if not project_id:
return None
db_url = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
@@ -1111,8 +1144,12 @@ class KnowledgeService:
)
return None
async def _read_asset_taxonomy_direct(self) -> list[KnowledgeAssetTaxonomyCount] | None:
project_id = _current_knowledge_project_id()
async def _read_asset_taxonomy_direct(
self,
*,
project_id: str | None = None,
) -> list[KnowledgeAssetTaxonomyCount] | None:
project_id = _resolve_knowledge_project_id(project_id)
if not project_id:
return None
db_url = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
@@ -1224,10 +1261,13 @@ class KnowledgeService:
q: str | None = None,
limit: int = 20,
offset: int = 0,
*,
project_id: str | None = None,
) -> KnowledgeListResponse:
"""列出知識條目 + 分類統計"""
try:
return await self._list_entries_from_primary_bounded(
project_id=project_id,
category=category,
entry_type=entry_type,
status=status,
@@ -1244,6 +1284,7 @@ class KnowledgeService:
try:
await asyncio.sleep(_PRIMARY_KM_RETRY_DELAY_SECONDS)
retry_response = await self._list_entries_from_primary_bounded(
project_id=project_id,
category=category,
entry_type=entry_type,
status=status,
@@ -1266,6 +1307,7 @@ class KnowledgeService:
offset=offset,
)
direct_response = await self._list_entries_from_direct(
project_id=project_id,
category=category,
entry_type=entry_type,
status=status,
@@ -1298,11 +1340,20 @@ class KnowledgeService:
offset=offset,
)
async def get_asset_taxonomy(self) -> list[KnowledgeAssetTaxonomyCount]:
async def get_asset_taxonomy(
self,
*,
project_id: str | None = None,
) -> list[KnowledgeAssetTaxonomyCount]:
"""取得 AI 自動化資產維度統計。"""
try:
primary_read = (
self._read_primary_asset_taxonomy()
if project_id is None
else self._read_primary_asset_taxonomy(project_id=project_id)
)
taxonomy = await asyncio.wait_for(
self._read_primary_asset_taxonomy(),
primary_read,
timeout=_PRIMARY_KM_SIDE_READ_TIMEOUT_SECONDS,
)
if taxonomy and any(row.count > 0 for row in taxonomy):
@@ -1311,7 +1362,11 @@ class KnowledgeService:
except Exception as exc: # noqa: BLE001 - taxonomy must not 500 the KM UI
reason = _knowledge_readback_exception_reason(exc)
if _classify_knowledge_readback_degraded_reason(reason) == "primary_km_db_timeout_or_pool_exhausted":
direct_taxonomy = await self._read_asset_taxonomy_direct()
direct_taxonomy = await (
self._read_asset_taxonomy_direct()
if project_id is None
else self._read_asset_taxonomy_direct(project_id=project_id)
)
if direct_taxonomy and any(row.count > 0 for row in direct_taxonomy):
return direct_taxonomy
logger.warning(
@@ -1321,11 +1376,20 @@ class KnowledgeService:
)
return _source_asset_taxonomy_counts(_source_backed_entries())
async def get_categories(self) -> list[CategoryCount]:
async def get_categories(
self,
*,
project_id: str | None = None,
) -> list[CategoryCount]:
"""取得分類統計(直接呼叫 repo不走 list_entries"""
try:
primary_read = (
self._read_primary_categories()
if project_id is None
else self._read_primary_categories(project_id=project_id)
)
categories = await asyncio.wait_for(
self._read_primary_categories(),
primary_read,
timeout=_PRIMARY_KM_SIDE_READ_TIMEOUT_SECONDS,
)
if categories:
@@ -1334,7 +1398,11 @@ class KnowledgeService:
except Exception as exc: # noqa: BLE001 - categories must not 500 the KM UI
reason = _knowledge_readback_exception_reason(exc)
if _classify_knowledge_readback_degraded_reason(reason) == "primary_km_db_timeout_or_pool_exhausted":
direct_categories = await self._read_categories_direct()
direct_categories = await (
self._read_categories_direct()
if project_id is None
else self._read_categories_direct(project_id=project_id)
)
if direct_categories and any(row.count > 0 for row in direct_categories):
return direct_categories
logger.warning(
@@ -1344,11 +1412,26 @@ class KnowledgeService:
)
return _source_category_counts(_source_backed_entries())
async def search(self, query: str, limit: int = 20) -> list[KnowledgeEntry]:
async def search(
self,
query: str,
limit: int = 20,
*,
project_id: str | None = None,
) -> list[KnowledgeEntry]:
"""關鍵字搜尋"""
try:
primary_read = (
self._search_primary_entries(query, limit)
if project_id is None
else self._search_primary_entries(
query,
limit,
project_id=project_id,
)
)
return await asyncio.wait_for(
self._search_primary_entries(query, limit),
primary_read,
timeout=_PRIMARY_KM_SIDE_READ_TIMEOUT_SECONDS,
)
except Exception as exc: # noqa: BLE001 - KM search must not 500 the UI