diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fd2a86..4ce70e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: run: uv sync - name: Run full test suite - run: uv run pytest tests/ -v + run: uv run pytest tests/ -v --reruns 2 --reruns-delay 10 build: runs-on: ubuntu-latest diff --git a/docs/API.md b/docs/API.md index 22a9e23..1775f83 100644 --- a/docs/API.md +++ b/docs/API.md @@ -151,26 +151,53 @@ asyncio.run(main()) | `literature` | `biocontext literature [-l INT]` | Search supporting peer-reviewed publications from Europe PMC / PubMed. | | `paper` | `biocontext paper ` | Fetch detailed paper metadata and abstract by PMID, PMCID, or DOI. | | `graph` | `biocontext graph [--taxon INT]` | Construct connected biological Knowledge Subgraph (PRD-21). | -| `traverse` | `biocontext traverse [--max-hops INT]` | Find and explain deterministic multi-hop reasoning paths (PRD-22). | -| `api` | `biocontext api [--host HOST] [--port PORT]` | Start standalone BioContext REST API v1 server (PRD-18). | +| `traverse` | `biocontext traverse [--target-type TYPE] [--max-hops INT] [--min-score FLOAT]` | Find and explain deterministic multi-hop reasoning paths (PRD-22). | +| `api` | `biocontext api [--host HOST] [--port PORT] [--cors-origin ORIGIN]` | Start standalone BioContext REST API v1 server (PRD-18). | | `mouse` | `biocontext mouse ` | Lookup mouse gene model via MGI. | | `cache` | `biocontext cache [stats|clear]` | Inspect or clear SQLite cache. | -| `serve` | `biocontext serve` | Start stdio MCP server for AI clients. | +| `serve` | `biocontext serve` (alias `server`) | Start stdio MCP server for AI clients. | --- ## 5. REST API v1 Endpoints Reference -Start the server using: `biocontext api --port 8000` +Start the server using: `biocontext api --port 8000 [--cors-origin "http://localhost:3000"]` | Method | Endpoint | Description | Example | | :--- | :--- | :--- | :--- | | `GET` | `/health` | Service health status and version info | `curl http://127.0.0.1:8000/health` | | `GET` | `/v1/gene/{symbol}` | Resolve gene symbol to canonical entity | `curl http://127.0.0.1:8000/v1/gene/TP53` | | `GET` | `/v1/protein/{accession}` | Fetch UniProt protein model | `curl http://127.0.0.1:8000/v1/protein/P04637` | -| `GET` | `/v1/disease/{query}` | Search MONDO disease ontology | `curl http://127.0.0.1:8000/v1/disease/cancer` | +| `GET` | `/v1/disease/{query}?limit=5` | Search MONDO disease ontology (limit 1-25) | `curl "http://127.0.0.1:8000/v1/disease/cancer?limit=5"` | | `GET` | `/v1/graph/{symbol}` | Construct interconnected Knowledge Subgraph | `curl http://127.0.0.1:8000/v1/graph/TP53` | -| `GET` | `/v1/traverse?source=X&target=Y` | Deterministic multi-hop path reasoning | `curl "http://127.0.0.1:8000/v1/traverse?source=TP53&target=cancer"` | +| `GET` | `/v1/traverse?source=X&target=Y&target_type=disease` | Deterministic multi-hop path reasoning | `curl "http://127.0.0.1:8000/v1/traverse?source=TP53&target=cancer&target_type=disease&max_hops=3"` | + +### Query Parameters for `/v1/traverse` +- `source` (required, str): Seed gene symbol or query. +- `target` (required, str): Target name or query. +- `target_type` (optional, str): Restrict matching target nodes by `NodeType` (`gene`, `protein`, `disease`, `pathway`, `go_term`, `publication`). Defaults to all node types except publications. +- `max_hops` (optional, int, default `3`, range `1..6`): Maximum allowed edges along the path. +- `min_score` (optional, float, default `0.0`, range `0.0..1.0`): Filter paths by cumulative score (unscored paths are retained when `min_score == 0.0`). + +--- + +## 6. Graph Semantics & Known Limitations + +### Edge Scoring Semantics +- **Curated / Definitional Edges** (`ENCODES`, `ANNOTATED_WITH`, `PARTICIPATES_IN`) have **no artificial confidence** (`weight=None`, confidence unscored). +- **Evidence-Based Associations** (`ASSOCIATED_WITH` via Open Targets Platform) retain empirical association weights provided by the source database. +- **Literature Co-occurrences** (`MENTIONED_IN`) carry `relation_basis="keyword_search_hit"` with unscored weights (`weight=None`). +- **Cumulative Traversal Score**: Calculated as the product of source-provided edge weights. Unscored edges act as neutral factors. If no edge on a path is scored, the cumulative score is `None`. + +### Observability & Gaps +- `SubGraphContext.layer_status` records the execution status of each subgraph layer (`ok`, `empty`, `skipped: `, or `failed: `). +- `SubGraphContext.warnings` and `TraversalResult.warnings` transparently list any upstream adapter failures or unresolved seeds. + +### Documented Limitations +- **Traversal Direction**: Currently explores outgoing edges from the seed entity. +- **Seed Types**: Subgraph generation seeds from gene symbols (with protein expansion); arbitrary disease-seeded graph construction is planned for future versions. +- **Network Dependency**: Live tests rely on upstream biomedical APIs; transient 5xx responses from external services are handled via retry logic. + diff --git a/pyproject.toml b/pyproject.toml index a7cad07..8936748 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "biocontext-mcp" -version = "1.0.0" +version = "1.0.1" description = "Authoritative Biological Entity Resolution & Contextual Intelligence Framework" readme = "README.md" authors = [ @@ -18,7 +18,7 @@ keywords = [ "uniprot", ] classifiers = [ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering :: Bio-Informatics", "License :: OSI Approved :: Apache Software License", @@ -32,8 +32,13 @@ dependencies = [ "httpx>=0.28.1", "mcp>=2.2.0", "pydantic>=2.13.5", +] + +[dependency-groups] +dev = [ "pytest>=9.1.1", "pytest-asyncio>=1.4.0", + "pytest-rerunfailures>=14.0", ] [project.scripts] diff --git a/src/biocontext/__init__.py b/src/biocontext/__init__.py index 7aad049..92bb462 100644 --- a/src/biocontext/__init__.py +++ b/src/biocontext/__init__.py @@ -3,7 +3,7 @@ from typing import List, Optional from biocontext.graph.builder import KnowledgeGraphBuilder -from biocontext.graph.models import SubGraphContext, TraversalResult +from biocontext.graph.models import NodeType, SubGraphContext, TraversalResult from biocontext.graph.traversal import TraversalEngine from biocontext.resolver import EntityResolver from biocontext.schemas import ( @@ -143,13 +143,19 @@ async def traverse( target_query: str, max_hops: int = 3, min_score: float = 0.0, + target_type: Optional[NodeType] = None, ) -> TraversalResult: - """Perform deterministic multi-hop reasoning between entities (PRD-22).""" + """Enumerate graph paths from a source gene to matching entities (PRD-22). + + Pass target_type (e.g. NodeType.DISEASE) to restrict matches; publications + are only matched when target_type is NodeType.PUBLICATION. + """ return await self.traversal_engine.traverse( source_query=source_query, target_query=target_query, max_hops=max_hops, min_score=min_score, + target_type=target_type, ) @@ -157,6 +163,7 @@ async def traverse( "BioContext", "EntityResolver", "GeneEntity", + "NodeType", "ProteinEntity", "ResolutionResult", "FunctionalAnnotation", diff --git a/src/biocontext/api.py b/src/biocontext/api.py index 5ffcd46..2d8ed93 100644 --- a/src/biocontext/api.py +++ b/src/biocontext/api.py @@ -1,107 +1,206 @@ """Lightweight REST API v1 server for BioContext (PRD-18). -Runs standalone using Python's standard library http.server and asyncio, -ensuring zero extra heavy dependencies (FastAPI/Starlette not required). +Uses the standard library only. Requests are served by a threaded HTTP server and +all coroutines run on one dedicated asyncio event loop, so a slow upstream call +does not block other requests. The API is read-only and unauthenticated: bind it +to localhost (the default) or place it behind an authenticating reverse proxy. """ import asyncio import json -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, HTTPServer +import threading import urllib.parse -from typing import Any, Dict +from concurrent.futures import TimeoutError as FutureTimeoutError +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Dict, Optional from biocontext import BioContext +from biocontext.graph.models import NodeType from biocontext.logging import get_logger logger = get_logger("api") +API_VERSION = "1.0.1" +REQUEST_TIMEOUT_SEC = 120.0 +MAX_PATH_PARAM_LEN = 200 + + +class ApiError(Exception): + """Error with an HTTP status that is reported to the client as JSON.""" + + def __init__(self, message: str, status: int = HTTPStatus.BAD_REQUEST): + super().__init__(message) + self.status = int(status) + + +def _match_path_param(raw_path: str, base_route: str, name: str) -> Optional[str]: + """Extract a path parameter after base_route, raising 400 if empty.""" + stripped = raw_path.rstrip("/") + if stripped == base_route: + raise ApiError(f"{name} is required") + prefix = base_route + "/" + if raw_path.startswith(prefix): + raw = urllib.parse.unquote(raw_path[len(prefix):]).strip().strip("/") + if not raw: + raise ApiError(f"{name} is required") + if len(raw) > MAX_PATH_PARAM_LEN: + raise ApiError(f"{name} exceeds {MAX_PATH_PARAM_LEN} characters") + return raw + return None + + +def _int_param(params: Dict[str, list], key: str, default: int, low: int, high: int) -> int: + values = params.get(key) + if not values: + return default + try: + value = int(values[0]) + except ValueError: + raise ApiError(f"Query parameter '{key}' must be an integer") + if not low <= value <= high: + raise ApiError(f"Query parameter '{key}' must be between {low} and {high}") + return value + + +def _float_param(params: Dict[str, list], key: str, default: float, low: float, high: float) -> float: + values = params.get(key) + if not values: + return default + try: + value = float(values[0]) + except ValueError: + raise ApiError(f"Query parameter '{key}' must be a number") + if not low <= value <= high: + raise ApiError(f"Query parameter '{key}' must be between {low} and {high}") + return value + class BioContextAPIHandler(BaseHTTPRequestHandler): """Handler for REST API v1 endpoints.""" - client: BioContext = None # Injected during startup - loop: asyncio.AbstractEventLoop = None + client: Optional[BioContext] = None + loop: Optional[asyncio.AbstractEventLoop] = None + cors_origin: Optional[str] = None - def _send_json(self, data: Any, status: int = HTTPStatus.OK): + def log_message(self, format: str, *args: Any) -> None: + logger.info("%s - %s", self.address_string(), format % args) + + def _send_json(self, data: Any, status: int = HTTPStatus.OK) -> None: body = json.dumps(data, indent=2).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) - self.send_header("Access-Control-Allow-Origin", "*") + if self.cors_origin: + self.send_header("Access-Control-Allow-Origin", self.cors_origin) self.end_headers() self.wfile.write(body) - def _send_error_json(self, message: str, status: int = HTTPStatus.BAD_REQUEST): - self._send_json({"error": message, "status_code": status}, status=status) - - def do_GET(self): - parsed_url = urllib.parse.urlparse(self.path) - path = parsed_url.path.rstrip("/") - query_params = urllib.parse.parse_qs(parsed_url.query) - - # Healthcheck - if path == "/health" or path == "": - return self._send_json({"status": "ok", "version": "1.0.0", "service": "BioContext API v1"}) - - # GET /v1/gene/{symbol} - if path.startswith("/v1/gene/"): - symbol = path.replace("/v1/gene/", "").strip() - if not symbol: - return self._send_error_json("Gene symbol is required") - - res = self.loop.run_until_complete(self.client.resolve_gene(symbol)) - return self._send_json(res.model_dump()) - - # GET /v1/protein/{accession} - if path.startswith("/v1/protein/"): - accession = path.replace("/v1/protein/", "").strip() - protein = self.loop.run_until_complete(self.client.get_protein(accession)) + def _run(self, coro: Any) -> Any: + if self.loop is None or self.client is None: + raise ApiError("Server is not initialised", HTTPStatus.SERVICE_UNAVAILABLE) + future = asyncio.run_coroutine_threadsafe(coro, self.loop) + try: + return future.result(timeout=REQUEST_TIMEOUT_SEC) + except FutureTimeoutError: + future.cancel() + raise ApiError("Upstream request timed out", HTTPStatus.GATEWAY_TIMEOUT) + + def do_GET(self) -> None: + try: + self._route() + except ApiError as e: + self._send_json({"error": str(e), "status_code": e.status}, status=e.status) + except Exception as e: + logger.error("Unhandled API error | path=%s error=%s", self.path, e, exc_info=True) + self._send_json( + {"error": "Internal server error", "status_code": HTTPStatus.INTERNAL_SERVER_ERROR}, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + def _route(self) -> None: + parsed = urllib.parse.urlparse(self.path) + raw_path = parsed.path + norm_path = raw_path.rstrip("/") + params = urllib.parse.parse_qs(parsed.query) + + if norm_path in ("", "/health"): + self._send_json({"status": "ok", "version": API_VERSION, "service": "BioContext API v1"}) + return + + symbol = _match_path_param(raw_path, "/v1/gene", "Gene symbol") + if symbol is not None: + result = self._run(self.client.resolve_gene(symbol)) + self._send_json(result.model_dump()) + return + + accession = _match_path_param(raw_path, "/v1/protein", "Protein accession") + if accession is not None: + protein = self._run(self.client.get_protein(accession)) if not protein: - return self._send_error_json(f"Protein '{accession}' not found", status=HTTPStatus.NOT_FOUND) - return self._send_json(protein.model_dump()) - - # GET /v1/disease/{query} - if path.startswith("/v1/disease/"): - disease_q = path.replace("/v1/disease/", "").strip() - diseases = self.loop.run_until_complete(self.client.resolve_disease(disease_q)) - return self._send_json([d.model_dump() for d in diseases]) - - # GET /v1/graph/{symbol} - if path.startswith("/v1/graph/"): - symbol = path.replace("/v1/graph/", "").strip() - subgraph = self.loop.run_until_complete(self.client.build_subgraph(symbol)) - return self._send_json(subgraph.model_dump()) - - # GET /v1/traverse?source=TP53&target=cancer - if path == "/v1/traverse": - source = query_params.get("source", [""])[0].strip() - target = query_params.get("target", [""])[0].strip() - max_hops = int(query_params.get("max_hops", [3])[0]) - + raise ApiError(f"Protein '{accession}' not found", HTTPStatus.NOT_FOUND) + self._send_json(protein.model_dump()) + return + + query = _match_path_param(raw_path, "/v1/disease", "Disease query") + if query is not None: + limit = _int_param(params, "limit", 5, 1, 25) + diseases = self._run(self.client.resolve_disease(query, limit=limit)) + self._send_json([d.model_dump() for d in diseases]) + return + + graph_symbol = _match_path_param(raw_path, "/v1/graph", "Gene symbol") + if graph_symbol is not None: + subgraph = self._run(self.client.build_subgraph(graph_symbol)) + self._send_json(subgraph.model_dump()) + return + + if norm_path == "/v1/traverse": + source = params.get("source", [""])[0].strip() + target = params.get("target", [""])[0].strip() if not source or not target: - return self._send_error_json("Both 'source' and 'target' query parameters are required") - - res = self.loop.run_until_complete(self.client.traverse(source, target, max_hops=max_hops)) - return self._send_json(res.model_dump()) - - return self._send_error_json("Endpoint not found", status=HTTPStatus.NOT_FOUND) - - -def run_api_server(host: str = "127.0.0.1", port: int = 8000): - """Launch the lightweight REST API v1 server.""" + raise ApiError("Both 'source' and 'target' query parameters are required") + max_hops = _int_param(params, "max_hops", 3, 1, 6) + min_score = _float_param(params, "min_score", 0.0, 0.0, 1.0) + raw_type = params.get("target_type", [""])[0].strip().lower() + node_type = None + if raw_type: + try: + node_type = NodeType(raw_type) + except ValueError: + valid = ", ".join(t.value for t in NodeType) + raise ApiError(f"Invalid target_type '{raw_type}'. Valid values: {valid}") + result = self._run( + self.client.traverse(source, target, max_hops=max_hops, min_score=min_score, target_type=node_type) + ) + self._send_json(result.model_dump()) + return + + raise ApiError("Endpoint not found", HTTPStatus.NOT_FOUND) + + +def start_event_loop_thread() -> asyncio.AbstractEventLoop: + """Start a dedicated asyncio loop in a daemon thread and return it.""" loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - client = BioContext() + thread = threading.Thread(target=loop.run_forever, name="biocontext-api-loop", daemon=True) + thread.start() + return loop + - BioContextAPIHandler.client = client +def run_api_server(host: str = "127.0.0.1", port: int = 8000, cors_origin: Optional[str] = None) -> None: + """Launch the REST API v1 server until interrupted.""" + loop = start_event_loop_thread() + BioContextAPIHandler.client = BioContext() BioContextAPIHandler.loop = loop + BioContextAPIHandler.cors_origin = cors_origin - server = HTTPServer((host, port), BioContextAPIHandler) - logger.info(f"BioContext REST API v1 listening at http://{host}:{port}") + server = ThreadingHTTPServer((host, port), BioContextAPIHandler) + server.daemon_threads = True + logger.info("BioContext REST API v%s listening | url=http://%s:%d cors_origin=%s", API_VERSION, host, port, cors_origin) try: server.serve_forever() except KeyboardInterrupt: - logger.info("Shutting down REST API server...") + logger.info("Shutting down REST API server") finally: server.server_close() + loop.call_soon_threadsafe(loop.stop) diff --git a/src/biocontext/cli.py b/src/biocontext/cli.py index 76bb707..deac315 100644 --- a/src/biocontext/cli.py +++ b/src/biocontext/cli.py @@ -33,7 +33,7 @@ def build_parser() -> argparse.ArgumentParser: for subcmd in cmd["subcommands"]: nested.add_parser(subcmd["name"], help=subcmd.get("help", "")) else: - sub = subparsers.add_parser(cmd_name, help=cmd_help) + sub = subparsers.add_parser(cmd_name, help=cmd_help, aliases=cmd.get("aliases", [])) for arg in cmd.get("arguments", []): flags = arg["flags"] kwargs = {k: v for k, v in arg.items() if k != "flags"} @@ -250,14 +250,20 @@ async def run_cli_async(args: argparse.Namespace) -> int: return 0 elif args.command == "traverse": + from biocontext.graph.models import NodeType from biocontext.graph.traversal import TraversalEngine engine = TraversalEngine(resolver=resolver) - res = await engine.traverse( - source_query=args.source, - target_query=args.target, - max_hops=args.max_hops, - min_score=args.min_score, - ) + try: + res = await engine.traverse( + source_query=args.source, + target_query=args.target, + max_hops=args.max_hops, + min_score=args.min_score, + target_type=NodeType(args.target_type) if args.target_type else None, + ) + except ValueError as e: + print(json.dumps({"error": str(e)}, indent=2)) + return 2 print(res.model_dump_json(indent=2)) return 0 @@ -285,17 +291,19 @@ def main(): args = parser.parse_args() - if args.command == "server": + if args.command in ("serve", "server"): mcp.run(transport="stdio") elif args.command == "api": from biocontext.api import run_api_server - run_api_server(host=args.host, port=args.port) - elif args.command == "bench": - import pytest - sys.exit(pytest.main(["tests/test_benchmark.py", "-v"])) - elif args.command == "test": - import pytest - sys.exit(pytest.main(["tests/", "-v"])) + run_api_server(host=args.host, port=args.port, cors_origin=args.cors_origin) + elif args.command in ("bench", "test"): + try: + import pytest + except ImportError: + print("pytest is not installed. Install development dependencies (e.g. `uv sync`) to use this command.", file=sys.stderr) + sys.exit(2) + target = ["tests/test_benchmark.py", "-v"] if args.command == "bench" else ["tests/", "-v"] + sys.exit(pytest.main(target)) elif args.command in ( "resolve", "batch", "protein", "transcripts", "ortholog", "mouse", "annotate", "go", "pathway", "pathway-info", "disease", "targets", diff --git a/src/biocontext/config.py b/src/biocontext/config.py index 4d0f799..657eee4 100644 --- a/src/biocontext/config.py +++ b/src/biocontext/config.py @@ -77,7 +77,8 @@ def get_headers(cls, email: Optional[str] = None) -> Dict[str, str]: # Allows auditing and extending CLI commands without modifying parsing loops CLI_COMMANDS_REGISTRY: List[Dict[str, Any]] = [ { - "name": "server", + "name": "serve", + "aliases": ["server"], "help": "Run the Model Context Protocol (MCP) stdio server for AI agents", "arguments": [] }, @@ -216,7 +217,8 @@ def get_headers(cls, email: Optional[str] = None) -> Dict[str, str]: {"flags": ["source"], "help": "Source entity (e.g. TP53, BRCA1)"}, {"flags": ["target"], "help": "Target entity or keyword (e.g. cancer, apoptosis)"}, {"flags": ["--max-hops"], "type": int, "default": 3, "help": "Max hops (default: 3)"}, - {"flags": ["--min-score"], "type": float, "default": 0.0, "help": "Minimum cumulative score (default: 0.0)"} + {"flags": ["--min-score"], "type": float, "default": 0.0, "help": "Minimum product of source-provided scores, 0.0-1.0 (default: 0.0)"}, + {"flags": ["--target-type"], "default": None, "choices": ["gene", "protein", "transcript", "pathway", "disease", "publication", "go_term"], "help": "Restrict target matches to one node type"} ] }, { @@ -224,7 +226,8 @@ def get_headers(cls, email: Optional[str] = None) -> Dict[str, str]: "help": "Start standalone BioContext REST API v1 HTTP server (PRD-18)", "arguments": [ {"flags": ["--host"], "default": "127.0.0.1", "help": "Host address (default: 127.0.0.1)"}, - {"flags": ["--port"], "type": int, "default": 8000, "help": "Port number (default: 8000)"} + {"flags": ["--port"], "type": int, "default": 8000, "help": "Port number (default: 8000)"}, + {"flags": ["--cors-origin"], "default": None, "help": "Value for Access-Control-Allow-Origin (default: header not sent)"} ] }, diff --git a/src/biocontext/graph/builder.py b/src/biocontext/graph/builder.py index 986696f..254d1c6 100644 --- a/src/biocontext/graph/builder.py +++ b/src/biocontext/graph/builder.py @@ -1,6 +1,12 @@ -"""Knowledge Graph Builder pipeline integrating BioContext adapters into structured subgraphs.""" +"""Knowledge Graph Builder pipeline integrating BioContext adapters into structured subgraphs. + +Every optional layer reports a status in SubGraphContext.layer_status so that a +failed or empty layer is visible to the caller instead of silently producing a +smaller graph. +""" + +from typing import Dict, List, Optional -from typing import List, Optional from biocontext.graph.models import ( EdgeType, GraphEdge, @@ -16,6 +22,11 @@ logger = get_logger("graph.builder") +LAYER_PATHWAYS = "pathways" +LAYER_GO = "go" +LAYER_DISEASES = "diseases" +LAYER_LITERATURE = "literature" + class KnowledgeGraphBuilder: """Orchestrates entity resolution and builds connected biological subgraphs.""" @@ -23,6 +34,23 @@ class KnowledgeGraphBuilder: def __init__(self, resolver: Optional[EntityResolver] = None): self.resolver = resolver or EntityResolver() + def _finish( + self, + store: InMemoryGraphStore, + root_id: str, + layer_status: Dict[str, str], + warnings: List[str], + max_depth: int, + ) -> SubGraphContext: + subgraph = store.get_subgraph(root_id, max_depth=max_depth) + subgraph.layer_status = layer_status + subgraph.warnings = warnings + logger.info( + "Subgraph built | root=%s nodes=%d edges=%d layers=%s", + root_id, subgraph.total_nodes, subgraph.total_edges, layer_status, + ) + return subgraph + async def build_gene_subgraph( self, query: str, @@ -37,11 +65,11 @@ async def build_gene_subgraph( ) -> SubGraphContext: """Resolve a gene and construct an interconnected biological subgraph across databases.""" store = InMemoryGraphStore() + layer_status: Dict[str, str] = {} + warnings: List[str] = [] - # 1. Resolve Gene res: ResolutionResult = await self.resolver.resolve(query=query, taxon_id=taxon_id) if not res.resolved_entity: - # Unresolved entity fallback node unresolved_node = GraphNode( id=f"unresolved:{query}", name=query, @@ -49,194 +77,188 @@ async def build_gene_subgraph( properties={"status": "unresolved", "query": query}, ) store.add_node(unresolved_node) - return store.get_subgraph(unresolved_node.id, max_depth=0) + warnings.append(f"Seed query '{query}' could not be resolved to a gene; no layers were built.") + return self._finish(store, unresolved_node.id, layer_status, warnings, max_depth=0) gene = res.resolved_entity gene_node_id = f"gene:{gene.symbol}" - gene_node = GraphNode( - id=gene_node_id, - name=gene.symbol, - node_type=NodeType.GENE, - properties={ - "hgnc_id": gene.hgnc_id, - "ncbi_gene_id": gene.ncbi_gene_id, - "ensembl_gene_id": gene.ensembl_gene_id, - "description": gene.name, - "species": gene.species, - "synonyms": gene.synonyms, - }, - provenance=Provenance( - source_database="HGNC", - confidence=res.confidence_score, - source_id=gene.hgnc_id, - ), + store.add_node( + GraphNode( + id=gene_node_id, + name=gene.symbol, + node_type=NodeType.GENE, + properties={ + "hgnc_id": gene.hgnc_id, + "ncbi_gene_id": gene.ncbi_gene_id, + "ensembl_gene_id": gene.ensembl_gene_id, + "description": gene.name, + "species": gene.species, + "synonyms": gene.synonyms, + "match_status": res.match_status, + }, + provenance=Provenance( + source_database="HGNC", + confidence=res.confidence_score, + source_id=gene.hgnc_id, + ), + ) ) - store.add_node(gene_node) - # 2. Proteins & Transcripts + # Proteins (UniProt accessions attached to the resolved gene) primary_accession = gene.uniprot_ids[0] if gene.uniprot_ids else None - for acc in gene.uniprot_ids[:2]: # Top 2 accessions + for acc in gene.uniprot_ids[:2]: protein_node_id = f"protein:{acc}" - protein_node = GraphNode( - id=protein_node_id, - name=f"{gene.symbol} ({acc})", - node_type=NodeType.PROTEIN, - properties={"accession": acc, "gene_symbol": gene.symbol}, - provenance=Provenance( - source_database="UniProt", - confidence=1.0, - source_id=acc, - ), + store.add_node( + GraphNode( + id=protein_node_id, + name=f"{gene.symbol} ({acc})", + node_type=NodeType.PROTEIN, + properties={"accession": acc, "gene_symbol": gene.symbol}, + provenance=Provenance(source_database="UniProt", source_id=acc), + ) ) - store.add_node(protein_node) store.add_edge( GraphEdge( source_id=gene_node_id, target_id=protein_node_id, edge_type=EdgeType.ENCODES, - weight=1.0, - provenance=Provenance(source_database="UniProt", confidence=1.0, source_id=acc), + provenance=Provenance(source_database="UniProt", source_id=acc), ) ) - # 3. Transcripts (from Ensembl if available) - for transcript in gene.transcripts[:3]: # Top 3 transcripts + # Transcripts (only present when the resolver populated them from Ensembl) + for transcript in gene.transcripts[:3]: t_node_id = f"transcript:{transcript.transcript_id}" - t_node = GraphNode( - id=t_node_id, - name=transcript.name or transcript.transcript_id, - node_type=NodeType.TRANSCRIPT, - properties={ - "transcript_id": transcript.transcript_id, - "is_canonical": transcript.is_canonical, - "biotype": transcript.biotype, - }, - provenance=Provenance( - source_database="Ensembl", - confidence=1.0, - source_id=transcript.transcript_id, - ), + store.add_node( + GraphNode( + id=t_node_id, + name=transcript.name or transcript.transcript_id, + node_type=NodeType.TRANSCRIPT, + properties={ + "transcript_id": transcript.transcript_id, + "is_canonical": transcript.is_canonical, + "biotype": transcript.biotype, + }, + provenance=Provenance(source_database="Ensembl", source_id=transcript.transcript_id), + ) ) - store.add_node(t_node) store.add_edge( GraphEdge( source_id=gene_node_id, target_id=t_node_id, edge_type=EdgeType.TRANSCRIBES, - weight=1.0, - provenance=Provenance(source_database="Ensembl", confidence=1.0), + provenance=Provenance(source_database="Ensembl", source_id=transcript.transcript_id), ) ) - # 4. Pathways (Reactome) - if include_pathways and primary_accession: + protein_anchor_id = f"protein:{primary_accession}" if primary_accession else None + anchor_id = protein_anchor_id if protein_anchor_id and store.get_node(protein_anchor_id) else gene_node_id + + # Pathways (Reactome) + if not include_pathways: + layer_status[LAYER_PATHWAYS] = "skipped" + elif not primary_accession: + layer_status[LAYER_PATHWAYS] = "skipped: no UniProt accession for gene" + else: try: p_ctx = await self.resolver.get_pathways(primary_accession) - for pathway in p_ctx.pathways[:limit_pathways]: + pathways = p_ctx.pathways[:limit_pathways] if p_ctx else [] + for pathway in pathways: p_node_id = f"pathway:{pathway.st_id}" - p_node = GraphNode( - id=p_node_id, - name=pathway.name, - node_type=NodeType.PATHWAY, - properties={ - "st_id": pathway.st_id, - "url": pathway.url, - "is_in_disease": pathway.is_in_disease, - }, - provenance=Provenance( - source_database="Reactome", - confidence=1.0, - source_id=pathway.st_id, - ), - ) - store.add_node(p_node) - # Link to protein - protein_node_id = f"protein:{primary_accession}" - if store.get_node(protein_node_id): - store.add_edge( - GraphEdge( - source_id=protein_node_id, - target_id=p_node_id, - edge_type=EdgeType.PARTICIPATES_IN, - weight=1.0, - provenance=Provenance(source_database="Reactome", confidence=1.0), - ) + store.add_node( + GraphNode( + id=p_node_id, + name=pathway.name, + node_type=NodeType.PATHWAY, + properties={ + "st_id": pathway.st_id, + "url": pathway.url, + "is_in_disease": pathway.is_in_disease, + }, + provenance=Provenance(source_database="Reactome", source_id=pathway.st_id), ) - else: - # Fallback link to gene - store.add_edge( - GraphEdge( - source_id=gene_node_id, - target_id=p_node_id, - edge_type=EdgeType.PARTICIPATES_IN, - weight=1.0, - provenance=Provenance(source_database="Reactome", confidence=1.0), - ) + ) + store.add_edge( + GraphEdge( + source_id=anchor_id, + target_id=p_node_id, + edge_type=EdgeType.PARTICIPATES_IN, + provenance=Provenance(source_database="Reactome", source_id=pathway.st_id), ) + ) + layer_status[LAYER_PATHWAYS] = "ok" if pathways else "empty" except Exception as e: - logger.warning(f"Failed to fetch pathways for graph: {e}") + logger.warning("Pathway layer failed | gene=%s error=%s", gene.symbol, e) + layer_status[LAYER_PATHWAYS] = f"failed: {e}" + warnings.append(f"Pathway layer failed: {e}") - # 5. Gene Ontology (GO Terms) - if include_go and primary_accession: + # Gene Ontology (QuickGO) + if not include_go: + layer_status[LAYER_GO] = "skipped" + elif not primary_accession: + layer_status[LAYER_GO] = "skipped: no UniProt accession for gene" + else: try: go_ctx = await self.resolver.annotate_gene(primary_accession, max_terms_per_aspect=2) - if go_ctx: - sampled_go = (go_ctx.biological_processes[:2] + go_ctx.molecular_functions[:2]) - for go in sampled_go: - go_node_id = f"go:{go.go_id}" - go_node = GraphNode( + sampled_go = ( + go_ctx.biological_processes[:2] + go_ctx.molecular_functions[:2] if go_ctx else [] + ) + for go in sampled_go: + go_node_id = f"go:{go.go_id}" + store.add_node( + GraphNode( id=go_node_id, name=go.name or go.go_id, node_type=NodeType.GO_TERM, properties={"go_id": go.go_id, "aspect": go.aspect}, provenance=Provenance( source_database="GeneOntology", - confidence=1.0, source_id=go.go_id, evidence_code=go.evidence_code, ), ) - store.add_node(go_node) - protein_node_id = f"protein:{primary_accession}" - src_id = protein_node_id if store.get_node(protein_node_id) else gene_node_id - edge_type = ( - EdgeType.INVOLVED_IN - if go.aspect == "biological_process" - else EdgeType.HAS_FUNCTION - ) - store.add_edge( - GraphEdge( - source_id=src_id, - target_id=go_node_id, - edge_type=edge_type, - weight=1.0, - provenance=Provenance(source_database="GeneOntology", confidence=1.0), - ) + ) + edge_type = ( + EdgeType.INVOLVED_IN if go.aspect == "biological_process" else EdgeType.HAS_FUNCTION + ) + store.add_edge( + GraphEdge( + source_id=anchor_id, + target_id=go_node_id, + edge_type=edge_type, + provenance=Provenance( + source_database="GeneOntology", + source_id=go.go_id, + evidence_code=go.evidence_code, + ), ) + ) + layer_status[LAYER_GO] = "ok" if sampled_go else "empty" except Exception as e: - logger.warning(f"Failed to fetch GO annotations for graph: {e}") + logger.warning("GO layer failed | gene=%s error=%s", gene.symbol, e) + layer_status[LAYER_GO] = f"failed: {e}" + warnings.append(f"GO layer failed: {e}") - # 6. Target-Disease Associations (Open Targets) - if include_diseases and gene.ensembl_gene_id: + # Target-disease associations (Open Targets). These carry real scores. + if not include_diseases: + layer_status[LAYER_DISEASES] = "skipped" + elif not gene.ensembl_gene_id: + layer_status[LAYER_DISEASES] = "skipped: no Ensembl gene ID for gene" + else: try: target_diseases = await self.resolver.get_target_diseases(gene.ensembl_gene_id) - for assoc in target_diseases.associations[:limit_diseases]: + associations = target_diseases.associations[:limit_diseases] if target_diseases else [] + for assoc in associations: disease_node_id = f"disease:{assoc.disease_id}" - disease_node = GraphNode( - id=disease_node_id, - name=assoc.disease_name, - node_type=NodeType.DISEASE, - properties={ - "disease_id": assoc.disease_id, - "association_score": assoc.score, - }, - provenance=Provenance( - source_database="OpenTargets", - confidence=assoc.score, - source_id=assoc.disease_id, - ), + store.add_node( + GraphNode( + id=disease_node_id, + name=assoc.disease_name, + node_type=NodeType.DISEASE, + properties={"disease_id": assoc.disease_id}, + provenance=Provenance(source_database="OpenTargets", source_id=assoc.disease_id), + ) ) - store.add_node(disease_node) store.add_edge( GraphEdge( source_id=gene_node_id, @@ -251,43 +273,56 @@ async def build_gene_subgraph( properties={"datatype_scores": assoc.datatype_scores or {}}, ) ) + layer_status[LAYER_DISEASES] = "ok" if associations else "empty" except Exception as e: - logger.warning(f"Failed to fetch disease associations for graph: {e}") + logger.warning("Disease layer failed | gene=%s error=%s", gene.symbol, e) + layer_status[LAYER_DISEASES] = f"failed: {e}" + warnings.append(f"Disease layer failed: {e}") - # 7. Supporting Literature (Europe PMC) - if include_literature: + # Literature (Europe PMC keyword search). Unscored: ranking is not evidence strength. + if not include_literature: + layer_status[LAYER_LITERATURE] = "skipped" + else: try: lit_ctx = await self.resolver.get_supporting_publications(gene.symbol, limit=limit_literature) - for pub in lit_ctx.publications[:limit_literature]: - pub_id_str = pub.pmid or pub.doi or pub.title[:30] - pub_node_id = f"pub:{pub_id_str}" - pub_node = GraphNode( - id=pub_node_id, - name=pub.title, - node_type=NodeType.PUBLICATION, - properties={ - "pmid": pub.pmid, - "doi": pub.doi, - "year": pub.pub_year, - "journal": pub.journal, - }, - provenance=Provenance( - source_database="EuropePMC", - confidence=1.0, - source_id=pub.pmid, - ), + publications = lit_ctx.publications[:limit_literature] if lit_ctx else [] + for pub in publications: + pub_key = pub.pmid or pub.doi + if not pub_key: + logger.warning("Skipping publication without PMID/DOI | title=%s", pub.title[:60]) + continue + pub_node_id = f"pub:{pub_key}" + store.add_node( + GraphNode( + id=pub_node_id, + name=pub.title, + node_type=NodeType.PUBLICATION, + properties={ + "pmid": pub.pmid, + "doi": pub.doi, + "year": pub.pub_year, + "journal": pub.journal, + }, + provenance=Provenance(source_database="EuropePMC", source_id=pub.pmid), + ) ) - store.add_node(pub_node) store.add_edge( GraphEdge( source_id=gene_node_id, target_id=pub_node_id, edge_type=EdgeType.CITED_IN, - weight=1.0, - provenance=Provenance(source_database="EuropePMC", confidence=1.0), + provenance=Provenance(source_database="EuropePMC", source_id=pub.pmid), + properties={ + "relation_basis": "keyword_search_hit", + "search_term": gene.symbol, + "note": "Hit for the gene symbol; topical relevance is not verified.", + }, ) ) + layer_status[LAYER_LITERATURE] = "ok" if publications else "empty" except Exception as e: - logger.warning(f"Failed to fetch literature for graph: {e}") + logger.warning("Literature layer failed | gene=%s error=%s", gene.symbol, e) + layer_status[LAYER_LITERATURE] = f"failed: {e}" + warnings.append(f"Literature layer failed: {e}") - return store.get_subgraph(gene_node_id, max_depth=2) + return self._finish(store, gene_node_id, layer_status, warnings, max_depth=2) diff --git a/src/biocontext/graph/models.py b/src/biocontext/graph/models.py index 2cb085d..b54fcc4 100644 --- a/src/biocontext/graph/models.py +++ b/src/biocontext/graph/models.py @@ -34,7 +34,7 @@ class EdgeType(str, Enum): class Provenance(BaseModel): """Audit trail and confidence metadata for graph nodes and edges.""" source_database: str = Field(..., description="Authoritative primary source (e.g. HGNC, UniProt, Reactome, OpenTargets)") - confidence: float = Field(1.0, ge=0.0, le=1.0, description="Confidence or association score between 0.0 and 1.0") + confidence: Optional[float] = Field(None, ge=0.0, le=1.0, description="Measured confidence or association score (0.0-1.0). None when the source provides no score.") source_id: Optional[str] = Field(None, description="Identifier in source database (e.g. HGNC:11998, R-HSA-5357801)") evidence_code: Optional[str] = Field(None, description="Experimental evidence code if applicable (e.g. IDA, IEA)") @@ -53,7 +53,7 @@ class GraphEdge(BaseModel): source_id: str = Field(..., description="Source node ID") target_id: str = Field(..., description="Target node ID") edge_type: EdgeType = Field(..., description="Semantic relationship category") - weight: float = Field(1.0, ge=0.0, le=1.0, description="Edge weight / association strength") + weight: Optional[float] = Field(None, ge=0.0, le=1.0, description="Association strength from the source database (0.0-1.0). None for curated/structural or unscored relations.") provenance: Optional[Provenance] = Field(None, description="Source attribution and evidence") properties: Dict[str, Any] = Field(default_factory=dict, description="Edge specific annotations (e.g. mechanism, datatype_scores)") @@ -66,6 +66,8 @@ class SubGraphContext(BaseModel): edges: List[GraphEdge] = Field(default_factory=list, description="Relationships connecting nodes in the subgraph") total_nodes: int = Field(0, description="Total node count") total_edges: int = Field(0, description="Total edge count") + layer_status: Dict[str, str] = Field(default_factory=dict, description="Per-layer build status: 'ok', 'empty', 'skipped', or 'failed: '") + warnings: List[str] = Field(default_factory=list, description="Human-readable warnings, e.g. layers that failed or an unresolved seed") class PathStep(BaseModel): @@ -81,14 +83,18 @@ class TraversalPath(BaseModel): target_node_id: str = Field(..., description="Destination entity ID") steps: List[PathStep] = Field(default_factory=list, description="Sequence of hops in the path") hop_count: int = Field(0, description="Number of edges traversed") - cumulative_score: float = Field(1.0, ge=0.0, le=1.0, description="Product of confidence scores along path") - human_explanation: str = Field(..., description="Deterministic, zero-hallucination natural language narrative of the biological path") + cumulative_score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Product of source-provided edge weights along the path. None if no edge on the path carries a score. Not a probability.") + scored_hops: int = Field(0, description="Number of hops that contributed a source-provided score") + human_explanation: str = Field(..., description="Template-generated narrative of the path; every statement maps to a graph edge and its source database") class TraversalResult(BaseModel): """Result of a multi-hop graph traversal query.""" source_query: str = Field(..., description="Initial source query or symbol") target_query: str = Field(..., description="Initial target query or disease/pathway") - paths_found: List[TraversalPath] = Field(default_factory=list, description="Discovered deterministic biological paths") + target_type: Optional[NodeType] = Field(None, description="Node type the target was restricted to, if any") + paths_found: List[TraversalPath] = Field(default_factory=list, description="Discovered paths between source and matching targets") total_paths: int = Field(0, description="Count of valid paths discovered") + matched_targets: List[str] = Field(default_factory=list, description="Node IDs that matched the target query") + warnings: List[str] = Field(default_factory=list, description="Warnings such as failed graph layers or unresolved seed") diff --git a/src/biocontext/graph/store.py b/src/biocontext/graph/store.py index bec22a4..f9d0e85 100644 --- a/src/biocontext/graph/store.py +++ b/src/biocontext/graph/store.py @@ -125,6 +125,8 @@ def get_subgraph(self, root_id: str, max_depth: int = 1) -> SubGraphContext: total_edges=len(collected_edges), ) + MAX_ALLOWED_HOPS = 6 + def find_paths( self, start_id: str, @@ -132,7 +134,15 @@ def find_paths( max_hops: int = 3, min_cumulative_score: float = 0.0, ) -> List[List[GraphEdge]]: - """Find deterministic directed paths between start_id and target_id using DFS.""" + """Find simple directed paths from start_id to target_id using DFS. + + Edges without a source-provided weight (weight is None) are neutral: + they do not reduce the running score. Only scored edges are multiplied. + """ + if not 1 <= max_hops <= self.MAX_ALLOWED_HOPS: + raise ValueError(f"max_hops must be between 1 and {self.MAX_ALLOWED_HOPS}, got {max_hops}") + if not 0.0 <= min_cumulative_score <= 1.0: + raise ValueError(f"min_cumulative_score must be between 0.0 and 1.0, got {min_cumulative_score}") if start_id not in self._nodes or target_id not in self._nodes: return [] @@ -149,7 +159,7 @@ def dfs(current_id: str, current_path: List[GraphEdge], visited: Set[str], curre for edge in self.get_outgoing_edges(current_id): next_id = edge.target_id if next_id not in visited: - next_score = current_score * edge.weight + next_score = current_score * (edge.weight if edge.weight is not None else 1.0) if next_score >= min_cumulative_score: visited.add(next_id) current_path.append(edge) diff --git a/src/biocontext/graph/traversal.py b/src/biocontext/graph/traversal.py index afce948..77ad6ec 100644 --- a/src/biocontext/graph/traversal.py +++ b/src/biocontext/graph/traversal.py @@ -1,10 +1,19 @@ -"""Ontological Multi-hop Traversal and Reasoning Engine (PRD-22).""" +"""Ontological multi-hop traversal over the BioContext knowledge graph (PRD-22). + +Paths are enumerated deterministically from graph edges. The narrative for each +path is generated from a fixed template, so every sentence maps to one edge and +its source database. Scores are products of source-provided edge weights and are +not probabilities; unscored edges are reported as such. +""" + +import re +from typing import List, Optional, Tuple -from typing import List, Optional from biocontext.graph.builder import KnowledgeGraphBuilder from biocontext.graph.models import ( GraphEdge, GraphNode, + NodeType, PathStep, TraversalPath, TraversalResult, @@ -16,8 +25,55 @@ logger = get_logger("graph.traversal") +def _node_identifiers(node: GraphNode) -> List[str]: + """Identifiers a user may supply for a node (graph ID and source accession fields).""" + ids = [node.id] + for key in ("disease_id", "st_id", "go_id", "accession", "pmid", "doi", "transcript_id"): + value = node.properties.get(key) + if value: + ids.append(str(value)) + return ids + + +def match_target_nodes( + nodes: List[GraphNode], + target_query: str, + target_type: Optional[NodeType] = None, + exclude_id: Optional[str] = None, +) -> List[GraphNode]: + """Select nodes matching a target query using identifiers or whole-phrase names. + + Matching rules: + - Exact, case-insensitive match on the node ID or a source accession field. + - Otherwise a whole-word, case-insensitive phrase match on the node name. + - Free-text properties are never searched. + - Publication nodes are only eligible when target_type is PUBLICATION, because + a paper title containing a word does not establish a biological relation. + """ + query = target_query.strip().lower() + phrase = re.compile(rf"(? str: - """Construct a deterministic natural language explanation from traversal steps.""" - if not steps: - return "No connecting path found." - - narrative_parts = [] + @staticmethod + def _score_path(edges: List[GraphEdge]) -> Tuple[Optional[float], int]: + """Return (product of source-provided weights, number of scored hops).""" + scored = [e.weight for e in edges if e.weight is not None] + if not scored: + return None, 0 + product = 1.0 + for w in scored: + product *= w + return product, len(scored) + + @staticmethod + def _generate_explanation(steps: List[PathStep], score: Optional[float], scored_hops: int) -> str: + """Template-based narrative; one line per edge, with source database and weight if any.""" + lines = [] for i, step in enumerate(steps, 1): src = f"{step.source_node.name} ({step.source_node.node_type.value})" rel = step.edge.edge_type.value.replace("_", " ") tgt = f"{step.target_node.name} ({step.target_node.node_type.value})" - prov_str = "" - if step.edge.provenance: - prov_str = f" [via {step.edge.provenance.source_database}" - if step.edge.provenance.source_id: - prov_str += f":{step.edge.provenance.source_id}" - prov_str += f", weight={step.edge.weight:.2f}]" - - narrative_parts.append(f"Hop {i}: {src} {rel} {tgt}{prov_str}") - - start_name = steps[0].source_node.name - end_name = steps[-1].target_node.name - overall_summary = ( - f"Evidence path connects {start_name} to {end_name} in {len(steps)} hops " - f"(cumulative confidence score: {cumulative_score:.2f})." + prov = step.edge.provenance + source_txt = prov.source_database if prov else "unknown source" + if prov and prov.source_id: + source_txt += f":{prov.source_id}" + weight_txt = f"score={step.edge.weight:.2f}" if step.edge.weight is not None else "unscored" + lines.append(f"Hop {i}: {src} {rel} {tgt} [source={source_txt}; {weight_txt}]") + + start = steps[0].source_node.name + end = steps[-1].target_node.name + score_txt = ( + f"product of {scored_hops} source-provided score(s) = {score:.2f}" + if score is not None + else "no source-provided score on this path" ) - return f"{overall_summary}\n" + "\n".join(f"- {part}" for part in narrative_parts) + header = f"Path from {start} to {end} in {len(steps)} hop(s); {score_txt}." + return header + "\n" + "\n".join(f"- {line}" for line in lines) async def traverse( self, @@ -60,16 +125,23 @@ async def traverse( target_query: str, max_hops: int = 3, min_score: float = 0.0, + target_type: Optional[NodeType] = None, ) -> TraversalResult: - """Find deterministic multi-hop reasoning paths connecting two biological entities.""" - # 1. Build subgraph around source entity + """Enumerate paths from a source gene to graph entities matching target_query.""" + if not source_query or not source_query.strip(): + raise ValueError("source_query must be a non-empty string") + if not target_query or not target_query.strip(): + raise ValueError("target_query must be a non-empty string") + if not 1 <= max_hops <= self.MAX_HOPS: + raise ValueError(f"max_hops must be between 1 and {self.MAX_HOPS}, got {max_hops}") + if not 0.0 <= min_score <= 1.0: + raise ValueError(f"min_score must be between 0.0 and 1.0, got {min_score}") + subgraph = await self.builder.build_gene_subgraph( - query=source_query, - include_pathways=True, - include_go=True, - include_diseases=True, - include_literature=True, + query=source_query.strip(), + include_literature=(target_type == NodeType.PUBLICATION), ) + warnings = list(subgraph.warnings) store = InMemoryGraphStore() for node in subgraph.nodes: @@ -78,60 +150,46 @@ async def traverse( store.add_edge(edge) start_id = subgraph.root_node_id - - # 2. Identify target node(s) matching target_query - target_q_clean = target_query.strip().lower() - candidate_target_ids = [] - for node in subgraph.nodes: - if ( - target_q_clean in node.id.lower() - or target_q_clean in node.name.lower() - or target_q_clean in str(node.properties).lower() - ): - candidate_target_ids.append(node.id) - - traversal_paths: List[TraversalPath] = [] - - for target_id in candidate_target_ids: - if target_id == start_id: - continue - - raw_paths: List[List[GraphEdge]] = store.find_paths( - start_id=start_id, - target_id=target_id, - max_hops=max_hops, - min_cumulative_score=min_score, + targets = match_target_nodes(subgraph.nodes, target_query, target_type, exclude_id=start_id) + if not targets: + warnings.append( + f"No node in the subgraph of '{source_query}' matches target '{target_query}'" + + (f" with type '{target_type.value}'" if target_type else "") + + "." ) - for edge_list in raw_paths: + paths: List[TraversalPath] = [] + for target in targets: + for edge_list in store.find_paths(start_id, target.id, max_hops=max_hops, min_cumulative_score=min_score): steps: List[PathStep] = [] - cumulative_score = 1.0 for edge in edge_list: src_node = store.get_node(edge.source_id) tgt_node = store.get_node(edge.target_id) - if src_node and tgt_node: - steps.append(PathStep(source_node=src_node, edge=edge, target_node=tgt_node)) - cumulative_score *= edge.weight - - if steps: - explanation = self._generate_explanation(steps, cumulative_score) - traversal_paths.append( - TraversalPath( - start_node_id=start_id, - target_node_id=target_id, - steps=steps, - hop_count=len(steps), - cumulative_score=cumulative_score, - human_explanation=explanation, - ) + if src_node is None or tgt_node is None: + raise RuntimeError(f"Edge references a missing node: {edge.source_id} -> {edge.target_id}") + steps.append(PathStep(source_node=src_node, edge=edge, target_node=tgt_node)) + + score, scored_hops = self._score_path(edge_list) + paths.append( + TraversalPath( + start_node_id=start_id, + target_node_id=target.id, + steps=steps, + hop_count=len(steps), + cumulative_score=score, + scored_hops=scored_hops, + human_explanation=self._generate_explanation(steps, score, scored_hops), ) + ) - # Sort paths by highest cumulative score and lowest hop count - traversal_paths.sort(key=lambda p: (-p.cumulative_score, p.hop_count)) + paths.sort(key=lambda p: (p.cumulative_score is None, -(p.cumulative_score or 0.0), p.hop_count)) return TraversalResult( source_query=source_query, target_query=target_query, - paths_found=traversal_paths, - total_paths=len(traversal_paths), + target_type=target_type, + paths_found=paths, + total_paths=len(paths), + matched_targets=[t.id for t in targets], + warnings=warnings, ) diff --git a/src/biocontext/server.py b/src/biocontext/server.py index 5d7dbac..41bde0a 100644 --- a/src/biocontext/server.py +++ b/src/biocontext/server.py @@ -354,32 +354,52 @@ async def traverse_biological_graph( target_query: str, max_hops: int = 3, min_score: float = 0.0, + target_type: str | None = None, ) -> str: - """Traverse the biological Knowledge Graph to explain multi-hop relations deterministically (PRD-22). + """Enumerate graph paths from a source gene to a target entity (PRD-22). - Discovers evidence-backed relational paths connecting a source entity (e.g. gene) - to a target entity (e.g. disease, pathway, protein) without hallucination. + Paths follow edges drawn from curated databases; each hop reports its source + database and score if the source provides one. Scores are products of + source-provided weights, not probabilities. Literature edges are keyword hits + and are only used when target_type is 'publication'. Args: - source_query: Origin gene symbol (e.g. 'TP53', 'BRCA1'). - target_query: Destination disease, pathway name, or identifier (e.g. 'cancer', 'apoptosis', 'breast'). - max_hops: Maximum intermediate relational edges to traverse (default: 3). - min_score: Minimum cumulative confidence score threshold (default: 0.0). + source_query: Origin gene symbol or alias (e.g. 'TP53'). + target_query: Target identifier (e.g. 'MONDO:0005575', 'R-HSA-69563') or a whole-phrase name (e.g. 'colorectal cancer'). + max_hops: Maximum edges to traverse, 1 to 6 (default: 3). + min_score: Minimum product of source-provided scores, 0.0 to 1.0 (default: 0.0). + target_type: Optional node type filter: gene, protein, transcript, pathway, disease, publication, go_term. Returns: - JSON string containing explainable paths, hops, edge weights, and natural language narrative. + JSON string with paths, matched targets, and warnings. On invalid input, a JSON object with an 'error' field. """ + import json + from biocontext.graph.models import NodeType from biocontext.graph.traversal import TraversalEngine + + node_type = None + if target_type: + try: + node_type = NodeType(target_type.strip().lower()) + except ValueError: + valid = ", ".join(t.value for t in NodeType) + return json.dumps({"error": f"Invalid target_type '{target_type}'. Valid values: {valid}"}) + engine = TraversalEngine(resolver=resolver) - result = await engine.traverse( - source_query=source_query, - target_query=target_query, - max_hops=max_hops, - min_score=min_score, - ) + try: + result = await engine.traverse( + source_query=source_query, + target_query=target_query, + max_hops=max_hops, + min_score=min_score, + target_type=node_type, + ) + except ValueError as e: + return json.dumps({"error": str(e)}) return result.model_dump_json(indent=2) + def main(): """Run MCP server over stdio.""" mcp.run(transport="stdio") diff --git a/tests/test_graph.py b/tests/test_graph.py index 709915e..6f2e880 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -116,7 +116,8 @@ async def test_knowledge_graph_builder_tp53(): for n in subgraph.nodes: if n.provenance: assert n.provenance.source_database is not None - assert 0.0 <= n.provenance.confidence <= 1.0 + if n.provenance.confidence is not None: + assert 0.0 <= n.provenance.confidence <= 1.0 @pytest.mark.asyncio @@ -146,12 +147,17 @@ async def test_traversal_engine_find_paths(): result = await engine.traverse( source_query="TP53", target_query="cancer", + target_type=NodeType.DISEASE, max_hops=3, ) assert result.source_query == "TP53" assert result.target_query == "cancer" + assert result.target_type == NodeType.DISEASE assert result.total_paths >= 1 + assert len(result.matched_targets) >= 1 + for m in result.matched_targets: + assert m.startswith("disease:") top_path = result.paths_found[0] assert top_path.start_node_id == "gene:TP53" assert top_path.hop_count >= 1 @@ -166,10 +172,13 @@ async def test_mcp_tool_traverse_biological_graph(): res_json = await traverse_biological_graph( source_query="TP53", - target_query="PUMA", + target_query="cancer", + target_type="disease", max_hops=3, ) data = json.loads(res_json) assert data["source_query"] == "TP53" assert "paths_found" in data + assert "matched_targets" in data + assert "warnings" in data diff --git a/tests/test_offline_robustness.py b/tests/test_offline_robustness.py new file mode 100644 index 0000000..816d8df --- /dev/null +++ b/tests/test_offline_robustness.py @@ -0,0 +1,377 @@ +"""Offline robustness tests for the graph builder, traversal engine, store, CLI and REST API. + +[SYNTHETIC MOCK / SIMULATION] +Everything below uses test doubles with fictional identifiers (gene FAKEG1, accession +Q00000, disease IDs FAKE:*). They verify software behaviour (error visibility, matching +rules, scoring arithmetic, input validation) and make no biological claim. They do not +touch the network, so they are deterministic and independent of upstream API outages. +""" + +import asyncio +import json +import threading +import time +import urllib.error +import urllib.request +from http.server import ThreadingHTTPServer + +import pytest + +from biocontext.api import BioContextAPIHandler, start_event_loop_thread +from biocontext.cli import build_parser +from biocontext.graph.builder import KnowledgeGraphBuilder +from biocontext.graph.models import EdgeType, GraphEdge, GraphNode, NodeType, Provenance +from biocontext.graph.store import InMemoryGraphStore +from biocontext.graph.traversal import TraversalEngine, match_target_nodes +from biocontext.schemas import ( + FunctionalAnnotation, + GeneEntity, + GOAnnotation, + LiteratureContext, + PathwayContext, + PathwayEntity, + PublicationEntity, + ResolutionResult, + TargetAssociationContext, + TargetDiseaseAssociation, +) + + +class StubResolver: + """[SYNTHETIC MOCK] Resolver double returning fixed fictional data.""" + + def __init__(self, fail_pathways=False, resolvable=True): + self.fail_pathways = fail_pathways + self.resolvable = resolvable + + async def resolve(self, query, taxon_id=9606, context=None): + if not self.resolvable: + return ResolutionResult(query=query, match_status="unresolved", confidence_score=0.0) + gene = GeneEntity( + symbol="FAKEG1", + hgnc_id="HGNC:0", + ensembl_gene_id="ENSG00000000000", + uniprot_ids=["Q00000"], + ) + return ResolutionResult(query=query, match_status="exact", confidence_score=0.99, resolved_entity=gene) + + async def get_pathways(self, query, **kwargs): + if self.fail_pathways: + raise RuntimeError("simulated pathway outage") + return PathwayContext( + query=query, + pathways=[PathwayEntity(st_id="R-FAKE-1", name="Fake apoptosis signalling")], + total_pathways=1, + ) + + async def annotate_gene(self, query, **kwargs): + return FunctionalAnnotation( + query=query, + biological_processes=[GOAnnotation(go_id="GO:0000001", name="fake process", aspect="biological_process")], + molecular_functions=[GOAnnotation(go_id="GO:0000002", name="fake function", aspect="molecular_function")], + ) + + async def get_target_diseases(self, query, **kwargs): + return TargetAssociationContext( + query=query, + associations=[ + TargetDiseaseAssociation(disease_id="FAKE:0001", disease_name="fake colorectal cancer", score=0.8), + TargetDiseaseAssociation(disease_id="FAKE:0002", disease_name="fake cancerous lesion", score=0.5), + ], + ) + + async def get_supporting_publications(self, query, **kwargs): + return LiteratureContext( + query=query, + publications=[PublicationEntity(pmid="1", title="Guidelines for cancer care in general")], + ) + + +class StubResolverWithoutGO(StubResolver): + """[SYNTHETIC MOCK] Same as StubResolver but the GO method is missing, reproducing the v0.8.0 bug class.""" + + annotate_gene = None + + +@pytest.mark.asyncio +async def test_builder_reports_layer_failure_instead_of_hiding_it(): + builder = KnowledgeGraphBuilder(resolver=StubResolver(fail_pathways=True)) + sub = await builder.build_gene_subgraph("FAKEG1") + + assert sub.layer_status["pathways"].startswith("failed:") + assert any("Pathway layer failed" in w for w in sub.warnings) + assert sub.layer_status["go"] == "ok" + assert sub.layer_status["diseases"] == "ok" + assert not any(n.node_type == NodeType.PATHWAY for n in sub.nodes) + + +@pytest.mark.asyncio +async def test_builder_go_layer_failure_is_visible_when_method_missing(): + builder = KnowledgeGraphBuilder(resolver=StubResolverWithoutGO()) + sub = await builder.build_gene_subgraph("FAKEG1") + assert sub.layer_status["go"].startswith("failed:") + + +@pytest.mark.asyncio +async def test_builder_go_layer_adds_nodes_when_available(): + builder = KnowledgeGraphBuilder(resolver=StubResolver()) + sub = await builder.build_gene_subgraph("FAKEG1") + go_nodes = [n for n in sub.nodes if n.node_type == NodeType.GO_TERM] + assert len(go_nodes) == 2 + assert sub.layer_status["go"] == "ok" + + +@pytest.mark.asyncio +async def test_builder_does_not_invent_scores(): + builder = KnowledgeGraphBuilder(resolver=StubResolver()) + sub = await builder.build_gene_subgraph("FAKEG1") + + by_type = {} + for e in sub.edges: + by_type.setdefault(e.edge_type, []).append(e) + + assert all(e.weight is None for e in by_type[EdgeType.ENCODES]) + assert all(e.weight is None for e in by_type[EdgeType.PARTICIPATES_IN]) + assert all(e.weight is None for e in by_type[EdgeType.CITED_IN]) + assert all(e.properties["relation_basis"] == "keyword_search_hit" for e in by_type[EdgeType.CITED_IN]) + assert sorted(e.weight for e in by_type[EdgeType.ASSOCIATED_WITH]) == [0.5, 0.8] + + +@pytest.mark.asyncio +async def test_builder_unresolved_seed_warns_and_builds_no_layers(): + builder = KnowledgeGraphBuilder(resolver=StubResolver(resolvable=False)) + sub = await builder.build_gene_subgraph("NOTAGENE") + assert sub.root_node_id == "unresolved:NOTAGENE" + assert sub.total_nodes == 1 + assert sub.layer_status == {} + assert any("could not be resolved" in w for w in sub.warnings) + + +def _nodes(): + return [ + GraphNode(id="gene:FAKEG1", name="FAKEG1", node_type=NodeType.GENE), + GraphNode(id="disease:FAKE:0001", name="fake colorectal cancer", node_type=NodeType.DISEASE, + properties={"disease_id": "FAKE:0001"}), + GraphNode(id="disease:FAKE:0002", name="fake cancerous lesion", node_type=NodeType.DISEASE, + properties={"disease_id": "FAKE:0002"}), + GraphNode(id="pub:1", name="Guidelines for cancer care in general", node_type=NodeType.PUBLICATION, + properties={"pmid": "1"}), + GraphNode(id="pathway:R-FAKE-1", name="Fake apoptosis signalling", node_type=NodeType.PATHWAY, + properties={"st_id": "R-FAKE-1"}), + ] + + +def test_target_matching_uses_whole_words_and_excludes_publications(): + matched = {n.id for n in match_target_nodes(_nodes(), "cancer")} + assert matched == {"disease:FAKE:0001"} # not 'cancerous', not the publication title + + +def test_target_matching_by_identifier_and_type(): + assert [n.id for n in match_target_nodes(_nodes(), "FAKE:0002")] == ["disease:FAKE:0002"] + assert [n.id for n in match_target_nodes(_nodes(), "r-fake-1")] == ["pathway:R-FAKE-1"] + assert match_target_nodes(_nodes(), "cancer", target_type=NodeType.PATHWAY) == [] + pubs = match_target_nodes(_nodes(), "cancer", target_type=NodeType.PUBLICATION) + assert [n.id for n in pubs] == ["pub:1"] + + +def test_target_matching_ignores_free_text_properties(): + nodes = [GraphNode(id="x:1", name="alpha", node_type=NodeType.PATHWAY, properties={"note": "cancer"})] + assert match_target_nodes(nodes, "cancer") == [] + + +@pytest.mark.asyncio +async def test_traversal_scores_and_explanations(): + engine = TraversalEngine(resolver=StubResolver()) + result = await engine.traverse("FAKEG1", "cancer", max_hops=3, target_type=NodeType.DISEASE) + + assert result.matched_targets == ["disease:FAKE:0001"] + assert result.total_paths == 1 + path = result.paths_found[0] + assert path.cumulative_score == pytest.approx(0.8) + assert path.scored_hops == 1 + assert "score=0.80" in path.human_explanation + assert "source=OpenTargets" in path.human_explanation + + +@pytest.mark.asyncio +async def test_traversal_unscored_path_has_no_score(): + engine = TraversalEngine(resolver=StubResolver()) + result = await engine.traverse("FAKEG1", "R-FAKE-1", max_hops=3) + assert result.total_paths == 1 + path = result.paths_found[0] + assert path.cumulative_score is None + assert path.scored_hops == 0 + assert "unscored" in path.human_explanation + assert path.hop_count == 2 # gene -> protein -> pathway + + +@pytest.mark.asyncio +async def test_traversal_no_match_produces_warning(): + engine = TraversalEngine(resolver=StubResolver()) + result = await engine.traverse("FAKEG1", "nonexistent phrase") + assert result.total_paths == 0 + assert any("No node in the subgraph" in w for w in result.warnings) + + +@pytest.mark.asyncio +async def test_traversal_propagates_layer_warnings(): + engine = TraversalEngine(resolver=StubResolver(fail_pathways=True)) + result = await engine.traverse("FAKEG1", "cancer", target_type=NodeType.DISEASE) + assert any("Pathway layer failed" in w for w in result.warnings) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "kwargs", + [ + {"source_query": "", "target_query": "x"}, + {"source_query": "FAKEG1", "target_query": " "}, + {"source_query": "FAKEG1", "target_query": "x", "max_hops": 0}, + {"source_query": "FAKEG1", "target_query": "x", "max_hops": 99}, + {"source_query": "FAKEG1", "target_query": "x", "min_score": 1.5}, + ], +) +async def test_traversal_rejects_invalid_input(kwargs): + engine = TraversalEngine(resolver=StubResolver()) + with pytest.raises(ValueError): + await engine.traverse(**kwargs) + + +def test_store_find_paths_treats_unscored_edges_as_neutral_and_avoids_cycles(): + store = InMemoryGraphStore() + for nid in ("a", "b", "c"): + store.add_node(GraphNode(id=nid, name=nid, node_type=NodeType.GENE)) + store.add_edge(GraphEdge(source_id="a", target_id="b", edge_type=EdgeType.INTERACTS_WITH)) + store.add_edge(GraphEdge(source_id="b", target_id="c", edge_type=EdgeType.INTERACTS_WITH, weight=0.6)) + store.add_edge(GraphEdge(source_id="c", target_id="a", edge_type=EdgeType.INTERACTS_WITH, weight=0.9)) + + assert len(store.find_paths("a", "c", max_hops=2, min_cumulative_score=0.5)) == 1 + assert store.find_paths("a", "c", max_hops=2, min_cumulative_score=0.7) == [] + assert store.find_paths("a", "a", max_hops=3) == [] # no trivial or cyclic self path + + +def test_store_find_paths_validates_arguments(): + store = InMemoryGraphStore() + with pytest.raises(ValueError): + store.find_paths("a", "b", max_hops=0) + with pytest.raises(ValueError): + store.find_paths("a", "b", max_hops=InMemoryGraphStore.MAX_ALLOWED_HOPS + 1) + with pytest.raises(ValueError): + store.find_paths("a", "b", min_cumulative_score=-0.1) + + +def test_provenance_confidence_may_be_absent(): + assert Provenance(source_database="X").confidence is None + assert GraphEdge(source_id="a", target_id="b", edge_type=EdgeType.ENCODES).weight is None + + +def test_cli_accepts_documented_serve_command_and_legacy_alias(): + parser = build_parser() + assert parser.parse_args(["serve"]).command == "serve" + assert parser.parse_args(["server"]).command == "server" + + +@pytest.mark.asyncio +async def test_mcp_traverse_tool_returns_error_json_for_invalid_input(): + from biocontext.server import traverse_biological_graph + + bad_type = json.loads(await traverse_biological_graph("FAKEG1", "x", target_type="banana")) + assert "Invalid target_type" in bad_type["error"] + + bad_hops = json.loads(await traverse_biological_graph("FAKEG1", "x", max_hops=0)) + assert "max_hops" in bad_hops["error"] + + +class _Dumpable: + def __init__(self, payload): + self._payload = payload + + def model_dump(self): + return self._payload + + +class StubClient: + """[SYNTHETIC MOCK] BioContext client double for exercising the HTTP layer only.""" + + async def resolve_gene(self, symbol): + await asyncio.sleep(0.5) + return _Dumpable({"query": symbol}) + + async def get_protein(self, accession): + return None + + async def traverse(self, source, target, max_hops=3, min_score=0.0, target_type=None): + return _Dumpable({"source": source, "target": target, "max_hops": max_hops, + "min_score": min_score, "target_type": target_type.value if target_type else None}) + + +@pytest.fixture +def api_base(): + loop = start_event_loop_thread() + handler = type("TestHandler", (BioContextAPIHandler,), {"client": StubClient(), "loop": loop, "cors_origin": None}) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + server.daemon_threads = True + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_port}" + server.shutdown() + server.server_close() + loop.call_soon_threadsafe(loop.stop) + + +def _get(url): + try: + with urllib.request.urlopen(url, timeout=10) as resp: + return resp.status, dict(resp.headers), json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return e.code, dict(e.headers), json.loads(e.read().decode()) + + +@pytest.mark.parametrize( + "path,status", + [ + ("/v1/traverse", 400), + ("/v1/traverse?source=A", 400), + ("/v1/traverse?source=A&target=B&max_hops=abc", 400), + ("/v1/traverse?source=A&target=B&max_hops=0", 400), + ("/v1/traverse?source=A&target=B&max_hops=7", 400), + ("/v1/traverse?source=A&target=B&min_score=2", 400), + ("/v1/traverse?source=A&target=B&target_type=banana", 400), + ("/v1/gene/", 400), + ("/v1/protein/Q99999", 404), + ("/v1/unknown", 404), + ], +) +def test_api_rejects_bad_requests_with_json_errors(api_base, path, status): + code, _, body = _get(api_base + path) + assert code == status + assert "error" in body + + +def test_api_valid_traverse_passes_validated_parameters(api_base): + code, _, body = _get(api_base + "/v1/traverse?source=A&target=B&max_hops=2&min_score=0.5&target_type=Disease") + assert code == 200 + assert body == {"source": "A", "target": "B", "max_hops": 2, "min_score": 0.5, "target_type": "disease"} + + +def test_api_has_no_cors_header_by_default(api_base): + _, headers, _ = _get(api_base + "/health") + assert "Access-Control-Allow-Origin" not in headers + + +def test_api_serves_concurrent_requests_in_parallel(api_base): + results = [] + + def call(): + results.append(_get(api_base + "/v1/gene/FAKEG1")[0]) + + start = time.perf_counter() + threads = [threading.Thread(target=call) for _ in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + elapsed = time.perf_counter() - start + + assert results == [200, 200, 200, 200] + assert elapsed < 1.5, f"requests were serialised (elapsed={elapsed:.2f}s, serial would be ~2.0s)" diff --git a/tests/test_sdk_and_api.py b/tests/test_sdk_and_api.py index f93531e..4c8f95b 100644 --- a/tests/test_sdk_and_api.py +++ b/tests/test_sdk_and_api.py @@ -1,7 +1,5 @@ -"""Unit and integration tests for the unified Python SDK and REST API v1 (PRD-18).""" - import pytest -from biocontext import BioContext +from biocontext import BioContext, NodeType from biocontext.api import BioContextAPIHandler from http import HTTPStatus @@ -27,7 +25,7 @@ async def test_sdk_client_subgraph_and_traverse(): assert subgraph.root_node_id == "gene:TP53" assert subgraph.total_nodes > 1 - traversal = await bc.traverse("TP53", "cancer", max_hops=2) + traversal = await bc.traverse("TP53", "cancer", target_type=NodeType.DISEASE, max_hops=3) assert traversal.source_query == "TP53" assert traversal.total_paths >= 1 assert "TP53" in traversal.paths_found[0].human_explanation @@ -37,10 +35,10 @@ def test_api_handler_health(): import urllib.request import threading from http.server import HTTPServer - import asyncio + from biocontext.api import API_VERSION, start_event_loop_thread import json - loop = asyncio.new_event_loop() + loop = start_event_loop_thread() client = BioContext() BioContextAPIHandler.client = client BioContextAPIHandler.loop = loop @@ -57,7 +55,7 @@ def test_api_handler_health(): assert response.status == 200 data = json.loads(response.read().decode()) assert data["status"] == "ok" - assert data["version"] == "1.0.0" + assert data["version"] == API_VERSION finally: server.shutdown() server.server_close() diff --git a/uv.lock b/uv.lock index 13aec51..1e8d921 100644 --- a/uv.lock +++ b/uv.lock @@ -39,15 +39,20 @@ wheels = [ ] [[package]] -name = "biocontext" -version = "0.1.0" +name = "biocontext-mcp" +version = "1.0.1" source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "mcp" }, { name = "pydantic" }, +] + +[package.dev-dependencies] +dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-rerunfailures" }, ] [package.metadata] @@ -55,8 +60,13 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", specifier = ">=2.2.0" }, { name = "pydantic", specifier = ">=2.13.5" }, +] + +[package.metadata.requires-dev] +dev = [ { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "pytest-rerunfailures", specifier = ">=14.0" }, ] [[package]] @@ -607,6 +617,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "pytest-rerunfailures" +version = "16.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/b0/6b5337f9d59b26b0069ea3d5e863c31dc04b69e51bbfb1cf2ea6328fba87/pytest_rerunfailures-16.7.tar.gz", hash = "sha256:6956ddfb65ca1d07e7e3d99e2c5359d82300f4cb062e6049563bd2c106f72d5c", size = 59666, upload-time = "2026-09-17T07:08:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d3/07ea35102cf2020ddaaac368d2a6f0bcc63d6523fb8918805bac3eb8b98f/pytest_rerunfailures-16.7-py3-none-any.whl", hash = "sha256:edf1886209c2b7dafe35b5bf1708d6ec40ccf6c6b357f0f02807efcec0204c99", size = 23952, upload-time = "2026-09-17T07:08:47.635Z" }, +] + [[package]] name = "python-multipart" version = "0.0.32"