insika 0.8.0 → 1.0.1

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 (276) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +312 -2
  3. data/README.md +10 -6
  4. data/bin/insika +4 -1
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +142 -18
  7. data/docs/API.md +73 -0
  8. data/docs/ARCHITECTURE.md +56 -50
  9. data/docs/ARTIFACTS.md +33 -3
  10. data/docs/BENCHMARK.md +50 -7
  11. data/docs/CHANNELS.md +19 -2
  12. data/docs/CONTEXT.md +96 -27
  13. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  14. data/docs/DEPLOY.md +70 -13
  15. data/docs/EMBEDDING.md +10 -0
  16. data/docs/EVALS.md +129 -10
  17. data/docs/FACTS.md +12 -6
  18. data/docs/HARVEST.md +8 -3
  19. data/docs/KNOWLEDGE.md +14 -0
  20. data/docs/MEDIA.md +6 -2
  21. data/docs/OBSERVABILITY.md +164 -28
  22. data/docs/PERFORMANCE.md +110 -0
  23. data/docs/PLUGINS.md +4 -4
  24. data/docs/POLICY.md +26 -14
  25. data/docs/RELEASING.md +9 -0
  26. data/docs/ROUTER.md +7 -0
  27. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  28. data/docs/SECURITY.md +34 -1
  29. data/docs/SHARED-CONVERSATIONS.md +40 -0
  30. data/docs/SKILLS.md +5 -3
  31. data/docs/STUDIO.md +122 -0
  32. data/docs/TOOLS.md +238 -42
  33. data/docs/operate.md +3 -0
  34. data/docs/prompts/ADD-TOOL.md +12 -2
  35. data/docs/prompts/DIAGNOSE-TURN.md +3 -0
  36. data/docs/prompts/GO-LIVE.md +3 -1
  37. data/lib/insika/agent_defaults.rb +140 -0
  38. data/lib/insika/agent_file_store.rb +12 -0
  39. data/lib/insika/agent_profile.rb +99 -17
  40. data/lib/insika/artifact_signing.rb +11 -0
  41. data/lib/insika/channels/web/widget.js +33 -0
  42. data/lib/insika/channels/web.rb +5 -2
  43. data/lib/insika/chat_builder.rb +124 -28
  44. data/lib/insika/checkpoint.rb +6 -1
  45. data/lib/insika/checkpoint_store.rb +59 -7
  46. data/lib/insika/commands/agent_payload.rb +3 -3
  47. data/lib/insika/commands/delete_kit.rb +26 -0
  48. data/lib/insika/commands/gate_harvest.rb +3 -0
  49. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  50. data/lib/insika/commands/run_distillation.rb +5 -8
  51. data/lib/insika/commands/run_harvest.rb +17 -1
  52. data/lib/insika/commands/seed_session.rb +128 -0
  53. data/lib/insika/commands/send_message.rb +9 -3
  54. data/lib/insika/commands/update_settings.rb +23 -1
  55. data/lib/insika/commands/write_kit.rb +27 -0
  56. data/lib/insika/compaction.rb +33 -7
  57. data/lib/insika/config_store.rb +87 -7
  58. data/lib/insika/context/builder.rb +29 -9
  59. data/lib/insika/context/priority.rb +5 -0
  60. data/lib/insika/context/provider.rb +7 -2
  61. data/lib/insika/context/providers/briefing.rb +11 -8
  62. data/lib/insika/context/providers/fence_notice.rb +27 -0
  63. data/lib/insika/context/providers/knowledge.rb +32 -6
  64. data/lib/insika/context/providers/memory.rb +61 -8
  65. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  66. data/lib/insika/context/providers/prompt.rb +17 -4
  67. data/lib/insika/context/providers/session.rb +46 -6
  68. data/lib/insika/context/reranker.rb +75 -0
  69. data/lib/insika/distill.rb +22 -13
  70. data/lib/insika/doctor.rb +142 -4
  71. data/lib/insika/dsl/runtime.rb +37 -4
  72. data/lib/insika/dsl/server_boot.rb +4 -12
  73. data/lib/insika/dsl.rb +19 -5
  74. data/lib/insika/edge_limiter.rb +27 -16
  75. data/lib/insika/egress_guard.rb +11 -3
  76. data/lib/insika/env_schema.rb +33 -73
  77. data/lib/insika/errors.rb +1 -0
  78. data/lib/insika/evals/assertions.rb +259 -18
  79. data/lib/insika/evals/golden.rb +230 -4
  80. data/lib/insika/evals/judge.rb +19 -10
  81. data/lib/insika/evals/moderator_compare.rb +47 -0
  82. data/lib/insika/evals/report.rb +61 -9
  83. data/lib/insika/evals/runner.rb +64 -4
  84. data/lib/insika/evals/simulator.rb +21 -3
  85. data/lib/insika/evals/transport.rb +317 -15
  86. data/lib/insika/event_stream.rb +63 -7
  87. data/lib/insika/evidence.rb +105 -17
  88. data/lib/insika/executor.rb +698 -164
  89. data/lib/insika/fence.rb +96 -0
  90. data/lib/insika/golden_store.rb +3 -0
  91. data/lib/insika/harvest.rb +26 -14
  92. data/lib/insika/harvest_engine.rb +1 -0
  93. data/lib/insika/harvest_store.rb +12 -0
  94. data/lib/insika/http_client.rb +87 -4
  95. data/lib/insika/kits.rb +54 -0
  96. data/lib/insika/knowledge.rb +34 -20
  97. data/lib/insika/llm_configurator.rb +46 -4
  98. data/lib/insika/llm_provider_store.rb +1 -3
  99. data/lib/insika/llm_trace_store.rb +54 -0
  100. data/lib/insika/mcp_client.rb +27 -63
  101. data/lib/insika/mcp_live_tool.rb +55 -9
  102. data/lib/insika/mcp_store.rb +37 -3
  103. data/lib/insika/mcp_tool_registry.rb +76 -26
  104. data/lib/insika/media.rb +38 -25
  105. data/lib/insika/memory_store.rb +16 -3
  106. data/lib/insika/memory_trim.rb +60 -0
  107. data/lib/insika/middleware.rb +12 -3
  108. data/lib/insika/model_metrics_store.rb +285 -0
  109. data/lib/insika/model_registry.rb +110 -0
  110. data/lib/insika/model_resolver.rb +1 -1
  111. data/lib/insika/model_selection.rb +14 -21
  112. data/lib/insika/model_visible.rb +17 -6
  113. data/lib/insika/onboarding.rb +1 -0
  114. data/lib/insika/outcome_store.rb +1 -1
  115. data/lib/insika/overlay_tool_registry.rb +30 -2
  116. data/lib/insika/pack_importer.rb +54 -9
  117. data/lib/insika/packaging.rb +14 -5
  118. data/lib/insika/pending_action_store.rb +45 -18
  119. data/lib/insika/plugin/loader.rb +4 -2
  120. data/lib/insika/prefix_fingerprint.rb +32 -27
  121. data/lib/insika/profile_source.rb +5 -0
  122. data/lib/insika/provider_error_classifier.rb +18 -3
  123. data/lib/insika/queue_policy.rb +7 -2
  124. data/lib/insika/refinement/proposer.rb +42 -17
  125. data/lib/insika/reliability.rb +12 -3
  126. data/lib/insika/router/app.rb +14 -5
  127. data/lib/insika/router/session_key.rb +35 -5
  128. data/lib/insika/safety/config.rb +20 -3
  129. data/lib/insika/safety/corpus.rb +10 -3
  130. data/lib/insika/safety/factory.rb +39 -1
  131. data/lib/insika/safety/input_guardrail.rb +21 -1
  132. data/lib/insika/safety/judge_moderator.rb +70 -0
  133. data/lib/insika/safety/output_validator.rb +29 -5
  134. data/lib/insika/safety/prompt_echo.rb +89 -0
  135. data/lib/insika/schema_guard.rb +53 -76
  136. data/lib/insika/server/app.rb +58 -7
  137. data/lib/insika/server/rack_app.rb +2 -0
  138. data/lib/insika/server/responses.rb +50 -5
  139. data/lib/insika/session_actor.rb +58 -28
  140. data/lib/insika/session_store.rb +163 -11
  141. data/lib/insika/settings_store.rb +57 -2
  142. data/lib/insika/shared_conversations.rb +305 -0
  143. data/lib/insika/skill_catalog.rb +28 -2
  144. data/lib/insika/skill_store.rb +6 -0
  145. data/lib/insika/soak/runner.rb +1 -1
  146. data/lib/insika/spoken_transcript.rb +31 -0
  147. data/lib/insika/steer_injector.rb +7 -48
  148. data/lib/insika/store.rb +29 -1
  149. data/lib/insika/stores/json_value.rb +37 -0
  150. data/lib/insika/stores/memory.rb +22 -35
  151. data/lib/insika/stores/postgres.rb +295 -0
  152. data/lib/insika/stores/sqlite.rb +257 -43
  153. data/lib/insika/stores/turn_counter.rb +25 -0
  154. data/lib/insika/studio/app.rb +1459 -163
  155. data/lib/insika/studio/assets/dist/application.css +1 -1
  156. data/lib/insika/studio/assets/dist/application.js +25 -25
  157. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  158. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  159. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  160. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  161. data/lib/insika/studio/forms.rb +189 -12
  162. data/lib/insika/studio/nav_icons.rb +3 -0
  163. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  164. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  165. data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
  166. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  167. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  168. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  169. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  170. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  171. data/lib/insika/studio/views/_agents_master.erb +8 -12
  172. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  173. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  174. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  175. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  176. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  177. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  178. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  179. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  180. data/lib/insika/studio/views/_chats_master.erb +66 -0
  181. data/lib/insika/studio/views/_customers_master.erb +37 -0
  182. data/lib/insika/studio/views/_empty.erb +15 -0
  183. data/lib/insika/studio/views/_facts_master.erb +42 -0
  184. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  185. data/lib/insika/studio/views/_identity.erb +16 -0
  186. data/lib/insika/studio/views/_kpi.erb +17 -0
  187. data/lib/insika/studio/views/_message.erb +40 -2
  188. data/lib/insika/studio/views/_model_chart.erb +82 -0
  189. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  190. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
  191. data/lib/insika/studio/views/_tool_users.erb +17 -0
  192. data/lib/insika/studio/views/_why.erb +53 -0
  193. data/lib/insika/studio/views/agent_detail.erb +17 -10
  194. data/lib/insika/studio/views/agents.erb +28 -16
  195. data/lib/insika/studio/views/approvals.erb +16 -11
  196. data/lib/insika/studio/views/artifact.erb +20 -7
  197. data/lib/insika/studio/views/artifacts.erb +19 -14
  198. data/lib/insika/studio/views/chats.erb +22 -29
  199. data/lib/insika/studio/views/customer.erb +88 -43
  200. data/lib/insika/studio/views/customers.erb +16 -26
  201. data/lib/insika/studio/views/evals.erb +49 -31
  202. data/lib/insika/studio/views/fact_detail.erb +114 -0
  203. data/lib/insika/studio/views/facts.erb +15 -116
  204. data/lib/insika/studio/views/followups.erb +63 -52
  205. data/lib/insika/studio/views/funnel.erb +104 -43
  206. data/lib/insika/studio/views/harvest.erb +85 -59
  207. data/lib/insika/studio/views/home.erb +138 -23
  208. data/lib/insika/studio/views/inbox.erb +36 -0
  209. data/lib/insika/studio/views/kits.erb +176 -0
  210. data/lib/insika/studio/views/knowledge.erb +92 -80
  211. data/lib/insika/studio/views/layout.erb +42 -11
  212. data/lib/insika/studio/views/live.erb +45 -0
  213. data/lib/insika/studio/views/mcp.erb +43 -13
  214. data/lib/insika/studio/views/models.erb +187 -0
  215. data/lib/insika/studio/views/parity.erb +38 -32
  216. data/lib/insika/studio/views/playground.erb +13 -9
  217. data/lib/insika/studio/views/refinement.erb +15 -224
  218. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  219. data/lib/insika/studio/views/session.erb +153 -118
  220. data/lib/insika/studio/views/settings.erb +86 -22
  221. data/lib/insika/studio/views/skills.erb +53 -19
  222. data/lib/insika/studio/views/system_files.erb +7 -7
  223. data/lib/insika/studio/views/task.erb +45 -15
  224. data/lib/insika/studio/views/tasks.erb +35 -18
  225. data/lib/insika/studio/views/tools.erb +62 -73
  226. data/lib/insika/task_actor.rb +4 -1
  227. data/lib/insika/task_store.rb +67 -1
  228. data/lib/insika/telemetry/pricing.rb +16 -14
  229. data/lib/insika/telemetry/recorder.rb +115 -9
  230. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  231. data/lib/insika/telemetry.rb +12 -2
  232. data/lib/insika/templates/daily-digest/README.md +13 -0
  233. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  234. data/lib/insika/testing/store_contract.rb +65 -0
  235. data/lib/insika/text_tool_call.rb +66 -0
  236. data/lib/insika/tick.rb +7 -1
  237. data/lib/insika/token_store.rb +5 -0
  238. data/lib/insika/tool_assembly.rb +21 -13
  239. data/lib/insika/tool_batch.rb +1 -1
  240. data/lib/insika/tool_catalog.rb +12 -2
  241. data/lib/insika/tool_definition.rb +82 -13
  242. data/lib/insika/tool_envelope.rb +266 -29
  243. data/lib/insika/tool_manifest.rb +1 -0
  244. data/lib/insika/tool_store.rb +12 -4
  245. data/lib/insika/tool_trace_store.rb +15 -8
  246. data/lib/insika/tool_usage_report.rb +14 -4
  247. data/lib/insika/tools/a2a_remote.rb +1 -1
  248. data/lib/insika/tools/agent_enum.rb +1 -1
  249. data/lib/insika/tools/confirm_pending.rb +112 -0
  250. data/lib/insika/tools/data_defined_tool.rb +24 -15
  251. data/lib/insika/tools/generate_image.rb +2 -2
  252. data/lib/insika/tools/load_knowledge.rb +7 -3
  253. data/lib/insika/tools/load_skill.rb +3 -3
  254. data/lib/insika/tools/present.rb +115 -0
  255. data/lib/insika/tools/remember.rb +7 -2
  256. data/lib/insika/tools/run_persona_eval.rb +9 -6
  257. data/lib/insika/tools/save_artifact.rb +16 -6
  258. data/lib/insika/tools/schedule_followup.rb +3 -3
  259. data/lib/insika/tools/stuck_signal.rb +3 -3
  260. data/lib/insika/tools/subagent.rb +5 -5
  261. data/lib/insika/tools/subagents.rb +3 -3
  262. data/lib/insika/tools/tool_search.rb +11 -7
  263. data/lib/insika/tools/tts.rb +2 -2
  264. data/lib/insika/tools/update_briefing.rb +4 -4
  265. data/lib/insika/turn_output.rb +2 -2
  266. data/lib/insika/turn_state.rb +31 -1
  267. data/lib/insika/turn_timing.rb +51 -14
  268. data/lib/insika/version.rb +1 -1
  269. data/lib/insika/wiring/graph.rb +88 -16
  270. data/lib/insika/wiring/graph_chat.rb +4 -0
  271. data/lib/insika/workflow.rb +25 -64
  272. data/lib/insika.rb +23 -3
  273. metadata +74 -13
  274. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  275. data/lib/insika/mcp_http_client.rb +0 -67
  276. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -13,19 +13,50 @@ module Insika
13
13
  # offline without a server.
14
14
  #
15
15
  # output_text: the final assistant text (for content checks)
16
- # tool_calls: [{ "name" =>, "status" => }] captured from the stream's tool events
16
+ # tool_calls: [{ "name" =>, "arguments" =>, "status" =>, "gate" => }] captured
17
+ # from the stream's tool events. `arguments` (a Hash) and
18
+ # `status` ("ok" | "error" | "blocked", with `gate` naming what
19
+ # held a blocked call) arrive from the item frames; an older
20
+ # deployment sends names only and they stay nil.
21
+ # ui: [{ "component" =>, "count" => }] — one per presentation call
22
+ # (the `insika.ui` frames); nil/[] when the turn showed nothing
17
23
  # error: transport/turn error string, or nil on a clean turn
18
- TurnResult = Struct.new(:output_text, :tool_calls, :error, keyword_init: true) do
24
+ TurnResult = Struct.new(:output_text, :tool_calls, :ui, :error, keyword_init: true) do
19
25
  def tool_names = Array(tool_calls).map { |t| (t["name"] || t[:name]).to_s }
20
26
 
21
- # A tool call whose status is anything but a success ("ok"/2xx/"success").
27
+ # Components that actually put a card in front of the customer (count > 0).
28
+ def shown_components
29
+ Array(ui).select { |u| (u["count"] || u[:count]).to_i.positive? }
30
+ .map { |u| (u["component"] || u[:component]).to_s }
31
+ end
32
+
33
+ # A tool call whose status is anything but a success ("ok"/2xx/"success"). A
34
+ # BLOCKED call is not an error: the tool never ran, a gate held it — that is
35
+ # the `blocked_gates` grader's business, not `must_not: tool_error`'s. Nor is a
36
+ # HELD one: it waits for the customer, and `phantom_action` reads the reply.
22
37
  def errored_tools
23
- Array(tool_calls).reject { |t| Assertions.ok_status?(t["status"] || t[:status]) }
38
+ Array(tool_calls).reject do |t|
39
+ status = t["status"] || t[:status]
40
+ Assertions.ok_status?(status) || %w[blocked held].include?(status.to_s)
41
+ end
42
+ end
43
+
44
+ def blocked_tools
45
+ Array(tool_calls).select { |t| (t["status"] || t[:status]).to_s == "blocked" }
24
46
  end
25
47
  end
26
48
 
27
49
  # A single check within a case (e.g. "tool:shipping_quote", "must_not:pii_leak").
28
- Check = Struct.new(:name, :pass, :detail, keyword_init: true)
50
+ #
51
+ # `skipped` is the third outcome, per check rather than per case: the transport
52
+ # could not observe what this grader reads (a harness that reports no tool
53
+ # calls), so the check neither held nor broke. It carries `pass: true` so it
54
+ # cannot fail a case it never examined, and the report prints it as skipped —
55
+ # the same discipline `requires` already applies to a whole case: reported,
56
+ # never a silent pass.
57
+ Check = Struct.new(:name, :pass, :detail, :skipped, keyword_init: true) do
58
+ def skipped? = skipped == true
59
+ end
29
60
 
30
61
  # The verdict for one golden case. `judge` (a Judge::Verdict) is attached AFTER
31
62
  # the deterministic pass when the case has a rubric and a judge is configured;
@@ -48,6 +79,7 @@ module Insika
48
79
  def skipped? = !skipped.nil?
49
80
  def pass? = !skipped? && error.nil? && checks.all?(&:pass) && (judge.nil? || judge.pass)
50
81
  def failures = checks.reject(&:pass)
82
+ def skipped_checks = checks.select(&:skipped?)
51
83
  # Has a rubric to score but no verdict yet (judge disabled / not run). A skipped
52
84
  # case is not pending anything — nobody is going to judge a turn that never ran.
53
85
  def judge_pending? = !skipped? && !rubric.to_s.strip.empty? && judge.nil?
@@ -79,6 +111,11 @@ module Insika
79
111
  # That is also the honest scope — a question nobody received is not a question.
80
112
  POLICIES = {
81
113
  # "UMA PERGUNTA POR VEZ" — the rule Insika broke twice under a 28 KB prompt.
114
+ # `max:` is the store's, default 1. Measured across six harnesses on one
115
+ # greeting task: with max 1 every reply that says "tudo bem?" fails and every
116
+ # reply that does not passes, which grades a courtesy formula rather than the
117
+ # rule. A store whose customers are greeted sets 2 and keeps the check for
118
+ # what it is for — the form ("qual seu nome, CPF e número do pedido?").
82
119
  "ask_once" => "at most one question per reply",
83
120
  # Establish the objective before acting on a vague opener.
84
121
  "investigate_first" => "asks before calling a tool, on the first turn",
@@ -135,7 +172,16 @@ module Insika
135
172
  # all of them — "one question per reply" is a rule about every reply, and the
136
173
  # violation that motivated this was on the FIRST turn. Defaults to the single
137
174
  # result so existing callers keep working.
138
- def evaluate(golden, result, turns: nil)
175
+ # `store` is the post-turn snapshot ({ collection => [rows] }) when the case
176
+ # declares a `store_state:` and the runner had a reader; nil otherwise.
177
+ #
178
+ # `tools_reported` is false when the TRANSPORT cannot see tool calls at all —
179
+ # a benched harness that answers text and nothing else. Its tool-shaped graders
180
+ # are then skipped with the reason instead of passing (they would: an empty
181
+ # list satisfies `never_calls`) or failing (it would: nothing satisfies
182
+ # `tools_called`). Both readings would be a claim about a harness nobody
183
+ # measured.
184
+ def evaluate(golden, result, turns: nil, store: nil, store_error: nil, tools_reported: true)
139
185
  if result.error
140
186
  return CaseResult.new(id: golden.id, agent: golden.agent, error: result.error, rubric: nil, judge: nil,
141
187
  checks: [Check.new(name: "turn", pass: false, detail: "turn error: #{result.error}")])
@@ -144,12 +190,101 @@ module Insika
144
190
  # NOT `Array(turns)`: TurnResult is a Struct, so Array() would explode a single
145
191
  # one into its members and hand the policy checks three strings.
146
192
  conversation = turns.nil? || turns.empty? ? [result] : turns
147
- checks = tool_checks(golden, result) + must_not_checks(golden, result) +
148
- policy_checks(golden, conversation)
193
+ checks = tool_checks(golden, result) + turn_tool_checks(golden, conversation) + call_checks(golden, result) +
194
+ reply_checks(golden, result) + ui_checks(golden, result) +
195
+ must_not_checks(golden, result, conversation) + policy_checks(golden, conversation)
196
+ checks = skip_tool_shaped(checks) unless tools_reported
197
+ checks += store_state_checks(golden, store, error: store_error) if golden.store_state?
149
198
  CaseResult.new(id: golden.id, agent: golden.agent, error: nil, checks: checks,
150
199
  rubric: golden.rubric, judge: nil)
151
200
  end
152
201
 
202
+ # Graders that read the turn's tool calls. Named by prefix in ONE place: a
203
+ # grader added without a line here would be silently mis-skipped, and the
204
+ # policies are in the list because `investigate_first`/`act_fast` are questions
205
+ # about whether a tool was called, not about the text.
206
+ TOOL_SHAPED = /\A(?:tool:|never_calls:|calls_one_of\z|first_tool:|max_tool_calls:|blocked_gates:|
207
+ must_not:(?:tool_error|phantom_action)\z|policy:(?:investigate_first|act_fast)\z)/x
208
+
209
+ def skip_tool_shaped(checks)
210
+ checks.map do |c|
211
+ next c unless c.name.match?(TOOL_SHAPED)
212
+
213
+ Check.new(name: c.name, pass: true, skipped: true,
214
+ detail: "skipped: this harness does not report tool calls")
215
+ end
216
+ end
217
+
218
+ # WHAT THE STORE LOOKS LIKE AFTER THE TURN. A confident answer with no order
219
+ # is a fail, and no reply grader can say so. `snapshot` is
220
+ # { collection => [rows] } as the reader returned it; nil means the reader
221
+ # gave nothing back, which fails rather than passes — an unread store must
222
+ # never read as a satisfied one.
223
+ def store_state_checks(golden, snapshot, error: nil)
224
+ if snapshot.nil?
225
+ return [Check.new(name: "store_state", pass: false,
226
+ detail: "no store snapshot was read#{" — #{error}" if error}")]
227
+ end
228
+
229
+ rows = Insika::Coercion.deep_stringify(snapshot)
230
+ spec = golden.store_state
231
+ count_checks(spec["count"], rows) + record_checks(spec["records"], rows) +
232
+ absent_checks(spec["absent"], rows)
233
+ end
234
+
235
+ def count_checks(spec, rows)
236
+ Array(spec).map do |collection, expected|
237
+ actual = Array(rows[collection.to_s]).size
238
+ Check.new(name: "store_state:count:#{collection}", pass: actual == expected,
239
+ detail: "expected #{expected}, saw #{actual}")
240
+ end
241
+ end
242
+
243
+ def record_checks(spec, rows)
244
+ Array(spec).flat_map do |collection, expected_rows|
245
+ present = Array(rows[collection.to_s])
246
+ expected_rows.each_with_index.map do |expected, i|
247
+ hit = present.any? { |row| record_match?(expected, row) }
248
+ Check.new(name: "store_state:#{collection}[#{i}]", pass: hit,
249
+ detail: hit ? "matched" : "no row of #{present.size} in #{collection} matches #{expected.inspect}")
250
+ end
251
+ end
252
+ end
253
+
254
+ def absent_checks(spec, rows)
255
+ Array(spec).flat_map do |collection, expected_rows|
256
+ present = Array(rows[collection.to_s])
257
+ expected_rows.each_with_index.map do |expected, i|
258
+ hits = present.count { |row| record_match?(expected, row) }
259
+ Check.new(name: "store_state:absent:#{collection}[#{i}]", pass: hits.zero?,
260
+ detail: hits.zero? ? "absent" : "#{hits} row(s) in #{collection} match #{expected.inspect}")
261
+ end
262
+ end
263
+ end
264
+
265
+ # A row matches when every field the case NAMES matches — a store row carries
266
+ # ids, timestamps and totals nobody wrote the case about, and demanding the
267
+ # whole row would make every task un-authorable.
268
+ def record_match?(expected, row)
269
+ row.is_a?(Hash) && expected.all? { |k, v| value_match?(v, row[k.to_s]) }
270
+ end
271
+
272
+ # Numbers compare as numbers (189.9 == 189.90, and a store that answers a
273
+ # string "189.90" is still the same money); a list means "each of these is in
274
+ # there", not "these are all of them" — `count:` is where exactness is stated.
275
+ def value_match?(expected, actual)
276
+ case expected
277
+ when Hash then record_match?(expected, actual)
278
+ when Array then actual.is_a?(Array) && expected.all? { |e| actual.any? { |a| value_match?(e, a) } }
279
+ when Numeric then numeric(actual) && (expected.to_f - actual.to_f).abs < 1e-6
280
+ else expected.to_s == actual.to_s
281
+ end
282
+ end
283
+
284
+ def numeric(value)
285
+ value.is_a?(Numeric) || (value.is_a?(String) && !Float(value, exception: false).nil?)
286
+ end
287
+
153
288
  # Each REQUIRED expected tool must appear in the turn's tool calls. Optional
154
289
  # ("name?") tools are informational — present or not, they never fail.
155
290
  def tool_checks(golden, result)
@@ -163,14 +298,119 @@ module Insika
163
298
  end
164
299
  end
165
300
 
166
- # `must_not` detectors. "tool_error" is special (inspects statuses); the rest
167
- # are content detectors over the output text.
168
- def must_not_checks(golden, result)
169
- golden.must_not.map do |name|
301
+ # The graders over the turn's CALLS. Each is its own Check, so the report names
302
+ # the one that failed instead of "tool checks failed". All read the LAST turn,
303
+ # like `tools_called`.
304
+ # The calls a specific turn had to make (`turns[i].tools_called`). The case-level
305
+ # grader reads the last turn; a two-turn case where turn one claimed an action
306
+ # and turn two repaired the store passes it, and this is what catches turn one.
307
+ def turn_tool_checks(golden, turns)
308
+ turns.each_with_index.flat_map do |turn, i|
309
+ names = turn.tool_names
310
+ golden.turn_tools_called(i).reject { |t| t[:optional] }.map do |t|
311
+ present = names.include?(t[:name])
312
+ Check.new(name: "tool:#{t[:name]}@turn#{i + 1}", pass: present,
313
+ detail: present ? "called on turn #{i + 1}" : "turn #{i + 1} called #{names.empty? ? 'nothing' : names.join(', ')}")
314
+ end
315
+ end
316
+ end
317
+
318
+ def call_checks(golden, result)
319
+ names = result.tool_names
320
+ checks = golden.never_calls.map do |name|
321
+ hit = names.include?(name)
322
+ Check.new(name: "never_calls:#{name}", pass: !hit, detail: hit ? "called #{name}" : "not called")
323
+ end
324
+
325
+ unless golden.calls_one_of.empty?
326
+ hit = golden.calls_one_of & names
327
+ checks << Check.new(name: "calls_one_of", pass: !hit.empty?,
328
+ detail: hit.empty? ? "none of #{golden.calls_one_of.join(', ')} called (saw: #{names.join(', ')})"
329
+ : "called #{hit.join(', ')}")
330
+ end
331
+
332
+ if (first = golden.first_tool)
333
+ checks << Check.new(name: "first_tool:#{first}", pass: names.first == first,
334
+ detail: "first call was #{names.first || 'none'}")
335
+ end
336
+
337
+ if (max = golden.max_tool_calls)
338
+ checks << Check.new(name: "max_tool_calls:#{max}", pass: names.size <= max,
339
+ detail: "#{names.size} call(s): #{names.join(', ')}")
340
+ end
341
+
342
+ blocked = result.blocked_tools.map { |t| "#{t['name'] || t[:name]}:#{t['gate'] || t[:gate]}" }
343
+ checks + golden.blocked_gates.map do |pair|
344
+ held = blocked.include?(pair)
345
+ Check.new(name: "blocked_gates:#{pair}", pass: held,
346
+ detail: held ? "held" : "not held (blocked: #{blocked.empty? ? 'none' : blocked.join(', ')})")
347
+ end
348
+ end
349
+
350
+ # Case-insensitive substrings over the PUBLISHED answer. `reply_omits` is where
351
+ # an internal id, a CPF or a raw tag leaking into the customer's text is pinned —
352
+ # the negative half of `reply_includes`.
353
+ def reply_checks(golden, result)
354
+ text = result.output_text.to_s.downcase
355
+ golden.reply_includes.map do |s|
356
+ hit = text.include?(s.downcase)
357
+ Check.new(name: "reply_includes:#{s}", pass: hit, detail: hit ? "present" : "missing from the reply")
358
+ end + golden.reply_omits.map do |s|
359
+ hit = text.include?(s.downcase)
360
+ Check.new(name: "reply_omits:#{s}", pass: !hit, detail: hit ? "present in the reply" : "absent")
361
+ end
362
+ end
363
+
364
+ # The graders over what the turn SHOWED. `ui_components` pins that a presentation
365
+ # tool put at least one card of that component in front of the customer;
366
+ # `no_ui` is its negative — a turn that must answer in text alone.
367
+ def ui_checks(golden, result)
368
+ shown = result.shown_components
369
+ checks = golden.ui_components.map do |component|
370
+ hit = shown.include?(component)
371
+ Check.new(name: "ui_components:#{component}", pass: hit,
372
+ detail: hit ? "shown" : "not shown (ui: #{shown.empty? ? 'none' : shown.join(', ')})")
373
+ end
374
+ return checks unless golden.no_ui?
375
+
376
+ checks << Check.new(name: "no_ui", pass: shown.empty?,
377
+ detail: shown.empty? ? "nothing shown" : "shown: #{shown.join(', ')}")
378
+ end
379
+
380
+ # A reply that says it did something the store never saw happen. The case's
381
+ # `claims:` names each mutation and the words a reply uses to report it — the
382
+ # store's language, so it lives in the case, not here. Read after the turn,
383
+ # cumulative over the conversation ("já adicionei" on turn two is true when
384
+ # turn one did it) — and "ran" means a call that ENDED ok: a claim over a
385
+ # failed or blocked call is the same lie. Store-level arithmetic and duplicates
386
+ # are `store_state:`'s business; this is only whether the words match the calls.
387
+ def phantom_action_checks(golden, turns)
388
+ claims = golden.claims.transform_values { |pattern| Regexp.new(pattern, Regexp::IGNORECASE) }
389
+ ran = []
390
+ offences = turns.each_with_index.filter_map do |turn, i|
391
+ ran += Array(turn.tool_calls).select { |t| Assertions.ok_status?(t["status"] || t[:status]) }
392
+ .map { |t| (t["name"] || t[:name]).to_s }
393
+ claimed = claims.find { |tool, verbs| turn.output_text.to_s.match?(verbs) && !ran.include?(tool) }
394
+ next unless claimed
395
+
396
+ tool = claimed.first
397
+ how = turn.tool_names.include?(tool) ? "#{tool} did not succeed" : "#{tool} never ran"
398
+ "turn #{i + 1} claims an action but #{how}: #{turn.output_text.to_s.strip[0, 120].inspect}"
399
+ end
400
+ [Check.new(name: "must_not:phantom_action", pass: offences.empty?,
401
+ detail: offences.empty? ? "every claimed action ran" : offences.join(" | "))]
402
+ end
403
+
404
+ # `must_not` detectors. "tool_error" and "phantom_action" are special (they read
405
+ # the calls); the rest are content detectors over the output text.
406
+ def must_not_checks(golden, result, turns = [result])
407
+ golden.must_not.flat_map do |name|
170
408
  if name == "tool_error"
171
409
  bad = result.errored_tools
172
410
  Check.new(name: "must_not:tool_error", pass: bad.empty?,
173
411
  detail: bad.empty? ? "no tool errors" : "errored: #{bad.map { |t| t['name'] || t[:name] }.join(', ')}")
412
+ elsif name == "phantom_action"
413
+ phantom_action_checks(golden, turns)
174
414
  else
175
415
  hit = detect(name, result.output_text.to_s)
176
416
  Check.new(name: "must_not:#{name}", pass: hit.nil?,
@@ -188,7 +428,7 @@ module Insika
188
428
  # Exhaustive on purpose: a policy added to POLICIES without a rule here would
189
429
  # otherwise fall into whichever branch was last and check the wrong thing.
190
430
  pass, detail = case name
191
- when "ask_once" then ask_once(turns)
431
+ when "ask_once" then ask_once(turns, max: golden.policy_options.fetch("max", 1).to_i)
192
432
  when "investigate_first" then investigate_first(turns.first)
193
433
  when "act_fast" then act_fast(turns.first)
194
434
  else raise ArgumentError, "policy #{name.inspect} has no rule"
@@ -196,14 +436,15 @@ module Insika
196
436
  [Check.new(name: "policy:#{name}", pass: pass, detail: detail)]
197
437
  end
198
438
 
199
- # Every reply asks at most one question. Reported with the offending turn and
439
+ # Every reply asks at most `max` questions. Reported with the offending turn and
200
440
  # the reply itself — "2 questions" alone sends the reader digging.
201
- def ask_once(turns)
202
- offender = turns.each_with_index.find { |t, _| count_questions(t.output_text) > 1 }
203
- return [true, "at most one question per reply"] unless offender
441
+ def ask_once(turns, max: 1)
442
+ max = 1 if max < 1
443
+ offender = turns.each_with_index.find { |t, _| count_questions(t.output_text) > max }
444
+ return [true, "at most #{max} question(s) per reply"] unless offender
204
445
 
205
446
  turn, i = offender
206
- [false, "turn #{i + 1} asked #{count_questions(turn.output_text)} questions: " \
447
+ [false, "turn #{i + 1} asked #{count_questions(turn.output_text)} questions (max #{max}): " \
207
448
  "#{turn.output_text.to_s.strip[0, 160].inspect}"]
208
449
  end
209
450
 
@@ -20,8 +20,16 @@ module Insika
20
20
  # single-tenant default, like `save_artifact`'s own binding_tenant) unless the
21
21
  # case declares one. `run_persona_eval` uses it to keep a QA agent from ever
22
22
  # running (or even seeing) another tenant's persona case in the same store.
23
+ #
24
+ # `state`: the snapshot the conversation starts from — evidence ids, memory
25
+ # facts and notes, prior history, briefing fields — loaded into the session
26
+ # BEFORE turn 1. It is how a case tests "the customer already saw three products
27
+ # and says 'add the second one'" without replaying the search turn: no dependence
28
+ # on the model's first answer, one turn cheaper, and a messy state (a
29
+ # contradiction from six turns ago) becomes reproducible. {} = the case starts
30
+ # empty, as every case did before the key existed.
23
31
  Golden = Struct.new(:id, :agent, :turns, :expect, :requires, :reference, :source, :persona, :tenant,
24
- keyword_init: true) do
32
+ :state, :store_state, keyword_init: true) do
25
33
  # The user messages to replay, in order. Empty for a persona case: a generated
26
34
  # conversation has no scripted turns.
27
35
  def user_turns = turns.map { |t| t["user"] }
@@ -41,12 +49,77 @@ module Insika
41
49
  end
42
50
  end
43
51
 
52
+ # The tools turn `i` (0-based) must call, same `name?` optional marker as above.
53
+ # [] for a turn that pins nothing.
54
+ def turn_tools_called(i)
55
+ Array(turns.dig(i, "tools_called")).map do |ref|
56
+ s = ref.to_s
57
+ optional = s.end_with?("?")
58
+ { name: optional ? s[0..-2] : s, optional: optional }
59
+ end
60
+ end
61
+
44
62
  # Names of the negative assertions to run (e.g. "pii_leak", "tool_error").
45
63
  def must_not = Array(expect["must_not"]).map(&:to_s)
46
64
 
65
+ # `claims:` — tool name => the words a reply uses to say it did that (a regex
66
+ # source, matched case-insensitively). What `must_not: phantom_action` reads.
67
+ def claims = (expect["claims"] || {}).transform_keys(&:to_s)
68
+
69
+ # The graders over the turn's CALLS and its published REPLY — each optional,
70
+ # each deterministic. Every positive has a negative: `never_calls` pins what a
71
+ # correct turn does NOT do, which `tools_called` alone can never say.
72
+ def never_calls = Array(expect["never_calls"]).map(&:to_s)
73
+ def calls_one_of = Array(expect["calls_one_of"]).map(&:to_s)
74
+ def first_tool = GoldenLoader.presence(expect["first_tool"])
75
+ def max_tool_calls = expect["max_tool_calls"]
76
+ def reply_includes = Array(expect["reply_includes"]).map(&:to_s)
77
+ def reply_omits = Array(expect["reply_omits"]).map(&:to_s)
78
+ # "tool:gate" pairs that must appear among the turn's BLOCKED calls.
79
+ def blocked_gates = Array(expect["blocked_gates"]).map(&:to_s)
80
+ # UI components a presentation tool must have shown (`insika.ui` frames with
81
+ # at least one card), and its negative: `no_ui: true` pins a turn that shows
82
+ # nothing.
83
+ def ui_components = Array(expect["ui_components"]).map(&:to_s)
84
+ def no_ui? = expect["no_ui"] == true
85
+
86
+ def state = self[:state] || {}
87
+ def seeded? = !state.empty?
88
+
89
+ # What the STORE must look like after the turn — the half of a commerce case
90
+ # no reply can prove. `records` are rows that must be there, `count` the exact
91
+ # size of a collection (a duplicated order fails it), `absent` rows that must
92
+ # not exist. Read after the last turn by a store reader the runner is given;
93
+ # {} = the case says nothing about the store, as every case did before.
94
+ def store_state = self[:store_state] || {}
95
+ def store_state? = !store_state.empty?
96
+
97
+ # The collections this case talks about — what the reader is asked for, and
98
+ # nothing else: a bench task about orders should not pull the catalogue.
99
+ def store_collections
100
+ (Array(store_state["records"]&.keys) + Array(store_state["count"]&.keys) +
101
+ Array(store_state["absent"]&.keys)).map(&:to_s).uniq
102
+ end
103
+
47
104
  # How much the agent should ask before acting. nil = the store
48
105
  # has no opinion and only the rubric decides.
49
- def policy = GoldenLoader.presence(expect["policy"])
106
+ def policy = policy_pair.first
107
+
108
+ # What the store set the policy TO. The engine ships the mechanism (count the
109
+ # questions); the number is the store's, because "one question per reply" is a
110
+ # tolerance, not a law — a greeting that says "tudo bem?" spends one on
111
+ # courtesy in half the languages a store sells in.
112
+ def policy_options = policy_pair.last
113
+
114
+ # `policy: ask_once` and `policy: { ask_once: { max: 2 } }` are the same
115
+ # declaration with and without a number. -> [name, options]
116
+ def policy_pair
117
+ value = expect["policy"]
118
+ return [GoldenLoader.presence(value), {}] unless value.is_a?(Hash)
119
+
120
+ name, options = value.first
121
+ [GoldenLoader.presence(name), (options || {}).transform_keys(&:to_s)]
122
+ end
50
123
 
51
124
  # What the DEPLOYMENT must have for this case to mean anything.
52
125
  # Empty = runs everywhere.
@@ -111,6 +184,9 @@ module Insika
111
184
  raise InvalidGolden, "#{source}: 'expect' must be a mapping (case '#{id}')" unless expect.is_a?(Hash)
112
185
 
113
186
  validate_policy!(expect["policy"], id: id, source: source)
187
+ validate_graders!(expect, id: id, source: source)
188
+ state = normalize_state(raw["state"], id: id, source: source)
189
+ store_state = normalize_store_state(raw["store_state"], id: id, source: source)
114
190
  requires = raw["requires"] || {}
115
191
  unless requires.is_a?(Hash)
116
192
  raise InvalidGolden, "#{source}: 'requires' must be a mapping (case '#{id}')"
@@ -121,7 +197,7 @@ module Insika
121
197
 
122
198
  Golden.new(id: id, agent: agent, turns: turns, expect: expect,
123
199
  requires: requires, reference: reference, source: source, persona: persona,
124
- tenant: tenant)
200
+ tenant: tenant, state: state, store_state: store_state)
125
201
  end
126
202
 
127
203
  # `persona:` is the alternative shape to `turns:`: the
@@ -174,11 +250,151 @@ module Insika
174
250
  { "role" => role, "text" => text }.merge(origin ? { "origin" => origin } : {})
175
251
  end
176
252
 
253
+ STATE_KEYS = %w[evidence memory history briefing].freeze
254
+
255
+ # state: the snapshot a case starts from. Absent -> {}. Only the four known
256
+ # keys, each in its own shape — a typo'd key (`evidences:`) would seed nothing
257
+ # and the case would go on passing against the wrong precondition. Refused at
258
+ # LOAD, not at seed time: a case that half-seeds is a hole in the net.
259
+ def normalize_state(raw, id:, source:)
260
+ return {} if raw.nil?
261
+
262
+ where = "#{source}: state (case '#{id}')"
263
+ raise InvalidGolden, "#{where} must be a mapping" unless raw.is_a?(Hash)
264
+
265
+ unknown = raw.keys.map(&:to_s) - STATE_KEYS
266
+ unless unknown.empty?
267
+ raise InvalidGolden, "#{where}: unknown key(s) #{unknown.join(', ')} — known: #{STATE_KEYS.join(', ')}"
268
+ end
269
+
270
+ state = raw.compact
271
+ mapping_with!(state["evidence"], "ids", Array, "#{where}.evidence")
272
+ mapping_with!(state["memory"], "facts", Hash, "#{where}.memory")
273
+ mapping_with!(state["memory"], "notes", Array, "#{where}.memory")
274
+ mapping_with!(state["briefing"], "fields", Hash, "#{where}.briefing")
275
+ history = state["history"]
276
+ unless history.nil? || (history.is_a?(Array) && history.all? { |m| history_message?(m) })
277
+ raise InvalidGolden, "#{where}.history must be [{ role: user|assistant, content: '…' }]"
278
+ end
279
+
280
+ state
281
+ end
282
+
283
+ STORE_STATE_KEYS = %w[records count absent].freeze
284
+
285
+ # store_state: what the store must look like AFTER the turn, graded by code
286
+ # against a snapshot the runner reads. Three keys, each a mapping keyed by
287
+ # collection: `records` (rows that must exist), `count` (the exact size of a
288
+ # collection) and `absent` (rows that must not exist). A closed vocabulary on
289
+ # purpose — a case that wrote `orders:` at the top level would grade nothing
290
+ # and pass, which is the failure this key exists to catch.
291
+ def normalize_store_state(raw, id:, source:)
292
+ return {} if raw.nil?
293
+
294
+ where = "#{source}: store_state (case '#{id}')"
295
+ raise InvalidGolden, "#{where} must be a mapping" unless raw.is_a?(Hash)
296
+
297
+ unknown = raw.keys.map(&:to_s) - STORE_STATE_KEYS
298
+ unless unknown.empty?
299
+ raise InvalidGolden, "#{where}: unknown key(s) #{unknown.join(', ')} — " \
300
+ "known: #{STORE_STATE_KEYS.join(', ')}"
301
+ end
302
+
303
+ state = raw.compact
304
+ rows_by_collection!(state["records"], "#{where}.records")
305
+ rows_by_collection!(state["absent"], "#{where}.absent")
306
+ counts!(state["count"], "#{where}.count")
307
+ state
308
+ end
309
+
310
+ def rows_by_collection!(value, where)
311
+ return if value.nil?
312
+ raise InvalidGolden, "#{where} must be a mapping of collection -> rows" unless value.is_a?(Hash)
313
+
314
+ value.each do |collection, rows|
315
+ next if rows.is_a?(Array) && !rows.empty? && rows.all?(Hash)
316
+
317
+ raise InvalidGolden, "#{where}.#{collection} must be a non-empty list of mappings"
318
+ end
319
+ end
320
+
321
+ def counts!(value, where)
322
+ return if value.nil?
323
+ raise InvalidGolden, "#{where} must be a mapping of collection -> integer" unless value.is_a?(Hash)
324
+
325
+ value.each do |collection, n|
326
+ next if n.is_a?(Integer) && n >= 0
327
+
328
+ raise InvalidGolden, "#{where}.#{collection} must be a non-negative integer (got #{n.inspect})"
329
+ end
330
+ end
331
+
332
+ def mapping_with!(value, key, type, where)
333
+ return if value.nil?
334
+ raise InvalidGolden, "#{where} must be a mapping" unless value.is_a?(Hash)
335
+ return if value[key].nil? || value[key].is_a?(type)
336
+
337
+ raise InvalidGolden, "#{where}.#{key} must be #{type == Array ? 'a list' : 'a mapping'}"
338
+ end
339
+
340
+ def history_message?(message)
341
+ message.is_a?(Hash) && %w[user assistant].include?(message["role"].to_s) &&
342
+ !presence(message["content"]).nil?
343
+ end
344
+
345
+ # The graders with a shape to get wrong: a non-integer `max_tool_calls` would
346
+ # compare against nil and pass; a `blocked_gates` entry without its gate would
347
+ # never match anything and pass. Refused at load, like `policy`.
348
+ def validate_graders!(expect, id:, source:)
349
+ max = expect["max_tool_calls"]
350
+ unless max.nil? || (max.is_a?(Integer) && max >= 0)
351
+ raise InvalidGolden, "#{source}: max_tool_calls must be a non-negative integer (case '#{id}')"
352
+ end
353
+
354
+ Array(expect["blocked_gates"]).each do |pair|
355
+ next if pair.to_s.match?(/\A[^:\s]+:[^:\s]+\z/)
356
+
357
+ raise InvalidGolden, "#{source}: blocked_gates entries are 'tool:gate' (got #{pair.inspect}, case '#{id}')"
358
+ end
359
+
360
+ validate_claims!(expect, id: id, source: source)
361
+ end
362
+
363
+ # A phantom-action check with no words to look for would pass everything, and a
364
+ # pattern that does not compile would fail every cell at grading time instead of
365
+ # the one author at load time.
366
+ def validate_claims!(expect, id:, source:)
367
+ claims = expect["claims"]
368
+ phantom = Array(expect["must_not"]).map(&:to_s).include?("phantom_action")
369
+ if phantom && !(claims.is_a?(Hash) && !claims.empty?)
370
+ raise InvalidGolden, "#{source}: must_not: phantom_action needs a non-empty 'claims:' map (case '#{id}')"
371
+ end
372
+ return if claims.nil?
373
+
374
+ raise InvalidGolden, "#{source}: 'claims' must be a map of tool => pattern (case '#{id}')" unless claims.is_a?(Hash)
375
+
376
+ claims.each do |tool, pattern|
377
+ Regexp.new(pattern.to_s)
378
+ rescue RegexpError => e
379
+ raise InvalidGolden, "#{source}: claims.#{tool} is not a valid pattern — #{e.message} (case '#{id}')"
380
+ end
381
+ end
382
+
177
383
  # A typo'd policy must not silently mean "no policy" — the case would go on
178
384
  # passing while the rule it was written for stopped being checked. The
179
385
  # `Assertions` constant is resolved at CALL time (this file loads first, and
180
386
  # assertions.rb touches `Safety::Detectors` at load time).
181
387
  def validate_policy!(value, id:, source:)
388
+ if value.is_a?(Hash)
389
+ raise InvalidGolden, "#{source}: policy takes ONE name (case '#{id}')" unless value.size == 1
390
+
391
+ name, options = value.first
392
+ unless options.nil? || options.is_a?(Hash)
393
+ raise InvalidGolden, "#{source}: policy #{name.inspect} options must be a map (case '#{id}')"
394
+ end
395
+
396
+ value = name
397
+ end
182
398
  name = presence(value)
183
399
  return if name.nil? || Assertions::POLICIES.key?(name)
184
400
 
@@ -188,6 +404,11 @@ module Insika
188
404
 
189
405
  # turns: a non-empty array of { "user" => String }. Rejects anything else so a
190
406
  # typo (e.g. `users:`) surfaces at load time, not as an empty replay.
407
+ #
408
+ # A turn may also carry `tools_called: [names]` — the calls THAT turn must make.
409
+ # The case-level `tools_called` reads the last turn only, and the bench found a
410
+ # reply that said "adicionado ao carrinho" on turn one without ever calling
411
+ # add_to_cart, then recovered on turn two so the final store looked right.
191
412
  def normalize_turns(turns, id:, source:)
192
413
  unless turns.is_a?(Array) && !turns.empty?
193
414
  raise InvalidGolden, "#{source}: 'turns' must be a non-empty array (case '#{id}')"
@@ -196,7 +417,12 @@ module Insika
196
417
  turns.each_with_index.map do |t, i|
197
418
  user = t.is_a?(Hash) ? presence(t["user"]) : nil
198
419
  user || (raise InvalidGolden, "#{source}: turns[#{i}] needs a non-empty 'user' (case '#{id}')")
199
- { "user" => user }
420
+ tools = t["tools_called"]
421
+ unless tools.nil? || (tools.is_a?(Array) && tools.all? { |n| presence(n) })
422
+ raise InvalidGolden, "#{source}: turns[#{i}].tools_called must be a list of tool names (case '#{id}')"
423
+ end
424
+
425
+ tools.nil? ? { "user" => user } : { "user" => user, "tools_called" => tools.map(&:to_s) }
200
426
  end
201
427
  end
202
428