1
0
Fork 0
mirror of https://github.com/EDeev/tablo.git synced 2026-10-07 20:49:31 +03:00
tablo/app/services/ai_scan.py
Egor Deev 164623417c Безопасность: права на экспорт, обязательный SECRET_KEY, ошибки ИИ не показываются пользователю
- экспорт в JSON/CSV/PDF/PNG пускал любого вошедшего к любому расписанию, у которого
  есть хоть одна ссылка-приглашение (достаточно перебрать id); теперь — только владелец
  и редакторы, как и просмотр по id;
- три копии проверки прав сведены в app/access.py;
- без SECRET_KEY приложение не запускается (кроме отладки и тестов): со значением по
  умолчанию cookie сессии можно было подделать и войти под любым пользователем;
- текст исключения от ИИ-провайдера больше не уходит в интерфейс, ошибка пишется в лог;
- DATABASE_URL как альтернатива DB_*; debug в run.py — из FLASK_DEBUG;
- неиспользуемые импорты и переменные удалены (ruff).
2026-10-02 13:10:20 +00:00

132 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import base64
import json
import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
TYPE_COLOR_POOL = """
Доступные цвета для типов занятий (выбери наиболее подходящий для каждого типа):
"blue" — лекции, теоретические занятия
"green" — практики, семинары
"yellow" — лабораторные работы, практикумы
"purple" — семинары, коллоквиумы
"orange" — консультации, факультативы
"red" — экзамены, зачёты, контрольные
"teal" — проектная работа, курсовые
"gray" — прочее, не указано
"""
SYSTEM_PROMPT = """Ты — точный парсер расписания учебных занятий.
Тебе дадут картинку с расписанием. Твоя задача — извлечь ВСЕ занятия и вернуть ТОЛЬКО валидный JSON, без лишнего текста, пояснений и markdown.
═══════════════════════════════════════════════
ВАЖНО: ОДИН ПРЕДМЕТ — ОДИН ОБЪЕКТ В МАССИВЕ
═══════════════════════════════════════════════
Один и тот же предмет может встречаться в расписании много раз:
— в разные дни недели
— в разное время
— с разными типами (Лекция, Практика, Лаб. работа)
— в разные периоды дат
— в разных аудиториях
— у разных преподавателей
ВСЕ вхождения одного предмета должны быть объединены в ОДИН объект!
═══════════════════════════════════════════════
СТРУКТУРА ОТВЕТА
═══════════════════════════════════════════════
[
{
"subject": "Название предмета",
"types": {
"Тип занятия": {
"color": "blue",
"dates": {
"ДД.ММ-ДД.ММ": [
["день", "ЧЧ:ММ-ЧЧ:ММ", "кабинет_или_null", "преподаватель_или_null"]
]
}
}
}
}
]
═══════════════════════════════════════════════
ПРАВИЛА
═══════════════════════════════════════════════
subject — обязательно, точное название
types — обязательно, словарь типов
Ключ типа — "Лекция", "Практика", "Лаб. работа" и т.п.
Если тип не указан — "Не указано"
color — цвет типа из пула ниже, выбери по смыслу типа
""" + TYPE_COLOR_POOL + """
Ключ дат — формат "ДД.ММ-ДД.ММ", например "02.02-05.04"
Месяцы: янв=01 фев=02 мар=03 апр=04 май=05 июн=06
июл=07 авг=08 сен=09 окт=10 ноя=11 дек=12
Если период не указан — "Весь период"
Строка слота:
[0] день — нижний регистр (понедельник, вторник...)
[1] время — "ЧЧ:ММ-ЧЧ:ММ"
[2] кабинет — строка или null
[3] преподаватель — строка (несколько через запятую) или null
ВАЖНО: поле types изменилось — теперь каждый тип содержит объект с полями "color" и "dates" (вместо прямого словаря дат).
"""
def _encode_image(image_bytes: bytes, ext: str) -> tuple[str, str]:
mime_map = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.webp': 'image/webp', '.bmp': 'image/bmp'}
mime = mime_map.get(ext.lower(), 'image/jpeg')
return base64.standard_b64encode(image_bytes).decode('utf-8'), mime
def _strip_markdown(raw: str) -> str:
raw = raw.strip()
if raw.startswith('```'):
lines = raw.splitlines()
end = len(lines) - 1 if lines[-1].strip() == '```' else len(lines)
raw = '\n'.join(lines[1:end])
return raw
def scan_image(image_bytes: bytes, ext: str, extra_prompt: str = '') -> list[dict]:
client = OpenAI(
api_key=os.getenv('OPENAI_API_KEY'),
base_url=os.getenv('OPENAI_BASE_URL') or None,
)
model = os.getenv('OPENAI_MODEL', 'gpt-4o')
image_data, mime_type = _encode_image(image_bytes, ext)
user_text = 'Извлеки расписание из этого изображения и верни JSON строго по инструкции.'
if extra_prompt:
user_text += f'\n\nДополнительные указания от пользователя: {extra_prompt}'
response = client.chat.completions.create(
model=model,
messages=[
{'role': 'system', 'content': SYSTEM_PROMPT},
{
'role': 'user',
'content': [
{
'type': 'image_url',
'image_url': {
'url': f'data:{mime_type};base64,{image_data}',
'detail': 'high',
},
},
{'type': 'text', 'text': user_text},
],
},
],
temperature=0,
)
raw = response.choices[0].message.content
return json.loads(_strip_markdown(raw))