"""Story business logic service.""" import asyncio from datetime import datetime, timedelta, timezone from fastapi import HTTPException from sqlalchemy import desc, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload from app.core.config import settings from app.core.logging import get_logger from app.db.models import ChildProfile, GenerationJob, 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, delete_audio_cache, get_audio_cache_metadata, read_audio_cache, write_story_audio_cache, ) from app.services.generation_jobs import ( claim_generation_job_for_worker, create_generation_job, ensure_no_active_story_generation_job, finish_generation_job, generation_job_can_retry, get_generation_job_for_user, public_generation_job_to_summary, record_generation_event, ) from app.services.harness.artifacts import ( AssetCompletionResult, asset_result_metadata, ) from app.services.harness.asset_workflows import ( build_storybook_error_message, complete_audio_asset, complete_cover_image_asset, complete_storybook_image_assets, get_storybook_pages_data, read_cached_audio_asset, resolve_storybook_image_status, ) from app.services.harness.control import ( ExecutionControl, GenerationJobCanceledError, ) from app.services.harness.evaluators import ( EvaluationResult, evaluate_story_output, evaluate_storybook_output, ) from app.services.harness.executor import ( record_evaluation_result, record_executor_result, record_workflow_plan, run_asset_plan, ) from app.services.harness.plans import ( build_asset_plan, build_story_plan, build_storybook_plan, ) from app.services.harness.quality_gates import ( QualityGateError, ) from app.services.harness.trace import TraceRecorder from app.services.harness.types import ArtifactKind 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__) 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.""" await TraceRecorder(db).record_step( job=job, story_id=story_id, event_type=event_type, status=status, message=message, metadata=metadata, ) async def _stop_if_job_cancel_requested( db: AsyncSession, *, job, story: Story | None = None, ) -> None: """Stop a worker-owned job at the next safe checkpoint after cancellation.""" await ExecutionControl(db).stop_if_cancel_requested(job=job, story=story) async def _record_quality_gate_failure_if_present( db: AsyncSession, *, job, error: QualityGateError, ) -> None: """Append a quality gate failure event for tracked worker jobs.""" await _record_job_event_if_present( db, job=job, event_type="quality_gate_failed", status="failed", message="Generated content failed deterministic quality gates.", metadata=error.to_metadata(), ) async def _record_evaluation_result_if_present( db: AsyncSession, *, job, evaluation: EvaluationResult, artifact: ArtifactKind | str = ArtifactKind.STORY_TEXT, ) -> None: """Append deterministic evaluation metadata for tracked worker jobs.""" await record_evaluation_result( db, job=job, metadata=evaluation.to_metadata(), status="succeeded" if evaluation.passed else "failed", artifact=artifact, ) def _asset_result_metadata(result: AssetCompletionResult) -> dict: """Build JSON-safe metadata for asset workflow events.""" return asset_result_metadata(result) def _build_storybook_error_message( *, cover_failed: bool, failed_pages: list[int], ) -> str | None: """Summarize storybook image generation errors for the latest attempt.""" return build_storybook_error_message( cover_failed=cover_failed, failed_pages=failed_pages, ) 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.""" return resolve_storybook_image_status( generate_images=generate_images, cover_prompt=cover_prompt, cover_url=cover_url, pages_data=pages_data, ) 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), }, ) await _stop_if_job_cancel_requested(db, job=job) 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 _stop_if_job_cancel_requested(db, job=job) 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: await _stop_if_job_cancel_requested(db, job=job) 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 await _stop_if_job_cancel_requested(db, job=job) 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.""" return await complete_cover_image_asset( story, db, generate_image_func=generate_image, raise_on_failure=raise_on_failure, last_error_prefix=last_error_prefix, log_event=log_event, job=job, ) def _get_storybook_pages_data(story: Story) -> list[dict]: """Return mutable storybook page data from the persisted JSON field.""" return get_storybook_pages_data(story) async def _complete_storybook_image_assets( story: Story, db: AsyncSession, *, job=None, ) -> AssetCompletionResult: """Complete missing cover/page images for a persisted storybook.""" return await complete_storybook_image_assets( story, db, generate_image_func=generate_image, job=job, ) async def _read_cached_audio_asset(story: Story, db: AsyncSession) -> bytes | None: """Read cached audio or repair stale audio cache metadata.""" return await read_cached_audio_asset( story, db, audio_cache_exists_func=audio_cache_exists, read_audio_cache_func=read_audio_cache, ) 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.""" from app.services.provider_router import text_to_speech return await complete_audio_asset( story, db, text_to_speech_func=text_to_speech, audio_cache_exists_func=audio_cache_exists, read_audio_cache_func=read_audio_cache, write_story_audio_cache_func=write_story_audio_cache, raise_on_failure=raise_on_failure, job=job, ) 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: await _stop_if_job_cancel_requested(db, job=job) 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 evaluation = evaluate_story_output( result, education_theme=request.education_theme, ) if evaluation.gate_error is not None: await _record_quality_gate_failure_if_present( db, job=job, error=evaluation.gate_error, ) await _record_evaluation_result_if_present( db, job=job, evaluation=evaluation, ) if evaluation.blocking: raise HTTPException( status_code=502, detail="Story generation failed quality checks, please try again.", ) 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}, ) await _stop_if_job_cancel_requested(db, job=job) story = await _persist_text_story_result( result=result, user_id=user_id, profile_id=profile_id, universe_id=universe_id, db=db, job=job, ) await _stop_if_job_cancel_requested(db, job=job, story=story) return story 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) await _stop_if_job_cancel_requested(db, job=job, story=story) 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: await _stop_if_job_cancel_requested(db, job=job) 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}") evaluation = evaluate_storybook_output( storybook, education_theme=request.education_theme, ) if evaluation.gate_error is not None: await _record_quality_gate_failure_if_present( db, job=job, error=evaluation.gate_error, ) await _record_evaluation_result_if_present( db, job=job, evaluation=evaluation, artifact=ArtifactKind.STORYBOOK_PAGES, ) if evaluation.blocking: raise HTTPException( status_code=500, detail=f"故事书质量检查失败: {evaluation.gate_error or 'evaluation blocked'}", ) 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), }, ) await _stop_if_job_cancel_requested(db, job=job) final_cover_url = storybook.cover_url cover_failed = False failed_pages: list[int] = [] if request.generate_images: await _stop_if_job_cancel_requested(db, job=job) ( 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, ) await _stop_if_job_cancel_requested(db, job=job, story=story) 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: """Queue one unified generation workflow for background execution.""" 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"), ) await _dispatch_generation_job(db, job=job) return _build_queued_generation_response(request, job_id=job.id) async def _dispatch_generation_job( db: AsyncSession, *, job: GenerationJob, ) -> None: """Dispatch one accepted generation job to the background worker.""" try: from app.tasks.generation_workflow import run_generation_workflow_task run_generation_workflow_task.delay(job.id) except Exception as exc: await finish_generation_job( db, job=job, story=None, status="failed", current_step="generation_failed", error_message="Background generation dispatch failed.", message="Generation failed before the worker could start processing the job.", metadata={"dispatch_error": str(exc)}, ) raise HTTPException( status_code=503, detail="后台生成任务派发失败,请确认 worker 可用后重试。", ) from exc def _build_queued_generation_response( request: GenerationRequest, *, job_id: str, ) -> GenerationResponse: """Build the immediate API response after a generation job is accepted.""" return GenerationResponse( id=None, generation_job_id=job_id, title="生成任务已提交", mode="storybook" if request.output_mode == "storybook" else "generated", generation_status="queued", text_status="generating", image_status="not_requested", audio_status="not_requested", last_error=None, retryable_assets=[], child_profile_id=request.child_profile_id, universe_id=request.universe_id, ) async def execute_generation_job_service( job: GenerationJob, db: AsyncSession, ) -> GenerationResponse: """Execute one previously accepted generation job inside the worker.""" try: if job.output_mode == "asset_generation": response = await _generate_asset_generation_service_with_job(job, db) else: request = GenerationRequest.model_validate(job.request_payload or {}) response = await _generate_generation_service_with_job( request, job.user_id, db, job=job, ) except GenerationJobCanceledError: return _build_canceled_generation_response(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 def _build_canceled_generation_response(job: GenerationJob) -> GenerationResponse: """Build a compact response for a worker job that ended as canceled.""" snapshot = job.result_snapshot or {} return GenerationResponse( id=snapshot.get("story_id"), generation_job_id=job.id, title="生成任务已取消", mode="storybook" if job.output_mode == "storybook" else "generated", generation_status=str(snapshot.get("generation_status") or "failed"), text_status=str(snapshot.get("text_status") or "failed"), image_status=str(snapshot.get("image_status") or "not_requested"), audio_status=str(snapshot.get("audio_status") or "not_requested"), last_error=str(snapshot.get("last_error") or "Generation canceled by user."), retryable_assets=list(snapshot.get("retryable_assets") or []), ) def _build_generation_response_from_story( story: Story, *, job_id: str, ) -> GenerationResponse: """Build a unified generation response from one persisted story record.""" pages = None if story.mode == "storybook": pages = _storybook_pages_to_response(story.pages or []) return GenerationResponse( id=story.id, generation_job_id=job_id, title=story.title, mode=story.mode, story_text=story.story_text, pages=pages, cover_prompt=story.cover_prompt, image_url=story.image_url, cover_url=story.image_url, audio_ready=story.audio_status == StoryAssetStatus.READY.value, 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 run_generation_job_service( job_id: str, db: AsyncSession, ) -> GenerationJob | None: """Claim and execute one generation job from the background queue.""" job = await claim_generation_job_for_worker(db, job_id=job_id) if job is None: logger.info("generation_job_execution_skipped", job_id=job_id) return None await execute_generation_job_service(job, db) return job async def _generate_asset_generation_service_with_job( job: GenerationJob, db: AsyncSession, ) -> GenerationResponse: """Run queued asset generation in the background worker.""" payload = job.request_payload or {} story_id = payload.get("story_id") or job.story_id requested_assets = list(dict.fromkeys(payload.get("assets") or [])) if story_id is None: raise HTTPException(status_code=400, detail="资源任务缺少 story_id。") if not requested_assets: raise HTTPException(status_code=400, detail="资源任务缺少 assets。") plan = build_asset_plan( output_mode="asset_generation", assets=requested_assets, ) await record_workflow_plan( db, job=job, plan=plan, ) story = await get_story_detail(int(story_id), job.user_id, db) async def complete_image() -> AssetCompletionResult: if story.mode == "storybook": return await _complete_storybook_image_assets(story, db, job=job) return await _complete_cover_image_asset( story, db, raise_on_failure=True, log_event="cover_generation_failed", job=job, ) async def complete_audio() -> AssetCompletionResult: return await _complete_audio_asset( story, db, raise_on_failure=True, job=job, ) asset_plan_result = await run_asset_plan( plan, image_task=complete_image if "image" in requested_assets else None, audio_task=complete_audio if "audio" in requested_assets else None, ) await record_executor_result( db, job=job, plan=plan, result=asset_plan_result, ) story = await get_story_detail(story.id, job.user_id, db) await finish_generation_job( db, job=job, story=story, current_step="asset_generation_completed", message="Asset generation completed in the background worker.", metadata={"assets": requested_assets}, ) return _build_generation_response_from_story(story, job_id=job.id) async def retry_generation_job_service( job_id: str, user_id: str, db: AsyncSession, ) -> dict: """Clone one failed/canceled generation job and queue it again.""" source_job = await get_generation_job_for_user(db, job_id=job_id, user_id=user_id) if not generation_job_can_retry(source_job): raise HTTPException(status_code=409, detail="当前任务还不能重新排队") if source_job.story_id is not None: await ensure_no_active_story_generation_job( db, story_id=source_job.story_id, user_id=user_id, ) retry_job = await create_generation_job( db, user_id=user_id, output_mode=source_job.output_mode, input_type=source_job.input_type, request_payload=source_job.request_payload or {}, story_id=source_job.story_id, ) await record_generation_event( db, job=retry_job, story_id=retry_job.story_id, event_type="retry_queued", status="queued", message="Retry job accepted from a previous terminal generation.", metadata={"source_job_id": source_job.id}, ) await _dispatch_generation_job(db, job=retry_job) await db.refresh(retry_job) return public_generation_job_to_summary(retry_job) 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": await record_workflow_plan( db, job=job, plan=build_storybook_plan(generate_images=request.generate_images), ) 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, ) if request.output_mode == "story" and not request.generate_images: return await _execute_story_without_assets_plan(request, user_id, db, job=job) 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: await record_workflow_plan( db, job=job, plan=build_story_plan(generate_images=True), ) 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 _execute_story_without_assets_plan( request: GenerationRequest, user_id: str, db: AsyncSession, *, job, ) -> GenerationResponse: """Execute the minimal text-story workflow through an explicit plan.""" plan = build_story_plan(generate_images=False) await record_workflow_plan(db, job=job, plan=plan) 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, ) 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 queue_story_asset_generation( story_id: int, user_id: str, assets: list[str], db: AsyncSession, ) -> dict: """Queue one asset generation job for an already persisted story.""" await ensure_no_active_story_generation_job(db, story_id=story_id, user_id=user_id) requested_assets = list(dict.fromkeys(assets)) if not requested_assets: raise HTTPException(status_code=400, detail="至少需要一个待生成资源") story = await get_story_detail(story_id, user_id, db) if "image" in requested_assets: has_image_prompt = bool(story.cover_prompt) if story.mode == "storybook": has_image_prompt = has_image_prompt or any( isinstance(page, dict) and page.get("image_prompt") for page in story.pages or [] ) if not has_image_prompt: raise HTTPException(status_code=400, detail="当前故事没有可生成的图片提示词") if "audio" in requested_assets and not story.story_text: raise HTTPException(status_code=400, detail="当前故事没有可生成音频的正文") job = await create_generation_job( db, user_id=user_id, output_mode="asset_generation", input_type=",".join(requested_assets), request_payload={"story_id": story_id, "assets": requested_assets}, story_id=story_id, ) await _dispatch_generation_job(db, job=job) await db.refresh(job) return public_generation_job_to_summary(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.""" await ensure_no_active_story_generation_job(db, story_id=story_id, user_id=user_id) 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) plan = build_asset_plan( output_mode="asset_retry", assets=requested_assets, ) await record_workflow_plan( db, job=job, plan=plan, ) 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}, ) async def retry_image() -> AssetCompletionResult: if story.mode == "storybook": return await _complete_storybook_image_assets(story, db, job=job) return await _complete_cover_image_asset( story, db, last_error_prefix="封面生成失败", log_event="cover_asset_retry_failed", job=job, ) async def retry_audio() -> AssetCompletionResult: return await _complete_audio_asset( story, db, raise_on_failure=False, job=job, ) asset_plan_result = await run_asset_plan( plan, image_task=retry_image if "image" in requested_assets else None, audio_task=retry_audio if "audio" in requested_assets else None, ) await record_executor_result( db, job=job, plan=plan, result=asset_plan_result, ) 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.""" await ensure_no_active_story_generation_job(db, story_id=story_id, user_id=user_id) 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) plan = build_asset_plan(output_mode="asset_generation", assets=["image"]) await record_workflow_plan( db, job=job, plan=plan, ) asset_result = await run_asset_plan( plan, image_task=lambda: _complete_cover_image_asset( story, db, raise_on_failure=True, log_event="cover_generation_failed", job=job, ), ) await record_executor_result( db, job=job, plan=plan, result=asset_result, ) image_result = asset_result.task_results[0] if asset_result.task_results else None 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 is not None and 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.""" await ensure_no_active_story_generation_job(db, story_id=story_id, user_id=user_id) 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) plan = build_asset_plan(output_mode="asset_generation", assets=["audio"]) await record_workflow_plan( db, job=job, plan=plan, ) asset_result = await run_asset_plan( plan, audio_task=lambda: _complete_audio_asset( story, db, raise_on_failure=True, job=job, ), ) await record_executor_result( db, job=job, plan=plan, result=asset_result, ) audio_result = asset_result.task_results[0] if asset_result.task_results else None 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 is not None and 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_audio_status( story_id: int, user_id: str, db: AsyncSession, ) -> dict: """Return audio cache status without generating audio.""" story = await get_story_detail(story_id, user_id, db) metadata = get_audio_cache_metadata(story.audio_path) if story.audio_path and not metadata.exists: logger.warning( "story_audio_cache_missing_on_status", 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() metadata = get_audio_cache_metadata(story.audio_path) return { "story_id": story.id, "audio_ready": story.audio_status == StoryAssetStatus.READY.value and metadata.exists, "cache_exists": metadata.exists, "cache_size_bytes": metadata.size_bytes, "cache_updated_at": metadata.updated_at, "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 clear_story_audio_cache( story_id: int, user_id: str, db: AsyncSession, ) -> dict: """Delete cached audio and mark audio as retryable again.""" story = await get_story_detail(story_id, user_id, db) delete_audio_cache(story.audio_path) story.audio_path = None sync_story_status( story, audio_status=StoryAssetStatus.NOT_REQUESTED, last_error=None, ) await db.commit() return await get_story_audio_status(story_id, user_id, db) async def prune_story_audio_cache(db: AsyncSession) -> dict[str, int]: """Prune expired audio cache files and repair story metadata.""" ttl_days = max(1, settings.story_audio_cache_ttl_days) cutoff = datetime.now(timezone.utc) - timedelta(days=ttl_days) result = await db.execute(select(Story).where(Story.audio_path.is_not(None))) stories = result.scalars().all() scanned = 0 pruned = 0 repaired = 0 for story in stories: scanned += 1 metadata = get_audio_cache_metadata(story.audio_path) if not metadata.exists: story.audio_path = None if story.audio_status == StoryAssetStatus.READY.value: sync_story_status(story, audio_status=StoryAssetStatus.NOT_REQUESTED) repaired += 1 continue if metadata.updated_at and metadata.updated_at < cutoff: delete_audio_cache(story.audio_path) story.audio_path = None sync_story_status( story, audio_status=StoryAssetStatus.NOT_REQUESTED, last_error=None, ) pruned += 1 await db.commit() logger.info( "story_audio_cache_pruned", scanned=scanned, pruned=pruned, repaired=repaired, ttl_days=ttl_days, ) return {"scanned": scanned, "pruned": pruned, "repaired": repaired} 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