File size: 19,819 Bytes
2fe573b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a1cd96
2fe573b
2a1cd96
 
2fe573b
 
 
 
 
2a1cd96
 
2fe573b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a1cd96
 
2fe573b
 
 
 
 
 
 
 
2a1cd96
2fe573b
 
 
 
 
 
2a1cd96
2fe573b
 
 
 
 
2a1cd96
 
2fe573b
 
 
 
 
2a1cd96
 
2fe573b
 
 
 
 
 
 
 
 
 
 
 
 
421b5c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2fe573b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a1cd96
2fe573b
 
 
 
 
 
 
 
421b5c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2fe573b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421b5c9
2fe573b
421b5c9
 
 
2fe573b
 
421b5c9
 
 
 
 
 
2fe573b
421b5c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2fe573b
 
 
 
421b5c9
2fe573b
 
 
 
 
 
 
 
 
 
421b5c9
 
 
 
 
 
 
 
 
 
 
2fe573b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
import requests
import json
import os
from typing import List, Dict, Any, Optional

# API Configuration
OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'

def analyze_mbti(mbti: str) -> Dict[str, str]:
    """Helper to break down MBTI into explicit tags"""
    if not mbti or len(mbti) < 4:
        return {"type": "Unknown", "tags": []}
    
    m = mbti.upper()
    return {
        "energy": 'Introvert (درون‌گرا)' if m[0] == 'I' else 'Extrovert (برون‌گرا)',
        "info": 'Intuitive' if m[1] == 'N' else 'Sensing',
        "decision": 'Thinking' if m[2] == 'T' else 'Feeling',
        "structure": 'Judging' if m[3] == 'J' else 'Perceiving'
    }

def group_students_with_ai(students: List[Any], course_name: str, api_key: Optional[str] = None) -> Dict[str, Any]:
    """

    Group students using OpenRouter API (ChatGPT) with advanced educational psychology principles

    Args:

        students: List of student objects

        course_name: Name of the course

        api_key: OpenRouter API key (optional, falls back to env var)

    """
    # Get API key from parameter or environment variable
    openrouter_key = api_key or os.getenv('OPENROUTER_API_KEY', '')
    
    # Clean the API key - remove any whitespace and newlines
    if openrouter_key:
        openrouter_key = openrouter_key.strip().replace('\n', '').replace('\r', '')
    
    if not openrouter_key or openrouter_key == '':
        raise Exception(
            "OpenRouter API key not configured! "
            "Please add OPENROUTER_API_KEY in Railway Variables tab. "
            "Get your free key at: https://openrouter.ai/keys"
        )
    
    # Validate API key format (but don't log the key)
    if not openrouter_key.startswith('sk-or-v1-'):
        raise Exception(
            f"Invalid API key format. OpenRouter keys should start with 'sk-or-v1-'. "
            f"Please check your OPENROUTER_API_KEY in Railway Variables."
        )
    
    # Sanitization & Data Enrichment
    valid_student_ids = set(s.studentNumber for s in students)
    
    student_data = []
    for s in students:
        mbti_details = analyze_mbti(s.mbti)
        student_data.append({
            "id": s.studentNumber,
            "name": s.name,
            "mbti": s.mbti,
            "mbti_analysis": mbti_details,
            "learningStyle": s.learningStyle,
            "ams": s.ams if hasattr(s, 'ams') else None,
            "cooperative": s.cooperative if hasattr(s, 'cooperative') else None,
            "grade": s.grade,
            "preferredStudents": [id for id in (s.preferredStudents or []) if id in valid_student_ids]
        })
    
    # Dynamic Group Size Logic
    total_students = len(students)
    if total_students < 4:
        size_guidance = "a single group"
    elif total_students < 8:
        size_guidance = "groups of 3-4 students"
    else:
        # Prefer groups of 5, but use groups of 4 if needed to avoid very small groups
        # Examples: 30 students = 6 groups of 5
        #          27 students = 5 groups of 5 + 1 group of 2 (bad) → instead: 3 groups of 5 + 3 groups of 4 (good)
        #          25 students = 5 groups of 5
        #          22 students = 4 groups of 5 + 1 group of 2 (bad) → instead: 2 groups of 5 + 3 groups of 4 (good)
        remainder = total_students % 5
        if remainder == 1:
            # e.g., 21 students: would be 4 groups of 5 + 1 of 1 → instead make 3 groups of 5 + 2 groups of 3
            size_guidance = "groups of 5 students, with some groups of 3-4 if needed to avoid groups smaller than 3"
        elif remainder == 2:
            # e.g., 22 students: would be 4 groups of 5 + 1 of 2 → instead make 2 groups of 5 + 3 groups of 4
            size_guidance = "groups of 5 students, with some groups of 4 if needed to avoid groups of 2"
        else:
            size_guidance = "groups of 5 students"
    
    # The Enhanced Prompt
    prompt = f"""You are an expert educational psychologist specializing in adolescent team formation and Vygotsky's Zone of Proximal Development (ZPD). Create optimal learning groups for "{course_name}" course with 15-16 year old students.



INPUT DATA:

{json.dumps(student_data, ensure_ascii=False, indent=2)}



TOTAL STUDENTS: {total_students}

GROUPING STRATEGY: Prefer {size_guidance}. IMPORTANT: Avoid creating groups with only 1-2 students. If the math doesn't work out evenly with groups of 5, adjust by creating some groups of 4 to balance the numbers. For example:

- 30 students = 6 groups of 5 ✓

- 27 students = 3 groups of 5 + 3 groups of 4 ✓ (NOT 5 groups of 5 + 1 group of 2 ✗)

- 25 students = 5 groups of 5 ✓

- 22 students = 2 groups of 5 + 3 groups of 4 ✓ (NOT 4 groups of 5 + 1 group of 2 ✗)



STUDENT AGE CONTEXT (15-16 years - Adolescence):

- High need for peer acceptance and social belonging

- Developing abstract thinking and metacognition

- Identity formation through social interactions

- Sensitivity to feedback from peers

- Collaborative learning enhances engagement



GROUPING FRAMEWORK - HIERARCHY OF IMPORTANCE:



1. **PRIMARY DRIVER: ZPD OPTIMIZATION (Zone of Proximal Development)**

   *This is the most critical psychological factor.*

   - Mix academic performance (grade field) to create ZPD scaffolding

   - Place high performers (معدل بالا) with medium performers for peer tutoring

   - Avoid grouping all high or all low performers together

   - Target: Each group should have grade variance of 1-2 points to maximize learning



2. **SECONDARY DRIVER: MBTI COMPLEMENTARITY (NOT Similarity)**

   *Use this to refine the groups created by ZPD.*

   Research-based MBTI pairings for adolescent teamwork:

   - ENFP + INTJ: Visionary creativity with strategic planning

   - ENTP + INFJ: Innovation meets deep insight and empathy

   - ENTJ + INFP: Leadership with values-driven creativity

   - ESTJ + ISFP: Organization with practical creativity

   - ESFJ + INTP: Social cohesion with analytical thinking

   - ESTP + ISFJ: Action-oriented with detail consciousness

   - ENFJ + ISTP: Motivational leadership with technical problem-solving

   - ESFP + ISTJ: Enthusiasm with reliability and structure

   

   KEY PRINCIPLES:

   - Balance E (Extrovert) and I (Introvert): 2-3 of each per group

   - Complement T (Thinking) with F (Feeling) for balanced decision-making

   - Mix N (Intuitive) with S (Sensing) for big-picture + detail focus

   - Combine J (Judging) with P (Perceiving) for structure + flexibility



3. **TERTIARY DRIVER: VARK DIVERSITY (Learning Styles)**

   *Ensure the group has "Full Coverage" of capabilities.*

   - Include different learning styles in each group:

     * Visual (دیداری): Diagrams, charts, spatial understanding

     * Aural (شنیداری): Discussions, verbal explanations

     * Read/Write: Text-based learning, note-taking

     * Kinesthetic (حرکتی): Hands-on, experiential learning

   - Diversity ensures multiple teaching approaches within group

   - Adolescents learn best when exposed to varied learning methods



4. **BALANCING FACTOR: ACADEMIC MOTIVATION (AMS Score)**

   - AMS field: Academic Motivation Scale (0-196)

   - Balance high and moderate motivation levels

   - High motivation students (>140) can inspire others

   - Avoid grouping all low-motivation (<100) students together

   - Target: Each group has at least one high-motivation member



5. **SOCIAL GLUE: COOPERATIVE LEARNING SKILLS**

   - Cooperative field: Cooperation ability (0-125)

   - High cooperation students (>88) act as social facilitators

   - Mix cooperation levels for peer modeling

   - Students with strong cooperation skills help integrate introverts



6. **CONTEXTUAL ADAPTATION: COURSE-SPECIFIC REQUIREMENTS**

   Based on "{course_name}":

   - Math/Science: Prioritize T (Thinking) types, Visual/Kinesthetic learners

   - Literature/Humanities: Include F (Feeling) types, Read/Write learners

   - Projects/Labs: Need high Kinesthetic and ESTP/ISTP types

   - Discussion-based: Ensure Aural learners and E (Extrovert) types



7. **TIE-BREAKER: STUDENT PREFERENCES**

   *Only use this if it does not violate the drivers above.*

   - Honor "preferredStudents" field ONLY if it doesn't compromise above criteria

   - Adolescents benefit from working outside comfort zones

   - Strategic separation can reduce cliques and expand social circles



CRITICAL RULES:

✓ ALL students MUST be assigned to a group

✓ PREFER groups of 5 students to minimize total number of groups

✓ Adjust group sizes (use groups of 4) to avoid creating groups with only 1-2 students

✓ Each group should have 3-5 students (never 1-2 students alone)

✓ Each group needs MBTI balance: 2-3 Introverts + 2-3 Extroverts

✓ Each group needs grade diversity: Mix high (>18) with medium (16-18) performers

✓ Prioritize complementary MBTI types over similar types

✓ Use provided data fields - DO NOT invent values



🚨 MANDATORY DUPLICATE PREVENTION (HIGHEST PRIORITY) 🚨

This is a HARD CONSTRAINT, not a guideline:

✓ Each student ID (S001, S002, etc.) can appear in EXACTLY ONE group

✓ NO student can be in multiple groups - this would be a CRITICAL ERROR

✓ Before outputting, verify EVERY student ID appears exactly once

✓ If you find a duplicate, STOP and fix it immediately

✓ Total students in all groups MUST equal {total_students}



VALIDATION CHECKLIST (complete this mentally before responding):

□ Step 1: List all student IDs used across all groups

□ Step 2: Check if any ID appears more than once → if YES, remove duplicates

□ Step 3: Count total students in groups → must equal {total_students}

□ Step 4: Check for missing students → add them to appropriate groups

□ Step 5: Verify no duplicates exist → if duplicates found, START OVER



OUTPUT FORMAT (Valid JSON Only):

{{

  "groups": [

    {{

      "groupNumber": 1,

      "students": ["S001", "S002", "S003", "S004"],

      "reasoning": "توضیحات کامل به فارسی - شامل: (1) تحلیل ZPD: معدل‌ها و چگونگی یادگیری همیاری (2) تکمیل MBTI: چرا این تیپ‌ها با هم سازگارند (3) تنوع VARK (4) سطح انگیزش و همکاری (5) مناسب بودن برای درس {course_name}. مثال: 'این گروه دارای ZPD مطلوب است: S001 (معدل 19.5) و S002 (معدل 17.2) به S003 (معدل 16) کمک می‌کنند. تکمیل MBTI: ENFP (S001) با خلاقیت و INTJ (S002) با برنامه‌ریزی استراتژیک همکاری می‌کنند. تنوع یادگیری: 2 Visual، 1 Aural، 1 Kinesthetic. انگیزش بالا (AMS>150) در S001 الهام‌بخش است.'"

    }}

  ]

}}"""

    # Make API call using requests library
    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {openrouter_key}',
        'HTTP-Referer': 'https://talimbot-talimbot.hf.space',
        'X-Title': 'TalimBot'
    }
    
    payload = {
        'model': 'openai/gpt-4o',  # Using GPT-4o for better accuracy and reasoning
        'messages': [
            {
                'role': 'system',
                'content': '''You are a precise algorithmic grouping assistant. You MUST output ONLY valid JSON - no markdown, no code blocks, no extra text. Start directly with { and end with }.



🚨 CRITICAL DUPLICATE PREVENTION RULE 🚨

This is the MOST IMPORTANT rule - violating this makes your output INVALID:

• Each student ID (e.g., S001, S002) can appear in EXACTLY ONE group

• NO DUPLICATES ALLOWED - putting a student in multiple groups is a CRITICAL ERROR

• Before you output, you MUST verify: count how many times each student ID appears across ALL groups

• If ANY student ID appears more than once, your output is REJECTED

• If the total count of students in all groups ≠ total input students, your output is REJECTED



VALIDATION STEPS (do this before outputting):

1. Make a list of ALL student IDs from all groups you created

2. Check if any ID appears 2 or more times → if YES, remove duplicates

3. Count total students: sum of all group sizes must equal the TOTAL STUDENTS number

4. Verify each input student ID appears exactly once



You rely on the explicit "mbti_analysis" fields provided in the user prompt for your reasoning.'''
            },
            {
                'role': 'user',
                'content': prompt
            }
        ],
        'temperature': 0.2  # Lower temperature for more consistent, logical grouping
    }
    
    print(f"Sending request to OpenRouter API...")
    
    response = requests.post(
        OPENROUTER_API_URL,
        headers=headers,
        json=payload,
        timeout=60
    )
    
    print(f"Response status: {response.status_code}")
    print(f"Response preview: {response.text[:200]}")
    
    if response.status_code == 401:
        try:
            error_data = response.json()
            error_msg = error_data.get('error', {}).get('message', 'Unauthorized')
        except:
            error_msg = response.text
        
        raise Exception(
            f"OpenRouter Authentication Error: {error_msg}. "
            f"Your API key is configured but invalid. Please:\n"
            f"1. Go to https://openrouter.ai/keys\n"
            f"2. Check if your key is active and has credits\n"
            f"3. Create a NEW key if needed\n"
            f"4. Update OPENROUTER_API_KEY in Railway Variables"
        )
    
    if response.status_code == 402:
        raise Exception(
            "OpenRouter Payment Required: Your account has no credits. "
            "Add credits at https://openrouter.ai/credits"
        )
    
    if not response.ok:
        try:
            error_data = response.json()
            error_detail = error_data.get('error', {}).get('message', response.text)
        except:
            error_detail = response.text
        raise Exception(f"API request failed ({response.status_code}): {error_detail}")
    
    data = response.json()
    content = data['choices'][0]['message']['content']
    print(f"🔍 DEBUG: Got response content, length: {len(content)}")
    
    # Parse Result - Extract JSON from markdown code blocks if present
    try:
        # Try direct JSON parse first
        grouping_result = json.loads(content)
    except json.JSONDecodeError as e:
        # Try to extract JSON from markdown code blocks
        import re
        
        # Look for JSON in ```json ... ``` or ``` ... ``` blocks
        json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL)
        if json_match:
            try:
                grouping_result = json.loads(json_match.group(1))
                print(f"✅ Extracted JSON from markdown code block")
            except json.JSONDecodeError:
                print(f"Failed to parse JSON from code block: {json_match.group(1)[:200]}")
                raise Exception("Invalid JSON from API (even after markdown extraction)")
        else:
            # Try to find JSON object in the content
            json_match = re.search(r'\{.*"groups".*\}', content, re.DOTALL)
            if json_match:
                try:
                    grouping_result = json.loads(json_match.group(0))
                    print(f"✅ Extracted JSON object from response")
                except json.JSONDecodeError:
                    print(f"Failed to parse extracted JSON: {json_match.group(0)[:200]}")
                    raise Exception("Invalid JSON from API (extraction failed)")
            else:
                print(f"❌ No JSON found in response. Full content:\n{content}")
                raise Exception("Invalid JSON from API - no valid JSON structure found")
    
    # Failsafe: Detect and remove duplicates, then add missing students
    assigned_students = set()
    duplicate_students = set()
    
    # First pass: detect duplicates
    for group in grouping_result['groups']:
        if 'students' in group:
            for student_id in group['students']:
                if student_id in assigned_students:
                    duplicate_students.add(student_id)
                    print(f'⚠️ DUPLICATE DETECTED: {student_id} appears in multiple groups!')
                else:
                    assigned_students.add(student_id)
    
    # Second pass: remove duplicates (keep first occurrence only)
    if duplicate_students:
        print(f'🔧 Removing duplicates: {duplicate_students}')
        first_occurrence = {}
        for i, group in enumerate(grouping_result['groups']):
            if 'students' in group:
                cleaned_students = []
                for student_id in group['students']:
                    if student_id in duplicate_students:
                        if student_id not in first_occurrence:
                            # Keep first occurrence
                            first_occurrence[student_id] = i
                            cleaned_students.append(student_id)
                        else:
                            # Remove duplicate
                            print(f'  Removing {student_id} from group {group["groupNumber"]}')
                    else:
                        cleaned_students.append(student_id)
                group['students'] = cleaned_students
        
        # Rebuild assigned_students set after cleaning
        assigned_students = set()
        for group in grouping_result['groups']:
            if 'students' in group:
                assigned_students.update(group['students'])
    
    # Third pass: add missing students
    all_ids = [s.studentNumber for s in students]
    missing = [id for id in all_ids if id not in assigned_students]
    
    if missing:
        print(f'⚠️ AI missed students, adding to last group: {missing}')
        if grouping_result['groups']:
            grouping_result['groups'][-1]['students'].extend(missing)
            grouping_result['groups'][-1]['reasoning'] += f" (سیستم دانش‌آموزان {', '.join(missing)} را به این گروه اضافه کرد)"
        else:
            grouping_result['groups'].append({
                "groupNumber": 1,
                "students": missing,
                "reasoning": "گروه بازیابی شده توسط سیستم"
            })
    
    # Final verification
    final_assigned = set()
    for group in grouping_result['groups']:
        if 'students' in group:
            final_assigned.update(group['students'])
    
    if len(final_assigned) != len(students):
        print(f'❌ ERROR: Final count mismatch! Expected {len(students)}, got {len(final_assigned)}')
    else:
        print(f'✅ Verification passed: All {len(students)} students assigned exactly once')
    
    return grouping_result

async def random_grouping(students: List[Any]) -> Dict[str, Any]:
    """Fallback random grouping if API fails"""
    import random
    
    shuffled = students.copy()
    random.shuffle(shuffled)
    
    group_size = 5
    num_groups = (len(shuffled) + group_size - 1) // group_size
    
    groups = []
    for i in range(num_groups):
        group_students = shuffled[i * group_size:(i + 1) * group_size]
        groups.append({
            "groupNumber": i + 1,
            "students": [s.studentNumber for s in group_students],
            "reasoning": "گروه‌بندی تصادفی (API در دسترس نبود)"
        })
    
    return {"groups": groups}