$ cat node-template.py
O
Ontology Search
// Search the organization ontology (knowledge graph) for matching objects plus a match count. Supports entity mode (search by NAME), schema mode (search by RELATIONSHIP/THEME via matching types), and hybrid.
Input
Data
#ontology#knowledge-graph#search#read
template.py
1"""ontology-search — full-text search the organization ontology.23Queries the knowledge graph for objects matching a free-text ``query`` via the4gais.ontology SDK (``Gais.ontology.search``), which carries the per-execution5USER_TOKEN and talks to the permissioned www-emblema v2 Ontology API — this node6imports no HTTP client. Returns the raw match list plus a convenience ``count``7so a downstream node can branch on "did we find anything?".8"""910from __future__ import annotations1112import json13import sys14import traceback15from typing import Any1617from gais import Gais1819MAX_LIMIT = 10020MAX_QUERY_VARIANTS = 4212223def _norm(value: Any) -> str:24 return " ".join(str(value or "").lower().split())252627def _lexical_text(row: dict[str, Any]) -> str:28 """The NAME-bearing text a hit is scored on lexically: display name +29 keywords_en. Deliberately EXCLUDES description_en — that is the vector arm's30 field, and a hit whose description merely MENTIONS the query is not a lexical31 entity match (see `_is_lexical`)."""32 props = row.get("properties") if isinstance(row.get("properties"), dict) else {}33 parts = [34 row.get("displayName"),35 props.get("name"),36 props.get("keywords_en"),37 props.get("nameAlt"),38 ]39 return _norm(" ".join(str(p) for p in parts if p))404142def _is_lexical(row: dict[str, Any], variant_norms: list[str]) -> bool:43 """True when the query terms appear in the hit's NAME/keywords — i.e. it44 matched the exact/BM25 arms, not just the vector arm.4546 B-prime side-effect: `profile_vector` embeds `description_en`, so a document47 that only MENTIONS the entity (e.g. a certification "issued at the Ministry of48 Foreign Affairs") surfaces via kNN and can rank #1 of the entity search — but49 it is NOT the entity the question names. Tagging lets `merge-ranked-lists`50 anchor the profiled entity (topId) to a real lexical hit, skipping mentions.51 """52 text = _lexical_text(row)53 if not text:54 return False55 for variant in variant_norms:56 if not variant:57 continue58 if variant in text:59 return True60 tokens = [t for t in variant.split() if len(t) > 2]61 if tokens and all(t in text for t in tokens):62 return True63 return False646566def process(inputs: dict[str, Any]) -> dict[str, Any]:67 query = inputs.get("query")68 if not isinstance(query, str) or not query.strip():69 raise ValueError("Required input 'query' not provided")70 query = query.strip()7172 # Multi-variant queries (1.2.0): "term italiano | english term" runs one73 # search per variant and unions the results by id, keeping the FIRST74 # variant's ranking priority. Observed 2026-07-19: agents fill a single75 # language and miss the corpus-language names; the pipe pattern makes76 # supplying Italian + English variants deterministic instead of a prompt77 # dice roll. Capped to keep the fan-out bounded.78 variants: list[str] = []79 for part in query.split("|"):80 trimmed = part.strip()81 if trimmed and trimmed not in variants:82 variants.append(trimmed)83 variants = variants[:MAX_QUERY_VARIANTS] or [query]8485 # Optional object-type filter (id or name); blank -> search every type.86 raw_type = inputs.get("type")87 object_type = (88 raw_type.strip() if isinstance(raw_type, str) and raw_type.strip() else None89 )9091 # Optional N2 retrieval mode (entity | schema | hybrid). Blank/unknown ->92 # None, and the API defaults to entity search. `schema`/`hybrid` reach the93 # graph through matching TYPES (relationship/theme questions) instead of94 # entity NAMES — see the input description for when the agent picks each.95 raw_mode = inputs.get("mode")96 mode = raw_mode.strip().lower() if isinstance(raw_mode, str) and raw_mode.strip() else None97 if mode not in {"entity", "schema", "hybrid"}:98 mode = None99100 # Clamp the limit to a sane range (SDK default is 10).101 try:102 limit = int(inputs.get("limit") or 10)103 except (TypeError, ValueError):104 limit = 10105 limit = max(1, min(limit, MAX_LIMIT))106107 # Normalized variants for the lexical-vs-mention tag (1.3.0). A hit is108 # LEXICAL when the query terms are in its name/keywords; a vector-only109 # description-mention is not, so merge-ranked-lists won't anchor topId to it.110 variant_norms = [_norm(v) for v in variants]111112 results: list[Any] = []113 seen_ids: set[str] = set()114 for variant in variants:115 batch = Gais.ontology.search(116 query=variant, type=object_type, limit=limit, mode=mode117 )118 if not isinstance(batch, list):119 continue120 for row in batch:121 row_id = row.get("id") if isinstance(row, dict) else None122 if isinstance(row_id, str):123 if row_id in seen_ids:124 continue125 seen_ids.add(row_id)126 if isinstance(row, dict):127 row["lexical"] = _is_lexical(row, variant_norms)128 results.append(row)129 if len(results) >= limit:130 break131 if len(results) >= limit:132 break133134 print(135 f"[ontology-search] variants={variants!r} type={object_type} "136 f"mode={mode or 'entity'} limit={limit} found={len(results)}",137 file=sys.stderr,138 )139140 return {"results": results, "count": len(results)}141142143def main() -> None:144 try:145 envelope = json.loads(sys.stdin.read() or "{}")146 if not isinstance(envelope, dict):147 envelope = {}148 inputs = envelope.get("inputs", {}) or {}149 json.dump(process(inputs), sys.stdout)150 except Exception as e:151 print(152 json.dumps({153 "error": str(e),154 "errorType": type(e).__name__,155 "traceback": traceback.format_exc(),156 }),157 file=sys.stderr,158 )159 sys.exit(1)160161162if __name__ == "__main__":163 main()$ git log --oneline
v1.4.0
HEAD
2026-08-18v1.3.0
Ontology Search 1.3.0 (B-prime lexical anchor): tags each hit `lexical` when the query terms are in the object name/keywords_en (not just a vector description-mention), so Merge Ranked Lists can anchor topId to a real name match and skip vector-only mentions.
v1.2.02026-07-19
v1.1.02026-07-19
1.1.0: query-crafting guidance in input descriptions (corpus-language translation, keywords not questions, topic preservation)
v1.0.02026-07-16