Module: Studio::Forms

Included in:
App
Defined in:
lib/insika/studio/forms.rb

Overview

Form → command-payload parsing for the Studio App (§11 B6). A mixin, NOT a standalone object: included into App so every method keeps self == the App instance and reaches its request helpers (presence, split_list) and per-request state (@agent) exactly as before. Pure extraction — the bodies moved verbatim from app.rb; app_spec's POST cases are the safety net.

Constant Summary collapse

UNEDITED_TOOL_FIELDS =

Fields a tool CAN carry that this form does not render. The store REPLACES the record on write, so anything missing here is erased — a save that only fixed a typo in the description would silently drop them (the class of bug §12 already paid for once). stored carries them through untouched.

%w[group tags halt_when].freeze

Instance Method Summary collapse

Instance Method Details

#coerce(raw) ⇒ Object

Parses a numeric-ish form field, returning nil for blank or unparseable input (never raises — a bad value must not turn a config save into a 500).



93
94
95
96
97
98
99
100
# File 'lib/insika/studio/forms.rb', line 93

def coerce(raw)
  v = presence(raw)
  return nil unless v

  yield(v)
rescue ArgumentError, TypeError
  nil
end

#config_patch(r) ⇒ Object



10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/insika/studio/forms.rb', line 10

def config_patch(r)
  limits = @agent.limits.dup
  # Always present via DEFAULT_LIMITS (tool_concurrency defaults to 1 = serial),
  # so a blank field just keeps whatever is stored.
  %w[turn_timeout tool_timeout tool_concurrency].each do |field|
    v = presence(r.params[field])
    limits[field.to_sym] = Integer(v) if v
  end
  # Per-agent edge overrides (item 33). Unlike the timeouts (always present via
  # DEFAULT_LIMITS), these are OPT-IN keys: blank DELETES the override (inherit
  # the platform edge settings); 0 explicitly disables for this agent.
  %w[chat_rate_limit agent_token_ceiling].each do |field|
    v = edge_int(r.params[field], field)
    if v.nil?
      limits.delete(field.to_sym)
    else
      limits[field.to_sym] = v
    end
  end
  {
    id: @agent.id,
    model: presence(r.params["model"]),
    provider: r.params["provider"].to_s,
    memory: r.params["memory"] == "1",
    limits: limits,
    params: params_patch(r),
    model_policy: model_policy_patch(r),
    guardrails: guardrails_patch(r)
  }
end

#edge_float(raw, field) ⇒ Object

Strict float, blank = nil (inherit the default). Mirrors edge_int.



239
240
241
242
243
244
245
246
# File 'lib/insika/studio/forms.rb', line 239

def edge_float(raw, field)
  v = presence(raw)
  return nil unless v

  Float(v)
rescue ArgumentError, TypeError
  raise Insika::ValidationError, "#{field} must be a number (got #{raw.inspect})"
end

#edge_int(raw, field) ⇒ Object

Strict integer for the edge fields: blank = nil (off / inherit — intentional), but an UNPARSEABLE value must not silently disable a production limit (the coerce drop-to-nil semantics would turn a typo into "off" with a green flash). ValidationError -> with_flash renders it as the red flash, no dispatch.



252
253
254
255
256
257
258
259
# File 'lib/insika/studio/forms.rb', line 252

def edge_int(raw, field)
  v = presence(raw)
  return nil unless v

  Integer(v)
rescue ArgumentError, TypeError
  raise Insika::ValidationError, "#{field} must be an integer (got #{raw.inspect})"
end

#edge_patch(r) ⇒ Object

Edge limits (item 33 / §12 G7) — the platform rate-limit/cost layer, saved from its OWN form (a sub-resource, like models). The limit fields write nil when blank (off — the EdgeLimiter reads nil/0 as off); the windows fall back to the built-in defaults when cleared (the limiter guards non-positive).



188
189
190
191
192
193
194
195
# File 'lib/insika/studio/forms.rb', line 188

def edge_patch(r)
  edge = {}
  %w[chat_rate_limit chat_rate_window agent_token_ceiling agent_token_window].each do |f|
    edge[f] = edge_int(r.params[f], f)
  end
  edge["limit_response"] = presence(r.params["limit_response"])
  { "edge" => edge }
end

#evals_patch(r) ⇒ Object

Evals graders (RFC-0013 §3.9). judges is one provider/model per LINE — a textarea, because the SIZE of the panel is the point and a fixed pair of fields would cap it at two. A bare model (no slash) is valid: the provider is then inferred by RubyLLM, same as everywhere else.



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/insika/studio/forms.rb', line 216

def evals_patch(r)
  judges = r.params["judges"].to_s.lines.filter_map do |line|
    ref = presence(line)
    next unless ref

    provider, model = ref.include?("/") ? ref.split("/", 2) : [nil, ref]
    { "provider" => presence(provider), "model" => presence(model) }.compact
  end.reject { |j| j["model"].nil? }

  {
    "evals" => {
      "judges" => judges,
      "aggregate" => %w[median mean min].include?(r.params["aggregate"]) ? r.params["aggregate"] : "median",
      # Same strictness as the edge fields: a typo must not silently become "off"
      # (min_agreement 0 would pass a case no judge liked).
      "min_agreement" => edge_float(r.params["min_agreement"], "min_agreement"),
      "quorum" => edge_int(r.params["quorum"], "quorum"),
      "tolerance" => edge_float(r.params["tolerance"], "tolerance")
    }.compact
  }
end

#golden_patch(r) ⇒ Object

An eval case arrives as the YAML text the operator edited — the same shape the corpus files hold, so there is one format to learn and a pull request can review what was authored. Decoding happens HERE, at the transport edge; the SHAPE is validated by the one loader inside the store, so both doors agree.



201
202
203
204
205
206
207
208
209
210
# File 'lib/insika/studio/forms.rb', line 201

def golden_patch(r)
  parsed = begin
    YAML.safe_load(r.params["yaml"].to_s, permitted_classes: [], aliases: false)
  rescue Psych::SyntaxError => e
    raise Insika::ValidationError, "invalid YAML: #{e.message}"
  end
  raise Insika::ValidationError, "the case must be a YAML mapping (id/agent/turns/expect)" unless parsed.is_a?(Hash)

  { case: parsed }
end

#guardrail_responses_patch(r) ⇒ Object

Per-category safe-reply overrides (RFC-0009 §7, config over convention). Only the non-blank fields are persisted; Safety::Config normalizes on read.



59
60
61
62
63
# File 'lib/insika/studio/forms.rb', line 59

def guardrail_responses_patch(r)
  %w[default injection sexual abuse escalate].each_with_object({}) do |cat, acc|
    (v = presence(r.params["guardrail_response_#{cat}"])) && (acc[cat] = v)
  end
end

#guardrails_patch(r) ⇒ Object

Guardrails config from the form (RFC-0009). The config form OWNS these fields, so the patch reflects the whole guardrails state. String values round-trip cleanly through the JSON store; Safety::Config normalizes on read. A blank moderator drops the key (deterministic only).



45
46
47
48
49
50
51
52
53
54
55
# File 'lib/insika/studio/forms.rb', line 45

def guardrails_patch(r)
  out = {
    "input" => r.params["guardrail_input"] == "1",
    "output" => r.params["guardrail_output"] == "1",
    "strictness" => presence(r.params["guardrail_strictness"]) || "medium"
  }
  (mod = presence(r.params["guardrail_moderator"])) && (out["moderator"] = mod)
  responses = guardrail_responses_patch(r)
  out["responses"] = responses unless responses.empty?
  out
end

#mcp_patch(r) ⇒ Object

MCP instance from the form. env comes as "KEY=value" lines (CSP forbids inline JS for add/remove line; a textarea is the simple, honest path). Masked values come back as a sentinel — keeping them preserves the secret; changing replaces; deleting the line clears it.



314
315
316
317
318
319
320
321
322
323
324
# File 'lib/insika/studio/forms.rb', line 314

def mcp_patch(r)
  {
    name: presence(r.params["name"]),
    transport: presence(r.params["transport"]) || "stdio",
    command: r.params["command"].to_s,
    url: r.params["url"].to_s,
    description: r.params["description"].to_s,
    enabled: r.params["enabled"] == "1",
    env: parse_kv_lines(r.params["env"])
  }
end

#model_defaults_patch(r) ⇒ Object

LLM config v2 (§10) — the platform model layer, saved from its OWN form (a sub-resource, like providers) so the general-settings save never touches it and vice-versa. The scalar refs are always present (blank -> nil, which the deep-merge writes and the ModelResolver reads as "no platform default"); fallback_models is a full-list replace (deep_merge substitutes arrays, so a resubmit never accumulates).



267
268
269
270
271
272
273
274
275
276
# File 'lib/insika/studio/forms.rb', line 267

def model_defaults_patch(r)
  {
    "default_model" => presence(r.params["default_model"]),
    "default_provider" => presence(r.params["default_provider"]),
    "utility_model" => presence(r.params["utility_model"]),
    "fallback_models" => split_list(r.params["fallback_models"]),
    # GLOBAL reasoning default (§10, 4-layer). Blank -> nil = provider default.
    "thinking" => presence(r.params["thinking"])
  }
end

#model_params_patch(r) ⇒ Object

Per-model reasoning defaults (§10, the per-model layer). One line per model:

<provider/model | model> | <off|on|low|medium|high>

CSP forbids JS for dynamic rows, so a textarea of lines is the honest path (same idiom as the MCP env / tool params). A blank effort -> {} for that ref (an inherit no-op). deep_merge merges per-ref, so refs accumulate; clearing an override = set its effort blank (removing the ref entirely is a later refinement).



284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/insika/studio/forms.rb', line 284

def model_params_patch(r)
  map = r.params["model_params"].to_s.each_line.each_with_object({}) do |line, acc|
    line = line.strip
    next if line.empty? || line.start_with?("#")

    ref, thinking = line.split("|", 2).map(&:strip)
    next if ref.to_s.empty?

    acc[ref] = { "thinking" => presence(thinking) }.compact
  end
  { "model_params" => map }
end

#model_policy_patch(r) ⇒ Object

Per-agent model fence (v2, §10): { "allow" => [refs] } where a ref is "provider/model", "provider/*" or "model". Blank textarea -> nil (NO fence, all models — parity). Enforced on the RESOLVED model by the ModelResolver, so a per-chat pin can never escape it.



86
87
88
89
# File 'lib/insika/studio/forms.rb', line 86

def model_policy_patch(r)
  refs = split_list(r.params["model_policy_allow"])
  refs.empty? ? nil : { "allow" => refs }
end

#params_patch(r) ⇒ Object

Per-agent generation params (v2, §10). The config form OWNS these fields, so the patch reflects the whole form state: a blank field drops the key (and an all-blank form clears params to {}). temperature/max_tokens MUST be Numeric — ModelSelection#apply_params guards on numeric? and silently skips a String — so coerce here; a malformed number is dropped rather than 500-ing the save. thinking is a reasoning-effort string (low/medium/high), applied verbatim.



71
72
73
74
75
76
77
78
79
80
# File 'lib/insika/studio/forms.rb', line 71

def params_patch(r)
  out = {}
  t = coerce(r.params["temperature"]) { |v| Float(v) }
  out["temperature"] = t unless t.nil?
  m = coerce(r.params["max_tokens"]) { |v| Integer(v) }
  out["max_tokens"] = m unless m.nil?
  thinking = presence(r.params["thinking"])
  out["thinking"] = thinking if thinking
  out
end

#parse_kv_lines(text) ⇒ Object

"KEY=value" per line -> Hash. Ignores blank lines and comments (#).



327
328
329
330
331
332
333
334
335
336
337
338
# File 'lib/insika/studio/forms.rb', line 327

def parse_kv_lines(text)
  text.to_s.each_line.filter_map do |line|
    line = line.strip
    next if line.empty? || line.start_with?("#")

    k, v = line.split("=", 2)
    k = k.to_s.strip
    next if k.empty?

    [k, v.to_s.strip]
  end.to_h
end

#parse_param_lines(text) ⇒ Object

Flat sugar: one line per param, pipe-delimited (CSP forbids JS for dynamic lines; a textarea is the honest path, like the MCP env):

name | type | required|optional | description


154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/insika/studio/forms.rb', line 154

def parse_param_lines(text)
  text.to_s.each_line.filter_map do |line|
    line = line.strip
    next if line.empty? || line.start_with?("#")

    name, type, req, desc = line.split("|", 4).map(&:strip)
    next if name.to_s.empty?

    { "name" => name, "type" => (presence(type) || "string"),
      "required" => req.to_s.downcase != "optional", "description" => desc.to_s }
  end
end

#parse_parameters(text) ⇒ Object

Parameters, TWO accepted syntaxes in one field (auto-detected, no mode toggle): text starting with { is a full JSON Schema — the only form that expresses nesting (an array of objects, a nested object); anything else is the flat pipe-delimited sugar. This is what keeps the editor from being a lossy round-trip: a nested tool renders as JSON here and saves back as the same JSON, instead of being flattened into a shape its author never wrote.



140
141
142
143
144
145
146
147
148
149
# File 'lib/insika/studio/forms.rb', line 140

def parse_parameters(text)
  s = text.to_s.strip
  return parse_param_lines(text) unless s.start_with?("{")

  begin
    JSON.parse(s)
  rescue JSON::ParserError => e
    raise Insika::ValidationError, "parameters: invalid JSON Schema (#{e.message.lines.first.to_s.strip})"
  end
end

#provider_patch(r) ⇒ Object

LLM provider from the form. api_key is sentinel-aware: the form pre-fills with the sentinel when a key already exists, so resubmitting without touching preserves it; a new string replaces it; "" clears it. models = CSV.



300
301
302
303
304
305
306
307
308
# File 'lib/insika/studio/forms.rb', line 300

def provider_patch(r)
  {
    api: presence(r.params["api"]),
    base_url: r.params["base_url"].to_s,
    auth_header: r.params["auth_header"].to_s,
    api_key: r.params["api_key"].to_s,
    models: split_list(r.params["models"])
  }
end

#settings_patch(r) ⇒ Object



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/insika/studio/forms.rb', line 167

def settings_patch(r)
  patch = {
    "streaming" => r.params["streaming"] == "1",
    "compaction" => {
      "enabled" => r.params["compaction_enabled"] == "1"
    }
  }
  { "request_timeout" => "request_timeout", "max_retries" => "max_retries",
    "turn_timeout" => "turn_timeout", "tool_timeout" => "tool_timeout" }.each_key do |f|
    v = presence(r.params[f])
    patch[f] = Integer(v) if v
  end
  kl = presence(r.params["keep_last"])
  patch["compaction"]["keep_last"] = Integer(kl) if kl
  patch
end

#tool_patch(r, stored = nil) ⇒ Object

:write_data_tool payload from the form. nested request/response; headers/query as "key=value" per line (same idiom as the MCP env — a masked secret comes back as a sentinel and is reconciled in the store). stored = the definition being edited (nil when creating).



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/insika/studio/forms.rb', line 112

def tool_patch(r, stored = nil)
  preserved = (stored || {}).slice(*UNEDITED_TOOL_FIELDS).compact
  preserved.merge(
    name: presence(r.params["name"]),
    description: r.params["description"].to_s,
    parameters: parse_parameters(r.params["parameters"]),
    request: {
      method: presence(r.params["method"]) || "GET",
      url: r.params["url"].to_s,
      headers: parse_kv_lines(r.params["headers"]),
      query: parse_kv_lines(r.params["query"]),
      body: presence(r.params["body"])
    },
    response: {
      extract: presence(r.params["extract"]) || "body_raw",
      path: presence(r.params["path"])
    },
    secret_headers: split_list(r.params["secret_headers"]),
    timeout: presence(r.params["timeout"])
  )
end