renatavl commited on
Commit
3256847
·
0 Parent(s):
Files changed (9) hide show
  1. .gitignore +32 -0
  2. README.md +43 -0
  3. agent.py +248 -0
  4. app.py +87 -0
  5. data/students.json +4 -0
  6. data/topics.json +12 -0
  7. llm.py +50 -0
  8. requirements.txt +2 -0
  9. tools.py +185 -0
.gitignore ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- Python ---
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # --- Virtual env ---
7
+ .venv/
8
+ venv/
9
+ ENV/
10
+ env/
11
+
12
+ # --- OS / IDE ---
13
+ .DS_Store
14
+ Thumbs.db
15
+ .idea/
16
+ .vscode/
17
+ *.iml
18
+
19
+ # --- Logs ---
20
+ *.log
21
+
22
+ # --- Gradio / cache ---
23
+ .gradio/
24
+ .cache/
25
+
26
+ # --- Local data (do NOT commit exam results / sessions) ---
27
+ data/sessions.json
28
+ data/results.json
29
+
30
+ # If you ever store secrets locally:
31
+ .env
32
+ .env.*
README.md ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AI Examiner Agent
3
+ emoji: 📝
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: "4.0.0"
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ # AI Examiner Agent
13
+
14
+ Міні-екзаменатор (LLM-агент з tools): питає ім’я та email, обирає 2–3 теми, веде короткий діалог по темах, виставляє оцінку (0..10) та зберігає історію іспиту у файли.
15
+
16
+ ## Функціонал
17
+
18
+ - Збір **name + email**
19
+ - Tool `start_exam(email, name) -> list[str]`:
20
+ - перевіряє, чи є студент у `data/students.json`
21
+ - обирає 2–3 теми з `data/topics.json`
22
+ - створює сесію у `data/sessions.json`
23
+ - Tool `get_next_topic() -> str`:
24
+ - повертає наступну тему з черги сесії
25
+ - Tool `end_exam(email, score, history)`:
26
+ - зберігає результат у `data/results.json`
27
+ - `history` зберігається у форматі `{role, content, datetime}`
28
+
29
+ ## Структура проєкту
30
+
31
+ ```text
32
+ .
33
+ ├── app.py
34
+ ├── agent.py
35
+ ├── llm.py
36
+ ├── tools.py
37
+ ├── requirements.txt
38
+ └── data
39
+ ├── students.json
40
+ ├── topics.json
41
+ ├── sessions.json
42
+ └── results.json
43
+ ```
agent.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from typing import Dict, List, Optional, Tuple
8
+
9
+ from llm import LLMError, chat_completion
10
+ from tools import (
11
+ Message,
12
+ StudentNotFoundError,
13
+ end_exam,
14
+ get_last_session_id,
15
+ get_next_topic,
16
+ set_current_session,
17
+ start_exam,
18
+ )
19
+
20
+ EMAIL_RE = re.compile(r"([a-zA-Z0-9_.+\-]+@[a-zA-Z0-9\-]+\.[a-zA-Z0-9\-.]+)")
21
+
22
+
23
+ def _utc_now_iso() -> str:
24
+ return datetime.now(timezone.utc).isoformat()
25
+
26
+
27
+ def _looks_like_idk(text: str) -> bool:
28
+ t = text.strip().lower()
29
+ triggers = ["не знаю", "не пам'ятаю", "не памятаю", "не можу", "no idea", "idk", "не впевнений", "не впевнена"]
30
+ return any(x in t for x in triggers)
31
+
32
+
33
+ @dataclass
34
+ class ExamAgent:
35
+ stage: str = "collect_identity"
36
+ name: Optional[str] = None
37
+ email: Optional[str] = None
38
+
39
+ session_id: Optional[str] = None
40
+ topics_total: int = 0
41
+ current_topic: Optional[str] = None
42
+
43
+ max_questions_per_topic: int = 3
44
+ questions_in_topic: int = 0
45
+
46
+ topic_scores: Dict[str, float] = field(default_factory=dict)
47
+ history: List[Message] = field(default_factory=list)
48
+
49
+ def _log(self, role: str, content: str) -> None:
50
+ self.history.append(Message(role=role, content=content, datetime=_utc_now_iso()))
51
+
52
+ def initial_message(self) -> str:
53
+ msg = "Привіт! Я екзаменатор. Як тебе звати?"
54
+ self._log("system", msg)
55
+ return msg
56
+
57
+ def _bind_session(self) -> None:
58
+ if self.session_id:
59
+ set_current_session(self.session_id)
60
+
61
+ def _start_exam_tools(self) -> Tuple[bool, str]:
62
+ self._log("tool_call", f"start_exam(email={self.email}, name={self.name})")
63
+ try:
64
+ topics = start_exam(self.email or "", self.name or "")
65
+ except StudentNotFoundError:
66
+ self.session_id = None
67
+ self.topics_total = 0
68
+ self.current_topic = None
69
+ self.stage = "collect_identity"
70
+ msg = (
71
+ "Я не знайшов(ла) студента з таким email у списку. "
72
+ "Перевір, будь ласка, email і надішли його ще раз."
73
+ )
74
+ self._log("system", msg)
75
+ return False, msg
76
+
77
+ self.session_id = get_last_session_id()
78
+ self.topics_total = len(topics)
79
+
80
+ msg = f"Добре, {self.name}. Починаємо іспит. Тем буде {self.topics_total}."
81
+ self._log("system", msg)
82
+ return True, msg
83
+
84
+ def _next_topic(self) -> Optional[str]:
85
+ self._bind_session()
86
+ topic = get_next_topic()
87
+ if topic:
88
+ self.current_topic = topic
89
+ self.questions_in_topic = 0
90
+ return topic
91
+ self.current_topic = None
92
+ return None
93
+
94
+ def _ask_question(self, api_key: str, model: str, base_url: str) -> str:
95
+ assert self.current_topic is not None
96
+
97
+ sys = "Ти екзаменатор. Питай коротко українською. Одне питання за раз."
98
+ user = f"Тема: {self.current_topic}\nЗадай наступне питання."
99
+ try:
100
+ q = chat_completion(
101
+ api_key=api_key,
102
+ model=model,
103
+ base_url=base_url,
104
+ messages=[{"role": "system", "content": sys}, {"role": "user", "content": user}],
105
+ ).strip()
106
+ except LLMError:
107
+ q = f"Поясни ключові поняття та приклад по темі: {self.current_topic}."
108
+
109
+ self.questions_in_topic += 1
110
+ self._log("system", q)
111
+ return q
112
+
113
+ def _evaluate_answer(self, api_key: str, model: str, base_url: str, answer: str) -> Dict[str, object]:
114
+ assert self.current_topic is not None
115
+
116
+ if _looks_like_idk(answer):
117
+ return {"score": 0.0, "action": "next_topic", "note": "student_idk", "feedback": ""}
118
+
119
+ sys = "Оціни відповідь студента для однієї теми. Поверни ТІЛЬКИ JSON."
120
+ user = {
121
+ "topic": self.current_topic,
122
+ "answer": answer,
123
+ "json_schema": {"score": "0..10", "action": "ask_followup|next_topic", "feedback": "string"},
124
+ }
125
+
126
+ try:
127
+ raw = chat_completion(
128
+ api_key=api_key,
129
+ model=model,
130
+ base_url=base_url,
131
+ messages=[{"role": "system", "content": sys}, {"role": "user", "content": json.dumps(user, ensure_ascii=False)}],
132
+ ).strip()
133
+ data = json.loads(raw)
134
+
135
+ score = float(data.get("score", 0.0))
136
+ score = max(0.0, min(10.0, score))
137
+
138
+ action = str(data.get("action", "ask_followup")).strip()
139
+ if action not in {"ask_followup", "next_topic"}:
140
+ action = "ask_followup"
141
+
142
+ feedback = str(data.get("feedback", "")).strip()
143
+ return {"score": score, "action": action, "feedback": feedback}
144
+ except Exception:
145
+ length = len(answer.strip())
146
+ score = 3.0 if length < 80 else 6.0 if length < 250 else 7.5
147
+ action = "next_topic" if score >= 7.0 else "ask_followup"
148
+ return {"score": score, "action": action, "feedback": "Оцінка приблизна (fallback)."}
149
+
150
+ def _finalize(self) -> str:
151
+ avg = round((sum(self.topic_scores.values()) / max(1, len(self.topic_scores))) if self.topic_scores else 0.0, 1)
152
+
153
+ strong = [t for t, s in self.topic_scores.items() if s >= 7.0]
154
+ weak = [t for t, s in self.topic_scores.items() if s < 7.0]
155
+
156
+ feedback_lines = []
157
+ if strong:
158
+ feedback_lines.append("Добре вийшло по темах: " + ", ".join(strong) + ".")
159
+ if weak:
160
+ feedback_lines.append("Варто підтягнути: " + ", ".join(weak) + ".")
161
+ if not feedback_lines:
162
+ feedback_lines.append("Дякую! Є над чим попрацювати — продовжуй практикуватися.")
163
+
164
+ msg = f"Іспит завершено. Оцінка: {avg}/10.\n" + "\n".join(feedback_lines)
165
+ self._log("system", msg)
166
+
167
+ self._log("tool_call", f"end_exam(email={self.email}, score={avg}, history=[...])")
168
+ end_exam(self.email or "", avg, [m.to_dict() for m in self.history])
169
+
170
+ self.stage = "finished"
171
+ return msg
172
+
173
+ def step(self, user_text: str, api_key: str, model: str, base_url: str) -> str:
174
+ user_text = (user_text or "").strip()
175
+ self._log("user", user_text)
176
+
177
+ if self.stage == "collect_identity":
178
+ if not self.name:
179
+ self.name = user_text if user_text else None
180
+ if not self.name:
181
+ msg = "Як тебе звати?"
182
+ self._log("system", msg)
183
+ return msg
184
+ msg = f"Приємно познайомитись, {self.name}! Тепер напиши свій email."
185
+ self._log("system", msg)
186
+ return msg
187
+
188
+ if not self.email:
189
+ m = EMAIL_RE.search(user_text)
190
+ self.email = m.group(1) if m else None
191
+ if not self.email:
192
+ msg = "Не бачу коректного email. Спробуй ще раз (наприклад: name@example.com)."
193
+ self._log("system", msg)
194
+ return msg
195
+
196
+ ok, start_msg = self._start_exam_tools()
197
+ if not ok:
198
+ self.email = None
199
+ return start_msg
200
+
201
+ self.stage = "exam"
202
+ topic = self._next_topic()
203
+ if not topic:
204
+ return self._finalize()
205
+
206
+ intro = f"Тема 1/{self.topics_total}: {topic}"
207
+ self._log("system", intro)
208
+ q = self._ask_question(api_key, model, base_url)
209
+ return intro + "\n" + q
210
+
211
+ if self.stage == "exam":
212
+ if not self.current_topic:
213
+ return self._finalize()
214
+
215
+ eval_res = self._evaluate_answer(api_key, model, base_url, user_text)
216
+ score = float(eval_res.get("score", 0.0))
217
+ action = str(eval_res.get("action", "ask_followup"))
218
+ feedback = str(eval_res.get("feedback", "")).strip()
219
+
220
+ prev = self.topic_scores.get(self.current_topic, 0.0)
221
+ self.topic_scores[self.current_topic] = max(prev, score)
222
+
223
+ idk = (eval_res.get("note") == "student_idk")
224
+ too_many = self.questions_in_topic >= self.max_questions_per_topic
225
+ good_enough = score >= 7.0
226
+
227
+ if idk or good_enough or too_many or action == "next_topic":
228
+ note = f"Коментар: {feedback}\n" if feedback else ""
229
+ next_t = self._next_topic()
230
+ if not next_t:
231
+ return note + self._finalize()
232
+
233
+ done = len({t for t in self.topic_scores.keys()})
234
+ header = f"{note}Переходимо далі.\nТема {min(done+1, self.topics_total)}/{self.topics_total}: {next_t}"
235
+ self._log("system", header)
236
+ q = self._ask_question(api_key, model, base_url)
237
+ return header + "\n" + q
238
+
239
+ follow = "Ок. Уточню:"
240
+ self._log("system", follow)
241
+ q = self._ask_question(api_key, model, base_url)
242
+ if feedback:
243
+ return f"Коментар: {feedback}\n{follow}\n{q}"
244
+ return f"{follow}\n{q}"
245
+
246
+ msg = "Іспит уже завершено. Якщо хочеш — натисни Reset і почнемо заново."
247
+ self._log("system", msg)
248
+ return msg
app.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import gradio as gr
4
+ from agent import ExamAgent
5
+
6
+
7
+ def new_agent() -> ExamAgent:
8
+ return ExamAgent()
9
+
10
+
11
+ def on_load():
12
+ agent = new_agent()
13
+ first = agent.initial_message()
14
+ chat_messages = [{"role": "assistant", "content": first}]
15
+ return agent, chat_messages
16
+
17
+
18
+ def on_reset():
19
+ return on_load()
20
+
21
+
22
+ def on_user_message(
23
+ agent: ExamAgent,
24
+ chat_messages,
25
+ user_text: str,
26
+ api_key: str,
27
+ model: str,
28
+ base_url: str,
29
+ ):
30
+ if agent is None:
31
+ agent = new_agent()
32
+
33
+ if chat_messages is None:
34
+ chat_messages = []
35
+
36
+ user_text = (user_text or "").strip()
37
+ if not user_text:
38
+ return agent, chat_messages, ""
39
+
40
+ chat_messages.append({"role": "user", "content": user_text})
41
+
42
+ try:
43
+ reply = agent.step(user_text, api_key=api_key, model=model, base_url=base_url)
44
+ except Exception as e:
45
+ reply = f"Сталася помилка: {e}"
46
+
47
+ chat_messages.append({"role": "assistant", "content": reply})
48
+ return agent, chat_messages, ""
49
+
50
+
51
+ with gr.Blocks(title="AI Examiner Agent") as demo:
52
+ gr.Markdown(
53
+ "# AI Examiner Agent\n"
54
+ "Сервіс проводить міні-іспит: питає ім’я та email, обирає 2–3 теми, ставить питання, "
55
+ "оцінює відповіді та зберігає результат у файли."
56
+ )
57
+
58
+ with gr.Row():
59
+ api_key = gr.Textbox(label="LLM API Key", type="password", placeholder="Встав ключ тут")
60
+ model = gr.Textbox(label="Model", value="gpt-4o-mini")
61
+ base_url = gr.Textbox(label="Base URL", value="https://api.openai.com")
62
+
63
+ chatbot = gr.Chatbot(label="Exam Chat", height=420)
64
+
65
+ with gr.Row():
66
+ user_in = gr.Textbox(label="Твоє повідомлення", placeholder="Напиши відповідь…", scale=4)
67
+ send = gr.Button("Send", scale=1)
68
+ reset = gr.Button("Reset", scale=1)
69
+
70
+ agent_state = gr.State()
71
+
72
+ demo.load(on_load, outputs=[agent_state, chatbot])
73
+ reset.click(on_reset, outputs=[agent_state, chatbot])
74
+
75
+ send.click(
76
+ on_user_message,
77
+ inputs=[agent_state, chatbot, user_in, api_key, model, base_url],
78
+ outputs=[agent_state, chatbot, user_in],
79
+ )
80
+ user_in.submit(
81
+ on_user_message,
82
+ inputs=[agent_state, chatbot, user_in, api_key, model, base_url],
83
+ outputs=[agent_state, chatbot, user_in],
84
+ )
85
+
86
+ if __name__ == "__main__":
87
+ demo.launch()
data/students.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ [
2
+ { "email": "student1@example.com", "name": "Student One" },
3
+ { "email": "student2@example.com", "name": "Student Two" }
4
+ ]
data/topics.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "HDFS: реплікація та читання",
3
+ "Git: push/SSH і типові проблеми",
4
+ "REST API: CRUD та валідація",
5
+ "MongoDB: індекси під патерни доступу",
6
+ "CI/CD: пайплайни та деплой",
7
+ "Моніторинг і логування сервісів",
8
+ "LLM агенти: tools і state",
9
+ "RAG: базова архітектура",
10
+ "HTTP: статус-коди та помилки",
11
+ "Docker: образи і змінні середовища"
12
+ ]
llm.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, Dict, List, Optional
5
+ import requests
6
+
7
+
8
+ class LLMError(RuntimeError):
9
+ pass
10
+
11
+
12
+ def chat_completion(
13
+ api_key: str,
14
+ messages: List[Dict[str, str]],
15
+ model: str = "gpt-4o-mini",
16
+ base_url: str = "https://api.openai.com",
17
+ timeout_s: int = 45,
18
+ ) -> str:
19
+ """
20
+ Minimal OpenAI-compatible Chat Completions call.
21
+ Expects provider that supports:
22
+ POST {base_url}/v1/chat/completions
23
+ """
24
+ if not api_key or not api_key.strip():
25
+ raise LLMError("LLM API key is empty.")
26
+
27
+ url = base_url.rstrip("/") + "/v1/chat/completions"
28
+ headers = {
29
+ "Authorization": f"Bearer {api_key.strip()}",
30
+ "Content-Type": "application/json",
31
+ }
32
+ payload: Dict[str, Any] = {
33
+ "model": model,
34
+ "messages": messages,
35
+ "temperature": 0.3,
36
+ }
37
+
38
+ try:
39
+ r = requests.post(url, headers=headers, json=payload, timeout=timeout_s)
40
+ except Exception as e:
41
+ raise LLMError(f"Request failed: {e}") from e
42
+
43
+ if r.status_code >= 400:
44
+ raise LLMError(f"LLM HTTP {r.status_code}: {r.text[:400]}")
45
+
46
+ data = r.json()
47
+ try:
48
+ return data["choices"][0]["message"]["content"]
49
+ except Exception:
50
+ raise LLMError(f"Unexpected LLM response shape: {json.dumps(data)[:600]}")
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio>=4.0.0
2
+ requests>=2.31.0
tools.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ import threading
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+ import contextvars
12
+
13
+ DATA_DIR = Path(__file__).parent / "data"
14
+ STUDENTS_PATH = DATA_DIR / "students.json"
15
+ TOPICS_PATH = DATA_DIR / "topics.json"
16
+ SESSIONS_PATH = DATA_DIR / "sessions.json"
17
+ RESULTS_PATH = DATA_DIR / "results.json"
18
+
19
+ _FILE_LOCK = threading.Lock()
20
+
21
+ _current_session_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
22
+ "current_session_id", default=None
23
+ )
24
+
25
+ _LAST_SESSION_ID: Optional[str] = None
26
+
27
+
28
+ class StudentNotFoundError(Exception):
29
+ pass
30
+
31
+
32
+ def _utc_now_iso() -> str:
33
+ return datetime.now(timezone.utc).isoformat()
34
+
35
+
36
+ def _read_json(path: Path, default: Any) -> Any:
37
+ with _FILE_LOCK:
38
+ if not path.exists():
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ path.write_text(json.dumps(default, ensure_ascii=False, indent=2), encoding="utf-8")
41
+ return default
42
+ txt = path.read_text(encoding="utf-8").strip()
43
+ if not txt:
44
+ return default
45
+ return json.loads(txt)
46
+
47
+
48
+ def _write_json(path: Path, data: Any) -> None:
49
+ with _FILE_LOCK:
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
52
+
53
+
54
+ def ensure_data_files() -> None:
55
+ _read_json(STUDENTS_PATH, [])
56
+ _read_json(TOPICS_PATH, [])
57
+ _read_json(SESSIONS_PATH, {})
58
+ _read_json(RESULTS_PATH, [])
59
+
60
+
61
+ @dataclass
62
+ class Message:
63
+ role: str
64
+ content: str
65
+ datetime: str
66
+
67
+ def to_dict(self) -> Dict[str, str]:
68
+ return {"role": self.role, "content": self.content, "datetime": self.datetime}
69
+
70
+
71
+ def set_current_session(session_id: Optional[str]) -> None:
72
+ _current_session_id.set(session_id)
73
+
74
+
75
+ def get_last_session_id() -> Optional[str]:
76
+ return _LAST_SESSION_ID
77
+
78
+
79
+ def _load_students() -> List[Dict[str, str]]:
80
+ return _read_json(STUDENTS_PATH, [])
81
+
82
+
83
+ def _load_topics() -> List[str]:
84
+ return _read_json(TOPICS_PATH, [])
85
+
86
+
87
+ def _load_sessions() -> Dict[str, Any]:
88
+ return _read_json(SESSIONS_PATH, {})
89
+
90
+
91
+ def _save_sessions(sessions: Dict[str, Any]) -> None:
92
+ _write_json(SESSIONS_PATH, sessions)
93
+
94
+
95
+ def _append_result(record: Dict[str, Any]) -> None:
96
+ results = _read_json(RESULTS_PATH, [])
97
+ if not isinstance(results, list):
98
+ results = []
99
+ results.append(record)
100
+ _write_json(RESULTS_PATH, results)
101
+
102
+
103
+ def start_exam(email: str, name: str) -> List[str]:
104
+ global _LAST_SESSION_ID
105
+
106
+ ensure_data_files()
107
+
108
+ students = _load_students()
109
+ found = next((s for s in students if s.get("email", "").strip().lower() == email.strip().lower()), None)
110
+ if not found:
111
+ raise StudentNotFoundError(f"Student not found for email: {email}")
112
+
113
+ topics = _load_topics()
114
+ if len(topics) < 2:
115
+ topics = ["General knowledge: topic A", "General knowledge: topic B", "General knowledge: topic C"]
116
+
117
+ k = 2 if len(topics) < 3 else random.choice([2, 3])
118
+ selected = random.sample(topics, k=min(k, len(topics)))
119
+
120
+ sessions = _load_sessions()
121
+ session_id = str(uuid.uuid4())
122
+ sessions[session_id] = {
123
+ "session_id": session_id,
124
+ "email": email,
125
+ "name": name,
126
+ "topics_queue": selected,
127
+ "topics_all": selected,
128
+ "started_at": _utc_now_iso(),
129
+ "finished_at": None,
130
+ "status": "active",
131
+ }
132
+ _save_sessions(sessions)
133
+
134
+ _LAST_SESSION_ID = session_id
135
+ set_current_session(session_id)
136
+
137
+ return selected
138
+
139
+
140
+ def get_next_topic() -> str:
141
+ """
142
+ IMPORTANT FIX:
143
+ Gradio callbacks may run in a different context, so contextvar can be empty.
144
+ We fallback to _LAST_SESSION_ID.
145
+ """
146
+ ensure_data_files()
147
+
148
+ session_id = _current_session_id.get() or _LAST_SESSION_ID
149
+ if not session_id:
150
+ return ""
151
+
152
+ sessions = _load_sessions()
153
+ sess = sessions.get(session_id)
154
+ if not sess or sess.get("status") != "active":
155
+ return ""
156
+
157
+ queue = sess.get("topics_queue", [])
158
+ if not queue:
159
+ return ""
160
+
161
+ next_topic = queue.pop(0)
162
+ sess["topics_queue"] = queue
163
+ sessions[session_id] = sess
164
+ _save_sessions(sessions)
165
+ return str(next_topic)
166
+
167
+
168
+ def end_exam(email: str, score: float, history: List[Dict[str, str]]) -> None:
169
+ ensure_data_files()
170
+
171
+ sessions = _load_sessions()
172
+ for sid, sess in list(sessions.items()):
173
+ if sess.get("email", "").strip().lower() == email.strip().lower() and sess.get("status") == "active":
174
+ sess["status"] = "finished"
175
+ sess["finished_at"] = _utc_now_iso()
176
+ sessions[sid] = sess
177
+ _save_sessions(sessions)
178
+
179
+ record = {
180
+ "email": email,
181
+ "score": float(score),
182
+ "history": history,
183
+ "saved_at": _utc_now_iso(),
184
+ }
185
+ _append_result(record)