Add draft archive and acceptance workflow

This commit is contained in:
Joey Grasty
2026-08-23 15:47:46 -05:00
parent 23e4ef595e
commit 630c0c468f
18 changed files with 1701 additions and 67 deletions

View File

@@ -1,18 +1,23 @@
#!/usr/bin/env python3
"""Read-only local package-preview prototype for a Scrivener project.
"""Local authoring-system prototype for a Scrivener project.
This deliberately has no model call and no Scrivener write path. It is a
small standard-library server so the first workflow test has no dependency
installation step.
It previews and sends packages, and makes carefully scoped, lock-guarded
writes for accepted drafts. It remains a standard-library server.
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import shutil
import subprocess
import urllib.error
import urllib.request
import uuid
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from http import HTTPStatus
@@ -22,6 +27,7 @@ from urllib.parse import urlparse
APP_DIR = Path(__file__).resolve().parent
DEFAULT_PROJECT = APP_DIR.parent / "hapagirlsauthoringsystemv1.scriv"
OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6"
@dataclass
@@ -54,6 +60,24 @@ def rtf_to_text(path: Path) -> str:
return re.sub(r"\n{3,}", "\n\n", raw).strip()
def text_to_rtf(value: str) -> str:
"""Create deliberately simple RTF suitable for Scrivener text documents."""
escaped = []
for character in value.replace("\r\n", "\n").replace("\r", "\n"):
if character == "\n":
escaped.append("\\par\n")
elif character in "\\{}":
escaped.append("\\" + character)
elif ord(character) > 127:
codepoint = ord(character)
if codepoint > 32767:
codepoint -= 65536
escaped.append(f"\\u{codepoint}?")
else:
escaped.append(character)
return "{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0\\fnil Helvetica;}}\n\\f0\\fs24 " + "".join(escaped) + "\n}"
class ScrivenerReader:
def __init__(self, project: Path):
self.project = project
@@ -175,11 +199,163 @@ class ScrivenerReader:
}
class ScrivenerWriter:
"""The small, guarded subset of Scrivener writes needed by this prototype."""
def __init__(self, project: Path):
self.project = project
self.reader = ScrivenerReader(project)
def _assert_unlocked(self) -> None:
if (self.project / "Files" / "user.lock").exists():
raise ValueError("Scrivener is open (Files/user.lock exists). Close the project before writing.")
def _tree_and_binder(self) -> tuple[ET.ElementTree, ET.Element]:
tree = ET.parse(self.reader.project_file)
binder = tree.getroot().find("Binder")
if binder is None:
raise ValueError("Scrivener project has no Binder.")
return tree, binder
@staticmethod
def _find_child(element: ET.Element, title: str) -> ET.Element | None:
children = element.find("Children")
if children is None:
return None
return next((item for item in children.findall("BinderItem") if (item.findtext("Title") or "").strip() == title), None)
def _folder(self, binder: ET.Element, path: list[str]) -> ET.Element:
parent: ET.Element | None = None
children = binder
for title in path:
parent = next((item for item in children.findall("BinderItem") if (item.findtext("Title") or "").strip() == title), None)
if parent is None:
raise ValueError("Required Scrivener folder is missing: " + "".join(path))
children = parent.find("Children")
if children is None and title != path[-1]:
raise ValueError("Required Scrivener folder has no children: " + title)
assert parent is not None
return parent
def _backup_binder(self) -> None:
backup_dir = self.project / "Files" / "authoring-backups"
backup_dir.mkdir(parents=True, exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
shutil.copy2(self.reader.project_file, backup_dir / f"{stamp}.scrivx")
checksum = self.project / "Files" / "Data" / "docs.checksum"
if checksum.exists():
shutil.copy2(checksum, backup_dir / f"{stamp}.docs.checksum")
def _update_checksum(self, uuid: str, content: bytes) -> None:
checksum_path = self.project / "Files" / "Data" / "docs.checksum"
digest = hashlib.sha1(content).hexdigest()
key = f"{uuid.lower()}/content.rtf"
lines = checksum_path.read_text(encoding="utf-8", errors="replace").splitlines() if checksum_path.exists() else []
updated = False
output = []
for line in lines:
if line.startswith(key + "="):
output.append(f"{key}={digest}")
updated = True
else:
output.append(line)
if not updated:
output.append(f"{key}={digest}")
checksum_path.write_text("\n".join(output) + "\n", encoding="utf-8")
def _write_rtf(self, uuid: str, text: str) -> None:
content = text_to_rtf(text).encode("utf-8")
data_dir = self.project / "Files" / "Data" / uuid
data_dir.mkdir(parents=True, exist_ok=False)
(data_dir / "content.rtf").write_bytes(content)
self._update_checksum(uuid, content)
def _add_text(self, folder: ET.Element, title: str, text: str) -> str:
children = folder.find("Children")
if children is None:
children = ET.SubElement(folder, "Children")
doc_uuid = str(uuid.uuid4()).upper()
stamp = dt.datetime.now().astimezone().isoformat(timespec="seconds")
item = ET.SubElement(children, "BinderItem", {"UUID": doc_uuid, "Type": "Text", "Created": stamp, "Modified": stamp})
ET.SubElement(item, "Title").text = title
metadata = ET.SubElement(item, "MetaData")
ET.SubElement(metadata, "IncludeInCompile").text = "No"
settings = ET.SubElement(item, "TextSettings")
ET.SubElement(settings, "TextSelection").text = "0,0"
self._write_rtf(doc_uuid, text)
return doc_uuid
def _save_tree(self, tree: ET.ElementTree) -> None:
tree.write(self.reader.project_file, encoding="UTF-8", xml_declaration=True)
def accept_for_editing(self, scene_uuid: str, draft_text: str, package: str, model: str) -> dict:
self._assert_unlocked()
if not draft_text.strip() or not package.strip():
raise ValueError("A non-empty draft and package are required for acceptance.")
scene = self.reader.by_uuid.get(scene_uuid)
if scene is None:
raise ValueError("The selected scene no longer exists.")
self._backup_binder()
tree, binder = self._tree_and_binder()
accepted = self._folder(binder, ["Authoring System", "Drafting Archive", "Accepted for Editing"])
snapshots = self._folder(binder, ["Authoring System", "Drafting Archive", "Package Snapshots"])
stamp = dt.datetime.now().strftime("%Y-%m-%d %H%M")
initial_uuid = self._add_text(accepted, f"{scene.title} — initial draft — {stamp}", draft_text)
working_uuid = self._add_text(accepted, f"{scene.title} — working copy — {stamp}", draft_text)
package_header = f"Scene: {scene.title}\nModel: {model}\nAccepted for editing: {dt.datetime.now().astimezone().isoformat(timespec='seconds')}\n\n"
package_uuid = self._add_text(snapshots, f"{scene.title} — package — {stamp}", package_header + package)
self._save_tree(tree)
self.reader = ScrivenerReader(self.project)
return {"initial_uuid": initial_uuid, "working_uuid": working_uuid, "package_uuid": package_uuid}
def save_working_copy(self, working_uuid: str, text: str) -> None:
self._assert_unlocked()
item = self.reader.by_uuid.get(working_uuid)
if item is None or item.item_type != "Text":
raise ValueError("The working-copy archive record no longer exists.")
path = self.project / "Files" / "Data" / working_uuid / "content.rtf"
if not path.exists():
raise ValueError("The working-copy archive record has no text file.")
content = text_to_rtf(text).encode("utf-8")
path.write_bytes(content)
self._update_checksum(working_uuid, content)
def accept_into_book(self, scene_uuid: str, text: str) -> None:
self._assert_unlocked()
item = self.reader.by_uuid.get(scene_uuid)
if item is None or item.item_type != "Text":
raise ValueError("The selected Manuscript scene no longer exists.")
manuscript = self.reader.top_level("Manuscript")
if manuscript is None or not any(candidate.uuid == scene_uuid for candidate in self.reader.walk(manuscript.children)):
raise ValueError("The selected item is not a Manuscript scene.")
path = self.project / "Files" / "Data" / scene_uuid / "content.rtf"
content = text_to_rtf(text).encode("utf-8")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
self._update_checksum(scene_uuid, content)
def section(name: str, value: str) -> str:
return f"### {name}\n{value.strip() or '(none)'}"
def preflight(project: ScrivenerReader, payload: dict) -> list[str]:
data = project.summary()
scene = next((item for item in data["scenes"] if item["uuid"] == payload.get("scene_uuid")), None)
if scene is None or not scene["brief"].strip():
raise ValueError("Choose a scene with a paired BRIEF.")
if not data["style"].strip():
raise ValueError("STYLE is empty.")
directive = payload.get("directive", "").strip()
if not directive:
raise ValueError("DIRECTIVE is required.")
if not re.search(r"\b(maximum|max|not exceed)\b", directive, re.IGNORECASE):
raise ValueError("DIRECTIVE needs a word maximum.")
return ["No scene-specific NOTES or Place records selected; verify that no physical or time continuity constraint is needed."] if not payload.get("notes", "").strip() and not payload.get("place_uuids", []) else []
def build_package(project: ScrivenerReader, payload: dict) -> str:
preflight(project, payload)
data = project.summary()
scene = next((scene for scene in data["scenes"] if scene["uuid"] == payload.get("scene_uuid")), None)
if scene is None:
@@ -220,6 +396,27 @@ def build_package(project: ScrivenerReader, payload: dict) -> str:
) + "\n"
def draft(project: ScrivenerReader, payload: dict) -> dict:
key = os.environ.get("OPENROUTER_API_KEY")
if not key:
raise ValueError("OPENROUTER_API_KEY is not set in this server's Terminal session.")
if not payload.get("send_confirmation"):
raise ValueError("Confirm the OpenRouter send before generating.")
system = project.summary()["frontier_instructions"].strip()
package = build_package(project, payload)
body = json.dumps({"model": OPENROUTER_MODEL, "messages": [{"role": "system", "content": system}, {"role": "user", "content": package}], "max_tokens": int(payload.get("max_tokens", 3000))}).encode()
request = urllib.request.Request("https://openrouter.ai/api/v1/chat/completions", data=body, headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(request, timeout=300) as response:
result = json.loads(response.read().decode())
except urllib.error.HTTPError as error:
raise ValueError(f"OpenRouter returned HTTP {error.code}: {error.read().decode(errors='replace')}") from error
text = result.get("choices", [{}])[0].get("message", {}).get("content")
if not isinstance(text, str):
raise ValueError("OpenRouter response did not contain draft text.")
return {"draft": text, "usage": result.get("usage", {}), "model": result.get("model", OPENROUTER_MODEL)}
class AppHandler(SimpleHTTPRequestHandler):
reader: ScrivenerReader
@@ -240,7 +437,7 @@ class AppHandler(SimpleHTTPRequestHandler):
self.send_json(self.reader.summary())
return
if path == "/api/health":
self.send_json({"status": "ok", "mode": "read-only prototype"})
self.send_json({"status": "ok", "mode": "local prototype"})
return
if path == "/":
self.path = "/static/index.html"
@@ -252,13 +449,36 @@ class AppHandler(SimpleHTTPRequestHandler):
super().do_GET()
def do_POST(self) -> None:
if urlparse(self.path).path != "/api/package-preview":
path = urlparse(self.path).path
if path not in {"/api/package-preview", "/api/draft", "/api/accept-for-editing", "/api/save-working-copy", "/api/accept-into-book"}:
self.send_error(HTTPStatus.NOT_FOUND)
return
try:
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length).decode("utf-8"))
self.send_json({"package": build_package(self.reader, payload)})
if path == "/api/draft":
self.send_json(draft(self.reader, payload))
elif path == "/api/accept-for-editing":
if not payload.get("accept_confirmation"):
raise ValueError("Confirm acceptance before creating archive records.")
package = build_package(self.reader, payload)
writer = ScrivenerWriter(self.reader.project)
result = writer.accept_for_editing(payload.get("scene_uuid", ""), payload.get("draft", ""), package, payload.get("model", OPENROUTER_MODEL))
AppHandler.reader = writer.reader
self.send_json(result)
elif path == "/api/save-working-copy":
writer = ScrivenerWriter(self.reader.project)
writer.save_working_copy(payload.get("working_uuid", ""), payload.get("text", ""))
self.send_json({"saved": True})
elif path == "/api/accept-into-book":
if not payload.get("final_confirmation"):
raise ValueError("Confirm final acceptance before writing canonical Manuscript text.")
writer = ScrivenerWriter(self.reader.project)
writer.accept_into_book(payload.get("scene_uuid", ""), payload.get("text", ""))
AppHandler.reader = ScrivenerReader(self.reader.project)
self.send_json({"accepted": True})
else:
self.send_json({"package": build_package(self.reader, payload), "warnings": preflight(self.reader, payload)})
except (ValueError, json.JSONDecodeError) as error:
self.send_json({"error": str(error)}, HTTPStatus.BAD_REQUEST)
@@ -267,7 +487,7 @@ class AppHandler(SimpleHTTPRequestHandler):
def main() -> None:
parser = argparse.ArgumentParser(description="Run the read-only authoring-system prototype.")
parser = argparse.ArgumentParser(description="Run the local authoring-system prototype.")
parser.add_argument("--project", type=Path, default=DEFAULT_PROJECT)
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
@@ -276,7 +496,7 @@ def main() -> None:
parser.error(f"Project not found: {project}")
AppHandler.reader = ScrivenerReader(project)
server = ThreadingHTTPServer(("127.0.0.1", args.port), AppHandler)
print(f"Read-only authoring prototype: http://127.0.0.1:{args.port}")
print(f"Local authoring prototype: http://127.0.0.1:{args.port}")
print(f"Project: {project}")
server.serve_forever()