Skip to content

Add structured output support with generate_object! for E2E tests #65

@andreasronge

Description

@andreasronge

Summary

Add structured output mode to the LLM client using ReqLLM.generate_object! with a flattened LLM-friendly schema.

Background

The program wrapper (#58, #59) enables structured output by avoiding root-level anyOf. This issue adds the code to actually use structured output in E2E tests.

Text mode (current): Uses generate_text! with full JSON schema in prompt, cleans markdown fences from response.

Structured mode (new): Uses generate_object! with flattened schema, guarantees valid JSON structure from LLM.

Acceptance Criteria

  • Add to_llm_schema/0 function in lib/ptc_runner/schema.ex
  • Add generate_program_structured!/1 in test/support/llm_client.ex
  • Add E2E tests using structured mode (tagged :e2e)
  • All E2E tests pass with mix test --include e2e

Implementation Hints

to_llm_schema/0

A flattened schema optimized for LLM structured output:

  • No $ref recursion (LLMs struggle with recursive schemas)
  • Limited nesting depth (3 levels)
  • Supports common operations: pipe, load, literal, filter, reject, map, select, aggregations, comparisons
def to_llm_schema do
  %{
    "$schema" => "http://json-schema.org/draft-07/schema#",
    "title" => "PTC DSL Program (LLM-Friendly)",
    "type" => "object",
    "properties" => %{
      "program" => %{
        "description" => "The PTC program - pipe, load, or literal operation",
        "anyOf" => [pipe_schema(), load_schema(), literal_schema()]
      }
    },
    "required" => ["program"],
    "additionalProperties" => false
  }
end

generate_program_structured!/1

def generate_program_structured!(task) do
  ensure_api_key!()
  
  prompt = build_structured_prompt(task)
  schema = PtcRunner.Schema.to_llm_schema()
  
  result = ReqLLM.generate_object!(@model, prompt, schema,
    receive_timeout: @timeout,
    openrouter_provider: %{require_parameters: true}
  )
  
  # Extract program from wrapper and return as JSON string
  Jason.encode!(result["program"])
end

E2E Tests

Add tests similar to existing text mode tests:

describe "structured mode - LLM program generation" do
  @describetag :e2e
  
  test "generates valid filter program" do
    task = "Filter products where price is greater than 10"
    program_json = LLMClient.generate_program_structured!(task)
    
    context = %{"input" => [%{"name" => "A", "price" => 5}, %{"name" => "B", "price" => 15}]}
    assert {:ok, result, _} = PtcRunner.run(program_json, context: context)
    assert length(result) == 1
  end
end

Dependencies

Test Plan

  • Run mix test --include e2e - all E2E tests pass (both text and structured)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions