Spaces:
Sleeping
Sleeping
| """ | |
| Career / Track Comparison Tool — Phase 4 core logic. | |
| Exports consumed by app.py: | |
| llm — LLM instance for the agent | |
| LANG_CONFIG — Language-specific prompts, criteria labels | |
| search_web — SERPER web-search tool instance | |
| create_comparator_agent — Factory → CrewAI Agent | |
| """ | |
| import os | |
| import json | |
| import requests | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| # Comparison now runs entirely on Groq: we do the web search ourselves (Serper) | |
| # and feed the results to a plain Groq JSON call. This avoids both the | |
| # OpenRouter dependency (out of credits) and CrewAI's Groq tool-calling issue. | |
| _GROQ_MODEL = "llama-3.3-70b-versatile" | |
| # --------------------------------------------------------------------------- | |
| # Language configuration | |
| # --------------------------------------------------------------------------- | |
| LANG_CONFIG = { | |
| "en": { | |
| "criteria": { | |
| "1": "Required Skills", | |
| "2": "Average Salary", | |
| "3": "Learning Duration", | |
| "4": "Market Demand", | |
| "5": "Difficulty Level", | |
| "6": "Job Opportunities", | |
| "7": "Alternative Paths", | |
| }, | |
| "task_prompt": ( | |
| "Search the web for the most recent available information about these tracks/careers: {tracks}\n\n" | |
| "Context: Location={location}, Currency={currency}. Reference year: {year} " | |
| "(use the latest data you can find — recent figures are perfectly fine as estimates for this year).\n" | |
| "Compare them on: {criteria}\n\n" | |
| "For EACH criterion and EACH track provide:\n" | |
| " - The actual data/value (give your best estimate from recent data — NEVER refuse or leave blank)\n" | |
| " - A source URL where this info was found\n\n" | |
| "IMPORTANT: You must always produce the comparison. If exact current-year data is unavailable, " | |
| "use the most recent figures and note them as estimates. Do not apologize or decline.\n\n" | |
| "Return ONLY valid JSON — no markdown, no preamble.\n" | |
| "CRITICAL STRUCTURE RULE: for every criterion, the \"cells\" array MUST contain exactly one " | |
| "object per track, in the SAME ORDER as the \"tracks\" array (cells[0] is for tracks[0], " | |
| "cells[1] is for tracks[1], and so on). Never merge two tracks into one cell and never leave a cell empty.\n\n" | |
| "{{\n" | |
| ' "tracks": ["First Track Name", "Second Track Name"],\n' | |
| ' "rows": [\n' | |
| ' {{\n' | |
| ' "criterion": "Required Skills",\n' | |
| ' "cells": [\n' | |
| ' {{"value": "value for the FIRST track", "source": "https://..."}},\n' | |
| ' {{"value": "value for the SECOND track", "source": "https://..."}}\n' | |
| " ]\n" | |
| " }}\n" | |
| " ],\n" | |
| ' "insights": {{\n' | |
| ' "highest_salary": "track name",\n' | |
| ' "fastest_growing": "track name",\n' | |
| ' "easiest_to_start": "track name",\n' | |
| ' "summary": "2-3 sentence overall summary"\n' | |
| " }}\n" | |
| "}}" | |
| ), | |
| "expected_output": ( | |
| "Valid JSON comparing the requested tracks, with a source URL " | |
| "for each data point and an insights summary." | |
| ), | |
| }, | |
| "ar": { | |
| "criteria": { | |
| "1": "المهارات المطلوبة", | |
| "2": "متوسط الراتب", | |
| "3": "مدة التعلم", | |
| "4": "الطلب في السوق", | |
| "5": "مستوى الصعوبة", | |
| "6": "فرص العمل", | |
| "7": "المسارات البديلة", | |
| }, | |
| "task_prompt": ( | |
| "ابحث على الإنترنت عن أحدث المعلومات المتاحة عن هذه المسارات/الوظائف: {tracks}\n\n" | |
| "السياق: الموقع={location}، العملة={currency}. السنة المرجعية: {year} " | |
| "(استخدم أحدث بيانات تجدها — الأرقام الحديثة مقبولة تماماً كتقديرات لهذه السنة).\n" | |
| "قارنها على أساس: {criteria}\n\n" | |
| "لكل معيار ولكل مسار قدّم:\n" | |
| " - القيمة الفعلية (أعطِ أفضل تقدير من البيانات الحديثة — لا ترفض أبداً ولا تترك فراغاً)\n" | |
| " - رابط المصدر الذي وجدت فيه المعلومة\n\n" | |
| "مهم: يجب دائماً إنتاج المقارنة. إذا لم تتوفر بيانات دقيقة للسنة الحالية، " | |
| "استخدم أحدث الأرقام المتاحة واعتبرها تقديرات. لا تعتذر ولا ترفض.\n\n" | |
| "أرجع فقط JSON صالح — بدون markdown أو مقدمة.\n" | |
| "قاعدة بنية حاسمة: لكل معيار، يجب أن تحتوي مصفوفة \"cells\" على عنصر واحد بالضبط لكل مسار، " | |
| "وبنفس ترتيب مصفوفة \"tracks\" (cells[0] للمسار الأول، cells[1] للمسار الثاني، وهكذا). " | |
| "لا تدمج مسارين في خلية واحدة ولا تترك أي خلية فارغة.\n\n" | |
| "{{\n" | |
| ' "tracks": ["اسم المسار الأول", "اسم المسار الثاني"],\n' | |
| ' "rows": [\n' | |
| ' {{\n' | |
| ' "criterion": "المهارات المطلوبة",\n' | |
| ' "cells": [\n' | |
| ' {{"value": "القيمة الخاصة بالمسار الأول", "source": "https://..."}},\n' | |
| ' {{"value": "القيمة الخاصة بالمسار الثاني", "source": "https://..."}}\n' | |
| " ]\n" | |
| " }}\n" | |
| " ],\n" | |
| ' "insights": {{\n' | |
| ' "highest_salary": "اسم المسار",\n' | |
| ' "fastest_growing": "اسم المسار",\n' | |
| ' "easiest_to_start": "اسم المسار",\n' | |
| ' "summary": "ملخص 2-3 جمل"\n' | |
| " }}\n" | |
| "}}" | |
| ), | |
| "expected_output": ( | |
| "JSON صالح يقارن المسارات المطلوبة مع رابط مصدر لكل نقطة بيانات وملخص insights." | |
| ), | |
| }, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Web search (Serper) + Groq comparison | |
| # --------------------------------------------------------------------------- | |
| def _serper(query: str, num: int = 4) -> list: | |
| key = os.environ.get("SERPER_API_KEY", "") | |
| if not key: | |
| return [] | |
| try: | |
| resp = requests.post( | |
| "https://google.serper.dev/search", | |
| headers={"X-API-KEY": key, "Content-Type": "application/json"}, | |
| json={"q": query, "num": num}, | |
| timeout=10, | |
| ) | |
| resp.raise_for_status() | |
| return resp.json().get("organic", [])[:num] | |
| except Exception as exc: | |
| print(f"[Comparison] Serper error: {exc}") | |
| return [] | |
| def _gather_context(tracks: list, location: str) -> str: | |
| """Collect real web snippets + source links for each track.""" | |
| blocks = [] | |
| for tr in tracks: | |
| snips = [] | |
| for q in ( | |
| f"{tr} required skills and qualifications {location}", | |
| f"{tr} average salary {location}", | |
| f"{tr} job market demand and growth {location}", | |
| ): | |
| for item in _serper(q, 3): | |
| title = item.get("title", "") | |
| snippet = item.get("snippet", "") | |
| link = item.get("link", "") | |
| if snippet: | |
| snips.append(f"- {title}: {snippet} (source: {link})") | |
| blocks.append(f"### {tr}\n" + ("\n".join(snips[:8]) or "- (no results found; use your best estimate)")) | |
| return "\n\n".join(blocks) | |
| def run_comparison(tracks_str: str, location: str, currency: str, | |
| year: str, criteria_str: str, lang: str = "en") -> str: | |
| """Produce the structured comparison JSON string (runs entirely on Groq).""" | |
| from groq import Groq | |
| cfg = LANG_CONFIG.get(lang, LANG_CONFIG["en"]) | |
| tracks = [t.strip() for t in tracks_str.split(",") if t.strip()] | |
| context = _gather_context(tracks, location) | |
| prompt = cfg["task_prompt"].format( | |
| tracks=tracks_str, location=location, currency=currency, | |
| year=year, criteria=criteria_str, | |
| ) | |
| prompt += ( | |
| "\n\n--- WEB SEARCH RESULTS (base your data and sources on these) ---\n" | |
| f"{context}\n" | |
| "Use these real results for the values and the source URLs. " | |
| "If something is missing, give your best estimate and still fill every cell." | |
| ) | |
| client = Groq(api_key=os.environ.get("GROQ_API_KEY", "")) | |
| resp = client.chat.completions.create( | |
| model=_GROQ_MODEL, | |
| messages=[ | |
| {"role": "system", "content": ( | |
| "You are an expert career research analyst. You always return valid JSON only, " | |
| "following the exact structure requested, and you never leave a cell empty." | |
| )}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| temperature=0.3, | |
| max_tokens=4000, | |
| response_format={"type": "json_object"}, | |
| ) | |
| return resp.choices[0].message.content | |