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

102 lines
4.6 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 6971% 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