From 0c6b6b2c2ceb8ce5326a5a02d4ac60c5c67e341f Mon Sep 17 00:00:00 2001 From: Evgeniya Sukhodolskaya Date: Fri, 3 Jul 2026 12:46:15 +0200 Subject: [PATCH 1/4] fix: origin-locked CORS, read-only-compatible /neo4j/stats, safe error logging - CORS now driven by CORS_ORIGINS env (default localhost:3000); drop invalid allow_origins=* + allow_credentials=True combo. - /api/neo4j/stats uses plain MATCH label counts instead of db.labels()/APOC, which a read-only Neo4j role is forbidden from executing. - Replace logger.error(f"...{e}...") with loguru-safe logging so Neo4j error messages containing { } braces no longer crash the handler and mask the real error. Co-Authored-By: Claude Opus 4.8 --- src/biomedical_graphrag/api/server.py | 35 ++++++++----------- .../services/hybrid_service/tool_calling.py | 2 +- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/biomedical_graphrag/api/server.py b/src/biomedical_graphrag/api/server.py index 92ec3ba..1ebc896 100644 --- a/src/biomedical_graphrag/api/server.py +++ b/src/biomedical_graphrag/api/server.py @@ -8,6 +8,7 @@ """ import asyncio +import os from contextlib import asynccontextmanager from typing import Any @@ -69,12 +70,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=["*"], ) @@ -136,24 +139,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]->() @@ -181,7 +176,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") @@ -228,7 +223,7 @@ async def search(request: SearchRequest) -> SearchResponse: ) 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") diff --git a/src/biomedical_graphrag/application/services/hybrid_service/tool_calling.py b/src/biomedical_graphrag/application/services/hybrid_service/tool_calling.py index a42197d..c5d140b 100644 --- a/src/biomedical_graphrag/application/services/hybrid_service/tool_calling.py +++ b/src/biomedical_graphrag/application/services/hybrid_service/tool_calling.py @@ -242,7 +242,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 From c8abf33b1e19af5cf29d19c3b542bbb48eed26f9 Mon Sep 17 00:00:00 2001 From: Evgeniya Sukhodolskaya Date: Fri, 3 Jul 2026 13:07:36 +0200 Subject: [PATCH 2/4] feat: per-request user-supplied OpenAI key Demo users supply their own OpenAI key so the host never pays for inference. - SearchRequest.openai_api_key (required); threaded through run_tools_sequence_and_summarize -> tool_calling / AsyncQdrantQuery / AsyncQdrantVectorStore. Removes the module-global OpenAI clients. - Key used for both the LLM agent and query-time embeddings (direct or via Qdrant Cloud Inference header). Never stored or logged. - Rejected keys surface as HTTP 401 detail=openai_key_rejected so the frontend can reopen its key gate. - Tests: key threading + openai_api_key required. Co-Authored-By: Claude Opus 4.8 --- src/biomedical_graphrag/api/server.py | 9 ++++- .../services/hybrid_service/qdrant_query.py | 8 +++-- .../services/hybrid_service/tool_calling.py | 36 +++++++++++-------- .../qdrant_engine/qdrant_vectorstore.py | 13 ++++--- tests/unit/test_api_server.py | 17 ++++++--- tests/unit/test_key_threading.py | 13 +++++++ 6 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 tests/unit/test_key_threading.py diff --git a/src/biomedical_graphrag/api/server.py b/src/biomedical_graphrag/api/server.py index 1ebc896..170273f 100644 --- a/src/biomedical_graphrag/api/server.py +++ b/src/biomedical_graphrag/api/server.py @@ -14,6 +14,7 @@ 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 @@ -89,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): @@ -191,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] @@ -222,6 +226,9 @@ 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.opt(exception=True).error("Search error: {}", e) raise HTTPException(status_code=500, detail="Search failed") diff --git a/src/biomedical_graphrag/application/services/hybrid_service/qdrant_query.py b/src/biomedical_graphrag/application/services/hybrid_service/qdrant_query.py index aeeb47e..19b498d 100644 --- a/src/biomedical_graphrag/application/services/hybrid_service/qdrant_query.py +++ b/src/biomedical_graphrag/application/services/hybrid_service/qdrant_query.py @@ -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.""" diff --git a/src/biomedical_graphrag/application/services/hybrid_service/tool_calling.py b/src/biomedical_graphrag/application/services/hybrid_service/tool_calling.py index c5d140b..bac0066 100644 --- a/src/biomedical_graphrag/application/services/hybrid_service/tool_calling.py +++ b/src/biomedical_graphrag/application/services/hybrid_service/tool_calling.py @@ -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] = [] @@ -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] = {} @@ -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: @@ -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. @@ -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 @@ -306,28 +307,33 @@ 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")) diff --git a/src/biomedical_graphrag/infrastructure/qdrant_engine/qdrant_vectorstore.py b/src/biomedical_graphrag/infrastructure/qdrant_engine/qdrant_vectorstore.py index de3955d..a1e98d9 100644 --- a/src/biomedical_graphrag/infrastructure/qdrant_engine/qdrant_vectorstore.py +++ b/src/biomedical_graphrag/infrastructure/qdrant_engine/qdrant_vectorstore.py @@ -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 @@ -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, @@ -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: @@ -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, }, ) diff --git a/tests/unit/test_api_server.py b/tests/unit/test_api_server.py index 7d49897..0053927 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -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: diff --git a/tests/unit/test_key_threading.py b/tests/unit/test_key_threading.py new file mode 100644 index 0000000..2873c0f --- /dev/null +++ b/tests/unit/test_key_threading.py @@ -0,0 +1,13 @@ +from biomedical_graphrag.application.services.hybrid_service.qdrant_query import AsyncQdrantQuery +from biomedical_graphrag.infrastructure.qdrant_engine.qdrant_vectorstore import AsyncQdrantVectorStore + + +def test_vectorstore_uses_passed_key(): + vs = AsyncQdrantVectorStore(openai_api_key="sk-explicit-test") + assert vs.openai_client.api_key == "sk-explicit-test" + assert vs._openai_api_key == "sk-explicit-test" + + +def test_query_threads_key_to_vectorstore(): + q = AsyncQdrantQuery(openai_api_key="sk-through-query") + assert q.qdrant_client.openai_client.api_key == "sk-through-query" From 4a175ac9fe3e9a09956fc85bea0dd87c08f4c227 Mon Sep 17 00:00:00 2001 From: Evgeniya Sukhodolskaya Date: Fri, 3 Jul 2026 16:15:00 +0200 Subject: [PATCH 3/4] fix: don't count summarization as a tool Summarization is a phase, not a retrieval/graph tool. Drop the ToolExecution(name='summarize') from the trace so it is never counted as a 'tool executed' (was causing a 4-vs-3 mismatch between the trace panel and the chat header). Co-Authored-By: Claude Opus 4.8 --- .../application/services/hybrid_service/tool_calling.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/biomedical_graphrag/application/services/hybrid_service/tool_calling.py b/src/biomedical_graphrag/application/services/hybrid_service/tool_calling.py index bac0066..0fd4056 100644 --- a/src/biomedical_graphrag/application/services/hybrid_service/tool_calling.py +++ b/src/biomedical_graphrag/application/services/hybrid_service/tool_calling.py @@ -335,7 +335,8 @@ async def run_tools_sequence_and_summarize( summary = await summarize_fused_results_async( 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, From c246eb2cd9aa161af43381b25dab31e56a67dfdd Mon Sep 17 00:00:00 2001 From: Evgeniya Sukhodolskaya Date: Fri, 3 Jul 2026 17:01:42 +0200 Subject: [PATCH 4/4] chore: add Dockerfile.vercel for Vercel container deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy of Dockerfile without COPY data/ — the serving backend only queries Qdrant + Neo4j and never reads the JSON datasets at runtime (config warns, doesn't fail). Reads $PORT (Vercel injects it). A Dockerfile.vercel.dockerignore keeps the build context lean without affecting the legacy Dockerfile. Validated: image builds and the container boots + serves /health. Co-Authored-By: Claude Opus 4.8 --- Dockerfile.vercel | 28 ++++++++++++++++++++++++++++ Dockerfile.vercel.dockerignore | 21 +++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 Dockerfile.vercel create mode 100644 Dockerfile.vercel.dockerignore diff --git a/Dockerfile.vercel b/Dockerfile.vercel new file mode 100644 index 0000000..6cd2999 --- /dev/null +++ b/Dockerfile.vercel @@ -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"] diff --git a/Dockerfile.vercel.dockerignore b/Dockerfile.vercel.dockerignore new file mode 100644 index 0000000..90d6681 --- /dev/null +++ b/Dockerfile.vercel.dockerignore @@ -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