fix(knowledge): bind canonical project readback
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user