File size: 8,738 Bytes
7ff6662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Job Assessor β€” 2-phase pipeline.

Phase 1 (INSTANT): Keyword-based scoring for all jobs.
  - No LLM calls β€” pure keyword/signal matching against Saiteja's profile
  - Scores 1-10 based on title, skills, and description signals
  - Takes <1 second for any number of jobs

Phase 2 (LLM, top jobs only): Detailed assessment for top PHASE2_MAX jobs.
  - Sends to multi-model pool (GLM + Qwen + GPT-OSS + others)
  - Each model picks up batches from shared queue as it finishes
  - Fallback: if all models fail, keeps keyword score from Phase 1
"""

import re
from colorama import Fore, Style, init

from .model_pool import ModelPool, PHASE2_THRESHOLD, PHASE2_MAX
from .scrapers.base import Job

init(autoreset=True)

# PM keywords for pre-filtering
PM_KEYWORDS = {
    "product manager", "product owner", "product lead", "product director",
    "vp product", "head of product", "chief product", "product strategist",
    "technical product", "growth product", "ai product", "data product",
    "platform product", "saas product", "product marketing", "program manager",
    "product operations", "product analyst", "associate product", "senior product",
    "lead product", "product development", "product management",
}

BLOCK_KEYWORDS = {
    "teacher", "faculty", "lecturer", "professor", "tutor",
    "civil engineer", "mechanical engineer", "electrical engineer",
    "accountant", "chartered accountant", " ca ", "nurse", "doctor",
    "chef", "cook", "driver", "security guard",
    "hardware engineer", "network engineer", "rf engineer",
    "sap consultant", "sap basis", "sap abap", "erp consultant",
    "custody product", "care advisor", "specialty care", "speciality care",
}

# High-match signals for Saiteja's profile
HIGH_MATCH_SIGNALS = {
    # Role signals (title)
    "title": [
        "ai product", "ai pm", "conversational", "edtech", "growth product",
        "growth pm", "technical product", "technical pm", "platform product",
        "data product", "saas product", "senior product manager",
        "lead product", "head of product", "product director",
    ],
    # Skill signals (description)
    "skills": [
        "funnel optimization", "funnel", "conversion rate", "a/b test",
        "conversational ai", "llm", "chatbot", "ocr", "automation pipeline",
        "crm", "webengage", "whatsapp", "growth hacking", "user acquisition",
        "retention", "cohort", "edtech", "b2c", "0 to 1", "0β†’1",
        "product-led", "viral", "referral", "engagement",
    ],
    # Company type signals
    "company": [
        "swiggy", "zomato", "meesho", "razorpay", "cred", "juspay",
        "groww", "zepto", "blinkit", "nykaa", "mamaearth", "unacademy",
        "byju", "classplus", "vedantu", "toppr", "upgrad", "scaler",
        "google", "microsoft", "amazon", "meta", "flipkart", "myntra",
        "ola", "uber", "paytm", "phonepe", "gpay", "freshworks", "zoho",
    ],
}

MEDIUM_MATCH_SIGNALS = {
    "title": [
        "product owner", "associate product", "product manager",
        "product specialist", "product analyst",
    ],
    "skills": [
        "product strategy", "roadmap", "agile", "scrum", "user research",
        "ux research", "data-driven", "kpi", "analytics", "feature prioritization",
        "prd", "go-to-market", "gtm", "cross-functional", "stakeholder",
    ],
}


def _is_relevant(title: str) -> bool:
    """Use the same PM filter as the scraper for consistency."""
    from .scrapers.base import BaseScraper
    return BaseScraper.is_pm_role(title)


def keyword_score(job: Job) -> int:
    """
    Instant 1-10 score based on title, description, and company signals.
    No LLM required β€” deterministic, sub-millisecond.
    """
    title = (job.title or "").lower()
    desc  = (job.description or "").lower()
    comp  = (job.company or "").lower()
    text  = title + " " + desc + " " + comp

    score = 4  # baseline for PM-relevant job

    # High match β€” title signals (+3)
    for sig in HIGH_MATCH_SIGNALS["title"]:
        if sig in title:
            score += 3
            break

    # High match β€” skill signals (+2)
    skill_hits = sum(1 for sig in HIGH_MATCH_SIGNALS["skills"] if sig in text)
    score += min(skill_hits * 1, 3)

    # Company signals (+1)
    for sig in HIGH_MATCH_SIGNALS["company"]:
        if sig in comp:
            score += 1
            break

    # Medium match β€” title signals (+1)
    for sig in MEDIUM_MATCH_SIGNALS["title"]:
        if sig in title:
            score += 1
            break

    # Penalise if description is empty (less info)
    if not job.description:
        score -= 1

    # Clamp to 1-10
    return max(1, min(10, score))


class JobAssessor:
    BATCH_SIZE = 8   # Jobs per LLM call β€” all fast models handle 8 at once

    def __init__(self, model_pool: ModelPool, compact_profile: str):
        self.pool            = model_pool
        self.compact_profile = compact_profile

    def assess_all(self, jobs: list[Job]) -> list[dict]:
        # ── Step 1: Pre-filter by title ──────────────────────────────────
        relevant   = [j for j in jobs if _is_relevant(j.title)]
        irrelevant = [j for j in jobs if not _is_relevant(j.title)]

        print(f"\n{Fore.CYAN}Pre-filter: {len(relevant)} PM-relevant | {len(irrelevant)} irrelevant (skipped){Style.RESET_ALL}")

        skipped_results = [
            {
                **j.to_dict(),
                "relevance_score": 1, "skills_match_percentage": 0,
                "experience_match": "Not Relevant", "matching_skills": "",
                "missing_skills": "", "key_strengths": "",
                "recommendation": "Not a PM role β€” filtered.",
                "ats_keywords": "", "application_priority": "Low",
                "_raw_assessment": {}, "_assessed_by": "pre-filter",
            }
            for j in irrelevant
        ]

        if not relevant:
            return skipped_results

        # ── Step 2: Phase 1 β€” instant keyword scoring ─────────────────────
        print(f"{Fore.CYAN}Phase 1: Keyword scoring {len(relevant)} jobs (instant)...{Style.RESET_ALL}")
        for job in relevant:
            job._keyword_score = keyword_score(job)

        kw_high = sum(1 for j in relevant if j._keyword_score >= 7)
        kw_med  = sum(1 for j in relevant if 5 <= j._keyword_score < 7)
        print(f"{Fore.GREEN}βœ“ Keyword scoring done β†’ High(7+): {kw_high}  Medium(5-6): {kw_med}{Style.RESET_ALL}")

        # ── Step 3: Phase 2 β€” LLM detailed assessment of ALL relevant jobs ──
        # Process ALL PM jobs (no cap) β€” fast models handle batches quickly
        top_jobs = sorted(relevant, key=lambda x: x._keyword_score, reverse=True)

        print(f"\n{Fore.YELLOW}Phase 2: LLM detailed assessment of ALL {len(top_jobs)} PM jobs...{Style.RESET_ALL}")

        llm_results: dict[str, dict] = {}   # url β†’ assessment dict

        if top_jobs:
            assessed = self.pool.process_all(
                jobs=top_jobs,
                compact_profile=self.compact_profile,
                batch_size=self.BATCH_SIZE,
            )
            for r in assessed:
                url = r.get("url", "")
                if url:
                    llm_results[url] = r

        # ── Step 4: Merge β€” LLM result if available, else keyword score ──
        final = []

        for job in relevant:
            url = job.url or ""
            if url in llm_results:
                # Use LLM result (already a full dict)
                final.append(llm_results[url])
            else:
                # Use keyword score
                score = job._keyword_score
                final.append({
                    **job.to_dict(),
                    "relevance_score":         score,
                    "skills_match_percentage": min(score * 10, 100),
                    "experience_match":        "Good fit" if score >= 7 else "Unknown",
                    "matching_skills":         "",
                    "missing_skills":          "",
                    "key_strengths":           "",
                    "recommendation":          f"Keyword score: {score}/10. LLM assessment not run.",
                    "ats_keywords":            "",
                    "application_priority":    "High" if score >= 8 else ("Medium" if score >= 6 else "Low"),
                    "_raw_assessment":         {"score": score},
                    "_assessed_by":            "keyword",
                })

        all_results = final + skipped_results
        all_results.sort(key=lambda x: x.get("relevance_score", 0), reverse=True)
        return all_results