refactor: describe asset completion results
This commit is contained in:
@@ -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
|
||||||
|
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(
|
sync_story_status(
|
||||||
story,
|
story,
|
||||||
image_status=_resolve_storybook_image_status(
|
image_status=image_status,
|
||||||
generate_images=True,
|
last_error=error_message,
|
||||||
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 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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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. **状态表达不统一**
|
||||||
系统缺少标准的“生成中、部分完成、已完成、失败、可重试”等状态定义,导致前端难以做出成熟体验。
|
系统缺少标准的“生成中、部分完成、已完成、失败、可重试”等状态定义,导致前端难以做出成熟体验。
|
||||||
|
|||||||
Reference in New Issue
Block a user