Ctrl K
ComunidadGuías…

agents.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
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