insika 0.2.0 → 0.7.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
data/lib/insika/dsl.rb CHANGED
@@ -47,6 +47,7 @@ module Insika
47
47
  def initialize
48
48
  @definitions = []
49
49
  @workflows = []
50
+ @mcp_instances = []
50
51
  @runtime = {}
51
52
  end
52
53
 
@@ -54,7 +55,8 @@ module Insika
54
55
  instance_eval(&block) if block
55
56
  raise ArgumentError, "Insika.system needs at least one agent" if @definitions.empty?
56
57
 
57
- System.new(definitions: @definitions, workflows: @workflows, runtime: @runtime, backend: backend)
58
+ System.new(definitions: @definitions, workflows: @workflows,
59
+ mcp_instances: @mcp_instances, runtime: @runtime, backend: backend)
58
60
  end
59
61
 
60
62
  # Declares one agent — the SAME block the standalone `Insika.agent` takes.
@@ -101,6 +103,34 @@ module Insika
101
103
  def provider(name) = @runtime[:provider] = name.to_s
102
104
  def api_key(value) = @runtime[:api_key] = value.to_s
103
105
  def api_base(value) = @runtime[:api_base] = value.to_s
106
+
107
+ # Declares an MCP server instance: global to the graph, not
108
+ # any one agent — gated per agent through `tools_allow_groups` on the
109
+ # group `mcp:<name>`, same as any other tool group. `Insika::DSL::Runtime`
110
+ # upserts it into the McpStore at boot; the MOTOR-VS-FORJA rule applies —
111
+ # code is the TEMPLATE (transport/command/args/url/description always
112
+ # follow the DSL), but an operator's own `enabled`/`env`/`headers` edit
113
+ # via Studio/CLI/API, once the instance exists, is never clobbered back.
114
+ # mcp "tavily", transport: :http, url: "https://mcp.tavily.com/mcp",
115
+ # headers: { "Authorization" => "Bearer #{ENV["TAVILY_KEY"]}" }
116
+ # mcp "filesystem", transport: :stdio, command: "npx",
117
+ # args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
118
+ #
119
+ # A SYSTEM-level declaration (as opposed to inside one member `agent { }`
120
+ # block) has no single agent's config to auto-grant — it does NOT by
121
+ # itself give any agent access. Declare the `mcp` inside the specific
122
+ # `agent { }` block that needs it instead, where `Builder#mcp` auto-adds
123
+ # "mcp:<name>" to THAT agent's `tools_allow_groups` (below).
124
+ def mcp(name, transport: nil, command: nil, args: nil, url: nil,
125
+ headers: nil, env: nil, description: nil, enabled: true)
126
+ n = name.to_s
127
+ raise ArgumentError, "duplicate mcp instance in system: #{n}" if @mcp_instances.any? { |m| m[:name] == n }
128
+
129
+ @mcp_instances << { name: n, transport: transport&.to_s, command: command, args: args,
130
+ url: url, headers: headers, env: env, description: description,
131
+ enabled: enabled }
132
+ n
133
+ end
104
134
  end
105
135
 
106
136
  # Collects the declarations and emits a Insika::Pack. Declarations map 1:1 to
@@ -117,11 +147,12 @@ module Insika
117
147
  # correct once you restrict tools/skills. Visible in #to_pack — no hidden magic.
118
148
  @config[:policies] = %i[tool_allowlist skill_allowlist]
119
149
  @runtime = {} # non-pack knobs (llm provider/key/base) consumed by the runtime
150
+ @mcp_instances = []
120
151
  end
121
152
 
122
153
  def build(&block)
123
154
  instance_eval(&block) if block
124
- Definition.new(pack: to_pack, runtime: @runtime)
155
+ Definition.new(pack: to_pack, runtime: @runtime, mcp_instances: @mcp_instances)
125
156
  end
126
157
 
127
158
  # --- identity & model ------------------------------------------------
@@ -204,6 +235,34 @@ module Insika
204
235
  @config[:subagents] = ids.flatten.map(&:to_s)
205
236
  end
206
237
 
238
+ # --- mcp ---------------------------------------------------------------
239
+ # Declares an MCP server instance — see
240
+ # Insika::DSL::SystemBuilder#mcp for the transport/lifecycle doc; identical
241
+ # shape here for a standalone `Insika.agent { … }` script, or one member
242
+ # agent of a system, that wants one.
243
+ #
244
+ # Auto-adds "mcp:<name>" to THIS agent's `tools_allow_groups` — without
245
+ # it, a pack with no `data_tool` gets PackImporter's `tools_allow: []`
246
+ # (isolation default) and no `tools_allow_groups` at all, so
247
+ # Policy::ToolAllowlist#allowed_names resolves an EMPTY allowlist and the
248
+ # agent could never call the MCP tool it just declared (found writing the
249
+ # MCP templates — no existing spec exercised this path end to
250
+ # end). Same "auto-added to the allowlist" contract `data_tool` already
251
+ # gives its own tool name; `deny_tools` has no group-string equivalent
252
+ # yet, so a whole MCP group cannot be denied by name today.
253
+ def mcp(name, transport: nil, command: nil, args: nil, url: nil,
254
+ headers: nil, env: nil, description: nil, enabled: true)
255
+ n = name.to_s
256
+ raise ArgumentError, "duplicate mcp instance in agent: #{n}" if @mcp_instances.any? { |m| m[:name] == n }
257
+
258
+ @mcp_instances << { name: n, transport: transport&.to_s, command: command, args: args,
259
+ url: url, headers: headers, env: env, description: description,
260
+ enabled: enabled }
261
+ group = "mcp:#{n}"
262
+ (@config[:tools_allow_groups] ||= []) << group unless Array(@config[:tools_allow_groups]).include?(group)
263
+ n
264
+ end
265
+
207
266
  # --- knobs -----------------------------------------------------------
208
267
  def memory(on = true) = @config[:memory] = on
209
268
 
@@ -215,6 +274,81 @@ module Insika
215
274
  # budget daily: 100_000, monthly: 2_000_000, soft: false
216
275
  def budget(hash) = (@config[:budget] ||= {}).merge!(hash.transform_keys(&:to_s))
217
276
 
277
+ # The outcome funnel declaration: the store's stage vocabulary
278
+ # as pack data — the engine folds WS7 outcome kinds into the DECLARED
279
+ # stages, and never hard-codes one itself. Merges, so repeated calls
280
+ # accumulate (like budget).
281
+ # funnel stages: %w[greeted qualified cart paid],
282
+ # advance_on: { "pix_paid" => "paid", "abandoned_cart" => "cart" },
283
+ # primary: "paid", attribution_window: "72h"
284
+ def funnel(hash) = (@config[:funnel] ||= {}).merge!(hash.transform_keys(&:to_s))
285
+
286
+ # The follow-up declaration: the agent may book a follow-up
287
+ # with a customer at a future time (`schedule` tool); the engine fires the
288
+ # synthetic turn and enforces the policy (quiet hours, max frequency,
289
+ # cancellation keywords, silence detection) at fire time. Pack data —
290
+ # merges, so repeated calls accumulate (like budget).
291
+ # followup arm: "schedule",
292
+ # policy: { quiet_hours: { timezone: "America/Sao_Paulo",
293
+ # start: "21:30", end: "09:00" },
294
+ # max_frequency: "2/24h",
295
+ # cancel_keywords: ["não quero mais contato"],
296
+ # silence_after_sends: 3 }
297
+ def followup(hash) = (@config[:followup] ||= {}).merge!(hash.transform_keys(&:to_s))
298
+
299
+ # The session-distillation declaration: what counts as a fact
300
+ # for THIS store, distilled from finished customer conversations. Pack
301
+ # data — merges, so repeated calls accumulate (like budget). `prompt` and
302
+ # `model` are pack-authored keys the DSL passes through.
303
+ # distill enabled: true, idle_hours: 6, max_proposals: 10
304
+ def distill(hash) = (@config[:distill] ||= {}).merge!(hash.transform_keys(&:to_s))
305
+
306
+ # The gated-harvest declaration: the engine may read this
307
+ # agent's finished traffic and propose SKILLS for the store's playbook —
308
+ # through the negative list, the grounding filter and the double gate,
309
+ # never applied automatically. Pack data — merges, so repeated calls
310
+ # accumulate (like budget). `prompt`/`model` are pack-authored keys the
311
+ # DSL passes through.
312
+ # harvest enabled: true,
313
+ # negative_list: [ { rule: "no-competitor-prices", pattern: "concorrente" } ],
314
+ # miner: { model: "deepseek-v4-flash", window: { last_sessions: 200 } }
315
+ def harvest(hash) = (@config[:harvest] ||= {}).merge!(hash.transform_keys(&:to_s))
316
+
317
+ # The post-turn knowledge declaration: after a turn completes, the
318
+ # engine may extract durable CONCEPTS (facts, procedures, policies,
319
+ # objections) from it and persist them for later turns to retrieve.
320
+ # Pack data — merges, so repeated calls accumulate (like budget).
321
+ # `prompt`/`model` are pack-authored keys the DSL passes through.
322
+ # knowledge extract: true, retrieve: true, types: %w[fact policy]
323
+ def knowledge(hash) = (@config[:knowledge] ||= {}).merge!(hash.transform_keys(&:to_s))
324
+
325
+ # A recurring schedule: one declaration per call,
326
+ # named — a turn the ENGINE fires on its own tick, nobody has to
327
+ # remember. `cron` (5 fields) or `every` (plain interval), a tz for
328
+ # cron materialization, the synthetic inbound `message` that kicks each
329
+ # run, a session mode (`new` = a fresh session per run — the report
330
+ # case; `fixed` = one standing session), per-run `overrides`
331
+ # (turn_timeout / max_tool_calls / model) and `enabled`.
332
+ # schedule "daily_report", cron: "0 22 * * *", tz: "America/Sao_Paulo",
333
+ # message: "Run the daily report now.",
334
+ # overrides: { turn_timeout: 900, max_tool_calls: 200 }
335
+ # Distinct by shape from the `schedule_followup` TOOL (a one-shot,
336
+ # customer-facing, consent-gated contact); see docs/SCHEDULING.md.
337
+ def schedule(name, every: nil, cron: nil, tz: nil, message: nil,
338
+ session_mode: nil, session_id: nil, overrides: nil, enabled: nil)
339
+ id = name.to_s.downcase # the engine canonicalizes ids to lowercase
340
+ if Array(@config[:schedules]).any? { |s| s["id"] == id }
341
+ raise ArgumentError, "duplicate schedule in agent: #{id}"
342
+ end
343
+
344
+ entry = { "id" => id, "every" => every, "cron" => cron, "tz" => tz,
345
+ "message" => message, "session_mode" => session_mode,
346
+ "session_id" => session_id, "overrides" => overrides,
347
+ "enabled" => enabled }.compact
348
+ @config[:schedules] = Array(@config[:schedules]) + [entry]
349
+ id
350
+ end
351
+
218
352
  # Provider-interaction reliability, as DATA (WS3): retries + exponential
219
353
  # backoff on transient failures, a fallback model chain (mid-turn
220
354
  # rotation), and a circuit breaker per (tenant, provider/model) that
@@ -226,17 +360,58 @@ module Insika
226
360
  (@config[:reliability] ||= {}).merge!(hash.transform_keys(&:to_s))
227
361
  end
228
362
 
229
- # Operator alert delivery (WS6): POST this agent's budget_warning /
230
- # breaker_open / delivery_failed events to the webhook, as JSON.
231
- # alerts webhook: "https://ops.example.com/insika-alerts"
363
+ # Operator alert delivery (WS6): POST this agent's budget_warning /
364
+ # breaker_open / delivery_failed events to the webhook as JSON.
232
365
  def alerts(hash) = (@config[:alerts] ||= {}).merge!(hash.transform_keys(&:to_s))
233
366
 
367
+ # Intent routing (WS4): classify each turn's message into one route with a
368
+ # cheap model BEFORE the ask. A Hash: route name -> description (or a Hash
369
+ # with description/delegate/stuck/message), plus the reserved keys
370
+ # "default" (the deterministic fallback) and "model"/"provider" (the cheap
371
+ # classifier). The classifier prompt is generated — data only.
372
+ # routes "shopping" => "the customer wants to browse products",
373
+ # "order" => { "description" => "asks about an existing order",
374
+ # "delegate" => "order-agent" },
375
+ # "human" => { "description" => "the customer asks for a person",
376
+ # "stuck" => true },
377
+ # "default" => "shopping", "model" => "deepseek-v4-flash"
378
+ def routes(hash) = (@config[:routes] ||= {}).merge!(hash.transform_keys(&:to_s))
379
+
234
380
  # The agent may signal it cannot proceed (WS5): when on, the model
235
381
  # can call `signal_stuck`, which ends the turn with `outcome: :stuck` + a final
236
382
  # message + a `:turn_stuck` event. What "stuck" means is the consumer's call.
237
383
  # stuck_signal true
238
384
  def stuck_signal(on = true) = @config[:stuck_signal] = on
239
385
 
386
+ # The per-session working-state schema this agent keeps and asks for
387
+ # a flat list of field names. [] = off (no provider output,
388
+ # no update_briefing/set_next_step tools).
389
+ # briefing_fields "size", "budget", "delivery_day"
390
+ def briefing_fields(*names)
391
+ @config[:briefing_fields] = names.flatten.map(&:to_s)
392
+ end
393
+
394
+ # Generated-media output policy (WS9, saída): the media kinds this
395
+ # agent MAY generate as turn outputs, with per-kind config. The other
396
+ # half of the gate is the CHANNEL's: the request must declare the
397
+ # matching capability for the tools to exist at all.
398
+ # outputs image: { model: "gpt-image-1", size: "1024x1024" },
399
+ # tts: { model: "tts-1", voice: "alloy" }
400
+ def outputs(hash) = (@config[:outputs] ||= {}).merge!(hash.transform_keys(&:to_s))
401
+
402
+ # STT vocabulary hint (WS9): domain words (product names, brand terms)
403
+ # the transcriber should expect on THIS agent's voice notes — passed
404
+ # straight through to the Whisper-family provider's `prompt:`. Falls
405
+ # back to INSIKA_STT_PROMPT (deployment default) when unset.
406
+ # stt_prompt "Ocean Drop, tênis, boné trucker, chinelo"
407
+ def stt_prompt(text) = @config[:stt_prompt] = text.to_s
408
+
409
+ # The engine's "Tool discipline" block in the system prompt (retry a
410
+ # weak/empty tool result with a different approach before giving up).
411
+ # ON by default — this setter exists to turn it OFF:
412
+ # tool_persistence false
413
+ def tool_persistence(on = true) = @config[:tool_persistence] = on
414
+
240
415
  # Mechanical tool-result dedupe in the replayed history
241
416
  # (no-LLM compaction, apt for bloated transcripts). CHANGES WHAT THE MODEL
242
417
  # SEES: repeated identical tool results collapse to a back-reference.
@@ -250,6 +425,12 @@ module Insika
250
425
  # responses: { "injection" => "I can't help with that." }
251
426
  def guardrails(hash) = (@config[:guardrails] ||= {}).merge!(hash.transform_keys(&:to_s))
252
427
 
428
+ # Evidence-grounding policy: the pack declares how the engine
429
+ # polices product claims against the evidence ledger. Same
430
+ # config-over-code shape as `guardrails`; absent = off (parity).
431
+ # grounding mode: :flag, matcher: { sku: '\b[A-Z]{2,4}\d{4,8}\b' }
432
+ def grounding(hash = {}) = (@config[:grounding] ||= {}).merge!(hash.transform_keys(&:to_s))
433
+
253
434
  # Refinement — how the agent's own instruction files may be
254
435
  # improved from real traffic. Same config-over-code shape as `guardrails`;
255
436
  # omitting it entirely leaves the agent report-only (writes nothing).
@@ -65,8 +65,14 @@ module Insika
65
65
  # the rate-limit reply exactly when the window is saturated. Entry checks
66
66
  # are skipped; the turn's usage still lands on the ledger below.
67
67
  resumed = state.resumed
68
-
69
- if !resumed && (limit = positive(limits.key?(:chat_rate_limit) ? limits[:chat_rate_limit] : edge["chat_rate_limit"]))
68
+ # a SCHEDULED turn (the FollowupEngine's kick) skips the
69
+ # ENTRY checks exactly like a resume a follow-up that trips the token
70
+ # ceiling must not receive the rate-limit REPLY (the customer agreed to
71
+ # this message; the volume control is the follow-up policy, not the flood
72
+ # rail). The turn still runs and its usage still lands on the ledger.
73
+ scheduled = scheduled_turn?(state)
74
+
75
+ if !resumed && !scheduled && (limit = positive(limits.key?(:chat_rate_limit) ? limits[:chat_rate_limit] : edge["chat_rate_limit"]))
70
76
  breach = check_chat_rate(state, limit, edge)
71
77
  return block(state, edge, **breach) if breach
72
78
  end
@@ -75,7 +81,7 @@ module Insika
75
81
  # `"chat_rate_limit": null`) reads as OFF for that agent, not "inherit".
76
82
  if (ceiling = positive(limits.key?(:agent_token_ceiling) ? limits[:agent_token_ceiling] : edge["agent_token_ceiling"]))
77
83
  token_window = positive(edge["agent_token_window"]) || DEFAULT_TOKEN_WINDOW
78
- unless resumed
84
+ unless resumed || scheduled
79
85
  spent = @ledger.count(TOKENS_KIND, state.profile.id.to_s, window: token_window)
80
86
  if spent >= ceiling
81
87
  return block(state, edge, category: :token_ceiling,
@@ -90,9 +96,11 @@ module Insika
90
96
  # typed error (never a customer-facing reply); the alert_at warning and
91
97
  # the SOFT over-cap both warn once per window + inject a context note.
92
98
  # A resumed turn (crash/pause replay) was already admitted: it is never
93
- # refused twice — its spend still lands on the ledger below.
99
+ # refused twice — its spend still lands on the ledger below. A scheduled
100
+ # turn rides the same rule: the follow-up policy is the
101
+ # volume control, not the budget wall.
94
102
  budget_on = budget_configured?(state)
95
- budget_enforce(state) unless resumed
103
+ budget_enforce(state) unless resumed || scheduled
96
104
 
97
105
  result = begin
98
106
  nxt.call(state)
@@ -110,6 +118,14 @@ module Insika
110
118
 
111
119
  private
112
120
 
121
+ # is this turn the FollowupEngine's synthetic kick? The
122
+ # command type is stamped by the engine only — a consumer cannot send it
123
+ # (the SendMessage edge refuses the spelling, C8).
124
+ def scheduled_turn?(state)
125
+ command = state.respond_to?(:task) && state.task&.command
126
+ command.is_a?(Hash) && command["type"].to_s == "scheduled_followup"
127
+ end
128
+
113
129
  # One KV get per turn (same order of cost as the guardrail's config read);
114
130
  # no SettingsStore in the wiring -> per-agent limits only.
115
131
  def platform_edge
@@ -231,12 +247,17 @@ module Insika
231
247
  return if @budget_ledger.mark_alert(tenant: tenant, agent: agent, window: w[:window],
232
248
  level: level, now: now)
233
249
 
250
+ # `tenant` on the META too, not only in the payload: the tenant-scoped
251
+ # /v1/events subscription filters on meta[:tenant] and is fail-closed, so
252
+ # a warning about the tenant's OWN budget never reached the tenant.
253
+ meta = { task_id: state.task&.id, session_id: state.task&.session_id,
254
+ at: Time.now.utc.iso8601 }
255
+ meta[:tenant] = tenant unless tenant.nil?
234
256
  @event_stream&.emit(Insika::Event.new(
235
257
  type: :budget_warning,
236
258
  data: { agent: agent, tenant: tenant, window: w[:window],
237
259
  spent: spent, cap: w[:cap], level: level },
238
- meta: { task_id: state.task&.id, session_id: state.task&.session_id,
239
- at: Time.now.utc.iso8601 }
260
+ meta: meta
240
261
  ))
241
262
  end
242
263
 
@@ -102,29 +102,51 @@ module Insika
102
102
  spec(name: "INSIKA_ENV", description: "Environment name shown in the Studio (falls back to RACK_ENV)."),
103
103
  spec(name: "INSIKA_A2A_AGENT", description: "Agent id to expose over inbound A2A (opt-in)."),
104
104
  spec(name: "INSIKA_A2A_REMOTES", type: :csv, description: "Comma-separated remote A2A endpoints."),
105
- spec(name: "INSIKA_EGRESS_ALLOW_HTTP", type: :boolean, description: "Allow plain http egress from data-tools (default: https only)."),
105
+ spec(name: "INSIKA_EGRESS_ALLOW_HTTP", type: :boolean, description: "Allow plain http egress from data-tools, channel callbacks and media fetches (default: https only)."),
106
106
  spec(name: "INSIKA_EGRESS_ALLOW_PRIVATE", type: :boolean, description: "Allow egress to private/loopback ranges (SSRF guard off)."),
107
- spec(name: "INSIKA_EGRESS_HOSTS", type: :csv, description: "Comma-separated host allowlist for data-tool egress."),
107
+ spec(name: "INSIKA_EGRESS_HOSTS", type: :csv, description: "Comma-separated host allowlist for data-tool egress (media fetches are NOT pinned by it)."),
108
108
  spec(name: "INSIKA_OTEL", type: :boolean, description: "Turn on OpenTelemetry export (opt-in)."),
109
109
  spec(name: "INSIKA_MODEL_PRICING", description: "JSON rates table (USD per million tokens) for the estimated-cost attribute; unset -> no cost reported."),
110
110
  spec(name: "INSIKA_TURN_TIMING", type: :boolean, description: "Emit per-turn TTFB breakdown in responses (opt-in)."),
111
111
  spec(name: "INSIKA_SUBAGENT_DEPTH_CAP", type: :integer, description: "Max delegation depth in the subagent graph (default 5)."),
112
112
  spec(name: "INSIKA_SUBAGENT_FANOUT_CAP", type: :integer, description: "Max parallel children in spawn_subagents (default 8)."),
113
+ spec(name: "INSIKA_ARTIFACT_SIGNING_KEY", secret: true, description: "HMAC key for signed artifact links (INSIKA_ARTIFACT_SIGNING_TTL). Unset -> no signed artifact surface."),
114
+ spec(name: "INSIKA_ARTIFACT_SIGNING_TTL", type: :integer, description: "Seconds a signed artifact link stays valid (default 604800 = 7 days)."),
115
+ spec(name: "INSIKA_ARTIFACT_MAX_BYTES", type: :integer, description: "Size cap on an artifact's content in bytes (default 1048576)."),
113
116
  spec(name: "INSIKA_CONFIG_STRICT", type: :boolean, description: "Refuse boot on any config finding instead of warning."),
114
117
  spec(name: "INSIKA_BOOT_ID", description: "Boot generation id shared by all workers of one container start; the recovery task sweep runs once per id. Unset -> every boot sweeps."),
115
118
  spec(name: "INSIKA_DRAIN_TIMEOUT", type: :integer, description: "Seconds a stopping worker waits for in-flight turns before abandoning them to the next boot's recovery (default 20)."),
116
119
  spec(name: "INSIKA_TICK_INTERVAL", type: :integer, description: "Seconds between tick passes (outbox drain + stale recovery sweep). Default 60; 0 disables."),
117
120
  spec(name: "INSIKA_TICK_STALE_AFTER", type: :integer, description: "Seconds a :queued/:running task must sit untouched before the tick sweeps it (default 900). Must exceed the largest turn_timeout of the deployment."),
121
+ spec(name: "INSIKA_STT_MODEL", description: "Model used to transcribe audio message parts (WS9). Unset -> RubyLLM's default transcription model."),
122
+ spec(name: "INSIKA_STT_LANGUAGE", description: "Language hint for the transcription of audio message parts (WS9)."),
123
+ spec(name: "INSIKA_STT_PROMPT", description: "Deployment-wide vocabulary hint (product names, brand terms) for audio transcription (WS9). Overridden per agent by the profile's stt_prompt."),
118
124
  spec(name: "INSIKA_TENANCY", enum: %w[single_tenant multi_tenant], description: "single_tenant (default: one operator credential) or multi_tenant (per-tenant + operator tokens resolved from the store)."),
119
125
  spec(name: "INSIKA_ONBOARDING", type: :boolean, description: "Expose the public onboarding surface (/start.md, /models.json, /docs) in production (opt-in)."),
120
126
  spec(name: "INSIKA_RELAY_TOKEN", secret: true, description: "Bearer the relay consumer sends us. Unset -> the relay channel is not mounted."),
121
127
  spec(name: "INSIKA_RELAY_DELIVER_URL", type: :url, description: "Consumer callback the relay POSTs each reply to."),
122
128
  spec(name: "INSIKA_RELAY_DELIVER_TOKEN", secret: true, description: "Bearer the relay sends TO the consumer's callback (optional)."),
129
+ spec(name: "INSIKA_RELAY_SHADOW", type: :boolean, description: "Shadow mode: the relay records replies instead of delivering them."),
130
+ spec(name: "INSIKA_RELAY_DELIVERY", type: :enum, enum: %w[at_end progressive], description: "How the relay flushes the outbox: at_end (one POST) or progressive (one POST per balloon). Unset -> at_end."),
131
+ spec(name: "INSIKA_PARITY_CRITERION", type: :path, description: "The frozen parity criterion file (required in shadow mode)."),
132
+ spec(name: "INSIKA_HARVEST_CRITERION", type: :path, description: "The frozen harvest conversion criterion file (strict-loaded before any promotion)."),
133
+ spec(name: "INSIKA_HARVEST_NEGATIVE", type: :path, description: "The negative-list seed file the harvest CLI imports into agent profiles."),
123
134
  spec(name: "INSIKA_WIDGET_ORIGINS", type: :csv, description: "Exact-match origins allowed to embed the web widget. Unset -> the widget channel is not mounted."),
124
135
  spec(name: "INSIKA_WIDGET_AGENTS", type: :csv, description: "Agent ids a widget visitor may address. Unset -> the widget channel is not mounted."),
136
+ spec(name: "INSIKA_MCP_STDIO", type: :boolean, description: "Allow stdio MCP instances to spawn a child process (arbitrary command execution by config). Unset -> stdio instances save but refuse to start."),
137
+ spec(name: "INSIKA_ROUTER_BACKENDS", type: :csv, description: "comma-separated backend URLs for `insika-router` (static discovery, the Railway shape). Exactly one of this or INSIKA_ROUTER_BACKENDS_DNS."),
138
+ spec(name: "INSIKA_ROUTER_BACKENDS_DNS", description: "a headless-Service hostname `insika-router` re-resolves on an interval (the Kubernetes shape). Requires INSIKA_ROUTER_BACKEND_PORT."),
139
+ spec(name: "INSIKA_ROUTER_BACKEND_PORT", type: :integer, description: "the engine port on every DNS-resolved backend pod (required with INSIKA_ROUTER_BACKENDS_DNS)."),
140
+ spec(name: "INSIKA_ROUTER_DNS_INTERVAL", type: :integer, description: "seconds between `insika-router` DNS re-resolves (default 15)."),
141
+ spec(name: "INSIKA_ROUTER_BODY_MAX_BYTES", type: :integer, description: "size cap `insika-router` will parse looking for a session key before falling back to round-robin (default 262144; never bounds what is forwarded)."),
142
+ spec(name: "INSIKA_ROUTER_BACKEND_TIMEOUT", type: :integer, description: "`insika-router`'s connect/read timeout to a backend, in seconds (default 10)."),
143
+ spec(name: "INSIKA_ROUTER_HOST", description: "bind address for `insika-router` itself (default 0.0.0.0)."),
144
+ spec(name: "INSIKA_ROUTER_PORT", type: :integer, description: "listen port for `insika-router` itself (default 9090)."),
125
145
  spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
126
146
  spec(name: "OPENCLAW_AGENTS_DIR", type: :path, description: "Directory of OpenClaw-style agent packs."),
127
- spec(name: "OPENCLAW_PLUGIN_DIR", type: :path, description: "Directory of plugins to load."),
147
+ spec(name: "INSIKA_PLUGIN_DIR", type: :path, description: "Workspace plugin root (directories with insika.plugin.yml). Loaded at boot; ids still need INSIKA_PLUGINS."),
148
+ spec(name: "INSIKA_PLUGINS", type: :csv, description: "Plugin ids to enable from the workspace/bundled roots. Announced gems are enabled by installing them."),
149
+ spec(name: "INSIKA_PLUGINS_DISABLED", type: :csv, description: "Plugin ids that never load — the absolute veto, wins over INSIKA_PLUGINS and over an announced gem."),
128
150
  spec(name: "ADMIN_TOKEN", secret: true, description: "Studio login token; unset -> /studio fail-closed."),
129
151
  spec(name: "OTEL_SERVICE_NAME", description: "Service name for OTEL spans (default: insika).")
130
152
  ].freeze
data/lib/insika/errors.rb CHANGED
@@ -182,6 +182,17 @@ module Insika
182
182
  # tell "the recipient refused" from "the engine has a bug".
183
183
  class DeliveryError < Error; end
184
184
 
185
+ # WS4 routing failed: a route's delegate agent is not configured, or its turn
186
+ # failed. An operator/config error — the envelope names the :routing stage
187
+ # instead of swallowing it as :unknown.
188
+ class RoutingError < Error; end
189
+
190
+ # WS9 media failed: an audio part could not be fetched or transcribed, an
191
+ # image attachment could not be built, or a media URL was egress-blocked. A
192
+ # customer's voice message that never entered the turn must not be silently
193
+ # dropped — the :media stage names it.
194
+ class MediaError < Error; end
195
+
185
196
  # Strict configuration violation (— OpenClaw's config discipline:
186
197
  # "recusa boot com chave desconhecida, no silent config compat"). Raised by
187
198
  # EnvSchema.enforce! at boot ONLY when strictness is on (INSIKA_CONFIG_STRICT) —
@@ -59,8 +59,9 @@ module Insika
59
59
  module Assertions
60
60
  # Named negative detectors for `must_not` now live in the runtime. Kept as
61
61
  # an alias so any external reference to Evals::Assertions::PII_DETECTORS still
62
- # resolves; the values ARE the runtime's, never a fork.
63
- PII_DETECTORS = Insika::Safety::Detectors::PII
62
+ # resolves; the values ARE the runtime's, never a fork. the
63
+ # pattern data moved to the corpus, still under the same Safety umbrella.
64
+ PII_DETECTORS = Insika::Safety::Corpus::PII
64
65
 
65
66
  # HOW MUCH THE AGENT SHOULD ASK BEFORE ACTING. Declared per
66
67
  # case because it is a per-STORE decision, not a universal rule: sometimes the
@@ -11,10 +11,26 @@ module Insika
11
11
  module Evals
12
12
  # A curated behavior case, loaded from a data file (evals/golden/<agent>/*.yml).
13
13
  # Data, not code — same spirit as tools-as-data. See evals/README.md for the format.
14
- Golden = Struct.new(:id, :agent, :turns, :expect, :requires, :reference, :source, keyword_init: true) do
15
- # The user messages to replay, in order.
14
+ #
15
+ # A case is ONE of two shapes: `turns:` (a scripted replay) or
16
+ # `persona:` (a conversation the Simulator GENERATES). A persona
17
+ # case is `simulated?` — the replay Runner skips it, and the Simulator drives it.
18
+ #
19
+ # `tenant` (C3.1): which tenant authored this case — "platform" (the
20
+ # single-tenant default, like `save_artifact`'s own binding_tenant) unless the
21
+ # case declares one. `run_persona_eval` uses it to keep a QA agent from ever
22
+ # running (or even seeing) another tenant's persona case in the same store.
23
+ Golden = Struct.new(:id, :agent, :turns, :expect, :requires, :reference, :source, :persona, :tenant,
24
+ keyword_init: true) do
25
+ # The user messages to replay, in order. Empty for a persona case: a generated
26
+ # conversation has no scripted turns.
16
27
  def user_turns = turns.map { |t| t["user"] }
17
28
 
29
+ # The simulated customer. nil for a scripted case.
30
+ def simulated? = !persona.nil?
31
+
32
+ def opens_with = persona ? persona.opens_with : user_turns.first
33
+
18
34
  # Tool refs the case expects; a trailing "?" marks OPTIONAL (never fails).
19
35
  # -> [{ name:, optional: }]
20
36
  def tools_called
@@ -85,7 +101,12 @@ module Insika
85
101
 
86
102
  id = presence(raw["id"]) || (raise InvalidGolden, "#{source}: 'id' is required")
87
103
  agent = presence(raw["agent"]) || (raise InvalidGolden, "#{source}: 'agent' is required (case '#{id}')")
88
- turns = normalize_turns(raw["turns"], id: id, source: source)
104
+ persona = normalize_persona(raw["persona"], id: id, source: source)
105
+ if persona && !raw["turns"].nil?
106
+ raise InvalidGolden, "#{source}: a case is ONE shape — 'turns' or 'persona', not both (case '#{id}')"
107
+ end
108
+
109
+ turns = persona ? [] : normalize_turns(raw["turns"], id: id, source: source)
89
110
  expect = raw["expect"] || {}
90
111
  raise InvalidGolden, "#{source}: 'expect' must be a mapping (case '#{id}')" unless expect.is_a?(Hash)
91
112
 
@@ -96,9 +117,25 @@ module Insika
96
117
  end
97
118
 
98
119
  reference = normalize_reference(raw["reference"], id: id, source: source)
120
+ tenant = presence(raw["tenant"]) || "platform"
99
121
 
100
122
  Golden.new(id: id, agent: agent, turns: turns, expect: expect,
101
- requires: requires, reference: reference, source: source)
123
+ requires: requires, reference: reference, source: source, persona: persona,
124
+ tenant: tenant)
125
+ end
126
+
127
+ # `persona:` is the alternative shape to `turns:`: the
128
+ # conversation is GENERATED, not replayed. Malformed is REFUSED — a persona
129
+ # without `knows` or `max_turns` would simulate nothing. The PersonaLoader
130
+ # already prefixes its messages with the source path; the case id is added
131
+ # ONCE here (the loader is shared by the persona-file CLI, which has no case
132
+ # shape).
133
+ def normalize_persona(raw, id:, source:)
134
+ return nil if raw.nil?
135
+
136
+ PersonaLoader.build(raw, source: source)
137
+ rescue PersonaLoader::InvalidPersona => e
138
+ raise InvalidGolden, "#{e.message} (case '#{id}')"
102
139
  end
103
140
 
104
141
  # reference: { "source" => String?, "messages" => [{ "role" =>, "text" =>,
@@ -67,7 +67,28 @@ module Insika
67
67
  return nil if rubric.empty?
68
68
 
69
69
  prompt = build_prompt(rubric, golden.user_turns, result.output_text.to_s, golden.policy)
70
- min = golden.min_score || DEFAULT_MIN_SCORE
70
+ score_prompt(prompt, golden.min_score || DEFAULT_MIN_SCORE)
71
+ end
72
+
73
+ # A whole CONVERSATION (a simulated run) scored against the
74
+ # rubric — the transcript, not its last turn. `transcript` is
75
+ # [{ role: "user"|"assistant", text: }] in order. -> Verdict, or nil without
76
+ # a rubric. Same panel, same rules as `score`: the rubric that scores a
77
+ # scripted replay scores a generated one.
78
+ def score_conversation(rubric:, transcript:, policy: nil, min_score: DEFAULT_MIN_SCORE)
79
+ rubric = rubric.to_s.strip
80
+ return nil if rubric.empty?
81
+
82
+ prompt = conversation_prompt(rubric, transcript, policy)
83
+ score_prompt(prompt, min_score)
84
+ end
85
+
86
+ private
87
+
88
+ # The panel machinery shared by `score` (a single reply) and
89
+ # `score_conversation` (a generated transcript): ask each judge, combine,
90
+ # decide by min_agreement. -> Verdict.
91
+ def score_prompt(prompt, min)
71
92
  panel = @asks.map { |ask| judge_once(ask, prompt, min) }
72
93
 
73
94
  agreed = panel.count { |j| j[:pass] }
@@ -77,7 +98,31 @@ module Insika
77
98
  judges: panel.map { |j| j[:score] })
78
99
  end
79
100
 
80
- private
101
+ # The judge reads the FULL conversation as the customer lived it — every
102
+ # user turn and every assistant reply interleaved. A rubric about the whole
103
+ # exchange ("does it discover the objective before recommending?") is
104
+ # unanswerable from the last reply alone.
105
+ def conversation_prompt(rubric, transcript, policy)
106
+ lines = Array(transcript).map do |m|
107
+ role = m[:role].to_s == "user" ? "customer" : "assistant"
108
+ "#{role}: #{m[:text].to_s.strip}"
109
+ end.join("\n")
110
+ <<~PROMPT
111
+ You are a strict QA judge for a customer-service AI assistant. Judge the
112
+ ASSISTANT'S HANDLING OF THE WHOLE CONVERSATION against the RUBRIC — nothing
113
+ else.
114
+
115
+ RUBRIC:
116
+ #{rubric}
117
+ #{policy_clause(policy)}
118
+ CONVERSATION (in order):
119
+ #{lines}
120
+
121
+ Score from 0.0 (fails the rubric) to 1.0 (fully meets it). Respond with ONLY a
122
+ JSON object, no prose:
123
+ {"score": <0..1>, "reason": "<one short sentence>"}
124
+ PROMPT
125
+ end
81
126
 
82
127
  # One model's verdict: its own samples, its own median, its own pass/fail.
83
128
  def judge_once(ask, prompt, min)
@@ -60,12 +60,19 @@ module Insika
60
60
  def compare(golden:, turns:)
61
61
  return nil unless golden.reference?
62
62
 
63
- ours = Pairwise.transcript(golden.user_turns, turns)
64
- theirs = Pairwise.reference_transcript(golden.reference_messages)
65
- return nil if ours.strip.empty?
63
+ compare_texts(ours: Pairwise.transcript(golden.user_turns, turns),
64
+ theirs: Pairwise.reference_transcript(golden.reference_messages),
65
+ vs: golden.human_assisted? ? "human-assisted" : "agent")
66
+ end
67
+
68
+ # two transcripts, no golden — the shadow seam. The judge is
69
+ # the SAME object with the SAME prompt, so shadow pairs and golden cases are
70
+ # graded by one rule. An empty side returns nil (never a verdict against an
71
+ # empty string — that was a bug in the golden path until this seam landed).
72
+ def compare_texts(ours:, theirs:, vs: "agent")
73
+ return nil if ours.to_s.strip.empty? || theirs.to_s.strip.empty?
66
74
 
67
- panel = @asks.map { |ask| judge_once(ask, ours, theirs) }
68
- combine(panel, vs: golden.human_assisted? ? "human-assisted" : "agent")
75
+ combine(@asks.map { |ask| judge_once(ask, ours, theirs) }, vs: vs)
69
76
  end
70
77
 
71
78
  # The replayed conversation as the judge reads it: the user turns we sent,
@@ -77,12 +84,28 @@ module Insika
77
84
  end.flatten.join("\n")
78
85
  end
79
86
 
87
+ # A GENERATED (simulated) conversation as the judge reads it: the transcript
88
+ # is already interleaved [{ role: "user"|"assistant", text: }] — map it to the
89
+ # same customer/assistant lines a replay produces, so pairwise compares like
90
+ # for like (a simulated run can be compared against the incumbent).
91
+ def self.transcript_text(messages)
92
+ Array(messages).map do |m|
93
+ role = m[:role].to_s == "user" ? "customer" : "assistant"
94
+ "#{role}: #{m[:text].to_s.strip}"
95
+ end.join("\n")
96
+ end
97
+
80
98
  # The incumbent's half. Human turns are NOT flagged to the judge: what it grades
81
99
  # is the conversation as the customer received it, and telling it "a person wrote
82
100
  # this one" is an invitation to grade the author instead. The fact is carried to
83
101
  # the READER as `vs: human-assisted` instead, which is where it changes a decision.
102
+ # A reference with no text at all returns "" — the caller's empty guard then
103
+ # refuses instead of judging against an empty string .
84
104
  def self.reference_transcript(messages)
85
- Array(messages).map do |m|
105
+ msgs = Array(messages)
106
+ return "" if msgs.all? { |m| m["text"].to_s.strip.empty? }
107
+
108
+ msgs.map do |m|
86
109
  speaker = m["role"].to_s == "user" ? "customer" : "assistant"
87
110
  "#{speaker}: #{m['text'].to_s.strip}"
88
111
  end.join("\n")