feat: move unified generation to background worker

This commit is contained in:
2026-04-19 17:29:37 +08:00
parent 5318de670f
commit 6fb128955f
15 changed files with 632 additions and 285 deletions

View File

@@ -67,7 +67,7 @@ docker compose down
docker compose down -v docker compose down -v
``` ```
`scripts/demo_smoke.sh` 会检查健康状态、本地登录、统一生成、资产重试、故事列表和 Provider 能力分层。默认跳过 TTS演示前需要验证语音链路时使用 `SMOKE_AUDIO=1` `scripts/demo_smoke.sh` 会检查健康状态、本地登录、统一生成后台任务、主记录落库、资产重试、故事列表和 Provider 能力分层。默认跳过 TTS演示前需要验证语音链路时使用 `SMOKE_AUDIO=1`
## 手动开发 ## 手动开发
@@ -128,7 +128,7 @@ npm run build
| GET | `/auth/github/signin` | GitHub OAuth 登录 | | GET | `/auth/github/signin` | GitHub OAuth 登录 |
| GET | `/auth/google/signin` | Google OAuth 登录 | | GET | `/auth/google/signin` | Google OAuth 登录 |
| GET | `/auth/session` | 当前会话 | | GET | `/auth/session` | 当前会话 |
| POST | `/api/generations` | 统一生成故事或绘本 | | POST | `/api/generations` | 提交后台生成任务,立即返回 `generation_job_id` |
| GET | `/api/generations/{story_id}` | 统一读取生成结果 | | GET | `/api/generations/{story_id}` | 统一读取生成结果 |
| POST | `/api/generations/{story_id}/retry-assets` | 统一重试封面/语音资源 | | POST | `/api/generations/{story_id}/retry-assets` | 统一重试封面/语音资源 |
| GET | `/api/generations/jobs/{job_id}` | 查询生成任务事件流 | | GET | `/api/generations/jobs/{job_id}` | 查询生成任务事件流 |
@@ -165,4 +165,4 @@ npm run build
## 当前取舍 ## 当前取舍
仓库只保留一个 Docker Compose 入口:`docker-compose.yml`。生产部署、HA 演练、旧 Claude 原型和历史归档已从主仓库移除,避免干扰当前求职演示主线。音频缓存默认按 `STORY_AUDIO_CACHE_TTL_DAYS=30` 做后台清理Celery beat 会每日执行一次 prune生成任务默认按 `GENERATION_JOB_STALE_MINUTES=60` 判定卡住,后台会定时自动收敛为失败态,避免故事长期显示“永远在跑”。 仓库只保留一个 Docker Compose 入口:`docker-compose.yml`。生产部署、HA 演练、旧 Claude 原型和历史归档已从主仓库移除,避免干扰当前求职演示主线。统一生成接口现在会先创建后台任务,再由 Celery worker 负责真正的故事/绘本生成;因此本地开发或 Docker 演示时需要保证 `worker` 服务可用。音频缓存默认按 `STORY_AUDIO_CACHE_TTL_DAYS=30` 做后台清理Celery beat 会每日执行一次 prune生成任务默认按 `GENERATION_JOB_STALE_MINUTES=60` 判定卡住,后台会定时自动收敛为失败态,避免故事长期显示“永远在跑”。

View File

@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { Component } from 'vue' import type { Component } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useUserStore } from '../stores/user' import { useUserStore } from '../stores/user'
import { useStorybookStore } from '../stores/storybook' import { api } from '../api/client'
import { api } from '../api/client'
import BaseButton from './ui/BaseButton.vue' import BaseButton from './ui/BaseButton.vue'
import BaseInput from './ui/BaseInput.vue' import BaseInput from './ui/BaseInput.vue'
import BaseSelect from './ui/BaseSelect.vue' import BaseSelect from './ui/BaseSelect.vue'
@@ -34,10 +33,9 @@ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const storybookStore = useStorybookStore()
// State // State
const inputType = ref<'keywords' | 'full_story'>('keywords') const inputType = ref<'keywords' | 'full_story'>('keywords')
@@ -88,28 +86,63 @@ const generationTitle = computed(() =>
const generationSteps = computed(() => { const generationSteps = computed(() => {
if (requestedOutputMode.value === 'storybook') { if (requestedOutputMode.value === 'storybook') {
return [ return [
'正在整理主题和成长目标...', '正在提交后台任务...',
'生成绘本分镜和每页文字...', 'Worker 会生成绘本分镜和每页文字...',
'保存绘本主记录,确保刷新也能找回...', '主记录一落库就能通过 ID 找回...',
'补全封面和分页插图...', '插图会继续在后台补全...',
'马上进入可翻页阅读模式。', '稍后自动进入可翻页阅读模式。',
] ]
} }
return [ return [
'正在整理孩子档案和故事主题...', '正在提交后台任务...',
'生成可先阅读的故事正文...', 'Worker 会生成故事正文并保存主记录...',
'保存故事主记录,避免结果丢失...', '主内容一可读就会自动跳转详情页...',
'补全封面图,失败也可稍后重试...', '封面会继续在后台补全,失败也重试...',
'马上进入故事详情页。', '马上进入故事详情页。',
] ]
}) })
interface GenerationAcceptedResponse {
id: number | null
generation_job_id: string | null
}
interface GenerationJobDetail {
story_id: number | null
is_terminal: boolean
error_message: string | null
}
const JOB_POLL_INTERVAL_MS = 1500
const JOB_POLL_MAX_ATTEMPTS = 80
// Methods // Methods
function close() { function close() {
emit('update:modelValue', false) emit('update:modelValue', false)
error.value = '' error.value = ''
} }
function sleep(ms: number) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms)
})
}
async function waitForStoryId(jobId: string) {
for (let attempt = 0; attempt < JOB_POLL_MAX_ATTEMPTS; attempt += 1) {
const detail = await api.get<GenerationJobDetail>(`/api/generations/jobs/${jobId}`)
if (detail.story_id) {
return detail.story_id
}
if (detail.is_terminal) {
throw new Error(detail.error_message || '生成失败,请稍后重试')
}
await sleep(JOB_POLL_INTERVAL_MS)
}
throw new Error('任务已提交,但主内容落库超时,请稍后到故事库查看最新结果')
}
async function fetchProfiles() { async function fetchProfiles() {
if (!userStore.user) return if (!userStore.user) return
@@ -173,25 +206,22 @@ async function generateStory() {
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value
if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value
if (requestedOutputMode.value === 'storybook') { const accepted = await api.post<GenerationAcceptedResponse>('/api/generations', payload)
const response = await api.post<any>('/api/generations', payload) const jobId = accepted.generation_job_id
if (!jobId) {
throw new Error('生成任务已创建,但缺少任务编号')
}
storybookStore.setStorybook(response) const storyId = accepted.id ?? await waitForStoryId(jobId)
close() close()
const storybookPath = response.id ? `/storybook/view/${response.id}` : '/storybook/view' if (requestedOutputMode.value === 'storybook') {
router.push(storybookPath) router.push(`/storybook/view/${storyId}`)
} else { } else {
const result = await api.post<any>('/api/generations', payload) router.push(`/story/${storyId}`)
const query: Record<string, string> = {} }
if (result.errors && Object.keys(result.errors).length > 0) { } catch (e) {
if (result.errors.image) query.imageError = '1' error.value = e instanceof Error ? e.message : '生成失败'
} } finally {
close()
router.push({ path: `/story/${result.id}`, query })
}
} catch (e) {
error.value = e instanceof Error ? e.message : '生成失败'
} finally {
loading.value = false loading.value = false
} }
} }

View File

@@ -116,6 +116,7 @@ function statusLabel(status?: string) {
function eventLabel(eventType: string) { function eventLabel(eventType: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
request_accepted: '请求接收', request_accepted: '请求接收',
worker_started: '后台任务开始',
context_prepared: '上下文准备', context_prepared: '上下文准备',
narrative_generated: '正文生成', narrative_generated: '正文生成',
story_saved: '故事保存', story_saved: '故事保存',

View File

@@ -67,6 +67,7 @@ const audioDuration = ref(0)
const error = ref('') const error = ref('')
const showDeleteConfirm = ref(false) const showDeleteConfirm = ref(false)
const generationTraceRef = ref<InstanceType<typeof GenerationTrace> | null>(null) const generationTraceRef = ref<InstanceType<typeof GenerationTrace> | null>(null)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ?? []) 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))
@@ -75,6 +76,7 @@ const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status))
const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false) const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false)
const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false) const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false)
const isAudioGenerating = computed(() => story.value?.audio_status === 'generating') const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
const shouldAutoRefreshStory = computed(() => story.value?.generation_status === 'assets_generating')
const audioCacheLabel = computed(() => { const audioCacheLabel = computed(() => {
if (!audioCacheStatus.value?.cache_exists) return '暂无缓存' if (!audioCacheStatus.value?.cache_exists) return '暂无缓存'
const size = audioCacheStatus.value.cache_size_bytes ?? 0 const size = audioCacheStatus.value.cache_size_bytes ?? 0
@@ -109,6 +111,13 @@ async function refreshStorySnapshot() {
await refreshAudioStatus().catch(() => undefined) await refreshAudioStatus().catch(() => undefined)
} }
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
async function fetchStory() { async function fetchStory() {
loading.value = true loading.value = true
error.value = '' error.value = ''
@@ -283,7 +292,19 @@ watch(
{ immediate: true }, { immediate: true },
) )
watch(shouldAutoRefreshStory, (enabled) => {
stopAutoRefresh()
if (enabled) {
refreshTimer = setInterval(() => {
if (!loading.value && !imageLoading.value && !audioLoading.value) {
void refreshStorySnapshot().catch(() => undefined)
}
}, 2500)
}
})
onUnmounted(() => { onUnmounted(() => {
stopAutoRefresh()
if (audioUrl.value) { if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value) URL.revokeObjectURL(audioUrl.value)
} }

View File

@@ -67,13 +67,13 @@ def _mark_legacy_generation_endpoint(response: Response, successor: str) -> None
response.headers.update(_legacy_generation_headers(successor)) response.headers.update(_legacy_generation_headers(successor))
@router.post("/generations", response_model=GenerationResponse) @router.post("/generations", response_model=GenerationResponse, status_code=202)
async def create_generation( async def create_generation(
request: GenerationRequest, request: GenerationRequest,
user: User = Depends(require_user), user: User = Depends(require_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Create a story or storybook through the unified generation workflow.""" """Accept one story/storybook generation request for background execution."""
await check_rate_limit(f"story:{user.id}", RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW) await check_rate_limit(f"story:{user.id}", RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW)
return await story_service.generate_generation_service(request, user.id, db) return await story_service.generate_generation_service(request, user.id, db)

View File

@@ -117,9 +117,9 @@ class StorybookResponse(StoryStatusMixin):
class GenerationResponse(StoryStatusMixin): class GenerationResponse(StoryStatusMixin):
"""Unified generation response for the target workflow API.""" """Unified generation response for the target workflow API."""
id: int id: int | None = None
generation_job_id: str | None = None generation_job_id: str | None = None
title: str title: str | None = None
mode: str mode: str
story_text: str | None = None story_text: str | None = None
pages: list[StorybookPageResponse] | None = None pages: list[StorybookPageResponse] | None = None

View File

@@ -6,7 +6,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import desc, select from sqlalchemy import desc, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
@@ -59,6 +59,7 @@ def _job_progress(job: GenerationJob) -> dict[str, Any]:
progress_map: dict[str, tuple[int, str]] = { progress_map: dict[str, tuple[int, str]] = {
"request_accepted": (5, "已接收请求"), "request_accepted": (5, "已接收请求"),
"worker_started": (12, "后台任务已开始"),
"context_prepared": (20, "上下文已准备"), "context_prepared": (20, "上下文已准备"),
"narrative_generated": (45, "正文已生成"), "narrative_generated": (45, "正文已生成"),
"story_saved": (60, "主记录已保存"), "story_saved": (60, "主记录已保存"),
@@ -66,8 +67,18 @@ def _job_progress(job: GenerationJob) -> dict[str, Any]:
"provider_call_succeeded": (72, "Provider 调用成功"), "provider_call_succeeded": (72, "Provider 调用成功"),
"provider_call_failed": (72, "Provider 调用失败,尝试恢复"), "provider_call_failed": (72, "Provider 调用失败,尝试恢复"),
"cover_image_started": (75, "封面生成中"), "cover_image_started": (75, "封面生成中"),
"cover_image_succeeded": (88, "封面已生成"),
"cover_image_failed": (88, "封面生成失败"),
"storybook_images_started": (75, "绘本插图生成中"), "storybook_images_started": (75, "绘本插图生成中"),
"storybook_cover_image_succeeded": (82, "绘本封面已生成"),
"storybook_cover_image_failed": (82, "绘本封面生成失败"),
"storybook_page_image_succeeded": (86, "分页插图已生成"),
"storybook_page_image_failed": (86, "分页插图生成失败"),
"storybook_images_completed": (92, "绘本插图已完成"),
"audio_started": (75, "音频生成中"), "audio_started": (75, "音频生成中"),
"audio_cache_hit": (88, "音频缓存已复用"),
"audio_succeeded": (88, "音频已生成"),
"audio_failed": (88, "音频生成失败"),
"asset_retry_started": (25, "资源重试中"), "asset_retry_started": (25, "资源重试中"),
"postprocessing_queued": (90, "后处理已排队"), "postprocessing_queued": (90, "后处理已排队"),
"asset_generation_completed": (100, "资源已完成"), "asset_generation_completed": (100, "资源已完成"),
@@ -155,6 +166,10 @@ async def record_generation_event(
) -> GenerationJobEvent: ) -> GenerationJobEvent:
"""Append one event to an existing generation job.""" """Append one event to an existing generation job."""
job.current_step = event_type
if story_id is not None:
job.story_id = story_id
event = GenerationJobEvent( event = GenerationJobEvent(
job_id=job.id, job_id=job.id,
story_id=story_id if story_id is not None else job.story_id, story_id=story_id if story_id is not None else job.story_id,
@@ -169,6 +184,42 @@ async def record_generation_event(
return event return event
async def claim_generation_job_for_worker(
db: AsyncSession,
*,
job_id: str,
) -> GenerationJob | None:
"""Claim one queued generation job for worker execution once."""
claim_result = await db.execute(
update(GenerationJob)
.where(
GenerationJob.id == job_id,
GenerationJob.status == "running",
GenerationJob.current_step == "request_accepted",
)
.values(current_step="worker_started")
)
await db.commit()
if not claim_result.rowcount:
return None
result = await db.execute(select(GenerationJob).where(GenerationJob.id == job_id))
job = result.scalar_one_or_none()
if job is None:
return None
await record_generation_event(
db,
job=job,
event_type="worker_started",
status="running",
message="Generation worker started processing the accepted request.",
)
return job
async def finish_generation_job( async def finish_generation_job(
db: AsyncSession, db: AsyncSession,
*, *,

View File

@@ -12,7 +12,7 @@ from sqlalchemy.orm import joinedload
from app.core.config import settings from app.core.config import settings
from app.core.logging import get_logger from app.core.logging import get_logger
from app.db.models import ChildProfile, Story, StoryUniverse from app.db.models import ChildProfile, GenerationJob, Story, StoryUniverse
from app.schemas.story_schemas import ( from app.schemas.story_schemas import (
AchievementItem, AchievementItem,
FullStoryResponse, FullStoryResponse,
@@ -33,6 +33,7 @@ from app.services.audio_storage import (
write_story_audio_cache, write_story_audio_cache,
) )
from app.services.generation_jobs import ( from app.services.generation_jobs import (
claim_generation_job_for_worker,
create_generation_job, create_generation_job,
ensure_no_active_story_generation_job, ensure_no_active_story_generation_job,
finish_generation_job, finish_generation_job,
@@ -1113,7 +1114,7 @@ async def generate_generation_service(
user_id: str, user_id: str,
db: AsyncSession, db: AsyncSession,
) -> GenerationResponse: ) -> GenerationResponse:
"""Unified generation workflow entry point for stories and storybooks.""" """Queue one unified generation workflow for background execution."""
job = await create_generation_job( job = await create_generation_job(
db, db,
@@ -1124,7 +1125,65 @@ async def generate_generation_service(
) )
try: try:
response = await _generate_generation_service_with_job(request, user_id, db, job=job) from app.tasks.generation_workflow import run_generation_workflow_task
run_generation_workflow_task.delay(job.id)
except Exception as exc:
await finish_generation_job(
db,
job=job,
story=None,
status="failed",
current_step="generation_failed",
error_message="Background generation dispatch failed.",
message="Generation failed before the worker could start processing the job.",
metadata={"dispatch_error": str(exc)},
)
raise HTTPException(
status_code=503,
detail="后台生成任务派发失败,请确认 worker 可用后重试。",
) from exc
return _build_queued_generation_response(request, job_id=job.id)
def _build_queued_generation_response(
request: GenerationRequest,
*,
job_id: str,
) -> GenerationResponse:
"""Build the immediate API response after a generation job is accepted."""
return GenerationResponse(
id=None,
generation_job_id=job_id,
title="生成任务已提交",
mode="storybook" if request.output_mode == "storybook" else "generated",
generation_status="queued",
text_status="generating",
image_status="not_requested",
audio_status="not_requested",
last_error=None,
retryable_assets=[],
child_profile_id=request.child_profile_id,
universe_id=request.universe_id,
)
async def execute_generation_job_service(
job: GenerationJob,
db: AsyncSession,
) -> GenerationResponse:
"""Execute one previously accepted generation job inside the worker."""
try:
request = GenerationRequest.model_validate(job.request_payload or {})
response = await _generate_generation_service_with_job(
request,
job.user_id,
db,
job=job,
)
except HTTPException as exc: except HTTPException as exc:
await finish_generation_job( await finish_generation_job(
db, db,
@@ -1151,6 +1210,21 @@ async def generate_generation_service(
return response return response
async def run_generation_job_service(
job_id: str,
db: AsyncSession,
) -> GenerationJob | None:
"""Claim and execute one generation job from the background queue."""
job = await claim_generation_job_for_worker(db, job_id=job_id)
if job is None:
logger.info("generation_job_execution_skipped", job_id=job_id)
return None
await execute_generation_job_service(job, db)
return job
async def _generate_generation_service_with_job( async def _generate_generation_service_with_job(
request: GenerationRequest, request: GenerationRequest,
user_id: str, user_id: str,

View File

@@ -0,0 +1,38 @@
"""Background execution for unified generation workflows."""
import asyncio
from app.core.celery_app import celery_app
from app.core.logging import get_logger
from app.db.database import _get_session_factory
from app.services.story_service import run_generation_job_service
logger = get_logger(__name__)
@celery_app.task
def run_generation_workflow_task(job_id: str):
"""Execute one accepted generation job in the Celery worker."""
logger.info("generation_workflow_task_started", job_id=job_id)
async def _run():
session_factory = _get_session_factory()
async with session_factory() as session:
return await run_generation_job_service(job_id, session)
try:
result = asyncio.run(_run())
logger.info(
"generation_workflow_task_completed",
job_id=job_id,
executed=bool(result),
)
return {"job_id": job_id, "executed": bool(result)}
except Exception as exc:
logger.error(
"generation_workflow_task_failed",
job_id=job_id,
error=str(exc),
)
raise

View File

@@ -18,6 +18,7 @@ from app.services.generation_jobs import (
mark_stale_generation_jobs, mark_stale_generation_jobs,
record_generation_event, record_generation_event,
) )
from app.services.story_service import run_generation_job_service
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@@ -45,7 +46,7 @@ def build_storybook_output() -> Storybook:
) )
async def test_unified_generation_records_job_events_and_retryable_assets( async def test_unified_generation_is_queued_then_worker_persists_story_and_events(
db_session, db_session,
test_user, test_user,
auth_token, auth_token,
@@ -56,82 +57,108 @@ async def test_unified_generation_records_job_events_and_retryable_assets(
app.dependency_overrides[get_db] = override_get_db app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
task_delay_path = (
"app.tasks.generation_workflow.run_generation_workflow_task.delay"
)
try: try:
async with AsyncClient(transport=transport, base_url="http://test") as client: with patch(task_delay_path) as mock_delay:
client.cookies.set("access_token", auth_token) async with AsyncClient(transport=transport, base_url="http://test") as client:
client.cookies.set("access_token", auth_token)
response = await client.post( response = await client.post(
"/api/generations", "/api/generations",
json={ json={
"output_mode": "story", "output_mode": "story",
"type": "keywords", "type": "keywords",
"data": "小兔子, 森林", "data": "小兔子, 森林",
"generate_images": False, "generate_images": False,
}, },
)
assert response.status_code == 200
data = response.json()
assert data["generation_status"] == "partial_ready"
assert data["retryable_assets"] == ["image", "audio"]
assert data["generation_job_id"]
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"]
assert data["generation_job_id"] == job.id
events = ( assert response.status_code == 202
await db_session.execute( data = response.json()
select(GenerationJobEvent) assert data["id"] is None
.where(GenerationJobEvent.job_id == job.id) assert data["generation_status"] == "queued"
.order_by(GenerationJobEvent.id) assert data["text_status"] == "generating"
) assert data["retryable_assets"] == []
).scalars().all() assert data["generation_job_id"]
assert [event.event_type for event in events] == [ mock_delay.assert_called_once_with(data["generation_job_id"])
"request_accepted",
"context_prepared",
"narrative_generated",
"story_saved",
"generation_completed",
]
assert events[1].event_metadata["has_memory_context"] is False
assert events[2].event_metadata["title"] == "小兔子的冒险"
assert events[3].story_id == data["id"]
detail_response = await client.get(f"/api/generations/jobs/{job.id}") jobs = (
assert detail_response.status_code == 200 await db_session.execute(
detail = detail_response.json() select(GenerationJob).where(GenerationJob.user_id == test_user.id)
assert detail["id"] == job.id )
assert detail["story_id"] == data["id"] ).scalars().all()
assert detail["progress_percent"] == 100 assert len(jobs) == 1
assert detail["progress_label"] == "已完成" job = jobs[0]
assert detail["is_terminal"] is True assert job.story_id is None
assert [event["event_type"] for event in detail["events"]] == [ assert job.output_mode == "story"
"request_accepted", assert job.input_type == "keywords"
"context_prepared", assert job.status == "running"
"narrative_generated", assert job.current_step == "request_accepted"
"story_saved", assert data["generation_job_id"] == job.id
"generation_completed",
]
list_response = await client.get(f"/api/generations/{data['id']}/jobs") await run_generation_job_service(job.id, db_session)
assert list_response.status_code == 200
job_list = list_response.json() job = (
assert [item["id"] for item in job_list] == [job.id] await db_session.execute(
assert job_list[0]["progress_percent"] == 100 select(GenerationJob).where(GenerationJob.id == job.id)
assert job_list[0]["is_terminal"] is True )
).scalar_one()
assert job.story_id is not None
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",
"worker_started",
"context_prepared",
"narrative_generated",
"story_saved",
"generation_completed",
]
assert events[2].event_metadata["has_memory_context"] is False
assert events[3].event_metadata["title"] == "小兔子的冒险"
assert events[4].story_id == job.story_id
detail_response = await client.get(f"/api/generations/jobs/{job.id}")
assert detail_response.status_code == 200
detail = detail_response.json()
assert detail["id"] == job.id
assert detail["story_id"] == job.story_id
assert detail["progress_percent"] == 100
assert detail["progress_label"] == "已完成"
assert detail["is_terminal"] is True
assert [event["event_type"] for event in detail["events"]] == [
"request_accepted",
"worker_started",
"context_prepared",
"narrative_generated",
"story_saved",
"generation_completed",
]
story_response = await client.get(f"/api/generations/{job.story_id}")
assert story_response.status_code == 200
story_data = story_response.json()
assert story_data["generation_status"] == "partial_ready"
assert story_data["retryable_assets"] == ["image", "audio"]
list_response = await client.get(f"/api/generations/{job.story_id}/jobs")
assert list_response.status_code == 200
job_list = list_response.json()
assert [item["id"] for item in job_list] == [job.id]
assert job_list[0]["progress_percent"] == 100
assert job_list[0]["is_terminal"] is True
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -196,7 +223,7 @@ async def test_asset_retry_records_job_events_and_updates_retryable_assets(
app.dependency_overrides.clear() app.dependency_overrides.clear()
async def test_storybook_generation_records_page_image_events( async def test_storybook_generation_is_queued_then_worker_records_page_image_events(
db_session, db_session,
auth_token, auth_token,
): ):
@@ -222,61 +249,78 @@ async def test_storybook_generation_records_page_image_events(
"https://example.com/storybook-page-2.png", "https://example.com/storybook-page-2.png",
] ]
async with AsyncClient(transport=transport, base_url="http://test") as client: task_delay_path = (
client.cookies.set("access_token", auth_token) "app.tasks.generation_workflow.run_generation_workflow_task.delay"
response = await client.post(
"/api/generations",
json={
"output_mode": "storybook",
"type": "keywords",
"data": "森林, 发光, 友情",
"page_count": 6,
"generate_images": True,
},
)
assert response.status_code == 200
data = response.json()
assert data["mode"] == "storybook"
assert data["image_status"] == "ready"
job = (
await db_session.execute(
select(GenerationJob).where(
GenerationJob.story_id == data["id"],
GenerationJob.output_mode == "storybook",
) )
) with patch(task_delay_path) as mock_delay:
).scalar_one() async with AsyncClient(transport=transport, base_url="http://test") as client:
client.cookies.set("access_token", auth_token)
events = ( response = await client.post(
await db_session.execute( "/api/generations",
select(GenerationJobEvent) json={
.where(GenerationJobEvent.job_id == job.id) "output_mode": "storybook",
.order_by(GenerationJobEvent.id) "type": "keywords",
) "data": "森林, 发光, 友情",
).scalars().all() "page_count": 6,
"generate_images": True,
},
)
assert [event.event_type for event in events] == [ assert response.status_code == 202
"request_accepted", data = response.json()
"context_prepared", assert data["id"] is None
"narrative_generated", assert data["mode"] == "storybook"
"storybook_images_started", assert data["generation_status"] == "queued"
"storybook_cover_image_succeeded", assert data["text_status"] == "generating"
"storybook_page_image_succeeded", mock_delay.assert_called_once_with(data["generation_job_id"])
"storybook_page_image_succeeded", job = (
"storybook_images_completed", await db_session.execute(
"story_saved", select(GenerationJob).where(
"generation_completed", GenerationJob.id == data["generation_job_id"],
] )
page_events = [ )
event ).scalar_one()
for event in events await run_generation_job_service(job.id, db_session)
if event.event_type == "storybook_page_image_succeeded"
] job = (
assert [event.event_metadata["page_number"] for event in page_events] == [1, 2] await db_session.execute(
assert events[7].event_metadata["completed_pages"] == [1, 2] select(GenerationJob).where(
GenerationJob.id == job.id,
)
)
).scalar_one()
assert job.story_id is not None
assert job.status == "completed"
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",
"worker_started",
"context_prepared",
"narrative_generated",
"storybook_images_started",
"storybook_cover_image_succeeded",
"storybook_page_image_succeeded",
"storybook_page_image_succeeded",
"storybook_images_completed",
"story_saved",
"generation_completed",
]
page_events = [
event
for event in events
if event.event_type == "storybook_page_image_succeeded"
]
assert [event.event_metadata["page_number"] for event in page_events] == [1, 2]
assert events[8].event_metadata["completed_pages"] == [1, 2]
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()

View File

@@ -430,6 +430,8 @@ class TestGenerateFull:
class TestUnifiedGenerations: class TestUnifiedGenerations:
"""Tests for the target unified generation API.""" """Tests for the target unified generation API."""
TASK_DELAY_PATH = "app.tasks.generation_workflow.run_generation_workflow_task.delay"
def test_create_generation_without_auth(self, client: TestClient): def test_create_generation_without_auth(self, client: TestClient):
response = client.post( response = client.post(
"/api/generations", "/api/generations",
@@ -443,60 +445,64 @@ class TestUnifiedGenerations:
mock_text_provider, mock_text_provider,
mock_image_provider, mock_image_provider,
): ):
response = auth_client.post( with patch(self.TASK_DELAY_PATH) as mock_delay:
"/api/generations", response = auth_client.post(
json={ "/api/generations",
"output_mode": "story", json={
"type": "keywords", "output_mode": "story",
"data": "小兔子, 森林, 勇气", "type": "keywords",
"generate_images": True, "data": "小兔子, 森林, 勇气",
}, "generate_images": True,
) },
)
assert response.status_code == 200 assert response.status_code == 202
assert "Deprecation" not in response.headers assert "Deprecation" not in response.headers
data = response.json() data = response.json()
assert data["id"] is not None assert data["id"] is None
assert data["mode"] == "generated" assert data["mode"] == "generated"
assert data["story_text"] == "从前有一只小兔子。" assert data["story_text"] is None
assert data["image_url"] == "https://example.com/image.png" assert data["image_url"] is None
assert data["cover_url"] == "https://example.com/image.png" assert data["cover_url"] is None
assert data["pages"] is None assert data["pages"] is None
assert data["generation_status"] == "partial_ready" assert data["generation_status"] == "queued"
assert data["image_status"] == "ready" assert data["text_status"] == "generating"
assert data["image_status"] == "not_requested"
assert data["audio_status"] == "not_requested" assert data["audio_status"] == "not_requested"
assert data["errors"] == {} assert data["errors"] == {}
mock_delay.assert_called_once_with(data["generation_job_id"])
def test_create_story_generation_without_assets( def test_create_story_generation_without_assets(
self, self,
auth_client: TestClient, auth_client: TestClient,
mock_text_provider, mock_text_provider,
): ):
response = auth_client.post( with patch(self.TASK_DELAY_PATH) as mock_delay:
"/api/generations", response = auth_client.post(
json={ "/api/generations",
"output_mode": "story", json={
"type": "keywords", "output_mode": "story",
"data": "小兔子, 森林", "type": "keywords",
"generate_images": False, "data": "小兔子, 森林",
}, "generate_images": False,
) },
)
assert response.status_code == 200 assert response.status_code == 202
data = response.json() data = response.json()
assert data["mode"] == "generated" assert data["mode"] == "generated"
assert data["image_url"] is None assert data["image_url"] is None
assert data["generation_status"] == "partial_ready" assert data["generation_status"] == "queued"
assert data["text_status"] == "generating"
assert data["image_status"] == "not_requested" assert data["image_status"] == "not_requested"
mock_delay.assert_called_once_with(data["generation_job_id"])
def test_create_story_generation_image_failure( def test_create_story_generation_image_failure(
self, self,
auth_client: TestClient, auth_client: TestClient,
mock_text_provider, mock_text_provider,
): ):
with patch("app.services.story_service.generate_image", new_callable=AsyncMock) as mock_img: with patch(self.TASK_DELAY_PATH) as mock_delay:
mock_img.side_effect = Exception("Image API error")
response = auth_client.post( response = auth_client.post(
"/api/generations", "/api/generations",
json={ json={
@@ -507,55 +513,45 @@ class TestUnifiedGenerations:
}, },
) )
assert response.status_code == 200 assert response.status_code == 202
data = response.json() data = response.json()
assert data["image_url"] is None assert data["image_url"] is None
assert data["generation_status"] == "degraded_completed" assert data["generation_status"] == "queued"
assert data["image_status"] == "failed" assert data["text_status"] == "generating"
assert data["image_status"] == "not_requested"
assert data["audio_status"] == "not_requested" assert data["audio_status"] == "not_requested"
assert "Image API error" in data["errors"]["image"] assert data["errors"] == {}
assert "Image API error" in data["last_error"] assert data["last_error"] is None
mock_delay.assert_called_once_with(data["generation_job_id"])
def test_create_storybook_generation_success(self, auth_client: TestClient): def test_create_storybook_generation_success(self, auth_client: TestClient):
with patch( with patch(self.TASK_DELAY_PATH) as mock_delay:
"app.services.story_service.generate_storybook", response = auth_client.post(
new_callable=AsyncMock, "/api/generations",
) as mock_storybook: json={
with patch( "output_mode": "storybook",
"app.services.story_service.generate_image", "type": "keywords",
new_callable=AsyncMock, "data": "森林, 发光, 友情",
) as mock_image: "page_count": 6,
mock_storybook.return_value = build_storybook_output() "generate_images": True,
mock_image.side_effect = [ },
"https://example.com/storybook-cover.png", )
"https://example.com/storybook-page-1.png",
"https://example.com/storybook-page-2.png",
]
response = auth_client.post( assert response.status_code == 202
"/api/generations",
json={
"output_mode": "storybook",
"type": "keywords",
"data": "森林, 发光, 友情",
"page_count": 6,
"generate_images": True,
},
)
assert response.status_code == 200
data = response.json() data = response.json()
assert data["id"] is not None assert data["id"] is None
assert data["mode"] == "storybook" assert data["mode"] == "storybook"
assert data["story_text"] is None assert data["story_text"] is None
assert len(data["pages"]) == 2 assert data["pages"] is None
assert data["cover_url"] == "https://example.com/storybook-cover.png" assert data["cover_url"] is None
assert data["image_url"] == "https://example.com/storybook-cover.png" assert data["image_url"] is None
assert data["main_character"] == "小兔子露露" assert data["main_character"] is None
assert data["art_style"] == "温暖水彩" assert data["art_style"] is None
assert data["generation_status"] == "completed" assert data["generation_status"] == "queued"
assert data["image_status"] == "ready" assert data["text_status"] == "generating"
assert data["image_status"] == "not_requested"
assert data["audio_status"] == "not_requested" assert data["audio_status"] == "not_requested"
mock_delay.assert_called_once_with(data["generation_job_id"])
def test_get_generation_alias(self, auth_client: TestClient, test_story): def test_get_generation_alias(self, auth_client: TestClient, test_story):
response = auth_client.get(f"/api/generations/{test_story.id}") response = auth_client.get(f"/api/generations/{test_story.id}")

View File

@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { Component } from 'vue' import type { Component } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useUserStore } from '../stores/user' import { useUserStore } from '../stores/user'
import { useStorybookStore } from '../stores/storybook' import { api } from '../api/client'
import { api } from '../api/client'
import BaseButton from './ui/BaseButton.vue' import BaseButton from './ui/BaseButton.vue'
import BaseInput from './ui/BaseInput.vue' import BaseInput from './ui/BaseInput.vue'
import BaseSelect from './ui/BaseSelect.vue' import BaseSelect from './ui/BaseSelect.vue'
@@ -34,10 +33,9 @@ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const storybookStore = useStorybookStore()
// State // State
const inputType = ref<'keywords' | 'full_story'>('keywords') const inputType = ref<'keywords' | 'full_story'>('keywords')
@@ -88,28 +86,63 @@ const generationTitle = computed(() =>
const generationSteps = computed(() => { const generationSteps = computed(() => {
if (requestedOutputMode.value === 'storybook') { if (requestedOutputMode.value === 'storybook') {
return [ return [
'正在整理主题和成长目标...', '正在提交后台任务...',
'生成绘本分镜和每页文字...', 'Worker 会生成绘本分镜和每页文字...',
'保存绘本主记录,确保刷新也能找回...', '主记录一落库就能通过 ID 找回...',
'补全封面和分页插图...', '插图会继续在后台补全...',
'马上进入可翻页阅读模式。', '稍后自动进入可翻页阅读模式。',
] ]
} }
return [ return [
'正在整理孩子档案和故事主题...', '正在提交后台任务...',
'生成可先阅读的故事正文...', 'Worker 会生成故事正文并保存主记录...',
'保存故事主记录,避免结果丢失...', '主内容一可读就会自动跳转详情页...',
'补全封面图,失败也可稍后重试...', '封面会继续在后台补全,失败也重试...',
'马上进入故事详情页。', '马上进入故事详情页。',
] ]
}) })
interface GenerationAcceptedResponse {
id: number | null
generation_job_id: string | null
}
interface GenerationJobDetail {
story_id: number | null
is_terminal: boolean
error_message: string | null
}
const JOB_POLL_INTERVAL_MS = 1500
const JOB_POLL_MAX_ATTEMPTS = 80
// Methods // Methods
function close() { function close() {
emit('update:modelValue', false) emit('update:modelValue', false)
error.value = '' error.value = ''
} }
function sleep(ms: number) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms)
})
}
async function waitForStoryId(jobId: string) {
for (let attempt = 0; attempt < JOB_POLL_MAX_ATTEMPTS; attempt += 1) {
const detail = await api.get<GenerationJobDetail>(`/api/generations/jobs/${jobId}`)
if (detail.story_id) {
return detail.story_id
}
if (detail.is_terminal) {
throw new Error(detail.error_message || '生成失败,请稍后重试')
}
await sleep(JOB_POLL_INTERVAL_MS)
}
throw new Error('任务已提交,但主内容落库超时,请稍后到故事库查看最新结果')
}
async function fetchProfiles() { async function fetchProfiles() {
if (!userStore.user) return if (!userStore.user) return
@@ -173,21 +206,22 @@ async function generateStory() {
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value
if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value
if (requestedOutputMode.value === 'storybook') { const accepted = await api.post<GenerationAcceptedResponse>('/api/generations', payload)
const response = await api.post<any>('/api/generations', payload) const jobId = accepted.generation_job_id
if (!jobId) {
throw new Error('生成任务已创建,但缺少任务编号')
}
storybookStore.setStorybook(response) const storyId = accepted.id ?? await waitForStoryId(jobId)
close() close()
const storybookPath = response.id ? `/storybook/view/${response.id}` : '/storybook/view' if (requestedOutputMode.value === 'storybook') {
router.push(storybookPath) router.push(`/storybook/view/${storyId}`)
} else { } else {
const result = await api.post<any>('/api/generations', payload) router.push(`/story/${storyId}`)
close()
router.push(`/story/${result.id}`)
} }
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '生成失败' error.value = e instanceof Error ? e.message : '生成失败'
} finally { } finally {
loading.value = false loading.value = false
} }
} }

View File

@@ -101,6 +101,7 @@ function getJobStatusLabel(status?: string) {
function getEventLabel(eventType: string) { function getEventLabel(eventType: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
request_accepted: '请求接收', request_accepted: '请求接收',
worker_started: '后台任务开始',
context_prepared: '上下文准备', context_prepared: '上下文准备',
narrative_generated: '正文生成', narrative_generated: '正文生成',
story_saved: '故事保存', story_saved: '故事保存',

View File

@@ -67,6 +67,7 @@ const audioDuration = ref(0)
const error = ref('') const error = ref('')
const showDeleteConfirm = ref(false) const showDeleteConfirm = ref(false)
const generationTraceRef = ref<InstanceType<typeof GenerationTrace> | null>(null) const generationTraceRef = ref<InstanceType<typeof GenerationTrace> | null>(null)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ?? []) 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))
@@ -75,6 +76,7 @@ const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status))
const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false) const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false)
const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false) const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false)
const isAudioGenerating = computed(() => story.value?.audio_status === 'generating') const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
const shouldAutoRefreshStory = computed(() => story.value?.generation_status === 'assets_generating')
const audioCacheLabel = computed(() => { const audioCacheLabel = computed(() => {
if (!audioCacheStatus.value?.cache_exists) return '暂无缓存' if (!audioCacheStatus.value?.cache_exists) return '暂无缓存'
const size = audioCacheStatus.value.cache_size_bytes ?? 0 const size = audioCacheStatus.value.cache_size_bytes ?? 0
@@ -109,6 +111,13 @@ async function refreshStorySnapshot() {
await refreshAudioStatus().catch(() => undefined) await refreshAudioStatus().catch(() => undefined)
} }
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
async function fetchStory() { async function fetchStory() {
loading.value = true loading.value = true
error.value = '' error.value = ''
@@ -283,7 +292,19 @@ watch(
{ immediate: true }, { immediate: true },
) )
watch(shouldAutoRefreshStory, (enabled) => {
stopAutoRefresh()
if (enabled) {
refreshTimer = setInterval(() => {
if (!loading.value && !imageLoading.value && !audioLoading.value) {
void refreshStorySnapshot().catch(() => undefined)
}
}, 2500)
}
})
onUnmounted(() => { onUnmounted(() => {
stopAutoRefresh()
if (audioUrl.value) { if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value) URL.revokeObjectURL(audioUrl.value)
} }

View File

@@ -49,6 +49,32 @@ assert_jq() {
fi fi
} }
wait_for_job_story() {
local job_id="$1"
local attempts="${2:-60}"
for ((i=1; i<=attempts; i++)); do
local job_json
job_json="$(get_json "$APP_URL/api/generations/jobs/$job_id")"
if jq -e '.story_id != null' >/dev/null <<<"$job_json"; then
printf '%s\n' "$job_json"
return 0
fi
if jq -e '.is_terminal == true and .story_id == null' >/dev/null <<<"$job_json"; then
echo "Generation job finished without a persisted story: $job_id" >&2
echo "$job_json" | jq '.' >&2
exit 1
fi
sleep 1
done
echo "Timed out waiting for generation job to persist a story: $job_id" >&2
exit 1
}
say "Checking backend health" say "Checking backend health"
curl -fsS "$BACKEND_URL/health" | jq -e '.status == "ok"' >/dev/null curl -fsS "$BACKEND_URL/health" | jq -e '.status == "ok"' >/dev/null
curl -fsS "$ADMIN_BACKEND_URL/health" | jq -e '.status == "ok"' >/dev/null curl -fsS "$ADMIN_BACKEND_URL/health" | jq -e '.status == "ok"' >/dev/null
@@ -70,18 +96,23 @@ story_json="$(post_json "$APP_URL/api/generations" '{
"education_theme": "复盘与成长", "education_theme": "复盘与成长",
"generate_images": false "generate_images": false
}')" }')"
story_id="$(jq -r '.id' <<<"$story_json")"
story_job_id="$(jq -r '.generation_job_id' <<<"$story_json")" story_job_id="$(jq -r '.generation_job_id' <<<"$story_json")"
assert_jq "$story_json" '.mode == "generated" and .generation_status == "partial_ready" and .text_status == "ready"' "story should be readable before assets" assert_jq "$story_json" '.mode == "generated" and .generation_status == "queued" and .text_status == "generating"' "story request should be accepted for background execution"
assert_jq "$story_json" '.generation_job_id != null and .generation_job_id != ""' "story generation should expose a job id" assert_jq "$story_json" '.generation_job_id != null and .generation_job_id != ""' "story generation should expose a job id"
echo "$story_json" | jq '{generation_job_id,mode,generation_status,text_status}'
say "Waiting for story main record to be persisted"
story_job_json="$(wait_for_job_story "$story_job_id")"
story_id="$(jq -r '.story_id' <<<"$story_job_json")"
story_json="$(get_json "$APP_URL/api/generations/$story_id")"
assert_jq "$story_json" '.mode == "generated" and .generation_status == "partial_ready" and .text_status == "ready"' "story should be readable before assets"
assert_jq "$story_json" '(.retryable_assets | index("image")) != null and (.retryable_assets | index("audio")) != null' "story should expose image/audio as retryable assets" 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}' echo "$story_json" | jq '{id,title,mode,generation_status,image_status,audio_status,retryable_assets}'
say "Checking story generation job events" say "Checking story generation job events"
story_job_json="$(get_json "$APP_URL/api/generations/jobs/$story_job_id")"
assert_jq "$story_job_json" '.id == "'"$story_job_id"'" and .story_id == '"$story_id"'' "story generation job should be queryable" assert_jq "$story_job_json" '.id == "'"$story_job_id"'" and .story_id == '"$story_id"'' "story generation job should be queryable"
assert_jq "$story_job_json" '.progress_percent == 100 and .is_terminal == true' "story generation job should expose progress summary" assert_jq "$story_job_json" '.progress_percent == 100 and .is_terminal == true' "story generation job should expose progress summary"
assert_jq "$story_job_json" '([.events[].event_type] | index("context_prepared")) != null and ([.events[].event_type] | index("narrative_generated")) != null and ([.events[].event_type] | index("story_saved")) != null' "story generation job should include workflow events" assert_jq "$story_job_json" '([.events[].event_type] | index("worker_started")) != null and ([.events[].event_type] | index("context_prepared")) != null and ([.events[].event_type] | index("narrative_generated")) != null and ([.events[].event_type] | index("story_saved")) != null' "story generation job should include workflow events"
assert_jq "$story_job_json" '([.events[].event_type] | index("provider_call_succeeded")) != null' "story generation job should include provider call events" assert_jq "$story_job_json" '([.events[].event_type] | index("provider_call_succeeded")) != null' "story generation job should include provider call events"
echo "$story_job_json" | jq '{id,status,current_step,events:([.events[].event_type] | unique)}' echo "$story_job_json" | jq '{id,status,current_step,events:([.events[].event_type] | unique)}'
@@ -127,18 +158,23 @@ storybook_json="$(post_json "$APP_URL/api/generations" '{
"generate_images": false, "generate_images": false,
"page_count": 6 "page_count": 6
}')" }')"
storybook_id="$(jq -r '.id' <<<"$storybook_json")"
storybook_job_id="$(jq -r '.generation_job_id' <<<"$storybook_json")" storybook_job_id="$(jq -r '.generation_job_id' <<<"$storybook_json")"
assert_jq "$storybook_json" '.mode == "storybook" and .generation_status == "partial_ready" and .text_status == "ready" and .image_status == "not_requested" and (.pages | length) >= 4' "storybook should be readable before images" assert_jq "$storybook_json" '.mode == "storybook" and .generation_status == "queued" and .text_status == "generating"' "storybook request should be accepted for background execution"
assert_jq "$storybook_json" '.generation_job_id != null and .generation_job_id != ""' "storybook generation should expose a job id" assert_jq "$storybook_json" '.generation_job_id != null and .generation_job_id != ""' "storybook generation should expose a job id"
echo "$storybook_json" | jq '{generation_job_id,mode,generation_status,text_status}'
say "Waiting for storybook main record to be persisted"
storybook_job_json="$(wait_for_job_story "$storybook_job_id")"
storybook_id="$(jq -r '.story_id' <<<"$storybook_job_json")"
storybook_json="$(get_json "$APP_URL/api/generations/$storybook_id")"
assert_jq "$storybook_json" '.mode == "storybook" and .generation_status == "partial_ready" and .text_status == "ready" and .image_status == "not_requested" and (.pages | length) >= 4' "storybook should be readable before images"
assert_jq "$storybook_json" '(.retryable_assets | index("image")) != null and (.retryable_assets | index("audio")) == null' "storybook should expose images as retryable assets" 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)}' echo "$storybook_json" | jq '{id,title,mode,generation_status,image_status,audio_status,retryable_assets,pages:(.pages | length)}'
say "Checking storybook generation job events" say "Checking storybook generation job events"
storybook_job_json="$(get_json "$APP_URL/api/generations/jobs/$storybook_job_id")"
assert_jq "$storybook_job_json" '.id == "'"$storybook_job_id"'" and .story_id == '"$storybook_id"'' "storybook generation job should be queryable" assert_jq "$storybook_job_json" '.id == "'"$storybook_job_id"'" and .story_id == '"$storybook_id"'' "storybook generation job should be queryable"
assert_jq "$storybook_job_json" '.progress_percent == 100 and .is_terminal == true' "storybook generation job should expose progress summary" assert_jq "$storybook_job_json" '.progress_percent == 100 and .is_terminal == true' "storybook generation job should expose progress summary"
assert_jq "$storybook_job_json" '([.events[].event_type] | index("context_prepared")) != null and ([.events[].event_type] | index("narrative_generated")) != null and ([.events[].event_type] | index("story_saved")) != null' "storybook generation job should include workflow events" assert_jq "$storybook_job_json" '([.events[].event_type] | index("worker_started")) != null and ([.events[].event_type] | index("context_prepared")) != null and ([.events[].event_type] | index("narrative_generated")) != null and ([.events[].event_type] | index("story_saved")) != null' "storybook generation job should include workflow events"
echo "$storybook_job_json" | jq '{id,status,current_step,events:([.events[].event_type] | unique)}' echo "$storybook_job_json" | jq '{id,status,current_step,events:([.events[].event_type] | unique)}'
say "Checking storybook provider stats" say "Checking storybook provider stats"