insika 0.3.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -0
  3. data/README.md +45 -10
  4. data/bin/insika +684 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +94 -403
  7. data/docs/API.md +5 -5
  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 +14 -14
  12. data/docs/CONTEXT.md +9 -7
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +71 -3
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +2 -2
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +15 -10
  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 +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +3 -3
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +22 -6
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +2 -2
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +152 -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 +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  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 +73 -16
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +22 -2
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/context/priority.rb +2 -0
  76. data/lib/insika/context/providers/knowledge.rb +108 -0
  77. data/lib/insika/context/providers/prompt.rb +30 -24
  78. data/lib/insika/cron.rb +189 -0
  79. data/lib/insika/demo/agent_attrs.rb +43 -0
  80. data/lib/insika/demo/golden_cases.rb +81 -0
  81. data/lib/insika/demo/seeder.rb +336 -0
  82. data/lib/insika/doctor.rb +176 -8
  83. data/lib/insika/dsl/definition.rb +3 -2
  84. data/lib/insika/dsl/runtime.rb +60 -79
  85. data/lib/insika/dsl/server_boot.rb +23 -1
  86. data/lib/insika/dsl/system.rb +10 -2
  87. data/lib/insika/dsl.rb +103 -2
  88. data/lib/insika/env_schema.rb +16 -1
  89. data/lib/insika/evals/golden.rb +41 -4
  90. data/lib/insika/evals/judge.rb +47 -2
  91. data/lib/insika/evals/pairwise.rb +11 -0
  92. data/lib/insika/evals/persona.rb +98 -0
  93. data/lib/insika/evals/runner.rb +9 -0
  94. data/lib/insika/evals/simulator.rb +225 -0
  95. data/lib/insika/evals/transport.rb +83 -1
  96. data/lib/insika/event_stream.rb +10 -0
  97. data/lib/insika/executor.rb +231 -55
  98. data/lib/insika/followup_policy.rb +2 -25
  99. data/lib/insika/golden_store.rb +16 -1
  100. data/lib/insika/grounding/matcher.rb +1 -1
  101. data/lib/insika/knowledge.rb +680 -0
  102. data/lib/insika/knowledge_store.rb +140 -0
  103. data/lib/insika/mcp_client.rb +94 -0
  104. data/lib/insika/mcp_json.rb +74 -0
  105. data/lib/insika/mcp_live_tool.rb +43 -0
  106. data/lib/insika/mcp_store.rb +98 -26
  107. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  108. data/lib/insika/mcp_tool_registry.rb +100 -0
  109. data/lib/insika/media.rb +115 -31
  110. data/lib/insika/message_origin.rb +1 -1
  111. data/lib/insika/middleware.rb +9 -0
  112. data/lib/insika/onboarding.rb +17 -1
  113. data/lib/insika/outcome_store.rb +1 -1
  114. data/lib/insika/overlay_tool_registry.rb +37 -17
  115. data/lib/insika/packaging.rb +2 -2
  116. data/lib/insika/profile_source.rb +8 -1
  117. data/lib/insika/prompt_catalog.rb +10 -0
  118. data/lib/insika/retention.rb +36 -1
  119. data/lib/insika/router/app.rb +157 -0
  120. data/lib/insika/router/backend_pool.rb +98 -0
  121. data/lib/insika/router/hash_ring.rb +55 -0
  122. data/lib/insika/router/proxy_body.rb +34 -0
  123. data/lib/insika/router/session_key.rb +54 -0
  124. data/lib/insika/router.rb +18 -0
  125. data/lib/insika/schedule.rb +177 -0
  126. data/lib/insika/schedule_engine.rb +314 -0
  127. data/lib/insika/schedule_store.rb +208 -0
  128. data/lib/insika/server/app.rb +105 -15
  129. data/lib/insika/server/rack_app.rb +5 -1
  130. data/lib/insika/server/responses.rb +1 -1
  131. data/lib/insika/skill_catalog.rb +12 -0
  132. data/lib/insika/steer_injector.rb +21 -10
  133. data/lib/insika/studio/app.rb +567 -45
  134. data/lib/insika/studio/assets/dist/application.css +1 -1
  135. data/lib/insika/studio/assets/dist/application.js +21 -21
  136. data/lib/insika/studio/forms.rb +46 -5
  137. data/lib/insika/studio/nav_icons.rb +14 -1
  138. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  139. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  140. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  141. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  142. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  143. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  144. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  145. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  146. data/lib/insika/studio/views/_agents_master.erb +44 -0
  147. data/lib/insika/studio/views/_message.erb +49 -32
  148. data/lib/insika/studio/views/agent_detail.erb +61 -820
  149. data/lib/insika/studio/views/agents.erb +70 -57
  150. data/lib/insika/studio/views/artifact.erb +23 -0
  151. data/lib/insika/studio/views/artifacts.erb +59 -0
  152. data/lib/insika/studio/views/evals.erb +2 -2
  153. data/lib/insika/studio/views/facts.erb +1 -1
  154. data/lib/insika/studio/views/funnel.erb +1 -1
  155. data/lib/insika/studio/views/home.erb +106 -67
  156. data/lib/insika/studio/views/knowledge.erb +123 -0
  157. data/lib/insika/studio/views/layout.erb +14 -11
  158. data/lib/insika/studio/views/mcp.erb +174 -80
  159. data/lib/insika/studio/views/session.erb +231 -177
  160. data/lib/insika/studio/views/settings.erb +39 -1
  161. data/lib/insika/studio/views/skills.erb +1 -1
  162. data/lib/insika/studio/views/tools.erb +24 -9
  163. data/lib/insika/templates/browser-agent/README.md +36 -0
  164. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  165. data/lib/insika/templates/daily-digest/README.md +38 -0
  166. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  167. data/lib/insika/templates/repo-explorer/README.md +36 -0
  168. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  169. data/lib/insika/templates/research-analyst/README.md +26 -0
  170. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  171. data/lib/insika/templates/review-panel/README.md +20 -0
  172. data/lib/insika/templates/review-panel/agent.rb +50 -0
  173. data/lib/insika/templates/travel-planner/README.md +35 -0
  174. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  175. data/lib/insika/templates.rb +112 -0
  176. data/lib/insika/tick.rb +24 -12
  177. data/lib/insika/timezone.rb +45 -0
  178. data/lib/insika/tools/generate_image.rb +52 -7
  179. data/lib/insika/tools/load_knowledge.rb +74 -0
  180. data/lib/insika/tools/run_persona_eval.rb +328 -0
  181. data/lib/insika/tools/save_artifact.rb +95 -0
  182. data/lib/insika/turn_output.rb +1 -1
  183. data/lib/insika/turn_state.rb +15 -4
  184. data/lib/insika/version.rb +1 -1
  185. data/lib/insika/wiring/graph.rb +184 -12
  186. data/lib/insika/wiring/graph_chat.rb +102 -0
  187. data/lib/insika.rb +57 -0
  188. metadata +105 -5
  189. data/docs/build.md +0 -14
  190. data/docs/understand.md +0 -10
@@ -14,8 +14,6 @@ module Insika
14
14
  # Built lazily by Definition#runtime, so `require "insika"` never pays for
15
15
  # ruby_llm or the HTTP server.
16
16
  class Runtime
17
- TERMINAL = %i[task_completed task_failed task_cancelled].freeze
18
-
19
17
  # graph: the assembled Wiring::Graph::Result; pack: the PRIMARY imported
20
18
  # Pack; packs: every imported Pack (one per agent — a System has N, a
21
19
  # Definition has one). All read by the server boot and the specs.
@@ -40,6 +38,11 @@ module Insika
40
38
  # rather than reading a process-wide singleton at call time.
41
39
  @llm = build_llm_context
42
40
  @graph = build_graph
41
+ # the dispatch+drain seam `#chat`/`#run_workflow` delegate to —
42
+ # `Insika::Wiring::GraphChat`, shared with `config/deployment.rb`'s own
43
+ # registration of `run_persona_eval` (see `register_persona_eval_tool`
44
+ # below).
45
+ @chat_runtime = Insika::Wiring::GraphChat.new(graph: @graph)
43
46
  seed_default_model
44
47
  import_packs
45
48
  end
@@ -62,12 +65,8 @@ module Insika
62
65
  # one-shot; a value threads multi-turn memory (created on first use).
63
66
  # `agent:` targets one agent of a system (default: the primary).
64
67
  def chat(message, session_id: nil, timeout: nil, agent: nil)
65
- command = Insika::Command.build(
66
- :send_message,
67
- { agent: (agent || @definition.id).to_s, message: message, session_id: session_id },
68
- transport: :cli
69
- )
70
- run_command(command, session_id: session_id, timeout: timeout)[:text]
68
+ @chat_runtime.chat(message, agent: (agent || @definition.id).to_s,
69
+ session_id: session_id, timeout: timeout)
71
70
  end
72
71
 
73
72
  # Triggers a declared workflow and returns its OUTPUT (the typed value the
@@ -78,7 +77,7 @@ module Insika
78
77
  command = Insika::Command.build(
79
78
  :trigger_workflow, { workflow: name, agent: agent, input: input }, transport: :cli
80
79
  )
81
- outcome = run_command(command, session_id: nil, timeout: timeout)
80
+ outcome = @chat_runtime.run_command(command, session_id: nil, timeout: timeout)
82
81
  outcome.key?(:output) ? outcome[:output] : outcome[:text]
83
82
  end
84
83
 
@@ -93,70 +92,6 @@ module Insika
93
92
 
94
93
  private
95
94
 
96
- # Dispatch + drain, shared by a turn and a workflow run: subscribe BEFORE
97
- # dispatching (the fiber may emit eagerly), bind to the task, read to the
98
- # terminal event. A synchronous handler error (unknown agent/workflow, bad
99
- # input against the schema) propagates from `dispatch` untouched — the
100
- # caller sees the real ValidationError/NotFoundError, not a turn failure.
101
- def run_command(command, session_id:, timeout:)
102
- require "async"
103
- outcome = {}
104
- rejected = nil
105
- Async do |task|
106
- serving = !session_id.nil?
107
- @graph.executor.supervised = true if serving
108
- ensure_session(session_id) if session_id
109
- sub = @graph.event_stream.subscribe
110
- res = begin
111
- @graph.bus.dispatch(command)
112
- rescue StandardError => e
113
- # CAPTURED, not re-raised inside the reactor: letting it escape the
114
- # Async block logs "Task may have ended with unhandled exception" —
115
- # alarming noise for a DOCUMENTED rejection (a schema violation, an
116
- # unknown agent). Re-raised verbatim below, outside the reactor.
117
- sub.close
118
- rejected = e
119
- next
120
- end
121
- sub.bind(task_id: res[:task_id])
122
- drain(sub, outcome, task, timeout)
123
- teardown_serving if serving
124
- end.wait
125
- raise rejected if rejected
126
- raise Insika::Error, "turn #{outcome[:error]}" if outcome[:error]
127
-
128
- outcome
129
- end
130
-
131
- # Reads the stream until this turn reaches a terminal event, then stops.
132
- # A workflow run also carries its typed OUTPUT, which is not the turn text.
133
- def drain(sub, outcome, task, timeout)
134
- reader = task.async do
135
- sub.each do |ev|
136
- case ev.type
137
- when :task_completed then outcome[:text] = ev.data[:content].to_s
138
- when :workflow_completed then outcome[:output] = ev.data[:output]
139
- when :task_failed then outcome[:error] = "failed: #{ev.data[:message] || ev.data[:error]}"
140
- when :task_cancelled then outcome[:error] = "cancelled"
141
- end
142
- sub.close if TERMINAL.include?(ev.type)
143
- end
144
- end
145
- timeout ? task.with_timeout(timeout) { reader.wait } : reader.wait
146
- rescue Async::TimeoutError
147
- outcome[:error] = "timed out after #{timeout}s"
148
- sub.close
149
- end
150
-
151
- def ensure_session(session_id)
152
- @graph.session_store.find(session_id) || @graph.session_store.create(id: session_id, vars: {})
153
- end
154
-
155
- def teardown_serving
156
- @graph.executor.stop_session_actors
157
- @graph.executor.instance_variable_get(:@supervisor)&.stop
158
- end
159
-
160
95
  # --- graph assembly (mirrors config/deployment.rb, generically) ------
161
96
 
162
97
  def build_graph
@@ -183,6 +118,20 @@ module Insika
183
118
  )
184
119
  register_authoring_commands(graph, c)
185
120
  register_workflows(graph)
121
+ import_mcp_declarations(c)
122
+ # `run_persona_eval` (C3.1): the SAME registration `config/deployment.rb`
123
+ # does for its own graph — see `Wiring::Graph.register_persona_eval_tool`.
124
+ # One implementation, both roots; this one just already has its
125
+ # `golden_store`/`settings_store` at hand from `build_components`.
126
+ Insika::Wiring::Graph.register_persona_eval_tool(
127
+ graph, golden_store: Insika::GoldenStore.new(config_store: c[:config_store]),
128
+ settings_store: c[:settings_store], llm: @llm
129
+ )
130
+ # Same boot step the server roots run: an installed `insika-plugin-*`
131
+ # gem (or INSIKA_PLUGIN_DIR) extends a DSL-run agent too — a plugin
132
+ # works everywhere or it is not a plugin. No bundled root here: the DSL
133
+ # runs outside the repo checkout.
134
+ Insika::Wiring::Graph.load_plugins(graph)
186
135
  graph
187
136
  end
188
137
 
@@ -210,15 +159,42 @@ module Insika
210
159
  ))
211
160
  end
212
161
 
162
+ # Upserts every DSL-declared `mcp` instance into the McpStore at boot.
163
+ # Code is the TEMPLATE — transport/command/args/url/
164
+ # description always follow the declaration — but `enabled` and the
165
+ # credentials (`env`/`headers`) are the operator's once the instance
166
+ # exists: an already-present record's own values are re-sent verbatim
167
+ # instead of the DSL's, so a Studio/CLI/API edit made after boot is never
168
+ # clobbered by the next restart (motor-vs-forja: the store's DATA wins).
169
+ def import_mcp_declarations(c)
170
+ declared = Array(@definition.mcp_instances)
171
+ return if declared.empty?
172
+
173
+ mcp_store = c[:mcp_store]
174
+ declared.each do |decl|
175
+ attrs = decl.dup
176
+ existing = mcp_store.get_raw(attrs[:name])
177
+ if existing
178
+ attrs[:enabled] = existing["enabled"]
179
+ attrs[:env] = existing["env"]
180
+ attrs[:headers] = existing["headers"]
181
+ end
182
+ mcp_store.upsert(attrs)
183
+ end
184
+ end
185
+
213
186
  def build_components(backend, spine)
214
187
  config_store = Insika::ConfigStore.new(store: backend)
215
188
  settings_store = Insika::SettingsStore.new(config_store: config_store)
216
189
  provider_store = Insika::LLMProviderStore.new(config_store: config_store)
217
190
  tool_store = Insika::ToolStore.new(config_store: config_store)
218
191
  skill_store = Insika::SkillStore.new(config_store: config_store)
192
+ mcp_store = Insika::McpStore.new(config_store: config_store)
193
+ mcp_tool_registry = Insika::McpToolRegistry.new(mcp_store: mcp_store)
219
194
  tool_registry = Insika::OverlayToolRegistry.new(
220
195
  base: spine.code_tool_registry, tool_store: tool_store,
221
- http: Insika::HttpClient.new, event_stream: spine.event_stream
196
+ http: Insika::HttpClient.new, event_stream: spine.event_stream,
197
+ mcp_registry: mcp_tool_registry
222
198
  )
223
199
  {
224
200
  backend: backend, config_store: config_store,
@@ -227,7 +203,7 @@ module Insika
227
203
  agent_file_store: Insika::AgentFileStore.new(config_store: config_store),
228
204
  skill_store: skill_store, tool_store: tool_store,
229
205
  system_file_store: Insika::SystemFileStore.new(config_store: config_store),
230
- mcp_store: Insika::McpStore.new(config_store: config_store),
206
+ mcp_store: mcp_store, mcp_tool_registry: mcp_tool_registry,
231
207
  tool_trace_store: Insika::ToolTraceStore.new(store: backend),
232
208
  context_trace_store: Insika::ContextTraceStore.new(store: backend),
233
209
  # the per-AGENT cache-hit series (the Studio agent-detail card).
@@ -252,15 +228,16 @@ module Insika
252
228
  [
253
229
  Insika::Context::Providers::Request.new,
254
230
  Insika::Context::Providers::Prompt.new(
255
- base: "", files: [], catalog: c[:prompt_catalog],
231
+ base: "", catalog: c[:prompt_catalog],
256
232
  agent_files: c[:agent_file_store], system_files: c[:system_file_store]
257
233
  ),
258
234
  Insika::Context::Providers::Skill.new(catalog: c[:skill_catalog]),
259
235
  Insika::Context::Providers::SkillTrigger.new(catalog: c[:skill_catalog]),
260
236
  Insika::Context::Providers::ToolSearch.new(catalog: c[:tool_catalog]),
261
237
  Insika::Context::Providers::Memory.new(store: spine.memory_store),
238
+ Insika::Context::Providers::Knowledge.new(store: spine.knowledge_store),
262
239
  Insika::Context::Providers::Session.new(session_store: spine.session_store)
263
- ].freeze
240
+ ] # NOT frozen: load_plugins appends plugin providers at boot
264
241
  end
265
242
 
266
243
  # The runtime-authoring surface a pack import needs (create_agent +
@@ -288,14 +265,18 @@ module Insika
288
265
  bus.register(:update_settings, Insika::Commands::UpdateSettings.new(settings_store: c[:settings_store], event_stream: es))
289
266
  bus.register(:upsert_llm_provider, Insika::Commands::UpsertLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
290
267
  bus.register(:delete_llm_provider, Insika::Commands::DeleteLLMProvider.new(provider_store: c[:provider_store], configurator: c[:configurator], event_stream: es))
291
- bus.register(:upsert_mcp, Insika::Commands::UpsertMcp.new(mcp_store: c[:mcp_store], event_stream: es))
292
- bus.register(:delete_mcp, Insika::Commands::DeleteMcp.new(mcp_store: c[:mcp_store], event_stream: es))
268
+ bus.register(:upsert_mcp, Insika::Commands::UpsertMcp.new(mcp_store: c[:mcp_store], mcp_registry: c[:mcp_tool_registry], event_stream: es))
269
+ bus.register(:delete_mcp, Insika::Commands::DeleteMcp.new(mcp_store: c[:mcp_store], mcp_registry: c[:mcp_tool_registry], event_stream: es))
270
+ bus.register(:refresh_mcp_tools, Insika::Commands::RefreshMcpTools.new(mcp_registry: c[:mcp_tool_registry], event_stream: es))
293
271
  bus.register(:write_system_file, Insika::Commands::WriteSystemFile.new(system_file_store: c[:system_file_store], event_stream: es))
294
272
  bus.register(:delete_system_file, Insika::Commands::DeleteSystemFile.new(system_file_store: c[:system_file_store], event_stream: es))
295
273
  bus.register(:restore_system_file, Insika::Commands::RestoreSystemFile.new(system_file_store: c[:system_file_store], event_stream: es))
296
274
  bus.register(:write_data_tool, Insika::Commands::WriteDataTool.new(tool_store: c[:tool_store], registry: c[:tool_registry], tool_catalog: c[:tool_catalog], event_stream: es))
297
275
  bus.register(:delete_data_tool, Insika::Commands::DeleteDataTool.new(tool_store: c[:tool_store], registry: c[:tool_registry], tool_catalog: c[:tool_catalog], event_stream: es))
298
276
  bus.register(:restore_data_tool, Insika::Commands::RestoreDataTool.new(tool_store: c[:tool_store], registry: c[:tool_registry], tool_catalog: c[:tool_catalog], event_stream: es))
277
+ bus.register(:write_concept, Insika::Commands::WriteConcept.new(knowledge_store: graph.knowledge_store, event_stream: es))
278
+ bus.register(:delete_concept, Insika::Commands::DeleteConcept.new(knowledge_store: graph.knowledge_store, event_stream: es))
279
+ bus.register(:restore_concept, Insika::Commands::RestoreConcept.new(knowledge_store: graph.knowledge_store, event_stream: es))
299
280
  end
300
281
 
301
282
  # --- RubyLLM configuration ------------------------------------------
@@ -51,6 +51,10 @@ module Insika
51
51
  Insika::Shutdown.install(executor: @graph.executor)
52
52
  Async do
53
53
  @graph.executor.supervised = true # serving mode: turns survive disconnects
54
+ # forces the tick/alert/distill/harvest supervisor up now — otherwise a
55
+ # deployment with only scheduled agents (no live chat) never ticks until
56
+ # unrelated traffic happens to hit turn_parent first.
57
+ @graph.executor.start_supervisor!
54
58
  Insika::Telemetry.attach(event_stream: @graph.event_stream, recorder: telemetry)
55
59
  Async::HTTP::Server.new(middleware, endpoint).run
56
60
  end
@@ -122,10 +126,28 @@ module Insika
122
126
  # (nil = the page renders the refusal/empty states).
123
127
  harvest_store: @graph.harvest_store,
124
128
  harvest_criterion: harvest_criterion,
125
- negative_list: harvest_negative_list
129
+ negative_list: harvest_negative_list,
130
+ # the Knowledge page reads the store directly; its
131
+ # mutations (write/delete/restore) dispatch bus commands.
132
+ knowledge_store: @graph.knowledge_store,
133
+ # the Artifacts tab reads the report store; the signing
134
+ # config (nil without INSIKA_ARTIFACT_SIGNING_KEY) decides whether a
135
+ # signed sharing link exists at all.
136
+ artifact_store: @graph.artifact_store,
137
+ artifact_signing: artifact_signing
126
138
  )
127
139
  end
128
140
 
141
+ # -> { key:, ttl:, base_url: } | nil (no signing key -> no signed surface).
142
+ def artifact_signing
143
+ key = Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_KEY")
144
+ return nil if key.to_s.empty?
145
+
146
+ { key: key,
147
+ ttl: (Insika::EnvSchema.read("INSIKA_ARTIFACT_SIGNING_TTL") || 604_800).to_i,
148
+ base_url: Insika::Coercion.presence(Insika::EnvSchema.read("INSIKA_PUBLIC_URL")).to_s }
149
+ end
150
+
129
151
  # The frozen criterion — best-effort at boot: a bare install
130
152
  # without the env var renders the ruler's hole instead of
131
153
  # crashing the Studio.
@@ -15,13 +15,21 @@ module Insika
15
15
  # require the child agents to be resolvable in the SAME ProfileSource. A
16
16
  # Definition owns exactly one pack, so those patterns had no home in the DSL.
17
17
  class System
18
- attr_reader :definitions, :workflows, :runtime_options
18
+ attr_reader :definitions, :workflows, :mcp_instances, :runtime_options
19
19
 
20
20
  # `backend`: the store this system's graph owns. nil = the
21
21
  # historic path (INSIKA_DB, or memory when unset). Set by `Insika.embed`.
22
- def initialize(definitions:, workflows: [], runtime: {}, backend: nil)
22
+ def initialize(definitions:, workflows: [], mcp_instances: [], runtime: {}, backend: nil)
23
23
  @definitions = definitions.freeze
24
24
  @workflows = workflows.freeze
25
+ # MCP instances are global to the graph (one McpStore, not per-agent),
26
+ # so a system-level `mcp` declaration and one nested inside a member
27
+ # `agent { … }` block land in the same set. A name declared both places
28
+ # is NOT an error here (unlike within one collection) — the later one
29
+ # (system-level, applied last) simply wins; Insika::DSL::Runtime upserts
30
+ # this combined list once at boot.
31
+ @mcp_instances = (definitions.flat_map(&:mcp_instances) + mcp_instances)
32
+ .each_with_object({}) { |m, acc| acc[m[:name]] = m }.values.freeze
25
33
  @backend = backend
26
34
  # Agent-level runtime knobs merged in declaration order, then the
27
35
  # system-level ones on top (an explicit `provider`/`api_key` in the
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
 
@@ -255,6 +314,41 @@ module Insika
255
314
  # miner: { model: "deepseek-v4-flash", window: { last_sessions: 200 } }
256
315
  def harvest(hash) = (@config[:harvest] ||= {}).merge!(hash.transform_keys(&:to_s))
257
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
+
258
352
  # Provider-interaction reliability, as DATA (WS3): retries + exponential
259
353
  # backoff on transient failures, a fallback model chain (mid-turn
260
354
  # rotation), and a circuit breaker per (tenant, provider/model) that
@@ -305,6 +399,13 @@ module Insika
305
399
  # tts: { model: "tts-1", voice: "alloy" }
306
400
  def outputs(hash) = (@config[:outputs] ||= {}).merge!(hash.transform_keys(&:to_s))
307
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
+
308
409
  # The engine's "Tool discipline" block in the system prompt (retry a
309
410
  # weak/empty tool result with a different approach before giving up).
310
411
  # ON by default — this setter exists to turn it OFF:
@@ -110,6 +110,9 @@ module Insika
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)."),
@@ -117,6 +120,7 @@ module Insika
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."),
118
121
  spec(name: "INSIKA_STT_MODEL", description: "Model used to transcribe audio message parts (WS9). Unset -> RubyLLM's default transcription model."),
119
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."),
120
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)."),
121
125
  spec(name: "INSIKA_ONBOARDING", type: :boolean, description: "Expose the public onboarding surface (/start.md, /models.json, /docs) in production (opt-in)."),
122
126
  spec(name: "INSIKA_RELAY_TOKEN", secret: true, description: "Bearer the relay consumer sends us. Unset -> the relay channel is not mounted."),
@@ -129,9 +133,20 @@ module Insika
129
133
  spec(name: "INSIKA_HARVEST_NEGATIVE", type: :path, description: "The negative-list seed file the harvest CLI imports into agent profiles."),
130
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."),
131
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)."),
132
145
  spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
133
146
  spec(name: "OPENCLAW_AGENTS_DIR", type: :path, description: "Directory of OpenClaw-style agent packs."),
134
- 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."),
135
150
  spec(name: "ADMIN_TOKEN", secret: true, description: "Studio login token; unset -> /studio fail-closed."),
136
151
  spec(name: "OTEL_SERVICE_NAME", description: "Service name for OTEL spans (default: insika).")
137
152
  ].freeze
@@ -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)
@@ -84,6 +84,17 @@ module Insika
84
84
  end.flatten.join("\n")
85
85
  end
86
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
+
87
98
  # The incumbent's half. Human turns are NOT flagged to the judge: what it grades
88
99
  # is the conversation as the customer received it, and telling it "a person wrote
89
100
  # this one" is an invitation to grade the author instead. The fact is carried to