Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
49 changes: 6 additions & 43 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@
*.raven-cluster-topology
/build/lib/ravendb
/venv/
/.venv/
1 change: 1 addition & 0 deletions ravendb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@
MethodCall,
OptimisticConcurrencyMode,
OrderingType,
NullsOrdering,
JavaScriptMap,
DocumentQueryCustomization,
ResponseTimeInformation,
Expand Down
14 changes: 13 additions & 1 deletion ravendb/documents/ai/ai_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions ravendb/documents/ai/ai_operations.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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:
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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(
Expand All @@ -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,
)


Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 33 additions & 1 deletion ravendb/documents/operations/ai/chunking_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,36 @@ 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]:
return {
"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:
Expand All @@ -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.")

Expand Down Expand Up @@ -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,
)
)
35 changes: 33 additions & 2 deletions ravendb/documents/session/misc.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations
import datetime
import hashlib
import json
import threading
from abc import ABC
from enum import Enum
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Loading
Loading