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

108 lines
6.2 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
# 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. 24 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