""" AI Agents for Artwork Analysis using Gemini API. Phase 3: Automated Metadata Generation with Image Optimization. """ import asyncio import logging from pathlib import Path from PIL import Image from sqlalchemy.orm import Session import ai_client from config import strip_markdown from models import ArtworkModel # Increase Pillow limit for high-res artwork Image.MAX_IMAGE_PIXELS = 200000000 logger = logging.getLogger("artwork-display-api.agents") # Shared focal-point derivation, used by both vision passes (this module's upload analysis and the # RAG curator) so the instruction + parsing can't drift between them. FOCAL_POINT_INSTRUCTION = ( "Also include 'focal_point': the [x, y] location of the composition's main visual subject — the " "point a viewer's eye is drawn to that must stay in frame when the image is cropped or panned " "(e.g. a portrait's face, the principal figure or object). Use normalized coordinates where " "[0, 0] is the top-left corner and [1, 1] is the bottom-right (a face in the upper middle is " "about [0.5, 0.3]); use [0.5, 0.5] only when there is no single clear subject." ) def apply_focal_point(artwork: ArtworkModel, metadata: dict) -> None: """Store an optional 'focal_point': [x, y] (normalized 0..1) from a vision response as the artwork's framing anchor. Tolerant of missing/malformed values (keeps the prior focal point).""" fp = metadata.get("focal_point") if not (isinstance(fp, (list, tuple)) and len(fp) == 2): return try: x = min(1.0, max(0.0, float(fp[0]))) y = min(1.0, max(0.0, float(fp[1]))) except (TypeError, ValueError): return # parse both before assigning — never leave a half-applied focal point artwork.focal_x = x artwork.focal_y = y async def process_artwork(artwork_id: int, db: Session, user_hint: str = None): """ Analyzes artwork using Gemini 2.5 Flash. Optimizes image size before sending to prevent timeouts. """ logger.info(f"[AI Agent] Starting analysis for artwork ID: {artwork_id} (Hint: {user_hint or 'None'})") artwork = db.query(ArtworkModel).filter(ArtworkModel.id == artwork_id).first() if not artwork: return from config import LIBRARY_DIR image_path = LIBRARY_DIR / artwork.filename if not image_path.exists(): return # Clean filename for context clean_filename = Path(artwork.filename).stem.replace("_", " ").replace("-", " ") try: # Optimization: Resize for AI processing (max 2048px) handled by ai_client.image_part. image_data = ai_client.image_part(str(image_path)) system_instruction = ( "You are a strict, factual museum curator. I am providing an image. " f"The original filename was \"{clean_filename}\". Use this filename as a hint for the title or artist ONLY if it contains readable words; " "ignore it if it looks like random letters/numbers. " ) if user_hint: system_instruction += f"If a User Hint is provided: \"{user_hint}\", treat it as absolute fact and build your description around it. " system_instruction += ( "If you cannot confidently identify the creator from the visual data or hints, explicitly state \"Unknown Artist\" rather than guessing. " "Return ONLY a valid JSON object strictly using these keys: " "'title', 'agent_name', 'agent_role' (e.g., 'Painter', 'Attributed to'), 'creation_date', 'cultural_context' (e.g., 'Dutch', 'Post-Impressionist'), " "'medium' (e.g., 'Oil on canvas'), 'physical_dimensions', 'current_repository' (museum location if known, else 'Unknown'), " "'date_display' (a formatted string like 'c. 1890', or '19th century'), " "'description_narrative' (a 2-sentence museum-style blurb), " "and 'tags' (a flat array of 5-10 descriptive strings covering mood, subject, style, " "and season if applicable). " + FOCAL_POINT_INSTRUCTION ) response_text = await asyncio.to_thread( ai_client.chat, "vision", [{"role": "user", "content": [ai_client.text_part(system_instruction), image_data]}], json_mode=True, ) metadata = ai_client.parse_json(response_text) logger.info(f"[AI Agent] Metadata generated for {artwork.filename}") artwork.title = strip_markdown(metadata.get('title', 'Untitled')) artwork.agent_name = metadata.get('agent_name', 'Unknown Artist') artwork.agent_role = metadata.get('agent_role', 'Artist') artwork.creation_date = metadata.get('creation_date', 'Unknown') artwork.cultural_context = metadata.get('cultural_context', '') artwork.medium = metadata.get('medium', '') artwork.date_display = metadata.get('date_display', '') artwork.description_narrative = strip_markdown(metadata.get('description_narrative', '')) tags = metadata.get('tags', []) artwork.tags = ", ".join(tags) if isinstance(tags, list) else str(tags) apply_focal_point(artwork, metadata) db.commit() ai_client.clear_failure() # engine demonstrably healthy — drop any stale error banner return artwork # Return updated object except Exception as e: logger.error(f"[AI Agent] AI processing failed: {e}", exc_info=True) db.rollback() # Record WHY, so the Review Queue can say it. Without this the artwork just lands with empty # metadata and the only evidence is this log line — the user is told nothing. ai_client.record_failure(e) return None