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

212 lines
11 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
# 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, 24 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