Zhen Ye commited on
Commit
0effcd5
·
1 Parent(s): 163ff2b

feat: Add threat chat feature for Q&A about detected threats

Browse files

- Add utils/threat_chat.py with chat_about_threats() function
- Add POST /chat/threat endpoint to app.py
- Add frontend chat panel to Tab 1 (index.html)
- Add js/ui/chat.js UI module
- Add chatAboutThreats() API function to client.js
- Add chat panel CSS styles to style.css

app.py CHANGED
@@ -56,6 +56,7 @@ from jobs.storage import (
56
  get_output_video_path,
57
  )
58
  from utils.gpt_reasoning import estimate_threat_gpt
 
59
 
60
  logging.basicConfig(level=logging.INFO)
61
 
@@ -688,6 +689,42 @@ async def reason_track(
688
  return results
689
 
690
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691
 
692
  if __name__ == "__main__":
693
  uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False)
 
56
  get_output_video_path,
57
  )
58
  from utils.gpt_reasoning import estimate_threat_gpt
59
+ from utils.threat_chat import chat_about_threats
60
 
61
  logging.basicConfig(level=logging.INFO)
62
 
 
689
  return results
690
 
691
 
692
+ @app.post("/chat/threat")
693
+ async def chat_threat_endpoint(
694
+ question: str = Form(...),
695
+ detections: str = Form(...) # JSON string of current detections
696
+ ):
697
+ """
698
+ Chat about detected threats using GPT.
699
+
700
+ Args:
701
+ question: User's question about the current threat situation.
702
+ detections: JSON string of detection list with threat analysis data.
703
+
704
+ Returns:
705
+ GPT response about the threats.
706
+ """
707
+ import json as json_module
708
+
709
+ if not question.strip():
710
+ raise HTTPException(status_code=400, detail="Question cannot be empty.")
711
+
712
+ try:
713
+ detection_list = json_module.loads(detections)
714
+ except json_module.JSONDecodeError:
715
+ raise HTTPException(status_code=400, detail="Invalid detections JSON.")
716
+
717
+ if not isinstance(detection_list, list):
718
+ raise HTTPException(status_code=400, detail="Detections must be a list.")
719
+
720
+ # Run chat in thread to avoid blocking
721
+ try:
722
+ response = await asyncio.to_thread(chat_about_threats, question, detection_list)
723
+ return {"response": response}
724
+ except Exception as e:
725
+ logging.exception("Threat chat failed")
726
+ raise HTTPException(status_code=500, detail=str(e))
727
+
728
 
729
  if __name__ == "__main__":
730
  uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False)
frontend/index.html CHANGED
@@ -195,6 +195,26 @@
195
  </div>
196
  </div>
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  </div>
199
 
200
  </section>
@@ -292,6 +312,7 @@
292
  <script src="./js/ui/cards.js"></script>
293
  <script src="./js/ui/features.js"></script>
294
  <script src="./js/ui/cursor.js"></script>
 
295
  <script src="./data/helicopter_demo_data.js"></script>
296
  <script src="./js/core/demo.js"></script>
297
  <script src="./js/main.js"></script>
 
195
  </div>
196
  </div>
197
 
198
+ <!-- Threat Chat Panel -->
199
+ <div class="panel panel-chat" id="chatPanel">
200
+ <h3>
201
+ <span>🎖️ Threat Analyst Chat</span>
202
+ <button class="collapse-btn" id="chatToggle" style="font-size: 0.75rem;">▲ Close Chat</button>
203
+ </h3>
204
+ <div class="chat-container">
205
+ <div class="chat-messages" id="chatMessages">
206
+ <div class="chat-message chat-system">
207
+ <span class="chat-icon">ℹ️</span>
208
+ <span class="chat-content">Run detection first, then ask questions about detected threats.</span>
209
+ </div>
210
+ </div>
211
+ <div class="chat-input-row">
212
+ <input type="text" id="chatInput" placeholder="Ask about threats..." autocomplete="off">
213
+ <button id="chatSend" class="btn">Send</button>
214
+ </div>
215
+ </div>
216
+ </div>
217
+
218
  </div>
219
 
220
  </section>
 
312
  <script src="./js/ui/cards.js"></script>
313
  <script src="./js/ui/features.js"></script>
314
  <script src="./js/ui/cursor.js"></script>
315
+ <script src="./js/ui/chat.js"></script>
316
  <script src="./data/helicopter_demo_data.js"></script>
317
  <script src="./js/core/demo.js"></script>
318
  <script src="./js/main.js"></script>
frontend/js/api/client.js CHANGED
@@ -236,3 +236,24 @@ APP.api.client.callHfObjectDetection = async function (canvas) {
236
 
237
  return await resp.json();
238
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
  return await resp.json();
238
  };
239
+
240
+ // Chat about threats using GPT
241
+ APP.api.client.chatAboutThreats = async function (question, detections) {
242
+ const { state } = APP.core;
243
+
244
+ const form = new FormData();
245
+ form.append("question", question);
246
+ form.append("detections", JSON.stringify(detections));
247
+
248
+ const resp = await fetch(`${state.hf.baseUrl}/chat/threat`, {
249
+ method: "POST",
250
+ body: form
251
+ });
252
+
253
+ if (!resp.ok) {
254
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
255
+ throw new Error(err.detail || "Chat request failed");
256
+ }
257
+
258
+ return await resp.json();
259
+ };
frontend/js/ui/chat.js ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Chat UI Module - Threat-aware chat with GPT
2
+ (function () {
3
+ const { state } = APP.core;
4
+ const { $ } = APP.core.utils;
5
+ const { log } = APP.ui.logging;
6
+
7
+ let chatHistory = [];
8
+
9
+ /**
10
+ * Initialize the chat module.
11
+ */
12
+ function init() {
13
+ const chatInput = $("#chatInput");
14
+ const chatSend = $("#chatSend");
15
+
16
+ if (chatSend) {
17
+ chatSend.addEventListener("click", sendMessage);
18
+ }
19
+
20
+ if (chatInput) {
21
+ chatInput.addEventListener("keydown", (e) => {
22
+ if (e.key === "Enter" && !e.shiftKey) {
23
+ e.preventDefault();
24
+ sendMessage();
25
+ }
26
+ });
27
+ }
28
+
29
+ // Toggle chat panel
30
+ const chatToggle = $("#chatToggle");
31
+ const chatPanel = $("#chatPanel");
32
+ if (chatToggle && chatPanel) {
33
+ chatToggle.addEventListener("click", () => {
34
+ chatPanel.classList.toggle("collapsed");
35
+ chatToggle.textContent = chatPanel.classList.contains("collapsed")
36
+ ? "▼ Chat"
37
+ : "▲ Close Chat";
38
+ });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Send a chat message about current threats.
44
+ */
45
+ async function sendMessage() {
46
+ const chatInput = $("#chatInput");
47
+ const chatMessages = $("#chatMessages");
48
+
49
+ if (!chatInput || !chatMessages) return;
50
+
51
+ const question = chatInput.value.trim();
52
+ if (!question) {
53
+ log("Please enter a question.", "w");
54
+ return;
55
+ }
56
+
57
+ // Check if we have detections
58
+ if (!state.detections || state.detections.length === 0) {
59
+ appendMessage("system", "No threats detected yet. Run Reason first to analyze the scene.");
60
+ return;
61
+ }
62
+
63
+ // Add user message to UI
64
+ appendMessage("user", question);
65
+ chatInput.value = "";
66
+ chatInput.disabled = true;
67
+
68
+ // Show loading indicator
69
+ const loadingId = appendMessage("assistant", "Analyzing threats...", true);
70
+
71
+ try {
72
+ const response = await APP.api.client.chatAboutThreats(question, state.detections);
73
+
74
+ // Remove loading message
75
+ removeMessage(loadingId);
76
+
77
+ if (response.response) {
78
+ appendMessage("assistant", response.response);
79
+ chatHistory.push({ role: "user", content: question });
80
+ chatHistory.push({ role: "assistant", content: response.response });
81
+ } else if (response.error || response.detail) {
82
+ appendMessage("system", `Error: ${response.error || response.detail}`);
83
+ }
84
+ } catch (err) {
85
+ removeMessage(loadingId);
86
+ appendMessage("system", `Failed to get response: ${err.message}`);
87
+ log(`Chat error: ${err.message}`, "e");
88
+ } finally {
89
+ chatInput.disabled = false;
90
+ chatInput.focus();
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Append a message to the chat display.
96
+ */
97
+ function appendMessage(role, content, isLoading = false) {
98
+ const chatMessages = $("#chatMessages");
99
+ if (!chatMessages) return null;
100
+
101
+ const msgId = `msg-${Date.now()}`;
102
+ const msgDiv = document.createElement("div");
103
+ msgDiv.className = `chat-message chat-${role}`;
104
+ msgDiv.id = msgId;
105
+
106
+ if (isLoading) {
107
+ msgDiv.classList.add("loading");
108
+ }
109
+
110
+ // Format content with line breaks
111
+ const formatted = content.replace(/\n/g, "<br>");
112
+
113
+ const icon = role === "user" ? "👤" : role === "assistant" ? "🎖️" : "⚠️";
114
+ msgDiv.innerHTML = `<span class="chat-icon">${icon}</span><span class="chat-content">${formatted}</span>`;
115
+
116
+ chatMessages.appendChild(msgDiv);
117
+ chatMessages.scrollTop = chatMessages.scrollHeight;
118
+
119
+ return msgId;
120
+ }
121
+
122
+ /**
123
+ * Remove a message by ID.
124
+ */
125
+ function removeMessage(msgId) {
126
+ const msg = document.getElementById(msgId);
127
+ if (msg) msg.remove();
128
+ }
129
+
130
+ /**
131
+ * Clear chat history.
132
+ */
133
+ function clearChat() {
134
+ const chatMessages = $("#chatMessages");
135
+ if (chatMessages) {
136
+ chatMessages.innerHTML = "";
137
+ }
138
+ chatHistory = [];
139
+ }
140
+
141
+ // Export
142
+ APP.ui = APP.ui || {};
143
+ APP.ui.chat = {
144
+ init,
145
+ sendMessage,
146
+ clearChat
147
+ };
148
+
149
+ // Auto-init on DOMContentLoaded
150
+ if (document.readyState === "loading") {
151
+ document.addEventListener("DOMContentLoaded", init);
152
+ } else {
153
+ init();
154
+ }
155
+ })();
frontend/style.css CHANGED
@@ -932,4 +932,141 @@ input[type="number"]:focus {
932
 
933
  .gpt-text {
934
  color: #e0e0e0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
935
  }
 
932
 
933
  .gpt-text {
934
  color: #e0e0e0;
935
+ }
936
+
937
+ /* =========================================
938
+ Threat Chat Panel
939
+ ========================================= */
940
+
941
+ .panel-chat {
942
+ grid-column: 1 / -1;
943
+ max-height: 280px;
944
+ display: flex;
945
+ flex-direction: column;
946
+ }
947
+
948
+ .panel-chat.collapsed .chat-container {
949
+ display: none;
950
+ }
951
+
952
+ .panel-chat.collapsed {
953
+ max-height: 44px;
954
+ }
955
+
956
+ .chat-container {
957
+ display: flex;
958
+ flex-direction: column;
959
+ flex: 1;
960
+ min-height: 0;
961
+ gap: 8px;
962
+ }
963
+
964
+ .chat-messages {
965
+ flex: 1;
966
+ overflow-y: auto;
967
+ background: rgba(0, 0, 0, .35);
968
+ border: 1px solid rgba(255, 255, 255, .10);
969
+ border-radius: 12px;
970
+ padding: 10px;
971
+ display: flex;
972
+ flex-direction: column;
973
+ gap: 8px;
974
+ min-height: 100px;
975
+ max-height: 160px;
976
+ }
977
+
978
+ .chat-message {
979
+ display: flex;
980
+ gap: 8px;
981
+ padding: 8px 10px;
982
+ border-radius: 10px;
983
+ font-size: 12px;
984
+ line-height: 1.4;
985
+ animation: fadeIn 0.2s ease;
986
+ }
987
+
988
+ @keyframes fadeIn {
989
+ from {
990
+ opacity: 0;
991
+ transform: translateY(4px);
992
+ }
993
+
994
+ to {
995
+ opacity: 1;
996
+ transform: translateY(0);
997
+ }
998
+ }
999
+
1000
+ .chat-message.chat-user {
1001
+ background: linear-gradient(135deg, rgba(124, 58, 237, .25), rgba(34, 211, 238, .10));
1002
+ border: 1px solid rgba(124, 58, 237, .35);
1003
+ margin-left: 20px;
1004
+ }
1005
+
1006
+ .chat-message.chat-assistant {
1007
+ background: rgba(255, 255, 255, .04);
1008
+ border: 1px solid rgba(255, 255, 255, .10);
1009
+ margin-right: 20px;
1010
+ }
1011
+
1012
+ .chat-message.chat-system {
1013
+ background: rgba(245, 158, 11, .10);
1014
+ border: 1px solid rgba(245, 158, 11, .25);
1015
+ color: rgba(245, 158, 11, .95);
1016
+ font-size: 11px;
1017
+ }
1018
+
1019
+ .chat-message.loading .chat-content::after {
1020
+ content: "";
1021
+ display: inline-block;
1022
+ width: 12px;
1023
+ height: 12px;
1024
+ border: 2px solid rgba(255, 255, 255, .3);
1025
+ border-top-color: var(--cyan);
1026
+ border-radius: 50%;
1027
+ animation: spin 0.8s linear infinite;
1028
+ margin-left: 8px;
1029
+ vertical-align: middle;
1030
+ }
1031
+
1032
+ .chat-icon {
1033
+ flex-shrink: 0;
1034
+ font-size: 14px;
1035
+ }
1036
+
1037
+ .chat-content {
1038
+ flex: 1;
1039
+ color: rgba(255, 255, 255, .88);
1040
+ word-break: break-word;
1041
+ }
1042
+
1043
+ .chat-input-row {
1044
+ display: flex;
1045
+ gap: 8px;
1046
+ }
1047
+
1048
+ .chat-input-row input {
1049
+ flex: 1;
1050
+ background: rgba(255, 255, 255, .04);
1051
+ border: 1px solid rgba(255, 255, 255, .12);
1052
+ border-radius: 10px;
1053
+ padding: 10px 12px;
1054
+ color: var(--text);
1055
+ font-size: 12px;
1056
+ outline: none;
1057
+ }
1058
+
1059
+ .chat-input-row input:focus {
1060
+ border-color: rgba(124, 58, 237, .55);
1061
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, .16);
1062
+ }
1063
+
1064
+ .chat-input-row input:disabled {
1065
+ opacity: 0.5;
1066
+ cursor: not-allowed;
1067
+ }
1068
+
1069
+ .chat-input-row .btn {
1070
+ padding: 10px 16px;
1071
+ min-width: 70px;
1072
  }
utils/threat_chat.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Threat Chat Module - GPT-powered Q&A about detected threats.
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import logging
8
+ from typing import List, Dict, Any
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def chat_about_threats(question: str, detections: List[Dict[str, Any]]) -> str:
14
+ """
15
+ Answer user questions about detected threats using GPT.
16
+
17
+ Args:
18
+ question: User's question about the current threat situation.
19
+ detections: List of detection dicts with gpt_raw threat analysis.
20
+
21
+ Returns:
22
+ GPT's response as a string.
23
+ """
24
+ import urllib.request
25
+ import urllib.error
26
+
27
+ api_key = os.environ.get("OPENAI_API_KEY")
28
+ if not api_key:
29
+ logger.warning("OPENAI_API_KEY not set. Cannot process threat chat.")
30
+ return "Error: OpenAI API key not configured."
31
+
32
+ if not detections:
33
+ return "No threats detected yet. Run detection first to analyze the scene."
34
+
35
+ # Build threat context from detections
36
+ threat_context = _build_threat_context(detections)
37
+
38
+ system_prompt = (
39
+ "You are a Naval Tactical Intelligence Officer providing real-time threat analysis support. "
40
+ "You have access to the current threat assessment data from optical surveillance. "
41
+ "Answer questions concisely and tactically. Use military terminology where appropriate. "
42
+ "If asked about engagement recommendations, always note that final decisions rest with the commanding officer.\n\n"
43
+ "CURRENT THREAT PICTURE:\n"
44
+ f"{threat_context}\n\n"
45
+ "Respond to the operator's question based on this threat data."
46
+ )
47
+
48
+ payload = {
49
+ "model": "gpt-4o",
50
+ "messages": [
51
+ {"role": "system", "content": system_prompt},
52
+ {"role": "user", "content": question}
53
+ ],
54
+ "max_tokens": 500,
55
+ "temperature": 0.3,
56
+ }
57
+
58
+ headers = {
59
+ "Content-Type": "application/json",
60
+ "Authorization": f"Bearer {api_key}"
61
+ }
62
+
63
+ try:
64
+ req = urllib.request.Request(
65
+ "https://api.openai.com/v1/chat/completions",
66
+ data=json.dumps(payload).encode('utf-8'),
67
+ headers=headers,
68
+ method="POST"
69
+ )
70
+ with urllib.request.urlopen(req, timeout=30) as response:
71
+ resp_data = json.loads(response.read().decode('utf-8'))
72
+
73
+ content = resp_data['choices'][0]['message'].get('content', '')
74
+ return content.strip() if content else "No response generated."
75
+
76
+ except urllib.error.HTTPError as e:
77
+ logger.error(f"OpenAI API HTTP error: {e.code} - {e.reason}")
78
+ return f"API Error: {e.reason}"
79
+ except Exception as e:
80
+ logger.error(f"Threat chat failed: {e}")
81
+ return f"Error processing question: {str(e)}"
82
+
83
+
84
+ def _build_threat_context(detections: List[Dict[str, Any]]) -> str:
85
+ """Build a text summary of all detected threats for GPT context."""
86
+ lines = []
87
+
88
+ for det in detections:
89
+ obj_id = det.get("id", "Unknown")
90
+ label = det.get("label", "object")
91
+
92
+ # Extract GPT raw data if available
93
+ gpt_raw = det.get("gpt_raw") or det.get("features") or {}
94
+
95
+ # Basic info
96
+ threat_score = det.get("threat_level_score") or gpt_raw.get("threat_level_score", "?")
97
+ threat_class = det.get("threat_classification") or gpt_raw.get("threat_classification", "Unknown")
98
+
99
+ # Detailed fields
100
+ vessel_cat = gpt_raw.get("vessel_category", label)
101
+ specific_class = gpt_raw.get("specific_class", "")
102
+ weapons = gpt_raw.get("visible_weapons", [])
103
+ weapon_ready = gpt_raw.get("weapon_readiness") or det.get("weapon_readiness", "Unknown")
104
+ motion = gpt_raw.get("motion_status", "Unknown")
105
+ range_nm = gpt_raw.get("range_estimation_nm")
106
+ bearing = gpt_raw.get("bearing_clock") or det.get("gpt_direction", "")
107
+ flag = gpt_raw.get("flag_state", "Unknown")
108
+ sensors = gpt_raw.get("sensor_profile", [])
109
+ aspect = gpt_raw.get("aspect", "")
110
+ activity = gpt_raw.get("deck_activity", "")
111
+ intent = gpt_raw.get("tactical_intent", "")
112
+
113
+ # Build entry
114
+ entry = f"[{obj_id}] {vessel_cat}"
115
+ if specific_class:
116
+ entry += f" ({specific_class})"
117
+ entry += f"\n - Threat: {threat_class} (Score: {threat_score}/10)"
118
+
119
+ if range_nm:
120
+ entry += f"\n - Range: {range_nm} NM"
121
+ if bearing:
122
+ entry += f", Bearing: {bearing}"
123
+ if motion:
124
+ entry += f"\n - Motion: {motion}"
125
+ if aspect:
126
+ entry += f", Aspect: {aspect}"
127
+ if weapons:
128
+ entry += f"\n - Weapons: {', '.join(weapons) if isinstance(weapons, list) else weapons}"
129
+ if weapon_ready and weapon_ready != "Unknown":
130
+ entry += f" ({weapon_ready})"
131
+ if sensors:
132
+ entry += f"\n - Sensors: {', '.join(sensors) if isinstance(sensors, list) else sensors}"
133
+ if flag and flag != "Unknown":
134
+ entry += f"\n - Flag State: {flag}"
135
+ if activity:
136
+ entry += f"\n - Deck Activity: {activity}"
137
+ if intent:
138
+ entry += f"\n - Assessed Intent: {intent}"
139
+
140
+ lines.append(entry)
141
+
142
+ return "\n\n".join(lines) if lines else "No threat data available."