edupick-api / interview_engine.py
Ali Abozahra
Deploy: Groq migration, comparison fix, roadmap quizzes, 844-college dataset
3df4c2e
Raw
History Blame Contribute Delete
36.2 kB
"""
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
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@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