#!/usr/bin/env python3 “”” proofagent.py — AI Proofreading Agent (CLI + library) Usage: python proofagent.py –file manuscript.txt –style chicago –apply-auto Outputs: – report.json – edited_full.txt (if apply-auto) – patch.diff (if apply-auto) “”” import os import sys import json import argparse import hashlib from typing import List, Dict # Replace this with your LLM client (OpenAI, etc.) import openai # pip install openai OPENAI_MODEL = “gpt-4o-mini” # replace with your preferred model def read_file(path: str) -> str: with open(path, “r”, encoding=”utf-8″) as f: return f.read() def chunk_text(text: str, max_chars: int = 3000) -> List[str]: “””Naive chunker by characters for LLM limits””” chunks = [] i = 0 n = len(text) while i < n: chunks.append(text[i:i+max_chars]) i += max_chars return chunks def call_llm(prompt: str, model=OPENAI_MODEL, max_tokens=1500): # Basic wrapper. Configure api key env var OPENAI_API_KEY for openai library. response = openai.ChatCompletion.create( model=model, messages=[{"role":"system","content":SYSTEM_PROMPT}, {"role":"user","content":prompt}], max_tokens=max_tokens, temperature=0.0 ) return response.choices[0].message["content"] SYSTEM_PROMPT = ( "You are an expert book proofreader and copyeditor. Return strict JSON as described in prompts. " "Do not include any explanation outside the JSON." ) PROMPT_TEMPLATE = { "surface": """PASS: SURFACE Style guide: {style} Task: Find and correct ONLY surface-level errors: spelling, punctuation, grammar, duplicated/missing words. For each correction return a JSON array of suggestion objects with keys: - id, chapter, start, end, original, suggestion, severity, confidence, rationale, auto_apply_possible Text: \"\"\"{text}\"\"\" Return only the JSON array.""", "consistency": """PASS: CONSISTENCY Style guide: {style} Task: Identify inconsistencies in names, dates, hyphenation, capitalization, numeric formatting. For each issue return objects with: - id, type, canonical, variants: [{variant, locations}], severity, rationale Text: \"\"\"{text}\"\"\" Return only JSON array.""", "style": """PASS: STYLE Style guide: {style} Task: Suggest improvements for clarity, concision, and style. Return JSON array of suggestions: - id, start, end, original, suggestion, severity, rationale, applyable (true/false) Text: \"\"\"{text}\"\"\" Return only JSON array.""", "readaloud": """PASS: READALLOUD Task: Identify sentences awkward when read aloud and suggest small rewrites. Return JSON array.""" } def run_pass(text: str, pass_type: str, style: str) -> List[Dict]: prompt = PROMPT_TEMPLATE[pass_type].format(style=style, text=text) out = call_llm(prompt) # Try safe parse – tolerant try: data = json.loads(out) return data except Exception as e: # If parsing fails, attempt to find first JSON substring import re m = re.search(r'(\[.*\])’, out, flags=re.S) if m: return json.loads(m.group(1)) else: raise RuntimeError(“LLM output not valid JSON:\n” + out) def apply_auto_fixes(original: str, suggestions: List[Dict]) -> str: “””Apply suggestions with auto_apply_possible == true. Assumes suggestions have start/end offsets. We’ll apply from highest offset to lowest to keep indices valid.””” auto = [s for s in suggestions if s.get(“auto_apply_possible”)] auto_sorted = sorted(auto, key=lambda s: s[“start”], reverse=True) text = original for s in auto_sorted: start, end, repl = s[“start”], s[“end”], s[“suggestion”] text = text[:start] + repl + text[end:] return text def unique_id(s: str) -> str: return hashlib.sha1(s.encode()).hexdigest()[:8] def main(): parser = argparse.ArgumentParser() parser.add_argument(“–file”, required=True) parser.add_argument(“–style”, default=”Chicago”) parser.add_argument(“–apply-auto”, action=”store_true”) parser.add_argument(“–passes”, nargs=”+”, default=[“surface”,”consistency”,”style”,”readaloud”]) args = parser.parse_args() text = read_file(args.file) chunks = chunk_text(text, max_chars=3000) all_reports = {p: [] for p in args.passes} # iterate passes and chunks for p in args.passes: for idx, chunk in enumerate(chunks): try: items = run_pass(chunk, p, args.style) # assign id and chapter for each result if missing for it in items: if “id” not in it: it[“id”] = unique_id(json.dumps(it)) if “chapter” not in it: it[“chapter”] = f”{os.path.basename(args.file)}:chunk{idx}” all_reports[p].extend(items) except Exception as e: print(f”Warning: pass {p} chunk {idx} failed: {e}”, file=sys.stderr) report_path = “report.json” with open(report_path, “w”, encoding=”utf-8″) as f: json.dump(all_reports, f, indent=2, ensure_ascii=False) print(f”Report written to {report_path}”) if args.apply_auto: # Gather surface-level auto-fixes across the whole doc only surface_items = all_reports.get(“surface”, []) try: edited = apply_auto_fixes(text, surface_items) with open(“edited_full.txt”, “w”, encoding=”utf-8″) as f: f.write(edited) # produce simple unified diff import difflib diff = difflib.unified_diff(text.splitlines(), edited.splitlines(), lineterm=””) with open(“patch.diff”, “w”, encoding=”utf-8″) as f: f.write(“\n”.join(diff)) print(“Auto-applied fixes saved to edited_full.txt and patch.diff”) except Exception as e: print(“Error applying auto fixes:”, e, file=sys.stderr) if __name__ == “__main__”: main()