Add continuity context and update proposals

This commit is contained in:
Joey Grasty
2026-08-23 17:32:18 -05:00
parent 630c0c468f
commit dfbd313870
42 changed files with 7358 additions and 52 deletions

View File

@@ -163,6 +163,8 @@ class ScrivenerReader:
continuity = self.child(authoring, "Continuity")
briefs_folder = self.child(authoring, "Scene Briefs")
summaries_folder = self.child(continuity, "Scene Summaries")
archive = self.child(authoring, "Drafting Archive")
snapshots_folder = self.child(archive, "Package Snapshots")
style = self.child(setup, "STYLE")
instructions = self.child(setup, "Frontier-Model Instructions")
lexicon = self.child(continuity, "Master Lexicon")
@@ -171,6 +173,21 @@ class ScrivenerReader:
briefs = self.records(briefs_folder, self)
brief_by_title = {brief["title"]: brief for brief in briefs}
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] = {}
for snapshot in self.records(snapshots_folder, self):
scene_title = re.search(r"^Scene:\s*(.+)$", snapshot["text"], re.MULTILINE)
if not scene_title:
continue
title = scene_title.group(1).strip()
text = snapshot["text"]
uuid_line = re.search(r"^Character UUIDs:\s*(.*)$", text, re.MULTILINE)
if uuid_line:
character_uuids = [value for value in uuid_line.group(1).split(",") if value]
else:
character_block = self._package_section(text, "CHARACTERS")
character_uuids = [character_by_title[line.strip()] for line in re.findall(r"^(.+?)\n", character_block) if line.strip() in character_by_title]
package_context[title] = {"character_uuids": character_uuids, "notes": self._package_section(text, "NOTES")}
paired = []
for scene in manuscript:
@@ -180,6 +197,7 @@ class ScrivenerReader:
"brief": brief_by_title.get(scene["title"], {}).get("text", ""),
"brief_uuid": brief_by_title.get(scene["title"], {}).get("uuid"),
"summary": summary_by_title.get(scene["title"], {}).get("text", ""),
"package_context": package_context.get(scene["title"], {"character_uuids": [], "notes": ""}),
}
)
@@ -198,6 +216,11 @@ class ScrivenerReader:
"unmatched_scenes": [scene for scene in manuscript if scene["title"] not in brief_by_title],
}
@staticmethod
def _package_section(package: str, name: str) -> str:
match = re.search(rf"^### {re.escape(name)}\n(.*?)(?=^### |\Z)", package, re.MULTILINE | re.DOTALL)
return match.group(1).strip() if match else ""
class ScrivenerWriter:
"""The small, guarded subset of Scrivener writes needed by this prototype."""
@@ -288,7 +311,7 @@ class ScrivenerWriter:
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:
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():
raise ValueError("A non-empty draft and package are required for acceptance.")
@@ -302,7 +325,7 @@ class ScrivenerWriter:
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_header = f"Scene: {scene.title}\nModel: {model}\nCharacter UUIDs: {','.join(character_uuids)}\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)
@@ -334,6 +357,64 @@ class ScrivenerWriter:
path.write_bytes(content)
self._update_checksum(scene_uuid, content)
def save_scene_summary(self, scene_uuid: str, summary: str) -> str:
"""Create or update the one author-approved dense summary for a scene."""
self._assert_unlocked()
scene = self.reader.by_uuid.get(scene_uuid)
if scene is None or not summary.strip():
raise ValueError("A scene and non-empty summary are required.")
tree, binder = self._tree_and_binder()
summaries = self._folder(binder, ["Authoring System", "Continuity", "Scene Summaries"])
existing = self._find_child(summaries, scene.title)
if existing is not None:
existing_uuid = existing.attrib.get("UUID", "")
path = self.project / "Files" / "Data" / existing_uuid / "content.rtf"
if not existing_uuid or not path.exists():
raise ValueError("The existing scene summary record is incomplete.")
content = text_to_rtf(summary).encode("utf-8")
path.write_bytes(content)
self._update_checksum(existing_uuid, content)
return existing_uuid
self._backup_binder()
summary_uuid = self._add_text(summaries, scene.title, summary)
self._save_tree(tree)
self.reader = ScrivenerReader(self.project)
return summary_uuid
def _append_to_item(self, item: Item, addition: str) -> bool:
path = self.project / "Files" / "Data" / item.uuid / "content.rtf"
existing = self.reader.content(item)
addition = addition.strip()
if not addition or addition in existing:
return False
content = text_to_rtf((existing.rstrip() + "\n\n" + addition).strip() + "\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], 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 = []
for update in character_updates:
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()}"):
saved_characters.append(item.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}
def section(name: str, value: str) -> str:
return f"### {name}\n{value.strip() or '(none)'}"
@@ -417,6 +498,68 @@ def draft(project: ScrivenerReader, payload: dict) -> dict:
return {"draft": text, "usage": result.get("usage", {}), "model": result.get("model", OPENROUTER_MODEL)}
def propose_summary(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 a summary.")
scene = project.by_uuid.get(payload.get("scene_uuid", ""))
text = payload.get("text", "").strip()
if scene is None or not text:
raise ValueError("A canonical scene is required to generate a summary.")
system = "You create continuity records for a fiction-authoring system. Return exactly one dense, factual sentence that captures the scene's viewpoint, material plot movement, character or relationship changes, discoveries, and unresolved state. Do not add a title, explanation, or invented facts."
user = f"Scene label: {scene.title}\n\nCanonical scene:\n{text}"
body = json.dumps({"model": OPENROUTER_MODEL, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], "max_tokens": 500}).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
summary = result.get("choices", [{}])[0].get("message", {}).get("content")
if not isinstance(summary, str) or not summary.strip():
raise ValueError("OpenRouter response did not contain a scene summary.")
return {"summary": summary.strip(), "model": result.get("model", OPENROUTER_MODEL)}
def propose_continuity_updates(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 continuity proposals.")
scene = project.by_uuid.get(payload.get("scene_uuid", ""))
text = payload.get("text", "").strip()
if scene is None or not text:
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."
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")
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
raw = result.get("choices", [{}])[0].get("message", {}).get("content")
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)
try:
proposals = json.loads(raw)
except json.JSONDecodeError as error:
raise ValueError("OpenRouter returned proposals in an unexpected format; please try again.") from error
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)}
class AppHandler(SimpleHTTPRequestHandler):
reader: ScrivenerReader
@@ -450,7 +593,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"}:
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"}:
self.send_error(HTTPStatus.NOT_FOUND)
return
try:
@@ -463,7 +606,7 @@ class AppHandler(SimpleHTTPRequestHandler):
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))
result = writer.accept_for_editing(payload.get("scene_uuid", ""), payload.get("draft", ""), package, payload.get("model", OPENROUTER_MODEL), payload.get("character_uuids", []))
AppHandler.reader = writer.reader
self.send_json(result)
elif path == "/api/save-working-copy":
@@ -477,6 +620,24 @@ class AppHandler(SimpleHTTPRequestHandler):
writer.accept_into_book(payload.get("scene_uuid", ""), payload.get("text", ""))
AppHandler.reader = ScrivenerReader(self.reader.project)
self.send_json({"accepted": True})
elif path == "/api/propose-summary":
self.send_json(propose_summary(self.reader, payload))
elif path == "/api/save-summary":
if not payload.get("summary_confirmation"):
raise ValueError("Confirm the summary before saving it to Scrivener.")
writer = ScrivenerWriter(self.reader.project)
summary_uuid = writer.save_scene_summary(payload.get("scene_uuid", ""), payload.get("summary", ""))
AppHandler.reader = writer.reader
self.send_json({"saved": True, "summary_uuid": summary_uuid})
elif path == "/api/propose-continuity-updates":
self.send_json(propose_continuity_updates(self.reader, payload))
elif path == "/api/save-continuity-updates":
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", ""))
AppHandler.reader = ScrivenerReader(self.reader.project)
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: