| import requests
|
| import json
|
| import os
|
| from typing import List, Dict, Any, Optional
|
|
|
|
|
| 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)
|
| """
|
|
|
| openrouter_key = api_key or os.getenv('OPENROUTER_API_KEY', '')
|
|
|
|
|
| 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"
|
| )
|
|
|
|
|
| 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."
|
| )
|
|
|
|
|
| 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]
|
| })
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
| remainder = total_students % 5
|
| if remainder == 1:
|
|
|
| size_guidance = "groups of 5 students, with some groups of 3-4 if needed to avoid groups smaller than 3"
|
| elif remainder == 2:
|
|
|
| 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"
|
|
|
|
|
| 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 الهامبخش است.'"
|
| }}
|
| ]
|
| }}"""
|
|
|
|
|
| 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',
|
| '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
|
| }
|
|
|
| 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)}")
|
|
|
|
|
| try:
|
|
|
| grouping_result = json.loads(content)
|
| except json.JSONDecodeError as e:
|
|
|
| import re
|
|
|
|
|
| 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:
|
|
|
| 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")
|
|
|
|
|
| assigned_students = set()
|
| duplicate_students = set()
|
|
|
|
|
| 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)
|
|
|
|
|
| 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:
|
|
|
| first_occurrence[student_id] = i
|
| cleaned_students.append(student_id)
|
| else:
|
|
|
| print(f' Removing {student_id} from group {group["groupNumber"]}')
|
| else:
|
| cleaned_students.append(student_id)
|
| group['students'] = cleaned_students
|
|
|
|
|
| assigned_students = set()
|
| for group in grouping_result['groups']:
|
| if 'students' in group:
|
| assigned_students.update(group['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_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}
|
|
|