phronomy 0.5.3 → 0.6.0
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/CHANGELOG.md +41 -0
- data/README.md +24 -25
- data/lib/phronomy/agent/base.rb +88 -2
- data/lib/phronomy/agent/fsm.rb +165 -0
- data/lib/phronomy/agent/orchestrator.rb +100 -19
- data/lib/phronomy/agent/parallel_tool_chat.rb +75 -0
- data/lib/phronomy/configuration.rb +6 -0
- data/lib/phronomy/context.rb +0 -1
- data/lib/phronomy/event.rb +14 -0
- data/lib/phronomy/event_loop.rb +147 -0
- data/lib/phronomy/fsm_session.rb +194 -0
- data/lib/phronomy/generator_verifier.rb +22 -22
- data/lib/phronomy/guardrail.rb +0 -1
- data/lib/phronomy/vector_store/base.rb +15 -0
- data/lib/phronomy/vector_store/in_memory.rb +11 -1
- data/lib/phronomy/vector_store/pgvector.rb +8 -2
- data/lib/phronomy/vector_store/redis_search.rb +16 -3
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +83 -71
- data/lib/phronomy/workflow_context.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +167 -112
- data/lib/phronomy.rb +4 -0
- metadata +7 -6
- data/lib/phronomy/context/builder.rb +0 -92
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +0 -100
- data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +0 -67
- data/lib/phronomy/guardrail/builtin.rb +0 -16
|
@@ -28,6 +28,11 @@ module Phronomy
|
|
|
28
28
|
# Recursion limit for graph execution (default: 25)
|
|
29
29
|
attr_accessor :recursion_limit
|
|
30
30
|
|
|
31
|
+
# When true, workflow execution is driven by EventLoop instead of a
|
|
32
|
+
# synchronous loop in the calling thread. Defaults to false (sync mode).
|
|
33
|
+
# @see Phronomy::EventLoop
|
|
34
|
+
attr_accessor :event_loop
|
|
35
|
+
|
|
31
36
|
# When true (default), user input and LLM output are recorded in trace spans.
|
|
32
37
|
# Set to false in privacy-sensitive environments to prevent PII from reaching
|
|
33
38
|
# the tracing backend (OTel, Langfuse, etc.).
|
|
@@ -37,6 +42,7 @@ module Phronomy
|
|
|
37
42
|
@recursion_limit = 25
|
|
38
43
|
@tracer = Phronomy::Tracing::NullTracer.new
|
|
39
44
|
@trace_pii = true
|
|
45
|
+
@event_loop = false
|
|
40
46
|
end
|
|
41
47
|
end
|
|
42
48
|
end
|
data/lib/phronomy/context.rb
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Immutable event struct used for inter-FSM communication via EventLoop.
|
|
5
|
+
#
|
|
6
|
+
# @param type [Symbol] event identifier (:start, :state_completed,
|
|
7
|
+
# :finished, :halted, :error, or any user-defined name)
|
|
8
|
+
# @param target_id [String] FSMSession identifier — matches WorkflowContext#thread_id
|
|
9
|
+
# @param payload [Object] optional data attached to the event:
|
|
10
|
+
# - final/halted context for :finished/:halted
|
|
11
|
+
# - Exception for :error
|
|
12
|
+
# - nil for :start / :state_completed
|
|
13
|
+
Event = Data.define(:type, :target_id, :payload)
|
|
14
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Singleton event loop that manages all FSMSession instances.
|
|
5
|
+
#
|
|
6
|
+
# A single background thread reads from a global Thread::Queue and dispatches
|
|
7
|
+
# events to their target FSMSession. IO work (LLM calls, tool calls) runs in
|
|
8
|
+
# separate IO threads that post events back to the loop via EventLoop#post.
|
|
9
|
+
#
|
|
10
|
+
# Activated with: +Phronomy.configure { |c| c.event_loop = true }+
|
|
11
|
+
#
|
|
12
|
+
# == Fork safety
|
|
13
|
+
#
|
|
14
|
+
# +EventLoop.instance+ is lazily initialized. The background thread is not
|
|
15
|
+
# created until the first call, so Puma worker forking does not duplicate the
|
|
16
|
+
# thread. No +after_fork+ hook is required.
|
|
17
|
+
#
|
|
18
|
+
# == Deadlock warning
|
|
19
|
+
#
|
|
20
|
+
# Do NOT call +Workflow#invoke+ (in EventLoop mode) from within a workflow
|
|
21
|
+
# entry action. The entry action runs on the EventLoop thread; a nested
|
|
22
|
+
# +invoke+ would block waiting for the same thread to process events →
|
|
23
|
+
# deadlock. Use the async IO pattern instead (spawn a Thread, post events
|
|
24
|
+
# back to the EventLoop).
|
|
25
|
+
class EventLoop
|
|
26
|
+
# Returns the singleton instance, creating and starting it on first call.
|
|
27
|
+
def self.instance
|
|
28
|
+
@instance ||= new.tap(&:start)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Stops and destroys the singleton. Primarily used in tests.
|
|
32
|
+
# @api private
|
|
33
|
+
def self.reset!
|
|
34
|
+
@instance&.stop
|
|
35
|
+
@instance = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@queue = Thread::Queue.new # global event queue (thread-safe; no Mutex needed)
|
|
40
|
+
@fsms = {} # { id => FSMSession } — EventLoop thread only
|
|
41
|
+
@waiting = {} # { id => completion_queue } — EventLoop thread only
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Registers an FSMSession for execution and returns a completion queue.
|
|
45
|
+
#
|
|
46
|
+
# The session and its completion queue are handed off to the EventLoop thread
|
|
47
|
+
# via the queue payload, so +@fsms+ and +@waiting+ are exclusively written
|
|
48
|
+
# and read by the EventLoop thread. No Mutex is required.
|
|
49
|
+
#
|
|
50
|
+
# The caller blocks on +completion_queue.pop+ to receive the final context
|
|
51
|
+
# (WorkflowContext) once the workflow finishes or halts. If an error occurred,
|
|
52
|
+
# the popped value will be an Exception — callers are responsible for re-raising it.
|
|
53
|
+
#
|
|
54
|
+
# @param fsm_session [Phronomy::FSMSession]
|
|
55
|
+
# @return [Thread::Queue] resolves to final/halted context, or an Exception
|
|
56
|
+
def register(fsm_session)
|
|
57
|
+
if Thread.current[:phronomy_event_loop_thread]
|
|
58
|
+
raise Phronomy::Error,
|
|
59
|
+
"Cannot call Workflow#invoke (EventLoop mode) from within an EventLoop " \
|
|
60
|
+
"entry action. Use the async IO pattern: spawn a Thread, post events " \
|
|
61
|
+
"back via Phronomy::EventLoop.instance.post(...) instead."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
completion_queue = Thread::Queue.new
|
|
65
|
+
# Pass both session and completion_queue in the event payload so that the
|
|
66
|
+
# EventLoop thread is the sole writer of @fsms and @waiting.
|
|
67
|
+
@queue.push(Event.new(type: :start, target_id: fsm_session.id,
|
|
68
|
+
payload: {session: fsm_session, completion: completion_queue}))
|
|
69
|
+
completion_queue
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Enqueues an {AgentFSM} as a fire-and-forget child session.
|
|
73
|
+
#
|
|
74
|
+
# Unlike {#register}, this method:
|
|
75
|
+
# - Is safe to call from the EventLoop thread (entry actions).
|
|
76
|
+
# - Does NOT block — no completion queue is created.
|
|
77
|
+
# - Delegates `:finished`/`:error` cleanup to the EventLoop via posted events.
|
|
78
|
+
#
|
|
79
|
+
# @param agent_fsm [Phronomy::Agent::FSM]
|
|
80
|
+
# @return [nil]
|
|
81
|
+
def enqueue_child(agent_fsm)
|
|
82
|
+
@queue.push(Event.new(type: :start, target_id: agent_fsm.id,
|
|
83
|
+
payload: {session: agent_fsm, completion: nil}))
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Posts an event to the loop. Safe to call from any thread (including IO threads).
|
|
88
|
+
#
|
|
89
|
+
# @param event [Phronomy::Event]
|
|
90
|
+
def post(event)
|
|
91
|
+
@queue.push(event)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Starts the background event loop thread.
|
|
95
|
+
# @return [self]
|
|
96
|
+
def start
|
|
97
|
+
@running = true
|
|
98
|
+
@thread = Thread.new do
|
|
99
|
+
Thread.current[:phronomy_event_loop_thread] = true
|
|
100
|
+
run_loop
|
|
101
|
+
end
|
|
102
|
+
@thread.abort_on_exception = false
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Stops the background thread. Used in tests only.
|
|
107
|
+
# @api private
|
|
108
|
+
def stop
|
|
109
|
+
@running = false
|
|
110
|
+
@thread&.kill
|
|
111
|
+
@thread = nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def run_loop
|
|
117
|
+
while @running
|
|
118
|
+
event = @queue.pop
|
|
119
|
+
|
|
120
|
+
case event.type
|
|
121
|
+
when :finished, :halted, :error
|
|
122
|
+
# All three terminal events share the same cleanup path.
|
|
123
|
+
# Both @fsms and @waiting are exclusively owned by this thread.
|
|
124
|
+
@fsms.delete(event.target_id)
|
|
125
|
+
cq = @waiting.delete(event.target_id)
|
|
126
|
+
cq&.push(event.payload)
|
|
127
|
+
|
|
128
|
+
when :start
|
|
129
|
+
# session and completion_queue arrive together in the payload so that
|
|
130
|
+
# this thread is the sole writer of @fsms and @waiting.
|
|
131
|
+
# completion may be nil for fire-and-forget child sessions (AgentFSM).
|
|
132
|
+
@fsms[event.target_id] = event.payload[:session]
|
|
133
|
+
cq = event.payload[:completion]
|
|
134
|
+
@waiting[event.target_id] = cq if cq
|
|
135
|
+
event.payload[:session].start
|
|
136
|
+
|
|
137
|
+
else
|
|
138
|
+
@fsms[event.target_id]&.handle(event)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
rescue => e
|
|
142
|
+
# Unblock all waiting callers if the loop dies unexpectedly.
|
|
143
|
+
@waiting.values.each { |cq| cq.push(e) }
|
|
144
|
+
raise
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Event-driven execution wrapper for a single workflow run.
|
|
5
|
+
#
|
|
6
|
+
# Created by WorkflowRunner and registered with EventLoop. All public methods
|
|
7
|
+
# are called from the EventLoop thread — FSMSession is NOT thread-safe and must
|
|
8
|
+
# not be accessed concurrently from multiple threads.
|
|
9
|
+
#
|
|
10
|
+
# == Lifecycle
|
|
11
|
+
#
|
|
12
|
+
# register(session) → EventLoop posts :start → session.start
|
|
13
|
+
# ↓ (auto-transition present)
|
|
14
|
+
# EventLoop posts :state_completed → session.handle
|
|
15
|
+
# ↓ (repeat)
|
|
16
|
+
# session posts :finished or :halted
|
|
17
|
+
# ↓
|
|
18
|
+
# EventLoop pushes ctx to completion_queue → caller unblocks
|
|
19
|
+
#
|
|
20
|
+
# == Async IO pattern (EventLoop mode only)
|
|
21
|
+
#
|
|
22
|
+
# When a state has no auto-transition and is not a wait_state, but has an
|
|
23
|
+
# external event registered (e.g. +transition from: :fetching, on: :fetch_done+),
|
|
24
|
+
# the FSMSession stays registered in the EventLoop and waits for that event.
|
|
25
|
+
# The entry action is expected to spawn an IO thread that posts the event back:
|
|
26
|
+
#
|
|
27
|
+
# entry :fetching, ->(ctx) {
|
|
28
|
+
# Thread.new {
|
|
29
|
+
# ctx.result = http.get(ctx.url)
|
|
30
|
+
# Phronomy::EventLoop.instance.post(
|
|
31
|
+
# Phronomy::Event.new(type: :fetch_done, target_id: ctx.thread_id, payload: nil)
|
|
32
|
+
# )
|
|
33
|
+
# }
|
|
34
|
+
# }
|
|
35
|
+
# transition from: :fetching, on: :fetch_done, to: :process
|
|
36
|
+
class FSMSession
|
|
37
|
+
FINISH = WorkflowRunner::FINISH
|
|
38
|
+
|
|
39
|
+
# @return [String] workflow thread_id (matches WorkflowContext#thread_id)
|
|
40
|
+
attr_reader :id
|
|
41
|
+
|
|
42
|
+
# @param id [String]
|
|
43
|
+
# @param context [Object] includes Phronomy::WorkflowContext
|
|
44
|
+
# @param entry_point [Symbol] initial state name
|
|
45
|
+
# @param entry_actions [Hash] { state_name => [callable, ...] }
|
|
46
|
+
# @param auto_state_set [Hash] { state_name => true }
|
|
47
|
+
# @param declared_states [Array<Symbol>] all action state names
|
|
48
|
+
# @param wait_state_names [Array<Symbol>]
|
|
49
|
+
# @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
|
|
50
|
+
# @param phase_machine_class [Class] state_machines-backed phase tracker class
|
|
51
|
+
# @param recursion_limit [Integer]
|
|
52
|
+
# @param resume_event [Symbol, nil] external event to fire when resuming
|
|
53
|
+
# @param resume_phase [Symbol, nil] wait state name to resume from
|
|
54
|
+
def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
|
|
55
|
+
declared_states:, wait_state_names:, external_events:, phase_machine_class:,
|
|
56
|
+
recursion_limit:, resume_event: nil, resume_phase: nil)
|
|
57
|
+
@id = id
|
|
58
|
+
@ctx = context
|
|
59
|
+
@entry_point = entry_point
|
|
60
|
+
@entry_actions = entry_actions
|
|
61
|
+
@auto_state_set = auto_state_set
|
|
62
|
+
@declared_states = declared_states
|
|
63
|
+
@wait_state_names = wait_state_names
|
|
64
|
+
@external_events = external_events
|
|
65
|
+
@phase_machine_class = phase_machine_class
|
|
66
|
+
@recursion_limit = recursion_limit
|
|
67
|
+
@resume_event = resume_event
|
|
68
|
+
@resume_phase = resume_phase
|
|
69
|
+
@step = 0
|
|
70
|
+
@done = false
|
|
71
|
+
@current_state = nil
|
|
72
|
+
@tracker = nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Begins workflow execution. Called by EventLoop on :start event.
|
|
76
|
+
def start
|
|
77
|
+
if @resume_event
|
|
78
|
+
# Resume from wait state: position tracker at the wait state, then fire the
|
|
79
|
+
# external event. state_machines fires before_transition (exit) and
|
|
80
|
+
# after_transition (entry) callbacks, so both actions execute here.
|
|
81
|
+
@current_state = @resume_phase
|
|
82
|
+
@tracker = build_tracker(@current_state)
|
|
83
|
+
@tracker.context = @ctx
|
|
84
|
+
fire_and_advance!(@resume_event)
|
|
85
|
+
else
|
|
86
|
+
# Fresh start: state_machines does not fire callbacks on initialization,
|
|
87
|
+
# so we invoke the entry action for the initial state manually.
|
|
88
|
+
@current_state = @entry_point
|
|
89
|
+
@tracker = build_tracker(@current_state)
|
|
90
|
+
@tracker.context = @ctx
|
|
91
|
+
(@entry_actions[@current_state] || []).each { |c| c.call(@ctx) }
|
|
92
|
+
advance_or_halt
|
|
93
|
+
end
|
|
94
|
+
rescue => e
|
|
95
|
+
finish_with_error(e)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Processes an event dispatched from EventLoop.
|
|
99
|
+
# Called for :state_completed and all user-defined external events.
|
|
100
|
+
#
|
|
101
|
+
# @param event [Phronomy::Event]
|
|
102
|
+
def handle(event)
|
|
103
|
+
return if @done
|
|
104
|
+
|
|
105
|
+
fire_and_advance!(event.type)
|
|
106
|
+
rescue => e
|
|
107
|
+
finish_with_error(e)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Fires event_name on the phase tracker, updates @current_state, then
|
|
113
|
+
# calls advance_or_halt to decide what to do next.
|
|
114
|
+
def fire_and_advance!(event_name)
|
|
115
|
+
if @step >= @recursion_limit
|
|
116
|
+
raise Phronomy::RecursionLimitError,
|
|
117
|
+
"Recursion limit (#{@recursion_limit}) exceeded"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
fire_event!(@tracker, event_name, @current_state)
|
|
121
|
+
next_phase = @tracker.phase.to_sym
|
|
122
|
+
# When next_phase == @current_state, no transition matched → treat as terminal.
|
|
123
|
+
@current_state = (next_phase == @current_state) ? FINISH : next_phase
|
|
124
|
+
@step += 1
|
|
125
|
+
advance_or_halt
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Determines the next action after the FSM has entered @current_state.
|
|
129
|
+
def advance_or_halt
|
|
130
|
+
return finish! if @current_state == FINISH
|
|
131
|
+
|
|
132
|
+
if @wait_state_names.include?(@current_state)
|
|
133
|
+
return halt!
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if @auto_state_set.key?(@current_state)
|
|
137
|
+
event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if has_external_event_from?(@current_state)
|
|
142
|
+
# Async IO pattern: the entry action spawned an IO thread that will post
|
|
143
|
+
# an external event back. Stay registered; do nothing here.
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# No transition declared — validate the state is known, then treat as terminal.
|
|
148
|
+
unless @declared_states.include?(@current_state)
|
|
149
|
+
raise ArgumentError, "State #{@current_state.inspect} is not defined"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
finish!
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def finish!
|
|
156
|
+
@done = true
|
|
157
|
+
@ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
|
|
158
|
+
event_loop.post(Event.new(type: :finished, target_id: @id, payload: @ctx))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def halt!
|
|
162
|
+
@done = true
|
|
163
|
+
@ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
|
|
164
|
+
event_loop.post(Event.new(type: :halted, target_id: @id, payload: @ctx))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def finish_with_error(err)
|
|
168
|
+
@done = true
|
|
169
|
+
event_loop.post(Event.new(type: :error, target_id: @id, payload: err))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def fire_event!(tracker, event_name, from_state)
|
|
173
|
+
return if tracker.send(event_name)
|
|
174
|
+
|
|
175
|
+
raise ArgumentError,
|
|
176
|
+
"Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
|
|
177
|
+
"Ensure at least one guard matches or add a fallback (no-guard) transition."
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def has_external_event_from?(state)
|
|
181
|
+
@external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def build_tracker(from_state)
|
|
185
|
+
machine = @phase_machine_class.new
|
|
186
|
+
machine.instance_variable_set(:@phase, from_state.to_s)
|
|
187
|
+
machine
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def event_loop
|
|
191
|
+
Phronomy::EventLoop.instance
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -133,7 +133,7 @@ module Phronomy
|
|
|
133
133
|
@threshold = confidence_threshold.to_f
|
|
134
134
|
@max_iterations = max_iterations.to_i
|
|
135
135
|
@raise_if_untrusted = raise_if_untrusted
|
|
136
|
-
@
|
|
136
|
+
@compiled_workflow = nil
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
# Run the generator-verifier pipeline.
|
|
@@ -144,7 +144,7 @@ module Phronomy
|
|
|
144
144
|
# @raise [Phronomy::LowConfidenceError] when +raise_if_untrusted:+ is +true+
|
|
145
145
|
# and the result does not meet the confidence threshold
|
|
146
146
|
def invoke(input, config: {})
|
|
147
|
-
app =
|
|
147
|
+
app = compiled_workflow
|
|
148
148
|
state = app.invoke({input: input}, config: config)
|
|
149
149
|
confidence = combined_confidence(state)
|
|
150
150
|
trusted = confidence >= @threshold
|
|
@@ -166,8 +166,8 @@ module Phronomy
|
|
|
166
166
|
[(state.self_score || 0.0).to_f, (state.review_score || 0.0).to_f].min
|
|
167
167
|
end
|
|
168
168
|
|
|
169
|
-
def
|
|
170
|
-
@
|
|
169
|
+
def compiled_workflow
|
|
170
|
+
@compiled_workflow ||= build_workflow
|
|
171
171
|
end
|
|
172
172
|
|
|
173
173
|
def build_workflow
|
|
@@ -184,42 +184,42 @@ module Phronomy
|
|
|
184
184
|
Phronomy::Workflow.define(PipelineState) do
|
|
185
185
|
initial :draft
|
|
186
186
|
|
|
187
|
-
state :draft
|
|
187
|
+
state :draft
|
|
188
|
+
state :review
|
|
189
|
+
state :finalize
|
|
190
|
+
|
|
191
|
+
entry :draft, ->(state) {
|
|
188
192
|
feedback = state.review_notes.last
|
|
189
193
|
prompt = dpb.call(state.input, feedback)
|
|
190
194
|
result = draft_agent.invoke(prompt)
|
|
191
195
|
parsed = drp.call(result[:output])
|
|
192
|
-
state.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
iteration: state.iteration + 1
|
|
197
|
-
)
|
|
196
|
+
state.draft = parsed[:answer].to_s
|
|
197
|
+
state.self_score = pipeline.__send__(:clamp, parsed[:confidence])
|
|
198
|
+
state.citations = pipeline.__send__(:normalize_citations, parsed[:citations])
|
|
199
|
+
state.iteration = state.iteration + 1
|
|
198
200
|
}
|
|
199
201
|
|
|
200
|
-
|
|
202
|
+
entry :review, ->(state) {
|
|
201
203
|
prompt = rpb.call(state.input, state.draft, state.citations)
|
|
202
204
|
result = review_agent.invoke(prompt)
|
|
203
205
|
parsed = rrp.call(result[:output])
|
|
204
|
-
state.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
review_notes: parsed[:feedback].to_s
|
|
208
|
-
)
|
|
206
|
+
state.review_score = pipeline.__send__(:clamp, parsed[:score])
|
|
207
|
+
state.approved = parsed[:approved] == true
|
|
208
|
+
state.review_notes << parsed[:feedback].to_s
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
entry :finalize, ->(state) { state.output = state.draft }
|
|
212
212
|
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
transition from: :draft, to: :review
|
|
214
|
+
transition from: :finalize, to: :__finish__
|
|
215
215
|
|
|
216
|
-
|
|
216
|
+
transition from: :review,
|
|
217
217
|
guard: ->(state) {
|
|
218
218
|
confidence = [state.self_score || 0.0, state.review_score || 0.0].min
|
|
219
219
|
(confidence >= threshold && state.approved) || state.iteration >= max_iter
|
|
220
220
|
},
|
|
221
221
|
to: :finalize
|
|
222
|
-
|
|
222
|
+
transition from: :review, to: :draft
|
|
223
223
|
end
|
|
224
224
|
end
|
|
225
225
|
|
data/lib/phronomy/guardrail.rb
CHANGED
|
@@ -36,6 +36,21 @@ module Phronomy
|
|
|
36
36
|
def clear
|
|
37
37
|
raise NotImplementedError, "#{self.class}#clear is not implemented"
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Validates that embedding has the expected dimension.
|
|
43
|
+
# Raises ArgumentError if sizes differ.
|
|
44
|
+
# A nil expected_dimension is a no-op (dimension not yet established).
|
|
45
|
+
def validate_embedding_dimension!(embedding, expected_dimension)
|
|
46
|
+
return unless expected_dimension
|
|
47
|
+
|
|
48
|
+
actual = embedding.size
|
|
49
|
+
return if actual == expected_dimension
|
|
50
|
+
|
|
51
|
+
raise ArgumentError,
|
|
52
|
+
"Embedding dimension mismatch: expected #{expected_dimension}, got #{actual}"
|
|
53
|
+
end
|
|
39
54
|
end
|
|
40
55
|
end
|
|
41
56
|
end
|
|
@@ -12,14 +12,22 @@ module Phronomy
|
|
|
12
12
|
# store.add(id: "1", embedding: [0.1, 0.9], metadata: { message: msg })
|
|
13
13
|
# results = store.search(query_embedding: [0.1, 0.8], k: 3)
|
|
14
14
|
class InMemory < Base
|
|
15
|
-
|
|
15
|
+
# @param dimension [Integer, nil] expected embedding dimension.
|
|
16
|
+
# When nil, the dimension is inferred from the first call to #add.
|
|
17
|
+
# For multi-threaded use, pass dimension: explicitly; concurrent first
|
|
18
|
+
# adds are not guaranteed to be race-free.
|
|
19
|
+
def initialize(dimension: nil)
|
|
16
20
|
@documents = {}
|
|
21
|
+
@expected_dimension = dimension
|
|
17
22
|
end
|
|
18
23
|
|
|
19
24
|
# @param id [String]
|
|
20
25
|
# @param embedding [Array<Float>]
|
|
21
26
|
# @param metadata [Hash]
|
|
22
27
|
def add(id:, embedding:, metadata: {})
|
|
28
|
+
# Establish expected dimension on first add, then validate.
|
|
29
|
+
@expected_dimension ||= embedding.size
|
|
30
|
+
validate_embedding_dimension!(embedding, @expected_dimension)
|
|
23
31
|
@documents[id] = {embedding: embedding, metadata: metadata}
|
|
24
32
|
self
|
|
25
33
|
end
|
|
@@ -28,6 +36,8 @@ module Phronomy
|
|
|
28
36
|
# @param k [Integer]
|
|
29
37
|
# @return [Array<Hash>] sorted by descending score
|
|
30
38
|
def search(query_embedding:, k: 5)
|
|
39
|
+
# search never establishes dimension; validate only when dimension is known.
|
|
40
|
+
validate_embedding_dimension!(query_embedding, @expected_dimension)
|
|
31
41
|
# Take an atomic snapshot before iterating. Hash#dup is a C-level
|
|
32
42
|
# call that completes without releasing the GVL, so it is atomic with
|
|
33
43
|
# respect to any other Ruby thread. Iterating the copy instead of
|
|
@@ -18,8 +18,11 @@ module Phronomy
|
|
|
18
18
|
# store.add(id: "doc1", embedding: [0.1, 0.9], metadata: {text: "hello"})
|
|
19
19
|
# results = store.search(query_embedding: [0.1, 0.8], k: 5)
|
|
20
20
|
class Pgvector < Base
|
|
21
|
-
# @param model_class [Class]
|
|
22
|
-
|
|
21
|
+
# @param model_class [Class] ActiveRecord model with id/embedding/metadata columns
|
|
22
|
+
# @param dimension [Integer, nil] expected embedding dimension for Phronomy-side
|
|
23
|
+
# pre-validation. When nil, dimension enforcement is delegated to the
|
|
24
|
+
# database schema; no pre-validation is performed by Phronomy.
|
|
25
|
+
def initialize(model_class:, dimension: nil)
|
|
23
26
|
begin
|
|
24
27
|
require "pgvector"
|
|
25
28
|
rescue LoadError
|
|
@@ -28,12 +31,14 @@ module Phronomy
|
|
|
28
31
|
"Add `gem 'pgvector'` to your Gemfile."
|
|
29
32
|
end
|
|
30
33
|
@model_class = model_class
|
|
34
|
+
@dimension = dimension
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
# @param id [String]
|
|
34
38
|
# @param embedding [Array<Float>]
|
|
35
39
|
# @param metadata [Hash]
|
|
36
40
|
def add(id:, embedding:, metadata: {})
|
|
41
|
+
validate_embedding_dimension!(embedding, @dimension)
|
|
37
42
|
@model_class.upsert(
|
|
38
43
|
{id: id, embedding: safe_vector(embedding), metadata: metadata.to_json},
|
|
39
44
|
unique_by: :id
|
|
@@ -45,6 +50,7 @@ module Phronomy
|
|
|
45
50
|
# @param k [Integer]
|
|
46
51
|
# @return [Array<Hash>] sorted by descending similarity score
|
|
47
52
|
def search(query_embedding:, k: 5)
|
|
53
|
+
validate_embedding_dimension!(query_embedding, @dimension)
|
|
48
54
|
vec = safe_vector_literal(query_embedding)
|
|
49
55
|
k_safe = Integer(k)
|
|
50
56
|
conn = @model_class.connection
|
|
@@ -25,7 +25,11 @@ module Phronomy
|
|
|
25
25
|
|
|
26
26
|
# @param redis [Redis] configured Redis client
|
|
27
27
|
# @param index_name [String] RediSearch index name
|
|
28
|
-
# @param dimension [Integer, nil] vector dimension; auto-detected on first add
|
|
28
|
+
# @param dimension [Integer, nil] vector dimension; auto-detected on first add.
|
|
29
|
+
# When connecting to an **existing** RediSearch index, you MUST pass
|
|
30
|
+
# dimension: explicitly. Without it, a freshly constructed instance
|
|
31
|
+
# treats the index as uninitialized until #add is called, and #search
|
|
32
|
+
# silently returns [] in the meantime.
|
|
29
33
|
def initialize(redis:, index_name: "phronomy_vectors", dimension: nil)
|
|
30
34
|
begin
|
|
31
35
|
require "redis"
|
|
@@ -45,7 +49,11 @@ module Phronomy
|
|
|
45
49
|
# @param embedding [Array<Float>]
|
|
46
50
|
# @param metadata [Hash]
|
|
47
51
|
def add(id:, embedding:, metadata: {})
|
|
48
|
-
|
|
52
|
+
# Establish expected dimension on first add (not race-free for concurrent
|
|
53
|
+
# first adds), then validate, then create/reuse the index.
|
|
54
|
+
@dimension ||= embedding.size
|
|
55
|
+
validate_embedding_dimension!(embedding, @dimension)
|
|
56
|
+
ensure_index!(@dimension)
|
|
49
57
|
@redis.call(
|
|
50
58
|
"HSET", "#{DOC_PREFIX}#{id}",
|
|
51
59
|
"embedding", pack_vector(embedding),
|
|
@@ -58,7 +66,12 @@ module Phronomy
|
|
|
58
66
|
# @param k [Integer]
|
|
59
67
|
# @return [Array<Hash>] sorted by descending similarity score
|
|
60
68
|
def search(query_embedding:, k: 5)
|
|
61
|
-
|
|
69
|
+
# search never establishes dimension. If dimension is unknown and the
|
|
70
|
+
# index has not been created yet, there are no documents to return.
|
|
71
|
+
return [] if @dimension.nil? && !@index_created
|
|
72
|
+
|
|
73
|
+
validate_embedding_dimension!(query_embedding, @dimension)
|
|
74
|
+
ensure_index!(@dimension)
|
|
62
75
|
k_safe = Integer(k)
|
|
63
76
|
blob = pack_vector(query_embedding)
|
|
64
77
|
|
data/lib/phronomy/version.rb
CHANGED