@@ -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