Add continuity context and update proposals
This commit is contained in:
169
prototype/app.py
169
prototype/app.py
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user