From db414f92f11b727fbc04bea6589f8d5e70948ef9 Mon Sep 17 00:00:00 2001 From: Benjiyaya Date: Mon, 31 Aug 2026 23:41:17 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20harden=20prompt=20preview=20=E2=80=94=20?= =?UTF-8?q?fail=20fast=20and=20never=20block=20Generate=20(1.3.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With an unreachable LLM endpoint the Review-prompt modal fired duplicate long-hanging requests and left Generate impossible. Preview resolves now fire exactly once per scene, the H3 rewrite times out after 30s and falls back to the deterministic template, and on failure the modal populates the editor with raw scene text so the user can edit + Generate or retry. --- calliope-backend/pyproject.toml | 2 +- calliope-backend/src/calliope/agent/llm.py | 11 ++-- .../src/calliope/agent/video_agent.py | 19 +++++-- calliope-backend/tests/test_scene_settings.py | 52 ++++++++++++++++++- calliope-web/package-lock.json | 4 +- calliope-web/package.json | 2 +- .../video/PromptPreviewModal.svelte | 39 ++++++++++++-- docs/changelogs/2026-08-31-hardening.md | 41 +++++++++++++++ docs/changelogs/README.md | 1 + 9 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 docs/changelogs/2026-08-31-hardening.md diff --git a/calliope-backend/pyproject.toml b/calliope-backend/pyproject.toml index 3ef6f0e5..2b07ad1f 100644 --- a/calliope-backend/pyproject.toml +++ b/calliope-backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "calliope" -version = "1.3.1" +version = "1.3.2" description = "Local-first AI story-to-video studio" requires-python = ">=3.11" dependencies = [ diff --git a/calliope-backend/src/calliope/agent/llm.py b/calliope-backend/src/calliope/agent/llm.py index 46444b1b..7d06d15e 100644 --- a/calliope-backend/src/calliope/agent/llm.py +++ b/calliope-backend/src/calliope/agent/llm.py @@ -22,25 +22,28 @@ class LLMClient: base_url: str | None = None, model: str | None = None, api_key: str | None = None, + timeout: float = 120.0, ) -> None: self.base_url = (base_url or settings.llm_base_url).rstrip("/") self.model = model or settings.llm_model self.api_key = api_key if api_key is not None else settings.llm_api_key # With every completion streamed (chat/chat_with_tools consume - # chat_stream), 120 s bounds the gap BETWEEN chunks, not total + # chat_stream), the timeout bounds the gap BETWEEN chunks, not total # generation time: a thinking model streaming reasoning_content keeps # the connection fed for as long as it genuinely works, while a dead - # server still fails fast. - self.client = httpx.AsyncClient(timeout=120.0) + # server still fails fast. Shorter timeouts (e.g. the 30 s preview + # path) trade headroom for a snappier deterministic fallback. + self.client = httpx.AsyncClient(timeout=timeout) @classmethod - def for_role(cls, role: str) -> LLMClient: + def for_role(cls, role: str, *, timeout: float = 120.0) -> LLMClient: """Client for an agent role's assigned profile (active fallback).""" profile = settings.resolve_llm_for_role(role) return cls( base_url=profile.get("base_url"), model=profile.get("model"), api_key=profile.get("api_key") if isinstance(profile.get("api_key"), str) else None, + timeout=timeout, ) def _headers(self) -> dict[str, str]: diff --git a/calliope-backend/src/calliope/agent/video_agent.py b/calliope-backend/src/calliope/agent/video_agent.py index d380974c..0b1e200c 100644 --- a/calliope-backend/src/calliope/agent/video_agent.py +++ b/calliope-backend/src/calliope/agent/video_agent.py @@ -67,9 +67,18 @@ def _h3_subjects( return subjects[:cap], paths[:cap] -async def _h3_rewrite(scene: dict[str, Any], subjects: list[dict[str, Any]]) -> str: - """LLM rewrite into H3's six-section format, deterministic template on failure.""" - client = LLMClient.for_role("video") +async def _h3_rewrite( + scene: dict[str, Any], + subjects: list[dict[str, Any]], + *, + timeout: float = 120.0, +) -> str: + """LLM rewrite into H3's six-section format, deterministic template on failure. + + The timeout bounds the whole wait for a dead endpoint before the template + kicks in — the preview path passes a short value so the UI fails fast. + """ + client = LLMClient.for_role("video", timeout=timeout) try: return await client.chat( build_minimax_h3_ref_messages(scene, subjects), temperature=0.4 @@ -249,7 +258,9 @@ async def preview_scene_prompt( "agent.thinking", {"message": f"H3 prompt rewrite · scene {scene.get('order_index')}", "project_id": project_id}, ) - prompt = await _h3_rewrite(scene, subjects) + # Preview is interactive — fail fast to the deterministic template + # instead of making the user wait out a dead endpoint. + prompt = await _h3_rewrite(scene, subjects, timeout=30.0) else: prompt = scene_video_prompt(scene, characters) return {"prompt": prompt, "profile": profile, "from_draft": False, "based_on": based_on} diff --git a/calliope-backend/tests/test_scene_settings.py b/calliope-backend/tests/test_scene_settings.py index 5149e21d..7df47947 100644 --- a/calliope-backend/tests/test_scene_settings.py +++ b/calliope-backend/tests/test_scene_settings.py @@ -145,7 +145,7 @@ def test_preview_prompt_endpoint_h3_profile(client, monkeypatch): finally: conn.close() - async def fake_rewrite(scene_, subjects): + async def fake_rewrite(scene_, subjects, **kwargs): return "H3 REWRITE" monkeypatch.setattr("calliope.agent.video_agent._h3_rewrite", fake_rewrite) @@ -214,7 +214,7 @@ def test_preview_prompt_fresh_draft_shortcircuits_llm(client, monkeypatch): called = [] - async def fake_rewrite(scene_, subjects): + async def fake_rewrite(scene_, subjects, **kwargs): called.append(1) return "SHOULD NOT BE USED" @@ -226,6 +226,54 @@ def test_preview_prompt_fresh_draft_shortcircuits_llm(client, monkeypatch): assert called == [] # LLM never invoked +def test_preview_prompt_dead_llm_returns_deterministic_fallback(client, monkeypatch): + """A failing LLM rewrite falls back to minimax_h3_ref_fallback — never 500s. + + This is the failure mode behind the Discord report: a dead LLM endpoint + must not leave the preview modal hanging or erroring out. + """ + from calliope.agent import video_agent + + pid = _mk_project(client, "Preview dead LLM") + scene = _add_scene(client, pid, 1, action="A lone rider crosses the salt flats.") + + conn = get_db(settings.db_path) + try: + wf_id = _insert_h3_workflow(conn, "H3 dead") + conn.execute( + "UPDATE scenes SET workflow_id = ? WHERE id = ?", (wf_id, scene["id"]) + ) + conn.commit() + finally: + conn.close() + + class DeadClient: + def __init__(self, *args, **kwargs): + pass + + async def chat(self, messages, temperature=0.7, response_format=None): + raise RuntimeError("connection refused") + + async def close(self): + return None + + monkeypatch.setattr( + video_agent, + "LLMClient", + type("LLMClientStub", (), {"for_role": staticmethod(lambda role, **kw: DeadClient())}), + ) + + result = asyncio_run(video_agent.preview_scene_prompt(pid, scene["id"])) + assert result["profile"] == "minimax_h3_ref" + assert result["from_draft"] is False + # Deterministic template content, not an exception and not empty + assert "salt flats" in result["prompt"] + assert result["prompt"].startswith("subject_definitions:") + # No jobs were enqueued by the preview + jobs = client.get(f"/api/jobs?project_id={pid}").json() + assert [j for j in jobs if j["kind"] == "video"] == [] + + def test_enqueue_prompts_override(client, monkeypatch): """Confirmed prompt from the review modal wins over the rewrite.""" pid = _mk_project(client, "Prompt override") diff --git a/calliope-web/package-lock.json b/calliope-web/package-lock.json index 20c9d3ac..0aede72e 100644 --- a/calliope-web/package-lock.json +++ b/calliope-web/package-lock.json @@ -1,12 +1,12 @@ { "name": "calliope-web", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calliope-web", - "version": "1.3.1", + "version": "1.3.2", "dependencies": { "@tanstack/svelte-query": "^5.66.0" }, diff --git a/calliope-web/package.json b/calliope-web/package.json index 80235f1d..c2655635 100644 --- a/calliope-web/package.json +++ b/calliope-web/package.json @@ -1,6 +1,6 @@ { "name": "calliope-web", - "version": "1.3.1", + "version": "1.3.2", "private": true, "scripts": { "dev": "vite dev", diff --git a/calliope-web/src/lib/components/video/PromptPreviewModal.svelte b/calliope-web/src/lib/components/video/PromptPreviewModal.svelte index 31c991c0..da2dbdd0 100644 --- a/calliope-web/src/lib/components/video/PromptPreviewModal.svelte +++ b/calliope-web/src/lib/components/video/PromptPreviewModal.svelte @@ -38,7 +38,10 @@ let basedOn = $state(''); let fromDraft = $state(false); let stale = $state(false); - let loadedScene = $state(null); + /** Scene id whose resolve already fired once — guards against store-transition re-runs. */ + let attemptedFor = $state(null); + /** Resolve failed — modal shows a client-side prose fallback instead of a dead end. */ + let failed = $state(false); const preview = createMutation({ mutationFn: async () => { @@ -52,21 +55,41 @@ text = data.prompt; basedOn = data.based_on; fromDraft = data.from_draft; + failed = false; stale = false; }, onError: (err) => { + failed = true; + if (scene) { + // Never a dead end: populate the editor with raw scene text so the + // user can edit and Generate (confirm sends it via prompts override), + // or hit Regenerate to retry the rewrite. + text = proseFallback(scene); + basedOn = ''; + fromDraft = false; + } toast.error(err instanceof Error ? err.message : String(err)); }, }); - // Resolve whenever the modal opens for a (new) scene. + // Resolve once per scene. `attemptedFor` is set before mutating so + // mutation-store transitions (pending → success/error) can't re-trigger + // this effect — the old `text` guard fired duplicate requests while the + // first was still pending. $effect(() => { if (!open || !scene) return; - if (loadedScene === scene.id && text) return; - loadedScene = scene.id; + if (attemptedFor === scene.id) return; + attemptedFor = scene.id; $preview.mutate(); }); + function proseFallback(s: Scene): string { + const heading = (s.heading || '').trim(); + const action = (s.action || '').trim(); + const dialog = (s.dialog || '').trim(); + return [heading, action, dialog].filter(Boolean).join('\n\n'); + } + // Stale check: a draft saved against different scene content should warn. $effect(() => { if (!scene || !basedOn) return; @@ -93,6 +116,7 @@ } function regenerate() { + failed = false; $preview.mutate(); } @@ -136,7 +160,12 @@ aria-label="Prompt text sent to the workflow" > - {#if fromDraft} + {#if failed} +
+ + Rewrite unavailable — showing raw scene text. You can edit and Generate, or Retry. +
+ {:else if fromDraft}

Loaded from your saved draft. Regenerate re-runs the rewrite.

{:else if workflow?.prompt_profile === 'minimax_h3_ref'}

MiniMax H3 six-section rewrite. Edit freely — this exact text goes to the (Input:prompt) node.

diff --git a/docs/changelogs/2026-08-31-hardening.md b/docs/changelogs/2026-08-31-hardening.md new file mode 100644 index 00000000..0eed51d9 --- /dev/null +++ b/docs/changelogs/2026-08-31-hardening.md @@ -0,0 +1,41 @@ +# Calliope 1.3.2 — prompt preview hardening + +Follow-up to 1.3.0's prompt review gate. Fixes the failure mode where an +unreachable or slow LLM endpoint left the **Review prompt** modal hanging on +"Resolving prompt…" until the connection died (`Failed to fetch`), and made +Generate impossible. + +## Fixed + +- **Preview requests no longer double-fire.** The modal's resolve effect was + subscribed to its own request store and re-fired on every state transition + while the prompt text was still empty — producing duplicate (sometimes + parallel) `POST /preview-prompt` calls per scene. The effect now fires + exactly once per scene. +- **Dead LLM endpoints fail fast.** The preview rewrite now times out after + **30 s** (enqueue keeps the 120 s headroom for thinking models) and returns + the deterministic six-section H3 template instead of hanging. +- **The modal is never a dead end.** When the rewrite can't be resolved, the + editor is populated with the raw scene text (heading + action + dialog) and + a hint explains the situation. You can edit that text and Generate — the + confirmed text wins — or hit **Regenerate** to retry. Previously an error + left an empty editor with a spinner-less dead end and re-firing requests + on every reopen. + +## Notes + +- The new client-side fallback text is **not** H3-formatted; that's + intentional and labeled in the UI. Generate sends it verbatim via the + confirmed-prompt override. +- If you hit this in the wild, also check **Settings → LLM**: a wrong base + URL or API key is the usual cause of ~60 s hangs (a proxy cutting an idle + connection to a dead endpoint). + +## Upgrade + +```bash +git pull +cd calliope-web && npm install +``` + +Backend deps unchanged. Restart both processes (`start.bat`). diff --git a/docs/changelogs/README.md b/docs/changelogs/README.md index 1db8a7e3..63cfb94c 100644 --- a/docs/changelogs/README.md +++ b/docs/changelogs/README.md @@ -2,6 +2,7 @@ Dated notes for GitHub releases. Newest first. +- [2026-08-31 hardening](./2026-08-31-hardening.md) — Prompt preview fails fast + never blocks Generate (1.3.2) - [2026-08-31 patch](./2026-08-31-patch.md) — SvelteKit security bump so `npm audit fix --force` is never needed (1.3.1) - [2026-08-31](./2026-08-31.md) — Prompt review gate, ComfyUI error surfacing, saved scene setups (1.3.0) - [2026-08-29](./2026-08-29.md) — Model per agent + clip-native export fps + video-extend continue (1.2.3)