$ 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-23

Depth 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