feat(km): add full corpus asset taxonomy readback
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / tests (push) Has been cancelled

This commit is contained in:
Your Name
2026-07-03 16:12:48 +08:00
parent 37dda358a3
commit f99305e481
10 changed files with 312 additions and 17 deletions

View File

@@ -20,6 +20,7 @@ from fastapi import APIRouter, HTTPException, Query
from src.models.knowledge import (
EntryStatus,
EntryType,
KnowledgeAssetTaxonomyCount,
KnowledgeEntry,
KnowledgeEntryCreate,
KnowledgeEntryUpdate,
@@ -105,6 +106,13 @@ async def get_categories() -> list[dict]:
return [cat.model_dump() for cat in cats]
@router.get("/asset-taxonomy", response_model=list[KnowledgeAssetTaxonomyCount])
async def get_asset_taxonomy() -> list[KnowledgeAssetTaxonomyCount]:
"""取得 AI 自動化資產維度統計(只讀,不寫入)。"""
service = get_knowledge_service()
return await service.get_asset_taxonomy()
@router.get("/{entry_id}", response_model=KnowledgeEntry)
async def get_entry(entry_id: str) -> KnowledgeEntry:
"""取得單筆知識條目 (view_count +1)"""

View File

@@ -122,11 +122,18 @@ class CategoryCount(BaseModel):
count: int
class KnowledgeAssetTaxonomyCount(BaseModel):
"""AI 自動化資產維度統計"""
key: str
count: int
class KnowledgeListResponse(BaseModel):
"""列表回應"""
items: list[KnowledgeEntry]
total: int
categories: list[CategoryCount] = Field(default_factory=list)
asset_taxonomy: list[KnowledgeAssetTaxonomyCount] = Field(default_factory=list)
readback_status: str = "ready"
operator_stage: str | None = None
next_step: str | None = None

View File

@@ -265,6 +265,10 @@ class IKnowledgeRepository(Protocol):
"""取得分類統計 [(category, count)]"""
...
async def get_asset_taxonomy_counts(self) -> list[tuple[str, int]]:
"""取得 AI 自動化資產維度統計 [(taxonomy_key, count)]"""
...
async def search(self, query: str, limit: int = 20) -> list[KnowledgeEntry]:
"""關鍵字搜尋 (title + content + tags)"""
...

View File

@@ -29,6 +29,113 @@ logger = structlog.get_logger(__name__)
_DEFAULT_CATEGORY = "general"
_ASSET_TAXONOMY_KEYS = (
"project",
"product",
"website",
"service",
"package",
"tool",
"log",
"alert",
"playbook",
"rag",
"mcp",
"schedule",
)
_ASSET_TAXONOMY_TERMS: dict[str, tuple[str, ...]] = {
"project": (
"project",
"repo",
"repository",
"gitea",
"branch",
"workflow",
"source_control",
),
"product": (
"product",
"awoooi",
"awooop",
"iwooos",
"stockplatform",
"momo",
"awooogo",
"agent-bounty",
"agent_bounty",
"tsenyang",
"vibework",
"2026fifa",
),
"website": ("website", "site", "nginx", "ssl", "domain", "route", "frontend"),
"service": ("service", "daemon", "api", "worker", "runtime", "container", "pod"),
"package": (
"package",
"dependency",
"npm",
"pnpm",
"node",
"python",
"pip",
"prisma",
"next",
"library",
),
"tool": (
"tool",
"ansible",
"mcp",
"playbook",
"telegram",
"wazuh",
"kali",
"sentry",
"signoz",
"runner",
),
"log": (
"log",
"logs",
"event",
"timeline",
"trace",
"audit",
"callback",
"conversation_event",
"telemetry",
),
"alert": (
"alert",
"telegram",
"sentry",
"signoz",
"notification",
"warning",
"critical",
),
"playbook": ("playbook", "runbook", "sop"),
"rag": ("rag", "vector", "embedding", "semantic", "retrieval"),
"mcp": ("mcp", "connector", "gateway", "tool integration", "tool-integration"),
"schedule": ("schedule", "cron", "job", "worker", "patrol", "recurrence", "cadence"),
}
_ASSET_TAXONOMY_CATEGORY_HINTS: dict[str, tuple[str, ...]] = {
"website": ("external_site",),
"service": (
"infrastructure",
"application",
"ai_system",
"database",
"host_resource",
"kubernetes",
"alert_handling",
),
"tool": ("devops_tool", "AI自動化/Ansible受控修復"),
"alert": ("alert_handling",),
"playbook": ("auto_repair",),
}
def _enum_text(column):
"""Return a lowercase text view for enum/varchar columns across old KM schemas."""
@@ -85,6 +192,32 @@ def _knowledge_entry_columns():
)
def _asset_taxonomy_condition(key: str):
"""Build a broad-but-readable taxonomy matcher for AI automation surfaces."""
category_expr = _normalized_category_expr()
category_text = func.lower(category_expr.cast(String))
text_columns = (
KnowledgeEntryRecord.title,
KnowledgeEntryRecord.content,
KnowledgeEntryRecord.tags.cast(String),
category_expr.cast(String),
)
conditions = []
category_hints = tuple(
hint.lower() for hint in _ASSET_TAXONOMY_CATEGORY_HINTS.get(key, ())
)
if category_hints:
conditions.append(category_text.in_(category_hints))
for term in _ASSET_TAXONOMY_TERMS.get(key, ()):
pattern = f"%{term.lower()}%"
conditions.extend(func.lower(column).like(pattern) for column in text_columns)
if key == "playbook":
conditions.append(KnowledgeEntryRecord.related_playbook_id.is_not(None))
if not conditions:
return category_text == key
return or_(*conditions)
class KnowledgeDBRepository:
"""
Knowledge Repository - PostgreSQL 實作
@@ -290,6 +423,26 @@ class KnowledgeDBRepository:
)
return [(_normalize_category(row.category), row.cnt) for row in result.all()]
async def get_asset_taxonomy_counts(self) -> list[tuple[str, int]]:
"""取得 AI 自動化資產維度統計。
這不是替代原始 category而是讓 UI / Agent 可以用穩定 taxonomy
將 KM 依專案、產品、網站、服務、套件、工具、Log、Alert、PlayBook、
RAG、MCP 與排程分群。
"""
rows: list[tuple[str, int]] = []
for key in _ASSET_TAXONOMY_KEYS:
result = await self.db.execute(
select(func.count())
.select_from(KnowledgeEntryRecord)
.where(
_active_status_filter(),
_asset_taxonomy_condition(key),
)
)
rows.append((key, int(result.scalar() or 0)))
return rows
async def search(self, query: str, limit: int = 20) -> list[KnowledgeEntry]:
"""關鍵字搜尋 (title + content + tags)"""
like_q = f"%{query}%"

View File

@@ -21,6 +21,7 @@ from src.models.knowledge import (
CategoryCount,
EntryStatus,
EntryType,
KnowledgeAssetTaxonomyCount,
KnowledgeEntry,
KnowledgeEntryCreate,
KnowledgeEntryUpdate,
@@ -49,6 +50,8 @@ _DEGRADED_CATEGORY_FALLBACKS = (
"general",
)
_ASSET_TAXONOMY_FALLBACKS = _DEGRADED_CATEGORY_FALLBACKS[:-1]
# =============================================================================
# Singleton
# =============================================================================
@@ -65,6 +68,10 @@ def build_knowledge_list_readback_degraded_response(reason: str) -> KnowledgeLis
CategoryCount(category=category, count=0)
for category in _DEGRADED_CATEGORY_FALLBACKS
],
asset_taxonomy=[
KnowledgeAssetTaxonomyCount(key=key, count=0)
for key in _ASSET_TAXONOMY_FALLBACKS
],
readback_status="degraded",
operator_stage="knowledge_readback_degraded_ai_controlled_repair",
next_step=(
@@ -191,8 +198,16 @@ class KnowledgeService:
categories = [
CategoryCount(category=cat, count=cnt) for cat, cnt in categories_raw
]
asset_taxonomy_raw = await repo.get_asset_taxonomy_counts()
asset_taxonomy = [
KnowledgeAssetTaxonomyCount(key=key, count=count)
for key, count in asset_taxonomy_raw
]
return KnowledgeListResponse(
items=items, total=total, categories=categories
items=items,
total=total,
categories=categories,
asset_taxonomy=asset_taxonomy,
)
except Exception as exc: # noqa: BLE001 - production readback must fail soft
logger.warning(
@@ -207,6 +222,23 @@ class KnowledgeService:
)
return build_knowledge_list_readback_degraded_response(str(exc))
async def get_asset_taxonomy(self) -> list[KnowledgeAssetTaxonomyCount]:
"""取得 AI 自動化資產維度統計。"""
try:
async with get_db_context() as db:
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
rows = await repo.get_asset_taxonomy_counts()
return [
KnowledgeAssetTaxonomyCount(key=key, count=count)
for key, count in rows
]
except Exception as exc: # noqa: BLE001 - taxonomy must not 500 the KM UI
logger.warning("knowledge_asset_taxonomy_readback_degraded", error=str(exc))
return [
KnowledgeAssetTaxonomyCount(key=key, count=0)
for key in _ASSET_TAXONOMY_FALLBACKS
]
async def get_categories(self) -> list[CategoryCount]:
"""取得分類統計(直接呼叫 repo不走 list_entries"""
try:

View File

@@ -8,7 +8,11 @@ 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
from src.repositories.knowledge_repository import (
KnowledgeDBRepository,
_ASSET_TAXONOMY_KEYS,
_enum_text,
)
def _record(**overrides):
@@ -177,6 +181,33 @@ async def test_get_categories_normalizes_null_or_blank_category() -> None:
assert "coalesce(nullif(trim(knowledge_entries.category)" in compiled
@pytest.mark.asyncio
async def test_get_asset_taxonomy_counts_uses_stable_ai_automation_lenses() -> None:
class _TaxonomyDb:
def __init__(self):
self.statements = []
self.values = list(range(10, 10 + len(_ASSET_TAXONOMY_KEYS)))
async def execute(self, statement):
self.statements.append(statement)
return _ScalarResult(self.values.pop(0))
db = _TaxonomyDb()
repo = KnowledgeDBRepository(db=db) # type: ignore[arg-type]
counts = await repo.get_asset_taxonomy_counts()
assert [key for key, _ in counts] == list(_ASSET_TAXONOMY_KEYS)
assert [count for _, count in counts] == list(range(10, 10 + len(_ASSET_TAXONOMY_KEYS)))
compiled = "\n".join(
str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}))
for stmt in db.statements
).lower()
assert "source_control" in compiled
assert "awoooi" in compiled
assert "related_playbook_id is not null" in compiled
def test_enum_text_helper_casts_status_to_lowercase_text() -> None:
compiled = str(
(_enum_text(KnowledgeEntryRecord.status) == EntryStatus.ARCHIVED.value).compile(

View File

@@ -41,6 +41,21 @@ async def test_knowledge_list_entries_fails_soft_when_readback_breaks(monkeypatc
"general",
]
assert all(row.count == 0 for row in response.categories)
assert [row.key for row in response.asset_taxonomy] == [
"project",
"product",
"website",
"service",
"package",
"tool",
"log",
"alert",
"playbook",
"rag",
"mcp",
"schedule",
]
assert all(row.count == 0 for row in response.asset_taxonomy)
assert response.readback_status == "degraded"
assert response.operator_stage == "knowledge_readback_degraded_ai_controlled_repair"
assert response.next_step == "queue_ai_controlled_km_readback_retry_tagging_and_connector_verifier"
@@ -75,3 +90,31 @@ async def test_knowledge_categories_fails_soft_when_readback_breaks(monkeypatch)
"general",
]
assert all(row.count == 0 for row in categories)
@pytest.mark.asyncio
async def test_knowledge_asset_taxonomy_fails_soft_when_readback_breaks(monkeypatch) -> None:
monkeypatch.setattr(
knowledge_service_module,
"get_db_context",
lambda: _BrokenDbContext(),
)
service = KnowledgeService.__new__(KnowledgeService)
taxonomy = await service.get_asset_taxonomy()
assert [row.key for row in taxonomy] == [
"project",
"product",
"website",
"service",
"package",
"tool",
"log",
"alert",
"playbook",
"rag",
"mcp",
"schedule",
]
assert all(row.count == 0 for row in taxonomy)

View File

@@ -2187,7 +2187,7 @@
"clear": "清除",
"matrixTitle": "全域分類 / 資產矩陣",
"matrixSubtitle": "用目前 KM readback 與 RAG stats把專案、產品、網站、服務、套件、工具、Log、Alert、PlayBook、RAG、MCP 與排程分群顯示。",
"autoUpdated": "API 驅動",
"autoUpdated": "Full-corpus readback",
"global": "全域",
"project": "專案",
"product": "產品",

View File

@@ -2187,7 +2187,7 @@
"clear": "清除",
"matrixTitle": "全域分類 / 資產矩陣",
"matrixSubtitle": "用目前 KM readback 與 RAG stats把專案、產品、網站、服務、套件、工具、Log、Alert、PlayBook、RAG、MCP 與排程分群顯示。",
"autoUpdated": "API 驅動",
"autoUpdated": "全庫 readback",
"global": "全域",
"project": "專案",
"product": "產品",

View File

@@ -53,10 +53,16 @@ interface CategoryCount {
count: number
}
interface AssetTaxonomyCount {
key: AssetLensKey
count: number
}
interface ListResponse {
items: KnowledgeEntry[]
total: number
categories: CategoryCount[]
asset_taxonomy?: AssetTaxonomyCount[]
readback_status?: 'ready' | 'degraded' | string
operator_stage?: string | null
next_step?: string | null
@@ -264,6 +270,7 @@ const DEFAULT_CATEGORY_ORDER = [
const KNOWN_CATEGORY_KEYS = new Set([
'AI自動化/Ansible受控修復',
'AI治理',
'alert',
'alert_handling',
'application',
'ai_system',
@@ -388,6 +395,7 @@ const CATEGORY_BAR_COLORS: Record<string, string> = {
}
const CATEGORY_ORDER_RANK = new Map(DEFAULT_CATEGORY_ORDER.map((category, index) => [category, index]))
const ASSET_TAXONOMY_FALLBACK_COUNTS: AssetTaxonomyCount[] = ASSET_LENS_KEYS.map(key => ({ key, count: 0 }))
const TAG_TONES: Record<string, string> = {
critical: 'border-status-critical/20 bg-status-critical/10 text-status-critical',
@@ -485,6 +493,7 @@ export default function KnowledgeBasePage({
const [entries, setEntries] = useState<KnowledgeEntry[]>([])
const [total, setTotal] = useState(0)
const [categories, setCategories] = useState<CategoryCount[]>([])
const [assetTaxonomy, setAssetTaxonomy] = useState<AssetTaxonomyCount[]>(ASSET_TAXONOMY_FALLBACK_COUNTS)
const [loading, setLoading] = useState(true)
const [entryFetchError, setEntryFetchError] = useState<string | null>(null)
const [entryReadback, setEntryReadback] = useState<Pick<ListResponse, 'readback_status' | 'operator_stage' | 'next_step'> | null>(null)
@@ -529,6 +538,7 @@ export default function KnowledgeBasePage({
setEntries(data.items)
setTotal(data.total)
setCategories(data.categories)
setAssetTaxonomy(data.asset_taxonomy?.length ? data.asset_taxonomy : ASSET_TAXONOMY_FALLBACK_COUNTS)
setEntryReadback({
readback_status: data.readback_status ?? 'ready',
operator_stage: data.operator_stage ?? null,
@@ -539,14 +549,16 @@ export default function KnowledgeBasePage({
const errorBody = await res.json().catch(() => null) as { detail?: string; message?: string } | null
setEntries([])
setTotal(0)
setCategories([])
setCategories(ASSET_TAXONOMY_FALLBACK_COUNTS.map(row => ({ category: row.key, count: 0 })))
setAssetTaxonomy(ASSET_TAXONOMY_FALLBACK_COUNTS)
setEntryReadback(null)
setEntryFetchError(errorBody?.detail ?? errorBody?.message ?? `${res.status} ${res.statusText}`)
} catch (err) {
console.error('Failed to fetch knowledge entries', err)
setEntries([])
setTotal(0)
setCategories([])
setCategories(ASSET_TAXONOMY_FALLBACK_COUNTS.map(row => ({ category: row.key, count: 0 })))
setAssetTaxonomy(ASSET_TAXONOMY_FALLBACK_COUNTS)
setEntryReadback(null)
setEntryFetchError(err instanceof Error ? err.message : String(err))
} finally {
@@ -746,22 +758,27 @@ export default function KnowledgeBasePage({
}, [categories, totalCount])
const categoryNavigationRows = useMemo(() => {
if (categories.length === 0) return []
const sourceRows = mergeCategoryCounts(categories)
const positiveRows = sourceRows.filter(row => row.count > 0)
const fallbackRows = ASSET_TAXONOMY_FALLBACK_COUNTS.map(row => ({
category: row.key,
count: assetTaxonomy.find(item => item.key === row.key)?.count ?? row.count,
}))
const rows = positiveRows.length > 0 ? positiveRows : fallbackRows
const sourceRows = mergeCategoryCounts(categories).filter(row => row.count > 0)
return [...sourceRows].sort((a, b) => {
return [...rows].sort((a, b) => {
const aRank = CATEGORY_ORDER_RANK.get(a.category) ?? Number.MAX_SAFE_INTEGER
const bRank = CATEGORY_ORDER_RANK.get(b.category) ?? Number.MAX_SAFE_INTEGER
if (aRank !== bRank) return aRank - bRank
return b.count - a.count
})
}, [categories])
}, [assetTaxonomy, categories])
const assetLensRows = useMemo(() => {
const ragChunks = governanceTelemetry.ragStats?.total_chunks ?? 0
const taxonomyByKey = new Map(assetTaxonomy.map(row => [row.key, row.count]))
const countWhere = (lens: AssetLensKey) =>
baseDisplayedEntries.filter(entry => entryMatchesAssetLens(entry, lens)).length
taxonomyByKey.get(lens) ?? baseDisplayedEntries.filter(entry => entryMatchesAssetLens(entry, lens)).length
const rows: Array<{
key: AssetLensKey
icon: LucideIcon
@@ -828,7 +845,7 @@ export default function KnowledgeBasePage({
icon: FileSearch,
count: ragChunks || countWhere('rag'),
tone: ragChunks > 0 ? 'text-status-healthy' : 'text-status-critical',
global: ragChunks > 0,
global: ragChunks > 0 || taxonomyByKey.has('rag'),
},
{
key: 'mcp',
@@ -844,7 +861,7 @@ export default function KnowledgeBasePage({
},
]
return ASSET_LENS_KEYS.map(key => rows.find(row => row.key === key)).filter((row): row is NonNullable<typeof row> => Boolean(row))
}, [baseDisplayedEntries, governanceTelemetry.ragStats])
}, [assetTaxonomy, baseDisplayedEntries, governanceTelemetry.ragStats])
const qualityRows = useMemo(() => {
const loaded = displayedEntries.length
@@ -1254,7 +1271,7 @@ export default function KnowledgeBasePage({
<Icon className={cn('h-3 w-3 shrink-0', row.tone)} aria-hidden={true} />
</div>
<p className={cn('mt-1 text-sm font-heading font-semibold tabular-nums', row.tone)}>
{(row.key === 'rag' ? governanceLoading : !knowledgeReadbackReady) ? '--' : formatCount(row.count)}
{(row.key === 'rag' ? governanceLoading && row.count === 0 : loading) ? '--' : formatCount(row.count)}
</p>
</button>
)
@@ -1265,7 +1282,7 @@ export default function KnowledgeBasePage({
<div className="mt-3 flex items-center justify-between px-2">
<span className="text-[10px] font-label uppercase tracking-wider text-muted">{t('rail.categoryTitle')}</span>
<span className="text-[10px] font-body tabular-nums text-muted">
{knowledgeReadbackReady ? categoryNavigationRows.length : '--'}
{loading ? '--' : categoryNavigationRows.length}
</span>
</div>
@@ -1578,7 +1595,7 @@ export default function KnowledgeBasePage({
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-6">
{assetLensRows.map(row => {
const Icon = row.icon
const value = (row.key === 'rag' ? governanceLoading : !knowledgeReadbackReady)
const value = (row.key === 'rag' ? governanceLoading && row.count === 0 : loading)
? '--'
: formatCount(row.count)
return (