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
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
|
data/lib/insika/evals/report.rb
CHANGED
|
@@ -12,7 +12,15 @@ module Insika
|
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
14
|
# -> Hash ready for JSON. `at` is an ISO-8601 string stamped by the caller.
|
|
15
|
-
|
|
15
|
+
#
|
|
16
|
+
# `perf` is what each case COST, keyed by case id: `{"ms" =>, "turns" =>,
|
|
17
|
+
# "tokens" =>, "cached" =>}`. Optional, because the answer to "did it pass" must
|
|
18
|
+
# not depend on anyone having measured the clock — but a cross-harness table
|
|
19
|
+
# without it is a table about correctness only, and a merchant asks how long the
|
|
20
|
+
# customer waited before asking anything else. Wall clock is measured by the
|
|
21
|
+
# RUNNER around the turn, never self-reported: it is the one column every
|
|
22
|
+
# entrant can be held to.
|
|
23
|
+
def to_h(results, at:, perf: nil)
|
|
16
24
|
passed = results.count(&:pass?)
|
|
17
25
|
skipped = results.count(&:skipped?)
|
|
18
26
|
{
|
|
@@ -27,17 +35,22 @@ module Insika
|
|
|
27
35
|
# Absent (not an empty tally) when nothing was compared — a run with no
|
|
28
36
|
# pairwise is the normal one, and a zeroed block reads like every case tied.
|
|
29
37
|
"pairwise" => pairwise_summary(results),
|
|
38
|
+
"perf" => perf_summary(perf),
|
|
30
39
|
"cases" => results.map do |r|
|
|
31
40
|
{
|
|
32
41
|
"id" => r.id, "agent" => r.agent, "pass" => r.pass?,
|
|
33
42
|
"skipped" => r.skipped,
|
|
34
43
|
"judge_pending" => r.judge_pending?, "error" => r.error,
|
|
35
|
-
"checks" => r.checks.map
|
|
44
|
+
"checks" => r.checks.map do |c|
|
|
45
|
+
{ "name" => c.name, "pass" => c.pass, "detail" => c.detail,
|
|
46
|
+
"skipped" => (true if c.skipped?) }.compact
|
|
47
|
+
end,
|
|
36
48
|
"judge" => (r.judge && { "score" => r.judge.score, "pass" => r.judge.pass, "reason" => r.judge.reason }),
|
|
37
49
|
"pairwise" => (r.pairwise && { "outcome" => r.pairwise.outcome, "vs" => r.pairwise.vs,
|
|
38
50
|
"reason" => r.pairwise.reason, "judges" => r.pairwise.judges,
|
|
39
|
-
"order_dependent" => r.pairwise.order_dependent })
|
|
40
|
-
|
|
51
|
+
"order_dependent" => r.pairwise.order_dependent }),
|
|
52
|
+
"perf" => perf&.[](r.id)
|
|
53
|
+
}.compact
|
|
41
54
|
end
|
|
42
55
|
}.compact
|
|
43
56
|
end
|
|
@@ -57,8 +70,36 @@ module Insika
|
|
|
57
70
|
"outcomes" => counts }
|
|
58
71
|
end
|
|
59
72
|
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
# nil when nothing was timed — an absent block, never a zeroed one: "nobody
|
|
74
|
+
# measured" and "it took no time" are different facts, and a table that confuses
|
|
75
|
+
# them publishes a harness as instant.
|
|
76
|
+
def perf_summary(perf)
|
|
77
|
+
measured = Array(perf&.values).select { |v| v["ms"] }
|
|
78
|
+
return nil if measured.empty?
|
|
79
|
+
|
|
80
|
+
case_ms = measured.map { |v| v["ms"].to_f }.sort
|
|
81
|
+
turn_ms = measured.flat_map { |v| Array(v["turn_ms"]) }.compact.sort
|
|
82
|
+
tokens = measured.filter_map { |v| v["tokens"] }
|
|
83
|
+
{ "cases_measured" => measured.size,
|
|
84
|
+
"case_ms_p50" => percentile(case_ms, 50), "case_ms_p95" => percentile(case_ms, 95),
|
|
85
|
+
"turn_ms_p50" => percentile(turn_ms, 50), "turn_ms_p95" => percentile(turn_ms, 95),
|
|
86
|
+
# Absent rather than zero when no provider reported usage — most rival
|
|
87
|
+
# harnesses will not, and a zero there reads as free.
|
|
88
|
+
"tokens_total" => (tokens.sum unless tokens.empty?),
|
|
89
|
+
"tokens_measured" => tokens.size }.compact
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def percentile(sorted, p)
|
|
93
|
+
return nil if sorted.empty?
|
|
94
|
+
|
|
95
|
+
r = (p / 100.0) * (sorted.length - 1)
|
|
96
|
+
lo = sorted[r.floor]
|
|
97
|
+
hi = sorted[r.ceil]
|
|
98
|
+
(lo + ((hi - lo) * (r - r.floor))).round
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def to_json(results, at:, perf: nil)
|
|
102
|
+
JSON.pretty_generate(to_h(results, at: at, perf: perf))
|
|
62
103
|
end
|
|
63
104
|
|
|
64
105
|
PAIRWISE_MARK = { "better" => "🟢", "comparable" => "🟡", "worse" => "🔴",
|
|
@@ -85,8 +126,8 @@ module Insika
|
|
|
85
126
|
end
|
|
86
127
|
|
|
87
128
|
# Human summary. One line per case; failing checks nested underneath.
|
|
88
|
-
def to_markdown(results, at:)
|
|
89
|
-
h = to_h(results, at: at)
|
|
129
|
+
def to_markdown(results, at:, perf: nil)
|
|
130
|
+
h = to_h(results, at: at, perf: perf)
|
|
90
131
|
lines = ["# Eval report — #{at}", "",
|
|
91
132
|
"**#{h['passed']}/#{h['total'] - h['skipped']} passed** · #{h['failed']} failed" \
|
|
92
133
|
"#{" · #{h['skipped']} skipped" if h['skipped'].positive?}" \
|
|
@@ -99,14 +140,25 @@ module Insika
|
|
|
99
140
|
next
|
|
100
141
|
end
|
|
101
142
|
|
|
102
|
-
|
|
143
|
+
took = perf&.dig(r.id, "ms")
|
|
144
|
+
lines << "- #{r.pass? ? '✅' : '❌'} `#{r.id}` (#{r.agent})" \
|
|
145
|
+
"#{" · #{(took / 1000.0).round(1)}s" if took}" \
|
|
146
|
+
"#{' ⏳ judge pending' if r.judge_pending?}"
|
|
103
147
|
r.failures.each { |c| lines << " - ❌ #{c.name}: #{c.detail}" }
|
|
148
|
+
# A grader the transport could not feed. Printed even on a passing case:
|
|
149
|
+
# "passed" over checks nobody could run is the claim this line prevents.
|
|
150
|
+
r.skipped_checks.each { |c| lines << " - ⏭️ #{c.name}: #{c.detail}" }
|
|
104
151
|
if r.judge
|
|
105
152
|
v = r.judge
|
|
106
153
|
lines << " - #{v.pass ? '✅' : '❌'} judge: #{v.score} — #{v.reason}"
|
|
107
154
|
end
|
|
108
155
|
lines << " - #{pairwise_line(r.pairwise)}" if r.pairwise
|
|
109
156
|
end
|
|
157
|
+
if (summary = h["perf"])
|
|
158
|
+
lines << "" << "**time** (#{summary['cases_measured']} case(s)): " \
|
|
159
|
+
"case p50/p95 #{summary['case_ms_p50']} / #{summary['case_ms_p95']} ms · " \
|
|
160
|
+
"turn p50/p95 #{summary['turn_ms_p50']} / #{summary['turn_ms_p95']} ms"
|
|
161
|
+
end
|
|
110
162
|
lines.concat(pairwise_block(h["pairwise"])) if h["pairwise"]
|
|
111
163
|
"#{lines.join("\n")}\n"
|
|
112
164
|
end
|
data/lib/insika/evals/runner.rb
CHANGED
|
@@ -19,7 +19,11 @@ module Insika
|
|
|
19
19
|
# cache, carried separately because it is the number that explains a total.
|
|
20
20
|
# Only the refinement gate reads them (records a run's cost); the
|
|
21
21
|
# report and the exit code are untouched.
|
|
22
|
-
|
|
22
|
+
# `turns` and `store` are what the case actually SAW: every turn's reply and
|
|
23
|
+
# tool calls, and the store as it stood afterwards. The verdict alone says a
|
|
24
|
+
# cell failed; only these say what the harness answered, and a cross-harness
|
|
25
|
+
# table is unreadable without being able to go and look.
|
|
26
|
+
RunCase = Struct.new(:result, :timings, :tokens, :cached, :turns, :store, keyword_init: true)
|
|
23
27
|
|
|
24
28
|
# judge: an Evals::Judge (optional). When set, a case with a rubric whose turn
|
|
25
29
|
# ran cleanly gets a subjective verdict attached on top of the deterministic pass.
|
|
@@ -35,12 +39,18 @@ module Insika
|
|
|
35
39
|
# pairwise: an Evals::Pairwise (optional). Only cases carrying a
|
|
36
40
|
# `reference:` are compared, and the verdict never touches pass/fail — it is the
|
|
37
41
|
# answer to "can we replace it", reported beside the suite's own verdict.
|
|
38
|
-
|
|
42
|
+
#
|
|
43
|
+
# store_reader: how a case's `store_state:` is graded — anything answering
|
|
44
|
+
# `#read(collection)` with the collection's rows. Only the cases that declare a
|
|
45
|
+
# `store_state:` ask for it; a case that declares one with no reader configured
|
|
46
|
+
# is SKIPPED with the reason, never run and passed on its reply alone.
|
|
47
|
+
def initialize(transport:, judge: nil, conv_map: {}, capabilities: nil, pairwise: nil, store_reader: nil)
|
|
39
48
|
@transport = transport
|
|
40
49
|
@judge = judge
|
|
41
50
|
@conv_map = conv_map || {}
|
|
42
51
|
@capabilities = capabilities
|
|
43
52
|
@pairwise = pairwise
|
|
53
|
+
@store_reader = store_reader
|
|
44
54
|
end
|
|
45
55
|
|
|
46
56
|
# [Golden] -> [RunCase]. Each RunCase carries the CaseResult (for the report) +
|
|
@@ -62,10 +72,37 @@ module Insika
|
|
|
62
72
|
skip = skip_reason(golden)
|
|
63
73
|
return RunCase.new(result: Assertions.skip(golden, skip), timings: []) if skip
|
|
64
74
|
|
|
75
|
+
if golden.store_state? && @store_reader.nil?
|
|
76
|
+
return RunCase.new(result: Assertions.skip(golden, "no store reader configured for `store_state`"),
|
|
77
|
+
timings: [])
|
|
78
|
+
end
|
|
79
|
+
|
|
65
80
|
# A backend that resolves state from a pre-existing conversation (e.g. a
|
|
66
81
|
# consumer needing a real Chat UUID as X-Chat-Id) supplies it via conv_map; otherwise the
|
|
67
82
|
# synthetic "eval-<id>" keeps the adapter's own multi-turn continuation.
|
|
83
|
+
# A reader that caches its snapshot is opening a new case now.
|
|
84
|
+
@store_reader.forget if @store_reader.respond_to?(:forget)
|
|
68
85
|
conv = @conv_map[golden.id] || "eval-#{golden.id}"
|
|
86
|
+
if golden.seeded?
|
|
87
|
+
# The snapshot goes in BEFORE turn 1 — on the same conversation id the turns
|
|
88
|
+
# continue. A transport that cannot seed (A2A: a remote agent has no seed
|
|
89
|
+
# route) SKIPS the case with the reason, never runs it against an empty
|
|
90
|
+
# state and passes. A deployment that refuses seeding (the setting is off)
|
|
91
|
+
# is the same outcome: skipped and reported, which is what the `requires`
|
|
92
|
+
# discipline already promises for anything the deployment lacks.
|
|
93
|
+
unless @transport.respond_to?(:seed)
|
|
94
|
+
return RunCase.new(result: Assertions.skip(golden, "transport cannot seed state"), timings: [])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
@transport.seed(conv, golden.state)
|
|
99
|
+
rescue SeedRefused => e
|
|
100
|
+
return RunCase.new(result: Assertions.skip(golden, "deployment refuses seeding — #{e.message}"), timings: [])
|
|
101
|
+
rescue Insika::Error => e
|
|
102
|
+
failed = TurnResult.new(output_text: "", tool_calls: [], error: "seed failed: #{e.message}")
|
|
103
|
+
return RunCase.new(result: Assertions.evaluate(golden, failed), timings: [])
|
|
104
|
+
end
|
|
105
|
+
end
|
|
69
106
|
turns = []
|
|
70
107
|
timings = []
|
|
71
108
|
spent = []
|
|
@@ -81,7 +118,10 @@ module Insika
|
|
|
81
118
|
last = turns.last
|
|
82
119
|
# Tool/content assertions read the last turn (unchanged); the policy checks
|
|
83
120
|
# read every turn — "one question per reply" is a rule about each of them.
|
|
84
|
-
|
|
121
|
+
# The store is read AFTER the last turn: what the conversation left behind.
|
|
122
|
+
store, store_error = read_store(golden) if golden.store_state? && last.error.nil?
|
|
123
|
+
result = Assertions.evaluate(golden, last, turns: turns, store: store, store_error: store_error,
|
|
124
|
+
tools_reported: tools_reported?)
|
|
85
125
|
# Subjective layer: only when a judge is configured, the case has a rubric, and
|
|
86
126
|
# the turn ran cleanly (nothing to judge on an errored turn).
|
|
87
127
|
result.judge = @judge.score(golden: golden, result: last) if @judge && result.rubric && result.error.nil?
|
|
@@ -90,11 +130,31 @@ module Insika
|
|
|
90
130
|
# comparison for a reason that has nothing to do with the agent.
|
|
91
131
|
result.pairwise = @pairwise.compare(golden: golden, turns: turns) if @pairwise && result.error.nil?
|
|
92
132
|
RunCase.new(result: result, timings: timings, tokens: sum_tokens(spent),
|
|
93
|
-
cached: sum_tokens(cached))
|
|
133
|
+
cached: sum_tokens(cached), turns: turns, store: store)
|
|
94
134
|
end
|
|
95
135
|
|
|
96
136
|
private
|
|
97
137
|
|
|
138
|
+
# Does the transport see tool calls at all? Only a benched harness answers no
|
|
139
|
+
# (an adapter that can report the text and nothing else). Anything that does
|
|
140
|
+
# not answer the question is assumed to report them, which is what every
|
|
141
|
+
# transport in the engine does.
|
|
142
|
+
def tools_reported?
|
|
143
|
+
return true unless @transport.respond_to?(:reports_tool_calls?)
|
|
144
|
+
|
|
145
|
+
@transport.reports_tool_calls?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# -> [{ collection => [rows] }, nil] or [nil, why]. A reader that raises fails
|
|
149
|
+
# the store graders WITH the reason rather than killing the run: one unreadable
|
|
150
|
+
# cell of a bench table must not throw away the other thirty-nine, and a store
|
|
151
|
+
# nobody could read is never a store that matched.
|
|
152
|
+
def read_store(golden)
|
|
153
|
+
[golden.store_collections.to_h { |c| [c, Array(@store_reader.read(c))] }, nil]
|
|
154
|
+
rescue StandardError => e
|
|
155
|
+
[nil, "#{e.class}: #{e.message}"]
|
|
156
|
+
end
|
|
157
|
+
|
|
98
158
|
# nil when NO turn reported usage; otherwise the sum of the ones that did. A
|
|
99
159
|
# partially-metered case is reported as what was actually measured, low rather
|
|
100
160
|
# than absent — a budget under-counting is a smaller lie than a budget that
|
|
@@ -95,11 +95,20 @@ module Insika
|
|
|
95
95
|
@safety = safety
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
-
# Runs one simulated conversation. -> SimulatedRun.
|
|
99
|
-
|
|
98
|
+
# Runs one simulated conversation. -> SimulatedRun. `state` is the case's
|
|
99
|
+
# snapshot (Golden#state), loaded into the conversation before the persona's
|
|
100
|
+
# opening line — a simulated customer can start from "already saw three
|
|
101
|
+
# products" too. Empty/nil = the conversation starts empty, as before.
|
|
102
|
+
def run(persona:, agent:, conv:, state: nil)
|
|
100
103
|
reason = @safety.refusal
|
|
101
104
|
raise UnsafeTarget, reason if reason
|
|
102
105
|
|
|
106
|
+
if state && !state.empty?
|
|
107
|
+
raise Insika::Error, "transport cannot seed state" unless @transport.respond_to?(:seed)
|
|
108
|
+
|
|
109
|
+
@transport.seed(conv, state)
|
|
110
|
+
end
|
|
111
|
+
|
|
103
112
|
transcript = []
|
|
104
113
|
message = persona.opens_with
|
|
105
114
|
stop = :max_turns
|
|
@@ -157,12 +166,21 @@ module Insika
|
|
|
157
166
|
# [tool names] the agent can reach that are marked side-effect, sorted.
|
|
158
167
|
def side_effect_tools(profile, registry)
|
|
159
168
|
allowed = if profile.tools_allow.nil? then Array(registry.names)
|
|
160
|
-
else Array(profile.tools_allow).map(&:to_s)
|
|
169
|
+
else Array(profile.tools_allow).map(&:to_s) | group_tools(profile, registry)
|
|
161
170
|
end
|
|
162
171
|
denied = Array(profile.tools_deny).map(&:to_s)
|
|
163
172
|
(allowed - denied).select { |name| registry.side_effect?(name) }.sort
|
|
164
173
|
end
|
|
165
174
|
|
|
175
|
+
# Tools reachable through tools_allow_groups (an MCP server, a kit's groups):
|
|
176
|
+
# the registry entries whose metadata group the agent allows.
|
|
177
|
+
def group_tools(profile, registry)
|
|
178
|
+
groups = Array(profile.tools_allow_groups).map(&:to_s)
|
|
179
|
+
return [] if groups.empty? || !registry.respond_to?(:entries)
|
|
180
|
+
|
|
181
|
+
registry.entries.select { |e| groups.include?(e.metadata[:group].to_s) }.map { |e| e.name.to_s }
|
|
182
|
+
end
|
|
183
|
+
|
|
166
184
|
# -> bool: can a simulated run touch this agent without a staging
|
|
167
185
|
# declaration (no reachable side-effect tool)?
|
|
168
186
|
def safe?(profile, registry)
|