Add brief editing and character record updates

This commit is contained in:
Joey Grasty
2026-08-23 21:01:16 -05:00
parent dfbd313870
commit 4499d4bd8f
70 changed files with 13946 additions and 152 deletions

View File

@@ -81,6 +81,7 @@ def text_to_rtf(value: str) -> str:
class ScrivenerReader:
def __init__(self, project: Path):
self.project = project
self.bindings_path = project / "Files" / "authoring-system-bindings.json"
project_files = list(project.glob("*.scrivx"))
if len(project_files) != 1:
raise ValueError("Project must contain exactly one .scrivx file.")
@@ -88,6 +89,16 @@ class ScrivenerReader:
self.root_items = self._load_binder()
self.by_uuid = {item.uuid: item for item in self.walk()}
def bindings(self) -> dict[str, str]:
if not self.bindings_path.exists():
return {}
try:
data = json.loads(self.bindings_path.read_text(encoding="utf-8"))
bindings = data.get("scene_to_brief", {})
return {str(scene_uuid): str(brief_uuid) for scene_uuid, brief_uuid in bindings.items()} if isinstance(bindings, dict) else {}
except (json.JSONDecodeError, OSError):
return {}
def _load_binder(self) -> list[Item]:
root = ET.parse(self.project_file).getroot()
binder = root.find("Binder")
@@ -172,6 +183,8 @@ class ScrivenerReader:
manuscript = self.manuscript_scenes()
briefs = self.records(briefs_folder, self)
brief_by_title = {brief["title"]: brief for brief in briefs}
brief_by_uuid = {brief["uuid"]: brief for brief in briefs}
bindings = self.bindings()
summary_by_title = {record["title"]: record for record in self.records(summaries_folder, self)}
character_by_title = {character["title"]: character["uuid"] for character in self.records(self.top_level("Characters"), self)}
package_context: dict[str, dict] = {}
@@ -190,18 +203,26 @@ class ScrivenerReader:
package_context[title] = {"character_uuids": character_uuids, "notes": self._package_section(text, "NOTES")}
paired = []
bound_brief_uuids: set[str] = set()
for scene in manuscript:
brief = brief_by_uuid.get(bindings.get(scene["uuid"], ""))
binding_state = "stable" if brief else ""
if brief is None:
brief = brief_by_title.get(scene["title"])
binding_state = "legacy-title-match" if brief else "unpaired"
if brief:
bound_brief_uuids.add(brief["uuid"])
paired.append(
{
**scene,
"brief": brief_by_title.get(scene["title"], {}).get("text", ""),
"brief_uuid": brief_by_title.get(scene["title"], {}).get("uuid"),
"brief": (brief or {}).get("text", ""),
"brief_uuid": (brief or {}).get("uuid"),
"binding_state": binding_state,
"summary": summary_by_title.get(scene["title"], {}).get("text", ""),
"package_context": package_context.get(scene["title"], {"character_uuids": [], "notes": ""}),
}
)
manuscript_titles = {scene["title"] for scene in manuscript}
return {
"project": str(self.project),
"locked": (self.project / "Files" / "user.lock").exists(),
@@ -212,8 +233,13 @@ class ScrivenerReader:
"scenes": paired,
"characters": self.records(self.top_level("Characters"), self),
"places": self.records(self.top_level("Places"), self),
"unmatched_briefs": [brief for brief in briefs if brief["title"] not in manuscript_titles],
"unmatched_scenes": [scene for scene in manuscript if scene["title"] not in brief_by_title],
"unmatched_briefs": [brief for brief in briefs if brief["uuid"] not in bound_brief_uuids],
"unmatched_scenes": [scene for scene in paired if not scene["brief_uuid"]],
"binding_counts": {
"stable": sum(scene["binding_state"] == "stable" for scene in paired),
"legacy": sum(scene["binding_state"] == "legacy-title-match" for scene in paired),
"unpaired": sum(scene["binding_state"] == "unpaired" for scene in paired),
},
}
@staticmethod
@@ -233,6 +259,30 @@ class ScrivenerWriter:
if (self.project / "Files" / "user.lock").exists():
raise ValueError("Scrivener is open (Files/user.lock exists). Close the project before writing.")
def _write_bindings(self, bindings: dict[str, str]) -> None:
payload = {
"version": 1,
"updated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
"scene_to_brief": bindings,
}
temporary = self.reader.bindings_path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
os.replace(temporary, self.reader.bindings_path)
def reconcile_scene_bindings(self) -> dict:
"""Persist currently resolved scene/BRIEF pairs without relying on their titles."""
self._assert_unlocked()
data = self.reader.summary()
briefs_folder = self.reader.child(self.reader.top_level("Authoring System"), "Scene Briefs")
valid_brief_uuids = {item.uuid for item in (briefs_folder.children if briefs_folder else []) if item.item_type == "Text"}
bindings = {scene_uuid: brief_uuid for scene_uuid, brief_uuid in self.reader.bindings().items() if scene_uuid in self.reader.by_uuid and brief_uuid in valid_brief_uuids}
for scene in data["scenes"]:
if scene["brief_uuid"]:
bindings[scene["uuid"]] = scene["brief_uuid"]
self._write_bindings(bindings)
self.reader = ScrivenerReader(self.project)
return self.reader.summary()["binding_counts"]
def _tree_and_binder(self) -> tuple[ET.ElementTree, ET.Element]:
tree = ET.parse(self.reader.project_file)
binder = tree.getroot().find("Binder")
@@ -311,6 +361,35 @@ class ScrivenerWriter:
def _save_tree(self, tree: ET.ElementTree) -> None:
tree.write(self.reader.project_file, encoding="UTF-8", xml_declaration=True)
def save_brief(self, scene_uuid: str, text: str) -> dict:
"""Update an existing BRIEF or create and bind one for a planned scene."""
self._assert_unlocked()
scene = self.reader.by_uuid.get(scene_uuid)
if scene is None:
raise ValueError("The selected Manuscript scene no longer exists.")
paired = next((item for item in self.reader.summary()["scenes"] if item["uuid"] == scene_uuid), None)
if paired and paired["brief_uuid"]:
brief_uuid = paired["brief_uuid"]
path = self.project / "Files" / "Data" / brief_uuid / "content.rtf"
content = text_to_rtf(text).encode("utf-8")
path.write_bytes(content)
self._update_checksum(brief_uuid, content)
bindings = self.reader.bindings()
bindings[scene_uuid] = brief_uuid
self._write_bindings(bindings)
self.reader = ScrivenerReader(self.project)
return {"created": False, "brief_uuid": brief_uuid}
self._backup_binder()
tree, binder = self._tree_and_binder()
briefs_folder = self._folder(binder, ["Authoring System", "Scene Briefs"])
brief_uuid = self._add_text(briefs_folder, scene.title, text)
self._save_tree(tree)
bindings = self.reader.bindings()
bindings[scene_uuid] = brief_uuid
self._write_bindings(bindings)
self.reader = ScrivenerReader(self.project)
return {"created": True, "brief_uuid": brief_uuid}
def accept_for_editing(self, scene_uuid: str, draft_text: str, package: str, model: str, character_uuids: list[str]) -> dict:
self._assert_unlocked()
if not draft_text.strip() or not package.strip():
@@ -392,12 +471,23 @@ class ScrivenerWriter:
self._update_checksum(item.uuid, content)
return True
def save_continuity_updates(self, scene_uuid: str, character_updates: list[dict], lexicon_additions: str, world_note_additions: str) -> dict:
def _replace_item(self, item: Item, text: str) -> bool:
text = text.strip()
if not text:
raise ValueError("A Character record cannot be replaced with empty text.")
path = self.project / "Files" / "Data" / item.uuid / "content.rtf"
if self.reader.content(item).strip() == text:
return False
content = text_to_rtf(text + "\n").encode("utf-8")
path.write_bytes(content)
self._update_checksum(item.uuid, content)
return True
def save_continuity_updates(self, scene_uuid: str, character_updates: list[dict], new_characters: list[dict], lexicon_additions: str, world_note_additions: str) -> dict:
self._assert_unlocked()
scene = self.reader.by_uuid.get(scene_uuid)
if scene is None:
raise ValueError("The selected scene no longer exists.")
stamp = f"Continuity update from {scene.title}:"
characters_folder = self.reader.top_level("Characters")
characters = {item.uuid: item for item in (characters_folder.children if characters_folder else []) if item.item_type == "Text"}
saved_characters = []
@@ -405,15 +495,34 @@ class ScrivenerWriter:
item = characters.get(update.get("uuid", ""))
if item is None:
raise ValueError("A proposed character update does not target a current Character record.")
if self._append_to_item(item, f"{stamp}\n{update.get('text', '').strip()}"):
if self._replace_item(item, update.get("text", "")):
saved_characters.append(item.title)
stamp = f"Continuity update from {scene.title}:"
authoring = self.reader.top_level("Authoring System")
continuity = self.reader.child(authoring, "Continuity")
lexicon = self.reader.child(continuity, "Master Lexicon")
world_notes = self.reader.child(continuity, "World Notes")
lexicon_saved = self._append_to_item(lexicon, f"{stamp}\n{lexicon_additions.strip()}") if lexicon and lexicon_additions.strip() else False
world_saved = self._append_to_item(world_notes, f"{stamp}\n{world_note_additions.strip()}") if world_notes and world_note_additions.strip() else False
return {"characters": saved_characters, "lexicon": lexicon_saved, "world_notes": world_saved}
existing_titles = {item.title.casefold() for item in characters.values()}
created_characters = []
candidates = []
for character in new_characters:
name = str(character.get("name", "")).strip()
description = str(character.get("text", "")).strip()
if name and description and name.casefold() not in existing_titles:
candidates.append((name, description))
existing_titles.add(name.casefold())
if candidates:
self._backup_binder()
tree, binder = self._tree_and_binder()
characters_folder = self._folder(binder, ["Characters"])
for name, description in candidates:
self._add_text(characters_folder, name, description)
created_characters.append(name)
self._save_tree(tree)
self.reader = ScrivenerReader(self.project)
return {"characters": saved_characters, "new_characters": created_characters, "lexicon": lexicon_saved, "world_notes": world_saved}
def section(name: str, value: str) -> str:
@@ -535,7 +644,7 @@ def propose_continuity_updates(project: ScrivenerReader, payload: dict) -> dict:
raise ValueError("A canonical scene is required to propose continuity updates.")
data = project.summary()
context = {"characters": data["characters"], "master_lexicon": data["master_lexicon"], "world_notes": data["world_notes"]}
system = "You propose factual continuity updates for a fiction-authoring system. Return JSON only, with exactly these keys: character_updates (array of objects with character_uuid and text), lexicon_additions (string), world_note_additions (string). Suggest only information newly established or materially changed by the canonical scene and absent from the supplied records. Character text must be a concise factual update, without a heading. Use a supplied UUID only when updating an existing character; do not create characters here. Use empty arrays or empty strings when there is no justified addition."
system = "You propose factual continuity updates for a fiction-authoring system. Return JSON only, with exactly these keys: character_updates (array of objects with character_uuid and text), new_characters (array of objects with name and text), lexicon_additions (string), world_note_additions (string). Suggest only information newly established or materially changed by the canonical scene and absent from the supplied records. Character text must be a concise factual update, without a heading. Use a supplied UUID only when updating an existing character. Put a newly introduced named person in new_characters, never in lexicon_additions; name is the character's display name and text is an initial concise factual Character record. Do not create a new character for a passing unnamed person. Use empty arrays or empty strings when there is no justified addition."
user = "Existing continuity records:\n" + json.dumps(context, ensure_ascii=False) + f"\n\nCanonical scene ({scene.title}):\n{text}"
body = json.dumps({"model": OPENROUTER_MODEL, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], "max_tokens": 1200}).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")
@@ -548,16 +657,35 @@ def propose_continuity_updates(project: ScrivenerReader, payload: dict) -> dict:
if not isinstance(raw, str):
raise ValueError("OpenRouter response did not contain continuity proposals.")
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw.strip(), flags=re.IGNORECASE)
# Frontier models occasionally add a one-line introduction despite the JSON-only
# instruction. Accept one complete object embedded in that formatting, but do
# not attempt to repair malformed JSON.
first_brace = raw.find("{")
last_brace = raw.rfind("}")
if first_brace >= 0 and last_brace > first_brace:
raw = raw[first_brace:last_brace + 1]
try:
proposals = json.loads(raw)
except json.JSONDecodeError as error:
raise ValueError("OpenRouter returned proposals in an unexpected format; please try again.") from error
if not isinstance(proposals, dict):
raise ValueError("OpenRouter returned proposals in an unexpected format; please try again.")
valid_uuids = {character["uuid"] for character in data["characters"]}
updates = [
{"uuid": update.get("character_uuid", ""), "text": str(update.get("text", "")).strip()}
for update in proposals.get("character_updates", []) if isinstance(update, dict) and update.get("character_uuid") in valid_uuids and str(update.get("text", "")).strip()
]
return {"character_updates": updates, "lexicon_additions": str(proposals.get("lexicon_additions", "")).strip(), "world_note_additions": str(proposals.get("world_note_additions", "")).strip(), "model": result.get("model", OPENROUTER_MODEL)}
existing_names = {character["title"].casefold() for character in data["characters"]}
new_characters = []
for character in proposals.get("new_characters", []):
if not isinstance(character, dict):
continue
name = str(character.get("name", "")).strip()
text = str(character.get("text", "")).strip()
if name and text and name.casefold() not in existing_names:
new_characters.append({"name": name, "text": text})
existing_names.add(name.casefold())
return {"character_updates": updates, "new_characters": new_characters, "lexicon_additions": str(proposals.get("lexicon_additions", "")).strip(), "world_note_additions": str(proposals.get("world_note_additions", "")).strip(), "model": result.get("model", OPENROUTER_MODEL)}
class AppHandler(SimpleHTTPRequestHandler):
@@ -593,7 +721,7 @@ class AppHandler(SimpleHTTPRequestHandler):
def do_POST(self) -> None:
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", "/api/propose-summary", "/api/save-summary", "/api/propose-continuity-updates", "/api/save-continuity-updates"}:
if path not in {"/api/package-preview", "/api/draft", "/api/accept-for-editing", "/api/save-working-copy", "/api/accept-into-book", "/api/propose-summary", "/api/save-summary", "/api/propose-continuity-updates", "/api/save-continuity-updates", "/api/reconcile-bindings", "/api/save-brief"}:
self.send_error(HTTPStatus.NOT_FOUND)
return
try:
@@ -635,9 +763,19 @@ class AppHandler(SimpleHTTPRequestHandler):
if not payload.get("updates_confirmation"):
raise ValueError("Confirm the proposed continuity updates before saving them to Scrivener.")
writer = ScrivenerWriter(self.reader.project)
result = writer.save_continuity_updates(payload.get("scene_uuid", ""), payload.get("character_updates", []), payload.get("lexicon_additions", ""), payload.get("world_note_additions", ""))
result = writer.save_continuity_updates(payload.get("scene_uuid", ""), payload.get("character_updates", []), payload.get("new_characters", []), payload.get("lexicon_additions", ""), payload.get("world_note_additions", ""))
AppHandler.reader = ScrivenerReader(self.reader.project)
self.send_json(result)
elif path == "/api/reconcile-bindings":
writer = ScrivenerWriter(self.reader.project)
counts = writer.reconcile_scene_bindings()
AppHandler.reader = writer.reader
self.send_json({"binding_counts": counts})
elif path == "/api/save-brief":
writer = ScrivenerWriter(self.reader.project)
result = writer.save_brief(payload.get("scene_uuid", ""), payload.get("text", ""))
AppHandler.reader = writer.reader
self.send_json(result)
else:
self.send_json({"package": build_package(self.reader, payload), "warnings": preflight(self.reader, payload)})
except (ValueError, json.JSONDecodeError) as error: