llmemory 0.2.11 → 0.3.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/README.md +23 -0
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +71 -0
- data/lib/generators/llmemory/upgrade_zero_mem/templates/create_llmemory_zero_mem_tables.rb +77 -0
- data/lib/generators/llmemory/upgrade_zero_mem/upgrade_zero_mem_generator.rb +19 -0
- data/lib/llmemory/cli/commands/zero_mem.rb +107 -0
- data/lib/llmemory/cli.rb +3 -0
- data/lib/llmemory/configuration.rb +59 -1
- data/lib/llmemory/instrumentation.rb +10 -1
- data/lib/llmemory/maintenance/cognitive_pass.rb +29 -1
- data/lib/llmemory/maintenance/zero_mem_ttl.rb +26 -0
- data/lib/llmemory/maintenance.rb +1 -0
- data/lib/llmemory/mcp/server.rb +2 -0
- data/lib/llmemory/mcp/store_helpers.rb +16 -0
- data/lib/llmemory/mcp/tools/memory_add_message.rb +17 -4
- data/lib/llmemory/mcp/tools/memory_consolidate.rb +9 -4
- data/lib/llmemory/mcp/tools/memory_maintain.rb +6 -1
- data/lib/llmemory/mcp/tools/memory_retrieve.rb +3 -1
- data/lib/llmemory/mcp/tools/memory_retrieve_evidence.rb +70 -0
- data/lib/llmemory/mcp/tools/memory_stats.rb +22 -0
- data/lib/llmemory/memory.rb +257 -9
- data/lib/llmemory/retrieval/bm25_scorer.rb +5 -0
- data/lib/llmemory/tokenizer.rb +18 -8
- data/lib/llmemory/vector_store/memory_store.rb +23 -1
- data/lib/llmemory/vector_store/openai_embeddings.rb +47 -21
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory/zero_mem/answer_calibrator.rb +63 -0
- data/lib/llmemory/zero_mem/backfill.rb +43 -0
- data/lib/llmemory/zero_mem/budget_assembler.rb +62 -0
- data/lib/llmemory/zero_mem/circuit_breaker.rb +37 -0
- data/lib/llmemory/zero_mem/embedding_provider.rb +73 -0
- data/lib/llmemory/zero_mem/engine.rb +247 -0
- data/lib/llmemory/zero_mem/entity.rb +31 -0
- data/lib/llmemory/zero_mem/entity_extractor.rb +23 -0
- data/lib/llmemory/zero_mem/entity_index.rb +65 -0
- data/lib/llmemory/zero_mem/entity_mention.rb +35 -0
- data/lib/llmemory/zero_mem/errors.rb +7 -0
- data/lib/llmemory/zero_mem/evidence.rb +43 -0
- data/lib/llmemory/zero_mem/evidence_calibrator.rb +158 -0
- data/lib/llmemory/zero_mem/evidence_closure.rb +98 -0
- data/lib/llmemory/zero_mem/evidence_fusion.rb +57 -0
- data/lib/llmemory/zero_mem/evidence_set.rb +54 -0
- data/lib/llmemory/zero_mem/extractors/heuristic.rb +44 -0
- data/lib/llmemory/zero_mem/extractors/http.rb +58 -0
- data/lib/llmemory/zero_mem/graph_retriever.rb +107 -0
- data/lib/llmemory/zero_mem/hierarchy_builder.rb +123 -0
- data/lib/llmemory/zero_mem/hierarchy_retriever.rb +133 -0
- data/lib/llmemory/zero_mem/indexer.rb +73 -0
- data/lib/llmemory/zero_mem/mode.rb +30 -0
- data/lib/llmemory/zero_mem/pagerank.rb +42 -0
- data/lib/llmemory/zero_mem/query_profile.rb +48 -0
- data/lib/llmemory/zero_mem/query_profiler.rb +138 -0
- data/lib/llmemory/zero_mem/repair.rb +31 -0
- data/lib/llmemory/zero_mem/router.rb +69 -0
- data/lib/llmemory/zero_mem/sidecar_client.rb +82 -0
- data/lib/llmemory/zero_mem/storage.rb +124 -0
- data/lib/llmemory/zero_mem/storages/active_record.rb +311 -0
- data/lib/llmemory/zero_mem/storages/active_record_models.rb +21 -0
- data/lib/llmemory/zero_mem/storages/file.rb +67 -0
- data/lib/llmemory/zero_mem/storages/memory.rb +294 -0
- data/lib/llmemory/zero_mem/storages/postgres.rb +85 -0
- data/lib/llmemory/zero_mem/storages/snapshot_codec.rb +180 -0
- data/lib/llmemory/zero_mem/storages.rb +43 -0
- data/lib/llmemory/zero_mem/trace.rb +100 -0
- data/lib/llmemory/zero_mem/trace_state_link.rb +64 -0
- data/lib/llmemory/zero_mem/trace_unit.rb +83 -0
- data/lib/llmemory/zero_mem.rb +42 -0
- data/lib/llmemory.rb +7 -0
- data/lib/tasks/llmemory.rake +52 -0
- metadata +48 -2
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class EntityExtractor
|
|
6
|
+
def extract(_text)
|
|
7
|
+
raise NotImplementedError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def generative?
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def name
|
|
15
|
+
self.class.name.split("::").last.downcase
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def version
|
|
19
|
+
"1"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class EntityIndex
|
|
6
|
+
def initialize(storage, extractor: nil)
|
|
7
|
+
@storage = storage
|
|
8
|
+
@extractor = extractor || build_extractor
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def extract_entities(text)
|
|
12
|
+
@extractor.extract(text)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def index_trace(trace)
|
|
16
|
+
entities_touched = 0
|
|
17
|
+
mentions = @extractor.extract(trace.content)
|
|
18
|
+
keys = []
|
|
19
|
+
|
|
20
|
+
mentions.each do |m|
|
|
21
|
+
entity = Entity.build(
|
|
22
|
+
user_id: trace.user_id,
|
|
23
|
+
normalized_key: m[:normalized],
|
|
24
|
+
display_name: m[:text]
|
|
25
|
+
)
|
|
26
|
+
@storage.upsert_entity(entity)
|
|
27
|
+
mention = EntityMention.build(
|
|
28
|
+
user_id: trace.user_id,
|
|
29
|
+
trace_id: trace.id,
|
|
30
|
+
entity_id: entity.id,
|
|
31
|
+
normalized_key: entity.normalized_key,
|
|
32
|
+
text: m[:text],
|
|
33
|
+
offset_start: m[:start],
|
|
34
|
+
offset_end: m[:end]
|
|
35
|
+
)
|
|
36
|
+
@storage.write_mention(mention)
|
|
37
|
+
keys << entity.normalized_key
|
|
38
|
+
entities_touched += 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
prev = @storage.previous_trace(trace.user_id, trace.session_id, trace.sequence)
|
|
42
|
+
if prev
|
|
43
|
+
@storage.link_adjacent_traces(trace.user_id, prev.id, trace.id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
affected_entities: entities_touched,
|
|
48
|
+
entity_keys: keys.uniq,
|
|
49
|
+
cache_invalidations: entities_touched
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def build_extractor
|
|
56
|
+
case Llmemory.configuration.zero_mem_entity_extractor.to_sym
|
|
57
|
+
when :http
|
|
58
|
+
Extractors::Http.new(base_url: Llmemory.configuration.zero_mem_ner_http_url)
|
|
59
|
+
else
|
|
60
|
+
Extractors::Heuristic.new
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class EntityMention
|
|
6
|
+
attr_reader :id, :user_id, :trace_id, :entity_id, :normalized_key, :text, :offset_start, :offset_end
|
|
7
|
+
|
|
8
|
+
def self.build(user_id:, trace_id:, entity_id:, normalized_key:, text:, offset_start: 0, offset_end: nil, id: nil)
|
|
9
|
+
new(
|
|
10
|
+
id: id || "men_#{SecureRandom.hex(8)}",
|
|
11
|
+
user_id: user_id.to_s,
|
|
12
|
+
trace_id: trace_id.to_s,
|
|
13
|
+
entity_id: entity_id.to_s,
|
|
14
|
+
normalized_key: normalized_key.to_s,
|
|
15
|
+
text: text.to_s,
|
|
16
|
+
offset_start: offset_start.to_i,
|
|
17
|
+
offset_end: (offset_end || offset_start.to_i + text.to_s.length).to_i
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(id:, user_id:, trace_id:, entity_id:, normalized_key:, text:, offset_start:, offset_end:)
|
|
22
|
+
@id = id
|
|
23
|
+
@user_id = user_id
|
|
24
|
+
@trace_id = trace_id
|
|
25
|
+
@entity_id = entity_id
|
|
26
|
+
@normalized_key = normalized_key
|
|
27
|
+
@text = text
|
|
28
|
+
@offset_start = offset_start
|
|
29
|
+
@offset_end = offset_end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
require "securerandom"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class Evidence
|
|
6
|
+
attr_reader :trace_id, :content, :role, :session_id, :occurred_at, :confidence, :score,
|
|
7
|
+
:sources, :conflict, :seed, :closure, :content_sha256
|
|
8
|
+
|
|
9
|
+
def initialize(trace_id:, content:, role:, session_id:, occurred_at:, confidence:, score:,
|
|
10
|
+
sources: [], conflict: false, seed: true, closure: false, content_sha256: nil)
|
|
11
|
+
@trace_id = trace_id
|
|
12
|
+
@content = content
|
|
13
|
+
@role = role
|
|
14
|
+
@session_id = session_id
|
|
15
|
+
@occurred_at = occurred_at
|
|
16
|
+
@confidence = confidence.to_f
|
|
17
|
+
@score = score.to_f
|
|
18
|
+
@sources = Array(sources).map(&:to_sym).uniq
|
|
19
|
+
@conflict = !!conflict
|
|
20
|
+
@seed = !!seed
|
|
21
|
+
@closure = !!closure
|
|
22
|
+
@content_sha256 = content_sha256
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{
|
|
27
|
+
trace_id: trace_id,
|
|
28
|
+
content: content,
|
|
29
|
+
role: role,
|
|
30
|
+
session_id: session_id,
|
|
31
|
+
occurred_at: occurred_at.is_a?(Time) ? occurred_at.utc.iso8601(6) : occurred_at,
|
|
32
|
+
confidence: confidence,
|
|
33
|
+
score: score,
|
|
34
|
+
sources: sources,
|
|
35
|
+
conflict: conflict,
|
|
36
|
+
seed: seed,
|
|
37
|
+
closure: closure,
|
|
38
|
+
content_sha256: content_sha256
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module ZeroMem
|
|
7
|
+
class EvidenceCalibrator
|
|
8
|
+
CONFLICT_PATTERNS = [
|
|
9
|
+
/\btalla\s+es\s+([A-Z])\b/i,
|
|
10
|
+
/\bsize\s+is\s+([A-Z])\b/i
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(storage)
|
|
14
|
+
@storage = storage
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def calibrate(user_id:, traces:, profile:, current_trace_id: nil, as_of: Time.now,
|
|
18
|
+
fusion_rows: nil, seed_ids: nil, closure_ids: nil)
|
|
19
|
+
excluded = []
|
|
20
|
+
kept = []
|
|
21
|
+
|
|
22
|
+
Array(traces).each do |t|
|
|
23
|
+
reason = exclusion_reason(user_id, t, profile, current_trace_id, as_of)
|
|
24
|
+
if reason
|
|
25
|
+
excluded << { id: t.id, reason: reason }
|
|
26
|
+
next
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
kept << t
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
deduped, dup_excluded = dedupe_by_content(kept, fusion_rows)
|
|
33
|
+
excluded.concat(dup_excluded)
|
|
34
|
+
|
|
35
|
+
conflict_ids = detect_conflicts(deduped)
|
|
36
|
+
ordered = sort_traces(deduped, profile, as_of)
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
traces: ordered,
|
|
40
|
+
excluded: excluded,
|
|
41
|
+
conflict_trace_ids: conflict_ids,
|
|
42
|
+
seed_ids: Array(seed_ids).map(&:to_s),
|
|
43
|
+
closure_ids: Array(closure_ids).map(&:to_s)
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def exclusion_reason(user_id, trace, profile, current_trace_id, as_of)
|
|
50
|
+
return :wrong_tenant unless trace.user_id == user_id.to_s
|
|
51
|
+
return :archived unless trace.active?
|
|
52
|
+
return :current_query if current_trace_id && trace.id == current_trace_id.to_s
|
|
53
|
+
return :outside_boundary unless boundary_match?(trace, profile.boundary)
|
|
54
|
+
return :hash_mismatch unless sha_ok?(trace)
|
|
55
|
+
return :stale_state unless current_state_ok?(user_id, trace, profile, as_of)
|
|
56
|
+
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def boundary_match?(trace, boundary)
|
|
61
|
+
return true unless boundary
|
|
62
|
+
|
|
63
|
+
ok = true
|
|
64
|
+
ok &&= (trace.session_id == boundary[:session_id].to_s) if boundary[:session_id]
|
|
65
|
+
ok &&= (trace.boundary_id == boundary[:boundary_id].to_s) if boundary[:boundary_id]
|
|
66
|
+
ok
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def sha_ok?(trace)
|
|
70
|
+
Digest::SHA256.hexdigest(trace.content) == trace.content_sha256
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def current_state_ok?(user_id, trace, profile, as_of)
|
|
74
|
+
return true unless profile.workload_class == :current_state
|
|
75
|
+
|
|
76
|
+
links = @storage.state_links_for(user_id, state_attribute_key(trace))
|
|
77
|
+
return true if links.empty?
|
|
78
|
+
|
|
79
|
+
current_id = @storage.current_state_trace_id(user_id, state_attribute_key(trace), as_of: as_of)
|
|
80
|
+
return true if current_id.nil?
|
|
81
|
+
|
|
82
|
+
trace.id == current_id.to_s || links.any? { |l| l.trace_id == trace.id }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def state_attribute_key(trace)
|
|
86
|
+
down = trace.content.downcase
|
|
87
|
+
return "talla" if down.include?("talla")
|
|
88
|
+
return "size" if down.include?("size")
|
|
89
|
+
|
|
90
|
+
"state:#{trace.session_id}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def dedupe_by_content(traces, fusion_rows)
|
|
94
|
+
by_hash = traces.group_by(&:content_sha256)
|
|
95
|
+
kept = []
|
|
96
|
+
excluded = []
|
|
97
|
+
by_hash.each do |hash, group|
|
|
98
|
+
if group.size == 1
|
|
99
|
+
kept << group.first
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
winner = pick_winner(group, fusion_rows)
|
|
104
|
+
kept << winner
|
|
105
|
+
group.each do |t|
|
|
106
|
+
next if t.id == winner.id
|
|
107
|
+
|
|
108
|
+
excluded << { id: t.id, reason: :duplicate_content, content_sha256: hash }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
[kept, excluded]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def pick_winner(group, fusion_rows)
|
|
115
|
+
scores = fusion_rows&.index_by { |r| r[:trace_id].to_s } || {}
|
|
116
|
+
group.max_by do |t|
|
|
117
|
+
row = scores[t.id]
|
|
118
|
+
[row ? row[:final].to_f : 0.0, t.occurred_at.to_f]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def detect_conflicts(traces)
|
|
123
|
+
buckets = Hash.new { |h, k| h[k] = [] }
|
|
124
|
+
traces.each do |t|
|
|
125
|
+
CONFLICT_PATTERNS.each do |pat|
|
|
126
|
+
m = t.content.match(pat)
|
|
127
|
+
next unless m
|
|
128
|
+
|
|
129
|
+
buckets[pat.source] << { id: t.id, value: m[1].upcase }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
conflict_ids = []
|
|
134
|
+
buckets.each_value do |entries|
|
|
135
|
+
values = entries.map { |e| e[:value] }.uniq
|
|
136
|
+
next if values.size <= 1
|
|
137
|
+
|
|
138
|
+
conflict_ids.concat(entries.map { |e| e[:id] })
|
|
139
|
+
end
|
|
140
|
+
conflict_ids.uniq
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def sort_traces(traces, profile, as_of)
|
|
144
|
+
if profile.freshness_requirement || profile.workload_class == :current_state
|
|
145
|
+
traces.sort_by { |t| [-t.occurred_at.to_f, subject_rank(t, profile)] }
|
|
146
|
+
else
|
|
147
|
+
traces.sort_by { |t| [subject_rank(t, profile), -t.occurred_at.to_f] }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def subject_rank(trace, profile)
|
|
152
|
+
down = trace.content.downcase
|
|
153
|
+
hits = profile.subject_entities.count { |e| down.include?(e.downcase) }
|
|
154
|
+
-hits
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class EvidenceClosure
|
|
6
|
+
DEFAULT_BRIDGE_BUDGET = 4
|
|
7
|
+
DEFAULT_NEIGHBOR_BUDGET = 4
|
|
8
|
+
|
|
9
|
+
def initialize(storage, config: Llmemory.configuration)
|
|
10
|
+
@storage = storage
|
|
11
|
+
@config = config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Expands fused seeds with relational bridges and local neighbors inside the boundary.
|
|
15
|
+
def expand(user_id:, profile:, seed_trace_ids:, fused_ranking:)
|
|
16
|
+
seed_trace_ids = Array(seed_trace_ids).map(&:to_s).uniq
|
|
17
|
+
return empty(seed_trace_ids) if seed_trace_ids.empty?
|
|
18
|
+
|
|
19
|
+
bridge_budget = [seed_trace_ids.size, DEFAULT_BRIDGE_BUDGET].min
|
|
20
|
+
neighbor_budget = DEFAULT_NEIGHBOR_BUDGET
|
|
21
|
+
per_seed = [neighbor_budget / [seed_trace_ids.size, 1].max, 1].max
|
|
22
|
+
|
|
23
|
+
closure_ids = []
|
|
24
|
+
bridges = []
|
|
25
|
+
groups = []
|
|
26
|
+
|
|
27
|
+
seed_trace_ids.each do |seed_id|
|
|
28
|
+
group = [seed_id]
|
|
29
|
+
added = 0
|
|
30
|
+
prev, nxt = @storage.adjacent_traces(user_id, seed_id)
|
|
31
|
+
[prev, nxt].compact.each do |tid|
|
|
32
|
+
break if added >= per_seed
|
|
33
|
+
next if seed_trace_ids.include?(tid)
|
|
34
|
+
next unless trace_ok?(user_id, tid, profile)
|
|
35
|
+
|
|
36
|
+
closure_ids << tid
|
|
37
|
+
bridges << [seed_id, tid]
|
|
38
|
+
group << tid
|
|
39
|
+
added += 1
|
|
40
|
+
end
|
|
41
|
+
groups << group if group.size > 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
span = @config.zero_mem_local_span.to_i
|
|
45
|
+
span = 2 if span <= 0
|
|
46
|
+
seed_trace_ids.each do |seed_id|
|
|
47
|
+
trace = @storage.get_trace(user_id, seed_id)
|
|
48
|
+
next unless trace
|
|
49
|
+
|
|
50
|
+
session_traces = @storage.list_traces(user_id, session_id: trace.session_id)
|
|
51
|
+
by_seq = session_traces.each_with_object({}) { |t, acc| acc[t.sequence] = t.id }
|
|
52
|
+
((trace.sequence - span)..(trace.sequence + span)).each do |seq|
|
|
53
|
+
tid = by_seq[seq]
|
|
54
|
+
next if tid.nil? || seed_trace_ids.include?(tid) || closure_ids.include?(tid)
|
|
55
|
+
next unless trace_ok?(user_id, tid, profile)
|
|
56
|
+
|
|
57
|
+
closure_ids << tid
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
closure_ids = closure_ids.uniq.first(bridge_budget + neighbor_budget)
|
|
62
|
+
{
|
|
63
|
+
seed_trace_ids: seed_trace_ids,
|
|
64
|
+
closure_trace_ids: closure_ids,
|
|
65
|
+
bridges: bridges.first(bridge_budget),
|
|
66
|
+
atomic_groups: groups.map { |g| g.uniq.sort }
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def empty(seed_trace_ids)
|
|
73
|
+
{
|
|
74
|
+
seed_trace_ids: seed_trace_ids,
|
|
75
|
+
closure_trace_ids: [],
|
|
76
|
+
bridges: [],
|
|
77
|
+
atomic_groups: []
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def trace_ok?(user_id, trace_id, profile)
|
|
82
|
+
trace = @storage.get_trace(user_id, trace_id)
|
|
83
|
+
return false unless trace&.active?
|
|
84
|
+
|
|
85
|
+
boundary_match?(trace, profile.boundary)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def boundary_match?(trace, boundary)
|
|
89
|
+
return true unless boundary
|
|
90
|
+
|
|
91
|
+
ok = true
|
|
92
|
+
ok &&= (trace.session_id == boundary[:session_id].to_s) if boundary[:session_id]
|
|
93
|
+
ok &&= (trace.boundary_id == boundary[:boundary_id].to_s) if boundary[:boundary_id]
|
|
94
|
+
ok
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class EvidenceFusion
|
|
6
|
+
# When all raw scores are equal, min-max yields this normalized value (see benchmarks/zero_mem/profile.yml).
|
|
7
|
+
EQUAL_SCORE_NORMALIZED = 0.5
|
|
8
|
+
|
|
9
|
+
def fuse(graph_scores:, hierarchy_scores:, weights:)
|
|
10
|
+
g_norm = min_max(graph_scores)
|
|
11
|
+
h_norm = min_max(hierarchy_scores)
|
|
12
|
+
ids = (g_norm.keys + h_norm.keys).uniq
|
|
13
|
+
|
|
14
|
+
fused = ids.map do |trace_id|
|
|
15
|
+
graph = g_norm.fetch(trace_id, 0.0)
|
|
16
|
+
hierarchy = h_norm.fetch(trace_id, 0.0)
|
|
17
|
+
graph_w = weights.fetch(:graph, 0.5).to_f
|
|
18
|
+
hierarchy_w = weights.fetch(:hierarchy, 0.5).to_f
|
|
19
|
+
total = graph_w + hierarchy_w
|
|
20
|
+
graph_w /= total
|
|
21
|
+
hierarchy_w /= total
|
|
22
|
+
final = (graph_w * graph) + (hierarchy_w * hierarchy)
|
|
23
|
+
sources = []
|
|
24
|
+
sources << :graph if graph_scores.key?(trace_id)
|
|
25
|
+
sources << :hierarchy if hierarchy_scores.key?(trace_id)
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
trace_id: trace_id,
|
|
29
|
+
graph: graph,
|
|
30
|
+
hierarchy: hierarchy,
|
|
31
|
+
graph_raw: graph_scores[trace_id],
|
|
32
|
+
hierarchy_raw: hierarchy_scores[trace_id],
|
|
33
|
+
final: final,
|
|
34
|
+
sources: sources
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
fused.sort_by { |row| [-row[:final], row[:trace_id]] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def min_max(scores)
|
|
42
|
+
return {} if scores.nil? || scores.empty?
|
|
43
|
+
|
|
44
|
+
values = scores.values.map(&:to_f)
|
|
45
|
+
min = values.min
|
|
46
|
+
max = values.max
|
|
47
|
+
if max == min
|
|
48
|
+
return scores.keys.to_h { |id| [id, EQUAL_SCORE_NORMALIZED] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
scores.transform_values do |v|
|
|
52
|
+
((v.to_f - min) / (max - min)).clamp(0.0, 1.0)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class EvidenceSet
|
|
6
|
+
attr_reader :route, :profile, :evidence, :metrics, :explain, :degraded,
|
|
7
|
+
:relational_trace_scores, :hierarchy_traces, :relational_traces, :warnings,
|
|
8
|
+
:seed_evidence, :closure_evidence
|
|
9
|
+
|
|
10
|
+
def initialize(route:, profile:, evidence:, metrics:, explain: nil, degraded: [],
|
|
11
|
+
relational_trace_scores: {}, hierarchy_traces: [], relational_traces: [],
|
|
12
|
+
warnings: [], seed_evidence: [], closure_evidence: [])
|
|
13
|
+
@route = route
|
|
14
|
+
@profile = profile
|
|
15
|
+
@evidence = evidence
|
|
16
|
+
@metrics = metrics
|
|
17
|
+
@explain = explain
|
|
18
|
+
@degraded = Array(degraded)
|
|
19
|
+
@relational_trace_scores = relational_trace_scores
|
|
20
|
+
@hierarchy_traces = hierarchy_traces
|
|
21
|
+
@relational_traces = relational_traces
|
|
22
|
+
@warnings = Array(warnings)
|
|
23
|
+
@seed_evidence = Array(seed_evidence)
|
|
24
|
+
@closure_evidence = Array(closure_evidence)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def recall_at_k(gold_trace_ids, k: nil)
|
|
28
|
+
k ||= evidence.size
|
|
29
|
+
gold = Array(gold_trace_ids).map(&:to_s)
|
|
30
|
+
ranked = evidence.map(&:trace_id).first(k)
|
|
31
|
+
hit = (gold & ranked).size
|
|
32
|
+
{
|
|
33
|
+
hit: hit,
|
|
34
|
+
gold: gold.size,
|
|
35
|
+
recall: gold.empty? ? 0.0 : hit.to_f / gold.size,
|
|
36
|
+
ranked: ranked
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_context
|
|
41
|
+
return "" if evidence.empty?
|
|
42
|
+
|
|
43
|
+
lines = ["=== ZERO-MEM EVIDENCE ===", ""]
|
|
44
|
+
evidence.each do |ev|
|
|
45
|
+
ts = ev.occurred_at.is_a?(Time) ? ev.occurred_at.utc.iso8601 : ev.occurred_at.to_s
|
|
46
|
+
lines << "[#{ev.trace_id}] #{ts} #{ev.session_id} #{ev.role}: #{ev.content}"
|
|
47
|
+
end
|
|
48
|
+
lines << ""
|
|
49
|
+
lines << "=== END ZERO-MEM EVIDENCE ==="
|
|
50
|
+
lines.join("\n")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../entity_extractor"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module ZeroMem
|
|
7
|
+
module Extractors
|
|
8
|
+
class Heuristic < EntityExtractor
|
|
9
|
+
CAP_PHRASE = /\b[\p{Lu}][\p{Ll}]+(?:\s+[\p{Lu}][\p{Ll}]+)*\b/u
|
|
10
|
+
ALL_CAPS = /\b[\p{Lu}]{2,}\b/u
|
|
11
|
+
|
|
12
|
+
def extract(text)
|
|
13
|
+
normalized_text = Llmemory::Tokenizer.normalize(text)
|
|
14
|
+
spans = []
|
|
15
|
+
text.to_s.scan(CAP_PHRASE) { |m| spans << [Regexp.last_match.begin(0), m] }
|
|
16
|
+
text.to_s.scan(ALL_CAPS) { |m| spans << [Regexp.last_match.begin(0), m] }
|
|
17
|
+
|
|
18
|
+
spans.map do |start, raw|
|
|
19
|
+
norm = normalize_key(raw)
|
|
20
|
+
next if norm.length < 2
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
text: raw,
|
|
24
|
+
normalized: norm,
|
|
25
|
+
type: :entity,
|
|
26
|
+
start: start,
|
|
27
|
+
end: start + raw.length
|
|
28
|
+
}
|
|
29
|
+
end.compact.uniq { |h| h[:normalized] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def name
|
|
33
|
+
"heuristic"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def normalize_key(raw)
|
|
39
|
+
Llmemory::Tokenizer.normalize(raw).gsub(/\s+/, " ").strip
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "faraday"
|
|
5
|
+
require_relative "../entity_extractor"
|
|
6
|
+
|
|
7
|
+
module Llmemory
|
|
8
|
+
module ZeroMem
|
|
9
|
+
module Extractors
|
|
10
|
+
class Http < EntityExtractor
|
|
11
|
+
def initialize(base_url:, timeout: 5, client: nil)
|
|
12
|
+
@base_url = base_url.to_s.sub(%r{/\z}, "")
|
|
13
|
+
@timeout = timeout
|
|
14
|
+
@client = client
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def extract(text)
|
|
18
|
+
body = sidecar.post_json("/ner", { text: text.to_s })
|
|
19
|
+
Array(body["entities"]).map do |ent|
|
|
20
|
+
{
|
|
21
|
+
text: ent["text"],
|
|
22
|
+
normalized: ent["normalized"] || ent["text"].to_s.downcase,
|
|
23
|
+
type: (ent["type"] || "entity").to_sym,
|
|
24
|
+
start: ent["start"] || 0,
|
|
25
|
+
end: ent["end"] || 0
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def name
|
|
31
|
+
"http"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def version
|
|
35
|
+
health["version"] || "unknown"
|
|
36
|
+
rescue StandardError
|
|
37
|
+
"unknown"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def health
|
|
41
|
+
sidecar.get_json("/health")
|
|
42
|
+
rescue StoreError
|
|
43
|
+
{ "status" => "down" }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def degraded?
|
|
47
|
+
sidecar.degraded?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def sidecar
|
|
53
|
+
@client ||= SidecarClient.new(base_url: @base_url, timeout: @timeout)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|