llmemory 0.1.1
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +193 -0
- data/lib/generators/llmemory/install/install_generator.rb +24 -0
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +73 -0
- data/lib/llmemory/configuration.rb +51 -0
- data/lib/llmemory/extractors/entity_relation_extractor.rb +74 -0
- data/lib/llmemory/extractors/fact_extractor.rb +74 -0
- data/lib/llmemory/extractors.rb +9 -0
- data/lib/llmemory/llm/anthropic.rb +48 -0
- data/lib/llmemory/llm/base.rb +17 -0
- data/lib/llmemory/llm/openai.rb +46 -0
- data/lib/llmemory/llm.rb +18 -0
- data/lib/llmemory/long_term/file_based/category.rb +22 -0
- data/lib/llmemory/long_term/file_based/item.rb +31 -0
- data/lib/llmemory/long_term/file_based/memory.rb +83 -0
- data/lib/llmemory/long_term/file_based/resource.rb +22 -0
- data/lib/llmemory/long_term/file_based/retrieval.rb +90 -0
- data/lib/llmemory/long_term/file_based/storage.rb +35 -0
- data/lib/llmemory/long_term/file_based/storages/active_record_models.rb +26 -0
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +144 -0
- data/lib/llmemory/long_term/file_based/storages/base.rb +71 -0
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +231 -0
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +180 -0
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +100 -0
- data/lib/llmemory/long_term/file_based.rb +15 -0
- data/lib/llmemory/long_term/graph_based/conflict_resolver.rb +33 -0
- data/lib/llmemory/long_term/graph_based/edge.rb +49 -0
- data/lib/llmemory/long_term/graph_based/knowledge_graph.rb +114 -0
- data/lib/llmemory/long_term/graph_based/memory.rb +143 -0
- data/lib/llmemory/long_term/graph_based/node.rb +42 -0
- data/lib/llmemory/long_term/graph_based/storage.rb +24 -0
- data/lib/llmemory/long_term/graph_based/storages/active_record_models.rb +23 -0
- data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +132 -0
- data/lib/llmemory/long_term/graph_based/storages/base.rb +39 -0
- data/lib/llmemory/long_term/graph_based/storages/memory_storage.rb +106 -0
- data/lib/llmemory/long_term/graph_based.rb +15 -0
- data/lib/llmemory/long_term.rb +9 -0
- data/lib/llmemory/maintenance/consolidator.rb +55 -0
- data/lib/llmemory/maintenance/reindexer.rb +27 -0
- data/lib/llmemory/maintenance/runner.rb +34 -0
- data/lib/llmemory/maintenance/summarizer.rb +57 -0
- data/lib/llmemory/maintenance.rb +8 -0
- data/lib/llmemory/memory.rb +96 -0
- data/lib/llmemory/retrieval/context_assembler.rb +53 -0
- data/lib/llmemory/retrieval/engine.rb +74 -0
- data/lib/llmemory/retrieval/temporal_ranker.rb +23 -0
- data/lib/llmemory/retrieval.rb +10 -0
- data/lib/llmemory/short_term/checkpoint.rb +47 -0
- data/lib/llmemory/short_term/stores/active_record_checkpoint.rb +14 -0
- data/lib/llmemory/short_term/stores/active_record_store.rb +58 -0
- data/lib/llmemory/short_term/stores/base.rb +21 -0
- data/lib/llmemory/short_term/stores/memory_store.rb +37 -0
- data/lib/llmemory/short_term/stores/postgres_store.rb +80 -0
- data/lib/llmemory/short_term/stores/redis_store.rb +54 -0
- data/lib/llmemory/short_term.rb +8 -0
- data/lib/llmemory/vector_store/base.rb +19 -0
- data/lib/llmemory/vector_store/memory_store.rb +53 -0
- data/lib/llmemory/vector_store/openai_embeddings.rb +49 -0
- data/lib/llmemory/vector_store.rb +10 -0
- data/lib/llmemory/version.rb +5 -0
- data/lib/llmemory.rb +19 -0
- metadata +163 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module Maintenance
|
|
7
|
+
class Consolidator
|
|
8
|
+
def initialize(storage)
|
|
9
|
+
@storage = storage
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run_nightly(user_id)
|
|
13
|
+
recent = @storage.get_items_since(user_id, hours: 24)
|
|
14
|
+
duplicates = find_duplicates(recent)
|
|
15
|
+
|
|
16
|
+
duplicates.each do |group|
|
|
17
|
+
merged = merge_items(group)
|
|
18
|
+
ids = group.map { |i| i[:id] }
|
|
19
|
+
@storage.replace_items(user_id, ids, merged)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def find_duplicates(items)
|
|
28
|
+
groups = []
|
|
29
|
+
seen = {}
|
|
30
|
+
items.each do |item|
|
|
31
|
+
key = normalize_content(item[:content].to_s)
|
|
32
|
+
next if key.empty?
|
|
33
|
+
seen[key] ||= []
|
|
34
|
+
seen[key] << item
|
|
35
|
+
end
|
|
36
|
+
seen.each_value { |v| groups << v if v.size > 1 }
|
|
37
|
+
groups
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def normalize_content(content)
|
|
41
|
+
content.to_s.downcase.gsub(/\s+/, " ").strip[0..200]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def merge_items(group)
|
|
45
|
+
contents = group.map { |i| i[:content].to_s }.uniq
|
|
46
|
+
{
|
|
47
|
+
id: "merged_#{SecureRandom.hex(4)}",
|
|
48
|
+
category: group.first[:category],
|
|
49
|
+
content: contents.join("; "),
|
|
50
|
+
source_resource_id: group.first[:source_resource_id]
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Maintenance
|
|
5
|
+
class Reindexer
|
|
6
|
+
ARCHIVE_AFTER_DAYS = 180
|
|
7
|
+
|
|
8
|
+
def initialize(storage)
|
|
9
|
+
@storage = storage
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run_monthly(user_id)
|
|
13
|
+
items = @storage.get_all_items(user_id)
|
|
14
|
+
resources = @storage.get_all_resources(user_id)
|
|
15
|
+
cutoff = Time.now - (ARCHIVE_AFTER_DAYS * 86400)
|
|
16
|
+
|
|
17
|
+
old_item_ids = items.select { |i| i[:created_at] < cutoff }.map { |i| i[:id] }
|
|
18
|
+
old_resource_ids = resources.select { |r| r[:created_at] < cutoff }.map { |r| r[:id] }
|
|
19
|
+
|
|
20
|
+
@storage.archive_items(user_id, old_item_ids) if old_item_ids.any?
|
|
21
|
+
@storage.archive_resources(user_id, old_resource_ids) if old_resource_ids.any?
|
|
22
|
+
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "consolidator"
|
|
4
|
+
require_relative "summarizer"
|
|
5
|
+
require_relative "reindexer"
|
|
6
|
+
|
|
7
|
+
module Llmemory
|
|
8
|
+
module Maintenance
|
|
9
|
+
class Runner
|
|
10
|
+
class << self
|
|
11
|
+
def run_nightly(user_id, storage: nil)
|
|
12
|
+
storage ||= default_storage(user_id)
|
|
13
|
+
Consolidator.new(storage).run_nightly(user_id)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run_weekly(user_id, storage: nil)
|
|
17
|
+
storage ||= default_storage(user_id)
|
|
18
|
+
Summarizer.new(storage).run_weekly(user_id)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run_monthly(user_id, storage: nil)
|
|
22
|
+
storage ||= default_storage(user_id)
|
|
23
|
+
Reindexer.new(storage).run_monthly(user_id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def default_storage(user_id)
|
|
29
|
+
Llmemory::LongTerm::FileBased::Storages.build
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Maintenance
|
|
5
|
+
class Summarizer
|
|
6
|
+
def initialize(storage, llm: nil)
|
|
7
|
+
@storage = storage
|
|
8
|
+
@llm = llm || Llmemory::LLM.client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def run_weekly(user_id, prune_after_days: nil)
|
|
12
|
+
prune_after_days ||= Llmemory.configuration.prune_after_days
|
|
13
|
+
old_items = @storage.get_items_older_than(user_id, days: 30)
|
|
14
|
+
categories = group_by_category(old_items)
|
|
15
|
+
|
|
16
|
+
categories.each do |category, items|
|
|
17
|
+
next if items.empty?
|
|
18
|
+
summary = create_summary(items)
|
|
19
|
+
existing = @storage.load_category(user_id, category)
|
|
20
|
+
updated = existing.to_s.empty? ? summary : "#{existing}\n\n## Archived summary\n#{summary}"
|
|
21
|
+
@storage.save_category(user_id, category, updated)
|
|
22
|
+
@storage.archive_items(user_id, items.map { |i| i[:id] })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
prune_stale_items(user_id, prune_after_days)
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def group_by_category(items)
|
|
32
|
+
items.group_by { |i| i[:category] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def create_summary(items)
|
|
36
|
+
bullet_points = items.map { |i| "- #{i[:content]}" }.join("\n")
|
|
37
|
+
return bullet_points if bullet_points.length < 500
|
|
38
|
+
prompt = <<~PROMPT
|
|
39
|
+
Summarize these memory items into a short markdown paragraph (max 200 words).
|
|
40
|
+
Items:
|
|
41
|
+
#{bullet_points}
|
|
42
|
+
Return only the summary.
|
|
43
|
+
PROMPT
|
|
44
|
+
@llm.invoke(prompt.strip).to_s
|
|
45
|
+
rescue Llmemory::LLMError
|
|
46
|
+
bullet_points[0..500]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def prune_stale_items(user_id, days)
|
|
50
|
+
old = @storage.get_items_older_than(user_id, days: days)
|
|
51
|
+
return if old.empty?
|
|
52
|
+
ids = old.map { |i| i[:id] }
|
|
53
|
+
@storage.archive_items(user_id, ids)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "short_term/checkpoint"
|
|
4
|
+
require_relative "long_term/file_based"
|
|
5
|
+
require_relative "retrieval/engine"
|
|
6
|
+
|
|
7
|
+
module Llmemory
|
|
8
|
+
class Memory
|
|
9
|
+
DEFAULT_SESSION_ID = "default"
|
|
10
|
+
STATE_KEY_MESSAGES = :messages
|
|
11
|
+
|
|
12
|
+
def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil, retrieval_engine: nil)
|
|
13
|
+
@user_id = user_id
|
|
14
|
+
@session_id = session_id
|
|
15
|
+
@checkpoint = checkpoint || ShortTerm::Checkpoint.new(user_id: user_id, session_id: session_id)
|
|
16
|
+
type = long_term_type || Llmemory.configuration.long_term_type || :file_based
|
|
17
|
+
@long_term = long_term || build_long_term(type)
|
|
18
|
+
@retrieval_engine = retrieval_engine || Retrieval::Engine.new(@long_term)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def add_message(role:, content:)
|
|
22
|
+
msgs = messages
|
|
23
|
+
msgs << { role: role.to_sym, content: content.to_s }
|
|
24
|
+
save_state(messages: msgs)
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def messages
|
|
29
|
+
state = @checkpoint.restore_state
|
|
30
|
+
return [] unless state.is_a?(Hash)
|
|
31
|
+
list = state[STATE_KEY_MESSAGES] || state[STATE_KEY_MESSAGES.to_s]
|
|
32
|
+
list.is_a?(Array) ? list.dup : []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def retrieve(query, max_tokens: nil)
|
|
36
|
+
short_context = format_short_term_context(messages)
|
|
37
|
+
long_context = @retrieval_engine.retrieve_for_inference(query, user_id: @user_id, max_tokens: max_tokens)
|
|
38
|
+
combine_contexts(short_context, long_context)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def consolidate!
|
|
42
|
+
msgs = messages
|
|
43
|
+
return true if msgs.empty?
|
|
44
|
+
conversation_text = msgs.map { |m| "#{m[:role]}: #{m[:content]}" }.join("\n")
|
|
45
|
+
@long_term.memorize(conversation_text)
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def clear_session!
|
|
50
|
+
@checkpoint.clear_state
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def user_id
|
|
55
|
+
@user_id
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def build_long_term(long_term_type)
|
|
61
|
+
case long_term_type.to_s.to_sym
|
|
62
|
+
when :graph_based
|
|
63
|
+
LongTerm::GraphBased::Memory.new(
|
|
64
|
+
user_id: @user_id,
|
|
65
|
+
storage: LongTerm::GraphBased::Storages.build
|
|
66
|
+
)
|
|
67
|
+
else
|
|
68
|
+
LongTerm::FileBased::Memory.new(user_id: @user_id, storage: LongTerm::FileBased::Storages.build)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def save_state(messages:)
|
|
73
|
+
@checkpoint.save_state(STATE_KEY_MESSAGES => messages)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def format_short_term_context(msgs)
|
|
77
|
+
return "" if msgs.empty?
|
|
78
|
+
lines = ["=== RECENT CONVERSATION ===", ""]
|
|
79
|
+
msgs.each do |m|
|
|
80
|
+
role = m[:role] || m["role"]
|
|
81
|
+
content = m[:content] || m["content"]
|
|
82
|
+
lines << "#{role}: #{content}"
|
|
83
|
+
end
|
|
84
|
+
lines << ""
|
|
85
|
+
lines << "=== END RECENT CONVERSATION ==="
|
|
86
|
+
lines.join("\n")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def combine_contexts(short_context, long_context)
|
|
90
|
+
parts = []
|
|
91
|
+
parts << short_context if short_context.to_s.strip.length.positive?
|
|
92
|
+
parts << long_context.to_s.strip if long_context.to_s.strip.length.positive?
|
|
93
|
+
parts.join("\n\n")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Retrieval
|
|
5
|
+
class ContextAssembler
|
|
6
|
+
def initialize(max_tokens: nil)
|
|
7
|
+
@max_tokens = max_tokens || Llmemory.configuration.max_retrieval_tokens
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def assemble(ranked_memories, max_tokens: nil)
|
|
11
|
+
max_tokens ||= @max_tokens
|
|
12
|
+
selected = []
|
|
13
|
+
token_count = 0
|
|
14
|
+
|
|
15
|
+
ranked_memories.each do |memory|
|
|
16
|
+
text = memory[:text] || memory["text"] || ""
|
|
17
|
+
memory_tokens = count_tokens(text)
|
|
18
|
+
break if token_count + memory_tokens > max_tokens
|
|
19
|
+
|
|
20
|
+
selected << {
|
|
21
|
+
text: text,
|
|
22
|
+
timestamp: memory[:timestamp] || memory["timestamp"],
|
|
23
|
+
confidence: memory[:temporal_score] || memory[:score] || memory["score"]
|
|
24
|
+
}
|
|
25
|
+
token_count += memory_tokens
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
format_context(selected)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def count_tokens(text)
|
|
32
|
+
(text.to_s.length / 4.0).ceil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def format_context(memories)
|
|
38
|
+
lines = ["=== RELEVANT MEMORIES ===", ""]
|
|
39
|
+
memories.each do |mem|
|
|
40
|
+
ts = mem[:timestamp]
|
|
41
|
+
ts_str = ts.respond_to?(:iso8601) ? ts.iso8601 : ts.to_s
|
|
42
|
+
conf = mem[:confidence]
|
|
43
|
+
conf_str = conf.is_a?(Numeric) ? format("%.2f", conf) : conf.to_s
|
|
44
|
+
lines << "[#{ts_str}] (confidence: #{conf_str})"
|
|
45
|
+
lines << mem[:text].to_s
|
|
46
|
+
lines << ""
|
|
47
|
+
end
|
|
48
|
+
lines << "=== END MEMORIES ==="
|
|
49
|
+
lines.join("\n")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "temporal_ranker"
|
|
4
|
+
require_relative "context_assembler"
|
|
5
|
+
|
|
6
|
+
module Llmemory
|
|
7
|
+
module Retrieval
|
|
8
|
+
class Engine
|
|
9
|
+
RELEVANCE_THRESHOLD = 0.7
|
|
10
|
+
|
|
11
|
+
def initialize(memory, llm: nil)
|
|
12
|
+
@memory = memory
|
|
13
|
+
@llm = llm || Llmemory::LLM.client
|
|
14
|
+
@ranker = TemporalRanker.new
|
|
15
|
+
@assembler = ContextAssembler.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def retrieve_for_inference(user_message, user_id: nil, max_tokens: nil)
|
|
19
|
+
user_id ||= @memory.respond_to?(:user_id) ? @memory.user_id : nil
|
|
20
|
+
search_query = generate_query(user_message)
|
|
21
|
+
candidates = fetch_candidates(search_query, user_id)
|
|
22
|
+
|
|
23
|
+
relevant = filter_by_relevance(candidates, user_message)
|
|
24
|
+
ranked = @ranker.rank(relevant)
|
|
25
|
+
@assembler.assemble(ranked, max_tokens: max_tokens)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def generate_query(user_message)
|
|
31
|
+
return user_message.to_s if user_message.to_s.length <= 100
|
|
32
|
+
prompt = <<~PROMPT
|
|
33
|
+
Summarize this user message into a short search query (one sentence, under 15 words) to find relevant memories.
|
|
34
|
+
Message: #{user_message}
|
|
35
|
+
Return only the search query.
|
|
36
|
+
PROMPT
|
|
37
|
+
@llm.invoke(prompt.strip).to_s.strip
|
|
38
|
+
rescue Llmemory::LLMError
|
|
39
|
+
user_message.to_s[0..200]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def fetch_candidates(search_query, user_id)
|
|
43
|
+
return [] unless @memory.respond_to?(:search_candidates)
|
|
44
|
+
|
|
45
|
+
raw = @memory.search_candidates(search_query, user_id: user_id, top_k: 20)
|
|
46
|
+
raw.map do |c|
|
|
47
|
+
{
|
|
48
|
+
text: c[:text] || c["text"],
|
|
49
|
+
timestamp: parse_timestamp(c[:timestamp] || c["timestamp"] || c[:created_at] || c["created_at"]),
|
|
50
|
+
score: (c[:score] || c["score"] || 1.0).to_f
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse_timestamp(ts)
|
|
56
|
+
return ts if ts.is_a?(Time)
|
|
57
|
+
return Time.parse(ts.to_s) if ts
|
|
58
|
+
Time.now
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def filter_by_relevance(candidates, user_message)
|
|
62
|
+
return candidates if candidates.size <= 5
|
|
63
|
+
user_lower = user_message.to_s.downcase
|
|
64
|
+
candidates.select do |c|
|
|
65
|
+
text = (c[:text] || c["text"]).to_s.downcase
|
|
66
|
+
score = (c[:score] || c["score"] || 1.0).to_f
|
|
67
|
+
next true if score >= RELEVANCE_THRESHOLD
|
|
68
|
+
next true if user_lower.split.any? { |w| w.length > 3 && text.include?(w) }
|
|
69
|
+
score >= 0.5
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module Retrieval
|
|
5
|
+
class TemporalRanker
|
|
6
|
+
def initialize(half_life_days: nil)
|
|
7
|
+
@half_life_days = half_life_days || Llmemory.configuration.time_decay_half_life_days
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def rank(candidates, now: Time.now)
|
|
11
|
+
candidates.map do |c|
|
|
12
|
+
score = (c[:score] || c["score"] || 1.0).to_f
|
|
13
|
+
timestamp = c[:timestamp] || c["timestamp"]
|
|
14
|
+
timestamp = Time.parse(timestamp.to_s) if timestamp.is_a?(String)
|
|
15
|
+
age_days = timestamp ? (now - timestamp).to_i / 86400 : 0
|
|
16
|
+
time_decay = 1.0 / (1.0 + (age_days.to_f / @half_life_days))
|
|
17
|
+
final_score = score * time_decay
|
|
18
|
+
c.merge(score: score, temporal_score: final_score, timestamp: timestamp)
|
|
19
|
+
end.sort_by { |c| -(c[:temporal_score] || 0) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stores/base"
|
|
4
|
+
require_relative "stores/memory_store"
|
|
5
|
+
require_relative "stores/redis_store"
|
|
6
|
+
require_relative "stores/postgres_store"
|
|
7
|
+
|
|
8
|
+
module Llmemory
|
|
9
|
+
module ShortTerm
|
|
10
|
+
class Checkpoint
|
|
11
|
+
DEFAULT_SESSION_ID = "default"
|
|
12
|
+
|
|
13
|
+
def initialize(user_id:, session_id: DEFAULT_SESSION_ID, store: nil)
|
|
14
|
+
@user_id = user_id
|
|
15
|
+
@session_id = session_id
|
|
16
|
+
@store = store || build_store
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def save_state(state)
|
|
20
|
+
@store.save(@user_id, @session_id, state)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def restore_state
|
|
24
|
+
@store.load(@user_id, @session_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def clear_state
|
|
28
|
+
@store.delete(@user_id, @session_id)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def build_store
|
|
34
|
+
case Llmemory.configuration.short_term_store.to_sym
|
|
35
|
+
when :memory then Stores::MemoryStore.new
|
|
36
|
+
when :redis then Stores::RedisStore.new
|
|
37
|
+
when :postgres then Stores::PostgresStore.new
|
|
38
|
+
when :active_record, :activerecord
|
|
39
|
+
require_relative "stores/active_record_store"
|
|
40
|
+
Stores::ActiveRecordStore.new
|
|
41
|
+
else
|
|
42
|
+
Stores::MemoryStore.new
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Model for short-term checkpoint (ActiveRecordStore). Table: llmemory_checkpoints.
|
|
4
|
+
# Create with: rails g llmemory:install && rails db:migrate
|
|
5
|
+
|
|
6
|
+
module Llmemory
|
|
7
|
+
module ShortTerm
|
|
8
|
+
module Stores
|
|
9
|
+
class ActiveRecordCheckpoint < ::ActiveRecord::Base
|
|
10
|
+
self.table_name = "llmemory_checkpoints"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module ShortTerm
|
|
7
|
+
module Stores
|
|
8
|
+
class ActiveRecordStore < Base
|
|
9
|
+
def initialize
|
|
10
|
+
self.class.load_model!
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.load_model!
|
|
14
|
+
return if @model_loaded
|
|
15
|
+
require "active_record"
|
|
16
|
+
require_relative "active_record_checkpoint"
|
|
17
|
+
@model_loaded = true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def save(user_id, session_id, state)
|
|
21
|
+
record = Llmemory::ShortTerm::Stores::ActiveRecordCheckpoint.find_or_initialize_by(
|
|
22
|
+
user_id: user_id,
|
|
23
|
+
session_id: session_id
|
|
24
|
+
)
|
|
25
|
+
record.state = state
|
|
26
|
+
record.updated_at = Time.current
|
|
27
|
+
record.save!
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def load(user_id, session_id)
|
|
32
|
+
record = Llmemory::ShortTerm::Stores::ActiveRecordCheckpoint.find_by(
|
|
33
|
+
user_id: user_id,
|
|
34
|
+
session_id: session_id
|
|
35
|
+
)
|
|
36
|
+
return nil unless record
|
|
37
|
+
raw = record.state
|
|
38
|
+
raw.is_a?(Hash) ? raw.transform_keys(&:to_sym) : deserialize(raw)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def delete(user_id, session_id)
|
|
42
|
+
Llmemory::ShortTerm::Stores::ActiveRecordCheckpoint.where(
|
|
43
|
+
user_id: user_id,
|
|
44
|
+
session_id: session_id
|
|
45
|
+
).destroy_all
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def deserialize(data)
|
|
52
|
+
return data if data.is_a?(Hash)
|
|
53
|
+
JSON.parse(data.to_s, symbolize_names: true)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module ShortTerm
|
|
5
|
+
module Stores
|
|
6
|
+
class Base
|
|
7
|
+
def save(user_id, session_id, state)
|
|
8
|
+
raise NotImplementedError, "#{self.class}#save must be implemented"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def load(user_id, session_id)
|
|
12
|
+
raise NotImplementedError, "#{self.class}#load must be implemented"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def delete(user_id, session_id)
|
|
16
|
+
raise NotImplementedError, "#{self.class}#delete must be implemented"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module ShortTerm
|
|
7
|
+
module Stores
|
|
8
|
+
class MemoryStore < Base
|
|
9
|
+
def initialize
|
|
10
|
+
@store = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def save(user_id, session_id, state)
|
|
14
|
+
key = key_for(user_id, session_id)
|
|
15
|
+
@store[key] = { state: state, updated_at: Time.now }
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def load(user_id, session_id)
|
|
20
|
+
key = key_for(user_id, session_id)
|
|
21
|
+
@store.dig(key, :state)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def delete(user_id, session_id)
|
|
25
|
+
@store.delete(key_for(user_id, session_id))
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def key_for(user_id, session_id)
|
|
32
|
+
"#{user_id}:#{session_id}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|