light-infer-chat commited on
Commit
08240ea
·
1 Parent(s): c2bb116
app/api/v1/vector_stores.py CHANGED
@@ -1,11 +1,9 @@
1
  from __future__ import annotations
2
 
3
  import asyncio
4
- import os
5
- import tempfile
6
  import time
7
 
8
- from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
9
 
10
  from app.api.deps import get_vector_store_service, require_auth
11
  from app.core.logger import get_logger
@@ -15,6 +13,7 @@ from app.models.schemas import (
15
  DeleteResponse,
16
  DocumentIngestRequest,
17
  DocumentIngestResponse,
 
18
  SearchRequest,
19
  SearchResponse,
20
  VectorStoreCreate,
@@ -22,12 +21,28 @@ from app.models.schemas import (
22
  VectorStoreResponse,
23
  )
24
  from app.services.converter_service import ConverterService
 
25
  from app.services.vector_store_service import VectorStoreService
26
 
27
  router = APIRouter()
28
  logger = get_logger(__name__)
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  @router.post(
32
  "/vector-stores",
33
  response_model=VectorStoreResponse,
@@ -196,6 +211,7 @@ async def ingest_pdf_document(
196
  doc_id: str = Form(..., min_length=1, max_length=256),
197
  chunk_size: int = Form(512, ge=64, le=4096),
198
  chunk_overlap: int = Form(64, ge=0, le=512),
 
199
  token: str = Depends(require_auth),
200
  vector_store_service: VectorStoreService = Depends(get_vector_store_service),
201
  ) -> DocumentIngestResponse:
@@ -208,67 +224,131 @@ async def ingest_pdf_document(
208
  if not file.filename or not file.filename.lower().endswith(".pdf"):
209
  raise HTTPException(status_code=400, detail="Only .pdf files are accepted")
210
 
211
- tmp_path = None
212
  try:
213
  raw = await file.read()
214
- if len(raw) < 5 or raw[:5] != b"%PDF-":
215
- raise HTTPException(status_code=400, detail="File is not a valid PDF")
216
-
217
- suffix = ".pdf"
218
- with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
219
- tmp.write(raw)
220
- tmp_path = tmp.name
221
-
222
- converter = ConverterService()
223
- loop = asyncio.get_running_loop()
224
- result = await loop.run_in_executor(None, converter.convert_file, tmp_path)
225
-
226
- if isinstance(result, ConversionError):
227
- logger.error("PDF conversion failed: %s", result.message)
228
- return DocumentIngestResponse(
229
- success=False,
230
- vector_store_id=store_id,
231
- doc_id=doc_id,
232
- chunks_ingested=0,
233
- time_ms=0,
234
- error=result.message,
235
- )
236
-
237
- text = result.markdown
238
- source = file.filename
239
 
 
240
  chunks, elapsed = await vector_store_service.ingest_document(
241
  store_id=store_id,
242
  doc_id=doc_id,
243
  text=text,
244
- source=source,
245
  chunk_size=chunk_size,
246
  chunk_overlap=chunk_overlap,
247
  )
248
-
 
249
  return DocumentIngestResponse(
250
- success=True,
251
  vector_store_id=store_id,
252
  doc_id=doc_id,
253
- chunks_ingested=chunks,
254
- time_ms=round(elapsed, 3),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  )
256
- except HTTPException:
257
- raise
258
  except Exception as exc:
259
- logger.error("PDF ingest failed for store %s: %s", store_id, exc)
260
  return DocumentIngestResponse(
261
  success=False,
262
  vector_store_id=store_id,
263
- doc_id=doc_id,
264
  chunks_ingested=0,
265
  time_ms=0,
266
  error=str(exc),
267
  )
268
- finally:
269
- if tmp_path and os.path.exists(tmp_path):
270
- os.unlink(tmp_path)
271
- await file.close()
 
 
 
 
272
 
273
 
274
  @router.post(
 
1
  from __future__ import annotations
2
 
3
  import asyncio
 
 
4
  import time
5
 
6
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
7
 
8
  from app.api.deps import get_vector_store_service, require_auth
9
  from app.core.logger import get_logger
 
13
  DeleteResponse,
14
  DocumentIngestRequest,
15
  DocumentIngestResponse,
16
+ DocumentIngestUrlRequest,
17
  SearchRequest,
18
  SearchResponse,
19
  VectorStoreCreate,
 
21
  VectorStoreResponse,
22
  )
23
  from app.services.converter_service import ConverterService
24
+ from app.services.text_cleaner_service import TextCleanerService
25
  from app.services.vector_store_service import VectorStoreService
26
 
27
  router = APIRouter()
28
  logger = get_logger(__name__)
29
 
30
 
31
+ async def _process_pdf_bytes(raw: bytes, source: str, clean_content: bool) -> str | ConversionError:
32
+ if len(raw) < 5 or raw[:5] != b"%PDF-":
33
+ return ConversionError(source=source, error_type="ValueError", message="Not a valid PDF", duration_ms=0)
34
+ loop = asyncio.get_running_loop()
35
+ converter = ConverterService()
36
+ result = await loop.run_in_executor(None, converter.convert_stream, raw, source)
37
+ if isinstance(result, ConversionError):
38
+ return result
39
+ text = result.markdown
40
+ if clean_content:
41
+ text_cleaner = TextCleanerService()
42
+ text = await loop.run_in_executor(None, text_cleaner.clean, text)
43
+ return text
44
+
45
+
46
  @router.post(
47
  "/vector-stores",
48
  response_model=VectorStoreResponse,
 
211
  doc_id: str = Form(..., min_length=1, max_length=256),
212
  chunk_size: int = Form(512, ge=64, le=4096),
213
  chunk_overlap: int = Form(64, ge=0, le=512),
214
+ clean_content: bool = Query(True, description="Clean markdown text after PDF conversion"),
215
  token: str = Depends(require_auth),
216
  vector_store_service: VectorStoreService = Depends(get_vector_store_service),
217
  ) -> DocumentIngestResponse:
 
224
  if not file.filename or not file.filename.lower().endswith(".pdf"):
225
  raise HTTPException(status_code=400, detail="Only .pdf files are accepted")
226
 
 
227
  try:
228
  raw = await file.read()
229
+ finally:
230
+ await file.close()
231
+
232
+ text = await _process_pdf_bytes(raw, file.filename, clean_content)
233
+ if isinstance(text, ConversionError):
234
+ return DocumentIngestResponse(
235
+ success=False,
236
+ vector_store_id=store_id,
237
+ doc_id=doc_id,
238
+ chunks_ingested=0,
239
+ time_ms=0,
240
+ error=text.message,
241
+ )
 
 
 
 
 
 
 
 
 
 
 
 
242
 
243
+ try:
244
  chunks, elapsed = await vector_store_service.ingest_document(
245
  store_id=store_id,
246
  doc_id=doc_id,
247
  text=text,
248
+ source=file.filename,
249
  chunk_size=chunk_size,
250
  chunk_overlap=chunk_overlap,
251
  )
252
+ except Exception as exc:
253
+ logger.error("PDF ingest failed for store %s: %s", store_id, exc)
254
  return DocumentIngestResponse(
255
+ success=False,
256
  vector_store_id=store_id,
257
  doc_id=doc_id,
258
+ chunks_ingested=0,
259
+ time_ms=0,
260
+ error=str(exc),
261
+ )
262
+
263
+ return DocumentIngestResponse(
264
+ success=True,
265
+ vector_store_id=store_id,
266
+ doc_id=doc_id,
267
+ chunks_ingested=chunks,
268
+ time_ms=round(elapsed, 3),
269
+ )
270
+
271
+
272
+ @router.post(
273
+ "/vector-stores/{store_id}/documents/upload-url",
274
+ response_model=DocumentIngestResponse,
275
+ summary="Ingest a PDF from a URL into the vector store",
276
+ )
277
+ async def ingest_pdf_url(
278
+ store_id: str,
279
+ body: DocumentIngestUrlRequest,
280
+ clean_content: bool = Query(True, description="Clean markdown text after PDF conversion"),
281
+ token: str = Depends(require_auth),
282
+ vector_store_service: VectorStoreService = Depends(get_vector_store_service),
283
+ ) -> DocumentIngestResponse:
284
+ record = vector_store_service.get_store(store_id)
285
+ if record is None:
286
+ raise HTTPException(status_code=404, detail=f"Vector store {store_id} not found")
287
+
288
+ import httpx
289
+
290
+ try:
291
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
292
+ resp = await client.get(body.url)
293
+ resp.raise_for_status()
294
+ raw = resp.content
295
+ except httpx.HTTPStatusError as exc:
296
+ return DocumentIngestResponse(
297
+ success=False,
298
+ vector_store_id=store_id,
299
+ doc_id=body.doc_id,
300
+ chunks_ingested=0,
301
+ time_ms=0,
302
+ error=f"Failed to fetch PDF from URL: HTTP {exc.response.status_code}",
303
+ )
304
+ except httpx.RequestError as exc:
305
+ return DocumentIngestResponse(
306
+ success=False,
307
+ vector_store_id=store_id,
308
+ doc_id=body.doc_id,
309
+ chunks_ingested=0,
310
+ time_ms=0,
311
+ error=f"Failed to fetch PDF from URL: {exc}",
312
+ )
313
+
314
+ text = await _process_pdf_bytes(raw, body.url, clean_content)
315
+ if isinstance(text, ConversionError):
316
+ return DocumentIngestResponse(
317
+ success=False,
318
+ vector_store_id=store_id,
319
+ doc_id=body.doc_id,
320
+ chunks_ingested=0,
321
+ time_ms=0,
322
+ error=text.message,
323
+ )
324
+
325
+ try:
326
+ chunks, elapsed = await vector_store_service.ingest_document(
327
+ store_id=store_id,
328
+ doc_id=body.doc_id,
329
+ text=text,
330
+ source=body.url,
331
+ chunk_size=body.chunk_size,
332
+ chunk_overlap=body.chunk_overlap,
333
  )
 
 
334
  except Exception as exc:
335
+ logger.error("PDF URL ingest failed for store %s: %s", store_id, exc)
336
  return DocumentIngestResponse(
337
  success=False,
338
  vector_store_id=store_id,
339
+ doc_id=body.doc_id,
340
  chunks_ingested=0,
341
  time_ms=0,
342
  error=str(exc),
343
  )
344
+
345
+ return DocumentIngestResponse(
346
+ success=True,
347
+ vector_store_id=store_id,
348
+ doc_id=body.doc_id,
349
+ chunks_ingested=chunks,
350
+ time_ms=round(elapsed, 3),
351
+ )
352
 
353
 
354
  @router.post(
app/models/schemas.py CHANGED
@@ -611,6 +611,13 @@ class DocumentIngestResponse(BaseModel):
611
  error: Optional[str] = None
612
 
613
 
 
 
 
 
 
 
 
614
  class SearchRequest(BaseModel):
615
  query: str = Field(..., min_length=1, max_length=5000, description="Natural language query")
616
  top_k: int = Field(default=10, ge=1, le=100, description="Max results to return")
 
611
  error: Optional[str] = None
612
 
613
 
614
+ class DocumentIngestUrlRequest(BaseModel):
615
+ url: str = Field(..., min_length=1, description="URL of the PDF file to ingest")
616
+ doc_id: str = Field(..., min_length=1, max_length=256)
617
+ chunk_size: int = Field(512, ge=64, le=4096)
618
+ chunk_overlap: int = Field(64, ge=0, le=512)
619
+
620
+
621
  class SearchRequest(BaseModel):
622
  query: str = Field(..., min_length=1, max_length=5000, description="Natural language query")
623
  top_k: int = Field(default=10, ge=1, le=100, description="Max results to return")
app/services/vector_store_service.py CHANGED
@@ -358,10 +358,6 @@ class VectorStoreService:
358
 
359
  # --- Async public API ---
360
 
361
- async def _run_in_thread(self, fn, *args, **kwargs):
362
- loop = asyncio.get_running_loop()
363
- return await loop.run_in_executor(self._thread_pool, _run_sync, lambda: fn(*args, **kwargs))
364
-
365
  async def _run_sync_fn(self, fn):
366
  loop = asyncio.get_running_loop()
367
  return await loop.run_in_executor(self._thread_pool, fn)
 
358
 
359
  # --- Async public API ---
360
 
 
 
 
 
361
  async def _run_sync_fn(self, fn):
362
  loop = asyncio.get_running_loop()
363
  return await loop.run_in_executor(self._thread_pool, fn)