feat: add generation trace and partial-ready workflow status

This commit is contained in:
2026-04-18 21:53:55 +08:00
parent 96dfc677e2
commit e99a7fbe14
36 changed files with 2597 additions and 144 deletions

View File

@@ -131,6 +131,9 @@ npm run build
| POST | `/api/generations` | 统一生成故事或绘本 |
| GET | `/api/generations/{story_id}` | 统一读取生成结果 |
| POST | `/api/generations/{story_id}/retry-assets` | 统一重试封面/语音资源 |
| GET | `/api/generations/jobs/{job_id}` | 查询生成任务事件流 |
| GET | `/api/generations/{story_id}/jobs` | 查询故事生成与重试历史 |
| GET | `/api/generations/{story_id}/provider-stats` | 查询 Provider 调用聚合指标 |
| GET | `/api/stories` | 故事列表 |
| GET | `/api/stories/{story_id}` | 故事详情 |
| DELETE | `/api/stories/{story_id}` | 删除故事 |

View File

@@ -0,0 +1,358 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { BoltIcon, ClockIcon } from '@heroicons/vue/24/outline'
import { api } from '../api/client'
import LoadingSpinner from './ui/LoadingSpinner.vue'
interface GenerationJobSummary {
id: string
story_id: number | null
output_mode: string
input_type: string
status: string
current_step: string
progress_percent: number
progress_label: string
is_terminal: boolean
result_snapshot: Record<string, unknown>
error_message: string | null
created_at: string
updated_at: string
}
interface GenerationJobEvent {
id: number
job_id: string
story_id: number | null
event_type: string
status: string
message: string | null
event_metadata: Record<string, unknown>
created_at: string
}
interface GenerationJobDetail extends GenerationJobSummary {
request_payload: Record<string, unknown>
events: GenerationJobEvent[]
}
interface GenerationProviderStats {
story_id: number
total_calls: number
successful_calls: number
failed_calls: number
avg_latency_ms: number | null
estimated_cost_usd: number
}
const props = withDefaults(
defineProps<{
storyId: number | null
tone?: 'light' | 'dark'
title?: string
description?: string
}>(),
{
tone: 'light',
title: '生成轨迹',
description: '查看生成、资源补全和 Provider 调用事件,便于演示时解释状态来源与失败恢复。',
},
)
const jobs = ref<GenerationJobSummary[]>([])
const activeJob = ref<GenerationJobDetail | null>(null)
const providerStats = ref<GenerationProviderStats | null>(null)
const loading = ref(false)
const error = ref('')
const isDark = computed(() => props.tone === 'dark')
const latestJob = computed(() => jobs.value[0] ?? null)
const activeEvents = computed(() => activeJob.value?.events.slice(-10) ?? [])
const activeProgress = computed(() => activeJob.value?.progress_percent ?? latestJob.value?.progress_percent ?? 0)
const activeProgressLabel = computed(() => activeJob.value?.progress_label ?? latestJob.value?.progress_label ?? '暂无进度')
const providerSuccessRate = computed(() => {
if (!providerStats.value?.total_calls) return null
return Math.round((providerStats.value.successful_calls / providerStats.value.total_calls) * 100)
})
const mutedClass = computed(() => (isDark.value ? 'text-white/65' : 'text-gray-500'))
const shellClass = computed(() => (
isDark.value ? 'border-white/10 bg-white/10 text-white backdrop-blur' : 'border-gray-100 bg-white/85 text-gray-900'
))
const panelClass = computed(() => (
isDark.value ? 'border-white/10 bg-black/25' : 'border-gray-100 bg-gray-50/80'
))
const statusClassMap: Record<string, string> = {
running: 'border-amber-200 bg-amber-50 text-amber-700',
queued: 'border-sky-200 bg-sky-50 text-sky-700',
succeeded: 'border-emerald-200 bg-emerald-50 text-emerald-700',
completed: 'border-emerald-200 bg-emerald-50 text-emerald-700',
degraded_completed: 'border-orange-200 bg-orange-50 text-orange-700',
failed: 'border-rose-200 bg-rose-50 text-rose-700',
}
function statusClass(status?: string) {
return statusClassMap[status ?? ''] ?? 'border-gray-200 bg-gray-50 text-gray-600'
}
function statusLabel(status?: string) {
const labels: Record<string, string> = {
running: '进行中',
queued: '已排队',
succeeded: '成功',
completed: '已完成',
degraded_completed: '降级完成',
failed: '失败',
}
return labels[status ?? ''] ?? '未知'
}
function eventLabel(eventType: string) {
const labels: Record<string, string> = {
request_accepted: '请求接收',
context_prepared: '上下文准备',
narrative_generated: '正文生成',
story_saved: '故事保存',
provider_call_started: 'Provider 调用',
provider_call_succeeded: 'Provider 成功',
provider_call_failed: 'Provider 失败',
cover_image_started: '封面开始',
cover_image_succeeded: '封面就绪',
cover_image_failed: '封面失败',
storybook_images_started: '绘本插图开始',
storybook_images_completed: '绘本插图完成',
storybook_page_image_succeeded: '分页插图就绪',
storybook_page_image_failed: '分页插图失败',
audio_cache_hit: '音频缓存命中',
audio_started: '音频开始',
audio_succeeded: '音频就绪',
audio_failed: '音频失败',
asset_retry_started: '资源重试开始',
asset_retry_completed: '资源重试完成',
asset_retry_failed: '资源重试失败',
generation_completed: '生成完成',
generation_failed: '生成失败',
}
return labels[eventType] ?? eventType
}
function formatTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(value))
}
function providerText(event: GenerationJobEvent) {
const meta = event.event_metadata
const adapter = meta.adapter ? String(meta.adapter) : ''
const latency = typeof meta.latency_ms === 'number' ? `${meta.latency_ms}ms` : ''
const cost = typeof meta.estimated_cost_usd === 'number'
? `$${meta.estimated_cost_usd.toFixed(4)}`
: ''
return [adapter, latency, cost].filter(Boolean).join(' · ')
}
function formatLatency(value?: number | null) {
return typeof value === 'number' ? `${Math.round(value)}ms` : '暂无'
}
function formatCost(value?: number | null) {
return typeof value === 'number' ? `$${value.toFixed(4)}` : '$0.0000'
}
async function selectJob(jobId: string) {
loading.value = true
error.value = ''
try {
activeJob.value = await api.get<GenerationJobDetail>(`/api/generations/jobs/${jobId}`)
} catch (e) {
error.value = e instanceof Error ? e.message : '生成轨迹加载失败'
} finally {
loading.value = false
}
}
async function refresh() {
if (props.storyId === null) {
jobs.value = []
activeJob.value = null
providerStats.value = null
return
}
error.value = ''
try {
const [nextJobs, stats] = await Promise.all([
api.get<GenerationJobSummary[]>(`/api/generations/${props.storyId}/jobs`),
api.get<GenerationProviderStats>(`/api/generations/${props.storyId}/provider-stats`),
])
jobs.value = nextJobs
providerStats.value = stats
const nextJobId = jobs.value[0]?.id
if (nextJobId) {
await selectJob(nextJobId)
} else {
activeJob.value = null
}
} catch (e) {
jobs.value = []
activeJob.value = null
providerStats.value = null
error.value = e instanceof Error ? e.message : '生成轨迹加载失败'
}
}
watch(
() => props.storyId,
() => {
void refresh()
},
{ immediate: true },
)
defineExpose({ refresh })
</script>
<template>
<section class="rounded-lg border p-5" :class="shellClass">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div class="flex items-center gap-2 font-semibold">
<BoltIcon class="h-5 w-5 text-amber-500" />
{{ props.title }}
</div>
<p class="mt-2 text-sm leading-6" :class="mutedClass">
{{ props.description }}
</p>
</div>
<span
v-if="latestJob"
class="w-fit rounded-full border px-3 py-1.5 text-sm font-medium"
:class="statusClass(latestJob.status)"
>
最近任务{{ statusLabel(latestJob.status) }}
</span>
</div>
<div v-if="error" class="mt-4 rounded-lg border border-rose-100 bg-rose-50 p-3 text-sm text-rose-600">
{{ error }}
</div>
<div v-else class="mt-5 space-y-5">
<div
v-if="providerStats?.total_calls"
class="grid gap-3 md:grid-cols-4"
>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedClass">Provider 成功率</div>
<div class="mt-1 text-xl font-semibold">{{ providerSuccessRate }}%</div>
</div>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedClass">平均耗时</div>
<div class="mt-1 text-xl font-semibold">{{ formatLatency(providerStats.avg_latency_ms) }}</div>
</div>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedClass">预估成本</div>
<div class="mt-1 text-xl font-semibold">{{ formatCost(providerStats.estimated_cost_usd) }}</div>
</div>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedClass">调用次数</div>
<div class="mt-1 text-xl font-semibold">{{ providerStats.total_calls }}</div>
</div>
</div>
<div v-if="!jobs.length" class="rounded-lg border border-dashed border-gray-200 p-4 text-sm" :class="mutedClass">
暂无生成轨迹旧数据会在下一次资源补全后开始记录
</div>
<div v-else class="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
<div class="flex gap-2 overflow-x-auto pb-1 lg:block lg:space-y-2 lg:overflow-visible">
<button
v-for="job in jobs"
:key="job.id"
type="button"
class="min-w-[190px] rounded-lg border px-3 py-3 text-left transition lg:w-full"
:class="activeJob?.id === job.id ? 'border-gray-900 bg-gray-50 text-gray-900' : 'border-gray-100 bg-white text-gray-900 hover:bg-gray-50'"
@click="selectJob(job.id)"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold">
{{ job.output_mode === 'asset_retry' ? '资源重试' : '内容生成' }}
</span>
<span class="rounded-full border px-2 py-0.5 text-xs" :class="statusClass(job.status)">
{{ statusLabel(job.status) }}
</span>
</div>
<div class="mt-2 flex items-center gap-1 text-xs text-gray-500">
<ClockIcon class="h-4 w-4" />
{{ formatTime(job.created_at) }}
</div>
</button>
</div>
<div class="rounded-lg border p-4" :class="panelClass">
<LoadingSpinner v-if="loading" size="sm" text="正在读取生成轨迹..." />
<div v-else-if="activeJob" class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">
{{ activeJob.output_mode === 'asset_retry' ? '资源重试事件' : '生成事件' }}
</div>
<div class="mt-1 text-xs" :class="mutedClass">
当前步骤{{ eventLabel(activeJob.current_step) }}
</div>
</div>
<span class="rounded-full border px-3 py-1 text-xs font-medium" :class="statusClass(activeJob.status)">
{{ statusLabel(activeJob.status) }}
</span>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-xs" :class="mutedClass">
<span>{{ activeProgressLabel }}</span>
<span>{{ activeProgress }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200/80">
<div
class="h-full rounded-full bg-emerald-500 transition-all duration-300"
:style="{ width: `${activeProgress}%` }"
/>
</div>
</div>
<ol class="space-y-3">
<li
v-for="event in activeEvents"
:key="event.id"
class="grid grid-cols-[88px_minmax(0,1fr)] gap-3"
>
<div class="pt-1 text-xs" :class="mutedClass">
{{ formatTime(event.created_at) }}
</div>
<div class="rounded-lg border border-white bg-white px-3 py-2 text-gray-900">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold">{{ eventLabel(event.event_type) }}</span>
<span class="rounded-full border px-2 py-0.5 text-xs" :class="statusClass(event.status)">
{{ statusLabel(event.status) }}
</span>
</div>
<p v-if="event.event_type.startsWith('provider_call')" class="mt-1 text-xs text-gray-500">
{{ providerText(event) }}
</p>
<p v-else-if="event.message" class="mt-1 text-xs text-gray-500">
{{ event.message }}
</p>
</div>
</li>
</ol>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -18,6 +18,7 @@ export interface Storybook {
cover_prompt: string
cover_url?: string
generation_status?: string
text_status?: string
image_status?: string
audio_status?: string
last_error?: string | null

View File

@@ -1,5 +1,6 @@
export type StoryGenerationStatus =
| 'narrative_ready'
| 'partial_ready'
| 'assets_generating'
| 'completed'
| 'degraded_completed'
@@ -23,6 +24,11 @@ const generationStatusMetaMap: Record<StoryGenerationStatus, StatusMeta> = {
description: '故事内容已经生成,可以继续补充封面或音频。',
badgeClass: 'bg-sky-50 text-sky-700 border border-sky-100',
},
partial_ready: {
label: '可先阅读',
description: '主内容已经可用,仍有封面、插图或音频可以继续补全。',
badgeClass: 'bg-cyan-50 text-cyan-700 border border-cyan-100',
},
assets_generating: {
label: '资源生成中',
description: '封面或音频正在生成中,请稍候查看结果。',
@@ -77,3 +83,13 @@ export function getAssetStatusMeta(status?: string): StatusMeta {
return assetStatusMetaMap[(status ?? 'not_requested') as StoryAssetStatus]
?? assetStatusMetaMap.not_requested
}
export function isReadableGenerationStatus(status?: string) {
return ['narrative_ready', 'partial_ready', 'completed', 'degraded_completed']
.includes(status ?? '')
}
export function needsGenerationAttention(status?: string) {
return ['partial_ready', 'assets_generating', 'degraded_completed', 'failed']
.includes(status ?? '')
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/client'
import BaseButton from '../components/ui/BaseButton.vue'
@@ -7,6 +7,11 @@ import BaseCard from '../components/ui/BaseCard.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import CreateStoryModal from '../components/CreateStoryModal.vue'
import {
getGenerationStatusMeta,
isReadableGenerationStatus,
needsGenerationAttention,
} from '../utils/storyStatus'
import {
BookOpenIcon,
ChevronRightIcon,
@@ -22,6 +27,11 @@ interface StoryItem {
image_url: string | null
created_at: string
mode: string
generation_status: string
text_status: string
image_status: string
audio_status: string
last_error: string | null
}
const router = useRouter()
@@ -29,6 +39,12 @@ const stories = ref<StoryItem[]>([])
const loading = ref(true)
const error = ref('')
const showCreateModal = ref(false)
const readableCount = computed(() =>
stories.value.filter((story) => isReadableGenerationStatus(story.generation_status)).length,
)
const attentionCount = computed(() =>
stories.value.filter((story) => needsGenerationAttention(story.generation_status)).length,
)
async function fetchStories() {
try {
@@ -134,8 +150,12 @@ onMounted(() => {
<div class="text-gray-500 text-sm mt-1">已配图</div>
</div>
<div class="text-center px-4">
<BookOpenIcon class="h-8 w-8 text-purple-500 mx-auto" />
<div class="text-gray-500 text-sm mt-1">继续阅读</div>
<div class="text-3xl font-bold text-gray-800">{{ readableCount }}</div>
<div class="text-gray-500 text-sm mt-1">阅读</div>
</div>
<div class="text-center px-4">
<div class="text-3xl font-bold text-gray-800">{{ attentionCount }}</div>
<div class="text-gray-500 text-sm mt-1">需关注</div>
</div>
</div>
</BaseCard>
@@ -164,6 +184,21 @@ onMounted(() => {
<PhotoIcon class="h-12 w-12" />
</div>
<div class="absolute top-4 left-4 flex flex-wrap gap-2">
<span
class="px-2.5 py-1 rounded-full text-xs font-medium backdrop-blur-sm"
:class="story.mode === 'storybook' ? 'bg-amber-100/90 text-amber-800' : 'bg-violet-100/90 text-violet-800'"
>
{{ story.mode === 'storybook' ? '绘本' : '故事' }}
</span>
<span
class="px-2.5 py-1 rounded-full text-xs font-medium backdrop-blur-sm"
:class="getGenerationStatusMeta(story.generation_status).badgeClass"
>
{{ getGenerationStatusMeta(story.generation_status).label }}
</span>
</div>
<!-- 悬停阅读提示 -->
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<span class="inline-flex items-center gap-1 px-4 py-2 bg-white/90 text-gray-900 rounded-full font-medium shadow-lg backdrop-blur-sm transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
@@ -177,6 +212,9 @@ onMounted(() => {
<h3 class="font-bold text-xl text-gray-800 mb-2 line-clamp-2 group-hover:text-purple-600 transition-colors">
{{ story.title }}
</h3>
<p class="text-sm text-gray-500 mb-4 leading-6">
{{ getGenerationStatusMeta(story.generation_status).description }}
</p>
<div class="mt-auto flex items-center justify-between text-sm text-gray-500">
<span>{{ formatDate(story.created_at) }}</span>
<span v-if="story.image_url" class="flex items-center gap-1 text-green-600 bg-green-50 px-2 py-0.5 rounded text-xs font-medium">

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BaseButton from '../components/ui/BaseButton.vue'
import ConfirmModal from '../components/ui/ConfirmModal.vue'
import GenerationTrace from '../components/GenerationTrace.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
import {
@@ -23,6 +24,7 @@ interface Story {
image_url: string | null
mode: string
generation_status: string
text_status: string
image_status: string
audio_status: string
last_error: string | null
@@ -49,6 +51,7 @@ const audioProgress = ref(0)
const audioDuration = ref(0)
const error = ref('')
const showDeleteConfirm = ref(false)
const generationTraceRef = ref<InstanceType<typeof GenerationTrace> | null>(null)
const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ?? [])
const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status))
@@ -62,6 +65,10 @@ const assetGuidance = computed(() => {
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
}
if (story.value?.generation_status === 'partial_ready') {
return '正文已经可读,仍可继续补全封面或音频。'
}
if (story.value?.generation_status === 'assets_generating') {
return '资源正在处理中,可以稍后刷新查看最新状态。'
}
@@ -103,6 +110,7 @@ async function generateImage() {
story.value = await api.post<Story>(`/api/generations/${story.value.id}/retry-assets`, {
assets: ['image'],
})
await generationTraceRef.value?.refresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '图片生成失败'
await refreshStorySnapshot().catch(() => undefined)
@@ -151,6 +159,7 @@ async function retryAudio() {
audioUrl.value = null
await loadAudio()
}
await generationTraceRef.value?.refresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '音频生成失败'
await refreshStorySnapshot().catch(() => undefined)
@@ -338,6 +347,13 @@ onUnmounted(() => {
<p>{{ assetGuidance }}</p>
</div>
<GenerationTrace
ref="generationTraceRef"
class="mb-10"
:story-id="story.id"
description="管理端可直接查看生成、资源补全和 Provider 调用事件,用于演示状态来源与排查失败。"
/>
<div class="prose prose-lg max-w-none mb-10">
<p
v-for="(paragraph, index) in storyParagraphs"

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import { useStorybookStore } from '../stores/storybook'
import BaseButton from '../components/ui/BaseButton.vue'
import GenerationTrace from '../components/GenerationTrace.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
import {
@@ -30,6 +31,7 @@ interface StoryDetailResponse {
image_url: string | null
mode: string
generation_status: string
text_status: string
image_status: string
audio_status: string
last_error: string | null
@@ -45,6 +47,7 @@ const loading = ref(true)
const imageLoading = ref(false)
const error = ref('')
const currentPageIndex = ref(-1)
const generationTraceRef = ref<InstanceType<typeof GenerationTrace> | null>(null)
const totalPages = computed(() => storybook.value?.pages.length || 0)
const isCover = computed(() => currentPageIndex.value === -1)
@@ -83,6 +86,7 @@ const currentStoryId = computed(() => {
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
})
const storybookTraceId = computed(() => storybook.value?.id ?? currentStoryId.value)
function goHome() {
store.clearStorybook()
@@ -122,6 +126,7 @@ async function loadStorybook() {
if (cachedStorybook?.id === storyId) {
loading.value = false
await generationTraceRef.value?.refresh()
return
}
@@ -150,6 +155,7 @@ async function loadStorybook() {
last_error: detail.last_error,
retryable_assets: detail.retryable_assets,
})
await generationTraceRef.value?.refresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '绘本加载失败'
} finally {
@@ -186,6 +192,7 @@ async function retryStorybookImages() {
last_error: detail.last_error,
retryable_assets: detail.retryable_assets,
})
await generationTraceRef.value?.refresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '插图补全失败'
await loadStorybook().catch(() => undefined)
@@ -394,6 +401,17 @@ watch(
读完了再来一本
</BaseButton>
</div>
<section class="bg-[#0D0F1A] px-4 pb-10 md:px-8">
<GenerationTrace
ref="generationTraceRef"
:story-id="storybookTraceId"
tone="dark"
title="绘本生成轨迹"
description="管理端可查看绘本正文、封面、分页插图和 Provider 调用事件,便于演示和排查资源补全。"
class="mx-auto max-w-5xl"
/>
</section>
</div>
</template>

View File

@@ -0,0 +1,107 @@
"""add story text status and partial ready semantics
Revision ID: 0012_story_text_status
Revises: 0011_add_generation_jobs
Create Date: 2026-04-18
"""
import sqlalchemy as sa
from alembic import op
revision = "0012_story_text_status"
down_revision = "0011_add_generation_jobs"
branch_labels = None
depends_on = None
stories = sa.table(
"stories",
sa.column("id", sa.Integer),
sa.column("story_text", sa.Text),
sa.column("pages", sa.JSON),
sa.column("cover_prompt", sa.Text),
sa.column("image_url", sa.String(length=500)),
sa.column("generation_status", sa.String(length=32)),
sa.column("text_status", sa.String(length=32)),
sa.column("image_status", sa.String(length=32)),
sa.column("audio_status", sa.String(length=32)),
)
def _has_narrative(row: dict) -> bool:
return bool(row.get("story_text")) or bool(row.get("pages"))
def _has_pending_image(row: dict) -> bool:
if row.get("image_status") in {"ready", "generating"}:
return False
pages = row.get("pages") or []
has_missing_page_image = any(
isinstance(page, dict)
and page.get("image_prompt")
and not page.get("image_url")
for page in pages
)
return bool(row.get("cover_prompt") and not row.get("image_url")) or has_missing_page_image
def _resolve_generation_status(row: dict) -> str:
if not _has_narrative(row):
return "failed"
image_status = row.get("image_status") or "not_requested"
audio_status = row.get("audio_status") or "not_requested"
if "generating" in {image_status, audio_status}:
return "assets_generating"
if "failed" in {image_status, audio_status}:
return "degraded_completed"
has_pending_audio = bool(row.get("story_text")) and audio_status not in {
"ready",
"generating",
}
if _has_pending_image(row) or has_pending_audio:
return "partial_ready"
if image_status == "not_requested" and audio_status == "not_requested":
return "narrative_ready"
return "completed"
def upgrade() -> None:
op.add_column(
"stories",
sa.Column("text_status", sa.String(length=32), nullable=False, server_default="ready"),
)
connection = op.get_bind()
rows = connection.execute(
sa.select(
stories.c.id,
stories.c.story_text,
stories.c.pages,
stories.c.cover_prompt,
stories.c.image_url,
stories.c.image_status,
stories.c.audio_status,
)
).mappings()
for row in rows:
text_status = "ready" if _has_narrative(row) else "failed"
generation_status = _resolve_generation_status(row)
connection.execute(
stories.update()
.where(stories.c.id == row["id"])
.values(text_status=text_status, generation_status=generation_status)
)
def downgrade() -> None:
op.drop_column("stories", "text_status")

View File

@@ -17,6 +17,9 @@ from app.schemas.story_schemas import (
AchievementItem,
FullStoryResponse,
GenerateRequest,
GenerationJobDetailResponse,
GenerationJobSummaryResponse,
GenerationProviderStatsResponse,
GenerationRequest,
GenerationResponse,
StoryAssetRetryRequest,
@@ -28,6 +31,11 @@ from app.schemas.story_schemas import (
StoryResponse,
)
from app.services import story_service
from app.services.generation_jobs import (
get_generation_job_detail,
get_story_provider_stats,
list_story_generation_jobs,
)
from app.services.memory_service import build_enhanced_memory_context
from app.services.provider_router import (
generate_image,
@@ -65,6 +73,42 @@ async def create_generation(
return await story_service.generate_generation_service(request, user.id, db)
@router.get("/generations/jobs/{job_id}", response_model=GenerationJobDetailResponse)
async def get_generation_job(
job_id: str,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Get one generation job with ordered workflow events."""
return await get_generation_job_detail(db, job_id=job_id, user_id=user.id)
@router.get(
"/generations/{story_id}/jobs",
response_model=list[GenerationJobSummaryResponse],
)
async def list_generation_jobs(
story_id: int,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""List recent generation jobs for a generated story/storybook."""
return await list_story_generation_jobs(db, story_id=story_id, user_id=user.id)
@router.get(
"/generations/{story_id}/provider-stats",
response_model=GenerationProviderStatsResponse,
)
async def get_generation_provider_stats(
story_id: int,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Get provider call stats aggregated from generation job events."""
return await get_story_provider_stats(db, story_id=story_id, user_id=user.id)
@router.get("/generations/{story_id}", response_model=StoryDetailResponse)
async def get_generation(
story_id: int,
@@ -135,13 +179,14 @@ async def generate_story_stream(
# Step 1: Generate Content
try:
result = await generate_story_content(
input_type=request.type,
data=request.data,
education_theme=request.education_theme,
memory_context=memory_context,
db=db,
)
result = await generate_story_content(
input_type=request.type,
data=request.data,
education_theme=request.education_theme,
memory_context=memory_context,
user_id=user.id,
db=db,
)
except Exception as e:
logger.error("sse_story_generation_failed", error=str(e))
yield {"event": "story_failed", "data": json.dumps({"error": str(e)})}
@@ -163,6 +208,7 @@ async def generate_story_stream(
"child_profile_id": story.child_profile_id,
"universe_id": story.universe_id,
"generation_status": story.generation_status,
"text_status": story.text_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
"last_error": story.last_error,
@@ -175,7 +221,12 @@ async def generate_story_stream(
await db.commit()
try:
# Direct call to provider router's generate_image, sharing db session
image_url = await generate_image(story.cover_prompt, db=db)
image_url = await generate_image(
story.cover_prompt,
db=db,
user_id=user.id,
story_id=story.id,
)
story.image_url = image_url
sync_story_status(
story,
@@ -188,6 +239,7 @@ async def generate_story_stream(
{
"image_url": image_url,
"generation_status": story.generation_status,
"text_status": story.text_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
"last_error": story.last_error,
@@ -208,6 +260,7 @@ async def generate_story_stream(
{
"error": str(e),
"generation_status": story.generation_status,
"text_status": story.text_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
"last_error": story.last_error,
@@ -221,6 +274,7 @@ async def generate_story_stream(
{
"story_id": story.id,
"generation_status": story.generation_status,
"text_status": story.text_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
"last_error": story.last_error,
@@ -296,6 +350,7 @@ async def generate_story_image(
return {
"image_url": url,
"generation_status": story.generation_status,
"text_status": story.text_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
"last_error": story.last_error,

View File

@@ -67,6 +67,9 @@ class Story(Base):
generation_status: Mapped[str] = mapped_column(
String(32), nullable=False, default="narrative_ready"
)
text_status: Mapped[str] = mapped_column(
String(32), nullable=False, default="ready"
)
image_status: Mapped[str] = mapped_column(
String(32), nullable=False, default="not_requested"
)

View File

@@ -1,7 +1,7 @@
"""Story-related Pydantic schemas."""
from datetime import datetime
from typing import Literal
from typing import Any, Literal
from pydantic import BaseModel, Field
@@ -14,6 +14,7 @@ class StoryStatusMixin(BaseModel):
"""Shared generation status fields returned by story APIs."""
generation_status: str
text_status: str
image_status: str
audio_status: str
last_error: str | None = None
@@ -117,6 +118,7 @@ class GenerationResponse(StoryStatusMixin):
"""Unified generation response for the target workflow API."""
id: int
generation_job_id: str | None = None
title: str
mode: str
story_text: str | None = None
@@ -158,6 +160,68 @@ class StoryAssetRetryRequest(BaseModel):
assets: list[Literal["image", "audio"]] = Field(..., min_length=1)
class GenerationJobEventResponse(BaseModel):
"""One persisted event emitted by a generation job."""
id: int
job_id: str
story_id: int | None = None
event_type: str
status: str
message: str | None = None
event_metadata: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
class GenerationJobSummaryResponse(BaseModel):
"""Generation job summary for progress lists."""
id: str
story_id: int | None = None
output_mode: str
input_type: str
status: str
current_step: str
progress_percent: int
progress_label: str
is_terminal: bool
result_snapshot: dict[str, Any] = Field(default_factory=dict)
error_message: str | None = None
created_at: datetime
updated_at: datetime
class GenerationJobDetailResponse(GenerationJobSummaryResponse):
"""Generation job detail with append-only workflow events."""
request_payload: dict[str, Any] = Field(default_factory=dict)
events: list[GenerationJobEventResponse] = Field(default_factory=list)
class GenerationProviderStatResponse(BaseModel):
"""Aggregated provider call stats for one adapter/capability pair."""
capability: str
adapter: str
call_count: int
success_count: int
failure_count: int
avg_latency_ms: float | None = None
estimated_cost_usd: float = 0.0
class GenerationProviderStatsResponse(BaseModel):
"""Provider call stats aggregated from generation job events."""
story_id: int
total_calls: int
successful_calls: int
failed_calls: int
avg_latency_ms: float | None = None
estimated_cost_usd: float = 0.0
by_provider: list[GenerationProviderStatResponse] = Field(default_factory=list)
class AchievementItem(BaseModel):
"""Achievement item returned for a story."""

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import GenerationJob, GenerationJobEvent, Story
@@ -17,6 +19,7 @@ def _story_snapshot(story: Story | None) -> dict[str, Any]:
"story_id": story.id,
"mode": story.mode,
"generation_status": story.generation_status,
"text_status": story.text_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
"retryable_assets": story.retryable_assets,
@@ -32,6 +35,48 @@ def _job_status_from_story(story: Story) -> str:
return "completed"
def _job_progress(job: GenerationJob) -> dict[str, Any]:
"""Resolve a compact progress summary for polling-oriented clients."""
if job.status == "failed":
return {
"progress_percent": 100,
"progress_label": "生成失败",
"is_terminal": True,
}
if job.status in {"completed", "degraded_completed"}:
return {
"progress_percent": 100,
"progress_label": "已完成" if job.status == "completed" else "降级完成",
"is_terminal": True,
}
progress_map: dict[str, tuple[int, str]] = {
"request_accepted": (5, "已接收请求"),
"context_prepared": (20, "上下文已准备"),
"narrative_generated": (45, "正文已生成"),
"story_saved": (60, "主记录已保存"),
"provider_call_started": (65, "Provider 调用中"),
"provider_call_succeeded": (72, "Provider 调用成功"),
"provider_call_failed": (72, "Provider 调用失败,尝试恢复"),
"cover_image_started": (75, "封面生成中"),
"storybook_images_started": (75, "绘本插图生成中"),
"audio_started": (75, "音频生成中"),
"asset_retry_started": (25, "资源重试中"),
"postprocessing_queued": (90, "后处理已排队"),
"asset_generation_completed": (100, "资源已完成"),
"asset_retry_completed": (100, "资源重试完成"),
"generation_completed": (100, "生成完成"),
}
percent, label = progress_map.get(job.current_step, (10, "生成处理中"))
return {
"progress_percent": percent,
"progress_label": label,
"is_terminal": percent >= 100,
}
async def create_generation_job(
db: AsyncSession,
*,
@@ -131,3 +176,198 @@ async def finish_generation_job(
await db.commit()
await db.refresh(job)
return job
def generation_event_to_response(event: GenerationJobEvent) -> dict[str, Any]:
"""Convert a generation event ORM object to an API response dict."""
return {
"id": event.id,
"job_id": event.job_id,
"story_id": event.story_id,
"event_type": event.event_type,
"status": event.status,
"message": event.message,
"event_metadata": event.event_metadata or {},
"created_at": event.created_at,
}
def generation_job_to_summary(job: GenerationJob) -> dict[str, Any]:
"""Convert a generation job ORM object to an API summary dict."""
progress = _job_progress(job)
return {
"id": job.id,
"story_id": job.story_id,
"output_mode": job.output_mode,
"input_type": job.input_type,
"status": job.status,
"current_step": job.current_step,
**progress,
"result_snapshot": job.result_snapshot or {},
"error_message": job.error_message,
"created_at": job.created_at,
"updated_at": job.updated_at,
}
async def get_generation_job_detail(
db: AsyncSession,
*,
job_id: str,
user_id: str,
) -> dict[str, Any]:
"""Return a user-owned generation job with its ordered event stream."""
result = await db.execute(
select(GenerationJob).where(
GenerationJob.id == job_id,
GenerationJob.user_id == user_id,
)
)
job = result.scalar_one_or_none()
if job is None:
raise HTTPException(status_code=404, detail="Generation job not found")
events = (
await db.execute(
select(GenerationJobEvent)
.where(GenerationJobEvent.job_id == job.id)
.order_by(GenerationJobEvent.id)
)
).scalars().all()
return {
**generation_job_to_summary(job),
"request_payload": job.request_payload or {},
"events": [generation_event_to_response(event) for event in events],
}
async def list_story_generation_jobs(
db: AsyncSession,
*,
story_id: int,
user_id: str,
) -> list[dict[str, Any]]:
"""Return recent generation jobs for a user-owned story."""
jobs = (
await db.execute(
select(GenerationJob)
.where(
GenerationJob.story_id == story_id,
GenerationJob.user_id == user_id,
)
.order_by(desc(GenerationJob.created_at), desc(GenerationJob.id))
)
).scalars().all()
return [generation_job_to_summary(job) for job in jobs]
def _as_float(value: Any) -> float | None:
if isinstance(value, int | float):
return float(value)
return None
async def get_story_provider_stats(
db: AsyncSession,
*,
story_id: int,
user_id: str,
) -> dict[str, Any]:
"""Aggregate provider call telemetry from all user-owned jobs for one story."""
events = (
await db.execute(
select(GenerationJobEvent)
.join(GenerationJob, GenerationJobEvent.job_id == GenerationJob.id)
.where(
GenerationJob.story_id == story_id,
GenerationJob.user_id == user_id,
GenerationJobEvent.event_type.in_(
["provider_call_succeeded", "provider_call_failed"]
),
)
.order_by(GenerationJobEvent.id)
)
).scalars().all()
by_key: dict[tuple[str, str], dict[str, Any]] = {}
total_latency = 0.0
latency_count = 0
total_cost = 0.0
successful_calls = 0
failed_calls = 0
for event in events:
metadata = event.event_metadata or {}
capability = str(metadata.get("capability") or "unknown")
adapter = str(metadata.get("adapter") or "unknown")
key = (capability, adapter)
bucket = by_key.setdefault(
key,
{
"capability": capability,
"adapter": adapter,
"call_count": 0,
"success_count": 0,
"failure_count": 0,
"latency_total": 0.0,
"latency_count": 0,
"estimated_cost_usd": 0.0,
},
)
bucket["call_count"] += 1
latency = _as_float(metadata.get("latency_ms"))
if latency is not None:
bucket["latency_total"] += latency
bucket["latency_count"] += 1
total_latency += latency
latency_count += 1
if event.event_type == "provider_call_succeeded":
bucket["success_count"] += 1
successful_calls += 1
cost = _as_float(metadata.get("estimated_cost_usd")) or 0.0
bucket["estimated_cost_usd"] += cost
total_cost += cost
else:
bucket["failure_count"] += 1
failed_calls += 1
by_provider = []
for bucket in by_key.values():
bucket_latency_count = bucket.pop("latency_count")
bucket_latency_total = bucket.pop("latency_total")
by_provider.append(
{
**bucket,
"avg_latency_ms": (
round(bucket_latency_total / bucket_latency_count, 2)
if bucket_latency_count
else None
),
"estimated_cost_usd": round(bucket["estimated_cost_usd"], 6),
}
)
by_provider.sort(
key=lambda item: (
str(item["capability"]),
str(item["adapter"]),
)
)
return {
"story_id": story_id,
"total_calls": successful_calls + failed_calls,
"successful_calls": successful_calls,
"failed_calls": failed_calls,
"avg_latency_ms": round(total_latency / latency_count, 2) if latency_count else None,
"estimated_cost_usd": round(total_cost, 6),
"by_provider": by_provider,
}

View File

@@ -10,6 +10,7 @@ from app.core.logging import get_logger
from app.services.adapters import AdapterConfig, AdapterRegistry
from app.services.adapters.text.models import StoryOutput
from app.services.cost_tracker import cost_tracker
from app.services.generation_jobs import record_generation_event
from app.services.provider_cache import get_providers
from app.services.provider_metrics import health_checker, metrics_collector
from app.services.provider_policy import (
@@ -22,6 +23,7 @@ from app.services.provider_policy import (
if TYPE_CHECKING:
from app.db.admin_models import Provider
from app.db.models import GenerationJob
logger = get_logger(__name__)
@@ -36,6 +38,58 @@ _round_robin_counters: dict[ProviderType, int] = {
_latency_cache: dict[str, float] = {}
def _safe_estimated_cost(adapter) -> float:
"""Return an adapter cost value that is safe to serialize in job events."""
try:
return float(adapter.estimated_cost)
except Exception:
return 0.0
async def _record_provider_event_if_present(
db: AsyncSession | None,
*,
job: "GenerationJob | None",
event_type: str,
status: str,
provider_type: ProviderType,
adapter_name: str,
strategy: RoutingStrategy,
provider_id: str | None = None,
story_id: int | None = None,
latency_ms: int | None = None,
estimated_cost: float | None = None,
error: str | None = None,
) -> None:
"""Append provider call telemetry to the active generation job."""
if db is None or job is None:
return
await record_generation_event(
db,
job=job,
story_id=story_id,
event_type=event_type,
status=status,
message=(
f"{provider_type} provider {adapter_name} {status}."
if error is None
else f"{provider_type} provider {adapter_name} failed."
),
metadata={
"capability": provider_type,
"adapter": adapter_name,
"provider_id": provider_id,
"strategy": strategy.value,
"latency_ms": latency_ms,
"estimated_cost_usd": estimated_cost,
"error": error,
},
)
def _get_api_key(config_ref: str | None, adapter_name: str) -> str:
"""根据 config_ref 或适配器名称获取 API Key。"""
# 优先使用 config_ref
@@ -228,6 +282,8 @@ async def _route_with_failover(
strategy: RoutingStrategy = RoutingStrategy.PRIORITY,
db: AsyncSession | None = None,
user_id: str | None = None,
generation_job: "GenerationJob | None" = None,
story_id: int | None = None,
**kwargs,
) -> T:
"""通用 provider failover 路由。
@@ -237,6 +293,8 @@ async def _route_with_failover(
strategy: 路由策略
db: 数据库会话(可选,用于指标收集和熔断检查)
user_id: 用户 ID可选用于成本追踪和预算检查
generation_job: 生成任务(可选,用于记录 provider 调用轨迹)
story_id: 故事 ID可选用于关联 provider 事件)
**kwargs: 传递给适配器的参数
"""
providers = await _get_providers_with_config(provider_type)
@@ -274,7 +332,9 @@ async def _route_with_failover(
errors.append(f"{name}: 适配器未注册")
continue
provider_id = db_provider.id if db_provider else None
provider_id = str(db_provider.id) if db_provider else None
estimated_cost: float | None = None
start_time: float | None = None
try:
logger.debug(
@@ -285,6 +345,20 @@ async def _route_with_failover(
)
adapter = adapter_class(config)
estimated_cost = _safe_estimated_cost(adapter)
await _record_provider_event_if_present(
db,
job=generation_job,
story_id=story_id,
event_type="provider_call_started",
status="running",
provider_type=provider_type,
adapter_name=name,
provider_id=provider_id,
strategy=strategy,
estimated_cost=estimated_cost,
)
# 执行并计时
start_time = time.time()
@@ -301,7 +375,7 @@ async def _route_with_failover(
provider_id=provider_id,
success=True,
latency_ms=latency_ms,
cost_usd=adapter.estimated_cost,
cost_usd=estimated_cost,
)
await health_checker.record_call_result(db, provider_id, success=True)
@@ -312,10 +386,24 @@ async def _route_with_failover(
user_id=user_id,
provider_name=name,
capability=provider_type,
estimated_cost=adapter.estimated_cost,
estimated_cost=estimated_cost,
provider_id=provider_id,
)
await _record_provider_event_if_present(
db,
job=generation_job,
story_id=story_id,
event_type="provider_call_succeeded",
status="succeeded",
provider_type=provider_type,
adapter_name=name,
provider_id=provider_id,
strategy=strategy,
latency_ms=latency_ms,
estimated_cost=estimated_cost,
)
logger.info(
"provider_success",
provider_type=provider_type,
@@ -326,6 +414,11 @@ async def _route_with_failover(
except Exception as exc:
error_msg = str(exc)
latency_ms = (
int((time.time() - start_time) * 1000)
if start_time is not None
else None
)
logger.warning(
"provider_failed",
provider_type=provider_type,
@@ -346,6 +439,21 @@ async def _route_with_failover(
db, provider_id, success=False, error=error_msg
)
await _record_provider_event_if_present(
db,
job=generation_job,
story_id=story_id,
event_type="provider_call_failed",
status="failed",
provider_type=provider_type,
adapter_name=name,
provider_id=provider_id,
strategy=strategy,
latency_ms=latency_ms,
estimated_cost=estimated_cost,
error=error_msg,
)
raise ValueError(f"No {provider_type} provider succeeded. Errors: {' | '.join(errors)}")
@@ -356,12 +464,16 @@ async def generate_story_content(
memory_context: str | None = None,
strategy: RoutingStrategy = RoutingStrategy.PRIORITY,
db: AsyncSession | None = None,
user_id: str | None = None,
generation_job: "GenerationJob | None" = None,
) -> StoryOutput:
"""生成或润色故事,支持 failover。"""
return await _route_with_failover(
"text",
strategy=strategy,
db=db,
user_id=user_id,
generation_job=generation_job,
input_type=input_type,
data=data,
education_theme=education_theme,
@@ -373,19 +485,42 @@ async def generate_image(
prompt: str,
strategy: RoutingStrategy = RoutingStrategy.PRIORITY,
db: AsyncSession | None = None,
user_id: str | None = None,
generation_job: "GenerationJob | None" = None,
story_id: int | None = None,
**kwargs,
) -> str:
"""生成图片,返回 URL支持 failover。"""
return await _route_with_failover("image", strategy=strategy, db=db, prompt=prompt, **kwargs)
return await _route_with_failover(
"image",
strategy=strategy,
db=db,
user_id=user_id,
generation_job=generation_job,
story_id=story_id,
prompt=prompt,
**kwargs,
)
async def text_to_speech(
text: str,
strategy: RoutingStrategy = RoutingStrategy.PRIORITY,
db: AsyncSession | None = None,
user_id: str | None = None,
generation_job: "GenerationJob | None" = None,
story_id: int | None = None,
) -> bytes:
"""文本转语音,返回 MP3 bytes支持 failover。"""
return await _route_with_failover("tts", strategy=strategy, db=db, text=text)
return await _route_with_failover(
"tts",
strategy=strategy,
db=db,
user_id=user_id,
generation_job=generation_job,
story_id=story_id,
text=text,
)
async def generate_storybook(
@@ -395,6 +530,8 @@ async def generate_storybook(
memory_context: str | None = None,
strategy: RoutingStrategy = RoutingStrategy.PRIORITY,
db: AsyncSession | None = None,
user_id: str | None = None,
generation_job: "GenerationJob | None" = None,
):
"""生成分页故事书,支持 failover。"""
from app.services.adapters.storybook.primary import Storybook
@@ -403,6 +540,8 @@ async def generate_storybook(
"storybook",
strategy=strategy,
db=db,
user_id=user_id,
generation_job=generation_job,
keywords=keywords,
page_count=page_count,
education_theme=education_theme,

View File

@@ -67,6 +67,43 @@ class AssetCompletionResult:
return self.status == StoryAssetStatus.READY and self.error is None
async def _record_job_event_if_present(
db: AsyncSession,
*,
job,
event_type: str,
status: str,
story_id: int | None = None,
message: str | None = None,
metadata: dict | None = None,
) -> None:
"""Append a workflow event when the caller is running under a tracked job."""
if job is None:
return
await record_generation_event(
db,
job=job,
story_id=story_id,
event_type=event_type,
status=status,
message=message,
metadata=metadata,
)
def _asset_result_metadata(result: AssetCompletionResult) -> dict:
"""Build JSON-safe metadata for asset workflow events."""
return {
"asset": result.asset,
"status": result.status.value,
"error": result.error,
"blocks_main_result": result.blocks_main_result,
}
def _build_storybook_error_message(
*,
cover_failed: bool,
@@ -125,6 +162,7 @@ async def _prepare_generation_context(
universe_id: str | None,
user_id: str,
db: AsyncSession,
job=None,
) -> tuple[str | None, str | None, str]:
"""Validate ownership and build the shared generation context."""
@@ -136,6 +174,18 @@ async def _prepare_generation_context(
resolved_universe_id,
db,
)
await _record_job_event_if_present(
db,
job=job,
event_type="context_prepared",
status="succeeded",
message="Profile, universe, and memory context were prepared.",
metadata={
"profile_id": resolved_profile_id,
"universe_id": resolved_universe_id,
"has_memory_context": bool(memory_context),
},
)
return resolved_profile_id, resolved_universe_id, memory_context
@@ -173,6 +223,7 @@ async def _persist_text_story_result(
profile_id: str | None,
universe_id: str | None,
db: AsyncSession,
job=None,
) -> Story:
"""Persist generated text content as the unified story record."""
@@ -195,6 +246,20 @@ async def _persist_text_story_result(
await db.commit()
await db.refresh(story)
_trigger_story_postprocessing(story)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="story_saved",
status="succeeded",
message="Readable story record was saved.",
metadata={
"mode": story.mode,
"generation_status": story.generation_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
},
)
return story
@@ -229,21 +294,47 @@ def _storybook_pages_to_response(pages_data: list[dict]) -> list[StorybookPageRe
async def _generate_storybook_image_assets(
storybook: Storybook,
db: AsyncSession,
*,
user_id: str,
job=None,
) -> tuple[str | None, bool, list[int]]:
"""Generate storybook cover and page images before persistence."""
final_cover_url = storybook.cover_url
cover_failed = False
failed_pages: list[int] = []
completed_pages: list[int] = []
attempted_cover = bool(storybook.cover_prompt and not storybook.cover_url)
attempted_pages = [
page.page_number
for page in storybook.pages
if page.image_prompt and not page.image_url
]
logger.info("storybook_parallel_generation_start", page_count=len(storybook.pages))
await _record_job_event_if_present(
db,
job=job,
event_type="storybook_images_started",
status="running",
message="Storybook cover and page image generation started.",
metadata={
"attempted_cover": attempted_cover,
"attempted_pages": attempted_pages,
},
)
async def _gen_cover() -> str | None:
nonlocal cover_failed
if storybook.cover_prompt and not storybook.cover_url:
try:
return await generate_image(storybook.cover_prompt, db=db)
return await generate_image(
storybook.cover_prompt,
db=db,
user_id=user_id,
generation_job=job,
)
except Exception as exc:
cover_failed = True
logger.warning("cover_gen_failed", error=str(exc))
@@ -254,7 +345,13 @@ async def _generate_storybook_image_assets(
return
try:
page.image_url = await generate_image(page.image_prompt, db=db)
page.image_url = await generate_image(
page.image_prompt,
db=db,
user_id=user_id,
generation_job=job,
)
completed_pages.append(page.page_number)
except Exception as exc:
failed_pages.append(page.page_number)
logger.warning("page_gen_failed", page=page.page_number, error=str(exc))
@@ -270,6 +367,57 @@ async def _generate_storybook_image_assets(
final_cover_url = cover_result
logger.info("storybook_parallel_generation_complete")
if attempted_cover:
await _record_job_event_if_present(
db,
job=job,
event_type=(
"storybook_cover_image_failed"
if cover_failed
else "storybook_cover_image_succeeded"
),
status="failed" if cover_failed else "succeeded",
message=(
"Storybook cover image generation failed."
if cover_failed
else "Storybook cover image was generated."
),
metadata={"asset": "image", "scope": "cover"},
)
for page_number in sorted(completed_pages):
await _record_job_event_if_present(
db,
job=job,
event_type="storybook_page_image_succeeded",
status="succeeded",
message="Storybook page image was generated.",
metadata={"asset": "image", "scope": "page", "page_number": page_number},
)
for page_number in sorted(failed_pages):
await _record_job_event_if_present(
db,
job=job,
event_type="storybook_page_image_failed",
status="failed",
message="Storybook page image generation failed.",
metadata={"asset": "image", "scope": "page", "page_number": page_number},
)
await _record_job_event_if_present(
db,
job=job,
event_type="storybook_images_completed",
status="failed" if cover_failed or failed_pages else "succeeded",
message="Storybook image generation finished.",
metadata={
"asset": "image",
"attempted_cover": attempted_cover,
"completed_pages": sorted(completed_pages),
"failed_pages": sorted(failed_pages),
},
)
return final_cover_url, cover_failed, failed_pages
@@ -284,6 +432,7 @@ async def _persist_storybook_result(
cover_failed: bool,
failed_pages: list[int],
db: AsyncSession,
job=None,
) -> tuple[Story, list[dict]]:
"""Persist generated storybook content as the unified story record."""
@@ -317,6 +466,21 @@ async def _persist_storybook_result(
await db.commit()
await db.refresh(story)
_trigger_story_postprocessing(story)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="story_saved",
status="succeeded",
message="Storybook record was saved.",
metadata={
"mode": story.mode,
"page_count": len(pages_data),
"generation_status": story.generation_status,
"image_status": story.image_status,
"audio_status": story.audio_status,
},
)
return story, pages_data
@@ -327,6 +491,7 @@ async def _complete_cover_image_asset(
raise_on_failure: bool = False,
last_error_prefix: str | None = None,
log_event: str = "cover_asset_generation_failed",
job=None,
) -> AssetCompletionResult:
"""Generate or retry a text story cover through one asset workflow."""
@@ -335,18 +500,43 @@ async def _complete_cover_image_asset(
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
await db.commit()
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="cover_image_started",
status="running",
message="Cover image generation started.",
metadata={"asset": "image", "cover_prompt_present": True},
)
try:
image_url = await generate_image(story.cover_prompt, db=db)
image_url = await generate_image(
story.cover_prompt,
db=db,
user_id=story.user_id,
generation_job=job,
story_id=story.id,
)
story.image_url = image_url
sync_story_status(story, image_status=StoryAssetStatus.READY)
await db.commit()
return AssetCompletionResult(
result = AssetCompletionResult(
asset="cover_image",
status=StoryAssetStatus.READY,
value=image_url,
blocks_main_result=raise_on_failure,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="cover_image_succeeded",
status="succeeded",
message="Cover image was generated.",
metadata=_asset_result_metadata(result),
)
return result
except Exception as exc:
provider_error = str(exc)
last_error = (
@@ -362,18 +552,28 @@ async def _complete_cover_image_asset(
await db.commit()
logger.warning(log_event, story_id=story.id, error=provider_error)
result = AssetCompletionResult(
asset="cover_image",
status=StoryAssetStatus.FAILED,
error=provider_error,
blocks_main_result=raise_on_failure,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="cover_image_failed",
status="failed",
message="Cover image generation failed.",
metadata=_asset_result_metadata(result),
)
if raise_on_failure:
raise HTTPException(
status_code=500,
detail=f"Image generation failed: {provider_error}",
) from exc
return AssetCompletionResult(
asset="cover_image",
status=StoryAssetStatus.FAILED,
error=provider_error,
blocks_main_result=raise_on_failure,
)
return result
def _get_storybook_pages_data(story: Story) -> list[dict]:
@@ -385,6 +585,8 @@ def _get_storybook_pages_data(story: Story) -> list[dict]:
async def _complete_storybook_image_assets(
story: Story,
db: AsyncSession,
*,
job=None,
) -> AssetCompletionResult:
"""Complete missing cover/page images for a persisted storybook."""
@@ -397,13 +599,38 @@ async def _complete_storybook_image_assets(
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
await db.commit()
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="storybook_images_started",
status="running",
message="Storybook missing image completion started.",
metadata={"asset": "image"},
)
cover_failed = False
failed_pages: list[int] = []
completed_pages: list[int] = []
if story.cover_prompt and not story.image_url:
try:
story.image_url = await generate_image(story.cover_prompt, db=db)
story.image_url = await generate_image(
story.cover_prompt,
db=db,
user_id=story.user_id,
generation_job=job,
story_id=story.id,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="storybook_cover_image_succeeded",
status="succeeded",
message="Storybook cover image was generated.",
metadata={"asset": "image", "scope": "cover"},
)
except Exception as exc:
cover_failed = True
logger.warning(
@@ -411,13 +638,40 @@ async def _complete_storybook_image_assets(
story_id=story.id,
error=str(exc),
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="storybook_cover_image_failed",
status="failed",
message="Storybook cover image generation failed.",
metadata={"asset": "image", "scope": "cover", "error": str(exc)},
)
for page in pages_data:
if not page.get("image_prompt") or page.get("image_url"):
continue
try:
page["image_url"] = await generate_image(page["image_prompt"], db=db)
page["image_url"] = await generate_image(
page["image_prompt"],
db=db,
user_id=story.user_id,
generation_job=job,
story_id=story.id,
)
page_number = page.get("page_number")
if isinstance(page_number, int):
completed_pages.append(page_number)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="storybook_page_image_succeeded",
status="succeeded",
message="Storybook page image was generated.",
metadata={"asset": "image", "scope": "page", "page_number": page_number},
)
except Exception as exc:
page_number = page.get("page_number")
if isinstance(page_number, int):
@@ -428,6 +682,20 @@ async def _complete_storybook_image_assets(
page=page_number,
error=str(exc),
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="storybook_page_image_failed",
status="failed",
message="Storybook page image generation failed.",
metadata={
"asset": "image",
"scope": "page",
"page_number": page_number,
"error": str(exc),
},
)
story.pages = pages_data
error_message = _build_storybook_error_message(
@@ -446,12 +714,26 @@ async def _complete_storybook_image_assets(
last_error=error_message,
)
await db.commit()
return AssetCompletionResult(
result = AssetCompletionResult(
asset="storybook_images",
status=image_status,
value=story.image_url,
error=error_message,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="storybook_images_completed",
status="failed" if error_message else "succeeded",
message="Storybook image completion finished.",
metadata={
**_asset_result_metadata(result),
"completed_pages": sorted(completed_pages),
"failed_pages": sorted(failed_pages),
},
)
return result
async def _read_cached_audio_asset(story: Story, db: AsyncSession) -> bytes | None:
@@ -482,6 +764,7 @@ async def _complete_audio_asset(
db: AsyncSession,
*,
raise_on_failure: bool = True,
job=None,
) -> AssetCompletionResult:
"""Complete TTS audio generation through one asset workflow."""
@@ -490,32 +773,67 @@ async def _complete_audio_asset(
cached_audio = await _read_cached_audio_asset(story, db)
if cached_audio is not None:
return AssetCompletionResult(
result = AssetCompletionResult(
asset="audio",
status=StoryAssetStatus.READY,
value=cached_audio,
blocks_main_result=raise_on_failure,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="audio_cache_hit",
status="succeeded",
message="Cached story audio was reused.",
metadata=_asset_result_metadata(result),
)
return result
from app.services.provider_router import text_to_speech
sync_story_status(story, audio_status=StoryAssetStatus.GENERATING)
await db.commit()
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="audio_started",
status="running",
message="Story audio generation started.",
metadata={"asset": "audio"},
)
try:
audio_data = await text_to_speech(story.story_text, db=db)
audio_data = await text_to_speech(
story.story_text,
db=db,
user_id=story.user_id,
generation_job=job,
story_id=story.id,
)
story.audio_path = write_story_audio_cache(story.id, audio_data)
sync_story_status(
story,
audio_status=StoryAssetStatus.READY,
)
await db.commit()
return AssetCompletionResult(
result = AssetCompletionResult(
asset="audio",
status=StoryAssetStatus.READY,
value=audio_data,
blocks_main_result=raise_on_failure,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="audio_succeeded",
status="succeeded",
message="Story audio was generated and cached.",
metadata=_asset_result_metadata(result),
)
return result
except Exception as exc:
provider_error = str(exc)
story.audio_path = None
@@ -527,18 +845,28 @@ async def _complete_audio_asset(
await db.commit()
logger.error("audio_generation_failed", story_id=story.id, error=provider_error)
result = AssetCompletionResult(
asset="audio",
status=StoryAssetStatus.FAILED,
error=provider_error,
blocks_main_result=raise_on_failure,
)
await _record_job_event_if_present(
db,
job=job,
story_id=story.id,
event_type="audio_failed",
status="failed",
message="Story audio generation failed.",
metadata=_asset_result_metadata(result),
)
if raise_on_failure:
raise HTTPException(
status_code=500,
detail=f"Audio generation failed: {provider_error}",
) from exc
return AssetCompletionResult(
asset="audio",
status=StoryAssetStatus.FAILED,
error=provider_error,
blocks_main_result=raise_on_failure,
)
return result
async def validate_profile_and_universe(
@@ -586,6 +914,8 @@ async def generate_and_save_story(
request: GenerateRequest,
user_id: str,
db: AsyncSession,
*,
job=None,
) -> Story:
"""Generate generic story content and save to DB."""
profile_id, universe_id, memory_context = await _prepare_generation_context(
@@ -593,21 +923,32 @@ async def generate_and_save_story(
universe_id=request.universe_id,
user_id=user_id,
db=db,
job=job,
)
try:
result = await generate_story_content(
input_type=request.type,
data=request.data,
education_theme=request.education_theme,
memory_context=memory_context,
db=db,
)
education_theme=request.education_theme,
memory_context=memory_context,
db=db,
user_id=user_id,
generation_job=job,
)
except Exception as exc:
raise HTTPException(
status_code=502,
detail="Story generation failed, please try again.",
) from exc
await _record_job_event_if_present(
db,
job=job,
event_type="narrative_generated",
status="succeeded",
message="Story narrative was generated.",
metadata={"mode": result.mode, "title": result.title},
)
return await _persist_text_story_result(
result=result,
@@ -615,6 +956,7 @@ async def generate_and_save_story(
profile_id=profile_id,
universe_id=universe_id,
db=db,
job=job,
)
@@ -622,9 +964,11 @@ async def generate_full_story_service(
request: GenerateRequest,
user_id: str,
db: AsyncSession,
*,
job=None,
) -> FullStoryResponse:
"""Generate story with parallel image generation."""
story = await generate_and_save_story(request, user_id, db)
story = await generate_and_save_story(request, user_id, db, job=job)
image_url: str | None = None
errors: dict[str, str | None] = {}
@@ -633,6 +977,7 @@ async def generate_full_story_service(
story,
db,
log_event="image_generation_failed",
job=job,
)
if image_result.succeeded and isinstance(image_result.value, str):
image_url = image_result.value
@@ -651,6 +996,7 @@ async def generate_full_story_service(
child_profile_id=story.child_profile_id,
universe_id=story.universe_id,
generation_status=story.generation_status,
text_status=story.text_status,
image_status=story.image_status,
audio_status=story.audio_status,
last_error=story.last_error,
@@ -662,6 +1008,8 @@ async def generate_storybook_service(
request: StorybookRequest,
user_id: str,
db: AsyncSession,
*,
job=None,
) -> StorybookResponse:
"""Generate storybook with parallel image generation for pages."""
profile_id, universe_id, memory_context = await _prepare_generation_context(
@@ -669,6 +1017,7 @@ async def generate_storybook_service(
universe_id=request.universe_id,
user_id=user_id,
db=db,
job=job,
)
logger.info(
@@ -684,13 +1033,27 @@ async def generate_storybook_service(
storybook = await generate_storybook(
keywords=request.keywords,
page_count=request.page_count,
education_theme=request.education_theme,
memory_context=memory_context,
db=db,
)
except Exception as e:
education_theme=request.education_theme,
memory_context=memory_context,
db=db,
user_id=user_id,
generation_job=job,
)
except Exception as e:
logger.error("storybook_generation_failed", error=str(e))
raise HTTPException(status_code=500, detail=f"故事书生成失败: {e}")
await _record_job_event_if_present(
db,
job=job,
event_type="narrative_generated",
status="succeeded",
message="Storybook narrative and page plan were generated.",
metadata={
"mode": "storybook",
"title": storybook.title,
"page_count": len(storybook.pages),
},
)
final_cover_url = storybook.cover_url
cover_failed = False
@@ -701,7 +1064,12 @@ async def generate_storybook_service(
final_cover_url,
cover_failed,
failed_pages,
) = await _generate_storybook_image_assets(storybook, db)
) = await _generate_storybook_image_assets(
storybook,
db,
user_id=user_id,
job=job,
)
story, pages_data = await _persist_storybook_result(
storybook=storybook,
@@ -713,6 +1081,7 @@ async def generate_storybook_service(
cover_failed=cover_failed,
failed_pages=failed_pages,
db=db,
job=job,
)
response_pages = _storybook_pages_to_response(pages_data)
@@ -726,6 +1095,7 @@ async def generate_storybook_service(
cover_prompt=storybook.cover_prompt,
cover_url=final_cover_url,
generation_status=story.generation_status,
text_status=story.text_status,
image_status=story.image_status,
audio_status=story.audio_status,
last_error=story.last_error,
@@ -797,6 +1167,7 @@ async def _generate_generation_service_with_job(
),
user_id,
db,
job=job,
)
if storybook.id is None:
raise HTTPException(status_code=500, detail="Storybook generation did not persist.")
@@ -812,6 +1183,7 @@ async def _generate_generation_service_with_job(
)
return GenerationResponse(
id=storybook.id,
generation_job_id=job.id,
title=storybook.title,
mode="storybook",
pages=storybook.pages,
@@ -821,6 +1193,7 @@ async def _generate_generation_service_with_job(
main_character=storybook.main_character,
art_style=storybook.art_style,
generation_status=storybook.generation_status,
text_status=saved_story.text_status,
image_status=storybook.image_status,
audio_status=storybook.audio_status,
last_error=storybook.last_error,
@@ -838,7 +1211,7 @@ async def _generate_generation_service_with_job(
)
if request.generate_images:
story = await generate_full_story_service(generate_request, user_id, db)
story = await generate_full_story_service(generate_request, user_id, db, job=job)
saved_story = await get_story_detail(story.id, user_id, db)
await _record_postprocessing_event_if_needed(db, job=job, story=saved_story)
await finish_generation_job(
@@ -850,6 +1223,7 @@ async def _generate_generation_service_with_job(
)
return GenerationResponse(
id=story.id,
generation_job_id=job.id,
title=story.title,
mode=story.mode,
story_text=story.story_text,
@@ -859,6 +1233,7 @@ async def _generate_generation_service_with_job(
audio_ready=story.audio_ready,
errors=story.errors,
generation_status=story.generation_status,
text_status=saved_story.text_status,
image_status=story.image_status,
audio_status=story.audio_status,
last_error=story.last_error,
@@ -867,7 +1242,7 @@ async def _generate_generation_service_with_job(
retryable_assets=saved_story.retryable_assets,
)
story = await generate_and_save_story(generate_request, user_id, db)
story = await generate_and_save_story(generate_request, user_id, db, job=job)
await _record_postprocessing_event_if_needed(db, job=job, story=story)
await finish_generation_job(
db,
@@ -878,6 +1253,7 @@ async def _generate_generation_service_with_job(
)
return GenerationResponse(
id=story.id,
generation_job_id=job.id,
title=story.title,
mode=story.mode,
story_text=story.story_text,
@@ -885,6 +1261,7 @@ async def _generate_generation_service_with_job(
image_url=story.image_url,
cover_url=story.image_url,
generation_status=story.generation_status,
text_status=story.text_status,
image_status=story.image_status,
audio_status=story.audio_status,
last_error=story.last_error,
@@ -954,7 +1331,7 @@ async def create_story_from_result(
)
async def _retry_cover_image_asset(story: Story, db: AsyncSession) -> None:
async def _retry_cover_image_asset(story: Story, db: AsyncSession, *, job=None) -> None:
"""Retry cover generation for a text story."""
await _complete_cover_image_asset(
@@ -962,19 +1339,25 @@ async def _retry_cover_image_asset(story: Story, db: AsyncSession) -> None:
db,
last_error_prefix="封面生成失败",
log_event="cover_asset_retry_failed",
job=job,
)
async def _retry_storybook_image_assets(story: Story, db: AsyncSession) -> None:
async def _retry_storybook_image_assets(
story: Story,
db: AsyncSession,
*,
job=None,
) -> None:
"""Retry missing storybook cover/page images."""
await _complete_storybook_image_assets(story, db)
await _complete_storybook_image_assets(story, db, job=job)
async def _retry_audio_asset(story: Story, db: AsyncSession) -> None:
async def _retry_audio_asset(story: Story, db: AsyncSession, *, job=None) -> None:
"""Retry audio generation while preserving persisted status on provider failure."""
await _complete_audio_asset(story, db, raise_on_failure=False)
await _complete_audio_asset(story, db, raise_on_failure=False, job=job)
async def retry_story_assets(
@@ -1009,12 +1392,12 @@ async def retry_story_assets(
if "image" in requested_assets:
if story.mode == "storybook":
await _retry_storybook_image_assets(story, db)
await _retry_storybook_image_assets(story, db, job=job)
else:
await _retry_cover_image_asset(story, db)
await _retry_cover_image_asset(story, db, job=job)
if "audio" in requested_assets:
await _retry_audio_asset(story, db)
await _retry_audio_asset(story, db, job=job)
story = await get_story_detail(story_id, user_id, db)
await finish_generation_job(
@@ -1075,6 +1458,7 @@ async def generate_story_cover(
db,
raise_on_failure=True,
log_event="cover_generation_failed",
job=job,
)
story = await get_story_detail(story_id, user_id, db)
await finish_generation_job(
@@ -1121,7 +1505,12 @@ async def generate_story_audio(
try:
story = await get_story_detail(story_id, user_id, db)
audio_result = await _complete_audio_asset(story, db, raise_on_failure=True)
audio_result = await _complete_audio_asset(
story,
db,
raise_on_failure=True,
job=job,
)
story = await get_story_detail(story_id, user_id, db)
await finish_generation_job(
db,

View File

@@ -10,6 +10,7 @@ class StoryGenerationStatus(str, Enum):
"""Overall story generation lifecycle."""
NARRATIVE_READY = "narrative_ready"
PARTIAL_READY = "partial_ready"
ASSETS_GENERATING = "assets_generating"
COMPLETED = "completed"
DEGRADED_COMPLETED = "degraded_completed"
@@ -30,7 +31,10 @@ class StoryLike(Protocol):
story_text: str | None
pages: list[dict] | None
cover_prompt: str | None
image_url: str | None
generation_status: str
text_status: str
image_status: str
audio_status: str
last_error: str | None
@@ -55,6 +59,37 @@ def has_narrative_content(story: StoryLike) -> bool:
return bool(story.story_text) or bool(story.pages)
def _has_retryable_image(story: StoryLike, image_status: StoryAssetStatus) -> bool:
if image_status in {StoryAssetStatus.READY, StoryAssetStatus.GENERATING}:
return False
pages = story.pages or []
has_missing_page_image = any(
isinstance(page, dict)
and page.get("image_prompt")
and not page.get("image_url")
for page in pages
)
return bool(story.cover_prompt and not story.image_url) or has_missing_page_image
def _has_pending_assets(
story: StoryLike,
*,
image_status: StoryAssetStatus,
audio_status: StoryAssetStatus,
) -> bool:
"""Whether readable content still has optional assets to complete."""
if _has_retryable_image(story, image_status):
return True
return bool(story.story_text) and audio_status not in {
StoryAssetStatus.READY,
StoryAssetStatus.GENERATING,
}
def resolve_story_generation_status(story: StoryLike) -> StoryGenerationStatus:
"""Derive the overall status from narrative and asset states."""
@@ -70,6 +105,9 @@ def resolve_story_generation_status(story: StoryLike) -> StoryGenerationStatus:
if StoryAssetStatus.FAILED in (image_status, audio_status):
return StoryGenerationStatus.DEGRADED_COMPLETED
if _has_pending_assets(story, image_status=image_status, audio_status=audio_status):
return StoryGenerationStatus.PARTIAL_READY
if (
image_status == StoryAssetStatus.NOT_REQUESTED
and audio_status == StoryAssetStatus.NOT_REQUESTED
@@ -105,6 +143,12 @@ def sync_story_status(
if last_error is not _ERROR_UNSET:
story.last_error = last_error
story.text_status = (
StoryAssetStatus.READY.value
if has_narrative_content(story)
else StoryAssetStatus.FAILED.value
)
generation_status = resolve_story_generation_status(story)
story.generation_status = generation_status.value

View File

@@ -66,7 +66,8 @@ async def test_story(db_session: AsyncSession, test_user: User) -> Story:
story_text="从前有一只小兔子。",
cover_prompt="A cute rabbit in a forest",
mode="generated",
generation_status="narrative_ready",
generation_status="partial_ready",
text_status="ready",
image_status="not_requested",
audio_status="not_requested",
)
@@ -102,6 +103,7 @@ async def storybook_story(db_session: AsyncSession, test_user: User) -> Story:
image_url="https://example.com/storybook-cover.png",
mode="storybook",
generation_status="degraded_completed",
text_status="ready",
image_status="failed",
audio_status="not_requested",
last_error="第 2 页插图生成失败",
@@ -123,6 +125,7 @@ async def degraded_story_with_text(db_session: AsyncSession, test_user: User) ->
cover_prompt="A rabbit under the moon",
mode="generated",
generation_status="degraded_completed",
text_status="ready",
image_status="failed",
audio_status="not_requested",
last_error="封面生成失败",

View File

@@ -1,5 +1,7 @@
"""Generation job tracking tests."""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
@@ -7,10 +9,37 @@ from sqlalchemy import select
from app.db.database import get_db
from app.db.models import GenerationJob, GenerationJobEvent
from app.main import app
from app.services.adapters import AdapterConfig
from app.services.adapters.storybook.primary import Storybook, StorybookPage
from app.services.adapters.text.models import StoryOutput
from app.services.generation_jobs import create_generation_job, record_generation_event
pytestmark = pytest.mark.asyncio
def build_storybook_output() -> Storybook:
"""Create a reusable mocked storybook payload."""
return Storybook(
title="森林里的发光冒险",
main_character="小兔子露露",
art_style="温暖水彩",
cover_prompt="A glowing forest storybook cover",
pages=[
StorybookPage(
page_number=1,
text="露露第一次走进会发光的森林。",
image_prompt="Lulu entering a glowing forest",
),
StorybookPage(
page_number=2,
text="她遇到了一只会唱歌的萤火虫。",
image_prompt="Lulu meeting a singing firefly",
),
],
)
async def test_unified_generation_records_job_events_and_retryable_assets(
db_session,
test_user,
@@ -39,8 +68,9 @@ async def test_unified_generation_records_job_events_and_retryable_assets(
assert response.status_code == 200
data = response.json()
assert data["generation_status"] == "narrative_ready"
assert data["generation_status"] == "partial_ready"
assert data["retryable_assets"] == ["image", "audio"]
assert data["generation_job_id"]
jobs = (
await db_session.execute(
@@ -55,6 +85,7 @@ async def test_unified_generation_records_job_events_and_retryable_assets(
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 = (
await db_session.execute(
@@ -65,8 +96,37 @@ async def test_unified_generation_records_job_events_and_retryable_assets(
).scalars().all()
assert [event.event_type for event in events] == [
"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}")
assert detail_response.status_code == 200
detail = detail_response.json()
assert detail["id"] == job.id
assert detail["story_id"] == data["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",
"context_prepared",
"narrative_generated",
"story_saved",
"generation_completed",
]
list_response = await client.get(f"/api/generations/{data['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:
app.dependency_overrides.clear()
@@ -122,7 +182,252 @@ async def test_asset_retry_records_job_events_and_updates_retryable_assets(
assert [event.event_type for event in events] == [
"request_accepted",
"asset_retry_started",
"cover_image_started",
"cover_image_succeeded",
"asset_retry_completed",
]
assert events[3].event_metadata["asset"] == "cover_image"
finally:
app.dependency_overrides.clear()
async def test_storybook_generation_records_page_image_events(
db_session,
auth_token,
):
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
try:
with patch(
"app.services.story_service.generate_storybook",
new_callable=AsyncMock,
) as mock_storybook:
with patch(
"app.services.story_service.generate_image",
new_callable=AsyncMock,
) as mock_image:
mock_storybook.return_value = build_storybook_output()
mock_image.side_effect = [
"https://example.com/storybook-cover.png",
"https://example.com/storybook-page-1.png",
"https://example.com/storybook-page-2.png",
]
async with AsyncClient(transport=transport, base_url="http://test") as client:
client.cookies.set("access_token", auth_token)
response = await client.post(
"/api/generations",
json={
"output_mode": "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",
)
)
).scalar_one()
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",
"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[7].event_metadata["completed_pages"] == [1, 2]
finally:
app.dependency_overrides.clear()
async def test_provider_call_events_record_latency_and_cost(
db_session,
test_user,
):
from app.services import provider_router
mock_result = StoryOutput(
mode="generated",
title="带供应商轨迹的故事",
story_text="一只小鹿学会了复盘。",
cover_prompt_suggestion="A deer with a golden bookmark",
)
class MockAdapter:
estimated_cost = 0.0123
def __init__(self, config):
self.config = config
async def execute(self, **kwargs):
return mock_result
job = await create_generation_job(
db_session,
user_id=test_user.id,
output_mode="story",
input_type="keywords",
request_payload={"data": "小鹿"},
)
with patch.object(
provider_router,
"_get_providers_with_config",
new_callable=AsyncMock,
) as mock_providers:
mock_providers.return_value = [("demo", AdapterConfig(api_key=""), None)]
with patch.object(provider_router.AdapterRegistry, "get", return_value=MockAdapter):
result = await provider_router.generate_story_content(
input_type="keywords",
data="小鹿",
db=db_session,
generation_job=job,
)
assert result == mock_result
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",
"provider_call_started",
"provider_call_succeeded",
]
provider_event = events[2]
assert provider_event.event_metadata["capability"] == "text"
assert provider_event.event_metadata["adapter"] == "demo"
assert provider_event.event_metadata["strategy"] == "priority"
assert provider_event.event_metadata["latency_ms"] >= 0
assert provider_event.event_metadata["estimated_cost_usd"] == 0.0123
async def test_story_provider_stats_aggregate_job_events(
db_session,
auth_token,
degraded_story_with_text,
):
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
job = await create_generation_job(
db_session,
user_id=degraded_story_with_text.user_id,
output_mode="asset_retry",
input_type="image",
request_payload={"assets": ["image"]},
story_id=degraded_story_with_text.id,
)
await record_generation_event(
db_session,
job=job,
story_id=degraded_story_with_text.id,
event_type="provider_call_succeeded",
status="succeeded",
metadata={
"capability": "image",
"adapter": "demo",
"strategy": "priority",
"latency_ms": 42,
"estimated_cost_usd": 0.01,
},
)
await record_generation_event(
db_session,
job=job,
story_id=degraded_story_with_text.id,
event_type="provider_call_failed",
status="failed",
metadata={
"capability": "image",
"adapter": "cqtai",
"strategy": "priority",
"latency_ms": 120,
"estimated_cost_usd": 0.02,
"error": "timeout",
},
)
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
client.cookies.set("access_token", auth_token)
response = await client.get(
f"/api/generations/{degraded_story_with_text.id}/provider-stats"
)
assert response.status_code == 200
data = response.json()
assert data["story_id"] == degraded_story_with_text.id
assert data["total_calls"] == 2
assert data["successful_calls"] == 1
assert data["failed_calls"] == 1
assert data["avg_latency_ms"] == 81.0
assert data["estimated_cost_usd"] == 0.01
assert data["by_provider"] == [
{
"capability": "image",
"adapter": "cqtai",
"call_count": 1,
"success_count": 0,
"failure_count": 1,
"avg_latency_ms": 120.0,
"estimated_cost_usd": 0.0,
},
{
"capability": "image",
"adapter": "demo",
"call_count": 1,
"success_count": 1,
"failure_count": 0,
"avg_latency_ms": 42.0,
"estimated_cost_usd": 0.01,
},
]
finally:
app.dependency_overrides.clear()

View File

@@ -76,7 +76,8 @@ class TestStoryGenerate:
assert "title" in data
assert "story_text" in data
assert data["mode"] == "generated"
assert data["generation_status"] == "narrative_ready"
assert data["generation_status"] == "partial_ready"
assert data["text_status"] == "ready"
assert data["image_status"] == "not_requested"
assert data["audio_status"] == "not_requested"
assert data["last_error"] is None
@@ -101,7 +102,8 @@ class TestStoryList:
assert len(data) == 1
assert data[0]["id"] == test_story.id
assert data[0]["title"] == test_story.title
assert data[0]["generation_status"] == "narrative_ready"
assert data[0]["generation_status"] == "partial_ready"
assert data[0]["text_status"] == "ready"
assert data[0]["image_status"] == "not_requested"
assert data[0]["audio_status"] == "not_requested"
@@ -133,7 +135,8 @@ class TestStoryDetail:
assert data["id"] == test_story.id
assert data["title"] == test_story.title
assert data["story_text"] == test_story.story_text
assert data["generation_status"] == "narrative_ready"
assert data["generation_status"] == "partial_ready"
assert data["text_status"] == "ready"
assert data["image_status"] == "not_requested"
assert data["audio_status"] == "not_requested"
assert data["last_error"] is None
@@ -250,7 +253,7 @@ class TestAudio:
detail_response = auth_client.get(f"/api/stories/{test_story.id}")
detail = detail_response.json()
assert detail["audio_status"] == "ready"
assert detail["generation_status"] == "completed"
assert detail["generation_status"] == "partial_ready"
assert detail["last_error"] is None
def test_get_audio_regenerates_when_cache_file_is_missing(
@@ -335,7 +338,7 @@ class TestGenerateFull:
assert data["image_url"] == "https://example.com/image.png"
assert data["audio_ready"] is False
assert data["errors"] == {}
assert data["generation_status"] == "completed"
assert data["generation_status"] == "partial_ready"
assert data["image_status"] == "ready"
assert data["audio_status"] == "not_requested"
assert data["last_error"] is None
@@ -412,7 +415,7 @@ class TestUnifiedGenerations:
assert data["image_url"] == "https://example.com/image.png"
assert data["cover_url"] == "https://example.com/image.png"
assert data["pages"] is None
assert data["generation_status"] == "completed"
assert data["generation_status"] == "partial_ready"
assert data["image_status"] == "ready"
assert data["audio_status"] == "not_requested"
assert data["errors"] == {}
@@ -436,7 +439,7 @@ class TestUnifiedGenerations:
data = response.json()
assert data["mode"] == "generated"
assert data["image_url"] is None
assert data["generation_status"] == "narrative_ready"
assert data["generation_status"] == "partial_ready"
assert data["image_status"] == "not_requested"
def test_create_story_generation_image_failure(
@@ -530,7 +533,7 @@ class TestUnifiedGenerations:
assert response.status_code == 200
data = response.json()
assert data["image_url"] == "https://example.com/image.png"
assert data["generation_status"] == "completed"
assert data["generation_status"] == "partial_ready"
assert data["image_status"] == "ready"
@@ -551,7 +554,7 @@ class TestImageGenerateSuccess:
)
data = response.json()
assert data["image_url"] == "https://example.com/image.png"
assert data["generation_status"] == "completed"
assert data["generation_status"] == "partial_ready"
assert data["image_status"] == "ready"
assert data["audio_status"] == "not_requested"
assert data["last_error"] is None
@@ -578,7 +581,7 @@ class TestAssetRetry:
)
data = response.json()
assert data["image_url"] == "https://example.com/image.png"
assert data["generation_status"] == "completed"
assert data["generation_status"] == "partial_ready"
assert data["image_status"] == "ready"
assert data["audio_status"] == "not_requested"
assert data["last_error"] is None
@@ -629,7 +632,7 @@ class TestAssetRetry:
assert response.status_code == 200
data = response.json()
assert data["generation_status"] == "completed"
assert data["generation_status"] == "partial_ready"
assert data["image_status"] == "not_requested"
assert data["audio_status"] == "ready"
assert data["last_error"] is None

View File

@@ -0,0 +1,109 @@
"""Tests for derived story generation statuses."""
from types import SimpleNamespace
from app.services.story_status import (
StoryAssetStatus,
StoryGenerationStatus,
resolve_story_generation_status,
sync_story_status,
)
def make_story(**overrides):
data = {
"story_text": "Once upon a time.",
"pages": None,
"cover_prompt": "A warm forest cover",
"image_url": None,
"generation_status": "narrative_ready",
"text_status": "ready",
"image_status": "not_requested",
"audio_status": "not_requested",
"last_error": None,
}
data.update(overrides)
return SimpleNamespace(**data)
def test_text_story_without_assets_is_partial_ready():
story = make_story()
sync_story_status(story)
assert story.text_status == "ready"
assert story.generation_status == StoryGenerationStatus.PARTIAL_READY.value
def test_text_story_with_all_assets_is_completed():
story = make_story(
image_url="https://example.com/cover.png",
image_status="ready",
audio_status="ready",
)
assert resolve_story_generation_status(story) == StoryGenerationStatus.COMPLETED
def test_failed_asset_keeps_readable_story_degraded():
story = make_story(image_status="failed", last_error="cover failed")
sync_story_status(story)
assert story.text_status == "ready"
assert story.generation_status == StoryGenerationStatus.DEGRADED_COMPLETED.value
assert story.last_error == "cover failed"
def test_storybook_missing_page_image_is_partial_ready():
story = make_story(
story_text=None,
pages=[
{
"page_number": 1,
"text": "Page one",
"image_prompt": "Page one image",
"image_url": "https://example.com/page-1.png",
},
{
"page_number": 2,
"text": "Page two",
"image_prompt": "Page two image",
"image_url": None,
},
],
cover_prompt="Storybook cover",
image_url="https://example.com/cover.png",
image_status="not_requested",
)
assert resolve_story_generation_status(story) == StoryGenerationStatus.PARTIAL_READY
def test_storybook_with_all_images_is_completed():
story = make_story(
story_text=None,
pages=[
{
"page_number": 1,
"text": "Page one",
"image_prompt": "Page one image",
"image_url": "https://example.com/page-1.png",
},
],
cover_prompt="Storybook cover",
image_url="https://example.com/cover.png",
image_status="ready",
audio_status="not_requested",
)
assert resolve_story_generation_status(story) == StoryGenerationStatus.COMPLETED
def test_missing_narrative_sets_text_failed():
story = make_story(story_text=None, pages=None)
sync_story_status(story, image_status=StoryAssetStatus.NOT_REQUESTED)
assert story.text_status == "failed"
assert story.generation_status == StoryGenerationStatus.FAILED.value

View File

@@ -39,7 +39,7 @@
旧生成 API 兼容层策略。用于说明历史接口如何迁移到统一 generation 入口。
- `technical/generation-job-state.md`
Generation Job 状态落库决策。用于说明当前为什么先复用 story 状态,何时再拆 job/event 表
Generation Job 状态落库决策。用于说明 job/event 如何追踪 workflow、资产补全、provider 调用和查询入口
- `technical/provider-routing.md`
Provider Routing 技术说明。用于解释 Capability / Provider / Adapter / Routing Policy 的职责边界。

View File

@@ -52,9 +52,15 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh
- [ ] admin-backend health 返回 `ok`
- [ ] dev login 能拿到 session
- [ ] `/api/generations` 能生成普通故事
- [ ] 普通故事生成响应返回 `generation_job_id`,且 job 事件可查询
- [ ] 普通故事 provider stats 返回成功率、耗时和成本字段
- [ ] 普通故事封面 retry 后 `image_status=ready`
- [ ] 故事详情页能看到生成轨迹和 Provider 调用结果
- [ ] `/api/generations` 能生成绘本
- [ ] 绘本生成响应返回 `generation_job_id`,且 story job history 可查询
- [ ] 绘本 provider stats 返回成功率、耗时和成本字段
- [ ] 绘本图片 retry 后 `image_status=ready`
- [ ] 绘本阅读页能看到生成轨迹和资源重试历史
- [ ] `/admin/providers/capabilities` 返回 `text/image/tts/storybook`
- [ ] 如果启用 `SMOKE_AUDIO=1`,音频 retry 后 `audio_status=ready`
- [ ] 验证结果已记录到 `docs/planning/demo-validation-log.md`
@@ -117,7 +123,7 @@ DreamWeaver 是面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴式讲
### 2:20 - 3:00 取舍与下一步
求职版优先稳定闭环和可解释性,不做支付、多租户和复杂监控。下一步会继续打磨前端状态体验、旧 API 兼容策略和 generation job 是否落库
求职版优先稳定闭环和可解释性,不做支付、多租户和复杂监控。现在 job/event 已能查询 workflow、资产补全、provider 调用轨迹和聚合指标,用户端和管理端也能展示生成轨迹;下一步会迁移到后台 worker 和进度轮询
---
@@ -126,6 +132,7 @@ DreamWeaver 是面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴式讲
| 风险 | 现场处理 |
| --- | --- |
| 网络导致 TTS 失败 | 说明音频是可恢复资产,不阻塞故事阅读;使用已缓存样本或跳过 TTS |
| 图片 provider 未补全 | 展示 partial ready说明主内容已可读、资产可稍后补全 |
| 图片 provider 失败 | 展示 degraded completed 与 retry 机制 |
| Docker 冷启动慢 | 演示前提前运行 smoke 脚本并保持容器运行 |
| Admin 页面不适合主展示 | 只用 Provider 分层说明辅助讲系统设计 |

View File

@@ -4,6 +4,15 @@
## 2026-04-18
补充验证:
- 后端新增 `partial_ready``text_status` 与迁移 `0012_story_text_status` 后,`backend/.venv/bin/python -m pytest backend/tests -q` 通过82 个测试通过。
- `backend/.venv/bin/python -m ruff check backend/app backend/tests backend/alembic/versions/0012_add_story_text_status_and_partial_ready.py` 通过。
- 用户端与管理端 `npm run build` 均通过。
- `docker compose up -d --build` 已用当前代码重建本地演示栈。
- 当前本地 Docker 数据卷来自早期 `create_all`,缺少 `alembic_version` 且旧 `stories` 表没有 `text_status`;本轮已为演示库补齐 `text_status`、回填状态,并 `alembic stamp head``0012_story_text_status`
- `./scripts/demo_smoke.sh` 通过:普通故事以 `partial_ready` 可读返回,封面补全后仍可读且音频待补;绘本无图时 `partial_ready`,插图补全后 `completed`generation job、story job history 和 provider stats 均可查询。
验证范围:
- 用户前端 Docker 生产构建

View File

@@ -31,10 +31,13 @@ AI 生成产品最大的问题不是“能不能调模型”,而是结果不
后端通过统一状态字段表达结果:
- `generation_status`
- `text_status`
- `image_status`
- `audio_status`
- `last_error`
其中 `partial_ready` 表示主内容已经可读但资产还可以继续补全,`degraded_completed` 表示主内容可读但某个资产失败,需要用户稍后重试。
服务层也抽出了 `AssetCompletionResult`,用来表达资产补全类型、状态、结果值、错误信息和是否阻塞主结果。
---
@@ -58,7 +61,7 @@ AI 生成产品最大的问题不是“能不能调模型”,而是结果不
目前本地 Docker 可以跑通完整链路,并且有 smoke 脚本验证健康检查、登录、生成、资产重试、故事列表和 Provider 能力分层。
下一步我会继续打磨前端状态体验,让生成中、部分完成、失败重试这些 AI 产品特有状态更清楚;同时明确旧 API 兼容层和 generation job 是否需要落库
现在 generation job 已经能查询完整事件流,包括 workflow、资产补全和 provider 调用;用户端和管理端都能展示生成轨迹,也能看到 provider 成功率、耗时和成本视角
我希望通过这个项目展示的是:我不只是会接 AI API而是能把不确定的模型能力收敛成稳定、可解释、可恢复的产品体验。
@@ -80,4 +83,4 @@ AI 生成产品最大的问题不是“能不能调模型”,而是结果不
### 这个项目下一步怎么上线?
我会先完成演示级前端状态体验和旧 API 兼容策略,再决定 generation job 是否落库。生产上线前还需要补真实用户鉴权配置、密钥管理、监控告警和部署策略。
我会先把当前轻量 job/event 模型迁移到后台 worker 和进度轮询,再补跨时间窗口的 provider 运营分析。生产上线前还需要补真实用户鉴权配置、密钥管理、监控告警和部署策略。

View File

@@ -49,7 +49,7 @@ Week 2 的目标不是做“完整商业产品”,而是做出一个面试时
- 会员、支付、商业化
- 多租户 Provider 市场
- 大规模视觉重做
- 复杂 generation job 落库
- 复杂工作流引擎和生产级任务编排
- 生产级部署、高可用、监控大盘
- 新增大量第三方 Provider
@@ -89,6 +89,7 @@ Week 2 的目标不是做“完整商业产品”,而是做出一个面试时
| W2-14 | Frontend | 同步管理端生成状态与资产补全体验 | 用户端/管理端状态体验不再分叉 | P1 | 0.5d | Done |
| W2-15 | Security | 移除管理后台弱默认密码 | 非 debug 管理后台拒绝空/弱密码 | P1 | 0.5d | Done |
| W2-16 | Workflow | 轻量落库 generation job/event 与 retryable assets | 生成/资产补全过程可追踪,前端按标准字段展示 CTA | P1 | 1.0d | Done |
| W2-17 | Workflow | 落地 `partial_ready``text_status` 细粒度状态 | 主内容可读、资产待补全、资产失败三类状态可明确区分 | P1 | 0.5d | Done |
---
@@ -129,12 +130,13 @@ Week 2 的目标不是做“完整商业产品”,而是做出一个面试时
## 7. Definition of Done
- [ ] smoke 脚本能在本地 Docker 栈中完成健康检查、登录、生成、重试和读回验证。
- [ ] 用户端主链路可手动演示,不需要打开数据库或日志解释状态。
- [ ] 故事和绘本的主要失败降级态有清楚展示和重试方式。
- [ ] README、docs index、演示 checklist 与当前代码一致。
- [ ] 面试讲解能在 3 分钟内说明产品价值、技术工作流和取舍。
- [ ] 全量后端测试、ruff、Docker build 在演示前可通过。
- [x] smoke 脚本能在本地 Docker 栈中完成健康检查、登录、生成、重试和读回验证。
- [x] 用户端主链路可手动演示,不需要打开数据库或日志解释状态。
- [x] 故事和绘本的主要失败降级态有清楚展示和重试方式。
- [x] README、docs index、演示 checklist 与当前代码一致。
- [x] 面试讲解能在 3 分钟内说明产品价值、技术工作流和取舍。
- [x] 全量后端测试、ruff、Docker build 在演示前可通过。
- [x] `partial_ready``text_status`、job progress 和 Provider stats 在 API、前端与文档中保持一致。
---

View File

@@ -144,10 +144,10 @@ DreamWeaver 是一款面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴
**Acceptance Criteria**
- [ ] 用户可以选择孩子档案并输入主题或教育目标
- [ ] 系统能结合档案与宇宙上下文生成故事文本
- [ ] 故事保存后可在故事库中查看
- [ ] 当图片或语音生成失败时,故事文本仍可正常保留并查看
- [x] 用户可以选择孩子档案并输入主题或教育目标
- [x] 系统能结合档案与宇宙上下文生成故事文本
- [x] 故事保存后可在故事库中查看
- [x] 当图片或语音生成失败时,故事文本仍可正常保留并查看
### Story 2: 生成并阅读绘本
@@ -157,10 +157,10 @@ DreamWeaver 是一款面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴
**Acceptance Criteria**
- [ ] 系统支持多页绘本生成
- [ ] 绘本可通过唯一 ID 被再次打开,而不是只依赖前端内存状态
- [ ] 页面刷新或重新进入时,绘本内容仍能恢复
- [ ] 若部分页面插图失败,文本页仍可正常展示
- [x] 系统支持多页绘本生成
- [x] 绘本可通过唯一 ID 被再次打开,而不是只依赖前端内存状态
- [x] 页面刷新或重新进入时,绘本内容仍能恢复
- [x] 若部分页面插图失败,文本页仍可正常展示
### Story 3: 听故事
@@ -170,9 +170,9 @@ DreamWeaver 是一款面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴
**Acceptance Criteria**
- [ ] 故事详情页支持加载和播放语音
- [ ] 同一故事音频应支持缓存或复用,避免重复生成
- [ ] 音频生成失败时,页面应给出明确状态与重试方式
- [x] 故事详情页支持加载和播放语音
- [x] 同一故事音频应支持缓存或复用,避免重复生成
- [x] 音频生成失败时,页面应给出明确状态与重试方式
### Story 4: 管理模型供应与成本风险

View File

@@ -17,7 +17,7 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
## Implementation Snapshot
**Updated**: 2026-04-18 afternoon
**Updated**: 2026-04-18 evening
当前代码已经从“纯目标态设计”进入“部分落地”阶段,主要进展如下:
@@ -25,10 +25,12 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
- `Story` 主记录已持久化以下统一状态相关字段:
- `generation_status`
- `text_status`
- `image_status`
- `audio_status`
- `last_error`
- `audio_path`
- `partial_ready` 已在服务层、迁移、API schema、用户端与管理端状态展示中落地用于表达“主内容可读但仍有封面、插图或音频可补全”
- 已新增轻量可查询的生成过程记录:
- `generation_jobs`
- `generation_job_events`
@@ -42,25 +44,39 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
- `POST /api/generations`
- `GET /api/generations/{story_id}`
- `POST /api/generations/{story_id}/retry-assets`
- `GET /api/generations/jobs/{job_id}`
- `GET /api/generations/{story_id}/jobs`
- `GET /api/generations/{story_id}/provider-stats`
- 用户前端与 admin 前端创建弹窗已切换到 `POST /api/generations`
- service 内部已开始收束统一工作流步骤:
- 上下文准备:档案/宇宙校验 + memory context 构建
- 主记录保存:文本故事与绘本统一持久化入口
- 资产补全:普通故事封面、绘本缺失插图、故事音频缓存/生成统一封装
- 已引入首版服务层 `AssetCompletionResult`,用于表达资产补全类型、状态、结果值、错误信息和是否阻塞主结果
- `generation_job_events` 已从首版请求/完成事件扩展到关键 workflow 节点:
- `context_prepared`
- `narrative_generated`
- `story_saved`
- 普通故事封面开始/成功/失败
- 绘本封面与逐页插图成功/失败
- 音频缓存命中、生成开始、成功和失败
- Provider failover 已记录到 job event包含 capability、adapter、strategy、latency 和 estimated cost
- Provider 调用已可按故事聚合为成功率、平均耗时、预估成本和 adapter 明细
- generation job 响应已提供 `progress_percent``progress_label``is_terminal`,前端可直接用于进度条和轮询
- `POST /api/generations` 响应已返回 `generation_job_id`smoke 脚本会验证 job 查询与 story job history
- 用户端与管理端的故事详情页、绘本阅读页已接入生成轨迹,展示生成/重试任务、关键事件、Provider 调用结果和聚合指标
- 故事详情页封面补全已切换到统一资产重试入口
- 管理端前端构建阻塞已修复,主前端与 admin 前端均可完成生产构建
### Still Missing
### Remaining Production Work
- 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 已开始抽取公共步骤,但旧 service 函数仍作为兼容层保留
- 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图故事音频,并已抽出 asset completion helper 与 `AssetCompletionResult`
- `generation_jobs` 已记录请求、完成、失败和资产重试事件,但尚未扩展到逐 provider 调用、逐页面资产步骤和完整运营分析
- `partial_ready``text_status` 等更细粒度状态仍停留在目标态
- 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 仍可继续减少兼容层分支
- 统一资产重试入口已覆盖普通故事封面、绘本缺失插图故事音频,后续可继续扩展更细的资产级审计
- 后台异步 worker 进度流、跨故事 Provider 运营分析和断点续跑仍属于后续生产化增强
### What This Means
这份 PRD 仍然目标态文档,但它对应的主干方向已经不是纸面方案。当前最适合的继续方式,不是重写文档,而是继续把 service workflow 和资产补全过程收拢成统一实现
这份 PRD 仍然保留目标态设计,但主干能力已经可在当前代码中演示。当前最适合的继续方式,是继续把同步请求迁移到可复用的后台任务与运营分析视角,而是继续扩大功能范围
---
@@ -71,13 +87,13 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
DreamWeaver 当前存在以下工作流层面问题:
1. **生成入口已建立,内部路径正在收束**
当前前端已切到 `/api/generations`,旧的 `/api/stories/generate``/api/stories/generate/full``/api/storybook/generate` 仍作为兼容入口保留。service 内部已抽取上下文准备、主记录保存、封面补全、绘本插图补全和音频补全 helper并用 `AssetCompletionResult` 表达资产补全结果。下一步重点是决定这些结果是否需要进一步沉淀为可查询的 generation job
当前前端已切到 `/api/generations`,旧的 `/api/stories/generate``/api/stories/generate/full``/api/storybook/generate` 仍作为兼容入口保留。service 内部已抽取上下文准备、主记录保存、封面补全、绘本插图补全和音频补全 helper并用 `AssetCompletionResult` 表达资产补全结果。generation job/event 已落库并可查询Provider 调用轨迹和聚合指标也已进入用户端与管理端展示。下一步重点是为后台异步 worker 与运营成本分析复用这些事件
2. **保存与资产补全过程正在统一**
文本故事和绘本已拥有更清晰的主记录保存 helper普通故事封面、绘本缺失插图、故事音频生成/缓存已共用各自的 asset completion helper。服务层已经能表达资产任务结果剩余差异集中在还没有持久化 job 对象
文本故事和绘本已拥有更清晰的主记录保存 helper普通故事封面、绘本缺失插图、故事音频生成/缓存已共用各自的 asset completion helper。服务层已经能表达资产任务结果并会把统一入口、资产重试、绘本逐页插图和音频生成的关键节点写入 job event
3. **状态表达不统一**
系统缺少标准的“生成中、部分完成、完成、失败可重试”等状态定义,导致前端难以做出成熟体验
3. **状态表达已基本统一,仍需生产化扩展**
当前已经能用 `generation_status``text_status``image_status``audio_status``retryable_assets` 表达生成中、部分可读、完成、降级完成、失败可重试。后续重点是让后台 worker、运营分析和通知系统复用同一套状态语义
4. **失败处理策略不统一**
图片、音频、绘本页生成失败时,系统没有统一的降级定义,用户体验和技术行为都不够稳定。
@@ -165,9 +181,9 @@ DreamWeaver 当前存在以下工作流层面问题:
**Acceptance Criteria**
- [ ] 创建入口支持选择输出类型:普通故事或绘本
- [ ] 系统能根据输入类型走统一流程,而不是完全独立逻辑
- [ ] 用户提交后立即看到生成状态
- [x] 创建入口支持选择输出类型:普通故事或绘本
- [x] 系统能根据输入类型走统一流程,而不是完全独立逻辑
- [x] 用户提交后立即看到生成状态
### Story 2: 获得可用结果
@@ -177,9 +193,9 @@ DreamWeaver 当前存在以下工作流层面问题:
**Acceptance Criteria**
- [ ] 文本生成完成后,主记录应被保存
- [ ] 图片、音频、绘本页可后续补全
- [ ] 即使部分资产失败,用户仍可查看文本结果
- [x] 文本生成完成后,主记录应被保存
- [x] 图片、音频、绘本页可后续补全
- [x] 即使部分资产失败,用户仍可查看文本结果
### Story 3: 恢复历史结果
@@ -189,9 +205,9 @@ DreamWeaver 当前存在以下工作流层面问题:
**Acceptance Criteria**
- [ ] 故事详情页支持按 ID 加载
- [ ] 绘本阅读器支持按 ID 加载
- [ ] 刷新页面不会导致内容丢失
- [x] 故事详情页支持按 ID 加载
- [x] 绘本阅读器支持按 ID 加载
- [x] 刷新页面不会导致内容丢失
### Story 4: 理解系统状态
@@ -201,9 +217,9 @@ DreamWeaver 当前存在以下工作流层面问题:
**Acceptance Criteria**
- [ ] 前端展示统一状态模型
- [ ] 失败原因对用户可解释
- [ ] 可补全资产应有独立重试入口
- [x] 前端展示统一状态模型
- [x] 失败原因对用户可解释
- [x] 可补全资产应有独立重试入口
### Story 5: 以统一方式扩展能力
@@ -213,9 +229,9 @@ DreamWeaver 当前存在以下工作流层面问题:
**Acceptance Criteria**
- [ ] 工作流步骤具备清晰边界
- [x] 工作流步骤具备清晰边界
- [x] 新能力接入时能挂入现有状态模型
- [ ] 不需要再新增完全平行的一套生成接口
- [x] 不需要再新增完全平行的一套生成接口
---
@@ -263,6 +279,8 @@ DreamWeaver 当前存在以下工作流层面问题:
- 每个状态必须有明确进入条件
- 前端可根据状态做 UI 展示
- `degraded_completed` 必须代表“主结果可用,部分资产失败”
- `partial_ready` 必须代表“主结果可读,资产尚未全部完成但没有失败”
- `text_status` 必须只表达主文本或绘本结构是否可读,不被图片、音频状态覆盖
### Feature 3: 统一主记录保存
@@ -466,7 +484,7 @@ DreamWeaver 当前存在以下工作流层面问题:
|------|------------|--------|---------------------|
| 工作流抽象过度 | Medium | High | 先围绕现有故事/绘本/音频场景做最小抽象 |
| 历史接口兼容性问题 | Medium | Medium | 保留兼容入口,内部统一服务实现 |
| 前后端状态模型理解不一致 | High | High | 先写清统一状态表,再进入实现 |
| 前后端状态模型理解不一致 | Medium | High | 通过共享状态 helper、API schema 和回归测试保持一致 |
| Storybook 恢复实现不彻底 | Medium | High | 把“按 ID 加载”作为硬性验收项 |
| 资产状态字段新增引发迁移成本 | Medium | Medium | 允许先在服务层实现,再视需要落库 |
@@ -483,7 +501,7 @@ DreamWeaver 当前存在以下工作流层面问题:
**Known Blockers**
- 统一入口尚未建立
- 当前没有阻塞 MVP 演示的已知问题;后续生产化主要受后台异步化与运营分析范围影响
- 多条生成链路重复实现
---

View File

@@ -21,6 +21,9 @@
- `POST /api/generations`
- `GET /api/generations/{story_id}`
- `POST /api/generations/{story_id}/retry-assets`
- `GET /api/generations/jobs/{job_id}`
- `GET /api/generations/{story_id}/jobs`
- `GET /api/generations/{story_id}/provider-stats`
前端创建弹窗、绘本阅读器、故事详情页的资产重试应优先使用这些入口。

View File

@@ -12,26 +12,37 @@
- `stories` 继续承载用户可见结果和当前状态。
- `generation_jobs` 记录一次生成或资产补全尝试。
- `generation_job_events` 记录关键步骤事件,例如 `request_accepted``generation_completed``asset_retry_started``asset_retry_completed`
- `generation_job_events` 记录关键步骤事件,例如 `request_accepted``context_prepared``narrative_generated``story_saved``cover_image_succeeded``storybook_page_image_succeeded``audio_succeeded``provider_call_succeeded``asset_retry_completed`
当前已提供三个查询入口:
- `GET /api/generations/jobs/{job_id}`:查询单次生成/补全任务及其事件流。
- `GET /api/generations/{story_id}/jobs`:查询某个故事或绘本的生成与重试历史。
- `GET /api/generations/{story_id}/provider-stats`:按故事聚合 Provider 调用成功率、平均耗时、预估成本和 adapter 明细。
job 响应会返回 `progress_percent``progress_label``is_terminal`,用户端与管理端已经消费这些查询入口,在故事详情页和绘本阅读页展示最近任务、任务历史、事件时间线、进度条和 Provider 聚合指标。
## 现有状态模型
当前 `stories` 表已承载演示所需状态:
- `generation_status`: 主流程状态,例如 `narrative_ready``assets_generating``completed``degraded_completed``failed`
- `generation_status`: 主流程状态,例如 `narrative_ready``partial_ready``assets_generating``completed``degraded_completed``failed`
- `text_status`: 主文本或绘本结构状态,当前用于区分主内容是否已经可读
- `image_status`: 封面或绘本插图状态
- `audio_status`: 语音状态
- `last_error`: 最近一次资产失败原因
这些字段足够支撑前端展示、smoke 检查、失败降级和资产重试
`partial_ready` 表示主内容已经可读、但仍有封面、插图或音频可以继续补全;`degraded_completed` 表示主内容可读但至少一个资产失败。两者的区分能让前端把“正常待补全”和“需要重试失败资源”分开展示
这些字段足够支撑前端展示、smoke 检查、失败降级、资产重试和生成轨迹解释。
## 什么时候需要落库 job
如果后续进入真实生产化,需要扩展当前 job/event 模型:
- 生成流程改成真正异步,前端需要轮询 job 进度。
- 单个故事会产生多次生成尝试,需要审计每次 provider 调用
- 需要展示更细颗粒度步骤,例如 prompt 构建、文本生成、封面生成、每页插图、TTS
- 生成流程改成真正异步,前端需要轮询后台 worker 的实时进度。
- 单个故事会产生多次生成尝试,需要对比每次任务的 provider 表现、重试原因和资产结果
- 需要展示比当前事件更细颗粒度步骤,例如 prompt 构建、provider 选择依据、provider failover 原因、每次调用 token/图片/语音成本
- 需要按 provider、成本、延迟和失败原因做运营分析。
- 需要断点续跑,避免 Worker 重启后丢失中间状态。
@@ -39,9 +50,9 @@
当前已有两层记录,未来可以继续扩展字段和事件颗粒度:
- `generation_job_events` 中补 provider、耗时、成本和错误摘要
- 对绘本逐页插图、TTS、后处理任务记录更细事件
- 为前端提供 job 查询接口,用于真正异步生成时轮询进度。
- job/event 查询继续接入真正异步生成时的进度条
- 将当前按故事聚合的 provider 指标扩展为跨用户、跨时间窗口的运营分析
- 将当前同步生成请求迁移到后台 worker 后,复用现有 job 查询接口做轮询进度。
## 面试表达

View File

@@ -0,0 +1,347 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { BoltIcon, ClockIcon } from '@heroicons/vue/24/outline'
import { api } from '../api/client'
import type {
GenerationJobDetail,
GenerationJobEvent,
GenerationJobSummary,
GenerationProviderStats,
} from '../types/generation'
import LoadingSpinner from './ui/LoadingSpinner.vue'
const props = withDefaults(
defineProps<{
storyId: number | null
tone?: 'light' | 'dark'
title?: string
description?: string
}>(),
{
tone: 'light',
title: '生成轨迹',
description: '每次生成和资源重试都会留下事件流便于确认当前结果来自哪次任务、Provider 是否成功、失败是否可恢复。',
},
)
const jobHistory = ref<GenerationJobSummary[]>([])
const activeJob = ref<GenerationJobDetail | null>(null)
const providerStats = ref<GenerationProviderStats | null>(null)
const loading = ref(false)
const error = ref('')
const isDark = computed(() => props.tone === 'dark')
const latestJob = computed(() => jobHistory.value[0] ?? null)
const activeJobEvents = computed(() => activeJob.value?.events.slice(-10) ?? [])
const activeProgress = computed(() => activeJob.value?.progress_percent ?? latestJob.value?.progress_percent ?? 0)
const activeProgressLabel = computed(() => activeJob.value?.progress_label ?? latestJob.value?.progress_label ?? '暂无进度')
const providerSuccessRate = computed(() => {
if (!providerStats.value?.total_calls) return null
return Math.round((providerStats.value.successful_calls / providerStats.value.total_calls) * 100)
})
const containerClass = computed(() => (
isDark.value
? 'border-white/10 bg-white/10 text-white shadow-2xl backdrop-blur'
: 'border-gray-100 bg-white/85 text-gray-900'
))
const mutedTextClass = computed(() => (isDark.value ? 'text-white/65' : 'text-gray-500'))
const panelClass = computed(() => (
isDark.value
? 'border-white/10 bg-black/25'
: 'border-gray-100 bg-gray-50/80'
))
const itemClass = computed(() => (
isDark.value
? 'border-white/10 bg-white/10 hover:bg-white/15'
: 'border-gray-100 bg-white hover:border-gray-300 hover:bg-gray-50'
))
const activeItemClass = computed(() => (
isDark.value
? 'border-white/60 bg-white/20'
: 'border-gray-900 bg-gray-50'
))
const eventCardClass = computed(() => (
isDark.value
? 'border-white/10 bg-white/10'
: 'border-white bg-white'
))
const jobStatusClassMap: Record<string, string> = {
running: 'border-amber-200 bg-amber-50 text-amber-700',
queued: 'border-sky-200 bg-sky-50 text-sky-700',
succeeded: 'border-emerald-200 bg-emerald-50 text-emerald-700',
completed: 'border-emerald-200 bg-emerald-50 text-emerald-700',
degraded_completed: 'border-orange-200 bg-orange-50 text-orange-700',
failed: 'border-rose-200 bg-rose-50 text-rose-700',
}
function getJobStatusClass(status?: string) {
return jobStatusClassMap[status ?? ''] ?? 'border-gray-200 bg-gray-50 text-gray-600'
}
function getJobStatusLabel(status?: string) {
const labels: Record<string, string> = {
running: '进行中',
queued: '已排队',
succeeded: '成功',
completed: '已完成',
degraded_completed: '降级完成',
failed: '失败',
}
return labels[status ?? ''] ?? '未知'
}
function getEventLabel(eventType: string) {
const labels: Record<string, string> = {
request_accepted: '请求接收',
context_prepared: '上下文准备',
narrative_generated: '正文生成',
story_saved: '故事保存',
cover_image_started: '封面开始',
cover_image_succeeded: '封面就绪',
cover_image_failed: '封面失败',
storybook_images_started: '绘本插图开始',
storybook_images_completed: '绘本插图完成',
storybook_cover_image_succeeded: '绘本封面就绪',
storybook_cover_image_failed: '绘本封面失败',
storybook_page_image_succeeded: '分页插图就绪',
storybook_page_image_failed: '分页插图失败',
audio_cache_hit: '音频缓存命中',
audio_started: '音频开始',
audio_succeeded: '音频就绪',
audio_failed: '音频失败',
provider_call_started: 'Provider 调用',
provider_call_succeeded: 'Provider 成功',
provider_call_failed: 'Provider 失败',
asset_retry_started: '资源重试开始',
asset_retry_completed: '资源重试完成',
asset_retry_failed: '资源重试失败',
generation_completed: '生成完成',
generation_failed: '生成失败',
asset_generation_completed: '资源生成完成',
asset_generation_failed: '资源生成失败',
}
return labels[eventType] ?? eventType
}
function formatDateTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(value))
}
function getProviderEventText(event: GenerationJobEvent) {
const meta = event.event_metadata
const adapter = meta.adapter ? String(meta.adapter) : ''
const latency = typeof meta.latency_ms === 'number' ? `${meta.latency_ms}ms` : ''
const cost = typeof meta.estimated_cost_usd === 'number'
? `$${meta.estimated_cost_usd.toFixed(4)}`
: ''
return [adapter, latency, cost].filter(Boolean).join(' · ')
}
function formatLatency(value?: number | null) {
return typeof value === 'number' ? `${Math.round(value)}ms` : '暂无'
}
function formatCost(value?: number | null) {
return typeof value === 'number' ? `$${value.toFixed(4)}` : '$0.0000'
}
async function selectGenerationJob(jobId: string) {
loading.value = true
error.value = ''
try {
activeJob.value = await api.get<GenerationJobDetail>(`/api/generations/jobs/${jobId}`)
} catch (e) {
error.value = e instanceof Error ? e.message : '生成轨迹加载失败'
} finally {
loading.value = false
}
}
async function refresh() {
if (props.storyId === null) {
jobHistory.value = []
activeJob.value = null
providerStats.value = null
return
}
error.value = ''
try {
const [jobs, stats] = await Promise.all([
api.get<GenerationJobSummary[]>(`/api/generations/${props.storyId}/jobs`),
api.get<GenerationProviderStats>(`/api/generations/${props.storyId}/provider-stats`),
])
jobHistory.value = jobs
providerStats.value = stats
const nextJobId = jobHistory.value[0]?.id
if (nextJobId) {
await selectGenerationJob(nextJobId)
} else {
activeJob.value = null
}
} catch (e) {
jobHistory.value = []
activeJob.value = null
providerStats.value = null
error.value = e instanceof Error ? e.message : '生成轨迹加载失败'
}
}
watch(
() => props.storyId,
() => {
void refresh()
},
{ immediate: true },
)
defineExpose({ refresh })
</script>
<template>
<section class="rounded-lg border p-5" :class="containerClass">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div class="flex items-center gap-2 font-semibold">
<BoltIcon class="h-5 w-5 text-amber-500" />
{{ props.title }}
</div>
<p class="mt-2 text-sm leading-6" :class="mutedTextClass">
{{ props.description }}
</p>
</div>
<span
v-if="latestJob"
class="w-fit rounded-full border px-3 py-1.5 text-sm font-medium"
:class="getJobStatusClass(latestJob.status)"
>
最近任务{{ getJobStatusLabel(latestJob.status) }}
</span>
</div>
<div v-if="error" class="mt-4 rounded-lg border border-rose-100 bg-rose-50 p-3 text-sm text-rose-600">
{{ error }}
</div>
<div v-else class="mt-5 space-y-5">
<div
v-if="providerStats?.total_calls"
class="grid gap-3 md:grid-cols-4"
>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedTextClass">Provider 成功率</div>
<div class="mt-1 text-xl font-semibold">{{ providerSuccessRate }}%</div>
</div>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedTextClass">平均耗时</div>
<div class="mt-1 text-xl font-semibold">{{ formatLatency(providerStats.avg_latency_ms) }}</div>
</div>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedTextClass">预估成本</div>
<div class="mt-1 text-xl font-semibold">{{ formatCost(providerStats.estimated_cost_usd) }}</div>
</div>
<div class="rounded-lg border p-3" :class="panelClass">
<div class="text-xs" :class="mutedTextClass">调用次数</div>
<div class="mt-1 text-xl font-semibold">{{ providerStats.total_calls }}</div>
</div>
</div>
<div v-if="!jobHistory.length" class="rounded-lg border border-dashed border-gray-200 p-4 text-sm" :class="mutedTextClass">
暂无生成轨迹旧数据会在下一次资源补全后开始记录
</div>
<div v-else class="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
<div class="flex gap-2 overflow-x-auto pb-1 lg:block lg:space-y-2 lg:overflow-visible">
<button
v-for="job in jobHistory"
:key="job.id"
type="button"
class="min-w-[190px] rounded-lg border px-3 py-3 text-left transition lg:w-full"
:class="[itemClass, activeJob?.id === job.id ? activeItemClass : '']"
@click="selectGenerationJob(job.id)"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold">
{{ job.output_mode === 'asset_retry' ? '资源重试' : '内容生成' }}
</span>
<span class="rounded-full border px-2 py-0.5 text-xs" :class="getJobStatusClass(job.status)">
{{ getJobStatusLabel(job.status) }}
</span>
</div>
<div class="mt-2 flex items-center gap-1 text-xs" :class="mutedTextClass">
<ClockIcon class="h-4 w-4" />
{{ formatDateTime(job.created_at) }}
</div>
</button>
</div>
<div class="rounded-lg border p-4" :class="panelClass">
<LoadingSpinner v-if="loading" size="sm" text="正在读取生成轨迹..." />
<div v-else-if="activeJob" class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold">
{{ activeJob.output_mode === 'asset_retry' ? '资源重试事件' : '生成事件' }}
</div>
<div class="mt-1 text-xs" :class="mutedTextClass">
当前步骤{{ getEventLabel(activeJob.current_step) }}
</div>
</div>
<span class="rounded-full border px-3 py-1 text-xs font-medium" :class="getJobStatusClass(activeJob.status)">
{{ getJobStatusLabel(activeJob.status) }}
</span>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-xs" :class="mutedTextClass">
<span>{{ activeProgressLabel }}</span>
<span>{{ activeProgress }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200/80">
<div
class="h-full rounded-full bg-emerald-500 transition-all duration-300"
:style="{ width: `${activeProgress}%` }"
/>
</div>
</div>
<ol class="space-y-3">
<li
v-for="event in activeJobEvents"
:key="event.id"
class="grid grid-cols-[88px_minmax(0,1fr)] gap-3"
>
<div class="pt-1 text-xs" :class="mutedTextClass">
{{ formatDateTime(event.created_at) }}
</div>
<div class="rounded-lg border px-3 py-2" :class="eventCardClass">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold">{{ getEventLabel(event.event_type) }}</span>
<span class="rounded-full border px-2 py-0.5 text-xs" :class="getJobStatusClass(event.status)">
{{ getJobStatusLabel(event.status) }}
</span>
</div>
<p v-if="event.event_type.startsWith('provider_call')" class="mt-1 text-xs" :class="mutedTextClass">
{{ getProviderEventText(event) }}
</p>
<p v-else-if="event.message" class="mt-1 text-xs" :class="mutedTextClass">
{{ event.message }}
</p>
</div>
</li>
</ol>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -18,6 +18,7 @@ export interface Storybook {
cover_prompt: string
cover_url?: string
generation_status?: string
text_status?: string
image_status?: string
audio_status?: string
last_error?: string | null

View File

@@ -0,0 +1,51 @@
export interface GenerationJobSummary {
id: string
story_id: number | null
output_mode: string
input_type: string
status: string
current_step: string
progress_percent: number
progress_label: string
is_terminal: boolean
result_snapshot: Record<string, unknown>
error_message: string | null
created_at: string
updated_at: string
}
export interface GenerationJobEvent {
id: number
job_id: string
story_id: number | null
event_type: string
status: string
message: string | null
event_metadata: Record<string, unknown>
created_at: string
}
export interface GenerationJobDetail extends GenerationJobSummary {
request_payload: Record<string, unknown>
events: GenerationJobEvent[]
}
export interface GenerationProviderStat {
capability: string
adapter: string
call_count: number
success_count: number
failure_count: number
avg_latency_ms: number | null
estimated_cost_usd: number
}
export interface GenerationProviderStats {
story_id: number
total_calls: number
successful_calls: number
failed_calls: number
avg_latency_ms: number | null
estimated_cost_usd: number
by_provider: GenerationProviderStat[]
}

View File

@@ -1,5 +1,6 @@
export type StoryGenerationStatus =
| 'narrative_ready'
| 'partial_ready'
| 'assets_generating'
| 'completed'
| 'degraded_completed'
@@ -23,6 +24,11 @@ const generationStatusMetaMap: Record<StoryGenerationStatus, StatusMeta> = {
description: '故事内容已经生成,可以继续补充封面或音频。',
badgeClass: 'bg-sky-50 text-sky-700 border border-sky-100',
},
partial_ready: {
label: '可先阅读',
description: '主内容已经可用,仍有封面、插图或音频可以继续补全。',
badgeClass: 'bg-cyan-50 text-cyan-700 border border-cyan-100',
},
assets_generating: {
label: '资源生成中',
description: '封面或音频正在生成中,请稍候查看结果。',
@@ -77,3 +83,13 @@ export function getAssetStatusMeta(status?: string): StatusMeta {
return assetStatusMetaMap[(status ?? 'not_requested') as StoryAssetStatus]
?? assetStatusMetaMap.not_requested
}
export function isReadableGenerationStatus(status?: string) {
return ['narrative_ready', 'partial_ready', 'completed', 'degraded_completed']
.includes(status ?? '')
}
export function needsGenerationAttention(status?: string) {
return ['partial_ready', 'assets_generating', 'degraded_completed', 'failed']
.includes(status ?? '')
}

View File

@@ -7,7 +7,12 @@ import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
import {
getAssetStatusMeta,
getGenerationStatusMeta,
isReadableGenerationStatus,
needsGenerationAttention,
} from '../utils/storyStatus'
import {
BookOpenIcon,
ChevronRightIcon,
@@ -24,6 +29,7 @@ interface StoryItem {
created_at: string
mode: string
generation_status: string
text_status: string
image_status: string
audio_status: string
last_error: string | null
@@ -35,14 +41,12 @@ const loading = ref(true)
const error = ref('')
const showCreateModal = ref(false)
const completedCount = computed(() =>
stories.value.filter((story) => story.generation_status === 'completed').length,
const readableCount = computed(() =>
stories.value.filter((story) => isReadableGenerationStatus(story.generation_status)).length,
)
const attentionCount = computed(() =>
stories.value.filter((story) =>
['degraded_completed', 'failed'].includes(story.generation_status),
).length,
stories.value.filter((story) => needsGenerationAttention(story.generation_status)).length,
)
async function fetchStories() {
@@ -144,12 +148,12 @@ onMounted(() => {
<div class="text-gray-500 text-sm mt-1">绘本数量</div>
</div>
<div class="text-center px-4 py-2">
<div class="text-3xl font-bold text-gray-800">{{ completedCount }}</div>
<div class="text-gray-500 text-sm mt-1">完整可用</div>
<div class="text-3xl font-bold text-gray-800">{{ readableCount }}</div>
<div class="text-gray-500 text-sm mt-1">可阅读</div>
</div>
<div class="text-center px-4 py-2">
<div class="text-3xl font-bold text-gray-800">{{ attentionCount }}</div>
<div class="text-gray-500 text-sm mt-1">待补资源</div>
<div class="text-gray-500 text-sm mt-1">需关注</div>
</div>
</div>
</BaseCard>

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BaseButton from '../components/ui/BaseButton.vue'
import ConfirmModal from '../components/ui/ConfirmModal.vue'
import GenerationTrace from '../components/GenerationTrace.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
import {
@@ -23,6 +24,7 @@ interface Story {
image_url: string | null
mode: string
generation_status: string
text_status: string
image_status: string
audio_status: string
last_error: string | null
@@ -49,6 +51,7 @@ const audioProgress = ref(0)
const audioDuration = ref(0)
const error = ref('')
const showDeleteConfirm = ref(false)
const generationTraceRef = ref<InstanceType<typeof GenerationTrace> | null>(null)
const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ?? [])
const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status))
@@ -62,6 +65,10 @@ const assetGuidance = computed(() => {
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
}
if (story.value?.generation_status === 'partial_ready') {
return '正文已经可读,仍可继续补全封面或音频。'
}
if (story.value?.generation_status === 'assets_generating') {
return '资源正在处理中,可以稍后刷新查看最新状态。'
}
@@ -103,6 +110,7 @@ async function generateImage() {
story.value = await api.post<Story>(`/api/generations/${story.value.id}/retry-assets`, {
assets: ['image'],
})
await generationTraceRef.value?.refresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '图片生成失败'
await refreshStorySnapshot().catch(() => undefined)
@@ -151,6 +159,7 @@ async function retryAudio() {
audioUrl.value = null
await loadAudio()
}
await generationTraceRef.value?.refresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '音频生成失败'
await refreshStorySnapshot().catch(() => undefined)
@@ -338,6 +347,12 @@ onUnmounted(() => {
<p>{{ assetGuidance }}</p>
</div>
<GenerationTrace
ref="generationTraceRef"
class="mb-10"
:story-id="story.id"
/>
<div class="prose prose-lg max-w-none mb-10">
<p
v-for="(paragraph, index) in storyParagraphs"

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import { useStorybookStore } from '../stores/storybook'
import BaseButton from '../components/ui/BaseButton.vue'
import GenerationTrace from '../components/GenerationTrace.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus'
import {
@@ -30,6 +31,7 @@ interface StoryDetailResponse {
image_url: string | null
mode: string
generation_status: string
text_status: string
image_status: string
audio_status: string
last_error: string | null
@@ -45,6 +47,7 @@ const loading = ref(true)
const imageLoading = ref(false)
const error = ref('')
const currentPageIndex = ref(-1)
const generationTraceRef = ref<InstanceType<typeof GenerationTrace> | null>(null)
const totalPages = computed(() => storybook.value?.pages.length || 0)
const isCover = computed(() => currentPageIndex.value === -1)
@@ -83,6 +86,7 @@ const currentStoryId = computed(() => {
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
})
const storybookTraceId = computed(() => storybook.value?.id ?? currentStoryId.value)
function goHome() {
store.clearStorybook()
@@ -122,6 +126,7 @@ async function loadStorybook() {
if (cachedStorybook?.id === storyId) {
loading.value = false
await generationTraceRef.value?.refresh()
return
}
@@ -150,6 +155,7 @@ async function loadStorybook() {
last_error: detail.last_error,
retryable_assets: detail.retryable_assets,
})
await generationTraceRef.value?.refresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '绘本加载失败'
} finally {
@@ -186,6 +192,7 @@ async function retryStorybookImages() {
last_error: detail.last_error,
retryable_assets: detail.retryable_assets,
})
await generationTraceRef.value?.refresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '插图补全失败'
await loadStorybook().catch(() => undefined)
@@ -394,6 +401,17 @@ watch(
读完了再来一本
</BaseButton>
</div>
<section class="bg-[#0D0F1A] px-4 pb-10 md:px-8">
<GenerationTrace
ref="generationTraceRef"
:story-id="storybookTraceId"
tone="dark"
title="绘本生成轨迹"
description="绘本正文、封面和每页插图都会写入事件流;失败时可以看到资源重试和 Provider 调用结果。"
class="mx-auto max-w-5xl"
/>
</section>
</div>
</template>

View File

@@ -71,13 +71,28 @@ story_json="$(post_json "$APP_URL/api/generations" '{
"generate_images": false
}')"
story_id="$(jq -r '.id' <<<"$story_json")"
assert_jq "$story_json" '.mode == "generated" and .generation_status == "narrative_ready"' "story should be readable before assets"
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" '.generation_job_id != null and .generation_job_id != ""' "story generation should expose a job id"
assert_jq "$story_json" '(.retryable_assets | index("image")) != null and (.retryable_assets | index("audio")) != null' "story should expose image/audio as retryable assets"
echo "$story_json" | jq '{id,title,mode,generation_status,image_status,audio_status,retryable_assets}'
say "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" '.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("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)}'
say "Checking story provider stats"
story_provider_stats_json="$(get_json "$APP_URL/api/generations/$story_id/provider-stats")"
assert_jq "$story_provider_stats_json" '.total_calls >= 1 and .successful_calls >= 1 and (.by_provider | length) >= 1' "story provider stats should summarize provider calls"
echo "$story_provider_stats_json" | jq '{story_id,total_calls,successful_calls,failed_calls,avg_latency_ms,estimated_cost_usd}'
say "Retrying story cover image"
story_image_json="$(post_json "$APP_URL/api/generations/$story_id/retry-assets" '{"assets":["image"]}')"
assert_jq "$story_image_json" '.image_status == "ready" and (.image_url != null)' "story cover should be ready after retry"
assert_jq "$story_image_json" '.generation_status == "partial_ready" and .image_status == "ready" and (.image_url != null)' "story cover should be ready after retry"
assert_jq "$story_image_json" '(.retryable_assets | index("image")) == null and (.retryable_assets | index("audio")) != null' "story image retry should leave only audio retryable"
echo "$story_image_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets}'
@@ -106,16 +121,38 @@ storybook_json="$(post_json "$APP_URL/api/generations" '{
"page_count": 6
}')"
storybook_id="$(jq -r '.id' <<<"$storybook_json")"
assert_jq "$storybook_json" '.mode == "storybook" and .image_status == "not_requested" and (.pages | length) >= 4' "storybook should be readable before images"
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" '.generation_job_id != null and .generation_job_id != ""' "storybook generation should expose a job id"
assert_jq "$storybook_json" '(.retryable_assets | index("image")) != null and (.retryable_assets | index("audio")) == null' "storybook should expose images as retryable assets"
echo "$storybook_json" | jq '{id,title,mode,generation_status,image_status,audio_status,retryable_assets,pages:(.pages | length)}'
say "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" '.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"
echo "$storybook_job_json" | jq '{id,status,current_step,events:([.events[].event_type] | unique)}'
say "Checking storybook provider stats"
storybook_provider_stats_json="$(get_json "$APP_URL/api/generations/$storybook_id/provider-stats")"
assert_jq "$storybook_provider_stats_json" '.total_calls >= 1 and .successful_calls >= 1 and (.by_provider | length) >= 1' "storybook provider stats should summarize provider calls"
echo "$storybook_provider_stats_json" | jq '{story_id,total_calls,successful_calls,failed_calls,avg_latency_ms,estimated_cost_usd}'
say "Retrying storybook images"
storybook_image_json="$(post_json "$APP_URL/api/generations/$storybook_id/retry-assets" '{"assets":["image"]}')"
assert_jq "$storybook_image_json" '.image_status == "ready" and (.pages | length) >= 4 and ([.pages[] | select(.image_url != null)] | length) == (.pages | length)' "storybook images should be ready after retry"
assert_jq "$storybook_image_json" '(.retryable_assets | length) == 0' "storybook should have no retryable assets after images are ready"
echo "$storybook_image_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets,pages:(.pages | length), ready_pages:([.pages[] | select(.image_url != null)] | length)}'
say "Checking story job history"
story_jobs_json="$(get_json "$APP_URL/api/generations/$story_id/jobs")"
storybook_jobs_json="$(get_json "$APP_URL/api/generations/$storybook_id/jobs")"
assert_jq "$story_jobs_json" 'length >= 2 and (map(.id) | index("'"$story_job_id"'")) != null' "story job history should include generation and retry jobs"
assert_jq "$storybook_jobs_json" 'length >= 2 and (map(.id) | index("'"$storybook_job_id"'")) != null' "storybook job history should include generation and retry jobs"
echo "$story_jobs_json" | jq '[.[] | {id,output_mode,status,current_step}]'
echo "$storybook_jobs_json" | jq '[.[] | {id,output_mode,status,current_step}]'
say "Checking story list"
list_json="$(get_json "$APP_URL/api/stories?limit=5")"
assert_jq "$list_json" "map(.id) | index($story_id) != null" "story list should include generated story"