vk_hackathon/search/main.py
q e49e3417eb Add logviewer project and fix Docker imports
- Add logviewer/: Dozzle web UI (port 9999) + analyze.py CLI tool
- docker-compose.yml: add json-file logging with rotation and labels for index/search
- Fix Dockerfiles: COPY *.py . so all modules are included in image
- Convert all relative imports to flat absolute imports for Docker flat layout
- Rename index/schemas.py → index/index_schemas.py to avoid module name collision with search/schemas.py in test runner
- Update all tests to add service dir to sys.path and use flat imports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:26:11 +03:00

131 lines
3.8 KiB
Python

import asyncio
import logging
import os
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from qdrant_client import AsyncQdrantClient
from aggregation import aggregate_message_ids
from config import (
API_KEY,
HOST,
HTTP_MAX_RETRIES,
HTTP_TIMEOUT,
PORT,
QDRANT_URL,
logger,
validate_required_env,
)
from query_builder import (
build_extra_dense_queries,
build_primary_query,
build_sparse_query,
embed_dense,
embed_dense_multi,
embed_sparse,
)
from rerank import rerank_points
from retrieval import qdrant_search
from schemas import SearchAPIItem, SearchAPIRequest, SearchAPIResponse, SparseVector
async def _embed_dense_with_retry(client: httpx.AsyncClient, text: str) -> list[float]:
last_exc: Exception | None = None
for attempt in range(HTTP_MAX_RETRIES + 1):
try:
return await embed_dense(client, text)
except (httpx.TransportError, httpx.HTTPStatusError) as exc:
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 500:
raise
last_exc = exc
if attempt < HTTP_MAX_RETRIES:
await asyncio.sleep(0.5 * (attempt + 1))
raise RuntimeError(f"Dense embedding failed after retries: {last_exc}")
@asynccontextmanager
async def lifespan(app: FastAPI):
validate_required_env()
app.state.http = httpx.AsyncClient(timeout=HTTP_TIMEOUT)
app.state.qdrant = AsyncQdrantClient(url=QDRANT_URL, api_key=API_KEY)
try:
yield
finally:
await app.state.http.aclose()
await app.state.qdrant.close()
app = FastAPI(title="Search Service", version="0.2.0", lifespan=lifespan)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/search", response_model=SearchAPIResponse)
async def search(payload: SearchAPIRequest) -> SearchAPIResponse:
question = payload.question
primary_query = build_primary_query(question)
if not primary_query:
raise HTTPException(status_code=400, detail="question.text is required")
client: httpx.AsyncClient = app.state.http
qdrant: AsyncQdrantClient = app.state.qdrant
extra_texts = build_extra_dense_queries(question)
sparse_text = build_sparse_query(question)
async def _no_extra() -> list:
return []
extra_task = embed_dense_multi(client, extra_texts) if extra_texts else _no_extra()
primary_dense, extra_dense_vecs, sparse_vec = await asyncio.gather(
_embed_dense_with_retry(client, primary_query),
extra_task,
asyncio.to_thread(embed_sparse, sparse_text),
)
points = await qdrant_search(
qdrant,
primary_dense,
extra_dense_vecs,
sparse_vec,
question,
)
if not points:
return SearchAPIResponse(results=[])
reranked_head, retrieval_tail = await rerank_points(client, primary_query, points)
message_ids = aggregate_message_ids(reranked_head, retrieval_tail)
return SearchAPIResponse(results=[SearchAPIItem(message_ids=message_ids)])
@app.exception_handler(Exception)
async def exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception(exc)
detail = str(exc) or repr(exc)
if isinstance(exc, RequestValidationError):
return JSONResponse(status_code=422, content={"detail": exc.errors()})
if isinstance(exc, HTTPException):
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
return JSONResponse(status_code=500, content={"detail": detail})
def main() -> None:
import uvicorn
uvicorn.run("main:app", host=HOST, port=PORT, reload=False)
if __name__ == "__main__":
main()