llmemory 0.1.16 → 0.2.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 +172 -0
- data/lib/llmemory/actions/reason.rb +49 -0
- data/lib/llmemory/actions.rb +8 -0
- data/lib/llmemory/configuration.rb +2 -0
- data/lib/llmemory/forget_log.rb +50 -0
- data/lib/llmemory/long_term/episodic/episode.rb +94 -0
- data/lib/llmemory/long_term/episodic/memory.rb +120 -0
- data/lib/llmemory/long_term/episodic/storage.rb +31 -0
- data/lib/llmemory/long_term/episodic/storages/base.rb +44 -0
- data/lib/llmemory/long_term/episodic/storages/file_storage.rb +126 -0
- data/lib/llmemory/long_term/episodic/storages/memory_storage.rb +74 -0
- data/lib/llmemory/long_term/episodic.rb +12 -0
- data/lib/llmemory/long_term/file_based/memory.rb +46 -0
- data/lib/llmemory/long_term/graph_based/memory.rb +30 -3
- data/lib/llmemory/long_term/procedural/memory.rb +116 -0
- data/lib/llmemory/long_term/procedural/skill.rb +93 -0
- data/lib/llmemory/long_term/procedural/storage.rb +31 -0
- data/lib/llmemory/long_term/procedural/storages/base.rb +53 -0
- data/lib/llmemory/long_term/procedural/storages/file_storage.rb +136 -0
- data/lib/llmemory/long_term/procedural/storages/memory_storage.rb +80 -0
- data/lib/llmemory/long_term/procedural.rb +12 -0
- data/lib/llmemory/long_term.rb +3 -0
- data/lib/llmemory/memory.rb +9 -1
- data/lib/llmemory/memory_module.rb +55 -0
- data/lib/llmemory/reflection/reflector.rb +116 -0
- data/lib/llmemory/reflection.rb +8 -0
- data/lib/llmemory/retrieval/engine.rb +115 -6
- data/lib/llmemory/retrieval/feedback_store.rb +50 -0
- data/lib/llmemory/short_term/checkpoint.rb +2 -14
- data/lib/llmemory/short_term/session_lifecycle.rb +3 -10
- data/lib/llmemory/short_term/stores.rb +27 -0
- data/lib/llmemory/version.rb +1 -1
- data/lib/llmemory/working_memory.rb +83 -0
- data/lib/llmemory.rb +5 -0
- metadata +24 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module Llmemory
|
|
9
|
+
module LongTerm
|
|
10
|
+
module Procedural
|
|
11
|
+
module Storages
|
|
12
|
+
class FileStorage < Base
|
|
13
|
+
def initialize(base_path: nil)
|
|
14
|
+
@base_path = base_path || Llmemory.configuration.long_term_storage_path || "./llmemory_data"
|
|
15
|
+
@base_path = File.expand_path(@base_path)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def save_skill(user_id, skill)
|
|
19
|
+
id = skill[:id] || skill["id"] || "skill_#{next_seq(user_id)}"
|
|
20
|
+
data = stringify_for_json(skill).merge("id" => id, "user_id" => user_id)
|
|
21
|
+
data["created_at"] ||= Time.now.iso8601(6)
|
|
22
|
+
File.write(skill_path(user_id, id), JSON.generate(data))
|
|
23
|
+
id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get_skill(user_id, id)
|
|
27
|
+
path = skill_path(user_id, id)
|
|
28
|
+
return nil unless File.file?(path)
|
|
29
|
+
load_skill(path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def list_skills(user_id, limit: nil)
|
|
33
|
+
sorted = all_skills(user_id).sort_by { |s| s[:created_at] }.reverse
|
|
34
|
+
limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def search_skills(user_id, query)
|
|
38
|
+
q = query.to_s.downcase
|
|
39
|
+
return list_skills(user_id) if q.strip.empty?
|
|
40
|
+
all_skills(user_id).select { |s| skill_text(s).downcase.include?(q) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def find_skills_by_name(user_id, name)
|
|
44
|
+
all_skills(user_id).select { |s| s[:name].to_s == name.to_s }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record_outcome(user_id, skill_id, success:)
|
|
48
|
+
skill = get_skill(user_id, skill_id)
|
|
49
|
+
return nil unless skill
|
|
50
|
+
key = success ? :success_count : :failure_count
|
|
51
|
+
skill[key] = (skill[key] || 0).to_i + 1
|
|
52
|
+
skill[:updated_at] = Time.now.iso8601(6)
|
|
53
|
+
File.write(skill_path(user_id, skill_id), JSON.generate(stringify_for_json(skill)))
|
|
54
|
+
skill
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def count_skills(user_id)
|
|
58
|
+
dir = user_path(user_id, "skills")
|
|
59
|
+
return 0 unless Dir.exist?(dir)
|
|
60
|
+
Dir.children(dir).count { |f| f.end_with?(".json") }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def delete_skills(user_id, ids)
|
|
64
|
+
Array(ids).map(&:to_s).count do |id|
|
|
65
|
+
path = skill_path(user_id, id)
|
|
66
|
+
next false unless File.file?(path)
|
|
67
|
+
File.delete(path)
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def list_users
|
|
73
|
+
return [] unless Dir.exist?(@base_path)
|
|
74
|
+
Dir.children(@base_path).select { |d| Dir.exist?(File.join(@base_path, d, "skills")) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def all_skills(user_id)
|
|
80
|
+
dir = user_path(user_id, "skills")
|
|
81
|
+
return [] unless Dir.exist?(dir)
|
|
82
|
+
Dir.children(dir).select { |f| f.end_with?(".json") }.map { |f| load_skill(File.join(dir, f)) }.compact
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def load_skill(path)
|
|
86
|
+
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
87
|
+
data[:created_at] = parse_time(data[:created_at])
|
|
88
|
+
data[:updated_at] = parse_time(data[:updated_at]) if data[:updated_at]
|
|
89
|
+
data
|
|
90
|
+
rescue JSON::ParserError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def skill_text(skill)
|
|
95
|
+
[skill[:name], skill[:description], skill[:body]].compact.join("\n")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def stringify_for_json(skill)
|
|
99
|
+
JSON.parse(JSON.generate(skill))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def user_path(user_id, *parts)
|
|
103
|
+
safe = user_id.to_s.gsub(%r{[^\w\-.]}, "_")
|
|
104
|
+
File.join(@base_path, safe, *parts)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def skill_path(user_id, id)
|
|
108
|
+
dir = user_path(user_id, "skills")
|
|
109
|
+
FileUtils.mkdir_p(dir)
|
|
110
|
+
File.join(dir, "#{id}.json")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def meta_path(user_id)
|
|
114
|
+
FileUtils.mkdir_p(user_path(user_id))
|
|
115
|
+
File.join(user_path(user_id), "meta.json")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def next_seq(user_id)
|
|
119
|
+
path = meta_path(user_id)
|
|
120
|
+
meta = File.file?(path) ? JSON.parse(File.read(path)) : {}
|
|
121
|
+
meta["skill_id_seq"] = (meta["skill_id_seq"] || 0) + 1
|
|
122
|
+
File.write(path, JSON.generate(meta))
|
|
123
|
+
meta["skill_id_seq"]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def parse_time(val)
|
|
127
|
+
return val if val.is_a?(Time)
|
|
128
|
+
Time.parse(val.to_s)
|
|
129
|
+
rescue ArgumentError
|
|
130
|
+
Time.now
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module LongTerm
|
|
7
|
+
module Procedural
|
|
8
|
+
module Storages
|
|
9
|
+
class MemoryStorage < Base
|
|
10
|
+
def initialize
|
|
11
|
+
@skills = Hash.new { |h, k| h[k] = [] }
|
|
12
|
+
@seq = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def save_skill(user_id, skill)
|
|
16
|
+
@seq += 1
|
|
17
|
+
id = skill[:id] || skill["id"] || "skill_#{@seq}"
|
|
18
|
+
record = symbolize(skill).merge(id: id, user_id: user_id)
|
|
19
|
+
record[:created_at] ||= Time.now
|
|
20
|
+
@skills[user_id] << record
|
|
21
|
+
id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get_skill(user_id, id)
|
|
25
|
+
@skills[user_id].find { |s| s[:id] == id }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def list_skills(user_id, limit: nil)
|
|
29
|
+
sorted = @skills[user_id].sort_by { |s| s[:created_at] }.reverse
|
|
30
|
+
limit && limit.to_i.positive? ? sorted.first(limit.to_i) : sorted
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def search_skills(user_id, query)
|
|
34
|
+
q = query.to_s.downcase
|
|
35
|
+
return list_skills(user_id) if q.strip.empty?
|
|
36
|
+
@skills[user_id].select { |s| skill_text(s).downcase.include?(q) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def find_skills_by_name(user_id, name)
|
|
40
|
+
@skills[user_id].select { |s| s[:name].to_s == name.to_s }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def record_outcome(user_id, skill_id, success:)
|
|
44
|
+
skill = get_skill(user_id, skill_id)
|
|
45
|
+
return nil unless skill
|
|
46
|
+
key = success ? :success_count : :failure_count
|
|
47
|
+
skill[key] = (skill[key] || 0).to_i + 1
|
|
48
|
+
skill[:updated_at] = Time.now
|
|
49
|
+
skill
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def count_skills(user_id)
|
|
53
|
+
@skills[user_id].size
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def delete_skills(user_id, ids)
|
|
57
|
+
ids = Array(ids).map(&:to_s)
|
|
58
|
+
before = @skills[user_id].size
|
|
59
|
+
@skills[user_id].reject! { |s| ids.include?(s[:id].to_s) }
|
|
60
|
+
before - @skills[user_id].size
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def list_users
|
|
64
|
+
@skills.keys
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def symbolize(hash)
|
|
70
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def skill_text(skill)
|
|
74
|
+
[skill[:name], skill[:description], skill[:body]].compact.join("\n")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
data/lib/llmemory/long_term.rb
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "memory_module"
|
|
3
4
|
require_relative "long_term/file_based"
|
|
4
5
|
require_relative "long_term/graph_based"
|
|
6
|
+
require_relative "long_term/episodic"
|
|
7
|
+
require_relative "long_term/procedural"
|
|
5
8
|
|
|
6
9
|
module Llmemory
|
|
7
10
|
module LongTerm
|
data/lib/llmemory/memory.rb
CHANGED
|
@@ -10,16 +10,24 @@ module Llmemory
|
|
|
10
10
|
DEFAULT_SESSION_ID = "default"
|
|
11
11
|
STATE_KEY_MESSAGES = :messages
|
|
12
12
|
|
|
13
|
-
def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil, retrieval_engine: nil, api_key: nil)
|
|
13
|
+
def initialize(user_id:, session_id: DEFAULT_SESSION_ID, checkpoint: nil, long_term: nil, long_term_type: nil, retrieval_engine: nil, working_memory: nil, api_key: nil)
|
|
14
14
|
@user_id = user_id
|
|
15
15
|
@session_id = session_id
|
|
16
16
|
@checkpoint = checkpoint || ShortTerm::Checkpoint.new(user_id: user_id, session_id: session_id)
|
|
17
|
+
@working_memory = working_memory
|
|
17
18
|
@llm = api_key.to_s.empty? ? nil : Llmemory::LLM.client(api_key: api_key)
|
|
18
19
|
type = long_term_type || Llmemory.configuration.long_term_type || :file_based
|
|
19
20
|
@long_term = long_term || build_long_term(type)
|
|
20
21
|
@retrieval_engine = retrieval_engine || Retrieval::Engine.new(@long_term, llm: @llm)
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
# Structured working memory for this session (CoALA working memory),
|
|
25
|
+
# parallel to the message checkpoint. Lazily built so it costs nothing
|
|
26
|
+
# unless an agent uses it.
|
|
27
|
+
def working_memory
|
|
28
|
+
@working_memory ||= WorkingMemory.new(user_id: @user_id, session_id: @session_id)
|
|
29
|
+
end
|
|
30
|
+
|
|
23
31
|
def add_message(role:, content:)
|
|
24
32
|
msgs = messages
|
|
25
33
|
msgs << { role: role.to_sym, content: content.to_s }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
# Uniform contract for queryable long-term memories (file-based, graph-based,
|
|
5
|
+
# episodic). CoALA argues agents should be modular with standardized
|
|
6
|
+
# abstractions; this mixin gives any memory store the same agent-facing
|
|
7
|
+
# surface so frameworks can treat them polymorphically:
|
|
8
|
+
#
|
|
9
|
+
# read(query, user_id:, limit:) -> relevant entries (retrieval)
|
|
10
|
+
# write(payload, ...) -> ingest into the store (learning)
|
|
11
|
+
# list(user_id:, limit:) -> enumerate stored entries
|
|
12
|
+
# stats(user_id:) -> counts and metadata
|
|
13
|
+
#
|
|
14
|
+
# `read` defaults to the de-facto `search_candidates` interface the retrieval
|
|
15
|
+
# Engine already relies on. `write`, `list` and `stats` are implemented by each
|
|
16
|
+
# including class over its native API.
|
|
17
|
+
#
|
|
18
|
+
# Deliberately excluded: session-state stores (Checkpoint, WorkingMemory) are a
|
|
19
|
+
# different abstraction (K/V session state, not a retrievable corpus), and
|
|
20
|
+
# deletion/forgetting semantics are deferred to a coherent unlearning API.
|
|
21
|
+
module MemoryModule
|
|
22
|
+
def read(query, user_id: nil, limit: 20)
|
|
23
|
+
unless respond_to?(:search_candidates)
|
|
24
|
+
raise NotImplementedError, "#{self.class} must implement #read or #search_candidates"
|
|
25
|
+
end
|
|
26
|
+
search_candidates(query, user_id: user_id, top_k: limit)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write(*, **)
|
|
30
|
+
raise NotImplementedError, "#{self.class} must implement #write"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def list(user_id: nil, limit: nil)
|
|
34
|
+
raise NotImplementedError, "#{self.class} must implement #list"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stats(user_id: nil)
|
|
38
|
+
raise NotImplementedError, "#{self.class} must implement #stats"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Removes entries by id (the same ids returned by #read) and records the
|
|
42
|
+
# removal in a ForgetLog audit. Returns the number of entries removed.
|
|
43
|
+
# Implemented by stores with a clear deletion model; others may not support
|
|
44
|
+
# it (CoALA-style "unlearning" is understudied; deletion semantics differ).
|
|
45
|
+
def forget(ids:, reason: nil)
|
|
46
|
+
raise NotImplementedError, "#{self.class} does not support #forget"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Shared audit trail for #forget. Lazily built; override or inject by setting
|
|
50
|
+
# @forget_log if a specific backend/store is required.
|
|
51
|
+
def forget_log
|
|
52
|
+
@forget_log ||= Llmemory::ForgetLog.new
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module Reflection
|
|
7
|
+
# Reflection distills an agent's recent episodes (episodic memory) into
|
|
8
|
+
# durable, higher-order insights and writes them to semantic memory. This is
|
|
9
|
+
# CoALA's "updating semantic memory with knowledge" (Reflexion / Generative
|
|
10
|
+
# Agents): unlike one-shot extraction from raw text, it reasons over lived
|
|
11
|
+
# experience to generalize lessons and patterns.
|
|
12
|
+
#
|
|
13
|
+
# Each insight is stored with provenance { method: "reflection",
|
|
14
|
+
# sources: [{ type: "episode", id: ... }] } so it stays traceable to the
|
|
15
|
+
# experiences that produced it.
|
|
16
|
+
#
|
|
17
|
+
# `semantic` must respond to:
|
|
18
|
+
# remember_fact(content:, category:, importance:, provenance:)
|
|
19
|
+
# (FileBased::Memory implements this; graph-based is a future target.)
|
|
20
|
+
class Reflector
|
|
21
|
+
DEFAULT_CATEGORY = "insights"
|
|
22
|
+
DEFAULT_IMPORTANCE = 0.6
|
|
23
|
+
|
|
24
|
+
def initialize(episodic:, semantic:, llm: nil)
|
|
25
|
+
@episodic = episodic
|
|
26
|
+
@semantic = semantic
|
|
27
|
+
@llm = llm || Llmemory::LLM.client
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Reflects over the most recent `window` episodes and writes the resulting
|
|
31
|
+
# insights to semantic memory. Returns the ids of the stored insights.
|
|
32
|
+
def reflect(window: 10, category: DEFAULT_CATEGORY)
|
|
33
|
+
episodes = @episodic.recent_episodes(limit: window)
|
|
34
|
+
return [] if episodes.empty?
|
|
35
|
+
|
|
36
|
+
insights = distill(episodes)
|
|
37
|
+
return [] if insights.empty?
|
|
38
|
+
|
|
39
|
+
sources = episodes.map(&:id).compact.map { |id| { type: "episode", id: id } }
|
|
40
|
+
|
|
41
|
+
insights.filter_map do |insight|
|
|
42
|
+
provenance = Llmemory::Provenance.build(
|
|
43
|
+
method: "reflection",
|
|
44
|
+
sources: sources,
|
|
45
|
+
confidence: insight[:confidence]
|
|
46
|
+
)
|
|
47
|
+
@semantic.remember_fact(
|
|
48
|
+
content: insight[:content],
|
|
49
|
+
category: category,
|
|
50
|
+
importance: insight[:confidence] || DEFAULT_IMPORTANCE,
|
|
51
|
+
provenance: provenance
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def distill(episodes)
|
|
59
|
+
response = @llm.invoke(build_prompt(episodes))
|
|
60
|
+
parse_insights(response)
|
|
61
|
+
rescue Llmemory::LLMError
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_prompt(episodes)
|
|
66
|
+
episodes_text = episodes.each_with_index.map do |ep, i|
|
|
67
|
+
"Episode #{i + 1} (outcome: #{ep.outcome || 'n/a'}):\n#{ep.searchable_text}"
|
|
68
|
+
end.join("\n\n")
|
|
69
|
+
|
|
70
|
+
<<~PROMPT
|
|
71
|
+
You are reflecting on an agent's recent experiences to distill durable,
|
|
72
|
+
higher-order insights: lessons learned, recurring patterns, and stable
|
|
73
|
+
preferences that will help in future situations. Generalize; do not
|
|
74
|
+
restate raw events.
|
|
75
|
+
|
|
76
|
+
Recent episodes:
|
|
77
|
+
#{episodes_text}
|
|
78
|
+
|
|
79
|
+
Return a JSON array of objects with "content" (the insight) and
|
|
80
|
+
"confidence" (0-1) keys. Return an empty array if nothing durable can
|
|
81
|
+
be concluded.
|
|
82
|
+
Example: [{"content": "Rolling back on deploy failure reliably restores service", "confidence": 0.8}]
|
|
83
|
+
PROMPT
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_insights(response)
|
|
87
|
+
json = extract_json_array(response)
|
|
88
|
+
return [] unless json
|
|
89
|
+
|
|
90
|
+
json.filter_map do |item|
|
|
91
|
+
next nil unless item.is_a?(Hash)
|
|
92
|
+
content = item["content"] || item[:content]
|
|
93
|
+
next nil if content.to_s.strip.empty?
|
|
94
|
+
{ content: content.to_s, confidence: normalize_confidence(item["confidence"] || item[:confidence]) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def normalize_confidence(value)
|
|
99
|
+
return nil if value.nil?
|
|
100
|
+
v = value.to_f
|
|
101
|
+
v.between?(0, 1) ? v : nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_json_array(response)
|
|
105
|
+
response = response.to_s.strip
|
|
106
|
+
start_idx = response.index("[")
|
|
107
|
+
end_idx = response.rindex("]")
|
|
108
|
+
return nil unless start_idx && end_idx
|
|
109
|
+
|
|
110
|
+
JSON.parse(response[start_idx..end_idx])
|
|
111
|
+
rescue JSON::ParserError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -4,34 +4,142 @@ require_relative "temporal_ranker"
|
|
|
4
4
|
require_relative "context_assembler"
|
|
5
5
|
require_relative "bm25_scorer"
|
|
6
6
|
require_relative "mmr_reranker"
|
|
7
|
+
require_relative "feedback_store"
|
|
7
8
|
|
|
8
9
|
module Llmemory
|
|
9
10
|
module Retrieval
|
|
10
11
|
class Engine
|
|
11
12
|
RELEVANCE_THRESHOLD = 0.7
|
|
13
|
+
FEEDBACK_CAP = 5
|
|
12
14
|
|
|
13
|
-
def initialize(memory, llm: nil)
|
|
15
|
+
def initialize(memory, llm: nil, feedback: nil)
|
|
14
16
|
@memory = memory
|
|
15
17
|
@llm = llm || Llmemory::LLM.client
|
|
16
18
|
@ranker = TemporalRanker.new
|
|
17
19
|
@assembler = ContextAssembler.new
|
|
18
20
|
@bm25_scorer = Bm25Scorer.new
|
|
19
21
|
@mmr_reranker = MmrReranker.new(lambda: Llmemory.configuration.mmr_lambda)
|
|
22
|
+
@feedback = feedback || FeedbackStore.new
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
def retrieve_for_inference(user_message, user_id: nil, max_tokens: nil)
|
|
23
26
|
user_id ||= @memory.respond_to?(:user_id) ? @memory.user_id : nil
|
|
24
27
|
search_query = generate_query(user_message)
|
|
28
|
+
ranked = ranked_candidates(search_query, user_id, user_message)
|
|
29
|
+
@assembler.assemble(ranked, max_tokens: max_tokens)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Multi-hop retrieval (CoALA: integrating retrieval and reasoning). After
|
|
33
|
+
# each hop, a reasoner inspects what has been retrieved and proposes a
|
|
34
|
+
# follow-up query for the missing piece, enabling multi-hop questions a
|
|
35
|
+
# single retrieval would miss. Candidates accumulate (deduped) across hops.
|
|
36
|
+
#
|
|
37
|
+
# `reasoner` is a callable (user_message, accumulated_candidates, hop) ->
|
|
38
|
+
# next query String, or "DONE"/blank to stop. Defaults to an LLM that
|
|
39
|
+
# proposes the next sub-query. Converges on `max_hops`, "DONE", a blank
|
|
40
|
+
# query, or a repeated query.
|
|
41
|
+
def iterative_retrieve(user_message, user_id: nil, max_tokens: nil, max_hops: 2, reasoner: nil)
|
|
42
|
+
user_id ||= @memory.respond_to?(:user_id) ? @memory.user_id : nil
|
|
43
|
+
reasoner ||= method(:default_followup_query)
|
|
44
|
+
|
|
45
|
+
query = generate_query(user_message)
|
|
46
|
+
seen = []
|
|
47
|
+
accumulated = []
|
|
48
|
+
hop = 0
|
|
49
|
+
|
|
50
|
+
while hop < max_hops && live_query?(query) && !seen.include?(query)
|
|
51
|
+
seen << query
|
|
52
|
+
accumulated = merge_candidates(accumulated, ranked_candidates(query, user_id, query))
|
|
53
|
+
hop += 1
|
|
54
|
+
break if hop >= max_hops
|
|
55
|
+
|
|
56
|
+
query = reasoner.call(user_message, accumulated, hop).to_s.strip
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
final = accumulated.sort_by { |c| -(c[:temporal_score] || c[:score] || 0) }
|
|
60
|
+
@assembler.assemble(final, max_tokens: max_tokens)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Records that previously-retrieved items were useful or harmful for the
|
|
64
|
+
# agent's task. Repeatedly useful items rank higher in future retrievals;
|
|
65
|
+
# noisy ones are dampened. Item ids come from the candidates returned by
|
|
66
|
+
# the memory's #read / #search_candidates.
|
|
67
|
+
def report_feedback(useful_ids: [], harmful_ids: [], user_id: nil)
|
|
68
|
+
user_id ||= @memory.respond_to?(:user_id) ? @memory.user_id : nil
|
|
69
|
+
Array(useful_ids).each { |id| @feedback.record(user_id, id, 1) }
|
|
70
|
+
Array(harmful_ids).each { |id| @feedback.record(user_id, id, -1) }
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# One retrieval hop: fetch -> hybrid -> relevance filter -> temporal rank
|
|
77
|
+
# -> feedback adjust -> (optional) MMR. Returns ranked candidates.
|
|
78
|
+
def ranked_candidates(search_query, user_id, relevance_text)
|
|
25
79
|
candidates = fetch_candidates(search_query, user_id)
|
|
26
80
|
candidates = apply_hybrid_scoring(candidates, search_query) if Llmemory.configuration.hybrid_search_enabled
|
|
27
|
-
|
|
28
|
-
relevant = filter_by_relevance(candidates, user_message)
|
|
81
|
+
relevant = filter_by_relevance(candidates, relevance_text)
|
|
29
82
|
ranked = @ranker.rank(relevant)
|
|
30
|
-
ranked =
|
|
31
|
-
@
|
|
83
|
+
ranked = apply_feedback(ranked, user_id)
|
|
84
|
+
Llmemory.configuration.mmr_enabled ? @mmr_reranker.rerank(ranked) : ranked
|
|
32
85
|
end
|
|
33
86
|
|
|
34
|
-
|
|
87
|
+
def live_query?(query)
|
|
88
|
+
!query.nil? && !query.to_s.strip.empty? && query.to_s.strip.upcase != "DONE"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def merge_candidates(accumulated, additions)
|
|
92
|
+
by_key = {}
|
|
93
|
+
(accumulated + additions).each do |c|
|
|
94
|
+
key = c[:id] || c[:text]
|
|
95
|
+
current = by_key[key]
|
|
96
|
+
if current.nil? || score_of(c) > score_of(current)
|
|
97
|
+
by_key[key] = c
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
by_key.values
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def score_of(candidate)
|
|
104
|
+
(candidate[:temporal_score] || candidate[:score] || 0).to_f
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def default_followup_query(user_message, accumulated, _hop)
|
|
108
|
+
context = accumulated.first(10).map { |c| c[:text] }.compact.join("\n")
|
|
109
|
+
prompt = <<~PROMPT
|
|
110
|
+
Question: #{user_message}
|
|
111
|
+
Information retrieved so far:
|
|
112
|
+
#{context}
|
|
113
|
+
|
|
114
|
+
If more information is needed to fully answer the question, reply with a
|
|
115
|
+
single short search query for the missing piece. If what was retrieved is
|
|
116
|
+
sufficient, reply with exactly "DONE".
|
|
117
|
+
PROMPT
|
|
118
|
+
@llm.invoke(prompt.strip).to_s.strip
|
|
119
|
+
rescue Llmemory::LLMError
|
|
120
|
+
"DONE"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def apply_feedback(ranked, user_id)
|
|
124
|
+
weight = Llmemory.configuration.retrieval_feedback_weight.to_f
|
|
125
|
+
return ranked if user_id.nil? || weight <= 0
|
|
126
|
+
|
|
127
|
+
adjusted = ranked.map do |c|
|
|
128
|
+
id = c[:id] || c["id"]
|
|
129
|
+
net = id.nil? ? 0 : @feedback.net(user_id, id)
|
|
130
|
+
next c if net.zero?
|
|
131
|
+
|
|
132
|
+
base = (c[:temporal_score] || c[:score] || 0).to_f
|
|
133
|
+
c.merge(temporal_score: base * feedback_factor(net, weight))
|
|
134
|
+
end
|
|
135
|
+
adjusted.sort_by { |c| -(c[:temporal_score] || 0) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Maps net feedback to a bounded multiplier in [1 - weight, 1 + weight].
|
|
139
|
+
def feedback_factor(net, weight)
|
|
140
|
+
capped = [[net, -FEEDBACK_CAP].max, FEEDBACK_CAP].min
|
|
141
|
+
1.0 + (weight * (capped.to_f / FEEDBACK_CAP))
|
|
142
|
+
end
|
|
35
143
|
|
|
36
144
|
def generate_query(user_message)
|
|
37
145
|
return user_message.to_s if user_message.to_s.length <= 100
|
|
@@ -51,6 +159,7 @@ module Llmemory
|
|
|
51
159
|
raw = @memory.search_candidates(search_query, user_id: user_id, top_k: 20)
|
|
52
160
|
raw.map do |c|
|
|
53
161
|
{
|
|
162
|
+
id: c[:id] || c["id"],
|
|
54
163
|
text: c[:text] || c["text"],
|
|
55
164
|
timestamp: parse_timestamp(c[:timestamp] || c["timestamp"] || c[:created_at] || c["created_at"]),
|
|
56
165
|
score: (c[:score] || c["score"] || 1.0).to_f,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../short_term/stores"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module Retrieval
|
|
7
|
+
# Persists retrieval feedback: a net utility signal per (user, memory item),
|
|
8
|
+
# accumulated from agents marking retrieved items useful (+1) or harmful (-1).
|
|
9
|
+
#
|
|
10
|
+
# CoALA flags adaptive retrieval — "learning better retrieval procedures" — as
|
|
11
|
+
# understudied. This is the minimal substrate for it: a feedback ledger the
|
|
12
|
+
# Engine consults to boost repeatedly-useful items and dampen noise.
|
|
13
|
+
#
|
|
14
|
+
# Backed by the same pluggable short-term stores as Checkpoint/WorkingMemory,
|
|
15
|
+
# under a per-user pseudo-session key.
|
|
16
|
+
class FeedbackStore
|
|
17
|
+
SESSION_KEY = "__retrieval_feedback__"
|
|
18
|
+
|
|
19
|
+
def initialize(store: nil)
|
|
20
|
+
@store = store || ShortTerm::Stores.build
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def record(user_id, item_id, delta)
|
|
24
|
+
return if user_id.nil? || item_id.nil?
|
|
25
|
+
state = load(user_id)
|
|
26
|
+
key = item_id.to_s
|
|
27
|
+
state[key] = (state[key] || 0) + delta.to_i
|
|
28
|
+
@store.save(user_id, SESSION_KEY, state)
|
|
29
|
+
state[key]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def net(user_id, item_id)
|
|
33
|
+
return 0 if user_id.nil? || item_id.nil?
|
|
34
|
+
load(user_id)[item_id.to_s] || 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def all(user_id)
|
|
38
|
+
load(user_id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def load(user_id)
|
|
44
|
+
state = @store.load(user_id, SESSION_KEY)
|
|
45
|
+
return {} unless state.is_a?(Hash)
|
|
46
|
+
state.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_i }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "stores
|
|
4
|
-
require_relative "stores/memory_store"
|
|
5
|
-
require_relative "stores/redis_store"
|
|
6
|
-
require_relative "stores/postgres_store"
|
|
3
|
+
require_relative "stores"
|
|
7
4
|
|
|
8
5
|
module Llmemory
|
|
9
6
|
module ShortTerm
|
|
@@ -31,16 +28,7 @@ module Llmemory
|
|
|
31
28
|
private
|
|
32
29
|
|
|
33
30
|
def build_store
|
|
34
|
-
|
|
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
|
|
31
|
+
Stores.build
|
|
44
32
|
end
|
|
45
33
|
end
|
|
46
34
|
end
|