كليم - التوثيق التقني

كليم - التوثيق التقني

روبوت محادثة ذكي للتوجيه المهني لطلاب الجامعة اللبنانية


جدول المحتويات

  1. نظرة عامة على المشروع
  2. الربط مع الأطروحة
  3. مصادر البيانات ومعالجتها
  4. هندسة النظام
  5. الميزات الأساسية
  6. التنفيذ التقني
  7. شرح كل مكون

نظرة عامة على المشروع

كليم روبوت محادثة ذكي مصمَّم لمساعدة طلاب المرحلة الثانوية في لبنان على اختيار التخصص الجامعي المناسب في الجامعة اللبنانية، عبر نظرية هولاند للشخصية المهنية (RIASEC) وواجهة محادثة عربية من اليمين إلى اليسار.

اقرأ هذا أولاً. التوصيات لا يُنتجها نموذج لغوي. ملف src/rag/retriever.py هو من يوصي بالكامل: تصفية حتمية وتسجيل مُرجَّح فوق data/processed/majors.json. أما النموذج اللغوي فيصوغ أجوبة أسئلة المتابعة في المحادثة فقط، والتطبيق يعمل كاملاً من دون أي نموذج مُعَدّ.

وهذا مهم عملياً: الترتيب هو ما تحقّق منه العميل، فنقل أي جزء منه إلى أمر (prompt) يجعل النتائج غير قابلة للتكرار. كما يعني أن البنية هي استرجاع ثم صياغة لا RAG بالمعنى التضميني — فوحدتا التضمين ومخزن المتّجهات موجودتان وتصفهما الأطروحة، لكن لا شيء في التطبيق العامل يستدعيهما.

التطبيق منشور على https://kalim-chatbot-thesis.streamlit.app/.

الأهداف الرئيسية


الربط مع الأطروحة

الأساس البحثي

تم بناء الروبوت بناءً على بحث أطروحة ماجستير تحدد:

المشكلة البحثية:

يواجه الطلاب اللبنانيون صعوبة في اختيار التخصصات الجامعية المناسبة بسبب: - غياب التوجيه المهني المناسب في المدارس - محدودية الوعي بالتخصصات المتاحة - عدم وجود طريقة منهجية لمطابقة الشخصية مع المهن

الحل المقترح:

روبوت محادثة ذكي يقوم بـ: - استخدام نظرية هولاند المعتمدة علمياً - تقديم توصيات مبنية على البيانات - توفير الوصول على مدار الساعة عبر واجهة ويب

تطبيق نظرية هولاند RIASEC

تستخدم الأطروحة نظرية هولاند المهنية التي تصنف الشخصيات إلى 6 أنماط:

الرمز النمط بالإنجليزية النمط بالعربية الوصف
R Realistic واقعي العمل العملي والتقني
I Investigative بحثي التفكير التحليلي والعلمي
A Artistic فني الأنشطة الإبداعية والتعبيرية
S Social اجتماعي مساعدة وتعليم الآخرين
E Enterprising مقدام القيادة والإقناع
C Conventional تقليدي التنظيم وإدارة البيانات

التطبيق في الأطروحة: كل تخصص في قاعدة بيانات الجامعة اللبنانية مُعَلَّم بـ 1-3 رموز هولاند تمثل أنماط الشخصية الأكثر ملاءمة لذلك المجال.


مصادر البيانات ومعالجتها

ملف Excel الأول: بيانات التخصصات

اسم الملف: داتا شات بوت كليم النهائية.xlsx

يحتوي هذا الملف على 95 تخصصاً عبر 16 كلية مع الحقول التالية:

العمود الاسم بالعربية الاستخدام في النظام
ID الرقم معرف فريد
Major Name (AR) اسم الاختصاص اسم العرض بالعربية
Major Name (EN) اسم الاختصاص بالإنكليزية للدعم ثنائي اللغة
Faculty الكلية/المعهد تجميع الكليات
Holland Codes رموز هولاند مطابقة الشخصية (مثل: “R, I”)
Secondary Track الفرع الثانوي فلتر الأهلية (علمي/أدبي/كلاهما)
Teaching Language لغة التدريس فلتر اللغة
Branches الفروع مواقع الحرم الجامعي
Description الوصف وصف التخصص للسياق
Career Opportunities الفرص الوظيفية معلومات المسار المهني

منطق المعالجة (src/data/processor.py، يُشغَّل بـ python -m src.data.processor):

# تحويل Excel إلى JSON
1. قراءة الملف الرئيسي باستخدام openpyxl
2. تحليل رموز هولاند إلى بنية (primary/secondary/tertiary/triplet)
3. تحليل الفروع/المواقع إلى كائنات (محافظة/قضاء/فرع)
4. تحليل الفروع الثانوية المسموحة (4 فروع)
5. دمج الملف التكميلي — للنواقص فقط
6. التنظيف والتحقق والتصدير إلى data/processed/*.json

لا شيء يقرأ ملفات Excel أثناء التشغيل. التطبيق لا يحمّل إلا ملفات JSON المولّدة، عبر src/data/loader.py (والدالة load_majors() مُخزَّنة بـ lru_cache). أعِد التوليد وأودِع النتيجة في المستودع.

يُقرأ الملف الرئيسي حسب موضع العمود، لذا فإن إعادة ترتيب الأعمدة في Excel تُفسد المخرجات بصمت:

الأعمدة الحقل
1 id (وإن لم يكن رقماً يُستخدم رقم الصف)
2-4 faculty_category، faculty_ar، degree_level
5-7 name_ar، name_en، name_fr
8-9 admission_requirements، description
10-13 لغات التدريس، عمود لكل لغة، ووجود القيمة يعني أنها متاحة
14-16 career_opportunities، entry_mechanism، allowed_tracks
17-19 هولاند: الأساسي / الثانوي / الثالث
20 الرمز الثلاثي — الرمز المدمج، وهو غير مرجعي (انظر أدناه)
21 holland_interpretation
22+ المواقع، ثلاثيات متكرّرة من (محافظة، قضاء، فرع)

قاعدتان في التحليل، أصلحت كلٌّ منهما خللاً حقيقياً:

ملف Excel الثاني: البيانات التكميلية

اسم الملف: معالجة النواقص.xlsx

مفتاحه هو معرّف التخصص نفسه، وهو يملأ النواقص فقط: يستطيع تجاوز لغات التدريس (من رمز Ara/Eng/Fre) وإضافة فروع لتخصص لا مواقع له إطلاقاً.

أما رموز هولاند فيه فتُتجاهل عن قصد — تعتبر merge_data() الملف الرئيسي مرجعاً لها.

البيانات الاستخدام
المحافظات فلتر الموقع الرئيسي — 8 محافظات إضافةً إلى الكل
الأقضية فلتر الموقع الثانوي
أسماء الفروع تحديد الحرم الجامعي، للعرض فقط

المعالجة: ينشئ data/processed/locations.json بهيكل هرمي:

{
  "بيروت": ["بيروت"],
  "جبل لبنان": ["المتن", "كسروان", "الشوف", "..."],
  "الشمال": ["طرابلس", "الكورة", "..."],
  "..."
}

هندسة النظام

مخطط التدفق

┌─────────────────────────────────────────────────────────────────┐
│                    واجهة STREAMLIT                               │
│  ┌─────────────┐  ┌─────────────────┐  ┌───────────────────┐   │
│  │ الشريط      │  │  اختيار هولاند  │  │  النتائج/المحادثة │   │
│  │ الجانبي    │  │   (6 RIASEC)    │  │                   │   │
│  └─────────────┘  └─────────────────┘  └───────────────────┘   │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      خط أنابيب التصفية                          │
│  ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌─────────────┐  │
│  │  فلتر   │ → │  فلتر   │ → │  فلتر   │ → │  تسجيل     │  │
│  │  الفرع  │   │  اللغة  │   │  الموقع │   │  هولاند    │  │
│  └──────────┘   └──────────┘   └──────────┘   └─────────────┘  │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│        طبقة LLM — لصياغة أجوبة المحادثة الحرة فقط، واختيارية    │
│  ┌─────────────────┐        ┌────────────────────────────────┐  │
│  │  Ollama / Qwen  │   أو   │  Groq (Qwen) / OpenAI /        │  │
│  │  (محلي، تطوير)  │        │  Anthropic / Together          │  │
│  └─────────────────┘        └────────────────────────────────┘  │
│                     │                                            │
│                     ▼                                            │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ _sanitize_reply: حذف سلسلة التفكير ورفض الإجابات بلغة     │  │
│  │ خاطئة، والرجوع إلى إجابة مبنية على البيانات مباشرةً        │  │
│  └───────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘

النتائج التي يراها الطالب لا تمرّ عبر النموذج اللغوي إطلاقاً. خط أنابيب التصفية في src/rag/retriever.py هو من يُنتج القائمة المرتّبة، والنموذج يُعطى هذه النتائج ويُطلب منه صياغة جواب على سؤال متابعة. من دون أي نموذج مُعَدّ يبقى التطبيق كاملاً وظيفياً ويجيب من البيانات مباشرةً.


الميزات الأساسية

1. نظام التصفية متعدد المراحل

الربط مع الأطروحة: تؤكد الأطروحة أن اختيار التخصص يجب أن يراعي عوامل متعددة، وليس الشخصية فقط.

# خط أنابيب التصفية (src/rag/retriever.py)

class KalimRetriever:
    def retrieve(self, profile: StudentProfile, top_k: int = 20) -> List[Dict]:
        candidates = self.majors.copy()

        # المرحلة 1: فلتر الفرع الثانوي
        # الأطروحة: الطلاب يمكنهم التقدم فقط للتخصصات المطابقة لفرعهم
        candidates = self.filter_by_track(candidates, profile.secondary_track)

        # المرحلة 2: فلتر اللغة
        # الأطروحة: إتقان اللغة ضروري للنجاح
        candidates = self.filter_by_language(candidates, profile.languages)

        # المرحلة 3: فلتر الموقع
        # يطابق حقلي المحافظة/القضاء المنظّمين فقط. كان يطابق أيضاً نص عنوان
        # الفرع الحر، ما كان يُدخل فروع جبل لبنان ضمن نتائج بيروت.
        candidates = self.filter_by_location(
            candidates,
            governorate=profile.governorate, district=profile.district,
            governorates=profile.governorates, districts=profile.districts,
        )

        # المرحلة 4: تسجيل هولاند — ترتيب لا تصفية
        # الأطروحة: مطابقة الشخصية-التخصص تتنبأ بالرضا
        scored = [{
            'major': m,
            'holland_score': self.calculate_holland_score(
                profile.holland_codes, m.get('holland_codes', {})),
            'match_reasons': ...,
        } for m in candidates]

        return sorted(scored, key=lambda x: x['holland_score'], reverse=True)[:top_k]

كل فلتر يعتبر القيمة الفارغة أو الكلمة الكل بمعنى “بلا تصفية” فيمرّر كل شيء. ورموز هولاند لا تُصفّي أبداً — إنما تُرتّب فقط، حتى لا يُقال للطالب إن تخصصاً غير متاح له بسبب شخصيته.

يستدعي app.py الدالة retrieve_with_expansion(top_k=MAX_RESULTS, min_results=PAGE_SIZE, score_threshold=50): تُسترجَع كل التخصصات التي تتجاوز 50% حتى MAX_RESULTS = 100، وتُعرض 10 في كل مرة عبر زر “عرض المزيد”. البحث نفسه غير مقصوص عند 10 — وكان ذلك ملاحظة من العميل: البناء السابق كان يقصّ البحث عند 10 نتائج، فلم يكن الطالب صاحب الملف الواسع يرى بقية التخصصات التي يستحقها.

2. خوارزمية مطابقة رموز هولاند

الربط مع الأطروحة: محرك التوصيات الأساسي المبني على نظرية هولاند.

⚠️ كانت النسخ السابقة من هذا المستند تعرض هنا خوارزمية بأوزان 3/2/1 من ملف src/chains/filters.py. تلك الخوارزمية لم تكن أبداً ما يعمل في التطبيق، وذلك الملف حُذف. الخوارزمية الفعلية والوحيدة موجودة في src/rag/retriever.py، وهي المعروضة أدناه، وهي التي تحقّق منها العميل يدوياً (النتائج المتوقّعة في Test_Results.docx في جذر المشروع).

# خوارزمية التسجيل (src/rag/retriever.py)

def calculate_holland_score(self, student_codes: List[str],
                            major_codes: Dict[str, str]) -> float:
    """
    ترجيح مزدوج: موقع الرمز عند التخصص × موقعه عند الطالب.

    وزن موقع الرمز في التخصص:    الأول 50  |  الثاني 30  |  الثالث 20
    معامل ترتيب الرمز عند الطالب: الأول 1.0 |  الثاني 0.8 |  الثالث 0.6

    أعلى مجموع ممكن = 50×1.0 + 30×0.8 + 20×0.6 = 86
    النسبة = (المجموع ÷ 86) × 100   →  التطابق التام = 100%
    """
    score = 0.0
    major_weights = [50, 30, 20]
    student_weights = [1.0, 0.8, 0.6]

    major_list = [major_codes.get('primary', '').upper(),
                  major_codes.get('secondary', '').upper(),
                  major_codes.get('tertiary', '').upper()]

    # حذف الرموز المكرّرة عند الطالب مع الإبقاء على الأقوى (الأسبق)
    unique_codes = list(dict.fromkeys(c.upper() for c in student_codes if c))

    consumed = set()   # كل موقع في التخصص يُطابَق مرة واحدة فقط

    for i, student_code in enumerate(unique_codes[:3]):
        for pos, major_code in enumerate(major_list):
            # الرمز الغائب عند التخصص هو نص فارغ، ويجب ألا يطابق شيئاً
            if not major_code or pos in consumed or major_code != student_code:
                continue
            score += major_weights[pos] * student_weights[i]
            consumed.add(pos)
            break

    return round(min((score / 86.0) * 100, 100), 1)

ثلاث قواعد في هذه الحلقة، كلٌّ منها أصلح خللاً حقيقياً في الترتيب:

  1. تُحذف الرموز المكرّرة عند الطالب. من دون ذلك يسجّل ['E','E','S'] نفس الموقع مرتين ويتفوّق على تطابق ثلاثي حقيقي.
  2. كل موقع في التخصص يُستهلك مرة واحدة فقط. بعد مطابقة الرمز الأساسي لا يستطيع رمز طالب لاحق مطابقته من جديد.
  3. الرمز الغائب عند التخصص لا يطابق شيئاً، وإلا لدفع '' == '' ثلاثين أو عشرين نقطة مقابل حقلين فارغين.

الدالة غير متماثلة: الوسيط الأول مرتّب حسب تفضيل الطالب والثاني حسب متطلبات التخصص، ولكلٍّ أوزان مختلفة. تبديلهما لا يُطلق خطأ بل يُعيد رقماً معقولاً لكنه خاطئ، فيبدو كأنه انحدار في التسجيل لا كخطأ في الاستدعاء. الاختبار test_score_is_asymmetric يثبّت ذلك.

التوسّع بالتباديل: حين لا يتجاوز عدد كافٍ من التخصصات العتبة، يُعيد النظام احتساب المرشّحين بكل ترتيبات رموز الطالب ويحتفظ بأفضل نتيجة لكل تخصص، حتى لا يخرج الطالب بنتائج فارغة. التفاصيل الكاملة في docs/Holland_Scoring_Algorithm.md.

3. المحادثة: استرجاع ثم صياغة

الربط مع الأطروحة: التفسيرات المدعومة بالذكاء الاصطناعي تعزز الفهم.

تصحيح: لا يُنتج النموذج اللغوي التوصيات. البنية هي استرجاع ثم صياغة: يستخرج app.py الوقائع من البيانات أولاً، ثم يُطلب من النموذج صياغتها بالعربية. تسليم السؤال الخام للنموذج مباشرةً أنتج أجوبة تخترع متطلبات قبول غير موجودة وتقلب معنى نسب التوافق.

# app.py — handle_free_chat

1. _majors_mentioned()    # عن أي تخصص يسأل الطالب؟ بالاسم أو برقم البطاقة
2. _why_not_in_results()  # أي فلتر استبعد تخصصاً يسأل عنه؟
3. _profile_summary()     # إجابات الطالب نفسها، مكتوبة للنموذج
4. → النموذج يصوغ الجواب من هذه الوقائع فقط (FOLLOWUP_PROMPT)
5. _sanitize_reply()      # وإلا: _answer_without_llm() من البيانات مباشرةً

يحمل FOLLOWUP_PROMPT قواعد صريحة جاء كلٌّ منها من خلل مُلاحَظ: لا تخترع متطلبات قبول، لا تُعِد ذكر نسبة التوافق بالمعنى المعاكس (ذُكرت مرة “34.9%” على أنها توافق عالٍ)، لا تخاطب الطالب بصيغة مذكّر أو مؤنّث محدّدة، وأجب بالعربية — وهي قاعدة مكرّرة في آخر الأمر حيث تثبت أكثر.

Qwen نموذج استدلالي، وسيطبع سلسلة تفكيره كاملةً بالإنكليزية كأنها الجواب ما لم يُمنع من ذلك. المنع مزدوج لأن كل طبقة وحدها أخفقت في الإنتاج:

4. واجهة عربية RTL

الربط مع الأطروحة: سهولة الوصول للطلاب اللبنانيين الناطقين بالعربية.

# تنسيق واجهة المستخدم (src/ui/styles.py)

MAIN_CSS = """
/* هيكل التطبيق يبقى LTR كي تعمل حسابات موضع الشريط الجانبي في Streamlit */
[data-testid="stAppViewContainer"] { direction: ltr; }

/* وRTL يُطبَّق على مناطق المحتوى وحدها */
[data-testid="stMain"] .stMarkdown,
[data-testid="stChatMessage"] { direction: rtl; text-align: right; }
"""

ثلاثة قيود في هذا الملف يسهل نقضها بغير قصد:

st.html ينقّي المحتوى عبر DOMPurify بصرامة. يحذف وسمَي <link> و<script>، ويرمي كتلة <style> بأكملها إذا احتوت @import أو @font-face — ولا ينفع استخدام data: URI، فالقاعدة نفسها مرفوضة. لذلك يُحمَّل الخط عبر _render_font_loader من داخل إطار component، وهو HTML حقيقي غير منقّى. وst.components.v1.html أصبح مهجوراً لصالح st.iframe؛ تجرّب الدالة الاسم الجديد أولاً ثم ترجع للقديم، ويجب أن يكون height عدداً صحيحاً موجباً (القيمة 0 تُطلق خطأ).

لا تُضِف flex-direction: row-reverse إلى stHorizontalBlock. صفوف الأعمدة ترث أصلاً direction: rtl التي تضع العمود الأول يميناً؛ والعكس الإضافي يُلغي ذلك ويعيده إلى اليسار.

الـ <div> المكتوب يدوياً لا يمكنه احتواء عناصر الواجهة. يرسم Streamlit كل استدعاء markdown في عقدة مستقلة ويغلق الوسوم غير المتوازنة تلقائياً، فلا يلتفّ <div> مفتوح حول ما بعده — استخدم st.container(border=True).

وعلى Streamlit Community Cloud يعمل التطبيق داخل إطار (iframe)، فلا يستطيع CSS التطبيق الوصول إلى شارة “Created by” في الصفحة الخارجية.


التنفيذ التقني

هيكل الملفات والغرض

Kalim Thesis/
├── app.py                      # نقطة الدخول الوحيدة لواجهة Streamlit
├── conftest.py                 # يضيف جذر المشروع و src/ إلى sys.path للاختبارات
├── CLAUDE.md                   # ملاحظات عمل: القيود وأسباب أخطاء سابقة
├── requirements.txt            # متطلبات التطوير
├── requirements-deploy.txt     # متطلبات النشر السحابي (أخف)
├── .env.example                # كل متغيرات البيئة المدعومة
├── Test_Results.docx           # النتائج المتوقّعة التي تحقّق منها العميل يدوياً
├── config/
│   ├── settings.py             # المسارات، رموز RIASEC، الفروع، أسماء النماذج
│   └── prompts.py              # أوامر LLM بالعربية
├── data/
│   ├── raw/                    # ملفات Excel الأصلية — لا تُقرأ أثناء التشغيل
│   └── processed/              # ملفات JSON المولّدة — وهي ما يقرأه التطبيق
│       ├── majors.json         # 95 تخصص مع جميع البيانات الوصفية
│       ├── locations.json      # رقم التخصص ← المحافظات
│       └── holland_mapping.json # تعريفات رموز RIASEC للواجهة
├── src/
│   ├── data/
│   │   ├── loader.py           # تحميل JSON مع lru_cache
│   │   └── processor.py        # تحويل Excel إلى JSON
│   ├── rag/
│   │   ├── retriever.py        # المحرّك: التصفية وتسجيل هولاند
│   │   ├── embeddings.py       # تضمينات النص — بلا مستدعٍ فعلي
│   │   └── vectorstore.py      # تكامل ChromaDB — بلا مستدعٍ فعلي
│   ├── llm/
│   │   ├── local_llm.py        # Ollama محلياً، للتطوير فقط
│   │   └── cloud_llm.py        # Groq / OpenAI / Anthropic / Together
│   └── ui/
│       └── styles.py           # تنسيق CSS (رموز تصميم، RTL، استجابة للجوال)
└── tests/
    ├── test_retriever.py       # المحرّك: التسجيل والفلاتر والتوسّع
    └── test_reply_sanitizer.py # تنقية ردود النموذج ورفض اللغة الخاطئة

حُذف منذ النسخ السابقة من هذا المستند: المجلدان src/chains/ و src/ui/components/. كان src/chains/ نسخة متفرّعة ومعطوبة من المسترجِع — نفس الفكرة بتسجيل مختلف وخاطئ — لا يستوردها أي كود يعمل، وقد يظنّها القارئ محرّك التوصيات. أما src/ui/components/ فلم يعد له مستورد بعد أن صار app.py يرسم عناصره بنفسه. كلاهما موجود في تاريخ git عند الحاجة.

اصطلاح الاستيراد: تستورد الوحدات بعضها بالاسم المجرّد (from data.loader import ... وليس from src.data.loader import ...). يضيف app.py وconftest.py جذر المشروع وsrc/ إلى sys.path. الاستيراد بصيغة src.data.loader يُحمّل نسخة ثانية من كل وحدة بذاكرة تخزين مؤقت خاصة بها.

تدفق البيانات

1. إدخال المستخدم
   ├── رموز هولاند: [R, I, A] (من الاختبار أو الاختيار)
   ├── الفرع الثانوي: علمي
   ├── اللغات: [عربي، فرنسي]
   └── الموقع: بيروت

2. التصفية (src/rag/retriever.py)
   ├── تحميل 95 تخصص من majors.json
   ├── التصفية بالفرع ← يتبقى 60 تخصص
   ├── التصفية باللغة ← يتبقى 45 تخصص
   ├── التصفية بالموقع ← يتبقى 30 تخصص
   └── التسجيل بهولاند ← قائمة مرتبة

3. التسجيل (src/rag/retriever.py)
   ├── التخصص: "هندسة الحاسوب" (primary=R, secondary=I, tertiary=C)
   ├── الطالب [R, I, A]: R(50×1.0) + I(30×0.8) = 74 خاماً
   ├── النسبة: (74 ÷ 86) × 100 = 86.0%
   └── النتيجة: {major: ..., holland_score: 86.0, match_reasons: [...]}

4. التوسّع — فقط إذا لم يتجاوز 10 تخصصات العتبة (retriever.py)
   ├── إعادة احتساب المرشّحين بكل ترتيبات رموز الطالب
   └── الاحتفاظ بأفضل نتيجة لكل تخصص

5. العرض (app.py)
   ├── الإبقاء على كل نتيجة ≥ 50% حتى MAX_RESULTS = 100
   ├── عرض PAGE_SIZE = 10 في كل مرة كبطاقات مع شارة النسبة
   ├── زر "عرض 10 تخصصات إضافية" يكشف الصفحة التالية — البحث غير مقصوص
   └── أزرار: بحث جديد، مقارنة، توصية

6. المحادثة الحرة — استرجاع ثم صياغة (app.py)
   ├── تحديد التخصصات المقصودة وسبب استبعاد ما سأل عنه الطالب
   ├── ← النموذج (local_llm.py أو cloud_llm.py) يصوغ الجواب من هذه الوقائع
   └── _sanitize_reply() ← يرجع إلى _answer_without_llm() عند الفشل

شرح كل مكون

⚠️ كانت النسخ السابقة من هذا المستند تشرح هنا أربع وحدات في src/ui/components/ — holland_selector.py وlocation_selector.py و results_display.py وchat_interface.py. هذا المجلد حُذف: لم يعد له أي مستورد بعد أن صار app.py يرسم كل عناصر الواجهة بنفسه. الوحدات موجودة في تاريخ git عند الحاجة.

1. التطبيق (app.py)

app.py هو الواجهة كلها. يدير المحادثة عبر st.session_state:

def init_state():
    defaults = {
        'messages': [],           # سجل المحادثة
        'step': 'welcome',        # الخطوة الحالية
        'holland_codes': [],      # رموز RIASEC المختارة (حتى 3)
        'track': None,            # الفرع الثانوي
        'languages': [],          # لغات التدريس المفضّلة
        'governorate': None,      # اختيار مفرد (متوافق مع القديم)
        'district': None,         # اختيار مفرد (متوافق مع القديم)
        'governorates': [],       # اختيار متعدّد
        'districts': [],          # اختيار متعدّد
        'results': [],            # كل النتائج (حتى MAX_RESULTS)
        'displayed_results': [],  # المعروض فعلياً على الشاشة
        'result_limit': PAGE_SIZE,
        'profile': None,
    }

وجود displayed_results ضروري كي تُحَلّ الإشارة برقم في المحادثة (“أخبرني أكثر عن رقم 3”) إلى البطاقة نفسها التي ينظر إليها الطالب، لا إلى العنصر الثالث من القائمة غير المُصفَّحة.

مسار الخطوات: welcome ← holland_ask ← holland_select ← track ← language ← location ← district (اختيارية) ← results.

رسالة الترحيب تُزرع داخل init_state() لا أثناء الرسم. يرسم Streamlit قائمة الرسائل مرة واحدة في كل تشغيل، فأي شيء يُضاف بعد تلك اللحظة يبقى غير مرئي حتى التشغيل التالي — وإضافة الترحيب داخل فرع welcome كانت تُفوّت أول رسم بالكامل.

2. ملف الطالب (src/rag/retriever.py)

@dataclass
class StudentProfile:
    holland_codes: List[str]                   # حتى 3 رموز RIASEC مرتّبة
    secondary_track: str                       # مثل "علوم عامة" أو "الكل"
    languages: List[str]                       # مثل ["الانكليزية", "الفرنسية"]
    governorate: str                           # اختيار مفرد، متوافق مع القديم
    district: Optional[str] = None             # اختيار مفرد، متوافق مع القديم
    governorates: Optional[List[str]] = None   # اختيار متعدّد
    districts: Optional[List[str]] = None      # اختيار متعدّد

3. محمّل البيانات (src/data/loader.py)

يوفّر وصولاً مُخزَّناً مؤقتاً: load_majors() وload_locations() و load_holland_mapping() كلها بـ @lru_cache(maxsize=1)، إضافة إلى دوال بحث مثل get_major_by_id وget_majors_by_faculty وsearch_majors.

تنبيه على شكل البيانات: holland_interpretation مفتاح في المستوى الأعلى، وليس متداخلاً تحت holland_codes. قراءته من المسار المتداخل تُخفيه بصمت في 93 تخصصاً من أصل 95.

4. البحث الدلالي (src/rag/embeddings.py وvectorstore.py) — غير مُفعَّل

هاتان الوحدتان تصفان القدرة التي تطرحها الأطروحة، لكن لا شيء في التطبيق العامل يستدعيهما: المستدعي الوحيد هو KalimRetriever.retrieve_by_query وهو نفسه بلا مستدعٍ، ولم يُبنَ أي فهرس (data/vectorstore/ فارغ)، فلن تُعيد شيئاً لو استُدعيت. وهما تُستوردان بشكل كسول داخل KalimRetriever.vectorstore: على مستوى الوحدة كانتا تجرّان torch وتكلّفان نحو 5 ثوانٍ عند كل إقلاع للتطبيق. كما أن chromadb وsentence-transformers معطّلتان في requirements.txt وغائبتان عن requirements-deploy.txt.

5. إعداد النموذج اللغوي (local_llm.py وcloud_llm.py)

الربط مع الأطروحة: التفسيرات المدعومة بالذكاء الاصطناعي تعزز الفهم — لكنها تُصاغ فقط، ولا تُرتِّب النتائج.

يُختار المزوّد تلقائياً حسب أول مفتاح متوفّر: GROQ_API_KEY ← OPENAI_API_KEY ← ANTHROPIC_API_KEY ← Ollama محلياً. وإذا لم يتوفّر أي منها بقي التطبيق عاملاً وأجاب من البيانات مباشرةً.

أسماء النماذج كلها في config/settings.py وكلها قابلة للتغيير عبر متغيرات البيئة:

المزوّد النموذج الافتراضي متغيّر البيئة
Groq qwen/qwen3.6-27b GROQ_MODEL
Together Qwen/Qwen3-235B-A22B-Instruct-2507-tput TOGETHER_MODEL
OpenAI gpt-4o-mini OPENAI_MODEL
Anthropic claude-opus-5 ANTHROPIC_MODEL
Ollama (محلي) qwen3 OLLAMA_MODEL

لماذا Qwen على Groq؟ العربية هي العامل الحاسم. أي نموذج لا يدعم العربية رسمياً ليس مرشّحاً هنا مهما كانت نتائجه في غير ذلك، بينما يغطي Qwen 119 لغة ويتصدّر النماذج مفتوحة الأوزان في مقياس HELM Arabic. كذلك لا يقدّم Groq إلا نماذج مفتوحة الأوزان، أي يمكن لاحقاً تشغيل الأوزان نفسها على خوادم الجامعة — وهو ما يهمّ في سياق أطروحة.

تتغيّر قائمة Groq بسرعة وقد يُسحب نموذج في أي وقت، لذا تحقّق من الاسم مقابل الحساب قبل الاعتماد عليه:

curl -H "Authorization: Bearer $GROQ_API_KEY" https://api.groq.com/openai/v1/models

أما محلياً، فتُرجِع الخاصية KalimLLM.effective_model أي نموذج Qwen مثبَّت فعلاً مع تسجيل تحذير، بدل أن يفشل كل طلب حين لا يكون الاسم المُعَدّ مثبتاً.


الاختبارات

python3 -m pytest tests/ -q          # 35 اختباراً

يضيف conftest.py في جذر المشروع كلاً من الجذر وsrc/ إلى sys.path، وهو ما تعتمد عليه الوحدات لأنها تستورد بعضها بالاسم المجرّد.

الملف ما يغطّيه
tests/test_retriever.py شكل البيانات وصحّة ثلاثيات هولاند؛ التسجيل (تطابق تام، جزئي، معدوم، إعادة ترتيب، رموز مكرّرة، رموز تخصص فارغة، عدم التماثل، سقف الـ100%)؛ كل فلتر ومرور الكل؛ اتحاد المحافظات المتعددة؛ الترتيب وtop_k ومسار التوسّع بالتباديل
tests/test_reply_sanitizer.py حذف سلسلة التفكير (وسوم <think> المغلقة وغير المغلقة واليتيمة، والمقدّمات الإنكليزية) ورفض اللغة الخاطئة (CJK، لاتيني غالب، فارغ)، إضافة إلى الحالات التي يجب أن تمرّ بلا تعديل

وإلى جانب هذه الاختبارات، النتائج المتوقّعة التي تحقّق منها العميل يدوياً موجودة في Test_Results.docx في جذر المشروع. بعد أي تعديل على التسجيل أو بيانات هولاند، شغّل طالباً برموز R-I-A (الفرع الكل، بلا تصفية لغة أو موقع) وقارن أفضل 10 نتائج بذلك المستند.


تشغيل التطبيق

التطبيق منشور فعلياً على https://kalim-chatbot-thesis.streamlit.app/ عبر Streamlit Community Cloud، من فرع main في github.com/YasserHaidar/Kalim-Thesis — وكل دفعة إلى main تعيد النشر. يُضبط GROQ_API_KEY (وGROQ_MODEL اختيارياً) هناك في Settings → Secrets، لا في المستودع.

بلا نموذج لغوي إطلاقاً — التوصيات والتصفية والتسجيل كلها تعمل:

pip install -r requirements.txt
streamlit run app.py

محلياً مع Ollama (للتطوير):

pip install -r requirements.txt
ollama serve
ollama pull qwen3                    # أو اضبط OLLAMA_MODEL على نموذج لديك
streamlit run app.py

سحابياً — وهو ما يعمل في الإنتاج:

pip install -r requirements-deploy.txt
export GROQ_API_KEY=your-key         # أول مفتاح متوفّر يفوز
streamlit run app.py

يُقرأ ملف .env المحلي تلقائياً إذا كان python-dotenv مثبّتاً، و.env.example يسرد كل المتغيرات المدعومة. وتقرأ الدالة _get_secret() في app.py من os.getenv أولاً ثم من st.secrets، لأن ملف .streamlit/secrets.toml المحلي لا يمكن الوصول إليه إلا عبر st.secrets.

عند تنقيح التطبيق المستضاف: تعرضه المنصة داخل إطار (iframe)، فأي أداة متصفّح موجَّهة إلى الرابط العام ترى الغلاف فقط — حمّل …streamlit.app/~/+/ للوصول إلى مستند التطبيق نفسه. كما تشغّل المنصة إصداراً من Streamlit أحدث من البيئة المحلية، فقد تتوفّر واجهات برمجية هناك وتغيب هنا (st.iframe مثال حالي).


ملخص: ربط الأطروحة ↔︎ الكود

مكون الأطروحة تنفيذ الكود
نظرية هولاند RIASEC app.py (أزرار الاختيار)، retriever.py (التسجيل)
قاعدة بيانات التخصصات majors.json (95 تخصصاً، مولّدة من Excel)
قيود الموقع app.py (الاختيار)، retriever.py (filter_by_location)
أهلية الفرع filter_by_track() في retriever.py
مطابقة الشخصية calculate_holland_score() في retriever.py
التوصيات retriever.py يُنتجها؛ local_llm.py/cloud_llm.py يصوغان أجوبة المحادثة فقط
واجهة عربية styles.py (CSS RTL، رموز تصميم، استجابة للجوال)
التوجيه التفاعلي handle_free_chat() في app.py (استرجاع ثم صياغة)
البحث الدلالي vectorstore.py + embeddings.py — مُنفَّذان لكن غير موصولين بالتطبيق العامل

التوثيق مُحدَّث في آب 2026 لروبوت كليم للتوجيه المهني - مشروع أطروحة ماجستير، الجامعة اللبنانية