guardia-messenger/components/AutoRCA.tsx

126 lines
4.6 KiB
TypeScript

/**
* AutoRCA (#21) — 자동 근본원인분석(RCA) 표시
*
* GET /api/ai/rca/{incident_id} 우선 시도, 실패 시 Ollama로 직접 분석.
* 원인 / 영향 범위 / 재발 방지 3섹션을 접을 수 있는 아코디언으로 표시.
*/
import { useState, useEffect } from 'react'
import { View, Text, Pressable, StyleSheet, ActivityIndicator } from 'react-native'
import { COLORS, API_BASE } from '../constants/Config'
import { authFetch } from '../utils/auth'
import { generateJSON, DEFAULT_TEXT_MODEL } from '../lib/ollama'
interface RCA {
cause: string
impact: string
prevention: string
}
interface Props {
incidentId: number | string
summary?: string // Ollama 폴백용 인시던트 요약 (자격증명 미포함)
}
const EMPTY: RCA = { cause: '', impact: '', prevention: '' }
export function AutoRCA({ incidentId, summary }: Props) {
const [rca, setRca] = useState<RCA>(EMPTY)
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState<'cause' | 'impact' | 'prevention' | null>('cause')
const [src, setSrc] = useState<'api' | 'ai' | 'none'>('none')
useEffect(() => {
let alive = true
;(async () => {
setLoading(true)
// 1) ITSM API 시도
try {
const res = await authFetch(`${API_BASE}/api/ai/rca/${incidentId}`)
if (res.ok) {
const d = await res.json()
const parsed: RCA = {
cause: d.cause ?? d.root_cause ?? '',
impact: d.impact ?? d.impact_scope ?? '',
prevention: d.prevention ?? d.recurrence_prevention ?? '',
}
if (alive && (parsed.cause || parsed.impact || parsed.prevention)) {
setRca(parsed)
setSrc('api')
setLoading(false)
return
}
}
} catch {
/* API 실패 → Ollama 폴백 */
}
// 2) Ollama 폴백
if (summary?.trim()) {
const prompt =
`다음 IT 인시던트에 대해 근본원인분석(RCA)을 수행하세요: "${summary}". ` +
`JSON으로만 출력: {"cause":"근본원인","impact":"영향범위","prevention":"재발방지책"}`
const aiRca = await generateJSON<RCA>(DEFAULT_TEXT_MODEL, prompt, EMPTY)
if (alive) {
setRca(aiRca)
setSrc(aiRca.cause ? 'ai' : 'none')
}
}
if (alive) setLoading(false)
})()
return () => {
alive = false
}
}, [incidentId, summary])
const sections: { key: 'cause' | 'impact' | 'prevention'; label: string; icon: string }[] = [
{ key: 'cause', label: '근본 원인', icon: '🔍' },
{ key: 'impact', label: '영향 범위', icon: '🌐' },
{ key: 'prevention', label: '재발 방지', icon: '🛡️' },
]
if (loading) {
return (
<View style={S.wrap}>
<ActivityIndicator color={COLORS.accent} />
<Text style={S.loadingText}>RCA ...</Text>
</View>
)
}
return (
<View style={S.wrap}>
<View style={S.head}>
<Text style={S.title}>🧩 RCA</Text>
<Text style={S.badge}>{src === 'api' ? 'ITSM 분석' : src === 'ai' ? 'AI 분석' : '데이터 없음'}</Text>
</View>
{sections.map(sec => (
<View key={sec.key} style={S.accItem}>
<Pressable style={S.accHead} onPress={() => setOpen(open === sec.key ? null : sec.key)}>
<Text style={S.accLabel}>
{sec.icon} {sec.label}
</Text>
<Text style={S.accArrow}>{open === sec.key ? '▲' : '▼'}</Text>
</Pressable>
{open === sec.key ? (
<Text style={S.accBody}>{rca[sec.key] || '분석 결과가 없습니다.'}</Text>
) : null}
</View>
))}
</View>
)
}
export default AutoRCA
const S = StyleSheet.create({
wrap: { backgroundColor: COLORS.card, borderRadius: 14, padding: 14, borderWidth: 1, borderColor: COLORS.border },
head: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
title: { fontSize: 15, fontWeight: '700', color: COLORS.text },
badge: { fontSize: 10, color: COLORS.blue, backgroundColor: COLORS.light, paddingHorizontal: 8, paddingVertical: 3, borderRadius: 8, overflow: 'hidden' },
loadingText: { fontSize: 12, color: COLORS.muted, marginTop: 6 },
accItem: { borderTopWidth: 1, borderTopColor: COLORS.border },
accHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 11 },
accLabel: { fontSize: 13, fontWeight: '600', color: COLORS.text },
accArrow: { fontSize: 10, color: COLORS.muted },
accBody: { fontSize: 13, color: COLORS.text, lineHeight: 19, paddingBottom: 12, paddingLeft: 4 },
})