#!/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()
