belay 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +155 -0
- data/app/assets/belay/application.css +1 -0
- data/app/controllers/belay/application_controller.rb +48 -0
- data/app/controllers/belay/runs_controller.rb +166 -0
- data/app/controllers/belay/stats_controller.rb +14 -0
- data/app/helpers/belay/application_helper.rb +76 -0
- data/app/views/belay/runs/_header.html.erb +20 -0
- data/app/views/belay/runs/_pending_gate.html.erb +25 -0
- data/app/views/belay/runs/_pending_needs_review.html.erb +30 -0
- data/app/views/belay/runs/_replayed_from.html.erb +7 -0
- data/app/views/belay/runs/_replays.html.erb +14 -0
- data/app/views/belay/runs/_step.html.erb +52 -0
- data/app/views/belay/runs/diff.html.erb +47 -0
- data/app/views/belay/runs/index.html.erb +60 -0
- data/app/views/belay/runs/show.html.erb +34 -0
- data/app/views/belay/shared/_flash.html.erb +11 -0
- data/app/views/belay/shared/_sidebar.html.erb +22 -0
- data/app/views/belay/stats/_chart.html.erb +56 -0
- data/app/views/belay/stats/_table.html.erb +22 -0
- data/app/views/belay/stats/index.html.erb +16 -0
- data/app/views/layouts/belay/application.html.erb +19 -0
- data/config/routes.rb +13 -0
- data/lib/belay/anthropic_provider.rb +166 -0
- data/lib/belay/application_record.rb +7 -0
- data/lib/belay/approval.rb +35 -0
- data/lib/belay/cache_entry.rb +65 -0
- data/lib/belay/configuration.rb +60 -0
- data/lib/belay/context.rb +241 -0
- data/lib/belay/daily_stats.rb +60 -0
- data/lib/belay/engine.rb +40 -0
- data/lib/belay/executor.rb +411 -0
- data/lib/belay/fake_provider.rb +41 -0
- data/lib/belay/janitor_job.rb +48 -0
- data/lib/belay/provider.rb +15 -0
- data/lib/belay/provider_response.rb +19 -0
- data/lib/belay/resume_run_job.rb +42 -0
- data/lib/belay/rule.rb +113 -0
- data/lib/belay/run.rb +629 -0
- data/lib/belay/step.rb +57 -0
- data/lib/belay/version.rb +5 -0
- data/lib/belay/workflow.rb +199 -0
- data/lib/belay.rb +49 -0
- data/lib/generators/belay/install/install_generator.rb +48 -0
- data/lib/generators/belay/install/templates/create_belay_approvals.rb.tt +43 -0
- data/lib/generators/belay/install/templates/create_belay_cache_entries.rb.tt +27 -0
- data/lib/generators/belay/install/templates/create_belay_rules.rb.tt +46 -0
- data/lib/generators/belay/install/templates/create_belay_runs.rb.tt +37 -0
- data/lib/generators/belay/install/templates/create_belay_steps.rb.tt +58 -0
- metadata +226 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belay
|
|
4
|
+
class Configuration
|
|
5
|
+
# Seconds a worker holds a run before the janitor may reclaim it. See
|
|
6
|
+
# DESIGN_DECISIONS.md #12 for why 300/30.
|
|
7
|
+
attr_accessor :lease_duration
|
|
8
|
+
|
|
9
|
+
# Seconds between heartbeats while a worker has a step in flight.
|
|
10
|
+
attr_accessor :heartbeat_interval
|
|
11
|
+
|
|
12
|
+
# The Belay::Provider ctx.llm calls. nil by default - a workflow with no
|
|
13
|
+
# llm-calling steps never touches this, but a step that calls ctx.llm without
|
|
14
|
+
# one configured gets a clear error instead of a confusing NoMethodError on nil.
|
|
15
|
+
attr_accessor :provider
|
|
16
|
+
|
|
17
|
+
# Gates who can load Belay::Engine's mounted dashboard: a ->(request) { boolean }
|
|
18
|
+
# proc, called on every request. nil by default - deliberately NOT "open to
|
|
19
|
+
# anyone" by default, since the dashboard shows prompts, responses, costs and
|
|
20
|
+
# approve/reject actions. Belay::ApplicationController raises a clear error if
|
|
21
|
+
# this is still nil when a request actually arrives, rather than quietly
|
|
22
|
+
# serving it unauthenticated. See lib/belay/application_controller.rb.
|
|
23
|
+
attr_accessor :authenticate_with
|
|
24
|
+
|
|
25
|
+
# How long a ctx.llm(..., cacheable: true) entry stays valid, in seconds.
|
|
26
|
+
# nil means never expires. One global setting, not a per-call override -
|
|
27
|
+
# cacheable: is already the one knob a step author needs; a second
|
|
28
|
+
# ttl: keyword on every ctx.llm call would be more API surface for a
|
|
29
|
+
# distinction most workflows don't need to make per-step.
|
|
30
|
+
attr_accessor :cache_ttl
|
|
31
|
+
|
|
32
|
+
# How many consistent, unedited approve!s of the exact same gate occurrence
|
|
33
|
+
# (same workflow_version, gate_name, and input/outputs - see Belay::Rule)
|
|
34
|
+
# it takes before that occurrence is learned as a rule. One global setting,
|
|
35
|
+
# same reasoning as cache_ttl above - a per-gate override is more API
|
|
36
|
+
# surface than any workflow has asked for yet. Does not by itself make
|
|
37
|
+
# anything auto-approve; see feat(rules): auto-settle a gate when a
|
|
38
|
+
# learned rule matches, before ever pausing.
|
|
39
|
+
attr_accessor :rule_approval_threshold
|
|
40
|
+
|
|
41
|
+
# Extra CSS appended, in the layout's own <style> tag, right after the
|
|
42
|
+
# dashboard's compiled Tailwind build - not a replacement for it. Appended
|
|
43
|
+
# rather than merged means a same-specificity rule (redeclaring .btn-primary,
|
|
44
|
+
# say) wins by ordinary cascade order, no !important required; nil by
|
|
45
|
+
# default renders nothing extra. A plain String, not a path or an asset -
|
|
46
|
+
# this gem has no asset pipeline opinion to lean on (see Engine.stylesheet's
|
|
47
|
+
# own comment), so the host app owns wherever this string actually comes from.
|
|
48
|
+
attr_accessor :custom_css
|
|
49
|
+
|
|
50
|
+
def initialize
|
|
51
|
+
@lease_duration = 300
|
|
52
|
+
@heartbeat_interval = 30
|
|
53
|
+
@provider = nil
|
|
54
|
+
@authenticate_with = nil
|
|
55
|
+
@cache_ttl = 7 * 24 * 60 * 60 # 7 days
|
|
56
|
+
@rule_approval_threshold = 3
|
|
57
|
+
@custom_css = nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json_schemer"
|
|
4
|
+
|
|
5
|
+
module Belay
|
|
6
|
+
# Passed as `ctx` to a step block (see README's Target API). Exposes the run's input
|
|
7
|
+
# and prior steps' outputs as dotted method calls - ctx.invoice_id, ctx.extract - built
|
|
8
|
+
# from two plain hashes, with no coupling to a specific Belay::Workflow class. Also
|
|
9
|
+
# exposes step_idempotency_key: a plain reader, not a method_missing-served dynamic
|
|
10
|
+
# key, since it's one fixed value for whichever step this Context was built for, not
|
|
11
|
+
# a named entry in input or outputs. Belay::Executor is the only thing that passes a
|
|
12
|
+
# real one (run_id + position, DESIGN_DECISIONS #3); it defaults to nil so Context
|
|
13
|
+
# stays constructible standalone, as every existing spec already does.
|
|
14
|
+
#
|
|
15
|
+
# The freeze below is shallow: @input/@outputs are frozen hashes, but a value inside
|
|
16
|
+
# them (e.g. ctx.extract returning a Hash) is not. If the executor ever reuses one
|
|
17
|
+
# Context, or shares an output value, across more than one step, a step mutating
|
|
18
|
+
# its own ctx.extract in place could corrupt what a later step reads. Not guarded
|
|
19
|
+
# here - the executor's construction pattern (one Context per step? shared?) isn't
|
|
20
|
+
# decided yet, and deep-freezing arbitrary step output is real added cost to pay for
|
|
21
|
+
# a risk that depends on that undecided design.
|
|
22
|
+
class Context
|
|
23
|
+
# Raised when schema: was given to ctx.llm but the provider's response doesn't
|
|
24
|
+
# match it. errors is JSONSchemer's own list of validation error hashes, not a
|
|
25
|
+
# single summarized string, so a caller can inspect exactly what failed.
|
|
26
|
+
class SchemaValidationError < Error
|
|
27
|
+
attr_reader :errors
|
|
28
|
+
|
|
29
|
+
def initialize(errors:, content:, schema:)
|
|
30
|
+
@errors = errors
|
|
31
|
+
messages = errors.map { |e| e["error"] }.join("; ")
|
|
32
|
+
super("ctx.llm's response did not match schema: #{messages} (content: #{content.inspect}, " \
|
|
33
|
+
"schema: #{schema.inspect})")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :input, :outputs, :step_idempotency_key
|
|
38
|
+
|
|
39
|
+
# model_override: is Run#replay(model:)'s whole mechanism - nil for every
|
|
40
|
+
# ordinary run (the common case: #llm just uses whatever model: the step's
|
|
41
|
+
# own block passed). Executor::StepRunner#build_context is the only real
|
|
42
|
+
# caller that ever passes one, reading it off @run.replay_model.
|
|
43
|
+
def initialize(input: {}, outputs: {}, step_idempotency_key: nil, model_override: nil)
|
|
44
|
+
@input = symbolize_without_collision!(input, "input")
|
|
45
|
+
@outputs = symbolize_without_collision!(outputs, "prior step output")
|
|
46
|
+
@step_idempotency_key = step_idempotency_key
|
|
47
|
+
@model_override = model_override
|
|
48
|
+
# Mutated in place by #llm below, not reassigned - this object freezes itself
|
|
49
|
+
# at the end of #initialize, which would make `@llm_metadata = ...` raise
|
|
50
|
+
# FrozenError from inside #llm. Mutating the Hash this ivar already points to
|
|
51
|
+
# is a different, allowed thing: freezing Context doesn't freeze objects it
|
|
52
|
+
# merely references.
|
|
53
|
+
@llm_metadata = {}
|
|
54
|
+
validate_no_name_collisions!
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Calls Belay.config.provider, validates the response against schema: (if given)
|
|
59
|
+
# with JSONSchemer, and returns response.content - a step block's return value
|
|
60
|
+
# becomes its checkpointed output, so #llm's return value IS the step's output,
|
|
61
|
+
# not a wrapper around it.
|
|
62
|
+
#
|
|
63
|
+
# Validated here, provider-agnostically, rather than inside each Provider:
|
|
64
|
+
# AnthropicProvider's own schema: already asks Anthropic's API to constrain its
|
|
65
|
+
# output, but FakeProvider enforces nothing at all - without this, a spec's
|
|
66
|
+
# scripted response could silently not match the schema: it claims to, and
|
|
67
|
+
# nothing would catch that until production. This is the one place that's true
|
|
68
|
+
# for every provider.
|
|
69
|
+
#
|
|
70
|
+
# schema: must use string keys for its own keywords (symbol-keyed schemas are
|
|
71
|
+
# deep-stringified below first) and set additionalProperties: false on every
|
|
72
|
+
# object, per Anthropic's own requirement for the same schema when a provider
|
|
73
|
+
# like AnthropicProvider forwards it as output_config.format.schema.
|
|
74
|
+
# model: passed here is a default, not the final word - model_override
|
|
75
|
+
# (set only on a replay's shadow run) takes precedence, so a replayed
|
|
76
|
+
# llm_call step actually calls the overriding model without the workflow
|
|
77
|
+
# body itself needing to know anything about replay.
|
|
78
|
+
#
|
|
79
|
+
# cacheable: false by default - opt-in, exact-match caching (DESIGN_
|
|
80
|
+
# DECISIONS #10, Belay::CacheEntry). Deliberately not inferred from
|
|
81
|
+
# kind: or anything else about the step: "decision steps only" can't be
|
|
82
|
+
# enforced mechanically (kind: "llm_call" covers both a classification
|
|
83
|
+
# AND an extraction call; Belay has no way to tell them apart), so this
|
|
84
|
+
# is a plain, explicit choice the step author makes, the same contract
|
|
85
|
+
# idempotent: true already uses elsewhere - only set this on a step
|
|
86
|
+
# whose ENTIRE prompt text fully and uniquely determines the correct
|
|
87
|
+
# answer. A short, self-contained classification prompt usually
|
|
88
|
+
# qualifies; an extraction prompt that references a large document
|
|
89
|
+
# (especially over a vision/file attachment, whose bytes aren't part of
|
|
90
|
+
# what gets hashed) usually doesn't - two different documents could
|
|
91
|
+
# share a cache key and get back the wrong answer.
|
|
92
|
+
def llm(model:, prompt:, schema: nil, cacheable: false)
|
|
93
|
+
effective_model = @model_override || model
|
|
94
|
+
provider = resolve_provider!
|
|
95
|
+
call_args = { provider: provider, model: effective_model, prompt: prompt, schema: schema }
|
|
96
|
+
|
|
97
|
+
cached = fetch_cached_llm_response(**call_args) if cacheable
|
|
98
|
+
return cached unless cached.nil?
|
|
99
|
+
|
|
100
|
+
response = provider.call(model: effective_model, prompt: prompt, schema: schema)
|
|
101
|
+
record_llm_metadata!(provider: provider, prompt: prompt, response: response)
|
|
102
|
+
validate_against_schema!(response.content, schema) if schema
|
|
103
|
+
store_cached_llm_response!(**call_args, content: response.content) if cacheable
|
|
104
|
+
response.content
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# {} if #llm was never called this step - nothing to persist onto the step row,
|
|
108
|
+
# same as any step that made no provider call. Frozen dup, same reasoning as
|
|
109
|
+
# Workflow#steps/#inputs and FakeProvider#calls: a caller reaching in and
|
|
110
|
+
# mutating this would corrupt what StepRunner later reads to update the row.
|
|
111
|
+
def llm_metadata
|
|
112
|
+
@llm_metadata.dup.freeze
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def method_missing(name, *args)
|
|
116
|
+
return super unless args.empty? && !block_given?
|
|
117
|
+
|
|
118
|
+
if @input.key?(name)
|
|
119
|
+
@input[name]
|
|
120
|
+
elsif @outputs.key?(name)
|
|
121
|
+
@outputs[name]
|
|
122
|
+
else
|
|
123
|
+
super
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def respond_to_missing?(name, include_private = false)
|
|
128
|
+
@input.key?(name) || @outputs.key?(name) || super
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def resolve_provider!
|
|
134
|
+
Belay.config.provider || raise(Error, "Belay.config.provider is not set - configure one before calling " \
|
|
135
|
+
"ctx.llm, e.g. Belay.configure { |c| c.provider = " \
|
|
136
|
+
"Belay::AnthropicProvider.new }")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# nil on a cache miss (or an expired entry) - the caller falls through to
|
|
140
|
+
# a real provider call either way, same as CacheEntry.fetch's own nil
|
|
141
|
+
# means to its two callers. Assumes a cached response's content is never
|
|
142
|
+
# itself nil, which ProviderResponse's own validation already guarantees
|
|
143
|
+
# (content is required) - there's no cacheable response this could ever
|
|
144
|
+
# mistake for a miss.
|
|
145
|
+
#
|
|
146
|
+
# input_tokens/output_tokens/cost_cents/latency_ms are 0, not nil, for a
|
|
147
|
+
# hit - nil already means something specific elsewhere (e.g. cost_cents
|
|
148
|
+
# is nil when no pricing: was configured for a REAL call - "we don't
|
|
149
|
+
# know"), and a hit's true cost really is zero, not unknown.
|
|
150
|
+
def fetch_cached_llm_response(provider:, model:, prompt:, schema:)
|
|
151
|
+
content = CacheEntry.fetch(provider: provider, model: model, prompt: prompt, schema: schema)
|
|
152
|
+
return nil if content.nil?
|
|
153
|
+
|
|
154
|
+
@llm_metadata.merge!(provider: provider.class.name, input: prompt, model: model, cache_hit: true,
|
|
155
|
+
input_tokens: 0, output_tokens: 0, cost_cents: 0, latency_ms: 0)
|
|
156
|
+
content
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Called only after validate_against_schema! has already passed (see #llm) -
|
|
160
|
+
# a response that fails validation never reaches here, so no cacheable: true
|
|
161
|
+
# key is ever stuck returning a bad answer forever.
|
|
162
|
+
def store_cached_llm_response!(provider:, model:, prompt:, schema:, content:)
|
|
163
|
+
CacheEntry.store!(provider: provider, model: model, prompt: prompt, schema: schema, content: content)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Recorded before validating, not after: provider.call above already made a real,
|
|
167
|
+
# billed request by the time we know whether its content matches schema:. A response
|
|
168
|
+
# that fails validation raises below and this step's row ends up "failed" -
|
|
169
|
+
# run_attempt's dual-merge (see executor.rb) persists whatever's in @llm_metadata onto
|
|
170
|
+
# that failed row regardless, which only works if the merge already happened before
|
|
171
|
+
# the raise, not after it.
|
|
172
|
+
#
|
|
173
|
+
# input: prompt, not :prompt - matches belay_steps' own column name exactly (same
|
|
174
|
+
# reasoning as every other key here: extra_step_attrs below merges this whole hash
|
|
175
|
+
# straight into step.update!, with no translation step in between), so the dashboard's
|
|
176
|
+
# run page has something real to show as "prompt" - the exact value passed to
|
|
177
|
+
# ctx.llm, not a summary of it.
|
|
178
|
+
def record_llm_metadata!(provider:, prompt:, response:)
|
|
179
|
+
fields = response.to_h.slice(:model, :input_tokens, :output_tokens, :cost_cents, :latency_ms)
|
|
180
|
+
@llm_metadata.merge!(provider: provider.class.name, input: prompt, cache_hit: false, **fields)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_against_schema!(content, schema)
|
|
184
|
+
document = json_normalize(content)
|
|
185
|
+
schemer = JSONSchemer.schema(json_normalize(schema))
|
|
186
|
+
return if schemer.valid?(document)
|
|
187
|
+
|
|
188
|
+
raise SchemaValidationError.new(errors: schemer.validate(document).to_a, content: content, schema: schema)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Hash#deep_stringify_keys only converts hash keys, not a bare Symbol used as a
|
|
192
|
+
# VALUE - required: [:total_cents] (a real, common JSON Schema shape, not an
|
|
193
|
+
# edge case) comes through untouched, so "total_cents" (the stringified
|
|
194
|
+
# document's actual key) never matches :total_cents. JSON itself has no symbol
|
|
195
|
+
# type; normalizing every symbol anywhere in the structure, key or value, is
|
|
196
|
+
# what makes a Ruby-idiomatic symbol-keyed/valued schema or FakeProvider script
|
|
197
|
+
# behave the same as what the real API actually returns (plain JSON.parse'd
|
|
198
|
+
# strings throughout).
|
|
199
|
+
def json_normalize(value)
|
|
200
|
+
case value
|
|
201
|
+
when Hash
|
|
202
|
+
value.to_h { |k, v| [k.to_s, json_normalize(v)] }
|
|
203
|
+
when Array
|
|
204
|
+
value.map { |v| json_normalize(v) }
|
|
205
|
+
when Symbol
|
|
206
|
+
value.to_s
|
|
207
|
+
else
|
|
208
|
+
value
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def validate_no_name_collisions!
|
|
213
|
+
name_overlap = @input.keys & @outputs.keys
|
|
214
|
+
unless name_overlap.empty?
|
|
215
|
+
raise ArgumentError, "input and prior step output names collide: #{name_overlap.inspect} - rename one"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# self.class.method_defined?, not respond_to?: respond_to? would consult
|
|
219
|
+
# respond_to_missing? above, which checks @input/@outputs themselves - every key
|
|
220
|
+
# would "collide" with its own dynamic accessor. method_defined? only sees real,
|
|
221
|
+
# statically-defined methods: #input/#outputs (attr_reader) and inherited
|
|
222
|
+
# Object/Kernel ones (class, hash, freeze, send, ...) - not method_missing-served
|
|
223
|
+
# ones. A key literally named "input" would otherwise shadow the reader above
|
|
224
|
+
# with no error.
|
|
225
|
+
reserved = (@input.keys + @outputs.keys).select { |key| self.class.method_defined?(key) }
|
|
226
|
+
return if reserved.empty?
|
|
227
|
+
|
|
228
|
+
raise ArgumentError,
|
|
229
|
+
"input/output name(s) #{reserved.inspect} collide with an existing method on #{self.class} - rename them"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def symbolize_without_collision!(hash, label)
|
|
233
|
+
symbolized = hash.transform_keys(&:to_sym)
|
|
234
|
+
if symbolized.size != hash.size
|
|
235
|
+
raise ArgumentError, "two #{label} keys collapse to the same name once symbolized: #{hash.keys.inspect}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
symbolized.freeze
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belay
|
|
4
|
+
# How many REAL provider calls (an llm_call step where cache_hit: false - a
|
|
5
|
+
# hit or a rule-auto-settled gate made no call at all) this install made per
|
|
6
|
+
# 100 runs, by day. Backs the dashboard's Stats page - plain Ruby, not an
|
|
7
|
+
# ActiveRecord model of its own: there's no belay_daily_stats table, this is
|
|
8
|
+
# a read-only view over belay_runs/belay_steps' own created_at timestamps.
|
|
9
|
+
class DailyStats
|
|
10
|
+
Row = Struct.new(:date, :runs, :real_calls, :calls_per_100_runs, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# Calls are grouped by the STEP's own day, not the owning run's - a run
|
|
13
|
+
# can pause on a gate for a real, unbounded amount of time (nothing times
|
|
14
|
+
# a pending approval out; Phase 4's own rule-learning needs N separate,
|
|
15
|
+
# time-separated approvals to even happen), so a run created Jan 1 whose
|
|
16
|
+
# gate isn't approved until Jan 15 makes its post-gate steps' real calls
|
|
17
|
+
# on Jan 15, not Jan 1. Grouping by the run's day instead would silently
|
|
18
|
+
# mutate Jan 1's historical count every time an old run resumes - exactly
|
|
19
|
+
# backwards for a chart whose whole purpose is "is this week's cost down,"
|
|
20
|
+
# and would make a past day's bar change on a later page load. Runs are
|
|
21
|
+
# still grouped by their OWN created_at below - "how many runs started
|
|
22
|
+
# that day" is a legitimately different question from "how many real
|
|
23
|
+
# calls happened that day," and conflating them was the bug this comment
|
|
24
|
+
# replaced.
|
|
25
|
+
def self.for_range
|
|
26
|
+
calls_by_day = calls_by_day_counts
|
|
27
|
+
runs_by_day = runs_by_day_counts
|
|
28
|
+
|
|
29
|
+
days = (calls_by_day.keys + runs_by_day.keys).uniq.sort
|
|
30
|
+
days.map { |date| build_row(date, runs_by_day.fetch(date, 0), calls_by_day.fetch(date, 0)) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.calls_by_day_counts
|
|
34
|
+
normalized_counts(Step.llm_call.where(cache_hit: false).group("DATE(created_at)").count)
|
|
35
|
+
end
|
|
36
|
+
private_class_method :calls_by_day_counts
|
|
37
|
+
|
|
38
|
+
def self.runs_by_day_counts
|
|
39
|
+
normalized_counts(Run.group("DATE(created_at)").count)
|
|
40
|
+
end
|
|
41
|
+
private_class_method :runs_by_day_counts
|
|
42
|
+
|
|
43
|
+
# .group("DATE(...)").count's hash keys come back as plain Strings on some
|
|
44
|
+
# adapter/Rails version combinations, real Date objects on others - a raw
|
|
45
|
+
# SQL group-by fragment (not an actual typed column) gives ActiveRecord
|
|
46
|
+
# nothing to infer the cast from. Normalized here, once, so every lookup
|
|
47
|
+
# and every Row's date below is reliably a Date, regardless of which
|
|
48
|
+
# shape the DB adapter actually returned.
|
|
49
|
+
def self.normalized_counts(counts_by_raw_date)
|
|
50
|
+
counts_by_raw_date.transform_keys { |key| key.is_a?(Date) ? key : Date.parse(key.to_s) }
|
|
51
|
+
end
|
|
52
|
+
private_class_method :normalized_counts
|
|
53
|
+
|
|
54
|
+
def self.build_row(date, runs, calls)
|
|
55
|
+
Row.new(date: date, runs: runs, real_calls: calls,
|
|
56
|
+
calls_per_100_runs: runs.zero? ? 0.0 : (calls.to_f / runs * 100).round(1))
|
|
57
|
+
end
|
|
58
|
+
private_class_method :build_row
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/belay/engine.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Belay
|
|
6
|
+
# Makes Belay mountable in a host app: mount Belay::Engine => "/belay" in
|
|
7
|
+
# config/routes.rb. isolate_namespace keeps every route helper, controller and
|
|
8
|
+
# view path under the Belay:: namespace, so a host app's own RunsController or
|
|
9
|
+
# route named "runs" never collides with this gem's. isolate_namespace also
|
|
10
|
+
# gives the engine its own Zeitwerk autoloader for app/controllers and
|
|
11
|
+
# app/views - unlike every plain Ruby class under lib/belay/ (Run, Step,
|
|
12
|
+
# Approval, ...), which is deliberately require_relative'd from lib/belay.rb
|
|
13
|
+
# instead, with no Rails autoloading convention to lean on (see those files'
|
|
14
|
+
# own comments on this). Controllers are different: Rails engines are BUILT to
|
|
15
|
+
# autoload app/controllers this way, so this is the one place in the gem that
|
|
16
|
+
# actually uses it, via the conventional app/controllers + app/views +
|
|
17
|
+
# config/routes.rb directory layout at the gem root (not under lib/ -
|
|
18
|
+
# belay.gemspec's spec.files lists app/**/* and config/routes.rb alongside
|
|
19
|
+
# lib/**/* for exactly this).
|
|
20
|
+
class Engine < ::Rails::Engine
|
|
21
|
+
isolate_namespace Belay
|
|
22
|
+
|
|
23
|
+
# The dashboard's CSS, inlined into the layout via <style> rather than
|
|
24
|
+
# served through a <link> and whichever asset pipeline (Sprockets,
|
|
25
|
+
# Propshaft, none at all) the host app happens to have configured - that's
|
|
26
|
+
# exactly the kind of host-app assumption Belay has never made anywhere
|
|
27
|
+
# else (no Zeitwerk assumption, explicit class_name: throughout, routes
|
|
28
|
+
# drawn from the engine's own config/routes.rb). app/assets/belay/
|
|
29
|
+
# application.css is a committed, precompiled Tailwind build (see
|
|
30
|
+
# package.json's build:css) - there is no runtime build step, and this
|
|
31
|
+
# gem's own end users never run npm.
|
|
32
|
+
#
|
|
33
|
+
# Memoized at the class level (not per-request, unlike an instance
|
|
34
|
+
# variable in a helper) - the file never changes while the process is
|
|
35
|
+
# running, so re-reading it on every single request would be pure waste.
|
|
36
|
+
def self.stylesheet
|
|
37
|
+
@stylesheet ||= root.join("app/assets/belay/application.css").read
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|