engram 0.3.0 → 0.5.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 +118 -0
- data/README.md +409 -39
- data/lib/engram/adapters/in_memory_processed_turns.rb +40 -8
- data/lib/engram/adapters/in_memory_store.rb +49 -11
- data/lib/engram/adapters/null_embedder.rb +9 -0
- data/lib/engram/adapters/pgvector_store.rb +67 -20
- data/lib/engram/adapters/ruby_llm_embedder.rb +45 -4
- data/lib/engram/configuration.rb +5 -1
- data/lib/engram/consolidators/heuristic_consolidator.rb +7 -1
- data/lib/engram/consolidators/llm_consolidator.rb +44 -12
- data/lib/engram/embedding_metadata.rb +135 -0
- data/lib/engram/extractors/llm_extractor.rb +16 -6
- data/lib/engram/instrumentation.rb +57 -0
- data/lib/engram/memory.rb +42 -17
- data/lib/engram/memory_kind.rb +19 -0
- data/lib/engram/persistence.rb +39 -0
- data/lib/engram/persistence_policy.rb +45 -0
- data/lib/engram/ports/memory_store.rb +16 -8
- data/lib/engram/ports/processed_turns.rb +16 -8
- data/lib/engram/provenance.rb +257 -0
- data/lib/engram/rails/cache_processed_turns.rb +51 -10
- data/lib/engram/rails/observe_job.rb +5 -0
- data/lib/engram/rails/tasks.rake +26 -0
- data/lib/engram/railtie.rb +4 -0
- data/lib/engram/record.rb +8 -3
- data/lib/engram/reserved_metadata.rb +52 -0
- data/lib/engram/use_cases/forget.rb +6 -2
- data/lib/engram/use_cases/inject.rb +17 -3
- data/lib/engram/use_cases/observe.rb +83 -19
- data/lib/engram/use_cases/rebuild_embeddings.rb +189 -0
- data/lib/engram/use_cases/recall.rb +28 -9
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +11 -0
- data/lib/generators/engram/install_generator.rb +10 -0
- data/lib/generators/engram/templates/create_engram_memories.rb.tt +10 -3
- metadata +14 -4
|
@@ -1,30 +1,71 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
3
6
|
module Engram
|
|
4
7
|
module Rails
|
|
5
|
-
#
|
|
6
|
-
#
|
|
8
|
+
# Claims use atomic unless-exist writes. Generic ActiveSupport caches do not expose
|
|
9
|
+
# compare-and-delete, so release leaves a claim in place until lease expiry and completion
|
|
10
|
+
# never deletes it.
|
|
7
11
|
class CacheProcessedTurns
|
|
8
12
|
include Engram::Ports::ProcessedTurns
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
class NonAtomicCacheError < Engram::Error; end
|
|
15
|
+
class CacheWriteError < Engram::Error; end
|
|
16
|
+
|
|
17
|
+
def initialize(namespace: "engram:processed_turns", ttl: 86_400, lease_ttl: 300, cache: ::Rails.cache)
|
|
11
18
|
@namespace = namespace
|
|
12
19
|
@ttl = ttl
|
|
20
|
+
@lease_ttl = lease_ttl
|
|
21
|
+
@cache = cache
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def claim(scope:, key:)
|
|
25
|
+
return if completed?(scope: scope, key: key)
|
|
26
|
+
token = SecureRandom.uuid
|
|
27
|
+
result = @cache.write(claim_key(scope, key), token, expires_in: @lease_ttl, unless_exist: true)
|
|
28
|
+
unless result == true || result == false
|
|
29
|
+
raise NonAtomicCacheError, "cache backend must return true/false for atomic write(unless_exist: true)"
|
|
30
|
+
end
|
|
31
|
+
return unless result
|
|
32
|
+
return token unless completed?(scope: scope, key: key)
|
|
33
|
+
|
|
34
|
+
release(scope: scope, key: key, claim: token)
|
|
35
|
+
nil
|
|
13
36
|
end
|
|
14
37
|
|
|
15
|
-
def
|
|
16
|
-
|
|
38
|
+
def complete(scope:, key:, claim:)
|
|
39
|
+
written = @cache.write(completed_key(scope, key), true, expires_in: @ttl)
|
|
40
|
+
raise CacheWriteError, "cache backend failed to write completed observation" unless written
|
|
41
|
+
true
|
|
17
42
|
end
|
|
18
43
|
|
|
19
|
-
def
|
|
20
|
-
|
|
21
|
-
|
|
44
|
+
def release(scope:, key:, claim:)
|
|
45
|
+
# A read followed by delete could delete a successor lease.
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def completed?(scope:, key:)
|
|
50
|
+
@cache.exist?(completed_key(scope, key))
|
|
22
51
|
end
|
|
23
52
|
|
|
24
53
|
private
|
|
25
54
|
|
|
26
|
-
def
|
|
27
|
-
|
|
55
|
+
def digest(scope, key)
|
|
56
|
+
encoded = [scope, key].each_with_object(+"") do |component, buffer|
|
|
57
|
+
bytes = component.to_s.encode(Encoding::UTF_8)
|
|
58
|
+
buffer << [bytes.bytesize].pack("N") << bytes
|
|
59
|
+
end
|
|
60
|
+
Digest::SHA256.hexdigest(encoded)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def claim_key(scope, key)
|
|
64
|
+
"#{@namespace}:claim:#{digest(scope, key)}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def completed_key(scope, key)
|
|
68
|
+
"#{@namespace}:completed:#{digest(scope, key)}"
|
|
28
69
|
end
|
|
29
70
|
end
|
|
30
71
|
end
|
|
@@ -4,6 +4,11 @@ module Engram
|
|
|
4
4
|
# Background observation: runs extract → consolidate off the request path.
|
|
5
5
|
# Defined only when ActiveJob is available (loaded via the Railtie).
|
|
6
6
|
class ObserveJob < ActiveJob::Base
|
|
7
|
+
# Later retries must outlast the default claim lease.
|
|
8
|
+
retry_on Engram::ObservationInProgressError,
|
|
9
|
+
wait: ->(executions) { (executions**4) + 2 },
|
|
10
|
+
attempts: 8
|
|
11
|
+
|
|
7
12
|
def perform(scope, messages)
|
|
8
13
|
Engram::Memory.new(scope: scope).observe(messages)
|
|
9
14
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded by the Railtie in Rails apps (where :environment boots the app and its
|
|
4
|
+
# initializers) and by the gem's own Rakefile (which defines a no-op :environment).
|
|
5
|
+
namespace :engram do
|
|
6
|
+
desc "Rebuild embeddings in a memory scope. Usage: bundle exec rake 'engram:rebuild_embeddings[user:1]'"
|
|
7
|
+
task :rebuild_embeddings, [:scope] => :environment do |_, args|
|
|
8
|
+
scope = args[:scope]
|
|
9
|
+
raise ArgumentError, "rebuild_embeddings requires a scope argument" if scope.to_s.empty?
|
|
10
|
+
|
|
11
|
+
stale_only = !%w[false 0 no].include?(ENV.fetch("STALE_ONLY", "true").downcase)
|
|
12
|
+
batch_size = Integer(ENV.fetch("BATCH_SIZE", "100"))
|
|
13
|
+
raise ArgumentError, "BATCH_SIZE must be greater than 0" unless batch_size.positive?
|
|
14
|
+
|
|
15
|
+
result = Engram::UseCases::RebuildEmbeddings.new(
|
|
16
|
+
store: Engram.config.store,
|
|
17
|
+
embedder: Engram.config.embedder
|
|
18
|
+
).call(scope: scope, stale_only: stale_only, batch_size: batch_size)
|
|
19
|
+
|
|
20
|
+
puts "scope=#{result[:scope]} processed=#{result[:processed]} updated=#{result[:updated]} skipped=#{result[:skipped]} failed=#{result[:failed]}"
|
|
21
|
+
|
|
22
|
+
if result[:failed] > 0
|
|
23
|
+
abort "failed_ids=#{result[:failed_ids].join(",")}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/engram/railtie.rb
CHANGED
data/lib/engram/record.rb
CHANGED
|
@@ -5,25 +5,30 @@ module Engram
|
|
|
5
5
|
#
|
|
6
6
|
# `id` is assigned by the store on persistence (nil until then); consolidation uses it
|
|
7
7
|
# to target UPDATE/FORGET. `scope` namespaces memories to an owner (e.g. "user:42").
|
|
8
|
-
# `kind` is a memory type (
|
|
8
|
+
# `kind` is a memory type (fact / preference / instruction / episodic). The legacy
|
|
9
|
+
# `semantic` kind is normalized to `fact` for compatibility with pre-1.0 records.
|
|
9
10
|
class Record
|
|
10
11
|
attr_accessor :id, :last_accessed_at
|
|
11
12
|
attr_reader :content, :embedding, :scope, :kind, :importance, :metadata,
|
|
12
13
|
:created_at
|
|
13
14
|
|
|
14
|
-
def initialize(content:, scope:, id: nil, embedding: nil, kind: :
|
|
15
|
+
def initialize(content:, scope:, id: nil, embedding: nil, kind: :fact,
|
|
15
16
|
importance: 1.0, metadata: {}, created_at: nil, last_accessed_at: nil)
|
|
16
17
|
@id = id
|
|
17
18
|
@content = content
|
|
18
19
|
@scope = scope
|
|
19
20
|
@embedding = embedding
|
|
20
|
-
@kind = kind
|
|
21
|
+
@kind = MemoryKind.normalize(kind)
|
|
21
22
|
@importance = importance
|
|
22
23
|
@metadata = metadata
|
|
23
24
|
@created_at = created_at || Time.now
|
|
24
25
|
@last_accessed_at = last_accessed_at
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
def with(**attributes)
|
|
29
|
+
self.class.new(**to_h.merge(attributes))
|
|
30
|
+
end
|
|
31
|
+
|
|
27
32
|
def to_h
|
|
28
33
|
{
|
|
29
34
|
id: id, content: content, scope: scope, embedding: embedding, kind: kind,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
# Collision-preserving operations for schemas sharing Engram's metadata namespace.
|
|
5
|
+
module ReservedMetadata
|
|
6
|
+
KEY = "_engram"
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def attach(metadata, schema_key, value, namespace_description: "metadata")
|
|
11
|
+
metadata = (metadata || {}).dup
|
|
12
|
+
reserved_values = [metadata.delete(KEY), metadata.delete(:_engram)].compact
|
|
13
|
+
unless reserved_values.all? { |reserved| reserved.is_a?(Hash) }
|
|
14
|
+
raise Engram::Error, "metadata key #{KEY.inspect} is reserved for Engram #{namespace_description}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
reserved = reserved_values.reduce({}) do |merged, reserved_value|
|
|
18
|
+
siblings = reserved_value.reject { |key, _nested| key.to_s == schema_key }
|
|
19
|
+
merge(merged, normalize(siblings))
|
|
20
|
+
end
|
|
21
|
+
metadata.merge(KEY => reserved.merge(schema_key => normalize(value)))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def normalize(value, path = [])
|
|
25
|
+
return value.map { |nested| normalize(nested, path) } if value.is_a?(Array)
|
|
26
|
+
return value unless value.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
value.each_with_object({}) do |(key, nested), normalized|
|
|
29
|
+
string_key = key.to_s
|
|
30
|
+
normalized_value = normalize(nested, path + [string_key])
|
|
31
|
+
normalized[string_key] = if normalized.key?(string_key)
|
|
32
|
+
merge_value(normalized[string_key], normalized_value, path + [string_key])
|
|
33
|
+
else
|
|
34
|
+
normalized_value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def merge(left, right, path = [])
|
|
40
|
+
right.each_with_object(left.dup) do |(key, value), merged|
|
|
41
|
+
merged[key] = merged.key?(key) ? merge_value(merged[key], value, path + [key]) : value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def merge_value(left, right, path)
|
|
46
|
+
return merge(left, right, path) if left.is_a?(Hash) && right.is_a?(Hash)
|
|
47
|
+
return left if left == right
|
|
48
|
+
|
|
49
|
+
raise Engram::Error, "conflicting reserved metadata at #{path.join(".")}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -20,8 +20,12 @@ module Engram
|
|
|
20
20
|
timestamp && timestamp < cutoff && record.importance.to_f < min_importance
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
stale.
|
|
24
|
-
|
|
23
|
+
stale.select do |record|
|
|
24
|
+
next false unless record.id
|
|
25
|
+
|
|
26
|
+
deleted = @store.delete(scope: scope, id: record.id)
|
|
27
|
+
deleted && deleted != 0
|
|
28
|
+
end
|
|
25
29
|
end
|
|
26
30
|
end
|
|
27
31
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
3
5
|
module Engram
|
|
4
6
|
module UseCases
|
|
5
7
|
# Render recalled memories into a prompt as a clearly delimited block.
|
|
@@ -12,10 +14,22 @@ module Engram
|
|
|
12
14
|
|
|
13
15
|
# Returns a new prompt string. If there are no memories, the prompt is unchanged.
|
|
14
16
|
def call(prompt:, memories:)
|
|
15
|
-
|
|
17
|
+
payload = {memory_count: memories&.size.to_i}
|
|
18
|
+
Engram::Instrumentation.instrument("inject", payload) do
|
|
19
|
+
next prompt if memories.nil? || memories.empty?
|
|
20
|
+
|
|
21
|
+
block = memories.map { |memory| render_memory(memory) }.join("\n")
|
|
22
|
+
"#{prompt}\n\n#{@header}:\n<engram-memories>\n#{block}\n</engram-memories>"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def render_memory(memory)
|
|
29
|
+
kind = CGI.escapeHTML((memory.kind || :fact).to_s)
|
|
30
|
+
content = CGI.escapeHTML(memory.content.to_s)
|
|
16
31
|
|
|
17
|
-
|
|
18
|
-
"#{prompt}\n\n#{@header}:\n#{block}"
|
|
32
|
+
%(<engram-memory kind="#{kind}">#{content}</engram-memory>)
|
|
19
33
|
end
|
|
20
34
|
end
|
|
21
35
|
end
|
|
@@ -9,51 +9,115 @@ module Engram
|
|
|
9
9
|
# When a ProcessedTurns store and an idempotency_key are provided, a turn that was
|
|
10
10
|
# already processed is skipped (no extraction, no duplicate memories).
|
|
11
11
|
class Observe
|
|
12
|
-
def initialize(store:, extractor:, consolidator:, processed_turns: nil)
|
|
12
|
+
def initialize(store:, extractor:, consolidator:, processed_turns: nil, embedder: Engram.config.embedder)
|
|
13
13
|
@store = store
|
|
14
14
|
@extractor = extractor
|
|
15
15
|
@consolidator = consolidator
|
|
16
16
|
@processed_turns = processed_turns
|
|
17
|
+
@embedder = embedder
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
# Returns the Array<Decision> that were applied (empty if skipped or nothing found).
|
|
20
21
|
def call(messages:, scope:, idempotency_key: nil)
|
|
21
|
-
|
|
22
|
+
payload = Engram::Instrumentation.payload(
|
|
23
|
+
scope: scope,
|
|
24
|
+
store: @store,
|
|
25
|
+
message_count: messages.size,
|
|
26
|
+
idempotency_key_present: !idempotency_key.nil?
|
|
27
|
+
)
|
|
28
|
+
Engram::Instrumentation.instrument("observe", payload) do
|
|
29
|
+
claim = acquire_claim(scope, idempotency_key)
|
|
30
|
+
if idempotency_key && @processed_turns && !claim
|
|
31
|
+
unless @processed_turns.completed?(scope: scope, key: idempotency_key)
|
|
32
|
+
# A live, incomplete lease is retryable, not a completed duplicate.
|
|
33
|
+
raise Engram::ObservationInProgressError,
|
|
34
|
+
"observation for this turn is claimed but not completed; retry after the claim lease expires"
|
|
35
|
+
end
|
|
22
36
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
payload[:skipped] = true
|
|
38
|
+
payload[:candidate_count] = 0
|
|
39
|
+
payload[:decision_count] = 0
|
|
40
|
+
next []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
candidates = extract(messages: messages, scope: scope)
|
|
45
|
+
payload[:candidate_count] = candidates.size
|
|
46
|
+
if candidates.empty?
|
|
47
|
+
complete_claim(scope, idempotency_key, claim)
|
|
48
|
+
payload[:decision_count] = 0
|
|
49
|
+
next []
|
|
50
|
+
end
|
|
28
51
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
decisions = consolidate(candidates: candidates, scope: scope)
|
|
53
|
+
applied_decisions = decisions.filter_map { |decision| apply(decision, scope) }
|
|
54
|
+
payload[:decision_count] = applied_decisions.size
|
|
55
|
+
payload[:decision_actions] = applied_decisions.map { |decision| decision.action.to_s }
|
|
56
|
+
complete_claim(scope, idempotency_key, claim)
|
|
57
|
+
applied_decisions
|
|
58
|
+
rescue
|
|
59
|
+
release_claim(scope, idempotency_key, claim)
|
|
60
|
+
raise
|
|
61
|
+
end
|
|
62
|
+
end
|
|
33
63
|
end
|
|
34
64
|
|
|
35
65
|
private
|
|
36
66
|
|
|
37
|
-
def
|
|
38
|
-
|
|
67
|
+
def acquire_claim(scope, key)
|
|
68
|
+
@processed_turns.claim(scope: scope, key: key) if key && @processed_turns
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def complete_claim(scope, key, claim)
|
|
72
|
+
@processed_turns.complete(scope: scope, key: key, claim: claim) if claim
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def release_claim(scope, key, claim)
|
|
76
|
+
@processed_turns.release(scope: scope, key: key, claim: claim) if claim
|
|
39
77
|
end
|
|
40
78
|
|
|
41
|
-
def
|
|
42
|
-
|
|
79
|
+
def extract(messages:, scope:)
|
|
80
|
+
payload = Engram::Instrumentation.payload(scope: scope, store: @store, message_count: messages.size)
|
|
81
|
+
Engram::Instrumentation.instrument("extract", payload) do
|
|
82
|
+
candidates = @extractor.extract(messages: messages, scope: scope)
|
|
83
|
+
payload[:candidate_count] = candidates.size
|
|
84
|
+
candidates
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def consolidate(candidates:, scope:)
|
|
89
|
+
payload = Engram::Instrumentation.payload(scope: scope, store: @store, candidate_count: candidates.size)
|
|
90
|
+
Engram::Instrumentation.instrument("consolidate", payload) do
|
|
91
|
+
decisions = @consolidator.reconcile_all(candidates: candidates, scope: scope)
|
|
92
|
+
payload[:decision_count] = decisions.size
|
|
93
|
+
payload[:decision_actions] = decisions.map { |decision| decision.action.to_s }
|
|
94
|
+
decisions
|
|
95
|
+
end
|
|
43
96
|
end
|
|
44
97
|
|
|
45
|
-
def apply(decision)
|
|
98
|
+
def apply(decision, scope)
|
|
46
99
|
case decision.action
|
|
47
100
|
when :add
|
|
48
|
-
|
|
101
|
+
raise Engram::Error, "cannot move memory across scopes" unless decision.candidate.scope == scope
|
|
102
|
+
|
|
103
|
+
decision if persistence.add(decision.candidate, scope: scope)
|
|
49
104
|
when :update
|
|
50
|
-
|
|
105
|
+
if decision.target_id && persistence.update(scope: scope, id: decision.target_id, record: decision.candidate)
|
|
106
|
+
decision
|
|
107
|
+
end
|
|
51
108
|
when :forget
|
|
52
|
-
|
|
109
|
+
if decision.target_id
|
|
110
|
+
deleted = @store.delete(scope: scope, id: decision.target_id)
|
|
111
|
+
decision if deleted && deleted != 0
|
|
112
|
+
end
|
|
53
113
|
when :noop
|
|
54
114
|
nil
|
|
55
115
|
end
|
|
56
116
|
end
|
|
117
|
+
|
|
118
|
+
def persistence
|
|
119
|
+
@persistence ||= Persistence.new(store: @store, embedder: @embedder)
|
|
120
|
+
end
|
|
57
121
|
end
|
|
58
122
|
end
|
|
59
123
|
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
module UseCases
|
|
5
|
+
# Rebuild stored embeddings when the active embedder configuration changes.
|
|
6
|
+
# Returns counts useful for logging/observability:
|
|
7
|
+
# - :processed: total rows examined in scope
|
|
8
|
+
# - :updated: rows re-embedded and persisted
|
|
9
|
+
# - :skipped: rows skipped because they were unchanged or incomplete
|
|
10
|
+
# - :failed: count of rows that failed during rebuild
|
|
11
|
+
# - :failed_ids: record IDs that failed during rebuild
|
|
12
|
+
# - :failed_errors: error class/message keyed by failed record ID
|
|
13
|
+
class RebuildEmbeddings
|
|
14
|
+
def initialize(store:, embedder:)
|
|
15
|
+
@store = store
|
|
16
|
+
@embedder = embedder
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(scope:, stale_only: true, batch_size: 100)
|
|
20
|
+
batch_size = Integer(batch_size)
|
|
21
|
+
raise ArgumentError, "batch_size must be greater than 0" unless batch_size.positive?
|
|
22
|
+
|
|
23
|
+
counts = {
|
|
24
|
+
processed: 0,
|
|
25
|
+
updated: 0,
|
|
26
|
+
skipped: 0,
|
|
27
|
+
failed: 0,
|
|
28
|
+
failed_ids: [],
|
|
29
|
+
failed_errors: {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
after_id = nil
|
|
33
|
+
legacy_index = 0
|
|
34
|
+
legacy_records = nil
|
|
35
|
+
processed_ids = Set.new
|
|
36
|
+
processed_nil_records = Set.new
|
|
37
|
+
loop do
|
|
38
|
+
batch_result = fetch_batch(scope:, batch_size:, after_id:, legacy_records:, legacy_index:)
|
|
39
|
+
legacy_records = batch_result[:legacy_records] if batch_result[:legacy_records]
|
|
40
|
+
batch = batch_result[:records]
|
|
41
|
+
if batch.empty?
|
|
42
|
+
break if legacy_records
|
|
43
|
+
|
|
44
|
+
# Reconcile with one stable snapshot: accepting keyset keywords does
|
|
45
|
+
# not guarantee that an adapter returns complete, ordered pages.
|
|
46
|
+
legacy_records = Array(@store.all(scope: scope))
|
|
47
|
+
legacy_index = 0
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
cursor_record = batch.reverse.find { |candidate| !candidate.id.nil? } unless legacy_records
|
|
52
|
+
|
|
53
|
+
if !legacy_records && fallback_before_processing?(batch:, after_id:)
|
|
54
|
+
legacy_records = Array(@store.all(scope: scope))
|
|
55
|
+
legacy_index = 0
|
|
56
|
+
batch = legacy_records.slice(legacy_index, batch_size) || []
|
|
57
|
+
break if batch.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
batch.each do |record|
|
|
61
|
+
identity = record.id.nil? ? record.object_id : record.id
|
|
62
|
+
seen = record.id.nil? ? processed_nil_records : processed_ids
|
|
63
|
+
next unless seen.add?(identity)
|
|
64
|
+
|
|
65
|
+
counts[:processed] += 1
|
|
66
|
+
|
|
67
|
+
if record.id.nil?
|
|
68
|
+
counts[:skipped] += 1
|
|
69
|
+
next
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if stale_only && !stale?(record)
|
|
73
|
+
counts[:skipped] += 1
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
rebuilt = record.with(embedding: @embedder.embed(record.content))
|
|
79
|
+
rebuilt = EmbeddingMetadata.attach(rebuilt, embedder: @embedder)
|
|
80
|
+
updated = @store.update(scope: scope, id: record.id, record: rebuilt)
|
|
81
|
+
raise Engram::Error, "memory update was not applied" unless updated && updated != 0
|
|
82
|
+
|
|
83
|
+
counts[:updated] += 1
|
|
84
|
+
rescue => error
|
|
85
|
+
counts[:failed] += 1
|
|
86
|
+
record_id = record.id
|
|
87
|
+
counts[:failed_ids] << record_id
|
|
88
|
+
counts[:failed_errors][record_id] = {
|
|
89
|
+
class: error.class.name,
|
|
90
|
+
message: error.message
|
|
91
|
+
}
|
|
92
|
+
warn "Rebuild failed for record ##{record_id}: #{error.class}: #{error.message}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if legacy_records
|
|
97
|
+
legacy_index += batch.length
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
if cursor_record.nil?
|
|
102
|
+
legacy_records = Array(@store.all(scope: scope))
|
|
103
|
+
legacy_index = 0
|
|
104
|
+
next
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if fallback_to_legacy_batching?(batch:, cursor_record:)
|
|
108
|
+
legacy_records = Array(@store.all(scope: scope))
|
|
109
|
+
legacy_index = 0
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
after_id = cursor_record.id
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
{scope: scope, **counts}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def fetch_batch(scope:, batch_size:, after_id:, legacy_records:, legacy_index:)
|
|
122
|
+
if legacy_records
|
|
123
|
+
return {records: legacy_records.slice(legacy_index, batch_size) || [], legacy_records:}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
records = modern_batch(scope:, batch_size:, after_id:)
|
|
127
|
+
return {records:} if records
|
|
128
|
+
|
|
129
|
+
legacy_records = Array(@store.all(scope: scope))
|
|
130
|
+
{records: legacy_records.slice(legacy_index, batch_size) || [], legacy_records:}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def modern_batch(scope:, batch_size:, after_id:)
|
|
134
|
+
return unless batched_all_supported?
|
|
135
|
+
|
|
136
|
+
@store.all(scope: scope, limit: batch_size, after_id: after_id)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def batched_all_supported?
|
|
140
|
+
parameters = @store.method(:all).parameters
|
|
141
|
+
keyword_names = parameters.filter_map do |kind, name|
|
|
142
|
+
name if [:keyreq, :key].include?(kind)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
keyword_names.include?(:limit) && keyword_names.include?(:after_id)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def fallback_to_legacy_batching?(batch:, cursor_record:)
|
|
149
|
+
cursor_record != batch.last
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def fallback_before_processing?(batch:, after_id:)
|
|
153
|
+
ids = batch.filter_map(&:id)
|
|
154
|
+
return true if ids.uniq.length != ids.length
|
|
155
|
+
return false if after_id.nil?
|
|
156
|
+
return true if batch.any? { |record| record.id.nil? }
|
|
157
|
+
|
|
158
|
+
batch.any? { |record| record.id && record.id <= after_id }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def stale?(record)
|
|
162
|
+
stored = EmbeddingMetadata.extract(record.metadata)
|
|
163
|
+
return true if stored.empty?
|
|
164
|
+
|
|
165
|
+
expected = expected_metadata(record)
|
|
166
|
+
return true unless expected
|
|
167
|
+
|
|
168
|
+
required = %w[adapter provider model dimensions fingerprint]
|
|
169
|
+
return true if required.any? { |key| stored[key] != expected[key] }
|
|
170
|
+
|
|
171
|
+
return true if stored["dimensions"] &&
|
|
172
|
+
record.embedding.respond_to?(:length) &&
|
|
173
|
+
record.embedding.length != stored["dimensions"].to_i
|
|
174
|
+
|
|
175
|
+
false
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# What the active embedder would produce for a rebuilt row. Declared dimensions take
|
|
179
|
+
# precedence so dimension-only configuration drift marks rows stale; fall back to the
|
|
180
|
+
# record's current vector length for embedders that do not declare dimensions.
|
|
181
|
+
def expected_metadata(record)
|
|
182
|
+
declared = EmbeddingMetadata.for_embedder(@embedder)
|
|
183
|
+
return declared if declared.nil? || declared["dimensions"]
|
|
184
|
+
|
|
185
|
+
EmbeddingMetadata.for_embedder(@embedder, embedding: record.embedding)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -24,16 +24,35 @@ module Engram
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Returns Array<Record>, most relevant first.
|
|
27
|
-
def call(query, scope:, limit: Engram.config.default_limit)
|
|
27
|
+
def call(query, scope:, limit: Engram.config.default_limit, kinds: nil)
|
|
28
28
|
raise ArgumentError, "query must be a non-empty string" if query.to_s.strip.empty?
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
payload = Engram::Instrumentation.payload(
|
|
31
|
+
scope: scope,
|
|
32
|
+
store: @store,
|
|
33
|
+
limit: limit,
|
|
34
|
+
kinds: Array(kinds).map(&:to_s),
|
|
35
|
+
reranking: reranking?
|
|
36
|
+
)
|
|
37
|
+
Engram::Instrumentation.instrument("recall", payload) do
|
|
38
|
+
embedding = @embedder.embed(query)
|
|
39
|
+
embedding_metadata = Engram::EmbeddingMetadata.for_embedder(@embedder, embedding: embedding)
|
|
40
|
+
pool_limit = reranking? ? limit * @pool_factor : limit
|
|
41
|
+
pool = Engram::EmbeddingMetadata.search(
|
|
42
|
+
@store,
|
|
43
|
+
embedding: embedding,
|
|
44
|
+
embedding_metadata: embedding_metadata,
|
|
45
|
+
scope: scope,
|
|
46
|
+
limit: pool_limit,
|
|
47
|
+
kinds: kinds
|
|
48
|
+
)
|
|
33
49
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
results = (reranking? ? rerank(pool, embedding) : pool).first(limit)
|
|
51
|
+
touch(results, scope) if @touch
|
|
52
|
+
payload[:result_count] = results.size
|
|
53
|
+
payload[:candidate_count] = pool.size
|
|
54
|
+
results
|
|
55
|
+
end
|
|
37
56
|
end
|
|
38
57
|
|
|
39
58
|
private
|
|
@@ -61,8 +80,8 @@ module Engram
|
|
|
61
80
|
0.5**(age / @recency_halflife)
|
|
62
81
|
end
|
|
63
82
|
|
|
64
|
-
def touch(records)
|
|
65
|
-
records.each { |record| @store.touch(id: record.id, at: Time.now) if record.id }
|
|
83
|
+
def touch(records, scope)
|
|
84
|
+
records.each { |record| @store.touch(scope: scope, id: record.id, at: Time.now) if record.id }
|
|
66
85
|
end
|
|
67
86
|
end
|
|
68
87
|
end
|
data/lib/engram/version.rb
CHANGED