save result definitions
Gitea Actions Demo / build_and_push (push) Successful in 31m34s Details

This commit is contained in:
artem 2026-01-17 14:53:44 +03:00
parent ee7739b875
commit 877dde5c18
13 changed files with 257 additions and 27 deletions

View File

@ -7,7 +7,6 @@ from server.modules.attachments import AtachmentService
class AtachmentController(Controller): class AtachmentController(Controller):
@get("/attachments/{raw_path:path}", media_type="image/jpeg") @get("/attachments/{raw_path:path}", media_type="image/jpeg")
async def get_file(self, raw_path: str) -> Response: async def get_file(self, raw_path: str) -> Response:
attach_service: AtachmentService = inject.instance(AtachmentService) attach_service: AtachmentService = inject.instance(AtachmentService)

View File

@ -47,7 +47,7 @@ class DescriptionController(Controller):
return Template( return Template(
template_name="beers-description.html", template_name="beers-description.html",
context={ context={
"text": markdown.markdown(breed.description), "text": markdown.markdown(breed.description or ""),
"title": breed.name, "title": breed.name,
"images": [f"/static/assets/dog/{name}/{i}" for i in images[name]], "images": [f"/static/assets/dog/{name}/{i}" for i in images[name]],
}, },

View File

@ -1,10 +1,9 @@
import uuid
from dataclasses import dataclass
from typing import Annotated from typing import Annotated
import inject import inject
from litestar import ( from litestar import Controller, Request, Response, post
Controller,
post,
)
from litestar.datastructures import UploadFile from litestar.datastructures import UploadFile
from litestar.enums import RequestEncodingType from litestar.enums import RequestEncodingType
from litestar.params import Body from litestar.params import Body
@ -12,15 +11,37 @@ from litestar.params import Body
from server.modules.recognizer import RecognizerService from server.modules.recognizer import RecognizerService
@dataclass
class BeerdsData:
f: UploadFile
device_id: str
class BreedsController(Controller): class BreedsController(Controller):
path = "/beerds" path = "/beerds"
@post("/dogs") @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) recognizer_service: RecognizerService = inject.instance(RecognizerService)
body = await data.read() body = await data.f.read()
result = await recognizer_service.predict_dog_image(body) result = await recognizer_service.predict_dog_image(body, user_id, data.device_id)
return result.to_serializable() return Response(
content=result.to_serializable(),
media_type="application/json",
cookies=new_cookies,
)
@post("/cats") @post("/cats")
async def beerds_cats(self, data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> dict: async def beerds_cats(self, data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> dict:

View File

@ -31,8 +31,9 @@ def inject_config(binder: inject.Binder):
db = AsyncDB(cnf) db = AsyncDB(cnf)
loop.run_until_complete(db.connect()) loop.run_until_complete(db.connect())
attach_service = AtachmentService(S3StorageDriver(cnf=cnf), DBAttachmentRepository(db)) attach_service = AtachmentService(S3StorageDriver(cnf=cnf), DBAttachmentRepository(db))
binder.bind(RecognizerService, RecognizerService(RecognizerRepository(), attach_service)) characters_repository = PGCharactersRepository(db)
binder.bind(CharactersService, CharactersService(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(VotesService, VotesService(PGVoteRepository(db)))
binder.bind(AtachmentService, attach_service) binder.bind(AtachmentService, attach_service)

View File

@ -6,6 +6,7 @@ from server.infra.db.db_mapper import mapper_registry
from server.modules.attachments.repository.models import * from server.modules.attachments.repository.models import *
from server.modules.descriptions.repository.models import * from server.modules.descriptions.repository.models import *
from server.modules.rate.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 import engine_from_config, pool
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base

View File

@ -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 ###

View File

@ -6,4 +6,4 @@ class Breed:
id: str id: str
name: str name: str
alias: str alias: str
description: str description: str | None

View File

@ -84,7 +84,7 @@ class PGCharactersRepository(ACharactersRepository):
id=str(row.id), id=str(row.id),
name=row.name.strip(), name=row.name.strip(),
alias=row.alias.strip(), alias=row.alias.strip(),
description=row.descriptions.strip(), description=None,
) )
for row in rows for row in rows
] ]

View File

@ -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)})

View File

@ -1,8 +1,20 @@
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from uuid import uuid4
import ujson import ujson
from aiocache import Cache, cached # type: ignore 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): class ARecognizerRepository(metaclass=ABCMeta):
@ -22,10 +34,23 @@ class ARecognizerRepository(metaclass=ABCMeta):
def labels_cats(self) -> dict: def labels_cats(self) -> dict:
pass 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): class RecognizerRepository(ARecognizerRepository):
def __init__(self): def __init__(self, db: AsyncDB):
pass self._db = db
@cached(ttl=60, cache=Cache.MEMORY) @cached(ttl=60, cache=Cache.MEMORY)
async def images_dogs(self) -> dict: 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 with open("server/modules/recognizer/repository/meta/labels_dogs.json") as f: # noqa: ASYNC230
data_labels = f.read() data_labels = f.read()
return ujson.loads(data_labels) 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, делаем один bulkINSERT
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]

View File

@ -1,7 +1,10 @@
import asyncio
import io import io
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, NewType, Protocol from typing import Any, NewType, Protocol
from uuid import uuid4
from dataclasses_ujson.dataclasses_ujson import UJsonMixin # type: ignore from dataclasses_ujson.dataclasses_ujson import UJsonMixin # type: ignore
from PIL import Image from PIL import Image
@ -11,7 +14,8 @@ import torch
from torchvision import transforms # type: ignore from torchvision import transforms # type: ignore
from server.modules.attachments.domains.attachments import Attachment 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) TorchModel = NewType("TorchModel", torch.nn.Module)
@ -46,11 +50,17 @@ class RecognizerResult(UJsonMixin):
class RecognizerService: 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._repository = repository
self._attachment_service = attachment_service self._attachment_service = attachment_service
self._repository_characters = repository_characters
async def images_cats(self) -> dict: async def images_cats(self) -> dict:
return await self._repository.images_cats() return await self._repository.images_cats()
@ -58,7 +68,22 @@ class RecognizerService:
async def images_dogs(self) -> dict: async def images_dogs(self) -> dict:
return await self._repository.images_dogs() 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) attachment = await self._attachment_service.create(image)
predicted_data = self._predict(image, DOG_MODEL) predicted_data = self._predict(image, DOG_MODEL)
results = {} results = {}
@ -76,6 +101,7 @@ class RecognizerService:
) )
description.setdefault(name, []).append(f"/dogs-characteristics/{name.replace(' ', '_')}") description.setdefault(name, []).append(f"/dogs-characteristics/{name.replace(' ', '_')}")
results[probabilities] = name results[probabilities] = name
asyncio.create_task(self.create_result(attachment, user_id, device_id, [results[key] for key in results]))
return RecognizerResult( return RecognizerResult(
results=results, images=images, description=description, uploaded_attach_id=attachment.id results=results, images=images, description=description, uploaded_attach_id=attachment.id
) )

View File

@ -1,19 +1,21 @@
let urlCreator = window.URL || window.webkitURL; let urlCreator = window.URL || window.webkitURL;
let current_attachment_id = 0; let currentAttachmentID = 0;
let current_beerd_name = []; let currentBeerdName = [];
const deviceID = "web";
async function SavePhoto(self) { async function SavePhoto(self) {
document.getElementById("result").innerHTML = ""; document.getElementById("result").innerHTML = "";
let photo = document.getElementById("file-input").files[0]; let photo = document.getElementById("file-input").files[0];
let formData = new FormData(); let formData = new FormData();
formData.append("f", photo); formData.append("f", photo);
formData.append('device_id', deviceID);
let response = await fetch(self.action, { method: "POST", body: formData }); let response = await fetch(self.action, { method: "POST", body: formData });
if (response.ok) { if (response.ok) {
let json = await response.json(); let json = await response.json();
current_attachment_id = json.uploaded_attach_id; currentAttachmentID = json.uploaded_attach_id;
let text = "<h3 class='image-results'>Результаты</h3>"; let text = "<h3 class='image-results'>Результаты</h3>";
let uniqChecker = {}; let uniqChecker = {};
@ -37,7 +39,7 @@ async function SavePhoto(self) {
// Обработка основных результатов // Обработка основных результатов
for (let key in json.results) { for (let key in json.results) {
current_beerd_name.push(json.results[key]); currentBeerdName.push(json.results[key]);
if (json.description != undefined) { if (json.description != undefined) {
text += `<div class='image-block'><div class='image-text'>${json.results[key]} (вероятность: ${Math.round(parseFloat(key) * 100)}%) <br/><a href="${json.description[json.results[key]]}" target='_blank'>Описание </a></div>`; text += `<div class='image-block'><div class='image-text'>${json.results[key]} (вероятность: ${Math.round(parseFloat(key) * 100)}%) <br/><a href="${json.description[json.results[key]]}" target='_blank'>Описание </a></div>`;
} else { } else {
@ -218,8 +220,8 @@ function closeModal() {
// Подготовьте объектpayload. Добавьте idтокены, если нужны. // Подготовьте объектpayload. Добавьте idтокены, если нужны.
const payload = { const payload = {
rate: value, rate: value,
attachment_id: current_attachment_id, attachment_id: currentAttachmentID,
beerd_name: current_beerd_name[0], beerd_name: currentBeerdName[0],
}; };
try { try {

View File

@ -45,6 +45,6 @@
{% block form %}{% endblock %} {% block form %}{% endblock %}
</body> </body>
</section> </section>
<script src="/static/scripts.js?v=6"></script> <script src="/static/scripts.js?v=7"></script>
</html> </html>