studio-engine 0.121.0 → 0.123.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +121 -0
  3. data/README.md +2 -2
  4. data/app/controllers/concerns/studio/admin_models.rb +24 -16
  5. data/app/controllers/concerns/studio/error_handling.rb +13 -0
  6. data/app/controllers/studio/agent_chats_controller.rb +31 -4
  7. data/app/controllers/studio/voice_takes_controller.rb +66 -2
  8. data/app/controllers/style_controller.rb +2 -5
  9. data/app/controllers/theme_settings_controller.rb +8 -8
  10. data/app/helpers/studio/agent_chat_helper.rb +37 -0
  11. data/app/javascript/studio/agent_chat.js +42 -2
  12. data/app/javascript/studio/agent_chat_voice.js +318 -0
  13. data/app/javascript/studio/alpine_shims.js +1 -3
  14. data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
  15. data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
  16. data/app/javascript/studio/microphone_chip.js +8 -3
  17. data/app/javascript/studio/stimulus.js +3 -0
  18. data/app/javascript/studio/style_guide.js +2 -0
  19. data/app/javascript/studio/style_section_nav.js +16 -0
  20. data/app/javascript/studio/theme_editor.js +6 -106
  21. data/app/javascript/studio/voice_profile.js +11 -1
  22. data/app/javascript/studio/voice_recorder.js +36 -4
  23. data/app/models/studio/user_session.rb +76 -0
  24. data/app/models/theme_setting.rb +3 -0
  25. data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
  26. data/app/views/studio/admin_models/show.html.erb +22 -11
  27. data/app/views/studio/agent_chat/_chat.html.erb +17 -2
  28. data/app/views/studio/agent_chat/_form.html.erb +3 -2
  29. data/app/views/studio/agent_chat/_voice.html.erb +54 -0
  30. data/app/views/studio/voice/_microphone_help.html.erb +9 -4
  31. data/app/views/studio/voice/_styles.html.erb +4 -0
  32. data/app/views/style/_theme.html.erb +75 -12
  33. data/app/views/style/index.html.erb +28 -30
  34. data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
  35. data/docs/SITE_FOOTER.md +33 -0
  36. data/lib/studio/agent_chat.rb +83 -5
  37. data/lib/studio/site_footer.rb +45 -2
  38. data/lib/studio/ui_primitives.rb +4 -2
  39. data/lib/studio/version.rb +1 -1
  40. data/lib/studio/voice_transcription.rb +100 -34
  41. data/lib/studio.rb +81 -8
  42. metadata +11 -4
  43. data/app/views/studio/admin_models/index.html.erb +0 -31
  44. data/app/views/theme_settings/edit.html.erb +0 -304
@@ -3,10 +3,10 @@
3
3
  application.html.erb, inheriting that app's navbar and theme, so every
4
4
  specimen restyles per app and in dark or light automatically.
5
5
 
6
- Five ENGINE sections, reached by the sticky section nav below and rendered
6
+ Five ENGINE sections, reached by the sticky title row below and rendered
7
7
  as sibling partials: Theme is the landing section (the color foundation - it
8
- owns the role tokens/swatches, the folded-in /admin/theme editor, and the
9
- live preview), then Modals (the shared host + engine card blocks), Tricks
8
+ owns the role tokens/swatches, the theme editor, the live preview, and the
9
+ logos and type), then Modals (the shared host + engine card blocks), Tricks
10
10
  (the button/surface/motion/effect/leveling primitives, framed as a board of
11
11
  copy-paste-for-an-agent snippets), Tasks (the shared board primitive), and
12
12
  Components (the ViewComponent previews, linking into the gallery at
@@ -54,36 +54,34 @@
54
54
  ]
55
55
  %>
56
56
  <div class="max-w-6xl mx-auto px-4 pb-16">
57
- <header class="space-y-2 pt-8 pb-6">
58
- <p class="label-upper">studio-engine &middot; v<%= Studio::VERSION %></p>
59
- <h1 class="text-3xl font-bold text-heading">Style</h1>
60
- <p class="text-body max-w-2xl">
61
- A living gallery of the shared UI primitives, rendered live in this app's
62
- theme. Everything below is styled by the engine's own
63
- <code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">engine.css</code>
64
- and
65
- <code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">engine-motion.css</code>
66
- layers - toggle this app between light and dark and every specimen follows.
67
- </p>
68
- </header>
69
-
70
- <%# Section nav — sticky under the whole pinned stack (--pin-stack-bottom; then
71
- --nav-bottom and --nav-h for a host on an older publisher; then 0). Not --nav-h first: that
72
- is the header's HEIGHT, and chrome stacked above the header would sit over
73
- the nav by its own height. A pill per
74
- section; the row scrolls horizontally on narrow screens, its edges softened
75
- by the .fade-edge-x primitive. Order leads with Theme (the color foundation).
76
- It sits at --z-sticky, above --z-raised, so the live hold button's portal
77
- bubbles scroll under it rather than over it. %>
78
- <nav class="sticky -mx-4 px-4 py-3 bg-surface border-y border-subtle"
79
- style="top: var(--pin-stack-bottom, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)" aria-label="Style guide sections">
80
- <div class="fade-edge-x overflow-x-auto">
81
- <div class="flex items-center gap-2 min-w-max">
82
- <% sections.each do |anchor, label| %>
83
- <a href="#<%= anchor %>" class="btn btn-neutral btn-sm"><%= label %></a>
57
+ <%# The page's title and its section tabs, one sticky row under the whole pinned
58
+ stack (--pin-stack-bottom; then --nav-bottom and --nav-h for a host on an
59
+ older publisher; then 0). Not --nav-h first: that is the header's HEIGHT,
60
+ and chrome stacked above the header would sit over the row by its own
61
+ height. The tabs scroll sideways on a narrow screen, softened by
62
+ .fade-edge-x; px-8 matches its 2rem fade, so a tab at rest is not faded.
63
+ The row sits at --z-sticky, above --z-raised, so the live hold button's
64
+ portal bubbles scroll under it rather than over it.
65
+
66
+ The style-section-nav controller moves the highlight as the page scrolls
67
+ (studio/style_section_nav). Theme starts active, so the first paint is
68
+ right before the module loads. %>
69
+ <nav class="sticky -mx-4 mt-6 px-4 py-2 bg-surface border-y border-subtle flex items-center gap-2"
70
+ style="top: var(--pin-stack-bottom, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)"
71
+ aria-label="Style guide sections"
72
+ data-studio-controller="style-section-nav">
73
+ <h1 class="text-xl font-bold text-heading shrink-0">Style</h1>
74
+ <div class="fade-edge-x overflow-x-auto min-w-0 flex-1" data-style-section-nav-target="scroller">
75
+ <div class="flex items-center gap-2 min-w-max px-8">
76
+ <% sections.each_with_index do |(anchor, label), index| %>
77
+ <a href="#<%= anchor %>"
78
+ class="btn btn-sm <%= index.zero? ? "btn-primary" : "btn-neutral" %>"
79
+ <%= 'aria-current="true"'.html_safe if index.zero? %>
80
+ data-style-section-nav-target="link"><%= label %></a>
84
81
  <% end %>
85
82
  </div>
86
83
  </div>
84
+ <p class="label-upper shrink-0 hidden md:block" title="studio-engine">v<%= Studio::VERSION %></p>
87
85
  </nav>
88
86
 
89
87
  <div class="space-y-16 pt-10">
@@ -0,0 +1,16 @@
1
+ # One row per signed-in browser session (Studio::UserSession). A small, cached
2
+ # record every app keeps the same way. No foreign key: a host's users table is
3
+ # its own, and a deleted user's sessions may stay as counts.
4
+ class CreateStudioUserSessions < ActiveRecord::Migration[7.2]
5
+ def change
6
+ create_table :studio_user_sessions do |t|
7
+ t.bigint :user_id, null: false
8
+ t.boolean :admin, null: false, default: false
9
+ t.datetime :started_at, null: false
10
+ t.datetime :last_seen_at, null: false
11
+ t.timestamps
12
+ t.index :user_id
13
+ t.index :last_seen_at
14
+ end
15
+ end
16
+ end
data/docs/SITE_FOOTER.md CHANGED
@@ -319,6 +319,39 @@ config.site_footer = ->(view) {
319
319
  only with a lockup (`studio/site_footer/_lockup_assets`). An engine older
320
320
  than the fact ignores the key, so a host can declare it before it upgrades.
321
321
 
322
+ ## The Chat link
323
+
324
+ The standard way to reach an app's agent chat ([`AGENT_CHAT.md`](AGENT_CHAT.md)):
325
+ a plain link at the end of the column headed **Contact** (any case), or a new
326
+ Contact column at the end if the facts have none. An app that sets nothing sees
327
+ no change.
328
+
329
+ ```ruby
330
+ config.site_footer_chat_path = "/chat" # or ->(view) { view.chat_path }
331
+ # config.site_footer_chat_label = "Chat" # the default
332
+ # config.site_footer_chat_allowed = ->(user) { user&.admin? } # default: Studio.agent_chat_allowed
333
+ ```
334
+
335
+ | Setting | Meaning | Unset |
336
+ |---------|---------|-------|
337
+ | `site_footer_chat_path` | A path, or a callable taking the view (route helpers do not exist yet in an initializer). It answers nil to show no link on this page | No link |
338
+ | `site_footer_chat_label` | The link's words | "Chat" |
339
+ | `site_footer_chat_allowed` | A callable taking the viewer: `current_user`, **nil when signed out**, so write `user&.admin?` | `Studio.agent_chat_allowed` (everyone, by default) |
340
+
341
+ - The link looks and behaves as any other column link (`_link.html.erb`). An href
342
+ the footer refuses (`javascript:`) adds nothing, and a Contact column that
343
+ already links the same path keeps its own row and label.
344
+ - A hook that raises hides the link and reports once through
345
+ `Studio::SiteFooter.reporter`, so `user.admin?` on a signed-out viewer shows the
346
+ link to no one and costs no page. Write `user&.admin?` all the same.
347
+ - **Hiding the link is not a gate.** `Studio.agent_chat_allowed` is
348
+ ([`AGENT_CHAT.md`](AGENT_CHAT.md), "Who may use it"); set that one, and the link
349
+ follows it.
350
+ - **An admin-only link needs a page that shows the footer to a signed-in viewer.**
351
+ A signed-in viewer sees the footer only on `site_footer_controllers` (see
352
+ *Where it shows*), so list the pages your admins will look at.
353
+ - Never cache the footer across viewers while the link is limited.
354
+
322
355
  ## Where it shows
323
356
 
324
357
  `studio_site_footer` asks `config.site_footer_visible`, one callable that
@@ -16,8 +16,8 @@ module Studio
16
16
  #
17
17
  # THE CONTRACT (.agents/epics/agent-chat-primitive.md, "Contract"):
18
18
  #
19
- # POST /api/v1/agent_chat/messages soul, text, person, ip
20
- # 201 {turn} · 422 {error} · 429 capped · 404 soul has no web chat
19
+ # POST /api/v1/agent_chat/messages soul, text, person, ip, voice (optional)
20
+ # 201 {turn, voice} · 422 {error} · 429 capped · 404 soul has no web chat
21
21
  # GET /api/v1/agent_chat/turns soul, person, after, limit (<= 50)
22
22
  # 200 {turns: [...], pending: bool}
23
23
  #
@@ -27,6 +27,11 @@ module Studio
27
27
  # {id, role: "user"|"agent", text, created_at, status} with status
28
28
  # pending|typing|answered|failed.
29
29
  #
30
+ # `voice` marks a spoken message: {check, score, duration_ms}, what POST
31
+ # /voice/takes answered for the take (voice_param). NEVER A NAME: the hub
32
+ # names the speaker itself (the verified person's own name when check is
33
+ # "matched", else "Someone new") and may answer what it kept as `voice`.
34
+ #
30
35
  # AUTHENTICATION is the APP's own bearer credential (Studio.agent_chat_token,
31
36
  # else ENV["AGENT_CHAT_TOKEN"]), never a user's. It is sent only over TLS: a
32
37
  # plain-http hub URL is refused unless its host is this machine (a developer
@@ -83,6 +88,31 @@ module Studio
83
88
  MAX_TEXT_LENGTH = 4_000
84
89
  # A hub answer larger than this is not a chat answer.
85
90
  MAX_BODY_BYTES = 1_048_576
91
+ # The words beside the microphone, approved by Alex McRitchie on 2026-11-01.
92
+ # Verbatim here and on an app's privacy page, from this one constant.
93
+ VOICE_PRIVACY = "When you speak to an agent, your recording is sent to OpenAI to turn it " \
94
+ "into text, then deleted. We never keep the audio."
95
+
96
+ # The hold-to-talk button's words per recorder state (merged over
97
+ # Studio::VoiceCapture::RECORDER_COPY). The recorder says nothing while it
98
+ # records: the chat's own status line says "Listening...", so the button does
99
+ # not move under a held finger when the line appears.
100
+ VOICE_RECORDER_COPY = {
101
+ "idle" => { "button" => "Hold to talk" },
102
+ "recording" => { "button" => "Let go to send", "status" => "", "heard" => "" },
103
+ "captured" => { "button" => "Hold to talk", "status" => "" },
104
+ "silent" => { "button" => "Hold to talk" },
105
+ "failed" => { "button" => "Hold to try again" },
106
+ "denied" => { "button" => "Hold to talk" },
107
+ "unsupported" => { "button" => "Hold to talk" }
108
+ }.freeze
109
+
110
+ # The microphone chip's first words (the engine's say "tap Record").
111
+ VOICE_CHIP_COPY = {
112
+ "prompt" => { "chip" => "Microphone: hold to talk to allow",
113
+ "note" => "Hold the talk button once and allow the microphone. Then you can pick one here." }
114
+ }.freeze
115
+
86
116
  LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
87
117
  NETWORK_ERRORS = [
88
118
  Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error,
@@ -93,13 +123,49 @@ module Studio
93
123
  class << self
94
124
  # Stores one message from `person` to `soul` and answers its turn (a Hash
95
125
  # with string keys). Raises a Studio::AgentChat::Error.
96
- def post_message(soul:, text:, person:, ip: nil)
97
- body = { soul: soul.to_s, text: text.to_s, person: person_param(person), ip: ip.to_s.presence }.compact
126
+ #
127
+ # `voice` marks it as spoken (voice_param). When the hub answers what it
128
+ # kept, the returned Hash also carries it under "voice".
129
+ def post_message(soul:, text:, person:, ip: nil, voice: nil)
130
+ body = { soul: soul.to_s, text: text.to_s, person: person_param(person), ip: ip.to_s.presence,
131
+ voice: voice_param(voice) }.compact
98
132
  payload = request(:post, MESSAGES_PATH, body: body)
99
133
  turn = payload.is_a?(Hash) && payload["turn"].is_a?(Hash) ? payload["turn"] : payload
100
134
  raise Unavailable.new("the hub answered with no turn", status: 201) unless turn.is_a?(Hash)
101
135
 
102
- turn
136
+ kept = payload["voice"] if payload.is_a?(Hash)
137
+ kept.is_a?(Hash) ? turn.merge("voice" => kept) : turn
138
+ end
139
+
140
+ # The words of the chat's voice option: {"privacy", "recorder", "chip"},
141
+ # the `overrides` (voice_copy: on studio/agent_chat/chat) over the defaults.
142
+ # A blank privacy line is the default one: the line is never just dropped.
143
+ def voice_copy(overrides = nil)
144
+ words = (overrides || {}).to_h.transform_keys(&:to_s)
145
+ {
146
+ "privacy" => words["privacy"].presence || VOICE_PRIVACY,
147
+ "recorder" => Studio::VoiceCapture.merge(VOICE_RECORDER_COPY, words["recorder"]),
148
+ "chip" => Studio::VoiceCapture.merge(VOICE_CHIP_COPY, words["chip"])
149
+ }
150
+ end
151
+
152
+ # What a message's `voice` may say to the hub, as the hub reads it: `check`
153
+ # is one of the engine's own (else Invalid), `score` a cosine (-1..1, 4
154
+ # places) or nil, `duration_ms` 1..MAX_DURATION_MS or nil. Everything else,
155
+ # a browser-chosen name above all, stops here. nil or "" is a typed message.
156
+ def voice_param(voice)
157
+ return nil if voice.nil? || voice == ""
158
+
159
+ voice = voice.to_unsafe_h if voice.respond_to?(:to_unsafe_h)
160
+ raise Invalid.new("voice must be {check, score, duration_ms}", status: 422) unless voice.is_a?(Hash)
161
+
162
+ voice = voice.transform_keys(&:to_s)
163
+ check = voice["check"].to_s
164
+ unless Studio::VoiceTranscription::CHECKS.include?(check)
165
+ raise Invalid.new("voice.check is not one the engine answers", status: 422)
166
+ end
167
+
168
+ { check: check, score: voice_score(voice["score"]), duration_ms: voice_duration(voice["duration_ms"]) }
103
169
  end
104
170
 
105
171
  # The thread's turns after `after` (a turn id; nil for the latest), and
@@ -118,6 +184,18 @@ module Studio
118
184
 
119
185
  private
120
186
 
187
+ def voice_score(raw)
188
+ return nil unless raw.is_a?(Numeric) || raw.is_a?(String)
189
+
190
+ value = Float(raw, exception: false)
191
+ value&.finite? ? value.clamp(-1.0, 1.0).round(4) : nil
192
+ end
193
+
194
+ def voice_duration(raw)
195
+ value = raw.to_s.match?(/\A\d{1,9}\z/) ? raw.to_s.to_i : nil
196
+ value&.between?(1, Studio::VoiceTranscription::MAX_DURATION_MS) ? value : nil
197
+ end
198
+
121
199
  # {email:}, {anon_token:}, or both: a signed-in post that still holds the
122
200
  # visitor's anon token sends both, and the hub folds that anonymous thread
123
201
  # into the person's.
@@ -170,7 +170,8 @@ module Studio
170
170
  # booking_path: the booking page's path (Studio.booking_path_for), or nil
171
171
  # address: Studio.site_footer_address: the address of facts that
172
172
  # carry none of their own
173
- def resolve(declared, view, name: nil, logo: nil, booking_path: nil, address: nil)
173
+ # chat: the Chat entry's [label, path] (`chat_row`), or nil
174
+ def resolve(declared, view, name: nil, logo: nil, booking_path: nil, address: nil, chat: nil)
174
175
  raw = declared.respond_to?(:call) ? declared.call(view) : declared
175
176
  return nil if raw.nil? || raw == false
176
177
 
@@ -189,7 +190,7 @@ module Studio
189
190
  email: text(facts[:email]),
190
191
  address: address_for(facts, address),
191
192
  social: Array(facts[:social]).filter_map { |row| social(row) },
192
- columns: Array(facts[:columns]).filter_map { |row| column(row, booking_path) },
193
+ columns: with_chat(Array(facts[:columns]).filter_map { |row| column(row, booking_path) }, chat),
193
194
  legal: Array(facts[:legal]).filter_map { |row| link(row, booking_path) },
194
195
  booking_label: text(booking[:label]) || DEFAULT_BOOKING_LABEL,
195
196
  booking_title: text(booking[:title])
@@ -374,6 +375,48 @@ module Studio
374
375
  { heading: heading, links: links, width: column_width(options[:width]) }
375
376
  end
376
377
 
378
+ # The column the standard Chat entry joins, made if the app has none.
379
+ CONTACT_HEADING = "Contact"
380
+
381
+ # The Chat entry for this viewer as a [label, path] row, or nil: no path
382
+ # means the app has no chat, and `allowed` (a callable taking the viewer, nil
383
+ # when signed out) can hide it, e.g. admins only while testing. A hook that
384
+ # raises hides the entry and logs; the footer is on every page, and a link
385
+ # meant for a few must not show because its gate broke.
386
+ def chat_row(view, path:, label: nil, allowed: nil)
387
+ href = path.respond_to?(:call) ? path.call(view) : path
388
+ return nil if text(href).nil?
389
+ return nil unless chat_allowed?(view, allowed)
390
+
391
+ [text(label) || "Chat", href]
392
+ end
393
+
394
+ def chat_allowed?(view, allowed)
395
+ return true if allowed.nil?
396
+
397
+ user = view.respond_to?(:current_user) ? view.current_user : nil
398
+ (allowed.respond_to?(:call) ? allowed.call(user) : allowed) ? true : false
399
+ rescue StandardError => error
400
+ SiteFooter.reporter.call("[studio.site_footer] site_footer_chat_allowed raised #{error.class}: the Chat link is hidden")
401
+ false
402
+ end
403
+
404
+ # `columns` with the Chat entry last in the Contact column (any case), which
405
+ # is added at the end if there is none. A refused href adds nothing, and an
406
+ # app that already links the same path keeps its own row.
407
+ def with_chat(columns, chat)
408
+ row = chat && link(chat)
409
+ return columns if row.nil? || row[:href].nil?
410
+
411
+ at = columns.index { |column| column[:heading].to_s.casecmp?(CONTACT_HEADING) }
412
+ return columns + [{ heading: CONTACT_HEADING, links: [row], width: nil }] if at.nil?
413
+ return columns if columns[at][:links].any? { |known| known[:href] == row[:href] }
414
+
415
+ columns.each_with_index.map do |column, index|
416
+ index == at ? column.merge(links: column[:links] + [row]) : column
417
+ end
418
+ end
419
+
377
420
  # A column's width hint, or nil for the default share. Anything that is not a
378
421
  # number between MIN_COLUMN_WIDTH and MAX_COLUMN_WIDTH is no hint.
379
422
  MIN_COLUMN_WIDTH = 0.5
@@ -10,8 +10,10 @@ module Studio
10
10
  align-items: center;
11
11
  justify-content: center;
12
12
  vertical-align: middle;
13
- width: 1.5rem;
14
- height: 1.25rem;
13
+ /* In em so a larger glyph fits: at text-lg on a 16px scale a fixed 1.25rem
14
+ box clipped 4px off a 24px emoji. The floor keeps the navbar size. */
15
+ width: max(1.5rem, 1.25em);
16
+ height: max(1.25rem, 1.25em);
15
17
  overflow: hidden;
16
18
  }
17
19
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.121.0"
2
+ VERSION = "0.123.0"
3
3
  end
@@ -5,6 +5,7 @@ require "net/http"
5
5
  require "openssl"
6
6
  require "securerandom"
7
7
  require "stringio"
8
+ require "timeout"
8
9
  require "uri"
9
10
 
10
11
  # Understand: one finished take (the recorder's blob and its mime type, plus the
@@ -38,17 +39,36 @@ require "uri"
38
39
  # logger and environment, so the unit suite runs it without an app.
39
40
  module Studio
40
41
  module VoiceTranscription
41
- # A transcriber call that failed: the message carries the vendor's words.
42
+ # A transcriber call that failed.
42
43
  class Error < StandardError; end
43
44
 
44
- # The vendor answered a non-2xx; the status rides along so a caller can tell
45
- # an auth failure from a model that fell over.
45
+ # The vendor failed. The status rides along (0 when nothing answered) with a
46
+ # reason code, so a caller can tell an auth failure from a model that fell
47
+ # over. The message names only the model, the status and the reason, never
48
+ # the vendor's own words: it reaches ErrorLog, and a vendor's error text can
49
+ # echo what it was sent.
46
50
  class HttpError < Error
47
- attr_reader :status
51
+ # Why, as a code: unauthorized (401/403), rate_limited (429),
52
+ # server_error (5xx), rejected (another non-2xx), timeout (the deadline
53
+ # ran out), unreachable (no answer: refused, reset, DNS, TLS).
54
+ REASONS = %w[unauthorized rate_limited server_error rejected timeout unreachable].freeze
48
55
 
49
- def initialize(message, status:)
56
+ attr_reader :status, :reason
57
+
58
+ def initialize(message, status:, reason: nil)
50
59
  super(message)
51
60
  @status = status
61
+ @reason = reason || self.class.reason_for(status)
62
+ end
63
+
64
+ def self.reason_for(status)
65
+ case status.to_i
66
+ when 401, 403 then "unauthorized"
67
+ when 429 then "rate_limited"
68
+ when 500..599 then "server_error"
69
+ when 0 then "unreachable"
70
+ else "rejected"
71
+ end
52
72
  end
53
73
  end
54
74
 
@@ -64,7 +84,8 @@ module Studio
64
84
 
65
85
  NO_LIVE_CALLS_ENV = "VOICE_NO_LIVE_CALLS"
66
86
  TRANSCRIBER_ENV = "STUDIO_VOICE_TRANSCRIBER"
67
- API_KEY_ENV = "OPENAI_API_KEY"
87
+ # Voice's own key. Deliberately NOT the app's shared OPENAI_API_KEY.
88
+ API_KEY_ENV = "STUDIO_VOICE_OPENAI_API_KEY"
68
89
 
69
90
  # What a page shows when the app has no transcriber key. Says what is wrong
70
91
  # without naming the variable to a visitor.
@@ -123,24 +144,38 @@ module Studio
123
144
  # The real speech-to-text: OpenAI's POST /v1/audio/transcriptions, one
124
145
  # multipart file. Ported from Voicebox's Transcription::OpenAI: the model is
125
146
  # told the language (left to guess, a model handed near-silence answers a
126
- # stock word in any language), and a failure other than auth is retried once
127
- # on the fallback model from the start of the audio.
147
+ # stock word in any language), and a model that fell over (a 5xx, or a 4xx
148
+ # other than auth and 429) is retried once on the fallback from the start of
149
+ # the audio.
150
+ #
151
+ # ONE DEADLINE for the whole take (Studio.voice_transcribe_deadline, 25 s):
152
+ # both attempts share it, every socket timeout is what is left of it, and a
153
+ # hard Timeout backs it, so a vendor that trickles bytes cannot hold a web
154
+ # thread past it. Nothing is retried after a timeout (the deadline is spent),
155
+ # a 429 (the account is throttled; a second model only spends more), an auth
156
+ # failure, or no answer at all; nor with under MIN_RETRY_SECONDS left.
128
157
  class OpenAI
129
158
  ENDPOINT = "https://api.openai.com/v1/audio/transcriptions"
159
+ # The cap on connecting, inside the deadline.
130
160
  OPEN_TIMEOUT = 10
131
- READ_TIMEOUT = 120
132
- AUTH_FAILURES = [401, 403].freeze
161
+ NO_RETRY = %w[unauthorized rate_limited timeout unreachable].freeze
162
+ MIN_RETRY_SECONDS = 2
133
163
 
134
- attr_reader :model, :fallback_model, :language, :endpoint
164
+ # The hard backstop's exception; turned into HttpError(reason: "timeout").
165
+ class DeadlineExceeded < Timeout::Error; end
166
+
167
+ attr_reader :model, :fallback_model, :language, :endpoint, :deadline
135
168
 
136
169
  def initialize(api_key: VoiceTranscription.api_key, model: Studio.voice_transcribe_model,
137
170
  fallback_model: Studio.voice_transcribe_fallback_model,
138
- language: Studio.voice_transcribe_language, endpoint: ENDPOINT)
171
+ language: Studio.voice_transcribe_language, endpoint: ENDPOINT,
172
+ deadline: Studio.voice_transcribe_deadline)
139
173
  @api_key = api_key
140
174
  @model = model.to_s
141
175
  @fallback_model = fallback_model.to_s.strip.empty? ? nil : fallback_model.to_s
142
176
  @language = language.to_s.strip.empty? ? nil : language.to_s
143
177
  @endpoint = endpoint
178
+ @deadline = deadline.to_f.positive? ? deadline.to_f : 25.0
144
179
  end
145
180
 
146
181
  def configured? = !@api_key.to_s.strip.empty?
@@ -149,13 +184,24 @@ module Studio
149
184
  def transcribe(io:, filename:, content_type:)
150
185
  raise NotConfigured, "#{API_KEY_ENV} is not set, so this app cannot transcribe voice" unless configured?
151
186
 
152
- request_text(model, io, filename, content_type)
153
- rescue HttpError => e
154
- raise if fallback_model.nil? || fallback_model == model || AUTH_FAILURES.include?(e.status)
187
+ ends_at = now + deadline
188
+ begin
189
+ request_text(model, io, filename, content_type, ends_at)
190
+ rescue HttpError => e
191
+ raise unless retry?(e, ends_at)
155
192
 
156
- VoiceTranscription.log_warn("#{model} transcription failed (#{e.message}); retrying on #{fallback_model}")
157
- io.rewind
158
- request_text(fallback_model, io, filename, content_type)
193
+ VoiceTranscription.log_warn("#{model} transcription failed (HTTP #{e.status}, #{e.reason}); " \
194
+ "retrying on #{fallback_model}")
195
+ io.rewind
196
+ request_text(fallback_model, io, filename, content_type, ends_at)
197
+ end
198
+ end
199
+
200
+ def retry?(error, ends_at)
201
+ return false if fallback_model.nil? || fallback_model == model
202
+ return false if NO_RETRY.include?(error.reason)
203
+
204
+ ends_at - now >= MIN_RETRY_SECONDS
159
205
  end
160
206
 
161
207
  # The multipart fields, apart from the socket so a test reads them.
@@ -184,36 +230,52 @@ module Studio
184
230
 
185
231
  private
186
232
 
187
- def request_text(model_name, io, filename, content_type)
188
- perform(model_name, io, filename, content_type).fetch("text", "").to_s.strip
233
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
234
+
235
+ def request_text(model_name, io, filename, content_type, ends_at)
236
+ perform(model_name, io, filename, content_type, ends_at).fetch("text", "").to_s.strip
189
237
  end
190
238
 
191
239
  # The only place a socket opens, so the only place the trap sits.
192
- def perform(model_name, io, filename, content_type)
240
+ # uri.hostname, not uri.host: an IPv6 literal's host keeps its brackets
241
+ # ("[::1]"), which neither the loopback check nor the connect reads.
242
+ def perform(model_name, io, filename, content_type, ends_at)
193
243
  uri = URI.parse(endpoint)
194
- VoiceTranscription.guard_live_call!("OpenAI", host: uri.host)
244
+ VoiceTranscription.guard_live_call!("OpenAI", host: uri.hostname)
245
+
246
+ left = ends_at - now
247
+ raise HttpError.new("OpenAI #{model_name} was not tried: the take's deadline ran out", status: 0, reason: "timeout") if left <= 0
195
248
 
196
249
  request = build_request(model_name, io, filename, content_type)
197
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
198
- open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
199
- http.request(request)
250
+ response = Timeout.timeout(left, DeadlineExceeded) do
251
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: [OPEN_TIMEOUT, left].min,
252
+ read_timeout: left, write_timeout: left) do |http|
253
+ http.request(request)
254
+ end
200
255
  end
201
256
  interpret(response, model_name)
202
- rescue Timeout::Error, SystemCallError, IOError, OpenSSL::SSL::SSLError, SocketError => e
203
- raise HttpError.new("OpenAI #{model_name} could not be reached (#{e.class})", status: 0)
257
+ rescue Timeout::Error => e
258
+ raise HttpError.new("OpenAI #{model_name} did not answer within the take's #{deadline.round(1)} s deadline (#{e.class})",
259
+ status: 0, reason: "timeout")
260
+ rescue SystemCallError, IOError, OpenSSL::SSL::SSLError, SocketError => e
261
+ raise HttpError.new("OpenAI #{model_name} could not be reached (#{e.class})", status: 0, reason: "unreachable")
204
262
  end
205
263
 
264
+ # A failure carries the status and its reason code, never the vendor's
265
+ # words (HttpError).
206
266
  def interpret(response, model_name)
267
+ status = response.code.to_i
268
+ unless response.is_a?(Net::HTTPSuccess)
269
+ reason = HttpError.reason_for(status)
270
+ raise HttpError.new("OpenAI #{model_name} answered HTTP #{status} (#{reason})", status: status, reason: reason)
271
+ end
272
+
207
273
  parsed = begin
208
274
  JSON.parse(response.body.to_s)
209
275
  rescue JSON::ParserError
210
276
  {}
211
277
  end
212
- parsed = {} unless parsed.is_a?(Hash)
213
- return parsed if response.is_a?(Net::HTTPSuccess)
214
-
215
- detail = parsed.dig("error", "message") || response.body.to_s[0, 200]
216
- raise HttpError.new("OpenAI #{model_name} answered HTTP #{response.code}: #{detail}", status: response.code.to_i)
278
+ parsed.is_a?(Hash) ? parsed : {}
217
279
  end
218
280
  end
219
281
 
@@ -269,7 +331,8 @@ module Studio
269
331
  klass.new
270
332
  end
271
333
 
272
- # Studio.voice_openai_api_key, else OPENAI_API_KEY (Voicebox's name).
334
+ # Studio.voice_openai_api_key, else STUDIO_VOICE_OPENAI_API_KEY. Never the
335
+ # app's shared OPENAI_API_KEY: voice spends on its own key and budget.
273
336
  def api_key
274
337
  key = Studio.voice_openai_api_key.to_s.strip
275
338
  key.empty? ? ENV[API_KEY_ENV].to_s.strip : key
@@ -285,9 +348,12 @@ module Studio
285
348
  rails_env == "test"
286
349
  end
287
350
 
351
+ # This machine. host is URI#hostname, so IPv6 arrives unbracketed.
352
+ LOOPBACK_HOSTS = %w[127.0.0.1 localhost ::1].freeze
353
+
288
354
  def guard_live_call!(vendor, host:)
289
355
  return unless live_calls_blocked?
290
- return if %w[127.0.0.1 localhost ::1].include?(host.to_s)
356
+ return if LOOPBACK_HOSTS.include?(host.to_s)
291
357
 
292
358
  raise LiveCallAttempted, "A live #{vendor} call was attempted with #{NO_LIVE_CALLS_ENV} armed. " \
293
359
  "Tests drive the mock transcriber (Studio.voice_transcriber = \"mock\")."