Human Review Interface
The human review interface provides an interactive CLI for reviewing detected scannos, allowing users to accept, reject, or modify suggested corrections before they are applied to the manuscript.
Core Implementation
defmodule Codexis.Review.InteractiveScannoReview do
@moduledoc """
Interactive CLI for reviewing detected scannos
"""
def review_corrections(corrections, latex_content) do
IO.puts("\n=== Scanno Detection Review ===")
IO.puts("Found #{total_corrections(corrections)} potential corrections\n")
reviewed = %{
accepted: [],
rejected: [],
modified: []
}
review_by_confidence(corrections, latex_content, reviewed)
end
defp review_by_confidence(corrections, content, reviewed) do
IO.puts("\n--- High Confidence Corrections ---")
reviewed = review_group(corrections.high, content, reviewed, auto_accept: true)
IO.puts("\n--- Medium Confidence Corrections ---")
reviewed = review_group(corrections.medium, content, reviewed)
IO.puts("\n--- Low Confidence Corrections ---")
reviewed = review_group(corrections.low, content, reviewed)
generate_report(reviewed)
end
defp review_group(corrections, content, reviewed, opts \\ []) do
auto_accept = Keyword.get(opts, :auto_accept, false)
Enum.reduce(corrections, reviewed, fn correction, acc ->
display_correction(correction, content)
decision = if auto_accept do
IO.puts(IO.ANSI.green() <> "Auto-accepting (high confidence)" <> IO.ANSI.reset())
:accept
else
get_user_decision()
end
case decision do
:accept ->
%{acc | accepted: [correction | acc.accepted]}
:reject ->
%{acc | rejected: [correction | acc.rejected]}
{:modify, new_text} ->
modified = Map.put(correction, "corrected", new_text)
%{acc | modified: [modified | acc.modified]}
:quit ->
save_progress(acc, corrections)
exit(:normal)
end
end)
end
defp display_correction(correction, content) do
context = get_context(content, correction["absolute_position"], 50)
IO.puts("\n" <> String.duplicate("-", 60))
IO.puts("Context: #{context}")
IO.puts(IO.ANSI.red() <> "Original: #{correction["original"]}" <> IO.ANSI.reset())
IO.puts(IO.ANSI.green() <> "Suggested: #{correction["corrected"]}" <> IO.ANSI.reset())
IO.puts("Reason: #{correction["reason"]}")
IO.puts("Confidence: #{correction["confidence"]}")
end
defp get_context(content, position, radius) do
start = max(0, position - radius)
length = min(String.length(content) - start, radius * 2)
context = String.slice(content, start, length)
# Highlight the error in context
error_start = position - start
error_length = String.length(correction["original"])
before = String.slice(context, 0, error_start)
error = String.slice(context, error_start, error_length)
after_error = String.slice(context, error_start + error_length..-1)
before <> IO.ANSI.yellow() <> error <> IO.ANSI.reset() <> after_error
end
defp get_user_decision do
response = IO.gets("\nAction? [a]ccept/[r]eject/[m]odify/[s]kip/[q]uit: ")
|> String.trim()
|> String.downcase()
case response do
"a" -> :accept
"r" -> :reject
"m" ->
new_text = IO.gets("Enter corrected text: ") |> String.trim()
{:modify, new_text}
"s" -> :skip
"q" -> :quit
_ ->
IO.puts("Invalid option. Please choose a/r/m/s/q")
get_user_decision()
end
end
defp total_corrections(corrections) do
length(corrections.high) + length(corrections.medium) + length(corrections.low)
end
defp generate_report(reviewed) do
report = %{
accepted: length(reviewed.accepted),
rejected: length(reviewed.rejected),
modified: length(reviewed.modified),
corrections: reviewed
}
save_report(report)
display_summary(report)
{:ok, reviewed}
end
defp save_report(report) do
timestamp = DateTime.utc_now() |> DateTime.to_iso8601()
filename = "scanno_review_#{timestamp}.json"
case Jason.encode(report, pretty: true) do
{:ok, json} -> File.write!(filename, json)
{:error, _} -> IO.puts("Warning: Could not save report")
end
end
defp display_summary(report) do
IO.puts("\n=== Review Summary ===")
IO.puts("Accepted: #{report.accepted}")
IO.puts("Rejected: #{report.rejected}")
IO.puts("Modified: #{report.modified}")
end
defp save_progress(reviewed, remaining) do
progress = %{
reviewed: reviewed,
remaining: remaining,
timestamp: DateTime.utc_now()
}
File.write!("scanno_review_progress.json", Jason.encode!(progress))
IO.puts("\nProgress saved. You can resume later.")
end
end
Advanced Review Features
defmodule Codexis.Review.AdvancedReview do
@moduledoc """
Advanced features for the review interface
"""
def batch_review(corrections, content, opts \\ []) do
batch_size = Keyword.get(opts, :batch_size, 5)
corrections
|> Enum.chunk_every(batch_size)
|> Enum.reduce(%{accepted: [], rejected: [], modified: []}, fn batch, acc ->
review_batch(batch, content, acc)
end)
end
defp review_batch(batch, content, acc) do
display_batch(batch, content)
case get_batch_decision() do
:accept_all ->
%{acc | accepted: acc.accepted ++ batch}
:reject_all ->
%{acc | rejected: acc.rejected ++ batch}
:review_individually ->
review_individual_in_batch(batch, content, acc)
:skip ->
acc
end
end
defp display_batch(batch, content) do
IO.puts("\n=== Batch Review (#{length(batch)} corrections) ===")
Enum.each(batch, fn correction ->
IO.puts("\n" <> String.duplicate("-", 40))
display_correction_summary(correction)
end)
end
defp display_correction_summary(correction) do
IO.puts("#{IO.ANSI.red()}#{correction["original"]}#{IO.ANSI.reset()} → " <>
"#{IO.ANSI.green()}#{correction["corrected"]}#{IO.ANSI.reset()}")
IO.puts("Confidence: #{correction["confidence"]}")
end
defp get_batch_decision do
IO.puts("\n[A]ccept all, [R]eject all, [I]ndividual review, [S]kip: ")
case IO.gets("") |> String.trim() |> String.downcase() do
"a" -> :accept_all
"r" -> :reject_all
"i" -> :review_individually
"s" -> :skip
_ ->
IO.puts("Invalid option")
get_batch_decision()
end
end
defp review_individual_in_batch(batch, content, acc) do
Enum.reduce(batch, acc, fn correction, inner_acc ->
decision = review_single_correction(correction, content)
case decision do
:accept -> %{inner_acc | accepted: [correction | inner_acc.accepted]}
:reject -> %{inner_acc | rejected: [correction | inner_acc.rejected]}
{:modify, text} ->
modified = Map.put(correction, "corrected", text)
%{inner_acc | modified: [modified | inner_acc.modified]}
end
end)
end
def resume_review(progress_file \\ "scanno_review_progress.json") do
case File.read(progress_file) do
{:ok, content} ->
case Jason.decode(content) do
{:ok, progress} -> continue_from_progress(progress)
{:error, _} -> {:error, "Invalid progress file"}
end
{:error, _} ->
{:error, "No progress file found"}
end
end
defp continue_from_progress(progress) do
IO.puts("Resuming review from #{progress["timestamp"]}")
IO.puts("Previously reviewed: #{map_size(progress["reviewed"])}")
# Continue with remaining corrections
review_group(progress["remaining"], "", progress["reviewed"])
end
end
Enhanced UI Features
defmodule Codexis.Review.EnhancedUI do
@moduledoc """
Enhanced UI features for better review experience
"""
def display_correction_with_diff(correction, content) do
context = get_extended_context(content, correction["absolute_position"])
clear_screen()
display_header(correction)
display_context_with_highlighting(context, correction)
display_diff(correction)
display_metadata(correction)
end
defp clear_screen do
IO.write("\e[H\e[2J")
end
defp display_header(correction) do
IO.puts(IO.ANSI.cyan() <> "=== Scanno Review ===" <> IO.ANSI.reset())
IO.puts("Position: #{correction["absolute_position"]}")
IO.puts(String.duplicate("-", 50))
end
defp get_extended_context(content, position, before_lines \\ 3, after_lines \\ 3) do
lines = String.split(content, "\n")
line_positions = build_line_position_map(lines)
{line_num, _} = find_line_containing_position(line_positions, position)
start_line = max(0, line_num - before_lines)
end_line = min(length(lines) - 1, line_num + after_lines)
context_lines = Enum.slice(lines, start_line..end_line)
%{
lines: context_lines,
target_line: line_num - start_line,
position_in_line: position - elem(Enum.at(line_positions, line_num), 1)
}
end
defp build_line_position_map(lines) do
lines
|> Enum.reduce({0, []}, fn line, {pos, acc} ->
{pos + String.length(line) + 1, [{pos, String.length(line)} | acc]}
end)
|> elem(1)
|> Enum.reverse()
|> Enum.with_index()
end
defp find_line_containing_position(line_positions, position) do
Enum.find(line_positions, fn {{start_pos, length}, _index} ->
position >= start_pos && position < start_pos + length
end)
end
defp display_context_with_highlighting(context, correction) do
IO.puts("\nContext:")
IO.puts(String.duplicate("-", 50))
Enum.with_index(context.lines) |> Enum.each(fn {line, index} ->
if index == context.target_line do
display_highlighted_line(line, context.position_in_line, correction)
else
IO.puts(" #{line}")
end
end)
IO.puts(String.duplicate("-", 50))
end
defp display_highlighted_line(line, pos_in_line, correction) do
orig_length = String.length(correction["original"])
before = String.slice(line, 0, pos_in_line)
highlighted = String.slice(line, pos_in_line, orig_length)
after_text = String.slice(line, pos_in_line + orig_length..-1//1)
IO.puts("→ #{before}" <>
IO.ANSI.yellow_background() <> IO.ANSI.black() <>
highlighted <>
IO.ANSI.reset() <>
after_text)
end
defp display_diff(correction) do
IO.puts("\nProposed Change:")
IO.puts(IO.ANSI.red() <> "- #{correction["original"]}" <> IO.ANSI.reset())
IO.puts(IO.ANSI.green() <> "+ #{correction["corrected"]}" <> IO.ANSI.reset())
end
defp display_metadata(correction) do
IO.puts("\nMetadata:")
IO.puts("Confidence: #{format_confidence(correction["confidence"])}")
IO.puts("Reason: #{correction["reason"]}")
if correction["sources"] do
IO.puts("Sources: #{Enum.join(correction["sources"], ", ")}")
end
end
defp format_confidence(confidence) do
color = cond do
confidence >= 0.9 -> IO.ANSI.green()
confidence >= 0.7 -> IO.ANSI.yellow()
true -> IO.ANSI.red()
end
"#{color}#{Float.round(confidence, 2)}#{IO.ANSI.reset()}"
end
end
Keyboard Navigation
defmodule Codexis.Review.KeyboardNav do
@moduledoc """
Keyboard navigation for review interface
"""
def setup_terminal do
# Set terminal to raw mode for single-key input
:io.setopts([:binary, :raw, :echo])
end
def restore_terminal do
:io.setopts([:binary, :cooked, :echo])
end
def get_key_command do
setup_terminal()
try do
key = :io.get_chars("", 1)
restore_terminal()
case key do
"a" -> :accept
"r" -> :reject
"m" -> :modify
"s" -> :skip
"q" -> :quit
"n" -> :next
"p" -> :previous
"?" -> :help
"\e" -> handle_escape_sequence()
_ -> :invalid
end
rescue
_ ->
restore_terminal()
:error
end
end
defp handle_escape_sequence do
# Handle arrow keys and other escape sequences
case :io.get_chars("", 2) do
"[A" -> :up
"[B" -> :down
"[C" -> :right
"[D" -> :left
_ -> :escape
end
end
def display_help do
IO.puts("\nKeyboard Commands:")
IO.puts(" a - Accept correction")
IO.puts(" r - Reject correction")
IO.puts(" m - Modify correction")
IO.puts(" s - Skip to next")
IO.puts(" n - Next correction")
IO.puts(" p - Previous correction")
IO.puts(" q - Quit review")
IO.puts(" ? - Show this help")
IO.puts("\nPress any key to continue...")
get_key_command()
end
end
Review Session Management
defmodule Codexis.Review.SessionManager do
@moduledoc """
Manages review sessions with undo/redo capability
"""
defstruct [
:current_index,
:corrections,
:decisions,
:history,
:redo_stack
]
def new(corrections) do
%__MODULE__{
current_index: 0,
corrections: corrections,
decisions: %{},
history: [],
redo_stack: []
}
end
def current_correction(session) do
Enum.at(session.corrections, session.current_index)
end
def make_decision(session, decision) do
correction = current_correction(session)
new_session = %{session |
decisions: Map.put(session.decisions, session.current_index, decision),
history: [{session.current_index, decision} | session.history],
redo_stack: [] # Clear redo stack on new decision
}
next_correction(new_session)
end
def next_correction(session) do
%{session | current_index: min(session.current_index + 1, length(session.corrections) - 1)}
end
def previous_correction(session) do
%{session | current_index: max(session.current_index - 1, 0)}
end
def undo(session) do
case session.history do
[] -> session
[{index, decision} | rest] ->
%{session |
history: rest,
redo_stack: [{index, decision} | session.redo_stack],
decisions: Map.delete(session.decisions, index),
current_index: index
}
end
end
def redo(session) do
case session.redo_stack do
[] -> session
[{index, decision} | rest] ->
%{session |
redo_stack: rest,
history: [{index, decision} | session.history],
decisions: Map.put(session.decisions, index, decision)
}
end
end
def jump_to(session, index) when index >= 0 and index < length(session.corrections) do
%{session | current_index: index}
end
def jump_to(session, _), do: session
def get_progress(session) do
total = length(session.corrections)
reviewed = map_size(session.decisions)
%{
total: total,
reviewed: reviewed,
remaining: total - reviewed,
percentage: if(total > 0, do: reviewed / total * 100, else: 0)
}
end
def export_decisions(session) do
session.corrections
|> Enum.with_index()
|> Enum.map(fn {correction, index} ->
case Map.get(session.decisions, index) do
nil -> {correction, :pending}
decision -> {correction, decision}
end
end)
|> Enum.group_by(fn {_, decision} -> decision end, fn {correction, _} -> correction end)
end
end