search.py 5.9 KB
"""
Search API routes.
"""

from fastapi import APIRouter, HTTPException, Query, Request
from typing import Optional
import uuid

from ..models import (
    SearchRequest,
    ImageSearchRequest,
    SearchResponse,
    DocumentResponse,
    ErrorResponse
)
from context.request_context import create_request_context, set_current_request_context, clear_current_request_context

router = APIRouter(prefix="/search", tags=["search"])


def extract_request_info(request: Request) -> tuple[str, str]:
    """Extract request ID and user ID from HTTP request"""
    # Try to get request ID from headers
    reqid = request.headers.get('X-Request-ID') or str(uuid.uuid4())[:8]

    # Try to get user ID from headers or default to anonymous
    uid = request.headers.get('X-User-ID') or request.headers.get('User-ID') or 'anonymous'

    return reqid, uid


@router.post("/", response_model=SearchResponse)
async def search(request: SearchRequest, http_request: Request):
    """
    Execute text search query.

    Supports:
    - Multi-language query processing
    - Boolean operators (AND, OR, RANK, ANDNOT)
    - Semantic search with embeddings
    - Custom ranking functions
    - Filters and aggregations
    """
    reqid, uid = extract_request_info(http_request)

    # Create request context
    context = create_request_context(reqid=reqid, uid=uid)

    # Set context in thread-local storage
    set_current_request_context(context)

    try:
        # Log request start
        context.logger.info(
            f"收到搜索请求 | IP: {http_request.client.host if http_request.client else 'unknown'} | "
            f"用户代理: {http_request.headers.get('User-Agent', 'unknown')[:100]}",
            extra={'reqid': context.reqid, 'uid': context.uid}
        )

        # Get searcher from app state
        from api.app import get_searcher
        searcher = get_searcher()

        # Execute search with context (using backend defaults from config)
        result = searcher.search(
            query=request.query,
            size=request.size,
            from_=request.from_,
            filters=request.filters,
            min_score=request.min_score,
            context=context,
            aggregations=request.aggregations,
            sort_by=request.sort_by,
            sort_order=request.sort_order
        )

        # Include performance summary in response
        performance_summary = context.get_summary() if context else None

        # Convert to response model
        return SearchResponse(
            hits=result.hits,
            total=result.total,
            max_score=result.max_score,
            took_ms=result.took_ms,
            aggregations=result.aggregations,
            query_info=result.query_info,
            performance_info=performance_summary
        )

    except Exception as e:
        # Log error in context
        if context:
            context.set_error(e)
            context.logger.error(
                f"搜索请求失败 | 错误: {str(e)}",
                extra={'reqid': context.reqid, 'uid': context.uid}
            )
        raise HTTPException(status_code=500, detail=str(e))
    finally:
        # Clear thread-local context
        clear_current_request_context()


@router.post("/image", response_model=SearchResponse)
async def search_by_image(request: ImageSearchRequest, http_request: Request):
    """
    Search by image similarity.

    Uses image embeddings to find visually similar products.
    """
    reqid, uid = extract_request_info(http_request)

    # Create request context
    context = create_request_context(reqid=reqid, uid=uid)

    # Set context in thread-local storage
    set_current_request_context(context)

    try:
        # Log request start
        context.logger.info(
            f"收到图片搜索请求 | 图片URL: {request.image_url} | "
            f"IP: {http_request.client.host if http_request.client else 'unknown'}",
            extra={'reqid': context.reqid, 'uid': context.uid}
        )

        from api.app import get_searcher
        searcher = get_searcher()

        # Execute image search
        result = searcher.search_by_image(
            image_url=request.image_url,
            size=request.size,
            filters=request.filters
        )

        # Include performance summary in response
        performance_summary = context.get_summary() if context else None

        return SearchResponse(
            hits=result.hits,
            total=result.total,
            max_score=result.max_score,
            took_ms=result.took_ms,
            aggregations=result.aggregations,
            query_info=result.query_info,
            performance_info=performance_summary
        )

    except ValueError as e:
        if context:
            context.set_error(e)
            context.logger.error(
                f"图片搜索请求参数错误 | 错误: {str(e)}",
                extra={'reqid': context.reqid, 'uid': context.uid}
            )
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        if context:
            context.set_error(e)
            context.logger.error(
                f"图片搜索请求失败 | 错误: {str(e)}",
                extra={'reqid': context.reqid, 'uid': context.uid}
            )
        raise HTTPException(status_code=500, detail=str(e))
    finally:
        # Clear thread-local context
        clear_current_request_context()


@router.get("/{doc_id}", response_model=DocumentResponse)
async def get_document(doc_id: str):
    """
    Get a single document by ID.
    """
    try:
        from api.app import get_searcher
        searcher = get_searcher()

        doc = searcher.get_document(doc_id)

        if doc is None:
            raise HTTPException(status_code=404, detail=f"Document {doc_id} not found")

        return DocumentResponse(id=doc_id, source=doc)

    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))