import json import os import fitz import re from datetime import datetime, timezone from fastapi import FastAPI, File, HTTPException, UploadFile from pydantic import BaseModel from CV_Parser.Extraction import extract_and_infer_profile, test_llm_connection from groq import Groq from prompts import INTERVIEW_EVALUATION_PROMPT, INTERVIEW_QUESTION_PROMPT app = FastAPI( title="AI Service", description="CV Parsing + Candidate Profile Extraction", version="1.1.0", ) SECTION_HINTS = ( "profile", "projects", "education", "professional experience", "experience", "skills & languages", "certificates & achievements", "courses", ) SECTION_BOUNDARIES = ( "professional experience", "certificates & achievements", "skills & languages", "education", "projects", "profile", "courses", ) def split_section_boundaries(text): normalized = str(text or "") for heading in sorted(SECTION_BOUNDARIES, key=len, reverse=True): pattern = rf"(?i)(? 220) short_lines = sum(1 for line in lines if len(line) < 18) return (heading_hits * 20) + len(lines) - long_lines - (short_lines // 3) def order_blocks_by_columns(page, blocks): page_mid = page.rect.width / 2 left_column = [] right_column = [] full_order = [] for block in blocks: if len(block) < 5: continue text = str(block[4]).strip() if not text: continue x0, y0, x1, y1 = block[:4] entry = (y0, x0, text) full_order.append(entry) if x1 <= page_mid: left_column.append(entry) elif x0 >= page_mid: right_column.append(entry) elif x0 < page_mid: left_column.append(entry) else: right_column.append(entry) left_text = "\n".join(text for _, _, text in sorted(left_column)) right_text = "\n".join(text for _, _, text in sorted(right_column)) full_text = "\n".join(text for _, _, text in sorted(full_order)) return [candidate for candidate in (left_text + ("\n" + right_text if right_text else ""), full_text) if candidate.strip()] def extract_page_text(page): candidates = [] plain_text = page.get_text("text", sort=True) or "" if plain_text.strip(): candidates.append(plain_text) blocks = page.get_text("blocks", sort=False) or [] candidates.extend(order_blocks_by_columns(page, blocks)) words = page.get_text("words", sort=True) or [] if words: word_lines = {} for word in words: if len(word) < 5: continue text = str(word[4]).strip() if not text: continue y_key = round(float(word[1]), 1) x_key = float(word[0]) word_lines.setdefault(y_key, []).append((x_key, text)) reconstructed = [] for _, entries in sorted(word_lines.items()): reconstructed.append(" ".join(text for _, text in sorted(entries))) word_text = "\n".join(reconstructed) if word_text.strip(): candidates.append(word_text) best_text = max(candidates, key=score_text_layout, default="") if not best_text.strip(): try: ocr_text_page = page.get_textpage_ocr(language="eng") best_text = page.get_text(textpage=ocr_text_page) or "" except Exception: best_text = plain_text return best_text class CVRequest(BaseModel): clean_cv_text: str class InterviewSessionRequest(BaseModel): profile: dict question_count: int = 5 class InterviewAnswerRequest(BaseModel): profile: dict question: str answer: str history: list[dict] = [] def get_groq_client(): api_key = os.environ.get("GROQ_API_KEY") if not api_key: raise ValueError("GROQ_API_KEY is not set") return Groq(api_key=api_key) def normalize_text(value): return " ".join(str(value or "").split()).strip() def normalize_list(values): return [normalize_text(value) for value in values or [] if normalize_text(value)] def sanitize_profile_for_interview(profile): candidate = profile.get("candidate", {}) if isinstance(profile, dict) else {} extraction = profile.get("extraction", {}) if isinstance(profile, dict) else {} return { "candidate": { "fullName": normalize_text(candidate.get("fullName")), "currentRole": normalize_text(candidate.get("currentRole")), "suggestedRole": normalize_text(candidate.get("suggestedRole")), "experienceYears": candidate.get("experienceYears", 0), "experienceLevel": normalize_text(candidate.get("experienceLevel")), "summary": normalize_text(candidate.get("summary")), }, "extraction": { "skills": normalize_list(extraction.get("skills")), "highlights": normalize_list(extraction.get("highlights")), "experience": normalize_list(extraction.get("experience")), "projects": normalize_list(extraction.get("projects")), "certifications": normalize_list(extraction.get("certifications")), }, } def fallback_interview_plan(profile, question_count=5): candidate = profile["candidate"] extraction = profile["extraction"] target_role = ( candidate.get("suggestedRole") or candidate.get("currentRole") or "the target role" ) focus_areas = normalize_list( [ target_role, *extraction.get("skills", [])[:2], *extraction.get("projects", [])[:1], "communication", ] )[:4] questions = [ { "id": "q1", "category": "Personal", "prompt": f"Tell me a bit about yourself and why you want {target_role}.", "why": "Opens the interview with role motivation and self-presentation.", }, { "id": "q2", "category": "Personal", "prompt": "What project from your CV are you most proud of?", "why": "Checks ownership, impact, and storytelling.", }, { "id": "q3", "category": "Technical", "prompt": ( f"What skill will help you most as a {target_role}, and where did you use it?" ), "why": "Connects declared skills to real experience.", }, { "id": "q4", "category": "Technical", "prompt": "Tell me about a technical problem you solved in a project.", "why": "Assesses reasoning, resilience, and execution.", }, { "id": "q5", "category": "Personal", "prompt": "What skill would you like to improve next?", "why": "Measures self-awareness and growth mindset.", }, ] return { "interviewerIntro": ( f"Hi {candidate.get('fullName') or 'there'}, I will simulate a live interview for " f"{target_role}. Answer naturally, and I will give coaching feedback after each response." ), "focusAreas": focus_areas, "questions": questions[: max(1, min(question_count, len(questions)))], } def parse_json_response(text): cleaned = str(text or "").strip() if cleaned.startswith("```json"): cleaned = cleaned[7:] if cleaned.startswith("```"): cleaned = cleaned[3:] if cleaned.endswith("```"): cleaned = cleaned[:-3] cleaned = cleaned.strip() if cleaned.startswith("{") and cleaned.endswith("}"): return json.loads(cleaned) start = cleaned.find("{") end = cleaned.rfind("}") if start != -1 and end != -1 and end > start: return json.loads(cleaned[start : end + 1]) return json.loads(cleaned) def request_interview_plan(profile, question_count=5): client = get_groq_client() model = os.environ.get("GROQ_MODEL", "llama-3.1-8b-instant") prompt_payload = json.dumps( { "questionCount": question_count, "profile": profile, }, ensure_ascii=False, ) completion = client.chat.completions.create( model=model, temperature=0.4, messages=[ {"role": "system", "content": INTERVIEW_QUESTION_PROMPT}, {"role": "user", "content": prompt_payload}, ], ) parsed = parse_json_response(completion.choices[0].message.content) questions = parsed.get("questions") if isinstance(parsed, dict) else None if not isinstance(questions, list) or not questions: raise ValueError("Interview plan did not return any questions") return { "interviewerIntro": normalize_text(parsed.get("interviewerIntro")), "focusAreas": normalize_list(parsed.get("focusAreas"))[:6], "questions": [ { "id": normalize_text(item.get("id")) or f"q{index + 1}", "category": normalize_text(item.get("category")) or "general", "prompt": normalize_text(item.get("prompt")), "why": normalize_text(item.get("why")), } for index, item in enumerate(questions) if normalize_text(item.get("prompt")) ][: max(1, question_count)], } def evaluate_answer_fallback(question, answer): answer_text = normalize_text(answer) word_count = len(answer_text.split()) score = 5 if word_count >= 25: score += 2 if word_count >= 45: score += 1 if any(token in answer_text.lower() for token in ["because", "result", "impact", "learned", "improved"]): score += 1 score = max(3, min(score, 9)) strengths = [] improvements = [] if word_count >= 25: strengths.append("You gave enough detail to understand your thinking.") else: improvements.append("Add more detail so the interviewer can judge your contribution.") if any(token in answer_text.lower() for token in ["result", "impact", "improved", "increased", "reduced"]): strengths.append("You hinted at outcomes, which makes the answer stronger.") else: improvements.append("Mention the result or impact of your work.") if not improvements: improvements.append("Make the structure even clearer using situation, action, and result.") return { "score": score, "strengths": strengths or ["Your answer addressed the question directly."], "improvements": improvements[:2], "followUpQuestion": f"Can you give one specific example related to: {normalize_text(question)}", "coachReply": "Solid start. Tighten the structure and make your impact more explicit.", } def request_answer_evaluation(profile, question, answer, history): client = get_groq_client() model = os.environ.get("GROQ_MODEL", "llama-3.1-8b-instant") prompt_payload = json.dumps( { "profile": profile, "question": normalize_text(question), "answer": normalize_text(answer), "history": history[-3:], }, ensure_ascii=False, ) completion = client.chat.completions.create( model=model, temperature=0.3, messages=[ {"role": "system", "content": INTERVIEW_EVALUATION_PROMPT}, {"role": "user", "content": prompt_payload}, ], ) parsed = parse_json_response(completion.choices[0].message.content) return { "score": max(1, min(int(parsed.get("score", 0) or 0), 10)), "strengths": normalize_list(parsed.get("strengths"))[:3], "improvements": normalize_list(parsed.get("improvements"))[:3], "followUpQuestion": normalize_text(parsed.get("followUpQuestion")), "coachReply": normalize_text(parsed.get("coachReply")), } def extract_text_from_pdf(file_bytes): pages = [] pdf = fitz.open(stream=file_bytes, filetype="pdf") for page in pdf: page_text = extract_page_text(page) if len(page_text.strip()) < 40: try: ocr_text_page = page.get_textpage_ocr(language="eng") ocr_text = page.get_text(textpage=ocr_text_page) or "" if len(ocr_text.strip()) > len(page_text.strip()): page_text = ocr_text except Exception: pass if page_text.strip(): pages.append(page_text) return "\n".join(pages) def clean_text(text): normalized = ( str(text or "") .replace("\u00a0", " ") .replace("\u2022", "- ") .replace("\u2013", "-") .replace("\u2014", "-") ) normalized = split_section_boundaries(normalized) cleaned_lines = [] for raw_line in normalized.splitlines(): line = " ".join(raw_line.split()).strip() if line: cleaned_lines.append(line) return "\n".join(cleaned_lines) def analyze_cv_bytes(file_bytes): raw_text = extract_text_from_pdf(file_bytes) clean_cv_text = clean_text(raw_text) if len(clean_cv_text.strip()) < 30: raise HTTPException( status_code=422, detail=( "No readable text could be extracted from this PDF. " "The file may be scanned or image-based and requires OCR." ), ) result = extract_and_infer_profile(clean_cv_text) if result["status"] == "error": raise HTTPException(status_code=500, detail=result["message"]) result["data"]["metadata"]["rawTextLength"] = len(raw_text) result["data"]["metadata"]["cleanTextLength"] = len(clean_cv_text) return clean_cv_text, result @app.get("/") async def root(): return {"message": "AI Service API is running successfully"} @app.get("/health/llm") async def health_llm(): checked_at = datetime.now(timezone.utc).isoformat() try: result = test_llm_connection() return { "status": "success", "service": "llm", "checkedAt": checked_at, **result, } except Exception as error: raise HTTPException( status_code=503, detail={ "status": "error", "service": "llm", "checkedAt": checked_at, "message": str(error), }, ) @app.post("/parse-cv") async def parse_cv(file: UploadFile = File(...)): try: content = await file.read() raw_text = extract_text_from_pdf(content) clean_cv_text = clean_text(raw_text) return { "status": "success", "clean_cv_text": clean_cv_text, } except HTTPException: raise except Exception as error: raise HTTPException(status_code=500, detail=str(error)) @app.post("/extract-profile") async def extract_profile(request: CVRequest): if not request.clean_cv_text.strip(): raise HTTPException( status_code=400, detail="clean_cv_text cannot be empty", ) result = extract_and_infer_profile(request.clean_cv_text) if result["status"] == "error": raise HTTPException(status_code=500, detail=result["message"]) return result @app.post("/analyze-cv") async def analyze_cv(file: UploadFile = File(...)): try: content = await file.read() clean_cv_text, result = analyze_cv_bytes(content) return { "status": "success", "message": "CV parsed and analyzed successfully", "clean_cv_text": clean_cv_text, "data": result["data"], } except HTTPException: raise except Exception as error: raise HTTPException(status_code=500, detail=str(error)) @app.post("/interview/session/start") async def start_interview_session(request: InterviewSessionRequest): profile = sanitize_profile_for_interview(request.profile) question_count = max(1, min(int(request.question_count or 5), 7)) try: try: plan = request_interview_plan(profile, question_count=question_count) source = "llm" except Exception: plan = fallback_interview_plan(profile, question_count=question_count) source = "fallback" return { "status": "success", "message": "Interview session created successfully", "data": { **plan, "questionCount": len(plan["questions"]), "source": source, }, } except Exception as error: raise HTTPException(status_code=500, detail=str(error)) @app.post("/interview/session/answer") async def evaluate_interview_answer(request: InterviewAnswerRequest): question = normalize_text(request.question) answer = normalize_text(request.answer) if not question: raise HTTPException(status_code=400, detail="question cannot be empty") if not answer: raise HTTPException(status_code=400, detail="answer cannot be empty") profile = sanitize_profile_for_interview(request.profile) history = request.history if isinstance(request.history, list) else [] try: try: evaluation = request_answer_evaluation(profile, question, answer, history) source = "llm" except Exception: evaluation = evaluate_answer_fallback(question, answer) source = "fallback" return { "status": "success", "message": "Interview answer evaluated successfully", "data": { **evaluation, "source": source, }, } except Exception as error: raise HTTPException(status_code=500, detail=str(error))