$ 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-18v1.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