BUCKYVERSE

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