feat(api,web): Phase 6.5 DecisionManager with dual-engine fallback

Backend:
- Add DecisionManager with state machine (INIT→ANALYZING→READY→EXECUTING)
- Implement Expert System rules engine (100% local, never fails)
- Dual-engine: LLM (primary) + Expert System (fallback)
- Auto-generate decision_token for each incident
- 30-second timeout guarantee

Frontend:
- Use decision.state to unlock [Y/n] buttons
- Display AI action suggestion in card
- Show source indicator [AI] or [EXP]
- Generate proposal on-demand if needed

Fixes: UI locked with hourglass when LLM times out

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-23 13:19:55 +08:00
parent c01742ef82
commit 0aaf6a276b
4 changed files with 636 additions and 45 deletions

View File

@@ -1,8 +1,8 @@
'use client'
/**
* DualStateIncidentCard - Phase 6.5a 雙態戰情室卡片
* ==================================================
* DualStateIncidentCard - Phase 6.5 雙態戰情室卡片
* ================================================
*
* Nothing.tech 視覺憲法:
* - 純白極簡 (bg-white/90)
@@ -14,16 +14,16 @@
* - normal: 淺灰邊框,靜態
* - alert: 紅色邊框,脈衝雷達動畫
*
* Phase 6.5c: [Y/n] 執行神經實裝
* - Y: 呼叫 signApproval API
* - n: 呼叫 rejectApproval API
* - Loading state + Success/Error feedback
* Phase 6.5: 決策令牌驅動
* - 使用 decision.proposal_data 顯示行動方案
* - decision.state === 'ready' 時解鎖按鈕
* - 點擊 Y/n 時先建立 Approval 再簽核
*
* 統帥鐵律: 禁止假數據!
* 統帥鐵律: 禁止假數據!UI 永不鎖死!
*/
import React, { useState, useCallback } from 'react'
import { apiClient } from '@/lib/api-client'
import { apiClient, DecisionInfo } from '@/lib/api-client'
type ButtonState = 'idle' | 'loading' | 'approved' | 'rejected' | 'error'
@@ -34,8 +34,10 @@ export interface DualStateIncidentCardProps {
tier?: 1 | 2 | 3
message: string
timestamp: string
/** Proposal ID for approval actions (required for tier > 1) */
/** @deprecated 使用 decision 取代 */
proposalId?: string
/** Phase 6.5: 決策令牌 (取代 proposalId) */
decision?: DecisionInfo | null
/** Callback when approval status changes */
onApprovalChange?: (proposalId: string, newStatus: 'approved' | 'rejected') => void
}
@@ -48,23 +50,33 @@ export const DualStateIncidentCard: React.FC<DualStateIncidentCardProps> = ({
message,
timestamp,
proposalId,
decision,
onApprovalChange,
}) => {
const isAlert = status === 'alert'
const [buttonState, setButtonState] = useState<ButtonState>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [currentProposalId, setCurrentProposalId] = useState<string | null>(proposalId || null)
// Phase 6.5: 決策令牌驅動
// 按鈕可用條件: decision.state === 'ready' 或有 proposalId
const isDecisionReady = decision?.state === 'ready' || !!currentProposalId
const isAnalyzing = decision?.state === 'analyzing'
const decisionAction = decision?.proposal_data?.action || ''
const decisionReasoning = decision?.proposal_data?.reasoning || ''
/**
* 處理簽核 (Y 按鈕)
* 呼叫 POST /api/v1/approvals/{id}/sign
* Phase 6.5c+: 樂觀更新 + 物理回饋強化
* Phase 6.5: 決策令牌驅動
* 1. 如果沒有 proposalId先從 incident 生成 proposal
* 2. 然後簽核該 proposal
*/
const handleApprove = useCallback(async () => {
// 樂觀更新: 立刻切換 loading不等待 fetch 啟動
console.log('🚀 指令授權中:', proposalId)
console.log('🚀 指令授權中:', { proposalId: currentProposalId, decision: decision?.token })
if (!proposalId || buttonState === 'loading') {
console.warn('⚠️ 授權阻斷: proposalId=', proposalId, 'buttonState=', buttonState)
// Phase 6.5: 決策必須就緒
if (!isDecisionReady || buttonState === 'loading') {
console.warn('⚠️ 授權阻斷: isDecisionReady=', isDecisionReady, 'buttonState=', buttonState)
return
}
@@ -73,14 +85,34 @@ export const DualStateIncidentCard: React.FC<DualStateIncidentCardProps> = ({
setErrorMessage(null)
try {
const result = await apiClient.signApproval(proposalId, 'commander', 'Authorized via WarRoom')
let approvalId = currentProposalId
// Step 1: 如果沒有 proposalId先從 incident 生成
if (!approvalId && decision?.token) {
console.log('📝 生成 Proposal from incident:', id)
const proposalResult = await apiClient.generateProposal(id)
if (!proposalResult.success || !proposalResult.proposal) {
throw new Error(proposalResult.message || 'Failed to generate proposal')
}
approvalId = proposalResult.proposal.id
setCurrentProposalId(approvalId)
console.log('✅ Proposal 生成成功:', approvalId)
}
if (!approvalId) {
throw new Error('No approval ID available')
}
// Step 2: 簽核
const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via WarRoom')
console.log('✅ 簽核回應:', result)
if (result.status === 'approved' || result.status === 'APPROVED') {
setButtonState('approved')
console.log('🎯 授權成功,觸發 onApprovalChange')
onApprovalChange?.(proposalId, 'approved')
onApprovalChange?.(approvalId, 'approved')
} else {
// Multi-sig: 還需要更多簽核
console.log('🔐 Multi-sig 等待中,目前簽核:', result.current_signatures, '/', result.required_signatures)
@@ -88,44 +120,55 @@ export const DualStateIncidentCard: React.FC<DualStateIncidentCardProps> = ({
}
} catch (error) {
console.error('❌ 授權失敗:', error)
console.error(' proposalId:', proposalId)
console.error(' API URL:', `${process.env.NEXT_PUBLIC_API_URL}/approvals/${proposalId}/sign`)
setButtonState('error')
setErrorMessage(error instanceof Error ? error.message : 'Unknown error')
// 3 秒後恢復
setTimeout(() => setButtonState('idle'), 3000)
}
}, [proposalId, buttonState, onApprovalChange])
}, [currentProposalId, decision, id, isDecisionReady, buttonState, onApprovalChange])
/**
* 處理拒絕 (n 按鈕)
* 呼叫 POST /api/v1/approvals/{id}/reject
* Phase 6.5c+: 樂觀更新 + 物理回饋強化
* Phase 6.5: 決策令牌驅動
*/
const handleReject = useCallback(async () => {
console.log('🛑 指令拒絕中:', proposalId)
console.log('🛑 指令拒絕中:', { proposalId: currentProposalId, decision: decision?.token })
if (!proposalId || buttonState === 'loading') {
console.warn('⚠️ 拒絕阻斷: proposalId=', proposalId, 'buttonState=', buttonState)
if (!isDecisionReady || buttonState === 'loading') {
console.warn('⚠️ 拒絕阻斷: isDecisionReady=', isDecisionReady, 'buttonState=', buttonState)
return
}
// 關鍵: 立即進入 loading消除 300ms 感知延遲
setButtonState('loading')
setErrorMessage(null)
try {
await apiClient.rejectApproval(proposalId, 'Rejected via WarRoom')
let approvalId = currentProposalId
// 如果沒有 proposalId先生成再拒絕
if (!approvalId && decision?.token) {
const proposalResult = await apiClient.generateProposal(id)
if (proposalResult.success && proposalResult.proposal) {
approvalId = proposalResult.proposal.id
setCurrentProposalId(approvalId)
}
}
if (!approvalId) {
throw new Error('No approval ID available')
}
await apiClient.rejectApproval(approvalId, 'Rejected via WarRoom')
console.log('✅ 拒絕成功')
setButtonState('rejected')
onApprovalChange?.(proposalId, 'rejected')
onApprovalChange?.(approvalId, 'rejected')
} catch (error) {
console.error('❌ 拒絕失敗:', error)
setButtonState('error')
setErrorMessage(error instanceof Error ? error.message : 'Unknown error')
setTimeout(() => setButtonState('idle'), 3000)
}
}, [proposalId, buttonState, onApprovalChange])
}, [currentProposalId, decision, id, isDecisionReady, buttonState, onApprovalChange])
/**
* 渲染決策按鈕區塊
@@ -162,24 +205,29 @@ export const DualStateIncidentCard: React.FC<DualStateIncidentCardProps> = ({
<div className="flex gap-1 items-center">
<button
onClick={handleApprove}
disabled={!proposalId}
disabled={!isDecisionReady}
className="px-2 py-1 bg-neutral-900 text-white text-xs hover:bg-green-700 active:scale-95 active:bg-neutral-800 transition-all duration-100 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed disabled:active:scale-100"
title={!proposalId ? '大腦分析中...' : '授權執行'}
title={!isDecisionReady ? (isAnalyzing ? '大腦分析中...' : '等待決策') : (decisionAction || '授權執行')}
>
Y
</button>
<span className="px-1 py-1 text-neutral-400 text-xs">/</span>
<button
onClick={handleReject}
disabled={!proposalId}
disabled={!isDecisionReady}
className="px-2 py-1 bg-neutral-900 text-white text-xs hover:bg-red-700 active:scale-95 active:bg-neutral-800 transition-all duration-100 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed disabled:active:scale-100"
title={!proposalId ? '大腦分析中...' : '拒絕提案'}
title={!isDecisionReady ? (isAnalyzing ? '大腦分析中...' : '等待決策') : '拒絕提案'}
>
n
</button>
{!proposalId && (
{isAnalyzing && (
<span className="text-[10px] text-amber-500 ml-1 animate-pulse"></span>
)}
{decision?.proposal_data?.source && (
<span className="text-[9px] text-neutral-400 ml-1">
[{decision.proposal_data.source.includes('llm') ? 'AI' : 'EXP'}]
</span>
)}
</div>
)
}
@@ -224,13 +272,37 @@ export const DualStateIncidentCard: React.FC<DualStateIncidentCardProps> = ({
</div>
<div className="mt-1 text-xs text-neutral-400">{timestamp}</div>
{/* 大腦決策層 (Proposal UI) - Phase 6.5c 執行神經實裝 */}
{/* 大腦決策層 - Phase 6.5 決策令牌驅動 */}
{isAlert && tier && (
<div className="mt-4 pt-3 border-t-[0.5px] border-red-200 flex justify-between items-center">
<span className="text-xs text-neutral-500">
{tier === 1 ? '>_ AI 執行中 (Tier 1)' : `>_ 等待統帥親核 (Tier ${tier})`}
</span>
{tier > 1 && renderActionButton()}
<div className="mt-4 pt-3 border-t-[0.5px] border-red-200">
{/* 決策狀態 */}
<div className="flex justify-between items-center">
<span className="text-xs text-neutral-500">
{tier === 1 ? (
'>_ AI 執行中 (Tier 1)'
) : isAnalyzing ? (
'>_ 大腦分析中...'
) : isDecisionReady ? (
`>_ 決策就緒 (Tier ${tier})`
) : (
`>_ 等待統帥親核 (Tier ${tier})`
)}
</span>
{tier > 1 && renderActionButton()}
</div>
{/* Phase 6.5: 顯示 AI 建議行動 */}
{decisionAction && tier > 1 && (
<div className="mt-2 p-2 bg-neutral-50 border-[0.5px] border-neutral-200 text-[10px] font-mono text-neutral-600">
<div className="text-neutral-400 mb-1">&gt; :</div>
<div className="text-neutral-800">{decisionAction}</div>
{decisionReasoning && (
<div className="mt-1 text-neutral-500 italic">
💡 {decisionReasoning.slice(0, 100)}{decisionReasoning.length > 100 ? '...' : ''}
</div>
)}
</div>
)}
</div>
)}
</div>

View File

@@ -173,6 +173,25 @@ export const apiClient = {
// Type Definitions (Phase 7)
// =========================================================================
/**
* Phase 6.5: 決策令牌資訊
* 確保 UI 永遠有決策可操作
*/
export interface DecisionInfo {
token: string
state: 'init' | 'analyzing' | 'ready' | 'executing' | 'completed' | 'error'
proposal_data: {
action: string
description: string
reasoning: string
risk_level: 'low' | 'medium' | 'critical'
kubectl_command: string
source: string
confidence: number
} | null
proposal_id: string | null
}
export interface IncidentResponse {
incident_id: string
status: 'investigating' | 'mitigating' | 'resolved' | 'closed'
@@ -182,6 +201,8 @@ export interface IncidentResponse {
proposal_count: number
created_at: string
updated_at: string
/** Phase 6.5: 決策令牌 (確保 UI 永不鎖死) */
decision: DecisionInfo | null
}
export interface IncidentListResponse {