engram 0.4.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 +57 -0
- data/README.md +95 -9
- data/lib/engram/adapters/in_memory_processed_turns.rb +40 -8
- data/lib/engram/adapters/in_memory_store.rb +25 -10
- data/lib/engram/adapters/null_embedder.rb +9 -0
- data/lib/engram/adapters/pgvector_store.rb +35 -17
- data/lib/engram/adapters/ruby_llm_embedder.rb +45 -4
- data/lib/engram/consolidators/heuristic_consolidator.rb +7 -1
- data/lib/engram/consolidators/llm_consolidator.rb +37 -10
- data/lib/engram/embedding_metadata.rb +135 -0
- data/lib/engram/extractors/llm_extractor.rb +4 -3
- data/lib/engram/memory.rb +13 -1
- data/lib/engram/persistence.rb +10 -5
- data/lib/engram/ports/memory_store.rb +14 -7
- 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/reserved_metadata.rb +52 -0
- data/lib/engram/use_cases/forget.rb +6 -2
- data/lib/engram/use_cases/observe.rb +43 -22
- data/lib/engram/use_cases/rebuild_embeddings.rb +189 -0
- data/lib/engram/use_cases/recall.rb +12 -4
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +7 -0
- metadata +8 -3
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Engram
|
|
6
|
+
# Helpers for storing and validating embedding provenance under Engram's
|
|
7
|
+
# reserved metadata namespace.
|
|
8
|
+
module EmbeddingMetadata
|
|
9
|
+
RESERVED_KEY = "_engram"
|
|
10
|
+
EMBEDDING_KEY = "embedding"
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def build(adapter:, dimensions:, model: nil, provider: nil)
|
|
15
|
+
data = {
|
|
16
|
+
"adapter" => adapter.to_s,
|
|
17
|
+
"dimensions" => dimensions
|
|
18
|
+
}
|
|
19
|
+
data["provider"] = provider.to_s if provider
|
|
20
|
+
data["model"] = model.to_s if model
|
|
21
|
+
data["fingerprint"] = fingerprint(data)
|
|
22
|
+
data
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def for_embedder(embedder, embedding: nil)
|
|
26
|
+
return nil unless embedder.respond_to?(:embedding_metadata)
|
|
27
|
+
|
|
28
|
+
metadata = stringify_keys(embedder.embedding_metadata || {})
|
|
29
|
+
return nil if metadata.empty?
|
|
30
|
+
|
|
31
|
+
dimensions = embedding.respond_to?(:length) ? embedding.length : metadata["dimensions"]
|
|
32
|
+
metadata = metadata.merge("dimensions" => dimensions) if dimensions
|
|
33
|
+
metadata["fingerprint"] = fingerprint(metadata)
|
|
34
|
+
metadata
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def attach(record, embedder: nil, embedding_metadata: nil)
|
|
38
|
+
metadata = stringify_keys(embedding_metadata || for_embedder(embedder, embedding: record.embedding)) || {}
|
|
39
|
+
return record if metadata.empty?
|
|
40
|
+
|
|
41
|
+
record.with(metadata: merge(record.metadata, metadata))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def extract(metadata)
|
|
45
|
+
metadata = stringify_keys(metadata || {})
|
|
46
|
+
reserved = metadata[RESERVED_KEY]
|
|
47
|
+
return {} unless reserved.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
stringify_keys(reserved[EMBEDDING_KEY] || {})
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def merge(metadata, embedding_metadata)
|
|
53
|
+
Engram::ReservedMetadata.attach(
|
|
54
|
+
metadata, EMBEDDING_KEY, embedding_metadata || {}, namespace_description: "embedding metadata"
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def search(store, embedding:, embedding_metadata:, scope:, limit:, kinds: nil)
|
|
59
|
+
store.search(
|
|
60
|
+
embedding: embedding,
|
|
61
|
+
embedding_metadata: embedding_metadata,
|
|
62
|
+
scope: scope,
|
|
63
|
+
limit: limit,
|
|
64
|
+
kinds: kinds
|
|
65
|
+
)
|
|
66
|
+
rescue ArgumentError => error
|
|
67
|
+
raise unless unknown_embedding_metadata_keyword?(error)
|
|
68
|
+
|
|
69
|
+
store.search(embedding: embedding, scope: scope, limit: limit, kinds: kinds)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_query!(embedding, embedding_metadata)
|
|
73
|
+
metadata = stringify_keys(embedding_metadata || {})
|
|
74
|
+
return if metadata.empty?
|
|
75
|
+
|
|
76
|
+
expected = metadata["dimensions"]
|
|
77
|
+
return unless expected && embedding.respond_to?(:length) && embedding.length != expected.to_i
|
|
78
|
+
|
|
79
|
+
raise Engram::Error,
|
|
80
|
+
"embedding dimension mismatch: query vector has #{embedding.length} dimensions, metadata declares #{expected}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_record!(record, query_embedding, query_metadata)
|
|
84
|
+
if query_embedding.respond_to?(:length) && record.embedding.respond_to?(:length) &&
|
|
85
|
+
query_embedding.length != record.embedding.length
|
|
86
|
+
raise Engram::Error,
|
|
87
|
+
"embedding dimension mismatch: query vector has #{query_embedding.length} dimensions, " \
|
|
88
|
+
"record #{record.id.inspect} has #{record.embedding.length}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
stored = extract(record.metadata)
|
|
92
|
+
return if stored.empty?
|
|
93
|
+
|
|
94
|
+
stored_dimensions = stored["dimensions"]
|
|
95
|
+
if stored_dimensions && record.embedding.respond_to?(:length) && record.embedding.length != stored_dimensions.to_i
|
|
96
|
+
raise Engram::Error,
|
|
97
|
+
"embedding metadata mismatch: record #{record.id.inspect} has #{record.embedding.length} dimensions, " \
|
|
98
|
+
"metadata declares #{stored_dimensions}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
query_metadata = stringify_keys(query_metadata || {})
|
|
102
|
+
return if query_metadata.empty?
|
|
103
|
+
|
|
104
|
+
conflicting_key = %w[adapter provider model dimensions fingerprint].find do |key|
|
|
105
|
+
stored.key?(key) && query_metadata.key?(key) && stored[key].to_s != query_metadata[key].to_s
|
|
106
|
+
end
|
|
107
|
+
return unless conflicting_key
|
|
108
|
+
|
|
109
|
+
raise Engram::Error,
|
|
110
|
+
"embedding metadata mismatch for record #{record.id.inspect}: #{conflicting_key} " \
|
|
111
|
+
"#{stored[conflicting_key].inspect} does not match query #{query_metadata[conflicting_key].inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def fingerprint(metadata)
|
|
115
|
+
relevant = stringify_keys(metadata).values_at("adapter", "provider", "model", "dimensions")
|
|
116
|
+
Digest::SHA256.hexdigest(relevant.map { |value| value.nil? ? "" : value.to_s }.join("\0"))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def stringify_keys(value)
|
|
120
|
+
case value
|
|
121
|
+
when Hash
|
|
122
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
123
|
+
out[key.to_s] = stringify_keys(nested)
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
value
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def unknown_embedding_metadata_keyword?(error)
|
|
131
|
+
error.message.include?("unknown keyword: :embedding_metadata") ||
|
|
132
|
+
error.message.include?("unknown keyword: \"embedding_metadata\"")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -58,14 +58,15 @@ module Engram
|
|
|
58
58
|
next if content.empty?
|
|
59
59
|
next if (fact["confidence"] || 1.0).to_f < @min_confidence
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
embedding = @embedder.embed(content)
|
|
62
|
+
Engram::EmbeddingMetadata.attach(Engram::Record.new(
|
|
62
63
|
content: content,
|
|
63
64
|
scope: scope,
|
|
64
65
|
kind: fact["kind"] || "fact",
|
|
65
66
|
importance: (fact["importance"] || 1.0).to_f,
|
|
66
67
|
metadata: {confidence: (fact["confidence"] || 1.0).to_f},
|
|
67
|
-
embedding:
|
|
68
|
-
)
|
|
68
|
+
embedding: embedding
|
|
69
|
+
), embedder: @embedder)
|
|
69
70
|
end
|
|
70
71
|
end
|
|
71
72
|
|
data/lib/engram/memory.rb
CHANGED
|
@@ -16,10 +16,11 @@ module Engram
|
|
|
16
16
|
# persistence policy rejects the record.
|
|
17
17
|
def add(content, kind: :fact, importance: 1.0, metadata: {})
|
|
18
18
|
Engram::Instrumentation.instrument("add", Engram::Instrumentation.payload(scope: scope, store: @store, kind: kind)) do
|
|
19
|
+
embedding = @embedder.embed(content)
|
|
19
20
|
record = Record.new(
|
|
20
21
|
content: content,
|
|
21
22
|
scope: scope,
|
|
22
|
-
embedding:
|
|
23
|
+
embedding: embedding,
|
|
23
24
|
kind: kind,
|
|
24
25
|
importance: importance,
|
|
25
26
|
metadata: metadata
|
|
@@ -85,6 +86,17 @@ module Engram
|
|
|
85
86
|
@store.all(scope: scope)
|
|
86
87
|
end
|
|
87
88
|
|
|
89
|
+
# Recompute embeddings (and embedding metadata) for memories in the scope.
|
|
90
|
+
# When `stale_only` is true, only records whose current metadata does not match the
|
|
91
|
+
# active embedder are rebuilt.
|
|
92
|
+
def rebuild_embeddings(stale_only: true, batch_size: 100)
|
|
93
|
+
UseCases::RebuildEmbeddings.new(store: @store, embedder: @embedder).call(
|
|
94
|
+
scope: scope,
|
|
95
|
+
stale_only: stale_only,
|
|
96
|
+
batch_size: batch_size
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
88
100
|
# Prune stale memories. `older_than` is a duration in seconds; `min_importance` keeps
|
|
89
101
|
# memories at or above that importance even when old. Returns the forgotten records.
|
|
90
102
|
def forget_stale(older_than:, min_importance: Float::INFINITY)
|
data/lib/engram/persistence.rb
CHANGED
|
@@ -11,14 +11,17 @@ module Engram
|
|
|
11
11
|
@persistence_policy = persistence_policy
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def add(record)
|
|
14
|
+
def add(record, scope: record.scope)
|
|
15
15
|
record = prepare(record)
|
|
16
|
+
if record && record.scope != scope
|
|
17
|
+
raise Engram::Error, "cannot move memory across scopes"
|
|
18
|
+
end
|
|
16
19
|
@store.add(record) if record
|
|
17
20
|
end
|
|
18
21
|
|
|
19
|
-
def update(id:, record:)
|
|
22
|
+
def update(scope:, id:, record:)
|
|
20
23
|
record = prepare(record)
|
|
21
|
-
@store.update(id: id, record: record) if record
|
|
24
|
+
@store.update(scope: scope, id: id, record: record) if record
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
private
|
|
@@ -27,8 +30,10 @@ module Engram
|
|
|
27
30
|
original_content = record.content
|
|
28
31
|
record = @before_persist.call(record) if @before_persist
|
|
29
32
|
record = @persistence_policy.call(record) if record && @persistence_policy
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
if record && record.content != original_content
|
|
34
|
+
record = record.with(embedding: @embedder.embed(record.content))
|
|
35
|
+
end
|
|
36
|
+
record ? Engram::EmbeddingMetadata.attach(record, embedder: @embedder) : nil
|
|
32
37
|
end
|
|
33
38
|
end
|
|
34
39
|
end
|
|
@@ -13,28 +13,35 @@ module Engram
|
|
|
13
13
|
# Return up to `limit` Records in `scope` nearest to `embedding`,
|
|
14
14
|
# ordered most-relevant first. When `kinds` is provided, only records with
|
|
15
15
|
# those canonical memory kinds are eligible.
|
|
16
|
-
def search(embedding:, scope:, limit:, kinds: nil)
|
|
16
|
+
def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
|
|
17
17
|
raise NotImplementedError, "#{self.class} must implement #search"
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
# All Records for a scope (mostly for inspection/tests).
|
|
21
|
-
|
|
21
|
+
# Supports optional `limit` and `offset` for batching large sweeps.
|
|
22
|
+
# Returned records are sorted in stable `id` order when batching is used.
|
|
23
|
+
# Use `after_id` for keyset pagination.
|
|
24
|
+
def all(scope:, limit: nil, offset: 0, after_id: nil)
|
|
22
25
|
raise NotImplementedError, "#{self.class} must implement #all"
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
# Replace the content/embedding of an existing memory. Used by consolidation
|
|
26
|
-
# (UPDATE). Returns the updated Record.
|
|
27
|
-
|
|
29
|
+
# (UPDATE). Returns the updated Record. Raises Engram::Error when the scoped
|
|
30
|
+
# record does not exist or the replacement would move it to another scope.
|
|
31
|
+
def update(scope:, id:, record:)
|
|
28
32
|
raise NotImplementedError, "#{self.class} must implement #update"
|
|
29
33
|
end
|
|
30
34
|
|
|
31
|
-
# Remove a memory by id. Used by consolidation (FORGET).
|
|
32
|
-
|
|
35
|
+
# Remove a memory by id. Used by consolidation (FORGET). Returns the number
|
|
36
|
+
# of affected rows: 1 when deleted, 0 when the scoped record does not exist.
|
|
37
|
+
def delete(scope:, id:)
|
|
33
38
|
raise NotImplementedError, "#{self.class} must implement #delete"
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
# Update the last-accessed timestamp of a memory. Used by recency-aware recall.
|
|
37
|
-
|
|
42
|
+
# Returns the number of affected rows: 1 when touched, 0 when the scoped record
|
|
43
|
+
# does not exist.
|
|
44
|
+
def touch(scope:, id:, at: Time.now)
|
|
38
45
|
raise NotImplementedError, "#{self.class} must implement #touch"
|
|
39
46
|
end
|
|
40
47
|
end
|
|
@@ -2,18 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Engram
|
|
4
4
|
module Ports
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# Scoped lifecycle for observation idempotency. Implementations suppress concurrent claims
|
|
6
|
+
# while a lease is live. Lease expiry may permit overlap; this contract does not provide
|
|
7
|
+
# token ownership, fencing, or exactly-once execution.
|
|
8
|
+
# #claim returns a truthy opaque token when the caller may proceed, or nil when suppressed.
|
|
7
9
|
# Implementations: Adapters::InMemoryProcessedTurns, Rails::CacheProcessedTurns.
|
|
8
10
|
module ProcessedTurns
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
raise NotImplementedError, "#{self.class} must implement #seen?"
|
|
11
|
+
def claim(scope:, key:)
|
|
12
|
+
raise NotImplementedError, "#{self.class} must implement #claim"
|
|
12
13
|
end
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
def complete(scope:, key:, claim:)
|
|
16
|
+
raise NotImplementedError, "#{self.class} must implement #complete"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def release(scope:, key:, claim:)
|
|
20
|
+
raise NotImplementedError, "#{self.class} must implement #release"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def completed?(scope:, key:)
|
|
24
|
+
raise NotImplementedError, "#{self.class} must implement #completed?"
|
|
17
25
|
end
|
|
18
26
|
end
|
|
19
27
|
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
# Provider-neutral source provenance stored in Record metadata.
|
|
5
|
+
#
|
|
6
|
+
# Offsets are zero-based Unicode codepoint indexes into one host-owned source
|
|
7
|
+
# message. The end offset is exclusive. Engram stores references and spans,
|
|
8
|
+
# never the source text itself.
|
|
9
|
+
class Provenance
|
|
10
|
+
RESERVED_KEY = "_engram"
|
|
11
|
+
METADATA_KEY = "provenance"
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
ALIGNMENTS = %i[exact normalized inferred ungrounded].freeze
|
|
14
|
+
|
|
15
|
+
class Span
|
|
16
|
+
OFFSET_UNIT = "unicode_codepoint"
|
|
17
|
+
|
|
18
|
+
attr_reader :start_offset, :end_offset, :offset_unit
|
|
19
|
+
|
|
20
|
+
def initialize(start_offset:, end_offset:, offset_unit: OFFSET_UNIT)
|
|
21
|
+
unless start_offset.is_a?(Integer) && end_offset.is_a?(Integer) &&
|
|
22
|
+
start_offset >= 0 && end_offset > start_offset
|
|
23
|
+
raise ArgumentError, "span offsets must be non-negative integers with end_offset > start_offset"
|
|
24
|
+
end
|
|
25
|
+
raise ArgumentError, "unsupported offset unit" unless offset_unit.to_s == OFFSET_UNIT
|
|
26
|
+
|
|
27
|
+
@start_offset = start_offset
|
|
28
|
+
@end_offset = end_offset
|
|
29
|
+
@offset_unit = OFFSET_UNIT
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def end_exclusive? = true
|
|
34
|
+
|
|
35
|
+
def ==(other)
|
|
36
|
+
other.is_a?(self.class) && to_h == other.to_h
|
|
37
|
+
end
|
|
38
|
+
alias_method :eql?, :==
|
|
39
|
+
|
|
40
|
+
def hash = to_h.hash
|
|
41
|
+
|
|
42
|
+
def to_h
|
|
43
|
+
{"start_offset" => start_offset, "end_offset" => end_offset, "offset_unit" => offset_unit}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class Source
|
|
48
|
+
attr_reader :source_id, :source_type, :message_index, :role, :spans, :alignment
|
|
49
|
+
|
|
50
|
+
def initialize(source_id:, source_type:, message_index:, role:, spans:, alignment:)
|
|
51
|
+
raise ArgumentError, "source_id is required" if source_id.to_s.strip.empty?
|
|
52
|
+
raise ArgumentError, "source_type is required" if source_type.to_s.strip.empty?
|
|
53
|
+
unless message_index.is_a?(Integer) && !message_index.negative?
|
|
54
|
+
raise ArgumentError, "message_index must be a non-negative integer"
|
|
55
|
+
end
|
|
56
|
+
raise ArgumentError, "role is required" if role.to_s.strip.empty?
|
|
57
|
+
|
|
58
|
+
unless alignment.is_a?(String) || alignment.is_a?(Symbol)
|
|
59
|
+
raise ArgumentError, "unknown alignment #{alignment.inspect}"
|
|
60
|
+
end
|
|
61
|
+
normalized_alignment = alignment.to_sym
|
|
62
|
+
raise ArgumentError, "unknown alignment #{alignment.inspect}" unless ALIGNMENTS.include?(normalized_alignment)
|
|
63
|
+
|
|
64
|
+
@source_id = source_id.to_s.dup.freeze
|
|
65
|
+
@source_type = source_type.to_s.dup.freeze
|
|
66
|
+
@message_index = message_index
|
|
67
|
+
@role = role.to_s.dup.freeze
|
|
68
|
+
raise ArgumentError, "spans must be an array" unless spans.is_a?(Array)
|
|
69
|
+
raise ArgumentError, "spans must contain at least one span" if spans.empty?
|
|
70
|
+
@spans = spans.map do |span|
|
|
71
|
+
raise ArgumentError, "spans must contain Provenance::Span values" unless span.is_a?(Span)
|
|
72
|
+
span
|
|
73
|
+
end.freeze
|
|
74
|
+
@alignment = normalized_alignment
|
|
75
|
+
freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ==(other)
|
|
79
|
+
other.is_a?(self.class) && to_h == other.to_h
|
|
80
|
+
end
|
|
81
|
+
alias_method :eql?, :==
|
|
82
|
+
|
|
83
|
+
def hash = to_h.hash
|
|
84
|
+
|
|
85
|
+
def to_h
|
|
86
|
+
data = {
|
|
87
|
+
"source_id" => source_id,
|
|
88
|
+
"source_type" => source_type,
|
|
89
|
+
"spans" => spans.map(&:to_h),
|
|
90
|
+
"alignment" => alignment.to_s
|
|
91
|
+
}
|
|
92
|
+
data["message_index"] = message_index
|
|
93
|
+
data["role"] = role
|
|
94
|
+
data
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class Extractor
|
|
99
|
+
attr_reader :name, :provider, :model
|
|
100
|
+
|
|
101
|
+
def initialize(name:, model:, provider: nil)
|
|
102
|
+
raise ArgumentError, "extractor name is required" if name.to_s.strip.empty?
|
|
103
|
+
raise ArgumentError, "extractor model is required" if model.to_s.strip.empty?
|
|
104
|
+
|
|
105
|
+
@name = name.to_s.dup.freeze
|
|
106
|
+
@provider = provider&.to_s&.dup&.freeze
|
|
107
|
+
@model = model.to_s.dup.freeze
|
|
108
|
+
freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def ==(other)
|
|
112
|
+
other.is_a?(self.class) && to_h == other.to_h
|
|
113
|
+
end
|
|
114
|
+
alias_method :eql?, :==
|
|
115
|
+
|
|
116
|
+
def hash = to_h.hash
|
|
117
|
+
|
|
118
|
+
def to_h
|
|
119
|
+
{"name" => name, "model" => model}.tap do |data|
|
|
120
|
+
data["provider"] = provider if provider
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
attr_reader :sources, :extractor, :confidence
|
|
126
|
+
|
|
127
|
+
def initialize(sources:, extractor:, confidence:)
|
|
128
|
+
raise ArgumentError, "sources must be an array" unless sources.is_a?(Array)
|
|
129
|
+
raise ArgumentError, "sources must contain at least one source" if sources.empty?
|
|
130
|
+
@sources = sources.map do |source|
|
|
131
|
+
raise ArgumentError, "sources must contain Provenance::Source values" unless source.is_a?(Source)
|
|
132
|
+
source
|
|
133
|
+
end.freeze
|
|
134
|
+
raise ArgumentError, "extractor must be a Provenance::Extractor" unless extractor.is_a?(Extractor)
|
|
135
|
+
valid_confidence = (confidence.is_a?(Integer) || confidence.is_a?(Float)) && confidence.between?(0, 1)
|
|
136
|
+
unless valid_confidence
|
|
137
|
+
raise ArgumentError, "confidence must be an Integer or Float between 0 and 1"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
@extractor = extractor
|
|
141
|
+
@confidence = confidence
|
|
142
|
+
freeze
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def ==(other)
|
|
146
|
+
other.is_a?(self.class) && to_h == other.to_h
|
|
147
|
+
end
|
|
148
|
+
alias_method :eql?, :==
|
|
149
|
+
|
|
150
|
+
def hash = to_h.hash
|
|
151
|
+
|
|
152
|
+
def to_h
|
|
153
|
+
{
|
|
154
|
+
"version" => SCHEMA_VERSION,
|
|
155
|
+
"sources" => sources.map(&:to_h),
|
|
156
|
+
"extractor" => extractor.to_h,
|
|
157
|
+
"confidence" => confidence
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
class << self
|
|
162
|
+
def attach(metadata, provenance)
|
|
163
|
+
raise ArgumentError, "provenance must be a Provenance value" unless provenance.is_a?(self)
|
|
164
|
+
|
|
165
|
+
Engram::ReservedMetadata.attach(metadata, METADATA_KEY, provenance.to_h)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def extract(metadata)
|
|
169
|
+
metadata = deep_stringify(metadata || {})
|
|
170
|
+
return nil unless metadata.is_a?(Hash)
|
|
171
|
+
|
|
172
|
+
reserved = metadata[RESERVED_KEY]
|
|
173
|
+
return nil unless reserved.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
data = reserved[METADATA_KEY]
|
|
176
|
+
return nil unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION
|
|
177
|
+
|
|
178
|
+
from_h(data)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def from_h(data)
|
|
184
|
+
sources = provenance_array(data["sources"], "_engram.provenance.sources")
|
|
185
|
+
extractor_data = provenance_hash(data["extractor"], "_engram.provenance.extractor")
|
|
186
|
+
|
|
187
|
+
parsed_sources = sources.each_with_index.map do |source, source_index|
|
|
188
|
+
source_path = "_engram.provenance.sources[#{source_index}]"
|
|
189
|
+
source = provenance_hash(source, source_path)
|
|
190
|
+
spans = provenance_array(source["spans"], "#{source_path}.spans")
|
|
191
|
+
parsed_spans = spans.each_with_index.map do |span, span_index|
|
|
192
|
+
span_path = "#{source_path}.spans[#{span_index}]"
|
|
193
|
+
span = provenance_hash(span, span_path)
|
|
194
|
+
build_provenance_value(span_path) do
|
|
195
|
+
Span.new(
|
|
196
|
+
start_offset: span["start_offset"],
|
|
197
|
+
end_offset: span["end_offset"],
|
|
198
|
+
offset_unit: span["offset_unit"]
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
build_provenance_value(source_path) do
|
|
204
|
+
Source.new(
|
|
205
|
+
source_id: source["source_id"],
|
|
206
|
+
source_type: source["source_type"],
|
|
207
|
+
message_index: source["message_index"],
|
|
208
|
+
role: source["role"],
|
|
209
|
+
spans: parsed_spans,
|
|
210
|
+
alignment: source["alignment"]
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
extractor = build_provenance_value("_engram.provenance.extractor") do
|
|
216
|
+
Extractor.new(**symbolize_extractor(extractor_data))
|
|
217
|
+
end
|
|
218
|
+
build_provenance_value("_engram.provenance") do
|
|
219
|
+
new(sources: parsed_sources, extractor: extractor, confidence: data["confidence"])
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def provenance_hash(value, path)
|
|
224
|
+
return value if value.is_a?(Hash)
|
|
225
|
+
|
|
226
|
+
raise Engram::Error, "malformed provenance at #{path}: expected an object"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def provenance_array(value, path)
|
|
230
|
+
return value if value.is_a?(Array)
|
|
231
|
+
|
|
232
|
+
raise Engram::Error, "malformed provenance at #{path}: expected an array"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def build_provenance_value(path)
|
|
236
|
+
yield
|
|
237
|
+
rescue ArgumentError, TypeError, KeyError, NoMethodError => error
|
|
238
|
+
raise Engram::Error, "malformed provenance at #{path}: #{error.message}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def symbolize_extractor(data)
|
|
242
|
+
{name: data["name"], provider: data["provider"], model: data["model"]}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def deep_stringify(value)
|
|
246
|
+
case value
|
|
247
|
+
when Hash
|
|
248
|
+
value.each_with_object({}) { |(key, nested), out| out[key.to_s] = deep_stringify(nested) }
|
|
249
|
+
when Array
|
|
250
|
+
value.map { |nested| deep_stringify(nested) }
|
|
251
|
+
else
|
|
252
|
+
value
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -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
|