mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +00:00
* Fix PDF vision path failing when poppler-utils is missing The Docker image omitted poppler-utils, so the OpenAI PDF vision path (which renders pages with pdftoppm before sending them upstream) always failed and the admin AI status page surfaced a generic "request failed" code rather than a useful reason. - install poppler-utils in the Docker base image (pdftoppm for the vision render path) - add an optional failure_code to Provider::Error, so probe errors can carry a machine-readable reason - in Provider::Openai::PdfProcessor#convert_pdf_to_images, check pdftoppm's return value and -- when the binary is genuinely missing, raise a Provider::Openai::Error with failure_code :render_missing_binary while preserving the existing [] fallback for other render failures - register the :render_missing_binary code in the admin locale so the AI status page shows a concrete, actionable message instead of "request failed" - AiHealth::Probe#failure_code now falls through to the default codes when an error's failure_code is nil, instead of returning nil - regression tests covering both the missing-binary and the present-but-fails cases Fixes the "PDF vision/native path" system check on instances where the image is built from the checked-in Dockerfile. * Fix missing-binary detection and preserve failure_code across the error boundary - convert_pdf_to_images: Kernel#system returns nil (not false) when the executable is absent; raise the coded error on rendered.nil? so a missing pdftoppm yields :render_missing_binary instead of a blank conversion. - Provider::Error#as_json + default_error_transformer: carry failure_code through serialization and error re-wrapping. - Drop the binary_missing? helper (nil result is the authoritative signal) and update regression tests to stub the real nil return value. - Add coverage for failure_code serialization/transformation. * Fix Provider::Error transformer syntax error and cover Faraday branch Addresses jjmata's blocking change-request: app/models/provider.rb:54 used a postfix `if` modifier inside a hash-argument/method-call argument list, which is not valid Ruby. `ruby -c` failed to parse the file, so the base class every provider inherits from could not autoload and the whole app (boot, requests, jobs, tests) was down. Rewrite default_error_transformer to build the optional failure_code keyword once (only when the error exposes a truthy code) and splat it, so both the Faraday::Error branch and the generic branch carry the code with no syntax error and no nil kwarg. Also add the two Faraday-branch regression tests that were missing (failure_code preservation + response-body-to-details extraction), guarding this exact class of broken-argument bug with real assertions. Verification: ruby -c passes on provider.rb, pdf_processor.rb, probe.rb, and both test files; plus a behavioral harness against the real provider.rb source covering the coded/plain/nil-code, Faraday-coded, Faraday-no-code, nil-response, and generic-error paths (19/19 pass). Ref: we-promise/sure#3275 * Document the methods added or changed by this PR * Add poppler-utils to devcontainer image --------- Co-authored-by: hermes-on-behalf-of-jon <hermes@nousresearch.com> Co-authored-by: jaysbeekay <jaysbeekay@users.noreply.github.com> Co-authored-by: sure-admin <sure-admin@splashblot.com>
163 lines
4.7 KiB
Ruby
163 lines
4.7 KiB
Ruby
require "test_helper"
|
|
|
|
class Provider::Openai::PdfProcessorTest < ActiveSupport::TestCase
|
|
setup do
|
|
@pdf_content = "%PDF-1.4 fake bytes".b
|
|
end
|
|
|
|
test "extracts only allowlisted error fields into span output when the API call fails" do
|
|
error = StandardError.new("boom")
|
|
def error.response_body
|
|
{
|
|
"error" => { "type" => "invalid_request_error", "message" => "invalid request", "code" => "bad_pdf" },
|
|
"request" => { "messages" => "statement text that should never leak" }
|
|
}
|
|
end
|
|
def error.response_headers
|
|
{ "x-request-id" => "req_abc123" }
|
|
end
|
|
|
|
captured_output = nil
|
|
trace = stub_trace { |output| captured_output = output }
|
|
|
|
assert_raises(StandardError) do
|
|
build_processor(error, trace).process
|
|
end
|
|
|
|
assert_equal(
|
|
{ type: "invalid_request_error", message: "invalid request", code: "bad_pdf", request_id: "req_abc123" },
|
|
captured_output[:error_detail]
|
|
)
|
|
end
|
|
|
|
test "error_detail is nil in span output when the error exposes no response_body" do
|
|
error = StandardError.new("boom")
|
|
|
|
captured_output = nil
|
|
trace = stub_trace { |output| captured_output = output }
|
|
|
|
assert_raises(StandardError) do
|
|
build_processor(error, trace).process
|
|
end
|
|
|
|
assert_nil captured_output[:error_detail]
|
|
end
|
|
|
|
test "error_detail falls back to a placeholder when reading response_body itself raises" do
|
|
error = StandardError.new("boom")
|
|
def error.response_body
|
|
raise "response_body accessor exploded"
|
|
end
|
|
|
|
captured_output = nil
|
|
trace = stub_trace { |output| captured_output = output }
|
|
|
|
assert_raises(StandardError) do
|
|
build_processor(error, trace).process
|
|
end
|
|
|
|
assert_match(/detail unavailable/i, captured_output[:error_detail])
|
|
end
|
|
|
|
test "text mode exercises only text extraction" do
|
|
expected = Provider::LlmConcept::PdfProcessingResult.new(
|
|
summary: "Synthetic PDF",
|
|
document_type: "other",
|
|
extracted_data: {}
|
|
)
|
|
processor = Provider::Openai::PdfProcessor.new(
|
|
mock,
|
|
model: "gpt-4.1",
|
|
pdf_content: @pdf_content,
|
|
max_response_tokens: 512,
|
|
processing_mode: :text
|
|
)
|
|
processor.expects(:process_with_text_extraction).returns(expected)
|
|
processor.expects(:process_with_vision).never
|
|
|
|
assert_equal expected, processor.process
|
|
end
|
|
|
|
test "vision mode exercises only vision processing" do
|
|
expected = Provider::LlmConcept::PdfProcessingResult.new(
|
|
summary: "Synthetic PDF",
|
|
document_type: "other",
|
|
extracted_data: {}
|
|
)
|
|
processor = Provider::Openai::PdfProcessor.new(
|
|
mock,
|
|
model: "gpt-4.1",
|
|
pdf_content: @pdf_content,
|
|
max_response_tokens: 512,
|
|
processing_mode: :vision
|
|
)
|
|
processor.expects(:process_with_text_extraction).never
|
|
processor.expects(:process_with_vision).returns(expected)
|
|
|
|
assert_equal expected, processor.process
|
|
end
|
|
|
|
test "convert_pdf_to_images raises a coded error when the pdftoppm binary is missing" do
|
|
processor = Provider::Openai::PdfProcessor.new(
|
|
mock,
|
|
model: "gpt-4.1",
|
|
pdf_content: @pdf_content,
|
|
max_response_tokens: 512,
|
|
processing_mode: :vision
|
|
)
|
|
|
|
# Simulate poppler-utils not being installed: Kernel#system returns `nil`
|
|
# (the executable cannot be started) rather than `false`.
|
|
processor.stubs(:system).returns(nil)
|
|
|
|
error = assert_raises(Provider::Openai::Error) do
|
|
processor.send(:convert_pdf_to_images)
|
|
end
|
|
|
|
assert_equal :render_missing_binary, error.failure_code
|
|
assert_match(/poppler-utils/, error.message)
|
|
end
|
|
|
|
test "convert_pdf_to_images still degrades to [] when pdftoppm is present but rejects the PDF" do
|
|
Rails.logger.stubs(:error)
|
|
processor = Provider::Openai::PdfProcessor.new(
|
|
mock,
|
|
model: "gpt-4.1",
|
|
pdf_content: @pdf_content,
|
|
max_response_tokens: 512,
|
|
processing_mode: :vision
|
|
)
|
|
|
|
# Binary is installed, but pdftoppm exits non-zero on bad input (system
|
|
# returns `false`): keep the pre-existing "return no pages" behavior (no
|
|
# coded error).
|
|
processor.stubs(:system).returns(false)
|
|
|
|
assert_equal [], processor.send(:convert_pdf_to_images)
|
|
end
|
|
|
|
private
|
|
def build_processor(error, trace)
|
|
client = mock
|
|
client.expects(:chat).raises(error)
|
|
|
|
processor = Provider::Openai::PdfProcessor.new(
|
|
client,
|
|
model: "gpt-4.1",
|
|
pdf_content: @pdf_content,
|
|
langfuse_trace: trace,
|
|
max_response_tokens: 1000
|
|
)
|
|
processor.stubs(:extract_text_from_pdf).returns("Statement text")
|
|
processor
|
|
end
|
|
|
|
def stub_trace
|
|
span = mock
|
|
span.expects(:end).with { |args| yield(args[:output]); true }
|
|
trace = mock
|
|
trace.stubs(:span).returns(span)
|
|
trace
|
|
end
|
|
end
|