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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 1 addition & 35 deletions ontokit/api/routes/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, status

from ontokit.schemas.graph import EntityGraphResponse
from ontokit.schemas.owl_class import (
OWLClassCreate,
OWLClassListResponse,
Expand Down Expand Up @@ -58,39 +57,6 @@ async def create_class(
return await service.create_class(ontology_id, owl_class)


@router.get(
"/ontologies/{ontology_id}/classes/graph",
response_model=EntityGraphResponse,
)
async def get_class_graph(
ontology_id: UUID,
service: Annotated[OntologyService, Depends(get_ontology_service)],
class_iri: str = Query(description="IRI of the class to build the graph around"),
branch: str = "main",
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 = True,
) -> EntityGraphResponse:
"""Build a multi-hop entity graph around a class via BFS.

Returns nodes and edges for visualization, with lineage-based node types
for ontology-agnostic coloring (root, ancestor, focus, descendant, etc.).
"""
result = await service.build_entity_graph(
ontology_id,
class_iri,
branch=branch,
ancestors_depth=ancestors_depth,
descendants_depth=descendants_depth,
max_nodes=max_nodes,
include_see_also=include_see_also,
)
if result is None:
raise HTTPException(status_code=404, detail="Class not found")
return result


@router.get("/ontologies/{ontology_id}/classes/{class_iri:path}", response_model=OWLClassResponse)
async def get_class(
ontology_id: UUID,
Expand Down
5 changes: 4 additions & 1 deletion ontokit/api/routes/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,9 @@ async def get_ontology_class_graph(
Returns nodes and edges for visualization, with lineage-based node types.
"""
resolved_branch = branch or git.get_default_branch(project_id)
await _ensure_ontology_loaded(project_id, service, ontology, user, resolved_branch, git)
project = await _ensure_ontology_loaded(
project_id, service, ontology, user, resolved_branch, git
)

result = await ontology.build_entity_graph(
project_id,
Expand All @@ -662,6 +664,7 @@ async def get_ontology_class_graph(
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(
Expand Down
26 changes: 24 additions & 2 deletions ontokit/schemas/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,30 @@

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."""
Expand All @@ -15,7 +37,7 @@ class GraphNode(BaseModel):
is_focus: bool = False
is_root: bool = False
depth: int = 0
node_type: str = "class"
node_type: GraphNodeType = "class"
child_count: int | None = None


Expand All @@ -25,7 +47,7 @@ class GraphEdge(BaseModel):
id: str
source: str
target: str
edge_type: str
edge_type: GraphEdgeType
label: str | None = None


Expand Down
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)

# 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