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,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
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../retrieval/bm25_scorer"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module ZeroMem
|
|
7
|
+
class HierarchyRetriever
|
|
8
|
+
def initialize(storage, config: Llmemory.configuration)
|
|
9
|
+
@storage = storage
|
|
10
|
+
@config = config
|
|
11
|
+
@bm25 = Retrieval::Bm25Scorer.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def retrieve(user_id:, query:, profile:, top_k:)
|
|
15
|
+
k = top_k || @config.zero_mem_top_k
|
|
16
|
+
episodes = filtered_episodes(user_id, profile)
|
|
17
|
+
ranked_episodes = rank_units(query, profile, episodes)
|
|
18
|
+
top_episodes = ranked_episodes.first([k, ranked_episodes.size].min)
|
|
19
|
+
|
|
20
|
+
windows = top_episodes.flat_map do |ep|
|
|
21
|
+
@storage.list_units(user_id, kind: :window, session_id: ep.session_id).select do |w|
|
|
22
|
+
(w.member_trace_ids - ep.member_trace_ids).empty? || (w.member_trace_ids & ep.member_trace_ids).any?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
windows = @storage.list_units(user_id, kind: :window) if windows.empty?
|
|
26
|
+
ranked_windows = rank_units(query, profile, windows.uniq { |u| u.id })
|
|
27
|
+
top_windows = ranked_windows.first([k, ranked_windows.size].min)
|
|
28
|
+
|
|
29
|
+
turns = top_windows.flat_map do |win|
|
|
30
|
+
@storage.list_units(user_id, kind: :turn, session_id: win.session_id).select do |t|
|
|
31
|
+
t.start_sequence >= win.start_sequence && t.end_sequence <= win.end_sequence
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
turns = @storage.list_units(user_id, kind: :turn) if turns.empty?
|
|
35
|
+
ranked_turns = rank_units(query, profile, turns.uniq { |u| u.id })
|
|
36
|
+
top_turns = ranked_turns.first([k, ranked_turns.size].min)
|
|
37
|
+
|
|
38
|
+
trace_ids = top_turns.flat_map(&:member_trace_ids)
|
|
39
|
+
trace_ids = expand_local_neighbors(user_id, trace_ids, top_turns)
|
|
40
|
+
traces = trace_ids.map { |id| @storage.get_trace(user_id, id) }.compact
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
traces: traces,
|
|
44
|
+
degraded: [:dense],
|
|
45
|
+
stage_scores: {
|
|
46
|
+
episodes: ranked_episodes.first(3).map { |u| score_unit(query, profile, u) },
|
|
47
|
+
windows: ranked_windows.first(3).map { |u| score_unit(query, profile, u) },
|
|
48
|
+
turns: ranked_turns.first(3).map { |u| score_unit(query, profile, u) }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def filtered_episodes(user_id, profile)
|
|
56
|
+
units = @storage.list_units(user_id, kind: :episode)
|
|
57
|
+
boundary = profile.boundary
|
|
58
|
+
return units unless boundary
|
|
59
|
+
|
|
60
|
+
units.select do |u|
|
|
61
|
+
ok = true
|
|
62
|
+
ok &&= (u.session_id == boundary[:session_id]) if boundary[:session_id]
|
|
63
|
+
ok &&= (u.boundary_id == boundary[:boundary_id]) if boundary[:boundary_id]
|
|
64
|
+
ok
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def rank_units(query, profile, units)
|
|
69
|
+
docs = units.map do |u|
|
|
70
|
+
{ id: u.id, text: unit_text(u), unit: u }
|
|
71
|
+
end
|
|
72
|
+
scored = @bm25.score_documents(query, docs)
|
|
73
|
+
scored.sort_by { |d| -combined_score(query, profile, d) }
|
|
74
|
+
.map { |d| d[:unit] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def combined_score(query, profile, doc)
|
|
78
|
+
unit = doc[:unit]
|
|
79
|
+
bm25 = doc[:normalized_bm25].to_f
|
|
80
|
+
phrase = phrase_boost(profile, doc[:text])
|
|
81
|
+
subject = subject_boost(profile, doc[:text])
|
|
82
|
+
temporal = temporal_boost(profile, unit)
|
|
83
|
+
boundary = profile.boundary ? 0.1 : 0.0
|
|
84
|
+
answer = 0.05
|
|
85
|
+
bm25 + phrase + subject + temporal + boundary + answer
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def score_unit(query, profile, unit)
|
|
89
|
+
doc = @bm25.score_documents(query, [{ id: unit.id, text: unit_text(unit), unit: unit }]).first
|
|
90
|
+
combined_score(query, profile, doc)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def unit_text(unit)
|
|
94
|
+
unit.member_trace_ids.map do |tid|
|
|
95
|
+
@storage.get_trace(unit.user_id, tid)&.content
|
|
96
|
+
end.compact.join(" ")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def phrase_boost(profile, text)
|
|
100
|
+
down = text.downcase
|
|
101
|
+
profile.quoted_phrases.count { |p| down.include?(p.downcase) } * 0.2
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def subject_boost(profile, text)
|
|
105
|
+
down = text.downcase
|
|
106
|
+
profile.subject_entities.count { |e| down.include?(e.downcase) } * 0.15
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def temporal_boost(profile, unit)
|
|
110
|
+
return 0.0 unless profile.freshness_requirement
|
|
111
|
+
|
|
112
|
+
age = Time.now - unit.occurred_to
|
|
113
|
+
1.0 / (1.0 + (age / 86_400.0))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def expand_local_neighbors(user_id, trace_ids, turn_units)
|
|
117
|
+
span = @config.zero_mem_local_span.to_i
|
|
118
|
+
span = 2 if span <= 0
|
|
119
|
+
sessions = turn_units.map(&:session_id).uniq
|
|
120
|
+
all_traces = sessions.flat_map { |sid| @storage.list_traces(user_id, session_id: sid) }
|
|
121
|
+
by_seq = all_traces.each_with_object({}) { |t, acc| acc[t.sequence] = t }
|
|
122
|
+
expanded = trace_ids.dup
|
|
123
|
+
turn_units.each do |tu|
|
|
124
|
+
seq = tu.start_sequence
|
|
125
|
+
((seq - span)..(seq + span)).each do |s|
|
|
126
|
+
expanded << by_seq[s].id if by_seq[s]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
expanded.uniq
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
# Index watermark + hierarchical units + entity graph (ZM2/ZM3).
|
|
6
|
+
class Indexer
|
|
7
|
+
def initialize(storage, mode: nil, builder: nil, entity_index: nil, embedding_provider: nil)
|
|
8
|
+
@storage = storage
|
|
9
|
+
@mode = (mode || Llmemory.configuration.zero_mem_index_mode).to_sym
|
|
10
|
+
@builder = builder || HierarchyBuilder.new(storage)
|
|
11
|
+
@entity_index = entity_index || EntityIndex.new(storage)
|
|
12
|
+
@embedding_provider = embedding_provider
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def after_trace_write(trace:, state_link: false)
|
|
16
|
+
wm = @storage.get_watermark(trace.user_id, trace.session_id)
|
|
17
|
+
next_version = wm[:index_version].to_i + 1
|
|
18
|
+
affected_units = @builder.index_trace(trace, index_version: next_version)
|
|
19
|
+
entity_stats = @entity_index.index_trace(trace)
|
|
20
|
+
encoder_stats = index_embedding(trace)
|
|
21
|
+
|
|
22
|
+
@storage.set_watermark(
|
|
23
|
+
trace.user_id,
|
|
24
|
+
trace.session_id,
|
|
25
|
+
last_sequence: trace.sequence,
|
|
26
|
+
index_version: next_version
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
index_mode: @mode,
|
|
31
|
+
index_lag: @mode == :deferred,
|
|
32
|
+
affected_units: affected_units,
|
|
33
|
+
affected_entities: entity_stats[:affected_entities],
|
|
34
|
+
cache_invalidations: entity_stats[:cache_invalidations],
|
|
35
|
+
maintenance_fanout: (state_link ? 2 : 1) + affected_units + entity_stats[:affected_entities],
|
|
36
|
+
encoder_calls: encoder_stats[:encoder_calls],
|
|
37
|
+
encoder_duration_ms: encoder_stats[:encoder_duration_ms]
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def index_embedding(trace)
|
|
44
|
+
provider = @embedding_provider || configured_embedding_provider
|
|
45
|
+
return { encoder_calls: 0, encoder_duration_ms: 0.0 } unless provider
|
|
46
|
+
|
|
47
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
48
|
+
vector = provider.embed_one(trace.content)
|
|
49
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
50
|
+
@storage.store_turn_embedding(
|
|
51
|
+
trace.user_id,
|
|
52
|
+
trace.id,
|
|
53
|
+
vector,
|
|
54
|
+
model: provider.model,
|
|
55
|
+
dimensions: provider.dimensions
|
|
56
|
+
)
|
|
57
|
+
Llmemory::Instrumentation.instrument(
|
|
58
|
+
:encoder_embed,
|
|
59
|
+
user_id: trace.user_id,
|
|
60
|
+
trace_id: trace.id,
|
|
61
|
+
model: provider.model,
|
|
62
|
+
dimensions: provider.dimensions,
|
|
63
|
+
duration_ms: elapsed
|
|
64
|
+
)
|
|
65
|
+
{ encoder_calls: 1, encoder_duration_ms: elapsed }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def configured_embedding_provider
|
|
69
|
+
Llmemory.configuration.zero_mem_embedding_provider
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
module Mode
|
|
6
|
+
MODES = %i[classic zero_mem hybrid].freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def valid?(mode)
|
|
11
|
+
MODES.include?(mode.to_sym)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def normalize(mode)
|
|
15
|
+
sym = (mode || :classic).to_sym
|
|
16
|
+
raise Llmemory::ConfigurationError, "invalid memory_mode: #{mode.inspect}" unless valid?(sym)
|
|
17
|
+
|
|
18
|
+
sym
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def zero_mem_enabled?(mode)
|
|
22
|
+
%i[zero_mem hybrid].include?(normalize(mode))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def zero_mem_strict?(mode)
|
|
26
|
+
normalize(mode) == :zero_mem
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class PageRank
|
|
6
|
+
def initialize(gamma: 0.6, max_iterations: 30, tolerance: 1e-6)
|
|
7
|
+
@gamma = gamma.to_f
|
|
8
|
+
@max_iterations = max_iterations
|
|
9
|
+
@tolerance = tolerance
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# graph: { node_id => [neighbor_ids] }, reset: { node_id => weight }
|
|
13
|
+
def run(graph, reset)
|
|
14
|
+
nodes = (graph.keys + reset.keys).uniq
|
|
15
|
+
return {} if nodes.empty?
|
|
16
|
+
|
|
17
|
+
out_degree = graph.transform_values { |nbs| [nbs.size, 1].max }
|
|
18
|
+
rank = nodes.to_h { |n| [n, 1.0 / nodes.size] }
|
|
19
|
+
reset_total = reset.values.sum
|
|
20
|
+
reset = reset.transform_values { |w| reset_total.zero? ? 0.0 : w / reset_total }
|
|
21
|
+
|
|
22
|
+
@max_iterations.times do
|
|
23
|
+
next_rank = nodes.to_h { |n| [n, 0.0] }
|
|
24
|
+
graph.each do |node, neighbors|
|
|
25
|
+
share = rank[node] / out_degree[node]
|
|
26
|
+
neighbors.each do |nb|
|
|
27
|
+
next_rank[nb] ||= 0.0
|
|
28
|
+
next_rank[nb] += @gamma * share
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
nodes.each do |n|
|
|
32
|
+
next_rank[n] += (1.0 - @gamma) * reset.fetch(n, 0.0)
|
|
33
|
+
end
|
|
34
|
+
delta = nodes.sum { |n| (next_rank[n] - rank[n]).abs }
|
|
35
|
+
rank = next_rank
|
|
36
|
+
break if delta < @tolerance
|
|
37
|
+
end
|
|
38
|
+
rank
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class QueryProfile
|
|
6
|
+
ANSWER_TYPES = %i[person place datetime number duration boolean list free_text].freeze
|
|
7
|
+
WORKLOADS = %i[local_fact cross_session temporal current_state multi_hop procedural].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :subject_entities, :keywords, :quoted_phrases, :answer_type, :temporal_cues,
|
|
10
|
+
:aggregation_cues, :boundary, :workload_class, :freshness_requirement,
|
|
11
|
+
:expected_evidence_count, :language, :rule_ids
|
|
12
|
+
|
|
13
|
+
def initialize(subject_entities:, keywords:, quoted_phrases:, answer_type:, temporal_cues:,
|
|
14
|
+
aggregation_cues:, boundary:, workload_class:, freshness_requirement:,
|
|
15
|
+
expected_evidence_count:, language:, rule_ids:)
|
|
16
|
+
@subject_entities = subject_entities
|
|
17
|
+
@keywords = keywords
|
|
18
|
+
@quoted_phrases = quoted_phrases
|
|
19
|
+
@answer_type = answer_type
|
|
20
|
+
@temporal_cues = temporal_cues
|
|
21
|
+
@aggregation_cues = aggregation_cues
|
|
22
|
+
@boundary = boundary
|
|
23
|
+
@workload_class = workload_class
|
|
24
|
+
@freshness_requirement = freshness_requirement
|
|
25
|
+
@expected_evidence_count = expected_evidence_count
|
|
26
|
+
@language = language
|
|
27
|
+
@rule_ids = rule_ids
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{
|
|
32
|
+
subject_entities: subject_entities,
|
|
33
|
+
keywords: keywords,
|
|
34
|
+
quoted_phrases: quoted_phrases,
|
|
35
|
+
answer_type: answer_type,
|
|
36
|
+
temporal_cues: temporal_cues,
|
|
37
|
+
aggregation_cues: aggregation_cues,
|
|
38
|
+
boundary: boundary,
|
|
39
|
+
workload_class: workload_class,
|
|
40
|
+
freshness_requirement: freshness_requirement,
|
|
41
|
+
expected_evidence_count: expected_evidence_count,
|
|
42
|
+
language: language,
|
|
43
|
+
rule_ids: rule_ids
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class QueryProfiler
|
|
6
|
+
TEMPORAL_EN = /\b(before|after|first|last|when|timeline|january|february|march|april|may|june|july|august|september|october|november|december|booking|remind)\b/i
|
|
7
|
+
TEMPORAL_ES = /\b(antes|después|primero|último|ultimo|cuándo|cuando|hora|enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre|reserva|recuerda)\b/i
|
|
8
|
+
CURRENT_EN = /\b(now|today|current|currently|what is)\b/i
|
|
9
|
+
CURRENT_ES = /\b(ahora|actual|hoy|cuál es|cual es)\b/i
|
|
10
|
+
MULTI_EN = /\b(between|compared to|relationship|and .+ and)\b/i
|
|
11
|
+
MULTI_ES = /\b(relación|relacion|entre|comparado)\b/i
|
|
12
|
+
PROCEDURAL = /\b(export|file|balance|informe|report|saldo)\b/i
|
|
13
|
+
|
|
14
|
+
def profile(query, boundary: nil, language: nil)
|
|
15
|
+
text = query.to_s.strip
|
|
16
|
+
lang = language || detect_language(text)
|
|
17
|
+
rule_ids = []
|
|
18
|
+
quoted = text.scan(/"([^"]+)"/).flatten
|
|
19
|
+
keywords = Llmemory::Tokenizer.tokenize(text)
|
|
20
|
+
entities = extract_entities(text, lang)
|
|
21
|
+
|
|
22
|
+
temporal = temporal_cues(text, lang)
|
|
23
|
+
rule_ids << "temporal_cues" unless temporal.empty?
|
|
24
|
+
|
|
25
|
+
workload = infer_workload(text, lang, entities, temporal, rule_ids)
|
|
26
|
+
answer_type = infer_answer_type(text, lang, rule_ids)
|
|
27
|
+
freshness = workload == :current_state || workload == :temporal
|
|
28
|
+
|
|
29
|
+
boundary_h = normalize_boundary(boundary)
|
|
30
|
+
rule_ids << "boundary_explicit" if boundary_h
|
|
31
|
+
|
|
32
|
+
QueryProfile.new(
|
|
33
|
+
subject_entities: entities,
|
|
34
|
+
keywords: keywords,
|
|
35
|
+
quoted_phrases: quoted,
|
|
36
|
+
answer_type: answer_type,
|
|
37
|
+
temporal_cues: temporal,
|
|
38
|
+
aggregation_cues: aggregation_cues(text),
|
|
39
|
+
boundary: boundary_h,
|
|
40
|
+
workload_class: workload,
|
|
41
|
+
freshness_requirement: freshness,
|
|
42
|
+
expected_evidence_count: expected_count(workload, aggregation_cues(text)),
|
|
43
|
+
language: lang,
|
|
44
|
+
rule_ids: rule_ids.uniq
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def detect_language(text)
|
|
51
|
+
text.match?(/[¿¡áéíóúñÁÉÍÓÚÑ]/) || text.match?(TEMPORAL_ES) ? :es : :en
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def extract_entities(text, _lang)
|
|
55
|
+
text.scan(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b/).uniq.first(5)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def temporal_cues(text, lang)
|
|
59
|
+
cues = []
|
|
60
|
+
cues.concat(text.scan(lang == :es ? TEMPORAL_ES : TEMPORAL_EN).flatten)
|
|
61
|
+
cues.concat(text.scan(/\b\d{1,2}:\d{2}\b/))
|
|
62
|
+
cues.concat(text.scan(/\b\d{4}-\d{2}-\d{2}\b/))
|
|
63
|
+
cues.map(&:to_s).uniq
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def aggregation_cues(text)
|
|
67
|
+
text.scan(/\b(all|every|list|cuántos|cuantos|how many)\b/i).flatten.uniq
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def infer_workload(text, lang, entities, temporal, rule_ids)
|
|
71
|
+
if text.match?(PROCEDURAL)
|
|
72
|
+
rule_ids << "procedural_cue"
|
|
73
|
+
return :procedural
|
|
74
|
+
end
|
|
75
|
+
if entities.size >= 2 || text.match?(lang == :es ? MULTI_ES : MULTI_EN)
|
|
76
|
+
rule_ids << "multi_entity"
|
|
77
|
+
return :multi_hop
|
|
78
|
+
end
|
|
79
|
+
if text.match?(lang == :es ? CURRENT_ES : CURRENT_EN)
|
|
80
|
+
rule_ids << "current_state_cue"
|
|
81
|
+
return :current_state
|
|
82
|
+
end
|
|
83
|
+
if temporal.any?
|
|
84
|
+
rule_ids << "temporal_workload"
|
|
85
|
+
return :temporal
|
|
86
|
+
end
|
|
87
|
+
if text.match?(/\b(session|sesión|sesion|conversation|earlier)\b/i)
|
|
88
|
+
rule_ids << "cross_session_cue"
|
|
89
|
+
return :cross_session
|
|
90
|
+
end
|
|
91
|
+
:local_fact
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def infer_answer_type(text, lang, rule_ids)
|
|
95
|
+
down = text.downcase
|
|
96
|
+
if down.match?(/\b(when|hora|time|fecha|date)\b/)
|
|
97
|
+
rule_ids << "answer_datetime"
|
|
98
|
+
return :datetime
|
|
99
|
+
end
|
|
100
|
+
if down.match?(/\b(how many|cuánt|cuant|number|número|numero)\b/)
|
|
101
|
+
rule_ids << "answer_number"
|
|
102
|
+
return :number
|
|
103
|
+
end
|
|
104
|
+
if down.match?(/\b(where|dónde|donde|place|address|dirección|direccion)\b/)
|
|
105
|
+
rule_ids << "answer_place"
|
|
106
|
+
return :place
|
|
107
|
+
end
|
|
108
|
+
if down.match?(/\b(yes|no|true|false|sí|si)\b/)
|
|
109
|
+
rule_ids << "answer_boolean"
|
|
110
|
+
return :boolean
|
|
111
|
+
end
|
|
112
|
+
if down.match?(/\b(list|which files|qué archivos|que archivos)\b/)
|
|
113
|
+
rule_ids << "answer_list"
|
|
114
|
+
return :list
|
|
115
|
+
end
|
|
116
|
+
:free_text
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def expected_count(workload, aggregation_cues = [])
|
|
120
|
+
return [3, Llmemory.configuration.zero_mem_max_top_k].min if aggregation_cues.any?
|
|
121
|
+
|
|
122
|
+
case workload
|
|
123
|
+
when :multi_hop then 3
|
|
124
|
+
when :procedural then 2
|
|
125
|
+
when :temporal then 2
|
|
126
|
+
else 1
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def normalize_boundary(boundary)
|
|
131
|
+
return nil if boundary.nil?
|
|
132
|
+
|
|
133
|
+
b = boundary.is_a?(Hash) ? boundary.transform_keys(&:to_sym) : { session_id: boundary }
|
|
134
|
+
b.compact
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|