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.
77 lines
2.8 KiB
Ruby
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!(/<[^>]+>/, " ")
|
|
{ " " => " ", "&" => "&", "<" => "<", ">" => ">", """ => '"', "'" => "'" }
|
|
.each { |k, v| t.gsub!(k, v) }
|
|
t.gsub(/\s+/, " ").strip
|
|
end
|
|
end
|
|
end
|