# frozen_string_literal: true # Zero-dependency offline tests for the Summariser. Run: ruby components/backend/test/test_summariser.rb # The HTTP transport is injectable so no key/network is needed. require_relative "../summariser" require "json" $pass = 0; $fail = 0 def ok(d); $pass += 1; puts " ok #{d}"; end def bad(d, got = nil); $fail += 1; puts " FAIL #{d}#{got.nil? ? '' : " (got: #{got.inspect})"}"; end def eq(a, b, d); a == b ? ok(d) : bad(d, a); end def truthy(x, d); x ? ok(d) : bad(d, x); end def fake_ok(content) body = JSON.generate("choices" => [{ "message" => { "content" => content } }], "usage" => { "cost" => 0.001 }) [200, body] end def fake_err(status) [status, "error"] end # --- existing baseline tests --- puts "summariser: happy path (strict schema reply)" good = JSON.generate( "summary" => "Shrnutí hovoru.", "suggested_position" => "dispečer", "action_items" => [{ "text" => "Zkontrolovat YAMT.", "kind" => "tool_hint", "target" => "yamt" }]) s = Helpdesk::Summariser.new(transport: ->(_u, _h, _b) { fake_ok(good) }, api_key: "x") r = s.summarise("Přepis…") truthy(r[:ok], "ok=true on happy path") eq(r[:summary], "Shrnutí hovoru.", "summary extracted") eq(r[:suggested_position], "dispečer", "suggested_position extracted") eq(r[:action_items].first["kind"], "tool_hint", "action_items normalized") eq(r[:action_items].first["target"], "yamt", "action_items target present") puts "summariser: missing api_key -> error (no transport override)" s_nokey = Helpdesk::Summariser.new(api_key: "") r_nokey = s_nokey.summarise("anything") truthy(!r_nokey[:ok], "no key -> ok=false") truthy(r_nokey[:error].include?("OPENROUTER"), "error mentions key") puts "summariser: http error -> propagated" s_err = Helpdesk::Summariser.new(transport: ->(_u, _h, _b) { fake_err(502) }, api_key: "x") r_err = s_err.summarise("t") truthy(!r_err[:ok], "http error -> ok=false") truthy(r_err[:error].include?("502"), "error contains status") puts "summariser: fallback to json_object when schema parse fails" calls = 0 bad_json = "not json at all" fallback_body = JSON.generate( "summary" => "Náhradní.", "suggested_position" => "", "action_items" => []) transport_fallback = ->(u, h, b) do calls += 1 calls == 1 ? fake_ok(bad_json) : fake_ok(fallback_body) end s_fb = Helpdesk::Summariser.new(transport: transport_fallback, api_key: "x") r_fb = s_fb.summarise("t") truthy(r_fb[:ok], "fallback reply ok=true") truthy(r_fb[:fell_back], "fell_back flag set") eq(r_fb[:summary], "Náhradní.", "fallback summary extracted") eq(calls, 2, "exactly two transport calls (schema + object)") puts "summariser: needs_review when both modes fail" s_bad = Helpdesk::Summariser.new(transport: ->(_u, _h, _b) { fake_ok("nope") }, api_key: "x") r_bad = s_bad.summarise("t") truthy(!r_bad[:ok], "ok=false when both fail") truthy(r_bad[:needs_review], "needs_review set") puts "summariser: request_body structure (schema mode)" s_rb = Helpdesk::Summariser.new(transport: ->(_u, _h, _b) { fake_ok(good) }, api_key: "x") body = JSON.parse(s_rb.request_body("hello", {}, mode: :schema)) truthy(body.key?("response_format"), "response_format present in schema mode") eq(body.dig("response_format", "type"), "json_schema", "type=json_schema") puts "summariser: request_body structure (object mode)" body_obj = JSON.parse(s_rb.request_body("hello", {}, mode: :object)) eq(body_obj.dig("response_format", "type"), "json_object", "type=json_object in object mode") puts "summariser: kind coercion (unknown kind -> other)" coerce = JSON.generate( "summary" => "S.", "suggested_position" => "", "action_items" => [{ "text" => "X", "kind" => "unknown_kind" }]) s_coerce = Helpdesk::Summariser.new(transport: ->(_u, _h, _b) { fake_ok(coerce) }, api_key: "x") r_coerce = s_coerce.summarise("t") eq(r_coerce[:action_items].first["kind"], "other", "unknown kind coerced to other") # --- Rev-4 context enrichment --- def env_with(content) # build a minimal OpenRouter chat envelope whose message.content is `content` JSON.generate("choices" => [{ "message" => { "content" => content } }], "usage" => { "cost" => 0.004 }) end puts "summariser: enriched fields parse + normalize" rich = JSON.generate( "summary" => "Shrnutí.", "suggested_position" => "", "action_items" => [{ "text" => "x", "kind" => "doc" }], "context_digest" => "Podrobný přepis bez žvástů.", "caller_facts" => ["Region: Morava", " ", 42], # blanks/nonstrings must be dropped/coerced "ledger_deltas" => [ { "entry_id" => "led1", "question" => "reset?", "decision" => "no", "quote" => "to nejde" }, { "entry_id" => nil, "question" => "sada?", "decision" => "maybe", "quote" => "NOT IN TRANSCRIPT" }, # bad decision + bad quote ]) s = Helpdesk::Summariser.new(transport: ->(_u, _h, _b) { [200, env_with(rich)] }, api_key: "x") r = s.summarise("… to nejde … reset …", ledger: [{ "id" => "led1", "question" => "reset?", "decision" => "no" }]) truthy(r[:ok], "enriched summary ok") eq(r[:context_digest], "Podrobný přepis bez žvástů.", "context_digest surfaced") eq(r[:caller_facts], ["Region: Morava"], "caller_facts drops blanks/non-strings") eq(r[:ledger_deltas].size, 1, "delta with a quote NOT in the transcript is dropped (hallucination guard)") eq(r[:ledger_deltas][0]["decision"], "no", "kept delta carries a valid decision") eq(r[:ledger_deltas][0]["entry_id"], "led1", "canonical entry_id preserved") puts "summariser: lenient validation (legacy-shaped reply still ok)" legacy = JSON.generate("summary" => "S.", "action_items" => [], "suggested_position" => "") s2 = Helpdesk::Summariser.new(transport: ->(_u, _h, _b) { [200, env_with(legacy)] }, api_key: "x") r2 = s2.summarise("hello") truthy(r2[:ok], "reply missing the new fields still validates (only summary+action_items required)") eq(r2[:context_digest], "", "absent context_digest -> \"\"") eq(r2[:ledger_deltas], [], "absent ledger_deltas -> []") puts "summariser: per-request entry_id enum + max_tokens" body = JSON.parse(s.request_body("t", { ledger: [{ "id" => "led1" }, { "id" => "led2" }] }, mode: :schema)) enum = body.dig("response_format", "json_schema", "schema", "properties", "ledger_deltas", "items", "properties", "entry_id", "enum") eq(enum.sort_by(&:to_s), [nil, "led1", "led2"].sort_by(&:to_s), "schema entry_id enum = active ids + null") eq(body["max_tokens"], 2500, "max_tokens raised to 2500") body0 = JSON.parse(s.request_body("t", { ledger: [] }, mode: :schema)) truthy(!body0.dig("response_format", "json_schema", "schema", "properties", "ledger_deltas", "items", "properties", "entry_id").key?("enum"), "empty ledger -> entry_id enum omitted (no enum:[null])") puts "\n#{$pass} passed, #{$fail} failed" exit($fail.zero? ? 0 : 1)