diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4ce42bc7..6ad50d3e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,49 +1,12 @@ -### Issue link +### Issue https://issues.hibernatingrhinos.com/issue/RDBC-... -### Additional description +### What changed -...Include details of the change made in this Pull Request or additional notes for the solution. Anything that can be useful for reviewers of this PR... +... -### Type of change +### Checklist -- [ ] Bug fix -- [ ] Regression bug fix -- [ ] Optimization -- [ ] New feature - -### How risky is the change? - -- [ ] Low -- [ ] Moderate -- [ ] High -- [ ] Not relevant - -### Backward compatibility - -- [ ] Non breaking change -- [ ] Ensured. Please explain how has it been implemented? -- [ ] Breaking change -- [ ] Not relevant - -### Is it platform specific issue? - -- [ ] Yes. Please list the affected platforms. -- [ ] No - -### Documentation update - -- [ ] This change requires a documentation update. Please mark the issue on YouTrack using `Python Documentation Required` tag. -- [ ] No documentation update is needed - -### Testing by Contributor - -- [ ] Tests have been added that prove the fix is effective or that the feature works -- [ ] It has been verified by manual testing -- [ ] Existing tests verify the correct behavior - -### Is there any existing behavior change of other features due to this change? - -- [ ] Yes. Please list the affected features/subsystems and provide appropriate explanation -- [ ] No +- [ ] Tests added or existing tests cover the change +- [ ] Breaking change (explain above if checked) diff --git a/.gitignore b/.gitignore index 2afc7c9d..dd77a9fb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ *.raven-cluster-topology /build/lib/ravendb /venv/ +/.venv/ diff --git a/ravendb/__init__.py b/ravendb/__init__.py index 4c1948bc..ea8bde2c 100644 --- a/ravendb/__init__.py +++ b/ravendb/__init__.py @@ -247,6 +247,7 @@ MethodCall, OptimisticConcurrencyMode, OrderingType, + NullsOrdering, JavaScriptMap, DocumentQueryCustomization, ResponseTimeInformation, diff --git a/ravendb/documents/ai/ai_conversation.py b/ravendb/documents/ai/ai_conversation.py index c5ec6bf7..50ea0fef 100644 --- a/ravendb/documents/ai/ai_conversation.py +++ b/ravendb/documents/ai/ai_conversation.py @@ -42,18 +42,21 @@ def __init__( options: AiConversationCreationOptions = None, conversation_id: str = None, change_vector: str = None, + debug: Optional[bool] = None, ): self._store = store self._agent_id = agent_id self._options = options or AiConversationCreationOptions() self._conversation_id = conversation_id self._change_vector = change_vector + self._debug = debug self._prompt_parts: List[ContentPart] = [] self._action_responses: Dict[str, AiAgentActionResponse] = {} self._artificial_actions: List[AiAgentArtificialActionResponse] = [] self._action_requests: Optional[List[AiAgentActionRequest]] = None self._attachments_commands: List = [] + self._dispatched_tool_ids: set = set() self._invocations: Dict[str, Callable[[AiAgentActionRequest], None]] = {} self.on_unhandled_action: Optional[Callable[[UnhandledActionEventArgs], None]] = None @@ -135,6 +138,8 @@ def add_artificial_action_with_response(self, tool_id: str, action_response) -> self._artificial_actions.append(AiAgentArtificialActionResponse(tool_id=tool_id, content=content)) def run(self) -> AiAnswer: + self._dispatched_tool_ids.clear() + while True: r = self._run_internal() if self._handle_server_reply(r): @@ -154,9 +159,10 @@ def _run_internal( from ravendb.documents.operations.ai.agents import RunConversationOperation import time - # Already round-tripped and nothing new to send. + # Already round-tripped and nothing new to send (no pending actions either). if ( self._action_requests is not None + and len(self._action_requests) == 0 and len(self._prompt_parts) == 0 and len(self._action_responses) == 0 and len(self._artificial_actions) == 0 @@ -187,6 +193,7 @@ def _run_internal( stream_property_path=stream_property_path, streamed_chunks_callback=streamed_chunks_callback, attachments_commands=self._attachments_commands, + debug=self._debug, ) try: @@ -225,6 +232,11 @@ def _handle_server_reply(self, answer: AiAnswer) -> bool: ) for action in self._action_requests: + # Skip actions we've already dispatched in a previous turn of this run + if action.tool_id in self._dispatched_tool_ids: + continue + self._dispatched_tool_ids.add(action.tool_id) + if action.name in self._invocations: self._invocations[action.name](action) elif self.on_unhandled_action is not None: diff --git a/ravendb/documents/ai/ai_operations.py b/ravendb/documents/ai/ai_operations.py index 963ff04a..77bf1ad8 100644 --- a/ravendb/documents/ai/ai_operations.py +++ b/ravendb/documents/ai/ai_operations.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, Any, Type +from typing import TYPE_CHECKING, Dict, Any, Optional, Type import warnings @@ -77,6 +77,7 @@ def conversation( conversation_id: str, creation_options: "AiConversationCreationOptions" = None, change_vector: str = None, + debug: Optional[bool] = None, ) -> AiConversation: """ Creates a new conversation with the specified AI agent. @@ -86,12 +87,13 @@ def conversation( conversation_id: The unique identifier for the conversation. You can also use e.g. chats/ for automatic id. creation_options: Optional creation options for the conversation change_vector: Optional change vector for concurrency control + debug: Optional flag enabling server-side conversation debugging Returns: Conversation operations interface for managing the conversation """ - return AiConversation(self._store, agent_id, creation_options, conversation_id, change_vector) + return AiConversation(self._store, agent_id, creation_options, conversation_id, change_vector, debug) def conversation_with_id(self, conversation_id: str, change_vector: str = None) -> AiConversation: """ diff --git a/ravendb/documents/operations/ai/agents/run_conversation_operation.py b/ravendb/documents/operations/ai/agents/run_conversation_operation.py index 68ddb438..b830284e 100644 --- a/ravendb/documents/operations/ai/agents/run_conversation_operation.py +++ b/ravendb/documents/operations/ai/agents/run_conversation_operation.py @@ -294,6 +294,7 @@ def __init__( stream_property_path: Optional[str] = None, streamed_chunks_callback: Optional[Callable[[str], None]] = None, attachments_commands: Optional[List[Any]] = None, + debug: Optional[bool] = None, ): if not agent_id or (isinstance(agent_id, str) and agent_id.isspace()): raise ValueError("agent_id cannot be None or empty") @@ -312,6 +313,7 @@ def __init__( self._stream_property_path = stream_property_path self._streamed_chunks_callback = streamed_chunks_callback self._attachments_commands = attachments_commands or [] + self._debug = debug def get_command(self, conventions: DocumentConventions) -> RavenCommand[ConversationResult[TSchema]]: return RunConversationCommand( @@ -326,6 +328,7 @@ def get_command(self, conventions: DocumentConventions) -> RavenCommand[Conversa streamed_chunks_callback=self._streamed_chunks_callback, conventions=conventions, attachments_commands=self._attachments_commands, + debug=self._debug, ) @@ -343,6 +346,7 @@ def __init__( streamed_chunks_callback: Optional[Callable[[str], None]] = None, conventions: Optional[DocumentConventions] = None, attachments_commands: Optional[List[Any]] = None, + debug: Optional[bool] = None, ): from ravendb.util.util import RaftIdGenerator from ravendb.documents.commands.batches import PutAttachmentCommandData @@ -358,6 +362,7 @@ def __init__( self._stream_property_path = stream_property_path self._streamed_chunks_callback = streamed_chunks_callback self._conventions = conventions + self._debug = debug self._attachments_commands = attachments_commands or [] # Raft id pinned at construction so retries keep the same id. @@ -400,6 +405,10 @@ def create_request(self, node: ServerNode) -> requests.Request: if self._stream_property_path: url += f"&streaming=true&streamPropertyPath={quote(self._stream_property_path)}" + # Add debug flag if requested + if self._debug is not None: + url += f"&debug={self._debug}" + request_body = ConversationRequestBody( action_responses=self._action_responses, artificial_actions=self._artificial_actions, diff --git a/ravendb/documents/operations/ai/chunking_options.py b/ravendb/documents/operations/ai/chunking_options.py index 2e35bc0b..3f2ed98e 100644 --- a/ravendb/documents/operations/ai/chunking_options.py +++ b/ravendb/documents/operations/ai/chunking_options.py @@ -25,17 +25,28 @@ def __init__( chunking_method: Optional[ChunkingMethod] = None, max_tokens_per_chunk: int = 512, overlap_tokens: int = 0, + context_prefix: Optional[str] = None, ): self.chunking_method = chunking_method self.max_tokens_per_chunk = max_tokens_per_chunk self.overlap_tokens = overlap_tokens + # Optional constant text prepended to every produced chunk before it is sent to the embedding model. + # Useful for adding broader document context (e.g. title) to isolated chunks. The prefix's tokens count + # against max_tokens_per_chunk - the effective chunking budget is reduced accordingly. + self.context_prefix = context_prefix + + # Internal-only marker: when set, the value is emitted unchunked with context_prefix prepended, and + # max_tokens_per_chunk / overlap_tokens are ignored. Never set by user-constructed config; not serialized. + self.no_chunking = False + @classmethod def from_json(cls, json_dict: Dict[str, Any]) -> "ChunkingOptions": return cls( chunking_method=ChunkingMethod(json_dict["ChunkingMethod"]), max_tokens_per_chunk=json_dict.get("MaxTokensPerChunk", None), overlap_tokens=json_dict.get("OverlapTokens", None), + context_prefix=json_dict.get("ContextPrefix", None), ) def to_json(self) -> Dict[str, Any]: @@ -43,6 +54,7 @@ def to_json(self) -> Dict[str, Any]: "ChunkingMethod": self.chunking_method.value if self.chunking_method else None, "MaxTokensPerChunk": self.max_tokens_per_chunk, "OverlapTokens": self.overlap_tokens, + "ContextPrefix": self.context_prefix, } def validate(self, source: str, errors: List[str]) -> None: @@ -52,6 +64,16 @@ def validate(self, source: str, errors: List[str]) -> None: source: The source context for error messages (e.g., 'embeddings.generate'). errors: List to append validation errors to. """ + if self.context_prefix is not None and not self.context_prefix.strip(): + errors.append( + f"{source}: ContextPrefix cannot be empty or whitespace-only. " + f"Either provide a non-empty value or omit it." + ) + + # no_chunking is set only by the with_context_prefix handler on raw strings/arrays and bypasses budget rules. + if self.no_chunking: + return + if self.max_tokens_per_chunk <= 0: errors.append(f"{source}: MaxTokensPerChunk must be greater than 0.") @@ -87,7 +109,17 @@ def __eq__(self, other: object) -> bool: self.chunking_method == other.chunking_method and self.max_tokens_per_chunk == other.max_tokens_per_chunk and self.overlap_tokens == other.overlap_tokens + and self.context_prefix == other.context_prefix + and self.no_chunking == other.no_chunking ) def __hash__(self) -> int: - return hash((self.chunking_method, self.max_tokens_per_chunk, self.overlap_tokens)) + return hash( + ( + self.chunking_method, + self.max_tokens_per_chunk, + self.overlap_tokens, + self.context_prefix, + self.no_chunking, + ) + ) diff --git a/ravendb/documents/session/misc.py b/ravendb/documents/session/misc.py index 55d2ffd4..84d201e1 100644 --- a/ravendb/documents/session/misc.py +++ b/ravendb/documents/session/misc.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime import hashlib +import json import threading from abc import ABC from enum import Enum @@ -338,15 +339,26 @@ def _get_next_argument_name(self) -> str: self._arg_counter += 1 return f"val_{self._arg_counter - 1}_{self._suffix}" + @staticmethod + def _format_key_for_javascript(key: _T_Key) -> str: + # Emit the key as a JS string literal (with surrounding quotes and proper escaping) so that keys + # containing dots, spaces, quotes, or numeric/special characters are handled correctly. + if key is None: + raise ValueError("Dictionary key cannot be None") + return json.dumps(str(key)) + def put(self, key: _T_Key, value: _T_Value) -> JavaScriptMap[_T_Key, _T_Value]: argument_name = self._get_next_argument_name() - self._script_lines.append(f"this.{self._path_to_map}.{key} = args.{argument_name};") + formatted_key = self._format_key_for_javascript(key) + self._script_lines.append(f"this.{self._path_to_map}[{formatted_key}] = args.{argument_name};") self.parameters[argument_name] = value return self def remove(self, key: _T_Key) -> JavaScriptMap[_T_Key, _T_Value]: - self._script_lines.append(f"delete this.{self._path_to_map}.{key};") + formatted_key = self._format_key_for_javascript(key) + self._script_lines.append(f"delete this.{self._path_to_map}[{formatted_key}];") + return self class MethodCall(ABC): @@ -379,3 +391,22 @@ class OrderingType(Enum): def __str__(self): return self.value + + +class NullsOrdering(Enum): + """ + Controls where ``null`` values are placed in the result of an ``ORDER BY`` clause. + + Per-query null placement (``FIRST`` / ``LAST``) is supported only by the Corax indexing engine. + Queries that specify ``FIRST`` or ``LAST`` against a Lucene index are rejected. + """ + + # No per-query placement is specified; the index/server configuration decides where nulls go. + DEFAULT = "Default" + # Null values appear first in the result, regardless of sort direction. Corax only. + FIRST = "First" + # Null values appear last in the result, regardless of sort direction. Corax only. + LAST = "Last" + + def __str__(self): + return self.value diff --git a/ravendb/documents/session/query.py b/ravendb/documents/session/query.py index c1fd23fa..3d6402cd 100644 --- a/ravendb/documents/session/query.py +++ b/ravendb/documents/session/query.py @@ -56,7 +56,13 @@ from ravendb.exceptions.exceptions import InvalidOperationException from ravendb.documents.session.event_args import BeforeQueryEventArgs from ravendb.documents.session.loaders.include import IncludeBuilderBase, QueryIncludeBuilder -from ravendb.documents.session.misc import MethodCall, CmpXchg, OrderingType, DocumentQueryCustomization +from ravendb.documents.session.misc import ( + MethodCall, + CmpXchg, + OrderingType, + NullsOrdering, + DocumentQueryCustomization, +) from ravendb.documents.session.operations.lazy import LazyQueryOperation from ravendb.documents.session.operations.query import QueryOperation from ravendb.documents.session.query_group_by import GroupByDocumentQuery @@ -806,7 +812,10 @@ def _proximity(self, proximity: int) -> None: where_token.options.proximity = proximity def _order_by( - self, field: str, sorter_name_or_ordering_type: Optional[Union[str, OrderingType]] = OrderingType.STRING + self, + field: str, + sorter_name_or_ordering_type: Optional[Union[str, OrderingType]] = OrderingType.STRING, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> None: is_ordering_type = isinstance(sorter_name_or_ordering_type, OrderingType) if not is_ordering_type and sorter_name_or_ordering_type.isspace(): @@ -814,10 +823,13 @@ def _order_by( self.__assert_no_raw_query() f = self._ensure_valid_field_name(field, False) - self._order_by_tokens.append(OrderByToken.create_ascending(f, sorter_name_or_ordering_type)) + self._order_by_tokens.append(OrderByToken.create_ascending(f, sorter_name_or_ordering_type, nulls)) def _order_by_descending( - self, field: str, sorter_name_or_ordering_type: Optional[Union[str, OrderingType]] = OrderingType.STRING + self, + field: str, + sorter_name_or_ordering_type: Optional[Union[str, OrderingType]] = OrderingType.STRING, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> None: is_ordering_type = isinstance(sorter_name_or_ordering_type, OrderingType) if not is_ordering_type and sorter_name_or_ordering_type.isspace(): @@ -825,7 +837,7 @@ def _order_by_descending( self.__assert_no_raw_query() f = self._ensure_valid_field_name(field, False) - self._order_by_tokens.append(OrderByToken.create_descending(f, sorter_name_or_ordering_type)) + self._order_by_tokens.append(OrderByToken.create_descending(f, sorter_name_or_ordering_type, nulls)) def _order_by_score(self) -> None: self.__assert_no_raw_query() @@ -1515,6 +1527,7 @@ def _order_by_distance( latitude: float, longitude: float, round_factor: float = 0, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> None: is_dynamic_field = isinstance(field_or_field_name, DynamicSpatialField) if is_dynamic_field: @@ -1534,6 +1547,7 @@ def _order_by_distance( self.__add_query_parameter(latitude), self.__add_query_parameter(longitude), round_factor_parameter_name, + nulls, ) ) @@ -1542,6 +1556,7 @@ def _order_by_distance_wkt( field_or_field_name: Union[DynamicSpatialField, str], shape_wkt: str, round_factor: float = 0, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> None: is_dynamic_field = isinstance(field_or_field_name, DynamicSpatialField) if is_dynamic_field: @@ -1556,7 +1571,7 @@ def _order_by_distance_wkt( round_factor_parameter_name = None if round_factor == 0 else self.__add_query_parameter(round_factor) self._order_by_tokens.append( - OrderByToken.create_distance_ascending_wkt(field_name, shape_wkt, round_factor_parameter_name) + OrderByToken.create_distance_ascending_wkt(field_name, shape_wkt, round_factor_parameter_name, nulls) ) def _order_by_distance_descending( @@ -1565,6 +1580,7 @@ def _order_by_distance_descending( latitude: float, longitude: float, round_factor: float = 0, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> None: is_dynamic_field = isinstance(field_or_field_name, DynamicSpatialField) @@ -1586,6 +1602,7 @@ def _order_by_distance_descending( self.__add_query_parameter(latitude), self.__add_query_parameter(longitude), round_factor_parameter_name, + nulls, ) ) @@ -1594,6 +1611,7 @@ def _order_by_distance_descending_wkt( field_or_field_name: Union[DynamicSpatialField, str], shape_wkt: str, round_factor: float = 0, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ): is_dynamic_field = isinstance(field_or_field_name, DynamicSpatialField) @@ -1609,7 +1627,7 @@ def _order_by_distance_descending_wkt( round_factor_parameter_name = None if round_factor == 0 else self.__add_query_parameter(round_factor) self._order_by_tokens.append( - OrderByToken.create_distance_descending_wkt(field_name, shape_wkt, round_factor_parameter_name) + OrderByToken.create_distance_descending_wkt(field_name, shape_wkt, round_factor_parameter_name, nulls) ) def _init_sync(self) -> None: @@ -1859,12 +1877,16 @@ def add_parameter(self, name: str, value: object) -> DocumentQuery[_T]: return self def add_order( - self, field_name: str, descending: bool, ordering: OrderingType = OrderingType.STRING + self, + field_name: str, + descending: bool, + ordering: OrderingType = OrderingType.STRING, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> DocumentQuery[_T]: if descending: - self._order_by_descending(field_name, ordering) + self._order_by_descending(field_name, ordering, nulls) else: - self._order_by(field_name, ordering) + self._order_by(field_name, ordering, nulls) return self def add_after_query_executed_listener(self, action: Callable[[QueryResult], None]) -> DocumentQuery[_T]: @@ -2536,15 +2558,21 @@ def of_type(self, t_result_class: Type[_TResult]) -> DocumentQuery[_TResult]: return self.create_document_query_internal(t_result_class) def order_by( - self, field: str, sorter_name_or_ordering_type: Union[str, OrderingType] = OrderingType.STRING + self, + field: str, + sorter_name_or_ordering_type: Union[str, OrderingType] = OrderingType.STRING, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> DocumentQuery[_T]: - self._order_by(field, sorter_name_or_ordering_type) + self._order_by(field, sorter_name_or_ordering_type, nulls) return self def order_by_descending( - self, field: str, sorter_name_or_ordering_type: Union[str, OrderingType] = OrderingType.STRING + self, + field: str, + sorter_name_or_ordering_type: Union[str, OrderingType] = OrderingType.STRING, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> DocumentQuery[_T]: - self._order_by_descending(field, sorter_name_or_ordering_type) + self._order_by_descending(field, sorter_name_or_ordering_type, nulls) return self def add_before_query_executed_listener(self, action: Callable[[IndexQuery], None]) -> DocumentQuery[_T]: @@ -2700,14 +2728,18 @@ def order_by_distance( latitude: float, longitude: float, round_factor: Optional[float] = 0.0, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> DocumentQuery[_T]: - self._order_by_distance(field_or_field_name, latitude, longitude, round_factor) + self._order_by_distance(field_or_field_name, latitude, longitude, round_factor, nulls) return self def order_by_distance_wkt( - self, field_or_field_name: Union[str, DynamicSpatialField], shape_wkt: str + self, + field_or_field_name: Union[str, DynamicSpatialField], + shape_wkt: str, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> DocumentQuery[_T]: - self._order_by_distance_wkt(field_or_field_name, shape_wkt) + self._order_by_distance_wkt(field_or_field_name, shape_wkt, nulls=nulls) return self def order_by_distance_descending( @@ -2716,14 +2748,18 @@ def order_by_distance_descending( latitude: float, longitude: float, round_factor: Optional[float] = 0.0, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> DocumentQuery[_T]: - self._order_by_distance_descending(field_or_field_name, latitude, longitude, round_factor) + self._order_by_distance_descending(field_or_field_name, latitude, longitude, round_factor, nulls) return self def order_by_distance_descending_wkt( - self, field_or_field_name: Union[str, DynamicSpatialField], shape_wkt: str + self, + field_or_field_name: Union[str, DynamicSpatialField], + shape_wkt: str, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> DocumentQuery[_T]: - self._order_by_distance_descending_wkt(field_or_field_name, shape_wkt) + self._order_by_distance_descending_wkt(field_or_field_name, shape_wkt, nulls=nulls) return self def more_like_this( diff --git a/ravendb/documents/session/tokens/query_tokens/definitions.py b/ravendb/documents/session/tokens/query_tokens/definitions.py index 5785fb41..dad77529 100644 --- a/ravendb/documents/session/tokens/query_tokens/definitions.py +++ b/ravendb/documents/session/tokens/query_tokens/definitions.py @@ -13,7 +13,7 @@ TimeSeriesCountRange, ) from ravendb.primitives import constants -from ravendb.documents.session.misc import OrderingType +from ravendb.documents.session.misc import OrderingType, NullsOrdering from ravendb.documents.indexes.spatial.configuration import SpatialUnits from ravendb.documents.queries.group_by import GroupByMethod from ravendb.documents.queries.misc import SearchOperator @@ -273,7 +273,13 @@ def score_ascending(cls) -> OrderByToken: def score_descending(cls) -> OrderByToken: return cls("score()", True, OrderingType.STRING) - def __init__(self, field_name: str, descending: bool, ordering_or_sorter_name: Union[OrderingType, str]): + def __init__( + self, + field_name: str, + descending: bool, + ordering_or_sorter_name: Union[OrderingType, str], + nulls: NullsOrdering = NullsOrdering.DEFAULT, + ): self.__field_name = field_name self.__descending = descending @@ -282,10 +288,15 @@ def __init__(self, field_name: str, descending: bool, ordering_or_sorter_name: U self.__ordering = ordering_or_sorter_name if is_ordering else None self.__sorter_name = None if is_ordering else ordering_or_sorter_name + self.__nulls_ordering = nulls if nulls is not None else NullsOrdering.DEFAULT @classmethod def create_distance_ascending_wkt( - cls, field_name: str, wkt_parameter_name: str, round_factor_parameter_name: str + cls, + field_name: str, + wkt_parameter_name: str, + round_factor_parameter_name: str, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> OrderByToken: return cls( f"spatial.distance({field_name}), " @@ -293,6 +304,7 @@ def create_distance_ascending_wkt( f"{'' if round_factor_parameter_name is None else ', $' + round_factor_parameter_name})", False, OrderingType.STRING, + nulls, ) @classmethod @@ -302,6 +314,7 @@ def create_distance_ascending( latitude_parameter_name: str, longitude_parameter_name: str, round_factor_parameter_name: str, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> OrderByToken: return cls( f"spatial.distance({field_name}, " @@ -310,16 +323,24 @@ def create_distance_ascending( f"{'' if round_factor_parameter_name is None else ', $'+round_factor_parameter_name})", False, OrderingType.STRING, + nulls, ) @classmethod - def create_distance_descending_wkt(cls, field_name: str, wkt_parameter_name: str, round_factor_parameter_name: str): + def create_distance_descending_wkt( + cls, + field_name: str, + wkt_parameter_name: str, + round_factor_parameter_name: str, + nulls: NullsOrdering = NullsOrdering.DEFAULT, + ): return cls( f"spatial.distance({field_name}, " f"spatial.wkt(${wkt_parameter_name})" f"{'' if round_factor_parameter_name is None else ', $' + round_factor_parameter_name})", True, OrderingType.STRING, + nulls, ) @classmethod @@ -329,6 +350,7 @@ def create_distance_descending( latitude_parameter_name: str, longitude_parameter_name: str, round_factor_parameter_name: str, + nulls: NullsOrdering = NullsOrdering.DEFAULT, ) -> OrderByToken: return cls( f"spatial.distance({field_name}, " @@ -337,6 +359,7 @@ def create_distance_descending( f"{'' if round_factor_parameter_name is None else ', $' + round_factor_parameter_name})", True, OrderingType.STRING, + nulls, ) @classmethod @@ -347,12 +370,22 @@ def create_random(cls, seed: str) -> OrderByToken: return cls("random('" + seed.replace("'", "''") + "')", False, OrderingType.STRING) @classmethod - def create_ascending(cls, field_name: str, sorter_name_or_ordering_type: Union[OrderingType, str]) -> OrderByToken: - return cls(field_name, False, sorter_name_or_ordering_type) + def create_ascending( + cls, + field_name: str, + sorter_name_or_ordering_type: Union[OrderingType, str], + nulls: NullsOrdering = NullsOrdering.DEFAULT, + ) -> OrderByToken: + return cls(field_name, False, sorter_name_or_ordering_type, nulls) @classmethod - def create_descending(cls, field_name: str, sorter_name_or_ordering_type: Union[OrderingType, str]) -> OrderByToken: - return cls(field_name, True, sorter_name_or_ordering_type) + def create_descending( + cls, + field_name: str, + sorter_name_or_ordering_type: Union[OrderingType, str], + nulls: NullsOrdering = NullsOrdering.DEFAULT, + ) -> OrderByToken: + return cls(field_name, True, sorter_name_or_ordering_type, nulls) def write_to(self, writer: List[str]) -> None: if self.__sorter_name is not None: @@ -375,6 +408,11 @@ def write_to(self, writer: List[str]) -> None: if self.__descending: writer.append(" desc") + if self.__nulls_ordering == NullsOrdering.FIRST: + writer.append(" nulls first") + elif self.__nulls_ordering == NullsOrdering.LAST: + writer.append(" nulls last") + class GroupByToken(QueryToken): def __init__(self, field_name: str, method: GroupByMethod): diff --git a/ravendb/primitives/constants.py b/ravendb/primitives/constants.py index 584b7318..a391e8d9 100644 --- a/ravendb/primitives/constants.py +++ b/ravendb/primitives/constants.py @@ -194,6 +194,7 @@ class Collections: EMBEDDINGS_CACHE_COLLECTION = "@embeddings-cache" AI_AGENT_CONVERSATIONS_COLLECTION = "@conversations" AI_AGGENT_CONVERSATION_HISTORY_COLLECTION = "@conversations-history" + AI_AGENT_CONVERSATION_DEBUG_COLLECTION = "@conversations-debug" NESTED_OBJECT_TYPES = "@nested-object-types" class Ai: diff --git a/ravendb/tests/ai_agent_tests/test_ai_conversation_debug.py b/ravendb/tests/ai_agent_tests/test_ai_conversation_debug.py new file mode 100644 index 00000000..3cb2c028 --- /dev/null +++ b/ravendb/tests/ai_agent_tests/test_ai_conversation_debug.py @@ -0,0 +1,105 @@ +import json +import unittest +from types import SimpleNamespace + +from ravendb.documents.ai.ai_conversation import AiConversation, AiHandleErrorStrategy +from ravendb.documents.ai.ai_operations import AiOperations +from ravendb.documents.operations.ai.agents import ( + AiAgentActionRequest, + AiUsage, + ConversationResult, + RunConversationOperation, +) +from ravendb.documents.operations.ai.agents.run_conversation_operation import RunConversationCommand +from ravendb.http.server_node import ServerNode + + +def _usage(): + return AiUsage(prompt_tokens=10, completion_tokens=20, total_tokens=30) + + +def _done_result(): + return ConversationResult( + conversation_id="conversations/1", + change_vector="A:1", + response={"answer": "ok"}, + usage=_usage(), + action_requests=[], + ) + + +def _action_result(name, tool_id, arguments=None): + return ConversationResult( + conversation_id="conversations/1", + change_vector="A:1", + response=None, + usage=_usage(), + action_requests=[AiAgentActionRequest(name=name, tool_id=tool_id, arguments=json.dumps(arguments or {}))], + ) + + +class _FakeMaintenance: + def __init__(self, results): + self._results = list(results) + self.sent_operations = [] + + def send(self, operation): + self.sent_operations.append(operation) + return self._results.pop(0) + + +class _FakeStore: + def __init__(self, results): + self.maintenance = _FakeMaintenance(results) + + +class TestAiConversationDebug(unittest.TestCase): + def _url(self, debug): + kwargs = {} if debug is None else {"debug": debug} + command = RunConversationCommand(agent_id="agents/1", conversation_id="conversations/1", **kwargs) + return command.create_request(ServerNode("http://localhost:8080", "db1")).url + + def test_debug_true_appended_to_url(self): + self.assertIn("&debug=True", self._url(True)) + + def test_debug_false_appended_to_url(self): + self.assertIn("&debug=False", self._url(False)) + + def test_debug_none_omits_param(self): + self.assertNotIn("debug", self._url(None)) + + def test_ai_operations_conversation_threads_debug(self): + ops = AiOperations(SimpleNamespace()) + conversation = ops.conversation("agents/1", "conversations/", debug=True) + self.assertTrue(conversation._debug) + + def test_run_passes_debug_to_operation(self): + store = _FakeStore([_done_result()]) + conversation = AiConversation(store, agent_id="agents/1", debug=True) + conversation.set_user_prompt("hi") + conversation.run() + + sent = store.maintenance.sent_operations + self.assertEqual(1, len(sent)) + self.assertIsInstance(sent[0], RunConversationOperation) + self.assertTrue(sent[0]._debug) + + def test_already_dispatched_tool_is_not_invoked_twice(self): + # Server returns the same tool-id across two turns; the handler must run only once. + store = _FakeStore([_action_result("act", "t1"), _action_result("act", "t1")]) + conversation = AiConversation(store, agent_id="agents/1") + + calls = [] + conversation.handle( + "act", lambda args: calls.append(args) or {"ok": True}, AiHandleErrorStrategy.RAISE_IMMEDIATELY + ) + + conversation.set_user_prompt("go") + conversation.run() # must terminate, not loop forever + + self.assertEqual(1, len(calls)) + self.assertEqual(2, len(store.maintenance.sent_operations)) + + +if __name__ == "__main__": + unittest.main() diff --git a/ravendb/tests/embeddings_generation_tests/test_chunking_options.py b/ravendb/tests/embeddings_generation_tests/test_chunking_options.py new file mode 100644 index 00000000..ab896680 --- /dev/null +++ b/ravendb/tests/embeddings_generation_tests/test_chunking_options.py @@ -0,0 +1,50 @@ +import unittest + +from ravendb.documents.operations.ai.chunking_options import ChunkingOptions, ChunkingMethod + + +class TestChunkingOptionsContextPrefix(unittest.TestCase): + def test_context_prefix_serialized(self): + options = ChunkingOptions(ChunkingMethod.PLAIN_TEXT_SPLIT, 100, 0, context_prefix="Title: Foo") + self.assertEqual("Title: Foo", options.to_json()["ContextPrefix"]) + + def test_context_prefix_round_trip(self): + options = ChunkingOptions(ChunkingMethod.PLAIN_TEXT_SPLIT, 100, 0, context_prefix="Title: Foo") + restored = ChunkingOptions.from_json(options.to_json()) + self.assertEqual("Title: Foo", restored.context_prefix) + self.assertEqual(options, restored) + self.assertEqual(hash(options), hash(restored)) + + def test_equality_is_sensitive_to_context_prefix(self): + left = ChunkingOptions(ChunkingMethod.HTML_STRIP, 100, 0, context_prefix="A") + right = ChunkingOptions(ChunkingMethod.HTML_STRIP, 100, 0, context_prefix="B") + self.assertNotEqual(left, right) + + def test_validate_rejects_empty_or_whitespace_prefix(self): + for bad in ("", " "): + errors = [] + ChunkingOptions(ChunkingMethod.HTML_STRIP, 100, 0, context_prefix=bad).validate("source", errors) + self.assertEqual(1, len(errors), bad) + self.assertIn("ContextPrefix", errors[0]) + + def test_validate_accepts_valid_prefix(self): + errors = [] + ChunkingOptions(ChunkingMethod.HTML_STRIP, 100, 0, context_prefix="Doc title").validate("source", errors) + self.assertEqual([], errors) + + def test_no_chunking_marker_is_not_serialized(self): + options = ChunkingOptions(ChunkingMethod.HTML_STRIP, 100, 0, context_prefix="ok") + options.no_chunking = True + self.assertNotIn("NoChunking", options.to_json()) + + def test_no_chunking_marker_bypasses_budget_validation(self): + # max_tokens_per_chunk == 0 would normally be rejected; no_chunking skips those checks. + options = ChunkingOptions(ChunkingMethod.HTML_STRIP, 0, 0, context_prefix="ok") + options.no_chunking = True + errors = [] + options.validate("source", errors) + self.assertEqual([], errors) + + +if __name__ == "__main__": + unittest.main() diff --git a/ravendb/tests/jvm_migrated_tests/client_tests/queries_tests/test_nulls_ordering.py b/ravendb/tests/jvm_migrated_tests/client_tests/queries_tests/test_nulls_ordering.py new file mode 100644 index 00000000..8a8f1aac --- /dev/null +++ b/ravendb/tests/jvm_migrated_tests/client_tests/queries_tests/test_nulls_ordering.py @@ -0,0 +1,94 @@ +from typing import Optional + +from ravendb import NullsOrdering, OrderingType +from ravendb.exceptions.raven_exceptions import RavenException +from ravendb.tests.test_base import TestBase + + +class OrderingDoc: + def __init__(self, name: Optional[str] = None, age: Optional[int] = None): + self.name = name + self.age = age + + +class TestNullsOrdering(TestBase): + def setUp(self): + super().setUp() + + # ---- RQL generation (client-side; engine independent) ---- + + def test_order_by_renders_nulls_first(self): + with self.store.open_session() as session: + query = session.advanced.document_query(object_type=OrderingDoc).order_by("name", nulls=NullsOrdering.FIRST) + self.assertEqual("from 'OrderingDocs' order by name nulls first", query._to_string()) + + def test_order_by_with_ordering_type_renders_nulls_last(self): + with self.store.open_session() as session: + query = session.advanced.document_query(object_type=OrderingDoc).order_by( + "age", OrderingType.LONG, NullsOrdering.LAST + ) + self.assertEqual("from 'OrderingDocs' order by age as long nulls last", query._to_string()) + + def test_order_by_descending_renders_desc_then_nulls(self): + with self.store.open_session() as session: + query = session.advanced.document_query(object_type=OrderingDoc).order_by_descending( + "name", nulls=NullsOrdering.FIRST + ) + self.assertEqual("from 'OrderingDocs' order by name desc nulls first", query._to_string()) + + def test_default_nulls_ordering_emits_no_clause(self): + with self.store.open_session() as session: + query = session.advanced.document_query(object_type=OrderingDoc).order_by("name") + self.assertEqual("from 'OrderingDocs' order by name", query._to_string()) + + def test_add_order_threads_nulls(self): + with self.store.open_session() as session: + query = session.advanced.document_query(object_type=OrderingDoc).add_order( + "age", True, OrderingType.LONG, NullsOrdering.LAST + ) + self.assertEqual("from 'OrderingDocs' order by age as long desc nulls last", query._to_string()) + + def test_order_by_distance_renders_nulls(self): + with self.store.open_session() as session: + query = session.advanced.document_query(object_type=OrderingDoc).order_by_distance( + "loc", 10.0, 20.0, nulls=NullsOrdering.FIRST + ) + self.assertEqual( + "from 'OrderingDocs' order by spatial.distance(loc, spatial.point($p0, $p1)) nulls first", + query._to_string(), + ) + + # ---- End-to-end placement (requires Corax, the default auto-index engine in 7.x) ---- + + def test_nulls_first_and_last_place_null_values(self): + # End-to-end placement requires a server (7.2.3+) and a Corax index. Older servers reject the + # 'nulls first/last' RQL at parse time; self-skip there so the suite stays green. + with self.store.open_session() as session: + session.store(OrderingDoc("a", 5), "docs/1") + session.store(OrderingDoc("b", None), "docs/2") + session.store(OrderingDoc("c", 3), "docs/3") + session.save_changes() + + def query(nulls): + with self.store.open_session() as session: + return list( + session.advanced.document_query(object_type=OrderingDoc) + .wait_for_non_stale_results() + .order_by("age", OrderingType.LONG, nulls) + ) + + try: + first = query(NullsOrdering.FIRST) + last = query(NullsOrdering.LAST) + except RavenException as e: + message = str(e).lower() + if "nulls" in message or "corax" in message: + self.skipTest( + "Server does not support per-query 'nulls first/last' ordering (needs 7.2.3+ with Corax)." + ) + raise + + self.assertEqual(3, len(first)) + self.assertIsNone(first[0].age) + self.assertEqual(3, len(last)) + self.assertIsNone(last[-1].age) diff --git a/ravendb/tests/jvm_migrated_tests/client_tests/session_tests/test_patch_dictionary_key_escaping.py b/ravendb/tests/jvm_migrated_tests/client_tests/session_tests/test_patch_dictionary_key_escaping.py new file mode 100644 index 00000000..6574a156 --- /dev/null +++ b/ravendb/tests/jvm_migrated_tests/client_tests/session_tests/test_patch_dictionary_key_escaping.py @@ -0,0 +1,63 @@ +import unittest +from typing import Optional + +from ravendb.documents.session.misc import JavaScriptMap +from ravendb.tests.test_base import TestBase + + +class TestJavaScriptMapKeyEscaping(unittest.TestCase): + def test_put_uses_bracket_notation_with_escaped_key(self): + js_map = JavaScriptMap(0, "attrs") + result = js_map.put("key.with.dots", 5) + self.assertIs(result, js_map) # fluent + self.assertEqual('this.attrs["key.with.dots"] = args.val_0_0;', js_map.script) + + def test_put_escapes_quotes_in_key(self): + js_map = JavaScriptMap(0, "attrs") + js_map.put('he"llo', 1) + self.assertEqual('this.attrs["he\\"llo"] = args.val_0_0;', js_map.script) + + def test_put_handles_numeric_key(self): + js_map = JavaScriptMap(3, "attrs") + js_map.put(7, "v") + self.assertEqual('this.attrs["7"] = args.val_0_3;', js_map.script) + + def test_remove_uses_bracket_notation_and_is_fluent(self): + js_map = JavaScriptMap(0, "attrs") + result = js_map.remove("key.with.dots") + self.assertIs(result, js_map) + self.assertEqual('delete this.attrs["key.with.dots"];', js_map.script) + + def test_none_key_raises(self): + with self.assertRaises(ValueError): + JavaScriptMap(0, "attrs").put(None, 1) + + +class PatchDoc: + def __init__(self, Id: Optional[str] = None, attributes: Optional[dict] = None): + self.Id = Id + self.attributes = attributes if attributes is not None else {} + + +class TestPatchObjectDictionaryKey(TestBase): + def setUp(self): + super().setUp() + + def test_patch_object_put_key_with_dots(self): + with self.store.open_session() as session: + session.store(PatchDoc(attributes={"existing": 1}), "docs/1") + session.save_changes() + + with self.store.open_session() as session: + session.advanced.patch_object("docs/1", "attributes", lambda m: m.put("key.with.dots", "value")) + session.save_changes() + + with self.store.open_session() as session: + doc = session.load("docs/1", PatchDoc) + # The dotted key must be stored as a single literal key, not a nested path. + self.assertEqual("value", doc.attributes["key.with.dots"]) + self.assertEqual(1, doc.attributes["existing"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/setup.py b/setup.py index be4096dc..4258dd4e 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name="ravendb", packages=find_packages(exclude=["*.tests.*", "tests", "*.tests", "tests.*"]), - version="7.2.2", + version="7.2.3", long_description_content_type="text/markdown", long_description=open("README_pypi.md").read(), description="Python client for RavenDB NoSQL Database",