1864 lines
56 KiB
Python
1864 lines
56 KiB
Python
"""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
|
|
|