flowengine 0.1.1 → 0.2.1
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/.env.example +1 -0
- data/.envrc +4 -0
- data/.rubocop_todo.yml +3 -8
- data/README.md +151 -6
- data/Rakefile +1 -1
- data/docs/badges/coverage_badge.svg +21 -0
- data/justfile +11 -0
- data/lib/flowengine/definition.rb +17 -2
- data/lib/flowengine/dsl/flow_builder.rb +25 -1
- data/lib/flowengine/dsl/rule_helpers.rb +22 -0
- data/lib/flowengine/dsl/step_builder.rb +23 -0
- data/lib/flowengine/dsl.rb +2 -0
- data/lib/flowengine/engine.rb +68 -2
- data/lib/flowengine/errors.rb +17 -0
- data/lib/flowengine/evaluator.rb +9 -0
- data/lib/flowengine/graph/mermaid_exporter.rb +7 -0
- data/lib/flowengine/introduction.rb +14 -0
- data/lib/flowengine/llm/adapter.rb +19 -0
- data/lib/flowengine/llm/client.rb +75 -0
- data/lib/flowengine/llm/openai_adapter.rb +38 -0
- data/lib/flowengine/llm/sensitive_data_filter.rb +45 -0
- data/lib/flowengine/llm/system_prompt_builder.rb +73 -0
- data/lib/flowengine/llm.rb +14 -0
- data/lib/flowengine/node.rb +47 -2
- data/lib/flowengine/rules/all.rb +7 -0
- data/lib/flowengine/rules/any.rb +7 -0
- data/lib/flowengine/rules/base.rb +9 -0
- data/lib/flowengine/rules/contains.rb +10 -0
- data/lib/flowengine/rules/equals.rb +9 -0
- data/lib/flowengine/rules/greater_than.rb +9 -0
- data/lib/flowengine/rules/less_than.rb +9 -0
- data/lib/flowengine/rules/not_empty.rb +7 -0
- data/lib/flowengine/transition.rb +14 -0
- data/lib/flowengine/validation/adapter.rb +13 -0
- data/lib/flowengine/validation/null_adapter.rb +4 -0
- data/lib/flowengine/version.rb +2 -1
- data/lib/flowengine.rb +35 -0
- data/resources/prompts/generic-dsl-intake.j2 +60 -0
- metadata +53 -2
- data/CHANGELOG.md +0 -5
data/lib/flowengine/engine.rb
CHANGED
|
@@ -1,28 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# Runtime session that drives flow navigation: holds definition, answers, and current step.
|
|
5
|
+
# Validates each answer via an optional {Validation::Adapter}, then advances using node transitions.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader definition [Definition] immutable flow definition
|
|
8
|
+
# @attr_reader answers [Hash] step_id => value (mutable as user answers)
|
|
9
|
+
# @attr_reader history [Array<Symbol>] ordered list of step ids visited (including current)
|
|
10
|
+
# @attr_reader current_step_id [Symbol, nil] current step id, or nil when flow is finished
|
|
11
|
+
# @attr_reader introduction_text [String, nil] free-form text submitted before the flow began
|
|
4
12
|
class Engine
|
|
5
|
-
attr_reader :definition, :answers, :history, :current_step_id
|
|
13
|
+
attr_reader :definition, :answers, :history, :current_step_id, :introduction_text
|
|
6
14
|
|
|
15
|
+
# @param definition [Definition] the flow to run
|
|
16
|
+
# @param validator [Validation::Adapter] validator for step answers (default: {Validation::NullAdapter})
|
|
7
17
|
def initialize(definition, validator: Validation::NullAdapter.new)
|
|
8
18
|
@definition = definition
|
|
9
19
|
@answers = {}
|
|
10
20
|
@history = []
|
|
11
21
|
@current_step_id = definition.start_step_id
|
|
12
22
|
@validator = validator
|
|
23
|
+
@introduction_text = nil
|
|
13
24
|
@history << @current_step_id
|
|
14
25
|
end
|
|
15
26
|
|
|
27
|
+
# @return [Node, nil] current step node, or nil if flow is finished
|
|
16
28
|
def current_step
|
|
17
29
|
return nil if finished?
|
|
18
30
|
|
|
19
31
|
definition.step(@current_step_id)
|
|
20
32
|
end
|
|
21
33
|
|
|
34
|
+
# @return [Boolean] true when there is no current step (flow ended)
|
|
22
35
|
def finished?
|
|
23
36
|
@current_step_id.nil?
|
|
24
37
|
end
|
|
25
38
|
|
|
39
|
+
# Submits an answer for the current step, validates it, stores it, and advances to the next step.
|
|
40
|
+
#
|
|
41
|
+
# @param value [Object] user's answer for the current step
|
|
42
|
+
# @raise [AlreadyFinishedError] if the flow has already finished
|
|
43
|
+
# @raise [ValidationError] if the validator rejects the value
|
|
26
44
|
def answer(value)
|
|
27
45
|
raise AlreadyFinishedError, "Flow is already finished" if finished?
|
|
28
46
|
|
|
@@ -33,14 +51,41 @@ module FlowEngine
|
|
|
33
51
|
advance_step
|
|
34
52
|
end
|
|
35
53
|
|
|
54
|
+
# Submits free-form introduction text, filters sensitive data, calls the LLM
|
|
55
|
+
# to extract answers, and auto-advances through pre-filled steps.
|
|
56
|
+
#
|
|
57
|
+
# @param text [String] user's free-form introduction
|
|
58
|
+
# @param llm_client [LLM::Client] configured LLM client for parsing
|
|
59
|
+
# @raise [SensitiveDataError] if text contains SSN, ITIN, EIN, etc.
|
|
60
|
+
# @raise [ValidationError] if text exceeds the introduction maxlength
|
|
61
|
+
# @raise [LLMError] on LLM communication or parsing failures
|
|
62
|
+
def submit_introduction(text, llm_client:)
|
|
63
|
+
validate_introduction_length!(text)
|
|
64
|
+
LLM::SensitiveDataFilter.check!(text)
|
|
65
|
+
@introduction_text = text
|
|
66
|
+
extracted = llm_client.parse_introduction(definition: @definition, introduction_text: text)
|
|
67
|
+
@answers.merge!(extracted)
|
|
68
|
+
auto_advance_prefilled
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Serializable state for persistence or resumption.
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash] current_step_id, answers, history, and introduction_text
|
|
36
74
|
def to_state
|
|
37
75
|
{
|
|
38
76
|
current_step_id: @current_step_id,
|
|
39
77
|
answers: @answers,
|
|
40
|
-
history: @history
|
|
78
|
+
history: @history,
|
|
79
|
+
introduction_text: @introduction_text
|
|
41
80
|
}
|
|
42
81
|
end
|
|
43
82
|
|
|
83
|
+
# Rebuilds an engine from a previously saved state (e.g. from DB or session).
|
|
84
|
+
#
|
|
85
|
+
# @param definition [Definition] same definition used when state was captured
|
|
86
|
+
# @param state_hash [Hash] hash with :current_step_id, :answers, :history (keys may be strings)
|
|
87
|
+
# @param validator [Validation::Adapter] validator to use (default: NullAdapter)
|
|
88
|
+
# @return [Engine] restored engine instance
|
|
44
89
|
def self.from_state(definition, state_hash, validator: Validation::NullAdapter.new)
|
|
45
90
|
state = symbolize_state(state_hash)
|
|
46
91
|
engine = allocate
|
|
@@ -48,6 +93,10 @@ module FlowEngine
|
|
|
48
93
|
engine
|
|
49
94
|
end
|
|
50
95
|
|
|
96
|
+
# Normalizes a state hash so step ids and history entries are symbols; answers keys are symbols.
|
|
97
|
+
#
|
|
98
|
+
# @param hash [Hash] raw state (e.g. from JSON)
|
|
99
|
+
# @return [Hash] symbolized state
|
|
51
100
|
def self.symbolize_state(hash)
|
|
52
101
|
return hash unless hash.is_a?(Hash)
|
|
53
102
|
|
|
@@ -66,6 +115,8 @@ module FlowEngine
|
|
|
66
115
|
end
|
|
67
116
|
end
|
|
68
117
|
|
|
118
|
+
# @param answers [Hash] answers map (keys may be strings)
|
|
119
|
+
# @return [Hash] same map with symbol keys
|
|
69
120
|
def self.symbolize_answers(answers)
|
|
70
121
|
return {} unless answers.is_a?(Hash)
|
|
71
122
|
|
|
@@ -84,6 +135,7 @@ module FlowEngine
|
|
|
84
135
|
@current_step_id = state[:current_step_id]
|
|
85
136
|
@answers = state[:answers] || {}
|
|
86
137
|
@history = state[:history] || []
|
|
138
|
+
@introduction_text = state[:introduction_text]
|
|
87
139
|
end
|
|
88
140
|
|
|
89
141
|
def advance_step
|
|
@@ -93,5 +145,19 @@ module FlowEngine
|
|
|
93
145
|
@current_step_id = next_id
|
|
94
146
|
@history << next_id if next_id
|
|
95
147
|
end
|
|
148
|
+
|
|
149
|
+
def validate_introduction_length!(text)
|
|
150
|
+
maxlength = @definition.introduction&.maxlength
|
|
151
|
+
return unless maxlength
|
|
152
|
+
return if text.length <= maxlength
|
|
153
|
+
|
|
154
|
+
raise ValidationError, "Introduction text exceeds maxlength (#{text.length}/#{maxlength})"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Advances through consecutive steps that already have pre-filled answers.
|
|
158
|
+
# Stops at the first step without a pre-filled answer or when the flow ends.
|
|
159
|
+
def auto_advance_prefilled
|
|
160
|
+
advance_step while @current_step_id && @answers.key?(@current_step_id)
|
|
161
|
+
end
|
|
96
162
|
end
|
|
97
163
|
end
|
data/lib/flowengine/errors.rb
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# Base exception for all flowengine errors.
|
|
4
5
|
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a flow definition is invalid (e.g. missing start step, unknown step reference).
|
|
5
8
|
class DefinitionError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when navigating to or requesting a step id that does not exist in the definition.
|
|
6
11
|
class UnknownStepError < Error; end
|
|
12
|
+
|
|
13
|
+
# Base exception for runtime engine errors (e.g. validation, already finished).
|
|
7
14
|
class EngineError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when {Engine#answer} is called after the flow has already finished.
|
|
8
17
|
class AlreadyFinishedError < EngineError; end
|
|
18
|
+
|
|
19
|
+
# Raised when the validator rejects the user's answer for the current step.
|
|
9
20
|
class ValidationError < EngineError; end
|
|
21
|
+
|
|
22
|
+
# Raised for LLM-related errors (missing API key, response parsing, etc.).
|
|
23
|
+
class LLMError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised when introduction text contains sensitive data (SSN, ITIN, EIN, etc.).
|
|
26
|
+
class SensitiveDataError < EngineError; end
|
|
10
27
|
end
|
data/lib/flowengine/evaluator.rb
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# Evaluates rule AST nodes against a given answer context.
|
|
5
|
+
# Used by transitions and visibility checks; rule objects implement {Rules::Base#evaluate}.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader answers [Hash] current answer state (step_id => value)
|
|
4
8
|
class Evaluator
|
|
5
9
|
attr_reader :answers
|
|
6
10
|
|
|
11
|
+
# @param answers [Hash] answer context to evaluate rules against
|
|
7
12
|
def initialize(answers)
|
|
8
13
|
@answers = answers
|
|
9
14
|
end
|
|
10
15
|
|
|
16
|
+
# Evaluates a single rule (or returns true if rule is nil).
|
|
17
|
+
#
|
|
18
|
+
# @param rule [Rules::Base, nil] rule to evaluate
|
|
19
|
+
# @return [Boolean] result of rule#evaluate(answers), or true when rule is nil
|
|
11
20
|
def evaluate(rule)
|
|
12
21
|
return true if rule.nil?
|
|
13
22
|
|
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Graph
|
|
5
|
+
# Exports a flow {Definition} to Mermaid flowchart syntax for visualization.
|
|
6
|
+
# Nodes are labeled with truncated question text; edges show condition labels when present.
|
|
5
7
|
class MermaidExporter
|
|
8
|
+
# Maximum characters for node labels in the diagram (longer text is truncated with "...").
|
|
6
9
|
MAX_LABEL_LENGTH = 50
|
|
7
10
|
|
|
8
11
|
attr_reader :definition
|
|
9
12
|
|
|
13
|
+
# @param definition [Definition] flow to export
|
|
10
14
|
def initialize(definition)
|
|
11
15
|
@definition = definition
|
|
12
16
|
end
|
|
13
17
|
|
|
18
|
+
# Generates Mermaid flowchart TD (top-down) source.
|
|
19
|
+
#
|
|
20
|
+
# @return [String] Mermaid diagram source (e.g. for Mermaid.js or docs)
|
|
14
21
|
def export
|
|
15
22
|
lines = ["flowchart TD"]
|
|
16
23
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlowEngine
|
|
4
|
+
# Immutable introduction configuration for a flow. When present in a Definition,
|
|
5
|
+
# indicates the UI should collect free-form text before the first step.
|
|
6
|
+
# The label is shown above the input field; the placeholder appears inside it.
|
|
7
|
+
# maxlength limits the character count of the free-form text (nil = unlimited).
|
|
8
|
+
Introduction = Data.define(:label, :placeholder, :maxlength) do
|
|
9
|
+
def initialize(label:, placeholder: "", maxlength: nil)
|
|
10
|
+
super
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlowEngine
|
|
4
|
+
module LLM
|
|
5
|
+
# Abstract adapter for LLM API calls. Subclass and implement {#chat}
|
|
6
|
+
# to integrate with a specific provider (OpenAI, Anthropic, etc.).
|
|
7
|
+
class Adapter
|
|
8
|
+
# Sends a system + user prompt pair to the LLM and returns the response text.
|
|
9
|
+
#
|
|
10
|
+
# @param system_prompt [String] system instructions for the LLM
|
|
11
|
+
# @param user_prompt [String] user's introduction text
|
|
12
|
+
# @param model [String] model identifier (e.g. "gpt-4o-mini")
|
|
13
|
+
# @return [String] the LLM's response text (expected to be JSON)
|
|
14
|
+
def chat(system_prompt:, user_prompt:, model:)
|
|
15
|
+
raise NotImplementedError, "#{self.class}#chat must be implemented"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FlowEngine
|
|
6
|
+
module LLM
|
|
7
|
+
# High-level LLM client that parses introduction text into pre-filled answers.
|
|
8
|
+
# Wraps an {Adapter} and a model name, builds the system prompt from the
|
|
9
|
+
# flow Definition, and parses the structured JSON response.
|
|
10
|
+
class Client
|
|
11
|
+
attr_reader :adapter, :model
|
|
12
|
+
|
|
13
|
+
# @param adapter [Adapter] LLM provider adapter (e.g. OpenAIAdapter)
|
|
14
|
+
# @param model [String] model identifier (default: "gpt-4o-mini")
|
|
15
|
+
def initialize(adapter:, model: "gpt-4o-mini")
|
|
16
|
+
@adapter = adapter
|
|
17
|
+
@model = model
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Sends the introduction text to the LLM with a system prompt built from
|
|
21
|
+
# the Definition, and returns a hash of extracted step answers.
|
|
22
|
+
#
|
|
23
|
+
# @param definition [Definition] flow definition (used to build system prompt)
|
|
24
|
+
# @param introduction_text [String] user's free-form introduction
|
|
25
|
+
# @return [Hash<Symbol, Object>] step_id => extracted value
|
|
26
|
+
# @raise [LLMError] on response parsing failures
|
|
27
|
+
def parse_introduction(definition:, introduction_text:)
|
|
28
|
+
system_prompt = SystemPromptBuilder.new(definition).build
|
|
29
|
+
response_text = adapter.chat(
|
|
30
|
+
system_prompt: system_prompt,
|
|
31
|
+
user_prompt: introduction_text,
|
|
32
|
+
model: model
|
|
33
|
+
)
|
|
34
|
+
parse_response(response_text, definition)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def parse_response(text, definition)
|
|
40
|
+
json_str = extract_json(text)
|
|
41
|
+
raw = JSON.parse(json_str, symbolize_names: true)
|
|
42
|
+
|
|
43
|
+
raw.each_with_object({}) do |(step_id, value), result|
|
|
44
|
+
next unless definition.steps.key?(step_id)
|
|
45
|
+
|
|
46
|
+
node = definition.step(step_id)
|
|
47
|
+
result[step_id] = coerce_value(value, node.type)
|
|
48
|
+
end
|
|
49
|
+
rescue JSON::ParserError => e
|
|
50
|
+
raise LLMError, "Failed to parse LLM response as JSON: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def extract_json(text)
|
|
54
|
+
# LLM may wrap JSON in markdown code fences
|
|
55
|
+
match = text.match(/```(?:json)?\s*\n?(.*?)\n?\s*```/m)
|
|
56
|
+
match ? match[1].strip : text.strip
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def coerce_value(value, type)
|
|
60
|
+
case type
|
|
61
|
+
when :number
|
|
62
|
+
value.is_a?(Numeric) ? value : value.to_i
|
|
63
|
+
when :multi_select
|
|
64
|
+
Array(value)
|
|
65
|
+
when :number_matrix
|
|
66
|
+
return {} unless value.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
value.transform_values { |v| v.is_a?(Numeric) ? v : v.to_i }
|
|
69
|
+
else
|
|
70
|
+
value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module FlowEngine
|
|
6
|
+
module LLM
|
|
7
|
+
# OpenAI adapter using the ruby_llm gem. Configures the API key
|
|
8
|
+
# and delegates chat calls to RubyLLM's conversation interface.
|
|
9
|
+
class OpenAIAdapter < Adapter
|
|
10
|
+
# @param api_key [String, nil] OpenAI API key; falls back to OPENAI_API_KEY env var
|
|
11
|
+
# @raise [LLMError] if no API key is available
|
|
12
|
+
def initialize(api_key: nil)
|
|
13
|
+
super()
|
|
14
|
+
@api_key = api_key || ENV.fetch("OPENAI_API_KEY", nil)
|
|
15
|
+
raise LLMError, "OpenAI API key not provided and OPENAI_API_KEY not set" unless @api_key
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param system_prompt [String] system instructions
|
|
19
|
+
# @param user_prompt [String] user's text
|
|
20
|
+
# @param model [String] OpenAI model identifier
|
|
21
|
+
# @return [String] response content from the LLM
|
|
22
|
+
def chat(system_prompt:, user_prompt:, model: "gpt-4o-mini")
|
|
23
|
+
configure_ruby_llm!
|
|
24
|
+
conversation = RubyLLM.chat(model: model)
|
|
25
|
+
response = conversation.with_instructions(system_prompt).ask(user_prompt)
|
|
26
|
+
response.content
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def configure_ruby_llm!
|
|
32
|
+
RubyLLM.configure do |config|
|
|
33
|
+
config.openai_api_key = @api_key
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlowEngine
|
|
4
|
+
module LLM
|
|
5
|
+
# Scans introduction text for sensitive data patterns (SSN, ITIN, EIN,
|
|
6
|
+
# bank account numbers) and raises {SensitiveDataError} if any are found.
|
|
7
|
+
# This prevents sensitive information from being sent to an LLM.
|
|
8
|
+
module SensitiveDataFilter
|
|
9
|
+
# SSN: 3 digits, dash, 2 digits, dash, 4 digits (e.g. 123-45-6789)
|
|
10
|
+
SSN_PATTERN = /\b\d{3}-\d{2}-\d{4}\b/
|
|
11
|
+
|
|
12
|
+
# ITIN: 9XX-XX-XXXX where first digit is 9
|
|
13
|
+
ITIN_PATTERN = /\b9\d{2}-\d{2}-\d{4}\b/
|
|
14
|
+
|
|
15
|
+
# EIN: 2 digits, dash, 7 digits (e.g. 12-3456789)
|
|
16
|
+
EIN_PATTERN = /\b\d{2}-\d{7}\b/
|
|
17
|
+
|
|
18
|
+
# Nine consecutive digits (SSN/ITIN without dashes)
|
|
19
|
+
NINE_DIGITS_PATTERN = /\b\d{9}\b/
|
|
20
|
+
|
|
21
|
+
PATTERNS = {
|
|
22
|
+
"SSN" => SSN_PATTERN,
|
|
23
|
+
"ITIN" => ITIN_PATTERN,
|
|
24
|
+
"EIN" => EIN_PATTERN,
|
|
25
|
+
"SSN/ITIN (no dashes)" => NINE_DIGITS_PATTERN
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Checks text for sensitive data patterns.
|
|
29
|
+
#
|
|
30
|
+
# @param text [String] introduction text to scan
|
|
31
|
+
# @raise [SensitiveDataError] if any sensitive patterns are detected
|
|
32
|
+
def self.check!(text)
|
|
33
|
+
detected = PATTERNS.each_with_object([]) do |(label, pattern), found|
|
|
34
|
+
found << label if text.match?(pattern)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return if detected.empty?
|
|
38
|
+
|
|
39
|
+
raise SensitiveDataError,
|
|
40
|
+
"Introduction contains sensitive information (#{detected.join(", ")}). " \
|
|
41
|
+
"Please remove all SSN, ITIN, EIN, and account numbers before proceeding."
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlowEngine
|
|
4
|
+
module LLM
|
|
5
|
+
# Builds the system prompt for the LLM from the static template
|
|
6
|
+
# and dynamic step metadata from the flow Definition.
|
|
7
|
+
class SystemPromptBuilder
|
|
8
|
+
TEMPLATE_PATH = File.expand_path("../../../resources/prompts/generic-dsl-intake.j2", __dir__)
|
|
9
|
+
|
|
10
|
+
# @param definition [Definition] flow definition to describe
|
|
11
|
+
# @param template_path [String] path to the static prompt template
|
|
12
|
+
def initialize(definition, template_path: TEMPLATE_PATH)
|
|
13
|
+
@definition = definition
|
|
14
|
+
@template_path = template_path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String] complete system prompt (static template + step descriptions + response format)
|
|
18
|
+
def build
|
|
19
|
+
[static_prompt, steps_description, response_format].join("\n\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def static_prompt
|
|
25
|
+
File.read(@template_path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def steps_description
|
|
29
|
+
lines = ["## Flow Steps\n"]
|
|
30
|
+
@definition.steps.each_value { |node| append_step_description(lines, node) }
|
|
31
|
+
lines.join("\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def append_step_description(lines, node)
|
|
35
|
+
lines << "### Step: `#{node.id}`"
|
|
36
|
+
lines << "- **Type**: #{node.type}"
|
|
37
|
+
lines << "- **Question**: #{node.question}"
|
|
38
|
+
append_options(lines, node) if node.options&.any?
|
|
39
|
+
lines << "- **Fields**: #{node.fields.join(", ")}" if node.fields&.any?
|
|
40
|
+
lines << ""
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def append_options(lines, node)
|
|
44
|
+
if node.option_labels
|
|
45
|
+
formatted = node.option_labels.map { |key, label| "#{key} (#{label})" }.join(", ")
|
|
46
|
+
lines << "- **Options**: #{formatted}"
|
|
47
|
+
lines << "- **Use the option keys in your response, not the labels**"
|
|
48
|
+
else
|
|
49
|
+
lines << "- **Options**: #{node.options.join(", ")}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def response_format
|
|
54
|
+
<<~PROMPT
|
|
55
|
+
## Response Format
|
|
56
|
+
|
|
57
|
+
Respond with ONLY a valid JSON object mapping step IDs (as strings) to extracted values.
|
|
58
|
+
Only include steps where you can confidently extract an answer from the user's text.
|
|
59
|
+
Do not guess or fabricate answers. If unsure, omit that step.
|
|
60
|
+
|
|
61
|
+
Value types by step type:
|
|
62
|
+
- `single_select`: one of the listed option strings
|
|
63
|
+
- `multi_select`: an array of matching option strings
|
|
64
|
+
- `number`: an integer
|
|
65
|
+
- `text`: extracted text string
|
|
66
|
+
- `number_matrix`: a hash mapping field names to integers (e.g. {"RealEstate": 2, "LLC": 1})
|
|
67
|
+
|
|
68
|
+
Example: {"filing_status": "single", "dependents": 2, "income_types": ["W2", "Business"]}
|
|
69
|
+
PROMPT
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm/adapter"
|
|
4
|
+
require_relative "llm/openai_adapter"
|
|
5
|
+
require_relative "llm/sensitive_data_filter"
|
|
6
|
+
require_relative "llm/system_prompt_builder"
|
|
7
|
+
require_relative "llm/client"
|
|
8
|
+
|
|
9
|
+
module FlowEngine
|
|
10
|
+
# Namespace for LLM integration: adapters, system prompt building,
|
|
11
|
+
# sensitive data filtering, and the high-level Client.
|
|
12
|
+
module LLM
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/flowengine/node.rb
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# A single step in the flow: question metadata, input config, and conditional transitions.
|
|
5
|
+
# Used by {Engine} to determine the next step and by UI/export to render the step.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader id [Symbol] unique step identifier
|
|
8
|
+
# @attr_reader type [Symbol] input type (e.g. :multi_select, :number_matrix)
|
|
9
|
+
# @attr_reader question [String] prompt text for the step
|
|
10
|
+
# @attr_reader options [Array, nil] option keys for select steps; nil for other types
|
|
11
|
+
# @attr_reader option_labels [Hash, nil] key => display label mapping (nil when options are plain strings)
|
|
12
|
+
# @attr_reader fields [Array, nil] field names for number_matrix etc.; nil otherwise
|
|
13
|
+
# @attr_reader transitions [Array<Transition>] ordered list of conditional next-step rules
|
|
14
|
+
# @attr_reader visibility_rule [Rules::Base, nil] rule controlling whether this node is visible (DAG mode)
|
|
4
15
|
class Node
|
|
5
|
-
attr_reader :id, :type, :question, :options, :fields, :transitions, :visibility_rule
|
|
16
|
+
attr_reader :id, :type, :question, :options, :option_labels, :fields, :transitions, :visibility_rule
|
|
6
17
|
|
|
18
|
+
# @param id [Symbol] step id
|
|
19
|
+
# @param type [Symbol] step/input type
|
|
20
|
+
# @param question [String] label/prompt
|
|
21
|
+
# @param decorations [Object, nil] optional UI decorations (not used by engine)
|
|
22
|
+
# @param options [Array, Hash, nil] option list or key=>label hash for select steps
|
|
23
|
+
# @param fields [Array, nil] field list for matrix-style steps
|
|
24
|
+
# @param transitions [Array<Transition>] conditional next-step transitions (default: [])
|
|
25
|
+
# @param visibility_rule [Rules::Base, nil] optional rule for visibility (default: always visible)
|
|
7
26
|
def initialize(id:, # rubocop:disable Metrics/ParameterLists
|
|
8
27
|
type:,
|
|
9
28
|
question:,
|
|
@@ -16,22 +35,48 @@ module FlowEngine
|
|
|
16
35
|
@type = type
|
|
17
36
|
@question = question
|
|
18
37
|
@decorations = decorations
|
|
19
|
-
|
|
38
|
+
extract_options(options)
|
|
20
39
|
@fields = fields&.freeze
|
|
21
40
|
@transitions = transitions.freeze
|
|
22
41
|
@visibility_rule = visibility_rule
|
|
23
42
|
freeze
|
|
24
43
|
end
|
|
25
44
|
|
|
45
|
+
# Resolves the next step id from current answers by evaluating transitions in order.
|
|
46
|
+
#
|
|
47
|
+
# @param answers [Hash] current answer state (step_id => value)
|
|
48
|
+
# @return [Symbol, nil] id of the next step, or nil if no transition matches (flow end)
|
|
26
49
|
def next_step_id(answers)
|
|
27
50
|
match = transitions.find { |t| t.applies?(answers) }
|
|
28
51
|
match&.target
|
|
29
52
|
end
|
|
30
53
|
|
|
54
|
+
# Whether this node should be considered visible given current answers (for DAG/visibility).
|
|
55
|
+
#
|
|
56
|
+
# @param answers [Hash] current answer state
|
|
57
|
+
# @return [Boolean] true if no visibility_rule, else result of rule evaluation
|
|
31
58
|
def visible?(answers)
|
|
32
59
|
return true if visibility_rule.nil?
|
|
33
60
|
|
|
34
61
|
visibility_rule.evaluate(answers)
|
|
35
62
|
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Normalizes options: a Hash is split into keys (options) and the full hash (option_labels);
|
|
67
|
+
# an Array is stored as-is with nil option_labels.
|
|
68
|
+
def extract_options(raw)
|
|
69
|
+
case raw
|
|
70
|
+
when Hash
|
|
71
|
+
@options = raw.keys.map(&:to_s).freeze
|
|
72
|
+
@option_labels = raw.transform_keys(&:to_s).freeze
|
|
73
|
+
when Array
|
|
74
|
+
@options = raw.freeze
|
|
75
|
+
@option_labels = nil
|
|
76
|
+
else
|
|
77
|
+
@options = nil
|
|
78
|
+
@option_labels = nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
36
81
|
end
|
|
37
82
|
end
|
data/lib/flowengine/rules/all.rb
CHANGED
|
@@ -2,19 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Composite rule: logical AND of multiple sub-rules. All must be true.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader rules [Array<Base>] sub-rules to evaluate
|
|
5
8
|
class All < Base
|
|
6
9
|
attr_reader :rules
|
|
7
10
|
|
|
11
|
+
# @param rules [Array<Base>] one or more rules (arrays are flattened)
|
|
8
12
|
def initialize(*rules)
|
|
9
13
|
super()
|
|
10
14
|
@rules = rules.flatten.freeze
|
|
11
15
|
freeze
|
|
12
16
|
end
|
|
13
17
|
|
|
18
|
+
# @param answers [Hash] current answers
|
|
19
|
+
# @return [Boolean] true if every sub-rule evaluates to true
|
|
14
20
|
def evaluate(answers)
|
|
15
21
|
rules.all? { |rule| rule.evaluate(answers) }
|
|
16
22
|
end
|
|
17
23
|
|
|
24
|
+
# @return [String] e.g. "(rule1 AND rule2 AND rule3)"
|
|
18
25
|
def to_s
|
|
19
26
|
"(#{rules.join(" AND ")})"
|
|
20
27
|
end
|
data/lib/flowengine/rules/any.rb
CHANGED
|
@@ -2,19 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Composite rule: logical OR of multiple sub-rules. At least one must be true.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader rules [Array<Base>] sub-rules to evaluate
|
|
5
8
|
class Any < Base
|
|
6
9
|
attr_reader :rules
|
|
7
10
|
|
|
11
|
+
# @param rules [Array<Base>] one or more rules (arrays are flattened)
|
|
8
12
|
def initialize(*rules)
|
|
9
13
|
super()
|
|
10
14
|
@rules = rules.flatten.freeze
|
|
11
15
|
freeze
|
|
12
16
|
end
|
|
13
17
|
|
|
18
|
+
# @param answers [Hash] current answers
|
|
19
|
+
# @return [Boolean] true if any sub-rule evaluates to true
|
|
14
20
|
def evaluate(answers)
|
|
15
21
|
rules.any? { |rule| rule.evaluate(answers) }
|
|
16
22
|
end
|
|
17
23
|
|
|
24
|
+
# @return [String] e.g. "(rule1 OR rule2 OR rule3)"
|
|
18
25
|
def to_s
|
|
19
26
|
"(#{rules.join(" OR ")})"
|
|
20
27
|
end
|
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Abstract base for rule AST nodes. Subclasses implement {#evaluate} and {#to_s}
|
|
6
|
+
# for use in transitions and visibility conditions.
|
|
5
7
|
class Base
|
|
8
|
+
# Evaluates the rule against the current answer context.
|
|
9
|
+
#
|
|
10
|
+
# @param _answers [Hash] step_id => value
|
|
11
|
+
# @return [Boolean]
|
|
6
12
|
def evaluate(_answers)
|
|
7
13
|
raise NotImplementedError, "#{self.class}#evaluate must be implemented"
|
|
8
14
|
end
|
|
9
15
|
|
|
16
|
+
# Human-readable representation (e.g. for graph labels).
|
|
17
|
+
#
|
|
18
|
+
# @return [String]
|
|
10
19
|
def to_s
|
|
11
20
|
raise NotImplementedError, "#{self.class}#to_s must be implemented"
|
|
12
21
|
end
|
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Rule: the answer for the given field (as an array) includes the given value.
|
|
6
|
+
# Used for multi-select steps (e.g. "BusinessOwnership in earnings").
|
|
7
|
+
#
|
|
8
|
+
# @attr_reader field [Symbol] answer key (step id)
|
|
9
|
+
# @attr_reader value [Object] value that must be present in the array
|
|
5
10
|
class Contains < Base
|
|
6
11
|
attr_reader :field, :value
|
|
7
12
|
|
|
13
|
+
# @param field [Symbol] answer key
|
|
14
|
+
# @param value [Object] value to check for
|
|
8
15
|
def initialize(field, value)
|
|
9
16
|
super()
|
|
10
17
|
@field = field
|
|
@@ -12,10 +19,13 @@ module FlowEngine
|
|
|
12
19
|
freeze
|
|
13
20
|
end
|
|
14
21
|
|
|
22
|
+
# @param answers [Hash] current answers
|
|
23
|
+
# @return [Boolean] true if answers[field] (as array) includes value
|
|
15
24
|
def evaluate(answers)
|
|
16
25
|
Array(answers[field]).include?(value)
|
|
17
26
|
end
|
|
18
27
|
|
|
28
|
+
# @return [String] e.g. "BusinessOwnership in earnings"
|
|
19
29
|
def to_s
|
|
20
30
|
"#{value} in #{field}"
|
|
21
31
|
end
|