Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions nodes/src/nodes/anonymize/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ Default: **GLiNER Small - Lightweight general-purpose model** (`glinerSmall`).
Choose a profile when one of the supplied model choices suits the text you
process, or choose `custom` and supply a model name. Most pipelines then only
need to tailor the entity labels and decide whether retaining entity labels in
the output is useful. The profile selected when adding the node is
`glinerSmall`; the configuration field itself defaults to `glinerMergedLarge`.
the output is useful. `glinerSmall` is the default whether the node is added
from the editor or configured from a hand-written `.pipe`.

### Model

Expand Down Expand Up @@ -134,7 +134,7 @@ still redacted.
| Field | Type | Description | Default |
|---|---|---|---|
| `anonymize.model` | `string` | **Model name**<br/>Gliner model to use for anonymization | |
| `anonymize.profile` | `string` | **Model**<br/>Anonymize model | `"glinerMergedLarge"` |
| `anonymize.profile` | `string` | **Model**<br/>Anonymize model | `"glinerSmall"` |
| `anonymizeChar` | `string` | **Character to use for anonymization**<br/>Character | |

## Dependencies
Expand Down
2 changes: 1 addition & 1 deletion nodes/src/nodes/anonymize/services.json
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@
"title": "Model",
"description": "Anonymize model",
"type": "string",
"default": "glinerMergedLarge",
"default": "glinerSmall",
"enum": ["*>preconfig.profiles.*.title"],
"conditional": [
{
Expand Down
2 changes: 1 addition & 1 deletion nodes/src/nodes/store_pinecone/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ separate caller choice rather than an accidental result of broad searching.
| Field | Type | Description | Default |
|---|---|---|---|
| `pinecone.collection` | `string` | **Collection**<br/>Enter the name of the collection. Accepted are: Lower case, alphanumeric characters, hyphens | `"rocketride"` |
| `pinecone.profile` | `string` | **Type of Pinecone Connection**<br/>Connect to... | `"pod-based"` |
| `pinecone.profile` | `string` | **Type of Pinecone Connection**<br/>Connect to... | `"serverless-dense"` |
| `pinecone.provider` | `string` | | const: `"pinecone"` |
| `pinecone.serverName` | `string` | **Tool Server Name**<br/>Namespace for agent-facing tool names, e.g. 'pinecone' exposes tools as pinecone.search / pinecone.upsert / pinecone.delete. Change this when running multiple Pinecone nodes in the same pipeline so their tool names do not collide. | `"pinecone"` |

Expand Down
2 changes: 1 addition & 1 deletion nodes/src/nodes/store_pinecone/services.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@
"title": "Type of Pinecone Connection",
"description": "Connect to...",
"type": "string",
"default": "pod-based",
"default": "serverless-dense",
"enum": ["*>preconfig.profiles.*.title"],
"conditional": [
{
Expand Down
140 changes: 140 additions & 0 deletions nodes/test/test_profile_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# =============================================================================
# MIT License
# Copyright (c) 2026 Aparavi Software AG
# =============================================================================

"""services.json invariants for every node with a profile selector.

Generalizes the per-node check added for store_weaviate (#1952) into one
sweep over every node's services.json, per #1953: a node's profile choice
has two entry points that are meant to agree --

- `preconfig.default`, the fallback when a config carries no `profile` key
(resolved in `packages/ai/src/ai/common/config.py`), and
- the profile field's own `default`, which is what the editor pre-fills into
a fresh node and what the generated README params table documents.

When they disagree, which one a user lands on depends on how the config
reached the node: a hand-written `.pipe` gets one, an editor-created node
gets the other. This sweep catches the next node that copies an existing
one as a starting point and carries the drift forward, rather than relying
on someone noticing during review.

The profile-selector field is identified generically by its `enum`, a fixed
`"*>preconfig.profiles.*.title"` reference every node with this shape uses
to populate the dropdown from `preconfig.profiles` -- there is no other way
to name "the field that chooses a preconfig profile" from the schema alone.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Dict, List, Tuple

import pytest

_NODES_SRC = Path(__file__).resolve().parent.parent / 'src' / 'nodes'
_PROFILE_ENUM_MARKER = ['*>preconfig.profiles.*.title']

Case = Tuple[str, Path, Dict[str, Any], str, Dict[str, Any]]


def _strip_jsonc(raw: str) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should fix — this is the fourth copy of the JSONC stripper in nodes/test/, in a PR whose subject is copy-driven drift.

The docstring is honest about it — "Ported from store_weaviate/test_services_profile_default.py" — which is the tell. At this head the same job is done by:

  • nodes/test/store_weaviate/test_services_profile_default.py (the one this was ported from)
  • nodes/test/store_chroma/test_services_top_k.py
  • nodes/test/context_optimizer/test_all.py
  • nodes/test/tool_microsoft_365/test_services_json.py
  • and scripts/validate-node-readme.py outside the test tree
git grep -ln "strip_jsonc\|jsonc" -- nodes/test/ scripts/

This matters more than an ordinary duplicate because the function is a hand-written parser with a real edge case in it — tracking string state so a // inside a documentation URL is not treated as a comment. That edge case was found once and is now stated five times. The next services.json feature that trips it (a /* inside a description, an escaped quote sequence) gets fixed in whichever copy the author happened to be reading, and the other four keep the bug. A test helper that silently mis-parses fails open: _discover_profile_bearing_cases would just find fewer cases, and the sweep would go green with less coverage than it claims.

nodes/test/framework/ is already the shared home for node-test helpers (__init__.py, discovery.py, expectations.py, pipeline.py, runner.py). One module there:

# nodes/test/framework/services_json.py
def load_services(path: Path) -> Dict[str, Any]:
    """Parse a services*.json (JSONC) file into a plain dict."""
    return json.loads(_strip_jsonc(path.read_text(encoding='utf-8')))

with this file importing it and the four existing copies migrated as they are next touched. Migrating them all in this PR would widen it past its subject — landing the shared helper and using it here is enough, and it means the count stops growing.

Not blocking. Flagging it because this PR's own argument is that a rule stated twice eventually gets fixed in one place only, and that argument applies to its test infrastructure as much as to services.json.

"""Drop // and /* */ comments, leaving comment-like text inside strings alone.

services.json is JSONC, and `//` also occurs inside real values (a
`documentation` URL), so this tracks string state rather than
pattern-matching. Ported from store_weaviate/test_services_profile_default.py.
"""
out: List[str] = []
i, n = 0, len(raw)
in_string = False
while i < n:
ch = raw[i]
if in_string:
out.append(ch)
if ch == '\\' and i + 1 < n:
out.append(raw[i + 1])
i += 2
continue
if ch == '"':
in_string = False
i += 1
elif ch == '"':
in_string = True
out.append(ch)
i += 1
elif raw.startswith('//', i):
i = raw.find('\n', i)
if i == -1:
break
elif raw.startswith('/*', i):
end = raw.find('*/', i + 2)
i = n if end == -1 else end + 2
else:
out.append(ch)
i += 1
return ''.join(out)


def _load_services(path: Path) -> Dict[str, Any]:
return json.loads(_strip_jsonc(path.read_text(encoding='utf-8')))


def _discover_profile_bearing_cases() -> List[Case]:
"""Find every services*.json with a preconfig.default and its profile-selector field(s)."""
found: List[Case] = []
for node_dir in sorted(_NODES_SRC.iterdir()):
if not node_dir.is_dir() or node_dir.name.startswith('_'):
continue
for service_file in sorted(node_dir.glob('service*.json')):
try:
svc = _load_services(service_file)
except (json.JSONDecodeError, OSError):
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
preconfig = svc.get('preconfig')
if not isinstance(preconfig, dict) or 'default' not in preconfig:
continue
fields = svc.get('fields')
if not isinstance(fields, dict):
continue
for field_key, field_def in fields.items():
if isinstance(field_def, dict) and field_def.get('enum') == _PROFILE_ENUM_MARKER:
found.append((node_dir.name, service_file, svc, field_key, field_def))
return found


_CASES = _discover_profile_bearing_cases()
_CASE_IDS = [f'{node_name}:{field_key}' for node_name, _path, _svc, field_key, _field in _CASES]


@pytest.mark.parametrize('case', _CASES, ids=_CASE_IDS)
def test_preconfig_default_matches_profile_field_default(case: Case):
node_name, path, svc, field_key, field_def = case
preconfig_default = svc['preconfig']['default']
field_default = field_def.get('default')
assert preconfig_default == field_default, (
f'{node_name} ({path}): preconfig.default is {preconfig_default!r} but {field_key}.default '
f'is {field_default!r}; which one a user lands on would depend on whether their config '
f'arrived with a profile key or fell through to the field default'
)


@pytest.mark.parametrize('case', _CASES, ids=_CASE_IDS)
def test_default_profile_is_declared(case: Case):
"""The default named in preconfig.default must actually exist in preconfig.profiles."""
node_name, path, svc, _field_key, _field_def = case
default = svc['preconfig']['default']
profiles = svc['preconfig'].get('profiles', {})
assert default in profiles, (
f'{node_name} ({path}): default profile {default!r} is not defined in preconfig.profiles'
)


def test_the_sweep_actually_discovered_the_known_profile_bearing_nodes():
"""Guard against a silently-empty or broken sweep passing vacuously."""
discovered = {node_name for node_name, *_ in _CASES}
expected = {'store_weaviate', 'store_chroma', 'store_pinecone', 'anonymize'}
missing = expected - discovered
assert not missing, f'expected profile-bearing nodes not discovered by the sweep: {sorted(missing)}'
Loading