# 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 if $PROGRAM_NAME == __FILE__ file = ARGV[0] or abort "usage: ruby summariser.rb " r = Helpdesk::Summariser.new.summarise(File.read(file)) puts JSON.pretty_generate(r) exit(r[:ok] ? 0 : 1) end