RamshaAnwar commited on
Commit
dfd168b
·
unverified ·
1 Parent(s): 3d08a7e

feat(hubs): idempotent create with inline document (#6)

Browse files

* feat(models): add relationships and hub response schemas

* feat(hubs): add tenant-safe list, get, and delete endpoints

* test(isolation): prove cross-tenant read, list, and delete are cloaked

* feat(ai): add gemini, embeddings, and chunking wrappers

* feat(hubs): implement idempotent create with inline document

* test(idempotency): cover replay, different body, and missing key

app/chunking.py CHANGED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ def chunk_text(text: str, size_tokens: int = 500, overlap_tokens: int = 50) -> list[tuple[str, int]]:
2
+ words = text.split()
3
+ chunks = []
4
+ i = 0
5
+ while i < len(words):
6
+ chunk_words = words[i:i + size_tokens]
7
+ chunks.append((" ".join(chunk_words), len(chunk_words)))
8
+ i += size_tokens - overlap_tokens
9
+ return chunks
app/idempotency.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import json
3
+ from uuid import UUID
4
+ from datetime import datetime, timezone, timedelta
5
+ from sqlalchemy.orm import Session
6
+ from sqlalchemy.dialects.postgresql import insert
7
+ from fastapi import Header, Response
8
+ from app.models import IdempotencyKey
9
+ from app.errors import AppError
10
+
11
+ def require_idempotency_key(x_idempotency_key: str | None = Header(default=None)) -> UUID:
12
+ if x_idempotency_key is None:
13
+ raise AppError(400, "IDEMPOTENCY_KEY_MISSING", "Header X-Idempotency-Key is required.")
14
+ try:
15
+ key = UUID(x_idempotency_key)
16
+ if key.version != 4:
17
+ raise ValueError
18
+ return key
19
+ except ValueError:
20
+ raise AppError(422, "IDEMPOTENCY_KEY_INVALID", "X-Idempotency-Key must be a UUIDv4.")
21
+
22
+ def body_hash(data: dict) -> str:
23
+ canonical = json.dumps(data, sort_keys=True, separators=(",", ":"))
24
+ return hashlib.sha256(canonical.encode()).hexdigest()
25
+
26
+ def purge_stale(db: Session, user_id: UUID):
27
+ now = datetime.now(timezone.utc)
28
+ # Delete expired keys OR in_progress keys older than 60 seconds
29
+ db.query(IdempotencyKey).filter(
30
+ IdempotencyKey.user_id == user_id,
31
+ (IdempotencyKey.expires_at < now) |
32
+ ((IdempotencyKey.status == "in_progress") & (IdempotencyKey.created_at < now - timedelta(seconds=60)))
33
+ ).delete()
34
+ db.commit()
35
+
36
+ def claim_key(db: Session, user_id: UUID, key: UUID, req_hash: str, path: str) -> bool:
37
+ stmt = insert(IdempotencyKey).values(
38
+ user_id=user_id, key=key, request_method="POST", request_path=path,
39
+ request_hash=req_hash, status="in_progress",
40
+ expires_at=datetime.now(timezone.utc) + timedelta(hours=24)
41
+ ).on_conflict_do_nothing().returning(IdempotencyKey.key)
42
+
43
+ result = db.execute(stmt)
44
+ db.commit()
45
+ # fetchone() returns None if the insert was skipped (conflict)
46
+ return result.fetchone() is not None
47
+
48
+ def complete_key(db: Session, user_id: UUID, key: UUID, status: int, body: dict):
49
+ row = db.query(IdempotencyKey).filter_by(user_id=user_id, key=key).first()
50
+ if row:
51
+ row.status = "completed"
52
+ row.response_status = status
53
+ row.response_body = body
54
+ db.commit()
55
+
56
+ def release_claim(db: Session, user_id: UUID, key: UUID):
57
+ db.query(IdempotencyKey).filter_by(user_id=user_id, key=key).delete()
58
+ db.commit()
59
+
60
+ def replay_or_conflict(db: Session, user_id: UUID, key: UUID, req_hash: str):
61
+ row = db.query(IdempotencyKey).filter_by(user_id=user_id, key=key).first()
62
+ if not row:
63
+ raise AppError(500, "INTERNAL_ERROR", "Idempotency key vanished.")
64
+
65
+ if row.request_hash != req_hash:
66
+ raise AppError(422, "IDEMPOTENCY_KEY_REUSED", "This idempotency key was used with a different payload.")
67
+
68
+ if row.status == "in_progress":
69
+ raise AppError(409, "IDEMPOTENCY_REQUEST_IN_PROGRESS", "Original request still processing.", headers={"Retry-After": "2"})
70
+
71
+ return Response(
72
+ content=json.dumps(row.response_body),
73
+ status_code=row.response_status,
74
+ media_type="application/json",
75
+ headers={"Idempotent-Replayed": "true"}
76
+ )
app/routers/hubs.py CHANGED
@@ -3,6 +3,81 @@ from fastapi import APIRouter, Depends, status, Query, Response
3
  from sqlalchemy.orm import Session
4
  from app.db import get_db
5
  from app.deps import get_current_user
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  from app.models import User
7
  from app.schemas import HubResponse, HubDetail
8
  from app.errors import AppError
 
3
  from sqlalchemy.orm import Session
4
  from app.db import get_db
5
  from app.deps import get_current_user
6
+ from app.models import User, Hub, Document, Chunk
7
+ from app.schemas import HubResponse, HubDetail, HubCreate
8
+ from app.errors import AppError
9
+ from app import repo
10
+ from app.idempotency import require_idempotency_key, body_hash, purge_stale, claim_key, complete_key, release_claim, replay_or_conflict
11
+ from app.chunking import chunk_text
12
+ from app.services.embeddings import embed_texts
13
+ from app.services.gemini import hub_insights
14
+ router = APIRouter(prefix="/hubs", tags=["hubs"])
15
+
16
+
17
+
18
+ @router.post("", response_model=HubResponse, status_code=status.HTTP_201_CREATED)
19
+ def create_hub(
20
+ body: HubCreate,
21
+ key: UUID = Depends(require_idempotency_key),
22
+ current_user: User = Depends(get_current_user),
23
+ db: Session = Depends(get_db)
24
+ ):
25
+ user_id = current_user.id
26
+ req_hash = body_hash(body.model_dump())
27
+
28
+ purge_stale(db, user_id)
29
+ if not claim_key(db, user_id, key, req_hash, "/api/v1/hubs"):
30
+ return replay_or_conflict(db, user_id, key, req_hash)
31
+
32
+ # Name pre-check
33
+ if db.query(Hub).filter(Hub.owner_id == user_id, Hub.name == body.name).first():
34
+ release_claim(db, user_id, key)
35
+ raise AppError(409, "HUB_NAME_CONFLICT", "You already have a hub with this name.")
36
+
37
+ # AI Summary
38
+ summary, questions = hub_insights(body.content) if body.content else (None, [])
39
+
40
+ # Create Hub
41
+ new_hub = Hub(owner_id=user_id, name=body.name, description=body.description, ai_summary=summary, suggested_questions=questions)
42
+ db.add(new_hub)
43
+ db.flush()
44
+
45
+ doc_count = 0
46
+ chunk_count = 0
47
+
48
+ # Inline Document
49
+ if body.content.strip():
50
+ doc_count = 1
51
+ new_doc = Document(
52
+ hub_id=new_hub.id, owner_id=user_id, origin="inline", filename="inline.txt",
53
+ mime_type="text/plain", size_bytes=len(body.content), checksum_sha256="0"*64, status="pending"
54
+ )
55
+ db.add(new_doc)
56
+ db.flush()
57
+ try:
58
+ chunks_data = chunk_text(body.content)
59
+ texts = [c[0] for c in chunks_data]
60
+ embeddings = embed_texts(texts)
61
+ for i, ((text, tokens), emb) in enumerate(zip(chunks_data, embeddings)):
62
+ db.add(Chunk(document_id=new_doc.id, hub_id=new_hub.id, owner_id=user_id, chunk_index=i, text=text, token_count=tokens, embedding=emb))
63
+ chunk_count += 1
64
+ new_doc.status = "ready"
65
+ except Exception:
66
+ new_doc.status = "failed"
67
+ new_doc.error_message = "Embedding failed"
68
+
69
+ db.commit()
70
+ db.refresh(new_hub)
71
+
72
+ # Build response
73
+ hub_resp = HubResponse.model_validate(new_hub)
74
+ hub_resp.document_count = doc_count
75
+ hub_resp.chunk_count = chunk_count
76
+ payload = hub_resp.model_dump(mode="json")
77
+
78
+ complete_key(db, user_id, key, 201, payload)
79
+ return payload
80
+
81
  from app.models import User
82
  from app.schemas import HubResponse, HubDetail
83
  from app.errors import AppError
app/schemas.py CHANGED
@@ -68,6 +68,13 @@ class HubResponse(BaseModel):
68
  updated_at: datetime
69
 
70
  class HubDetail(HubResponse):
 
 
 
 
 
 
 
71
  documents: list[DocumentSummary] = []
72
 
73
 
 
68
  updated_at: datetime
69
 
70
  class HubDetail(HubResponse):
71
+ documents: list[DocumentSummary] = []
72
+
73
+ class HubCreate(BaseModel):
74
+ model_config = ConfigDict(extra="forbid")
75
+ name: str = Field(min_length=1, max_length=120)
76
+ description: str | None = Field(default=None, max_length=500)
77
+ content: str = Field(default="", max_length=20000)
78
  documents: list[DocumentSummary] = []
79
 
80
 
app/services/embeddings.py CHANGED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from google import genai
2
+ from google.genai import types
3
+ from app.config import settings
4
+ from app.services.gemini import AiUnavailable
5
+
6
+ _client = genai.Client(api_key=settings.GEMINI_API_KEY, http_options=types.HttpOptions(timeout=15_000))
7
+
8
+ def embed_texts(texts: list[str], batch_size: int = 20) -> list[list[float]]:
9
+ out: list[list[float]] = []
10
+ for i in range(0, len(texts), batch_size):
11
+ batch = texts[i:i + batch_size]
12
+ try:
13
+ r = _client.models.embed_content(model=settings.GEMINI_EMBEDDING_MODEL, contents=batch)
14
+ out.extend([e.values for e in r.embeddings])
15
+ except Exception as exc:
16
+ raise AiUnavailable("provider", str(exc)) from exc
17
+ return out
app/services/gemini.py CHANGED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from google import genai
3
+ from google.genai import types
4
+ from app.config import settings
5
+
6
+ class AiUnavailable(Exception):
7
+ def __init__(self, kind: str, detail: str = ""):
8
+ super().__init__(detail)
9
+ self.kind = kind
10
+
11
+ _client = genai.Client(api_key=settings.GEMINI_API_KEY, http_options=types.HttpOptions(timeout=15_000))
12
+
13
+ def hub_insights(content: str) -> tuple[str | None, list[str]]:
14
+ try:
15
+ r = _client.models.generate_content(
16
+ model=settings.GEMINI_MODEL,
17
+ contents=f"<context>{content}</context>",
18
+ config=types.GenerateContentConfig(
19
+ system_instruction='Reply with JSON only: {"summary": "at most 2 sentences", "questions": ["3 questions"]}',
20
+ temperature=0.2, max_output_tokens=1024, response_mime_type="application/json"
21
+ )
22
+ )
23
+ d = json.loads(r.text)
24
+ return str(d.get("summary", ""))[:500], [str(q)[:200] for q in d.get("questions", [])][:3]
25
+ except Exception:
26
+ return None, []
tests/conftest.py CHANGED
@@ -35,9 +35,9 @@ def setup_database():
35
 
36
  @pytest.fixture(autouse=True)
37
  def clean_tables():
38
- # Truncate tables between tests
39
- with engine.begin() as conn:
40
- conn.execute(text("TRUNCATE idempotency_keys, chunks, documents, hubs, users CASCADE"))
41
  yield
42
 
43
  @pytest.fixture
 
35
 
36
  @pytest.fixture(autouse=True)
37
  def clean_tables():
38
+ with engine.connect() as conn:
39
+ conn.execute(text("TRUNCATE idempotency_keys, chunks, documents, hubs, users RESTART IDENTITY CASCADE"))
40
+ conn.commit()
41
  yield
42
 
43
  @pytest.fixture
tests/test_idempotency.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from uuid import uuid4
3
+ from unittest.mock import patch
4
+ from app.models import Hub, Document, Chunk
5
+
6
+ def test_create_hub_idempotency(client, db_session):
7
+ client.post("/api/v1/auth/signup", json={"email": "idem@example.com", "password": "Password123"})
8
+ login = client.post("/api/v1/auth/login", json={"email": "idem@example.com", "password": "Password123"})
9
+ token = login.json()["access_token"]
10
+
11
+ key = str(uuid4())
12
+ headers = {"Authorization": f"Bearer {token}", "X-Idempotency-Key": key}
13
+ body = {"name": "HR Hub", "content": "This is a long text about HR policies and employee benefits."}
14
+
15
+ with patch("app.routers.hubs.hub_insights", return_value=("Summary here", ["Q1?", "Q2?"])), \
16
+ patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768] * 3):
17
+
18
+ # First Request
19
+ r1 = client.post("/api/v1/hubs", headers=headers, json=body)
20
+ if r1.status_code != 201:
21
+ print("\n\nFAILED FIRST REQUEST:", r1.json(), "\n\n")
22
+
23
+ assert r1.status_code == 201
24
+ assert "Idempotent-Replayed" not in r1.headers
25
+
26
+ # Second Request (Replay)
27
+ r2 = client.post("/api/v1/hubs", headers=headers, json=body)
28
+
29
+ # --- ADD THIS PRINT STATEMENT ---
30
+ if r2.status_code != 201:
31
+ print("\n\nFAILED SECOND REQUEST:", r2.json(), "\n\n")
32
+ # --------------------------------
33
+
34
+ assert r2.status_code == 201
35
+ assert r2.headers["Idempotent-Replayed"] == "true"
36
+ assert r1.json() == r2.json()
37
+
38
+ # Check DB counts
39
+ assert db_session.query(Hub).count() == 1
40
+ assert db_session.query(Document).count() == 1
41
+ assert db_session.query(Chunk).count() > 0
42
+
43
+ # Third Request (Different Body)
44
+ r3 = client.post("/api/v1/hubs", headers=headers, json={"name": "Finance Hub", "content": "Different text"})
45
+ assert r3.status_code == 422
46
+ assert r3.json()["error"]["code"] == "IDEMPOTENCY_KEY_REUSED"
47
+
48
+ def test_create_hub_missing_key(client):
49
+ client.post("/api/v1/auth/signup", json={"email": "idem2@example.com", "password": "Password123"})
50
+ login = client.post("/api/v1/auth/login", json={"email": "idem2@example.com", "password": "Password123"})
51
+ token = login.json()["access_token"]
52
+
53
+ r = client.post("/api/v1/hubs", headers={"Authorization": f"Bearer {token}"}, json={"name": "No Key"})
54
+ assert r.status_code == 400
55
+ assert r.json()["error"]["code"] == "IDEMPOTENCY_KEY_MISSING"
56
+
57
+ def test_create_hub_missing_key(client):
58
+ client.post("/api/v1/auth/signup", json={"email": "idem2@example.com", "password": "Password123"})
59
+ login = client.post("/api/v1/auth/login", json={"email": "idem2@example.com", "password": "Password123"})
60
+ token = login.json()["access_token"]
61
+
62
+ r = client.post("/api/v1/hubs", headers={"Authorization": f"Bearer {token}"}, json={"name": "No Key"})
63
+ assert r.status_code == 400
64
+ assert r.json()["error"]["code"] == "IDEMPOTENCY_KEY_MISSING"