Day 25 把測試事件增加到 30 筆之後,我開始一筆一筆按 Analyze with AI 來看不同攻擊類型的分析結果。按了幾筆之後就發現一個問題:只要重新整理頁面,或是改按另一筆事件,前一筆的分析結果就不見了。想再看一次,只能重新呼叫 Gemini。
這會造成三個問題:
修改 main.py,新增 import
from datetime import datetime
在 init_database() 建立 events 資料表之後加入
# 保存 AI 分析結果,一筆事件對應一筆分析
conn.execute("""
CREATE TABLE IF NOT EXISTS ai_analyses (
event_id INTEGER PRIMARY KEY,
summary TEXT NOT NULL,
attack_type TEXT NOT NULL,
risk TEXT NOT NULL,
recommendation TEXT NOT NULL,
mitre_technique_id TEXT NOT NULL,
mitre_technique_name TEXT NOT NULL,
analyzed_at TEXT NOT NULL
)
""")
欄位直接對應 Day 18 定義的 Structured Output,再加上 analyzed_at 記錄分析時間。event_id 設為 PRIMARY KEY,代表一筆事件只保留一筆最新的分析結果。
這裡沒有把分析結果直接加在 events 資料表裡,是因為事件本身是 Suricata 或使用者提供的「事實」,而 AI 分析是「推論」。把兩者分開存放,之後要重新分析或刪除分析結果時,都不會動到原始事件。
前端需要知道每一筆事件是否已經有分析結果,所以查詢事件時用 LEFT JOIN 多帶出一個 analyzed 欄位。
在 # API 上方加入
# 查詢事件時一併帶出是否已有 AI 分析
EVENT_SELECT = """
SELECT events.*,
ai_analyses.event_id IS NOT NULL AS analyzed
FROM events
LEFT JOIN ai_analyses
ON ai_analyses.event_id = events.id
"""
修改 GET /events
rows = conn.execute(
EVENT_SELECT + "ORDER BY events.id"
).fetchall()
修改 GET /events/search
rows = conn.execute(EVENT_SELECT + """
WHERE event_type LIKE ?
OR src_ip LIKE ?
OR dest_ip LIKE ?
OR protocol LIKE ?
OR signature LIKE ?
ORDER BY events.id
""", (
因為現在有「從資料庫讀取」與「剛分析完成」兩種情況都要回傳相同格式,先把組合回傳內容的部分獨立成一個函式
def build_analysis_response(event, severity_level, analysis, cached):
return {
"event": {
"id": event["id"],
"event_type": event["event_type"],
"src_ip": event["src_ip"],
"dest_ip": event["dest_ip"],
"dest_port": event["dest_port"],
"protocol": event["protocol"],
"signature": event["signature"],
"severity": severity_level.value
},
"ai_analysis": {
"summary": analysis["summary"],
"attack_type": analysis["attack_type"],
"risk": analysis["risk"],
"recommendation": analysis["recommendation"],
"mitre_technique_id": analysis["mitre_technique_id"],
"mitre_technique_name": analysis["mitre_technique_name"]
},
"analyzed_at": analysis["analyzed_at"],
"cached": cached
}
Endpoint 增加 force 參數,並在查詢事件時一起查詢已保存的分析結果
@app.post("/events/{event_id}/analyze")
def analyze_event(event_id: int, force: bool = False):
# 1. 從 SQLite 找指定事件與已保存的分析結果
conn = get_db_connection()
event = conn.execute(
"SELECT * FROM events WHERE id = ?",
(event_id,)
).fetchone()
saved = conn.execute(
"SELECT * FROM ai_analyses WHERE event_id = ?",
(event_id,)
).fetchone()
conn.close()
在 Severity Mapping 之後、準備 Prompt 之前加入
# 已經分析過就直接回傳,除非指定 force 重新分析
if saved is not None and not force:
return build_analysis_response(
event, severity_level, saved, cached=True
)
最後把原本的「6. FastAPI 回傳 JSON」改成先保存再回傳
# 6. 保存分析結果
analysis = {
"summary": result.summary,
"attack_type": result.attack_type,
"risk": result.risk,
"recommendation": result.recommendation,
"mitre_technique_id": result.mitre_technique_id,
"mitre_technique_name": result.mitre_technique_name,
"analyzed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
conn = get_db_connection()
conn.execute("""
INSERT OR REPLACE INTO ai_analyses
(event_id, summary, attack_type, risk, recommendation,
mitre_technique_id, mitre_technique_name, analyzed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
event_id,
analysis["summary"],
analysis["attack_type"],
analysis["risk"],
analysis["recommendation"],
analysis["mitre_technique_id"],
analysis["mitre_technique_name"],
analysis["analyzed_at"]
))
conn.commit()
conn.close()
# 7. FastAPI 回傳 JSON
return build_analysis_response(
event, severity_level, analysis, cached=False
)
INSERT OR REPLACE 會在同一個 event_id 已經存在時直接覆蓋,所以重新分析之後保留的是最新的結果。
現在這個 Endpoint 的流程變成:
按下 Analyze with AI
↓
資料庫有分析結果? ── 有 ──→ 直接回傳(cached: true)
↓ 沒有,或指定 force
呼叫 Gemini
↓
存進 ai_analyses
↓
回傳(cached: false)
analyzeEvent() 增加 force 參數
const analyzeEvent = async (eventId, force = false) => {
try {
setAnalyzingId(eventId);
setAiAnalysis(null);
const response = await fetch(
`http://127.0.0.1:8000/events/${eventId}/analyze${
force ? "?force=true" : ""
}`,
{
method: "POST",
}
);
const data = await response.json();
setAiAnalysis(data);
// 更新事件列表中的分析狀態
searchEvents();
} catch (error) {
console.error("AI analysis failed:", error);
} finally {
setAnalyzingId(null);
}
};
分析完成後呼叫 searchEvents(),是為了讓事件列表重新取得 analyzed 欄位,同時保留目前的搜尋條件。
已經分析過的事件,按鈕文字改成 View Analysis
{analyzingId === event.id
? "Analyzing..."
: event.analyzed
? "View Analysis"
: "Analyze with AI"}
AI Analysis Panel 的狀態標籤依照 cached 顯示不同文字,並在標題下方加入分析時間與 Re-analyze 按鈕
<span className="analysis-status">
{aiAnalysis.cached ? "Saved Analysis" : "Analysis Complete"}
</span>
</div>
<div className="analysis-meta">
<span>分析時間:{aiAnalysis.analyzed_at}</span>
<button
onClick={() => analyzeEvent(aiAnalysis.event.id, true)}
disabled={analyzingId !== null}
>
Re-analyze
</button>
</div>
nano src/App.css 在最下面加入
.analysis-meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
color: #6b7280;
font-size: 14px;
}
重新啟動 FastAPI,選擇 SQL Injection 的事件按下 Analyze with AI。第一次會呼叫 Gemini,Panel 右上角顯示 Analysis Complete,下方出現分析時間。
回到事件列表,這筆事件的按鈕已經變成 View Analysis。
重新整理頁面後再按一次 View Analysis,結果立刻出現,狀態顯示 Saved Analysis,分析時間仍然是第一次分析的時間。
按下 Re-analyze 會重新呼叫 Gemini,分析時間更新成現在的時間。
比較前後兩次的結果可以發現,同一筆 SQL Injection 事件,兩次的事件摘要寫法並不相同。這也是前面提到需要把分析結果保存下來的原因。
小結
今天把 AI 分析結果存進 SQLite,分析過的事件不需要再重新呼叫 Gemini。
一開始我只是覺得每次重新等待很浪費時間,但實作之後發現,保存分析結果真正的意義是讓分析過程可以被追溯。LLM 的回答不是固定的,如果沒有保存下來,之後就沒有辦法確認當時是根據什麼內容做出判斷。
另外,把「原始事件」與「AI 分析」放在不同的資料表,也延續了 Day 19 的想法:確定的事實由 Backend 負責,AI 產生的內容則是另外保存、隨時可以重新產生的參考資訊。