Denysyk commited on
Commit
5d2b004
·
verified ·
1 Parent(s): 2323f40

Upload 5 files

Browse files
Files changed (5) hide show
  1. agent.py +246 -0
  2. app.py +191 -0
  3. exam_db.json +0 -0
  4. exam_functions.py +152 -0
  5. requirements.txt +2 -0
agent.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Examiner Agent — Groq with tool calling + fallback parser for leaked function calls.
3
+ """
4
+
5
+ import json
6
+ import re
7
+ import uuid
8
+ from datetime import datetime
9
+ from openai import OpenAI
10
+
11
+ from exam_functions import (
12
+ start_exam, get_next_topic, end_exam, set_topic_queue, Message,
13
+ )
14
+
15
+ TOOLS = [
16
+ {
17
+ "type": "function",
18
+ "function": {
19
+ "name": "start_exam",
20
+ "description": "Call once the student provided name and email. Returns list of exam topics.",
21
+ "parameters": {
22
+ "type": "object",
23
+ "properties": {
24
+ "email": {"type": "string"},
25
+ "name": {"type": "string"},
26
+ },
27
+ "required": ["email", "name"],
28
+ },
29
+ },
30
+ },
31
+ {
32
+ "type": "function",
33
+ "function": {
34
+ "name": "get_next_topic",
35
+ "description": "Call to get the next exam topic. Returns empty string when no topics remain.",
36
+ "parameters": {"type": "object", "properties": {}, "required": []},
37
+ },
38
+ },
39
+ {
40
+ "type": "function",
41
+ "function": {
42
+ "name": "end_exam",
43
+ "description": "Call after giving final feedback. Saves score (0-10) permanently.",
44
+ "parameters": {
45
+ "type": "object",
46
+ "properties": {
47
+ "email": {"type": "string"},
48
+ "score": {"type": "number"},
49
+ },
50
+ "required": ["email", "score"],
51
+ },
52
+ },
53
+ },
54
+ ]
55
+
56
+ SYSTEM_PROMPT = """You are an AI university examiner conducting an NLP course oral exam.
57
+
58
+ RULES:
59
+ 1. Greet the student and ask for their full name and email.
60
+ 2. Once you have both, call start_exam(email, name).
61
+ - On error: ask to double-check details.
62
+ - On success: immediately call get_next_topic() to get the first topic.
63
+
64
+ 3. For EACH topic, conduct a dialogue:
65
+ - Ask an open-ended question about the topic.
66
+ - Move to the NEXT QUESTION (not next topic) when:
67
+ a) The student gives a sufficiently complete answer — ask a follow-up to go deeper.
68
+ b) The student says "I don't know" or similar — acknowledge and ask a different/simpler question on the SAME topic.
69
+ c) It becomes clear the student's level won't change with more questions — then move to the next TOPIC.
70
+ - Move to the NEXT TOPIC (call get_next_topic()) when:
71
+ a) The student's knowledge on this topic is clearly established.
72
+ b) The student has said "I don't know" to 2+ questions in a row on this topic.
73
+ c) You have asked 3+ questions and have a clear picture of the student's level.
74
+
75
+ 4. CRITICAL: Do NOT show the score or end the exam until get_next_topic() returns "". Cover ALL topics.
76
+
77
+ 5. After all topics:
78
+ - Show the student their score (0-10) and feedback (strengths + what to improve).
79
+ - Call end_exam(email, score) with the EXACT numeric score you stated.
80
+ - Scoring guide:
81
+ * 9-10: Deep, accurate, detailed answers on all topics.
82
+ * 7-8: Good understanding, minor gaps.
83
+ * 5-6: Partial understanding, significant gaps.
84
+ * 3-4: Mostly "I don't know", very shallow answers.
85
+ * 0-2: No meaningful answers at all.
86
+
87
+ 6. Be encouraging but STRICT and objective. "I don't know" lowers the score significantly.
88
+ 7. Match the student's language (Ukrainian or English).
89
+ 8. Never add meta-comments in parentheses. Speak naturally."""
90
+
91
+
92
+ def _parse_leaked_calls(text: str) -> list[tuple[str, str]]:
93
+ """Parse <function=NAME...{json}...> in any format Llama might use."""
94
+ found = []
95
+ pattern = re.compile(r"<function=(\w+)[,\s(]*(\{.*?\})\s*\)?\s*(?:</function>)?", re.DOTALL)
96
+ for m in pattern.finditer(text):
97
+ name = m.group(1)
98
+ args_raw = m.group(2).strip()
99
+ try:
100
+ json.loads(args_raw)
101
+ args_str = args_raw
102
+ except (json.JSONDecodeError, ValueError):
103
+ args_str = "{}"
104
+ found.append((name, args_str))
105
+ return found
106
+
107
+
108
+ class ExaminerAgent:
109
+ def __init__(self, api_key: str):
110
+ self.client = OpenAI(
111
+ api_key=api_key,
112
+ base_url="https://api.groq.com/openai/v1",
113
+ )
114
+ self.messages: list[dict] = []
115
+ self.history: list[Message] = []
116
+ self.student_email = ""
117
+ self.exam_finished = False
118
+
119
+ def _log(self, role: str, content: str):
120
+ self.history.append({
121
+ "role": role, "content": content,
122
+ "datetime": datetime.now().isoformat(timespec="seconds"),
123
+ })
124
+
125
+ def _dispatch(self, name: str, arguments_str: str) -> str:
126
+ try:
127
+ inputs = json.loads(arguments_str) if arguments_str and arguments_str.strip() not in ("null", "None", "") else {}
128
+ except json.JSONDecodeError:
129
+ inputs = {}
130
+
131
+ self._log("tool_call", f"{name}({arguments_str})")
132
+
133
+ if name == "start_exam":
134
+ try:
135
+ topics = start_exam(inputs["email"], inputs["name"])
136
+ set_topic_queue(topics)
137
+ self.student_email = inputs["email"]
138
+ return json.dumps({"topics": topics})
139
+ except ValueError as e:
140
+ return json.dumps({"error": str(e)})
141
+
142
+ elif name == "get_next_topic":
143
+ return json.dumps({"topic": get_next_topic()})
144
+
145
+ elif name == "end_exam":
146
+ score = inputs.get("score", None)
147
+ # Fallback: extract score from chat history if missing or zero
148
+ if not score:
149
+ for entry in reversed(self.history[-10:]):
150
+ found = re.findall(r"([0-9]+(?:\.[0-9]+)?)\s*(?:out of|/)\s*10", entry.get("content", ""))
151
+ if found:
152
+ score = float(found[-1])
153
+ break
154
+ if not score:
155
+ score = 0.0
156
+ if self.student_email:
157
+ end_exam(self.student_email, float(score), self.history)
158
+ self.exam_finished = True
159
+ return json.dumps({"status": "saved"})
160
+
161
+ return json.dumps({"error": "unknown tool"})
162
+
163
+ def _inject_leaked(self, leaked: list[tuple[str, str]]):
164
+ """Execute leaked tool calls and inject results into message history."""
165
+ for name, args_str in leaked:
166
+ result = self._dispatch(name, args_str)
167
+ fake_id = f"call_{uuid.uuid4().hex[:8]}"
168
+ self.messages.append({
169
+ "role": "assistant",
170
+ "content": "",
171
+ "tool_calls": [{"id": fake_id, "type": "function",
172
+ "function": {"name": name, "arguments": args_str}}],
173
+ })
174
+ self.messages.append({"role": "tool", "tool_call_id": fake_id, "content": result})
175
+
176
+ def _run_turn(self) -> str:
177
+ while True:
178
+ try:
179
+ response = self.client.chat.completions.create(
180
+ model="llama-3.3-70b-versatile",
181
+ max_tokens=1024,
182
+ tools=TOOLS,
183
+ tool_choice="auto",
184
+ messages=[
185
+ {"role": "system", "content": SYSTEM_PROMPT},
186
+ *self.messages,
187
+ ],
188
+ )
189
+ except Exception as e:
190
+ leaked = _parse_leaked_calls(str(e))
191
+ if leaked:
192
+ if self.messages and self.messages[-1]["role"] == "assistant":
193
+ bad = self.messages.pop()
194
+ clean = re.sub(r"<function=.*", "", bad.get("content", ""), flags=re.DOTALL).strip()
195
+ if clean:
196
+ self.messages.append({"role": "assistant", "content": clean})
197
+ self._inject_leaked(leaked)
198
+ continue
199
+ raise
200
+
201
+ msg = response.choices[0].message
202
+ finish_reason = response.choices[0].finish_reason
203
+
204
+ assistant_msg: dict = {"role": "assistant", "content": msg.content or ""}
205
+ if msg.tool_calls:
206
+ assistant_msg["tool_calls"] = [
207
+ {"id": tc.id, "type": "function",
208
+ "function": {"name": tc.function.name, "arguments": tc.function.arguments}}
209
+ for tc in msg.tool_calls
210
+ ]
211
+ self.messages.append(assistant_msg)
212
+
213
+ if finish_reason != "tool_calls" or not msg.tool_calls:
214
+ text = msg.content or ""
215
+ leaked = _parse_leaked_calls(text)
216
+ if leaked:
217
+ clean = re.sub(r"<function=.*", "", text, flags=re.DOTALL).strip()
218
+ self.messages.pop()
219
+ if clean:
220
+ self.messages.append({"role": "assistant", "content": clean})
221
+ self._inject_leaked(leaked)
222
+ continue
223
+ self._log("system", text)
224
+ return text
225
+
226
+ for tc in msg.tool_calls:
227
+ result = self._dispatch(tc.function.name, tc.function.arguments)
228
+ self.messages.append({
229
+ "role": "tool",
230
+ "tool_call_id": tc.id,
231
+ "content": result,
232
+ })
233
+
234
+ def start(self) -> str:
235
+ self.messages = []
236
+ self.history = []
237
+ self.student_email = ""
238
+ self.exam_finished = False
239
+ set_topic_queue([])
240
+ self.messages = [{"role": "user", "content": "Hello, I am ready for my exam."}]
241
+ return self._run_turn()
242
+
243
+ def chat(self, user_message: str) -> str:
244
+ self._log("user", user_message)
245
+ self.messages.append({"role": "user", "content": user_message})
246
+ return self._run_turn()
app.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio web interface for AI Examiner Agent.
3
+ Run: python app.py
4
+ """
5
+
6
+ import gradio as gr
7
+ from agent import ExaminerAgent
8
+
9
+ _agent: ExaminerAgent | None = None
10
+
11
+
12
+ def init_exam(api_key: str):
13
+ """Called when the user clicks 'Start Exam'. Resets chat completely."""
14
+ global _agent
15
+
16
+ if not api_key.strip():
17
+ return [{
18
+ "role": "assistant",
19
+ "content": "⚠️ Please enter your Groq API key first."
20
+ }], gr.update(interactive=False)
21
+
22
+ try:
23
+ _agent = ExaminerAgent(api_key.strip())
24
+ opening = _agent.start()
25
+ return [{"role": "assistant", "content": opening}], gr.update(interactive=True)
26
+ except Exception as e:
27
+ _agent = None
28
+ return [{"role": "assistant", "content": f"❌ Error initialising agent: {e}"}], gr.update(interactive=False)
29
+
30
+
31
+ def user_message(message: str, history: list):
32
+ """Called when the student sends a message."""
33
+ global _agent
34
+
35
+ if not message.strip():
36
+ return history, ""
37
+
38
+ if _agent is None:
39
+ return history + [
40
+ {"role": "user", "content": message},
41
+ {"role": "assistant", "content": "⚠️ Please click **Start Exam** first."},
42
+ ], ""
43
+
44
+ # Block messages after exam is finished
45
+ if _agent.exam_finished:
46
+ return history + [
47
+ {"role": "user", "content": message},
48
+ {"role": "assistant", "content": "✅ The exam is already finished. Click **▶ Start Exam** to start a new session."},
49
+ ], ""
50
+
51
+ history = history + [{"role": "user", "content": message}]
52
+
53
+ try:
54
+ reply = _agent.chat(message)
55
+ except Exception as e:
56
+ reply = f"❌ Agent error: {e}"
57
+
58
+ history = history + [{"role": "assistant", "content": reply}]
59
+ return history, ""
60
+
61
+
62
+ # ─── UI ──────────────────────────────────────────────────────────────────────
63
+
64
+ CSS = """
65
+ @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;600;800&display=swap');
66
+
67
+ body, .gradio-container {
68
+ background: #0d0f14 !important;
69
+ font-family: 'Syne', sans-serif !important;
70
+ }
71
+
72
+ .title-block {
73
+ text-align: center;
74
+ padding: 2rem 1rem 1rem;
75
+ }
76
+ .title-block h1 {
77
+ font-family: 'Syne', sans-serif;
78
+ font-weight: 800;
79
+ font-size: 2.6rem;
80
+ letter-spacing: -1px;
81
+ background: linear-gradient(135deg, #e2ff5d 0%, #00ffc2 100%);
82
+ -webkit-background-clip: text;
83
+ -webkit-text-fill-color: transparent;
84
+ margin: 0;
85
+ }
86
+ .title-block p {
87
+ color: #8b95a8;
88
+ font-family: 'Space Mono', monospace;
89
+ font-size: 0.82rem;
90
+ margin-top: 0.4rem;
91
+ }
92
+
93
+ .gr-button-primary {
94
+ background: linear-gradient(135deg, #e2ff5d, #00ffc2) !important;
95
+ color: #0d0f14 !important;
96
+ font-family: 'Space Mono', monospace !important;
97
+ font-weight: 700 !important;
98
+ border: none !important;
99
+ border-radius: 6px !important;
100
+ }
101
+ .gr-button-primary:hover { filter: brightness(1.1) !important; }
102
+
103
+ label { color: #8b95a8 !important; font-family: 'Space Mono', monospace !important; font-size: 0.78rem !important; }
104
+ input, textarea { background: #141820 !important; border: 1px solid #2a3040 !important; color: #e8ecf4 !important; border-radius: 6px !important; }
105
+
106
+ .info-box {
107
+ background: #141820;
108
+ border: 1px solid #2a3040;
109
+ border-radius: 8px;
110
+ padding: 1rem 1.2rem;
111
+ font-family: 'Space Mono', monospace;
112
+ font-size: 0.75rem;
113
+ color: #5a6478;
114
+ line-height: 1.7;
115
+ }
116
+ .info-box strong { color: #e2ff5d; }
117
+ """
118
+
119
+ with gr.Blocks(title="AI Examiner Agent") as demo:
120
+
121
+ gr.HTML("""
122
+ <div class="title-block">
123
+ <h1>⬡ AI Examiner Agent</h1>
124
+ <p>NLP course · oral exam simulation · powered by AI</p>
125
+ </div>
126
+ """)
127
+
128
+ with gr.Row():
129
+ with gr.Column(scale=1, min_width=260):
130
+ gr.HTML("""
131
+ <div class="info-box">
132
+ <strong>How it works</strong><br>
133
+ 1. Paste your Groq API key<br>
134
+ 2. Click <em>Start Exam</em><br>
135
+ 3. Tell the bot your name &amp; email<br>
136
+ 4. Answer NLP questions<br>
137
+ 5. Get your score &amp; feedback
138
+ <br><br>
139
+ <strong>Demo students</strong><br>
140
+ test@test.com / test<br>
141
+ denys.kovalenko@student.lpnu.ua
142
+ </div>
143
+ """)
144
+
145
+ api_key = gr.Textbox(
146
+ label="Groq API Key",
147
+ placeholder="gsk_...",
148
+ type="password",
149
+ lines=1,
150
+ )
151
+ start_btn = gr.Button("▶ Start Exam", variant="primary")
152
+
153
+ with gr.Column(scale=3):
154
+ chatbot = gr.Chatbot(
155
+ label="Exam Chat",
156
+ height=520,
157
+ show_label=False,
158
+ layout="bubble",
159
+ )
160
+ with gr.Row():
161
+ msg_input = gr.Textbox(
162
+ placeholder="Type your answer here…",
163
+ show_label=False,
164
+ lines=1,
165
+ scale=5,
166
+ interactive=False,
167
+ )
168
+ send_btn = gr.Button("Send →", scale=1, variant="primary")
169
+
170
+ # Start Exam — clears chat history completely, creates new agent
171
+ start_btn.click(
172
+ fn=init_exam,
173
+ inputs=[api_key],
174
+ outputs=[chatbot, msg_input],
175
+ )
176
+
177
+ send_btn.click(
178
+ fn=user_message,
179
+ inputs=[msg_input, chatbot],
180
+ outputs=[chatbot, msg_input],
181
+ )
182
+
183
+ msg_input.submit(
184
+ fn=user_message,
185
+ inputs=[msg_input, chatbot],
186
+ outputs=[chatbot, msg_input],
187
+ )
188
+
189
+
190
+ if __name__ == "__main__":
191
+ demo.launch(share=False, css=CSS)
exam_db.json ADDED
The diff for this file is too large to render. See raw diff
 
exam_functions.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Exam backend functions for AI Examiner Agent.
3
+ """
4
+
5
+ import json
6
+ import random
7
+ import os
8
+ from datetime import datetime
9
+ from typing import TypedDict, Literal
10
+
11
+ TOPICS = [
12
+ "Tokenization and text preprocessing",
13
+ "Word embeddings (Word2Vec, GloVe, FastText)",
14
+ "Recurrent neural networks (RNN, LSTM, GRU)",
15
+ "Attention mechanism and Transformers",
16
+ "BERT and pre-trained language models",
17
+ "Named Entity Recognition (NER)",
18
+ "Sentiment analysis",
19
+ "Machine translation",
20
+ "Text classification",
21
+ "Language model evaluation metrics (BLEU, ROUGE, Perplexity)",
22
+ ]
23
+
24
+ STUDENTS = [
25
+ {"name": "Stanislav Androshchuk", "email": "Stanislav.Androshchuk.mKNSSh.2025@lpnu.ua"},
26
+ {"name": "Oleksandr Babilia", "email": "Oleksandr.Babilia.mKNSSh.2025@lpnu.ua"},
27
+ {"name": "Vitalii Bahrynets", "email": "Vitalii.Bahrynets.mKNSSh.2025@lpnu.ua"},
28
+ {"name": "Dmytro Betsa", "email": "Dmytro.Betsa.mKNSSh.2025@lpnu.ua"},
29
+ {"name": "Kateryna Bilyk", "email": "Kateryna.Bilyk.mKNSSh.2025@lpnu.ua"},
30
+ {"name": "Iryna Boiko", "email": "Iryna.Boiko.mKNSSh.2025@lpnu.ua"},
31
+ {"name": "Ihor Boklach", "email": "Ihor.Boklach.mKNSSh.2025@lpnu.ua"},
32
+ {"name": "Bohdan Boretskyi", "email": "Bohdan.Boretskyi.mKNSSh.2025@lpnu.ua"},
33
+ {"name": "Yaroslav Borys", "email": "Yaroslav.Borys.mKNSSh.2025@lpnu.ua"},
34
+ {"name": "Denys Brativnyk", "email": "Denys.Brativnyk.mKNSSh.2025@lpnu.ua"},
35
+ {"name": "Oleksandr Vlasiuk", "email": "Oleksandr.Vlasiuk.mKNSSh.2025@lpnu.ua"},
36
+ {"name": "Oleksandr Voznyi", "email": "Oleksandr.Voznyi.mKNSSh.2025@lpnu.ua"},
37
+ {"name": "Khrystyna Dolynska", "email": "Khrystyna.Dolynska.mKNSSh.2025@lpnu.ua"},
38
+ {"name": "Viktor Zharkivskyi", "email": "Viktor.Zharkivskyi.mKNSSh.2025@lpnu.ua"},
39
+ {"name": "Olena Kalenchuk", "email": "Olena.Kalenchuk.mKNSSh.2025@lpnu.ua"},
40
+ {"name": "Dmytro Kostinskyi", "email": "Dmytro.Kostinskyi.mKNSSh.2025@lpnu.ua"},
41
+ {"name": "Anastasiia Kudybovska", "email": "Anastasiia.Kudybovska.mKNSSh.2025@lpnu.ua"},
42
+ {"name": "Vladyslav Kuchynskyi", "email": "Vladyslav.Kuchynskyi.mKNSSh.2025@lpnu.ua"},
43
+ {"name": "Olena Litovska", "email": "Olena.Litovska.mKNSSh.2025@lpnu.ua"},
44
+ {"name": "Oleh Lozovyi", "email": "Oleh.Lozovyi.mKNSSh.2025@lpnu.ua"},
45
+ {"name": "Roman Maior", "email": "Roman.Maior.mKNSSh.2025@lpnu.ua"},
46
+ {"name": "Yevhen Makarenko", "email": "Yevhen.Makarenko.mKNSSh.2025@lpnu.ua"},
47
+ {"name": "Serhii Matsyshyn", "email": "Serhii.Matsyshyn.mKNSSh.2025@lpnu.ua"},
48
+ {"name": "Maksym Myna", "email": "Maksym.Myna.mKNSSh.2025@lpnu.ua"},
49
+ {"name": "Artem Mikanov", "email": "Artem.Mikanov.mKNSSh.2025@lpnu.ua"},
50
+ {"name": "Vitalii Mil", "email": "Vitalii.Mil.mKNSSh.2025@lpnu.ua"},
51
+ {"name": "Vladyslav Miniailo", "email": "Vladyslav.Miniailo.mKNSSh.2025@lpnu.ua"},
52
+ {"name": "Vladyslav Moiseienko", "email": "Vladyslav.Moiseienko.mKNSSh.2025@lpnu.ua"},
53
+ {"name": "Tymofii Nasobko", "email": "Tymofii.Nasobko.mKNSSh.2025@lpnu.ua"},
54
+ {"name": "Arsenii Ohar", "email": "Arsenii.Ohar.mKNSSh.2025@lpnu.ua"},
55
+ {"name": "Marta Oliinyk", "email": "Marta.Oliinyk.mKNSSh.2025@lpnu.ua"},
56
+ {"name": "Oleksii Oliinyk", "email": "Oleksii.Oliinyk.mKNSSh.2025@lpnu.ua"},
57
+ {"name": "Roman Omelchuk", "email": "Roman.Omelchuk.mKNSSh.2025@lpnu.ua"},
58
+ {"name": "Maksym Orlianskyi", "email": "Maksym.Orlianskyi.mKNSSh.2025@lpnu.ua"},
59
+ {"name": "Alina Pavliv", "email": "Alina.Pavliv.mKNSSh.2025@lpnu.ua"},
60
+ {"name": "Andrii Pytel", "email": "Andrii.Pytel.mKNSSh.2025@lpnu.ua"},
61
+ {"name": "Oleksii Postovyi", "email": "Oleksii.Postovyi.mKNSSh.2025@lpnu.ua"},
62
+ {"name": "Myroslav Pronyshyn", "email": "Myroslav.Pronyshyn.mKNSSh.2025@lpnu.ua"},
63
+ {"name": "Yurii Pukhta", "email": "Yurii.Pukhta.mKNSSh.2025@lpnu.ua"},
64
+ {"name": "Denys Ratushniak", "email": "Denys.Ratushniak.mKNSSh.2025@lpnu.ua"},
65
+ {"name": "Nazar Savitskyi", "email": "Nazar.Savitskyi.mKNSSh.2025@lpnu.ua"},
66
+ {"name": "Oleksandr Siryk", "email": "Oleksandr.Siryk.mKNSSh.2025@lpnu.ua"},
67
+ {"name": "Petro Slobodian", "email": "Petro.Slobodian.mKNSSh.2025@lpnu.ua"},
68
+ {"name": "Artem Somar", "email": "Artem.Somar.mKNSSh.2025@lpnu.ua"},
69
+ {"name": "Vladyslav Spivakov", "email": "Vladyslav.Spivakov.mKNSSh.2025@lpnu.ua"},
70
+ {"name": "Pavlo Stetsiuk", "email": "Pavlo.Stetsiuk.mKNSSh.2025@lpnu.ua"},
71
+ {"name": "Vladyslav Taraban", "email": "Vladyslav.Taraban.mKNSSh.2025@lpnu.ua"},
72
+ {"name": "Andrii Tarasov", "email": "Andrii.Tarasov.mKNSSh.2025@lpnu.ua"},
73
+ {"name": "Illia Feloniuk", "email": "Illia.Feloniuk.mKNSSh.2025@lpnu.ua"},
74
+ {"name": "Sviatoslav Shainoha", "email": "Sviatoslav.Shainoha.mKNSSh.2025@lpnu.ua"},
75
+ {"name": "Sviatoslav Shylkov", "email": "Sviatoslav.Shylkov.mKNSSh.2025@lpnu.ua"},
76
+ {"name": "Vitalii Yuzvyn", "email": "Vitalii.Yuzvyn.mKNSSh.2025@lpnu.ua"},
77
+ {"name": "Vladyslav Yakymchuk", "email": "Vladyslav.Yakymchuk.mKNSSh.2025@lpnu.ua"},
78
+ # demo
79
+ {"name": "Test", "email": "test@test.com"},
80
+ ]
81
+
82
+ DB_FILE = "exam_db.json"
83
+
84
+
85
+ def _load_db() -> dict:
86
+ if os.path.exists(DB_FILE):
87
+ with open(DB_FILE, "r", encoding="utf-8") as f:
88
+ return json.load(f)
89
+ return {"exams": []}
90
+
91
+
92
+ def _save_db(db: dict) -> None:
93
+ with open(DB_FILE, "w", encoding="utf-8") as f:
94
+ json.dump(db, f, ensure_ascii=False, indent=2)
95
+
96
+
97
+ def start_exam(email: str, name: str) -> list[str]:
98
+ student = next(
99
+ (s for s in STUDENTS if s["email"].lower() == email.strip().lower()),
100
+ None,
101
+ )
102
+ if student is None:
103
+ raise ValueError(
104
+ f"Student with email '{email}' not found. Please check your email address."
105
+ )
106
+
107
+ topics = random.sample(TOPICS, k=random.randint(2, 3))
108
+
109
+ db = _load_db()
110
+ db["exams"].append({
111
+ "email": email,
112
+ "name": name,
113
+ "started_at": datetime.now().isoformat(),
114
+ "topics": topics,
115
+ "score": None,
116
+ "finished_at": None,
117
+ })
118
+ _save_db(db)
119
+ print(f"[DB] Exam started for {name} <{email}>. Topics: {topics}")
120
+ return topics
121
+
122
+
123
+ _topic_queue: list[str] = []
124
+
125
+
126
+ def set_topic_queue(topics: list[str]) -> None:
127
+ global _topic_queue
128
+ _topic_queue = list(topics)
129
+
130
+
131
+ def get_next_topic() -> str:
132
+ if _topic_queue:
133
+ return _topic_queue.pop(0)
134
+ return ""
135
+
136
+
137
+ class Message(TypedDict):
138
+ role: Literal["system", "user", "tool_call"]
139
+ content: str
140
+ datetime: str
141
+
142
+
143
+ def end_exam(email: str, score: float, history: list[Message]) -> None:
144
+ db = _load_db()
145
+ for exam in reversed(db["exams"]):
146
+ if exam["email"].lower() == email.lower() and exam["score"] is None:
147
+ exam["score"] = score
148
+ exam["finished_at"] = datetime.now().isoformat()
149
+ exam["history"] = history
150
+ break
151
+ _save_db(db)
152
+ print(f"[DB] Exam finished for {email}. Score: {score}/10")
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ openai>=1.30.0
2
+ gradio>=4.44.0