Zhen Ye Claude Opus 4.6 commited on
Commit
a2ca6f9
·
1 Parent(s): ae714ec

feat: Add mission-driven object relevance abstractions

Browse files

Introduces MissionSpecification extraction, object relevance gating,
domain-aware GPT prompts, assessment provenance/staleness tracking,
and UNASSESSED status distinct from score 0. Mission text is now
parsed into structured intent before reaching the detector, and only
mission-relevant objects are sent to GPT for threat assessment.

Key changes:
- MissionSpecification + RelevanceCriteria schemas (utils/schemas.py)
- Mission text parser with fast-path and LLM extraction (utils/mission_parser.py)
- Deterministic relevance gate between detection and GPT (utils/relevance.py)
- Domain-aware GPT system prompts with mission context injection
- Temporal validity tracking (ASSESSED/UNASSESSED/STALE) in tracker
- LEGACY mode when no mission text provided (GPT auto-disabled)
- Frontend: deterministic sort, UNASSESSED/STALE badges, range qualifiers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

app.py CHANGED
@@ -57,6 +57,7 @@ from jobs.storage import (
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
 
@@ -266,14 +267,24 @@ async def detect_endpoint(
266
  fd, output_path = tempfile.mkstemp(prefix="output_", suffix=".mp4", dir="/tmp")
267
  os.close(fd)
268
 
269
- # Parse queries
270
- query_list = [q.strip() for q in queries.split(",") if q.strip()]
 
 
 
 
 
 
 
 
 
 
 
271
  if mode == "drone_detection" and not query_list:
272
  query_list = ["drone"]
273
 
274
  # Run inference
275
  try:
276
- detector_name = "drone_yolo" if mode == "drone_detection" else detector
277
 
278
  # Determine depth estimator
279
  active_depth = "depth" if enable_depth else None
@@ -348,9 +359,36 @@ async def detect_async_endpoint(
348
  finally:
349
  await video.close()
350
 
351
- query_list = [q.strip() for q in queries.split(",") if q.strip()]
352
- if not query_list:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  query_list = _default_queries_for_mode(mode)
 
 
 
 
 
354
 
355
  available_depth_estimators = set(list_depth_estimators())
356
  if depth_estimator not in available_depth_estimators:
@@ -362,11 +400,7 @@ async def detect_async_endpoint(
362
  ),
363
  )
364
 
365
- detector_name = detector
366
- if mode == "drone_detection":
367
- detector_name = "drone_yolo"
368
-
369
- # Determine actve depth estimator (Legacy)
370
  active_depth = depth_estimator if enable_depth else None
371
 
372
  try:
@@ -380,6 +414,7 @@ async def detect_async_endpoint(
380
  depth_scale=depth_scale,
381
  enable_depth_estimator=enable_depth,
382
  enable_gpt=enable_gpt,
 
383
  )
384
  cv2.imwrite(str(first_frame_path), processed_frame)
385
 
@@ -417,17 +452,13 @@ async def detect_async_endpoint(
417
  depth_output_path=str(depth_output_path),
418
  first_frame_depth_path=str(first_frame_depth_path),
419
  enable_gpt=enable_gpt,
 
 
420
  )
421
  get_job_storage().create(job)
422
  asyncio.create_task(process_video_async(job_id))
423
 
424
- return {
425
- "job_id": job_id,
426
- "first_frame_url": f"/detect/first-frame/{job_id}",
427
- "first_frame_depth_url": f"/detect/first-frame-depth/{job_id}",
428
- "status_url": f"/detect/status/{job_id}",
429
- "video_url": f"/detect/video/{job_id}",
430
- "depth_video_url": f"/detect/depth-video/{job_id}",
431
  "job_id": job_id,
432
  "first_frame_url": f"/detect/first-frame/{job_id}",
433
  "first_frame_depth_url": f"/detect/first-frame-depth/{job_id}",
@@ -437,8 +468,23 @@ async def detect_async_endpoint(
437
  "stream_url": f"/detect/stream/{job_id}",
438
  "status": job.status.value,
439
  "first_frame_detections": detections,
 
440
  }
441
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
 
443
  @app.get("/detect/status/{job_id}")
444
  async def detect_status(job_id: str):
@@ -692,34 +738,46 @@ async def reason_track(
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")
 
57
  )
58
  from utils.gpt_reasoning import estimate_threat_gpt
59
  from utils.threat_chat import chat_about_threats
60
+ from utils.mission_parser import parse_mission_text, MissionParseError
61
 
62
  logging.basicConfig(level=logging.INFO)
63
 
 
267
  fd, output_path = tempfile.mkstemp(prefix="output_", suffix=".mp4", dir="/tmp")
268
  os.close(fd)
269
 
270
+ # Parse queries with mission awareness
271
+ detector_name = "drone_yolo" if mode == "drone_detection" else detector
272
+ mission_spec = None
273
+
274
+ if queries.strip():
275
+ try:
276
+ mission_spec = parse_mission_text(queries.strip(), detector_name)
277
+ query_list = mission_spec.object_classes
278
+ except MissionParseError as e:
279
+ raise HTTPException(status_code=422, detail=str(e))
280
+ else:
281
+ query_list = _default_queries_for_mode(mode)
282
+
283
  if mode == "drone_detection" and not query_list:
284
  query_list = ["drone"]
285
 
286
  # Run inference
287
  try:
 
288
 
289
  # Determine depth estimator
290
  active_depth = "depth" if enable_depth else None
 
359
  finally:
360
  await video.close()
361
 
362
+ # --- Mission-Driven Query Parsing ---
363
+ mission_spec = None
364
+ mission_mode = "LEGACY"
365
+
366
+ detector_name = detector
367
+ if mode == "drone_detection":
368
+ detector_name = "drone_yolo"
369
+
370
+ if queries.strip():
371
+ try:
372
+ mission_spec = parse_mission_text(queries.strip(), detector_name)
373
+ query_list = mission_spec.object_classes
374
+ mission_mode = "MISSION"
375
+ logging.info(
376
+ "Mission parsed: mode=%s classes=%s domain=%s(%s)",
377
+ mission_mode, query_list, mission_spec.domain, mission_spec.domain_source,
378
+ )
379
+ except MissionParseError as e:
380
+ raise HTTPException(
381
+ status_code=422,
382
+ detail=str(e),
383
+ )
384
+ else:
385
+ # LEGACY mode: no mission context, use defaults, disable GPT
386
  query_list = _default_queries_for_mode(mode)
387
+ enable_gpt = False
388
+ mission_mode = "LEGACY"
389
+ logging.info(
390
+ "LEGACY mode: no mission text, defaults=%s, GPT disabled", query_list
391
+ )
392
 
393
  available_depth_estimators = set(list_depth_estimators())
394
  if depth_estimator not in available_depth_estimators:
 
400
  ),
401
  )
402
 
403
+ # Determine active depth estimator (Legacy)
 
 
 
 
404
  active_depth = depth_estimator if enable_depth else None
405
 
406
  try:
 
414
  depth_scale=depth_scale,
415
  enable_depth_estimator=enable_depth,
416
  enable_gpt=enable_gpt,
417
+ mission_spec=mission_spec,
418
  )
419
  cv2.imwrite(str(first_frame_path), processed_frame)
420
 
 
452
  depth_output_path=str(depth_output_path),
453
  first_frame_depth_path=str(first_frame_depth_path),
454
  enable_gpt=enable_gpt,
455
+ mission_spec=mission_spec,
456
+ mission_mode=mission_mode,
457
  )
458
  get_job_storage().create(job)
459
  asyncio.create_task(process_video_async(job_id))
460
 
461
+ response_data = {
 
 
 
 
 
 
462
  "job_id": job_id,
463
  "first_frame_url": f"/detect/first-frame/{job_id}",
464
  "first_frame_depth_url": f"/detect/first-frame-depth/{job_id}",
 
468
  "stream_url": f"/detect/stream/{job_id}",
469
  "status": job.status.value,
470
  "first_frame_detections": detections,
471
+ "mission_mode": mission_mode,
472
  }
473
 
474
+ if mission_spec:
475
+ response_data["mission_spec"] = {
476
+ "object_classes": mission_spec.object_classes,
477
+ "mission_intent": mission_spec.mission_intent,
478
+ "domain": mission_spec.domain,
479
+ "domain_source": mission_spec.domain_source,
480
+ "parse_confidence": mission_spec.parse_confidence,
481
+ "parse_warnings": mission_spec.parse_warnings,
482
+ "context_phrases": mission_spec.context_phrases,
483
+ "stripped_modifiers": mission_spec.stripped_modifiers,
484
+ }
485
+
486
+ return response_data
487
+
488
 
489
  @app.get("/detect/status/{job_id}")
490
  async def detect_status(job_id: str):
 
738
  @app.post("/chat/threat")
739
  async def chat_threat_endpoint(
740
  question: str = Form(...),
741
+ detections: str = Form(...), # JSON string of current detections
742
+ mission_context: str = Form(""), # Optional JSON string of mission spec
743
  ):
744
  """
745
  Chat about detected threats using GPT.
746
+
747
  Args:
748
  question: User's question about the current threat situation.
749
  detections: JSON string of detection list with threat analysis data.
750
+ mission_context: Optional JSON string of mission specification.
751
+
752
  Returns:
753
  GPT response about the threats.
754
  """
755
  import json as json_module
756
+
757
  if not question.strip():
758
  raise HTTPException(status_code=400, detail="Question cannot be empty.")
759
+
760
  try:
761
  detection_list = json_module.loads(detections)
762
  except json_module.JSONDecodeError:
763
  raise HTTPException(status_code=400, detail="Invalid detections JSON.")
764
+
765
  if not isinstance(detection_list, list):
766
  raise HTTPException(status_code=400, detail="Detections must be a list.")
767
+
768
+ # Parse optional mission context
769
+ mission_spec_dict = None
770
+ if mission_context.strip():
771
+ try:
772
+ mission_spec_dict = json_module.loads(mission_context)
773
+ except json_module.JSONDecodeError:
774
+ pass # Non-critical, proceed without mission context
775
+
776
  # Run chat in thread to avoid blocking
777
  try:
778
+ response = await asyncio.to_thread(
779
+ chat_about_threats, question, detection_list, mission_spec_dict
780
+ )
781
  return {"response": response}
782
  except Exception as e:
783
  logging.exception("Threat chat failed")
frontend/js/core/tracker.js CHANGED
@@ -193,13 +193,32 @@ APP.core.tracker.syncWithBackend = async function (frameIdx) {
193
  score: d.score,
194
  angle_deg: d.angle_deg,
195
  gpt_distance_m: d.gpt_distance_m,
196
- angle_deg: d.angle_deg,
197
- gpt_distance_m: d.gpt_distance_m,
198
  speed_kph: d.speed_kph,
199
  depth_est_m: d.depth_est_m,
200
  depth_rel: d.depth_rel,
201
  depth_valid: d.depth_valid,
202
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  // Keep UI state fields
204
  lastSeen: Date.now(),
205
  state: "TRACK"
 
193
  score: d.score,
194
  angle_deg: d.angle_deg,
195
  gpt_distance_m: d.gpt_distance_m,
196
+ gpt_direction: d.gpt_direction,
197
+ gpt_description: d.gpt_description,
198
  speed_kph: d.speed_kph,
199
  depth_est_m: d.depth_est_m,
200
  depth_rel: d.depth_rel,
201
  depth_valid: d.depth_valid,
202
+ // Threat intelligence
203
+ threat_level_score: d.threat_level_score || 0,
204
+ threat_classification: d.threat_classification || "Unknown",
205
+ weapon_readiness: d.weapon_readiness || "Unknown",
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
+ "Vessel Class": d.gpt_raw.specific_class || d.gpt_raw.vessel_category || "Unknown",
215
+ "Threat Lvl": d.gpt_raw.threat_level_score + "/10",
216
+ "Status": d.gpt_raw.threat_classification || "?",
217
+ "Weapons": (d.gpt_raw.visible_weapons || []).join(", ") || "None Visible",
218
+ "Readiness": d.gpt_raw.weapon_readiness || "Unknown",
219
+ "Motion": d.gpt_raw.motion_status || "Unknown",
220
+ "Range": d.gpt_raw.range_estimation_nm ? "~" + d.gpt_raw.range_estimation_nm + " NM (est.)" : "Unknown",
221
+ } : {},
222
  // Keep UI state fields
223
  lastSeen: Date.now(),
224
  state: "TRACK"
frontend/js/main.js CHANGED
@@ -505,13 +505,17 @@ document.addEventListener("DOMContentLoaded", () => {
505
  ? { x: d.bbox[0], y: d.bbox[1], w: d.bbox[2] - d.bbox[0], h: d.bbox[3] - d.bbox[1] }
506
  : { x: 0, y: 0, w: 10, h: 10 };
507
 
 
 
 
 
 
508
  return {
509
  id,
510
  label: d.label || d.class,
511
  score: d.score || 0.5,
512
  bbox,
513
  aim: { ...ap },
514
- aim: { ...ap },
515
  features: d.gpt_raw ? {
516
  "Vessel Class": d.gpt_raw.specific_class || d.gpt_raw.vessel_category || "Unknown",
517
  "Threat Lvl": d.gpt_raw.threat_level_score + "/10",
@@ -522,7 +526,7 @@ document.addEventListener("DOMContentLoaded", () => {
522
  "Sensors": (d.gpt_raw.sensor_profile || []).join(", ") || "None",
523
  "Flags/ID": (d.gpt_raw.identity_markers || []).join(", ") || (d.gpt_raw.flag_state || "Unknown"),
524
  "Activity": d.gpt_raw.deck_activity || "None",
525
- "Range": (d.gpt_raw.range_estimation_nm ? d.gpt_raw.range_estimation_nm + " NM" : "Unknown"),
526
  "Wake": d.gpt_raw.wake_description || "None"
527
  } : {},
528
  baseRange_m: null,
@@ -531,17 +535,22 @@ document.addEventListener("DOMContentLoaded", () => {
531
  reqP_kW: 40,
532
  maxP_kW: 0,
533
  pkill: 0,
534
- // New depth fields
535
  depth_est_m: (d.depth_est_m !== undefined && d.depth_est_m !== null) ? d.depth_est_m : null,
536
  depth_rel: (d.depth_rel !== undefined && d.depth_rel !== null) ? d.depth_rel : null,
537
  depth_valid: d.depth_valid ?? false,
538
  gpt_distance_m: d.gpt_distance_m || null,
539
  gpt_direction: d.gpt_direction || null,
540
  gpt_description: d.gpt_description || null,
541
- // New Threat Intelligence
542
  threat_level_score: d.threat_level_score || 0,
543
  threat_classification: d.threat_classification || "Unknown",
544
- weapon_readiness: d.weapon_readiness || "Unknown"
 
 
 
 
 
545
  };
546
  });
547
 
 
505
  ? { x: d.bbox[0], y: d.bbox[1], w: d.bbox[2] - d.bbox[0], h: d.bbox[3] - d.bbox[1] }
506
  : { x: 0, y: 0, w: 10, h: 10 };
507
 
508
+ // Range display: qualify GPT-estimated distances (INV-10)
509
+ const rangeDisplay = d.gpt_raw && d.gpt_raw.range_estimation_nm
510
+ ? "~" + d.gpt_raw.range_estimation_nm + " NM (est.)"
511
+ : "Unknown";
512
+
513
  return {
514
  id,
515
  label: d.label || d.class,
516
  score: d.score || 0.5,
517
  bbox,
518
  aim: { ...ap },
 
519
  features: d.gpt_raw ? {
520
  "Vessel Class": d.gpt_raw.specific_class || d.gpt_raw.vessel_category || "Unknown",
521
  "Threat Lvl": d.gpt_raw.threat_level_score + "/10",
 
526
  "Sensors": (d.gpt_raw.sensor_profile || []).join(", ") || "None",
527
  "Flags/ID": (d.gpt_raw.identity_markers || []).join(", ") || (d.gpt_raw.flag_state || "Unknown"),
528
  "Activity": d.gpt_raw.deck_activity || "None",
529
+ "Range": rangeDisplay,
530
  "Wake": d.gpt_raw.wake_description || "None"
531
  } : {},
532
  baseRange_m: null,
 
535
  reqP_kW: 40,
536
  maxP_kW: 0,
537
  pkill: 0,
538
+ // Depth fields
539
  depth_est_m: (d.depth_est_m !== undefined && d.depth_est_m !== null) ? d.depth_est_m : null,
540
  depth_rel: (d.depth_rel !== undefined && d.depth_rel !== null) ? d.depth_rel : null,
541
  depth_valid: d.depth_valid ?? false,
542
  gpt_distance_m: d.gpt_distance_m || null,
543
  gpt_direction: d.gpt_direction || null,
544
  gpt_description: d.gpt_description || null,
545
+ // Threat Intelligence
546
  threat_level_score: d.threat_level_score || 0,
547
  threat_classification: d.threat_classification || "Unknown",
548
+ weapon_readiness: d.weapon_readiness || "Unknown",
549
+ // Mission relevance and assessment status
550
+ mission_relevant: d.mission_relevant ?? null,
551
+ relevance_reason: d.relevance_reason || null,
552
+ assessment_status: d.assessment_status || "UNASSESSED",
553
+ assessment_frame_index: d.assessment_frame_index ?? null,
554
  };
555
  });
556
 
frontend/js/ui/cards.js CHANGED
@@ -9,7 +9,14 @@ APP.ui.cards.renderFrameTrackList = function () {
9
  if (!frameTrackList) return;
10
  frameTrackList.innerHTML = "";
11
 
12
- const dets = state.detections || [];
 
 
 
 
 
 
 
13
  if (trackCount) trackCount.textContent = dets.length;
14
 
15
  if (dets.length === 0) {
@@ -17,7 +24,18 @@ APP.ui.cards.renderFrameTrackList = function () {
17
  return;
18
  }
19
 
20
- const sorted = [...dets].sort((a, b) => (b.threat_level_score || 0) - (a.threat_level_score || 0));
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  sorted.forEach((det, i) => {
23
  const id = det.id || `T${String(i + 1).padStart(2, '0')}`;
@@ -28,7 +46,7 @@ APP.ui.cards.renderFrameTrackList = function () {
28
  if (det.depth_valid && det.depth_est_m != null) {
29
  rangeStr = `${Math.round(det.depth_est_m)}m (Depth)`;
30
  } else if (det.gpt_distance_m) {
31
- rangeStr = `${det.gpt_distance_m}m (GPT)`;
32
  } else if (det.baseRange_m) {
33
  rangeStr = `${Math.round(det.baseRange_m)}m (Area)`;
34
  }
@@ -51,11 +69,22 @@ APP.ui.cards.renderFrameTrackList = function () {
51
  ? `<div class="track-card-body"><span class="gpt-text">${det.gpt_description}</span></div>`
52
  : "";
53
 
 
 
 
 
 
 
 
 
 
 
 
54
  card.innerHTML = `
55
  <div class="track-card-header">
56
  <span>${id} · ${det.label}</span>
57
  <div style="display:flex; gap:4px">
58
- ${det.threat_level_score > 0 ? `<span class="badgemini" style="background:${det.threat_level_score >= 8 ? '#ff4d4d' : '#ff9f43'}; color:white">T-${det.threat_level_score}</span>` : ''}
59
  <span class="badgemini">${(det.score * 100).toFixed(0)}%</span>
60
  </div>
61
  </div>
 
9
  if (!frameTrackList) return;
10
  frameTrackList.innerHTML = "";
11
 
12
+ // Filter: only show mission-relevant detections (or all in LEGACY mode)
13
+ const dets = (state.detections || []).filter(d => {
14
+ // LEGACY mode: mission_relevant is null -> show all
15
+ if (d.mission_relevant === null || d.mission_relevant === undefined) return true;
16
+ // MISSION mode: only show relevant
17
+ return d.mission_relevant === true;
18
+ });
19
+
20
  if (trackCount) trackCount.textContent = dets.length;
21
 
22
  if (dets.length === 0) {
 
24
  return;
25
  }
26
 
27
+ // Deterministic sort: ASSESSED first (by threat score), then UNASSESSED, then STALE
28
+ // Within each group, sort by threat_level_score descending, then by confidence
29
+ const statusOrder = { "ASSESSED": 0, "UNASSESSED": 1, "STALE": 2 };
30
+ const sorted = [...dets].sort((a, b) => {
31
+ const statusA = statusOrder[a.assessment_status] ?? 1;
32
+ const statusB = statusOrder[b.assessment_status] ?? 1;
33
+ if (statusA !== statusB) return statusA - statusB;
34
+ const scoreA = a.threat_level_score || 0;
35
+ const scoreB = b.threat_level_score || 0;
36
+ if (scoreB !== scoreA) return scoreB - scoreA;
37
+ return (b.score || 0) - (a.score || 0);
38
+ });
39
 
40
  sorted.forEach((det, i) => {
41
  const id = det.id || `T${String(i + 1).padStart(2, '0')}`;
 
46
  if (det.depth_valid && det.depth_est_m != null) {
47
  rangeStr = `${Math.round(det.depth_est_m)}m (Depth)`;
48
  } else if (det.gpt_distance_m) {
49
+ rangeStr = `~${det.gpt_distance_m}m (est.)`;
50
  } else if (det.baseRange_m) {
51
  rangeStr = `${Math.round(det.baseRange_m)}m (Area)`;
52
  }
 
69
  ? `<div class="track-card-body"><span class="gpt-text">${det.gpt_description}</span></div>`
70
  : "";
71
 
72
+ // Assessment status badge (INV-6: UNASSESSED distinct from score 0)
73
+ let statusBadge = "";
74
+ const assessStatus = det.assessment_status || "UNASSESSED";
75
+ if (assessStatus === "UNASSESSED") {
76
+ statusBadge = '<span class="badgemini" style="background:#6c757d; color:white">UNASSESSED</span>';
77
+ } else if (assessStatus === "STALE") {
78
+ statusBadge = '<span class="badgemini" style="background:#ffc107; color:#333">STALE</span>';
79
+ } else if (det.threat_level_score > 0) {
80
+ statusBadge = `<span class="badgemini" style="background:${det.threat_level_score >= 8 ? '#ff4d4d' : '#ff9f43'}; color:white">T-${det.threat_level_score}</span>`;
81
+ }
82
+
83
  card.innerHTML = `
84
  <div class="track-card-header">
85
  <span>${id} · ${det.label}</span>
86
  <div style="display:flex; gap:4px">
87
+ ${statusBadge}
88
  <span class="badgemini">${(det.score * 100).toFixed(0)}%</span>
89
  </div>
90
  </div>
inference.py CHANGED
@@ -23,8 +23,10 @@ from models.depth_estimators.model_loader import load_depth_estimator, load_dept
23
  from models.depth_estimators.base import DepthEstimator
24
  from utils.video import extract_frames, write_video, VideoReader, VideoWriter, AsyncVideoReader
25
  from utils.gpt_reasoning import estimate_threat_gpt
 
26
  from jobs.storage import set_track_data
27
  import tempfile
 
28
 
29
 
30
  class AsyncVideoReader:
@@ -715,6 +717,7 @@ def process_first_frame(
715
  depth_scale: Optional[float] = None,
716
  enable_depth_estimator: bool = False,
717
  enable_gpt: bool = True, # ENABLED BY DEFAULT
 
718
  ) -> Tuple[np.ndarray, List[Dict[str, Any]], Optional[np.ndarray]]:
719
  frame, _, _, _ = extract_first_frame(video_path)
720
  if mode == "segmentation":
@@ -722,34 +725,61 @@ def process_first_frame(
722
  frame, text_queries=queries, segmenter_name=segmenter_name
723
  )
724
  return processed, [], None
725
-
726
  processed, detections = infer_frame(
727
  frame, queries, detector_name=detector_name
728
  )
729
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  # 1. Synchronous Depth Estimation (HF Backend)
731
  depth_map = None
732
  # If a specific depth estimator is requested OR if generic "enable" flag is on
733
  should_run_depth = (depth_estimator_name is not None) or enable_depth_estimator
734
-
735
  if should_run_depth and detections:
736
  try:
737
  # Resolve name: if none given, default to "depth"
738
  d_name = depth_estimator_name if depth_estimator_name else "depth"
739
  scale = depth_scale if depth_scale is not None else 1.0
740
-
741
  logging.info(f"Running synchronous depth estimation with {d_name} (scale={scale})...")
742
  estimator = load_depth_estimator(d_name)
743
-
744
  # Run prediction
745
  with _get_model_lock("depth", estimator.name):
746
  result = estimator.predict(frame)
747
-
748
  depth_map = result.depth_map
749
-
750
  # Compute per-detection depth metrics
751
  detections = compute_depth_per_detection(depth_map, detections, scale)
752
-
753
  except Exception as e:
754
  logging.exception(f"First frame depth failed: {e}")
755
  # Mark all detections as depth_valid=False just in case
@@ -759,40 +789,41 @@ def process_first_frame(
759
  det["depth_valid"] = False
760
 
761
  # 2. GPT-based Distance/Direction Estimation (Explicitly enabled)
762
- if enable_gpt:
763
- # We need to save the frame temporarily to pass to GPT (or refactor gpt_reasoning to take buffer)
764
- # For now, write to temp file
765
  try:
766
  with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp_img:
767
  cv2.imwrite(tmp_img.name, frame)
768
- gpt_results = estimate_threat_gpt(tmp_img.name, detections)
769
- logging.info(f"GPT Output for First Frame:\n{gpt_results}") # Expose to HF logs
770
- os.remove(tmp_img.name) # Clean up immediatey
 
 
771
 
772
  # Merge GPT results into detections
773
- # GPT returns { "T01": { "distance_m": ..., "direction": ... } }
774
- # Detections are list of dicts. We assume T01 maps to index 0, T02 to index 1...
775
- for i, det in enumerate(detections):
776
- # Index-based IDs are intentional here: no tracker runs for first-frame
777
- # preview, so GPT, inference merge, and frontend all use the same
778
- # index-based scheme (T01=index 0, T02=index 1, ...), keeping it
779
- # self-consistent. The video pipeline uses real ByteTracker IDs instead.
780
  obj_id = f"T{str(i+1).zfill(2)}"
781
  if obj_id in gpt_results:
782
  info = gpt_results[obj_id]
783
  det["gpt_distance_m"] = info.get("distance_m")
784
  det["gpt_direction"] = info.get("direction")
785
  det["gpt_description"] = info.get("description")
786
- # Threat Intelligence
787
  det["threat_level_score"] = info.get("threat_level_score")
788
  det["threat_classification"] = info.get("threat_classification")
789
  det["weapon_readiness"] = info.get("weapon_readiness")
790
- # Full Metadata for Feature Table
791
  det["gpt_raw"] = info
792
-
 
 
 
793
  except Exception as e:
794
  logging.error(f"GPT Threat estimation failed: {e}")
795
 
 
 
 
 
 
796
  return processed, detections, depth_map
797
 
798
 
@@ -807,6 +838,7 @@ def run_inference(
807
  depth_scale: float = 1.0,
808
  enable_gpt: bool = True,
809
  stream_queue: Optional[Queue] = None,
 
810
  ) -> Tuple[str, List[List[Dict[str, Any]]]]:
811
 
812
  # 1. Setup Video Reader
@@ -1115,27 +1147,59 @@ def run_inference(
1115
  dets = tracker.update(dets)
1116
  speed_est.estimate(dets)
1117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1118
  # --- GPT ESTIMATION (Frame 0 Only) ---
1119
- if next_idx == 0 and enable_gpt and dets:
1120
  try:
1121
  logging.info("Running GPT estimation for video start (Frame 0)...")
1122
  with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
1123
  cv2.imwrite(tmp.name, p_frame)
1124
- gpt_res = estimate_threat_gpt(tmp.name, dets)
 
 
1125
  os.remove(tmp.name)
1126
 
1127
  # Merge using real track_id assigned by ByteTracker
1128
- for d in dets:
1129
  oid = d.get('track_id')
1130
  if oid and oid in gpt_res:
1131
  d.update(gpt_res[oid])
 
 
1132
 
1133
  # Push GPT data back into tracker's internal STrack objects
1134
- # so it persists across subsequent frames via _sync_data
1135
- tracker.inject_metadata(dets)
1136
 
1137
  except Exception as e:
1138
  logging.error("GPT failed for Frame 0: %s", e)
 
 
 
 
 
1139
 
1140
  # --- RENDER BOXES & OVERLAYS ---
1141
  # We need to convert list of dicts back to boxes array for draw_boxes
 
23
  from models.depth_estimators.base import DepthEstimator
24
  from utils.video import extract_frames, write_video, VideoReader, VideoWriter, AsyncVideoReader
25
  from utils.gpt_reasoning import estimate_threat_gpt
26
+ from utils.relevance import evaluate_relevance
27
  from jobs.storage import set_track_data
28
  import tempfile
29
+ import json as json_module
30
 
31
 
32
  class AsyncVideoReader:
 
717
  depth_scale: Optional[float] = None,
718
  enable_depth_estimator: bool = False,
719
  enable_gpt: bool = True, # ENABLED BY DEFAULT
720
+ mission_spec=None, # Optional[MissionSpecification]
721
  ) -> Tuple[np.ndarray, List[Dict[str, Any]], Optional[np.ndarray]]:
722
  frame, _, _, _ = extract_first_frame(video_path)
723
  if mode == "segmentation":
 
725
  frame, text_queries=queries, segmenter_name=segmenter_name
726
  )
727
  return processed, [], None
728
+
729
  processed, detections = infer_frame(
730
  frame, queries, detector_name=detector_name
731
  )
732
 
733
+ # --- RELEVANCE GATE (between detection and GPT) ---
734
+ if mission_spec:
735
+ relevant_dets = []
736
+ for det in detections:
737
+ decision = evaluate_relevance(det, mission_spec.relevance_criteria)
738
+ det["mission_relevant"] = decision.relevant
739
+ det["relevance_reason"] = decision.reason
740
+ if decision.relevant:
741
+ relevant_dets.append(det)
742
+ else:
743
+ logging.info(
744
+ json_module.dumps({
745
+ "event": "relevance_decision",
746
+ "label": det.get("label"),
747
+ "relevant": False,
748
+ "reason": decision.reason,
749
+ "required_classes": mission_spec.relevance_criteria.required_classes,
750
+ "frame": 0,
751
+ })
752
+ )
753
+ gpt_input_dets = relevant_dets
754
+ else:
755
+ # LEGACY mode: all detections pass, tagged as unresolved
756
+ for det in detections:
757
+ det["mission_relevant"] = None
758
+ gpt_input_dets = detections
759
+
760
  # 1. Synchronous Depth Estimation (HF Backend)
761
  depth_map = None
762
  # If a specific depth estimator is requested OR if generic "enable" flag is on
763
  should_run_depth = (depth_estimator_name is not None) or enable_depth_estimator
764
+
765
  if should_run_depth and detections:
766
  try:
767
  # Resolve name: if none given, default to "depth"
768
  d_name = depth_estimator_name if depth_estimator_name else "depth"
769
  scale = depth_scale if depth_scale is not None else 1.0
770
+
771
  logging.info(f"Running synchronous depth estimation with {d_name} (scale={scale})...")
772
  estimator = load_depth_estimator(d_name)
773
+
774
  # Run prediction
775
  with _get_model_lock("depth", estimator.name):
776
  result = estimator.predict(frame)
777
+
778
  depth_map = result.depth_map
779
+
780
  # Compute per-detection depth metrics
781
  detections = compute_depth_per_detection(depth_map, detections, scale)
782
+
783
  except Exception as e:
784
  logging.exception(f"First frame depth failed: {e}")
785
  # Mark all detections as depth_valid=False just in case
 
789
  det["depth_valid"] = False
790
 
791
  # 2. GPT-based Distance/Direction Estimation (Explicitly enabled)
792
+ # Only assess mission-relevant detections
793
+ if enable_gpt and gpt_input_dets:
 
794
  try:
795
  with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp_img:
796
  cv2.imwrite(tmp_img.name, frame)
797
+ gpt_results = estimate_threat_gpt(
798
+ tmp_img.name, gpt_input_dets, mission_spec=mission_spec
799
+ )
800
+ logging.info(f"GPT Output for First Frame:\n{gpt_results}")
801
+ os.remove(tmp_img.name)
802
 
803
  # Merge GPT results into detections
804
+ for i, det in enumerate(gpt_input_dets):
 
 
 
 
 
 
805
  obj_id = f"T{str(i+1).zfill(2)}"
806
  if obj_id in gpt_results:
807
  info = gpt_results[obj_id]
808
  det["gpt_distance_m"] = info.get("distance_m")
809
  det["gpt_direction"] = info.get("direction")
810
  det["gpt_description"] = info.get("description")
 
811
  det["threat_level_score"] = info.get("threat_level_score")
812
  det["threat_classification"] = info.get("threat_classification")
813
  det["weapon_readiness"] = info.get("weapon_readiness")
 
814
  det["gpt_raw"] = info
815
+ # Provenance: tag assessment frame
816
+ det["assessment_frame_index"] = 0
817
+ det["assessment_status"] = "ASSESSED"
818
+
819
  except Exception as e:
820
  logging.error(f"GPT Threat estimation failed: {e}")
821
 
822
+ # Tag unassessed detections (INV-6: distinct from score 0)
823
+ for det in detections:
824
+ if "assessment_status" not in det:
825
+ det["assessment_status"] = "UNASSESSED"
826
+
827
  return processed, detections, depth_map
828
 
829
 
 
838
  depth_scale: float = 1.0,
839
  enable_gpt: bool = True,
840
  stream_queue: Optional[Queue] = None,
841
+ mission_spec=None, # Optional[MissionSpecification]
842
  ) -> Tuple[str, List[List[Dict[str, Any]]]]:
843
 
844
  # 1. Setup Video Reader
 
1147
  dets = tracker.update(dets)
1148
  speed_est.estimate(dets)
1149
 
1150
+ # --- RELEVANCE GATE ---
1151
+ if mission_spec:
1152
+ for d in dets:
1153
+ decision = evaluate_relevance(d, mission_spec.relevance_criteria)
1154
+ d["mission_relevant"] = decision.relevant
1155
+ d["relevance_reason"] = decision.reason
1156
+ if not decision.relevant:
1157
+ logging.info(
1158
+ json_module.dumps({
1159
+ "event": "relevance_decision",
1160
+ "track_id": d.get("track_id"),
1161
+ "label": d.get("label"),
1162
+ "relevant": False,
1163
+ "reason": decision.reason,
1164
+ "required_classes": mission_spec.relevance_criteria.required_classes,
1165
+ "frame": next_idx,
1166
+ })
1167
+ )
1168
+ gpt_dets = [d for d in dets if d.get("mission_relevant", True)]
1169
+ else:
1170
+ for d in dets:
1171
+ d["mission_relevant"] = None
1172
+ gpt_dets = dets
1173
+
1174
  # --- GPT ESTIMATION (Frame 0 Only) ---
1175
+ if next_idx == 0 and enable_gpt and gpt_dets:
1176
  try:
1177
  logging.info("Running GPT estimation for video start (Frame 0)...")
1178
  with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
1179
  cv2.imwrite(tmp.name, p_frame)
1180
+ gpt_res = estimate_threat_gpt(
1181
+ tmp.name, gpt_dets, mission_spec=mission_spec
1182
+ )
1183
  os.remove(tmp.name)
1184
 
1185
  # Merge using real track_id assigned by ByteTracker
1186
+ for d in gpt_dets:
1187
  oid = d.get('track_id')
1188
  if oid and oid in gpt_res:
1189
  d.update(gpt_res[oid])
1190
+ d["assessment_frame_index"] = 0
1191
+ d["assessment_status"] = "ASSESSED"
1192
 
1193
  # Push GPT data back into tracker's internal STrack objects
1194
+ tracker.inject_metadata(gpt_dets)
 
1195
 
1196
  except Exception as e:
1197
  logging.error("GPT failed for Frame 0: %s", e)
1198
+
1199
+ # Tag unassessed detections (INV-6)
1200
+ for d in dets:
1201
+ if "assessment_status" not in d:
1202
+ d["assessment_status"] = "UNASSESSED"
1203
 
1204
  # --- RENDER BOXES & OVERLAYS ---
1205
  # We need to convert list of dicts back to boxes array for draw_boxes
jobs/background.py CHANGED
@@ -52,6 +52,7 @@ async def process_video_async(job_id: str) -> None:
52
  job.depth_scale,
53
  job.enable_gpt,
54
  stream_queue,
 
55
  )
56
  detection_path, detections_list = result_pkg
57
 
 
52
  job.depth_scale,
53
  job.enable_gpt,
54
  stream_queue,
55
+ job.mission_spec, # Forward mission spec for relevance gating
56
  )
57
  detection_path, detections_list = result_pkg
58
 
jobs/models.py CHANGED
@@ -34,3 +34,6 @@ class JobInfo:
34
  partial_success: bool = False # True if one component failed but job completed
35
  depth_error: Optional[str] = None # Error message if depth failed
36
  enable_gpt: bool = True # Whether to use GPT for distance estimation
 
 
 
 
34
  partial_success: bool = False # True if one component failed but job completed
35
  depth_error: Optional[str] = None # Error message if depth failed
36
  enable_gpt: bool = True # Whether to use GPT for distance estimation
37
+ # Mission specification (None = LEGACY mode)
38
+ mission_spec: Optional[Any] = None # utils.schemas.MissionSpecification
39
+ mission_mode: str = "LEGACY" # "MISSION" or "LEGACY"
utils/gpt_reasoning.py CHANGED
@@ -13,19 +13,112 @@ def encode_image(image_path: str) -> str:
13
  with open(image_path, "rb") as image_file:
14
  return base64.b64encode(image_file.read()).decode('utf-8')
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  def estimate_threat_gpt(
17
- image_path: str,
18
- detections: List[Dict[str, Any]]
 
19
  ) -> Dict[str, Any]:
20
  """
21
- Perform Naval Threat Assessment on detected objects using GPT-4o.
22
-
23
  Args:
24
  image_path: Path to the image file.
25
  detections: List of detection dicts (bbox, label, etc.).
26
-
 
27
  Returns:
28
- Dict mapping object ID (e.g., T01) to NavalThreatAssessment dict.
29
  """
30
  api_key = os.environ.get("OPENAI_API_KEY")
31
  if not api_key:
@@ -35,14 +128,13 @@ def estimate_threat_gpt(
35
  # 1. Prepare detections summary for prompt
36
  det_summary = []
37
  for i, det in enumerate(detections):
38
- # UI uses T01, T02... logic usually matches index + 1
39
  obj_id = det.get("track_id") or det.get("id") or f"T{str(i+1).zfill(2)}"
40
  bbox = det.get("bbox", [])
41
  label = det.get("label", "object")
42
  det_summary.append(f"- ID: {obj_id}, Classification Hint: {label}, BBox: {bbox}")
43
 
44
  det_text = "\n".join(det_summary)
45
-
46
  if not det_text:
47
  return {}
48
 
@@ -53,45 +145,20 @@ def estimate_threat_gpt(
53
  logger.error(f"Failed to encode image for GPT: {e}")
54
  return {}
55
 
56
- # 3. Construct Prompt (Naval Focused)
57
- system_prompt = (
58
- "You are an elite Naval Intelligence Officer and Threat Analyst. "
59
- "Your task is to analyze optical surveillance imagery and provide a detailed tactical assessment for every detected object. "
60
- "You must output a STRICT JSON object that matches the following schema for every object ID provided:\n\n"
61
- "RESPONSE SCHEMA (JSON):\n"
62
- "{\n"
63
- " \"objects\": {\n"
64
- " \"T01\": {\n"
65
- " \"vessel_category\": \"Warship\" | \"Commercial\" | \"Fishing\" | \"Small Boat\" | \"Aircraft\" | \"Unknown\",\n"
66
- " \"specific_class\": \"string (e.g., Arleigh Burke, Skiff)\",\n"
67
- " \"identity_markers\": [\"string (hull numbers, flags)\"],\n"
68
- " \"flag_state\": \"string (Country)\",\n"
69
- " \"visible_weapons\": [\"string\"],\n"
70
- " \"weapon_readiness\": \"Stowed/PEACE\" | \"Trained/Aiming\" | \"Firing/HOSTILE\",\n"
71
- " \"sensor_profile\": [\"string (radars)\"],\n"
72
- " \"motion_status\": \"Dead in Water\" | \"Underway Slow\" | \"Underway Fast\" | \"Flank Speed\",\n"
73
- " \"wake_description\": \"string\",\n"
74
- " \"aspect\": \"Bow-on\" | \"Stern-on\" | \"Broadside\",\n"
75
- " \"range_estimation_nm\": float (Nautical Miles),\n"
76
- " \"bearing_clock\": \"string (e.g. 12 o'clock)\",\n"
77
- " \"deck_activity\": \"string\",\n"
78
- " \"special_features\": [\"string (anomalies)\"],\n"
79
- " \"threat_level_score\": int (1-10),\n"
80
- " \"threat_classification\": \"Friendly\" | \"Neutral\" | \"Suspect\" | \"Hostile\",\n"
81
- " \"tactical_intent\": \"string (e.g., Transit, Attack)\"\n"
82
- " }\n"
83
- " }\n"
84
- "}\n\n"
85
- "ASSUMPTIONS:\n"
86
- "- Unknown small boats approaching larger vessels are HIGH threat (Suspect/Hostile).\n"
87
- "- Visible trained weapons are IMMINENT threat (Score 9-10).\n"
88
- "- Ignore artifacts, focus on the objects."
89
- )
90
 
 
91
  user_prompt = (
92
- f"Analyze this naval surveillance image. The following objects have been detected:\n"
93
  f"{det_text}\n\n"
94
- "Provide a detailed Naval Threat Assessment for each object based on its visual signatures."
95
  )
96
 
97
  # 4. Call API
 
13
  with open(image_path, "rb") as image_file:
14
  return base64.b64encode(image_file.read()).decode('utf-8')
15
 
16
+ def _build_domain_system_prompt(domain: str, mission_spec=None) -> str:
17
+ """Select domain-appropriate system prompt based on MissionSpecification."""
18
+
19
+ # Mission context block (injected regardless of domain)
20
+ mission_context = ""
21
+ if mission_spec:
22
+ mission_context = (
23
+ "\n\nMISSION CONTEXT:\n"
24
+ f"- Operator Intent: {mission_spec.mission_intent}\n"
25
+ f"- Domain: {mission_spec.domain}\n"
26
+ f"- Target Classes: {', '.join(mission_spec.object_classes)}\n"
27
+ )
28
+ if mission_spec.context_phrases:
29
+ mission_context += f"- Situational Context: {'; '.join(mission_spec.context_phrases)}\n"
30
+ if mission_spec.stripped_modifiers:
31
+ mission_context += f"- Operator Modifiers (stripped): {', '.join(mission_spec.stripped_modifiers)}\n"
32
+ mission_context += (
33
+ "\nUse the mission context to inform your analysis. "
34
+ "Focus assessment on the target classes and domain specified."
35
+ )
36
+
37
+ if domain == "NAVAL":
38
+ return (
39
+ "You are an elite Naval Intelligence Officer and Threat Analyst. "
40
+ "Your task is to analyze optical surveillance imagery and provide a detailed tactical assessment for every detected object. "
41
+ "You must output a STRICT JSON object that matches the following schema for every object ID provided:\n\n"
42
+ "RESPONSE SCHEMA (JSON):\n"
43
+ "{\n"
44
+ " \"objects\": {\n"
45
+ " \"T01\": {\n"
46
+ " \"vessel_category\": \"Warship\" | \"Commercial\" | \"Fishing\" | \"Small Boat\" | \"Aircraft\" | \"Unknown\",\n"
47
+ " \"specific_class\": \"string (e.g., Arleigh Burke, Skiff)\",\n"
48
+ " \"identity_markers\": [\"string (hull numbers, flags)\"],\n"
49
+ " \"flag_state\": \"string (Country)\",\n"
50
+ " \"visible_weapons\": [\"string\"],\n"
51
+ " \"weapon_readiness\": \"Stowed/PEACE\" | \"Trained/Aiming\" | \"Firing/HOSTILE\",\n"
52
+ " \"sensor_profile\": [\"string (radars)\"],\n"
53
+ " \"motion_status\": \"Dead in Water\" | \"Underway Slow\" | \"Underway Fast\" | \"Flank Speed\",\n"
54
+ " \"wake_description\": \"string\",\n"
55
+ " \"aspect\": \"Bow-on\" | \"Stern-on\" | \"Broadside\",\n"
56
+ " \"range_estimation_nm\": float (Nautical Miles),\n"
57
+ " \"bearing_clock\": \"string (e.g. 12 o'clock)\",\n"
58
+ " \"deck_activity\": \"string\",\n"
59
+ " \"special_features\": [\"string (anomalies)\"],\n"
60
+ " \"threat_level_score\": int (1-10),\n"
61
+ " \"threat_classification\": \"Friendly\" | \"Neutral\" | \"Suspect\" | \"Hostile\",\n"
62
+ " \"tactical_intent\": \"string (e.g., Transit, Attack)\"\n"
63
+ " }\n"
64
+ " }\n"
65
+ "}\n\n"
66
+ "ASSUMPTIONS:\n"
67
+ "- Unknown small boats approaching larger vessels are HIGH threat (Suspect/Hostile).\n"
68
+ "- Visible trained weapons are IMMINENT threat (Score 9-10).\n"
69
+ "- Ignore artifacts, focus on the objects."
70
+ + mission_context
71
+ )
72
+
73
+ # Generic / non-naval domains use a simplified schema
74
+ return (
75
+ f"You are a surveillance analyst specializing in the {domain} domain. "
76
+ "Your task is to analyze optical surveillance imagery and provide a tactical assessment for every detected object. "
77
+ "You must output a STRICT JSON object that matches the following schema for every object ID provided:\n\n"
78
+ "RESPONSE SCHEMA (JSON):\n"
79
+ "{\n"
80
+ " \"objects\": {\n"
81
+ " \"T01\": {\n"
82
+ " \"vessel_category\": \"string (object category)\",\n"
83
+ " \"specific_class\": \"string (specific type if identifiable)\",\n"
84
+ " \"identity_markers\": [\"string (visible identifiers)\"],\n"
85
+ " \"flag_state\": \"string (origin if identifiable)\",\n"
86
+ " \"visible_weapons\": [\"string\"],\n"
87
+ " \"weapon_readiness\": \"Stowed/PEACE\" | \"Trained/Aiming\" | \"Firing/HOSTILE\" | \"Unknown\",\n"
88
+ " \"sensor_profile\": [\"string\"],\n"
89
+ " \"motion_status\": \"Stationary\" | \"Moving Slow\" | \"Moving Fast\" | \"Unknown\",\n"
90
+ " \"wake_description\": \"string\",\n"
91
+ " \"aspect\": \"string (orientation relative to camera)\",\n"
92
+ " \"range_estimation_nm\": float,\n"
93
+ " \"bearing_clock\": \"string (e.g. 12 o'clock)\",\n"
94
+ " \"deck_activity\": \"string\",\n"
95
+ " \"special_features\": [\"string (anomalies)\"],\n"
96
+ " \"threat_level_score\": int (1-10),\n"
97
+ " \"threat_classification\": \"Friendly\" | \"Neutral\" | \"Suspect\" | \"Hostile\",\n"
98
+ " \"tactical_intent\": \"string\"\n"
99
+ " }\n"
100
+ " }\n"
101
+ "}\n\n"
102
+ "Assess each object based on its visual signatures and the operational context."
103
+ + mission_context
104
+ )
105
+
106
+
107
  def estimate_threat_gpt(
108
+ image_path: str,
109
+ detections: List[Dict[str, Any]],
110
+ mission_spec=None, # Optional[MissionSpecification]
111
  ) -> Dict[str, Any]:
112
  """
113
+ Perform Threat Assessment on detected objects using GPT-4o.
114
+
115
  Args:
116
  image_path: Path to the image file.
117
  detections: List of detection dicts (bbox, label, etc.).
118
+ mission_spec: Optional MissionSpecification for domain-aware assessment.
119
+
120
  Returns:
121
+ Dict mapping object ID (e.g., T01) to threat assessment dict.
122
  """
123
  api_key = os.environ.get("OPENAI_API_KEY")
124
  if not api_key:
 
128
  # 1. Prepare detections summary for prompt
129
  det_summary = []
130
  for i, det in enumerate(detections):
 
131
  obj_id = det.get("track_id") or det.get("id") or f"T{str(i+1).zfill(2)}"
132
  bbox = det.get("bbox", [])
133
  label = det.get("label", "object")
134
  det_summary.append(f"- ID: {obj_id}, Classification Hint: {label}, BBox: {bbox}")
135
 
136
  det_text = "\n".join(det_summary)
137
+
138
  if not det_text:
139
  return {}
140
 
 
145
  logger.error(f"Failed to encode image for GPT: {e}")
146
  return {}
147
 
148
+ # 3. Domain-aware prompt selection (INV-7)
149
+ domain = "NAVAL" # default for backward compatibility
150
+ if mission_spec:
151
+ domain = mission_spec.domain
152
+ if mission_spec.domain_source == "INFERRED":
153
+ logger.info("GPT assessment using inferred domain=%s (domain_inferred=True)", domain)
154
+
155
+ system_prompt = _build_domain_system_prompt(domain, mission_spec)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
+ domain_label = domain.lower() if domain != "NAVAL" else "naval"
158
  user_prompt = (
159
+ f"Analyze this {domain_label} surveillance image. The following objects have been detected:\n"
160
  f"{det_text}\n\n"
161
+ f"Provide a detailed Threat Assessment for each object based on its visual signatures."
162
  )
163
 
164
  # 4. Call API
utils/mission_parser.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mission text parser — converts raw operator text into a validated MissionSpecification.
3
+
4
+ Single public function: parse_mission_text(raw_text, detector_key) -> MissionSpecification
5
+
6
+ Internal flow:
7
+ 1. Fast-path regex check -> skip LLM if comma-separated labels
8
+ 2. LLM extraction call (GPT-4o, temperature 0.0)
9
+ 3. Deterministic validation pipeline
10
+ 4. COCO vocabulary mapping for COCO-only detectors
11
+ 5. Build RelevanceCriteria deterministically from mapped classes
12
+ 6. Return validated MissionSpecification or raise MissionParseError
13
+ """
14
+
15
+ import json
16
+ import logging
17
+ import os
18
+ import re
19
+ import urllib.request
20
+ import urllib.error
21
+ from typing import List, Optional
22
+
23
+ from coco_classes import COCO_CLASSES, canonicalize_coco_name, coco_class_catalog
24
+ from utils.schemas import MissionSpecification, RelevanceCriteria
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Detectors that only support COCO class vocabulary
29
+ _COCO_ONLY_DETECTORS = frozenset({"hf_yolov8", "detr_resnet50"})
30
+
31
+
32
+ class MissionParseError(ValueError):
33
+ """Raised when mission text cannot be parsed into a valid MissionSpecification."""
34
+ def __init__(self, message: str, warnings: Optional[List[str]] = None):
35
+ self.warnings = warnings or []
36
+ super().__init__(message)
37
+
38
+
39
+ def _is_comma_separated_labels(text: str) -> bool:
40
+ """Fast-path: detect simple comma-separated class labels (no LLM needed)."""
41
+ # Match: word tokens separated by commas, each token <= 3 words
42
+ pattern = r"^[\w\s]+(,\s*[\w\s]+)*$"
43
+ if not re.match(pattern, text.strip()):
44
+ return False
45
+ tokens = [t.strip() for t in text.split(",") if t.strip()]
46
+ return all(len(t.split()) <= 3 for t in tokens)
47
+
48
+
49
+ def _is_coco_only(detector_key: str) -> bool:
50
+ return detector_key in _COCO_ONLY_DETECTORS
51
+
52
+
53
+ def _map_coco_classes(
54
+ object_classes: List[str], detector_key: str
55
+ ) -> tuple[List[str], List[str], List[str]]:
56
+ """Map object classes to COCO vocabulary for COCO-only detectors.
57
+
58
+ Returns:
59
+ (mapped_classes, unmappable_classes, warnings)
60
+ """
61
+ if not _is_coco_only(detector_key):
62
+ return object_classes, [], []
63
+
64
+ mapped = []
65
+ unmappable = []
66
+ warnings = []
67
+ seen = set()
68
+
69
+ for cls in object_classes:
70
+ canonical = canonicalize_coco_name(cls)
71
+ if canonical is not None:
72
+ if canonical not in seen:
73
+ mapped.append(canonical)
74
+ seen.add(canonical)
75
+ if canonical.lower() != cls.lower():
76
+ warnings.append(
77
+ f"'{cls}' mapped to COCO class '{canonical}'."
78
+ )
79
+ else:
80
+ unmappable.append(cls)
81
+ warnings.append(
82
+ f"'{cls}' is not in COCO vocabulary. Will not be detected by {detector_key}."
83
+ )
84
+
85
+ return mapped, unmappable, warnings
86
+
87
+
88
+ def _build_fast_path_spec(
89
+ raw_text: str, object_classes: List[str], detector_key: str
90
+ ) -> MissionSpecification:
91
+ """Build MissionSpecification for simple comma-separated input (no LLM call)."""
92
+ mapped, unmappable, warnings = _map_coco_classes(object_classes, detector_key)
93
+
94
+ if _is_coco_only(detector_key) and not mapped:
95
+ raise MissionParseError(
96
+ f"None of the requested objects ({', '.join(object_classes)}) match the "
97
+ f"{detector_key} vocabulary. This detector supports: "
98
+ f"{coco_class_catalog()}. "
99
+ f"Use an open-vocabulary detector (Grounding DINO) or adjust your mission.",
100
+ warnings=warnings,
101
+ )
102
+
103
+ final_classes = mapped if _is_coco_only(detector_key) else object_classes
104
+
105
+ return MissionSpecification(
106
+ object_classes=final_classes,
107
+ mission_intent="DETECT",
108
+ domain="GENERIC",
109
+ domain_source="INFERRED",
110
+ relevance_criteria=RelevanceCriteria(
111
+ required_classes=final_classes,
112
+ min_confidence=0.0,
113
+ ),
114
+ context_phrases=[],
115
+ stripped_modifiers=[],
116
+ operator_text=raw_text,
117
+ parse_confidence="HIGH",
118
+ parse_warnings=warnings,
119
+ )
120
+
121
+
122
+ # --- LLM Extraction ---
123
+
124
+ _SYSTEM_PROMPT = (
125
+ "You are a mission text parser for an object detection system. Your ONLY job is to extract "
126
+ "structured fields from operator mission text. You do NOT assess threats. You do NOT reason "
127
+ "about tactics. You extract and classify.\n\n"
128
+ "OUTPUT SCHEMA (strict JSON):\n"
129
+ "{\n"
130
+ ' "object_classes": ["string"],\n'
131
+ ' "mission_intent": "ENUM",\n'
132
+ ' "domain": "ENUM",\n'
133
+ ' "context_phrases": ["string"],\n'
134
+ ' "stripped_modifiers": ["string"],\n'
135
+ ' "parse_confidence": "ENUM",\n'
136
+ ' "parse_warnings": ["string"]\n'
137
+ "}\n\n"
138
+ "EXTRACTION RULES:\n\n"
139
+ "1. OBJECT_CLASSES — What to extract:\n"
140
+ " - Extract nouns and noun phrases that refer to PHYSICAL, VISUALLY DETECTABLE objects.\n"
141
+ " - Keep visual descriptors that narrow the category: 'small boat', 'military vehicle', 'cargo ship'.\n"
142
+ " - Use singular form: 'vessels' -> 'vessel', 'people' -> 'person'.\n"
143
+ " - If the input is already comma-separated class labels (e.g., 'person, car, boat'),\n"
144
+ " use them directly without modification.\n\n"
145
+ "2. OBJECT_CLASSES — What to strip:\n"
146
+ " - Remove threat/intent adjectives: 'hostile', 'suspicious', 'friendly', 'dangerous', 'enemy'.\n"
147
+ " -> Move these to stripped_modifiers.\n"
148
+ " - Remove action verbs: 'approaching', 'fleeing', 'attacking'.\n"
149
+ " -> Move the full phrase to context_phrases.\n"
150
+ " - Remove spatial/temporal phrases: 'from the east', 'near the harbor', 'at night'.\n"
151
+ " -> Move to context_phrases.\n"
152
+ " - Do NOT extract abstract concepts: 'threat', 'danger', 'hazard', 'risk' are not objects.\n\n"
153
+ "3. MISSION_INTENT — Infer from verbs:\n"
154
+ " - 'detect', 'find', 'locate', 'spot', 'search for' -> DETECT\n"
155
+ " - 'classify', 'identify', 'determine type of' -> CLASSIFY\n"
156
+ " - 'track', 'follow', 'monitor movement of' -> TRACK\n"
157
+ " - 'assess threat', 'evaluate danger', 'threat assessment' -> ASSESS_THREAT\n"
158
+ " - 'monitor', 'watch', 'observe', 'surveil' -> MONITOR\n"
159
+ " - If no verb present (bare class list), default to DETECT.\n\n"
160
+ "4. DOMAIN — Infer from contextual clues:\n"
161
+ " - Maritime vocabulary (vessel, ship, boat, harbor, naval, maritime, wake, sea) -> NAVAL\n"
162
+ " - Ground vocabulary (vehicle, convoy, checkpoint, road, building, infantry) -> GROUND\n"
163
+ " - Aerial vocabulary (aircraft, drone, UAV, airspace, altitude, flight) -> AERIAL\n"
164
+ " - Urban vocabulary (pedestrian, intersection, storefront, crowd, building) -> URBAN\n"
165
+ " - If no domain clues present -> GENERIC\n\n"
166
+ "5. PARSE_CONFIDENCE:\n"
167
+ " - HIGH: Clear object classes extracted, domain identifiable.\n"
168
+ " - MEDIUM: Some ambiguity but reasonable extraction possible. Include warnings.\n"
169
+ " - LOW: Cannot extract meaningful object classes. Input is too abstract,\n"
170
+ " contradictory, or contains no visual object references.\n"
171
+ " Examples of LOW: 'keep us safe', 'do your job', 'analyze everything'.\n\n"
172
+ "FORBIDDEN:\n"
173
+ "- Do NOT infer object classes not implied by the text. If the text says 'boats',\n"
174
+ " do not add 'person' or 'vehicle' unless mentioned.\n"
175
+ "- Do NOT add threat scores, engagement rules, or tactical recommendations.\n"
176
+ "- Do NOT interpret what 'threat' or 'danger' means in terms of specific objects.\n"
177
+ " If the operator writes 'detect threats', set parse_confidence to LOW and warn:\n"
178
+ " \"'threats' is not a visual object class. Specify what objects to detect.\""
179
+ )
180
+
181
+
182
+ def _call_extraction_llm(raw_text: str, detector_key: str) -> dict:
183
+ """Call GPT-4o to extract structured mission fields from natural language."""
184
+ api_key = os.environ.get("OPENAI_API_KEY")
185
+ if not api_key:
186
+ raise MissionParseError(
187
+ "OPENAI_API_KEY not set. Cannot parse natural language mission text. "
188
+ "Use comma-separated class labels instead (e.g., 'person, car, boat')."
189
+ )
190
+
191
+ detector_type = "COCO_ONLY" if _is_coco_only(detector_key) else "OPEN_VOCAB"
192
+
193
+ user_prompt = (
194
+ f'OPERATOR MISSION TEXT:\n"{raw_text}"\n\n'
195
+ f"DETECTOR TYPE: {detector_type}\n\n"
196
+ "Extract the structured mission specification from the above text."
197
+ )
198
+
199
+ payload = {
200
+ "model": "gpt-4o",
201
+ "temperature": 0.0,
202
+ "max_tokens": 500,
203
+ "response_format": {"type": "json_object"},
204
+ "messages": [
205
+ {"role": "system", "content": _SYSTEM_PROMPT},
206
+ {"role": "user", "content": user_prompt},
207
+ ],
208
+ }
209
+
210
+ headers = {
211
+ "Content-Type": "application/json",
212
+ "Authorization": f"Bearer {api_key}",
213
+ }
214
+
215
+ try:
216
+ req = urllib.request.Request(
217
+ "https://api.openai.com/v1/chat/completions",
218
+ data=json.dumps(payload).encode("utf-8"),
219
+ headers=headers,
220
+ method="POST",
221
+ )
222
+ with urllib.request.urlopen(req, timeout=30) as response:
223
+ resp_data = json.loads(response.read().decode("utf-8"))
224
+
225
+ content = resp_data["choices"][0]["message"].get("content")
226
+ if not content:
227
+ raise MissionParseError("GPT returned empty content during mission parsing.")
228
+
229
+ return json.loads(content)
230
+
231
+ except (urllib.error.HTTPError, urllib.error.URLError) as e:
232
+ raise MissionParseError(f"Mission parsing API call failed: {e}")
233
+ except json.JSONDecodeError:
234
+ raise MissionParseError(
235
+ "GPT returned invalid JSON. Please rephrase your mission."
236
+ )
237
+
238
+
239
+ def _validate_and_build(
240
+ llm_output: dict, raw_text: str, detector_key: str
241
+ ) -> MissionSpecification:
242
+ """Deterministic validation pipeline (Section 7.3 decision tree)."""
243
+
244
+ # Step 2: Extract fields with defaults
245
+ object_classes = llm_output.get("object_classes", [])
246
+ mission_intent = llm_output.get("mission_intent", "DETECT")
247
+ domain = llm_output.get("domain", "GENERIC")
248
+ context_phrases = llm_output.get("context_phrases", [])
249
+ stripped_modifiers = llm_output.get("stripped_modifiers", [])
250
+ parse_confidence = llm_output.get("parse_confidence", "LOW")
251
+ parse_warnings = llm_output.get("parse_warnings", [])
252
+
253
+ # Validate enum values
254
+ valid_intents = {"DETECT", "CLASSIFY", "TRACK", "ASSESS_THREAT", "MONITOR"}
255
+ if mission_intent not in valid_intents:
256
+ mission_intent = "DETECT"
257
+ parse_warnings.append(f"Invalid mission_intent '{llm_output.get('mission_intent')}', defaulted to DETECT.")
258
+
259
+ valid_domains = {"NAVAL", "GROUND", "AERIAL", "URBAN", "GENERIC"}
260
+ if domain not in valid_domains:
261
+ domain = "GENERIC"
262
+ parse_warnings.append(f"Invalid domain '{llm_output.get('domain')}', defaulted to GENERIC.")
263
+
264
+ valid_confidence = {"HIGH", "MEDIUM", "LOW"}
265
+ if parse_confidence not in valid_confidence:
266
+ parse_confidence = "LOW"
267
+
268
+ # Step 3: Parse confidence check
269
+ if parse_confidence == "LOW":
270
+ warnings_str = "; ".join(parse_warnings) if parse_warnings else "No details"
271
+ raise MissionParseError(
272
+ f"Could not extract object classes from mission text. "
273
+ f"Warnings: {warnings_str}. "
274
+ f"Please specify concrete objects to detect (e.g., 'vessel, small boat').",
275
+ warnings=parse_warnings,
276
+ )
277
+
278
+ # Validate object_classes is non-empty
279
+ if not object_classes:
280
+ raise MissionParseError(
281
+ "Mission text produced no detectable object classes. "
282
+ "Please specify concrete objects to detect.",
283
+ warnings=parse_warnings,
284
+ )
285
+
286
+ # Filter out empty strings
287
+ object_classes = [c.strip() for c in object_classes if c and c.strip()]
288
+ if not object_classes:
289
+ raise MissionParseError(
290
+ "All extracted object classes were empty after cleanup.",
291
+ warnings=parse_warnings,
292
+ )
293
+
294
+ # Step 4: COCO vocabulary mapping
295
+ mapped, unmappable, coco_warnings = _map_coco_classes(object_classes, detector_key)
296
+ parse_warnings.extend(coco_warnings)
297
+
298
+ if _is_coco_only(detector_key):
299
+ if not mapped:
300
+ raise MissionParseError(
301
+ f"None of the requested objects ({', '.join(object_classes)}) match the "
302
+ f"{detector_key} vocabulary. "
303
+ f"This detector supports: {coco_class_catalog()}. "
304
+ f"Use an open-vocabulary detector (Grounding DINO) or adjust your mission.",
305
+ warnings=parse_warnings,
306
+ )
307
+ final_classes = mapped
308
+ else:
309
+ final_classes = object_classes
310
+
311
+ # Step 5: Build RelevanceCriteria deterministically
312
+ relevance_criteria = RelevanceCriteria(
313
+ required_classes=final_classes,
314
+ min_confidence=0.0,
315
+ )
316
+
317
+ # Step 6: Construct MissionSpecification
318
+ return MissionSpecification(
319
+ object_classes=final_classes,
320
+ mission_intent=mission_intent,
321
+ domain=domain,
322
+ domain_source="INFERRED",
323
+ relevance_criteria=relevance_criteria,
324
+ # INVARIANT INV-13: context_phrases are forwarded to LLM reasoning layers
325
+ # (GPT threat assessment, threat chat) as situational context ONLY.
326
+ # They must NEVER be used in evaluate_relevance(), prioritization,
327
+ # or any deterministic filtering/sorting logic.
328
+ context_phrases=context_phrases,
329
+ stripped_modifiers=stripped_modifiers,
330
+ operator_text=raw_text,
331
+ parse_confidence=parse_confidence,
332
+ parse_warnings=parse_warnings,
333
+ )
334
+
335
+
336
+ def parse_mission_text(
337
+ raw_text: str,
338
+ detector_key: str,
339
+ ) -> MissionSpecification:
340
+ """Parse raw mission text into a validated MissionSpecification.
341
+
342
+ Args:
343
+ raw_text: Verbatim mission text from the operator.
344
+ detector_key: Detector model key (determines COCO vocabulary constraints).
345
+
346
+ Returns:
347
+ Validated MissionSpecification.
348
+
349
+ Raises:
350
+ MissionParseError: If mission text cannot produce a valid specification.
351
+ """
352
+ if not raw_text or not raw_text.strip():
353
+ raise MissionParseError(
354
+ "Mission text is empty. Specify objects to detect or use the default queries."
355
+ )
356
+
357
+ raw_text = raw_text.strip()
358
+
359
+ # Fast-path: simple comma-separated labels -> skip LLM
360
+ if _is_comma_separated_labels(raw_text):
361
+ object_classes = [t.strip() for t in raw_text.split(",") if t.strip()]
362
+ logger.info(
363
+ "Mission fast-path: comma-separated labels %s", object_classes
364
+ )
365
+ return _build_fast_path_spec(raw_text, object_classes, detector_key)
366
+
367
+ # LLM path: natural language mission text
368
+ logger.info("Mission LLM-path: extracting from natural language")
369
+ llm_output = _call_extraction_llm(raw_text, detector_key)
370
+ logger.info("Mission LLM extraction result: %s", llm_output)
371
+
372
+ mission_spec = _validate_and_build(llm_output, raw_text, detector_key)
373
+ logger.info(
374
+ "Mission parsed: classes=%s intent=%s domain=%s(%s) confidence=%s",
375
+ mission_spec.object_classes,
376
+ mission_spec.mission_intent,
377
+ mission_spec.domain,
378
+ mission_spec.domain_source,
379
+ mission_spec.parse_confidence,
380
+ )
381
+ return mission_spec
utils/relevance.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Object relevance evaluation — deterministic gate between detection and GPT assessment.
3
+
4
+ Single public function: evaluate_relevance(detection, criteria) -> RelevanceDecision
5
+
6
+ INVARIANT INV-13 enforcement: This function accepts RelevanceCriteria, NOT
7
+ MissionSpecification. It cannot see context_phrases, stripped_modifiers, or any
8
+ LLM-derived field. This is structural, not by convention.
9
+ """
10
+
11
+ import logging
12
+ from typing import Any, Dict, NamedTuple
13
+
14
+ from coco_classes import canonicalize_coco_name
15
+ from utils.schemas import RelevanceCriteria
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class RelevanceDecision(NamedTuple):
21
+ relevant: bool
22
+ reason: str # "ok" | "label_not_in_required_classes" | "below_confidence"
23
+
24
+
25
+ def evaluate_relevance(
26
+ detection: Dict[str, Any],
27
+ criteria: RelevanceCriteria,
28
+ ) -> RelevanceDecision:
29
+ """Evaluate whether a detection is relevant to the mission.
30
+
31
+ Pure deterministic predicate — no LLM involvement.
32
+
33
+ Args:
34
+ detection: Detection dict with at least 'label' and 'score' keys.
35
+ criteria: RelevanceCriteria with required_classes and min_confidence.
36
+
37
+ Returns:
38
+ RelevanceDecision(relevant=bool, reason=str).
39
+ """
40
+ label = (detection.get("label") or "").lower().strip()
41
+ confidence = detection.get("score", 0.0)
42
+
43
+ if not label:
44
+ return RelevanceDecision(False, "label_not_in_required_classes")
45
+
46
+ # Build lowercase set of required classes for comparison
47
+ required_lower = {c.lower() for c in criteria.required_classes}
48
+
49
+ # Direct match
50
+ if label in required_lower:
51
+ if confidence < criteria.min_confidence:
52
+ return RelevanceDecision(False, "below_confidence")
53
+ return RelevanceDecision(True, "ok")
54
+
55
+ # Synonym match via COCO canonicalization
56
+ canonical = canonicalize_coco_name(label)
57
+ if canonical and canonical.lower() in required_lower:
58
+ if confidence < criteria.min_confidence:
59
+ return RelevanceDecision(False, "below_confidence")
60
+ return RelevanceDecision(True, "ok")
61
+
62
+ # Check if any required class canonicalizes to the same COCO class as the label
63
+ if canonical:
64
+ for req in criteria.required_classes:
65
+ req_canonical = canonicalize_coco_name(req)
66
+ if req_canonical and req_canonical.lower() == canonical.lower():
67
+ if confidence < criteria.min_confidence:
68
+ return RelevanceDecision(False, "below_confidence")
69
+ return RelevanceDecision(True, "ok")
70
+
71
+ return RelevanceDecision(False, "label_not_in_required_classes")
utils/schemas.py CHANGED
@@ -1,4 +1,4 @@
1
- from pydantic import BaseModel, Field
2
  from typing import List, Optional, Literal
3
 
4
  class NavalThreatAssessment(BaseModel):
@@ -40,3 +40,107 @@ class NavalThreatAssessment(BaseModel):
40
 
41
  class FrameThreatAnalysis(BaseModel):
42
  objects: dict[str, NavalThreatAssessment] = Field(..., description="Map of Object ID (e.g., 'T01') to its assessment.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field, model_validator
2
  from typing import List, Optional, Literal
3
 
4
  class NavalThreatAssessment(BaseModel):
 
40
 
41
  class FrameThreatAnalysis(BaseModel):
42
  objects: dict[str, NavalThreatAssessment] = Field(..., description="Map of Object ID (e.g., 'T01') to its assessment.")
43
+
44
+
45
+ # --- Mission-Driven Abstractions ---
46
+
47
+
48
+ class RelevanceCriteria(BaseModel):
49
+ """Deterministic boolean predicate for filtering detections against a mission.
50
+
51
+ This is the ONLY input to evaluate_relevance(). It intentionally excludes
52
+ context_phrases, stripped_modifiers, and all LLM-derived context so that
53
+ relevance filtering remains purely deterministic (INV-13).
54
+ """
55
+ required_classes: List[str] = Field(
56
+ ..., min_length=1,
57
+ description="Object categories that satisfy the mission. "
58
+ "Detections whose label is not in this list are excluded."
59
+ )
60
+ min_confidence: float = Field(
61
+ default=0.0, ge=0.0, le=1.0,
62
+ description="Minimum detector confidence to consider a detection relevant."
63
+ )
64
+
65
+
66
+ class MissionSpecification(BaseModel):
67
+ """Structured representation of operator intent.
68
+
69
+ Created once from raw mission text at the API boundary (app.py).
70
+ Forwarded to: detector (object_classes), GPT (full spec), chat (full spec),
71
+ relevance gate (relevance_criteria only — INV-13).
72
+
73
+ INVARIANT INV-13: context_phrases are forwarded to LLM reasoning layers
74
+ (GPT threat assessment, threat chat) as situational context ONLY.
75
+ They must NEVER be used in evaluate_relevance(), prioritization,
76
+ or any deterministic filtering/sorting logic.
77
+ """
78
+
79
+ # --- Extracted by LLM or fast-path ---
80
+ object_classes: List[str] = Field(
81
+ ..., min_length=1,
82
+ description="Concrete, visually detectable object categories to detect. "
83
+ "These become detector queries. Must be nouns, not adjectives or verbs."
84
+ )
85
+ mission_intent: Literal[
86
+ "DETECT", "CLASSIFY", "TRACK", "ASSESS_THREAT", "MONITOR"
87
+ ] = Field(
88
+ ...,
89
+ description="Operator purpose. DETECT=find objects, CLASSIFY=identify type, "
90
+ "TRACK=follow over time, ASSESS_THREAT=evaluate danger, MONITOR=passive watch."
91
+ )
92
+ domain: Literal[
93
+ "NAVAL", "GROUND", "AERIAL", "URBAN", "GENERIC"
94
+ ] = Field(
95
+ ...,
96
+ description="Operational domain. Selects the GPT assessment schema and system prompt."
97
+ )
98
+ domain_source: Literal["INFERRED", "OPERATOR_SET"] = Field(
99
+ default="INFERRED",
100
+ description="Whether domain was LLM-inferred or explicitly set by operator."
101
+ )
102
+
103
+ # --- Deterministic (derived from object_classes) ---
104
+ relevance_criteria: RelevanceCriteria = Field(
105
+ ...,
106
+ description="Boolean predicate for filtering detections. "
107
+ "Built deterministically from object_classes after extraction."
108
+ )
109
+
110
+ # --- Context preservation ---
111
+ context_phrases: List[str] = Field(
112
+ default_factory=list,
113
+ description="Non-class contextual phrases from mission text. "
114
+ "E.g., 'approaching from the east', 'near the harbor'. "
115
+ "Forwarded to GPT as situational context, NOT used for detection."
116
+ )
117
+ stripped_modifiers: List[str] = Field(
118
+ default_factory=list,
119
+ description="Adjectives/modifiers removed during extraction. "
120
+ "E.g., 'hostile', 'suspicious', 'friendly'. Logged for audit."
121
+ )
122
+ operator_text: str = Field(
123
+ ...,
124
+ description="Original unmodified mission text from the operator. Preserved for audit."
125
+ )
126
+
127
+ # --- LLM self-assessment ---
128
+ parse_confidence: Literal["HIGH", "MEDIUM", "LOW"] = Field(
129
+ ...,
130
+ description="Confidence in the extraction. "
131
+ "LOW = could not reliably extract classes -> triggers rejection."
132
+ )
133
+ parse_warnings: List[str] = Field(
134
+ default_factory=list,
135
+ description="Specific issues encountered during extraction. "
136
+ "E.g., 'term \"threat\" is not a visual class, stripped'."
137
+ )
138
+
139
+ @model_validator(mode="after")
140
+ def reject_generic_threat_assessment(self):
141
+ if self.domain == "GENERIC" and self.mission_intent == "ASSESS_THREAT":
142
+ raise ValueError(
143
+ "Cannot assess threats without a specific domain. "
144
+ "Set domain to NAVAL, GROUND, AERIAL, or URBAN."
145
+ )
146
+ return self
utils/threat_chat.py CHANGED
@@ -10,20 +10,25 @@ from typing import List, Dict, Any
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.")
@@ -34,12 +39,41 @@ def chat_about_threats(question: str, detections: List[Dict[str, Any]]) -> str:
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."
 
10
  logger = logging.getLogger(__name__)
11
 
12
 
13
+ def chat_about_threats(
14
+ question: str,
15
+ detections: List[Dict[str, Any]],
16
+ mission_spec_dict: Dict[str, Any] = None,
17
+ ) -> str:
18
  """
19
  Answer user questions about detected threats using GPT.
20
+
21
  Args:
22
  question: User's question about the current threat situation.
23
  detections: List of detection dicts with gpt_raw threat analysis.
24
+ mission_spec_dict: Optional dict of mission specification fields.
25
+
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.")
 
39
 
40
  # Build threat context from detections
41
  threat_context = _build_threat_context(detections)
42
+
43
+ # Domain-aware role selection
44
+ domain = "NAVAL"
45
+ role_label = "Naval Tactical Intelligence Officer"
46
+ if mission_spec_dict:
47
+ domain = mission_spec_dict.get("domain", "NAVAL")
48
+ if domain == "GROUND":
49
+ role_label = "Ground Surveillance Intelligence Officer"
50
+ elif domain == "AERIAL":
51
+ role_label = "Air Surveillance Intelligence Officer"
52
+ elif domain == "URBAN":
53
+ role_label = "Urban Surveillance Intelligence Officer"
54
+ elif domain == "GENERIC":
55
+ role_label = "Tactical Intelligence Officer"
56
+
57
+ # Build mission context block (INV-8: mission context forwarded to LLM calls)
58
+ mission_block = ""
59
+ if mission_spec_dict:
60
+ mission_block = "\nMISSION CONTEXT:\n"
61
+ if mission_spec_dict.get("mission_intent"):
62
+ mission_block += f"- Intent: {mission_spec_dict['mission_intent']}\n"
63
+ if mission_spec_dict.get("domain"):
64
+ mission_block += f"- Domain: {mission_spec_dict['domain']}\n"
65
+ if mission_spec_dict.get("object_classes"):
66
+ mission_block += f"- Target Classes: {', '.join(mission_spec_dict['object_classes'])}\n"
67
+ if mission_spec_dict.get("context_phrases"):
68
+ mission_block += f"- Situation: {'; '.join(mission_spec_dict['context_phrases'])}\n"
69
+ mission_block += "\n"
70
+
71
  system_prompt = (
72
+ f"You are a {role_label} providing real-time threat analysis support. "
73
  "You have access to the current threat assessment data from optical surveillance. "
74
  "Answer questions concisely and tactically. Use military terminology where appropriate. "
75
  "If asked about engagement recommendations, always note that final decisions rest with the commanding officer.\n\n"
76
+ f"{mission_block}"
77
  "CURRENT THREAT PICTURE:\n"
78
  f"{threat_context}\n\n"
79
  "Respond to the operator's question based on this threat data."
utils/tracker.py CHANGED
@@ -195,6 +195,9 @@ class KalmanFilter:
195
  return ret
196
 
197
 
 
 
 
198
  GPT_SYNC_KEYS = frozenset({
199
  # Legacy fields
200
  "gpt_distance_m", "gpt_direction", "gpt_description", "gpt_raw",
@@ -207,6 +210,10 @@ GPT_SYNC_KEYS = frozenset({
207
  "special_features", "tactical_intent",
208
  # Computed fields
209
  "distance_m", "direction", "description",
 
 
 
 
210
  })
211
 
212
 
@@ -506,25 +513,28 @@ class ByteTracker:
506
 
507
  results = []
508
  for track in output_stracks:
509
- # Reconstruct dictionary
510
- # Get latest bbox from Kalman State for smoothness, or original?
511
- # Usually we use the detection box if matched, or predicted if lost (but logic above separates them).
512
- # If matched, we have updated KF.
513
-
514
  d_out = track.original_data.copy() if hasattr(track, 'original_data') else {}
515
-
516
- # Update bbox to tracked bbox? Or keep raw?
517
- # Keeping raw is safer for simple visualizer, but tracked bbox is smoother.
518
- # Let's use tracked bbox (tlbr).
519
  tracked_bbox = track.tlbr
520
  d_out['bbox'] = [float(x) for x in tracked_bbox]
521
  d_out['track_id'] = f"T{str(track.track_id).zfill(2)}"
522
-
523
  # Restore GPT data if track has it and current detection didn't
524
  for k, v in track.gpt_data.items():
525
  if k not in d_out:
526
  d_out[k] = v
527
-
 
 
 
 
 
 
 
 
 
 
 
528
  # Update history
529
  if 'history' not in track.gpt_data:
530
  track.gpt_data['history'] = []
@@ -532,9 +542,9 @@ class ByteTracker:
532
  if len(track.gpt_data['history']) > 30:
533
  track.gpt_data['history'].pop(0)
534
  d_out['history'] = track.gpt_data['history']
535
-
536
  results.append(d_out)
537
-
538
  return results
539
 
540
  def _sync_data(self, track, det_source):
@@ -553,6 +563,8 @@ class ByteTracker:
553
  Needed because GPT results are added to detection dicts *after* tracker.update()
554
  returns, so the tracker's internal state doesn't have GPT data unless we
555
  explicitly push it back in.
 
 
556
  """
557
  meta_by_tid = {}
558
  for d in tracked_dets:
@@ -561,6 +573,12 @@ class ByteTracker:
561
  continue
562
  meta = {k: d[k] for k in GPT_SYNC_KEYS if k in d}
563
  if meta:
 
 
 
 
 
 
564
  meta_by_tid[tid] = meta
565
  for track in self.tracked_stracks:
566
  tid_str = f"T{str(track.track_id).zfill(2)}"
 
195
  return ret
196
 
197
 
198
+ # Default staleness threshold: GPT metadata older than this many frames is flagged STALE
199
+ MAX_STALE_FRAMES = 300
200
+
201
  GPT_SYNC_KEYS = frozenset({
202
  # Legacy fields
203
  "gpt_distance_m", "gpt_direction", "gpt_description", "gpt_raw",
 
210
  "special_features", "tactical_intent",
211
  # Computed fields
212
  "distance_m", "direction", "description",
213
+ # Provenance and temporal validity
214
+ "assessment_frame_index", "assessment_status",
215
+ # Mission relevance
216
+ "mission_relevant", "relevance_reason",
217
  })
218
 
219
 
 
513
 
514
  results = []
515
  for track in output_stracks:
 
 
 
 
 
516
  d_out = track.original_data.copy() if hasattr(track, 'original_data') else {}
517
+
 
 
 
518
  tracked_bbox = track.tlbr
519
  d_out['bbox'] = [float(x) for x in tracked_bbox]
520
  d_out['track_id'] = f"T{str(track.track_id).zfill(2)}"
521
+
522
  # Restore GPT data if track has it and current detection didn't
523
  for k, v in track.gpt_data.items():
524
  if k not in d_out:
525
  d_out[k] = v
526
+
527
+ # --- Temporal validity check (INV-5, INV-11) ---
528
+ assessment_frame = d_out.get('assessment_frame_index')
529
+ if assessment_frame is not None:
530
+ frames_since = self.frame_id - assessment_frame
531
+ if frames_since > MAX_STALE_FRAMES:
532
+ d_out['assessment_status'] = 'STALE'
533
+ d_out['assessment_age_frames'] = frames_since
534
+ elif d_out.get('assessment_status') != 'ASSESSED':
535
+ # INV-6: Unassessed objects get explicit UNASSESSED status
536
+ d_out['assessment_status'] = 'UNASSESSED'
537
+
538
  # Update history
539
  if 'history' not in track.gpt_data:
540
  track.gpt_data['history'] = []
 
542
  if len(track.gpt_data['history']) > 30:
543
  track.gpt_data['history'].pop(0)
544
  d_out['history'] = track.gpt_data['history']
545
+
546
  results.append(d_out)
547
+
548
  return results
549
 
550
  def _sync_data(self, track, det_source):
 
563
  Needed because GPT results are added to detection dicts *after* tracker.update()
564
  returns, so the tracker's internal state doesn't have GPT data unless we
565
  explicitly push it back in.
566
+
567
+ Records assessment_frame_index for temporal validity tracking (INV-5).
568
  """
569
  meta_by_tid = {}
570
  for d in tracked_dets:
 
573
  continue
574
  meta = {k: d[k] for k in GPT_SYNC_KEYS if k in d}
575
  if meta:
576
+ # Ensure assessment_frame_index is recorded
577
+ if "assessment_frame_index" not in meta and any(
578
+ k in meta for k in ("threat_level_score", "gpt_raw", "vessel_category")
579
+ ):
580
+ meta["assessment_frame_index"] = self.frame_id
581
+ meta["assessment_status"] = "ASSESSED"
582
  meta_by_tid[tid] = meta
583
  for track in self.tracked_stracks:
584
  tid_str = f"T{str(track.track_id).zfill(2)}"