Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
458992a
feat: add server-side BFS entity graph endpoint
damienriehl Apr 6, 2026
f2bd047
fix(graph): include reverse seeAlso connections in BFS traversal
damienriehl Apr 6, 2026
fa6b26d
fix: use query params for graph routes to avoid greedy :path capture
JohnRDOrazio Apr 11, 2026
b3a04fc
fix: add input validation to build_entity_graph parameters
JohnRDOrazio Apr 11, 2026
bc0f56c
fix: include allValuesFrom and hasValue in reverse seeAlso discovery
JohnRDOrazio Apr 11, 2026
e1dfe13
fix: only compute is_root for class-type nodes in entity graph
JohnRDOrazio Apr 11, 2026
abba247
test: make classification assertions non-conditional and exact
JohnRDOrazio Apr 11, 2026
9ae0dd4
fix: only count seeAlso edges toward per-node budget when actually added
JohnRDOrazio Apr 11, 2026
964a086
test: add coverage for input validation and reverse restriction variants
JohnRDOrazio Apr 11, 2026
5634ecb
test: add route-level tests for entity graph endpoints
JohnRDOrazio Apr 11, 2026
cab395f
test: cover remaining edge-case branches in build_entity_graph
JohnRDOrazio Apr 11, 2026
7a70638
refactor: remove dead focus-node guard in build_entity_graph
JohnRDOrazio Apr 11, 2026
9b2f96e
perf: use deque for BFS queues in build_entity_graph
JohnRDOrazio Apr 11, 2026
38f8713
fix: prevent double-counting in total_discovered and ensure seeAlso a…
JohnRDOrazio Apr 12, 2026
3eef6ba
test: fix duplicate seeAlso edge test to actually trigger dedup path
JohnRDOrazio Apr 12, 2026
c2644c0
refactor: move EXTERNAL_NAMESPACES to module level and deduplicate se…
JohnRDOrazio Apr 12, 2026
533452a
fix(graph): delete unauthed /ontologies/{id}/classes/graph route (B1)
damienriehl Apr 25, 2026
9c6bcee
fix(graph): tighten node_type/edge_type to Literal unions (H1)
damienriehl Apr 25, 2026
236aad7
refactor(graph): extract seeAlso helpers to module scope (H4)
damienriehl Apr 25, 2026
29868b9
fix(graph): thread label_preferences through entity graph (B2/H2/H3/H4)
damienriehl Apr 25, 2026
1feeea8
test(graph): cover label_preferences threading and definition languag…
damienriehl May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 0 additions & 23 deletions ontokit/api/routes/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,26 +97,3 @@ async def delete_class(
deleted = await service.delete_class(ontology_id, class_iri)
if not deleted:
raise HTTPException(status_code=404, detail="Class not found")


@router.get("/ontologies/{ontology_id}/classes/{class_iri:path}/hierarchy")
async def get_class_hierarchy(
ontology_id: UUID,
class_iri: str,
service: Annotated[OntologyService, Depends(get_ontology_service)],
direction: str = "both",
depth: int = 3,
) -> dict[str, object]:
"""
Get the class hierarchy around a specific class.

Args:
direction: 'ancestors', 'descendants', or 'both'
depth: Maximum depth to traverse
"""
return await service.get_class_hierarchy(
ontology_id,
class_iri,
direction=direction,
depth=depth,
)
45 changes: 45 additions & 0 deletions ontokit/api/routes/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from ontokit.models.branch_metadata import BranchMetadata
from ontokit.models.pull_request import GitHubIntegration, PRStatus, PullRequest
from ontokit.models.user_github_token import UserGitHubToken
from ontokit.schemas.graph import EntityGraphResponse
from ontokit.schemas.owl_class import EntitySearchResponse, OWLClassResponse, OWLClassTreeResponse
from ontokit.schemas.project import (
BranchCreate,
Expand Down Expand Up @@ -656,6 +657,50 @@ async def get_ontology_tree_children(
return OWLClassTreeResponse(nodes=nodes, total_classes=total_classes)


@router.get(
"/{project_id}/ontology/classes/graph",
response_model=EntityGraphResponse,
)
async def get_ontology_class_graph(
project_id: UUID,
service: Annotated[ProjectService, Depends(get_service)],
ontology: Annotated[OntologyService, Depends(get_ontology)],
git: Annotated[GitRepositoryService, Depends(get_git)],
user: OptionalUser,
class_iri: str = Query(description="IRI of the class to build the graph around"),
branch: str | None = Query(default=None, description="Branch to read from"),
ancestors_depth: int = Query(default=5, ge=0, le=10),
descendants_depth: int = Query(default=2, ge=0, le=10),
max_nodes: int = Query(default=200, ge=1, le=500),
include_see_also: bool = Query(default=True),
) -> EntityGraphResponse:
"""Build a multi-hop entity graph around a class via BFS.

Returns nodes and edges for visualization, with lineage-based node types.
"""
resolved_branch = branch or git.get_default_branch(project_id)
project = await _ensure_ontology_loaded(
project_id, service, ontology, user, resolved_branch, git
)

result = await ontology.build_entity_graph(
project_id,
class_iri,
branch=resolved_branch,
ancestors_depth=ancestors_depth,
descendants_depth=descendants_depth,
max_nodes=max_nodes,
include_see_also=include_see_also,
label_preferences=project.label_preferences,
)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Class not found: {class_iri}",
)
return result


@router.get("/{project_id}/ontology/classes/{class_iri:path}", response_model=OWLClassResponse)
async def get_ontology_class(
project_id: UUID,
Expand Down
62 changes: 62 additions & 0 deletions ontokit/schemas/graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Pydantic models for the Entity Graph API."""

from __future__ import annotations

from typing import Literal

from pydantic import BaseModel

# Node type values produced by the BFS in `OntologyService.build_entity_graph`.
# Frontend mirror: `GraphNodeType` in `lib/graph/types.ts`.
GraphNodeType = Literal[
"focus",
"root",
"secondary_root",
"class",
"individual",
"property",
"external",
]

# Edge type values produced by the BFS. Frontend mirror: `GraphEdgeType`.
GraphEdgeType = Literal[
"subClassOf",
"equivalentClass",
"disjointWith",
"seeAlso",
]


class GraphNode(BaseModel):
"""A node in the entity graph."""

id: str
label: str
iri: str
definition: str | None = None
is_focus: bool = False
is_root: bool = False
depth: int = 0
node_type: GraphNodeType = "class"
child_count: int | None = None


class GraphEdge(BaseModel):
"""An edge in the entity graph."""

id: str
source: str
target: str
edge_type: GraphEdgeType
label: str | None = None


class EntityGraphResponse(BaseModel):
"""Complete graph response."""

focus_iri: str
focus_label: str
nodes: list[GraphNode]
edges: list[GraphEdge]
truncated: bool = False
total_concept_count: int = 0
76 changes: 76 additions & 0 deletions ontokit/services/entity_graph_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Helpers for entity-graph BFS — extracted from `OntologyService.build_entity_graph`.

These functions live at module scope so they can be unit-tested directly without
constructing an `OntologyService` and a loaded ontology graph.
"""

from __future__ import annotations

from rdflib import Graph, URIRef
from rdflib.namespace import OWL, RDF, RDFS


def get_see_also_targets(graph: Graph, uri: URIRef) -> list[URIRef]:
"""Extract seeAlso targets from both direct triples and OWL restrictions.

FOLIO encodes seeAlso as ``owl:Restriction`` with ``owl:someValuesFrom``
inside ``rdfs:subClassOf``, not as direct ``rdfs:seeAlso`` triples — both
forms are returned, deduplicated, in discovery order.
"""
seen: set[URIRef] = set()
targets: list[URIRef] = []

def _add(ref: URIRef) -> None:
if ref not in seen:
seen.add(ref)
targets.append(ref)

# Direct rdfs:seeAlso triples
for obj in graph.objects(uri, RDFS.seeAlso):
if isinstance(obj, URIRef):
_add(obj)

# OWL restrictions: subClassOf -> Restriction(onProperty=seeAlso, someValuesFrom=X)
for sc in graph.objects(uri, RDFS.subClassOf):
if isinstance(sc, URIRef):
continue # Named superclass, not a restriction
# sc is a blank node (restriction)
on_prop = next(graph.objects(sc, OWL.onProperty), None)
if on_prop == RDFS.seeAlso:
for predicate in (OWL.someValuesFrom, OWL.allValuesFrom, OWL.hasValue):
for val in graph.objects(sc, predicate):
if isinstance(val, URIRef):
_add(val)

return targets


def get_see_also_referrers(graph: Graph, uri: URIRef) -> list[URIRef]:
"""Find classes that reference ``uri`` via seeAlso (direct or restriction).

Reverse of :func:`get_see_also_targets`. Only returns classes (subjects with
``rdf:type owl:Class``) so callers don't surface arbitrary blank nodes.
"""
seen: set[URIRef] = set()
referrers: list[URIRef] = []

def _add(ref: URIRef) -> None:
if ref not in seen:
seen.add(ref)
referrers.append(ref)

# Direct reverse rdfs:seeAlso
for subj in graph.subjects(RDFS.seeAlso, uri):
if isinstance(subj, URIRef):
_add(subj)
Comment on lines +63 to +65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make direct and restriction referrer filtering agree.

The direct reverse branch adds any URIRef, while the restriction branch only returns owl:Class subjects. That means the graph shape changes based on RDF encoding for the same seeAlso link, and it also contradicts this helper’s docstring. Either filter the direct path to classes too, or relax the contract/tests in both places.

Suggested fix
     # Direct reverse rdfs:seeAlso
     for subj in graph.subjects(RDFS.seeAlso, uri):
-        if isinstance(subj, URIRef):
+        if isinstance(subj, URIRef) and (subj, RDF.type, OWL.Class) in graph:
             _add(subj)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ontokit/services/entity_graph_helpers.py` around lines 63 - 65, The direct
reverse branch currently adds any URIRef found via graph.subjects(RDFS.seeAlso,
uri) which disagrees with the restriction branch that only includes owl:Class
subjects; update that loop so it only calls _add(subj) for subjects that are
URIRef and have an RDF.type of OWL.Class (e.g., check isinstance(subj, URIRef)
and that OWL.Class appears in graph.objects(subj, RDF.type)) so both branches
return the same set of class referrers.


# Find restrictions that reference uri via someValuesFrom/allValuesFrom/hasValue
for predicate in (OWL.someValuesFrom, OWL.allValuesFrom, OWL.hasValue):
for restriction in graph.subjects(predicate, uri):
on_prop = next(graph.objects(restriction, OWL.onProperty), None)
if on_prop == RDFS.seeAlso:
for cls in graph.subjects(RDFS.subClassOf, restriction):
if isinstance(cls, URIRef) and (cls, RDF.type, OWL.Class) in graph:
_add(cls)

return referrers
Loading
Loading