Spaces:
Sleeping
Sleeping
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)
|