Prd/components/backend/test/test_summariser.rb
Lucy Doupalů be9f14ce34 Helpdesk - operator console + patched GrapheneOS Dialer for call handling
A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer
answers technician calls, records both call legs as separate channels, and a
Ruby backend transcribes them through Whisper and files an AI summary against
the caller.

Squashed to a single commit for sharing. No credentials are included; secrets
live outside the repo in /etc/helpdesk/env on the server or a gitignored
.claude/env.local locally. See .claude/env.local.example for the shape.

Start at README.md, then docs/architecture.md.
2026-07-27 18:50:32 +02:00

131 lines
6.6 KiB
Ruby

# 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)