palec87 commited on
Commit ·
f7fc536
1
Parent(s): 8ae7370
translations
Browse files- app.py +104 -19
- comic_gen/__init__.py +2 -0
- comic_gen/comics.py +29 -1
- comic_gen/exercise.py +44 -6
- comic_gen/image_backend.py +5 -3
- comic_gen/prompts.py +3 -1
- comic_gen/text_utils.py +201 -88
- comic_gen/translation_backend.py +204 -0
- examples/de_demo.json +158 -0
- examples/es_demo.json +24 -24
- examples/fr_demo.json +158 -0
- examples/pt_demo.json +158 -0
- test/test_app_ui_helpers.py +55 -0
- test/test_comics_pipeline.py +76 -0
- test/test_text_backend.py +2 -1
- test/test_translation_backend.py +75 -0
app.py
CHANGED
|
@@ -32,15 +32,17 @@ try:
|
|
| 32 |
except ImportError:
|
| 33 |
logger.warning("python-dotenv not installed, skipping .env loading")
|
| 34 |
|
|
|
|
| 35 |
|
| 36 |
LANGUAGE_OPTIONS = {
|
| 37 |
"English": "en",
|
|
|
|
| 38 |
"Espanol": "es",
|
| 39 |
"Francais": "fr",
|
| 40 |
"Deutsch": "de",
|
| 41 |
}
|
| 42 |
|
| 43 |
-
_VERSION = 0.
|
| 44 |
STYLE_OPTIONS = ["minimal", "newspaper", "watercolor", "retro"]
|
| 45 |
READING_LEVEL_OPTIONS = ["A1", "A2", "B1", "B2"]
|
| 46 |
MAX_SEED = 2**31 - 1
|
|
@@ -59,25 +61,75 @@ def _select_image_model(use_serverless_api: bool) -> str:
|
|
| 59 |
return SPACES_IMAGE_MODEL_ID
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def _render_source(document: dict[str, Any]) -> str:
|
|
|
|
| 63 |
source = document["source"]
|
| 64 |
article = document["article"]
|
| 65 |
return (
|
| 66 |
-
f"###
|
| 67 |
-
f"-
|
| 68 |
-
f"-
|
| 69 |
-
f"-
|
| 70 |
-
f"-
|
| 71 |
)
|
| 72 |
|
| 73 |
|
| 74 |
def _render_summary(document: dict[str, Any]) -> str:
|
|
|
|
| 75 |
simplified = document["simplified"]
|
| 76 |
keywords = ", ".join(simplified.get("keywords", []))
|
| 77 |
return (
|
| 78 |
-
f"###
|
| 79 |
f"{simplified['summary']}\n\n"
|
| 80 |
-
f"
|
| 81 |
)
|
| 82 |
|
| 83 |
|
|
@@ -164,8 +216,8 @@ def _overlay_bubbles_html(
|
|
| 164 |
"width:max-content;height:auto;"
|
| 165 |
"box-sizing:border-box;z-index:5;pointer-events:none;"
|
| 166 |
"display:flex;align-items:center;justify-content:center;"
|
| 167 |
-
"padding:
|
| 168 |
-
"border-radius:
|
| 169 |
"box-shadow:0 2px 8px rgba(17,24,39,0.18);"
|
| 170 |
"overflow:hidden;"
|
| 171 |
)
|
|
@@ -227,7 +279,8 @@ def _render_panels_html(
|
|
| 227 |
|
| 228 |
|
| 229 |
def _render_transcript(document: dict[str, Any]) -> str:
|
| 230 |
-
|
|
|
|
| 231 |
|
| 232 |
# Use enumerate to track if we are on the first panel or a later one
|
| 233 |
for i, panel in enumerate(document.get("panels", [])):
|
|
@@ -235,7 +288,9 @@ def _render_transcript(document: dict[str, Any]) -> str:
|
|
| 235 |
if i > 0:
|
| 236 |
lines.append("")
|
| 237 |
|
| 238 |
-
lines.append(
|
|
|
|
|
|
|
| 239 |
for line in panel.get("dialogue", []):
|
| 240 |
lines.append(f"- {line['character_id']}: {line['text']}")
|
| 241 |
|
|
@@ -243,8 +298,9 @@ def _render_transcript(document: dict[str, Any]) -> str:
|
|
| 243 |
|
| 244 |
|
| 245 |
def _panel_choices(document: dict[str, Any]) -> list[tuple[str, str]]:
|
|
|
|
| 246 |
return [
|
| 247 |
-
(f"
|
| 248 |
for p in document.get("panels", [])
|
| 249 |
]
|
| 250 |
|
|
@@ -417,8 +473,9 @@ def load_exercise(
|
|
| 417 |
document: dict[str, Any],
|
| 418 |
) -> tuple[str, str]:
|
| 419 |
if not document:
|
| 420 |
-
return "
|
| 421 |
|
|
|
|
| 422 |
panel_id = _panel_id_for_selection(selected_panel, document)
|
| 423 |
exercise_item = next(
|
| 424 |
(
|
|
@@ -429,9 +486,9 @@ def load_exercise(
|
|
| 429 |
None,
|
| 430 |
)
|
| 431 |
if not exercise_item:
|
| 432 |
-
return
|
| 433 |
|
| 434 |
-
return f"###
|
| 435 |
|
| 436 |
|
| 437 |
def submit_answer(
|
|
@@ -440,7 +497,7 @@ def submit_answer(
|
|
| 440 |
document: dict[str, Any],
|
| 441 |
) -> str:
|
| 442 |
if not document:
|
| 443 |
-
return "
|
| 444 |
panel_id = _panel_id_for_selection(selected_panel, document)
|
| 445 |
ok, feedback = exercise.evaluate_answer(document, panel_id, answer)
|
| 446 |
status = "ok" if ok else "retry"
|
|
@@ -455,7 +512,7 @@ with gr.Blocks() as demo:
|
|
| 455 |
if APP_CSS:
|
| 456 |
gr.HTML(f"<style>{APP_CSS}</style>")
|
| 457 |
gr.Markdown("# Anti-Ill Comix")
|
| 458 |
-
gr.Markdown(
|
| 459 |
(
|
| 460 |
"Turn international news into simple comic practice "
|
| 461 |
f"for adult reading and writing. {_VERSION}"
|
|
@@ -553,7 +610,7 @@ with gr.Blocks() as demo:
|
|
| 553 |
panel_html = gr.HTML()
|
| 554 |
transcript_md = gr.Markdown(elem_id="transcript_md")
|
| 555 |
|
| 556 |
-
gr.Markdown("### Writing Exercises")
|
| 557 |
panel_selector = gr.Dropdown(
|
| 558 |
choices=[],
|
| 559 |
label="Select panel",
|
|
@@ -601,6 +658,34 @@ with gr.Blocks() as demo:
|
|
| 601 |
],
|
| 602 |
)
|
| 603 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
panel_selector.change(
|
| 605 |
fn=load_exercise,
|
| 606 |
inputs=[panel_selector, session_state],
|
|
|
|
| 32 |
except ImportError:
|
| 33 |
logger.warning("python-dotenv not installed, skipping .env loading")
|
| 34 |
|
| 35 |
+
from comic_gen.text_utils import UI_TRANSLATIONS
|
| 36 |
|
| 37 |
LANGUAGE_OPTIONS = {
|
| 38 |
"English": "en",
|
| 39 |
+
"Portuguese": "pt",
|
| 40 |
"Espanol": "es",
|
| 41 |
"Francais": "fr",
|
| 42 |
"Deutsch": "de",
|
| 43 |
}
|
| 44 |
|
| 45 |
+
_VERSION = 0.14
|
| 46 |
STYLE_OPTIONS = ["minimal", "newspaper", "watercolor", "retro"]
|
| 47 |
READING_LEVEL_OPTIONS = ["A1", "A2", "B1", "B2"]
|
| 48 |
MAX_SEED = 2**31 - 1
|
|
|
|
| 61 |
return SPACES_IMAGE_MODEL_ID
|
| 62 |
|
| 63 |
|
| 64 |
+
def _language_code_for_label(language_label: str) -> str:
|
| 65 |
+
"""Return the app language code for a dropdown label."""
|
| 66 |
+
return LANGUAGE_OPTIONS.get(language_label, "en")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _ui_text(language_code: str, key: str) -> str:
|
| 70 |
+
"""Return localized UI text with English fallback."""
|
| 71 |
+
language = language_code if language_code in UI_TRANSLATIONS else "en"
|
| 72 |
+
return UI_TRANSLATIONS.get(language, {}).get(
|
| 73 |
+
key,
|
| 74 |
+
UI_TRANSLATIONS["en"].get(key, key),
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _localized_ui_updates(language_label: str) -> tuple[Any, ...]:
|
| 79 |
+
"""Build Gradio updates for static UI text."""
|
| 80 |
+
language = _language_code_for_label(language_label)
|
| 81 |
+
return (
|
| 82 |
+
gr.update(value=f"{_ui_text(language, 'app_subtitle')} {_VERSION}"),
|
| 83 |
+
gr.update(label=_ui_text(language, "language")),
|
| 84 |
+
gr.update(label=_ui_text(language, "art_style")),
|
| 85 |
+
gr.update(label=_ui_text(language, "reading_level")),
|
| 86 |
+
gr.update(label=_ui_text(language, "panel_count")),
|
| 87 |
+
gr.update(label=_ui_text(language, "live_feed")),
|
| 88 |
+
gr.update(label=_ui_text(language, "serverless")),
|
| 89 |
+
gr.update(label=_ui_text(language, "debug")),
|
| 90 |
+
gr.update(
|
| 91 |
+
label=_ui_text(language, "negative_prompt"),
|
| 92 |
+
placeholder=_ui_text(language, "negative_prompt_placeholder"),
|
| 93 |
+
),
|
| 94 |
+
gr.update(label=_ui_text(language, "seed")),
|
| 95 |
+
gr.update(label=_ui_text(language, "randomize_seed")),
|
| 96 |
+
gr.update(label=_ui_text(language, "width")),
|
| 97 |
+
gr.update(label=_ui_text(language, "height")),
|
| 98 |
+
gr.update(label=_ui_text(language, "guidance_scale")),
|
| 99 |
+
gr.update(label=_ui_text(language, "inference_steps")),
|
| 100 |
+
gr.update(value=_ui_text(language, "generate")),
|
| 101 |
+
gr.update(value=_ui_text(language, "exercises_heading")),
|
| 102 |
+
gr.update(label=_ui_text(language, "select_panel")),
|
| 103 |
+
gr.update(value=_ui_text(language, "unlock_exercises")),
|
| 104 |
+
gr.update(
|
| 105 |
+
label=_ui_text(language, "answer_label"),
|
| 106 |
+
placeholder=_ui_text(language, "answer_placeholder"),
|
| 107 |
+
),
|
| 108 |
+
gr.update(value=_ui_text(language, "submit")),
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
def _render_source(document: dict[str, Any]) -> str:
|
| 113 |
+
language = str(document.get("language", "en"))
|
| 114 |
source = document["source"]
|
| 115 |
article = document["article"]
|
| 116 |
return (
|
| 117 |
+
f"### {_ui_text(language, 'source')}\n"
|
| 118 |
+
f"- {_ui_text(language, 'publisher')}: {source['publisher']}\n"
|
| 119 |
+
f"- {_ui_text(language, 'title')}: {article['title']}\n"
|
| 120 |
+
f"- {_ui_text(language, 'link')}: {source['link']}\n"
|
| 121 |
+
f"- {_ui_text(language, 'published')}: {source['published_at']}"
|
| 122 |
)
|
| 123 |
|
| 124 |
|
| 125 |
def _render_summary(document: dict[str, Any]) -> str:
|
| 126 |
+
language = str(document.get("language", "en"))
|
| 127 |
simplified = document["simplified"]
|
| 128 |
keywords = ", ".join(simplified.get("keywords", []))
|
| 129 |
return (
|
| 130 |
+
f"### {_ui_text(language, 'summary')} ({simplified['level']})\n"
|
| 131 |
f"{simplified['summary']}\n\n"
|
| 132 |
+
f"{_ui_text(language, 'keywords')}: {keywords}"
|
| 133 |
)
|
| 134 |
|
| 135 |
|
|
|
|
| 216 |
"width:max-content;height:auto;"
|
| 217 |
"box-sizing:border-box;z-index:5;pointer-events:none;"
|
| 218 |
"display:flex;align-items:center;justify-content:center;"
|
| 219 |
+
"padding:2px 5px;border:1px solid rgba(17,24,39,0.92);"
|
| 220 |
+
"border-radius:8px;background:rgba(255,255,255,0.94);"
|
| 221 |
"box-shadow:0 2px 8px rgba(17,24,39,0.18);"
|
| 222 |
"overflow:hidden;"
|
| 223 |
)
|
|
|
|
| 279 |
|
| 280 |
|
| 281 |
def _render_transcript(document: dict[str, Any]) -> str:
|
| 282 |
+
language = str(document.get("language", "en"))
|
| 283 |
+
lines: list[str] = [f"### {_ui_text(language, 'transcript')}"]
|
| 284 |
|
| 285 |
# Use enumerate to track if we are on the first panel or a later one
|
| 286 |
for i, panel in enumerate(document.get("panels", [])):
|
|
|
|
| 288 |
if i > 0:
|
| 289 |
lines.append("")
|
| 290 |
|
| 291 |
+
lines.append(
|
| 292 |
+
f"{_ui_text(language, 'panel').upper()} {panel['frame_index']}"
|
| 293 |
+
)
|
| 294 |
for line in panel.get("dialogue", []):
|
| 295 |
lines.append(f"- {line['character_id']}: {line['text']}")
|
| 296 |
|
|
|
|
| 298 |
|
| 299 |
|
| 300 |
def _panel_choices(document: dict[str, Any]) -> list[tuple[str, str]]:
|
| 301 |
+
language = str(document.get("language", "en"))
|
| 302 |
return [
|
| 303 |
+
(f"{_ui_text(language, 'panel')} {p['frame_index']}", p["panel_id"])
|
| 304 |
for p in document.get("panels", [])
|
| 305 |
]
|
| 306 |
|
|
|
|
| 473 |
document: dict[str, Any],
|
| 474 |
) -> tuple[str, str]:
|
| 475 |
if not document:
|
| 476 |
+
return _ui_text("en", "generate_first"), ""
|
| 477 |
|
| 478 |
+
language = str(document.get("language", "en"))
|
| 479 |
panel_id = _panel_id_for_selection(selected_panel, document)
|
| 480 |
exercise_item = next(
|
| 481 |
(
|
|
|
|
| 486 |
None,
|
| 487 |
)
|
| 488 |
if not exercise_item:
|
| 489 |
+
return _ui_text(language, "no_exercise"), ""
|
| 490 |
|
| 491 |
+
return f"### {_ui_text(language, 'exercise')}\n{exercise_item['prompt']}", ""
|
| 492 |
|
| 493 |
|
| 494 |
def submit_answer(
|
|
|
|
| 497 |
document: dict[str, Any],
|
| 498 |
) -> str:
|
| 499 |
if not document:
|
| 500 |
+
return _ui_text("en", "generate_first")
|
| 501 |
panel_id = _panel_id_for_selection(selected_panel, document)
|
| 502 |
ok, feedback = exercise.evaluate_answer(document, panel_id, answer)
|
| 503 |
status = "ok" if ok else "retry"
|
|
|
|
| 512 |
if APP_CSS:
|
| 513 |
gr.HTML(f"<style>{APP_CSS}</style>")
|
| 514 |
gr.Markdown("# Anti-Ill Comix")
|
| 515 |
+
subtitle_md = gr.Markdown(
|
| 516 |
(
|
| 517 |
"Turn international news into simple comic practice "
|
| 518 |
f"for adult reading and writing. {_VERSION}"
|
|
|
|
| 610 |
panel_html = gr.HTML()
|
| 611 |
transcript_md = gr.Markdown(elem_id="transcript_md")
|
| 612 |
|
| 613 |
+
exercises_heading = gr.Markdown("### Writing Exercises")
|
| 614 |
panel_selector = gr.Dropdown(
|
| 615 |
choices=[],
|
| 616 |
label="Select panel",
|
|
|
|
| 658 |
],
|
| 659 |
)
|
| 660 |
|
| 661 |
+
language_input.change(
|
| 662 |
+
fn=_localized_ui_updates,
|
| 663 |
+
inputs=language_input,
|
| 664 |
+
outputs=[
|
| 665 |
+
subtitle_md,
|
| 666 |
+
language_input,
|
| 667 |
+
style_input,
|
| 668 |
+
reading_level_input,
|
| 669 |
+
panel_count,
|
| 670 |
+
live_feed_input,
|
| 671 |
+
use_serverless_api_input,
|
| 672 |
+
debug_mode_input,
|
| 673 |
+
negative_prompt_input,
|
| 674 |
+
seed_input,
|
| 675 |
+
randomize_seed_input,
|
| 676 |
+
width_input,
|
| 677 |
+
height_input,
|
| 678 |
+
guidance_scale_input,
|
| 679 |
+
num_steps_input,
|
| 680 |
+
generate_button,
|
| 681 |
+
exercises_heading,
|
| 682 |
+
panel_selector,
|
| 683 |
+
exercise_md,
|
| 684 |
+
answer_input,
|
| 685 |
+
submit_button,
|
| 686 |
+
],
|
| 687 |
+
)
|
| 688 |
+
|
| 689 |
panel_selector.change(
|
| 690 |
fn=load_exercise,
|
| 691 |
inputs=[panel_selector, session_state],
|
comic_gen/__init__.py
CHANGED
|
@@ -7,6 +7,7 @@ from . import (
|
|
| 7 |
session,
|
| 8 |
text_backend,
|
| 9 |
trace,
|
|
|
|
| 10 |
)
|
| 11 |
from .models import ValidationError, validate_session_document
|
| 12 |
|
|
@@ -19,6 +20,7 @@ __all__ = [
|
|
| 19 |
"session",
|
| 20 |
"text_backend",
|
| 21 |
"trace",
|
|
|
|
| 22 |
"ValidationError",
|
| 23 |
"validate_session_document",
|
| 24 |
]
|
|
|
|
| 7 |
session,
|
| 8 |
text_backend,
|
| 9 |
trace,
|
| 10 |
+
translation_backend,
|
| 11 |
)
|
| 12 |
from .models import ValidationError, validate_session_document
|
| 13 |
|
|
|
|
| 20 |
"session",
|
| 21 |
"text_backend",
|
| 22 |
"trace",
|
| 23 |
+
"translation_backend",
|
| 24 |
"ValidationError",
|
| 25 |
"validate_session_document",
|
| 26 |
]
|
comic_gen/comics.py
CHANGED
|
@@ -11,6 +11,7 @@ from .text_backend import (
|
|
| 11 |
)
|
| 12 |
from .text_utils import _normalize_model_fields
|
| 13 |
from .backends import deterministic_pipeline
|
|
|
|
| 14 |
from .trace import add_trace
|
| 15 |
from .errors import ModelPipelineError
|
| 16 |
|
|
@@ -94,11 +95,12 @@ def generate_story_pipeline(
|
|
| 94 |
) -> None:
|
| 95 |
options = _normalized_image_options(image_options)
|
| 96 |
options["enable_live_images"] = True
|
|
|
|
| 97 |
logger.info("Running unified model pipeline (text + image)")
|
| 98 |
|
| 99 |
try:
|
| 100 |
generated = generate_text_content_from_article(
|
| 101 |
-
language=
|
| 102 |
style_id=str(document.get("style_id", "minimal")),
|
| 103 |
reading_level=reading_level,
|
| 104 |
article=document.get("article", {}),
|
|
@@ -124,6 +126,19 @@ def generate_story_pipeline(
|
|
| 124 |
document["characters"] = characters
|
| 125 |
document["panels"] = panels
|
| 126 |
document["exercises"] = exercises_data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
add_trace(
|
| 128 |
document,
|
| 129 |
"model_pipeline",
|
|
@@ -163,3 +178,16 @@ def generate_story_pipeline(
|
|
| 163 |
document,
|
| 164 |
)
|
| 165 |
document.setdefault("simplified", {})["level"] = reading_level
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
)
|
| 12 |
from .text_utils import _normalize_model_fields
|
| 13 |
from .backends import deterministic_pipeline
|
| 14 |
+
from .translation_backend import translate_session_content
|
| 15 |
from .trace import add_trace
|
| 16 |
from .errors import ModelPipelineError
|
| 17 |
|
|
|
|
| 95 |
) -> None:
|
| 96 |
options = _normalized_image_options(image_options)
|
| 97 |
options["enable_live_images"] = True
|
| 98 |
+
target_language = str(document.get("language", "en"))
|
| 99 |
logger.info("Running unified model pipeline (text + image)")
|
| 100 |
|
| 101 |
try:
|
| 102 |
generated = generate_text_content_from_article(
|
| 103 |
+
language="en",
|
| 104 |
style_id=str(document.get("style_id", "minimal")),
|
| 105 |
reading_level=reading_level,
|
| 106 |
article=document.get("article", {}),
|
|
|
|
| 126 |
document["characters"] = characters
|
| 127 |
document["panels"] = panels
|
| 128 |
document["exercises"] = exercises_data
|
| 129 |
+
try:
|
| 130 |
+
translate_session_content(
|
| 131 |
+
document,
|
| 132 |
+
target_language,
|
| 133 |
+
source_language="en",
|
| 134 |
+
)
|
| 135 |
+
except Exception as exc:
|
| 136 |
+
add_trace(
|
| 137 |
+
document,
|
| 138 |
+
"translation",
|
| 139 |
+
"fallback",
|
| 140 |
+
f"Translation failed, keeping canonical content: {exc}",
|
| 141 |
+
)
|
| 142 |
add_trace(
|
| 143 |
document,
|
| 144 |
"model_pipeline",
|
|
|
|
| 178 |
document,
|
| 179 |
)
|
| 180 |
document.setdefault("simplified", {})["level"] = reading_level
|
| 181 |
+
try:
|
| 182 |
+
translate_session_content(
|
| 183 |
+
document,
|
| 184 |
+
target_language,
|
| 185 |
+
source_language=str(document.get("language", target_language)),
|
| 186 |
+
)
|
| 187 |
+
except Exception as translate_exc:
|
| 188 |
+
add_trace(
|
| 189 |
+
document,
|
| 190 |
+
"translation",
|
| 191 |
+
"fallback",
|
| 192 |
+
f"Translation failed after deterministic fallback: {translate_exc}",
|
| 193 |
+
)
|
comic_gen/exercise.py
CHANGED
|
@@ -5,6 +5,43 @@ from typing import Any
|
|
| 5 |
|
| 6 |
from .trace import add_trace
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
def _pick_keyword(document: dict[str, Any], fallback: str) -> str:
|
| 10 |
keywords = document.get("simplified", {}).get("keywords", [])
|
|
@@ -77,19 +114,20 @@ def evaluate_answer(
|
|
| 77 |
None,
|
| 78 |
)
|
| 79 |
if not exercise:
|
| 80 |
-
return False,
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
expected = exercise["answer_key"][0]
|
| 83 |
normalized_user = user_answer.strip().lower()
|
| 84 |
normalized_expected = expected.strip().lower()
|
| 85 |
ok = normalized_user == normalized_expected
|
|
|
|
| 86 |
if ok:
|
| 87 |
-
return True,
|
| 88 |
|
| 89 |
return (
|
| 90 |
False,
|
| 91 |
-
(
|
| 92 |
-
"Not yet. Try again with a central word from the panel "
|
| 93 |
-
f"(expected: {expected})."
|
| 94 |
-
),
|
| 95 |
)
|
|
|
|
| 5 |
|
| 6 |
from .trace import add_trace
|
| 7 |
|
| 8 |
+
FEEDBACK_TRANSLATIONS = {
|
| 9 |
+
"en": {
|
| 10 |
+
"missing": "Exercise not found for the selected panel.",
|
| 11 |
+
"correct": "Correct. Great writing practice.",
|
| 12 |
+
"retry": "Not yet. Try again with a central word from the panel (expected: {expected}).",
|
| 13 |
+
},
|
| 14 |
+
"pt": {
|
| 15 |
+
"missing": "Exercicio nao encontrado para o quadro selecionado.",
|
| 16 |
+
"correct": "Correto. Otima pratica de escrita.",
|
| 17 |
+
"retry": "Ainda nao. Tente outra vez com uma palavra central do quadro (esperado: {expected}).",
|
| 18 |
+
},
|
| 19 |
+
"es": {
|
| 20 |
+
"missing": "No se encontro ejercicio para la vineta seleccionada.",
|
| 21 |
+
"correct": "Correcto. Buena practica de escritura.",
|
| 22 |
+
"retry": "Todavia no. Intenta otra vez con una palabra central de la vineta (esperado: {expected}).",
|
| 23 |
+
},
|
| 24 |
+
"fr": {
|
| 25 |
+
"missing": "Aucun exercice trouve pour la case choisie.",
|
| 26 |
+
"correct": "Correct. Bonne pratique d'ecriture.",
|
| 27 |
+
"retry": "Pas encore. Reessayez avec un mot central de la case (attendu: {expected}).",
|
| 28 |
+
},
|
| 29 |
+
"de": {
|
| 30 |
+
"missing": "Keine Uebung fuer das ausgewaehlte Bild gefunden.",
|
| 31 |
+
"correct": "Richtig. Gute Schreibuebung.",
|
| 32 |
+
"retry": "Noch nicht. Versuche es mit einem wichtigen Wort aus dem Bild (erwartet: {expected}).",
|
| 33 |
+
},
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _feedback_text(language: str, key: str, **kwargs: str) -> str:
|
| 38 |
+
"""Return localized exercise feedback text."""
|
| 39 |
+
template = FEEDBACK_TRANSLATIONS.get(language, FEEDBACK_TRANSLATIONS["en"]).get(
|
| 40 |
+
key,
|
| 41 |
+
FEEDBACK_TRANSLATIONS["en"][key],
|
| 42 |
+
)
|
| 43 |
+
return template.format(**kwargs)
|
| 44 |
+
|
| 45 |
|
| 46 |
def _pick_keyword(document: dict[str, Any], fallback: str) -> str:
|
| 47 |
keywords = document.get("simplified", {}).get("keywords", [])
|
|
|
|
| 114 |
None,
|
| 115 |
)
|
| 116 |
if not exercise:
|
| 117 |
+
return False, _feedback_text(
|
| 118 |
+
str(document.get("language", "en")),
|
| 119 |
+
"missing",
|
| 120 |
+
)
|
| 121 |
|
| 122 |
expected = exercise["answer_key"][0]
|
| 123 |
normalized_user = user_answer.strip().lower()
|
| 124 |
normalized_expected = expected.strip().lower()
|
| 125 |
ok = normalized_user == normalized_expected
|
| 126 |
+
language = str(document.get("language", "en"))
|
| 127 |
if ok:
|
| 128 |
+
return True, _feedback_text(language, "correct")
|
| 129 |
|
| 130 |
return (
|
| 131 |
False,
|
| 132 |
+
_feedback_text(language, "retry", expected=expected),
|
|
|
|
|
|
|
|
|
|
| 133 |
)
|
comic_gen/image_backend.py
CHANGED
|
@@ -43,11 +43,11 @@ def build_image_prompt(document: dict[str, Any], panel: dict[str, Any]) -> str:
|
|
| 43 |
scene = _compact_text(panel.get("scene_description", ""), 70)
|
| 44 |
style_id = _compact_text(document.get("style_id", "minimal"), 32)
|
| 45 |
parts = [
|
| 46 |
-
|
| 47 |
"Characters and background only.",
|
| 48 |
f"Scene: {scene}.",
|
| 49 |
f"Keep strict {style_id} style.",
|
| 50 |
-
|
| 51 |
]
|
| 52 |
return " ".join(part for part in parts if part and not part.endswith(": "))
|
| 53 |
|
|
@@ -281,11 +281,13 @@ def _generate_panel_image(
|
|
| 281 |
num_inference_steps: int,
|
| 282 |
use_serverless_api: bool,
|
| 283 |
) -> tuple[str, int, str]:
|
|
|
|
|
|
|
| 284 |
chosen_seed = random.randint(0, MAX_SEED) if randomize_seed else seed
|
| 285 |
logger.info(f'\n\nIMAGE gen prompt:\n {prompt}\n\n')
|
| 286 |
|
| 287 |
last_error: BaseException | None = None
|
| 288 |
-
for attempt in range(1,
|
| 289 |
started = perf_counter()
|
| 290 |
try:
|
| 291 |
if use_serverless_api:
|
|
|
|
| 43 |
scene = _compact_text(panel.get("scene_description", ""), 70)
|
| 44 |
style_id = _compact_text(document.get("style_id", "minimal"), 32)
|
| 45 |
parts = [
|
| 46 |
+
"Plain comic scene only.",
|
| 47 |
"Characters and background only.",
|
| 48 |
f"Scene: {scene}.",
|
| 49 |
f"Keep strict {style_id} style.",
|
| 50 |
+
"Empty margins for later overlay.",
|
| 51 |
]
|
| 52 |
return " ".join(part for part in parts if part and not part.endswith(": "))
|
| 53 |
|
|
|
|
| 281 |
num_inference_steps: int,
|
| 282 |
use_serverless_api: bool,
|
| 283 |
) -> tuple[str, int, str]:
|
| 284 |
+
if isinstance(model_repo_id, (list, tuple)):
|
| 285 |
+
model_repo_id = str(model_repo_id[0] if model_repo_id else "")
|
| 286 |
chosen_seed = random.randint(0, MAX_SEED) if randomize_seed else seed
|
| 287 |
logger.info(f'\n\nIMAGE gen prompt:\n {prompt}\n\n')
|
| 288 |
|
| 289 |
last_error: BaseException | None = None
|
| 290 |
+
for attempt in range(1, 4):
|
| 291 |
started = perf_counter()
|
| 292 |
try:
|
| 293 |
if use_serverless_api:
|
comic_gen/prompts.py
CHANGED
|
@@ -19,13 +19,15 @@ UNIFIED_SESSION_PROMPT = (
|
|
| 19 |
"- Do NOT output any conversational text before or after the JSON structure.\n"
|
| 20 |
"- Use strict double quotes (\") for all keys and string values.\n"
|
| 21 |
"- Generate exactly {panel_count} panels.\n"
|
|
|
|
|
|
|
| 22 |
"- Set simplified.level exactly to {reading_level}.\n"
|
| 23 |
"- Length of the 'panels' list must match the length of the 'exercises' list.\n"
|
| 24 |
"- For each panel_id, there needs to be exactly one exercise in the 'exercises' list.\n"
|
| 25 |
"- There can be 1-3 characters.\n\n"
|
| 26 |
|
| 27 |
"### Context Parameters\n"
|
| 28 |
-
"-
|
| 29 |
"- Comic Style ID: {style_id}\n\n"
|
| 30 |
"- Reading Level: {reading_level}\n\n"
|
| 31 |
|
|
|
|
| 19 |
"- Do NOT output any conversational text before or after the JSON structure.\n"
|
| 20 |
"- Use strict double quotes (\") for all keys and string values.\n"
|
| 21 |
"- Generate exactly {panel_count} panels.\n"
|
| 22 |
+
"- Each must have at least one dialogue line and one bubble.\n"
|
| 23 |
+
"- Generate canonical learner content in English for stable parsing.\n"
|
| 24 |
"- Set simplified.level exactly to {reading_level}.\n"
|
| 25 |
"- Length of the 'panels' list must match the length of the 'exercises' list.\n"
|
| 26 |
"- For each panel_id, there needs to be exactly one exercise in the 'exercises' list.\n"
|
| 27 |
"- There can be 1-3 characters.\n\n"
|
| 28 |
|
| 29 |
"### Context Parameters\n"
|
| 30 |
+
"- Canonical Generation Language Code: {language}\n"
|
| 31 |
"- Comic Style ID: {style_id}\n\n"
|
| 32 |
"- Reading Level: {reading_level}\n\n"
|
| 33 |
|
comic_gen/text_utils.py
CHANGED
|
@@ -78,6 +78,205 @@ class ComicResponse(BaseModel):
|
|
| 78 |
exercises: List[Exercise]
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
def load_json_article(language: str) -> dict[str, str]:
|
| 82 |
repo_root = Path(__file__).resolve().parents[1]
|
| 83 |
example_path = repo_root / "examples" / f"{language}_demo.json"
|
|
@@ -346,8 +545,8 @@ def _box_overlap_ratio(first: list[int], second: list[int]) -> float:
|
|
| 346 |
|
| 347 |
def _normalize_bbox(
|
| 348 |
raw: Any,
|
| 349 |
-
min_width: int =
|
| 350 |
-
min_height: int =
|
| 351 |
) -> tuple[list[int], bool]:
|
| 352 |
"""Normalize one bubble box and report whether it needed repair."""
|
| 353 |
if not isinstance(raw, list) or len(raw) != 4:
|
|
@@ -460,89 +659,3 @@ def extract_json_object(raw_text: str) -> dict[str, Any]:
|
|
| 460 |
return json_dict
|
| 461 |
|
| 462 |
raise UnifiedGenerationError("model output is not valid JSON")
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
# def extract_json_object(raw_text: str) -> dict[str, Any]:
|
| 466 |
-
# start = raw_text.find("{")
|
| 467 |
-
# end = raw_text.rfind("}")
|
| 468 |
-
# if start == -1 or end == -1 or end <= start:
|
| 469 |
-
# raise UnifiedGenerationError("model output missing JSON object")
|
| 470 |
-
# try:
|
| 471 |
-
# parsed = json.loads(raw_text[start:end + 1])
|
| 472 |
-
# except json.JSONDecodeError as exc:
|
| 473 |
-
# logger.info("INPUT to parse JSON: %s", raw_text[start:end + 1])
|
| 474 |
-
# raise UnifiedGenerationError("model output is not valid JSON") from exc
|
| 475 |
-
# if not isinstance(parsed, dict):
|
| 476 |
-
# raise UnifiedGenerationError("model output root must be object")
|
| 477 |
-
# return parsed
|
| 478 |
-
|
| 479 |
-
# def extract_json_object(raw_text: str) -> dict[str, Any]:
|
| 480 |
-
# def _balanced_json_objects(text: str) -> list[str]:
|
| 481 |
-
# objects: list[str] = []
|
| 482 |
-
# start_idx = -1
|
| 483 |
-
# depth = 0
|
| 484 |
-
# in_string = False
|
| 485 |
-
# escaped = False
|
| 486 |
-
|
| 487 |
-
# for idx, char in enumerate(text):
|
| 488 |
-
# if in_string:
|
| 489 |
-
# if escaped:
|
| 490 |
-
# escaped = False
|
| 491 |
-
# continue
|
| 492 |
-
# if char == "\\":
|
| 493 |
-
# escaped = True
|
| 494 |
-
# elif char == '"':
|
| 495 |
-
# in_string = False
|
| 496 |
-
# continue
|
| 497 |
-
|
| 498 |
-
# if char == '"':
|
| 499 |
-
# in_string = True
|
| 500 |
-
# elif char == "{":
|
| 501 |
-
# if depth == 0:
|
| 502 |
-
# start_idx = idx
|
| 503 |
-
# depth += 1
|
| 504 |
-
# elif char == "}" and depth > 0:
|
| 505 |
-
# depth -= 1
|
| 506 |
-
# if depth == 0 and start_idx != -1:
|
| 507 |
-
# objects.append(text[start_idx: idx + 1])
|
| 508 |
-
# start_idx = -1
|
| 509 |
-
|
| 510 |
-
# return objects
|
| 511 |
-
|
| 512 |
-
# candidates: list[str] = []
|
| 513 |
-
# fenced_matches = re.findall(r"```(?:json)?\\s*([\\s\\S]*?)```", raw_text)
|
| 514 |
-
# candidates.extend(
|
| 515 |
-
# match.strip()
|
| 516 |
-
# for match in fenced_matches
|
| 517 |
-
# if match.strip()
|
| 518 |
-
# )
|
| 519 |
-
# candidates.append(raw_text)
|
| 520 |
-
|
| 521 |
-
# decoder = json.JSONDecoder()
|
| 522 |
-
# parse_errors: list[str] = []
|
| 523 |
-
|
| 524 |
-
# for candidate in candidates:
|
| 525 |
-
# for blob in _balanced_json_objects(candidate):
|
| 526 |
-
# try:
|
| 527 |
-
# parsed, end_idx = decoder.raw_decode(blob)
|
| 528 |
-
# if blob[end_idx:].strip():
|
| 529 |
-
# parse_errors.append("trailing_non_json_content")
|
| 530 |
-
# continue
|
| 531 |
-
# if not isinstance(parsed, dict):
|
| 532 |
-
# parse_errors.append("root_not_object")
|
| 533 |
-
# continue
|
| 534 |
-
# return parsed
|
| 535 |
-
# except json.JSONDecodeError as exc:
|
| 536 |
-
# context_start = max(0, exc.pos - 40)
|
| 537 |
-
# context_end = min(len(blob), exc.pos + 40)
|
| 538 |
-
# context = blob[context_start:context_end]
|
| 539 |
-
# parse_errors.append(
|
| 540 |
-
# f"{exc.msg} at line {exc.lineno}, col {exc.colno}, "
|
| 541 |
-
# f"pos {exc.pos}; context={context!r}"
|
| 542 |
-
# )
|
| 543 |
-
|
| 544 |
-
# if "{" not in raw_text or "}" not in raw_text:
|
| 545 |
-
# raise UnifiedGenerationError("model output missing JSON object")
|
| 546 |
-
|
| 547 |
-
# logger.info("Failed JSON parse details: %s", " | ".join(parse_errors))
|
| 548 |
-
# raise UnifiedGenerationError("model output is not valid JSON")
|
|
|
|
| 78 |
exercises: List[Exercise]
|
| 79 |
|
| 80 |
|
| 81 |
+
UI_TRANSLATIONS = {
|
| 82 |
+
"en": {
|
| 83 |
+
"app_subtitle": "Turn international news into simple comic practice for adult reading and writing.",
|
| 84 |
+
"language": "Language",
|
| 85 |
+
"art_style": "Art style",
|
| 86 |
+
"reading_level": "Reading level",
|
| 87 |
+
"panel_count": "Panel count",
|
| 88 |
+
"live_feed": "Use live RSS article",
|
| 89 |
+
"advanced_options": "Advanced Options",
|
| 90 |
+
"serverless": "Use HF serverless API for text + image generation",
|
| 91 |
+
"debug": "Debug panel rendering",
|
| 92 |
+
"negative_prompt": "Negative prompt",
|
| 93 |
+
"negative_prompt_placeholder": "Optional quality or style exclusions",
|
| 94 |
+
"seed": "Seed",
|
| 95 |
+
"randomize_seed": "Randomize seed",
|
| 96 |
+
"width": "Width",
|
| 97 |
+
"height": "Height",
|
| 98 |
+
"guidance_scale": "Guidance scale",
|
| 99 |
+
"inference_steps": "Inference steps",
|
| 100 |
+
"generate": "Generate Comic Strip",
|
| 101 |
+
"source": "Source",
|
| 102 |
+
"publisher": "Publisher",
|
| 103 |
+
"title": "Title",
|
| 104 |
+
"link": "Link",
|
| 105 |
+
"published": "Published",
|
| 106 |
+
"summary": "Simplified Summary",
|
| 107 |
+
"keywords": "Keywords",
|
| 108 |
+
"transcript": "Transcript",
|
| 109 |
+
"panel": "Panel",
|
| 110 |
+
"exercises_heading": "### Writing Exercises",
|
| 111 |
+
"select_panel": "Select panel",
|
| 112 |
+
"exercise": "Exercise",
|
| 113 |
+
"generate_first": "Generate a strip first.",
|
| 114 |
+
"unlock_exercises": "Generate a strip to unlock exercises.",
|
| 115 |
+
"no_exercise": "No exercise available for this panel.",
|
| 116 |
+
"answer_label": "Your answer",
|
| 117 |
+
"answer_placeholder": "Type the missing word",
|
| 118 |
+
"submit": "Submit Answer",
|
| 119 |
+
"trace": "Trace / Debug",
|
| 120 |
+
},
|
| 121 |
+
"pt": {
|
| 122 |
+
"app_subtitle": "Transforme noticias internacionais em pratica simples de leitura e escrita com quadrinhos.",
|
| 123 |
+
"language": "Idioma",
|
| 124 |
+
"art_style": "Estilo de arte",
|
| 125 |
+
"reading_level": "Nivel de leitura",
|
| 126 |
+
"panel_count": "Numero de quadros",
|
| 127 |
+
"live_feed": "Usar noticia RSS ao vivo",
|
| 128 |
+
"advanced_options": "Opcoes avancadas",
|
| 129 |
+
"serverless": "Usar API serverless HF para texto + imagem",
|
| 130 |
+
"debug": "Depurar quadros",
|
| 131 |
+
"negative_prompt": "Prompt negativo",
|
| 132 |
+
"negative_prompt_placeholder": "Exclusoes opcionais de qualidade ou estilo",
|
| 133 |
+
"seed": "Semente",
|
| 134 |
+
"randomize_seed": "Semente aleatoria",
|
| 135 |
+
"width": "Largura",
|
| 136 |
+
"height": "Altura",
|
| 137 |
+
"guidance_scale": "Escala de orientacao",
|
| 138 |
+
"inference_steps": "Passos de inferencia",
|
| 139 |
+
"generate": "Gerar quadrinhos",
|
| 140 |
+
"source": "Fonte",
|
| 141 |
+
"publisher": "Publicador",
|
| 142 |
+
"title": "Titulo",
|
| 143 |
+
"link": "Link",
|
| 144 |
+
"published": "Publicado",
|
| 145 |
+
"summary": "Resumo simplificado",
|
| 146 |
+
"keywords": "Palavras-chave",
|
| 147 |
+
"transcript": "Transcricao",
|
| 148 |
+
"panel": "Quadro",
|
| 149 |
+
"exercises_heading": "### Exercicios de escrita",
|
| 150 |
+
"select_panel": "Selecionar quadro",
|
| 151 |
+
"exercise": "Exercicio",
|
| 152 |
+
"generate_first": "Gere uma tira primeiro.",
|
| 153 |
+
"unlock_exercises": "Gere uma tira para liberar os exercicios.",
|
| 154 |
+
"no_exercise": "Nao ha exercicio para este quadro.",
|
| 155 |
+
"answer_label": "Sua resposta",
|
| 156 |
+
"answer_placeholder": "Digite a palavra que falta",
|
| 157 |
+
"submit": "Enviar resposta",
|
| 158 |
+
"trace": "Rastro / Debug",
|
| 159 |
+
},
|
| 160 |
+
"es": {
|
| 161 |
+
"app_subtitle": "Convierte noticias internacionales en practica simple de lectura y escritura con comics.",
|
| 162 |
+
"language": "Idioma",
|
| 163 |
+
"art_style": "Estilo artistico",
|
| 164 |
+
"reading_level": "Nivel de lectura",
|
| 165 |
+
"panel_count": "Numero de vinetas",
|
| 166 |
+
"live_feed": "Usar noticia RSS en vivo",
|
| 167 |
+
"advanced_options": "Opciones avanzadas",
|
| 168 |
+
"serverless": "Usar API serverless HF para texto + imagen",
|
| 169 |
+
"debug": "Depurar vinetas",
|
| 170 |
+
"negative_prompt": "Prompt negativo",
|
| 171 |
+
"negative_prompt_placeholder": "Exclusiones opcionales de calidad o estilo",
|
| 172 |
+
"seed": "Semilla",
|
| 173 |
+
"randomize_seed": "Semilla aleatoria",
|
| 174 |
+
"width": "Ancho",
|
| 175 |
+
"height": "Alto",
|
| 176 |
+
"guidance_scale": "Escala de guia",
|
| 177 |
+
"inference_steps": "Pasos de inferencia",
|
| 178 |
+
"generate": "Generar comic",
|
| 179 |
+
"source": "Fuente",
|
| 180 |
+
"publisher": "Editor",
|
| 181 |
+
"title": "Titulo",
|
| 182 |
+
"link": "Enlace",
|
| 183 |
+
"published": "Publicado",
|
| 184 |
+
"summary": "Resumen simplificado",
|
| 185 |
+
"keywords": "Palabras clave",
|
| 186 |
+
"transcript": "Transcripcion",
|
| 187 |
+
"panel": "Vineta",
|
| 188 |
+
"exercises_heading": "### Ejercicios de escritura",
|
| 189 |
+
"select_panel": "Seleccionar vineta",
|
| 190 |
+
"exercise": "Ejercicio",
|
| 191 |
+
"generate_first": "Primero genera una tira.",
|
| 192 |
+
"unlock_exercises": "Genera una tira para desbloquear ejercicios.",
|
| 193 |
+
"no_exercise": "No hay ejercicio para esta vineta.",
|
| 194 |
+
"answer_label": "Tu respuesta",
|
| 195 |
+
"answer_placeholder": "Escribe la palabra que falta",
|
| 196 |
+
"submit": "Enviar respuesta",
|
| 197 |
+
"trace": "Traza / Debug",
|
| 198 |
+
},
|
| 199 |
+
"fr": {
|
| 200 |
+
"app_subtitle": "Transformez des nouvelles internationales en pratique simple de lecture et d'ecriture avec BD.",
|
| 201 |
+
"language": "Langue",
|
| 202 |
+
"art_style": "Style artistique",
|
| 203 |
+
"reading_level": "Niveau de lecture",
|
| 204 |
+
"panel_count": "Nombre de cases",
|
| 205 |
+
"live_feed": "Utiliser une nouvelle RSS en direct",
|
| 206 |
+
"advanced_options": "Options avancees",
|
| 207 |
+
"serverless": "Utiliser l'API HF serverless pour texte + image",
|
| 208 |
+
"debug": "Debug des cases",
|
| 209 |
+
"negative_prompt": "Prompt negatif",
|
| 210 |
+
"negative_prompt_placeholder": "Exclusions optionnelles de qualite ou de style",
|
| 211 |
+
"seed": "Graine",
|
| 212 |
+
"randomize_seed": "Graine aleatoire",
|
| 213 |
+
"width": "Largeur",
|
| 214 |
+
"height": "Hauteur",
|
| 215 |
+
"guidance_scale": "Echelle de guidage",
|
| 216 |
+
"inference_steps": "Etapes d'inference",
|
| 217 |
+
"generate": "Generer la BD",
|
| 218 |
+
"source": "Source",
|
| 219 |
+
"publisher": "Editeur",
|
| 220 |
+
"title": "Titre",
|
| 221 |
+
"link": "Lien",
|
| 222 |
+
"published": "Publie",
|
| 223 |
+
"summary": "Resume simplifie",
|
| 224 |
+
"keywords": "Mots cles",
|
| 225 |
+
"transcript": "Transcription",
|
| 226 |
+
"panel": "Case",
|
| 227 |
+
"exercises_heading": "### Exercices d'ecriture",
|
| 228 |
+
"select_panel": "Choisir une case",
|
| 229 |
+
"exercise": "Exercice",
|
| 230 |
+
"generate_first": "Generez d'abord une BD.",
|
| 231 |
+
"unlock_exercises": "Generez une BD pour ouvrir les exercices.",
|
| 232 |
+
"no_exercise": "Aucun exercice pour cette case.",
|
| 233 |
+
"answer_label": "Votre reponse",
|
| 234 |
+
"answer_placeholder": "Tapez le mot manquant",
|
| 235 |
+
"submit": "Envoyer la reponse",
|
| 236 |
+
"trace": "Trace / Debug",
|
| 237 |
+
},
|
| 238 |
+
"de": {
|
| 239 |
+
"app_subtitle": "Verwandle internationale Nachrichten in einfache Lese- und Schreibuebung mit Comics.",
|
| 240 |
+
"language": "Sprache",
|
| 241 |
+
"art_style": "Kunststil",
|
| 242 |
+
"reading_level": "Leseniveau",
|
| 243 |
+
"panel_count": "Anzahl der Bilder",
|
| 244 |
+
"live_feed": "Live-RSS-Nachricht verwenden",
|
| 245 |
+
"advanced_options": "Erweiterte Optionen",
|
| 246 |
+
"serverless": "HF Serverless API fuer Text + Bild verwenden",
|
| 247 |
+
"debug": "Bilder debuggen",
|
| 248 |
+
"negative_prompt": "Negativer Prompt",
|
| 249 |
+
"negative_prompt_placeholder": "Optionale Qualitaets- oder Stil-Ausschluesse",
|
| 250 |
+
"seed": "Seed",
|
| 251 |
+
"randomize_seed": "Zufaelliger Seed",
|
| 252 |
+
"width": "Breite",
|
| 253 |
+
"height": "Hoehe",
|
| 254 |
+
"guidance_scale": "Fuehrungsstaerke",
|
| 255 |
+
"inference_steps": "Inferenzschritte",
|
| 256 |
+
"generate": "Comic erzeugen",
|
| 257 |
+
"source": "Quelle",
|
| 258 |
+
"publisher": "Herausgeber",
|
| 259 |
+
"title": "Titel",
|
| 260 |
+
"link": "Link",
|
| 261 |
+
"published": "Veroeffentlicht",
|
| 262 |
+
"summary": "Vereinfachte Zusammenfassung",
|
| 263 |
+
"keywords": "Schluesselwoerter",
|
| 264 |
+
"transcript": "Transkript",
|
| 265 |
+
"panel": "Bild",
|
| 266 |
+
"exercises_heading": "### Schreibuebungen",
|
| 267 |
+
"select_panel": "Bild auswaehlen",
|
| 268 |
+
"exercise": "Uebung",
|
| 269 |
+
"generate_first": "Erzeuge zuerst einen Comic.",
|
| 270 |
+
"unlock_exercises": "Erzeuge einen Comic, um Uebungen zu oeffnen.",
|
| 271 |
+
"no_exercise": "Keine Uebung fuer dieses Bild.",
|
| 272 |
+
"answer_label": "Deine Antwort",
|
| 273 |
+
"answer_placeholder": "Gib das fehlende Wort ein",
|
| 274 |
+
"submit": "Antwort senden",
|
| 275 |
+
"trace": "Trace / Debug",
|
| 276 |
+
},
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
|
| 280 |
def load_json_article(language: str) -> dict[str, str]:
|
| 281 |
repo_root = Path(__file__).resolve().parents[1]
|
| 282 |
example_path = repo_root / "examples" / f"{language}_demo.json"
|
|
|
|
| 545 |
|
| 546 |
def _normalize_bbox(
|
| 547 |
raw: Any,
|
| 548 |
+
min_width: int = 82,
|
| 549 |
+
min_height: int = 24,
|
| 550 |
) -> tuple[list[int], bool]:
|
| 551 |
"""Normalize one bubble box and report whether it needed repair."""
|
| 552 |
if not isinstance(raw, list) or len(raw) != 4:
|
|
|
|
| 659 |
return json_dict
|
| 660 |
|
| 661 |
raise UnifiedGenerationError("model output is not valid JSON")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
comic_gen/translation_backend.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from .trace import add_trace
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
DEFAULT_TRANSLATION_MODEL_ID = "facebook/nllb-200-distilled-600M"
|
| 11 |
+
NLLB_LANGUAGE_CODES = {
|
| 12 |
+
"en": "eng_Latn",
|
| 13 |
+
"pt": "por_Latn",
|
| 14 |
+
"es": "spa_Latn",
|
| 15 |
+
"fr": "fra_Latn",
|
| 16 |
+
"de": "deu_Latn",
|
| 17 |
+
}
|
| 18 |
+
PLACEHOLDER_TOKEN = "__BLANK_PLACEHOLDER__"
|
| 19 |
+
_PIPELINES: dict[tuple[str, str, str], Any] = {}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _get_translation_pipeline(
|
| 23 |
+
model_id: str,
|
| 24 |
+
source_language: str,
|
| 25 |
+
target_language: str,
|
| 26 |
+
) -> Any:
|
| 27 |
+
"""Load and cache a translation pipeline.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
model_id: Hugging Face model id.
|
| 31 |
+
source_language: NLLB source language code.
|
| 32 |
+
target_language: NLLB target language code.
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
A callable Transformers translation pipeline.
|
| 36 |
+
"""
|
| 37 |
+
cache_key = (model_id, source_language, target_language)
|
| 38 |
+
if cache_key in _PIPELINES:
|
| 39 |
+
return _PIPELINES[cache_key]
|
| 40 |
+
|
| 41 |
+
from transformers import pipeline
|
| 42 |
+
|
| 43 |
+
translator = pipeline(
|
| 44 |
+
"translation",
|
| 45 |
+
model=model_id,
|
| 46 |
+
src_lang=source_language,
|
| 47 |
+
tgt_lang=target_language,
|
| 48 |
+
)
|
| 49 |
+
_PIPELINES[cache_key] = translator
|
| 50 |
+
return translator
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _translation_result_text(result: Any) -> str:
|
| 54 |
+
"""Extract text from a Transformers translation result."""
|
| 55 |
+
if isinstance(result, list) and result:
|
| 56 |
+
first = result[0]
|
| 57 |
+
if isinstance(first, dict):
|
| 58 |
+
return str(first.get("translation_text", "")).strip()
|
| 59 |
+
if isinstance(result, dict):
|
| 60 |
+
return str(result.get("translation_text", "")).strip()
|
| 61 |
+
return str(result or "").strip()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def translate_text(
|
| 65 |
+
text: Any,
|
| 66 |
+
target_language: str,
|
| 67 |
+
*,
|
| 68 |
+
source_language: str = "en",
|
| 69 |
+
model_id: str = DEFAULT_TRANSLATION_MODEL_ID,
|
| 70 |
+
preserve_blanks: bool = False,
|
| 71 |
+
) -> str:
|
| 72 |
+
"""Translate text to the target language.
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
text: Source text.
|
| 76 |
+
target_language: App language code.
|
| 77 |
+
source_language: App source language code.
|
| 78 |
+
model_id: Hugging Face translation model id.
|
| 79 |
+
preserve_blanks: Whether to protect blank placeholders.
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
Translated text, or the source text for English/no-op cases.
|
| 83 |
+
"""
|
| 84 |
+
value = str(text or "").strip()
|
| 85 |
+
if not value:
|
| 86 |
+
return value
|
| 87 |
+
|
| 88 |
+
source_code = NLLB_LANGUAGE_CODES.get(source_language, "eng_Latn")
|
| 89 |
+
target_code = NLLB_LANGUAGE_CODES.get(target_language, "eng_Latn")
|
| 90 |
+
if source_code == target_code:
|
| 91 |
+
return value
|
| 92 |
+
|
| 93 |
+
protected = value
|
| 94 |
+
if preserve_blanks:
|
| 95 |
+
protected = protected.replace("_______", PLACEHOLDER_TOKEN)
|
| 96 |
+
protected = protected.replace("____", PLACEHOLDER_TOKEN)
|
| 97 |
+
|
| 98 |
+
translator = _get_translation_pipeline(model_id, source_code, target_code)
|
| 99 |
+
translated = _translation_result_text(translator(protected))
|
| 100 |
+
translated = translated or value
|
| 101 |
+
if preserve_blanks:
|
| 102 |
+
translated = translated.replace(PLACEHOLDER_TOKEN, "____")
|
| 103 |
+
if "____" not in translated and "____" in value:
|
| 104 |
+
translated = f"{translated} ____"
|
| 105 |
+
return translated
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def translate_session_content(
|
| 109 |
+
document: dict[str, Any],
|
| 110 |
+
target_language: str,
|
| 111 |
+
*,
|
| 112 |
+
source_language: str = "en",
|
| 113 |
+
model_id: str = DEFAULT_TRANSLATION_MODEL_ID,
|
| 114 |
+
) -> bool:
|
| 115 |
+
"""Translate learner-facing session fields in place.
|
| 116 |
+
|
| 117 |
+
Args:
|
| 118 |
+
document: Session document to mutate.
|
| 119 |
+
target_language: App target language code.
|
| 120 |
+
source_language: App source language code.
|
| 121 |
+
model_id: Hugging Face translation model id.
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
True when translation was applied, False for no-op English.
|
| 125 |
+
"""
|
| 126 |
+
if NLLB_LANGUAGE_CODES.get(source_language) == NLLB_LANGUAGE_CODES.get(
|
| 127 |
+
target_language
|
| 128 |
+
):
|
| 129 |
+
return False
|
| 130 |
+
|
| 131 |
+
add_trace(
|
| 132 |
+
document,
|
| 133 |
+
"translation",
|
| 134 |
+
"start",
|
| 135 |
+
f"Translating learner content to {target_language}",
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
simplified = document.get("simplified", {})
|
| 139 |
+
if isinstance(simplified, dict):
|
| 140 |
+
simplified["summary"] = translate_text(
|
| 141 |
+
simplified.get("summary", ""),
|
| 142 |
+
target_language,
|
| 143 |
+
source_language=source_language,
|
| 144 |
+
model_id=model_id,
|
| 145 |
+
)
|
| 146 |
+
keywords = simplified.get("keywords", [])
|
| 147 |
+
if isinstance(keywords, list):
|
| 148 |
+
simplified["keywords"] = [
|
| 149 |
+
translate_text(
|
| 150 |
+
keyword,
|
| 151 |
+
target_language,
|
| 152 |
+
source_language=source_language,
|
| 153 |
+
model_id=model_id,
|
| 154 |
+
)
|
| 155 |
+
for keyword in keywords
|
| 156 |
+
]
|
| 157 |
+
|
| 158 |
+
for character in document.get("characters", []):
|
| 159 |
+
if not isinstance(character, dict):
|
| 160 |
+
continue
|
| 161 |
+
character["description"] = translate_text(
|
| 162 |
+
character.get("description", ""),
|
| 163 |
+
target_language,
|
| 164 |
+
source_language=source_language,
|
| 165 |
+
model_id=model_id,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
for panel in document.get("panels", []):
|
| 169 |
+
if not isinstance(panel, dict):
|
| 170 |
+
continue
|
| 171 |
+
panel["scene_description"] = translate_text(
|
| 172 |
+
panel.get("scene_description", ""),
|
| 173 |
+
target_language,
|
| 174 |
+
source_language=source_language,
|
| 175 |
+
model_id=model_id,
|
| 176 |
+
)
|
| 177 |
+
for line in panel.get("dialogue", []):
|
| 178 |
+
if not isinstance(line, dict):
|
| 179 |
+
continue
|
| 180 |
+
line["text"] = translate_text(
|
| 181 |
+
line.get("text", ""),
|
| 182 |
+
target_language,
|
| 183 |
+
source_language=source_language,
|
| 184 |
+
model_id=model_id,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
for item in document.get("exercises", []):
|
| 188 |
+
if not isinstance(item, dict):
|
| 189 |
+
continue
|
| 190 |
+
item["prompt"] = translate_text(
|
| 191 |
+
item.get("prompt", ""),
|
| 192 |
+
target_language,
|
| 193 |
+
source_language=source_language,
|
| 194 |
+
model_id=model_id,
|
| 195 |
+
preserve_blanks=True,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
add_trace(
|
| 199 |
+
document,
|
| 200 |
+
"translation",
|
| 201 |
+
"ok",
|
| 202 |
+
f"Translated learner content to {target_language}",
|
| 203 |
+
)
|
| 204 |
+
return True
|
examples/de_demo.json
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": "1.0.0",
|
| 3 |
+
"session_id": "sess-demo-de-001",
|
| 4 |
+
"language": "de",
|
| 5 |
+
"style_id": "minimal",
|
| 6 |
+
"source": {
|
| 7 |
+
"link": "https://www.theguardian.com/technology/2026/jun/08/anti-slop-ai-art",
|
| 8 |
+
"publisher": "The Guardian",
|
| 9 |
+
"published_at": "Mon, 08 Jun 2026 13:00:25 GMT"
|
| 10 |
+
},
|
| 11 |
+
"article": {
|
| 12 |
+
"title": "Kuenstler machen 'Anti-Muell'-Kunst als Protest gegen KI: 'Es wurde uns aufgezwungen'",
|
| 13 |
+
"fulltext": "Als Reaktion auf den Hyperrealismus der KI wenden sich Kuenstler und Kreative dem Selbstgemachten und Unvollkommenen zu. Anfang dieses Jahres traf sich in New York eine Gruppe von Filmemachern, Werberegisseuren und Einflussnehmern der KI-Branche zum Runway AI Summit, einer eintagigen Veranstaltung, die das Potenzial dieser neuen Technologie stark hervorhob. In einem Vortrag sprach Rob Wrubel, Mitgruender und geschaeftsfuehrender Partner der Werbefirma Silverside in San Francisco, ueber seine Arbeit an der KI-generierten Coca-Cola-Werbung Holiday Caravan 2025. Wrubel sagte, das Unglaubliche an KI sei, dass man in nur zwei Wochen vom Drehbuch zur Produktion kommen koenne. Was er nicht erwaehnte: Die Werbung mit computergenerierten Eisbaeren und unecht wirkenden Lieferwagen wurde von fast allen, die sie sahen, stark abgelehnt. Die oeffentliche Ablehnung wurde zu einer eigenen Nachricht. Sie wurde vielleicht schnell entworfen, und genau so sah sie aus. Auf die Reaktion angesprochen, gab Wrubel zu, dass das Gespraech ueber die Werbung fast so wichtig wurde wie die Werbung selbst, weil es Fragen sichtbar machte, mit denen sich die ganze Kreativbranche derzeit beschaeftigt."
|
| 14 |
+
},
|
| 15 |
+
"simplified": {
|
| 16 |
+
"summary": "Erwachsene nutzen das Gartenprogramm, um Lesen und Schreiben in einfachen Schritten zu ueben.",
|
| 17 |
+
"level": "A2",
|
| 18 |
+
"keywords": ["Erwachsene", "Garten", "Lesen", "Schreiben"]
|
| 19 |
+
},
|
| 20 |
+
"characters": [
|
| 21 |
+
{
|
| 22 |
+
"id": "char_guide",
|
| 23 |
+
"name": "Anleiter",
|
| 24 |
+
"description": "Mentor mit einfacher Sprache."
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"id": "char_learner",
|
| 28 |
+
"name": "Lernende",
|
| 29 |
+
"description": "Erwachsene Person, die Lesen und Schreiben uebt."
|
| 30 |
+
}
|
| 31 |
+
],
|
| 32 |
+
"panels": [
|
| 33 |
+
{
|
| 34 |
+
"panel_id": "panel_1",
|
| 35 |
+
"frame_index": 1,
|
| 36 |
+
"scene_description": "Zwei Erwachsene lesen eine kurze Anweisungskarte in einem Garten.",
|
| 37 |
+
"dialogue": [
|
| 38 |
+
{
|
| 39 |
+
"character_id": "char_guide",
|
| 40 |
+
"text": "Wir lesen zusammen eine kurze Anweisung."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"character_id": "char_learner",
|
| 44 |
+
"text": "Jetzt kann ich sie mit eigenen Worten erklaeren."
|
| 45 |
+
}
|
| 46 |
+
],
|
| 47 |
+
"bubbles": [
|
| 48 |
+
{
|
| 49 |
+
"bbox_px": [30, 30, 300, 90]
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"bbox_px": [30, 140, 300, 90]
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
"render": {
|
| 56 |
+
"image_path": "assets/panel_1.png",
|
| 57 |
+
"overlay_applied": true
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"panel_id": "panel_2",
|
| 62 |
+
"frame_index": 2,
|
| 63 |
+
"scene_description": "Die Lernende schreibt einen kurzen Satz ueber die Aktivitaet.",
|
| 64 |
+
"dialogue": [
|
| 65 |
+
{
|
| 66 |
+
"character_id": "char_guide",
|
| 67 |
+
"text": "Schreibe eine wichtige Idee aus dem Artikel."
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"character_id": "char_learner",
|
| 71 |
+
"text": "Der Garten hilft mir jede Woche beim Lesenueben."
|
| 72 |
+
}
|
| 73 |
+
],
|
| 74 |
+
"bubbles": [
|
| 75 |
+
{
|
| 76 |
+
"bbox_px": [30, 30, 300, 90]
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"bbox_px": [30, 140, 300, 90]
|
| 80 |
+
}
|
| 81 |
+
],
|
| 82 |
+
"render": {
|
| 83 |
+
"image_path": "assets/panel_2.png",
|
| 84 |
+
"overlay_applied": true
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"panel_id": "panel_3",
|
| 89 |
+
"frame_index": 3,
|
| 90 |
+
"scene_description": "Die Gruppe teilt, was sie gelernt hat.",
|
| 91 |
+
"dialogue": [
|
| 92 |
+
{
|
| 93 |
+
"character_id": "char_guide",
|
| 94 |
+
"text": "Du hast dein Schreiben mit klaren Worten verbessert."
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"character_id": "char_learner",
|
| 98 |
+
"text": "Ich fuehle mich jetzt sicherer beim Lesen von Nachrichten."
|
| 99 |
+
}
|
| 100 |
+
],
|
| 101 |
+
"bubbles": [
|
| 102 |
+
{
|
| 103 |
+
"bbox_px": [30, 30, 300, 90]
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"bbox_px": [30, 140, 300, 90]
|
| 107 |
+
}
|
| 108 |
+
],
|
| 109 |
+
"render": {
|
| 110 |
+
"image_path": "assets/panel_3.png",
|
| 111 |
+
"overlay_applied": true
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
],
|
| 115 |
+
"exercises": [
|
| 116 |
+
{
|
| 117 |
+
"exercise_id": "ex_panel_1",
|
| 118 |
+
"panel_id": "panel_1",
|
| 119 |
+
"prompt": "Wir lesen zusammen eine kurze ____.",
|
| 120 |
+
"blanks": ["____"],
|
| 121 |
+
"answer_key": ["Anweisung"],
|
| 122 |
+
"feedback_rules": {
|
| 123 |
+
"case_sensitive": false,
|
| 124 |
+
"allow_trim_spaces": true
|
| 125 |
+
}
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"exercise_id": "ex_panel_2",
|
| 129 |
+
"panel_id": "panel_2",
|
| 130 |
+
"prompt": "Schreibe eine wichtige Idee aus dem ____.",
|
| 131 |
+
"blanks": ["____"],
|
| 132 |
+
"answer_key": ["Artikel"],
|
| 133 |
+
"feedback_rules": {
|
| 134 |
+
"case_sensitive": false,
|
| 135 |
+
"allow_trim_spaces": true
|
| 136 |
+
}
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"exercise_id": "ex_panel_3",
|
| 140 |
+
"panel_id": "panel_3",
|
| 141 |
+
"prompt": "Ich fuehle mich jetzt sicherer beim Lesen von ____.",
|
| 142 |
+
"blanks": ["____"],
|
| 143 |
+
"answer_key": ["Nachrichten"],
|
| 144 |
+
"feedback_rules": {
|
| 145 |
+
"case_sensitive": false,
|
| 146 |
+
"allow_trim_spaces": true
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
],
|
| 150 |
+
"trace": [
|
| 151 |
+
{
|
| 152 |
+
"ts": "2026-06-07T00:00:00+00:00",
|
| 153 |
+
"step": "demo",
|
| 154 |
+
"status": "ok",
|
| 155 |
+
"message": "Deterministisches Beispiel"
|
| 156 |
+
}
|
| 157 |
+
]
|
| 158 |
+
}
|
examples/es_demo.json
CHANGED
|
@@ -2,26 +2,26 @@
|
|
| 2 |
"schema_version": "1.0.0",
|
| 3 |
"session_id": "sess-demo-es-001",
|
| 4 |
"language": "es",
|
| 5 |
-
"style_id": "
|
| 6 |
"source": {
|
| 7 |
-
"link": "https://
|
| 8 |
-
"publisher": "
|
| 9 |
-
"published_at": "2026
|
| 10 |
},
|
| 11 |
"article": {
|
| 12 |
-
"title": "
|
| 13 |
-
"fulltext": "
|
| 14 |
},
|
| 15 |
"simplified": {
|
| 16 |
-
"summary": "Las personas adultas
|
| 17 |
"level": "A2",
|
| 18 |
-
"keywords": ["
|
| 19 |
},
|
| 20 |
"characters": [
|
| 21 |
{
|
| 22 |
"id": "char_guide",
|
| 23 |
"name": "Guia",
|
| 24 |
-
"description": "
|
| 25 |
},
|
| 26 |
{
|
| 27 |
"id": "char_learner",
|
|
@@ -33,15 +33,15 @@
|
|
| 33 |
{
|
| 34 |
"panel_id": "panel_1",
|
| 35 |
"frame_index": 1,
|
| 36 |
-
"scene_description": "Dos personas leen una
|
| 37 |
"dialogue": [
|
| 38 |
{
|
| 39 |
"character_id": "char_guide",
|
| 40 |
-
"text": "Leemos una
|
| 41 |
},
|
| 42 |
{
|
| 43 |
"character_id": "char_learner",
|
| 44 |
-
"text": "Ahora
|
| 45 |
}
|
| 46 |
],
|
| 47 |
"bubbles": [
|
|
@@ -60,15 +60,15 @@
|
|
| 60 |
{
|
| 61 |
"panel_id": "panel_2",
|
| 62 |
"frame_index": 2,
|
| 63 |
-
"scene_description": "La estudiante escribe
|
| 64 |
"dialogue": [
|
| 65 |
{
|
| 66 |
"character_id": "char_guide",
|
| 67 |
-
"text": "Escribe una
|
| 68 |
},
|
| 69 |
{
|
| 70 |
"character_id": "char_learner",
|
| 71 |
-
"text": "
|
| 72 |
}
|
| 73 |
],
|
| 74 |
"bubbles": [
|
|
@@ -87,15 +87,15 @@
|
|
| 87 |
{
|
| 88 |
"panel_id": "panel_3",
|
| 89 |
"frame_index": 3,
|
| 90 |
-
"scene_description": "El grupo comparte lo
|
| 91 |
"dialogue": [
|
| 92 |
{
|
| 93 |
"character_id": "char_guide",
|
| 94 |
-
"text": "
|
| 95 |
},
|
| 96 |
{
|
| 97 |
"character_id": "char_learner",
|
| 98 |
-
"text": "
|
| 99 |
}
|
| 100 |
],
|
| 101 |
"bubbles": [
|
|
@@ -116,9 +116,9 @@
|
|
| 116 |
{
|
| 117 |
"exercise_id": "ex_panel_1",
|
| 118 |
"panel_id": "panel_1",
|
| 119 |
-
"prompt": "Leemos una
|
| 120 |
"blanks": ["____"],
|
| 121 |
-
"answer_key": ["
|
| 122 |
"feedback_rules": {
|
| 123 |
"case_sensitive": false,
|
| 124 |
"allow_trim_spaces": true
|
|
@@ -127,9 +127,9 @@
|
|
| 127 |
{
|
| 128 |
"exercise_id": "ex_panel_2",
|
| 129 |
"panel_id": "panel_2",
|
| 130 |
-
"prompt": "
|
| 131 |
"blanks": ["____"],
|
| 132 |
-
"answer_key": ["
|
| 133 |
"feedback_rules": {
|
| 134 |
"case_sensitive": false,
|
| 135 |
"allow_trim_spaces": true
|
|
@@ -138,9 +138,9 @@
|
|
| 138 |
{
|
| 139 |
"exercise_id": "ex_panel_3",
|
| 140 |
"panel_id": "panel_3",
|
| 141 |
-
"prompt": "
|
| 142 |
"blanks": ["____"],
|
| 143 |
-
"answer_key": ["
|
| 144 |
"feedback_rules": {
|
| 145 |
"case_sensitive": false,
|
| 146 |
"allow_trim_spaces": true
|
|
|
|
| 2 |
"schema_version": "1.0.0",
|
| 3 |
"session_id": "sess-demo-es-001",
|
| 4 |
"language": "es",
|
| 5 |
+
"style_id": "minimal",
|
| 6 |
"source": {
|
| 7 |
+
"link": "https://www.theguardian.com/technology/2026/jun/08/anti-slop-ai-art",
|
| 8 |
+
"publisher": "The Guardian",
|
| 9 |
+
"published_at": "Mon, 08 Jun 2026 13:00:25 GMT"
|
| 10 |
},
|
| 11 |
"article": {
|
| 12 |
+
"title": "Los artistas crean arte 'anti-basura' para rebelarse contra la IA: 'Nos lo han impuesto a la fuerza'",
|
| 13 |
+
"fulltext": "En respuesta al hiperrealismo de la IA, artistas y creativos se acercan a lo casero y lo imperfecto. A comienzos de este ano, un grupo de cineastas, directores comerciales e influyentes de la industria de la IA se reunio en Nueva York para la Runway AI Summit, un evento de un dia que exageraba el potencial de esta nueva tecnologia. Durante una charla, Rob Wrubel, cofundador y socio director de la agencia de publicidad Silverside de San Francisco, hablo de su trabajo en el anuncio Holiday Caravan 2025 de Coca-Cola, generado con IA. Wrubel dijo que lo increible de la IA es que permite pasar del guion a la produccion en solo dos semanas. Lo que no menciono fue que el anuncio, con osos polares computarizados y camiones de reparto falsos, fue muy rechazado por casi todos los que lo vieron. El rechazo publico se convirtio en una noticia propia. Aunque pudo crearse rapido, tambien se veia asi. Al comentar la reaccion, Wrubel admitio que la conversacion sobre el anuncio llego a ser casi tan importante como el anuncio mismo, porque saco a la luz preguntas que toda la industria creativa esta enfrentando ahora."
|
| 14 |
},
|
| 15 |
"simplified": {
|
| 16 |
+
"summary": "Las personas adultas usan el programa del jardin para practicar lectura y escritura con pasos simples.",
|
| 17 |
"level": "A2",
|
| 18 |
+
"keywords": ["adultos", "jardin", "lectura", "escritura"]
|
| 19 |
},
|
| 20 |
"characters": [
|
| 21 |
{
|
| 22 |
"id": "char_guide",
|
| 23 |
"name": "Guia",
|
| 24 |
+
"description": "Mentor que usa lenguaje claro."
|
| 25 |
},
|
| 26 |
{
|
| 27 |
"id": "char_learner",
|
|
|
|
| 33 |
{
|
| 34 |
"panel_id": "panel_1",
|
| 35 |
"frame_index": 1,
|
| 36 |
+
"scene_description": "Dos personas adultas leen una tarjeta breve de instrucciones en un jardin.",
|
| 37 |
"dialogue": [
|
| 38 |
{
|
| 39 |
"character_id": "char_guide",
|
| 40 |
+
"text": "Leemos juntos una instruccion corta."
|
| 41 |
},
|
| 42 |
{
|
| 43 |
"character_id": "char_learner",
|
| 44 |
+
"text": "Ahora puedo explicarla con mis propias palabras."
|
| 45 |
}
|
| 46 |
],
|
| 47 |
"bubbles": [
|
|
|
|
| 60 |
{
|
| 61 |
"panel_id": "panel_2",
|
| 62 |
"frame_index": 2,
|
| 63 |
+
"scene_description": "La estudiante escribe una frase corta sobre la actividad.",
|
| 64 |
"dialogue": [
|
| 65 |
{
|
| 66 |
"character_id": "char_guide",
|
| 67 |
+
"text": "Escribe una idea clave del articulo."
|
| 68 |
},
|
| 69 |
{
|
| 70 |
"character_id": "char_learner",
|
| 71 |
+
"text": "El jardin me ayuda a practicar lectura cada semana."
|
| 72 |
}
|
| 73 |
],
|
| 74 |
"bubbles": [
|
|
|
|
| 87 |
{
|
| 88 |
"panel_id": "panel_3",
|
| 89 |
"frame_index": 3,
|
| 90 |
+
"scene_description": "El grupo comparte lo que aprendio.",
|
| 91 |
"dialogue": [
|
| 92 |
{
|
| 93 |
"character_id": "char_guide",
|
| 94 |
+
"text": "Mejoraste tu escritura con palabras claras."
|
| 95 |
},
|
| 96 |
{
|
| 97 |
"character_id": "char_learner",
|
| 98 |
+
"text": "Ahora tengo mas confianza para leer noticias."
|
| 99 |
}
|
| 100 |
],
|
| 101 |
"bubbles": [
|
|
|
|
| 116 |
{
|
| 117 |
"exercise_id": "ex_panel_1",
|
| 118 |
"panel_id": "panel_1",
|
| 119 |
+
"prompt": "Leemos juntos una ____ corta.",
|
| 120 |
"blanks": ["____"],
|
| 121 |
+
"answer_key": ["instruccion"],
|
| 122 |
"feedback_rules": {
|
| 123 |
"case_sensitive": false,
|
| 124 |
"allow_trim_spaces": true
|
|
|
|
| 127 |
{
|
| 128 |
"exercise_id": "ex_panel_2",
|
| 129 |
"panel_id": "panel_2",
|
| 130 |
+
"prompt": "Escribe una idea clave del ____.",
|
| 131 |
"blanks": ["____"],
|
| 132 |
+
"answer_key": ["articulo"],
|
| 133 |
"feedback_rules": {
|
| 134 |
"case_sensitive": false,
|
| 135 |
"allow_trim_spaces": true
|
|
|
|
| 138 |
{
|
| 139 |
"exercise_id": "ex_panel_3",
|
| 140 |
"panel_id": "panel_3",
|
| 141 |
+
"prompt": "Ahora tengo mas confianza para leer ____.",
|
| 142 |
"blanks": ["____"],
|
| 143 |
+
"answer_key": ["noticias"],
|
| 144 |
"feedback_rules": {
|
| 145 |
"case_sensitive": false,
|
| 146 |
"allow_trim_spaces": true
|
examples/fr_demo.json
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": "1.0.0",
|
| 3 |
+
"session_id": "sess-demo-fr-001",
|
| 4 |
+
"language": "fr",
|
| 5 |
+
"style_id": "minimal",
|
| 6 |
+
"source": {
|
| 7 |
+
"link": "https://www.theguardian.com/technology/2026/jun/08/anti-slop-ai-art",
|
| 8 |
+
"publisher": "The Guardian",
|
| 9 |
+
"published_at": "Mon, 08 Jun 2026 13:00:25 GMT"
|
| 10 |
+
},
|
| 11 |
+
"article": {
|
| 12 |
+
"title": "Des artistes creent de l'art 'anti-boue' pour se rebeller contre l'IA : 'On nous l'a impose'",
|
| 13 |
+
"fulltext": "En reaction a l'hyperrealisme de l'IA, des artistes et des creatifs se tournent vers le fait maison et l'imparfait. Au debut de cette annee, un groupe de cineastes, de realisateurs publicitaires et d'influenceurs de l'industrie de l'IA s'est reuni a New York pour le Runway AI Summit, un evenement d'une journee qui vantait le potentiel de cette nouvelle technologie. Pendant une intervention, Rob Wrubel, cofondateur et associe directeur de l'agence de publicite Silverside a San Francisco, a parle de son travail sur la publicite Holiday Caravan 2025 de Coca-Cola, generee par IA. Wrubel a dit que ce qui est incroyable avec l'IA, c'est que l'on peut passer du script a la production en seulement deux semaines. Ce qu'il n'a pas mentionne, c'est que la publicite, avec ses ours polaires informatises et ses camions de livraison peu credibles, a ete largement detestee par presque tous ceux qui l'ont vue. Le rejet public est devenu une information en soi. Elle a peut-etre ete concue rapidement, et cela se voyait. Interroge sur la reaction, Wrubel a admis que la conversation autour de la publicite etait devenue presque aussi importante que la publicite elle-meme, car elle a souleve des questions que toute l'industrie creative se pose actuellement."
|
| 14 |
+
},
|
| 15 |
+
"simplified": {
|
| 16 |
+
"summary": "Des adultes utilisent le programme du jardin pour pratiquer la lecture et l'ecriture avec des etapes simples.",
|
| 17 |
+
"level": "A2",
|
| 18 |
+
"keywords": ["adultes", "jardin", "lecture", "ecriture"]
|
| 19 |
+
},
|
| 20 |
+
"characters": [
|
| 21 |
+
{
|
| 22 |
+
"id": "char_guide",
|
| 23 |
+
"name": "Guide",
|
| 24 |
+
"description": "Mentor qui utilise un langage clair."
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"id": "char_learner",
|
| 28 |
+
"name": "Apprenant",
|
| 29 |
+
"description": "Adulte qui pratique l'alphabetisation."
|
| 30 |
+
}
|
| 31 |
+
],
|
| 32 |
+
"panels": [
|
| 33 |
+
{
|
| 34 |
+
"panel_id": "panel_1",
|
| 35 |
+
"frame_index": 1,
|
| 36 |
+
"scene_description": "Deux adultes lisent une courte carte d'instructions dans un jardin.",
|
| 37 |
+
"dialogue": [
|
| 38 |
+
{
|
| 39 |
+
"character_id": "char_guide",
|
| 40 |
+
"text": "Nous lisons ensemble une courte consigne."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"character_id": "char_learner",
|
| 44 |
+
"text": "Maintenant je peux l'expliquer avec mes propres mots."
|
| 45 |
+
}
|
| 46 |
+
],
|
| 47 |
+
"bubbles": [
|
| 48 |
+
{
|
| 49 |
+
"bbox_px": [30, 30, 300, 90]
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"bbox_px": [30, 140, 300, 90]
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
"render": {
|
| 56 |
+
"image_path": "assets/panel_1.png",
|
| 57 |
+
"overlay_applied": true
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"panel_id": "panel_2",
|
| 62 |
+
"frame_index": 2,
|
| 63 |
+
"scene_description": "L'apprenant ecrit une courte phrase sur l'activite.",
|
| 64 |
+
"dialogue": [
|
| 65 |
+
{
|
| 66 |
+
"character_id": "char_guide",
|
| 67 |
+
"text": "Ecris une idee importante de l'article."
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"character_id": "char_learner",
|
| 71 |
+
"text": "Le jardin m'aide a pratiquer la lecture chaque semaine."
|
| 72 |
+
}
|
| 73 |
+
],
|
| 74 |
+
"bubbles": [
|
| 75 |
+
{
|
| 76 |
+
"bbox_px": [30, 30, 300, 90]
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"bbox_px": [30, 140, 300, 90]
|
| 80 |
+
}
|
| 81 |
+
],
|
| 82 |
+
"render": {
|
| 83 |
+
"image_path": "assets/panel_2.png",
|
| 84 |
+
"overlay_applied": true
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"panel_id": "panel_3",
|
| 89 |
+
"frame_index": 3,
|
| 90 |
+
"scene_description": "Le groupe partage ce qu'il a appris.",
|
| 91 |
+
"dialogue": [
|
| 92 |
+
{
|
| 93 |
+
"character_id": "char_guide",
|
| 94 |
+
"text": "Tu as ameliore ton ecriture avec des mots clairs."
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"character_id": "char_learner",
|
| 98 |
+
"text": "Je me sens plus confiant pour lire les nouvelles maintenant."
|
| 99 |
+
}
|
| 100 |
+
],
|
| 101 |
+
"bubbles": [
|
| 102 |
+
{
|
| 103 |
+
"bbox_px": [30, 30, 300, 90]
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"bbox_px": [30, 140, 300, 90]
|
| 107 |
+
}
|
| 108 |
+
],
|
| 109 |
+
"render": {
|
| 110 |
+
"image_path": "assets/panel_3.png",
|
| 111 |
+
"overlay_applied": true
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
],
|
| 115 |
+
"exercises": [
|
| 116 |
+
{
|
| 117 |
+
"exercise_id": "ex_panel_1",
|
| 118 |
+
"panel_id": "panel_1",
|
| 119 |
+
"prompt": "Nous lisons ensemble une courte ____.",
|
| 120 |
+
"blanks": ["____"],
|
| 121 |
+
"answer_key": ["consigne"],
|
| 122 |
+
"feedback_rules": {
|
| 123 |
+
"case_sensitive": false,
|
| 124 |
+
"allow_trim_spaces": true
|
| 125 |
+
}
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"exercise_id": "ex_panel_2",
|
| 129 |
+
"panel_id": "panel_2",
|
| 130 |
+
"prompt": "Ecris une idee importante de l'____.",
|
| 131 |
+
"blanks": ["____"],
|
| 132 |
+
"answer_key": ["article"],
|
| 133 |
+
"feedback_rules": {
|
| 134 |
+
"case_sensitive": false,
|
| 135 |
+
"allow_trim_spaces": true
|
| 136 |
+
}
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"exercise_id": "ex_panel_3",
|
| 140 |
+
"panel_id": "panel_3",
|
| 141 |
+
"prompt": "Je me sens plus confiant pour lire les ____ maintenant.",
|
| 142 |
+
"blanks": ["____"],
|
| 143 |
+
"answer_key": ["nouvelles"],
|
| 144 |
+
"feedback_rules": {
|
| 145 |
+
"case_sensitive": false,
|
| 146 |
+
"allow_trim_spaces": true
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
],
|
| 150 |
+
"trace": [
|
| 151 |
+
{
|
| 152 |
+
"ts": "2026-06-07T00:00:00+00:00",
|
| 153 |
+
"step": "demo",
|
| 154 |
+
"status": "ok",
|
| 155 |
+
"message": "Exemple deterministe"
|
| 156 |
+
}
|
| 157 |
+
]
|
| 158 |
+
}
|
examples/pt_demo.json
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": "1.0.0",
|
| 3 |
+
"session_id": "sess-demo-pt-001",
|
| 4 |
+
"language": "pt",
|
| 5 |
+
"style_id": "minimal",
|
| 6 |
+
"source": {
|
| 7 |
+
"link": "https://www.theguardian.com/technology/2026/jun/08/anti-slop-ai-art",
|
| 8 |
+
"publisher": "The Guardian",
|
| 9 |
+
"published_at": "Mon, 08 Jun 2026 13:00:25 GMT"
|
| 10 |
+
},
|
| 11 |
+
"article": {
|
| 12 |
+
"title": "Artistas fazem arte 'anti-lixo' para se rebelar contra a IA: 'Foi empurrado garganta abaixo'",
|
| 13 |
+
"fulltext": "Em resposta ao hiper-realismo da IA, artistas e criativos estao se aproximando do caseiro e do imperfeito. No inicio deste ano, um grupo de cineastas, diretores comerciais e influenciadores da industria de IA se reuniu em Nova York para o Runway AI Summit, um evento de um dia que exaltava o potencial dessa nova tecnologia. Durante uma palestra, Rob Wrubel, cofundador e socio-diretor da agencia de publicidade Silverside, em Sao Francisco, falou sobre seu trabalho no anuncio Holiday Caravan 2025 da Coca-Cola, gerado por IA. Wrubel disse que o incrivel da IA e que ela permite ir do roteiro a producao em apenas duas semanas. O que ele nao mencionou foi que o anuncio, com ursos polares computadorizados e caminhoes de entrega falsos, foi muito rejeitado por quase todos que o viram. A rejeicao publica virou uma noticia propria. Talvez tenha sido criado rapidamente, e parecia mesmo. Ao comentar a reacao, Wrubel admitiu que a conversa sobre o anuncio ficou quase tao importante quanto o proprio anuncio, porque mostrou perguntas que toda a industria criativa enfrenta agora."
|
| 14 |
+
},
|
| 15 |
+
"simplified": {
|
| 16 |
+
"summary": "Pessoas adultas usam o programa do jardim para praticar leitura e escrita em passos simples.",
|
| 17 |
+
"level": "A2",
|
| 18 |
+
"keywords": ["adultos", "jardim", "leitura", "escrita"]
|
| 19 |
+
},
|
| 20 |
+
"characters": [
|
| 21 |
+
{
|
| 22 |
+
"id": "char_guide",
|
| 23 |
+
"name": "Guia",
|
| 24 |
+
"description": "Mentor que usa linguagem simples."
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"id": "char_learner",
|
| 28 |
+
"name": "Aprendiz",
|
| 29 |
+
"description": "Pessoa adulta que pratica alfabetizacao."
|
| 30 |
+
}
|
| 31 |
+
],
|
| 32 |
+
"panels": [
|
| 33 |
+
{
|
| 34 |
+
"panel_id": "panel_1",
|
| 35 |
+
"frame_index": 1,
|
| 36 |
+
"scene_description": "Duas pessoas adultas leem um cartao curto de instrucao em um jardim.",
|
| 37 |
+
"dialogue": [
|
| 38 |
+
{
|
| 39 |
+
"character_id": "char_guide",
|
| 40 |
+
"text": "Lemos juntos uma instrucao curta."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"character_id": "char_learner",
|
| 44 |
+
"text": "Agora posso explicar com minhas proprias palavras."
|
| 45 |
+
}
|
| 46 |
+
],
|
| 47 |
+
"bubbles": [
|
| 48 |
+
{
|
| 49 |
+
"bbox_px": [30, 30, 300, 90]
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"bbox_px": [30, 140, 300, 90]
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
"render": {
|
| 56 |
+
"image_path": "assets/panel_1.png",
|
| 57 |
+
"overlay_applied": true
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"panel_id": "panel_2",
|
| 62 |
+
"frame_index": 2,
|
| 63 |
+
"scene_description": "A aprendiz escreve uma frase curta sobre a atividade.",
|
| 64 |
+
"dialogue": [
|
| 65 |
+
{
|
| 66 |
+
"character_id": "char_guide",
|
| 67 |
+
"text": "Escreva uma ideia principal do artigo."
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"character_id": "char_learner",
|
| 71 |
+
"text": "O jardim me ajuda a praticar leitura toda semana."
|
| 72 |
+
}
|
| 73 |
+
],
|
| 74 |
+
"bubbles": [
|
| 75 |
+
{
|
| 76 |
+
"bbox_px": [30, 30, 300, 90]
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"bbox_px": [30, 140, 300, 90]
|
| 80 |
+
}
|
| 81 |
+
],
|
| 82 |
+
"render": {
|
| 83 |
+
"image_path": "assets/panel_2.png",
|
| 84 |
+
"overlay_applied": true
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"panel_id": "panel_3",
|
| 89 |
+
"frame_index": 3,
|
| 90 |
+
"scene_description": "O grupo compartilha o que aprendeu.",
|
| 91 |
+
"dialogue": [
|
| 92 |
+
{
|
| 93 |
+
"character_id": "char_guide",
|
| 94 |
+
"text": "Voce melhorou sua escrita com palavras claras."
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"character_id": "char_learner",
|
| 98 |
+
"text": "Agora me sinto mais confiante para ler noticias."
|
| 99 |
+
}
|
| 100 |
+
],
|
| 101 |
+
"bubbles": [
|
| 102 |
+
{
|
| 103 |
+
"bbox_px": [30, 30, 300, 90]
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"bbox_px": [30, 140, 300, 90]
|
| 107 |
+
}
|
| 108 |
+
],
|
| 109 |
+
"render": {
|
| 110 |
+
"image_path": "assets/panel_3.png",
|
| 111 |
+
"overlay_applied": true
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
],
|
| 115 |
+
"exercises": [
|
| 116 |
+
{
|
| 117 |
+
"exercise_id": "ex_panel_1",
|
| 118 |
+
"panel_id": "panel_1",
|
| 119 |
+
"prompt": "Lemos juntos uma ____ curta.",
|
| 120 |
+
"blanks": ["____"],
|
| 121 |
+
"answer_key": ["instrucao"],
|
| 122 |
+
"feedback_rules": {
|
| 123 |
+
"case_sensitive": false,
|
| 124 |
+
"allow_trim_spaces": true
|
| 125 |
+
}
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"exercise_id": "ex_panel_2",
|
| 129 |
+
"panel_id": "panel_2",
|
| 130 |
+
"prompt": "Escreva uma ideia principal do ____.",
|
| 131 |
+
"blanks": ["____"],
|
| 132 |
+
"answer_key": ["artigo"],
|
| 133 |
+
"feedback_rules": {
|
| 134 |
+
"case_sensitive": false,
|
| 135 |
+
"allow_trim_spaces": true
|
| 136 |
+
}
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"exercise_id": "ex_panel_3",
|
| 140 |
+
"panel_id": "panel_3",
|
| 141 |
+
"prompt": "Agora me sinto mais confiante para ler ____.",
|
| 142 |
+
"blanks": ["____"],
|
| 143 |
+
"answer_key": ["noticias"],
|
| 144 |
+
"feedback_rules": {
|
| 145 |
+
"case_sensitive": false,
|
| 146 |
+
"allow_trim_spaces": true
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
],
|
| 150 |
+
"trace": [
|
| 151 |
+
{
|
| 152 |
+
"ts": "2026-06-07T00:00:00+00:00",
|
| 153 |
+
"step": "demo",
|
| 154 |
+
"status": "ok",
|
| 155 |
+
"message": "Exemplo deterministico"
|
| 156 |
+
}
|
| 157 |
+
]
|
| 158 |
+
}
|
test/test_app_ui_helpers.py
CHANGED
|
@@ -2,15 +2,20 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from app import (
|
| 4 |
READING_LEVEL_OPTIONS,
|
|
|
|
| 5 |
_overlay_bubbles_html,
|
| 6 |
_panel_choices,
|
| 7 |
_panel_image_html,
|
|
|
|
|
|
|
| 8 |
load_exercise,
|
| 9 |
)
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def _document() -> dict:
|
| 13 |
return {
|
|
|
|
| 14 |
"panels": [
|
| 15 |
{
|
| 16 |
"panel_id": "P1",
|
|
@@ -50,10 +55,26 @@ def test_panel_choices_use_canonical_panel_ids():
|
|
| 50 |
]
|
| 51 |
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
def test_reading_level_options_are_a1_to_b2():
|
| 54 |
assert READING_LEVEL_OPTIONS == ["A1", "A2", "B1", "B2"]
|
| 55 |
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
def test_load_exercise_uses_canonical_panel_id():
|
| 58 |
prompt, answer = load_exercise("P1", _document())
|
| 59 |
|
|
@@ -68,6 +89,40 @@ def test_load_exercise_supports_legacy_frame_panel_id():
|
|
| 68 |
assert answer == ""
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
def test_overlay_bubbles_html_uses_inline_styles_and_escapes_text():
|
| 72 |
panel = {
|
| 73 |
"panel_id": "P1",
|
|
|
|
| 2 |
|
| 3 |
from app import (
|
| 4 |
READING_LEVEL_OPTIONS,
|
| 5 |
+
_language_code_for_label,
|
| 6 |
_overlay_bubbles_html,
|
| 7 |
_panel_choices,
|
| 8 |
_panel_image_html,
|
| 9 |
+
_render_transcript,
|
| 10 |
+
_ui_text,
|
| 11 |
load_exercise,
|
| 12 |
)
|
| 13 |
+
from comic_gen.exercise import evaluate_answer
|
| 14 |
|
| 15 |
|
| 16 |
def _document() -> dict:
|
| 17 |
return {
|
| 18 |
+
"language": "en",
|
| 19 |
"panels": [
|
| 20 |
{
|
| 21 |
"panel_id": "P1",
|
|
|
|
| 55 |
]
|
| 56 |
|
| 57 |
|
| 58 |
+
def test_panel_choices_use_localized_panel_label():
|
| 59 |
+
document = _document()
|
| 60 |
+
document["language"] = "es"
|
| 61 |
+
|
| 62 |
+
assert _panel_choices(document) == [
|
| 63 |
+
("Vineta 1", "P1"),
|
| 64 |
+
("Vineta 2", "P2"),
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
def test_reading_level_options_are_a1_to_b2():
|
| 69 |
assert READING_LEVEL_OPTIONS == ["A1", "A2", "B1", "B2"]
|
| 70 |
|
| 71 |
|
| 72 |
+
def test_ui_text_falls_back_to_english():
|
| 73 |
+
assert _language_code_for_label("Deutsch") == "de"
|
| 74 |
+
assert _ui_text("es", "generate") == "Generar comic"
|
| 75 |
+
assert _ui_text("xx", "generate") == "Generate Comic Strip"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
def test_load_exercise_uses_canonical_panel_id():
|
| 79 |
prompt, answer = load_exercise("P1", _document())
|
| 80 |
|
|
|
|
| 89 |
assert answer == ""
|
| 90 |
|
| 91 |
|
| 92 |
+
def test_load_exercise_uses_localized_heading_and_empty_state():
|
| 93 |
+
document = _document()
|
| 94 |
+
document["language"] = "es"
|
| 95 |
+
|
| 96 |
+
prompt, _ = load_exercise("P1", document)
|
| 97 |
+
missing, _ = load_exercise("P2", document)
|
| 98 |
+
|
| 99 |
+
assert prompt.startswith("### Ejercicio")
|
| 100 |
+
assert missing == "No hay ejercicio para esta vineta."
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_render_transcript_uses_localized_heading():
|
| 104 |
+
document = _document()
|
| 105 |
+
document["language"] = "de"
|
| 106 |
+
|
| 107 |
+
transcript = _render_transcript(document)
|
| 108 |
+
|
| 109 |
+
assert transcript.startswith("### Transkript")
|
| 110 |
+
assert "BILD 1" in transcript
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_exercise_feedback_uses_document_language():
|
| 114 |
+
document = _document()
|
| 115 |
+
document["language"] = "es"
|
| 116 |
+
|
| 117 |
+
ok, correct = evaluate_answer(document, "P1", "two")
|
| 118 |
+
retry_ok, retry = evaluate_answer(document, "P1", "wrong")
|
| 119 |
+
|
| 120 |
+
assert ok is True
|
| 121 |
+
assert correct == "Correcto. Buena practica de escritura."
|
| 122 |
+
assert retry_ok is False
|
| 123 |
+
assert "Todavia no." in retry
|
| 124 |
+
|
| 125 |
+
|
| 126 |
def test_overlay_bubbles_html_uses_inline_styles_and_escapes_text():
|
| 127 |
panel = {
|
| 128 |
"panel_id": "P1",
|
test/test_comics_pipeline.py
CHANGED
|
@@ -76,6 +76,7 @@ def test_generate_story_pipeline_passes_reading_level(monkeypatch):
|
|
| 76 |
|
| 77 |
def _fake_text(**kwargs):
|
| 78 |
captured["reading_level"] = kwargs["reading_level"]
|
|
|
|
| 79 |
return _generated(level=kwargs["reading_level"])
|
| 80 |
|
| 81 |
monkeypatch.setattr(
|
|
@@ -97,9 +98,84 @@ def test_generate_story_pipeline_passes_reading_level(monkeypatch):
|
|
| 97 |
)
|
| 98 |
|
| 99 |
assert captured["reading_level"] == "B2"
|
|
|
|
| 100 |
assert document["simplified"]["level"] == "B2"
|
| 101 |
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
def test_generate_story_pipeline_fallback_preserves_reading_level(monkeypatch):
|
| 104 |
def _fake_text(**kwargs):
|
| 105 |
raise UnifiedGenerationError("text failed")
|
|
|
|
| 76 |
|
| 77 |
def _fake_text(**kwargs):
|
| 78 |
captured["reading_level"] = kwargs["reading_level"]
|
| 79 |
+
captured["language"] = kwargs["language"]
|
| 80 |
return _generated(level=kwargs["reading_level"])
|
| 81 |
|
| 82 |
monkeypatch.setattr(
|
|
|
|
| 98 |
)
|
| 99 |
|
| 100 |
assert captured["reading_level"] == "B2"
|
| 101 |
+
assert captured["language"] == "en"
|
| 102 |
assert document["simplified"]["level"] == "B2"
|
| 103 |
|
| 104 |
|
| 105 |
+
def test_generate_story_pipeline_translates_before_images(monkeypatch):
|
| 106 |
+
captured = {}
|
| 107 |
+
|
| 108 |
+
monkeypatch.setattr(
|
| 109 |
+
"comic_gen.comics.generate_text_content_from_article",
|
| 110 |
+
lambda **kwargs: _generated(level=kwargs["reading_level"]),
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
def _fake_translate(document, target_language, **kwargs):
|
| 114 |
+
captured["target_language"] = target_language
|
| 115 |
+
document["panels"][0]["dialogue"][0]["text"] = "ES:Linea"
|
| 116 |
+
document["exercises"][0]["prompt"] = "ES:Linea ____"
|
| 117 |
+
return True
|
| 118 |
+
|
| 119 |
+
def _fake_images(document, panels, options, strict_mode=False):
|
| 120 |
+
captured["dialogue_for_image"] = panels[0]["dialogue"][0]["text"]
|
| 121 |
+
captured["exercise_for_image"] = document["exercises"][0]["prompt"]
|
| 122 |
+
return {"deterministic": 3}
|
| 123 |
+
|
| 124 |
+
monkeypatch.setattr(
|
| 125 |
+
"comic_gen.comics.translate_session_content",
|
| 126 |
+
_fake_translate,
|
| 127 |
+
)
|
| 128 |
+
monkeypatch.setattr(
|
| 129 |
+
"comic_gen.comics.generate_image_panels",
|
| 130 |
+
_fake_images,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
document = _document()
|
| 134 |
+
document["language"] = "es"
|
| 135 |
+
comics.generate_story_pipeline(
|
| 136 |
+
document,
|
| 137 |
+
panel_count=3,
|
| 138 |
+
text_model_repo_id="test-model",
|
| 139 |
+
reading_level="A2",
|
| 140 |
+
image_options={"enable_live_images": False},
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
assert captured["target_language"] == "es"
|
| 144 |
+
assert captured["dialogue_for_image"] == "ES:Linea"
|
| 145 |
+
assert captured["exercise_for_image"] == "ES:Linea ____"
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_generate_story_pipeline_translation_failure_keeps_content(monkeypatch):
|
| 149 |
+
monkeypatch.setattr(
|
| 150 |
+
"comic_gen.comics.generate_text_content_from_article",
|
| 151 |
+
lambda **kwargs: _generated(level=kwargs["reading_level"]),
|
| 152 |
+
)
|
| 153 |
+
monkeypatch.setattr(
|
| 154 |
+
"comic_gen.comics.translate_session_content",
|
| 155 |
+
lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom")),
|
| 156 |
+
)
|
| 157 |
+
monkeypatch.setattr(
|
| 158 |
+
"comic_gen.comics.generate_image_panels",
|
| 159 |
+
lambda *args, **kwargs: {"deterministic": 3},
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
document = _document()
|
| 163 |
+
document["language"] = "fr"
|
| 164 |
+
comics.generate_story_pipeline(
|
| 165 |
+
document,
|
| 166 |
+
panel_count=3,
|
| 167 |
+
text_model_repo_id="test-model",
|
| 168 |
+
reading_level="A2",
|
| 169 |
+
image_options={"enable_live_images": False},
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
assert document["panels"][0]["dialogue"][0]["text"] == "Line"
|
| 173 |
+
assert any(
|
| 174 |
+
item["step"] == "translation" and item["status"] == "fallback"
|
| 175 |
+
for item in document["trace"]
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
def test_generate_story_pipeline_fallback_preserves_reading_level(monkeypatch):
|
| 180 |
def _fake_text(**kwargs):
|
| 181 |
raise UnifiedGenerationError("text failed")
|
test/test_text_backend.py
CHANGED
|
@@ -246,7 +246,8 @@ def test_generate_text_prompt_honors_panel_count_and_article(monkeypatch):
|
|
| 246 |
)
|
| 247 |
|
| 248 |
assert "- Generate exactly 4 panels." in captured["prompt"]
|
| 249 |
-
assert "
|
|
|
|
| 250 |
assert "Comic Style ID: retro" in captured["prompt"]
|
| 251 |
assert "Reading Level: B1" in captured["prompt"]
|
| 252 |
assert "Set simplified.level exactly to B1." in captured["prompt"]
|
|
|
|
| 246 |
)
|
| 247 |
|
| 248 |
assert "- Generate exactly 4 panels." in captured["prompt"]
|
| 249 |
+
assert "Canonical Generation Language Code: en" in captured["prompt"]
|
| 250 |
+
assert "Generate canonical learner content in English" in captured["prompt"]
|
| 251 |
assert "Comic Style ID: retro" in captured["prompt"]
|
| 252 |
assert "Reading Level: B1" in captured["prompt"]
|
| 253 |
assert "Set simplified.level exactly to B1." in captured["prompt"]
|
test/test_translation_backend.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from comic_gen.translation_backend import (
|
| 4 |
+
NLLB_LANGUAGE_CODES,
|
| 5 |
+
translate_session_content,
|
| 6 |
+
translate_text,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_translate_text_noops_for_english():
|
| 11 |
+
assert translate_text("Hello", "en") == "Hello"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_translate_text_uses_nllb_codes(monkeypatch):
|
| 15 |
+
captured = {}
|
| 16 |
+
|
| 17 |
+
def _fake_get_pipeline(model_id, source_language, target_language):
|
| 18 |
+
captured["model_id"] = model_id
|
| 19 |
+
captured["source_language"] = source_language
|
| 20 |
+
captured["target_language"] = target_language
|
| 21 |
+
return lambda text: [{"translation_text": f"ES:{text}"}]
|
| 22 |
+
|
| 23 |
+
monkeypatch.setattr(
|
| 24 |
+
"comic_gen.translation_backend._get_translation_pipeline",
|
| 25 |
+
_fake_get_pipeline,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
translated = translate_text("Hello", "es")
|
| 29 |
+
|
| 30 |
+
assert translated == "ES:Hello"
|
| 31 |
+
assert captured["source_language"] == NLLB_LANGUAGE_CODES["en"]
|
| 32 |
+
assert captured["target_language"] == NLLB_LANGUAGE_CODES["es"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_translate_text_preserves_blank_placeholder(monkeypatch):
|
| 36 |
+
monkeypatch.setattr(
|
| 37 |
+
"comic_gen.translation_backend._get_translation_pipeline",
|
| 38 |
+
lambda *args, **kwargs: (
|
| 39 |
+
lambda text: [{"translation_text": text.replace("Read", "Leer")}]
|
| 40 |
+
),
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
assert translate_text("Read ____ today", "es", preserve_blanks=True) == (
|
| 44 |
+
"Leer ____ today"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_translate_session_content_translates_learner_fields(monkeypatch):
|
| 49 |
+
monkeypatch.setattr(
|
| 50 |
+
"comic_gen.translation_backend.translate_text",
|
| 51 |
+
lambda text, target_language, **kwargs: f"{target_language}:{text}",
|
| 52 |
+
)
|
| 53 |
+
document = {
|
| 54 |
+
"simplified": {"summary": "Summary", "keywords": ["word"]},
|
| 55 |
+
"characters": [{"description": "A guide"}],
|
| 56 |
+
"panels": [
|
| 57 |
+
{
|
| 58 |
+
"scene_description": "A scene",
|
| 59 |
+
"dialogue": [{"text": "Hello"}],
|
| 60 |
+
}
|
| 61 |
+
],
|
| 62 |
+
"exercises": [{"prompt": "Hello ____"}],
|
| 63 |
+
"trace": [],
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
applied = translate_session_content(document, "fr")
|
| 67 |
+
|
| 68 |
+
assert applied is True
|
| 69 |
+
assert document["simplified"]["summary"] == "fr:Summary"
|
| 70 |
+
assert document["simplified"]["keywords"] == ["fr:word"]
|
| 71 |
+
assert document["characters"][0]["description"] == "fr:A guide"
|
| 72 |
+
assert document["panels"][0]["scene_description"] == "fr:A scene"
|
| 73 |
+
assert document["panels"][0]["dialogue"][0]["text"] == "fr:Hello"
|
| 74 |
+
assert document["exercises"][0]["prompt"] == "fr:Hello ____"
|
| 75 |
+
assert document["trace"][-1]["status"] == "ok"
|