refactor: unify asset completion workflows
This commit is contained in:
@@ -318,6 +318,148 @@ async def _complete_cover_image_asset(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
return None, provider_error
|
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(
|
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:
|
async def _retry_storybook_image_assets(story: Story, db: AsyncSession) -> None:
|
||||||
"""Retry missing storybook cover/page images."""
|
"""Retry missing storybook cover/page images."""
|
||||||
|
|
||||||
pages_data = [dict(page) for page in story.pages or [] if isinstance(page, dict)]
|
await _complete_storybook_image_assets(story, db)
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
"""Retry audio generation while preserving persisted status on provider failure."""
|
||||||
|
|
||||||
try:
|
await _complete_audio_asset(story, db, raise_on_failure=False)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def retry_story_assets(
|
async def retry_story_assets(
|
||||||
@@ -760,7 +841,7 @@ async def retry_story_assets(
|
|||||||
await _retry_cover_image_asset(story, db)
|
await _retry_cover_image_asset(story, db)
|
||||||
|
|
||||||
if "audio" in requested_assets:
|
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)
|
return await get_story_detail(story_id, user_id, db)
|
||||||
|
|
||||||
@@ -790,53 +871,14 @@ async def generate_story_audio(
|
|||||||
user_id: str,
|
user_id: str,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""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)
|
||||||
|
|
||||||
if not story.story_text:
|
audio_data = await _complete_audio_asset(story, db, raise_on_failure=True)
|
||||||
raise HTTPException(status_code=400, detail="Story has no text")
|
if audio_data is not None:
|
||||||
|
|
||||||
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()
|
|
||||||
return audio_data
|
return audio_data
|
||||||
except Exception as e:
|
|
||||||
story.audio_path = None
|
raise HTTPException(status_code=500, detail="Audio generation failed")
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
async def get_story_achievements(
|
async def get_story_achievements(
|
||||||
|
|||||||
@@ -593,6 +593,51 @@ class TestAssetRetry:
|
|||||||
assert data["pages"][1]["image_url"] == "https://example.com/retried-page.png"
|
assert data["pages"][1]["image_url"] == "https://example.com/retried-page.png"
|
||||||
mock_image.assert_awaited_once()
|
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(
|
def test_retry_audio_on_storybook_is_rejected(
|
||||||
self,
|
self,
|
||||||
auth_client: TestClient,
|
auth_client: TestClient,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
- 已新增数据库迁移:
|
- 已新增数据库迁移:
|
||||||
- `0009_add_story_generation_statuses.py`
|
- `0009_add_story_generation_statuses.py`
|
||||||
- `0010_add_story_audio_cache_path.py`
|
- `0010_add_story_audio_cache_path.py`
|
||||||
- 已完成一轮后端回归验证:`backend/` 下 `pytest -q` 结果为 `64 passed`
|
- 已完成一轮后端回归验证:`backend/` 下 `pytest -q` 结果为 `66 passed`
|
||||||
- 已完成全量后端 lint 清理:`ruff check app tests` 可通过
|
- 已完成全量后端 lint 清理:`ruff check app tests` 可通过
|
||||||
- 已修复 admin-frontend 构建阻塞,主前端与管理端前端均可生产构建
|
- 已修复 admin-frontend 构建阻塞,主前端与管理端前端均可生产构建
|
||||||
- 已落地首版统一资产重试入口:`POST /api/stories/{story_id}/assets/retry`
|
- 已落地首版统一资产重试入口:`POST /api/stories/{story_id}/assets/retry`
|
||||||
@@ -67,12 +67,14 @@
|
|||||||
- 文本故事主记录保存
|
- 文本故事主记录保存
|
||||||
- 绘本主记录保存
|
- 绘本主记录保存
|
||||||
- 普通故事封面生成/重试
|
- 普通故事封面生成/重试
|
||||||
|
- 绘本缺失插图补全
|
||||||
|
- 故事音频缓存读取与 TTS 生成
|
||||||
|
|
||||||
### What Is In Progress
|
### What Is In Progress
|
||||||
|
|
||||||
- 统一状态模型与统一外部 API 已落地,内部 service workflow 已开始收束公共步骤
|
- 统一状态模型与统一外部 API 已落地,内部 service workflow 已开始收束公共步骤
|
||||||
- 旧生成 API 仍保留为兼容层,后续需要继续降低重复实现
|
- 旧生成 API 仍保留为兼容层,后续需要继续降低重复实现
|
||||||
- 资产补全已经具备统一重试入口首版,但绘本插图与音频还需要继续抽象统一补全过程和 generation job 边界
|
- 资产补全已经具备统一重试入口首版,封面/绘本插图/音频已有 asset completion helper;后续需要继续抽象 generation job 边界
|
||||||
|
|
||||||
### What Is Still Pending
|
### What Is Still Pending
|
||||||
|
|
||||||
|
|||||||
@@ -42,14 +42,14 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
|
|||||||
- service 内部已开始收束统一工作流步骤:
|
- service 内部已开始收束统一工作流步骤:
|
||||||
- 上下文准备:档案/宇宙校验 + memory context 构建
|
- 上下文准备:档案/宇宙校验 + memory context 构建
|
||||||
- 主记录保存:文本故事与绘本统一持久化入口
|
- 主记录保存:文本故事与绘本统一持久化入口
|
||||||
- 资产补全:普通故事封面生成/重试统一封装
|
- 资产补全:普通故事封面、绘本缺失插图、故事音频缓存/生成统一封装
|
||||||
- 故事详情页封面补全已切换到统一资产重试入口
|
- 故事详情页封面补全已切换到统一资产重试入口
|
||||||
- 管理端前端构建阻塞已修复,主前端与 admin 前端均可完成生产构建
|
- 管理端前端构建阻塞已修复,主前端与 admin 前端均可完成生产构建
|
||||||
|
|
||||||
### Still Missing
|
### Still Missing
|
||||||
|
|
||||||
- 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 已开始抽取公共步骤,但旧 service 函数仍作为兼容层保留
|
- 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 已开始抽取公共步骤,但旧 service 函数仍作为兼容层保留
|
||||||
- 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,但尚未抽象成完整 generation job 模型
|
- 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,并已抽出 asset completion helper,但尚未抽象成完整 generation job 模型
|
||||||
- `partial_ready`、`retryable_assets` 等更细粒度状态仍停留在目标态
|
- `partial_ready`、`retryable_assets` 等更细粒度状态仍停留在目标态
|
||||||
|
|
||||||
### What This Means
|
### What This Means
|
||||||
@@ -65,10 +65,10 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
|
|||||||
DreamWeaver 当前存在以下工作流层面问题:
|
DreamWeaver 当前存在以下工作流层面问题:
|
||||||
|
|
||||||
1. **生成入口已建立,内部路径正在收束**
|
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. **保存与资产补全过程正在统一**
|
2. **保存与资产补全过程正在统一**
|
||||||
文本故事和绘本已拥有更清晰的主记录保存 helper;普通故事封面生成与重试已共用资产补全 helper。剩余差异集中在绘本插图补全和音频补全过程还未统一成 generation job。
|
文本故事和绘本已拥有更清晰的主记录保存 helper;普通故事封面、绘本缺失插图、故事音频生成/缓存已共用各自的 asset completion helper。剩余差异集中在还没有统一的 job 对象来描述资产任务。
|
||||||
|
|
||||||
3. **状态表达不统一**
|
3. **状态表达不统一**
|
||||||
系统缺少标准的“生成中、部分完成、已完成、失败、可重试”等状态定义,导致前端难以做出成熟体验。
|
系统缺少标准的“生成中、部分完成、已完成、失败、可重试”等状态定义,导致前端难以做出成熟体验。
|
||||||
|
|||||||
Reference in New Issue
Block a user