phronomy 0.7.0 → 0.8.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/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +170 -47
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +6 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +285 -137
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +42 -65
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +27 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/concurrency/gate_registry.rb +52 -0
- data/lib/phronomy/concurrency/pool_registry.rb +57 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +13 -9
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +184 -46
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +389 -0
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +156 -7
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +37 -2
- data/lib/phronomy/workflow_runner.rb +28 -77
- data/lib/phronomy.rb +43 -0
- metadata +73 -33
- data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
- data/lib/phronomy/cancellation_token.rb +0 -92
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/embeddings/base.rb +0 -22
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -201
- data/lib/phronomy/knowledge_source/base.rb +0 -36
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/vector_store/base.rb +0 -82
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# A prompt template that substitutes {{variable}} placeholders in a string.
|
|
5
|
-
#
|
|
6
|
-
# @example Simple human template
|
|
7
|
-
# t = Phronomy::PromptTemplate.new(template: "Translate to {{lang}}: {{text}}")
|
|
8
|
-
# t.format(lang: "French", text: "Hello")
|
|
9
|
-
# # => "Translate to French: Hello"
|
|
10
|
-
#
|
|
11
|
-
# @example With a system template
|
|
12
|
-
# t = Phronomy::PromptTemplate.new(
|
|
13
|
-
# template: "{{question}}",
|
|
14
|
-
# system_template: "You are a {{role}} assistant."
|
|
15
|
-
# )
|
|
16
|
-
# t.format_system(role: "helpful")
|
|
17
|
-
# # => "You are a helpful assistant."
|
|
18
|
-
#
|
|
19
|
-
# As a Runnable, #invoke accepts a Hash of variables and returns a Hash
|
|
20
|
-
# with :prompt (and optionally :system) keys.
|
|
21
|
-
class PromptTemplate
|
|
22
|
-
include Phronomy::Runnable
|
|
23
|
-
|
|
24
|
-
PLACEHOLDER = /\{\{(\w+)\}\}/
|
|
25
|
-
|
|
26
|
-
attr_reader :template, :system_template
|
|
27
|
-
|
|
28
|
-
# @param template [String] human message template with {{var}} placeholders
|
|
29
|
-
# @param system_template [String, nil] optional system message template
|
|
30
|
-
# @api public
|
|
31
|
-
def initialize(template:, system_template: nil)
|
|
32
|
-
@template = template
|
|
33
|
-
@system_template = system_template
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Substitute all {{var}} placeholders in the human template.
|
|
37
|
-
#
|
|
38
|
-
# @param variables [Hash{Symbol => String}]
|
|
39
|
-
# @return [String]
|
|
40
|
-
# @api public
|
|
41
|
-
def format(**variables)
|
|
42
|
-
substitute(@template, variables)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Substitute all {{var}} placeholders in the system template.
|
|
46
|
-
# Returns nil when no system template was set.
|
|
47
|
-
#
|
|
48
|
-
# @param variables [Hash{Symbol => String}]
|
|
49
|
-
# @return [String, nil]
|
|
50
|
-
# @api public
|
|
51
|
-
def format_system(**variables)
|
|
52
|
-
@system_template && substitute(@system_template, variables)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Runnable interface: accepts a Hash of variable values.
|
|
56
|
-
# Returns { prompt: String, system: String|nil }.
|
|
57
|
-
#
|
|
58
|
-
# @param input [Hash{Symbol => String}]
|
|
59
|
-
# @return [Hash]
|
|
60
|
-
# @api public
|
|
61
|
-
def invoke(input, config: {})
|
|
62
|
-
vars = normalize_input(input)
|
|
63
|
-
result = {prompt: format(**vars)}
|
|
64
|
-
sys = format_system(**vars)
|
|
65
|
-
result[:system] = sys if sys
|
|
66
|
-
result
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Returns the list of placeholder names found in both templates.
|
|
70
|
-
#
|
|
71
|
-
# @return [Array<Symbol>]
|
|
72
|
-
# @api public
|
|
73
|
-
def variables
|
|
74
|
-
names = @template.scan(PLACEHOLDER).flatten
|
|
75
|
-
names += @system_template.scan(PLACEHOLDER).flatten if @system_template
|
|
76
|
-
names.map(&:to_sym).uniq
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
private
|
|
80
|
-
|
|
81
|
-
def substitute(text, variables)
|
|
82
|
-
text.gsub(PLACEHOLDER) do |match|
|
|
83
|
-
key = Regexp.last_match(1).to_sym
|
|
84
|
-
variables.fetch(key) { raise KeyError, "Missing variable: {{#{key}}}" }
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def normalize_input(input)
|
|
89
|
-
case input
|
|
90
|
-
when Hash then input
|
|
91
|
-
when String then {input: input}
|
|
92
|
-
else raise ArgumentError, "PromptTemplate#invoke expects a Hash of variables, got #{input.class}"
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
end
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Splitter
|
|
5
|
-
# Abstract base class for text splitters.
|
|
6
|
-
#
|
|
7
|
-
# A splitter takes a single document hash (or plain text) and returns an
|
|
8
|
-
# array of smaller chunk documents:
|
|
9
|
-
#
|
|
10
|
-
# [{ text: String, metadata: Hash }, ...]
|
|
11
|
-
#
|
|
12
|
-
# Subclasses must implement {#split}.
|
|
13
|
-
class Base
|
|
14
|
-
# Split +document+ into an array of chunk documents.
|
|
15
|
-
#
|
|
16
|
-
# @param document [Hash, String]
|
|
17
|
-
# Either a document hash (<tt>{ text: String, metadata: Hash }</tt>)
|
|
18
|
-
# returned by a Loader, or a plain String.
|
|
19
|
-
# @return [Array<Hash>] array of <tt>{ text: String, metadata: Hash }</tt>
|
|
20
|
-
# @raise [NotImplementedError] when not overridden by a subclass
|
|
21
|
-
# @api public
|
|
22
|
-
def split(document)
|
|
23
|
-
raise NotImplementedError, "#{self.class}#split is not implemented"
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Convenience method: split an array of documents.
|
|
27
|
-
#
|
|
28
|
-
# @param documents [Array<Hash, String>]
|
|
29
|
-
# @return [Array<Hash>]
|
|
30
|
-
# @api public
|
|
31
|
-
def split_all(documents)
|
|
32
|
-
documents.flat_map { |doc| split(doc) }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
|
|
37
|
-
# Normalise a document-or-string argument into {text:, metadata:}.
|
|
38
|
-
def normalise(document)
|
|
39
|
-
case document
|
|
40
|
-
when Hash then {text: document[:text].to_s, metadata: document.fetch(:metadata, {})}
|
|
41
|
-
when String then {text: document, metadata: {}}
|
|
42
|
-
else raise ArgumentError, "document must be a Hash or String, got #{document.class}"
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Splitter
|
|
5
|
-
# Splits text into fixed-size character chunks with optional overlap.
|
|
6
|
-
#
|
|
7
|
-
# @example
|
|
8
|
-
# splitter = Phronomy::Splitter::FixedSizeSplitter.new(chunk_size: 200, chunk_overlap: 20)
|
|
9
|
-
# chunks = splitter.split({ text: long_text, metadata: { source: "doc.txt" } })
|
|
10
|
-
# # => [
|
|
11
|
-
# # { text: "...(200 chars)...", metadata: { source: "doc.txt", chunk: 0 } },
|
|
12
|
-
# # { text: "...(200 chars, 20-char overlap)...", metadata: { source: "doc.txt", chunk: 1 } },
|
|
13
|
-
# # ]
|
|
14
|
-
class FixedSizeSplitter < Base
|
|
15
|
-
# @param chunk_size [Integer] maximum characters per chunk (default: 1000)
|
|
16
|
-
# @param chunk_overlap [Integer] characters to repeat at the start of each
|
|
17
|
-
# subsequent chunk (default: 200); must be less than chunk_size
|
|
18
|
-
# @api public
|
|
19
|
-
def initialize(chunk_size: 1000, chunk_overlap: 200)
|
|
20
|
-
raise ArgumentError, "chunk_overlap must be less than chunk_size" if chunk_overlap >= chunk_size
|
|
21
|
-
|
|
22
|
-
@chunk_size = chunk_size
|
|
23
|
-
@chunk_overlap = chunk_overlap
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# @param document [Hash, String]
|
|
27
|
-
# @return [Array<Hash>]
|
|
28
|
-
# @api public
|
|
29
|
-
def split(document)
|
|
30
|
-
doc = normalise(document)
|
|
31
|
-
text = doc[:text]
|
|
32
|
-
base_metadata = doc[:metadata]
|
|
33
|
-
|
|
34
|
-
chunks = []
|
|
35
|
-
start = 0
|
|
36
|
-
index = 0
|
|
37
|
-
|
|
38
|
-
while start < text.length
|
|
39
|
-
chunk_text = text[start, @chunk_size]
|
|
40
|
-
chunks << {text: chunk_text, metadata: base_metadata.merge(chunk: index)}
|
|
41
|
-
break if start + @chunk_size >= text.length
|
|
42
|
-
|
|
43
|
-
start += @chunk_size - @chunk_overlap
|
|
44
|
-
index += 1
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
chunks
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Splitter
|
|
5
|
-
# Splits text recursively using a prioritised list of separator strings.
|
|
6
|
-
#
|
|
7
|
-
# The splitter tries each separator in order. When a separator produces
|
|
8
|
-
# chunks that are still larger than +chunk_size+, it recurses with the
|
|
9
|
-
# next separator in the list. This mirrors LangChain's
|
|
10
|
-
# RecursiveCharacterTextSplitter behaviour.
|
|
11
|
-
#
|
|
12
|
-
# Default separators (in priority order):
|
|
13
|
-
# 1. "\n\n" — paragraph breaks
|
|
14
|
-
# 2. "\n" — line breaks
|
|
15
|
-
# 3. ". " — sentence boundaries
|
|
16
|
-
# 4. " " — word boundaries
|
|
17
|
-
# 5. "" — character-level fallback
|
|
18
|
-
#
|
|
19
|
-
# @example
|
|
20
|
-
# splitter = Phronomy::Splitter::RecursiveSplitter.new(chunk_size: 300, chunk_overlap: 30)
|
|
21
|
-
# chunks = splitter.split({ text: long_markdown, metadata: { source: "guide.md" } })
|
|
22
|
-
class RecursiveSplitter < Base
|
|
23
|
-
DEFAULT_SEPARATORS = ["\n\n", "\n", ". ", " ", ""].freeze
|
|
24
|
-
|
|
25
|
-
# @param chunk_size [Integer] maximum characters per chunk (default: 1000)
|
|
26
|
-
# @param chunk_overlap [Integer] overlap characters (default: 200)
|
|
27
|
-
# @param separators [Array<String>] separator list in priority order
|
|
28
|
-
# @api public
|
|
29
|
-
def initialize(chunk_size: 1000, chunk_overlap: 200, separators: DEFAULT_SEPARATORS)
|
|
30
|
-
raise ArgumentError, "chunk_overlap must be less than chunk_size" if chunk_overlap >= chunk_size
|
|
31
|
-
|
|
32
|
-
@chunk_size = chunk_size
|
|
33
|
-
@chunk_overlap = chunk_overlap
|
|
34
|
-
@separators = separators
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# @param document [Hash, String]
|
|
38
|
-
# @return [Array<Hash>]
|
|
39
|
-
# @api public
|
|
40
|
-
def split(document)
|
|
41
|
-
doc = normalise(document)
|
|
42
|
-
texts = recursive_split(doc[:text], @separators)
|
|
43
|
-
merge_with_overlap(texts).each_with_index.map do |text, idx|
|
|
44
|
-
{text: text, metadata: doc[:metadata].merge(chunk: idx)}
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
private
|
|
49
|
-
|
|
50
|
-
# Split +text+ using the first separator that yields non-trivial pieces,
|
|
51
|
-
# then recurse on any piece that is still too large.
|
|
52
|
-
def recursive_split(text, separators)
|
|
53
|
-
return [text] if text.length <= @chunk_size || separators.empty?
|
|
54
|
-
|
|
55
|
-
sep, *rest_seps = separators
|
|
56
|
-
|
|
57
|
-
# Character-level fallback: just slice
|
|
58
|
-
if sep == ""
|
|
59
|
-
return FixedSizeSplitter
|
|
60
|
-
.new(chunk_size: @chunk_size, chunk_overlap: @chunk_overlap)
|
|
61
|
-
.split(text)
|
|
62
|
-
.map { |c| c[:text] }
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
parts = text.split(sep)
|
|
66
|
-
|
|
67
|
-
# If this separator doesn't split, try the next
|
|
68
|
-
return recursive_split(text, rest_seps) if parts.length <= 1
|
|
69
|
-
|
|
70
|
-
# Re-attach the separator to each part except the last so context is preserved
|
|
71
|
-
parts_with_sep = parts.each_with_index.map do |part, i|
|
|
72
|
-
(i < parts.length - 1) ? part + sep : part
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
parts_with_sep.flat_map do |part|
|
|
76
|
-
if part.length > @chunk_size
|
|
77
|
-
recursive_split(part, rest_seps)
|
|
78
|
-
else
|
|
79
|
-
[part]
|
|
80
|
-
end
|
|
81
|
-
end.reject { |t| t.strip.empty? }
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Merge small adjacent pieces and apply overlap between chunks.
|
|
85
|
-
def merge_with_overlap(texts)
|
|
86
|
-
merged = []
|
|
87
|
-
current = +""
|
|
88
|
-
|
|
89
|
-
texts.each do |text|
|
|
90
|
-
if current.length + text.length <= @chunk_size
|
|
91
|
-
current << text
|
|
92
|
-
else
|
|
93
|
-
merged << current.strip unless current.strip.empty?
|
|
94
|
-
# Start next chunk with overlap from the end of current
|
|
95
|
-
overlap_text = (current.length > @chunk_overlap) ? current[-@chunk_overlap..] : current
|
|
96
|
-
current = overlap_text + text
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
merged << current.strip unless current.strip.empty?
|
|
101
|
-
merged
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module VectorStore
|
|
5
|
-
# Abstract interface for vector stores.
|
|
6
|
-
#
|
|
7
|
-
# Implementations manage a collection of (embedding, metadata) pairs and
|
|
8
|
-
# support similarity search.
|
|
9
|
-
class Base
|
|
10
|
-
# Add a document with its vector embedding.
|
|
11
|
-
#
|
|
12
|
-
# @param id [String] unique document identifier
|
|
13
|
-
# @param embedding [Array<Float>] vector embedding
|
|
14
|
-
# @param metadata [Hash] arbitrary metadata (e.g. the original message object)
|
|
15
|
-
# @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
16
|
-
# @api public
|
|
17
|
-
def add(id:, embedding:, metadata: {}, cancellation_token: nil)
|
|
18
|
-
cancellation_token&.raise_if_cancelled!
|
|
19
|
-
raise NotImplementedError, "#{self.class}#add is not implemented"
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Return the k most similar documents to the query embedding.
|
|
23
|
-
#
|
|
24
|
-
# @param query_embedding [Array<Float>]
|
|
25
|
-
# @param k [Integer] number of results
|
|
26
|
-
# @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
27
|
-
# @return [Array<Hash>] each element: { id:, score:, metadata: }
|
|
28
|
-
# @api public
|
|
29
|
-
def search(query_embedding:, k: 5, cancellation_token: nil)
|
|
30
|
-
cancellation_token&.raise_if_cancelled!
|
|
31
|
-
raise NotImplementedError, "#{self.class}#search is not implemented"
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Remove a single document by id.
|
|
35
|
-
#
|
|
36
|
-
# @param id [String] document identifier
|
|
37
|
-
# @api public
|
|
38
|
-
def remove(id:)
|
|
39
|
-
raise NotImplementedError, "#{self.class}#remove is not implemented"
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Remove all documents.
|
|
43
|
-
def clear
|
|
44
|
-
raise NotImplementedError, "#{self.class}#clear is not implemented"
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Return the number of documents stored.
|
|
48
|
-
#
|
|
49
|
-
# @return [Integer]
|
|
50
|
-
# @api public
|
|
51
|
-
def size
|
|
52
|
-
raise NotImplementedError, "#{self.class}#size is not implemented"
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
private
|
|
56
|
-
|
|
57
|
-
# Validates that embedding has the expected dimension.
|
|
58
|
-
# Raises ArgumentError if sizes differ.
|
|
59
|
-
# A nil expected_dimension is a no-op (dimension not yet established).
|
|
60
|
-
def validate_embedding_dimension!(embedding, expected_dimension)
|
|
61
|
-
return unless expected_dimension
|
|
62
|
-
|
|
63
|
-
actual = embedding.size
|
|
64
|
-
return if actual == expected_dimension
|
|
65
|
-
|
|
66
|
-
raise ArgumentError,
|
|
67
|
-
"Embedding dimension mismatch: expected #{expected_dimension}, got #{actual}"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Validates that k is a positive integer.
|
|
71
|
-
# Accepts any value accepted by Integer() (e.g. "5"), but raises
|
|
72
|
-
# ArgumentError for non-integer strings, zero, and negative values.
|
|
73
|
-
def validate_k!(k)
|
|
74
|
-
int_k = Integer(k)
|
|
75
|
-
raise ArgumentError, "k must be a positive integer, got #{int_k}" unless int_k >= 1
|
|
76
|
-
int_k
|
|
77
|
-
rescue ArgumentError => e
|
|
78
|
-
raise ArgumentError, "k must be a positive integer: #{e.message}"
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module VectorStore
|
|
5
|
-
# Pure-Ruby in-memory vector store using cosine similarity.
|
|
6
|
-
#
|
|
7
|
-
# Intended for tests, short-lived agents, and Retrieval::Semantic scenarios where
|
|
8
|
-
# the message count is small enough that a linear scan is fast enough.
|
|
9
|
-
#
|
|
10
|
-
# @example
|
|
11
|
-
# store = Phronomy::VectorStore::InMemory.new
|
|
12
|
-
# store.add(id: "1", embedding: [0.1, 0.9], metadata: { message: msg })
|
|
13
|
-
# results = store.search(query_embedding: [0.1, 0.8], k: 3)
|
|
14
|
-
class InMemory < Base
|
|
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
|
-
# @api public
|
|
20
|
-
def initialize(dimension: nil)
|
|
21
|
-
@documents = {}
|
|
22
|
-
@expected_dimension = dimension
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# @param id [String]
|
|
26
|
-
# @param embedding [Array<Float>]
|
|
27
|
-
# @param metadata [Hash]
|
|
28
|
-
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
29
|
-
# @api public
|
|
30
|
-
def add(id:, embedding:, metadata: {}, cancellation_token: nil)
|
|
31
|
-
cancellation_token&.raise_if_cancelled!
|
|
32
|
-
# Establish expected dimension on first add, then validate.
|
|
33
|
-
@expected_dimension ||= embedding.size
|
|
34
|
-
validate_embedding_dimension!(embedding, @expected_dimension)
|
|
35
|
-
@documents[id] = {embedding: embedding, metadata: metadata}
|
|
36
|
-
self
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# @param query_embedding [Array<Float>]
|
|
40
|
-
# @param k [Integer]
|
|
41
|
-
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
42
|
-
# @return [Array<Hash>] sorted by descending score
|
|
43
|
-
# @api public
|
|
44
|
-
def search(query_embedding:, k: 5, cancellation_token: nil)
|
|
45
|
-
cancellation_token&.raise_if_cancelled!
|
|
46
|
-
k = validate_k!(k)
|
|
47
|
-
# search never establishes dimension; validate only when dimension is known.
|
|
48
|
-
validate_embedding_dimension!(query_embedding, @expected_dimension)
|
|
49
|
-
# Take an atomic snapshot before iterating. Hash#dup is a C-level
|
|
50
|
-
# call that completes without releasing the GVL, so it is atomic with
|
|
51
|
-
# respect to any other Ruby thread. Iterating the copy instead of
|
|
52
|
-
# @documents directly prevents "can't add a new key into hash during
|
|
53
|
-
# iteration" when a concurrent thread calls #add.
|
|
54
|
-
snapshot = @documents.dup
|
|
55
|
-
results = snapshot.map do |id, doc|
|
|
56
|
-
score = cosine_similarity(query_embedding, doc[:embedding])
|
|
57
|
-
{id: id, score: score, metadata: doc[:metadata]}
|
|
58
|
-
end
|
|
59
|
-
results.sort_by { |r| -r[:score] }.first(k)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def remove(id:)
|
|
63
|
-
@documents.delete(id)
|
|
64
|
-
self
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def clear
|
|
68
|
-
@documents.clear
|
|
69
|
-
self
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# @return [Integer] number of documents stored
|
|
73
|
-
# @api public
|
|
74
|
-
def size
|
|
75
|
-
@documents.size
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
private
|
|
79
|
-
|
|
80
|
-
def cosine_similarity(a, b)
|
|
81
|
-
return 0.0 if a.empty? || b.empty?
|
|
82
|
-
|
|
83
|
-
dot = a.zip(b).sum { |x, y| x * y }
|
|
84
|
-
norm_a = Math.sqrt(a.sum { |x| x**2 })
|
|
85
|
-
norm_b = Math.sqrt(b.sum { |x| x**2 })
|
|
86
|
-
|
|
87
|
-
return 0.0 if norm_a.zero? || norm_b.zero?
|
|
88
|
-
|
|
89
|
-
dot / (norm_a * norm_b)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module Phronomy
|
|
6
|
-
module VectorStore
|
|
7
|
-
# PostgreSQL-backed vector store using the pgvector extension.
|
|
8
|
-
#
|
|
9
|
-
# Requires:
|
|
10
|
-
# - The +pgvector+ gem (add to your Gemfile)
|
|
11
|
-
# - An ActiveRecord model class with the following columns:
|
|
12
|
-
# id (string / uuid)
|
|
13
|
-
# embedding (vector — from the pgvector column type)
|
|
14
|
-
# metadata (text or jsonb — stores arbitrary metadata as JSON)
|
|
15
|
-
#
|
|
16
|
-
# @example Usage
|
|
17
|
-
# store = Phronomy::VectorStore::Pgvector.new(model_class: VectorDocument)
|
|
18
|
-
# store.add(id: "doc1", embedding: [0.1, 0.9], metadata: {text: "hello"})
|
|
19
|
-
# results = store.search(query_embedding: [0.1, 0.8], k: 5)
|
|
20
|
-
class Pgvector < Base
|
|
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
|
-
# @api public
|
|
26
|
-
def initialize(model_class:, dimension: nil)
|
|
27
|
-
begin
|
|
28
|
-
require "pgvector"
|
|
29
|
-
rescue LoadError
|
|
30
|
-
raise LoadError,
|
|
31
|
-
"pgvector gem is required for Phronomy::VectorStore::Pgvector. " \
|
|
32
|
-
"Add `gem 'pgvector'` to your Gemfile."
|
|
33
|
-
end
|
|
34
|
-
@model_class = model_class
|
|
35
|
-
@dimension = dimension
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# @param id [String]
|
|
39
|
-
# @param embedding [Array<Float>]
|
|
40
|
-
# @param metadata [Hash]
|
|
41
|
-
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
42
|
-
# @api public
|
|
43
|
-
def add(id:, embedding:, metadata: {}, cancellation_token: nil)
|
|
44
|
-
cancellation_token&.raise_if_cancelled!
|
|
45
|
-
validate_embedding_dimension!(embedding, @dimension)
|
|
46
|
-
@model_class.upsert(
|
|
47
|
-
{id: id, embedding: safe_vector(embedding), metadata: metadata.to_json},
|
|
48
|
-
unique_by: :id
|
|
49
|
-
)
|
|
50
|
-
self
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# @param query_embedding [Array<Float>]
|
|
54
|
-
# @param k [Integer]
|
|
55
|
-
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
56
|
-
# @return [Array<Hash>] sorted by descending similarity score
|
|
57
|
-
# @api public
|
|
58
|
-
def search(query_embedding:, k: 5, cancellation_token: nil)
|
|
59
|
-
cancellation_token&.raise_if_cancelled!
|
|
60
|
-
k_safe = validate_k!(k)
|
|
61
|
-
validate_embedding_dimension!(query_embedding, @dimension)
|
|
62
|
-
vec = safe_vector_literal(query_embedding)
|
|
63
|
-
conn = @model_class.connection
|
|
64
|
-
quoted_vec = "#{conn.quote(vec)}::vector"
|
|
65
|
-
|
|
66
|
-
@model_class
|
|
67
|
-
.select("id, metadata, 1 - (embedding <=> #{quoted_vec}) AS score")
|
|
68
|
-
.order("embedding <=> #{quoted_vec}")
|
|
69
|
-
.limit(k_safe)
|
|
70
|
-
.map do |r|
|
|
71
|
-
{
|
|
72
|
-
id: r.id.to_s,
|
|
73
|
-
score: r.score.to_f,
|
|
74
|
-
metadata: parse_metadata(r.metadata)
|
|
75
|
-
}
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def remove(id:)
|
|
80
|
-
@model_class.where(id: id).delete_all
|
|
81
|
-
self
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def clear
|
|
85
|
-
@model_class.delete_all
|
|
86
|
-
self
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Returns the number of documents in the backing table.
|
|
90
|
-
def size
|
|
91
|
-
@model_class.count
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
private
|
|
95
|
-
|
|
96
|
-
# Parses a metadata value returned by the pg driver.
|
|
97
|
-
# Handles NULL (nil), already-parsed Hash, and JSON string forms.
|
|
98
|
-
def parse_metadata(raw)
|
|
99
|
-
return {} if raw.nil?
|
|
100
|
-
return symbolize_hash_keys(raw) if raw.is_a?(Hash)
|
|
101
|
-
|
|
102
|
-
parsed = JSON.parse(raw.to_s, symbolize_names: true)
|
|
103
|
-
parsed.is_a?(Hash) ? parsed : {}
|
|
104
|
-
rescue JSON::ParserError
|
|
105
|
-
{}
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Recursively symbolizes keys for an already-parsed Hash.
|
|
109
|
-
def symbolize_hash_keys(hash)
|
|
110
|
-
hash.each_with_object({}) do |(k, v), h|
|
|
111
|
-
h[k.to_sym] = v.is_a?(Hash) ? symbolize_hash_keys(v) : v
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# Validates that all elements are numeric and converts to a pgvector-
|
|
116
|
-
# compatible literal string (e.g. "[1.0,0.5,-0.3]").
|
|
117
|
-
def safe_vector_literal(embedding)
|
|
118
|
-
"[#{embedding.map { |v| Float(v) }.join(",")}]"
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Returns a validated vector for the upsert call.
|
|
122
|
-
def safe_vector(embedding)
|
|
123
|
-
safe_vector_literal(embedding)
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
end
|