from fastapi import FastAPI, File, HTTPException, Request, UploadFile from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from transformers import AutoTokenizer, AutoModelForSeq2SeqLM from cachetools import TTLCache from PIL import Image from pypdf import PdfReader from docx import Document import fitz import hashlib import hmac import io import json import os import re import torch import pytesseract from urllib import request as urllib_request from urllib.error import URLError, HTTPError app = FastAPI(title="Local AI Service") # Shared secret for backend -> ai-service calls. This service has no user auth and can # generate against a paid provider (gemini/groq), so an unauthenticated caller on the # shared docker network could drain the API key. The port is no longer published to the # host (compose uses `expose`), and this header is the second layer. # # Unset => open, so local dev and the test suite work keyless. Production cannot reach # that state: docker-compose declares AI_SERVICE_TOKEN with `:?` so the stack refuses to # start without it. AI_SERVICE_TOKEN = os.getenv("AI_SERVICE_TOKEN", "").strip() AI_SERVICE_TOKEN_HEADER = "X-Ai-Service-Token" # /health stays open: the backend probe and the compose healthcheck both call it, and it # exposes no user data and no generation path. AI_SERVICE_OPEN_PATHS = {"/health"} @app.middleware("http") async def require_service_token(request: Request, call_next): if AI_SERVICE_TOKEN and request.url.path not in AI_SERVICE_OPEN_PATHS: supplied = request.headers.get(AI_SERVICE_TOKEN_HEADER, "") # compare_digest to avoid leaking the token through response timing. if not hmac.compare_digest(supplied, AI_SERVICE_TOKEN): return JSONResponse( {"detail": "Invalid or missing service token."}, status_code=401, ) return await call_next(request) MODEL_NAME = "sshleifer/distilbart-cnn-12-6" MAX_INPUT_CHARS = 20000 MAX_CONTEXT_CHARS = 2200 MAX_EXTRACT_FILE_BYTES = 8 * 1024 * 1024 OCR_LANGUAGES = "eng" IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "") # AI provider router. Structured /cv/* calls (the heavy ones) dispatch through the # active provider so production can offload a weak local GPU to a cloud provider. # Default stays "ollama" so the service works keyless/local. /summarize stays local # (distilbart) regardless of this setting. AI_PROVIDER = (os.getenv("AI_PROVIDER", "ollama").strip().lower() or "ollama") GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip() GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash").strip() GEMINI_BASE_URL = os.getenv("GEMINI_BASE_URL", "https://generativelanguage.googleapis.com").rstrip("/") GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip() GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile").strip() GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").rstrip("/") SKIP_MODEL_LOAD = os.getenv("AI_SERVICE_SKIP_MODEL_LOAD", "") == "1" EAGER_MODEL_LOAD = os.getenv("AI_SERVICE_EAGER_MODEL_LOAD", "") == "1" tokenizer = None model = None device = torch.device("cpu") GPU_AVAILABLE = False GPU_NAME = None MODEL_LOAD_ERROR = "Model loading is disabled by AI_SERVICE_SKIP_MODEL_LOAD." if SKIP_MODEL_LOAD else None MODEL_LOADED = False MODEL_DISABLED = SKIP_MODEL_LOAD def _load_runtime(): tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME) model.eval() has_cuda = torch.cuda.is_available() device = torch.device("cuda" if has_cuda else "cpu") model.to(device) gpu_name = torch.cuda.get_device_name(0) if has_cuda else None return tokenizer, model, device, has_cuda, gpu_name def _ensure_runtime_loaded(): global tokenizer, model, device, GPU_AVAILABLE, GPU_NAME, MODEL_LOAD_ERROR, MODEL_LOADED if MODEL_DISABLED: MODEL_LOAD_ERROR = "Model loading is disabled by AI_SERVICE_SKIP_MODEL_LOAD." return False if MODEL_LOADED and tokenizer is not None and model is not None: return True try: tokenizer, model, device, GPU_AVAILABLE, GPU_NAME = _load_runtime() MODEL_LOAD_ERROR = None MODEL_LOADED = True return True except Exception as exc: tokenizer, model = None, None device = torch.device("cpu") GPU_AVAILABLE = False GPU_NAME = None MODEL_LOADED = False MODEL_LOAD_ERROR = str(exc) return False if EAGER_MODEL_LOAD and not SKIP_MODEL_LOAD: _ensure_runtime_loaded() cache = TTLCache(maxsize=1024, ttl=60 * 60) class SummarizeRequest(BaseModel): text: str = Field(min_length=1, max_length=MAX_INPUT_CHARS) max_length: int = Field(default=160, ge=24, le=256) min_length: int = Field(default=45, ge=8, le=180) top_skills: int = Field(default=8, ge=3, le=12) class RewriteRequest(BaseModel): instruction: str = Field(min_length=1, max_length=6000) text: str = Field(min_length=1, max_length=MAX_INPUT_CHARS) max_length: int = Field(default=220, ge=24, le=256) min_length: int = Field(default=80, ge=8, le=180) class CvNormalizeRequest(BaseModel): text: str = Field(min_length=1, max_length=50000) class CvClassifyBlockRequest(BaseModel): block: str = Field(min_length=1, max_length=6000) def _key(text: str, max_length: int, min_length: int, top_skills: int) -> str: h = hashlib.sha256(text.encode("utf-8")).hexdigest() return f"{h}:{max_length}:{min_length}:{top_skills}" def _ollama_json(path: str): req = urllib_request.Request(f"{OLLAMA_BASE_URL}{path}", method="GET") with urllib_request.urlopen(req, timeout=5) as response: return json.loads(response.read().decode("utf-8")) def _ollama_status(): configured = bool(OLLAMA_MODEL) if not configured: return { "ollama_configured": False, "ollama_reachable": False, "ollama_model": None, "ollama_model_available": False, "ollama_version": None, "ollama_installed_models": [], "ollama_loaded_models": [], "ollama_loaded_count": 0, } try: tags_body = _ollama_json("/api/tags") version_body = _ollama_json("/api/version") try: ps_body = _ollama_json("/api/ps") except Exception: ps_body = {"models": []} except Exception: return { "ollama_configured": True, "ollama_reachable": False, "ollama_model": OLLAMA_MODEL, "ollama_model_available": False, "ollama_version": None, "ollama_installed_models": [], "ollama_loaded_models": [], "ollama_loaded_count": 0, } models = tags_body.get("models") or [] names = sorted({item.get("name") for item in models if isinstance(item, dict) and item.get("name")}) loaded_models = sorted({item.get("name") for item in (ps_body.get("models") or []) if isinstance(item, dict) and item.get("name")}) return { "ollama_configured": True, "ollama_reachable": True, "ollama_model": OLLAMA_MODEL, "ollama_model_available": OLLAMA_MODEL in names, "ollama_version": version_body.get("version"), "ollama_installed_models": names, "ollama_loaded_models": loaded_models, "ollama_loaded_count": len(loaded_models), } @app.get("/health") async def health(): return { "ok": True, "model": MODEL_NAME, "device": str(device), "gpu_available": GPU_AVAILABLE, "gpu_name": GPU_NAME, "ocr_available": True, "ocr_languages": OCR_LANGUAGES, "model_loaded": MODEL_LOADED, "model_disabled": MODEL_DISABLED, "summarize_available": MODEL_LOADED and not MODEL_DISABLED, "model_load_error": MODEL_LOAD_ERROR, "ai_provider": AI_PROVIDER, "ai_provider_configured": _provider_configured(), **_ollama_status(), } _TECH = [ "python", "c#", "dotnet", ".net", "java", "javascript", "typescript", "react", "node", "sql", "postgres", "postgresql", "mysql", "sqlite", "mongodb", "redis", "aws", "azure", "gcp", "docker", "kubernetes", "terraform", "linux", "git", "ci/cd", "graphql", "rest", ] _SOFT = [ "communication", "collaboration", "teamwork", "problem solving", "leadership", "mentoring", "ownership", "initiative", "adaptability", "stakeholder management", "detail oriented", ] _TECH_PRIORITY = [ "python", "c#", ".net", "dotnet", "typescript", "javascript", "react", "node", "sql", "postgresql", "postgres", "mysql", "sqlite", "docker", "kubernetes", "aws", "azure", "gcp", "terraform", "graphql", "rest", "git", ] _MUST_HAVE_HINTS = [ "must have", "required", "requirements", "you have", "you bring", "essential", "we are looking for", ] _NICE_TO_HAVE_HINTS = [ "nice to have", "bonus", "preferred", "advantageous", "extra plus", ] _SCREENING_HINTS = [ "experience with", "hands-on", "demonstrated", "proven", "track record", "delivered", ] def _rank_tech_skills(skills): ordered = [] seen = set() for preferred in _TECH_PRIORITY: for skill in skills: if skill == preferred and skill not in seen: ordered.append(skill) seen.add(skill) for skill in skills: if skill not in seen: ordered.append(skill) seen.add(skill) return ordered def _strip_html(text: str) -> str: text = re.sub(r"<\s*br\s*/?>", "\n", text, flags=re.IGNORECASE) text = re.sub(r"
", "\n", text, flags=re.IGNORECASE) text = re.sub(r"<[^>]+>", " ", text) return re.sub(r"\n{3,}", "\n\n", text).strip() def _extract_bullets(lines, max_items=8): out = [] for ln in lines: s = ln.strip() if not s: continue if re.match(r"^([-*]|\u2022)\s+", s): s = re.sub(r"^([-*]|\u2022)\s+", "", s).strip() if 3 <= len(s) <= 220: out.append(s) if len(out) >= max_items: break return out def _top_keywords(text: str, limit=6): words = re.findall(r"[a-zA-Z][a-zA-Z+#./-]{2,}", text.lower()) stop = { "with", "from", "that", "this", "will", "have", "your", "their", "about", "role", "team", "work", "experience", "skills", "requirements", "responsibilities", "company", "using", "ability", "years", "looking", "candidate", "position", "working", "across", "strong", "building", "support", } counts = {} for word in words: if word in stop or word in _TECH or word in _SOFT: continue counts[word] = counts.get(word, 0) + 1 ordered = sorted(counts.items(), key=lambda item: (-item[1], item[0])) return [word for word, _ in ordered[:limit]] def _first_matching_sentences(text: str, hints, limit=3): sentences = re.split(r"(?<=[.!?])\s+", text) found = [] for sentence in sentences: low = sentence.lower() if any(hint in low for hint in hints): cleaned = sentence.strip() if 20 <= len(cleaned) <= 220: found.append(cleaned) if len(found) >= limit: break return found def _trim_line(text: str, max_len: int = 140) -> str: text = re.sub(r"\s+", " ", text).strip(" -•\t") if len(text) <= max_len: return text return text[: max_len - 1].rstrip() + "…" def _role_focused_excerpt(text: str) -> dict: cleaned = _strip_html(text) lines = [ln.strip() for ln in cleaned.splitlines()] headings = { "responsibilities": ["responsibilities", "what you will do", "what you'll do", "the role", "your role", "you will"], "requirements": ["requirements", "what we are looking for", "what we're looking for", "skills", "experience", "must have"], "nice": ["nice to have", "bonus", "preferred"], } def match_heading(s: str): sl = s.lower().strip(":-\x7f ") for key, words in headings.items(): for word in words: if sl == word or sl.startswith(word + " "): return key return None section = None resp_lines = [] req_lines = [] nice_lines = [] for ln in lines: if not ln: continue heading = match_heading(ln) if heading: section = heading continue if section == "responsibilities": resp_lines.append(ln) elif section == "requirements": req_lines.append(ln) elif section == "nice": nice_lines.append(ln) responsibilities = _extract_bullets(resp_lines, max_items=7) requirements = _extract_bullets(req_lines, max_items=7) nice = _extract_bullets(nice_lines, max_items=5) tech_found = [] soft_found = [] low = cleaned.lower() for t in _TECH: if t in low: tech_found.append(t) for s in _SOFT: if s in low: soft_found.append(s) if not responsibilities and not requirements: any_bullets = _extract_bullets(lines, max_items=10) responsibilities = any_bullets[:6] requirements = any_bullets[6:10] if not requirements: requirements = [_trim_line(x) for x in _first_matching_sentences(cleaned, _MUST_HAVE_HINTS, limit=4)] if not nice: nice = [_trim_line(x) for x in _first_matching_sentences(cleaned, _NICE_TO_HAVE_HINTS, limit=3)] focused_parts = [] if responsibilities: focused_parts.append("Responsibilities:\n- " + "\n- ".join(responsibilities)) if requirements: focused_parts.append("Requirements:\n- " + "\n- ".join(requirements)) if nice: focused_parts.append("Nice to have:\n- " + "\n- ".join(nice)) focused_parts.append("Context:\n" + cleaned[:MAX_CONTEXT_CHARS]) screen_focus = [] for item in requirements[:4]: if any(hint in item.lower() for hint in _SCREENING_HINTS) or len(screen_focus) < 2: screen_focus.append(_trim_line(item)) if not screen_focus: screen_focus = [_trim_line(x) for x in _first_matching_sentences(cleaned, _SCREENING_HINTS, limit=3)] return { "cleaned": cleaned, "focused_input": "\n\n".join(focused_parts), "responsibilities": responsibilities, "requirements": requirements, "nice": nice, "tech": tech_found, "soft": soft_found, "keywords": _top_keywords(cleaned), "screen_focus": screen_focus[:3], } def _model_summarize(text: str, max_length: int, min_length: int) -> str: if not _ensure_runtime_loaded() or tokenizer is None or model is None: raise HTTPException(status_code=503, detail=MODEL_LOAD_ERROR or "Summarizer model is not loaded.") inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=1024) input_ids = inputs.input_ids.to(device) attention_mask = inputs.attention_mask.to(device) if hasattr(inputs, "attention_mask") else None with torch.no_grad(): outputs = model.generate( input_ids, attention_mask=attention_mask, max_length=max_length, min_length=min_length, num_beams=3, length_penalty=1.0, no_repeat_ngram_size=3, early_stopping=True, ) return tokenizer.decode(outputs[0], skip_special_tokens=True).strip() _PROVIDER_DISPLAY = {"ollama": "Ollama", "gemini": "Gemini", "groq": "Groq"} def _provider_display(provider: str) -> str: return _PROVIDER_DISPLAY.get(provider, provider or "AI provider") def _provider_configured() -> bool: if AI_PROVIDER == "gemini": return bool(GEMINI_API_KEY) if AI_PROVIDER == "groq": return bool(GROQ_API_KEY) return bool(OLLAMA_MODEL) def _http_post_json(url: str, payload: dict, headers: dict, timeout: int) -> dict: data = json.dumps(payload).encode("utf-8") req = urllib_request.Request( url, data=data, headers={"Content-Type": "application/json", **headers}, method="POST", ) with urllib_request.urlopen(req, timeout=timeout) as response: return json.loads(response.read().decode("utf-8")) def _ollama_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str: if not OLLAMA_MODEL: raise HTTPException(status_code=503, detail="OLLAMA_MODEL is not configured.") payload = { "model": OLLAMA_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": temperature}, } if json_mode: payload["format"] = "json" body = _http_post_json(f"{OLLAMA_BASE_URL}/api/generate", payload, {}, timeout) return (body.get("response") or "").strip() def _gemini_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str: if not GEMINI_API_KEY: raise HTTPException(status_code=503, detail="GEMINI_API_KEY is not configured.") generation_config = {"temperature": temperature} if json_mode: generation_config["responseMimeType"] = "application/json" payload = { "contents": [{"role": "user", "parts": [{"text": prompt}]}], "generationConfig": generation_config, } # Pass the key via header (not the URL query string, which can leak into logs). url = f"{GEMINI_BASE_URL}/v1beta/models/{GEMINI_MODEL}:generateContent" body = _http_post_json(url, payload, {"x-goog-api-key": GEMINI_API_KEY}, timeout) candidates = body.get("candidates") or [] if not candidates: return "" parts = (candidates[0].get("content") or {}).get("parts") or [] return "".join(part.get("text", "") for part in parts).strip() def _groq_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str: if not GROQ_API_KEY: raise HTTPException(status_code=503, detail="GROQ_API_KEY is not configured.") payload = { "model": GROQ_MODEL, "messages": [{"role": "user", "content": prompt}], "temperature": temperature, } if json_mode: payload["response_format"] = {"type": "json_object"} url = f"{GROQ_BASE_URL}/chat/completions" body = _http_post_json(url, payload, {"Authorization": f"Bearer {GROQ_API_KEY}"}, timeout) choices = body.get("choices") or [] if not choices: return "" return ((choices[0].get("message") or {}).get("content") or "").strip() def _provider_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str: provider = AI_PROVIDER try: if provider == "gemini": return _gemini_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) if provider == "groq": return _groq_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) return _ollama_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) except HTTPException: raise except HTTPError as ex: raise HTTPException(status_code=502, detail=f"{_provider_display(provider)} request failed with {ex.code}.") except URLError as ex: raise HTTPException(status_code=503, detail=f"{_provider_display(provider)} is unreachable: {ex.reason}.") def _ollama_generate_json(prompt: str): raw = _provider_generate(prompt, json_mode=True, temperature=0.1, timeout=120) if not raw: raise HTTPException(status_code=502, detail=f"{_provider_display(AI_PROVIDER)} returned an empty response.") try: return json.loads(raw) except json.JSONDecodeError: start = raw.find("{") end = raw.rfind("}") if start >= 0 and end > start: return json.loads(raw[start:end + 1]) raise HTTPException(status_code=502, detail=f"{_provider_display(AI_PROVIDER)} did not return valid JSON.") def _ollama_generate_text(prompt: str) -> str: raw = _provider_generate(prompt, json_mode=False, temperature=0.2, timeout=180) if not raw: raise HTTPException(status_code=502, detail=f"{_provider_display(AI_PROVIDER)} returned an empty rewrite.") return raw @app.post("/cv/normalize") async def normalize_cv(req: CvNormalizeRequest): prompt = f""" You normalize messy CV text into parser-friendly master-CV text. Return ONLY valid JSON with this exact shape: {{ "confidence": 0.0, "reason": "short reason", "normalized_text": "string" }} Rules for normalized_text: - Preserve facts only. Do not invent. - Use markdown section headings exactly like these when data exists: # Contact # Professional Summary # Work Experience # Education # Skills # Languages # Interests - Under # Contact, put one plain value per line, no labels unless unavoidable: Full name line email line phone line website line location line - Under # Professional Summary, write 1-3 plain sentences or bullet lines. - Preserve explicitly mentioned technologies, tools, and methods as skills when they appear in the source. - Never output helper words like "line", "value", "field", or "item". - Under # Work Experience, for each job use this exact shape: Job title only Company, Location 2019 - Present - bullet - bullet - Under # Education, for each entry use this exact shape: Qualification line Institution, Location line 2016 - 2019 line - detail - Under # Skills and # Languages, use one bullet per item. - Remove OCR/layout noise. - Do not output placeholders like Not specified. - If uncertain, omit the field/line rather than invent. The text below <<