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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user