Skip to content

Commit 84e164f

Browse files
damienriehlclaude
authored andcommitted
feat: add server-side BFS entity graph endpoint
Add GET /projects/{id}/ontology/classes/{iri}/graph endpoint that builds a multi-hop entity graph via BFS traversal. Returns nodes and edges for visualization with lineage-based node types (focus, root, class, etc.). Configurable: ancestors_depth, descendants_depth, max_nodes, include_see_also. Truncation detection when node count exceeds max_nodes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 29ab902 commit 84e164f

4 files changed

Lines changed: 351 additions & 1 deletion

File tree

ontokit/api/routes/classes.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
from typing import Annotated
44
from uuid import UUID
55

6-
from fastapi import APIRouter, Depends, HTTPException, status
6+
from fastapi import APIRouter, Depends, HTTPException, Query, status
77

8+
from ontokit.schemas.graph import EntityGraphResponse
89
from ontokit.schemas.owl_class import (
910
OWLClassCreate,
1011
OWLClassListResponse,
@@ -120,3 +121,36 @@ async def get_class_hierarchy(
120121
direction=direction,
121122
depth=depth,
122123
)
124+
125+
126+
@router.get(
127+
"/ontologies/{ontology_id}/classes/{class_iri:path}/graph",
128+
response_model=EntityGraphResponse,
129+
)
130+
async def get_class_graph(
131+
ontology_id: UUID,
132+
class_iri: str,
133+
service: Annotated[OntologyService, Depends(get_ontology_service)],
134+
branch: str = "main",
135+
ancestors_depth: int = Query(default=5, ge=0, le=10),
136+
descendants_depth: int = Query(default=2, ge=0, le=10),
137+
max_nodes: int = Query(default=200, ge=1, le=500),
138+
include_see_also: bool = True,
139+
) -> EntityGraphResponse:
140+
"""Build a multi-hop entity graph around a class via BFS.
141+
142+
Returns nodes and edges for visualization, with lineage-based node types
143+
for ontology-agnostic coloring (root, ancestor, focus, descendant, etc.).
144+
"""
145+
result = await service.build_entity_graph(
146+
ontology_id,
147+
class_iri,
148+
branch=branch,
149+
ancestors_depth=ancestors_depth,
150+
descendants_depth=descendants_depth,
151+
max_nodes=max_nodes,
152+
include_see_also=include_see_also,
153+
)
154+
if result is None:
155+
raise HTTPException(status_code=404, detail="Class not found")
156+
return result

ontokit/api/routes/projects.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from ontokit.models.branch_metadata import BranchMetadata
2828
from ontokit.models.pull_request import GitHubIntegration, PRStatus, PullRequest
2929
from ontokit.models.user_github_token import UserGitHubToken
30+
from ontokit.schemas.graph import EntityGraphResponse
3031
from ontokit.schemas.owl_class import EntitySearchResponse, OWLClassResponse, OWLClassTreeResponse
3132
from ontokit.schemas.project import (
3233
BranchCreate,
@@ -694,6 +695,49 @@ async def get_ontology_class_ancestors(
694695
return OWLClassTreeResponse(nodes=nodes, total_classes=total_classes)
695696

696697

698+
@router.get(
699+
"/{project_id}/ontology/classes/{class_iri:path}/graph",
700+
response_model=EntityGraphResponse,
701+
)
702+
async def get_ontology_class_graph(
703+
project_id: UUID,
704+
class_iri: str,
705+
service: Annotated[ProjectService, Depends(get_service)],
706+
ontology: Annotated[OntologyService, Depends(get_ontology)],
707+
git: Annotated[GitRepositoryService, Depends(get_git)],
708+
user: OptionalUser,
709+
branch: str | None = Query(default=None, description="Branch to read from"),
710+
ancestors_depth: int = Query(default=5, ge=0, le=10),
711+
descendants_depth: int = Query(default=2, ge=0, le=10),
712+
max_nodes: int = Query(default=200, ge=1, le=500),
713+
include_see_also: bool = Query(default=True),
714+
) -> EntityGraphResponse:
715+
"""Build a multi-hop entity graph around a class via BFS.
716+
717+
Returns nodes and edges for visualization, with lineage-based node types.
718+
"""
719+
resolved_branch = branch or git.get_default_branch(project_id)
720+
await _ensure_ontology_loaded(
721+
project_id, service, ontology, user, resolved_branch, git
722+
)
723+
724+
result = await ontology.build_entity_graph(
725+
project_id,
726+
class_iri,
727+
branch=resolved_branch,
728+
ancestors_depth=ancestors_depth,
729+
descendants_depth=descendants_depth,
730+
max_nodes=max_nodes,
731+
include_see_also=include_see_also,
732+
)
733+
if result is None:
734+
raise HTTPException(
735+
status_code=status.HTTP_404_NOT_FOUND,
736+
detail=f"Class not found: {class_iri}",
737+
)
738+
return result
739+
740+
697741
@router.get("/{project_id}/ontology/search", response_model=EntitySearchResponse)
698742
async def search_ontology_entities(
699743
project_id: UUID,

ontokit/schemas/graph.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Pydantic models for the Entity Graph API."""
2+
3+
from __future__ import annotations
4+
5+
from pydantic import BaseModel
6+
7+
8+
class GraphNode(BaseModel):
9+
"""A node in the entity graph."""
10+
11+
id: str
12+
label: str
13+
iri: str
14+
definition: str | None = None
15+
is_focus: bool = False
16+
is_root: bool = False
17+
depth: int = 0
18+
node_type: str = "class"
19+
child_count: int | None = None
20+
21+
22+
class GraphEdge(BaseModel):
23+
"""An edge in the entity graph."""
24+
25+
id: str
26+
source: str
27+
target: str
28+
edge_type: str
29+
label: str | None = None
30+
31+
32+
class EntityGraphResponse(BaseModel):
33+
"""Complete graph response."""
34+
35+
focus_iri: str
36+
focus_label: str
37+
nodes: list[GraphNode]
38+
edges: list[GraphEdge]
39+
truncated: bool = False
40+
total_concept_count: int = 0

ontokit/services/ontology.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,238 @@ async def get_class_hierarchy(
351351
# TODO: Implement hierarchy traversal
352352
raise NotImplementedError("Hierarchy implementation pending")
353353

354+
async def build_entity_graph(
355+
self,
356+
ontology_id: UUID,
357+
class_iri: str,
358+
branch: str = "main",
359+
ancestors_depth: int = 5,
360+
descendants_depth: int = 2,
361+
max_nodes: int = 200,
362+
include_see_also: bool = True,
363+
max_see_also_per_node: int = 5,
364+
) -> "EntityGraphResponse | None":
365+
"""Build a multi-hop graph around a class via BFS.
366+
367+
Traverses ancestors (subClassOf upward), descendants (subClassOf downward),
368+
and optional seeAlso cross-links. Returns nodes with lineage-based types
369+
for ontology-agnostic coloring.
370+
"""
371+
from ontokit.schemas.graph import EntityGraphResponse, GraphEdge, GraphNode
372+
373+
graph = await self._get_graph(ontology_id, branch)
374+
class_uri = URIRef(class_iri)
375+
376+
if (class_uri, RDF.type, OWL.Class) not in graph:
377+
return None
378+
379+
owl_thing = OWL.Thing
380+
EXTERNAL_NAMESPACES = (
381+
"http://www.w3.org/2000/01/rdf-schema#",
382+
"http://www.w3.org/2002/07/owl#",
383+
"http://xmlns.com/foaf/0.1/",
384+
"http://purl.org/dc/elements/1.1/",
385+
"http://purl.org/dc/terms/",
386+
"http://www.w3.org/2004/02/skos/core#",
387+
)
388+
389+
visited: dict[str, GraphNode] = {}
390+
edges: list[GraphEdge] = []
391+
edge_ids: set[str] = set()
392+
total_discovered = [0]
393+
394+
def _get_local_name(iri: str) -> str:
395+
if "#" in iri:
396+
return iri.split("#")[-1]
397+
return iri.rsplit("/", 1)[-1]
398+
399+
def _get_label(uri: URIRef) -> str:
400+
label = select_preferred_label(graph, uri)
401+
return label if label else _get_local_name(str(uri))
402+
403+
def _is_external(iri: str) -> bool:
404+
return any(iri.startswith(ns) for ns in EXTERNAL_NAMESPACES)
405+
406+
def _is_root_class(uri: URIRef) -> bool:
407+
parents = [
408+
p for p in graph.objects(uri, RDFS.subClassOf)
409+
if isinstance(p, URIRef) and p != owl_thing
410+
]
411+
return len(parents) == 0
412+
413+
def _classify_node(uri: URIRef, is_focus: bool, depth: int) -> str:
414+
iri = str(uri)
415+
if is_focus:
416+
return "focus"
417+
if _is_external(iri):
418+
return "external"
419+
# Check if individual (instance, not a class)
420+
if (uri, RDF.type, OWL.Class) not in graph:
421+
for rdf_type in graph.objects(uri, RDF.type):
422+
if rdf_type in (OWL.ObjectProperty, OWL.DatatypeProperty, OWL.AnnotationProperty):
423+
return "property"
424+
return "individual"
425+
if _is_root_class(uri):
426+
return "root"
427+
return "class"
428+
429+
def _get_definition(uri: URIRef) -> str | None:
430+
# Try SKOS definition first, then rdfs:comment
431+
for obj in graph.objects(uri, SKOS.definition):
432+
if isinstance(obj, RDFLiteral):
433+
return str(obj)
434+
for obj in graph.objects(uri, RDFS.comment):
435+
if isinstance(obj, RDFLiteral):
436+
return str(obj)
437+
return None
438+
439+
def _child_count(uri: URIRef) -> int:
440+
return sum(
441+
1 for s in graph.subjects(RDFS.subClassOf, uri)
442+
if isinstance(s, URIRef) and (s, RDF.type, OWL.Class) in graph
443+
)
444+
445+
def _make_node(uri: URIRef, depth: int) -> GraphNode | None:
446+
iri = str(uri)
447+
if iri in visited:
448+
return visited[iri]
449+
total_discovered[0] += 1
450+
if len(visited) >= max_nodes:
451+
return None
452+
is_focus = (uri == class_uri)
453+
node = GraphNode(
454+
id=iri,
455+
label=_get_label(uri),
456+
iri=iri,
457+
definition=_get_definition(uri),
458+
is_focus=is_focus,
459+
is_root=_is_root_class(uri),
460+
depth=depth,
461+
node_type=_classify_node(uri, is_focus, depth),
462+
child_count=_child_count(uri),
463+
)
464+
visited[iri] = node
465+
return node
466+
467+
def _add_edge(source: str, target: str, edge_type: str, label: str | None = None) -> None:
468+
eid = f"{source}->{target}:{edge_type}"
469+
if eid in edge_ids:
470+
return
471+
edge_ids.add(eid)
472+
edges.append(GraphEdge(id=eid, source=source, target=target, edge_type=edge_type, label=label))
473+
474+
# Create focus node
475+
focus_node = _make_node(class_uri, 0)
476+
if not focus_node:
477+
return None
478+
479+
# BFS upward (ancestors)
480+
ancestor_queue: list[tuple[URIRef, int]] = [(class_uri, 0)]
481+
ancestor_visited: set[str] = {class_iri}
482+
while ancestor_queue:
483+
current_uri, current_depth = ancestor_queue.pop(0)
484+
if current_depth >= ancestors_depth:
485+
continue
486+
for parent in graph.objects(current_uri, RDFS.subClassOf):
487+
if not isinstance(parent, URIRef) or parent == owl_thing:
488+
continue
489+
parent_iri = str(parent)
490+
parent_node = _make_node(parent, -(current_depth + 1))
491+
if parent_node is None:
492+
continue
493+
_add_edge(parent_iri, str(current_uri), "subClassOf")
494+
if parent_iri not in ancestor_visited:
495+
ancestor_visited.add(parent_iri)
496+
ancestor_queue.append((parent, current_depth + 1))
497+
498+
# BFS downward (descendants)
499+
descendant_queue: list[tuple[URIRef, int]] = [(class_uri, 0)]
500+
descendant_visited: set[str] = {class_iri}
501+
while descendant_queue:
502+
current_uri, current_depth = descendant_queue.pop(0)
503+
if current_depth >= descendants_depth:
504+
continue
505+
for child in graph.subjects(RDFS.subClassOf, current_uri):
506+
if not isinstance(child, URIRef):
507+
continue
508+
child_iri = str(child)
509+
child_node = _make_node(child, current_depth + 1)
510+
if child_node is None:
511+
continue
512+
_add_edge(str(current_uri), child_iri, "subClassOf")
513+
if child_iri not in descendant_visited:
514+
descendant_visited.add(child_iri)
515+
descendant_queue.append((child, current_depth + 1))
516+
517+
# Collect equivalentClass and disjointWith for visited nodes
518+
for node_iri in list(visited.keys()):
519+
node_uri = URIRef(node_iri)
520+
for equiv in graph.objects(node_uri, OWL.equivalentClass):
521+
if isinstance(equiv, URIRef) and str(equiv) in visited:
522+
if node_iri < str(equiv):
523+
_add_edge(node_iri, str(equiv), "equivalentClass", "equivalentTo")
524+
else:
525+
_add_edge(str(equiv), node_iri, "equivalentClass", "equivalentTo")
526+
for disj in graph.objects(node_uri, OWL.disjointWith):
527+
if isinstance(disj, URIRef) and str(disj) in visited:
528+
if node_iri < str(disj):
529+
_add_edge(node_iri, str(disj), "disjointWith", "disjointWith")
530+
else:
531+
_add_edge(str(disj), node_iri, "disjointWith", "disjointWith")
532+
533+
# Collect seeAlso cross-links
534+
see_also_nodes: list[URIRef] = []
535+
if include_see_also:
536+
for node_iri in list(visited.keys()):
537+
node_uri = URIRef(node_iri)
538+
sa_count = 0
539+
for related in graph.objects(node_uri, RDFS.seeAlso):
540+
if not isinstance(related, URIRef) or sa_count >= max_see_also_per_node:
541+
break
542+
related_iri = str(related)
543+
was_new = related_iri not in visited
544+
if was_new:
545+
related_node = _make_node(related, 0)
546+
if related_node is None:
547+
continue
548+
see_also_nodes.append(related)
549+
if node_iri < related_iri:
550+
_add_edge(node_iri, related_iri, "seeAlso", "rdfs:seeAlso")
551+
else:
552+
_add_edge(related_iri, node_iri, "seeAlso", "rdfs:seeAlso")
553+
sa_count += 1
554+
555+
# BFS upward from seeAlso nodes to their roots
556+
if see_also_nodes:
557+
sa_queue: list[tuple[URIRef, int]] = [(u, 0) for u in see_also_nodes]
558+
sa_visited: set[str] = {str(u) for u in see_also_nodes} | ancestor_visited
559+
while sa_queue:
560+
current_uri, current_depth = sa_queue.pop(0)
561+
if current_depth >= ancestors_depth:
562+
continue
563+
for parent in graph.objects(current_uri, RDFS.subClassOf):
564+
if not isinstance(parent, URIRef) or parent == owl_thing:
565+
continue
566+
parent_iri = str(parent)
567+
parent_node = _make_node(parent, -(current_depth + 1))
568+
if parent_node is None:
569+
continue
570+
_add_edge(parent_iri, str(current_uri), "subClassOf")
571+
if parent_iri not in sa_visited:
572+
sa_visited.add(parent_iri)
573+
sa_queue.append((parent, current_depth + 1))
574+
575+
truncated = total_discovered[0] > len(visited)
576+
577+
return EntityGraphResponse(
578+
focus_iri=class_iri,
579+
focus_label=_get_label(class_uri),
580+
nodes=list(visited.values()),
581+
edges=edges,
582+
truncated=truncated,
583+
total_concept_count=total_discovered[0],
584+
)
585+
354586
async def get_root_classes(
355587
self,
356588
project_id: UUID,

0 commit comments

Comments
 (0)