From 877dde5c18ec8c99825f3ded1af5704adfacb5db Mon Sep 17 00:00:00 2001 From: artem Date: Sat, 17 Jan 2026 14:53:44 +0300 Subject: [PATCH] save result definitions --- server/infra/web/attachments.py | 1 - server/infra/web/description.py | 2 +- server/infra/web/recognizer.py | 37 ++++++-- server/main.py | 5 +- server/migration/env.py | 1 + ...1316-bebaddef3e8d-parent-commit-ee7739b.py | 49 ++++++++++ server/modules/descriptions/domain.py | 2 +- .../descriptions/repository/repository.py | 2 +- .../modules/recognizer/repository/models.py | 44 +++++++++ .../recognizer/repository/repository.py | 91 ++++++++++++++++++- server/modules/recognizer/service.py | 34 ++++++- server/static/scripts.js | 14 +-- server/templates/base.html | 2 +- 13 files changed, 257 insertions(+), 27 deletions(-) create mode 100644 server/migration/versions/2026-01-17-1316-bebaddef3e8d-parent-commit-ee7739b.py create mode 100644 server/modules/recognizer/repository/models.py diff --git a/server/infra/web/attachments.py b/server/infra/web/attachments.py index da0f05a..cb54498 100644 --- a/server/infra/web/attachments.py +++ b/server/infra/web/attachments.py @@ -7,7 +7,6 @@ from server.modules.attachments import AtachmentService class AtachmentController(Controller): - @get("/attachments/{raw_path:path}", media_type="image/jpeg") async def get_file(self, raw_path: str) -> Response: attach_service: AtachmentService = inject.instance(AtachmentService) diff --git a/server/infra/web/description.py b/server/infra/web/description.py index 9d3fbd7..9034b37 100644 --- a/server/infra/web/description.py +++ b/server/infra/web/description.py @@ -47,7 +47,7 @@ class DescriptionController(Controller): return Template( template_name="beers-description.html", context={ - "text": markdown.markdown(breed.description), + "text": markdown.markdown(breed.description or ""), "title": breed.name, "images": [f"/static/assets/dog/{name}/{i}" for i in images[name]], }, diff --git a/server/infra/web/recognizer.py b/server/infra/web/recognizer.py index 8afd43b..27b306e 100644 --- a/server/infra/web/recognizer.py +++ b/server/infra/web/recognizer.py @@ -1,10 +1,9 @@ +import uuid +from dataclasses import dataclass from typing import Annotated import inject -from litestar import ( - Controller, - post, -) +from litestar import Controller, Request, Response, post from litestar.datastructures import UploadFile from litestar.enums import RequestEncodingType from litestar.params import Body @@ -12,15 +11,37 @@ from litestar.params import Body from server.modules.recognizer import RecognizerService +@dataclass +class BeerdsData: + f: UploadFile + device_id: str + + class BreedsController(Controller): path = "/beerds" @post("/dogs") - async def beerds_dogs(self, data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> dict: + async def beerds_dogs( + self, + request: Request, + data: Annotated[BeerdsData, Body(media_type=RequestEncodingType.MULTI_PART)], + ) -> Response: + user_id = request.cookies.get("user_id") + + # Cookie, которые нужно добавить в ответ (если пользователь новый) + new_cookies: dict[str, str] | None = None + if not user_id: + user_id = str(uuid.uuid4()) + new_cookies = {"user_id": user_id} + recognizer_service: RecognizerService = inject.instance(RecognizerService) - body = await data.read() - result = await recognizer_service.predict_dog_image(body) - return result.to_serializable() + body = await data.f.read() + result = await recognizer_service.predict_dog_image(body, user_id, data.device_id) + return Response( + content=result.to_serializable(), + media_type="application/json", + cookies=new_cookies, + ) @post("/cats") async def beerds_cats(self, data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> dict: diff --git a/server/main.py b/server/main.py index 0744f07..2a52c4e 100644 --- a/server/main.py +++ b/server/main.py @@ -31,8 +31,9 @@ def inject_config(binder: inject.Binder): db = AsyncDB(cnf) loop.run_until_complete(db.connect()) attach_service = AtachmentService(S3StorageDriver(cnf=cnf), DBAttachmentRepository(db)) - binder.bind(RecognizerService, RecognizerService(RecognizerRepository(), attach_service)) - binder.bind(CharactersService, CharactersService(PGCharactersRepository(db))) + characters_repository = PGCharactersRepository(db) + binder.bind(RecognizerService, RecognizerService(RecognizerRepository(db), attach_service, characters_repository)) + binder.bind(CharactersService, CharactersService(characters_repository)) binder.bind(VotesService, VotesService(PGVoteRepository(db))) binder.bind(AtachmentService, attach_service) diff --git a/server/migration/env.py b/server/migration/env.py index 05484c6..a1c0c1b 100644 --- a/server/migration/env.py +++ b/server/migration/env.py @@ -6,6 +6,7 @@ from server.infra.db.db_mapper import mapper_registry from server.modules.attachments.repository.models import * from server.modules.descriptions.repository.models import * from server.modules.rate.repository.models import * +from server.modules.recognizer.repository.models import * from sqlalchemy import engine_from_config, pool from sqlalchemy.ext.declarative import declarative_base diff --git a/server/migration/versions/2026-01-17-1316-bebaddef3e8d-parent-commit-ee7739b.py b/server/migration/versions/2026-01-17-1316-bebaddef3e8d-parent-commit-ee7739b.py new file mode 100644 index 0000000..9d95073 --- /dev/null +++ b/server/migration/versions/2026-01-17-1316-bebaddef3e8d-parent-commit-ee7739b.py @@ -0,0 +1,49 @@ +"""ee7739b + +Revision ID: bebaddef3e8d +Revises: 474b572b7fe2 +Create Date: 2026-01-17 13:16:39.809170 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "bebaddef3e8d" +down_revision: str | None = "474b572b7fe2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "recognizer_results", + sa.Column("id", sa.String(), nullable=False), + sa.Column("attachment_id", sa.String(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("device_id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["attachment_id"], ["attachments.id"], name="votes_attachment_id_fk"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "recognizer_results_beerds", + sa.Column("id", sa.String(), nullable=False), + sa.Column("recognizer_results_id", sa.String(), nullable=False), + sa.Column("beerd_id", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["beerd_id"], ["beerds.id"], name="recognizer_results_beerd_id_fk"), + sa.ForeignKeyConstraint(["recognizer_results_id"], ["recognizer_results.id"], name="recognizer_results_id_fk"), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("recognizer_results_beerds") + op.drop_table("recognizer_results") + # ### end Alembic commands ### diff --git a/server/modules/descriptions/domain.py b/server/modules/descriptions/domain.py index 34a632d..1b04de4 100644 --- a/server/modules/descriptions/domain.py +++ b/server/modules/descriptions/domain.py @@ -6,4 +6,4 @@ class Breed: id: str name: str alias: str - description: str + description: str | None diff --git a/server/modules/descriptions/repository/repository.py b/server/modules/descriptions/repository/repository.py index 76dfe91..6a945cc 100644 --- a/server/modules/descriptions/repository/repository.py +++ b/server/modules/descriptions/repository/repository.py @@ -84,7 +84,7 @@ class PGCharactersRepository(ACharactersRepository): id=str(row.id), name=row.name.strip(), alias=row.alias.strip(), - description=row.descriptions.strip(), + description=None, ) for row in rows ] diff --git a/server/modules/recognizer/repository/models.py b/server/modules/recognizer/repository/models.py new file mode 100644 index 0000000..5e58c38 --- /dev/null +++ b/server/modules/recognizer/repository/models.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime + +from dataclasses_ujson.dataclasses_ujson import UJsonMixin # type: ignore +from sqlalchemy import ( + Column, + DateTime, + ForeignKeyConstraint, + String, +) + +from server.infra.db.db_mapper import mapper_registry + + +@mapper_registry.mapped +@dataclass +class Results(UJsonMixin): + __sa_dataclass_metadata_key__ = "sa" + __tablename__ = "recognizer_results" + __table_args__ = (ForeignKeyConstraint(["attachment_id"], ["attachments.id"], "votes_attachment_id_fk"),) + + id: str = field(metadata={"sa": Column(String(), primary_key=True, nullable=False)}) + attachment_id: str = field(metadata={"sa": Column(String(), nullable=False)}) + user_id: str = field(metadata={"sa": Column(String(), nullable=False)}) + device_id: str = field(metadata={"sa": Column(String(), nullable=False)}) + created_at: datetime = field( + default=datetime.now(UTC), + metadata={"sa": Column(DateTime(timezone=True), nullable=False)}, + ) + + +@mapper_registry.mapped +@dataclass +class ResultBeerds(UJsonMixin): + __sa_dataclass_metadata_key__ = "sa" + __tablename__ = "recognizer_results_beerds" + __table_args__ = ( + ForeignKeyConstraint(["recognizer_results_id"], ["recognizer_results.id"], "recognizer_results_id_fk"), + ForeignKeyConstraint(["beerd_id"], ["beerds.id"], "recognizer_results_beerd_id_fk"), + ) + + id: str = field(metadata={"sa": Column(String(), primary_key=True, nullable=False)}) + recognizer_results_id: str = field(metadata={"sa": Column(String(), nullable=False)}) + beerd_id: str = field(metadata={"sa": Column(String(), nullable=False)}) diff --git a/server/modules/recognizer/repository/repository.py b/server/modules/recognizer/repository/repository.py index 68a767d..395c4ff 100644 --- a/server/modules/recognizer/repository/repository.py +++ b/server/modules/recognizer/repository/repository.py @@ -1,8 +1,20 @@ from abc import ABCMeta, abstractmethod +from dataclasses import dataclass from functools import lru_cache +from uuid import uuid4 import ujson from aiocache import Cache, cached # type: ignore +from sqlalchemy import insert, select + +from server.infra.db import AsyncDB +from server.modules.recognizer.repository import models as rm + + +@dataclass +class ResultWithBeerds: + result: rm.Results + beerds: list[rm.ResultBeerds] class ARecognizerRepository(metaclass=ABCMeta): @@ -22,10 +34,23 @@ class ARecognizerRepository(metaclass=ABCMeta): def labels_cats(self) -> dict: pass + @abstractmethod + async def get_results(self) -> list[ResultWithBeerds]: + """Получить **все** результаты (кэшируется).""" + + @abstractmethod + async def create_result_with_beerds(self, result: rm.Results, beerd_ids: list[str]) -> None: + """ + Создать новый результат и сразу же вставить связанные `ResultBeerds`. + + `beerd_ids` – список id пород, которые должны быть привязаны к + результату. Если список пуст, создаётся только результат. + """ + class RecognizerRepository(ARecognizerRepository): - def __init__(self): - pass + def __init__(self, db: AsyncDB): + self._db = db @cached(ttl=60, cache=Cache.MEMORY) async def images_dogs(self) -> dict: @@ -48,3 +73,65 @@ class RecognizerRepository(ARecognizerRepository): with open("server/modules/recognizer/repository/meta/labels_dogs.json") as f: # noqa: ASYNC230 data_labels = f.read() return ujson.loads(data_labels) + + async def create_result_with_beerds(self, result: rm.Results, beerd_ids: list[str]) -> None: + """ + Создаёт запись в ``recognizer_results`` и сразу же добавляет + одну запись в ``recognizer_results_beerds`` (если передан список + beerd_id) – все в одном `INSERT`‑запросе и одной транзакции. + + При отсутствии `id` у результата генерируется uuid4. + """ + # -------------------------------------------------------------------- + # 1️⃣ Подготовим объект результата + # -------------------------------------------------------------------- + if not result.id: + result.id = str(uuid4()) + + # -------------------------------------------------------------------- + # 2️⃣ Откроем транзакцию и добавим результат + # -------------------------------------------------------------------- + async with self._db.async_session() as session: + async with session.begin(): # начинается транзакция + session.add(result) # INSERT recognizer_results + + # Если есть связанные beerd, делаем один bulk‑INSERT + if beerd_ids: + values = [ + { + "id": str(uuid4()), + "recognizer_results_id": result.id, + "beerd_id": beerd_id, + } + for beerd_id in beerd_ids + ] + # Один INSERT … VALUES (..), (..), … + await session.execute(insert(rm.ResultBeerds).values(values)) + await session.commit() # завершаем транзакцию + + @cached(ttl=60, cache=Cache.MEMORY) + async def get_results(self) -> list[ResultWithBeerds]: + async with self._db.async_session() as session: + # 1️⃣ Получаем все результаты + stmt_res = select(rm.Results) + res_res = await session.execute(stmt_res) + results = res_res.scalars().all() + + if not results: + return [] + + # 2️⃣ Получаем все beerds, относящиеся к этим результатам + res_ids = [r.id for r in results] + stmt_beerds = ( + select(rm.ResultBeerds).where(rm.ResultBeerds.recognizer_results_id.in_(res_ids)) # type:ignore + ) + res_beerds = await session.execute(stmt_beerds) + beerds_list = res_beerds.scalars().all() + + # 3️⃣ Формируем карту id → beerds + by_res: dict[str, list[rm.ResultBeerds]] = {} + for b in beerds_list: + by_res.setdefault(b.recognizer_results_id, []).append(b) + + # 4️⃣ Собираем DTO‑ы + return [ResultWithBeerds(result=r, beerds=by_res.get(r.id, [])) for r in results] diff --git a/server/modules/recognizer/service.py b/server/modules/recognizer/service.py index db3723b..e52e405 100644 --- a/server/modules/recognizer/service.py +++ b/server/modules/recognizer/service.py @@ -1,7 +1,10 @@ +import asyncio import io import os from dataclasses import dataclass +from datetime import UTC, datetime from typing import Any, NewType, Protocol +from uuid import uuid4 from dataclasses_ujson.dataclasses_ujson import UJsonMixin # type: ignore from PIL import Image @@ -11,7 +14,8 @@ import torch from torchvision import transforms # type: ignore from server.modules.attachments.domains.attachments import Attachment -from server.modules.recognizer.repository import ARecognizerRepository +from server.modules.descriptions.repository import ACharactersRepository +from server.modules.recognizer.repository import ARecognizerRepository, models TorchModel = NewType("TorchModel", torch.nn.Module) @@ -46,11 +50,17 @@ class RecognizerResult(UJsonMixin): class RecognizerService: - __slots__ = ("_repository", "_attachment_service") + __slots__ = ("_repository", "_attachment_service", "_repository_characters") - def __init__(self, repository: ARecognizerRepository, attachment_service: AttachmentService): + def __init__( + self, + repository: ARecognizerRepository, + attachment_service: AttachmentService, + repository_characters: ACharactersRepository, + ): self._repository = repository self._attachment_service = attachment_service + self._repository_characters = repository_characters async def images_cats(self) -> dict: return await self._repository.images_cats() @@ -58,7 +68,22 @@ class RecognizerService: async def images_dogs(self) -> dict: return await self._repository.images_dogs() - async def predict_dog_image(self, image: bytes) -> RecognizerResult: + async def create_result(self, attachment: Attachment, user_id: str, device_id: str, beerd_names: list[str]): + characters = await self._repository_characters.get_characters() + await self._repository.create_result_with_beerds( + models.Results( + id=str(uuid4()), + attachment_id=attachment.id, + user_id=user_id, + device_id=device_id, + created_at=datetime.now(UTC), + ), + [ch.id for ch in characters if ch.name in beerd_names], + ) + + async def predict_dog_image(self, image: bytes, user_id: str, device_id: str | None) -> RecognizerResult: + if device_id is None: + device_id = "mobile" attachment = await self._attachment_service.create(image) predicted_data = self._predict(image, DOG_MODEL) results = {} @@ -76,6 +101,7 @@ class RecognizerService: ) description.setdefault(name, []).append(f"/dogs-characteristics/{name.replace(' ', '_')}") results[probabilities] = name + asyncio.create_task(self.create_result(attachment, user_id, device_id, [results[key] for key in results])) return RecognizerResult( results=results, images=images, description=description, uploaded_attach_id=attachment.id ) diff --git a/server/static/scripts.js b/server/static/scripts.js index 840a771..e695495 100644 --- a/server/static/scripts.js +++ b/server/static/scripts.js @@ -1,19 +1,21 @@ let urlCreator = window.URL || window.webkitURL; -let current_attachment_id = 0; -let current_beerd_name = []; +let currentAttachmentID = 0; +let currentBeerdName = []; +const deviceID = "web"; async function SavePhoto(self) { document.getElementById("result").innerHTML = ""; let photo = document.getElementById("file-input").files[0]; let formData = new FormData(); formData.append("f", photo); + formData.append('device_id', deviceID); let response = await fetch(self.action, { method: "POST", body: formData }); if (response.ok) { let json = await response.json(); - current_attachment_id = json.uploaded_attach_id; + currentAttachmentID = json.uploaded_attach_id; let text = "

Результаты

"; let uniqChecker = {}; @@ -37,7 +39,7 @@ async function SavePhoto(self) { // Обработка основных результатов for (let key in json.results) { - current_beerd_name.push(json.results[key]); + currentBeerdName.push(json.results[key]); if (json.description != undefined) { text += `
${json.results[key]} (вероятность: ${Math.round(parseFloat(key) * 100)}%)
Описание
`; } else { @@ -218,8 +220,8 @@ function closeModal() { // Подготовьте объект‑payload. Добавьте id‑токены, если нужны. const payload = { rate: value, - attachment_id: current_attachment_id, - beerd_name: current_beerd_name[0], + attachment_id: currentAttachmentID, + beerd_name: currentBeerdName[0], }; try { diff --git a/server/templates/base.html b/server/templates/base.html index 27fedc2..259c8ff 100644 --- a/server/templates/base.html +++ b/server/templates/base.html @@ -45,6 +45,6 @@ {% block form %}{% endblock %} - +