diff --git a/backend/app/services/story_service.py b/backend/app/services/story_service.py index 720265c..72752fa 100644 --- a/backend/app/services/story_service.py +++ b/backend/app/services/story_service.py @@ -318,6 +318,148 @@ async def _complete_cover_image_asset( ) from exc return None, provider_error + + +def _get_storybook_pages_data(story: Story) -> list[dict]: + """Return mutable storybook page data from the persisted JSON field.""" + + return [dict(page) for page in story.pages or [] if isinstance(page, dict)] + + +async def _complete_storybook_image_assets( + story: Story, + db: AsyncSession, +) -> None: + """Complete missing cover/page images for a persisted storybook.""" + + pages_data = _get_storybook_pages_data(story) + has_image_prompt = bool(story.cover_prompt) or any( + page.get("image_prompt") for page in pages_data + ) + if not has_image_prompt: + raise HTTPException(status_code=400, detail="Storybook has no image prompts") + + sync_story_status(story, image_status=StoryAssetStatus.GENERATING) + await db.commit() + + cover_failed = False + failed_pages: list[int] = [] + + if story.cover_prompt and not story.image_url: + try: + story.image_url = await generate_image(story.cover_prompt, db=db) + except Exception as exc: + cover_failed = True + logger.warning( + "storybook_cover_asset_completion_failed", + story_id=story.id, + error=str(exc), + ) + + for page in pages_data: + if not page.get("image_prompt") or page.get("image_url"): + continue + + try: + page["image_url"] = await generate_image(page["image_prompt"], db=db) + except Exception as exc: + page_number = page.get("page_number") + if isinstance(page_number, int): + failed_pages.append(page_number) + logger.warning( + "storybook_page_asset_completion_failed", + story_id=story.id, + page=page_number, + error=str(exc), + ) + + story.pages = pages_data + sync_story_status( + story, + image_status=_resolve_storybook_image_status( + generate_images=True, + cover_prompt=story.cover_prompt, + cover_url=story.image_url, + pages_data=pages_data, + ), + last_error=_build_storybook_error_message( + cover_failed=cover_failed, + failed_pages=failed_pages, + ), + ) + await db.commit() + + +async def _read_cached_audio_asset(story: Story, db: AsyncSession) -> bytes | None: + """Read cached audio or repair stale audio cache metadata.""" + + if story.audio_path and audio_cache_exists(story.audio_path): + if story.audio_status != StoryAssetStatus.READY.value: + sync_story_status(story, audio_status=StoryAssetStatus.READY) + await db.commit() + return read_audio_cache(story.audio_path) + + if story.audio_path and not audio_cache_exists(story.audio_path): + logger.warning( + "story_audio_cache_missing", + story_id=story.id, + audio_path=story.audio_path, + ) + story.audio_path = None + if story.audio_status == StoryAssetStatus.READY.value: + sync_story_status(story, audio_status=StoryAssetStatus.NOT_REQUESTED) + await db.commit() + + return None + + +async def _complete_audio_asset( + story: Story, + db: AsyncSession, + *, + raise_on_failure: bool = True, +) -> bytes | None: + """Complete TTS audio generation through one asset workflow.""" + + if not story.story_text: + raise HTTPException(status_code=400, detail="Story has no text") + + cached_audio = await _read_cached_audio_asset(story, db) + if cached_audio is not None: + return cached_audio + + from app.services.provider_router import text_to_speech + + sync_story_status(story, audio_status=StoryAssetStatus.GENERATING) + await db.commit() + + try: + audio_data = await text_to_speech(story.story_text, db=db) + story.audio_path = write_story_audio_cache(story.id, audio_data) + sync_story_status( + story, + audio_status=StoryAssetStatus.READY, + ) + await db.commit() + return audio_data + except Exception as exc: + provider_error = str(exc) + story.audio_path = None + sync_story_status( + story, + audio_status=StoryAssetStatus.FAILED, + last_error=provider_error, + ) + await db.commit() + logger.error("audio_generation_failed", story_id=story.id, error=provider_error) + + if raise_on_failure: + raise HTTPException( + status_code=500, + detail=f"Audio generation failed: {provider_error}", + ) from exc + + return None async def validate_profile_and_universe( @@ -672,74 +814,13 @@ async def _retry_cover_image_asset(story: Story, db: AsyncSession) -> None: async def _retry_storybook_image_assets(story: Story, db: AsyncSession) -> None: """Retry missing storybook cover/page images.""" - pages_data = [dict(page) for page in story.pages or [] if isinstance(page, dict)] - has_image_prompt = bool(story.cover_prompt) or any( - page.get("image_prompt") for page in pages_data - ) - if not has_image_prompt: - raise HTTPException(status_code=400, detail="Storybook has no image prompts") - - sync_story_status(story, image_status=StoryAssetStatus.GENERATING) - await db.commit() - - cover_failed = False - failed_pages: list[int] = [] - - if story.cover_prompt and not story.image_url: - try: - story.image_url = await generate_image(story.cover_prompt, db=db) - except Exception as exc: - cover_failed = True - logger.warning( - "storybook_cover_asset_retry_failed", - story_id=story.id, - error=str(exc), - ) - - for page in pages_data: - if not page.get("image_prompt") or page.get("image_url"): - continue - - try: - page["image_url"] = await generate_image(page["image_prompt"], db=db) - except Exception as exc: - page_number = page.get("page_number") - if isinstance(page_number, int): - failed_pages.append(page_number) - logger.warning( - "storybook_page_asset_retry_failed", - story_id=story.id, - page=page_number, - error=str(exc), - ) - - story.pages = pages_data - sync_story_status( - story, - image_status=_resolve_storybook_image_status( - generate_images=True, - cover_prompt=story.cover_prompt, - cover_url=story.image_url, - pages_data=pages_data, - ), - last_error=_build_storybook_error_message( - cover_failed=cover_failed, - failed_pages=failed_pages, - ), - ) - await db.commit() + await _complete_storybook_image_assets(story, db) -async def _retry_audio_asset(story_id: int, user_id: str, db: AsyncSession) -> None: +async def _retry_audio_asset(story: Story, db: AsyncSession) -> None: """Retry audio generation while preserving persisted status on provider failure.""" - try: - await generate_story_audio(story_id, user_id, db) - except HTTPException as exc: - if exc.status_code >= 500: - logger.warning("audio_asset_retry_failed", story_id=story_id, error=exc.detail) - return - raise + await _complete_audio_asset(story, db, raise_on_failure=False) async def retry_story_assets( @@ -760,7 +841,7 @@ async def retry_story_assets( await _retry_cover_image_asset(story, db) if "audio" in requested_assets: - await _retry_audio_asset(story_id, user_id, db) + await _retry_audio_asset(story, db) return await get_story_detail(story_id, user_id, db) @@ -790,53 +871,14 @@ async def generate_story_audio( user_id: str, db: AsyncSession, ) -> bytes: - """Generate audio for a story.""" - story = await get_story_detail(story_id, user_id, db) + """Generate audio for a story.""" + story = await get_story_detail(story_id, user_id, db) - if not story.story_text: - raise HTTPException(status_code=400, detail="Story has no text") - - if story.audio_path and audio_cache_exists(story.audio_path): - if story.audio_status != StoryAssetStatus.READY.value: - sync_story_status(story, audio_status=StoryAssetStatus.READY) - await db.commit() - return read_audio_cache(story.audio_path) - - if story.audio_path and not audio_cache_exists(story.audio_path): - logger.warning( - "story_audio_cache_missing", - story_id=story_id, - audio_path=story.audio_path, - ) - story.audio_path = None - if story.audio_status == StoryAssetStatus.READY.value: - sync_story_status(story, audio_status=StoryAssetStatus.NOT_REQUESTED) - await db.commit() - - from app.services.provider_router import text_to_speech - - sync_story_status(story, audio_status=StoryAssetStatus.GENERATING) - await db.commit() - - try: - audio_data = await text_to_speech(story.story_text, db=db) - story.audio_path = write_story_audio_cache(story.id, audio_data) - sync_story_status( - story, - audio_status=StoryAssetStatus.READY, - ) - await db.commit() + audio_data = await _complete_audio_asset(story, db, raise_on_failure=True) + if audio_data is not None: return audio_data - except Exception as e: - story.audio_path = None - sync_story_status( - story, - audio_status=StoryAssetStatus.FAILED, - last_error=str(e), - ) - await db.commit() - logger.error("audio_generation_failed", story_id=story_id, error=str(e)) - raise HTTPException(status_code=500, detail=f"Audio generation failed: {e}") + + raise HTTPException(status_code=500, detail="Audio generation failed") async def get_story_achievements( diff --git a/backend/tests/test_stories.py b/backend/tests/test_stories.py index f9bfe56..fc11a8b 100644 --- a/backend/tests/test_stories.py +++ b/backend/tests/test_stories.py @@ -593,6 +593,51 @@ class TestAssetRetry: assert data["pages"][1]["image_url"] == "https://example.com/retried-page.png" mock_image.assert_awaited_once() + def test_retry_audio_success( + self, + auth_client: TestClient, + test_story, + mock_tts_provider, + ): + response = auth_client.post( + f"/api/stories/{test_story.id}/assets/retry", + json={"assets": ["audio"]}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["generation_status"] == "completed" + assert data["image_status"] == "not_requested" + assert data["audio_status"] == "ready" + assert data["last_error"] is None + mock_tts_provider.assert_awaited_once() + + cached_audio_path = Path(settings.story_audio_cache_dir) / f"story-{test_story.id}.mp3" + assert cached_audio_path.is_file() + + def test_retry_audio_failure_updates_status_without_blocking_response( + self, + auth_client: TestClient, + test_story, + ): + with patch( + "app.services.provider_router.text_to_speech", + new_callable=AsyncMock, + ) as mock_tts: + mock_tts.side_effect = Exception("TTS provider timeout") + + response = auth_client.post( + f"/api/stories/{test_story.id}/assets/retry", + json={"assets": ["audio"]}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["generation_status"] == "degraded_completed" + assert data["image_status"] == "not_requested" + assert data["audio_status"] == "failed" + assert "TTS provider timeout" in data["last_error"] + def test_retry_audio_on_storybook_is_rejected( self, auth_client: TestClient, diff --git a/docs/planning/week-1-execution-backlog.md b/docs/planning/week-1-execution-backlog.md index cc0411f..8e7d83f 100644 --- a/docs/planning/week-1-execution-backlog.md +++ b/docs/planning/week-1-execution-backlog.md @@ -53,7 +53,7 @@ - 已新增数据库迁移: - `0009_add_story_generation_statuses.py` - `0010_add_story_audio_cache_path.py` -- 已完成一轮后端回归验证:`backend/` 下 `pytest -q` 结果为 `64 passed` +- 已完成一轮后端回归验证:`backend/` 下 `pytest -q` 结果为 `66 passed` - 已完成全量后端 lint 清理:`ruff check app tests` 可通过 - 已修复 admin-frontend 构建阻塞,主前端与管理端前端均可生产构建 - 已落地首版统一资产重试入口:`POST /api/stories/{story_id}/assets/retry` @@ -67,12 +67,14 @@ - 文本故事主记录保存 - 绘本主记录保存 - 普通故事封面生成/重试 + - 绘本缺失插图补全 + - 故事音频缓存读取与 TTS 生成 ### What Is In Progress - 统一状态模型与统一外部 API 已落地,内部 service workflow 已开始收束公共步骤 - 旧生成 API 仍保留为兼容层,后续需要继续降低重复实现 -- 资产补全已经具备统一重试入口首版,但绘本插图与音频还需要继续抽象统一补全过程和 generation job 边界 +- 资产补全已经具备统一重试入口首版,封面/绘本插图/音频已有 asset completion helper;后续需要继续抽象 generation job 边界 ### What Is Still Pending diff --git a/docs/product/unified-generation-workflow-prd.md b/docs/product/unified-generation-workflow-prd.md index f13f89f..793677e 100644 --- a/docs/product/unified-generation-workflow-prd.md +++ b/docs/product/unified-generation-workflow-prd.md @@ -42,14 +42,14 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本 - service 内部已开始收束统一工作流步骤: - 上下文准备:档案/宇宙校验 + memory context 构建 - 主记录保存:文本故事与绘本统一持久化入口 - - 资产补全:普通故事封面生成/重试统一封装 + - 资产补全:普通故事封面、绘本缺失插图、故事音频缓存/生成统一封装 - 故事详情页封面补全已切换到统一资产重试入口 - 管理端前端构建阻塞已修复,主前端与 admin 前端均可完成生产构建 ### Still Missing - 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 已开始抽取公共步骤,但旧 service 函数仍作为兼容层保留 -- 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,但尚未抽象成完整 generation job 模型 +- 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,并已抽出 asset completion helper,但尚未抽象成完整 generation job 模型 - `partial_ready`、`retryable_assets` 等更细粒度状态仍停留在目标态 ### What This Means @@ -65,10 +65,10 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本 DreamWeaver 当前存在以下工作流层面问题: 1. **生成入口已建立,内部路径正在收束** - 当前前端已切到 `/api/generations`,旧的 `/api/stories/generate`、`/api/stories/generate/full`、`/api/storybook/generate` 仍作为兼容入口保留。service 内部已抽取上下文准备、主记录保存和封面资产补全 helper,下一步重点是继续统一音频与绘本插图补全过程。 + 当前前端已切到 `/api/generations`,旧的 `/api/stories/generate`、`/api/stories/generate/full`、`/api/storybook/generate` 仍作为兼容入口保留。service 内部已抽取上下文准备、主记录保存、封面补全、绘本插图补全和音频补全 helper,下一步重点是把这些 helper 组织成更明确的 generation job 边界。 2. **保存与资产补全过程正在统一** - 文本故事和绘本已拥有更清晰的主记录保存 helper;普通故事封面生成与重试已共用资产补全 helper。剩余差异集中在绘本插图补全和音频补全过程还未统一成 generation job。 + 文本故事和绘本已拥有更清晰的主记录保存 helper;普通故事封面、绘本缺失插图、故事音频生成/缓存已共用各自的 asset completion helper。剩余差异集中在还没有统一的 job 对象来描述资产任务。 3. **状态表达不统一** 系统缺少标准的“生成中、部分完成、已完成、失败、可重试”等状态定义,导致前端难以做出成熟体验。