zye0616 commited on
Commit
34b56b2
·
1 Parent(s): 5995290

Update: Add mission pipeline registry

Browse files
app.py CHANGED
@@ -129,7 +129,7 @@ def _resolve_mission_plan(
129
  if not normalized_prompt:
130
  raise HTTPException(status_code=400, detail="Mission prompt is required.")
131
  _require_coordinates(latitude, longitude)
132
- plan = get_mission_plan(normalized_prompt)
133
  return plan, normalized_prompt
134
 
135
 
@@ -150,10 +150,15 @@ def _validate_inputs(
150
 
151
 
152
  def _location_only_prompt(latitude: float, longitude: float) -> str:
 
 
 
 
 
153
  return (
154
  "Threat reconnaissance mission. "
155
- f"Identify and prioritize potential hostile or suspicious object classes around latitude {latitude:.4f}, "
156
- f"longitude {longitude:.4f}. Consider common threats for this environment when selecting classes."
157
  )
158
 
159
 
@@ -164,7 +169,7 @@ async def location_context(
164
  ):
165
  prompt = _location_only_prompt(latitude, longitude)
166
  try:
167
- plan = get_mission_plan(prompt)
168
  except Exception as exc:
169
  logging.exception("Location-only planning failed.")
170
  raise HTTPException(status_code=500, detail=str(exc))
@@ -186,7 +191,7 @@ async def mission_plan_endpoint(
186
  if not normalized_prompt:
187
  raise HTTPException(status_code=400, detail="Prompt is required.")
188
  try:
189
- plan = get_mission_plan(normalized_prompt)
190
  except Exception as exc:
191
  logging.exception("Mission planning failed.")
192
  raise HTTPException(status_code=500, detail=str(exc))
 
129
  if not normalized_prompt:
130
  raise HTTPException(status_code=400, detail="Mission prompt is required.")
131
  _require_coordinates(latitude, longitude)
132
+ plan = get_mission_plan(normalized_prompt, latitude=latitude, longitude=longitude)
133
  return plan, normalized_prompt
134
 
135
 
 
150
 
151
 
152
  def _location_only_prompt(latitude: float, longitude: float) -> str:
153
+ lat_dir = "N" if latitude >= 0 else "S"
154
+ lon_dir = "E" if longitude >= 0 else "W"
155
+ lat_deg = abs(int(latitude))
156
+ lon_deg = abs(int(longitude))
157
+ grid_hint = f"{lat_deg}°{lat_dir}, {lon_deg}°{lon_dir}"
158
  return (
159
  "Threat reconnaissance mission. "
160
+ f"Identify and prioritize potential hostile or suspicious object classes near grid {grid_hint}. "
161
+ "Consider common threats for this environment when selecting classes."
162
  )
163
 
164
 
 
169
  ):
170
  prompt = _location_only_prompt(latitude, longitude)
171
  try:
172
+ plan = get_mission_plan(prompt, latitude=latitude, longitude=longitude)
173
  except Exception as exc:
174
  logging.exception("Location-only planning failed.")
175
  raise HTTPException(status_code=500, detail=str(exc))
 
191
  if not normalized_prompt:
192
  raise HTTPException(status_code=400, detail="Prompt is required.")
193
  try:
194
+ plan = get_mission_plan(normalized_prompt, latitude=latitude, longitude=longitude)
195
  except Exception as exc:
196
  logging.exception("Mission planning failed.")
197
  raise HTTPException(status_code=500, detail=str(exc))
coco_classes.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ import re
5
+ from typing import Dict, Tuple
6
+
7
+ COCO_CLASSES: Tuple[str, ...] = (
8
+ "person",
9
+ "bicycle",
10
+ "car",
11
+ "motorcycle",
12
+ "airplane",
13
+ "bus",
14
+ "train",
15
+ "truck",
16
+ "boat",
17
+ "traffic light",
18
+ "fire hydrant",
19
+ "stop sign",
20
+ "parking meter",
21
+ "bench",
22
+ "bird",
23
+ "cat",
24
+ "dog",
25
+ "horse",
26
+ "sheep",
27
+ "cow",
28
+ "elephant",
29
+ "bear",
30
+ "zebra",
31
+ "giraffe",
32
+ "backpack",
33
+ "umbrella",
34
+ "handbag",
35
+ "tie",
36
+ "suitcase",
37
+ "frisbee",
38
+ "skis",
39
+ "snowboard",
40
+ "sports ball",
41
+ "kite",
42
+ "baseball bat",
43
+ "baseball glove",
44
+ "skateboard",
45
+ "surfboard",
46
+ "tennis racket",
47
+ "bottle",
48
+ "wine glass",
49
+ "cup",
50
+ "fork",
51
+ "knife",
52
+ "spoon",
53
+ "bowl",
54
+ "banana",
55
+ "apple",
56
+ "sandwich",
57
+ "orange",
58
+ "broccoli",
59
+ "carrot",
60
+ "hot dog",
61
+ "pizza",
62
+ "donut",
63
+ "cake",
64
+ "chair",
65
+ "couch",
66
+ "potted plant",
67
+ "bed",
68
+ "dining table",
69
+ "toilet",
70
+ "tv",
71
+ "laptop",
72
+ "mouse",
73
+ "remote",
74
+ "keyboard",
75
+ "cell phone",
76
+ "microwave",
77
+ "oven",
78
+ "toaster",
79
+ "sink",
80
+ "refrigerator",
81
+ "book",
82
+ "clock",
83
+ "vase",
84
+ "scissors",
85
+ "teddy bear",
86
+ "hair drier",
87
+ "toothbrush",
88
+ )
89
+
90
+
91
+ def coco_class_catalog() -> str:
92
+ """Return the COCO classes in a comma-separated catalog for prompts."""
93
+
94
+ return ", ".join(COCO_CLASSES)
95
+
96
+
97
+ def _normalize(label: str) -> str:
98
+ return re.sub(r"[^a-z0-9]+", " ", label.lower()).strip()
99
+
100
+
101
+ _CANONICAL_LOOKUP: Dict[str, str] = {_normalize(name): name for name in COCO_CLASSES}
102
+ _COCO_SYNONYMS: Dict[str, str] = {
103
+ "people": "person",
104
+ "man": "person",
105
+ "woman": "person",
106
+ "men": "person",
107
+ "women": "person",
108
+ "motorbike": "motorcycle",
109
+ "motor bike": "motorcycle",
110
+ "bike": "bicycle",
111
+ "aircraft": "airplane",
112
+ "plane": "airplane",
113
+ "jet": "airplane",
114
+ "aeroplane": "airplane",
115
+ "pickup": "truck",
116
+ "pickup truck": "truck",
117
+ "semi": "truck",
118
+ "lorry": "truck",
119
+ "tractor trailer": "truck",
120
+ "coach": "bus",
121
+ "television": "tv",
122
+ "tv monitor": "tv",
123
+ "mobile phone": "cell phone",
124
+ "smartphone": "cell phone",
125
+ "cellphone": "cell phone",
126
+ "dinner table": "dining table",
127
+ "sofa": "couch",
128
+ "cooker": "oven",
129
+ }
130
+ _ALIAS_LOOKUP: Dict[str, str] = {_normalize(alias): canonical for alias, canonical in _COCO_SYNONYMS.items()}
131
+
132
+
133
+ def canonicalize_coco_name(value: str | None) -> str | None:
134
+ """Map an arbitrary string to the closest COCO class name if possible."""
135
+
136
+ if not value:
137
+ return None
138
+ normalized = _normalize(value)
139
+ if not normalized:
140
+ return None
141
+ if normalized in _CANONICAL_LOOKUP:
142
+ return _CANONICAL_LOOKUP[normalized]
143
+ if normalized in _ALIAS_LOOKUP:
144
+ return _ALIAS_LOOKUP[normalized]
145
+
146
+ for alias_norm, canonical in _ALIAS_LOOKUP.items():
147
+ if alias_norm and alias_norm in normalized:
148
+ return canonical
149
+ for canonical_norm, canonical in _CANONICAL_LOOKUP.items():
150
+ if canonical_norm and canonical_norm in normalized:
151
+ return canonical
152
+
153
+ tokens = normalized.split()
154
+ for token in tokens:
155
+ if token in _CANONICAL_LOOKUP:
156
+ return _CANONICAL_LOOKUP[token]
157
+ if token in _ALIAS_LOOKUP:
158
+ return _ALIAS_LOOKUP[token]
159
+
160
+ close = difflib.get_close_matches(normalized, list(_CANONICAL_LOOKUP.keys()), n=1, cutoff=0.82)
161
+ if close:
162
+ return _CANONICAL_LOOKUP[close[0]]
163
+ return None
inference.py CHANGED
@@ -6,6 +6,7 @@ import numpy as np
6
  from models.model_loader import load_detector
7
  from mission_planner import MissionPlan, get_mission_plan
8
  from mission_summarizer import summarize_results
 
9
  from utils.video import extract_frames, write_video
10
 
11
 
@@ -86,6 +87,18 @@ def run_inference(
86
  resolved_plan = mission_plan or get_mission_plan(mission_prompt_clean)
87
  logging.info("Mission plan: %s", resolved_plan.to_json())
88
  queries = resolved_plan.queries()
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  processed_frames: List[np.ndarray] = []
91
  detection_log: List[Dict[str, Any]] = []
@@ -93,7 +106,7 @@ def run_inference(
93
  if max_frames is not None and idx >= max_frames:
94
  break
95
  logging.debug("Processing frame %d", idx)
96
- processed_frame, detections = infer_frame(frame, queries, detector_name=detector_name)
97
  detection_log.append({"frame_index": idx, "detections": detections})
98
  processed_frames.append(processed_frame)
99
 
 
6
  from models.model_loader import load_detector
7
  from mission_planner import MissionPlan, get_mission_plan
8
  from mission_summarizer import summarize_results
9
+ from pipeline_registry import get_pipeline_spec
10
  from utils.video import extract_frames, write_video
11
 
12
 
 
87
  resolved_plan = mission_plan or get_mission_plan(mission_prompt_clean)
88
  logging.info("Mission plan: %s", resolved_plan.to_json())
89
  queries = resolved_plan.queries()
90
+ plan_detector = None
91
+ if resolved_plan.pipeline and resolved_plan.pipeline.primary_id:
92
+ spec = get_pipeline_spec(resolved_plan.pipeline.primary_id)
93
+ if spec:
94
+ hf_bindings = spec.get("huggingface") or {}
95
+ detections = hf_bindings.get("detection") or []
96
+ for entry in detections:
97
+ detector_key = entry.get("detector_key")
98
+ if detector_key:
99
+ plan_detector = detector_key
100
+ break
101
+ active_detector = detector_name or plan_detector
102
 
103
  processed_frames: List[np.ndarray] = []
104
  detection_log: List[Dict[str, Any]] = []
 
106
  if max_frames is not None and idx >= max_frames:
107
  break
108
  logging.debug("Processing frame %d", idx)
109
+ processed_frame, detections = infer_frame(frame, queries, detector_name=active_detector)
110
  detection_log.append({"frame_index": idx, "detections": detections})
111
  processed_frames.append(processed_frame)
112
 
mission_context.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import asdict, dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Literal, Optional, Tuple
9
+
10
+ try:
11
+ import reverse_geocoder as rg # type: ignore
12
+ except ImportError: # pragma: no cover
13
+ rg = None
14
+
15
+ try:
16
+ from timezonefinder import TimezoneFinder # type: ignore
17
+ except ImportError: # pragma: no cover
18
+ TimezoneFinder = None # type: ignore
19
+
20
+ try:
21
+ from zoneinfo import ZoneInfo
22
+ except ImportError: # pragma: no cover
23
+ ZoneInfo = None # type: ignore
24
+
25
+
26
+ MISSION_TYPE_OPTIONS: Tuple[str, ...] = (
27
+ "surveillance",
28
+ "tracking",
29
+ "threat_detection",
30
+ "safety_monitoring",
31
+ )
32
+
33
+ LOCATION_TYPE_OPTIONS: Tuple[str, ...] = (
34
+ "urban",
35
+ "suburban",
36
+ "rural",
37
+ "industrial",
38
+ "coastal",
39
+ "harbor",
40
+ "bridge",
41
+ "roadway",
42
+ "indoor",
43
+ "unknown",
44
+ )
45
+
46
+ TIME_OF_DAY_OPTIONS: Tuple[str, ...] = ("day", "night")
47
+
48
+ PRIORITY_LEVEL_OPTIONS: Tuple[str, ...] = ("routine", "elevated", "high")
49
+
50
+ INTEL_FLAG_OPTIONS: Tuple[str, ...] = ("none", "recent_incident", "elevated_activity")
51
+
52
+ SENSOR_TYPE_OPTIONS: Tuple[str, ...] = ("rgb_camera", "thermal_camera")
53
+
54
+ COMPUTE_MODE_OPTIONS: Tuple[str, ...] = ("edge", "hybrid", "cloud")
55
+
56
+ _DEFAULT_FOCUS_TERMS: Tuple[str, ...] = (
57
+ "drone",
58
+ "uav",
59
+ "vehicle",
60
+ "truck",
61
+ "convoy",
62
+ "ship",
63
+ "boat",
64
+ "aircraft",
65
+ "personnel",
66
+ "crowd",
67
+ "wildfire",
68
+ "perimeter",
69
+ "checkpoint",
70
+ "bridge",
71
+ "chemical",
72
+ "radiation",
73
+ "pipeline",
74
+ )
75
+
76
+ _KEYWORD_CONFIG_PATH = Path(__file__).with_name("mission_keywords.json")
77
+
78
+
79
+ def _load_focus_terms() -> Tuple[str, ...]:
80
+ focus_terms = list(_DEFAULT_FOCUS_TERMS)
81
+ if not _KEYWORD_CONFIG_PATH.exists():
82
+ return tuple(focus_terms)
83
+ try:
84
+ config = json.loads(_KEYWORD_CONFIG_PATH.read_text())
85
+ except Exception:
86
+ return tuple(focus_terms)
87
+ focus_overrides = config.get("focus_terms")
88
+ if isinstance(focus_overrides, (list, tuple)):
89
+ normalized_focus = [str(item).strip() for item in focus_overrides if str(item).strip()]
90
+ if normalized_focus:
91
+ focus_terms = normalized_focus
92
+ return tuple(focus_terms)
93
+
94
+
95
+ MISSION_FOCUS_KEYWORDS = _load_focus_terms()
96
+ _TIMEZONE_FINDER = TimezoneFinder() if TimezoneFinder is not None else None
97
+
98
+
99
+ @dataclass
100
+ class MissionContext:
101
+ mission_type: Literal["surveillance", "tracking", "threat_detection", "safety_monitoring"] = "surveillance"
102
+ location_type: Literal[
103
+ "urban",
104
+ "suburban",
105
+ "rural",
106
+ "industrial",
107
+ "coastal",
108
+ "harbor",
109
+ "bridge",
110
+ "roadway",
111
+ "indoor",
112
+ "unknown",
113
+ ] = "unknown"
114
+ time_of_day: Literal["day", "night"] | None = None
115
+ priority_level: Literal["routine", "elevated", "high"] | None = None
116
+ intel_flag: Literal["none", "recent_incident", "elevated_activity"] = "none"
117
+ sensor_type: Literal["rgb_camera", "thermal_camera"] = "rgb_camera"
118
+ compute_mode: Literal["edge", "hybrid", "cloud"] = "edge"
119
+
120
+ def __post_init__(self) -> None:
121
+ self._validate()
122
+
123
+ def _validate(self) -> None:
124
+ self._ensure_choice("mission_type", self.mission_type, MISSION_TYPE_OPTIONS)
125
+ self._ensure_choice("location_type", self.location_type, LOCATION_TYPE_OPTIONS)
126
+ if self.time_of_day is not None:
127
+ self._ensure_choice("time_of_day", self.time_of_day, TIME_OF_DAY_OPTIONS)
128
+ if self.priority_level is not None:
129
+ self._ensure_choice("priority_level", self.priority_level, PRIORITY_LEVEL_OPTIONS)
130
+ self._ensure_choice("intel_flag", self.intel_flag, INTEL_FLAG_OPTIONS)
131
+ self._ensure_choice("sensor_type", self.sensor_type, SENSOR_TYPE_OPTIONS)
132
+ self._ensure_choice("compute_mode", self.compute_mode, COMPUTE_MODE_OPTIONS)
133
+
134
+ @staticmethod
135
+ def _ensure_choice(field: str, value: str, choices: Tuple[str, ...]) -> None:
136
+ if value not in choices:
137
+ allowed = ", ".join(choices)
138
+ raise ValueError(f"Invalid value '{value}' for {field}. Allowed values: {allowed}.")
139
+
140
+ def to_prompt_payload(self) -> Dict[str, Any]:
141
+ payload: Dict[str, Any] = {
142
+ "mission_type": self.mission_type,
143
+ "location_type": self.location_type,
144
+ }
145
+ if self.time_of_day:
146
+ payload["time_of_day"] = self.time_of_day
147
+ return payload
148
+
149
+
150
+ @dataclass
151
+ class MissionClass:
152
+ name: str
153
+ score: float
154
+ rationale: str
155
+
156
+
157
+ @dataclass
158
+ class PipelineRecommendation:
159
+ primary_id: str | None = None
160
+ primary_reason: str | None = None
161
+ fallback_id: str | None = None
162
+ fallback_reason: str | None = None
163
+
164
+ def to_dict(self) -> Dict[str, Any]:
165
+ return {
166
+ "primary": self._entry_dict(self.primary_id, self.primary_reason),
167
+ "fallback": self._entry_dict(self.fallback_id, self.fallback_reason),
168
+ }
169
+
170
+ @staticmethod
171
+ def _entry_dict(pipeline_id: str | None, reason: str | None) -> Dict[str, Any] | None:
172
+ if not pipeline_id:
173
+ return None
174
+ from pipeline_registry import get_pipeline_spec
175
+
176
+ spec = get_pipeline_spec(pipeline_id)
177
+ return {
178
+ "id": pipeline_id,
179
+ "name": spec["id"] if spec else pipeline_id,
180
+ "reason": reason or "",
181
+ "modalities": spec.get("modalities") if spec else None,
182
+ "location_types": spec.get("location_types") if spec else None,
183
+ "time_of_day": spec.get("time_of_day") if spec else None,
184
+ "huggingface": spec.get("huggingface") if spec else None,
185
+ }
186
+
187
+
188
+ @dataclass
189
+ class MissionPlan:
190
+ mission: str
191
+ relevant_classes: List[MissionClass]
192
+ context: MissionContext | None = None
193
+ pipeline: PipelineRecommendation | None = None
194
+
195
+ def queries(self) -> List[str]:
196
+ return [entry.name for entry in self.relevant_classes]
197
+
198
+ def to_dict(self) -> Dict[str, Any]:
199
+ context_payload = self.context.to_prompt_payload() if self.context else None
200
+ return {
201
+ "mission": self.mission,
202
+ "context": context_payload,
203
+ "entities": [asdict(entry) for entry in self.relevant_classes],
204
+ "pipelines": self.pipeline.to_dict() if self.pipeline else None,
205
+ }
206
+
207
+ def to_json(self) -> str:
208
+ import json
209
+
210
+ return json.dumps(self.to_dict())
211
+
212
+
213
+ def build_prompt_hints(
214
+ mission: str,
215
+ latitude: Optional[float],
216
+ longitude: Optional[float],
217
+ ) -> Dict[str, Any]:
218
+ hints: Dict[str, Any] = {}
219
+ focus_terms = _extract_focus_terms(mission)
220
+ if focus_terms:
221
+ hints["mission_focus_terms"] = focus_terms
222
+ timezone_id = _lookup_timezone(latitude, longitude)
223
+ if timezone_id:
224
+ hints["timezone"] = timezone_id
225
+ local_time = _local_time_from_timezone(timezone_id)
226
+ if local_time:
227
+ hints["local_time"] = local_time
228
+ locality = _nearest_locality(latitude, longitude)
229
+ if locality:
230
+ hints["nearest_locality"] = locality
231
+ return hints
232
+
233
+
234
+ def _normalize(text: str) -> str:
235
+ return re.sub(r"\s+", " ", text.lower()).strip()
236
+
237
+
238
+ def _extract_focus_terms(mission: str) -> List[str]:
239
+ text = _normalize(mission)
240
+ matched = [term for term in MISSION_FOCUS_KEYWORDS if term in text]
241
+ return matched[:5]
242
+
243
+
244
+ def _lookup_timezone(latitude: Optional[float], longitude: Optional[float]) -> Optional[str]:
245
+ if latitude is None or longitude is None or _TIMEZONE_FINDER is None:
246
+ return None
247
+ try:
248
+ return _TIMEZONE_FINDER.timezone_at(lat=latitude, lng=longitude)
249
+ except Exception:
250
+ return None
251
+
252
+
253
+ def _local_time_from_timezone(timezone_id: Optional[str]) -> Optional[str]:
254
+ if timezone_id is None or ZoneInfo is None:
255
+ return None
256
+ try:
257
+ tz = ZoneInfo(timezone_id)
258
+ except Exception:
259
+ return None
260
+ return datetime.now(tz).isoformat(timespec="seconds")
261
+
262
+
263
+ def _nearest_locality(latitude: Optional[float], longitude: Optional[float]) -> Optional[str]:
264
+ if latitude is None or longitude is None or rg is None:
265
+ return None
266
+ try:
267
+ results = rg.search((latitude, longitude), mode=1) # type: ignore[arg-type]
268
+ except Exception:
269
+ return None
270
+ if not results:
271
+ return None
272
+ match = results[0]
273
+ city = (match.get("name") or "").strip()
274
+ admin = (match.get("admin1") or "").strip()
275
+ country = (match.get("cc") or "").strip()
276
+ components = [component for component in (city, admin, country) if component]
277
+ return ", ".join(components) if components else None
mission_planner.py CHANGED
@@ -2,125 +2,35 @@ from __future__ import annotations
2
 
3
  import json
4
  import logging
5
- from dataclasses import asdict, dataclass
6
- from typing import Dict, List, Tuple
 
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  from prompt import mission_planner_system_prompt, mission_planner_user_prompt
9
  from utils.openai_client import get_openai_client
10
 
11
 
12
- YOLO_CLASSES: Tuple[str, ...] = (
13
- "person",
14
- "bicycle",
15
- "car",
16
- "motorcycle",
17
- "airplane",
18
- "bus",
19
- "train",
20
- "truck",
21
- "boat",
22
- "traffic light",
23
- "fire hydrant",
24
- "stop sign",
25
- "parking meter",
26
- "bench",
27
- "bird",
28
- "cat",
29
- "dog",
30
- "horse",
31
- "sheep",
32
- "cow",
33
- "elephant",
34
- "bear",
35
- "zebra",
36
- "giraffe",
37
- "backpack",
38
- "umbrella",
39
- "handbag",
40
- "tie",
41
- "suitcase",
42
- "frisbee",
43
- "skis",
44
- "snowboard",
45
- "sports ball",
46
- "kite",
47
- "baseball bat",
48
- "baseball glove",
49
- "skateboard",
50
- "surfboard",
51
- "tennis racket",
52
- "bottle",
53
- "wine glass",
54
- "cup",
55
- "fork",
56
- "knife",
57
- "spoon",
58
- "bowl",
59
- "banana",
60
- "apple",
61
- "sandwich",
62
- "orange",
63
- "broccoli",
64
- "carrot",
65
- "hot dog",
66
- "pizza",
67
- "donut",
68
- "cake",
69
- "chair",
70
- "couch",
71
- "potted plant",
72
- "bed",
73
- "dining table",
74
- "toilet",
75
- "tv",
76
- "laptop",
77
- "mouse",
78
- "remote",
79
- "keyboard",
80
- "cell phone",
81
- "microwave",
82
- "oven",
83
- "toaster",
84
- "sink",
85
- "refrigerator",
86
- "book",
87
- "clock",
88
- "vase",
89
- "scissors",
90
- "teddy bear",
91
- "hair drier",
92
- "toothbrush",
93
- )
94
-
95
-
96
  DEFAULT_OPENAI_MODEL = "gpt-4o-mini"
97
 
98
 
99
- @dataclass
100
- class MissionClass:
101
- name: str
102
- score: float
103
- rationale: str
104
-
105
-
106
- @dataclass
107
- class MissionPlan:
108
- mission: str
109
- relevant_classes: List[MissionClass]
110
-
111
- def queries(self) -> List[str]:
112
- return [entry.name for entry in self.relevant_classes]
113
-
114
- def to_dict(self) -> dict:
115
- return {
116
- "mission": self.mission,
117
- "classes": [asdict(entry) for entry in self.relevant_classes],
118
- }
119
-
120
- def to_json(self) -> str:
121
- return json.dumps(self.to_dict())
122
-
123
-
124
  class MissionReasoner:
125
  def __init__(
126
  self,
@@ -130,22 +40,133 @@ class MissionReasoner:
130
  ) -> None:
131
  self._model_name = model_name
132
  self._top_k = top_k
 
133
 
134
- def plan(self, mission: str) -> MissionPlan:
 
 
 
 
 
 
135
  mission = (mission or "").strip()
136
  if not mission:
137
  raise ValueError("Mission prompt cannot be empty.")
138
- response_payload = self._query_llm(mission)
 
 
 
 
 
 
 
 
139
  relevant = self._parse_plan(response_payload, fallback_mission=mission)
140
- return MissionPlan(mission=response_payload.get("mission", mission), relevant_classes=relevant[: self._top_k])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
- def _query_llm(self, mission: str) -> Dict[str, object]:
 
 
 
 
 
 
 
143
  client = get_openai_client()
144
  system_prompt = mission_planner_system_prompt()
145
- user_prompt = mission_planner_user_prompt(mission, YOLO_CLASSES, self._top_k)
 
 
 
 
 
 
 
 
146
  completion = client.chat.completions.create(
147
  model=self._model_name,
148
- temperature=0.2,
149
  response_format={"type": "json_object"},
150
  messages=[
151
  {"role": "system", "content": system_prompt},
@@ -160,7 +181,12 @@ class MissionReasoner:
160
  return {"mission": mission, "classes": []}
161
 
162
  def _parse_plan(self, payload: Dict[str, object], fallback_mission: str) -> List[MissionClass]:
163
- entries = payload.get("classes") or payload.get("relevant_classes") or []
 
 
 
 
 
164
  mission = payload.get("mission") or fallback_mission
165
  parsed: List[MissionClass] = []
166
  seen = set()
@@ -168,35 +194,204 @@ class MissionReasoner:
168
  if not isinstance(entry, dict):
169
  continue
170
  name = str(entry.get("name") or "").strip()
171
- if not name or name not in YOLO_CLASSES or name in seen:
 
 
 
 
172
  continue
173
- seen.add(name)
 
 
174
  score_raw = entry.get("score")
175
  try:
176
  score = float(score_raw)
177
  except (TypeError, ValueError):
178
  score = 0.5
179
  rationale = str(entry.get("rationale") or f"Track '{name}' for mission '{mission}'.")
180
- parsed.append(MissionClass(name=name, score=max(0.0, min(1.0, score)), rationale=rationale))
181
-
182
- if not parsed:
183
- logging.warning("LLM returned no usable classes. Falling back to default YOLO list.")
184
- parsed = [
185
  MissionClass(
186
- name=label,
187
- score=1.0 - (idx * 0.05),
188
- rationale=f"Fallback selection for mission '{mission}'.",
189
  )
190
- for idx, label in enumerate(YOLO_CLASSES[: self._top_k])
191
- ]
 
 
192
  return parsed
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  _REASONER: MissionReasoner | None = None
196
 
197
 
198
- def get_mission_plan(mission: str) -> MissionPlan:
 
 
 
 
 
 
199
  global _REASONER
200
  if _REASONER is None:
201
  _REASONER = MissionReasoner()
202
- return _REASONER.plan(mission)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import json
4
  import logging
5
+ from dataclasses import asdict, dataclass, replace
6
+ from datetime import datetime
7
+ from typing import Any, Dict, List, Mapping, Tuple
8
 
9
+ from coco_classes import canonicalize_coco_name, coco_class_catalog
10
+ from mission_context import (
11
+ MissionClass,
12
+ MissionContext,
13
+ MissionPlan,
14
+ MISSION_TYPE_OPTIONS,
15
+ LOCATION_TYPE_OPTIONS,
16
+ TIME_OF_DAY_OPTIONS,
17
+ PRIORITY_LEVEL_OPTIONS,
18
+ PipelineRecommendation,
19
+ build_prompt_hints,
20
+ )
21
+ from pipeline_registry import (
22
+ PIPELINE_SPECS,
23
+ fallback_pipeline_for_context,
24
+ filter_pipelines_for_context,
25
+ get_pipeline_spec,
26
+ )
27
  from prompt import mission_planner_system_prompt, mission_planner_user_prompt
28
  from utils.openai_client import get_openai_client
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  DEFAULT_OPENAI_MODEL = "gpt-4o-mini"
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  class MissionReasoner:
35
  def __init__(
36
  self,
 
40
  ) -> None:
41
  self._model_name = model_name
42
  self._top_k = top_k
43
+ self._coco_catalog = coco_class_catalog()
44
 
45
+ def plan(
46
+ self,
47
+ mission: str,
48
+ *,
49
+ context: MissionContext,
50
+ cues: Mapping[str, Any] | None = None,
51
+ ) -> MissionPlan:
52
  mission = (mission or "").strip()
53
  if not mission:
54
  raise ValueError("Mission prompt cannot be empty.")
55
+ available_pipelines = self._candidate_pipelines(mission, context, cues)
56
+ candidate_ids = [spec["id"] for spec in available_pipelines] or [PIPELINE_SPECS[0]["id"]]
57
+ lock_pipeline_id = candidate_ids[0] if len(candidate_ids) == 1 else None
58
+ response_payload = self._query_llm(
59
+ mission,
60
+ context=context,
61
+ cues=None,
62
+ pipeline_ids=candidate_ids,
63
+ )
64
  relevant = self._parse_plan(response_payload, fallback_mission=mission)
65
+ enriched_context = self._merge_context(context, response_payload.get("context"))
66
+ if lock_pipeline_id:
67
+ pipeline_rec = PipelineRecommendation(
68
+ primary_id=lock_pipeline_id,
69
+ primary_reason="Only pipeline compatible with mission context.",
70
+ )
71
+ else:
72
+ pipeline_rec = self._parse_pipeline_recommendation(
73
+ response_payload.get("pipelines") or response_payload.get("pipeline"),
74
+ available_pipelines,
75
+ context,
76
+ )
77
+ return MissionPlan(
78
+ mission=response_payload.get("mission", mission),
79
+ relevant_classes=relevant[: self._top_k],
80
+ context=enriched_context,
81
+ pipeline=pipeline_rec,
82
+ )
83
+
84
+ def _render_pipeline_catalog(self, specs: List[Dict[str, object]]) -> str:
85
+ if not specs:
86
+ return "No compatible pipelines available."
87
+ sections: List[str] = []
88
+ for spec in specs:
89
+ reason = spec.get("availability_reason") or "Compatible with mission context."
90
+ hf_bindings = spec.get("huggingface") or {}
91
+
92
+ def _format_models(models: List[Dict[str, object]]) -> str:
93
+ if not models:
94
+ return "none"
95
+ labels = []
96
+ for entry in models:
97
+ model_id = entry.get("model_id") or entry.get("name") or "unknown"
98
+ label = entry.get("label") or model_id
99
+ suffix = " (optional)" if entry.get("optional") else ""
100
+ labels.append(f"{label}{suffix}")
101
+ return ", ".join(labels)
102
+
103
+ detection_models = _format_models(hf_bindings.get("detection", []))
104
+ segmentation_models = _format_models(hf_bindings.get("segmentation", []))
105
+ tracking_models = _format_models(hf_bindings.get("tracking", []))
106
+ hf_notes = hf_bindings.get("notes") or ""
107
+ sections.append(
108
+ "\n".join(
109
+ [
110
+ f"{spec['id']} pipeline",
111
+ f" Modalities: {', '.join(spec.get('modalities', ())) or 'unspecified'}",
112
+ f" Locations: {', '.join(spec.get('location_types', ())) or 'any'}",
113
+ f" Time of day: {', '.join(spec.get('time_of_day', ())) or 'any'}",
114
+ f" Availability: {reason}",
115
+ f" HF detection: {detection_models}",
116
+ f" HF segmentation: {segmentation_models}",
117
+ f" Tracking: {tracking_models}",
118
+ f" Notes: {hf_notes or 'n/a'}",
119
+ ]
120
+ )
121
+ )
122
+ return "\n\n".join(sections)
123
+
124
+ def _candidate_pipelines(
125
+ self,
126
+ mission: str,
127
+ context: MissionContext,
128
+ cues: Mapping[str, Any] | None,
129
+ ) -> List[Dict[str, object]]:
130
+ filtered = filter_pipelines_for_context(context)
131
+ if filtered:
132
+ return filtered
133
+ fallback_spec = fallback_pipeline_for_context(context, [])
134
+ if fallback_spec is None:
135
+ logging.error("No fallback pipeline available; mission context=%s", context)
136
+ return [dict(spec) for spec in PIPELINE_SPECS]
137
+ logging.warning(
138
+ "No compatible pipelines for context %s; selecting fallback %s.",
139
+ context,
140
+ fallback_spec["id"],
141
+ )
142
+ fallback_copy = dict(fallback_spec)
143
+ fallback_copy["availability_reason"] = (
144
+ "Fallback engaged because no specialized pipeline matched this mission context."
145
+ )
146
+ return [fallback_copy]
147
 
148
+ def _query_llm(
149
+ self,
150
+ mission: str,
151
+ *,
152
+ context: MissionContext,
153
+ cues: Mapping[str, Any] | None = None,
154
+ pipeline_ids: List[str] | None,
155
+ ) -> Dict[str, object]:
156
  client = get_openai_client()
157
  system_prompt = mission_planner_system_prompt()
158
+ context_payload = context.to_prompt_payload()
159
+ user_prompt = mission_planner_user_prompt(
160
+ mission,
161
+ self._top_k,
162
+ context=context_payload,
163
+ cues=cues,
164
+ pipeline_candidates=pipeline_ids,
165
+ coco_catalog=self._coco_catalog,
166
+ )
167
  completion = client.chat.completions.create(
168
  model=self._model_name,
169
+ temperature=0.1,
170
  response_format={"type": "json_object"},
171
  messages=[
172
  {"role": "system", "content": system_prompt},
 
181
  return {"mission": mission, "classes": []}
182
 
183
  def _parse_plan(self, payload: Dict[str, object], fallback_mission: str) -> List[MissionClass]:
184
+ entries = (
185
+ payload.get("entities")
186
+ or payload.get("classes")
187
+ or payload.get("relevant_classes")
188
+ or []
189
+ )
190
  mission = payload.get("mission") or fallback_mission
191
  parsed: List[MissionClass] = []
192
  seen = set()
 
194
  if not isinstance(entry, dict):
195
  continue
196
  name = str(entry.get("name") or "").strip()
197
+ if not name:
198
+ continue
199
+ canonical_name = canonicalize_coco_name(name)
200
+ if not canonical_name:
201
+ logging.warning("Skipping non-COCO entity '%s'.", name)
202
  continue
203
+ if canonical_name in seen:
204
+ continue
205
+ seen.add(canonical_name)
206
  score_raw = entry.get("score")
207
  try:
208
  score = float(score_raw)
209
  except (TypeError, ValueError):
210
  score = 0.5
211
  rationale = str(entry.get("rationale") or f"Track '{name}' for mission '{mission}'.")
212
+ parsed.append(
 
 
 
 
213
  MissionClass(
214
+ name=canonical_name,
215
+ score=max(0.0, min(1.0, score)),
216
+ rationale=rationale,
217
  )
218
+ )
219
+
220
+ if not parsed:
221
+ raise RuntimeError("LLM returned no semantic entities; aborting instead of fabricating outputs.")
222
  return parsed
223
 
224
+ def _merge_context(
225
+ self,
226
+ base_context: MissionContext,
227
+ context_payload: Dict[str, object] | None,
228
+ ) -> MissionContext:
229
+ payload = context_payload or {}
230
+ if not isinstance(payload, dict):
231
+ return base_context
232
+
233
+ def _coerce_choice(value: object | None, allowed: Tuple[str, ...]) -> str | None:
234
+ if value is None:
235
+ return None
236
+ candidate = str(value).strip().lower()
237
+ return candidate if candidate in allowed else None
238
+
239
+ updates: Dict[str, Any] = {}
240
+ new_mission_type = _coerce_choice(payload.get("mission_type"), MISSION_TYPE_OPTIONS)
241
+ new_location_type = _coerce_choice(payload.get("location_type"), LOCATION_TYPE_OPTIONS)
242
+ new_time_of_day = _coerce_choice(payload.get("time_of_day"), TIME_OF_DAY_OPTIONS)
243
+ new_priority = _coerce_choice(payload.get("priority_level"), PRIORITY_LEVEL_OPTIONS)
244
+
245
+ if new_mission_type:
246
+ updates["mission_type"] = new_mission_type
247
+ if new_location_type:
248
+ updates["location_type"] = new_location_type
249
+ if new_time_of_day:
250
+ updates["time_of_day"] = new_time_of_day
251
+ if new_priority:
252
+ updates["priority_level"] = new_priority
253
+
254
+ if not updates:
255
+ return base_context
256
+ return replace(base_context, **updates)
257
+
258
+ def _parse_pipeline_recommendation(
259
+ self,
260
+ payload: object,
261
+ available_specs: List[Dict[str, object]],
262
+ context: MissionContext,
263
+ ) -> PipelineRecommendation | None:
264
+ if not isinstance(payload, dict):
265
+ return self._validate_pipeline_selection(None, available_specs, context)
266
+
267
+ if "id" in payload or "pipeline_id" in payload or "pipeline" in payload:
268
+ pipeline_id_raw = payload.get("id") or payload.get("pipeline_id") or payload.get("pipeline")
269
+ pipeline_id = str(pipeline_id_raw or "").strip()
270
+ reason = str(payload.get("reason") or "").strip() or None
271
+ candidate = PipelineRecommendation(primary_id=pipeline_id or None, primary_reason=reason)
272
+ return self._validate_pipeline_selection(candidate, available_specs, context)
273
+
274
+ def _extract_entry(entry_key: str) -> tuple[str | None, str | None]:
275
+ value = payload.get(entry_key)
276
+ if not isinstance(value, dict):
277
+ return None, None
278
+ pipeline_id_raw = value.get("id") or value.get("pipeline_id") or value.get("pipeline")
279
+ pipeline_id = str(pipeline_id_raw).strip()
280
+ if not pipeline_id:
281
+ return None, None
282
+ if not get_pipeline_spec(pipeline_id):
283
+ return None, None
284
+ reason = str(value.get("reason") or "").strip() or None
285
+ return pipeline_id, reason
286
+
287
+ primary_id, primary_reason = _extract_entry("primary")
288
+ fallback_id, fallback_reason = _extract_entry("fallback")
289
+
290
+ rec = PipelineRecommendation(
291
+ primary_id=primary_id,
292
+ primary_reason=primary_reason,
293
+ fallback_id=fallback_id,
294
+ fallback_reason=fallback_reason,
295
+ )
296
+ return self._validate_pipeline_selection(rec, available_specs, context)
297
+
298
+ def _validate_pipeline_selection(
299
+ self,
300
+ candidate: PipelineRecommendation | None,
301
+ available_specs: List[Dict[str, object]],
302
+ context: MissionContext,
303
+ ) -> PipelineRecommendation | None:
304
+ if not available_specs:
305
+ return None
306
+ available_ids = {spec["id"] for spec in available_specs}
307
+
308
+ def _normalize_reason(reason: str | None, default: str) -> str:
309
+ text = (reason or "").strip()
310
+ return text or default
311
+
312
+ primary_id = candidate.primary_id if candidate and candidate.primary_id in available_ids else None
313
+ if not primary_id:
314
+ fallback_spec = fallback_pipeline_for_context(context, available_specs)
315
+ if fallback_spec is None:
316
+ logging.warning("No pipelines available even after fallback.")
317
+ return None
318
+ logging.warning(
319
+ "Pipeline recommendation invalid or missing. Defaulting to %s.", fallback_spec["id"]
320
+ )
321
+ return PipelineRecommendation(
322
+ primary_id=fallback_spec["id"],
323
+ primary_reason=_normalize_reason(
324
+ candidate.primary_reason if candidate else None,
325
+ "Auto-selected based on available sensors and context.",
326
+ ),
327
+ fallback_id=None,
328
+ fallback_reason=None,
329
+ )
330
+
331
+ primary_reason = _normalize_reason(candidate.primary_reason if candidate else None, "LLM-selected.")
332
+
333
+ fallback_allowed = context.priority_level in {"elevated", "high"}
334
+ fallback_id = candidate.fallback_id if candidate else None
335
+ fallback_reason = candidate.fallback_reason if candidate else None
336
+
337
+ if not fallback_allowed or fallback_id not in available_ids or fallback_id == primary_id:
338
+ if fallback_id:
339
+ logging.info("Dropping fallback pipeline %s due to priority/context constraints.", fallback_id)
340
+ fallback_id_valid = None
341
+ fallback_reason_valid = None
342
+ else:
343
+ fallback_id_valid = fallback_id
344
+ fallback_reason_valid = _normalize_reason(fallback_reason, "Fallback allowed due to priority level.")
345
+
346
+ return PipelineRecommendation(
347
+ primary_id=primary_id,
348
+ primary_reason=primary_reason,
349
+ fallback_id=fallback_id_valid,
350
+ fallback_reason=fallback_reason_valid,
351
+ )
352
+
353
 
354
  _REASONER: MissionReasoner | None = None
355
 
356
 
357
+ def get_mission_plan(
358
+ mission: str,
359
+ *,
360
+ latitude: float | None = None,
361
+ longitude: float | None = None,
362
+ context_overrides: MissionContext | None = None,
363
+ ) -> MissionPlan:
364
  global _REASONER
365
  if _REASONER is None:
366
  _REASONER = MissionReasoner()
367
+ context = context_overrides or MissionContext()
368
+ cues = build_prompt_hints(mission, latitude, longitude)
369
+ if latitude is not None and longitude is not None:
370
+ logging.info("Mission location coordinates: lat=%s, lon=%s", latitude, longitude)
371
+ local_time_hint = cues.get("local_time") if isinstance(cues, Mapping) else None
372
+ if local_time_hint:
373
+ logging.info("Derived local mission time: %s", local_time_hint)
374
+ timezone_hint = cues.get("timezone") if isinstance(cues, Mapping) else None
375
+ if timezone_hint:
376
+ logging.info("Derived local timezone: %s", timezone_hint)
377
+ locality_hint = cues.get("nearest_locality") if isinstance(cues, Mapping) else None
378
+ if locality_hint:
379
+ logging.info("Reverse geocoded locality: %s", locality_hint)
380
+ inferred_time = _infer_time_of_day_from_cues(context, cues)
381
+ if inferred_time and context.time_of_day != inferred_time:
382
+ context = replace(context, time_of_day=inferred_time)
383
+ return _REASONER.plan(mission, context=context, cues=cues)
384
+
385
+
386
+ def _infer_time_of_day_from_cues(context: MissionContext, cues: Mapping[str, Any] | None) -> str | None:
387
+ if context.time_of_day or not cues:
388
+ return context.time_of_day
389
+ local_time_raw = cues.get("local_time") if isinstance(cues, Mapping) else None
390
+ if not local_time_raw:
391
+ return None
392
+ try:
393
+ local_dt = datetime.fromisoformat(str(local_time_raw))
394
+ except (ValueError, TypeError):
395
+ return None
396
+ hour = local_dt.hour
397
+ return "day" if 6 <= hour < 18 else "night"
mission_planner_cli.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Command-line helper to run the mission planner independently."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import logging
9
+ import sys
10
+ from typing import Dict, Any
11
+
12
+ from mission_context import (
13
+ COMPUTE_MODE_OPTIONS,
14
+ INTEL_FLAG_OPTIONS,
15
+ LOCATION_TYPE_OPTIONS,
16
+ MISSION_TYPE_OPTIONS,
17
+ SENSOR_TYPE_OPTIONS,
18
+ TIME_OF_DAY_OPTIONS,
19
+ PRIORITY_LEVEL_OPTIONS,
20
+ MissionContext,
21
+ )
22
+ from mission_planner import get_mission_plan
23
+
24
+
25
+ def _parse_args() -> argparse.Namespace:
26
+ parser = argparse.ArgumentParser(
27
+ description="Run the mission planner with a prompt and location context."
28
+ )
29
+ parser.add_argument(
30
+ "--prompt",
31
+ required=True,
32
+ help="Mission description to plan for.",
33
+ )
34
+ parser.add_argument(
35
+ "--latitude",
36
+ type=float,
37
+ required=True,
38
+ help="Latitude for the current operation.",
39
+ )
40
+ parser.add_argument(
41
+ "--longitude",
42
+ type=float,
43
+ required=True,
44
+ help="Longitude for the current operation.",
45
+ )
46
+ parser.add_argument(
47
+ "--mission-type",
48
+ choices=MISSION_TYPE_OPTIONS,
49
+ help="Optional override for mission type.",
50
+ )
51
+ parser.add_argument(
52
+ "--location-type",
53
+ choices=LOCATION_TYPE_OPTIONS,
54
+ help="Optional override for location type.",
55
+ )
56
+ parser.add_argument(
57
+ "--time-of-day",
58
+ choices=TIME_OF_DAY_OPTIONS,
59
+ help="Optional override for time of day.",
60
+ )
61
+ parser.add_argument(
62
+ "--priority-level",
63
+ choices=PRIORITY_LEVEL_OPTIONS,
64
+ help="Optional override for mission priority.",
65
+ )
66
+ parser.add_argument(
67
+ "--intel-flag",
68
+ choices=INTEL_FLAG_OPTIONS,
69
+ help="Operator-set intel status override.",
70
+ )
71
+ parser.add_argument(
72
+ "--sensor-type",
73
+ choices=SENSOR_TYPE_OPTIONS,
74
+ help="Override for primary sensor type.",
75
+ )
76
+ parser.add_argument(
77
+ "--compute-mode",
78
+ choices=COMPUTE_MODE_OPTIONS,
79
+ help="Override for deployment compute mode.",
80
+ )
81
+ parser.add_argument(
82
+ "--indent",
83
+ type=int,
84
+ default=2,
85
+ help="Pretty-print JSON using this indent (set to 0 for compact output).",
86
+ )
87
+ return parser.parse_args()
88
+
89
+
90
+ def main() -> int:
91
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
92
+ args = _parse_args()
93
+ prompt = (args.prompt or "").strip()
94
+ if not prompt:
95
+ print("Prompt cannot be empty.", file=sys.stderr)
96
+ return 1
97
+
98
+ context_kwargs: Dict[str, Any] = {}
99
+ if args.mission_type:
100
+ context_kwargs["mission_type"] = args.mission_type
101
+ if args.location_type:
102
+ context_kwargs["location_type"] = args.location_type
103
+ if args.time_of_day:
104
+ context_kwargs["time_of_day"] = args.time_of_day
105
+ if args.priority_level:
106
+ context_kwargs["priority_level"] = args.priority_level
107
+ if args.intel_flag:
108
+ context_kwargs["intel_flag"] = args.intel_flag
109
+ if args.sensor_type:
110
+ context_kwargs["sensor_type"] = args.sensor_type
111
+ if args.compute_mode:
112
+ context_kwargs["compute_mode"] = args.compute_mode
113
+ context = MissionContext(**context_kwargs) if context_kwargs else None
114
+ plan = get_mission_plan(
115
+ prompt,
116
+ latitude=args.latitude,
117
+ longitude=args.longitude,
118
+ context_overrides=context,
119
+ )
120
+ indent = None if args.indent <= 0 else args.indent
121
+ print(json.dumps(plan.to_dict(), indent=indent))
122
+ return 0
123
+
124
+
125
+ if __name__ == "__main__":
126
+ raise SystemExit(main())
models/detectors/detr.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Sequence
3
+
4
+ import numpy as np
5
+ import torch
6
+ from transformers import DetrForObjectDetection, DetrImageProcessor
7
+
8
+ from models.detectors.base import DetectionResult, ObjectDetector
9
+
10
+
11
+ class DetrDetector(ObjectDetector):
12
+ """Wrapper around facebook/detr-resnet-50 for mission-aligned detection."""
13
+
14
+ MODEL_NAME = "facebook/detr-resnet-50"
15
+
16
+ def __init__(self, score_threshold: float = 0.3) -> None:
17
+ self.name = "detr_resnet50"
18
+ self.score_threshold = score_threshold
19
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
20
+ logging.info("Loading %s onto %s", self.MODEL_NAME, self.device)
21
+ self.processor = DetrImageProcessor.from_pretrained(self.MODEL_NAME)
22
+ self.model = DetrForObjectDetection.from_pretrained(self.MODEL_NAME)
23
+ self.model.to(self.device)
24
+ self.model.eval()
25
+
26
+ def predict(self, frame: np.ndarray, queries: Sequence[str]) -> DetectionResult:
27
+ inputs = self.processor(images=frame, return_tensors="pt")
28
+ inputs = {key: value.to(self.device) for key, value in inputs.items()}
29
+ with torch.no_grad():
30
+ outputs = self.model(**inputs)
31
+ target_sizes = torch.tensor([frame.shape[:2]], device=self.device)
32
+ processed = self.processor.post_process_object_detection(
33
+ outputs,
34
+ threshold=self.score_threshold,
35
+ target_sizes=target_sizes,
36
+ )[0]
37
+ boxes = processed["boxes"].cpu().numpy()
38
+ scores = processed["scores"].cpu().tolist()
39
+ labels = processed["labels"].cpu().tolist()
40
+ label_names = [
41
+ self.model.config.id2label.get(int(idx), f"class_{idx}") for idx in labels
42
+ ]
43
+ return DetectionResult(
44
+ boxes=boxes,
45
+ scores=scores,
46
+ labels=labels,
47
+ label_names=label_names,
48
+ )
models/detectors/grounding_dino.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Sequence
3
+
4
+ import numpy as np
5
+ import torch
6
+ from transformers import GroundingDinoForObjectDetection, GroundingDinoProcessor
7
+
8
+ from models.detectors.base import DetectionResult, ObjectDetector
9
+
10
+
11
+ class GroundingDinoDetector(ObjectDetector):
12
+ """IDEA-Research Grounding DINO-B detector for open-vocabulary missions."""
13
+
14
+ MODEL_NAME = "IDEA-Research/grounding-dino-base"
15
+
16
+ def __init__(self, box_threshold: float = 0.35, text_threshold: float = 0.25) -> None:
17
+ self.name = "grounding_dino"
18
+ self.box_threshold = box_threshold
19
+ self.text_threshold = text_threshold
20
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
21
+ logging.info("Loading %s onto %s", self.MODEL_NAME, self.device)
22
+ self.processor = GroundingDinoProcessor.from_pretrained(self.MODEL_NAME)
23
+ self.model = GroundingDinoForObjectDetection.from_pretrained(self.MODEL_NAME)
24
+ self.model.to(self.device)
25
+ self.model.eval()
26
+
27
+ def _build_prompt(self, queries: Sequence[str]) -> str:
28
+ filtered = [query.strip() for query in queries if query and query.strip()]
29
+ if not filtered:
30
+ return "object."
31
+ return " ".join(f"{term}." for term in filtered)
32
+
33
+ def predict(self, frame: np.ndarray, queries: Sequence[str]) -> DetectionResult:
34
+ prompt = self._build_prompt(queries)
35
+ inputs = self.processor(images=frame, text=prompt, return_tensors="pt")
36
+ inputs = {key: value.to(self.device) for key, value in inputs.items()}
37
+ with torch.no_grad():
38
+ outputs = self.model(**inputs)
39
+ target_sizes = torch.tensor([frame.shape[:2]], device=self.device)
40
+ processed = self.processor.post_process_grounded_object_detection(
41
+ outputs,
42
+ inputs["input_ids"],
43
+ box_threshold=self.box_threshold,
44
+ text_threshold=self.text_threshold,
45
+ target_sizes=target_sizes,
46
+ )[0]
47
+ boxes = processed["boxes"].cpu().numpy()
48
+ scores = processed["scores"].cpu().tolist()
49
+ label_names = list(processed.get("labels") or [])
50
+ label_ids = list(range(len(label_names)))
51
+ return DetectionResult(
52
+ boxes=boxes,
53
+ scores=scores,
54
+ labels=label_ids,
55
+ label_names=label_names,
56
+ )
models/detectors/owlv2.py CHANGED
@@ -9,7 +9,7 @@ from models.detectors.base import DetectionResult, ObjectDetector
9
 
10
 
11
  class Owlv2Detector(ObjectDetector):
12
- MODEL_NAME = "google/owlv2-large-patch14"
13
 
14
  def __init__(self) -> None:
15
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
@@ -21,7 +21,7 @@ class Owlv2Detector(ObjectDetector):
21
  )
22
  self.model.to(self.device)
23
  self.model.eval()
24
- self.name = "owlv2"
25
 
26
  def predict(self, frame: np.ndarray, queries: Sequence[str]) -> DetectionResult:
27
  inputs = self.processor(text=queries, images=frame, return_tensors="pt")
 
9
 
10
 
11
  class Owlv2Detector(ObjectDetector):
12
+ MODEL_NAME = "google/owlv2-base-patch32"
13
 
14
  def __init__(self) -> None:
15
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
 
21
  )
22
  self.model.to(self.device)
23
  self.model.eval()
24
+ self.name = "owlv2_base"
25
 
26
  def predict(self, frame: np.ndarray, queries: Sequence[str]) -> DetectionResult:
27
  inputs = self.processor(text=queries, images=frame, return_tensors="pt")
models/detectors/yolov12_bot_sort.py DELETED
@@ -1,56 +0,0 @@
1
- import logging
2
- from typing import Sequence
3
-
4
- import numpy as np
5
- import torch
6
- from huggingface_hub import hf_hub_download
7
- from ultralytics import YOLO
8
-
9
- from models.detectors.base import DetectionResult, ObjectDetector
10
-
11
-
12
- class HuggingFaceYoloV12BotSortDetector(ObjectDetector):
13
- """YOLOv12 model (BoT-SORT + ReID) hosted on Hugging Face."""
14
-
15
- REPO_ID = "wish44165/YOLOv12-BoT-SORT-ReID"
16
- WEIGHT_FILE = "MOT_yolov12n.pt"
17
-
18
- def __init__(self, score_threshold: float = 0.3) -> None:
19
- self.name = "hf_yolov12_bot_sort"
20
- self.score_threshold = score_threshold
21
- self.device = "cuda:0" if torch.cuda.is_available() else "cpu"
22
- logging.info(
23
- "Loading Hugging Face YOLOv12 BoT-SORT weights %s/%s onto %s",
24
- self.REPO_ID,
25
- self.WEIGHT_FILE,
26
- self.device,
27
- )
28
- weight_path = hf_hub_download(repo_id=self.REPO_ID, filename=self.WEIGHT_FILE)
29
- self.model = YOLO(weight_path)
30
- self.model.to(self.device)
31
- self.class_names = self.model.names
32
-
33
- def predict(self, frame: np.ndarray, queries: Sequence[str]) -> DetectionResult:
34
- device_arg = 0 if self.device.startswith("cuda") else "cpu"
35
- results = self.model.predict(
36
- source=frame,
37
- device=device_arg,
38
- conf=self.score_threshold,
39
- verbose=False,
40
- )
41
- result = results[0]
42
- boxes = result.boxes
43
- if boxes is None or boxes.xyxy is None:
44
- empty = np.empty((0, 4), dtype=np.float32)
45
- return DetectionResult(empty, [], [], [])
46
-
47
- xyxy = boxes.xyxy.cpu().numpy()
48
- scores = boxes.conf.cpu().numpy().tolist()
49
- label_ids = boxes.cls.cpu().numpy().astype(int).tolist()
50
- label_names = [self.class_names.get(idx, f"class_{idx}") for idx in label_ids]
51
- return DetectionResult(
52
- boxes=xyxy,
53
- scores=scores,
54
- labels=label_ids,
55
- label_names=label_names,
56
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/detectors/yolov8_defence.py DELETED
@@ -1,12 +0,0 @@
1
- from models.detectors.yolov8 import HuggingFaceYoloV8Detector
2
-
3
-
4
- class HuggingFaceYoloV8DefenceDetector(HuggingFaceYoloV8Detector):
5
- """YOLOv8m detector fine-tuned on defence data hosted on Hugging Face."""
6
-
7
- REPO_ID = "spencercdz/YOLOv8m_defence"
8
- WEIGHT_FILE = "yolov8m_defence.pt"
9
-
10
- def __init__(self, score_threshold: float = 0.3) -> None:
11
- super().__init__(score_threshold=score_threshold)
12
- self.name = "hf_yolov8_defence"
 
 
 
 
 
 
 
 
 
 
 
 
 
models/model_loader.py CHANGED
@@ -3,18 +3,18 @@ from functools import lru_cache
3
  from typing import Callable, Dict, Optional
4
 
5
  from models.detectors.base import ObjectDetector
 
 
6
  from models.detectors.owlv2 import Owlv2Detector
7
  from models.detectors.yolov8 import HuggingFaceYoloV8Detector
8
- from models.detectors.yolov8_defence import HuggingFaceYoloV8DefenceDetector
9
- from models.detectors.yolov12_bot_sort import HuggingFaceYoloV12BotSortDetector
10
 
11
- DEFAULT_DETECTOR = "owlv2"
12
 
13
  _REGISTRY: Dict[str, Callable[[], ObjectDetector]] = {
14
- "owlv2": Owlv2Detector,
15
  "hf_yolov8": HuggingFaceYoloV8Detector,
16
- "hf_yolov8_defence": HuggingFaceYoloV8DefenceDetector,
17
- "hf_yolov12_bot_sort": HuggingFaceYoloV12BotSortDetector,
18
  }
19
 
20
 
 
3
  from typing import Callable, Dict, Optional
4
 
5
  from models.detectors.base import ObjectDetector
6
+ from models.detectors.detr import DetrDetector
7
+ from models.detectors.grounding_dino import GroundingDinoDetector
8
  from models.detectors.owlv2 import Owlv2Detector
9
  from models.detectors.yolov8 import HuggingFaceYoloV8Detector
 
 
10
 
11
+ DEFAULT_DETECTOR = "owlv2_base"
12
 
13
  _REGISTRY: Dict[str, Callable[[], ObjectDetector]] = {
14
+ "owlv2_base": Owlv2Detector,
15
  "hf_yolov8": HuggingFaceYoloV8Detector,
16
+ "detr_resnet50": DetrDetector,
17
+ "grounding_dino": GroundingDinoDetector,
18
  }
19
 
20
 
pipeline_registry.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Tuple
4
+
5
+ from mission_context import MissionContext
6
+
7
+
8
+ PipelineSpec = Dict[str, object]
9
+
10
+
11
+ PIPELINE_SPECS: Tuple[PipelineSpec, ...] = (
12
+ {
13
+ "id": "RGB_DAY",
14
+ "modalities": ("rgb",),
15
+ "location_types": ("urban", "suburban", "rural"),
16
+ "time_of_day": ("day",),
17
+ "huggingface": {
18
+ "detection": [
19
+ {
20
+ "model_id": "facebook/detr-resnet-50",
21
+ "task": "object-detection",
22
+ "label": "DETR-ResNet-50",
23
+ "detector_key": "detr_resnet50",
24
+ },
25
+ {
26
+ "model_id": "Ultralytics/YOLOv8",
27
+ "task": "object-detection",
28
+ "label": "YOLOv8",
29
+ "detector_key": "hf_yolov8",
30
+ },
31
+ ],
32
+ "segmentation": [
33
+ {
34
+ "model_id": "facebook/mask2former-swin-base-coco",
35
+ "task": "image-segmentation",
36
+ "label": "Mask2Former Swin-B",
37
+ },
38
+ {
39
+ "model_id": "facebook/segformer-b5-finetuned-ade-640-640",
40
+ "task": "image-segmentation",
41
+ "label": "SegFormer B5 ADE",
42
+ },
43
+ ],
44
+ "tracking": [
45
+ {"name": "ByteTrack", "notes": "GPU MOT for vehicles + pedestrians"},
46
+ ],
47
+ "notes": "Best balance of speed and quality for daylight RGB scenes.",
48
+ },
49
+ },
50
+ {
51
+ "id": "THERMAL_NIGHT",
52
+ "modalities": ("thermal",),
53
+ "location_types": ("urban", "industrial", "rural"),
54
+ "time_of_day": ("night",),
55
+ "huggingface": {
56
+ "detection": [
57
+ {
58
+ "model_id": "Ultralytics/YOLOv8",
59
+ "task": "object-detection",
60
+ "label": "YOLOv8 (thermal tuned later)",
61
+ "notes": "Use RGB weights until thermal finetune is ready.",
62
+ "detector_key": "hf_yolov8",
63
+ },
64
+ {
65
+ "model_id": "facebook/detr-resnet-50",
66
+ "task": "object-detection",
67
+ "label": "DETR baseline",
68
+ "detector_key": "detr_resnet50",
69
+ },
70
+ ],
71
+ "segmentation": [
72
+ {
73
+ "model_id": "facebook/sam-vit-base",
74
+ "task": "image-segmentation",
75
+ "label": "SAM ViT-B",
76
+ "optional": True,
77
+ "notes": "Optional: prompt SAM with thermal detections.",
78
+ },
79
+ ],
80
+ "tracking": [
81
+ {"name": "ByteTrack", "notes": "Thermal tracking excels with ByteTrack."},
82
+ ],
83
+ "notes": "Night/thermal focus; skip heavy segmentation unless SAM is needed.",
84
+ },
85
+ },
86
+ {
87
+ "id": "COASTAL_OPEN",
88
+ "modalities": ("rgb", "open_vocab"),
89
+ "location_types": ("coastal", "harbor", "bridge"),
90
+ "time_of_day": ("day", "night"),
91
+ "huggingface": {
92
+ "detection": [
93
+ {
94
+ "model_id": "IDEA-Research/grounding-dino-base",
95
+ "task": "zero-shot-object-detection",
96
+ "label": "Grounding DINO-B",
97
+ "detector_key": "grounding_dino",
98
+ },
99
+ {
100
+ "model_id": "google/owlvit-base-patch32",
101
+ "task": "zero-shot-object-detection",
102
+ "label": "OWLv2 Base",
103
+ "detector_key": "owlv2_base",
104
+ },
105
+ ],
106
+ "segmentation": [
107
+ {"model_id": "facebook/sam-vit-base", "task": "image-segmentation", "label": "SAM ViT-B"},
108
+ {
109
+ "model_id": "facebook/segformer-b5-finetuned-ade-640-640",
110
+ "task": "image-segmentation",
111
+ "label": "SegFormer B5 ADE",
112
+ "notes": "Great for water/sky layout.",
113
+ },
114
+ ],
115
+ "tracking": [
116
+ {"name": "ByteTrack", "notes": "Track boats, drones, aircraft."},
117
+ ],
118
+ "notes": "Strong combo for open-world discovery plus structural understanding.",
119
+ },
120
+ },
121
+ {
122
+ "id": "INDOOR",
123
+ "modalities": ("rgb",),
124
+ "location_types": ("indoor", "industrial"),
125
+ "time_of_day": ("day", "night"),
126
+ "huggingface": {
127
+ "detection": [
128
+ {
129
+ "model_id": "facebook/detr-resnet-50",
130
+ "task": "object-detection",
131
+ "label": "DETR-ResNet-50",
132
+ "detector_key": "detr_resnet50",
133
+ },
134
+ {
135
+ "model_id": "Ultralytics/YOLOv8",
136
+ "task": "object-detection",
137
+ "label": "YOLOv8",
138
+ "detector_key": "hf_yolov8",
139
+ },
140
+ ],
141
+ "segmentation": [
142
+ {
143
+ "model_id": "facebook/mask2former-swin-base-coco",
144
+ "task": "image-segmentation",
145
+ "label": "Mask2Former Swin-B",
146
+ },
147
+ {
148
+ "model_id": "facebook/segformer-b5-finetuned-ade-640-640",
149
+ "task": "image-segmentation",
150
+ "label": "SegFormer B5 ADE",
151
+ },
152
+ ],
153
+ "tracking": [
154
+ {"name": "DeepSORT", "notes": "Appearance-assisted tracking fits indoor scenes."},
155
+ ],
156
+ "notes": "Balanced coverage for indoor/industrial monitoring.",
157
+ },
158
+ },
159
+ )
160
+
161
+ PIPELINE_SPECS_BY_ID: Dict[str, PipelineSpec] = {spec["id"]: spec for spec in PIPELINE_SPECS}
162
+
163
+
164
+ def get_pipeline_spec(pipeline_id: str | None) -> PipelineSpec | None:
165
+ if not pipeline_id:
166
+ return None
167
+ return PIPELINE_SPECS_BY_ID.get(pipeline_id)
168
+
169
+
170
+ def huggingface_model_bindings(pipeline_id: str | None) -> Dict[str, Any]:
171
+ spec = get_pipeline_spec(pipeline_id)
172
+ if not spec:
173
+ return {}
174
+ return spec.get("huggingface") or {}
175
+
176
+
177
+ def build_hf_inference_clients(
178
+ pipeline_id: str,
179
+ *,
180
+ token: str | None = None,
181
+ ) -> Dict[str, List[Dict[str, Any]]]:
182
+ try:
183
+ from huggingface_hub import InferenceClient
184
+ except ImportError as exc: # pragma: no cover - dependency already required elsewhere.
185
+ raise RuntimeError("huggingface_hub is required to build inference clients.") from exc
186
+ bindings = huggingface_model_bindings(pipeline_id)
187
+ clients: Dict[str, List[Dict[str, Any]]] = {}
188
+ for task, entries in bindings.items():
189
+ if task == "notes" or not isinstance(entries, list):
190
+ continue
191
+ for entry in entries:
192
+ model_id = entry.get("model_id")
193
+ if not model_id:
194
+ continue
195
+ client = InferenceClient(model=model_id, token=token)
196
+ payload = {"model_id": model_id, "client": client, "metadata": entry}
197
+ clients.setdefault(task, []).append(payload)
198
+ return clients
199
+
200
+
201
+ def filter_pipelines_for_context(context: MissionContext) -> List[PipelineSpec]:
202
+ filtered: List[PipelineSpec] = []
203
+ for spec in PIPELINE_SPECS:
204
+ reason = _availability_reason(spec, context)
205
+ if reason:
206
+ spec_copy = dict(spec)
207
+ spec_copy["availability_reason"] = reason
208
+ filtered.append(spec_copy)
209
+ return filtered
210
+
211
+
212
+ def fallback_pipeline_for_context(
213
+ context: MissionContext,
214
+ available_specs: List[PipelineSpec],
215
+ ) -> PipelineSpec | None:
216
+ if available_specs:
217
+ return available_specs[0]
218
+ return _first_compatible_pipeline(context) or PIPELINE_SPECS[0]
219
+
220
+
221
+ def _first_compatible_pipeline(context: MissionContext) -> PipelineSpec | None:
222
+ for spec in PIPELINE_SPECS:
223
+ if _supports_time(spec, context.time_of_day) and _supports_location(spec, context.location_type):
224
+ return spec
225
+ return None
226
+
227
+
228
+ def _supports_time(spec: PipelineSpec, time_of_day: str | None) -> bool:
229
+ if not time_of_day:
230
+ return True
231
+ return time_of_day in spec["time_of_day"]
232
+
233
+
234
+ def _supports_location(spec: PipelineSpec, location_type: str | None) -> bool:
235
+ if not location_type or location_type == "unknown":
236
+ return True
237
+ return location_type in spec["location_types"]
238
+
239
+
240
+ def _availability_reason(spec: PipelineSpec, context: MissionContext) -> str | None:
241
+ if not _supports_time(spec, context.time_of_day):
242
+ return None
243
+ if not _supports_location(spec, context.location_type):
244
+ return None
245
+ reasons = [
246
+ f"time {context.time_of_day or 'any'}",
247
+ f"location {context.location_type or 'any'}",
248
+ ]
249
+ return ", ".join(reasons)
prompt.py CHANGED
@@ -2,36 +2,175 @@
2
 
3
  from __future__ import annotations
4
 
5
- from typing import Iterable
6
 
7
 
8
  def mission_planner_system_prompt() -> str:
9
  return (
10
- "You are a mission-planning assistant helping a vision system select which YOLO object "
11
- "classes to detect. You must only reference the provided list of YOLO classes."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  )
13
 
14
 
15
- def mission_planner_user_prompt(mission: str, available_classes: Iterable[str], top_k: int) -> str:
16
- classes_blob = ", ".join(available_classes)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  return (
18
- f"Mission: {mission}\n"
19
- f"Available YOLO classes: {classes_blob}\n"
20
- "Return JSON with: mission (string) and classes (array). "
21
- "Each entry needs name, score (0-1 float), rationale. "
22
- f"Limit to at most {top_k} classes. Only choose names from the list."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  )
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def mission_summarizer_system_prompt() -> str:
27
  return (
28
- "You are a surveillance analyst. Review structured detections aligned to a mission and "
29
- "summarize actionable insights, highlighting objects of interest, temporal trends, and any "
30
- "security concerns. Base conclusions solely on the provided data; if nothing is detected, "
31
- "explicitly state that."
32
  )
33
 
34
 
35
  def mission_summarizer_user_prompt(payload_json: str) -> str:
36
- return "Use this JSON to summarize the mission outcome:\n" f"{payload_json}"
37
-
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
+ from typing import Any, Iterable, Mapping
6
 
7
 
8
  def mission_planner_system_prompt() -> str:
9
  return (
10
+ "You are an expert mission-aware perception planner for an intelligent sensing system. "
11
+ "Your task is to translate a high-level mission directive and contextual cues into a "
12
+ "prioritized set of semantic entities, objects, or events that the perception stack "
13
+ "should sense.\n\n"
14
+
15
+ "Think in terms of mission-relevant semantics rather than detector-specific classes. "
16
+ "Objects may represent physical entities, behaviors, configurations, or latent threats. "
17
+ "Do not constrain recommendations to any fixed taxonomy.\n\n"
18
+
19
+ "For each recommendation, reason causally about how sensing this entity reduces mission "
20
+ "uncertainty, enables downstream decisions, or serves as an early indicator of mission-relevant events. "
21
+ "Favor entities that are information-dense and mission-critical.\n\n"
22
+
23
+ "If the mission directive is abstract (e.g., tracking, monitoring, movement),"
24
+ "you must decompose it into concrete, observable semantic entities or events"
25
+ "that a perception system could plausibly sense.\n\n"
26
+
27
+ "Ground every recommendation in observable perceptual evidence (visual, spatial, or temporal), "
28
+ "but do not assume a specific detector architecture. "
29
+ "Avoid hallucinations beyond the provided mission and cues."
30
  )
31
 
32
 
33
+ def mission_planner_user_prompt(
34
+ mission: str,
35
+ top_k: int,
36
+ *,
37
+ context: Mapping[str, Any] | None = None,
38
+ cues: Mapping[str, Any] | None = None,
39
+ pipeline_candidates: Iterable[str] | None = None,
40
+ coco_catalog: str | None = None,
41
+ ) -> str:
42
+ mission_text = mission.strip() or "N/A"
43
+ context_blob = _format_context_blob(context)
44
+ cues_blob = _format_cues_blob(cues)
45
+ candidate_text = (
46
+ ", ".join(sorted({candidate.strip() for candidate in pipeline_candidates if candidate.strip()}))
47
+ if pipeline_candidates
48
+ else "Preselected by mission context."
49
+ )
50
+ coco_text = coco_catalog.strip() if coco_catalog else "COCO classes unavailable."
51
+
52
  return (
53
+ f"Mission directive:\n{mission_text}\n\n"
54
+ f"Structured context inputs:\n{context_blob}\n\n"
55
+ f"Derived location/mission cues:\n{cues_blob}\n\n"
56
+ f"Selectable pipeline IDs: {candidate_text}\n\n"
57
+ "Downstream detector recognizes ONLY these COCO objects (use exact spelling):\n"
58
+ f"{coco_text}\n\n"
59
+
60
+ "Instructions:\n"
61
+ "- Interpret the mission directive and contextual cues to determine what **semantic entities, "
62
+ "objects, or events** are most critical for mission success.\n"
63
+
64
+ "- If the mission is abstract (e.g., tracking, monitoring, movement, surveillance), you MUST "
65
+ "decompose it into **concrete, observable entities or events** that a perception system could "
66
+ "plausibly sense. Do not leave the mission at an abstract level.\n"
67
+
68
+ "- Infer mission_type, location_type, time_of_day, and priority_level "
69
+ "(bounded to allowed enums). Do NOT modify intel_flag, sensor_type, or compute_mode.\n"
70
+
71
+ "- If multiple pipeline IDs are listed, select exactly ONE id from that list. "
72
+ "If only one ID is given, respect it.\n"
73
+
74
+ "- Never invent new pipelines or modify existing pipeline definitions.\n"
75
+
76
+ "- Recommendations are NOT limited to static objects. They may include:\n"
77
+ " • dynamic entities (e.g., vehicles, aircraft, people)\n"
78
+ " • behaviors or motion patterns\n"
79
+ " • spatial configurations or interactions\n"
80
+ " • anomalous or unexpected activity\n"
81
+
82
+ "- Each recommendation MUST correspond to a **specific, real-world, observable phenomenon**.\n"
83
+ "- Do NOT use placeholder names such as 'Objective', 'Target', 'Entity', or generic labels.\n"
84
+ "- Every entity name MUST be an exact string match to one of the provided COCO classes above. "
85
+ "If the mission demands a concept outside this list, decompose it into observable COCO objects.\n"
86
+
87
+ "- Provide at most "
88
+ f"{top_k} "
89
+ "recommendations, ordered by priority and expected information gain for the mission.\n"
90
+
91
+ "- Scores MUST be floats in [0, 1] and represent **relative mission importance**, "
92
+ "NOT detection confidence or likelihood.\n"
93
+
94
+ "- For EACH recommendation, you MUST explain:\n"
95
+ " • why it matters to the mission\n"
96
+ " • what observable perceptual cues (visual, spatial, or temporal) would indicate its presence\n"
97
+ " • how sensing it informs downstream decision-making or reduces mission uncertainty\n\n"
98
+
99
+ "Return JSON with the following schema ONLY (no extra text):\n"
100
+ "{\n"
101
+ ' "mission": "<possibly refined mission summary>",\n'
102
+ ' "context": {\n'
103
+ ' "mission_type": "<one of surveillance|tracking|threat_detection|safety_monitoring>",\n'
104
+ ' "location_type": "<one of urban|suburban|rural|industrial|coastal|harbor|bridge|roadway|indoor|unknown>",\n'
105
+ ' "time_of_day": "<day|night|null>",\n'
106
+ ' "priority_level": "<routine|elevated|high|null>"\n'
107
+ " },\n"
108
+ ' "pipeline": {"id": "<pipeline_id_from_list>", "reason": "<concise justification>"},\n'
109
+ ' "entities": [\n'
110
+ " {\n"
111
+ ' "name": "<semantic entity or event>",\n'
112
+ ' "score": <float>,\n'
113
+ ' "semantic_role": "<primary_target|threat_proxy|contextual_indicator|operational_constraint>",\n'
114
+ ' "perceptual_cues": "<observable visual/spatial/temporal evidence>",\n'
115
+ ' "rationale": "<mission-grounded justification>"\n'
116
+ " }\n"
117
+ " ]\n"
118
+ "}"
119
  )
120
 
121
 
122
+
123
+ def _format_context_blob(context: Mapping[str, Any] | None) -> str:
124
+ if not context:
125
+ return "No additional context provided."
126
+
127
+ lines = []
128
+ mission_type = context.get("mission_type")
129
+ location_type = context.get("location_type")
130
+ time_of_day = context.get("time_of_day")
131
+
132
+ if mission_type:
133
+ lines.append(f"- Mission type: {mission_type}")
134
+ if location_type:
135
+ lines.append(f"- Location type: {location_type}")
136
+ if time_of_day:
137
+ lines.append(f"- Time of day: {time_of_day}")
138
+
139
+ if not lines:
140
+ return "Context provided but no structured cues supplied."
141
+ return "\n".join(lines)
142
+
143
+
144
+ def _format_cues_blob(cues: Mapping[str, Any] | None) -> str:
145
+ if not cues:
146
+ return "No derived cues."
147
+ lines = []
148
+ for key, value in cues.items():
149
+ if value is None:
150
+ continue
151
+ if isinstance(value, (list, tuple)):
152
+ serialized = ", ".join(str(item) for item in value if item)
153
+ else:
154
+ serialized = str(value)
155
+ if not serialized:
156
+ continue
157
+ lines.append(f"- {key.replace('_', ' ').capitalize()}: {serialized}")
158
+ if not lines:
159
+ return "Derived cues provided but empty."
160
+ return "\n".join(lines)
161
+
162
+
163
  def mission_summarizer_system_prompt() -> str:
164
  return (
165
+ "You are a surveillance analyst producing brief situation reports. "
166
+ "Review the provided mission context and detections, then respond with a concise summary "
167
+ "(at most three short sentences) covering key findings only. "
168
+ "If no detections appear, clearly state that fact. Avoid extra commentary or formatting."
169
  )
170
 
171
 
172
  def mission_summarizer_user_prompt(payload_json: str) -> str:
173
+ return (
174
+ "Summarize this mission outcome succinctly (<=3 sentences, no bullet points):\n"
175
+ f"{payload_json}"
176
+ )
requirements.txt CHANGED
@@ -10,3 +10,5 @@ scipy
10
  openai
11
  huggingface-hub
12
  ultralytics
 
 
 
10
  openai
11
  huggingface-hub
12
  ultralytics
13
+ reverse_geocoder
14
+ timezonefinder