Ctrl K
ComunidadGuías…

scout.py

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

Archivo original ↗Preguntar a KiBot
"""
Modular Semantic Art Scout for Pieria.
Discovers new high-resolution public-domain art.

Smart Search: Uses QueryClassifier to dispatch API-specific optimized queries.
"""

import asyncio
import difflib
import json
import logging
import random
import re
import time
import traceback
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from urllib.parse import quote

import httpx
from sqlalchemy.orm import Session

from core import safe_http
from core.downloads import guarded_stream
from models import SettingsModel
from query_classifier import SearchIntent

logger = logging.getLogger("artwork-display-api.scout")

# ---------------------------------------------------------------------------
# Shared source helpers (canonical home).
# The offline catalog builder (tools/sources.py) imports these so live Scouts
# and the builder apply identical PD / resolution logic and never drift.
# ---------------------------------------------------------------------------

# A display image must be at least this many pixels on its long edge to look
# good on big (up-to-4K) displays. Used to gate Wikimedia originals.
MIN_DISPLAY_EDGE = 2000

_TAG_RE = re.compile(r"<[^>]+>")


def _strip_html(s) -> str:
    return _TAG_RE.sub("", s or "").strip()


def _ratio(a, b) -> float:
    return difflib.SequenceMatcher(None, (a or "").lower(), (b or "").lower()).ratio()


def _wikimedia_filepath(filename: str, width: int) -> str:
    return f"https://commons.wikimedia.org/wiki/Special:FilePath/{quote(filename)}?width={width}"


def _wm_match(fname: str, title: str, artist: str = "") -> float:
    """Score a Commons filename against a wanted work. Commons filenames embed artist/date
    (e.g. 'Mucha-Job-1896.jpg'), so a plain ratio underrates short titles like 'Job' — instead
    reward title-token containment, then nudge for the artist surname."""
    f = re.sub(r"[_\-]+", " ", fname.rsplit(".", 1)[0]).lower()
    toks = [w for w in re.findall(r"[a-z0-9]+", title.lower()) if len(w) > 2]
    if toks and all(t in f for t in toks):
        score = 0.9
    else:
        present = (sum(1 for t in toks if t in f) / len(toks)) if toks else 0.0
        score = max(present, _ratio(f, title))
    if artist:
        surname = artist.lower().split()[-1]
        if len(surname) > 2 and surname in f:
            score = min(1.0, score + 0.15)
    return score


def _wm_is_pd(extmeta: dict) -> bool:
    parts = []
    for k in ("LicenseShortName", "License", "UsageTerms"):
        v = (extmeta.get(k) or {}).get("value")
        if v:
            parts.append(str(v).lower())
    blob = " ".join(parts)
    if "public domain" in blob or "cc0" in blob or "pd-" in blob:
        return True
    if (extmeta.get("Copyrighted") or {}).get("value", "").strip().lower() in ("false", "no"):
        return True
    return False


# Polite Wikimedia rate limiting: one request at a time, spaced out, descriptive UA.
_wm_lock = asyncio.Lock()
_wm_last = [0.0]
WM_MIN_INTERVAL = 1.2


async def _wm_throttle():
    async with _wm_lock:
        wait = WM_MIN_INTERVAL - (time.monotonic() - _wm_last[0])
        if wait > 0:
            await asyncio.sleep(wait)
        _wm_last[0] = time.monotonic()


async def _nasa_best_asset(client, nasa_id: str):
    """Resolve the real full-resolution image from a NASA asset manifest (prefer ~orig/~large jpg).
    The search 'links' only give a thumbnail, and naive ~orig.jpg guessing often 404s."""
    try:
        r = await client.get(f"https://images-assets.nasa.gov/image/{nasa_id}/collection.json", timeout=20.0)
        if r.status_code != 200:
            return None
        urls = [u for u in r.json() if isinstance(u, str) and u.lower().endswith((".jpg", ".jpeg", ".png"))]
    except Exception:
        return None
    if not urls:
        return None
    for suffix in ("~orig.jpg", "~orig.jpeg", "~orig.png", "~large.jpg", "~medium.jpg"):
        for u in urls:
            if u.lower().endswith(suffix):
                return u
    return urls[-1]

class MuseumScout(ABC):
    @abstractmethod
    async def find_art(self, query: str = None, intent: SearchIntent = None,
                       offset: int = 0, limit: int = 10) -> List[Dict]:
        """Returns a list of art dictionaries with source_url, thumbnail_url, etc."""
        pass


class ChicagoArtScout(MuseumScout):
    """
    Scout for the Art Institute of Chicago.
    Uses Elasticsearch DSL for targeted field queries.
    """
    API_URL = "https://api.artic.edu/api/v1/artworks/search"

    async def find_art(self, query: str = None, intent: SearchIntent = None,
                       offset: int = 0, limit: int = 10) -> List[Dict]:
        q = query or "painting"
        logger.info(f"[Scout] ChicagoArtScout searching for: {q} (intent: {intent.query_type if intent else 'none'}, offset: {offset})")
        found = []
        headers = {"User-Agent": "Pieria/1.0"}

        try:
            async with httpx.AsyncClient(headers=headers) as client:
                # Build query params based on intent type
                params = {
                    "fields": "id,title,artist_title,image_id,date_display,medium_display,"
                              "classification_titles,style_titles,is_boosted,thumbnail",
                    "limit": limit,
                    "page": (offset // limit) + 1,
                }

                if intent and intent.query_type == "artist":
                    # Use canonical name for better artist matching via q param
                    # Chicago's q param naturally boosts artist_title field matches
                    params["q"] = intent.canonical_name
                    params["query[term][is_public_domain]"] = "true"
                elif intent and intent.query_type == "genre":
                    # Genre search — q param matches against style_titles, classification_titles
                    params["q"] = intent.canonical_name
                    params["query[term][is_public_domain]"] = "true"
                else:
                    # Freetext / subject: use generic q param
                    params["q"] = q
                    params["query[term][is_public_domain]"] = "true"

                response = await client.get(self.API_URL, params=params, timeout=15.0)
                if response.status_code != 200:
                    logger.error(f"[Scout] Chicago API returned {response.status_code}")
                    return []

                data = response.json()
                artworks = data.get('data', [])
                iiif_base = data.get('config', {}).get('iiif_url', 'https://www.artic.edu/iiif/2')

                for art in artworks:
                    image_id = art.get('image_id')
                    if not image_id:
                        continue

                    full_url = f"{iiif_base}/{image_id}/full/max/0/default.jpg"
                    thumb_url = f"{iiif_base}/{image_id}/full/400,/0/default.jpg"

                    found.append({
                        "source_url": full_url,
                        "thumbnail_url": thumb_url,
                        "proposed_title": art.get('title') or 'Unknown',
                        "proposed_artist": art.get('artist_title') or 'Unknown Artist',
                        "source_api": "Art Institute of Chicago",
                        "context_hints": json.dumps(art)
                    })
        except Exception:
            logger.error(f"[Scout] ChicagoArtScout failed: {traceback.format_exc()}")
        return found


class MetMuseumScout(MuseumScout):
    """
    Scout for the Metropolitan Museum of Art.
    Uses artistOrCulture and isHighlight flags for targeted queries.
    """
    SEARCH_URL = "https://collectionapi.metmuseum.org/public/collection/v1/search"
    OBJECT_URL = "https://collectionapi.metmuseum.org/public/collection/v1/objects/{id}"

    async def find_art(self, query: str = None, intent: SearchIntent = None,
                       offset: int = 0, limit: int = 10) -> List[Dict]:
        q = query or "painting"
        logger.info(f"[Scout] MetMuseumScout searching for: {q} (intent: {intent.query_type if intent else 'none'}, offset: {offset})")
        found = []
        headers = {"User-Agent": "Pieria/1.0"}

        try:
            async with httpx.AsyncClient(headers=headers, timeout=30.0) as client:
                params = {"q": intent.canonical_name if intent and intent.query_type == "artist" else q,
                           "hasImages": "true"}

                if intent and intent.query_type == "artist":
                    params["artistOrCulture"] = "true"
                elif intent and intent.query_type == "genre":
                    params["isHighlight"] = "true"

                response = await client.get(self.SEARCH_URL, params=params)
                if response.status_code != 200:
                    logger.error(f"[Scout] Met search returned {response.status_code}")
                    return []

                data = response.json()
                object_ids = data.get('objectIDs') or []  # Handle null explicitly
                logger.info(f"[Scout] Met search returned {len(object_ids)} object IDs (total: {data.get('total', 0)})")

                # Fallback: if artistOrCulture returned nothing, retry without it
                if not object_ids and intent and intent.query_type == "artist":
                    logger.info("[Scout] Met: artistOrCulture returned 0, retrying broad search...")
                    fallback_params = {"q": intent.canonical_name, "hasImages": "true"}
                    response = await client.get(self.SEARCH_URL, params=fallback_params)
                    if response.status_code == 200:
                        data = response.json()
                        object_ids = data.get('objectIDs') or []
                        logger.info(f"[Scout] Met fallback returned {len(object_ids)} object IDs")

                # Fallback: if isHighlight returned nothing for genre, retry without it
                if not object_ids:
                    if intent and intent.query_type == "genre" and "isHighlight" in params:
                        logger.info("[Scout] Met: No highlights found, retrying without isHighlight...")
                        del params["isHighlight"]
                        response = await client.get(self.SEARCH_URL, params=params)
                        if response.status_code == 200:
                            data = response.json()
                            object_ids = data.get('objectIDs') or []
                    if not object_ids:
                        logger.info("[Scout] Met: No results after fallbacks")
                        return []

                # Paginate: take a slice based on offset and limit
                selected_ids = object_ids[offset:offset + limit]
                if not selected_ids:
                    return []

                # Fetch object details sequentially with small delay
                # Met API rate-limits aggressive concurrent requests
                logger.info(f"[Scout] Met: Fetching details for {len(selected_ids)} objects: {selected_ids}")
                for obj_id in selected_ids:
                    try:
                        resp = await client.get(self.OBJECT_URL.format(id=obj_id))
                        if resp.status_code == 200:
                            obj_data = resp.json()
                            img_url = obj_data.get('primaryImage')
                            if not img_url:
                                logger.debug(f"[Scout] Met object {obj_id}: no primaryImage, skipping")
                                continue

                            found.append({
                                "source_url": img_url,
                                "thumbnail_url": obj_data.get('primaryImageSmall') or img_url,
                                "proposed_title": obj_data.get('title') or 'Unknown',
                                "proposed_artist": obj_data.get('artistDisplayName') or 'Unknown Artist',
                                "source_api": "The Metropolitan Museum of Art",
                                "context_hints": json.dumps(obj_data)
                            })
                        else:
                            logger.warning(f"[Scout] Met object {obj_id}: HTTP {resp.status_code}")
                    except Exception as e:
                        logger.warning(f"[Scout] Met object {obj_id} fetch failed: {e}")
                    # Small delay to avoid rate limiting
                    await asyncio.sleep(0.2)

        except Exception:
            logger.error(f"[Scout] MetMuseumScout failed: {traceback.format_exc()}")
        logger.info(f"[Scout] Met: returning {len(found)} artworks")
        return found


class ClevelandArtScout(MuseumScout):
    """
    Scout for the Cleveland Museum of Art.
    Uses dedicated 'artists' param and 'highlight' flag.
    """
    API_URL = "https://openaccess-api.clevelandart.org/api/artworks/"

    async def find_art(self, query: str = None, intent: SearchIntent = None,
                       offset: int = 0, limit: int = 10) -> List[Dict]:
        q = query or "painting"
        logger.info(f"[Scout] ClevelandArtScout searching for: {q} (intent: {intent.query_type if intent else 'none'}, offset: {offset})")
        found = []
        headers = {"User-Agent": "Pieria/1.0"}

        try:
            async with httpx.AsyncClient(headers=headers) as client:
                params = {"has_image": "1", "cc0": "1", "limit": limit, "skip": offset}

                if intent and intent.query_type == "artist":
                    # Use dedicated artists param for precise artist search
                    params["artists"] = intent.canonical_name
                elif intent and intent.query_type == "genre":
                    params["q"] = intent.canonical_name
                    params["type"] = "Painting"
                    params["highlight"] = "1"
                else:
                    params["q"] = q

                response = await client.get(self.API_URL, params=params, timeout=15.0)
                if response.status_code != 200:
                    return []

                data = response.json()
                artworks = data.get('data', [])
                for art in artworks:
                    images = art.get('images', {})
                    if not images:
                        continue
                    full_res = images.get('print', {}).get('url') or images.get('web', {}).get('url')
                    if not full_res:
                        continue
                    creators = art.get('creators', [])
                    artist = creators[0].get('description') if creators else 'Unknown Artist'
                    found.append({
                        "source_url": full_res,
                        "thumbnail_url": images.get('web', {}).get('url') or full_res,
                        "proposed_title": art.get('title') or 'Unknown',
                        "proposed_artist": artist,
                        "source_api": "Cleveland Museum of Art",
                        "context_hints": json.dumps(art)
                    })
        except Exception:
            logger.error(f"[Scout] ClevelandArtScout failed: {traceback.format_exc()}")
        return found


class RijksmuseumScout(MuseumScout):
    """
    Scout for the Rijksmuseum (Amsterdam) using the free Open Data Search API.
    Does NOT require an API key.
    Uses Linked Art resolution to extract IIIF image endpoints and metadata.
    """
    SEARCH_URL = "https://data.rijksmuseum.nl/search/collection"

    async def find_art(self, query: str = None, intent: SearchIntent = None,
                       offset: int = 0, limit: int = 10) -> List[Dict]:
        q = query or "painting"
        logger.info(f"[Scout] RijksmuseumScout (Open Data) searching for: {q} (intent: {intent.query_type if intent else 'none'})")
        found = []
        try:
            # Pinned-resolution client (M2); redirects are followed per-hop by guarded_stream below.
            async with safe_http.safe_async_client() as client:
                params = {"imageAvailable": "true"}

                if intent and intent.query_type == "artist":
                    # Use 'creator' param for artist queries — returns actual works
                    # BY the artist, not just items mentioning them (posters, etc.)
                    # creator=Vincent van Gogh → 11 works with images
                    # vs description=Vincent van Gogh → 52 items (mostly merch)
                    params["creator"] = intent.canonical_name
                else:
                    # Use description for genre/subject/freetext
                    params["description"] = q

                response = await client.get(self.SEARCH_URL, params=params, timeout=15.0)
                if response.status_code != 200:
                    logger.error(f"[Scout] Rijksmuseum search failed: {response.status_code}")
                    return []

                data = response.json()
                items = data.get('orderedItems', [])
                if not items:
                    return []

                # Apply offset and limit
                selected_items = items[offset:offset + limit]

                for item in selected_items:
                    item_url = item.get('id')
                    if not item_url:
                        continue

                    await asyncio.sleep(0.2)  # B4: space the per-item resolution requests (rate-limit courtesy, like Met)

                    # Ensure we use the 'data' resolver directly to preserve profile params
                    item_url = item_url.replace("id.rijksmuseum.nl", "data.rijksmuseum.nl")

                    # Resolve each item using Dublin Core profile for easy metadata extraction
                    res_params = {"_profile": "dc"}
                    res_headers = {"Accept": "application/ld+json"}
                    # item_url comes from the API response (not a fixed host): every hop is SSRF-validated.
                    try:
                        async with guarded_stream(client, "GET", item_url, params=res_params,
                                                  headers=res_headers, timeout=10.0) as res_resp:
                            if res_resp.status_code != 200:
                                continue
                            await res_resp.aread()
                            item_data = res_resp.json()
                    except Exception as e:   # blocked/unsafe/too many redirects/network: skip the item
                        logger.warning(f"[Scout] Rijksmuseum item fetch refused/failed: {e}")
                        continue

                    # Extract IIIF image from 'relation' field
                    relation = item_data.get('relation', {})
                    img_base_url = None
                    if isinstance(relation, dict):
                        img_base_url = relation.get('@id')
                    elif isinstance(relation, list):
                        for r in relation:
                            if isinstance(r, dict) and r.get('@id'):
                                img_base_url = r.get('@id')
                                break

                    if not img_base_url:
                        continue

                    source_url = img_base_url
                    thumb_url = source_url.replace("/full/max/", "/full/400,/")

                    def extract_label(node):
                        if isinstance(node, str):
                            return node
                        if isinstance(node, dict):
                            t_val = node.get('title')
                            if isinstance(t_val, list):
                                for t in t_val:
                                    if isinstance(t, dict) and t.get('@language') == 'en':
                                        return t.get('@value')
                                if t_val:
                                    return t_val[0].get('@value') if isinstance(t_val[0], dict) else t_val[0]
                            return t_val or node.get('@id')
                        return str(node)

                    artist_node = item_data.get('creator', {})
                    artist = extract_label(artist_node) or "Unknown Artist"

                    raw_title = item_data.get('title')

                    found.append({
                        "source_url": source_url,
                        "thumbnail_url": thumb_url,
                        "proposed_title": raw_title or 'Unknown Title',
                        "proposed_artist": artist,
                        "source_api": "Rijksmuseum",
                        "context_hints": json.dumps({
                            "source_lang": "nl",
                            "original_title": raw_title,
                            "raw_metadata": item_data
                        })
                    })
        except Exception:
            logger.error(f"[Scout] RijksmuseumScout failed: {traceback.format_exc()}")
        return found


class SmkScout(MuseumScout):
    """
    Scout for the Statens Museum for Kunst (Denmark).
    Post-filters artist results to ensure artist match.
    """
    API_URL = "https://api.smk.dk/api/v1/art/search/"

    async def find_art(self, query: str = None, intent: SearchIntent = None,
                       offset: int = 0, limit: int = 10) -> List[Dict]:
        q = query or "*"
        logger.info(f"[Scout] SmkScout searching for: {q} (intent: {intent.query_type if intent else 'none'}, offset: {offset})")
        found = []
        try:
            async with httpx.AsyncClient() as client:
                params = {
                    "keys": intent.canonical_name if intent and intent.query_type == "artist" else q,
                    "filters": "[has_image:true],[public_domain:true]",
                    "lang": "en",
                    "rows": limit * 3 if intent and intent.query_type == "artist" else limit,
                    "offset": offset,
                }
                response = await client.get(self.API_URL, params=params, timeout=15.0)
                if response.status_code != 200:
                    return []

                data = response.json()
                items = data.get('items', [])
                count = 0
                for item in items:
                    if count >= limit:
                        break
                    image_url = item.get('image_native')
                    if not image_url:
                        iiif_id = item.get('image_iiif_id')
                        if iiif_id:
                            image_url = f"https://iip.smk.dk/iiif/jp2/{iiif_id}/full/max/0/default.jpg"
                    if not image_url:
                        continue

                    artist = "Unknown Artist"
                    production = item.get('production', [])
                    if production and isinstance(production, list):
                        artist = production[0].get('creator', artist)

                    # Post-filter: for artist queries, ensure artist matches
                    if intent and intent.query_type == "artist":
                        artist_lower = artist.lower()
                        query_lower = intent.original_query.lower()
                        # Check if query terms appear in artist name
                        if not any(term in artist_lower for term in query_lower.split()):
                            continue

                    found.append({
                        "source_url": image_url,
                        "thumbnail_url": item.get('image_thumbnail') or image_url,
                        "proposed_title": item.get('titles', [{}])[0].get('title') or 'Unknown',
                        "proposed_artist": artist,
                        "source_api": "Statens Museum for Kunst (Denmark)",
                        "context_hints": json.dumps(item)
                    })
                    count += 1
        except Exception:
            logger.error(f"[Scout] SmkScout failed: {traceback.format_exc()}")
        return found


# ---------------------------------------------------------------------------
# Premium (Tier-2) Scouts — Unchanged for now, future optimization pass
# ---------------------------------------------------------------------------

class HarvardScout(MuseumScout):
    """Tier-2 Scout for Harvard Art Museums requiring API key."""
    API_URL = "https://api.harvardartmuseums.org/object"

    def __init__(self, api_key: str = None):
        self.api_key = api_key

    async def find_art(self, query: str = None, intent: SearchIntent = None,
                       offset: int = 0, limit: int = 10) -> List[Dict]:
        if not self.api_key:
            return []

        logger.info(f"[Scout] HarvardScout searching for: {query or 'public domain'}")
        found = []
        try:
            async with httpx.AsyncClient() as client:
                params = {
                    "apikey": self.api_key,
                    "keyword": query or "public domain",
                    "hasimage": 1,
                    "size": limit,
                    "page": (offset // limit) + 1 if query else random.randint(1, 10),
                    "fields": "id,title,people,images,century,culture,medium,dimensions,creditline"
                }
                response = await client.get(self.API_URL, params=params, timeout=15.0)
                if response.status_code != 200:
                    return []

                data = response.json()
                artworks = data.get('records', [])
                for art in artworks:
                    images = art.get('images', [])
                    if not images:
                        continue
                    img = images[0].get('baseimageurl')
                    if not img:
                        continue

                    found.append({
                        "source_url": f"{img}?width=2000&apikey={self.api_key}",
                        "thumbnail_url": f"{img}?width=400&apikey={self.api_key}",
                        "proposed_title": art.get('title') or 'Unknown',
                        "proposed_artist": art.get('people', [{}])[0].get('name', 'Unknown') if art.get('people') else 'Unknown',
                        "source_api": "Harvard Art Museums",
                        "context_hints": json.dumps(art)
                    })
        except Exception as e:
            logger.error(f"[Scout] Harvard error: {e}", exc_info=True)
        return found


class SmithsonianScout(MuseumScout):
    """Tier-2 Scout for Smithsonian Open Access requiring API key."""
    API_URL = "https://api.si.edu/openaccess/api/v1.0/search"

    def __init__(self, api_key: str = None):
        self.api_key = api_key

    async def find_art(self, query: str = None, intent: SearchIntent = None,
                       offset: int = 0, limit: int = 10) -> List[Dict]:
        if not self.api_key:
            return []

        logger.info(f"[Scout] Smithsonian searching for: {query or 'art'}")
        found = []
        try:
            async with httpx.AsyncClient() as client:
                params = {
                    "api_key": self.api_key,
                    "q": f"{query or 'art'} AND online_media_type:Images",
                    "rows": limit,
                    "start": offset if query else random.randint(0, 100)
                }
                response = await client.get(self.API_URL, params=params, timeout=15.0)
                if response.status_code != 200:
                    return []

                rows = response.json().get('response', {}).get('rows', [])
                for art in rows:
                    content = art.get('content', {})
                    descriptive = content.get('descriptiveNonRepeating', {})

                    images = descriptive.get('online_media', {}).get('media', [])
                    if not images:
                        continue

                    img = [i for i in images if i.get('type') == 'Images']
                    if not img:
                        continue
                    img_url = img[0].get('content')
                    if not img_url:
                        continue

                    freetext = content.get('freetext', {})
                    creators = freetext.get('name', [])
                    artist = creators[0].get('content', 'Unknown') if creators else 'Unknown'

                    found.append({
                        "source_url": img_url,
                        "thumbnail_url": img[0].get('thumbnail', img_url),
                        "proposed_title": art.get('title') or 'Unknown Smithsonian Object',
                        "proposed_artist": artist,
                        "source_api": f"Smithsonian {descriptive.get('data_source', '')}",
                        "context_hints": json.dumps(art)
                    })
        except Exception as e:
            logger.error(f"[Scout] Smithsonian error: {e}", exc_info=True)
        return found


class EuropeanaScout(MuseumScout):
    """
    Tier-2 Scout for Europeana requiring API WSKey.

    Quality filters:
    - contentTier:3/4 — high-quality records with good images
    - IMAGE_SIZE:large/extra_large — decent resolution images
    - what:painting — restricts to paintings for artist searches
    - profile=rich — returns fuller metadata
    """
    API_URL = "https://api.europeana.eu/record/v2/search.json"

    def __init__(self, api_key: str = None):
        self.api_key = api_key

    async def find_art(self, query: str = None, intent: SearchIntent = None,
                       offset: int = 0, limit: int = 10) -> List[Dict]:
        if not self.api_key:
            return []

        q = query or "painting"
        logger.info(f"[Scout] Europeana searching for: {q} (intent: {intent.query_type if intent else 'none'}, offset: {offset})")
        found = []
        try:
            async with httpx.AsyncClient() as client:
                # Base params shared across all strategies
                base_params = {
                    "wskey": self.api_key,
                    "reusability": "open",
                    "rows": limit,
                    "media": True,
                    "start": offset + 1,
                    "profile": "rich",
                }

                # Strategy: progressive fallback for best quality results
                # For artists: who: field returns actual works BY the artist
                # For genres: text search + TYPE:IMAGE
                if intent and intent.query_type == "artist":
                    strategies = [
                        # 1st: who: field — small but precise (genuine works)
                        {
                            "query": f'who:"{intent.canonical_name}"',
                            "qf": ["TYPE:IMAGE"],
                        },
                        # 2nd: text + painting — broader, includes glass slides of paintings
                        {
                            "query": f'"{intent.canonical_name}" painting',
                            "qf": ["TYPE:IMAGE"],
                        },
                    ]
                else:
                    strategies = [
                        {
                            "query": q,
                            "qf": ["TYPE:IMAGE"],
                        },
                    ]

                items = []
                for strategy in strategies:
                    params = {
                        **base_params,
                        "query": strategy["query"],
                        "qf": strategy["qf"],
                    }
                    response = await client.get(self.API_URL, params=params, timeout=15.0)
                    if response.status_code != 200:
                        logger.warning(f"[Scout] Europeana returned {response.status_code} for query: {strategy['query']}")
                        continue

                    data = response.json()
                    items = data.get('items', [])
                    total = data.get('totalResults', 0)
                    logger.info(f"[Scout] Europeana strategy '{strategy['query']}' → {len(items)} items (total: {total})")
                    if items:
                        break  # Got results, stop trying fallbacks

                if not items:
                    logger.info("[Scout] Europeana: no results from any strategy")
                    return []

                for art in items:
                    # Prefer edmIsShownBy (full res) over edmPreview (thumbnail)
                    full_url = None
                    if art.get('edmIsShownBy'):
                        full_url = art['edmIsShownBy'][0]

                    thumb_url = None
                    if art.get('edmPreview'):
                        thumb_url = art['edmPreview'][0]

                    if not full_url and not thumb_url:
                        continue

                    img_url = full_url or thumb_url

                    artist = 'Unknown'
                    if art.get('dcCreator'):
                        artist = art['dcCreator'][0]
                    elif art.get('dcContributor'):
                        artist = art['dcContributor'][0]

                    title = art.get('title', ['Unknown Europeana Asset'])[0]

                    found.append({
                        "source_url": img_url,
                        "thumbnail_url": thumb_url or img_url,
                        "proposed_title": title,
                        "proposed_artist": artist,
                        "source_api": "Europeana",
                        "context_hints": json.dumps({
                            "provider": (art.get('dataProvider') or ['Unknown'])[0],
                            "country": (art.get('country') or ['Unknown'])[0],
                            "year": (art.get('year') or ['Unknown'])[0],
                            "rights": (art.get('rights') or ['Unknown'])[0],
                            "edmIsShownAt": (art.get('edmIsShownAt') or [None])[0],
                        })
                    })
        except Exception as e:
            logger.error(f"[Scout] Europeana error: {e}", exc_info=True)
        logger.info(f"[Scout] Europeana returning {len(found)} results")
        return found


# ---------------------------------------------------------------------------
# Keyless open-collection Scouts (NASA, Wikimedia Commons)
# ---------------------------------------------------------------------------

class NasaScout(MuseumScout):
    """
    Scout for the NASA images library (images-api.nasa.gov). Keyless, public domain.
    Resolves each search hit to its real full-resolution asset via the item's
    collection.json manifest (the search 'links' only expose a thumbnail).
    """
    SEARCH_URL = "https://images-api.nasa.gov/search"

    async def find_art(self, query: str = None, intent: SearchIntent = None,
                       offset: int = 0, limit: int = 10) -> List[Dict]:
        q = (intent.canonical_name if intent and intent.query_type == "artist" else query) or "galaxy"
        logger.info(f"[Scout] NasaScout searching for: {q} (offset: {offset})")
        found = []
        headers = {"User-Agent": "Pieria/1.0"}
        try:
            async with httpx.AsyncClient(headers=headers, timeout=30.0) as client:
                resp = await client.get(self.SEARCH_URL, params={"q": q, "media_type": "image"})
                if resp.status_code != 200:
                    logger.error(f"[Scout] NASA search returned {resp.status_code}")
                    return []
                items = resp.json().get("collection", {}).get("items", [])
                selected = items[offset:offset + limit]
                for it in selected:
                    d = (it.get("data") or [{}])[0]
                    links = it.get("links") or []
                    thumb = links[0].get("href") if links else None
                    if not thumb:
                        continue
                    full = await _nasa_best_asset(client, d.get("nasa_id", "")) or thumb
                    found.append({
                        "source_url": full,
                        "thumbnail_url": thumb,
                        "proposed_title": d.get("title") or "Untitled",
                        "proposed_artist": (f"NASA — {d.get('center')}" if d.get("center") else "NASA"),
                        "source_api": "NASA",
                        "context_hints": json.dumps(d),
                    })
        except Exception:
            logger.error(f"[Scout] NasaScout failed: {traceback.format_exc()}")
        logger.info(f"[Scout] NASA: returning {len(found)} artworks")
        return found


class WikimediaScout(MuseumScout):
    """
    Scout for Wikimedia Commons (commons.wikimedia.org). Keyless. Gates hard, in-path,
    on what the live pipeline cannot gate later: public-domain/CC0 license AND a real
    raster image at least MIN_DISPLAY_EDGE px on the long edge (display-grade). Politely
    rate-limited via the shared _wm_throttle.
    """
    API_URL = "https://commons.wikimedia.org/w/api.php"

    async def find_art(self, query: str = None, intent: SearchIntent = None,
                       offset: int = 0, limit: int = 10) -> List[Dict]:
        q = (intent.canonical_name if intent and intent.query_type == "artist" else query) or "painting"
        logger.info(f"[Scout] WikimediaScout searching for: {q} (offset: {offset})")
        found = []
        params = {
            "action": "query", "format": "json", "generator": "search",
            "gsrsearch": q, "gsrnamespace": "6",
            "gsrlimit": str(limit), "gsroffset": str(offset),
            "prop": "imageinfo", "iiprop": "url|mime|size|extmetadata",
        }
        await _wm_throttle()
        try:
            async with httpx.AsyncClient(headers={"User-Agent": "Pieria/1.0 (https://github.com/pieria-art/Pieria)"}, timeout=30.0) as client:
                resp = None
                for attempt in range(3):
                    resp = await client.get(self.API_URL, params=params)
                    if resp.status_code == 429:
                        await asyncio.sleep(3 * (attempt + 1)); continue
                    break
                if not resp or resp.status_code != 200:
                    logger.error(f"[Scout] Wikimedia returned {resp.status_code if resp else 'no response'}")
                    return []
                pages = (resp.json().get("query") or {}).get("pages") or {}
        except Exception:
            logger.error(f"[Scout] WikimediaScout failed: {traceback.format_exc()}")
            return []

        for p in pages.values():
            ii = (p.get("imageinfo") or [{}])[0]
            mime = ii.get("mime", "")
            if not mime.startswith("image/") or "svg" in mime:
                continue
            if max(ii.get("width") or 0, ii.get("height") or 0) < MIN_DISPLAY_EDGE:
                continue
            extmeta = ii.get("extmetadata") or {}
            if not _wm_is_pd(extmeta):
                continue
            page_title = p.get("title", "")  # "File:Foo.jpg"
            fname = page_title.split("File:", 1)[-1]
            if not fname:
                continue
            artist = _strip_html((extmeta.get("Artist") or {}).get("value", "")) or "Unknown Artist"
            found.append({
                "source_url": _wikimedia_filepath(fname, 3840),   # up to 4K (capped at the original)
                "thumbnail_url": _wikimedia_filepath(fname, 600),
                "proposed_title": _wm_clean_title((extmeta.get("ObjectName") or {}).get("value", ""), fname),
                "proposed_artist": artist,
                "source_api": "Wikimedia Commons",
                "context_hints": json.dumps({
                    "license": (extmeta.get("LicenseShortName") or {}).get("value", ""),
                    "width": ii.get("width"), "height": ii.get("height"),
                    "descriptionurl": ii.get("descriptionurl"),
                }),
            })
        logger.info(f"[Scout] Wikimedia: returning {len(found)} artworks")
        return found


def _title_from_filename(fn: str) -> str:
    stem = fn.rsplit(".", 1)[0]
    return stem.replace("_", " ").strip()


def _wm_clean_title(raw: str, fname: str) -> str:
    """Commons ObjectName often embeds Wikibase structured values
    (e.g. 'The Starry Nighttitle QS:P1476,en:"The Starry Night"'). Strip to a plain label,
    falling back to the filename when nothing clean remains."""
    t = re.split(r"\s*(?:title\s*)?QS:", _strip_html(raw or ""))[0].strip()
    return t or _title_from_filename(fname)


# ---------------------------------------------------------------------------
# Search Session State — In-memory store for "Load More" pagination
# ---------------------------------------------------------------------------

@dataclass
class SearchSession:
    """Tracks state for a paginated search session."""
    session_id: str
    query: str
    intent: SearchIntent
    sources: List[str]
    offset: int = 0
    limit: int = 10
    created_at: datetime = field(default_factory=datetime.utcnow)

# In-memory session store — survives as long as the server runs
_search_sessions: Dict[str, SearchSession] = {}
SESSION_TTL_MINUTES = 30


def create_search_session(query: str, intent: SearchIntent, sources: List[str], limit: int = 10) -> SearchSession:
    """Create a new search session and return it."""
    _cleanup_expired_sessions()
    session = SearchSession(
        session_id=str(uuid.uuid4()),
        query=query,
        intent=intent,
        sources=sources,
        offset=0,
        limit=limit,
    )
    _search_sessions[session.session_id] = session
    logger.info(f"[Session] Created search session {session.session_id[:8]}... "
                f"(query='{query}', sources={sources}, limit={limit})")
    return session


def get_search_session(session_id: str) -> Optional[SearchSession]:
    """Retrieve an active session by ID, or None if expired/not found."""
    _cleanup_expired_sessions()
    return _search_sessions.get(session_id)


def _cleanup_expired_sessions():
    """Remove sessions older than TTL."""
    now = datetime.utcnow()
    expired = [
        sid for sid, session in _search_sessions.items()
        if (now - session.created_at) > timedelta(minutes=SESSION_TTL_MINUTES)
    ]
    for sid in expired:
        del _search_sessions[sid]
        logger.info(f"[Session] Expired session {sid[:8]}...")


# ---------------------------------------------------------------------------
# Scout Dispatcher
# ---------------------------------------------------------------------------

async def run_scouts(db: Session, query: str = None, sources: List[str] = None,
                     intent: SearchIntent = None, offset: int = 0, limit: int = 10) -> List[Dict]:
    """
    Runs selected active scouts and returns results (without inserting into DB).
    The caller is responsible for insertion.
    """
    settings = db.query(SettingsModel).all()
    keys = {s.setting_key: s.setting_value for s in settings}

    all_scouts = {
        "chicago": ChicagoArtScout(),
        "met": MetMuseumScout(),
        "cleveland": ClevelandArtScout(),
        "rijks": RijksmuseumScout(),
        "smk": SmkScout(),
        "nasa": NasaScout(),
        "wikimedia": WikimediaScout(),
        "harvard": HarvardScout(keys.get("harvard_api_key")),
        "smithsonian": SmithsonianScout(keys.get("smithsonian_api_key")),
        "europeana": EuropeanaScout(keys.get("europeana_api_key"))
    }

    active_scouts = []
    if sources:
        for s in sources:
            if s in all_scouts:
                active_scouts.append(all_scouts[s])
    else:
        active_scouts = list(all_scouts.values())

    if not active_scouts:
        return []

    tasks = [scout.find_art(query=query, intent=intent, offset=offset, limit=limit) for scout in active_scouts]
    results_lists = await asyncio.gather(*tasks)

    # Flatten results
    all_results = []
    for results in results_lists:
        all_results.extend(results)

    logger.info(f"[Scout] Scouts returned {len(all_results)} total items across {len(active_scouts)} sources.")
    return all_results