refactor: describe asset completion results
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user