robot_lab-durable 0.2.1 → 0.2.6
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/.envrc +2 -0
- data/.loki +10 -0
- data/.quality/reek_baseline.txt +0 -0
- data/.rubocop.yml +1 -173
- data/CHANGELOG.md +42 -0
- data/CLAUDE.md +65 -0
- data/README.md +65 -30
- data/Rakefile +47 -31
- data/docs/document_store_backend_design.md +4 -37
- data/docs/index.md +230 -25
- data/examples/01_day_trader.rb +92 -0
- data/examples/{33_stock_generator.rb → day_trader_lib/generator.rb} +6 -4
- data/examples/{33_stock_predictor.rb → day_trader_lib/predictor.rb} +92 -73
- data/lib/robot_lab/durable/adapter.rb +42 -0
- data/lib/robot_lab/durable/entry.rb +8 -39
- data/lib/robot_lab/durable/hook.rb +48 -0
- data/lib/robot_lab/durable/version.rb +1 -1
- data/lib/robot_lab/durable.rb +6 -8
- data/lib/robot_lab/recall_knowledge.rb +6 -10
- data/lib/robot_lab/record_knowledge.rb +7 -17
- metadata +29 -12
- data/lib/robot_lab/durable/learning.rb +0 -39
- data/lib/robot_lab/durable/reflector.rb +0 -47
- data/lib/robot_lab/durable/store.rb +0 -120
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'htm'
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module Durable
|
|
7
|
+
class Adapter
|
|
8
|
+
attr_reader :htm
|
|
9
|
+
|
|
10
|
+
def initialize(robot_name:)
|
|
11
|
+
@htm = HTM.new(robot_name: robot_name)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Recall entries relevant to a query using HTM hybrid search.
|
|
15
|
+
#
|
|
16
|
+
# @param query [String] Natural-language search string
|
|
17
|
+
# @param limit [Integer] Maximum number of results
|
|
18
|
+
# @param strategy [Symbol] :vector, :fulltext, or :hybrid
|
|
19
|
+
# @return [Array<Entry>]
|
|
20
|
+
def recall(query:, limit: 20, strategy: :hybrid)
|
|
21
|
+
nodes = @htm.recall(query, limit: limit, strategy: strategy, raw: true)
|
|
22
|
+
nodes.map { |node| Entry.from_node(node) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Store content in HTM long-term memory.
|
|
26
|
+
#
|
|
27
|
+
# @param content [String]
|
|
28
|
+
# @param reasoning [String, nil] Why this is worth remembering
|
|
29
|
+
# @param category [String, Symbol] fact, preference, pattern, correction, or observation
|
|
30
|
+
# @return [Integer] HTM node_id
|
|
31
|
+
def record(content:, reasoning: nil, category: 'fact')
|
|
32
|
+
@htm.remember(
|
|
33
|
+
content,
|
|
34
|
+
metadata: {
|
|
35
|
+
'reasoning' => reasoning,
|
|
36
|
+
'category' => category.to_s
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -2,46 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module RobotLab
|
|
4
4
|
module Durable
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
Entry = Data.define(:content, :reasoning, :category, :domain, :confidence, :use_count, :created_at, :updated_at) do
|
|
9
|
-
# Return a new Entry with confidence incremented and use_count bumped.
|
|
10
|
-
def confirm
|
|
11
|
-
new_confidence = [confidence + CONFIDENCE_INCREMENT, MAX_CONFIDENCE].min
|
|
12
|
-
with(
|
|
13
|
-
confidence: new_confidence.round(10),
|
|
14
|
-
use_count: use_count + 1,
|
|
15
|
-
updated_at: Time.now.iso8601
|
|
16
|
-
)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# Serialize to a plain Hash with string keys (safe for YAML round-trip).
|
|
20
|
-
def to_h
|
|
21
|
-
{
|
|
22
|
-
'content' => content,
|
|
23
|
-
'reasoning' => reasoning,
|
|
24
|
-
'category' => category.to_s,
|
|
25
|
-
'domain' => domain,
|
|
26
|
-
'confidence' => confidence,
|
|
27
|
-
'use_count' => use_count,
|
|
28
|
-
'created_at' => created_at,
|
|
29
|
-
'updated_at' => updated_at
|
|
30
|
-
}
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Deserialize from a Hash (string or symbol keys).
|
|
34
|
-
def self.from_h(hash)
|
|
35
|
-
h = hash.transform_keys(&:to_s)
|
|
5
|
+
Entry = Data.define(:node_id, :content, :reasoning, :category, :created_at) do
|
|
6
|
+
def self.from_node(node)
|
|
7
|
+
meta = node['metadata'] || {}
|
|
36
8
|
new(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
use_count: h['use_count'].to_i,
|
|
43
|
-
created_at: h['created_at'],
|
|
44
|
-
updated_at: h['updated_at']
|
|
9
|
+
node_id: node['id'],
|
|
10
|
+
content: node['content'],
|
|
11
|
+
reasoning: meta['reasoning'],
|
|
12
|
+
category: (meta['category'] || 'fact').to_sym,
|
|
13
|
+
created_at: node['created_at']
|
|
45
14
|
)
|
|
46
15
|
end
|
|
47
16
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module Durable
|
|
5
|
+
class Hook < RobotLab::Hook
|
|
6
|
+
self.namespace = :durable
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
# Initialize an HTM-backed adapter for the robot and make it available
|
|
10
|
+
# thread-locally for the duration of the run, then tear it down.
|
|
11
|
+
def around_run(ctx, &block)
|
|
12
|
+
robot_name = ctx.robot.name || 'default'
|
|
13
|
+
set_adapter(Adapter.new(robot_name: robot_name))
|
|
14
|
+
block.call
|
|
15
|
+
ensure
|
|
16
|
+
clear_adapter
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Persist new session learnings to HTM long-term memory.
|
|
20
|
+
# HTM deduplicates by content hash, so double-writes from RecordKnowledge
|
|
21
|
+
# are harmless — the second call simply increments remember_count.
|
|
22
|
+
def on_learn(ctx)
|
|
23
|
+
return unless ctx.stored
|
|
24
|
+
|
|
25
|
+
adapter = current_adapter
|
|
26
|
+
return unless adapter
|
|
27
|
+
|
|
28
|
+
adapter.record(content: ctx.text, category: :observation)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns the active durable adapter for the current thread, or nil.
|
|
32
|
+
def current_adapter
|
|
33
|
+
Thread.current[:robot_lab_durable_adapter]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def set_adapter(adapter)
|
|
39
|
+
Thread.current[:robot_lab_durable_adapter] = adapter
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def clear_adapter
|
|
43
|
+
Thread.current[:robot_lab_durable_adapter] = nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/robot_lab/durable.rb
CHANGED
|
@@ -2,25 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'durable/version'
|
|
4
4
|
require_relative 'durable/entry'
|
|
5
|
-
require_relative 'durable/
|
|
6
|
-
require_relative 'durable/reflector'
|
|
7
|
-
require_relative 'durable/learning'
|
|
5
|
+
require_relative 'durable/adapter'
|
|
8
6
|
|
|
9
|
-
# Minimal error stub so the
|
|
7
|
+
# Minimal error stub so the adapter layer works without robot_lab loaded.
|
|
10
8
|
# When robot_lab is present its own RobotLab::Error takes precedence.
|
|
11
9
|
module RobotLab
|
|
12
10
|
Error = StandardError unless defined?(Error)
|
|
13
11
|
end
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
if defined?(RobotLab::Hook)
|
|
14
|
+
require_relative 'durable/hook'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
17
|
if defined?(RobotLab::Tool)
|
|
18
18
|
require_relative 'recall_knowledge'
|
|
19
19
|
require_relative 'record_knowledge'
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
RobotLab::Robot.include(RobotLab::Durable::Learning) if defined?(RobotLab::Robot)
|
|
23
|
-
|
|
24
22
|
if defined?(RobotLab) && RobotLab.respond_to?(:register_extension)
|
|
25
23
|
RobotLab.register_extension(:durable, RobotLab::Durable)
|
|
26
24
|
end
|
|
@@ -7,22 +7,18 @@ module RobotLab
|
|
|
7
7
|
'to check if you have seen a similar situation before. ' \
|
|
8
8
|
'When in doubt and no relevant knowledge is found, skip the action.'
|
|
9
9
|
|
|
10
|
-
param :query,
|
|
11
|
-
param :domain, type: 'string', desc: "Topic area to search (e.g. 'newsletter curation')", required: false
|
|
10
|
+
param :query, type: 'string', desc: 'Natural language description of the decision you are about to make'
|
|
12
11
|
|
|
13
|
-
def execute(query
|
|
14
|
-
|
|
15
|
-
return 'No durable
|
|
12
|
+
def execute(query:)
|
|
13
|
+
adapter = RobotLab::Durable::Hook.current_adapter
|
|
14
|
+
return 'No durable session active on this robot.' unless adapter
|
|
16
15
|
|
|
17
|
-
entries =
|
|
16
|
+
entries = adapter.recall(query: query)
|
|
18
17
|
|
|
19
18
|
if entries.empty?
|
|
20
19
|
"No relevant past knowledge found for: #{query}. When in doubt, skip."
|
|
21
20
|
else
|
|
22
|
-
lines = entries.map
|
|
23
|
-
"[#{e.category}/conf:#{format('%.1f', e.confidence)}] #{e.content} — #{e.reasoning}"
|
|
24
|
-
end
|
|
25
|
-
|
|
21
|
+
lines = entries.map { |e| "[#{e.category}] #{e.content} — #{e.reasoning}" }
|
|
26
22
|
"Relevant past knowledge:\n#{lines.join("\n")}"
|
|
27
23
|
end
|
|
28
24
|
end
|
|
@@ -11,26 +11,16 @@ module RobotLab
|
|
|
11
11
|
param :reasoning, type: 'string',
|
|
12
12
|
desc: 'Why this is worth remembering — the observation or discussion that led to it'
|
|
13
13
|
param :category, type: 'string', desc: 'One of: fact, preference, pattern, correction'
|
|
14
|
-
param :domain, type: 'string', desc: "Topic area this applies to (e.g. 'newsletter curation', 'ruby tooling')"
|
|
15
14
|
|
|
16
|
-
def execute(content:, reasoning:, category
|
|
17
|
-
|
|
18
|
-
return 'No durable
|
|
15
|
+
def execute(content:, reasoning:, category:)
|
|
16
|
+
adapter = RobotLab::Durable::Hook.current_adapter
|
|
17
|
+
return 'No durable session active on this robot.' unless adapter
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
entry = Durable::Entry.new(
|
|
22
|
-
content:,
|
|
23
|
-
reasoning:,
|
|
24
|
-
category: category.to_sym,
|
|
25
|
-
domain:,
|
|
26
|
-
confidence: 0.1,
|
|
27
|
-
use_count: 0,
|
|
28
|
-
created_at: now,
|
|
29
|
-
updated_at: now
|
|
30
|
-
)
|
|
19
|
+
adapter.record(content: content, reasoning: reasoning, category: category)
|
|
31
20
|
|
|
32
|
-
|
|
33
|
-
|
|
21
|
+
# Update session memory so subsequent LLM calls in this run see the fact.
|
|
22
|
+
# HTM deduplicates by content hash, so the on_learn callback that follows is safe.
|
|
23
|
+
robot.learn(content)
|
|
34
24
|
|
|
35
25
|
"Recorded: #{content}"
|
|
36
26
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: robot_lab-durable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dewayne VanHoozer
|
|
@@ -23,10 +23,24 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: 0.2.0
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: htm
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
description: Provides RobotLab::Durable — an adapter between robot_lab and the HTM
|
|
41
|
+
gem (Hierarchical Temporal Memory). Replaces the former YAML store with PostgreSQL-backed
|
|
42
|
+
long-term memory featuring vector embeddings, hybrid semantic search, hierarchical
|
|
43
|
+
tagging, and multi-robot federation. Includes RecallKnowledge and RecordKnowledge
|
|
30
44
|
tools that integrate directly into Robot when robot_lab is present.
|
|
31
45
|
email:
|
|
32
46
|
- dvanhoozer@gmail.com
|
|
@@ -36,8 +50,11 @@ extra_rdoc_files: []
|
|
|
36
50
|
files:
|
|
37
51
|
- ".envrc"
|
|
38
52
|
- ".github/workflows/deploy-github-pages.yml"
|
|
53
|
+
- ".loki"
|
|
54
|
+
- ".quality/reek_baseline.txt"
|
|
39
55
|
- ".rubocop.yml"
|
|
40
56
|
- CHANGELOG.md
|
|
57
|
+
- CLAUDE.md
|
|
41
58
|
- LICENSE.txt
|
|
42
59
|
- README.md
|
|
43
60
|
- Rakefile
|
|
@@ -45,13 +62,13 @@ files:
|
|
|
45
62
|
- docs/index.md
|
|
46
63
|
- docs/superpowers/plans/2026-05-06-durable-learning.md
|
|
47
64
|
- docs/superpowers/specs/2026-05-06-durable-learning-design.md
|
|
48
|
-
- examples/
|
|
49
|
-
- examples/
|
|
65
|
+
- examples/01_day_trader.rb
|
|
66
|
+
- examples/day_trader_lib/generator.rb
|
|
67
|
+
- examples/day_trader_lib/predictor.rb
|
|
50
68
|
- lib/robot_lab/durable.rb
|
|
69
|
+
- lib/robot_lab/durable/adapter.rb
|
|
51
70
|
- lib/robot_lab/durable/entry.rb
|
|
52
|
-
- lib/robot_lab/durable/
|
|
53
|
-
- lib/robot_lab/durable/reflector.rb
|
|
54
|
-
- lib/robot_lab/durable/store.rb
|
|
71
|
+
- lib/robot_lab/durable/hook.rb
|
|
55
72
|
- lib/robot_lab/durable/version.rb
|
|
56
73
|
- lib/robot_lab/recall_knowledge.rb
|
|
57
74
|
- lib/robot_lab/record_knowledge.rb
|
|
@@ -78,7 +95,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
78
95
|
- !ruby/object:Gem::Version
|
|
79
96
|
version: '0'
|
|
80
97
|
requirements: []
|
|
81
|
-
rubygems_version: 4.0.
|
|
98
|
+
rubygems_version: 4.0.17
|
|
82
99
|
specification_version: 4
|
|
83
|
-
summary:
|
|
100
|
+
summary: HTM-backed long-term memory for RobotLab agents
|
|
84
101
|
test_files: []
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RobotLab
|
|
4
|
-
module Durable
|
|
5
|
-
module Learning
|
|
6
|
-
# Configure durable learning on a robot after initialization.
|
|
7
|
-
#
|
|
8
|
-
# @param domain [String] topic area for this robot's knowledge
|
|
9
|
-
# @param store_path [String, nil] override default ~/.robot_lab/durable path
|
|
10
|
-
def setup_durable_learning(domain:, store_path: nil)
|
|
11
|
-
@learn_domain = domain.to_s
|
|
12
|
-
opts = store_path ? { path: store_path } : {}
|
|
13
|
-
@durable_store = Store.new(**opts)
|
|
14
|
-
|
|
15
|
-
seed_from_store
|
|
16
|
-
@local_tools = (@local_tools + [RecallKnowledge, RecordKnowledge]).uniq
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# Run the end-of-session reflection pass.
|
|
20
|
-
# Called automatically from Robot#run when durable learning is active.
|
|
21
|
-
def run_reflector
|
|
22
|
-
return unless @durable_store && @learn_domain && @learnings&.any?
|
|
23
|
-
|
|
24
|
-
Reflector.new(store: @durable_store, domain: @learn_domain).reflect(@learnings)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
private
|
|
28
|
-
|
|
29
|
-
def seed_from_store
|
|
30
|
-
return unless @durable_store && @learn_domain
|
|
31
|
-
|
|
32
|
-
entries = @durable_store.recall(query: @learn_domain, domain: @learn_domain, min_confidence: 0.0)
|
|
33
|
-
entries.each do |e|
|
|
34
|
-
learn("[#{e.category}] #{e.content}: #{e.reasoning}")
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RobotLab
|
|
4
|
-
module Durable
|
|
5
|
-
class Reflector
|
|
6
|
-
def initialize(store:, domain:)
|
|
7
|
-
@store = store
|
|
8
|
-
@domain = domain.to_s
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
# Examine plain-text learnings accumulated during a session and promote
|
|
12
|
-
# any that are not already represented in the store.
|
|
13
|
-
#
|
|
14
|
-
# @param learnings [Array<String>] robot.learnings from the completed session
|
|
15
|
-
def reflect(learnings)
|
|
16
|
-
Array(learnings).each do |text|
|
|
17
|
-
next if text.nil? || text.strip.empty?
|
|
18
|
-
|
|
19
|
-
text = text.strip
|
|
20
|
-
next if already_stored?(text)
|
|
21
|
-
|
|
22
|
-
now = Time.now.iso8601
|
|
23
|
-
@store.record(
|
|
24
|
-
Entry.new(
|
|
25
|
-
content: text,
|
|
26
|
-
reasoning: 'Observed during session (auto-promoted by Reflector)',
|
|
27
|
-
category: :pattern,
|
|
28
|
-
domain: @domain,
|
|
29
|
-
confidence: 0.1,
|
|
30
|
-
use_count: 0,
|
|
31
|
-
created_at: now,
|
|
32
|
-
updated_at: now
|
|
33
|
-
)
|
|
34
|
-
)
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
private
|
|
39
|
-
|
|
40
|
-
def already_stored?(text)
|
|
41
|
-
@store.recall(query: text, domain: @domain, min_confidence: 0.0).any? do |e|
|
|
42
|
-
e.content.downcase == text.downcase
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'yaml'
|
|
4
|
-
require 'fileutils'
|
|
5
|
-
|
|
6
|
-
module RobotLab
|
|
7
|
-
module Durable
|
|
8
|
-
class Store
|
|
9
|
-
DEFAULT_PATH = File.join(Dir.home, '.robot_lab', 'durable')
|
|
10
|
-
|
|
11
|
-
MIN_WORD_LENGTH = 3
|
|
12
|
-
|
|
13
|
-
def initialize(path: DEFAULT_PATH)
|
|
14
|
-
@path = path
|
|
15
|
-
FileUtils.mkdir_p(@path)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# Return entries matching query keywords, sorted by descending confidence.
|
|
19
|
-
#
|
|
20
|
-
# @param query [String] natural-language search string
|
|
21
|
-
# @param domain [String, nil] restrict to one domain file; nil searches all
|
|
22
|
-
# @param min_confidence [Float] exclude entries below this threshold
|
|
23
|
-
# @return [Array<Entry>]
|
|
24
|
-
def recall(query:, domain: nil, min_confidence: 0.0)
|
|
25
|
-
entries = domain ? load_domain(domain) : load_all
|
|
26
|
-
words = tokenize(query)
|
|
27
|
-
|
|
28
|
-
entries
|
|
29
|
-
.select { |e| e.confidence >= min_confidence }
|
|
30
|
-
.select { |e| matches?(e, words) }
|
|
31
|
-
.sort_by { |e| -e.confidence }
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Persist a new entry. If an entry with the same content already exists
|
|
35
|
-
# in the domain file, increment its confidence and use_count instead.
|
|
36
|
-
#
|
|
37
|
-
# @param entry [Entry]
|
|
38
|
-
# @return [Entry] the stored entry (may differ if an existing one was updated)
|
|
39
|
-
def record(entry)
|
|
40
|
-
with_domain_lock(entry.domain) do
|
|
41
|
-
entries = load_domain(entry.domain)
|
|
42
|
-
idx = entries.find_index { |e| e.content.downcase == entry.content.downcase }
|
|
43
|
-
|
|
44
|
-
if idx
|
|
45
|
-
entries[idx] = entries[idx].confirm
|
|
46
|
-
else
|
|
47
|
-
entries << entry
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
save_domain(entry.domain, entries)
|
|
51
|
-
entries[idx || -1]
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Increment confidence and use_count on a stored entry.
|
|
56
|
-
#
|
|
57
|
-
# @param entry [Entry]
|
|
58
|
-
# @return [Entry] the updated entry
|
|
59
|
-
def confirm(entry)
|
|
60
|
-
updated = entry.confirm
|
|
61
|
-
record_exact(updated)
|
|
62
|
-
updated
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
private
|
|
66
|
-
|
|
67
|
-
def matches?(entry, words)
|
|
68
|
-
text = "#{entry.content} #{entry.domain}".downcase
|
|
69
|
-
words.any? { |w| text.include?(w) }
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def tokenize(str)
|
|
73
|
-
str.downcase.split(/\s+/).reject { |w| w.length < MIN_WORD_LENGTH }
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def load_domain(domain)
|
|
77
|
-
file = domain_file(domain)
|
|
78
|
-
return [] unless File.exist?(file)
|
|
79
|
-
|
|
80
|
-
raw = Array(YAML.safe_load_file(file) || [])
|
|
81
|
-
raw.map { |h| Entry.from_h(h) }
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def load_all
|
|
85
|
-
Dir.glob(File.join(@path, '*.yaml')).flat_map do |file|
|
|
86
|
-
raw = Array(YAML.safe_load_file(file) || [])
|
|
87
|
-
raw.map { |h| Entry.from_h(h) }
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def save_domain(domain, entries)
|
|
92
|
-
File.write(domain_file(domain), YAML.dump(entries.map(&:to_h)))
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def record_exact(entry)
|
|
96
|
-
with_domain_lock(entry.domain) do
|
|
97
|
-
entries = load_domain(entry.domain)
|
|
98
|
-
idx = entries.find_index { |e| e.content.downcase == entry.content.downcase }
|
|
99
|
-
raise RobotLab::Error, "Cannot confirm: entry not found in domain '#{entry.domain}'" unless idx
|
|
100
|
-
|
|
101
|
-
entries[idx] = entry
|
|
102
|
-
save_domain(entry.domain, entries)
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def domain_file(domain)
|
|
107
|
-
safe = domain.to_s.downcase.gsub(/[^a-z0-9]+/, '_').delete_prefix('_').delete_suffix('_')
|
|
108
|
-
File.join(@path, "#{safe}.yaml")
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def with_domain_lock(domain, &block)
|
|
112
|
-
lock_path = "#{domain_file(domain)}.lock"
|
|
113
|
-
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |f|
|
|
114
|
-
f.flock(File::LOCK_EX)
|
|
115
|
-
block.call
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
end
|