Books/2026.08-PrayersForDailyLife/revise_prayers.py
2026-09-09 19:59:36 -07:00

302 lines
12 KiB
Python

#!/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<ref>##\s*\S+)[ \t]*\n"
r">(?!>)[ \t]*(?P<title>[^\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()