File size: 7,529 Bytes
4fdb3ad
e83a7fe
 
 
 
4fdb3ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46f845b
4fdb3ad
 
 
 
 
 
 
 
 
 
46f845b
 
 
 
 
 
 
 
 
 
 
 
4fdb3ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46f845b
4fdb3ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e83a7fe
4fdb3ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46f845b
 
4fdb3ad
 
 
 
 
e83a7fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4fdb3ad
 
 
 
 
 
 
 
 
 
e83a7fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46f845b
e83a7fe
 
 
46f845b
 
 
e83a7fe
46f845b
e83a7fe
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
"""
engine.py — StoryForge engine. Coherence lives HERE, not in the model's memory.
The small model forgets, so we never rely on it: every turn we re-inject the
canonical facts (StoryState) and ask only for the NEXT beat. Explicit state in,
one grounded step out. Also builds CLIP-safe image prompts per beat.
"""

import json
import re
from dataclasses import dataclass, field, asdict


@dataclass
class StoryState:
    theme: str = ""
    hero: str = ""
    world: str = ""
    facts: list = field(default_factory=list)
    history: list = field(default_factory=list)   # [{"beat": str, "choice": str}]
    moment: int = 0
    total_moments: int = 10
    num_options: int = 5
    language: str = "English"
    finished: bool = False

    def to_dict(self):
        return asdict(self)

    @staticmethod
    def from_dict(d):
        return StoryState(**d) if d else StoryState()


def system_prompt(language: str = "English") -> str:
    return (
        "You are a warm, imaginative storyteller writing a branching picture-book "
        "adventure for a young child (age 4-8). Keep language simple, kind, and "
        "vivid. No violence, no scary or unsafe content. Every beat is 2-4 short "
        f"sentences. ALWAYS write the story, the choices and all descriptions in "
        f"{language} only — except the JSON keys and the 'scene' field, which are "
        "always English. You ALWAYS answer with valid JSON and nothing else."
    )


SYSTEM = system_prompt()


def _state_block(s: StoryState) -> str:
    facts = "; ".join(s.facts) if s.facts else "none yet"
    recap = ""
    for i, h in enumerate(s.history, 1):
        recap += f"\n  Beat {i}: {h['beat']}\n    The child chose: {h['choice']}"
    return (
        f"STORY SO FAR (do not contradict any of this):\n"
        f"- Theme: {s.theme}\n"
        f"- Hero: {s.hero or '(define one)'}\n"
        f"- World: {s.world or '(define one)'}\n"
        f"- Established facts: {facts}\n"
        f"- Beats played so far:{recap or ' none'}\n"
    )


def build_prompt(s: StoryState) -> str:
    last = s.moment + 1
    is_final = last >= s.total_moments
    if s.moment == 0:
        task = (
            f"Begin the story based on the theme '{s.theme}'. Invent the hero and "
            f"the world. Write the opening beat, then offer exactly {s.num_options} "
            f"distinct choices for what happens next."
        )
    elif is_final:
        task = (
            "Write the FINAL beat that brings the adventure to a happy, satisfying "
            "close. This is the ending: provide an empty options list."
        )
    else:
        task = (
            f"Continue from the child's last choice. Write the next beat "
            f"(beat {last} of {s.total_moments}), staying consistent with the story "
            f"so far, then offer exactly {s.num_options} distinct choices."
        )
    schema = (
        '{"beat": "<2-4 sentence story text>", '
        '"hero": "<short hero description, only if newly established else repeat>", '
        '"world": "<short world description, same rule>", '
        '"new_facts": ["<any new canonical fact to remember>"], '
        '"scene": "<6-12 word visual description of this beat, in English>", '
        '"options": ["<choice 1>", "..."]}'
    )
    return (
        f"{_state_block(s)}\n"
        f"TASK: {task}\n\n"
        f"Respond with ONLY this JSON shape, no preamble, no markdown:\n{schema}"
    )


def _strip_think(text: str) -> str:
    return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()


def _strip_non_latin(text: str) -> str:
    """Remove CJK and other non-Latin unicode blocks that Qwen3 occasionally emits."""
    return re.sub(r"[⺀-鿿豈-﫿︰-﹏]+", "", text).strip()


def _clean(text: str) -> str:
    return _strip_non_latin(_strip_think(text))


def parse_response(raw: str) -> dict:
    raw = _strip_think(raw.strip())
    raw = re.sub(r"^```(?:json)?", "", raw).strip()
    raw = re.sub(r"```$", "", raw).strip()
    m = re.search(r"\{.*\}", raw, re.DOTALL)
    candidate = m.group(0) if m else raw
    try:
        data = json.loads(candidate)
    except json.JSONDecodeError:
        beat_m = re.search(r'"beat"\s*:\s*"((?:[^"\\]|\\.)*)"', candidate)
        beat = beat_m.group(1) if beat_m else "The story pauses for a moment..."
        data = {"beat": beat, "options": []}
    data.setdefault("beat", "")
    data.setdefault("options", [])
    data.setdefault("new_facts", [])
    data.setdefault("hero", "")
    data.setdefault("world", "")
    data.setdefault("scene", "")
    data["scene"] = _clean(str(data["scene"] or ""))
    data["beat"] = _clean(data["beat"])
    data["options"] = [_clean(o) for o in data["options"] if o]
    return data


_PARTIAL_BEAT_RE = re.compile(r'"beat"\s*:\s*"((?:[^"\\]|\\.)*)')


def extract_partial_beat(raw: str) -> str:
    """Best-effort beat text from a partially generated JSON response.

    Used while streaming: the closing quote may not have arrived yet, and a
    <think> block may still be open.
    """
    raw = re.sub(r"<think>.*?(?:</think>|$)", "", raw, flags=re.DOTALL)
    m = _PARTIAL_BEAT_RE.search(raw)
    if not m:
        return ""
    text = m.group(1)
    try:
        text = json.loads(f'"{text}"')
    except json.JSONDecodeError:
        text = text.replace('\\"', '"').replace("\\n", " ")
    return _clean(text)


def apply_turn(s: StoryState, data: dict) -> StoryState:
    if data.get("hero") and not s.hero:
        s.hero = data["hero"].strip()
    if data.get("world") and not s.world:
        s.world = data["world"].strip()
    for f in data.get("new_facts", []):
        f = (f or "").strip()
        if f and f.lower() != "none" and f not in s.facts:
            s.facts.append(f)
    return s


# ── Image prompt builder ──────────────────────────────────────────────────────

_STOPWORDS = {
    "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "of",
    "for", "with", "is", "are", "was", "were", "be", "been", "being",
    "it", "its", "he", "she", "they", "their", "this", "that", "then",
    "as", "by", "from", "up", "out", "about", "into", "through", "after",
}

_MAX_BEAT_WORDS = 30


def _trim_beat(beat: str) -> str:
    """Extract the most visually descriptive words from a beat (≤18 words)."""
    words = beat.replace(",", " ").replace(".", " ").replace("!", " ").split()
    content = [w for w in words if w.lower() not in _STOPWORDS]
    return " ".join(content[:_MAX_BEAT_WORDS])


_NEGATIVE_PROMPT = (
    "border, frame, box, square, panel, grid, letterbox, vignette, "
    "text, watermark, signature, logo, blur, dark, ugly, deformed"
)


def build_image_prompt(beat: str, hero: str, world: str, scene: str = "") -> str:
    """
    Build a CLIP-safe image prompt (target ≤77 tokens).
    Always includes the 'ghibli style' trigger phrase required by Ghibli-Diffusion.
    Scene content leads so CLIP weights it highest. The model's English "scene"
    field is preferred over the raw beat — it stays English even when the story
    is written in another language (SD 1.5's CLIP understands English best).
    """
    scene = (scene or "").strip() or _trim_beat(beat)
    parts = [scene]
    if hero:
        parts.append(hero[:30])
    if world:
        parts.append(world[:30])
    parts += ["ghibli style", "soft watercolor", "warm light", "highly detailed", "cinematic"]
    return ", ".join(parts)