Merge pull request #1924 from siiddhantt/feat/agent-schema-response

feat: add support for structured output and JSON schema validation
This commit is contained in:
Alex authored and GitHub committed 2025-08-13 17:33:29 +01:00
commit cd0fbf79a3
13 files changed
+652 -144

No files matched your search

+43 -4
View File
@@ -28,6 +28,7 @@ class BaseAgent(ABC):
chat_history: Optional[List[Dict]] = None, chat_history: Optional[List[Dict]] = None,
decoded_token: Optional[Dict] = None, decoded_token: Optional[Dict] = None,
attachments: Optional[List[Dict]] = None, attachments: Optional[List[Dict]] = None,
json_schema: Optional[Dict] = None,
): ):
self.endpoint = endpoint self.endpoint = endpoint
self.llm_name = llm_name self.llm_name = llm_name
@@ -51,6 +52,7 @@ class BaseAgent(ABC):
llm_name if llm_name else "default" llm_name if llm_name else "default"
) )
self.attachments = attachments or [] self.attachments = attachments or []
self.json_schema = json_schema
@log_activity() @log_activity()
def gen( def gen(
@@ -283,6 +285,21 @@ class BaseAgent(ABC):
and self.tools and self.tools
): ):
gen_kwargs["tools"] = self.tools gen_kwargs["tools"] = self.tools
if (
self.json_schema
and hasattr(self.llm, "_supports_structured_output")
and self.llm._supports_structured_output()
):
structured_format = self.llm.prepare_structured_output_format(
self.json_schema
)
if structured_format:
if self.llm_name == "openai":
gen_kwargs["response_format"] = structured_format
elif self.llm_name == "google":
gen_kwargs["response_schema"] = structured_format
resp = self.llm.gen_stream(**gen_kwargs) resp = self.llm.gen_stream(**gen_kwargs)
if log_context: if log_context:
@@ -307,11 +324,25 @@ class BaseAgent(ABC):
return resp return resp
def _handle_response(self, response, tools_dict, messages, log_context): def _handle_response(self, response, tools_dict, messages, log_context):
is_structured_output = (
self.json_schema is not None
and hasattr(self.llm, "_supports_structured_output")
and self.llm._supports_structured_output()
)
if isinstance(response, str): if isinstance(response, str):
yield {"answer": response} answer_data = {"answer": response}
if is_structured_output:
answer_data["structured"] = True
answer_data["schema"] = self.json_schema
yield answer_data
return return
if hasattr(response, "message") and getattr(response.message, "content", None): if hasattr(response, "message") and getattr(response.message, "content", None):
yield {"answer": response.message.content} answer_data = {"answer": response.message.content}
if is_structured_output:
answer_data["structured"] = True
answer_data["schema"] = self.json_schema
yield answer_data
return return
processed_response_gen = self._llm_handler( processed_response_gen = self._llm_handler(
response, tools_dict, messages, log_context, self.attachments response, tools_dict, messages, log_context, self.attachments
@@ -319,8 +350,16 @@ class BaseAgent(ABC):
for event in processed_response_gen: for event in processed_response_gen:
if isinstance(event, str): if isinstance(event, str):
yield {"answer": event} answer_data = {"answer": event}
if is_structured_output:
answer_data["structured"] = True
answer_data["schema"] = self.json_schema
yield answer_data
elif hasattr(event, "message") and getattr(event.message, "content", None): elif hasattr(event, "message") and getattr(event.message, "content", None):
yield {"answer": event.message.content} answer_data = {"answer": event.message.content}
if is_structured_output:
answer_data["structured"] = True
answer_data["schema"] = self.json_schema
yield answer_data
elif isinstance(event, dict) and "type" in event: elif isinstance(event, dict) and "type" in event:
yield event yield event
+21 -3
View File
@@ -83,9 +83,24 @@ class AnswerResource(Resource, BaseAnswerResource):
index=None, index=None,
should_save_conversation=data.get("save_conversation", True), should_save_conversation=data.get("save_conversation", True),
) )
conversation_id, response, sources, tool_calls, thought, error = ( stream_result = self.process_response_stream(stream)
self.process_response_stream(stream)
) if len(stream_result) == 7:
(
conversation_id,
response,
sources,
tool_calls,
thought,
error,
structured_info,
) = stream_result
else:
conversation_id, response, sources, tool_calls, thought, error = (
stream_result
)
structured_info = None
if error: if error:
return make_response({"error": error}, 400) return make_response({"error": error}, 400)
result = { result = {
@@ -95,6 +110,9 @@ class AnswerResource(Resource, BaseAnswerResource):
"tool_calls": tool_calls, "tool_calls": tool_calls,
"thought": thought, "thought": thought,
} }
if structured_info:
result.update(structured_info)
except Exception as e: except Exception as e:
logger.error( logger.error(
f"/api/answer - error: {str(e)} - traceback: {traceback.format_exc()}", f"/api/answer - error: {str(e)} - traceback: {traceback.format_exc()}",
+45 -13
View File
@@ -79,12 +79,20 @@ class BaseAnswerResource:
""" """
try: try:
response_full, thought, source_log_docs, tool_calls = "", "", [], [] response_full, thought, source_log_docs, tool_calls = "", "", [], []
is_structured = False
schema_info = None
structured_chunks = []
for line in agent.gen(query=question, retriever=retriever): for line in agent.gen(query=question, retriever=retriever):
if "answer" in line: if "answer" in line:
response_full += str(line["answer"]) response_full += str(line["answer"])
data = json.dumps({"type": "answer", "answer": line["answer"]}) if line.get("structured"):
yield f"data: {data}\n\n" is_structured = True
schema_info = line.get("schema")
structured_chunks.append(line["answer"])
else:
data = json.dumps({"type": "answer", "answer": line["answer"]})
yield f"data: {data}\n\n"
elif "sources" in line: elif "sources" in line:
truncated_sources = [] truncated_sources = []
source_log_docs = line["sources"] source_log_docs = line["sources"]
@@ -109,6 +117,17 @@ class BaseAnswerResource:
elif "type" in line: elif "type" in line:
data = json.dumps(line) data = json.dumps(line)
yield f"data: {data}\n\n" yield f"data: {data}\n\n"
if is_structured and structured_chunks:
structured_data = {
"type": "structured_answer",
"answer": response_full,
"structured": True,
"schema": schema_info,
}
data = json.dumps(structured_data)
yield f"data: {data}\n\n"
if isNoneDoc: if isNoneDoc:
for doc in source_log_docs: for doc in source_log_docs:
doc["source"] = "None" doc["source"] = "None"
@@ -139,15 +158,12 @@ class BaseAnswerResource:
) )
else: else:
conversation_id = None conversation_id = None
# Send conversation ID id_data = {"type": "id", "id": str(conversation_id)}
data = json.dumps(id_data)
data = json.dumps({"type": "id", "id": str(conversation_id)})
yield f"data: {data}\n\n" yield f"data: {data}\n\n"
# Log the interaction
retriever_params = retriever.get_params() retriever_params = retriever.get_params()
log_entry = { log_data = {
"action": "stream_answer", "action": "stream_answer",
"level": "info", "level": "info",
"user": decoded_token.get("sub"), "user": decoded_token.get("sub"),
@@ -159,13 +175,17 @@ class BaseAnswerResource:
"attachments": attachment_ids, "attachments": attachment_ids,
"timestamp": datetime.datetime.now(datetime.timezone.utc), "timestamp": datetime.datetime.now(datetime.timezone.utc),
} }
if is_structured:
log_data["structured_output"] = True
if schema_info:
log_data["schema"] = schema_info
# clean up text fields to be no longer than 10000 characters # clean up text fields to be no longer than 10000 characters
for key, value in log_entry.items(): for key, value in log_data.items():
if isinstance(value, str) and len(value) > 10000: if isinstance(value, str) and len(value) > 10000:
log_entry[key] = value[:10000] log_data[key] = value[:10000]
self.user_logs_collection.insert_one(log_entry) self.user_logs_collection.insert_one(log_data)
# End of stream # End of stream
@@ -190,6 +210,8 @@ class BaseAnswerResource:
tool_calls = [] tool_calls = []
thought = "" thought = ""
stream_ended = False stream_ended = False
is_structured = False
schema_info = None
for line in stream: for line in stream:
try: try:
@@ -200,6 +222,10 @@ class BaseAnswerResource:
conversation_id = event["id"] conversation_id = event["id"]
elif event["type"] == "answer": elif event["type"] == "answer":
response_full += event["answer"] response_full += event["answer"]
elif event["type"] == "structured_answer":
response_full = event["answer"]
is_structured = True
schema_info = event.get("schema")
elif event["type"] == "source": elif event["type"] == "source":
source_log_docs = event["source"] source_log_docs = event["source"]
elif event["type"] == "tool_calls": elif event["type"] == "tool_calls":
@@ -217,7 +243,8 @@ class BaseAnswerResource:
if not stream_ended: if not stream_ended:
logger.error("Stream ended unexpectedly without an 'end' event.") logger.error("Stream ended unexpectedly without an 'end' event.")
return None, None, None, None, "Stream ended unexpectedly" return None, None, None, None, "Stream ended unexpectedly"
return (
result = (
conversation_id, conversation_id,
response_full, response_full,
source_log_docs, source_log_docs,
@@ -226,6 +253,11 @@ class BaseAnswerResource:
None, None,
) )
if is_structured:
result = result + ({"structured": True, "schema": schema_info},)
return result
def error_stream_generate(self, err_response): def error_stream_generate(self, err_response):
data = json.dumps({"type": "error", "error": err_response}) data = json.dumps({"type": "error", "error": err_response})
yield f"data: {data}\n\n" yield f"data: {data}\n\n"
@@ -192,6 +192,7 @@ class StreamProcessor:
"prompt_id": data_key.get("prompt_id", "default"), "prompt_id": data_key.get("prompt_id", "default"),
"agent_type": data_key.get("agent_type", settings.AGENT_NAME), "agent_type": data_key.get("agent_type", settings.AGENT_NAME),
"user_api_key": api_key, "user_api_key": api_key,
"json_schema": data_key.get("json_schema"),
} }
) )
self.initial_user_id = data_key.get("user") self.initial_user_id = data_key.get("user")
@@ -203,6 +204,7 @@ class StreamProcessor:
"prompt_id": data_key.get("prompt_id", "default"), "prompt_id": data_key.get("prompt_id", "default"),
"agent_type": data_key.get("agent_type", settings.AGENT_NAME), "agent_type": data_key.get("agent_type", settings.AGENT_NAME),
"user_api_key": self.agent_key, "user_api_key": self.agent_key,
"json_schema": data_key.get("json_schema"),
} }
) )
self.decoded_token = ( self.decoded_token = (
@@ -216,6 +218,7 @@ class StreamProcessor:
"prompt_id": self.data.get("prompt_id", "default"), "prompt_id": self.data.get("prompt_id", "default"),
"agent_type": settings.AGENT_NAME, "agent_type": settings.AGENT_NAME,
"user_api_key": None, "user_api_key": None,
"json_schema": None,
} }
) )
@@ -243,6 +246,7 @@ class StreamProcessor:
chat_history=self.history, chat_history=self.history,
decoded_token=self.decoded_token, decoded_token=self.decoded_token,
attachments=self.attachments, attachments=self.attachments,
json_schema=self.agent_config.get("json_schema"),
) )
def create_retriever(self): def create_retriever(self):
+45
View File
@@ -1127,6 +1127,7 @@ class GetAgent(Resource):
"tool_details": resolve_tool_details(agent.get("tools", [])), "tool_details": resolve_tool_details(agent.get("tools", [])),
"agent_type": agent.get("agent_type", ""), "agent_type": agent.get("agent_type", ""),
"status": agent.get("status", ""), "status": agent.get("status", ""),
"json_schema": agent.get("json_schema"),
"created_at": agent.get("createdAt", ""), "created_at": agent.get("createdAt", ""),
"updated_at": agent.get("updatedAt", ""), "updated_at": agent.get("updatedAt", ""),
"last_used_at": agent.get("lastUsedAt", ""), "last_used_at": agent.get("lastUsedAt", ""),
@@ -1181,6 +1182,7 @@ class GetAgents(Resource):
"tool_details": resolve_tool_details(agent.get("tools", [])), "tool_details": resolve_tool_details(agent.get("tools", [])),
"agent_type": agent.get("agent_type", ""), "agent_type": agent.get("agent_type", ""),
"status": agent.get("status", ""), "status": agent.get("status", ""),
"json_schema": agent.get("json_schema"),
"created_at": agent.get("createdAt", ""), "created_at": agent.get("createdAt", ""),
"updated_at": agent.get("updatedAt", ""), "updated_at": agent.get("updatedAt", ""),
"last_used_at": agent.get("lastUsedAt", ""), "last_used_at": agent.get("lastUsedAt", ""),
@@ -1226,6 +1228,9 @@ class CreateAgent(Resource):
"status": fields.String( "status": fields.String(
required=True, description="Status of the agent (draft or published)" required=True, description="Status of the agent (draft or published)"
), ),
"json_schema": fields.Raw(
required=False, description="JSON schema for enforcing structured output format"
),
}, },
) )
@@ -1244,7 +1249,35 @@ class CreateAgent(Resource):
data["tools"] = json.loads(data["tools"]) data["tools"] = json.loads(data["tools"])
except json.JSONDecodeError: except json.JSONDecodeError:
data["tools"] = [] data["tools"] = []
if "json_schema" in data:
try:
data["json_schema"] = json.loads(data["json_schema"])
except json.JSONDecodeError:
data["json_schema"] = None
print(f"Received data: {data}") print(f"Received data: {data}")
# Validate JSON schema if provided
if data.get("json_schema"):
try:
# Basic validation - ensure it's a valid JSON structure
json_schema = data.get("json_schema")
if not isinstance(json_schema, dict):
return make_response(
jsonify({"success": False, "message": "JSON schema must be a valid JSON object"}),
400
)
# Validate that it has either a 'schema' property or is itself a schema
if "schema" not in json_schema and "type" not in json_schema:
return make_response(
jsonify({"success": False, "message": "JSON schema must contain either a 'schema' property or be a valid JSON schema with 'type' property"}),
400
)
except Exception as e:
return make_response(
jsonify({"success": False, "message": f"Invalid JSON schema: {str(e)}"}),
400
)
if data.get("status") not in ["draft", "published"]: if data.get("status") not in ["draft", "published"]:
return make_response( return make_response(
@@ -1302,6 +1335,7 @@ class CreateAgent(Resource):
"tools": data.get("tools", []), "tools": data.get("tools", []),
"agent_type": data.get("agent_type", ""), "agent_type": data.get("agent_type", ""),
"status": data.get("status"), "status": data.get("status"),
"json_schema": data.get("json_schema"),
"createdAt": datetime.datetime.now(datetime.timezone.utc), "createdAt": datetime.datetime.now(datetime.timezone.utc),
"updatedAt": datetime.datetime.now(datetime.timezone.utc), "updatedAt": datetime.datetime.now(datetime.timezone.utc),
"lastUsedAt": None, "lastUsedAt": None,
@@ -1342,6 +1376,9 @@ class UpdateAgent(Resource):
"status": fields.String( "status": fields.String(
required=True, description="Status of the agent (draft or published)" required=True, description="Status of the agent (draft or published)"
), ),
"json_schema": fields.Raw(
required=False, description="JSON schema for enforcing structured output format"
),
}, },
) )
@@ -1360,6 +1397,11 @@ class UpdateAgent(Resource):
data["tools"] = json.loads(data["tools"]) data["tools"] = json.loads(data["tools"])
except json.JSONDecodeError: except json.JSONDecodeError:
data["tools"] = [] data["tools"] = []
if "json_schema" in data:
try:
data["json_schema"] = json.loads(data["json_schema"])
except json.JSONDecodeError:
data["json_schema"] = None
if not ObjectId.is_valid(agent_id): if not ObjectId.is_valid(agent_id):
return make_response( return make_response(
@@ -1405,6 +1447,7 @@ class UpdateAgent(Resource):
"tools", "tools",
"agent_type", "agent_type",
"status", "status",
"json_schema",
] ]
for field in allowed_fields: for field in allowed_fields:
@@ -1797,6 +1840,7 @@ class SharedAgent(Resource):
"tool_details": resolve_tool_details(shared_agent.get("tools", [])), "tool_details": resolve_tool_details(shared_agent.get("tools", [])),
"agent_type": shared_agent.get("agent_type", ""), "agent_type": shared_agent.get("agent_type", ""),
"status": shared_agent.get("status", ""), "status": shared_agent.get("status", ""),
"json_schema": shared_agent.get("json_schema"),
"created_at": shared_agent.get("createdAt", ""), "created_at": shared_agent.get("createdAt", ""),
"updated_at": shared_agent.get("updatedAt", ""), "updated_at": shared_agent.get("updatedAt", ""),
"shared": shared_agent.get("shared_publicly", False), "shared": shared_agent.get("shared_publicly", False),
@@ -1874,6 +1918,7 @@ class SharedAgents(Resource):
"tool_details": resolve_tool_details(agent.get("tools", [])), "tool_details": resolve_tool_details(agent.get("tools", [])),
"agent_type": agent.get("agent_type", ""), "agent_type": agent.get("agent_type", ""),
"status": agent.get("status", ""), "status": agent.get("status", ""),
"json_schema": agent.get("json_schema"),
"created_at": agent.get("createdAt", ""), "created_at": agent.get("createdAt", ""),
"updated_at": agent.get("updatedAt", ""), "updated_at": agent.get("updatedAt", ""),
"pinned": str(agent["_id"]) in pinned_ids, "pinned": str(agent["_id"]) in pinned_ids,
+15 -1
View File
@@ -120,6 +120,20 @@ class BaseLLM(ABC):
def _supports_tools(self): def _supports_tools(self):
raise NotImplementedError("Subclass must implement _supports_tools method") raise NotImplementedError("Subclass must implement _supports_tools method")
def supports_structured_output(self):
"""Check if the LLM supports structured output/JSON schema enforcement"""
return hasattr(self, "_supports_structured_output") and callable(
getattr(self, "_supports_structured_output")
)
def _supports_structured_output(self):
return False
def prepare_structured_output_format(self, json_schema):
"""Prepare structured output format specific to the LLM provider"""
_ = json_schema
return None
def get_supported_attachment_types(self): def get_supported_attachment_types(self):
""" """
Return a list of MIME types supported by this LLM for file uploads. Return a list of MIME types supported by this LLM for file uploads.
@@ -127,4 +141,4 @@ class BaseLLM(ABC):
Returns: Returns:
list: List of supported MIME types list: List of supported MIME types
""" """
return [] # Default: no attachments supported return []
+137 -44
View File
@@ -1,11 +1,13 @@
import json
import logging
from google import genai from google import genai
from google.genai import types from google.genai import types
import logging
import json from application.core.settings import settings
from application.llm.base import BaseLLM from application.llm.base import BaseLLM
from application.storage.storage_creator import StorageCreator from application.storage.storage_creator import StorageCreator
from application.core.settings import settings
class GoogleLLM(BaseLLM): class GoogleLLM(BaseLLM):
@@ -24,12 +26,12 @@ class GoogleLLM(BaseLLM):
list: List of supported MIME types list: List of supported MIME types
""" """
return [ return [
'application/pdf', "application/pdf",
'image/png', "image/png",
'image/jpeg', "image/jpeg",
'image/jpg', "image/jpg",
'image/webp', "image/webp",
'image/gif' "image/gif",
] ]
def prepare_messages_with_attachments(self, messages, attachments=None): def prepare_messages_with_attachments(self, messages, attachments=None):
@@ -70,26 +72,30 @@ class GoogleLLM(BaseLLM):
files = [] files = []
for attachment in attachments: for attachment in attachments:
mime_type = attachment.get('mime_type') mime_type = attachment.get("mime_type")
if mime_type in self.get_supported_attachment_types(): if mime_type in self.get_supported_attachment_types():
try: try:
file_uri = self._upload_file_to_google(attachment) file_uri = self._upload_file_to_google(attachment)
logging.info(f"GoogleLLM: Successfully uploaded file, got URI: {file_uri}") logging.info(
f"GoogleLLM: Successfully uploaded file, got URI: {file_uri}"
)
files.append({"file_uri": file_uri, "mime_type": mime_type}) files.append({"file_uri": file_uri, "mime_type": mime_type})
except Exception as e: except Exception as e:
logging.error(f"GoogleLLM: Error uploading file: {e}", exc_info=True) logging.error(
if 'content' in attachment: f"GoogleLLM: Error uploading file: {e}", exc_info=True
prepared_messages[user_message_index]["content"].append({ )
"type": "text", if "content" in attachment:
"text": f"[File could not be processed: {attachment.get('path', 'unknown')}]" prepared_messages[user_message_index]["content"].append(
}) {
"type": "text",
"text": f"[File could not be processed: {attachment.get('path', 'unknown')}]",
}
)
if files: if files:
logging.info(f"GoogleLLM: Adding {len(files)} files to message") logging.info(f"GoogleLLM: Adding {len(files)} files to message")
prepared_messages[user_message_index]["content"].append({ prepared_messages[user_message_index]["content"].append({"files": files})
"files": files
})
return prepared_messages return prepared_messages
@@ -103,10 +109,10 @@ class GoogleLLM(BaseLLM):
Returns: Returns:
str: Google AI file URI for the uploaded file. str: Google AI file URI for the uploaded file.
""" """
if 'google_file_uri' in attachment: if "google_file_uri" in attachment:
return attachment['google_file_uri'] return attachment["google_file_uri"]
file_path = attachment.get('path') file_path = attachment.get("path")
if not file_path: if not file_path:
raise ValueError("No file path provided in attachment") raise ValueError("No file path provided in attachment")
@@ -116,17 +122,19 @@ class GoogleLLM(BaseLLM):
try: try:
file_uri = self.storage.process_file( file_uri = self.storage.process_file(
file_path, file_path,
lambda local_path, **kwargs: self.client.files.upload(file=local_path).uri lambda local_path, **kwargs: self.client.files.upload(
file=local_path
).uri,
) )
from application.core.mongo_db import MongoDB from application.core.mongo_db import MongoDB
mongo = MongoDB.get_client() mongo = MongoDB.get_client()
db = mongo[settings.MONGO_DB_NAME] db = mongo[settings.MONGO_DB_NAME]
attachments_collection = db["attachments"] attachments_collection = db["attachments"]
if '_id' in attachment: if "_id" in attachment:
attachments_collection.update_one( attachments_collection.update_one(
{"_id": attachment['_id']}, {"_id": attachment["_id"]}, {"$set": {"google_file_uri": file_uri}}
{"$set": {"google_file_uri": file_uri}}
) )
return file_uri return file_uri
@@ -166,13 +174,13 @@ class GoogleLLM(BaseLLM):
) )
) )
elif "files" in item: elif "files" in item:
for file_data in item["files"]: for file_data in item["files"]:
parts.append( parts.append(
types.Part.from_uri( types.Part.from_uri(
file_uri=file_data["file_uri"], file_uri=file_data["file_uri"],
mime_type=file_data["mime_type"] mime_type=file_data["mime_type"],
)
) )
)
else: else:
raise ValueError( raise ValueError(
f"Unexpected content dictionary format:{item}" f"Unexpected content dictionary format:{item}"
@@ -231,6 +239,7 @@ class GoogleLLM(BaseLLM):
stream=False, stream=False,
tools=None, tools=None,
formatting="openai", formatting="openai",
response_schema=None,
**kwargs, **kwargs,
): ):
client = genai.Client(api_key=self.api_key) client = genai.Client(api_key=self.api_key)
@@ -244,16 +253,21 @@ class GoogleLLM(BaseLLM):
if tools: if tools:
cleaned_tools = self._clean_tools_format(tools) cleaned_tools = self._clean_tools_format(tools)
config.tools = cleaned_tools config.tools = cleaned_tools
response = client.models.generate_content(
model=model, # Add response schema for structured output if provided
contents=messages, if response_schema:
config=config, config.response_schema = response_schema
) config.response_mime_type = "application/json"
response = client.models.generate_content(
model=model,
contents=messages,
config=config,
)
if tools:
return response return response
else: else:
response = client.models.generate_content(
model=model, contents=messages, config=config
)
return response.text return response.text
def _raw_gen_stream( def _raw_gen_stream(
@@ -264,6 +278,7 @@ class GoogleLLM(BaseLLM):
stream=True, stream=True,
tools=None, tools=None,
formatting="openai", formatting="openai",
response_schema=None,
**kwargs, **kwargs,
): ):
client = genai.Client(api_key=self.api_key) client = genai.Client(api_key=self.api_key)
@@ -278,17 +293,24 @@ class GoogleLLM(BaseLLM):
cleaned_tools = self._clean_tools_format(tools) cleaned_tools = self._clean_tools_format(tools)
config.tools = cleaned_tools config.tools = cleaned_tools
# Add response schema for structured output if provided
if response_schema:
config.response_schema = response_schema
config.response_mime_type = "application/json"
# Check if we have both tools and file attachments # Check if we have both tools and file attachments
has_attachments = False has_attachments = False
for message in messages: for message in messages:
for part in message.parts: for part in message.parts:
if hasattr(part, 'file_data') and part.file_data is not None: if hasattr(part, "file_data") and part.file_data is not None:
has_attachments = True has_attachments = True
break break
if has_attachments: if has_attachments:
break break
logging.info(f"GoogleLLM: Starting stream generation. Model: {model}, Messages: {json.dumps(messages, default=str)}, Has attachments: {has_attachments}") logging.info(
f"GoogleLLM: Starting stream generation. Model: {model}, Messages: {json.dumps(messages, default=str)}, Has attachments: {has_attachments}"
)
response = client.models.generate_content_stream( response = client.models.generate_content_stream(
model=model, model=model,
@@ -296,7 +318,6 @@ class GoogleLLM(BaseLLM):
config=config, config=config,
) )
for chunk in response: for chunk in response:
if hasattr(chunk, "candidates") and chunk.candidates: if hasattr(chunk, "candidates") and chunk.candidates:
for candidate in chunk.candidates: for candidate in chunk.candidates:
@@ -311,3 +332,75 @@ class GoogleLLM(BaseLLM):
def _supports_tools(self): def _supports_tools(self):
return True return True
def _supports_structured_output(self):
return True
def prepare_structured_output_format(self, json_schema):
if not json_schema:
return None
type_map = {
"object": "OBJECT",
"array": "ARRAY",
"string": "STRING",
"integer": "INTEGER",
"number": "NUMBER",
"boolean": "BOOLEAN",
}
def convert(schema):
if not isinstance(schema, dict):
return schema
result = {}
schema_type = schema.get("type")
if schema_type:
result["type"] = type_map.get(schema_type.lower(), schema_type.upper())
for key in [
"description",
"nullable",
"enum",
"minItems",
"maxItems",
"required",
"propertyOrdering",
]:
if key in schema:
result[key] = schema[key]
if "format" in schema:
format_value = schema["format"]
if schema_type == "string":
if format_value == "date":
result["format"] = "date-time"
elif format_value in ["enum", "date-time"]:
result["format"] = format_value
else:
result["format"] = format_value
if "properties" in schema:
result["properties"] = {
k: convert(v) for k, v in schema["properties"].items()
}
if "propertyOrdering" not in result and result.get("type") == "OBJECT":
result["propertyOrdering"] = list(result["properties"].keys())
if "items" in schema:
result["items"] = convert(schema["items"])
for field in ["anyOf", "oneOf", "allOf"]:
if field in schema:
result[field] = [convert(s) for s in schema[field]]
return result
try:
return convert(json_schema)
except Exception as e:
logging.error(
f"Error preparing structured output format for Google: {e}",
exc_info=True,
)
return None
+171 -73
View File
@@ -1,5 +1,5 @@
import json
import base64 import base64
import json
import logging import logging
from application.core.settings import settings from application.core.settings import settings
@@ -13,7 +13,10 @@ class OpenAILLM(BaseLLM):
from openai import OpenAI from openai import OpenAI
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if isinstance(settings.OPENAI_BASE_URL, str) and settings.OPENAI_BASE_URL.strip(): if (
isinstance(settings.OPENAI_BASE_URL, str)
and settings.OPENAI_BASE_URL.strip()
):
self.client = OpenAI(api_key=api_key, base_url=settings.OPENAI_BASE_URL) self.client = OpenAI(api_key=api_key, base_url=settings.OPENAI_BASE_URL)
else: else:
DEFAULT_OPENAI_API_BASE = "https://api.openai.com/v1" DEFAULT_OPENAI_API_BASE = "https://api.openai.com/v1"
@@ -73,14 +76,30 @@ class OpenAILLM(BaseLLM):
elif isinstance(item, dict): elif isinstance(item, dict):
content_parts = [] content_parts = []
if "text" in item: if "text" in item:
content_parts.append({"type": "text", "text": item["text"]}) content_parts.append(
elif "type" in item and item["type"] == "text" and "text" in item: {"type": "text", "text": item["text"]}
)
elif (
"type" in item
and item["type"] == "text"
and "text" in item
):
content_parts.append(item) content_parts.append(item)
elif "type" in item and item["type"] == "file" and "file" in item: elif (
"type" in item
and item["type"] == "file"
and "file" in item
):
content_parts.append(item) content_parts.append(item)
elif "type" in item and item["type"] == "image_url" and "image_url" in item: elif (
"type" in item
and item["type"] == "image_url"
and "image_url" in item
):
content_parts.append(item) content_parts.append(item)
cleaned_messages.append({"role": role, "content": content_parts}) cleaned_messages.append(
{"role": role, "content": content_parts}
)
else: else:
raise ValueError( raise ValueError(
f"Unexpected content dictionary format: {item}" f"Unexpected content dictionary format: {item}"
@@ -98,22 +117,29 @@ class OpenAILLM(BaseLLM):
stream=False, stream=False,
tools=None, tools=None,
engine=settings.AZURE_DEPLOYMENT_NAME, engine=settings.AZURE_DEPLOYMENT_NAME,
response_format=None,
**kwargs, **kwargs,
): ):
messages = self._clean_messages_openai(messages) messages = self._clean_messages_openai(messages)
request_params = {
"model": model,
"messages": messages,
"stream": stream,
**kwargs,
}
if tools:
request_params["tools"] = tools
if response_format:
request_params["response_format"] = response_format
response = self.client.chat.completions.create(**request_params)
if tools: if tools:
response = self.client.chat.completions.create(
model=model,
messages=messages,
stream=stream,
tools=tools,
**kwargs,
)
return response.choices[0] return response.choices[0]
else: else:
response = self.client.chat.completions.create(
model=model, messages=messages, stream=stream, **kwargs
)
return response.choices[0].message.content return response.choices[0].message.content
def _raw_gen_stream( def _raw_gen_stream(
@@ -124,24 +150,32 @@ class OpenAILLM(BaseLLM):
stream=True, stream=True,
tools=None, tools=None,
engine=settings.AZURE_DEPLOYMENT_NAME, engine=settings.AZURE_DEPLOYMENT_NAME,
response_format=None,
**kwargs, **kwargs,
): ):
messages = self._clean_messages_openai(messages) messages = self._clean_messages_openai(messages)
request_params = {
"model": model,
"messages": messages,
"stream": stream,
**kwargs,
}
if tools: if tools:
response = self.client.chat.completions.create( request_params["tools"] = tools
model=model,
messages=messages, if response_format:
stream=stream, request_params["response_format"] = response_format
tools=tools,
**kwargs, response = self.client.chat.completions.create(**request_params)
)
else:
response = self.client.chat.completions.create(
model=model, messages=messages, stream=stream, **kwargs
)
for line in response: for line in response:
if len(line.choices) > 0 and line.choices[0].delta.content is not None and len(line.choices[0].delta.content) > 0: if (
len(line.choices) > 0
and line.choices[0].delta.content is not None
and len(line.choices[0].delta.content) > 0
):
yield line.choices[0].delta.content yield line.choices[0].delta.content
elif len(line.choices) > 0: elif len(line.choices) > 0:
yield line.choices[0] yield line.choices[0]
@@ -149,6 +183,66 @@ class OpenAILLM(BaseLLM):
def _supports_tools(self): def _supports_tools(self):
return True return True
def _supports_structured_output(self):
return True
def prepare_structured_output_format(self, json_schema):
if not json_schema:
return None
try:
def add_additional_properties_false(schema_obj):
if isinstance(schema_obj, dict):
schema_copy = schema_obj.copy()
if schema_copy.get("type") == "object":
schema_copy["additionalProperties"] = False
# Ensure 'required' includes all properties for OpenAI strict mode
if "properties" in schema_copy:
schema_copy["required"] = list(
schema_copy["properties"].keys()
)
for key, value in schema_copy.items():
if key == "properties" and isinstance(value, dict):
schema_copy[key] = {
prop_name: add_additional_properties_false(prop_schema)
for prop_name, prop_schema in value.items()
}
elif key == "items" and isinstance(value, dict):
schema_copy[key] = add_additional_properties_false(value)
elif key in ["anyOf", "oneOf", "allOf"] and isinstance(
value, list
):
schema_copy[key] = [
add_additional_properties_false(sub_schema)
for sub_schema in value
]
return schema_copy
return schema_obj
processed_schema = add_additional_properties_false(json_schema)
result = {
"type": "json_schema",
"json_schema": {
"name": processed_schema.get("name", "response"),
"description": processed_schema.get(
"description", "Structured response"
),
"schema": processed_schema,
"strict": True,
},
}
return result
except Exception as e:
logging.error(f"Error preparing structured output format: {e}")
return None
def get_supported_attachment_types(self): def get_supported_attachment_types(self):
""" """
Return a list of MIME types supported by OpenAI for file uploads. Return a list of MIME types supported by OpenAI for file uploads.
@@ -157,12 +251,12 @@ class OpenAILLM(BaseLLM):
list: List of supported MIME types list: List of supported MIME types
""" """
return [ return [
'application/pdf', "application/pdf",
'image/png', "image/png",
'image/jpeg', "image/jpeg",
'image/jpg', "image/jpg",
'image/webp', "image/webp",
'image/gif' "image/gif",
] ]
def prepare_messages_with_attachments(self, messages, attachments=None): def prepare_messages_with_attachments(self, messages, attachments=None):
@@ -202,39 +296,46 @@ class OpenAILLM(BaseLLM):
prepared_messages[user_message_index]["content"] = [] prepared_messages[user_message_index]["content"] = []
for attachment in attachments: for attachment in attachments:
mime_type = attachment.get('mime_type') mime_type = attachment.get("mime_type")
if mime_type and mime_type.startswith('image/'): if mime_type and mime_type.startswith("image/"):
try: try:
base64_image = self._get_base64_image(attachment) base64_image = self._get_base64_image(attachment)
prepared_messages[user_message_index]["content"].append({ prepared_messages[user_message_index]["content"].append(
"type": "image_url", {
"image_url": { "type": "image_url",
"url": f"data:{mime_type};base64,{base64_image}" "image_url": {
"url": f"data:{mime_type};base64,{base64_image}"
},
} }
}) )
except Exception as e: except Exception as e:
logging.error(f"Error processing image attachment: {e}", exc_info=True) logging.error(
if 'content' in attachment: f"Error processing image attachment: {e}", exc_info=True
prepared_messages[user_message_index]["content"].append({ )
"type": "text", if "content" in attachment:
"text": f"[Image could not be processed: {attachment.get('path', 'unknown')}]" prepared_messages[user_message_index]["content"].append(
}) {
"type": "text",
"text": f"[Image could not be processed: {attachment.get('path', 'unknown')}]",
}
)
# Handle PDFs using the file API # Handle PDFs using the file API
elif mime_type == 'application/pdf': elif mime_type == "application/pdf":
try: try:
file_id = self._upload_file_to_openai(attachment) file_id = self._upload_file_to_openai(attachment)
prepared_messages[user_message_index]["content"].append({ prepared_messages[user_message_index]["content"].append(
"type": "file", {"type": "file", "file": {"file_id": file_id}}
"file": {"file_id": file_id} )
})
except Exception as e: except Exception as e:
logging.error(f"Error uploading PDF to OpenAI: {e}", exc_info=True) logging.error(f"Error uploading PDF to OpenAI: {e}", exc_info=True)
if 'content' in attachment: if "content" in attachment:
prepared_messages[user_message_index]["content"].append({ prepared_messages[user_message_index]["content"].append(
"type": "text", {
"text": f"File content:\n\n{attachment['content']}" "type": "text",
}) "text": f"File content:\n\n{attachment['content']}",
}
)
return prepared_messages return prepared_messages
@@ -248,13 +349,13 @@ class OpenAILLM(BaseLLM):
Returns: Returns:
str: Base64-encoded image data. str: Base64-encoded image data.
""" """
file_path = attachment.get('path') file_path = attachment.get("path")
if not file_path: if not file_path:
raise ValueError("No file path provided in attachment") raise ValueError("No file path provided in attachment")
try: try:
with self.storage.get_file(file_path) as image_file: with self.storage.get_file(file_path) as image_file:
return base64.b64encode(image_file.read()).decode('utf-8') return base64.b64encode(image_file.read()).decode("utf-8")
except FileNotFoundError: except FileNotFoundError:
raise FileNotFoundError(f"File not found: {file_path}") raise FileNotFoundError(f"File not found: {file_path}")
@@ -273,10 +374,10 @@ class OpenAILLM(BaseLLM):
""" """
import logging import logging
if 'openai_file_id' in attachment: if "openai_file_id" in attachment:
return attachment['openai_file_id'] return attachment["openai_file_id"]
file_path = attachment.get('path') file_path = attachment.get("path")
if not self.storage.file_exists(file_path): if not self.storage.file_exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}") raise FileNotFoundError(f"File not found: {file_path}")
@@ -285,19 +386,18 @@ class OpenAILLM(BaseLLM):
file_id = self.storage.process_file( file_id = self.storage.process_file(
file_path, file_path,
lambda local_path, **kwargs: self.client.files.create( lambda local_path, **kwargs: self.client.files.create(
file=open(local_path, 'rb'), file=open(local_path, "rb"), purpose="assistants"
purpose="assistants" ).id,
).id
) )
from application.core.mongo_db import MongoDB from application.core.mongo_db import MongoDB
mongo = MongoDB.get_client() mongo = MongoDB.get_client()
db = mongo[settings.MONGO_DB_NAME] db = mongo[settings.MONGO_DB_NAME]
attachments_collection = db["attachments"] attachments_collection = db["attachments"]
if '_id' in attachment: if "_id" in attachment:
attachments_collection.update_one( attachments_collection.update_one(
{"_id": attachment['_id']}, {"_id": attachment["_id"]}, {"$set": {"openai_file_id": file_id}}
{"$set": {"openai_file_id": file_id}}
) )
return file_id return file_id
@@ -308,9 +408,7 @@ class OpenAILLM(BaseLLM):
class AzureOpenAILLM(OpenAILLM): class AzureOpenAILLM(OpenAILLM):
def __init__( def __init__(self, api_key, user_api_key, *args, **kwargs):
self, api_key, user_api_key, *args, **kwargs
):
super().__init__(api_key) super().__init__(api_key)
self.api_base = (settings.OPENAI_API_BASE,) self.api_base = (settings.OPENAI_API_BASE,)
@@ -321,5 +419,5 @@ class AzureOpenAILLM(OpenAILLM):
self.client = AzureOpenAI( self.client = AzureOpenAI(
api_key=api_key, api_key=api_key,
api_version=settings.OPENAI_API_VERSION, api_version=settings.OPENAI_API_VERSION,
azure_endpoint=settings.OPENAI_API_BASE azure_endpoint=settings.OPENAI_API_BASE,
) )
+127 -6
View File
@@ -51,6 +51,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
tools: [], tools: [],
agent_type: '', agent_type: '',
status: '', status: '',
json_schema: undefined,
}); });
const [imageFile, setImageFile] = useState<File | null>(null); const [imageFile, setImageFile] = useState<File | null>(null);
const [prompts, setPrompts] = useState< const [prompts, setPrompts] = useState<
@@ -72,6 +73,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const [draftLoading, setDraftLoading] = useState(false); const [draftLoading, setDraftLoading] = useState(false);
const [publishLoading, setPublishLoading] = useState(false); const [publishLoading, setPublishLoading] = useState(false);
const [jsonSchemaText, setJsonSchemaText] = useState('');
const [jsonSchemaValid, setJsonSchemaValid] = useState(true);
const [isJsonSchemaExpanded, setIsJsonSchemaExpanded] = useState(false);
const initialAgentRef = useRef<Agent | null>(null); const initialAgentRef = useRef<Agent | null>(null);
const sourceAnchorButtonRef = useRef<HTMLButtonElement>(null); const sourceAnchorButtonRef = useRef<HTMLButtonElement>(null);
@@ -113,9 +117,15 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
]; ];
const isPublishable = () => { const isPublishable = () => {
return ( const hasRequiredFields =
agent.name && agent.description && agent.prompt_id && agent.agent_type agent.name && agent.description && agent.prompt_id && agent.agent_type;
); const isJsonSchemaValidOrEmpty =
jsonSchemaText.trim() === '' || jsonSchemaValid;
return hasRequiredFields && isJsonSchemaValidOrEmpty;
};
const isJsonSchemaInvalid = () => {
return jsonSchemaText.trim() !== '' && !jsonSchemaValid;
}; };
const handleUpload = useCallback((files: File[]) => { const handleUpload = useCallback((files: File[]) => {
@@ -153,6 +163,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
formData.append('tools', JSON.stringify(agent.tools)); formData.append('tools', JSON.stringify(agent.tools));
else formData.append('tools', '[]'); else formData.append('tools', '[]');
if (agent.json_schema) {
formData.append('json_schema', JSON.stringify(agent.json_schema));
}
try { try {
setDraftLoading(true); setDraftLoading(true);
const response = const response =
@@ -194,6 +208,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
formData.append('tools', JSON.stringify(agent.tools)); formData.append('tools', JSON.stringify(agent.tools));
else formData.append('tools', '[]'); else formData.append('tools', '[]');
if (agent.json_schema) {
formData.append('json_schema', JSON.stringify(agent.json_schema));
}
try { try {
setPublishLoading(true); setPublishLoading(true);
const response = const response =
@@ -226,6 +244,22 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
} }
}; };
const validateAndSetJsonSchema = (text: string) => {
setJsonSchemaText(text);
if (text.trim() === '') {
setAgent({ ...agent, json_schema: undefined });
setJsonSchemaValid(true);
return;
}
try {
const parsed = JSON.parse(text);
setAgent({ ...agent, json_schema: parsed });
setJsonSchemaValid(true);
} catch (error) {
setJsonSchemaValid(false);
}
};
useEffect(() => { useEffect(() => {
const getTools = async () => { const getTools = async () => {
const response = await userService.getUserTools(token); const response = await userService.getUserTools(token);
@@ -264,6 +298,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setSelectedSourceIds(new Set([data.retriever])); setSelectedSourceIds(new Set([data.retriever]));
if (data.tools) setSelectedToolIds(new Set(data.tools)); if (data.tools) setSelectedToolIds(new Set(data.tools));
if (data.status === 'draft') setEffectiveMode('draft'); if (data.status === 'draft') setEffectiveMode('draft');
if (data.json_schema) {
const jsonText = JSON.stringify(data.json_schema, null, 2);
setJsonSchemaText(jsonText);
setJsonSchemaValid(true);
}
setAgent(data); setAgent(data);
initialAgentRef.current = data; initialAgentRef.current = data;
}; };
@@ -317,10 +356,17 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setHasChanges(false); setHasChanges(false);
return; return;
} }
const initialJsonSchemaText = initialAgentRef.current.json_schema
? JSON.stringify(initialAgentRef.current.json_schema, null, 2)
: '';
const isChanged = const isChanged =
!isEqual(agent, initialAgentRef.current) || imageFile !== null; !isEqual(agent, initialAgentRef.current) ||
imageFile !== null ||
jsonSchemaText !== initialJsonSchemaText;
setHasChanges(isChanged); setHasChanges(isChanged);
}, [agent, dispatch, effectiveMode, imageFile]); }, [agent, dispatch, effectiveMode, imageFile, jsonSchemaText]);
return ( return (
<div className="p-4 md:p-12"> <div className="p-4 md:p-12">
<div className="flex items-center gap-3 px-4"> <div className="flex items-center gap-3 px-4">
@@ -356,7 +402,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
)} )}
{modeConfig[effectiveMode].showSaveDraft && ( {modeConfig[effectiveMode].showSaveDraft && (
<button <button
className="hover:bg-vi</button>olets-are-blue border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue w-28 rounded-3xl border border-solid py-2 text-sm font-medium transition-colors hover:text-white" disabled={isJsonSchemaInvalid()}
className={`border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue w-28 rounded-3xl border border-solid py-2 text-sm font-medium transition-colors hover:text-white ${
isJsonSchemaInvalid() ? 'cursor-not-allowed opacity-30' : ''
}`}
onClick={handleSaveDraft} onClick={handleSaveDraft}
> >
<span className="flex items-center justify-center transition-all duration-200"> <span className="flex items-center justify-center transition-all duration-200">
@@ -602,6 +651,78 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
/> />
</div> </div>
</div> </div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<button
onClick={() => setIsJsonSchemaExpanded(!isJsonSchemaExpanded)}
className="flex w-full items-center justify-between text-left focus:outline-none"
>
<div>
<h2 className="text-lg font-semibold">Advanced</h2>
</div>
<div className="ml-4 flex items-center">
<svg
className={`h-5 w-5 transform transition-transform duration-200 ${
isJsonSchemaExpanded ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</button>
{isJsonSchemaExpanded && (
<div className="mt-3">
<div>
<h2 className="text-sm font-medium">JSON response schema</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
Define a JSON schema to enforce structured output format
</p>
</div>
<textarea
value={jsonSchemaText}
onChange={(e) => validateAndSetJsonSchema(e.target.value)}
placeholder={`{
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"}
},
"required": ["name", "email"],
"additionalProperties": false
}`}
rows={9}
className={`border-silver text-jet dark:bg-raisin-black dark:text-bright-gray mt-2 w-full rounded-2xl border bg-white px-4 py-3 font-mono text-sm outline-hidden dark:border-[#7E7E7E]`}
/>
{jsonSchemaText.trim() !== '' && (
<div
className={`mt-2 flex items-center gap-2 text-sm ${
jsonSchemaValid
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
<span
className={`h-4 w-4 bg-contain bg-center bg-no-repeat ${
jsonSchemaValid
? "bg-[url('/src/assets/circle-check.svg')]"
: "bg-[url('/src/assets/circle-x.svg')]"
}`}
/>
{jsonSchemaValid
? 'Valid JSON'
: 'Invalid JSON - fix to enable saving'}
</div>
)}
</div>
)}
</div>
</div> </div>
<div className="col-span-3 flex flex-col gap-3 rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]"> <div className="col-span-3 flex flex-col gap-3 rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Preview</h2> <h2 className="text-lg font-semibold">Preview</h2>
+19
View File
@@ -96,6 +96,17 @@ export const fetchPreviewAnswer = createAsyncThunk<
message: data.error, message: data.error,
}), }),
); );
} else if (data.type === 'structured_answer') {
dispatch(
updateStreamingQuery({
index: targetIndex,
query: {
response: data.answer,
structured: data.structured,
schema: data.schema,
},
}),
);
} else { } else {
dispatch( dispatch(
updateStreamingQuery({ updateStreamingQuery({
@@ -201,6 +212,14 @@ export const agentPreviewSlice = createSlice({
state.queries[index].response = state.queries[index].response =
(state.queries[index].response || '') + query.response; (state.queries[index].response || '') + query.response;
} }
if (query.structured !== undefined) {
state.queries[index].structured = query.structured;
}
if (query.schema !== undefined) {
state.queries[index].schema = query.schema;
}
}, },
updateThought( updateThought(
state, state,
+1
View File
@@ -26,4 +26,5 @@ export type Agent = {
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
last_used_at?: string; last_used_at?: string;
json_schema?: object;
}; };
@@ -33,6 +33,8 @@ export interface Answer {
thought: string; thought: string;
sources: { title: string; text: string; source: string }[]; sources: { title: string; text: string; source: string }[];
tool_calls: ToolCallsType[]; tool_calls: ToolCallsType[];
structured?: boolean;
schema?: object;
} }
export interface Query { export interface Query {
@@ -46,6 +48,8 @@ export interface Query {
tool_calls?: ToolCallsType[]; tool_calls?: ToolCallsType[];
error?: string; error?: string;
attachments?: { id: string; fileName: string }[]; attachments?: { id: string; fileName: string }[];
structured?: boolean;
schema?: object;
} }
export interface RetrievalPayload { export interface RetrievalPayload {
@@ -130,6 +130,18 @@ export const fetchAnswer = createAsyncThunk<
message: data.error, message: data.error,
}), }),
); );
} else if (data.type === 'structured_answer') {
dispatch(
updateStreamingQuery({
conversationId: currentConversationId,
index: targetIndex,
query: {
response: data.answer,
structured: data.structured,
schema: data.schema,
},
}),
);
} else { } else {
dispatch( dispatch(
updateStreamingQuery({ updateStreamingQuery({
@@ -250,6 +262,14 @@ export const conversationSlice = createSlice({
state.queries[index].response = state.queries[index].response =
(state.queries[index].response || '') + query.response; (state.queries[index].response || '') + query.response;
} }
if (query.structured !== undefined) {
state.queries[index].structured = query.structured;
}
if (query.schema !== undefined) {
state.queries[index].schema = query.schema;
}
}, },
updateConversationId( updateConversationId(
state, state,