diff --git a/ravendb/__init__.py b/ravendb/__init__.py index 323188af..0c1a7bea 100644 --- a/ravendb/__init__.py +++ b/ravendb/__init__.py @@ -80,11 +80,8 @@ # AI Operations from ravendb.documents.ai import ( AiOperations, - IAiConversationOperations, AiConversation, AiConversationResult, - AiAgentParametersBuilder, - IAiAgentParametersBuilder, ) from ravendb.documents.operations.ai.agents import ( AiAgentConfiguration, @@ -101,6 +98,7 @@ AiAgentActionRequest, AiAgentActionResponse, AiUsage, + AiConversationCreationOptions, GetAiAgentOperation, GetAiAgentsResponse, AddOrUpdateAiAgentOperation, diff --git a/ravendb/documents/ai/__init__.py b/ravendb/documents/ai/__init__.py index 391aec81..de693a7f 100644 --- a/ravendb/documents/ai/__init__.py +++ b/ravendb/documents/ai/__init__.py @@ -1,14 +1,12 @@ from .ai_operations import AiOperations -from .ai_conversation_operations import IAiConversationOperations from .ai_conversation import AiConversation from .ai_conversation_result import AiConversationResult -from .ai_agent_parameters_builder import AiAgentParametersBuilder, IAiAgentParametersBuilder +from .ai_answer import AiAnswer, AiConversationStatus __all__ = [ "AiOperations", - "IAiConversationOperations", "AiConversation", "AiConversationResult", - "AiAgentParametersBuilder", - "IAiAgentParametersBuilder", + "AiAnswer", + "AiConversationStatus", ] diff --git a/ravendb/documents/ai/ai_agent_parameters_builder.py b/ravendb/documents/ai/ai_agent_parameters_builder.py deleted file mode 100644 index 68476ebf..00000000 --- a/ravendb/documents/ai/ai_agent_parameters_builder.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations -from abc import ABC, abstractmethod -from typing import Dict, Any, TypeVar, Generic, TYPE_CHECKING - -if TYPE_CHECKING: - from ravendb.documents.ai.ai_conversation_operations import IAiConversationOperations - -TResponse = TypeVar("TResponse") - - -class IAiAgentParametersBuilder(ABC, Generic[TResponse]): - """ - Interface for building parameters for AI agent conversations. - """ - - @abstractmethod - def with_parameter(self, name: str, value: Any) -> IAiAgentParametersBuilder[TResponse]: - """ - Adds a parameter to the conversation. - - Args: - name: The parameter name - value: The parameter value - - Returns: - The builder instance for method chaining - """ - pass - - @abstractmethod - def build(self) -> IAiConversationOperations[TResponse]: - """ - Builds and returns the conversation operations instance. - - Returns: - The conversation operations interface - """ - pass - - -class AiAgentParametersBuilder(IAiAgentParametersBuilder[TResponse]): - """ - Builder for constructing AI agent conversation parameters. - """ - - def __init__(self, conversation_factory): - """ - Initializes the parameters builder. - - Args: - conversation_factory: A callable that creates the conversation with the built parameters - """ - self._parameters: Dict[str, Any] = {} - self._conversation_factory = conversation_factory - - def with_parameter(self, name: str, value: Any) -> IAiAgentParametersBuilder[TResponse]: - """ - Adds a parameter to the conversation. - - Args: - name: The parameter name - value: The parameter value - - Returns: - The builder instance for method chaining - """ - self._parameters[name] = value - return self - - def build(self) -> IAiConversationOperations[TResponse]: - """ - Builds and returns the conversation operations instance. - - Returns: - The conversation operations interface - """ - return self._conversation_factory(self._parameters) diff --git a/ravendb/documents/ai/ai_answer.py b/ravendb/documents/ai/ai_answer.py new file mode 100644 index 00000000..648da791 --- /dev/null +++ b/ravendb/documents/ai/ai_answer.py @@ -0,0 +1,68 @@ +from __future__ import annotations +from typing import Optional, TypeVar, Generic, TYPE_CHECKING +from datetime import timedelta +import enum + +if TYPE_CHECKING: + from ravendb.documents.operations.ai.agents import AiUsage + +TAnswer = TypeVar("TAnswer") + + +class AiConversationStatus(enum.Enum): + """ + Represents the status of an AI conversation. + """ + + DONE = "Done" + ACTION_REQUIRED = "ActionRequired" + + def __str__(self): + return self.value + + +class AiAnswer(Generic[TAnswer]): + """ + Represents the answer from an AI conversation turn. + + This class contains the AI's response, the conversation status, + token usage statistics, and timing information. + """ + + def __init__( + self, + answer: Optional[TAnswer] = None, + status: AiConversationStatus = AiConversationStatus.DONE, + usage: Optional[AiUsage] = None, + elapsed: Optional[timedelta] = None, + ): + """ + Initialize an AiAnswer instance. + + Args: + answer: The answer content produced by the AI + status: The status of the conversation (Done or ActionRequired) + usage: Token usage reported by the model + elapsed: The total time elapsed to produce the answer + """ + self.answer = answer + self.status = status + self.usage = usage + self.elapsed = elapsed + + def __str__(self) -> str: + """String representation for debugging.""" + return ( + f"AiAnswer(status={self.status.value}, " + f"has_answer={self.answer is not None}, " + f"elapsed={self.elapsed})" + ) + + def __repr__(self) -> str: + """Detailed representation for debugging.""" + return ( + f"AiAnswer(answer={self.answer!r}, " + f"status={self.status!r}, " + f"usage={self.usage!r}, " + f"elapsed={self.elapsed!r})" + ) diff --git a/ravendb/documents/ai/ai_conversation.py b/ravendb/documents/ai/ai_conversation.py index 769cdaf4..d08f8850 100644 --- a/ravendb/documents/ai/ai_conversation.py +++ b/ravendb/documents/ai/ai_conversation.py @@ -1,22 +1,35 @@ from __future__ import annotations + import json -from typing import List, Dict, Any, Optional, Union, TypeVar, TYPE_CHECKING +import traceback +from typing import List, Dict, Any, Optional, TypeVar, TYPE_CHECKING, Callable +from datetime import timedelta -from ravendb.documents.ai.ai_conversation_operations import IAiConversationOperations -from ravendb.documents.ai.ai_conversation_result import AiConversationResult +from ravendb.documents.ai.ai_answer import AiAnswer, AiConversationStatus +from ravendb.documents.operations.ai.agents import ( + AiAgentActionRequest, + AiAgentActionResponse, + AiConversationCreationOptions, +) if TYPE_CHECKING: from ravendb.documents.store.definition import DocumentStore - from ravendb.documents.operations.ai.agents import ( - AiAgentActionRequest, - AiAgentActionResponse, - ConversationResult, - ) TResponse = TypeVar("TResponse") -class AiConversation(IAiConversationOperations[TResponse]): +class AiHandleErrorStrategy: + SEND_ERRORS_TO_MODEL = "SendErrorsToModel" + RAISE_IMMEDIATELY = "RaiseImmediately" + + +class UnhandledActionEventArgs: + def __init__(self, sender: AiConversation, action: AiAgentActionRequest): + self.sender = sender + self.action = action + + +class AiConversation: """ Implementation of AI conversation operations for managing conversations with AI agents. @@ -30,23 +43,37 @@ def __init__( self, store: DocumentStore, agent_id: str = None, - parameters: Dict[str, Any] = None, + options: AiConversationCreationOptions = None, conversation_id: str = None, change_vector: str = None, ): self._store = store self._agent_id = agent_id - self._parameters = parameters or {} + self._options = options or AiConversationCreationOptions() self._conversation_id = conversation_id self._change_vector = change_vector - self._user_prompt: Optional[str] = None + + self._prompt_parts: List[str] = [] self._action_responses: List[AiAgentActionResponse] = [] - self._last_result: Optional[ConversationResult[TResponse]] = None + self._action_requests: Optional[List[AiAgentActionRequest]] = None + + # Action handlers + self._invocations: Dict[str, Callable[[AiAgentActionRequest], None]] = {} + + self.on_unhandled_action: Optional[Callable[[UnhandledActionEventArgs], None]] = None + + def __enter__(self) -> AiConversation: + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Context manager exit - cleanup resources.""" + pass @classmethod def with_conversation_id( cls, store: DocumentStore, conversation_id: str, change_vector: str = None - ) -> AiConversation[TResponse]: + ) -> AiConversation: """ Creates a conversation instance for continuing an existing conversation. @@ -69,18 +96,21 @@ def required_actions(self) -> List[AiAgentActionRequest]: """ Gets the list of action requests that need to be fulfilled before the conversation can continue. + + Raises: + RuntimeError: If run() hasn't been called yet """ - if self._last_result and self._last_result.action_requests: - return self._last_result.action_requests - return [] + if self._action_requests is None: + raise RuntimeError("You have to call run() first") + return self._action_requests - def add_action_response(self, action_id: str, action_response: Union[str, TResponse]) -> None: + def add_action_response(self, action_id: str, action_response: str) -> None: """ Adds a response for a given action request. Args: action_id: The ID of the action to respond to - action_response: The response content (string or typed response object) + action_response: The response content """ from ravendb.documents.operations.ai.agents import AiAgentActionResponse @@ -88,80 +118,147 @@ def add_action_response(self, action_id: str, action_response: Union[str, TRespo if isinstance(action_response, str): response.content = action_response - else: - # More robust JSON serialization - try: - response.content = json.dumps( - action_response.__dict__ if hasattr(action_response, "__dict__") else action_response, default=str - ) - except (TypeError, ValueError) as e: - response.content = str(action_response) self._action_responses.append(response) - def run(self) -> AiConversationResult[TResponse]: + def run(self) -> AiAnswer: """ - Executes one "turn" of the conversation: - sends the current prompt, processes any required actions, - and awaits the agent's reply. + Executes the conversation loop, automatically handling action requests + until the conversation is complete or no handlers are available. + + Returns: + AiAnswer with the final response, status, usage, and elapsed time """ - from ravendb.documents.operations.ai.agents import RunConversationOperation + while True: + r = self._run_internal() + if self._handle_server_reply(r): + return r - if self._conversation_id: - # Continue existing conversation - if not self._agent_id: - raise ValueError("Agent ID is required for conversation continuation") + def stream(self, stream_property_path: str = None, on_chunk: Optional[Callable[[str], None]] = None) -> AiAnswer: + """ + Stream the LLM response for the given property and return the final AiAnswer when done. + """ + while True: + r = self._run_internal(stream_property_path=stream_property_path, streamed_chunks_callback=on_chunk) + if self._handle_server_reply(r): + return r - operation = RunConversationOperation( - self._conversation_id, - self._user_prompt, - self._action_responses, - self._change_vector, - ) - # Set agent ID for conversation continuation - operation._agent_id = self._agent_id - else: - # Start new conversation - if not self._agent_id: - raise ValueError("Agent ID is required for new conversations") - - operation = RunConversationOperation( - self._agent_id, - self._user_prompt, - self._parameters, + def _run_internal( + self, + stream_property_path: Optional[str] = None, + streamed_chunks_callback: Optional[Callable[[str], None]] = None, + ) -> AiAnswer: + """ + Internal method that executes a single server call. + + Returns: + AiAnswer from this single turn + """ + from ravendb.documents.operations.ai.agents import RunConversationOperation + import time + + # If we already went to the server and have nothing new to tell it, we're done + if self._action_requests is not None and len(self._prompt_parts) == 0 and len(self._action_responses) == 0: + return AiAnswer( + answer=None, + status=AiConversationStatus.DONE, + usage=None, + elapsed=None, ) - # Execute the operation - result = self._store.maintenance.send(operation) - self._last_result = result + # Build the operation + if not self._agent_id: + raise ValueError("Agent ID is required") - # Update conversation state for future calls - if result.conversation_id: - self._conversation_id = result.conversation_id - if result.change_vector: + # If we don't have a conversation ID yet, generate one with the prefix + # The server will complete it with a unique ID + if not self._conversation_id: + self._conversation_id = "conversations/" + + # Create operation with all required parameters + operation = RunConversationOperation( + agent_id=self._agent_id, + conversation_id=self._conversation_id, + prompt_parts=self._prompt_parts, # Always send list, even if empty + action_responses=self._action_responses, # Always send list, even if empty + options=self._options, + change_vector=self._change_vector, + stream_property_path=stream_property_path, + streamed_chunks_callback=streamed_chunks_callback, + ) + + try: + # Track elapsed time + start_time = time.time() + result = self._store.maintenance.send(operation) + elapsed = timedelta(seconds=time.time() - start_time) + + # Update conversation state self._change_vector = result.change_vector + self._conversation_id = result.conversation_id + self._action_requests = result.action_requests or [] + + # Build AiAnswer + return AiAnswer( + answer=result.response, + status=( + AiConversationStatus.ACTION_REQUIRED + if len(self._action_requests) > 0 + else AiConversationStatus.DONE + ), + usage=result.usage, + elapsed=elapsed, + ) + # except ConcurrencyException as e: + # self._change_vector = e.actual_change_vector + # raise + finally: + # Clear the user prompt and tool responses after running the conversation + self._prompt_parts.clear() + self._action_responses.clear() - # Preserve agent ID for future conversation turns - if not self._agent_id and hasattr(operation, "_agent_id"): - self._agent_id = operation._agent_id + def _handle_server_reply(self, answer: AiAnswer) -> bool: + """ + Handles the server reply by invoking registered action handlers. + + Args: + answer: The answer from the server + + Returns: + True if the conversation is done, False if it should continue + """ + if answer.status == AiConversationStatus.DONE: + return True - # Clear processed data for next turn - self._user_prompt = None - self._action_responses.clear() + if len(self._action_requests) == 0: + raise RuntimeError( + f"There are no action requests to process, but Status was {answer.status}, should not be possible." + ) - # Convert to AiConversationResult - conversation_result = AiConversationResult[TResponse]() - conversation_result.conversation_id = result.conversation_id - conversation_result.change_vector = result.change_vector - conversation_result.response = result.response - conversation_result.usage = result.usage - conversation_result.action_requests = result.action_requests or [] + # Process each action request + for action in self._action_requests: + if action.name in self._invocations: + # Invoke the registered handler + # Error handling is done by the invocation based on the error strategy + self._invocations[action.name](action) + elif self.on_unhandled_action is not None: + self.on_unhandled_action(UnhandledActionEventArgs(self, action)) + else: + # No handler registered for this action + raise RuntimeError( + f"There is no action defined for action '{action.name}' on agent '{self._agent_id}' " + f"({self._conversation_id}), but it was invoked by the model with: {action.arguments}. " + f"Did you forget to call {self.receive.__name__}() or {self.handle.__name__}()? You can also handle unexpected action invocations using the {self.on_unhandled_action.__name__} event." + ) - return conversation_result + # If we have nothing to tell the server (no action responses), we're done + # Otherwise, continue the loop to send the responses + return len(self._action_responses) == 0 def set_user_prompt(self, user_prompt: str) -> None: """ - Sets the next user prompt to send to the AI agent. + Sets the user prompt to send to the AI agent. + Clears any existing prompt parts and adds the new prompt. Args: user_prompt: The prompt text to send to the agent @@ -171,14 +268,110 @@ def set_user_prompt(self, user_prompt: str) -> None: """ if not user_prompt or user_prompt.isspace(): raise ValueError("User prompt cannot be empty or whitespace-only") - self._user_prompt = user_prompt + self._prompt_parts.clear() + self._prompt_parts.append(user_prompt) - def __enter__(self) -> AiConversation[TResponse]: - """Context manager entry.""" - return self + def add_user_prompt(self, *prompts: str) -> None: + """ + Adds one or more user prompts to the conversation. - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - """Context manager exit - cleanup resources.""" - # Clear any pending data - self._user_prompt = None - self._action_responses.clear() + Args: + *prompts: One or more prompt strings to add + + Raises: + ValueError: If any prompt is empty or whitespace-only + """ + for prompt in prompts: + if not prompt or prompt.isspace(): + raise ValueError("User prompt cannot be empty or whitespace-only") + self._prompt_parts.append(prompt) + + def handle( + self, + action_name: str, + action: Callable[[dict], Any], + ai_handle_error: AiHandleErrorStrategy, + ) -> None: + self.handle_ai_agent_action_request(action_name, lambda _, args: action(args), ai_handle_error) + + def handle_ai_agent_action_request( + self, + action_name: str, + action: Callable[[AiAgentActionRequest, dict], Any], + ai_handle_error: AiHandleErrorStrategy = AiHandleErrorStrategy.SEND_ERRORS_TO_MODEL, + ) -> None: + def wrapped_no_return(request: AiAgentActionRequest, args: dict) -> Any: + result = action(request, args) + self.add_action_response(request.tool_id, result) + + self.receive(action_name, wrapped_no_return, ai_handle_error) + + def receive( + self, + action_name: str, + action: Callable[[AiAgentActionRequest, dict], None], + ai_handle_error: AiHandleErrorStrategy = AiHandleErrorStrategy.SEND_ERRORS_TO_MODEL, + ): + t = self.AiActionContext(self, lambda request, args: action(request, args), ai_handle_error) + self._add_action(action_name, t.execute) + + def _add_action(self, action_name: str, action: Callable[[AiAgentActionRequest], Any]): + if action_name in self._invocations: + raise ValueError(f"Action '{action_name}' already exists") + + self._invocations[action_name] = action + + class AiActionContext: + def __init__( + self, + conversation: AiConversation, + action: Callable[[AiAgentActionRequest, dict], Any], + ai_handle_error: AiHandleErrorStrategy, + ): + self._conversation = conversation + self._action = action + self._ai_handle_error = ai_handle_error + + def execute(self, action_request: AiAgentActionRequest): + args = json.loads(action_request.arguments) + self.invoke(action_request, args) + + def invoke(self, action_request: AiAgentActionRequest, args: dict): + try: + self._action(action_request, args) + except Exception as e: + if self._ai_handle_error == AiHandleErrorStrategy.SEND_ERRORS_TO_MODEL: + self._conversation.add_action_response(action_request.tool_id, self.create_error_message_for_llm(e)) + else: + raise e + + @staticmethod + def create_error_message_for_llm(exc: Exception) -> str: + parts = [] + + current = exc + indent = 0 + + while current is not None: + prefix = " " * indent + header = f"{prefix}{current.__class__.__name__}: {current}" + parts.append(header) + + tb = "".join(traceback.format_exception(type(current), current, current.__traceback__)) + tb_lines = tb.strip().splitlines() + + # indent the traceback block + indented_tb = "\n".join(prefix + " " + line for line in tb_lines) + parts.append(indented_tb) + + # Move to next exception in the chain + if current.__cause__: + current = current.__cause__ + elif current.__context__ and not current.__suppress_context__: + current = current.__context__ + else: + current = None + + indent += 1 + + return "\n".join(parts) diff --git a/ravendb/documents/ai/ai_conversation_operations.py b/ravendb/documents/ai/ai_conversation_operations.py deleted file mode 100644 index 4f1d6d86..00000000 --- a/ravendb/documents/ai/ai_conversation_operations.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations -from abc import ABC, abstractmethod -from typing import List, TypeVar, Generic, Union, TYPE_CHECKING - -if TYPE_CHECKING: - from ravendb.documents.operations.ai.agents import AiAgentActionRequest - from ravendb.documents.ai.ai_conversation_result import AiConversationResult - -TResponse = TypeVar("TResponse") - - -class IAiConversationOperations(ABC, Generic[TResponse]): - """ - Interface for AI conversation operations, providing methods to manage - conversations with AI agents including sending prompts, handling actions, - and running conversation turns. - """ - - @property - @abstractmethod - def required_actions(self) -> List[AiAgentActionRequest]: - """ - Gets the list of action requests that need to be fulfilled before - the conversation can continue. - - Returns: - List of action requests that require responses - """ - pass - - @abstractmethod - def add_action_response(self, action_id: str, action_response: Union[str, TResponse]) -> None: - """ - Adds a response for a given action request. - - Args: - action_id: The ID of the action to respond to - action_response: The response content (string or typed response object) - """ - pass - - @abstractmethod - def run(self) -> AiConversationResult[TResponse]: - """ - Executes one "turn" of the conversation: - sends the current prompt, processes any required actions, - and awaits the agent's reply. - - Returns: - The result of the conversation turn - """ - pass - - @abstractmethod - def set_user_prompt(self, user_prompt: str) -> None: - """ - Sets the next user prompt to send to the AI agent. - - Args: - user_prompt: The prompt text to send to the agent - """ - pass diff --git a/ravendb/documents/ai/ai_conversation_result.py b/ravendb/documents/ai/ai_conversation_result.py index a554429a..9749e5c3 100644 --- a/ravendb/documents/ai/ai_conversation_result.py +++ b/ravendb/documents/ai/ai_conversation_result.py @@ -13,12 +13,19 @@ class AiConversationResult(Generic[TResponse]): usage statistics, and any action requests that need to be fulfilled. """ - def __init__(self): - self.conversation_id: Optional[str] = None - self.change_vector: Optional[str] = None - self.response: Optional[TResponse] = None - self.usage: Optional[AiUsage] = None - self.action_requests: List[AiAgentActionRequest] = [] + def __init__( + self, + conversation_id: Optional[str] = None, + change_vector: Optional[str] = None, + response: Optional[TResponse] = None, + usage: Optional[AiUsage] = None, + action_requests: Optional[List[AiAgentActionRequest]] = None, + ): + self.conversation_id: Optional[str] = conversation_id + self.change_vector: Optional[str] = change_vector + self.response: Optional[TResponse] = response + self.usage: Optional[AiUsage] = usage + self.action_requests: List[AiAgentActionRequest] = action_requests or [] def __str__(self) -> str: """String representation for debugging.""" diff --git a/ravendb/documents/ai/ai_operations.py b/ravendb/documents/ai/ai_operations.py index 8c02ff01..32bb3a53 100644 --- a/ravendb/documents/ai/ai_operations.py +++ b/ravendb/documents/ai/ai_operations.py @@ -1,5 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Dict, Any, Type +from typing import TYPE_CHECKING, Dict, Any, Type +from ravendb.documents.ai.ai_conversation import AiConversation if TYPE_CHECKING: from ravendb.documents.store.definition import DocumentStore @@ -8,7 +9,6 @@ AiAgentConfigurationResult, GetAiAgentsResponse, ) - from ravendb.documents.ai.ai_conversation_operations import IAiConversationOperations class AiOperations: @@ -67,7 +67,7 @@ def get_agents(self, agent_id: str = None) -> GetAiAgentsResponse: operation = GetAiAgentOperation(agent_id) return self._store.maintenance.send(operation) - def conversation(self, agent_id: str, parameters: Dict[str, Any] = None) -> IAiConversationOperations: + def conversation(self, agent_id: str, parameters: Dict[str, Any] = None) -> AiConversation: """ Creates a new conversation with the specified AI agent. @@ -78,11 +78,10 @@ def conversation(self, agent_id: str, parameters: Dict[str, Any] = None) -> IAiC Returns: Conversation operations interface for managing the conversation """ - from ravendb.documents.ai.ai_conversation import AiConversation return AiConversation(self._store, agent_id, parameters) - def conversation_with_id(self, conversation_id: str, change_vector: str = None) -> IAiConversationOperations: + def conversation_with_id(self, conversation_id: str, change_vector: str = None) -> AiConversation: """ Continues an existing conversation by its ID. diff --git a/ravendb/documents/operations/ai/agents/__init__.py b/ravendb/documents/operations/ai/agents/__init__.py index d6048a5d..a88ce169 100644 --- a/ravendb/documents/operations/ai/agents/__init__.py +++ b/ravendb/documents/operations/ai/agents/__init__.py @@ -27,6 +27,7 @@ AiAgentActionRequest, AiAgentActionResponse, AiUsage, + AiConversationCreationOptions, ) __all__ = [ @@ -44,6 +45,7 @@ "AiAgentActionRequest", "AiAgentActionResponse", "AiUsage", + "AiConversationCreationOptions", "GetAiAgentOperation", "GetAiAgentsResponse", "AddOrUpdateAiAgentOperation", diff --git a/ravendb/documents/operations/ai/agents/add_or_update_ai_agent_operation.py b/ravendb/documents/operations/ai/agents/add_or_update_ai_agent_operation.py index be99b7f2..ed4d1491 100644 --- a/ravendb/documents/operations/ai/agents/add_or_update_ai_agent_operation.py +++ b/ravendb/documents/operations/ai/agents/add_or_update_ai_agent_operation.py @@ -1,5 +1,6 @@ from __future__ import annotations import json +import warnings from typing import Optional, Type, Dict, Any, TYPE_CHECKING from ravendb.documents.operations.definitions import MaintenanceOperation @@ -28,6 +29,13 @@ def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentConfigurationResult: class AddOrUpdateAiAgentOperation(MaintenanceOperation[AiAgentConfigurationResult]): def __init__(self, configuration: AiAgentConfiguration, schema_type: Type = None): + if schema_type is not None: + warnings.warn( + "The 'schema_type' parameter is deprecated and will be removed in 8.0 version of Python client." + " Use 'sample_object' or 'output_schema' inside AiAgentConfiguration instead.", + DeprecationWarning, + stacklevel=2, + ) if configuration is None: raise ValueError("configuration cannot be None") diff --git a/ravendb/documents/operations/ai/agents/ai_agent_configuration.py b/ravendb/documents/operations/ai/agents/ai_agent_configuration.py index cc45cb71..f609e2ff 100644 --- a/ravendb/documents/operations/ai/agents/ai_agent_configuration.py +++ b/ravendb/documents/operations/ai/agents/ai_agent_configuration.py @@ -10,12 +10,19 @@ class AiAgentToolQuery: and its results provided back to the model. """ - def __init__(self, name: str = None, description: str = None, query: str = None): + def __init__( + self, + name: str = None, + description: str = None, + query: str = None, + parameters_sample_object: str = None, + parameters_schema: str = None, + ): self.name = name self.description = description self.query = query - self.parameters_sample_object: Optional[str] = None - self.parameters_schema: Optional[str] = None + self.parameters_sample_object: Optional[str] = parameters_sample_object + self.parameters_schema: Optional[str] = parameters_schema def to_json(self) -> Dict[str, Any]: return { @@ -46,11 +53,17 @@ class AiAgentToolAction: Tool actions represent external functions whose results are provided by the user """ - def __init__(self, name: str = None, description: str = None): + def __init__( + self, + name: str = None, + description: str = None, + parameters_sample_object: str = None, + parameters_schema: str = None, + ): self.name = name self.description = description - self.parameters_sample_object: Optional[str] = None - self.parameters_schema: Optional[str] = None + self.parameters_sample_object: Optional[str] = parameters_sample_object + self.parameters_schema: Optional[str] = parameters_schema def to_json(self) -> Dict[str, Any]: return { @@ -105,12 +118,21 @@ class AiAgentSummarizationByTokens: DEFAULT_MAX_TOKENS_BEFORE_SUMMARIZATION = 32 * 1024 - def __init__(self): - self.summarization_task_beginning_prompt: Optional[str] = None - self.summarization_task_end_prompt: Optional[str] = None - self.result_prefix: Optional[str] = None - self.max_tokens_before_summarization: int = self.DEFAULT_MAX_TOKENS_BEFORE_SUMMARIZATION - self.max_tokens_after_summarization: int = 1024 + def __init__( + self, + summarization_task_beginning_prompt: str = None, + summarization_task_end_prompt: str = None, + result_prefix: str = None, + max_tokens_before_summarization: int = None, + max_tokens_after_summarization: int = None, + ): + self.summarization_task_beginning_prompt: Optional[str] = summarization_task_beginning_prompt + self.summarization_task_end_prompt: Optional[str] = summarization_task_end_prompt + self.result_prefix: Optional[str] = result_prefix + self.max_tokens_before_summarization: int = ( + max_tokens_before_summarization or self.DEFAULT_MAX_TOKENS_BEFORE_SUMMARIZATION + ) + self.max_tokens_after_summarization: int = max_tokens_after_summarization or 1024 def to_json(self) -> Dict[str, Any]: return { @@ -141,9 +163,13 @@ class AiAgentTruncateChat: DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE = 500 - def __init__(self): - self.messages_length_before_truncate: int = self.DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE - self.messages_length_after_truncate: int = self.DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE // 2 + def __init__(self, messages_length_before_truncate: int = None, messages_length_after_truncate: int = None): + self.messages_length_before_truncate: int = ( + messages_length_before_truncate or self.DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE + ) + self.messages_length_after_truncate: int = ( + messages_length_after_truncate or self.DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE // 2 + ) def to_json(self) -> Dict[str, Any]: return { @@ -168,8 +194,8 @@ class AiAgentHistoryConfiguration: Defines the configuration for retention and expiration of AI agent chat history documents. """ - def __init__(self): - self.history_expiration_in_sec: Optional[int] = None + def __init__(self, history_expiration_in_sec: int = None): + self.history_expiration_in_sec: Optional[int] = history_expiration_in_sec def to_json(self) -> Dict[str, Any]: return { @@ -223,19 +249,33 @@ class AiAgentConfiguration: tools (queries/actions), output schema, persistence settings, and connection string. """ - def __init__(self, name: str = None, connection_string_name: str = None, system_prompt: str = None): - self.identifier: Optional[str] = None + def __init__( + self, + name: str = None, + connection_string_name: str = None, + system_prompt: str = None, + identifier: str = None, + sample_object: str = None, + output_schema: str = None, + queries: List[AiAgentToolQuery] = None, + actions: List[AiAgentToolAction] = None, + persistence: AiAgentPersistenceConfiguration = None, + parameters: Set[str] = None, + chat_trimming: AiAgentChatTrimmingConfiguration = None, + max_model_iterations_per_call: int = None, + ): self.name = name self.connection_string_name = connection_string_name self.system_prompt = system_prompt - self.sample_object: Optional[str] = None - self.output_schema: Optional[str] = None - self.queries: List[AiAgentToolQuery] = [] - self.actions: List[AiAgentToolAction] = [] - self.persistence: Optional[AiAgentPersistenceConfiguration] = None - self.parameters: Set[str] = set() - self.chat_trimming: Optional[AiAgentChatTrimmingConfiguration] = None - self.max_model_iterations_per_call: Optional[int] = None + self.identifier: Optional[str] = identifier + self.sample_object: Optional[str] = sample_object + self.output_schema: Optional[str] = output_schema + self.queries: List[AiAgentToolQuery] = queries or [] + self.actions: List[AiAgentToolAction] = actions or [] + self.persistence: Optional[AiAgentPersistenceConfiguration] = persistence + self.parameters: Set[str] = parameters or set() + self.chat_trimming: Optional[AiAgentChatTrimmingConfiguration] = chat_trimming + self.max_model_iterations_per_call: Optional[int] = max_model_iterations_per_call def to_json(self) -> Dict[str, Any]: # Convert parameters set to list of parameter objects using list comprehension diff --git a/ravendb/documents/operations/ai/agents/run_conversation_operation.py b/ravendb/documents/operations/ai/agents/run_conversation_operation.py index 4c83e3db..75ed4526 100644 --- a/ravendb/documents/operations/ai/agents/run_conversation_operation.py +++ b/ravendb/documents/operations/ai/agents/run_conversation_operation.py @@ -1,28 +1,34 @@ from __future__ import annotations import json from dataclasses import dataclass -from typing import Optional, List, Dict, Any, TypeVar, Generic +from typing import Optional, List, Dict, Any, TypeVar, Generic, Callable from ravendb.documents.operations.definitions import MaintenanceOperation from ravendb.documents.conventions import DocumentConventions -from ravendb.http.raven_command import RavenCommand +from ravendb.http.raven_command import RavenCommand, RavenCommandResponseType from ravendb.http.server_node import ServerNode import requests +from ravendb.http.misc import ResponseDisposeHandling + TSchema = TypeVar("TSchema") -@dataclass class AiAgentActionRequest: """Represents an action request from an AI agent.""" - name: Optional[str] = None - tool_id: Optional[str] = None - arguments: Optional[str] = None + def __init__(self, name: str = None, tool_id: str = None, arguments: str = None): + self.name = name + self.tool_id = tool_id + self.arguments = arguments @classmethod def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentActionRequest: - return cls(name=json_dict.get("Name"), tool_id=json_dict.get("ToolId"), arguments=json_dict.get("Arguments")) + return cls( + name=json_dict.get("Name"), + tool_id=json_dict.get("ToolId"), + arguments=json_dict.get("Arguments"), + ) def to_json(self) -> Dict[str, Any]: return { @@ -78,27 +84,37 @@ def to_json(self) -> Dict[str, Any]: class ConversationResult(Generic[TSchema]): - def __init__(self): - self.conversation_id: Optional[str] = None - self.change_vector: Optional[str] = None - self.response: Optional[TSchema] = None - self.usage: Optional[AiUsage] = None - self.action_requests: List[AiAgentActionRequest] = [] + def __init__( + self, + conversation_id: Optional[str] = None, + change_vector: Optional[str] = None, + response: Optional[TSchema] = None, + usage: Optional[AiUsage] = None, + action_requests: Optional[List[AiAgentActionRequest]] = None, + ): + self.conversation_id: Optional[str] = conversation_id + self.change_vector: Optional[str] = change_vector + self.response: Optional[TSchema] = response + self.usage: Optional[AiUsage] = usage + self.action_requests: List[AiAgentActionRequest] = action_requests or [] @classmethod def from_json(cls, json_dict: Dict[str, Any]) -> ConversationResult: - result = cls() - result.conversation_id = json_dict.get("ConversationId") - result.change_vector = json_dict.get("ChangeVector") - result.response = json_dict.get("Response") - + usage = None if json_dict.get("Usage"): - result.usage = AiUsage.from_json(json_dict["Usage"]) + usage = AiUsage.from_json(json_dict["Usage"]) + action_requests = None if json_dict.get("ActionRequests"): - result.action_requests = [AiAgentActionRequest.from_json(req) for req in json_dict["ActionRequests"]] + action_requests = [AiAgentActionRequest.from_json(req) for req in json_dict["ActionRequests"]] - return result + return cls( + conversation_id=json_dict.get("ConversationId"), + change_vector=json_dict.get("ChangeVector"), + response=json_dict.get("Response"), + usage=usage, + action_requests=action_requests, + ) class AiConversationCreationOptions: @@ -106,9 +122,25 @@ class AiConversationCreationOptions: Options for creating AI agent conversations, including parameters and expiration settings. """ - def __init__(self): - self.expiration_in_sec: Optional[int] = None - self.parameters: Optional[Dict[str, Any]] = None + def __init__(self, parameters: Optional[Dict[str, Any]] = None, expiration_in_sec: Optional[int] = None): + self.expiration_in_sec: Optional[int] = expiration_in_sec + self.parameters: Optional[Dict[str, Any]] = parameters + + def add_parameter(self, name: str, value: Any) -> AiConversationCreationOptions: + """ + Adds a parameter to the conversation creation options. + + Args: + name: The parameter name + value: The parameter value + + Returns: + Self for method chaining + """ + if self.parameters is None: + self.parameters = {} + self.parameters[name] = value + return self def to_json(self) -> Dict[str, Any]: """ @@ -126,10 +158,15 @@ class ConversationRequestBody: action responses, and creation options. """ - def __init__(self): - self.action_responses: Optional[List[AiAgentActionResponse]] = None - self.user_prompt: Optional[str] = None - self.creation_options: Optional[AiConversationCreationOptions] = None + def __init__( + self, + action_responses: Optional[List[AiAgentActionResponse]] = None, + user_prompt: Optional[List[str]] = None, + creation_options: Optional[AiConversationCreationOptions] = None, + ): + self.action_responses: Optional[List[AiAgentActionResponse]] = action_responses + self.user_prompt: Optional[List[str]] = user_prompt # List of prompt parts + self.creation_options: Optional[AiConversationCreationOptions] = creation_options def to_json(self) -> Dict[str, Any]: """ @@ -138,69 +175,80 @@ def to_json(self) -> Dict[str, Any]: Returns: Dictionary representation of the request body """ - # Build dictionary with only non-None values result = {} - if self.action_responses is not None: - result["ActionResponses"] = [resp.to_json() for resp in self.action_responses] + # ActionResponses: null if None, otherwise array + result["ActionResponses"] = ( + None if self.action_responses is None else [resp.to_json() for resp in self.action_responses] + ) - if self.user_prompt is not None: - result["UserPrompt"] = self.user_prompt + # UserPrompt: null if None, otherwise array (even if empty) + result["UserPrompt"] = self.user_prompt - if self.creation_options is not None: - result["CreationOptions"] = self.creation_options.to_json() + # CreationOptions: always present (create empty if None, matching C# behavior) + result["CreationOptions"] = (self.creation_options or AiConversationCreationOptions()).to_json() return result class RunConversationOperation(MaintenanceOperation[ConversationResult[TSchema]]): + """ + Operation for running AI agent conversations. + + Both agent_id and conversation_id are required. The agent_id identifies which AI agent to use, + while conversation_id tracks the conversation state across multiple turns. + """ + def __init__( self, - agent_id_or_conversation_id: str, - user_prompt: str = None, - parameters_or_action_responses: Any = None, - change_vector: str = None, + agent_id: str, + conversation_id: str, + prompt_parts: Optional[List[str]] = None, + action_responses: Optional[List[AiAgentActionResponse]] = None, + options: Optional[AiConversationCreationOptions] = None, + change_vector: Optional[str] = None, + stream_property_path: Optional[str] = None, + streamed_chunks_callback: Optional[Callable[[str], None]] = None, ): - # Reset all fields first - self._conversation_id = None - self._agent_id = None - self._user_prompt = None - self._parameters = None - self._action_responses = None - self._change_vector = None - - if change_vector is not None or isinstance(parameters_or_action_responses, list): - # Constructor overload: conversationId-based - if not agent_id_or_conversation_id or agent_id_or_conversation_id.isspace(): - raise ValueError("conversation_id cannot be None or empty") - - self._conversation_id = agent_id_or_conversation_id - self._user_prompt = user_prompt - self._action_responses = ( - parameters_or_action_responses if isinstance(parameters_or_action_responses, list) else None - ) - self._change_vector = change_vector - else: - # Constructor overload: agentId-based - if not agent_id_or_conversation_id or agent_id_or_conversation_id.isspace(): - raise ValueError("agent_id cannot be None or empty") - if user_prompt is not None and (not user_prompt or user_prompt.isspace()): - raise ValueError("user_prompt cannot be empty") - - self._agent_id = agent_id_or_conversation_id - self._user_prompt = user_prompt - self._parameters = ( - parameters_or_action_responses if isinstance(parameters_or_action_responses, dict) else None - ) + """ + Initialize a RunConversationOperation. + + Args: + agent_id: The ID of the AI agent (required) + conversation_id: The ID of the conversation (required) + prompt_parts: List of prompt strings to send to the agent + action_responses: List of action responses from previous turn + options: Creation options including parameters and expiration + change_vector: Change vector for optimistic concurrency + stream_property_path: Optional response property name to stream + streamed_chunks_callback: Optional callback invoked per streamed chunk + """ + if not agent_id or (isinstance(agent_id, str) and agent_id.isspace()): + raise ValueError("agent_id cannot be None or empty") + if not conversation_id or (isinstance(conversation_id, str) and conversation_id.isspace()): + raise ValueError("conversation_id cannot be None or empty") + if (stream_property_path is None) != (streamed_chunks_callback is None): + raise ValueError("Both stream_property_path and streamed_chunks_callback must be specified together") + + self._agent_id = agent_id + self._conversation_id = conversation_id + self._prompt_parts = prompt_parts + self._action_responses = action_responses + self._options = options + self._change_vector = change_vector + self._stream_property_path = stream_property_path + self._streamed_chunks_callback = streamed_chunks_callback def get_command(self, conventions: DocumentConventions) -> RavenCommand[ConversationResult[TSchema]]: return RunConversationCommand( - conversation_id=self._conversation_id, agent_id=self._agent_id, - prompt=self._user_prompt, - parameters=self._parameters, + conversation_id=self._conversation_id, + prompt_parts=self._prompt_parts, action_responses=self._action_responses, + options=self._options, change_vector=self._change_vector, + stream_property_path=self._stream_property_path, + streamed_chunks_callback=self._streamed_chunks_callback, conventions=conventions, ) @@ -208,59 +256,62 @@ def get_command(self, conventions: DocumentConventions) -> RavenCommand[Conversa class RunConversationCommand(RavenCommand[ConversationResult[TSchema]]): def __init__( self, - conversation_id: str = None, - agent_id: str = None, - prompt: str = None, - parameters: Dict[str, Any] = None, - action_responses: List[AiAgentActionResponse] = None, - change_vector: str = None, - conventions: DocumentConventions = None, + agent_id: str, + conversation_id: str, + prompt_parts: Optional[List[str]] = None, + action_responses: Optional[List[AiAgentActionResponse]] = None, + options: Optional[AiConversationCreationOptions] = None, + change_vector: Optional[str] = None, + stream_property_path: Optional[str] = None, + streamed_chunks_callback: Optional[Callable[[str], None]] = None, + conventions: Optional[DocumentConventions] = None, ): + from ravendb.util.util import RaftIdGenerator + super().__init__(ConversationResult) - self._conversation_id = conversation_id self._agent_id = agent_id - self._prompt = prompt - self._parameters = parameters + self._conversation_id = conversation_id + self._prompt_parts = prompt_parts self._action_responses = action_responses + self._options = options self._change_vector = change_vector + self._stream_property_path = stream_property_path + self._streamed_chunks_callback = streamed_chunks_callback self._conventions = conventions + self._raft_id = RaftIdGenerator.dont_care_id() def is_read_request(self) -> bool: return False def create_request(self, node: ServerNode) -> requests.Request: - url = f"{node.url}/databases/{node.database}/ai/agent" - - # Add query parameters - server requires BOTH agentId and conversationId - query_params = [] from urllib.parse import quote + from ravendb.util.util import RaftIdGenerator - if self._conversation_id and self._agent_id: - # Continuing conversation - we have both - query_params.append(f"conversationId={quote(self._conversation_id)}") - query_params.append(f"agentId={quote(self._agent_id)}") - elif self._conversation_id: - # We only have conversation ID - this might fail, but let's try - query_params.append(f"conversationId={quote(self._conversation_id)}") - elif self._agent_id: - # New conversation - use conversation prefix as per RavenDB documentation - # The server will generate the full conversation ID from the prefix - conversation_prefix = "conversations/" - query_params.append(f"conversationId={quote(conversation_prefix)}") - query_params.append(f"agentId={quote(self._agent_id)}") - - if query_params: - url += "?" + "&".join(query_params) + # Build URL with required query parameters + url = ( + f"{node.url}/databases/{node.database}/ai/agent" + f"?conversationId={quote(self._conversation_id)}" + f"&agentId={quote(self._agent_id)}" + ) - # Build request body with correct structure to match .NET client - request_body = ConversationRequestBody() - request_body.action_responses = self._action_responses - request_body.user_prompt = self._prompt + # Check if this is a Raft operation (conversation_id ends with '|') + if self._conversation_id.endswith("|"): + self._raft_id = RaftIdGenerator.new_id() + + # Add changeVector to URL if provided (for optimistic concurrency) + if self._change_vector: + url += f"&changeVector={quote(self._change_vector)}" - # Always include CreationOptions to match .NET client structure - creation_options = AiConversationCreationOptions() - creation_options.parameters = self._parameters - request_body.creation_options = creation_options + # Add streaming flags if requested + if self._stream_property_path: + url += f"&streaming=true&streamPropertyPath={quote(self._stream_property_path)}" + + # Build request body with correct structure to match .NET client + request_body = ConversationRequestBody( + action_responses=self._action_responses, + user_prompt="".join(self._prompt_parts), + creation_options=self._options, + ) body = json.dumps(request_body.to_json()) @@ -268,22 +319,50 @@ def create_request(self, node: ServerNode) -> requests.Request: request = requests.Request("POST", url) request.headers = {"Content-Type": "application/json"} - if self._change_vector: - request.headers["If-Match"] = self._change_vector - request.data = body return request + # todo: this should be handled by writing custom set_response_raw method, and ravendcommandresponsetype set to RAW + def process_response(self, cache, response: requests.Response, url) -> ResponseDisposeHandling: + # If not streaming, delegate to the default handler + if not self._stream_property_path: + return super().process_response(cache, response, url) + + try: + for line in response.iter_lines(decode_unicode=True): + if not line: + continue + if line.startswith("{"): + response_json = json.loads(line) + self.result = ConversationResult.from_json(response_json) + return ResponseDisposeHandling.AUTOMATIC + # Non-final lines are JSON-encoded strings (e.g. "\\\"chunk\\\"") + try: + chunk = json.loads(line) + except Exception: + chunk = line + if self._streamed_chunks_callback: + self._streamed_chunks_callback(chunk) + # No final JSON object received; set empty result + self.result = ConversationResult() + return ResponseDisposeHandling.AUTOMATIC + finally: + # Response will be closed by RequestExecutor when AUTOMATIC is returned + pass + + def send(self, session: requests.Session, request: requests.Request) -> requests.Response: + if self._stream_property_path: + from ravendb.util.request_utils import RequestUtils + + prepared_request = session.prepare_request(request) + RequestUtils.remove_zstd_encoding(prepared_request) + return session.send(prepared_request, cert=session.cert, stream=True) + return super().send(session, request) + def set_response(self, response: str, from_cache: bool) -> None: if response is None: - self.result = ConversationResult() + self.result = ConversationResult() # Uses default constructor with all None values return response_json = json.loads(response) self.result = ConversationResult.from_json(response_json) - - def get_raft_unique_request_id(self) -> str: - # Generate a unique ID for Raft operations - import uuid - - return str(uuid.uuid4()) diff --git a/ravendb/documents/operations/ai/ai_connection_string.py b/ravendb/documents/operations/ai/ai_connection_string.py index 0bd13cc7..dda821e3 100644 --- a/ravendb/documents/operations/ai/ai_connection_string.py +++ b/ravendb/documents/operations/ai/ai_connection_string.py @@ -9,6 +9,8 @@ from ravendb.documents.operations.ai.mistral_ai_settings import MistralAiSettings from ravendb.documents.operations.ai.ollama_settings import OllamaSettings from ravendb.documents.operations.ai.open_ai_settings import OpenAiSettings +from ravendb.documents.operations.ai.vertex_settings import VertexSettings + from ravendb.documents.operations.connection_strings import ConnectionString @@ -29,6 +31,7 @@ def __init__( google_settings: Optional[GoogleSettings] = None, huggingface_settings: Optional[HuggingFaceSettings] = None, mistral_ai_settings: Optional[MistralAiSettings] = None, + vertex_settings: Optional[VertexSettings] = None, model_type: AiModelType = None, ): super().__init__(name) @@ -40,6 +43,7 @@ def __init__( self.google_settings = google_settings self.huggingface_settings = huggingface_settings self.mistral_ai_settings = mistral_ai_settings + self.vertex_settings = vertex_settings self.model_type = model_type if not any( @@ -51,10 +55,11 @@ def __init__( google_settings, huggingface_settings, mistral_ai_settings, + vertex_settings, ] ): raise ValueError( - "Please provide at least one of the following settings: openai_settings, azure_openai_settings, ollama_settings, embedded_settings, google_settings, huggingface_settings, mistral_ai_settings" + "Please provide at least one of the following settings: openai_settings, azure_openai_settings, ollama_settings, embedded_settings, google_settings, huggingface_settings, mistral_ai_settings, vertex_settings" ) if model_type is None: @@ -69,12 +74,13 @@ def __init__( google_settings, huggingface_settings, mistral_ai_settings, + vertex_settings, ]: if setting: settings_set_count += 1 if setting else 0 if settings_set_count > 1: raise ValueError( - "Please provide only one of the following settings: openai_settings, azure_openai_settings, ollama_settings, embedded_settings, google_settings, huggingface_settings, mistral_ai_settings" + "Please provide only one of the following settings: openai_settings, azure_openai_settings, ollama_settings, embedded_settings, google_settings, huggingface_settings, mistral_ai_settings, vertex_settings" ) @property @@ -92,6 +98,7 @@ def to_json(self) -> Dict[str, Any]: "GoogleSettings": self.google_settings.to_json() if self.google_settings else None, "HuggingFaceSettings": self.huggingface_settings.to_json() if self.huggingface_settings else None, "MistralAiSettings": self.mistral_ai_settings.to_json() if self.mistral_ai_settings else None, + "VertexSettings": self.vertex_settings.to_json() if self.vertex_settings else None, "ModelType": self.model_type.value if self.model_type else None, "Type": self.get_type, } @@ -128,5 +135,8 @@ def from_json(cls, json_dict: Dict[str, Any]) -> "AiConnectionString": if json_dict.get("MistralAiSettings") else None ), + vertex_settings=( + VertexSettings.from_json(json_dict["VertexSettings"]) if json_dict.get("VertexSettings") else None + ), model_type=AiModelType(json_dict["ModelType"]) if json_dict.get("ModelType") else None, ) diff --git a/ravendb/documents/operations/ai/chunking_options.py b/ravendb/documents/operations/ai/chunking_options.py new file mode 100644 index 00000000..8dd78904 --- /dev/null +++ b/ravendb/documents/operations/ai/chunking_options.py @@ -0,0 +1,39 @@ +from enum import Enum +from typing import Dict, Any, Optional + + +# todo: EmbeddingsGenerationConfiguration +class ChunkingMethod(Enum): + PLAIN_TEXT_SPLIT = "PlainTextSplit" + PLAIN_TEXT_SPLIT_LINES = "PlainTextSplitLines" + PLAIN_TEXT_SPLIT_PARAGRAPHS = "PlainTextSplitParagraphs" + MARK_DOWN_SPLIT_LINES = "MarkDownSplitLines" + MARK_DOWN_SPLIT_PARAGRAPHS = "MarkDownSplitParagraphs" + HTML_STRIP = "HtmlStrip" + + +class ChunkingOptions: + def __init__( + self, + chunking_method: Optional[ChunkingMethod] = None, + max_tokens_per_chunk: int = 512, + overlap_tokens: int = 0, + ): + self.chunking_method = chunking_method + self.max_tokens_per_chunk = max_tokens_per_chunk + self.overlap_tokens = overlap_tokens + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> "ChunkingOptions": + return cls( + chunking_method=ChunkingMethod(json_dict["ChunkingMethod"]) if json_dict.get("ChunkingMethod") else None, + max_tokens_per_chunk=json_dict.get("MaxTokensPerChunk", 512), + overlap_tokens=json_dict.get("OverlapTokens", 0), + ) + + 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, + } diff --git a/ravendb/documents/operations/ai/embedding_path_configuration.py b/ravendb/documents/operations/ai/embedding_path_configuration.py new file mode 100644 index 00000000..58988d5f --- /dev/null +++ b/ravendb/documents/operations/ai/embedding_path_configuration.py @@ -0,0 +1,25 @@ +from typing import Dict, Any, Optional + +from ravendb.documents.operations.ai.chunking_options import ChunkingOptions + + +# todo: EmbeddingsGenerationConfiguration +class EmbeddingPathConfiguration: + def __init__(self, path: Optional[str] = None, chunking_options: Optional[ChunkingOptions] = None): + self.path = path + self.chunking_options = chunking_options + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> "EmbeddingPathConfiguration": + return cls( + path=json_dict.get("Path"), + chunking_options=( + ChunkingOptions.from_json(json_dict["ChunkingOptions"]) if json_dict.get("ChunkingOptions") else None + ), + ) + + def to_json(self) -> Dict[str, Any]: + return { + "Path": self.path, + "ChunkingOptions": self.chunking_options.to_json() if self.chunking_options else None, + } diff --git a/ravendb/documents/operations/ai/embeddings_transformation.py b/ravendb/documents/operations/ai/embeddings_transformation.py new file mode 100644 index 00000000..c9ff5f1c --- /dev/null +++ b/ravendb/documents/operations/ai/embeddings_transformation.py @@ -0,0 +1,33 @@ +from typing import Dict, Any, Optional + +from ravendb.documents.operations.ai.chunking_options import ChunkingOptions, ChunkingMethod + + +# todo: EmbeddingsGenerationConfiguration +class EmbeddingsTransformation: + GENERATE_EMBEDDINGS_FUNCTION_NAME = "embeddings.generate" + + def __init__( + self, + script: Optional[str] = None, + chunking_options: Optional[ChunkingOptions] = None, + ): + self.script = script + self.chunking_options = chunking_options or ChunkingOptions( + chunking_method=ChunkingMethod.PLAIN_TEXT_SPLIT, max_tokens_per_chunk=256 + ) + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> "EmbeddingsTransformation": + return cls( + script=json_dict.get("Script"), + chunking_options=( + ChunkingOptions.from_json(json_dict["ChunkingOptions"]) if json_dict.get("ChunkingOptions") else None + ), + ) + + def to_json(self) -> Dict[str, Any]: + return { + "Script": self.script, + "ChunkingOptions": self.chunking_options.to_json() if self.chunking_options else None, + } diff --git a/ravendb/documents/operations/ai/vertex_settings.py b/ravendb/documents/operations/ai/vertex_settings.py new file mode 100644 index 00000000..24a2713c --- /dev/null +++ b/ravendb/documents/operations/ai/vertex_settings.py @@ -0,0 +1,42 @@ +from enum import Enum +from typing import Dict, Any, Optional + +from ravendb.documents.operations.ai.abstract_ai_settings import AbstractAiSettings + + +class VertexAIVersion(Enum): + V1 = "V1" + V1_BETA = "V1_Beta" + + +class VertexSettings(AbstractAiSettings): + def __init__( + self, + model: Optional[str] = None, + google_credentials_json: Optional[str] = None, + location: Optional[str] = None, + ai_version: Optional[VertexAIVersion] = None, + ): + super().__init__() + self.model = model + self.google_credentials_json = google_credentials_json + self.location = location + self.ai_version = ai_version + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> "VertexSettings": + return cls( + model=json_dict.get("Model"), + google_credentials_json=json_dict.get("GoogleCredentialsJson"), + location=json_dict.get("Location"), + ai_version=VertexAIVersion(json_dict["AiVersion"]) if json_dict.get("AiVersion") else None, + ) + + def to_json(self) -> Dict[str, Any]: + return { + "Model": self.model, + "GoogleCredentialsJson": self.google_credentials_json, + "AiVersion": self.ai_version.value if self.ai_version else None, + "Location": self.location, + "EmbeddingsMaxConcurrentBatches": self.embeddings_max_concurrent_batches, + } diff --git a/ravendb/documents/queries/utils.py b/ravendb/documents/queries/utils.py index 64e0e5eb..d34e3f27 100644 --- a/ravendb/documents/queries/utils.py +++ b/ravendb/documents/queries/utils.py @@ -100,7 +100,7 @@ def escape_if_necessary(name: str, is_path: bool = False) -> str: not name or name == constants.Documents.Indexing.Fields.DOCUMENT_ID_FIELD_NAME or name == constants.Documents.Indexing.Fields.REDUCE_KEY_HASH_FIELD_NAME - or name == constants.Documents.Indexing.Fields.REDUCE_KEY_KEY_VALUE_FIELD_NAME + or name == constants.Documents.Indexing.Fields.REDUCE_KEY_VALUE_FIELD_NAME or name == constants.Documents.Indexing.Fields.VALUE_FIELD_NAME or name == constants.Documents.Indexing.Fields.SPATIAL_SHAPE_FIELD_NAME ): diff --git a/ravendb/documents/session/document_session.py b/ravendb/documents/session/document_session.py index 0d39136c..f288b546 100644 --- a/ravendb/documents/session/document_session.py +++ b/ravendb/documents/session/document_session.py @@ -668,6 +668,16 @@ def has_changed(self, entity: object) -> bool: if document_info is None: return False + # Ensure metadata modifications performed via advanced.get_metadata_for(...) + # are reflected before diffing, so metadata-only changes are detected. + try: + from ravendb.documents.session.document_session_operations.misc import _update_metadata_modifications + + _update_metadata_modifications(document_info.metadata_instance, document_info.metadata) + except Exception: + # Be conservative: if helper import fails for any reason, proceed without blocking + pass + document = self._session.entity_to_json.convert_entity_to_json(entity, document_info) return self._session._entity_changed(document, document_info, None) diff --git a/ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py b/ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py index 3c212ca2..470945c9 100644 --- a/ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py +++ b/ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py @@ -1166,6 +1166,9 @@ def _entity_changed( def has_changes(self) -> bool: for entity in self._documents_by_entity: entity: DocumentsByEntityHolder.DocumentsByEntityEnumeratorResult + # Ensure metadata modifications done via advanced.get_metadata_for(...) + # are reflected before diffing, so metadata-only changes are detected. + _update_metadata_modifications(entity.value.metadata_instance, entity.value.metadata) document = self.entity_to_json.convert_entity_to_json(entity.key, entity.value) if self._entity_changed(document, entity.value, None): return True @@ -1833,7 +1836,7 @@ def _process_query_parameters( if not is_collection and not is_index: collection_name = conventions.get_collection_name(object_type) collection_name = ( - collection_name if collection_name else constants.Documents.Metadata.ALL_DOCUMENTS_COLLECTION + collection_name if collection_name else constants.Documents.Collections.ALL_DOCUMENTS_COLLECTION ) return index_name, collection_name diff --git a/ravendb/http/request_executor.py b/ravendb/http/request_executor.py index ff2a1928..85aa98fc 100644 --- a/ravendb/http/request_executor.py +++ b/ravendb/http/request_executor.py @@ -48,7 +48,7 @@ class RequestExecutor: __INITIAL_TOPOLOGY_ETAG = -2 __GLOBAL_APPLICATION_IDENTIFIER = uuid.uuid4() - CLIENT_VERSION = "7.1.2" + CLIENT_VERSION = "7.1.3" logger = logging.getLogger("request_executor") # todo: initializer should take also cryptography certificates diff --git a/ravendb/primitives/constants.py b/ravendb/primitives/constants.py index 2c83a056..30285280 100644 --- a/ravendb/primitives/constants.py +++ b/ravendb/primitives/constants.py @@ -1,3 +1,4 @@ +import enum import sys from ravendb.documents.indexes.vector.embedding import VectorEmbeddingType @@ -9,82 +10,307 @@ nan_value = float("nan") +class _CompanyInformation: + COMPANY_OID = "1.3.6.1.4.1.45751" + + +class Json: + class Fields: + TYPE = "$type" + VALUES = "$values" + + +class QueryString: + NODE_TAG = "nodeTag" + SHARD_NUMBER = "shardNumber" + + +class Headers: + REQUEST_TIME = "Request-Time" + SERVER_STARTUP_TIME = "Server-Startup-Time" + REFRESH_TOPOLOGY = "Refresh-Topology" + TOPOLOGY_ETAG = "Topology-Etag" + CLUSTER_TOPOLOGY_ETAG = "Cluster-Topology-Etag" + CLIENT_CONFIGURATION_ETAG = "Client-Configuration-Etag" + LAST_KNOWN_CLUSTER_TRANSACTION_INDEX = "Known-Raft-Index" + DATABASE_CLUSTER_TRANSACTION_ID = "Database-Cluster-Tx-Id" + REFRESH_CLIENT_CONFIGURATION = "Refresh-Client-Configuration" + ETAG = "ETag" + CLIENT_VERSION = "Raven-Client-Version" + SERVER_VERSION = "Raven-Server-Version" + STUDIO_VERSION = "Raven-Studio-Version" + IF_MATCH = "If-Match" + IF_NONE_MATCH = "If-None-Match" + TRANSFER_ENCODING = "Transfer-Encoding" + CONTENT_ENCODING = "Content-Encoding" + ACCEPT_ENCODING = "Accept-Encoding" + CONTENT_DISPOSITION = "Content-Disposition" + CONTENT_TYPE = "Content-Type" + CONTENT_LENGTH = "Content-Length" + ORIGIN = "Origin" + INCREMENTAL_TIME_SERIES_PREFIX = "INC:" + SHARDED = "Sharded" + ATTACHMENT_HASH = "Attachment-Hash" + DATABASE_MISSING = "Database-Missing" + + class Encodings: + GZIP = "gzip" + BROTLI = "br" + DEFLATE = "deflate" + ZSTD = "zstd" + + +class Platform: + class Windows: + MAX_PATH = 0x7FFF + RESERVED_FILE_NAMES = [ + "con", + "prn", + "aux", + "nul", + "com1", + "com2", + "com3", + "com4", + "com5", + "com6", + "com7", + "com8", + "com9", + "lpt1", + "lpt2", + "lpt3", + "lpt4", + "lpt5", + "lpt6", + "lpt7", + "lpt8", + "lpt9", + "clock$", + ] + + class Linux: + MAX_PATH = 4096 + MAX_FILE_NAME_LENGTH = 230 + + +class Certificates: + PREFIX = "certificates/" + MAX_NUMBER_OF_CERTS_WITH_SAME_HASH = 5 + SERVER_AUTHENTICATION_OID = "1.3.6.1.5.5.7.3.1" + CLIENT_AUTHENTICATION_OID = "1.3.6.1.5.5.7.3.2" + SERVER_CERT_EXTENSION_OID = _CompanyInformation.COMPANY_OID + ".2.1" + + +class Network: + ANY_IP = "0.0.0.0" + ZERO_VALUE = 0 + DEFAULT_SECURED_RAVEN_DB_HTTP_PORT = 443 + DEFAULT_SECURED_RAVEN_DB_TCP_PORT = 38888 + + +class DatabaseSettings: + STUDIO_ID = "DatabasesSettings/Studio" + + +class Configuration: + class Indexes: + INDEXING_STATIC_SEARCH_ENGINE_TYPE = "Indexing.Static.SearchEngineType" + + CLIENT_ID = "Configuration/Client" + STUDIO_ID = "Configuration/Studio" + + +class Counters: + ALL = "@all_counters" + + +class TimeSeries: + SELECT_FIELD_NAME = "timeseries" + QUERY_FUNCTION = "__timeSeriesQueryFunction" + + ALL = "@all_timeseries" + + class Documents: + PREFIX = "db/" + MAX_DATABASE_NAME_LENGTH = 128 + + class SubscriptionChangeVectorSpecialStates(enum.Enum): + DO_NOT_CHANGE = "DoNotChange" + LAST_DOCUMENT = "LastDocument" + BEGINNING_OF_TIME = "BeginningOfTime" + class Metadata: + EDGES = "@edges" COLLECTION = "@collection" - CONFLICT = "@conflict" PROJECTION = "@projection" - METADATA = "@metadata" KEY = "@metadata" ID = "@id" + CONFLICT = "@conflict" + ID_PROPERTY = "Id" FLAGS = "@flags" ATTACHMENTS = "@attachments" + COUNTERS = "@counters" + TIME_SERIES = "@timeseries" + TIME_SERIES_NAMED_VALUES = "@timeseries-named-values" + REVISION_COUNTERS = "@counters-snapshot" + REVISION_TIME_SERIES = "@timeseries-snapshot" + LEGACY_ATTACHMENT_METADATA = "@legacy-attachment-metadata" INDEX_SCORE = "@index-score" + SPATIAL_RESULT = "@spatial" LAST_MODIFIED = "@last-modified" + RAVEN_PYTHON_TYPE = "Raven-Python-Type" CHANGE_VECTOR = "@change-vector" EXPIRES = "@expires" REFRESH = "@refresh" + ARCHIVE_AT = "@archive-at" + ARCHIVED = "@archived" + HAS_VALUE = "HasValue" + ETAG = "@etag" + QUANTIZATION = "@quantization" + GEN_AI_HASHES = "@gen-ai-hashes" + + class Sharding: + SHARD_NUMBER = "@shard-number" + + class Querying: + ORDER_BY_FIELDS = "@order-by-fields" + SUGGESTIONS_POPULARITY_FIELDS = "@suggestions-popularity" + RESULT_DATA_HASH = "@data-hash" + + class Subscription: + NON_PERSISTENT_FLAGS = "@non-persistent-flags" + + class Collections: ALL_DOCUMENTS_COLLECTION = "@all_docs" EMPTY_COLLECTION = "@empty" + EMBEDDINGS_CACHE_COLLECTION = "@embeddings-cache" + AI_AGENT_CONVERSATIONS_COLLECTION = "@conversations" + AI_AGGENT_CONVERSATION_HISTORY_COLLECTION = "@conversations-history" NESTED_OBJECT_TYPES = "@nested-object-types" - COUNTERS = "@counters" - TIME_SERIES = "@timeseries" - REVISION_COUNTERS = "@counters-snapshot" - REVISION_TIME_SERIES = "@timeseries-snapshot" - RAVEN_PYTHON_TYPE = "Raven-Python-Type" + + class Ai: + AI_AGENT_ID_PREFIX = "Conversations" class Indexing: SIDE_BY_SIDE_INDEX_NAME_PREFIX = "ReplacementOf/" class Fields: + COUNT_FIELD_NAME = "Count" DOCUMENT_ID_FIELD_NAME = "id()" + DOCUMENT_ID_METHOD_NAME = "id" SOURCE_DOCUMENT_ID_FIELD_NAME = "sourceDocId()" REDUCE_KEY_HASH_FIELD_NAME = "hash(key())" - REDUCE_KEY_KEY_VALUE_FIELD_NAME = "key()" + REDUCE_KEY_VALUE_FIELD_NAME = "key()" VALUE_FIELD_NAME = "value()" ALL_FIELDS = "__all_fields" + ALL_STORED_FIELDS = "__all_stored_fields" SPATIAL_SHAPE_FIELD_NAME = "spatial(shape)" + RANGE_FIELD_SUFFIX = "_Range" + RANGE_FIELD_SUFFIX_LONG = "_L" + RANGE_FIELD_SUFFIX + RANGE_FIELD_SUFFIX_DOUBLE = "_D" + RANGE_FIELD_SUFFIX + TIME_FIELD_SUFFIX = "_Time" + NULL_VALUE = "NULL_VALUE" + EMPTY_STRING = "EMPTY_STRING" class JavaScript: + VALUE_PROPERTY_NAME = "$value" + OPTIONS_PROPERTY_NAME = "$options" + NAME_PROPERTY_NAME = "$name" + SPATIAL_PROPERTY_NAME = "$spatial" + BOOST_PROPERTY_NAME = "$boost" VECTOR_PROPERTY_NAME = "$vector" + LOAD_VECTOR_PROPERTY_NAME = "$loadvector" class Spatial: DEFAULT_DISTANCE_ERROR_PCT = 0.025 + EARTH_MEAN_RADIUS_KM = 6371.0087714 + MILES_TO_KM = 1.60934 + + class Analyzers: + DEFAULT = "LowerCaseKeywordAnalyzer" + DEFAULT_EXACT = "KeywordAnalyzer" + DEFAULT_SEARCH = "RavenStandardAnalyzer" + + class Querying: + class Facet: + ALL_RESULTS = "@AllResults" + + class Fields: + POWER_BI_JSON_FIELD_NAME = "json()" + + class Sharding: + SHARD_CONTEXT_PARAMETER_NAME = "__shardContext" + SHARD_CONTEXT_DOCUMENT_IDS = "DocumentIds" + SHARD_CONTEXT_PREFIXES = "Prefixes" + + class Terms: + LEFT_NULL_VALUE_OF_BETWEEN_QUERY = "*" + RIGHT_NULL_VALUE_OF_BETWEEN_QUERY = "NULL" + + class PeriodicBackup: + FULL_BACKUP_EXTENSTION = ".ravendb-full-backup" + SNAPSHOT_EXTENSTION = ".ravendb-snapshot" + ENCRYPTED_FULL_BACKUP_EXTENSTION = ".ravendb-encrypted-full-backup" + ENCRYPTED_SNAPSHOT_EXTENSTION = ".ravendb-encrypted-snapshot" + INCREMENTAL_BACKUP_EXTENSTION = ".ravendb-incremental-backup" + ENCRYPTED_INCREMENTAL_BACKUP_EXTENSTION = ".ravendb-encrypted-incremental-backup" + + class Folders: + INDEXES = "Indexes" + DOCUMENTS = "Documents" + CONFIGURATION = "Configuration" + + class Blob: + DOCUMENT = "@raven-data" + SIZE = "@raven-blob-size" + + +class Identities: + DEFAULT_SEPARATOR = "/" + + +class Smuggler: + IMPORT_OPTIONS = "importOptions" + CSV_IMPORT_OPTIONS = "csvImportOptions" + + +class Operations: + INVALID_OPERATION_ID = -1 class CompareExchange: + RVN_ATOMIC_PREFIX = "rvn-atomic/" OBJECT_FIELD_NAME = "Object" -class Counters: - ALL = "@all_counters" +class Monitoring: + class Snmp: + DATABASES_MAPPING_KEY = "monitoring/snmp/databases/mapping" + SNMP_ROOT_ID = _CompanyInformation.COMPANY_OID + ".1.1" -class Headers: - REQUEST_TIME = "Raven-Request-Time" - REFRESH_TOPOLOGY = "Refresh-Topology" - TOPOLOGY_ETAG = "Topology-Etag" - LAST_KNOWN_CLUSTER_TRANSACTION_INDEX = "Known-Raft-Index" - CLIENT_CONFIGURATION_ETAG = "Client-Configuration-Etag" - REFRESH_CLIENT_CONFIGURATION = "Refresh-Client-Configuration" - CLIENT_VERSION = "Raven-Client-Version" - SERVER_VERSION = "Raven-Server-Version" - ETAG = "ETag" - IF_NONE_MATCH = "If-None-Match" - TRANSFER_ENCODING = "Transfer-Encoding" - CONTENT_ENCODING = "Content-Encoding" - CONTENT_LENGTH = "Content-Length" +class Fields: + DOCUMENT_CHANGE_VECTOR = None + DESTINATION_DOCUMENT_CHANGE_VECTOR = None -class TimeSeries: - SELECT_FIELD_NAME = "timeseries" - QUERY_FUNCTION = "__timeSeriesQueryFunction" +class Obsolete: + pass - ALL = "@all_timeseries" + +class DatabaseRecord: + class SupportedFeatures: + THROW_REVISION_KEY_TOO_BIG_FIX = "ThrowRevisionKeyTooBigFix" class VectorSearch: + AI_TASK_METHOD_NAME = "ai.task" EMBEDDING_PREFIX = "embedding." + + EMBEDDING_FOR_DOCUMENT = EMBEDDING_PREFIX + "forDoc" + EMBEDDING_FOR_RAW = EMBEDDING_PREFIX + "Raw" EMBEDDING_TEXT = EMBEDDING_PREFIX + "text" EMBEDDING_TEXT_INT_8 = EMBEDDING_PREFIX + "text_i8" EMBEDDING_TEXT_INT_1 = EMBEDDING_PREFIX + "text_i1" diff --git a/ravendb/tools/utils.py b/ravendb/tools/utils.py index df34bfff..78a81cbf 100644 --- a/ravendb/tools/utils.py +++ b/ravendb/tools/utils.py @@ -289,7 +289,7 @@ def escape_if_necessary(name: str, is_path: bool = False) -> str: in [ constants.Documents.Indexing.Fields.DOCUMENT_ID_FIELD_NAME, constants.Documents.Indexing.Fields.REDUCE_KEY_HASH_FIELD_NAME, - constants.Documents.Indexing.Fields.REDUCE_KEY_KEY_VALUE_FIELD_NAME, + constants.Documents.Indexing.Fields.REDUCE_KEY_VALUE_FIELD_NAME, constants.Documents.Indexing.Fields.VALUE_FIELD_NAME, constants.Documents.Indexing.Fields.SPATIAL_SHAPE_FIELD_NAME, ] diff --git a/setup.py b/setup.py index bb1dc126..c5b81c41 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name="ravendb", packages=find_packages(exclude=["*.tests.*", "tests", "*.tests", "tests.*"]), - version="7.1.2.post3", + version="7.1.3", long_description_content_type="text/markdown", long_description=open("README_pypi.md").read(), description="Python client for RavenDB NoSQL Database",