mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 23:13:58 +08:00
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:
@ -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()
|
||||
@ -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
@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user