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

77 lines
2.8 KiB
Ruby

# frozen_string_literal: true
# WikiClient — fetches the Vodafone Wiki.js content via its GraphQL API for the RAG index.
# JWT from env (WIKI_JWT). Strips HTML to plain text. Excludes pure data-dump pages (inventory /
# location tables) that are noise for call summaries. Standard library only; transport injectable for tests.
require "json"
require "net/http"
require "uri"
module Helpdesk
class WikiClient
DEFAULT_BASE = "https://wikivf.max.support"
# exclude data dumps: path keywords + an oversize guard (the standards pages ~145K chars stay;
# the 511K PSU-inventory and 203K location-table pages go).
EXCLUDE_PATHS = [/inventura/i, /invent/i, /lokalit/i].freeze
MAX_PAGE_CHARS = 160_000
def initialize(base_url: DEFAULT_BASE, jwt: ENV["WIKI_JWT"], transport: nil)
@base = base_url.chomp("/")
@jwt = jwt
@transport = transport # ->(query_string) { response_body_json } — for tests
end
# → [{ id:, path:, title:, updated_at: }]
def list
d = gql("{ pages { list { id path title updatedAt } } }")
d.dig("data", "pages", "list").map { |p| { id: p["id"], path: p["path"], title: p["title"], updated_at: p["updatedAt"] } }
end
# → plain text of one page (HTML stripped), or nil if forbidden/missing.
def content(id)
d = gql("{ pages { single(id:#{id}){ content } } }")
html = d.dig("data", "pages", "single", "content")
html && html_to_text(html)
end
# The knowledge pages to index: [{ title:, path:, text: }], excluding data dumps + empties.
def knowledge_pages(exclude_paths: EXCLUDE_PATHS, max_chars: MAX_PAGE_CHARS)
list.reject { |p| exclude_paths.any? { |re| p[:path] =~ re } }.filter_map do |p|
text = content(p[:id])
next if text.nil? || text.strip.empty? || text.length > max_chars
{ title: p[:title], path: p[:path], text: text }
end
end
private
def gql(query)
raw = @transport ? @transport.call(query) : post(query)
JSON.parse(raw)
end
def post(query)
uri = URI("#{@base}/graphql")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
http.open_timeout = 15
http.read_timeout = 60
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{@jwt}"
req["Content-Type"] = "application/json"
req.body = JSON.generate(query: query)
http.request(req).body
end
# minimal HTML → text: drop script/style, tags → spaces, decode a few entities, collapse whitespace.
def html_to_text(html)
t = html.dup
t.gsub!(%r{<(script|style)[^>]*>.*?</\1>}mi, " ")
t.gsub!(/<[^>]+>/, " ")
{ "&nbsp;" => " ", "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"', "&#39;" => "'" }
.each { |k, v| t.gsub!(k, v) }
t.gsub(/\s+/, " ").strip
end
end
end