File size: 7,447 Bytes
37b0787
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import asyncio
import json
import logging
import re
from typing import Any

from src.agents.prompts import PLANNER_SYSTEM_PROMPT
from src.core.config import get_llm_client, get_settings
from src.core.constants import INDIAN_CITIES, INDIAN_COMPANIES
from src.core.models import (
    ExperienceRequirements,
    LocationRequirements,
    ParsedQuery,
    PreferredSkill,
    RequiredSkill,
    SkillImportance,
)
from src.language.code_mixed import CodeMixedProcessor
from src.matching.skill_matcher import SKILL_ALIASES

logger = logging.getLogger(__name__)


def _strip_json_fences(content: str) -> str:
    """Strip markdown JSON code fences (```json ... ```) from LLM output."""
    content = content.strip()
    if content.startswith("```"):
        content = re.sub(r"^```(?:json)?\s*", "", content)
        content = re.sub(r"\s*```$", "", content)
    return content.strip()


class PlannerAgent:
    def __init__(self) -> None:
        self._client = None
        self._code_mixed_processor: CodeMixedProcessor | None = None
        settings = get_settings()
        self.model = settings.openai_model

    @property
    def client(self) -> Any:
        if self._client is None:
            try:
                self._client = get_llm_client()
            except Exception:
                logger.warning("LLM client unavailable for planner")
                self._client = None
        return self._client

    async def plan(self, raw_query: str) -> ParsedQuery:  # noqa: C901
        # mypy: return type is inferred from the return statements below
        try:
            if self._code_mixed_processor is None:
                self._code_mixed_processor = CodeMixedProcessor()
            processor = self._code_mixed_processor
            if processor.detect_code_mixed(raw_query):
                logger.info("Code-mixed query detected, applying TinT prompting")
                tint_query = (
                    "[Translate-in-Thought] The following query contains Hinglish "
                    "(Hindi-English code-mixed text). Internally translate it to English "
                    "before parsing, then output the JSON result.\n\n"
                    "Query: " + raw_query
                )
            else:
                tint_query = raw_query

            from langchain_core.messages import HumanMessage, SystemMessage

            messages = [
                SystemMessage(content=PLANNER_SYSTEM_PROMPT),
                HumanMessage(content=tint_query),
            ]
            response = await asyncio.wait_for(
                self.client.ainvoke(messages), timeout=30.0,
            )
            content = response.content if hasattr(response, "content") else str(response)
            if not content or not content.strip():
                logger.warning("Planner LLM returned empty content, using fallback")
                return self._fallback_parse(raw_query)
            content = _strip_json_fences(content)
            try:
                parsed = json.loads(content)
                return ParsedQuery(**parsed)
            except (json.JSONDecodeError, Exception) as e:
                logger.warning(f"Planner LLM parse failed: {e}. Raw: {content[:200]}")
                return self._fallback_parse(raw_query)
        except TimeoutError:
            logger.warning("Planner LLM timed out after 30s, using fallback")
            return self._fallback_parse(raw_query)
        except Exception as e:
            logger.warning(f"Planner LLM failed: {type(e).__name__}: {e}, using fallback")
            return self._fallback_parse(raw_query)

    async def replan(
        self, original_query: str, previous_params: dict[str, Any], feedback: str,
    ) -> ParsedQuery:
        try:
            prompt = (
                "Original query: " + original_query + "\n"
                "Previous params: " + json.dumps(previous_params) + "\n"
                "Feedback: " + feedback + "\n"
                "Revise the search parameters. Output valid JSON only."
            )
            from langchain_core.messages import HumanMessage, SystemMessage

            messages = [
                SystemMessage(content=PLANNER_SYSTEM_PROMPT),
                HumanMessage(content=prompt),
            ]
            response = await asyncio.wait_for(
                self.client.ainvoke(messages), timeout=30.0,
            )
            content = response.content if hasattr(response, "content") else str(response)
            content = _strip_json_fences(content)
            if not content or not content.strip():
                logger.warning("Replan LLM returned empty content, using fallback")
                relaxed = self._relax_params(previous_params)
                return ParsedQuery(**relaxed)
            parsed = json.loads(content)
            return ParsedQuery(**parsed)
        except TimeoutError:
            logger.warning("Replan LLM timed out after 30s, using fallback")
            relaxed = self._relax_params(previous_params)
            return ParsedQuery(**relaxed)
        except Exception as e:
            logger.warning(f"Replan LLM failed, using fallback: {e}")
            relaxed = self._relax_params(previous_params)
            return ParsedQuery(**relaxed)

    def _fallback_parse(self, query: str) -> ParsedQuery:
        required: list[RequiredSkill] = []
        preferred: list[PreferredSkill] = []
        min_years: float | None = None
        max_years: float | None = None
        industry: str | None = None
        city: str | None = None

        for alias, aliases in SKILL_ALIASES.items():
            candidates = [alias] + aliases
            if any(qs in query.lower() for qs in candidates):
                required.append(
                    RequiredSkill(name=alias.title(), importance=SkillImportance.REQUIRED)
                )

        year_match = re.search(r"(\d+)\+?\s*(?:years?|yrs?)", query.lower())
        if year_match:
            min_years = float(year_match.group(1))

        year_range = re.search(r"(\d+)\s*[-to]+\s*(\d+)\s*(?:years?|yrs?)", query.lower())
        if year_range:
            min_years = float(year_range.group(1))
            max_years = float(year_range.group(2))

        for c in INDIAN_CITIES:
            if c.lower() in query.lower():
                city = c
                break

        for comp in INDIAN_COMPANIES:
            if comp.lower() in query.lower():
                industry = "technology"
                break

        return ParsedQuery(
            required_skills=required,
            preferred_skills=preferred,
            experience=ExperienceRequirements(  # noqa: E501
                min_years=min_years, max_years=max_years, industry=industry,
            ),
            location=LocationRequirements(city=city, remote_ok="remote" in query.lower()),
        )

    def _relax_params(self, params: dict) -> dict:
        params = dict(params)
        exp = dict(params.get("experience", {}))
        if exp.get("min_years") is not None:
            exp["min_years"] = max(0, exp["min_years"] - 2)
        if exp.get("max_years") is not None:
            exp["max_years"] = (exp["max_years"] or 0) + 3
        params["experience"] = exp

        loc = dict(params.get("location", {}))
        loc["city"] = None
        loc["remote_ok"] = True
        params["location"] = loc

        params["required_skills"] = []
        return params