From 0444b81df6516c27ece468e632debd3e23a24f23 Mon Sep 17 00:00:00 2001 From: Yuyan Date: Sat, 18 Apr 2026 13:26:58 +0800 Subject: [PATCH] refactor: describe asset completion results --- backend/app/services/story_service.py | 110 +++++++++++++----- docs/planning/week-1-execution-backlog.md | 3 +- .../unified-generation-workflow-prd.md | 7 +- 3 files changed, 89 insertions(+), 31 deletions(-) diff --git a/backend/app/services/story_service.py b/backend/app/services/story_service.py index 72752fa..4514ec2 100644 --- a/backend/app/services/story_service.py +++ b/backend/app/services/story_service.py @@ -1,6 +1,8 @@ """Story business logic service.""" import asyncio +from dataclasses import dataclass +from typing import Literal from fastapi import HTTPException from sqlalchemy import desc, select @@ -40,6 +42,25 @@ from app.tasks.achievements import extract_story_achievements logger = get_logger(__name__) +AssetCompletionKind = Literal["cover_image", "storybook_images", "audio"] + + +@dataclass(frozen=True) +class AssetCompletionResult: + """Service-level result for a generated asset completion attempt.""" + + asset: AssetCompletionKind + status: StoryAssetStatus + value: str | bytes | None = None + error: str | None = None + blocks_main_result: bool = False + + @property + def succeeded(self) -> bool: + """Whether the asset reached a usable ready state.""" + + return self.status == StoryAssetStatus.READY and self.error is None + def _build_storybook_error_message( *, @@ -281,7 +302,7 @@ async def _complete_cover_image_asset( raise_on_failure: bool = False, last_error_prefix: str | None = None, log_event: str = "cover_asset_generation_failed", -) -> tuple[str | None, str | None]: +) -> AssetCompletionResult: """Generate or retry a text story cover through one asset workflow.""" if not story.cover_prompt: @@ -295,7 +316,12 @@ async def _complete_cover_image_asset( story.image_url = image_url sync_story_status(story, image_status=StoryAssetStatus.READY) await db.commit() - return image_url, None + return AssetCompletionResult( + asset="cover_image", + status=StoryAssetStatus.READY, + value=image_url, + blocks_main_result=raise_on_failure, + ) except Exception as exc: provider_error = str(exc) last_error = ( @@ -317,7 +343,12 @@ async def _complete_cover_image_asset( detail=f"Image generation failed: {provider_error}", ) from exc - return None, provider_error + return AssetCompletionResult( + asset="cover_image", + status=StoryAssetStatus.FAILED, + error=provider_error, + blocks_main_result=raise_on_failure, + ) def _get_storybook_pages_data(story: Story) -> list[dict]: @@ -329,7 +360,7 @@ def _get_storybook_pages_data(story: Story) -> list[dict]: async def _complete_storybook_image_assets( story: Story, db: AsyncSession, -) -> None: +) -> AssetCompletionResult: """Complete missing cover/page images for a persisted storybook.""" pages_data = _get_storybook_pages_data(story) @@ -374,20 +405,28 @@ async def _complete_storybook_image_assets( ) story.pages = pages_data + error_message = _build_storybook_error_message( + cover_failed=cover_failed, + failed_pages=failed_pages, + ) + image_status = _resolve_storybook_image_status( + generate_images=True, + cover_prompt=story.cover_prompt, + cover_url=story.image_url, + pages_data=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, - ), + image_status=image_status, + last_error=error_message, ) await db.commit() + return AssetCompletionResult( + asset="storybook_images", + status=image_status, + value=story.image_url, + error=error_message, + ) async def _read_cached_audio_asset(story: Story, db: AsyncSession) -> bytes | None: @@ -418,7 +457,7 @@ async def _complete_audio_asset( db: AsyncSession, *, raise_on_failure: bool = True, -) -> bytes | None: +) -> AssetCompletionResult: """Complete TTS audio generation through one asset workflow.""" if not story.story_text: @@ -426,7 +465,12 @@ async def _complete_audio_asset( cached_audio = await _read_cached_audio_asset(story, db) if cached_audio is not None: - return cached_audio + return AssetCompletionResult( + asset="audio", + status=StoryAssetStatus.READY, + value=cached_audio, + blocks_main_result=raise_on_failure, + ) from app.services.provider_router import text_to_speech @@ -441,7 +485,12 @@ async def _complete_audio_asset( audio_status=StoryAssetStatus.READY, ) await db.commit() - return audio_data + return AssetCompletionResult( + asset="audio", + status=StoryAssetStatus.READY, + value=audio_data, + blocks_main_result=raise_on_failure, + ) except Exception as exc: provider_error = str(exc) story.audio_path = None @@ -459,7 +508,12 @@ async def _complete_audio_asset( detail=f"Audio generation failed: {provider_error}", ) from exc - return None + return AssetCompletionResult( + asset="audio", + status=StoryAssetStatus.FAILED, + error=provider_error, + blocks_main_result=raise_on_failure, + ) async def validate_profile_and_universe( @@ -550,13 +604,15 @@ async def generate_full_story_service( errors: dict[str, str | None] = {} if story.cover_prompt: - image_url, image_error = await _complete_cover_image_asset( + image_result = await _complete_cover_image_asset( story, db, log_event="image_generation_failed", ) - if image_error: - errors["image"] = image_error + if image_result.succeeded and isinstance(image_result.value, str): + image_url = image_result.value + if image_result.error: + errors["image"] = image_result.error return FullStoryResponse( id=story.id, @@ -854,14 +910,14 @@ async def generate_story_cover( """Generate cover image for an existing story.""" story = await get_story_detail(story_id, user_id, db) - image_url, _ = await _complete_cover_image_asset( + image_result = await _complete_cover_image_asset( story, db, raise_on_failure=True, log_event="cover_generation_failed", ) - if image_url is not None: - return image_url + if image_result.succeeded and isinstance(image_result.value, str): + return image_result.value raise HTTPException(status_code=500, detail="Image generation failed") @@ -874,9 +930,9 @@ async def generate_story_audio( """Generate audio for a story.""" story = await get_story_detail(story_id, user_id, db) - audio_data = await _complete_audio_asset(story, db, raise_on_failure=True) - if audio_data is not None: - return audio_data + audio_result = await _complete_audio_asset(story, db, raise_on_failure=True) + if audio_result.succeeded and isinstance(audio_result.value, bytes): + return audio_result.value raise HTTPException(status_code=500, detail="Audio generation failed") diff --git a/docs/planning/week-1-execution-backlog.md b/docs/planning/week-1-execution-backlog.md index 8e7d83f..140e05b 100644 --- a/docs/planning/week-1-execution-backlog.md +++ b/docs/planning/week-1-execution-backlog.md @@ -69,12 +69,13 @@ - 普通故事封面生成/重试 - 绘本缺失插图补全 - 故事音频缓存读取与 TTS 生成 +- 已引入首版服务层 `AssetCompletionResult`,用于统一表达资产补全结果 ### What Is In Progress - 统一状态模型与统一外部 API 已落地,内部 service workflow 已开始收束公共步骤 - 旧生成 API 仍保留为兼容层,后续需要继续降低重复实现 -- 资产补全已经具备统一重试入口首版,封面/绘本插图/音频已有 asset completion helper;后续需要继续抽象 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 793677e..e174da5 100644 --- a/docs/product/unified-generation-workflow-prd.md +++ b/docs/product/unified-generation-workflow-prd.md @@ -43,13 +43,14 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本 - 上下文准备:档案/宇宙校验 + memory context 构建 - 主记录保存:文本故事与绘本统一持久化入口 - 资产补全:普通故事封面、绘本缺失插图、故事音频缓存/生成统一封装 +- 已引入首版服务层 `AssetCompletionResult`,用于表达资产补全类型、状态、结果值、错误信息和是否阻塞主结果 - 故事详情页封面补全已切换到统一资产重试入口 - 管理端前端构建阻塞已修复,主前端与 admin 前端均可完成生产构建 ### Still Missing - 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 已开始抽取公共步骤,但旧 service 函数仍作为兼容层保留 -- 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,并已抽出 asset completion helper,但尚未抽象成完整 generation job 模型 +- 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,并已抽出 asset completion helper 与 `AssetCompletionResult`,但尚未落库为完整 generation job 模型 - `partial_ready`、`retryable_assets` 等更细粒度状态仍停留在目标态 ### What This Means @@ -65,10 +66,10 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本 DreamWeaver 当前存在以下工作流层面问题: 1. **生成入口已建立,内部路径正在收束** - 当前前端已切到 `/api/generations`,旧的 `/api/stories/generate`、`/api/stories/generate/full`、`/api/storybook/generate` 仍作为兼容入口保留。service 内部已抽取上下文准备、主记录保存、封面补全、绘本插图补全和音频补全 helper,下一步重点是把这些 helper 组织成更明确的 generation job 边界。 + 当前前端已切到 `/api/generations`,旧的 `/api/stories/generate`、`/api/stories/generate/full`、`/api/storybook/generate` 仍作为兼容入口保留。service 内部已抽取上下文准备、主记录保存、封面补全、绘本插图补全和音频补全 helper,并用 `AssetCompletionResult` 表达资产补全结果。下一步重点是决定这些结果是否需要进一步沉淀为可查询的 generation job。 2. **保存与资产补全过程正在统一** - 文本故事和绘本已拥有更清晰的主记录保存 helper;普通故事封面、绘本缺失插图、故事音频生成/缓存已共用各自的 asset completion helper。剩余差异集中在还没有统一的 job 对象来描述资产任务。 + 文本故事和绘本已拥有更清晰的主记录保存 helper;普通故事封面、绘本缺失插图、故事音频生成/缓存已共用各自的 asset completion helper。服务层已经能表达资产任务结果,剩余差异集中在还没有持久化 job 对象。 3. **状态表达不统一** 系统缺少标准的“生成中、部分完成、已完成、失败、可重试”等状态定义,导致前端难以做出成熟体验。