defmodule LangChain.Tools.DeepResearch.ResearchResult do @moduledoc """ Represents the final result of a completed Deep Research request. This schema captures the research findings, citations, usage statistics, and other metadata from a successful research operation. Sources are stored as `LangChain.Message.Citation` structs, providing a unified citation representation shared across all providers. """ use Ecto.Schema import Ecto.Changeset alias LangChain.Message.Citation @type t() :: %__MODULE__{ id: String.t() | nil, output_text: String.t() | nil, model: String.t() | nil, created_at: integer() | nil, completion_time: integer() | nil, sources: [Citation.t()], usage: any() | nil, tool_calls: [any()] } @primary_key false embedded_schema do field :id, :string field :output_text, :string field :model, :string field :created_at, :integer field :completion_time, :integer embeds_many :sources, Citation embeds_one :usage, Usage do field :input_tokens, :integer field :output_tokens, :integer field :total_tokens, :integer field :reasoning_tokens, :integer end embeds_many :tool_calls, ToolCall do field :type, :string field :status, :string field :action, :map field :result, :map end end @doc """ Creates a changeset for research result. """ @spec changeset(map()) :: Ecto.Changeset.t() @spec changeset(__MODULE__.t(), map()) :: Ecto.Changeset.t() def changeset(result \\ %__MODULE__{}, attrs) do result |> cast(attrs, [:id, :output_text, :model, :created_at, :completion_time]) |> cast_embed(:sources, with: &Citation.changeset/2) |> cast_embed(:usage, with: &usage_changeset/2) |> cast_embed(:tool_calls, with: &tool_call_changeset/2) |> validate_required([:id, :output_text]) end @doc """ Creates a ResearchResult from an OpenAI API response. """ @spec from_api_response(map()) :: {:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()} def from_api_response(response) do attrs = %{ id: Map.get(response, "id"), output_text: extract_output_text(response), model: Map.get(response, "model"), created_at: Map.get(response, "created_at"), completion_time: calculate_completion_time(response), sources: extract_sources(response), usage: Map.get(response, "usage"), tool_calls: extract_tool_calls(response) } changeset = changeset(%__MODULE__{}, attrs) if changeset.valid? do {:ok, apply_changes(changeset)} else {:error, changeset} end end @doc """ Gets the total number of sources cited in the research. """ @spec source_count(__MODULE__.t()) :: integer() def source_count(%__MODULE__{sources: sources}), do: length(sources) @doc """ Gets the total number of tool calls made during research. """ @spec tool_call_count(__MODULE__.t()) :: integer() def tool_call_count(%__MODULE__{tool_calls: tool_calls}), do: length(tool_calls) @doc """ Formats the result for display, including the main text and source summary. """ @spec format_for_display(__MODULE__.t()) :: String.t() def format_for_display(%__MODULE__{} = result) do source_summary = format_sources(result.sources) output = """ ## Research Findings #{result.output_text} """ if source_summary != "" do output <> "\n\n## Sources\n\n#{source_summary}" else output end end @doc """ Extracts just the URLs from the sources for easy reference. """ @spec source_urls(__MODULE__.t()) :: [String.t()] def source_urls(%__MODULE__{sources: sources}) do sources |> Enum.map(& &1.source.url) |> Enum.reject(&is_nil/1) end # Private functions @spec usage_changeset(map(), map()) :: Ecto.Changeset.t() defp usage_changeset(usage, attrs) do usage |> cast(attrs, [:input_tokens, :output_tokens, :total_tokens, :reasoning_tokens]) |> validate_number(:input_tokens, greater_than_or_equal_to: 0) |> validate_number(:output_tokens, greater_than_or_equal_to: 0) |> validate_number(:total_tokens, greater_than_or_equal_to: 0) |> validate_number(:reasoning_tokens, greater_than_or_equal_to: 0) end @spec tool_call_changeset(map(), map()) :: Ecto.Changeset.t() defp tool_call_changeset(tool_call, attrs) do tool_call |> cast(attrs, [:type, :status, :action, :result]) |> validate_required([:type]) |> validate_inclusion(:type, ["web_search_call", "code_interpreter_call", "mcp_tool_call"]) end @spec extract_output_text(map()) :: String.t() defp extract_output_text(response) do response |> get_in(["output"]) |> case do output when is_list(output) -> output |> Enum.find(&(Map.get(&1, "type") == "message")) |> case do nil -> "No message output found" message -> message |> get_in(["content"]) |> case do content when is_list(content) -> content |> Enum.find(&(Map.get(&1, "type") == "output_text")) |> case do nil -> "No text content found" text_content -> Map.get(text_content, "text", "No text available") end _ -> "Invalid content format" end end _ -> "No output available" end end @spec extract_sources(map()) :: [map()] defp extract_sources(response) do response |> get_in(["output"]) |> case do output when is_list(output) -> output |> Enum.flat_map(fn item -> item |> get_in(["content"]) |> case do content when is_list(content) -> content |> Enum.flat_map(fn content_item -> Map.get(content_item, "annotations", []) end) _ -> [] end end) |> Enum.map(&normalize_source/1) _ -> [] end end @spec normalize_source(map()) :: map() defp normalize_source(annotation) do %{ cited_text: Map.get(annotation, "snippet"), start_index: Map.get(annotation, "start_index"), end_index: Map.get(annotation, "end_index"), source: %{ type: :web, title: Map.get(annotation, "title"), url: Map.get(annotation, "url") }, metadata: %{"provider" => "openai_deep_research"} } end @spec extract_tool_calls(map()) :: [map()] defp extract_tool_calls(response) do response |> get_in(["output"]) |> case do output when is_list(output) -> output |> Enum.filter( &(Map.get(&1, "type") in ["web_search_call", "code_interpreter_call", "mcp_tool_call"]) ) |> Enum.map(&normalize_tool_call/1) _ -> [] end end @spec normalize_tool_call(map()) :: map() defp normalize_tool_call(tool_call) do %{ type: Map.get(tool_call, "type"), status: Map.get(tool_call, "status"), action: Map.get(tool_call, "action"), result: Map.get(tool_call, "result") } end @spec calculate_completion_time(map()) :: integer() | nil defp calculate_completion_time(_response) do # If we have completion timestamp info, calculate duration # For now, return nil as this would need to be tracked externally nil end @spec format_sources([Citation.t()]) :: String.t() defp format_sources(sources) when is_list(sources) do sources |> Enum.with_index(1) |> Enum.map(fn {citation, index} -> title = (citation.source && citation.source.title) || "Untitled" url = (citation.source && citation.source.url) || "No URL" "#{index}. #{title} - #{url}" end) |> Enum.join("\n") end defp format_sources(_), do: "" end