palec87 commited on
Commit
f7fc536
·
1 Parent(s): 8ae7370

translations

Browse files
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.13
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"### Source\n"
67
- f"- Publisher: {source['publisher']}\n"
68
- f"- Title: {article['title']}\n"
69
- f"- Link: {source['link']}\n"
70
- f"- Published: {source['published_at']}"
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"### Simplified Summary ({simplified['level']})\n"
79
  f"{simplified['summary']}\n\n"
80
- f"Keywords: {keywords}"
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:3px 6px;border:1px solid rgba(17,24,39,0.92);"
168
- "border-radius:10px;background:rgba(255,255,255,0.94);"
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
- lines: list[str] = ["### Transcript"]
 
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(f"PANEL {panel['frame_index']}")
 
 
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"Panel {p['frame_index']}", p["panel_id"])
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 "Generate a strip first.", ""
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 "No exercise available for this panel.", ""
433
 
434
- return f"### Exercise\n{exercise_item['prompt']}", ""
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 "Generate a strip first."
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=str(document.get("language", "en")),
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, "Exercise not found for the selected panel."
 
 
 
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, "Correct. Great writing practice."
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
- # "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,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, 3):
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
- "- Target Language Code: {language}\n"
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 = 90,
350
- min_height: int = 30,
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": "watercolor",
6
  "source": {
7
- "link": "https://example.org/es/biblioteca-barrio",
8
- "publisher": "Ejemplo Noticias",
9
- "published_at": "2026-06-07T00:00:00+00:00"
10
  },
11
  "article": {
12
- "title": "Una biblioteca de barrio impulsa la lectura adulta",
13
- "fulltext": "Una biblioteca local inicio talleres semanales para personas adultas. Los participantes leen noticias breves y escriben resumenes con ayuda de un tutor."
14
  },
15
  "simplified": {
16
- "summary": "Las personas adultas practican lectura y escritura con noticias cortas.",
17
  "level": "A2",
18
- "keywords": ["adultas", "lectura", "escritura", "noticias"]
19
  },
20
  "characters": [
21
  {
22
  "id": "char_guide",
23
  "name": "Guia",
24
- "description": "Mentora que explica con lenguaje claro."
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 noticia corta en la biblioteca.",
37
  "dialogue": [
38
  {
39
  "character_id": "char_guide",
40
- "text": "Leemos una noticia breve con palabras simples."
41
  },
42
  {
43
  "character_id": "char_learner",
44
- "text": "Ahora entiendo mejor la idea principal."
45
  }
46
  ],
47
  "bubbles": [
@@ -60,15 +60,15 @@
60
  {
61
  "panel_id": "panel_2",
62
  "frame_index": 2,
63
- "scene_description": "La estudiante escribe un resumen corto.",
64
  "dialogue": [
65
  {
66
  "character_id": "char_guide",
67
- "text": "Escribe una frase con la palabra clave."
68
  },
69
  {
70
  "character_id": "char_learner",
71
- "text": "La lectura me ayuda a escribir mejor."
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 aprendido.",
91
  "dialogue": [
92
  {
93
  "character_id": "char_guide",
94
- "text": "Tu comprension mejora cada semana."
95
  },
96
  {
97
  "character_id": "char_learner",
98
- "text": "Tengo mas confianza para leer noticias."
99
  }
100
  ],
101
  "bubbles": [
@@ -116,9 +116,9 @@
116
  {
117
  "exercise_id": "ex_panel_1",
118
  "panel_id": "panel_1",
119
- "prompt": "Leemos una noticia ____ con palabras simples.",
120
  "blanks": ["____"],
121
- "answer_key": ["breve"],
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": "La lectura me ayuda a ____ mejor.",
131
  "blanks": ["____"],
132
- "answer_key": ["escribir"],
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": "Tengo mas ____ para leer noticias.",
142
  "blanks": ["____"],
143
- "answer_key": ["confianza"],
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 "Target Language Code: en" in captured["prompt"]
 
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"