fix(knowledge): classify assets and fail soft list readback
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
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 09:00:42 +08:00
parent cc5b508ac5
commit 77daf55cdd
7 changed files with 266 additions and 75 deletions

View File

@@ -57,6 +57,11 @@ interface ListResponse {
items: KnowledgeEntry[]
total: number
categories: CategoryCount[]
readback_status?: 'ready' | 'degraded' | string
operator_stage?: string | null
next_step?: string | null
writes_on_read?: boolean
manual_review_required?: boolean
}
interface KnowledgeStaleCandidate {
@@ -150,6 +155,43 @@ type AssetLensKey =
// Category Config
// =============================================================================
const ASSET_LENS_KEYS: AssetLensKey[] = [
'project',
'product',
'website',
'service',
'package',
'tool',
'log',
'alert',
'playbook',
'rag',
'mcp',
'schedule',
]
const ASSET_LENS_CATEGORY_HINTS: Partial<Record<AssetLensKey, string[]>> = {
website: ['external_site'],
service: ['infrastructure', 'application', 'ai_system', 'database', 'host_resource', 'kubernetes', 'alert_handling'],
tool: ['devops_tool'],
alert: ['alert_handling'],
}
const ASSET_LENS_TERMS: Record<AssetLensKey, string[]> = {
project: ['project', 'repo', 'repository', 'gitea', 'branch', 'workflow', 'source_control'],
product: ['product', 'awoooi', 'awooop', 'iwooos', 'vibework', 'stockplatform', 'momo', 'awooogo', 'agent-bounty', 'tsenyang'],
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'],
}
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
'AI自動化/Ansible受控修復': <Bot className="w-4 h-4" />,
AI治理: <Bot className="w-4 h-4" />,
@@ -368,6 +410,13 @@ const entryMatchesAny = (entry: KnowledgeEntry, candidates: string[]) => {
return candidates.some(candidate => text.includes(candidate.toLowerCase()))
}
const entryMatchesAssetLens = (entry: KnowledgeEntry, lens: AssetLensKey) => {
if (lens === 'playbook' && entry.related_playbook_id) return true
const categoryHints = ASSET_LENS_CATEGORY_HINTS[lens] ?? []
if (categoryHints.includes(entry.category)) return true
return entryMatchesAny(entry, ASSET_LENS_TERMS[lens])
}
const mergeCategoryCounts = (rows: CategoryCount[]) => {
const merged = new Map<string, number>()
rows.forEach(row => {
@@ -393,6 +442,7 @@ export default function KnowledgeBasePage({
const [categories, setCategories] = useState<CategoryCount[]>([])
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)
const [governanceTelemetry, setGovernanceTelemetry] = useState<KnowledgeGovernanceTelemetry>({
staleCandidates: null,
ownerReviews: null,
@@ -405,6 +455,7 @@ export default function KnowledgeBasePage({
// Filters
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [selectedType, setSelectedType] = useState<string | null>(null)
const [selectedAssetLens, setSelectedAssetLens] = useState<AssetLensKey | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [semanticMode, setSemanticMode] = useState(false)
const [semanticResults, setSemanticResults] = useState<(KnowledgeEntry & { score: number })[]>([])
@@ -433,18 +484,25 @@ export default function KnowledgeBasePage({
setEntries(data.items)
setTotal(data.total)
setCategories(data.categories)
setEntryReadback({
readback_status: data.readback_status ?? 'ready',
operator_stage: data.operator_stage ?? null,
next_step: data.next_step ?? null,
})
return
}
const errorBody = await res.json().catch(() => null) as { detail?: string; message?: string } | null
setEntries([])
setTotal(0)
setCategories([])
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([])
setEntryReadback(null)
setEntryFetchError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
@@ -570,10 +628,17 @@ export default function KnowledgeBasePage({
}
}, [selectedEntry, actionLoading, fetchEntries])
const displayedEntries = semanticMode ? semanticResults : entries
const baseDisplayedEntries = semanticMode ? semanticResults : entries
const displayedEntries = useMemo(
() => selectedAssetLens
? baseDisplayedEntries.filter(entry => entryMatchesAssetLens(entry, selectedAssetLens))
: baseDisplayedEntries,
[baseDisplayedEntries, selectedAssetLens],
)
const totalCount = categories.reduce((sum, c) => sum + c.count, 0)
const isKnowledgeListUnavailable = Boolean(entryFetchError)
const knowledgeReadbackReady = !loading && !isKnowledgeListUnavailable
const isKnowledgeListDegraded = entryReadback?.readback_status === 'degraded'
const knowledgeReadbackReady = !loading && !isKnowledgeListUnavailable && !isKnowledgeListDegraded
const localeCode = params.locale === 'en' ? 'en-US' : 'zh-TW'
const formatCount = useCallback(
(value: number) => value.toLocaleString(localeCode),
@@ -650,8 +715,8 @@ export default function KnowledgeBasePage({
const assetLensRows = useMemo(() => {
const ragChunks = governanceTelemetry.ragStats?.total_chunks ?? 0
const countWhere = (candidates: string[]) =>
displayedEntries.filter(entry => entryMatchesAny(entry, candidates)).length
const countWhere = (lens: AssetLensKey) =>
baseDisplayedEntries.filter(entry => entryMatchesAssetLens(entry, lens)).length
const rows: Array<{
key: AssetLensKey
icon: LucideIcon
@@ -662,99 +727,79 @@ export default function KnowledgeBasePage({
{
key: 'project',
icon: GitBranch,
count: countWhere([
'project', 'repo', 'repository', 'gitea', 'branch', 'workflow', 'source_control',
]),
count: countWhere('project'),
tone: 'text-claw-blue',
},
{
key: 'product',
icon: Package,
count: countWhere([
'product', 'awoooi', 'awooop', 'iwooos', 'vibework', 'stockplatform', 'momo', 'awooogo', 'agent-bounty', 'tsenyang',
]),
count: countWhere('product'),
tone: 'text-purple-600',
},
{
key: 'website',
icon: Globe2,
count: displayedEntries.filter(entry =>
entry.category === 'external_site'
|| entryMatchesAny(entry, ['website', 'site', 'nginx', 'ssl', 'domain', 'route', 'frontend']),
).length,
count: countWhere('website'),
tone: 'text-status-healthy',
},
{
key: 'service',
icon: Server,
count: displayedEntries.filter(entry =>
['infrastructure', 'application', 'ai_system', 'database', 'host_resource', 'kubernetes', 'alert_handling'].includes(entry.category)
|| entryMatchesAny(entry, ['service', 'daemon', 'api', 'worker', 'runtime', 'container', 'pod']),
).length,
count: countWhere('service'),
tone: 'text-primary',
},
{
key: 'package',
icon: Package,
count: countWhere([
'package', 'dependency', 'npm', 'pnpm', 'node', 'python', 'pip', 'prisma', 'next', 'library',
]),
count: countWhere('package'),
tone: 'text-status-warning',
},
{
key: 'tool',
icon: Wrench,
count: displayedEntries.filter(entry =>
entry.category === 'devops_tool'
|| entryMatchesAny(entry, ['tool', 'ansible', 'mcp', 'playbook', 'telegram', 'wazuh', 'kali', 'sentry', 'signoz', 'runner']),
).length,
count: countWhere('tool'),
tone: 'text-status-critical',
},
{
key: 'log',
icon: FileText,
count: countWhere(['log', 'logs', 'event', 'timeline', 'trace', 'audit', 'callback', 'conversation_event', 'telemetry']),
count: countWhere('log'),
tone: 'text-primary',
},
{
key: 'alert',
icon: TriangleAlert,
count: displayedEntries.filter(entry =>
entry.category === 'alert_handling'
|| entryMatchesAny(entry, ['alert', 'telegram', 'sentry', 'signoz', 'notification', 'warning', 'critical']),
).length,
count: countWhere('alert'),
tone: 'text-status-warning',
},
{
key: 'playbook',
icon: ClipboardList,
count: displayedEntries.filter(entry =>
Boolean(entry.related_playbook_id) || entryMatchesAny(entry, ['playbook', 'runbook', 'sop']),
).length,
count: countWhere('playbook'),
tone: 'text-claw-blue',
},
{
key: 'rag',
icon: FileSearch,
count: ragChunks || countWhere(['rag', 'vector', 'embedding', 'semantic', 'retrieval']),
count: ragChunks || countWhere('rag'),
tone: ragChunks > 0 ? 'text-status-healthy' : 'text-status-critical',
global: ragChunks > 0,
},
{
key: 'mcp',
icon: Wrench,
count: countWhere(['mcp', 'connector', 'gateway', 'tool integration', 'tool-integration']),
count: countWhere('mcp'),
tone: 'text-purple-600',
},
{
key: 'schedule',
icon: Clock3,
count: countWhere(['schedule', 'cron', 'job', 'worker', 'patrol', 'recurrence', 'cadence']),
count: countWhere('schedule'),
tone: 'text-status-healthy',
},
]
return rows
}, [displayedEntries, governanceTelemetry.ragStats])
return ASSET_LENS_KEYS.map(key => rows.find(row => row.key === key)).filter((row): row is NonNullable<typeof row> => Boolean(row))
}, [baseDisplayedEntries, governanceTelemetry.ragStats])
const qualityRows = useMemo(() => {
const loaded = displayedEntries.length
@@ -1121,10 +1166,13 @@ export default function KnowledgeBasePage({
{/* All */}
<button
onClick={() => setSelectedCategory(null)}
onClick={() => {
setSelectedCategory(null)
setSelectedAssetLens(null)
}}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm font-body transition-colors',
!selectedCategory
!selectedCategory && !selectedAssetLens
? 'bg-claw-blue/8 text-claw-blue font-medium'
: 'text-secondary hover:bg-nothing-gray-100'
)}
@@ -1134,6 +1182,42 @@ export default function KnowledgeBasePage({
<span className="text-xs text-muted">{knowledgeReadbackReady ? formatCount(total) : '--'}</span>
</button>
<div className="mt-3 rounded-md border border-nothing-gray-200 bg-white/75 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-[10px] font-label uppercase tracking-wider text-muted">{t('assetLens.title')}</span>
<span className="shrink-0 text-[10px] font-body text-muted">{t('assetLens.scope')}</span>
</div>
<div className="grid grid-cols-2 gap-1">
{assetLensRows.map(row => {
const Icon = row.icon
const isActive = selectedAssetLens === row.key
return (
<button
key={row.key}
type="button"
onClick={() => setSelectedAssetLens(isActive ? null : row.key)}
className={cn(
'rounded border px-2 py-1.5 text-left transition-colors',
isActive
? 'border-claw-blue/30 bg-claw-blue/8'
: 'border-nothing-gray-100 bg-nothing-gray-50 hover:border-claw-blue/20 hover:bg-white',
)}
title={t(`assetLens.detail.${row.key}` as never)}
>
<div className="flex items-center justify-between gap-1">
<span className="truncate text-[10px] font-body text-secondary">{t(`assetLens.${row.key}` as never)}</span>
<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)}
</p>
</button>
)
})}
</div>
<p className="mt-2 text-[10px] font-body leading-4 text-muted">{t('assetLens.filterHelp')}</p>
</div>
<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">
@@ -1170,26 +1254,26 @@ export default function KnowledgeBasePage({
</div>
<div className="mt-4 rounded-md border border-nothing-gray-200 bg-white/75 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-[10px] font-label uppercase tracking-wider text-muted">{t('assetLens.title')}</span>
<span className="shrink-0 text-[10px] font-body text-muted">{t('assetLens.scope')}</span>
</div>
<div className="grid grid-cols-2 gap-1">
{assetLensRows.map(row => {
const Icon = row.icon
return (
<div key={row.key} className="rounded border border-nothing-gray-100 bg-nothing-gray-50 px-2 py-1.5">
<div className="flex items-center justify-between gap-1">
<span className="truncate text-[10px] font-body text-secondary">{t(`assetLens.${row.key}` as never)}</span>
<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)}
</p>
</div>
)
})}
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[10px] font-label uppercase tracking-wider text-muted">{t('assetLens.activeTitle')}</span>
{selectedAssetLens && (
<button
type="button"
onClick={() => setSelectedAssetLens(null)}
className="shrink-0 rounded border border-nothing-gray-200 bg-white px-2 py-0.5 text-[10px] font-label text-muted transition-colors hover:border-claw-blue/30 hover:text-claw-blue"
>
{t('assetLens.clear')}
</button>
)}
</div>
<p className="mt-1 text-sm font-heading font-semibold text-primary">
{selectedAssetLens ? t(`assetLens.${selectedAssetLens}` as never) : t('allCategories')}
</p>
<p className="mt-1 text-[10px] font-body leading-4 text-muted">
{selectedAssetLens
? t(`assetLens.detail.${selectedAssetLens}` as never)
: t('assetLens.activeDescription')}
</p>
</div>
<div className="mt-4 grid grid-cols-2 gap-2 lg:grid-cols-1">
{[
@@ -1284,16 +1368,21 @@ export default function KnowledgeBasePage({
</button>
</div>
{entryFetchError && (
{(entryFetchError || isKnowledgeListDegraded) && (
<div className="border-b border-status-warning/20 bg-status-warning/10 px-4 py-3">
<div className="flex flex-col gap-2 rounded-md border border-status-warning/25 bg-white/75 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-label text-status-warning">
<TriangleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>{t('dataChain.errorTitle')}</span>
<span>{entryFetchError ? t('dataChain.errorTitle') : t('dataChain.degradedTitle')}</span>
</div>
<p className="mt-1 text-xs font-body leading-5 text-secondary">
{t('dataChain.errorDescription', { reason: entryFetchError })}
{entryFetchError
? t('dataChain.errorDescription', { reason: entryFetchError })
: t('dataChain.degradedDescription', {
stage: entryReadback?.operator_stage ?? '--',
next: entryReadback?.next_step ?? '--',
})}
</p>
</div>
<button