Skip to content

Commit 83165fb

Browse files
authored
Merge pull request #299 from poissoncorp/RDBC-1068-ai-config-fixes
RDBC-1068 Hotfix: C# parity fixes for AI config & spatial RQL
2 parents 1ab9583 + 7aee9aa commit 83165fb

9 files changed

Lines changed: 198 additions & 14 deletions

File tree

ravendb/documents/operations/ai/chunking_options.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ class ChunkingMethod(Enum):
1111
HTML_STRIP = "HtmlStrip"
1212

1313

14-
# Methods that support overlap tokens
14+
# Methods that support overlap tokens. Mirrors the server (TextChunker.cs) and the C#/Node clients:
15+
# only the two *paragraph* methods consume OverlapTokens; every other method ignores it.
1516
METHODS_SUPPORTING_OVERLAP_TOKENS = {
16-
ChunkingMethod.PLAIN_TEXT_SPLIT,
17-
ChunkingMethod.PLAIN_TEXT_SPLIT_LINES,
1817
ChunkingMethod.PLAIN_TEXT_SPLIT_PARAGRAPHS,
18+
ChunkingMethod.MARK_DOWN_SPLIT_PARAGRAPHS,
1919
}
2020

2121

@@ -44,8 +44,8 @@ def __init__(
4444
def from_json(cls, json_dict: Dict[str, Any]) -> "ChunkingOptions":
4545
return cls(
4646
chunking_method=ChunkingMethod(json_dict["ChunkingMethod"]),
47-
max_tokens_per_chunk=json_dict.get("MaxTokensPerChunk", None),
48-
overlap_tokens=json_dict.get("OverlapTokens", None),
47+
max_tokens_per_chunk=json_dict.get("MaxTokensPerChunk", 512),
48+
overlap_tokens=json_dict.get("OverlapTokens", 0),
4949
context_prefix=json_dict.get("ContextPrefix", None),
5050
)
5151

@@ -84,10 +84,8 @@ def validate(self, source: str, errors: List[str]) -> None:
8484
errors.append(f"{source}: OverlapTokens cannot be greater than MaxTokensPerChunk.")
8585

8686
if self.overlap_tokens > 0 and self.chunking_method not in METHODS_SUPPORTING_OVERLAP_TOKENS:
87-
errors.append(
88-
f"{source}: OverlapTokens is only supported for PlainTextSplit, "
89-
f"PlainTextSplitLines, and PlainTextSplitParagraphs chunking methods."
90-
)
87+
supported = ", ".join(sorted(method.value for method in METHODS_SUPPORTING_OVERLAP_TOKENS))
88+
errors.append(f"{source}: OverlapTokens is only supported for the following chunking methods: {supported}.")
9189

9290
@staticmethod
9391
def are_equal(left: Optional["ChunkingOptions"], right: Optional["ChunkingOptions"]) -> bool:

ravendb/documents/operations/ai/embeddings_generation_configuration.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,14 @@ def validate(
135135

136136
if not has_paths and not has_transformation:
137137
errors.append("Either EmbeddingsPathConfigurations or EmbeddingsTransformation must be provided")
138-
elif has_paths and has_transformation:
139-
errors.append("Cannot specify both EmbeddingsPathConfigurations and EmbeddingsTransformation")
138+
139+
# Validate each path's chunking options (mirrors the server / C# client; both may be set).
140+
if self.embeddings_path_configurations:
141+
for path_configuration in self.embeddings_path_configurations:
142+
if path_configuration.chunking_options is not None:
143+
path_configuration.chunking_options.validate(path_configuration.path, errors)
144+
else:
145+
errors.append(f"Path '{path_configuration.path}': ChunkingOptions must be provided.")
140146

141147
# Validate transformation if provided
142148
if has_transformation:

ravendb/documents/session/query.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1571,7 +1571,9 @@ def _order_by_distance_wkt(
15711571

15721572
round_factor_parameter_name = None if round_factor == 0 else self.__add_query_parameter(round_factor)
15731573
self._order_by_tokens.append(
1574-
OrderByToken.create_distance_ascending_wkt(field_name, shape_wkt, round_factor_parameter_name, nulls)
1574+
OrderByToken.create_distance_ascending_wkt(
1575+
field_name, self.__add_query_parameter(shape_wkt), round_factor_parameter_name, nulls
1576+
)
15751577
)
15761578

15771579
def _order_by_distance_descending(
@@ -1627,7 +1629,9 @@ def _order_by_distance_descending_wkt(
16271629
round_factor_parameter_name = None if round_factor == 0 else self.__add_query_parameter(round_factor)
16281630

16291631
self._order_by_tokens.append(
1630-
OrderByToken.create_distance_descending_wkt(field_name, shape_wkt, round_factor_parameter_name, nulls)
1632+
OrderByToken.create_distance_descending_wkt(
1633+
field_name, self.__add_query_parameter(shape_wkt), round_factor_parameter_name, nulls
1634+
)
16311635
)
16321636

16331637
def _init_sync(self) -> None:

ravendb/documents/session/tokens/query_tokens/definitions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ def create_distance_ascending_wkt(
299299
nulls: NullsOrdering = NullsOrdering.DEFAULT,
300300
) -> OrderByToken:
301301
return cls(
302-
f"spatial.distance({field_name}), "
302+
f"spatial.distance({field_name}, "
303303
f"spatial.wkt(${wkt_parameter_name})"
304304
f"{'' if round_factor_parameter_name is None else ', $' + round_factor_parameter_name})",
305305
False,

ravendb/tests/embeddings_generation_tests/test_chunking_options.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,36 @@ def test_no_chunking_marker_bypasses_budget_validation(self):
4545
options.validate("source", errors)
4646
self.assertEqual([], errors)
4747

48+
def test_overlap_allowed_only_on_paragraph_methods(self):
49+
# Matches the server / C# / Node clients: overlap is consumed only by the two paragraph methods.
50+
for method in (ChunkingMethod.PLAIN_TEXT_SPLIT_PARAGRAPHS, ChunkingMethod.MARK_DOWN_SPLIT_PARAGRAPHS):
51+
errors = []
52+
ChunkingOptions(method, 100, 10).validate("source", errors)
53+
self.assertEqual([], errors, method)
54+
55+
def test_overlap_rejected_on_non_paragraph_methods(self):
56+
for method in (
57+
ChunkingMethod.PLAIN_TEXT_SPLIT,
58+
ChunkingMethod.PLAIN_TEXT_SPLIT_LINES,
59+
ChunkingMethod.MARK_DOWN_SPLIT_LINES,
60+
ChunkingMethod.HTML_STRIP,
61+
):
62+
errors = []
63+
ChunkingOptions(method, 100, 10).validate("source", errors)
64+
self.assertEqual(1, len(errors), method)
65+
self.assertIn("OverlapTokens", errors[0])
66+
67+
def test_from_json_missing_int_keys_use_csharp_defaults(self):
68+
# Missing MaxTokensPerChunk/OverlapTokens must default to 512/0 (matching C#'s non-nullable
69+
# int initializers), not None. None would crash validate() with a TypeError.
70+
options = ChunkingOptions.from_json({"ChunkingMethod": "HtmlStrip"})
71+
self.assertEqual(512, options.max_tokens_per_chunk)
72+
self.assertEqual(0, options.overlap_tokens)
73+
74+
errors = []
75+
options.validate("source", errors) # must not raise TypeError on the defaults
76+
self.assertEqual([], errors)
77+
4878

4979
if __name__ == "__main__":
5080
unittest.main()
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import unittest
2+
3+
from ravendb.documents.operations.ai.chunking_options import ChunkingOptions, ChunkingMethod
4+
from ravendb.documents.operations.ai.embedding_path_configuration import EmbeddingPathConfiguration
5+
from ravendb.documents.operations.ai.embeddings_generation_configuration import EmbeddingsGenerationConfiguration
6+
from ravendb.documents.operations.ai.embeddings_transformation import EmbeddingsTransformation
7+
8+
9+
def _config(**overrides) -> EmbeddingsGenerationConfiguration:
10+
# A base config that passes every check except the paths/transformation logic under test.
11+
base = dict(
12+
name="emb",
13+
identifier="emb",
14+
collection="Docs",
15+
connection_string_name="cs",
16+
chunking_options_for_querying=ChunkingOptions(ChunkingMethod.HTML_STRIP, 100, 0),
17+
)
18+
base.update(overrides)
19+
return EmbeddingsGenerationConfiguration(**base)
20+
21+
22+
def _validate(config) -> list:
23+
return config.validate(validate_name=False, validate_identifier=False)
24+
25+
26+
class TestEmbeddingsGenerationConfigurationValidate(unittest.TestCase):
27+
def test_paths_and_transformation_together_are_allowed(self):
28+
# C# has no mutual-exclusivity rule; setting both must NOT be rejected client-side.
29+
config = _config(
30+
embeddings_path_configurations=[
31+
EmbeddingPathConfiguration("Name", ChunkingOptions(ChunkingMethod.HTML_STRIP, 100, 0))
32+
],
33+
embeddings_transformation=EmbeddingsTransformation(script="embeddings.generate(this.Name)"),
34+
)
35+
errors = _validate(config)
36+
self.assertNotIn("Cannot specify both EmbeddingsPathConfigurations and EmbeddingsTransformation", errors)
37+
self.assertEqual([], errors)
38+
39+
def test_path_missing_chunking_options_is_rejected(self):
40+
# Mirrors C#: each path must carry ChunkingOptions.
41+
config = _config(embeddings_path_configurations=[EmbeddingPathConfiguration("Name", None)])
42+
errors = _validate(config)
43+
self.assertIn("Path 'Name': ChunkingOptions must be provided.", errors)
44+
45+
def test_path_invalid_chunking_options_is_rejected(self):
46+
# Each path's ChunkingOptions is validated with the path as the error source.
47+
config = _config(
48+
embeddings_path_configurations=[
49+
EmbeddingPathConfiguration("Name", ChunkingOptions(ChunkingMethod.HTML_STRIP, 0, 0))
50+
]
51+
)
52+
errors = _validate(config)
53+
self.assertTrue(
54+
any("Name" in e and "MaxTokensPerChunk" in e for e in errors),
55+
f"expected a per-path MaxTokensPerChunk error, got: {errors}",
56+
)
57+
58+
def test_path_with_valid_chunking_options_passes(self):
59+
config = _config(
60+
embeddings_path_configurations=[
61+
EmbeddingPathConfiguration("Name", ChunkingOptions(ChunkingMethod.HTML_STRIP, 100, 0))
62+
]
63+
)
64+
self.assertEqual([], _validate(config))
65+
66+
67+
if __name__ == "__main__":
68+
unittest.main()

ravendb/tests/embeddings_generation_tests/test_tasks_management.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
UpdateEmbeddingsGenerationOperation,
1515
)
1616
from ravendb.documents.operations.ai.embedded_settings import EmbeddedSettings
17+
from ravendb.documents.operations.ai.embeddings_transformation import EmbeddingsTransformation
1718
from ravendb.documents.operations.connection_string.put_connection_string_operation import PutConnectionStringOperation
1819
from ravendb.documents.operations.connection_string.remove_connection_string_operation import (
1920
RemoveConnectionStringOperation,
@@ -214,6 +215,27 @@ def test_embeddings_generation_configuration_without_chunking_options_should_pro
214215
self.assertIn("PostContent", error_message)
215216
self.assertIn("ChunkingOptions", error_message)
216217

218+
def test_can_add_task_with_both_paths_and_transformation(self):
219+
"""The server imposes no mutual-exclusivity between paths and transformation, so a config
220+
that sets BOTH must be accepted (the client must not reject it pre-send either)."""
221+
config = EmbeddingsGenerationConfiguration(
222+
name="ai-task-both",
223+
connection_string_name=self.CONNECTION_STRING_NAME,
224+
embeddings_path_configurations=[
225+
EmbeddingPathConfiguration(path="PostContent", chunking_options=self.DEFAULT_CHUNKING_OPTIONS),
226+
],
227+
embeddings_transformation=EmbeddingsTransformation(
228+
script="embeddings.generate(this.Comments)",
229+
chunking_options=self.DEFAULT_CHUNKING_OPTIONS,
230+
),
231+
collection="Posts",
232+
chunking_options_for_querying=self.DEFAULT_CHUNKING_OPTIONS,
233+
)
234+
235+
add_result = self.store.maintenance.send(AddEmbeddingsGenerationOperation(config))
236+
self.assertIsNotNone(add_result.task_id)
237+
self._created_task_ids.append(add_result.task_id)
238+
217239
def test_database_record_contains_embeddings_generations(self):
218240
config = self._create_valid_config()
219241

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import unittest
2+
3+
from ravendb.documents.session.tokens.query_tokens.definitions import OrderByToken
4+
5+
6+
def _rql(token: OrderByToken) -> str:
7+
writer = []
8+
token.write_to(writer)
9+
return "".join(writer)
10+
11+
12+
class TestOrderByDistanceWktRql(unittest.TestCase):
13+
"""The WKT ascending distance factory must nest spatial.wkt(...) inside spatial.distance(field, ...),
14+
matching C# (OrderByToken.cs) and its three sibling factories. The bug emitted a stray ')' right
15+
after the field name: 'spatial.distance(loc), spatial.wkt(...)'."""
16+
17+
def test_distance_ascending_wkt_nests_wkt_inside_distance(self):
18+
rql = _rql(OrderByToken.create_distance_ascending_wkt("loc", "p0", None))
19+
self.assertIn("spatial.distance(loc, spatial.wkt($p0))", rql)
20+
self.assertNotIn("spatial.distance(loc),", rql)
21+
22+
def test_distance_ascending_wkt_matches_descending_structure(self):
23+
asc = _rql(OrderByToken.create_distance_ascending_wkt("loc", "p0", None))
24+
desc = _rql(OrderByToken.create_distance_descending_wkt("loc", "p0", None))
25+
# Identical apart from the trailing descending marker.
26+
self.assertEqual(asc, desc.replace(" desc", ""))
27+
28+
def test_distance_ascending_wkt_with_round_factor(self):
29+
rql = _rql(OrderByToken.create_distance_ascending_wkt("loc", "p0", "p1"))
30+
self.assertIn("spatial.distance(loc, spatial.wkt($p0), $p1)", rql)
31+
32+
33+
if __name__ == "__main__":
34+
unittest.main()

ravendb/tests/jvm_migrated_tests/spatial_tests/test_order_by_distance_quoting.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,28 @@ def test_order_by_distance_descending_with_dynamic_point_field(self):
4848
self.assertGreaterEqual(len(results), 2)
4949
self.assertEqual("geo/2", results[0].Id)
5050

51+
def test_order_by_distance_wkt_ascending_with_dynamic_point_field(self):
52+
# Regression for the create_distance_ascending_wkt RQL bug: a stray ')' produced
53+
# 'spatial.distance(<field>), spatial.wkt(...)', which the server rejects at parse time.
54+
# WKT is "POINT(longitude latitude)"; the point below is geo/1 (Greenwich).
55+
with self.store.open_session() as s:
56+
results = list(
57+
s.query(object_type=_Geo).order_by_distance_wkt(PointField("lat", "lng"), "POINT(0.0015 51.4779)")
58+
)
59+
self.assertGreaterEqual(len(results), 2)
60+
self.assertEqual("geo/1", results[0].Id)
61+
62+
def test_order_by_distance_wkt_descending_with_dynamic_point_field(self):
63+
# Same WKT path, descending: the farthest document (New York) comes first.
64+
with self.store.open_session() as s:
65+
results = list(
66+
s.query(object_type=_Geo).order_by_distance_descending_wkt(
67+
PointField("lat", "lng"), "POINT(0.0015 51.4779)"
68+
)
69+
)
70+
self.assertGreaterEqual(len(results), 2)
71+
self.assertEqual("geo/2", results[0].Id)
72+
5173

5274
if __name__ == "__main__":
5375
unittest.main()

0 commit comments

Comments
 (0)