diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 00000000..957ebebe
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,10 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(node --input-type=module --check)",
+ "Bash(bundle exec *)",
+ "Bash(git stash *)",
+ "Bash(bundle exec rails runner ' *)"
+ ]
+ }
+}
diff --git a/app/controllers/ocr_controller.rb b/app/controllers/ocr_controller.rb
index a69255ba..df7fd663 100644
--- a/app/controllers/ocr_controller.rb
+++ b/app/controllers/ocr_controller.rb
@@ -2,34 +2,19 @@ class OcrController < ApplicationController
include Secured
def scan
- # Process only the first uploaded file
- file = params[:files].first
+ files = Array(params[:files])
ai_method = params[:ai_method] || 'mistral_only'
+ ocr_flags = build_ocr_flags(files)
+ ocr_files = files.select.with_index { |_, index| ocr_flags[index] }
- begin
- # Process based on selected AI method
- magic_data_json = if ai_method == 'mistral_only'
- # Two-phase: Mistral OCR -> Mistral parsing
- markdown = mistral_service.ocr_to_markdown(file.tempfile, file.content_type)
-
- if markdown.blank?
- raise "Mistral OCR returned empty markdown"
- end
-
- mistral_service.parse_markdown_to_recipes(markdown)
- elsif ai_method == 'mistral_openai'
- # Two-phase: Mistral OCR -> OpenAI parsing
- markdown = mistral_service.ocr_to_markdown(file.tempfile, file.content_type)
-
- if markdown.blank?
- raise "Mistral OCR returned empty markdown"
- end
+ if ocr_files.empty?
+ render json: { success: false, error: I18n.t('ocr.errors.no_images_selected') }
+ return
+ end
- openai_service.parse_markdown_to_recipes(markdown)
- else
- # Direct OpenAI OCR
- openai_service.ocr(file.tempfile, file.content_type)
- end
+ begin
+ files_with_types = ocr_files.map { |f| [f.tempfile, f.content_type] }
+ magic_data_json = ocr_pipeline.extract_recipes(files_with_types, ai_method)
if magic_data_json.empty?
raise "No recipes extracted from image"
@@ -39,11 +24,11 @@ def scan
# Save full OCR result array to database and store id in flash to avoid flash size limits
ocrresult = OcrResult.create(result: magic_data_json.to_json, ai_method: ai_method)
- ocrresult.image.attach(file)
+ ocrresult.image.attach(files.first)
ocrresult.save
- if params[:files].length > 1
- params[:files][1..].each { |f| ocrresult.extra_images.attach(f) }
+ if files.length > 1
+ files[1..].each { |f| ocrresult.extra_images.attach(f) }
end
logger.debug "OCR data id stored in flash: #{ocrresult.id}"
@@ -149,29 +134,7 @@ def reparse_image
temp_file.write(image_file)
temp_file.rewind
- # Process based on selected AI method
- magic_data_json = if ai_method == 'mistral_only'
- # Two-phase: Mistral OCR -> Mistral parsing
- markdown = mistral_service.ocr_to_markdown(temp_file, content_type)
-
- if markdown.blank?
- raise "Mistral OCR returned empty markdown"
- end
-
- mistral_service.parse_markdown_to_recipes(markdown)
- elsif ai_method == 'mistral_openai'
- # Two-phase: Mistral OCR -> OpenAI parsing
- markdown = mistral_service.ocr_to_markdown(temp_file, content_type)
-
- if markdown.blank?
- raise "Mistral OCR returned empty markdown"
- end
-
- openai_service.parse_markdown_to_recipes(markdown)
- else
- # Direct OpenAI OCR
- openai_service.ocr(temp_file, content_type)
- end
+ magic_data_json = ocr_pipeline.extract_recipes([[temp_file, content_type]], ai_method)
if magic_data_json.empty?
raise "No recipes extracted from image"
@@ -210,11 +173,22 @@ def reparse_image
private
- def openai_service
- @openai_service ||= OpenaiService.new
+ BOOLEAN_TYPE = ActiveModel::Type::Boolean.new
+
+ # Builds a boolean array (same length/order as files) indicating which files should be sent for OCR.
+ # Missing, blank, or unparseable ocr_flags default to true (include all); only an explicit false excludes.
+ def build_ocr_flags(files)
+ raw_flags = Array(params[:ocr_flags])
+ return Array.new(files.length, true) if raw_flags.empty?
+
+ files.each_index.map { |index| BOOLEAN_TYPE.cast(raw_flags[index]) != false }
+ end
+
+ def ocr_pipeline
+ @ocr_pipeline ||= OcrPipeline.new
end
- def mistral_service
- @mistral_service ||= MistralaiService.new
+ def openai_service
+ @openai_service ||= OpenaiService.new
end
end
diff --git a/app/controllers/recipes_controller.rb b/app/controllers/recipes_controller.rb
index 45f95e15..9f2539a6 100644
--- a/app/controllers/recipes_controller.rb
+++ b/app/controllers/recipes_controller.rb
@@ -121,51 +121,6 @@ def favorite
render json: { redirect_url: recipe_path(recipe.id) }
end
- def reparse_image
- @recipe = Recipe.find(params[:id])
- attachment_id = params[:attachment_id]
-
- begin
- # Find the selected image attachment
- attachment = @recipe.recipe_images.find(attachment_id)
-
- # Download the image blob and get content type
- blob = attachment.blob
- image_file = blob.download
- content_type = blob.content_type
-
- # Create a temporary file for the OpenAI service
- temp_file = Tempfile.new(['recipe_image', File.extname(blob.filename.to_s)])
- temp_file.binmode
- temp_file.write(image_file)
- temp_file.rewind
-
- # Call OpenAI service to parse the image
- openai = OpenaiService.new
- magic_data_json = openai.ocr(temp_file, content_type)
-
- # Save OCR result to database
- ocrresult = OcrResult.create(result: magic_data_json.to_s)
- ocrresult.image.attach(blob)
- ocrresult.save
- flash[:ocr_data] = ocrresult.id
-
- logger.debug "Reparse OCR data id stored in flash: #{flash[:ocr_data]}"
-
- redirect_to edit_recipe_path(@recipe)
- rescue JSON::ParserError => e
- logger.error "Reparse JSON parse error: #{e.message}"
- flash[:error] = I18n.t('ocr.errors.parse_failed')
- redirect_to recipe_path(@recipe)
- rescue => e
- logger.error "Reparse error: #{e.message}"
- flash[:error] = I18n.t('ocr.errors.processing_failed')
- redirect_to recipe_path(@recipe)
- ensure
- temp_file&.close
- temp_file&.unlink
- end
- end
def destroy
@recipe = Recipe.find(params[:id])
diff --git a/app/javascript/controllers/dropzone_controller.js b/app/javascript/controllers/dropzone_controller.js
index 0e552029..736f6172 100644
--- a/app/javascript/controllers/dropzone_controller.js
+++ b/app/javascript/controllers/dropzone_controller.js
@@ -3,7 +3,7 @@ import { Utils } from "../src/utils"
export default class extends Controller {
static targets = ["dropzone", "fileInput", "spinner", "successMessage", "errorMessage", "resetButton", "previewContainer", "uploadButton"]
- static values = { url: String, previewMode: Boolean }
+ static values = { url: String, previewMode: Boolean, ocrBadgeTitle: String, noImagesSelectedError: String }
connect() {
this.maxFiles = 10
@@ -11,6 +11,7 @@ export default class extends Controller {
this.acceptedExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif']
this.pendingFiles = []
this.pendingObjectUrls = []
+ this.pendingOcrFlags = []
// Initialize button state in preview mode
if (this.previewModeValue && this.hasUploadButtonTarget) {
@@ -92,6 +93,7 @@ export default class extends Controller {
if (this.previewModeValue) {
for (let i = 0; i < files.length; i++) {
this.pendingFiles.push(files[i])
+ this.pendingOcrFlags.push(true)
}
this.renderPreviews()
this.uploadButtonTarget.disabled = false
@@ -121,6 +123,11 @@ export default class extends Controller {
const objectUrl = URL.createObjectURL(file)
this.pendingObjectUrls.push(objectUrl)
+ const included = this.pendingOcrFlags[index]
+ const badgeClass = included
+ ? 'bg-blue-500 text-white'
+ : 'bg-gray-300 text-gray-500 opacity-60'
+
const card = document.createElement('div')
card.className = 'relative rounded-lg overflow-hidden border border-gray-200 bg-gray-50'
card.innerHTML = `
@@ -133,7 +140,13 @@ export default class extends Controller {
aria-label="Remove">
- ${index === 0 ? '
AI
' : ''}
+
`
grid.appendChild(card)
})
@@ -145,6 +158,7 @@ export default class extends Controller {
removeFile(event) {
const index = parseInt(event.currentTarget.dataset.index, 10)
this.pendingFiles.splice(index, 1)
+ this.pendingOcrFlags.splice(index, 1)
if (this.pendingFiles.length === 0) {
this.uploadButtonTarget.disabled = true
@@ -157,19 +171,33 @@ export default class extends Controller {
}
}
+ toggleOcr(event) {
+ event.stopPropagation()
+ const index = parseInt(event.currentTarget.dataset.index, 10)
+ this.pendingOcrFlags[index] = !this.pendingOcrFlags[index]
+ this.renderPreviews()
+ }
+
submit() {
+ if (this.pendingOcrFlags.every(flag => !flag)) {
+ this.showError(this.noImagesSelectedErrorValue)
+ return
+ }
+
const filesToUpload = this.pendingFiles.slice()
+ const ocrFlagsToUpload = this.pendingOcrFlags.slice()
this.pendingFiles = []
+ this.pendingOcrFlags = []
this.uploadButtonTarget.disabled = true
this.previewContainerTarget.classList.add('hidden')
- this.uploadFiles(filesToUpload)
+ this.uploadFiles(filesToUpload, ocrFlagsToUpload)
}
escapeHtml(str) {
return str.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
}
- uploadFiles(files) {
+ uploadFiles(files, ocrFlags = null) {
// Hide dropzone
this.dropzoneTarget.classList.add('hidden')
@@ -180,6 +208,9 @@ export default class extends Controller {
const formData = new FormData()
for (let i = 0; i < files.length; i++) {
formData.append('files[]', files[i])
+ if (ocrFlags) {
+ formData.append('ocr_flags[]', ocrFlags[i] ? 'true' : 'false')
+ }
}
// Add AI method selection if present
@@ -240,6 +271,7 @@ export default class extends Controller {
// Clear any pending preview state
this.pendingFiles = []
+ this.pendingOcrFlags = []
this.pendingObjectUrls.forEach(url => URL.revokeObjectURL(url))
this.pendingObjectUrls = []
if (this.hasPreviewContainerTarget) {
diff --git a/app/services/image_data_uri.rb b/app/services/image_data_uri.rb
new file mode 100644
index 00000000..f424ca9d
--- /dev/null
+++ b/app/services/image_data_uri.rb
@@ -0,0 +1,24 @@
+module ImageDataUri
+ def build_data_uri(image_file, content_type)
+ image_data = if image_file.respond_to?(:read)
+ image_file.rewind if image_file.respond_to?(:rewind)
+ data = Base64.strict_encode64(image_file.read)
+ image_file.rewind if image_file.respond_to?(:rewind)
+ data
+ elsif image_file.is_a?(String)
+ File.open(image_file, 'rb') { |f| Base64.strict_encode64(f.read) }
+ else
+ raise ArgumentError, "Invalid image_file type"
+ end
+
+ image_format = case content_type
+ when /jpeg|jpg/ then 'jpeg'
+ when /png/ then 'png'
+ when /webp/ then 'webp'
+ when /heic|heif/ then 'heic'
+ else 'jpeg' # default
+ end
+
+ "data:image/#{image_format};base64,#{image_data}"
+ end
+end
diff --git a/app/services/mistralai_service.rb b/app/services/mistralai_service.rb
index 690cf723..c71272d3 100644
--- a/app/services/mistralai_service.rb
+++ b/app/services/mistralai_service.rb
@@ -1,4 +1,7 @@
class MistralaiService
+ include ImageDataUri
+ include RecipeJsonParser
+
def initialize
# API key validation happens at runtime (not initialization) to allow
# the service to be instantiated even when Mistral AI is not configured.
@@ -10,31 +13,7 @@ def initialize
end
def ocr_to_markdown(image_file, content_type)
- # Read and encode the image file as base64
- image_data = if image_file.respond_to?(:read)
- # Handle uploaded file (Tempfile)
- image_file.rewind
- data = Base64.strict_encode64(image_file.read)
- image_file.rewind # Reset for potential subsequent reads
- data
- elsif image_file.is_a?(String)
- # Handle file path
- File.open(image_file, 'rb') { |f| Base64.strict_encode64(f.read) }
- else
- raise ArgumentError, "Invalid image_file type"
- end
-
- # Determine the image format from content_type or file extension
- image_format = case content_type
- when /jpeg|jpg/ then 'jpeg'
- when /png/ then 'png'
- when /webp/ then 'webp'
- when /heic|heif/ then 'heic'
- else 'jpeg' # default
- end
-
- Rails.logger.debug "Image format detected: #{image_format} for content type: #{content_type}"
- filedata = "data:image/#{image_format};base64,#{image_data}"
+ filedata = build_data_uri(image_file, content_type)
response = @client.ocr(filedata, kind: :image)
recognized_markdown = response.pages[0].markdown
@@ -59,20 +38,7 @@ def parse_markdown_to_recipes(markdown_text)
Rails.logger.debug "Mistral AI markdown parsing response: #{completion.text}"
llm_response_text = completion.text.gsub(/```json/, '').gsub(/```/, '')
-
- begin
- parsed = JSON.parse(llm_response_text)
- recipes = parsed['recipes'] || []
- Rails.logger.warn "No recipes found in Mistral AI response" if recipes.empty?
- Rails.logger.info "Mistral AI parsed #{recipes.length} recipes from markdown"
- recipes
- rescue JSON::ParserError => e
- Rails.logger.error "Failed to parse Mistral AI markdown response: #{e.message}"
- raise
- rescue => e
- Rails.logger.error "Unexpected error parsing recipes from markdown: #{e.message}"
- raise
- end
+ parse_recipes_json(llm_response_text, "Mistral AI markdown parsing", on_error: :raise)
end
def parse_url_to_recipes(markdown_text)
@@ -89,19 +55,6 @@ def parse_url_to_recipes(markdown_text)
Rails.logger.debug "Mistral AI URL parsing response: #{completion.text}"
llm_response_text = completion.text.gsub(/```json/, '').gsub(/```/, '')
-
- begin
- parsed = JSON.parse(llm_response_text)
- recipes = parsed['recipes'] || []
- Rails.logger.warn "No recipes found in Mistral AI URL response" if recipes.empty?
- Rails.logger.info "Mistral AI parsed #{recipes.length} recipes from URL markdown"
- recipes
- rescue JSON::ParserError => e
- Rails.logger.error "Failed to parse Mistral AI URL response: #{e.message}"
- raise
- rescue => e
- Rails.logger.error "Unexpected error parsing recipes from URL: #{e.message}"
- raise
- end
+ parse_recipes_json(llm_response_text, "Mistral AI URL parsing", on_error: :raise)
end
end
diff --git a/app/services/ocr_pipeline.rb b/app/services/ocr_pipeline.rb
new file mode 100644
index 00000000..7e24a729
--- /dev/null
+++ b/app/services/ocr_pipeline.rb
@@ -0,0 +1,38 @@
+class OcrPipeline
+ PAGE_BREAK_MARKER = "\n\n---\n**[NEW SCANNED PAGE]**\n---\n\n"
+
+ # files_with_types is an array of [tempfile, content_type] pairs.
+ def extract_recipes(files_with_types, ai_method)
+ case ai_method
+ when 'mistral_only'
+ mistral_service.parse_markdown_to_recipes(combined_ocr_markdown(files_with_types))
+ when 'mistral_openai'
+ openai_service.parse_markdown_to_recipes(combined_ocr_markdown(files_with_types))
+ else
+ openai_service.ocr_multi(files_with_types)
+ end
+ end
+
+ private
+
+ # Runs Mistral OCR on each image in turn, then joins the resulting markdown
+ # pages into one document (separated by PAGE_BREAK_MARKER) for a single parse step.
+ def combined_ocr_markdown(files_with_types)
+ markdowns = files_with_types.each_with_index.map do |(tempfile, content_type), index|
+ markdown = mistral_service.ocr_to_markdown(tempfile, content_type)
+ raise "Mistral OCR returned empty markdown for image #{index + 1}" if markdown.blank?
+
+ markdown
+ end
+
+ markdowns.join(PAGE_BREAK_MARKER)
+ end
+
+ def openai_service
+ @openai_service ||= OpenaiService.new
+ end
+
+ def mistral_service
+ @mistral_service ||= MistralaiService.new
+ end
+end
diff --git a/app/services/openai_service.rb b/app/services/openai_service.rb
index 339edffc..ecf1deca 100644
--- a/app/services/openai_service.rb
+++ b/app/services/openai_service.rb
@@ -1,4 +1,7 @@
class OpenaiService
+ include ImageDataUri
+ include RecipeJsonParser
+
def initialize
# Try environment variable first, fallback to credentials for development
api_key = ENV['OPENAI_API_KEY']
@@ -26,32 +29,12 @@ def cleanup(text, prompt)
response.dig("choices", 0, "message", "content")
end
- def ocr(image_file, content_type)
- # Read and encode the image file as base64
- image_data = if image_file.respond_to?(:read)
- # Handle uploaded file (Tempfile)
- Base64.strict_encode64(image_file.read)
- elsif image_file.is_a?(String)
- # Handle file path
- File.open(image_file, 'rb') { |f| Base64.strict_encode64(f.read) }
- else
- raise ArgumentError, "Invalid image_file type"
- end
-
- # Determine the image format from content_type or file extension
- image_format = case content_type
- when /jpeg|jpg/ then 'jpeg'
- when /png/ then 'png'
- when /webp/ then 'webp'
- when /heic|heif/ then 'heic'
- else 'jpeg' # default
- end
-
- # puts "Image format detected: #{image_format} for content type: #{content_type} content #{image_data[0..30]}..."
- filedata = "data:image/#{image_format};base64,#{image_data}"
+ def ocr_multi(image_files_with_types)
+ image_blocks = image_files_with_types.map { |image_file, content_type| build_image_content_block(image_file, content_type) }
+
model = Rails.configuration.openai.recipe_ocr_model
system_prompt = File.read(Rails.root.join("config", "prompts", Rails.configuration.openai.recipe_ocr_prompt_file))
- Rails.logger.debug "Sending data to OpenAI API (model #{model}) -> #{filedata[0..100]}..."
+ Rails.logger.debug "Sending #{image_blocks.length} image(s) to OpenAI API (model #{model})"
response = @client.responses.create(
parameters: {
@@ -66,12 +49,9 @@ def ocr(image_file, content_type)
content: [
{
type: "input_text",
- text: "Extract all data from this image"
+ text: "Extract all data from the following image(s)"
},
- {
- type: "input_image",
- image_url: filedata
- }
+ *image_blocks
]
}
],
@@ -88,23 +68,7 @@ def ocr(image_file, content_type)
Rails.logger.debug "OpenAI OCR response: #{response}"
- output = response.dig("output") || []
- message = output.find { |item| item["type"] == "message" }
- llm_response_text = message&.dig("content", 0, "text")
-
- begin
- parsed = JSON.parse(llm_response_text)
- recipes = parsed['recipes'] || []
- Rails.logger.warn "No recipes found in OpenAI response" if recipes.empty?
- Rails.logger.info "OpenAI OCR recipes: #{recipes}"
- recipes
- rescue JSON::ParserError => e
- Rails.logger.error "Failed to parse OpenAI response: #{e.message}"
- []
- rescue => e
- Rails.logger.error "Unexpected error parsing recipes: #{e.message}"
- []
- end
+ extract_recipes_from_response(response, "OpenAI OCR")
end
def parse_markdown_to_recipes(markdown_text)
@@ -138,23 +102,7 @@ def parse_markdown_to_recipes(markdown_text)
Rails.logger.debug "OpenAI markdown parsing response: #{response}"
- output = response.dig("output") || []
- message = output.find { |item| item["type"] == "message" }
- llm_response_text = message&.dig("content", 0, "text")
-
- begin
- parsed = JSON.parse(llm_response_text)
- recipes = parsed['recipes'] || []
- Rails.logger.warn "No recipes found in OpenAI markdown parsing response" if recipes.empty?
- Rails.logger.info "OpenAI parsed #{recipes.length} recipes from markdown"
- recipes
- rescue JSON::ParserError => e
- Rails.logger.error "Failed to parse OpenAI markdown response: #{e.message}"
- raise
- rescue => e
- Rails.logger.error "Unexpected error parsing recipes from markdown: #{e.message}"
- raise
- end
+ parse_recipes_json(extract_message_text(response), "OpenAI markdown parsing", on_error: :raise)
end
def parse_url_to_recipes(markdown_text)
@@ -188,22 +136,25 @@ def parse_url_to_recipes(markdown_text)
Rails.logger.debug "OpenAI URL parsing response: #{response}"
+ parse_recipes_json(extract_message_text(response), "OpenAI URL parsing", on_error: :raise)
+ end
+
+ private
+
+ def build_image_content_block(image_file, content_type)
+ {
+ type: "input_image",
+ image_url: build_data_uri(image_file, content_type)
+ }
+ end
+
+ def extract_message_text(response)
output = response.dig("output") || []
message = output.find { |item| item["type"] == "message" }
- llm_response_text = message&.dig("content", 0, "text")
-
- begin
- parsed = JSON.parse(llm_response_text)
- recipes = parsed['recipes'] || []
- Rails.logger.warn "No recipes found in OpenAI URL parsing response" if recipes.empty?
- Rails.logger.info "OpenAI parsed #{recipes.length} recipes from URL markdown"
- recipes
- rescue JSON::ParserError => e
- Rails.logger.error "Failed to parse OpenAI URL response: #{e.message}"
- raise
- rescue => e
- Rails.logger.error "Unexpected error parsing recipes from URL: #{e.message}"
- raise
- end
+ message&.dig("content", 0, "text")
+ end
+
+ def extract_recipes_from_response(response, log_label)
+ parse_recipes_json(extract_message_text(response), log_label)
end
end
diff --git a/app/services/recipe_json_parser.rb b/app/services/recipe_json_parser.rb
new file mode 100644
index 00000000..66338ee8
--- /dev/null
+++ b/app/services/recipe_json_parser.rb
@@ -0,0 +1,17 @@
+module RecipeJsonParser
+ def parse_recipes_json(text, log_label, on_error: :empty)
+ parsed = JSON.parse(text)
+ recipes = parsed['recipes'] || []
+ Rails.logger.warn "No recipes found in #{log_label} response" if recipes.empty?
+ Rails.logger.info "#{log_label} recipes: #{recipes}"
+ recipes
+ rescue JSON::ParserError => e
+ Rails.logger.error "Failed to parse #{log_label} response: #{e.message}"
+ raise if on_error == :raise
+ []
+ rescue => e
+ Rails.logger.error "Unexpected error parsing recipes from #{log_label}: #{e.message}"
+ raise if on_error == :raise
+ []
+ end
+end
diff --git a/app/views/shared/_dropzone.html.erb b/app/views/shared/_dropzone.html.erb
index 9a59b7d6..0bb3c8a1 100644
--- a/app/views/shared/_dropzone.html.erb
+++ b/app/views/shared/_dropzone.html.erb
@@ -14,7 +14,12 @@
preview_mode ||= false
%>
-
+
'Multi-page Recipe' }] }
+ let(:captured_args) { [] }
+
+ before do
+ allow(openai_service).to receive(:ocr_multi) { |args| captured_args.replace(args); single_recipe }
+ end
+
+ it 'sends all files in a single ocr_multi call' do
+ post :scan, params: { files: [file, file2], ai_method: 'openai_direct' }
+
+ expect(openai_service).to have_received(:ocr_multi).once
+ expect(captured_args.length).to eq(2)
+ end
+
+ it 'attaches the first file as image and the rest as extra_images' do
+ post :scan, params: { files: [file, file2], ai_method: 'openai_direct' }
+
+ ocrresult = OcrResult.last
+ expect(ocrresult.image).to be_attached
+ expect(ocrresult.extra_images.count).to eq(1)
+ end
+ end
+
+ context 'with multiple uploaded images (mistral_only)' do
+ let(:single_recipe) { [{ 'title' => 'Multi-page Recipe' }] }
+ let(:captured_markdown) { [] }
+
+ before do
+ allow(mistral_service).to receive(:ocr_to_markdown).and_return('page one markdown', 'page two markdown')
+ allow(mistral_service).to receive(:parse_markdown_to_recipes) { |markdown| captured_markdown << markdown; single_recipe }
+ end
+
+ it 'OCRs every image and parses the combined, ordered markdown once' do
+ post :scan, params: { files: [file, file2], ai_method: 'mistral_only' }
+
+ expect(mistral_service).to have_received(:ocr_to_markdown).twice
+ expect(mistral_service).to have_received(:parse_markdown_to_recipes).once
+ combined = captured_markdown.first
+ expect(combined).to include('page one markdown')
+ expect(combined).to include('page two markdown')
+ expect(combined.index('page one markdown')).to be < combined.index('page two markdown')
+ end
+ end
+
+ context 'when one of several images fails OCR (mistral_only)' do
+ before do
+ call_count = 0
+ allow(mistral_service).to receive(:ocr_to_markdown) do
+ call_count += 1
+ call_count == 1 ? 'page one markdown' : raise(StandardError, 'Mistral API Error')
+ end
+ end
+
+ it 'fails the whole request and does not create an OcrResult' do
+ expect {
+ post :scan, params: { files: [file, file2], ai_method: 'mistral_only' }
+ }.not_to change(OcrResult, :count)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['success']).to be false
+ end
+ end
+
+ context 'with per-image ocr_flags opt-out' do
+ let(:single_recipe) { [{ 'title' => 'Test Recipe' }] }
+ let(:captured_args) { [] }
+
+ before do
+ allow(openai_service).to receive(:ocr_multi) { |args| captured_args.replace(args); single_recipe }
+ end
+
+ it 'only sends flagged-in files for OCR but attaches all files' do
+ post :scan, params: {
+ files: [file, file2, file3],
+ ai_method: 'openai_direct',
+ ocr_flags: ['true', 'false', 'true']
+ }
+
+ expect(captured_args.length).to eq(2)
+
+ ocrresult = OcrResult.last
+ expect(ocrresult.extra_images.count).to eq(2)
+ end
+
+ it 'defaults to including all files when ocr_flags is omitted' do
+ post :scan, params: { files: [file, file2], ai_method: 'openai_direct' }
+
+ expect(captured_args.length).to eq(2)
+ end
+
+ it 'rejects the request when every image is excluded' do
+ expect {
+ post :scan, params: {
+ files: [file, file2],
+ ai_method: 'openai_direct',
+ ocr_flags: ['false', 'false']
+ }
+ }.not_to change(OcrResult, :count)
+
+ expect(openai_service).not_to have_received(:ocr_multi)
+ json_response = JSON.parse(response.body)
+ expect(json_response['success']).to be false
+ expect(json_response['error']).to be_present
+ end
+
+ it 'defaults a blank ocr_flags entry to include the image' do
+ post :scan, params: {
+ files: [file, file2],
+ ai_method: 'openai_direct',
+ ocr_flags: ['', 'false']
+ }
+
+ expect(captured_args.length).to eq(1)
+
+ ocrresult = OcrResult.last
+ expect(ocrresult.extra_images.count).to eq(1)
+ end
+ end
end
describe 'POST #select_recipe' do