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,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module ZeroMem
|
|
7
|
+
class Backfill
|
|
8
|
+
WARNING = "Backfill only covers messages still present in the checkpoint; " \
|
|
9
|
+
"text removed by prior LLM compaction cannot be reconstructed."
|
|
10
|
+
|
|
11
|
+
def initialize(storage:, indexer: nil)
|
|
12
|
+
@storage = storage
|
|
13
|
+
@indexer = indexer || Indexer.new(storage)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run!(user_id:, session_id:, messages:, id_prefix: "backfill")
|
|
17
|
+
created = []
|
|
18
|
+
Array(messages).each_with_index do |msg, idx|
|
|
19
|
+
role = msg[:role] || msg["role"]
|
|
20
|
+
content = msg[:content] || msg["content"]
|
|
21
|
+
next if content.to_s.empty?
|
|
22
|
+
|
|
23
|
+
sequence = @storage.next_sequence(user_id, session_id)
|
|
24
|
+
trace = Trace.build(
|
|
25
|
+
user_id: user_id,
|
|
26
|
+
session_id: session_id,
|
|
27
|
+
role: role,
|
|
28
|
+
content: content,
|
|
29
|
+
sequence: sequence,
|
|
30
|
+
idempotency_key: "#{id_prefix}-#{idx}-#{Digest::SHA256.hexdigest(content.to_s)[0, 12]}"
|
|
31
|
+
)
|
|
32
|
+
next if @storage.find_by_idempotency_key(user_id, trace.idempotency_key)
|
|
33
|
+
|
|
34
|
+
@storage.write_trace(trace)
|
|
35
|
+
@indexer.after_trace_write(trace: trace)
|
|
36
|
+
created << trace.id
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
{ created_trace_ids: created, warning: WARNING }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class BudgetAssembler
|
|
6
|
+
RESERVE_TOKENS = 64
|
|
7
|
+
|
|
8
|
+
def assemble(evidence, max_tokens:, local_reserve: RESERVE_TOKENS, atomic_groups: nil)
|
|
9
|
+
budget = max_tokens&.to_i
|
|
10
|
+
return evidence if budget.nil? || budget <= 0
|
|
11
|
+
|
|
12
|
+
reserve = [local_reserve, budget / 4].min
|
|
13
|
+
remaining = budget - reserve
|
|
14
|
+
selected = []
|
|
15
|
+
used_ids = {}
|
|
16
|
+
|
|
17
|
+
groups = build_atomic_groups(evidence, atomic_groups)
|
|
18
|
+
ungrouped = evidence.reject { |ev| groups.any? { |g| g.include?(ev.trace_id) } }
|
|
19
|
+
|
|
20
|
+
groups.each do |group|
|
|
21
|
+
members = group.map { |id| evidence.find { |ev| ev.trace_id == id } }.compact
|
|
22
|
+
cost = members.sum { |ev| token_cost(ev) }
|
|
23
|
+
next if cost > remaining
|
|
24
|
+
|
|
25
|
+
members.each do |ev|
|
|
26
|
+
selected << ev
|
|
27
|
+
used_ids[ev.trace_id] = true
|
|
28
|
+
end
|
|
29
|
+
remaining -= cost
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
ungrouped.each do |ev|
|
|
33
|
+
next if used_ids[ev.trace_id]
|
|
34
|
+
|
|
35
|
+
cost = token_cost(ev)
|
|
36
|
+
next if cost > remaining && !selected.empty?
|
|
37
|
+
|
|
38
|
+
if cost <= remaining
|
|
39
|
+
selected << ev
|
|
40
|
+
remaining -= cost
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
selected
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_atomic_groups(evidence, explicit_groups)
|
|
48
|
+
ids = evidence.map(&:trace_id)
|
|
49
|
+
Array(explicit_groups).map do |group|
|
|
50
|
+
Array(group).map(&:to_s).select { |id| ids.include?(id) }
|
|
51
|
+
end.reject(&:empty?)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def token_cost(evidence)
|
|
57
|
+
line = "[#{evidence.trace_id}] #{evidence.role}: #{evidence.content}"
|
|
58
|
+
(Llmemory::Tokenizer.tokenize(line).size + 4)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
class CircuitBreaker
|
|
6
|
+
def initialize(failure_threshold: 3, cool_down_seconds: 30)
|
|
7
|
+
@failure_threshold = failure_threshold.to_i
|
|
8
|
+
@cool_down_seconds = cool_down_seconds.to_i
|
|
9
|
+
@failures = 0
|
|
10
|
+
@opened_at = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def allow_request?
|
|
14
|
+
return true if @opened_at.nil?
|
|
15
|
+
return false if (Time.now - @opened_at) < @cool_down_seconds
|
|
16
|
+
|
|
17
|
+
@opened_at = nil
|
|
18
|
+
@failures = 0
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record_success
|
|
23
|
+
@failures = 0
|
|
24
|
+
@opened_at = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def record_failure
|
|
28
|
+
@failures += 1
|
|
29
|
+
@opened_at = Time.now if @failures >= @failure_threshold
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def open?
|
|
33
|
+
!allow_request?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ZeroMem
|
|
5
|
+
module EmbeddingProvider
|
|
6
|
+
def embed(texts)
|
|
7
|
+
raise NotImplementedError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def model
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def dimensions
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def local?
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def last_usage
|
|
23
|
+
Llmemory::LLM::Usage.zero
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def embed_one(text)
|
|
27
|
+
embed([text]).first
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Deterministic vectors for tests (no HTTP).
|
|
32
|
+
class FakeEmbeddingProvider
|
|
33
|
+
include EmbeddingProvider
|
|
34
|
+
|
|
35
|
+
attr_reader :calls
|
|
36
|
+
|
|
37
|
+
def initialize(dimensions: 8, model: "fake-embed")
|
|
38
|
+
@dimensions = dimensions
|
|
39
|
+
@model = model
|
|
40
|
+
@calls = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def embed(texts)
|
|
44
|
+
@calls += 1
|
|
45
|
+
Array(texts).map { |text| vector_for(text) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def model
|
|
49
|
+
@model
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dimensions
|
|
53
|
+
@dimensions
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def local?
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def vector_for(text)
|
|
63
|
+
seed = Digest::SHA256.hexdigest(text.to_s)
|
|
64
|
+
@dimensions.times.map do |i|
|
|
65
|
+
byte = seed.bytes[i % seed.bytes.length] || 0
|
|
66
|
+
(byte / 255.0)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
require "digest"
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../retrieval/bm25_scorer"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module ZeroMem
|
|
7
|
+
class Engine
|
|
8
|
+
def initialize(trace_store:, user_id:, config: nil, profiler: nil, retriever: nil,
|
|
9
|
+
graph_retriever: nil, calibrator: nil, assembler: nil, entity_index: nil,
|
|
10
|
+
embedding_provider: nil, router: nil, fusion: nil, closure: nil, **options)
|
|
11
|
+
if options.key?(:llm) || options.key?("llm")
|
|
12
|
+
raise ArgumentError, "ZeroMem::Engine does not accept an llm client"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@storage = trace_store
|
|
16
|
+
@user_id = user_id
|
|
17
|
+
@config = config || Llmemory.configuration
|
|
18
|
+
@profiler = profiler || QueryProfiler.new
|
|
19
|
+
@retriever = retriever || HierarchyRetriever.new(@storage, config: @config)
|
|
20
|
+
@entity_index = entity_index || EntityIndex.new(@storage)
|
|
21
|
+
@embedding_provider = embedding_provider
|
|
22
|
+
@graph_retriever = graph_retriever || GraphRetriever.new(
|
|
23
|
+
@storage,
|
|
24
|
+
entity_index: @entity_index,
|
|
25
|
+
embedding_provider: @embedding_provider,
|
|
26
|
+
config: @config
|
|
27
|
+
)
|
|
28
|
+
@calibrator = calibrator || EvidenceCalibrator.new(@storage)
|
|
29
|
+
@assembler = assembler || BudgetAssembler.new
|
|
30
|
+
@router = router || Router.new
|
|
31
|
+
@fusion = fusion || EvidenceFusion.new
|
|
32
|
+
@closure = closure || EvidenceClosure.new(@storage, config: @config)
|
|
33
|
+
@bm25 = Retrieval::Bm25Scorer.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def retrieve_evidence(query, top_k: nil, max_tokens: nil, boundary: nil, explain: false,
|
|
37
|
+
current_trace_id: nil, skip_closure: false, skip_calibration: false,
|
|
38
|
+
fusion_weights: nil)
|
|
39
|
+
effective_k = effective_top_k(top_k, nil)
|
|
40
|
+
metrics = { llm_calls: 0, encoder_calls: 0, encoder_duration_ms: 0.0, stages: {} }
|
|
41
|
+
|
|
42
|
+
profile = nil
|
|
43
|
+
Llmemory::Instrumentation.instrument(:zero_mem_profile, user_id: @user_id, query_chars: query.to_s.length) do
|
|
44
|
+
profile = @profiler.profile(query, boundary: boundary)
|
|
45
|
+
end
|
|
46
|
+
effective_k = effective_top_k(top_k, profile)
|
|
47
|
+
|
|
48
|
+
retrieval = nil
|
|
49
|
+
Llmemory::Instrumentation.instrument(:zero_mem_hierarchy_retrieve, user_id: @user_id, workload: profile.workload_class) do
|
|
50
|
+
retrieval = @retriever.retrieve(user_id: @user_id, query: query, profile: profile, top_k: effective_k)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
graph = nil
|
|
54
|
+
Llmemory::Instrumentation.instrument(:zero_mem_graph_retrieve, user_id: @user_id) do
|
|
55
|
+
graph = @graph_retriever.retrieve(user_id: @user_id, query: query, profile: profile, top_k: effective_k)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
degraded = (retrieval[:degraded] + graph[:degraded]).uniq
|
|
59
|
+
degraded << :dense unless @embedding_provider
|
|
60
|
+
degraded.uniq!
|
|
61
|
+
|
|
62
|
+
hierarchy_scores = hierarchy_trace_scores(query, retrieval[:traces])
|
|
63
|
+
graph_scores = graph[:trace_scores] || {}
|
|
64
|
+
routing = @router.route(profile, hierarchy_scores: hierarchy_scores, graph_scores: graph_scores, config: @config)
|
|
65
|
+
weights = fusion_weights || routing[:weights]
|
|
66
|
+
|
|
67
|
+
fused = @fusion.fuse(graph_scores: graph_scores, hierarchy_scores: hierarchy_scores, weights: weights)
|
|
68
|
+
seed_rows = fused.first(effective_k)
|
|
69
|
+
seed_ids = seed_rows.map { |r| r[:trace_id] }
|
|
70
|
+
|
|
71
|
+
closure_result = if skip_closure
|
|
72
|
+
{ seed_trace_ids: seed_ids, closure_trace_ids: [], bridges: [], atomic_groups: [] }
|
|
73
|
+
else
|
|
74
|
+
Llmemory::Instrumentation.instrument(:zero_mem_closure, user_id: @user_id, seeds: seed_ids.size) do
|
|
75
|
+
@closure.expand(
|
|
76
|
+
user_id: @user_id,
|
|
77
|
+
profile: profile,
|
|
78
|
+
seed_trace_ids: seed_ids,
|
|
79
|
+
fused_ranking: fused
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
ordered_ids = (seed_ids + closure_result[:closure_trace_ids]).uniq
|
|
85
|
+
traces = ordered_ids.map { |id| @storage.get_trace(@user_id, id) }.compact
|
|
86
|
+
|
|
87
|
+
calibration = if skip_calibration
|
|
88
|
+
{ traces: traces, excluded: [], conflict_trace_ids: [], seed_ids: seed_ids,
|
|
89
|
+
closure_ids: closure_result[:closure_trace_ids] }
|
|
90
|
+
else
|
|
91
|
+
Llmemory::Instrumentation.instrument(:zero_mem_calibrate, user_id: @user_id, trace_count: traces.size) do
|
|
92
|
+
@calibrator.calibrate(
|
|
93
|
+
user_id: @user_id,
|
|
94
|
+
traces: traces,
|
|
95
|
+
profile: profile,
|
|
96
|
+
current_trace_id: current_trace_id,
|
|
97
|
+
fusion_rows: fused,
|
|
98
|
+
seed_ids: seed_ids,
|
|
99
|
+
closure_ids: closure_result[:closure_trace_ids]
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
fusion_by_id = fused.each_with_object({}) { |row, acc| acc[row[:trace_id].to_s] = row }
|
|
105
|
+
conflict_ids = calibration[:conflict_trace_ids].to_h { |id| [id.to_s, true] }
|
|
106
|
+
|
|
107
|
+
evidence = calibration[:traces].map do |trace|
|
|
108
|
+
row = fusion_by_id[trace.id]
|
|
109
|
+
score = row ? row[:final] : 0.5
|
|
110
|
+
Evidence.new(
|
|
111
|
+
trace_id: trace.id,
|
|
112
|
+
content: trace.content,
|
|
113
|
+
role: trace.role,
|
|
114
|
+
session_id: trace.session_id,
|
|
115
|
+
occurred_at: trace.occurred_at,
|
|
116
|
+
confidence: score.clamp(0.0, 1.0),
|
|
117
|
+
score: score.clamp(0.0, 1.0),
|
|
118
|
+
sources: row ? row[:sources] : [],
|
|
119
|
+
conflict: conflict_ids[trace.id] == true,
|
|
120
|
+
seed: seed_ids.include?(trace.id),
|
|
121
|
+
closure: closure_result[:closure_trace_ids].include?(trace.id),
|
|
122
|
+
content_sha256: trace.content_sha256
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
seed_evidence = evidence.select(&:seed)
|
|
127
|
+
closure_evidence = evidence.select(&:closure)
|
|
128
|
+
|
|
129
|
+
packed = @assembler.assemble(
|
|
130
|
+
evidence,
|
|
131
|
+
max_tokens: max_tokens,
|
|
132
|
+
atomic_groups: closure_result[:atomic_groups]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
explain_payload = if explain
|
|
136
|
+
build_explain(
|
|
137
|
+
profile: profile,
|
|
138
|
+
routing: routing,
|
|
139
|
+
weights: weights,
|
|
140
|
+
effective_k: effective_k,
|
|
141
|
+
graph: graph,
|
|
142
|
+
retrieval: retrieval,
|
|
143
|
+
fused: fused,
|
|
144
|
+
calibration: calibration,
|
|
145
|
+
closure_result: closure_result,
|
|
146
|
+
degraded: degraded,
|
|
147
|
+
warnings: graph[:warnings]
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
result = EvidenceSet.new(
|
|
152
|
+
route: routing[:route],
|
|
153
|
+
profile: profile,
|
|
154
|
+
evidence: packed,
|
|
155
|
+
seed_evidence: seed_evidence.select { |ev| packed.any? { |p| p.trace_id == ev.trace_id } },
|
|
156
|
+
closure_evidence: closure_evidence.select { |ev| packed.any? { |p| p.trace_id == ev.trace_id } },
|
|
157
|
+
metrics: metrics.merge(
|
|
158
|
+
degraded: degraded,
|
|
159
|
+
seed_recall: seed_evidence.map(&:trace_id),
|
|
160
|
+
closure_recall: (seed_evidence + closure_evidence).map(&:trace_id).uniq
|
|
161
|
+
),
|
|
162
|
+
explain: explain_payload,
|
|
163
|
+
degraded: degraded,
|
|
164
|
+
relational_trace_scores: graph_scores,
|
|
165
|
+
hierarchy_traces: retrieval[:traces],
|
|
166
|
+
relational_traces: graph[:traces],
|
|
167
|
+
warnings: graph[:warnings]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
Llmemory::Instrumentation.instrument(
|
|
171
|
+
:zero_mem_retrieve,
|
|
172
|
+
user_id: @user_id,
|
|
173
|
+
evidence_count: packed.size,
|
|
174
|
+
degraded: degraded,
|
|
175
|
+
seed_count: seed_evidence.size,
|
|
176
|
+
closure_count: closure_evidence.size
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
result
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def to_context(query, **opts)
|
|
183
|
+
retrieve_evidence(query, **opts).to_context
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def effective_top_k(explicit, profile)
|
|
189
|
+
base = explicit || @config.zero_mem_top_k
|
|
190
|
+
need = profile ? [base, profile.expected_evidence_count].max : base
|
|
191
|
+
[need, @config.zero_mem_max_top_k].min
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def hierarchy_trace_scores(query, traces)
|
|
195
|
+
docs = traces.map { |t| { id: t.id, text: t.content } }
|
|
196
|
+
return {} if docs.empty?
|
|
197
|
+
|
|
198
|
+
@bm25.score_documents(query, docs).to_h { |d| [d[:id], d[:normalized_bm25].to_f] }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def build_explain(profile:, routing:, weights:, effective_k:, graph:, retrieval:, fused:,
|
|
202
|
+
calibration:, closure_result:, degraded:, warnings:)
|
|
203
|
+
{
|
|
204
|
+
profile: profile.to_h,
|
|
205
|
+
route: routing[:route],
|
|
206
|
+
weights: weights,
|
|
207
|
+
thresholds: {
|
|
208
|
+
rho: routing[:rho],
|
|
209
|
+
gamma: @config.zero_mem_pagerank_damping,
|
|
210
|
+
top_k: effective_k
|
|
211
|
+
},
|
|
212
|
+
graph: {
|
|
213
|
+
seeds: graph[:trace_scores]&.keys&.first(5),
|
|
214
|
+
nodes_visited: graph[:traces]&.size,
|
|
215
|
+
truncated: warnings.include?(:pagerank_truncated),
|
|
216
|
+
boosts: []
|
|
217
|
+
},
|
|
218
|
+
hierarchy: {
|
|
219
|
+
episodes: retrieval[:stage_scores][:episodes],
|
|
220
|
+
windows: retrieval[:stage_scores][:windows],
|
|
221
|
+
turns: retrieval[:stage_scores][:turns]
|
|
222
|
+
},
|
|
223
|
+
fusion: {
|
|
224
|
+
per_trace: fused.first(effective_k).map do |row|
|
|
225
|
+
{
|
|
226
|
+
id: row[:trace_id],
|
|
227
|
+
graph: row[:graph],
|
|
228
|
+
hierarchy: row[:hierarchy],
|
|
229
|
+
final: row[:final],
|
|
230
|
+
sources: row[:sources]
|
|
231
|
+
}
|
|
232
|
+
end
|
|
233
|
+
},
|
|
234
|
+
included: calibration[:traces].map(&:id),
|
|
235
|
+
excluded: calibration[:excluded],
|
|
236
|
+
closure: {
|
|
237
|
+
seed_ids: closure_result[:seed_trace_ids],
|
|
238
|
+
closure_ids: closure_result[:closure_trace_ids],
|
|
239
|
+
bridges: closure_result[:bridges]
|
|
240
|
+
},
|
|
241
|
+
warnings: warnings,
|
|
242
|
+
degraded: degraded
|
|
243
|
+
}
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module ZeroMem
|
|
7
|
+
class Entity
|
|
8
|
+
attr_reader :id, :user_id, :normalized_key, :display_name, :entity_type, :ambiguous
|
|
9
|
+
|
|
10
|
+
def self.build(user_id:, normalized_key:, display_name: nil, entity_type: :entity, id: nil, ambiguous: false)
|
|
11
|
+
new(
|
|
12
|
+
id: id || "ent_#{Digest::SHA256.hexdigest([user_id, normalized_key].join(':'))[0, 16]}",
|
|
13
|
+
user_id: user_id.to_s,
|
|
14
|
+
normalized_key: normalized_key.to_s,
|
|
15
|
+
display_name: display_name || normalized_key.to_s,
|
|
16
|
+
entity_type: entity_type.to_sym,
|
|
17
|
+
ambiguous: ambiguous
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(id:, user_id:, normalized_key:, display_name:, entity_type:, ambiguous:)
|
|
22
|
+
@id = id
|
|
23
|
+
@user_id = user_id
|
|
24
|
+
@normalized_key = normalized_key
|
|
25
|
+
@display_name = display_name
|
|
26
|
+
@entity_type = entity_type
|
|
27
|
+
@ambiguous = ambiguous
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -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
|