feat: add generation trace and partial-ready workflow status
This commit is contained in:
@@ -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}` | 删除故事 |
|
||||
|
||||
358
admin-frontend/src/components/GenerationTrace.vue
Normal file
358
admin-frontend/src/components/GenerationTrace.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? '')
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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="封面生成失败",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
109
backend/tests/test_story_status.py
Normal file
109
backend/tests/test_story_status.py
Normal 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
|
||||
@@ -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 的职责边界。
|
||||
|
||||
@@ -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 分层说明辅助讲系统设计 |
|
||||
|
||||
@@ -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 生产构建
|
||||
|
||||
@@ -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 运营分析。生产上线前还需要补真实用户鉴权配置、密钥管理、监控告警和部署策略。
|
||||
|
||||
@@ -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、前端与文档中保持一致。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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: 管理模型供应与成本风险
|
||||
|
||||
|
||||
@@ -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 演示的已知问题;后续生产化主要受后台异步化与运营分析范围影响
|
||||
- 多条生成链路重复实现
|
||||
|
||||
---
|
||||
|
||||
@@ -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`
|
||||
|
||||
前端创建弹窗、绘本阅读器、故事详情页的资产重试应优先使用这些入口。
|
||||
|
||||
|
||||
@@ -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 查询接口做轮询进度。
|
||||
|
||||
## 面试表达
|
||||
|
||||
|
||||
347
frontend/src/components/GenerationTrace.vue
Normal file
347
frontend/src/components/GenerationTrace.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
51
frontend/src/types/generation.ts
Normal file
51
frontend/src/types/generation.ts
Normal 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[]
|
||||
}
|
||||
@@ -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 ?? '')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user