quantumbit commited on
Commit
9904304
·
verified ·
1 Parent(s): c1bb97a

Delete api.py

Browse files
Files changed (1) hide show
  1. api.py +0 -524
api.py DELETED
@@ -1,524 +0,0 @@
1
- import logging
2
- import time
3
- import uuid as _uuid
4
- from contextlib import asynccontextmanager
5
- from typing import Optional
6
-
7
- from fastapi import FastAPI, HTTPException, UploadFile, File, BackgroundTasks, Body
8
- from fastapi.middleware.cors import CORSMiddleware
9
- from fastapi.responses import StreamingResponse
10
- from fastapi.middleware.gzip import GZipMiddleware
11
-
12
- from .config import get_settings
13
- from .models import (
14
- IngestRequest, IngestResponse,
15
- QueryRequest, QueryResponse,
16
- EvalRequest, EvalResponse,
17
- HealthResponse,
18
- )
19
- from .document_processor import process_texts, process_file
20
- from .vector_store import (
21
- add_documents, load_or_create_store, is_loaded,
22
- list_collections, get_collection_stats, delete_collection,
23
- cleanup_stale_collections,
24
- )
25
- from .query_engine import query as run_query, stream_query, pipeline_stream_query
26
- from .eval import evaluate
27
- from .cache import cache_connected, get_cache_stats
28
- from .embeddings import get_embeddings
29
- from .guardrails import _load_llama_guard
30
- from .retriever import _reranker
31
-
32
- logging.basicConfig(
33
- level=logging.INFO,
34
- format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
35
- handlers=[
36
- logging.FileHandler("system_logs.txt", mode="w", encoding="utf-8"),
37
- logging.StreamHandler(),
38
- ],
39
- )
40
- logger = logging.getLogger(__name__)
41
- settings = get_settings()
42
-
43
- # In-memory job registry for background ingestion tasks
44
- _ingest_jobs: dict[str, dict] = {}
45
-
46
- # Raw file bytes for document preview: collection_name -> (bytes, content_type)
47
- _doc_files: dict[str, tuple[bytes, str]] = {}
48
-
49
- _FILE_CONTENT_TYPES: dict[str, str] = {
50
- '.pdf': 'application/pdf',
51
- '.txt': 'text/plain; charset=utf-8',
52
- '.md': 'text/markdown; charset=utf-8',
53
- }
54
-
55
- # Viz cache: per collection, stores fitted PCA + 2D projected points
56
- _viz_cache: dict[str, dict] = {}
57
-
58
-
59
- def _compute_viz(collection: str) -> dict:
60
- """PCA-project all chunk embeddings to 2D. Cached per collection."""
61
- if collection in _viz_cache:
62
- return _viz_cache[collection]
63
-
64
- from .vector_store import get_store
65
- store = get_store(collection)
66
- if store is None or store.index.ntotal == 0:
67
- return {"points": [], "pca": None, "vectors": None}
68
-
69
- import numpy as np
70
- from sklearn.decomposition import PCA
71
-
72
- n = store.index.ntotal
73
- d = store.index.d
74
- try:
75
- vectors = store.index.reconstruct_n(0, n).astype(np.float32)
76
- except Exception:
77
- return {"points": [], "pca": None, "vectors": None}
78
-
79
- n_components = min(2, n, d)
80
- pca = PCA(n_components=n_components)
81
- coords = pca.fit_transform(vectors)
82
-
83
- points = []
84
- for i in range(n):
85
- doc_id = store.index_to_docstore_id.get(i)
86
- if not doc_id:
87
- continue
88
- doc = store.docstore._dict.get(doc_id)
89
- if not doc:
90
- continue
91
- cx = float(coords[i, 0]) if n_components >= 1 else 0.0
92
- cy = float(coords[i, 1]) if n_components >= 2 else 0.0
93
- points.append({
94
- "doc_id": doc_id,
95
- "x": cx,
96
- "y": cy,
97
- "preview": doc.page_content[:100],
98
- "page": doc.metadata.get("page"),
99
- "source": str(doc.metadata.get("source_id", "")),
100
- "chunk_index": int(doc.metadata.get("chunk_index", i)),
101
- })
102
-
103
- result = {"points": points, "pca": pca, "vectors": vectors}
104
- _viz_cache[collection] = result
105
- return result
106
-
107
-
108
- def _safe_coll_name(filename: str) -> str:
109
- """Convert a filename to a safe FAISS collection name component."""
110
- from pathlib import Path as _Path
111
- import re as _re
112
- stem = _Path(filename).stem if filename else "doc"
113
- safe = _re.sub(r'[^a-z0-9-]', '_', stem.lower())
114
- safe = _re.sub(r'_+', '_', safe).strip('_')[:40]
115
- return safe or 'doc'
116
-
117
-
118
- # Lifespan (startup / shutdown)
119
- @asynccontextmanager
120
- async def lifespan(app: FastAPI):
121
- logger.info("Starting RAG API...")
122
-
123
- logger.info("Preloading models...")
124
- get_embeddings()
125
- _load_llama_guard()
126
- if getattr(_reranker, "available", False):
127
- logger.info("Reranker model preloaded")
128
- else:
129
- logger.info("Reranker unavailable; skipping preload")
130
-
131
- from pathlib import Path
132
- base_path = Path(settings.faiss_index_path)
133
- if base_path.exists():
134
- for d in base_path.iterdir():
135
- if d.is_dir():
136
- load_or_create_store(d.name)
137
-
138
- logger.info("RAG API ready!")
139
-
140
- # Background session-cleanup loop: remove collections idle > 30 min
141
- import asyncio
142
-
143
- async def _session_cleanup_loop():
144
- while True:
145
- await asyncio.sleep(300) # check every 5 minutes
146
- removed = cleanup_stale_collections(ttl_seconds=1800)
147
- if removed:
148
- logger.info(f"Session cleanup removed {len(removed)} stale collection(s): {removed}")
149
- for coll in removed:
150
- _doc_files.pop(coll, None)
151
-
152
- cleanup_task = asyncio.create_task(_session_cleanup_loop())
153
-
154
- yield
155
-
156
- cleanup_task.cancel()
157
- logger.info("Shutting down RAG API")
158
-
159
-
160
- app = FastAPI(
161
- title=settings.api_title,
162
- version=settings.api_version,
163
- description="Production RAG system: ingest documents, query with advanced retrieval",
164
- lifespan=lifespan,
165
- )
166
-
167
- app.add_middleware(
168
- CORSMiddleware,
169
- allow_origins=settings.cors_origins,
170
- allow_methods=["*"],
171
- allow_headers=["*"],
172
- )
173
- app.add_middleware(GZipMiddleware, minimum_size=1000)
174
-
175
-
176
- @app.middleware("http")
177
- async def add_process_time_header(request, call_next):
178
- start = time.monotonic()
179
- response = await call_next(request)
180
- response.headers["X-Process-Time-Ms"] = str(round((time.monotonic() - start) * 1000, 2))
181
- return response
182
-
183
-
184
- # ── Ops ──────────────────────────────────────────────────────────────────────
185
-
186
- @app.get("/health", response_model=HealthResponse, tags=["ops"])
187
- async def health():
188
- return HealthResponse(
189
- status="ok",
190
- vector_store_loaded=is_loaded(None),
191
- cache_connected=cache_connected(),
192
- model=settings.chat_model,
193
- )
194
-
195
-
196
- @app.get("/cache_stats", tags=["ops"])
197
- async def cache_stats():
198
- return get_cache_stats()
199
-
200
-
201
- # ── Ingest ────────────────────────────────────────────────────────────────────
202
-
203
- @app.post("/ingest", response_model=IngestResponse, tags=["ingest"])
204
- async def ingest_texts(req: IngestRequest):
205
- """Ingest raw text strings into a named collection."""
206
- try:
207
- docs = process_texts(
208
- texts=req.texts,
209
- metadatas=req.metadatas,
210
- source_id=req.collection_name,
211
- )
212
- add_documents(docs, collection=req.collection_name, force_reindex=req.force_reindex)
213
- return IngestResponse(
214
- success=True,
215
- docs_indexed=len(docs),
216
- collection_name=req.collection_name,
217
- message=f"Indexed {len(docs)} chunks into '{req.collection_name}'.",
218
- )
219
- except Exception as e:
220
- logger.exception("Ingest failed")
221
- raise HTTPException(status_code=500, detail=str(e))
222
-
223
-
224
- @app.post("/ingest/file", response_model=IngestResponse, tags=["ingest"])
225
- async def ingest_file(
226
- file: UploadFile = File(...),
227
- collection_name: str = "default",
228
- background_tasks: BackgroundTasks = None,
229
- ):
230
- """
231
- Upload a PDF, TXT, or Markdown file.
232
- Returns a job_id immediately; processing runs in the background.
233
- Poll GET /ingest/jobs/{job_id} or subscribe to GET /ingest/jobs/{job_id}/events.
234
- """
235
- import tempfile, os
236
-
237
- job_id = str(_uuid.uuid4())
238
- suffix = "." + file.filename.rsplit(".", 1)[-1].lower()
239
- doc_collection = f"{collection_name}__{_safe_coll_name(file.filename)}"
240
-
241
- with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
242
- content = await file.read()
243
- tmp.write(content)
244
- tmp_path = tmp.name
245
-
246
- _doc_files[doc_collection] = (content, _FILE_CONTENT_TYPES.get(suffix, 'application/octet-stream'))
247
-
248
- _ingest_jobs[job_id] = {
249
- "job_id": job_id,
250
- "status": "processing",
251
- "collection_name": doc_collection,
252
- "filename": file.filename,
253
- "chunks_created": 0,
254
- "message": "File received, extracting text...",
255
- "progress": 5,
256
- }
257
-
258
- def _process():
259
- try:
260
- _ingest_jobs[job_id]["progress"] = 20
261
- _ingest_jobs[job_id]["message"] = "Extracting and chunking text..."
262
- docs = process_file(tmp_path, display_name=file.filename)
263
-
264
- _ingest_jobs[job_id]["progress"] = 60
265
- _ingest_jobs[job_id]["message"] = f"Embedding and indexing {len(docs)} chunks..."
266
- add_documents(docs, collection=doc_collection)
267
- _viz_cache.pop(doc_collection, None) # invalidate stale viz
268
-
269
- _ingest_jobs[job_id]["progress"] = 100
270
- _ingest_jobs[job_id]["status"] = "done"
271
- _ingest_jobs[job_id]["chunks_created"] = len(docs)
272
- _ingest_jobs[job_id]["message"] = f"Indexed {len(docs)} chunks into '{doc_collection}'"
273
- logger.info(f"Ingest job {job_id} complete: {file.filename} -> {len(docs)} chunks")
274
- except Exception as e:
275
- _ingest_jobs[job_id]["status"] = "failed"
276
- _ingest_jobs[job_id]["message"] = str(e)
277
- logger.exception(f"Ingest job {job_id} failed")
278
- finally:
279
- os.unlink(tmp_path)
280
-
281
- if background_tasks:
282
- background_tasks.add_task(_process)
283
- return IngestResponse(
284
- success=True,
285
- docs_indexed=-1,
286
- collection_name=doc_collection,
287
- message=f"Job '{job_id}' started for '{file.filename}'",
288
- job_id=job_id,
289
- )
290
-
291
- _process()
292
- return IngestResponse(
293
- success=True,
294
- docs_indexed=_ingest_jobs[job_id].get("chunks_created", 0),
295
- collection_name=doc_collection,
296
- message=_ingest_jobs[job_id].get("message", "Done"),
297
- job_id=job_id,
298
- )
299
-
300
-
301
- @app.get("/ingest/jobs", tags=["ingest"])
302
- async def list_ingest_jobs():
303
- """List all ingestion jobs (most recent first)."""
304
- return {"jobs": list(reversed(list(_ingest_jobs.values())))}
305
-
306
-
307
- @app.get("/ingest/jobs/{job_id}", tags=["ingest"])
308
- async def get_ingest_job(job_id: str):
309
- """Get the current status of an ingestion job."""
310
- job = _ingest_jobs.get(job_id)
311
- if not job:
312
- raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found")
313
- return job
314
-
315
-
316
- @app.get("/ingest/jobs/{job_id}/events", tags=["ingest"])
317
- async def ingest_job_events(job_id: str):
318
- """
319
- SSE stream of ingestion progress events.
320
- Emits the job dict every 300 ms until status is 'done' or 'failed'.
321
- """
322
- import asyncio, json
323
-
324
- async def generate():
325
- while True:
326
- job = _ingest_jobs.get(job_id)
327
- if not job:
328
- yield f"data: {json.dumps({'error': 'Job not found'})}\n\n"
329
- return
330
- yield f"data: {json.dumps(job)}\n\n"
331
- if job["status"] in ("done", "failed"):
332
- return
333
- await asyncio.sleep(0.3)
334
-
335
- return StreamingResponse(
336
- generate(),
337
- media_type="text/event-stream",
338
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
339
- )
340
-
341
-
342
- # ── Query ─────────────────────────────────────────────────────────────────────
343
-
344
- @app.post("/query", response_model=QueryResponse, tags=["query"])
345
- async def query_endpoint(req: QueryRequest):
346
- """
347
- Main RAG query endpoint.
348
- Supports multi-turn history, hybrid retrieval, semantic caching, and multi-doc routing.
349
- Set stream=true in body to get a plain SSE token stream.
350
- """
351
- collections = req.doc_collections or [req.collection_name]
352
- for coll in collections:
353
- if not is_loaded(coll):
354
- load_or_create_store(coll)
355
- if not any(is_loaded(c) for c in collections):
356
- raise HTTPException(
357
- status_code=404,
358
- detail="No indexed documents found. Ingest documents first.",
359
- )
360
-
361
- if req.stream:
362
- return StreamingResponse(
363
- stream_query(req),
364
- media_type="text/event-stream",
365
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
366
- )
367
-
368
- try:
369
- result = await run_query(req)
370
- return result
371
- except Exception as e:
372
- logger.exception("Query failed")
373
- raise HTTPException(status_code=500, detail=str(e))
374
-
375
-
376
- @app.post("/query/pipeline", tags=["query"])
377
- async def pipeline_query_endpoint(req: QueryRequest):
378
- """
379
- Pipeline-events SSE endpoint — supports multi-doc routing.
380
- Streams a structured JSON event for every RAG step (guardrail → cache →
381
- rewrite → doc_routing → retrieval → context → generation), then streams LLM tokens.
382
- """
383
- collections = req.doc_collections or [req.collection_name]
384
- for coll in collections:
385
- if not is_loaded(coll):
386
- load_or_create_store(coll)
387
- if not any(is_loaded(c) for c in collections):
388
- raise HTTPException(
389
- status_code=404,
390
- detail="No indexed documents found. Ingest documents first.",
391
- )
392
- return StreamingResponse(
393
- pipeline_stream_query(req),
394
- media_type="text/event-stream",
395
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
396
- )
397
-
398
-
399
- # ── Collections ───────────────────────────────────────────────────────────────
400
-
401
- @app.get("/collections", tags=["collections"])
402
- async def list_collections_endpoint():
403
- """List all collections with chunk count and disk size."""
404
- names = list_collections()
405
- return {"collections": [get_collection_stats(n) for n in names]}
406
-
407
-
408
- @app.get("/collections/{collection_name}", tags=["collections"])
409
- async def get_collection_endpoint(collection_name: str):
410
- """Get detailed stats for a specific collection."""
411
- if collection_name not in list_collections():
412
- raise HTTPException(status_code=404, detail=f"Collection '{collection_name}' not found")
413
- return get_collection_stats(collection_name)
414
-
415
-
416
- @app.delete("/collections/{collection_name}", tags=["collections"])
417
- async def delete_collection_endpoint(collection_name: str):
418
- """Permanently delete a collection from memory and disk."""
419
- deleted = delete_collection(collection_name)
420
- if not deleted:
421
- raise HTTPException(status_code=404, detail=f"Collection '{collection_name}' not found")
422
- _doc_files.pop(collection_name, None)
423
- _viz_cache.pop(collection_name, None)
424
- return {"success": True, "message": f"Collection '{collection_name}' deleted"}
425
-
426
-
427
- # ── Viz ───────────────────────────────────────────────────────────────────────
428
-
429
- @app.get("/collections/{collection_name}/viz", tags=["viz"])
430
- async def get_collection_viz(collection_name: str):
431
- """Return PCA 2D projection of all chunk embeddings for scatter-plot visualization."""
432
- if not is_loaded(collection_name):
433
- load_or_create_store(collection_name)
434
- if not is_loaded(collection_name):
435
- raise HTTPException(status_code=404, detail=f"Collection '{collection_name}' not found")
436
- result = _compute_viz(collection_name)
437
- return {"collection": collection_name, "points": result["points"]}
438
-
439
-
440
- @app.post("/collections/{collection_name}/query_similarity", tags=["viz"])
441
- async def get_query_similarity(collection_name: str, body: dict = Body(...)):
442
- """
443
- Project a query into the chunk embedding PCA space.
444
- Returns query 2D position + all chunks with cosine similarity scores.
445
- Enables the live similarity animation as the user types.
446
- """
447
- query = (body.get("query") or "").strip()
448
- if not query:
449
- return {"query": None, "chunks": []}
450
-
451
- if not is_loaded(collection_name):
452
- load_or_create_store(collection_name)
453
- if not is_loaded(collection_name):
454
- raise HTTPException(status_code=404, detail=f"Collection '{collection_name}' not found")
455
-
456
- result = _compute_viz(collection_name)
457
- if not result["points"] or result["pca"] is None:
458
- return {"query": None, "chunks": []}
459
-
460
- import numpy as np
461
-
462
- q_vec = np.array(get_embeddings().embed_query(query), dtype=np.float32).reshape(1, -1)
463
- q_2d = result["pca"].transform(q_vec)[0]
464
-
465
- vectors = result["vectors"]
466
- norms = np.linalg.norm(vectors, axis=1)
467
- q_norm = float(np.linalg.norm(q_vec))
468
- with np.errstate(divide='ignore', invalid='ignore'):
469
- sims = (vectors @ q_vec.T).flatten() / (norms * q_norm + 1e-10)
470
-
471
- chunks = []
472
- for i, pt in enumerate(result["points"]):
473
- chunks.append({**pt, "score": float(sims[i]) if i < len(sims) else 0.0})
474
- chunks.sort(key=lambda c: c["score"], reverse=True)
475
-
476
- return {
477
- "query": {"x": float(q_2d[0]), "y": float(q_2d[1])},
478
- "chunks": chunks,
479
- }
480
-
481
-
482
- # ── Documents ─────────────────────────────────────────────────────────────────
483
-
484
- @app.get("/documents/{collection_name}/raw", tags=["documents"])
485
- async def get_document_raw(collection_name: str):
486
- """Serve raw document bytes for in-browser preview."""
487
- from fastapi.responses import Response
488
- entry = _doc_files.get(collection_name)
489
- if not entry:
490
- raise HTTPException(status_code=404, detail=f"Document '{collection_name}' not available for preview")
491
- data, media_type = entry
492
- # Derive a human-readable filename from the collection key
493
- display_name = collection_name.split("__")[-1] if "__" in collection_name else collection_name
494
- return Response(
495
- content=data,
496
- media_type=media_type,
497
- headers={"Content-Disposition": f'inline; filename="{display_name}"'},
498
- )
499
-
500
-
501
- # ── Evaluate ──────────────────────────────────────────────────────────────────
502
-
503
- @app.post("/evaluate", response_model=EvalResponse, tags=["eval"])
504
- async def evaluate_endpoint(req: EvalRequest):
505
- """Run RAGAS-style evaluation on a (question, answer, contexts) triple."""
506
- try:
507
- return await evaluate(req)
508
- except Exception as e:
509
- logger.exception("Eval failed")
510
- raise HTTPException(status_code=500, detail=str(e))
511
-
512
-
513
- # Entry point
514
- if __name__ == "__main__":
515
- import uvicorn
516
- uvicorn.run(
517
- "rag_system.api:app",
518
- host="0.0.0.0",
519
- port=8000,
520
- reload=True,
521
- workers=1,
522
- )
523
-
524
- print("[api] FastAPI app configured.")