File size: 10,616 Bytes
ae44122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
"""
Flask-приложение для определения жанра книги по аннотации.

Приложение разработано как демонстрационная часть выпускной квалификационной
работы по теме многоклассовой классификации текстов аннотаций книг по жанрам.
Пользователь вводит аннотацию на английском языке, после чего приложение строит
признаки текста и возвращает один из пяти жанров.
"""

from __future__ import annotations

# Библиотека transformers может попытаться загрузить TensorFlow, если он установлен в системе.
# Для приложения используется PyTorch, поэтому TensorFlow отключается до импорта модулей модели.
# Эти переменные должны быть заданы в самом начале файла, иначе настройка может не сработать.
import os
os.environ.setdefault("USE_TF", "0")
os.environ.setdefault("USE_TORCH", "1")
os.environ.setdefault("TRANSFORMERS_NO_TF", "1")

from functools import lru_cache
from pathlib import Path
from typing import Dict, List

import pandas as pd
from flask import Flask, jsonify, render_template, request

from services.prediction_service import BookGenrePredictionService, ModelFilesError


# Создаем экземпляр приложения Flask.
# Папки templates и static будут найдены автоматически, так как имеют стандартные названия.
app = Flask(__name__)

# Ограничиваем размер пользовательского запроса.
# Для текстовой аннотации 64 килобайт достаточно, а лишняя защита снижает риск случайной перегрузки.
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024


@lru_cache(maxsize=1)
def get_prediction_service() -> BookGenrePredictionService:
    """
    Загружает сервис классификации один раз и переиспользует его между запросами.

    Загрузка модели смысловых векторов занимает время и память, поэтому сервис
    кэшируется. При первом обращении создается объект BookGenrePredictionService,
    при последующих обращениях возвращается уже загруженный экземпляр.
    """
    return BookGenrePredictionService(model_dir="model")


def is_model_package_available() -> bool:
    """
    Проверяет наличие главного файла модели.

    Папка model в переданном архиве намеренно оставлена пустой. Пользователь
    должен самостоятельно скопировать в нее файлы из архива с результатами
    эксперимента. Проверка позволяет вывести понятное сообщение на главной
    странице вместо технической ошибки.
    """
    return Path("model/book_genre_model_package.joblib").exists()


def load_metrics_summary() -> List[Dict[str, str]]:
    """
    Загружает краткую справку о качестве модели, если файл с метриками существует.

    Приложение может работать без папки results. Если файлы результатов еще не
    скопированы, функция возвращает пустой список, а интерфейс просто не показывает
    блок с метриками.
    """
    metrics_path = Path("results/final_metrics.csv")

    if not metrics_path.exists():
        return []

    try:
        metrics_table = pd.read_csv(metrics_path)
    except Exception:
        return []

    if metrics_table.empty:
        return []

    row = metrics_table.iloc[0]

    return [
        {
            "name": "Доля верных ответов",
            "value": f"{float(row.get('Доля верных ответов', 0)):.4f}",
        },
        {
            "name": "F1-мера с усреднением по классам",
            "value": f"{float(row.get('F1-мера с усреднением по классам', 0)):.4f}",
        },
        {
            "name": "F1-мера с учетом размера классов",
            "value": f"{float(row.get('F1-мера с учетом размера классов', 0)):.4f}",
        },
    ]


def get_default_genres() -> List[str]:
    """
    Возвращает список жанров для главной страницы до загрузки модели.

    Даже если файлы модели еще не скопированы, пользователь видит, какие классы
    поддерживает приложение.
    """
    return [
        "детектив",
        "историческая проза",
        "научная литература",
        "романтика",
        "фантастика",
    ]


@app.route("/health", methods=["GET"])
def health():
    """
    Возвращает простой ответ для проверки работоспособности приложения.

    Такой маршрут удобен при размещении на сервере: по нему можно быстро понять,
    что Flask-приложение запущено и принимает запросы. Проверка не загружает
    модель классификации, поэтому выполняется быстро и не расходует лишнюю память.
    """
    return jsonify({"status": "ok"})


@app.route("/", methods=["GET"])
def index():
    """
    Отображает главную страницу приложения.

    На странице размещено поле для ввода аннотации, список поддерживаемых жанров,
    справка об ограничении языка и, при наличии файлов results, метрики лучшей модели.
    """
    genres = get_default_genres()

    if is_model_package_available():
        try:
            genres = get_prediction_service().get_available_genres()
        except ModelFilesError:
            # Если пакет модели есть, но поврежден, список жанров оставляется базовым.
            # Подробная ошибка будет показана при попытке выполнить классификацию.
            genres = get_default_genres()

    return render_template(
        "index.html",
        genres=genres,
        metrics=load_metrics_summary(),
        model_available=is_model_package_available(),
    )


@app.route("/predict", methods=["POST"])
def predict():
    """
    Принимает аннотацию из формы и возвращает страницу с результатом.

    Если модель отсутствует или текст слишком короткий, пользователь получает
    понятное сообщение с рекомендацией, что именно нужно исправить.
    """
    annotation = request.form.get("annotation", "")

    try:
        service = get_prediction_service()
        prediction_result = service.predict(annotation)
    except (ModelFilesError, ValueError) as error:
        return render_template(
            "error.html",
            error_message=str(error),
            annotation=annotation,
            genres=get_default_genres(),
        ), 400
    except Exception as error:
        return render_template(
            "error.html",
            error_message=(
                "При выполнении классификации возникла непредвиденная ошибка. "
                "Проверьте, что все файлы модели скопированы в папку model, "
                "а зависимости установлены из файла requirements.txt."
            ),
            annotation=annotation,
            genres=get_default_genres(),
        ), 500

    return render_template(
        "result.html",
        result=prediction_result,
        genres=service.get_available_genres(),
        metrics=load_metrics_summary(),
    )


@app.errorhandler(413)
def request_entity_too_large(error):
    """
    Обрабатывает ситуацию, когда пользователь отправил слишком большой текст.

    Ограничение нужно для защиты приложения от случайной передачи больших файлов
    вместо обычной аннотации.
    """
    return render_template(
        "error.html",
        error_message="Текст слишком большой. Введите обычную аннотацию книги объемом в несколько предложений.",
        annotation="",
        genres=get_default_genres(),
    ), 413


if __name__ == "__main__":
    # При локальном запуске приложение использует значения из переменных окружения.
    # На Hugging Face Spaces порт должен совпадать с параметром app_port в README.md.
    # По умолчанию выбран порт 7860, так как именно его ожидает Docker Space.
    host = os.environ.get("HOST", "0.0.0.0")
    port = int(os.environ.get("PORT", "7860"))

    # Режим отладки включается только явно через переменную FLASK_DEBUG=1.
    # На сервере отладку оставляют отключенной, чтобы не показывать технические ошибки пользователю.
    debug_mode = os.environ.get("FLASK_DEBUG", "0") == "1"

    app.run(host=host, port=port, debug=debug_mode)