Skip to content

Commit 19fedd9

Browse files
committed
RDBC-967 GenAI Tasks Operations
1 parent 0c506a4 commit 19fedd9

21 files changed

+2301
-10
lines changed

ravendb/documents/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from ravendb.documents.starting_point_change_vector import StartingPointChangeVector
2+
3+
__all__ = [
4+
"StartingPointChangeVector",
5+
]

ravendb/documents/ai/content_part.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,3 @@ def to_json(self) -> Dict[str, Any]:
5858
AiMessagePromptFields.TYPE: self._type,
5959
AiMessagePromptFields.TEXT: self._text,
6060
}
61-
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from ravendb.documents.operations.ai.ai_connection_string import (
2+
AiConnectionString,
3+
AiModelType,
4+
AiConnectorType,
5+
)
6+
from ravendb.documents.operations.ai.ai_task_identifier_helper import AiTaskIdentifierHelper
7+
from ravendb.documents.operations.ai.gen_ai_transformation import GenAiTransformation
8+
from ravendb.documents.operations.ai.gen_ai_configuration import GenAiConfiguration
9+
from ravendb.documents.operations.ai.abstract_ai_integration_configuration import AbstractAiIntegrationConfiguration
10+
from ravendb.documents.operations.ai.ai_task_operation_results import (
11+
AddAiTaskOperationResult,
12+
AddGenAiOperationResult,
13+
AddEmbeddingsGenerationOperationResult,
14+
)
15+
from ravendb.documents.operations.ai.add_gen_ai_operation import AddGenAiOperation
16+
from ravendb.documents.operations.ai.update_gen_ai_operation import UpdateGenAiOperation
17+
18+
__all__ = [
19+
"AiConnectionString",
20+
"AiModelType",
21+
"AiConnectorType",
22+
"AiTaskIdentifierHelper",
23+
"GenAiTransformation",
24+
"GenAiConfiguration",
25+
"AbstractAiIntegrationConfiguration",
26+
"AddAiTaskOperationResult",
27+
"AddGenAiOperationResult",
28+
"AddEmbeddingsGenerationOperationResult",
29+
"AddGenAiOperation",
30+
"UpdateGenAiOperation",
31+
]
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from __future__ import annotations
2+
from abc import ABC
3+
from typing import TYPE_CHECKING, Optional, List
4+
5+
from ravendb.documents.operations.etl.configuration import EtlConfiguration
6+
from ravendb.documents.operations.ai.ai_connection_string import AiConnectionString, AiConnectorType
7+
from ravendb.documents.operations.etl.transformation import Transformation
8+
9+
if TYPE_CHECKING:
10+
pass
11+
12+
13+
class AbstractAiIntegrationConfiguration(EtlConfiguration[AiConnectionString], ABC):
14+
"""
15+
Base class for AI integration configurations.
16+
Extends EtlConfiguration with AiConnectionString as the connection type.
17+
"""
18+
19+
def __init__(
20+
self,
21+
name: Optional[str] = None,
22+
task_id: int = 0,
23+
connection_string_name: Optional[str] = None,
24+
mentor_node: Optional[str] = None,
25+
pin_to_mentor_node: bool = False,
26+
transforms: Optional[List[Transformation]] = None,
27+
disabled: bool = False,
28+
allow_etl_on_non_encrypted_channel: bool = False,
29+
):
30+
super().__init__(
31+
name=name,
32+
task_id=task_id,
33+
connection_string_name=connection_string_name,
34+
mentor_node=mentor_node,
35+
pin_to_mentor_node=pin_to_mentor_node,
36+
transforms=transforms,
37+
disabled=disabled,
38+
allow_etl_on_non_encrypted_channel=allow_etl_on_non_encrypted_channel,
39+
)
40+
41+
@property
42+
def ai_connector_type(self) -> AiConnectorType:
43+
"""Returns the AI connector type based on the active provider in the connection."""
44+
if self.connection:
45+
return self.connection.get_active_provider()
46+
return AiConnectorType.NONE
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from __future__ import annotations
2+
import json
3+
from typing import Optional, TYPE_CHECKING
4+
from urllib.parse import quote
5+
6+
from ravendb.documents.operations.definitions import MaintenanceOperation
7+
from ravendb.documents.conventions import DocumentConventions
8+
from ravendb.http.raven_command import RavenCommand
9+
from ravendb.http.server_node import ServerNode
10+
from ravendb.documents.starting_point_change_vector import StartingPointChangeVector
11+
from ravendb.documents.operations.ai.ai_task_operation_results import AddGenAiOperationResult
12+
import requests
13+
14+
from ravendb.util.util import RaftIdGenerator
15+
16+
if TYPE_CHECKING:
17+
from ravendb.documents.operations.ai.gen_ai_configuration import GenAiConfiguration
18+
19+
20+
class AddGenAiOperation(MaintenanceOperation[AddGenAiOperationResult]):
21+
"""
22+
Operation to add a new GenAI task to the database.
23+
"""
24+
25+
def __init__(
26+
self,
27+
configuration: GenAiConfiguration,
28+
starting_point: Optional[StartingPointChangeVector] = StartingPointChangeVector.LAST_DOCUMENT,
29+
):
30+
if configuration is None:
31+
raise ValueError("configuration cannot be None")
32+
33+
self._configuration = configuration
34+
self._starting_point = starting_point
35+
36+
def get_command(self, conventions: DocumentConventions) -> RavenCommand[AddGenAiOperationResult]:
37+
return AddGenAiCommand(self._configuration, self._starting_point, conventions)
38+
39+
40+
class AddGenAiCommand(RavenCommand[AddGenAiOperationResult]):
41+
def __init__(
42+
self,
43+
configuration: GenAiConfiguration,
44+
starting_point: StartingPointChangeVector,
45+
conventions: DocumentConventions,
46+
):
47+
super().__init__(AddGenAiOperationResult)
48+
self._configuration = configuration
49+
self._starting_point = starting_point
50+
self._conventions = conventions
51+
52+
def is_read_request(self) -> bool:
53+
return False
54+
55+
def create_request(self, node: ServerNode) -> requests.Request:
56+
url = f"{node.url}/databases/{node.database}/admin/etl?changeVector={quote(self._starting_point.value)}"
57+
58+
body_json = self._configuration.to_json()
59+
body = json.dumps(body_json)
60+
61+
request = requests.Request("PUT", url)
62+
request.headers = {"Content-Type": "application/json"}
63+
request.data = body
64+
return request
65+
66+
def set_response(self, response: str, from_cache: bool) -> None:
67+
if response is None:
68+
self.result = AddGenAiOperationResult()
69+
return
70+
71+
response_json = json.loads(response)
72+
self.result = AddGenAiOperationResult.from_json(response_json)
73+
74+
def get_raft_unique_request_id(self) -> str:
75+
return RaftIdGenerator.new_id()

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,7 @@ def to_json(self) -> Dict[str, Any]:
214214
)
215215

216216
# 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-
)
217+
result["UserPrompt"] = None if self.user_prompt is None else [part.to_json() for part in self.user_prompt]
220218

221219
# CreationOptions: always present (create empty if None, matching C# behavior)
222220
result["CreationOptions"] = (self.creation_options or AiConversationCreationOptions()).to_json()

ravendb/documents/operations/ai/ai_connection_string.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ class AiModelType(enum.Enum):
1919
CHAT = "Chat"
2020

2121

22+
class AiConnectorType(enum.Enum):
23+
NONE = "None"
24+
OPEN_AI = "OpenAi"
25+
AZURE_OPEN_AI = "AzureOpenAi"
26+
OLLAMA = "Ollama"
27+
EMBEDDED = "Embedded"
28+
GOOGLE = "Google"
29+
HUGGING_FACE = "HuggingFace"
30+
MISTRAL_AI = "MistralAi"
31+
VERTEX = "Vertex"
32+
33+
2234
class AiConnectionString(ConnectionString):
2335
def __init__(
2436
self,
@@ -87,6 +99,50 @@ def __init__(
8799
def get_type(self):
88100
return ConnectionStringType.AI.value
89101

102+
def get_active_provider(self) -> AiConnectorType:
103+
"""Returns the active AI connector type based on which settings are configured."""
104+
if self.openai_settings:
105+
return AiConnectorType.OPEN_AI
106+
if self.azure_openai_settings:
107+
return AiConnectorType.AZURE_OPEN_AI
108+
if self.ollama_settings:
109+
return AiConnectorType.OLLAMA
110+
if self.embedded_settings:
111+
return AiConnectorType.EMBEDDED
112+
if self.google_settings:
113+
return AiConnectorType.GOOGLE
114+
if self.huggingface_settings:
115+
return AiConnectorType.HUGGING_FACE
116+
if self.mistral_ai_settings:
117+
return AiConnectorType.MISTRAL_AI
118+
if self.vertex_settings:
119+
return AiConnectorType.VERTEX
120+
return AiConnectorType.NONE
121+
122+
def using_encrypted_communication_channel(self) -> bool:
123+
"""Returns True if the connection uses HTTPS (encrypted communication)."""
124+
active_settings = None
125+
if self.openai_settings:
126+
active_settings = self.openai_settings
127+
elif self.azure_openai_settings:
128+
active_settings = self.azure_openai_settings
129+
elif self.ollama_settings:
130+
active_settings = self.ollama_settings
131+
elif self.google_settings:
132+
active_settings = self.google_settings
133+
elif self.huggingface_settings:
134+
active_settings = self.huggingface_settings
135+
elif self.mistral_ai_settings:
136+
active_settings = self.mistral_ai_settings
137+
elif self.vertex_settings:
138+
active_settings = self.vertex_settings
139+
140+
if active_settings and hasattr(active_settings, "endpoint") and active_settings.endpoint:
141+
return active_settings.endpoint.lower().startswith("https://")
142+
143+
# Embedded settings don't have an endpoint
144+
return False
145+
90146
def to_json(self) -> Dict[str, Any]:
91147
return {
92148
"Name": self.name,
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import unicodedata
2+
from typing import List, Tuple
3+
4+
5+
class AiTaskIdentifierHelper:
6+
"""Helper class for validating and generating AI task identifiers."""
7+
8+
@staticmethod
9+
def validate_identifier(identifier: str) -> Tuple[bool, List[str]]:
10+
"""
11+
Validates an AI task identifier.
12+
13+
Args:
14+
identifier: The identifier to validate.
15+
16+
Returns:
17+
A tuple of (is_valid, errors). If valid, errors is an empty list.
18+
"""
19+
errors: List[str] = []
20+
21+
if not identifier or identifier.isspace():
22+
errors.append("Identifier cannot be empty or contain only whitespace;")
23+
return False, errors
24+
25+
# Check that the string is already normalized (contains only a-z, 0-9 and hyphens)
26+
normalized = unicodedata.normalize("NFD", identifier)
27+
if identifier != normalized:
28+
errors.append("Identifier contains diacritical marks or non-ASCII characters;")
29+
30+
# Check that there are no uppercase letters
31+
if any(c.isupper() for c in identifier):
32+
errors.append("Identifier contains uppercase letters;")
33+
34+
# Check for invalid characters and collect them
35+
invalid_chars = set()
36+
for c in identifier:
37+
if not (("a" <= c <= "z") or ("0" <= c <= "9") or c == "-"):
38+
invalid_chars.add(c)
39+
40+
if invalid_chars:
41+
chars_str = ", ".join(f"'{c}'" for c in sorted(invalid_chars))
42+
errors.append(
43+
f"Identifier contains invalid characters: {chars_str}. "
44+
f"Only lowercase letters (a-z), numbers (0-9) and hyphens (-) are allowed."
45+
)
46+
47+
# Check that there are no consecutive hyphens
48+
if "--" in identifier:
49+
errors.append("Identifier contains consecutive hyphens;")
50+
51+
# Check that the string does not end with a hyphen
52+
if identifier.endswith("-"):
53+
errors.append("Identifier ends with a hyphen;")
54+
55+
return len(errors) == 0, errors
56+
57+
@staticmethod
58+
def generate_identifier(input_str: str) -> str:
59+
"""
60+
Generates a valid identifier from an input string.
61+
62+
Args:
63+
input_str: The input string to convert to an identifier.
64+
65+
Returns:
66+
A valid identifier string, or a default identifier if input is empty.
67+
"""
68+
if not input_str or input_str.isspace():
69+
return None
70+
71+
result = []
72+
last_was_hyphen = False
73+
74+
# First normalize to FormD to separate letters from their diacritics
75+
normalized = unicodedata.normalize("NFD", input_str)
76+
77+
for c in normalized:
78+
# Check if this is a letter that needs to be preserved
79+
if "a" <= c <= "z" or "0" <= c <= "9":
80+
result.append(c)
81+
last_was_hyphen = False
82+
elif "A" <= c <= "Z":
83+
result.append(c.lower())
84+
last_was_hyphen = False
85+
elif not last_was_hyphen and len(result) > 0:
86+
# Add hyphen for any other character
87+
result.append("-")
88+
last_was_hyphen = True
89+
90+
# Trim any trailing hyphens
91+
final_result = "".join(result).rstrip("-")
92+
93+
# Ensure we have at least one character
94+
return final_result if final_result else "AiConnectionStringIdentifier"
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from __future__ import annotations
2+
from typing import Dict, Any, Optional
3+
4+
from ravendb.documents.operations.etl.etl_operation_results import AddEtlOperationResult
5+
6+
7+
class AddAiTaskOperationResult(AddEtlOperationResult):
8+
"""Base result class for AI task add operations."""
9+
10+
def __init__(
11+
self,
12+
raft_command_index: int = 0,
13+
task_id: int = 0,
14+
identifier: Optional[str] = None,
15+
):
16+
super().__init__(raft_command_index=raft_command_index, task_id=task_id)
17+
self.identifier = identifier
18+
19+
@classmethod
20+
def from_json(cls, json_dict: Dict[str, Any]) -> "AddAiTaskOperationResult":
21+
return cls(
22+
raft_command_index=json_dict.get("RaftCommandIndex", 0),
23+
task_id=json_dict.get("TaskId", 0),
24+
identifier=json_dict.get("Identifier"),
25+
)
26+
27+
28+
class AddGenAiOperationResult(AddAiTaskOperationResult):
29+
"""Result of adding a GenAI task."""
30+
31+
@classmethod
32+
def from_json(cls, json_dict: Dict[str, Any]) -> "AddGenAiOperationResult":
33+
return cls(
34+
raft_command_index=json_dict.get("RaftCommandIndex", 0),
35+
task_id=json_dict.get("TaskId", 0),
36+
identifier=json_dict.get("Identifier"),
37+
)
38+
39+
40+
class AddEmbeddingsGenerationOperationResult(AddAiTaskOperationResult):
41+
"""Result of adding an Embeddings Generation task."""
42+
43+
@classmethod
44+
def from_json(cls, json_dict: Dict[str, Any]) -> "AddEmbeddingsGenerationOperationResult":
45+
return cls(
46+
raft_command_index=json_dict.get("RaftCommandIndex", 0),
47+
task_id=json_dict.get("TaskId", 0),
48+
identifier=json_dict.get("Identifier"),
49+
)

0 commit comments

Comments
 (0)