Skip to content

feat(wizard): separate UI, conversation and content languages - #108

Merged
IAnMove merged 17 commits into
mainfrom
feat/wizard-language-intent
Sep 2, 2026
Merged

feat(wizard): separate UI, conversation and content languages#108
IAnMove merged 17 commits into
mainfrom
feat/wizard-language-intent

Conversation

@IAnMove

@IAnMove IAnMove commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Resultado

Introduce un contrato multidioma durable para que el idioma de la interfaz no decida el idioma de conversación, contenido, voz/letra ni prompts técnicos del proveedor.

  • El mago responde en el idioma de la petición actual, independientemente del locale de UI.
  • Story Lab, Series Lab y Comics persisten languageIntent y lo heredan en adaptaciones y Director.
  • Los prompts técnicos se compilan en inglés por defecto.
  • Diálogo, letra, subtítulos, texto visible y nombres se guardan como segmentos literales y nunca se traducen ni parafrasean.
  • Studio sigue rellenando los campos visibles para que el usuario vea, aprenda y pueda editar antes de generar.
  • El escritor musical separa STYLE en inglés y LYRICS en el idioma elegido, respetando los límites de MiniMax.
  • Las conversaciones persistidas guardan el idioma de cada respuesta.
  • Los cambios lingüísticos se validan antes de persistir; el idioma del chat no invalida canon, pero contenido, voz, prompts técnicos y literales sí.
  • Se documentan el contrato, migración legacy y pruebas nocturnas/release.

Validación

  • Python: 1688 passed.
  • UI: 527 passed.
  • Playwright determinista: 3 passed.
  • ESLint y TypeScript: sin errores ni warnings.
  • i18n: 14 namespaces en/es correctos.
  • Build y presupuesto: correctos (entry gzip 313230 / 327680 B).
  • Ratchet de arquitectura/complejidad: correcto.
  • MiniMax real, sin generación de media: conversación fr, contenido técnico en, voz es y diálogo exacto «¡Hola, mundo!».
  • Playwright real contra Pinokio + UI de esta rama: el mago abrió y rellenó Studio de forma visible, mantuvo los cuatro idiomas separados y respetó «no generes nada».

Integración

La rama incorpora main hasta f9148d8 y actualmente no está por detrás. El trabajo se realizó en un clon temporal aislado; no se modificó el árbol usado por Grok.


Note

Medium Risk
Broad cross-cutting changes to Wizard actions, LLM prompts, and persistence boundaries; incorrect language routing could affect generated media and canon approval, though behavior is heavily tested.

Overview
Introduces a durable LanguageIntent contract so UI locale no longer drives Wizard replies, authored content, speech/lyrics, or provider prompts. Creative projects in Story, Series, and Comics now persist languageIntent (with backend normalization and legacy migration from language / spokenLanguage).

The Wizard LLM schema gains conversation_language and per-action language_intent; the client merges quoted dialogue/lyrics into verbatimSegments, tags assistant messages with lang, and compiles Studio/Series/Comic provider prompts in English while keeping reader-facing fields in the requested content language. Song writers and Story Lab generation prompts now require STYLE / visualPrompt in English and lyrics or narrative text in the chosen language, with character-for-character protection for literals.

Series canon invalidation moves to series_canon_inputs_changed, which treats production language intent (excluding chat-only conversationLanguage) as a canon input. Acceptance adds a language Wizard scenario, a loopback test:wizard-language-live script, and related docs/tests.

Reviewed by Cursor Bugbot for commit ee9c1e5. Configure here.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

PR Review — Loreframe Studio

Risk: medium
Scope: 47 file(s); +1813/-162; React UI, backend services, docs, repo scripts / CI

Automated review from scripts/analyze_pr.py. This is a heuristic pass (no LLM) so humans still own the merge decision.

Findings

  • medium — Large pull request
    1813 additions / 162 deletions. Reviewers will have an easier time with smaller, focused PRs.
  • low — UI changed — rebuild before merge
    Run cd ui && npm run build (CI already does this). Pinokio Update rebuilds for end users; keep ui/dist untracked.

Changed files

  • added: app/services/language_intent.py, tests/test_language_intent.py, ui/scripts/check-wizard-language-live.ts, ui/src/features/series/languageIntent.ts, ui/src/features/stories/languageIntent.ts, ui/src/lib/languageIntent.ts, ui/tests/languageIntent.test.ts
  • modified: app/_launch_runtime.py, app/routers/llm.py, app/services/llm_guides/music/song_writer.md, app/services/llm_guides/music/song_writer_instrumental.md, app/services/llm_guides/music/song_writer_minimax.md, app/services/series_library.py, app/services/series_planning.py, app/services/story_library.py, app/services/wizard_conversations.py, docs/WIZARD_ACCEPTANCE_TESTING.md, docs/development/INTERNATIONALIZATION.md, scripts/run_wizard_acceptance.py, tests/test_minimax_song_writer_prompt.py, tests/test_wizard_conversations.py, ui/e2e/live-specs/wizard-generation.spec.ts, ui/e2e/playwright.config.ts, ui/package.json, ui/src/features/agent/AgentAssistantPanel.tsx, ui/src/features/agent/agentActions.ts, ui/src/features/agent/agentKnowledge.ts
    … and 20 more

CONTRIBUTING checklist

  • python scripts/verify_clean_repo.py
  • python -m compileall -q app/services app/launch.py scripts
  • cd ui && npm run build if the UI changed
  • No weights, CivitAI sidecars, or generated guides
  • Stays local-first (no required accounts / telemetry)

Posted by the repo PR review workflow. Re-runs on each push to the PR.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

Code health

Metric Value
Production LOC 236,007
Production files 514
Test LOC 65,284
Functions measured 14,793
Functions complexity ≥ 15 775
Maximum complexity 667

Markdown, JSON catalogs and tests are out of this table. Only app/ runtime + ui/src TS/JS count.

Most complex functions

Complexity Where
667 app/wgp.py:7157 generate_video
374 ui/src/stores/useStore.ts:4412 Async method 'startGeneration'
356 app/_launch_runtime.py:23154 _run_generation
308 app/wgp.py:12274 generate_video_tab
272 ui/src/components/Sidebar/SceneAnimatorPanel.tsx:469 Function 'SceneAnimatorPanel'
266 ui/src/stores/useStore.ts:8934 Async method 'loadSettingsFromOutput'
258 app/services/director/planners/short_film.py:3433 ShortFilmPlanner._plan_story_driven
254 app/services/director_pipeline.py:13539 _run_video_generation
245 app/services/director_pipeline.py:7663 _run_pipeline
241 ui/src/features/agent/agentActions.ts:1093 Function 'parseAction'
227 ui/src/features/agent/agentActions.ts:2726 Async function 'executeAgentActions'
226 app/services/director_pipeline.py:6492 update_comic_preview

Trend vs baseline

Metric Δ
Production LOC +2,342
Test LOC +1,470
Functions ≥ 15 +1
Maximum complexity +0

Warnings

  • production LOC increased by +2,342
  • functions at complexity >= 15 increased by +1
  • complexity hotspot ui/src/stores/useStore.ts rose 373 -> 374
  • complexity hotspot app/services/model3d_service.py rose 51 -> 54
  • complexity hotspot ui/src/features/stories/actions.ts rose 51 -> 52
  • complexity hotspot ui/src/features/series/actions.ts rose 47 -> 50
  • complexity hotspot ui/src/components/MainContent/TabFilter.tsx rose 19 -> 23
  • complexity hotspot ui/src/features/agent/AgentAssistantPanel.tsx rose 19 -> 23
  • complexity hotspot app/services/story_library.py rose 16 -> 19
  • hotspot ui/src/features/agent/agentActions.ts increased by +57 lines
  • hotspot ui/src/features/video-editor/VideoEditorPanel.tsx increased by +19 lines
  • hotspot ui/src/features/comics/ComicEditorPanel.tsx increased by +1 lines
  • hotspot ui/src/types/index.ts increased by +3 lines
  • hotspot ui/src/features/stories/actions.ts increased by +54 lines
  • hotspot app/services/series_library.py increased by +36 lines
  • hotspot app/services/model3d_service.py increased by +58 lines
  • hotspot ui/src/features/agent/capabilityRegistry.ts increased by +65 lines
  • hotspot ui/src/features/agent/applicationAdapters.ts increased by +7 lines

Ratchet passed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Language-only project updates are dropped
    • update_story and update_series_episode now treat language_intent as a real patch, attach it before validate, and accept language-only updates of the open project.
  • ✅ Fixed: Story generation does not persist intent
    • generate_story_section now writes the merged languageIntent to the library, and the writer overview schema plus apply path can persist spokenLanguage and verbatimSegments.

Create PR

Or push these changes by commenting:

@cursor push 555f85ef36
Preview (555f85ef36)
diff --git a/app/_launch_runtime.py b/app/_launch_runtime.py
--- a/app/_launch_runtime.py
+++ b/app/_launch_runtime.py
@@ -26917,6 +26917,37 @@
     beat_maximum = 8 if project_type == "quick_video" else 10 if project_type == "music_video" else 12 if project_type == "trailer" else 14
     music_minimum = 1 if project_type == "music_video" else 4
     music_maximum = 1 if project_type == "music_video" else 16
+    language_intent = {
+        "type": "object",
+        "properties": {
+            "conversationLanguage": string,
+            "contentLanguage": string,
+            "spokenLanguage": string,
+            "technicalPromptLanguage": {"type": "string", "enum": ["auto", "en"]},
+            "verbatimSegments": {
+                "type": "array",
+                "maxItems": 40,
+                "items": {
+                    "type": "object",
+                    "properties": {
+                        "kind": {"type": "string", "enum": [
+                            "dialogue", "lyrics", "visible_text", "subtitle", "name",
+                        ]},
+                        "text": string,
+                        "language": string,
+                        "speaker": string,
+                    },
+                    "required": ["kind", "text"],
+                    "additionalProperties": False,
+                },
+            },
+        },
+        "required": [
+            "conversationLanguage", "contentLanguage", "spokenLanguage",
+            "technicalPromptLanguage", "verbatimSegments",
+        ],
+        "additionalProperties": False,
+    }
     creative_brief = {
         "type": "object",
         "properties": {
@@ -26939,7 +26970,9 @@
             "type": "object",
             "properties": {
                 "title": string, "creativeBrief": creative_brief,
-                "language": string, "genre": string, "tone": string,
+                "language": string, "spokenLanguage": string,
+                "languageIntent": language_intent,
+                "genre": string, "tone": string,
                 "audience": string, "visualStyle": string,
                 "characterVisualStyle": string,
                 "enforceVisualStyle": {"type": "boolean"},

diff --git a/tests/test_story_lab_music_plan.py b/tests/test_story_lab_music_plan.py
--- a/tests/test_story_lab_music_plan.py
+++ b/tests/test_story_lab_music_plan.py
@@ -184,6 +184,13 @@
         self.assertIn("[Chorus]", repaired["lyrics"])
         self.assertIsNone(_story_stage_problem(normalized, "music", project))
 
+    def test_overview_schema_can_carry_language_intent(self):
+        overview = _story_lab_schema("overview")["properties"]["overview"]
+        self.assertIn("languageIntent", overview["properties"])
+        self.assertIn("spokenLanguage", overview["properties"])
+        self.assertNotIn("languageIntent", overview["required"])
+        self.assertIn("verbatimSegments", overview["properties"]["languageIntent"]["properties"])
+
     def test_quick_video_structure_is_compact(self):
         schema = _story_lab_schema("beats", "quick_video")
         beats = schema["properties"]["beats"]

diff --git a/ui/src/features/agent/capabilityRegistry.ts b/ui/src/features/agent/capabilityRegistry.ts
--- a/ui/src/features/agent/capabilityRegistry.ts
+++ b/ui/src/features/agent/capabilityRegistry.ts
@@ -47,6 +47,7 @@
 import {
   LANGUAGE_INTENT_SCHEMA,
   compileProviderPrompt,
+  hasLanguageIntent,
   normalizeConversationLanguageTag,
   normalizeLanguageIntent,
   type LanguageIntent,
@@ -559,10 +560,10 @@
   resolve(raw) {
     const fields = storyFields(raw)
     const action: AgentUpdateStoryAction = { type: 'update_story', targetStoryTitle: text(raw.target_story_title, 300), ...fields }
-    const hasPatch = action.title || action.creativeBrief || action.premise || action.logline || action.synopsis || action.theme || action.ending || action.genre || action.tone || action.visualStyle || action.worldSummary || action.language || action.characters.length || action.locations.length || action.outlineBeats.length || action.durationSeconds !== undefined
+    const hasPatch = action.title || action.creativeBrief || action.premise || action.logline || action.synopsis || action.theme || action.ending || action.genre || action.tone || action.visualStyle || action.worldSummary || action.language || action.characters.length || action.locations.length || action.outlineBeats.length || action.durationSeconds !== undefined || hasLanguageIntent(raw.language_intent)
     return hasPatch ? action : null
   },
-  validate(action) { return action.targetStoryTitle || action.title || action.premise ? [] : ['a target story or a patch is required'] }, async prepare(action) { return action },
+  validate(action) { return action.targetStoryTitle || action.title || action.premise || hasLanguageIntent(action.languageIntent) ? [] : ['a target story or a patch is required'] }, async prepare(action) { return action },
   async execute(action, context) { return context.adapters.storyLab.update(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome },
   report: { targetKind: 'story', successState: 'completed' }, summarize(_action, outcome) { return outcome.message },
   presentation: { destination: 'story_lab', anchors: ['overview', 'characters', 'world', 'structure'], replay: 'atomic' },
@@ -704,9 +705,9 @@
   resolve(raw) {
     const fields = seriesEpisodeFields(raw)
     const action: AgentUpdateSeriesEpisodeAction = { type: 'update_series_episode', seriesTitle: fields.seriesTitle, targetEpisodeTitle: text(raw.target_episode_title, 300), episodeTitle: fields.episodeTitle, episodePremise: fields.episodePremise, episodeLogline: fields.episodeLogline, outlineBeats: fields.outlineBeats, targetDurationSeconds: fields.targetDurationSeconds }
-    return action.episodeTitle || action.episodePremise || action.episodeLogline || action.outlineBeats.length || action.targetDurationSeconds !== undefined ? action : null
+    return action.episodeTitle || action.episodePremise || action.episodeLogline || action.outlineBeats.length || action.targetDurationSeconds !== undefined || hasLanguageIntent(raw.language_intent) ? action : null
   },
-  validate(action) { return action.episodeTitle || action.episodePremise || action.episodeLogline || action.outlineBeats.length || action.targetDurationSeconds !== undefined ? [] : ['an episode patch is required'] }, async prepare(action) { return action },
+  validate(action) { return action.episodeTitle || action.episodePremise || action.episodeLogline || action.outlineBeats.length || action.targetDurationSeconds !== undefined || hasLanguageIntent(action.languageIntent) ? [] : ['an episode patch is required'] }, async prepare(action) { return action },
   async execute(action, context) { return context.adapters.seriesLab.updateEpisode(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome },
   report: { targetKind: 'series_episode', successState: 'completed' }, summarize(_action, outcome) { return outcome.message },
   presentation: { destination: 'series_lab', anchors: ['episode'], replay: 'atomic' },
@@ -1106,11 +1107,13 @@
   const definition = definitions.get(name)
   if (!definition) return undefined
   const action = definition.resolve(raw)
-  if (!action || definition.validate(action).length) return null
-  if (!LANGUAGE_AWARE_CAPABILITIES.has(action.type)) return action
-  const rawIntent = raw.language_intent
-  if (!rawIntent || typeof rawIntent !== 'object' || Array.isArray(rawIntent)) return action
-  return { ...action, languageIntent: normalizeLanguageIntent(rawIntent) } as AgentAction
+  if (!action) return null
+  const prepared = LANGUAGE_AWARE_CAPABILITIES.has(action.type)
+    && raw.language_intent && typeof raw.language_intent === 'object' && !Array.isArray(raw.language_intent)
+    ? { ...action, languageIntent: normalizeLanguageIntent(raw.language_intent) } as AgentAction
+    : action
+  if (definition.validate(prepared).length) return null
+  return prepared
 }
 
 export async function executeRegisteredCapability(

diff --git a/ui/src/features/stories/actions.ts b/ui/src/features/stories/actions.ts
--- a/ui/src/features/stories/actions.ts
+++ b/ui/src/features/stories/actions.ts
@@ -8,7 +8,7 @@
   outlineBeats,
 } from '../../lib/labHelpers'
 import { useStore } from '../../stores/useStore'
-import { compileProviderPrompt, mergeLanguageIntent } from '../../lib/languageIntent'
+import { compileProviderPrompt, mergeLanguageIntent, normalizeLanguageIntent } from '../../lib/languageIntent'
 import { applyMusicVideoDirectVideoDefaults, resolveMusicVideoVisualStyle } from './musicVideoLook'
 import type {
   ApplyStoryProposalCommand,
@@ -710,22 +710,25 @@
     ? Object.values(current.projects).find(item => normalizeName(item.title) === normalizeName(action.targetStoryTitle))
     : current.project
   if (!storedProject) throw new Error(`No existe la historia “${action.targetStoryTitle}” en este workspace.`)
-  const project = action.languageIntent ? {
-    ...storedProject,
-    languageIntent: mergeLanguageIntent(storedProject.languageIntent, action.languageIntent),
-    language: action.languageIntent.contentLanguage || storedProject.language,
-    spokenLanguage: action.languageIntent.spokenLanguage || storedProject.spokenLanguage,
-  } : storedProject
-  if (current.activeProjectOperations[project.id]) {
-    throw new Error(`La historia “${project.title}” ya tiene una operación activa.`)
+  if (current.activeProjectOperations[storedProject.id]) {
+    throw new Error(`La historia “${storedProject.title}” ya tiene una operación activa.`)
   }
+  const project = action.languageIntent
+    ? await saveActiveStoryProjectMutation(workspace, current, storedProject.id, source => ({
+      ...source,
+      languageIntent: mergeLanguageIntent(source.languageIntent, action.languageIntent),
+      language: action.languageIntent.contentLanguage || source.language,
+      spokenLanguage: action.languageIntent.spokenLanguage || source.spokenLanguage,
+      updatedAt: new Date().toISOString(),
+    }))
+    : storedProject
   const premise = project.premise.trim()
     || project.creativeBrief.generalIdea.trim()
     || project.logline.trim()
     || project.synopsis.trim()
   if (!premise) throw new Error(`“${project.title}” necesita una premisa o briefing antes de invocar al escritor.`)
 
-  useStoryStore.setState({ project, dirty: false })
+  if (!action.languageIntent) useStoryStore.setState({ project, dirty: false })
   const visibleSection = action.scope === 'all' ? 'overview' : action.scope
   const resultKey = `maestro-story-plan-result:${workspace}:${project.id}`
   const jobKey = `maestro-story-plan-job:${workspace}:${project.id}`
@@ -840,6 +843,14 @@
         }
       })
     }
+    if (overview.languageIntent && typeof overview.languageIntent === 'object' && !Array.isArray(overview.languageIntent)) {
+      candidate.languageIntent = mergeLanguageIntent(
+        candidate.languageIntent,
+        normalizeLanguageIntent(overview.languageIntent),
+      )
+      candidate.language = candidate.languageIntent.contentLanguage || candidate.language
+      candidate.spokenLanguage = candidate.languageIntent.spokenLanguage || candidate.spokenLanguage
+    }
   }
 
   const generatedWorld = result.world && typeof result.world === 'object' && !Array.isArray(result.world)

diff --git a/ui/tests/languageIntent.test.ts b/ui/tests/languageIntent.test.ts
--- a/ui/tests/languageIntent.test.ts
+++ b/ui/tests/languageIntent.test.ts
@@ -171,6 +171,29 @@
   assert.equal(series?.languageIntent.technicalPromptLanguage, 'en')
 })
 
+test('language-only Story and Series updates resolve instead of being dropped', () => {
+  const story = parseRegisteredCapability('update_story', {
+    type: 'update_story',
+    language_intent: {
+      spoken_language: 'Català',
+      verbatim_segments: [{ kind: 'dialogue', text: 'hola', language: 'ca' }],
+    },
+  })
+  assert.equal(story?.type, 'update_story')
+  assert.equal(story && 'languageIntent' in story && story.languageIntent?.spokenLanguage, 'Català')
+  assert.equal(story && 'languageIntent' in story && story.languageIntent?.verbatimSegments[0].text, 'hola')
+
+  const episode = parseRegisteredCapability('update_series_episode', {
+    type: 'update_series_episode',
+    language_intent: { spoken_language: 'Català' },
+  })
+  assert.equal(episode?.type, 'update_series_episode')
+  assert.equal(episode && 'languageIntent' in episode && episode.languageIntent?.spokenLanguage, 'Català')
+
+  assert.equal(parseRegisteredCapability('update_story', { type: 'update_story' }), null)
+  assert.equal(parseRegisteredCapability('update_series_episode', { type: 'update_series_episode' }), null)
+})
+
 test('changing only protected Story literals is a real persisted overview change', () => {
   const before = createStoryProject()
   const after = normalizeStoryProject({

You can send follow-ups to the cloud agent here.

Comment thread ui/src/features/agent/capabilityRegistry.ts
Comment thread ui/src/features/stories/actions.ts
@IAnMove

IAnMove commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Language persist runs before validation
    • Moved Story and Series Lab validation ahead of the language persist so a rejected turn no longer writes languageIntent or unapproves Overview/canon.

Create PR

Or push these changes by commenting:

@cursor push ea6147c864
Preview (ea6147c864)
diff --git a/ui/src/features/series/actions.ts b/ui/src/features/series/actions.ts
--- a/ui/src/features/series/actions.ts
+++ b/ui/src/features/series/actions.ts
@@ -292,6 +292,23 @@
   if (!series) throw new Error(action.seriesTitle
     ? `No existe la serie “${action.seriesTitle}” en este workspace.`
     : 'No hay una serie activa que planificar.')
+  const episodeMatches = action.targetEpisodeTitle
+    ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle))
+    : []
+  if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`)
+  const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id
+    ? useSeriesStore.getState().activeEpisodeId : ''
+  const episodes = Object.values(series.episodesById)
+  const episode = episodeMatches[0]
+    || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null)
+    || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null)
+  if (!episode) throw new Error(action.targetEpisodeTitle
+    ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.`
+    : `“${series.title}” necesita un episodio activo o único.`)
+  if (!episode.premise.trim()) throw new Error(`“${episode.title}” necesita una premisa antes de planificarse.`)
+  if (action.scope === 'shots' && !episode.script.length) {
+    throw new Error('Regenerar shots requiere un guion existente; genera script o complete primero.')
+  }
   if (action.languageIntent) {
     const languageIntent = mergeLanguageIntent(series.languageIntent, action.languageIntent, {
       contentLanguage: series.language,
@@ -315,23 +332,6 @@
       await useSeriesStore.getState().loadWorkspace(workspace)
     }
   }
-  const episodeMatches = action.targetEpisodeTitle
-    ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle))
-    : []
-  if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`)
-  const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id
-    ? useSeriesStore.getState().activeEpisodeId : ''
-  const episodes = Object.values(series.episodesById)
-  const episode = episodeMatches[0]
-    || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null)
-    || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null)
-  if (!episode) throw new Error(action.targetEpisodeTitle
-    ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.`
-    : `“${series.title}” necesita un episodio activo o único.`)
-  if (!episode.premise.trim()) throw new Error(`“${episode.title}” necesita una premisa antes de planificarse.`)
-  if (action.scope === 'shots' && !episode.script.length) {
-    throw new Error('Regenerar shots requiere un guion existente; genera script o complete primero.')
-  }
 
   await useSeriesStore.getState().openSeries(series.id)
   useSeriesStore.getState().openEpisode(episode.id)

diff --git a/ui/src/features/stories/actions.ts b/ui/src/features/stories/actions.ts
--- a/ui/src/features/stories/actions.ts
+++ b/ui/src/features/stories/actions.ts
@@ -713,6 +713,11 @@
   if (current.activeProjectOperations[storedProject.id]) {
     throw new Error(`La historia “${storedProject.title}” ya tiene una operación activa.`)
   }
+  const premise = storedProject.premise.trim()
+    || storedProject.creativeBrief.generalIdea.trim()
+    || storedProject.logline.trim()
+    || storedProject.synopsis.trim()
+  if (!premise) throw new Error(`“${storedProject.title}” necesita una premisa o briefing antes de invocar al escritor.`)
   let project = storedProject
   if (action.languageIntent) {
     const intended = mergeLanguageIntent(storedProject.languageIntent, action.languageIntent, {
@@ -749,11 +754,6 @@
       })
     }
   }
-  const premise = project.premise.trim()
-    || project.creativeBrief.generalIdea.trim()
-    || project.logline.trim()
-    || project.synopsis.trim()
-  if (!premise) throw new Error(`“${project.title}” necesita una premisa o briefing antes de invocar al escritor.`)
 
   useStoryStore.setState({ project, dirty: false })
   const visibleSection = action.scope === 'all' ? 'overview' : action.scope

You can send follow-ups to the cloud agent here.

Comment thread ui/src/features/stories/actions.ts
@IAnMove

IAnMove commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

He corregido el fallo real del ratchet sin relajar controles: la aplicación y persistencia del contrato de idioma de Story Lab se extrajo a features/stories/languageIntent.ts. stories/actions.ts queda en +58 líneas frente a main (límite 75) y python scripts/code_health.py --check pasa. Añadí además regresión para selección de idioma legacy al crear una historia. Validación local: 14 pruebas focalizadas, ESLint cero warnings y TypeScript limpios.

@IAnMove

IAnMove commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Series language saved before validation
    • updateSeriesEpisode now resolves a unique target episode first and only then persists languageIntent when the merged contract actually differs.
  • ✅ Fixed: Episode create resets approved canon
    • Creating a later episode no longer treats languageIntent as needsSetup; unchanged language is inherited and only real language changes are saved without drafting canon.
  • ✅ Fixed: Legacy language desyncs language intent
    • A legacy language patch now rebuilds languageIntent from the updated language and spokenLanguage so the persisted contract stays aligned.

Create PR

Or push these changes by commenting:

@cursor push 15e87d3494
Preview (15e87d3494)
diff --git a/ui/src/features/series/actions.ts b/ui/src/features/series/actions.ts
--- a/ui/src/features/series/actions.ts
+++ b/ui/src/features/series/actions.ts
@@ -117,13 +117,17 @@
     spokenLanguage: action.language || series.spokenLanguage,
     technicalPromptLanguage: 'en',
   })
+  const language = languageIntent.contentLanguage || action.language || series.language
+  const spokenLanguage = languageIntent.spokenLanguage || action.language || series.spokenLanguage
+  const languageChanged = JSON.stringify(languageIntent) !== JSON.stringify(series.languageIntent)
+    || language !== series.language
+    || spokenLanguage !== series.spokenLanguage
   const needsSetup = createdSeries
     || !series.premise.trim()
     || !series.visualStyle.trim()
     || !series.canon.worldSummary.trim()
     || !series.characters.length
     || !series.locations.length
-    || Boolean(action.languageIntent)
   if (needsSetup) {
     const patched = {
       ...series,
@@ -135,8 +139,8 @@
       visualStyle: series.visualStyle || action.visualStyle || 'Continuidad televisiva cinematográfica, composición clara y personajes consistentes.',
       characterVisualStyle: series.characterVisualStyle || action.visualStyle || 'Identidades y vestuario consistentes entre episodios.',
       cameraLanguage: series.cameraLanguage || 'Planos de situación claros, planos medios para diálogo y primeros planos para reacciones.',
-      language: languageIntent.contentLanguage || action.language || series.language,
-      spokenLanguage: languageIntent.spokenLanguage || action.language || series.spokenLanguage,
+      language,
+      spokenLanguage,
       languageIntent,
       sourceMode: action.knownUniverse ? 'known_universe_experimental' as const : series.sourceMode,
       masterUniversePrompt: series.masterUniversePrompt || (action.knownUniverse
@@ -162,6 +166,14 @@
       updatedAt: new Date().toISOString(),
     }
     series = await api.saveSeriesProject(workspace, patched, series.revision)
+  } else if (languageChanged) {
+    series = await api.saveSeriesProject(workspace, {
+      ...series,
+      language,
+      spokenLanguage,
+      languageIntent,
+      updatedAt: new Date().toISOString(),
+    }, series.revision)
   }
   let approvedCanon = false
   if (series.canon.approval !== 'approved') {
@@ -217,18 +229,6 @@
   if (!series) throw new Error(action.seriesTitle
     ? `No existe la serie “${action.seriesTitle}” en este workspace.`
     : 'No hay una serie activa que modificar.')
-  if (action.languageIntent) {
-    const languageIntent = mergeLanguageIntent(series.languageIntent, action.languageIntent)
-    series = await api.saveSeriesProject(workspace, {
-      ...series,
-      language: languageIntent.contentLanguage || series.language,
-      spokenLanguage: languageIntent.spokenLanguage || series.spokenLanguage,
-      languageIntent,
-      updatedAt: new Date().toISOString(),
-    }, series.revision)
-    useSeriesStore.setState({ hydrated: false })
-    await useSeriesStore.getState().loadWorkspace(workspace)
-  }
 
   const episodeMatches = action.targetEpisodeTitle
     ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle))
@@ -245,6 +245,29 @@
   if (!episode) throw new Error(action.targetEpisodeTitle
     ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.`
     : `“${series.title}” necesita un episodio activo o un único episodio para poder inferir el destino.`)
+  if (action.languageIntent) {
+    const languageIntent = mergeLanguageIntent(series.languageIntent, action.languageIntent, {
+      contentLanguage: series.language,
+      spokenLanguage: series.spokenLanguage,
+    })
+    const language = languageIntent.contentLanguage || series.language
+    const spokenLanguage = languageIntent.spokenLanguage || series.spokenLanguage
+    if (
+      JSON.stringify(languageIntent) !== JSON.stringify(series.languageIntent)
+      || language !== series.language
+      || spokenLanguage !== series.spokenLanguage
+    ) {
+      series = await api.saveSeriesProject(workspace, {
+        ...series,
+        language,
+        spokenLanguage,
+        languageIntent,
+        updatedAt: new Date().toISOString(),
+      }, series.revision)
+      useSeriesStore.setState({ hydrated: false })
+      await useSeriesStore.getState().loadWorkspace(workspace)
+    }
+  }
 
   await useSeriesStore.getState().openSeries(series.id)
   useSeriesStore.getState().openEpisode(episode.id)

diff --git a/ui/src/features/stories/actions.ts b/ui/src/features/stories/actions.ts
--- a/ui/src/features/stories/actions.ts
+++ b/ui/src/features/stories/actions.ts
@@ -562,7 +562,7 @@
     candidate.language = action.language
     if (!action.languageIntent?.spokenLanguage) candidate.spokenLanguage = action.language
   }
-  if (action.languageIntent) {
+  if (action.languageIntent || action.language) {
     Object.assign(candidate, applyStoryLanguageIntent(candidate, action.languageIntent))
   }
 

diff --git a/ui/src/features/stories/languageIntent.ts b/ui/src/features/stories/languageIntent.ts
--- a/ui/src/features/stories/languageIntent.ts
+++ b/ui/src/features/stories/languageIntent.ts
@@ -6,8 +6,25 @@
   update: LanguageIntent | undefined,
   fallback: Partial<LanguageIntent> = {},
 ): StoryProject {
-  if (!update && !Object.keys(fallback).length) return project
-  const languageIntent = mergeLanguageIntent(project.languageIntent, update, {
+  const languageChanged = project.language !== project.languageIntent.contentLanguage
+    || project.spokenLanguage !== project.languageIntent.spokenLanguage
+  const syncedUpdate = update
+    ? {
+        ...update,
+        contentLanguage: update.contentLanguage || project.language,
+        spokenLanguage: update.spokenLanguage || project.spokenLanguage,
+      }
+    : languageChanged
+      ? {
+          conversationLanguage: '',
+          contentLanguage: project.language,
+          spokenLanguage: project.spokenLanguage,
+          technicalPromptLanguage: project.languageIntent.technicalPromptLanguage,
+          verbatimSegments: [],
+        }
+      : undefined
+  if (!syncedUpdate && !Object.keys(fallback).length) return project
+  const languageIntent = mergeLanguageIntent(project.languageIntent, syncedUpdate, {
     contentLanguage: project.language,
     spokenLanguage: project.spokenLanguage,
     ...fallback,

You can send follow-ups to the cloud agent here.

Comment thread ui/src/features/series/actions.ts Outdated
Comment thread ui/src/features/series/actions.ts
Comment thread ui/src/features/stories/actions.ts
Comment thread app/_launch_runtime.py
@IAnMove

IAnMove commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

@cursor review\n\nRevisión solicitada tras 7f5c753: están corregidos y cubiertos los cuatro hallazgos del último Bugbot (validación transaccional de update_series_episode, canon estable para conversationLanguage, sincronización Story legado y languageIntent como entrada de canon).

@IAnMove

IAnMove commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

@cursor review\n\nRevisión final solicitada tras 9b361ce. Además de los cuatro hallazgos anteriores, la revisión manual sincroniza los controles visibles de idioma de Story Lab, Series Lab y las rutas de planificación/traducción de Comics con el contrato durable.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Legacy language overwrites series speech
    • resolveSeriesLanguageIntent now keeps an existing spokenLanguage unless the update supplies a new spoken value, so restating the legacy content language no longer collapses speech or drops canon to draft.

Create PR

Or push these changes by commenting:

@cursor push 5b4505ce88
Preview (5b4505ce88)
diff --git a/ui/src/features/series/languageIntent.ts b/ui/src/features/series/languageIntent.ts
--- a/ui/src/features/series/languageIntent.ts
+++ b/ui/src/features/series/languageIntent.ts
@@ -15,7 +15,7 @@
   return {
     ...merged,
     contentLanguage: update?.contentLanguage || legacyLanguage,
-    spokenLanguage: update?.spokenLanguage || legacyLanguage,
+    spokenLanguage: update?.spokenLanguage || merged.spokenLanguage || legacyLanguage,
   }
 }
 

diff --git a/ui/tests/languageIntent.test.ts b/ui/tests/languageIntent.test.ts
--- a/ui/tests/languageIntent.test.ts
+++ b/ui/tests/languageIntent.test.ts
@@ -288,8 +288,16 @@
 
   const legacySelection = resolveSeriesLanguageIntent(series!, 'Français', undefined)
   assert.equal(legacySelection.contentLanguage, 'Français')
-  assert.equal(legacySelection.spokenLanguage, 'Français')
+  assert.equal(legacySelection.spokenLanguage, 'Español')
   assert.equal(seriesLanguageIntentAffectsCanon(series!, legacySelection), true)
+
+  const splitSpeech = normalizeSeriesProject({
+    id: 'series-speech', title: 'Night Shift', language: 'Español', spokenLanguage: 'Español de España',
+  })
+  const restatedContent = resolveSeriesLanguageIntent(splitSpeech!, 'Español', undefined)
+  assert.equal(restatedContent.contentLanguage, 'Español')
+  assert.equal(restatedContent.spokenLanguage, 'Español de España')
+  assert.equal(seriesLanguageIntentAffectsCanon(splitSpeech!, restatedContent), false)
 })
 
 test('manual Story and Series language controls update visible and durable fields together', () => {

You can send follow-ups to the cloud agent here.

Comment thread ui/src/features/series/languageIntent.ts
@IAnMove

IAnMove commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

@cursor review\n\nRevisión final tras ee9c1e5: corregido el hallazgo de voz regional y cubierto el caso exacto de una serie existente con Español + Español de España.

@IAnMove
IAnMove merged commit 0309bb0 into main Sep 2, 2026
4 of 5 checks passed

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit ee9c1e5. Configure here.

@IAnMove
IAnMove deleted the feat/wizard-language-intent branch September 5, 2026 11:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant