2026-07-17 23:20:34 +02:00
|
|
|
"""Search endpoint (SPEC §9.1 Search, §8). Results grouped by thread (FR-28)."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
from typing import Annotated
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.api.schemas import SearchOut, SearchThreadHit
|
2026-09-25 20:17:37 +02:00
|
|
|
from app.api.threads import _summary, thread_senders
|
2026-07-17 23:20:34 +02:00
|
|
|
from app.db import get_session
|
|
|
|
|
from app.identity.deps import CurrentUser
|
|
|
|
|
from app.models import Thread
|
|
|
|
|
from app.search.factory import get_search_backend
|
|
|
|
|
from app.search.queryparser import to_search_query
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/search", tags=["search"])
|
|
|
|
|
|
|
|
|
|
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("", response_model=SearchOut)
|
|
|
|
|
async def search(
|
|
|
|
|
user: CurrentUser,
|
|
|
|
|
db: DbSession,
|
|
|
|
|
q: str = Query(""),
|
|
|
|
|
cursor: str | None = None,
|
|
|
|
|
limit: int = Query(50, ge=1, le=200),
|
|
|
|
|
) -> SearchOut:
|
|
|
|
|
if not q.strip():
|
|
|
|
|
return SearchOut(threads=[], cursor=None)
|
|
|
|
|
|
|
|
|
|
backend = get_search_backend()
|
|
|
|
|
query = to_search_query(q, user.id)
|
|
|
|
|
hits, next_cursor = await backend.query(query, limit=limit, cursor=cursor)
|
|
|
|
|
|
|
|
|
|
# Group hits by thread, preserving order and keeping the first highlight.
|
|
|
|
|
order: list[uuid.UUID] = []
|
|
|
|
|
highlights: dict[uuid.UUID, dict[str, str]] = {}
|
|
|
|
|
for hit in hits:
|
|
|
|
|
if hit.thread_id not in highlights:
|
|
|
|
|
order.append(hit.thread_id)
|
|
|
|
|
highlights[hit.thread_id] = hit.highlights
|
|
|
|
|
elif hit.highlights and not highlights[hit.thread_id]:
|
|
|
|
|
highlights[hit.thread_id] = hit.highlights
|
|
|
|
|
|
|
|
|
|
if not order:
|
|
|
|
|
return SearchOut(threads=[], cursor=next_cursor)
|
|
|
|
|
|
|
|
|
|
threads = {
|
|
|
|
|
t.id: t
|
|
|
|
|
for t in await db.scalars(
|
|
|
|
|
select(Thread).where(Thread.id.in_(order), Thread.user_id == user.id)
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-09-25 20:17:37 +02:00
|
|
|
senders = await thread_senders(db, user.id, list(threads))
|
2026-07-17 23:20:34 +02:00
|
|
|
results = [
|
2026-09-25 20:17:37 +02:00
|
|
|
SearchThreadHit(
|
|
|
|
|
thread=_summary(threads[tid], senders=senders.get(tid)),
|
|
|
|
|
highlights=highlights.get(tid, {}),
|
|
|
|
|
)
|
2026-07-17 23:20:34 +02:00
|
|
|
for tid in order
|
|
|
|
|
if tid in threads
|
|
|
|
|
]
|
|
|
|
|
return SearchOut(threads=results, cursor=next_cursor)
|