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.
108 lines
6.2 KiB
Ruby
108 lines
6.2 KiB
Ruby
# frozen_string_literal: true
|
||
# ContextMaintainer — renders the two LLM-owned dossier sections (summary, other_context) from a caller's
|
||
# accumulated digests/facts + the deterministic ledger + operator notes. Mirrors Summariser: OpenRouter,
|
||
# strict Structured Outputs + json_object fallback + client-side validate, injectable transport (offline).
|
||
# Pinned sections are forced to the operator's verbatim text (never lost); divergence flags needs_review.
|
||
# PURE: no store access — the server snapshots inputs (under lock) and writes the result (CAS).
|
||
require "json"; require "net/http"; require "uri"
|
||
module Helpdesk
|
||
class ContextMaintainer
|
||
ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
|
||
OUTPUT_SCHEMA = {
|
||
"type" => "object", "additionalProperties" => false, "required" => %w[summary other_context],
|
||
"properties" => {
|
||
"summary" => { "type" => "string", "description" => "Kdo volající je + co nejčastěji potřebuje. 2–4 věty, česky." },
|
||
"other_context" => { "type" => "array", "items" => { "type" => "string" },
|
||
"description" => "Další kontext seřazený podle důležitosti/budoucí relevance. [] pokud nic." } } }.freeze
|
||
|
||
def initialize(api_key: ENV["OPENROUTER_API_KEY"],
|
||
model: (ENV["MAINTAINER_MODEL"] || "google/gemini-3.6-flash"),
|
||
rebuild_model: (ENV["MAINTAINER_REBUILD_MODEL"] || "google/gemini-3.6-flash"),
|
||
pack: nil, pack_path: File.join(__dir__, "context_maintainer_pack.md"),
|
||
temperature: 0.2, max_tokens: 1200, reasoning_effort: "low",
|
||
endpoint: ENDPOINT, transport: nil, app_title: "Helpdesk")
|
||
@api_key = api_key; @model = model; @rebuild_model = rebuild_model
|
||
@pack = pack || (File.exist?(pack_path) ? File.read(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
|
||
end
|
||
|
||
def build(mode:, old_ai_context:, pinned_sections: {}, digests: [], caller_facts: [], ledger: [],
|
||
notes: "", transcripts: [])
|
||
return { ok: false, error: "OPENROUTER_API_KEY not set" } if @api_key.to_s.empty? && !@custom_transport
|
||
model = (mode == :rebuild ? @rebuild_model : @model)
|
||
body = request_body(model, mode, old_ai_context, pinned_sections, digests, caller_facts, ledger, notes, transcripts)
|
||
status, raw = @transport.call(@endpoint, headers, JSON.generate(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
|
||
parsed = (JSON.parse(content) rescue nil)
|
||
return { ok: false, needs_review: true, error: "unparseable", model: model } unless valid?(parsed)
|
||
ai = { "summary" => parsed["summary"].to_s,
|
||
"other_context" => Array(parsed["other_context"]).select { |x| x.is_a?(String) && !x.strip.empty? }.map(&:strip) }
|
||
ai, needs = enforce_pins(ai, pinned_sections || {})
|
||
{ ok: true, ai_context: ai, needs_review: needs, model: model, cost: env.dig("usage", "cost") }
|
||
rescue JSON::ParserError => e
|
||
{ ok: false, needs_review: true, error: "bad envelope: #{e.message}" }
|
||
end
|
||
|
||
def request_body(model, mode, old_ai, pins, digests, facts, ledger, notes, transcripts)
|
||
body = { "model" => model, "temperature" => @temperature, "max_tokens" => @max_tokens,
|
||
"messages" => [{ "role" => "system", "content" => @pack },
|
||
{ "role" => "user", "content" => user_prompt(mode, old_ai, pins, digests, facts, ledger, notes, transcripts) }],
|
||
"response_format" => { "type" => "json_schema",
|
||
"json_schema" => { "name" => "caller_context", "strict" => true, "schema" => OUTPUT_SCHEMA } },
|
||
"provider" => { "require_parameters" => true } }
|
||
body["reasoning"] = { "effort" => @reasoning_effort } if @reasoning_effort
|
||
body
|
||
end
|
||
|
||
def headers
|
||
{ "Authorization" => "Bearer #{@api_key}", "Content-Type" => "application/json",
|
||
"HTTP-Referer" => "https://helpdesk.local", "X-Title" => @app_title }
|
||
end
|
||
|
||
private
|
||
|
||
def user_prompt(mode, old_ai, pins, digests, facts, ledger, notes, transcripts)
|
||
parts = []
|
||
parts << "Režim: #{mode == :rebuild ? 'PŘESTAVBA (celá historie, přepiš od nuly)' : 'INKREMENT (uprav minimálně, zachovej co stále platí)'}"
|
||
parts << "Stávající kontext (JSON): #{JSON.generate(old_ai || {})}"
|
||
unless (pins || {}).empty?
|
||
parts << "PŘIPNUTÉ sekce operátorem — NEPŘEPISUJ je, nové fakty dej do ostatních sekcí: #{JSON.generate(pins)}"
|
||
end
|
||
parts << "Operátorské poznámky: #{notes}" unless notes.to_s.strip.empty?
|
||
parts << "Ledger rozhodnutí (autoritativní, nevymýšlej ani neobracej): #{JSON.generate(ledger || [])}"
|
||
parts << "Fakty o volajícím: #{JSON.generate(Array(facts))}"
|
||
parts << "Shrnutí hovorů (nejnovější první):\n" + Array(digests).map { |d| "- #{d}" }.join("\n")
|
||
if mode == :rebuild && !Array(transcripts).empty?
|
||
parts << "Přepisy posledních hovorů (kontext, neciteruj doslovně):\n" + Array(transcripts).join("\n---\n")
|
||
end
|
||
parts.join("\n\n")
|
||
end
|
||
|
||
def valid?(o) = o.is_a?(Hash) && o["summary"].is_a?(String) && o["other_context"].is_a?(Array)
|
||
|
||
# pinned sections are operator-owned: force the operator text, flag needs_review if the LLM diverged.
|
||
def enforce_pins(ai, pins)
|
||
needs = false
|
||
pins.each do |sec, val|
|
||
next unless %w[summary other_context].include?(sec)
|
||
needs = true if norm(ai[sec]) != norm(val)
|
||
ai[sec] = val
|
||
end
|
||
[ai, needs]
|
||
end
|
||
|
||
def norm(x)
|
||
x.is_a?(Array) ? x.map { |s| s.to_s.gsub(/\s+/, " ").strip } : x.to_s.gsub(/\s+/, " ").strip
|
||
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
|