notRaphael commited on
Commit
29f6075
Β·
verified Β·
1 Parent(s): 1eb45d2

feat: add REST API (FastAPI) + Dockerfile for containerized deployment

Browse files
Files changed (1) hide show
  1. video_intelligence/api.py +315 -0
video_intelligence/api.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Video Intelligence Platform β€” REST API
3
+ FastAPI server exposing all platform capabilities as REST endpoints.
4
+
5
+ Run:
6
+ uvicorn video_intelligence.api:app --host 0.0.0.0 --port 8000
7
+
8
+ All endpoints return JSON. Upload videos as multipart/form-data.
9
+ Frontend (React/Next.js) just makes fetch() calls to these endpoints.
10
+ """
11
+ import os
12
+ import io
13
+ import shutil
14
+ import tempfile
15
+ from typing import Optional, List
16
+ from pathlib import Path
17
+
18
+ from fastapi import FastAPI, UploadFile, File, HTTPException, Header, Query
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from pydantic import BaseModel
21
+ from contextlib import asynccontextmanager
22
+
23
+ from .config import Config
24
+ from .pipeline import IndexingPipeline
25
+ from .query_engine import QueryEngine, QueryResult
26
+ from .akinator import AkinatorRefiner
27
+ from .gemini_client import GeminiClient
28
+ from .index_store import VideoIndex
29
+
30
+
31
+ # ── State ───────────────────────────────────────────────────────────────────
32
+ # Initialized on first /init call. Stays alive for the server lifetime.
33
+ state = {
34
+ "pipeline": None,
35
+ "query_engine": None,
36
+ "akinator": None,
37
+ "initialized": False,
38
+ }
39
+
40
+
41
+ # ── App ─────────────────────────────────────────────────────────────────────
42
+ app = FastAPI(
43
+ title="Video Intelligence Platform",
44
+ description="Akinator-style video search with RAG, boolean queries, and tree refinement",
45
+ version="1.0.0",
46
+ docs_url="/docs", # Swagger UI at /docs
47
+ redoc_url="/redoc", # ReDoc at /redoc
48
+ )
49
+
50
+ # CORS β€” allow your React frontend to call this API
51
+ # In production, replace ["*"] with your actual frontend domain
52
+ app.add_middleware(
53
+ CORSMiddleware,
54
+ allow_origins=["*"], # e.g. ["http://localhost:3000", "https://yourdomain.com"]
55
+ allow_credentials=True,
56
+ allow_methods=["*"],
57
+ allow_headers=["*"],
58
+ )
59
+
60
+
61
+ # ── Request/Response Models ─────────────────────────────────────────────────
62
+
63
+ class InitRequest(BaseModel):
64
+ gemini_api_key: str
65
+ device: str = "cpu"
66
+
67
+ class InitResponse(BaseModel):
68
+ status: str
69
+ message: str
70
+
71
+ class SearchRequest(BaseModel):
72
+ query: str
73
+ top_k: int = 20
74
+
75
+ class SearchResult(BaseModel):
76
+ frame_id: int
77
+ timestamp_sec: float
78
+ time_str: str
79
+ score: float
80
+ caption: str
81
+ detections: List[str]
82
+ match_source: str
83
+
84
+ class SearchResponse(BaseModel):
85
+ query: str
86
+ results: List[SearchResult]
87
+ count: int
88
+ akinator_active: bool = False
89
+ akinator_question: Optional[str] = None
90
+ akinator_options: Optional[List[str]] = None
91
+
92
+ class RefineRequest(BaseModel):
93
+ choice: str
94
+ query: str
95
+
96
+ class RefineResponse(BaseModel):
97
+ status: str # "refining" or "done"
98
+ count: int
99
+ results: Optional[List[dict]] = None
100
+ question: Optional[str] = None
101
+ options: Optional[List[str]] = None
102
+ history: Optional[List[dict]] = None
103
+
104
+ class RAGRequest(BaseModel):
105
+ query: str
106
+
107
+ class RAGResponse(BaseModel):
108
+ query: str
109
+ answer: str
110
+
111
+ class IndexResponse(BaseModel):
112
+ status: str
113
+ frames: int
114
+ detections: int
115
+ visual_vectors: int
116
+ caption_vectors: int
117
+ elapsed_sec: float
118
+
119
+ class HealthResponse(BaseModel):
120
+ status: str
121
+ initialized: bool
122
+ version: str
123
+
124
+
125
+ # ── Endpoints ───────────────────────────────────────────────────────────────
126
+
127
+ @app.get("/health", response_model=HealthResponse)
128
+ def health():
129
+ """Health check β€” use for container readiness/liveness probes."""
130
+ return HealthResponse(
131
+ status="ok",
132
+ initialized=state["initialized"],
133
+ version="1.0.0",
134
+ )
135
+
136
+
137
+ @app.post("/init", response_model=InitResponse)
138
+ def initialize(req: InitRequest):
139
+ """
140
+ Initialize models with your Gemini API key.
141
+ Call once before indexing/searching. Takes ~30-60s to load models.
142
+ """
143
+ try:
144
+ config = Config(
145
+ gemini_api_key=req.gemini_api_key,
146
+ device=req.device,
147
+ )
148
+
149
+ pipeline = IndexingPipeline(config)
150
+ query_engine = QueryEngine(
151
+ index=pipeline.index,
152
+ gemini=pipeline.gemini,
153
+ siglip=pipeline.siglip,
154
+ top_k=20,
155
+ )
156
+ akinator = AkinatorRefiner(
157
+ index=pipeline.index,
158
+ gemini=pipeline.gemini,
159
+ threshold=10,
160
+ )
161
+
162
+ state["pipeline"] = pipeline
163
+ state["query_engine"] = query_engine
164
+ state["akinator"] = akinator
165
+ state["initialized"] = True
166
+
167
+ return InitResponse(status="ok", message="Models loaded successfully")
168
+
169
+ except Exception as e:
170
+ raise HTTPException(status_code=500, detail=str(e))
171
+
172
+
173
+ @app.post("/index", response_model=IndexResponse)
174
+ async def index_video(
175
+ video: UploadFile = File(...),
176
+ caption_every_n: int = Query(default=3, ge=1, le=20),
177
+ ):
178
+ """
179
+ Upload and index a video. Extracts frames, runs detection,
180
+ generates embeddings and captions.
181
+
182
+ Send as multipart/form-data with field name "video".
183
+ """
184
+ if not state["initialized"]:
185
+ raise HTTPException(status_code=400, detail="Not initialized. Call POST /init first.")
186
+
187
+ # Save uploaded video to temp file
188
+ suffix = Path(video.filename).suffix or ".mp4"
189
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
190
+ shutil.copyfileobj(video.file, tmp)
191
+ tmp_path = tmp.name
192
+
193
+ try:
194
+ stats = state["pipeline"].index_video(
195
+ tmp_path,
196
+ caption_every_n=caption_every_n,
197
+ detect_every_n=1,
198
+ )
199
+ return IndexResponse(
200
+ status="ok",
201
+ frames=stats["frames"],
202
+ detections=stats["detections"],
203
+ visual_vectors=stats["visual_vectors"],
204
+ caption_vectors=stats["caption_vectors"],
205
+ elapsed_sec=stats["elapsed_sec"],
206
+ )
207
+ except Exception as e:
208
+ raise HTTPException(status_code=500, detail=str(e))
209
+ finally:
210
+ os.unlink(tmp_path)
211
+
212
+
213
+ @app.post("/search", response_model=SearchResponse)
214
+ def search(req: SearchRequest):
215
+ """
216
+ Search the indexed video with natural language.
217
+ Supports boolean: "red car AND person", "dog OR cat"
218
+ """
219
+ if not state["initialized"]:
220
+ raise HTTPException(status_code=400, detail="Not initialized. Call POST /init first.")
221
+
222
+ try:
223
+ results = state["query_engine"].search(req.query, top_k=req.top_k)
224
+
225
+ search_results = [
226
+ SearchResult(
227
+ frame_id=r.frame_id,
228
+ timestamp_sec=r.timestamp_sec,
229
+ time_str=r.time_str,
230
+ score=round(r.score, 4),
231
+ caption=r.caption or "",
232
+ detections=r.detections,
233
+ match_source=r.match_source,
234
+ )
235
+ for r in results
236
+ ]
237
+
238
+ # Store for RAG/Akinator
239
+ state["_last_results"] = results
240
+
241
+ # Check if Akinator refinement is needed
242
+ akinator_active = False
243
+ akinator_question = None
244
+ akinator_options = None
245
+
246
+ if len(results) > 10 and state["akinator"]:
247
+ ak_result = state["akinator"].start(results, req.query)
248
+ if ak_result["status"] == "refining":
249
+ akinator_active = True
250
+ akinator_question = ak_result["question"]
251
+ akinator_options = ak_result["options"]
252
+
253
+ return SearchResponse(
254
+ query=req.query,
255
+ results=search_results,
256
+ count=len(search_results),
257
+ akinator_active=akinator_active,
258
+ akinator_question=akinator_question,
259
+ akinator_options=akinator_options,
260
+ )
261
+
262
+ except Exception as e:
263
+ raise HTTPException(status_code=500, detail=str(e))
264
+
265
+
266
+ @app.post("/refine", response_model=RefineResponse)
267
+ def refine(req: RefineRequest):
268
+ """
269
+ Answer an Akinator refinement question to narrow results.
270
+ Send the chosen option from the previous search/refine response.
271
+ """
272
+ if not state["akinator"]:
273
+ raise HTTPException(status_code=400, detail="No active refinement session")
274
+
275
+ try:
276
+ result = state["akinator"].answer(req.choice, req.query)
277
+ return RefineResponse(
278
+ status=result["status"],
279
+ count=result["count"],
280
+ results=result.get("results"),
281
+ question=result.get("question"),
282
+ options=result.get("options"),
283
+ history=result.get("history"),
284
+ )
285
+ except Exception as e:
286
+ raise HTTPException(status_code=500, detail=str(e))
287
+
288
+
289
+ @app.post("/rag", response_model=RAGResponse)
290
+ def rag_answer(req: RAGRequest):
291
+ """
292
+ Generate a RAG answer from the last search results.
293
+ Cites specific timestamps in the response.
294
+ """
295
+ if not state["initialized"]:
296
+ raise HTTPException(status_code=400, detail="Not initialized. Call POST /init first.")
297
+
298
+ last_results = state.get("_last_results", [])
299
+ if not last_results:
300
+ raise HTTPException(status_code=400, detail="No search results. Call POST /search first.")
301
+
302
+ try:
303
+ contexts = [r.to_dict() for r in last_results[:15]]
304
+ answer = state["pipeline"].gemini.generate_rag_answer(req.query, contexts)
305
+ return RAGResponse(query=req.query, answer=answer)
306
+ except Exception as e:
307
+ raise HTTPException(status_code=500, detail=str(e))
308
+
309
+
310
+ @app.get("/stats")
311
+ def stats():
312
+ """Get current index statistics."""
313
+ if not state["initialized"]:
314
+ raise HTTPException(status_code=400, detail="Not initialized.")
315
+ return state["pipeline"].index.stats()