Module: Insika::Evals::Assertions
- Defined in:
- lib/insika/evals/assertions.rb
Overview
Deterministic (Fase A) evaluation — cheap, zero-token, zero-flakiness. It's the layer that catches the gross regressions (a tool stopped being called, a secret leaked, the turn errored). Subjective scoring is the LLM-judge in Fase B.
Constant Summary collapse
- PII_DETECTORS =
Named negative detectors for
must_notnow live in the runtime (D4). Kept as an alias so any external reference to Evals::Assertions::PII_DETECTORS still resolves; the values ARE the runtime's, never a fork. Insika::Safety::Detectors::PII
- POLICIES =
HOW MUCH THE AGENT SHOULD ASK BEFORE ACTING (RFC-0014 §3.3). Declared per case because it is a per-STORE decision, not a universal rule: sometimes the agent should establish the objective before searching ("energia, treino ou sono?" — Ocean Drop does this well), and sometimes asking again is the failure and it should just search. A global assertion would be wrong half the time; the judge is TOLD the policy (Judge#build_prompt) and this layer checks the half that needs no reader.
Each rule is stated as the CUSTOMER-VISIBLE fact it checks. RFC-0014 phrased this as "questions before the first tool call", written before P19: text a model emits before calling a tool never reaches the customer now (it rides
:intermediate), and the eval is a client of/v1/responses, so what it can observe per turn is the published answer plus the tools that turn called. That is also the honest scope — a question nobody received is not a question. { # "UMA PERGUNTA POR VEZ" — the rule Insika broke twice under a 28 KB prompt. "ask_once" => "at most one question per reply", # Establish the objective before acting on a vague opener. "investigate_first" => "asks before calling a tool, on the first turn", # Act on the first plausible reading; refine after. "act_fast" => "calls a tool on the first turn instead of asking" }.freeze
Class Method Summary collapse
- .act_fast(turn) ⇒ Object
-
.ask_once(turns) ⇒ Object
Every reply asks at most one question.
-
.count_questions(text) ⇒ Object
Questions in ONE reply.
-
.detect(name, text) ⇒ Object
Runs a named detector over the text.
-
.evaluate(golden, result, turns: nil) ⇒ Object
Golden + TurnResult -> CaseResult.
- .investigate_first(turn) ⇒ Object
-
.must_not_checks(golden, result) ⇒ Object
must_notdetectors. -
.ok_status?(status) ⇒ Boolean
A tool status counts as success when it's blank/"ok"/"success" or a 2xx code.
-
.policy_checks(golden, turns) ⇒ Object
The declared
policy, checked deterministically over the conversation. -
.skip(golden, reason) ⇒ Object
The case did not run and MUST NOT read as either a pass or a failure.
-
.tool_checks(golden, result) ⇒ Object
Each REQUIRED expected tool must appear in the turn's tool calls.
-
.unmet_requirements(golden, available) ⇒ Object
WHAT THIS DEPLOYMENT LACKS for the case to be worth running (RFC-0014 §3.2).
Class Method Details
.act_fast(turn) ⇒ Object
220 221 222 223 224 225 226 227 |
# File 'lib/insika/evals/assertions.rb', line 220 def act_fast(turn) return [false, "no turn to check"] if turn.nil? tools = turn.tool_names return [true, "acted: called #{tools.join(', ')}"] unless tools.empty? [false, "asked instead of acting: #{turn.output_text.to_s.strip[0, 160].inspect}"] end |
.ask_once(turns) ⇒ Object
Every reply asks at most one question. Reported with the offending turn and the reply itself — "2 questions" alone sends the reader digging.
200 201 202 203 204 205 206 207 |
# File 'lib/insika/evals/assertions.rb', line 200 def ask_once(turns) offender = turns.each_with_index.find { |t, _| count_questions(t.output_text) > 1 } return [true, "at most one question per reply"] unless offender turn, i = offender [false, "turn #{i + 1} asked #{count_questions(turn.output_text)} questions: " \ "#{turn.output_text.to_s.strip[0, 160].inspect}"] end |
.count_questions(text) ⇒ Object
Questions in ONE reply. Deliberately crude and deliberately documented: a run of "?" counts once ("já pensou??" is one question), and URLs are dropped first so a tracking link's query string is not read as the agent asking something. It is a policy signal, not grammar — and it already caught a real violation ("é pra você ou tá pensando em presentear alguém? E qual seu tamanho?").
234 235 236 |
# File 'lib/insika/evals/assertions.rb', line 234 def count_questions(text) text.to_s.gsub(%r{https?://\S+}, " ").scan(/\?+/).size end |
.detect(name, text) ⇒ Object
Runs a named detector over the text. "pii_leak" = union of all PII detectors; otherwise a single named pattern. Delegates to the runtime's single source (D4) — which itself fails loud on an unknown name (a typo'd assertion must not silently pass).
242 243 244 |
# File 'lib/insika/evals/assertions.rb', line 242 def detect(name, text) Insika::Safety::Detectors.detect(name, text) end |
.evaluate(golden, result, turns: nil) ⇒ Object
Golden + TurnResult -> CaseResult. A turn that failed to run yields a single failing check (there's nothing to assert on a turn that never produced output).
turns is every turn of the conversation, in order; result is the last one
(what the tool/content assertions have always run on). The policy checks need
all of them — "one question per reply" is a rule about every reply, and the
violation that motivated this was on the FIRST turn. Defaults to the single
result so existing callers keep working.
137 138 139 140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/insika/evals/assertions.rb', line 137 def evaluate(golden, result, turns: nil) if result.error return CaseResult.new(id: golden.id, agent: golden.agent, error: result.error, rubric: nil, judge: nil, checks: [Check.new(name: "turn", pass: false, detail: "turn error: #{result.error}")]) end # NOT `Array(turns)`: TurnResult is a Struct, so Array() would explode a single # one into its members and hand the policy checks three strings. conversation = turns.nil? || turns.empty? ? [result] : turns checks = tool_checks(golden, result) + must_not_checks(golden, result) + policy_checks(golden, conversation) CaseResult.new(id: golden.id, agent: golden.agent, error: nil, checks: checks, rubric: golden.rubric, judge: nil) end |
.investigate_first(turn) ⇒ Object
209 210 211 212 213 214 215 216 217 218 |
# File 'lib/insika/evals/assertions.rb', line 209 def investigate_first(turn) return [false, "no turn to check"] if turn.nil? tools = turn.tool_names return [false, "called #{tools.join(', ')} before asking anything"] unless tools.empty? return [false, "answered without asking: #{turn.output_text.to_s.strip[0, 160].inspect}"] if count_questions(turn.output_text).zero? [true, "asked before acting"] end |
.must_not_checks(golden, result) ⇒ Object
must_not detectors. "tool_error" is special (inspects statuses); the rest
are content detectors over the output text.
167 168 169 170 171 172 173 174 175 176 177 178 179 |
# File 'lib/insika/evals/assertions.rb', line 167 def must_not_checks(golden, result) golden.must_not.map do |name| if name == "tool_error" bad = result.errored_tools Check.new(name: "must_not:tool_error", pass: bad.empty?, detail: bad.empty? ? "no tool errors" : "errored: #{bad.map { |t| t['name'] || t[:name] }.join(', ')}") else hit = detect(name, result.output_text.to_s) Check.new(name: "must_not:#{name}", pass: hit.nil?, detail: hit ? "matched #{hit.inspect}" : "clean") end end end |
.ok_status?(status) ⇒ Boolean
A tool status counts as success when it's blank/"ok"/"success" or a 2xx code.
119 120 121 122 123 124 125 126 127 |
# File 'lib/insika/evals/assertions.rb', line 119 def ok_status?(status) return true if status.nil? s = status.to_s.strip.downcase return true if s.empty? || %w[ok success succeeded done].include?(s) code = Integer(s, exception: false) code ? code.between?(200, 299) : false end |
.policy_checks(golden, turns) ⇒ Object
The declared policy, checked deterministically over the conversation. No
policy -> no check (and nothing to explain in the report).
183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'lib/insika/evals/assertions.rb', line 183 def policy_checks(golden, turns) name = golden.policy return [] if name.nil? # Exhaustive on purpose: a policy added to POLICIES without a rule here would # otherwise fall into whichever branch was last and check the wrong thing. pass, detail = case name when "ask_once" then ask_once(turns) when "investigate_first" then investigate_first(turns.first) when "act_fast" then act_fast(turns.first) else raise ArgumentError, "policy #{name.inspect} has no rule" end [Check.new(name: "policy:#{name}", pass: pass, detail: detail)] end |
.skip(golden, reason) ⇒ Object
The case did not run and MUST NOT read as either a pass or a failure.
113 114 115 116 |
# File 'lib/insika/evals/assertions.rb', line 113 def skip(golden, reason) CaseResult.new(id: golden.id, agent: golden.agent, checks: [], error: nil, rubric: nil, judge: nil, skipped: reason) end |
.tool_checks(golden, result) ⇒ Object
Each REQUIRED expected tool must appear in the turn's tool calls. Optional ("name?") tools are informational — present or not, they never fail.
154 155 156 157 158 159 160 161 162 163 |
# File 'lib/insika/evals/assertions.rb', line 154 def tool_checks(golden, result) names = result.tool_names golden.tools_called.filter_map do |t| next if t[:optional] present = names.include?(t[:name]) Check.new(name: "tool:#{t[:name]}", pass: present, detail: present ? "called" : "expected but not called (saw: #{names.join(', ')})") end end |
.unmet_requirements(golden, available) ⇒ Object
WHAT THIS DEPLOYMENT LACKS for the case to be worth running (RFC-0014 §3.2). -> [reason]; empty = run it.
available is the deployment's answer for this agent:
{ "tools" => [names] | nil, "capabilities" => [names] }
tools nil means an OPEN allowlist — the agent may call every registered tool,
so no tool requirement can be judged missing and the case runs. That is the
deliberate reading: "I could not rule it out" must not become a skip, or a
permissive agent would quietly stop being tested.
99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/insika/evals/assertions.rb', line 99 def unmet_requirements(golden, available) tools = available["tools"] declared = Array(available["capabilities"]).map(&:to_s) missing_tools = tools.nil? ? [] : golden.required_tools - Array(tools).map(&:to_s) missing_caps = golden.required_capabilities - declared reasons = [] reasons << "tool not available: #{missing_tools.join(', ')}" unless missing_tools.empty? reasons << "capability not declared: #{missing_caps.join(', ')}" unless missing_caps.empty? reasons end |