Files
dreamweaver/backend/app/services/story_service.py

1574 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Story business logic service."""
import asyncio
from dataclasses import dataclass
from typing import Literal
from fastapi import HTTPException
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.core.logging import get_logger
from app.db.models import ChildProfile, Story, StoryUniverse
from app.schemas.story_schemas import (
AchievementItem,
FullStoryResponse,
GenerateRequest,
GenerationRequest,
GenerationResponse,
StorybookPageResponse,
StorybookRequest,
StorybookResponse,
)
from app.services.adapters.storybook.primary import Storybook
from app.services.adapters.text.models import StoryOutput
from app.services.audio_storage import (
audio_cache_exists,
read_audio_cache,
write_story_audio_cache,
)
from app.services.generation_jobs import (
create_generation_job,
finish_generation_job,
record_generation_event,
)
from app.services.memory_service import build_enhanced_memory_context
from app.services.provider_router import (
generate_image,
generate_story_content,
generate_storybook,
)
from app.services.story_status import (
StoryAssetStatus,
sync_story_status,
)
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
async def _record_job_event_if_present(
db: AsyncSession,
*,
job,
event_type: str,
status: str,
story_id: int | None = None,
message: str | None = None,
metadata: dict | None = None,
) -> None:
"""Append a workflow event when the caller is running under a tracked job."""
if job is None:
return
await record_generation_event(
db,
job=job,
story_id=story_id,
event_type=event_type,
status=status,
message=message,
metadata=metadata,
)
def _asset_result_metadata(result: AssetCompletionResult) -> dict:
"""Build JSON-safe metadata for asset workflow events."""
return {
"asset": result.asset,
"status": result.status.value,
"error": result.error,
"blocks_main_result": result.blocks_main_result,
}
def _build_storybook_error_message(
*,
cover_failed: bool,
failed_pages: list[int],
) -> str | None:
"""Summarize storybook image generation errors for the latest attempt."""
parts: list[str] = []
if cover_failed:
parts.append("封面生成失败")
if failed_pages:
pages = "".join(str(page) for page in sorted(failed_pages))
parts.append(f"{pages} 页插图生成失败")
return "".join(parts) if parts else None
def _resolve_storybook_image_status(
*,
generate_images: bool,
cover_prompt: str | None,
cover_url: str | None,
pages_data: list[dict],
) -> StoryAssetStatus:
"""Resolve the persisted image status for a storybook."""
if not generate_images:
return StoryAssetStatus.NOT_REQUESTED
expected_assets = 0
ready_assets = 0
if cover_prompt or cover_url:
expected_assets += 1
if cover_url:
ready_assets += 1
for page in pages_data:
if not page.get("image_prompt") and not page.get("image_url"):
continue
expected_assets += 1
if page.get("image_url"):
ready_assets += 1
if expected_assets == 0:
return StoryAssetStatus.NOT_REQUESTED
if ready_assets == expected_assets:
return StoryAssetStatus.READY
return StoryAssetStatus.FAILED
async def _prepare_generation_context(
*,
profile_id: str | None,
universe_id: str | None,
user_id: str,
db: AsyncSession,
job=None,
) -> tuple[str | None, str | None, str]:
"""Validate ownership and build the shared generation context."""
resolved_profile_id, resolved_universe_id = await validate_profile_and_universe(
profile_id, universe_id, user_id, db
)
memory_context = await build_enhanced_memory_context(
resolved_profile_id,
resolved_universe_id,
db,
)
await _record_job_event_if_present(
db,
job=job,
event_type="context_prepared",
status="succeeded",
message="Profile, universe, and memory context were prepared.",
metadata={
"profile_id": resolved_profile_id,
"universe_id": resolved_universe_id,
"has_memory_context": bool(memory_context),
},
)
return resolved_profile_id, resolved_universe_id, memory_context
def _trigger_story_postprocessing(story: Story) -> None:
"""Trigger non-blocking post-processing for a persisted story."""
if story.universe_id:
extract_story_achievements.delay(story.id, story.universe_id)
async def _record_postprocessing_event_if_needed(
db: AsyncSession,
*,
job,
story: Story,
) -> None:
if not story.universe_id:
return
await record_generation_event(
db,
job=job,
story_id=story.id,
event_type="postprocessing_queued",
status="queued",
message="Achievement extraction queued after the main story record was saved.",
metadata={"universe_id": story.universe_id},
)
async def _persist_text_story_result(
*,
result: StoryOutput,
user_id: str,
profile_id: str | None,
universe_id: str | None,
db: AsyncSession,
job=None,
) -> Story:
"""Persist generated text content as the unified story record."""
story = Story(
user_id=user_id,
child_profile_id=profile_id,
universe_id=universe_id,
title=result.title,
story_text=result.story_text,
cover_prompt=result.cover_prompt_suggestion,
mode=result.mode,
)
sync_story_status(
story,
image_status=StoryAssetStatus.NOT_REQUESTED,
audio_status=StoryAssetStatus.NOT_REQUESTED,
last_error=None,
)
db.add(story)
await db.commit()
await db.refresh(story)
_trigger_story_postprocessing(story)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="story_saved",
status="succeeded",
message="Readable story record was saved.",
metadata={
"mode": story.mode,
"generation_status": story.generation_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
},
)
return story
def _storybook_pages_to_data(storybook: Storybook) -> list[dict]:
"""Convert generated storybook pages to the persisted JSON shape."""
return [
{
"page_number": page.page_number,
"text": page.text,
"image_prompt": page.image_prompt,
"image_url": page.image_url,
}
for page in storybook.pages
]
def _storybook_pages_to_response(pages_data: list[dict]) -> list[StorybookPageResponse]:
"""Convert persisted storybook page JSON to API response models."""
return [
StorybookPageResponse(
page_number=page["page_number"],
text=page["text"],
image_prompt=page["image_prompt"],
image_url=page.get("image_url"),
)
for page in pages_data
]
async def _generate_storybook_image_assets(
storybook: Storybook,
db: AsyncSession,
*,
user_id: str,
job=None,
) -> tuple[str | None, bool, list[int]]:
"""Generate storybook cover and page images before persistence."""
final_cover_url = storybook.cover_url
cover_failed = False
failed_pages: list[int] = []
completed_pages: list[int] = []
attempted_cover = bool(storybook.cover_prompt and not storybook.cover_url)
attempted_pages = [
page.page_number
for page in storybook.pages
if page.image_prompt and not page.image_url
]
logger.info("storybook_parallel_generation_start", page_count=len(storybook.pages))
await _record_job_event_if_present(
db,
job=job,
event_type="storybook_images_started",
status="running",
message="Storybook cover and page image generation started.",
metadata={
"attempted_cover": attempted_cover,
"attempted_pages": attempted_pages,
},
)
async def _gen_cover() -> str | None:
nonlocal cover_failed
if storybook.cover_prompt and not storybook.cover_url:
try:
return await generate_image(
storybook.cover_prompt,
db=db,
user_id=user_id,
generation_job=job,
)
except Exception as exc:
cover_failed = True
logger.warning("cover_gen_failed", error=str(exc))
return storybook.cover_url
async def _gen_page(page) -> None:
if not page.image_prompt or page.image_url:
return
try:
page.image_url = await generate_image(
page.image_prompt,
db=db,
user_id=user_id,
generation_job=job,
)
completed_pages.append(page.page_number)
except Exception as exc:
failed_pages.append(page.page_number)
logger.warning("page_gen_failed", page=page.page_number, error=str(exc))
results = await asyncio.gather(
_gen_cover(),
*(_gen_page(page) for page in storybook.pages),
return_exceptions=True,
)
cover_result = results[0]
if isinstance(cover_result, str):
final_cover_url = cover_result
logger.info("storybook_parallel_generation_complete")
if attempted_cover:
await _record_job_event_if_present(
db,
job=job,
event_type=(
"storybook_cover_image_failed"
if cover_failed
else "storybook_cover_image_succeeded"
),
status="failed" if cover_failed else "succeeded",
message=(
"Storybook cover image generation failed."
if cover_failed
else "Storybook cover image was generated."
),
metadata={"asset": "image", "scope": "cover"},
)
for page_number in sorted(completed_pages):
await _record_job_event_if_present(
db,
job=job,
event_type="storybook_page_image_succeeded",
status="succeeded",
message="Storybook page image was generated.",
metadata={"asset": "image", "scope": "page", "page_number": page_number},
)
for page_number in sorted(failed_pages):
await _record_job_event_if_present(
db,
job=job,
event_type="storybook_page_image_failed",
status="failed",
message="Storybook page image generation failed.",
metadata={"asset": "image", "scope": "page", "page_number": page_number},
)
await _record_job_event_if_present(
db,
job=job,
event_type="storybook_images_completed",
status="failed" if cover_failed or failed_pages else "succeeded",
message="Storybook image generation finished.",
metadata={
"asset": "image",
"attempted_cover": attempted_cover,
"completed_pages": sorted(completed_pages),
"failed_pages": sorted(failed_pages),
},
)
return final_cover_url, cover_failed, failed_pages
async def _persist_storybook_result(
*,
storybook: Storybook,
user_id: str,
profile_id: str | None,
universe_id: str | None,
final_cover_url: str | None,
generate_images: bool,
cover_failed: bool,
failed_pages: list[int],
db: AsyncSession,
job=None,
) -> tuple[Story, list[dict]]:
"""Persist generated storybook content as the unified story record."""
pages_data = _storybook_pages_to_data(storybook)
story = Story(
user_id=user_id,
child_profile_id=profile_id,
universe_id=universe_id,
title=storybook.title,
mode="storybook",
pages=pages_data,
story_text=None,
cover_prompt=storybook.cover_prompt,
image_url=final_cover_url,
)
sync_story_status(
story,
image_status=_resolve_storybook_image_status(
generate_images=generate_images,
cover_prompt=storybook.cover_prompt,
cover_url=final_cover_url,
pages_data=pages_data,
),
audio_status=StoryAssetStatus.NOT_REQUESTED,
last_error=_build_storybook_error_message(
cover_failed=cover_failed,
failed_pages=failed_pages,
),
)
db.add(story)
await db.commit()
await db.refresh(story)
_trigger_story_postprocessing(story)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="story_saved",
status="succeeded",
message="Storybook record was saved.",
metadata={
"mode": story.mode,
"page_count": len(pages_data),
"generation_status": story.generation_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
},
)
return story, pages_data
async def _complete_cover_image_asset(
story: Story,
db: AsyncSession,
*,
raise_on_failure: bool = False,
last_error_prefix: str | None = None,
log_event: str = "cover_asset_generation_failed",
job=None,
) -> AssetCompletionResult:
"""Generate or retry a text story cover through one asset workflow."""
if not story.cover_prompt:
raise HTTPException(status_code=400, detail="Story has no cover prompt")
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
await db.commit()
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="cover_image_started",
status="running",
message="Cover image generation started.",
metadata={"asset": "image", "cover_prompt_present": True},
)
try:
image_url = await generate_image(
story.cover_prompt,
db=db,
user_id=story.user_id,
generation_job=job,
story_id=story.id,
)
story.image_url = image_url
sync_story_status(story, image_status=StoryAssetStatus.READY)
await db.commit()
result = AssetCompletionResult(
asset="cover_image",
status=StoryAssetStatus.READY,
value=image_url,
blocks_main_result=raise_on_failure,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="cover_image_succeeded",
status="succeeded",
message="Cover image was generated.",
metadata=_asset_result_metadata(result),
)
return result
except Exception as exc:
provider_error = str(exc)
last_error = (
f"{last_error_prefix}: {provider_error}"
if last_error_prefix
else provider_error
)
sync_story_status(
story,
image_status=StoryAssetStatus.FAILED,
last_error=last_error,
)
await db.commit()
logger.warning(log_event, story_id=story.id, error=provider_error)
result = AssetCompletionResult(
asset="cover_image",
status=StoryAssetStatus.FAILED,
error=provider_error,
blocks_main_result=raise_on_failure,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="cover_image_failed",
status="failed",
message="Cover image generation failed.",
metadata=_asset_result_metadata(result),
)
if raise_on_failure:
raise HTTPException(
status_code=500,
detail=f"Image generation failed: {provider_error}",
) from exc
return result
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,
*,
job=None,
) -> AssetCompletionResult:
"""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()
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="storybook_images_started",
status="running",
message="Storybook missing image completion started.",
metadata={"asset": "image"},
)
cover_failed = False
failed_pages: list[int] = []
completed_pages: list[int] = []
if story.cover_prompt and not story.image_url:
try:
story.image_url = await generate_image(
story.cover_prompt,
db=db,
user_id=story.user_id,
generation_job=job,
story_id=story.id,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="storybook_cover_image_succeeded",
status="succeeded",
message="Storybook cover image was generated.",
metadata={"asset": "image", "scope": "cover"},
)
except Exception as exc:
cover_failed = True
logger.warning(
"storybook_cover_asset_completion_failed",
story_id=story.id,
error=str(exc),
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="storybook_cover_image_failed",
status="failed",
message="Storybook cover image generation failed.",
metadata={"asset": "image", "scope": "cover", "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,
user_id=story.user_id,
generation_job=job,
story_id=story.id,
)
page_number = page.get("page_number")
if isinstance(page_number, int):
completed_pages.append(page_number)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="storybook_page_image_succeeded",
status="succeeded",
message="Storybook page image was generated.",
metadata={"asset": "image", "scope": "page", "page_number": page_number},
)
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),
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="storybook_page_image_failed",
status="failed",
message="Storybook page image generation failed.",
metadata={
"asset": "image",
"scope": "page",
"page_number": page_number,
"error": str(exc),
},
)
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=image_status,
last_error=error_message,
)
await db.commit()
result = AssetCompletionResult(
asset="storybook_images",
status=image_status,
value=story.image_url,
error=error_message,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="storybook_images_completed",
status="failed" if error_message else "succeeded",
message="Storybook image completion finished.",
metadata={
**_asset_result_metadata(result),
"completed_pages": sorted(completed_pages),
"failed_pages": sorted(failed_pages),
},
)
return result
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,
job=None,
) -> AssetCompletionResult:
"""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:
result = AssetCompletionResult(
asset="audio",
status=StoryAssetStatus.READY,
value=cached_audio,
blocks_main_result=raise_on_failure,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="audio_cache_hit",
status="succeeded",
message="Cached story audio was reused.",
metadata=_asset_result_metadata(result),
)
return result
from app.services.provider_router import text_to_speech
sync_story_status(story, audio_status=StoryAssetStatus.GENERATING)
await db.commit()
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="audio_started",
status="running",
message="Story audio generation started.",
metadata={"asset": "audio"},
)
try:
audio_data = await text_to_speech(
story.story_text,
db=db,
user_id=story.user_id,
generation_job=job,
story_id=story.id,
)
story.audio_path = write_story_audio_cache(story.id, audio_data)
sync_story_status(
story,
audio_status=StoryAssetStatus.READY,
)
await db.commit()
result = AssetCompletionResult(
asset="audio",
status=StoryAssetStatus.READY,
value=audio_data,
blocks_main_result=raise_on_failure,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="audio_succeeded",
status="succeeded",
message="Story audio was generated and cached.",
metadata=_asset_result_metadata(result),
)
return result
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)
result = AssetCompletionResult(
asset="audio",
status=StoryAssetStatus.FAILED,
error=provider_error,
blocks_main_result=raise_on_failure,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="audio_failed",
status="failed",
message="Story audio generation failed.",
metadata=_asset_result_metadata(result),
)
if raise_on_failure:
raise HTTPException(
status_code=500,
detail=f"Audio generation failed: {provider_error}",
) from exc
return result
async def validate_profile_and_universe(
profile_id: str | None,
universe_id: str | None,
user_id: str,
db: AsyncSession,
) -> tuple[str | None, str | None]:
"""Validate child profile and universe ownership/relationship."""
if not profile_id and not universe_id:
return None, None
if profile_id:
result = await db.execute(
select(ChildProfile).where(
ChildProfile.id == profile_id,
ChildProfile.user_id == user_id,
)
)
profile = result.scalar_one_or_none()
if not profile:
raise HTTPException(status_code=404, detail="孩子档案不存在")
if universe_id:
result = await db.execute(
select(StoryUniverse)
.join(ChildProfile, StoryUniverse.child_profile_id == ChildProfile.id)
.where(
StoryUniverse.id == universe_id,
ChildProfile.user_id == user_id,
)
)
universe = result.scalar_one_or_none()
if not universe:
raise HTTPException(status_code=404, detail="故事宇宙不存在")
if profile_id and universe.child_profile_id != profile_id:
raise HTTPException(status_code=400, detail="故事宇宙与孩子档案不匹配")
if not profile_id:
profile_id = universe.child_profile_id
return profile_id, universe_id
async def generate_and_save_story(
request: GenerateRequest,
user_id: str,
db: AsyncSession,
*,
job=None,
) -> Story:
"""Generate generic story content and save to DB."""
profile_id, universe_id, memory_context = await _prepare_generation_context(
profile_id=request.child_profile_id,
universe_id=request.universe_id,
user_id=user_id,
db=db,
job=job,
)
try:
result = await generate_story_content(
input_type=request.type,
data=request.data,
education_theme=request.education_theme,
memory_context=memory_context,
db=db,
user_id=user_id,
generation_job=job,
)
except Exception as exc:
raise HTTPException(
status_code=502,
detail="Story generation failed, please try again.",
) from exc
await _record_job_event_if_present(
db,
job=job,
event_type="narrative_generated",
status="succeeded",
message="Story narrative was generated.",
metadata={"mode": result.mode, "title": result.title},
)
return await _persist_text_story_result(
result=result,
user_id=user_id,
profile_id=profile_id,
universe_id=universe_id,
db=db,
job=job,
)
async def generate_full_story_service(
request: GenerateRequest,
user_id: str,
db: AsyncSession,
*,
job=None,
) -> FullStoryResponse:
"""Generate story with parallel image generation."""
story = await generate_and_save_story(request, user_id, db, job=job)
image_url: str | None = None
errors: dict[str, str | None] = {}
if story.cover_prompt:
image_result = await _complete_cover_image_asset(
story,
db,
log_event="image_generation_failed",
job=job,
)
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,
title=story.title,
story_text=story.story_text,
cover_prompt=story.cover_prompt,
image_url=image_url,
audio_ready=False,
mode=story.mode,
errors=errors,
child_profile_id=story.child_profile_id,
universe_id=story.universe_id,
generation_status=story.generation_status,
text_status=story.text_status,
image_status=story.image_status,
audio_status=story.audio_status,
last_error=story.last_error,
retryable_assets=story.retryable_assets,
)
async def generate_storybook_service(
request: StorybookRequest,
user_id: str,
db: AsyncSession,
*,
job=None,
) -> StorybookResponse:
"""Generate storybook with parallel image generation for pages."""
profile_id, universe_id, memory_context = await _prepare_generation_context(
profile_id=request.child_profile_id,
universe_id=request.universe_id,
user_id=user_id,
db=db,
job=job,
)
logger.info(
"storybook_request",
user_id=user_id,
keywords=request.keywords,
page_count=request.page_count,
profile_id=profile_id,
universe_id=universe_id,
)
try:
storybook = await generate_storybook(
keywords=request.keywords,
page_count=request.page_count,
education_theme=request.education_theme,
memory_context=memory_context,
db=db,
user_id=user_id,
generation_job=job,
)
except Exception as e:
logger.error("storybook_generation_failed", error=str(e))
raise HTTPException(status_code=500, detail=f"故事书生成失败: {e}")
await _record_job_event_if_present(
db,
job=job,
event_type="narrative_generated",
status="succeeded",
message="Storybook narrative and page plan were generated.",
metadata={
"mode": "storybook",
"title": storybook.title,
"page_count": len(storybook.pages),
},
)
final_cover_url = storybook.cover_url
cover_failed = False
failed_pages: list[int] = []
if request.generate_images:
(
final_cover_url,
cover_failed,
failed_pages,
) = await _generate_storybook_image_assets(
storybook,
db,
user_id=user_id,
job=job,
)
story, pages_data = await _persist_storybook_result(
storybook=storybook,
user_id=user_id,
profile_id=profile_id,
universe_id=universe_id,
final_cover_url=final_cover_url,
generate_images=request.generate_images,
cover_failed=cover_failed,
failed_pages=failed_pages,
db=db,
job=job,
)
response_pages = _storybook_pages_to_response(pages_data)
return StorybookResponse(
id=story.id,
title=storybook.title,
main_character=storybook.main_character,
art_style=storybook.art_style,
pages=response_pages,
cover_prompt=storybook.cover_prompt,
cover_url=final_cover_url,
generation_status=story.generation_status,
text_status=story.text_status,
image_status=story.image_status,
audio_status=story.audio_status,
last_error=story.last_error,
retryable_assets=story.retryable_assets,
)
async def generate_generation_service(
request: GenerationRequest,
user_id: str,
db: AsyncSession,
) -> GenerationResponse:
"""Unified generation workflow entry point for stories and storybooks."""
job = await create_generation_job(
db,
user_id=user_id,
output_mode=request.output_mode,
input_type=request.type,
request_payload=request.model_dump(mode="json"),
)
try:
response = await _generate_generation_service_with_job(request, user_id, db, job=job)
except HTTPException as exc:
await finish_generation_job(
db,
job=job,
story=None,
status="failed",
current_step="generation_failed",
error_message=str(exc.detail),
message="Generation failed before a durable story result was available.",
)
raise
except Exception as exc:
await finish_generation_job(
db,
job=job,
story=None,
status="failed",
current_step="generation_failed",
error_message=str(exc),
message="Generation failed before a durable story result was available.",
)
raise
return response
async def _generate_generation_service_with_job(
request: GenerationRequest,
user_id: str,
db: AsyncSession,
*,
job,
) -> GenerationResponse:
"""Run the unified generation workflow after the tracking job has been created."""
if request.output_mode == "storybook":
storybook = await generate_storybook_service(
StorybookRequest(
keywords=request.data,
page_count=request.page_count,
education_theme=request.education_theme,
generate_images=request.generate_images,
child_profile_id=request.child_profile_id,
universe_id=request.universe_id,
),
user_id,
db,
job=job,
)
if storybook.id is None:
raise HTTPException(status_code=500, detail="Storybook generation did not persist.")
saved_story = await get_story_detail(storybook.id, user_id, db)
await _record_postprocessing_event_if_needed(db, job=job, story=saved_story)
await finish_generation_job(
db,
job=job,
story=saved_story,
current_step="generation_completed",
message="Storybook generation completed with persisted text and current asset states.",
)
return GenerationResponse(
id=storybook.id,
generation_job_id=job.id,
title=storybook.title,
mode="storybook",
pages=storybook.pages,
cover_prompt=storybook.cover_prompt,
image_url=storybook.cover_url,
cover_url=storybook.cover_url,
main_character=storybook.main_character,
art_style=storybook.art_style,
generation_status=storybook.generation_status,
text_status=saved_story.text_status,
image_status=storybook.image_status,
audio_status=storybook.audio_status,
last_error=storybook.last_error,
child_profile_id=saved_story.child_profile_id,
universe_id=saved_story.universe_id,
retryable_assets=saved_story.retryable_assets,
)
generate_request = GenerateRequest(
type=request.type,
data=request.data,
education_theme=request.education_theme,
child_profile_id=request.child_profile_id,
universe_id=request.universe_id,
)
if request.generate_images:
story = await generate_full_story_service(generate_request, user_id, db, job=job)
saved_story = await get_story_detail(story.id, user_id, db)
await _record_postprocessing_event_if_needed(db, job=job, story=saved_story)
await finish_generation_job(
db,
job=job,
story=saved_story,
current_step="generation_completed",
message="Story generation completed with persisted text and current asset states.",
)
return GenerationResponse(
id=story.id,
generation_job_id=job.id,
title=story.title,
mode=story.mode,
story_text=story.story_text,
cover_prompt=story.cover_prompt,
image_url=story.image_url,
cover_url=story.image_url,
audio_ready=story.audio_ready,
errors=story.errors,
generation_status=story.generation_status,
text_status=saved_story.text_status,
image_status=story.image_status,
audio_status=story.audio_status,
last_error=story.last_error,
child_profile_id=story.child_profile_id,
universe_id=story.universe_id,
retryable_assets=saved_story.retryable_assets,
)
story = await generate_and_save_story(generate_request, user_id, db, job=job)
await _record_postprocessing_event_if_needed(db, job=job, story=story)
await finish_generation_job(
db,
job=job,
story=story,
current_step="generation_completed",
message="Story generation completed with a persisted readable narrative.",
)
return GenerationResponse(
id=story.id,
generation_job_id=job.id,
title=story.title,
mode=story.mode,
story_text=story.story_text,
cover_prompt=story.cover_prompt,
image_url=story.image_url,
cover_url=story.image_url,
generation_status=story.generation_status,
text_status=story.text_status,
image_status=story.image_status,
audio_status=story.audio_status,
last_error=story.last_error,
child_profile_id=story.child_profile_id,
universe_id=story.universe_id,
retryable_assets=story.retryable_assets,
)
async def list_stories(
user_id: str,
limit: int,
offset: int,
db: AsyncSession,
) -> list[Story]:
"""List stories for user."""
result = await db.execute(
select(Story)
.where(Story.user_id == user_id)
.order_by(desc(Story.created_at))
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def get_story_detail(
story_id: int,
user_id: str,
db: AsyncSession,
) -> Story:
"""Get story detail."""
result = await db.execute(
select(Story).where(Story.id == story_id, Story.user_id == user_id)
)
story = result.scalar_one_or_none()
if not story:
raise HTTPException(status_code=404, detail="Story not found")
return story
async def delete_story(
story_id: int,
user_id: str,
db: AsyncSession,
) -> None:
"""Delete a story."""
story = await get_story_detail(story_id, user_id, db)
await db.delete(story)
await db.commit()
async def create_story_from_result(
result: StoryOutput,
user_id: str,
profile_id: str | None,
universe_id: str | None,
db: AsyncSession,
) -> Story:
"""Save a generated story to DB (helper for stream endpoint)."""
return await _persist_text_story_result(
result=result,
user_id=user_id,
profile_id=profile_id,
universe_id=universe_id,
db=db,
)
async def _retry_cover_image_asset(story: Story, db: AsyncSession, *, job=None) -> None:
"""Retry cover generation for a text story."""
await _complete_cover_image_asset(
story,
db,
last_error_prefix="封面生成失败",
log_event="cover_asset_retry_failed",
job=job,
)
async def _retry_storybook_image_assets(
story: Story,
db: AsyncSession,
*,
job=None,
) -> None:
"""Retry missing storybook cover/page images."""
await _complete_storybook_image_assets(story, db, job=job)
async def _retry_audio_asset(story: Story, db: AsyncSession, *, job=None) -> None:
"""Retry audio generation while preserving persisted status on provider failure."""
await _complete_audio_asset(story, db, raise_on_failure=False, job=job)
async def retry_story_assets(
story_id: int,
user_id: str,
assets: list[str],
db: AsyncSession,
) -> Story:
"""Retry selected assets through one workflow-level endpoint."""
requested_assets = list(dict.fromkeys(assets))
job = await create_generation_job(
db,
user_id=user_id,
output_mode="asset_retry",
input_type=",".join(requested_assets),
request_payload={"story_id": story_id, "assets": requested_assets},
story_id=story_id,
)
story: Story | None = None
try:
story = await get_story_detail(story_id, user_id, db)
await record_generation_event(
db,
job=job,
story_id=story.id,
event_type="asset_retry_started",
status="running",
message="Asset retry started.",
metadata={"assets": requested_assets},
)
if "image" in requested_assets:
if story.mode == "storybook":
await _retry_storybook_image_assets(story, db, job=job)
else:
await _retry_cover_image_asset(story, db, job=job)
if "audio" in requested_assets:
await _retry_audio_asset(story, db, job=job)
story = await get_story_detail(story_id, user_id, db)
await finish_generation_job(
db,
job=job,
story=story,
current_step="asset_retry_completed",
message="Asset retry completed with persisted status updates.",
metadata={"assets": requested_assets},
)
return story
except HTTPException as exc:
await finish_generation_job(
db,
job=job,
story=story,
status="failed",
current_step="asset_retry_failed",
error_message=str(exc.detail),
message="Asset retry failed.",
metadata={"assets": requested_assets},
)
raise
except Exception as exc:
await finish_generation_job(
db,
job=job,
story=story,
status="failed",
current_step="asset_retry_failed",
error_message=str(exc),
message="Asset retry failed.",
metadata={"assets": requested_assets},
)
raise
async def generate_story_cover(
story_id: int,
user_id: str,
db: AsyncSession,
) -> str:
"""Generate cover image for an existing story."""
job = await create_generation_job(
db,
user_id=user_id,
output_mode="asset_generation",
input_type="image",
request_payload={"story_id": story_id, "assets": ["image"]},
story_id=story_id,
)
story: Story | None = None
try:
story = await get_story_detail(story_id, user_id, db)
image_result = await _complete_cover_image_asset(
story,
db,
raise_on_failure=True,
log_event="cover_generation_failed",
job=job,
)
story = await get_story_detail(story_id, user_id, db)
await finish_generation_job(
db,
job=job,
story=story,
current_step="asset_generation_completed",
message="Cover image generation completed.",
metadata={"assets": ["image"]},
)
if image_result.succeeded and isinstance(image_result.value, str):
return image_result.value
except HTTPException as exc:
await finish_generation_job(
db,
job=job,
story=story,
status="failed",
current_step="asset_generation_failed",
error_message=str(exc.detail),
message="Cover image generation failed.",
metadata={"assets": ["image"]},
)
raise
raise HTTPException(status_code=500, detail="Image generation failed")
async def generate_story_audio(
story_id: int,
user_id: str,
db: AsyncSession,
) -> bytes:
"""Generate audio for a story."""
job = await create_generation_job(
db,
user_id=user_id,
output_mode="asset_generation",
input_type="audio",
request_payload={"story_id": story_id, "assets": ["audio"]},
story_id=story_id,
)
story: Story | None = None
try:
story = await get_story_detail(story_id, user_id, db)
audio_result = await _complete_audio_asset(
story,
db,
raise_on_failure=True,
job=job,
)
story = await get_story_detail(story_id, user_id, db)
await finish_generation_job(
db,
job=job,
story=story,
current_step="asset_generation_completed",
message="Story audio generation completed.",
metadata={"assets": ["audio"]},
)
if audio_result.succeeded and isinstance(audio_result.value, bytes):
return audio_result.value
except HTTPException as exc:
await finish_generation_job(
db,
job=job,
story=story,
status="failed",
current_step="asset_generation_failed",
error_message=str(exc.detail),
message="Story audio generation failed.",
metadata={"assets": ["audio"]},
)
raise
raise HTTPException(status_code=500, detail="Audio generation failed")
async def get_story_achievements(
story_id: int,
user_id: str,
db: AsyncSession,
) -> list[AchievementItem]:
"""Get achievements unlocked by a specific story."""
result = await db.execute(
select(Story)
.options(joinedload(Story.story_universe))
.where(Story.id == story_id, Story.user_id == user_id)
)
story = result.scalar_one_or_none()
if not story:
raise HTTPException(status_code=404, detail="Story not found")
if not story.universe_id or not story.story_universe:
return []
universe = story.story_universe
if not universe.achievements:
return []
results = []
for ach in universe.achievements:
if isinstance(ach, dict) and ach.get("source_story_id") == story_id:
results.append(AchievementItem(
type=ach.get("type", "Unknown"),
description=ach.get("description", ""),
obtained_at=ach.get("obtained_at")
))
return results