321 lines
13 KiB
Python
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()
|