diff --git a/chat/config.py b/chat/config.py index fa08803..8d633b3 100644 --- a/chat/config.py +++ b/chat/config.py @@ -23,9 +23,13 @@ class Settings(BaseModel): retrieval_k: int = 4 narrative_budget_hard: int = 8000 narrative_budget_soft: int = 6000 - # Cap on each generated bot response. ~400 tokens ≈ 1–2 short paragraphs. - # Bump if you want longer scenes; drop to 200 for terse banter. - narrative_max_tokens: int = 400 + # Cap on each generated bot response. The asterisk-action format + # (see ``_closing_instruction`` in chat/services/prompt.py) targets + # 2-4 short interleaved action+dialogue beats; ~250 tokens fits that + # without leaving room for the model to drift into multi-paragraph + # inner-monologue prose. Bump back up if you want longer scenes; + # drop to 150 for very terse banter. + narrative_max_tokens: int = 250 # Sampling temperature for narrative generation. 0.7 = grounded / # consistent; 0.85 = creative-but-in-character (default); 1.0 = wide # variety, can drift; >1.0 = often off-the-rails. diff --git a/chat/services/prompt.py b/chat/services/prompt.py index d27d23c..cae9fee 100644 --- a/chat/services/prompt.py +++ b/chat/services/prompt.py @@ -327,12 +327,23 @@ def _build_open_threads_block(threads: list[dict]) -> str | None: def _closing_instruction(speaker_name: str, addressee_name: str) -> str: return ( - f"Continue the scene as {speaker_name}, in their voice, responding " - "naturally. Use *asterisks* for actions and quotes for dialogue. " - f"Stay in character. Do not narrate {addressee_name}'s actions or " - "thoughts. " - "Keep your response to a single beat — one or two short paragraphs " - "at most. Don't monologue; leave room for the other person to react." + f"Continue as {speaker_name}. Format strictly:\n" + f"- Wrap actions and gestures in *asterisks*, third person " + f"({speaker_name}/she/he/they) — never first person, never inner " + "thoughts inside asterisks.\n" + "- Speak dialogue as plain text between action beats, no quote " + "marks. Keep speech fragmented, not paragraphs.\n" + "- Interleave 2-4 short beats (action, brief speech, action, brief " + "speech). Each beat is one concrete gesture or sensory image — no " + "explanation, no inner monologue, no stage-direction adverbs.\n" + "- Trailing ellipses (...) are fine for emotional weight.\n" + "Example: *She turns with soapy hands to cup your face* That's how " + "I know it's real... *She kisses you softly* You love me when I'm " + "messy... *She rests her forehead against yours* ...and every " + "moment in between.\n" + f"Show only what {addressee_name} could externally observe of " + f"{speaker_name}; never narrate {addressee_name}'s actions or " + "thoughts. One response — leave room to react." ) diff --git a/tests/test_prompt.py b/tests/test_prompt.py index 471d5b4..be12271 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -565,8 +565,12 @@ def test_tight_budget_drops_guest_activity_bullet_first(tmp_path): speaker_bot_id="bot_a", recent_dialogue=dialogue, retrieved_memory_summaries=[], - budget_soft=250, - budget_hard=340, + # Closing instruction grew with the asterisk-format spec + # (Phase 4.6 narrative-style fix). Budget bumped enough to + # accommodate the larger MUST floor while still exercising + # the SHOULD-tier trim path. + budget_soft=440, + budget_hard=460, ) body = msgs[0].content # Speaker bullet survives (MUST-tier floor). @@ -696,13 +700,15 @@ def test_nice_trim_order_documented(tmp_path): # Soft tuned so the all-NICE config (with the heavy previous # scene summary) overflows, but dropping just previous-scene # fits comfortably. Hard set high so SHOULD-tier never trims. + # Soft bumped (was 400) to make room for the larger closing + # instruction shipped with the asterisk-format spec. msgs = assemble_narrative_prompt( conn, chat_id="chat_bot_a", speaker_bot_id="bot_a", recent_dialogue=dialogue, retrieved_memory_summaries=memories, - budget_soft=400, + budget_soft=540, budget_hard=8000, ) body = msgs[0].content @@ -748,8 +754,12 @@ def test_assemble_with_tight_budget_drops_guest_activity_first(tmp_path): # group node + other edges) push it well over 380. budget_hard # is set just above MUST core so SHOULD-tier blocks must be # trimmed away. - budget_soft=250, - budget_hard=340, + # Closing instruction grew with the asterisk-format spec + # (Phase 4.6 narrative-style fix). Budget bumped enough to + # accommodate the larger MUST floor while still exercising + # the SHOULD-tier trim path. + budget_soft=440, + budget_hard=460, ) body = msgs[0].content # MUST: speaker identity, edge to addressee, last 4 dialogue turns. @@ -759,10 +769,11 @@ def test_assemble_with_tight_budget_drops_guest_activity_first(tmp_path): assert f"line-{i:02d}" in body # Guest activity (SHOULD-tier) must be dropped under tight budget. assert "smirking-distinctively" not in body - # Token budget honoured. + # Token budget honoured. Bumped (was 340) for the larger closing + # instruction that ships the asterisk-format spec. import tiktoken enc = tiktoken.get_encoding("cl100k_base") - assert len(enc.encode(body)) <= 340 + assert len(enc.encode(body)) <= 460 # ---------------------------------------------------------------------------