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):
@get("/attachments/{raw_path:path}", media_type="image/jpeg")
async def get_file(self, raw_path: str) -> Response:
attach_service: AtachmentService = inject.instance(AtachmentService)

View File

@ -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]],
},

View File

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

View File

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

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.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

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
name: str
alias: str
description: str
description: str | None

View File

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

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 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, делаем один 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 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
)

View File

@ -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 = "<h3 class='image-results'>Результаты</h3>";
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 += `<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 {
@ -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 {

View File

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