mirror of
https://github.com/we-promise/sure.git
synced 2026-09-02 05:11:05 +00:00
* Add live AI checks to system health Give super admins a dedicated AI status view with bounded liveness probes for LLMs, vector stores, pgvector, and embedding endpoints. Record sanitized failures in both the system debug log and Rails logger, and document the recommended local configuration.\n\nCloses #3145 * Fix AI health CI checks * Address AI health review feedback * Correct Ollama model preload guidance * Distinguish OpenAI-compatible providers * Make Ollama startup readiness explicit * Recognize Cloudflare AI endpoints
219 lines
6.8 KiB
Ruby
219 lines
6.8 KiB
Ruby
require "test_helper"
|
|
|
|
class VectorStore::EmbeddableTest < ActiveSupport::TestCase
|
|
class EmbeddableHost
|
|
include VectorStore::Embeddable
|
|
# Expose private methods for testing
|
|
public :extract_text, :chunk_text, :embed, :embed_batch,
|
|
:embedding_model, :embedding_dimensions, :embedding_uri_base,
|
|
:embedding_access_token
|
|
end
|
|
|
|
setup do
|
|
@host = EmbeddableHost.new
|
|
end
|
|
|
|
# --- extract_text ---
|
|
|
|
test "extract_text returns plain text for .txt files" do
|
|
result = @host.extract_text("Hello world", "notes.txt")
|
|
assert_equal "Hello world", result
|
|
end
|
|
|
|
test "extract_text returns content for markdown files" do
|
|
result = @host.extract_text("# Heading\n\nBody", "readme.md")
|
|
assert_equal "# Heading\n\nBody", result
|
|
end
|
|
|
|
test "extract_text returns content for code files" do
|
|
result = @host.extract_text("def foo; end", "app.rb")
|
|
assert_equal "def foo; end", result
|
|
end
|
|
|
|
test "extract_text returns nil for unsupported binary formats" do
|
|
assert_nil @host.extract_text("\x00\x01binary", "photo.png")
|
|
assert_nil @host.extract_text("\x00\x01binary", "archive.zip")
|
|
end
|
|
|
|
test "extract_text handles PDF files" do
|
|
pdf_content = "fake pdf bytes"
|
|
mock_page = mock("page")
|
|
mock_page.stubs(:text).returns("Page 1 content")
|
|
|
|
mock_reader = mock("reader")
|
|
mock_reader.stubs(:pages).returns([ mock_page ])
|
|
|
|
PDF::Reader.expects(:new).with(instance_of(StringIO)).returns(mock_reader)
|
|
|
|
result = @host.extract_text(pdf_content, "document.pdf")
|
|
assert_equal "Page 1 content", result
|
|
end
|
|
|
|
test "extract_text returns nil when PDF extraction fails" do
|
|
PDF::Reader.expects(:new).raises(StandardError, "corrupt pdf")
|
|
|
|
result = @host.extract_text("bad data", "broken.pdf")
|
|
assert_nil result
|
|
end
|
|
|
|
# --- chunk_text ---
|
|
|
|
test "chunk_text returns empty array for blank text" do
|
|
assert_equal [], @host.chunk_text("")
|
|
assert_equal [], @host.chunk_text(nil)
|
|
end
|
|
|
|
test "chunk_text returns single chunk for short text" do
|
|
text = "Short paragraph."
|
|
chunks = @host.chunk_text(text)
|
|
assert_equal 1, chunks.size
|
|
assert_equal "Short paragraph.", chunks.first
|
|
end
|
|
|
|
test "chunk_text splits on paragraph boundaries" do
|
|
# Create text that exceeds CHUNK_SIZE when combined
|
|
para1 = "A" * 1200
|
|
para2 = "B" * 1200
|
|
text = "#{para1}\n\n#{para2}"
|
|
|
|
chunks = @host.chunk_text(text)
|
|
assert_equal 2, chunks.size
|
|
assert_includes chunks.first, "A" * 1200
|
|
assert_includes chunks.last, "B" * 1200
|
|
end
|
|
|
|
test "chunk_text includes overlap between chunks" do
|
|
para1 = "A" * 1500
|
|
para2 = "B" * 1500
|
|
text = "#{para1}\n\n#{para2}"
|
|
|
|
chunks = @host.chunk_text(text)
|
|
assert_equal 2, chunks.size
|
|
# Second chunk should start with overlap from end of first chunk
|
|
overlap = para1.last(VectorStore::Embeddable::CHUNK_OVERLAP)
|
|
assert chunks.last.start_with?(overlap)
|
|
end
|
|
|
|
test "chunk_text keeps small paragraphs together" do
|
|
paragraphs = Array.new(5) { |i| "Paragraph #{i} content." }
|
|
text = paragraphs.join("\n\n")
|
|
|
|
chunks = @host.chunk_text(text)
|
|
assert_equal 1, chunks.size
|
|
end
|
|
|
|
test "chunk_text hard-splits oversized paragraphs" do
|
|
# A single paragraph longer than CHUNK_SIZE with no paragraph breaks
|
|
long_para = "X" * 5000
|
|
chunks = @host.chunk_text(long_para)
|
|
|
|
assert chunks.size > 1
|
|
chunks.each do |chunk|
|
|
assert chunk.length <= VectorStore::Embeddable::CHUNK_SIZE + VectorStore::Embeddable::CHUNK_OVERLAP + 2,
|
|
"Chunk too large: #{chunk.length} chars"
|
|
end
|
|
end
|
|
|
|
# --- embed ---
|
|
|
|
test "embed calls embedding endpoint and returns vector" do
|
|
expected_vector = [ 0.1, 0.2, 0.3 ]
|
|
stub_response = { "data" => [ { "embedding" => expected_vector, "index" => 0 } ] }
|
|
|
|
mock_client = mock("faraday")
|
|
mock_client.expects(:post).with("embeddings").yields(mock_request).returns(
|
|
OpenStruct.new(body: stub_response)
|
|
)
|
|
@host.instance_variable_set(:@embedding_client, mock_client)
|
|
|
|
result = @host.embed("test text")
|
|
assert_equal expected_vector, result
|
|
end
|
|
|
|
test "embed raises on failed response" do
|
|
mock_client = mock("faraday")
|
|
mock_client.expects(:post).with("embeddings").yields(mock_request).returns(
|
|
OpenStruct.new(body: { "error" => "bad request" })
|
|
)
|
|
@host.instance_variable_set(:@embedding_client, mock_client)
|
|
|
|
assert_raises(VectorStore::Error) { @host.embed("test text") }
|
|
end
|
|
|
|
test "embedding configuration delegates to the shared runtime configuration" do
|
|
VectorStore.expects(:embedding_model).returns("model")
|
|
VectorStore.expects(:embedding_dimensions).returns(3)
|
|
VectorStore.expects(:embedding_uri_base).returns("https://embeddings.example.test/v1")
|
|
VectorStore.expects(:embedding_access_token).returns("token")
|
|
|
|
assert_equal "model", @host.embedding_model
|
|
assert_equal 3, @host.embedding_dimensions
|
|
assert_equal "https://embeddings.example.test/v1", @host.embedding_uri_base
|
|
assert_equal "token", @host.embedding_access_token
|
|
end
|
|
|
|
# --- embed_batch ---
|
|
|
|
test "embed_batch processes texts and returns ordered vectors" do
|
|
texts = [ "first", "second", "third" ]
|
|
vectors = [ [ 0.1 ], [ 0.2 ], [ 0.3 ] ]
|
|
stub_response = {
|
|
"data" => [
|
|
{ "embedding" => vectors[0], "index" => 0 },
|
|
{ "embedding" => vectors[1], "index" => 1 },
|
|
{ "embedding" => vectors[2], "index" => 2 }
|
|
]
|
|
}
|
|
|
|
mock_client = mock("faraday")
|
|
mock_client.expects(:post).with("embeddings").yields(mock_request).returns(
|
|
OpenStruct.new(body: stub_response)
|
|
)
|
|
@host.instance_variable_set(:@embedding_client, mock_client)
|
|
|
|
result = @host.embed_batch(texts)
|
|
assert_equal vectors, result
|
|
end
|
|
|
|
test "embed_batch handles multiple batches" do
|
|
# Override batch size constant for testing
|
|
original = VectorStore::Embeddable::EMBED_BATCH_SIZE
|
|
VectorStore::Embeddable.send(:remove_const, :EMBED_BATCH_SIZE)
|
|
VectorStore::Embeddable.const_set(:EMBED_BATCH_SIZE, 2)
|
|
|
|
texts = [ "a", "b", "c" ]
|
|
|
|
batch1_response = {
|
|
"data" => [
|
|
{ "embedding" => [ 0.1 ], "index" => 0 },
|
|
{ "embedding" => [ 0.2 ], "index" => 1 }
|
|
]
|
|
}
|
|
batch2_response = {
|
|
"data" => [
|
|
{ "embedding" => [ 0.3 ], "index" => 0 }
|
|
]
|
|
}
|
|
|
|
mock_client = mock("faraday")
|
|
mock_client.expects(:post).with("embeddings").twice
|
|
.yields(mock_request)
|
|
.returns(OpenStruct.new(body: batch1_response))
|
|
.then.returns(OpenStruct.new(body: batch2_response))
|
|
@host.instance_variable_set(:@embedding_client, mock_client)
|
|
|
|
result = @host.embed_batch(texts)
|
|
assert_equal [ [ 0.1 ], [ 0.2 ], [ 0.3 ] ], result
|
|
ensure
|
|
VectorStore::Embeddable.send(:remove_const, :EMBED_BATCH_SIZE)
|
|
VectorStore::Embeddable.const_set(:EMBED_BATCH_SIZE, original)
|
|
end
|
|
|
|
private
|
|
|
|
def mock_request
|
|
request = OpenStruct.new(body: nil)
|
|
request
|
|
end
|
|
end
|