BUCKYVERSE

This module integrates the scanno detection system into the Codexis build pipeline, allowing automatic or semi-automatic correction of OCR errors during the book building process.

Core Integration

defmodule Codexis.BuildManager.ScannoIntegration do
  @moduledoc """
  Integrates scanno detection into the build pipeline
  """
  
  alias Codexis.QualityChecker.ScannoDetector
  alias Codexis.Review.InteractiveScannoReview
  
  def check_manuscript_for_scannos(manuscript_path, opts \\ []) do
    with {:ok, content} <- File.read(manuscript_path),
         {:ok, corrections} <- detect_scannos(content, opts),
         {:ok, reviewed} <- review_corrections(corrections, content, opts),
         {:ok, updated_content} <- apply_corrections(content, reviewed) do
      
      # Write corrected content
      output_path = Keyword.get(opts, :output, manuscript_path <> ".corrected")
      File.write!(output_path, updated_content)
      
      {:ok, %{
        output_path: output_path,
        corrections_applied: length(reviewed.accepted),
        corrections_rejected: length(reviewed.rejected),
        corrections_modified: length(reviewed.modified)
      }}
    end
  end
  
  defp detect_scannos(content, opts) do
    model = Keyword.get(opts, :model, "qwen3.0:8b")
    parallel = Keyword.get(opts, :parallel, true)
    
    ScannoDetector.detect_scannos(content, 
      model: model,
      parallel: parallel
    )
  end
  
  defp review_corrections(corrections, content, opts) do
    if Keyword.get(opts, :interactive, true) do
      InteractiveScannoReview.review_corrections(corrections, content)
    else
      # Auto-accept high confidence corrections only
      {:ok, %{
        accepted: corrections.high,
        rejected: corrections.low,
        modified: []
      }}
    end
  end
  
  defp apply_corrections(content, reviewed) do
    all_corrections = reviewed.accepted ++ reviewed.modified
    |> Enum.sort_by(& &1["absolute_position"], :desc)
    
    updated_content = Enum.reduce(all_corrections, content, fn correction, acc ->
      apply_single_correction(acc, correction)
    end)
    
    {:ok, updated_content}
  end
  
  defp apply_single_correction(content, correction) do
    pos = correction["absolute_position"]
    original_length = String.length(correction["original"])
    
    before = String.slice(content, 0, pos)
    after_text = String.slice(content, pos + original_length..-1//1)
    
    before <> correction["corrected"] <> after_text
  end
end

Build Pipeline Configuration

defmodule Codexis.BuildPipeline.Config do
  @moduledoc """
  Configuration for scanno detection in build pipeline
  """
  
  def default_scanno_config do
    %{
      enabled: true,
      model: "qwen3.0:8b",
      parallel_processing: true,
      auto_accept_threshold: 0.95,
      interactive_threshold: 0.7,
      max_corrections_per_file: 1000,
      batch_size: 100,
      cache_results: true,
      cache_directory: "priv/scanno_cache"
    }
  end
  
  def load_config(overrides \\ %{}) do
    default_scanno_config()
    |> Map.merge(Application.get_env(:codexis, :scanno_detection, %{}))
    |> Map.merge(overrides)
  end
  
  def validate_config(config) do
    required_keys = [:enabled, :model, :auto_accept_threshold]
    
    missing_keys = Enum.filter(required_keys, fn key ->
      not Map.has_key?(config, key)
    end)
    
    if Enum.empty?(missing_keys) do
      {:ok, config}
    else
      {:error, "Missing required configuration keys: #{inspect(missing_keys)}"}
    end
  end
end

Pipeline Stage Implementation

defmodule Codexis.BuildPipeline.ScannoStage do
  @moduledoc """
  Scanno detection stage for the build pipeline
  """
  
  @behaviour Codexis.BuildPipeline.Stage
  
  alias Codexis.BuildManager.ScannoIntegration
  
  @impl true
  def name, do: :scanno_detection
  
  @impl true
  def run(context) do
    config = context.config.scanno_detection
    
    if config.enabled do
      execute_scanno_detection(context, config)
    else
      {:ok, context}
    end
  end
  
  defp execute_scanno_detection(context, config) do
    manuscript_files = find_manuscript_files(context)
    
    results = manuscript_files
    |> Task.async_stream(fn file ->
      process_file(file, config)
    end, max_concurrency: System.schedulers_online())
    |> Enum.reduce({[], []}, fn
      {:ok, {:ok, result}}, {successes, failures} ->
        {[result | successes], failures}
      {:ok, {:error, error}}, {successes, failures} ->
        {successes, [error | failures]}
      {:exit, reason}, {successes, failures} ->
        {successes, [{:exit, reason} | failures]}
    end)
    
    case results do
      {successes, []} ->
        updated_context = update_context_with_results(context, successes)
        {:ok, updated_context}
      {_, failures} ->
        {:error, format_failures(failures)}
    end
  end
  
  defp find_manuscript_files(context) do
    Path.wildcard(Path.join(context.manuscript_dir, "**/*.tex"))
  end
  
  defp process_file(file_path, config) do
    cache_key = generate_cache_key(file_path)
    
    case check_cache(cache_key, config) do
      {:ok, cached_result} ->
        {:ok, cached_result}
      :miss ->
        result = ScannoIntegration.check_manuscript_for_scannos(file_path,
          model: config.model,
          interactive: should_be_interactive?(config),
          auto_accept_threshold: config.auto_accept_threshold
        )
        
        cache_result(cache_key, result, config)
        result
    end
  end
  
  defp should_be_interactive?(config) do
    # Determine if review should be interactive based on config
    Map.get(config, :force_interactive, false) ||
    (Map.get(config, :ci_mode, false) == false)
  end
  
  defp generate_cache_key(file_path) do
    file_stat = File.stat!(file_path)
    content_hash = :crypto.hash(:sha256, File.read!(file_path))
    
    :crypto.hash(:sha256, 
      file_path <> 
      to_string(file_stat.mtime) <> 
      content_hash
    ) |> Base.encode16()
  end
  
  defp check_cache(key, config) do
    if config.cache_results do
      cache_file = Path.join(config.cache_directory, key)
      
      if File.exists?(cache_file) do
        case File.read(cache_file) do
          {:ok, content} ->
            case Jason.decode(content) do
              {:ok, data} -> {:ok, data}
              {:error, _} -> :miss
            end
          {:error, _} -> :miss
        end
      else
        :miss
      end
    else
      :miss
    end
  end
  
  defp cache_result(key, result, config) do
    if config.cache_results do
      File.mkdir_p!(config.cache_directory)
      cache_file = Path.join(config.cache_directory, key)
      
      case Jason.encode(result) do
        {:ok, json} -> File.write(cache_file, json)
        {:error, _} -> :ok
      end
    end
  end
  
  defp update_context_with_results(context, results) do
    Map.update(context, :scanno_results, results, fn existing ->
      existing ++ results
    end)
  end
  
  defp format_failures(failures) do
    formatted = Enum.map(failures, fn
      {:exit, reason} -> "Process exited: #{inspect(reason)}"
      error -> inspect(error)
    end)
    
    "Scanno detection failed:\n" <> Enum.join(formatted, "\n")
  end
end

CI/CD Integration

defmodule Codexis.CI.ScannoCheck do
  @moduledoc """
  CI/CD integration for scanno detection
  """
  
  def run_ci_check(opts \\ []) do
    config = %{
      enabled: true,
      model: "qwen2.5:3b",  # Faster model for CI
      interactive: false,
      auto_accept_threshold: 0.98,  # Higher threshold for CI
      max_corrections: 50,
      fail_on_low_confidence: true
    }
    
    manuscript_path = Keyword.get(opts, :manuscript)
    
    case Codexis.BuildManager.ScannoIntegration.check_manuscript_for_scannos(
      manuscript_path,
      Map.to_list(config)
    ) do
      {:ok, result} ->
        evaluate_ci_result(result, config)
      {:error, error} ->
        {:error, error}
    end
  end
  
  defp evaluate_ci_result(result, config) do
    cond do
      result.corrections_applied == 0 ->
        {:ok, "No corrections needed"}
      
      result.corrections_applied <= config.max_corrections ->
        {:warning, "Applied #{result.corrections_applied} corrections"}
      
      true ->
        {:error, "Too many corrections needed: #{result.corrections_applied}"}
    end
  end
  
  def generate_ci_report(results) do
    """
    # Scanno Detection Report
    
    ## Summary
    - Files checked: #{length(results)}
    - Total corrections: #{total_corrections(results)}
    - Files with corrections: #{files_with_corrections(results)}
    
    ## Details
    #{format_detailed_results(results)}
    """
  end
  
  defp total_corrections(results) do
    Enum.reduce(results, 0, fn result, acc ->
      acc + result.corrections_applied
    end)
  end
  
  defp files_with_corrections(results) do
    Enum.count(results, fn result ->
      result.corrections_applied > 0
    end)
  end
  
  defp format_detailed_results(results) do
    results
    |> Enum.filter(fn r -> r.corrections_applied > 0 end)
    |> Enum.map(fn result ->
      """
      ### #{result.output_path}
      - Corrections applied: #{result.corrections_applied}
      - Corrections rejected: #{result.corrections_rejected}
      - Corrections modified: #{result.corrections_modified}
      """
    end)
    |> Enum.join("\n")
  end
end

Build Hooks

defmodule Codexis.BuildHooks.ScannoHooks do
  @moduledoc """
  Build hooks for scanno detection integration
  """
  
  def pre_build_hook(context) do
    if should_run_scanno_check?(context) do
      run_scanno_check(context)
    else
      {:ok, context}
    end
  end
  
  def post_build_hook(context) do
    if has_scanno_corrections?(context) do
      generate_correction_report(context)
    else
      {:ok, context}
    end
  end
  
  defp should_run_scanno_check?(context) do
    context.config.scanno_detection.enabled &&
    not skip_scanno_check?(context)
  end
  
  defp skip_scanno_check?(context) do
    Map.get(context.flags, :skip_scanno, false) ||
    Map.get(context.flags, :quick_build, false)
  end
  
  defp run_scanno_check(context) do
    # Trigger scanno detection stage
    Codexis.BuildPipeline.ScannoStage.run(context)
  end
  
  defp has_scanno_corrections?(context) do
    Map.has_key?(context, :scanno_results) &&
    not Enum.empty?(context.scanno_results)
  end
  
  defp generate_correction_report(context) do
    report_path = Path.join(context.output_dir, "scanno_corrections_report.html")
    
    report_content = Codexis.QualityChecker.ResultExporter.export_html_report(
      context.scanno_results,
      %{}
    )
    
    File.write!(report_path, report_content)
    
    {:ok, Map.put(context, :scanno_report_path, report_path)}
  end
end

CLI Integration

defmodule Mix.Tasks.Codexis.Build do
  use Mix.Task
  
  @shortdoc "Build manuscript with optional scanno detection"
  
  @moduledoc """
  Build manuscript with integrated scanno detection.
  
  ## Options
  
    * `--scanno` - Enable scanno detection (default: true)
    * `--interactive` - Interactive review mode (default: true)
    * `--model` - LLM model to use (default: qwen3.0:8b)
    * `--auto-accept` - Auto-accept threshold (default: 0.95)
    * `--skip-cache` - Skip cached results
  
  ## Examples
  
      mix codexis.build manuscript.tex
      mix codexis.build manuscript.tex --no-scanno
      mix codexis.build manuscript.tex --model deepseek-r1:8b
  """
  
  def run(args) do
    {opts, [manuscript_path], _} = OptionParser.parse(args,
      switches: [
        scanno: :boolean,
        interactive: :boolean,
        model: :string,
        auto_accept: :float,
        skip_cache: :boolean
      ]
    )
    
    config = build_config(opts)
    
    case Codexis.BuildManager.build(manuscript_path, config) do
      {:ok, result} ->
        print_success(result)
      {:error, error} ->
        print_error(error)
        System.halt(1)
    end
  end
  
  defp build_config(opts) do
    base_config = Codexis.BuildPipeline.Config.load_config()
    
    scanno_config = %{
      enabled: Keyword.get(opts, :scanno, true),
      interactive: Keyword.get(opts, :interactive, true),
      model: Keyword.get(opts, :model, base_config.model),
      auto_accept_threshold: Keyword.get(opts, :auto_accept, base_config.auto_accept_threshold),
      cache_results: not Keyword.get(opts, :skip_cache, false)
    }
    
    %{base_config | scanno_detection: Map.merge(base_config.scanno_detection, scanno_config)}
  end
  
  defp print_success(result) do
    IO.puts("\nBuild completed successfully!")
    
    if result[:scanno_results] do
      IO.puts("\nScanno Detection Results:")
      IO.puts("  Files processed: #{length(result.scanno_results)}")
      
      total_corrections = Enum.reduce(result.scanno_results, 0, fn r, acc ->
        acc + r.corrections_applied
      end)
      
      IO.puts("  Total corrections: #{total_corrections}")
    end
    
    IO.puts("\nOutput: #{result.output_path}")
  end
  
  defp print_error(error) do
    IO.puts("\nBuild failed:")
    IO.puts(inspect(error))
  end
end

Monitoring and Metrics

defmodule Codexis.Metrics.ScannoMetrics do
  @moduledoc """
  Metrics collection for scanno detection
  """
  
  def record_detection_metrics(results, duration) do
    :telemetry.execute(
      [:codexis, :scanno, :detection],
      %{
        duration: duration,
        files_processed: length(results),
        total_corrections: total_corrections(results),
        average_confidence: average_confidence(results)
      },
      %{model: "qwen3.0:8b"}
    )
  end
  
  def record_review_metrics(reviewed, duration) do
    :telemetry.execute(
      [:codexis, :scanno, :review],
      %{
        duration: duration,
        accepted: length(reviewed.accepted),
        rejected: length(reviewed.rejected),
        modified: length(reviewed.modified)
      },
      %{}
    )
  end
  
  defp total_corrections(results) do
    Enum.reduce(results, 0, fn result, acc ->
      acc + result.corrections_applied
    end)
  end
  
  defp average_confidence(results) do
    confidences = Enum.flat_map(results, fn result ->
      result.corrections |> Enum.map(& &1["confidence"])
    end)
    
    if Enum.empty?(confidences) do
      0
    else
      Enum.sum(confidences) / length(confidences)
    end
  end
  
  def attach_telemetry_handlers do
    :telemetry.attach_many(
      "codexis-scanno-metrics",
      [
        [:codexis, :scanno, :detection],
        [:codexis, :scanno, :review]
      ],
      &handle_event/4,
      nil
    )
  end
  
  defp handle_event([:codexis, :scanno, :detection], measurements, metadata, _) do
    Logger.info("""
    Scanno detection completed:
      Duration: #{measurements.duration / 1_000_000}s
      Files: #{measurements.files_processed}
      Corrections: #{measurements.total_corrections}
      Avg confidence: #{Float.round(measurements.average_confidence, 2)}
      Model: #{metadata.model}
    """)
  end
  
  defp handle_event([:codexis, :scanno, :review], measurements, _metadata, _) do
    Logger.info("""
    Scanno review completed:
      Duration: #{measurements.duration / 1_000_000}s
      Accepted: #{measurements.accepted}
      Rejected: #{measurements.rejected}
      Modified: #{measurements.modified}
    """)
  end
end