Spaces:
Sleeping
Sleeping
| """ | |
| Track Discovery Interview Engine | |
| Port of RAG_FINALLL_V_LAST.ipynb โ stateful HTTP-friendly module. | |
| Session lifecycle (called from app.py routes): | |
| engine.start_session(user_name, path_type, email) โ {session_id, question, ...} | |
| engine.answer(session_id, answer) โ {done, question, ...} OR {done:True, result} | |
| engine.get_result(session_id) โ result dict (if done) | |
| """ | |
| import os | |
| import json | |
| import uuid | |
| import re | |
| from pathlib import Path | |
| from typing import Optional | |
| from dataclasses import dataclass, field | |
| from dotenv import load_dotenv | |
| from langchain_core.documents import Document | |
| from langchain_core.messages import HumanMessage, SystemMessage | |
| from langchain_huggingface import HuggingFaceEmbeddings | |
| from langchain_chroma import Chroma | |
| from langchain_text_splitters import RecursiveCharacterTextSplitter | |
| load_dotenv() | |
| # โโ paths โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # RAG data ships inside the backend (back/data) so it can deploy standalone. | |
| # DATA_DIR / CHROMA_DIR can be overridden via env (e.g. a mounted disk). | |
| _BACK_DIR = Path(__file__).parent | |
| _DATA_DIR = Path(os.getenv("DATA_DIR", _BACK_DIR / "data")) | |
| _FRONT_DATA = _BACK_DIR.parent / "Front" / "graduation-project--front" / "src" / "data" | |
| def _data_path(name: str) -> Path: | |
| """Prefer back/data; fall back to the legacy frontend data folder.""" | |
| p = _DATA_DIR / name | |
| return p if p.exists() else (_FRONT_DATA / name) | |
| COLLEGES_JSON = _data_path("chatbot_final_data.json") | |
| TRACKS_XLSX = _data_path("all_tracks.xlsx") | |
| CHROMA_DIR = os.getenv("CHROMA_DIR", str(_BACK_DIR / "chroma_interview")) | |
| # โโ LLM tier assignment (Groq now; OpenRouter variants kept in llms.py) โโ | |
| from llms import groq_llm as _q_llm # interview questions | |
| from llms import groq_llm as _ext_llm # keyword extraction | |
| TOTAL_QUESTIONS = 4 | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Session dataclass | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| class InterviewSession: | |
| session_id: str | |
| user_name: str | |
| path_type: str # "track_only" | "college_and_track" | |
| email: Optional[str] = None | |
| # Profile context (pulled from the user's account at signup) | |
| age: Optional[int] = None | |
| status: Optional[str] = None # "student" | "graduate" | |
| study_level: Optional[str] = None # "high_school" | "college" | |
| conversation_history: list = field(default_factory=list) | |
| current_question: int = 0 # which question is currently pending | |
| pending_question: str = "" # text of the question currently asked | |
| done: bool = False | |
| result: Optional[dict] = None | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Document builders (mirror notebook cells 3 & 4) | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def _build_college_docs(json_path: Path) -> list: | |
| with open(json_path, encoding="utf-8") as f: | |
| data = json.load(f) | |
| docs = [] | |
| def _d(obj, key): | |
| """Null-safe nested dict accessor (some records have null fields).""" | |
| v = (obj or {}).get(key) | |
| return v if isinstance(v, dict) else {} | |
| def _list(obj, key, lang): | |
| v = _d(obj, key).get(lang, []) | |
| return ", ".join(v) if isinstance(v, list) else (str(v) if v else "") | |
| for idx, item in enumerate(data): | |
| item = item or {} | |
| meta = item.get("metadata") or {} | |
| university_ar = _d(meta, "university").get("ar", "") | |
| faculty_ar = _d(meta, "faculty").get("ar", "") | |
| department_ar = _d(meta, "department").get("ar", "") | |
| description = _d(item, "page_content").get("ar", "") | |
| interests_ar = _list(meta, "interests", "ar") | |
| subjects_ar = _list(meta, "strong_subjects", "ar") | |
| skills_ar = _list(meta, "required_skills", "ar") | |
| careers_ar = _list(meta, "career_paths", "ar") | |
| learning_ar = _list(meta, "learning_style", "ar") | |
| department_en = _d(meta, "department").get("en", "") | |
| interests_en = _list(meta, "interests", "en") | |
| skills_en = _list(meta, "required_skills", "en") | |
| content = ( | |
| f"ุงูุฌุงู ุนุฉ: {university_ar}\n" | |
| f"ุงููููุฉ: {faculty_ar}\n" | |
| f"ุงููุณู : {department_ar}\n\n" | |
| f"ูุตู ุงูุจุฑูุงู ุฌ:\n{description}\n\n" | |
| f"ุงูุงูุชู ุงู ุงุช: {interests_ar}\n" | |
| f"ุงูู ูุงุฏ ุงูุฃุณุงุณูุฉ: {subjects_ar}\n" | |
| f"ุงูู ูุงุฑุงุช ุงูู ุทููุจุฉ: {skills_ar}\n" | |
| f"ุฃุณููุจ ุงูุฏุฑุงุณุฉ: {learning_ar}\n" | |
| f"ุงูู ุณุงุฑุงุช ุงูู ูููุฉ: {careers_ar}\n\n" | |
| f"--- English Keywords ---\n" | |
| f"Department: {department_en}\n" | |
| f"Interests: {interests_en}\n" | |
| f"Skills: {skills_en}" | |
| ) | |
| docs.append(Document( | |
| page_content=content.strip(), | |
| metadata={ | |
| "id": item.get("id", f"college_{idx}"), | |
| "university": university_ar, | |
| "faculty": faculty_ar, | |
| "department": department_ar, | |
| "type": "faculty_department_profile", | |
| } | |
| )) | |
| return docs | |
| def _build_track_docs(xlsx_path: Path) -> list: | |
| import pandas as pd | |
| df = pd.read_excel(xlsx_path).fillna("") | |
| docs = [] | |
| for _, row in df.iterrows(): | |
| track_ar = str(row.get("Track_Name_Arabic", "")) | |
| track_en = str(row.get("Track_Name_English", "")) | |
| category_ar = str(row.get("Category_Arabic", "")) | |
| level = str(row.get("Level", "")) | |
| duration = str(row.get("Duration_Months", "")) | |
| core_skills = str(row.get("Core_Skills", "")) | |
| soft_skills = str(row.get("Soft_Skills_Arabic", "")) | |
| job_roles = str(row.get("Job_Roles", "")) | |
| content = ( | |
| f"ุงูู ุณุงุฑ ุงูู ููู: {track_ar}\n" | |
| f"ุงููุฆุฉ: {category_ar}\n" | |
| f"ุงูู ุณุชูู: {level}\n" | |
| f"ู ุฏุฉ ุงูุชุนูู : {duration} ุดูุฑ\n\n" | |
| f"ุงูู ูุงุฑุงุช ุงูุฃุณุงุณูุฉ: {core_skills}\n" | |
| f"ุงูู ูุงุฑุงุช ุงูุณููููุฉ: {soft_skills}\n" | |
| f"ุงููุธุงุฆู ุงูู ุญุชู ูุฉ: {job_roles}\n\n" | |
| f"--- English Keywords ---\n" | |
| f"Track: {track_en}" | |
| ) | |
| docs.append(Document( | |
| page_content=content.strip(), | |
| metadata={ | |
| "Track_Name_Arabic": track_ar, | |
| "Track_Name_English": track_en, | |
| "type": "career_track", | |
| } | |
| )) | |
| return docs | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Prompt helpers (mirror notebook cells 8-10) | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def _profile_line(session: InterviewSession) -> str: | |
| """Human-readable Arabic profile snippet injected into prompts.""" | |
| bits = [] | |
| if session.age: | |
| bits.append(f"ุงูุนู ุฑ: {session.age}") | |
| if session.status == "student": | |
| if session.study_level == "high_school": | |
| bits.append("ุงูุญุงูุฉ: ุทุงูุจ ุซุงูููุฉ") | |
| elif session.study_level == "college": | |
| bits.append("ุงูุญุงูุฉ: ุทุงูุจ ุฌุงู ุนู") | |
| else: | |
| bits.append("ุงูุญุงูุฉ: ุทุงูุจ") | |
| elif session.status == "graduate": | |
| bits.append("ุงูุญุงูุฉ: ุฎุฑูุฌ") | |
| return " โ ".join(bits) if bits else "ูุง ุชูุฌุฏ ุจูุงูุงุช ุฅุถุงููุฉ" | |
| def _system_prompt(session: InterviewSession) -> str: | |
| n, t = session.user_name, session.path_type | |
| profile = _profile_line(session) | |
| if t == "track_only": | |
| return ( | |
| f"ุฃูุช ู ุณุชุดุงุฑ ู ุณุงุฑุงุช ุชุฏุฑูุจูุฉ ู ุญุชุฑู ุชูุฌุฑู ู ูุงุจูุฉ ู ุจุงุดุฑุฉ ู ุน {n}ุ ููู ูุฌูุจ ุฃู ุงู ู ุงูุขู.\n" | |
| f"ุงุณู ุงูุดุฎุต: {n} โ ู ุนููู ุงุช ุนูู: {profile}\n" | |
| f"ูุฏู ุงูู ูุงุจูุฉ: ู ุณุงุนุฏุฉ {n} ูู ุชุญุฏูุฏ ุงูุชุฑุงู ุงูุชุฏุฑูุจู ุงูุฃูุซุฑ ุชูุงููุงู ู ุน ู ูุงุฑุงุชู.\n" | |
| f"ุงูู ูุงุจูุฉ ู ู {TOTAL_QUESTIONS} ุฃุณุฆูุฉ ููุท โ ู ุฑุญูุฉ ุฌู ุน ู ุนููู ุงุชุ ุจุฏูู ุชูุตูุงุช ุงูุขู.\n\n" | |
| "ุงูุฃูุฏุงู: ุงูู ูุงุฑุงุช ุงูุญุงููุฉุ ุงูุงูุชู ุงู ุงุชุ ู ุณุชูู ุงูุฎุจุฑุฉุ ุฃุณููุจ ุงูุชุนูู ุ ุงููุฏู ุงูููุงุฆู.\n\n" | |
| "ููุงุนุฏ ุตุงุฑู ุฉ:\n" | |
| "- ุณุคุงู ูุงุญุฏ ููุท ูู ูู ู ุฑุฉ.\n" | |
| "- ูู ุณุคุงู ู ุจูู ุนูู ุงูุฅุฌุงุจุงุช ุงูุณุงุจูุฉ.\n" | |
| f"- ุงุณุชุฎุฏู ุงุณู {n} ุจุดูู ุทุจูุนู ูุฎุงุทุจู ู ุจุงุดุฑุฉ.\n" | |
| "- ุฃุณููุจ ู ูุงุจูุฉ ุญูููู (ูุงุถุญ โ ู ุจุงุดุฑ โ ูุฏูุฏ).\n" | |
| "- ูุง ุชูุฏู ุฃู ุชุฑุดูุญุงุช ุงูุขู." | |
| ) | |
| else: | |
| return ( | |
| f"ุฃูุช ู ุณุชุดุงุฑ ุฃูุงุฏูู ู ูู ููู ู ุญุชุฑู ู ุชุฎุตุต ูู ุชูุฌูู ุทูุงุจ ุงูุซุงูููุฉุ ุชูุฌุฑู ู ูุงุจูุฉ ู ุจุงุดุฑุฉ ู ุน {n} ููู ูุฌูุจ ุฃู ุงู ู.\n" | |
| f"ุงุณู ุงูุดุฎุต: {n} โ ู ุนููู ุงุช ุนูู: {profile}\n" | |
| f"ู ูุงุญุธุฉ ู ูู ุฉ: {n} ุฎุฑูุฌ/ูุฉ ุซุงูููุฉ ูู ููุจู ุนูู ุงุฎุชูุงุฑ ุงููููุฉ ูุงูุชุฎุตุต ุงูุฌุงู ุนู (ุทุงูุจ)ุ ูุงุฌุนู ุฃุณุฆูุชู ู ูุงุณุจุฉ ููุฐู ุงูู ุฑุญูุฉ.\n" | |
| f"ูุฏู ุงูู ูุงุจูุฉ: ู ุณุงุนุฏุฉ {n} ูู ุงุฎุชูุงุฑ ุงููููุฉ ูุงูุชุฎุตุต ุงูุฃูุณุจ.\n" | |
| f"ุงูู ูุงุจูุฉ ู ู {TOTAL_QUESTIONS} ุฃุณุฆูุฉ ููุท โ ู ุฑุญูุฉ ููู ูุชุญููู ููุท.\n\n" | |
| "ุงูุฃูุฏุงู: ุงูุงูุชู ุงู ุงุช ุงูุฃูุงุฏูู ูุฉุ ุงูู ูุงุฏ ุงูู ูุถูุฉุ ุงูุฃูุฏุงู ุงูู ูููุฉุ ุฃุณููุจ ุงูุชุนูู .\n\n" | |
| "ููุงุนุฏ ุตุงุฑู ุฉ:\n" | |
| "- ุณุคุงู ูุงุญุฏ ููุท ูู ูู ู ุฑุฉ.\n" | |
| "- ูู ุณุคุงู ู ุจูู ุนูู ุงูุฅุฌุงุจุงุช ุงูุณุงุจูุฉ.\n" | |
| f"- ุงุณุชุฎุฏู ุงุณู {n} ุจุดูู ุทุจูุนู ูุฎุงุทุจู ู ุจุงุดุฑุฉ.\n" | |
| "- ูุง ุชูุตูุงุช ุงูุขู." | |
| ) | |
| def _format_answers(history: list) -> str: | |
| lines = [] | |
| for item in history: | |
| lines.append( | |
| f"ุงูุณุคุงู {item['question_number']}: {item['question']}\n" | |
| f"ุงูุฅุฌุงุจุฉ: {item['answer']}\n" | |
| "โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ" | |
| ) | |
| return "\n".join(lines) | |
| _ANSWER_STYLE = ( | |
| "\n\nุฃุณููุจ ุงูุณุคุงู (ุฅูุฒุงู ู): ุงุทุฑุญ ุณุคุงูุงู ูุงุญุฏุงู ูุงุถุญุงู ูู ุชูุณุท ุงูุทูู โ ูุง ูุตูุฑุงู ุฌุฏุงู ููุง ุทูููุงู ู ุนููุฏุงู โ " | |
| "ุจูุบุฉ ุจุณูุทุฉ ูููู ูุง ุฃู ุดุฎุต. ุงุดุฑุญ ูู ุงูุณุคุงู ู ุง ุชูุตุฏู ุจุฅูุฌุงุฒุ ุซู ุงุทูุจ ู ู ุงูุดุฎุต ุตุฑุงุญุฉู ุฃู ูุฌูุจ ุจุงูุชูุตูู " | |
| "ููุฐูุฑ ุฃู ุซูุฉ ูุฃุณุจุงุจูุง (ู ุซู: ยซุงุดุฑุญ ูู ุจุงูุชูุตูู ูููู ูู ูู ุงููู ูู ุจุงููยป). ูุฌุจ ุฃู ูููู ุงูุณุคุงู ุงุญุชุฑุงูููุง " | |
| "ููุณุชูุดู ููุทุฉ ุฌุฏูุฏุฉ ุชุณุงุนุฏ ูุนูุงู ุนูู ุชุฑุดูุญ ุฏููู. ุฃุฎุฑุฌ ูุต ุงูุณุคุงู ููุท." | |
| ) | |
| def _question_prompt(session: InterviewSession, rag_context: str = "") -> str: | |
| n = session.user_name | |
| q = session.current_question | |
| hist = session.conversation_history | |
| rag_block = "" | |
| if rag_context: | |
| rag_block = ( | |
| "\n\nุณูุงู ู ุฑุฌุนู (ู ุณุงุฑุงุช/ุชุฎุตุตุงุช ูุฑูุจุฉ ู ู ุฅุฌุงุจุงุช ุงูู ุณุชุฎุฏู โ ุงุณุชุฑุดุฏ ุจูุง ูุชุทุฑุญ ุณุคุงูุงู ุฃุฏู " | |
| "ูู ููุฒ ุจูู ูุฐู ุงูุงุชุฌุงูุงุชุ ุฏูู ุฐูุฑูุง ุฃู ุงูุชุฑุดูุญ ุจูุง ุตุฑุงุญุฉู):\n" | |
| f"{rag_context}\n" | |
| ) | |
| if q == 1: | |
| hint = ( | |
| "ูุฌุจ ุฃู ูููู ุณุคุงูุงู ุนู ุงูู ูุงุฑุงุช ุงูุญุงููุฉ ูุงูุงูุชู ุงู ุงุช ุงูุชุฏุฑูุจูุฉ." | |
| if session.path_type == "track_only" | |
| else "ูุฌุจ ุฃู ูููู ุณุคุงูุงู ุนู ุงูุงูุชู ุงู ุงุช ุงูุฃูุงุฏูู ูุฉ ูุงูู ูุงุฏ ุงูู ูุถูุฉ ูู ุงูุซุงูููุฉ." | |
| ) | |
| return ( | |
| f"ุฃูุช ุงูุขู ูู ุจุฏุงูุฉ ุงูู ูุงุจูุฉ ู ุน {n}.\n" | |
| f"ุงูุณุคุงู ุงูุญุงูู: 1 ู ู {TOTAL_QUESTIONS}\n" | |
| "ูุง ุชูุฌุฏ ุฅุฌุงุจุงุช ุณุงุจูุฉ.\n\n" | |
| f"ุงุทุฑุญ ุงูุณุคุงู ุงูุฃูู ูู {n}.\n{hint}" | |
| + _ANSWER_STYLE | |
| ) | |
| else: | |
| last_ans = hist[-1]["answer"] | |
| return ( | |
| f"ุงูุณุคุงู ุงูุญุงูู: {q} ู ู {TOTAL_QUESTIONS}\n" | |
| f"ุงูุฃุณุฆูุฉ ุงูู ุชุจููุฉ: {TOTAL_QUESTIONS - q}\n\n" | |
| f"ู ูุฎุต ุงูู ูุงุจูุฉ ู ุน {n} ุญุชู ุงูุขู:\n" | |
| f"{_format_answers(hist)}\n\n" | |
| f"ุขุฎุฑ ุฅุฌุงุจุฉ ู ู {n}: \"{last_ans}\"{rag_block}\n\n" | |
| f"ุงุทุฑุญ ุงูุณุคุงู ุฑูู {q} ูู {n}.\n" | |
| "ูุฌุจ ุฃู ูููู ู ุจููุงู ุนูู ุงูุฅุฌุงุจุงุช ุงูุณุงุจูุฉ ููุบุทู ุฌุงูุจุงู ุฌุฏูุฏุงู." | |
| + _ANSWER_STYLE | |
| ) | |
| def _track_keywords_prompt(answers_summary: str, user_name: str) -> str: | |
| return ( | |
| f"ูุฏูู ู ูุงุจูุฉ ู ูุชู ูุฉ ู ุน {user_name} ุจูุฏู ุงุฎุชูุงุฑ ู ุณุงุฑ ุชุฏุฑูุจู ู ูุงุณุจ.\n\n" | |
| f"ุฅุฌุงุจุงุช ุงูู ูุงุจูุฉ:\n{answers_summary}\n\n" | |
| "ู ูู ุชู: ุญููู ุงูุฅุฌุงุจุงุช ูู ุณุชุดุงุฑ ู ูููุ ูุฃุฑุฌุน JSON ุฎุงู ููุท โ ุจุฏูู markdown ุฃู ูุต ุฅุถุงูู.\n\n" | |
| "ููุงุนุฏ ุตุงุฑู ุฉ: JSON ููุท. ูุง ุชูุฑุงุฑ. ูุง ุงูุชุฑุงุถุงุช ุบูุฑ ู ุฏุนูู ุฉ ุจุงูุฅุฌุงุจุงุช.\n\n" | |
| "{\n" | |
| ' "interests": ["ุงูุชู ุงู ุชุทุจููู ูุงุถุญ"],\n' | |
| ' "skills": ["ู ูุงุฑุฉ ุชูููุฉ ุฃู ู ูููุฉ"],\n' | |
| ' "career_goals": ["ูุฏู ู ููู ูุงูุนู"],\n' | |
| ' "search_queries_arabic": ["ููู ุฉ ุจุญุซ ุนุฑุจูุฉ ุฏูููุฉ"],\n' | |
| ' "search_queries_english": ["technical keyword"],\n' | |
| ' "preferred_tracks": ["ุงุณู ุชุฑุงู ู ุญุชู ู"]\n' | |
| "}" | |
| ) | |
| def _college_keywords_prompt(answers_summary: str, user_name: str) -> str: | |
| return ( | |
| f"ูุฏูู ู ูุงุจูุฉ ู ูุชู ูุฉ ู ุน {user_name}ุ ุฎุฑูุฌ ุซุงูููุฉุ ุจูุฏู ุงุฎุชูุงุฑ ุงููููุฉ ูุงููุณู .\n\n" | |
| f"ุฅุฌุงุจุงุช ุงูู ูุงุจูุฉ:\n{answers_summary}\n\n" | |
| "ู ูู ุชู: ุญููู ุงูุฅุฌุงุจุงุช ูู ุฑุดุฏ ุฃูุงุฏูู ูุ ูุฃุฑุฌุน JSON ุฎุงู ููุท โ ุจุฏูู markdown ุฃู ูุต ุฅุถุงูู.\n\n" | |
| "{\n" | |
| ' "interests": ["ุงูุชู ุงู ุฃูุงุฏูู ู ูุงุถุญ"],\n' | |
| ' "skills": ["ู ูุงุฑุฉ ุฏุฑุงุณูุฉ ุฃู ุชุญููููุฉ"],\n' | |
| ' "career_goals": ["ูุฏู ู ููู ุจุนุฏ ุงูุชุฎุฑุฌ"],\n' | |
| ' "search_queries_arabic": ["ุงุณู ูููุฉ ุฃู ุชุฎุตุต ุจุงูุนุฑุจูุฉ"],\n' | |
| ' "search_queries_english": ["academic major keyword"],\n' | |
| ' "preferred_departments": ["ุงุณู ูุณู ุฃู ูููุฉ ู ุญุชู ูุฉ"]\n' | |
| "}" | |
| ) | |
| def _focused_query(kw: Optional[dict], pref_key: str) -> str: | |
| """Build a clean natural-language retrieval query from extracted keywords. | |
| Dumping the whole keyword JSON into the embedder adds noise (field names, | |
| brackets, generic search strings) and hurts relevance. Instead we join the | |
| most meaningful signals โ the model's explicit picks, the career goal, the | |
| interests โ into a focused phrase, repeating the explicit picks so they | |
| dominate the embedding. | |
| """ | |
| if not isinstance(kw, dict): | |
| return "" | |
| chunks = [] | |
| def add(val): | |
| if isinstance(val, list): | |
| chunks.extend(str(x).strip() for x in val if str(x).strip()) | |
| elif val: | |
| chunks.append(str(val).strip()) | |
| # weight the explicit preferred picks the most | |
| add(kw.get(pref_key)) | |
| add(kw.get(pref_key)) | |
| add(kw.get("career_goals")) | |
| add(kw.get("interests")) | |
| add(kw.get("skills")) | |
| add(kw.get("search_queries_arabic")) | |
| return " ุ ".join(chunks) | |
| def _parse_json_response(raw: str) -> Optional[dict]: | |
| cleaned = re.sub(r"```json\s*|\s*```", "", raw.strip()) | |
| start = cleaned.find("{") | |
| if start == -1: | |
| return None | |
| depth = end = 0 | |
| for i, ch in enumerate(cleaned[start:], start): | |
| if ch == "{": depth += 1 | |
| elif ch == "}": | |
| depth -= 1 | |
| if depth == 0: | |
| end = i + 1 | |
| break | |
| try: | |
| return json.loads(cleaned[start:end]) | |
| except Exception: | |
| return None | |
| def _final_report_prompt(session: InterviewSession, answers_summary: str, | |
| track_kw, college_kw, | |
| track_results: list, college_results: list, | |
| suggested_tracks: list = None, suggested_colleges: list = None) -> tuple: | |
| """Returns (system_prompt, user_prompt) for the final report. | |
| The report is forced to recommend EXACTLY the same top-3 tracks/colleges that | |
| the structured `suggested_tracks` / `suggested_colleges` contain, so the text | |
| report and the UI chips always match. | |
| """ | |
| n = session.user_name | |
| pt = session.path_type | |
| suggested_tracks = suggested_tracks or [] | |
| suggested_colleges = suggested_colleges or [] | |
| track_list = "\n".join( | |
| f" {i}. {t['name']}" for i, t in enumerate(suggested_tracks, 1) | |
| ) or " (ูุง ููุฌุฏ)" | |
| college_list = "\n".join( | |
| f" {i}. {c['name']}" for i, c in enumerate(suggested_colleges, 1) | |
| ) or " (ูุง ููุฌุฏ)" | |
| track_results_text = "ูุชุงุฆุฌ ุงูุจุญุซ โ ุงูุชุฑุงูุงุช ุงูุชุฏุฑูุจูุฉ:\n\n" | |
| for i, doc in enumerate(track_results[:5], 1): | |
| track_results_text += f"ุชุฑุงู {i}\nMetadata: {doc['metadata']}\n{doc['content']}\n\n" | |
| if pt == "track_only": | |
| system = ( | |
| "ุฃูุช ู ุณุชุดุงุฑ ู ุณุงุฑุงุช ุชุฏุฑูุจูุฉ ูุฎุจูุฑ ุชูุฌูู ู ููู. ููุฏ ุงูุชููุช ููุชู ู ู ุฅุฌุฑุงุก ู ูุงุจูุฉ " | |
| f"ู ุน {n}ุ ูุงูุขู ุชูุฏูู ูู ุชูุฑูุฑู ุงูููุงุฆู ูุฃูุช ุชุฎุงุทุจู ู ุจุงุดุฑุฉ ูุฌูุงู ููุฌู." | |
| ) | |
| user = ( | |
| f"ุงูุดุฎุต ุงูุฐู ูุงุจูุชู ุงุณู ู: {n}\n\n" | |
| f"ุฅุฌุงุจุงุช {n} ูู ุงูู ูุงุจูุฉ:\n{answers_summary}\n\n" | |
| f"ุชุญููู ุงูุงูุชู ุงู ุงุช:\n{json.dumps(track_kw, ensure_ascii=False, indent=2)}\n\n" | |
| f"{track_results_text}\n" | |
| "ุงูุชุฑุงูุงุช ุงูุซูุงุซุฉ ุงูู ุฑุดูุญุฉ (ุฅูุฒุงู ู ุงุณุชุฎุฏุงู ูุง ูู ุง ูู ุจุงูุถุจุท ูุจููุณ ุงูุชุฑุชูุจุ " | |
| "ุจููุณ ุงูุฃุณู ุงุก ุญุฑููุงู ุฏูู ุชุฑุฌู ุฉ ุฃู ุชุบููุฑ ุฃู ุฅุถุงูุฉ ุบูุฑูุง):\n" | |
| f"{track_list}\n\n" | |
| f"ุงูุชุจ ุงูุขู ุชูุฑูุฑุงู ู ูุฌูุงู ุฅูู {n} ู ุจุงุดุฑุฉ ุจุตูุบุฉ ุงูู ุฎุงุทุจ (ุงุณุชุฎุฏู : ุฃูุชุ ุฅุฌุงุจุงุชูุ ููุงุณุจูุ ููุตุญู)ุ " | |
| "ููุญุชูู ุนูู:\n" | |
| "1) ุชุญููู ุดุฎุตู ูู (ููุฑุชุงู) โ \"ู ู ุฎูุงู ุฅุฌุงุจุงุชู ูุงุญุธุชู ุฃูู...\"\n" | |
| "2) ุงูุชุฑุงูุงุช ุงูุซูุงุซุฉ ุงูู ุฑุดูุญุฉ ุฃุนูุงู ุจุงูุถุจุท (ููุณ ุงูุฃุณู ุงุก ูุงูุชุฑุชูุจ) โ ููู ุชุฑุงู: ูู ุงุฐุง ููุงุณุจู ุฃูุช ุชุญุฏูุฏุงูุ ู ุฏุฉ ุงูุชุฏุฑูุจุ ุงูู ูุงุฑุงุชุ ุงููุธุงุฆูุ ุชุญุฏู ู ุญุชู ู\n" | |
| "3) ูุตุงุฆุญ ุนู ููุฉ ู ุฎุตุตุฉ ูู (3-4 ููุงุท)\n" | |
| "4) ู ูุงุฑุฏ ู ูุชุฑุญุฉ\n\n" | |
| f"ููุงุนุฏ: ูุง ุชุฎุชุฑุน ุชุฑุงูุงุช ุบูุฑ ุงูู ุฐููุฑุฉ ุฃุนูุงู ููุง ุชุญุฐู ุฃูููุง ู ููุง. ูุง ูุณุจ ุฃู ุฃุฑูุงู ุชูููู . " | |
| f"ุงุฑุจุท ูู ุงุณุชูุชุงุฌ ุจุฅุฌุงุจุงุช {n}. ุฎุงุทุจู ุจุตูุบุฉ \"ุฃูุช\" ุทูุงู ุงูุชูุฑูุฑ. ูุงุถุญ ูู ุฎุชุตุฑ." | |
| ) | |
| else: | |
| college_results_text = "ูุชุงุฆุฌ ุงูุจุญุซ โ ุงููููุงุช ูุงูุฃูุณุงู :\n\n" | |
| for i, doc in enumerate(college_results[:5], 1): | |
| college_results_text += f"ูููุฉ {i}\nMetadata: {doc['metadata']}\n{doc['content']}\n\n" | |
| system = ( | |
| "ุฃูุช ู ุณุชุดุงุฑ ุฃูุงุฏูู ู ูู ููู ุฎุจูุฑ ูู ุชูุฌูู ุทูุงุจ ุงูุซุงูููุฉ. ุงูุชููุช ููุชู ู ู ู ูุงุจูุฉ " | |
| f"ู ุน {n}ุ ูุงูุขู ุชูุฏูู ูู ุชูุฑูุฑู ุงูููุงุฆู ู ุฎุงุทุจุงู ุฅูุงู ู ุจุงุดุฑุฉ." | |
| ) | |
| user = ( | |
| f"ุงูุทุงูุจ ุงูุฐู ูุงุจูุชู ุงุณู ู: {n} โ ุฎุฑูุฌ ุซุงูููุฉ ู ูุจู ุนูู ุงูุฌุงู ุนุฉ\n\n" | |
| f"ุฅุฌุงุจุงุช {n} ูู ุงูู ูุงุจูุฉ:\n{answers_summary}\n\n" | |
| f"ุชุญููู ุงูุงูุชู ุงู ุงุช ุงูุฃูุงุฏูู ูุฉ:\n{json.dumps(college_kw, ensure_ascii=False, indent=2)}\n\n" | |
| f"ุชุญููู ุงูู ููู ุงูู ูููุฉ:\n{json.dumps(track_kw, ensure_ascii=False, indent=2)}\n\n" | |
| f"{college_results_text}\n" | |
| f"{track_results_text}\n" | |
| "ุงููููุงุช ุงูุซูุงุซ ุงูู ุฑุดูุญุฉ (ุฅูุฒุงู ู ุงุณุชุฎุฏุงู ูุง ูู ุง ูู ุจุงูุถุจุท ูุจููุณ ุงูุชุฑุชูุจ):\n" | |
| f"{college_list}\n\n" | |
| "ุงูุชุฑุงูุงุช ุงูุซูุงุซุฉ ุงูู ุฑุดูุญุฉ (ุฅูุฒุงู ู ุงุณุชุฎุฏุงู ูุง ูู ุง ูู ุจุงูุถุจุท ูุจููุณ ุงูุชุฑุชูุจุ ุจููุณ ุงูุฃุณู ุงุก ุญุฑููุงู):\n" | |
| f"{track_list}\n\n" | |
| f"ุงูุชุจ ุงูุขู ุชูุฑูุฑุงู ู ูุฌูุงู ุฅูู {n} ู ุจุงุดุฑุฉ ุจุตูุบุฉ ุงูู ุฎุงุทุจ (ุฃูุชุ ุฅุฌุงุจุงุชูุ ููุงุณุจู)ุ ููุญุชูู ุนูู:\n" | |
| "1) ุชุญููู ุฃูุงุฏูู ู ูู ููู ูู (ููุฑุชุงู)\n" | |
| "2) ุงููููุงุช ุงูุซูุงุซ ุงูู ุฑุดูุญุฉ ุฃุนูุงู ุจุงูุถุจุท (ููุณ ุงูุฃุณู ุงุก ูุงูุชุฑุชูุจ) โ ููู ูููุฉ: ูู ุงุฐุง ุชูุงุณุจูุ ู ูุงุฏุ ู ูุงุฑุงุชุ ู ุณุงุฑุงุชุ ุชุญุฏู\n" | |
| "3) ุงูุชุฑุงูุงุช ุงูุซูุงุซุฉ ุงูู ุฑุดูุญุฉ ุฃุนูุงู ุจุงูุถุจุท (ููุณ ุงูุฃุณู ุงุก ูุงูุชุฑุชูุจ) โ ููู ุชุฑุงู: ููู ูุฏุนู ูุ ู ุฏุฉุ ู ูุงุฑุงุชุ ููู ุฉ ู ุถุงูุฉ\n" | |
| "4) ูุตุงุฆุญ ุนู ููุฉ ูู (3-4 ููุงุท)\n" | |
| "5) ู ูุงุฑุฏ ู ูุชุฑุญุฉ\n\n" | |
| f"ููุงุนุฏ: ูุง ุชุฎุชุฑุน ูููุงุช ุฃู ุชุฑุงูุงุช ุบูุฑ ุงูู ุฐููุฑุฉ ุฃุนูุงู ููุง ุชุญุฐู ุฃูููุง ู ููุง. ูุง ูุณุจ ุฃู ุฃุฑูุงู . " | |
| f"ุงุฑุจุท ูู ุชูุตูุฉ ุจุฅุฌุงุจุงุช {n}. ุฎุงุทุจู ุจุตูุบุฉ \"ุฃูุช\" ุทูุงู ุงูุชูุฑูุฑ. ูุงุถุญ ูู ุฎุชุตุฑ." | |
| ) | |
| return system, user | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Engine | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| class InterviewEngine: | |
| """ | |
| Manages stateful interview sessions in memory. | |
| Suitable for single-process Flask dev/demo. | |
| """ | |
| def __init__(self): | |
| self._sessions: dict[str, InterviewSession] = {} | |
| self._embeddings = None # loaded lazily on first session | |
| self._college_vs: Optional[Chroma] = None | |
| self._track_vs: Optional[Chroma] = None | |
| self._splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100) | |
| self._ready = False # True after vector stores built | |
| def _ensure_ready(self): | |
| if self._ready: | |
| return | |
| print("[Interview] Initialising embeddings & vector stores (first use)...") | |
| self._embeddings = HuggingFaceEmbeddings( | |
| model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" | |
| ) | |
| self._init_vector_stores() | |
| self._ready = True | |
| # โโ Vector store init โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def _init_vector_stores(self): | |
| self._college_vs = self._load_or_build( | |
| name="colleges", | |
| collection="interview_colleges", | |
| data_path=COLLEGES_JSON, | |
| builder=_build_college_docs, | |
| ) | |
| self._track_vs = self._load_or_build( | |
| name="tracks", | |
| collection="interview_tracks", | |
| data_path=TRACKS_XLSX, | |
| builder=_build_track_docs, | |
| ) | |
| def _load_or_build(self, name, collection, data_path, builder) -> Optional[Chroma]: | |
| if not data_path.exists(): | |
| print(f"[Interview] WARNING: {data_path} not found โ {name} search disabled.") | |
| return None | |
| persist = os.path.join(CHROMA_DIR, name) | |
| try: | |
| vs = Chroma( | |
| persist_directory=persist, | |
| embedding_function=self._embeddings, | |
| collection_name=collection, | |
| ) | |
| count = vs._collection.count() | |
| if count > 0: | |
| print(f"[Interview] Loaded {name} VS ({count} chunks).") | |
| return vs | |
| raise ValueError("empty collection") | |
| except Exception: | |
| print(f"[Interview] Building {name} VS (first run โ may take a moment)...") | |
| docs = builder(data_path) | |
| vs = Chroma.from_documents( | |
| self._splitter.split_documents(docs), | |
| embedding=self._embeddings, | |
| persist_directory=persist, | |
| collection_name=collection, | |
| ) | |
| print(f"[Interview] Built {name} VS ({vs._collection.count()} chunks).") | |
| return vs | |
| # โโ Public API โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def start_session(self, user_name: str, path_type: str, | |
| email: Optional[str] = None, | |
| profile: Optional[dict] = None) -> dict: | |
| self._ensure_ready() | |
| profile = profile or {} | |
| session = InterviewSession( | |
| session_id=str(uuid.uuid4()), | |
| user_name=user_name.strip(), | |
| path_type=path_type, | |
| email=email, | |
| age=profile.get("age"), | |
| status=profile.get("status"), | |
| study_level=profile.get("study_level"), | |
| ) | |
| session.current_question = 1 | |
| question_text = self._ask(session) | |
| session.pending_question = question_text | |
| self._sessions[session.session_id] = session | |
| return { | |
| "session_id": session.session_id, | |
| "question": question_text, | |
| "question_number": 1, | |
| "total_questions": TOTAL_QUESTIONS, | |
| } | |
| def answer(self, session_id: str, user_answer: str) -> dict: | |
| session = self._sessions.get(session_id) | |
| if not session: | |
| return {"error": "Session not found or expired."} | |
| if session.done: | |
| return {"error": "Session already completed."} | |
| # Record answer | |
| session.conversation_history.append({ | |
| "question_number": session.current_question, | |
| "question": session.pending_question, | |
| "answer": user_answer.strip(), | |
| }) | |
| if session.current_question >= TOTAL_QUESTIONS: | |
| # All questions answered โ run analysis | |
| result = self._run_analysis(session) | |
| session.done = True | |
| session.result = result | |
| return {"done": True, "result": result} | |
| # Ask next question | |
| session.current_question += 1 | |
| question_text = self._ask(session) | |
| session.pending_question = question_text | |
| return { | |
| "done": False, | |
| "question": question_text, | |
| "question_number": session.current_question, | |
| "total_questions": TOTAL_QUESTIONS, | |
| } | |
| def get_result(self, session_id: str) -> Optional[dict]: | |
| session = self._sessions.get(session_id) | |
| if session and session.done: | |
| return session.result | |
| return None | |
| # โโ Private helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def _ask(self, session: InterviewSession) -> str: | |
| rag_context = self._retrieve_question_context(session) | |
| messages = [ | |
| SystemMessage(content=_system_prompt(session)), | |
| HumanMessage(content=_question_prompt(session, rag_context)), | |
| ] | |
| response = _q_llm.invoke(messages) | |
| return response.content.strip() | |
| def _retrieve_question_context(self, session: InterviewSession) -> str: | |
| """ | |
| From Q2 onward, ground the next question in RAG results: search the | |
| relevant vector store with the conversation so far and surface a few | |
| candidate track/department names + skills to make the question sharper. | |
| """ | |
| if session.current_question < 2 or not session.conversation_history: | |
| return "" | |
| query = " ".join( | |
| f"{h.get('answer','')}" for h in session.conversation_history | |
| ).strip() | |
| if not query: | |
| return "" | |
| try: | |
| vs = (self._college_vs if session.path_type == "college_and_track" | |
| else self._track_vs) | |
| results = self._search(vs, query, k=3) | |
| lines = [] | |
| for r in results: | |
| meta = r.get("metadata", {}) | |
| name = (meta.get("Track_Name_Arabic") or meta.get("department") | |
| or meta.get("Track_Name_English") or "").strip() | |
| if name: | |
| lines.append(f"- {name}") | |
| return "\n".join(lines) | |
| except Exception as exc: | |
| print(f"[Interview] RAG question context error: {exc}") | |
| return "" | |
| def _extract_keywords(self, prompt_text: str) -> Optional[dict]: | |
| messages = [ | |
| SystemMessage(content="ุฃูุช ู ุญูู ูุณุชุฎุฑุฌ JSON ุฎุงู ููุท ุฏูู ุฃู ูุต ุฅุถุงูู."), | |
| HumanMessage(content=prompt_text), | |
| ] | |
| raw = _ext_llm.invoke(messages).content | |
| return _parse_json_response(raw) | |
| def _search(self, vs: Optional[Chroma], query: str, k: int = 5) -> list: | |
| if vs is None: | |
| return [] | |
| docs = vs.similarity_search(query, k=k) | |
| return [{"metadata": d.metadata, "content": d.page_content[:600]} for d in docs] | |
| def _run_analysis(self, session: InterviewSession) -> dict: | |
| answers_summary = _format_answers(session.conversation_history) | |
| user_name = session.user_name | |
| path_type = session.path_type | |
| # โโ 1. Keyword extraction โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| track_kw = self._extract_keywords( | |
| _track_keywords_prompt(answers_summary, user_name) | |
| ) | |
| college_kw = None | |
| if path_type == "college_and_track": | |
| college_kw = self._extract_keywords( | |
| _college_keywords_prompt(answers_summary, user_name) | |
| ) | |
| # โโ 2. Vector search (focused query โ better relevance) โโโโโโ | |
| track_query = _focused_query(track_kw, "preferred_tracks") or answers_summary | |
| track_results = self._search(self._track_vs, track_query, k=8) | |
| college_results = [] | |
| if path_type == "college_and_track": | |
| college_query = _focused_query(college_kw, "preferred_departments") or answers_summary | |
| college_results = self._search(self._college_vs, college_query, k=8) | |
| # โโ 3. Build structured top-3 track + college suggestions FIRST โโ | |
| # (so the written report can be forced to use exactly the same ones) | |
| suggested_tracks = self._build_suggested_tracks(track_results, track_kw) | |
| suggested_track = suggested_tracks[0]["name"] if suggested_tracks else None | |
| suggested_colleges = self._build_suggested_colleges(college_results) | |
| # โโ 4. Final report (must use the exact suggestions above) โโโโโโ | |
| sys_p, user_p = _final_report_prompt( | |
| session, answers_summary, | |
| track_kw, college_kw, | |
| track_results, college_results, | |
| suggested_tracks, suggested_colleges, | |
| ) | |
| report_text = _q_llm.invoke([ | |
| SystemMessage(content=sys_p), | |
| HumanMessage(content=user_p), | |
| ]).content.strip() | |
| return { | |
| "report": report_text, | |
| "track_keywords": track_kw, | |
| "college_keywords": college_kw, | |
| "track_results": [r["metadata"] for r in track_results], | |
| "college_results": [r["metadata"] for r in college_results], | |
| "suggested_track": suggested_track, | |
| "suggested_tracks": suggested_tracks, # [{name, name_ar}] โ top 3 | |
| "suggested_colleges": suggested_colleges, # [{name, university, faculty, department}] โ top 3 | |
| "answers": list(session.conversation_history), | |
| "path_type": path_type, | |
| "user_name": user_name, | |
| "email": session.email, | |
| } | |
| def _build_suggested_colleges(self, college_results: list) -> list: | |
| """Return up to 3 unique college/department suggestions from the vector results.""" | |
| out, seen = [], set() | |
| for r in college_results: | |
| meta = r.get("metadata", {}) | |
| university = (meta.get("university") or "").strip() | |
| faculty = (meta.get("faculty") or "").strip() | |
| department = (meta.get("department") or "").strip() | |
| parts = [p for p in (university, faculty, department) if p] | |
| if not parts: | |
| continue | |
| name = " - ".join(parts) | |
| key = name.lower() | |
| if key not in seen: | |
| seen.add(key) | |
| out.append({ | |
| "name": name, | |
| "university": university, | |
| "faculty": faculty, | |
| "department": department, | |
| }) | |
| if len(out) >= 3: | |
| break | |
| return out | |
| def _build_suggested_tracks(self, track_results: list, track_kw: Optional[dict]) -> list: | |
| """Return up to 3 unique {name, name_ar} suggestions from the vector results.""" | |
| out, seen = [], set() | |
| for r in track_results: | |
| meta = r.get("metadata", {}) | |
| name_en = (meta.get("Track_Name_English") or "").strip() | |
| name_ar = (meta.get("Track_Name_Arabic") or "").strip() | |
| name = name_en or name_ar | |
| key = name.lower() | |
| if name and key not in seen: | |
| seen.add(key) | |
| out.append({"name": name, "name_ar": name_ar or name}) | |
| if len(out) >= 3: | |
| break | |
| # Fallback to the LLM's preferred_tracks if vector search gave nothing | |
| if not out and track_kw and isinstance(track_kw.get("preferred_tracks"), list): | |
| for t in track_kw["preferred_tracks"][:3]: | |
| t = str(t).strip() | |
| if t and t.lower() not in seen: | |
| seen.add(t.lower()) | |
| out.append({"name": t, "name_ar": t}) | |
| return out | |