refactor: remove dead assistant_chat system, consolidate image helpers
The old /assistant/chats/* CRUD endpoints and assistant_chat_service
chat functions were unused — the frontend exclusively uses
/ai-sessions/{id}/chat (unified_chat_service) for all chat operations.
Removed:
- Chat CRUD endpoints (create, list, get, send, delete, conclude)
- assistant_chat_service: create_chat, send_message,
generate_conclusion_summary, CONCLUSION_SYSTEM_PROMPT
- Frontend: assistantChatApi chat methods, dead types
(AssistantChat, AssistantChatMessage, ConcludeChatRequest, etc.)
Kept:
- /assistant/retention endpoints (used by ChatRetentionSettingsPage)
- Shared AI infrastructure (_call_ai, _call_anthropic_cached,
ASSISTANT_SYSTEM_PROMPT, _auto_title) — imported by unified_chat_service
Moved:
- fetch_upload_images + resize_image_for_vision → storage_service.py
(shared location, not tied to dead endpoint)
Also added "Image Analysis" section to system prompt so Claude knows
to describe attached screenshots.
-650 lines of dead code removed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
"""S3-compatible object storage service for file uploads."""
|
||||
import base64
|
||||
import logging
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config as BotoConfig
|
||||
@@ -92,3 +95,107 @@ async def delete_file(storage_key: str) -> None:
|
||||
client.delete_object(Bucket=settings.STORAGE_BUCKET_NAME, Key=storage_key)
|
||||
except ClientError:
|
||||
logger.warning(f"Failed to delete S3 object: {storage_key}")
|
||||
|
||||
|
||||
# ── Vision helpers (resize + fetch for AI) ─────────────────────
|
||||
|
||||
# Claude vision costs: (width × height) / 750 tokens per image.
|
||||
# Claude auto-resizes images >1568px on the longest edge.
|
||||
# We resize server-side to avoid sending multi-MB base64 payloads over the wire.
|
||||
MAX_IMAGE_DIMENSION = 1568 # Claude's max efficient resolution
|
||||
MAX_IMAGES_PER_MESSAGE = 3 # Cap to control token budget
|
||||
|
||||
|
||||
def resize_image_for_vision(file_data: bytes, content_type: str) -> tuple[bytes, str]:
|
||||
"""Resize image to fit within Claude's efficient vision bounds.
|
||||
|
||||
Returns (resized_bytes, media_type). Converts PNG screenshots to JPEG
|
||||
when it reduces size significantly (screenshots are often huge PNGs).
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(BytesIO(file_data))
|
||||
w, h = img.size
|
||||
|
||||
# Only resize if larger than Claude's max efficient dimension
|
||||
if max(w, h) > MAX_IMAGE_DIMENSION:
|
||||
ratio = MAX_IMAGE_DIMENSION / max(w, h)
|
||||
new_w, new_h = int(w * ratio), int(h * ratio)
|
||||
img = img.resize((new_w, new_h), Image.LANCZOS)
|
||||
|
||||
# Convert RGBA (common in screenshots) to RGB for JPEG
|
||||
out_type = content_type
|
||||
if img.mode in ("RGBA", "P") and content_type == "image/png":
|
||||
img = img.convert("RGB")
|
||||
out_type = "image/jpeg"
|
||||
|
||||
buf = BytesIO()
|
||||
if out_type == "image/jpeg":
|
||||
img.save(buf, format="JPEG", quality=85, optimize=True)
|
||||
else:
|
||||
img.save(buf, format=img.format or "PNG", optimize=True)
|
||||
|
||||
result = buf.getvalue()
|
||||
|
||||
# Only use resized version if it's actually smaller
|
||||
if len(result) < len(file_data):
|
||||
return result, out_type
|
||||
return file_data, content_type
|
||||
|
||||
except ImportError:
|
||||
# Pillow not installed — send original (Claude auto-resizes)
|
||||
logger.debug("Pillow not available, sending original image to Claude")
|
||||
return file_data, content_type
|
||||
except Exception:
|
||||
logger.warning("Image resize failed, sending original")
|
||||
return file_data, content_type
|
||||
|
||||
|
||||
async def fetch_upload_images(
|
||||
upload_ids: list[UUID],
|
||||
account_id: UUID,
|
||||
db: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch uploaded images from S3 and return as base64-encoded dicts for Claude vision.
|
||||
|
||||
Resizes images server-side to reduce network payload and applies a per-message
|
||||
cap to control token budget (~1,600 tokens per full-res image).
|
||||
"""
|
||||
if not upload_ids or not settings.STORAGE_ENDPOINT:
|
||||
return []
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.models.file_upload import FileUpload
|
||||
|
||||
# Cap the number of images to limit token cost
|
||||
capped_ids = upload_ids[:MAX_IMAGES_PER_MESSAGE]
|
||||
if len(upload_ids) > MAX_IMAGES_PER_MESSAGE:
|
||||
logger.info(
|
||||
"Capped images from %d to %d for token budget",
|
||||
len(upload_ids), MAX_IMAGES_PER_MESSAGE,
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(FileUpload).where(
|
||||
FileUpload.id.in_(capped_ids),
|
||||
FileUpload.account_id == account_id,
|
||||
FileUpload.content_type.in_(ALLOWED_IMAGE_TYPES),
|
||||
)
|
||||
)
|
||||
uploads = result.scalars().all()
|
||||
|
||||
images: list[dict[str, Any]] = []
|
||||
for upload in uploads:
|
||||
try:
|
||||
file_data = download_file(upload.storage_key)
|
||||
resized_data, media_type = resize_image_for_vision(
|
||||
file_data, upload.content_type
|
||||
)
|
||||
images.append({
|
||||
"media_type": media_type,
|
||||
"data": base64.b64encode(resized_data).decode("ascii"),
|
||||
})
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch upload %s from S3", upload.id)
|
||||
return images
|
||||
|
||||
Reference in New Issue
Block a user