$ cat node-template.py
Ontology Chain
// THE node for 'how is X connected to Y?' (Start X, To Y) and 'what is related to X?' (Start X alone): explained paths through the ontology, each hop cited to its source passage; it can also walk a typed chain of hops. Emits a synthesizer-ready summaryText; wire Start from Merge Ranked Lists' topId and summaryText into a Knowledge Synthesizer's context. Start and To take an entity's NAME only, as the documents write it and never translated ('现金流风险' stays '现金流风险'): the kind the user names goes in Start Type / To Type ('lo strumento AI' is Start 'AI', Start Type 'AI tool'; 'AI (AI_Model)' is Start 'AI', Start Type 'AI_Model'). A result shows 2 paths unless Max Paths raises it (up to 5). Query (advanced) replaces every field, Start included.
1"""ontology-chain — explained paths through the ontology.23One engine, two shapes (docs/designs/ontology-chain-queries.md):45- connect: the paths between Start and To, or, with To empty, the6 neighbourhood around Start, grouped by the first hop's relation;7- chain: a typed walk from Start along 1 to 3 Hops (Palantir's searchAround).89The query goes through the gais SDK (``Gais.ontology.chain``, the POST form of10/api/v2/ontology/query/traverse), which carries the per-execution USER_TOKEN;11this node imports no HTTP client. Every result carries the paths that reached12it and every hop its evidence (a quote and its source, a recording's time13range, or none); ``summaryText`` renders them for a Knowledge Synthesizer,14marking ``· unreviewed`` a link no reviewer has confirmed (extracted by AI).1516Start / To mapping follows the chat tool's name-versus-id split: an object id17(a UUID) becomes ``{"ids": [...]}``; anything else is a NAME, resolved by an18entity-mode search keeping the top 3 matches (entity mode: the caller names a19specific thing). mention:// references (ending in a UUID) and id lists20(several ids separated by commas) are node-only extensions: the chat tool21sends both to the name search.2223Failure posture. A static mistake in the node's own fields (unknown mode, bad24Hops JSON) fails the node. The route's refusal (HTTP 400) does not: its repair25message can depend on what the start entity is linked to — a link type absent26around this particular anchor — so the node returns it in ``summaryText`` and27``result`` instead of failing a whole macro run. Anything else (403, 5xx,28timeout) fails the node, as in ontology-traverse.2930Never written to the log: names, ids, filter values or the repair text. The31stderr line carries kinds, counts and flags only.32"""3334from __future__ import annotations3536import json37import math38import re39import sys40import traceback41from typing import Any4243_UUID_RE = re.compile(44 r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",45 re.IGNORECASE,46)47_MODES = ("connect", "chain")48_DIRECTIONS = ("out", "in", "both")49# The chat tool's mapping: a name resolves to the top 3 entity-search hits.50_NAME_SEARCH_LIMIT = 351_DEFAULT_MAX_HOPS = 252_MAX_HOPS = 353# Paths shown per result: the route's default and its cap.54_DEFAULT_MAX_PATHS = 255_MAX_PATHS = 556# What a Query of each mode cannot do without: it replaces every field.57_QUERY_REQUIRED = {"chain": ("start", "hops"), "connect": ("from", "maxHops")}58# The engine returns at most 25 results by default; a larger `limits` asked59# through Query still renders only this many, then says how many were left.60_SUMMARY_RESULTS_MAX = 2561_QUOTE_MAX_CHARS = 30062# Names split on commas, semicolons and newlines, never on spaces: the route63# matches names ignoring case AND spacing, so "spoke in" means spoke_in.64_NAME_SPLIT_RE = re.compile(r"[,;\n]+")65_ID_SPLIT_RE = re.compile(r"[\s,;]+")66_SPACES_RE = re.compile(r"\s+")67# Cuts of the walk itself: counts over a walk cut this way are lower bounds.68# "results" is not one (counts are taken before the maxResults cut).69_WALK_CUTS = frozenset({"fanout", "frontier", "start"})70# A hop whose link no reviewer has confirmed carries this mark, explained once.71_UNREVIEWED_MARK = " · unreviewed"72_UNREVIEWED_LEGEND = "(links marked 'unreviewed' were extracted by AI and no reviewer has confirmed them yet)"737475# ---- inputs -> ChainQuery ----------------------------------------------------767778def _text(value: Any, field: str) -> str:79 """A text input, trimmed; None counts as empty, any other type is an error."""80 if value is None:81 return ""82 if not isinstance(value, str):83 raise ValueError(f"{field} must be text, got {type(value).__name__}")84 return value.strip()858687def _names(value: Any) -> list[str]:88 """Distinct trimmed names from a comma-separated string or a list."""89 if isinstance(value, str):90 parts: list[Any] = _NAME_SPLIT_RE.split(value)91 elif isinstance(value, (list, tuple)):92 parts = list(value)93 else:94 return []95 names: list[str] = []96 for part in parts:97 name = part.strip() if isinstance(part, str) else ""98 if name and name not in names:99 names.append(name)100 return names101102103def _object_ids(text: str, field: str) -> list[str] | None:104 """The ids ``text`` lists, or None when it is a name.105106 Ids are UUIDs or mention:// references ending in one, separated by commas,107 semicolons or whitespace. A mention:// reference that does not end in a108 UUID is an error: it was meant as an id, never as a search.109 """110 ids: list[str] = []111 for part in _ID_SPLIT_RE.split(text):112 if not part:113 continue114 candidate = part115 if "://" in part:116 candidate = part.rstrip("/").rsplit("/", 1)[-1]117 if not _UUID_RE.match(candidate):118 if part.lower().startswith("mention://"):119 raise ValueError(120 f"{field}: a mention:// reference must end in an object id (UUID)"121 )122 return None123 elif not _UUID_RE.match(candidate):124 return None125 if candidate not in ids:126 ids.append(candidate)127 return ids or None128129130def _start(value: Any, type_value: Any, field: str) -> dict[str, Any] | None:131 """A Start / To input as a StartSpec: ids, or an entity search for a name."""132 text = _text(value, field)133 if not text:134 return None135 ids = _object_ids(text, field)136 if ids is not None:137 return {"ids": ids}138 search: dict[str, Any] = {"q": text, "mode": "entity", "limit": _NAME_SEARCH_LIMIT}139 types = _names(type_value)140 if types:141 search["types"] = types142 return {"search": search}143144145def _max_hops(value: Any) -> int:146 try:147 hops = int(value) if value not in (None, "") else _DEFAULT_MAX_HOPS148 except (TypeError, ValueError, OverflowError):149 hops = _DEFAULT_MAX_HOPS150 return max(1, min(hops, _MAX_HOPS))151152153def _max_paths(value: Any) -> int | None:154 """Max Paths as the route's limits.maxPathsPerResult, clamped to 1-5, or155 None for the route's own default of 2 (the query then carries no limits)."""156 try:157 paths = int(value) if value not in (None, "") else _DEFAULT_MAX_PATHS158 except (TypeError, ValueError, OverflowError):159 paths = _DEFAULT_MAX_PATHS160 paths = max(1, min(paths, _MAX_PATHS))161 return None if paths == _DEFAULT_MAX_PATHS else paths162163164def _json_value(value: Any, field: str) -> Any:165 """A json-widget input: already parsed, or JSON text to parse."""166 if not isinstance(value, str):167 return value168 text = value.strip()169 if not text:170 return None171 try:172 return json.loads(text)173 except json.JSONDecodeError as err:174 raise ValueError(175 f"{field} is not valid JSON ({err.msg} at line {err.lineno} column {err.colno})"176 ) from None177178179def _hop(raw: Any, index: int) -> dict[str, Any]:180 """One Hops entry as the route's HopSpec; singular aliases become lists."""181 if not isinstance(raw, dict):182 raise ValueError(183 f'hop {index} must be an object such as {{"linkType": "cites", "direction": "out"}}'184 )185 hop: dict[str, Any] = {}186 for key, value in raw.items():187 if key in ("linkType", "linkTypes", "targetType", "targetTypes"):188 # A value of the wrong type must never be dropped: an absent189 # linkTypes means "every relation", a far wider walk than asked.190 is_list = isinstance(value, (list, tuple)) and all(isinstance(v, str) for v in value)191 if value is not None and not isinstance(value, str) and not is_list:192 raise ValueError(f"hop {index}: {key} must be a name or a list of names")193 plural = "linkTypes" if key.startswith("link") else "targetTypes"194 known = hop.get(plural, [])195 merged = known + [name for name in _names(value) if name not in known]196 if merged:197 hop[plural] = merged198 elif key == "direction":199 if value is not None and not isinstance(value, str):200 raise ValueError(f"hop {index}: direction must be one of out, in, both")201 direction = (value or "").strip().lower()202 if direction:203 if direction not in _DIRECTIONS:204 raise ValueError(f"hop {index}: direction must be one of out, in, both")205 hop["direction"] = direction206 else:207 # `where` and anything else go to the route, whose strict schema208 # answers a typo with a repair message instead of ignoring it.209 hop[key] = value210 return hop211212213def _hops(value: Any) -> list[dict[str, Any]]:214 raw = _json_value(value, "hops")215 if raw is None:216 raw = []217 if isinstance(raw, dict):218 raw = [raw]219 if not isinstance(raw, list):220 raise ValueError("hops must be a list of hop objects")221 if not 1 <= len(raw) <= _MAX_HOPS:222 raise ValueError(223 'chain mode needs 1 to 3 hops, e.g. [{"linkType": "cites", "direction": "out"}]'224 )225 return [_hop(entry, index) for index, entry in enumerate(raw, 1)]226227228def _advanced_query(value: Any) -> dict[str, Any] | None:229 """The Query input: a full ChainQuery sent as is, or None when unset."""230 raw = _json_value(value, "query")231 if raw is None:232 return None233 if not isinstance(raw, dict):234 raise ValueError("query must be a JSON object (a chain query)")235 if not raw:236 return None237 if raw.get("mode") not in _MODES:238 raise ValueError('query needs "mode": "connect" or "chain"')239 missing = [f'"{key}"' for key in _QUERY_REQUIRED[raw["mode"]] if key not in raw]240 if missing:241 # A partial Query (only "limits", say) would replace the fields and the242 # wired Start with a query the route refuses: fail where the agent sees it.243 raise ValueError(244 f'query is incomplete: a {raw["mode"]} query needs {" and ".join(missing)}. '245 "A Query replaces every field above, Start included, so it must be a whole chain query; "246 "to show more paths per result, set Max Paths instead"247 )248 return raw249250251def build_query(inputs: dict[str, Any]) -> dict[str, Any] | None:252 """The ChainQuery the inputs describe, or None when there is nothing to walk.253254 The node's own fields are checked first, so a misconfigured node fails255 even on a run whose Start happens to be empty.256 """257 advanced = _advanced_query(inputs.get("query"))258 if advanced is not None:259 return advanced260 mode = _text(inputs.get("mode"), "mode").lower() or "connect"261 if mode not in _MODES:262 raise ValueError("mode must be one of connect, chain")263 paths = _max_paths(inputs.get("maxPathsPerResult"))264 limits = {"limits": {"maxPathsPerResult": paths}} if paths is not None else {}265 if mode == "chain":266 hops = _hops(inputs.get("hops"))267 start = _start(inputs.get("start"), inputs.get("startType"), "start")268 if start is None:269 return None270 return {"mode": "chain", "start": start, "hops": hops, **limits}271 max_hops = _max_hops(inputs.get("maxHops"))272 link_types = _names(inputs.get("linkTypes"))273 start = _start(inputs.get("start"), inputs.get("startType"), "start")274 if start is None:275 return None276 query: dict[str, Any] = {"mode": "connect", "from": start, "maxHops": max_hops}277 to = _start(inputs.get("to"), inputs.get("toType"), "to")278 if to is not None:279 query["to"] = to280 if link_types:281 query["linkTypes"] = link_types282 return {**query, **limits}283284285# ---- ChainResult -> summaryText ----------------------------------------------286287288def _one_line(value: Any) -> str:289 return _SPACES_RE.sub(" ", value).strip() if isinstance(value, str) else ""290291292def _plural(count: int, word: str) -> str:293 return f"{count} {word}" if count == 1 else f"{count} {word}s"294295296def _join_words(words: list[str]) -> str:297 if len(words) <= 1:298 return "".join(words)299 return f"{', '.join(words[:-1])} and {words[-1]}"300301302def _is_int(value: Any) -> bool:303 return isinstance(value, int) and not isinstance(value, bool)304305306def _hop_count(query: dict[str, Any]) -> int | None:307 if query.get("mode") == "chain":308 hops = query.get("hops")309 return len(hops) if isinstance(hops, list) else None310 max_hops = query.get("maxHops")311 return max_hops if _is_int(max_hops) else None312313314def _walk_was_cut(result: dict[str, Any]) -> bool:315 """The walk itself stopped early: every count over it is a lower bound."""316 cuts = result.get("truncation")317 return bool(result.get("budgetExceeded")) or (318 isinstance(cuts, list) and any(cut in _WALK_CUTS for cut in cuts)319 )320321322def _entity(ref: Any) -> str:323 if not isinstance(ref, dict):324 return "?"325 name = _one_line(ref.get("displayName")) or _one_line(ref.get("id")) or "?"326 type_name = _one_line(ref.get("typeName"))327 return f"{name} ({type_name})" if type_name else name328329330def _clock(seconds: float) -> str:331 minutes, secs = divmod(max(0, int(seconds)), 60)332 return f"{minutes:02d}:{secs:02d}"333334335def _number(value: Any) -> float | None:336 if isinstance(value, bool) or not isinstance(value, (int, float)):337 return None338 return float(value) if math.isfinite(value) else None339340341def _evidence(evidence: Any) -> str:342 """One hop's evidence: its quote and source, a recording range, or none."""343 if not isinstance(evidence, dict):344 return "(no source passage)"345 kind = evidence.get("kind")346 source = _one_line(evidence.get("sourceName"))347 if kind == "quote":348 quote = _one_line(evidence.get("quote"))349 if quote:350 if len(quote) > _QUOTE_MAX_CHARS:351 quote = quote[:_QUOTE_MAX_CHARS].rstrip() + "…"352 return f'"{quote}"' + (f" (source: {source})" if source else "")353 elif kind == "timeRange":354 start = _number(evidence.get("start"))355 if start is not None:356 end = _number(evidence.get("end"))357 span = (358 f"{_clock(start)}-{_clock(end)}"359 if end is not None and end >= start360 else f"from {_clock(start)}"361 )362 return f'recording "{source}" {span}' if source else f"recording {span}"363 return "(no source passage)"364365366def _unreviewed(link: Any) -> bool:367 """A link no reviewer has confirmed: any status but "confirmed" (the route368 sends "auto" for an AI extraction). A route that sends no status (one369 deployed before hops carried it) leaves the hop unmarked."""370 status = link.get("status")371 return status is not None and status != "confirmed"372373374def _hop_line(hop: Any) -> str:375 """`A -[cites]-> B` for an outbound hop, `A <-[cites]- B` for an inbound one;376 `-[cites · unreviewed]->` when no reviewer has confirmed the link."""377 if not isinstance(hop, dict):378 return "?"379 link = hop.get("link") if isinstance(hop.get("link"), dict) else {}380 name = _one_line(link.get("linkTypeName")) or "?"381 if _unreviewed(link):382 name += _UNREVIEWED_MARK383 direction = link.get("direction")384 if direction == "out":385 arrow = f"-[{name}]->"386 elif direction == "in":387 arrow = f"<-[{name}]-"388 else:389 arrow = f"-[{name}]-"390 return f"{_entity(hop.get('from'))} {arrow} {_entity(hop.get('to'))} — {_evidence(hop.get('evidence'))}"391392393def _result_lines(number: int, item: dict[str, Any]) -> list[str]:394 lines = [f"{number}. {_entity(item.get('object'))}"]395 paths = [p for p in item.get("paths") or [] if isinstance(p, dict)]396 for index, path in enumerate(paths, 1):397 hops = [h for h in path.get("hops") or [] if isinstance(h, dict)]398 if len(hops) == 1:399 lines.append(f" path {index}: {_hop_line(hops[0])}")400 elif hops:401 lines.append(f" path {index}:")402 lines.extend(f" {_hop_line(hop)}" for hop in hops)403 return lines404405406def _group_line(index: int, group: dict[str, Any], returned: int, cut: bool) -> str:407 """A relation group's header: its first-hop relation and how many it holds.408409 ``size`` (every surviving member, before the result cut) is present only410 where this box enables counts; it is "at least" when the walk was cut.411 """412 name = _one_line(group.get("linkTypeName")) or "?"413 direction = group.get("direction")414 side = (415 "outgoing from the start"416 if direction == "out"417 else "incoming to the start"418 if direction == "in"419 else "from the start"420 )421 count = _plural(returned, "result")422 size = group.get("size")423 if _is_int(size) and (cut or size != returned):424 count += f" ({'at least ' if cut else ''}{size} in total)"425 return f"Group {index}: {name}, {side} — {count}"426427428def _body_lines(results: list[dict[str, Any]], groups: list[dict[str, Any]], cut: bool) -> list[str]:429 """Results in order, or group by group for a neighbourhood; capped."""430 lines: list[str] = []431 rendered = 0432433 def render(item: dict[str, Any]) -> bool:434 nonlocal rendered435 if rendered >= _SUMMARY_RESULTS_MAX:436 return False437 rendered += 1438 lines.extend(_result_lines(rendered, item))439 return True440441 if groups:442 by_id: dict[str, dict[str, Any]] = {}443 for item in results:444 obj = item.get("object")445 if isinstance(obj, dict) and isinstance(obj.get("id"), str):446 by_id.setdefault(obj["id"], item)447 placed: set[str] = set()448 shown_groups = 0449 for group in groups:450 ids: list[str] = []451 for result_id in group.get("resultIds") or []:452 if result_id in by_id and result_id not in placed and result_id not in ids:453 ids.append(result_id)454 if not ids:455 continue456 if rendered >= _SUMMARY_RESULTS_MAX:457 break458 shown_groups += 1459 lines.append(_group_line(shown_groups, group, len(ids), cut))460 for result_id in ids:461 placed.add(result_id)462 if not render(by_id[result_id]):463 break464 rest = [465 item for item in results466 if not (isinstance(item.get("object"), dict) and item["object"].get("id") in placed)467 ]468 if rest and rendered < _SUMMARY_RESULTS_MAX:469 lines.append("Other results:")470 for item in rest:471 if not render(item):472 break473 else:474 for item in results:475 if not render(item):476 break477 if len(results) > rendered:478 lines.append(f"... and {len(results) - rendered} more results not shown")479 return lines480481482def _group_by_label(spec: dict[str, Any]) -> str:483 by = spec.get("by")484 if by == "type":485 return "Grouped by object type"486 if by == "property":487 prop = _one_line(spec.get("property"))488 return f"Grouped by the property '{prop}'" if prop else "Grouped by property"489 if by == "hopObject":490 hop = spec.get("hop")491 return f"Grouped by the object at hop {hop}" if _is_int(hop) else "Grouped by hop object"492 return "Grouped"493494495def _grouping_withheld(result: dict[str, Any]) -> bool:496 """A groupBy hopObject too large to compute exactly: its buckets were withheld."""497 withheld = result.get("countsWithheld")498 return isinstance(withheld, dict) and withheld.get("reason") == "groupingTooLarge"499500501def _aggregate_lines(query: dict[str, Any], result: dict[str, Any]) -> list[str]:502 aggregate = result.get("aggregate")503 if not isinstance(aggregate, dict) or not _is_int(aggregate.get("total")):504 return []505 total = aggregate["total"]506 lower = bool(aggregate.get("lowerBound"))507 at_least = "at least " if lower else ""508 note = " (lower bounds: the walk was cut before it finished)" if lower else ""509 spec = query.get("aggregate") if isinstance(query.get("aggregate"), dict) else {}510 buckets = [b for b in aggregate.get("buckets") or [] if isinstance(b, dict)]511 # A withheld grouping keeps only its total, so it renders as a count.512 if not buckets and (spec.get("kind") != "groupBy" or _grouping_withheld(result)):513 return [f"Count: {at_least}{_plural(total, 'result')}{note}."]514 lines = [f"{_group_by_label(spec)}, over {at_least}{_plural(total, 'result')}{note}:"]515 for bucket in buckets:516 label = _one_line(bucket.get("label")) or "(none)"517 count = bucket.get("count")518 lines.append(f" - {label}: {at_least}{count if _is_int(count) else '?'}")519 if aggregate.get("bucketsOverlap"):520 lines.append(521 " (a result can fall in several groups, so the group counts add up to more than the total)"522 )523 return lines524525526def _withheld_line(query: dict[str, Any], result: dict[str, Any]) -> str | None:527 withheld = result.get("countsWithheld")528 if not isinstance(withheld, dict):529 return None530 if _grouping_withheld(result):531 spec = query.get("aggregate") if isinstance(query.get("aggregate"), dict) else {}532 hop = spec.get("hop")533 grouping = f"grouping by the object at hop {hop}" if _is_int(hop) else "the grouping"534 aggregate = result.get("aggregate")535 given = (536 "only the total is given"537 if isinstance(aggregate, dict) and _is_int(aggregate.get("total"))538 else "no group counts are given"539 )540 return f"(groups withheld: {grouping} was too large to compute exactly on this request; {given})"541 max_hops = withheld.get("maxHops")542 if _is_int(max_hops) and max_hops > 0:543 return (544 f"(counts withheld: this box gives counts only for walks of up to "545 f"{_plural(max_hops, 'hop')}; the paths come without counts)"546 )547 return "(counts withheld: counts are not enabled on this box; the paths come without counts)"548549550def _partial_line(result: dict[str, Any]) -> str | None:551 """The loud PARTIAL line, worded like ontology-traverse's."""552 cuts: list[str] = []553 for cut in result.get("truncation") or []:554 if isinstance(cut, str) and cut and cut not in cuts:555 cuts.append(cut)556 reasons: list[str] = []557 if cuts:558 limits = "limits" if len(cuts) > 1 else "limit"559 reasons.append(f"the graph was truncated at the {_join_words(cuts)} {limits}")560 if result.get("budgetExceeded"):561 reasons.append("the walk ran out of its read budget before it finished")562 if result.get("degreeEstimated"):563 reasons.append("some hub degrees were estimated, so the ranking is approximate")564 if not reasons:565 return None566 return f"(results are PARTIAL — {'; '.join(reasons)}; there may be more connected entities)"567568569def summarize(query: dict[str, Any], result: dict[str, Any]) -> str:570 """Render a ChainResult as synthesizer-ready text."""571 mode = query.get("mode")572 hop_count = _hop_count(query)573 neighbourhood = mode == "connect" and not query.get("to")574 if mode == "chain":575 title = f"Ontology chain ({_plural(hop_count, 'hop')})" if hop_count else "Ontology chain"576 nothing = "no objects reached"577 elif neighbourhood:578 title = "Ontology neighbourhood"579 nothing = "no connected entities found"580 else:581 title = "Ontology connection search"582 nothing = "no connection found"583 if mode != "chain" and hop_count:584 title += f" (up to {_plural(hop_count, 'hop')})"585586 results = [r for r in result.get("results") or [] if isinstance(r, dict)]587 groups = [g for g in result.get("groups") or [] if isinstance(g, dict)]588 if results:589 head = f"{title}: {_plural(len(results), 'result')}"590 if groups:591 head += f" in {_plural(len(groups), 'relation group')}"592 lines = [head + "."]593 body = _body_lines(results, groups, _walk_was_cut(result))594 lines.extend(body)595 # Only the rendered hops count: past the render cap a marker never shows.596 if any(f"{_UNREVIEWED_MARK}]" in line for line in body):597 lines.append(_UNREVIEWED_LEGEND)598 else:599 empty = result.get("emptyReason")600 detail = _one_line(empty.get("detail")) if isinstance(empty, dict) else ""601 # The engine's own reason (an unresolved start, a filter or type that602 # matched nothing) is never reported as "no connection".603 lines = [f"{title}: no results — {detail}." if detail else f"{title}: {nothing}."]604 lines.extend(_aggregate_lines(query, result))605 for line in (_withheld_line(query, result), _partial_line(result)):606 if line:607 lines.append(line)608 return "\n".join(lines)609610611# ---- the node ----------------------------------------------------------------612613614def _chain_api() -> Any:615 # Imported here, not at module top, so a run with nothing to walk (and the616 # examples harness, which has no executor image) never needs the SDK.617 from gais import Gais618619 chain = getattr(Gais.ontology, "chain", None)620 if not callable(chain):621 raise RuntimeError(622 "Gais.ontology.chain is missing from this executor's gais SDK; "623 "rebuild the workspace executor image from a version that ships it"624 )625 return chain626627628def _log(query: dict[str, Any], result: dict[str, Any] | None = None, refused: bool = False) -> None:629 """One stderr line of kinds, counts and flags: never a name, id or value."""630631 def start_kind(spec: Any) -> str:632 if not isinstance(spec, dict):633 return "-"634 if isinstance(spec.get("ids"), list):635 return f"ids:{len(spec['ids'])}"636 return "search" if "search" in spec else "type" if "type" in spec else "?"637638 mode = query.get("mode")639 fields = [640 f"mode={mode}",641 f"hops={_hop_count(query) or '?'}",642 f"start={start_kind(query.get('start') if mode == 'chain' else query.get('from'))}",643 ]644 if mode == "connect":645 fields.append(f"to={start_kind(query.get('to'))}")646 if refused:647 fields.append("refused=400")648 elif result is not None:649 results = [r for r in result.get("results") or [] if isinstance(r, dict)]650 cuts = [c for c in result.get("truncation") or [] if isinstance(c, str)]651 empty = result.get("emptyReason")652 fields += [653 f"results={len(results)}",654 f"paths={sum(len(r.get('paths') or []) for r in results)}",655 f"groups={len(result.get('groups') or [])}",656 f"truncation={','.join(cuts) or '-'}",657 f"budgetExceeded={bool(result.get('budgetExceeded'))}",658 f"degreeEstimated={bool(result.get('degreeEstimated'))}",659 f"empty={empty.get('kind') if isinstance(empty, dict) else '-'}",660 ]661 print("[ontology-chain] " + " ".join(fields), file=sys.stderr)662663664def process(inputs: dict[str, Any]) -> dict[str, Any]:665 query = build_query(inputs)666 if query is None:667 # Clean degradation: nothing resolved upstream, nothing to walk.668 print("[ontology-chain] no start and no query: empty outputs", file=sys.stderr)669 return {"result": {}, "summaryText": ""}670671 run_chain = _chain_api()672 try:673 result = run_chain(query)674 except Exception as err: # noqa: BLE001 — only the route's 400 is handled675 if getattr(err, "http_status", None) != 400:676 raise677 message = _one_line(getattr(err, "repair_message", None)) or _one_line(str(err))678 message = message or "the route gave no reason"679 _log(query, refused=True)680 return {681 "result": {682 "error": {683 "status": 400,684 "code": getattr(err, "error_code", None),685 "message": message,686 }687 },688 "summaryText": f"The ontology chain query was refused: {message}",689 }690691 if not isinstance(result, dict):692 result = {}693 summary_text = summarize(query, result)694 _log(query, result)695 return {"result": result, "summaryText": summary_text}696697698def main() -> None:699 try:700 envelope = json.loads(sys.stdin.read() or "{}")701 if not isinstance(envelope, dict):702 envelope = {}703 inputs = envelope.get("inputs", {}) or {}704 json.dump(process(inputs), sys.stdout)705 except Exception as e:706 print(707 json.dumps({708 "error": str(e),709 "errorType": type(e).__name__,710 "traceback": traceback.format_exc(),711 }),712 file=sys.stderr,713 )714 sys.exit(1)715716717if __name__ == "__main__":718 main()719$ git log --oneline
Each hop now carries the link's review status: AI-extracted relations not yet confirmed by a reviewer are marked '· unreviewed' in summaryText, with a legend. New Max Paths input, a whole-Query check, and a name-only Start.
Start and To take a name as the documents write it, never translated: an agent passed 'rischio di flusso di cassa' for the entity 现金流风险, which matched nothing.
Max Paths field (1-5, default 2) for more paths per result in both modes. A Query without its start and hops (chain) or from and maxHops (connect) fails the node, saying it replaces every field, Start included: an agent had set Query to only a path limit, which replaced the wired Start and was refused as a run that looked completed. The description leads with the question the node answers: 'how is X connected to Y?'.
The node description itself now says that Start and To take an entity's name only (the kind the user names goes in Start Type / To Type) and that a result shows 2 paths unless Query raises limits.maxPathsPerResult: agents reading only ports and widgets never saw the 1.1.0 field descriptions.
Field descriptions: a name is the entity's name only and the user's description of its kind goes in Start Type / To Type (plain words work); Query documents its real shape and limits.maxPathsPerResult (a result shows 2 paths by default, up to 5); summaryText marks a link no reviewer confirmed '· unreviewed' once the route sends link status.
First release: explained, source-cited ontology paths. Connect two entities, map everything around one (grouped by relation), or walk a typed chain of hops; emits a synthesizer-ready summaryText.