$ 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")262728def _normalize_uuid(value: Any, field: str) -> str:29    """Accept a bare UUID or a ``mention://.../<uuid>`` URI; validate as UUID."""30    if not isinstance(value, str):31        raise ValueError(f"{field} must be a string, got {type(value).__name__}")32    s = value.strip()33    if "://" in s:34        s = s.rsplit("/", 1)[-1]35    if not _UUID_RE.match(s):36        raise ValueError(f"{field} is not a valid UUID: {value!r}")37    return s383940def _summarize(result: dict[str, Any], link_type: str, direction: str) -> str:41    """Render the traversal as synthesizer-ready TEXT.4243    The node previously emitted only ``result`` (Json), which cannot wire into a44    Knowledge Synthesizer's ``context`` port (Text[]). Mirrors the pattern45    ontology-entity-360 already uses with its ``summaryText`` output — that is46    precisely why entity-360 was composable in a macro and this node was not.47    """48    endpoints = result.get("endpoints")49    if not isinstance(endpoints, list) or not endpoints:50        scope = link_type or "any relation"51        return f"No connected entities found ({scope}, direction={direction})."5253    lines = [54        f"Connected entities ({link_type or 'all relations'}, direction={direction}): "55        f"{len(endpoints)}"56    ]57    for ep in endpoints[:25]:58        if not isinstance(ep, dict):59            continue60        name = ep.get("displayName") or ep.get("name") or ep.get("id") or "?"61        rel = ep.get("linkType") or ep.get("relation") or ""62        etype = ep.get("typeName") or ep.get("type") or ""63        bits = [b for b in (str(etype), str(rel)) if b]64        lines.append(f"- {name}" + (f" ({', '.join(bits)})" if bits else ""))65    if len(endpoints) > 25:66        lines.append(f"... and {len(endpoints) - 25} more")67    return "\n".join(lines)686970def process(inputs: dict[str, Any]) -> dict[str, Any]:71    object_id = _normalize_uuid(inputs.get("objectId"), "objectId")7273    # linkType is OPTIONAL: empty means "walk every relation". Making it74    # mandatory is what stopped this node being composable inside a macro,75    # where the relation is not known ahead of time (E1 surface race).76    link_type_raw = inputs.get("linkType")77    link_type = link_type_raw.strip() if isinstance(link_type_raw, str) else ""7879    direction = (inputs.get("direction") or "out").strip().lower()80    if direction not in _DIRECTIONS:81        raise ValueError(f"direction must be one of {list(_DIRECTIONS)}")8283    try:84        depth = int(inputs.get("depth") or 1)85    except (TypeError, ValueError):86        depth = 187    depth = max(1, depth)8889    result = Gais.ontology.traverse(90        object_id=object_id,91        link_type=link_type or None,92        direction=direction,93        depth=depth,94    )95    if not isinstance(result, dict):96        result = {}9798    summary_text = _summarize(result, link_type, direction)99100    print(101        f"[ontology-traverse] object={object_id} "102        f"link_type={link_type or '<all>'} direction={direction} depth={depth} "103        f"endpoints={len(result.get('endpoints') or [])}",104        file=sys.stderr,105    )106107    return {"result": result, "summaryText": summary_text}108109110def main() -> None:111    try:112        envelope = json.loads(sys.stdin.read() or "{}")113        if not isinstance(envelope, dict):114            envelope = {}115        inputs = envelope.get("inputs", {}) or {}116        json.dump(process(inputs), sys.stdout)117    except Exception as e:118        print(119            json.dumps({120                "error": str(e),121                "errorType": type(e).__name__,122                "traceback": traceback.format_exc(),123            }),124            file=sys.stderr,125        )126        sys.exit(1)127128129if __name__ == "__main__":130    main()

$ git log --oneline

v1.1.0
HEAD
2026-08-18
v1.0.02026-07-16