"""
Board AI Engine - STREAMING VERSION.
Updated: per-user locks (gevent), zero global blocking.
"""
import re
import json
import time
import gevent
from gevent.lock import RLock
from config import GPT_URL, MAX_CHAT_HISTORY, GPT_TIMEOUT
from http_pool import gpt_session
from board.tts import TTSEngine
from board.icons import IconResolver
from board.pages import resolve_page_tags
from subjects.loader import subject_loader
from json_processor import BoardProcessor
class BoardEngine:
"""
Board AI Engine with streaming segment delivery.
Per-user locks: User A never blocks User B.
"""
def __init__(self):
self.gpt_url = GPT_URL
self.board_processor = BoardProcessor()
self.tts_engine = TTSEngine()
self.icon_resolver = IconResolver()
# Per-user state with per-user locks
self._user_sessions = {}
self._user_locks = {}
self._master_lock = RLock() # Only for creating new user entries
# Cleanup inactive user locks every 30 minutes
gevent.spawn(self._periodic_lock_cleanup)
print("✅ BoardEngine initialized (per-user locks + gevent)")
# ─── Per-user lock management ───
def _get_user_lock(self, username):
"""Get or create a lock for a specific user. Only the master lock is held briefly."""
with self._master_lock:
if username not in self._user_locks:
self._user_locks[username] = {
'lock': RLock(),
'last_access': time.time()
}
self._user_locks[username]['last_access'] = time.time()
return self._user_locks[username]['lock']
def _periodic_lock_cleanup(self):
"""Remove locks for users inactive for 30+ minutes."""
while True:
gevent.sleep(1800) # 30 minutes
try:
now = time.time()
cutoff = now - 1800
with self._master_lock:
stale = [u for u, info in self._user_locks.items()
if info['last_access'] < cutoff and u not in self._user_sessions]
for u in stale:
del self._user_locks[u]
if stale:
print(f" 🗑️ Cleaned {len(stale)} stale user locks")
except Exception:
pass
# ─── User session management (per-user locked) ───
def _get_user_session(self, username):
lock = self._get_user_lock(username)
with lock:
if username not in self._user_sessions:
self._user_sessions[username] = {
"subject_id": None,
"conversation_history": [],
"last_sequence": [],
}
return self._user_sessions[username]
def set_subject(self, username, subject_id):
lock = self._get_user_lock(username)
with lock:
if username not in self._user_sessions:
self._user_sessions[username] = {
"subject_id": None,
"conversation_history": [],
"last_sequence": [],
}
us = self._user_sessions[username]
if us["subject_id"] and us["subject_id"] != subject_id:
us["conversation_history"] = []
us["last_sequence"] = []
print(f" 🔄 Board subject switched: {us['subject_id']} → {subject_id} for {username}")
us["subject_id"] = subject_id
print(f" 📋 Board subject set: {subject_id} for {username}")
def get_subject(self, username):
lock = self._get_user_lock(username)
with lock:
us = self._user_sessions.get(username, {})
return us.get("subject_id")
# ─── GPT call (uses connection pool) ───
def _call_gpt5(self, user_message, system_prompt, temperature=0.7, max_tokens=4000):
payload = {
"user_input": user_message,
"chat_history": [
{"role": "system", "content": system_prompt}
],
"temperature": temperature,
"top_p": 0.95,
"max_completion_tokens": max_tokens
}
try:
response = gpt_session.post(self.gpt_url, json=payload, timeout=GPT_TIMEOUT)
response.raise_for_status()
return response.json().get("assistant_response", "")
except Exception as e:
print(f" ❌ GPT error: {e}")
return None
# ─── Chat history formatting ───
def _format_chat_history(self, username):
us = self._get_user_session(username)
history = us.get("conversation_history", [])
if not history:
return ""
recent = history[-10:]
parts = []
for msg in recent:
if msg["role"] == "user":
parts.append(f"الطالب: {msg['content']}")
elif msg["role"] == "assistant":
parts.append(f"المدرس: {msg['content']}")
return (
"\n\n═══ سجل المحادثة السابقة ═══\n"
+ "\n".join(parts)
+ "\n═══ نهاية السجل ═══"
)
# ─── Step 1: Route message ───
def _route_message(self, user_message, username):
us = self._get_user_session(username)
subject_id = us["subject_id"]
if not subject_id:
return "main.txt"
subject_data = subject_loader.load(subject_id)
if not subject_data:
return "main.txt"
structure = subject_data.get("structure.txt", "")
p_files = subject_data.get("_p_files", [])
chat_history_text = self._format_chat_history(username)
p_files_desc = "\n".join([f"- {f}: الفصل {i+1}" for i, f in enumerate(p_files)])
routing_prompt = f"""أنت نظام توجيه ذكي لمساعد تعليمي.
مهمتك: تحليل رسالة الطالب واختيار الملف المناسب للرد.
الملفات المتاحة:
- main.txt: للتحيات، الأسئلة العامة، أي شيء لا يتعلق بفصل محدد
{p_files_desc}
فهرس الكتاب (للمساعدة في التوجيه):
{structure}
{chat_history_text}
تعليمات:
1. إذا كانت الرسالة تحية أو سؤال عام → main.txt
2. إذا كانت تسأل عن موضوع في فصل محدد → الملف المناسب
3. استخدم الفهرس لتحديد الفصل الصحيح
4. مهم جداً: إذا قال الطالب "اشرح أكثر" أو "وضح" أو أي طلب متابعة، ارجع لسجل المحادثة لتعرف الموضوع الحالي واختر نفس الملف
5. أجب فقط باسم الملف (مثال: p1.txt أو main.txt) بدون أي كلام إضافي
رسالة الطالب: {user_message}
الملف المناسب:"""
chosen = self._call_gpt5(
user_message, routing_prompt,
temperature=0.2, max_tokens=50
)
if not chosen:
return "main.txt"
chosen = chosen.strip().lower()
valid_files = ["main.txt"] + p_files
for v in valid_files:
if v in chosen:
return v
return "main.txt"
# ─── Step 2: Generate XML response ───
def _generate_xml_response(self, user_message, chosen_file, username):
us = self._get_user_session(username)
subject_id = us["subject_id"]
if not subject_id:
return None
subject_data = subject_loader.load(subject_id)
if not subject_data:
return None
file_content = subject_data.get(chosen_file, "")
chat_history_text = self._format_chat_history(username)
system_prompt = f"""انتي مدرسة خبيرة ومحترفة في هذه المادة. تشرح على سبورة تفاعلية رقمية.
═══ صيغة الرد ═══
يجب أن تردي بصيغة XML خاصة تحتوي على:
1. عناصر السبورة - ما سيُرسم/يُضاف على السبورة أولاً
2. نص الكلام - النص الذي سيُقرأ بصوت عالٍ للطالب بعد رسم العناصر (عربي طبيعي)
═══ عناصر السبورة المتاحة (داخل ) ═══
• محتوى الملاحظة
• نص مباشر على السبورة بدون خلفية
•
الأنواع: circle, triangle, star, arrow-right, arrow-left, arrow-up, arrow-down,
rectangle, diamond, hexagon, square, oval, arrow-double-h, checkmark, cross,
heart, cloud, lightning, speech, process, decision
•
سيتم البحث عن أيقونة مرسومة يدوياً (مثل: ball, car, force, spring, weight, rope, pulley)
• رقم_الصفحة
لعرض صفحة محددة من الكتاب كصورة على السبورة
مثال: 12 لعرض الصفحة 12 من الكتاب
استخدمها عندما تحتاج تعرض للطالب صفحة معينة من الكتاب
═══ قواعد مهمة جداً ═══
1. اشرح خطوة بخطوة: ابدأ بـ ثم ثم ثم وهكذا
2. اجعل الشرح متدرجاً كأنك تشرح على سبورة حقيقية أمام الطلاب
3. = ما يظهر على السبورة أولاً (ملاحظات، نصوص، أشكال، صور، صفحات الكتاب)
4. = الكلام المسموع بعد رسم العناصر (طبيعي، ودود، واضح، يشرح ما تم رسمه)
5. لا تضع كل شيء دفعة واحدة - اجعله تسلسلياً
7.