inquirex-llm 0.8.0 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e08a0d70d91af1bc1cd099d6b1cc32398e3e83e20a49245fcead8e7dfd7b809b
4
- data.tar.gz: 91e2e04cd12c5a75978d91c77b06d0f6b3ba0c4827d2a0eea7401151e6ba4eb0
3
+ metadata.gz: 2d7bb4e511826270c246b50b51b9305bfa5d117bf00a93a4cb64d6def3904697
4
+ data.tar.gz: 929c58c5fcb08cfd4210594d1cf34d1bb47b873e91f7b23e5215c6bca576b045
5
5
  SHA512:
6
- metadata.gz: 90e8b76887633ecd3841a01e2e2de0b59ed178e4458191d1bc2ebeb96928c9f8c8bc9c36da6eb3dddc9e2a59081f41eca94d050166330f5264d96c20a18819e7
7
- data.tar.gz: 4abdf58f828cbbe125cd8f00297cacc0d04a9ebd46f07b5f2c22f4e9c8fb26d98a29f85a48531dd3f73f0c0c3fe08341e862d2059deb6475062027768d87474f
6
+ metadata.gz: 0a9f47a1bb791ac80cee430b5e97009a456d3312b76bd66dcc784c28aeb463a06e0e99a6b47811519f4f62c91ec8c6f6d443b7e937d96b7c8c187036aaa44812
7
+ data.tar.gz: a0475eb9b50579a4873bd3dc5cc034b0ca17135bf5d590614121570c8153c1586f34bf7d4bbacc77acd7cf187a41a36148cb31d086bc6f700d5e8ab24b764e6b
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  LLM integration verbs for the [Inquirex](https://github.com/inquirex/inquirex) questionnaire engine.
6
6
 
7
- Extends the core DSL with a server-side `extract` verb (alias: `clarify`) that turns free-text answers into structured data via LLM processing. Ships with a pluggable adapter interface and a `NullAdapter` for testing. (`describe`, `summarize`, and `detour` are temporarily parked.)
7
+ Extends the core DSL with two server-side verbs: `extract` (alias: `clarify`), which turns free-text answers into structured data, and `summarize`, which closes a flow with a prose summary of the whole session. Ships with a pluggable adapter interface and a `NullAdapter` for testing. (`describe` and `detour` are temporarily parked.)
8
8
 
9
9
  `inquirex` is a pure Ruby, declarative, rules-driven questionnaire engine for building conditional intake forms, qualification wizards, and branching surveys.
10
10
 
@@ -14,8 +14,8 @@ Extends the core DSL with a server-side `extract` verb (alias: `clarify`) that t
14
14
  >
15
15
  > - [`inquirex`](https://github.com/inquirex/inquirex)
16
16
  > - [`inquirex-llm`](https://github.com/inquirex/inquirex-llm)
17
- > - [`inquirex-tty`](https://github.com/inquirex/inquirex-)
18
- > - [`inquirex-js`](https://github.com/inquirex/inquirex-js) (`npmjs` module [`@kigster/inquirex-js`](https://www.npmjs.com/package/@kigster/inquirex-js))
17
+ > - [`inquirex-tty`](https://github.com/inquirex/inquirex-tty)
18
+ > - [`inquirex-widget`](https://github.com/inquirex/inquirex-widget) (`npmjs` module [`inquirex-widget`](https://www.npmjs.com/package/inquirex-widget), formerly `@kigster/inquirex-js`)
19
19
  >
20
20
  > For a presentation about these gems and what they do please watch the [RubySF presentation](https://www.youtube.com/watch?v=iaoKW7Ap3_M&t=1s) and you can also [view the slides from the presentation](https://reinvent.one/images/talks/pdfs/2026.inquirex.pdf).
21
21
  >
@@ -60,6 +60,18 @@ end
60
60
 
61
61
  All core verbs (`ask`, `say`, `header`, `btw`, `warning`, `confirm`) and widget hints work alongside LLM verbs in the same `Inquirex.define` block.
62
62
 
63
+ ### Loading stored flows
64
+
65
+ Since inquirex 0.7.0, `Inquirex.load_dsl` validates source against a **default-deny** allowlist before evaluating it — a word nobody declared is a violation, not an oversight. Requiring this gem registers its own vocabulary (`extract`, `clarify`, `summarize`, and the methods legal inside their blocks), so a flow stored in a database or authored in a visual builder loads normally:
66
+
67
+ ```ruby
68
+ Inquirex.load_dsl(customer.flow_dsl) # validates, then evaluates
69
+ ```
70
+
71
+ `fallback` is the one deliberate exception: it takes a Ruby block, which is exactly what static validation cannot vet, so it is excluded from the allowlist rather than permitted. Flows that need it must be loaded from your own source with `unsafe: true`.
72
+
73
+ Registration is skipped on inquirex versions predating the allowlist, so the gem still works against them.
74
+
63
75
  ## Currently Supported LLM Verbs
64
76
 
65
77
  ### `extract` (alias: `clarify`)
@@ -78,12 +90,61 @@ extract :business_extracted do
78
90
  end
79
91
  ```
80
92
 
93
+ ### `summarize`
94
+
95
+ Closes a flow with a multi-paragraph prose summary of the whole session, written for the user to read and keep.
96
+
97
+ ```ruby
98
+ Inquirex.define id: "depreciation-help" do
99
+ start :intro
100
+
101
+ say(:intro) { text "Depreciation spreads an asset's cost over its useful life."; transition to: :asset }
102
+ ask(:asset) { type :enum; question "What kind of asset?"; options vehicle: "A vehicle", building: "A building" }
103
+
104
+ summarize :wrap_up do
105
+ temperature 0.4
106
+ end
107
+ end
108
+ ```
109
+
110
+ Two things make it different from every other verb.
111
+
112
+ **It reads the transcript, not the answers.** The core gem's [text accumulators](https://github.com/inquirex/inquirex#text-accumulators-the-session-transcript) record everything the user was shown and every answer they gave. That is what `summarize` is given. It matters because a flow that mostly *explains* things — a help widget, an explainer — collects almost no answers, so a summary built from the answers hash would have nothing to say. Declaring `summarize` adds a `:transcript` accumulator automatically if the flow declares no text accumulator of its own.
113
+
114
+ **The gem owns the prompt.** `Inquirex::LLM::Prompts::SUMMARIZE` is a constant, and adapters read it from there rather than from `node.prompt`. A flow author who could replace it could make the summary assert things the session never established, over a signature that still reads as the application's own words — so `prompt` is rejected at definition time rather than ignored at runtime, and a hand-forged or tampered node cannot substitute its own instructions either.
115
+
116
+ The prompt constrains the model to what the transcript establishes, and to the markdown subset a renderer can lay out and print: headings, lists, blockquotes, emphasis, inline code, and fenced code blocks.
117
+
118
+ #### What it refuses, and why
119
+
120
+ | Rejected | Because |
121
+ | ------------------------------------------- | -------------------------------------------- |
122
+ | `prompt` | its prompt is owned by the gem |
123
+ | `schema` | it returns prose, not fields |
124
+ | `from` / `from_all` | it always reads the whole session transcript |
125
+ | a transition, or any step declared after it | it must be the last step in the flow |
126
+ | a second `summarize` step | a flow may close with only one |
127
+
128
+ Each raises `Errors::DefinitionError` naming the reason.
129
+
130
+ `temperature`, `model`, and `max_tokens` remain available: they change how the summary is generated, not what it is allowed to say.
131
+
132
+ #### Calling it
133
+
134
+ `summarize` uses its own adapter method, because nothing about the call resembles an extraction — the prompt is the gem's, the input is the transcript, and the result is a markdown `String` rather than a schema-shaped `Hash`:
135
+
136
+ ```ruby
137
+ adapter = Inquirex::LLM::AnthropicAdapter.new(api_key: ENV["ANTHROPIC_API_KEY"])
138
+ markdown = adapter.summarize(engine.current_step, engine.text(:transcript))
139
+ ```
140
+
141
+ An adapter that implements only `#call` keeps working for `extract` and raises `NotImplementedError` the first time a flow asks it to summarize — loudly, rather than returning something that looks like a summary and is not. An empty transcript raises `Errors::AdapterError` rather than asking a model to invent one.
142
+
143
+ The [inquirex-widget](https://github.com/inquirex/inquirex-widget) renders the result with **Close** and **Print** buttons; markdown is sanitized against a strict element allowlist before it reaches the page.
144
+
81
145
  ## Schema: Question References (preferred)
82
146
 
83
- Most extract schemas exist to pre-fill questions asked later in the same flow. Declaring
84
- those fields twice — once in the schema, once in the question — invites drift, and worse:
85
- a hand-typed `income_types: :multi_enum` gives the LLM no idea which values are legal, so
86
- its answers won't match the question's options.
147
+ Most extract schemas exist to pre-fill questions asked later in the same flow. Declaring those fields twice — once in the schema, once in the question — invites drift, and worse: a hand-typed `income_types: :multi_enum` gives the LLM no idea which values are legal, so its answers won't match the question's options.
87
148
 
88
149
  Instead, pass the schema as a list of question ids:
89
150
 
@@ -104,16 +165,9 @@ end
104
165
  # ...
105
166
  ```
106
167
 
107
- Each symbol is resolved against the flow at definition time — references may point
108
- **forward** to questions defined after the extract step. The gem looks up the question's
109
- declared type, and for `:enum` / `:multi_enum` questions folds the exhaustive list of
110
- allowed option values into the JSON schema sent to the LLM. The adapters then instruct
111
- the model to answer using only those values, so extracted answers always match the
112
- downstream question's options (and `Engine#prefill!` can skip the question).
168
+ Each symbol is resolved against the flow at definition time — references may point **forward** to questions defined after the extract step. The gem looks up the question's declared type, and for `:enum` / `:multi_enum` questions folds the exhaustive list of allowed option values into the JSON schema sent to the LLM. The adapters then instruct the model to answer using only those values, so extracted answers always match the downstream question's options (and `Engine#prefill!` can skip the question).
113
169
 
114
- A symbol that matches no `ask`/`confirm` step in the flow fails validation with
115
- `Inquirex::LLM::Errors::DefinitionError` — as do references to display-only steps and
116
- other LLM steps.
170
+ A symbol that matches no `ask`/`confirm` step in the flow fails validation with `Inquirex::LLM::Errors::DefinitionError` — as do references to display-only steps and other LLM steps.
117
171
 
118
172
  Both forms compose. Use keywords for output fields that have no corresponding question:
119
173
 
@@ -123,9 +177,7 @@ schema :filing_status, :income_types, confidence: :decimal
123
177
 
124
178
  ### `prompt :auto`
125
179
 
126
- When the schema is built from question references, the schema already tells the LLM the
127
- field names, types, and allowed values — the main thing a hand-written prompt still adds
128
- is the questions' own wording. `prompt :auto` generates exactly that at definition time:
180
+ When the schema is built from question references, the schema already tells the LLM the field names, types, and allowed values — the main thing a hand-written prompt still adds is the questions' own wording. `prompt :auto` generates exactly that at definition time:
129
181
 
130
182
  ```ruby
131
183
  extract :extracted do
@@ -136,31 +188,27 @@ extract :extracted do
136
188
  end
137
189
  ```
138
190
 
139
- The generated prompt enumerates each referenced question's text ("- filing_status: What
140
- is your filing status for 2025?" …), lists explicit keyword fields by name and type, and
141
- instructs the model to leave unsupported fields empty. Generation happens at build time,
142
- so the wire format and adapters always see a concrete prompt string — `:auto` never
143
- leaves the DSL. It requires at least one question reference; with only explicit
144
- `key: :type` fields there is no question wording to generate from, and validation fails.
191
+ The generated prompt enumerates each referenced question's text ("- filing_status: What is your filing status for 2025?" …), lists explicit keyword fields by name and type, and instructs the model to leave unsupported fields empty. Generation happens at build time, so the wire format and adapters always see a concrete prompt string — `:auto` never leaves the DSL. It requires at least one question reference; with only explicit `key: :type` fields there is no question wording to generate from, and validation fails.
145
192
 
146
- Write the prompt by hand when you need domain framing the questions don't carry
147
- ("for tax filing purposes", "map S-Corp to s_corp") — an explicit prompt always wins.
193
+ Write the prompt by hand when you need domain framing the questions don't carry ("for tax filing purposes", "map S-Corp to s_corp") — an explicit prompt always wins.
148
194
 
149
195
  ## DSL Methods (inside LLM verb blocks)
150
196
 
151
- | Method | Purpose | Required |
152
- | ------------------------------- | ---------------------------------------------------- | ----------------------------- |
153
- | `prompt "..."` / `prompt :auto` | LLM prompt template, or generated from question refs | Always |
154
- | `schema :question_id, ...` | Fields resolved from questions (types + options) | `extract` (this or keywords) |
155
- | `schema key: :type, ...` | Explicit field => type pairs | `extract` (this or refs) |
156
- | `from :step_id` | Source step(s) whose answers feed the LLM | `extract` (or use `from_all`) |
157
- | `from_all` | Pass all collected answers to the LLM | Alternative to `from` |
158
- | `model :claude_sonnet` | Optional model hint for the adapter | No |
159
- | `temperature 0.3` | Optional sampling temperature | No |
160
- | `max_tokens 1024` | Optional max output tokens | No |
161
- | `fallback { \|answers\| ... }` | Server-side fallback (stripped from JSON) | No |
162
- | `transition to: :step` | Conditional transition (same as core) | No |
163
- | `skip_if rule` | Skip step when condition is true | No |
197
+ | Method | Purpose | Required |
198
+ | ------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------- |
199
+ | `prompt "..."` / `prompt :auto` | LLM prompt template, or generated from question refs | `extract`; **rejected** on `summarize` |
200
+ | `schema :question_id, ...` | Fields resolved from questions (types + options) | `extract` (this or keywords); **rejected** on `summarize` |
201
+ | `schema key: :type, ...` | Explicit field => type pairs | `extract` (this or refs); **rejected** on `summarize` |
202
+ | `from :step_id` | Source step(s) whose answers feed the LLM | `extract` (or use `from_all`); **rejected** on `summarize` |
203
+ | `from_all` | Pass all collected answers to the LLM | Alternative to `from`; **rejected** on `summarize` |
204
+ | `model :claude_sonnet` | Optional model hint for the adapter | No |
205
+ | `temperature 0.3` | Optional sampling temperature | No |
206
+ | `max_tokens 1024` | Optional max output tokens | No |
207
+ | `fallback { \|answers\| ... }` | Server-side fallback (stripped from JSON) | No |
208
+ | `transition to: :step` | Conditional transition (same as core) | No; **rejected** on `summarize` |
209
+ | `skip_if rule` | Skip step when condition is true | No |
210
+
211
+ `summarize` rejects the content-shaping methods rather than ignoring them — see [`summarize`](#summarize) for each refusal and its reason.
164
212
 
165
213
  ## Engine Integration
166
214
 
@@ -307,10 +355,7 @@ LLM steps serialize with `"requires_server": true` so the JS widget knows to rou
307
355
  }
308
356
  ```
309
357
 
310
- Unconstrained fields serialize as a plain type string; fields resolved from `:enum` /
311
- `:multi_enum` questions serialize as `{ "type": ..., "values": [...] }` so any consumer
312
- (the JS widget, a server adapter) sees the full contract. Fallback procs are stripped
313
- from JSON (server-side only).
358
+ Unconstrained fields serialize as a plain type string; fields resolved from `:enum` / `:multi_enum` questions serialize as `{ "type": ..., "values": [...] }` so any consumer (the JS widget, a server adapter) sees the full contract. Fallback procs are stripped from JSON (server-side only).
314
359
 
315
360
  ## Custom Adapter
316
361
 
@@ -349,18 +394,6 @@ describe :business_narrative do
349
394
  end
350
395
  ```
351
396
 
352
- ### `summarize`
353
-
354
- Produce a summary of all or selected answers. Use `from_all` to pass everything, or `from` to select specific steps.
355
-
356
- ```ruby
357
- summarize :intake_summary do
358
- from_all
359
- prompt "Summarize this client's tax situation."
360
- transition to: :review
361
- end
362
- ```
363
-
364
397
  ### `detour` (parked)
365
398
 
366
399
  Dynamically generate follow-up questions based on an answer. The server adapter handles presenting the generated questions and collecting responses. Requires `from`, `prompt`, and `schema`.
@@ -374,8 +407,6 @@ detour :followup do
374
407
  end
375
408
  ```
376
409
 
377
- ##
378
-
379
410
  ## Development
380
411
 
381
412
  ```bash
@@ -35,6 +35,38 @@ module Inquirex
35
35
  raise NotImplementedError, "#{self.class}#call must be implemented"
36
36
  end
37
37
 
38
+ # Produces the closing prose summary for a `summarize` step.
39
+ #
40
+ # Separate from {#call} because nothing about it is the same: the prompt
41
+ # is the gem's rather than the node's, the input is the session
42
+ # transcript rather than selected answers, and the result is markdown
43
+ # rather than a schema-shaped Hash. Keeping it apart also means an
44
+ # existing custom adapter that only implements #call keeps working for
45
+ # `extract` and fails loudly — rather than subtly — if a flow starts
46
+ # asking it to summarise.
47
+ #
48
+ # @param node [LLM::Node] the summarize step
49
+ # @param transcript [String] everything the user was shown and answered
50
+ # @param answers [Hash] collected answers, for adapters that want them
51
+ # @return [String] markdown summary
52
+ # @raise [Errors::AdapterError] if the LLM call fails
53
+ def summarize(node, transcript, answers = {})
54
+ raise NotImplementedError, "#{self.class}#summarize must be implemented"
55
+ end
56
+
57
+ # The user-side prompt for a summarize call: the transcript, and nothing
58
+ # else that could compete with it for the model's attention.
59
+ #
60
+ # @param transcript [String]
61
+ # @return [String]
62
+ # @raise [Errors::AdapterError] when there is nothing to summarise
63
+ def summary_input(transcript)
64
+ text = transcript.to_s.strip
65
+ raise Errors::AdapterError, "Cannot summarize an empty transcript" if text.empty?
66
+
67
+ "Here is the session transcript.\n\n#{text}"
68
+ end
69
+
38
70
  # Gathers the source answer data that feeds the LLM prompt.
39
71
  #
40
72
  # @param node [LLM::Node]
@@ -65,8 +97,51 @@ module Inquirex
65
97
  "LLM output for #{node.id.inspect} missing fields: #{missing.join(", ")}"
66
98
  end
67
99
 
100
+ # Canonicalizes LLM output against the schema's value constraints — the
101
+ # regression guard for "the model answered with a label or a case
102
+ # variant". Every value-constrained field is matched against the
103
+ # allowed form values: an exact match passes through, a
104
+ # case-insensitive match is rewritten to the canonical value, and a
105
+ # value outside the list becomes nil (enum) or is dropped from the
106
+ # array (multi_enum) — "unknown, will ask" instead of junk that
107
+ # prefills the wrong option downstream. Unconstrained fields are
108
+ # untouched.
109
+ #
110
+ # @param node [LLM::Node]
111
+ # @param output [Hash, Object] parsed LLM response
112
+ # @return [Hash, Object] output with constrained fields canonicalized
113
+ def normalize_output(node, output)
114
+ schema = node.respond_to?(:schema) ? node.schema : nil
115
+ return output unless schema && output.is_a?(Hash)
116
+
117
+ output.to_h do |key, raw|
118
+ values = schema.values_for(key)
119
+ next [key, raw] unless values
120
+
121
+ if schema.fields[key.to_sym] == :multi_enum
122
+ [key, Array(raw).filter_map { |entry| canonical_value(values, entry) }]
123
+ else
124
+ [key, canonical_value(values, raw)]
125
+ end
126
+ end
127
+ end
128
+
68
129
  protected
69
130
 
131
+ # The canonical form value for a raw LLM answer, or nil when the answer
132
+ # is outside the allowed list (after trimming and case-folding).
133
+ #
134
+ # @param values [Array<String>] allowed form values
135
+ # @param raw [Object] one LLM-provided value
136
+ # @return [String, nil]
137
+ def canonical_value(values, raw)
138
+ return nil if raw.nil?
139
+
140
+ candidate = raw.to_s.strip
141
+ values.find { |value| value == candidate } ||
142
+ values.find { |value| value.casecmp?(candidate) }
143
+ end
144
+
70
145
  # The schema as a JSON contract for the system prompt: enum-constrained
71
146
  # fields render as { "type": ..., "values": [...] } so the model knows
72
147
  # the exhaustive list of allowed answers.
@@ -29,6 +29,12 @@ module Inquirex
29
29
  DEFAULT_MODEL = "claude-sonnet-4-20250514"
30
30
  DEFAULT_MAX_TOKENS = 2048
31
31
 
32
+ # Summaries want a little more room than an extraction (prose, not a
33
+ # handful of fields) and a little more warmth than one (0.2 produces
34
+ # summaries that read like a database dump).
35
+ DEFAULT_SUMMARY_MAX_TOKENS = 4096
36
+ DEFAULT_SUMMARY_TEMPERATURE = 0.4
37
+
32
38
  # Maps Inquirex short model symbols to concrete Anthropic model ids.
33
39
  MODEL_MAP = {
34
40
  claude_sonnet: "claude-sonnet-4-20250514",
@@ -65,11 +71,34 @@ module Inquirex
65
71
  max_tokens: max_tokens
66
72
  )
67
73
 
68
- result = parse_response(response)
74
+ result = normalize_output(node, parse_response(response))
69
75
  validate_output!(node, result)
70
76
  result
71
77
  end
72
78
 
79
+ # Generates the closing summary for a `summarize` step.
80
+ #
81
+ # The system prompt is {Prompts::SUMMARIZE} — the node's own prompt is
82
+ # that same constant, but reading it from the constant means a node
83
+ # forged by hand or deserialized from a tampered definition still cannot
84
+ # substitute its own instructions.
85
+ #
86
+ # @param node [Inquirex::LLM::Node] the summarize step
87
+ # @param transcript [String] the session transcript
88
+ # @param answers [Hash] unused; accepted for interface symmetry
89
+ # @return [String] markdown summary
90
+ # @raise [Errors::AdapterError] on API / parse failures
91
+ def summarize(node, transcript, _answers = {})
92
+ response = call_api(
93
+ model: resolve_model(node),
94
+ system: Prompts::SUMMARIZE,
95
+ user: summary_input(transcript),
96
+ temperature: node.respond_to?(:temperature) ? (node.temperature || DEFAULT_SUMMARY_TEMPERATURE) : DEFAULT_SUMMARY_TEMPERATURE,
97
+ max_tokens: node.respond_to?(:max_tokens) ? (node.max_tokens || DEFAULT_SUMMARY_MAX_TOKENS) : DEFAULT_SUMMARY_MAX_TOKENS
98
+ )
99
+ parse_text_response(response)
100
+ end
101
+
73
102
  private
74
103
 
75
104
  def resolve_model(node)
@@ -147,12 +176,34 @@ module Inquirex
147
176
  JSON.parse(response.body)
148
177
  end
149
178
 
150
- def parse_response(api_response)
179
+ # The response's text content, with the model's habitual wrapping fence
180
+ # removed. Unlike {#parse_response} it stays a String: a summary is
181
+ # markdown for a human, not JSON for the engine.
182
+ #
183
+ # @param api_response [Hash]
184
+ # @return [String]
185
+ # @raise [Errors::AdapterError] when the response carries no text, or
186
+ # the model returned nothing usable
187
+ def parse_text_response(api_response)
188
+ text = response_text(api_response)
189
+ text = text.gsub(/\A```(?:markdown|md)?\s*\n?/, "").gsub(/\n?```\s*\z/, "").strip
190
+ raise Errors::AdapterError, "Anthropic returned an empty summary" if text.empty?
191
+
192
+ text
193
+ end
194
+
195
+ # @return [String] the first text block's content
196
+ # @raise [Errors::AdapterError] when there is none
197
+ def response_text(api_response)
151
198
  content = api_response["content"]
152
199
  text_block = content.is_a?(Array) ? content.find { |c| c["type"] == "text" } : nil
153
200
  raise Errors::AdapterError, "No text content in Anthropic response" unless text_block
154
201
 
155
- raw_text = text_block["text"].to_s.strip
202
+ text_block["text"].to_s.strip
203
+ end
204
+
205
+ def parse_response(api_response)
206
+ raw_text = response_text(api_response)
156
207
  raw_text = raw_text.gsub(/\A```(?:json)?\s*\n?/, "").gsub(/\n?```\s*\z/, "").strip
157
208
 
158
209
  parsed = JSON.parse(raw_text, symbolize_names: true)
@@ -15,6 +15,10 @@ module Inquirex
15
15
  # so that schema question references can resolve forward to questions
16
16
  # defined after the LLM step.
17
17
  module FlowBuilderExtension
18
+ # Accumulator added when a flow declares `summarize` without declaring
19
+ # anywhere for the narrative to accumulate.
20
+ TRANSCRIPT_ACCUMULATOR = :transcript
21
+
18
22
  # Defines an LLM extraction step: takes free-text input and produces
19
23
  # structured data matching the declared schema.
20
24
  #
@@ -33,13 +37,27 @@ module Inquirex
33
37
  # add_llm_step(id, :describe, &)
34
38
  # end
35
39
 
36
- # # Defines an LLM summarization step: takes all or selected answers and
37
- # # produces a textual summary.
38
- # #
39
- # # @param id [Symbol] step id
40
- # def summarize(id, &)
41
- # add_llm_step(id, :summarize, &)
42
- # end
40
+ # Closes the flow with a prose summary of the whole session.
41
+ #
42
+ # Takes no prompt (the gem owns it — see {Prompts::SUMMARIZE}), no
43
+ # schema, and no `from`: its input is always the session transcript,
44
+ # and its output is markdown for the user to read, keep, and print.
45
+ # It must be the last step declared in the flow, and a flow may
46
+ # declare only one.
47
+ #
48
+ # Declaring it also guarantees the flow has somewhere to summarise
49
+ # *from*: if no `:text` accumulator was declared, a `:transcript` one
50
+ # is added automatically.
51
+ #
52
+ # @example
53
+ # summarize :wrap_up do
54
+ # temperature 0.4
55
+ # end
56
+ #
57
+ # @param id [Symbol] step id
58
+ def summarize(id, &)
59
+ add_llm_step(id, :summarize, &)
60
+ end
43
61
 
44
62
  # # Defines an LLM detour step: based on an answer, dynamically generates
45
63
  # # follow-up questions. The server adapter handles presenting the generated
@@ -53,12 +71,67 @@ module Inquirex
53
71
  # Builds any deferred LLM steps (now that the full node map exists),
54
72
  # then produces the frozen Definition via the core builder.
55
73
  def build
74
+ validate_summarize_placement!
75
+ ensure_transcript_accumulator!
56
76
  resolve_llm_steps!
57
77
  super
58
78
  end
59
79
 
60
80
  private
61
81
 
82
+ # @return [Array<Symbol>] ids of the flow's summarize steps, in
83
+ # declaration order
84
+ def summarize_step_ids
85
+ @nodes.filter_map { |id, entry| id if summarize_entry?(entry) }
86
+ end
87
+
88
+ # True for a parked LlmStepBuilder or an already-built node whose verb
89
+ # is :summarize.
90
+ def summarize_entry?(entry)
91
+ entry.respond_to?(:verb) ? entry.verb.to_sym == :summarize : entry.instance_variable_get(:@verb) == :summarize
92
+ end
93
+
94
+ # `summarize` closes the flow, so it has to be the last step declared
95
+ # and there can only be one. Checked here rather than in the step
96
+ # builder because only the flow builder knows the declaration order —
97
+ # the step builder sees one step at a time.
98
+ #
99
+ # @raise [Errors::DefinitionError]
100
+ def validate_summarize_placement!
101
+ ids = summarize_step_ids
102
+ return if ids.empty?
103
+
104
+ if ids.length > 1
105
+ raise Errors::DefinitionError,
106
+ "flow declares #{ids.length} summarize steps (#{ids.map(&:inspect).join(", ")}) — " \
107
+ "a flow may close with only one"
108
+ end
109
+
110
+ last_id = @nodes.keys.last
111
+ return if ids.first == last_id
112
+
113
+ raise Errors::DefinitionError,
114
+ "summarize step #{ids.first.inspect} is followed by #{last_id.inspect} — " \
115
+ "summarize must be the last step declared in the flow"
116
+ end
117
+
118
+ # Guarantees a `summarize` flow has a narrative to summarise. A flow
119
+ # that mostly explains things collects almost no answers, so without a
120
+ # text accumulator there would be nothing to send the model but an
121
+ # empty hash.
122
+ def ensure_transcript_accumulator!
123
+ return if summarize_step_ids.empty?
124
+ return if @accumulators.any? { |_, acc| text_accumulator?(acc) }
125
+
126
+ accumulator(TRANSCRIPT_ACCUMULATOR, type: :text)
127
+ end
128
+
129
+ # `Accumulator#text?` arrived with text accumulators; fall back to the
130
+ # type for the older core versions this gem still supports.
131
+ def text_accumulator?(acc)
132
+ acc.respond_to?(:text?) ? acc.text? : acc.type.to_sym == :text
133
+ end
134
+
62
135
  # Evaluates the step block immediately (same as core FlowBuilder#add_step)
63
136
  # but parks the builder in the node map instead of building the node.
64
137
  # The builder placeholder holds this step's position; #build replaces it.
@@ -167,7 +167,7 @@ module Inquirex
167
167
 
168
168
  field_map = resolve_schema_refs(id, nodes).merge(@schema_fields)
169
169
  schema_obj = field_map.empty? ? nil : Schema.new(**field_map)
170
- prompt_text = @prompt == :auto ? auto_prompt(nodes) : @prompt
170
+ prompt_text = resolve_prompt(nodes)
171
171
 
172
172
  LLM::Node.new(
173
173
  id:,
@@ -189,6 +189,19 @@ module Inquirex
189
189
 
190
190
  private
191
191
 
192
+ # The prompt this step will carry on the wire. `summarize` takes the
193
+ # gem's own text and never the author's; `:auto` is expanded here so
194
+ # that adapters only ever see a concrete string.
195
+ #
196
+ # @param nodes [Hash{Symbol => Inquirex::Node}, nil]
197
+ # @return [String]
198
+ def resolve_prompt(nodes)
199
+ return Prompts::SUMMARIZE if @verb == :summarize
200
+ return auto_prompt(nodes) if @prompt == :auto
201
+
202
+ @prompt
203
+ end
204
+
192
205
  # Turns question references into full field specs by looking up each
193
206
  # referenced step in the flow: its declared type, and for enum-like
194
207
  # types the exhaustive list of allowed option values.
@@ -271,6 +284,8 @@ module Inquirex
271
284
  end
272
285
 
273
286
  def validate!(id)
287
+ return validate_summarize!(id) if @verb == :summarize
288
+
274
289
  raise Errors::DefinitionError, "LLM step #{id.inspect} requires a prompt" if @prompt.nil?
275
290
 
276
291
  if @prompt == :auto && @schema_refs.empty?
@@ -297,6 +312,39 @@ module Inquirex
297
312
  raise Errors::DefinitionError,
298
313
  "LLM step #{id.inspect} (#{@verb}) requires `from` or `from_all`"
299
314
  end
315
+
316
+ # `summarize` is defined by what it refuses. Its prompt is the gem's
317
+ # ({Prompts::SUMMARIZE}), its input is always the whole session
318
+ # transcript, its output is prose rather than fields, and it ends the
319
+ # flow. Every setter below would contradict one of those, so each is
320
+ # rejected at definition time with the reason rather than ignored at
321
+ # runtime.
322
+ #
323
+ # `temperature`, `model`, and `max_tokens` stay available: they change
324
+ # how the summary is generated, not what it is allowed to say.
325
+ def validate_summarize!(id)
326
+ reject_summarize!(id, "declares a prompt", "its prompt is owned by the gem") unless @prompt.nil?
327
+
328
+ unless @schema_fields.empty? && @schema_refs.empty?
329
+ reject_summarize!(id, "declares a schema", "it returns prose, not fields")
330
+ end
331
+
332
+ unless @from_steps.empty? && !@from_all
333
+ reject_summarize!(id,
334
+ "declares `from`/`from_all`",
335
+ "it always reads the whole session transcript")
336
+ end
337
+
338
+ return if @transitions.empty?
339
+
340
+ reject_summarize!(id, "declares a transition", "it must be the last step in the flow")
341
+ end
342
+
343
+ # @raise [Errors::DefinitionError]
344
+ def reject_summarize!(id, what, why)
345
+ raise Errors::DefinitionError,
346
+ "summarize step #{id.inspect} #{what}, which is not allowed — #{why}"
347
+ end
300
348
  end
301
349
  end
302
350
  end
@@ -8,10 +8,14 @@ module Inquirex
8
8
  #
9
9
  # LLM verbs:
10
10
  # :extract — extract structured data from a free-text answer
11
+ # :summarize — close the flow with a prose summary of the whole session
11
12
  # # :describe — generate natural-language text from structured data
12
- # # :summarize — produce a summary of all or selected answers
13
13
  # # :detour — dynamically generate follow-up questions based on an answer
14
14
  #
15
+ # `summarize` is the one verb whose prompt the gem owns ({Prompts::SUMMARIZE})
16
+ # and whose answer is markdown prose rather than structured data. It must be
17
+ # the last step in a flow and carries no schema and no transitions.
18
+ #
15
19
  # All LLM nodes are collecting (they produce answers) and require server
16
20
  # round-trips. The frontend shows a "thinking" state while the server processes.
17
21
  #
@@ -24,9 +28,13 @@ module Inquirex
24
28
  # @attr_reader max_tokens [Integer, nil] optional max output tokens
25
29
  # @attr_reader fallback [Proc, nil] server-side fallback (stripped from JSON)
26
30
  class Node < Inquirex::Node
27
- LLM_VERBS = %i[extract].freeze
31
+ LLM_VERBS = %i[extract summarize].freeze
28
32
  # LLM_VERBS = %i[extract describe summarize detour].freeze
29
33
 
34
+ # Verbs whose result is prose for the user to read, not data for the
35
+ # engine to store against other steps.
36
+ NARRATIVE_VERBS = %i[summarize].freeze
37
+
30
38
  attr_reader :prompt,
31
39
  :schema,
32
40
  :from_steps,
@@ -59,6 +67,13 @@ module Inquirex
59
67
  # Whether this is an LLM-powered step requiring server processing.
60
68
  def llm_verb? = true
61
69
 
70
+ # Whether this step closes the flow with prose for the user to read.
71
+ # Narrative steps carry no schema and no transitions.
72
+ def narrative? = NARRATIVE_VERBS.include?(@verb)
73
+
74
+ # Whether this is the `summarize` step.
75
+ def summarize? = @verb == :summarize
76
+
62
77
  # Serializes to a plain Hash. LLM metadata is nested under "llm".
63
78
  # Fallback procs are stripped (server-side only).
64
79
  # All transitions are marked requires_server: true.
@@ -47,6 +47,25 @@ module Inquirex
47
47
  end
48
48
  end
49
49
 
50
+ # Placeholder summary, in the markdown shape a real summary takes — so a
51
+ # renderer wired against this adapter exercises its heading, list, and
52
+ # paragraph handling rather than one unbroken line.
53
+ #
54
+ # @param node [LLM::Node] the summarize step
55
+ # @param transcript [String] the session transcript
56
+ # @param _answers [Hash] ignored
57
+ # @return [String] markdown
58
+ def summarize(node, transcript, _answers = {})
59
+ <<~MARKDOWN.strip
60
+ This is a placeholder summary for #{node.id}. No language model was called.
61
+
62
+ ## What the session contained
63
+
64
+ - A transcript of #{transcript.to_s.strip.length} characters.
65
+ - Nothing else worth reporting, because this adapter invents nothing.
66
+ MARKDOWN
67
+ end
68
+
50
69
  private
51
70
 
52
71
  def placeholder_for(schema, name, type)
@@ -24,6 +24,11 @@ module Inquirex
24
24
  DEFAULT_MODEL = "gpt-4o-mini"
25
25
  DEFAULT_MAX_TOKENS = 2048
26
26
 
27
+ # Summaries want more room than an extraction (prose, not a handful of
28
+ # fields) and more warmth than one (0.2 reads like a database dump).
29
+ DEFAULT_SUMMARY_MAX_TOKENS = 4096
30
+ DEFAULT_SUMMARY_TEMPERATURE = 0.4
31
+
27
32
  # Maps Inquirex DSL model symbols to concrete OpenAI model ids. Accepts
28
33
  # Claude symbols too — we substitute sensible OpenAI equivalents so flow
29
34
  # definitions written against Anthropic still run against this adapter.
@@ -66,11 +71,33 @@ module Inquirex
66
71
  max_tokens: max_tokens
67
72
  )
68
73
 
69
- result = parse_response(response)
74
+ result = normalize_output(node, parse_response(response))
70
75
  validate_output!(node, result)
71
76
  result
72
77
  end
73
78
 
79
+ # Generates the closing summary for a `summarize` step.
80
+ #
81
+ # The system prompt is read from {Prompts::SUMMARIZE} rather than from
82
+ # the node, so a hand-forged or tampered node cannot substitute its own
83
+ # instructions.
84
+ #
85
+ # @param node [Inquirex::LLM::Node] the summarize step
86
+ # @param transcript [String] the session transcript
87
+ # @param answers [Hash] unused; accepted for interface symmetry
88
+ # @return [String] markdown summary
89
+ # @raise [Errors::AdapterError] on API / parse failures
90
+ def summarize(node, transcript, _answers = {})
91
+ response = call_api(
92
+ model: resolve_model(node),
93
+ system: Prompts::SUMMARIZE,
94
+ user: summary_input(transcript),
95
+ temperature: node.respond_to?(:temperature) ? (node.temperature || DEFAULT_SUMMARY_TEMPERATURE) : DEFAULT_SUMMARY_TEMPERATURE,
96
+ max_tokens: node.respond_to?(:max_tokens) ? (node.max_tokens || DEFAULT_SUMMARY_MAX_TOKENS) : DEFAULT_SUMMARY_MAX_TOKENS
97
+ )
98
+ parse_text_response(response)
99
+ end
100
+
74
101
  private
75
102
 
76
103
  def resolve_model(node)
@@ -152,13 +179,35 @@ module Inquirex
152
179
  JSON.parse(response.body)
153
180
  end
154
181
 
155
- def parse_response(api_response)
182
+ # The response's message content, with the model's habitual wrapping
183
+ # fence removed. Unlike {#parse_response} it stays a String: a summary
184
+ # is markdown for a human, not JSON for the engine.
185
+ #
186
+ # @param api_response [Hash]
187
+ # @return [String]
188
+ # @raise [Errors::AdapterError] when the response carries no content, or
189
+ # the model returned nothing usable
190
+ def parse_text_response(api_response)
191
+ text = response_text(api_response)
192
+ text = text.gsub(/\A```(?:markdown|md)?\s*\n?/, "").gsub(/\n?```\s*\z/, "").strip
193
+ raise Errors::AdapterError, "OpenAI returned an empty summary" if text.empty?
194
+
195
+ text
196
+ end
197
+
198
+ # @return [String] the first choice's message content
199
+ # @raise [Errors::AdapterError] when there is none
200
+ def response_text(api_response)
156
201
  choices = api_response["choices"]
157
202
  message = choices.is_a?(Array) ? choices.first&.dig("message") : nil
158
203
  raw_text = message&.dig("content")
159
204
  raise Errors::AdapterError, "No message content in OpenAI response" unless raw_text
160
205
 
161
- raw_text = raw_text.to_s.strip
206
+ raw_text.to_s.strip
207
+ end
208
+
209
+ def parse_response(api_response)
210
+ raw_text = response_text(api_response)
162
211
  raw_text = raw_text.gsub(/\A```(?:json)?\s*\n?/, "").gsub(/\n?```\s*\z/, "").strip
163
212
 
164
213
  parsed = JSON.parse(raw_text, symbolize_names: true)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module LLM
5
+ # Prompts the gem owns outright.
6
+ #
7
+ # `extract` takes its prompt from the flow author, because only the author
8
+ # knows what they are extracting. `summarize` does not: its job is fixed,
9
+ # its input is always the session transcript, and its output has to be
10
+ # markdown a renderer can lay out and print. A flow author who could
11
+ # replace this text could make the summary say anything — including things
12
+ # the session never established — over a signature that still reads as the
13
+ # application's own summary. So the flow author gets `temperature`, and the
14
+ # gem keeps the words.
15
+ module Prompts
16
+ # System prompt for the `summarize` verb.
17
+ #
18
+ # Constrains the model to the transcript it is given (the anti-invention
19
+ # rule is the load-bearing line — a summary that adds plausible detail is
20
+ # worse than one that omits it), and to the markdown subset the widget
21
+ # renders and prints.
22
+ SUMMARIZE = <<~PROMPT
23
+ You are writing the closing summary for a completed questionnaire session.
24
+
25
+ You will be given a transcript: everything the user was shown, and every
26
+ question they were asked with the answer they gave, in order.
27
+
28
+ Write a well-organised, multi-paragraph summary of that session for the
29
+ user to read and keep.
30
+
31
+ Rules:
32
+ - Use ONLY what the transcript establishes. Never introduce a fact,
33
+ figure, name, date, or recommendation that is not in it. If something
34
+ is unclear or was skipped, say so plainly or leave it out — do not
35
+ guess, and do not fill gaps with what is usually true.
36
+ - Address the user directly as "you". Do not refer to "the transcript",
37
+ "the session", or "the form".
38
+ - Open with a short paragraph stating what was covered. Then organise
39
+ the substance under headings. Close with next steps only if the
40
+ transcript actually indicates any.
41
+ - Where the session mostly explained things rather than collecting
42
+ answers, summarise the explanation — that is the substance.
43
+
44
+ Format your answer as GitHub-flavoured Markdown, using only:
45
+ headings (##, ###), paragraphs, bullet and numbered lists, blockquotes,
46
+ bold and italic, inline code, and fenced code blocks with a language tag.
47
+ Do not wrap the whole answer in a code fence. Do not include HTML.
48
+ Do not add a title heading; start with the opening paragraph.
49
+ PROMPT
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module LLM
5
+ # Teaches {Inquirex::SafeSource}'s allowlist about this gem's verbs.
6
+ #
7
+ # Since inquirex 0.7.0, `Inquirex.load_dsl` validates source against
8
+ # {Inquirex::SafeSource::Vocabulary} before evaluating it, and the
9
+ # vocabulary is default-deny: a word nobody declared is a violation. Every
10
+ # word this gem adds to the DSL therefore has to be registered, or a
11
+ # stored flow using `extract` is rejected as unsafe — not because it is
12
+ # dangerous, but because the allowlist has never heard of it.
13
+ #
14
+ # Registration is guarded because the gem supports inquirex versions
15
+ # predating SafeSource. On those, there is no allowlist to teach and
16
+ # nothing to do.
17
+ module SafeSource
18
+ # Whether the core gem is new enough to have an allowlist.
19
+ #
20
+ # @return [Boolean]
21
+ def self.available?
22
+ defined?(Inquirex::SafeSource::Vocabulary) ? true : false
23
+ end
24
+
25
+ # Registers the LLM scope and verbs. Idempotent — `register_scope` and
26
+ # `allow` both overwrite rather than append, so loading the gem twice is
27
+ # harmless.
28
+ #
29
+ # @return [Boolean] false when the core gem has no allowlist
30
+ def self.install!
31
+ return false unless available?
32
+
33
+ vocabulary = Inquirex::SafeSource::Vocabulary
34
+ vocabulary.register_scope(
35
+ :llm_step,
36
+ label: "an LLM step",
37
+ vocabulary: -> { Inquirex::LLM::DSL::LlmStepBuilder.public_instance_methods(false) }
38
+ )
39
+
40
+ install_verbs!(vocabulary)
41
+ install_step_words!(vocabulary)
42
+ true
43
+ end
44
+
45
+ # The flow-level verbs: `extract`, its `clarify` alias, and `summarize`.
46
+ #
47
+ # @param vocabulary [Module]
48
+ # @return [void]
49
+ def self.install_verbs!(vocabulary)
50
+ %i[extract clarify summarize].each do |verb|
51
+ vocabulary.allow(:flow, verb, positional: %i[symbol], block: :llm_step)
52
+ end
53
+ end
54
+
55
+ # The words legal inside an LLM step block.
56
+ #
57
+ # `schema` takes a mix of positional question ids and keyword field
58
+ # types, so both are declared. `fallback` is excluded outright: it takes
59
+ # a Ruby block, and a block is exactly the thing static validation
60
+ # cannot vet.
61
+ #
62
+ # @param vocabulary [Module]
63
+ # @return [void]
64
+ def self.install_step_words!(vocabulary)
65
+ v = vocabulary
66
+ v.allow(:llm_step, :prompt, positional: %i[literal])
67
+ v.allow(:llm_step,
68
+ :schema,
69
+ positional: { repeat: :symbol },
70
+ keywords: { Inquirex::SafeSource::Vocabulary::ANY_OTHER => :symbol })
71
+ v.allow(:llm_step, :from, positional: { repeat: :symbol })
72
+ v.allow(:llm_step, :from_all, positional: { optional: :literal })
73
+ v.allow(:llm_step, :model, positional: %i[symbol])
74
+ v.allow(:llm_step, :temperature, positional: %i[literal])
75
+ v.allow(:llm_step, :max_tokens, positional: %i[literal])
76
+ v.allow(:llm_step, :question, positional: %i[string])
77
+ v.allow(:llm_step, :text, positional: %i[string])
78
+ v.allow(:llm_step, :skip_if, positional: %i[rule])
79
+ v.allow(:llm_step,
80
+ :transition,
81
+ keywords: Inquirex::SafeSource::Vocabulary::TRANSITION_KEYWORDS)
82
+ v.exclude(:llm_step, :fallback, "a Ruby block cannot be validated")
83
+ end
84
+ end
85
+ end
86
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Inquirex
4
4
  module LLM
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.4"
6
6
  end
7
7
  end
data/lib/inquirex/llm.rb CHANGED
@@ -5,6 +5,7 @@ require "json"
5
5
 
6
6
  require_relative "llm/version"
7
7
  require_relative "llm/errors"
8
+ require_relative "llm/prompts"
8
9
  require_relative "llm/schema"
9
10
  require_relative "llm/node"
10
11
  require_relative "llm/adapter"
@@ -13,14 +14,15 @@ require_relative "llm/anthropic_adapter"
13
14
  require_relative "llm/openai_adapter"
14
15
  require_relative "llm/dsl/llm_step_builder"
15
16
  require_relative "llm/dsl/flow_builder"
17
+ require_relative "llm/safe_source"
16
18
 
17
19
  module Inquirex
18
20
  # LLM integration layer for Inquirex flows.
19
21
  #
20
22
  # Extends the core DSL with LLM-powered verbs that run server-side:
21
23
  # - extract — extract structured data from free-text answers (`clarify` is an alias)
24
+ # - summarize — close the flow with a prose summary of the whole session
22
25
  # # - describe — generate natural-language text from structured data
23
- # # - summarize — produce a summary of all or selected answers
24
26
  # # - detour — dynamically generate follow-up questions
25
27
  #
26
28
  # LLM calls never happen on the frontend. Steps are marked `requires_server: true`
@@ -46,3 +48,8 @@ end
46
48
  # construction until the whole flow is known, so schema question references
47
49
  # can resolve forward to questions defined after the LLM step.
48
50
  Inquirex::DSL::FlowBuilder.prepend(Inquirex::LLM::DSL::FlowBuilderExtension)
51
+
52
+ # Teach SafeSource's default-deny allowlist about the verbs just added, so
53
+ # that `Inquirex.load_dsl` accepts a stored flow using them. A no-op on
54
+ # inquirex versions predating SafeSource.
55
+ Inquirex::LLM::SafeSource.install!
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inquirex-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -48,6 +48,8 @@ files:
48
48
  - lib/inquirex/llm/node.rb
49
49
  - lib/inquirex/llm/null_adapter.rb
50
50
  - lib/inquirex/llm/openai_adapter.rb
51
+ - lib/inquirex/llm/prompts.rb
52
+ - lib/inquirex/llm/safe_source.rb
51
53
  - lib/inquirex/llm/schema.rb
52
54
  - lib/inquirex/llm/version.rb
53
55
  homepage: https://github.com/inquirex/inquirex-llm