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.
131 lines
6.6 KiB
Ruby
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)
|