Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ FROM python:3.12-slim
# Install PostgreSQL client libraries and build tools
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq-dev build-essential \
libglib2.0-0 libgfortran5 \
libglib2.0-0 libgfortran5 libgl1 libxext6 libsm6 libxrender1 libxcb1 \
&& rm -rf /var/lib/apt/lists/*

ENV PYTHONDONTWRITEBYTECODE=1
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
s7a 3idkom
# multAI Backend

Project run instructions are documented here:

- [Run Modes Guide](docs/RUN_MODES.md)

Use the guide to choose the right workflow:

- Backend Dev Mode (hot reload on host)
- Staging Mode (shared image-based containers)
- Backend Staging-Check Mode (local code in staging-like containers)
9 changes: 5 additions & 4 deletions app/router/mobile/photo_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,20 @@ async def list_my_approvals(
current_user: MobileUserSchema = Depends(get_current_mobile_user),
container: Container = Depends(get_container),
) -> list[dict[str, object]]:
approvals = []
approvals: list[dict[str, object]] = []
async for a in container.photo_approval_querier.list_approvals_by_user_and_status(
user_id=current_user.user_id,
dollar_2=status,
limit=limit,
offset=offset,
status=status,
):
approvals.append({
item: dict[str, object] = {
"id": str(a.id),
"photo_id": str(a.photo_id),
"decision": a.decision,
"decided_at": a.decided_at.isoformat() if a.decided_at else None,
})
}
approvals.append(item)
return approvals


Expand Down
6 changes: 3 additions & 3 deletions app/service/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ async def inactivate_device(
user_id: uuid.UUID,
) -> None:
try:
device = await self.device_querier.get_device__by_id(id=device_id)
device = await self.device_querier.get_device_by_id(id=device_id)
if device is None or device.user_id != user_id:
raise AppException.not_found("Device not found")
await self.device_querier.deactivate_device(
Expand All @@ -112,7 +112,7 @@ async def get_device_by_id(
user_id: uuid.UUID,
) -> UserDevice:
try :
device = await self.device_querier.get_device__by_id(id=device_id)
device = await self.device_querier.get_device_by_id(id=device_id)
if device is None :
raise AppException.not_found("device not found ")
return device
Expand All @@ -121,7 +121,7 @@ async def get_device_by_id(

async def count_devices(self: "DeviceService", user_id: uuid.UUID) -> int:
try :
count = await self.device_querier.count__user__devices(user_id=user_id)
count = await self.device_querier.count_user_devices(user_id=user_id)
if count is None :
raise AppException.internal_error("db failed to count ")
return count
Expand Down
36 changes: 24 additions & 12 deletions app/service/face_match.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,19 +59,9 @@ async def process_detected_face(
matched_user = await self.user_match_service.find_closest_user(
embedding_literal=embedding_literal,
)
if matched_user is None:
logger.info("No user embeddings available for matching, auto-approving photo %s", job.photo_id)
await self.photo_querier.update_photo_status(id=job.photo_id, status="approved")
return

from app.worker.photo_worker.settings import settings as worker_settings
if matched_user.distance > worker_settings.similarity_threshold:
logger.info(
"Closest user distance %.4f exceeds threshold %.4f for photo %s; auto-approving",
matched_user.distance, worker_settings.similarity_threshold, job.photo_id,
)
await self.photo_querier.update_photo_status(id=job.photo_id, status="approved")
if await self._autoapprove_if_unmatchable(job, matched_user):
return
assert matched_user is not None

params = photo_face_queries.PhotoFacesEnsureFaceMatchParams(
photo_id=job.photo_id,
Expand Down Expand Up @@ -103,6 +93,7 @@ async def process_detected_face(
return

if created_face_match_id:
assert matched_user is not None
await self.photo_querier.update_photo_status(
id=job.photo_id, status="approved",
)
Expand All @@ -114,6 +105,27 @@ async def process_detected_face(
},
)

async def _autoapprove_if_unmatchable(
self,
job: SingleFaceMatchJob,
matched_user: ClosestUserMatch | None,
) -> bool:
if matched_user is None:
logger.info("No user embeddings available for matching, auto-approving photo %s", job.photo_id)
await self.photo_querier.update_photo_status(id=job.photo_id, status="approved")
return True

from app.worker.photo_worker.settings import settings as worker_settings
if matched_user.distance > worker_settings.similarity_threshold:
logger.info(
"Closest user distance %.4f exceeds threshold %.4f for photo %s; auto-approving",
matched_user.distance, worker_settings.similarity_threshold, job.photo_id,
)
await self.photo_querier.update_photo_status(id=job.photo_id, status="approved")
return True

return False

async def Check_photo_exists(self, photo_id: UUID) -> bool:
row = await self.photo_face_querier.photo_faces_photo_exists(id=photo_id)
return row is not None
Expand Down
2 changes: 1 addition & 1 deletion app/service/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ async def _ensure_device_for_login(
user_id: uuid.UUID,
req: MobileAuthRequest,
) -> UserDevice:
existing_device = await self.device_querier.get_device__by_id(id=req.device_id)
existing_device = await self.device_querier.get_device_by_id(id=req.device_id)

if existing_device:
if existing_device.user_id != user_id:
Expand Down
11 changes: 5 additions & 6 deletions app/worker/photo_worker/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from app.service.user_notification import UserNotificationService
from app.worker.photo_worker.schema.event import PhotoProcessEvent
from app.worker.photo_worker.settings import settings as worker_settings
from db.generated import models
from db.generated import photo_faces as photo_face_queries
from db.generated import photos as photo_queries
from db.generated import processing_jobs as processing_job_queries
Expand All @@ -35,7 +36,7 @@ class PhotoApprovalDecision(str, Enum):


class PhotoWorker:

def __init__(
self,
conn: AsyncConnection,
Expand Down Expand Up @@ -94,7 +95,6 @@ async def handle_message(self, data: bytes) -> None:
await self._publish_audit(event, len(faces))
await self._schedule_cleanup(event.image_ref)



async def _handle_single_face(self, event: PhotoProcessEvent, face: DetectedFace) -> None:
from app.schema.internal.single_face_match import SingleFaceMatchJob
Expand All @@ -119,7 +119,6 @@ async def _handle_single_face(self, event: PhotoProcessEvent, face: DetectedFace
except Exception as exc:
logger.exception("Single face match failed for photo %s: %s", event.photo_id, exc)



async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[DetectedFace]) -> None:
logger.info("Processing group photo %s with %d faces", event.photo_id, len(faces))
Expand Down Expand Up @@ -180,7 +179,7 @@ async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[Detect
)


async def _create_job(self, event: PhotoProcessEvent) -> object | None:
async def _create_job(self, event: PhotoProcessEvent) -> models.ProcessingJob | None:
if self._pj_querier is None:
return None
try:
Expand All @@ -191,11 +190,11 @@ async def _create_job(self, event: PhotoProcessEvent) -> object | None:
logger.warning("Failed to create processing job for photo %s: %s", event.photo_id, exc)
return None

async def _update_job(self, job: object | None, status: str) -> None:
async def _update_job(self, job: models.ProcessingJob | None, status: str) -> None:
if job is None or self._pj_querier is None:
return
try:
await self._pj_querier.update_processing_job_status(id=job.id, status=status) # type: ignore[union-attr]
await self._pj_querier.update_processing_job_status(id=job.id, status=status)
except Exception as exc:
logger.warning("Failed to update processing job: %s", exc)

Expand Down
12 changes: 6 additions & 6 deletions db/generated/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"""


COUNT__USER__DEVICES = """-- name: count__user__devices \\:one
COUNT_USER_DEVICES = """-- name: count_user_devices \\:one
SELECT COUNT(*)
FROM user_devices
WHERE user_id = :p1
Expand Down Expand Up @@ -67,7 +67,7 @@ class CreateDeviceParams:
"""


GET_DEVICE__BY_ID = """-- name: get_device__by_id \\:one
GET_DEVICE_BY_ID = """-- name: get_device_by_id \\:one
SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token from user_devices
WHERE id =:p1
"""
Expand Down Expand Up @@ -123,8 +123,8 @@ def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection):
async def activate_device(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None:
await self._conn.execute(sqlalchemy.text(ACTIVATE_DEVICE), {"p1": id, "p2": user_id})

async def count__user__devices(self, *, user_id: uuid.UUID) -> Optional[int]:
row = (await self._conn.execute(sqlalchemy.text(COUNT__USER__DEVICES), {"p1": user_id})).first()
async def count_user_devices(self, *, user_id: uuid.UUID) -> Optional[int]:
row = (await self._conn.execute(sqlalchemy.text(COUNT_USER_DEVICES), {"p1": user_id})).first()
if row is None:
return None
return row[0]
Expand Down Expand Up @@ -159,8 +159,8 @@ async def deactivate_device(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None:
async def enable_device2_fa(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None:
await self._conn.execute(sqlalchemy.text(ENABLE_DEVICE2_FA), {"p1": id, "p2": user_id})

async def get_device__by_id(self, *, id: uuid.UUID) -> Optional[models.UserDevice]:
row = (await self._conn.execute(sqlalchemy.text(GET_DEVICE__BY_ID), {"p1": id})).first()
async def get_device_by_id(self, *, id: uuid.UUID) -> Optional[models.UserDevice]:
row = (await self._conn.execute(sqlalchemy.text(GET_DEVICE_BY_ID), {"p1": id})).first()
if row is None:
return None
return models.UserDevice(
Expand Down
8 changes: 5 additions & 3 deletions db/generated/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ class AuditEventType(str, enum.Enum):
UPLOAD_REQUESTCREATED = "upload_request.created"
UPLOAD_REQUESTAPPROVED = "upload_request.approved"
UPLOAD_REQUESTREJECTED = "upload_request.rejected"
PHOTOPROCESSED = "photo.processed"
PHOTO_APPROVALDECIDED = "photo_approval.decided"


class EventStatus(str, enum.Enum):
Expand Down Expand Up @@ -204,14 +206,14 @@ class UploadRequestGroup:
requested_by: uuid.UUID
approved_by: Optional[uuid.UUID]
status: Any
processing_status: str
total_photo_count: int
batch_count: int
processed_photo_count: int
failed_photo_count: int
created_at: datetime.datetime
approved_at: Optional[datetime.datetime]
rejection_reason: Optional[str]
processing_status: str
processed_photo_count: int
failed_photo_count: int
error_message: Optional[str]


Expand Down
12 changes: 6 additions & 6 deletions db/generated/photo_approvals.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@
LIST_APPROVALS_BY_USER_AND_STATUS = """-- name: list_approvals_by_user_and_status \\:many
SELECT id, photo_id, user_id, decision, decided_at FROM photo_approvals
WHERE user_id = :p1
AND (:p2\\:\\:varchar IS NULL OR decision = :p2)
AND (:p4\\:\\:varchar IS NULL OR decision = :p4)
ORDER BY decided_at DESC
LIMIT :p3 OFFSET :p4
LIMIT :p2 OFFSET :p3
"""


Expand Down Expand Up @@ -72,12 +72,12 @@ async def get_photo_approvals_by_photo_id(self, *, photo_id: uuid.UUID) -> Async
decided_at=row[4],
)

async def list_approvals_by_user_and_status(self, *, user_id: uuid.UUID, dollar_2: str, limit: int, offset: int) -> AsyncIterator[models.PhotoApproval]:
async def list_approvals_by_user_and_status(self, *, user_id: uuid.UUID, limit: int, offset: int, status: Optional[str]) -> AsyncIterator[models.PhotoApproval]:
result = await self._conn.stream(sqlalchemy.text(LIST_APPROVALS_BY_USER_AND_STATUS), {
"p1": user_id,
"p2": dollar_2,
"p3": limit,
"p4": offset,
"p2": limit,
"p3": offset,
"p4": status,
})
async for row in result:
yield models.PhotoApproval(
Expand Down
Loading