feat: track generation jobs

This commit is contained in:
2026-04-18 16:29:22 +08:00
parent 16fafe0fe0
commit 96dfc677e2
18 changed files with 709 additions and 71 deletions

View File

@@ -21,6 +21,7 @@ export interface Storybook {
image_status?: string image_status?: string
audio_status?: string audio_status?: string
last_error?: string | null last_error?: string | null
retryable_assets?: Array<'image' | 'audio'>
} }
export const useStorybookStore = defineStore('storybook', () => { export const useStorybookStore = defineStore('storybook', () => {

View File

@@ -26,6 +26,7 @@ interface Story {
image_status: string image_status: string
audio_status: string audio_status: string
last_error: string | null last_error: string | null
retryable_assets: Array<'image' | 'audio'>
pages?: Array<{ pages?: Array<{
page_number: number page_number: number
text: string 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 generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status))
const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status)) const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status))
const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status)) const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status))
const canRetryImage = computed(() => const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false)
Boolean(story.value?.cover_prompt) const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false)
&& 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 isAudioGenerating = computed(() => story.value?.audio_status === 'generating') const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
const assetGuidance = computed(() => { const assetGuidance = computed(() => {
if (story.value?.generation_status === 'degraded_completed') { if (story.value?.generation_status === 'degraded_completed') {

View File

@@ -33,6 +33,7 @@ interface StoryDetailResponse {
image_status: string image_status: string
audio_status: string audio_status: string
last_error: string | null last_error: string | null
retryable_assets: Array<'image' | 'audio'>
} }
const route = useRoute() const route = useRoute()
@@ -51,11 +52,7 @@ const isLastPage = computed(() => currentPageIndex.value === totalPages.value -
const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status)) const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status))
const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status)) const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status))
const audioMeta = computed(() => getAssetStatusMeta(storybook.value?.audio_status)) const audioMeta = computed(() => getAssetStatusMeta(storybook.value?.audio_status))
const canRetryImages = computed(() => const canRetryImages = computed(() => storybook.value?.retryable_assets?.includes('image') ?? false)
Boolean(storybook.value?.id)
&& storybook.value?.image_status !== 'ready'
&& storybook.value?.image_status !== 'generating',
)
const currentPage = computed(() => { const currentPage = computed(() => {
if (!storybook.value || isCover.value) return null if (!storybook.value || isCover.value) return null
return storybook.value.pages[currentPageIndex.value] return storybook.value.pages[currentPageIndex.value]
@@ -151,6 +148,7 @@ async function loadStorybook() {
image_status: detail.image_status, image_status: detail.image_status,
audio_status: detail.audio_status, audio_status: detail.audio_status,
last_error: detail.last_error, last_error: detail.last_error,
retryable_assets: detail.retryable_assets,
}) })
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '绘本加载失败' error.value = e instanceof Error ? e.message : '绘本加载失败'
@@ -186,6 +184,7 @@ async function retryStorybookImages() {
image_status: detail.image_status, image_status: detail.image_status,
audio_status: detail.audio_status, audio_status: detail.audio_status,
last_error: detail.last_error, last_error: detail.last_error,
retryable_assets: detail.retryable_assets,
}) })
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '插图补全失败' error.value = e instanceof Error ? e.message : '插图补全失败'

View File

@@ -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")

View File

@@ -299,6 +299,7 @@ async def generate_story_image(
"image_status": story.image_status, "image_status": story.image_status,
"audio_status": story.audio_status, "audio_status": story.audio_status,
"last_error": story.last_error, "last_error": story.last_error,
"retryable_assets": story.retryable_assets,
} }

View File

@@ -83,11 +83,88 @@ class Story(Base):
child_profile: Mapped["ChildProfile | None"] = relationship("ChildProfile") child_profile: Mapped["ChildProfile | None"] = relationship("ChildProfile")
story_universe: Mapped["StoryUniverse | None"] = relationship("StoryUniverse") 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: def _uuid() -> str:
return str(uuid4()) 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): class ChildProfile(Base):
"""Child profile entity.""" """Child profile entity."""

View File

@@ -17,6 +17,7 @@ class StoryStatusMixin(BaseModel):
image_status: str image_status: str
audio_status: str audio_status: str
last_error: str | None = None last_error: str | None = None
retryable_assets: list[Literal["image", "audio"]] = Field(default_factory=list)
class GenerateRequest(BaseModel): class GenerateRequest(BaseModel):

View File

@@ -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

View File

@@ -28,6 +28,11 @@ from app.services.audio_storage import (
read_audio_cache, read_audio_cache,
write_story_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.memory_service import build_enhanced_memory_context
from app.services.provider_router import ( from app.services.provider_router import (
generate_image, generate_image,
@@ -141,6 +146,26 @@ def _trigger_story_postprocessing(story: Story) -> None:
extract_story_achievements.delay(story.id, 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( async def _persist_text_story_result(
*, *,
result: StoryOutput, result: StoryOutput,
@@ -629,6 +654,7 @@ async def generate_full_story_service(
image_status=story.image_status, image_status=story.image_status,
audio_status=story.audio_status, audio_status=story.audio_status,
last_error=story.last_error, last_error=story.last_error,
retryable_assets=story.retryable_assets,
) )
@@ -703,6 +729,7 @@ async def generate_storybook_service(
image_status=story.image_status, image_status=story.image_status,
audio_status=story.audio_status, audio_status=story.audio_status,
last_error=story.last_error, last_error=story.last_error,
retryable_assets=story.retryable_assets,
) )
@@ -713,6 +740,51 @@ async def generate_generation_service(
) -> GenerationResponse: ) -> GenerationResponse:
"""Unified generation workflow entry point for stories and storybooks.""" """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": if request.output_mode == "storybook":
storybook = await generate_storybook_service( storybook = await generate_storybook_service(
StorybookRequest( StorybookRequest(
@@ -730,6 +802,14 @@ async def generate_generation_service(
raise HTTPException(status_code=500, detail="Storybook generation did not persist.") raise HTTPException(status_code=500, detail="Storybook generation did not persist.")
saved_story = await get_story_detail(storybook.id, user_id, db) 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( return GenerationResponse(
id=storybook.id, id=storybook.id,
title=storybook.title, title=storybook.title,
@@ -746,6 +826,7 @@ async def generate_generation_service(
last_error=storybook.last_error, last_error=storybook.last_error,
child_profile_id=saved_story.child_profile_id, child_profile_id=saved_story.child_profile_id,
universe_id=saved_story.universe_id, universe_id=saved_story.universe_id,
retryable_assets=saved_story.retryable_assets,
) )
generate_request = GenerateRequest( generate_request = GenerateRequest(
@@ -758,6 +839,15 @@ async def generate_generation_service(
if request.generate_images: if request.generate_images:
story = await generate_full_story_service(generate_request, user_id, db) 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( return GenerationResponse(
id=story.id, id=story.id,
title=story.title, title=story.title,
@@ -774,9 +864,18 @@ async def generate_generation_service(
last_error=story.last_error, last_error=story.last_error,
child_profile_id=story.child_profile_id, child_profile_id=story.child_profile_id,
universe_id=story.universe_id, universe_id=story.universe_id,
retryable_assets=saved_story.retryable_assets,
) )
story = await generate_and_save_story(generate_request, user_id, db) 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( return GenerationResponse(
id=story.id, id=story.id,
title=story.title, title=story.title,
@@ -791,6 +890,7 @@ async def generate_generation_service(
last_error=story.last_error, last_error=story.last_error,
child_profile_id=story.child_profile_id, child_profile_id=story.child_profile_id,
universe_id=story.universe_id, universe_id=story.universe_id,
retryable_assets=story.retryable_assets,
) )
@@ -884,9 +984,28 @@ async def retry_story_assets(
db: AsyncSession, db: AsyncSession,
) -> Story: ) -> Story:
"""Retry selected assets through one workflow-level endpoint.""" """Retry selected assets through one workflow-level endpoint."""
story = await get_story_detail(story_id, user_id, db)
requested_assets = list(dict.fromkeys(assets)) requested_assets = list(dict.fromkeys(assets))
job = await create_generation_job(
db,
user_id=user_id,
output_mode="asset_retry",
input_type=",".join(requested_assets),
request_payload={"story_id": story_id, "assets": requested_assets},
story_id=story_id,
)
story: Story | None = None
try:
story = await get_story_detail(story_id, user_id, db)
await record_generation_event(
db,
job=job,
story_id=story.id,
event_type="asset_retry_started",
status="running",
message="Asset retry started.",
metadata={"assets": requested_assets},
)
if "image" in requested_assets: if "image" in requested_assets:
if story.mode == "storybook": if story.mode == "storybook":
@@ -897,7 +1016,40 @@ async def retry_story_assets(
if "audio" in requested_assets: if "audio" in requested_assets:
await _retry_audio_asset(story, db) await _retry_audio_asset(story, db)
return await get_story_detail(story_id, user_id, 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( async def generate_story_cover(
@@ -906,16 +1058,47 @@ async def generate_story_cover(
db: AsyncSession, db: AsyncSession,
) -> str: ) -> str:
"""Generate cover image for an existing story.""" """Generate cover image for an existing 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="image",
request_payload={"story_id": story_id, "assets": ["image"]},
story_id=story_id,
)
story: Story | None = None
try:
story = await get_story_detail(story_id, user_id, db)
image_result = await _complete_cover_image_asset( image_result = await _complete_cover_image_asset(
story, story,
db, db,
raise_on_failure=True, raise_on_failure=True,
log_event="cover_generation_failed", 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): if image_result.succeeded and isinstance(image_result.value, str):
return image_result.value 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") raise HTTPException(status_code=500, detail="Image generation failed")
@@ -926,11 +1109,42 @@ async def generate_story_audio(
db: AsyncSession, db: AsyncSession,
) -> bytes: ) -> bytes:
"""Generate audio for a story.""" """Generate audio for a story."""
story = await get_story_detail(story_id, user_id, db) job = await create_generation_job(
db,
user_id=user_id,
output_mode="asset_generation",
input_type="audio",
request_payload={"story_id": story_id, "assets": ["audio"]},
story_id=story_id,
)
story: Story | None = None
try:
story = await get_story_detail(story_id, user_id, db)
audio_result = await _complete_audio_asset(story, db, raise_on_failure=True) 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): if audio_result.succeeded and isinstance(audio_result.value, bytes):
return audio_result.value 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") raise HTTPException(status_code=500, detail="Audio generation failed")

View File

@@ -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()

View File

@@ -14,6 +14,8 @@
- Pydantic v2 兼容性 warning 清理 - Pydantic v2 兼容性 warning 清理
- Dockerfile build 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-admin-frontend:dev` 构建通过。
- Docker 后端镜像 `dreamweaver-backend:dev` 构建通过。 - Docker 后端镜像 `dreamweaver-backend:dev` 构建通过。
- `ruff check app tests` 通过。 - `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_AUDIO=1 ./scripts/demo_smoke.sh` 通过。
- smoke 会断言 `retryable_assets` 在故事、音频、绘本补全前后按预期变化。
- 本地用户端可通过 `http://localhost:52080` 访问。 - 本地用户端可通过 `http://localhost:52080` 访问。
- 本地管理端可通过 `http://localhost:52888` 访问。 - 本地管理端可通过 `http://localhost:52888` 访问。
- 技术债扫描未发现 `class Config``TODO``FIXME`、旧 Issue 注释或 Dockerfile `FROM ... as` - 技术债扫描未发现 `class Config``TODO``FIXME`、旧 Issue 注释或 Dockerfile `FROM ... as`
- 后端不再内置 `admin123` 管理密码;非 debug 环境开启管理后台时会拒绝空/弱密码。 - 后端不再内置 `admin123` 管理密码;非 debug 环境开启管理后台时会拒绝空/弱密码。
- 统一生成和资产重试会写入 `generation_jobs``generation_job_events`
- API 响应返回 `retryable_assets`,前端按标准字段展示补全/重试入口。
已确认的演示能力: 已确认的演示能力:

View File

@@ -88,6 +88,7 @@ Week 2 的目标不是做“完整商业产品”,而是做出一个面试时
| W2-13 | Tech Debt | 清理 Pydantic v2 warning、Dockerfile warning 和旧 TODO | 测试无 warningDocker build 无 casing warning | P1 | 0.5d | Done | | W2-13 | Tech Debt | 清理 Pydantic v2 warning、Dockerfile warning 和旧 TODO | 测试无 warningDocker build 无 casing warning | P1 | 0.5d | Done |
| W2-14 | Frontend | 同步管理端生成状态与资产补全体验 | 用户端/管理端状态体验不再分叉 | P1 | 0.5d | Done | | W2-14 | Frontend | 同步管理端生成状态与资产补全体验 | 用户端/管理端状态体验不再分叉 | P1 | 0.5d | Done |
| W2-15 | Security | 移除管理后台弱默认密码 | 非 debug 管理后台拒绝空/弱密码 | 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 |
--- ---

View File

@@ -29,8 +29,12 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
- `audio_status` - `audio_status`
- `last_error` - `last_error`
- `audio_path` - `audio_path`
- 已新增轻量可查询的生成过程记录:
- `generation_jobs`
- `generation_job_events`
- Storybook 阅读器已支持按 ID 恢复,不再只依赖 Pinia 内存态 - Storybook 阅读器已支持按 ID 恢复,不再只依赖 Pinia 内存态
- 故事列表页、故事详情页、绘本阅读页已接入统一状态展示 - 故事列表页、故事详情页、绘本阅读页已接入统一状态展示
- API 响应已统一返回 `retryable_assets`,前端不再各自推断可补全资产
- 故事音频已支持首次生成后缓存复用 - 故事音频已支持首次生成后缓存复用
- `degraded_completed` 已在服务层和前端语义中落地 - `degraded_completed` 已在服务层和前端语义中落地
- 已新增首版统一资产重试入口:`POST /api/stories/{story_id}/assets/retry` - 已新增首版统一资产重试入口:`POST /api/stories/{story_id}/assets/retry`
@@ -50,8 +54,9 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
### Still Missing ### Still Missing
- 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 已开始抽取公共步骤,但旧 service 函数仍作为兼容层保留 - 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 已开始抽取公共步骤,但旧 service 函数仍作为兼容层保留
- 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,并已抽出 asset completion helper 与 `AssetCompletionResult`,但尚未落库为完整 generation job 模型 - 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,并已抽出 asset completion helper 与 `AssetCompletionResult`
- `partial_ready``retryable_assets` 等更细粒度状态仍停留在目标态 - `generation_jobs` 已记录请求、完成、失败和资产重试事件,但尚未扩展到逐 provider 调用、逐页面资产步骤和完整运营分析
- `partial_ready``text_status` 等更细粒度状态仍停留在目标态
### What This Means ### What This Means

View File

@@ -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 ## 什么时候需要落库 job
如果后续进入真实生产化,需要重新评估 `generation_jobs` 如果后续进入真实生产化,需要扩展当前 job/event 模型
- 生成流程改成真正异步,前端需要轮询 job 进度。 - 生成流程改成真正异步,前端需要轮询 job 进度。
- 单个故事会产生多次生成尝试,需要审计每次 provider 调用。 - 单个故事会产生多次生成尝试,需要审计每次 provider 调用。
@@ -29,15 +35,14 @@
- 需要按 provider、成本、延迟和失败原因做运营分析。 - 需要按 provider、成本、延迟和失败原因做运营分析。
- 需要断点续跑,避免 Worker 重启后丢失中间状态。 - 需要断点续跑,避免 Worker 重启后丢失中间状态。
## 推荐未来结构 ## 推荐未来扩展
未来可以新增两层记录 当前已有两层记录,未来可以继续扩展字段和事件颗粒度
- `generation_jobs`: 一次用户发起的生成任务,记录输入、状态、耗时、错误和关联 story - `generation_job_events` 中补 provider、耗时、成本和错误摘要
- `generation_job_events`: 任务事件流记录每一步开始、成功、失败、provider、耗时和错误摘要 - 对绘本逐页插图、TTS、后处理任务记录更细事件
- 为前端提供 job 查询接口,用于真正异步生成时轮询进度。
这会把“用户可见结果”和“系统执行过程”分开,但目前还不是求职版的最高优先级。
## 面试表达 ## 面试表达
现在没有急着加 job 表,是因为 MVP 首要目标是让故事结果稳定可读,并让资产失败可恢复。等生成链路变成真正异步、需要审计和运营指标时,再把执行过程拆到 job/event 表,会比现在提前设计复杂表结构更稳 没有一上来引入复杂工作流引擎,而是先用轻量 job/event 表把关键执行轨迹落下来。这样既能回答“生成过程是否可追踪”,又不会为了求职版 MVP 牺牲主链路稳定性

View File

@@ -21,6 +21,7 @@ export interface Storybook {
image_status?: string image_status?: string
audio_status?: string audio_status?: string
last_error?: string | null last_error?: string | null
retryable_assets?: Array<'image' | 'audio'>
} }
export const useStorybookStore = defineStore('storybook', () => { export const useStorybookStore = defineStore('storybook', () => {

View File

@@ -26,6 +26,7 @@ interface Story {
image_status: string image_status: string
audio_status: string audio_status: string
last_error: string | null last_error: string | null
retryable_assets: Array<'image' | 'audio'>
pages?: Array<{ pages?: Array<{
page_number: number page_number: number
text: string 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 generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status))
const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status)) const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status))
const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status)) const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status))
const canRetryImage = computed(() => const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false)
Boolean(story.value?.cover_prompt) const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false)
&& 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 isAudioGenerating = computed(() => story.value?.audio_status === 'generating') const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
const assetGuidance = computed(() => { const assetGuidance = computed(() => {
if (story.value?.generation_status === 'degraded_completed') { if (story.value?.generation_status === 'degraded_completed') {

View File

@@ -33,6 +33,7 @@ interface StoryDetailResponse {
image_status: string image_status: string
audio_status: string audio_status: string
last_error: string | null last_error: string | null
retryable_assets: Array<'image' | 'audio'>
} }
const route = useRoute() const route = useRoute()
@@ -51,11 +52,7 @@ const isLastPage = computed(() => currentPageIndex.value === totalPages.value -
const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status)) const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status))
const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status)) const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status))
const audioMeta = computed(() => getAssetStatusMeta(storybook.value?.audio_status)) const audioMeta = computed(() => getAssetStatusMeta(storybook.value?.audio_status))
const canRetryImages = computed(() => const canRetryImages = computed(() => storybook.value?.retryable_assets?.includes('image') ?? false)
Boolean(storybook.value?.id)
&& storybook.value?.image_status !== 'ready'
&& storybook.value?.image_status !== 'generating',
)
const currentPage = computed(() => { const currentPage = computed(() => {
if (!storybook.value || isCover.value) return null if (!storybook.value || isCover.value) return null
return storybook.value.pages[currentPageIndex.value] return storybook.value.pages[currentPageIndex.value]
@@ -151,6 +148,7 @@ async function loadStorybook() {
image_status: detail.image_status, image_status: detail.image_status,
audio_status: detail.audio_status, audio_status: detail.audio_status,
last_error: detail.last_error, last_error: detail.last_error,
retryable_assets: detail.retryable_assets,
}) })
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '绘本加载失败' error.value = e instanceof Error ? e.message : '绘本加载失败'
@@ -186,6 +184,7 @@ async function retryStorybookImages() {
image_status: detail.image_status, image_status: detail.image_status,
audio_status: detail.audio_status, audio_status: detail.audio_status,
last_error: detail.last_error, last_error: detail.last_error,
retryable_assets: detail.retryable_assets,
}) })
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '插图补全失败' error.value = e instanceof Error ? e.message : '插图补全失败'

View File

@@ -72,23 +72,26 @@ story_json="$(post_json "$APP_URL/api/generations" '{
}')" }')"
story_id="$(jq -r '.id' <<<"$story_json")" story_id="$(jq -r '.id' <<<"$story_json")"
assert_jq "$story_json" '.mode == "generated" and .generation_status == "narrative_ready"' "story should be readable before assets" 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" say "Retrying story cover image"
story_image_json="$(post_json "$APP_URL/api/generations/$story_id/retry-assets" '{"assets":["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" 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 if [[ "$SMOKE_AUDIO" == "1" ]]; then
say "Retrying story audio" say "Retrying story audio"
story_audio_json="$(post_json "$APP_URL/api/generations/$story_id/retry-assets" '{"assets":["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" '.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")" 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 if [[ "$audio_probe" != 200\ audio/mpeg* ]]; then
echo "Unexpected audio response: $audio_probe" >&2 echo "Unexpected audio response: $audio_probe" >&2
exit 1 exit 1
fi 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 else
say "Skipping audio smoke; set SMOKE_AUDIO=1 to include TTS" say "Skipping audio smoke; set SMOKE_AUDIO=1 to include TTS"
fi fi
@@ -104,17 +107,19 @@ storybook_json="$(post_json "$APP_URL/api/generations" '{
}')" }')"
storybook_id="$(jq -r '.id' <<<"$storybook_json")" 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" 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" say "Retrying storybook images"
storybook_image_json="$(post_json "$APP_URL/api/generations/$storybook_id/retry-assets" '{"assets":["image"]}')" 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" 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" say "Checking story list"
list_json="$(get_json "$APP_URL/api/stories?limit=5")" 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($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" 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" say "DreamWeaver demo smoke passed"