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.
500 lines
32 KiB
Ruby
500 lines
32 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Zero-dependency test harness for the Helpdesk domain (no gems, no network).
|
|
# Run: ruby components/backend/test/test_domain.rb
|
|
require_relative "../lib/helpdesk/domain"
|
|
require_relative "../lib/helpdesk/business_hours"
|
|
require_relative "../seed"
|
|
|
|
$pass = 0; $fail = 0
|
|
def ok(desc); $pass += 1; puts " ok #{desc}"; end
|
|
def bad(desc, got = nil); $fail += 1; puts " FAIL #{desc}#{got.nil? ? '' : " (got: #{got.inspect})"}"; end
|
|
def eq(a, b, desc); a == b ? ok(desc) : bad(desc, a); end
|
|
def truthy(x, desc); x ? ok(desc) : bad(desc, x); end
|
|
|
|
CID = "11111111-1111-4111-8111-111111111111"
|
|
def t(offset = 0) = (Time.utc(2026, 7, 2, 9, 0, 0) + offset).iso8601
|
|
|
|
# fixed clock for deterministic watchdog tests
|
|
def svc(now: Time.utc(2026, 7, 2, 9, 0, 0))
|
|
Helpdesk::Seed.call(Helpdesk::Service.new(Helpdesk::Store.new, clock: -> { now }))
|
|
end
|
|
|
|
puts "phone normalization"
|
|
eq(Helpdesk::Phone.e164("601 234 567"), "+420601234567", "bare CZ national -> +420")
|
|
eq(Helpdesk::Phone.e164("00420601234567"), "+420601234567", "00420 -> +420")
|
|
eq(Helpdesk::Phone.e164("+420601234567"), "+420601234567", "already E.164 passthrough")
|
|
eq(Helpdesk::Phone.e164("hidden"), nil, "garbage -> nil")
|
|
|
|
puts "lookups"
|
|
s = svc
|
|
truthy(s.store.person_by_e164("+420601234567"), "person found by any of their numbers")
|
|
eq(s.store.did_by_e164("+420800111222")[:operator], "tmobile", "DID resolves operator")
|
|
|
|
puts "incoming creates ringing + resolves person/operator via DID + screen-pop"
|
|
s = svc
|
|
r = s.incoming(call_id: CID, ts: t(0), number: "601 234 567",
|
|
presentation: "allowed", dialed_did: "+420800111222", device_id: "dev1")
|
|
ev = s.store.event_by_call_id(CID)
|
|
eq(ev[:state], "ringing", "state=ringing")
|
|
eq(ev[:operator_context], "tmobile", "operator from DID, not caller")
|
|
eq(s.store.people[ev[:person_id]][:name], "Jan Novák", "person resolved by number")
|
|
pop = s.drain_broadcasts("tmobile")
|
|
eq(pop.size, 1, "one screen-pop broadcast to the operator")
|
|
eq(pop.first[:person][:name], "Jan Novák", "screen-pop carries the person")
|
|
eq(pop.first[:number], "+420601234567", "screen-pop carries the full caller number (operator needs it)")
|
|
|
|
puts "unknown caller -> screen-pop with unknown_caller=true"
|
|
s = svc
|
|
s.incoming(call_id: "u", ts: t(0), number: "+420999888777",
|
|
presentation: "allowed", dialed_did: "+420800111222")
|
|
pop = s.drain_broadcasts("tmobile").first
|
|
truthy(pop[:unknown_caller], "unknown_caller flagged")
|
|
eq(pop[:person], nil, "no person on unknown caller")
|
|
|
|
puts "happy path advances monotonically + derives duration"
|
|
s = svc
|
|
s.incoming(call_id: CID, ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.answered(call_id: CID, ts: t(5))
|
|
s.ended(call_id: CID, ts: t(125), disconnect_cause: "remote", recording_uuid: "rec1")
|
|
ev = s.store.event_by_call_id(CID)
|
|
eq(ev[:state], "ended", "state=ended")
|
|
eq(ev[:duration_s], 120, "duration derived answered->ended = 120s")
|
|
s.recording_uploaded(recording_uuid: "rec1", call_id: CID, url: "/rec/rec1.flac")
|
|
eq(s.store.event_by_call_id(CID)[:state], "recording_uploaded", "upload advances state")
|
|
s.mark_transcribed(call_id: CID, transcript: "[Speaker 1]: ahoj")
|
|
s.mark_summarised(call_id: CID, summary: "test", action_items: [{ text: "x" }])
|
|
eq(s.store.event_by_call_id(CID)[:state], "summarised", "reaches summarised")
|
|
|
|
puts "out-of-order + duplicates are harmless (monotonic)"
|
|
s = svc
|
|
# ended arrives before answered (retry reordering), then a late duplicate answered
|
|
s.incoming(call_id: CID, ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.ended(call_id: CID, ts: t(125), duration_s: 120, disconnect_cause: "remote", recording_uuid: "rec1")
|
|
s.answered(call_id: CID, ts: t(5)) # late, lower rank
|
|
ev = s.store.event_by_call_id(CID)
|
|
eq(ev[:state], "ended", "late lower-rank /answered does NOT regress state")
|
|
eq(ev[:answered_at], t(5), "but it still fills answered_at")
|
|
s.incoming(call_id: CID, ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
eq(s.store.events.size, 1, "duplicate /incoming is an idempotent upsert (no new Event)")
|
|
|
|
puts "recording upload with no Event -> quarantine, then attach"
|
|
s = svc
|
|
res = s.recording_uploaded(recording_uuid: "orphan", call_id: "later", url: "/x")
|
|
truthy(res[:quarantined], "orphan upload quarantined")
|
|
eq(s.store.quarantine.size, 1, "one item in quarantine")
|
|
|
|
puts "missed call: never answered -> watchdog closes to recording_missing"
|
|
now = Time.utc(2026, 7, 2, 9, 0, 0)
|
|
s = Helpdesk::Seed.call(Helpdesk::Service.new(Helpdesk::Store.new, clock: -> { now }))
|
|
s.incoming(call_id: CID, ts: now.iso8601, number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
now += 16 * 60 # 16 min later
|
|
s.reconcile!
|
|
eq(s.store.event_by_call_id(CID)[:state], "recording_missing", "ringing >15min -> recording_missing")
|
|
|
|
puts "ended, recording promised but not uploaded within grace -> recording_missing"
|
|
now = Time.utc(2026, 7, 2, 9, 0, 0)
|
|
s = Helpdesk::Seed.call(Helpdesk::Service.new(Helpdesk::Store.new, clock: -> { now }))
|
|
s.incoming(call_id: CID, ts: now.iso8601, number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.answered(call_id: CID, ts: now.iso8601)
|
|
s.ended(call_id: CID, ts: now.iso8601, duration_s: 60, disconnect_cause: "remote", recording_uuid: "rec1")
|
|
now += 31 * 60
|
|
s.reconcile!
|
|
eq(s.store.event_by_call_id(CID)[:state], "recording_missing", "ended + no upload in 30min -> recording_missing")
|
|
# but a late upload still advances it
|
|
s.recording_uploaded(recording_uuid: "rec1", call_id: CID, url: "/x")
|
|
eq(s.store.event_by_call_id(CID)[:state], "recording_uploaded", "late upload still advances past recording_missing")
|
|
|
|
puts "operator notes + resolution + call log (console-facing)"
|
|
s = svc
|
|
s.incoming(call_id: CID, ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.answered(call_id: CID, ts: t(5)); s.ended(call_id: CID, ts: t(65), duration_s: 60, disconnect_cause: "remote")
|
|
v = s.set_notes(call_id: CID, notes: "meter swap", resolution: "solved")
|
|
eq(v[:resolution], "solved", "operator sets resolution")
|
|
eq(v[:notes], "meter swap", "operator notes stored")
|
|
eq(s.set_notes(call_id: CID, resolution: "bogus")[:resolution], "solved", "invalid resolution ignored")
|
|
eq(s.set_notes(call_id: CID, notes: "updated")[:resolution], "solved", "notes-only update keeps resolution (wrap-up safe)")
|
|
truthy(s.recent_events(operator: "tmobile").any? { |e| e[:call_id] == CID }, "event appears in tmobile queue log")
|
|
eq(s.recent_events(operator: "vodafone").size, 0, "not in the vodafone queue")
|
|
truthy(s.recent_events(operator: "all").size >= 1, "all-queue log includes it")
|
|
|
|
puts "screen-pop drain: all vs per-operator"
|
|
s = svc
|
|
s.incoming(call_id: "a", ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.incoming(call_id: "b", ts: t(0), number: "+420602345678", presentation: "allowed", dialed_did: "+420800333444")
|
|
eq(s.drain_broadcasts("all").size, 2, "all-queue drains both operators' pops")
|
|
eq(s.drain_broadcasts("all").size, 0, "drain empties the queue")
|
|
|
|
puts "business hours (Mon-Fri 08:00-16:00 Europe/Prague, CZ holidays off)"
|
|
bh = Helpdesk::BusinessHours
|
|
truthy(bh.open?(Time.new(2026, 7, 2, 9, 0, 0)), "Thu 09:00 -> open") # 2026-07-02 is a Thursday
|
|
eq(bh.open?(Time.new(2026, 7, 2, 16, 0, 0)), false, "16:00 sharp -> closed")
|
|
eq(bh.open?(Time.new(2026, 7, 2, 7, 59, 0)), false, "07:59 -> closed")
|
|
eq(bh.open?(Time.new(2026, 7, 4, 10, 0, 0)), false, "Saturday -> closed")
|
|
eq(bh.open?(Time.new(2026, 7, 6, 10, 0, 0)), false, "Jul 6 (CZ holiday) -> closed")
|
|
eq(bh.open?(Time.new(2026, 1, 1, 10, 0, 0)), false, "Jan 1 (holiday) -> closed")
|
|
eq(bh.easter_date(2026), Date.new(2026, 4, 5), "Easter 2026 = Apr 5")
|
|
eq(bh.open?(Time.new(2026, 4, 6, 10, 0, 0)), false, "Easter Monday -> closed")
|
|
|
|
puts "mark_failed: stuck transcription -> terminal failed side-state"
|
|
s = svc
|
|
s.incoming(call_id: CID, ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.answered(call_id: CID, ts: t(5))
|
|
s.ended(call_id: CID, ts: t(65), duration_s: 60, disconnect_cause: "local")
|
|
s.recording_uploaded(recording_uuid: "rec-1", call_id: CID, url: "/rec/rec-1", audio_path: "/tmp/x.audio")
|
|
s.mark_failed(call_id: CID, reason: "transcription_failed", retryable: true)
|
|
evf = s.event_by_call_id(CID)
|
|
eq(evf[:state], "failed", "failed side-state set when transcription never produces a transcript")
|
|
eq(evf[:failure_reason], "transcription_failed", "failure_reason recorded (console can show why)")
|
|
truthy(evf[:flags].include?("transcription_failed"), "transcription_failed flag set")
|
|
eq(s.event_view(evf)[:failure_reason], "transcription_failed", "failure_reason surfaced in event_view")
|
|
# must NOT clobber an already-successful outcome
|
|
s2 = svc
|
|
s2.incoming(call_id: "c2", ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s2.recording_uploaded(recording_uuid: "r2", call_id: "c2", url: "/rec/r2", audio_path: "/tmp/y.audio")
|
|
s2.mark_transcribed(call_id: "c2", transcript: "ahoj")
|
|
s2.mark_summarised(call_id: "c2", summary: "shrnuti")
|
|
s2.mark_failed(call_id: "c2", reason: "late_fail")
|
|
eq(s2.event_by_call_id("c2")[:state], "summarised", "mark_failed does not clobber a summarised event")
|
|
|
|
puts "quarantine: a recording PUT before /incoming is reattached on incoming (the race)"
|
|
s = svc
|
|
q = s.recording_uploaded(recording_uuid: "rec-early", call_id: "late-cid", url: "/rec/rec-early", audio_path: "/tmp/early.audio")
|
|
truthy(q[:quarantined], "no event yet -> quarantined")
|
|
eq(s.store.quarantine.size, 1, "one quarantined upload held")
|
|
s.incoming(call_id: "late-cid", ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
evq = s.event_by_call_id("late-cid")
|
|
eq(evq[:state], "recording_uploaded", "incoming reattaches the quarantined recording")
|
|
eq(evq[:recording_uuid], "rec-early", "recording_uuid reattached")
|
|
eq(evq[:audio_path], "/tmp/early.audio", "audio_path reattached so the pipeline can find the file")
|
|
eq(s.store.quarantine.size, 0, "quarantine drained after reattach")
|
|
|
|
puts "reconcile!: a nil ended_at Event neither crashes nor starves later Events"
|
|
s = svc(now: Time.utc(2026, 7, 2, 9, 0, 0))
|
|
s.incoming(call_id: "A", ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.ended(call_id: "A", ts: nil, disconnect_cause: "local") # malformed: ended_at stays nil
|
|
truthy(s.event_by_call_id("A")[:ended_at].nil?, "A has nil ended_at (malformed webhook)")
|
|
s.incoming(call_id: "B", ts: t(-3600), number: "+420602345678", presentation: "allowed", dialed_did: "+420800333444")
|
|
begin
|
|
s.reconcile!; ok("reconcile! did not raise on the nil-ended_at Event")
|
|
rescue => e
|
|
bad("reconcile! raised", "#{e.class}: #{e.message}")
|
|
end
|
|
eq(s.event_by_call_id("B")[:state], "recording_missing", "stale ringing B still reconciled despite malformed A")
|
|
|
|
puts "concurrency: parallel incoming + mark_* + reconcile on a shared Service stays consistent"
|
|
# Pre-fix this raised 'can\\'t add a new key into hash during iteration' (reproduced in the pre-flash
|
|
# review). With the Service Monitor it is serialized. Smoke test: false-negatives possible, never
|
|
# false-positives — with the lock it is deterministic-clean.
|
|
s = svc
|
|
errors = []
|
|
threads = (0...80).map do |i|
|
|
Thread.new do
|
|
cid = "cc-#{i}"
|
|
s.incoming(call_id: cid, ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.recording_uploaded(recording_uuid: "rc#{i}", call_id: cid, url: "/r", audio_path: "/tmp/a.audio")
|
|
s.mark_transcribed(call_id: cid, transcript: "t#{i}")
|
|
s.mark_summarised(call_id: cid, summary: "s#{i}")
|
|
s.recent_events(limit: 5) # concurrent .values iteration
|
|
s.reconcile! # concurrent each_value while others insert
|
|
rescue => e
|
|
errors << "#{e.class}: #{e.message}"
|
|
end
|
|
end
|
|
threads.each(&:join)
|
|
eq(errors, [], "no thread raised (Monitor serialized concurrent store access)")
|
|
done = s.store.events.values.count { |e| e[:call_id].to_s.start_with?("cc-") && e[:state] == "summarised" }
|
|
eq(done, 80, "all 80 concurrent calls reached summarised with no lost/corrupt entries")
|
|
|
|
puts "people directory + per-caller context"
|
|
s = svc
|
|
# two calls from Jan Novák (known person) + one unknown caller
|
|
s.incoming(call_id: "pd-1", ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.ended(call_id: "pd-1", ts: t(60), duration_s: 60, disconnect_cause: "local")
|
|
s.incoming(call_id: "pd-2", ts: t(200), number: "601 234 567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.incoming(call_id: "pd-x", ts: t(300), number: "+420777000111", presentation: "allowed", dialed_did: "+420800111222")
|
|
jan = s.store.person_by_e164("+420601234567")[:id]
|
|
list = s.people_list
|
|
truthy(list.is_a?(Array) && list.any? { |p| p[:id] == jan }, "people_list returns known people")
|
|
janrow = list.find { |p| p[:id] == jan }
|
|
eq(janrow[:call_count], 2, "call_count counts only that person's calls (unknown caller excluded)")
|
|
eq(janrow[:numbers].include?("+420601234567"), true, "person_summary lists e164 numbers")
|
|
truthy(janrow[:has_context], "seeded Jan has context")
|
|
det = s.person_detail(jan)
|
|
eq(det[:events].map { |e| e[:call_id] }.sort, %w[pd-1 pd-2], "person_detail returns that caller's calls only")
|
|
truthy(det[:context].to_s.include?("Beroun"), "person_detail includes the context text")
|
|
upd = s.set_person_context(person_id: jan, context: "nový kontext")
|
|
eq(upd[:context], "nový kontext", "set_person_context persists")
|
|
eq(s.person_detail(jan)[:context], "nový kontext", "context survives a re-read")
|
|
eq(s.person_detail(999_999), nil, "person_detail(unknown id) -> nil")
|
|
eq(s.set_person_context(person_id: 999_999, context: "x"), nil, "set context on unknown id -> nil")
|
|
|
|
puts "contact create/edit (B)"
|
|
s = svc
|
|
pd = s.create_person(name: "Nová Firma", operator: "vodafone", position: "Vedoucí", numbers: ["605 111 222", "+420605111222"], email: "a@b.cz", context: "vip")
|
|
eq(pd[:position], "Vedoucí", "position set on create")
|
|
truthy(pd[:id], "create returns a person")
|
|
eq(pd[:name], "Nová Firma", "name set")
|
|
eq(pd[:email], "a@b.cz", "email set")
|
|
eq(pd[:operator], "vodafone", "operator set")
|
|
eq(pd[:numbers], ["+420605111222"], "numbers normalized + deduped to one")
|
|
truthy(s.store.person_by_e164("+420605111222"), "reachable by normalized number")
|
|
eq(s.create_person(name: " ")[:error], "name_required", "blank name rejected")
|
|
eq(s.create_person(name: "X", operator: "bogus")[:operator], "unrouted", "invalid operator -> unrouted")
|
|
up = s.update_person(person_id: pd[:id], name: "Renamed", numbers: ["777 000 111"], email: "")
|
|
eq(up[:name], "Renamed", "name updated")
|
|
eq(up[:numbers], ["+420777000111"], "numbers replaced on edit")
|
|
eq(up[:email], nil, "email cleared with blank string")
|
|
truthy(s.store.person_by_e164("+420605111222").nil?, "old number released when numbers replaced")
|
|
eq(s.update_person(person_id: 999_999, name: "x"), nil, "update unknown id -> nil")
|
|
s.update_person(person_id: pd[:id], name: "Renamed2")
|
|
eq(s.person_detail(pd[:id])[:context], "vip", "context preserved on an unrelated edit")
|
|
del = s.delete_person(person_id: pd[:id])
|
|
eq(del && del[:deleted], pd[:id], "delete returns the removed id")
|
|
eq(s.person_detail(pd[:id]), nil, "deleted person is gone")
|
|
truthy(s.store.person_by_e164("+420777000111").nil?, "deleted person's numbers released")
|
|
eq(s.delete_person(person_id: 999_999), nil, "delete unknown id -> nil")
|
|
|
|
puts "phone contacts sync (A): upsert by number, phone owns identity, context preserved"
|
|
s = svc
|
|
jan = s.store.person_by_e164("+420601234567")[:id]
|
|
s.set_person_context(person_id: jan, context: "keep me")
|
|
r = s.upsert_contacts([{ "name" => "Jan N. (mobil)", "numbers" => ["601 234 567"], "emails" => ["jan@x.cz"] }])
|
|
eq(r[:upserted], 1, "one contact upserted")
|
|
eq(s.store.people[jan][:name], "Jan N. (mobil)", "phone owns name (existing matched by number)")
|
|
eq(s.store.people[jan][:email], "jan@x.cz", "phone owns email")
|
|
eq(s.person_detail(jan)[:context], "keep me", "context is NOT touched by a phone sync")
|
|
before = s.store.people.size
|
|
s.upsert_contacts([{ "name" => "Lucy D", "numbers" => ["605 170 197"], "emails" => ["lucie@doupal.cz"] }])
|
|
np = s.store.person_by_e164("+420605170197")
|
|
truthy(np, "new contact's number indexed")
|
|
eq(np[:name], "Lucy D", "new contact name")
|
|
eq(np[:operator], "unrouted", "phone contact -> unrouted queue")
|
|
eq(np[:source], "phone", "marked source=phone")
|
|
eq(s.store.people.size, before + 1, "exactly one new person created")
|
|
s.upsert_contacts([{ "name" => "Lucy D", "numbers" => ["+420605170197"] }])
|
|
eq(s.store.people.size, before + 1, "re-sync is idempotent (matched by E.164 number)")
|
|
eq(s.upsert_contacts([{ "name" => "", "numbers" => ["x"] }, { "name" => "No Num", "numbers" => [] }])[:upserted], 0, "nameless / numberless contacts skipped")
|
|
|
|
puts "add_number never STEALS a number owned by another person (review fix)"
|
|
s = svc
|
|
alice = s.create_person(name: "Alice", numbers: ["+420601111111"])[:id]
|
|
bob = s.create_person(name: "Bob", numbers: ["+420602222222"])[:id]
|
|
s.update_person(person_id: alice, numbers: ["+420601111111", "+420602222222"]) # operator tries to grab Bob's number
|
|
eq(s.store.person_by_e164("+420602222222")&.dig(:id), bob, "edit can't steal another person's number (still Bob)")
|
|
eq(s.store.person_by_e164("+420601111111")&.dig(:id), alice, "Alice keeps her own number")
|
|
s.upsert_contacts([{ "name" => "Merged", "numbers" => ["+420601111111", "+420602222222"] }]) # sync claims both
|
|
eq(s.store.person_by_e164("+420602222222")&.dig(:id), bob, "upsert can't steal Bob's number in a multi-person claim")
|
|
|
|
puts "AI-suggested position surfaces only when position is unset"
|
|
s = svc
|
|
cid = s.create_person(name: "Test Caller", numbers: ["+420700000001"])[:id] # no position
|
|
s.incoming(call_id: "sp-1", ts: t(0), number: "+420700000001", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.mark_summarised(call_id: "sp-1", summary: "sum", action_items: [], suggested_position: "dispečer")
|
|
eq(s.person_detail(cid)[:suggested_position], "dispečer", "unset position -> AI suggestion surfaced from latest call")
|
|
eq(s.store.event_by_call_id("sp-1")[:suggested_position], "dispečer", "suggested_position stored on the event")
|
|
s.update_person(person_id: cid, position: "ručně nastaveno")
|
|
eq(s.person_detail(cid)[:suggested_position], nil, "position set -> suggestion no longer shown")
|
|
eq(s.person_detail(cid)[:position], "ručně nastaveno", "manual position wins")
|
|
|
|
puts "idle presence heartbeat"
|
|
s = svc
|
|
eq(s.device_status[:connected], false, "no device seen yet")
|
|
s.device_heartbeat("dev1")
|
|
ds = s.device_status
|
|
eq(ds[:connected], true, "heartbeat marks the phone present (no call needed)")
|
|
eq(ds[:devices].first[:device_id], "dev1", "device id recorded from heartbeat")
|
|
|
|
puts "persistence: JSON snapshot round-trips people/context/events + id counters"
|
|
require "tmpdir"
|
|
s = svc
|
|
jan = s.store.person_by_e164("+420601234567")[:id]
|
|
s.set_person_context(person_id: jan, context: "durable ctx")
|
|
s.incoming(call_id: "ps-1", ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.ended(call_id: "ps-1", ts: t(60), duration_s: 42, disconnect_cause: "local")
|
|
s.mark_summarised(call_id: "ps-1", summary: "sum", action_items: [{ "text" => "do x", "kind" => "tool_hint", "target" => "yamt" }])
|
|
path = File.join(Dir.mktmpdir, "state.json")
|
|
s.store.save(path)
|
|
truthy(File.exist?(path) && !File.zero?(path), "snapshot written")
|
|
s2 = Helpdesk::Service.new(Helpdesk::Store.load(path))
|
|
eq(s2.person_detail(jan)[:context], "durable ctx", "context survives save/load")
|
|
eq(s2.store.people[jan][:name], "Jan Novák", "person survives (integer id key rebuilt)")
|
|
eq(s2.store.person_by_e164("+420601234567")&.dig(:id), jan, "phone_number index rebuilt on load")
|
|
ev2 = s2.store.event_by_call_id("ps-1")
|
|
eq(ev2 && ev2[:duration_s], 42, "events survive")
|
|
eq(ev2 && ev2[:action_items]&.first&.dig("text"), "do x", "action_items keep STRING keys after load (not symbolized)")
|
|
newid = s2.add_person(name: "New Guy", operator: "tmobile")
|
|
truthy(newid > jan, "next_id continues past restored ids — no collision (@seq persisted)")
|
|
# corrupt-file resilience
|
|
bad = File.join(Dir.mktmpdir, "bad.json"); File.write(bad, "{not json")
|
|
truthy(Helpdesk::Store.load(bad).people.empty?, "corrupt snapshot -> fresh store, no crash")
|
|
|
|
puts "context: mark_summarised stores enriched fields"
|
|
s = svc
|
|
s.incoming(call_id: CID, ts: t(0), number: "601 234 567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.mark_summarised(call_id: CID, summary: "S", action_items: [], suggested_position: nil,
|
|
context_digest: "digest", caller_facts: ["Morava"],
|
|
ledger_deltas: [{ "entry_id" => nil, "question" => "reset?", "decision" => "no",
|
|
"quote" => "ne", "question_vec" => Helpdesk::Ledger.pack_vec([1.0, 0.0]) }])
|
|
ev = s.store.event_by_call_id(CID)
|
|
eq(ev[:context_digest], "digest", "context_digest stored on event")
|
|
eq(ev[:caller_facts], ["Morava"], "caller_facts stored")
|
|
eq(ev[:ledger_deltas].size, 1, "ledger_deltas stored (string-keyed)")
|
|
|
|
puts "context: apply_ledger_deltas builds the caller's ledger + is idempotent"
|
|
pid = ev[:person_id]
|
|
r = s.apply_ledger_deltas(person_id: pid, event_id: ev[:id])
|
|
eq(r[:ledger_size], 1, "one ledger entry created from the delta")
|
|
truthy(r[:changed], "changed=true")
|
|
again = s.apply_ledger_deltas(person_id: pid, event_id: ev[:id])
|
|
truthy(!again[:changed], "re-applying the same event is a no-op (idempotent)")
|
|
eq(s.store.people[pid][:resolutions_ledger].first["decision"], "no", "ledger entry decision persisted")
|
|
|
|
puts "context: person_active_ledger returns non-superseded, id/question/decision only"
|
|
al = s.person_active_ledger(pid)
|
|
eq(al.size, 1, "one active entry")
|
|
eq(al.first.keys.sort, %w[decision id question], "active-ledger rows carry id/question/decision only")
|
|
|
|
puts "context: resolutions_ledger survives dump/restore string-keyed"
|
|
dumped = JSON.parse(JSON.generate(s.store.dump))
|
|
restored = Helpdesk::Store.new.restore(dumped)
|
|
entry = restored.people[pid][:resolutions_ledger].first
|
|
truthy(entry.is_a?(Hash) && entry.key?("decision") && !entry.key?(:decision), "nested ledger entry stays string-keyed after restore")
|
|
|
|
puts "context: set_person_ai_context CAS"
|
|
s = svc
|
|
pid = s.create_person(name: "Zed", operator: "tmobile")[:id]
|
|
r = s.set_person_ai_context(person_id: pid, ai_context: { "summary" => "hi", "other_context" => [] }, expected_rev: 0)
|
|
truthy(r[:ok], "write at expected_rev 0 ok")
|
|
eq(r[:rev], 1, "rev bumped to 1")
|
|
stale = s.set_person_ai_context(person_id: pid, ai_context: { "summary" => "x", "other_context" => [] }, expected_rev: 0)
|
|
truthy(stale[:stale], "stale write (expected_rev 0, actual 1) rejected")
|
|
eq(s.store.people[pid][:ai_context]["summary"], "hi", "stale write did not clobber")
|
|
|
|
puts "context: pen-edit pins a section + guards; release clears it"
|
|
d1 = s.edit_person_ai_context(person_id: pid, section: "summary", value: "OPERATOR TEXT")
|
|
truthy(s.store.people[pid][:ai_context_edited], "edit sets ai_context_edited")
|
|
eq(s.store.people[pid][:ai_context_pinned_sections]["summary"], "OPERATOR TEXT", "summary section pinned verbatim")
|
|
s.release_ai_context_section(person_id: pid, section: "summary")
|
|
truthy(!s.store.people[pid][:ai_context_edited], "releasing the only pin clears ai_context_edited")
|
|
|
|
puts "context: resolve_contradiction acknowledges an entry"
|
|
s.store.people[pid][:resolutions_ledger] = [{ "id" => "ledA", "question" => "q", "decision" => "no", "contradiction" => true }]
|
|
s.resolve_contradiction(person_id: pid, entry_id: "ledA")
|
|
e = s.store.people[pid][:resolutions_ledger].first
|
|
truthy(e["acknowledged"] && !e.key?("contradiction"), "contradiction cleared + acknowledged")
|
|
|
|
puts "context: recompute_ledger == incremental application"
|
|
s2 = svc
|
|
s2.incoming(call_id: "c1", ts: t(0), number: "601 234 567", presentation: "allowed", dialed_did: "+420800111222")
|
|
ev1 = s2.store.event_by_call_id("c1"); p2 = ev1[:person_id]
|
|
s2.mark_summarised(call_id: "c1", summary: "S", context_digest: "d", caller_facts: [],
|
|
ledger_deltas: [{ "entry_id" => nil, "question" => "reset?", "decision" => "no", "quote" => "ne",
|
|
"question_vec" => Helpdesk::Ledger.pack_vec([1.0, 0.0]) }])
|
|
s2.apply_ledger_deltas(person_id: p2, event_id: ev1[:id])
|
|
incremental = s2.store.people[p2][:resolutions_ledger].map { |e| [e["question"], e["decision"]] }
|
|
s2.recompute_ledger(person_id: p2)
|
|
replayed = s2.store.people[p2][:resolutions_ledger].map { |e| [e["question"], e["decision"]] }
|
|
eq(replayed, incremental, "recompute_ledger reproduces the incremental decisions")
|
|
|
|
puts "context: notes_rev bumps on context change only"
|
|
before = s.store.people[pid][:notes_rev] || 0
|
|
s.set_person_context(person_id: pid, context: "note")
|
|
eq(s.store.people[pid][:notes_rev], before + 1, "set_person_context bumps notes_rev")
|
|
|
|
puts "context: person_detail carries ai_context + derived recently-* lists from resolution enum"
|
|
s = svc
|
|
s.incoming(call_id: "d1", ts: t(0), number: "601 234 567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s.mark_summarised(call_id: "d1", summary: "solved one", context_digest: "Vyřešeno: ping.", caller_facts: [], ledger_deltas: [])
|
|
s.set_notes(call_id: "d1", resolution: "solved")
|
|
pid = s.store.event_by_call_id("d1")[:person_id]
|
|
det = s.person_detail(pid)
|
|
eq(det[:ai_context], { "summary" => "", "other_context" => [] }, "ai_context defaults to empty structured object")
|
|
eq(det[:recently_solved].size, 1, "the solved call appears in recently_solved")
|
|
eq(det[:recently_solved].first[:line], "Vyřešeno: ping.", "derived line taken from context_digest")
|
|
eq(det[:recently_unsolved], [], "no unsolved calls -> empty list")
|
|
truthy(det.key?(:resolutions_ledger), "resolutions_ledger surfaced")
|
|
|
|
puts "context: create_person backlinks prior unowned events by number (never steals live-owned)"
|
|
s = svc
|
|
s.incoming(call_id: "orphan", ts: t(0), number: "+420777000111", presentation: "allowed", dialed_did: "+420800111222")
|
|
truthy(s.store.event_by_call_id("orphan")[:person_id].nil?, "unknown caller's event starts unowned")
|
|
det = s.create_person(name: "New Guy", operator: "tmobile", numbers: ["+420777000111"])
|
|
eq(s.store.event_by_call_id("orphan")[:person_id], det[:id], "prior event backlinked to the new contact")
|
|
|
|
puts "context: maintainer input snapshot shape"
|
|
inp = s.context_maintainer_input(det[:id], mode: :rebuild)
|
|
truthy(inp[:ai_context] && inp.key?(:digests) && inp.key?(:ledger) && inp.key?(:pinned_sections), "snapshot carries maintainer inputs")
|
|
|
|
puts "context: rebuild vs incremental snapshot shapes (FIX 1 guard)"
|
|
# Set up a person with at least one transcribed event so transcript list is non-empty
|
|
s_fix1 = svc
|
|
s_fix1.incoming(call_id: "fix1-1", ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222")
|
|
s_fix1.mark_transcribed(call_id: "fix1-1", transcript: "hovor o platbe")
|
|
s_fix1.mark_summarised(call_id: "fix1-1", summary: "platba", action_items: [], context_digest: "digest1",
|
|
caller_facts: [], ledger_deltas: [])
|
|
pid_fix1 = s_fix1.store.event_by_call_id("fix1-1")[:person_id]
|
|
rebuild_inp = s_fix1.context_maintainer_input(pid_fix1, mode: :rebuild)
|
|
incr_inp = s_fix1.context_maintainer_input(pid_fix1, mode: :incremental)
|
|
truthy(rebuild_inp[:transcripts].any?, "rebuild snapshot includes last-<=5 transcripts (non-empty)")
|
|
eq(incr_inp[:transcripts], [], "incremental snapshot returns transcripts: [] (full history excluded)")
|
|
|
|
puts "resolve_contradiction: acknowledge keeps the current decision; a different decision overrides append-only"
|
|
s_rc = Helpdesk::Service.new(Helpdesk::Store.new)
|
|
rc_pid = s_rc.add_person(name: "RC", operator: "tmobile")
|
|
rc_ledger = lambda do
|
|
[{ "id" => "a", "question" => "Smí X?", "decision" => "no", "last_at" => "2026-07-10T09:00:00Z", "quote" => "ne", "contradiction" => true, "superseded_by" => "b" },
|
|
{ "id" => "b", "question" => "Smí X?", "decision" => "yes", "last_at" => "2026-07-20T09:00:00Z", "quote" => "ano", "contradiction" => true }]
|
|
end
|
|
pp = s_rc.store.people[rc_pid]; pp[:resolutions_ledger] = rc_ledger.call; s_rc.store.put_person(pp)
|
|
s_rc.resolve_contradiction(person_id: rc_pid, entry_id: "b") # acknowledge (no decision)
|
|
led = s_rc.store.people[rc_pid][:resolutions_ledger]; act = led.reject { |e| e["superseded_by"] }
|
|
eq(act.map { |e| e["decision"] }, ["yes"], "acknowledge: current decision stands, one active entry")
|
|
truthy(led.none? { |e| e["contradiction"] }, "acknowledge: conflict flag cleared")
|
|
pp = s_rc.store.people[rc_pid]; pp[:resolutions_ledger] = rc_ledger.call; s_rc.store.put_person(pp)
|
|
s_rc.resolve_contradiction(person_id: rc_pid, entry_id: "b", decision: "no") # operator overrides to NO
|
|
led = s_rc.store.people[rc_pid][:resolutions_ledger]; act = led.reject { |e| e["superseded_by"] }
|
|
eq(act.map { |e| e["decision"] }, ["no"], "override: active decision is the operator's choice")
|
|
eq(act.first["source"], "operator", "override: active entry is operator-sourced")
|
|
eq(led.size, 3, "override: append-only (both AI entries kept + the operator entry)")
|
|
truthy(led.none? { |e| e["contradiction"] }, "override: conflict flag cleared")
|
|
|
|
puts "add_resolution: operator records a yes/no by hand; a repeat question supersedes append-only"
|
|
s_ar = Helpdesk::Service.new(Helpdesk::Store.new)
|
|
ar_pid = s_ar.add_person(name: "AR", operator: "vodafone")
|
|
s_ar.add_resolution(person_id: ar_pid, question: "Přístup do věží Ježlovice?", decision: "no")
|
|
led = s_ar.store.people[ar_pid][:resolutions_ledger]
|
|
eq(led.size, 1, "add_resolution: first entry created on an empty ledger")
|
|
eq(led.first["decision"], "no", "add_resolution: decision stored")
|
|
eq(led.first["source"], "operator", "add_resolution: source tagged operator")
|
|
truthy(led.first["id"].start_with?("led-op-"), "add_resolution: operator-id prefix")
|
|
s_ar.add_resolution(person_id: ar_pid, question: " přístup do věží ježlovice? ", decision: "yes") # same Q, whitespace/case
|
|
led = s_ar.store.people[ar_pid][:resolutions_ledger]; act = led.reject { |e| e["superseded_by"] }
|
|
eq(led.size, 2, "add_resolution: repeat appends (append-only)")
|
|
eq(act.map { |e| e["decision"] }, ["yes"], "add_resolution: repeat supersedes -> newest active")
|
|
eq(s_ar.add_resolution(person_id: ar_pid, question: "x", decision: "maybe")[:error], "bad_decision", "add_resolution: rejects a non-yes/no decision")
|
|
eq(s_ar.add_resolution(person_id: ar_pid, question: " ", decision: "no")[:error], "bad_question", "add_resolution: rejects a blank question")
|
|
eq(s_ar.add_resolution(person_id: 999_999, question: "x", decision: "no"), nil, "add_resolution: unknown person -> nil")
|
|
|
|
puts "edit_resolution: any active entry is editable, append-only supersede by id"
|
|
s_ed = Helpdesk::Service.new(Helpdesk::Store.new)
|
|
ed_pid = s_ed.add_person(name: "ED", operator: "tmobile")
|
|
pp = s_ed.store.people[ed_pid]
|
|
pp[:resolutions_ledger] = [{ "id" => "led7", "question" => "Smí instalovat repeater?", "decision" => "yes",
|
|
"first_at" => "2026-07-01T09:00:00Z", "last_at" => "2026-07-01T09:00:00Z", "quote" => "ano" }]
|
|
s_ed.store.put_person(pp)
|
|
s_ed.edit_resolution(person_id: ed_pid, entry_id: "led7", question: "Smí instalovat repeater bez souhlasu?", decision: "no")
|
|
led = s_ed.store.people[ed_pid][:resolutions_ledger]; act = led.reject { |e| e["superseded_by"] }
|
|
eq(led.size, 2, "edit_resolution: append-only (original kept, superseded)")
|
|
eq(led.find { |e| e["id"] == "led7" }["superseded_by"].nil?, false, "edit_resolution: original marked superseded")
|
|
eq(act.map { |e| e["decision"] }, ["no"], "edit_resolution: newest active carries the edited decision")
|
|
eq(act.first["question"], "Smí instalovat repeater bez souhlasu?", "edit_resolution: wording can change")
|
|
eq(act.first["source"], "operator", "edit_resolution: edit is operator-sourced")
|
|
eq(act.first["first_at"], "2026-07-01T09:00:00Z", "edit_resolution: first_at preserved from the original")
|
|
eq(s_ed.edit_resolution(person_id: ed_pid, entry_id: "led7", question: "x", decision: "no")[:error], "not_found", "edit_resolution: a superseded entry can't be edited")
|
|
eq(s_ed.edit_resolution(person_id: ed_pid, entry_id: "nope", question: "x", decision: "no")[:error], "not_found", "edit_resolution: unknown entry -> not_found")
|
|
eq(s_ed.edit_resolution(person_id: ed_pid, entry_id: act.first["id"], question: "x", decision: "maybe")[:error], "bad_decision", "edit_resolution: rejects a bad decision")
|
|
eq(s_ed.edit_resolution(person_id: 999_999, entry_id: "x", question: "y", decision: "no"), nil, "edit_resolution: unknown person -> nil")
|
|
|
|
puts "\n#{$pass} passed, #{$fail} failed"
|
|
exit($fail.zero? ? 0 : 1)
|