Drops face recognition, OCR, object detection, and semantic embeddings. The sole remaining vision task is a CLIP-based binary classifier (photography vs other); photos in "other" get needs_review=true so screenshots, documents, memes and scans can be triaged from a new filter pill in the UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
78 lines
2.6 KiB
Python
78 lines
2.6 KiB
Python
"""
|
|
FTS search over photos.search_vector with optional tag/date filters.
|
|
"""
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import select, text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models import Photo
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def hybrid_search(
|
|
db: AsyncSession,
|
|
q: Optional[str] = None,
|
|
tag_ids: Optional[list[str]] = None,
|
|
date_from: Optional[str] = None,
|
|
date_to: Optional[str] = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> list[dict]:
|
|
"""Full-text search using photos.search_vector. No embeddings, no OCR."""
|
|
if q:
|
|
try:
|
|
fts_stmt = text("""
|
|
SELECT id, ts_rank(search_vector, plainto_tsquery('english', :q)) AS rank
|
|
FROM photos
|
|
WHERE search_vector @@ plainto_tsquery('english', :q)
|
|
AND is_trashed = false
|
|
AND is_hidden = false
|
|
ORDER BY rank DESC
|
|
LIMIT 500
|
|
""")
|
|
rows = (await db.execute(fts_stmt, {"q": q})).fetchall()
|
|
except Exception as e:
|
|
logger.warning("FTS search failed: %s", e)
|
|
rows = []
|
|
|
|
scored = [(pid, float(rank)) for pid, rank in rows]
|
|
|
|
if tag_ids:
|
|
from app.models.tags import photo_tags
|
|
photo_ids = [pid for pid, _ in scored]
|
|
if not photo_ids:
|
|
return []
|
|
stmt = select(photo_tags.c.photo_id).where(
|
|
photo_tags.c.photo_id.in_(photo_ids),
|
|
photo_tags.c.tag_id.in_(tag_ids),
|
|
).distinct()
|
|
valid = {row[0] for row in (await db.execute(stmt)).fetchall()}
|
|
scored = [(pid, s) for pid, s in scored if pid in valid]
|
|
|
|
page = scored[offset : offset + limit]
|
|
return [{"photo_id": pid, "score": s} for pid, s in page]
|
|
|
|
# No text query — recent photos with tag/date filters.
|
|
if tag_ids:
|
|
from app.models.tags import photo_tags
|
|
sub = select(photo_tags.c.photo_id).where(
|
|
photo_tags.c.tag_id.in_(tag_ids)
|
|
).distinct().subquery()
|
|
stmt = select(Photo.id).join(sub, Photo.id == sub.c.photo_id)
|
|
else:
|
|
stmt = select(Photo.id)
|
|
stmt = stmt.where(
|
|
Photo.is_discarded.is_(False),
|
|
Photo.is_hidden.is_(False),
|
|
)
|
|
if date_from:
|
|
stmt = stmt.where(Photo.taken_at >= date_from)
|
|
if date_to:
|
|
stmt = stmt.where(Photo.taken_at <= date_to)
|
|
stmt = stmt.order_by(Photo.added_at.desc()).offset(offset).limit(limit)
|
|
rows = (await db.execute(stmt)).fetchall()
|
|
return [{"photo_id": row[0], "score": 0.0} for row in rows]
|