File size: 3,303 Bytes
22b7d63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
"""
main.py — the CineMatch FastAPI service.

Routes (see app/schemas.py for the full contract):
  GET  /api/health                     engine + model status
  GET  /api/stats                      catalog facets for the empty state
  GET  /api/suggest?q=&limit=          title autocomplete
  POST /api/search                     hybrid search  (SearchRequest -> SearchResponse)
  GET  /api/movie/{id}                 single movie
  GET  /api/movie/{id}/similar?limit=  "More like this"
"""
from __future__ import annotations

import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware

from app.config import settings
from app.engine.search import SearchEngine
from app.schemas import (
    HealthResponse,
    Movie,
    SearchRequest,
    SearchResponse,
    StatsResponse,
    Suggestion,
)

logging.basicConfig(level=logging.INFO, format="%(asctime)s  %(message)s")
log = logging.getLogger("cinematch")

state: dict[str, SearchEngine] = {}


@asynccontextmanager
async def lifespan(app: FastAPI):
    log.info("loading engine ...")
    engine = SearchEngine(settings)
    state["engine"] = engine
    log.info("corpus=%d  slm=%s  loading models (first run downloads them) ...",
             engine.corpus.size, engine.slm.label)
    try:
        engine.warmup()
        log.info("ready. reranker=%s", bool(engine.reranker and engine.reranker.available))
    except Exception as e:  # pragma: no cover - model download failure shouldn't kill server
        log.warning("warmup issue (engine still serves): %s", e)
    yield
    state.clear()


app = FastAPI(title="CineMatch API", version="2.0.0", lifespan=lifespan)
app.add_middleware(
    CORSMiddleware,
    # Public, read-only, no-credentials API — allow any origin so the deployed
    # frontend (Vercel, HF Space, localhost, …) can call it from the browser.
    allow_origins=["*"],
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
)


def engine() -> SearchEngine:
    eng = state.get("engine")
    if eng is None:
        raise HTTPException(503, "engine not ready")
    return eng


@app.get("/")
def root():
    return {"service": "CineMatch API", "docs": "/docs", "health": "/api/health"}


@app.get("/api/health", response_model=HealthResponse)
def health():
    return engine().health()


@app.get("/api/stats", response_model=StatsResponse)
def stats():
    return engine().stats()


@app.get("/api/suggest", response_model=list[Suggestion])
def suggest(q: str = Query(..., min_length=1), limit: int = Query(8, ge=1, le=20)):
    return engine().suggest(q, limit)


@app.post("/api/search", response_model=SearchResponse)
def search(req: SearchRequest):
    return engine().search(req)


@app.get("/api/movie/{movie_id}", response_model=Movie)
def get_movie(movie_id: str):
    try:
        return engine().get_movie(movie_id)
    except KeyError:
        raise HTTPException(404, f"movie '{movie_id}' not found")


@app.get("/api/movie/{movie_id}/similar", response_model=SearchResponse)
def similar(movie_id: str, limit: int = Query(12, ge=1, le=50)):
    try:
        return engine().similar(movie_id, limit)
    except KeyError:
        raise HTTPException(404, f"movie '{movie_id}' not found")