$ cat node-template.py

O

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.

Process
Data
#ontology#knowledge-graph#chain#paths#graph#read
template.py
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

v1.2.2
HEAD
2026-10-23

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.

v1.2.12026-10-23

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.

v1.2.02026-10-23

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?'.

v1.1.12026-10-23

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.

v1.1.02026-10-23

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.

v1.0.02026-10-23

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.