|
|
from __future__ import annotations |
|
|
|
|
|
import json |
|
|
import re |
|
|
from dataclasses import dataclass, field |
|
|
from datetime import datetime, timezone |
|
|
from typing import Dict, List, Optional, Tuple |
|
|
|
|
|
from llm import LLMError, chat_completion |
|
|
from tools import ( |
|
|
Message, |
|
|
StudentNotFoundError, |
|
|
end_exam, |
|
|
get_last_session_id, |
|
|
get_next_topic, |
|
|
set_current_session, |
|
|
start_exam, |
|
|
) |
|
|
|
|
|
EMAIL_RE = re.compile(r"([a-zA-Z0-9_.+\-]+@[a-zA-Z0-9\-]+\.[a-zA-Z0-9\-.]+)") |
|
|
|
|
|
|
|
|
def _utc_now_iso() -> str: |
|
|
return datetime.now(timezone.utc).isoformat() |
|
|
|
|
|
|
|
|
def _looks_like_idk(text: str) -> bool: |
|
|
t = text.strip().lower() |
|
|
triggers = ["не знаю", "не пам'ятаю", "не памятаю", "не можу", "no idea", "idk", "не впевнений", "не впевнена"] |
|
|
return any(x in t for x in triggers) |
|
|
|
|
|
|
|
|
@dataclass |
|
|
class ExamAgent: |
|
|
stage: str = "collect_identity" |
|
|
name: Optional[str] = None |
|
|
email: Optional[str] = None |
|
|
|
|
|
session_id: Optional[str] = None |
|
|
topics_total: int = 0 |
|
|
current_topic: Optional[str] = None |
|
|
|
|
|
max_questions_per_topic: int = 3 |
|
|
questions_in_topic: int = 0 |
|
|
|
|
|
topic_scores: Dict[str, float] = field(default_factory=dict) |
|
|
history: List[Message] = field(default_factory=list) |
|
|
|
|
|
def _log(self, role: str, content: str) -> None: |
|
|
self.history.append(Message(role=role, content=content, datetime=_utc_now_iso())) |
|
|
|
|
|
def initial_message(self) -> str: |
|
|
msg = "Привіт! Я екзаменатор. Як тебе звати?" |
|
|
self._log("system", msg) |
|
|
return msg |
|
|
|
|
|
def _bind_session(self) -> None: |
|
|
if self.session_id: |
|
|
set_current_session(self.session_id) |
|
|
|
|
|
def _start_exam_tools(self) -> Tuple[bool, str]: |
|
|
self._log("tool_call", f"start_exam(email={self.email}, name={self.name})") |
|
|
try: |
|
|
topics = start_exam(self.email or "", self.name or "") |
|
|
except StudentNotFoundError: |
|
|
self.session_id = None |
|
|
self.topics_total = 0 |
|
|
self.current_topic = None |
|
|
self.stage = "collect_identity" |
|
|
msg = ( |
|
|
"Я не знайшов(ла) студента з таким email у списку. " |
|
|
"Перевір, будь ласка, email і надішли його ще раз." |
|
|
) |
|
|
self._log("system", msg) |
|
|
return False, msg |
|
|
|
|
|
self.session_id = get_last_session_id() |
|
|
self.topics_total = len(topics) |
|
|
|
|
|
msg = f"Добре, {self.name}. Починаємо іспит. Тем буде {self.topics_total}." |
|
|
self._log("system", msg) |
|
|
return True, msg |
|
|
|
|
|
def _next_topic(self) -> Optional[str]: |
|
|
self._bind_session() |
|
|
topic = get_next_topic() |
|
|
if topic: |
|
|
self.current_topic = topic |
|
|
self.questions_in_topic = 0 |
|
|
return topic |
|
|
self.current_topic = None |
|
|
return None |
|
|
|
|
|
def _ask_question(self, api_key: str, model: str, base_url: str) -> str: |
|
|
assert self.current_topic is not None |
|
|
|
|
|
sys = "Ти екзаменатор. Питай коротко українською. Одне питання за раз." |
|
|
user = f"Тема: {self.current_topic}\nЗадай наступне питання." |
|
|
try: |
|
|
q = chat_completion( |
|
|
api_key=api_key, |
|
|
model=model, |
|
|
base_url=base_url, |
|
|
messages=[{"role": "system", "content": sys}, {"role": "user", "content": user}], |
|
|
).strip() |
|
|
except LLMError: |
|
|
q = f"Поясни ключові поняття та приклад по темі: {self.current_topic}." |
|
|
|
|
|
self.questions_in_topic += 1 |
|
|
self._log("system", q) |
|
|
return q |
|
|
|
|
|
def _evaluate_answer(self, api_key: str, model: str, base_url: str, answer: str) -> Dict[str, object]: |
|
|
assert self.current_topic is not None |
|
|
|
|
|
if _looks_like_idk(answer): |
|
|
return {"score": 0.0, "action": "next_topic", "note": "student_idk", "feedback": ""} |
|
|
|
|
|
sys = "Оціни відповідь студента для однієї теми. Поверни ТІЛЬКИ JSON." |
|
|
user = { |
|
|
"topic": self.current_topic, |
|
|
"answer": answer, |
|
|
"json_schema": {"score": "0..10", "action": "ask_followup|next_topic", "feedback": "string"}, |
|
|
} |
|
|
|
|
|
try: |
|
|
raw = chat_completion( |
|
|
api_key=api_key, |
|
|
model=model, |
|
|
base_url=base_url, |
|
|
messages=[{"role": "system", "content": sys}, {"role": "user", "content": json.dumps(user, ensure_ascii=False)}], |
|
|
).strip() |
|
|
data = json.loads(raw) |
|
|
|
|
|
score = float(data.get("score", 0.0)) |
|
|
score = max(0.0, min(10.0, score)) |
|
|
|
|
|
action = str(data.get("action", "ask_followup")).strip() |
|
|
if action not in {"ask_followup", "next_topic"}: |
|
|
action = "ask_followup" |
|
|
|
|
|
feedback = str(data.get("feedback", "")).strip() |
|
|
return {"score": score, "action": action, "feedback": feedback} |
|
|
except Exception: |
|
|
length = len(answer.strip()) |
|
|
score = 3.0 if length < 80 else 6.0 if length < 250 else 7.5 |
|
|
action = "next_topic" if score >= 7.0 else "ask_followup" |
|
|
return {"score": score, "action": action, "feedback": "Оцінка приблизна (fallback)."} |
|
|
|
|
|
def _finalize(self) -> str: |
|
|
avg = round((sum(self.topic_scores.values()) / max(1, len(self.topic_scores))) if self.topic_scores else 0.0, 1) |
|
|
|
|
|
strong = [t for t, s in self.topic_scores.items() if s >= 7.0] |
|
|
weak = [t for t, s in self.topic_scores.items() if s < 7.0] |
|
|
|
|
|
feedback_lines = [] |
|
|
if strong: |
|
|
feedback_lines.append("Добре вийшло по темах: " + ", ".join(strong) + ".") |
|
|
if weak: |
|
|
feedback_lines.append("Варто підтягнути: " + ", ".join(weak) + ".") |
|
|
if not feedback_lines: |
|
|
feedback_lines.append("Дякую! Є над чим попрацювати — продовжуй практикуватися.") |
|
|
|
|
|
msg = f"Іспит завершено. Оцінка: {avg}/10.\n" + "\n".join(feedback_lines) |
|
|
self._log("system", msg) |
|
|
|
|
|
self._log("tool_call", f"end_exam(email={self.email}, score={avg}, history=[...])") |
|
|
end_exam(self.email or "", avg, [m.to_dict() for m in self.history]) |
|
|
|
|
|
self.stage = "finished" |
|
|
return msg |
|
|
|
|
|
def step(self, user_text: str, api_key: str, model: str, base_url: str) -> str: |
|
|
user_text = (user_text or "").strip() |
|
|
self._log("user", user_text) |
|
|
|
|
|
if self.stage == "collect_identity": |
|
|
if not self.name: |
|
|
self.name = user_text if user_text else None |
|
|
if not self.name: |
|
|
msg = "Як тебе звати?" |
|
|
self._log("system", msg) |
|
|
return msg |
|
|
msg = f"Приємно познайомитись, {self.name}! Тепер напиши свій email." |
|
|
self._log("system", msg) |
|
|
return msg |
|
|
|
|
|
if not self.email: |
|
|
m = EMAIL_RE.search(user_text) |
|
|
self.email = m.group(1) if m else None |
|
|
if not self.email: |
|
|
msg = "Не бачу коректного email. Спробуй ще раз (наприклад: name@example.com)." |
|
|
self._log("system", msg) |
|
|
return msg |
|
|
|
|
|
ok, start_msg = self._start_exam_tools() |
|
|
if not ok: |
|
|
self.email = None |
|
|
return start_msg |
|
|
|
|
|
self.stage = "exam" |
|
|
topic = self._next_topic() |
|
|
if not topic: |
|
|
return self._finalize() |
|
|
|
|
|
intro = f"Тема 1/{self.topics_total}: {topic}" |
|
|
self._log("system", intro) |
|
|
q = self._ask_question(api_key, model, base_url) |
|
|
return intro + "\n" + q |
|
|
|
|
|
if self.stage == "exam": |
|
|
if not self.current_topic: |
|
|
return self._finalize() |
|
|
|
|
|
eval_res = self._evaluate_answer(api_key, model, base_url, user_text) |
|
|
score = float(eval_res.get("score", 0.0)) |
|
|
action = str(eval_res.get("action", "ask_followup")) |
|
|
feedback = str(eval_res.get("feedback", "")).strip() |
|
|
|
|
|
prev = self.topic_scores.get(self.current_topic, 0.0) |
|
|
self.topic_scores[self.current_topic] = max(prev, score) |
|
|
|
|
|
idk = (eval_res.get("note") == "student_idk") |
|
|
too_many = self.questions_in_topic >= self.max_questions_per_topic |
|
|
good_enough = score >= 7.0 |
|
|
|
|
|
if idk or good_enough or too_many or action == "next_topic": |
|
|
note = f"Коментар: {feedback}\n" if feedback else "" |
|
|
next_t = self._next_topic() |
|
|
if not next_t: |
|
|
return note + self._finalize() |
|
|
|
|
|
done = len({t for t in self.topic_scores.keys()}) |
|
|
header = f"{note}Переходимо далі.\nТема {min(done+1, self.topics_total)}/{self.topics_total}: {next_t}" |
|
|
self._log("system", header) |
|
|
q = self._ask_question(api_key, model, base_url) |
|
|
return header + "\n" + q |
|
|
|
|
|
follow = "Ок. Уточню:" |
|
|
self._log("system", follow) |
|
|
q = self._ask_question(api_key, model, base_url) |
|
|
if feedback: |
|
|
return f"Коментар: {feedback}\n{follow}\n{q}" |
|
|
return f"{follow}\n{q}" |
|
|
|
|
|
msg = "Іспит уже завершено. Якщо хочеш — натисни Reset і почнемо заново." |
|
|
self._log("system", msg) |
|
|
return msg |