expand arc polish

This commit is contained in:
vegu-ai-tools
2026-03-31 18:34:18 +03:00
parent 1c23c0fdc2
commit 87a109c7ef
31 changed files with 2054 additions and 980 deletions

View File

@@ -26,6 +26,7 @@ from .websocket_handler import DirectorWebsocketHandler
from .chat.mixin import DirectorChatMixin
from .character_management import CharacterManagementMixin
from .scene_direction.mixin import SceneDirectionMixin
from .plan.mixin import PlanMixin
import talemate.agents.director.nodes # noqa: F401
if TYPE_CHECKING:
@@ -38,6 +39,7 @@ log = structlog.get_logger("talemate.agent.director")
class DirectorAgent(
DirectorChatMixin,
SceneDirectionMixin,
PlanMixin,
GuideSceneMixin,
MemoryRAGMixin,
GenerateChoicesMixin,
@@ -95,6 +97,7 @@ class DirectorAgent(
CharacterManagementMixin.add_actions(actions)
DirectorChatMixin.add_actions(actions)
SceneDirectionMixin.add_scene_direction_actions(actions)
PlanMixin.add_plan_actions(actions)
return actions
@classmethod

View File

@@ -118,25 +118,6 @@ class DirectorChatMixin:
step=10,
min=20,
max=500,
title="Arc Generation",
),
"generate_arc_dialogue_ratio": AgentActionConfig(
type="number",
label="Dialogue beat ratio",
description="Target fraction of beats that should be dialogue during arc generation. 0.4 = 40% dialogue beats.",
value=0.4,
step=0.1,
min=0.0,
max=1.0,
),
"generate_arc_expand_chunk_size": AgentActionConfig(
type="number",
label="Expand chunk size",
description="Number of beats per expansion chunk in expand mode. Larger chunks produce more cohesive prose but use more context.",
value=5,
step=1,
min=3,
max=12,
),
},
)
@@ -163,10 +144,6 @@ class DirectorChatMixin:
def chat_scene_context_ratio(self) -> float:
return self.actions["chat"].config["scene_context_ratio"].value
@property
def chat_dialogue_ratio(self) -> float:
return self.actions["chat"].config["generate_arc_dialogue_ratio"].value
@property
def chat_enable_analysis(self) -> bool:
return self.actions["chat"].config["enable_analysis"].value

View File

@@ -44,6 +44,8 @@ class ChatCreateGenerateArcPayload(pydantic.BaseModel):
beat_count: int = 8
dialogue_ratio: float | None = None
mode: Literal["generate_arc", "generate_arc_expand"] = "generate_arc"
outline_critique: bool | None = None
expand_critique: bool | None = None
class ChatRegeneratePayload(pydantic.BaseModel):
@@ -451,10 +453,20 @@ class DirectorChatWebsocketMixin:
payload = ChatCreateGenerateArcPayload(**data)
if payload.dialogue_ratio is not None:
self.director.actions["chat"].config[
"generate_arc_dialogue_ratio"
self.director.actions["plan"].config[
"dialogue_ratio"
].value = payload.dialogue_ratio
if payload.outline_critique is not None:
self.director.actions["plan"].config[
"outline_critique"
].value = payload.outline_critique
if payload.expand_critique is not None:
self.director.actions["plan"].config[
"expand_critique"
].value = payload.expand_critique
chat = self.director.chat_create_generate_arc(
payload.instructions,
payload.beat_count,

File diff suppressed because it is too large Load Diff

View File

@@ -161,7 +161,7 @@
"base_type": "core/DynamicSocketNodeBase"
},
"6afa0a64-551b-4724-85f4-53a5bdebbac9": {
"title": "Generate Response",
"title": "Critique Outline",
"id": "6afa0a64-551b-4724-85f4-53a5bdebbac9",
"properties": {
"data_output": false,

View File

@@ -169,28 +169,15 @@
},
"x": 570,
"y": 320,
"width": 212,
"height": 158,
"width": 220,
"height": 178,
"collapsed": false,
"inherited": false,
"registry": "agents/director/plan/ExpandStoryArc",
"base_type": "core/Node"
},
"dc9bff13-a1bb-46a7-9159-090287494766": {
"title": "Director Settings",
"id": "dc9bff13-a1bb-46a7-9159-090287494766",
"properties": {},
"x": 10,
"y": 760,
"width": 363,
"height": 926,
"collapsed": true,
"inherited": false,
"registry": "agents/director/Settings",
"base_type": "core/Node"
},
"fe4b0231-b5ec-4dd2-9ff5-8e3b2bfad061": {
"title": "chat_generate_arc_expand_chunk_size",
"title": "plan_expand_chunk_size",
"id": "fe4b0231-b5ec-4dd2-9ff5-8e3b2bfad061",
"properties": {},
"x": 200,
@@ -216,6 +203,47 @@
"inherited": false,
"registry": "data/number/AsNumber",
"base_type": "core/Node"
},
"08c18906-862b-4450-a1b1-71937b095717": {
"title": "plan_expand_critique",
"id": "08c18906-862b-4450-a1b1-71937b095717",
"properties": {},
"x": 230,
"y": 850,
"width": 168,
"height": 26,
"collapsed": false,
"inherited": false,
"registry": "core/Watch",
"base_type": "core/Node"
},
"673c3bef-fe28-409e-8f3a-1be74aa33780": {
"title": "As Bool",
"id": "673c3bef-fe28-409e-8f3a-1be74aa33780",
"properties": {
"default": false
},
"x": 450,
"y": 870,
"width": 210,
"height": 58,
"collapsed": true,
"inherited": false,
"registry": "core/AsBool",
"base_type": "core/Node"
},
"dc9bff13-a1bb-46a7-9159-090287494766": {
"title": "Director Settings",
"id": "dc9bff13-a1bb-46a7-9159-090287494766",
"properties": {},
"x": 10,
"y": 770,
"width": 363,
"height": 986,
"collapsed": true,
"inherited": false,
"registry": "agents/director/Settings",
"base_type": "core/Node"
}
},
"edges": {
@@ -252,14 +280,23 @@
"a30b01b3-84da-46c0-8783-785660b85ccb.result": [
"b12ebc94-8767-4ce4-9d97-1f8adfa8ae66.value"
],
"dc9bff13-a1bb-46a7-9159-090287494766.chat_generate_arc_expand_chunk_size": [
"fe4b0231-b5ec-4dd2-9ff5-8e3b2bfad061.value"
],
"fe4b0231-b5ec-4dd2-9ff5-8e3b2bfad061.value": [
"9b188432-1182-4fe0-82d4-28ad012a7a9a.value"
],
"9b188432-1182-4fe0-82d4-28ad012a7a9a.value": [
"a30b01b3-84da-46c0-8783-785660b85ccb.chunk_size"
],
"08c18906-862b-4450-a1b1-71937b095717.value": [
"673c3bef-fe28-409e-8f3a-1be74aa33780.value"
],
"673c3bef-fe28-409e-8f3a-1be74aa33780.value": [
"a30b01b3-84da-46c0-8783-785660b85ccb.expand_critique"
],
"dc9bff13-a1bb-46a7-9159-090287494766.plan_expand_chunk_size": [
"fe4b0231-b5ec-4dd2-9ff5-8e3b2bfad061.value"
],
"dc9bff13-a1bb-46a7-9159-090287494766.plan_expand_critique": [
"08c18906-862b-4450-a1b1-71937b095717.value"
]
},
"groups": [
@@ -277,8 +314,8 @@
"title": "Process",
"x": -2,
"y": 226,
"width": 1051,
"height": 574,
"width": 1050,
"height": 675,
"color": "#3f789e",
"font_size": 24,
"inherited": false
@@ -290,28 +327,28 @@
"inputs": [],
"outputs": [
{
"id": "3d11a687-076d-4645-9bfc-43e63daf0739",
"id": "37499312-00a2-4f6f-862e-63372a020be1",
"name": "fn",
"optional": false,
"group": null,
"socket_type": "function"
},
{
"id": "0901e75e-4235-484a-a5ed-3bdf1bd83e52",
"id": "deea07dc-3fde-475f-b6f1-db7a4e723862",
"name": "name",
"optional": false,
"group": null,
"socket_type": "str"
},
{
"id": "728a9946-813f-4125-bffb-153eeaa3e2fb",
"id": "235121d0-ddca-48e0-828c-e4f8c0fe1602",
"name": "allow_multiple_calls",
"optional": false,
"group": null,
"socket_type": "bool"
},
{
"id": "26402d15-3605-4ae7-aa5a-c909b2e13825",
"id": "bcbb8f4e-0ad8-48ae-b874-3d7615c4d3a1",
"name": "ai_callback",
"optional": false,
"group": null,

View File

@@ -149,8 +149,8 @@
"typ": "int",
"instructions": "The exact number of beats to generate. Each beat represents one narrator or conversation generation turn."
},
"x": -1367,
"y": 747,
"x": -1722,
"y": 642,
"width": 210,
"height": 106,
"collapsed": false,
@@ -166,8 +166,8 @@
"typ": "str",
"instructions": "What the scene outline should cover. Include the setting, characters involved, tone, themes, and any specific events or arcs."
},
"x": -1367,
"y": 587,
"x": -1722,
"y": 482,
"width": 210,
"height": 106,
"collapsed": false,
@@ -191,8 +191,8 @@
}
]
},
"x": -560,
"y": 650,
"x": -546,
"y": 542,
"width": 244,
"height": 82,
"collapsed": false,
@@ -308,21 +308,6 @@
"registry": "agents/director/chat/DirectorChatSubAction",
"base_type": "core/Node"
},
"506d845f-e842-4e95-9285-2ad407a2146e": {
"title": "DEF create_outline",
"id": "506d845f-e842-4e95-9285-2ad407a2146e",
"properties": {
"name": "create_outline"
},
"x": -270,
"y": 650,
"width": 210,
"height": 78,
"collapsed": false,
"inherited": false,
"registry": "core/functions/DefineFunction",
"base_type": "core/Node"
},
"32c778ed-68e4-4039-9b95-03726c146323": {
"title": "DEF create_plan",
"id": "32c778ed-68e4-4039-9b95-03726c146323",
@@ -861,46 +846,12 @@
"registry": "focal/Argument",
"base_type": "core/Node"
},
"651a5f02-fd36-4da3-8e6a-80d18c415f22": {
"title": "Create Outline",
"id": "651a5f02-fd36-4da3-8e6a-80d18c415f22",
"properties": {},
"x": -1053,
"y": 646,
"width": 212,
"height": 106,
"collapsed": false,
"inherited": false,
"registry": "agents/director/plan/CreateOutline",
"base_type": "core/Graph"
},
"b52af225-f7f7-4466-b27e-3a96b591eb26": {
"title": "Jinja2 Format",
"id": "b52af225-f7f7-4466-b27e-3a96b591eb26",
"properties": {
"template": "Plan ID: {{ plan_id }}"
},
"x": -970,
"y": 800,
"width": 210,
"height": 153,
"collapsed": true,
"inherited": false,
"registry": "prompt/Jinja2Format",
"dynamic_inputs": [
{
"name": "item0",
"type": "*"
}
],
"base_type": "core/DynamicSocketNodeBase"
},
"ce9a5ea3-57cf-4323-8c61-b32a47c310f6": {
"title": "Return",
"id": "ce9a5ea3-57cf-4323-8c61-b32a47c310f6",
"properties": {},
"x": -780,
"y": 680,
"x": -766,
"y": 542,
"width": 140,
"height": 26,
"collapsed": false,
@@ -944,6 +895,81 @@
}
],
"base_type": "core/DynamicSocketNodeBase"
},
"506d845f-e842-4e95-9285-2ad407a2146e": {
"title": "DEF create_outline",
"id": "506d845f-e842-4e95-9285-2ad407a2146e",
"properties": {
"name": "create_outline"
},
"x": -256,
"y": 542,
"width": 210,
"height": 78,
"collapsed": false,
"inherited": false,
"registry": "core/functions/DefineFunction",
"base_type": "core/Node"
},
"651a5f02-fd36-4da3-8e6a-80d18c415f22": {
"title": "Create Outline",
"id": "651a5f02-fd36-4da3-8e6a-80d18c415f22",
"properties": {},
"x": -1206,
"y": 542,
"width": 212,
"height": 126,
"collapsed": false,
"inherited": false,
"registry": "agents/director/plan/CreateOutline",
"base_type": "core/Graph"
},
"50a06e8b-0b1f-4794-ac11-5b76b6d0a341": {
"title": "plan_outline_critique",
"id": "50a06e8b-0b1f-4794-ac11-5b76b6d0a341",
"properties": {},
"x": -1446,
"y": 722,
"width": 176,
"height": 26,
"collapsed": false,
"inherited": false,
"registry": "core/Watch",
"base_type": "core/Node"
},
"0680094e-85a3-4704-a2c5-2a6b9c60085d": {
"title": "Director Settings",
"id": "0680094e-85a3-4704-a2c5-2a6b9c60085d",
"properties": {},
"x": -1722,
"y": 832,
"width": 363,
"height": 986,
"collapsed": true,
"inherited": false,
"registry": "agents/director/Settings",
"base_type": "core/Node"
},
"b52af225-f7f7-4466-b27e-3a96b591eb26": {
"title": "Jinja2 Format",
"id": "b52af225-f7f7-4466-b27e-3a96b591eb26",
"properties": {
"template": "Plan ID: {{ plan_id }}"
},
"x": -933,
"y": 542,
"width": 210,
"height": 153,
"collapsed": true,
"inherited": false,
"registry": "prompt/Jinja2Format",
"dynamic_inputs": [
{
"name": "item0",
"type": "*"
}
],
"base_type": "core/DynamicSocketNodeBase"
}
},
"edges": {
@@ -1094,12 +1120,6 @@
"c9309352-4080-401f-8e7a-8f82d4cb0cd1.state",
"c9309352-4080-401f-8e7a-8f82d4cb0cd1.task_id"
],
"651a5f02-fd36-4da3-8e6a-80d18c415f22.plan_id": [
"b52af225-f7f7-4466-b27e-3a96b591eb26.item0"
],
"b52af225-f7f7-4466-b27e-3a96b591eb26.result": [
"ce9a5ea3-57cf-4323-8c61-b32a47c310f6.value"
],
"ce9a5ea3-57cf-4323-8c61-b32a47c310f6.value": [
"1f391a2f-a696-44fd-8dbb-951f6612d627.state"
],
@@ -1111,15 +1131,27 @@
],
"acfef6ab-44e3-4a82-8fef-ff6428374181.result": [
"bb0aaccf-8d2e-4b7a-9536-ac729b8e02dc.value"
],
"651a5f02-fd36-4da3-8e6a-80d18c415f22.plan_id": [
"b52af225-f7f7-4466-b27e-3a96b591eb26.item0"
],
"50a06e8b-0b1f-4794-ac11-5b76b6d0a341.value": [
"651a5f02-fd36-4da3-8e6a-80d18c415f22.critique"
],
"0680094e-85a3-4704-a2c5-2a6b9c60085d.plan_outline_critique": [
"50a06e8b-0b1f-4794-ac11-5b76b6d0a341.value"
],
"b52af225-f7f7-4466-b27e-3a96b591eb26.result": [
"ce9a5ea3-57cf-4323-8c61-b32a47c310f6.value"
]
},
"groups": [
{
"title": "Function",
"x": -1388,
"y": 521,
"width": 1366,
"height": 365,
"x": -1747,
"y": 402,
"width": 1726,
"height": 484,
"color": "#b06634",
"font_size": 24,
"inherited": false
@@ -1191,28 +1223,28 @@
"inputs": [],
"outputs": [
{
"id": "e6602099-1ace-47f3-9f17-bbc3c7dea5f6",
"id": "161139d1-8eb6-4a7d-89d4-00cefc247065",
"name": "fn",
"optional": false,
"group": null,
"socket_type": "function"
},
{
"id": "353a9c7c-4b64-4273-ba85-0461105f7d56",
"id": "03903a3d-5342-4b07-859f-70cb10384b7d",
"name": "name",
"optional": false,
"group": null,
"socket_type": "str"
},
{
"id": "c5e18533-c2a3-4d0e-a927-0c7f11db3d99",
"id": "1c5b36f4-cd45-4b3b-9fdc-0602f0a76330",
"name": "allow_multiple_calls",
"optional": false,
"group": null,
"socket_type": "bool"
},
{
"id": "8f932bf2-9c66-4ec9-8d91-a0ec166364f9",
"id": "b632a9f9-c4ae-4157-ae24-d0a8490de4b9",
"name": "ai_callback",
"optional": false,
"group": null,

View File

@@ -6,4 +6,5 @@ The director creates plans with tasks, then uses existing actions to execute the
from .schema import Task, Beat, Plan, PlanStatus # noqa: F401
from .util import get_plan, save_plan, delete_plan, complete_task, parse_beats # noqa: F401
from .expand import expand_beats # noqa: F401
from .expand import compute_chunks, compute_arc_info # noqa: F401
from .mixin import PlanMixin # noqa: F401

View File

@@ -1,26 +1,24 @@
"""
Arc expansion — expands plan beats into prose and pushes to scene history.
Arc expansion — pure functions for chunking and arc metadata computation.
The expand pipeline methods live on PlanMixin (mixin.py).
"""
import re
import structlog
import pydantic
from talemate.emit import emit
import talemate.emit.async_signals
from talemate.agents.narrator import NarratorAgentEmission
from talemate.prompts import Prompt
from talemate.scene_message import NarratorMessage, CharacterMessage
from .schema import Beat
from .util import complete_task, emit_plan_updated, get_plan
log = structlog.get_logger("talemate.agents.director.plan.expand")
# Matches any opening block tag leaked into content
# TODO: this can't hardcode the tag style or names -- the template defines it
_LEAKED_TAG_RE = re.compile(r"</?(?:NARRATOR|CHARACTER)\b", re.IGNORECASE)
def has_leaked_tags(blocks: list[dict]) -> bool:
"""Check if any extracted block contains raw block tags in its content."""
return any(_LEAKED_TAG_RE.search(b.get("content", "")) for b in blocks)
# Minimum beats per chunk when splitting at tension valleys
MIN_CHUNK_BEATS = 3
@@ -120,194 +118,3 @@ def compute_arc_info(
)
return infos
async def revise_narrator_content(narrator, content: str) -> str:
"""Send narrator generated signal so automatic revision can process the content."""
emission = NarratorAgentEmission(agent=narrator, response=content)
await talemate.emit.async_signals.get("agent.narrator.generated").send(emission)
return emission.response
async def critique_expanded_blocks(
blocks: list[dict], narrator
) -> list[dict]:
"""
Run a post-expansion critique pass on all blocks to fix cross-beat
redundancy, intensity monotony, and repeated vocabulary.
"""
log.info("expand.critique", block_count=len(blocks))
response, extracted = await Prompt.request(
"narrator.arc-expand-critique",
narrator.client,
"narrate_4096",
vars={
"blocks": blocks,
"max_tokens": narrator.client.max_token_length,
"response_length": 4096,
},
)
revised = extracted.get("response", [])
if not revised:
log.warning("expand.critique.no_blocks_returned, using originals")
return blocks
log.info("expand.critique.done", original=len(blocks), revised=len(revised))
return revised
async def push_and_emit_block(scene, block: dict, narrator) -> int:
"""
Push a single extracted block to scene history and emit to frontend.
Runs automatic revision on narrator blocks. Returns word count of the block,
or 0 if the block was empty/skipped.
"""
content = block.get("content", "").strip()
if not content:
return 0
if block["type"] == "narrator":
content = await revise_narrator_content(narrator, content)
msg = NarratorMessage(content)
await scene.push_history(msg)
emit("narrator", msg)
elif block["type"] == "character":
char_name = block.get("name", "Unknown")
msg = CharacterMessage(f"{char_name}: {content}")
await scene.push_history(msg)
character = scene.get_character(char_name)
emit("character", message=msg, character=character)
else:
return 0
return len(content.split())
async def expand_beats(
scene,
narrator,
beats: list[Beat],
plan_id: str,
perspective: str,
director_notes: str = "",
chunk_size: int = 3,
chat_id: str | None = None,
) -> tuple[int, int]:
"""
Expand beats into prose in chunks and push to scene history.
Chunks are split at tension valleys when possible (deliberate chunking),
falling back to max chunk_size. Each chunk receives arc-position metadata
to guide pacing.
Returns (total_blocks, total_words).
"""
total_words = 0
total_blocks = 0
preceding_text = ""
all_blocks: list[dict] = []
# Compute chunks and arc metadata
chunks = compute_chunks(beats, chunk_size)
arc_infos = compute_arc_info(chunks, beats)
# Build a flat index to find following beats across chunk boundaries
beat_offset = 0
for chunk_num, (chunk_beats, arc_info) in enumerate(
zip(chunks, arc_infos), start=1
):
# Following beats: first 2 beats from the next chunk
following_beats = []
next_offset = beat_offset + len(chunk_beats)
if next_offset < len(beats):
following_beats = beats[next_offset : next_offset + 2]
log.info(
"expand.chunk",
chunk=chunk_num,
beats=f"{beat_offset + 1}-{beat_offset + len(chunk_beats)}",
total=len(beats),
position=arc_info.position,
tension=f"{arc_info.tension_range[0]:.1f}-{arc_info.tension_range[1]:.1f}",
)
# Call the expansion template with retry on malformed output
max_attempts = 3
blocks = []
prompt_vars = {
"scene": scene,
"max_tokens": narrator.client.max_token_length,
"beats": chunk_beats,
"following_beats": following_beats,
"preceding_text": preceding_text[-2000:] if preceding_text else "",
"perspective": perspective,
"director_notes": director_notes,
"extra_instructions": narrator.extra_instructions,
"response_length": 4096,
"arc_info": arc_info,
}
for attempt in range(1, max_attempts + 1):
response, extracted = await Prompt.request(
"narrator.arc-expand",
narrator.client,
"narrate_4096",
vars=prompt_vars,
)
blocks = extracted.get("response", [])
if not blocks:
log.warning("expand.no_blocks", chunk=chunk_num, attempt=attempt)
continue
# Validate: check for leaked block tags in content
has_leaked_tags = any(
_LEAKED_TAG_RE.search(b.get("content", "")) for b in blocks
)
if not has_leaked_tags:
break
log.warning("expand.leaked_tags", chunk=chunk_num, attempt=attempt)
blocks = []
if not blocks:
raise RuntimeError(
f"Expansion failed for chunk {chunk_num} after {max_attempts} attempts. "
f"The model produced malformed output with leaked block tags. "
f"This may indicate the model is too weak for structured generation — "
f"consider using a more capable model."
)
all_blocks.extend(blocks)
# Accumulate preceding text for next chunk
chunk_text = "\n\n".join(b.get("content", "") for b in blocks)
preceding_text += "\n\n" + chunk_text
beat_offset += len(chunk_beats)
# Post-expansion critique pass
if len(chunks) > 1 and all_blocks:
all_blocks = await critique_expanded_blocks(all_blocks, narrator)
# Push all blocks to scene history and emit to frontend
for block in all_blocks:
words = await push_and_emit_block(scene, block, narrator)
if words:
total_blocks += 1
total_words += words
# Mark all beats as completed
for beat in beats:
complete_task(scene, beat.id, plan_id=plan_id)
# Emit plan update
plan = get_plan(scene, plan_id)
if plan:
emit_plan_updated(plan, chat_id=chat_id)
return total_blocks, total_words

View File

@@ -0,0 +1,293 @@
"""
Plan mixin — provides arc generation configuration and expand pipeline methods
to the director agent.
"""
import structlog
from talemate.agents.base import AgentAction, AgentActionConfig
from talemate.emit import emit
import talemate.emit.async_signals
from talemate.agents.narrator import NarratorAgentEmission
from talemate.prompts import Prompt
from talemate.scene_message import NarratorMessage, CharacterMessage
from .expand import (
compute_chunks,
compute_arc_info,
has_leaked_tags,
)
from .schema import Beat
from .util import complete_task, emit_plan_updated, get_plan
log = structlog.get_logger("talemate.agents.director.plan.mixin")
class PlanMixin:
"""
Agent mixin for arc generation planning and expansion.
Provides:
- Configuration for expand chunk size, dialogue ratio, critique toggles
- The expand pipeline: chunked expansion with arc metadata
- Post-expansion critique pass
"""
@classmethod
def add_plan_actions(cls, actions: dict[str, AgentAction]):
actions["plan"] = AgentAction(
enabled=True,
container=True,
can_be_disabled=False,
label="Arc Generation",
icon="mdi-movie-open",
description="Settings for the arc generation pipeline.",
config={
"dialogue_ratio": AgentActionConfig(
type="number",
label="Dialogue beat ratio",
description="Target fraction of beats that should be dialogue during arc generation. 0.4 = 40% dialogue beats.",
value=0.4,
step=0.1,
min=0.0,
max=1.0,
),
"expand_chunk_size": AgentActionConfig(
type="number",
label="Expand chunk size",
description="Maximum number of beats per expansion chunk in expand mode. Larger chunks produce more cohesive prose but use more context.",
value=5,
step=1,
min=3,
max=12,
),
"outline_critique": AgentActionConfig(
type="bool",
label="Outline critique",
description="Run a critique pass on the generated outline to improve beat quality before expansion.",
value=True,
),
"expand_critique": AgentActionConfig(
type="bool",
label="Expansion critique",
description="Run a post-expansion critique pass to fix cross-beat redundancy and intensity monotony. Adds ~10s per generation.",
value=True,
),
},
)
# === Config property helpers ===
@property
def plan_dialogue_ratio(self) -> float:
return float(self.actions["plan"].config["dialogue_ratio"].value)
@property
def plan_expand_chunk_size(self) -> int:
return int(self.actions["plan"].config["expand_chunk_size"].value)
@property
def plan_outline_critique(self) -> bool:
return bool(self.actions["plan"].config["outline_critique"].value)
@property
def plan_expand_critique(self) -> bool:
return bool(self.actions["plan"].config["expand_critique"].value)
# === Expand pipeline methods ===
async def _plan_revise_narrator_content(self, narrator, content: str) -> str:
"""Send narrator generated signal so automatic revision can process the content."""
emission = NarratorAgentEmission(agent=narrator, response=content)
await talemate.emit.async_signals.get("agent.narrator.generated").send(
emission
)
return emission.response
async def _plan_push_and_emit_block(self, scene, block: dict, narrator) -> int:
"""
Push a single extracted block to scene history and emit to frontend.
Runs automatic revision on narrator blocks. Returns word count of the block,
or 0 if the block was empty/skipped.
"""
content = block.get("content", "").strip()
if not content:
return 0
if block["type"] == "narrator":
content = await self._plan_revise_narrator_content(narrator, content)
msg = NarratorMessage(content)
await scene.push_history(msg)
emit("narrator", msg)
elif block["type"] == "character":
char_name = block.get("name", "Unknown")
msg = CharacterMessage(f"{char_name}: {content}")
await scene.push_history(msg)
character = scene.get_character(char_name)
emit("character", message=msg, character=character)
else:
return 0
return len(content.split())
async def _plan_critique_expanded_blocks(
self, blocks: list[dict], narrator
) -> list[dict]:
"""
Run a post-expansion critique pass on all blocks to fix cross-beat
redundancy, intensity monotony, and repeated vocabulary.
"""
log.info("expand.critique", block_count=len(blocks))
response, extracted = await Prompt.request(
"narrator.arc-expand-critique",
narrator.client,
"narrate_4096",
vars={
"blocks": blocks,
"max_tokens": narrator.client.max_token_length,
"response_length": 4096,
},
)
revised = extracted.get("response", [])
if not revised:
log.warning("expand.critique.no_blocks_returned", fallback="using originals")
return blocks
log.info("expand.critique.done", original=len(blocks), revised=len(revised))
return revised
async def plan_expand_beats(
self,
scene,
narrator,
beats: list[Beat],
plan_id: str,
perspective: str,
director_notes: str = "",
chunk_size: int | None = None,
chat_id: str | None = None,
critique: bool | None = None,
) -> tuple[int, int]:
"""
Expand beats into prose in chunks and push to scene history.
Chunks are split at tension valleys when possible (deliberate chunking),
falling back to max chunk_size. Each chunk receives arc-position metadata
to guide pacing.
Returns (total_blocks, total_words).
"""
if chunk_size is None:
chunk_size = self.plan_expand_chunk_size
total_words = 0
total_blocks = 0
preceding_text = ""
all_blocks: list[dict] = []
# Compute chunks and arc metadata
chunks = compute_chunks(beats, chunk_size)
arc_infos = compute_arc_info(chunks, beats)
# Build a flat index to find following beats across chunk boundaries
beat_offset = 0
for chunk_num, (chunk_beats, arc_info) in enumerate(
zip(chunks, arc_infos), start=1
):
# Following beats: first 2 beats from the next chunk
following_beats = []
next_offset = beat_offset + len(chunk_beats)
if next_offset < len(beats):
following_beats = beats[next_offset : next_offset + 2]
log.info(
"expand.chunk",
chunk=chunk_num,
beats=f"{beat_offset + 1}-{beat_offset + len(chunk_beats)}",
total=len(beats),
position=arc_info.position,
tension=f"{arc_info.tension_range[0]:.1f}-{arc_info.tension_range[1]:.1f}",
)
# Call the expansion template with retry on malformed output
max_attempts = 3
blocks = []
prompt_vars = {
"scene": scene,
"max_tokens": narrator.client.max_token_length,
"beats": chunk_beats,
"following_beats": following_beats,
"preceding_text": preceding_text[-2000:] if preceding_text else "",
"perspective": perspective,
"director_notes": director_notes,
"extra_instructions": narrator.extra_instructions,
"response_length": 4096,
"arc_info": arc_info,
}
for attempt in range(1, max_attempts + 1):
response, extracted = await Prompt.request(
"narrator.arc-expand",
narrator.client,
"narrate_4096",
vars=prompt_vars,
)
blocks = extracted.get("response", [])
if not blocks:
log.warning(
"expand.no_blocks", chunk=chunk_num, attempt=attempt
)
continue
# Validate: check for leaked block tags in content
if not has_leaked_tags(blocks):
break
log.warning(
"expand.leaked_tags", chunk=chunk_num, attempt=attempt
)
blocks = []
if not blocks:
raise RuntimeError(
f"Expansion failed for chunk {chunk_num} after {max_attempts} attempts. "
f"The model produced malformed output with leaked block tags. "
f"This may indicate the model is too weak for structured generation — "
f"consider using a more capable model."
)
all_blocks.extend(blocks)
# Accumulate preceding text for next chunk
chunk_text = "\n\n".join(b.get("content", "") for b in blocks)
preceding_text += "\n\n" + chunk_text
beat_offset += len(chunk_beats)
# Post-expansion critique pass
do_critique = critique if critique is not None else self.plan_expand_critique
if do_critique and len(chunks) > 1 and all_blocks:
all_blocks = await self._plan_critique_expanded_blocks(all_blocks, narrator)
# Push all blocks to scene history and emit to frontend
for block in all_blocks:
words = await self._plan_push_and_emit_block(scene, block, narrator)
if words:
total_blocks += 1
total_words += words
# Mark all beats as completed
for beat in beats:
complete_task(scene, beat.id, plan_id=plan_id)
# Emit plan update
plan = get_plan(scene, plan_id)
if plan:
emit_plan_updated(plan, chat_id=chat_id)
return total_blocks, total_words

View File

@@ -33,7 +33,6 @@ from .schema import (
NARRATION_BEAT_RATIO,
)
from .util import save_plan, complete_task, emit_plan_updated, get_plan
from .expand import expand_beats
log = structlog.get_logger("talemate.agents.director.plan.nodes")
@@ -327,8 +326,8 @@ class ExpandStoryArc(AgentNode):
"""
Expands a plan's beats into full prose and pushes them to scene history.
Delegates to expand_beats() which handles chunking, template calls,
revision, emission, and task completion.
Delegates to the director's PlanMixin.expand_beats() which handles
chunking, template calls, revision, emission, and task completion.
"""
_agent_name: ClassVar[str] = "narrator"
@@ -353,6 +352,7 @@ class ExpandStoryArc(AgentNode):
self.add_input("perspective", socket_type="str")
self.add_input("director_notes", socket_type="str", optional=True)
self.add_input("chunk_size", socket_type="int", optional=True)
self.add_input("expand_critique", socket_type="bool", optional=True)
self.set_property("chunk_size", 5)
@@ -369,9 +369,10 @@ class ExpandStoryArc(AgentNode):
chunk_size = self.normalized_input_value("chunk_size") or int(
self.get_property("chunk_size")
)
expand_critique = self.normalized_input_value("expand_critique")
scene = active_scene.get()
narrator = get_agent("narrator")
director = get_agent("director")
if not beats:
self.set_output_values(
@@ -386,15 +387,16 @@ class ExpandStoryArc(AgentNode):
chat_ctx = director_chat_context.get()
chat_id = chat_ctx.chat_id if chat_ctx else None
total_blocks, total_words = await expand_beats(
total_blocks, total_words = await director.plan_expand_beats(
scene=scene,
narrator=narrator,
narrator=get_agent("narrator"),
beats=beats,
plan_id=plan_id,
perspective=perspective,
director_notes=director_notes,
chunk_size=chunk_size,
chat_id=chat_id,
critique=expand_critique,
)
result = f"Generated {total_blocks} blocks, {total_words} words from {len(beats)} beats"

View File

@@ -41,7 +41,7 @@ Guidelines:
- The `tension` value must reflect genuine escalation. Early beats must have LOW tension (0.1-0.3). Save high tension (0.7+) for the latter portion. Do not start at high intensity.
**Beat construction:**
{% set dialogue_pct = (agent_config("director.chat.generate_arc_dialogue_ratio") * 100) | int -%}
{% set dialogue_pct = (agent_config("director.plan.dialogue_ratio") * 100) | int -%}
- Approximately {{ dialogue_pct }}% of beats should be `dialogue` type — characters need to speak, not just act and observe
- Vary beat types for engaging pacing (don't stack 5 dialogue beats in a row)
- Use `transition` beats for time/location changes

View File

@@ -52,7 +52,7 @@ Every beat description must specify a concrete EVENT or ACTION — something tha
**Structural completeness:** The final beat MUST involve a character making a choice, taking an action, or facing a consequence — not just witnessing a revelation or spectacle. If the last beat is purely "they see something terrifying/beautiful," rewrite it so the character DOES something in response that closes or pivots the arc. The arc needs somewhere to land, and that landing must involve character agency.
{% set dialogue_pct = (agent_config("director.chat.generate_arc_dialogue_ratio") * 100) | int -%}
{% set dialogue_pct = (agent_config("director.plan.dialogue_ratio") * 100) | int -%}
**Dialogue ratio:** Approximately {{ dialogue_pct }}% of beats should be `dialogue` type. Characters need to speak, argue, and reveal information through conversation — not just act and observe. If the outline is heavily skewed toward narration, convert some beats to dialogue.
**Perspective consistency:** All beat descriptions must be compatible with the stated perspective ({{ perspective }}). If the perspective is limited to one character, beats must not describe another character's internal thoughts.

View File

@@ -130,6 +130,28 @@
<div class="text-muted" v-else>Expand mode: beats are expanded into prose in chunks. Much faster but with less per-turn context injection.</div>
</v-alert>
<div class="d-flex ga-4 mb-4">
<v-checkbox
v-model="scenePlanOutlineCritique"
label="Outline critique"
hint="Critique and refine the outline before expansion"
persistent-hint
density="compact"
hide-details="auto"
color="primary"
></v-checkbox>
<v-checkbox
v-model="scenePlanExpandCritique"
label="Expansion critique"
hint="Post-expansion pass to fix redundancy"
persistent-hint
density="compact"
hide-details="auto"
color="primary"
v-if="scenePlanMode === 'generate_arc_expand'"
></v-checkbox>
</div>
<v-alert variant="outlined" density="compact" color="primary" class="mb-3">
<div class="text-muted">This may generate actions and dialogue for player controlled characters as well.</div>
</v-alert>
@@ -166,6 +188,8 @@ export default {
scenePlanBeats: 8,
scenePlanDialogueRatio: 40,
scenePlanMode: 'generate_arc',
scenePlanOutlineCritique: true,
scenePlanExpandCritique: true,
minRecommendedNarratorLength: 1024,
}
},
@@ -250,7 +274,9 @@ export default {
this.scenePlanInstructions = '';
this.scenePlanBeats = 8;
this.scenePlanMode = 'generate_arc';
this.scenePlanDialogueRatio = Math.round((this.agentStatus?.director?.actions?.chat?.config?.generate_arc_dialogue_ratio?.value ?? 0.4) * 100);
this.scenePlanDialogueRatio = Math.round((this.agentStatus?.director?.actions?.plan?.config?.dialogue_ratio?.value ?? 0.4) * 100);
this.scenePlanOutlineCritique = this.agentStatus?.director?.actions?.plan?.config?.outline_critique?.value ?? true;
this.scenePlanExpandCritique = this.agentStatus?.director?.actions?.plan?.config?.expand_critique?.value ?? true;
this.scenePlanDialog = true;
},
@@ -263,6 +289,8 @@ export default {
beat_count: this.scenePlanBeats,
dialogue_ratio: this.scenePlanDialogueRatio / 100,
mode: this.scenePlanMode,
outline_critique: this.scenePlanOutlineCritique,
expand_critique: this.scenePlanExpandCritique,
}));
this.openDirectorConsole();
},

View File

@@ -23,7 +23,10 @@ The character MUST feel relatable to the audience.
You must use simple language to describe the character's dialogue instructions.
Keep the format similar and stick to one paragraph.
**Structure your instructions in two short paragraphs:**
1. **Default register** — How Elena normally speaks: vocabulary, formality, sentence structure, verbal habits, how they address others.
2. **Under pressure** — How their speech changes when stressed, scared, angry, or pushed past their limit. Do NOT write that they simply become "more of the same" (e.g., "gets even more clinical" or "becomes even quieter"). Their composure must crack in a specific, visible way — shorter sentences, different word choices, breaking their own verbal patterns, physical urgency replacing their normal mode. This variation is critical for believable dialogue in dramatic scenes.
The length of your response must fit within 4 paragraphs.

View File

@@ -0,0 +1,104 @@
## Context
## Classification
Content Classification: Fantasy adventure story
Narrative Perspective: <Mock name='mock.perspective' id='NORMALIZED'>
Scenario Premise:
A peaceful clearing in the heart of an ancient forest.
## Intention of this story
The overarching intention of this story. Use it to guide your decisions in the scene.
A test story
## Scene
Elena: Hello there, traveler.
The sun filters through the leaves above.
Marcus: What brings you to these woods?
## Characters
### Hero
name: Hero
A test character.
#### General character guide for Hero
Speaks normally.
### Elena
name: Elena
A test character.
#### General character guide for Elena
Speaks normally.
## Director notes
Focus on building tension.
## Writing style
## Task
Write scene prose from the beat descriptions below.
**Arc position:** OPENING (chunk 1 of 2, tension 0.30.7)
You are writing the OPENING of the story. Establish the world and characters, build tension gradually. Do not peak yet — save the most intense moments for later.
**Beats to write:**
Beat 1 [narration] (pacing: slow, tension: 0.3)
Characters: Elena
The protagonist discovers the door is locked from the inside.
Beat 2 [dialogue] (pacing: moderate, tension: 0.5)
Characters: Elena
Elena confronts Hero about what happened last night, demanding answers.
Beat 3 [action] (pacing: fast, tension: 0.7)
Characters: Hero, Elena
A sudden noise from the basement forces both characters to investigate together.
**OUTPUT FORMAT (mandatory):**
Your response MUST be a sequence of `<NARRATOR>` and `<CHARACTER name="Name">` blocks. No prose outside of blocks.
- Narration/action/transition/reveal beats → `<NARRATOR>` blocks (1-3 paragraphs of prose)
- Dialogue beats → `<CHARACTER name="CharacterName">` blocks (action + body language + dialogue as one unit)
Structure:
```
<NARRATOR>
... narration prose here (1-3 paragraphs) ...
</NARRATOR>
<CHARACTER name="Kaira">
... physical action, body language, and dialogue together as one unit ...
</CHARACTER>
<NARRATOR>
... more narration prose ...
</NARRATOR>
<CHARACTER name="Elmer">
... his actions and dialogue as one unit ...
</CHARACTER>
```
**Writing rules:**
- `<CHARACTER>` blocks must be complete character moments: physical action, body language, AND dialogue together. Not just a quoted line.
- ONE strong image per paragraph. Develop it — let the reader sit with it before introducing the next. Do not stack multiple phenomena in rapid succession.
- Do NOT repeat sensory details or character descriptions already established in the "Story so far" section.
- Vary sentence length. Mix short and long.
- Show emotion through action, not narration. "His hands shook" not "he felt terror."
- Low-tension beats must use short sentences, plain language, breathing room.
- Do NOT start CHARACTER block content with the character's name — the tag already identifies them.
- **Dialogue variety:** Each character's dialogue must serve a DIFFERENT function across beats. Vary between: questioning, arguing, joking, commanding, confessing, deflecting, comforting, provoking. A character must NOT deliver the same type of speech twice. If a character made a philosophical declaration in one beat, their next line must be a question, a joke, or a pragmatic instruction — anything except another declaration.
The length of your response must fit within 4 paragraphs.
<|BOT|><NARRATOR>

View File

@@ -0,0 +1,104 @@
## Context
## Classification
Content Classification: Fantasy adventure story
Narrative Perspective: <Mock name='mock.perspective' id='NORMALIZED'>
Scenario Premise:
A peaceful clearing in the heart of an ancient forest.
## Intention of this story
The overarching intention of this story. Use it to guide your decisions in the scene.
A test story
## Scene
Elena: Hello there, traveler.
The sun filters through the leaves above.
Marcus: What brings you to these woods?
## Characters
### Hero
name: Hero
A test character.
#### General character guide for Hero
Speaks normally.
### Elena
name: Elena
A test character.
#### General character guide for Elena
Speaks normally.
## Writing style
## Task
Write scene prose from the beat descriptions below.
**Arc position:** CLIMAX (chunk 2 of 2, tension 0.50.7)
You are writing the CLIMAX — the dramatic high point. Commit fully to the peak intensity. This is where the biggest moments happen.
**Story so far** (continue from here):
```
The door creaked open, revealing an empty room. Elena stepped inside cautiously.
```
CRITICAL: Do NOT repeat, rephrase, or re-describe ANY event, action, dialogue, or image from the story so far. If a beat description asks for something that already happened above, find a NEW angle or skip to the next development. The reader has already seen it.
**Beats to write:**
Beat 2 [dialogue] (pacing: moderate, tension: 0.5)
Characters: Elena
Elena confronts Hero about what happened last night, demanding answers.
Beat 3 [action] (pacing: fast, tension: 0.7)
Characters: Hero, Elena
A sudden noise from the basement forces both characters to investigate together.
**OUTPUT FORMAT (mandatory):**
Your response MUST be a sequence of `<NARRATOR>` and `<CHARACTER name="Name">` blocks. No prose outside of blocks.
- Narration/action/transition/reveal beats → `<NARRATOR>` blocks (1-3 paragraphs of prose)
- Dialogue beats → `<CHARACTER name="CharacterName">` blocks (action + body language + dialogue as one unit)
Structure:
```
<NARRATOR>
... narration prose here (1-3 paragraphs) ...
</NARRATOR>
<CHARACTER name="Kaira">
... physical action, body language, and dialogue together as one unit ...
</CHARACTER>
<NARRATOR>
... more narration prose ...
</NARRATOR>
<CHARACTER name="Elmer">
... his actions and dialogue as one unit ...
</CHARACTER>
```
**Writing rules:**
- `<CHARACTER>` blocks must be complete character moments: physical action, body language, AND dialogue together. Not just a quoted line.
- ONE strong image per paragraph. Develop it — let the reader sit with it before introducing the next. Do not stack multiple phenomena in rapid succession.
- Do NOT repeat sensory details or character descriptions already established in the "Story so far" section.
- Vary sentence length. Mix short and long.
- Show emotion through action, not narration. "His hands shook" not "he felt terror."
- Low-tension beats must use short sentences, plain language, breathing room.
- Do NOT start CHARACTER block content with the character's name — the tag already identifies them.
- **Dialogue variety:** Each character's dialogue must serve a DIFFERENT function across beats. Vary between: questioning, arguing, joking, commanding, confessing, deflecting, comforting, provoking. A character must NOT deliver the same type of speech twice. If a character made a philosophical declaration in one beat, their next line must be a question, a joke, or a pragmatic instruction — anything except another declaration.
The length of your response must fit within 4 paragraphs.
<|BOT|><NARRATOR>

View File

@@ -0,0 +1,33 @@
## Task
You are an editor reviewing the following scene prose. Your job is to revise it to fix these specific problems:
1. **Repeated imagery/vocabulary:** If the same image, metaphor, or sensory detail appears in multiple blocks (e.g., "pulsing" used 5 times, "copper taste" in 3 blocks, the ship "dissolving" described identically twice), rewrite the later occurrences to use different language or cut them entirely.
2. **Intensity monotony:** If every block is at maximum intensity with no breathing room, revise 1-2 blocks to be quieter — shorter sentences, less sensory detail, a moment of stillness or thought.
3. **Repeated dialogue structure:** If a character delivers the same type of speech multiple times (e.g., two exposition monologues with the same structure), rewrite one to be shorter, more fragmented, or serve a different function.
**Rules:**
- Keep all plot events intact — do NOT change what happens, only how it's written.
- Keep the same number of blocks in the same order.
- Keep block types (NARRATOR/CHARACTER) and character names unchanged.
- Your revisions must be subtle — polish, not rewrite. Cut redundant sentences, vary repeated words, adjust intensity. Do not add new plot elements.
**Original prose:**
<NARRATOR>
The room was dark and cold. A chill ran down her spine.
</NARRATOR>
<CHARACTER name="Elena">
She stepped forward, her hands trembling. "Who's there?" she whispered.
</CHARACTER>
<NARRATOR>
A chill ran through the room. The darkness pressed in from all sides.
</NARRATOR>
Output the revised version using the same `<NARRATOR>` and `<CHARACTER name="Name">` block format. Every block from the original must appear in your output.
The length of your response must fit within 4 paragraphs.
<|BOT|><NARRATOR>

View File

@@ -1,27 +1,5 @@
## Examples
<REVIEW>
The incandescent luminescence of twilight's dying breath painted the heavens in hues of amethyst and vermillion, while shadows danced their ancient waltz across the emerald tapestry of the lawn. Rebecca's heart thundered like a thousand war drums as she glimpsed his silhouette, a dark prince emerging from the veil of dusk's embrace.
</REVIEW>
<ANALYSIS>
- Thought process: This is trying way too hard to describe a simple sunset scene. Every single phrase is overwritten with metaphors and fancy vocabulary when it could just say "sunset" and "she saw him."
- Purple prose: MAJOR ISSUES - "incandescent luminescence," "twilight's dying breath," "ancient waltz," "thousand war drums," "dark prince"
- Unnatural dialogue: N/A - no dialogue
- Over-description: YES - simple sunset/shadow scene made overly complex
- Length: TOO LONG - could be said in 1-2 sentences
- Tense: PAST - "Rebecca's heart thundered like a thousand war drums"
- Mature content: N/A
- Name overuse: N/A - no dialogue
- Talking vs Showing: N/A - no dialogue
</ANALYSIS>
<FIX>
The sunset cast purple and red across the sky as shadows stretched across the lawn. Rebecca's heart raced when she saw him walking up the driveway.
</FIX>
---
<REVIEW>
"Hello, Mother. I have returned from my educational institution," said Tim.
"Excellent. Did you successfully complete your mathematical assignments?" she responded.
@@ -50,28 +28,6 @@ The sunset cast purple and red across the sky as shadows stretched across the la
---
<REVIEW>
She reaches for the door handle. Her index finger extends first, followed by her middle finger, then her ring finger, and finally her pinky. Her thumb moves to oppose them. The metal is cool to the touch, approximately 68 degrees Fahrenheit. She grips it with 3.2 pounds of pressure and rotates her wrist 47 degrees clockwise.
</REVIEW>
<ANALYSIS>
- Thought process: While the exact measurements and finger-by-finger description is excessive, we should keep that the handle was cold - it's a sensory detail that adds atmosphere without being purple prose.
- Purple prose: NO
- Unnatural dialogue: N/A - no dialogue
- Over-description: EXTREME - every finger movement, exact temperature, precise measurements
- Length: WAY TOO LONG - should be 3-4 words
- Tense: PRESENT - "reaches for the door handle"
- Mature content: N/A
- Name overuse: N/A
- Talking vs Showing: N/A - no dialogue
</ANALYSIS>
<FIX>
She gripped the cold metal handle and turned it, opening the door.
</FIX>
---
<REVIEW>
Beneath the opalescent moon's ethereal glow, Jake's sapphire eyes met hers.
"Your presence illuminates my existence more brilliantly than a thousand supernovas," he declared.
@@ -210,6 +166,31 @@ She bit her lip, looking away. "It's just... I mean... um... something feels wro
---
<REVIEW>
"As you know, the dual-phase cooling system requires constant monitoring to prevent a cascade failure in the secondary loop," Dr. Chen explained to her co-engineer, who had been maintaining the reactor for twelve years. "The isothermal regulators must be calibrated every six hours, and the backup condensers need to cycle through their purge sequence before any power increase can be authorized."
"Indeed, I am well aware of these procedures," he responded. "Perhaps we should also discuss the implications of the recent firmware update on the thermal management subsystem."
</REVIEW>
<ANALYSIS>
- Thought process: This is exposition dumped into dialogue. These are two engineers who both know this information — they wouldn't explain it to each other. The dialogue exists purely to inform the reader, not because the characters would actually say it. It needs to be replaced with what they'd actually discuss: the immediate problem, not the textbook explanation.
- Purple prose: NO
- Unnatural dialogue: SEVERE - characters explaining things they both already know
- Over-description: YES - unnecessary technical detail
- Length: TOO LONG
- Tense: PAST - "explained"
- Mature content: N/A
- Name overuse: NO
- Talking vs Showing: SEVERE - lecturing the reader through dialogue
</ANALYSIS>
<FIX>
"The cooling readings look wrong again." Dr. Chen frowned at her display, tapping one of the fluctuating values.
He leaned over her shoulder. "That started after the firmware update. Want me to roll it back?"
"Not yet. Let me check the secondary loop first."
</FIX>
---
<REVIEW>
"Hi David, how are you today?" asked Emily.
"I'm doing well, Emily. Have you seen Michael?"

View File

@@ -23,7 +23,10 @@ The character MUST feel relatable to the audience.
You must use simple language to describe the character's dialogue instructions.
Keep the format similar and stick to one paragraph.
**Structure your instructions in two short paragraphs:**
1. **Default register** — How Elena normally speaks: vocabulary, formality, sentence structure, verbal habits, how they address others.
2. **Under pressure** — How their speech changes when stressed, scared, angry, or pushed past their limit. Do NOT write that they simply become "more of the same" (e.g., "gets even more clinical" or "becomes even quieter"). Their composure must crack in a specific, visible way — shorter sentences, different word choices, breaking their own verbal patterns, physical urgency replacing their normal mode. This variation is critical for believable dialogue in dramatic scenes.
The length of your response must fit within 4 paragraphs.

View File

@@ -1,27 +1,5 @@
## Examples
<REVIEW>
The incandescent luminescence of twilight's dying breath painted the heavens in hues of amethyst and vermillion, while shadows danced their ancient waltz across the emerald tapestry of the lawn. Rebecca's heart thundered like a thousand war drums as she glimpsed his silhouette, a dark prince emerging from the veil of dusk's embrace.
</REVIEW>
<ANALYSIS>
- Thought process: This is trying way too hard to describe a simple sunset scene. Every single phrase is overwritten with metaphors and fancy vocabulary when it could just say "sunset" and "she saw him."
- Purple prose: MAJOR ISSUES - "incandescent luminescence," "twilight's dying breath," "ancient waltz," "thousand war drums," "dark prince"
- Unnatural dialogue: N/A - no dialogue
- Over-description: YES - simple sunset/shadow scene made overly complex
- Length: TOO LONG - could be said in 1-2 sentences
- Tense: PAST - "Rebecca's heart thundered like a thousand war drums"
- Mature content: N/A
- Name overuse: N/A - no dialogue
- Talking vs Showing: N/A - no dialogue
</ANALYSIS>
<FIX>
The sunset cast purple and red across the sky as shadows stretched across the lawn. Rebecca's heart raced when she saw him walking up the driveway.
</FIX>
---
<REVIEW>
"Hello, Mother. I have returned from my educational institution," said Tim.
"Excellent. Did you successfully complete your mathematical assignments?" she responded.
@@ -50,28 +28,6 @@ The sunset cast purple and red across the sky as shadows stretched across the la
---
<REVIEW>
She reaches for the door handle. Her index finger extends first, followed by her middle finger, then her ring finger, and finally her pinky. Her thumb moves to oppose them. The metal is cool to the touch, approximately 68 degrees Fahrenheit. She grips it with 3.2 pounds of pressure and rotates her wrist 47 degrees clockwise.
</REVIEW>
<ANALYSIS>
- Thought process: While the exact measurements and finger-by-finger description is excessive, we should keep that the handle was cold - it's a sensory detail that adds atmosphere without being purple prose.
- Purple prose: NO
- Unnatural dialogue: N/A - no dialogue
- Over-description: EXTREME - every finger movement, exact temperature, precise measurements
- Length: WAY TOO LONG - should be 3-4 words
- Tense: PRESENT - "reaches for the door handle"
- Mature content: N/A
- Name overuse: N/A
- Talking vs Showing: N/A - no dialogue
</ANALYSIS>
<FIX>
She gripped the cold metal handle and turned it, opening the door.
</FIX>
---
<REVIEW>
Beneath the opalescent moon's ethereal glow, Jake's sapphire eyes met hers.
"Your presence illuminates my existence more brilliantly than a thousand supernovas," he declared.
@@ -210,6 +166,31 @@ She bit her lip, looking away. "It's just... I mean... um... something feels wro
---
<REVIEW>
"As you know, the dual-phase cooling system requires constant monitoring to prevent a cascade failure in the secondary loop," Dr. Chen explained to her co-engineer, who had been maintaining the reactor for twelve years. "The isothermal regulators must be calibrated every six hours, and the backup condensers need to cycle through their purge sequence before any power increase can be authorized."
"Indeed, I am well aware of these procedures," he responded. "Perhaps we should also discuss the implications of the recent firmware update on the thermal management subsystem."
</REVIEW>
<ANALYSIS>
- Thought process: This is exposition dumped into dialogue. These are two engineers who both know this information — they wouldn't explain it to each other. The dialogue exists purely to inform the reader, not because the characters would actually say it. It needs to be replaced with what they'd actually discuss: the immediate problem, not the textbook explanation.
- Purple prose: NO
- Unnatural dialogue: SEVERE - characters explaining things they both already know
- Over-description: YES - unnecessary technical detail
- Length: TOO LONG
- Tense: PAST - "explained"
- Mature content: N/A
- Name overuse: NO
- Talking vs Showing: SEVERE - lecturing the reader through dialogue
</ANALYSIS>
<FIX>
"The cooling readings look wrong again." Dr. Chen frowned at her display, tapping one of the fluctuating values.
He leaned over her shoulder. "That started after the firmware update. Want me to roll it back?"
"Not yet. Let me check the secondary loop first."
</FIX>
---
<REVIEW>
"Hi David, how are you today?" asked Emily.
"I'm doing well, Emily. Have you seen Michael?"

View File

@@ -23,6 +23,9 @@ The character MUST feel relatable to the audience.
You must use simple language to describe the character's dialogue instructions.
Keep the format similar and stick to one paragraph.
**Structure your instructions in two short paragraphs:**
1. **Default register** — How Elena normally speaks: vocabulary, formality, sentence structure, verbal habits, how they address others.
2. **Under pressure** — How their speech changes when stressed, scared, angry, or pushed past their limit. Do NOT write that they simply become "more of the same" (e.g., "gets even more clinical" or "becomes even quieter"). Their composure must crack in a specific, visible way — shorter sentences, different word choices, breaking their own verbal patterns, physical urgency replacing their normal mode. This variation is critical for believable dialogue in dramatic scenes.
<|BOT|>Dialogue instructions:

View File

@@ -1,27 +1,5 @@
## Examples
<REVIEW>
The incandescent luminescence of twilight's dying breath painted the heavens in hues of amethyst and vermillion, while shadows danced their ancient waltz across the emerald tapestry of the lawn. Rebecca's heart thundered like a thousand war drums as she glimpsed his silhouette, a dark prince emerging from the veil of dusk's embrace.
</REVIEW>
<ANALYSIS>
- Thought process: This is trying way too hard to describe a simple sunset scene. Every single phrase is overwritten with metaphors and fancy vocabulary when it could just say "sunset" and "she saw him."
- Purple prose: MAJOR ISSUES - "incandescent luminescence," "twilight's dying breath," "ancient waltz," "thousand war drums," "dark prince"
- Unnatural dialogue: N/A - no dialogue
- Over-description: YES - simple sunset/shadow scene made overly complex
- Length: TOO LONG - could be said in 1-2 sentences
- Tense: PAST - "Rebecca's heart thundered like a thousand war drums"
- Mature content: N/A
- Name overuse: N/A - no dialogue
- Talking vs Showing: N/A - no dialogue
</ANALYSIS>
<FIX>
The sunset cast purple and red across the sky as shadows stretched across the lawn. Rebecca's heart raced when she saw him walking up the driveway.
</FIX>
---
<REVIEW>
"Hello, Mother. I have returned from my educational institution," said Tim.
"Excellent. Did you successfully complete your mathematical assignments?" she responded.
@@ -50,28 +28,6 @@ The sunset cast purple and red across the sky as shadows stretched across the la
---
<REVIEW>
She reaches for the door handle. Her index finger extends first, followed by her middle finger, then her ring finger, and finally her pinky. Her thumb moves to oppose them. The metal is cool to the touch, approximately 68 degrees Fahrenheit. She grips it with 3.2 pounds of pressure and rotates her wrist 47 degrees clockwise.
</REVIEW>
<ANALYSIS>
- Thought process: While the exact measurements and finger-by-finger description is excessive, we should keep that the handle was cold - it's a sensory detail that adds atmosphere without being purple prose.
- Purple prose: NO
- Unnatural dialogue: N/A - no dialogue
- Over-description: EXTREME - every finger movement, exact temperature, precise measurements
- Length: WAY TOO LONG - should be 3-4 words
- Tense: PRESENT - "reaches for the door handle"
- Mature content: N/A
- Name overuse: N/A
- Talking vs Showing: N/A - no dialogue
</ANALYSIS>
<FIX>
She gripped the cold metal handle and turned it, opening the door.
</FIX>
---
<REVIEW>
Beneath the opalescent moon's ethereal glow, Jake's sapphire eyes met hers.
"Your presence illuminates my existence more brilliantly than a thousand supernovas," he declared.
@@ -210,6 +166,31 @@ She bit her lip, looking away. "It's just... I mean... um... something feels wro
---
<REVIEW>
"As you know, the dual-phase cooling system requires constant monitoring to prevent a cascade failure in the secondary loop," Dr. Chen explained to her co-engineer, who had been maintaining the reactor for twelve years. "The isothermal regulators must be calibrated every six hours, and the backup condensers need to cycle through their purge sequence before any power increase can be authorized."
"Indeed, I am well aware of these procedures," he responded. "Perhaps we should also discuss the implications of the recent firmware update on the thermal management subsystem."
</REVIEW>
<ANALYSIS>
- Thought process: This is exposition dumped into dialogue. These are two engineers who both know this information — they wouldn't explain it to each other. The dialogue exists purely to inform the reader, not because the characters would actually say it. It needs to be replaced with what they'd actually discuss: the immediate problem, not the textbook explanation.
- Purple prose: NO
- Unnatural dialogue: SEVERE - characters explaining things they both already know
- Over-description: YES - unnecessary technical detail
- Length: TOO LONG
- Tense: PAST - "explained"
- Mature content: N/A
- Name overuse: NO
- Talking vs Showing: SEVERE - lecturing the reader through dialogue
</ANALYSIS>
<FIX>
"The cooling readings look wrong again." Dr. Chen frowned at her display, tapping one of the fluctuating values.
He leaned over her shoulder. "That started after the firmware update. Want me to roll it back?"
"Not yet. Let me check the secondary loop first."
</FIX>
---
<REVIEW>
"Hi David, how are you today?" asked Emily.
"I'm doing well, Emily. Have you seen Michael?"

View File

@@ -23,7 +23,10 @@ The character MUST feel relatable to the audience.
You must use simple language to describe the character's dialogue instructions.
Keep the format similar and stick to one paragraph.
**Structure your instructions in two short paragraphs:**
1. **Default register** — How Elena normally speaks: vocabulary, formality, sentence structure, verbal habits, how they address others.
2. **Under pressure** — How their speech changes when stressed, scared, angry, or pushed past their limit. Do NOT write that they simply become "more of the same" (e.g., "gets even more clinical" or "becomes even quieter"). Their composure must crack in a specific, visible way — shorter sentences, different word choices, breaking their own verbal patterns, physical urgency replacing their normal mode. This variation is critical for believable dialogue in dramatic scenes.
The length of your response must fit within 4 paragraphs.

View File

@@ -1,27 +1,5 @@
## Examples
<REVIEW>
The incandescent luminescence of twilight's dying breath painted the heavens in hues of amethyst and vermillion, while shadows danced their ancient waltz across the emerald tapestry of the lawn. Rebecca's heart thundered like a thousand war drums as she glimpsed his silhouette, a dark prince emerging from the veil of dusk's embrace.
</REVIEW>
<ANALYSIS>
- Thought process: This is trying way too hard to describe a simple sunset scene. Every single phrase is overwritten with metaphors and fancy vocabulary when it could just say "sunset" and "she saw him."
- Purple prose: MAJOR ISSUES - "incandescent luminescence," "twilight's dying breath," "ancient waltz," "thousand war drums," "dark prince"
- Unnatural dialogue: N/A - no dialogue
- Over-description: YES - simple sunset/shadow scene made overly complex
- Length: TOO LONG - could be said in 1-2 sentences
- Tense: PAST - "Rebecca's heart thundered like a thousand war drums"
- Mature content: N/A
- Name overuse: N/A - no dialogue
- Talking vs Showing: N/A - no dialogue
</ANALYSIS>
<FIX>
The sunset cast purple and red across the sky as shadows stretched across the lawn. Rebecca's heart raced when she saw him walking up the driveway.
</FIX>
---
<REVIEW>
"Hello, Mother. I have returned from my educational institution," said Tim.
"Excellent. Did you successfully complete your mathematical assignments?" she responded.
@@ -50,28 +28,6 @@ The sunset cast purple and red across the sky as shadows stretched across the la
---
<REVIEW>
She reaches for the door handle. Her index finger extends first, followed by her middle finger, then her ring finger, and finally her pinky. Her thumb moves to oppose them. The metal is cool to the touch, approximately 68 degrees Fahrenheit. She grips it with 3.2 pounds of pressure and rotates her wrist 47 degrees clockwise.
</REVIEW>
<ANALYSIS>
- Thought process: While the exact measurements and finger-by-finger description is excessive, we should keep that the handle was cold - it's a sensory detail that adds atmosphere without being purple prose.
- Purple prose: NO
- Unnatural dialogue: N/A - no dialogue
- Over-description: EXTREME - every finger movement, exact temperature, precise measurements
- Length: WAY TOO LONG - should be 3-4 words
- Tense: PRESENT - "reaches for the door handle"
- Mature content: N/A
- Name overuse: N/A
- Talking vs Showing: N/A - no dialogue
</ANALYSIS>
<FIX>
She gripped the cold metal handle and turned it, opening the door.
</FIX>
---
<REVIEW>
Beneath the opalescent moon's ethereal glow, Jake's sapphire eyes met hers.
"Your presence illuminates my existence more brilliantly than a thousand supernovas," he declared.
@@ -210,6 +166,31 @@ She bit her lip, looking away. "It's just... I mean... um... something feels wro
---
<REVIEW>
"As you know, the dual-phase cooling system requires constant monitoring to prevent a cascade failure in the secondary loop," Dr. Chen explained to her co-engineer, who had been maintaining the reactor for twelve years. "The isothermal regulators must be calibrated every six hours, and the backup condensers need to cycle through their purge sequence before any power increase can be authorized."
"Indeed, I am well aware of these procedures," he responded. "Perhaps we should also discuss the implications of the recent firmware update on the thermal management subsystem."
</REVIEW>
<ANALYSIS>
- Thought process: This is exposition dumped into dialogue. These are two engineers who both know this information — they wouldn't explain it to each other. The dialogue exists purely to inform the reader, not because the characters would actually say it. It needs to be replaced with what they'd actually discuss: the immediate problem, not the textbook explanation.
- Purple prose: NO
- Unnatural dialogue: SEVERE - characters explaining things they both already know
- Over-description: YES - unnecessary technical detail
- Length: TOO LONG
- Tense: PAST - "explained"
- Mature content: N/A
- Name overuse: NO
- Talking vs Showing: SEVERE - lecturing the reader through dialogue
</ANALYSIS>
<FIX>
"The cooling readings look wrong again." Dr. Chen frowned at her display, tapping one of the fluctuating values.
He leaned over her shoulder. "That started after the firmware update. Want me to roll it back?"
"Not yet. Let me check the secondary loop first."
</FIX>
---
<REVIEW>
"Hi David, how are you today?" asked Emily.
"I'm doing well, Emily. Have you seen Michael?"

View File

@@ -23,7 +23,10 @@ The character MUST feel relatable to the audience.
You must use simple language to describe the character's dialogue instructions.
Keep the format similar and stick to one paragraph.
**Structure your instructions in two short paragraphs:**
1. **Default register** — How Elena normally speaks: vocabulary, formality, sentence structure, verbal habits, how they address others.
2. **Under pressure** — How their speech changes when stressed, scared, angry, or pushed past their limit. Do NOT write that they simply become "more of the same" (e.g., "gets even more clinical" or "becomes even quieter"). Their composure must crack in a specific, visible way — shorter sentences, different word choices, breaking their own verbal patterns, physical urgency replacing their normal mode. This variation is critical for believable dialogue in dramatic scenes.
The length of your final answer must fit within 4 paragraphs.

View File

@@ -1,27 +1,5 @@
## Examples
<REVIEW>
The incandescent luminescence of twilight's dying breath painted the heavens in hues of amethyst and vermillion, while shadows danced their ancient waltz across the emerald tapestry of the lawn. Rebecca's heart thundered like a thousand war drums as she glimpsed his silhouette, a dark prince emerging from the veil of dusk's embrace.
</REVIEW>
<ANALYSIS>
- Thought process: This is trying way too hard to describe a simple sunset scene. Every single phrase is overwritten with metaphors and fancy vocabulary when it could just say "sunset" and "she saw him."
- Purple prose: MAJOR ISSUES - "incandescent luminescence," "twilight's dying breath," "ancient waltz," "thousand war drums," "dark prince"
- Unnatural dialogue: N/A - no dialogue
- Over-description: YES - simple sunset/shadow scene made overly complex
- Length: TOO LONG - could be said in 1-2 sentences
- Tense: PAST - "Rebecca's heart thundered like a thousand war drums"
- Mature content: N/A
- Name overuse: N/A - no dialogue
- Talking vs Showing: N/A - no dialogue
</ANALYSIS>
<FIX>
The sunset cast purple and red across the sky as shadows stretched across the lawn. Rebecca's heart raced when she saw him walking up the driveway.
</FIX>
---
<REVIEW>
"Hello, Mother. I have returned from my educational institution," said Tim.
"Excellent. Did you successfully complete your mathematical assignments?" she responded.
@@ -50,28 +28,6 @@ The sunset cast purple and red across the sky as shadows stretched across the la
---
<REVIEW>
She reaches for the door handle. Her index finger extends first, followed by her middle finger, then her ring finger, and finally her pinky. Her thumb moves to oppose them. The metal is cool to the touch, approximately 68 degrees Fahrenheit. She grips it with 3.2 pounds of pressure and rotates her wrist 47 degrees clockwise.
</REVIEW>
<ANALYSIS>
- Thought process: While the exact measurements and finger-by-finger description is excessive, we should keep that the handle was cold - it's a sensory detail that adds atmosphere without being purple prose.
- Purple prose: NO
- Unnatural dialogue: N/A - no dialogue
- Over-description: EXTREME - every finger movement, exact temperature, precise measurements
- Length: WAY TOO LONG - should be 3-4 words
- Tense: PRESENT - "reaches for the door handle"
- Mature content: N/A
- Name overuse: N/A
- Talking vs Showing: N/A - no dialogue
</ANALYSIS>
<FIX>
She gripped the cold metal handle and turned it, opening the door.
</FIX>
---
<REVIEW>
Beneath the opalescent moon's ethereal glow, Jake's sapphire eyes met hers.
"Your presence illuminates my existence more brilliantly than a thousand supernovas," he declared.
@@ -210,6 +166,31 @@ She bit her lip, looking away. "It's just... I mean... um... something feels wro
---
<REVIEW>
"As you know, the dual-phase cooling system requires constant monitoring to prevent a cascade failure in the secondary loop," Dr. Chen explained to her co-engineer, who had been maintaining the reactor for twelve years. "The isothermal regulators must be calibrated every six hours, and the backup condensers need to cycle through their purge sequence before any power increase can be authorized."
"Indeed, I am well aware of these procedures," he responded. "Perhaps we should also discuss the implications of the recent firmware update on the thermal management subsystem."
</REVIEW>
<ANALYSIS>
- Thought process: This is exposition dumped into dialogue. These are two engineers who both know this information — they wouldn't explain it to each other. The dialogue exists purely to inform the reader, not because the characters would actually say it. It needs to be replaced with what they'd actually discuss: the immediate problem, not the textbook explanation.
- Purple prose: NO
- Unnatural dialogue: SEVERE - characters explaining things they both already know
- Over-description: YES - unnecessary technical detail
- Length: TOO LONG
- Tense: PAST - "explained"
- Mature content: N/A
- Name overuse: NO
- Talking vs Showing: SEVERE - lecturing the reader through dialogue
</ANALYSIS>
<FIX>
"The cooling readings look wrong again." Dr. Chen frowned at her display, tapping one of the fluctuating values.
He leaned over her shoulder. "That started after the firmware update. Want me to roll it back?"
"Not yet. Let me check the secondary loop first."
</FIX>
---
<REVIEW>
"Hi David, how are you today?" asked Emily.
"I'm doing well, Emily. Have you seen Michael?"

View File

@@ -23,7 +23,10 @@ The character MUST feel relatable to the audience.
You must use simple language to describe the character's dialogue instructions.
Keep the format similar and stick to one paragraph.
**Structure your instructions in two short paragraphs:**
1. **Default register** — How Elena normally speaks: vocabulary, formality, sentence structure, verbal habits, how they address others.
2. **Under pressure** — How their speech changes when stressed, scared, angry, or pushed past their limit. Do NOT write that they simply become "more of the same" (e.g., "gets even more clinical" or "becomes even quieter"). Their composure must crack in a specific, visible way — shorter sentences, different word choices, breaking their own verbal patterns, physical urgency replacing their normal mode. This variation is critical for believable dialogue in dramatic scenes.
</TASK>
The length of your response must fit within 4 paragraphs.

View File

@@ -1,27 +1,5 @@
## Examples
<REVIEW>
The incandescent luminescence of twilight's dying breath painted the heavens in hues of amethyst and vermillion, while shadows danced their ancient waltz across the emerald tapestry of the lawn. Rebecca's heart thundered like a thousand war drums as she glimpsed his silhouette, a dark prince emerging from the veil of dusk's embrace.
</REVIEW>
<ANALYSIS>
- Thought process: This is trying way too hard to describe a simple sunset scene. Every single phrase is overwritten with metaphors and fancy vocabulary when it could just say "sunset" and "she saw him."
- Purple prose: MAJOR ISSUES - "incandescent luminescence," "twilight's dying breath," "ancient waltz," "thousand war drums," "dark prince"
- Unnatural dialogue: N/A - no dialogue
- Over-description: YES - simple sunset/shadow scene made overly complex
- Length: TOO LONG - could be said in 1-2 sentences
- Tense: PAST - "Rebecca's heart thundered like a thousand war drums"
- Mature content: N/A
- Name overuse: N/A - no dialogue
- Talking vs Showing: N/A - no dialogue
</ANALYSIS>
<FIX>
The sunset cast purple and red across the sky as shadows stretched across the lawn. Rebecca's heart raced when she saw him walking up the driveway.
</FIX>
---
<REVIEW>
"Hello, Mother. I have returned from my educational institution," said Tim.
"Excellent. Did you successfully complete your mathematical assignments?" she responded.
@@ -50,28 +28,6 @@ The sunset cast purple and red across the sky as shadows stretched across the la
---
<REVIEW>
She reaches for the door handle. Her index finger extends first, followed by her middle finger, then her ring finger, and finally her pinky. Her thumb moves to oppose them. The metal is cool to the touch, approximately 68 degrees Fahrenheit. She grips it with 3.2 pounds of pressure and rotates her wrist 47 degrees clockwise.
</REVIEW>
<ANALYSIS>
- Thought process: While the exact measurements and finger-by-finger description is excessive, we should keep that the handle was cold - it's a sensory detail that adds atmosphere without being purple prose.
- Purple prose: NO
- Unnatural dialogue: N/A - no dialogue
- Over-description: EXTREME - every finger movement, exact temperature, precise measurements
- Length: WAY TOO LONG - should be 3-4 words
- Tense: PRESENT - "reaches for the door handle"
- Mature content: N/A
- Name overuse: N/A
- Talking vs Showing: N/A - no dialogue
</ANALYSIS>
<FIX>
She gripped the cold metal handle and turned it, opening the door.
</FIX>
---
<REVIEW>
Beneath the opalescent moon's ethereal glow, Jake's sapphire eyes met hers.
"Your presence illuminates my existence more brilliantly than a thousand supernovas," he declared.
@@ -210,6 +166,31 @@ She bit her lip, looking away. "It's just... I mean... um... something feels wro
---
<REVIEW>
"As you know, the dual-phase cooling system requires constant monitoring to prevent a cascade failure in the secondary loop," Dr. Chen explained to her co-engineer, who had been maintaining the reactor for twelve years. "The isothermal regulators must be calibrated every six hours, and the backup condensers need to cycle through their purge sequence before any power increase can be authorized."
"Indeed, I am well aware of these procedures," he responded. "Perhaps we should also discuss the implications of the recent firmware update on the thermal management subsystem."
</REVIEW>
<ANALYSIS>
- Thought process: This is exposition dumped into dialogue. These are two engineers who both know this information — they wouldn't explain it to each other. The dialogue exists purely to inform the reader, not because the characters would actually say it. It needs to be replaced with what they'd actually discuss: the immediate problem, not the textbook explanation.
- Purple prose: NO
- Unnatural dialogue: SEVERE - characters explaining things they both already know
- Over-description: YES - unnecessary technical detail
- Length: TOO LONG
- Tense: PAST - "explained"
- Mature content: N/A
- Name overuse: NO
- Talking vs Showing: SEVERE - lecturing the reader through dialogue
</ANALYSIS>
<FIX>
"The cooling readings look wrong again." Dr. Chen frowned at her display, tapping one of the fluctuating values.
He leaned over her shoulder. "That started after the firmware update. Want me to roll it back?"
"Not yet. Let me check the secondary loop first."
</FIX>
---
<REVIEW>
"Hi David, how are you today?" asked Emily.
"I'm doing well, Emily. Have you seen Michael?"

View File

@@ -12,6 +12,8 @@ import talemate.emit.async_signals
from talemate.agents.base import DynamicInstruction
from talemate.agents.director.chat.schema import DirectorChatMessage
from talemate.agents.director.plan.schema import Beat
from talemate.agents.director.plan.expand import ChunkArcInfo
from ..conftest import mock_llm_client # noqa: F401
from ..test_director_templates import ( # noqa: F401
@@ -28,7 +30,7 @@ from ..test_director_templates import ( # noqa: F401
active_context,
MockCharacter,
)
from .conftest import capture_prompt
from .conftest import capture_prompt, capture_all_prompts
AGENT = "director"
@@ -265,3 +267,166 @@ class TestDirectorBaselines:
baseline_checker(
capture_prompt(director), AGENT, "detect_characters_from_texts"
)
def _make_test_beats() -> list[Beat]:
"""Create a small set of beats for testing expand templates."""
return [
Beat(
description="The protagonist discovers the door is locked from the inside.",
order=1, tension=0.3, pacing="slow", type="narration",
characters=["Elena"],
),
Beat(
description="Elena confronts Hero about what happened last night, demanding answers.",
order=2, tension=0.5, pacing="moderate", type="dialogue",
characters=["Elena"],
),
Beat(
description="A sudden noise from the basement forces both characters to investigate together.",
order=3, tension=0.7, pacing="fast", type="action",
characters=["Hero", "Elena"],
),
]
class TestPlanExpandBaselines:
"""Baseline tests for plan expand templates (arc-expand, arc-expand-critique)."""
@pytest.mark.asyncio
async def test_arc_expand(self, active_context, baseline_checker):
"""Test the arc-expand template renders correctly with beats and arc info."""
from talemate.prompts import Prompt
from talemate.agents.base import ActiveAgent
director = active_context
from talemate.instance import AGENTS
narrator = AGENTS.get("narrator")
narrator.agent_type = "narrator"
narrator.client = director.client
narrator.extra_instructions = ""
narrator.content_use_writing_style = False
narrator.action_response_length = Mock(return_value=4096)
beats = _make_test_beats()
arc_info = ChunkArcInfo(
position="opening",
chunk_index=0,
total_chunks=2,
tension_range=(0.3, 0.7),
has_peak=False,
)
director.client.send_prompt = AsyncMock(
return_value="<NARRATOR>Test narration.</NARRATOR>"
)
with ActiveAgent(narrator, lambda: None):
await Prompt.request(
"narrator.arc-expand",
narrator.client,
"narrate_4096",
vars={
"scene": director.scene,
"max_tokens": 8192,
"beats": beats,
"following_beats": [],
"preceding_text": "",
"perspective": "Third person limited, past tense.",
"director_notes": "Focus on building tension.",
"extra_instructions": "",
"response_length": 4096,
"arc_info": arc_info,
},
)
baseline_checker(capture_prompt(director), AGENT, "arc_expand")
@pytest.mark.asyncio
async def test_arc_expand__with_preceding_text(
self, active_context, baseline_checker
):
"""Test arc-expand with preceding text context."""
from talemate.prompts import Prompt
from talemate.agents.base import ActiveAgent
director = active_context
from talemate.instance import AGENTS
narrator = AGENTS.get("narrator")
narrator.agent_type = "narrator"
narrator.client = director.client
narrator.extra_instructions = ""
narrator.content_use_writing_style = False
narrator.action_response_length = Mock(return_value=4096)
beats = _make_test_beats()[1:] # beats 2-3
arc_info = ChunkArcInfo(
position="climax",
chunk_index=1,
total_chunks=2,
tension_range=(0.5, 0.7),
has_peak=True,
)
director.client.send_prompt = AsyncMock(
return_value="<NARRATOR>More narration.</NARRATOR>"
)
with ActiveAgent(narrator, lambda: None):
await Prompt.request(
"narrator.arc-expand",
narrator.client,
"narrate_4096",
vars={
"scene": director.scene,
"max_tokens": 8192,
"beats": beats,
"following_beats": [],
"preceding_text": "The door creaked open, revealing an empty room. Elena stepped inside cautiously.",
"perspective": "Third person limited, past tense.",
"director_notes": "",
"extra_instructions": "",
"response_length": 4096,
"arc_info": arc_info,
},
)
baseline_checker(
capture_prompt(director), AGENT, "arc_expand__with_preceding_text"
)
@pytest.mark.asyncio
async def test_arc_expand_critique(self, active_context, baseline_checker):
"""Test the arc-expand-critique template renders correctly."""
from talemate.prompts import Prompt
from talemate.agents.base import ActiveAgent
director = active_context
narrator = Mock()
narrator.client = director.client
narrator.extra_instructions = ""
narrator.content_use_writing_style = False
blocks = [
{"type": "narrator", "content": "The room was dark and cold. A chill ran down her spine."},
{"type": "character", "name": "Elena", "content": "She stepped forward, her hands trembling. \"Who's there?\" she whispered."},
{"type": "narrator", "content": "A chill ran through the room. The darkness pressed in from all sides."},
]
director.client.send_prompt = AsyncMock(
return_value="<NARRATOR>Revised narration.</NARRATOR>"
)
with ActiveAgent(narrator, lambda: None):
await Prompt.request(
"narrator.arc-expand-critique",
narrator.client,
"narrate_4096",
vars={
"blocks": blocks,
"max_tokens": 8192,
"response_length": 4096,
},
)
baseline_checker(capture_prompt(director), AGENT, "arc_expand_critique")

259
tests/test_plan_expand.py Normal file
View File

@@ -0,0 +1,259 @@
"""
Unit tests for the director plan expand system.
Tests cover:
- Deliberate chunking (tension-valley splitting)
- Arc position metadata computation
- Leaked tag detection
- PlanMixin config properties
- Chat creation for generate_arc modes
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from talemate.agents.director.plan.expand import (
compute_chunks,
compute_arc_info,
has_leaked_tags,
MIN_CHUNK_BEATS,
)
from talemate.agents.director.plan.schema import Beat
def _make_beats(tensions: list[float]) -> list[Beat]:
"""Helper to create Beat objects with given tension values."""
return [
Beat(
description=f"Beat {i+1}",
order=i + 1,
tension=t,
pacing="moderate",
type="narration",
)
for i, t in enumerate(tensions)
]
class TestComputeChunks:
"""Tests for deliberate chunking at tension valleys."""
def test_single_chunk_when_under_max(self):
beats = _make_beats([0.2, 0.4, 0.6, 0.8])
chunks = compute_chunks(beats, max_chunk_size=8)
assert len(chunks) == 1
assert len(chunks[0]) == 4
def test_single_chunk_when_equal_to_max(self):
beats = _make_beats([0.2, 0.4, 0.6, 0.8, 1.0])
chunks = compute_chunks(beats, max_chunk_size=5)
assert len(chunks) == 1
assert len(chunks[0]) == 5
def test_splits_at_tension_valley(self):
# Tension rises to 0.7 then drops to 0.3 — should split at the valley
beats = _make_beats([0.2, 0.4, 0.7, 0.3, 0.5, 0.8, 1.0])
chunks = compute_chunks(beats, max_chunk_size=6)
assert len(chunks) == 2
# First chunk: beats 1-3 (tension rises to 0.7)
assert len(chunks[0]) == 3
# Second chunk: beats 4-7 (starts at 0.3)
assert len(chunks[1]) == 4
def test_respects_min_chunk_size(self):
# Valley at beat 2, but that would leave chunk 1 with only 2 beats
beats = _make_beats([0.5, 0.3, 0.4, 0.6, 0.8, 1.0])
chunks = compute_chunks(beats, max_chunk_size=5)
# Should not split at beat 2 because chunk would be < MIN_CHUNK_BEATS
assert all(len(c) >= MIN_CHUNK_BEATS for c in chunks)
def test_splits_at_max_size_when_no_valley(self):
# Monotonically increasing — no valleys, must split at max
beats = _make_beats([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8])
chunks = compute_chunks(beats, max_chunk_size=4)
assert len(chunks) == 2
assert len(chunks[0]) == 4
assert len(chunks[1]) == 4
def test_merges_small_leftover(self):
# 7 beats, max 4 — would split to [4, 3], but if valley is at 5
# we might get [5, 2] which should merge to [7]
beats = _make_beats([0.2, 0.4, 0.6, 0.8, 0.5, 0.3, 0.2])
chunks = compute_chunks(beats, max_chunk_size=6)
# With valley at beat 4→5, should split to [4, 3]
# But leftover of 2 is < MIN_CHUNK_BEATS, so merges back
for chunk in chunks:
assert len(chunk) >= MIN_CHUNK_BEATS or len(chunks) == 1
def test_multiple_valleys(self):
beats = _make_beats([0.2, 0.5, 0.8, 0.3, 0.6, 0.9, 0.4, 0.7, 1.0])
chunks = compute_chunks(beats, max_chunk_size=8)
# Two valleys: beat 3→4 (0.8→0.3) and beat 6→7 (0.9→0.4)
assert len(chunks) == 3
def test_empty_beats(self):
chunks = compute_chunks([], max_chunk_size=5)
assert len(chunks) == 1
assert len(chunks[0]) == 0
class TestComputeArcInfo:
"""Tests for arc position metadata computation."""
def test_single_chunk_is_full(self):
beats = _make_beats([0.2, 0.5, 0.8])
chunks = [beats]
infos = compute_arc_info(chunks, beats)
assert len(infos) == 1
assert infos[0].position == "full"
def test_two_chunks_opening_and_climax(self):
beats = _make_beats([0.2, 0.4, 0.6, 0.8, 0.9, 1.0])
chunks = [beats[:3], beats[3:]]
infos = compute_arc_info(chunks, beats)
assert infos[0].position == "opening"
assert infos[1].position == "climax"
def test_three_chunks_opening_rising_climax(self):
beats = _make_beats([0.2, 0.3, 0.5, 0.6, 0.8, 0.9, 1.0])
chunks = [beats[:2], beats[2:4], beats[4:]]
infos = compute_arc_info(chunks, beats)
assert infos[0].position == "opening"
assert infos[1].position == "rising"
assert infos[2].position == "climax"
def test_resolution_when_peak_not_in_last_chunk(self):
beats = _make_beats([0.2, 0.5, 1.0, 0.8, 0.4, 0.3])
chunks = [beats[:3], beats[3:]]
infos = compute_arc_info(chunks, beats)
assert infos[0].position == "opening" # has peak but is first chunk
assert infos[1].position == "resolution"
def test_climax_and_resolution_when_winds_down(self):
beats = _make_beats([0.2, 0.5, 0.8, 1.0, 0.7, 0.5, 0.3])
chunks = [beats[:3], beats[3:]]
infos = compute_arc_info(chunks, beats)
assert infos[0].position == "opening"
# Second chunk has peak (1.0) but winds down to 0.3
assert infos[1].position == "climax_and_resolution"
def test_tension_range_computed_correctly(self):
beats = _make_beats([0.2, 0.8, 0.5])
chunks = [beats]
infos = compute_arc_info(chunks, beats)
assert infos[0].tension_range == (0.2, 0.8)
def test_has_peak_within_tolerance(self):
beats = _make_beats([0.2, 0.5, 0.95, 1.0])
chunks = [beats[:2], beats[2:]]
infos = compute_arc_info(chunks, beats)
# 0.95 is within 0.05 of peak 1.0
assert infos[1].has_peak is True
class TestHasLeakedTags:
"""Tests for leaked block tag detection."""
def test_clean_blocks(self):
blocks = [
{"type": "narrator", "content": "The ship drifted silently."},
{"type": "character", "name": "Elmer", "content": "Let's go."},
]
assert has_leaked_tags(blocks) is False
def test_leaked_narrator_tag(self):
blocks = [
{"type": "narrator", "content": "Some text <NARRATOR> more text"},
]
assert has_leaked_tags(blocks) is True
def test_leaked_character_tag(self):
blocks = [
{"type": "narrator", "content": 'Text <CHARACTER name="Kaira"> more'},
]
assert has_leaked_tags(blocks) is True
def test_leaked_closing_tag(self):
blocks = [
{"type": "character", "content": "Text </NARRATOR> more"},
]
assert has_leaked_tags(blocks) is True
def test_empty_blocks(self):
assert has_leaked_tags([]) is False
def test_empty_content(self):
blocks = [{"type": "narrator", "content": ""}]
assert has_leaked_tags(blocks) is False
class TestPlanMixinConfig:
"""Tests for PlanMixin configuration properties."""
def test_plan_action_registered(self):
from talemate.agents.director import DirectorAgent
actions = DirectorAgent.init_actions()
assert "plan" in actions
plan = actions["plan"]
assert plan.label == "Arc Generation"
assert plan.container is True
assert plan.icon == "mdi-movie-open"
def test_plan_config_keys(self):
from talemate.agents.director import DirectorAgent
actions = DirectorAgent.init_actions()
config = actions["plan"].config
assert "dialogue_ratio" in config
assert "expand_chunk_size" in config
assert "outline_critique" in config
assert "expand_critique" in config
def test_plan_config_defaults(self):
from talemate.agents.director import DirectorAgent
actions = DirectorAgent.init_actions()
config = actions["plan"].config
assert config["dialogue_ratio"].value == 0.4
assert config["expand_chunk_size"].value == 5
assert config["outline_critique"].value is True
assert config["expand_critique"].value is True
class TestChatCreateGenerateArc:
"""Tests for creating arc generation chats."""
@staticmethod
def _make_director():
from talemate.agents.director import DirectorAgent
director = DirectorAgent.__new__(DirectorAgent)
director.actions = DirectorAgent.init_actions()
director._chats = {}
director._last_active_chat_id = None
# Mock scene with agent_state for chat persistence
director.scene = Mock()
director.scene.agent_state = {"director": {}}
return director
def test_create_generate_arc_default_mode(self):
director = self._make_director()
chat = director.chat_create_generate_arc("Test instructions", 8)
assert chat.mode == "generate_arc"
assert chat.confirm_write_actions is False
assert len(chat.messages) == 2
def test_create_generate_arc_expand_mode(self):
director = self._make_director()
chat = director.chat_create_generate_arc(
"Test instructions", 8, mode="generate_arc_expand"
)
assert chat.mode == "generate_arc_expand"
def test_create_generate_arc_instructions_in_message(self):
director = self._make_director()
chat = director.chat_create_generate_arc("Write a horror scene", 12)
user_msg = chat.messages[1]
assert "Write a horror scene" in user_msg.message
assert "12 beats" in user_msg.message