$ cat node-template.py
O
Ontology From Documents
// Bridge documents onto the knowledge graph: given document ids (e.g. semantic chunk hits), return the ontology objects appearing in them, ranked by appearance frequency and connectivity, optionally with the links among them. Wire from KB Semantic Search's documentIds output.
Process
Data
#ontology#knowledge-graph#bridge#documents#retrieval#read
template.py
1"""ontology-from-documents — bridge documents onto the knowledge graph.23The thematic bridge of the unified-retrieval flow: feed it the document ids of4semantic chunk hits (KB Semantic Search's ``documentIds`` output) and it5returns the accessible ontology objects appearing in those documents, ranked6by appearance frequency with connectivity (link degree) as tiebreak. Calls the7gais.ontology SDK (``Gais.ontology.from_documents``), which carries the8per-execution USER_TOKEN and talks to the permissioned www-emblema v2 Ontology9API — this node imports no HTTP client. Objects the caller cannot see are10silently dropped server-side, and unknown/inaccessible document ids simply11contribute nothing.1213Outputs (flat):14- ``entities`` — the ranked object dicts (per-item ``links`` stripped);15- ``links`` — the deduped typed edges among the returned entities16 (``[]`` when ``includeLinks`` is off);17- ``truncated`` — loud cutoff flag (more accessible objects existed than18 ``limit`` allowed).1920An empty ``documentIds`` list (e.g. the upstream search found nothing) is a21CLEAN empty output, never an error — the wired pipeline degrades gracefully.22"""2324from __future__ import annotations2526import json27import sys28import traceback29from typing import Any3031from gais import Gais3233MAX_LIMIT = 10034# The v2 route accepts at most 50 document ids per call (bridge fan-in cap).35MAX_DOCUMENT_IDS = 50363738def _parse_document_ids(raw: Any) -> list[str]:39 """Lenient documentIds parse: JSON string or list -> deduped id list."""40 if raw is None:41 raise ValueError("Required input 'documentIds' not provided")42 if isinstance(raw, str):43 try:44 raw = json.loads(raw)45 except json.JSONDecodeError as e:46 raise ValueError(f"'documentIds' is not valid JSON: {e}") from e47 if not isinstance(raw, (list, tuple)):48 raise ValueError("'documentIds' must be a JSON array of document ids")49 # Dedupe, order-preserving (dict preserves insertion order).50 return list(51 dict.fromkeys(52 item.strip() for item in raw if isinstance(item, str) and item.strip()53 )54 )555657def _to_bool(raw: Any, default: bool) -> bool:58 """Coerce a boolean-widget value; tolerate its common string forms."""59 if raw is None:60 return default61 if isinstance(raw, str):62 return raw.strip().lower() not in ("", "false", "no", "0")63 return bool(raw)646566def _flatten_links(items: list[dict]) -> list[dict]:67 """Per-item link stubs -> one deduped edge list.6869 The API attaches each link to BOTH endpoints (outgoing on the source,70 incoming on the target — both are always page members), so normalize every71 stub to a (source, target, type) edge and dedupe.72 """73 display_name_by_id = {74 item["id"]: item.get("displayName") or ""75 for item in items76 if item.get("id")77 }78 edges: list[dict] = []79 seen: set[tuple] = set()80 for item in items:81 item_id = item.get("id")82 for link in item.get("links") or []:83 if not isinstance(link, dict):84 continue85 direction = link.get("direction")86 if direction == "outgoing":87 source_id, target_id = item_id, link.get("otherId")88 elif direction == "incoming":89 source_id, target_id = link.get("otherId"), item_id90 else:91 continue92 link_type = link.get("type") or ""93 key = (source_id, target_id, link_type)94 if not source_id or not target_id or key in seen:95 continue96 seen.add(key)97 edges.append({98 "type": link_type,99 "sourceId": source_id,100 "sourceDisplayName": display_name_by_id.get(source_id, ""),101 "targetId": target_id,102 "targetDisplayName": display_name_by_id.get(target_id, ""),103 })104 return edges105106107def process(inputs: dict[str, Any]) -> dict[str, Any]:108 document_ids = _parse_document_ids(inputs.get("documentIds"))109110 # Empty upstream result (semantic search found nothing) -> clean empties.111 if not document_ids:112 print("[ontology-from-documents] no document ids — empty result", file=sys.stderr)113 return {"entities": [], "links": [], "truncated": False}114115 if len(document_ids) > MAX_DOCUMENT_IDS:116 print(117 f"[ontology-from-documents] clamping {len(document_ids)} document ids "118 f"to the API cap of {MAX_DOCUMENT_IDS}",119 file=sys.stderr,120 )121 document_ids = document_ids[:MAX_DOCUMENT_IDS]122123 try:124 limit = int(inputs.get("limit") or 25)125 except (TypeError, ValueError):126 limit = 25127 limit = max(1, min(limit, MAX_LIMIT))128129 include_links = _to_bool(inputs.get("includeLinks"), default=True)130131 result = Gais.ontology.from_documents(132 document_ids=document_ids,133 limit=limit,134 include_links=include_links,135 )136 if not isinstance(result, dict):137 result = {}138139 raw_items = result.get("items")140 items = [item for item in raw_items if isinstance(item, dict)] if isinstance(141 raw_items, list142 ) else []143144 links = _flatten_links(items) if include_links else []145 # Per-item link stubs are folded into the flat `links` output — strip them146 # from the entities so each fact is emitted once.147 entities = [148 {key: value for key, value in item.items() if key != "links"}149 for item in items150 ]151 truncated = bool(result.get("truncated"))152153 print(154 f"[ontology-from-documents] documents={len(document_ids)} limit={limit} "155 f"includeLinks={include_links} entities={len(entities)} "156 f"links={len(links)} truncated={truncated}",157 file=sys.stderr,158 )159160 return {"entities": entities, "links": links, "truncated": truncated}161162163def main() -> None:164 try:165 envelope = json.loads(sys.stdin.read() or "{}")166 if not isinstance(envelope, dict):167 envelope = {}168 inputs = envelope.get("inputs", {}) or {}169 json.dump(process(inputs), sys.stdout)170 except Exception as e:171 print(172 json.dumps({173 "error": str(e),174 "errorType": type(e).__name__,175 "traceback": traceback.format_exc(),176 }),177 file=sys.stderr,178 )179 sys.exit(1)180181182if __name__ == "__main__":183 main()$ git log --oneline
v1.0.0
HEAD
2026-08-18