LLM / agent.py
renatavl's picture
init
3256847
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