diff --git a/app/lib/r3x/client/ocr.rb b/app/lib/r3x/client/ocr.rb new file mode 100644 index 00000000..1cab26b0 --- /dev/null +++ b/app/lib/r3x/client/ocr.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "base64" + +module R3x + module Client + class Ocr + ENDPOINT = "parse/image" + BASE_URL = "https://api.ocr.space" + + MIME_TYPES = { + ".png" => "image/png", + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".tif" => "image/tiff", + ".tiff" => "image/tiff", + ".bmp" => "image/bmp", + ".pdf" => "application/pdf" + }.freeze + + def initialize(api_key_env: "OCRSPACE_API_KEY") + @api_key = R3x::Env.secure_fetch(api_key_env, prefix: "OCRSPACE_API_KEY") + end + + def parse(io_or_path, language: nil, engine: nil, filetype: nil, overlay: false) + mime_type = filetype || detect_mime(io_or_path) + params = build_params(io_or_path, mime_type, language: language, engine: engine, overlay: overlay) + response = connection.post(ENDPOINT, params) + raise "OCR request failed: #{response.status}" unless response.success? + + body = response.body + raise "OCR API error: #{body["ErrorMessage"]}" if body["IsErroredOnProcessing"] + + Result.new(body) + end + + private + + attr_reader :api_key + + def connection + @connection ||= Faraday.new(url: BASE_URL) do |f| + f.request :url_encoded + f.response :json + f.options.timeout = 30 + f.options.open_timeout = 5 + f.headers["apikey"] = api_key + end + end + + def detect_mime(io_or_path) + if io_or_path.respond_to?(:read) + raise ArgumentError, "filetype required for IO objects (e.g. filetype: 'image/jpeg')" + end + + ext = File.extname(io_or_path.to_s).downcase + MIME_TYPES.fetch(ext) do + raise ArgumentError, "Unsupported file extension: '#{ext}'. Pass filetype: explicitly." + end + end + + def build_params(io_or_path, mime_type, language:, engine:, overlay:) + params = { + isOverlayRequired: overlay.to_s + } + params[:language] = language if language + params[:OCREngine] = engine.to_s if engine + + raw = io_or_path.respond_to?(:read) ? io_or_path.read : File.binread(io_or_path.to_s) + encoded = Base64.strict_encode64(raw) + params[:base64Image] = "data:#{mime_type};base64,#{encoded}" + + params + end + end + end +end diff --git a/app/lib/r3x/client/ocr/result.rb b/app/lib/r3x/client/ocr/result.rb new file mode 100644 index 00000000..ab2bc44e --- /dev/null +++ b/app/lib/r3x/client/ocr/result.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module R3x + module Client + class Ocr + class Result + include Enumerable + + def initialize(body) + @body = body + @pages = body.fetch("ParsedResults", []).map { |r| Page.new(r) } + end + + def text + pages.map(&:text).join("\n") + end + + def success? + exit_code == 1 + end + + def partial? + exit_code == 2 + end + + def exit_code + body["OCRExitCode"].to_i + end + + def processing_time_ms + body["ProcessingTimeInMilliseconds"]&.to_i + end + + def each(&block) + pages.each(&block) + end + + private + + attr_reader :body, :pages + + Page = Struct.new(:data) do + def text; data["ParsedText"]; end + def success?; data["FileParseExitCode"].to_i == 1; end + def error_message; data["ErrorMessage"]; end + def error_details; data["ErrorDetails"]; end + end + end + end + end +end diff --git a/test/lib/r3x/client/ocr_test.rb b/test/lib/r3x/client/ocr_test.rb new file mode 100644 index 00000000..af6fe8da --- /dev/null +++ b/test/lib/r3x/client/ocr_test.rb @@ -0,0 +1,276 @@ +require "test_helper" + +module R3x + module Client + class OcrTest < ActiveSupport::TestCase + setup do + @original_key = ENV["OCRSPACE_API_KEY"] + ENV["OCRSPACE_API_KEY"] = "test-api-key" + end + + teardown do + ENV["OCRSPACE_API_KEY"] = @original_key + WebMock.reset! + end + + test "raises when OCRSPACE_API_KEY is missing" do + ENV.delete("OCRSPACE_API_KEY") + + error = assert_raises(ArgumentError) do + Ocr.new + end + + assert_equal "Missing OCRSPACE_API_KEY", error.message + end + + test "raises when OCRSPACE_API_KEY is blank" do + ENV["OCRSPACE_API_KEY"] = "" + + error = assert_raises(ArgumentError) do + Ocr.new + end + + assert_equal "Missing OCRSPACE_API_KEY", error.message + end + + test "raises when api_key_env does not start with OCRSPACE_API_KEY" do + error = assert_raises(ArgumentError) do + Ocr.new(api_key_env: "SOME_OTHER_KEY") + end + + assert_equal "Key 'SOME_OTHER_KEY' must start with 'OCRSPACE_API_KEY'", error.message + end + + test "accepts custom api_key_env with OCRSPACE_API_KEY prefix" do + ENV["OCRSPACE_API_KEY_CUSTOM"] = "custom-key" + + stub_success("hello") + + client = Ocr.new(api_key_env: "OCRSPACE_API_KEY_CUSTOM") + result = client.parse(StringIO.new("fake"), filetype: "image/jpeg") + + assert_equal "hello", result.text + end + + test "parse with IO object sends base64 encoded image" do + stub_success("Extracted text") + + io = StringIO.new("fake image data") + client = Ocr.new + result = client.parse(io, filetype: "image/jpeg") + + assert_equal "Extracted text", result.text + assert_requested :post, "https://api.ocr.space/parse/image", + headers: { "Apikey" => "test-api-key" } + end + + test "parse with IO requires filetype" do + client = Ocr.new + + error = assert_raises(ArgumentError) do + client.parse(StringIO.new("data")) + end + + assert_equal "filetype required for IO objects (e.g. filetype: 'image/jpeg')", error.message + end + + test "parse with file path auto-detects MIME type" do + stub_success("File text") + + tempfile = Tempfile.new([ "test", ".png" ]) + tempfile.write("fake png data") + tempfile.rewind + + client = Ocr.new + result = client.parse(tempfile.path) + + assert_equal "File text", result.text + ensure + tempfile&.close + tempfile&.unlink + end + + test "parse with unsupported extension raises" do + client = Ocr.new + + error = assert_raises(ArgumentError) do + client.parse("/tmp/file.xyz") + end + + assert_match "Unsupported file extension: '.xyz'", error.message + end + + test "parse passes language parameter" do + stub_success("Polski tekst") + + client = Ocr.new + result = client.parse(StringIO.new("data"), filetype: "image/jpeg", language: "pol") + + assert_equal "Polski tekst", result.text + end + + test "parse passes engine parameter" do + stub_success("Engine 2 text") + + client = Ocr.new + result = client.parse(StringIO.new("data"), filetype: "image/jpeg", engine: 2) + + assert_equal "Engine 2 text", result.text + end + + test "parse passes overlay parameter" do + stub_success("Overlay text") + + client = Ocr.new + result = client.parse(StringIO.new("data"), filetype: "image/jpeg", overlay: true) + + assert_equal "Overlay text", result.text + end + + test "parse raises on HTTP error" do + stub_request(:post, "https://api.ocr.space/parse/image") + .to_return(status: 500, body: "internal error") + + client = Ocr.new + + error = assert_raises(RuntimeError) do + client.parse(StringIO.new("data"), filetype: "image/jpeg") + end + + assert_equal "OCR request failed: 500", error.message + end + + test "parse raises on API error" do + stub_request(:post, "https://api.ocr.space/parse/image") + .to_return( + status: 200, + body: { + IsErroredOnProcessing: true, + ErrorMessage: "Invalid image format", + OCRExitCode: "4" + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + client = Ocr.new + + error = assert_raises(RuntimeError) do + client.parse(StringIO.new("data"), filetype: "image/jpeg") + end + + assert_equal "OCR API error: Invalid image format", error.message + end + + test "result success? returns true when exit code is 1" do + stub_success("ok") + + client = Ocr.new + result = client.parse(StringIO.new("data"), filetype: "image/jpeg") + + assert result.success? + end + + test "result partial? returns true when exit code is 2" do + stub_request(:post, "https://api.ocr.space/parse/image") + .to_return( + status: 200, + body: { + ParsedResults: [ + { ParsedText: "page 1", FileParseExitCode: "1" }, + { ParsedText: "page 2", FileParseExitCode: "1" } + ], + OCRExitCode: "2", + IsErroredOnProcessing: false, + ProcessingTimeInMilliseconds: "1500" + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + client = Ocr.new + result = client.parse(StringIO.new("data"), filetype: "image/jpeg") + + assert result.partial? + assert_equal "page 1\npage 2", result.text + assert_equal 1500, result.processing_time_ms + end + + test "result is enumerable over pages" do + stub_request(:post, "https://api.ocr.space/parse/image") + .to_return( + status: 200, + body: { + ParsedResults: [ + { ParsedText: "page 1", FileParseExitCode: "1" }, + { ParsedText: "page 2", FileParseExitCode: "1" } + ], + OCRExitCode: "1", + IsErroredOnProcessing: false + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + client = Ocr.new + result = client.parse(StringIO.new("data"), filetype: "image/jpeg") + + assert_equal 2, result.count + assert_equal "page 1", result.first.text + assert_equal "page 2", result.to_a.last.text + end + + test "page success? returns true for successful parse" do + stub_success("ok") + + client = Ocr.new + result = client.parse(StringIO.new("data"), filetype: "image/jpeg") + + assert result.first.success? + assert_nil result.first.error_message + end + + test "page exposes error details" do + stub_request(:post, "https://api.ocr.space/parse/image") + .to_return( + status: 200, + body: { + ParsedResults: [ + { + ParsedText: "", + FileParseExitCode: "-10", + ErrorMessage: "OCR Engine Error", + ErrorDetails: "Could not process image" + } + ], + OCRExitCode: "3", + IsErroredOnProcessing: false + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + client = Ocr.new + result = client.parse(StringIO.new("data"), filetype: "image/jpeg") + + refute result.first.success? + assert_equal "OCR Engine Error", result.first.error_message + assert_equal "Could not process image", result.first.error_details + end + + private + + def stub_success(parsed_text) + stub_request(:post, "https://api.ocr.space/parse/image") + .to_return( + status: 200, + body: { + ParsedResults: [ + { ParsedText: parsed_text, FileParseExitCode: "1" } + ], + OCRExitCode: "1", + IsErroredOnProcessing: false, + ProcessingTimeInMilliseconds: "500" + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + end + end +end diff --git a/test/workflows/summerhouse_monitoring_test.rb b/test/workflows/summerhouse_monitoring_test.rb deleted file mode 100644 index 61c0222f..00000000 --- a/test/workflows/summerhouse_monitoring_test.rb +++ /dev/null @@ -1,36 +0,0 @@ -require "test_helper" - -module Workflows - class SummerhouseMonitoringTest < ActiveSupport::TestCase - setup do - @original = ENV["R3X_WORKFLOW_PATHS"] - ENV["R3X_WORKFLOW_PATHS"] = Rails.root.join("workflows").to_s - R3x::Workflow::PackLoader.load!(force: true) - end - - teardown do - ENV["R3X_WORKFLOW_PATHS"] = @original - R3x::Workflow::PackLoader.load!(force: true) - end - - test "declares manual and schedule triggers" do - wf = R3x::Workflow::Registry.fetch("summerhouse_monitoring") - assert_equal 2, wf.triggers.size - assert_equal :manual, wf.triggers[0].type - assert_equal :schedule, wf.triggers[1].type - assert_equal "0 12 * * *", wf.triggers[1].cron - end - - test "declares networking and llm capabilities" do - wf = R3x::Workflow::Registry.fetch("summerhouse_monitoring") - assert wf.uses?(:networking) - assert wf.uses?(:llm) - assert_equal({ api_key_env: "GEMINI_API_KEY_MICHAL" }, wf.llm_config) - end - - test "workflow_key is summerhouse_monitoring" do - wf = R3x::Workflow::Registry.fetch("summerhouse_monitoring") - assert_equal "summerhouse_monitoring", wf.workflow_key - end - end -end