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.
102 lines
4.6 KiB
Ruby
102 lines
4.6 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
# Reporting — KPI + billing rollups computed from the Event stream (Service#report feeds the console).
|
||
# Pure functions over event_view hashes; nothing is stored derived — every number recomputes from Events
|
||
# so a filed report is reproducible from a frozen set of Event ids.
|
||
#
|
||
# Metric definitions (all documented — these are DELIBERATE choices, not survey-standard):
|
||
# • calls_handled — inbound calls in the period; `answered` = a human picked up (answered_at present).
|
||
# • aht_s — Average Handle Time = mean talk time (duration_s) over answered calls. NOTE: talk-time
|
||
# only until the Dialer exposes hold/wrap-up time (then it becomes full COPC-style AHT).
|
||
# • fcr — First-Call Resolution, INTERNAL method (NOT survey-FCR; differs from the SQM 69–71% band):
|
||
# of the FIRST calls (no prior_event_id) that were triaged (resolution set), the share marked
|
||
# `solved` with NO repeat call from the same person within the RCR window. `partial`/`unsolved`
|
||
# count as not-resolved.
|
||
# • repeat_call_rate — share of handled calls that ARE a repeat (prior_event_id set).
|
||
# • resolution_breakdown — counts of solved/partial/unsolved/untriaged.
|
||
# • Percentages are SUPPRESSED (nil) below `min_n` handled calls to avoid small-N noise.
|
||
|
||
require "time"
|
||
|
||
module Helpdesk
|
||
module Report
|
||
module_function
|
||
|
||
def build(events, from: nil, to: nil, operator: nil, campaign: nil,
|
||
rcr_window_days: 7, min_n: 5, retainer_czk: nil, whatsapp_surcharge_czk: 100)
|
||
all = events.map { |e| symbolize(e) }
|
||
window = rcr_window_days * 86_400
|
||
|
||
inperiod = all.select do |e|
|
||
t = ts(e[:started_at]); next false unless t
|
||
(from.nil? || t >= time(from)) && (to.nil? || t < time(to)) &&
|
||
(operator.nil? || e[:operator] == operator) && (campaign.nil? || e[:campaign] == campaign)
|
||
end
|
||
|
||
{
|
||
period: { from: from, to: to }, filter: { operator: operator, campaign: campaign },
|
||
rcr_window_days: rcr_window_days,
|
||
**kpis(inperiod, all, window, min_n),
|
||
resolution_breakdown: resolution_breakdown(inperiod),
|
||
surcharges: surcharges(inperiod, whatsapp_surcharge_czk),
|
||
retainer_czk: retainer_czk,
|
||
by_operator: group(inperiod, all, window, min_n) { |e| e[:operator] || "unrouted" },
|
||
by_campaign: group(inperiod, all, window, min_n) { |e| e[:campaign] || "—" },
|
||
}
|
||
end
|
||
|
||
# --- core KPIs over a set of calls -----------------------------------------
|
||
def kpis(calls, universe, window, min_n)
|
||
total = calls.size
|
||
answered = calls.count { |e| e[:answered_at] }
|
||
durs = calls.select { |e| e[:answered_at] && e[:duration_s] }.map { |e| e[:duration_s] }
|
||
repeats = calls.count { |e| e[:prior_event_id] }
|
||
|
||
firsts = calls.select { |e| e[:prior_event_id].nil? }
|
||
triaged_firsts = firsts.select { |e| e[:resolution] }
|
||
fcr_pos = triaged_firsts.count { |e| e[:resolution] == "solved" && !called_back?(e, universe, window) }
|
||
|
||
{
|
||
calls_handled: total, answered: answered, missed: total - answered,
|
||
aht_s: durs.empty? ? nil : (durs.sum.to_f / durs.size).round,
|
||
fcr: pct(fcr_pos, triaged_firsts.size, total, min_n),
|
||
repeat_call_rate: pct(repeats, total, total, min_n),
|
||
}
|
||
end
|
||
|
||
def resolution_breakdown(calls)
|
||
b = { "solved" => 0, "partial" => 0, "unsolved" => 0, "untriaged" => 0 }
|
||
calls.each { |e| b[e[:resolution] || "untriaged"] += 1 }
|
||
b
|
||
end
|
||
|
||
def surcharges(calls, whatsapp_czk)
|
||
wa = calls.count { |e| e[:channel] == "whatsapp" }
|
||
{ whatsapp: { count: wa, unit_czk: whatsapp_czk, total_czk: wa * whatsapp_czk } }
|
||
end
|
||
|
||
def group(calls, universe, window, min_n)
|
||
calls.group_by { |e| yield(e) }.transform_values do |grp|
|
||
kpis(grp, universe, window, min_n).merge(resolution_breakdown: resolution_breakdown(grp))
|
||
end
|
||
end
|
||
|
||
# --- helpers ---------------------------------------------------------------
|
||
# did anyone call back (a later event linking to this one) within the window?
|
||
def called_back?(call, universe, window)
|
||
base = ts(call[:started_at]); return false unless base
|
||
universe.any? do |e|
|
||
e[:prior_event_id] == call[:id] && (t = ts(e[:started_at])) && t > base && (t - base) <= window
|
||
end
|
||
end
|
||
|
||
def pct(num, den, total, min_n)
|
||
return nil if den.zero? || total < min_n # suppress small-N
|
||
((num.to_f / den) * 100).round(1)
|
||
end
|
||
|
||
def ts(v) = v ? (Time.parse(v.to_s) rescue nil) : nil
|
||
def time(v) = v.is_a?(Time) ? v : Time.parse(v.to_s)
|
||
def symbolize(e) = e.is_a?(Hash) ? e.transform_keys(&:to_sym) : e
|
||
end
|
||
end
|