From 96dfc677e265aae0328a69f9089152db1d4990d2 Mon Sep 17 00:00:00 2001 From: Yuyan Date: Sat, 18 Apr 2026 16:29:22 +0800 Subject: [PATCH] feat: track generation jobs --- admin-frontend/src/stores/storybook.ts | 1 + admin-frontend/src/views/StoryDetail.vue | 13 +- admin-frontend/src/views/StorybookViewer.vue | 9 +- .../versions/0011_add_generation_jobs.py | 77 ++++++ backend/app/api/stories.py | 1 + backend/app/db/models.py | 77 ++++++ backend/app/schemas/story_schemas.py | 1 + backend/app/services/generation_jobs.py | 133 +++++++++ backend/app/services/story_service.py | 258 ++++++++++++++++-- backend/tests/test_generation_jobs.py | 128 +++++++++ docs/planning/demo-validation-log.md | 7 +- docs/planning/week-2-execution-backlog.md | 1 + .../unified-generation-workflow-prd.md | 9 +- docs/technical/generation-job-state.md | 25 +- frontend/src/stores/storybook.ts | 1 + frontend/src/views/StoryDetail.vue | 13 +- frontend/src/views/StorybookViewer.vue | 9 +- scripts/demo_smoke.sh | 17 +- 18 files changed, 709 insertions(+), 71 deletions(-) create mode 100644 backend/alembic/versions/0011_add_generation_jobs.py create mode 100644 backend/app/services/generation_jobs.py create mode 100644 backend/tests/test_generation_jobs.py diff --git a/admin-frontend/src/stores/storybook.ts b/admin-frontend/src/stores/storybook.ts index 5d00f3f..eab8ef2 100644 --- a/admin-frontend/src/stores/storybook.ts +++ b/admin-frontend/src/stores/storybook.ts @@ -21,6 +21,7 @@ export interface Storybook { image_status?: string audio_status?: string last_error?: string | null + retryable_assets?: Array<'image' | 'audio'> } export const useStorybookStore = defineStore('storybook', () => { diff --git a/admin-frontend/src/views/StoryDetail.vue b/admin-frontend/src/views/StoryDetail.vue index 70ff2a3..87a6eda 100644 --- a/admin-frontend/src/views/StoryDetail.vue +++ b/admin-frontend/src/views/StoryDetail.vue @@ -26,6 +26,7 @@ interface Story { image_status: string audio_status: string last_error: string | null + retryable_assets: Array<'image' | 'audio'> pages?: Array<{ page_number: number text: string @@ -53,16 +54,8 @@ const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ?? const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status)) const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status)) const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status)) -const canRetryImage = computed(() => - Boolean(story.value?.cover_prompt) - && story.value?.image_status !== 'ready' - && story.value?.image_status !== 'generating', -) -const canRetryAudio = computed(() => - Boolean(story.value?.story_text) - && story.value?.audio_status !== 'ready' - && story.value?.audio_status !== 'generating', -) +const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false) +const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false) const isAudioGenerating = computed(() => story.value?.audio_status === 'generating') const assetGuidance = computed(() => { if (story.value?.generation_status === 'degraded_completed') { diff --git a/admin-frontend/src/views/StorybookViewer.vue b/admin-frontend/src/views/StorybookViewer.vue index 9a47730..db0f94d 100644 --- a/admin-frontend/src/views/StorybookViewer.vue +++ b/admin-frontend/src/views/StorybookViewer.vue @@ -33,6 +33,7 @@ interface StoryDetailResponse { image_status: string audio_status: string last_error: string | null + retryable_assets: Array<'image' | 'audio'> } const route = useRoute() @@ -51,11 +52,7 @@ const isLastPage = computed(() => currentPageIndex.value === totalPages.value - const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status)) const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status)) const audioMeta = computed(() => getAssetStatusMeta(storybook.value?.audio_status)) -const canRetryImages = computed(() => - Boolean(storybook.value?.id) - && storybook.value?.image_status !== 'ready' - && storybook.value?.image_status !== 'generating', -) +const canRetryImages = computed(() => storybook.value?.retryable_assets?.includes('image') ?? false) const currentPage = computed(() => { if (!storybook.value || isCover.value) return null return storybook.value.pages[currentPageIndex.value] @@ -151,6 +148,7 @@ async function loadStorybook() { image_status: detail.image_status, audio_status: detail.audio_status, last_error: detail.last_error, + retryable_assets: detail.retryable_assets, }) } catch (e) { error.value = e instanceof Error ? e.message : '绘本加载失败' @@ -186,6 +184,7 @@ async function retryStorybookImages() { image_status: detail.image_status, audio_status: detail.audio_status, last_error: detail.last_error, + retryable_assets: detail.retryable_assets, }) } catch (e) { error.value = e instanceof Error ? e.message : '插图补全失败' diff --git a/backend/alembic/versions/0011_add_generation_jobs.py b/backend/alembic/versions/0011_add_generation_jobs.py new file mode 100644 index 0000000..2376e0c --- /dev/null +++ b/backend/alembic/versions/0011_add_generation_jobs.py @@ -0,0 +1,77 @@ +"""add generation jobs + +Revision ID: 0011_add_generation_jobs +Revises: 0010_add_story_audio_cache_path +Create Date: 2026-04-18 + +""" + +import sqlalchemy as sa +from alembic import op + + +revision = "0011_add_generation_jobs" +down_revision = "0010_add_story_audio_cache_path" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "generation_jobs", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=255), nullable=False), + sa.Column("story_id", sa.Integer(), nullable=True), + sa.Column("output_mode", sa.String(length=32), nullable=False), + sa.Column("input_type", sa.String(length=32), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="running"), + sa.Column( + "current_step", + sa.String(length=64), + nullable=False, + server_default="request_accepted", + ), + sa.Column("request_payload", sa.JSON(), nullable=False, server_default="{}"), + sa.Column("result_snapshot", sa.JSON(), nullable=False, server_default="{}"), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(["story_id"], ["stories.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_generation_jobs_user_id", "generation_jobs", ["user_id"]) + op.create_index("ix_generation_jobs_story_id", "generation_jobs", ["story_id"]) + op.create_index("ix_generation_jobs_status", "generation_jobs", ["status"]) + op.create_index("ix_generation_jobs_created_at", "generation_jobs", ["created_at"]) + + op.create_table( + "generation_job_events", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("job_id", sa.String(length=36), nullable=False), + sa.Column("story_id", sa.Integer(), nullable=True), + sa.Column("event_type", sa.String(length=64), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("message", sa.Text(), nullable=True), + sa.Column("event_metadata", sa.JSON(), nullable=False, server_default="{}"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(["job_id"], ["generation_jobs.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["story_id"], ["stories.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_generation_job_events_job_id", "generation_job_events", ["job_id"]) + op.create_index("ix_generation_job_events_story_id", "generation_job_events", ["story_id"]) + op.create_index("ix_generation_job_events_created_at", "generation_job_events", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_generation_job_events_created_at", table_name="generation_job_events") + op.drop_index("ix_generation_job_events_story_id", table_name="generation_job_events") + op.drop_index("ix_generation_job_events_job_id", table_name="generation_job_events") + op.drop_table("generation_job_events") + + op.drop_index("ix_generation_jobs_created_at", table_name="generation_jobs") + op.drop_index("ix_generation_jobs_status", table_name="generation_jobs") + op.drop_index("ix_generation_jobs_story_id", table_name="generation_jobs") + op.drop_index("ix_generation_jobs_user_id", table_name="generation_jobs") + op.drop_table("generation_jobs") diff --git a/backend/app/api/stories.py b/backend/app/api/stories.py index 29a1a1e..e84ce60 100644 --- a/backend/app/api/stories.py +++ b/backend/app/api/stories.py @@ -299,6 +299,7 @@ async def generate_story_image( "image_status": story.image_status, "audio_status": story.audio_status, "last_error": story.last_error, + "retryable_assets": story.retryable_assets, } diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 93ef2a2..542304b 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -83,11 +83,88 @@ class Story(Base): child_profile: Mapped["ChildProfile | None"] = relationship("ChildProfile") story_universe: Mapped["StoryUniverse | None"] = relationship("StoryUniverse") + @property + def retryable_assets(self) -> list[str]: + """Assets that can be completed or retried from the current persisted state.""" + + assets: list[str] = [] + + image_is_busy_or_ready = self.image_status in {"ready", "generating"} + if not image_is_busy_or_ready: + if self.mode == "storybook": + pages = self.pages or [] + has_missing_page_image = any( + isinstance(page, dict) + and page.get("image_prompt") + and not page.get("image_url") + for page in pages + ) + if (self.cover_prompt and not self.image_url) or has_missing_page_image: + assets.append("image") + elif self.cover_prompt: + assets.append("image") + + audio_is_busy_or_ready = self.audio_status in {"ready", "generating"} + if self.story_text and not audio_is_busy_or_ready: + assets.append("audio") + + return assets + def _uuid() -> str: return str(uuid4()) +class GenerationJob(Base): + """User-visible generation attempt that can be inspected after the request returns.""" + + __tablename__ = "generation_jobs" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + user_id: Mapped[str] = mapped_column( + String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + story_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("stories.id", ondelete="SET NULL"), nullable=True, index=True + ) + output_mode: Mapped[str] = mapped_column(String(32), nullable=False) + input_type: Mapped[str] = mapped_column(String(32), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="running", index=True) + current_step: Mapped[str] = mapped_column( + String(64), nullable=False, default="request_accepted" + ) + request_payload: Mapped[dict] = mapped_column(JSON, default=dict) + result_snapshot: Mapped[dict] = mapped_column(JSON, default=dict) + error_message: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class GenerationJobEvent(Base): + """Append-only event emitted by a generation job.""" + + __tablename__ = "generation_job_events" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + job_id: Mapped[str] = mapped_column( + String(36), ForeignKey("generation_jobs.id", ondelete="CASCADE"), nullable=False, index=True + ) + story_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("stories.id", ondelete="SET NULL"), nullable=True, index=True + ) + event_type: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + message: Mapped[str | None] = mapped_column(Text) + event_metadata: Mapped[dict] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + + class ChildProfile(Base): """Child profile entity.""" diff --git a/backend/app/schemas/story_schemas.py b/backend/app/schemas/story_schemas.py index 4bb780f..cc7d826 100644 --- a/backend/app/schemas/story_schemas.py +++ b/backend/app/schemas/story_schemas.py @@ -17,6 +17,7 @@ class StoryStatusMixin(BaseModel): image_status: str audio_status: str last_error: str | None = None + retryable_assets: list[Literal["image", "audio"]] = Field(default_factory=list) class GenerateRequest(BaseModel): diff --git a/backend/app/services/generation_jobs.py b/backend/app/services/generation_jobs.py new file mode 100644 index 0000000..b6133b0 --- /dev/null +++ b/backend/app/services/generation_jobs.py @@ -0,0 +1,133 @@ +"""Lightweight generation job/event tracking.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import GenerationJob, GenerationJobEvent, Story + + +def _story_snapshot(story: Story | None) -> dict[str, Any]: + if story is None: + return {} + + return { + "story_id": story.id, + "mode": story.mode, + "generation_status": story.generation_status, + "image_status": story.image_status, + "audio_status": story.audio_status, + "retryable_assets": story.retryable_assets, + "last_error": story.last_error, + } + + +def _job_status_from_story(story: Story) -> str: + if story.generation_status == "failed": + return "failed" + if story.generation_status == "degraded_completed": + return "degraded_completed" + return "completed" + + +async def create_generation_job( + db: AsyncSession, + *, + user_id: str, + output_mode: str, + input_type: str, + request_payload: dict[str, Any], + story_id: int | None = None, +) -> GenerationJob: + """Create a generation job and record its first event.""" + + job = GenerationJob( + user_id=user_id, + story_id=story_id, + output_mode=output_mode, + input_type=input_type, + status="running", + current_step="request_accepted", + request_payload=request_payload, + result_snapshot={}, + ) + db.add(job) + await db.flush() + await record_generation_event( + db, + job=job, + story_id=story_id, + event_type="request_accepted", + status="succeeded", + message="Generation request accepted.", + metadata={"output_mode": output_mode, "input_type": input_type}, + commit=False, + ) + await db.commit() + await db.refresh(job) + return job + + +async def record_generation_event( + db: AsyncSession, + *, + job: GenerationJob, + event_type: str, + status: str, + story_id: int | None = None, + message: str | None = None, + metadata: dict[str, Any] | None = None, + commit: bool = True, +) -> GenerationJobEvent: + """Append one event to an existing generation job.""" + + event = GenerationJobEvent( + job_id=job.id, + story_id=story_id if story_id is not None else job.story_id, + event_type=event_type, + status=status, + message=message, + event_metadata=metadata or {}, + ) + db.add(event) + if commit: + await db.commit() + return event + + +async def finish_generation_job( + db: AsyncSession, + *, + job: GenerationJob, + story: Story | None, + status: str | None = None, + current_step: str, + error_message: str | None = None, + message: str | None = None, + metadata: dict[str, Any] | None = None, +) -> GenerationJob: + """Mark a generation job as completed/degraded/failed and append a final event.""" + + job.story_id = story.id if story is not None else job.story_id + job.status = status or (_job_status_from_story(story) if story is not None else "failed") + job.current_step = current_step + job.error_message = error_message + job.result_snapshot = _story_snapshot(story) + await record_generation_event( + db, + job=job, + story_id=job.story_id, + event_type=current_step, + status=job.status, + message=message, + metadata={ + **(metadata or {}), + "result_snapshot": job.result_snapshot, + }, + commit=False, + ) + await db.commit() + await db.refresh(job) + return job diff --git a/backend/app/services/story_service.py b/backend/app/services/story_service.py index 5c8ba35..7cf9218 100644 --- a/backend/app/services/story_service.py +++ b/backend/app/services/story_service.py @@ -28,6 +28,11 @@ from app.services.audio_storage import ( read_audio_cache, write_story_audio_cache, ) +from app.services.generation_jobs import ( + create_generation_job, + finish_generation_job, + record_generation_event, +) from app.services.memory_service import build_enhanced_memory_context from app.services.provider_router import ( generate_image, @@ -141,6 +146,26 @@ def _trigger_story_postprocessing(story: Story) -> None: 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, @@ -629,6 +654,7 @@ async def generate_full_story_service( image_status=story.image_status, audio_status=story.audio_status, last_error=story.last_error, + retryable_assets=story.retryable_assets, ) @@ -703,6 +729,7 @@ async def generate_storybook_service( image_status=story.image_status, audio_status=story.audio_status, last_error=story.last_error, + retryable_assets=story.retryable_assets, ) @@ -713,6 +740,51 @@ async def generate_generation_service( ) -> GenerationResponse: """Unified generation workflow entry point for stories and storybooks.""" + job = await create_generation_job( + db, + user_id=user_id, + output_mode=request.output_mode, + input_type=request.type, + request_payload=request.model_dump(mode="json"), + ) + + try: + response = await _generate_generation_service_with_job(request, user_id, db, job=job) + except HTTPException as exc: + await finish_generation_job( + db, + job=job, + story=None, + status="failed", + current_step="generation_failed", + error_message=str(exc.detail), + message="Generation failed before a durable story result was available.", + ) + raise + except Exception as exc: + await finish_generation_job( + db, + job=job, + story=None, + status="failed", + current_step="generation_failed", + error_message=str(exc), + message="Generation failed before a durable story result was available.", + ) + raise + + return response + + +async def _generate_generation_service_with_job( + request: GenerationRequest, + user_id: str, + db: AsyncSession, + *, + job, +) -> GenerationResponse: + """Run the unified generation workflow after the tracking job has been created.""" + if request.output_mode == "storybook": storybook = await generate_storybook_service( StorybookRequest( @@ -730,6 +802,14 @@ async def generate_generation_service( 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, title=storybook.title, @@ -746,6 +826,7 @@ async def generate_generation_service( last_error=storybook.last_error, child_profile_id=saved_story.child_profile_id, universe_id=saved_story.universe_id, + retryable_assets=saved_story.retryable_assets, ) generate_request = GenerateRequest( @@ -758,6 +839,15 @@ async def generate_generation_service( if request.generate_images: story = await generate_full_story_service(generate_request, user_id, db) + 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, title=story.title, @@ -774,9 +864,18 @@ async def generate_generation_service( 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) + 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, title=story.title, @@ -791,6 +890,7 @@ async def generate_generation_service( last_error=story.last_error, child_profile_id=story.child_profile_id, universe_id=story.universe_id, + retryable_assets=story.retryable_assets, ) @@ -884,20 +984,72 @@ async def retry_story_assets( db: AsyncSession, ) -> Story: """Retry selected assets through one workflow-level endpoint.""" - - story = await get_story_detail(story_id, user_id, db) 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 - if "image" in requested_assets: - if story.mode == "storybook": - await _retry_storybook_image_assets(story, db) - else: - await _retry_cover_image_asset(story, db) + try: + story = await get_story_detail(story_id, user_id, db) + await record_generation_event( + db, + job=job, + story_id=story.id, + event_type="asset_retry_started", + status="running", + message="Asset retry started.", + metadata={"assets": requested_assets}, + ) - if "audio" in requested_assets: - await _retry_audio_asset(story, db) + if "image" in requested_assets: + if story.mode == "storybook": + await _retry_storybook_image_assets(story, db) + else: + await _retry_cover_image_asset(story, db) - return await get_story_detail(story_id, user_id, db) + if "audio" in requested_assets: + await _retry_audio_asset(story, db) + + 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( @@ -906,16 +1058,47 @@ async def generate_story_cover( db: AsyncSession, ) -> str: """Generate cover image for an existing story.""" - story = await get_story_detail(story_id, user_id, db) - - image_result = await _complete_cover_image_asset( - story, + job = await create_generation_job( db, - raise_on_failure=True, - log_event="cover_generation_failed", + user_id=user_id, + output_mode="asset_generation", + input_type="image", + request_payload={"story_id": story_id, "assets": ["image"]}, + story_id=story_id, ) - if image_result.succeeded and isinstance(image_result.value, str): - return image_result.value + story: Story | None = None + + try: + story = await get_story_detail(story_id, user_id, db) + image_result = await _complete_cover_image_asset( + story, + db, + raise_on_failure=True, + log_event="cover_generation_failed", + ) + story = await get_story_detail(story_id, user_id, db) + await finish_generation_job( + db, + job=job, + story=story, + current_step="asset_generation_completed", + message="Cover image generation completed.", + metadata={"assets": ["image"]}, + ) + if image_result.succeeded and isinstance(image_result.value, str): + return image_result.value + except HTTPException as exc: + await finish_generation_job( + db, + job=job, + story=story, + status="failed", + current_step="asset_generation_failed", + error_message=str(exc.detail), + message="Cover image generation failed.", + metadata={"assets": ["image"]}, + ) + raise raise HTTPException(status_code=500, detail="Image generation failed") @@ -926,11 +1109,42 @@ async def generate_story_audio( db: AsyncSession, ) -> bytes: """Generate audio for a story.""" - story = await get_story_detail(story_id, user_id, db) + 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 - audio_result = await _complete_audio_asset(story, db, raise_on_failure=True) - if audio_result.succeeded and isinstance(audio_result.value, bytes): - return audio_result.value + try: + story = await get_story_detail(story_id, user_id, db) + audio_result = await _complete_audio_asset(story, db, raise_on_failure=True) + story = await get_story_detail(story_id, user_id, db) + await finish_generation_job( + db, + job=job, + story=story, + current_step="asset_generation_completed", + message="Story audio generation completed.", + metadata={"assets": ["audio"]}, + ) + if audio_result.succeeded and isinstance(audio_result.value, bytes): + return audio_result.value + except HTTPException as exc: + await finish_generation_job( + db, + job=job, + story=story, + status="failed", + current_step="asset_generation_failed", + error_message=str(exc.detail), + message="Story audio generation failed.", + metadata={"assets": ["audio"]}, + ) + raise raise HTTPException(status_code=500, detail="Audio generation failed") diff --git a/backend/tests/test_generation_jobs.py b/backend/tests/test_generation_jobs.py new file mode 100644 index 0000000..587b576 --- /dev/null +++ b/backend/tests/test_generation_jobs.py @@ -0,0 +1,128 @@ +"""Generation job tracking tests.""" + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.db.database import get_db +from app.db.models import GenerationJob, GenerationJobEvent +from app.main import app + +pytestmark = pytest.mark.asyncio + + +async def test_unified_generation_records_job_events_and_retryable_assets( + db_session, + test_user, + auth_token, + mock_text_provider, +): + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + client.cookies.set("access_token", auth_token) + + response = await client.post( + "/api/generations", + json={ + "output_mode": "story", + "type": "keywords", + "data": "小兔子, 森林", + "generate_images": False, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["generation_status"] == "narrative_ready" + assert data["retryable_assets"] == ["image", "audio"] + + jobs = ( + await db_session.execute( + select(GenerationJob).where(GenerationJob.user_id == test_user.id) + ) + ).scalars().all() + assert len(jobs) == 1 + job = jobs[0] + assert job.story_id == data["id"] + assert job.output_mode == "story" + assert job.input_type == "keywords" + assert job.status == "completed" + assert job.current_step == "generation_completed" + assert job.result_snapshot["retryable_assets"] == ["image", "audio"] + + events = ( + await db_session.execute( + select(GenerationJobEvent) + .where(GenerationJobEvent.job_id == job.id) + .order_by(GenerationJobEvent.id) + ) + ).scalars().all() + assert [event.event_type for event in events] == [ + "request_accepted", + "generation_completed", + ] + finally: + app.dependency_overrides.clear() + + +async def test_asset_retry_records_job_events_and_updates_retryable_assets( + db_session, + test_user, + auth_token, + degraded_story_with_text, + mock_image_provider, +): + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + client.cookies.set("access_token", auth_token) + + response = await client.post( + f"/api/generations/{degraded_story_with_text.id}/retry-assets", + json={"assets": ["image"]}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["image_status"] == "ready" + assert data["retryable_assets"] == ["audio"] + + jobs = ( + await db_session.execute( + select(GenerationJob).where( + GenerationJob.story_id == degraded_story_with_text.id, + GenerationJob.output_mode == "asset_retry", + ) + ) + ).scalars().all() + assert len(jobs) == 1 + job = jobs[0] + assert job.status == "completed" + assert job.current_step == "asset_retry_completed" + assert job.result_snapshot["retryable_assets"] == ["audio"] + + events = ( + await db_session.execute( + select(GenerationJobEvent) + .where(GenerationJobEvent.job_id == job.id) + .order_by(GenerationJobEvent.id) + ) + ).scalars().all() + assert [event.event_type for event in events] == [ + "request_accepted", + "asset_retry_started", + "asset_retry_completed", + ] + finally: + app.dependency_overrides.clear() diff --git a/docs/planning/demo-validation-log.md b/docs/planning/demo-validation-log.md index aeb62cf..6285784 100644 --- a/docs/planning/demo-validation-log.md +++ b/docs/planning/demo-validation-log.md @@ -14,6 +14,8 @@ - Pydantic v2 兼容性 warning 清理 - Dockerfile build warning 清理 - 管理后台弱默认密码防护 +- Generation job/event 轻量落库 +- `retryable_assets` 标准响应字段 - 后端统一生成接口 - 故事封面资产补全 - 故事音频资产补全 @@ -42,12 +44,15 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh - Docker 管理前端镜像 `dreamweaver-admin-frontend:dev` 构建通过。 - Docker 后端镜像 `dreamweaver-backend:dev` 构建通过。 - `ruff check app tests` 通过。 -- `pytest -q` 通过,71 个测试通过,Pydantic v2 deprecation warning 已清零。 +- `pytest -q` 通过,73 个测试通过,Pydantic v2 deprecation warning 已清零。 - `SMOKE_AUDIO=1 ./scripts/demo_smoke.sh` 通过。 +- smoke 会断言 `retryable_assets` 在故事、音频、绘本补全前后按预期变化。 - 本地用户端可通过 `http://localhost:52080` 访问。 - 本地管理端可通过 `http://localhost:52888` 访问。 - 技术债扫描未发现 `class Config`、`TODO`、`FIXME`、旧 Issue 注释或 Dockerfile `FROM ... as`。 - 后端不再内置 `admin123` 管理密码;非 debug 环境开启管理后台时会拒绝空/弱密码。 +- 统一生成和资产重试会写入 `generation_jobs` 与 `generation_job_events`。 +- API 响应返回 `retryable_assets`,前端按标准字段展示补全/重试入口。 已确认的演示能力: diff --git a/docs/planning/week-2-execution-backlog.md b/docs/planning/week-2-execution-backlog.md index e5380f2..64df155 100644 --- a/docs/planning/week-2-execution-backlog.md +++ b/docs/planning/week-2-execution-backlog.md @@ -88,6 +88,7 @@ Week 2 的目标不是做“完整商业产品”,而是做出一个面试时 | W2-13 | Tech Debt | 清理 Pydantic v2 warning、Dockerfile warning 和旧 TODO | 测试无 warning,Docker build 无 casing warning | P1 | 0.5d | Done | | W2-14 | Frontend | 同步管理端生成状态与资产补全体验 | 用户端/管理端状态体验不再分叉 | P1 | 0.5d | Done | | W2-15 | Security | 移除管理后台弱默认密码 | 非 debug 管理后台拒绝空/弱密码 | P1 | 0.5d | Done | +| W2-16 | Workflow | 轻量落库 generation job/event 与 retryable assets | 生成/资产补全过程可追踪,前端按标准字段展示 CTA | P1 | 1.0d | Done | --- diff --git a/docs/product/unified-generation-workflow-prd.md b/docs/product/unified-generation-workflow-prd.md index e174da5..ec1547c 100644 --- a/docs/product/unified-generation-workflow-prd.md +++ b/docs/product/unified-generation-workflow-prd.md @@ -29,8 +29,12 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本 - `audio_status` - `last_error` - `audio_path` +- 已新增轻量可查询的生成过程记录: + - `generation_jobs` + - `generation_job_events` - Storybook 阅读器已支持按 ID 恢复,不再只依赖 Pinia 内存态 - 故事列表页、故事详情页、绘本阅读页已接入统一状态展示 +- API 响应已统一返回 `retryable_assets`,前端不再各自推断可补全资产 - 故事音频已支持首次生成后缓存复用 - `degraded_completed` 已在服务层和前端语义中落地 - 已新增首版统一资产重试入口:`POST /api/stories/{story_id}/assets/retry` @@ -50,8 +54,9 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本 ### Still Missing - 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 已开始抽取公共步骤,但旧 service 函数仍作为兼容层保留 -- 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,并已抽出 asset completion helper 与 `AssetCompletionResult`,但尚未落库为完整 generation job 模型 -- `partial_ready`、`retryable_assets` 等更细粒度状态仍停留在目标态 +- 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,并已抽出 asset completion helper 与 `AssetCompletionResult` +- `generation_jobs` 已记录请求、完成、失败和资产重试事件,但尚未扩展到逐 provider 调用、逐页面资产步骤和完整运营分析 +- `partial_ready`、`text_status` 等更细粒度状态仍停留在目标态 ### What This Means diff --git a/docs/technical/generation-job-state.md b/docs/technical/generation-job-state.md index be3e6c3..c257f9a 100644 --- a/docs/technical/generation-job-state.md +++ b/docs/technical/generation-job-state.md @@ -4,9 +4,15 @@ ## 当前结论 -短期不新增 `generation_jobs` 表,继续把求职版状态落在 `stories` 主记录上。 +已新增轻量 `generation_jobs` 与 `generation_job_events` 表,但不引入复杂工作流引擎。 -原因是当前 MVP 的生成方式仍然以同步请求为主:后端在一次请求中完成主内容保存,再补全封面、绘本插图或语音。用户最关心的是“这个故事现在能不能读、哪些资产可补全”,而不是一个独立 job 的生命周期。 +原因是当前 MVP 的生成方式仍然以同步请求为主:后端在一次请求中完成主内容保存,再补全封面、绘本插图或语音。用户最关心的是“这个故事现在能不能读、哪些资产可补全”;系统侧则需要有足够的轨迹说明“这次生成做到了哪一步、哪里失败、哪些资产还能重试”。 + +因此当前采用轻量落库策略: + +- `stories` 继续承载用户可见结果和当前状态。 +- `generation_jobs` 记录一次生成或资产补全尝试。 +- `generation_job_events` 记录关键步骤事件,例如 `request_accepted`、`generation_completed`、`asset_retry_started`、`asset_retry_completed`。 ## 现有状态模型 @@ -21,7 +27,7 @@ ## 什么时候需要落库 job -如果后续进入真实生产化,需要重新评估 `generation_jobs`: +如果后续进入真实生产化,需要扩展当前 job/event 模型: - 生成流程改成真正异步,前端需要轮询 job 进度。 - 单个故事会产生多次生成尝试,需要审计每次 provider 调用。 @@ -29,15 +35,14 @@ - 需要按 provider、成本、延迟和失败原因做运营分析。 - 需要断点续跑,避免 Worker 重启后丢失中间状态。 -## 推荐未来结构 +## 推荐未来扩展 -未来可以新增两层记录: +当前已有两层记录,未来可以继续扩展字段和事件颗粒度: -- `generation_jobs`: 一次用户发起的生成任务,记录输入、状态、耗时、错误和关联 story。 -- `generation_job_events`: 任务事件流,记录每一步开始、成功、失败、provider、耗时和错误摘要。 - -这会把“用户可见结果”和“系统执行过程”分开,但目前还不是求职版的最高优先级。 +- 在 `generation_job_events` 中补 provider、耗时、成本和错误摘要。 +- 对绘本逐页插图、TTS、后处理任务记录更细事件。 +- 为前端提供 job 查询接口,用于真正异步生成时轮询进度。 ## 面试表达 -我现在没有急着加 job 表,是因为 MVP 首要目标是让故事结果稳定可读,并让资产失败可恢复。等生成链路变成真正异步、需要审计和运营指标时,再把执行过程拆到 job/event 表,会比现在提前设计复杂表结构更稳。 +我没有一上来引入复杂工作流引擎,而是先用轻量 job/event 表把关键执行轨迹落下来。这样既能回答“生成过程是否可追踪”,又不会为了求职版 MVP 牺牲主链路稳定性。 diff --git a/frontend/src/stores/storybook.ts b/frontend/src/stores/storybook.ts index 5d00f3f..eab8ef2 100644 --- a/frontend/src/stores/storybook.ts +++ b/frontend/src/stores/storybook.ts @@ -21,6 +21,7 @@ export interface Storybook { image_status?: string audio_status?: string last_error?: string | null + retryable_assets?: Array<'image' | 'audio'> } export const useStorybookStore = defineStore('storybook', () => { diff --git a/frontend/src/views/StoryDetail.vue b/frontend/src/views/StoryDetail.vue index 8cebdbf..415ec7e 100644 --- a/frontend/src/views/StoryDetail.vue +++ b/frontend/src/views/StoryDetail.vue @@ -26,6 +26,7 @@ interface Story { image_status: string audio_status: string last_error: string | null + retryable_assets: Array<'image' | 'audio'> pages?: Array<{ page_number: number text: string @@ -53,16 +54,8 @@ const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ?? const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status)) const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status)) const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status)) -const canRetryImage = computed(() => - Boolean(story.value?.cover_prompt) - && story.value?.image_status !== 'ready' - && story.value?.image_status !== 'generating', -) -const canRetryAudio = computed(() => - Boolean(story.value?.story_text) - && story.value?.audio_status !== 'ready' - && story.value?.audio_status !== 'generating', -) +const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false) +const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false) const isAudioGenerating = computed(() => story.value?.audio_status === 'generating') const assetGuidance = computed(() => { if (story.value?.generation_status === 'degraded_completed') { diff --git a/frontend/src/views/StorybookViewer.vue b/frontend/src/views/StorybookViewer.vue index 78e4063..615bc8f 100644 --- a/frontend/src/views/StorybookViewer.vue +++ b/frontend/src/views/StorybookViewer.vue @@ -33,6 +33,7 @@ interface StoryDetailResponse { image_status: string audio_status: string last_error: string | null + retryable_assets: Array<'image' | 'audio'> } const route = useRoute() @@ -51,11 +52,7 @@ const isLastPage = computed(() => currentPageIndex.value === totalPages.value - const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status)) const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status)) const audioMeta = computed(() => getAssetStatusMeta(storybook.value?.audio_status)) -const canRetryImages = computed(() => - Boolean(storybook.value?.id) - && storybook.value?.image_status !== 'ready' - && storybook.value?.image_status !== 'generating', -) +const canRetryImages = computed(() => storybook.value?.retryable_assets?.includes('image') ?? false) const currentPage = computed(() => { if (!storybook.value || isCover.value) return null return storybook.value.pages[currentPageIndex.value] @@ -151,6 +148,7 @@ async function loadStorybook() { image_status: detail.image_status, audio_status: detail.audio_status, last_error: detail.last_error, + retryable_assets: detail.retryable_assets, }) } catch (e) { error.value = e instanceof Error ? e.message : '绘本加载失败' @@ -186,6 +184,7 @@ async function retryStorybookImages() { image_status: detail.image_status, audio_status: detail.audio_status, last_error: detail.last_error, + retryable_assets: detail.retryable_assets, }) } catch (e) { error.value = e instanceof Error ? e.message : '插图补全失败' diff --git a/scripts/demo_smoke.sh b/scripts/demo_smoke.sh index 5bbb7ca..95159c8 100755 --- a/scripts/demo_smoke.sh +++ b/scripts/demo_smoke.sh @@ -72,23 +72,26 @@ story_json="$(post_json "$APP_URL/api/generations" '{ }')" story_id="$(jq -r '.id' <<<"$story_json")" assert_jq "$story_json" '.mode == "generated" and .generation_status == "narrative_ready"' "story should be readable before assets" -echo "$story_json" | jq '{id,title,mode,generation_status,image_status,audio_status}' +assert_jq "$story_json" '(.retryable_assets | index("image")) != null and (.retryable_assets | index("audio")) != null' "story should expose image/audio as retryable assets" +echo "$story_json" | jq '{id,title,mode,generation_status,image_status,audio_status,retryable_assets}' say "Retrying story cover image" story_image_json="$(post_json "$APP_URL/api/generations/$story_id/retry-assets" '{"assets":["image"]}')" assert_jq "$story_image_json" '.image_status == "ready" and (.image_url != null)' "story cover should be ready after retry" -echo "$story_image_json" | jq '{id,title,generation_status,image_status,audio_status}' +assert_jq "$story_image_json" '(.retryable_assets | index("image")) == null and (.retryable_assets | index("audio")) != null' "story image retry should leave only audio retryable" +echo "$story_image_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets}' if [[ "$SMOKE_AUDIO" == "1" ]]; then say "Retrying story audio" story_audio_json="$(post_json "$APP_URL/api/generations/$story_id/retry-assets" '{"assets":["audio"]}')" assert_jq "$story_audio_json" '.audio_status == "ready"' "story audio should be ready after retry" + assert_jq "$story_audio_json" '(.retryable_assets | length) == 0' "story should have no retryable assets after image and audio are ready" audio_probe="$(curl -fsS -b "$COOKIE_JAR" -o /tmp/dreamweaver-smoke-audio.mp3 -w '%{http_code} %{content_type} %{size_download}' "$APP_URL/api/audio/$story_id")" if [[ "$audio_probe" != 200\ audio/mpeg* ]]; then echo "Unexpected audio response: $audio_probe" >&2 exit 1 fi - echo "$story_audio_json" | jq '{id,title,generation_status,image_status,audio_status}' + echo "$story_audio_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets}' else say "Skipping audio smoke; set SMOKE_AUDIO=1 to include TTS" fi @@ -104,17 +107,19 @@ storybook_json="$(post_json "$APP_URL/api/generations" '{ }')" storybook_id="$(jq -r '.id' <<<"$storybook_json")" assert_jq "$storybook_json" '.mode == "storybook" and .image_status == "not_requested" and (.pages | length) >= 4' "storybook should be readable before images" -echo "$storybook_json" | jq '{id,title,mode,generation_status,image_status,audio_status,pages:(.pages | length)}' +assert_jq "$storybook_json" '(.retryable_assets | index("image")) != null and (.retryable_assets | index("audio")) == null' "storybook should expose images as retryable assets" +echo "$storybook_json" | jq '{id,title,mode,generation_status,image_status,audio_status,retryable_assets,pages:(.pages | length)}' say "Retrying storybook images" storybook_image_json="$(post_json "$APP_URL/api/generations/$storybook_id/retry-assets" '{"assets":["image"]}')" assert_jq "$storybook_image_json" '.image_status == "ready" and (.pages | length) >= 4 and ([.pages[] | select(.image_url != null)] | length) == (.pages | length)' "storybook images should be ready after retry" -echo "$storybook_image_json" | jq '{id,title,generation_status,image_status,audio_status,pages:(.pages | length), ready_pages:([.pages[] | select(.image_url != null)] | length)}' +assert_jq "$storybook_image_json" '(.retryable_assets | length) == 0' "storybook should have no retryable assets after images are ready" +echo "$storybook_image_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets,pages:(.pages | length), ready_pages:([.pages[] | select(.image_url != null)] | length)}' say "Checking story list" list_json="$(get_json "$APP_URL/api/stories?limit=5")" assert_jq "$list_json" "map(.id) | index($story_id) != null" "story list should include generated story" assert_jq "$list_json" "map(.id) | index($storybook_id) != null" "story list should include generated storybook" -echo "$list_json" | jq '.[] | {id,title,mode,generation_status,image_status,audio_status}' +echo "$list_json" | jq '.[] | {id,title,mode,generation_status,image_status,audio_status,retryable_assets}' say "DreamWeaver demo smoke passed"