#!/usr/bin/env python3 """ revise_prayers.py PASS B of the migration: run this after classify_occasions.py has split the old prayers out into per-category files in ./txt. This script does NOT generate new prayers and does NOT rewrite for style. It is a narrow, conservative revision pass that checks each already-written prayer against a fixed checklist of specific, recurring issues caught by hand across many editing sessions on this project -- and edits ONLY what the checklist flags. Anything not flagged is left untouched, verbatim. The checklist (also embedded in the prompt below): 1. Two over-used "default" stanzas -- the discernment couplet ("Show me what to tend / and what to lay down...") and the community stanza ("Bring me near steady/quiet/sturdy people...") -- should only appear when the occasion is genuinely ABOUT sorting/ discernment (1) or isolation (2). If either shows up in a prayer whose occasion isn't really about that, and it's not doing real specific work for THIS occasion, cut or replace it. 2. Known recurring stock phrases across the collection -- flag and replace (not just reword): "I know how fast/quickly...", "the phone keeps ringing", "call fear wisdom" / "call ... prudence", "cracks by noon", and close variants of any of these. 3. Mixed imagery: a prayer should commit to ONE governing image system (water, fire, weight, light, a room, etc.) and stay inside it. If multiple unrelated image systems appear, resolve to the strongest one and rewrite the others to match it. 4. "You" / "Your" / "Yourself" (addressing God) capitalized consistently throughout. 5. Typos and grammar. This is intentionally NOT a full style pass -- it will not touch a prayer's voice, line breaks, ending device, or anything not on this list, even if it could plausibly be improved. The goal is consistency across the whole collection, not re-polishing prayers that already work. Only files with existing prayer content are touched -- bare occasion files (no prayer yet) are left alone for generate_petitions2.py. For each file, prints what changed and which checklist item triggered it, so you can spot-check rather than trust the pass blindly. Nothing beyond that report is written to the file itself. Usage: pip install openai python-dotenv python revise_prayers.py txt python revise_prayers.py txt --dry-run python revise_prayers.py txt --model gpt-5.4 """ import argparse import json import os import re import sys import time from pathlib import Path from dotenv import load_dotenv load_dotenv() API_KEY = os.environ.get("OPENAI_API_KEY") or os.environ.get("OPEN_API_KEY") if not API_KEY: sys.exit( "No API key found. Add OPENAI_API_KEY=sk-... (or OPEN_API_KEY=sk-...) " "to a .env file in the working directory, or export it in your shell." ) try: from openai import OpenAI except ImportError: sys.exit("Missing dependency. Run: pip install openai") client = OpenAI(api_key=API_KEY) # --- Parsing ----------------------------------------------------------- FULL_HEADER_RE = re.compile( r"^(?P##\s*\S+)[ \t]*\n" r">(?!>)[ \t]*(?P[^\n]*)\n" r">>[ \t]*(?P<quote>[^\n]*)\n" ) OCCASION_LINE_RE = re.compile(r"(?m)^Occasion:[ \t]*(?P<text>.+)$") def parse_file(text: str): """Returns {"ref", "title", "quote", "occasion", "content"} for a single-occasion new-format file, or None if the file doesn't match (no header, or no Occasion line). Files may have exactly one Occasion line (the new per-category convention) or, for BONEYARD files, several -- this script only revises single-occasion files; BONEYARD files are skipped (they haven't been sorted yet).""" stripped = text.lstrip("\n") m = FULL_HEADER_RE.match(stripped) if not m: return None ref = m.group("ref").lstrip("#").strip() title = m.group("title").strip() quote = m.group("quote").strip() body = text[m.end():] matches = list(OCCASION_LINE_RE.finditer(body)) if len(matches) != 1: return None # 0 = malformed, 2+ = a BONEYARD-style file, skip both mo = matches[0] occasion = mo.group("text").strip() content = body[mo.end():].strip() return {"ref": ref, "title": title, "quote": quote, "occasion": occasion, "content": content} def render_file(ref: str, title: str, quote: str, occasion: str, content: str) -> str: return f"## {ref}\n> {title}\n>> {quote}\n\nOccasion: {occasion}\n{content}\n" # --- Revision call ----------------------------------------------------- CHECKLIST = ( "1. OVER-USED DEFAULT STANZAS. Two specific moves showed up far too " "often across this collection and should NOT appear reflexively:\n" " (a) the discernment couplet -- something like 'Show me what to " "tend / and what to lay down' -- belongs ONLY when the occasion is " "genuinely about sorting or discernment.\n" " (b) the community stanza -- something like 'Bring me near " "steady/quiet/sturdy people' -- belongs ONLY when the occasion is " "genuinely about isolation.\n" " If either appears in a prayer whose occasion isn't really about " "that, and it isn't doing real, specific work for THIS occasion, cut " "it or replace it with something that grows out of this prayer's own " "image instead.\n\n" "2. KNOWN STOCK PHRASES. Flag and replace (don't just reword) any of " "these or close variants, if present: 'I know how fast/quickly...', " "'the phone keeps ringing', 'call fear wisdom', 'call [delay/panic] " "prudence', 'cracks by noon'.\n\n" "3. MIXED IMAGERY. A prayer should commit to ONE governing image " "system (water, fire, weight, light, a room, a road, etc.) and stay " "inside it start to finish. If more than one unrelated image system " "appears, keep the strongest one and rewrite the others to fit it.\n\n" "4. CAPITALIZATION. 'You' / 'Your' / 'Yourself', when addressing God, " "must be capitalized every time.\n\n" "5. TYPOS AND GRAMMAR. Fix plain errors.\n\n" "Nothing else is in scope. Do not touch voice, line breaks, ending " "device, word choice, or anything not covered by the 5 items above, " "even if you think it could be improved. If nothing on the checklist " "applies, return the prayer completely unchanged." ) SYSTEM_PROMPT = ( "You are doing a narrow, conservative proofreading pass on an " "already-finished devotional prayer -- NOT a rewrite, NOT a style " "pass. The author has hand-polished these prayers already; your only " "job is to check against a fixed checklist and fix ONLY what it " "flags, changing as little as possible to fix each flagged issue. " "Preserve every line that isn't touched by the checklist exactly as " "written, including its exact line breaks.\n\n" "CHECKLIST:\n\n" + CHECKLIST + "\n\n" "Respond with the full revised prayer text (or the original, " "unchanged, if nothing applies) AND a short list of which checklist " "item number(s) you acted on and what you changed because of them. " "If you changed nothing, say so explicitly rather than omitting the " "field." ) USER_PROMPT_TEMPLATE = """\ Occasion: {occasion} Verse ({ref}): {quote} Prayer text: {content} Respond ONLY with valid JSON in this exact shape, no other text: {{"revised_text": "...", "changes": ["item 3: merged fire and flood imagery into flood only", ...]}} If nothing needed changing, "changes" should be an empty list [] and \ "revised_text" should be byte-for-byte identical to the input. """ def build_user_prompt(ref: str, quote: str, occasion: str, content: str) -> str: return USER_PROMPT_TEMPLATE.format(ref=ref, quote=quote, occasion=occasion, content=content) def revise(ref: str, quote: str, occasion: str, content: str, model: str, retries: int = 3): """Returns (revised_text, changes_list).""" prompt = build_user_prompt(ref, quote, occasion, content) last_err = None for attempt in range(1, retries + 1): try: resp = client.chat.completions.create( model=model, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}, ], temperature=0.2, response_format={"type": "json_object"}, ) data = json.loads(resp.choices[0].message.content) revised = data["revised_text"].strip() changes = data.get("changes", []) if not isinstance(changes, list): raise ValueError(f"'changes' should be a list, got {changes!r}") if not revised: raise ValueError("empty revised_text in response") return revised, changes except Exception as e: # noqa: BLE001 last_err = e print(f" attempt {attempt}/{retries} failed: {e}", file=sys.stderr) time.sleep(1.5 * attempt) raise RuntimeError(f"Giving up revising {ref} / {occasion!r}: {last_err}") # --- Main ------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument("directory", help="Directory of new-format per-category .txt files (e.g. txt)") parser.add_argument("--pattern", default="*.txt", help="Glob pattern for input files (default: *.txt)") parser.add_argument("--model", default="gpt-5.4", help="OpenAI model to use (default: gpt-5.4)") parser.add_argument( "--dry-run", action="store_true", help="Show what would change without writing anything", ) args = parser.parse_args() directory = Path(args.directory) if not directory.is_dir(): sys.exit(f"Not a directory: {directory}") files = sorted(directory.glob(args.pattern)) if not files: sys.exit(f"No files matching {args.pattern} in {directory}") print(f"Found {len(files)} files in {directory}") if args.dry_run: print("(dry run -- nothing will be written)") print() revised_count = 0 unchanged_count = 0 skipped = [] failed = [] for path in files: text = path.read_text(encoding="utf-8") parsed = parse_file(text) if parsed is None: skipped.append(path.name) continue if not parsed["content"]: # Bare occasion, no prayer yet -- not this script's job. skipped.append(path.name) continue try: revised_text, changes = revise( parsed["ref"], parsed["quote"], parsed["occasion"], parsed["content"], args.model ) except Exception as e: # noqa: BLE001 print(f"{path.name} FAILED: {e}", file=sys.stderr) failed.append(path.name) continue if not changes or revised_text.strip() == parsed["content"].strip(): unchanged_count += 1 continue print(f"{path.name} [{parsed['ref']}] \"{parsed['occasion']}\"") for c in changes: print(f" - {c}") if not args.dry_run: new_text = render_file( parsed["ref"], parsed["title"], parsed["quote"], parsed["occasion"], revised_text ) path.write_text(new_text, encoding="utf-8") revised_count += 1 print() print( f"Done. Revised: {revised_count}, unchanged (already clean): {unchanged_count}, " f"skipped (bare/boneyard/unparseable): {len(skipped)}, failed: {len(failed)}" ) if failed: print("Failed files:") for name in failed: print(f" - {name}") if __name__ == "__main__": main()