|
|
from __future__ import annotations |
|
|
|
|
|
import json |
|
|
import random |
|
|
import threading |
|
|
import uuid |
|
|
from dataclasses import dataclass |
|
|
from datetime import datetime, timezone |
|
|
from pathlib import Path |
|
|
from typing import Any, Dict, List, Optional |
|
|
import contextvars |
|
|
|
|
|
DATA_DIR = Path(__file__).parent / "data" |
|
|
STUDENTS_PATH = DATA_DIR / "students.json" |
|
|
TOPICS_PATH = DATA_DIR / "topics.json" |
|
|
SESSIONS_PATH = DATA_DIR / "sessions.json" |
|
|
RESULTS_PATH = DATA_DIR / "results.json" |
|
|
|
|
|
_FILE_LOCK = threading.Lock() |
|
|
|
|
|
_current_session_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( |
|
|
"current_session_id", default=None |
|
|
) |
|
|
|
|
|
_LAST_SESSION_ID: Optional[str] = None |
|
|
|
|
|
|
|
|
class StudentNotFoundError(Exception): |
|
|
pass |
|
|
|
|
|
|
|
|
def _utc_now_iso() -> str: |
|
|
return datetime.now(timezone.utc).isoformat() |
|
|
|
|
|
|
|
|
def _read_json(path: Path, default: Any) -> Any: |
|
|
with _FILE_LOCK: |
|
|
if not path.exists(): |
|
|
path.parent.mkdir(parents=True, exist_ok=True) |
|
|
path.write_text(json.dumps(default, ensure_ascii=False, indent=2), encoding="utf-8") |
|
|
return default |
|
|
txt = path.read_text(encoding="utf-8").strip() |
|
|
if not txt: |
|
|
return default |
|
|
return json.loads(txt) |
|
|
|
|
|
|
|
|
def _write_json(path: Path, data: Any) -> None: |
|
|
with _FILE_LOCK: |
|
|
path.parent.mkdir(parents=True, exist_ok=True) |
|
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") |
|
|
|
|
|
|
|
|
def ensure_data_files() -> None: |
|
|
_read_json(STUDENTS_PATH, []) |
|
|
_read_json(TOPICS_PATH, []) |
|
|
_read_json(SESSIONS_PATH, {}) |
|
|
_read_json(RESULTS_PATH, []) |
|
|
|
|
|
|
|
|
@dataclass |
|
|
class Message: |
|
|
role: str |
|
|
content: str |
|
|
datetime: str |
|
|
|
|
|
def to_dict(self) -> Dict[str, str]: |
|
|
return {"role": self.role, "content": self.content, "datetime": self.datetime} |
|
|
|
|
|
|
|
|
def set_current_session(session_id: Optional[str]) -> None: |
|
|
_current_session_id.set(session_id) |
|
|
|
|
|
|
|
|
def get_last_session_id() -> Optional[str]: |
|
|
return _LAST_SESSION_ID |
|
|
|
|
|
|
|
|
def _load_students() -> List[Dict[str, str]]: |
|
|
return _read_json(STUDENTS_PATH, []) |
|
|
|
|
|
|
|
|
def _load_topics() -> List[str]: |
|
|
return _read_json(TOPICS_PATH, []) |
|
|
|
|
|
|
|
|
def _load_sessions() -> Dict[str, Any]: |
|
|
return _read_json(SESSIONS_PATH, {}) |
|
|
|
|
|
|
|
|
def _save_sessions(sessions: Dict[str, Any]) -> None: |
|
|
_write_json(SESSIONS_PATH, sessions) |
|
|
|
|
|
|
|
|
def _append_result(record: Dict[str, Any]) -> None: |
|
|
results = _read_json(RESULTS_PATH, []) |
|
|
if not isinstance(results, list): |
|
|
results = [] |
|
|
results.append(record) |
|
|
_write_json(RESULTS_PATH, results) |
|
|
|
|
|
|
|
|
def start_exam(email: str, name: str) -> List[str]: |
|
|
global _LAST_SESSION_ID |
|
|
|
|
|
ensure_data_files() |
|
|
|
|
|
students = _load_students() |
|
|
found = next((s for s in students if s.get("email", "").strip().lower() == email.strip().lower()), None) |
|
|
if not found: |
|
|
raise StudentNotFoundError(f"Student not found for email: {email}") |
|
|
|
|
|
topics = _load_topics() |
|
|
if len(topics) < 2: |
|
|
topics = ["General knowledge: topic A", "General knowledge: topic B", "General knowledge: topic C"] |
|
|
|
|
|
k = 2 if len(topics) < 3 else random.choice([2, 3]) |
|
|
selected = random.sample(topics, k=min(k, len(topics))) |
|
|
|
|
|
sessions = _load_sessions() |
|
|
session_id = str(uuid.uuid4()) |
|
|
sessions[session_id] = { |
|
|
"session_id": session_id, |
|
|
"email": email, |
|
|
"name": name, |
|
|
"topics_queue": selected, |
|
|
"topics_all": selected, |
|
|
"started_at": _utc_now_iso(), |
|
|
"finished_at": None, |
|
|
"status": "active", |
|
|
} |
|
|
_save_sessions(sessions) |
|
|
|
|
|
_LAST_SESSION_ID = session_id |
|
|
set_current_session(session_id) |
|
|
|
|
|
return selected |
|
|
|
|
|
|
|
|
def get_next_topic() -> str: |
|
|
""" |
|
|
IMPORTANT FIX: |
|
|
Gradio callbacks may run in a different context, so contextvar can be empty. |
|
|
We fallback to _LAST_SESSION_ID. |
|
|
""" |
|
|
ensure_data_files() |
|
|
|
|
|
session_id = _current_session_id.get() or _LAST_SESSION_ID |
|
|
if not session_id: |
|
|
return "" |
|
|
|
|
|
sessions = _load_sessions() |
|
|
sess = sessions.get(session_id) |
|
|
if not sess or sess.get("status") != "active": |
|
|
return "" |
|
|
|
|
|
queue = sess.get("topics_queue", []) |
|
|
if not queue: |
|
|
return "" |
|
|
|
|
|
next_topic = queue.pop(0) |
|
|
sess["topics_queue"] = queue |
|
|
sessions[session_id] = sess |
|
|
_save_sessions(sessions) |
|
|
return str(next_topic) |
|
|
|
|
|
|
|
|
def end_exam(email: str, score: float, history: List[Dict[str, str]]) -> None: |
|
|
ensure_data_files() |
|
|
|
|
|
sessions = _load_sessions() |
|
|
for sid, sess in list(sessions.items()): |
|
|
if sess.get("email", "").strip().lower() == email.strip().lower() and sess.get("status") == "active": |
|
|
sess["status"] = "finished" |
|
|
sess["finished_at"] = _utc_now_iso() |
|
|
sessions[sid] = sess |
|
|
_save_sessions(sessions) |
|
|
|
|
|
record = { |
|
|
"email": email, |
|
|
"score": float(score), |
|
|
"history": history, |
|
|
"saved_at": _utc_now_iso(), |
|
|
} |
|
|
_append_result(record) |