insika 0.9.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 +237 -2
- data/README.md +5 -3
- data/bin/insika +3 -0
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +90 -7
- data/docs/ARCHITECTURE.md +12 -7
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CONTEXT.md +63 -0
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +57 -11
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +33 -4
- data/docs/FACTS.md +8 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +143 -22
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +22 -13
- data/docs/RELEASING.md +5 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +7 -0
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +111 -9
- data/docs/operate.md +3 -0
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +78 -8
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/chat_builder.rb +79 -25
- 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_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +11 -1
- 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/priority.rb +3 -0
- data/lib/insika/context/provider.rb +2 -2
- data/lib/insika/context/providers/knowledge.rb +25 -2
- data/lib/insika/context/providers/memory.rb +53 -4
- 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 +39 -3
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +34 -4
- data/lib/insika/dsl/runtime.rb +36 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +13 -5
- data/lib/insika/edge_limiter.rb +23 -15
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/evals/assertions.rb +171 -16
- data/lib/insika/evals/golden.rb +141 -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 +44 -4
- data/lib/insika/evals/simulator.rb +10 -1
- data/lib/insika/evals/transport.rb +200 -0
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +29 -8
- data/lib/insika/executor.rb +668 -141
- 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 +32 -1
- data/lib/insika/mcp_tool_registry.rb +69 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +4 -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 +25 -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/profile_source.rb +4 -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 +17 -8
- data/lib/insika/server/responses.rb +19 -1
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +159 -10
- data/lib/insika/settings_store.rb +47 -1
- 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/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 +1453 -161
- 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 +171 -9
- 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 +78 -242
- 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} +32 -9
- 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 +152 -117
- 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 +103 -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_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +10 -4
- data/lib/insika/tool_envelope.rb +165 -28
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +3 -0
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +2 -2
- 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 +23 -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 +1 -8
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +3 -5
- 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 +7 -5
- 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 +18 -0
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +81 -16
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +19 -3
- metadata +69 -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
|
@@ -32,11 +32,12 @@ module Insika
|
|
|
32
32
|
|
|
33
33
|
# A tool call whose status is anything but a success ("ok"/2xx/"success"). A
|
|
34
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.
|
|
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.
|
|
36
37
|
def errored_tools
|
|
37
38
|
Array(tool_calls).reject do |t|
|
|
38
39
|
status = t["status"] || t[:status]
|
|
39
|
-
Assertions.ok_status?(status) || status.to_s
|
|
40
|
+
Assertions.ok_status?(status) || %w[blocked held].include?(status.to_s)
|
|
40
41
|
end
|
|
41
42
|
end
|
|
42
43
|
|
|
@@ -46,7 +47,16 @@ module Insika
|
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
# A single check within a case (e.g. "tool:shipping_quote", "must_not:pii_leak").
|
|
49
|
-
|
|
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
|
|
50
60
|
|
|
51
61
|
# The verdict for one golden case. `judge` (a Judge::Verdict) is attached AFTER
|
|
52
62
|
# the deterministic pass when the case has a rubric and a judge is configured;
|
|
@@ -69,6 +79,7 @@ module Insika
|
|
|
69
79
|
def skipped? = !skipped.nil?
|
|
70
80
|
def pass? = !skipped? && error.nil? && checks.all?(&:pass) && (judge.nil? || judge.pass)
|
|
71
81
|
def failures = checks.reject(&:pass)
|
|
82
|
+
def skipped_checks = checks.select(&:skipped?)
|
|
72
83
|
# Has a rubric to score but no verdict yet (judge disabled / not run). A skipped
|
|
73
84
|
# case is not pending anything — nobody is going to judge a turn that never ran.
|
|
74
85
|
def judge_pending? = !skipped? && !rubric.to_s.strip.empty? && judge.nil?
|
|
@@ -100,6 +111,11 @@ module Insika
|
|
|
100
111
|
# That is also the honest scope — a question nobody received is not a question.
|
|
101
112
|
POLICIES = {
|
|
102
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?").
|
|
103
119
|
"ask_once" => "at most one question per reply",
|
|
104
120
|
# Establish the objective before acting on a vague opener.
|
|
105
121
|
"investigate_first" => "asks before calling a tool, on the first turn",
|
|
@@ -156,7 +172,16 @@ module Insika
|
|
|
156
172
|
# all of them — "one question per reply" is a rule about every reply, and the
|
|
157
173
|
# violation that motivated this was on the FIRST turn. Defaults to the single
|
|
158
174
|
# result so existing callers keep working.
|
|
159
|
-
|
|
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)
|
|
160
185
|
if result.error
|
|
161
186
|
return CaseResult.new(id: golden.id, agent: golden.agent, error: result.error, rubric: nil, judge: nil,
|
|
162
187
|
checks: [Check.new(name: "turn", pass: false, detail: "turn error: #{result.error}")])
|
|
@@ -165,12 +190,101 @@ module Insika
|
|
|
165
190
|
# NOT `Array(turns)`: TurnResult is a Struct, so Array() would explode a single
|
|
166
191
|
# one into its members and hand the policy checks three strings.
|
|
167
192
|
conversation = turns.nil? || turns.empty? ? [result] : turns
|
|
168
|
-
checks = tool_checks(golden, result) +
|
|
169
|
-
|
|
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?
|
|
170
198
|
CaseResult.new(id: golden.id, agent: golden.agent, error: nil, checks: checks,
|
|
171
199
|
rubric: golden.rubric, judge: nil)
|
|
172
200
|
end
|
|
173
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
|
+
|
|
174
288
|
# Each REQUIRED expected tool must appear in the turn's tool calls. Optional
|
|
175
289
|
# ("name?") tools are informational — present or not, they never fail.
|
|
176
290
|
def tool_checks(golden, result)
|
|
@@ -187,6 +301,20 @@ module Insika
|
|
|
187
301
|
# The graders over the turn's CALLS. Each is its own Check, so the report names
|
|
188
302
|
# the one that failed instead of "tool checks failed". All read the LAST turn,
|
|
189
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
|
+
|
|
190
318
|
def call_checks(golden, result)
|
|
191
319
|
names = result.tool_names
|
|
192
320
|
checks = golden.never_calls.map do |name|
|
|
@@ -249,14 +377,40 @@ module Insika
|
|
|
249
377
|
detail: shown.empty? ? "nothing shown" : "shown: #{shown.join(', ')}")
|
|
250
378
|
end
|
|
251
379
|
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
|
|
255
|
-
|
|
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|
|
|
256
408
|
if name == "tool_error"
|
|
257
409
|
bad = result.errored_tools
|
|
258
410
|
Check.new(name: "must_not:tool_error", pass: bad.empty?,
|
|
259
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)
|
|
260
414
|
else
|
|
261
415
|
hit = detect(name, result.output_text.to_s)
|
|
262
416
|
Check.new(name: "must_not:#{name}", pass: hit.nil?,
|
|
@@ -274,7 +428,7 @@ module Insika
|
|
|
274
428
|
# Exhaustive on purpose: a policy added to POLICIES without a rule here would
|
|
275
429
|
# otherwise fall into whichever branch was last and check the wrong thing.
|
|
276
430
|
pass, detail = case name
|
|
277
|
-
when "ask_once" then ask_once(turns)
|
|
431
|
+
when "ask_once" then ask_once(turns, max: golden.policy_options.fetch("max", 1).to_i)
|
|
278
432
|
when "investigate_first" then investigate_first(turns.first)
|
|
279
433
|
when "act_fast" then act_fast(turns.first)
|
|
280
434
|
else raise ArgumentError, "policy #{name.inspect} has no rule"
|
|
@@ -282,14 +436,15 @@ module Insika
|
|
|
282
436
|
[Check.new(name: "policy:#{name}", pass: pass, detail: detail)]
|
|
283
437
|
end
|
|
284
438
|
|
|
285
|
-
# Every reply asks at most
|
|
439
|
+
# Every reply asks at most `max` questions. Reported with the offending turn and
|
|
286
440
|
# the reply itself — "2 questions" alone sends the reader digging.
|
|
287
|
-
def ask_once(turns)
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
290
445
|
|
|
291
446
|
turn, i = offender
|
|
292
|
-
[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}): " \
|
|
293
448
|
"#{turn.output_text.to_s.strip[0, 160].inspect}"]
|
|
294
449
|
end
|
|
295
450
|
|
data/lib/insika/evals/golden.rb
CHANGED
|
@@ -29,7 +29,7 @@ module Insika
|
|
|
29
29
|
# contradiction from six turns ago) becomes reproducible. {} = the case starts
|
|
30
30
|
# empty, as every case did before the key existed.
|
|
31
31
|
Golden = Struct.new(:id, :agent, :turns, :expect, :requires, :reference, :source, :persona, :tenant,
|
|
32
|
-
:state, keyword_init: true) do
|
|
32
|
+
:state, :store_state, keyword_init: true) do
|
|
33
33
|
# The user messages to replay, in order. Empty for a persona case: a generated
|
|
34
34
|
# conversation has no scripted turns.
|
|
35
35
|
def user_turns = turns.map { |t| t["user"] }
|
|
@@ -49,9 +49,23 @@ module Insika
|
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
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
|
+
|
|
52
62
|
# Names of the negative assertions to run (e.g. "pii_leak", "tool_error").
|
|
53
63
|
def must_not = Array(expect["must_not"]).map(&:to_s)
|
|
54
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
|
+
|
|
55
69
|
# The graders over the turn's CALLS and its published REPLY — each optional,
|
|
56
70
|
# each deterministic. Every positive has a negative: `never_calls` pins what a
|
|
57
71
|
# correct turn does NOT do, which `tools_called` alone can never say.
|
|
@@ -72,9 +86,40 @@ module Insika
|
|
|
72
86
|
def state = self[:state] || {}
|
|
73
87
|
def seeded? = !state.empty?
|
|
74
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
|
+
|
|
75
104
|
# How much the agent should ask before acting. nil = the store
|
|
76
105
|
# has no opinion and only the rubric decides.
|
|
77
|
-
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
|
|
78
123
|
|
|
79
124
|
# What the DEPLOYMENT must have for this case to mean anything.
|
|
80
125
|
# Empty = runs everywhere.
|
|
@@ -141,6 +186,7 @@ module Insika
|
|
|
141
186
|
validate_policy!(expect["policy"], id: id, source: source)
|
|
142
187
|
validate_graders!(expect, id: id, source: source)
|
|
143
188
|
state = normalize_state(raw["state"], id: id, source: source)
|
|
189
|
+
store_state = normalize_store_state(raw["store_state"], id: id, source: source)
|
|
144
190
|
requires = raw["requires"] || {}
|
|
145
191
|
unless requires.is_a?(Hash)
|
|
146
192
|
raise InvalidGolden, "#{source}: 'requires' must be a mapping (case '#{id}')"
|
|
@@ -151,7 +197,7 @@ module Insika
|
|
|
151
197
|
|
|
152
198
|
Golden.new(id: id, agent: agent, turns: turns, expect: expect,
|
|
153
199
|
requires: requires, reference: reference, source: source, persona: persona,
|
|
154
|
-
tenant: tenant, state: state)
|
|
200
|
+
tenant: tenant, state: state, store_state: store_state)
|
|
155
201
|
end
|
|
156
202
|
|
|
157
203
|
# `persona:` is the alternative shape to `turns:`: the
|
|
@@ -234,6 +280,55 @@ module Insika
|
|
|
234
280
|
state
|
|
235
281
|
end
|
|
236
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
|
+
|
|
237
332
|
def mapping_with!(value, key, type, where)
|
|
238
333
|
return if value.nil?
|
|
239
334
|
raise InvalidGolden, "#{where} must be a mapping" unless value.is_a?(Hash)
|
|
@@ -261,6 +356,28 @@ module Insika
|
|
|
261
356
|
|
|
262
357
|
raise InvalidGolden, "#{source}: blocked_gates entries are 'tool:gate' (got #{pair.inspect}, case '#{id}')"
|
|
263
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
|
|
264
381
|
end
|
|
265
382
|
|
|
266
383
|
# A typo'd policy must not silently mean "no policy" — the case would go on
|
|
@@ -268,6 +385,16 @@ module Insika
|
|
|
268
385
|
# `Assertions` constant is resolved at CALL time (this file loads first, and
|
|
269
386
|
# assertions.rb touches `Safety::Detectors` at load time).
|
|
270
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
|
|
271
398
|
name = presence(value)
|
|
272
399
|
return if name.nil? || Assertions::POLICIES.key?(name)
|
|
273
400
|
|
|
@@ -277,6 +404,11 @@ module Insika
|
|
|
277
404
|
|
|
278
405
|
# turns: a non-empty array of { "user" => String }. Rejects anything else so a
|
|
279
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.
|
|
280
412
|
def normalize_turns(turns, id:, source:)
|
|
281
413
|
unless turns.is_a?(Array) && !turns.empty?
|
|
282
414
|
raise InvalidGolden, "#{source}: 'turns' must be a non-empty array (case '#{id}')"
|
|
@@ -285,7 +417,12 @@ module Insika
|
|
|
285
417
|
turns.each_with_index.map do |t, i|
|
|
286
418
|
user = t.is_a?(Hash) ? presence(t["user"]) : nil
|
|
287
419
|
user || (raise InvalidGolden, "#{source}: turns[#{i}] needs a non-empty 'user' (case '#{id}')")
|
|
288
|
-
|
|
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) }
|
|
289
426
|
end
|
|
290
427
|
end
|
|
291
428
|
|
data/lib/insika/evals/judge.rb
CHANGED
|
@@ -37,8 +37,13 @@ module Insika
|
|
|
37
37
|
# rubric alone decides, and inventing a default here would be inventing an
|
|
38
38
|
# opinion for someone else's store.
|
|
39
39
|
POLICY_INSTRUCTIONS = {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
# %<max>s is the store's number, not ours. Stated to the judge in the same
|
|
41
|
+
# words and with the same threshold the deterministic layer uses — a judge
|
|
42
|
+
# told "at most one" while the counter allows two grades a different rule.
|
|
43
|
+
"ask_once" => "This store allows AT MOST %<max>s question(s) per reply. One more " \
|
|
44
|
+
"than that in a single message is a failure even if the content is " \
|
|
45
|
+
"otherwise good. A greeting formula that asks nothing the assistant " \
|
|
46
|
+
"needs (\"tudo bem?\", \"how are you?\") is courtesy, not a question.",
|
|
42
47
|
"investigate_first" => "This store wants the objective established BEFORE acting: on a " \
|
|
43
48
|
"vague request the assistant should ask (once or twice, not a " \
|
|
44
49
|
"form), not search immediately.",
|
|
@@ -66,7 +71,8 @@ module Insika
|
|
|
66
71
|
rubric = golden.rubric.to_s.strip
|
|
67
72
|
return nil if rubric.empty?
|
|
68
73
|
|
|
69
|
-
prompt = build_prompt(rubric, golden.user_turns, result.output_text.to_s, golden.policy
|
|
74
|
+
prompt = build_prompt(rubric, golden.user_turns, result.output_text.to_s, golden.policy,
|
|
75
|
+
golden.respond_to?(:policy_options) ? golden.policy_options : {})
|
|
70
76
|
score_prompt(prompt, golden.min_score || DEFAULT_MIN_SCORE)
|
|
71
77
|
end
|
|
72
78
|
|
|
@@ -75,11 +81,12 @@ module Insika
|
|
|
75
81
|
# [{ role: "user"|"assistant", text: }] in order. -> Verdict, or nil without
|
|
76
82
|
# a rubric. Same panel, same rules as `score`: the rubric that scores a
|
|
77
83
|
# scripted replay scores a generated one.
|
|
78
|
-
def score_conversation(rubric:, transcript:, policy: nil,
|
|
84
|
+
def score_conversation(rubric:, transcript:, policy: nil, policy_options: {},
|
|
85
|
+
min_score: DEFAULT_MIN_SCORE)
|
|
79
86
|
rubric = rubric.to_s.strip
|
|
80
87
|
return nil if rubric.empty?
|
|
81
88
|
|
|
82
|
-
prompt = conversation_prompt(rubric, transcript, policy)
|
|
89
|
+
prompt = conversation_prompt(rubric, transcript, policy, policy_options)
|
|
83
90
|
score_prompt(prompt, min_score)
|
|
84
91
|
end
|
|
85
92
|
|
|
@@ -102,7 +109,7 @@ module Insika
|
|
|
102
109
|
# user turn and every assistant reply interleaved. A rubric about the whole
|
|
103
110
|
# exchange ("does it discover the objective before recommending?") is
|
|
104
111
|
# unanswerable from the last reply alone.
|
|
105
|
-
def conversation_prompt(rubric, transcript, policy)
|
|
112
|
+
def conversation_prompt(rubric, transcript, policy, policy_options = {})
|
|
106
113
|
lines = Array(transcript).map do |m|
|
|
107
114
|
role = m[:role].to_s == "user" ? "customer" : "assistant"
|
|
108
115
|
"#{role}: #{m[:text].to_s.strip}"
|
|
@@ -114,7 +121,7 @@ module Insika
|
|
|
114
121
|
|
|
115
122
|
RUBRIC:
|
|
116
123
|
#{rubric}
|
|
117
|
-
#{policy_clause(policy)}
|
|
124
|
+
#{policy_clause(policy, policy_options)}
|
|
118
125
|
CONVERSATION (in order):
|
|
119
126
|
#{lines}
|
|
120
127
|
|
|
@@ -144,14 +151,14 @@ module Insika
|
|
|
144
151
|
# much this store wants the agent to ask before acting is a per-store decision,
|
|
145
152
|
# and a judge that is not TOLD it will guess — half the time wrongly. The
|
|
146
153
|
# deterministic half is `Assertions.policy_checks`; this is the other half.
|
|
147
|
-
def build_prompt(rubric, user_turns, reply, policy = nil)
|
|
154
|
+
def build_prompt(rubric, user_turns, reply, policy = nil, policy_options = {})
|
|
148
155
|
<<~PROMPT
|
|
149
156
|
You are a strict QA judge for a customer-service AI assistant. Judge the
|
|
150
157
|
ASSISTANT REPLY against the RUBRIC — nothing else.
|
|
151
158
|
|
|
152
159
|
RUBRIC:
|
|
153
160
|
#{rubric}
|
|
154
|
-
#{policy_clause(policy)}
|
|
161
|
+
#{policy_clause(policy, policy_options)}
|
|
155
162
|
CONVERSATION (user turns, in order):
|
|
156
163
|
#{user_turns.map { |t| "- #{t}" }.join("\n")}
|
|
157
164
|
|
|
@@ -164,10 +171,12 @@ module Insika
|
|
|
164
171
|
PROMPT
|
|
165
172
|
end
|
|
166
173
|
|
|
167
|
-
def policy_clause(policy)
|
|
174
|
+
def policy_clause(policy, policy_options = {})
|
|
168
175
|
instruction = POLICY_INSTRUCTIONS[policy.to_s]
|
|
169
176
|
return "" unless instruction
|
|
170
177
|
|
|
178
|
+
instruction = format(instruction, max: (policy_options || {}).fetch("max", 1)) if
|
|
179
|
+
instruction.include?("%<max>s")
|
|
171
180
|
"\nSTORE POLICY (weigh this as part of the rubric):\n#{instruction}\n"
|
|
172
181
|
end
|
|
173
182
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Evals
|
|
5
|
+
# Runs each moderator over the same labeled messages and counts how they
|
|
6
|
+
# differ from the label. Pure: the moderators are injected.
|
|
7
|
+
class ModeratorCompare
|
|
8
|
+
BLOCKS = %w[refuse escalate].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(moderators:)
|
|
11
|
+
@moderators = moderators
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run(cases)
|
|
15
|
+
@moderators.to_h { |name, mod| [name, score(mod, cases)] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def score(mod, cases)
|
|
21
|
+
r = { "correct" => 0, "total" => cases.size, "false_blocks" => 0, "missed_blocks" => 0,
|
|
22
|
+
"unavailable" => 0, "misses" => [], "verdicts" => {} }
|
|
23
|
+
times = cases.map do |c|
|
|
24
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
25
|
+
v = mod.classify(c["message"])
|
|
26
|
+
tally(r, c, v)
|
|
27
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
|
|
28
|
+
end.sort
|
|
29
|
+
r.merge("p50_ms" => pct(times, 0.5), "p95_ms" => pct(times, 0.95))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tally(r, c, v)
|
|
33
|
+
r["verdicts"][c["id"]] = { "action" => v.action.to_s, "category" => v.category.to_s, "reason" => v.reason.to_s }
|
|
34
|
+
r["unavailable"] += 1 if v.unavailable?
|
|
35
|
+
want_block = BLOCKS.include?(c["expected"])
|
|
36
|
+
ok = want_block ? v.block? : !v.block? && !v.unavailable?
|
|
37
|
+
return r["correct"] += 1 if ok
|
|
38
|
+
|
|
39
|
+
r["misses"] << c["id"]
|
|
40
|
+
r["false_blocks"] += 1 if !want_block && v.block?
|
|
41
|
+
r["missed_blocks"] += 1 if want_block && !v.block?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pct(sorted, q) = sorted.empty? ? 0 : sorted[((sorted.size - 1) * q).round].round(1)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|