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
767 changes: 484 additions & 283 deletions ravendb/__init__.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions ravendb/documents/ai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
"AiMessagePromptFields",
"AiMessagePromptTypes",
]
from ravendb.documents.ai.ai_output_options import AiOutputOptions
48 changes: 48 additions & 0 deletions ravendb/documents/ai/ai_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from datetime import timedelta

from ravendb.documents.ai.ai_answer import AiAnswer, AiConversationStatus
from ravendb.documents.ai.ai_output_options import AiOutputOptions
from ravendb.documents.ai.content_part import ContentPart, TextPart
from ravendb.documents.operations.ai.agents import (
AiAgentActionRequest,
Expand Down Expand Up @@ -43,13 +44,17 @@ def __init__(
conversation_id: str = None,
change_vector: str = None,
debug: Optional[bool] = None,
cancel_pending_action_tools: bool = False,
):
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
# One-shot: the server drops the tool calls still awaiting a response on the next
# run, and the flag clears itself once that run succeeds.
self._cancel_pending_action_tools = cancel_pending_action_tools

self._prompt_parts: List[ContentPart] = []
self._action_responses: Dict[str, AiAgentActionResponse] = {}
Expand Down Expand Up @@ -145,16 +150,56 @@ def run(self) -> AiAnswer:
if self._handle_server_reply(r):
return r

def run_with_schema(self, output_options: "AiOutputOptions") -> AiAnswer:
"""
Runs one turn with the output format overridden for that turn only, leaving the
agent's own schema in place for later turns. Pass
``AiOutputOptions(no_schema=True)`` to get free-form text back instead of JSON.
"""
if output_options is None:
raise ValueError("output_options cannot be None")

self._dispatched_tool_ids.clear()

while True:
r = self._run_internal(output_options=output_options)
if self._handle_server_reply(r):
return r

def stream(self, stream_property_path: str = None, on_chunk: Optional[Callable[[str], None]] = None) -> AiAnswer:
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

def stream_with_schema(
self,
stream_property_path: str = None,
on_chunk: Optional[Callable[[str], None]] = None,
output_options: "AiOutputOptions" = None,
) -> AiAnswer:
"""
Streams one turn with the output format overridden for that turn only.
``stream_property_path`` is ignored when the options ask for no schema, since
free-form text has no property to stream from.
"""
if output_options is None:
raise ValueError("output_options cannot be None")

while True:
r = self._run_internal(
stream_property_path=stream_property_path,
streamed_chunks_callback=on_chunk,
output_options=output_options,
)
if self._handle_server_reply(r):
return r

def _run_internal(
self,
stream_property_path: Optional[str] = None,
streamed_chunks_callback: Optional[Callable[[str], None]] = None,
output_options: Optional["AiOutputOptions"] = None,
) -> AiAnswer:
from ravendb.documents.operations.ai.agents import RunConversationOperation
import time
Expand Down Expand Up @@ -193,7 +238,9 @@ def _run_internal(
stream_property_path=stream_property_path,
streamed_chunks_callback=streamed_chunks_callback,
attachments_commands=self._attachments_commands,
output_options=output_options,
debug=self._debug,
cancel_pending_action_tools=self._cancel_pending_action_tools,
)

try:
Expand All @@ -203,6 +250,7 @@ def _run_internal(

self._change_vector = result.change_vector
self._conversation_id = result.conversation_id
self._cancel_pending_action_tools = False
self._action_requests = result.action_requests or []

return AiAnswer(
Expand Down
36 changes: 34 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, Optional, Type
from typing import TYPE_CHECKING, Dict, Any, Optional, Type, Union

import warnings

Expand All @@ -11,7 +11,9 @@
from ravendb.documents.operations.ai.agents import (
AiAgentConfiguration,
AiAgentConfigurationResult,
AiConversationMessagesResult,
GetAiAgentsResponse,
GetConversationMessagesOptions,
)


Expand Down Expand Up @@ -71,13 +73,33 @@ def get_agents(self, agent_id: str = None) -> GetAiAgentsResponse:
operation = GetAiAgentOperation(agent_id)
return self._store.maintenance.send(operation)

def get_conversation_messages(
self, conversation_id_or_parameters: Union[str, "GetConversationMessagesOptions"]
) -> "AiConversationMessagesResult":
"""
Reads messages from an AI conversation. Returns the most recent messages by default.

Args:
conversation_id_or_parameters: The conversation document ID, or a
GetConversationMessagesOptions for full control over paging
(before/after timestamps), page size, and detail level

Returns:
The conversation's messages, cumulative usage, and paging state
"""
from ravendb.documents.operations.ai.agents import GetConversationMessagesOperation

operation = GetConversationMessagesOperation(conversation_id_or_parameters)
return self._store.maintenance.send(operation)

def conversation(
self,
agent_id: str,
conversation_id: str,
creation_options: "AiConversationCreationOptions" = None,
change_vector: str = None,
debug: Optional[bool] = None,
cancel_pending_action_tools: bool = False,
) -> AiConversation:
"""
Creates a new conversation with the specified AI agent.
Expand All @@ -88,12 +110,22 @@ def conversation(
creation_options: Optional creation options for the conversation
change_vector: Optional change vector for concurrency control
debug: Optional flag enabling server-side conversation debugging
cancel_pending_action_tools: Drop the tool calls the conversation is still waiting on
instead of answering them, on the next run. Cleared once that run succeeds.

Returns:
Conversation operations interface for managing the conversation
"""

return AiConversation(self._store, agent_id, creation_options, conversation_id, change_vector, debug)
return AiConversation(
self._store,
agent_id,
creation_options,
conversation_id,
change_vector,
debug,
cancel_pending_action_tools,
)

def conversation_with_id(self, conversation_id: str, change_vector: str = None) -> AiConversation:
"""
Expand Down
72 changes: 72 additions & 0 deletions ravendb/documents/ai/ai_output_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from __future__ import annotations

import json
from typing import Any, Dict, Optional


class AiOutputOptions:
"""
Overrides the output format for a single conversation turn.

The agent still needs a default output schema when it is created; these options take
precedence over it, and only for the turn they are passed to.

Give it exactly one of:

- ``sample_object`` - an object the server turns into a JSON schema at request time
- ``output_schema`` - an explicit JSON schema string
- ``no_schema=True`` - no structured output at all, so the model answers in free text
"""

def __init__(
self,
sample_object: Any = None,
output_schema: str = None,
no_schema: bool = False,
):
if no_schema and (sample_object is not None or output_schema is not None):
raise ValueError(
"no_schema asks the model for free-form text, so it cannot be combined with "
"an output schema or a sample object. Drop one of them."
)

if output_schema is not None and (not output_schema or output_schema.isspace()):
raise ValueError("output_schema cannot be empty or whitespace")

self.sample_object = sample_object
# Takes precedence over sample_object when both are set.
self.output_schema = output_schema
self.no_schema = no_schema

def to_json(self) -> Dict[str, Any]:
json_dict = {}

if self.sample_object is not None:
sample = self.sample_object
if callable(getattr(sample, "to_json", None)):
sample = sample.to_json()
# The server reads this as a JSON string, not as a nested object.
json_dict["SampleObject"] = json.dumps(sample)

if self.output_schema is not None:
json_dict["OutputSchema"] = self.output_schema

if self.no_schema:
json_dict["NoSchema"] = True

return json_dict

@classmethod
def from_json(cls, json_dict: Optional[Dict[str, Any]]) -> Optional[AiOutputOptions]:
if not json_dict:
return None

options = cls.__new__(cls)
sample = json_dict.get("SampleObject")
if sample is not None:
options.sample_object = json.loads(sample) if isinstance(sample, str) else sample
else:
options.sample_object = None
options.output_schema = json_dict.get("OutputSchema")
options.no_schema = json_dict.get("NoSchema", False)
return options
43 changes: 43 additions & 0 deletions ravendb/documents/commands/batches.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ravendb.util.util import RaftIdGenerator

if TYPE_CHECKING:
from ravendb.documents.operations.json_patch import JsonPatchDocument
from ravendb.documents.conventions import DocumentConventions
from ravendb.documents.operations.attachments import RemoteAttachmentParameters
from ravendb.documents.operations.patch import PatchRequest
Expand All @@ -48,6 +49,7 @@ class CommandType(Enum):
TIME_SERIES = "TimeSeries"
TIME_SERIES_BULK_INSERT = "TIME_SERIES_BULK_INSERT"
TIME_SERIES_COPY = "TIME_SERIES_COPY"
JSON_PATCH = "JsonPatch"
BATCH_PATCH = "BatchPATCH"
BATCH_TRACK_CHANGES = "BatchTrackChanges"
CLIENT_ANY_COMMAND = "CLIENT_ANY_COMMAND"
Expand Down Expand Up @@ -80,6 +82,8 @@ def from_csharp_value_str(cls, value: str) -> CommandType:
return cls.COMPARE_EXCHANGE_DELETE
elif value == "Counters":
return cls.COUNTERS
elif value == "JsonPatch":
return cls.JSON_PATCH
elif value == "BatchPATCH":
return cls.BATCH_PATCH
elif value == "BatchTrackChanges":
Expand Down Expand Up @@ -421,6 +425,45 @@ def serialize(self, conventions: DocumentConventions) -> dict:
}


class JsonPatchCommandData(CommandData):
"""
A batch command carrying RFC 6902 operations instead of a JavaScript script.
The server applies them structurally, without running a patch script.
"""

def __init__(self, key: str, patch: "JsonPatchDocument"):
super().__init__(key, None, None, CommandType.JSON_PATCH)
if not key:
raise ValueError("Key cannot be None")
if patch is None:
raise ValueError("Patch cannot be None")

self.__patch = patch
self.return_document: Union[None, bool] = None

def __consumer(session: InMemoryDocumentSessionOperations) -> None:
self.return_document = session.advanced.is_loaded(key)

self.__on_before_save_changes = __consumer

@property
def json_patch(self) -> "JsonPatchDocument":
return self.__patch

@property
def on_before_save_changes(self):
return self.__on_before_save_changes

def serialize(self, conventions: DocumentConventions) -> dict:
return {
"Id": self.key,
"ChangeVector": None,
"JsonPatch": {"Operations": self.__patch.to_json()},
"ReturnDocument": bool(self.return_document),
"Type": CommandType.JSON_PATCH.value,
}


class PatchCommandData(CommandData):
def __init__(
self,
Expand Down
32 changes: 32 additions & 0 deletions ravendb/documents/conventions.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@
)


class SessionPatchBehavior(Enum):
"""
Which command the session's patch methods emit.

JSON_PATCH generates RFC 6902 operations where it can, and still falls back to a
JavaScript patch for anything JsonPatch cannot express. JAVA_SCRIPT always emits the
JavaScript patch, which is what this client did before JsonPatch support existed.
"""

JSON_PATCH = "JsonPatch"
JAVA_SCRIPT = "JavaScript"

def __str__(self) -> str:
return self.value


class DocumentConventions(object):
@classmethod
def default_conventions(cls):
Expand Down Expand Up @@ -64,6 +80,7 @@ def __init__(self):
self.throw_if_query_page_size_is_not_set = False
self._send_application_identifier = True
self._save_enums_as_integers: Optional[bool] = None
self._session_patch_behavior = SessionPatchBehavior.JSON_PATCH
self._disable_atomic_document_writes_in_cluster_wide_transaction: Optional[bool] = None

# Configuration
Expand Down Expand Up @@ -189,6 +206,20 @@ def save_enums_as_integers(self) -> bool:
def save_enums_as_integers(self, value: bool):
self._save_enums_as_integers = value

@property
def session_patch_behavior(self) -> "SessionPatchBehavior":
"""
Whether the session's patch methods emit RFC 6902 JsonPatch commands (the default)
or the JavaScript patch commands this client used before. Set it to
SessionPatchBehavior.JAVA_SCRIPT to stay on the old code path entirely.
"""
return self._session_patch_behavior

@session_patch_behavior.setter
def session_patch_behavior(self, value: "SessionPatchBehavior") -> None:
self._assert_not_frozen()
self._session_patch_behavior = value

@property
def find_python_class_name(self) -> Callable[[type], str]:
return self._find_python_class_name
Expand Down Expand Up @@ -437,6 +468,7 @@ def clone(self) -> DocumentConventions:
cloned._should_ignore_entity_changes = self._should_ignore_entity_changes
cloned._original_configuration = self._original_configuration
cloned._save_enums_as_integers = self._save_enums_as_integers
cloned._session_patch_behavior = self._session_patch_behavior
cloned.identity_parts_separator = self.identity_parts_separator
cloned.disable_topology_updates = self.disable_topology_updates
cloned._find_identity_property_name = self._find_identity_property_name
Expand Down
Loading
Loading