# 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