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

321 lines
13 KiB
Python

#!/usr/bin/env python3
"""
revise_prayers1.py
PASS C: run this AFTER classify_occasions.py and the original
revise_prayers.py. Where revise_prayers.py checked prayers against
checklist items 1-5 (structural/consistency issues), this script adds
item 6 -- approachability -- and is meant to run over EVERY finished
prayer, including ones already reviewed and culled by hand, since the
goal (warming the register) applies to the whole book regardless of
where a given prayer is in the review process.
Same conservative approach as revise_prayers.py: only what's flagged
gets changed; 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.
6. Approachability -- contractions, plain spoken address ('God...' as
well as 'Lord'), and short spoken asides where they fit naturally.
A little more length is fine in service of this; genuine padding
(repeated stanzas, the same thought restated more than once, vague
filler) is not.
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_prayers1.py txt
python revise_prayers1.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"
"6. APPROACHABILITY. Warm the register toward plain, spoken address: "
"use contractions where they read naturally (I'm, don't, that's), feel "
"free to open with 'God...' instead of 'Lord' where it fits the "
"occasion, and allow a short spoken aside (like 'Honestly?') where it "
"fits. A little more length is fine if it makes the prayer feel more "
"spoken and conversational -- a thought can restate itself once in "
"plainer words if that reads as warmth rather than filler. But don't "
"let this run away into genuine padding: no repeated stanzas, no "
"restating the same thought more than once, no vague filler lines "
"added just to sound conversational. If a line already reads plainly "
"and directly, leave it exactly as is.\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()