Spaces:
Sleeping
Sleeping
Update: Add mission pipeline registry
Browse files- app.py +10 -5
- coco_classes.py +163 -0
- inference.py +14 -1
- mission_context.py +277 -0
- mission_planner.py +327 -132
- mission_planner_cli.py +126 -0
- models/detectors/detr.py +48 -0
- models/detectors/grounding_dino.py +56 -0
- models/detectors/owlv2.py +2 -2
- models/detectors/yolov12_bot_sort.py +0 -56
- models/detectors/yolov8_defence.py +0 -12
- models/model_loader.py +6 -6
- pipeline_registry.py +249 -0
- prompt.py +155 -16
- requirements.txt +2 -0
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
|
| 156 |
-
|
| 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=
|
| 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
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
mission = (mission or "").strip()
|
| 136 |
if not mission:
|
| 137 |
raise ValueError("Mission prompt cannot be empty.")
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
relevant = self._parse_plan(response_payload, fallback_mission=mission)
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
-
def _query_llm(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
client = get_openai_client()
|
| 144 |
system_prompt = mission_planner_system_prompt()
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
completion = client.chat.completions.create(
|
| 147 |
model=self._model_name,
|
| 148 |
-
temperature=0.
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
continue
|
| 173 |
-
seen
|
|
|
|
|
|
|
| 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(
|
| 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=
|
| 187 |
-
score=
|
| 188 |
-
rationale=
|
| 189 |
)
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
| 192 |
return parsed
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
_REASONER: MissionReasoner | None = None
|
| 196 |
|
| 197 |
|
| 198 |
-
def get_mission_plan(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
global _REASONER
|
| 200 |
if _REASONER is None:
|
| 201 |
_REASONER = MissionReasoner()
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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 = "
|
| 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 = "
|
| 12 |
|
| 13 |
_REGISTRY: Dict[str, Callable[[], ObjectDetector]] = {
|
| 14 |
-
"
|
| 15 |
"hf_yolov8": HuggingFaceYoloV8Detector,
|
| 16 |
-
"
|
| 17 |
-
"
|
| 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
|
| 11 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
)
|
| 13 |
|
| 14 |
|
| 15 |
-
def mission_planner_user_prompt(
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
return (
|
| 18 |
-
f"Mission
|
| 19 |
-
f"
|
| 20 |
-
"
|
| 21 |
-
"
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
)
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def mission_summarizer_system_prompt() -> str:
|
| 27 |
return (
|
| 28 |
-
"You are a surveillance analyst
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
)
|
| 33 |
|
| 34 |
|
| 35 |
def mission_summarizer_user_prompt(payload_json: str) -> str:
|
| 36 |
-
return
|
| 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
|