File size: 11,998 Bytes
e1c010e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
"""
Language Reasoning Module — Natural-language command interpretation via LLM.

Takes a user command and the list of detected objects, then asks an LLM
to produce a structured action interpretation:

    {"action": "move", "source": "bottle", "destination": "box"}

Supported providers (set via LLM_PROVIDER env var):
    - "openai"  → OpenAI API  (GPT-4o-mini by default)
    - "groq"    → Groq API    (Llama 3.3 70B by default, ~5-10x faster)
"""

import json
import logging
from typing import Any

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# System prompt sent to the LLM
# ---------------------------------------------------------------------------
_SYSTEM_PROMPT = """\
You are an AI assistant for a Vision Language Action robotics system.

Given:
  1. A list of objects detected in a scene (with labels and bounding boxes).
  2. A natural-language command from a human operator.

Your task:
  Interpret the command and return a JSON object with exactly these keys:
    - "action"      : The verb describing what to do (e.g. "move", "pick_up",
                       "place", "inspect", "rotate", "remove").
    - "source"      : The object to act on.
    - "destination"  : The target location or object (if applicable, else null).

CRITICAL RULES:
  • For "source" and "destination", you MUST use the EXACT label string from
    the detected objects list. The user may refer to objects using different
    words (e.g. "wrench" might actually be detected as "knife", "toolbox"
    might be "suitcase", "mug" might be "cup"). Your job is to figure out
    which detected object the user is referring to and use that detected label.
  • If the user says "wrench" but only "knife" is detected, return "knife".
  • If the user says "toolbox" but only "suitcase" is detected, return "suitcase".
  • If the user says "bin" but only "bowl" is detected, return "bowl".
  • NEVER return a label that is not in the detected objects list.
  • If truly no detected object can reasonably match, use the closest detected
    object label and note it.
  • Return ONLY the JSON object, no extra text.
  • If the command is ambiguous, make your best interpretation.
  • If no destination is implied, set "destination" to null.
"""


# Provider → base URL mapping (Groq uses the OpenAI-compatible SDK)
_PROVIDER_BASE_URLS: dict[str, str | None] = {
    "openai": None,                           # uses default OpenAI endpoint
    "groq":   "https://api.groq.com/openai/v1",
}


class CommandReasoner:
    """
    Interprets natural-language commands using an OpenAI-compatible LLM.

    Supports OpenAI and Groq as backends — both expose the same
    Chat Completions API so the same prompt and parsing logic work for both.

    Attributes:
        provider: Active provider name ("openai" or "groq").
        model:    Model identifier (e.g. "gpt-4o-mini" or "llama-3.3-70b-versatile").
        client:   OpenAI SDK client instance (or None in fallback mode).
    """

    def __init__(
        self,
        provider: str,
        api_key: str,
        model: str,
    ):
        """
        Initialise the reasoner for a specific provider.

        Args:
            provider: "openai" or "groq".
            api_key:  API key for the chosen provider.
            model:    Model identifier string.
        """
        self.provider = provider
        self.model = model
        self.client = None

        if api_key:
            self._init_client(provider, api_key)
        else:
            logger.warning(
                "No API key provided for provider='%s' — "
                "CommandReasoner will use fallback mode.",
                provider,
            )

    def _init_client(self, provider: str, api_key: str) -> None:
        """Create an OpenAI-SDK client pointed at the chosen provider endpoint."""
        try:
            from openai import OpenAI

            base_url = _PROVIDER_BASE_URLS.get(provider)  # None → OpenAI default
            kwargs: dict = {"api_key": api_key}
            if base_url:
                kwargs["base_url"] = base_url

            self.client = OpenAI(**kwargs)
            logger.info(
                "LLM client initialised (provider=%s, model=%s).",
                provider,
                self.model,
            )
        except Exception as exc:
            logger.error("Failed to initialise LLM client (%s): %s", provider, exc)
            self.client = None

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def interpret(
        self,
        command: str,
        detected_objects: list[dict[str, Any]],
    ) -> dict[str, Any]:
        """
        Interpret a user command in the context of detected objects.

        Args:
            command: Free-form user instruction.
            detected_objects: List of detection dicts from the vision module.

        Returns:
            Dict with keys ``action``, ``source``, ``destination``.
        """
        if not command or not command.strip():
            logger.warning("Empty command received.")
            return self._error_result("No command provided.")

        if self.client is None:
            logger.info("Using fallback reasoning (no LLM client).")
            return self._fallback_interpret(command, detected_objects)

        return self._llm_interpret(command, detected_objects)

    # ------------------------------------------------------------------
    # LLM-based interpretation
    # ------------------------------------------------------------------

    def _llm_interpret(
        self,
        command: str,
        detected_objects: list[dict[str, Any]],
    ) -> dict[str, Any]:
        """Call the LLM to interpret the command."""
        objects_description = self._format_objects(detected_objects)

        user_message = (
            f"Detected objects in the scene:\n{objects_description}\n\n"
            f"User command: \"{command}\"\n\n"
            f"Return the structured JSON interpretation."
        )

        try:
            response = self.client.chat.completions.create(
                model=self.model,
                messages=[
                    {"role": "system", "content": _SYSTEM_PROMPT},
                    {"role": "user", "content": user_message},
                ],
                temperature=0.1,
                max_tokens=256,
            )

            raw = response.choices[0].message.content.strip()
            logger.info("LLM raw response: %s", raw)

            return self._parse_response(raw)

        except Exception as exc:
            logger.error("LLM call failed: %s", exc)
            return self._fallback_interpret(command, detected_objects)

    # ------------------------------------------------------------------
    # Fallback (no LLM)
    # ------------------------------------------------------------------

    def _fallback_interpret(
        self,
        command: str,
        detected_objects: list[dict[str, Any]],
    ) -> dict[str, Any]:
        """
        Smart keyword-based fallback when no LLM is available.

        Uses the ObjectMapper for synonym-aware matching between user
        terms and detected YOLO labels.
        """
        from src.utils.object_mapper import find_best_match

        logger.info("Fallback interpretation for: '%s'", command)

        # Try to determine action verb
        action = "move"
        for verb in ("pick", "place", "move", "inspect", "rotate", "remove", "find"):
            if verb in command.lower():
                action = verb
                break

        labels = [obj["label"] for obj in detected_objects]
        cmd_lower = command.lower()

        # Extract noun phrases from command by removing common verbs/prepositions
        stop_words = {
            "the", "a", "an", "and", "or", "to", "from", "in", "on", "at",
            "into", "onto", "of", "with", "for", "it", "up", "back", "next",
            "pick", "place", "move", "inspect", "rotate", "remove", "find",
            "put", "take", "grab", "get", "bring", "carry", "push", "pull",
            "near", "close", "closest", "nearest", "under", "over", "above",
            "below", "beside", "behind",
        }
        words = cmd_lower.replace(",", " ").replace(".", " ").split()

        # Build candidate noun phrases (single words + bigrams)
        candidates: list[str] = []
        for w in words:
            if w not in stop_words and len(w) > 1:
                candidates.append(w)
        for i in range(len(words) - 1):
            bigram = f"{words[i]} {words[i+1]}"
            if words[i] not in stop_words or words[i+1] not in stop_words:
                candidates.append(bigram)

        # Match candidates against detected labels using ObjectMapper
        source = None
        destination = None
        used_labels: set[str] = set()

        for candidate in candidates:
            match = find_best_match(candidate, labels)
            if match and match not in used_labels:
                if source is None:
                    source = match
                    used_labels.add(match)
                elif destination is None:
                    destination = match
                    used_labels.add(match)
                    break

        # Fallback: if no matches, use first two detected objects
        if source is None and labels:
            source = labels[0]
        if destination is None and len(labels) > 1:
            remaining = [l for l in labels if l != source]
            if remaining:
                destination = remaining[0]

        return {
            "action": action,
            "source": source or "unknown",
            "destination": destination,
        }

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _format_objects(detected_objects: list[dict[str, Any]]) -> str:
        """Format detected objects as a readable string for the LLM prompt."""
        if not detected_objects:
            return "No objects detected."

        lines: list[str] = []
        for i, obj in enumerate(detected_objects, 1):
            bbox = obj.get("bbox", [])
            conf = obj.get("confidence", 0)
            lines.append(
                f"  {i}. {obj['label']} (confidence: {conf:.2f}, bbox: {bbox})"
            )
        return "\n".join(lines)

    @staticmethod
    def _parse_response(raw: str) -> dict[str, Any]:
        """
        Parse the LLM response string into a dict.

        Handles markdown code fences and extra whitespace.
        """
        cleaned = raw.strip()
        # Strip optional markdown code fences
        if cleaned.startswith("```"):
            lines = cleaned.split("\n")
            lines = [ln for ln in lines if not ln.strip().startswith("```")]
            cleaned = "\n".join(lines).strip()

        try:
            result = json.loads(cleaned)
        except json.JSONDecodeError:
            logger.warning("Failed to parse LLM JSON: %s", cleaned)
            return {
                "action": "unknown",
                "source": "unknown",
                "destination": None,
                "error": "Failed to parse LLM response.",
                "raw_response": raw,
            }

        # Ensure required keys
        return {
            "action": result.get("action", "unknown"),
            "source": result.get("source", "unknown"),
            "destination": result.get("destination"),
        }

    @staticmethod
    def _error_result(message: str) -> dict[str, Any]:
        """Return a structured error result."""
        return {
            "action": "error",
            "source": "unknown",
            "destination": None,
            "error": message,
        }