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.
212 lines
11 KiB
Ruby
212 lines
11 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
# Summariser — turns a [Speaker N] transcript into a concise Czech summary + suggested (advisory)
|
||
# action items, via OpenRouter's OpenAI-compatible chat/completions API. Standard library only
|
||
# (net/http + json).
|
||
#
|
||
# Robustness: strict json_schema is only PARTIALLY reliable for Gemini via OpenRouter, so we
|
||
# (1) request Structured Outputs with provider.require_parameters, (2) VALIDATE the
|
||
# reply client-side, and (3) fall back to json_object and, failing that, flag needs_human_review — we
|
||
# never trust the typed contract blindly. Everything returned is ADVISORY; humans set resolution.
|
||
#
|
||
# The HTTP transport is injectable (opts[:transport]) so this is fully testable without a key/network.
|
||
|
||
require "json"
|
||
require "net/http"
|
||
require "uri"
|
||
|
||
module Helpdesk
|
||
class Summariser
|
||
ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
|
||
# Pinned (stable). gemini-3.6-flash: newest Gemini 3 (1M context, honors strict json_schema); a step
|
||
# up from 2.5-pro on Czech telco transcripts, and cost is immaterial at helpdesk volume. Override with
|
||
# SUMMARISER_MODEL env. Never use a floating alias (a wrong/nonexistent slug fails as a silent 404).
|
||
MODEL = "google/gemini-3.6-flash"
|
||
KINDS = %w[doc tool_hint training other].freeze
|
||
DECISIONS = %w[yes no conditional].freeze
|
||
MAX_DELTAS = 8
|
||
MAX_QUOTE = 200
|
||
|
||
# Base output schema TEMPLATE. Built per-request via output_schema(ledger_ids) so entry_id can carry
|
||
# the caller's active ids as an enum. NOT frozen-shared-mutated: the builder deep-dups.
|
||
OUTPUT_SCHEMA_BASE = {
|
||
"type" => "object", "additionalProperties" => false,
|
||
"required" => %w[summary suggested_position action_items context_digest caller_facts ledger_deltas],
|
||
"properties" => {
|
||
"summary" => { "type" => "string", "description" => "Concise Czech summary, 2–4 sentences." },
|
||
"suggested_position" => { "type" => "string", "description" => "Caller's likely role in Czech; \"\" if unclear." },
|
||
"action_items" => { "type" => "array", "items" => {
|
||
"type" => "object", "additionalProperties" => false, "required" => %w[text kind],
|
||
"properties" => {
|
||
"text" => { "type" => "string" }, "kind" => { "type" => "string", "enum" => KINDS },
|
||
"target" => { "type" => "string" } } } },
|
||
"context_digest" => { "type" => "string",
|
||
"description" => "Thorough, de-babbled account of the WHOLE call in Czech — every fact/ask/decision, " \
|
||
"no greetings/repetition/diarization noise. Max ~250 words." },
|
||
"caller_facts" => { "type" => "array", "items" => { "type" => "string" },
|
||
"description" => "Durable caller-specific facts (region, kit, recurring asks, preferences). [] if none." },
|
||
"ledger_deltas" => { "type" => "array", "items" => {
|
||
"type" => "object", "additionalProperties" => false, "required" => %w[entry_id question decision quote],
|
||
"properties" => {
|
||
"entry_id" => { "type" => %w[string null] },
|
||
"question" => { "type" => "string" },
|
||
"decision" => { "type" => "string", "enum" => DECISIONS },
|
||
"quote" => { "type" => "string", "description" => "Verbatim span from the transcript." } } },
|
||
"description" => "Explicit yes/no/conditional decisions from THIS call. Reuse an existing entry_id " \
|
||
"when the ask matches; else entry_id=null. Empty array if none. Max #{MAX_DELTAS}." } } }.freeze
|
||
|
||
# deep-dup the frozen template + inject the active entry_id enum (omit when the ledger is empty).
|
||
def output_schema(ledger_ids)
|
||
schema = Marshal.load(Marshal.dump(OUTPUT_SCHEMA_BASE)) # deep, unfrozen copy
|
||
unless ledger_ids.nil? || ledger_ids.empty?
|
||
schema["properties"]["ledger_deltas"]["items"]["properties"]["entry_id"]["enum"] = ledger_ids + [nil]
|
||
end
|
||
schema
|
||
end
|
||
|
||
def initialize(api_key: ENV["OPENROUTER_API_KEY"], model: (ENV["SUMMARISER_MODEL"] || MODEL), context_pack: nil,
|
||
context_pack_path: File.join(__dir__, "context_pack.md"),
|
||
temperature: 0.1, max_tokens: 2500, reasoning_effort: "low",
|
||
endpoint: ENDPOINT, transport: nil, app_title: "Helpdesk")
|
||
@api_key = api_key
|
||
@model = model
|
||
@pack = context_pack || (File.exist?(context_pack_path) ? File.read(context_pack_path) : "")
|
||
@temperature = temperature
|
||
@max_tokens = max_tokens
|
||
@reasoning_effort = reasoning_effort
|
||
@endpoint = endpoint
|
||
@transport = transport || method(:http_transport)
|
||
@custom_transport = !transport.nil?
|
||
@app_title = app_title
|
||
@transcript = ""
|
||
end
|
||
|
||
# → { ok:, summary:, action_items:, context_digest:, caller_facts:, ledger_deltas:, model:, cost:, usage:, needs_review?:, error?:, raw?: }
|
||
def summarise(transcript, operator: nil, campaign: nil, wiki_context: nil, ledger: [])
|
||
return { ok: false, error: "OPENROUTER_API_KEY not set" } if @api_key.to_s.empty? && !@custom_transport
|
||
@transcript = transcript.to_s # kept for the quote-verbatim check in normalize_deltas
|
||
meta = { operator: operator, campaign: campaign, wiki_context: wiki_context, ledger: Array(ledger) }
|
||
r = attempt(request_body(transcript, meta, mode: :schema))
|
||
return r if r[:ok] || !r[:parse_failed]
|
||
# fall back to a looser json_object request if the strict schema wasn't honoured
|
||
attempt(request_body(transcript, meta, mode: :object)).tap { |x| x[:fell_back] = true if x[:ok] }
|
||
end
|
||
|
||
# Pure, testable: the exact request body we POST (returns a JSON string).
|
||
def request_body(transcript, meta = {}, mode: :schema)
|
||
ledger_ids = Array(meta[:ledger]).map { |e| e["id"] }.compact
|
||
body = {
|
||
"model" => @model, "temperature" => @temperature, "max_tokens" => @max_tokens,
|
||
"messages" => [
|
||
{ "role" => "system", "content" => system_prompt },
|
||
{ "role" => "user", "content" => user_prompt(transcript, meta) },
|
||
],
|
||
}
|
||
if mode == :schema
|
||
body["response_format"] = { "type" => "json_schema",
|
||
"json_schema" => { "name" => "call_summary", "strict" => true, "schema" => output_schema(ledger_ids) } }
|
||
body["provider"] = { "require_parameters" => true }
|
||
else
|
||
body["response_format"] = { "type" => "json_object" }
|
||
end
|
||
body["reasoning"] = { "effort" => @reasoning_effort } if @reasoning_effort
|
||
JSON.generate(body)
|
||
end
|
||
|
||
def headers
|
||
{ "Authorization" => "Bearer #{@api_key}", "Content-Type" => "application/json",
|
||
"HTTP-Referer" => "https://helpdesk.local", "X-Title" => @app_title }
|
||
end
|
||
|
||
private
|
||
|
||
def system_prompt
|
||
"#{@pack}\n\n---\nRespond ONLY with a JSON object matching the schema: a Czech `summary`, a " \
|
||
"`suggested_position` (the caller's likely role/position at their company, inferred from the call, in " \
|
||
"Czech; empty string if unclear), and an `action_items` array (each {text, kind∈#{KINDS.join('|')}, " \
|
||
"target?}). No prose outside the JSON." \
|
||
" Kromě 'summary' vrať i 'context_digest' (podrobný přepis bez žvástů), 'caller_facts' (trvalé fakty" \
|
||
" o volajícím) a 'ledger_deltas' (rozhodnutí ano/ne/podmíněně s doslovným 'quote')."
|
||
end
|
||
|
||
def user_prompt(transcript, meta)
|
||
m = [meta[:operator] && "operátor=#{meta[:operator]}", meta[:campaign] && "kampaň=#{meta[:campaign]}"].compact.join(", ")
|
||
wiki = meta[:wiki_context].to_s.strip
|
||
wiki_block = wiki.empty? ? "" : "Relevantní úryvky z interní wiki (ZDROJE, uvedené jako `- [název stránky] text`). " \
|
||
"Když se hovor ptá na konkrétní hodnotu, normu nebo postup a wiki ji obsahuje, uveď ji KONKRÉTNĚ v příslušném " \
|
||
"action_item (přesná hodnota / číslo normy / krok) a do `target` dej název zdrojové stránky. NIKDY neuváděj " \
|
||
"konkrétní údaj (číslo, normu, hodnotu), který v těchto úryvcích není — pokud chybí, řekni, kde ho dohledat.\n#{wiki}\n\n---\n"
|
||
led = Array(meta[:ledger])
|
||
led_block = led.empty? ? "" : ("Existující rozhodnutí u tohoto volajícího (pokud se hovor některého týká, " \
|
||
"použij jeho `entry_id` a stejnou formulaci; jinak `entry_id: null`):\n" +
|
||
led.map { |e| "- [#{e['id']}] #{e['question']} → #{e['decision']}" }.join("\n") + "\n\n---\n")
|
||
"#{wiki_block}#{led_block}Přepis hovoru#{m.empty? ? '' : " (#{m})"}:\n\n#{transcript}"
|
||
end
|
||
|
||
# one round-trip + parse + validate
|
||
def attempt(body)
|
||
status, raw = @transport.call(@endpoint, headers, body)
|
||
return { ok: false, error: "http #{status}", raw: raw } unless status.to_i.between?(200, 299)
|
||
env = JSON.parse(raw)
|
||
content = env.dig("choices", 0, "message", "content").to_s
|
||
cost = env.dig("usage", "cost")
|
||
parsed = (JSON.parse(content) rescue nil)
|
||
unless valid?(parsed)
|
||
return { ok: false, parse_failed: true, needs_review: true, raw: content, cost: cost, model: @model }
|
||
end
|
||
sp = parsed["suggested_position"].to_s.strip
|
||
{ ok: true, summary: parsed["summary"], action_items: normalize(parsed["action_items"]),
|
||
suggested_position: (sp.empty? ? nil : sp),
|
||
context_digest: parsed["context_digest"].to_s,
|
||
caller_facts: normalize_facts(parsed["caller_facts"]),
|
||
ledger_deltas: normalize_deltas(parsed["ledger_deltas"]),
|
||
model: @model, cost: cost, usage: env["usage"] }
|
||
rescue JSON::ParserError => e
|
||
{ ok: false, parse_failed: true, needs_review: true, error: "bad envelope: #{e.message}", raw: raw }
|
||
end
|
||
|
||
def valid?(obj)
|
||
return false unless obj.is_a?(Hash) && obj["summary"].is_a?(String) && obj["action_items"].is_a?(Array)
|
||
obj["action_items"].all? { |a| a.is_a?(Hash) && a["text"].is_a?(String) }
|
||
end
|
||
|
||
def normalize(items)
|
||
items.map do |a|
|
||
{ "text" => a["text"], "kind" => (KINDS.include?(a["kind"]) ? a["kind"] : "other"), "target" => a["target"] }.compact
|
||
end
|
||
end
|
||
|
||
def normalize_facts(arr) = Array(arr).select { |x| x.is_a?(String) && !x.strip.empty? }.map(&:strip)
|
||
|
||
# Coerce decision to the enum, cap count, and DROP any delta whose quote is blank or not actually in the
|
||
# transcript (whitespace-squeezed) — a free hallucination guard.
|
||
def normalize_deltas(arr)
|
||
squeeze = ->(s) { s.to_s.gsub(/\s+/, " ").strip }
|
||
hay = squeeze.call(@transcript)
|
||
Array(arr).filter_map do |d|
|
||
next unless d.is_a?(Hash)
|
||
q = squeeze.call(d["quote"])
|
||
next if q.empty? || !hay.include?(q)
|
||
{ "entry_id" => (d["entry_id"].to_s.empty? ? nil : d["entry_id"].to_s),
|
||
"question" => d["question"].to_s,
|
||
"decision" => (DECISIONS.include?(d["decision"].to_s) ? d["decision"].to_s : "conditional"),
|
||
"quote" => d["quote"].to_s[0, MAX_QUOTE] }
|
||
end.first(MAX_DELTAS)
|
||
end
|
||
|
||
def http_transport(url, hdrs, body)
|
||
uri = URI(url); http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = (uri.scheme == "https")
|
||
req = Net::HTTP::Post.new(uri); hdrs.each { |k, v| req[k] = v }; req.body = body
|
||
res = http.request(req)
|
||
[res.code.to_i, res.body]
|
||
end
|
||
end
|
||
end
|
||
|
||
# CLI: OPENROUTER_API_KEY=... ruby summariser.rb <transcript-file>
|
||
if $PROGRAM_NAME == __FILE__
|
||
file = ARGV[0] or abort "usage: ruby summariser.rb <transcript-file>"
|
||
r = Helpdesk::Summariser.new.summarise(File.read(file))
|
||
puts JSON.pretty_generate(r)
|
||
exit(r[:ok] ? 0 : 1)
|
||
end
|