samagotchi 0.35.0 → 0.37.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/CHANGELOG.md +57 -1
- data/README.md +2 -2
- data/bin/chi +1 -1
- data/docs/broadcast.md +91 -12
- data/docs/configuration.md +8 -1
- data/docs/context.md +37 -0
- data/docs/guardrails.md +26 -3
- data/docs/hooks.md +1 -1
- data/docs/memory.md +3 -0
- data/docs/plugins.md +51 -4
- data/lib/samagotchi/broadcast/triage.rb +267 -0
- data/lib/samagotchi/broadcast/triage_log.rb +165 -0
- data/lib/samagotchi/broadcast/triage_model.rb +56 -0
- data/lib/samagotchi/broadcast_command.rb +273 -69
- data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +72 -27
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
- data/lib/samagotchi/cli/command.rb +27 -3
- data/lib/samagotchi/config.rb +9 -0
- data/lib/samagotchi/context_command.rb +0 -17
- data/lib/samagotchi/engine.rb +4 -6
- data/lib/samagotchi/guardrail_wiring.rb +7 -2
- data/lib/samagotchi/guardrails/approval.rb +5 -2
- data/lib/samagotchi/guardrails/approvals.rb +9 -5
- data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
- data/lib/samagotchi/guardrails/rules.rb +19 -6
- data/lib/samagotchi/guardrails/targets.rb +83 -11
- data/lib/samagotchi/idle_client.rb +48 -28
- data/lib/samagotchi/idle_recap.rb +6 -5
- data/lib/samagotchi/idle_target.rb +95 -0
- data/lib/samagotchi/llm/openai_chat.rb +29 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
- data/lib/samagotchi/note_command.rb +0 -21
- data/lib/samagotchi/recap_setup.rb +38 -52
- data/lib/samagotchi/send_command.rb +0 -22
- data/lib/samagotchi/session_commands.rb +1 -0
- data/lib/samagotchi/tool_activity.rb +14 -3
- data/lib/samagotchi/tool_declarations.rb +8 -4
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +103 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/question_card.js +4 -3
- metadata +5 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../idle_client"
|
|
4
|
+
require_relative "../idle_target"
|
|
5
|
+
require_relative "../cancellation_controller"
|
|
6
|
+
require_relative "../log"
|
|
7
|
+
require_relative "tags"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Broadcast
|
|
11
|
+
# Whether a broadcast concerns a recipient its tags didn't match: a
|
|
12
|
+
# small model reads the note and the session's ScopeCard and answers
|
|
13
|
+
# yes or no (Triage::LLM). Many recipients are judged in parallel under
|
|
14
|
+
# one deadline (Triage.judge_all); whatever isn't judged by then, or
|
|
15
|
+
# fails, is delivered unchecked (fail open: a missed recipient is worse
|
|
16
|
+
# than an extra note).
|
|
17
|
+
module Triage
|
|
18
|
+
# One recipient's verdict.
|
|
19
|
+
# @!attribute relevant [Boolean] it gets the note
|
|
20
|
+
# @!attribute p [Float, nil] the model's P(yes) from its logprobs,
|
|
21
|
+
# 1.0 or 0.0 from a plain answer, nil when no model judged it
|
|
22
|
+
# @!attribute reason [String] why, for the output and the log
|
|
23
|
+
# @!attribute by [String] who decided: "tags" (a shared tag),
|
|
24
|
+
# "model", "fallback" (delivered unchecked: deadline, error, an
|
|
25
|
+
# answer that is no yes or no), "scope" (the note's first line
|
|
26
|
+
# names another project), "all" (--all) or "repl" (a chi REPL owns
|
|
27
|
+
# it: no notes)
|
|
28
|
+
# @!attribute match [Match, nil] the shared tag (by "tags")
|
|
29
|
+
Verdict = Data.define(:relevant, :p, :reason, :by, :match) do
|
|
30
|
+
def initialize(match: nil, **) = super
|
|
31
|
+
|
|
32
|
+
def unchecked? = by == "fallback"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The spike's prompt (plain yes/no beat JSON with a p and a reason
|
|
36
|
+
# for every model tried: writing a reason pushes towards "relevant").
|
|
37
|
+
SYSTEM = <<~TEXT
|
|
38
|
+
You route short notes that a developer broadcasts to their running coding-agent sessions.
|
|
39
|
+
You get one note and one session's card (project, branch, title, what the session was started for, its latest recap, the last request).
|
|
40
|
+
Decide whether the note may affect THIS session's current work: would the agent working on it want to know?
|
|
41
|
+
A note can start with an informal scope line (a project, a component, a team); use it as a hint.
|
|
42
|
+
Notes about things unrelated to the session's work (social, other projects, other components) are not relevant.
|
|
43
|
+
Answer with exactly one word: yes or no.
|
|
44
|
+
TEXT
|
|
45
|
+
|
|
46
|
+
DEFAULT_PARALLEL = 4
|
|
47
|
+
DEFAULT_DEADLINE = 20.0
|
|
48
|
+
DEFAULT_THRESHOLD = 0.5
|
|
49
|
+
# How long threads still asking get to stop once the deadline passed.
|
|
50
|
+
JOIN_GRACE = 1.0
|
|
51
|
+
|
|
52
|
+
module_function
|
|
53
|
+
|
|
54
|
+
# @return [Verdict] delivered unchecked, +why+ in the reason
|
|
55
|
+
def unchecked(why) = Verdict.new(relevant: true, p: nil, reason: "unchecked: #{why}", by: "fallback")
|
|
56
|
+
|
|
57
|
+
# The messages a model judges +note+ for +card+ by.
|
|
58
|
+
def messages(note, card)
|
|
59
|
+
[{ role: "system", content: SYSTEM }, { role: "user", content: "NOTE:\n#{note}\n\nSESSION CARD:\n#{card}" }]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The one project the note's first line names, as a whole word (any
|
|
63
|
+
# case), among +projects+ (the recipients' project names); nil when it
|
|
64
|
+
# names none or several. A scope line ("shopfront/checkout", "agentd
|
|
65
|
+
# web") then keeps the note to that project's sessions; anything
|
|
66
|
+
# fuzzier is the model's job.
|
|
67
|
+
# @param projects [Array<String>]
|
|
68
|
+
# @return [String, nil]
|
|
69
|
+
def scope_project(note, projects)
|
|
70
|
+
line = note.to_s.lines.first.to_s.downcase
|
|
71
|
+
named = projects.compact.uniq.select do |project|
|
|
72
|
+
line.match?(/(?<![[:alnum:]_-])#{Regexp.escape(project.downcase)}(?![[:alnum:]_-])/)
|
|
73
|
+
end
|
|
74
|
+
named.one? ? named.first : nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Every card's verdict: a shared tag delivers (Tags.match); a note
|
|
78
|
+
# whose first line names one of the projects (scope_project) skips
|
|
79
|
+
# the other projects' sessions; the model judges the rest
|
|
80
|
+
# (judge_all, +judge+ its keywords).
|
|
81
|
+
# @param cards [Array<ScopeCard>]
|
|
82
|
+
# @param note_tags [Array<Tag>]
|
|
83
|
+
# @return [Hash{String => Verdict}] by card id, in +cards+' order
|
|
84
|
+
def verdicts(note, cards, note_tags:, new_backend:, **judge)
|
|
85
|
+
scope = scope_project(note, cards.map(&:project))
|
|
86
|
+
decided = cards.to_h do |card|
|
|
87
|
+
match = Tags.match(note_tags, card.tags)
|
|
88
|
+
verdict = if match
|
|
89
|
+
Verdict.new(relevant: true, p: nil, reason: match.reason, by: "tags", match: match)
|
|
90
|
+
elsif scope && card.project != scope
|
|
91
|
+
Verdict.new(relevant: false, p: nil, reason: "scope line names #{scope}", by: "scope")
|
|
92
|
+
end
|
|
93
|
+
[card.id, verdict]
|
|
94
|
+
end
|
|
95
|
+
judged = judge_all(note, cards.reject { |card| decided[card.id] }, new_backend: new_backend, **judge)
|
|
96
|
+
cards.to_h { |card| [card.id, decided[card.id] || judged.fetch(card.id)] }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Judges +cards+ with at most +parallel+ backends at a time, one per
|
|
100
|
+
# thread (a backend keeps per-host state), all under +deadline+
|
|
101
|
+
# seconds. A card not judged by then is delivered unchecked; the
|
|
102
|
+
# requests still running are cancelled.
|
|
103
|
+
# @param new_backend [#call] (CancellationController) → a backend:
|
|
104
|
+
# #judge(note, card) → Verdict
|
|
105
|
+
# @param cards [Array<ScopeCard>]
|
|
106
|
+
# @return [Hash{String => Verdict}] by card id
|
|
107
|
+
def judge_all(note, cards, new_backend:, parallel: DEFAULT_PARALLEL, deadline: DEFAULT_DEADLINE,
|
|
108
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
109
|
+
return {} if cards.empty?
|
|
110
|
+
|
|
111
|
+
queue = Queue.new
|
|
112
|
+
cards.each { |card| queue << card }
|
|
113
|
+
queue.close
|
|
114
|
+
results = {}
|
|
115
|
+
lock = Mutex.new
|
|
116
|
+
changed = ConditionVariable.new
|
|
117
|
+
cancel = CancellationController.new
|
|
118
|
+
ends = clock.call + deadline.to_f
|
|
119
|
+
finished = 0
|
|
120
|
+
threads = Array.new(parallel.to_i.clamp(1, cards.size)) do
|
|
121
|
+
Thread.new do
|
|
122
|
+
card = nil
|
|
123
|
+
backend = new_backend.call(cancel)
|
|
124
|
+
while (card = queue.pop)
|
|
125
|
+
verdict = judge_one(backend, note, card)
|
|
126
|
+
lock.synchronize do
|
|
127
|
+
results[card.id] = verdict unless cancel.cancelled?
|
|
128
|
+
changed.broadcast
|
|
129
|
+
end
|
|
130
|
+
card = nil
|
|
131
|
+
end
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
# No backend (or a bug around it): this thread's card and the
|
|
134
|
+
# ones it takes from here on are delivered unchecked at once,
|
|
135
|
+
# so nobody waits for the deadline.
|
|
136
|
+
Log.exception(:broadcast, "triage_thread_failed", e)
|
|
137
|
+
failed = unchecked("triage failed (#{e.class})")
|
|
138
|
+
lock.synchronize { results[card.id] ||= failed if card }
|
|
139
|
+
while (card = queue.pop)
|
|
140
|
+
lock.synchronize { results[card.id] ||= failed }
|
|
141
|
+
end
|
|
142
|
+
ensure
|
|
143
|
+
lock.synchronize do
|
|
144
|
+
finished += 1
|
|
145
|
+
changed.broadcast
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
lock.synchronize do
|
|
150
|
+
loop do
|
|
151
|
+
break if results.size == cards.size || finished == threads.size
|
|
152
|
+
|
|
153
|
+
left = ends - clock.call
|
|
154
|
+
break if left <= 0
|
|
155
|
+
|
|
156
|
+
changed.wait(lock, left)
|
|
157
|
+
end
|
|
158
|
+
cancel.cancel!(:deadline)
|
|
159
|
+
queue.clear
|
|
160
|
+
end
|
|
161
|
+
threads.each { |thread| thread.join(JOIN_GRACE) || thread.kill }
|
|
162
|
+
cards.to_h { |card| [card.id, results[card.id] || unchecked("triage deadline")] }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def judge_one(backend, note, card)
|
|
166
|
+
backend.judge(note, card)
|
|
167
|
+
rescue Samagotchi::LLM::RequestCancelled
|
|
168
|
+
unchecked("triage deadline")
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
Log.exception(:broadcast, "triage_failed", e)
|
|
171
|
+
unchecked("triage failed (#{e.class})")
|
|
172
|
+
end
|
|
173
|
+
private_class_method :judge_one
|
|
174
|
+
|
|
175
|
+
# A triage model on an OpenAI-compatible host (an IdleTarget), asked
|
|
176
|
+
# through its own IdleClient: thinking off, a few tokens, the first
|
|
177
|
+
# token's logprobs when the host gives them. P(yes) / (P(yes) +
|
|
178
|
+
# P(no)) from those is graded against +threshold+; a plain yes or no
|
|
179
|
+
# is 1.0 or 0.0.
|
|
180
|
+
class LLM
|
|
181
|
+
MAX_TOKENS = 16
|
|
182
|
+
TOP_LOGPROBS = 5
|
|
183
|
+
LOGPROB_OPTIONS = { logprobs: true, top_logprobs: TOP_LOGPROBS }.freeze
|
|
184
|
+
# Hosts (base urls) that refused logprobs with a 400: asked without
|
|
185
|
+
# them from then on, in this process.
|
|
186
|
+
@no_logprobs = Set.new
|
|
187
|
+
@no_logprobs_lock = Mutex.new
|
|
188
|
+
|
|
189
|
+
class << self
|
|
190
|
+
def logprobs?(base_url) = @no_logprobs_lock.synchronize { !@no_logprobs.include?(base_url) }
|
|
191
|
+
|
|
192
|
+
def no_logprobs!(base_url) = @no_logprobs_lock.synchronize { @no_logprobs << base_url }
|
|
193
|
+
|
|
194
|
+
# Specs: forget the hosts that refused.
|
|
195
|
+
def reset_logprobs! = @no_logprobs_lock.synchronize { @no_logprobs.clear }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @param target [IdleTarget]
|
|
199
|
+
# @param timeout [Numeric] each request's
|
|
200
|
+
# @param client [IdleClient, nil] specs inject one
|
|
201
|
+
def initialize(target:, timeout:, threshold: DEFAULT_THRESHOLD, cancel_controller: nil, client: nil)
|
|
202
|
+
@target = target
|
|
203
|
+
@threshold = threshold.to_f
|
|
204
|
+
@cancel = cancel_controller
|
|
205
|
+
@client = client || IdleClient.for(target, timeout: timeout, purpose: "broadcast")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# @return [Verdict]
|
|
209
|
+
# @raise [Samagotchi::LLM::RequestCancelled] the deadline passed
|
|
210
|
+
def judge(note, card)
|
|
211
|
+
answer = ask(Triage.messages(note, card))
|
|
212
|
+
word = self.class.word(answer.text)
|
|
213
|
+
return Triage.unchecked("the model answered #{answer.text.to_s.strip[0, 40].inspect}") unless word
|
|
214
|
+
|
|
215
|
+
p = self.class.logprob_p(answer.top_logprobs, word: word)
|
|
216
|
+
relevant = p ? p >= @threshold : word == "yes"
|
|
217
|
+
reason = "model: #{relevant ? "yes" : "no"}#{format(" (p %.2f)", p) if p}"
|
|
218
|
+
Verdict.new(relevant: relevant, p: p || (word == "yes" ? 1.0 : 0.0), reason: reason, by: "model")
|
|
219
|
+
rescue IdleClient::SummarizeError => e
|
|
220
|
+
Triage.unchecked("the model failed (#{e.message[0, 120]})")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# "yes" or "no": the answer's first such word (thinking left out),
|
|
224
|
+
# nil when it has neither.
|
|
225
|
+
def self.word(text)
|
|
226
|
+
text.to_s.gsub(%r{<think>.*?</think>}m, "").strip.downcase[/\A\W*(yes|no)\b/, 1]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# P(yes) / (P(yes) + P(no)) over the first token's alternatives
|
|
230
|
+
# ("Yes", " yes" count as yes), when that token is the answer +word+
|
|
231
|
+
# itself: its likeliest alternative is yes or no and agrees with
|
|
232
|
+
# +word+. nil otherwise (an answer starting "**", "<think>" or a
|
|
233
|
+
# quote: those alternatives say nothing about yes or no).
|
|
234
|
+
# @param top [Array<Samagotchi::LLM::TokenLogprob>]
|
|
235
|
+
# @return [Float, nil]
|
|
236
|
+
def self.logprob_p(top, word:)
|
|
237
|
+
first = top.max_by(&:logprob)
|
|
238
|
+
return nil unless first && first.token.strip.downcase == word
|
|
239
|
+
|
|
240
|
+
sum = ->(want) { top.select { |t| t.token.strip.downcase == want }.sum { |t| Math.exp(t.logprob) } }
|
|
241
|
+
yes = sum.call("yes")
|
|
242
|
+
no = sum.call("no")
|
|
243
|
+
(yes / (yes + no)).round(4)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
private
|
|
247
|
+
|
|
248
|
+
def ask(messages)
|
|
249
|
+
base_url = @target.base_url
|
|
250
|
+
options = self.class.logprobs?(base_url) ? LOGPROB_OPTIONS : {}
|
|
251
|
+
begin
|
|
252
|
+
@client.ask(messages, max_tokens: MAX_TOKENS, cancel_controller: @cancel, kind: "broadcast", options: options)
|
|
253
|
+
rescue IdleClient::SummarizeError => e
|
|
254
|
+
raise unless !options.empty? && e.cause.is_a?(Samagotchi::LLM::BadRequest)
|
|
255
|
+
|
|
256
|
+
# The host won't give logprobs (a server that refuses "n and
|
|
257
|
+
# logprobs"): ask again without, and leave them out from now on.
|
|
258
|
+
self.class.no_logprobs!(base_url)
|
|
259
|
+
Log.info(:broadcast, "logprobs_refused", model: @target.label, detail: e.cause.message.to_s[0, 200])
|
|
260
|
+
options = {}
|
|
261
|
+
retry
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "../paths"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module Broadcast
|
|
10
|
+
# What each broadcast decided, one JSON line per broadcast in
|
|
11
|
+
# <state>/samagotchi/broadcast/log.jsonl: its id, time, note text, the
|
|
12
|
+
# note's tags and every recipient's verdict (no scope cards: they quote
|
|
13
|
+
# the user's prompts). `chi broadcast deliver` adds a "corrected" line
|
|
14
|
+
# for the sessions it delivered to anyway: labels for the threshold and
|
|
15
|
+
# a future classifier. Not a list sessions read: broadcasts stay
|
|
16
|
+
# fire-and-forget. Over MAX_BYTES the file becomes log.jsonl.1 (one old
|
|
17
|
+
# file kept).
|
|
18
|
+
class TriageLog
|
|
19
|
+
MAX_BYTES = 2 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
# One recipient's line in a broadcast.
|
|
22
|
+
# @!attribute result [String] "delivered", "skipped" or "failed"
|
|
23
|
+
# @!attribute p [Float, nil] the triage model's P(yes), nil when no
|
|
24
|
+
# model judged it
|
|
25
|
+
# @!attribute by [String] Triage::Verdict#by
|
|
26
|
+
# @!attribute tags [Array<String>] the session's tags (Tag#label)
|
|
27
|
+
Entry = Data.define(:session, :result, :p, :reason, :by, :tags)
|
|
28
|
+
|
|
29
|
+
# A `chi broadcast deliver`: when, and each session's result.
|
|
30
|
+
# @!attribute sessions [Array<Delivery>]
|
|
31
|
+
Correction = Data.define(:at, :sessions)
|
|
32
|
+
|
|
33
|
+
# One session a correction delivered to (or tried).
|
|
34
|
+
Delivery = Data.define(:session, :result)
|
|
35
|
+
|
|
36
|
+
# A broadcast as read back, with the corrections made to it since.
|
|
37
|
+
# @!attribute at [Time]
|
|
38
|
+
# @!attribute recipients [Array<Entry>]
|
|
39
|
+
# @!attribute corrections [Array<Correction>]
|
|
40
|
+
Record = Data.define(:id, :at, :text, :note_tags, :triage_model, :recipients, :corrections) do
|
|
41
|
+
# @return [Entry, nil]
|
|
42
|
+
def entry(session) = recipients.find { |e| e.session == session }
|
|
43
|
+
|
|
44
|
+
# Whether +session+ got it, from the broadcast or a correction.
|
|
45
|
+
def delivered?(session)
|
|
46
|
+
entry(session)&.result == "delivered" ||
|
|
47
|
+
corrections.any? { |c| c.sessions.any? { |d| d.session == session && d.result == "delivered" } }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_json_hash
|
|
51
|
+
{ id: id, at: at.iso8601, text: text, note_tags: note_tags, triage_model: triage_model,
|
|
52
|
+
recipients: recipients.map(&:to_h),
|
|
53
|
+
corrections: corrections.map { |c| { at: c.at.iso8601, sessions: c.sessions.map(&:to_h) } } }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# No broadcast with that id, or several start with it.
|
|
58
|
+
class NotFound < StandardError; end
|
|
59
|
+
|
|
60
|
+
# @return [String] <state>/samagotchi/broadcast/log.jsonl
|
|
61
|
+
def self.default_path(env: ENV) = File.join(Paths.state_dir(env: env), "broadcast", "log.jsonl")
|
|
62
|
+
|
|
63
|
+
attr_reader :path
|
|
64
|
+
|
|
65
|
+
def initialize(path = self.class.default_path)
|
|
66
|
+
@path = path
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param at [Time]
|
|
70
|
+
# @param recipients [Array<Entry>]
|
|
71
|
+
def append_broadcast(id:, at:, text:, note_tags:, triage_model:, recipients:)
|
|
72
|
+
append(type: "broadcast", id: id, at: at.iso8601, text: text, note_tags: note_tags, triage_model: triage_model,
|
|
73
|
+
recipients: recipients.map(&:to_h))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param sessions [Array<Delivery>]
|
|
77
|
+
def append_correction(id:, at:, sessions:)
|
|
78
|
+
append(type: "corrected", id: id, at: at.iso8601, sessions: sessions.map(&:to_h))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Every broadcast in the log (the rotated file's first), oldest
|
|
82
|
+
# first, with its corrections. A line that doesn't parse is skipped.
|
|
83
|
+
# @return [Array<Record>]
|
|
84
|
+
def records
|
|
85
|
+
found = {}
|
|
86
|
+
[rotated_path, @path].each do |file|
|
|
87
|
+
next unless File.file?(file)
|
|
88
|
+
|
|
89
|
+
File.foreach(file) do |line|
|
|
90
|
+
data = parse(line)
|
|
91
|
+
next unless data
|
|
92
|
+
|
|
93
|
+
if data["type"] == "broadcast"
|
|
94
|
+
found[data["id"]] = record(data)
|
|
95
|
+
elsif data["type"] == "corrected" && (previous = found[data["id"]])
|
|
96
|
+
found[data["id"]] = previous.with(corrections: previous.corrections + [correction(data)])
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
found.values
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The broadcast +given+ names: its id, with or without "b-", or the
|
|
104
|
+
# start of one.
|
|
105
|
+
# @return [Record]
|
|
106
|
+
# @raise [NotFound]
|
|
107
|
+
def find(given)
|
|
108
|
+
want = given.to_s.strip.delete_prefix("b-")
|
|
109
|
+
raise NotFound, "no broadcast id given" if want.empty?
|
|
110
|
+
|
|
111
|
+
matches = records.select { |r| r.id.delete_prefix("b-").start_with?(want) }
|
|
112
|
+
return matches.first if matches.one?
|
|
113
|
+
raise NotFound, "no broadcast #{given} in the log (chi broadcast log)" if matches.empty?
|
|
114
|
+
|
|
115
|
+
raise NotFound, "#{given} names #{matches.size} broadcasts (#{matches.map(&:id).join(", ")}); give more of the id"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def rotated_path = "#{@path}.1"
|
|
121
|
+
|
|
122
|
+
def append(data)
|
|
123
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
124
|
+
File.rename(@path, rotated_path) if File.file?(@path) && File.size(@path) > MAX_BYTES
|
|
125
|
+
File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o600) do |file|
|
|
126
|
+
file.flock(File::LOCK_EX)
|
|
127
|
+
file.write("#{JSON.generate(data)}\n")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse(line)
|
|
132
|
+
data = JSON.parse(line)
|
|
133
|
+
data.is_a?(Hash) && data["id"].is_a?(String) ? data : nil
|
|
134
|
+
rescue JSON::ParserError
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def record(data)
|
|
139
|
+
Record.new(id: data["id"], at: time(data["at"]), text: data["text"].to_s, note_tags: Array(data["note_tags"]),
|
|
140
|
+
triage_model: data["triage_model"],
|
|
141
|
+
recipients: Array(data["recipients"]).filter_map { |e| entry(e) }, corrections: [])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def entry(data)
|
|
145
|
+
return nil unless data.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
Entry.new(session: data["session"].to_s, result: data["result"].to_s, p: data["p"], reason: data["reason"].to_s,
|
|
148
|
+
by: data["by"].to_s, tags: Array(data["tags"]))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def correction(data)
|
|
152
|
+
sessions = Array(data["sessions"]).filter_map do |d|
|
|
153
|
+
Delivery.new(session: d["session"].to_s, result: d["result"].to_s) if d.is_a?(Hash)
|
|
154
|
+
end
|
|
155
|
+
Correction.new(at: time(data["at"]), sessions: sessions)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def time(value)
|
|
159
|
+
Time.iso8601(value.to_s)
|
|
160
|
+
rescue ArgumentError
|
|
161
|
+
Time.at(0)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../idle_target"
|
|
5
|
+
require_relative "../model_profile"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# Loaded on first use, as the CLI commands do: host_registry pulls in the
|
|
9
|
+
# LLM layer.
|
|
10
|
+
autoload :HostRegistry, File.expand_path("../host_registry", __dir__)
|
|
11
|
+
|
|
12
|
+
module Broadcast
|
|
13
|
+
# Which model a broadcast's triage asks: broadcast.triage_model /
|
|
14
|
+
# triage_host_ref / triage_base_url; with none of them set, the recap's
|
|
15
|
+
# (recap.*); with none of those, default.model. Settings that can't be
|
|
16
|
+
# resolved are a problem, not a fallback to the next ones: triage then
|
|
17
|
+
# delivers unchecked and says why.
|
|
18
|
+
module TriageModel
|
|
19
|
+
# @!attribute target [IdleTarget, nil] nil when +problem+ says why
|
|
20
|
+
# @!attribute setting [String] where it came from: "broadcast.triage_model",
|
|
21
|
+
# "recap.model" or "default.model" (the prefix of the keys read)
|
|
22
|
+
Choice = Data.define(:target, :setting, :problem)
|
|
23
|
+
|
|
24
|
+
SETTINGS = [%w[broadcast.triage_model broadcast.triage_host_ref broadcast.triage_base_url],
|
|
25
|
+
%w[recap.model recap.host_ref recap.base_url]].freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# @param get [#call] a Config key → its value
|
|
30
|
+
# @return [Choice]
|
|
31
|
+
def resolve(host_registry: nil, get: ->(key) { Config.get(key) })
|
|
32
|
+
registry = host_registry || HostRegistry.new
|
|
33
|
+
SETTINGS.each do |model, host_ref, base_url|
|
|
34
|
+
target = IdleTarget.resolve(model: get.call(model), host_ref: get.call(host_ref), base_url: get.call(base_url),
|
|
35
|
+
host_registry: registry)
|
|
36
|
+
return Choice.new(target: target, setting: model, problem: nil) if target
|
|
37
|
+
rescue IdleTarget::Unresolved => e
|
|
38
|
+
return Choice.new(target: nil, setting: model, problem: problem(e, model, host_ref))
|
|
39
|
+
end
|
|
40
|
+
model = ModelProfile.required_model_name(get.call("default.model"))
|
|
41
|
+
Choice.new(target: IdleTarget.of_model(registry, model), setting: "default.model", problem: nil)
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
Choice.new(target: nil, setting: "default.model", problem: e.message.to_s.lines.first.to_s.strip[0, 200])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def problem(error, model_key, host_ref_key)
|
|
47
|
+
case error.reason
|
|
48
|
+
when :host_ref_unknown then "#{host_ref_key} '#{error.host_ref}' is not in hosts:"
|
|
49
|
+
when :model_host_mismatch then "#{model_key} '#{error.model}' names host '#{error.named}', not #{host_ref_key} '#{error.host_ref}'"
|
|
50
|
+
else "#{model_key} is missing (a host or base_url alone names no model)"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
private_class_method :problem
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|