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