$ cat node-template.py
O
Ontology Traverse
// Walk typed links out of, into, or both directions from an ontology object and return the connected sub-graph.
Process
Data
#ontology#knowledge-graph#traverse#graph#read
template.py
1"""ontology-traverse — walk typed links out of (or into) an ontology object.23Given a starting object id and a link type, returns the connected sub-graph via4the gais.ontology SDK (``Gais.ontology.traverse``), which carries the5per-execution USER_TOKEN and talks to the permissioned www-emblema v2 Ontology6API — this node imports no HTTP client. The object id is UUID-validated here7(defense in depth) so an id wired from an upstream node can't rewrite the8request route.9"""1011from __future__ import annotations1213import json14import re15import sys16import traceback17from typing import Any1819from gais import Gais2021_UUID_RE = re.compile(22 r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",23 re.IGNORECASE,24)25_DIRECTIONS = ("out", "in", "both")26# The v2 traverse route walks 1 to 3 hops and clamps anything else.27_MAX_DEPTH = 3282930def _normalize_uuid(value: Any, field: str) -> str:31 """Accept a bare UUID or a ``mention://.../<uuid>`` URI; validate as UUID."""32 if not isinstance(value, str):33 raise ValueError(f"{field} must be a string, got {type(value).__name__}")34 s = value.strip()35 if "://" in s:36 s = s.rsplit("/", 1)[-1]37 if not _UUID_RE.match(s):38 raise ValueError(f"{field} is not a valid UUID: {value!r}")39 return s404142def _summarize(result: dict[str, Any], link_type: str, direction: str) -> str:43 """Render the traversal as synthesizer-ready TEXT.4445 The node previously emitted only ``result`` (Json), which cannot wire into a46 Knowledge Synthesizer's ``context`` port (Text[]). Mirrors the pattern47 ontology-entity-360 already uses with its ``summaryText`` output — that is48 precisely why entity-360 was composable in a macro and this node was not.4950 A walk past one hop can stop at a limit (``truncated``, with the limit in51 ``truncationReason``). The text then ends with a loud PARTIAL line, worded52 like the chat tool ``ontology_traverse``'s, so a synthesizer never presents53 a partial list as every connection. When ``degreeEstimated`` is set (a54 fan-out ranking used an estimated degree for a very connected entity, or a55 level's degree read hit its row cap and left some neighbours unranked, so56 hidden rows may have shifted which neighbours were kept), the same line57 also says so, in the chat chain tool's words.58 """59 endpoints = result.get("endpoints")60 if not isinstance(endpoints, list) or not endpoints:61 scope = link_type or "any relation"62 lines = [f"No connected entities found ({scope}, direction={direction})."]63 else:64 lines = [65 f"Connected entities ({link_type or 'all relations'}, direction={direction}): "66 f"{len(endpoints)}"67 ]68 for ep in endpoints[:25]:69 if not isinstance(ep, dict):70 continue71 name = ep.get("displayName") or ep.get("name") or ep.get("id") or "?"72 rel = ep.get("linkType") or ep.get("relation") or ""73 etype = ep.get("typeName") or ep.get("type") or ""74 bits = [b for b in (str(etype), str(rel)) if b]75 lines.append(f"- {name}" + (f" ({', '.join(bits)})" if bits else ""))76 if len(endpoints) > 25:77 lines.append(f"... and {len(endpoints) - 25} more")78 truncated = bool(result.get("truncated"))79 estimated = bool(result.get("degreeEstimated"))80 if truncated or estimated:81 # The chat chain tool's PARTIAL line: every reason first, then the82 # "there may be more" tail. The route sends `degreeEstimated` only with83 # `truncated`; it is still named alone, so a ranking resting on an84 # estimate is never presented as exact.85 reasons: list[str] = []86 if truncated:87 reason = result.get("truncationReason")88 at_limit = f" at the {reason} limit" if reason else ""89 reasons.append(f"the graph was truncated{at_limit}")90 if estimated:91 reasons.append("rankings around very connected entities are estimated")92 tail = "; there may be more connected entities" if truncated else ""93 lines.append(f"(results are PARTIAL — {'; '.join(reasons)}{tail})")94 return "\n".join(lines)959697def process(inputs: dict[str, Any]) -> dict[str, Any]:98 object_id = _normalize_uuid(inputs.get("objectId"), "objectId")99100 # linkType is OPTIONAL: empty means "walk every relation". Making it101 # mandatory is what stopped this node being composable inside a macro,102 # where the relation is not known ahead of time (E1 surface race).103 link_type_raw = inputs.get("linkType")104 link_type = link_type_raw.strip() if isinstance(link_type_raw, str) else ""105106 direction = (inputs.get("direction") or "out").strip().lower()107 if direction not in _DIRECTIONS:108 raise ValueError(f"direction must be one of {list(_DIRECTIONS)}")109110 try:111 depth = int(inputs.get("depth") or 1)112 except (TypeError, ValueError):113 depth = 1114 # Clamp here too, so the request and the log line below say what is walked.115 depth = max(1, min(depth, _MAX_DEPTH))116117 result = Gais.ontology.traverse(118 object_id=object_id,119 link_type=link_type or None,120 direction=direction,121 depth=depth,122 )123 if not isinstance(result, dict):124 result = {}125126 summary_text = _summarize(result, link_type, direction)127128 print(129 f"[ontology-traverse] object={object_id} "130 f"link_type={link_type or '<all>'} direction={direction} depth={depth} "131 f"endpoints={len(result.get('endpoints') or [])}",132 file=sys.stderr,133 )134135 return {"result": result, "summaryText": summary_text}136137138def main() -> None:139 try:140 envelope = json.loads(sys.stdin.read() or "{}")141 if not isinstance(envelope, dict):142 envelope = {}143 inputs = envelope.get("inputs", {}) or {}144 json.dump(process(inputs), sys.stdout)145 except Exception as e:146 print(147 json.dumps({148 "error": str(e),149 "errorType": type(e).__name__,150 "traceback": traceback.format_exc(),151 }),152 file=sys.stderr,153 )154 sys.exit(1)155156157if __name__ == "__main__":158 main()159$ git log --oneline
v1.2.0
HEAD
2026-10-23Depth is clamped to 1-3 hops (the v2 route's limit), and summaryText ends with a PARTIAL line when the walk stopped at a limit or rankings around very connected entities were estimated.
v1.1.02026-08-18