$ 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