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.
122 lines
5.6 KiB
Ruby
122 lines
5.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Offline tests for the wiki RAG core (Embedder + WikiIndex) — fake transport/embedder, no network.
|
|
# Run: ruby components/backend/wiki/test_wiki_rag.rb
|
|
|
|
require "json"
|
|
require "tmpdir"
|
|
require_relative "embedder"
|
|
require_relative "wiki_index"
|
|
|
|
$pass = 0; $fail = 0
|
|
def ok(d); $pass += 1; puts " ok #{d}"; end
|
|
def bad(d, g = nil); $fail += 1; puts " FAIL #{d}#{g.nil? ? '' : " (#{g.inspect})"}"; end
|
|
def eq(a, b, d); a == b ? ok(d) : bad(d, a); end
|
|
def truthy(x, d); x ? ok(d) : bad(d, x); end
|
|
|
|
puts "Embedder: parses /embeddings response, preserves order"
|
|
# fake transport returns two embeddings out of index order — Embedder must re-sort by index
|
|
fake = ->(_body) { JSON.generate("data" => [{ "index" => 1, "embedding" => [0.0, 1.0] }, { "index" => 0, "embedding" => [1.0, 0.0] }]) }
|
|
emb = Helpdesk::Embedder.new(api_key: "x", transport: fake)
|
|
vecs = emb.embed(%w[a b])
|
|
eq(vecs, [[1.0, 0.0], [0.0, 1.0]], "embeddings returned in input order (sorted by index)")
|
|
eq(emb.embed_one("a"), [1.0, 0.0], "embed_one returns the first vector")
|
|
begin
|
|
Helpdesk::Embedder.new(api_key: "x", transport: ->(_b) { JSON.generate("error" => "boom") }).embed("a")
|
|
bad("api error should raise")
|
|
rescue => e
|
|
ok("api error raises (#{e.message[0, 20]})")
|
|
end
|
|
|
|
# bag-of-words fake embedder: deterministic vectors so cosine ranking is predictable
|
|
VOCAB = %w[ping elektromer sada optika kabel klima alarm modem signal].freeze
|
|
class BowEmbedder
|
|
def embed(texts) = texts.map { |t| vec(t) }
|
|
def embed_one(t) = vec(t)
|
|
def vec(t)
|
|
s = t.to_s.downcase
|
|
VOCAB.map { |w| s.include?(w) ? 1.0 : 0.0 }
|
|
end
|
|
end
|
|
|
|
puts "\nWikiIndex: build + retrieve ranks the relevant page top"
|
|
pages = [
|
|
{ title: "Pingání v terénu", path: "net/ping", text: "technik nemohl ping elektromer pomohla sada" },
|
|
{ title: "Standardy optiky", path: "sow/optika", text: "standardy optika kabel trasa" },
|
|
{ title: "Klimatizace", path: "sow/klima", text: "klima chlazeni alarm teplota" },
|
|
]
|
|
idx = Helpdesk::WikiIndex.new(embedder: BowEmbedder.new).build(pages)
|
|
eq(idx.size, 3, "one chunk per short page")
|
|
r = idx.retrieve("mám problém s ping na elektromer", k: 2)
|
|
eq(r.first[:title], "Pingání v terénu", "ping/elektromer query -> ping page top")
|
|
truthy(r.first[:score] > (r[1] ? r[1][:score] : -1), "top score is highest")
|
|
eq(idx.retrieve("optika kabel trasa", k: 1).first[:title], "Standardy optiky", "optika query -> optika page")
|
|
eq(idx.retrieve("alarm klima", k: 1).first[:title], "Klimatizace", "klima query -> klima page")
|
|
|
|
puts "\nWikiIndex: chunking splits long pages"
|
|
long = { title: "Dlouhá", path: "x", text: (["slovo"] * 400).join(" ") }
|
|
idx2 = Helpdesk::WikiIndex.new(embedder: BowEmbedder.new, chunk_words: 150).build([long])
|
|
eq(idx2.size, 3, "400 words / 150 per chunk -> 3 chunks")
|
|
|
|
puts "\nWikiIndex: save/load round-trip"
|
|
dir = Dir.mktmpdir
|
|
path = File.join(dir, "idx.json")
|
|
idx.save(path)
|
|
loaded = Helpdesk::WikiIndex.new(embedder: BowEmbedder.new).load(path)
|
|
eq(loaded.size, 3, "loaded chunk count matches")
|
|
eq(loaded.retrieve("ping elektromer", k: 1).first[:title], "Pingání v terénu", "retrieval works after load")
|
|
|
|
puts "\nSummariser: injects wiki_context into the prompt (and omits it when absent)"
|
|
require_relative "../summariser"
|
|
s = Helpdesk::Summariser.new(api_key: "x")
|
|
up = ->(body_str) { JSON.parse(body_str)["messages"].find { |m| m["role"] == "user" }["content"] }
|
|
with = up.call(s.request_body("PREPIS", { wiki_context: "WIKI-CHUNK-XYZ" }))
|
|
truthy(with.include?("WIKI-CHUNK-XYZ"), "wiki context appears in the user prompt")
|
|
truthy(with.include?("PREPIS"), "transcript still present")
|
|
without = up.call(s.request_body("PREPIS", {}))
|
|
truthy(!without.include?("interní wiki"), "no wiki block when none retrieved")
|
|
|
|
puts "\nWikiIndex: build fails fast on a short/nil embedding batch (never ships nil vectors to disk)"
|
|
class ShortEmbedder # returns one FEWER vector than requested (a partial API batch)
|
|
def embed(texts) = texts[0...-1].map { [1.0, 0.0] }
|
|
def embed_one(_t) = [1.0, 0.0]
|
|
end
|
|
begin
|
|
Helpdesk::WikiIndex.new(embedder: ShortEmbedder.new)
|
|
.build([{ title: "a", path: "p", text: "one" }, { title: "b", path: "p", text: "two" }])
|
|
bad("build should raise on a short embedding batch")
|
|
rescue => e
|
|
ok("build raises on count mismatch (#{e.message[0, 24]})")
|
|
end
|
|
class NilVecEmbedder
|
|
def embed(texts) = texts.map { nil }
|
|
def embed_one(_t) = [1.0]
|
|
end
|
|
begin
|
|
Helpdesk::WikiIndex.new(embedder: NilVecEmbedder.new).build([{ title: "a", path: "p", text: "one" }])
|
|
bad("build should raise on a nil vector")
|
|
rescue => e
|
|
ok("build raises on nil embedding (#{e.message[0, 20]})")
|
|
end
|
|
|
|
puts "\nWikiIndex: cosine rejects a dimension mismatch instead of silently truncating"
|
|
begin
|
|
Helpdesk::WikiIndex.new(embedder: BowEmbedder.new).send(:cosine, [1.0, 0.0], [1.0, 0.0, 0.0])
|
|
bad("cosine should raise on dim mismatch")
|
|
rescue ArgumentError
|
|
ok("cosine raises ArgumentError on dim mismatch")
|
|
end
|
|
|
|
puts "\nWikiIndex: retrieve skips nil/mismatched-dim vectors in a corrupt loaded index"
|
|
dir2 = Dir.mktmpdir
|
|
bad_path = File.join(dir2, "corrupt.json")
|
|
File.write(bad_path, JSON.generate([
|
|
{ "text" => "good ping", "title" => "Good", "path" => "g", "vector" => Array.new(VOCAB.size, 0.0).tap { |v| v[0] = 1.0 } },
|
|
{ "text" => "nilvec", "title" => "Nil", "path" => "n", "vector" => nil },
|
|
{ "text" => "wrongdim", "title" => "Wrong","path" => "w", "vector" => [1.0, 2.0] }]))
|
|
ci = Helpdesk::WikiIndex.new(embedder: BowEmbedder.new).load(bad_path)
|
|
res = ci.retrieve("ping", k: 5)
|
|
eq(res.map { |c| c[:title] }, ["Good"], "only the valid-dim chunk returned; nil + wrong-dim skipped, no crash")
|
|
|
|
puts "\n#{$pass} passed, #{$fail} failed"
|
|
exit($fail.zero? ? 0 : 1)
|