Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions Dockerfile.vercel
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Vercel container build (Fluid compute). Same as Dockerfile but WITHOUT the data/ folder:
# the deployed backend only queries Qdrant + Neo4j, it never reads the JSON datasets at
# runtime (config.py just warns if they're absent). This keeps the image small.
# The server listens on $PORT (server.py reads it), which Vercel injects.
FROM python:3.13-slim

WORKDIR /app

# Build deps for biopython (a transitive dependency; only used by data-collection scripts,
# but present in the locked environment).
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
build-essential \
&& rm -rf /var/lib/apt/lists/*

RUN pip install uv

# Dependency layer (cached)
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --no-install-project

# Source only — no data/
COPY src/ ./src/
RUN uv sync --frozen --no-dev

EXPOSE 8765

CMD ["uv", "run", "python", "-m", "biomedical_graphrag.api.server"]
21 changes: 21 additions & 0 deletions Dockerfile.vercel.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Build-context ignore that applies ONLY to Dockerfile.vercel (BuildKit convention).
# Keeps the Vercel build lean; the serving backend needs none of these.
data/
.git
.github/
.venv
tests/
static/
__pycache__
**/__pycache__
*.pyc
.env
.env.*
.mypy_cache/
.ruff_cache/
.pytest_cache/
frontend/
*.backup
*_original.json
*_subset_*.json
.DS_Store
44 changes: 23 additions & 21 deletions src/biomedical_graphrag/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
"""

import asyncio
import os
from contextlib import asynccontextmanager
from typing import Any

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from openai import AuthenticationError
from pydantic import BaseModel, Field

from biomedical_graphrag.utils.logger_util import setup_logging
Expand Down Expand Up @@ -69,12 +71,14 @@ async def _preload_services() -> None:
lifespan=lifespan,
)

# CORS middleware
# CORS middleware — origin-locked via env (default = local frontend).
# Set CORS_ORIGINS to the deployed frontend domain in production (comma-separated for multiple).
_allowed_origins = [o.strip() for o in os.environ.get("CORS_ORIGINS", "http://localhost:3000").split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_origins=_allowed_origins,
allow_credentials=False,
allow_methods=["POST", "GET", "OPTIONS"],
allow_headers=["*"],
)

Expand All @@ -86,6 +90,7 @@ class SearchRequest(BaseModel):
query: str = Field(..., description="The search query")
limit: int = Field(default=5, ge=1, le=5, description="Maximum number of results (vector search)")
mode: str = Field(default="graphrag", description="Search mode: graphrag (Qdrant + Neo4j context engineering)")
openai_api_key: str = Field(..., min_length=20, description="Caller-supplied OpenAI API key (used transiently, never stored)")


class TraceStep(BaseModel):
Expand Down Expand Up @@ -136,24 +141,16 @@ async def get_neo4j_stats() -> Neo4jStatsResponse:
neo4j = _neo4j_query_class()

try:
# Get node counts by label
# Node counts by label. Plain MATCH (no db.labels()/APOC procedures) so it works
# with a read-only Neo4j role, which is forbidden from executing procedures.
node_labels_result = neo4j.query("""
CALL db.labels() YIELD label
CALL apoc.cypher.run('MATCH (n:`' + label + '`) RETURN count(n) as count', {}) YIELD value
RETURN label, value.count as count
MATCH (n)
WITH labels(n) as nodeLabels
UNWIND nodeLabels as label
RETURN label, count(*) as count
ORDER BY count DESC
""")

# Fallback if APOC not available
if not node_labels_result:
node_labels_result = neo4j.query("""
MATCH (n)
WITH labels(n) as nodeLabels
UNWIND nodeLabels as label
RETURN label, count(*) as count
ORDER BY count DESC
""")

# Get relationship counts by type
rel_types_result = neo4j.query("""
MATCH ()-[r]->()
Expand Down Expand Up @@ -181,7 +178,7 @@ async def get_neo4j_stats() -> Neo4jStatsResponse:
neo4j.close()

except Exception as e:
logger.error(f"Error fetching Neo4j stats: {e}", exc_info=True)
logger.opt(exception=True).error("Error fetching Neo4j stats: {}", e)
raise HTTPException(status_code=500, detail="Failed to fetch Neo4j stats")


Expand All @@ -196,7 +193,9 @@ async def search(request: SearchRequest) -> SearchResponse:
_load_services()

# Run the async hybrid search (returns GraphRAGResult with trace)
graphrag_result = await _run_tools_sequence(request.query, limit=request.limit)
graphrag_result = await _run_tools_sequence(
request.query, limit=request.limit, openai_api_key=request.openai_api_key
)

# Build trace from tool executions (including arguments)
trace = [TraceStep(name=t.name, arguments=t.arguments, result_count=t.result_count, results=t.results) for t in graphrag_result.trace]
Expand Down Expand Up @@ -227,8 +226,11 @@ async def search(request: SearchRequest) -> SearchResponse:
metadata={"query": request.query},
)

except AuthenticationError:
# The caller-supplied OpenAI key was rejected — let the frontend reopen the key gate.
raise HTTPException(status_code=401, detail="openai_key_rejected")
except Exception as e:
logger.error(f"Search error: {e}", exc_info=True)
logger.opt(exception=True).error("Search error: {}", e)
raise HTTPException(status_code=500, detail="Search failed")


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@
class AsyncQdrantQuery:
"""Handles querying Qdrant vector search engine for natural language questions (async)."""

def __init__(self) -> None:
def __init__(self, openai_api_key: str | None = None) -> None:
"""
Initialize the async Qdrant client with connection parameters.

Args:
openai_api_key: Per-request OpenAI key, threaded to the vector store for
query-time embeddings (direct or via Qdrant Cloud Inference).
"""
self.qdrant_client = AsyncQdrantVectorStore()
self.qdrant_client = AsyncQdrantVectorStore(openai_api_key=openai_api_key)

async def close(self) -> None:
"""Close the async Qdrant client."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@
logger = setup_logging()


openai_client = OpenAI(api_key=settings.openai.api_key.get_secret_value())


def _extract_qdrant_context(qdrant_results: list[dict]) -> dict[str, list[str]]: #Is it a reverse engineering approach of Paper class?
"""Extract structured entities from Qdrant results for Neo4j tool pre-fill."""
pmids: list[str] = []
Expand Down Expand Up @@ -116,16 +113,20 @@ class QdrantSearchResult:
# --------------------------------------------------------------------
# Phase 1 — Qdrant tools selection + execution
# --------------------------------------------------------------------
async def run_qdrant_vector_search(question: str, limit: int = 5) -> QdrantSearchResult:
async def run_qdrant_vector_search(
question: str, limit: int, openai_client: OpenAI, openai_api_key: str
) -> QdrantSearchResult:
"""Run Qdrant vector search.
Args:
question: The user question.
limit: Maximum number of results to return.
openai_client: Per-request OpenAI client (for LLM tool selection).
openai_api_key: Per-request OpenAI key (for query-time embeddings).
Returns:
QdrantSearchResult with results and tool execution info.
"""
prompt = QDRANT_PROMPT.format(question=question)
qdrant = AsyncQdrantQuery()
qdrant = AsyncQdrantQuery(openai_api_key=openai_api_key)
tool_name = "unknown"
tool_args: dict[str, Any] = {}

Expand Down Expand Up @@ -174,7 +175,7 @@ class Neo4jEnrichmentResult:
# --------------------------------------------------------------------
# Phase 2 — Neo4j enrichment tools selection + execution
# --------------------------------------------------------------------
def run_graph_enrichment(question: str, qdrant_results: list[dict]) -> Neo4jEnrichmentResult:
def run_graph_enrichment(question: str, qdrant_results: list[dict], openai_client: OpenAI) -> Neo4jEnrichmentResult:
"""Run graph enrichment.

Args:
Expand Down Expand Up @@ -242,7 +243,7 @@ def run_graph_enrichment(question: str, qdrant_results: list[dict]) -> Neo4jEnri
results[name] = result
count = len(result) if isinstance(result, list) else None
except Exception as e:
logger.error(f"Neo4j tool {name} failed: {e}", exc_info=True)
logger.opt(exception=True).error("Neo4j tool {} failed: {}", name, e)
results[name] = f"Tool '{name}' encountered an error and returned no results."
result = None
count = 0
Expand All @@ -254,16 +255,16 @@ def run_graph_enrichment(question: str, qdrant_results: list[dict]) -> Neo4jEnri
neo4j.close()


async def run_graph_enrichment_async(question: str, qdrant_results: list[dict]) -> Neo4jEnrichmentResult:
async def run_graph_enrichment_async(question: str, qdrant_results: list[dict], openai_client: OpenAI) -> Neo4jEnrichmentResult:
"""Async wrapper for run_graph_enrichment to avoid blocking the event loop."""
return await asyncio.to_thread(run_graph_enrichment, question, qdrant_results)
return await asyncio.to_thread(run_graph_enrichment, question, qdrant_results, openai_client)


# --------------------------------------------------------------------
# Phase 3 — Fusion summarization
# --------------------------------------------------------------------
def summarize_fused_results(
question: str, qdrant_results: list[dict], neo4j_results: dict[str, Any], limit: int = 5
question: str, qdrant_results: list[dict], neo4j_results: dict[str, Any], openai_client: OpenAI, limit: int = 5
) -> str:
"""Fuse semantic and graph evidence into one final biomedical summary.

Expand All @@ -287,11 +288,11 @@ def summarize_fused_results(


async def summarize_fused_results_async(
question: str, qdrant_results: list[dict], neo4j_results: dict[str, Any], limit: int = 5
question: str, qdrant_results: list[dict], neo4j_results: dict[str, Any], openai_client: OpenAI, limit: int = 5
) -> str:
"""Async wrapper for summarize_fused_results to avoid blocking the event loop."""
return await asyncio.to_thread(
summarize_fused_results, question, qdrant_results, neo4j_results, limit
summarize_fused_results, question, qdrant_results, neo4j_results, openai_client, limit
)

@dataclass
Expand All @@ -306,30 +307,36 @@ class GraphRAGResult:
# --------------------------------------------------------------------
# Unified helper
# --------------------------------------------------------------------
async def run_tools_sequence_and_summarize(question: str, limit: int = 5) -> GraphRAGResult:
async def run_tools_sequence_and_summarize(
question: str, limit: int = 5, *, openai_api_key: str
) -> GraphRAGResult:
"""Run graph enrichment and summarize the results.

Args:
question: The user question.
limit: Number of papers to retrieve.
openai_api_key: Per-request OpenAI key supplied by the demo user.

Returns:
GraphRAGResult containing summary, results, and trace.
"""
openai_client = OpenAI(api_key=openai_api_key)
trace: list[ToolExecution] = []

# Phase 1: Qdrant vector search
qdrant_result = await run_qdrant_vector_search(question, limit=limit)
qdrant_result = await run_qdrant_vector_search(question, limit, openai_client, openai_api_key)
trace.append(qdrant_result.tool)

# Phase 2: Neo4j enrichment
neo4j_result = await run_graph_enrichment_async(question, qdrant_result.results)
neo4j_result = await run_graph_enrichment_async(question, qdrant_result.results, openai_client)
trace.extend(neo4j_result.tools)

# Phase 3: Summarization
summary = await summarize_fused_results_async(
question, qdrant_result.results, neo4j_result.results, limit=limit
question, qdrant_result.results, neo4j_result.results, openai_client, limit=limit
)
trace.append(ToolExecution(name="summarize"))
# Note: summarization is a phase, not a retrieval/graph tool, so it is intentionally
# NOT added to the trace — it should never be counted as a "tool executed".

return GraphRAGResult(
summary=summary,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ class AsyncQdrantVectorStore:
Async Qdrant client for managing collections and points.
"""

def __init__(self) -> None:
def __init__(self, openai_api_key: str | None = None) -> None:
"""
Initialize the async Qdrant client with connection parameters.

Args:
openai_api_key: Per-request OpenAI key (from the demo user). Falls back to
the configured key (used for local dev and the one-time ingest).
"""
self.url = settings.qdrant.url
self.api_key = settings.qdrant.api_key
Expand All @@ -31,7 +35,8 @@ def __init__(self) -> None:
self.estimate_bm25_avg_len_on_x_docs = settings.qdrant.estimate_bm25_avg_len_on_x_docs
self.cloud_inference = settings.qdrant.cloud_inference

self.openai_client = AsyncOpenAI(api_key=settings.openai.api_key.get_secret_value())
self._openai_api_key = openai_api_key or settings.openai.api_key.get_secret_value()
self.openai_client = AsyncOpenAI(api_key=self._openai_api_key)

self.client = AsyncQdrantClient(
url=self.url,
Expand Down Expand Up @@ -100,7 +105,7 @@ async def _get_openai_vectors(self, text: str, dimensions: int) -> list[float]:
)
return embedding.data[0].embedding
except Exception as e:
logger.error(f"❌ Failed to create embedding: {e}")
logger.opt(exception=True).error("❌ Failed to create embedding: {}", e)
raise

def _define_openai_vectors(self, text: str, mrl_dimensions: int = 1536) -> models.Document:
Expand All @@ -118,7 +123,7 @@ def _define_openai_vectors(self, text: str, mrl_dimensions: int = 1536) -> model
text=text,
model=f"openai/{settings.qdrant.embedding_model}",
options={
"openai-api-key": settings.openai.api_key.get_secret_value(),
"openai-api-key": self._openai_api_key,
"mrl": mrl_dimensions,
},
)
Expand Down
17 changes: 13 additions & 4 deletions tests/unit/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,29 +25,38 @@ def test_health_check_returns_healthy(self, client: TestClient) -> None:
assert response.json() == {"status": "healthy"}


_FAKE_KEY = "sk-test-key-1234567890" # >= 20 chars to satisfy min_length


class TestSearchRequestModel:
def test_default_values(self) -> None:
req = SearchRequest(query="test")
req = SearchRequest(query="test", openai_api_key=_FAKE_KEY)
assert req.query == "test"
assert req.limit == 5
assert req.mode == "graphrag"

def test_custom_values(self) -> None:
req = SearchRequest(query="BRCA1", limit=3, mode="dense")
req = SearchRequest(query="BRCA1", limit=3, mode="dense", openai_api_key=_FAKE_KEY)
assert req.limit == 3
assert req.mode == "dense"

def test_openai_api_key_required(self) -> None:
from pydantic import ValidationError

with pytest.raises(ValidationError):
SearchRequest(query="test")

def test_limit_validation_max(self) -> None:
from pydantic import ValidationError

with pytest.raises(ValidationError):
SearchRequest(query="test", limit=10)
SearchRequest(query="test", limit=10, openai_api_key=_FAKE_KEY)

def test_limit_validation_min(self) -> None:
from pydantic import ValidationError

with pytest.raises(ValidationError):
SearchRequest(query="test", limit=0)
SearchRequest(query="test", limit=0, openai_api_key=_FAKE_KEY)


class TestResponseModels:
Expand Down
Loading
Loading