File size: 12,722 Bytes
8edee29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
agents/detect_agent.py
-----------------------
Auto-Detect Agent for AutoDevAgent.

Makes a fast, cheap LLM call (Llama 3.1 8B) to classify the user's
task into a supported language with a confidence score and one-sentence
reason. The result pre-selects the language radio button in the UI
with a coloured badge so the user can verify or override.

Design:
  - Uses the fast 8B model β€” classification is simple, 70B is overkill.
  - Returns a DetectionResult Pydantic model β€” typed, validated, clean.
  - If detection fails or returns UNKNOWN, the UI defaults to Python
    (the most common language for generic tasks).
  - The LLM is prompted to return JSON only. The _parse_json helper
    from planning_agent is replicated here to keep the module
    self-contained and avoid circular imports.
  - Override is always available and prominent in the UI.
    Any override is logged to session history.

Usage:
    from agents.detect_agent import DetectAgent
    from pipeline.state import PipelineState

    agent = DetectAgent()
    result = agent.detect("find duplicates in a list")
    print(result.language)    # Language.PYTHON
    print(result.confidence)  # "high"
    print(result.reason)      # "task involves list manipulation in Python"
"""

import json
import logging

from langchain_groq import ChatGroq
from langchain_core.messages import SystemMessage, HumanMessage

from config import settings
from pipeline.state import (
    DetectionResult,
    Language,
)

logger = logging.getLogger(__name__)


# ------------------------------------------------------------------ #
#  Prompt                                                              #
# ------------------------------------------------------------------ #

DETECT_SYSTEM = """
You are a programming language classifier.
Classify the given task into one of these languages: python, sql, unknown.

Use "python" for: general coding tasks, algorithms, data manipulation,
  file processing, APIs, machine learning, scripting.
Use "sql" for: database queries, SELECT/INSERT/UPDATE/DELETE statements,
  aggregations, JOINs, schema design.
Use "unknown" for: tasks that are ambiguous or don't clearly fit either.

Return ONLY a JSON object in this exact format β€” no markdown, no explanation:
{
  "language": "python",
  "confidence": "high",
  "reason": "one sentence explaining the detection"
}

confidence must be one of: "high", "medium", "low"
""".strip()

CODING_TASK_SYSTEM = """
You are a gate classifier for a code assistant that only handles Python and SQL programming tasks.

Decide whether the user's input is a programming/coding task or not.

A CODING task includes: writing code, debugging, algorithms, SQL queries, data structures,
  functions, classes, scripts, APIs, database design, anything that requires generating code.

A NON-CODING task includes: general knowledge questions, factual questions, opinions,
  math calculations without code, translations, creative writing, geography, history,
  or anything that does not require writing Python or SQL code.

Return ONLY a JSON object β€” no markdown, no explanation:
{
  "is_coding": true,
  "confidence": "high",
  "reason": "one sentence explaining the decision"
}

is_coding must be true or false.
confidence must be one of: "high", "medium", "low".
""".strip()

SCOPE_CHECK_SYSTEM = """
You are a scope classifier for a code assistant that generates focused Python or SQL code snippets.
The assistant can only produce a single self-contained file of at most ~100 lines.

Decide whether the user's task is realistically achievable as a SINGLE code snippet.

IN SCOPE (single snippet) examples:
- Write a Python function to reverse a string
- Sort a list of dictionaries by a key
- SQL query to find top 5 customers by revenue
- SQL query to find the second highest salary from an employees table
- SQL query with date filters: customers active in last 30 days but not in previous 30 days
- SQL query to calculate a running total using window functions or subqueries
- SQL query to de-duplicate a table keeping the latest row per user
- Fetch video titles from YouTube API using Python
- Parse a CSV and calculate column averages
- Implement binary search in Python
- Write a regex to validate email addresses

OUT OF SCOPE (full application / multi-file project) examples:
- Build a YouTube app / clone
- Build a social media platform
- Create a full e-commerce website
- Build a chat application
- Make an Android/iOS app
- Build a REST API with authentication, database, deployment
- Create a machine learning pipeline with training, evaluation, and deployment

Rules:
- A task is OUT OF SCOPE only if it requires: a frontend UI framework, multiple files/modules,
  user authentication flows, deployment infrastructure, or weeks of engineering work.
- A task is IN SCOPE if it can be done in a single Python function/class or a single SQL query β€”
  even if that query is complex (uses subqueries, CTEs, window functions, date ranges, JOINs).
- IMPORTANT: ANY single SQL query β€” no matter how complex the logic β€” is always IN SCOPE.
  A SQL query never "requires a backend server" on its own; it is just a query.
- Be STRICT β€” "build an app" is always out of scope, but "write a query / function" is always in scope.

If OUT OF SCOPE, provide a short, friendly suggestion showing how to break the task into
a concrete single-snippet version the assistant CAN help with.

Return ONLY a JSON object β€” no markdown, no explanation:
{
  "in_scope": true,
  "confidence": "high",
  "reason": "one sentence",
  "suggestion": ""
}

If in_scope is false, suggestion must be a non-empty string with a concrete reframed example.
confidence must be one of: "high", "medium", "low".
""".strip()


# ------------------------------------------------------------------ #
#  Agent                                                               #
# ------------------------------------------------------------------ #

class DetectAgent:
    """
    Classifies a task description into a programming language.

    Uses a single fast LLM call to return a DetectionResult with
    the detected language, confidence level, and a brief reason.

    Attributes:
        llm: ChatGroq using the fast 8B model.
    """

    def __init__(self) -> None:
        """Initialise with the fast LLM from config."""
        self.llm = ChatGroq(
            api_key    = settings.groq_api_key,
            model      = settings.groq_model_fast,
            temperature= 0.0,    # Fully deterministic β€” classification task
            max_tokens = 150,    # Short response expected
        )

    def is_coding_task(self, task: str) -> tuple[bool, str]:
        """
        Classify whether the task is a coding task at all.

        Returns:
            (is_coding: bool, reason: str)
            is_coding=False means the task is non-technical and should
            be rejected before the pipeline runs.
        """
        logger.info("DetectAgent coding-gate check: '%s'", task[:60])
        messages = [
            SystemMessage(content=CODING_TASK_SYSTEM),
            HumanMessage(content=f"Task: {task}"),
        ]
        try:
            response = self.llm.invoke(messages)
            parsed   = _parse_json(response.content.strip())
            is_coding  = bool(parsed.get("is_coding", True))
            confidence = parsed.get("confidence", "low").lower().strip()
            reason     = parsed.get("reason", "")
            logger.info("DetectAgent gate: is_coding=%s (%s) β€” %s", is_coding, confidence, reason)
            # Only block when the model is confident it's NOT coding
            if not is_coding and confidence in ("high", "medium"):
                return False, reason
            return True, reason
        except Exception as e:
            logger.warning("DetectAgent gate failed: %s β€” allowing task through", e)
            return True, ""

    def is_in_scope(self, task: str) -> tuple[bool, str]:
        """
        Check whether the task is realistic for a single code snippet (~100 lines).

        Tasks like "build a YouTube app" or "create a social media platform" are
        full projects β€” out of scope for this assistant. We catch them here and
        return a friendly reframing suggestion instead of wasting pipeline retries.

        Returns:
            (in_scope: bool, suggestion: str)
            in_scope=False means the task is too large; suggestion holds a
            concrete example of how the user could break it down.
        """
        logger.info("DetectAgent scope check: '%s'", task[:80])
        messages = [
            SystemMessage(content=SCOPE_CHECK_SYSTEM),
            HumanMessage(content=f"Task: {task}"),
        ]
        try:
            response   = self.llm.invoke(messages)
            parsed     = _parse_json(response.content.strip())
            in_scope   = bool(parsed.get("in_scope", True))
            confidence = parsed.get("confidence", "low").lower().strip()
            reason     = parsed.get("reason", "")
            suggestion = parsed.get("suggestion", "").strip()
            logger.info("DetectAgent scope: in_scope=%s (%s) β€” %s", in_scope, confidence, reason)
            # Block at any confidence level when out-of-scope.
            # The cost of letting a massive project through (wasted retries,
            # broken output) outweighs occasionally rejecting a borderline task.
            # Unlike the non-coding gate (low-confidence = probably fine),
            # a low-confidence out-of-scope still means "probably too big".
            if not in_scope:
                return False, suggestion
            return True, ""
        except Exception as e:
            logger.warning("DetectAgent scope check failed: %s β€” allowing task through", e)
            return True, ""

    def detect(self, task: str) -> DetectionResult:
        """
        Detect the programming language for a given task description.

        Args:
            task: The user's task description string.

        Returns:
            DetectionResult with language, confidence, and reason.
            Falls back to Language.UNKNOWN on any failure.
        """
        logger.info("DetectAgent classifying task: '%s'", task[:60])

        messages = [
            SystemMessage(content=DETECT_SYSTEM),
            HumanMessage(content=f"Task: {task}"),
        ]

        try:
            response = self.llm.invoke(messages)
            raw      = response.content.strip()
            logger.debug("DetectAgent raw response: %s", raw)

            parsed = _parse_json(raw)

            # Validate and normalise language value
            lang_str = parsed.get("language", "unknown").lower().strip()
            try:
                language = Language(lang_str)
            except ValueError:
                language = Language.UNKNOWN

            confidence = parsed.get("confidence", "low").lower().strip()
            if confidence not in ("high", "medium", "low"):
                confidence = "low"

            reason = parsed.get("reason", "Could not determine reason.")

            result = DetectionResult(
                language   = language,
                confidence = confidence,
                reason     = reason,
            )

            logger.info(
                "DetectAgent: %s (%s confidence) β€” %s",
                result.language.value,
                result.confidence,
                result.reason,
            )

            return result

        except Exception as e:
            logger.warning("DetectAgent failed: %s β€” defaulting to UNKNOWN", e)
            return DetectionResult(
                language   = Language.UNKNOWN,
                confidence = "low",
                reason     = "Auto-detection failed β€” please select language manually.",
            )


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

def _parse_json(raw: str) -> dict:
    """
    Safely parse JSON from LLM response, stripping markdown fences.

    Args:
        raw: Raw string content from the LLM.

    Returns:
        Parsed dict.

    Raises:
        ValueError: If JSON cannot be parsed after cleaning.
    """
    cleaned = raw.strip()
    if cleaned.startswith("```"):
        cleaned = cleaned.split("\n", 1)[-1]
    if cleaned.endswith("```"):
        cleaned = cleaned.rsplit("```", 1)[0]
    cleaned = cleaned.strip()

    try:
        return json.loads(cleaned)
    except json.JSONDecodeError as e:
        raise ValueError(f"DetectAgent returned invalid JSON: {e}") from e