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