Skip to content

Latest commit

 

History

History
78 lines (57 loc) · 4.38 KB

File metadata and controls

78 lines (57 loc) · 4.38 KB

Prompt AST

Read this when your prompt has more than one shape (per-tenant, per-language, per-audience) and string concatenation is starting to drift.

Prompts are structured data, not strings. That matters the moment SummarizeArticle has to ship in more than one shape — a different audience per tenant, a different language per region, a different tone template for a B2B vs consumer card. Building those variants by string-concatenating a monolithic prompt leads to silent drift across environments. The AST gives you typed nodes (system, rule, section, example, user) that compose, diff, and snapshot-test cleanly.

Available node types:

prompt do
  system  "You summarize articles for a UI card."     # system message
  rule    "Return valid JSON only."                   # appended as separate system message
  section "AUDIENCE", "Rails developers"              # labeled system message: [AUDIENCE]\n...
  example input:  "Ruby 3.4 ships frozen strings...", # user/assistant few-shot pair
          output: '{"tldr":"...","takeaways":[...],"tone":"analytical"}'
  user    "{input}"                                   # user message with interpolation
end

Or just a plain string (wraps as a single user message):

prompt "Summarize this article for a UI card. {input}"

The AST is immutable, diffable, and hashable. Useful for snapshot testing and auditing prompt changes.

Hash inputs with variable interpolation

When input is a Hash, each key becomes a template variable. Concrete scenario: a multi-language newsletter product where the same article has to be summarised in Polish for EU subscribers, English for US, with different audiences per tier (Rails developers vs engineering managers). Hash inputs let one step cover all of these without forking the class.

The Types::Hash.schema(...) form below declares a typed contract on the inputrun() raises TypeError if a required key is missing or its value violates the declared type. Use it when the input shape is part of the Step's interface (multi-key inputs, multi-tenant variants); plain input_type Hash accepts any hash and is fine for prototypes or when the schema is enforced upstream.

class SummarizeArticle < RubyLLM::Contract::Step::Base
  input_type RubyLLM::Contract::Types::Hash.schema(
    article:  RubyLLM::Contract::Types::String,
    audience: RubyLLM::Contract::Types::String,
    language: RubyLLM::Contract::Types::String
  )

  prompt do
    system  "You summarize articles for a UI card."
    rule    "Write the TL;DR and takeaways in {language}."
    section "AUDIENCE", "{audience}"
    user    "{article}"
  end

  output_schema do
    string :tldr
    array  :takeaways, of: :string, min_items: 3, max_items: 5
    string :tone, enum: %w[neutral positive negative analytical]
  end
end

Every {key} in a prompt node is pulled from the input hash at run time. Missing keys raise — making wire-up bugs loud, not silent.

Not the same as RubyLLM::Agent.inputs. Step.input_type is a runtime type check on the positional argument passed to run(input) — it raises TypeError if the input violates the declared shape. RubyLLM::Agent.inputs is a list of named template locals injected into ERB instructions. They solve different problems (validation vs templating) and can coexist on the same project.

Not the same as RubyLLM::Agent ERB templates either. The prompt do ... end DSL above builds a multi-role message list (system / user / assistant / example nodes) into a node-AST. Agent.instructions :name loads a single-string ERB file from app/prompts/<agent_path>/<name>.txt.erb for the system prompt only. Different output shape, different scope. Variable interpolation here uses {key} substitution; ERB uses full <%= ruby %>.

Cross-validating output against input

Validate blocks support 2-arity |output, input| so you can check that the model's answer stays faithful to the request:

validate("tldr is not just the article reprinted") do |output, input|
  # Guard against lazy models that return the input verbatim.
  output[:tldr].length < input[:article].length / 2
end

validate("no takeaway repeats the TL;DR") do |output, _input|
  output[:takeaways].none? { |t| t == output[:tldr] }
end

The first example uses input; the second ignores it. Both are legal 2-arity signatures — Ruby accepts the unused _input parameter naming convention.