Feat: add structured output schema to ollama (#10271)

* feat: add output schema for ChatOllama component

feat: nested json input for output format, and added data and dataframe outputs for chatollama

add unit and integration tests

chore: update component index

update component index

patch parse logic

ruff style checks

update component index

draft modfication to s.o.c; fallback to langchain directly if trustcall fails

feat: add input table for output format in ChatOllama component

chore: update component index

[autofix.ci] apply automated fixes

[autofix.ci] apply automated fixes (attempt 2/3)

[autofix.ci] apply automated fixes (attempt 3/3)

chore: update component index

* chore: update component index

* ruff (ollama.py imports)

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes (attempt 3/3)

---------

Co-authored-by: Hamza Rashid <hzarashid@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Eric Hare <ericrhare@gmail.com>
This commit is contained in:
Hamza Rashid
2025-10-30 16:36:22 -04:00
committed by GitHub
parent e24c3878ae
commit d8bd70dd2d
5 changed files with 639 additions and 4 deletions

View File

@ -0,0 +1,167 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from lfx.components.ollama.ollama import ChatOllamaComponent
from lfx.schema.data import Data
from lfx.schema.dataframe import DataFrame
from lfx.schema.message import Message
@pytest.mark.integration
class TestChatOllamaIntegration:
"""Integration tests for ChatOllama structured output flow."""
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllama")
async def test_end_to_end_structured_output_to_data(self, mock_chat_ollama):
"""Test complete flow from model response to Data output with JSON schema."""
# Mock the model and its response
mock_model = MagicMock()
mock_chat_ollama.return_value = mock_model
# Define a JSON schema for structured output
json_schema = {
"type": "object",
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}, "email": {"type": "string"}},
"required": ["name", "email"],
}
# Create component with schema format
component = ChatOllamaComponent(
base_url="http://localhost:11434", model_name="llama3.1", format=json_schema, temperature=0.1
)
# Set up input message
component.input_value = "Tell me about John"
# Build model with schema
model = component.build_model()
assert model is not None
# Mock the text_response to return a Message with JSON content
json_response = '{"name": "John Doe", "age": 30, "email": "john@example.com"}'
mock_message = Message(text=json_response)
# Patch text_response as an async method
with patch.object(component, "text_response", new_callable=AsyncMock, return_value=mock_message):
# Get Data output
data_output = await component.build_data_output()
# Verify Data output structure
assert isinstance(data_output, Data)
assert data_output.data["name"] == "John Doe"
assert data_output.data["age"] == 30
assert data_output.data["email"] == "john@example.com"
# Verify ChatOllama was called with the schema
call_args = mock_chat_ollama.call_args[1]
assert call_args["format"] == json_schema
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllama")
async def test_end_to_end_structured_output_to_dataframe(self, mock_chat_ollama):
"""Test complete flow from model response to DataFrame output with list of dicts."""
# Mock the model
mock_model = MagicMock()
mock_chat_ollama.return_value = mock_model
# Create component with JSON format
component = ChatOllamaComponent(
base_url="http://localhost:11434", model_name="llama3.1", format="json", temperature=0.1
)
# Set up input message
component.input_value = "List some people"
# Mock the text_response with list of structured data
json_response = """[
{"name": "Alice", "age": 28, "city": "NYC"},
{"name": "Bob", "age": 35, "city": "LA"},
{"name": "Charlie", "age": 42, "city": "Chicago"}
]"""
mock_message = Message(text=json_response)
with patch.object(component, "text_response", new_callable=AsyncMock, return_value=mock_message):
# Get DataFrame output
df_output = await component.build_dataframe_output()
# Verify DataFrame structure
assert isinstance(df_output, DataFrame)
assert len(df_output) == 3
assert list(df_output.columns) == ["name", "age", "city"]
assert df_output.iloc[0]["name"] == "Alice"
assert df_output.iloc[1]["name"] == "Bob"
assert df_output.iloc[2]["name"] == "Charlie"
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllama")
async def test_end_to_end_with_pydantic_schema(self, mock_chat_ollama):
"""Test end-to-end flow using Pydantic model schema (addresses issue #7122)."""
from pydantic import BaseModel, Field
# Mock the model
mock_model = MagicMock()
mock_chat_ollama.return_value = mock_model
# Create Pydantic model as users would
class PersonInfo(BaseModel):
"""Information about a person."""
name: str = Field(description="The person's full name")
age: int = Field(ge=0, le=150, description="The person's age")
email: str = Field(description="Email address")
city: str = Field(description="City of residence")
# Generate schema from Pydantic model
pydantic_schema = PersonInfo.model_json_schema()
# Create component with Pydantic schema
component = ChatOllamaComponent(
base_url="http://localhost:11434", model_name="llama3.1", format=pydantic_schema, temperature=0.1
)
component.input_value = "Extract person info"
# Verify model builds without error (was the bug in #7122)
model = component.build_model()
assert model is not None
# Mock the text_response
json_response = '{"name": "Jane Smith", "age": 25, "email": "jane@test.com", "city": "Boston"}'
mock_message = Message(text=json_response)
with patch.object(component, "text_response", new_callable=AsyncMock, return_value=mock_message):
# Verify Data output works
data_output = await component.build_data_output()
assert isinstance(data_output, Data)
assert data_output.data["name"] == "Jane Smith"
assert data_output.data["city"] == "Boston"
# Verify schema was passed correctly
call_args = mock_chat_ollama.call_args[1]
assert call_args["format"] == pydantic_schema
assert call_args["format"]["type"] == "object"
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllama")
async def test_json_parsing_error_handling(self, mock_chat_ollama):
"""Test that invalid JSON responses are handled gracefully."""
# Mock the model
mock_model = MagicMock()
mock_chat_ollama.return_value = mock_model
component = ChatOllamaComponent(
base_url="http://localhost:11434", model_name="llama3.1", format="json", temperature=0.1
)
component.input_value = "Generate some data"
# Mock text_response with invalid JSON
invalid_response = "This is not valid JSON at all!"
mock_message = Message(text=invalid_response)
with (
patch.object(component, "text_response", new_callable=AsyncMock, return_value=mock_message),
pytest.raises(ValueError, match="Invalid JSON response"),
):
await component.build_data_output()

View File

@ -5,6 +5,7 @@ import pytest
from langchain_ollama import ChatOllama
from lfx.base.models.ollama_constants import DEFAULT_OLLAMA_API_URL
from lfx.components.ollama.ollama import ChatOllamaComponent
from lfx.schema import Data, DataFrame
from tests.base import ComponentTestBaseWithoutClient
@ -233,6 +234,7 @@ class TestChatOllamaComponent(ComponentTestBaseWithoutClient):
assert updated_config["keep_alive"]["value"] == "0"
assert updated_config["keep_alive"]["advanced"] is True
@pytest.mark.integration
@patch(
"langchain_ollama.ChatOllama",
return_value=ChatOllama(base_url="http://localhost:11434", model="llama3.1"),
@ -505,6 +507,302 @@ class TestChatOllamaComponent(ComponentTestBaseWithoutClient):
updated_config = await component.update_build_config(build_config, field_value, field_name)
assert updated_config["model_name"]["options"] == []
@patch("lfx.components.ollama.ollama.ChatOllama")
def test_build_model_with_json_format_string(self, mock_chat_ollama, component_class, default_kwargs):
"""Test that the format field works with 'json' string value (backward compatibility)."""
mock_instance = MagicMock()
mock_chat_ollama.return_value = mock_instance
# Use default_kwargs which has format="json"
component = component_class(**default_kwargs)
model = component.build_model()
# Verify ChatOllama was called with format="json"
call_args = mock_chat_ollama.call_args[1]
assert call_args["format"] == "json"
assert model == mock_instance
@patch("lfx.components.ollama.ollama.ChatOllama")
def test_build_model_with_json_schema_dict(self, mock_chat_ollama, component_class, default_kwargs):
"""Test that the format field works with a JSON schema dictionary."""
mock_instance = MagicMock()
mock_chat_ollama.return_value = mock_instance
# Define a simple JSON schema
json_schema = {
"type": "object",
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
"required": ["name"],
}
# Override format with the JSON schema dict
kwargs = default_kwargs.copy()
kwargs["format"] = json_schema
component = component_class(**kwargs)
model = component.build_model()
# Verify ChatOllama was called with the JSON schema dict
call_args = mock_chat_ollama.call_args[1]
assert call_args["format"] == json_schema
assert call_args["format"]["type"] == "object"
assert "name" in call_args["format"]["properties"]
assert "age" in call_args["format"]["properties"]
assert call_args["format"]["required"] == ["name"]
assert model == mock_instance
@patch("lfx.components.ollama.ollama.ChatOllama")
def test_build_model_with_complex_json_schema(self, mock_chat_ollama, component_class, default_kwargs):
"""Test that the format field works with a complex/realistic JSON schema (e.g., from Pydantic)."""
mock_instance = MagicMock()
mock_chat_ollama.return_value = mock_instance
# Simulate a more complex schema like one generated by Pydantic's model_json_schema()
complex_schema = {
"type": "object",
"title": "Person",
"properties": {
"name": {"type": "string", "description": "The person's full name"},
"age": {"type": "integer", "minimum": 0, "maximum": 150},
"email": {"type": "string", "format": "email"},
"address": {
"type": "object",
"properties": {
"street": {"type": "string"},
"city": {"type": "string"},
"zipcode": {"type": "string", "pattern": "^[0-9]{5}$"},
},
"required": ["city"],
},
"tags": {"type": "array", "items": {"type": "string"}},
},
"required": ["name", "email"],
"additionalProperties": False,
}
# Override format with the complex JSON schema
kwargs = default_kwargs.copy()
kwargs["format"] = complex_schema
component = component_class(**kwargs)
model = component.build_model()
# Verify ChatOllama was called with the complex schema
call_args = mock_chat_ollama.call_args[1]
assert call_args["format"] == complex_schema
assert call_args["format"]["title"] == "Person"
assert call_args["format"]["properties"]["address"]["type"] == "object"
assert call_args["format"]["required"] == ["name", "email"]
assert call_args["format"]["additionalProperties"] is False
assert model == mock_instance
@patch("lfx.components.ollama.ollama.ChatOllama")
def test_build_model_with_pydantic_model_json_schema(self, mock_chat_ollama, component_class, default_kwargs):
"""Test that the format field works with a schema generated from Pydantic's model_json_schema() method.
This test reproduces the exact use case described in issue #7122:
https://github.com/langflow-ai/langflow/issues/7122
"""
from pydantic import BaseModel, Field
mock_instance = MagicMock()
mock_chat_ollama.return_value = mock_instance
# Create a Pydantic model exactly as a user would
class PersonInfo(BaseModel):
"""Information about a person."""
name: str = Field(description="The person's full name")
age: int = Field(ge=0, le=150, description="The person's age")
email: str = Field(description="Email address")
city: str = Field(description="City of residence")
# Generate the schema using Pydantic's model_json_schema() as mentioned in the issue
pydantic_schema = PersonInfo.model_json_schema()
# Override format with the Pydantic-generated schema
kwargs = default_kwargs.copy()
kwargs["format"] = pydantic_schema
component = component_class(**kwargs)
# This should NOT raise an exception (was the bug in issue #7122)
model = component.build_model()
# Verify ChatOllama was called with the Pydantic-generated schema
call_args = mock_chat_ollama.call_args[1]
assert call_args["format"] == pydantic_schema
assert call_args["format"]["type"] == "object"
assert "name" in call_args["format"]["properties"]
assert "age" in call_args["format"]["properties"]
assert "email" in call_args["format"]["properties"]
assert "city" in call_args["format"]["properties"]
assert call_args["format"]["properties"]["name"]["description"] == "The person's full name"
assert model == mock_instance
@pytest.mark.asyncio
async def test_parse_json_response_valid_dict(self, component_class, default_kwargs):
"""Test _parse_json_response with valid JSON dict response."""
mock_message = MagicMock()
mock_message.text = '{"name": "John", "age": 30}'
component = component_class(**default_kwargs)
component.text_response = AsyncMock(return_value=mock_message)
result = await component._parse_json_response()
assert result == {"name": "John", "age": 30}
assert isinstance(result, dict)
@pytest.mark.asyncio
async def test_parse_json_response_valid_list(self, component_class, default_kwargs):
"""Test _parse_json_response with valid JSON list response."""
mock_message = MagicMock()
mock_message.text = '[{"id": 1}, {"id": 2}]'
component = component_class(**default_kwargs)
component.text_response = AsyncMock(return_value=mock_message)
result = await component._parse_json_response()
assert result == [{"id": 1}, {"id": 2}]
assert isinstance(result, list)
@pytest.mark.asyncio
async def test_parse_json_response_invalid_json(self, component_class, default_kwargs):
"""Test _parse_json_response with invalid JSON raises ValueError."""
mock_message = MagicMock()
mock_message.text = "This is not JSON"
component = component_class(**default_kwargs)
component.text_response = AsyncMock(return_value=mock_message)
with pytest.raises(ValueError, match="Invalid JSON response"):
await component._parse_json_response()
@pytest.mark.asyncio
async def test_parse_json_response_empty_response(self, component_class, default_kwargs):
"""Test _parse_json_response with empty response raises ValueError."""
mock_message = MagicMock()
mock_message.text = ""
component = component_class(**default_kwargs)
component.text_response = AsyncMock(return_value=mock_message)
with pytest.raises(ValueError, match="No response from model"):
await component._parse_json_response()
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllamaComponent._parse_json_response")
async def test_build_data_output_with_dict(self, mock_parse_json, component_class, default_kwargs):
"""Test build_data_output with dict response."""
mock_parse_json.return_value = {"name": "Alice", "city": "NYC"}
component = component_class(**default_kwargs)
result = await component.build_data_output()
assert isinstance(result, Data)
assert result.data == {"name": "Alice", "city": "NYC"}
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllamaComponent._parse_json_response")
async def test_build_data_output_with_list_single_item(self, mock_parse_json, component_class, default_kwargs):
"""Test build_data_output with single-item list response."""
mock_parse_json.return_value = [{"id": 1, "value": "test"}]
component = component_class(**default_kwargs)
result = await component.build_data_output()
assert isinstance(result, Data)
assert result.data == {"id": 1, "value": "test"}
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllamaComponent._parse_json_response")
async def test_build_data_output_with_list_multiple_items(self, mock_parse_json, component_class, default_kwargs):
"""Test build_data_output with multiple-item list response."""
mock_parse_json.return_value = [{"id": 1}, {"id": 2}, {"id": 3}]
component = component_class(**default_kwargs)
result = await component.build_data_output()
assert isinstance(result, Data)
assert result.data == {"results": [{"id": 1}, {"id": 2}, {"id": 3}]}
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllamaComponent._parse_json_response")
async def test_build_data_output_with_primitive(self, mock_parse_json, component_class, default_kwargs):
"""Test build_data_output with primitive value response."""
mock_parse_json.return_value = "simple string"
component = component_class(**default_kwargs)
result = await component.build_data_output()
assert isinstance(result, Data)
assert result.data == {"value": "simple string"}
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllamaComponent._parse_json_response")
async def test_build_dataframe_output_with_list_of_dicts(self, mock_parse_json, component_class, default_kwargs):
"""Test build_dataframe_output with list of dicts."""
mock_parse_json.return_value = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]
component = component_class(**default_kwargs)
result = await component.build_dataframe_output()
assert isinstance(result, DataFrame)
assert len(result) == 2
assert list(result.columns) == ["name", "age"]
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllamaComponent._parse_json_response")
async def test_build_dataframe_output_with_empty_list(self, mock_parse_json, component_class, default_kwargs):
"""Test build_dataframe_output with empty list."""
mock_parse_json.return_value = []
component = component_class(**default_kwargs)
result = await component.build_dataframe_output()
assert isinstance(result, DataFrame)
assert len(result) == 0
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllamaComponent._parse_json_response")
async def test_build_dataframe_output_with_single_dict(self, mock_parse_json, component_class, default_kwargs):
"""Test build_dataframe_output with single dict."""
mock_parse_json.return_value = {"name": "Charlie", "score": 95}
component = component_class(**default_kwargs)
result = await component.build_dataframe_output()
assert isinstance(result, DataFrame)
assert len(result) == 1
assert result.iloc[0]["name"] == "Charlie"
assert result.iloc[0]["score"] == 95
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllamaComponent._parse_json_response")
async def test_build_dataframe_output_with_primitive(self, mock_parse_json, component_class, default_kwargs):
"""Test build_dataframe_output with primitive value."""
mock_parse_json.return_value = 42
component = component_class(**default_kwargs)
result = await component.build_dataframe_output()
assert isinstance(result, DataFrame)
assert len(result) == 1
assert result.iloc[0]["value"] == 42
@pytest.mark.asyncio
@patch("lfx.components.ollama.ollama.ChatOllamaComponent._parse_json_response")
async def test_build_dataframe_output_with_invalid_list(self, mock_parse_json, component_class, default_kwargs):
"""Test build_dataframe_output with list of non-dicts raises ValueError."""
mock_parse_json.return_value = [1, 2, 3, "string"]
component = component_class(**default_kwargs)
with pytest.raises(ValueError, match="List items must be dictionaries"):
await component.build_dataframe_output()
def test_headers_with_cloud_url_no_api_key(self):
"""Test that headers return None when cloud URL but no API key."""
component = ChatOllamaComponent()

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,6 @@
import asyncio
import json
from contextlib import suppress
from typing import Any
from urllib.parse import urljoin
@ -8,6 +10,7 @@ from langchain_ollama import ChatOllama
from lfx.base.models.model import LCModelComponent
from lfx.field_typing import LanguageModel
from lfx.field_typing.range_spec import RangeSpec
from lfx.helpers.base_model import build_model_from_schema
from lfx.io import (
BoolInput,
DictInput,
@ -15,13 +18,19 @@ from lfx.io import (
FloatInput,
IntInput,
MessageTextInput,
Output,
SecretStrInput,
SliderInput,
TableInput,
)
from lfx.log.logger import logger
from lfx.schema.data import Data
from lfx.schema.dataframe import DataFrame
from lfx.schema.table import EditMode
from lfx.utils.util import transform_localhost_url
HTTP_STATUS_OK = 200
TABLE_ROW_PLACEHOLDER = {"name": "field", "description": "description of field", "type": "str", "multiple": "False"}
class ChatOllamaComponent(LCModelComponent):
@ -37,6 +46,46 @@ class ChatOllamaComponent(LCModelComponent):
DESIRED_CAPABILITY = "completion"
TOOL_CALLING_CAPABILITY = "tools"
# Define the table schema for the format input
TABLE_SCHEMA = [
{
"name": "name",
"display_name": "Name",
"type": "str",
"description": "Specify the name of the output field.",
"default": "field",
"edit_mode": EditMode.INLINE,
},
{
"name": "description",
"display_name": "Description",
"type": "str",
"description": "Describe the purpose of the output field.",
"default": "description of field",
"edit_mode": EditMode.POPOVER,
},
{
"name": "type",
"display_name": "Type",
"type": "str",
"edit_mode": EditMode.INLINE,
"description": ("Indicate the data type of the output field (e.g., str, int, float, bool, dict)."),
"options": ["str", "int", "float", "bool", "dict"],
"default": "str",
},
{
"name": "multiple",
"display_name": "As List",
"type": "boolean",
"description": "Set to True if this output field should be a list of the specified type.",
"edit_mode": EditMode.INLINE,
"options": ["True", "False"],
"default": "False",
},
]
default_table_row = {row["name"]: row.get("default", None) for row in TABLE_SCHEMA}
default_table_row_schema = build_model_from_schema([default_table_row]).model_json_schema()
inputs = [
MessageTextInput(
name="base_url",
@ -69,8 +118,13 @@ class ChatOllamaComponent(LCModelComponent):
range_spec=RangeSpec(min=0, max=1, step=0.01),
advanced=True,
),
MessageTextInput(
name="format", display_name="Format", info="Specify the format of the output (e.g., json).", advanced=True
TableInput(
name="format",
display_name="Format",
info="Specify the format of the output.",
advanced=False,
table_schema=TABLE_SCHEMA,
value=default_table_row,
),
DictInput(name="metadata", display_name="Metadata", info="Metadata to add to the run trace.", advanced=True),
DropdownInput(
@ -164,6 +218,13 @@ class ChatOllamaComponent(LCModelComponent):
*LCModelComponent.get_base_inputs(),
]
outputs = [
Output(display_name="Text", name="text_output", method="text_response"),
Output(display_name="Language Model", name="model_output", method="build_model"),
Output(display_name="Data", name="data_output", method="build_data_output"),
Output(display_name="DataFrame", name="dataframe_output", method="build_dataframe_output"),
]
def build_model(self) -> LanguageModel: # type: ignore[type-var]
# Mapping mirostat settings to their corresponding values
mirostat_options = {"Mirostat": 1, "Mirostat 2.0": 2}
@ -192,12 +253,18 @@ class ChatOllamaComponent(LCModelComponent):
"Learn more at https://docs.ollama.com/openai#openai-compatibility"
)
try:
output_format = self._parse_format_field(self.format)
except Exception as e:
msg = f"Failed to parse the format field: {e}"
raise ValueError(msg) from e
# Mapping system settings to their corresponding values
llm_params = {
"base_url": transformed_base_url,
"model": self.model_name,
"mirostat": mirostat_value,
"format": self.format,
"format": output_format,
"metadata": self.metadata,
"tags": self.tags.split(",") if self.tags else None,
"mirostat_eta": mirostat_eta,
@ -356,6 +423,109 @@ class ChatOllamaComponent(LCModelComponent):
return model_ids
def _parse_format_field(self, format_value: Any) -> Any:
"""Parse the format field to handle both string and dict inputs.
The format field can be:
- A simple string like "json" (backward compatibility)
- A JSON string from NestedDictInput that needs parsing
- A dict/JSON schema (already parsed)
- None or empty
Args:
format_value: The raw format value from the input field
Returns:
Parsed format value as string, dict, or None
"""
if not format_value:
return None
schema = format_value
if isinstance(format_value, list):
schema = build_model_from_schema(format_value).model_json_schema()
if schema == self.default_table_row_schema:
return None # the rows are generic placeholder rows
elif isinstance(format_value, str): # parse as json if string
with suppress(json.JSONDecodeError): # e.g., literal "json" is valid for format field
schema = json.loads(format_value)
return schema or None
async def _parse_json_response(self) -> Any:
"""Parse the JSON response from the model.
This method gets the text response and attempts to parse it as JSON.
Works with models that have format='json' or a JSON schema set.
Returns:
Parsed JSON (dict, list, or primitive type)
Raises:
ValueError: If the response is not valid JSON
"""
message = await self.text_response()
text = message.text if hasattr(message, "text") else str(message)
if not text:
msg = "No response from model"
raise ValueError(msg)
try:
return json.loads(text)
except json.JSONDecodeError as e:
msg = f"Invalid JSON response. Ensure model supports JSON output. Error: {e}"
raise ValueError(msg) from e
async def build_data_output(self) -> Data:
"""Build a Data output from the model's JSON response.
Returns:
Data: A Data object containing the parsed JSON response
"""
parsed = await self._parse_json_response()
# If the response is already a dict, wrap it in Data
if isinstance(parsed, dict):
return Data(data=parsed)
# If it's a list, wrap in a results container
if isinstance(parsed, list):
if len(parsed) == 1:
return Data(data=parsed[0])
return Data(data={"results": parsed})
# For primitive types, wrap in a value container
return Data(data={"value": parsed})
async def build_dataframe_output(self) -> DataFrame:
"""Build a DataFrame output from the model's JSON response.
Returns:
DataFrame: A DataFrame containing the parsed JSON response
Raises:
ValueError: If the response cannot be converted to a DataFrame
"""
parsed = await self._parse_json_response()
# If it's a list of dicts, convert directly to DataFrame
if isinstance(parsed, list):
if not parsed:
return DataFrame()
# Ensure all items are dicts for proper DataFrame conversion
if all(isinstance(item, dict) for item in parsed):
return DataFrame(parsed)
msg = "List items must be dictionaries to convert to DataFrame"
raise ValueError(msg)
# If it's a single dict, wrap in a list to create a single-row DataFrame
if isinstance(parsed, dict):
return DataFrame([parsed])
# For primitive types, create a single-column DataFrame
return DataFrame([{"value": parsed}])
@property
def headers(self) -> dict[str, str] | None:
"""Get the headers for the Ollama API."""