mirror of
https://github.com/EDeev/tablo.git
synced 2026-10-07 20:49:31 +03:00
- экспорт в JSON/CSV/PDF/PNG пускал любого вошедшего к любому расписанию, у которого есть хоть одна ссылка-приглашение (достаточно перебрать id); теперь — только владелец и редакторы, как и просмотр по id; - три копии проверки прав сведены в app/access.py; - без SECRET_KEY приложение не запускается (кроме отладки и тестов): со значением по умолчанию cookie сессии можно было подделать и войти под любым пользователем; - текст исключения от ИИ-провайдера больше не уходит в интерфейс, ошибка пишется в лог; - DATABASE_URL как альтернатива DB_*; debug в run.py — из FLASK_DEBUG; - неиспользуемые импорты и переменные удалены (ruff).
132 lines
6.2 KiB
Python
132 lines
6.2 KiB
Python
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))
|