Skip to content

Commit 0c506a4

Browse files
committed
RDBC-967 7.1.4 AI changes
1 parent 71607f3 commit 0c506a4

File tree

8 files changed

+181
-25
lines changed

8 files changed

+181
-25
lines changed

ravendb/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,15 @@
8282
AiOperations,
8383
AiConversation,
8484
AiConversationResult,
85+
ContentPart,
86+
TextPart,
87+
AiMessagePromptFields,
88+
AiMessagePromptTypes,
8589
)
8690
from ravendb.documents.operations.ai.agents import (
8791
AiAgentConfiguration,
8892
AiAgentConfigurationResult,
93+
AiAgentParameter,
8994
AiAgentToolAction,
9095
AiAgentToolQuery,
9196
AiAgentPersistenceConfiguration,

ravendb/documents/ai/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22
from .ai_conversation import AiConversation
33
from .ai_conversation_result import AiConversationResult
44
from .ai_answer import AiAnswer, AiConversationStatus
5+
from .content_part import ContentPart, TextPart, AiMessagePromptFields, AiMessagePromptTypes
56

67
__all__ = [
78
"AiOperations",
89
"AiConversation",
910
"AiConversationResult",
1011
"AiAnswer",
1112
"AiConversationStatus",
13+
"ContentPart",
14+
"TextPart",
15+
"AiMessagePromptFields",
16+
"AiMessagePromptTypes",
1217
]

ravendb/documents/ai/ai_conversation.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from datetime import timedelta
77

88
from ravendb.documents.ai.ai_answer import AiAnswer, AiConversationStatus
9+
from ravendb.documents.ai.content_part import ContentPart, TextPart
910
from ravendb.documents.operations.ai.agents import (
1011
AiAgentActionRequest,
1112
AiAgentActionResponse,
@@ -53,7 +54,7 @@ def __init__(
5354
self._conversation_id = conversation_id
5455
self._change_vector = change_vector
5556

56-
self._prompt_parts: List[str] = []
57+
self._prompt_parts: List[ContentPart] = []
5758
self._action_responses: List[AiAgentActionResponse] = []
5859
self._action_requests: Optional[List[AiAgentActionRequest]] = None
5960

@@ -269,7 +270,7 @@ def set_user_prompt(self, user_prompt: str) -> None:
269270
if not user_prompt or user_prompt.isspace():
270271
raise ValueError("User prompt cannot be empty or whitespace-only")
271272
self._prompt_parts.clear()
272-
self._prompt_parts.append(user_prompt)
273+
self.add_user_prompt(user_prompt)
273274

274275
def add_user_prompt(self, *prompts: str) -> None:
275276
"""
@@ -284,7 +285,7 @@ def add_user_prompt(self, *prompts: str) -> None:
284285
for prompt in prompts:
285286
if not prompt or prompt.isspace():
286287
raise ValueError("User prompt cannot be empty or whitespace-only")
287-
self._prompt_parts.append(prompt)
288+
self._prompt_parts.append(TextPart(prompt))
288289

289290
def handle(
290291
self,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from __future__ import annotations
2+
from typing import Dict, Any
3+
4+
5+
class AiMessagePromptFields:
6+
"""Constants for AI message prompt field names."""
7+
8+
TEXT = "text"
9+
TYPE = "type"
10+
11+
12+
class AiMessagePromptTypes:
13+
"""Constants for AI message prompt types."""
14+
15+
TEXT = "text"
16+
17+
18+
class ContentPart:
19+
"""
20+
Base class for content parts in AI prompts.
21+
Content parts allow structured prompt content with different types (text, etc.).
22+
"""
23+
24+
def __init__(self, content_type: str):
25+
self._type = content_type
26+
27+
@property
28+
def type(self) -> str:
29+
return self._type
30+
31+
def to_json(self) -> Dict[str, Any]:
32+
"""
33+
Converts the content part to a JSON-serializable dictionary.
34+
Subclasses should override this method to include their specific fields.
35+
"""
36+
return {AiMessagePromptFields.TYPE: self._type}
37+
38+
39+
class TextPart(ContentPart):
40+
"""
41+
Represents a text content part in AI prompts.
42+
"""
43+
44+
def __init__(self, text: str):
45+
super().__init__(AiMessagePromptTypes.TEXT)
46+
self._text = text
47+
48+
@property
49+
def text(self) -> str:
50+
return self._text
51+
52+
@text.setter
53+
def text(self, value: str):
54+
self._text = value
55+
56+
def to_json(self) -> Dict[str, Any]:
57+
return {
58+
AiMessagePromptFields.TYPE: self._type,
59+
AiMessagePromptFields.TEXT: self._text,
60+
}
61+

ravendb/documents/operations/ai/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .ai_agent_configuration import (
22
AiAgentConfiguration,
3+
AiAgentParameter,
34
AiAgentToolAction,
45
AiAgentToolQuery,
56
AiAgentPersistenceConfiguration,
@@ -33,6 +34,7 @@
3334
__all__ = [
3435
"AiAgentConfiguration",
3536
"AiAgentConfigurationResult",
37+
"AiAgentParameter",
3638
"AiAgentToolAction",
3739
"AiAgentToolQuery",
3840
"AiAgentPersistenceConfiguration",

ravendb/documents/operations/ai/agents/ai_agent_configuration.py

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,48 @@
11
from __future__ import annotations
2-
from typing import List, Set, Optional, Dict, Any
2+
from typing import List, Optional, Dict, Any, Union
3+
4+
5+
class AiAgentParameter:
6+
"""
7+
Represents a parameter for an AI agent configuration.
8+
Parameters can be used to pass values to the agent's system prompt.
9+
"""
10+
11+
def __init__(
12+
self,
13+
name: str = None,
14+
description: str = None,
15+
send_to_model: bool = None,
16+
):
17+
"""
18+
Initialize an agent parameter.
19+
20+
Args:
21+
name: The parameter name. Cannot be null or empty.
22+
description: A human-readable description. May be null or empty.
23+
send_to_model: When False, the parameter is hidden from the model
24+
(it will not be included in prompts/echo messages).
25+
When True, the parameter is exposed to the model.
26+
If None (default), treated as exposed.
27+
"""
28+
self.name = name
29+
self.description: Optional[str] = description
30+
self.send_to_model: Optional[bool] = send_to_model
31+
32+
def to_json(self) -> Dict[str, Any]:
33+
return {
34+
"Name": self.name,
35+
"Description": self.description,
36+
"SendToModel": self.send_to_model,
37+
}
38+
39+
@classmethod
40+
def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentParameter:
41+
return cls(
42+
name=json_dict.get("name") or json_dict.get("Name"),
43+
description=json_dict.get("description") or json_dict.get("Description"),
44+
send_to_model=json_dict.get("sendToModel") if "sendToModel" in json_dict else json_dict.get("SendToModel"),
45+
)
346

447

548
class AiAgentToolQuery:
@@ -260,7 +303,7 @@ def __init__(
260303
queries: List[AiAgentToolQuery] = None,
261304
actions: List[AiAgentToolAction] = None,
262305
persistence: AiAgentPersistenceConfiguration = None,
263-
parameters: Set[str] = None,
306+
parameters: List[Union[str, AiAgentParameter]] = None,
264307
chat_trimming: AiAgentChatTrimmingConfiguration = None,
265308
max_model_iterations_per_call: int = None,
266309
):
@@ -273,14 +316,24 @@ def __init__(
273316
self.queries: List[AiAgentToolQuery] = queries or []
274317
self.actions: List[AiAgentToolAction] = actions or []
275318
self.persistence: Optional[AiAgentPersistenceConfiguration] = persistence
276-
self.parameters: Set[str] = parameters or set()
319+
self.parameters: List[AiAgentParameter] = self._normalize_parameters(parameters)
277320
self.chat_trimming: Optional[AiAgentChatTrimmingConfiguration] = chat_trimming
278321
self.max_model_iterations_per_call: Optional[int] = max_model_iterations_per_call
279322

280-
def to_json(self) -> Dict[str, Any]:
281-
# Convert parameters set to list of parameter objects using list comprehension
282-
parameters_list = [{"Name": param_name, "Description": None} for param_name in self.parameters]
323+
@staticmethod
324+
def _normalize_parameters(parameters: List[Union[str, AiAgentParameter]]) -> List[AiAgentParameter]:
325+
"""Convert a list of strings or AiAgentParameter objects to a list of AiAgentParameter objects."""
326+
if not parameters:
327+
return []
328+
result = []
329+
for param in parameters:
330+
if isinstance(param, str):
331+
result.append(AiAgentParameter(name=param))
332+
else:
333+
result.append(param)
334+
return result
283335

336+
def to_json(self) -> Dict[str, Any]:
284337
return {
285338
"Identifier": self.identifier,
286339
"Name": self.name,
@@ -291,7 +344,7 @@ def to_json(self) -> Dict[str, Any]:
291344
"Queries": [q.to_json() for q in self.queries],
292345
"Actions": [a.to_json() for a in self.actions],
293346
"Persistence": self.persistence.to_json() if self.persistence else None,
294-
"Parameters": parameters_list,
347+
"Parameters": [p.to_json() for p in self.parameters],
295348
"ChatTrimming": self.chat_trimming.to_json() if self.chat_trimming else None,
296349
"MaxModelIterationsPerCall": self.max_model_iterations_per_call,
297350
}
@@ -321,13 +374,7 @@ def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentConfiguration:
321374

322375
params_data = json_dict.get("parameters") or json_dict.get("Parameters")
323376
if params_data:
324-
# Handle both string list and object list formats
325-
if params_data and isinstance(params_data[0], dict):
326-
# New format: list of objects with name property
327-
instance.parameters = set(param.get("name") or param.get("Name") for param in params_data)
328-
else:
329-
# Old format: list of strings
330-
instance.parameters = set(params_data)
377+
instance.parameters = [AiAgentParameter.from_json(param) for param in params_data]
331378

332379
trimming_data = json_dict.get("chatTrimming") or json_dict.get("ChatTrimming")
333380
if trimming_data:

ravendb/documents/operations/ai/agents/run_conversation_operation.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ravendb.http.server_node import ServerNode
1010
import requests
1111
from ravendb.http.misc import ResponseDisposeHandling
12+
from ravendb.documents.ai.content_part import ContentPart
1213

1314

1415
TSchema = TypeVar("TSchema")
@@ -64,6 +65,7 @@ class AiUsage:
6465
completion_tokens: int = 0
6566
total_tokens: int = 0
6667
cached_tokens: int = 0
68+
reasoning_tokens: int = 0
6769

6870
@classmethod
6971
def from_json(cls, json_dict: Dict[str, Any]) -> AiUsage:
@@ -72,6 +74,7 @@ def from_json(cls, json_dict: Dict[str, Any]) -> AiUsage:
7274
completion_tokens=json_dict.get("CompletionTokens", 0),
7375
total_tokens=json_dict.get("TotalTokens", 0),
7476
cached_tokens=json_dict.get("CachedTokens", 0),
77+
reasoning_tokens=json_dict.get("ReasoningTokens", 0),
7578
)
7679

7780
def to_json(self) -> Dict[str, Any]:
@@ -80,8 +83,36 @@ def to_json(self) -> Dict[str, Any]:
8083
"CompletionTokens": self.completion_tokens,
8184
"TotalTokens": self.total_tokens,
8285
"CachedTokens": self.cached_tokens,
86+
"ReasoningTokens": self.reasoning_tokens,
8387
}
8488

89+
@staticmethod
90+
def get_usage_difference(current: AiUsage, previous: AiUsage) -> AiUsage:
91+
"""
92+
Calculate the usage difference between current and previous usage.
93+
94+
Args:
95+
current: The current usage statistics
96+
previous: The previous usage statistics
97+
98+
Returns:
99+
An AiUsage object representing the difference
100+
"""
101+
previous_total_without_reasoning = (
102+
previous.completion_tokens - previous.reasoning_tokens + previous.prompt_tokens
103+
)
104+
return AiUsage(
105+
# in case the model gives us crappy results and current.prompt_tokens - previous_total_without_reasoning < 0
106+
prompt_tokens=max(current.prompt_tokens - previous_total_without_reasoning, 0),
107+
# in case the model gives us crappy results and current.total_tokens - previous_total_without_reasoning < 0
108+
total_tokens=max(current.total_tokens - previous_total_without_reasoning, 0),
109+
# we don't want to subtract cached tokens, as they are only for the last response
110+
cached_tokens=current.cached_tokens,
111+
# we don't want to subtract completion tokens, as they are only for the last response
112+
completion_tokens=current.completion_tokens,
113+
reasoning_tokens=current.reasoning_tokens,
114+
)
115+
85116

86117
class ConversationResult(Generic[TSchema]):
87118
def __init__(
@@ -161,11 +192,11 @@ class ConversationRequestBody:
161192
def __init__(
162193
self,
163194
action_responses: Optional[List[AiAgentActionResponse]] = None,
164-
user_prompt: Optional[List[str]] = None,
195+
user_prompt: Optional[List[ContentPart]] = None,
165196
creation_options: Optional[AiConversationCreationOptions] = None,
166197
):
167198
self.action_responses: Optional[List[AiAgentActionResponse]] = action_responses
168-
self.user_prompt: Optional[List[str]] = user_prompt # List of prompt parts
199+
self.user_prompt: Optional[List[ContentPart]] = user_prompt # List of ContentPart objects
169200
self.creation_options: Optional[AiConversationCreationOptions] = creation_options
170201

171202
def to_json(self) -> Dict[str, Any]:
@@ -182,8 +213,10 @@ def to_json(self) -> Dict[str, Any]:
182213
None if self.action_responses is None else [resp.to_json() for resp in self.action_responses]
183214
)
184215

185-
# UserPrompt: null if None, otherwise array (even if empty)
186-
result["UserPrompt"] = self.user_prompt
216+
# UserPrompt: null if None, otherwise array of ContentPart JSON objects
217+
result["UserPrompt"] = (
218+
None if self.user_prompt is None else [part.to_json() for part in self.user_prompt]
219+
)
187220

188221
# CreationOptions: always present (create empty if None, matching C# behavior)
189222
result["CreationOptions"] = (self.creation_options or AiConversationCreationOptions()).to_json()
@@ -203,7 +236,7 @@ def __init__(
203236
self,
204237
agent_id: str,
205238
conversation_id: str,
206-
prompt_parts: Optional[List[str]] = None,
239+
prompt_parts: Optional[List[ContentPart]] = None,
207240
action_responses: Optional[List[AiAgentActionResponse]] = None,
208241
options: Optional[AiConversationCreationOptions] = None,
209242
change_vector: Optional[str] = None,
@@ -216,7 +249,7 @@ def __init__(
216249
Args:
217250
agent_id: The ID of the AI agent (required)
218251
conversation_id: The ID of the conversation (required)
219-
prompt_parts: List of prompt strings to send to the agent
252+
prompt_parts: List of ContentPart objects to send to the agent
220253
action_responses: List of action responses from previous turn
221254
options: Creation options including parameters and expiration
222255
change_vector: Change vector for optimistic concurrency
@@ -258,7 +291,7 @@ def __init__(
258291
self,
259292
agent_id: str,
260293
conversation_id: str,
261-
prompt_parts: Optional[List[str]] = None,
294+
prompt_parts: Optional[List[ContentPart]] = None,
262295
action_responses: Optional[List[AiAgentActionResponse]] = None,
263296
options: Optional[AiConversationCreationOptions] = None,
264297
change_vector: Optional[str] = None,
@@ -309,7 +342,7 @@ def create_request(self, node: ServerNode) -> requests.Request:
309342
# Build request body with correct structure to match .NET client
310343
request_body = ConversationRequestBody(
311344
action_responses=self._action_responses,
312-
user_prompt="".join(self._prompt_parts),
345+
user_prompt=self._prompt_parts,
313346
creation_options=self._options,
314347
)
315348

ravendb/documents/operations/ai/azure_open_ai_settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ def __init__(
1414
temperature: float = None,
1515
):
1616
super().__init__(api_key, endpoint, model, dimensions, temperature)
17+
if deployment_name is None:
18+
raise ValueError("deployment_name cannot be None")
1719
self.deployment_name = deployment_name
1820

1921
@classmethod

0 commit comments

Comments
 (0)