GitHub Actions commited on
Commit
2c37b83
·
1 Parent(s): c87183c

Deploy from GitHub Actions

Browse files
README.md CHANGED
@@ -7,8 +7,34 @@ sdk: docker
7
  pinned: false
8
  ---
9
 
10
- # Автоматический генератор объявлений о недвижимости
11
 
12
  [![Test and Deploy](https://github.com/Eslonf/house_ad_generator/actions/workflows/ci.yml/badge.svg)](https://github.com/Eslonf/house_ad_generator/actions/workflows/ci.yml)
13
 
 
 
14
  Веб-приложение, которое использует две нейросетевые модели для автоматического создания рекламных объявлений на основе фотографий домов.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  pinned: false
8
  ---
9
 
10
+ # 🤖 Автоматический генератор объявлений о недвижимости
11
 
12
  [![Test and Deploy](https://github.com/Eslonf/house_ad_generator/actions/workflows/ci.yml/badge.svg)](https://github.com/Eslonf/house_ad_generator/actions/workflows/ci.yml)
13
 
14
+ ## 🚀 [Попробовать](https://huggingface.co/spaces/Eslonf/house_ad_generator)
15
+
16
  Веб-приложение, которое использует две нейросетевые модели для автоматического создания рекламных объявлений на основе фотографий домов.
17
+
18
+ ---
19
+
20
+ ## ⚙️ Как это работает?
21
+
22
+ Приложение построено на асинхронной архитектуре с очередью задач, чтобы интерфейс оставался отзывчивым даже во время выполнения тяжелых ML-операций.
23
+
24
+ 1. **Загрузка фото:** Пользователь загружает изображение дома и выбирает желаемый стиль объявления.
25
+ 2. **Анализ изображения (Модель 1):** Модель **Visual Question Answering** "отвечает" на заранее заданные вопросы по изображению, извлекая его ключевые характеристики (материал стен, этажность и т.д.).
26
+ 3. **Генерация текста (Модель 2):** Легковесная языковая модель **Gemma** получает эти характеристики в виде промпта и пишет на их основе красивое рекламное объявление в заданном стиле.
27
+ 4. **Получение результата:** Клиент периодически опрашивает сервер, и как только задача выполнена, результат отображается на странице.
28
+
29
+ ---
30
+
31
+ ## 🛠️ Стек технологий
32
+
33
+ | Категория | Технология |
34
+ | :--- | :--- |
35
+ | **Язык** | **Python 3.12** |
36
+ | **Бэкенд** | `FastAPI`, `Uvicorn` |
37
+ | **Фронтенд** | `HTML`, `Bootstrap 5`, `JavaScript (Vanilla JS)` |
38
+ | **ML Модели** | 1. VQA: [`Salesforce/blip-vqa-base`](https://huggingface.co/Salesforce/blip-vqa-base) <br> 2. LLM: [`unsloth/gemma-3-4b-it-GGUF`](https://huggingface.co/unsloth/gemma-3-4b-it-GGUF) |
39
+ | **Основные библиотеки** | `Transformers`, `llama-cpp-python`, `PyTorch` |
40
+ | **CI/CD и Тестирование** | `GitHub Actions`, `Pytest`, `Docker` |
deploy/README.md CHANGED
@@ -7,6 +7,34 @@ sdk: docker
7
  pinned: false
8
  ---
9
 
10
- # Автоматический генератор объявлений о недвижимости
 
 
 
 
11
 
12
  Веб-приложение, которое использует две нейросетевые модели для автоматического создания рекламных объявлений на основе фотографий домов.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  pinned: false
8
  ---
9
 
10
+ # 🤖 Автоматический генератор объявлений о недвижимости
11
+
12
+ [![Test and Deploy](https://github.com/Eslonf/house_ad_generator/actions/workflows/ci.yml/badge.svg)](https://github.com/Eslonf/house_ad_generator/actions/workflows/ci.yml)
13
+
14
+ ## 🚀 [Попробовать](https://huggingface.co/spaces/Eslonf/house_ad_generator)
15
 
16
  Веб-приложение, которое использует две нейросетевые модели для автоматического создания рекламных объявлений на основе фотографий домов.
17
+
18
+ ---
19
+
20
+ ## ⚙️ Как это работает?
21
+
22
+ Приложение построено на асинхронной архитектуре с очередью задач, чтобы интерфейс оставался отзывчивым даже во время выполнения тяжелых ML-операций.
23
+
24
+ 1. **Загрузка фото:** Пользователь загружает изображение дома и выбирает желаемый стиль объявления.
25
+ 2. **Анализ изображения (Модель 1):** Модель **Visual Question Answering** "отвечает" на заранее заданные вопросы по изображению, извлекая его ключевые характеристики (материал стен, этажность и т.д.).
26
+ 3. **Генерация текста (Модель 2):** Легковесная языковая модель **Gemma** получает эти характеристики в виде промпта и пишет на их основе красивое рекламное объявление в заданном стиле.
27
+ 4. **Получение результата:** Клиент периодически опрашивает сервер, и как только задача выполнена, результат отображается на странице.
28
+
29
+ ---
30
+
31
+ ## 🛠️ Стек технологий
32
+
33
+ | Категория | Технология |
34
+ | :--- | :--- |
35
+ | **Язык** | **Python 3.12** |
36
+ | **Бэкенд** | `FastAPI`, `Uvicorn` |
37
+ | **Фронтенд** | `HTML`, `Bootstrap 5`, `JavaScript (Vanilla JS)` |
38
+ | **ML Модели** | 1. VQA: [`Salesforce/blip-vqa-base`](https://huggingface.co/Salesforce/blip-vqa-base) <br> 2. LLM: [`unsloth/gemma-3-4b-it-GGUF`](https://huggingface.co/unsloth/gemma-3-4b-it-GGUF) |
39
+ | **Основные библиотеки** | `Transformers`, `llama-cpp-python`, `PyTorch` |
40
+ | **CI/CD и Тестирование** | `GitHub Actions`, `Pytest`, `Docker` |
deploy/deploy/deploy/.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ app/__pycache__/
2
+ app/ml_model/__pycache__/
3
+ app/routers/__pycache__/
4
+ tests/__pycache__/
5
+
6
+ # # Virtual Environments
7
+ .env
8
+ .venv
9
+ venv/
10
+ env/
11
+ *_env/
deploy/deploy/deploy/Dockerfile ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY ./app /app/app
6
+ EXPOSE 8000
7
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
deploy/deploy/deploy/README.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AI House Ad Generator
3
+ emoji: 🏠
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # Автоматический генератор объявлений о недвижимости
11
+
12
+ Веб-приложение, которое использует две нейросетевые модели для автоматического создания рекламных объявлений на основе фотографий домов.
deploy/deploy/deploy/app/main.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from contextlib import asynccontextmanager
3
+ from fastapi import FastAPI
4
+ from fastapi.staticfiles import StaticFiles
5
+
6
+ from app.routers import generator_page
7
+ from app.ml_model.processing import generate_ad_from_image
8
+
9
+ from app.state import TASK_QUEUE, TASK_RESULTS
10
+
11
+ async def worker():
12
+ """
13
+ Фоновый "работник", который в бесконечном цикле берет задачи из очереди,
14
+ выполняет их и сохраняет результат.
15
+ """
16
+ print("Воркер запущен и готов к работе...")
17
+ while True:
18
+ # Ждем, пока в очереди появится новая задача
19
+ task = await TASK_QUEUE.get()
20
+ task_id = task["task_id"]
21
+ image_bytes = task["image_bytes"]
22
+ style = task["style"]
23
+
24
+ print(f"Воркер взял в работу задачу: {task_id}")
25
+ try:
26
+ result = await asyncio.to_thread(generate_ad_from_image, image_bytes, style)
27
+ TASK_RESULTS[task_id] = {"status": "completed", "data": result}
28
+ print(f"Задача {task_id} успешно выполнена.")
29
+ except Exception as e:
30
+ print(f"Ошибка при выполнении задачи {task_id}: {e}")
31
+ TASK_RESULTS[task_id] = {"status": "failed", "error": str(e)}
32
+ finally:
33
+ # Сообщаем очереди, что задача обработана
34
+ TASK_QUEUE.task_done()
35
+
36
+ @asynccontextmanager
37
+ async def lifespan(app: FastAPI):
38
+ """
39
+ Управляет жизненным циклом приложения. Запускает фонового воркера при старте.
40
+ """
41
+ # Запускаем нашего воркера как фоновую задачу
42
+ asyncio.create_task(worker())
43
+ yield
44
+
45
+ app = FastAPI(title="Генератор объявлений", lifespan=lifespan)
46
+
47
+ # Подключаем роутер и статику
48
+ app.include_router(generator_page.router)
49
+ app.mount("/static", StaticFiles(directory="app/static"), name="static")
deploy/deploy/deploy/app/ml_model/processing.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from PIL import Image
3
+ from transformers import pipeline
4
+ from huggingface_hub import hf_hub_download
5
+ from llama_cpp import Llama
6
+ import io
7
+
8
+ # --- ГЛОБАЛЬНАЯ ЗАГРУЗКА МОДЕЛЕЙ (ВЫПОЛНЯЕТСЯ ОДИН РАЗ ПРИ СТАРТЕ ПРИЛОЖЕНИЯ) ---
9
+
10
+ print("Загрузка ML-моделей... Это может занять время.")
11
+ MODELS_LOADED = False
12
+
13
+ try:
14
+ # Загрузка Модели 1 (VQA)
15
+ # VQA-модель довольно легкая, ее можно оставить на CPU
16
+ vqa_pipeline = pipeline(
17
+ "visual-question-answering",
18
+ model="Salesforce/blip-vqa-base",
19
+ device="cpu"
20
+ )
21
+
22
+ # Загрузка Модели 2 (Gemma GGUF)
23
+ model_name_gguf = "unsloth/gemma-3-4b-it-GGUF"
24
+ model_file = "gemma-3-4b-it-Q3_K_M.gguf"
25
+ model_path = hf_hub_download(repo_id=model_name_gguf, filename=model_file)
26
+
27
+ # Инициализируем для работы на CPU
28
+ gemma_gguf_model = Llama(
29
+ model_path=model_path,
30
+ n_ctx=2048,
31
+ n_gpu_layers=0,
32
+ verbose=False
33
+ )
34
+
35
+ MODELS_LOADED = True
36
+ print("Все ML-модели успешно загружены!")
37
+
38
+ except Exception as e:
39
+ print(f"КРИТИЧЕСКАЯ ОШИБКА при загрузке моделей: {e}")
40
+
41
+ # Список вопросов, которые мы будем задавать VQA-модели
42
+ QUESTIONS_TO_ASK = {
43
+ "Материал стен": "What is the primary material of the exterior walls?",
44
+ "Количество этажей": "How many floors does the house have?",
45
+ "Цвет крыши": "What color is the roof?",
46
+ "Архитектурный стиль": "What is the architectural style of the house?",
47
+ "Гараж": "Is there a garage?",
48
+ "Озеленение": "What does the landscaping look like?",
49
+ }
50
+
51
+ PROMPT_INSTRUCTIONS = {
52
+ "brief": "Ты — копирайтер, который пишет короткие, яркие и продающие объявления для недвижимости (4-5 предложений).",
53
+ "professional": "Ты — риелтор, который составляет детальное и структурированное объявление для листинга на сайте недвижимости. Опиши преимущества, используя профессиональный и деловой стиль.",
54
+ "social": "Ты — SMM-менеджер, который пишет яркий и вовлекающий пост для социальных сетей. Используй эмодзи, добавь несколько релевантных хэштегов и обращайся к аудитории неформально."
55
+ }
56
+
57
+ # --- ГЛАВНАЯ ФУНКЦИЯ ПАЙПЛАЙНА ---
58
+
59
+ def generate_ad_from_image(image_bytes: bytes, style: str) -> dict:
60
+ """
61
+ Принимает изображение и стиль, анализирует и генерирует объявление.
62
+ """
63
+ if not MODELS_LOADED:
64
+ raise RuntimeError("Модели не были загружены.")
65
+
66
+ try:
67
+ image = Image.open(io.BytesIO(image_bytes))
68
+ except Exception as e:
69
+ raise ValueError(f"Не удалось прочитать изображение: {e}")
70
+
71
+ # 1. Сбор характеристик с помощью VQA
72
+ print("Анализ изображения...")
73
+ house_characteristics = {}
74
+ for key, question in QUESTIONS_TO_ASK.items():
75
+ answer = vqa_pipeline(image, question=question, top_k=1)
76
+ characteristic = answer[0]['answer']
77
+ house_characteristics[key] = characteristic
78
+
79
+ # 2. Создание промпта для Gemma
80
+ print(f"Создание промпта в стиле '{style}'...")
81
+ prompt_details = ""
82
+ for key, value in house_characteristics.items():
83
+ prompt_details += f"- {key}: {value}\n"
84
+
85
+ # Выбираем нужную инструкцию из словаря. Если стиль неизвестен, используем "brief".
86
+ instruction = PROMPT_INSTRUCTIONS.get(style, PROMPT_INSTRUCTIONS["brief"])
87
+
88
+ prompt = f"""<start_header_id>user<end_header_id>
89
+ {instruction}
90
+
91
+ Вот характеристики дома:
92
+ {prompt_details}
93
+ Напиши объявление.<end_header_id>
94
+ <start_header_id>model<end_header_id>
95
+ """
96
+
97
+ # 3. Генерация текста объявления
98
+ print("Генерация объявления...")
99
+ response = gemma_gguf_model(
100
+ prompt,
101
+ max_tokens=200,
102
+ temperature=1.0,
103
+ top_k=64,
104
+ top_p=0.95,
105
+ min_p=0.0,
106
+ stop=["<end_header_id>"],
107
+ )
108
+ ad_text = response['choices'][0]['text'].strip()
109
+
110
+ # 4. Постобработка текста
111
+ if ad_text and not ad_text.endswith(('.', '!', '?')):
112
+ last_sentence_end = max(ad_text.rfind('.'), ad_text.rfind('!'), ad_text.rfind('?'))
113
+ if last_sentence_end != -1:
114
+ ad_text = ad_text[:last_sentence_end + 1]
115
+
116
+ print("Генерация завершена.")
117
+ return {
118
+ "characteristics": house_characteristics,
119
+ "ad_text": ad_text
120
+ }
deploy/deploy/deploy/app/routers/generator_page.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from fastapi import APIRouter, Request, File, UploadFile, HTTPException, status, Form
3
+ from fastapi.responses import HTMLResponse, JSONResponse
4
+ from fastapi.templating import Jinja2Templates
5
+
6
+ from app.state import TASK_QUEUE, TASK_RESULTS
7
+
8
+ router = APIRouter()
9
+ templates = Jinja2Templates(directory="app/templates")
10
+
11
+ @router.get("/", response_class=HTMLResponse)
12
+ async def read_root(request: Request):
13
+ """Отдает главную HTML-страницу."""
14
+ return templates.TemplateResponse(request, "index.html")
15
+
16
+ @router.post("/generate-ad")
17
+ async def generate_ad_task_endpoint(
18
+ style: str = Form(...),
19
+ image: UploadFile = File(...)
20
+ ):
21
+ """
22
+ МГНОВЕННО принимает задачу, кладет ее в очередь и возвращает ID задачи.
23
+ """
24
+ if not image.content_type.startswith("image/"):
25
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Недопустимый тип файла.")
26
+
27
+ # Генерируем уникальный ID для нашей задачи
28
+ task_id = str(uuid.uuid4())
29
+ image_bytes = await image.read()
30
+
31
+ # Создаем задачу и кладем ее в асинхронную очередь
32
+ task = {"task_id": task_id, "image_bytes": image_bytes, "style": style}
33
+ await TASK_QUEUE.put(task)
34
+
35
+ # Сразу же возвращаем клиенту ID, по которому он будет проверять результат
36
+ return JSONResponse(status_code=status.HTTP_202_ACCEPTED, content={"task_id": task_id})
37
+
38
+ @router.get("/results/{task_id}")
39
+ async def get_task_result_endpoint(task_id: str):
40
+ """
41
+ Проверяет статус задачи. Если она готова - возвращает результат.
42
+ Если в процессе - сообщает об этом.
43
+ """
44
+ result = TASK_RESULTS.get(task_id)
45
+
46
+ if result is None:
47
+ # Задача еще не обработана воркером
48
+ return JSONResponse(status_code=status.HTTP_202_ACCEPTED, content={"status": "processing"})
49
+
50
+ # Удаляем результат после того, как отдали его
51
+ final_result = TASK_RESULTS.pop(task_id)
52
+
53
+ if final_result["status"] == "failed":
54
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=final_result.get("error"))
55
+
56
+ return JSONResponse(status_code=status.HTTP_200_OK, content=final_result["data"])
deploy/deploy/deploy/app/state.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import asyncio
2
+
3
+ # Асинхронная очередь для задач
4
+ TASK_QUEUE = asyncio.Queue()
5
+ # Словарь для хранения результатов
6
+ TASK_RESULTS = {}
deploy/deploy/deploy/app/static/js/main.js ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', () => {
2
+
3
+ const uploadZone = document.getElementById('upload-zone');
4
+ const fileInput = document.getElementById('file-input');
5
+ const loader = document.getElementById('loader');
6
+ const resultArea = document.getElementById('result-area');
7
+ const resetButton = document.getElementById('reset-button');
8
+
9
+ // Элементы для вывода результата
10
+ const resultImage = document.getElementById('result-image');
11
+ const characteristicsList = document.getElementById('characteristics-list');
12
+ const adTextArea = document.getElementById('ad-text-area');
13
+ const copyButton = document.getElementById('copy-button');
14
+
15
+ // Элементы для вывода ошибок
16
+ const errorAlert = document.getElementById('error-alert');
17
+ const errorMessage = document.getElementById('error-message');
18
+
19
+ uploadZone.addEventListener('click', () => fileInput.click());
20
+
21
+ fileInput.addEventListener('change', (event) => {
22
+ const file = event.target.files[0];
23
+ if (file) {
24
+ handleFile(file);
25
+ }
26
+ });
27
+
28
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
29
+ uploadZone.addEventListener(eventName, preventDefaults, false);
30
+ });
31
+ ['dragenter', 'dragover'].forEach(eventName => {
32
+ uploadZone.addEventListener(eventName, () => uploadZone.classList.add('dragover'), false);
33
+ });
34
+ ['dragleave', 'drop'].forEach(eventName => {
35
+ uploadZone.addEventListener(eventName, () => uploadZone.classList.remove('dragover'), false);
36
+ });
37
+ uploadZone.addEventListener('drop', (event) => {
38
+ const file = event.dataTransfer.files[0];
39
+ if (file) {
40
+ handleFile(file);
41
+ }
42
+ });
43
+
44
+ resetButton.addEventListener('click', () => {
45
+ resultArea.style.display = 'none';
46
+ uploadZone.style.display = 'block';
47
+ if (errorAlert) errorAlert.style.display = 'none';
48
+ fileInput.value = '';
49
+ });
50
+
51
+ copyButton.addEventListener('click', () => {
52
+ adTextArea.select();
53
+ navigator.clipboard.writeText(adTextArea.value).then(() => {
54
+ const originalText = copyButton.innerHTML;
55
+ copyButton.innerHTML = '<i class="bi bi-check-lg"></i> Скопировано!';
56
+ setTimeout(() => {
57
+ copyButton.innerHTML = originalText;
58
+ }, 2000);
59
+ }).catch(err => {
60
+ console.error('Не удалось скопировать текст: ', err);
61
+ displayError("Ошибка при копировании текста. Возможно, ваш браузер не поддерживает эту функцию.");
62
+ });
63
+ });
64
+
65
+ function preventDefaults(e) {
66
+ e.preventDefault();
67
+ e.stopPropagation();
68
+ }
69
+
70
+ async function handleFile(file) {
71
+ if (!file.type.startsWith('image/')) {
72
+ displayError('Пожалуйста, выберите файл изображения.');
73
+ return;
74
+ }
75
+ if (errorAlert) errorAlert.style.display = 'none';
76
+ uploadZone.style.display = 'none';
77
+ resultArea.style.display = 'none';
78
+ loader.style.display = 'block';
79
+
80
+ const formData = new FormData();
81
+ formData.append('image', file);
82
+ const selectedStyle = document.querySelector('input[name="style"]:checked').value;
83
+ formData.append('style', selectedStyle);
84
+
85
+ const tempImageURL = URL.createObjectURL(file);
86
+
87
+ try {
88
+ // 1. Отправляем файл и получаем ID задачи
89
+ const response = await fetch('/generate-ad', {
90
+ method: 'POST',
91
+ body: formData,
92
+ });
93
+
94
+ if (!response.ok) {
95
+ const errorData = await response.json();
96
+ throw new Error(errorData.detail || `Ошибка сервера: ${response.status}`);
97
+ }
98
+ const taskData = await response.json();
99
+ const taskId = taskData.task_id;
100
+
101
+ sessionStorage.setItem('activeTaskId', taskId);
102
+ sessionStorage.setItem('tempImageURL', tempImageURL);
103
+
104
+ // 2. Начинаем "опрашивать" сервер о готовности результата
105
+ pollForResult(taskId, file);
106
+
107
+ } catch (error) {
108
+ console.error('Ошибка при отправке файла:', error);
109
+ displayError(error.message);
110
+ }
111
+ }
112
+
113
+ function checkSessionForActiveTask() {
114
+ const activeTaskId = sessionStorage.getItem('activeTaskId');
115
+ const tempImageURL = sessionStorage.getItem('tempImageURL');
116
+
117
+ if (activeTaskId && tempImageURL) {
118
+ console.log(`Найдена активная задача ${activeTaskId}. Возобновляем опрос...`);
119
+ const pseudoFile = { isPseudo: true, url: tempImageURL };
120
+
121
+ uploadZone.style.display = 'none';
122
+ loader.style.display = 'block';
123
+ pollForResult(activeTaskId, pseudoFile);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Периодически запрашивает результат для задачи с указанным ID
129
+ * @param {string} taskId - ID задачи
130
+ * @param {File} originalFile - Исходный файл для превью
131
+ */
132
+ function pollForResult(taskId, originalFile) {
133
+ const interval = setInterval(async () => {
134
+ try {
135
+ const response = await fetch(`/results/${taskId}`);
136
+
137
+ if (response.status === 200) {
138
+ // Результат готов
139
+ clearInterval(interval);
140
+
141
+ sessionStorage.removeItem('activeTaskId');
142
+ sessionStorage.removeItem('tempImageURL');
143
+
144
+ const data = await response.json();
145
+ displayResult(data, originalFile);
146
+ loader.style.display = 'none';
147
+ } else if (response.status === 202) {
148
+ // Еще в процессе, просто ждем следующего опроса
149
+ console.log(`Задача ${taskId} еще в процессе...`);
150
+ } else {
151
+ clearInterval(interval);
152
+
153
+ sessionStorage.removeItem('activeTaskId');
154
+ sessionStorage.removeItem('tempImageURL');
155
+
156
+ const errorData = await response.json();
157
+ throw new Error(errorData.detail || "Неизвестная ошибка на сервере при обработке.");
158
+ }
159
+ } catch (error) {
160
+ clearInterval(interval);
161
+
162
+ sessionStorage.removeItem('activeTaskId');
163
+ sessionStorage.removeItem('tempImageURL');
164
+
165
+ console.error('Ошибка при опросе результата:', error);
166
+ displayError(error.message);
167
+ }
168
+ }, 3000);
169
+ }
170
+
171
+ function displayResult(data, originalFile) {
172
+ resultImage.src = originalFile.isPseudo ? originalFile.url : URL.createObjectURL(originalFile);
173
+
174
+ characteristicsList.innerHTML = '';
175
+ for (const [key, value] of Object.entries(data.characteristics)) {
176
+ const li = document.createElement('li');
177
+ li.className = 'list-group-item';
178
+ li.innerHTML = `<strong>${key}:</strong> ${value}`;
179
+ characteristicsList.appendChild(li);
180
+ }
181
+
182
+ adTextArea.value = data.ad_text;
183
+ resultArea.style.display = 'block';
184
+ }
185
+
186
+ function displayError(message) {
187
+ if (errorMessage) {
188
+ errorMessage.textContent = message;
189
+ }
190
+ if (errorAlert) {
191
+ errorAlert.style.display = 'block';
192
+ }
193
+
194
+ loader.style.display = 'none';
195
+ uploadZone.style.display = 'block';
196
+ resultArea.style.display = 'none';
197
+ }
198
+ checkSessionForActiveTask();
199
+ });
deploy/deploy/deploy/app/templates/index.html ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Генератор объявлений о недвижимости</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css">
9
+ <style>
10
+ .border-dashed { border-style: dashed !important; }
11
+ .drop-zone { transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; cursor: pointer;}
12
+ .drop-zone:hover { background-color: #f8f9fa; }
13
+ .drop-zone.dragover { background-color: #e9ecef; border-color: #0d6efd !important; }
14
+ #result-image { max-height: 400px; object-fit: cover; }
15
+ #ad-text-area { min-height: 200px; font-family: monospace; }
16
+ </style>
17
+ </head>
18
+ <body class="bg-light">
19
+
20
+ <main class="container my-5">
21
+ <div class="text-center">
22
+ <h1 class="display-5">Генератор объявлений</h1>
23
+ </div>
24
+
25
+ <!-- 1. ЗОНА ЗАГРУЗКИ (ВИДНА ПО УМОЛЧАНИЮ) -->
26
+ <div id="upload-zone" class="mt-4 p-5 text-center border border-2 border-dashed rounded-3 drop-zone">
27
+ <i class="bi bi-cloud-arrow-up-fill fs-1 text-secondary"></i>
28
+ <p class="mt-3 mb-1">Перетащите файл сюда или нажмите, чтобы выбрать</p>
29
+ <p class="text-muted small">(Рекомендуются изображения в хорошем качестве)</p>
30
+ <input type="file" id="file-input" accept="image/*" class="d-none">
31
+ </div>
32
+
33
+ <!-- 2. СПИННЕР ЗАГРУЗКИ (СКРЫТ ПО УМОЛЧАНИЮ) -->
34
+ <div id="loader" class="text-center my-5" style="display: none;">
35
+ <div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;">
36
+ <span class="visually-hidden">Загрузка...</span>
37
+ </div>
38
+ <p class="mt-3">Анализируем изображение и пишем текст... Это может занять несколько минут.</p>
39
+ </div>
40
+
41
+ <!-- 3. ОБЛАСТЬ РЕЗУЛЬТАТА (СКРЫТА ПО УМОЛЧАНИЮ) -->
42
+ <div id="result-area" class="card shadow-sm mt-4" style="display: none;">
43
+ <div class="card-body">
44
+ <div class="row g-4">
45
+ <!-- Левая колонка с изображением -->
46
+ <div class="col-lg-5">
47
+ <h5 class="mb-3">Ваше изображение:</h5>
48
+ <img id="result-image" src="" alt="Загруженное изображение дома" class="img-fluid rounded border">
49
+ </div>
50
+ <!-- Правая колонка с результатами -->
51
+ <div class="col-lg-7">
52
+ <h5 class="mb-3">🔍 Извлеченные характеристики дома:</h5>
53
+ <ul id="characteristics-list" class="list-group list-group-flush mb-4">
54
+ <!-- Сюда JS добавит список характеристик -->
55
+ </ul>
56
+
57
+ <div class="d-flex justify-content-between align-items-center mb-2">
58
+ <h5 class="mb-0">✍️ Сгенерированное объявление:</h5>
59
+ <button id="copy-button" class="btn btn-sm btn-outline-secondary">
60
+ <i class="bi bi-clipboard"></i> Копировать
61
+ </button>
62
+ </div>
63
+ <textarea id="ad-text-area" class="form-control" readonly></textarea>
64
+ </div>
65
+ </div>
66
+ <div class="text-center mt-4">
67
+ <button id="reset-button" class="btn btn-primary">Загрузить другое фото</button>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ <div class="text-center my-4">
73
+ <p class="mb-2"><strong>Выберите стиль объявления:</strong></p>
74
+ <div class="btn-group" role="group" aria-label="Стили объявления">
75
+ <input type="radio" class="btn-check" name="style" id="style-brief" value="brief" autocomplete="off" checked>
76
+ <label class="btn btn-outline-primary" for="style-brief">Краткий</label>
77
+
78
+ <input type="radio" class="btn-check" name="style" id="style-professional" value="professional" autocomplete="off">
79
+ <label class="btn btn-outline-primary" for="style-professional">Профессиональный</label>
80
+
81
+ <input type="radio" class="btn-check" name="style" id="style-social" value="social" autocomplete="off">
82
+ <label class="btn btn-outline-primary" for="style-social">Для соцсетей</label>
83
+ </div>
84
+ </div>
85
+
86
+ <div id="error-alert" class="alert alert-danger alert-dismissible fade show" role="alert" style="display: none;">
87
+ <strong><i class="bi bi-exclamation-triangle-fill"></i> Ошибка:</strong>
88
+ <span id="error-message">Здесь будет текст ошибки.</span>
89
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
90
+ </div>
91
+
92
+ </main>
93
+
94
+ <script src="/static/js/main.js"></script>
95
+ </body>
96
+ </html>
deploy/deploy/deploy/pytest.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [pytest]
2
+ pythonpath = .
deploy/deploy/deploy/requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ jinja2
4
+ python-multipart
5
+ transformers
6
+ torch --index-url https://download.pytorch.org/whl/cpu
7
+ https://github.com/sergey21000/llama-cpp-python-wheels/releases/download/v0.3.16-cpu/llama_cpp_python-0.3.16-cp312-cp312-linux_x86_64.whl
8
+ huggingface-hub
9
+ Pillow
10
+
11
+ # Тестирование
12
+ pytest
13
+ httpx
14
+ pytest-mock
deploy/deploy/deploy/tests/assets/fake_image.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ this is not an image
deploy/deploy/deploy/tests/assets/test_image.png ADDED
deploy/deploy/deploy/tests/test_main.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi.testclient import TestClient
2
+ import pytest
3
+
4
+ from app.main import app
5
+
6
+ # --- Подготовка к тестам ---
7
+ # Создаем "виртуального клиента", который будет отправлять запросы к приложению.
8
+ client = TestClient(app)
9
+
10
+ # --- Тест-кейс №1 ---
11
+ def test_read_main_page():
12
+ """
13
+ Тест проверяет, что главная страница ("/") успешно загружается.
14
+ """
15
+ # 1. Действие:
16
+ # Отправляем GET-запрос на главную страницу.
17
+ response = client.get("/")
18
+
19
+ # 2. Проверка (Assert):
20
+ # Утверждаем, что сервер ответил кодом 200 (OK).
21
+ assert response.status_code == 200
22
+
23
+ # 3. Проверка содержимого:
24
+ assert "Генератор объявлений" in response.text
25
+
26
+ # --- Тест-кейс №2 ---
27
+ def test_submit_image_successfully(mocker):
28
+ """
29
+ Тест проверяет успешную отправку изображения и постановку задачи в очередь.
30
+ 'mocker' - это специальный аргумент от pytest-mock для создания "моков".
31
+ """
32
+ # 1. Подготовка "мока":
33
+ # Мы "подменяем" настоящую, тяжелую ML-функцию на заглушку.
34
+ mocker.patch(
35
+ 'app.main.generate_ad_from_image',
36
+ return_value={"status": "mocked_ok"}
37
+ )
38
+
39
+ # 2. Действие:
40
+ # Имитируем отправку файла, как это делает браузер.
41
+ with open("tests/assets/test_image.png", "rb") as f:
42
+ response = client.post(
43
+ "/generate-ad",
44
+ data={"style": "brief"},
45
+ files={"image": ("test_image.png", f, "image/png")}
46
+ )
47
+
48
+ # 3. Проверки (Asserts):
49
+ # Утверждаем, что сервер ответил кодом 202 (Accepted),
50
+ # что означает "задача принята в обработку".
51
+ assert response.status_code == 202
52
+
53
+ # Утверждаем, что в JSON-ответе есть ключ "task_id".
54
+ data = response.json()
55
+ assert "task_id" in data
56
+
57
+ # --- Тест-кейс №3 ---
58
+ def test_submit_wrong_file_type():
59
+ """
60
+ Тест проверяет, что сервер правильно отклоняет файлы, не являющиеся изображениями.
61
+ """
62
+ # 1. Действие:
63
+ # Открываем текстовый файл и отправляем его на сервер.
64
+ with open("tests/assets/fake_image.txt", "rb") as f:
65
+ response = client.post(
66
+ "/generate-ad",
67
+ data={"style": "brief"},
68
+ files={"image": ("fake_image.txt", f, "text/plain")}
69
+ )
70
+
71
+ # 2. Проверки (Asserts):
72
+ # Утверждаем, что сервер ответил кодом 400 (Bad Request).
73
+ assert response.status_code == 400
74
+
75
+ # Утверждаем, что в JSON-ответе содержится правильное сообщение об ошибке.
76
+ data = response.json()
77
+ assert "detail" in data
78
+ assert data["detail"] == "Недопустимый тип файла."