agentf 0.4.7 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/agentf/agents/architect.rb +7 -3
- data/lib/agentf/agents/base.rb +31 -3
- data/lib/agentf/agents/debugger.rb +30 -8
- data/lib/agentf/agents/designer.rb +20 -8
- data/lib/agentf/agents/documenter.rb +8 -2
- data/lib/agentf/agents/explorer.rb +29 -11
- data/lib/agentf/agents/reviewer.rb +12 -7
- data/lib/agentf/agents/security.rb +27 -15
- data/lib/agentf/agents/specialist.rb +34 -18
- data/lib/agentf/agents/tester.rb +48 -8
- data/lib/agentf/cli/agent.rb +95 -0
- data/lib/agentf/cli/eval.rb +203 -0
- data/lib/agentf/cli/install.rb +7 -0
- data/lib/agentf/cli/memory.rb +138 -90
- data/lib/agentf/cli/router.rb +16 -4
- data/lib/agentf/cli/update.rb +9 -2
- data/lib/agentf/commands/memory_reviewer.rb +22 -48
- data/lib/agentf/commands/metrics.rb +18 -25
- data/lib/agentf/commands/registry.rb +28 -0
- data/lib/agentf/context_builder.rb +4 -14
- data/lib/agentf/embedding_provider.rb +35 -0
- data/lib/agentf/evals/report.rb +134 -0
- data/lib/agentf/evals/runner.rb +771 -0
- data/lib/agentf/evals/scenario.rb +211 -0
- data/lib/agentf/installer.rb +498 -365
- data/lib/agentf/mcp/server.rb +294 -114
- data/lib/agentf/memory.rb +354 -214
- data/lib/agentf/service/providers.rb +10 -62
- data/lib/agentf/version.rb +1 -1
- data/lib/agentf/workflow_engine.rb +205 -77
- data/lib/agentf.rb +10 -3
- metadata +9 -3
- data/lib/agentf/packs.rb +0 -74
data/lib/agentf/memory.rb
CHANGED
|
@@ -10,17 +10,33 @@ module Agentf
|
|
|
10
10
|
module Memory
|
|
11
11
|
# Redis-backed memory system for agent learning
|
|
12
12
|
class RedisMemory
|
|
13
|
+
EPISODIC_INDEX = "episodic:logs"
|
|
13
14
|
EDGE_INDEX = "edge:links"
|
|
15
|
+
DEFAULT_SEMANTIC_SCAN_LIMIT = 200
|
|
16
|
+
VECTOR_DIMENSIONS = defined?(Agentf::EmbeddingProvider::DIMENSIONS) ? Agentf::EmbeddingProvider::DIMENSIONS : 64
|
|
14
17
|
|
|
15
18
|
attr_reader :project
|
|
16
19
|
|
|
17
|
-
def initialize(redis_url: nil, project: nil)
|
|
20
|
+
def initialize(redis_url: nil, project: nil, embedding_provider: Agentf::EmbeddingProvider.new)
|
|
18
21
|
@redis_url = redis_url || Agentf.config.redis_url
|
|
19
22
|
@project = project || Agentf.config.project_name
|
|
23
|
+
@embedding_provider = embedding_provider
|
|
20
24
|
@client = Redis.new(client_options)
|
|
21
25
|
@json_supported = detect_json_support
|
|
22
26
|
@search_supported = detect_search_support
|
|
27
|
+
@vector_search_supported = false
|
|
23
28
|
ensure_indexes if @search_supported
|
|
29
|
+
@vector_search_supported = detect_vector_search_support if @search_supported
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Raised when a write requires explicit user confirmation (ask_first).
|
|
33
|
+
class ConfirmationRequired < StandardError
|
|
34
|
+
attr_reader :details
|
|
35
|
+
|
|
36
|
+
def initialize(message = "confirmation required to persist memory", details = {})
|
|
37
|
+
super(message)
|
|
38
|
+
@details = details
|
|
39
|
+
end
|
|
24
40
|
end
|
|
25
41
|
|
|
26
42
|
def store_task(content:, embedding: [], language: nil, task_type: nil, success: true, agent: Agentf::AgentRoles::PLANNER)
|
|
@@ -44,10 +60,16 @@ module Agentf
|
|
|
44
60
|
task_id
|
|
45
61
|
end
|
|
46
62
|
|
|
47
|
-
def store_episode(type:, title:, description:, context: "", code_snippet: "",
|
|
63
|
+
def store_episode(type:, title:, description:, context: "", code_snippet: "", agent: Agentf::AgentRoles::ORCHESTRATOR,
|
|
64
|
+
outcome: nil, embedding: nil,
|
|
48
65
|
related_task_id: nil, metadata: {}, entity_ids: [], relationships: [], parent_episode_id: nil, causal_from: nil, confirm: nil)
|
|
49
66
|
# Determine persistence preference from the agent's policy boundaries.
|
|
50
|
-
# Precedence: never >
|
|
67
|
+
# Precedence: never > ask_first > always > none.
|
|
68
|
+
# For local/dev testing we may bypass interactive confirmation when
|
|
69
|
+
# AGENTF_AUTO_CONFIRM_MEMORIES=true. Otherwise, when an agent declares
|
|
70
|
+
# an "ask_first" persistence preference we raise ConfirmationRequired
|
|
71
|
+
# so higher-level code (MCP server / workflow engine / CLI) can prompt
|
|
72
|
+
# the user and retry the write with confirm: true.
|
|
51
73
|
auto_confirm = ENV['AGENTF_AUTO_CONFIRM_MEMORIES'] == 'true'
|
|
52
74
|
pref = persistence_preference_for(agent)
|
|
53
75
|
|
|
@@ -58,24 +80,13 @@ module Agentf
|
|
|
58
80
|
rescue StandardError
|
|
59
81
|
end
|
|
60
82
|
return nil
|
|
61
|
-
when :always
|
|
62
|
-
# proceed without requiring explicit confirm
|
|
63
83
|
when :ask_first
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
end
|
|
71
|
-
else
|
|
72
|
-
# default conservative behavior: require explicit confirm (or env opt-in)
|
|
73
|
-
unless agent == Agentf::AgentRoles::ORCHESTRATOR || confirm == true || auto_confirm
|
|
74
|
-
begin
|
|
75
|
-
puts "[MEMORY] Skipping persistence for #{agent}: confirmation required"
|
|
76
|
-
rescue StandardError
|
|
77
|
-
end
|
|
78
|
-
return nil
|
|
84
|
+
# If the agent's policy requires asking first, and we do not have
|
|
85
|
+
# an explicit confirmation (confirm: true) and auto_confirm is not
|
|
86
|
+
# enabled, raise ConfirmationRequired so callers can handle prompting.
|
|
87
|
+
unless auto_confirm || confirm == true
|
|
88
|
+
details = { "reason" => "ask_first", "agent" => agent.to_s, "attempted" => { "type" => type, "title" => title } }
|
|
89
|
+
raise ConfirmationRequired.new("confirm", details)
|
|
79
90
|
end
|
|
80
91
|
end
|
|
81
92
|
episode_id = "episode_#{SecureRandom.hex(4)}"
|
|
@@ -83,22 +94,28 @@ module Agentf
|
|
|
83
94
|
metadata: metadata,
|
|
84
95
|
agent: agent,
|
|
85
96
|
type: type,
|
|
86
|
-
tags: tags,
|
|
87
97
|
entity_ids: entity_ids,
|
|
88
98
|
relationships: relationships,
|
|
89
99
|
parent_episode_id: parent_episode_id,
|
|
90
|
-
causal_from: causal_from
|
|
100
|
+
causal_from: causal_from,
|
|
101
|
+
outcome: outcome
|
|
91
102
|
)
|
|
103
|
+
supplied_embedding = parse_embedding(embedding)
|
|
104
|
+
embedding_vector = if supplied_embedding.any?
|
|
105
|
+
normalize_vector_dimensions(supplied_embedding)
|
|
106
|
+
else
|
|
107
|
+
embed_text(episode_embedding_text(title: title, description: description, context: context, code_snippet: code_snippet, metadata: normalized_metadata))
|
|
108
|
+
end
|
|
92
109
|
|
|
93
110
|
data = {
|
|
94
111
|
"id" => episode_id,
|
|
95
112
|
"type" => type,
|
|
113
|
+
"outcome" => normalize_outcome(outcome),
|
|
96
114
|
"title" => title,
|
|
97
115
|
"description" => description,
|
|
98
116
|
"project" => @project,
|
|
99
117
|
"context" => context,
|
|
100
118
|
"code_snippet" => code_snippet,
|
|
101
|
-
"tags" => tags,
|
|
102
119
|
"created_at" => Time.now.to_i,
|
|
103
120
|
"agent" => agent,
|
|
104
121
|
"related_task_id" => related_task_id || "",
|
|
@@ -106,7 +123,8 @@ module Agentf
|
|
|
106
123
|
"relationships" => relationships,
|
|
107
124
|
"parent_episode_id" => parent_episode_id.to_s,
|
|
108
125
|
"causal_from" => causal_from.to_s,
|
|
109
|
-
"metadata" => normalized_metadata
|
|
126
|
+
"metadata" => normalized_metadata,
|
|
127
|
+
"embedding" => embedding_vector
|
|
110
128
|
}
|
|
111
129
|
|
|
112
130
|
key = "episodic:#{episode_id}"
|
|
@@ -132,63 +150,34 @@ module Agentf
|
|
|
132
150
|
related_task_id: related_task_id,
|
|
133
151
|
relationships: relationships,
|
|
134
152
|
metadata: normalized_metadata,
|
|
135
|
-
tags: tags,
|
|
136
153
|
agent: agent
|
|
137
154
|
)
|
|
138
155
|
|
|
139
156
|
episode_id
|
|
140
157
|
end
|
|
141
158
|
|
|
142
|
-
|
|
159
|
+
def store_lesson(title:, description:, context: "", code_snippet: "", agent: Agentf::AgentRoles::ORCHESTRATOR, confirm: nil)
|
|
143
160
|
store_episode(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def store_pitfall(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER, confirm: nil)
|
|
156
|
-
store_episode(
|
|
157
|
-
type: "pitfall",
|
|
158
|
-
title: title,
|
|
159
|
-
description: description,
|
|
160
|
-
context: context,
|
|
161
|
-
code_snippet: code_snippet,
|
|
162
|
-
tags: tags,
|
|
163
|
-
agent: agent,
|
|
164
|
-
confirm: confirm
|
|
165
|
-
)
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def store_lesson(title:, description:, context: "", code_snippet: "", tags: [], agent: Agentf::AgentRoles::ENGINEER, confirm: nil)
|
|
169
|
-
store_episode(
|
|
170
|
-
type: "lesson",
|
|
171
|
-
title: title,
|
|
172
|
-
description: description,
|
|
173
|
-
context: context,
|
|
174
|
-
code_snippet: code_snippet,
|
|
175
|
-
tags: tags,
|
|
176
|
-
agent: agent,
|
|
177
|
-
confirm: confirm
|
|
178
|
-
)
|
|
179
|
-
end
|
|
161
|
+
type: "lesson",
|
|
162
|
+
title: title,
|
|
163
|
+
description: description,
|
|
164
|
+
context: context,
|
|
165
|
+
code_snippet: code_snippet,
|
|
166
|
+
agent: agent,
|
|
167
|
+
confirm: confirm
|
|
168
|
+
)
|
|
169
|
+
end
|
|
180
170
|
|
|
181
|
-
def store_business_intent(title:, description:, constraints: [],
|
|
171
|
+
def store_business_intent(title:, description:, constraints: [], agent: Agentf::AgentRoles::ORCHESTRATOR, priority: 1, confirm: nil)
|
|
182
172
|
context = constraints.any? ? "Constraints: #{constraints.join('; ')}" : ""
|
|
183
173
|
|
|
184
174
|
store_episode(
|
|
185
175
|
type: "business_intent",
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
confirm: confirm,
|
|
176
|
+
title: title,
|
|
177
|
+
description: description,
|
|
178
|
+
context: context,
|
|
179
|
+
agent: agent,
|
|
180
|
+
confirm: confirm,
|
|
192
181
|
metadata: {
|
|
193
182
|
"intent_kind" => "business",
|
|
194
183
|
"constraints" => constraints,
|
|
@@ -197,7 +186,7 @@ module Agentf
|
|
|
197
186
|
)
|
|
198
187
|
end
|
|
199
188
|
|
|
200
|
-
def store_feature_intent(title:, description:, acceptance_criteria: [], non_goals: [],
|
|
189
|
+
def store_feature_intent(title:, description:, acceptance_criteria: [], non_goals: [], agent: Agentf::AgentRoles::PLANNER,
|
|
201
190
|
related_task_id: nil, confirm: nil)
|
|
202
191
|
context_parts = []
|
|
203
192
|
context_parts << "Acceptance: #{acceptance_criteria.join('; ')}" if acceptance_criteria.any?
|
|
@@ -205,12 +194,11 @@ module Agentf
|
|
|
205
194
|
|
|
206
195
|
store_episode(
|
|
207
196
|
type: "feature_intent",
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
confirm: confirm,
|
|
197
|
+
title: title,
|
|
198
|
+
description: description,
|
|
199
|
+
context: context_parts.join(" | "),
|
|
200
|
+
agent: agent,
|
|
201
|
+
confirm: confirm,
|
|
214
202
|
related_task_id: related_task_id,
|
|
215
203
|
metadata: {
|
|
216
204
|
"intent_kind" => "feature",
|
|
@@ -220,15 +208,15 @@ module Agentf
|
|
|
220
208
|
)
|
|
221
209
|
end
|
|
222
210
|
|
|
223
|
-
def store_incident(title:, description:, root_cause: "", resolution: "",
|
|
224
|
-
business_capability: nil)
|
|
211
|
+
def store_incident(title:, description:, root_cause: "", resolution: "", agent: Agentf::AgentRoles::INCIDENT_RESPONDER,
|
|
212
|
+
business_capability: nil, confirm: nil)
|
|
225
213
|
store_episode(
|
|
226
214
|
type: "incident",
|
|
227
215
|
title: title,
|
|
228
216
|
description: description,
|
|
229
217
|
context: ["Root cause: #{root_cause}", "Resolution: #{resolution}"].reject { |entry| entry.end_with?(": ") }.join(" | "),
|
|
230
|
-
tags: tags,
|
|
231
218
|
agent: agent,
|
|
219
|
+
confirm: confirm,
|
|
232
220
|
metadata: {
|
|
233
221
|
"root_cause" => root_cause,
|
|
234
222
|
"resolution" => resolution,
|
|
@@ -238,14 +226,14 @@ module Agentf
|
|
|
238
226
|
)
|
|
239
227
|
end
|
|
240
228
|
|
|
241
|
-
def store_playbook(title:, description:, steps: [],
|
|
229
|
+
def store_playbook(title:, description:, steps: [], agent: Agentf::AgentRoles::PLANNER, feature_area: nil, confirm: nil)
|
|
242
230
|
store_episode(
|
|
243
231
|
type: "playbook",
|
|
244
232
|
title: title,
|
|
245
233
|
description: description,
|
|
246
234
|
context: steps.any? ? "Steps: #{steps.join('; ')}" : "",
|
|
247
|
-
tags: tags,
|
|
248
235
|
agent: agent,
|
|
236
|
+
confirm: confirm,
|
|
249
237
|
metadata: {
|
|
250
238
|
"steps" => steps,
|
|
251
239
|
"feature_area" => feature_area,
|
|
@@ -306,13 +294,18 @@ module Agentf
|
|
|
306
294
|
end
|
|
307
295
|
|
|
308
296
|
def get_relevant_context(agent:, query_embedding: nil, task_type: nil, limit: 8)
|
|
309
|
-
get_agent_context(agent: agent, query_embedding: query_embedding, task_type: task_type, limit: limit)
|
|
297
|
+
get_agent_context(agent: agent, query_embedding: query_embedding, query_text: nil, task_type: task_type, limit: limit)
|
|
310
298
|
end
|
|
311
299
|
|
|
312
|
-
def get_agent_context(agent:, query_embedding: nil, task_type: nil, limit: 8)
|
|
300
|
+
def get_agent_context(agent:, query_embedding: nil, query_text: nil, task_type: nil, limit: 8)
|
|
313
301
|
profile = context_profile(agent)
|
|
314
|
-
|
|
315
|
-
|
|
302
|
+
query_vector = normalized_query_embedding(query_embedding: query_embedding, query_text: query_text)
|
|
303
|
+
candidates = if vector_search_supported? && query_vector.any?
|
|
304
|
+
vector_search_candidates(query_embedding: query_vector, limit: DEFAULT_SEMANTIC_SCAN_LIMIT)
|
|
305
|
+
else
|
|
306
|
+
collect_episode_records(scope: "project").sort_by { |mem| -(mem["created_at"] || 0) }.first(DEFAULT_SEMANTIC_SCAN_LIMIT)
|
|
307
|
+
end
|
|
308
|
+
ranked = rank_memories(candidates: candidates, agent: agent, profile: profile, query_embedding: query_vector)
|
|
316
309
|
|
|
317
310
|
{
|
|
318
311
|
"agent" => agent,
|
|
@@ -323,12 +316,11 @@ module Agentf
|
|
|
323
316
|
}
|
|
324
317
|
end
|
|
325
318
|
|
|
326
|
-
def
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
end
|
|
319
|
+
def get_episodes(limit: 10, outcome: nil)
|
|
320
|
+
memories = fetch_memories_without_search(limit: [limit * 8, DEFAULT_SEMANTIC_SCAN_LIMIT].min)
|
|
321
|
+
memories = memories.select { |mem| mem["type"] == "episode" }
|
|
322
|
+
memories = memories.select { |mem| mem["outcome"].to_s == normalize_outcome(outcome) } if outcome
|
|
323
|
+
memories.first(limit)
|
|
332
324
|
end
|
|
333
325
|
|
|
334
326
|
def get_recent_memories(limit: 10)
|
|
@@ -339,16 +331,6 @@ module Agentf
|
|
|
339
331
|
end
|
|
340
332
|
end
|
|
341
333
|
|
|
342
|
-
def get_all_tags
|
|
343
|
-
memories = get_recent_memories(limit: 100)
|
|
344
|
-
all_tags = Set.new
|
|
345
|
-
memories.each do |mem|
|
|
346
|
-
tags = mem["tags"]
|
|
347
|
-
all_tags.merge(tags) if tags.is_a?(Array)
|
|
348
|
-
end
|
|
349
|
-
all_tags.to_a
|
|
350
|
-
end
|
|
351
|
-
|
|
352
334
|
def delete_memory_by_id(id:, scope: "project", dry_run: false)
|
|
353
335
|
normalized_scope = normalize_scope(scope)
|
|
354
336
|
episode_id = normalize_episode_id(id)
|
|
@@ -411,7 +393,7 @@ module Agentf
|
|
|
411
393
|
)
|
|
412
394
|
end
|
|
413
395
|
|
|
414
|
-
def store_edge(source_id:, target_id:, relation:, weight: 1.0,
|
|
396
|
+
def store_edge(source_id:, target_id:, relation:, weight: 1.0, agent: Agentf::AgentRoles::ORCHESTRATOR, metadata: {})
|
|
415
397
|
edge_id = "edge_#{SecureRandom.hex(5)}"
|
|
416
398
|
data = {
|
|
417
399
|
"id" => edge_id,
|
|
@@ -419,7 +401,6 @@ module Agentf
|
|
|
419
401
|
"target_id" => target_id,
|
|
420
402
|
"relation" => relation,
|
|
421
403
|
"weight" => weight.to_f,
|
|
422
|
-
"tags" => tags,
|
|
423
404
|
"project" => @project,
|
|
424
405
|
"agent" => agent,
|
|
425
406
|
"metadata" => metadata,
|
|
@@ -471,34 +452,12 @@ module Agentf
|
|
|
471
452
|
end
|
|
472
453
|
|
|
473
454
|
def create_episodic_index
|
|
474
|
-
@client.call(
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
"$.type", "AS", "type", "TEXT",
|
|
481
|
-
"$.title", "AS", "title", "TEXT",
|
|
482
|
-
"$.description", "AS", "description", "TEXT",
|
|
483
|
-
"$.project", "AS", "project", "TAG",
|
|
484
|
-
"$.context", "AS", "context", "TEXT",
|
|
485
|
-
"$.code_snippet", "AS", "code_snippet", "TEXT",
|
|
486
|
-
"$.tags", "AS", "tags", "TAG",
|
|
487
|
-
"$.created_at", "AS", "created_at", "NUMERIC",
|
|
488
|
-
"$.agent", "AS", "agent", "TEXT",
|
|
489
|
-
"$.related_task_id", "AS", "related_task_id", "TEXT",
|
|
490
|
-
"$.metadata.intent_kind", "AS", "intent_kind", "TAG",
|
|
491
|
-
"$.metadata.priority", "AS", "priority", "NUMERIC",
|
|
492
|
-
"$.metadata.confidence", "AS", "confidence", "NUMERIC",
|
|
493
|
-
"$.metadata.business_capability", "AS", "business_capability", "TAG",
|
|
494
|
-
"$.metadata.feature_area", "AS", "feature_area", "TAG",
|
|
495
|
-
"$.metadata.agent_role", "AS", "agent_role", "TAG",
|
|
496
|
-
"$.metadata.division", "AS", "division", "TAG",
|
|
497
|
-
"$.metadata.specialty", "AS", "specialty", "TAG",
|
|
498
|
-
"$.entity_ids[*]", "AS", "entity_ids", "TAG",
|
|
499
|
-
"$.parent_episode_id", "AS", "parent_episode_id", "TEXT",
|
|
500
|
-
"$.causal_from", "AS", "causal_from", "TEXT"
|
|
501
|
-
)
|
|
455
|
+
@client.call("FT.CREATE", EPISODIC_INDEX, *episodic_index_schema(include_vector: true))
|
|
456
|
+
rescue Redis::CommandError => e
|
|
457
|
+
raise if index_already_exists?(e)
|
|
458
|
+
raise unless vector_query_unsupported?(e)
|
|
459
|
+
|
|
460
|
+
@client.call("FT.CREATE", EPISODIC_INDEX, *episodic_index_schema(include_vector: false))
|
|
502
461
|
end
|
|
503
462
|
|
|
504
463
|
def create_edge_index
|
|
@@ -514,38 +473,19 @@ module Agentf
|
|
|
514
473
|
"$.project", "AS", "project", "TAG",
|
|
515
474
|
"$.agent", "AS", "agent", "TAG",
|
|
516
475
|
"$.weight", "AS", "weight", "NUMERIC",
|
|
517
|
-
"$.created_at", "AS", "created_at", "NUMERIC"
|
|
518
|
-
"$.tags", "AS", "tags", "TAG"
|
|
476
|
+
"$.created_at", "AS", "created_at", "NUMERIC"
|
|
519
477
|
)
|
|
520
478
|
end
|
|
521
479
|
|
|
522
480
|
def search_episodic(query:, limit:)
|
|
523
481
|
results = @client.call(
|
|
524
|
-
"FT.SEARCH",
|
|
482
|
+
"FT.SEARCH", EPISODIC_INDEX,
|
|
525
483
|
query,
|
|
526
484
|
"SORTBY", "created_at", "DESC",
|
|
527
485
|
"LIMIT", "0", limit.to_s
|
|
528
486
|
)
|
|
529
487
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
memories = []
|
|
533
|
-
(2...results.length).step(2) do |i|
|
|
534
|
-
item = results[i]
|
|
535
|
-
if item.is_a?(Array)
|
|
536
|
-
item.each_with_index do |part, j|
|
|
537
|
-
if part == "$" && j + 1 < item.length
|
|
538
|
-
begin
|
|
539
|
-
memory = JSON.parse(item[j + 1])
|
|
540
|
-
memories << memory
|
|
541
|
-
rescue JSON::ParserError
|
|
542
|
-
# Skip invalid JSON
|
|
543
|
-
end
|
|
544
|
-
end
|
|
545
|
-
end
|
|
546
|
-
end
|
|
547
|
-
end
|
|
548
|
-
memories
|
|
488
|
+
parse_search_results(results)
|
|
549
489
|
end
|
|
550
490
|
|
|
551
491
|
def index_already_exists?(error)
|
|
@@ -579,7 +519,7 @@ module Agentf
|
|
|
579
519
|
end
|
|
580
520
|
|
|
581
521
|
def detect_search_support
|
|
582
|
-
@client.call("FT.INFO",
|
|
522
|
+
@client.call("FT.INFO", EPISODIC_INDEX)
|
|
583
523
|
true
|
|
584
524
|
rescue Redis::CommandError => e
|
|
585
525
|
return true if index_missing_error?(e)
|
|
@@ -588,6 +528,28 @@ module Agentf
|
|
|
588
528
|
raise Redis::CommandError, "Failed to check RediSearch availability: #{e.message}"
|
|
589
529
|
end
|
|
590
530
|
|
|
531
|
+
def detect_vector_search_support
|
|
532
|
+
return false unless @search_supported
|
|
533
|
+
|
|
534
|
+
info = @client.call("FT.INFO", EPISODIC_INDEX)
|
|
535
|
+
return false unless info.to_s.upcase.include?("VECTOR")
|
|
536
|
+
|
|
537
|
+
@client.call(
|
|
538
|
+
"FT.SEARCH", EPISODIC_INDEX,
|
|
539
|
+
"*=>[KNN 1 @embedding $query_vector AS vector_distance]",
|
|
540
|
+
"PARAMS", "2", "query_vector", pack_vector(Array.new(VECTOR_DIMENSIONS, 0.0)),
|
|
541
|
+
"SORTBY", "vector_distance", "ASC",
|
|
542
|
+
"RETURN", "2", "$", "vector_distance",
|
|
543
|
+
"DIALECT", "2",
|
|
544
|
+
"LIMIT", "0", "1"
|
|
545
|
+
)
|
|
546
|
+
true
|
|
547
|
+
rescue Redis::CommandError => e
|
|
548
|
+
return false if index_missing_error?(e) || vector_query_unsupported?(e)
|
|
549
|
+
|
|
550
|
+
raise Redis::CommandError, "Failed to check Redis vector search availability: #{e.message}"
|
|
551
|
+
end
|
|
552
|
+
|
|
591
553
|
def index_missing_error?(error)
|
|
592
554
|
message = error.message
|
|
593
555
|
return false unless message
|
|
@@ -617,7 +579,7 @@ module Agentf
|
|
|
617
579
|
cursor, batch = @client.scan(cursor, match: "episodic:*", count: 100)
|
|
618
580
|
batch.each do |key|
|
|
619
581
|
memory = load_episode(key)
|
|
620
|
-
memories << memory if memory
|
|
582
|
+
memories << memory if memory && memory["project"].to_s == @project.to_s
|
|
621
583
|
end
|
|
622
584
|
break if cursor == "0"
|
|
623
585
|
end
|
|
@@ -628,21 +590,21 @@ module Agentf
|
|
|
628
590
|
def context_profile(agent)
|
|
629
591
|
case agent.to_s.upcase
|
|
630
592
|
when Agentf::AgentRoles::PLANNER
|
|
631
|
-
{ "preferred_types" => %w[business_intent feature_intent lesson playbook
|
|
593
|
+
{ "preferred_types" => %w[business_intent feature_intent lesson playbook episode], "negative_outcome_penalty" => 0.1 }
|
|
632
594
|
when Agentf::AgentRoles::ENGINEER
|
|
633
|
-
{ "preferred_types" => %w[playbook
|
|
595
|
+
{ "preferred_types" => %w[playbook episode lesson], "negative_outcome_penalty" => 0.05 }
|
|
634
596
|
when Agentf::AgentRoles::QA_TESTER
|
|
635
|
-
{ "preferred_types" => %w[lesson
|
|
597
|
+
{ "preferred_types" => %w[lesson episode incident], "negative_outcome_penalty" => 0.0 }
|
|
636
598
|
when Agentf::AgentRoles::INCIDENT_RESPONDER
|
|
637
|
-
{ "preferred_types" => %w[incident
|
|
599
|
+
{ "preferred_types" => %w[incident episode lesson], "negative_outcome_penalty" => 0.0 }
|
|
638
600
|
when Agentf::AgentRoles::SECURITY_REVIEWER
|
|
639
|
-
{ "preferred_types" => %w[
|
|
601
|
+
{ "preferred_types" => %w[episode lesson incident], "negative_outcome_penalty" => 0.0 }
|
|
640
602
|
else
|
|
641
|
-
{ "preferred_types" => %w[lesson
|
|
603
|
+
{ "preferred_types" => %w[lesson episode business_intent feature_intent], "negative_outcome_penalty" => 0.05 }
|
|
642
604
|
end
|
|
643
605
|
end
|
|
644
606
|
|
|
645
|
-
def rank_memories(candidates:, agent:, profile:)
|
|
607
|
+
def rank_memories(candidates:, agent:, profile:, query_embedding: nil)
|
|
646
608
|
now = Time.now.to_i
|
|
647
609
|
preferred_types = Array(profile["preferred_types"])
|
|
648
610
|
|
|
@@ -654,19 +616,41 @@ module Agentf
|
|
|
654
616
|
confidence = metadata.fetch("confidence", 0.6).to_f
|
|
655
617
|
confidence = 0.0 if confidence.negative?
|
|
656
618
|
confidence = 1.0 if confidence > 1.0
|
|
619
|
+
semantic_score = cosine_similarity(query_embedding, parse_embedding(memory["embedding"]))
|
|
657
620
|
|
|
658
621
|
type_score = preferred_types.include?(type) ? 1.0 : 0.25
|
|
659
622
|
agent_score = (memory["agent"] == agent || memory["agent"] == Agentf::AgentRoles::ORCHESTRATOR) ? 1.0 : 0.2
|
|
660
623
|
age_seconds = [now - memory.fetch("created_at", now).to_i, 0].max
|
|
661
624
|
recency_score = 1.0 / (1.0 + (age_seconds / 86_400.0))
|
|
662
625
|
|
|
663
|
-
|
|
664
|
-
memory["rank_score"] = ((0.
|
|
626
|
+
negative_outcome_penalty = memory["outcome"] == "negative" ? profile.fetch("negative_outcome_penalty", 0.0).to_f : 0.0
|
|
627
|
+
memory["rank_score"] = ((0.4 * semantic_score) + (0.22 * type_score) + (0.18 * agent_score) + (0.15 * recency_score) + (0.05 * confidence) - negative_outcome_penalty).round(6)
|
|
665
628
|
memory
|
|
666
629
|
end
|
|
667
630
|
.sort_by { |memory| -memory["rank_score"] }
|
|
668
631
|
end
|
|
669
632
|
|
|
633
|
+
public def search_memories(query:, limit: 10, type: nil, agent: nil, outcome: nil)
|
|
634
|
+
query_vector = embed_text(query)
|
|
635
|
+
candidates = if vector_search_supported? && query_vector.any?
|
|
636
|
+
native = vector_search_episodes(query_embedding: query_vector, limit: limit, type: type, agent: agent, outcome: outcome)
|
|
637
|
+
native.empty? ? collect_episode_records(scope: "project", type: type, agent: agent) : native
|
|
638
|
+
else
|
|
639
|
+
collect_episode_records(scope: "project", type: type, agent: agent)
|
|
640
|
+
end
|
|
641
|
+
candidates = candidates.select { |mem| mem["outcome"].to_s == normalize_outcome(outcome) } if outcome
|
|
642
|
+
|
|
643
|
+
ranked = candidates.map do |memory|
|
|
644
|
+
score = cosine_similarity(query_vector, parse_embedding(memory["embedding"]))
|
|
645
|
+
lexical = lexical_overlap_score(query, memory)
|
|
646
|
+
next if score <= 0 && lexical <= 0
|
|
647
|
+
|
|
648
|
+
memory.merge("score" => ((0.75 * score) + (0.25 * lexical)).round(6))
|
|
649
|
+
end.compact
|
|
650
|
+
|
|
651
|
+
ranked.sort_by { |memory| -memory["score"] }.first(limit)
|
|
652
|
+
end
|
|
653
|
+
|
|
670
654
|
def load_episode(key)
|
|
671
655
|
raw = if @json_supported
|
|
672
656
|
begin
|
|
@@ -701,6 +685,37 @@ module Agentf
|
|
|
701
685
|
[]
|
|
702
686
|
end
|
|
703
687
|
|
|
688
|
+
def parse_search_results(results)
|
|
689
|
+
return [] unless results && results[0].to_i.positive?
|
|
690
|
+
|
|
691
|
+
records = []
|
|
692
|
+
(2...results.length).step(2) do |i|
|
|
693
|
+
item = results[i]
|
|
694
|
+
next unless item.is_a?(Array)
|
|
695
|
+
|
|
696
|
+
record = {}
|
|
697
|
+
item.each_slice(2) do |field, value|
|
|
698
|
+
next if value.nil?
|
|
699
|
+
|
|
700
|
+
if field == "$"
|
|
701
|
+
begin
|
|
702
|
+
payload = JSON.parse(value)
|
|
703
|
+
record.merge!(payload) if payload.is_a?(Hash)
|
|
704
|
+
rescue JSON::ParserError
|
|
705
|
+
record = nil
|
|
706
|
+
break
|
|
707
|
+
end
|
|
708
|
+
else
|
|
709
|
+
record[field] = value
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
records << record if record.is_a?(Hash) && record.any?
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
records
|
|
717
|
+
end
|
|
718
|
+
|
|
704
719
|
def cosine_similarity(a, b)
|
|
705
720
|
return 0.0 if a.empty? || b.empty? || a.length != b.length
|
|
706
721
|
|
|
@@ -748,6 +763,53 @@ module Agentf
|
|
|
748
763
|
memories
|
|
749
764
|
end
|
|
750
765
|
|
|
766
|
+
def vector_search_episodes(query_embedding:, limit:, type: nil, agent: nil, outcome: nil)
|
|
767
|
+
return [] unless vector_search_supported?
|
|
768
|
+
|
|
769
|
+
requested_limit = [limit.to_i, 1].max
|
|
770
|
+
search_limit = [requested_limit * 4, 10].max
|
|
771
|
+
filters = ["@project:{#{escape_tag(@project)}}"]
|
|
772
|
+
normalized_outcome = normalize_outcome(outcome)
|
|
773
|
+
filters << "@outcome:{#{escape_tag(normalized_outcome)}}" if normalized_outcome
|
|
774
|
+
base_query = filters.join(" ")
|
|
775
|
+
|
|
776
|
+
results = @client.call(
|
|
777
|
+
"FT.SEARCH", EPISODIC_INDEX,
|
|
778
|
+
"#{base_query}=>[KNN #{search_limit} @embedding $query_vector AS vector_distance]",
|
|
779
|
+
"PARAMS", "2", "query_vector", pack_vector(query_embedding),
|
|
780
|
+
"SORTBY", "vector_distance", "ASC",
|
|
781
|
+
"RETURN", "2", "$", "vector_distance",
|
|
782
|
+
"DIALECT", "2",
|
|
783
|
+
"LIMIT", "0", search_limit.to_s
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
parse_search_results(results)
|
|
787
|
+
.select do |memory|
|
|
788
|
+
next false unless memory["project"].to_s == @project.to_s
|
|
789
|
+
next false unless type.to_s.empty? || memory["type"].to_s == type.to_s
|
|
790
|
+
next false unless agent.to_s.empty? || memory["agent"].to_s == agent.to_s
|
|
791
|
+
next false unless normalized_outcome.nil? || memory["outcome"].to_s == normalized_outcome
|
|
792
|
+
|
|
793
|
+
true
|
|
794
|
+
end
|
|
795
|
+
.each { |memory| memory["vector_distance"] = memory["vector_distance"].to_f if memory.key?("vector_distance") }
|
|
796
|
+
.first(requested_limit)
|
|
797
|
+
rescue Redis::CommandError => e
|
|
798
|
+
if vector_query_unsupported?(e)
|
|
799
|
+
@vector_search_supported = false
|
|
800
|
+
return []
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
raise
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def vector_search_candidates(query_embedding:, limit:)
|
|
807
|
+
native = vector_search_episodes(query_embedding: query_embedding, limit: limit)
|
|
808
|
+
return native if native.any?
|
|
809
|
+
|
|
810
|
+
collect_episode_records(scope: "project").sort_by { |mem| -(mem["created_at"] || 0) }.first(DEFAULT_SEMANTIC_SCAN_LIMIT)
|
|
811
|
+
end
|
|
812
|
+
|
|
751
813
|
def collect_related_edge_keys(episode_ids:, scope:)
|
|
752
814
|
ids = episode_ids.map(&:to_s).reject(&:empty?).to_set
|
|
753
815
|
return [] if ids.empty?
|
|
@@ -844,9 +906,9 @@ module Agentf
|
|
|
844
906
|
}
|
|
845
907
|
end
|
|
846
908
|
|
|
847
|
-
def persist_relationship_edges(episode_id:, related_task_id:, relationships:, metadata:,
|
|
909
|
+
def persist_relationship_edges(episode_id:, related_task_id:, relationships:, metadata:, agent:)
|
|
848
910
|
if related_task_id && !related_task_id.to_s.strip.empty?
|
|
849
|
-
store_edge(source_id: episode_id, target_id: related_task_id, relation: "relates_to",
|
|
911
|
+
store_edge(source_id: episode_id, target_id: related_task_id, relation: "relates_to", agent: agent)
|
|
850
912
|
end
|
|
851
913
|
|
|
852
914
|
Array(relationships).each do |relation|
|
|
@@ -861,7 +923,6 @@ module Agentf
|
|
|
861
923
|
target_id: target,
|
|
862
924
|
relation: relation_type,
|
|
863
925
|
weight: (relation["weight"] || relation[:weight] || 1.0).to_f,
|
|
864
|
-
tags: tags,
|
|
865
926
|
agent: agent,
|
|
866
927
|
metadata: { "source_metadata" => extract_metadata_slice(metadata, %w[intent_kind agent_role division]) }
|
|
867
928
|
)
|
|
@@ -869,58 +930,89 @@ module Agentf
|
|
|
869
930
|
|
|
870
931
|
parent = metadata["parent_episode_id"].to_s
|
|
871
932
|
unless parent.empty?
|
|
872
|
-
store_edge(source_id: episode_id, target_id: parent, relation: "child_of",
|
|
933
|
+
store_edge(source_id: episode_id, target_id: parent, relation: "child_of", agent: agent)
|
|
873
934
|
end
|
|
874
935
|
|
|
875
936
|
causal_from = metadata["causal_from"].to_s
|
|
876
937
|
unless causal_from.empty?
|
|
877
|
-
store_edge(source_id: episode_id, target_id: causal_from, relation: "caused_by",
|
|
938
|
+
store_edge(source_id: episode_id, target_id: causal_from, relation: "caused_by", agent: agent)
|
|
878
939
|
end
|
|
879
940
|
rescue StandardError
|
|
880
941
|
nil
|
|
881
942
|
end
|
|
882
943
|
|
|
883
|
-
def enrich_metadata(metadata:, agent:, type:,
|
|
944
|
+
def enrich_metadata(metadata:, agent:, type:, entity_ids:, relationships:, parent_episode_id:, causal_from:, outcome:)
|
|
884
945
|
base = metadata.is_a?(Hash) ? metadata.dup : {}
|
|
885
946
|
base["agent_role"] = agent
|
|
886
947
|
base["division"] = infer_division(agent)
|
|
887
948
|
base["specialty"] = infer_specialty(agent)
|
|
888
949
|
base["capabilities"] = infer_capabilities(agent)
|
|
889
950
|
base["episode_type"] = type
|
|
890
|
-
base["tag_count"] = Array(tags).length
|
|
891
951
|
base["relationship_count"] = Array(relationships).length
|
|
892
952
|
base["entity_ids"] = Array(entity_ids)
|
|
953
|
+
base["outcome"] = normalize_outcome(outcome) if outcome
|
|
893
954
|
base["parent_episode_id"] = parent_episode_id.to_s unless parent_episode_id.to_s.empty?
|
|
894
955
|
base["causal_from"] = causal_from.to_s unless causal_from.to_s.empty?
|
|
895
956
|
base
|
|
896
957
|
end
|
|
897
958
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
# memories. Agent classes register policy boundaries on their class
|
|
902
|
-
# definitions (see agents/*). When agent is provided as a role string
|
|
903
|
-
# (eg. "ENGINEER") we try to match against known agent classes.
|
|
904
|
-
def agent_requires_confirmation?(agent)
|
|
905
|
-
begin
|
|
906
|
-
# If a developer passed an agent class-like string, try to map it to
|
|
907
|
-
# the loaded agent class and inspect its policy_boundaries.
|
|
908
|
-
candidate = Agentf::Agents.constants
|
|
909
|
-
.map { |c| Agentf::Agents.const_get(c) }
|
|
910
|
-
.find do |klass|
|
|
911
|
-
klass.is_a?(Class) && klass.respond_to?(:policy_boundaries) && klass.typed_name == agent
|
|
912
|
-
end
|
|
959
|
+
def normalized_query_embedding(query_embedding:, query_text:)
|
|
960
|
+
embedded = parse_embedding(query_embedding)
|
|
961
|
+
return embedded if embedded.any?
|
|
913
962
|
|
|
914
|
-
|
|
963
|
+
embed_text(query_text)
|
|
964
|
+
end
|
|
915
965
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
966
|
+
def episode_embedding_text(title:, description:, context:, code_snippet:, metadata:)
|
|
967
|
+
[
|
|
968
|
+
title,
|
|
969
|
+
description,
|
|
970
|
+
context,
|
|
971
|
+
code_snippet,
|
|
972
|
+
metadata["feature_area"],
|
|
973
|
+
metadata["business_capability"],
|
|
974
|
+
metadata["intent_kind"],
|
|
975
|
+
metadata["resolution"],
|
|
976
|
+
metadata["root_cause"]
|
|
977
|
+
].compact.join("\n")
|
|
922
978
|
end
|
|
923
979
|
|
|
980
|
+
def lexical_overlap_score(query, memory)
|
|
981
|
+
query_tokens = normalize_tokens(query)
|
|
982
|
+
return 0.0 if query_tokens.empty?
|
|
983
|
+
|
|
984
|
+
memory_tokens = normalize_tokens([memory["title"], memory["description"], memory["context"], memory["code_snippet"]].compact.join(" "))
|
|
985
|
+
return 0.0 if memory_tokens.empty?
|
|
986
|
+
|
|
987
|
+
overlap = (query_tokens & memory_tokens).length
|
|
988
|
+
overlap.to_f / query_tokens.length.to_f
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
def normalize_tokens(text)
|
|
992
|
+
text.to_s.downcase.scan(/[a-z0-9_]+/).reject { |token| token.length < 2 }
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
def embed_text(text)
|
|
996
|
+
@embedding_provider.embed(text)
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
def normalize_outcome(value)
|
|
1000
|
+
normalized = value.to_s.strip.downcase
|
|
1001
|
+
return nil if normalized.empty?
|
|
1002
|
+
return "positive" if %w[positive success succeeded passed pass completed approved].include?(normalized)
|
|
1003
|
+
return "negative" if %w[negative failure failed fail pitfall error blocked violated].include?(normalized)
|
|
1004
|
+
return "neutral" if %w[neutral info informational lesson observation].include?(normalized)
|
|
1005
|
+
|
|
1006
|
+
normalized
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# NOTE: previous implementations exposed an `agent_requires_confirmation?`
|
|
1010
|
+
# helper here. That functionality is superseded by
|
|
1011
|
+
# `persistence_preference_for(agent)` which returns explicit preferences
|
|
1012
|
+
# (:always, :ask_first, :never) and is used by callers to decide whether
|
|
1013
|
+
# interactive confirmation is required. Keep this file lean and avoid
|
|
1014
|
+
# duplicate helpers.
|
|
1015
|
+
|
|
924
1016
|
# Inspect loaded agent classes for explicit persistence preference.
|
|
925
1017
|
# Returns one of: :always, :ask_first, :never, or nil when unknown.
|
|
926
1018
|
def persistence_preference_for(agent)
|
|
@@ -934,9 +1026,15 @@ module Agentf
|
|
|
934
1026
|
return nil unless candidate
|
|
935
1027
|
|
|
936
1028
|
boundaries = candidate.policy_boundaries
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1029
|
+
persist_pattern = /persist|store|save/i
|
|
1030
|
+
|
|
1031
|
+
never_matches = Array(boundaries["never"]).select { |s| s =~ persist_pattern }
|
|
1032
|
+
ask_matches = Array(boundaries["ask_first"]).select { |s| s =~ persist_pattern }
|
|
1033
|
+
always_matches = Array(boundaries["always"]).select { |s| s =~ persist_pattern }
|
|
1034
|
+
|
|
1035
|
+
return :never if never_matches.any?
|
|
1036
|
+
return :ask_first if ask_matches.any?
|
|
1037
|
+
return :always if always_matches.any? && ask_matches.empty?
|
|
940
1038
|
nil
|
|
941
1039
|
rescue StandardError
|
|
942
1040
|
nil
|
|
@@ -1071,24 +1169,7 @@ module Agentf
|
|
|
1071
1169
|
"SORTBY", "created_at", "DESC",
|
|
1072
1170
|
"LIMIT", "0", limit.to_s
|
|
1073
1171
|
)
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
records = []
|
|
1077
|
-
(2...results.length).step(2) do |i|
|
|
1078
|
-
item = results[i]
|
|
1079
|
-
next unless item.is_a?(Array)
|
|
1080
|
-
|
|
1081
|
-
item.each_with_index do |part, j|
|
|
1082
|
-
next unless part == "$" && j + 1 < item.length
|
|
1083
|
-
|
|
1084
|
-
begin
|
|
1085
|
-
records << JSON.parse(item[j + 1])
|
|
1086
|
-
rescue JSON::ParserError
|
|
1087
|
-
nil
|
|
1088
|
-
end
|
|
1089
|
-
end
|
|
1090
|
-
end
|
|
1091
|
-
records
|
|
1172
|
+
parse_search_results(results)
|
|
1092
1173
|
end
|
|
1093
1174
|
|
|
1094
1175
|
def fetch_edges_without_search(node_id:, relation_filters:, limit:)
|
|
@@ -1115,6 +1196,65 @@ module Agentf
|
|
|
1115
1196
|
value.to_s.gsub(/[\-{}\[\]|\\]/) { |m| "\\#{m}" }
|
|
1116
1197
|
end
|
|
1117
1198
|
|
|
1199
|
+
def episodic_index_schema(include_vector:)
|
|
1200
|
+
schema = [
|
|
1201
|
+
"ON", "JSON",
|
|
1202
|
+
"PREFIX", "1", "episodic:",
|
|
1203
|
+
"SCHEMA",
|
|
1204
|
+
"$.id", "AS", "id", "TEXT",
|
|
1205
|
+
"$.type", "AS", "type", "TEXT",
|
|
1206
|
+
"$.outcome", "AS", "outcome", "TAG",
|
|
1207
|
+
"$.title", "AS", "title", "TEXT",
|
|
1208
|
+
"$.description", "AS", "description", "TEXT",
|
|
1209
|
+
"$.project", "AS", "project", "TAG",
|
|
1210
|
+
"$.context", "AS", "context", "TEXT",
|
|
1211
|
+
"$.code_snippet", "AS", "code_snippet", "TEXT",
|
|
1212
|
+
"$.created_at", "AS", "created_at", "NUMERIC",
|
|
1213
|
+
"$.agent", "AS", "agent", "TEXT",
|
|
1214
|
+
"$.related_task_id", "AS", "related_task_id", "TEXT",
|
|
1215
|
+
"$.metadata.intent_kind", "AS", "intent_kind", "TAG",
|
|
1216
|
+
"$.metadata.priority", "AS", "priority", "NUMERIC",
|
|
1217
|
+
"$.metadata.confidence", "AS", "confidence", "NUMERIC",
|
|
1218
|
+
"$.metadata.business_capability", "AS", "business_capability", "TAG",
|
|
1219
|
+
"$.metadata.feature_area", "AS", "feature_area", "TAG",
|
|
1220
|
+
"$.metadata.agent_role", "AS", "agent_role", "TAG",
|
|
1221
|
+
"$.metadata.division", "AS", "division", "TAG",
|
|
1222
|
+
"$.metadata.specialty", "AS", "specialty", "TAG",
|
|
1223
|
+
"$.entity_ids[*]", "AS", "entity_ids", "TAG",
|
|
1224
|
+
"$.parent_episode_id", "AS", "parent_episode_id", "TEXT",
|
|
1225
|
+
"$.causal_from", "AS", "causal_from", "TEXT"
|
|
1226
|
+
]
|
|
1227
|
+
|
|
1228
|
+
return schema unless include_vector
|
|
1229
|
+
|
|
1230
|
+
schema + [
|
|
1231
|
+
"$.embedding", "AS", "embedding", "VECTOR", "FLAT", "6",
|
|
1232
|
+
"TYPE", "FLOAT32",
|
|
1233
|
+
"DIM", VECTOR_DIMENSIONS.to_s,
|
|
1234
|
+
"DISTANCE_METRIC", "COSINE"
|
|
1235
|
+
]
|
|
1236
|
+
end
|
|
1237
|
+
|
|
1238
|
+
def vector_search_supported?
|
|
1239
|
+
@search_supported && @vector_search_supported
|
|
1240
|
+
end
|
|
1241
|
+
|
|
1242
|
+
def normalize_vector_dimensions(vector)
|
|
1243
|
+
values = Array(vector).map(&:to_f).first(VECTOR_DIMENSIONS)
|
|
1244
|
+
values.fill(0.0, values.length...VECTOR_DIMENSIONS)
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
def pack_vector(vector)
|
|
1248
|
+
normalize_vector_dimensions(vector).pack("e*")
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
def vector_query_unsupported?(error)
|
|
1252
|
+
message = error.message.to_s.downcase
|
|
1253
|
+
return false if message.empty?
|
|
1254
|
+
|
|
1255
|
+
message.include?("vector") || message.include?("knn") || message.include?("dialect") || message.include?("syntax error")
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1118
1258
|
def extract_metadata_slice(metadata, keys)
|
|
1119
1259
|
keys.each_with_object({}) do |key, acc|
|
|
1120
1260
|
acc[key] = metadata[key] if metadata.key?(key)
|