Add brief editing and character record updates
This commit is contained in:
164
prototype/app.py
164
prototype/app.py
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user