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,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
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class Repair
|
|
6
|
+
def initialize(storage:)
|
|
7
|
+
@storage = storage
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Reconcile watermark with persisted traces and checkpoint message counts (ZM1).
|
|
11
|
+
def run!(user_id:, session_id:, checkpoint_messages:)
|
|
12
|
+
traces = @storage.list_traces(user_id, session_id: session_id)
|
|
13
|
+
last_sequence = traces.map(&:sequence).max.to_i
|
|
14
|
+
wm = @storage.get_watermark(user_id, session_id)
|
|
15
|
+
@storage.set_watermark(
|
|
16
|
+
user_id,
|
|
17
|
+
session_id,
|
|
18
|
+
last_sequence: last_sequence,
|
|
19
|
+
index_version: [wm[:index_version].to_i, last_sequence].max
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
trace_count: traces.size,
|
|
24
|
+
checkpoint_message_count: Array(checkpoint_messages).size,
|
|
25
|
+
last_sequence: last_sequence,
|
|
26
|
+
index_version: [wm[:index_version].to_i, last_sequence].max
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class Router
|
|
6
|
+
RELATIONAL_WORKLOADS = %i[multi_hop].freeze
|
|
7
|
+
LOCAL_WORKLOADS = %i[temporal procedural cross_session local_fact].freeze
|
|
8
|
+
|
|
9
|
+
def route(profile, hierarchy_scores:, graph_scores:, config: Llmemory.configuration)
|
|
10
|
+
weights, route, tie_break = route_for_workload(profile, config)
|
|
11
|
+
if tie_break
|
|
12
|
+
weights = resolve_tie(weights, hierarchy_scores, graph_scores, profile)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
route: route,
|
|
17
|
+
weights: weights,
|
|
18
|
+
rho: weights[:graph],
|
|
19
|
+
tie_break: tie_break
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def route_for_workload(profile, config)
|
|
26
|
+
workload = profile.workload_class
|
|
27
|
+
if RELATIONAL_WORKLOADS.include?(workload) || profile.aggregation_cues.any?
|
|
28
|
+
return [relational_weights(config), :relational, false]
|
|
29
|
+
end
|
|
30
|
+
if LOCAL_WORKLOADS.include?(workload)
|
|
31
|
+
return [local_weights(config), :local, false]
|
|
32
|
+
end
|
|
33
|
+
if workload == :current_state
|
|
34
|
+
base = profile.subject_entities.size >= 2 ? relational_weights(config) : local_weights(config)
|
|
35
|
+
return [base, :current_state, profile.subject_entities.size < 2]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
[local_weights(config), :local, true]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def relational_weights(config)
|
|
42
|
+
rho = config.zero_mem_graph_weight || 0.6
|
|
43
|
+
{ graph: rho.to_f, hierarchy: 1.0 - rho.to_f }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def local_weights(_config)
|
|
47
|
+
{ graph: 0.4, hierarchy: 0.6 }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve_tie(default_weights, hierarchy_scores, graph_scores, profile)
|
|
51
|
+
h = anchor_confidence(hierarchy_scores, profile)
|
|
52
|
+
g = anchor_confidence(graph_scores, profile)
|
|
53
|
+
if g > h
|
|
54
|
+
relational_weights(Llmemory.configuration)
|
|
55
|
+
elsif h > g
|
|
56
|
+
local_weights(Llmemory.configuration)
|
|
57
|
+
else
|
|
58
|
+
default_weights
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def anchor_confidence(scores, profile)
|
|
63
|
+
return 0.0 if scores.nil? || scores.empty?
|
|
64
|
+
|
|
65
|
+
scores.values.sum.to_f / scores.size
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "faraday"
|
|
6
|
+
|
|
7
|
+
module Llmemory
|
|
8
|
+
module ZeroMem
|
|
9
|
+
class SidecarClient
|
|
10
|
+
MAX_TEXT_BYTES = 32_768
|
|
11
|
+
|
|
12
|
+
def initialize(base_url:, timeout: 5, breaker: nil)
|
|
13
|
+
@base_url = base_url.to_s.sub(%r{/\z}, "")
|
|
14
|
+
@timeout = timeout.to_f
|
|
15
|
+
@breaker = breaker || CircuitBreaker.new
|
|
16
|
+
validate_host!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def get_json(path)
|
|
20
|
+
raise StoreError, "sidecar circuit open" unless @breaker.allow_request?
|
|
21
|
+
|
|
22
|
+
response = connection.get(path)
|
|
23
|
+
unless response.success?
|
|
24
|
+
@breaker.record_failure
|
|
25
|
+
raise StoreError, "sidecar HTTP error: #{response.status}"
|
|
26
|
+
end
|
|
27
|
+
@breaker.record_success
|
|
28
|
+
JSON.parse(response.body)
|
|
29
|
+
rescue Faraday::Error
|
|
30
|
+
@breaker.record_failure
|
|
31
|
+
raise StoreError, "sidecar request failed"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def post_json(path, body)
|
|
35
|
+
raise StoreError, "sidecar circuit open" unless @breaker.allow_request?
|
|
36
|
+
|
|
37
|
+
text = body[:text].to_s
|
|
38
|
+
if text.bytesize > MAX_TEXT_BYTES
|
|
39
|
+
raise StoreError, "sidecar payload exceeds max text bytes (#{MAX_TEXT_BYTES})"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
response = connection.post(path) do |req|
|
|
43
|
+
req.headers["Content-Type"] = "application/json"
|
|
44
|
+
req.body = body.to_json
|
|
45
|
+
end
|
|
46
|
+
unless response.success?
|
|
47
|
+
@breaker.record_failure
|
|
48
|
+
raise StoreError, "sidecar HTTP error: #{response.status}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@breaker.record_success
|
|
52
|
+
JSON.parse(response.body)
|
|
53
|
+
rescue Faraday::Error
|
|
54
|
+
@breaker.record_failure
|
|
55
|
+
raise StoreError, "sidecar request failed"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def degraded?
|
|
59
|
+
@breaker.open?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def connection
|
|
65
|
+
@connection ||= Faraday.new(url: @base_url, request: { timeout: @timeout, open_timeout: @timeout }) do |f|
|
|
66
|
+
f.adapter Faraday.default_adapter
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_host!
|
|
71
|
+
return unless Llmemory.configuration.zero_mem_require_local_encoders
|
|
72
|
+
|
|
73
|
+
uri = URI.parse(@base_url)
|
|
74
|
+
host = uri.host.to_s
|
|
75
|
+
allowed = %w[127.0.0.1 localhost ::1]
|
|
76
|
+
return if allowed.include?(host)
|
|
77
|
+
|
|
78
|
+
raise ConfigurationError, "zero_mem sidecar host must be loopback when require_local_encoders is enabled"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|