Skip to content

Commit fbdaea7

Browse files
andreasrongegithub-actions[bot]claude
authored
feat: Add structured output support with generate_program_structured! for E2E tests (#65) (#67)
* feat: Add structured output support with generate_program_structured! for E2E tests - Implement PtcRunner.Schema.to_llm_schema/0 with flattened schema optimized for LLMs - Use anyOf pattern to list all operations at top level, avoiding recursion - Add PtcRunner.TestSupport.LLMClient.generate_program_structured!/1 for structured output mode - Uses ReqLLM.generate_object! for guaranteed valid JSON from LLMs - Add E2E tests for both text mode (text mode) and structured output mode - E2E tests verify filter, sum, and chained operations work correctly - Addresses issue #65 review comments on schema validation and error handling 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * test: Add comprehensive unit tests for to_llm_schema/0 Adds 13 new tests for the to_llm_schema/0 function following the same patterns as the existing to_json_schema/0 tests. Tests verify: - Basic schema structure with title, type, properties, anyOf - Program property with correct structure - Correct count of 33 operation schemas - Each operation has required structure - Literal operation schema correctness - Operations with no fields have only 'op' as required - Operations with optional fields exclude them from required - Expr types use inline object schema (not $ref) - List of expr types have correct inline schema - List of string types have correct schema - Non-negative integer types have minimum constraint - Recursive operations like pipe validate correctly - Generated schema is valid JSON Fixes issue from PR #67 review. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2655ae commit fbdaea7

File tree

4 files changed

+388
-3
lines changed

4 files changed

+388
-3
lines changed

lib/ptc_runner/schema.ex

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,121 @@ defmodule PtcRunner.Schema do
352352
base_required ++ field_required
353353
end
354354

355+
@doc """
356+
Generate a flattened JSON Schema optimized for LLM structured output.
357+
358+
This schema uses `anyOf` to list all operations at the top level, avoiding
359+
the recursive `$ref` patterns that LLMs struggle with. The schema is designed
360+
to work with ReqLLM.generate_object! for structured output mode.
361+
362+
## Returns
363+
A map representing the flattened JSON Schema for the PTC DSL.
364+
"""
365+
@spec to_llm_schema() :: map()
366+
def to_llm_schema do
367+
operation_schemas =
368+
@operations
369+
|> Enum.map(fn {op_name, op_def} ->
370+
operation_to_llm_schema(op_name, op_def)
371+
end)
372+
373+
%{
374+
"title" => "PTC Program",
375+
"type" => "object",
376+
"properties" => %{
377+
"program" => %{
378+
"description" => "The PTC program operation",
379+
"anyOf" => operation_schemas
380+
}
381+
},
382+
"required" => ["program"],
383+
"additionalProperties" => false
384+
}
385+
end
386+
387+
# Convert a single operation to its flattened schema representation for LLM use
388+
defp operation_to_llm_schema(op_name, op_def) do
389+
fields = op_def["fields"]
390+
properties = build_llm_properties(op_name, fields)
391+
required_fields = build_required(fields)
392+
393+
%{
394+
"type" => "object",
395+
"description" => op_def["description"],
396+
"properties" => properties,
397+
"required" => required_fields,
398+
"additionalProperties" => false
399+
}
400+
end
401+
402+
# Build the properties map for an LLM schema operation (flattened, no $ref)
403+
defp build_llm_properties(op_name, fields) do
404+
base_properties = %{
405+
"op" => %{"const" => op_name}
406+
}
407+
408+
field_properties =
409+
fields
410+
|> Enum.map(fn {field_name, field_spec} ->
411+
{field_name, type_to_llm_json_schema(field_spec["type"])}
412+
end)
413+
|> Enum.into(%{})
414+
415+
Map.merge(base_properties, field_properties)
416+
end
417+
418+
# Convert Elixir type to flattened JSON Schema type for LLM use (no $ref)
419+
defp type_to_llm_json_schema(:any), do: %{}
420+
421+
defp type_to_llm_json_schema(:string) do
422+
%{"type" => "string"}
423+
end
424+
425+
# For nested expressions, use a placeholder schema instead of $ref
426+
# This allows the LLM to generate valid operations
427+
defp type_to_llm_json_schema(:expr) do
428+
%{
429+
"description" => "A PTC operation (any valid operation type)",
430+
"type" => "object",
431+
"properties" => %{
432+
"op" => %{"type" => "string"}
433+
},
434+
"required" => ["op"]
435+
}
436+
end
437+
438+
defp type_to_llm_json_schema({:list, :expr}) do
439+
%{
440+
"type" => "array",
441+
"items" => %{
442+
"description" => "A PTC operation (any valid operation type)",
443+
"type" => "object",
444+
"properties" => %{
445+
"op" => %{"type" => "string"}
446+
},
447+
"required" => ["op"]
448+
}
449+
}
450+
end
451+
452+
defp type_to_llm_json_schema({:list, :string}) do
453+
%{
454+
"type" => "array",
455+
"items" => %{"type" => "string"}
456+
}
457+
end
458+
459+
defp type_to_llm_json_schema(:map) do
460+
%{"type" => "object"}
461+
end
462+
463+
defp type_to_llm_json_schema(:non_neg_integer) do
464+
%{
465+
"type" => "integer",
466+
"minimum" => 0
467+
}
468+
end
469+
355470
# Convert Elixir type to JSON Schema type specification
356471
defp type_to_json_schema(:any), do: %{}
357472

test/ptc_runner/e2e_test.exs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ defmodule PtcRunner.E2ETest do
55

66
alias PtcRunner.TestSupport.LLMClient
77

8-
describe "LLM program generation" do
8+
describe "LLM program generation - text mode" do
99
test "generates valid filter program" do
1010
task = "Filter products where price is greater than 10"
1111
json_schema = PtcRunner.Schema.to_json_schema()
@@ -64,4 +64,61 @@ defmodule PtcRunner.E2ETest do
6464
assert result == 2
6565
end
6666
end
67+
68+
describe "LLM program generation - structured output mode" do
69+
test "generates valid filter program with structured output" do
70+
task = "Filter products where price is greater than 10"
71+
72+
program_json = LLMClient.generate_program_structured!(task)
73+
74+
context = %{
75+
"input" => [
76+
%{"name" => "Apple", "price" => 5},
77+
%{"name" => "Book", "price" => 15},
78+
%{"name" => "Laptop", "price" => 999}
79+
]
80+
}
81+
82+
assert {:ok, result, _metrics} = PtcRunner.run(program_json, context: context)
83+
84+
# Constraint assertions - not exact values
85+
assert is_list(result)
86+
assert length(result) == 2
87+
assert Enum.all?(result, fn item -> item["price"] > 10 end)
88+
end
89+
90+
test "generates valid sum aggregation with structured output" do
91+
task = "Calculate the sum of all prices"
92+
93+
program_json = LLMClient.generate_program_structured!(task)
94+
95+
context = %{
96+
"input" => [
97+
%{"price" => 10},
98+
%{"price" => 20},
99+
%{"price" => 30}
100+
]
101+
}
102+
103+
assert {:ok, result, _metrics} = PtcRunner.run(program_json, context: context)
104+
assert result == 60
105+
end
106+
107+
test "generates valid chained operations with structured output" do
108+
task = "Filter products where price > 10, then count them"
109+
110+
program_json = LLMClient.generate_program_structured!(task)
111+
112+
context = %{
113+
"input" => [
114+
%{"name" => "A", "price" => 5},
115+
%{"name" => "B", "price" => 15},
116+
%{"name" => "C", "price" => 25}
117+
]
118+
}
119+
120+
assert {:ok, result, _metrics} = PtcRunner.run(program_json, context: context)
121+
assert result == 2
122+
end
123+
end
67124
end

test/ptc_runner/schema_test.exs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,4 +486,160 @@ defmodule PtcRunner.SchemaTest do
486486
"Generated schema does not match priv/ptc_schema.json"
487487
end
488488
end
489+
490+
describe "to_llm_schema/0" do
491+
test "returns a valid JSON Schema structure" do
492+
schema = PtcRunner.Schema.to_llm_schema()
493+
494+
assert is_map(schema)
495+
assert schema["title"] == "PTC Program"
496+
assert schema["type"] == "object"
497+
assert is_map(schema["properties"])
498+
assert is_list(schema["properties"]["program"]["anyOf"])
499+
end
500+
501+
test "schema has program property with correct structure" do
502+
schema = PtcRunner.Schema.to_llm_schema()
503+
504+
assert is_map(schema["properties"])
505+
assert is_map(schema["properties"]["program"])
506+
assert is_list(schema["properties"]["program"]["anyOf"])
507+
assert schema["required"] == ["program"]
508+
assert schema["additionalProperties"] == false
509+
end
510+
511+
test "generates 33 operation schemas" do
512+
schema = PtcRunner.Schema.to_llm_schema()
513+
assert length(schema["properties"]["program"]["anyOf"]) == 33
514+
end
515+
516+
test "each operation schema has required structure" do
517+
schema = PtcRunner.Schema.to_llm_schema()
518+
519+
Enum.each(schema["properties"]["program"]["anyOf"], fn op_schema ->
520+
assert is_map(op_schema)
521+
assert op_schema["type"] == "object"
522+
assert is_map(op_schema["properties"])
523+
assert is_list(op_schema["required"])
524+
assert op_schema["additionalProperties"] == false
525+
end)
526+
end
527+
528+
test "literal operation schema is correct" do
529+
schema = PtcRunner.Schema.to_llm_schema()
530+
531+
literal_schema =
532+
Enum.find(schema["properties"]["program"]["anyOf"], fn op ->
533+
op["properties"]["op"]["const"] == "literal"
534+
end)
535+
536+
assert literal_schema != nil
537+
assert "op" in literal_schema["required"]
538+
assert "value" in literal_schema["required"]
539+
assert literal_schema["properties"]["value"] == %{}
540+
end
541+
542+
test "operations with no fields have only 'op' as required" do
543+
schema = PtcRunner.Schema.to_llm_schema()
544+
545+
count_schema =
546+
Enum.find(schema["properties"]["program"]["anyOf"], fn op ->
547+
op["properties"]["op"]["const"] == "count"
548+
end)
549+
550+
assert count_schema["required"] == ["op"]
551+
end
552+
553+
test "operations with optional fields do not include them in required" do
554+
schema = PtcRunner.Schema.to_llm_schema()
555+
556+
get_schema =
557+
Enum.find(schema["properties"]["program"]["anyOf"], fn op ->
558+
op["properties"]["op"]["const"] == "get"
559+
end)
560+
561+
assert "path" in get_schema["required"]
562+
assert "default" not in get_schema["required"]
563+
assert Map.has_key?(get_schema["properties"], "default")
564+
end
565+
566+
test "expr types use inline object schema (not $ref)" do
567+
schema = PtcRunner.Schema.to_llm_schema()
568+
569+
let_schema =
570+
Enum.find(schema["properties"]["program"]["anyOf"], fn op ->
571+
op["properties"]["op"]["const"] == "let"
572+
end)
573+
574+
# Verify value and in fields use inline object schema, not $ref
575+
assert let_schema["properties"]["value"]["type"] == "object"
576+
assert let_schema["properties"]["value"]["properties"]["op"]["type"] == "string"
577+
assert let_schema["properties"]["in"]["type"] == "object"
578+
assert let_schema["properties"]["in"]["properties"]["op"]["type"] == "string"
579+
end
580+
581+
test "list of expr types have correct inline schema" do
582+
schema = PtcRunner.Schema.to_llm_schema()
583+
584+
and_schema =
585+
Enum.find(schema["properties"]["program"]["anyOf"], fn op ->
586+
op["properties"]["op"]["const"] == "and"
587+
end)
588+
589+
assert and_schema["properties"]["conditions"]["type"] == "array"
590+
assert is_map(and_schema["properties"]["conditions"]["items"])
591+
assert and_schema["properties"]["conditions"]["items"]["type"] == "object"
592+
593+
assert and_schema["properties"]["conditions"]["items"]["properties"]["op"]["type"] ==
594+
"string"
595+
end
596+
597+
test "list of string types have correct schema" do
598+
schema = PtcRunner.Schema.to_llm_schema()
599+
600+
select_schema =
601+
Enum.find(schema["properties"]["program"]["anyOf"], fn op ->
602+
op["properties"]["op"]["const"] == "select"
603+
end)
604+
605+
assert select_schema["properties"]["fields"] == %{
606+
"type" => "array",
607+
"items" => %{"type" => "string"}
608+
}
609+
end
610+
611+
test "non_neg_integer types have minimum constraint" do
612+
schema = PtcRunner.Schema.to_llm_schema()
613+
614+
nth_schema =
615+
Enum.find(schema["properties"]["program"]["anyOf"], fn op ->
616+
op["properties"]["op"]["const"] == "nth"
617+
end)
618+
619+
assert nth_schema["properties"]["index"]["type"] == "integer"
620+
assert nth_schema["properties"]["index"]["minimum"] == 0
621+
end
622+
623+
test "recursive operations like pipe validate correctly" do
624+
schema = PtcRunner.Schema.to_llm_schema()
625+
626+
pipe_schema =
627+
Enum.find(schema["properties"]["program"]["anyOf"], fn op ->
628+
op["properties"]["op"]["const"] == "pipe"
629+
end)
630+
631+
assert pipe_schema != nil
632+
assert pipe_schema["properties"]["steps"]["type"] == "array"
633+
assert pipe_schema["properties"]["steps"]["items"]["type"] == "object"
634+
assert pipe_schema["properties"]["steps"]["items"]["properties"]["op"]["type"] == "string"
635+
end
636+
637+
test "generated schema is valid JSON" do
638+
schema = PtcRunner.Schema.to_llm_schema()
639+
json = Jason.encode!(schema)
640+
decoded = Jason.decode!(json)
641+
642+
assert decoded == schema
643+
end
644+
end
489645
end

0 commit comments

Comments
 (0)