Spaces:
Runtime error
Runtime error
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 +9 -0
- app/idempotency.py +76 -0
- app/routers/hubs.py +75 -0
- app/schemas.py +7 -0
- app/services/embeddings.py +17 -0
- app/services/gemini.py +26 -0
- tests/conftest.py +3 -3
- tests/test_idempotency.py +64 -0
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 |
-
|
| 39 |
-
|
| 40 |
-
conn.
|
| 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"
|