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

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)