llmemory 0.1.8 → 0.1.10
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 +34 -10
- data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/application_controller.rb +1 -1
- data/lib/llmemory/extractors/entity_relation_extractor.rb +76 -10
- data/lib/llmemory/llm/base.rb +6 -0
- data/lib/llmemory/llm/openai.rb +33 -0
- data/lib/llmemory/long_term/graph_based/memory.rb +46 -11
- data/lib/llmemory/long_term/graph_based/storage.rb +1 -1
- data/lib/llmemory/long_term/graph_based/storages/active_record_models.rb +2 -2
- data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +11 -10
- data/lib/llmemory/memory.rb +9 -6
- data/lib/llmemory/vector_store/active_record_embedding.rb +9 -0
- data/lib/llmemory/vector_store/active_record_store.rb +73 -0
- data/lib/llmemory/vector_store/openai_embeddings.rb +1 -1
- data/lib/llmemory/version.rb +1 -1
- metadata +20 -18
- /data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/graph_controller.rb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/long_term_controller.rb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/search_controller.rb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/short_term_controller.rb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/stats_controller.rb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/users_controller.rb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/views/layouts/application.html.erb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/graph/index.html.erb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/long_term/categories.html.erb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/long_term/index.html.erb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/search/index.html.erb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/short_term/show.html.erb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/stats/index.html.erb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/users/index.html.erb +0 -0
- /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/users/show.html.erb +0 -0
- /data/{lib/llmemory/dashboard/config → config}/routes.rb +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 81446e0a05a3028dbcd9bb4745295714304eab3c5b7b898ebd0bb72aa0e48c4e
|
|
4
|
+
data.tar.gz: 43a8426f108c3c927e72540738fb1b2f946395ad12e01eaba3183e28a38382e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0396292691b3f8705988d9b86fe33f4f84caee8fcafc498f18631042dff94895175060fd876f6400668effc2e87ae061af60c6bee28c10b69526fab36bfdbdcd'
|
|
7
|
+
data.tar.gz: 6b7706873ad15231dc70e22c96b89090b9beb36c5fb9b0630e6f5112f125a83e5014964448e37ec59e3e5b248593b55279b2c99f1228218c86f59fd9de8cbec0
|
data/README.md
CHANGED
|
@@ -214,24 +214,48 @@ Use `--store TYPE` where applicable to override the configured store (e.g. `memo
|
|
|
214
214
|
|
|
215
215
|
If you use Rails and want a web UI to browse memory, load the dashboard and mount the engine. **Rails is not a dependency of the gem**; the dashboard is only loaded when you require it.
|
|
216
216
|
|
|
217
|
-
|
|
217
|
+
The dashboard must be **required early in boot** (in `config/application.rb`), not in an initializer, so that Rails registers the engine’s routes correctly (same as other engines like mailbin).
|
|
218
|
+
|
|
219
|
+
**1. Require the dashboard in `config/application.rb`** (e.g. right after `Bundler.require`):
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
# config/application.rb
|
|
223
|
+
Bundler.require(*Rails.groups)
|
|
224
|
+
|
|
225
|
+
require "llmemory/dashboard" if Rails.env.development? # optional: only in development
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**2. Configure llmemory** in `config/initializers/llmemory.rb` (store, LLM, etc.):
|
|
218
229
|
|
|
219
230
|
```ruby
|
|
220
|
-
|
|
231
|
+
# config/initializers/llmemory.rb
|
|
232
|
+
Llmemory.configure do |config|
|
|
233
|
+
config.llm_provider = :openai
|
|
234
|
+
config.llm_api_key = ENV["OPENAI_API_KEY"]
|
|
235
|
+
config.short_term_store = :active_record
|
|
236
|
+
config.long_term_type = :graph_based
|
|
237
|
+
config.long_term_store = :active_record
|
|
238
|
+
# ...
|
|
239
|
+
end
|
|
221
240
|
```
|
|
222
241
|
|
|
223
|
-
|
|
242
|
+
**3. Mount the engine** in `config/routes.rb` (you can wrap it in a development check or behind auth):
|
|
224
243
|
|
|
225
244
|
```ruby
|
|
226
|
-
|
|
245
|
+
# config/routes.rb
|
|
246
|
+
Rails.application.routes.draw do
|
|
247
|
+
# ...
|
|
248
|
+
mount Llmemory::Dashboard::Engine, at: "/llmemory" if Rails.env.development?
|
|
249
|
+
end
|
|
227
250
|
```
|
|
228
251
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
252
|
+
**4. Visit** `/llmemory`. You get:
|
|
253
|
+
|
|
254
|
+
- List of users with memory
|
|
255
|
+
- Short-term: conversation messages per session
|
|
256
|
+
- Long-term (file-based): resources, items by category, category summaries
|
|
257
|
+
- Long-term (graph-based): nodes and edges
|
|
258
|
+
- Search and stats
|
|
235
259
|
|
|
236
260
|
The dashboard uses your existing `Llmemory.configuration` (short-term store, long-term store/type, etc.) and does not add any gem dependency; it only runs when Rails is present and you require `llmemory/dashboard`.
|
|
237
261
|
|
data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/application_controller.rb
RENAMED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Llmemory
|
|
4
4
|
module Dashboard
|
|
5
5
|
class ApplicationController < ActionController::Base
|
|
6
|
-
helper_method :short_term_store, :file_based_storage, :graph_based_storage
|
|
6
|
+
helper_method :short_term_store, :file_based_storage, :graph_based_storage, :file_based?, :graph_based?
|
|
7
7
|
|
|
8
8
|
protected
|
|
9
9
|
|
|
@@ -5,30 +5,96 @@ require "json"
|
|
|
5
5
|
module Llmemory
|
|
6
6
|
module Extractors
|
|
7
7
|
class EntityRelationExtractor
|
|
8
|
+
# Long conversations often make the LLM return empty JSON; truncate for extraction.
|
|
9
|
+
MAX_CONVERSATION_CHARS = 2500
|
|
10
|
+
|
|
11
|
+
# JSON Schema for Structured Outputs (OpenAI response_format).
|
|
12
|
+
# Ensures valid entities/relations shape and avoids refusals or malformed JSON.
|
|
13
|
+
EXTRACTION_JSON_SCHEMA = {
|
|
14
|
+
name: "entity_relation_extraction",
|
|
15
|
+
schema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
entities: {
|
|
19
|
+
type: "array",
|
|
20
|
+
items: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
type: { type: "string", description: "Entity type: person, company, place, concept, etc." },
|
|
24
|
+
name: { type: "string", description: "Entity name" }
|
|
25
|
+
},
|
|
26
|
+
required: ["type", "name"],
|
|
27
|
+
additionalProperties: false
|
|
28
|
+
},
|
|
29
|
+
description: "List of entities mentioned in the conversation"
|
|
30
|
+
},
|
|
31
|
+
relations: {
|
|
32
|
+
type: "array",
|
|
33
|
+
items: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
subject: { type: "string", description: "Subject entity (use 'User' when the user talks about themselves or their family)" },
|
|
37
|
+
predicate: { type: "string", description: "Relation inferred from context, snake_case (e.g. has_son, works_at, likes, spouse)" },
|
|
38
|
+
object: { type: "string", description: "Object entity (person name, place, concept)" }
|
|
39
|
+
},
|
|
40
|
+
required: ["subject", "predicate", "object"],
|
|
41
|
+
additionalProperties: false
|
|
42
|
+
},
|
|
43
|
+
description: "Subject-predicate-object relations extracted from the conversation"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
required: ["entities", "relations"],
|
|
47
|
+
additionalProperties: false
|
|
48
|
+
}
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
8
51
|
def initialize(llm: nil)
|
|
9
52
|
@llm = llm || Llmemory::LLM.client
|
|
10
53
|
end
|
|
11
54
|
|
|
12
55
|
def extract(conversation_text)
|
|
56
|
+
text = conversation_text.to_s.strip
|
|
57
|
+
text = text[0, MAX_CONVERSATION_CHARS] + "\n[...]" if text.length > MAX_CONVERSATION_CHARS
|
|
13
58
|
prompt = <<~PROMPT
|
|
14
|
-
|
|
15
|
-
- Entities: people,
|
|
16
|
-
- Relations: subject-predicate-object
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
{"entities": [{"type": "person", "name": "User"}, {"type": "company", "name": "OpenAI"}], "relations": [{"subject": "User", "predicate": "works_at", "object": "OpenAI"}]}
|
|
59
|
+
Infer entities and relations from this user-assistant conversation. Build a knowledge graph from what the user says, even when they don't state facts in formal language.
|
|
60
|
+
- Entities: people, places, companies, concepts (type and name).
|
|
61
|
+
- Relations: infer subject-predicate-object from context. Use "User" as subject when the user talks about themselves or people close to them.
|
|
62
|
+
Examples of inference: "mi hijo se llama Luis" → User has_son Luis; "trabajo en Acme" → User works_at Acme; "no me gustan las macros" → User prefers (or dislikes) Excel macros. Infer family (has_son, has_daughter, spouse), work (works_at, current_job), preferences (likes, prefers), and any other relation that clearly follows from the conversation. Use snake_case predicates.
|
|
63
|
+
Return empty arrays only if the conversation contains no extractable facts.
|
|
64
|
+
|
|
21
65
|
Conversation:
|
|
22
|
-
#{
|
|
66
|
+
#{text}
|
|
23
67
|
PROMPT
|
|
24
|
-
|
|
68
|
+
|
|
69
|
+
result = extract_once(prompt.strip)
|
|
70
|
+
# Retry with plain invoke() if API returned empty (avoids empty json_schema response)
|
|
71
|
+
if (result[:entities].empty? && result[:relations].empty?) && text.length > 50
|
|
72
|
+
result = parse_response(@llm.invoke(prompt.strip))
|
|
73
|
+
end
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def extract_once(prompt)
|
|
78
|
+
if @llm.respond_to?(:invoke_with_json_schema)
|
|
79
|
+
begin
|
|
80
|
+
parsed = @llm.invoke_with_json_schema(prompt, EXTRACTION_JSON_SCHEMA)
|
|
81
|
+
if parsed.is_a?(Hash) && !parsed.empty?
|
|
82
|
+
result = parse_response(parsed)
|
|
83
|
+
return result if result[:entities].any? || result[:relations].any?
|
|
84
|
+
end
|
|
85
|
+
rescue Llmemory::LLMError
|
|
86
|
+
# Model may not support response_format json_schema; fall back to invoke + parse
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
response = @llm.invoke(prompt)
|
|
25
91
|
parse_response(response)
|
|
26
92
|
end
|
|
27
93
|
|
|
28
94
|
private
|
|
29
95
|
|
|
30
96
|
def parse_response(response)
|
|
31
|
-
json = extract_json(response)
|
|
97
|
+
json = response.is_a?(Hash) ? response : extract_json(response)
|
|
32
98
|
return { entities: [], relations: [] } unless json.is_a?(Hash)
|
|
33
99
|
entities = Array(json["entities"] || json[:entities]).map { |e| normalize_entity(e) }
|
|
34
100
|
relations = Array(json["relations"] || json[:relations]).map { |r| normalize_relation(r) }
|
data/lib/llmemory/llm/base.rb
CHANGED
|
@@ -7,6 +7,12 @@ module Llmemory
|
|
|
7
7
|
raise NotImplementedError, "#{self.class}#invoke must be implemented"
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
# Optional: Structured Outputs (JSON schema). Override in providers that support it (e.g. OpenAI).
|
|
11
|
+
# When not overridden, returns nil and callers should fall back to invoke + parse.
|
|
12
|
+
def invoke_with_json_schema(_prompt, _json_schema)
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
10
16
|
protected
|
|
11
17
|
|
|
12
18
|
def config
|
data/lib/llmemory/llm/openai.rb
CHANGED
|
@@ -32,6 +32,39 @@ module Llmemory
|
|
|
32
32
|
body.dig("choices", 0, "message", "content")&.strip || ""
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Calls the model with response_format json_schema (Structured Outputs).
|
|
36
|
+
# Returns the parsed JSON hash. Use when the model supports structured outputs
|
|
37
|
+
# (e.g. gpt-4o, gpt-4o-mini 2024-08-06 and later).
|
|
38
|
+
def invoke_with_json_schema(prompt, json_schema)
|
|
39
|
+
payload = {
|
|
40
|
+
model: @model,
|
|
41
|
+
messages: [{ role: "user", content: prompt }],
|
|
42
|
+
temperature: 0,
|
|
43
|
+
response_format: {
|
|
44
|
+
type: "json_schema",
|
|
45
|
+
json_schema: {
|
|
46
|
+
strict: true,
|
|
47
|
+
name: json_schema[:name] || "extraction",
|
|
48
|
+
schema: json_schema[:schema] || json_schema["schema"]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
response = connection.post("chat/completions") do |req|
|
|
53
|
+
req.body = payload.to_json
|
|
54
|
+
req.headers["Content-Type"] = "application/json"
|
|
55
|
+
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
raise Llmemory::LLMError, "OpenAI API error: #{response.body}" unless response.success?
|
|
59
|
+
|
|
60
|
+
body = response.body.is_a?(Hash) ? response.body : JSON.parse(response.body.to_s)
|
|
61
|
+
content = body.dig("choices", 0, "message", "content")&.strip
|
|
62
|
+
return {} if content.nil? || content.empty?
|
|
63
|
+
JSON.parse(content)
|
|
64
|
+
rescue JSON::ParserError => e
|
|
65
|
+
raise Llmemory::LLMError, "Failed to parse JSON response: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
35
68
|
private
|
|
36
69
|
|
|
37
70
|
def connection
|
|
@@ -21,32 +21,48 @@ module Llmemory
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def memorize(conversation_text)
|
|
24
|
-
data = @extractor.extract(conversation_text)
|
|
24
|
+
data = @extractor.extract(conversation_text) rescue { entities: [], relations: [] }
|
|
25
|
+
data = { entities: [], relations: [] } unless data.is_a?(Hash)
|
|
26
|
+
entities = Array(data[:entities] || data["entities"])
|
|
27
|
+
relations = Array(data[:relations] || data["relations"])
|
|
28
|
+
|
|
29
|
+
return true if entities.empty? && relations.empty?
|
|
30
|
+
|
|
25
31
|
name_to_id = {}
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
entities.each do |e|
|
|
34
|
+
next unless e.is_a?(Hash)
|
|
35
|
+
entity_type = e[:type] || e["type"] || "concept"
|
|
36
|
+
name = e[:name] || e["name"]
|
|
37
|
+
next if name.nil? || name.to_s.strip.empty?
|
|
38
|
+
id = @kg.add_node(entity_type: entity_type, name: name.to_s.strip, properties: {})
|
|
39
|
+
name_to_id[name.to_s.strip] ||= id
|
|
30
40
|
end
|
|
31
41
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
relations.each do |r|
|
|
43
|
+
next unless r.is_a?(Hash)
|
|
44
|
+
subject = (r[:subject] || r["subject"]).to_s.strip
|
|
45
|
+
predicate = (r[:predicate] || r["predicate"]).to_s.strip
|
|
46
|
+
object = (r[:object] || r["object"]).to_s.strip
|
|
47
|
+
next if subject.empty? || predicate.empty? || object.empty?
|
|
48
|
+
|
|
49
|
+
subject_id = name_to_id[subject] || @kg.add_node(entity_type: "concept", name: subject, properties: {})
|
|
50
|
+
object_id = name_to_id[object] || @kg.add_node(entity_type: "concept", name: object, properties: {})
|
|
35
51
|
|
|
36
52
|
edge = Edge.new(
|
|
37
53
|
id: nil,
|
|
38
54
|
user_id: @user_id,
|
|
39
55
|
subject_id: subject_id,
|
|
40
|
-
predicate:
|
|
56
|
+
predicate: predicate,
|
|
41
57
|
target_id: object_id,
|
|
42
58
|
properties: {},
|
|
43
59
|
created_at: Time.now,
|
|
44
60
|
archived_at: nil
|
|
45
61
|
)
|
|
46
62
|
@conflict_resolver.resolve(edge)
|
|
47
|
-
edge_id = @kg.add_edge(subject: subject_id, predicate:
|
|
63
|
+
edge_id = @kg.add_edge(subject: subject_id, predicate: predicate, object: object_id, properties: {})
|
|
48
64
|
|
|
49
|
-
text = "#{
|
|
65
|
+
text = "#{subject} #{predicate} #{object}"
|
|
50
66
|
embedding = @vector_store.respond_to?(:embed) ? @vector_store.embed(text) : nil
|
|
51
67
|
if embedding && @vector_store.respond_to?(:store)
|
|
52
68
|
@vector_store.store(id: "edge_#{edge_id}", embedding: embedding, metadata: { text: text, created_at: Time.now }, user_id: @user_id)
|
|
@@ -84,7 +100,13 @@ module Llmemory
|
|
|
84
100
|
|
|
85
101
|
def build_vector_store
|
|
86
102
|
emb = Llmemory::VectorStore::OpenAIEmbeddings.new
|
|
87
|
-
Llmemory
|
|
103
|
+
store_type = (Llmemory.configuration.long_term_store || :memory).to_s.to_sym
|
|
104
|
+
if store_type == :active_record || store_type == :activerecord
|
|
105
|
+
require_relative "../../vector_store/active_record_store"
|
|
106
|
+
Llmemory::VectorStore::ActiveRecordStore.new(embedding_provider: emb)
|
|
107
|
+
else
|
|
108
|
+
Llmemory::VectorStore::MemoryStore.new(embedding_provider: emb)
|
|
109
|
+
end
|
|
88
110
|
end
|
|
89
111
|
|
|
90
112
|
def hybrid_search(query, top_k:)
|
|
@@ -115,6 +137,19 @@ module Llmemory
|
|
|
115
137
|
end
|
|
116
138
|
end
|
|
117
139
|
|
|
140
|
+
# When vector store is empty (e.g. in-memory not persisted), use graph edges as fallback
|
|
141
|
+
# so long-term context is still recovered from persisted nodes/edges.
|
|
142
|
+
if out.empty? && @graph_storage.respond_to?(:list_edges)
|
|
143
|
+
edges = @graph_storage.list_edges(@user_id, limit: top_k)
|
|
144
|
+
edges.each do |e|
|
|
145
|
+
subj = @kg.find_node_by_id(e.subject_id)
|
|
146
|
+
obj = @kg.find_node_by_id(e.target_id)
|
|
147
|
+
next unless subj && obj
|
|
148
|
+
edge_text = "#{subj.name} #{e.predicate} #{obj.name}"
|
|
149
|
+
out << { text: edge_text, score: 0.7, created_at: e.created_at }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
118
153
|
out.sort_by { |r| -(r[:score] || 0) }.first(top_k)
|
|
119
154
|
end
|
|
120
155
|
|
|
@@ -8,7 +8,7 @@ module Llmemory
|
|
|
8
8
|
module GraphBased
|
|
9
9
|
module Storages
|
|
10
10
|
def self.build(store: nil)
|
|
11
|
-
case (store ||
|
|
11
|
+
case (store || Llmemory.configuration.long_term_store).to_s.to_sym
|
|
12
12
|
when :memory
|
|
13
13
|
MemoryStorage.new
|
|
14
14
|
when :active_record, :activerecord
|
|
@@ -5,14 +5,14 @@ module Llmemory
|
|
|
5
5
|
module GraphBased
|
|
6
6
|
module Storages
|
|
7
7
|
class LlmemoryGraphNode < ::ActiveRecord::Base
|
|
8
|
-
self.table_name = "
|
|
8
|
+
self.table_name = "llmemory_nodes"
|
|
9
9
|
self.primary_key = "id"
|
|
10
10
|
has_many :out_edges, class_name: "Llmemory::LongTerm::GraphBased::Storages::LlmemoryGraphEdge", foreign_key: :subject_id
|
|
11
11
|
has_many :in_edges, class_name: "Llmemory::LongTerm::GraphBased::Storages::LlmemoryGraphEdge", foreign_key: :object_id
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
class LlmemoryGraphEdge < ::ActiveRecord::Base
|
|
15
|
-
self.table_name = "
|
|
15
|
+
self.table_name = "llmemory_edges"
|
|
16
16
|
self.primary_key = "id"
|
|
17
17
|
belongs_to :subject_node, class_name: "Llmemory::LongTerm::GraphBased::Storages::LlmemoryGraphNode", foreign_key: :subject_id, optional: true
|
|
18
18
|
belongs_to :object_node, class_name: "Llmemory::LongTerm::GraphBased::Storages::LlmemoryGraphNode", foreign_key: :object_id, optional: true
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "securerandom"
|
|
4
3
|
require_relative "base"
|
|
5
4
|
require_relative "active_record_models"
|
|
6
5
|
require_relative "../node"
|
|
@@ -33,15 +32,13 @@ module Llmemory
|
|
|
33
32
|
rec.update!(properties: n.properties || {}, updated_at: Time.current)
|
|
34
33
|
rec.id
|
|
35
34
|
else
|
|
36
|
-
|
|
37
|
-
LlmemoryGraphNode.create!(
|
|
38
|
-
id: id,
|
|
35
|
+
rec = LlmemoryGraphNode.create!(
|
|
39
36
|
user_id: user_id,
|
|
40
37
|
entity_type: n.entity_type.to_s,
|
|
41
38
|
name: n.name.to_s,
|
|
42
39
|
properties: n.properties || {}
|
|
43
40
|
)
|
|
44
|
-
id
|
|
41
|
+
rec.id
|
|
45
42
|
end
|
|
46
43
|
end
|
|
47
44
|
|
|
@@ -64,8 +61,11 @@ module Llmemory
|
|
|
64
61
|
|
|
65
62
|
def save_edge(user_id, edge)
|
|
66
63
|
e = edge.is_a?(Edge) ? edge : Edge.from_h(edge.to_h)
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
rec = if e.id && e.id.is_a?(Integer)
|
|
65
|
+
LlmemoryGraphEdge.find_by(user_id: user_id, id: e.id)
|
|
66
|
+
else
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
69
|
if rec
|
|
70
70
|
rec.update!(
|
|
71
71
|
subject_id: e.subject_id,
|
|
@@ -73,17 +73,17 @@ module Llmemory
|
|
|
73
73
|
object_id: e.target_id,
|
|
74
74
|
properties: e.properties || {}
|
|
75
75
|
)
|
|
76
|
+
rec.id
|
|
76
77
|
else
|
|
77
|
-
LlmemoryGraphEdge.create!(
|
|
78
|
-
id: id,
|
|
78
|
+
rec = LlmemoryGraphEdge.create!(
|
|
79
79
|
user_id: user_id,
|
|
80
80
|
subject_id: e.subject_id,
|
|
81
81
|
predicate: e.predicate,
|
|
82
82
|
object_id: e.target_id,
|
|
83
83
|
properties: e.properties || {}
|
|
84
84
|
)
|
|
85
|
+
rec.id
|
|
85
86
|
end
|
|
86
|
-
id
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def find_edges(user_id, subject_id: nil, predicate: nil, object_id: nil, include_archived: false)
|
|
@@ -110,6 +110,7 @@ module Llmemory
|
|
|
110
110
|
scope = LlmemoryGraphEdge.where(user_id: user_id, archived_at: nil)
|
|
111
111
|
scope = scope.where(subject_id: subject_id) if subject_id
|
|
112
112
|
scope = scope.where(predicate: predicate) if predicate
|
|
113
|
+
scope = scope.order(created_at: :desc) if limit && limit.to_i.positive?
|
|
113
114
|
scope = scope.limit(limit) if limit && limit.to_i.positive?
|
|
114
115
|
scope.map { |r| record_to_edge(r) }
|
|
115
116
|
end
|
data/lib/llmemory/memory.rb
CHANGED
|
@@ -42,7 +42,7 @@ module Llmemory
|
|
|
42
42
|
def consolidate!
|
|
43
43
|
msgs = messages
|
|
44
44
|
return true if msgs.empty?
|
|
45
|
-
conversation_text = msgs.map { |m|
|
|
45
|
+
conversation_text = msgs.map { |m| format_message(m) }.join("\n")
|
|
46
46
|
@long_term.memorize(conversation_text)
|
|
47
47
|
true
|
|
48
48
|
end
|
|
@@ -79,16 +79,19 @@ module Llmemory
|
|
|
79
79
|
def format_short_term_context(msgs)
|
|
80
80
|
return "" if msgs.empty?
|
|
81
81
|
lines = ["=== RECENT CONVERSATION ===", ""]
|
|
82
|
-
msgs.each
|
|
83
|
-
role = m[:role] || m["role"]
|
|
84
|
-
content = m[:content] || m["content"]
|
|
85
|
-
lines << "#{role}: #{content}"
|
|
86
|
-
end
|
|
82
|
+
msgs.each { |m| lines << format_message(m) }
|
|
87
83
|
lines << ""
|
|
88
84
|
lines << "=== END RECENT CONVERSATION ==="
|
|
89
85
|
lines.join("\n")
|
|
90
86
|
end
|
|
91
87
|
|
|
88
|
+
# Formats a message hash, handling both symbol and string keys.
|
|
89
|
+
def format_message(m)
|
|
90
|
+
role = m[:role] || m["role"]
|
|
91
|
+
content = m[:content] || m["content"]
|
|
92
|
+
"#{role}: #{content}"
|
|
93
|
+
end
|
|
94
|
+
|
|
92
95
|
def combine_contexts(short_context, long_context)
|
|
93
96
|
parts = []
|
|
94
97
|
parts << short_context if short_context.to_s.strip.length.positive?
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module VectorStore
|
|
7
|
+
# Persists embeddings in llmemory_embeddings (pgvector).
|
|
8
|
+
# Use when long_term_store is :active_record so hybrid search finds persisted embeddings.
|
|
9
|
+
class ActiveRecordStore < Base
|
|
10
|
+
def initialize(embedding_provider: nil)
|
|
11
|
+
self.class.load_model!
|
|
12
|
+
@embedding_provider = embedding_provider
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.load_model!
|
|
16
|
+
return if @model_loaded
|
|
17
|
+
require "active_record"
|
|
18
|
+
require_relative "active_record_embedding"
|
|
19
|
+
@model_loaded = true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def embed(text)
|
|
23
|
+
return Array.new(1536, 0.0) unless @embedding_provider&.respond_to?(:embed)
|
|
24
|
+
@embedding_provider.embed(text)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def store(id:, embedding:, metadata: {}, user_id: nil)
|
|
28
|
+
return id if user_id.nil? || user_id.to_s.empty?
|
|
29
|
+
text_content = (metadata || {}).dig("text") || (metadata || {}).dig(:text)
|
|
30
|
+
rec = Llmemory::VectorStore::ActiveRecordEmbedding.find_or_initialize_by(
|
|
31
|
+
user_id: user_id.to_s,
|
|
32
|
+
source_type: "edge",
|
|
33
|
+
source_id: id.to_s
|
|
34
|
+
)
|
|
35
|
+
rec.embedding = embedding.to_a.map(&:to_f)
|
|
36
|
+
rec.text_content = text_content
|
|
37
|
+
rec.save!
|
|
38
|
+
id
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def search(query_embedding, top_k: 10, user_id: nil)
|
|
42
|
+
return [] if user_id.nil? || user_id.to_s.empty?
|
|
43
|
+
vec = query_embedding.to_a.map(&:to_f)
|
|
44
|
+
return [] if vec.empty?
|
|
45
|
+
# Sanitize vector for pgvector (only floats allowed)
|
|
46
|
+
sanitized_vec = vec.map { |v| v.finite? ? v : 0.0 }
|
|
47
|
+
vector_literal = "[#{sanitized_vec.join(',')}]"
|
|
48
|
+
# pgvector cosine distance <=> (0 = same, 2 = opposite); score = 1 - distance for similarity
|
|
49
|
+
scope = Llmemory::VectorStore::ActiveRecordEmbedding.where(user_id: user_id.to_s)
|
|
50
|
+
rows = scope.select(
|
|
51
|
+
Llmemory::VectorStore::ActiveRecordEmbedding.arel_table[Arel.star],
|
|
52
|
+
Arel.sql("(embedding <=> '#{vector_literal}'::vector) AS distance")
|
|
53
|
+
).order(Arel.sql("embedding <=> '#{vector_literal}'::vector")).limit(top_k)
|
|
54
|
+
rows.map do |r|
|
|
55
|
+
distance = r["distance"] || r.attributes["distance"] || 0.0
|
|
56
|
+
score = (1.0 - distance.to_f).clamp(-1.0, 1.0)
|
|
57
|
+
{
|
|
58
|
+
id: r.source_id,
|
|
59
|
+
score: score,
|
|
60
|
+
metadata: { "text" => r.text_content, "created_at" => r.created_at, "user_id" => r.user_id }
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def search_by_text(query_text, top_k: 10, user_id: nil)
|
|
66
|
+
return [] if user_id.nil? || user_id.to_s.empty?
|
|
67
|
+
return [] unless @embedding_provider&.respond_to?(:embed)
|
|
68
|
+
query_embedding = @embedding_provider.embed(query_text)
|
|
69
|
+
search(query_embedding, top_k: top_k, user_id: user_id)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -17,7 +17,7 @@ module Llmemory
|
|
|
17
17
|
|
|
18
18
|
def embed(text)
|
|
19
19
|
return Array.new(DEFAULT_DIMS, 0.0) if text.to_s.strip.empty?
|
|
20
|
-
response = connection.post("
|
|
20
|
+
response = connection.post("embeddings") do |req|
|
|
21
21
|
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
22
22
|
req.headers["Content-Type"] = "application/json"
|
|
23
23
|
req.body = { input: text.to_s.strip, model: @model }.to_json
|
data/lib/llmemory/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llmemory
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- llmemory
|
|
@@ -76,6 +76,23 @@ extra_rdoc_files: []
|
|
|
76
76
|
files:
|
|
77
77
|
- LICENSE.txt
|
|
78
78
|
- README.md
|
|
79
|
+
- app/controllers/llmemory/dashboard/application_controller.rb
|
|
80
|
+
- app/controllers/llmemory/dashboard/graph_controller.rb
|
|
81
|
+
- app/controllers/llmemory/dashboard/long_term_controller.rb
|
|
82
|
+
- app/controllers/llmemory/dashboard/search_controller.rb
|
|
83
|
+
- app/controllers/llmemory/dashboard/short_term_controller.rb
|
|
84
|
+
- app/controllers/llmemory/dashboard/stats_controller.rb
|
|
85
|
+
- app/controllers/llmemory/dashboard/users_controller.rb
|
|
86
|
+
- app/views/layouts/application.html.erb
|
|
87
|
+
- app/views/llmemory/dashboard/graph/index.html.erb
|
|
88
|
+
- app/views/llmemory/dashboard/long_term/categories.html.erb
|
|
89
|
+
- app/views/llmemory/dashboard/long_term/index.html.erb
|
|
90
|
+
- app/views/llmemory/dashboard/search/index.html.erb
|
|
91
|
+
- app/views/llmemory/dashboard/short_term/show.html.erb
|
|
92
|
+
- app/views/llmemory/dashboard/stats/index.html.erb
|
|
93
|
+
- app/views/llmemory/dashboard/users/index.html.erb
|
|
94
|
+
- app/views/llmemory/dashboard/users/show.html.erb
|
|
95
|
+
- config/routes.rb
|
|
79
96
|
- exe/llmemory
|
|
80
97
|
- lib/generators/llmemory/install/install_generator.rb
|
|
81
98
|
- lib/generators/llmemory/install/templates/create_llmemory_tables.rb
|
|
@@ -95,23 +112,6 @@ files:
|
|
|
95
112
|
- lib/llmemory/cli/commands/users.rb
|
|
96
113
|
- lib/llmemory/configuration.rb
|
|
97
114
|
- lib/llmemory/dashboard.rb
|
|
98
|
-
- lib/llmemory/dashboard/app/controllers/llmemory/dashboard/application_controller.rb
|
|
99
|
-
- lib/llmemory/dashboard/app/controllers/llmemory/dashboard/graph_controller.rb
|
|
100
|
-
- lib/llmemory/dashboard/app/controllers/llmemory/dashboard/long_term_controller.rb
|
|
101
|
-
- lib/llmemory/dashboard/app/controllers/llmemory/dashboard/search_controller.rb
|
|
102
|
-
- lib/llmemory/dashboard/app/controllers/llmemory/dashboard/short_term_controller.rb
|
|
103
|
-
- lib/llmemory/dashboard/app/controllers/llmemory/dashboard/stats_controller.rb
|
|
104
|
-
- lib/llmemory/dashboard/app/controllers/llmemory/dashboard/users_controller.rb
|
|
105
|
-
- lib/llmemory/dashboard/app/views/layouts/application.html.erb
|
|
106
|
-
- lib/llmemory/dashboard/app/views/llmemory/dashboard/graph/index.html.erb
|
|
107
|
-
- lib/llmemory/dashboard/app/views/llmemory/dashboard/long_term/categories.html.erb
|
|
108
|
-
- lib/llmemory/dashboard/app/views/llmemory/dashboard/long_term/index.html.erb
|
|
109
|
-
- lib/llmemory/dashboard/app/views/llmemory/dashboard/search/index.html.erb
|
|
110
|
-
- lib/llmemory/dashboard/app/views/llmemory/dashboard/short_term/show.html.erb
|
|
111
|
-
- lib/llmemory/dashboard/app/views/llmemory/dashboard/stats/index.html.erb
|
|
112
|
-
- lib/llmemory/dashboard/app/views/llmemory/dashboard/users/index.html.erb
|
|
113
|
-
- lib/llmemory/dashboard/app/views/llmemory/dashboard/users/show.html.erb
|
|
114
|
-
- lib/llmemory/dashboard/config/routes.rb
|
|
115
115
|
- lib/llmemory/dashboard/engine.rb
|
|
116
116
|
- lib/llmemory/extractors.rb
|
|
117
117
|
- lib/llmemory/extractors/entity_relation_extractor.rb
|
|
@@ -164,6 +164,8 @@ files:
|
|
|
164
164
|
- lib/llmemory/short_term/stores/postgres_store.rb
|
|
165
165
|
- lib/llmemory/short_term/stores/redis_store.rb
|
|
166
166
|
- lib/llmemory/vector_store.rb
|
|
167
|
+
- lib/llmemory/vector_store/active_record_embedding.rb
|
|
168
|
+
- lib/llmemory/vector_store/active_record_store.rb
|
|
167
169
|
- lib/llmemory/vector_store/base.rb
|
|
168
170
|
- lib/llmemory/vector_store/memory_store.rb
|
|
169
171
|
- lib/llmemory/vector_store/openai_embeddings.rb
|
|
File without changes
|
/data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/long_term_controller.rb
RENAMED
|
File without changes
|
/data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/search_controller.rb
RENAMED
|
File without changes
|
/data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/short_term_controller.rb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/long_term/categories.html.erb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|