RamshaAnwar commited on
Commit
b13c030
·
unverified ·
1 Parent(s): da24e62

feat(documents): async ingestion and state machine (#10)

Browse files

* feat(documents): add upload endpoint and async state machine

* test(documents): cover resumability and per-stage idempotency

app/main.py CHANGED
@@ -8,6 +8,8 @@ from app.routers import auth,hubs
8
  from app.deps import get_current_user
9
  from app.models import User
10
  from app.services.gemini import AiUnavailable
 
 
11
 
12
 
13
  logger = logging.getLogger("uvicorn.error")
@@ -22,6 +24,8 @@ def create_app() -> FastAPI:
22
 
23
  app.include_router(auth.router, prefix="/api/v1")
24
  app.include_router(hubs.router, prefix="/api/v1")
 
 
25
 
26
  @app.middleware("http")
27
  async def catch_unhandled(request: Request, call_next):
 
8
  from app.deps import get_current_user
9
  from app.models import User
10
  from app.services.gemini import AiUnavailable
11
+ from app.routers import auth, hubs, documents
12
+
13
 
14
 
15
  logger = logging.getLogger("uvicorn.error")
 
24
 
25
  app.include_router(auth.router, prefix="/api/v1")
26
  app.include_router(hubs.router, prefix="/api/v1")
27
+ app.include_router(documents.router, prefix="/api/v1")
28
+
29
 
30
  @app.middleware("http")
31
  async def catch_unhandled(request: Request, call_next):
app/routers/documents.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import io
3
+ from uuid import UUID
4
+ from fastapi import APIRouter, Depends, File, UploadFile, Header, status
5
+ from sqlalchemy.orm import Session
6
+ from pypdf import PdfReader
7
+
8
+ from app.db import get_db
9
+ from app.deps import get_current_user
10
+ from app.models import User, Hub, Document, Chunk
11
+ from app.schemas import DocumentResponse
12
+ from app.errors import AppError
13
+ from app.idempotency import (
14
+ require_idempotency_key, purge_stale, claim_key,
15
+ complete_key, release_claim, replay_or_conflict
16
+ )
17
+ from app.services.embeddings import embed_texts
18
+ from app.chunking import chunk_text
19
+
20
+ router = APIRouter(prefix="/hubs/{hub_id}/documents", tags=["documents"])
21
+
22
+ @router.post("", response_model=DocumentResponse, status_code=status.HTTP_202_ACCEPTED)
23
+ async def upload_document(
24
+ hub_id: UUID,
25
+ file: UploadFile = File(...),
26
+ key: UUID = Depends(require_idempotency_key),
27
+ current_user: User = Depends(get_current_user),
28
+ db: Session = Depends(get_db)
29
+ ):
30
+ # 1. Verify hub ownership
31
+ hub = db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == current_user.id).first()
32
+ if not hub:
33
+ raise AppError(404, "HUB_NOT_FOUND", "Hub not found.")
34
+
35
+ # 2. Validate MIME type and size
36
+ allowed_mimes = ["application/pdf", "text/plain", "text/markdown"]
37
+ if file.content_type not in allowed_mimes:
38
+ raise AppError(415, "UNSUPPORTED_MIME_TYPE", "Only PDF, TXT, and MD are allowed.")
39
+
40
+ # Read bytes (limit to 10MB)
41
+ contents = await file.read()
42
+ if len(contents) > 10 * 1024 * 1024:
43
+ raise AppError(413, "DOCUMENT_TOO_LARGE", "File exceeds 10MB.")
44
+
45
+ # 3. Calculate checksum
46
+ checksum = hashlib.sha256(contents).hexdigest()
47
+
48
+ # 4. Idempotency hash (hub_id + filename + size + checksum)
49
+ hash_input = f"{hub_id}{file.filename}{len(contents)}{checksum}"
50
+ req_hash = hashlib.sha256(hash_input.encode()).hexdigest()
51
+
52
+ # 5. Claim key
53
+ purge_stale(db, current_user.id)
54
+ if not claim_key(db, current_user.id, key, req_hash, f"/api/v1/hubs/{hub_id}/documents"):
55
+ return replay_or_conflict(db, current_user.id, key, req_hash)
56
+
57
+ # 6. Check for duplicate upload
58
+ existing = db.query(Document).filter(Document.hub_id == hub_id, Document.checksum_sha256 == checksum).first()
59
+ if existing:
60
+ release_claim(db, current_user.id, key)
61
+ raise AppError(409, "DOCUMENT_ALREADY_EXISTS", "This file has already been uploaded to this hub.")
62
+
63
+ # 7. Save document
64
+ new_doc = Document(
65
+ hub_id=hub_id, owner_id=current_user.id, origin="upload",
66
+ filename=file.filename, mime_type=file.content_type, size_bytes=len(contents),
67
+ checksum_sha256=checksum, status="pending", file_bytes=contents
68
+ )
69
+ db.add(new_doc)
70
+ db.commit()
71
+ db.refresh(new_doc)
72
+
73
+ # 8. Build response and complete key
74
+ payload = DocumentResponse.model_validate(new_doc).model_dump(mode="json")
75
+ complete_key(db, current_user.id, key, 202, payload)
76
+ return payload
77
+
78
+ @router.post("/{doc_id}/process", response_model=DocumentResponse)
79
+ def process_document(
80
+ hub_id: UUID,
81
+ doc_id: UUID,
82
+ key: UUID | None = Header(default=None, alias="X-Idempotency-Key"),
83
+ current_user: User = Depends(get_current_user),
84
+ db: Session = Depends(get_db)
85
+ ):
86
+ # 1. Fetch document with tenant isolation
87
+ doc = db.query(Document).filter(
88
+ Document.id == doc_id, Document.hub_id == hub_id, Document.owner_id == current_user.id
89
+ ).first()
90
+
91
+ if not doc:
92
+ raise AppError(404, "DOCUMENT_NOT_FOUND", "Document not found.")
93
+ if doc.status in ("ready", "failed"):
94
+ raise AppError(409, "DOCUMENT_NOT_PROCESSABLE", "Document is already complete or failed.")
95
+
96
+ # 2. Idempotency check: If the same key is used, return the current state without advancing
97
+ if key and doc.process_claim and str(doc.process_claim) == str(key):
98
+ return DocumentResponse.model_validate(doc)
99
+
100
+ # 3. Claim the stage
101
+ if key:
102
+ doc.process_claim = key
103
+ db.commit() # Commit the claim immediately so concurrent requests see it
104
+
105
+ # 4. Advance state machine
106
+ if doc.status == "pending":
107
+ doc.status = "chunking"
108
+ db.commit()
109
+
110
+ elif doc.status == "chunking":
111
+ try:
112
+ if doc.mime_type == "application/pdf":
113
+ reader = PdfReader(io.BytesIO(doc.file_bytes))
114
+ text = "\n".join(page.extract_text() for page in reader.pages if page.extract_text())
115
+ else:
116
+ text = doc.file_bytes.decode("utf-8", errors="ignore")
117
+
118
+ if not text.strip():
119
+ raise ValueError("No text extracted")
120
+
121
+ chunks_data = chunk_text(text)
122
+ for i, (chunk_text_str, tokens) in enumerate(chunks_data):
123
+ db.add(Chunk(
124
+ document_id=doc.id, hub_id=hub_id, owner_id=current_user.id,
125
+ chunk_index=i, text=chunk_text_str, token_count=tokens,
126
+ embedding=[0.0] * 768 # Placeholder
127
+ ))
128
+
129
+ doc.file_bytes = None # Delete raw bytes!
130
+ doc.status = "embedding"
131
+ db.commit()
132
+ except Exception as e:
133
+ doc.status = "failed"
134
+ doc.error_message = "PDF parsing failed: " + str(e)
135
+ db.commit()
136
+
137
+ elif doc.status == "embedding":
138
+ chunks = db.query(Chunk).filter(Chunk.document_id == doc.id).order_by(Chunk.chunk_index).all()
139
+ if not chunks:
140
+ doc.status = "failed"
141
+ doc.error_message = "No chunks found to embed."
142
+ db.commit()
143
+ else:
144
+ try:
145
+ texts = [c.text for c in chunks]
146
+ embeddings = embed_texts(texts)
147
+
148
+ for chunk, emb in zip(chunks, embeddings):
149
+ chunk.embedding = emb
150
+
151
+ doc.status = "ready"
152
+ doc.process_claim = None # Clear claim on success
153
+ db.commit()
154
+ except Exception as e:
155
+ doc.status = "failed"
156
+ doc.error_message = "Embedding failed: " + str(e)
157
+ db.commit()
158
+
159
+ db.refresh(doc)
160
+ return DocumentResponse.model_validate(doc)
app/schemas.py CHANGED
@@ -77,4 +77,16 @@ class HubCreate(BaseModel):
77
  content: str = Field(default="", max_length=20000)
78
  documents: list[DocumentSummary] = []
79
 
80
-
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  content: str = Field(default="", max_length=20000)
78
  documents: list[DocumentSummary] = []
79
 
80
+ class DocumentResponse(BaseModel):
81
+ model_config = ConfigDict(from_attributes=True)
82
+ id: UUID
83
+ hub_id: UUID
84
+ origin: str
85
+ filename: str
86
+ mime_type: str
87
+ size_bytes: int
88
+ status: str
89
+ error_message: str | None
90
+ chunk_count: int = 0
91
+ created_at: datetime
92
+ updated_at: datetime
tests/test_ingestion.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from uuid import uuid4
3
+ from unittest.mock import patch
4
+ from app.models import Document, Chunk
5
+
6
+ def test_upload_and_process_pdf(client, db_session):
7
+ # Setup user and hub
8
+ client.post("/api/v1/auth/signup", json={"email": "doc@test.com", "password": "Password123"})
9
+ token = client.post("/api/v1/auth/login", json={"email": "doc@test.com", "password": "Password123"}).json()["access_token"]
10
+ headers = {"Authorization": f"Bearer {token}"}
11
+
12
+ # Create a hub
13
+ key = str(uuid4())
14
+ hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": key}, json={"name": "PDF Hub"})
15
+ hub_id = hub_res.json()["id"]
16
+
17
+ # Upload a fake text file
18
+ file_content = b"This is a test document. It has multiple sentences to chunk."
19
+ upload_key = str(uuid4())
20
+ files = {"file": ("test.txt", file_content, "text/plain")}
21
+
22
+ upload_res = client.post(f"/api/v1/hubs/{hub_id}/documents", headers={**headers, "X-Idempotency-Key": upload_key}, files=files)
23
+ assert upload_res.status_code == 202
24
+ doc_id = upload_res.json()["id"]
25
+ assert upload_res.json()["status"] == "pending"
26
+
27
+ # Process Stage 1 (chunking)
28
+ with patch("app.routers.documents.embed_texts", return_value=[[0.1]*768]):
29
+ res1 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers=headers)
30
+ assert res1.json()["status"] == "chunking"
31
+
32
+ # Process Stage 2 (embedding)
33
+ res2 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers=headers)
34
+ assert res2.json()["status"] == "embedding"
35
+
36
+ # Process Stage 3 (ready)
37
+ res3 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers=headers)
38
+ assert res3.json()["status"] == "ready"
39
+
40
+ # Verify chunks exist in DB
41
+ assert db_session.query(Chunk).count() > 0
42
+
43
+ def test_process_is_idempotent(client, db_session):
44
+ # Setup user and hub
45
+ client.post("/api/v1/auth/signup", json={"email": "idem_doc@test.com", "password": "Password123"})
46
+ token = client.post("/api/v1/auth/login", json={"email": "idem_doc@test.com", "password": "Password123"}).json()["access_token"]
47
+ headers = {"Authorization": f"Bearer {token}"}
48
+
49
+ hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": str(uuid4())}, json={"name": "Idem Doc Hub"})
50
+ hub_id = hub_res.json()["id"]
51
+
52
+ files = {"file": ("test.txt", b"Test text for idempotency.", "text/plain")}
53
+ upload_res = client.post(f"/api/v1/hubs/{hub_id}/documents", headers={**headers, "X-Idempotency-Key": str(uuid4())}, files=files)
54
+ doc_id = upload_res.json()["id"]
55
+
56
+ key1 = str(uuid4())
57
+
58
+ with patch("app.routers.documents.embed_texts", return_value=[[0.1]*768]):
59
+ # 1. Advance to chunking
60
+ res1 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers={**headers, "X-Idempotency-Key": key1})
61
+ assert res1.json()["status"] == "chunking"
62
+
63
+ # 2. Retry the exact same request with key1
64
+ res1_retry = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers={**headers, "X-Idempotency-Key": key1})
65
+ # It should return "chunking", NOT "embedding"!
66
+ assert res1_retry.json()["status"] == "chunking"
67
+
68
+ # 3. Advance to embedding with a NEW key
69
+ key2 = str(uuid4())
70
+ res2 = client.post(f"/api/v1/hubs/{hub_id}/documents/{doc_id}/process", headers={**headers, "X-Idempotency-Key": key2})
71
+ assert res2.json()["status"] == "embedding"