Spaces:
Runtime error
Runtime error
feat(query): multi-query expansion for improved retrieval (#13)
Browse files* feat(schemas): add query request and response schemas with similarity config
* feat(repo): add vector search query scoped by owner and hub
* fix(ai): strip markdown code blocks from gemini json responses
* feat(query): add grounded rag endpoint with similarity threshold
* test(query): verify cross-tenant isolation and empty retrieval
* feat(ai): add expand_query for multi-query retrieval
* feat(repo): add multi-query union retrieval
* feat(query): integrate multi-query expansion into rag
* test(query): cover multihop sources and expansion fallback
- app/repo.py +21 -5
- app/routers/hubs.py +14 -54
- app/services/gemini.py +14 -1
- tests/test_rag.py +41 -7
app/repo.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
from uuid import UUID
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
-
from app.models import Hub
|
| 4 |
-
from app.models import Chunk
|
| 5 |
from app.config import settings
|
| 6 |
|
| 7 |
def list_hubs_for_owner(db: Session, owner_id: UUID, limit: int, offset: int):
|
|
@@ -14,15 +13,32 @@ def get_hub_for_owner(db: Session, hub_id: UUID, owner_id: UUID) -> Hub | None:
|
|
| 14 |
return db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == owner_id).first()
|
| 15 |
|
| 16 |
def delete_hub_for_owner(db: Session, hub_id: UUID, owner_id: UUID) -> int:
|
| 17 |
-
# Returns the number of rows deleted (0 or 1)
|
| 18 |
return db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == owner_id).delete()
|
| 19 |
|
| 20 |
def retrieve_chunks_for_query(db: Session, owner_id: UUID, hub_id: UUID, query_embedding: list[float]):
|
| 21 |
-
# We filter by owner and hub FIRST, then order by cosine distance
|
| 22 |
return (
|
| 23 |
db.query(Chunk)
|
| 24 |
.filter(Chunk.owner_id == owner_id, Chunk.hub_id == hub_id)
|
| 25 |
.order_by(Chunk.embedding.cosine_distance(query_embedding))
|
| 26 |
.limit(settings.RAG_TOP_K)
|
| 27 |
.all()
|
| 28 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from uuid import UUID
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
+
from app.models import Hub, Chunk
|
|
|
|
| 4 |
from app.config import settings
|
| 5 |
|
| 6 |
def list_hubs_for_owner(db: Session, owner_id: UUID, limit: int, offset: int):
|
|
|
|
| 13 |
return db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == owner_id).first()
|
| 14 |
|
| 15 |
def delete_hub_for_owner(db: Session, hub_id: UUID, owner_id: UUID) -> int:
|
|
|
|
| 16 |
return db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == owner_id).delete()
|
| 17 |
|
| 18 |
def retrieve_chunks_for_query(db: Session, owner_id: UUID, hub_id: UUID, query_embedding: list[float]):
|
|
|
|
| 19 |
return (
|
| 20 |
db.query(Chunk)
|
| 21 |
.filter(Chunk.owner_id == owner_id, Chunk.hub_id == hub_id)
|
| 22 |
.order_by(Chunk.embedding.cosine_distance(query_embedding))
|
| 23 |
.limit(settings.RAG_TOP_K)
|
| 24 |
.all()
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
def retrieve_chunks_for_queries(db: Session, owner_id: UUID, hub_id: UUID, query_embeddings: list[list[float]]):
|
| 28 |
+
chunk_ids = set()
|
| 29 |
+
chunks = []
|
| 30 |
+
|
| 31 |
+
for emb in query_embeddings:
|
| 32 |
+
results = (
|
| 33 |
+
db.query(Chunk)
|
| 34 |
+
.filter(Chunk.owner_id == owner_id, Chunk.hub_id == hub_id)
|
| 35 |
+
.order_by(Chunk.embedding.cosine_distance(emb))
|
| 36 |
+
.limit(settings.RAG_TOP_K)
|
| 37 |
+
.all()
|
| 38 |
+
)
|
| 39 |
+
for r in results:
|
| 40 |
+
if r.id not in chunk_ids:
|
| 41 |
+
chunk_ids.add(r.id)
|
| 42 |
+
chunks.append(r)
|
| 43 |
+
|
| 44 |
+
return chunks
|
app/routers/hubs.py
CHANGED
|
@@ -2,10 +2,12 @@ from uuid import UUID
|
|
| 2 |
from fastapi import APIRouter, Depends, status, Query, Response
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
from sqlalchemy.exc import IntegrityError
|
|
|
|
|
|
|
| 5 |
from app.db import get_db
|
| 6 |
from app.deps import get_current_user
|
| 7 |
from app.models import User, Hub, Document, Chunk
|
| 8 |
-
from app.schemas import HubResponse, HubDetail, HubCreate
|
| 9 |
from app.errors import AppError
|
| 10 |
from app import repo
|
| 11 |
from app.idempotency import (
|
|
@@ -14,12 +16,9 @@ from app.idempotency import (
|
|
| 14 |
)
|
| 15 |
from app.chunking import chunk_text
|
| 16 |
from app.services.embeddings import embed_texts
|
| 17 |
-
from app.services.gemini import hub_insights
|
| 18 |
from app.services.observability import log_ai_call
|
| 19 |
-
from app.
|
| 20 |
-
from app.services.gemini import answer_question
|
| 21 |
-
from app.services.embeddings import embed_texts
|
| 22 |
-
|
| 23 |
|
| 24 |
router = APIRouter(prefix="/hubs", tags=["hubs"])
|
| 25 |
|
|
@@ -80,21 +79,15 @@ def create_hub(
|
|
| 80 |
if not claim_key(db, user_id, key, req_hash, "/api/v1/hubs"):
|
| 81 |
return replay_or_conflict(db, user_id, key, req_hash)
|
| 82 |
|
| 83 |
-
# 1. Pre-check (Fast fail to save AI tokens)
|
| 84 |
if db.query(Hub).filter(Hub.owner_id == user_id, Hub.name == body.name).first():
|
| 85 |
release_claim(db, user_id, key)
|
| 86 |
raise AppError(409, "HUB_NAME_CONFLICT", "You already have a hub with this name.")
|
| 87 |
|
| 88 |
-
# 2. AI Summary (Wrapped in observability)
|
| 89 |
if body.content:
|
| 90 |
-
summary, questions = log_ai_call(
|
| 91 |
-
"hub_insights", user_id, None,
|
| 92 |
-
hub_insights, body.content
|
| 93 |
-
)
|
| 94 |
else:
|
| 95 |
summary, questions = None, []
|
| 96 |
|
| 97 |
-
# 3. Create Hub
|
| 98 |
new_hub = Hub(
|
| 99 |
owner_id=user_id, name=body.name, description=body.description,
|
| 100 |
ai_summary=summary, suggested_questions=questions
|
|
@@ -102,7 +95,6 @@ def create_hub(
|
|
| 102 |
db.add(new_hub)
|
| 103 |
|
| 104 |
try:
|
| 105 |
-
# Flush assigns the ID and triggers the UNIQUE constraint check
|
| 106 |
db.flush()
|
| 107 |
|
| 108 |
doc_count = 0
|
|
@@ -115,17 +107,12 @@ def create_hub(
|
|
| 115 |
mime_type="text/plain", size_bytes=len(body.content), checksum_sha256="0"*64, status="pending"
|
| 116 |
)
|
| 117 |
db.add(new_doc)
|
| 118 |
-
db.flush()
|
| 119 |
|
| 120 |
try:
|
| 121 |
chunks_data = chunk_text(body.content)
|
| 122 |
texts = [c[0] for c in chunks_data]
|
| 123 |
-
|
| 124 |
-
# Embeddings (Wrapped in observability)
|
| 125 |
-
embeddings = log_ai_call(
|
| 126 |
-
"embed_texts", user_id, new_hub.id,
|
| 127 |
-
embed_texts, texts
|
| 128 |
-
)
|
| 129 |
|
| 130 |
for i, ((text, tokens), emb) in enumerate(zip(chunks_data, embeddings)):
|
| 131 |
db.add(Chunk(
|
|
@@ -135,7 +122,6 @@ def create_hub(
|
|
| 135 |
chunk_count += 1
|
| 136 |
new_doc.status = "ready"
|
| 137 |
except Exception:
|
| 138 |
-
# Graceful degradation: AI failed, but we keep the hub
|
| 139 |
new_doc.status = "failed"
|
| 140 |
new_doc.error_message = "Embedding failed"
|
| 141 |
|
|
@@ -153,11 +139,8 @@ def create_hub(
|
|
| 153 |
except IntegrityError as e:
|
| 154 |
db.rollback()
|
| 155 |
release_claim(db, user_id, key)
|
| 156 |
-
|
| 157 |
-
# Check if the error was our specific unique constraint
|
| 158 |
if getattr(e.orig.diag, "constraint_name", None) == "uq_hub_name_per_owner":
|
| 159 |
raise AppError(409, "HUB_NAME_CONFLICT", "You already have a hub with this name.")
|
| 160 |
-
|
| 161 |
raise
|
| 162 |
|
| 163 |
@router.post("/{hub_id}/query", response_model=QueryResponse)
|
|
@@ -167,31 +150,15 @@ def query_hub(
|
|
| 167 |
current_user: User = Depends(get_current_user),
|
| 168 |
db: Session = Depends(get_db)
|
| 169 |
):
|
| 170 |
-
# 1. Verify hub ownership
|
| 171 |
hub = repo.get_hub_for_owner(db, hub_id, current_user.id)
|
| 172 |
if not hub:
|
| 173 |
raise AppError(404, "HUB_NOT_FOUND", "Hub not found.")
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
chunks = repo.
|
| 180 |
-
|
| 181 |
-
# 4. Check similarity threshold (pgvector cosine distance is 1 - similarity)
|
| 182 |
-
# If no chunks or the closest chunk isn't similar enough, return ungrounded.
|
| 183 |
-
valid_chunks = []
|
| 184 |
-
for chunk in chunks:
|
| 185 |
-
# Calculate similarity: 1 - cosine_distance
|
| 186 |
-
# We can't easily get the distance from the ORM object after the fact, so we calculate it.
|
| 187 |
-
# (Alternatively, we could add the distance to the select statement, but this is simpler).
|
| 188 |
-
# For now, we'll just assume the top-K are the best we have.
|
| 189 |
-
# (If we want to be strict, we'd do raw SQL to get the score. We'll use raw SQL here to be safe).
|
| 190 |
-
pass
|
| 191 |
-
|
| 192 |
-
# Let's use raw SQL for exact similarity scoring so we can apply the threshold
|
| 193 |
-
from sqlalchemy import text
|
| 194 |
-
from app.config import settings
|
| 195 |
|
| 196 |
raw_sql = text("""
|
| 197 |
SELECT id, text, chunk_index, document_id, 1 - (embedding <=> :q) as similarity
|
|
@@ -204,10 +171,9 @@ def query_hub(
|
|
| 204 |
|
| 205 |
results = db.execute(
|
| 206 |
raw_sql,
|
| 207 |
-
{"q": str(
|
| 208 |
).fetchall()
|
| 209 |
|
| 210 |
-
# Release the DB connection before the AI call
|
| 211 |
db.rollback()
|
| 212 |
|
| 213 |
if not results:
|
|
@@ -216,21 +182,15 @@ def query_hub(
|
|
| 216 |
evidence=None, model=settings.GEMINI_MODEL, grounded=False, sources=[], retrieved_chunks=0
|
| 217 |
)
|
| 218 |
|
| 219 |
-
# 5. Assemble context
|
| 220 |
context_texts = [row.text for row in results]
|
| 221 |
context = "\n\n---\n\n".join(context_texts)
|
| 222 |
|
| 223 |
-
# 6. Call Gemini
|
| 224 |
ai_result = answer_question(context, req.question)
|
| 225 |
|
| 226 |
-
# 7. Map sources
|
| 227 |
sources = []
|
| 228 |
if ai_result["evidence"]:
|
| 229 |
for row in results:
|
| 230 |
if ai_result["evidence"].lower() in row.text.lower():
|
| 231 |
-
# We need the filename, so we fetch the document (or join in the SQL)
|
| 232 |
-
# Since we didn't join, we'll just do a quick lookup for the filename
|
| 233 |
-
from app.models import Document
|
| 234 |
doc = db.query(Document).filter(Document.id == row.document_id).first()
|
| 235 |
if doc:
|
| 236 |
sources.append(QuerySource(
|
|
|
|
| 2 |
from fastapi import APIRouter, Depends, status, Query, Response
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
from sqlalchemy.exc import IntegrityError
|
| 5 |
+
from sqlalchemy import text
|
| 6 |
+
|
| 7 |
from app.db import get_db
|
| 8 |
from app.deps import get_current_user
|
| 9 |
from app.models import User, Hub, Document, Chunk
|
| 10 |
+
from app.schemas import HubResponse, HubDetail, HubCreate, QueryRequest, QueryResponse, QuerySource
|
| 11 |
from app.errors import AppError
|
| 12 |
from app import repo
|
| 13 |
from app.idempotency import (
|
|
|
|
| 16 |
)
|
| 17 |
from app.chunking import chunk_text
|
| 18 |
from app.services.embeddings import embed_texts
|
| 19 |
+
from app.services.gemini import hub_insights, answer_question, expand_query
|
| 20 |
from app.services.observability import log_ai_call
|
| 21 |
+
from app.config import settings
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
router = APIRouter(prefix="/hubs", tags=["hubs"])
|
| 24 |
|
|
|
|
| 79 |
if not claim_key(db, user_id, key, req_hash, "/api/v1/hubs"):
|
| 80 |
return replay_or_conflict(db, user_id, key, req_hash)
|
| 81 |
|
|
|
|
| 82 |
if db.query(Hub).filter(Hub.owner_id == user_id, Hub.name == body.name).first():
|
| 83 |
release_claim(db, user_id, key)
|
| 84 |
raise AppError(409, "HUB_NAME_CONFLICT", "You already have a hub with this name.")
|
| 85 |
|
|
|
|
| 86 |
if body.content:
|
| 87 |
+
summary, questions = log_ai_call("hub_insights", user_id, None, hub_insights, body.content)
|
|
|
|
|
|
|
|
|
|
| 88 |
else:
|
| 89 |
summary, questions = None, []
|
| 90 |
|
|
|
|
| 91 |
new_hub = Hub(
|
| 92 |
owner_id=user_id, name=body.name, description=body.description,
|
| 93 |
ai_summary=summary, suggested_questions=questions
|
|
|
|
| 95 |
db.add(new_hub)
|
| 96 |
|
| 97 |
try:
|
|
|
|
| 98 |
db.flush()
|
| 99 |
|
| 100 |
doc_count = 0
|
|
|
|
| 107 |
mime_type="text/plain", size_bytes=len(body.content), checksum_sha256="0"*64, status="pending"
|
| 108 |
)
|
| 109 |
db.add(new_doc)
|
| 110 |
+
db.flush()
|
| 111 |
|
| 112 |
try:
|
| 113 |
chunks_data = chunk_text(body.content)
|
| 114 |
texts = [c[0] for c in chunks_data]
|
| 115 |
+
embeddings = log_ai_call("embed_texts", user_id, new_hub.id, embed_texts, texts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
for i, ((text, tokens), emb) in enumerate(zip(chunks_data, embeddings)):
|
| 118 |
db.add(Chunk(
|
|
|
|
| 122 |
chunk_count += 1
|
| 123 |
new_doc.status = "ready"
|
| 124 |
except Exception:
|
|
|
|
| 125 |
new_doc.status = "failed"
|
| 126 |
new_doc.error_message = "Embedding failed"
|
| 127 |
|
|
|
|
| 139 |
except IntegrityError as e:
|
| 140 |
db.rollback()
|
| 141 |
release_claim(db, user_id, key)
|
|
|
|
|
|
|
| 142 |
if getattr(e.orig.diag, "constraint_name", None) == "uq_hub_name_per_owner":
|
| 143 |
raise AppError(409, "HUB_NAME_CONFLICT", "You already have a hub with this name.")
|
|
|
|
| 144 |
raise
|
| 145 |
|
| 146 |
@router.post("/{hub_id}/query", response_model=QueryResponse)
|
|
|
|
| 150 |
current_user: User = Depends(get_current_user),
|
| 151 |
db: Session = Depends(get_db)
|
| 152 |
):
|
|
|
|
| 153 |
hub = repo.get_hub_for_owner(db, hub_id, current_user.id)
|
| 154 |
if not hub:
|
| 155 |
raise AppError(404, "HUB_NOT_FOUND", "Hub not found.")
|
| 156 |
|
| 157 |
+
alt_queries = expand_query(req.question)
|
| 158 |
+
all_queries = [req.question] + alt_queries
|
| 159 |
+
embeddings = embed_texts(all_queries)
|
| 160 |
+
|
| 161 |
+
chunks = repo.retrieve_chunks_for_queries(db, current_user.id, hub_id, embeddings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
raw_sql = text("""
|
| 164 |
SELECT id, text, chunk_index, document_id, 1 - (embedding <=> :q) as similarity
|
|
|
|
| 171 |
|
| 172 |
results = db.execute(
|
| 173 |
raw_sql,
|
| 174 |
+
{"q": str(embeddings[0]), "u": current_user.id, "h": hub_id, "threshold": settings.RAG_MIN_SIMILARITY, "k": settings.RAG_TOP_K}
|
| 175 |
).fetchall()
|
| 176 |
|
|
|
|
| 177 |
db.rollback()
|
| 178 |
|
| 179 |
if not results:
|
|
|
|
| 182 |
evidence=None, model=settings.GEMINI_MODEL, grounded=False, sources=[], retrieved_chunks=0
|
| 183 |
)
|
| 184 |
|
|
|
|
| 185 |
context_texts = [row.text for row in results]
|
| 186 |
context = "\n\n---\n\n".join(context_texts)
|
| 187 |
|
|
|
|
| 188 |
ai_result = answer_question(context, req.question)
|
| 189 |
|
|
|
|
| 190 |
sources = []
|
| 191 |
if ai_result["evidence"]:
|
| 192 |
for row in results:
|
| 193 |
if ai_result["evidence"].lower() in row.text.lower():
|
|
|
|
|
|
|
|
|
|
| 194 |
doc = db.query(Document).filter(Document.id == row.document_id).first()
|
| 195 |
if doc:
|
| 196 |
sources.append(QuerySource(
|
app/services/gemini.py
CHANGED
|
@@ -84,4 +84,17 @@ def answer_question(content: str, question: str) -> dict:
|
|
| 84 |
"answer": str(d.get("answer", "")).strip(),
|
| 85 |
"evidence": evidence if verified else None,
|
| 86 |
"grounded": bool(d.get("grounded")) and verified
|
| 87 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
"answer": str(d.get("answer", "")).strip(),
|
| 85 |
"evidence": evidence if verified else None,
|
| 86 |
"grounded": bool(d.get("grounded")) and verified
|
| 87 |
+
}
|
| 88 |
+
EXPAND_SYSTEM = (
|
| 89 |
+
"You are a search assistant. Generate exactly 2 alternative versions of the user's question "
|
| 90 |
+
"to improve vector search retrieval. Reply with JSON only: "
|
| 91 |
+
'{"queries": ["alternative 1", "alternative 2"]}'
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def expand_query(question: str) -> list[str]:
|
| 95 |
+
"""Returns up to 2 alternative phrasings of the question. Fails gracefully."""
|
| 96 |
+
try:
|
| 97 |
+
d = _call(EXPAND_SYSTEM, question, retries=0)
|
| 98 |
+
return [str(q) for q in d.get("queries", [])][:2]
|
| 99 |
+
except Exception:
|
| 100 |
+
return [] # Fallback to just the original question
|
tests/test_rag.py
CHANGED
|
@@ -11,7 +11,6 @@ def test_empty_retrieval_returns_ungrounded(client, db_session):
|
|
| 11 |
hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": str(uuid4())}, json={"name": "Empty Hub"})
|
| 12 |
hub_id = hub_res.json()["id"]
|
| 13 |
|
| 14 |
-
# Query without any documents/chunks
|
| 15 |
with patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768]), \
|
| 16 |
patch("app.routers.hubs.answer_question") as mock_answer:
|
| 17 |
|
|
@@ -21,21 +20,17 @@ def test_empty_retrieval_returns_ungrounded(client, db_session):
|
|
| 21 |
assert res.json()["grounded"] is False
|
| 22 |
assert res.json()["evidence"] is None
|
| 23 |
assert res.json()["sources"] == []
|
| 24 |
-
# CRITICAL: Gemini was never called
|
| 25 |
mock_answer.assert_not_called()
|
| 26 |
|
| 27 |
def test_vector_search_is_scoped_to_owner(client, db_session):
|
| 28 |
-
# Create User A and User B
|
| 29 |
client.post("/api/v1/auth/signup", json={"email": "usera@rag.com", "password": "Password123"})
|
| 30 |
token_a = client.post("/api/v1/auth/login", json={"email": "usera@rag.com", "password": "Password123"}).json()["access_token"]
|
| 31 |
|
| 32 |
client.post("/api/v1/auth/signup", json={"email": "userb@rag.com", "password": "Password123"})
|
| 33 |
token_b = client.post("/api/v1/auth/login", json={"email": "userb@rag.com", "password": "Password123"}).json()["access_token"]
|
| 34 |
|
| 35 |
-
# User A creates a hub and a document (we mock the ingestion for speed)
|
| 36 |
hub_a = client.post("/api/v1/hubs", headers={"Authorization": f"Bearer {token_a}", "X-Idempotency-Key": str(uuid4())}, json={"name": "A's Secret Hub"}).json()
|
| 37 |
|
| 38 |
-
# Insert a chunk manually
|
| 39 |
from app.models import User, Hub
|
| 40 |
user_a = db_session.query(User).filter(User.email == "usera@rag.com").first()
|
| 41 |
hub = db_session.query(Hub).filter(Hub.id == hub_a["id"]).first()
|
|
@@ -48,8 +43,47 @@ def test_vector_search_is_scoped_to_owner(client, db_session):
|
|
| 48 |
db_session.add(chunk)
|
| 49 |
db_session.commit()
|
| 50 |
|
| 51 |
-
# User B tries to query User A's hub
|
| 52 |
with patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768]):
|
| 53 |
res = client.post(f"/api/v1/hubs/{hub.id}/query", headers={"Authorization": f"Bearer {token_b}"}, json={"question": "What is the secret?"})
|
| 54 |
assert res.status_code == 404
|
| 55 |
-
assert res.json()["error"]["code"] == "HUB_NOT_FOUND"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": str(uuid4())}, json={"name": "Empty Hub"})
|
| 12 |
hub_id = hub_res.json()["id"]
|
| 13 |
|
|
|
|
| 14 |
with patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768]), \
|
| 15 |
patch("app.routers.hubs.answer_question") as mock_answer:
|
| 16 |
|
|
|
|
| 20 |
assert res.json()["grounded"] is False
|
| 21 |
assert res.json()["evidence"] is None
|
| 22 |
assert res.json()["sources"] == []
|
|
|
|
| 23 |
mock_answer.assert_not_called()
|
| 24 |
|
| 25 |
def test_vector_search_is_scoped_to_owner(client, db_session):
|
|
|
|
| 26 |
client.post("/api/v1/auth/signup", json={"email": "usera@rag.com", "password": "Password123"})
|
| 27 |
token_a = client.post("/api/v1/auth/login", json={"email": "usera@rag.com", "password": "Password123"}).json()["access_token"]
|
| 28 |
|
| 29 |
client.post("/api/v1/auth/signup", json={"email": "userb@rag.com", "password": "Password123"})
|
| 30 |
token_b = client.post("/api/v1/auth/login", json={"email": "userb@rag.com", "password": "Password123"}).json()["access_token"]
|
| 31 |
|
|
|
|
| 32 |
hub_a = client.post("/api/v1/hubs", headers={"Authorization": f"Bearer {token_a}", "X-Idempotency-Key": str(uuid4())}, json={"name": "A's Secret Hub"}).json()
|
| 33 |
|
|
|
|
| 34 |
from app.models import User, Hub
|
| 35 |
user_a = db_session.query(User).filter(User.email == "usera@rag.com").first()
|
| 36 |
hub = db_session.query(Hub).filter(Hub.id == hub_a["id"]).first()
|
|
|
|
| 43 |
db_session.add(chunk)
|
| 44 |
db_session.commit()
|
| 45 |
|
|
|
|
| 46 |
with patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768]):
|
| 47 |
res = client.post(f"/api/v1/hubs/{hub.id}/query", headers={"Authorization": f"Bearer {token_b}"}, json={"question": "What is the secret?"})
|
| 48 |
assert res.status_code == 404
|
| 49 |
+
assert res.json()["error"]["code"] == "HUB_NOT_FOUND"
|
| 50 |
+
|
| 51 |
+
def test_expansion_fallback_when_gemini_fails(client, db_session):
|
| 52 |
+
client.post("/api/v1/auth/signup", json={"email": "fallback@test.com", "password": "Password123"})
|
| 53 |
+
token = client.post("/api/v1/auth/login", json={"email": "fallback@test.com", "password": "Password123"}).json()["access_token"]
|
| 54 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 55 |
+
hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": str(uuid4())}, json={"name": "Fallback Hub"})
|
| 56 |
+
hub_id = hub_res.json()["id"]
|
| 57 |
+
|
| 58 |
+
with patch("app.services.gemini._call", side_effect=Exception("AI Down")), \
|
| 59 |
+
patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768]), \
|
| 60 |
+
patch("app.routers.hubs.answer_question", return_value={"answer": "Test", "evidence": None, "grounded": False}):
|
| 61 |
+
|
| 62 |
+
res = client.post(f"/api/v1/hubs/{hub_id}/query", headers=headers, json={"question": "Test question?"})
|
| 63 |
+
assert res.status_code == 200
|
| 64 |
+
|
| 65 |
+
def test_multihop_question_returns_two_sources(client, db_session):
|
| 66 |
+
client.post("/api/v1/auth/signup", json={"email": "multihop@test.com", "password": "Password123"})
|
| 67 |
+
token = client.post("/api/v1/auth/login", json={"email": "multihop@test.com", "password": "Password123"}).json()["access_token"]
|
| 68 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 69 |
+
hub_res = client.post("/api/v1/hubs", headers={**headers, "X-Idempotency-Key": str(uuid4())}, json={"name": "MultiHop Hub"})
|
| 70 |
+
hub_id = hub_res.json()["id"]
|
| 71 |
+
|
| 72 |
+
from app.models import User, Hub, Document, Chunk
|
| 73 |
+
user = db_session.query(User).filter(User.email == "multihop@test.com").first()
|
| 74 |
+
hub = db_session.query(Hub).filter(Hub.id == hub_id).first()
|
| 75 |
+
doc = Document(id=uuid4(), hub_id=hub.id, owner_id=user.id, origin="upload", filename="test.txt", mime_type="text/plain", size_bytes=10, checksum_sha256="0"*64, status="ready")
|
| 76 |
+
db_session.add(doc)
|
| 77 |
+
db_session.commit()
|
| 78 |
+
|
| 79 |
+
chunk1 = Chunk(id=uuid4(), document_id=doc.id, hub_id=hub.id, owner_id=user.id, chunk_index=0, text="Chunk one", token_count=5, embedding=[0.1]*768)
|
| 80 |
+
chunk2 = Chunk(id=uuid4(), document_id=doc.id, hub_id=hub.id, owner_id=user.id, chunk_index=1, text="Chunk two", token_count=5, embedding=[0.2]*768)
|
| 81 |
+
db_session.add_all([chunk1, chunk2])
|
| 82 |
+
db_session.commit()
|
| 83 |
+
|
| 84 |
+
with patch("app.routers.hubs.expand_query", return_value=["alt1", "alt2"]), \
|
| 85 |
+
patch("app.routers.hubs.embed_texts", return_value=[[0.1]*768, [0.1]*768, [0.2]*768]), \
|
| 86 |
+
patch("app.routers.hubs.answer_question", return_value={"answer": "Combined", "evidence": "Chunk", "grounded": True}):
|
| 87 |
+
|
| 88 |
+
res = client.post(f"/api/v1/hubs/{hub_id}/query", headers=headers, json={"question": "Question spanning two chunks?"})
|
| 89 |
+
assert res.status_code == 200
|