Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 163 additions & 5 deletions lib/chains/data_extraction_chain.ex
Original file line number Diff line number Diff line change
Expand Up @@ -108,22 +108,70 @@ defmodule LangChain.Chains.DataExtractionChain do
]
|> FunctionParam.to_parameters_schema()

## Provider Strategy vs. Tool Strategy

This chain can ask the LLM to return JSON constrained to a schema natively
("structured outputs"), without any tool/function calling. The chain checks
whether `llm`'s struct type natively supports this (see
`LangChain.ChatModels.ChatModel.supports_json_output?/1`) — i.e. whether its
bare struct defines both `:json_schema` and `:json_response` fields. If it
does, the chain runs against a patched copy of `llm` with `json_response:
true` and `json_schema` set to `schema_parameters` as given, and parses the
plain JSON response directly with `extract_result/1` instead of requiring a
tool call. Some models require extra parameters alongside the schema itself
(e.g. `LangChain.ChatModels.ChatOpenAIResponses` requires a separate
`:json_schema_name`); the chain fills these in with sensible defaults when
the struct defines them.

When `opts[:strategy]` is **not given**, the chain defaults to
`:provider_strategy`, but fails gracefully: if `llm`'s struct type doesn't
support it, it falls back to `:tool_strategy` — asking the LLM to call an
`information_extraction` tool built from `schema_parameters`, as shown
above.

When `opts[:strategy]` **is given** explicitly, it is used strictly — there is
no fallback. Passing `strategy: :provider_strategy` for an `llm` whose struct
type doesn't support it raises from `run_chain/4` (and `run/4` will return an
`{:error, %LangChainError{}}`):

# Always uses tool calling, regardless of what `llm` supports
{:ok, result} =
DataExtractionChain.run(chat, schema_parameters, data_prompt, strategy: :tool_strategy)

# Strictly requires provider_strategy support; raises from run_chain/4 otherwise
{:ok, chain} =
DataExtractionChain.run_chain(chat, schema_parameters, data_prompt, strategy: :provider_strategy)

Note: the exact wire format for structured output still differs by provider
(compare `:json_schema` on `LangChain.ChatModels.ChatAnthropic` vs
`LangChain.ChatModels.ChatMistralAI`, which expects the entire response
format nested under `:json_schema`); the defaults applied here favor the
common bare-schema shape and may not be fully correct for every provider.

"""
use Ecto.Schema
require Logger
alias LangChain.PromptTemplate
alias LangChain.Message
alias LangChain.Message.ContentPart
alias LangChain.Message.ToolCall
alias LangChain.LangChainError
alias LangChain.Chains.LLMChain
alias LangChain.ChatModels.ChatOpenAI
alias LangChain.ChatModels.ChatModel
alias LangChain.MessageProcessors.JsonProcessor

@function_name "information_extraction"
@extraction_template ~s"Extract and save the relevant entities mentioned in the following passage together with their properties. Use the value `null` when missing in the passage.

Passage:
<%= @input %>"

# Sensible defaults applied to extra fields some models require alongside
# `:json_schema`/`:json_response`, when the struct defines them and they're
# not already set. Only fields explicitly known to exist across the chat
# model modules are listed here.
@extra_field_defaults %{json_schema_name: @function_name}

@doc """
Coerces the extraction tool's `info` argument to a list of rows.

Expand Down Expand Up @@ -159,15 +207,53 @@ Passage:

## Options

- `:strategy` - either `:provider_strategy` or `:tool_strategy`. When
omitted, defaults to `:provider_strategy` but falls back to
`:tool_strategy` if `llm` doesn't support it. When given explicitly, it is
used strictly, raising if `llm` doesn't support it. See the "Provider
Strategy vs. Tool Strategy" section in the module docs.
- `:verbose` - when `true`, enables verbose logging on the internally run
`LLMChain`. Defaults to `false`.
- `:callbacks` - a list of callback handler maps to register on the
internally run `LLMChain`. See `LangChain.Chains.ChainCallbacks` for the
available events. Defaults to `[]`.
"""
@spec run_chain(ChatOpenAI.t(), json_schema :: map(), prompt :: [any()], opts :: Keyword.t()) ::
@spec run_chain(ChatModel.t(), json_schema :: map(), prompt :: [any()], opts :: Keyword.t()) ::
{:ok, LLMChain.t()} | {:error, LLMChain.t(), LangChainError.t()}
def run_chain(llm, json_schema, prompt, opts \\ []) do
case Keyword.fetch(opts, :strategy) do
:error ->
# No explicit strategy: default to provider_strategy, but fail
# gracefully by falling back to tool_strategy when unsupported.
if ChatModel.supports_json_output?(llm.__struct__) do
run_chain_provider_strategy(llm, json_schema, prompt, opts)
else
Logger.warning(
"#{inspect(llm.__struct__)} does not support :provider_strategy (it does not define both :json_schema and :json_response fields). Falling back to :tool_strategy since no :strategy was explicitly specified."
)

run_chain_tool_strategy(llm, json_schema, prompt, opts)
end

{:ok, :tool_strategy} ->
run_chain_tool_strategy(llm, json_schema, prompt, opts)

{:ok, :provider_strategy} ->
# Explicit strategy: used strictly, no fallback.
if ChatModel.supports_json_output?(llm.__struct__) do
run_chain_provider_strategy(llm, json_schema, prompt, opts)
else
raise LangChainError,
"`llm`'s struct type does not support :provider_strategy"
end

{:ok, other} ->
raise LangChainError,
"Invalid :strategy #{inspect(other)}. Expected :tool_strategy or :provider_strategy."
end
end

defp run_chain_tool_strategy(llm, json_schema, prompt, opts) do
verbose = Keyword.get(opts, :verbose, false)
callbacks = Keyword.get(opts, :callbacks, [])

Expand All @@ -187,12 +273,66 @@ Passage:
|> LLMChain.run()
end

defp run_chain_provider_strategy(llm, json_schema, prompt, opts) do
verbose = Keyword.get(opts, :verbose, false)
callbacks = Keyword.get(opts, :callbacks, [])

messages =
[
Message.new_system!(
"You are a helpful assistant that extracts structured data from text passages. Respond only with JSON matching the required schema. Use the value `null` when missing in the passage."
),
PromptTemplate.new!(%{role: :user, text: @extraction_template})
]
|> PromptTemplate.to_messages!(%{input: prompt})

%{
llm: patch_llm_for_provider_strategy(llm, json_schema),
verbose: verbose,
callbacks: callbacks
}
|> LLMChain.new!()
|> LLMChain.message_processors([JsonProcessor.new!()])
|> LLMChain.add_messages(messages)
|> LLMChain.run()
end

# Patches a copy of `llm` with the fields needed to request structured JSON
# output for `json_schema`, unwrapped and as given. The schema is always
# taken from the argument passed to this call, never from how `llm` happened
# to be constructed.
defp patch_llm_for_provider_strategy(llm, json_schema) do
llm
|> Map.put(:json_response, true)
|> Map.put(:json_schema, json_schema)
|> apply_extra_field_defaults()
end

defp apply_extra_field_defaults(llm) do
Enum.reduce(@extra_field_defaults, llm, fn {field, default}, acc ->
if Map.has_key?(acc, field) and is_nil(Map.get(acc, field)) do
Map.put(acc, field, default)
else
acc
end
end)
end

@doc """
Return the extracted data from an executed `LangChain.Chains.LLMChain` that
was run by `run_chain/4`.

Under `:tool_strategy`, this reads the `info` array from the extraction
tool call. Under `:provider_strategy`, the response isn't wrapped in an
`info` envelope, so this instead takes whatever JSON the LLM returned
(list or map, matching `json_schema` as given) and normalizes it via
`normalize_extraction_info/1` — this works regardless of the shape of
`json_schema` passed to `run_chain/4`.

Returns an error when the LLM did not respond with the expected extraction
tool call.
tool call, or (under `:provider_strategy`) valid JSON. When `JsonProcessor`
halted on invalid JSON, that corrective error message is surfaced directly
instead of a generic "unexpected response" message.
"""
@spec extract_result(LLMChain.t()) :: {:ok, result :: [any()]} | {:error, LangChainError.t()}
def extract_result(%LLMChain{
Expand All @@ -209,8 +349,26 @@ Passage:
normalize_extraction_info(info)
end

def extract_result(%LLMChain{
last_message: %Message{role: :assistant, processed_content: processed_content}
})
when is_list(processed_content) or is_map(processed_content) do
normalize_extraction_info(processed_content)
end

# Assuming there was no last message. the extraction did not work due to invalid json schema
# we propagate the error forward.
def extract_result(%LLMChain{
last_message: %Message{role: :user, content: content}
}) do
case ContentPart.content_to_string(content) do
"ERROR: " <> _ = error_text -> {:error, LangChainError.exception(error_text)}
_ -> {:error, LangChainError.exception("Unexpected response.")}
end
end

def extract_result(%LLMChain{} = chain) do
{:error, LangChainError.exception("Unexpected response. #{inspect({:ok, chain})}")}
{:error, LangChainError.exception("Unexpected response. #{inspect(chain.last_message)}")}
end

@doc """
Expand All @@ -221,7 +379,7 @@ Passage:

Accepts the same options as `run_chain/4`.
"""
@spec run(ChatOpenAI.t(), json_schema :: map(), prompt :: [any()], opts :: Keyword.t()) ::
@spec run(ChatModel.t(), json_schema :: map(), prompt :: [any()], opts :: Keyword.t()) ::
{:ok, result :: [any()]} | {:error, LangChainError.t()}
def run(llm, json_schema, prompt, opts \\ []) do
try do
Expand Down
16 changes: 16 additions & 0 deletions lib/chat_models/chat_model.ex
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,22 @@ defmodule LangChain.ChatModels.ChatModel do

defp json_response_format?(_), do: false

@doc """
Returns whether a chat model module natively supports requesting
structured JSON output, i.e. whether its bare struct defines both
`:json_schema` and `:json_response` fields. This reflects what the *type*
supports, not whether a given instance currently has those fields
configured — use `output_type/1` for that.

Takes the chat model module, not a struct instance — pass `llm.__struct__`
when starting from a configured struct.
"""
@spec supports_json_output?(module()) :: boolean()
def supports_json_output?(module) when is_atom(module) do
pure = struct(module)
Map.has_key?(pure, :json_schema) and Map.has_key?(pure, :json_response)
end

# Request endpoint URL for `server.address`/`server.port`, when the model exposes
# one under the conventional `:endpoint` field.
@spec endpoint(struct() | nil) :: String.t() | nil
Expand Down
Loading