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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +312 -2
- data/README.md +10 -6
- data/bin/insika +4 -1
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +142 -18
- data/docs/API.md +73 -0
- data/docs/ARCHITECTURE.md +56 -50
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CHANNELS.md +19 -2
- data/docs/CONTEXT.md +96 -27
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +70 -13
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +129 -10
- data/docs/FACTS.md +12 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +14 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +164 -28
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +26 -14
- data/docs/RELEASING.md +9 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +34 -1
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +238 -42
- data/docs/operate.md +3 -0
- data/docs/prompts/ADD-TOOL.md +12 -2
- data/docs/prompts/DIAGNOSE-TURN.md +3 -0
- data/docs/prompts/GO-LIVE.md +3 -1
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +99 -17
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/channels/web/widget.js +33 -0
- data/lib/insika/channels/web.rb +5 -2
- data/lib/insika/chat_builder.rb +124 -28
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_distillation.rb +5 -8
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +128 -0
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/builder.rb +29 -9
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +7 -2
- data/lib/insika/context/providers/briefing.rb +11 -8
- data/lib/insika/context/providers/fence_notice.rb +27 -0
- data/lib/insika/context/providers/knowledge.rb +32 -6
- data/lib/insika/context/providers/memory.rb +61 -8
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +46 -6
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +142 -4
- data/lib/insika/dsl/runtime.rb +37 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +19 -5
- data/lib/insika/edge_limiter.rb +27 -16
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/errors.rb +1 -0
- data/lib/insika/evals/assertions.rb +259 -18
- data/lib/insika/evals/golden.rb +230 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +64 -4
- data/lib/insika/evals/simulator.rb +21 -3
- data/lib/insika/evals/transport.rb +317 -15
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +105 -17
- data/lib/insika/executor.rb +698 -164
- data/lib/insika/fence.rb +96 -0
- data/lib/insika/golden_store.rb +3 -0
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +37 -3
- data/lib/insika/mcp_tool_registry.rb +76 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +16 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +30 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/prefix_fingerprint.rb +32 -27
- data/lib/insika/profile_source.rb +5 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +58 -7
- data/lib/insika/server/rack_app.rb +2 -0
- data/lib/insika/server/responses.rb +50 -5
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +163 -11
- data/lib/insika/settings_store.rb +57 -2
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/spoken_transcript.rb +31 -0
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1459 -163
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +189 -12
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +153 -118
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +115 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_assembly.rb +21 -13
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +82 -13
- data/lib/insika/tool_envelope.rb +266 -29
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +12 -4
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +14 -4
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +24 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +115 -0
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +9 -6
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +11 -7
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +31 -1
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +88 -16
- data/lib/insika/wiring/graph_chat.rb +4 -0
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +23 -3
- metadata +74 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- 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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) +
|
|
148
|
-
|
|
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
|
-
#
|
|
167
|
-
#
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
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
|
|
data/lib/insika/evals/golden.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|