feat: move unified generation to background worker
This commit is contained in:
@@ -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` 判定卡住,后台会定时自动收敛为失败态,避免故事长期显示“永远在跑”。
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '故事保存',
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
38
backend/app/tasks/generation_workflow.py
Normal file
38
backend/app/tasks/generation_workflow.py
Normal 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
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '故事保存',
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user