From fdc51b1d1c44c6a67f1900d797009993ce05f782 Mon Sep 17 00:00:00 2001 From: damienriehl Date: Sat, 25 Apr 2026 10:58:11 -0500 Subject: [PATCH 1/4] fix(graph): delete unauthed /ontologies/{id}/classes/graph route (B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint had no auth dependency and constructed a fresh OntologyService per request via get_ontology_service — that service never loads any graph, so calling the endpoint would raise ValueError("Graph for project ... not loaded") and 500 the request. The frontend uses the project-scoped route at /projects/{id}/ontology/classes/graph instead, which is correctly auth'd via verify_project_access and goes through _ensure_ontology_loaded. Removes the dead route + its companion test class. Co-Authored-By: Claude Opus 4.7 --- ontokit/api/routes/classes.py | 36 +------------------------------ tests/unit/test_graph_routes.py | 38 --------------------------------- 2 files changed, 1 insertion(+), 73 deletions(-) diff --git a/ontokit/api/routes/classes.py b/ontokit/api/routes/classes.py index e81dc499..f3902362 100644 --- a/ontokit/api/routes/classes.py +++ b/ontokit/api/routes/classes.py @@ -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, @@ -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, diff --git a/tests/unit/test_graph_routes.py b/tests/unit/test_graph_routes.py index 98d5110a..f9612ca0 100644 --- a/tests/unit/test_graph_routes.py +++ b/tests/unit/test_graph_routes.py @@ -9,7 +9,6 @@ import pytest from fastapi.testclient import TestClient -from ontokit.api.routes.classes import get_ontology_service from ontokit.api.routes.projects import get_git, get_ontology, get_service from ontokit.main import app from ontokit.schemas.graph import EntityGraphResponse, GraphNode @@ -31,43 +30,6 @@ def _sample_graph_response() -> EntityGraphResponse: ) -# --------------------------------------------------------------------------- -# classes.py — GET /api/v1/ontologies/{id}/classes/graph -# --------------------------------------------------------------------------- - - -class TestClassesGraphRoute: - @pytest.fixture - def mock_ontology_svc(self) -> Generator[AsyncMock, None, None]: - mock_svc = AsyncMock() - app.dependency_overrides[get_ontology_service] = lambda: mock_svc - try: - yield mock_svc - finally: - app.dependency_overrides.pop(get_ontology_service, None) - - def test_graph_success(self, mock_ontology_svc: AsyncMock) -> None: - mock_ontology_svc.build_entity_graph = AsyncMock(return_value=_sample_graph_response()) - client = TestClient(app, raise_server_exceptions=False) - resp = client.get( - f"/api/v1/ontologies/{PROJECT_ID}/classes/graph", - params={"class_iri": FOCUS_IRI}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["focus_iri"] == FOCUS_IRI - assert len(data["nodes"]) == 1 - - def test_graph_not_found(self, mock_ontology_svc: AsyncMock) -> None: - mock_ontology_svc.build_entity_graph = AsyncMock(return_value=None) - client = TestClient(app, raise_server_exceptions=False) - resp = client.get( - f"/api/v1/ontologies/{PROJECT_ID}/classes/graph", - params={"class_iri": "http://example.org/Missing"}, - ) - assert resp.status_code == 404 - - # --------------------------------------------------------------------------- # projects.py — GET /api/v1/projects/{id}/ontology/classes/graph # --------------------------------------------------------------------------- From 92ca7e88fabdd8754ff96593e9804f0d56576d69 Mon Sep 17 00:00:00 2001 From: damienriehl Date: Sat, 25 Apr 2026 10:58:20 -0500 Subject: [PATCH 2/4] fix(graph): tighten node_type/edge_type to Literal unions (H1) Schema previously typed node_type/edge_type as `str`, but the BFS only ever emits one of: focus / root / secondary_root / class / individual / property / external for nodes, and subClassOf / equivalentClass / disjointWith / seeAlso for edges. Frontend already has GraphNodeType / GraphEdgeType union types in lib/graph/types.ts; the API contract should match. Adds GraphNodeType and GraphEdgeType Literal aliases. Pydantic enforces them at serialization, OpenAPI generates proper enums for clients, and a typo in the service code (e.g., "focuss") fails type-check instead of shipping silently. Co-Authored-By: Claude Opus 4.7 --- ontokit/schemas/graph.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/ontokit/schemas/graph.py b/ontokit/schemas/graph.py index 2464530e..4fee1447 100644 --- a/ontokit/schemas/graph.py +++ b/ontokit/schemas/graph.py @@ -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.""" @@ -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 @@ -25,7 +47,7 @@ class GraphEdge(BaseModel): id: str source: str target: str - edge_type: str + edge_type: GraphEdgeType label: str | None = None From 2e6f73940ea3e7894d6287fa1fb0460dab95a93b Mon Sep 17 00:00:00 2001 From: damienriehl Date: Sat, 25 Apr 2026 10:58:29 -0500 Subject: [PATCH 3/4] refactor(graph): extract seeAlso helpers to module scope (H4) The original _get_see_also_targets and _get_see_also_referrers were inner closures inside build_entity_graph (a 330-line function with 9 nested closures). They could only be exercised through the full BFS path, requiring fixture graphs and assertions on downstream BFS state. Extracts both as top-level functions in `entity_graph_helpers.py` so they can be unit-tested directly. Adds 12 targeted tests covering FOLIO-style restriction encoding (someValuesFrom / allValuesFrom / hasValue), direct rdfs:seeAlso triples, dedup across encodings, named-superclass exclusion, restriction-with-unrelated-property exclusion, and class-only filtering for referrers. build_entity_graph still wraps them as thin closures (so the BFS code reads the same) but the heavy lifting is now reusable + independently tested. Co-Authored-By: Claude Opus 4.7 --- ontokit/services/entity_graph_helpers.py | 76 +++++++++++ tests/unit/test_entity_graph_helpers.py | 163 +++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 ontokit/services/entity_graph_helpers.py create mode 100644 tests/unit/test_entity_graph_helpers.py diff --git a/ontokit/services/entity_graph_helpers.py b/ontokit/services/entity_graph_helpers.py new file mode 100644 index 00000000..daa8bc8b --- /dev/null +++ b/ontokit/services/entity_graph_helpers.py @@ -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 diff --git a/tests/unit/test_entity_graph_helpers.py b/tests/unit/test_entity_graph_helpers.py new file mode 100644 index 00000000..b5ca723a --- /dev/null +++ b/tests/unit/test_entity_graph_helpers.py @@ -0,0 +1,163 @@ +"""Direct unit tests for the extracted seeAlso helpers. + +These were inner closures inside `OntologyService.build_entity_graph` and could +only be exercised through the full BFS path. Module-level extraction lets us +test edge cases (FOLIO restriction encoding, cross-direction lookup, dedup) +without graph construction overhead. +""" + +from __future__ import annotations + +from rdflib import BNode, Graph, Namespace +from rdflib.namespace import OWL, RDF, RDFS + +from ontokit.services.entity_graph_helpers import ( + get_see_also_referrers, + get_see_also_targets, +) + +EX = Namespace("http://example.org/") + + +def _make_class(g: Graph, *names: str) -> None: + for name in names: + g.add((EX[name], RDF.type, OWL.Class)) + + +# --------------------------------------------------------------------------- +# get_see_also_targets +# --------------------------------------------------------------------------- + + +class TestGetSeeAlsoTargets: + def test_direct_see_also_triple(self) -> None: + g = Graph() + _make_class(g, "A", "B") + g.add((EX.A, RDFS.seeAlso, EX.B)) + + targets = get_see_also_targets(g, EX.A) + assert targets == [EX.B] + + def test_folio_style_restriction_some_values_from(self) -> None: + """FOLIO encodes seeAlso as a Restriction inside subClassOf.""" + g = Graph() + _make_class(g, "A", "B") + restriction = BNode() + g.add((EX.A, RDFS.subClassOf, restriction)) + g.add((restriction, RDF.type, OWL.Restriction)) + g.add((restriction, OWL.onProperty, RDFS.seeAlso)) + g.add((restriction, OWL.someValuesFrom, EX.B)) + + targets = get_see_also_targets(g, EX.A) + assert targets == [EX.B] + + def test_folio_style_all_values_from_and_has_value(self) -> None: + """allValuesFrom and hasValue restrictions are also recognized.""" + g = Graph() + _make_class(g, "A", "B", "C") + for val_pred, target in ( + (OWL.allValuesFrom, EX.B), + (OWL.hasValue, EX.C), + ): + r = BNode() + g.add((EX.A, RDFS.subClassOf, r)) + g.add((r, OWL.onProperty, RDFS.seeAlso)) + g.add((r, val_pred, target)) + + targets = get_see_also_targets(g, EX.A) + assert set(targets) == {EX.B, EX.C} + + def test_dedupes_when_same_target_appears_in_direct_and_restriction(self) -> None: + g = Graph() + _make_class(g, "A", "B") + g.add((EX.A, RDFS.seeAlso, EX.B)) + r = BNode() + g.add((EX.A, RDFS.subClassOf, r)) + g.add((r, OWL.onProperty, RDFS.seeAlso)) + g.add((r, OWL.someValuesFrom, EX.B)) + + targets = get_see_also_targets(g, EX.A) + assert targets == [EX.B] + + def test_named_superclass_is_not_treated_as_restriction(self) -> None: + """A named (URIRef) parent must not be misread as a restriction.""" + g = Graph() + _make_class(g, "Animal", "Dog") + g.add((EX.Dog, RDFS.subClassOf, EX.Animal)) + g.add((EX.Animal, RDFS.seeAlso, EX.OtherAnimal)) + + targets = get_see_also_targets(g, EX.Dog) + # Animal is a named superclass — its own seeAlso must NOT bubble up + assert targets == [] + + def test_restriction_with_unrelated_property_is_ignored(self) -> None: + """Only restrictions on rdfs:seeAlso are considered.""" + g = Graph() + _make_class(g, "A", "B") + r = BNode() + g.add((EX.A, RDFS.subClassOf, r)) + g.add((r, OWL.onProperty, EX.someOtherProperty)) + g.add((r, OWL.someValuesFrom, EX.B)) + + targets = get_see_also_targets(g, EX.A) + assert targets == [] + + def test_no_see_also_returns_empty(self) -> None: + g = Graph() + _make_class(g, "A") + + assert get_see_also_targets(g, EX.A) == [] + + +# --------------------------------------------------------------------------- +# get_see_also_referrers +# --------------------------------------------------------------------------- + + +class TestGetSeeAlsoReferrers: + def test_direct_reverse_lookup(self) -> None: + g = Graph() + _make_class(g, "A", "B") + g.add((EX.A, RDFS.seeAlso, EX.B)) + + # B is referenced by A + assert get_see_also_referrers(g, EX.B) == [EX.A] + + def test_restriction_reverse_lookup(self) -> None: + g = Graph() + _make_class(g, "A", "B") + r = BNode() + g.add((EX.A, RDFS.subClassOf, r)) + g.add((r, OWL.onProperty, RDFS.seeAlso)) + g.add((r, OWL.someValuesFrom, EX.B)) + + # B is referenced by A via restriction + assert get_see_also_referrers(g, EX.B) == [EX.A] + + def test_only_returns_classes(self) -> None: + """If a referrer subject is not declared as owl:Class, exclude it.""" + g = Graph() + # Note: NotAClass is not declared as owl:Class + r = BNode() + g.add((EX.NotAClass, RDFS.subClassOf, r)) + g.add((r, OWL.onProperty, RDFS.seeAlso)) + g.add((r, OWL.someValuesFrom, EX.Target)) + + assert get_see_also_referrers(g, EX.Target) == [] + + def test_dedupes_when_same_referrer_appears_multiple_ways(self) -> None: + g = Graph() + _make_class(g, "A", "B") + g.add((EX.A, RDFS.seeAlso, EX.B)) + r = BNode() + g.add((EX.A, RDFS.subClassOf, r)) + g.add((r, OWL.onProperty, RDFS.seeAlso)) + g.add((r, OWL.someValuesFrom, EX.B)) + + assert get_see_also_referrers(g, EX.B) == [EX.A] + + def test_no_referrers_returns_empty(self) -> None: + g = Graph() + _make_class(g, "A") + + assert get_see_also_referrers(g, EX.A) == [] From 6f32696c3fc96043502c1fd4de7e25437d846e79 Mon Sep 17 00:00:00 2001 From: damienriehl Date: Sat, 25 Apr 2026 10:58:40 -0500 Subject: [PATCH 4/4] fix(graph): thread label_preferences through entity graph (B2/H2/H3/H4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle of related changes to build_entity_graph: - B2: thread project.label_preferences through. _get_label now passes them to select_preferred_label so multilingual projects see graph labels in their preferred language. _get_definition derives a preferred language from the same preferences and prefers literals matching that tag for skos:definition / rdfs:comment, falling back to any literal. - H2: drop the unreachable `isinstance(include_see_also, bool)` runtime check — FastAPI coerces query params before they reach this code, and type hints handle direct service callers. - H3: replace the Python-2-style `total_discovered = [0]` closure workaround with `nonlocal total_discovered`. - H4: replace inner closures with calls to top-level get_see_also_targets / get_see_also_referrers from entity_graph_helpers. - node_type / edge_type closures now return GraphNodeType / GraphEdgeType to match the tightened schema. Drops the test that asserted the removed isinstance check. Co-Authored-By: Claude Opus 4.7 --- ontokit/api/routes/projects.py | 5 +- ontokit/services/ontology.py | 114 ++++++++++++-------------------- tests/unit/test_entity_graph.py | 11 --- 3 files changed, 45 insertions(+), 85 deletions(-) diff --git a/ontokit/api/routes/projects.py b/ontokit/api/routes/projects.py index b2bad732..dd13df51 100644 --- a/ontokit/api/routes/projects.py +++ b/ontokit/api/routes/projects.py @@ -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, @@ -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( diff --git a/ontokit/services/ontology.py b/ontokit/services/ontology.py index 7a59f310..adacc7eb 100644 --- a/ontokit/services/ontology.py +++ b/ontokit/services/ontology.py @@ -34,10 +34,14 @@ OWLPropertyResponse, OWLPropertyUpdate, ) +from ontokit.services.entity_graph_helpers import ( + get_see_also_referrers, + get_see_also_targets, +) from ontokit.services.storage import StorageService if TYPE_CHECKING: - from ontokit.schemas.graph import EntityGraphResponse + from ontokit.schemas.graph import EntityGraphResponse, GraphEdgeType, GraphNodeType # Map file extensions to RDF formats FORMAT_MAP = { @@ -366,6 +370,7 @@ async def build_entity_graph( max_nodes: int = 200, include_see_also: bool = True, max_see_also_per_node: int = 5, + label_preferences: list[str] | None = None, ) -> EntityGraphResponse | None: """Build a multi-hop graph around a class via BFS. @@ -381,8 +386,6 @@ async def build_entity_graph( raise ValueError("descendants_depth must be non-negative") if max_see_also_per_node < 0: raise ValueError("max_see_also_per_node must be non-negative") - if not isinstance(include_see_also, bool): - raise ValueError("include_see_also must be a boolean") from ontokit.schemas.graph import EntityGraphResponse, GraphEdge, GraphNode @@ -394,10 +397,20 @@ async def build_entity_graph( owl_thing = OWL.Thing + # Derive a preferred language from label_preferences for definitions + # (e.g., ["rdfs:label@es", ...] → "es"). Used to prefer matching-language + # rdfs:comment / skos:definition over arbitrary first hit. + preferred_lang: str | None = None + for pref_string in label_preferences or []: + pref = LabelPreference.parse(pref_string) + if pref is not None and pref.language: + preferred_lang = pref.language + break + visited: dict[str, GraphNode] = {} edges: list[GraphEdge] = [] edge_ids: set[str] = set() - total_discovered = [0] + total_discovered = 0 def _get_local_name(iri: str) -> str: if "#" in iri: @@ -405,7 +418,7 @@ def _get_local_name(iri: str) -> str: return iri.rsplit("/", 1)[-1] def _get_label(uri: URIRef) -> str: - label = select_preferred_label(graph, uri) + label = select_preferred_label(graph, uri, label_preferences) return label if label else _get_local_name(str(uri)) def _is_external(iri: str) -> bool: @@ -419,7 +432,7 @@ def _is_root_class(uri: URIRef) -> bool: ] return len(parents) == 0 - def _classify_node(uri: URIRef, is_focus: bool, _depth: int) -> str: + def _classify_node(uri: URIRef, is_focus: bool, _depth: int) -> GraphNodeType: iri = str(uri) if is_focus: return "focus" @@ -440,13 +453,19 @@ def _classify_node(uri: URIRef, is_focus: bool, _depth: int) -> str: return "class" def _get_definition(uri: URIRef) -> str | None: - # Try SKOS definition first, then rdfs:comment - for obj in graph.objects(uri, SKOS.definition): - if isinstance(obj, RDFLiteral): - return str(obj) - for obj in graph.objects(uri, RDFS.comment): - if isinstance(obj, RDFLiteral): - return str(obj) + # Prefer the project's preferred language; fall back to any literal. + # SKOS definition takes precedence over rdfs:comment. + for predicate in (SKOS.definition, RDFS.comment): + fallback: str | None = None + for obj in graph.objects(uri, predicate): + if not isinstance(obj, RDFLiteral): + continue + if preferred_lang and obj.language == preferred_lang: + return str(obj) + if fallback is None: + fallback = str(obj) + if fallback is not None: + return fallback return None def _child_count(uri: URIRef) -> int: @@ -459,12 +478,13 @@ def _child_count(uri: URIRef) -> int: seen: set[str] = set() def _make_node(uri: URIRef, depth: int) -> GraphNode | None: + nonlocal total_discovered iri = str(uri) if iri in visited: return visited[iri] if iri not in seen: seen.add(iri) - total_discovered[0] += 1 + total_discovered += 1 if len(visited) >= max_nodes: return None is_focus = uri == class_uri @@ -484,7 +504,9 @@ def _make_node(uri: URIRef, depth: int) -> GraphNode | None: visited[iri] = node return node - def _add_edge(source: str, target: str, edge_type: str, label: str | None = None) -> bool: + def _add_edge( + source: str, target: str, edge_type: GraphEdgeType, label: str | None = None + ) -> bool: eid = f"{source}->{target}:{edge_type}" if eid in edge_ids: return False @@ -554,64 +576,10 @@ def _add_edge(source: str, target: str, edge_type: str, label: str | None = None # Extract seeAlso targets from OWL restrictions on rdfs:seeAlso def _get_see_also_targets(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. - """ - 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 val in graph.objects(sc, OWL.someValuesFrom): - if isinstance(val, URIRef): - _add(val) - for val in graph.objects(sc, OWL.allValuesFrom): - if isinstance(val, URIRef): - _add(val) - for val in graph.objects(sc, OWL.hasValue): - if isinstance(val, URIRef): - _add(val) - return targets + return get_see_also_targets(graph, uri) def _get_see_also_referrers(uri: URIRef) -> list[URIRef]: - """Find classes that have seeAlso restrictions pointing TO this URI.""" - 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 + return get_see_also_referrers(graph, uri) # Collect seeAlso cross-links # Outgoing seeAlso: checked on all visited nodes (focus + ancestors) @@ -680,7 +648,7 @@ def _add(ref: URIRef) -> None: if node.node_type == "root" and node.iri not in ancestor_visited: node.node_type = "secondary_root" - truncated = total_discovered[0] > len(visited) + truncated = total_discovered > len(visited) return EntityGraphResponse( focus_iri=class_iri, @@ -688,7 +656,7 @@ def _add(ref: URIRef) -> None: nodes=list(visited.values()), edges=edges, truncated=truncated, - total_concept_count=total_discovered[0], + total_concept_count=total_discovered, ) async def get_root_classes( diff --git a/tests/unit/test_entity_graph.py b/tests/unit/test_entity_graph.py index 249ee1ec..8407b38d 100644 --- a/tests/unit/test_entity_graph.py +++ b/tests/unit/test_entity_graph.py @@ -455,17 +455,6 @@ async def test_negative_max_see_also_per_node_raises(self) -> None: PROJECT_ID, str(EX.Person), BRANCH, max_see_also_per_node=-1 ) - @pytest.mark.asyncio - async def test_non_bool_include_see_also_raises(self) -> None: - svc = _service_with_graph(_base_graph()) - with pytest.raises(ValueError, match="include_see_also must be a boolean"): - await svc.build_entity_graph( - PROJECT_ID, - str(EX.Person), - BRANCH, - include_see_also="yes", # type: ignore[arg-type] - ) - class TestBuildEntityGraphIncomingRestrictions: @pytest.mark.asyncio