933 lines
48 KiB
Python
933 lines
48 KiB
Python
"""
|
||
Сервис для AI проверки домашних заданий.
|
||
Поддерживает ИИ-агенты из БД (RouterAI и др. OpenAI-совместимые)
|
||
и fallback на OPENAI_API_KEY из настроек.
|
||
|
||
RouterAI: https://routerai.ru/docs/reference
|
||
OpenAI-совместимый: POST https://routerai.ru/api/v1/chat/completions
|
||
Модели: openai/gpt-4o-mini, anthropic/claude-3-5-sonnet и др. — см. https://routerai.ru/models
|
||
Авторизация: Bearer <API-ключ из https://routerai.ru/settings/keys>
|
||
Поддерживает multimodal (текст + base64 изображения).
|
||
"""
|
||
import base64
|
||
import io
|
||
import logging
|
||
import os
|
||
import subprocess
|
||
import tempfile
|
||
import requests
|
||
from django.conf import settings
|
||
from django.db.models import F
|
||
from typing import Dict, List, Optional, Tuple, Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Максимальный размер одного изображения для отправки в AI (байты)
|
||
MAX_IMAGE_SIZE = 8 * 1024 * 1024 # 8 МБ
|
||
# Расширения изображений, которые отправляем в запрос
|
||
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
|
||
# Расширения текстовых файлов: читаем содержимое и подставляем в промпт (задание/решение)
|
||
TEXT_EXTENSIONS = {'.txt', '.py', '.md', '.json', '.csv', '.xml', '.html', '.css', '.js', '.ts', '.jsx', '.tsx', '.c', '.cpp', '.h', '.java', '.kt', '.rs', '.go', '.rb', '.php', '.sql', '.yaml', '.yml', '.ini', '.cfg', '.log'}
|
||
# Максимальный размер текстового файла для чтения (байты)
|
||
MAX_TEXT_FILE_SIZE = 25 * 1024 * 1024 # 2 МБ
|
||
# Максимум символов из одного файла в промпт (чтобы не перегружать токены)
|
||
MAX_CHARS_PER_FILE = 800_000
|
||
|
||
|
||
def _read_file_text(file_path: str) -> Optional[str]:
|
||
"""
|
||
Читает текстовый файл (.txt, .py, .md и т.д.) и возвращает содержимое.
|
||
Кодировки: utf-8, затем cp1251, latin-1. Ограничение размера и длины — MAX_TEXT_FILE_SIZE, MAX_CHARS_PER_FILE.
|
||
"""
|
||
if not file_path or not os.path.isfile(file_path):
|
||
return None
|
||
ext = os.path.splitext(file_path)[1].lower()
|
||
if ext not in TEXT_EXTENSIONS:
|
||
return None
|
||
try:
|
||
size = os.path.getsize(file_path)
|
||
if size > MAX_TEXT_FILE_SIZE:
|
||
logger.warning("AI check: пропуск текстового файла (слишком большой %s bytes): %s", size, file_path)
|
||
return None
|
||
for encoding in ('utf-8', 'cp1251', 'latin-1'):
|
||
try:
|
||
with open(file_path, 'r', encoding=encoding) as f:
|
||
text = f.read(MAX_CHARS_PER_FILE + 1)
|
||
if len(text) > MAX_CHARS_PER_FILE:
|
||
text = text[:MAX_CHARS_PER_FILE] + "\n\n[... файл обрезан ...]"
|
||
return text
|
||
except UnicodeDecodeError:
|
||
continue
|
||
return None
|
||
except Exception as e:
|
||
logger.warning("AI check: не удалось прочитать текстовый файл %s: %s", file_path, e)
|
||
return None
|
||
|
||
|
||
def _extract_pdf_text_pymupdf(data: bytes) -> Optional[str]:
|
||
"""Fallback: извлечение текста из PDF через PyMuPDF (часто справляется там, где pypdf возвращает пусто)."""
|
||
if not data or len(data) > MAX_TEXT_FILE_SIZE:
|
||
return None
|
||
try:
|
||
import pymupdf
|
||
doc = pymupdf.open(stream=data, filetype="pdf")
|
||
parts = []
|
||
total = 0
|
||
for page in doc:
|
||
if total >= MAX_CHARS_PER_FILE:
|
||
parts.append("\n[... PDF обрезан ...]")
|
||
break
|
||
text = page.get_text() or ""
|
||
if total + len(text) > MAX_CHARS_PER_FILE:
|
||
text = text[: MAX_CHARS_PER_FILE - total]
|
||
parts.append(text)
|
||
total += len(text)
|
||
doc.close()
|
||
return "\n".join(parts).strip() or None
|
||
except ImportError:
|
||
logger.debug("AI check: pymupdf не установлен, fallback для PDF недоступен")
|
||
return None
|
||
except Exception as e:
|
||
logger.warning("AI check: PyMuPDF не смог извлечь текст из PDF: %s", e)
|
||
return None
|
||
|
||
|
||
def _extract_pdf_text_pdfminer(data: bytes) -> Optional[str]:
|
||
"""Извлечение текста из PDF через pdfminer.six (часто справляется с кодировками и структурой)."""
|
||
if not data or len(data) > MAX_TEXT_FILE_SIZE:
|
||
return None
|
||
try:
|
||
from pdfminer.high_level import extract_text
|
||
from pdfminer.layout import LAParams
|
||
text = extract_text(io.BytesIO(data), laparams=LAParams(line_margin=0.5, word_margin=0.1))
|
||
if not text or not text.strip():
|
||
return None
|
||
if len(text) > MAX_CHARS_PER_FILE:
|
||
text = text[:MAX_CHARS_PER_FILE] + "\n\n[... PDF обрезан ...]"
|
||
return text.strip()
|
||
except ImportError:
|
||
logger.debug("AI check: pdfminer.six не установлен")
|
||
return None
|
||
except Exception as e:
|
||
logger.warning("AI check: pdfminer исключение: %s", e)
|
||
return None
|
||
|
||
|
||
def _write_text_to_txt_and_read(text: str) -> Optional[str]:
|
||
"""Пишем текст во временный .txt и читаем обратно — результат «из файла»."""
|
||
if not text or not text.strip():
|
||
return None
|
||
try:
|
||
fd, path = tempfile.mkstemp(suffix=".txt", prefix="pdf2txt_", text=True)
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||
f.write(text[:MAX_CHARS_PER_FILE])
|
||
if len(text) > MAX_CHARS_PER_FILE:
|
||
f.write("\n\n[... обрезано ...]")
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
return f.read().strip() or None
|
||
finally:
|
||
try:
|
||
os.unlink(path)
|
||
except OSError:
|
||
pass
|
||
except Exception as e:
|
||
logger.warning("AI check: не удалось записать/прочитать .txt: %s", e)
|
||
return text.strip()
|
||
|
||
|
||
def _convert_pdf_to_txt_and_read(pdf_path: Optional[str] = None, pdf_bytes: Optional[bytes] = None) -> Optional[str]:
|
||
"""
|
||
Получаем PDF → сразу конвертируем в .txt файл → читаем текст из .txt.
|
||
Так текст всегда получаем из файла. Порядок: pdftotext (если есть) → pypdf → PyMuPDF → pdfminer.
|
||
"""
|
||
if pdf_path and not os.path.isfile(pdf_path):
|
||
pdf_path = None
|
||
if not pdf_path and not pdf_bytes:
|
||
return None
|
||
if pdf_bytes:
|
||
if len(pdf_bytes) > MAX_TEXT_FILE_SIZE:
|
||
return None
|
||
if len(pdf_bytes) >= 5 and pdf_bytes[:5] != b"%PDF-":
|
||
logger.warning("AI check: байты не похожи на PDF, len=%s", len(pdf_bytes))
|
||
return None
|
||
|
||
# Если только байты — пишем во временный PDF, дальше работаем с путём
|
||
temp_pdf_path = None
|
||
if not pdf_path and pdf_bytes:
|
||
try:
|
||
fd, temp_pdf_path = tempfile.mkstemp(suffix=".pdf")
|
||
os.close(fd)
|
||
with open(temp_pdf_path, "wb") as f:
|
||
f.write(pdf_bytes)
|
||
pdf_path = temp_pdf_path
|
||
except Exception as e:
|
||
logger.warning("AI check: не удалось записать PDF во временный файл: %s", e)
|
||
return None
|
||
|
||
txt_path = None
|
||
try:
|
||
# 1) pdftotext (poppler) — часто надёжнее библиотек
|
||
try:
|
||
fd, txt_path = tempfile.mkstemp(suffix=".txt", prefix="pdf2txt_", text=True)
|
||
os.close(fd)
|
||
r = subprocess.run(
|
||
["pdftotext", "-enc", "UTF-8", pdf_path, txt_path],
|
||
capture_output=True,
|
||
timeout=60,
|
||
cwd=os.path.dirname(pdf_path) or None,
|
||
)
|
||
if r.returncode == 0 and os.path.isfile(txt_path):
|
||
with open(txt_path, "r", encoding="utf-8", errors="replace") as f:
|
||
text = f.read(MAX_CHARS_PER_FILE + 1)
|
||
if len(text) > MAX_CHARS_PER_FILE:
|
||
text = text[:MAX_CHARS_PER_FILE] + "\n\n[... обрезано ...]"
|
||
if text.strip():
|
||
logger.info("AI check: pdftotext сконвертировал PDF в .txt, прочитано %s символов", len(text))
|
||
return text.strip()
|
||
logger.info("AI check: pdftotext вернул код 0, но .txt пустой (возможно скан без текстового слоя)")
|
||
else:
|
||
stderr = (r.stderr or b"").decode("utf-8", errors="replace").strip() if r.stderr else ""
|
||
logger.warning("AI check: pdftotext не сработал returncode=%s stderr=%s", r.returncode, stderr[:500])
|
||
except FileNotFoundError:
|
||
logger.warning("AI check: pdftotext не найден в PATH (в Docker установите poppler-utils)")
|
||
except Exception as e:
|
||
logger.warning("AI check: pdftotext исключение: %s", e)
|
||
finally:
|
||
if txt_path and os.path.isfile(txt_path):
|
||
try:
|
||
os.unlink(txt_path)
|
||
except OSError:
|
||
pass
|
||
txt_path = None
|
||
|
||
# 2) Извлечение библиотеками и запись в .txt — пробуем все по очереди
|
||
with open(pdf_path, "rb") as f:
|
||
data = f.read()
|
||
logger.info("AI check: PDF прочитан с диска, %s bytes, пробуем извлечь текст", len(data))
|
||
raw_text = None
|
||
# pdfminer часто лучше для PDF с кириллицей и сложной вёрсткой
|
||
raw_text = _extract_pdf_text_pdfminer(data)
|
||
if raw_text and raw_text.strip():
|
||
logger.info("AI check: pdfminer извлёк %s символов", len(raw_text))
|
||
else:
|
||
logger.info("AI check: pdfminer вернул пустой текст")
|
||
if not (raw_text and raw_text.strip()):
|
||
raw_text = _extract_pdf_text_pymupdf(data)
|
||
if raw_text and raw_text.strip():
|
||
logger.info("AI check: PyMuPDF извлёк %s символов", len(raw_text))
|
||
else:
|
||
logger.info("AI check: PyMuPDF вернул пустой текст")
|
||
if not (raw_text and raw_text.strip()):
|
||
try:
|
||
from pypdf import PdfReader
|
||
reader = PdfReader(io.BytesIO(data))
|
||
parts = []
|
||
total = 0
|
||
for page in reader.pages:
|
||
if total >= MAX_CHARS_PER_FILE:
|
||
break
|
||
t = page.extract_text() or ""
|
||
if total + len(t) > MAX_CHARS_PER_FILE:
|
||
t = t[: MAX_CHARS_PER_FILE - total]
|
||
parts.append(t)
|
||
total += len(t)
|
||
raw_text = "\n".join(parts).strip() or None
|
||
if raw_text:
|
||
logger.info("AI check: pypdf извлёк %s символов", len(raw_text))
|
||
else:
|
||
logger.info("AI check: pypdf вернул пустой текст (страниц: %s)", len(reader.pages))
|
||
except Exception as e:
|
||
logger.warning("AI check: pypdf исключение: %s", e)
|
||
if not (raw_text and raw_text.strip()):
|
||
logger.warning(
|
||
"AI check: ни один метод не извлек текст. Возможные причины: PDF — скан (нет текстового слоя), защищён, или нестандартная кодировка. Путь: %s",
|
||
pdf_path,
|
||
)
|
||
return None
|
||
# Конверт: пишем в .txt и читаем из файла
|
||
return _write_text_to_txt_and_read(raw_text)
|
||
finally:
|
||
if temp_pdf_path and os.path.isfile(temp_pdf_path):
|
||
try:
|
||
os.unlink(temp_pdf_path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _resolve_media_path(file_path: str) -> Optional[str]:
|
||
"""Проверяем путь к файлу; если не найден и путь относительный — пробуем MEDIA_ROOT (для .py, .txt, PDF и т.д.)."""
|
||
if not file_path:
|
||
return None
|
||
if os.path.isfile(file_path):
|
||
return file_path
|
||
try:
|
||
media = getattr(settings, "MEDIA_ROOT", None) or ""
|
||
if not media or os.path.isabs(file_path):
|
||
return None
|
||
base = file_path.lstrip("/")
|
||
for candidate in (
|
||
os.path.normpath(os.path.join(media, base)),
|
||
os.path.normpath(os.path.join(media, os.path.basename(file_path))),
|
||
):
|
||
if os.path.isfile(candidate):
|
||
return candidate
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _resolve_pdf_path(file_path: str) -> Optional[str]:
|
||
"""Проверяем путь к PDF; если относительный и файл не найден — пробуем MEDIA_ROOT."""
|
||
return _resolve_media_path(file_path)
|
||
|
||
|
||
def _read_pdf_text(file_path: str) -> Optional[str]:
|
||
"""PDF по пути → конверт в .txt → читаем текст из .txt. Всегда пробуем извлечь текст."""
|
||
resolved = _resolve_pdf_path(file_path)
|
||
if not resolved:
|
||
logger.warning("AI check: PDF файл не найден: %s", file_path)
|
||
return None
|
||
file_path = resolved
|
||
if os.path.splitext(file_path)[1].lower() != ".pdf":
|
||
return None
|
||
try:
|
||
size = os.path.getsize(file_path)
|
||
logger.info("AI check: читаем PDF path=%s size=%s", file_path, size)
|
||
if size > MAX_TEXT_FILE_SIZE:
|
||
logger.warning("AI check: пропуск PDF (слишком большой): %s", file_path)
|
||
return None
|
||
return _convert_pdf_to_txt_and_read(pdf_path=file_path)
|
||
except Exception as e:
|
||
logger.warning("AI check: не удалось прочитать PDF %s: %s", file_path, e)
|
||
return None
|
||
|
||
|
||
# Magic bytes для определения PDF по содержимому (если имя файла без расширения)
|
||
PDF_MAGIC = b"%PDF-"
|
||
|
||
|
||
def _extract_pdf_text_from_bytes(data: bytes) -> Optional[str]:
|
||
"""PDF из байтов → конверт в .txt → читаем текст из .txt."""
|
||
if not data or len(data) > MAX_TEXT_FILE_SIZE:
|
||
return None
|
||
if len(data) >= 5 and data[:5] != PDF_MAGIC:
|
||
logger.warning("AI check: байты не похожи на PDF, len=%s", len(data))
|
||
return None
|
||
logger.info("AI check: конвертируем PDF в .txt, размер %s bytes", len(data))
|
||
return _convert_pdf_to_txt_and_read(pdf_bytes=data)
|
||
|
||
|
||
# Подпись для пустых или нечитаемых файлов, чтобы AI видел, что файл приложен, и мог попросить добавить код
|
||
_EMPTY_OR_UNREADABLE_PLACEHOLDER = "(файл пустой или не удалось прочитать содержимое — попроси студента добавить код/текст в файл)"
|
||
|
||
|
||
def _read_file_content_for_ai(file_path: str) -> Optional[Tuple[str, str]]:
|
||
"""
|
||
Возвращает (имя_файла, содержимое) для вставки в промпт.
|
||
Если файл пустой или не удалось прочитать — возвращает (имя, подпись), чтобы AI видел приложенный файл.
|
||
Изображения не возвращаются (идут в multimodal).
|
||
"""
|
||
# Разрешаем путь через MEDIA_ROOT при необходимости (PDF, .py, .txt и т.д.)
|
||
if file_path:
|
||
file_path = _resolve_media_path(file_path) or file_path
|
||
if not file_path or not os.path.isfile(file_path):
|
||
return None
|
||
ext = os.path.splitext(file_path)[1].lower()
|
||
name = os.path.basename(file_path)
|
||
if ext in IMAGE_EXTENSIONS:
|
||
return None # изображения обрабатываются отдельно
|
||
if ext == '.pdf':
|
||
content = _read_pdf_text(file_path)
|
||
elif ext in TEXT_EXTENSIONS:
|
||
content = _read_file_text(file_path)
|
||
else:
|
||
content = _read_file_text(file_path) # попробовать как текст
|
||
if content and content.strip():
|
||
return (name, content.strip())
|
||
# Файл пустой или не удалось прочитать — всё равно добавляем в промпт с подписью
|
||
return (name, _EMPTY_OR_UNREADABLE_PLACEHOLDER)
|
||
|
||
|
||
def _read_image_as_data_url(file_path: str) -> Optional[str]:
|
||
"""
|
||
Читает файл с диска и возвращает data URL (data:image/...;base64,...) для изображений.
|
||
Возвращает None, если файл не изображение, слишком большой или ошибка чтения.
|
||
"""
|
||
if not file_path or not os.path.isfile(file_path):
|
||
return None
|
||
ext = os.path.splitext(file_path)[1].lower()
|
||
if ext not in IMAGE_EXTENSIONS:
|
||
return None
|
||
try:
|
||
size = os.path.getsize(file_path)
|
||
if size > MAX_IMAGE_SIZE:
|
||
logger.warning("AI check: пропуск изображения (слишком большой %s bytes): %s", size, file_path)
|
||
return None
|
||
with open(file_path, 'rb') as f:
|
||
data = f.read()
|
||
b64 = base64.b64encode(data).decode('ascii')
|
||
mime = 'image/jpeg' if ext in ('.jpg', '.jpeg') else f'image/{ext[1:]}' # png, gif, webp, bmp
|
||
return f"data:{mime};base64,{b64}"
|
||
except Exception as e:
|
||
logger.warning("AI check: не удалось прочитать изображение %s: %s", file_path, e)
|
||
return None
|
||
|
||
|
||
def _get_default_agent():
|
||
"""Получить агент по умолчанию для проверки ДЗ (из БД)."""
|
||
try:
|
||
from .models import HomeworkAIAgent
|
||
return HomeworkAIAgent.objects.filter(is_default=True, is_active=True).first()
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _get_client_and_model():
|
||
"""
|
||
Возвращает (client, model_name, agent) для проверки ДЗ.
|
||
agent может быть None при fallback на OPENAI_*.
|
||
Лимиты и параметры генерации задаются у провайдера (RouterAI и др.); в запросе передаём только промпт (messages).
|
||
Приоритет: агент из БД (is_default=True) → настройки OPENAI_*.
|
||
"""
|
||
agent = _get_default_agent()
|
||
if agent:
|
||
api_key = (agent.api_key or '').strip() or getattr(settings, 'HOMEWORK_AI_API_KEY', None) or getattr(settings, 'OPENAI_API_KEY', None)
|
||
if not api_key:
|
||
logger.warning("ИИ-агент '%s' выбран, но API ключ не задан (агент.api_key или HOMEWORK_AI_API_KEY).", agent.name)
|
||
return None, None, None
|
||
try:
|
||
from openai import OpenAI
|
||
base_url = agent.get_base_url()
|
||
if not base_url:
|
||
logger.warning("ИИ-агент '%s': openai_url пустой.", agent.name)
|
||
return None, None, None
|
||
auth_header = getattr(agent, 'auth_header', None) or 'Bearer'
|
||
client_kwargs = {'base_url': base_url, 'api_key': api_key}
|
||
if auth_header == 'X-API-Key':
|
||
client_kwargs['default_headers'] = {'X-API-Key': api_key}
|
||
client = OpenAI(**client_kwargs)
|
||
return client, agent.model_name, agent
|
||
except ImportError:
|
||
logger.error("OpenAI библиотека не установлена. Установите: pip install openai")
|
||
return None, None, None
|
||
# Fallback: настройки OpenAI
|
||
api_key = getattr(settings, 'OPENAI_API_KEY', None)
|
||
model = getattr(settings, 'OPENAI_MODEL', 'gpt-4o-mini')
|
||
if not api_key:
|
||
logger.warning("OPENAI_API_KEY не установлен. AI проверка недоступна.")
|
||
return None, None, None
|
||
try:
|
||
from openai import OpenAI
|
||
client = OpenAI(api_key=api_key)
|
||
return client, model, None
|
||
except ImportError:
|
||
logger.error("OpenAI библиотека не установлена. Установите: pip install openai")
|
||
return None, None, None
|
||
|
||
|
||
def _normalize_usage(data: Any) -> Optional[Dict[str, int]]:
|
||
"""
|
||
Извлечь usage (prompt_tokens, completion_tokens, total_tokens) из ответа API.
|
||
Поддерживает OpenAI-формат (usage.prompt_tokens и т.д.) и варианты (usage.total_tokens, tokens).
|
||
"""
|
||
if not data:
|
||
return None
|
||
usage = None
|
||
if isinstance(data, dict):
|
||
usage = data.get('usage')
|
||
elif hasattr(data, 'usage'):
|
||
usage = getattr(data, 'usage', None)
|
||
if usage is None:
|
||
return None
|
||
if hasattr(usage, 'prompt_tokens'):
|
||
pt = getattr(usage, 'prompt_tokens', None)
|
||
ct = getattr(usage, 'completion_tokens', None)
|
||
tt = getattr(usage, 'total_tokens', None)
|
||
elif isinstance(usage, dict):
|
||
pt = usage.get('prompt_tokens')
|
||
ct = usage.get('completion_tokens')
|
||
tt = usage.get('total_tokens')
|
||
else:
|
||
return None
|
||
try:
|
||
prompt_tokens = int(pt) if pt is not None else 0
|
||
completion_tokens = int(ct) if ct is not None else 0
|
||
total_tokens = int(tt) if tt is not None else (prompt_tokens + completion_tokens)
|
||
return {
|
||
'prompt_tokens': prompt_tokens,
|
||
'completion_tokens': completion_tokens,
|
||
'total_tokens': total_tokens or (prompt_tokens + completion_tokens),
|
||
}
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _get_agent_for_usage_stats():
|
||
"""
|
||
Агент, которому приписать использование: по умолчанию тот, у кого is_default=True;
|
||
если такого нет — первый активный (чтобы статистика обновлялась в любом случае).
|
||
"""
|
||
from .models import HomeworkAIAgent
|
||
agent = HomeworkAIAgent.objects.filter(is_default=True, is_active=True).first()
|
||
if agent:
|
||
return agent
|
||
return HomeworkAIAgent.objects.filter(is_active=True).order_by('order', 'name').first()
|
||
|
||
|
||
def _increment_agent_usage(agent, usage: Optional[Dict[str, int]] = None):
|
||
"""Увеличить счётчик использований агента на 1 и при необходимости накопить токены."""
|
||
if not agent or not getattr(agent, 'pk', None):
|
||
return
|
||
try:
|
||
from .models import HomeworkAIAgent
|
||
# Сначала только usage_count, чтобы не падать при отсутствии полей токенов (до миграции)
|
||
updated = HomeworkAIAgent.objects.filter(pk=agent.pk).update(usage_count=F('usage_count') + 1)
|
||
if not updated:
|
||
logger.warning("HomeworkAIAgent id=%s update usage_count matched 0 rows", agent.pk)
|
||
return
|
||
logger.info("HomeworkAIAgent id=%s usage_count += 1, tokens=%s", agent.pk, usage or 'n/a')
|
||
# Токены — отдельным update (поля могут отсутствовать до миграции 0015)
|
||
if usage and (usage.get('prompt_tokens') or usage.get('completion_tokens')):
|
||
field_names = {f.name for f in HomeworkAIAgent._meta.get_fields()}
|
||
if 'total_prompt_tokens' in field_names and 'total_completion_tokens' in field_names:
|
||
HomeworkAIAgent.objects.filter(pk=agent.pk).update(
|
||
total_prompt_tokens=F('total_prompt_tokens') + usage.get('prompt_tokens', 0),
|
||
total_completion_tokens=F('total_completion_tokens') + usage.get('completion_tokens', 0),
|
||
)
|
||
except Exception as e:
|
||
logger.exception("Ошибка обновления usage_count для агента %s: %s", getattr(agent, 'pk'), e)
|
||
|
||
|
||
class AICheckingService:
|
||
"""Сервис для автоматической проверки домашних заданий через ИИ (агенты из БД или OpenAI)."""
|
||
|
||
def __init__(self):
|
||
pass
|
||
|
||
def check_submission(
|
||
self,
|
||
homework_title: str,
|
||
homework_description: str,
|
||
homework_max_score: int,
|
||
submission_content: str,
|
||
submission_files: list = None,
|
||
homework_files: list = None,
|
||
homework_file_paths: list = None,
|
||
submission_file_paths: list = None,
|
||
homework_file_contents: list = None,
|
||
submission_file_contents: list = None,
|
||
student_name: Optional[str] = None,
|
||
) -> Dict[str, any]:
|
||
"""
|
||
Проверка решения домашнего задания через AI.
|
||
|
||
Отправляет: задание (текст + прикреплённые файлы/изображения если есть),
|
||
решение (текст + прикреплённые файлы/изображения если есть).
|
||
В ответ: комментарий и оценка 1–5.
|
||
|
||
Args:
|
||
homework_file_paths: Пути к файлам задания на диске (для чтения текста и изображений)
|
||
submission_file_paths: Пути к файлам решения на диске
|
||
homework_file_contents: [(имя_файла, содержимое_str_or_bytes), ...] — когда path недоступен (S3 и т.д.)
|
||
submission_file_contents: [(имя_файла, содержимое_str_or_bytes), ...]
|
||
student_name: Имя ученика — добавляется в начало промпта (например «Имя ученика: Кирилл»).
|
||
"""
|
||
agent = _get_default_agent()
|
||
has_submission_files = bool(submission_file_paths or submission_file_contents or submission_files)
|
||
if not submission_content and not submission_files and not has_submission_files:
|
||
return {
|
||
'success': False,
|
||
'error': 'Решение пустое. Нет текста или файлов для проверки.'
|
||
}
|
||
|
||
homework_file_paths = homework_file_paths or []
|
||
submission_file_paths = submission_file_paths or []
|
||
homework_file_contents = homework_file_contents or []
|
||
submission_file_contents = submission_file_contents or []
|
||
|
||
# ─── Фаза 1: сначала полностью извлекаем текст из всех файлов, только потом отправляем в AI ───
|
||
logger.info(
|
||
"AI check: фаза 1 — извлечение текста из файлов (homework paths=%s, contents=%s, submission paths=%s, contents=%s)",
|
||
len(homework_file_paths), len(homework_file_contents), len(submission_file_paths), len(submission_file_contents),
|
||
)
|
||
|
||
def _normalize_content(raw: Any, filename: str = "") -> str:
|
||
if raw is None:
|
||
return ""
|
||
if isinstance(raw, bytes):
|
||
# .py и код обычно в UTF-8; убираем BOM при наличии
|
||
data = raw
|
||
if data.startswith(b"\xef\xbb\xbf"):
|
||
data = data[3:]
|
||
for enc in ("utf-8", "cp1251", "latin-1"):
|
||
try:
|
||
return data.decode(enc)
|
||
except UnicodeDecodeError:
|
||
continue
|
||
return data.decode("utf-8", errors="replace")
|
||
return str(raw)
|
||
|
||
def _content_to_text(filename: str, content: Any) -> str:
|
||
"""Для файлов из S3/contents: PDF из байтов извлекаем через pypdf+PyMuPDF, остальное (.py, .txt и т.д.) — как текст."""
|
||
if content is None:
|
||
return ""
|
||
is_pdf = (filename or "").lower().endswith(".pdf") or (
|
||
isinstance(content, bytes) and len(content) >= 5 and content[:5] == PDF_MAGIC
|
||
)
|
||
if isinstance(content, bytes) and is_pdf:
|
||
extracted = _extract_pdf_text_from_bytes(content)
|
||
return (extracted or "").strip()
|
||
return _normalize_content(content, filename)
|
||
|
||
homework_file_texts: List[Tuple[str, str]] = []
|
||
for name, content in homework_file_contents:
|
||
text = _content_to_text(name, content)
|
||
if text.strip():
|
||
homework_file_texts.append((name, text.strip()))
|
||
else:
|
||
homework_file_texts.append((name, _EMPTY_OR_UNREADABLE_PLACEHOLDER))
|
||
for path in homework_file_paths:
|
||
item = _read_file_content_for_ai(path)
|
||
if item and not any(n == item[0] for n, _ in homework_file_texts):
|
||
homework_file_texts.append(item)
|
||
submission_file_texts: List[Tuple[str, str]] = []
|
||
for name, content in submission_file_contents:
|
||
text = _content_to_text(name, content)
|
||
if text.strip():
|
||
submission_file_texts.append((name, text.strip()))
|
||
if (name or "").lower().endswith(".py"):
|
||
logger.info("AI check: файл решения .py из contents «%s» — добавлен, %s символов", name, len(text))
|
||
else:
|
||
submission_file_texts.append((name, _EMPTY_OR_UNREADABLE_PLACEHOLDER))
|
||
for path in submission_file_paths:
|
||
item = _read_file_content_for_ai(path)
|
||
if item and not any(n == item[0] for n, _ in submission_file_texts):
|
||
submission_file_texts.append(item)
|
||
name, text = item
|
||
if (name or "").lower().endswith(".py") and text != _EMPTY_OR_UNREADABLE_PLACEHOLDER:
|
||
logger.info("AI check: файл решения .py по пути «%s» — добавлен, %s символов", name, len(text))
|
||
|
||
# Фаза 1 завершена: извлечение из всех файлов выполнено. Дальше только формируем промпт и отправляем.
|
||
logger.info(
|
||
"AI check: фаза 1 завершена — задание: %s файлов (текст есть у %s), решение: %s файлов (текст есть у %s). Формируем промпт и отправляем в AI.",
|
||
len(homework_file_texts),
|
||
sum(1 for _, t in homework_file_texts if t != _EMPTY_OR_UNREADABLE_PLACEHOLDER),
|
||
len(submission_file_texts),
|
||
sum(1 for _, t in submission_file_texts if t != _EMPTY_OR_UNREADABLE_PLACEHOLDER),
|
||
)
|
||
|
||
# ─── Фаза 2: извлечение завершено; проверяем unreadable, затем формируем промпт и только потом отправляем в AI ───
|
||
# Если не удалось извлечь текст из файлов — не вызываем AI, возвращаем черновик с сообщением
|
||
homework_has_files = bool(homework_file_texts)
|
||
homework_all_unreadable = homework_has_files and all(
|
||
text == _EMPTY_OR_UNREADABLE_PLACEHOLDER for _, text in homework_file_texts
|
||
)
|
||
submission_has_files = bool(submission_file_texts)
|
||
submission_all_unreadable = submission_has_files and all(
|
||
text == _EMPTY_OR_UNREADABLE_PLACEHOLDER for _, text in submission_file_texts
|
||
)
|
||
|
||
if (
|
||
homework_has_files and homework_all_unreadable and not (homework_description or "").strip()
|
||
and submission_has_files and submission_all_unreadable and not (submission_content or "").strip()
|
||
):
|
||
logger.info("AI check: не удалось прочитать задание и решение, запрос к AI не отправляем")
|
||
return {
|
||
'success': True,
|
||
'score': None,
|
||
'feedback': 'Не удалось прочитать задание и решение. Добавьте условие и решение в текстовом виде или в читаемых файлах (.txt, PDF с текстом, не сканы).',
|
||
'skipped_reason': 'unreadable_both',
|
||
}
|
||
if homework_has_files and homework_all_unreadable and not (homework_description or "").strip():
|
||
logger.info("AI check: не удалось прочитать задание (все файлы пустые/нечитаемые), запрос к AI не отправляем")
|
||
return {
|
||
'success': True,
|
||
'score': None,
|
||
'feedback': 'Не удалось прочитать задание. Добавьте условие текстом в описание или приложите файл .txt или PDF с извлекаемым текстом (не скан).',
|
||
'skipped_reason': 'unreadable_assignment',
|
||
}
|
||
if submission_has_files and submission_all_unreadable and not (submission_content or "").strip():
|
||
logger.info("AI check: не удалось прочитать решение (все файлы пустые/нечитаемые), запрос к AI не отправляем")
|
||
return {
|
||
'success': True,
|
||
'score': None,
|
||
'feedback': 'Не удалось прочитать решение. Попросите студента добавить текст решения в поле «Моё решение» или приложить файл .txt / PDF с извлекаемым текстом.',
|
||
'skipped_reason': 'unreadable_submission',
|
||
}
|
||
|
||
# Формируем промпт для AI (оценка 1–5). Режим разработки задаётся на агенте, по умолчанию выключен.
|
||
dev_mode = getattr(agent, 'dev_mode', False) if agent else False
|
||
# Имя ученика передаётся только в системный промпт (добавляется к вашему промпту из админки)
|
||
prompt = self._build_prompt(
|
||
homework_title=homework_title,
|
||
homework_description=homework_description,
|
||
homework_max_score=min(homework_max_score, 5),
|
||
submission_content=submission_content,
|
||
submission_files=submission_files or [],
|
||
homework_files=homework_files or [],
|
||
homework_file_texts=homework_file_texts,
|
||
submission_file_texts=submission_file_texts,
|
||
dev_mode=dev_mode,
|
||
)
|
||
|
||
# Собираем изображения для multimodal: при наличии путей используем chat/completions с картинками
|
||
image_data_urls: List[str] = []
|
||
for path in homework_file_paths:
|
||
url = _read_image_as_data_url(path)
|
||
if url:
|
||
image_data_urls.append(url)
|
||
for path in submission_file_paths:
|
||
url = _read_image_as_data_url(path)
|
||
if url:
|
||
image_data_urls.append(url)
|
||
has_images = bool(image_data_urls)
|
||
# Вариант 2: всегда отправляем изображения как base64 data URL в chat/completions (multimodal)
|
||
use_multimodal = has_images
|
||
if has_images:
|
||
logger.info(
|
||
"AI check: загружаем %s изображений в запрос (base64 data URL, multimodal chat/completions)",
|
||
len(image_data_urls),
|
||
)
|
||
|
||
try:
|
||
# OpenAI-совместимый API (chat/completions), в т.ч. multimodal (текст + изображения). RouterAI и др.
|
||
client, model_name, used_agent = _get_client_and_model()
|
||
if not client or not model_name:
|
||
return {
|
||
'success': False,
|
||
'error': 'AI проверка недоступна. Добавьте ИИ-агента в админке (ДЗ → ИИ-агенты) или задайте OPENAI_API_KEY.'
|
||
}
|
||
# used_agent — агент, через которого реально пошёл запрос (при fallback на OPENAI_* он None)
|
||
# Контент пользователя: текст или multimodal (текст + изображения)
|
||
user_content: Any
|
||
if use_multimodal and image_data_urls:
|
||
user_content = [{'type': 'text', 'text': prompt}]
|
||
for data_url in image_data_urls:
|
||
user_content.append({'type': 'image_url', 'image_url': {'url': data_url}})
|
||
logger.info(
|
||
"AI check (chat/completions multimodal): model=%s prompt_len=%s images=%s",
|
||
model_name, len(prompt), len(image_data_urls)
|
||
)
|
||
else:
|
||
user_content = prompt
|
||
logger.info(
|
||
"AI check (chat/completions): model=%s prompt_len=%s prompt_preview=%s",
|
||
model_name, len(prompt), (prompt[:400] + '...' if len(prompt) > 400 else prompt)
|
||
)
|
||
# Системный промпт: из агента или встроенный по умолчанию
|
||
default_system = (
|
||
'Ты опытный преподаватель, который проверяет домашние задания студентов. '
|
||
'Твоя задача — объективно оценить работу, указать на ошибки и дать конструктивную обратную связь. '
|
||
'Оценка должна быть справедливой и мотивированной.'
|
||
)
|
||
system_content = (getattr(used_agent, 'system_prompt', None) or '').strip() if used_agent else ''
|
||
if not system_content:
|
||
system_content = default_system
|
||
dev_mode = getattr(used_agent, 'dev_mode', False) if used_agent else False
|
||
if dev_mode:
|
||
system_content += (
|
||
' Режим отладки: в комментарии обязательно опиши содержимое каждого приложенного изображения '
|
||
'(что на фото/скриншоте/рисунке, какой текст виден), затем дай оценку и отзыв по заданию.'
|
||
)
|
||
# Имя ученика (кто отправил ДЗ) в начало системного промпта
|
||
display_name = (str(student_name).strip() if student_name else "") or ""
|
||
if display_name:
|
||
system_content = f"Имя ученика: {display_name}\n\n{system_content}"
|
||
logger.info("AI check: в системный промпт добавлено имя ученика: %r", display_name[:80])
|
||
|
||
# Параметры генерации из агента (влияют на ответ модели)
|
||
extra_kwargs = {}
|
||
if used_agent:
|
||
t = getattr(used_agent, 'temperature', None)
|
||
if t is not None:
|
||
extra_kwargs['temperature'] = float(t)
|
||
p = getattr(used_agent, 'top_p', None)
|
||
if p is not None:
|
||
extra_kwargs['top_p'] = float(p)
|
||
mt = getattr(used_agent, 'max_tokens', None)
|
||
if mt is not None:
|
||
extra_kwargs['max_tokens'] = int(mt)
|
||
response = client.chat.completions.create(
|
||
model=model_name,
|
||
messages=[
|
||
{'role': 'system', 'content': system_content},
|
||
{'role': 'user', 'content': user_content}
|
||
],
|
||
**extra_kwargs
|
||
)
|
||
ai_response = response.choices[0].message.content.strip()
|
||
usage = _normalize_usage(getattr(response, 'usage', None))
|
||
logger.info(
|
||
"AI check (chat/completions) response: status=ok response_len=%s usage=%s",
|
||
len(ai_response), usage
|
||
)
|
||
max_for_parse = min(homework_max_score, 5)
|
||
score, feedback = self._parse_ai_response(ai_response, max_for_parse)
|
||
score = max(1, min(5, score))
|
||
agent_for_stats = _get_agent_for_usage_stats()
|
||
_increment_agent_usage(agent_for_stats, usage)
|
||
from .utils import feedback_to_html
|
||
result = {
|
||
'success': True,
|
||
'score': score,
|
||
'feedback': feedback,
|
||
'feedback_html': feedback_to_html(feedback),
|
||
'raw_response': ai_response
|
||
}
|
||
if usage:
|
||
result['usage'] = usage
|
||
return result
|
||
except requests.RequestException as e:
|
||
resp = getattr(e, 'response', None)
|
||
if resp is not None:
|
||
try:
|
||
err_body = resp.json()
|
||
msg = err_body.get('message') or err_body.get('error', {}).get('message') or resp.text[:300]
|
||
except Exception:
|
||
msg = resp.text[:300] if getattr(resp, 'text', None) else str(e)
|
||
if resp.status_code == 403:
|
||
return {
|
||
'success': False,
|
||
'error': f'403 Доступ запрещён. RouterAI: проверьте ключ и баланс на routerai.ru/settings. {msg}'
|
||
}
|
||
else:
|
||
msg = str(e)
|
||
logger.error("Ошибка AI проверки (request): %s", msg, exc_info=True)
|
||
return {'success': False, 'error': f'Ошибка при проверке через AI: {msg}'}
|
||
except Exception as e:
|
||
logger.error(f"Ошибка AI проверки: {str(e)}", exc_info=True)
|
||
return {
|
||
'success': False,
|
||
'error': f'Ошибка при проверке через AI: {str(e)}'
|
||
}
|
||
|
||
def _build_prompt(
|
||
self,
|
||
homework_title: str,
|
||
homework_description: str,
|
||
homework_max_score: int,
|
||
submission_content: str,
|
||
submission_files: list = None,
|
||
homework_files: list = None,
|
||
homework_file_texts: list = None,
|
||
submission_file_texts: list = None,
|
||
dev_mode: bool = False,
|
||
) -> str:
|
||
"""Построение промпта для AI: задание, решение и содержимое приложенных файлов (PDF, .txt, .py и т.д.). Имя ученика добавляется в системный промпт при формировании запроса."""
|
||
homework_file_texts = homework_file_texts or []
|
||
submission_file_texts = submission_file_texts or []
|
||
|
||
parts = [
|
||
"ЗАДАНИЕ:",
|
||
f"Название: {homework_title}",
|
||
f"Описание: {homework_description or 'Нет текста.'}",
|
||
]
|
||
for name, content in homework_file_texts:
|
||
parts.append(f"\n--- Файл задания «{name}» ---\n{content}")
|
||
parts.append("\nРЕШЕНИЕ СТУДЕНТА:")
|
||
parts.append(submission_content or "Нет текста.")
|
||
for name, content in submission_file_texts:
|
||
parts.append(f"\n--- Файл решения «{name}» ---\n{content}")
|
||
parts.append(
|
||
"\n\n--- ИНСТРУКЦИЯ ДЛЯ ОТВЕТА ---\n"
|
||
"Ответь строго в два поля.\n"
|
||
"1) ОЦЕНКА: [целое число от 1 до 5].\n"
|
||
"2) КОММЕНТАРИЙ: [развёрнутый комментарий для студента на русском]. "
|
||
"Пиши полноценный отзыв: что сделано хорошо, что улучшить, конкретные замечания по коду или решению. "
|
||
"Не используй краткие формулировки вроде «Совпало: да», «Верно: да» — только развёрнутый текст в поле КОММЕНТАРИЙ."
|
||
)
|
||
if dev_mode:
|
||
parts.append(
|
||
"\n\n[РЕЖИМ ОТЛАДКИ] В комментарии обязательно опиши, что изображено на каждом приложенном изображении (фото, скриншот, рисунок): что на них видно, текст если есть, структура. Начни с описания изображений, затем дай оценку и общий комментарий по заданию."
|
||
)
|
||
return "\n".join(parts)
|
||
|
||
def _parse_ai_response(self, response: str, max_score: int) -> Tuple[int, str]:
|
||
"""
|
||
Парсинг ответа AI.
|
||
|
||
Returns:
|
||
tuple: (score, feedback)
|
||
"""
|
||
try:
|
||
# Ищем оценку
|
||
score = None
|
||
feedback = ""
|
||
|
||
lines = response.split('\n')
|
||
in_comment = False
|
||
|
||
for line in lines:
|
||
line = line.strip()
|
||
|
||
# Ищем оценку
|
||
if line.startswith('ОЦЕНКА:') or 'ОЦЕНКА:' in line:
|
||
try:
|
||
# Извлекаем число
|
||
parts = line.split('ОЦЕНКА:')
|
||
if len(parts) > 1:
|
||
score_str = parts[1].strip().split()[0]
|
||
score = int(score_str)
|
||
except (ValueError, IndexError):
|
||
pass
|
||
|
||
# Ищем комментарий
|
||
if line.startswith('КОММЕНТАРИЙ:') or 'КОММЕНТАРИЙ:' in line:
|
||
in_comment = True
|
||
# Извлекаем текст после "КОММЕНТАРИЙ:"
|
||
parts = line.split('КОММЕНТАРИЙ:')
|
||
if len(parts) > 1:
|
||
feedback += parts[1].strip() + '\n'
|
||
continue
|
||
|
||
if in_comment:
|
||
feedback += line + '\n'
|
||
|
||
# Если не нашли оценку, пытаемся найти число в начале ответа
|
||
if score is None:
|
||
for line in lines[:5]: # Проверяем первые 5 строк
|
||
try:
|
||
# Ищем первое число
|
||
words = line.split()
|
||
for word in words:
|
||
if word.isdigit():
|
||
score = int(word)
|
||
if 0 <= score <= max_score:
|
||
break
|
||
if score is not None:
|
||
break
|
||
except:
|
||
pass
|
||
|
||
# Если все еще не нашли, используем среднее значение (3 по шкале 1–5)
|
||
if score is None:
|
||
score = 3
|
||
logger.warning("Не удалось извлечь оценку из ответа AI. Используется 3.")
|
||
# Ограничиваем оценку 1–5
|
||
score = max(1, min(score, min(max_score, 5)))
|
||
|
||
# Если не нашли комментарий, используем весь ответ
|
||
if not feedback.strip():
|
||
feedback = response
|
||
|
||
return score, feedback.strip()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка парсинга ответа AI: {str(e)}")
|
||
# Возвращаем среднее значение и весь ответ как комментарий
|
||
return max_score // 2, response
|
||
|
||
|
||
# Глобальный экземпляр сервиса
|
||
_ai_service = None
|
||
|
||
|
||
def get_ai_service() -> AICheckingService:
|
||
"""Получить экземпляр сервиса AI проверки."""
|
||
global _ai_service
|
||
if _ai_service is None:
|
||
_ai_service = AICheckingService()
|
||
return _ai_service
|
||
|