From 4c631b281ea64b660f6f6e29ae5f782e45a97d03 Mon Sep 17 00:00:00 2001 From: Lichun Date: Fri, 31 Oct 2025 21:06:13 +0000 Subject: [PATCH 1/3] optimize query & remove legacy structure --- .../src/pages/ExplorePage/ExplorePage.tsx | 90 +++---- py_backend/app/crud.py | 165 ++++++++++++- py_backend/app/routers/upload.py | 225 ++++-------------- py_backend/app/schemas.py | 4 + py_backend/app/storage.py | 23 +- 5 files changed, 277 insertions(+), 230 deletions(-) diff --git a/frontend/src/pages/ExplorePage/ExplorePage.tsx b/frontend/src/pages/ExplorePage/ExplorePage.tsx index 08b8554d..58287bca 100644 --- a/frontend/src/pages/ExplorePage/ExplorePage.tsx +++ b/frontend/src/pages/ExplorePage/ExplorePage.tsx @@ -67,6 +67,7 @@ export default function ExplorePage() { const [showExportModal, setShowExportModal] = useState(false); const [isExporting, setIsExporting] = useState(false); const [exportSuccess, setExportSuccess] = useState(false); + const [fetchError, setFetchError] = useState(null); // Delete state management const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -86,11 +87,13 @@ export default function ExplorePage() { const fetchCaptions = useCallback(() => { setIsLoadingContent(true); + setFetchError(null); // Build query parameters for server-side filtering and pagination const params = new URLSearchParams({ page: currentPage.toString(), - limit: itemsPerPage.toString() + limit: itemsPerPage.toString(), + include_count: 'true' }); if (search) params.append('search', search); @@ -105,74 +108,40 @@ export default function ExplorePage() { fetch(`/api/images/grouped?${params.toString()}`) .then(r => { if (!r.ok) { - console.error('ExplorePage: Grouped endpoint failed, trying legacy endpoint'); - // Fallback to legacy endpoint for backward compatibility - return fetch('/api/captions/legacy').then(r2 => { - if (!r2.ok) { - console.error('ExplorePage: Legacy endpoint failed, trying regular images endpoint'); - return fetch('/api/images').then(r3 => { - if (!r3.ok) { - throw new Error(`HTTP ${r3.status}: ${r3.statusText}`); - } - return r3.json(); - }); - } - return r2.json(); - }); + throw new Error(`Failed to fetch images: ${r.status} ${r.statusText}`); } return r.json(); }) .then(data => { console.log('ExplorePage: Fetched captions:', data); - setCaptions(data); + if (data.items && typeof data.total_count === 'number') { + setCaptions(data.items); + setTotalItems(data.total_count); + setTotalPages(Math.ceil(data.total_count / itemsPerPage)); + } else if (Array.isArray(data)) { + setCaptions(data); + } else { + throw new Error('Unexpected response format'); + } + setFetchError(null); }) .catch(error => { console.error('ExplorePage: Error fetching captions:', error); + setFetchError(error instanceof Error ? error.message : 'Failed to load images. Please try again later.'); setCaptions([]); + setTotalItems(0); + setTotalPages(0); }) .finally(() => { setIsLoadingContent(false); }); }, [currentPage, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, uploadTypeFilter, showReferenceExamples, itemsPerPage]); - const fetchTotalCount = useCallback(() => { - // Build query parameters for count endpoint - const params = new URLSearchParams(); - - if (search) params.append('search', search); - if (srcFilter) params.append('source', srcFilter); - if (catFilter) params.append('event_type', catFilter); - if (regionFilter) params.append('region', regionFilter); - if (countryFilter) params.append('country', countryFilter); - if (imageTypeFilter) params.append('image_type', imageTypeFilter); - if (uploadTypeFilter) params.append('upload_type', uploadTypeFilter); - if (showReferenceExamples) params.append('starred_only', 'true'); - - fetch(`/api/images/grouped/count?${params.toString()}`) - .then(r => { - if (!r.ok) { - console.error('ExplorePage: Count endpoint failed'); - return { total_count: 0 }; - } - return r.json(); - }) - .then(data => { - console.log('ExplorePage: Total count:', data.total_count); - setTotalItems(data.total_count); - setTotalPages(Math.ceil(data.total_count / itemsPerPage)); - }) - .catch(error => { - console.error('ExplorePage: Error fetching total count:', error); - setTotalItems(0); - setTotalPages(0); - }); - }, [search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, uploadTypeFilter, showReferenceExamples, itemsPerPage]); // Fetch data when component mounts or filters change useEffect(() => { fetchCaptions(); - fetchTotalCount(); - }, [fetchCaptions, fetchTotalCount]); + }, [fetchCaptions]); // Reset to first page when filters change (but not when currentPage changes) useEffect(() => { @@ -672,8 +641,27 @@ export default function ExplorePage() { )} + {/* Error State */} + {!isLoadingContent && fetchError && ( +
+ +
+
Failed to Load Images
+
{fetchError}
+ +
+
+
+ )} + {/* Content */} - {!isLoadingContent && ( + {!isLoadingContent && !fetchError && (
{paginatedResults.map(c => (
diff --git a/py_backend/app/crud.py b/py_backend/app/crud.py index 48ea1c93..47135009 100644 --- a/py_backend/app/crud.py +++ b/py_backend/app/crud.py @@ -1,7 +1,8 @@ import io, hashlib import logging from typing import Optional, List -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session, joinedload, selectinload +from sqlalchemy import func, or_, and_, distinct, case from . import models, schemas from fastapi import HTTPException @@ -153,6 +154,168 @@ def get_all_captions_with_images(db: Session): .all() ) +def get_captions_with_images_filtered( + db: Session, + search: Optional[str] = None, + source: Optional[str] = None, + event_type: Optional[str] = None, + region: Optional[str] = None, + country: Optional[str] = None, + image_type: Optional[str] = None, + upload_type: Optional[str] = None, + starred_only: bool = False, + page: int = 1, + limit: int = 10, +): + """Get captions with filtered and paginated results using SQL queries""" + needs_grouping = upload_type is not None + needs_image_join = source is not None or event_type is not None or image_type is not None or region is not None or country is not None or upload_type is not None + + base_query = db.query(models.Captions) + + if search: + search_pattern = f"%{search.lower()}%" + base_query = base_query.filter( + or_( + func.lower(models.Captions.title).like(search_pattern), + func.lower(models.Captions.generated).like(search_pattern) + ) + ) + + if starred_only: + base_query = base_query.filter(models.Captions.starred == True) + + if needs_image_join: + base_query = base_query.join(models.images_captions).join(models.Images) + + if source: + base_query = base_query.filter(models.Images.source == source) + + if event_type: + base_query = base_query.filter(models.Images.event_type == event_type) + + if image_type: + base_query = base_query.filter(models.Images.image_type == image_type) + + if region or country: + base_query = base_query.join(models.image_countries).join(models.Country) + if region: + base_query = base_query.filter(models.Country.r_code == region) + if country: + base_query = base_query.filter(models.Country.c_code == country) + + if needs_grouping: + base_query = base_query.group_by(models.Captions.caption_id) + effective_count = case( + ( + func.max(models.Captions.image_count).isnot(None), + case( + (func.max(models.Captions.image_count) > 0, func.max(models.Captions.image_count)), + else_=func.count(distinct(models.Images.image_id)) + ) + ), + else_=func.count(distinct(models.Images.image_id)) + ) + if upload_type == 'single': + base_query = base_query.having(effective_count <= 1) + elif upload_type == 'multiple': + base_query = base_query.having(effective_count > 1) + + if needs_grouping: + count_query = base_query.with_entities(func.count()) + elif needs_image_join: + count_query = base_query.with_entities(func.count(distinct(models.Captions.caption_id))) + else: + count_query = base_query.with_entities(func.count(models.Captions.caption_id)) + total_count = count_query.scalar() + + query = base_query.order_by(models.Captions.created_at.desc()) + + offset = (page - 1) * limit + query = query.offset(offset).limit(limit) + + caption_ids = [row[0] for row in query.with_entities(models.Captions.caption_id).all()] + + captions = ( + db.query(models.Captions) + .filter(models.Captions.caption_id.in_(caption_ids)) + .options( + selectinload(models.Captions.images).selectinload(models.Images.countries) + ) + .order_by(models.Captions.created_at.desc()) + .all() + ) + + return captions, total_count + +def count_captions_with_images_filtered( + db: Session, + search: Optional[str] = None, + source: Optional[str] = None, + event_type: Optional[str] = None, + region: Optional[str] = None, + country: Optional[str] = None, + image_type: Optional[str] = None, + upload_type: Optional[str] = None, + starred_only: bool = False, +): + """Count captions matching filters using SQL queries""" + needs_grouping = upload_type is not None + needs_image_join = source is not None or event_type is not None or image_type is not None or region is not None or country is not None or upload_type is not None + + query = db.query(models.Captions.caption_id).distinct() + + if search: + search_pattern = f"%{search.lower()}%" + query = query.filter( + or_( + func.lower(models.Captions.title).like(search_pattern), + func.lower(models.Captions.generated).like(search_pattern) + ) + ) + + if starred_only: + query = query.filter(models.Captions.starred == True) + + if needs_image_join: + query = query.join(models.images_captions).join(models.Images) + + if source: + query = query.filter(models.Images.source == source) + + if event_type: + query = query.filter(models.Images.event_type == event_type) + + if image_type: + query = query.filter(models.Images.image_type == image_type) + + if region or country: + query = query.join(models.image_countries).join(models.Country) + if region: + query = query.filter(models.Country.r_code == region) + if country: + query = query.filter(models.Country.c_code == country) + + if needs_grouping: + query = query.group_by(models.Captions.caption_id) + effective_count = case( + ( + func.max(models.Captions.image_count).isnot(None), + case( + (func.max(models.Captions.image_count) > 0, func.max(models.Captions.image_count)), + else_=func.count(distinct(models.Images.image_id)) + ) + ), + else_=func.count(distinct(models.Images.image_id)) + ) + if upload_type == 'single': + query = query.having(effective_count <= 1) + elif upload_type == 'multiple': + query = query.having(effective_count > 1) + + count = query.count() + return count + def get_prompts(db: Session): """Get all available prompts""" return db.query(models.Prompts).all() diff --git a/py_backend/app/routers/upload.py b/py_backend/app/routers/upload.py index 01f11341..bee004fb 100644 --- a/py_backend/app/routers/upload.py +++ b/py_backend/app/routers/upload.py @@ -44,8 +44,11 @@ def get_db(): db.close() -def convert_image_to_dict(img, image_url): - """Helper function to convert SQLAlchemy image model to dict for Pydantic""" +def convert_image_to_dict(img, image_url, url_cache: Optional[dict[str, str]] = None): + """Helper function to convert SQLAlchemy image model to dict for Pydantic + + url_cache: Optional dict to cache generated URLs by key, avoiding duplicate presigned URL generation + """ countries_list = [] if hasattr(img, 'countries') and img.countries is not None: try: @@ -110,19 +113,19 @@ def convert_image_to_dict(img, image_url): created_at = first_caption.get("created_at") updated_at = first_caption.get("updated_at") - # Generate URLs for all image versions + # Generate URLs for all image versions (using cache if provided) thumbnail_url = None detail_url = None if hasattr(img, 'thumbnail_key') and img.thumbnail_key: try: - thumbnail_url = storage.get_object_url(img.thumbnail_key) + thumbnail_url = storage.get_object_url(img.thumbnail_key, cache=url_cache) except Exception as e: logger.warning(f"Error generating thumbnail URL for image {img.image_id}: {e}") if hasattr(img, 'detail_key') and img.detail_key: try: - detail_url = storage.get_object_url(img.detail_key) + detail_url = storage.get_object_url(img.detail_key, cache=url_cache) except Exception as e: logger.warning(f"Error generating detail URL for image {img.image_id}: {e}") @@ -181,14 +184,15 @@ def convert_image_to_dict(img, image_url): def list_images(db: Session = Depends(get_db)): """Get all images with their caption data""" images = crud.get_images(db) + url_cache: dict[str, str] = {} result = [] for img in images: - img_dict = convert_image_to_dict(img, f"/api/images/{img.image_id}/file") + img_dict = convert_image_to_dict(img, f"/api/images/{img.image_id}/file", url_cache=url_cache) result.append(schemas.ImageOut(**img_dict)) return result -@router.get("/grouped", response_model=List[schemas.ImageOut]) +@router.get("/grouped") def list_images_grouped( page: int = 1, limit: int = 10, @@ -200,67 +204,46 @@ def list_images_grouped( image_type: str = None, upload_type: str = None, starred_only: bool = False, + include_count: bool = False, db: Session = Depends(get_db) ): - """Get images grouped by shared captions for multi-upload items with pagination and filtering""" + """Get images grouped by shared captions for multi-upload items with pagination and filtering + + If include_count=true, returns {items: [], total_count: N} format. + Otherwise returns array format for backward compatibility. + """ - # Validate pagination parameters if page < 1: page = 1 if limit < 1 or limit > 100: limit = 10 - # Get all captions with their associated images - captions = crud.get_all_captions_with_images(db) - result = [] + captions, total_count = crud.get_captions_with_images_filtered( + db=db, + search=search, + source=source, + event_type=event_type, + region=region, + country=country, + image_type=image_type, + upload_type=upload_type, + starred_only=starred_only, + page=page, + limit=limit + ) + url_cache: dict[str, str] = {} + + result = [] for caption in captions: if not caption.images: continue effective_image_count = caption.image_count if caption.image_count is not None and caption.image_count > 0 else len(caption.images) - # Apply filters - if search: - search_lower = search.lower() - if not (caption.title and search_lower in caption.title.lower()) and \ - not (caption.generated and search_lower in caption.generated.lower()): - continue - - if starred_only and not caption.starred: - continue - if effective_image_count > 1: first_img = caption.images[0] - if source: - if not any(source in img.source for img in caption.images if img.source): - continue - if event_type: - if not any(event_type in img.event_type for img in caption.images if img.event_type): - continue - if image_type: - if not any(img.image_type == image_type for img in caption.images): - continue - if upload_type: - if upload_type == 'single' and effective_image_count > 1: - continue - if upload_type == 'multiple' and effective_image_count <= 1: - continue - if region or country: - has_matching_country = False - for img in caption.images: - for img_country in img.countries: - if region and img_country.r_code == region: - has_matching_country = True - break - if country and img_country.c_code == country: - has_matching_country = True - break - if has_matching_country: - break - if not has_matching_country: - continue - # Combine metadata from all images + combined_source = set() combined_event_type = set() combined_epsg = set() @@ -273,15 +256,12 @@ def list_images_grouped( if img.epsg: combined_epsg.add(img.epsg) - # Create a combined image dict using the first image as a template - img_dict = convert_image_to_dict(first_img, f"/api/images/{first_img.image_id}/file") + img_dict = convert_image_to_dict(first_img, f"/api/images/{first_img.image_id}/file", url_cache=url_cache) - # Override with combined metadata img_dict["source"] = ", ".join(sorted(list(combined_source))) if combined_source else "OTHER" img_dict["event_type"] = ", ".join(sorted(list(combined_event_type))) if combined_event_type else "OTHER" img_dict["epsg"] = ", ".join(sorted(list(combined_epsg))) if combined_epsg else "OTHER" - # Update countries to include all unique countries all_countries = [] for img in caption.images: for country_obj in img.countries: @@ -289,11 +269,9 @@ def list_images_grouped( all_countries.append({"c_code": country_obj.c_code, "label": country_obj.label, "r_code": country_obj.r_code}) img_dict["countries"] = all_countries - # Add all image IDs for reference img_dict["all_image_ids"] = [str(img.image_id) for img in caption.images] img_dict["image_count"] = effective_image_count - # Set caption-level fields img_dict["title"] = caption.title img_dict["prompt"] = caption.prompt img_dict["model"] = caption.model @@ -310,36 +288,12 @@ def list_images_grouped( result.append(schemas.ImageOut(**img_dict)) else: - # For single images, apply filters img = caption.images[0] - if source and img.source != source: - continue - if event_type and img.event_type != event_type: - continue - if image_type and img.image_type != image_type: - continue - if upload_type == 'multiple': - continue - - # Apply region/country filter - if region or country: - has_matching_country = False - for img_country in img.countries: - if region and img_country.r_code == region: - has_matching_country = True - break - if country and img_country.c_code == country: - has_matching_country = True - break - if not has_matching_country: - continue - - img_dict = convert_image_to_dict(img, f"/api/images/{img.image_id}/file") + img_dict = convert_image_to_dict(img, f"/api/images/{img.image_id}/file", url_cache=url_cache) img_dict["all_image_ids"] = [str(img.image_id)] img_dict["image_count"] = 1 - # Set caption-level fields img_dict["title"] = caption.title img_dict["prompt"] = caption.prompt img_dict["model"] = caption.model @@ -356,13 +310,9 @@ def list_images_grouped( result.append(schemas.ImageOut(**img_dict)) - # Apply pagination - total_count = len(result) - start_index = (page - 1) * limit - end_index = start_index + limit - paginated_result = result[start_index:end_index] - - return paginated_result + if include_count: + return {"items": result, "total_count": total_count} + return result @router.get("/grouped/count") def get_images_grouped_count( @@ -378,92 +328,17 @@ def get_images_grouped_count( ): """Get total count of images for pagination""" - # Get all captions with their associated images - captions = crud.get_all_captions_with_images(db) - count = 0 - - for caption in captions: - if not caption.images: - continue - - # Determine the effective image count for this caption - effective_image_count = caption.image_count if caption.image_count is not None and caption.image_count > 0 else len(caption.images) - - # Apply filters (same logic as above) - if search: - search_lower = search.lower() - if not (caption.title and search_lower in caption.title.lower()) and \ - not (caption.generated and search_lower in caption.generated.lower()): - continue - - if starred_only and not caption.starred: - continue - - if effective_image_count > 1: - # Multi-upload item - first_img = caption.images[0] - - # Apply filters - if source: - if not any(source in img.source for img in caption.images if img.source): - continue - - if event_type: - if not any(event_type in img.event_type for img in caption.images if img.event_type): - continue - - if image_type: - if not any(img.image_type == image_type for img in caption.images): - continue - - if upload_type: - if upload_type == 'single' and effective_image_count > 1: - continue - if upload_type == 'multiple' and effective_image_count <= 1: - continue - - if region or country: - has_matching_country = False - for img in caption.images: - for img_country in img.countries: - if region and img_country.r_code == region: - has_matching_country = True - break - if country and img_country.c_code == country: - has_matching_country = True - break - if has_matching_country: - break - if not has_matching_country: - continue - - count += 1 - else: - # Single image - img = caption.images[0] - - if source and img.source != source: - continue - if event_type and img.event_type != event_type: - continue - if image_type and img.image_type != image_type: - continue - if upload_type == 'multiple': - continue - - if region or country: - has_matching_country = False - for img_country in img.countries: - if region and img_country.r_code == region: - has_matching_country = True - break - if country and img_country.c_code == country: - has_matching_country = True - break - if not has_matching_country: - continue - - count += 1 + count = crud.count_captions_with_images_filtered( + db=db, + search=search, + source=source, + event_type=event_type, + region=region, + country=country, + image_type=image_type, + upload_type=upload_type, + starred_only=starred_only + ) return {"total_count": count} diff --git a/py_backend/app/schemas.py b/py_backend/app/schemas.py index c284d7e3..c4a6f2cb 100644 --- a/py_backend/app/schemas.py +++ b/py_backend/app/schemas.py @@ -211,4 +211,8 @@ class Config: class ModelToggleRequest(BaseModel): is_available: bool +class PaginatedImageOut(BaseModel): + items: List[ImageOut] + total_count: int + ImageOut.update_forward_refs() diff --git a/py_backend/app/storage.py b/py_backend/app/storage.py index 70ecd3be..ed290cef 100644 --- a/py_backend/app/storage.py +++ b/py_backend/app/storage.py @@ -47,15 +47,32 @@ def _ensure_bucket() -> None: s3.create_bucket(**create_kwargs) -def get_object_url(key: str, *, expires_in: int = 3600) -> str: - """Return browser-usable URL for object.""" +_url_cache: dict[str, str] = {} + +def get_object_url(key: str, *, expires_in: int = 3600, cache: Optional[dict[str, str]] = None) -> str: + """Return browser-usable URL for object. + + If cache dict is provided, URLs are cached per key to avoid duplicate presigned URL generation. + """ if settings.STORAGE_PROVIDER == "local": return f"/uploads/{key}" public_base = getattr(settings, "S3_PUBLIC_URL_BASE", None) if public_base: return f"{public_base.rstrip('/')}/{key}" - return generate_presigned_url(key, expires_in=expires_in) + + cache_dict = cache if cache is not None else _url_cache + + if key in cache_dict: + return cache_dict[key] + + url = generate_presigned_url(key, expires_in=expires_in) + cache_dict[key] = url + return url + +def clear_url_cache(): + """Clear the global URL cache (mainly for testing).""" + _url_cache.clear() def generate_presigned_url(key: str, expires_in: int = 3600) -> str: From 9213aa81c54285e055484d9ea45d393dbef276e2 Mon Sep 17 00:00:00 2001 From: Lichun Date: Fri, 31 Oct 2025 21:33:11 +0000 Subject: [PATCH 2/3] B tree indexing --- .../versions/0022_add_filtering_indexes.py | 47 +++++++++++++++++++ py_backend/app/models.py | 10 +++- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 py_backend/alembic/versions/0022_add_filtering_indexes.py diff --git a/py_backend/alembic/versions/0022_add_filtering_indexes.py b/py_backend/alembic/versions/0022_add_filtering_indexes.py new file mode 100644 index 00000000..744b9a6b --- /dev/null +++ b/py_backend/alembic/versions/0022_add_filtering_indexes.py @@ -0,0 +1,47 @@ +"""add_filtering_indexes + +Revision ID: 0022 +Revises: 0021 +Create Date: 2024-12-19 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0022' +down_revision = '0021' +branch_labels = None +depends_on = None + + +def upgrade(): + # Indexes for images table - commonly filtered columns + # Note: ix_images_captured_at already exists from migration 0016, skip it here + op.execute("CREATE INDEX IF NOT EXISTS ix_images_source ON images(source)") + op.execute("CREATE INDEX IF NOT EXISTS ix_images_event_type ON images(event_type)") + op.execute("CREATE INDEX IF NOT EXISTS ix_images_image_type ON images(image_type)") + + # Indexes for captions table - filtered and ordered columns + op.execute("CREATE INDEX IF NOT EXISTS ix_captions_starred ON captions(starred)") + op.execute("CREATE INDEX IF NOT EXISTS ix_captions_created_at ON captions(created_at)") + + # Indexes for join tables - improve join performance + op.execute("CREATE INDEX IF NOT EXISTS ix_image_countries_image_id ON image_countries(image_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_image_countries_c_code ON image_countries(c_code)") + + # Index on Country.r_code for region filtering + op.execute("CREATE INDEX IF NOT EXISTS ix_countries_r_code ON countries(r_code)") + + +def downgrade(): + op.drop_index('ix_countries_r_code', table_name='countries', if_exists=True) + op.drop_index('ix_image_countries_c_code', table_name='image_countries', if_exists=True) + op.drop_index('ix_image_countries_image_id', table_name='image_countries', if_exists=True) + op.drop_index('ix_captions_created_at', table_name='captions', if_exists=True) + op.drop_index('ix_captions_starred', table_name='captions', if_exists=True) + op.drop_index('ix_images_image_type', table_name='images', if_exists=True) + op.drop_index('ix_images_event_type', table_name='images', if_exists=True) + op.drop_index('ix_images_source', table_name='images', if_exists=True) + diff --git a/py_backend/app/models.py b/py_backend/app/models.py index 024867f8..07161cb6 100644 --- a/py_backend/app/models.py +++ b/py_backend/app/models.py @@ -1,6 +1,6 @@ from sqlalchemy import ( Column, String, DateTime, SmallInteger, Table, ForeignKey, Boolean, - CheckConstraint, UniqueConstraint, Text, Integer + CheckConstraint, UniqueConstraint, Text, Integer, Index ) from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP, CHAR, JSONB from sqlalchemy.orm import relationship @@ -22,6 +22,8 @@ ForeignKey("countries.c_code"), primary_key=True, ), + Index('ix_image_countries_image_id', 'image_id'), + Index('ix_image_countries_c_code', 'c_code'), ) images_captions = Table( @@ -121,6 +123,10 @@ class Images(Base): CheckConstraint('pitch_deg IS NULL OR (pitch_deg BETWEEN -90 AND 90)', name='chk_images_pitch_deg'), CheckConstraint('yaw_deg IS NULL OR (yaw_deg BETWEEN -180 AND 180)', name='chk_images_yaw_deg'), CheckConstraint('roll_deg IS NULL OR (roll_deg BETWEEN -180 AND 180)', name='chk_images_roll_deg'), + Index('ix_images_source', 'source'), + Index('ix_images_event_type', 'event_type'), + Index('ix_images_image_type', 'image_type'), + Index('ix_images_captured_at', 'captured_at'), ) image_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -159,6 +165,8 @@ class Captions(Base): CheckConstraint('accuracy IS NULL OR (accuracy BETWEEN 0 AND 100)', name='chk_captions_accuracy'), CheckConstraint('context IS NULL OR (context BETWEEN 0 AND 100)', name='chk_captions_context'), CheckConstraint('usability IS NULL OR (usability BETWEEN 0 AND 100)', name='chk_captions_usability'), + Index('ix_captions_starred', 'starred'), + Index('ix_captions_created_at', 'created_at'), ) caption_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) From a75de057d290825a5ea81a53f8d6a56476c03c71 Mon Sep 17 00:00:00 2001 From: Lichun Date: Fri, 31 Oct 2025 21:53:39 +0000 Subject: [PATCH 3/3] admin page model selection logic --- frontend/src/pages/AdminPage/AdminPage.tsx | 65 +++++++++++----------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/frontend/src/pages/AdminPage/AdminPage.tsx b/frontend/src/pages/AdminPage/AdminPage.tsx index 2257dcca..7869f1f3 100644 --- a/frontend/src/pages/AdminPage/AdminPage.tsx +++ b/frontend/src/pages/AdminPage/AdminPage.tsx @@ -135,6 +135,33 @@ export default function AdminPage() { localStorage.setItem(SELECTED_MODEL_KEY, firstAvailableModel.m_code); } } + + // Fetch current fallback model after models are loaded + fetch('/api/admin/fallback-model', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('adminToken')}` + } + }) + .then(r => { + if (!r.ok) { + throw new Error(`HTTP ${r.status}: ${r.statusText}`); + } + return r.json(); + }) + .then(fallbackData => { + console.log('Fallback model data received:', fallbackData); + if (fallbackData.fallback_model) { + setSelectedFallbackModel(fallbackData.fallback_model.m_code); + } else { + const stubModel = modelsData.models.find((m: ModelData) => m.m_code === 'STUB_MODEL' && m.is_available); + setSelectedFallbackModel(stubModel ? 'STUB_MODEL' : ''); + } + }) + .catch((error) => { + console.error('Error fetching fallback model:', error); + const stubModel = modelsData.models.find((m: ModelData) => m.m_code === 'STUB_MODEL' && m.is_available); + setSelectedFallbackModel(stubModel ? 'STUB_MODEL' : ''); + }); } else { console.error('Expected models object but got:', modelsData); setAvailableModels([]); @@ -144,31 +171,6 @@ export default function AdminPage() { console.error('Error fetching models:', error); setAvailableModels([]); }); - - // Fetch current fallback model - fetch('/api/admin/fallback-model', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('adminToken')}` - } - }) - .then(r => { - if (!r.ok) { - throw new Error(`HTTP ${r.status}: ${r.statusText}`); - } - return r.json(); - }) - .then(fallbackData => { - console.log('Fallback model data received:', fallbackData); - if (fallbackData.fallback_model) { - setSelectedFallbackModel(fallbackData.fallback_model.m_code); - } else { - setSelectedFallbackModel(''); - } - }) - .catch((error) => { - console.error('Error fetching fallback model:', error); - setSelectedFallbackModel(''); - }); }, []); const fetchPrompts = useCallback(() => { @@ -791,7 +793,10 @@ Model "${newModelData.label}" added successfully! label="Model" name="selected-model" value={selectedModel} - onChange={(newValue) => handleModelChange(newValue || '')} + onChange={(newValue) => { + const modelCode = newValue || (availableModels.find(m => m.m_code === 'STUB_MODEL' && m.is_available)?.m_code || 'STUB_MODEL'); + handleModelChange(modelCode); + }} options={[ { value: 'random', label: 'Random' }, ...(availableModels || []) @@ -809,16 +814,14 @@ Model "${newModelData.label}" added successfully! label="Fallback" name="fallback-model" value={selectedFallbackModel} - onChange={(newValue) => handleFallbackModelChange(newValue || '')} - options={[ - { value: '', label: 'No fallback (use STUB_MODEL)' }, - ...(availableModels || []) + onChange={(newValue) => handleFallbackModelChange(newValue || 'STUB_MODEL')} + options={(availableModels || []) .filter(model => model.is_available) .map(model => ({ value: model.m_code, label: model.label })) - ]} + } keySelector={(o) => o.value} labelSelector={(o) => o.label} />