| from typing import Optional |
|
|
| from services.intent_classifier_client import classify_message_with_model |
|
|
|
|
| def normalize_text(text: str) -> str: |
| return (text or "").strip().lower() |
|
|
|
|
| def contains_any(text: str, keywords: list) -> bool: |
| return any(k in text for k in keywords) |
|
|
|
|
| def is_yes(text: str) -> bool: |
| t = normalize_text(text) |
| return t in [ |
| "نعم", "اه", "أه", "ايوه", "أيوه", "yes", "y", |
| "درست", "اه درست", "أيوه درست" |
| ] |
|
|
|
|
| def is_no(text: str) -> bool: |
| t = normalize_text(text) |
| return t in [ |
| "لا", "لأ", "لاا", "no", "n", |
| "مدرستش", "ما درستش", "لا مدرستش" |
| ] |
|
|
|
|
| def is_new_student(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "طالب جديد", "جديد", "عميل جديد", "اول مرة", "أول مرة", |
| "لسه جديد", "مشترك جديد" |
| ]) |
|
|
|
|
| def is_current_student(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "طالب حالي", "حالي", "عميل حالي", "مشترك", "مشترك حالي", |
| "أنا طالب", "انا طالب عندكم", "انا مشترك" |
| ]) |
|
|
|
|
| def is_adults(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "كبار", "adult", "adults", "الكبار", "كورسات الكبار" |
| ]) |
|
|
|
|
| def is_children(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "اطفال", "أطفال", "طفل", "children", "kids", |
| "كورسات الأطفال", "كورسات الاطفال" |
| ]) |
|
|
|
|
| def is_support_request(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "استفسار", "سؤال", "عندي سؤال", "مشكلة", "مش فاهم", |
| "عايز اسأل", "عايزة اسأل", "محتاج مساعدة", "محتاجه مساعدة", |
| "support", "خدمة العملاء" |
| ]) |
|
|
|
|
| def is_next_level_booking(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "حجز", "احجز", "المستوى التالي", "مستوى تالي", |
| "next level", "احجز المستوى", "حجز مستوى" |
| ]) |
|
|
|
|
| def is_complaint(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "شكوى", "اشتكي", "اشتك", "مشكلة كبيرة", "complaint" |
| ]) |
|
|
|
|
| def wants_direct_support(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "تواصل", "اكلم", "عايز حد يكلمني", "عايزة حد يكلمني", |
| "عايز اكلم خدمة العملاء", "عايزة اكلم خدمة العملاء" |
| ]) |
|
|
|
|
| def wants_start(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "ابدأ", "ابدا", "مساعدة", "مساعده", "start", "menu", "القائمة" |
| ]) |
|
|
|
|
| def wants_restart(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "من جديد", "ابدأ من جديد", "restart", "مينيو", "القائمة", "ابدأ" |
| ]) |
|
|
|
|
| def wants_new_topic(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "عايز اسال عن حاجة تانية", |
| "عايزة اسال عن حاجة تانية", |
| "استفسار جديد", |
| "موضوع تاني", |
| "حاجة تانية" |
| ]) |
|
|
|
|
| def wants_courses_info(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "كورسات", |
| "الكورسات", |
| "ايه الكورسات", |
| "ما هي الكورسات", |
| "الأنواع", |
| "الانواع", |
| "عايز اعرف الكورسات", |
| "عايزة اعرف الكورسات", |
| "ايه الكورسات المتاحة", |
| "الكورسات المتاحة" |
| ]) |
|
|
|
|
| def asks_about_prior_study_case(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "لو كنت درست", |
| "لو كنت دارس", |
| "لو درست قبل كده", |
| "طب لو درست", |
| "ولو درست", |
| "اذا كنت درست", |
| "إذا كنت درست", |
| "اختبار تحديد مستوى", |
| "تحديد مستوى" |
| ]) |
|
|
|
|
| def asks_about_beginner_case(text: str) -> bool: |
| t = normalize_text(text) |
| return contains_any(t, [ |
| "لو مكنتش درست", |
| "لو ما درستش", |
| "لو مدرستش", |
| "لو لسه جديد", |
| "لو مبتدئ", |
| "لو بادئ", |
| "لو اول مرة", |
| "لو أول مرة" |
| ]) |
|
|
|
|
| def detect_level(text: str) -> Optional[str]: |
| t = normalize_text(text) |
|
|
| if contains_any(t, ["1a", "a1", "a1.1", "1 a"]): |
| return "1A" |
|
|
| if contains_any(t, ["2a", "a2", "a1.2", "2 a"]): |
| return "2A" |
|
|
| if contains_any(t, ["1b", "b1", "b1.1", "1 b"]): |
| return "1B" |
|
|
| if contains_any(t, ["1c", "2b", "b2", "1c2/b", "1 c", "2 b"]): |
| return "1C2/B" |
|
|
| return None |
|
|
|
|
| def detect_payment_method(text: str) -> Optional[str]: |
| t = normalize_text(text) |
|
|
| if contains_any(t, ["فرع", "فروع", "كاش", "cash"]): |
| return "branch_or_cash" |
|
|
| if contains_any(t, ["تحويل", "بنكي", "bank", "transfer"]): |
| return "bank_transfer" |
|
|
| if contains_any(t, ["فودافون", "vodafone", "vodafone cash"]): |
| return "vodafone_cash" |
|
|
| if contains_any(t, ["فيزا", "visa", "ماستر", "master", "credit card", "card"]): |
| return "card" |
|
|
| if contains_any(t, ["تقسيط", "value", "فاليو"]): |
| return "installments" |
|
|
| return None |
|
|
|
|
| def _make_result(kind: str, value: Optional[str], confidence: float, entities: Optional[dict] = None, source: str = "rules", raw_model_output: Optional[str] = None): |
| result = { |
| "kind": kind, |
| "value": value, |
| "confidence": confidence, |
| "entities": entities or {}, |
| "source": source, |
| } |
| if raw_model_output is not None: |
| result["raw_model_output"] = raw_model_output |
| return result |
|
|
|
|
| def _map_model_intent_to_result(state: str, text: str, model_intent: str, raw_output: str = ""): |
| |
| if model_intent == "restart": |
| return _make_result("topic_switch", "restart", 0.90, {}, "model", raw_output) |
|
|
| if model_intent == "new_topic": |
| return _make_result("topic_switch", "new_topic", 0.88, {}, "model", raw_output) |
|
|
| if model_intent == "complaint": |
| return _make_result("topic_switch", "complaint", 0.92, {}, "model", raw_output) |
|
|
| if model_intent == "direct_support": |
| return _make_result("topic_switch", "direct_support", 0.90, {}, "model", raw_output) |
|
|
| if model_intent == "courses_info": |
| return _make_result("topic_switch", "courses_info", 0.88, {}, "model", raw_output) |
|
|
| if model_intent == "children_courses": |
| return _make_result( |
| "topic_switch", |
| "children_courses", |
| 0.88, |
| {"audience": "children"}, |
| "model", |
| raw_output |
| ) |
|
|
| if model_intent == "adults_courses": |
| return _make_result( |
| "topic_switch", |
| "adults_courses", |
| 0.88, |
| {"audience": "adults"}, |
| "model", |
| raw_output |
| ) |
|
|
| if model_intent == "new_student": |
| target_kind = "direct_answer" if state == "WAITING_USER_TYPE" else "topic_switch" |
| return _make_result( |
| target_kind, |
| "new_student", |
| 0.90, |
| {"customer_type": "new"}, |
| "model", |
| raw_output |
| ) |
|
|
| if model_intent == "current_student": |
| target_kind = "direct_answer" if state == "WAITING_USER_TYPE" else "topic_switch" |
| return _make_result( |
| target_kind, |
| "current_student", |
| 0.90, |
| {"customer_type": "current"}, |
| "model", |
| raw_output |
| ) |
|
|
| |
| if model_intent == "adults": |
| return _make_result("direct_answer", "adults", 0.93, {"audience": "adults"}, "model", raw_output) |
|
|
| if model_intent == "children": |
| return _make_result("direct_answer", "children", 0.93, {"audience": "children"}, "model", raw_output) |
|
|
| if model_intent == "prior_study_yes": |
| return _make_result("direct_answer", "prior_study_yes", 0.94, {"prior_study": True}, "model", raw_output) |
|
|
| if model_intent == "prior_study_no": |
| return _make_result("direct_answer", "prior_study_no", 0.94, {"prior_study": False}, "model", raw_output) |
|
|
| if model_intent == "confirm_schedule_reviewed": |
| return _make_result("direct_answer", "confirm_schedule_reviewed", 0.90, {}, "model", raw_output) |
|
|
| if model_intent == "proceed_booking": |
| return _make_result("direct_answer", "proceed_booking", 0.90, {}, "model", raw_output) |
|
|
| if model_intent == "confirm_pdf_reviewed": |
| return _make_result("direct_answer", "confirm_pdf_reviewed", 0.90, {}, "model", raw_output) |
|
|
| if model_intent == "confirm_placement_test_reviewed": |
| return _make_result("direct_answer", "confirm_placement_test_reviewed", 0.90, {}, "model", raw_output) |
|
|
| if model_intent == "current_student_support": |
| return _make_result("direct_answer", "current_student_support", 0.90, {}, "model", raw_output) |
|
|
| if model_intent == "current_student_next_level": |
| return _make_result("direct_answer", "current_student_next_level", 0.90, {}, "model", raw_output) |
|
|
| if model_intent == "support_question_text": |
| return _make_result( |
| "direct_answer", |
| "support_question_text", |
| 0.85, |
| {"support_question": text}, |
| "model", |
| raw_output |
| ) |
|
|
| if model_intent == "level_selected": |
| level = detect_level(text) |
| if level: |
| return _make_result( |
| "direct_answer", |
| "level_selected", |
| 0.92, |
| {"selected_level": level}, |
| "model", |
| raw_output |
| ) |
|
|
| if model_intent == "payment_method_selected": |
| payment_method = detect_payment_method(text) |
| if payment_method: |
| return _make_result( |
| "direct_answer", |
| "payment_method_selected", |
| 0.92, |
| {"payment_method": payment_method}, |
| "model", |
| raw_output |
| ) |
|
|
| if model_intent == "complaint_form_submitted": |
| return _make_result("direct_answer", "complaint_form_submitted", 0.90, {}, "model", raw_output) |
|
|
| if model_intent == "thanks": |
| return _make_result("direct_answer", "thanks", 0.95, {}, "model", raw_output) |
|
|
| |
| if model_intent == "switch_to_prior_study_true": |
| return _make_result( |
| "state_switch", |
| "switch_to_prior_study_true", |
| 0.90, |
| {"prior_study": True}, |
| "model", |
| raw_output |
| ) |
|
|
| if model_intent == "switch_to_prior_study_false": |
| return _make_result( |
| "state_switch", |
| "switch_to_prior_study_false", |
| 0.90, |
| {"prior_study": False}, |
| "model", |
| raw_output |
| ) |
|
|
| if model_intent == "support_needed": |
| return _make_result("state_switch", "support_needed", 0.86, {}, "model", raw_output) |
|
|
| return None |
|
|
|
|
| def _fallback_rule_based_classification(state: str, text: str, flow_data: dict | None = None): |
| """ |
| ده اللوجيك القديم كما هو تقريبًا، عشان ما نكسرش أي حاجة. |
| """ |
| flow_data = flow_data or {} |
| t = normalize_text(text) |
|
|
| |
| if wants_restart(t): |
| return _make_result("topic_switch", "restart", 0.99, {}) |
|
|
| if wants_new_topic(t): |
| return _make_result("topic_switch", "new_topic", 0.95, {}) |
|
|
| if is_complaint(t): |
| return _make_result("topic_switch", "complaint", 0.98, {}) |
|
|
| if wants_direct_support(t): |
| return _make_result("topic_switch", "direct_support", 0.95, {}) |
|
|
| if wants_courses_info(t): |
| return _make_result("topic_switch", "courses_info", 0.90, {}) |
|
|
| if is_children(t): |
| return _make_result("topic_switch", "children_courses", 0.88, {"audience": "children"}) |
|
|
| if is_adults(t): |
| return _make_result("topic_switch", "adults_courses", 0.88, {"audience": "adults"}) |
|
|
| if is_new_student(t): |
| return _make_result("topic_switch", "new_student", 0.90, {"customer_type": "new"}) |
|
|
| if is_current_student(t): |
| return _make_result("topic_switch", "current_student", 0.90, {"customer_type": "current"}) |
|
|
| |
| if state == "WAITING_USER_TYPE": |
| if is_new_student(t): |
| return _make_result("direct_answer", "new_student", 0.95, {"customer_type": "new"}) |
| if is_current_student(t): |
| return _make_result("direct_answer", "current_student", 0.95, {"customer_type": "current"}) |
|
|
| if state == "WAITING_AUDIENCE": |
| if is_adults(t): |
| return _make_result("direct_answer", "adults", 0.95, {"audience": "adults"}) |
| if is_children(t): |
| return _make_result("direct_answer", "children", 0.95, {"audience": "children"}) |
|
|
| if state == "WAITING_PRIOR_STUDY": |
| if is_yes(t): |
| return _make_result("direct_answer", "prior_study_yes", 0.96, {"prior_study": True}) |
| if is_no(t): |
| return _make_result("direct_answer", "prior_study_no", 0.96, {"prior_study": False}) |
|
|
| if state in [ |
| "WAITING_BEGINNER_SCHEDULE_CHOICE", |
| "WAITING_PDF_102_CONFIRMATION", |
| "WAITING_PLACEMENT_TEST_CONFIRMATION", |
| ]: |
| if asks_about_prior_study_case(t): |
| return _make_result("state_switch", "switch_to_prior_study_true", 0.92, {"prior_study": True}) |
|
|
| if asks_about_beginner_case(t): |
| return _make_result("state_switch", "switch_to_prior_study_false", 0.92, {"prior_study": False}) |
|
|
| if state == "WAITING_BEGINNER_SCHEDULE_CHOICE": |
| if contains_any(t, ["تم", "اخترت", "اختارت", "جاهز", "جاهزة"]): |
| return _make_result("direct_answer", "confirm_schedule_reviewed", 0.92, {}) |
|
|
| if contains_any(t, ["عايز احجز", "عايزة احجز", "احجز", "حجز", "اشترك", "اشتراك"]): |
| return _make_result("direct_answer", "proceed_booking", 0.90, {}) |
|
|
| if is_support_request(t): |
| return _make_result("state_switch", "support_needed", 0.88, {}) |
|
|
| if state == "WAITING_PDF_102_CONFIRMATION": |
| if contains_any(t, ["تم", "خلصت", "قريت", "اطلعت", "جاهز", "جاهزة"]): |
| return _make_result("direct_answer", "confirm_pdf_reviewed", 0.92, {}) |
|
|
| if is_support_request(t): |
| return _make_result("state_switch", "support_needed", 0.88, {}) |
|
|
| if state == "WAITING_PLACEMENT_TEST_CONFIRMATION": |
| if contains_any(t, ["تم", "اخترت", "اختارت", "جاهز", "جاهزة"]): |
| return _make_result("direct_answer", "confirm_placement_test_reviewed", 0.92, {}) |
|
|
| if is_support_request(t): |
| return _make_result("state_switch", "support_needed", 0.88, {}) |
|
|
| if state == "WAITING_CURRENT_STUDENT_ACTION": |
| if is_support_request(t): |
| return _make_result("direct_answer", "current_student_support", 0.92, {}) |
|
|
| if is_next_level_booking(t): |
| return _make_result("direct_answer", "current_student_next_level", 0.92, {}) |
|
|
| if state == "WAITING_SUPPORT_QUESTION": |
| if t: |
| return _make_result( |
| "direct_answer", |
| "support_question_text", |
| 0.85, |
| {"support_question": text} |
| ) |
|
|
| if state == "WAITING_LEVEL_SELECTION": |
| level = detect_level(t) |
| if level: |
| return _make_result("direct_answer", "level_selected", 0.95, {"selected_level": level}) |
|
|
| if is_support_request(t) or contains_any(t, ["مش عارف", "مش متأكد", "مش متاكدة"]): |
| return _make_result("state_switch", "support_needed", 0.85, {}) |
|
|
| if state == "WAITING_PAYMENT_METHOD": |
| payment_method = detect_payment_method(t) |
| if payment_method: |
| return _make_result( |
| "direct_answer", |
| "payment_method_selected", |
| 0.95, |
| {"payment_method": payment_method} |
| ) |
|
|
| if is_support_request(t): |
| return _make_result("state_switch", "support_needed", 0.85, {}) |
|
|
| if state == "WAITING_COMPLAINT_FORM": |
| if contains_any(t, ["تم", "خلصت", "سجلت", "قدمت", "بعت"]): |
| return _make_result("direct_answer", "complaint_form_submitted", 0.90, {}) |
|
|
| if state == "HANDOFF_DONE": |
| if contains_any(t, ["شكرا", "متشكر", "تسلم", "ميرسي"]): |
| return _make_result("direct_answer", "thanks", 0.95, {}) |
|
|
| if is_support_request(t): |
| return _make_result("topic_switch", "direct_support", 0.90, {}) |
|
|
| return _make_result("unclear", None, 0.30, {}) |
|
|
|
|
| def classify_message(state: str, text: str, flow_data: dict | None = None): |
| """ |
| Returns structured classification: |
| { |
| "kind": "direct_answer" | "state_switch" | "topic_switch" | "unclear", |
| "value": str | None, |
| "confidence": float, |
| "entities": dict, |
| "source": "model" | "rules" |
| } |
| """ |
| flow_data = flow_data or {} |
|
|
| |
| try: |
| model_res = classify_message_with_model( |
| user_message=text, |
| state=state, |
| flow_data=flow_data, |
| ) |
|
|
| if model_res and model_res.get("intent"): |
| mapped = _map_model_intent_to_result( |
| state=state, |
| text=text, |
| model_intent=model_res["intent"], |
| raw_output=model_res.get("raw_output", "") |
| ) |
| if mapped: |
| return mapped |
|
|
| except Exception as e: |
| print(f"[message_understanding] model classifier failed: {repr(e)}") |
|
|
| |
| return _fallback_rule_based_classification(state, text, flow_data) |