feat: Add image, update VS Code launch configuration, and enhance gitignore rules for build artifacts.

This commit is contained in:
xds
2026-02-12 14:02:36 +03:00
parent 456562ec1d
commit c6142715d9
31 changed files with 85 additions and 51 deletions

14
.gitignore vendored
View File

@@ -9,3 +9,17 @@ minio_backup.tar.gz
.idea .idea
.venv .venv
.vscode .vscode
.vscode/launch.json
middlewares/__pycache__/
middlewares/*.pyc
api/__pycache__/
api/*.pyc
repos/__pycache__/
repos/*.pyc
adapters/__pycache__/
adapters/*.pyc
services/__pycache__/
services/*.pyc
utils/__pycache__/
utils/*.pyc
.vscode/launch.json

27
.vscode/launch.json vendored
View File

@@ -7,7 +7,7 @@
"request": "launch", "request": "launch",
"module": "uvicorn", "module": "uvicorn",
"args": [ "args": [
"main:app", "aiws:app",
"--reload", "--reload",
"--port", "--port",
"8090", "8090",
@@ -16,31 +16,6 @@
], ],
"jinja": true, "jinja": true,
"justMyCode": true "justMyCode": true
},
{
"name": "Python: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": true,
"env": {
"PYTHONPATH": "${workspaceFolder}"
}
},
{
"name": "Debug Tests: Current File",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"args": [
"${file}"
],
"console": "integratedTerminal",
"justMyCode": true,
"env": {
"PYTHONPATH": "${workspaceFolder}"
}
} }
] ]
} }

View File

@@ -23,28 +23,30 @@ class GoogleAdapter:
self.TEXT_MODEL = "gemini-3-pro-preview" self.TEXT_MODEL = "gemini-3-pro-preview"
self.IMAGE_MODEL = "gemini-3-pro-image-preview" self.IMAGE_MODEL = "gemini-3-pro-image-preview"
def _prepare_contents(self, prompt: str, images_list: List[bytes] = None) -> list: def _prepare_contents(self, prompt: str, images_list: List[bytes] = None) -> tuple:
"""Вспомогательный метод для подготовки контента (текст + картинки)""" """Вспомогательный метод для подготовки контента (текст + картинки).
Returns (contents, opened_images) — caller MUST close opened_images after use."""
contents = [prompt] contents = [prompt]
opened_images = []
if images_list: if images_list:
logger.info(f"Preparing content with {len(images_list)} images") logger.info(f"Preparing content with {len(images_list)} images")
for img_bytes in images_list: for img_bytes in images_list:
try: try:
# Gemini API требует PIL Image на входе
image = Image.open(io.BytesIO(img_bytes)) image = Image.open(io.BytesIO(img_bytes))
contents.append(image) contents.append(image)
opened_images.append(image)
except Exception as e: except Exception as e:
logger.error(f"Error processing input image: {e}") logger.error(f"Error processing input image: {e}")
else: else:
logger.info("Preparing content with no images") logger.info("Preparing content with no images")
return contents return contents, opened_images
def generate_text(self, prompt: str, images_list: List[bytes] = None) -> str: def generate_text(self, prompt: str, images_list: List[bytes] = None) -> str:
""" """
Генерация текста (Чат или Vision). Генерация текста (Чат или Vision).
Возвращает строку с ответом. Возвращает строку с ответом.
""" """
contents = self._prepare_contents(prompt, images_list) contents, opened_images = self._prepare_contents(prompt, images_list)
logger.info(f"Generating text: {prompt}") logger.info(f"Generating text: {prompt}")
try: try:
response = self.client.models.generate_content( response = self.client.models.generate_content(
@@ -68,6 +70,9 @@ class GoogleAdapter:
except Exception as e: except Exception as e:
logger.error(f"Gemini Text API Error: {e}") logger.error(f"Gemini Text API Error: {e}")
raise GoogleGenerationException(f"Gemini Text API Error: {e}") raise GoogleGenerationException(f"Gemini Text API Error: {e}")
finally:
for img in opened_images:
img.close()
def generate_image(self, prompt: str, aspect_ratio: AspectRatios, quality: Quality, images_list: List[bytes] = None, ) -> Tuple[List[io.BytesIO], Dict[str, Any]]: def generate_image(self, prompt: str, aspect_ratio: AspectRatios, quality: Quality, images_list: List[bytes] = None, ) -> Tuple[List[io.BytesIO], Dict[str, Any]]:
""" """
@@ -75,7 +80,7 @@ class GoogleAdapter:
Возвращает список байтовых потоков (готовых к отправке). Возвращает список байтовых потоков (готовых к отправке).
""" """
contents = self._prepare_contents(prompt, images_list) contents, opened_images = self._prepare_contents(prompt, images_list)
logger.info(f"Generating image. Prompt length: {len(prompt)}, Ratio: {aspect_ratio}, Quality: {quality}") logger.info(f"Generating image. Prompt length: {len(prompt)}, Ratio: {aspect_ratio}, Quality: {quality}")
start_time = datetime.now() start_time = datetime.now()
@@ -148,3 +153,7 @@ class GoogleAdapter:
except Exception as e: except Exception as e:
logger.error(f"Gemini Image API Error: {e}") logger.error(f"Gemini Image API Error: {e}")
raise GoogleGenerationException(f"Gemini Image API Error: {e}") raise GoogleGenerationException(f"Gemini Image API Error: {e}")
finally:
for img in opened_images:
img.close()
del contents

View File

@@ -56,6 +56,21 @@ class S3Adapter:
print(f"Error downloading from S3: {e}") print(f"Error downloading from S3: {e}")
return None return None
async def stream_file(self, object_name: str, chunk_size: int = 65536):
"""Streams a file from S3 yielding chunks. Memory-efficient for large files."""
try:
async with self._get_client() as client:
response = await client.get_object(Bucket=self.bucket_name, Key=object_name)
async with response['Body'] as stream:
while True:
chunk = await stream.read(chunk_size)
if not chunk:
break
yield chunk
except ClientError as e:
print(f"Error streaming from S3: {e}")
return
async def delete_file(self, object_name: str): async def delete_file(self, object_name: str):
"""Deletes a file from S3.""" """Deletes a file from S3."""
try: try:

View File

@@ -9,7 +9,7 @@ from pymongo import MongoClient
from starlette import status from starlette import status
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import Response, JSONResponse from starlette.responses import Response, JSONResponse, StreamingResponse
from adapters.s3_adapter import S3Adapter from adapters.s3_adapter import S3Adapter
from api.models.AssetDTO import AssetsResponse, AssetResponse from api.models.AssetDTO import AssetsResponse, AssetResponse
@@ -33,27 +33,46 @@ async def get_asset(
asset_id: str, asset_id: str,
request: Request, request: Request,
thumbnail: bool = False, thumbnail: bool = False,
dao: DAO = Depends(get_dao) dao: DAO = Depends(get_dao),
s3_adapter: S3Adapter = Depends(get_s3_adapter),
) -> Response: ) -> Response:
logger.debug(f"get_asset called for ID: {asset_id}, thumbnail={thumbnail}") logger.debug(f"get_asset called for ID: {asset_id}, thumbnail={thumbnail}")
asset = await dao.assets.get_asset(asset_id) # Загружаем только метаданные (без data/thumbnail bytes)
# 2. Проверка на существование asset = await dao.assets.get_asset(asset_id, with_data=False)
if not asset: if not asset:
raise HTTPException(status_code=404, detail="Asset not found") raise HTTPException(status_code=404, detail="Asset not found")
headers = { headers = {
# Кэшировать на 1 год (31536000 сек)
"Cache-Control": "public, max-age=31536000, immutable" "Cache-Control": "public, max-age=31536000, immutable"
} }
content = asset.data # Thumbnail: маленький, можно грузить в RAM
media_type = "image/png" # Default, or detect if thumbnail:
if asset.minio_thumbnail_object_name and s3_adapter:
thumb_bytes = await s3_adapter.get_file(asset.minio_thumbnail_object_name)
if thumb_bytes:
return Response(content=thumb_bytes, media_type="image/jpeg", headers=headers)
# Fallback: thumbnail in DB
if asset.thumbnail:
return Response(content=asset.thumbnail, media_type="image/jpeg", headers=headers)
# No thumbnail available — fall through to main content
if thumbnail and asset.thumbnail: # Main content: стримим из S3 без загрузки в RAM
content = asset.thumbnail if asset.minio_object_name and s3_adapter:
media_type = "image/jpeg" content_type = "image/png"
if asset.content_type == AssetContentType.VIDEO:
content_type = "video/mp4"
return StreamingResponse(
s3_adapter.stream_file(asset.minio_object_name),
media_type=content_type,
headers=headers,
)
return Response(content=content, media_type=media_type, headers=headers) # Fallback: data stored in DB (legacy)
if asset.data:
return Response(content=asset.data, media_type="image/png", headers=headers)
raise HTTPException(status_code=404, detail="Asset data not found")
@router.delete("/orphans", dependencies=[Depends(get_current_user)]) @router.delete("/orphans", dependencies=[Depends(get_current_user)])
async def delete_orphan_assets_from_minio( async def delete_orphan_assets_from_minio(

View File

@@ -50,16 +50,18 @@ async def generate_image_task(
logger.info(f"generate_image_task completed, received {len(generated_images_io) if generated_images_io else 0} images") logger.info(f"generate_image_task completed, received {len(generated_images_io) if generated_images_io else 0} images")
except GoogleGenerationException as e: except GoogleGenerationException as e:
raise e raise e
finally:
# Освобождаем входные данные — они больше не нужны
del media_group_bytes
images_bytes = [] images_bytes = []
if generated_images_io: if generated_images_io:
for img_io in generated_images_io: for img_io in generated_images_io:
# Читаем байты из BytesIO
img_io.seek(0) img_io.seek(0)
content = img_io.read() images_bytes.append(img_io.read())
images_bytes.append(content)
# Закрываем поток
img_io.close() img_io.close()
# Освобождаем список BytesIO сразу
del generated_images_io
return images_bytes, metrics return images_bytes, metrics