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 +4 -4
- data/README.md +89 -58
- data/lib/inquirex/llm/adapter.rb +75 -0
- data/lib/inquirex/llm/anthropic_adapter.rb +54 -3
- data/lib/inquirex/llm/dsl/flow_builder.rb +80 -7
- data/lib/inquirex/llm/dsl/llm_step_builder.rb +49 -1
- data/lib/inquirex/llm/node.rb +17 -2
- data/lib/inquirex/llm/null_adapter.rb +19 -0
- data/lib/inquirex/llm/openai_adapter.rb +52 -3
- data/lib/inquirex/llm/prompts.rb +52 -0
- data/lib/inquirex/llm/safe_source.rb +86 -0
- data/lib/inquirex/llm/version.rb +1 -1
- data/lib/inquirex/llm.rb +8 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2d7bb4e511826270c246b50b51b9305bfa5d117bf00a93a4cb64d6def3904697
|
|
4
|
+
data.tar.gz: 929c58c5fcb08cfd4210594d1cf34d1bb47b873e91f7b23e5215c6bca576b045
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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-
|
|
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 |
|
|
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
|
data/lib/inquirex/llm/adapter.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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 =
|
|
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
|
data/lib/inquirex/llm/node.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
data/lib/inquirex/llm/version.rb
CHANGED
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.
|
|
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
|