$ cat node-template.py

O

Ontology Entity 360

// Fetches the full 360 profile of one ontology entity - its links grouped by type WITH counts (e.g. 'employed_by (incoming): 18'), plus appearance counts - and emits a synthesizer-ready text summary. Wire objectId from Merge Ranked Lists' topId so the top fused entity's relationship profile reaches the answer. This is the node that answers COUNT and relationship questions from the graph.

Process
Data
template.py
1"""ontology-entity-360 — full relationship profile of one ontology entity.23Fetches the 360 payload via ``Gais.ontology.get_object`` (the permissioned4v2 ``query/object/{id}`` API — post-gate counts, grouped links, appearances)5and formats a synthesizer-ready text summary with EXACT per-link-type counts.6This is the node that lets the Unified Ontology Retrieval template answer7COUNT / relationship questions from the graph ("employed_by (incoming): 18")8instead of guessing from document chunks.910Empty ``objectId`` degrades cleanly (empty outputs, no error) so the wired11pipeline survives retrieval finding nothing — same posture as the bridge.12"""1314from __future__ import annotations1516import json17import sys18import traceback19from typing import Any2021from gais import Gais2223DEFAULT_MAX_NAMES = 1524MAX_NAMES_CAP = 50252627def _link_type(link: dict[str, Any]) -> str:28    # Live v2 payload carries the link type as `linkTypeName` (with `linkTypeId`29    # beside it); the endpoint object's own type sits nested under source/target30    # as `typeName`, so it never collides here. Legacy keys kept as fallbacks.31    for key in ("linkTypeName", "type", "typeName", "linkType"):32        value = link.get(key)33        if isinstance(value, str) and value.strip():34            return value.strip()35    return "unknown"363738def _endpoint_name(link: dict[str, Any], side: str) -> str:39    ep = link.get(side)40    if isinstance(ep, dict):41        for key in ("displayName", "name"):42            value = ep.get(key)43            if isinstance(value, str) and value.strip():44                return value.strip()45    return "?"464748def _group(links: Any, side: str) -> dict[str, list[str]]:49    grouped: dict[str, list[str]] = {}50    if not isinstance(links, list):51        return grouped52    for link in links:53        if not isinstance(link, dict):54            continue55        grouped.setdefault(_link_type(link), []).append(_endpoint_name(link, side))56    return grouped575859def process(inputs: dict[str, Any]) -> dict[str, Any]:60    object_id = inputs.get("objectId")61    object_id = object_id.strip() if isinstance(object_id, str) else ""62    if not object_id:63        # Clean degradation: nothing resolved upstream → nothing to profile.64        return {"entity": {}, "summaryText": "", "linkCounts": {}}6566    try:67        max_names = int(inputs.get("maxNamesPerType") or DEFAULT_MAX_NAMES)68    except (TypeError, ValueError):69        max_names = DEFAULT_MAX_NAMES70    max_names = max(1, min(max_names, MAX_NAMES_CAP))7172    payload = Gais.ontology.get_object(object_id)73    if not isinstance(payload, dict) or not payload:74        return {"entity": {}, "summaryText": "", "linkCounts": {}}7576    raw_obj = payload.get("object")77    obj: dict[str, Any] = raw_obj if isinstance(raw_obj, dict) else payload78    display = obj.get("displayName") or obj.get("name") or object_id79    type_name = obj.get("typeName") or obj.get("type") or "?"8081    raw_counts = payload.get("counts")82    counts: dict[str, Any] = raw_counts if isinstance(raw_counts, dict) else {}83    incoming = _group(payload.get("incomingLinks"), "source")84    outgoing = _group(payload.get("outgoingLinks"), "target")8586    link_counts: dict[str, int] = {}87    lines: list[str] = [f"Entity profile: {display} ({type_name})"]88    totals = (89        f"Totals - incoming links: {counts.get('incomingLinks', sum(len(v) for v in incoming.values()))}, "90        f"outgoing links: {counts.get('outgoingLinks', sum(len(v) for v in outgoing.values()))}, "91        f"document appearances: {counts.get('appearances', '?')}"92    )93    lines.append(totals)9495    # Direction must be spelled out PER LINE, not just in the header: the96    # 1.0.2 header-only note ("each LISTED entity has this relation TO this97    # entity") was still inverted by a live ZH run ("MAECI regulates 8 laws" -98    # the laws regulate MAECI). Types whose verb reads naturally with the99    # profiled entity as subject (regulates, supervises) get flipped unless100    # every line carries its own arrow.101    for label, grouped, total_key, direction_note, line_arrow in (102        ("Incoming", incoming, "incomingLinks",103         "each LISTED entity has this relation TO this entity, e.g. listed person employed_by THIS entity",104         lambda ltype: f"(each listed → {ltype} → this entity)"),105        ("Outgoing", outgoing, "outgoingLinks",106         "THIS entity has this relation TO each listed entity, e.g. THIS entity manages listed event",107         lambda ltype: f"(this entity → {ltype} → each listed)"),108    ):109        if not grouped:110            continue111        lines.append(f"{label} links by type (EXACT counts; {direction_note}):")112        shown = 0113        for ltype, names in sorted(grouped.items(), key=lambda kv: -len(kv[1])):114            link_counts[f"{ltype} ({label.lower()})"] = len(names)115            shown += len(names)116            head = ", ".join(names[:max_names])117            more = f" (+{len(names) - max_names} more)" if len(names) > max_names else ""118            lines.append(f"  - {ltype}: {len(names)} {line_arrow(ltype)} - {head}{more}")119        total = counts.get(total_key)120        if isinstance(total, int) and total > shown:121            lines.append(122                f"  (showing {shown} of {total} {label.lower()} links - "123                f"per-type counts above cover only the returned links)"124            )125126    summary = "\n".join(lines)127    print(128        f"[ontology-entity-360] id={object_id} types={len(link_counts)} "129        f"summary_chars={len(summary)}",130        file=sys.stderr,131    )132    return {"entity": payload, "summaryText": summary, "linkCounts": link_counts}133134135def main() -> None:136    try:137        envelope = json.loads(sys.stdin.read() or "{}")138        if not isinstance(envelope, dict):139            envelope = {}140        inputs = envelope.get("inputs", {}) or {}141        json.dump(process(inputs), sys.stdout)142    except Exception as e:143        print(144            json.dumps({145                "error": str(e),146                "errorType": type(e).__name__,147                "traceback": traceback.format_exc(),148            }),149            file=sys.stderr,150        )151        sys.exit(1)152153154if __name__ == "__main__":155    main()

$ git log --oneline

v1.0.3
HEAD
2026-08-18
v1.0.22026-07-19
v1.0.12026-07-19
v1.0.02026-07-19

1.0.0: initial - 360 profile with EXACT per-link-type counts, synthesizer-ready summary