scout-ai 1.1.6 → 1.1.7
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/.vimproject +13 -21
- data/VERSION +1 -1
- data/doc/Agent.md +162 -192
- data/lib/scout/llm/agent.rb +15 -0
- data/lib/scout/llm/backends/bedrock.rb +1 -3
- data/lib/scout/llm/chat/parse.rb +1 -2
- data/lib/scout/llm/chat/process/tools.rb +1 -1
- data/lib/scout/llm/tools/call.rb +4 -2
- data/lib/scout/llm/tools/workflow.rb +2 -1
- data/lib/scout/network/entity.rb +206 -0
- data/lib/scout/network/knowledge_base.rb +142 -0
- data/lib/scout/network/paths.rb +177 -0
- data/scout-ai.gemspec +9 -3
- data/test/scout/llm/backends/test_bedrock.rb +1 -1
- data/test/scout/llm/test_agent.rb +17 -1
- data/test/scout/network/test_entity.rb +47 -0
- data/test/scout/network/test_knowledge_base.rb +61 -0
- data/test/scout/network/test_paths.rb +56 -0
- metadata +8 -2
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
require_relative 'paths'
|
|
2
|
+
|
|
3
|
+
module Entity
|
|
4
|
+
module Adjacent
|
|
5
|
+
def path_to(adjacency, entities, threshold = nil, max_steps = nil)
|
|
6
|
+
if Array === self
|
|
7
|
+
self.collect{|entity| entity.path_to(adjacency, entities, threshold, max_steps)}
|
|
8
|
+
else
|
|
9
|
+
if adjacency.type == :flat
|
|
10
|
+
max_steps ||= threshold
|
|
11
|
+
Paths.dijkstra(adjacency, self, entities, max_steps)
|
|
12
|
+
else
|
|
13
|
+
Paths.weighted_dijkstra(adjacency, self, entities, threshold, max_steps)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def random_paths_to(adjacency, l, times, entities)
|
|
19
|
+
if Array === self
|
|
20
|
+
self.inject([]){|acc,entity| acc += entity.random_paths_to(adjacency, l, times, entities)}
|
|
21
|
+
else
|
|
22
|
+
paths = []
|
|
23
|
+
times.times do
|
|
24
|
+
paths << Paths.random_weighted_dijkstra(adjacency, l, self, entities)
|
|
25
|
+
end
|
|
26
|
+
paths
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# list of neighbours up to a given radius using unweighted adjacency
|
|
31
|
+
# adjacency: Hash[String => Array[String]] or TSV(:flat) treated as adjacency
|
|
32
|
+
# k: maximum number of steps
|
|
33
|
+
# Returns an Array of Arrays (one per entity when self is an array), each
|
|
34
|
+
# containing the reachable entities (as plain values) within k steps
|
|
35
|
+
def neighborhood(adjacency, k)
|
|
36
|
+
if Array === self
|
|
37
|
+
self.collect{|entity| entity.neighborhood(adjacency, k)}
|
|
38
|
+
else
|
|
39
|
+
adj_hash = adjacency.respond_to?(:include?) ? adjacency : adjacency.to_hash
|
|
40
|
+
Paths.neighborhood(adj_hash, self, k)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module AssociationItem
|
|
47
|
+
|
|
48
|
+
# Dijkstra over a list of AssociationItems, using an optional block to
|
|
49
|
+
# compute edge weights.
|
|
50
|
+
def self.dijkstra(associations, start_node, end_node = nil, threshold = nil, max_steps = nil, &block)
|
|
51
|
+
adjacency = {}
|
|
52
|
+
|
|
53
|
+
associations.each do |m|
|
|
54
|
+
s, t, _sep = m.split "~"
|
|
55
|
+
next if s.nil? || t.nil? || s.strip.empty? || t.strip.empty?
|
|
56
|
+
adjacency[s] ||= Set.new
|
|
57
|
+
adjacency[s] << t
|
|
58
|
+
next unless m.undirected
|
|
59
|
+
adjacency[t] ||= Set.new
|
|
60
|
+
adjacency[t] << s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return nil unless adjacency.include? start_node
|
|
64
|
+
|
|
65
|
+
active = PriorityQueue.new
|
|
66
|
+
distances = Hash.new { 1.0 / 0.0 }
|
|
67
|
+
parents = Hash.new
|
|
68
|
+
|
|
69
|
+
active[start_node] << 0
|
|
70
|
+
best = 1.0 / 0.0
|
|
71
|
+
found = false
|
|
72
|
+
node_dist_cache = {}
|
|
73
|
+
|
|
74
|
+
until active.empty?
|
|
75
|
+
u = active.priorities.first
|
|
76
|
+
distance = active.shift
|
|
77
|
+
distances[u] = distance
|
|
78
|
+
path = Paths.extract_path(parents, start_node, u) if parents.key?(u)
|
|
79
|
+
next if max_steps && path && path.length > max_steps
|
|
80
|
+
next unless adjacency.include?(u) && adjacency[u] && !adjacency[u].empty?
|
|
81
|
+
adjacency[u].each do |v|
|
|
82
|
+
node_dist = node_dist_cache[[u,v]] ||= (block_given? ? block.call(u,v) : 1)
|
|
83
|
+
next if node_dist.nil? || (threshold && node_dist > threshold)
|
|
84
|
+
d = distance + node_dist
|
|
85
|
+
next unless d < distances[v] && d < best # we can't relax this one
|
|
86
|
+
active[v] << d
|
|
87
|
+
distances[v] = d
|
|
88
|
+
parents[v] = u
|
|
89
|
+
if String === end_node ? (end_node == v) : (end_node && end_node.include?(v))
|
|
90
|
+
best = d
|
|
91
|
+
found = true
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
return nil unless found
|
|
97
|
+
|
|
98
|
+
if end_node
|
|
99
|
+
end_node = (end_node & parents.keys).first unless String === end_node
|
|
100
|
+
return nil unless parents.include? end_node
|
|
101
|
+
Paths.extract_path(parents, start_node, end_node)
|
|
102
|
+
else
|
|
103
|
+
parents
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Connected components from a list of AssociationItems.
|
|
108
|
+
# Returns an Array of Arrays of node identifiers.
|
|
109
|
+
def self.components(associations, undirected: true)
|
|
110
|
+
inc = associations.respond_to?(:incidence) ? associations.incidence : AssociationItem.incidence(associations)
|
|
111
|
+
|
|
112
|
+
adjacency = Hash.new { |h,k| h[k] = [] }
|
|
113
|
+
nodes = Set.new
|
|
114
|
+
targets = inc.fields
|
|
115
|
+
|
|
116
|
+
inc.each do |src, row|
|
|
117
|
+
Array(row).each_with_index do |val, i|
|
|
118
|
+
next if val.nil?
|
|
119
|
+
t = targets[i]
|
|
120
|
+
adjacency[src] << t
|
|
121
|
+
nodes << src << t
|
|
122
|
+
adjacency[t] << src if undirected
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
components = []
|
|
127
|
+
visited = Set.new
|
|
128
|
+
|
|
129
|
+
nodes.each do |n|
|
|
130
|
+
next if visited.include?(n)
|
|
131
|
+
comp = []
|
|
132
|
+
queue = [n]
|
|
133
|
+
visited << n
|
|
134
|
+
until queue.empty?
|
|
135
|
+
u = queue.shift
|
|
136
|
+
comp << u
|
|
137
|
+
adjacency[u].each do |v|
|
|
138
|
+
next if visited.include?(v)
|
|
139
|
+
visited << v
|
|
140
|
+
queue << v
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
components << comp
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
components
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Degree per node from an AssociationItem list.
|
|
150
|
+
# direction: :out, :in, :both
|
|
151
|
+
def self.degrees(associations, direction: :both)
|
|
152
|
+
inc = associations.respond_to?(:incidence) ? associations.incidence : AssociationItem.incidence(associations)
|
|
153
|
+
deg = Hash.new(0)
|
|
154
|
+
targets = inc.fields
|
|
155
|
+
|
|
156
|
+
inc.each do |src, row|
|
|
157
|
+
Array(row).each_with_index do |val, i|
|
|
158
|
+
next if val.nil?
|
|
159
|
+
t = targets[i]
|
|
160
|
+
case direction
|
|
161
|
+
when :out
|
|
162
|
+
deg[src] += 1
|
|
163
|
+
when :in
|
|
164
|
+
deg[t] += 1
|
|
165
|
+
when :both
|
|
166
|
+
deg[src] += 1
|
|
167
|
+
deg[t] += 1
|
|
168
|
+
else
|
|
169
|
+
raise ArgumentError, "Unknown direction: #{direction.inspect}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
deg
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Induced subgraph: keep only edges whose endpoints are in the given node set.
|
|
178
|
+
def self.subset_by_nodes(associations, nodes)
|
|
179
|
+
node_set = nodes.to_set
|
|
180
|
+
associations.select do |m|
|
|
181
|
+
s = m.source rescue nil
|
|
182
|
+
t = m.target rescue nil
|
|
183
|
+
next false if s.nil? || t.nil?
|
|
184
|
+
node_set.include?(s) && node_set.include?(t)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Neighborhood within k steps inside a fixed subgraph, using unweighted BFS
|
|
189
|
+
# over adjacency built from associations.
|
|
190
|
+
def self.neighborhood(associations, seeds, k)
|
|
191
|
+
inc = associations.respond_to?(:incidence) ? associations.incidence : AssociationItem.incidence(associations)
|
|
192
|
+
adjacency = Hash.new { |h,k| h[k] = [] }
|
|
193
|
+
targets = inc.fields
|
|
194
|
+
|
|
195
|
+
inc.each do |src, row|
|
|
196
|
+
Array(row).each_with_index do |val, i|
|
|
197
|
+
next if val.nil?
|
|
198
|
+
t = targets[i]
|
|
199
|
+
adjacency[src] << t
|
|
200
|
+
adjacency[t] << src
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
Paths.neighborhood(adjacency, seeds, k)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require 'scout/knowledge_base'
|
|
2
|
+
require 'set'
|
|
3
|
+
|
|
4
|
+
class KnowledgeBase
|
|
5
|
+
# Outward k-hop expansion from seeds over a KB database
|
|
6
|
+
# db: database name (Symbol or String)
|
|
7
|
+
# seeds: Entity or Array of Entities/ids
|
|
8
|
+
# depth: Integer
|
|
9
|
+
# direction: :out, :in, :both
|
|
10
|
+
# filter: optional Proc taking an AssociationItem and returning true/false
|
|
11
|
+
#
|
|
12
|
+
# Returns [visited_nodes, edges], where:
|
|
13
|
+
# - visited_nodes is an Array of identifiers (as returned by AssociationItem.source/target)
|
|
14
|
+
# - edges is an Array of AssociationItems traversed during the expansion
|
|
15
|
+
def radial_expand(db, seeds, depth:, direction: :out, filter: nil)
|
|
16
|
+
current = Array(seeds).compact
|
|
17
|
+
return [[], []] if current.empty?
|
|
18
|
+
|
|
19
|
+
visited = Set.new(current)
|
|
20
|
+
edges = []
|
|
21
|
+
|
|
22
|
+
1.upto(depth) do
|
|
23
|
+
next_front = []
|
|
24
|
+
|
|
25
|
+
current.each do |entity|
|
|
26
|
+
step_edges = case direction
|
|
27
|
+
when :out
|
|
28
|
+
children(db, entity)
|
|
29
|
+
when :in
|
|
30
|
+
parents(db, entity)
|
|
31
|
+
when :both
|
|
32
|
+
children(db, entity) + parents(db, entity)
|
|
33
|
+
else
|
|
34
|
+
raise ArgumentError, "Unknown direction: #{direction.inspect}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
step_edges.each do |item|
|
|
38
|
+
next if filter && ! filter.call(item)
|
|
39
|
+
|
|
40
|
+
s = item.source
|
|
41
|
+
t = item.target
|
|
42
|
+
|
|
43
|
+
edges << item
|
|
44
|
+
|
|
45
|
+
targets = case direction
|
|
46
|
+
when :out then [t]
|
|
47
|
+
when :in then [s]
|
|
48
|
+
when :both then [s, t]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
targets.each do |node|
|
|
52
|
+
next if visited.include?(node)
|
|
53
|
+
visited << node
|
|
54
|
+
next_front << node
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
break if next_front.empty?
|
|
60
|
+
current = next_front
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
[visited.to_a, edges.uniq]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Return radial layers around seeds as an array of arrays of nodes, plus edges.
|
|
67
|
+
# layers[0] are the seeds, layers[1] nodes at distance 1, ... up to depth or
|
|
68
|
+
# until no more nodes are reached.
|
|
69
|
+
def radial_layers(db, seeds, depth:, direction: :out, filter: nil)
|
|
70
|
+
current = Array(seeds).compact
|
|
71
|
+
return [[], []] if current.empty?
|
|
72
|
+
|
|
73
|
+
layers = []
|
|
74
|
+
visited = Set.new(current)
|
|
75
|
+
edges = []
|
|
76
|
+
|
|
77
|
+
layers << current.dup
|
|
78
|
+
|
|
79
|
+
1.upto(depth) do
|
|
80
|
+
next_front = []
|
|
81
|
+
|
|
82
|
+
current.each do |entity|
|
|
83
|
+
step_edges = case direction
|
|
84
|
+
when :out
|
|
85
|
+
children(db, entity)
|
|
86
|
+
when :in
|
|
87
|
+
parents(db, entity)
|
|
88
|
+
when :both
|
|
89
|
+
children(db, entity) + parents(db, entity)
|
|
90
|
+
else
|
|
91
|
+
raise ArgumentError, "Unknown direction: #{direction.inspect}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
step_edges.each do |item|
|
|
95
|
+
next if filter && ! filter.call(item)
|
|
96
|
+
|
|
97
|
+
s = item.source
|
|
98
|
+
t = item.target
|
|
99
|
+
|
|
100
|
+
edges << item
|
|
101
|
+
|
|
102
|
+
targets = case direction
|
|
103
|
+
when :out then [t]
|
|
104
|
+
when :in then [s]
|
|
105
|
+
when :both then [s, t]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
targets.each do |node|
|
|
109
|
+
next if visited.include?(node)
|
|
110
|
+
visited << node
|
|
111
|
+
next_front << node
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
break if next_front.empty?
|
|
117
|
+
layers << next_front
|
|
118
|
+
current = next_front
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
[layers, edges.uniq]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Directional convenience wrappers
|
|
125
|
+
def radial_expand_out(db, seeds, depth:, filter: nil)
|
|
126
|
+
radial_expand(db, seeds, depth: depth, direction: :out, filter: filter)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def radial_expand_in(db, seeds, depth:, filter: nil)
|
|
130
|
+
radial_expand(db, seeds, depth: depth, direction: :in, filter: filter)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def radial_expand_both(db, seeds, depth:, filter: nil)
|
|
134
|
+
radial_expand(db, seeds, depth: depth, direction: :both, filter: filter)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Convenience: only return the edges in a k-hop subgraph around seeds
|
|
138
|
+
def subgraph_around(db, seeds, depth:, direction: :out, filter: nil)
|
|
139
|
+
_visited, edges = radial_expand(db, seeds, depth: depth, direction: direction, filter: filter)
|
|
140
|
+
edges
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
require 'fc'
|
|
2
|
+
|
|
3
|
+
module Paths
|
|
4
|
+
|
|
5
|
+
def self.extract_path(parents, start_node, end_node)
|
|
6
|
+
path = [end_node]
|
|
7
|
+
while not path.last === start_node
|
|
8
|
+
path << parents[path.last]
|
|
9
|
+
end
|
|
10
|
+
path
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.dijkstra(adjacency, start_node, end_node = nil, max_steps = nil)
|
|
14
|
+
|
|
15
|
+
return nil unless adjacency.include? start_node
|
|
16
|
+
|
|
17
|
+
case end_node
|
|
18
|
+
when String
|
|
19
|
+
return nil unless adjacency.values.flatten.include? end_node
|
|
20
|
+
when Array
|
|
21
|
+
return nil unless (adjacency.values.flatten & end_node).any?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
active = FastContainers::PriorityQueue.new(:min)
|
|
25
|
+
distances = Hash.new { 1.0 / 0.0 }
|
|
26
|
+
parents = Hash.new
|
|
27
|
+
|
|
28
|
+
active.push(start_node, 0)
|
|
29
|
+
best = 1.0 / 0.0
|
|
30
|
+
until active.empty?
|
|
31
|
+
u = active.top
|
|
32
|
+
distance = active.top_key
|
|
33
|
+
active.pop
|
|
34
|
+
|
|
35
|
+
distances[u] = distance
|
|
36
|
+
d = distance + 1
|
|
37
|
+
path = extract_path(parents, start_node, u)
|
|
38
|
+
next if path.length > max_steps if max_steps
|
|
39
|
+
adjacency[u].each do |v|
|
|
40
|
+
next unless d < distances[v] and d < best # we can't relax this one
|
|
41
|
+
best = d if (String === end_node ? end_node == v : end_node.include?(v))
|
|
42
|
+
active.push(v,d) if adjacency.include? v
|
|
43
|
+
distances[v] = d
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
parents[v] = u
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if end_node
|
|
51
|
+
end_node = end_node.select{|n| parents.keys.include? n}.first unless String === end_node
|
|
52
|
+
return nil if not parents.include? end_node
|
|
53
|
+
extract_path(parents, start_node, end_node)
|
|
54
|
+
else
|
|
55
|
+
parents
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.weighted_dijkstra(adjacency, start_node, end_node = nil, threshold = nil, max_steps = nil)
|
|
60
|
+
return nil unless adjacency.include? start_node
|
|
61
|
+
|
|
62
|
+
active = FastContainers::PriorityQueue.new(:min)
|
|
63
|
+
distances = Hash.new { 1.0 / 0.0 }
|
|
64
|
+
parents = Hash.new
|
|
65
|
+
|
|
66
|
+
active.push(start_node, 0)
|
|
67
|
+
best = 1.0 / 0.0
|
|
68
|
+
found = false
|
|
69
|
+
until active.empty?
|
|
70
|
+
u = active.top
|
|
71
|
+
distance = active.top_key
|
|
72
|
+
active.pop
|
|
73
|
+
distances[u] = distance
|
|
74
|
+
path = extract_path(parents, start_node, u)
|
|
75
|
+
next if path.length > max_steps if max_steps
|
|
76
|
+
next if not adjacency.include?(u) or (adjacency[u].nil? or adjacency[u].empty? )
|
|
77
|
+
NamedArray.zip_fields(adjacency[u]).each do |v,node_dist|
|
|
78
|
+
node_dist = node_dist.to_f
|
|
79
|
+
next if node_dist.nil? or (threshold and node_dist > threshold)
|
|
80
|
+
d = distance + node_dist
|
|
81
|
+
next unless d < distances[v] and d < best # we can't relax this one
|
|
82
|
+
active.push(v, d)
|
|
83
|
+
distances[v] = d
|
|
84
|
+
parents[v] = u
|
|
85
|
+
if (String === end_node ? end_node == v : end_node.include?(v))
|
|
86
|
+
best = d
|
|
87
|
+
found = true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
return nil unless found
|
|
93
|
+
|
|
94
|
+
if end_node
|
|
95
|
+
end_node = (end_node & parents.keys).first unless String === end_node
|
|
96
|
+
return nil if not parents.include? end_node
|
|
97
|
+
extract_path(parents, start_node, end_node)
|
|
98
|
+
else
|
|
99
|
+
parents
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.random_weighted_dijkstra(adjacency, l, start_node, end_node = nil)
|
|
104
|
+
return nil unless adjacency.include? start_node
|
|
105
|
+
|
|
106
|
+
active = PriorityQueue.new
|
|
107
|
+
distances = Hash.new { 1.0 / 0.0 }
|
|
108
|
+
parents = Hash.new
|
|
109
|
+
|
|
110
|
+
active[start_node] << 0
|
|
111
|
+
best = 1.0 / 0.0
|
|
112
|
+
until active.empty?
|
|
113
|
+
u = active.priorities.first
|
|
114
|
+
distance = active.shift
|
|
115
|
+
distances[u] = distance
|
|
116
|
+
next if not adjacency.include?(u) or adjacency[u].nil? or adjacency[u].empty?
|
|
117
|
+
NamedArray.zip_fields(adjacency[u]).each do |v,node_dist|
|
|
118
|
+
next if node_dist.nil?
|
|
119
|
+
d = distance + (node_dist * (l + rand))
|
|
120
|
+
next unless d < distances[v] and d < best # we can't relax this one
|
|
121
|
+
active[v] << distances[v] = d
|
|
122
|
+
parents[v] = u
|
|
123
|
+
best = d if (String === end_node ? end_node == v : end_node.include?(v))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if end_node
|
|
128
|
+
end_node = (end_node & parents.keys).first unless String === end_node
|
|
129
|
+
return nil if not parents.include? end_node
|
|
130
|
+
path = [end_node]
|
|
131
|
+
while not path.last === start_node
|
|
132
|
+
path << parents[path.last]
|
|
133
|
+
end
|
|
134
|
+
path
|
|
135
|
+
else
|
|
136
|
+
parents
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# New: breadth‑first exploration from one or many start nodes (unweighted)
|
|
141
|
+
# adjacency: Hash[String => Array[String]]
|
|
142
|
+
# sources: String or Array[String]
|
|
143
|
+
# max_steps: Integer or nil (no limit)
|
|
144
|
+
# Returns Hash[node => distance_from_any_source]
|
|
145
|
+
def self.breadth_first(adjacency, sources, max_steps = nil)
|
|
146
|
+
sources = [sources] unless Array === sources
|
|
147
|
+
distances = {}
|
|
148
|
+
queue = []
|
|
149
|
+
|
|
150
|
+
sources.each do |s|
|
|
151
|
+
next unless adjacency.include?(s)
|
|
152
|
+
distances[s] = 0
|
|
153
|
+
queue << s
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
until queue.empty?
|
|
157
|
+
u = queue.shift
|
|
158
|
+
d = distances[u]
|
|
159
|
+
next if max_steps && d >= max_steps
|
|
160
|
+
next unless adjacency.include?(u)
|
|
161
|
+
adjacency[u].each do |v|
|
|
162
|
+
next if distances.key?(v)
|
|
163
|
+
distances[v] = d + 1
|
|
164
|
+
queue << v
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
distances
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# New: enumerate nodes within k steps of a set of sources (unweighted)
|
|
172
|
+
# Convenience wrapper over breadth_first
|
|
173
|
+
def self.neighborhood(adjacency, sources, k)
|
|
174
|
+
distances = breadth_first(adjacency, sources, k)
|
|
175
|
+
distances.keys
|
|
176
|
+
end
|
|
177
|
+
end
|
data/scout-ai.gemspec
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
# DO NOT EDIT THIS FILE DIRECTLY
|
|
3
3
|
# Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec'
|
|
4
4
|
# -*- encoding: utf-8 -*-
|
|
5
|
-
# stub: scout-ai 1.1.
|
|
5
|
+
# stub: scout-ai 1.1.7 ruby lib
|
|
6
6
|
|
|
7
7
|
Gem::Specification.new do |s|
|
|
8
8
|
s.name = "scout-ai".freeze
|
|
9
|
-
s.version = "1.1.
|
|
9
|
+
s.version = "1.1.7".freeze
|
|
10
10
|
|
|
11
11
|
s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
|
|
12
12
|
s.require_paths = ["lib".freeze]
|
|
@@ -79,6 +79,9 @@ Gem::Specification.new do |s|
|
|
|
79
79
|
"lib/scout/model/python/torch/load_and_save.rb",
|
|
80
80
|
"lib/scout/model/util/run.rb",
|
|
81
81
|
"lib/scout/model/util/save.rb",
|
|
82
|
+
"lib/scout/network/entity.rb",
|
|
83
|
+
"lib/scout/network/knowledge_base.rb",
|
|
84
|
+
"lib/scout/network/paths.rb",
|
|
82
85
|
"python/scout_ai/__init__.py",
|
|
83
86
|
"python/scout_ai/huggingface/data.py",
|
|
84
87
|
"python/scout_ai/huggingface/eval.py",
|
|
@@ -135,11 +138,14 @@ Gem::Specification.new do |s|
|
|
|
135
138
|
"test/scout/model/python/torch/test_helpers.rb",
|
|
136
139
|
"test/scout/model/test_base.rb",
|
|
137
140
|
"test/scout/model/util/test_save.rb",
|
|
141
|
+
"test/scout/network/test_entity.rb",
|
|
142
|
+
"test/scout/network/test_knowledge_base.rb",
|
|
143
|
+
"test/scout/network/test_paths.rb",
|
|
138
144
|
"test/test_helper.rb"
|
|
139
145
|
]
|
|
140
146
|
s.homepage = "http://github.com/mikisvaz/scout-ai".freeze
|
|
141
147
|
s.licenses = ["MIT".freeze]
|
|
142
|
-
s.rubygems_version = "3.7.
|
|
148
|
+
s.rubygems_version = "3.7.2".freeze
|
|
143
149
|
s.summary = "AI gear for scouts".freeze
|
|
144
150
|
|
|
145
151
|
s.specification_version = 4
|
|
@@ -13,9 +13,25 @@ class TestLLMAgent < Test::Unit::TestCase
|
|
|
13
13
|
|
|
14
14
|
agent = LLM::Agent.new knowledge_base: kb
|
|
15
15
|
|
|
16
|
-
sss 0
|
|
17
16
|
ppp agent.ask "Who is Miguel's brother-in-law. Brother in law is your spouses sibling or your sibling's spouse"
|
|
18
17
|
end
|
|
19
18
|
end
|
|
19
|
+
|
|
20
|
+
def test_workflow_eval
|
|
21
|
+
agent = LLM::Agent.new
|
|
22
|
+
agent.workflow do
|
|
23
|
+
input :c_degrees, :float, "Degrees Celsius"
|
|
24
|
+
|
|
25
|
+
task :c_to_f => :float do |c_degrees|
|
|
26
|
+
(c_degrees * 9.0 / 5.0) + 32.0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
export :c_to_f
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
agent.user "Convert 30 celsius into faranheit"
|
|
33
|
+
res = agent.json_format({conversion: {type: :number}})
|
|
34
|
+
assert_equal 86.0, res['conversion']
|
|
35
|
+
end
|
|
20
36
|
end
|
|
21
37
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require File.expand_path(__FILE__).sub(%r(.*/test/), '').sub(/test_(.*)\.rb/,'\\1')
|
|
3
|
+
|
|
4
|
+
require 'scout/network/entity'
|
|
5
|
+
require 'scout/knowledge_base'
|
|
6
|
+
|
|
7
|
+
class TestNetworkEntityAssociationItem < Test::Unit::TestCase
|
|
8
|
+
def build_pairs
|
|
9
|
+
kb = KnowledgeBase.new tmpdir
|
|
10
|
+
kb.register :brothers, datafile_test(:person).brothers, undirected: true
|
|
11
|
+
kb.all(:brothers)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def test_components
|
|
15
|
+
pairs = build_pairs
|
|
16
|
+
comps = AssociationItem.components(pairs)
|
|
17
|
+
# All nodes from brothers should appear in the (single) component
|
|
18
|
+
flat = comps.flatten
|
|
19
|
+
%w[Miki Isa Clei Guille].each do |name|
|
|
20
|
+
assert_include flat, name
|
|
21
|
+
end
|
|
22
|
+
assert_equal 1, comps.length
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_degrees
|
|
26
|
+
pairs = build_pairs
|
|
27
|
+
deg = AssociationItem.degrees(pairs)
|
|
28
|
+
# In brothers example, Miki and Isa each connected to at least one
|
|
29
|
+
assert deg['Miki'] > 0
|
|
30
|
+
assert deg['Isa'] > 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_subset_by_nodes
|
|
34
|
+
pairs = build_pairs
|
|
35
|
+
sub = AssociationItem.subset_by_nodes(pairs, %w[Miki Isa])
|
|
36
|
+
assert sub.any? { |e| [e.source, e.target].sort == %w[Isa Miki].sort }
|
|
37
|
+
# Edges involving others should be filtered out
|
|
38
|
+
refute sub.any? { |e| (e.source == 'Clei') || (e.target == 'Clei') }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_neighborhood_inside_subgraph
|
|
42
|
+
pairs = build_pairs
|
|
43
|
+
neigh = AssociationItem.neighborhood(pairs, 'Miki', 1)
|
|
44
|
+
assert_include neigh, 'Miki'
|
|
45
|
+
assert_include neigh, 'Isa'
|
|
46
|
+
end
|
|
47
|
+
end
|