brainiac 0.0.19 → 0.0.20
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/Gemfile.lock +2 -2
- data/lib/brainiac/intent.rb +166 -63
- data/lib/brainiac/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 619fac25bcbc560564a318e78421b8c826811abe7ba92e74de0b3b7104d8a712
|
|
4
|
+
data.tar.gz: acc359990201eaabfb5d755c17102867bce0a5a1bc68407c09ef3c4fcca94e7c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e44c64a94143b7eb8ff47312d8b889baf91d654a5341c2a5436329693e6ed700432d6f37428c4e44cddc0369d1c1453a6df69929ac577017b6fe1900c9fa7fe5
|
|
7
|
+
data.tar.gz: 64500e610c6cc4393a640d069c442c1bff5913ca36e61cc0d0f1323891419760aef1b2e9f7717a66326d731d4b2df139e17aa47ff90b5b0f05764b3402176a71
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
brainiac (0.0.
|
|
4
|
+
brainiac (0.0.20)
|
|
5
5
|
puma (~> 7.2)
|
|
6
6
|
rackup (~> 2.3)
|
|
7
7
|
sinatra (~> 4.1)
|
|
@@ -84,7 +84,7 @@ DEPENDENCIES
|
|
|
84
84
|
CHECKSUMS
|
|
85
85
|
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
86
86
|
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
|
87
|
-
brainiac (0.0.
|
|
87
|
+
brainiac (0.0.20)
|
|
88
88
|
json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
|
|
89
89
|
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
|
|
90
90
|
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
data/lib/brainiac/intent.rb
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
#
|
|
15
15
|
# "intent": {
|
|
16
16
|
# "enabled": true,
|
|
17
|
-
# "endpoint": "http://localhost:11434/api/
|
|
18
|
-
# "model": "
|
|
17
|
+
# "endpoint": "http://localhost:11434/api/chat",
|
|
18
|
+
# "model": "qwen3:1.7b",
|
|
19
19
|
# "timeout": 10,
|
|
20
20
|
# "temperature": 0.1
|
|
21
21
|
# }
|
|
@@ -29,40 +29,24 @@
|
|
|
29
29
|
|
|
30
30
|
INTENT_CONFIG_DEFAULTS = {
|
|
31
31
|
"enabled" => false,
|
|
32
|
-
"endpoint" => "http://localhost:11434/api/
|
|
33
|
-
"model" => "
|
|
32
|
+
"endpoint" => "http://localhost:11434/api/chat",
|
|
33
|
+
"model" => "qwen2.5:3b",
|
|
34
34
|
"timeout" => 10,
|
|
35
|
-
"temperature" => 0.
|
|
35
|
+
"temperature" => 0.0
|
|
36
36
|
}.freeze
|
|
37
37
|
|
|
38
38
|
INTENT_PROMPT_TEMPLATE = <<~PROMPT
|
|
39
|
-
|
|
39
|
+
Agents in this chat: {{AGENT_ROSTER}}
|
|
40
|
+
{{LAST_RESPONDER}}
|
|
40
41
|
|
|
41
|
-
{{
|
|
42
|
+
Human's message: "{{MESSAGE}}"
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
- If the message addresses {{AGENT_NAME}} by name, gives {{AGENT_NAME}} instructions, or asks {{AGENT_NAME}} a question → yes
|
|
45
|
-
- If the message continues a conversation directed at {{AGENT_NAME}} (e.g. {{AGENT_NAME}} was the last to respond and the human follows up) → yes
|
|
46
|
-
- If the message starts with or addresses a DIFFERENT person/agent (not {{AGENT_NAME}}) → no
|
|
47
|
-
- If the message contains another agent's name (e.g. "Effie, ...", "Another one Effie", "Hey Galen") and does NOT contain "{{AGENT_NAME}}" → no
|
|
48
|
-
- If the message is humans talking to each other and {{AGENT_NAME}} is not being addressed → no
|
|
49
|
-
- If the message is a simple acknowledgment ("thanks", "ok", "got it") directed at {{AGENT_NAME}}'s previous work → no
|
|
50
|
-
- If the message is asking a question to another person or agent (not {{AGENT_NAME}}) → no
|
|
51
|
-
- If the message is responding to or commenting on what someone OTHER than {{AGENT_NAME}} just said → no
|
|
52
|
-
- If the conversation context shows a DIFFERENT agent was the last to respond, and the human's follow-up does not mention {{AGENT_NAME}} by name → no
|
|
53
|
-
- If uncertain, lean toward yes (better to respond unnecessarily than miss a request)
|
|
54
|
-
|
|
55
|
-
Critical: "addresses someone else" means someone whose name is NOT {{AGENT_NAME}}. If the message says "{{AGENT_NAME}}, ..." that IS addressed to {{AGENT_NAME}} → yes.
|
|
56
|
-
|
|
57
|
-
Respond with ONLY "yes" or "no" — nothing else.
|
|
58
|
-
|
|
59
|
-
Context:
|
|
60
|
-
{{CONTEXT}}
|
|
61
|
-
|
|
62
|
-
Latest message:
|
|
63
|
-
{{MESSAGE}}
|
|
44
|
+
Is this message directed at {{AGENT_NAME}}? Answer yes or no.
|
|
64
45
|
PROMPT
|
|
65
46
|
|
|
47
|
+
# Words/phrases that indicate pure acknowledgment — no action needed from the agent.
|
|
48
|
+
ACKNOWLEDGMENT_PATTERN = /\A\s*(thanks|thank you|thx|ty|ok|okay|k|got it|sounds good|cool|nice|👍|🙏|✅)\s*[.!]?\s*\z/i
|
|
49
|
+
|
|
66
50
|
PENDING_WORK_PROMPT_TEMPLATE = <<~PROMPT
|
|
67
51
|
You are analyzing a message posted by an AI agent named {{AGENT_NAME}}. Your job: determine if this message indicates the agent intends to do more work that hasn't been completed yet.
|
|
68
52
|
|
|
@@ -138,6 +122,13 @@ end
|
|
|
138
122
|
|
|
139
123
|
# Check whether a message requires an agent to respond.
|
|
140
124
|
#
|
|
125
|
+
# Uses a layered approach:
|
|
126
|
+
# 1. Deterministic pre-checks (fast, no LLM):
|
|
127
|
+
# - If message names another agent → skip
|
|
128
|
+
# - If this agent was last to speak AND message isn't pure acknowledgment → respond
|
|
129
|
+
# - If a different agent was last to speak AND message doesn't name this agent → skip
|
|
130
|
+
# 2. LLM fallback (only for ambiguous cases — no agent spoke, or mixed signals)
|
|
131
|
+
#
|
|
141
132
|
# @param message [String] The message text to classify
|
|
142
133
|
# @param agent_name [String] The agent being addressed
|
|
143
134
|
# @param channel [String] Context description (e.g., "Discord thread", "Fizzy card comment")
|
|
@@ -156,61 +147,97 @@ def check_intent(message, agent_name:, channel: "conversation", context: nil)
|
|
|
156
147
|
return false
|
|
157
148
|
end
|
|
158
149
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
150
|
+
# Deterministic conversational-flow check: use last-responder detection to
|
|
151
|
+
# resolve the common case without hitting the LLM at all.
|
|
152
|
+
last_responder = detect_last_responder_name(context)
|
|
153
|
+
|
|
154
|
+
if last_responder
|
|
155
|
+
is_acknowledgment = message.strip.match?(ACKNOWLEDGMENT_PATTERN)
|
|
156
|
+
|
|
157
|
+
if last_responder.downcase == agent_name.downcase
|
|
158
|
+
# This agent was the last to speak — human is continuing with us
|
|
159
|
+
if is_acknowledgment
|
|
160
|
+
LOG.info "[Intent] Deterministic skip for #{agent_name} — pure acknowledgment, no action needed"
|
|
161
|
+
return false
|
|
162
|
+
end
|
|
163
|
+
LOG.info "[Intent] Deterministic respond for #{agent_name} — was last to speak, human continues"
|
|
164
|
+
return true
|
|
165
|
+
else
|
|
166
|
+
# A different agent was the last to speak — this message is probably for them
|
|
167
|
+
# UNLESS it explicitly names this agent (already checked above via intent_names_other_agent? for OTHER agents,
|
|
168
|
+
# but also check if it mentions THIS agent's name directly)
|
|
169
|
+
if message_mentions_agent?(message, agent_name)
|
|
170
|
+
LOG.info "[Intent] Deterministic respond for #{agent_name} — named in message despite #{last_responder} speaking last"
|
|
171
|
+
return true
|
|
172
|
+
end
|
|
173
|
+
LOG.info "[Intent] Deterministic skip for #{agent_name} — #{last_responder} was last to speak"
|
|
174
|
+
return false
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# No clear last responder detected — fall through to LLM for classification.
|
|
179
|
+
classify_intent_via_llm(message, agent_name:, channel:, config:)
|
|
180
|
+
rescue OllamaModelNotFoundError => e
|
|
181
|
+
LOG.error "[Intent] #{e.message}"
|
|
182
|
+
LOG.error "[Intent] Disabling intent classification until model is installed."
|
|
183
|
+
BRAINIAC_CONFIG["intent"] ||= {}
|
|
184
|
+
BRAINIAC_CONFIG["intent"]["enabled"] = false
|
|
185
|
+
true
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
LOG.warn "[Intent] Classification failed (fail-open): #{e.message}"
|
|
188
|
+
true
|
|
189
|
+
end
|
|
167
190
|
|
|
168
|
-
|
|
191
|
+
# Fall through to LLM for intent classification when deterministic checks are inconclusive.
|
|
192
|
+
# This handles cases like: no agents have spoken yet, or context is unavailable.
|
|
193
|
+
def classify_intent_via_llm(message, agent_name:, channel:, config:)
|
|
169
194
|
roster_block = build_intent_agent_roster(agent_name)
|
|
195
|
+
last_responder_block = "No agent has spoken recently."
|
|
170
196
|
|
|
171
197
|
prompt = INTENT_PROMPT_TEMPLATE
|
|
172
198
|
.gsub("{{AGENT_NAME}}", agent_name)
|
|
173
|
-
.gsub("{{CHANNEL}}", channel)
|
|
174
199
|
.gsub("{{AGENT_ROSTER}}", roster_block)
|
|
175
|
-
.gsub("{{
|
|
200
|
+
.gsub("{{LAST_RESPONDER}}", last_responder_block)
|
|
176
201
|
.gsub("{{MESSAGE}}", message.strip)
|
|
177
202
|
|
|
178
203
|
LOG.info "[Intent] Checking intent for #{agent_name} (#{channel}): #{message.strip.slice(0, 80)}..."
|
|
179
204
|
LOG.debug "[Intent] Full prompt:\n#{prompt}" if LOG.debug?
|
|
180
|
-
response = query_local_llm(prompt, config)
|
|
205
|
+
response = query_local_llm(prompt, config, system: "Answer yes or no only.")
|
|
181
206
|
result = positive_intent?(response)
|
|
182
207
|
LOG.info "[Intent] Result for #{agent_name}: #{result ? "RESPOND" : "SKIP"} (model: #{config["model"]})"
|
|
183
208
|
result
|
|
184
|
-
rescue OllamaModelNotFoundError => e
|
|
185
|
-
LOG.error "[Intent] #{e.message}"
|
|
186
|
-
LOG.error "[Intent] Disabling intent classification until model is installed."
|
|
187
|
-
BRAINIAC_CONFIG["intent"] ||= {}
|
|
188
|
-
BRAINIAC_CONFIG["intent"]["enabled"] = false
|
|
189
|
-
true
|
|
190
|
-
rescue StandardError => e
|
|
191
|
-
LOG.warn "[Intent] Classification failed (fail-open): #{e.message}"
|
|
192
|
-
true
|
|
193
209
|
end
|
|
194
210
|
|
|
195
211
|
# Query the local LLM via Ollama's HTTP API.
|
|
196
212
|
#
|
|
197
213
|
# @param prompt [String] The classification prompt
|
|
198
214
|
# @param config [Hash] Intent configuration
|
|
215
|
+
# @param system [String, nil] Optional system message for output format control
|
|
199
216
|
# @return [String] Raw response text from the model
|
|
200
|
-
def query_local_llm(prompt, config)
|
|
201
|
-
|
|
217
|
+
def query_local_llm(prompt, config, system: nil)
|
|
218
|
+
endpoint = config["endpoint"]
|
|
219
|
+
# Use /api/chat with think:false to disable thinking mode on models like Qwen3.
|
|
220
|
+
# The /api/generate endpoint always uses thinking tokens, which generates hundreds
|
|
221
|
+
# of hidden tokens for a simple yes/no answer (2.5s+ vs 0.2s with think:false).
|
|
222
|
+
chat_uri = URI(endpoint.sub(%r{/api/generate\z}, "/api/chat"))
|
|
223
|
+
|
|
224
|
+
messages = []
|
|
225
|
+
messages << { role: "system", content: system } if system
|
|
226
|
+
messages << { role: "user", content: prompt }
|
|
227
|
+
|
|
202
228
|
payload = {
|
|
203
229
|
model: config["model"],
|
|
204
|
-
|
|
230
|
+
messages: messages,
|
|
205
231
|
stream: false,
|
|
206
|
-
|
|
232
|
+
think: false,
|
|
233
|
+
options: { temperature: config["temperature"], num_predict: 5 }
|
|
207
234
|
}
|
|
208
235
|
|
|
209
|
-
http = Net::HTTP.new(
|
|
236
|
+
http = Net::HTTP.new(chat_uri.host, chat_uri.port)
|
|
210
237
|
http.open_timeout = config["timeout"]
|
|
211
238
|
http.read_timeout = config["timeout"]
|
|
212
239
|
|
|
213
|
-
request = Net::HTTP::Post.new(
|
|
240
|
+
request = Net::HTTP::Post.new(chat_uri.path, "Content-Type" => "application/json")
|
|
214
241
|
request.body = JSON.generate(payload)
|
|
215
242
|
|
|
216
243
|
response = http.request(request)
|
|
@@ -222,9 +249,9 @@ def query_local_llm(prompt, config)
|
|
|
222
249
|
end
|
|
223
250
|
|
|
224
251
|
body = JSON.parse(response.body)
|
|
225
|
-
body
|
|
252
|
+
body.dig("message", "content") || ""
|
|
226
253
|
rescue Errno::ECONNREFUSED
|
|
227
|
-
raise "Ollama not running at #{
|
|
254
|
+
raise "Ollama not running at #{endpoint}. Start it with: ollama serve"
|
|
228
255
|
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
229
256
|
raise "Ollama timed out after #{config["timeout"]}s"
|
|
230
257
|
end
|
|
@@ -360,18 +387,94 @@ def directly_addressed_to?(msg, name)
|
|
|
360
387
|
end
|
|
361
388
|
|
|
362
389
|
# Build a compact agent roster string for the intent prompt.
|
|
363
|
-
#
|
|
390
|
+
# Returns a comma-separated list of agent display names.
|
|
364
391
|
#
|
|
365
|
-
# @param agent_name [String] The current agent (
|
|
366
|
-
# @return [String]
|
|
392
|
+
# @param agent_name [String] The current agent (unused but kept for interface consistency)
|
|
393
|
+
# @return [String] Comma-separated agent names
|
|
367
394
|
def build_intent_agent_roster(agent_name)
|
|
368
|
-
return
|
|
395
|
+
return agent_name unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
|
|
369
396
|
|
|
370
397
|
names = AGENT_REGISTRY.map do |key, entry|
|
|
371
398
|
entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
|
|
372
399
|
end.uniq
|
|
373
400
|
|
|
374
|
-
return
|
|
401
|
+
return agent_name if names.empty?
|
|
402
|
+
|
|
403
|
+
names.join(", ")
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Detect the last agent to respond in the conversation context.
|
|
407
|
+
# Returns a prompt-friendly string telling the model who was last to respond.
|
|
408
|
+
#
|
|
409
|
+
# The context is formatted as "username: message\n..." lines.
|
|
410
|
+
# We scan backwards to find the most recent line authored by a known agent.
|
|
411
|
+
#
|
|
412
|
+
# @param context [String, nil] Conversation history
|
|
413
|
+
# @param agent_name [String] The current agent being evaluated
|
|
414
|
+
# @return [String] Prompt block describing the last responder
|
|
415
|
+
def detect_last_responder(context, _agent_name = nil)
|
|
416
|
+
return "No agent has spoken yet." if context.nil? || context.strip.empty?
|
|
417
|
+
return "No agent has spoken yet." unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
|
|
418
|
+
|
|
419
|
+
# Build a lookup of agent display names (lowercase) to their proper display name
|
|
420
|
+
agent_names = {}
|
|
421
|
+
AGENT_REGISTRY.each do |key, entry|
|
|
422
|
+
display = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
|
|
423
|
+
agent_names[display.downcase] = display
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Scan context lines in reverse to find the last agent message
|
|
427
|
+
lines = context.strip.lines.reverse
|
|
428
|
+
lines.each do |line|
|
|
429
|
+
# Lines are formatted as "username: message content"
|
|
430
|
+
match = line.match(/\A(\S+?):\s/)
|
|
431
|
+
next unless match
|
|
432
|
+
|
|
433
|
+
username = match[1].downcase
|
|
434
|
+
next unless agent_names.key?(username)
|
|
435
|
+
|
|
436
|
+
responder = agent_names[username]
|
|
437
|
+
return "#{responder} was the last to speak. The human is continuing the conversation with #{responder}."
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
"No agent has spoken yet."
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Detect the last agent to respond — returns just the display name (or nil).
|
|
444
|
+
# Used for deterministic conversational-flow checks without LLM involvement.
|
|
445
|
+
#
|
|
446
|
+
# @param context [String, nil] Conversation history
|
|
447
|
+
# @return [String, nil] Display name of last agent to speak, or nil
|
|
448
|
+
def detect_last_responder_name(context)
|
|
449
|
+
return nil if context.nil? || context.strip.empty?
|
|
450
|
+
return nil unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
|
|
451
|
+
|
|
452
|
+
agent_names = {}
|
|
453
|
+
AGENT_REGISTRY.each do |key, entry|
|
|
454
|
+
display = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
|
|
455
|
+
agent_names[display.downcase] = display
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
context.strip.lines.reverse_each do |line|
|
|
459
|
+
match = line.match(/\A(\S+?):\s/)
|
|
460
|
+
next unless match
|
|
461
|
+
|
|
462
|
+
username = match[1].downcase
|
|
463
|
+
return agent_names[username] if agent_names.key?(username)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
nil
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Check if a message contains a direct reference to a specific agent's name.
|
|
470
|
+
# More permissive than intent_names_other_agent? — catches any occurrence of the name.
|
|
471
|
+
#
|
|
472
|
+
# @param message [String] The message text
|
|
473
|
+
# @param agent_name [String] The agent name to look for
|
|
474
|
+
# @return [Boolean] true if the agent is mentioned by name
|
|
475
|
+
def message_mentions_agent?(message, agent_name)
|
|
476
|
+
return false if message.nil? || agent_name.nil?
|
|
375
477
|
|
|
376
|
-
|
|
478
|
+
escaped = Regexp.escape(agent_name)
|
|
479
|
+
message.match?(/\b#{escaped}\b/i)
|
|
377
480
|
end
|
data/lib/brainiac/version.rb
CHANGED