Prd/components/backend/lib/helpdesk/pg_store.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

186 lines
9.9 KiB
Ruby

# frozen_string_literal: true
# PgStore — a PostgreSQL-backed drop-in for Helpdesk::Store (the JSON Store lives in domain.rb).
# Same public interface as the JSON Store: the 9 collection attr_readers, next_id, the three indexes,
# and the explicit write interface (put_*/delete_*/cas_person_ai_context). Only plain Ruby hashes cross
# the seam — no Sequel objects leak into Service.
#
# Model: a WRITE-THROUGH in-memory CACHE. All durable rows are loaded into symbol-keyed record hashes at
# boot (jsonb columns → plain string-keyed Ruby, timestamptz → "...Z" ISO8601 strings) so Service READS
# (`events.values.find`, `people[id]`, `each_value`) are unchanged. Every write goes through an explicit
# put_*/delete_* that updates the cache AND UPSERTs/DELETEs the row (parameterized via Sequel). Transient
# broadcasts/commands stay in-memory only (never persisted — same as the JSON Store).
#
# Single-writer-process assumption: the cache is authoritative within the one WEBrick process; the
# reentrant Monitor in Service serializes writes. cas_person_ai_context still takes a real
# SELECT..FOR UPDATE row lock so the AI-memory read-modify-write is correct even multi-process.
# Pin the two runtime gems at the require site: bundler is not installed on the server and the app is
# deliberately launched with plain `ruby` (dev/prod parity, no bundler dependency).
# These activate the intended versions from the user-install gem path and fail loud if a wrong major/minor
# is present. See components/backend/Gemfile for the manifest used by `bundle install` on a server.
gem "pg", "~> 1.5"
gem "sequel", "~> 5.106"
require "sequel"
require "json"
require "time"
Sequel.extension :pg_json
module Helpdesk
class PgStore
attr_reader :people, :phone_numbers, :orgs, :campaigns, :dids, :events, :quarantine, :broadcasts, :commands
# column spec per table (mirrors db/migrate/001_init.sql). jsonb: plain-Ruby<->pg_jsonb; ts: string<->timestamptz.
SPECS = {
orgs: { table: :orgs, pk: :id, cols: %i[id name operator], jsonb: [], ts: [] },
campaigns: { table: :campaigns, pk: :id, cols: %i[id name operator], jsonb: [], ts: [] },
dids: { table: :dids, pk: :e164, cols: %i[e164 operator campaign_id], jsonb: [], ts: [] },
people: { table: :people, pk: :id, cols: %i[id name operator email context position org_id
source notes_rev ai_context ai_context_rev
ai_context_pinned_sections ai_context_edited
ai_context_built_from resolutions_ledger
ledger_applied_events],
jsonb: %i[ai_context ai_context_pinned_sections ai_context_built_from resolutions_ledger
ledger_applied_events], ts: [] },
phone_numbers: { table: :phone_numbers, pk: :e164, cols: %i[e164 person_id raw_input label], jsonb: [], ts: [] },
events: { table: :events, pk: :id, cols: %i[id call_id person_id campaign_id did_id
recording_uuid recording_url started_at answered_at
ended_at duration_s direction operator_context
presentation state notes transcript ai_summary
action_items resolution prior_event_id
pomoc_ticket_id device_id flags muted on_hold
audio_route dtmf_log disconnect_cause number
dialed_did audio_path context_digest caller_facts
ledger_deltas failure_reason failure_retryable
suggested_position],
jsonb: %i[action_items flags dtmf_log caller_facts ledger_deltas],
ts: %i[started_at answered_at ended_at] }
}.freeze
SEQ = { org: "org_id_seq", person: "person_id_seq", campaign: "campaign_id_seq",
event: "event_id_seq", ledger: "ledger_seq" }.freeze
def initialize(url)
@db = Sequel.connect(url, max_connections: 10)
@db.extension :pg_json
@broadcasts = Hash.new { |h, k| h[k] = [] }
@commands = Hash.new { |h, k| h[k] = [] }
reload!
end
attr_reader :db
# (re)build every cache from the DB. Called at boot; also usable after the importer.
def reload!
@orgs = index(load_all(:orgs), :id)
@campaigns = index(load_all(:campaigns), :id)
@people = index(load_all(:people), :id)
@phone_numbers = index(load_all(:phone_numbers), :e164)
@dids = index(load_all(:dids), :e164)
@events = index(load_all(:events), :id)
@quarantine = @db[:quarantine].all.each_with_object({}) do |row, acc|
acc[row[:recording_uuid]] = { call_id: row[:call_id], url: row[:url],
audio_path: row[:audio_path], at: iso(row[:at]) }
end
self
end
# ---- indexes (over the cache — identical semantics to the JSON Store) ----
def person_by_e164(e164) = @phone_numbers[e164]&.then { |pn| @people[pn[:person_id]] }
def did_by_e164(e164) = @dids[e164]
def event_by_call_id(cid) = @events.values.find { |e| e[:call_id] == cid }
def next_id(kind) = @db.get(Sequel.function(:nextval, SEQ.fetch(kind))).to_i
# ---- write interface (DB write first, then cache; a DB error propagates and leaves the cache clean) ----
def put_org(o) = (upsert(:orgs, o); @orgs[o[:id]] = o)
def put_campaign(c) = (upsert(:campaigns, c); @campaigns[c[:id]] = c)
def put_person(p) = (upsert(:people, p); @people[p[:id]] = p)
def put_event(ev) = (upsert(:events, ev); @events[ev[:id]] = ev)
def put_phone_number(pn) = (upsert(:phone_numbers, pn); @phone_numbers[pn[:e164]] = pn)
def put_did(d) = (upsert(:dids, d); @dids[d[:e164]] = d)
def put_quarantine(uuid, h)
@db[:quarantine].insert_conflict(
target: :recording_uuid,
update: { call_id: Sequel[:excluded][:call_id], url: Sequel[:excluded][:url],
audio_path: Sequel[:excluded][:audio_path], at: Sequel[:excluded][:at] }
).insert(recording_uuid: uuid, call_id: h[:call_id], url: h[:url],
audio_path: h[:audio_path], at: parse_ts(h[:at]))
@quarantine[uuid] = h
end
def delete_person(id) = (@db[:people].where(id: id).delete; @people.delete(id))
def delete_phone_number(e164) = (@db[:phone_numbers].where(e164: e164).delete; @phone_numbers.delete(e164))
def delete_quarantine(uuid) = (@db[:quarantine].where(recording_uuid: uuid).delete; @quarantine.delete(uuid))
# AI-memory compare-and-set under a real row lock (correct multi-process). LLM call is done by the
# caller OUTSIDE this method / txn. Returns the updated person hash, or nil if stale (caller retries).
def cas_person_ai_context(id, expected_rev, ai_context, built_from)
ok = false
@db.transaction do
row = @db[:people].where(id: id).for_update.first
break if row.nil? || (row[:ai_context_rev] || 0) != expected_rev
@db[:people].where(id: id).update(
ai_context: Sequel.pg_jsonb(ai_context),
ai_context_rev: expected_rev + 1,
ai_context_built_from: built_from ? Sequel.pg_jsonb(built_from) : Sequel[:ai_context_built_from]
)
ok = true
end
return nil unless ok
p = @people[id]
p[:ai_context] = ai_context
p[:ai_context_rev] = expected_rev + 1
p[:ai_context_built_from] = built_from if built_from
p
end
# transaction seam for multi-row Service ops (update_person/delete_person/upsert_contacts). The block
# runs inside a DB transaction; Service still calls put_*/delete_* which participate in it.
def transaction(&blk) = @db.transaction(&blk)
def save(_path) = nil # no-op: writes are immediate
private
def index(list, key) = list.each_with_object({}) { |r, acc| acc[r[key]] = r }
def load_all(kind) = @db[SPECS[kind][:table]].all.map { |row| row_to_record(kind, row) }
def upsert(kind, rec)
spec = SPECS[kind]
row = record_to_row(kind, rec)
update = spec[:cols].reject { |c| c == spec[:pk] }
.each_with_object({}) { |c, u| u[c] = Sequel[:excluded][c] }
@db[spec[:table]].insert_conflict(target: spec[:pk], update: update).insert(row)
end
def record_to_row(kind, rec)
spec = SPECS[kind]
spec[:cols].each_with_object({}) do |c, row|
v = rec[c]
row[c] = if spec[:jsonb].include?(c) then (v.nil? ? nil : Sequel.pg_jsonb(v))
elsif spec[:ts].include?(c) then parse_ts(v)
else v
end
end
end
def row_to_record(kind, row)
spec = SPECS[kind]
spec[:cols].each_with_object({}) do |c, rec|
v = row[c]
rec[c] = if spec[:jsonb].include?(c) then (v.nil? ? nil : plainify(v))
elsif spec[:ts].include?(c) then iso(v)
else v
end
end
end
# jsonb <-> plain string-keyed Ruby (matches the "nested = string keys" contract). Sequel's pg_json
# returns JSONBHash/JSONBArray wrappers on read; deep-convert to plain Hash/Array.
def plainify(o) = JSON.parse(JSON.generate(o))
def parse_ts(v) = v.nil? ? nil : (v.is_a?(Time) ? v : Time.parse(v).utc)
def iso(v) = v.nil? ? nil : (v.is_a?(String) ? v : v.utc.iso8601)
end
end