# 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)