feat: track generation jobs
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 : '插图补全失败'
|
||||
|
||||
77
backend/alembic/versions/0011_add_generation_jobs.py
Normal file
77
backend/alembic/versions/0011_add_generation_jobs.py
Normal 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")
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
133
backend/app/services/generation_jobs.py
Normal file
133
backend/app/services/generation_jobs.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
|
||||
128
backend/tests/test_generation_jobs.py
Normal file
128
backend/tests/test_generation_jobs.py
Normal 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()
|
||||
@@ -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`,前端按标准字段展示补全/重试入口。
|
||||
|
||||
已确认的演示能力:
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 牺牲主链路稳定性。
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 : '插图补全失败'
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user