llmemory 0.1.8 → 0.1.9

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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +34 -10
  3. data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/application_controller.rb +1 -1
  4. data/lib/llmemory/extractors/entity_relation_extractor.rb +76 -10
  5. data/lib/llmemory/llm/base.rb +6 -0
  6. data/lib/llmemory/llm/openai.rb +33 -0
  7. data/lib/llmemory/long_term/graph_based/memory.rb +46 -11
  8. data/lib/llmemory/long_term/graph_based/storage.rb +1 -1
  9. data/lib/llmemory/long_term/graph_based/storages/active_record_models.rb +2 -2
  10. data/lib/llmemory/long_term/graph_based/storages/active_record_storage.rb +11 -10
  11. data/lib/llmemory/memory.rb +9 -6
  12. data/lib/llmemory/vector_store/active_record_embedding.rb +9 -0
  13. data/lib/llmemory/vector_store/active_record_store.rb +73 -0
  14. data/lib/llmemory/vector_store/openai_embeddings.rb +1 -1
  15. data/lib/llmemory/version.rb +1 -1
  16. metadata +20 -18
  17. /data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/graph_controller.rb +0 -0
  18. /data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/long_term_controller.rb +0 -0
  19. /data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/search_controller.rb +0 -0
  20. /data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/short_term_controller.rb +0 -0
  21. /data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/stats_controller.rb +0 -0
  22. /data/{lib/llmemory/dashboard/app → app}/controllers/llmemory/dashboard/users_controller.rb +0 -0
  23. /data/{lib/llmemory/dashboard/app → app}/views/layouts/application.html.erb +0 -0
  24. /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/graph/index.html.erb +0 -0
  25. /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/long_term/categories.html.erb +0 -0
  26. /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/long_term/index.html.erb +0 -0
  27. /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/search/index.html.erb +0 -0
  28. /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/short_term/show.html.erb +0 -0
  29. /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/stats/index.html.erb +0 -0
  30. /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/users/index.html.erb +0 -0
  31. /data/{lib/llmemory/dashboard/app → app}/views/llmemory/dashboard/users/show.html.erb +0 -0
  32. /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: 63dd1fafe3af80e9a86be5da51e3ef4dbb6254cc0ebad341bed240875f539ebf
4
- data.tar.gz: 6da8ed10591ed7c88a1be6b33ba30eff780d388cf146750f070518a1748f9f92
3
+ metadata.gz: 1f9b00624e48d777ff556291241414f616a3b9728b86702a620644b3246bb5be
4
+ data.tar.gz: c8ce9be33170c8ea2ea4166b5ba87b01f0d3619849e812cd678dca39e282cd6c
5
5
  SHA512:
6
- metadata.gz: 527c5e19c2f53299e966c7c8d8e2b004375a99ab58f726d50b23bac88d015a3c8eb48ddbb00da1f536522122c782c754110dedb77f698a721a058227cb5e8bee
7
- data.tar.gz: e7cd4cfbb1dcb805687de7217b84299f130ac941edd97a39de4d7deba784a3e45e4944e2ae6fd07ff0da433bdc6f2c7227b7fc84130be1848adc7ccbc888fda9
6
+ metadata.gz: cf776ea804887c5a9ece6b317b6c21a699b3583a2994da694a59e23df419f4e28d476aeaf699f39e8d2dd418906aa43e561723940e7b6b2e5379bd74d1135b9e
7
+ data.tar.gz: ee6aabf2a9ca91914824207bab40af2144c76e92c6b2e4b733ee234b69827fa740fabad06fb11097f3c1faceef43bd9fc706c4656fcf09b23b0599126cef5147
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
- 1. In an initializer or early in boot (e.g. `config/initializers/llmemory.rb`):
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
- require "llmemory/dashboard"
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
- 2. In `config/routes.rb`:
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
- mount Llmemory::Dashboard::Engine, at: "/llmemory"
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
- 3. Visit `/llmemory`. You get:
230
- - List of users with memory
231
- - Short-term: conversation messages per session
232
- - Long-term (file-based): resources, items by category, category summaries
233
- - Long-term (graph-based): nodes and edges
234
- - Search and stats
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
 
@@ -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
- Extract entities and relations from this conversation as a knowledge graph.
15
- - Entities: people, companies, places, preferences, concepts (type and name).
16
- - Relations: subject-predicate-object triplets (e.g. User works_at OpenAI).
17
- Use "User" as subject when the user talks about themselves.
18
- Predicates: works_at, lives_in, prefers, is_allergic_to, likes, knows, current_job, current_city, etc.
19
- Return ONLY valid JSON with this shape:
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
- #{conversation_text}
66
+ #{text}
23
67
  PROMPT
24
- response = @llm.invoke(prompt.strip)
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) }
@@ -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
@@ -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
- data[:entities].each do |e|
28
- id = @kg.add_node(entity_type: e[:type], name: e[:name], properties: {})
29
- name_to_id[e[:name]] ||= id
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
- data[:relations].each do |r|
33
- subject_id = name_to_id[r[:subject]] || @kg.add_node(entity_type: "concept", name: r[:subject], properties: {})
34
- object_id = name_to_id[r[:object]] || @kg.add_node(entity_type: "concept", name: r[:object], properties: {})
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: r[: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: r[:predicate], object: object_id, properties: {})
63
+ edge_id = @kg.add_edge(subject: subject_id, predicate: predicate, object: object_id, properties: {})
48
64
 
49
- text = "#{r[:subject]} #{r[:predicate]} #{r[:object]}"
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::VectorStore::MemoryStore.new(embedding_provider: emb)
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 || :memory).to_s.to_sym
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 = "llmemory_graph_nodes"
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 = "llmemory_graph_edges"
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
- id = n.id || "node_#{SecureRandom.hex(8)}"
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
- id = e.id || "edge_#{SecureRandom.hex(8)}"
68
- rec = LlmemoryGraphEdge.find_by(user_id: user_id, id: id)
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
@@ -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| "#{m[:role]}: #{m[:content]}" }.join("\n")
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 do |m|
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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module VectorStore
5
+ class ActiveRecordEmbedding < ::ActiveRecord::Base
6
+ self.table_name = "llmemory_embeddings"
7
+ end
8
+ end
9
+ end
@@ -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("/embeddings") do |req|
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Llmemory
4
- VERSION = "0.1.8"
4
+ VERSION = "0.1.9"
5
5
  end
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.8
4
+ version: 0.1.9
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