Prd/components/backend/db/parity_check.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

100 lines
5.2 KiB
Ruby

# frozen_string_literal: true
# Parity harness: run an IDENTICAL scripted sequence of Service operations against the JSON Store and
# PgStore, then diff the observable results. Any divergence is a bug. This is what proves the two
# backends behave the same, so the store choice (HELPDESK_DATABASE_URL) stays a pure config switch.
# Uses a fixed clock for determinism and a fresh (truncated) helpdesk_test DB for the PG side.
# HELPDESK_TEST_DATABASE_URL=postgres://... ruby db/parity_check.rb
require_relative "../lib/helpdesk/domain"
require_relative "../lib/helpdesk/pg_store"
TEST_URL = ENV["HELPDESK_TEST_DATABASE_URL"] or abort "set HELPDESK_TEST_DATABASE_URL"
CLOCK = -> { Time.utc(2026, 7, 23, 12, 0, 0) } # fixed clock
def fresh_pg
db = Sequel.connect(TEST_URL)
db.run "TRUNCATE orgs, campaigns, dids, people, phone_numbers, events, quarantine;
ALTER SEQUENCE org_id_seq RESTART; ALTER SEQUENCE campaign_id_seq RESTART;
ALTER SEQUENCE person_id_seq RESTART; ALTER SEQUENCE event_id_seq RESTART;
ALTER SEQUENCE ledger_seq RESTART;"
db.disconnect
Helpdesk::Service.new(Helpdesk::PgStore.new(TEST_URL), clock: CLOCK)
end
def fresh_json = Helpdesk::Service.new(Helpdesk::Store.new, clock: CLOCK)
# The identical script. Returns an ordered list of [label, observable-result] to diff. Runs the
# formerly-in-place-mutating paths: webhooks, notes/resolution, people CRUD, AI-context CAS + pen-edit,
# ledger apply, reconcile, command apply, quarantine reattach race.
def run_script(svc)
out = []
ts = "2026-07-23T11:00:00Z"
# a full inbound call lifecycle
svc.incoming(call_id: "c1", ts: ts, number: "+420602345678", presentation: "allowed", device_id: "dev1")
svc.answered(call_id: "c1", ts: "2026-07-23T11:00:05Z")
svc.ended(call_id: "c1", ts: "2026-07-23T11:01:00Z", duration_s: 55, disconnect_cause: "local")
svc.recording_uploaded(recording_uuid: "u1", call_id: "c1", url: "/rec/u1", audio_path: "/tmp/u1.audio")
svc.mark_transcribed(call_id: "c1", transcript: "ahoj svete")
svc.mark_summarised(call_id: "c1", summary: "test call", action_items: [{ "text" => "do x", "kind" => "doc" }],
caller_facts: ["fact a"], context_digest: "digest a")
svc.set_notes(call_id: "c1", notes: "operator note", resolution: "solved")
out << ["c1", svc.event_by_call_id("c1")]
# quarantine-before-incoming race
svc.recording_uploaded(recording_uuid: "u2", call_id: "c2", url: "/rec/u2", audio_path: "/tmp/u2.audio")
svc.incoming(call_id: "c2", ts: "2026-07-23T11:05:00Z", number: "+420602345678", presentation: "allowed")
out << ["c2_reattached_rec", svc.event_by_call_id("c2")[:recording_url]]
# outgoing
svc.outgoing(call_id: "c3", ts: "2026-07-23T11:10:00Z", number: "+420777111222", device_id: "dev1")
out << ["c3_dir", svc.event_by_call_id("c3")[:direction]]
# people CRUD
pd = svc.create_person(name: "Alice", operator: "tmobile", numbers: ["+420602345678"], email: "a@x.cz")
pid = pd[:id]
svc.update_person(person_id: pid, position: "technik", context: "vip")
svc.set_person_context(person_id: pid, context: "vip caller")
out << ["person", svc.person_detail(pid)&.slice(:name, :position, :context, :operator, :notes_rev)]
# AI-context CAS + pen-edit + release
svc.set_person_ai_context(person_id: pid, ai_context: { "summary" => "S1", "other_context" => ["o1"] }, expected_rev: 0,
built_from: { "model" => "gemini", "at" => "2026-07-23T11:11:00Z" })
stale = svc.set_person_ai_context(person_id: pid, ai_context: { "summary" => "STALE" }, expected_rev: 0) # wrong rev
svc.edit_person_ai_context(person_id: pid, section: "summary", value: "PINNED")
svc.release_ai_context_section(person_id: pid, section: "summary")
p = svc.person_detail(pid)
out << ["ai", { ctx: p[:ai_context], rev: p[:ai_context_rev], stale: stale[:stale] }]
# reconcile (nothing should trip with the fixed near-time clock, but exercise it)
svc.reconcile!
out << ["people_count", svc.store.people.size]
out << ["event_count", svc.store.events.size]
# command apply (mute/hold live attrs + dtmf) on the active-ish call
svc.incoming(call_id: "c4", ts: "2026-07-23T11:20:00Z", number: "+420777111222", presentation: "allowed")
svc.answered(call_id: "c4", ts: "2026-07-23T11:20:03Z")
svc.apply_command(id: "k1", call_id: "c4", verb: "mute")
svc.apply_command(id: "k2", call_id: "c4", verb: "hold")
svc.apply_command(id: "k3", call_id: "c4", verb: "dtmf", arg: "5")
e4 = svc.event_by_call_id("c4")
out << ["c4_attrs", { muted: e4[:muted], on_hold: e4[:on_hold], dtmf: e4[:dtmf_log] }]
out
end
def normalize(o)
# stable, backend-agnostic comparison: strip clock-derived nondeterminism is unnecessary (fixed clock),
# but drop nils vs missing keys mismatch by deep JSON round-trip (both -> plain JSON structures).
JSON.parse(JSON.generate(o))
end
json = run_script(fresh_json)
pg = run_script(fresh_pg)
fails = 0
json.each_with_index do |(label, jv), i|
plabel, pv = pg[i]
jn = normalize(jv); pn = normalize(pv)
if jn == pn
puts " ok #{label}"
else
fails += 1
puts " DIVERGE #{label}"
puts " json: #{jn.inspect[0, 300]}"
puts " pg: #{pn.inspect[0, 300]}"
end
end
puts "\n#{json.size - fails}/#{json.size} parity checks passed"
exit(fails.zero? ? 0 : 1)