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).
storedcarries them through untouched. %w[group tags halt_when].freeze
Instance Method Summary collapse
-
#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).
- #config_patch(r) ⇒ Object
-
#edge_float(raw, field) ⇒ Object
Strict float, blank = nil (inherit the default).
-
#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
coercedrop-to-nil semantics would turn a typo into "off" with a green flash). -
#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).
-
#evals_patch(r) ⇒ Object
Evals graders (RFC-0013 §3.9).
-
#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.
-
#guardrail_responses_patch(r) ⇒ Object
Per-category safe-reply overrides (RFC-0009 §7, config over convention).
-
#guardrails_patch(r) ⇒ Object
Guardrails config from the form (RFC-0009).
-
#mcp_patch(r) ⇒ Object
MCP instance from the form.
-
#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.
-
#model_params_patch(r) ⇒ Object
Per-model reasoning defaults (§10, the per-model layer).
-
#model_policy_patch(r) ⇒ Object
Per-agent model fence (v2, §10): { "allow" => [refs] } where a ref is "provider/model", "provider/*" or "model".
-
#params_patch(r) ⇒ Object
Per-agent generation params (v2, §10).
-
#parse_kv_lines(text) ⇒ Object
"KEY=value" per line -> Hash.
-
#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.
-
#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.. -
#provider_patch(r) ⇒ Object
LLM provider from the form.
- #settings_patch(r) ⇒ Object
-
#tool_patch(r, stored = nil) ⇒ Object
:write_data_tool payload from the form.
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.}" 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..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 |