diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a75919e6f..2a3de4c1d 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1460,6 +1460,41 @@ "review": "審核", "missing": "尚未關聯" }, + "workItems": { + "title": "Work Items 接續狀態", + "subtitle": "這裡只讀取 Hermes KB owner-review queue,讓你知道陳舊 KM 是否已進入可追蹤工作項;實際預覽、確認與寫回仍在 AwoooP Work Items 執行。", + "open": "打開 Work Items", + "loading": "讀取中", + "threshold": "門檻 {value}", + "entriesToThreshold": "需處理 {count} 筆才回到門檻", + "ownerPendingSub": "待 Owner review", + "readyBlockedSub": "ready / blocked", + "burndownStatus": "狀態:{status}", + "topCandidate": "最高優先處理項", + "readOnly": "read-only", + "staleDays": "{days} 天未更新", + "score": "score {score}", + "topCandidateRefs": "Incident {incident} / Playbook {playbook}", + "noCandidate": "目前沒有可顯示的 stale KM candidate。", + "ownerSplit": "主責分工", + "hermes": "Hermes:建立 KM 更新草稿與 owner-review queue。", + "openclaw": "OpenClaw:補告警分類、Playbook 與上下文摘要。", + "owner": "Owner:在 AwoooP Work Items 預覽後確認寫回。", + "noWritesOnRead": "讀取不寫入", + "unexpectedWrites": "偵測到 read endpoint 宣告會寫入", + "metric": { + "staleRatio": "陳舊比例", + "staleTotal": "陳舊 KM", + "ownerPending": "待審核", + "readyBlocked": "可處理 / 阻塞", + "completed": "已完成" + }, + "status": { + "above_threshold": "高於門檻", + "within_threshold": "低於門檻", + "unknown": "未知" + } + }, "noResults": "找不到相關知識條目", "createEntry": "新增條目", "viewCount": "瀏覽", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index a75919e6f..2a3de4c1d 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1460,6 +1460,41 @@ "review": "審核", "missing": "尚未關聯" }, + "workItems": { + "title": "Work Items 接續狀態", + "subtitle": "這裡只讀取 Hermes KB owner-review queue,讓你知道陳舊 KM 是否已進入可追蹤工作項;實際預覽、確認與寫回仍在 AwoooP Work Items 執行。", + "open": "打開 Work Items", + "loading": "讀取中", + "threshold": "門檻 {value}", + "entriesToThreshold": "需處理 {count} 筆才回到門檻", + "ownerPendingSub": "待 Owner review", + "readyBlockedSub": "ready / blocked", + "burndownStatus": "狀態:{status}", + "topCandidate": "最高優先處理項", + "readOnly": "read-only", + "staleDays": "{days} 天未更新", + "score": "score {score}", + "topCandidateRefs": "Incident {incident} / Playbook {playbook}", + "noCandidate": "目前沒有可顯示的 stale KM candidate。", + "ownerSplit": "主責分工", + "hermes": "Hermes:建立 KM 更新草稿與 owner-review queue。", + "openclaw": "OpenClaw:補告警分類、Playbook 與上下文摘要。", + "owner": "Owner:在 AwoooP Work Items 預覽後確認寫回。", + "noWritesOnRead": "讀取不寫入", + "unexpectedWrites": "偵測到 read endpoint 宣告會寫入", + "metric": { + "staleRatio": "陳舊比例", + "staleTotal": "陳舊 KM", + "ownerPending": "待審核", + "readyBlocked": "可處理 / 阻塞", + "completed": "已完成" + }, + "status": { + "above_threshold": "高於門檻", + "within_threshold": "低於門檻", + "unknown": "未知" + } + }, "noResults": "找不到相關知識條目", "createEntry": "新增條目", "viewCount": "瀏覽", diff --git a/apps/web/src/app/[locale]/knowledge-base/page.tsx b/apps/web/src/app/[locale]/knowledge-base/page.tsx index c0820c8ac..d0c69e1e0 100644 --- a/apps/web/src/app/[locale]/knowledge-base/page.tsx +++ b/apps/web/src/app/[locale]/knowledge-base/page.tsx @@ -14,11 +14,12 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { AppLayout } from '@/components/layout' import { useTranslations } from 'next-intl' +import Link from 'next/link' import { cn } from '@/lib/utils' import { Search, BookOpen, FileText, Shield, Cpu, Server, Eye, Bot, ChevronRight, Plus, Sparkles, ClipboardList, Tag, TriangleAlert, - GitBranch, CheckCircle2, Clock3, Link2, FileSearch, + GitBranch, CheckCircle2, Clock3, Link2, FileSearch, ListChecks, ArrowRight, Users, } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -56,6 +57,69 @@ interface ListResponse { categories: CategoryCount[] } +interface KnowledgeStaleCandidate { + entry_id: string + title: string + priority_tier: string + priority_score: number + stale_days: number + recommended_action: string + owner_review_dispatch_id?: string | null + owner_review_status?: string | null + owner_review_stage?: string | null + related_incident_id?: string | null + related_playbook_id?: string | null +} + +interface KnowledgeStaleCandidatesResponse { + total_stale: number + returned: number + threshold_days: number + writes_on_read: boolean + manual_review_required: boolean + items: KnowledgeStaleCandidate[] +} + +interface KnowledgeStaleOwnerReviewInboxResponse { + total: number + returned: number + writes_on_read: boolean + manual_review_required: boolean +} + +interface KnowledgeStaleOwnerReviewBurnDownResponse { + burn_down_status: string + entries_to_threshold: number + pending_owner_reviews: number + completed_owner_reviews: number + current_snapshot?: { + stale_count: number + total_count: number + stale_ratio: number + threshold: number + stale_days: number + } | null +} + +interface KnowledgeStaleOwnerReviewCompletionQueueResponse { + total: number + pending_count: number + ready_count: number + blocked_count: number + completed_count: number + failed_count: number + writes_on_read: boolean + manual_review_required: boolean + batch_writes_allowed: boolean +} + +interface KnowledgeGovernanceTelemetry { + staleCandidates: KnowledgeStaleCandidatesResponse | null + ownerReviews: KnowledgeStaleOwnerReviewInboxResponse | null + burnDown: KnowledgeStaleOwnerReviewBurnDownResponse | null + completionQueue: KnowledgeStaleOwnerReviewCompletionQueueResponse | null +} + // ============================================================================= // Category Config // ============================================================================= @@ -219,6 +283,13 @@ export default function KnowledgeBasePage({ const [total, setTotal] = useState(0) const [categories, setCategories] = useState([]) const [loading, setLoading] = useState(true) + const [governanceTelemetry, setGovernanceTelemetry] = useState({ + staleCandidates: null, + ownerReviews: null, + burnDown: null, + completionQueue: null, + }) + const [governanceLoading, setGovernanceLoading] = useState(true) // Filters const [selectedCategory, setSelectedCategory] = useState(null) @@ -260,6 +331,42 @@ export default function KnowledgeBasePage({ if (!semanticMode) fetchEntries() }, [fetchEntries, semanticMode]) + const fetchGovernanceTelemetry = useCallback(async () => { + setGovernanceLoading(true) + try { + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? '' + const [staleCandidates, ownerReviews, burnDown, completionQueue] = await Promise.all([ + fetch(`${apiBase}/api/v1/ai/governance/km-stale-candidates?project_id=awoooi&limit=5`) + .then(res => res.ok ? res.json() : null) + .catch(() => null), + fetch(`${apiBase}/api/v1/ai/governance/km-stale-owner-reviews?project_id=awoooi&dispatch_status=pending&limit=5`) + .then(res => res.ok ? res.json() : null) + .catch(() => null), + fetch(`${apiBase}/api/v1/ai/governance/km-stale-owner-review-burndown?project_id=awoooi&limit=5`) + .then(res => res.ok ? res.json() : null) + .catch(() => null), + fetch(`${apiBase}/api/v1/ai/governance/km-stale-owner-review-completion-queue?project_id=awoooi&status_bucket=all&limit=5`) + .then(res => res.ok ? res.json() : null) + .catch(() => null), + ]) + + setGovernanceTelemetry({ + staleCandidates, + ownerReviews, + burnDown, + completionQueue, + }) + } catch (err) { + console.error('Failed to fetch knowledge governance telemetry', err) + } finally { + setGovernanceLoading(false) + } + }, []) + + useEffect(() => { + fetchGovernanceTelemetry() + }, [fetchGovernanceTelemetry]) + // KB Phase 2: 語意搜尋 (2026-04-04 Claude Code) const fetchSemanticResults = useCallback(async (q: string) => { if (!q.trim()) { setSemanticResults([]); return } @@ -453,6 +560,40 @@ export default function KnowledgeBasePage({ ] as const }, [displayedEntries]) + const governanceSummary = useMemo(() => { + const snapshot = governanceTelemetry.burnDown?.current_snapshot ?? null + const ratio = snapshot ? Math.round(snapshot.stale_ratio * 1000) / 10 : null + const threshold = snapshot ? Math.round(snapshot.threshold * 1000) / 10 : null + const topCandidate = governanceTelemetry.staleCandidates?.items?.[0] ?? null + const ownerReady = governanceTelemetry.completionQueue?.ready_count ?? 0 + const ownerBlocked = governanceTelemetry.completionQueue?.blocked_count ?? 0 + const ownerPending = governanceTelemetry.ownerReviews?.total ?? governanceTelemetry.completionQueue?.pending_count ?? 0 + const completed = governanceTelemetry.completionQueue?.completed_count ?? governanceTelemetry.burnDown?.completed_owner_reviews ?? 0 + const workItemsHref = topCandidate?.owner_review_dispatch_id + ? `/awooop/work-items?project_id=awoooi&work_item_id=${encodeURIComponent(topCandidate.owner_review_dispatch_id)}` + : '/awooop/work-items?project_id=awoooi' + + return { + snapshot, + ratio, + threshold, + topCandidate, + ownerReady, + ownerBlocked, + ownerPending, + completed, + workItemsHref, + staleTotal: governanceTelemetry.staleCandidates?.total_stale ?? snapshot?.stale_count ?? null, + entriesToThreshold: governanceTelemetry.burnDown?.entries_to_threshold ?? null, + burnDownStatus: governanceTelemetry.burnDown?.burn_down_status ?? null, + writesOnRead: [ + governanceTelemetry.staleCandidates?.writes_on_read, + governanceTelemetry.ownerReviews?.writes_on_read, + governanceTelemetry.completionQueue?.writes_on_read, + ].some(Boolean), + } + }, [governanceTelemetry]) + const content = (
@@ -695,6 +836,119 @@ export default function KnowledgeBasePage({
+ +
+
+
+
+
+

+ {t('workItems.subtitle')} +

+
+ + {t('workItems.open')} +
+ +
+ {[ + { + key: 'staleRatio', + value: governanceSummary.ratio === null ? '--' : `${governanceSummary.ratio}%`, + sub: governanceSummary.threshold === null ? t('workItems.loading') : t('workItems.threshold', { value: `${governanceSummary.threshold}%` }), + tone: 'text-status-warning', + }, + { + key: 'staleTotal', + value: governanceSummary.staleTotal === null ? '--' : formatCount(governanceSummary.staleTotal), + sub: governanceSummary.entriesToThreshold === null ? t('workItems.loading') : t('workItems.entriesToThreshold', { count: formatCount(governanceSummary.entriesToThreshold) }), + tone: 'text-primary', + }, + { + key: 'ownerPending', + value: formatCount(governanceSummary.ownerPending), + sub: t('workItems.ownerPendingSub'), + tone: 'text-claw-blue', + }, + { + key: 'readyBlocked', + value: `${formatCount(governanceSummary.ownerReady)} / ${formatCount(governanceSummary.ownerBlocked)}`, + sub: t('workItems.readyBlockedSub'), + tone: 'text-purple-600', + }, + { + key: 'completed', + value: formatCount(governanceSummary.completed), + sub: governanceSummary.burnDownStatus ? t('workItems.burndownStatus', { status: t(`workItems.status.${governanceSummary.burnDownStatus}` as never) }) : t('workItems.loading'), + tone: 'text-status-healthy', + }, + ].map(metric => ( +
+

{t(`workItems.metric.${metric.key}`)}

+

{governanceLoading ? '--' : metric.value}

+

{metric.sub}

+
+ ))} +
+ +
+
+
+

{t('workItems.topCandidate')}

+ {t('workItems.readOnly')} +
+ {governanceSummary.topCandidate ? ( +
+
+ + {governanceSummary.topCandidate.priority_tier} + + + {t('workItems.staleDays', { days: governanceSummary.topCandidate.stale_days })} + + + {t('workItems.score', { score: governanceSummary.topCandidate.priority_score })} + +
+

+ {governanceSummary.topCandidate.title} +

+

+ {t('workItems.topCandidateRefs', { + incident: governanceSummary.topCandidate.related_incident_id ?? '--', + playbook: governanceSummary.topCandidate.related_playbook_id ?? '--', + })} +

+
+ ) : ( +

{t('workItems.noCandidate')}

+ )} +
+
+
+
+
+

{t('workItems.hermes')}

+

{t('workItems.openclaw')}

+

{t('workItems.owner')}

+
+ {governanceSummary.writesOnRead ? ( +

{t('workItems.unexpectedWrites')}

+ ) : ( +

{t('workItems.noWritesOnRead')}

+ )} +
+
+
{/* 結果列表 */}