refactor: describe asset completion results

This commit is contained in:
2026-04-18 13:26:58 +08:00
parent f1cbd202ab
commit 0444b81df6
3 changed files with 89 additions and 31 deletions

View File

@@ -1,6 +1,8 @@
"""Story business logic service.""" """Story business logic service."""
import asyncio import asyncio
from dataclasses import dataclass
from typing import Literal
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import desc, select from sqlalchemy import desc, select
@@ -40,6 +42,25 @@ from app.tasks.achievements import extract_story_achievements
logger = get_logger(__name__) 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( def _build_storybook_error_message(
*, *,
@@ -281,7 +302,7 @@ async def _complete_cover_image_asset(
raise_on_failure: bool = False, raise_on_failure: bool = False,
last_error_prefix: str | None = None, last_error_prefix: str | None = None,
log_event: str = "cover_asset_generation_failed", log_event: str = "cover_asset_generation_failed",
) -> tuple[str | None, str | None]: ) -> AssetCompletionResult:
"""Generate or retry a text story cover through one asset workflow.""" """Generate or retry a text story cover through one asset workflow."""
if not story.cover_prompt: if not story.cover_prompt:
@@ -295,7 +316,12 @@ async def _complete_cover_image_asset(
story.image_url = image_url story.image_url = image_url
sync_story_status(story, image_status=StoryAssetStatus.READY) sync_story_status(story, image_status=StoryAssetStatus.READY)
await db.commit() 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: except Exception as exc:
provider_error = str(exc) provider_error = str(exc)
last_error = ( last_error = (
@@ -317,7 +343,12 @@ async def _complete_cover_image_asset(
detail=f"Image generation failed: {provider_error}", detail=f"Image generation failed: {provider_error}",
) from exc ) 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]: 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( async def _complete_storybook_image_assets(
story: Story, story: Story,
db: AsyncSession, db: AsyncSession,
) -> None: ) -> AssetCompletionResult:
"""Complete missing cover/page images for a persisted storybook.""" """Complete missing cover/page images for a persisted storybook."""
pages_data = _get_storybook_pages_data(story) pages_data = _get_storybook_pages_data(story)
@@ -374,20 +405,28 @@ async def _complete_storybook_image_assets(
) )
story.pages = pages_data story.pages = pages_data
sync_story_status( error_message = _build_storybook_error_message(
story, cover_failed=cover_failed,
image_status=_resolve_storybook_image_status( failed_pages=failed_pages,
)
image_status = _resolve_storybook_image_status(
generate_images=True, generate_images=True,
cover_prompt=story.cover_prompt, cover_prompt=story.cover_prompt,
cover_url=story.image_url, cover_url=story.image_url,
pages_data=pages_data, pages_data=pages_data,
), )
last_error=_build_storybook_error_message( sync_story_status(
cover_failed=cover_failed, story,
failed_pages=failed_pages, image_status=image_status,
), last_error=error_message,
) )
await db.commit() 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: async def _read_cached_audio_asset(story: Story, db: AsyncSession) -> bytes | None:
@@ -418,7 +457,7 @@ async def _complete_audio_asset(
db: AsyncSession, db: AsyncSession,
*, *,
raise_on_failure: bool = True, raise_on_failure: bool = True,
) -> bytes | None: ) -> AssetCompletionResult:
"""Complete TTS audio generation through one asset workflow.""" """Complete TTS audio generation through one asset workflow."""
if not story.story_text: if not story.story_text:
@@ -426,7 +465,12 @@ async def _complete_audio_asset(
cached_audio = await _read_cached_audio_asset(story, db) cached_audio = await _read_cached_audio_asset(story, db)
if cached_audio is not None: 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 from app.services.provider_router import text_to_speech
@@ -441,7 +485,12 @@ async def _complete_audio_asset(
audio_status=StoryAssetStatus.READY, audio_status=StoryAssetStatus.READY,
) )
await db.commit() 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: except Exception as exc:
provider_error = str(exc) provider_error = str(exc)
story.audio_path = None story.audio_path = None
@@ -459,7 +508,12 @@ async def _complete_audio_asset(
detail=f"Audio generation failed: {provider_error}", detail=f"Audio generation failed: {provider_error}",
) from exc ) 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( async def validate_profile_and_universe(
@@ -550,13 +604,15 @@ async def generate_full_story_service(
errors: dict[str, str | None] = {} errors: dict[str, str | None] = {}
if story.cover_prompt: if story.cover_prompt:
image_url, image_error = await _complete_cover_image_asset( image_result = await _complete_cover_image_asset(
story, story,
db, db,
log_event="image_generation_failed", log_event="image_generation_failed",
) )
if image_error: if image_result.succeeded and isinstance(image_result.value, str):
errors["image"] = image_error image_url = image_result.value
if image_result.error:
errors["image"] = image_result.error
return FullStoryResponse( return FullStoryResponse(
id=story.id, id=story.id,
@@ -854,14 +910,14 @@ async def generate_story_cover(
"""Generate cover image for an existing story.""" """Generate cover image for an existing story."""
story = await get_story_detail(story_id, user_id, db) 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, story,
db, db,
raise_on_failure=True, raise_on_failure=True,
log_event="cover_generation_failed", log_event="cover_generation_failed",
) )
if image_url is not None: if image_result.succeeded and isinstance(image_result.value, str):
return image_url return image_result.value
raise HTTPException(status_code=500, detail="Image generation failed") raise HTTPException(status_code=500, detail="Image generation failed")
@@ -874,9 +930,9 @@ async def generate_story_audio(
"""Generate audio for a story.""" """Generate audio for a story."""
story = await get_story_detail(story_id, user_id, db) story = await get_story_detail(story_id, user_id, db)
audio_data = await _complete_audio_asset(story, db, raise_on_failure=True) audio_result = await _complete_audio_asset(story, db, raise_on_failure=True)
if audio_data is not None: if audio_result.succeeded and isinstance(audio_result.value, bytes):
return audio_data return audio_result.value
raise HTTPException(status_code=500, detail="Audio generation failed") raise HTTPException(status_code=500, detail="Audio generation failed")

View File

@@ -69,12 +69,13 @@
- 普通故事封面生成/重试 - 普通故事封面生成/重试
- 绘本缺失插图补全 - 绘本缺失插图补全
- 故事音频缓存读取与 TTS 生成 - 故事音频缓存读取与 TTS 生成
- 已引入首版服务层 `AssetCompletionResult`,用于统一表达资产补全结果
### What Is In Progress ### What Is In Progress
- 统一状态模型与统一外部 API 已落地,内部 service workflow 已开始收束公共步骤 - 统一状态模型与统一外部 API 已落地,内部 service workflow 已开始收束公共步骤
- 旧生成 API 仍保留为兼容层,后续需要继续降低重复实现 - 旧生成 API 仍保留为兼容层,后续需要继续降低重复实现
- 资产补全已经具备统一重试入口首版,封面/绘本插图/音频已有 asset completion helper;后续需要继续抽象 generation job 边界 - 资产补全已经具备统一重试入口首版,封面/绘本插图/音频已有 asset completion helper 与结果对象;后续需要评估是否落库为 generation job
### What Is Still Pending ### What Is Still Pending

View File

@@ -43,13 +43,14 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
- 上下文准备:档案/宇宙校验 + memory context 构建 - 上下文准备:档案/宇宙校验 + memory context 构建
- 主记录保存:文本故事与绘本统一持久化入口 - 主记录保存:文本故事与绘本统一持久化入口
- 资产补全:普通故事封面、绘本缺失插图、故事音频缓存/生成统一封装 - 资产补全:普通故事封面、绘本缺失插图、故事音频缓存/生成统一封装
- 已引入首版服务层 `AssetCompletionResult`,用于表达资产补全类型、状态、结果值、错误信息和是否阻塞主结果
- 故事详情页封面补全已切换到统一资产重试入口 - 故事详情页封面补全已切换到统一资产重试入口
- 管理端前端构建阻塞已修复,主前端与 admin 前端均可完成生产构建 - 管理端前端构建阻塞已修复,主前端与 admin 前端均可完成生产构建
### Still Missing ### Still Missing
- 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 已开始抽取公共步骤,但旧 service 函数仍作为兼容层保留 - 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 已开始抽取公共步骤,但旧 service 函数仍作为兼容层保留
- 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,并已抽出 asset completion helper但尚未抽象成完整 generation job 模型 - 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,并已抽出 asset completion helper`AssetCompletionResult`,但尚未落库为完整 generation job 模型
- `partial_ready``retryable_assets` 等更细粒度状态仍停留在目标态 - `partial_ready``retryable_assets` 等更细粒度状态仍停留在目标态
### What This Means ### What This Means
@@ -65,10 +66,10 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
DreamWeaver 当前存在以下工作流层面问题: DreamWeaver 当前存在以下工作流层面问题:
1. **生成入口已建立,内部路径正在收束** 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. **保存与资产补全过程正在统一** 2. **保存与资产补全过程正在统一**
文本故事和绘本已拥有更清晰的主记录保存 helper普通故事封面、绘本缺失插图、故事音频生成/缓存已共用各自的 asset completion helper。剩余差异集中在还没有统一的 job 对象来描述资产任务 文本故事和绘本已拥有更清晰的主记录保存 helper普通故事封面、绘本缺失插图、故事音频生成/缓存已共用各自的 asset completion helper。服务层已经能表达资产任务结果,剩余差异集中在还没有持久化 job 对象。
3. **状态表达不统一** 3. **状态表达不统一**
系统缺少标准的“生成中、部分完成、已完成、失败、可重试”等状态定义,导致前端难以做出成熟体验。 系统缺少标准的“生成中、部分完成、已完成、失败、可重试”等状态定义,导致前端难以做出成熟体验。