Upload 5 files
Browse files- agent.py +246 -0
- app.py +191 -0
- exam_db.json +0 -0
- exam_functions.py +152 -0
- 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 & email<br>
|
| 136 |
+
4. Answer NLP questions<br>
|
| 137 |
+
5. Get your score & 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
|