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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
# Optional ActiveSupport::Notifications integration.
|
|
5
|
+
#
|
|
6
|
+
# Engram core remains dependency-free: when ActiveSupport is not loaded, instrumentation
|
|
7
|
+
# is a no-op around the supplied block.
|
|
8
|
+
module Instrumentation
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def instrument(event, payload = {})
|
|
12
|
+
started_at = monotonic_time
|
|
13
|
+
|
|
14
|
+
unless notifications?
|
|
15
|
+
return yield if block_given?
|
|
16
|
+
return nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
ActiveSupport::Notifications.instrument("#{event}.engram", payload) do
|
|
20
|
+
yield if block_given?
|
|
21
|
+
ensure
|
|
22
|
+
payload[:duration_ms] = elapsed_ms(started_at)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def payload(scope: nil, store: nil, **attributes)
|
|
27
|
+
attributes = attributes.compact
|
|
28
|
+
attributes[:store_adapter] = adapter_name(store) if store
|
|
29
|
+
scope_identifier = scope_identifier(scope)
|
|
30
|
+
attributes[:scope_identifier] = scope_identifier if scope_identifier
|
|
31
|
+
attributes
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def notifications?
|
|
35
|
+
defined?(ActiveSupport::Notifications) && ActiveSupport::Notifications.respond_to?(:instrument)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def adapter_name(adapter)
|
|
39
|
+
adapter.class.name || adapter.class.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def scope_identifier(scope)
|
|
43
|
+
formatter = Engram.config.instrumentation_scope_identifier
|
|
44
|
+
return nil unless formatter
|
|
45
|
+
|
|
46
|
+
formatter.respond_to?(:call) ? formatter.call(scope) : scope.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def elapsed_ms(started_at)
|
|
50
|
+
((monotonic_time - started_at) * 1000).round(1)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def monotonic_time
|
|
54
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/engram/memory.rb
CHANGED
|
@@ -12,21 +12,25 @@ module Engram
|
|
|
12
12
|
@embedder = embedder
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Persist a
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
15
|
+
# Persist a memory record of the given kind. Returns nil when the configured
|
|
16
|
+
# persistence policy rejects the record.
|
|
17
|
+
def add(content, kind: :fact, importance: 1.0, metadata: {})
|
|
18
|
+
Engram::Instrumentation.instrument("add", Engram::Instrumentation.payload(scope: scope, store: @store, kind: kind)) do
|
|
19
|
+
embedding = @embedder.embed(content)
|
|
20
|
+
record = Record.new(
|
|
21
|
+
content: content,
|
|
22
|
+
scope: scope,
|
|
23
|
+
embedding: embedding,
|
|
24
|
+
kind: kind,
|
|
25
|
+
importance: importance,
|
|
26
|
+
metadata: metadata
|
|
27
|
+
)
|
|
28
|
+
persist(record)
|
|
29
|
+
end
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
# Return the most relevant memories for a query.
|
|
29
|
-
def recall(query, limit: Engram.config.default_limit)
|
|
33
|
+
def recall(query, limit: Engram.config.default_limit, kinds: nil)
|
|
30
34
|
UseCases::Recall.new(
|
|
31
35
|
store: @store,
|
|
32
36
|
embedder: @embedder,
|
|
@@ -34,12 +38,12 @@ module Engram
|
|
|
34
38
|
recency_weight: Engram.config.recency_weight,
|
|
35
39
|
recency_halflife: Engram.config.recency_halflife,
|
|
36
40
|
touch: Engram.config.touch_on_recall
|
|
37
|
-
).call(query, scope: scope, limit: limit)
|
|
41
|
+
).call(query, scope: scope, limit: limit, kinds: kinds)
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
# Recall, then inject into a prompt string.
|
|
41
|
-
def inject_into(prompt, query:, limit: Engram.config.default_limit)
|
|
42
|
-
memories = recall(query, limit: limit)
|
|
45
|
+
def inject_into(prompt, query:, limit: Engram.config.default_limit, kinds: nil)
|
|
46
|
+
memories = recall(query, limit: limit, kinds: kinds)
|
|
43
47
|
UseCases::Inject.new.call(prompt: prompt, memories: memories)
|
|
44
48
|
end
|
|
45
49
|
|
|
@@ -55,7 +59,8 @@ module Engram
|
|
|
55
59
|
store: @store,
|
|
56
60
|
extractor: build_extractor(completion),
|
|
57
61
|
consolidator: build_consolidator(completion),
|
|
58
|
-
processed_turns: Engram.config.processed_turns
|
|
62
|
+
processed_turns: Engram.config.processed_turns,
|
|
63
|
+
embedder: @embedder
|
|
59
64
|
).call(
|
|
60
65
|
messages: messages,
|
|
61
66
|
scope: scope,
|
|
@@ -69,13 +74,29 @@ module Engram
|
|
|
69
74
|
raise Engram::Error, "observe_later needs ActiveJob (Rails). Use #observe outside Rails."
|
|
70
75
|
end
|
|
71
76
|
|
|
72
|
-
Engram::
|
|
77
|
+
Engram::Instrumentation.instrument(
|
|
78
|
+
"observe_later",
|
|
79
|
+
Engram::Instrumentation.payload(scope: scope, store: @store, message_count: messages.size)
|
|
80
|
+
) do
|
|
81
|
+
Engram::ObserveJob.perform_later(scope, messages)
|
|
82
|
+
end
|
|
73
83
|
end
|
|
74
84
|
|
|
75
85
|
def all
|
|
76
86
|
@store.all(scope: scope)
|
|
77
87
|
end
|
|
78
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
|
+
|
|
79
100
|
# Prune stale memories. `older_than` is a duration in seconds; `min_importance` keeps
|
|
80
101
|
# memories at or above that importance even when old. Returns the forgotten records.
|
|
81
102
|
def forget_stale(older_than:, min_importance: Float::INFINITY)
|
|
@@ -85,6 +106,10 @@ module Engram
|
|
|
85
106
|
|
|
86
107
|
private
|
|
87
108
|
|
|
109
|
+
def persist(record)
|
|
110
|
+
Persistence.new(store: @store, embedder: @embedder).add(record)
|
|
111
|
+
end
|
|
112
|
+
|
|
88
113
|
def build_extractor(completion)
|
|
89
114
|
Extractors::LLMExtractor.new(
|
|
90
115
|
completion: completion,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
# Canonical memory categories used by extraction, recall, and policy.
|
|
5
|
+
module MemoryKind
|
|
6
|
+
VALID = %i[fact preference instruction episodic].freeze
|
|
7
|
+
LEGACY_ALIASES = {semantic: :fact}.freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def normalize(kind)
|
|
12
|
+
normalized = kind.to_s.strip.downcase.to_sym
|
|
13
|
+
normalized = LEGACY_ALIASES.fetch(normalized, normalized)
|
|
14
|
+
return normalized if VALID.include?(normalized)
|
|
15
|
+
|
|
16
|
+
raise ArgumentError, "unknown memory kind #{kind.inspect}; expected one of #{VALID.join(", ")}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
# Applies persistence hooks and policy consistently before writing records.
|
|
5
|
+
class Persistence
|
|
6
|
+
def initialize(store:, embedder:, before_persist: Engram.config.before_persist,
|
|
7
|
+
persistence_policy: Engram.config.persistence_policy)
|
|
8
|
+
@store = store
|
|
9
|
+
@embedder = embedder
|
|
10
|
+
@before_persist = before_persist
|
|
11
|
+
@persistence_policy = persistence_policy
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(record, scope: record.scope)
|
|
15
|
+
record = prepare(record)
|
|
16
|
+
if record && record.scope != scope
|
|
17
|
+
raise Engram::Error, "cannot move memory across scopes"
|
|
18
|
+
end
|
|
19
|
+
@store.add(record) if record
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def update(scope:, id:, record:)
|
|
23
|
+
record = prepare(record)
|
|
24
|
+
@store.update(scope: scope, id: id, record: record) if record
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def prepare(record)
|
|
30
|
+
original_content = record.content
|
|
31
|
+
record = @before_persist.call(record) if @before_persist
|
|
32
|
+
record = @persistence_policy.call(record) if record && @persistence_policy
|
|
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
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
# Default gate applied before memories are persisted. It keeps obvious secrets and
|
|
5
|
+
# transient task-progress updates out of durable memory, and can redact caller-supplied
|
|
6
|
+
# denylist patterns before storage.
|
|
7
|
+
class PersistencePolicy
|
|
8
|
+
SECRET_PATTERNS = [
|
|
9
|
+
/\b(?:api[_ -]?key|token|secret|password)\b\s*(?:is|=|:)\s+(?=\S*[0-9_-])\S{8,}/i,
|
|
10
|
+
/\bsk-[A-Za-z0-9_-]{6,}\b/,
|
|
11
|
+
/\b(?:ghp|github_pat)_[A-Za-z0-9_]{10,}\b/
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
TRANSIENT_PATTERNS = [
|
|
15
|
+
/\b(?:fixed|resolved|done|completed|finished)\b.*\b(?:today|now|this session)\b/i,
|
|
16
|
+
/\b(?:today|now|this session)\b.*\b(?:fixed|resolved|done|completed|finished)\b/i
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(denylist_patterns: [])
|
|
20
|
+
@denylist_patterns = denylist_patterns
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(record)
|
|
24
|
+
return nil if reject?(record.content)
|
|
25
|
+
|
|
26
|
+
redact(record)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def reject?(content)
|
|
32
|
+
SECRET_PATTERNS.any? { |pattern| content.match?(pattern) } ||
|
|
33
|
+
TRANSIENT_PATTERNS.any? { |pattern| content.match?(pattern) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def redact(record)
|
|
37
|
+
redacted = @denylist_patterns.reduce(record.content) do |content, pattern|
|
|
38
|
+
content.gsub(pattern, "[REDACTED]")
|
|
39
|
+
end
|
|
40
|
+
return record if redacted == record.content
|
|
41
|
+
|
|
42
|
+
record.with(content: redacted)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -11,29 +11,37 @@ module Engram
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
# Return up to `limit` Records in `scope` nearest to `embedding`,
|
|
14
|
-
# ordered most-relevant first.
|
|
15
|
-
|
|
14
|
+
# ordered most-relevant first. When `kinds` is provided, only records with
|
|
15
|
+
# those canonical memory kinds are eligible.
|
|
16
|
+
def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
|
|
16
17
|
raise NotImplementedError, "#{self.class} must implement #search"
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
# All Records for a scope (mostly for inspection/tests).
|
|
20
|
-
|
|
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)
|
|
21
25
|
raise NotImplementedError, "#{self.class} must implement #all"
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
# Replace the content/embedding of an existing memory. Used by consolidation
|
|
25
|
-
# (UPDATE). Returns the updated Record.
|
|
26
|
-
|
|
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:)
|
|
27
32
|
raise NotImplementedError, "#{self.class} must implement #update"
|
|
28
33
|
end
|
|
29
34
|
|
|
30
|
-
# Remove a memory by id. Used by consolidation (FORGET).
|
|
31
|
-
|
|
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:)
|
|
32
38
|
raise NotImplementedError, "#{self.class} must implement #delete"
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
# Update the last-accessed timestamp of a memory. Used by recency-aware recall.
|
|
36
|
-
|
|
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)
|
|
37
45
|
raise NotImplementedError, "#{self.class} must implement #touch"
|
|
38
46
|
end
|
|
39
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
|