Zhen Ye commited on
Commit
d257dcc
·
1 Parent(s): bb6e650

feat(frontend): improve UI for detection and chat interaction

Browse files
app.py CHANGED
@@ -55,9 +55,11 @@ from jobs.storage import (
55
  get_job_storage,
56
  get_output_video_path,
57
  )
58
- from utils.gpt_reasoning import estimate_threat_gpt, encode_frame_to_b64
59
  from utils.threat_chat import chat_about_threats
60
- from utils.relevance import evaluate_relevance, evaluate_relevance_llm
 
 
61
  from utils.mission_parser import parse_mission_text, build_broad_queries, MissionParseError
62
 
63
  logging.basicConfig(level=logging.INFO)
@@ -87,78 +89,41 @@ async def _enrich_first_frame_gpt(
87
  if not enable_gpt or not detections:
88
  return
89
  try:
90
- # LLM relevance filter (if LLM_EXTRACTED mode)
91
- gpt_dets = detections
92
- if mission_spec and mission_spec.parse_mode == "LLM_EXTRACTED":
93
- unique_labels = list({
94
- d.get("label", "").lower()
95
- for d in detections if d.get("label")
96
- })
97
- relevant_labels = await asyncio.to_thread(
98
- evaluate_relevance_llm, unique_labels, mission_spec.operator_text
99
- )
100
- mission_spec.relevance_criteria.required_classes = list(relevant_labels)
101
- # Apply deterministic filter with refined classes
102
  for d in detections:
103
  decision = evaluate_relevance(d, mission_spec.relevance_criteria)
104
  d["mission_relevant"] = decision.relevant
105
  d["relevance_reason"] = decision.reason
106
- gpt_dets = [d for d in detections if d.get("mission_relevant", True)]
107
- elif mission_spec:
108
- for d in detections:
109
- decision = evaluate_relevance(d, mission_spec.relevance_criteria)
110
- d["mission_relevant"] = decision.relevant
111
- d["relevance_reason"] = decision.reason
112
- gpt_dets = [d for d in detections if d.get("mission_relevant", True)]
113
 
114
- if not gpt_dets:
115
- # All detections filtered as not relevant — mark ASSESSED and persist
116
- for det in detections:
117
- det["assessment_status"] = "ASSESSED"
118
- storage = get_job_storage()
119
- storage.update(
120
- job_id,
121
- first_frame_detections=detections,
122
- )
123
- logging.info("All detections non-relevant for job %s; marked ASSESSED", job_id)
124
- return
125
-
126
- # GPT threat assessment
127
- frame_b64 = encode_frame_to_b64(frame)
128
  gpt_results = await asyncio.to_thread(
129
- estimate_threat_gpt,
130
- detections=gpt_dets,
131
- mission_spec=mission_spec,
132
- image_b64=frame_b64,
133
  )
134
  logging.info("Background GPT enrichment complete for job %s", job_id)
135
 
136
- # Merge GPT results into detections using the same object IDs used in
137
- # the GPT request payload.
138
- gpt_obj_ids = []
139
- for i, det in enumerate(gpt_dets):
140
- obj_id = det.get("track_id") or det.get("id")
141
- if obj_id is None:
142
- obj_id = f"T{str(i + 1).zfill(2)}"
143
- gpt_obj_ids.append(str(obj_id))
144
-
145
- for det, obj_id in zip(gpt_dets, gpt_obj_ids):
146
- if obj_id in gpt_results:
147
- info = gpt_results[obj_id]
148
- det.update(info)
149
- det["gpt_raw"] = info
150
- det.setdefault("assessment_frame_index", 0)
151
- det["assessment_status"] = info.get("assessment_status", "ASSESSED")
152
- else:
153
- det.setdefault("assessment_status", "UNASSESSED")
154
 
 
155
  for det in detections:
156
  if "assessment_status" not in det:
157
- det["assessment_status"] = "UNASSESSED"
158
 
159
  # Update stored job so frontend polls pick up GPT data
160
- storage = get_job_storage()
161
- storage.update(
162
  job_id,
163
  first_frame_detections=detections,
164
  first_frame_gpt_results=gpt_results,
@@ -549,12 +514,14 @@ async def detect_async_endpoint(
549
  asyncio.create_task(process_video_async(job_id))
550
 
551
  # Fire-and-forget: enrich first-frame detections with GPT in background.
552
- # Segmentation mode already runs one-shot GPT in GSAM2 writer, so skip this
553
- # path there to avoid duplicate GPT calls on frame 0.
554
- if mode != "segmentation":
555
- asyncio.create_task(_enrich_first_frame_gpt(
556
- job_id, processed_frame, detections, enable_gpt, mission_spec,
557
- ))
 
 
558
 
559
  response_data = {
560
  "job_id": job_id,
 
55
  get_job_storage,
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
+ from utils.relevance import evaluate_relevance
61
+ from utils.enrichment import run_enrichment
62
+ from utils.schemas import AssessmentStatus
63
  from utils.mission_parser import parse_mission_text, build_broad_queries, MissionParseError
64
 
65
  logging.basicConfig(level=logging.INFO)
 
89
  if not enable_gpt or not detections:
90
  return
91
  try:
92
+ # Non-LLM_EXTRACTED relevance filter runs BEFORE run_enrichment (FAST_PATH case)
93
+ if mission_spec and mission_spec.parse_mode != "LLM_EXTRACTED":
 
 
 
 
 
 
 
 
 
 
94
  for d in detections:
95
  decision = evaluate_relevance(d, mission_spec.relevance_criteria)
96
  d["mission_relevant"] = decision.relevant
97
  d["relevance_reason"] = decision.reason
98
+ filtered = [d for d in detections if d.get("mission_relevant", True)]
99
+ if not filtered:
100
+ for det in detections:
101
+ det["assessment_status"] = AssessmentStatus.ASSESSED
102
+ get_job_storage().update(job_id, first_frame_detections=detections)
103
+ logging.info("All detections non-relevant for job %s; marked ASSESSED", job_id)
104
+ return
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  gpt_results = await asyncio.to_thread(
107
+ run_enrichment, 0, frame, detections, mission_spec,
108
+ job_id=job_id,
 
 
109
  )
110
  logging.info("Background GPT enrichment complete for job %s", job_id)
111
 
112
+ if not gpt_results:
113
+ # All detections filtered as not relevant
114
+ for det in detections:
115
+ det["assessment_status"] = AssessmentStatus.ASSESSED
116
+ get_job_storage().update(job_id, first_frame_detections=detections)
117
+ logging.info("All detections non-relevant for job %s; marked ASSESSED", job_id)
118
+ return
 
 
 
 
 
 
 
 
 
 
 
119
 
120
+ # Tag any remaining detections without an assessment status
121
  for det in detections:
122
  if "assessment_status" not in det:
123
+ det["assessment_status"] = AssessmentStatus.UNASSESSED
124
 
125
  # Update stored job so frontend polls pick up GPT data
126
+ get_job_storage().update(
 
127
  job_id,
128
  first_frame_detections=detections,
129
  first_frame_gpt_results=gpt_results,
 
514
  asyncio.create_task(process_video_async(job_id))
515
 
516
  # Fire-and-forget: enrich first-frame detections with GPT in background.
517
+ # Runs for ALL modes including segmentation first-frame detections from
518
+ # process_first_frame() already have stable track IDs (T01, T02, ...) and
519
+ # valid bboxes, so there's no reason to defer. The GSAM2 writer's
520
+ # enrichment thread will see the cached results via first_frame_gpt_results
521
+ # in JobStorage and skip the duplicate call on frame 0.
522
+ asyncio.create_task(_enrich_first_frame_gpt(
523
+ job_id, processed_frame, detections, enable_gpt, mission_spec,
524
+ ))
525
 
526
  response_data = {
527
  "job_id": job_id,
frontend/index.html CHANGED
@@ -282,6 +282,7 @@
282
  <script src="./js/core/video.js"></script>
283
  <script src="./js/core/hel.js"></script>
284
  <script src="./js/ui/logging.js"></script>
 
285
  <script src="./js/core/tracker.js"></script>
286
  <script src="./js/api/client.js"></script>
287
  <script src="./js/ui/overlays.js"></script>
 
282
  <script src="./js/core/video.js"></script>
283
  <script src="./js/core/hel.js"></script>
284
  <script src="./js/ui/logging.js"></script>
285
+ <script src="./js/core/gptMapping.js"></script>
286
  <script src="./js/core/tracker.js"></script>
287
  <script src="./js/api/client.js"></script>
288
  <script src="./js/ui/overlays.js"></script>
frontend/js/api/client.js CHANGED
@@ -126,26 +126,7 @@ APP.api.client._syncGptFromDetections = function (rawDets, logLabel) {
126
  const existing = (state.detections || []).find(d => d.id === tid);
127
  if (existing && rd.gpt_raw) {
128
  const g = rd.gpt_raw;
129
- const rangeStr = g.range_estimate && g.range_estimate !== "Unknown"
130
- ? g.range_estimate + " (est.)" : "Unknown";
131
- existing.features = {
132
- "Type": g.object_type || "Unknown",
133
- "Size": g.size || "Unknown",
134
- "Threat Lvl": (g.threat_level || g.threat_level_score || "?") + "/10",
135
- "Status": g.threat_classification || "?",
136
- "Weapons": (g.visible_weapons || []).join(", ") || "None Visible",
137
- "Readiness": g.weapon_readiness || "Unknown",
138
- "Motion": g.motion_status || "Unknown",
139
- "Range": rangeStr,
140
- "Bearing": g.bearing || "Unknown",
141
- "Intent": g.tactical_intent || "Unknown",
142
- };
143
- const dynFeats = g.dynamic_features || [];
144
- for (const feat of dynFeats) {
145
- if (feat && feat.key && feat.value) {
146
- existing.features[feat.key] = feat.value;
147
- }
148
- }
149
  existing.threat_level_score = rd.threat_level_score || g.threat_level_score || 0;
150
  existing.threat_classification = rd.threat_classification || g.threat_classification || "Unknown";
151
  existing.weapon_readiness = rd.weapon_readiness || g.weapon_readiness || "Unknown";
 
126
  const existing = (state.detections || []).find(d => d.id === tid);
127
  if (existing && rd.gpt_raw) {
128
  const g = rd.gpt_raw;
129
+ existing.features = APP.core.gptMapping.buildFeatures(g);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  existing.threat_level_score = rd.threat_level_score || g.threat_level_score || 0;
131
  existing.threat_classification = rd.threat_classification || g.threat_classification || "Unknown";
132
  existing.weapon_readiness = rd.weapon_readiness || g.weapon_readiness || "Unknown";
frontend/js/core/gptMapping.js ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * gptMapping.js — canonical GPT-raw → features field mapping.
3
+ *
4
+ * Replaces 4 identical inline mapping blocks across main.js, client.js,
5
+ * and tracker.js (2 locations).
6
+ */
7
+ APP.core.gptMapping = {};
8
+
9
+ /** Frozen assessment-status string constants. */
10
+ APP.core.gptMapping.STATUS = Object.freeze({
11
+ ASSESSED: "ASSESSED",
12
+ UNASSESSED: "UNASSESSED",
13
+ STALE: "STALE",
14
+ PENDING_GPT: "PENDING_GPT",
15
+ });
16
+
17
+ /**
18
+ * Build a features object from a gpt_raw payload.
19
+ *
20
+ * @param {Object|null|undefined} gptRaw - The gpt_raw dict from a detection.
21
+ * @returns {Object} Features key-value map (empty object if gptRaw is falsy).
22
+ */
23
+ APP.core.gptMapping.buildFeatures = function (gptRaw) {
24
+ if (!gptRaw) return {};
25
+ const rangeStr = gptRaw.range_estimate && gptRaw.range_estimate !== "Unknown"
26
+ ? gptRaw.range_estimate + " (est.)" : "Unknown";
27
+ const features = {
28
+ "Type": gptRaw.object_type || "Unknown",
29
+ "Size": gptRaw.size || "Unknown",
30
+ "Threat Lvl": (gptRaw.threat_level || gptRaw.threat_level_score || "?") + "/10",
31
+ "Status": gptRaw.threat_classification || "?",
32
+ "Weapons": (gptRaw.visible_weapons || []).join(", ") || "None Visible",
33
+ "Readiness": gptRaw.weapon_readiness || "Unknown",
34
+ "Motion": gptRaw.motion_status || "Unknown",
35
+ "Range": rangeStr,
36
+ "Bearing": gptRaw.bearing || "Unknown",
37
+ "Intent": gptRaw.tactical_intent || "Unknown",
38
+ };
39
+ const dynFeats = gptRaw.dynamic_features || [];
40
+ for (const feat of dynFeats) {
41
+ if (feat && feat.key && feat.value) {
42
+ features[feat.key] = feat.value;
43
+ }
44
+ }
45
+ return features;
46
+ };
frontend/js/core/tracker.js CHANGED
@@ -206,29 +206,11 @@ APP.core.tracker.syncWithBackend = async function (frameIdx) {
206
  // Mission relevance and assessment status
207
  mission_relevant: d.mission_relevant ?? null,
208
  relevance_reason: d.relevance_reason || null,
209
- assessment_status: d.assessment_status || "UNASSESSED",
210
  assessment_frame_index: d.assessment_frame_index ?? null,
211
  // GPT raw data for feature table
212
  gpt_raw: d.gpt_raw || null,
213
- features: d.gpt_raw ? (() => {
214
- const f = {
215
- "Type": d.gpt_raw.object_type || "Unknown",
216
- "Size": d.gpt_raw.size || "Unknown",
217
- "Threat Lvl": (d.gpt_raw.threat_level || d.gpt_raw.threat_level_score || "?") + "/10",
218
- "Status": d.gpt_raw.threat_classification || "?",
219
- "Weapons": (d.gpt_raw.visible_weapons || []).join(", ") || "None Visible",
220
- "Readiness": d.gpt_raw.weapon_readiness || "Unknown",
221
- "Motion": d.gpt_raw.motion_status || "Unknown",
222
- "Range": d.gpt_raw.range_estimate && d.gpt_raw.range_estimate !== "Unknown"
223
- ? d.gpt_raw.range_estimate + " (est.)" : "Unknown",
224
- "Bearing": d.gpt_raw.bearing || "Unknown",
225
- "Intent": d.gpt_raw.tactical_intent || "Unknown",
226
- };
227
- for (const feat of (d.gpt_raw.dynamic_features || [])) {
228
- if (feat && feat.key && feat.value) f[feat.key] = feat.value;
229
- }
230
- return f;
231
- })() : {},
232
  // Keep UI state fields
233
  lastSeen: Date.now(),
234
  state: "TRACK"
@@ -248,7 +230,7 @@ APP.core.tracker.syncWithBackend = async function (frameIdx) {
248
  if (!cached || track.gpt_raw) continue;
249
  const g = cached.gpt_raw;
250
  track.gpt_raw = g;
251
- track.assessment_status = cached.assessment_status || "ASSESSED";
252
  track.threat_level_score = cached.threat_level_score || g.threat_level_score || 0;
253
  track.threat_classification = cached.threat_classification || g.threat_classification || "Unknown";
254
  track.weapon_readiness = cached.weapon_readiness || g.weapon_readiness || "Unknown";
@@ -256,22 +238,7 @@ APP.core.tracker.syncWithBackend = async function (frameIdx) {
256
  track.gpt_direction = cached.gpt_direction || null;
257
  track.mission_relevant = cached.mission_relevant ?? track.mission_relevant;
258
  track.relevance_reason = cached.relevance_reason || track.relevance_reason;
259
- track.features = {
260
- "Type": g.object_type || "Unknown",
261
- "Size": g.size || "Unknown",
262
- "Threat Lvl": (g.threat_level || g.threat_level_score || "?") + "/10",
263
- "Status": g.threat_classification || "?",
264
- "Weapons": (g.visible_weapons || []).join(", ") || "None Visible",
265
- "Readiness": g.weapon_readiness || "Unknown",
266
- "Motion": g.motion_status || "Unknown",
267
- "Range": g.range_estimate && g.range_estimate !== "Unknown"
268
- ? g.range_estimate + " (est.)" : "Unknown",
269
- "Bearing": g.bearing || "Unknown",
270
- "Intent": g.tactical_intent || "Unknown",
271
- };
272
- for (const feat of (g.dynamic_features || [])) {
273
- if (feat && feat.key && feat.value) track.features[feat.key] = feat.value;
274
- }
275
  }
276
  }
277
 
 
206
  // Mission relevance and assessment status
207
  mission_relevant: d.mission_relevant ?? null,
208
  relevance_reason: d.relevance_reason || null,
209
+ assessment_status: d.assessment_status || APP.core.gptMapping.STATUS.UNASSESSED,
210
  assessment_frame_index: d.assessment_frame_index ?? null,
211
  // GPT raw data for feature table
212
  gpt_raw: d.gpt_raw || null,
213
+ features: APP.core.gptMapping.buildFeatures(d.gpt_raw),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  // Keep UI state fields
215
  lastSeen: Date.now(),
216
  state: "TRACK"
 
230
  if (!cached || track.gpt_raw) continue;
231
  const g = cached.gpt_raw;
232
  track.gpt_raw = g;
233
+ track.assessment_status = cached.assessment_status || APP.core.gptMapping.STATUS.ASSESSED;
234
  track.threat_level_score = cached.threat_level_score || g.threat_level_score || 0;
235
  track.threat_classification = cached.threat_classification || g.threat_classification || "Unknown";
236
  track.weapon_readiness = cached.weapon_readiness || g.weapon_readiness || "Unknown";
 
238
  track.gpt_direction = cached.gpt_direction || null;
239
  track.mission_relevant = cached.mission_relevant ?? track.mission_relevant;
240
  track.relevance_reason = cached.relevance_reason || track.relevance_reason;
241
+ track.features = APP.core.gptMapping.buildFeatures(g);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  }
243
  }
244
 
frontend/js/main.js CHANGED
@@ -522,34 +522,8 @@ document.addEventListener("DOMContentLoaded", () => {
522
  ? { x: d.bbox[0], y: d.bbox[1], w: d.bbox[2] - d.bbox[0], h: d.bbox[3] - d.bbox[1] }
523
  : { x: 0, y: 0, w: 10, h: 10 };
524
 
525
- // Range display: qualify GPT-estimated distances (INV-10)
526
- const rangeDisplay = d.gpt_raw && d.gpt_raw.range_estimate && d.gpt_raw.range_estimate !== "Unknown"
527
- ? d.gpt_raw.range_estimate + " (est.)"
528
- : "Unknown";
529
-
530
- // Build features from universal schema
531
- let features = {};
532
- if (d.gpt_raw) {
533
- features = {
534
- "Type": d.gpt_raw.object_type || "Unknown",
535
- "Size": d.gpt_raw.size || "Unknown",
536
- "Threat Lvl": (d.gpt_raw.threat_level || d.gpt_raw.threat_level_score || "?") + "/10",
537
- "Status": d.gpt_raw.threat_classification || "?",
538
- "Weapons": (d.gpt_raw.visible_weapons || []).join(", ") || "None Visible",
539
- "Readiness": d.gpt_raw.weapon_readiness || "Unknown",
540
- "Motion": d.gpt_raw.motion_status || "Unknown",
541
- "Range": rangeDisplay,
542
- "Bearing": d.gpt_raw.bearing || "Unknown",
543
- "Intent": d.gpt_raw.tactical_intent || "Unknown",
544
- };
545
- // Spread dynamic features as extra key-value pairs
546
- const dynFeats = d.gpt_raw.dynamic_features || [];
547
- for (const feat of dynFeats) {
548
- if (feat && feat.key && feat.value) {
549
- features[feat.key] = feat.value;
550
- }
551
- }
552
- }
553
 
554
  return {
555
  id,
@@ -578,7 +552,7 @@ document.addEventListener("DOMContentLoaded", () => {
578
  // Mission relevance and assessment status
579
  mission_relevant: d.mission_relevant ?? null,
580
  relevance_reason: d.relevance_reason || null,
581
- assessment_status: d.assessment_status || "UNASSESSED",
582
  assessment_frame_index: d.assessment_frame_index ?? null,
583
  };
584
  });
 
522
  ? { x: d.bbox[0], y: d.bbox[1], w: d.bbox[2] - d.bbox[0], h: d.bbox[3] - d.bbox[1] }
523
  : { x: 0, y: 0, w: 10, h: 10 };
524
 
525
+ // Build features from universal schema via canonical mapping
526
+ let features = APP.core.gptMapping.buildFeatures(d.gpt_raw);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
 
528
  return {
529
  id,
 
552
  // Mission relevance and assessment status
553
  mission_relevant: d.mission_relevant ?? null,
554
  relevance_reason: d.relevance_reason || null,
555
+ assessment_status: d.assessment_status || APP.core.gptMapping.STATUS.UNASSESSED,
556
  assessment_frame_index: d.assessment_frame_index ?? null,
557
  };
558
  });
frontend/js/ui/cards.js CHANGED
@@ -27,7 +27,8 @@ APP.ui.cards.renderFrameTrackList = function () {
27
  }
28
 
29
  // Sort: ASSESSED first (by threat score), then UNASSESSED, then STALE
30
- const statusOrder = { "ASSESSED": 0, "UNASSESSED": 1, "STALE": 2 };
 
31
  const sorted = [...dets].sort((a, b) => {
32
  const statusA = statusOrder[a.assessment_status] ?? 1;
33
  const statusB = statusOrder[b.assessment_status] ?? 1;
@@ -68,14 +69,14 @@ APP.ui.cards.renderFrameTrackList = function () {
68
 
69
  // Assessment status badge
70
  let statusBadge = "";
71
- const assessStatus = det.assessment_status || "UNASSESSED";
72
- if (assessStatus === "UNASSESSED") {
73
  statusBadge = '<span class="badgemini" style="background:#6c757d; color:white">UNASSESSED</span>';
74
- } else if (assessStatus === "STALE") {
75
  statusBadge = '<span class="badgemini" style="background:#ffc107; color:#333">STALE</span>';
76
  } else if (det.threat_level_score > 0) {
77
  statusBadge = `<span class="badgemini" style="background:${det.threat_level_score >= 8 ? '#ff4d4d' : '#ff9f43'}; color:white">T-${det.threat_level_score}</span>`;
78
- } else if (assessStatus === "ASSESSED") {
79
  statusBadge = '<span class="badgemini" style="background:#17a2b8; color:white">ASSESSED</span>';
80
  }
81
 
 
27
  }
28
 
29
  // Sort: ASSESSED first (by threat score), then UNASSESSED, then STALE
30
+ const S = APP.core.gptMapping.STATUS;
31
+ const statusOrder = { [S.ASSESSED]: 0, [S.UNASSESSED]: 1, [S.STALE]: 2 };
32
  const sorted = [...dets].sort((a, b) => {
33
  const statusA = statusOrder[a.assessment_status] ?? 1;
34
  const statusB = statusOrder[b.assessment_status] ?? 1;
 
69
 
70
  // Assessment status badge
71
  let statusBadge = "";
72
+ const assessStatus = det.assessment_status || S.UNASSESSED;
73
+ if (assessStatus === S.UNASSESSED) {
74
  statusBadge = '<span class="badgemini" style="background:#6c757d; color:white">UNASSESSED</span>';
75
+ } else if (assessStatus === S.STALE) {
76
  statusBadge = '<span class="badgemini" style="background:#ffc107; color:#333">STALE</span>';
77
  } else if (det.threat_level_score > 0) {
78
  statusBadge = `<span class="badgemini" style="background:${det.threat_level_score >= 8 ? '#ff4d4d' : '#ff9f43'}; color:white">T-${det.threat_level_score}</span>`;
79
+ } else if (assessStatus === S.ASSESSED) {
80
  statusBadge = '<span class="badgemini" style="background:#17a2b8; color:white">ASSESSED</span>';
81
  }
82
 
utils/threat_chat.py CHANGED
@@ -2,11 +2,12 @@
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
 
@@ -26,11 +27,7 @@ def chat_about_threats(
26
  Returns:
27
  GPT's response as a string.
28
  """
29
- import urllib.request
30
- import urllib.error
31
-
32
- api_key = os.environ.get("OPENAI_API_KEY")
33
- if not api_key:
34
  logger.warning("OPENAI_API_KEY not set. Cannot process threat chat.")
35
  return "Error: OpenAI API key not configured."
36
 
@@ -42,17 +39,9 @@ def chat_about_threats(
42
 
43
  # Domain-aware role selection
44
  domain = "GENERIC"
45
- role_label = "Tactical Intelligence Officer"
46
  if mission_spec_dict:
47
  domain = mission_spec_dict.get("domain", "GENERIC")
48
- if domain == "NAVAL":
49
- role_label = "Naval Tactical Intelligence Officer"
50
- elif domain == "GROUND":
51
- role_label = "Ground Surveillance Intelligence Officer"
52
- elif domain == "AERIAL":
53
- role_label = "Air Surveillance Intelligence Officer"
54
- elif domain == "URBAN":
55
- role_label = "Urban Surveillance Intelligence Officer"
56
 
57
  # Build mission context block (INV-8: mission context forwarded to LLM calls)
58
  mission_block = ""
@@ -89,29 +78,16 @@ def chat_about_threats(
89
  "temperature": 0.3,
90
  }
91
 
92
- headers = {
93
- "Content-Type": "application/json",
94
- "Authorization": f"Bearer {api_key}"
95
- }
96
-
97
  try:
98
- req = urllib.request.Request(
99
- "https://api.openai.com/v1/chat/completions",
100
- data=json.dumps(payload).encode('utf-8'),
101
- headers=headers,
102
- method="POST"
103
- )
104
- with urllib.request.urlopen(req, timeout=30) as response:
105
- resp_data = json.loads(response.read().decode('utf-8'))
106
-
107
- content = resp_data['choices'][0]['message'].get('content', '')
108
  return content.strip() if content else "No response generated."
109
-
110
- except urllib.error.HTTPError as e:
111
- logger.error(f"OpenAI API HTTP error: {e.code} - {e.reason}")
112
- return f"API Error: {e.reason}"
113
  except Exception as e:
114
- logger.error(f"Threat chat failed: {e}")
115
  return f"Error processing question: {str(e)}"
116
 
117
 
 
2
  Threat Chat Module - GPT-powered Q&A about detected threats.
3
  """
4
 
 
 
5
  import logging
6
  from typing import List, Dict, Any
7
 
8
+ from utils.openai_client import chat_completion, extract_content, get_api_key, OpenAIAPIError
9
+ from utils.gpt_reasoning import _DOMAIN_ROLES
10
+
11
  logger = logging.getLogger(__name__)
12
 
13
 
 
27
  Returns:
28
  GPT's response as a string.
29
  """
30
+ if not get_api_key():
 
 
 
 
31
  logger.warning("OPENAI_API_KEY not set. Cannot process threat chat.")
32
  return "Error: OpenAI API key not configured."
33
 
 
39
 
40
  # Domain-aware role selection
41
  domain = "GENERIC"
 
42
  if mission_spec_dict:
43
  domain = mission_spec_dict.get("domain", "GENERIC")
44
+ role_label = _DOMAIN_ROLES.get(domain, _DOMAIN_ROLES["GENERIC"])
 
 
 
 
 
 
 
45
 
46
  # Build mission context block (INV-8: mission context forwarded to LLM calls)
47
  mission_block = ""
 
78
  "temperature": 0.3,
79
  }
80
 
 
 
 
 
 
81
  try:
82
+ resp_data = chat_completion(payload)
83
+ content, _refusal = extract_content(resp_data)
 
 
 
 
 
 
 
 
84
  return content.strip() if content else "No response generated."
85
+
86
+ except OpenAIAPIError as e:
87
+ logger.error("OpenAI API error: %s", e)
88
+ return f"API Error: {e}"
89
  except Exception as e:
90
+ logger.error("Threat chat failed: %s", e)
91
  return f"Error processing question: {str(e)}"
92
 
93