Spaces:
Running
Running
| """ | |
| Student profile extraction (LLM-based) and the onboarding question sequencer. | |
| """ | |
| import json | |
| import re | |
| from src.utils.config import groq_client, openrouter_client | |
| from src.utils.schemas import StudentProfile | |
| _EXTRACTION_SYSTEM = """ | |
| You are an assistant who extracts student information from a conversation message. | |
| Return ONLY JSON object with these keys (if the information is not exist use null): | |
| { | |
| "gpa": floating number from 0 to 100 or null, | |
| "academic_track": one of [علمي، صناعي، تكنولوجيا معلومات، أدبي، تجاري، أخرى] or null, | |
| "likes_math": null or true/false, | |
| "interest_areas": one of [علم البيانات، الذكاء الاصطناعي، أمن المعلومات، شبكات الحاسوب، هندسة الحاسوب، غير محدد] or null, | |
| "degree_preference": one of [بكالوريوس، دبلوم، غير محدد] or null | |
| } | |
| Rules: | |
| - Only extract a field if the student's message is actually answering the question about THAT field, | |
| or explicitly volunteers that information. A short answer like "نعم"/"لا" only applies to the field | |
| whose question was just asked — do not let it populate any other field. | |
| - The student's message may be a short answer (e.g. "نعم", "لا", "أيوة", "yes", "no") to a question | |
| the assistant just asked. Use the conversation context to determine which field this answer applies to, | |
| and map Arabic/English affirmatives (نعم، أجل، أيوة، صح، yes) to true and negatives (لا، مش، no) to false. | |
| - For degree_preference: if the question asked about degree/duration and the student answers with a | |
| duration, map it accordingly — "سنتان"، "سنتين"، "2"، "two years" → دبلوم; | |
| "اربع سنوات"، "4"، "أربعة"، "four years" → بكالوريوس. | |
| - Do not invent information that is not in the message or implied by the immediate question context. | |
| - Do not modify fields in the current file unless the student explicitly corrects information. | |
| - Return only JSON, without explanation or markdown. | |
| """ | |
| def extract_profile(user_message: str, current: StudentProfile) -> StudentProfile: | |
| """Call LLM to extract profile fields from student message and merge with current profile.""" | |
| try: | |
| resp = groq_client.chat.completions.create( | |
| model="llama-3.1-8b-instant", | |
| messages=[ | |
| {"role": "system", "content": _EXTRACTION_SYSTEM}, | |
| {"role": "user", "content": ( | |
| f"الملف الحالي: {current.model_dump_json()}\n\n" | |
| f"رسالة الطالب: {user_message}" | |
| )}, | |
| ], | |
| temperature=0.0, | |
| max_tokens=400, | |
| ) | |
| raw = re.sub(r"```json|```", "", resp.choices[0].message.content).strip() | |
| extracted = json.loads(raw) | |
| # NEW — strip whitespace from all extracted string/list values | |
| cleaned = {} | |
| for key, value in extracted.items(): | |
| if isinstance(value, str): | |
| cleaned[key] = value.strip() | |
| elif isinstance(value, list): | |
| cleaned[key] = [v.strip() if isinstance(v, str) else v for v in value] | |
| else: | |
| cleaned[key] = value | |
| # Merge: only fill None / empty-list fields from extraction | |
| current_data = current.model_dump() | |
| for key, value in cleaned.items(): | |
| if value is None: | |
| continue | |
| if key == "interest_areas" and isinstance(value, str): | |
| value = [value] | |
| existing = current_data.get(key) | |
| if existing is None or (isinstance(existing, list) and len(existing) == 0): | |
| current_data[key] = value | |
| return StudentProfile(**current_data) | |
| except Exception as e: | |
| print(f"[Profile extraction error] {e}") | |
| return current # return unchanged on any failure | |
| # Maps missing profile fields -> natural Arabic questions. | |
| # No LLM needed — pure lookup table for speed and reliability. | |
| _FIELD_QUESTIONS: dict[str, str] = { | |
| "gpa": ( | |
| "للبدء، ما معدلك في الثانوية العامة (التوجيهي)؟ " | |
| "هذا يساعدني في معرفة البرامج التي تؤهل للقبول." | |
| ), | |
| "academic_track": ( | |
| "ما فرعك الدراسي في الثانوية؟ " | |
| "(علمي / صناعي / تكنولوجيا معلومات / أدبي / تجاري / أخرى)" | |
| ), | |
| "likes_math": ( | |
| "هل تستمتع بالرياضيات والإحصاء؟ " | |
| "أسألك لأن بعض التخصصات كعلم البيانات تعتمد عليهما بشكل كبير." | |
| ), | |
| "interest_areas": ( | |
| "ما الذي يثير اهتمامك أكثر؟ " | |
| "(تحليل البيانات / الذكاء الاصطناعي / أمن المعلومات / الشبكات / هندسة الحاسوب)" | |
| ), | |
| "degree_preference": ( | |
| "هل تفضل الحصول على درجة البكالوريوس (4 سنوات) أم الدبلوم (سنتان)؟" | |
| ), | |
| } | |
| def next_onboarding_question(profile: StudentProfile) -> str | None: | |
| """Return the next onboarding question to ask, or None if profile is complete.""" | |
| for field in profile.missing_fields(): | |
| q = _FIELD_QUESTIONS.get(field) | |
| if q: | |
| return q | |
| return None | |