Module: Studio::Forms
- Included in:
- App
- Defined in:
- lib/insika/studio/forms.rb
Overview
Form → command-payload parsing for the Studio App. 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 already paid for once).
storedcarries them through untouched. %w[group tags halt_when].freeze
Instance Method Summary collapse
-
#alerts_patch(r) ⇒ Object
WS6: the operator-alert webhook.
-
#allowlist_patch(r, field) ⇒ Object
An ALLOWLIST-style field (nil = all, [] = none).
-
#budget_patch(r) ⇒ Object
WS2: the calendar spend caps.
-
#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
The agent config form OWNS every rendered field: a save reflects the whole form state, so a blank field is a deliberate "clear to default", never a carry-over.
-
#distill_patch(r) ⇒ Object
distillation — enabled checkbox + the forge's knobs.
-
#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 — the platform rate-limit/cost layer, saved from its OWN form (a sub-resource, like models).
-
#edge_stream_patch(r) ⇒ Object
Which internal channels may cross to the customer.
-
#evals_patch(r) ⇒ Object
Evals graders.
-
#followup_patch(r) ⇒ Object
the follow-up declaration.
-
#funnel_patch(r) ⇒ Object
the outcome funnel — the pack's own stage vocabulary, edited hot.
-
#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.
-
#grounding_patch(r) ⇒ Object
grounding — { "mode" => "flag"|"enforce", "matcher" => { "sku" => "sku:…", "name_keys" => [...] } }.
-
#guardrail_responses_patch(r) ⇒ Object
Per-category safe-reply overrides (config over convention).
-
#guardrails_patch(r) ⇒ Object
Guardrails config from the form.
- #harvest_miner_patch(r) ⇒ Object
-
#harvest_patch(r) ⇒ Object
the gated harvest.
- #json_block(raw, field) ⇒ Object
-
#json_patch(r, field) ⇒ Object
A JSON textarea -> parsed Hash/Array, or nil when blank.
-
#lines(str) ⇒ Object
One non-blank token per line.
- #list(str) ⇒ Object
-
#list_patch(r, field) ⇒ Object
A comma-separated list field.
-
#mcp_patch(r) ⇒ Object
MCP instance from the form (transport-aware, but the form always submits every field regardless of which the JS shows; McpStore stores them all either way).
-
#model_defaults_patch(r) ⇒ Object
LLM config v2 — 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 (the per-model layer).
-
#model_policy_patch(r) ⇒ Object
Per-agent model fence: { "allow" => [refs] } where a ref is "provider/model", "provider/*" or "model".
-
#params_patch(r) ⇒ Object
Per-agent generation params.
-
#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.
-
#refinement_patch(r) ⇒ Object
Refinement: mode select + the report/propose/auto_apply knobs.
-
#reliability_patch(r) ⇒ Object
WS3: retries/backoff/fallback/breaker/timeout.
-
#schedules_patch(r) ⇒ Object
recurring schedules — a JSON array of declarations (name / trigger / tz / message / session mode / overrides / enabled).
- #settings_patch(r) ⇒ Object
-
#skills_eager_patch(r) ⇒ Object
skills_eager: blank = none (progressive disclosure, parity); "all" = blanket eager; otherwise a comma list of exactly these.
-
#split_lines(text) ⇒ Object
One argv token per line (stdio's
args). -
#tool_patch(r, stored = nil) ⇒ Object
:write_data_tool payload from the form.
Instance Method Details
#alerts_patch(r) ⇒ Object
WS6: the operator-alert webhook. Blank = no webhook (parity).
257 258 259 260 |
# File 'lib/insika/studio/forms.rb', line 257 def alerts_patch(r) url = presence(r.params["alerts_webhook"]) url ? { "webhook" => url } : nil end |
#allowlist_patch(r, field) ⇒ Object
An ALLOWLIST-style field (nil = all, [] = none). Blank → nil (all), the open reading; a typed list closes it to exactly those names.
303 304 305 306 |
# File 'lib/insika/studio/forms.rb', line 303 def allowlist_patch(r, field) names = list(r.params[field]) names.empty? ? nil : names end |
#budget_patch(r) ⇒ Object
WS2: the calendar spend caps. All blank = no budget (parity).
220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'lib/insika/studio/forms.rb', line 220 def budget_patch(r) daily = edge_int(r.params["budget_daily"], "budget_daily") monthly = edge_int(r.params["budget_monthly"], "budget_monthly") return nil if daily.nil? && monthly.nil? out = {} out["daily"] = daily unless daily.nil? out["monthly"] = monthly unless monthly.nil? (a = edge_float(r.params["budget_alert_at"], "budget_alert_at")) && (out["alert_at"] = a) out["soft"] = r.params["budget_soft"] == "1" out end |
#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).
376 377 378 379 380 381 382 383 |
# File 'lib/insika/studio/forms.rb', line 376 def coerce(raw) v = presence(raw) return nil unless v yield(v) rescue ArgumentError, TypeError nil end |
#config_patch(r) ⇒ Object
The agent config form OWNS every rendered field: a save reflects the
whole form state, so a blank field is a deliberate "clear to default",
never a carry-over. The one exception is keys the form cannot express —
those ride the carry merge below (the guardrails corpora precedent).
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 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/insika/studio/forms.rb', line 14 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. 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 # Burst policy (QueuePolicy). Opt-in keys, same convention as the edge # overrides above: blank DELETES (inherit platform / DEFAULTS — followup, # steer_max 5); an explicit value pins it for this agent. steer_max 0 is # a legitimate "never steer" and is stored, not deleted. if (qm = presence(r.params["queue_mode"])) unless Insika::QueuePolicy::MODES.map(&:to_s).include?(qm) raise Insika::ValidationError, "queue_mode must be one of: #{Insika::QueuePolicy::MODES.join(', ')}" end limits[:queue_mode] = qm else limits.delete(:queue_mode) end smm = edge_int(r.params["steer_max_messages"], "steer_max_messages") smm.nil? ? limits.delete(:steer_max_messages) : limits[:steer_max_messages] = smm { 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), # Every remaining AgentProfile field is editable here — the pack # import is one door, this form is the other (hot, no restart). grounding: grounding_patch(r), funnel: funnel_patch(r), followup: followup_patch(r), schedules: schedules_patch(r), distill: distill_patch(r), harvest: harvest_patch(r), refinement: refinement_patch(r), budget: budget_patch(r), reliability: reliability_patch(r), routes: json_patch(r, "routes"), outputs: json_patch(r, "outputs"), stt_prompt: presence(r.params["stt_prompt"]), metadata: json_patch(r, "metadata") || {}, alerts: alerts_patch(r), edge_stream: edge_stream_patch(r), stuck_signal: r.params["stuck_signal"] == "1", prompt_caching: r.params["prompt_caching"] == "1", # The one opt-out flag: checked (the rendered default) saves true, an # unchecked box saves an explicit false — both spellings of ON collapse. tool_persistence: r.params["tool_persistence"] == "1", tool_output_compression: r.params["tool_output_compression"] == "1", subagents: list_patch(r, "subagents"), capabilities: list_patch(r, "capabilities"), tools_deferred: list_patch(r, "tools_deferred"), briefing_fields: list_patch(r, "briefing_fields"), context_providers: allowlist_patch(r, "context_providers"), workflows_allow: allowlist_patch(r, "workflows_allow"), approvals_required: list_patch(r, "approvals_required"), policies: list_patch(r, "policies").map(&:to_sym), prompt_refs: list_patch(r, "prompt_refs"), capabilities_declared: list_patch(r, "capabilities_declared"), skills_eager: skills_eager_patch(r) } end |
#distill_patch(r) ⇒ Object
distillation — enabled checkbox + the forge's knobs. Blank prompt/model drop the keys (the engine defaults take over).
160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/insika/studio/forms.rb', line 160 def distill_patch(r) enabled = r.params["distill_enabled"] == "1" out = { "enabled" => enabled } (p = presence(r.params["distill_prompt"])) && (out["prompt"] = p) (m = presence(r.params["distill_model"])) && (out["model"] = m) %w[idle_hours min_messages max_proposals].each do |key| v = edge_int(r.params["distill_#{key}"], "distill_#{key}") out[key] = v unless v.nil? end out end |
#edge_float(raw, field) ⇒ Object
Strict float, blank = nil (inherit the default). Mirrors edge_int.
533 534 535 536 537 538 539 540 |
# File 'lib/insika/studio/forms.rb', line 533 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.
546 547 548 549 550 551 552 553 |
# File 'lib/insika/studio/forms.rb', line 546 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 — 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).
482 483 484 485 486 487 488 489 |
# File 'lib/insika/studio/forms.rb', line 482 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 |
#edge_stream_patch(r) ⇒ Object
Which internal channels may cross to the customer. Both checkboxes are rendered, so the patch reflects the whole state (false = held back).
264 265 266 267 |
# File 'lib/insika/studio/forms.rb', line 264 def edge_stream_patch(r) { "thinking" => r.params["edge_thinking"] == "1", "intermediate" => r.params["edge_intermediate"] == "1" } end |
#evals_patch(r) ⇒ Object
Evals graders. 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.
510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 |
# File 'lib/insika/studio/forms.rb', line 510 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 |
#followup_patch(r) ⇒ Object
the follow-up declaration. Blank arm = the feature is off (parity). The policy keys are all rendered; a blank one drops the key.
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/insika/studio/forms.rb', line 124 def followup_patch(r) arm = presence(r.params["followup_arm"]) return nil unless arm policy = {} tz = presence(r.params["followup_tz"]) if tz quiet = { "timezone" => tz } (s = presence(r.params["followup_quiet_start"])) && (quiet["start"] = s) (e = presence(r.params["followup_quiet_end"])) && (quiet["end"] = e) policy["quiet_hours"] = quiet end (f = presence(r.params["followup_max_frequency"])) && (policy["max_frequency"] = f) keywords = list(r.params["followup_cancel_keywords"]) policy["cancel_keywords"] = keywords unless keywords.empty? silence = edge_int(r.params["followup_silence_after_sends"], "followup_silence_after_sends") policy["silence_after_sends"] = silence unless silence.nil? { "arm" => arm, "policy" => policy } end |
#funnel_patch(r) ⇒ Object
the outcome funnel — the pack's own stage vocabulary, edited hot. Blank stages = no funnel (parity). Stages: one per line.
110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/insika/studio/forms.rb', line 110 def funnel_patch(r) stages = lines(r.params["funnel_stages"]) return nil if stages.empty? out = { "stages" => stages } (p = presence(r.params["funnel_primary"])) && (out["primary"] = p) (w = presence(r.params["funnel_attribution_window"])) && (out["attribution_window"] = w) advance = json_block(r.params["funnel_advance_on"], "funnel advance_on") out["advance_on"] = advance unless advance.nil? || advance.empty? out 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.
495 496 497 498 499 500 501 502 503 504 |
# File 'lib/insika/studio/forms.rb', line 495 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 |
#grounding_patch(r) ⇒ Object
grounding — { "mode" => "flag"|"enforce", "matcher" => { "sku" => "sku:…", "name_keys" => [...] } }. Blank mode = no grounding (parity). The matcher keys are carried only while a mode is declared.
97 98 99 100 101 102 103 104 105 106 |
# File 'lib/insika/studio/forms.rb', line 97 def grounding_patch(r) mode = presence(r.params["grounding_mode"]) return nil unless %w[flag enforce].include?(mode) matcher = {} (sku = presence(r.params["grounding_matcher_sku"])) && (matcher["sku"] = sku) keys = list(r.params["grounding_name_keys"]) matcher["name_keys"] = keys unless keys.empty? { "mode" => mode, "matcher" => matcher } end |
#guardrail_responses_patch(r) ⇒ Object
Per-category safe-reply overrides (config over convention). Only the non-blank fields are persisted; Safety::Config normalizes on read.
342 343 344 345 346 |
# File 'lib/insika/studio/forms.rb', line 342 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. The config form OWNS the fields it renders, so the patch reflects the whole rendered state. String values round-trip cleanly through the JSON store; Safety::Config normalizes on read. A blank moderator drops the key (deterministic only).
corpora (the removability knob — languages/extra) has NO
form field: it is DSL/pack data (docs/domain.md), not Studio UI. The
form must not erase it on save — carry the existing value through
(the shallow patch merge would otherwise wipe it, the halt_when class).
326 327 328 329 330 331 332 333 334 335 336 337 338 |
# File 'lib/insika/studio/forms.rb', line 326 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? existing = @agent.guardrails || {} out["corpora"] = existing["corpora"] if existing["corpora"] out end |
#harvest_miner_patch(r) ⇒ Object
189 190 191 192 193 194 195 196 197 198 199 200 |
# File 'lib/insika/studio/forms.rb', line 189 def harvest_miner_patch(r) miner = {} (m = presence(r.params["harvest_miner_model"])) && (miner["model"] = m) window = {} (w = edge_int(r.params["harvest_miner_window"], "harvest_miner_window")) && (window["last_sessions"] = w) miner["window"] = window unless window.empty? (m = edge_int(r.params["harvest_miner_max_proposals"], "harvest_miner_max_proposals")) && (miner["max_proposals"] = m) budget = {} (t = edge_int(r.params["harvest_miner_budget_tokens"], "harvest_miner_budget_tokens")) && (budget["tokens"] = t) miner["budget"] = budget unless budget.empty? miner end |
#harvest_patch(r) ⇒ Object
the gated harvest. Same shape discipline as distill; the negative list is a JSON array of { rule, pattern, note }.
174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/insika/studio/forms.rb', line 174 def harvest_patch(r) enabled = r.params["harvest_enabled"] == "1" out = { "enabled" => enabled } (p = presence(r.params["harvest_prompt"])) && (out["prompt"] = p) %w[idle_hours min_messages].each do |key| v = edge_int(r.params["harvest_#{key}"], "harvest_#{key}") out[key] = v unless v.nil? end negative = json_block(r.params["harvest_negative_list"], "harvest negative_list") out["negative_list"] = negative unless negative.nil? miner = harvest_miner_patch(r) out["miner"] = miner unless miner.empty? out end |
#json_block(raw, field) ⇒ Object
284 285 286 287 288 289 290 291 292 293 |
# File 'lib/insika/studio/forms.rb', line 284 def json_block(raw, field) text = raw.to_s.strip return nil if text.empty? begin JSON.parse(text) rescue JSON::ParserError => e raise Insika::ValidationError, "#{field}: invalid JSON (#{e..lines.first.to_s.strip})" end end |
#json_patch(r, field) ⇒ Object
A JSON textarea -> parsed Hash/Array, or nil when blank. Malformed JSON is a ValidationError (a red flash, never a silent drop).
280 281 282 |
# File 'lib/insika/studio/forms.rb', line 280 def json_patch(r, field) json_block(r.params[field], field) end |
#lines(str) ⇒ Object
One non-blank token per line.
313 314 315 |
# File 'lib/insika/studio/forms.rb', line 313 def lines(str) str.to_s.lines.map(&:strip).reject(&:empty?) end |
#list(str) ⇒ Object
308 309 310 |
# File 'lib/insika/studio/forms.rb', line 308 def list(str) str.to_s.split(/[,\n]/).map(&:strip).reject(&:empty?).uniq end |
#list_patch(r, field) ⇒ Object
A comma-separated list field. Blank = [] (none) — the explicit empty reading; an allowlist-style field wants nil=all and should not use this.
297 298 299 |
# File 'lib/insika/studio/forms.rb', line 297 def list_patch(r, field) list(r.params[field]) end |
#mcp_patch(r) ⇒ Object
MCP instance from the form (transport-aware, but the
form always submits every field regardless of which the JS shows;
McpStore stores them all either way). env/headers come as
"KEY=value" lines, args as one argv token per line (CSP forbids
inline JS for add/remove line; a textarea is the simple, honest path).
Masked credential values come back as a sentinel — keeping them
preserves the secret; changing replaces; deleting the line clears it.
611 612 613 614 615 616 617 618 619 620 621 622 623 |
# File 'lib/insika/studio/forms.rb', line 611 def mcp_patch(r) { name: presence(r.params["name"]), transport: presence(r.params["transport"]) || "stdio", command: r.params["command"].to_s, args: split_lines(r.params["args"]), url: r.params["url"].to_s, description: r.params["description"].to_s, enabled: r.params["enabled"] == "1", env: parse_kv_lines(r.params["env"]), headers: parse_kv_lines(r.params["headers"]) } end |
#model_defaults_patch(r) ⇒ Object
LLM config v2 — 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).
561 562 563 564 565 566 567 568 569 570 |
# File 'lib/insika/studio/forms.rb', line 561 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 (4-layer). Blank -> nil = provider default. "thinking" => presence(r.params["thinking"]) } end |
#model_params_patch(r) ⇒ Object
Per-model reasoning defaults (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).
578 579 580 581 582 583 584 585 586 587 588 589 |
# File 'lib/insika/studio/forms.rb', line 578 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: { "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.
369 370 371 372 |
# File 'lib/insika/studio/forms.rb', line 369 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. 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.
354 355 356 357 358 359 360 361 362 363 |
# File 'lib/insika/studio/forms.rb', line 354 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 (#).
626 627 628 629 630 631 632 633 634 635 636 637 |
# File 'lib/insika/studio/forms.rb', line 626 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
437 438 439 440 441 442 443 444 445 446 447 448 |
# File 'lib/insika/studio/forms.rb', line 437 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.
423 424 425 426 427 428 429 430 431 432 |
# File 'lib/insika/studio/forms.rb', line 423 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.
594 595 596 597 598 599 600 601 602 |
# File 'lib/insika/studio/forms.rb', line 594 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 |
#refinement_patch(r) ⇒ Object
Refinement: mode select + the report/propose/auto_apply knobs. Blank mode = report-only (the no-opt-in default — the form's "report" option writes it down explicitly).
205 206 207 208 209 210 211 212 213 214 215 216 217 |
# File 'lib/insika/studio/forms.rb', line 205 def refinement_patch(r) mode = presence(r.params["refinement_mode"]) out = { "mode" => mode } if mode (w = edge_int(r.params["refinement_window"], "refinement_window")) && (out["window"] = { "last_sessions" => w }) (f = edge_int(r.params["refinement_max_findings"], "refinement_max_findings")) && (out["max_findings"] = f) files = list(r.params["refinement_files"]) out["files"] = files unless files.empty? proposers = list(r.params["refinement_proposers"]) out["proposers"] = proposers unless proposers.empty? (t = edge_int(r.params["refinement_budget_tokens"], "refinement_budget_tokens")) && (out["budget"] = { "tokens" => t }) (e = edge_int(r.params["refinement_auto_apply_max_edits"], "refinement_auto_apply_max_edits")) && (out["auto_apply_max_edits"] = e) out end |
#reliability_patch(r) ⇒ Object
WS3: retries/backoff/fallback/breaker/timeout. All blank = the plain single attempt (parity).
235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 |
# File 'lib/insika/studio/forms.rb', line 235 def reliability_patch(r) retries = edge_int(r.params["reliability_retries"], "reliability_retries") backoff = presence(r.params["reliability_backoff"]) timeout = edge_int(r.params["reliability_timeout"], "reliability_timeout") fallback = list(r.params["reliability_fallback"]) return nil if retries.nil? && backoff.nil? && timeout.nil? && fallback.empty? out = {} out["retries"] = retries unless retries.nil? out["backoff"] = backoff if backoff out["timeout"] = timeout unless timeout.nil? out["fallback"] = fallback unless fallback.empty? breaker = {} %w[after within cooldown].each do |key| v = edge_int(r.params["reliability_breaker_#{key}"], "reliability_breaker_#{key}") breaker[key] = v unless v.nil? end out["circuit_breaker"] = breaker unless breaker.empty? out end |
#schedules_patch(r) ⇒ Object
recurring schedules — a JSON array of declarations (name / trigger / tz / message / session mode / overrides / enabled). Same honest shape as routes/metadata: the list is unbounded, and a JSON textarea is the CSP-safe editor. Blank = no schedules (parity).
148 149 150 151 152 153 154 155 156 |
# File 'lib/insika/studio/forms.rb', line 148 def schedules_patch(r) parsed = json_block(r.params["schedules"], "schedules") return nil if parsed.nil? parsed = parsed.is_a?(Hash) ? [parsed] : parsed raise Insika::ValidationError, "schedules must be an array of declarations" unless parsed.is_a?(Array) parsed end |
#settings_patch(r) ⇒ Object
450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 |
# File 'lib/insika/studio/forms.rb', line 450 def settings_patch(r) patch = { "streaming" => r.params["streaming"] == "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 # memory TTL (days). Blank = off (nil); a number sets the # platform default for EVERY cell. Saving here replaces an ops-authored # per-tenant map in the record — the view says so. v = presence(r.params["memory_ttl_days"]) patch["memory_ttl_days"] = v.nil? ? nil : Integer(v) # In-session compaction (RFC-0044). The checkbox is authoritative on # this form (unchecked = disable); the numbers keep their stored value # when cleared (deep_merge — the defaults backstop a fresh record); # model blank = nil = the platform utility_model. compaction = { "enabled" => r.params["compaction_enabled"] == "1", "model" => presence(r.params["compaction_model"]) } %w[keep_last compact_after].each do |f| v = presence(r.params["compaction_#{f}"]) compaction[f] = Integer(v) if v end patch["compaction"] = compaction patch end |
#skills_eager_patch(r) ⇒ Object
skills_eager: blank = none (progressive disclosure, parity); "all" = blanket eager; otherwise a comma list of exactly these.
271 272 273 274 275 276 |
# File 'lib/insika/studio/forms.rb', line 271 def skills_eager_patch(r) raw = r.params["skills_eager"].to_s.strip return nil if raw.empty? raw == "all" ? true : list(raw) end |
#split_lines(text) ⇒ Object
One argv token per line (stdio's args). Ignores blank lines/comments.
640 641 642 |
# File 'lib/insika/studio/forms.rb', line 640 def split_lines(text) text.to_s.each_line.map(&:strip).reject { |l| l.empty? || l.start_with?("#") } 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).
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 |
# File 'lib/insika/studio/forms.rb', line 395 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 |