llmemory 0.2.10 → 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 +48 -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 +67 -1
- data/lib/llmemory/consolidation/filter.rb +74 -0
- data/lib/llmemory/consolidation/policy.rb +17 -0
- data/lib/llmemory/consolidation/rule_based_policy.rb +63 -0
- data/lib/llmemory/consolidation/unit.rb +54 -0
- data/lib/llmemory/consolidation.rb +11 -0
- data/lib/llmemory/instrumentation.rb +10 -1
- data/lib/llmemory/long_term/file_based/memory.rb +18 -7
- data/lib/llmemory/long_term/graph_based/memory.rb +31 -8
- data/lib/llmemory/maintenance/cognitive_pass.rb +29 -1
- data/lib/llmemory/maintenance/policy_prune.rb +85 -0
- data/lib/llmemory/maintenance/zero_mem_ttl.rb +26 -0
- data/lib/llmemory/maintenance.rb +2 -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/retrieval/context_assembler.rb +17 -1
- 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 +8 -0
- data/lib/tasks/llmemory.rake +84 -2
- metadata +54 -2
|
@@ -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
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class GraphRetriever
|
|
6
|
+
MAX_NODES = 200
|
|
7
|
+
|
|
8
|
+
def initialize(storage, entity_index: nil, embedding_provider: nil, config: Llmemory.configuration)
|
|
9
|
+
@storage = storage
|
|
10
|
+
@entity_index = entity_index || EntityIndex.new(storage)
|
|
11
|
+
@embedding_provider = embedding_provider
|
|
12
|
+
@config = config
|
|
13
|
+
@pagerank = PageRank.new(gamma: config.zero_mem_pagerank_damping)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def retrieve(user_id:, query:, profile:, top_k:)
|
|
17
|
+
warnings = []
|
|
18
|
+
degraded = []
|
|
19
|
+
query_entities = @entity_index.extract_entities(query)
|
|
20
|
+
degraded << :ner if query_entities.empty?
|
|
21
|
+
|
|
22
|
+
seeds = match_entities(user_id, query, query_entities, degraded: degraded)
|
|
23
|
+
return empty_result(degraded, warnings) if seeds.empty?
|
|
24
|
+
|
|
25
|
+
trace_ids = seeds.flat_map { |key| @storage.trace_ids_for_entity_key(user_id, key) }.uniq
|
|
26
|
+
trace_ids = trace_ids.first(MAX_NODES)
|
|
27
|
+
warnings << :pagerank_truncated if trace_ids.size >= MAX_NODES
|
|
28
|
+
|
|
29
|
+
graph, reset = build_graph(user_id, trace_ids, seeds)
|
|
30
|
+
ranks = @pagerank.run(graph, reset)
|
|
31
|
+
trace_scores = ranks.select { |node, _| node.start_with?("tr:") }
|
|
32
|
+
.transform_keys { |k| k.sub("tr:", "") }
|
|
33
|
+
trace_ids.each { |tid| trace_scores[tid] ||= 0.5 }
|
|
34
|
+
trace_scores = trace_scores.sort_by { |_, score| -score }
|
|
35
|
+
|
|
36
|
+
traces = trace_scores.first(top_k).map do |trace_id, score|
|
|
37
|
+
t = @storage.get_trace(user_id, trace_id)
|
|
38
|
+
next unless t
|
|
39
|
+
|
|
40
|
+
{ trace: t, score: score }
|
|
41
|
+
end.compact
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
traces: traces.map { |h| h[:trace] },
|
|
45
|
+
trace_scores: trace_scores.to_h,
|
|
46
|
+
degraded: degraded.uniq,
|
|
47
|
+
warnings: warnings
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def empty_result(degraded, warnings)
|
|
54
|
+
{ traces: [], trace_scores: {}, degraded: degraded.uniq, warnings: warnings }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def match_entities(user_id, query, query_entities, degraded:)
|
|
58
|
+
keys = query_entities.map { |e| e[:normalized] }.compact
|
|
59
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
60
|
+
keys = expand_entity_keys(user_id, keys) if keys.any?
|
|
61
|
+
if keys.empty?
|
|
62
|
+
keys = @storage.all_entity_keys(user_id).select do |key|
|
|
63
|
+
tokens.any? { |t| key.include?(t) || t.include?(key) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
degraded << :dense if @embedding_provider.nil?
|
|
67
|
+
keys.select { |k| @storage.entity_exists?(user_id, k) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def expand_entity_keys(user_id, keys)
|
|
71
|
+
stored = @storage.all_entity_keys(user_id)
|
|
72
|
+
keys.flat_map do |k|
|
|
73
|
+
next [] if k.to_s.length < 2
|
|
74
|
+
|
|
75
|
+
stored.select { |s| s == k || s.include?(k) || k.include?(s) }
|
|
76
|
+
end.uniq
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_graph(user_id, trace_ids, seed_keys)
|
|
80
|
+
graph = {}
|
|
81
|
+
reset = {}
|
|
82
|
+
|
|
83
|
+
trace_ids.each do |tid|
|
|
84
|
+
node = "tr:#{tid}"
|
|
85
|
+
neighbors = ["tr:#{tid}"]
|
|
86
|
+
@storage.entity_keys_for_trace(user_id, tid).each do |key|
|
|
87
|
+
ent_node = "ent:#{key}"
|
|
88
|
+
graph[ent_node] ||= []
|
|
89
|
+
graph[ent_node] << node
|
|
90
|
+
graph[node] ||= []
|
|
91
|
+
graph[node] << ent_node
|
|
92
|
+
neighbors << ent_node
|
|
93
|
+
reset[ent_node] = 1.0 if seed_keys.include?(key)
|
|
94
|
+
end
|
|
95
|
+
prev, nxt = @storage.adjacent_traces(user_id, tid)
|
|
96
|
+
[prev, nxt].compact.each do |other|
|
|
97
|
+
graph[node] ||= []
|
|
98
|
+
graph[node] << "tr:#{other}"
|
|
99
|
+
end
|
|
100
|
+
graph[node] = (graph[node] || neighbors).uniq
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
[graph, reset]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class HierarchyBuilder
|
|
6
|
+
def initialize(storage, config: Llmemory.configuration)
|
|
7
|
+
@storage = storage
|
|
8
|
+
@config = config
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def index_trace(trace, index_version:)
|
|
12
|
+
affected = 0
|
|
13
|
+
affected += write_turn_unit(trace, index_version)
|
|
14
|
+
affected += update_tail_windows(trace, index_version)
|
|
15
|
+
affected += update_episode(trace, index_version)
|
|
16
|
+
affected
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def window_size
|
|
22
|
+
@config.zero_mem_window_size.to_i.positive? ? @config.zero_mem_window_size.to_i : 8
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def window_overlap
|
|
26
|
+
ov = @config.zero_mem_window_overlap.to_i
|
|
27
|
+
ov = 2 if ov.negative?
|
|
28
|
+
[ov, window_size - 1].min
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def episode_gap_seconds
|
|
32
|
+
gap = @config.zero_mem_episode_gap_seconds.to_i
|
|
33
|
+
gap.positive? ? gap : 86_400
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def write_turn_unit(trace, index_version)
|
|
37
|
+
unit = TraceUnit.build(
|
|
38
|
+
id: "turn_#{trace.id}",
|
|
39
|
+
user_id: trace.user_id,
|
|
40
|
+
kind: :turn,
|
|
41
|
+
session_id: trace.session_id,
|
|
42
|
+
boundary_id: trace.boundary_id,
|
|
43
|
+
start_sequence: trace.sequence,
|
|
44
|
+
end_sequence: trace.sequence,
|
|
45
|
+
occurred_from: trace.occurred_at,
|
|
46
|
+
occurred_to: trace.occurred_at,
|
|
47
|
+
member_trace_ids: [trace.id],
|
|
48
|
+
index_version: index_version
|
|
49
|
+
)
|
|
50
|
+
@storage.write_unit(unit)
|
|
51
|
+
1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def update_tail_windows(trace, index_version)
|
|
55
|
+
size = window_size
|
|
56
|
+
seq = trace.sequence
|
|
57
|
+
start_seq = [1, seq - size + 1].max
|
|
58
|
+
traces = @storage.list_traces(trace.user_id, session_id: trace.session_id).select do |t|
|
|
59
|
+
t.sequence >= start_seq && t.sequence <= seq
|
|
60
|
+
end
|
|
61
|
+
return 0 if traces.empty?
|
|
62
|
+
|
|
63
|
+
unit = TraceUnit.build(
|
|
64
|
+
id: "window_#{trace.session_id}_#{traces.first.sequence}_#{traces.last.sequence}",
|
|
65
|
+
user_id: trace.user_id,
|
|
66
|
+
kind: :window,
|
|
67
|
+
session_id: trace.session_id,
|
|
68
|
+
boundary_id: trace.boundary_id,
|
|
69
|
+
start_sequence: traces.first.sequence,
|
|
70
|
+
end_sequence: traces.last.sequence,
|
|
71
|
+
occurred_from: traces.first.occurred_at,
|
|
72
|
+
occurred_to: traces.last.occurred_at,
|
|
73
|
+
member_trace_ids: traces.map(&:id),
|
|
74
|
+
index_version: index_version
|
|
75
|
+
)
|
|
76
|
+
@storage.write_unit(unit)
|
|
77
|
+
1
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def update_episode(trace, index_version)
|
|
81
|
+
key = trace.boundary_id || trace.session_id
|
|
82
|
+
open_episode = @storage.open_episode(trace.user_id, key)
|
|
83
|
+
gap = episode_gap_seconds
|
|
84
|
+
|
|
85
|
+
if open_episode
|
|
86
|
+
last_at = open_episode.occurred_to
|
|
87
|
+
if (trace.occurred_at - last_at).to_f > gap
|
|
88
|
+
@storage.close_episode(open_episode.id)
|
|
89
|
+
open_episode = nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if open_episode
|
|
94
|
+
ids = (open_episode.member_trace_ids + [trace.id]).uniq
|
|
95
|
+
traces = ids.map { |id| @storage.get_trace(trace.user_id, id) }.compact
|
|
96
|
+
updated = open_episode.with(
|
|
97
|
+
end_sequence: trace.sequence,
|
|
98
|
+
occurred_to: trace.occurred_at,
|
|
99
|
+
member_trace_ids: ids,
|
|
100
|
+
index_version: index_version
|
|
101
|
+
)
|
|
102
|
+
@storage.write_unit(updated)
|
|
103
|
+
else
|
|
104
|
+
unit = TraceUnit.build(
|
|
105
|
+
user_id: trace.user_id,
|
|
106
|
+
kind: :episode,
|
|
107
|
+
session_id: trace.session_id,
|
|
108
|
+
boundary_id: trace.boundary_id,
|
|
109
|
+
start_sequence: trace.sequence,
|
|
110
|
+
end_sequence: trace.sequence,
|
|
111
|
+
occurred_from: trace.occurred_at,
|
|
112
|
+
occurred_to: trace.occurred_at,
|
|
113
|
+
member_trace_ids: [trace.id],
|
|
114
|
+
index_version: index_version
|
|
115
|
+
)
|
|
116
|
+
@storage.write_unit(unit)
|
|
117
|
+
@storage.set_open_episode(trace.user_id, key, unit.id)
|
|
118
|
+
end
|
|
119
|
+
1
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|