BUCKYVERSE

Scanno Detection Engine

The scanno detection engine is the core component that uses LLMs to identify OCR errors in processed content.

Core Implementation

defmodule Codexis.QualityChecker.ScannoDetector do
  @moduledoc """
  Main scanno detection engine using LLMs
  """
  
  alias Codexis.LLM.OllamaClient
  alias Codexis.QualityChecker.LaTeXProcessor
  
  @correction_schema %{
    corrections: %{
      type: "array",
      items: %{
        type: "object",
        properties: %{
          original: %{type: "string"},
          corrected: %{type: "string"},
          reason: %{type: "string"},
          confidence: %{type: "number", minimum: 0, maximum: 1},
          position: %{type: "integer"}
        }
      }
    }
  }
  
  def detect_scannos(latex_content, opts \\ []) do
    model = Keyword.get(opts, :model, "qwen3.0:8b")
    parallel = Keyword.get(opts, :parallel, true)
    
    content_parts = LaTeXProcessor.extract_checkable_content(latex_content)
    
    results = if parallel do
      detect_parallel(content_parts, model)
    else
      detect_sequential(content_parts, model)
    end
    
    aggregate_results(results)
  end
  
  defp detect_parallel(content_parts, model) do
    content_parts.text_blocks
    |> Flow.from_enumerable(max_demand: 5)
    |> Flow.map(fn block ->
      {block.index, check_text_block(block, model)}
    end)
    |> Enum.to_list()
    |> Map.new()
  end
  
  defp detect_sequential(content_parts, model) do
    content_parts.text_blocks
    |> Enum.map(fn block ->
      {block.index, check_text_block(block, model)}
    end)
    |> Map.new()
  end
  
  defp check_text_block(block, model) do
    prompt = build_scanno_prompt(block.content)
    
    case OllamaClient.generate_structured(prompt, @correction_schema, model: model) do
      {:ok, result} ->
        process_corrections(result, block)
      {:error, reason} ->
        %{error: reason, block: block.index}
    end
  end
  
  defp build_scanno_prompt(text) do
    """
    You are an expert proofreader checking for OCR errors (scannos) in academic text.
    
    Common OCR errors include:
    - Character substitutions: rn→m, cl→d, li→h, 1→l, 0→O
    - Merged words: "ofthe" instead of "of the"  
    - Split words: "be cause" instead of "because"
    - Punctuation errors: ,→. or :→;
    - Missing spaces after punctuation
    - Incorrect quotation marks
    
    Analyze this text for OCR errors:
    
    #{text}
    
    For each error found, provide:
    1. The original incorrect text
    2. The corrected version
    3. Brief explanation of why it's likely an error
    4. Confidence level (0.0-1.0)
    5. Character position in the text
    
    Focus on actual OCR errors, not style or grammar unless they indicate scanning problems.
    """
  end
  
  defp process_corrections(%{"corrections" => corrections}, block) do
    corrections
    |> Enum.map(fn correction ->
      Map.merge(correction, %{
        "block_index" => block.index,
        "line_start" => block.line_start,
        "absolute_position" => block.line_start + correction["position"]
      })
    end)
    |> Enum.filter(fn c -> c["confidence"] >= 0.7 end)
  end
  
  defp aggregate_results(results) do
    corrections = results
    |> Map.values()
    |> List.flatten()
    |> Enum.filter(&is_map/1)
    |> Enum.sort_by(& &1["absolute_position"])
    |> Enum.uniq_by(fn c ->
      {c["absolute_position"], c["original"], c["corrected"]}
    end)
    
    %{
      high: Enum.filter(corrections, fn c -> c["confidence"] >= 0.9 end),
      medium: Enum.filter(corrections, fn c -> 
        c["confidence"] >= 0.7 && c["confidence"] < 0.9 
      end),
      low: Enum.filter(corrections, fn c -> 
        c["confidence"] >= 0.5 && c["confidence"] < 0.7 
      end)
    }
  end
end

Advanced Detection Strategies

defmodule Codexis.QualityChecker.AdvancedDetection do
  @moduledoc """
  Advanced scanno detection strategies
  """
  
  alias Codexis.LLM.OllamaClient
  
  def context_aware_detection(block, surrounding_blocks, model) do
    # Use surrounding context for better detection
    context = build_context(block, surrounding_blocks)
    
    prompt = """
    Analyze this text for OCR errors, considering the surrounding context:
    
    PREVIOUS: #{context.previous}
    
    CURRENT: #{block.content}
    
    NEXT: #{context.next}
    
    Look for errors that become apparent when considering the context.
    """
    
    OllamaClient.generate_structured(prompt, correction_schema(), model: model)
  end
  
  def domain_specific_detection(block, domain_info, model) do
    # Use domain knowledge for specialized detection
    prompt = """
    You are checking academic text in the field of #{domain_info.field}.
    
    Common terminology includes: #{Enum.join(domain_info.terms, ", ")}
    
    Check this text for OCR errors specific to this domain:
    
    #{block.content}
    
    Pay special attention to:
    - Technical terms that might be misread
    - Domain-specific abbreviations
    - Specialized notation
    """
    
    OllamaClient.generate_structured(prompt, correction_schema(), model: model)
  end
  
  def historical_pattern_detection(block, historical_patterns, model) do
    # Use past corrections to improve detection
    prompt = """
    Based on previous corrections in this document, common errors include:
    #{format_historical_patterns(historical_patterns)}
    
    Check this text for similar OCR errors:
    
    #{block.content}
    """
    
    OllamaClient.generate_structured(prompt, correction_schema(), model: model)
  end
  
  defp build_context(block, surrounding_blocks) do
    previous = surrounding_blocks
    |> Enum.filter(fn b -> b.index < block.index end)
    |> Enum.sort_by(& &1.index, :desc)
    |> Enum.take(2)
    |> Enum.map(& &1.content)
    |> Enum.join(" ")
    
    next = surrounding_blocks
    |> Enum.filter(fn b -> b.index > block.index end)
    |> Enum.sort_by(& &1.index)
    |> Enum.take(2)
    |> Enum.map(& &1.content)
    |> Enum.join(" ")
    
    %{previous: previous, next: next}
  end
  
  defp format_historical_patterns(patterns) do
    patterns
    |> Enum.map(fn pattern ->
      "- #{pattern.original} → #{pattern.corrected} (#{pattern.count} times)"
    end)
    |> Enum.join("\n")
  end
  
  defp correction_schema do
    %{
      corrections: %{
        type: "array",
        items: %{
          type: "object",
          properties: %{
            original: %{type: "string"},
            corrected: %{type: "string"},
            reason: %{type: "string"},
            confidence: %{type: "number"},
            position: %{type: "integer"}
          }
        }
      }
    }
  end
end

Multi-Model Detection

defmodule Codexis.QualityChecker.MultiModelDetection do
  @moduledoc """
  Uses multiple models for higher accuracy
  """
  
  alias Codexis.LLM.OllamaClient
  
  def ensemble_detection(block, models \\ ["qwen3.0:8b", "deepseek-r1:8b"]) do
    results = models
    |> Enum.map(fn model ->
      Task.async(fn ->
        {model, detect_with_model(block, model)}
      end)
    end)
    |> Enum.map(&Task.await/1)
    
    aggregate_model_results(results)
  end
  
  defp detect_with_model(block, model) do
    prompt = build_scanno_prompt(block.content)
    
    case OllamaClient.generate_structured(prompt, correction_schema(), model: model) do
      {:ok, result} -> result
      {:error, _} -> %{"corrections" => []}
    end
  end
  
  defp aggregate_model_results(results) do
    corrections = results
    |> Enum.flat_map(fn {model, result} ->
      result["corrections"]
      |> Enum.map(fn correction ->
        Map.put(correction, "model", model)
      end)
    end)
    
    # Group by error location and text
    grouped = Enum.group_by(corrections, fn c ->
      {c["position"], c["original"], c["corrected"]}
    end)
    
    # Aggregate confidence scores
    aggregated = grouped
    |> Enum.map(fn {{pos, orig, corr}, group} ->
      avg_confidence = 
        group
        |> Enum.map(& &1["confidence"])
        |> mean()
      
      %{
        "position" => pos,
        "original" => orig,
        "corrected" => corr,
        "confidence" => avg_confidence,
        "models_agreed" => length(group),
        "reason" => aggregate_reasons(group)
      }
    end)
    
    %{"corrections" => aggregated}
  end
  
  defp mean(numbers) do
    Enum.sum(numbers) / length(numbers)
  end
  
  defp aggregate_reasons(group) do
    group
    |> Enum.map(& &1["reason"])
    |> Enum.uniq()
    |> Enum.join("; ")
  end
  
  defp build_scanno_prompt(text) do
    """
    Check this text for OCR errors (scannos):
    
    #{text}
    
    Common OCR errors include character substitutions (rn→m, cl→d), 
    merged/split words, and punctuation errors.
    """
  end
  
  defp correction_schema do
    %{
      corrections: %{
        type: "array",
        items: %{
          type: "object",
          properties: %{
            original: %{type: "string"},
            corrected: %{type: "string"},
            reason: %{type: "string"},
            confidence: %{type: "number"},
            position: %{type: "integer"}
          }
        }
      }
    }
  end
end

Confidence Calibration

defmodule Codexis.QualityChecker.ConfidenceCalibration do
  @moduledoc """
  Calibrates confidence scores based on historical accuracy
  """
  
  def calibrate_confidence(correction, historical_data) do
    base_confidence = correction["confidence"]
    
    # Adjust based on pattern frequency
    pattern_adjustment = calculate_pattern_adjustment(correction, historical_data)
    
    # Adjust based on model performance
    model_adjustment = calculate_model_adjustment(correction, historical_data)
    
    # Adjust based on context
    context_adjustment = calculate_context_adjustment(correction)
    
    calibrated = base_confidence * pattern_adjustment * model_adjustment * context_adjustment
    
    Map.put(correction, "calibrated_confidence", min(1.0, calibrated))
  end
  
  defp calculate_pattern_adjustment(correction, historical_data) do
    pattern = {correction["original"], correction["corrected"]}
    
    case Map.get(historical_data.patterns, pattern) do
      nil -> 1.0
      data -> 
        accuracy = data.correct_count / data.total_count
        if accuracy > 0.8, do: 1.2, else: 0.8
    end
  end
  
  defp calculate_model_adjustment(correction, historical_data) do
    model = correction["model"]
    
    case Map.get(historical_data.model_performance, model) do
      nil -> 1.0
      performance -> performance.accuracy
    end
  end
  
  defp calculate_context_adjustment(correction) do
    cond do
      String.length(correction["original"]) == 1 -> 0.8  # Single char less reliable
      String.contains?(correction["reason"], "common pattern") -> 1.1
      true -> 1.0
    end
  end
end