feat: add week 3 audio and timeline enhancements
This commit is contained in:
@@ -135,6 +135,8 @@ npm run build
|
|||||||
| GET | `/api/generations/{story_id}/jobs` | 查询故事生成与重试历史 |
|
| GET | `/api/generations/{story_id}/jobs` | 查询故事生成与重试历史 |
|
||||||
| GET | `/api/generations/{story_id}/provider-stats` | 查询 Provider 调用聚合指标 |
|
| GET | `/api/generations/{story_id}/provider-stats` | 查询 Provider 调用聚合指标 |
|
||||||
| GET | `/api/generations/provider-analytics` | 查询当前用户跨故事 Provider 运营摘要 |
|
| GET | `/api/generations/provider-analytics` | 查询当前用户跨故事 Provider 运营摘要 |
|
||||||
|
| GET | `/api/audio/{story_id}/status` | 查询音频缓存状态,不触发生成 |
|
||||||
|
| DELETE | `/api/audio/{story_id}/cache` | 清理故事音频缓存 |
|
||||||
| GET | `/api/stories` | 故事列表 |
|
| GET | `/api/stories` | 故事列表 |
|
||||||
| GET | `/api/stories/{story_id}` | 故事详情 |
|
| GET | `/api/stories/{story_id}` | 故事详情 |
|
||||||
| DELETE | `/api/stories/{story_id}` | 删除故事 |
|
| DELETE | `/api/stories/{story_id}` | 删除故事 |
|
||||||
@@ -147,6 +149,7 @@ npm run build
|
|||||||
- `docs/product/unified-generation-workflow-prd.md`:统一生成工作流 PRD
|
- `docs/product/unified-generation-workflow-prd.md`:统一生成工作流 PRD
|
||||||
- `docs/planning/week-1-execution-backlog.md`:短期执行 backlog
|
- `docs/planning/week-1-execution-backlog.md`:短期执行 backlog
|
||||||
- `docs/planning/week-2-execution-backlog.md`:下一阶段执行 backlog
|
- `docs/planning/week-2-execution-backlog.md`:下一阶段执行 backlog
|
||||||
|
- `docs/planning/week-2-to-4-execution-backlog.md`:Week 2 到 Week 4 总执行 backlog
|
||||||
- `docs/planning/demo-checklist.md`:求职演示检查清单
|
- `docs/planning/demo-checklist.md`:求职演示检查清单
|
||||||
- `docs/planning/demo-validation-log.md`:本地 Docker 演示验证记录
|
- `docs/planning/demo-validation-log.md`:本地 Docker 演示验证记录
|
||||||
- `docs/planning/interview-pitch.md`:3 分钟项目讲解稿
|
- `docs/planning/interview-pitch.md`:3 分钟项目讲解稿
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ import BaseButton from '../components/ui/BaseButton.vue'
|
|||||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||||
import EmptyState from '../components/ui/EmptyState.vue'
|
import EmptyState from '../components/ui/EmptyState.vue'
|
||||||
import {
|
import {
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
FlagIcon,
|
FlagIcon,
|
||||||
CalendarIcon,
|
HeartIcon,
|
||||||
|
CalendarIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ExclamationCircleIcon
|
ExclamationCircleIcon
|
||||||
} from '@heroicons/vue/24/solid'
|
} from '@heroicons/vue/24/solid'
|
||||||
|
|
||||||
interface TimelineEvent {
|
interface TimelineEvent {
|
||||||
date: string
|
date: string
|
||||||
type: 'story' | 'achievement' | 'milestone'
|
type: 'story' | 'achievement' | 'milestone' | 'reading_event' | 'memory'
|
||||||
title: string
|
title: string
|
||||||
description: string | null
|
description: string | null
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
@@ -41,19 +42,23 @@ const profileId = route.params.id as string
|
|||||||
const profileName = ref('') // We might need to fetch profile details separately or store it
|
const profileName = ref('') // We might need to fetch profile details separately or store it
|
||||||
|
|
||||||
function getIcon(type: string) {
|
function getIcon(type: string) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'milestone': return FlagIcon
|
case 'milestone': return FlagIcon
|
||||||
case 'story': return BookOpenIcon
|
case 'story': return BookOpenIcon
|
||||||
case 'achievement': return TrophyIcon
|
case 'reading_event': return CalendarIcon
|
||||||
|
case 'memory': return HeartIcon
|
||||||
|
case 'achievement': return TrophyIcon
|
||||||
default: return SparklesIcon
|
default: return SparklesIcon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getColor(type: string) {
|
function getColor(type: string) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'milestone': return 'text-blue-500'
|
case 'milestone': return 'text-blue-500'
|
||||||
case 'story': return 'text-purple-500'
|
case 'story': return 'text-purple-500'
|
||||||
case 'achievement': return 'text-yellow-500'
|
case 'reading_event': return 'text-emerald-500'
|
||||||
|
case 'memory': return 'text-pink-500'
|
||||||
|
case 'achievement': return 'text-yellow-500'
|
||||||
default: return 'text-gray-500'
|
default: return 'text-gray-500'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +88,7 @@ async function fetchTimeline() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEventClick(event: TimelineEvent) {
|
function handleEventClick(event: TimelineEvent) {
|
||||||
if (event.type === 'story' && event.metadata?.story_id) {
|
if ((event.type === 'story' || event.type === 'reading_event' || event.type === 'memory') && event.metadata?.story_id) {
|
||||||
if (event.metadata.mode === 'storybook') {
|
if (event.metadata.mode === 'storybook') {
|
||||||
router.push(`/storybook/view/${event.metadata.story_id}`)
|
router.push(`/storybook/view/${event.metadata.story_id}`)
|
||||||
} else {
|
} else {
|
||||||
@@ -175,11 +180,17 @@ onMounted(fetchTimeline)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Role Badge -->
|
<!-- Role Badge -->
|
||||||
<div v-if="event.type === 'achievement'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-yellow-50 text-yellow-700 text-xs font-bold border border-yellow-200">
|
<div v-if="event.type === 'achievement'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-yellow-50 text-yellow-700 text-xs font-bold border border-yellow-200">
|
||||||
<TrophyIcon class="h-3 w-3 mr-1" /> 成就解锁
|
<TrophyIcon class="h-3 w-3 mr-1" /> 成就解锁
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-else-if="event.type === 'reading_event'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-emerald-50 text-emerald-700 text-xs font-bold border border-emerald-200">
|
||||||
</div>
|
<CalendarIcon class="h-3 w-3 mr-1" /> 阅读记录
|
||||||
|
</div>
|
||||||
|
<div v-else-if="event.type === 'memory'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-pink-50 text-pink-700 text-xs font-bold border border-pink-200">
|
||||||
|
<HeartIcon class="h-3 w-3 mr-1" /> 记忆沉淀
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -37,10 +37,25 @@ interface Story {
|
|||||||
}> | null
|
}> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AudioCacheStatus {
|
||||||
|
story_id: number
|
||||||
|
audio_ready: boolean
|
||||||
|
cache_exists: boolean
|
||||||
|
cache_size_bytes: number | null
|
||||||
|
cache_updated_at: string | null
|
||||||
|
generation_status: string
|
||||||
|
text_status: string
|
||||||
|
image_status: string
|
||||||
|
audio_status: string
|
||||||
|
last_error: string | null
|
||||||
|
retryable_assets: Array<'image' | 'audio'>
|
||||||
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const story = ref<Story | null>(null)
|
const story = ref<Story | null>(null)
|
||||||
|
const audioCacheStatus = ref<AudioCacheStatus | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const imageLoading = ref(false)
|
const imageLoading = ref(false)
|
||||||
const audioLoading = ref(false)
|
const audioLoading = ref(false)
|
||||||
@@ -60,6 +75,12 @@ const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status))
|
|||||||
const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false)
|
const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false)
|
||||||
const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false)
|
const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false)
|
||||||
const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
|
const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
|
||||||
|
const audioCacheLabel = computed(() => {
|
||||||
|
if (!audioCacheStatus.value?.cache_exists) return '暂无缓存'
|
||||||
|
const size = audioCacheStatus.value.cache_size_bytes ?? 0
|
||||||
|
const kb = Math.max(1, Math.round(size / 1024))
|
||||||
|
return `已缓存 · ${kb} KB`
|
||||||
|
})
|
||||||
const assetGuidance = computed(() => {
|
const assetGuidance = computed(() => {
|
||||||
if (story.value?.generation_status === 'degraded_completed') {
|
if (story.value?.generation_status === 'degraded_completed') {
|
||||||
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
|
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
|
||||||
@@ -85,6 +106,7 @@ async function refreshStorySnapshot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
story.value = data
|
story.value = data
|
||||||
|
await refreshAudioStatus().catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStory() {
|
async function fetchStory() {
|
||||||
@@ -137,6 +159,7 @@ async function loadAudio() {
|
|||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
audioUrl.value = URL.createObjectURL(blob)
|
audioUrl.value = URL.createObjectURL(blob)
|
||||||
await refreshStorySnapshot().catch(() => undefined)
|
await refreshStorySnapshot().catch(() => undefined)
|
||||||
|
await refreshAudioStatus().catch(() => undefined)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '音频加载失败'
|
error.value = e instanceof Error ? e.message : '音频加载失败'
|
||||||
await refreshStorySnapshot().catch(() => undefined)
|
await refreshStorySnapshot().catch(() => undefined)
|
||||||
@@ -159,6 +182,7 @@ async function retryAudio() {
|
|||||||
audioUrl.value = null
|
audioUrl.value = null
|
||||||
await loadAudio()
|
await loadAudio()
|
||||||
}
|
}
|
||||||
|
await refreshAudioStatus().catch(() => undefined)
|
||||||
await generationTraceRef.value?.refresh()
|
await generationTraceRef.value?.refresh()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '音频生成失败'
|
error.value = e instanceof Error ? e.message : '音频生成失败'
|
||||||
@@ -168,6 +192,28 @@ async function retryAudio() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshAudioStatus() {
|
||||||
|
if (!story.value) return
|
||||||
|
audioCacheStatus.value = await api.get<AudioCacheStatus>(`/api/audio/${story.value.id}/status`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAudioCache() {
|
||||||
|
if (!story.value) return
|
||||||
|
|
||||||
|
audioLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
audioCacheStatus.value = await api.delete<AudioCacheStatus>(`/api/audio/${story.value.id}/cache`)
|
||||||
|
audioUrl.value = null
|
||||||
|
story.value = await api.get<Story>(`/api/stories/${story.value.id}`)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '清理音频缓存失败'
|
||||||
|
} finally {
|
||||||
|
audioLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
if (!audioRef.value) return
|
if (!audioRef.value) return
|
||||||
|
|
||||||
@@ -202,6 +248,16 @@ function formatTime(seconds: number) {
|
|||||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) return '暂无'
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(value))
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteStory() {
|
async function deleteStory() {
|
||||||
if (!story.value) return
|
if (!story.value) return
|
||||||
|
|
||||||
@@ -329,6 +385,12 @@ onUnmounted(() => {
|
|||||||
<p class="text-sm text-gray-500 leading-6">
|
<p class="text-sm text-gray-500 leading-6">
|
||||||
{{ audioMeta.description }} 音频首次生成后会缓存复用,状态记录的是当前可播放结果。
|
{{ audioMeta.description }} 音频首次生成后会缓存复用,状态记录的是当前可播放结果。
|
||||||
</p>
|
</p>
|
||||||
|
<div class="mt-3 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 text-xs text-gray-500">
|
||||||
|
<div>{{ audioCacheLabel }}</div>
|
||||||
|
<div v-if="audioCacheStatus?.cache_updated_at" class="mt-1">
|
||||||
|
更新时间:{{ formatDateTime(audioCacheStatus.cache_updated_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="canRetryAudio"
|
v-if="canRetryAudio"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -339,6 +401,16 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
{{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }}
|
{{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-if="audioCacheStatus?.cache_exists"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
:loading="audioLoading"
|
||||||
|
class="mt-3 w-full"
|
||||||
|
@click="clearAudioCache"
|
||||||
|
>
|
||||||
|
清理缓存
|
||||||
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.deps import require_user
|
from app.core.deps import require_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import ChildProfile, Story, StoryUniverse, User
|
from app.db.models import ChildProfile, MemoryItem, ReadingEvent, Story, StoryUniverse, User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ class TimelineEvent(BaseModel):
|
|||||||
"""Timeline event item."""
|
"""Timeline event item."""
|
||||||
|
|
||||||
date: str
|
date: str
|
||||||
type: Literal["story", "achievement", "milestone"]
|
type: Literal["story", "achievement", "milestone", "reading_event", "memory"]
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
image_url: str | None = None
|
image_url: str | None = None
|
||||||
@@ -241,7 +241,10 @@ async def get_profile_timeline(
|
|||||||
|
|
||||||
# 2. Stories
|
# 2. Stories
|
||||||
stories_result = await db.execute(
|
stories_result = await db.execute(
|
||||||
select(Story).where(Story.child_profile_id == profile_id)
|
select(Story).where(
|
||||||
|
Story.child_profile_id == profile_id,
|
||||||
|
Story.user_id == user.id,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for s in stories_result.scalars():
|
for s in stories_result.scalars():
|
||||||
events.append(TimelineEvent(
|
events.append(TimelineEvent(
|
||||||
@@ -252,7 +255,89 @@ async def get_profile_timeline(
|
|||||||
metadata={"story_id": s.id, "mode": s.mode}
|
metadata={"story_id": s.id, "mode": s.mode}
|
||||||
))
|
))
|
||||||
|
|
||||||
# 3. Achievements (from Universe)
|
# 3. Reading events
|
||||||
|
reading_result = await db.execute(
|
||||||
|
select(ReadingEvent, Story)
|
||||||
|
.outerjoin(Story, ReadingEvent.story_id == Story.id)
|
||||||
|
.where(ReadingEvent.child_profile_id == profile_id)
|
||||||
|
.order_by(ReadingEvent.created_at.desc())
|
||||||
|
)
|
||||||
|
reading_labels = {
|
||||||
|
"started": "开始阅读",
|
||||||
|
"completed": "读完故事",
|
||||||
|
"replayed": "再次阅读",
|
||||||
|
"skipped": "暂时跳过",
|
||||||
|
}
|
||||||
|
for reading_event, story in reading_result.all():
|
||||||
|
story_title = story.title if story else "未关联故事"
|
||||||
|
duration = (
|
||||||
|
f"阅读 {reading_event.reading_time} 秒"
|
||||||
|
if reading_event.reading_time
|
||||||
|
else "记录了一次阅读行为"
|
||||||
|
)
|
||||||
|
events.append(
|
||||||
|
TimelineEvent(
|
||||||
|
date=reading_event.created_at.isoformat(),
|
||||||
|
type="reading_event",
|
||||||
|
title=reading_labels.get(reading_event.event_type, "阅读记录"),
|
||||||
|
description=f"{story_title} · {duration}",
|
||||||
|
image_url=story.image_url if story else None,
|
||||||
|
metadata={
|
||||||
|
"story_id": reading_event.story_id,
|
||||||
|
"mode": story.mode if story else None,
|
||||||
|
"event_type": reading_event.event_type,
|
||||||
|
"reading_time": reading_event.reading_time,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Memory items
|
||||||
|
memories_result = await db.execute(
|
||||||
|
select(MemoryItem)
|
||||||
|
.where(MemoryItem.child_profile_id == profile_id)
|
||||||
|
.order_by(MemoryItem.created_at.desc())
|
||||||
|
.limit(20)
|
||||||
|
)
|
||||||
|
memory_labels = {
|
||||||
|
"recent_story": "近期故事记忆",
|
||||||
|
"favorite_character": "喜欢的角色",
|
||||||
|
"scary_element": "回避元素",
|
||||||
|
"vocabulary_growth": "词汇积累",
|
||||||
|
"emotional_highlight": "情感高光",
|
||||||
|
"reading_preference": "阅读偏好",
|
||||||
|
"milestone": "成长里程碑",
|
||||||
|
"skill_mastered": "掌握的技能",
|
||||||
|
}
|
||||||
|
for memory in memories_result.scalars():
|
||||||
|
value = memory.value or {}
|
||||||
|
title = str(
|
||||||
|
value.get("title")
|
||||||
|
or value.get("name")
|
||||||
|
or value.get("keyword")
|
||||||
|
or value.get("description")
|
||||||
|
or memory_labels.get(memory.type, memory.type)
|
||||||
|
)
|
||||||
|
events.append(
|
||||||
|
TimelineEvent(
|
||||||
|
date=memory.created_at.isoformat(),
|
||||||
|
type="memory",
|
||||||
|
title=f"记忆沉淀:{memory_labels.get(memory.type, memory.type)}",
|
||||||
|
description=title,
|
||||||
|
image_url=(
|
||||||
|
value.get("image_url")
|
||||||
|
if isinstance(value.get("image_url"), str)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"memory_id": memory.id,
|
||||||
|
"memory_type": memory.type,
|
||||||
|
"story_id": value.get("story_id") or value.get("source_story_id"),
|
||||||
|
"mode": value.get("mode"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Achievements (from Universe)
|
||||||
universes_result = await db.execute(
|
universes_result = await db.execute(
|
||||||
select(StoryUniverse).where(StoryUniverse.child_profile_id == profile_id)
|
select(StoryUniverse).where(StoryUniverse.child_profile_id == profile_id)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -109,7 +109,11 @@ async def create_reading_event(
|
|||||||
value={
|
value={
|
||||||
"story_id": story.id,
|
"story_id": story.id,
|
||||||
"title": story.title,
|
"title": story.title,
|
||||||
|
"mode": story.mode,
|
||||||
|
"image_url": story.image_url,
|
||||||
"event_type": payload.event_type,
|
"event_type": payload.event_type,
|
||||||
|
"reading_time": payload.reading_time,
|
||||||
|
"source": "reading_event",
|
||||||
},
|
},
|
||||||
base_weight=weight,
|
base_weight=weight,
|
||||||
last_used_at=datetime.now(timezone.utc),
|
last_used_at=datetime.now(timezone.utc),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from app.schemas.story_schemas import (
|
|||||||
GenerationRequest,
|
GenerationRequest,
|
||||||
GenerationResponse,
|
GenerationResponse,
|
||||||
StoryAssetRetryRequest,
|
StoryAssetRetryRequest,
|
||||||
|
StoryAudioStatusResponse,
|
||||||
StorybookRequest,
|
StorybookRequest,
|
||||||
StorybookResponse,
|
StorybookResponse,
|
||||||
StoryDetailResponse,
|
StoryDetailResponse,
|
||||||
@@ -395,8 +396,28 @@ async def get_story_audio(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get story audio (MP3)."""
|
"""Get story audio (MP3)."""
|
||||||
audio_bytes = await story_service.generate_story_audio(story_id, user.id, db)
|
audio_bytes = await story_service.generate_story_audio(story_id, user.id, db)
|
||||||
return Response(content=audio_bytes, media_type="audio/mpeg")
|
return Response(content=audio_bytes, media_type="audio/mpeg")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audio/{story_id}/status", response_model=StoryAudioStatusResponse)
|
||||||
|
async def get_story_audio_status(
|
||||||
|
story_id: int,
|
||||||
|
user: User = Depends(require_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get audio cache status without generating audio."""
|
||||||
|
return await story_service.get_story_audio_status(story_id, user.id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/audio/{story_id}/cache", response_model=StoryAudioStatusResponse)
|
||||||
|
async def delete_story_audio_cache(
|
||||||
|
story_id: int,
|
||||||
|
user: User = Depends(require_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Clear cached story audio so it can be regenerated."""
|
||||||
|
return await story_service.clear_story_audio_cache(story_id, user.id, db)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stories/{story_id}/achievements", response_model=list[AchievementItem])
|
@router.get("/stories/{story_id}/achievements", response_model=list[AchievementItem])
|
||||||
|
|||||||
@@ -154,6 +154,16 @@ class StoryImageResponse(StoryStatusMixin):
|
|||||||
image_url: str | None
|
image_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class StoryAudioStatusResponse(StoryStatusMixin):
|
||||||
|
"""Audio cache status for one story."""
|
||||||
|
|
||||||
|
story_id: int
|
||||||
|
audio_ready: bool
|
||||||
|
cache_exists: bool
|
||||||
|
cache_size_bytes: int | None = None
|
||||||
|
cache_updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class StoryAssetRetryRequest(BaseModel):
|
class StoryAssetRetryRequest(BaseModel):
|
||||||
"""Retry selected generated assets for a story."""
|
"""Retry selected generated assets for a story."""
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,23 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AudioCacheMetadata:
|
||||||
|
"""Metadata about one cached story audio file."""
|
||||||
|
|
||||||
|
exists: bool
|
||||||
|
path: str | None = None
|
||||||
|
size_bytes: int | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
def build_story_audio_path(story_id: int) -> str:
|
def build_story_audio_path(story_id: int) -> str:
|
||||||
"""Build the cache path for a story audio file."""
|
"""Build the cache path for a story audio file."""
|
||||||
|
|
||||||
@@ -25,6 +37,39 @@ def read_audio_cache(audio_path: str) -> bytes:
|
|||||||
return Path(audio_path).read_bytes()
|
return Path(audio_path).read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_cache_metadata(audio_path: str | None) -> AudioCacheMetadata:
|
||||||
|
"""Return cache metadata without reading the audio bytes."""
|
||||||
|
|
||||||
|
if not audio_path:
|
||||||
|
return AudioCacheMetadata(exists=False)
|
||||||
|
|
||||||
|
path = Path(audio_path)
|
||||||
|
if not path.is_file():
|
||||||
|
return AudioCacheMetadata(exists=False, path=str(path))
|
||||||
|
|
||||||
|
stat = path.stat()
|
||||||
|
return AudioCacheMetadata(
|
||||||
|
exists=True,
|
||||||
|
path=str(path),
|
||||||
|
size_bytes=stat.st_size,
|
||||||
|
updated_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_audio_cache(audio_path: str | None) -> bool:
|
||||||
|
"""Delete cached story audio if it exists."""
|
||||||
|
|
||||||
|
if not audio_path:
|
||||||
|
return False
|
||||||
|
|
||||||
|
path = Path(audio_path)
|
||||||
|
if not path.is_file():
|
||||||
|
return False
|
||||||
|
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def write_story_audio_cache(story_id: int, audio_data: bytes) -> str:
|
def write_story_audio_cache(story_id: int, audio_data: bytes) -> str:
|
||||||
"""Persist story audio and return the saved file path."""
|
"""Persist story audio and return the saved file path."""
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from app.services.adapters.storybook.primary import Storybook
|
|||||||
from app.services.adapters.text.models import StoryOutput
|
from app.services.adapters.text.models import StoryOutput
|
||||||
from app.services.audio_storage import (
|
from app.services.audio_storage import (
|
||||||
audio_cache_exists,
|
audio_cache_exists,
|
||||||
|
delete_audio_cache,
|
||||||
|
get_audio_cache_metadata,
|
||||||
read_audio_cache,
|
read_audio_cache,
|
||||||
write_story_audio_cache,
|
write_story_audio_cache,
|
||||||
)
|
)
|
||||||
@@ -1536,9 +1538,66 @@ async def generate_story_audio(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
raise HTTPException(status_code=500, detail="Audio generation failed")
|
raise HTTPException(status_code=500, detail="Audio generation failed")
|
||||||
|
|
||||||
|
|
||||||
async def get_story_achievements(
|
async def get_story_audio_status(
|
||||||
|
story_id: int,
|
||||||
|
user_id: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> dict:
|
||||||
|
"""Return audio cache status without generating audio."""
|
||||||
|
|
||||||
|
story = await get_story_detail(story_id, user_id, db)
|
||||||
|
metadata = get_audio_cache_metadata(story.audio_path)
|
||||||
|
|
||||||
|
if story.audio_path and not metadata.exists:
|
||||||
|
logger.warning(
|
||||||
|
"story_audio_cache_missing_on_status",
|
||||||
|
story_id=story.id,
|
||||||
|
audio_path=story.audio_path,
|
||||||
|
)
|
||||||
|
story.audio_path = None
|
||||||
|
if story.audio_status == StoryAssetStatus.READY.value:
|
||||||
|
sync_story_status(story, audio_status=StoryAssetStatus.NOT_REQUESTED)
|
||||||
|
await db.commit()
|
||||||
|
metadata = get_audio_cache_metadata(story.audio_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"story_id": story.id,
|
||||||
|
"audio_ready": story.audio_status == StoryAssetStatus.READY.value
|
||||||
|
and metadata.exists,
|
||||||
|
"cache_exists": metadata.exists,
|
||||||
|
"cache_size_bytes": metadata.size_bytes,
|
||||||
|
"cache_updated_at": metadata.updated_at,
|
||||||
|
"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,
|
||||||
|
"retryable_assets": story.retryable_assets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_story_audio_cache(
|
||||||
|
story_id: int,
|
||||||
|
user_id: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> dict:
|
||||||
|
"""Delete cached audio and mark audio as retryable again."""
|
||||||
|
|
||||||
|
story = await get_story_detail(story_id, user_id, db)
|
||||||
|
delete_audio_cache(story.audio_path)
|
||||||
|
story.audio_path = None
|
||||||
|
sync_story_status(
|
||||||
|
story,
|
||||||
|
audio_status=StoryAssetStatus.NOT_REQUESTED,
|
||||||
|
last_error=None,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return await get_story_audio_status(story_id, user_id, db)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_story_achievements(
|
||||||
story_id: int,
|
story_id: int,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
|||||||
@@ -65,6 +65,23 @@ async def test_create_reading_event_updates_stats_and_memory(
|
|||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
assert items[0].type == "recent_story"
|
assert items[0].type == "recent_story"
|
||||||
assert items[0].value["story_id"] == test_story.id
|
assert items[0].value["story_id"] == test_story.id
|
||||||
|
assert items[0].value["mode"] == test_story.mode
|
||||||
|
assert items[0].value["reading_time"] == 120
|
||||||
|
assert items[0].value["source"] == "reading_event"
|
||||||
|
|
||||||
|
timeline_response = await client.get(f"/api/profiles/{profile_id}/timeline")
|
||||||
|
assert timeline_response.status_code == 200
|
||||||
|
timeline_events = timeline_response.json()["events"]
|
||||||
|
reading_events = [
|
||||||
|
event for event in timeline_events if event["type"] == "reading_event"
|
||||||
|
]
|
||||||
|
memory_events = [event for event in timeline_events if event["type"] == "memory"]
|
||||||
|
assert reading_events
|
||||||
|
assert memory_events
|
||||||
|
assert reading_events[0]["metadata"]["story_id"] == test_story.id
|
||||||
|
assert reading_events[0]["metadata"]["reading_time"] == 120
|
||||||
|
assert memory_events[0]["metadata"]["memory_type"] == "recent_story"
|
||||||
|
assert memory_events[0]["metadata"]["story_id"] == test_story.id
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/reading-events",
|
"/api/reading-events",
|
||||||
|
|||||||
@@ -256,6 +256,53 @@ class TestAudio:
|
|||||||
assert detail["generation_status"] == "partial_ready"
|
assert detail["generation_status"] == "partial_ready"
|
||||||
assert detail["last_error"] is None
|
assert detail["last_error"] is None
|
||||||
|
|
||||||
|
status_response = auth_client.get(f"/api/audio/{test_story.id}/status")
|
||||||
|
assert status_response.status_code == 200
|
||||||
|
status_data = status_response.json()
|
||||||
|
assert status_data["audio_ready"] is True
|
||||||
|
assert status_data["cache_exists"] is True
|
||||||
|
assert status_data["cache_size_bytes"] == len(b"fake-audio-bytes")
|
||||||
|
assert status_data["cache_updated_at"] is not None
|
||||||
|
assert status_data["audio_status"] == "ready"
|
||||||
|
|
||||||
|
def test_audio_status_does_not_generate_audio(
|
||||||
|
self,
|
||||||
|
auth_client: TestClient,
|
||||||
|
test_story,
|
||||||
|
mock_tts_provider,
|
||||||
|
):
|
||||||
|
response = auth_client.get(f"/api/audio/{test_story.id}/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["audio_ready"] is False
|
||||||
|
assert data["cache_exists"] is False
|
||||||
|
assert data["audio_status"] == "not_requested"
|
||||||
|
assert "audio" in data["retryable_assets"]
|
||||||
|
mock_tts_provider.assert_not_awaited()
|
||||||
|
|
||||||
|
def test_delete_audio_cache_makes_audio_retryable(
|
||||||
|
self,
|
||||||
|
auth_client: TestClient,
|
||||||
|
test_story,
|
||||||
|
mock_tts_provider,
|
||||||
|
):
|
||||||
|
response = auth_client.get(f"/api/audio/{test_story.id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
cached_audio_path = Path(settings.story_audio_cache_dir) / f"story-{test_story.id}.mp3"
|
||||||
|
assert cached_audio_path.is_file()
|
||||||
|
|
||||||
|
response = auth_client.delete(f"/api/audio/{test_story.id}/cache")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["audio_ready"] is False
|
||||||
|
assert data["cache_exists"] is False
|
||||||
|
assert data["audio_status"] == "not_requested"
|
||||||
|
assert "audio" in data["retryable_assets"]
|
||||||
|
assert not cached_audio_path.exists()
|
||||||
|
|
||||||
def test_get_audio_regenerates_when_cache_file_is_missing(
|
def test_get_audio_regenerates_when_cache_file_is_missing(
|
||||||
self,
|
self,
|
||||||
auth_client: TestClient,
|
auth_client: TestClient,
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
- `planning/week-2-execution-backlog.md`
|
- `planning/week-2-execution-backlog.md`
|
||||||
Week 2 执行 backlog。用于继续推进演示闭环、前端状态体验和面试材料。
|
Week 2 执行 backlog。用于继续推进演示闭环、前端状态体验和面试材料。
|
||||||
|
|
||||||
|
- `planning/week-2-to-4-execution-backlog.md`
|
||||||
|
Week 2 到 Week 4 总执行 backlog。用于跟踪 Phase 2 音频缓存、时间线联动、Provider 运营摘要和 Demo 包装。
|
||||||
|
|
||||||
- `planning/demo-checklist.md`
|
- `planning/demo-checklist.md`
|
||||||
求职演示检查清单。用于演示前确认 Docker 环境、核心链路、话术和风险预案。
|
求职演示检查清单。用于演示前确认 Docker 环境、核心链路、话术和风险预案。
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh
|
|||||||
- [ ] 绘本图片 retry 后 `image_status=ready`
|
- [ ] 绘本图片 retry 后 `image_status=ready`
|
||||||
- [ ] 绘本阅读页能看到生成轨迹和资源重试历史
|
- [ ] 绘本阅读页能看到生成轨迹和资源重试历史
|
||||||
- [ ] `/admin/providers/capabilities` 返回 `text/image/tts/storybook`
|
- [ ] `/admin/providers/capabilities` 返回 `text/image/tts/storybook`
|
||||||
|
- [ ] `/api/audio/{story_id}/status` 能查询音频缓存状态且不触发生成
|
||||||
- [ ] 如果启用 `SMOKE_AUDIO=1`,音频 retry 后 `audio_status=ready`
|
- [ ] 如果启用 `SMOKE_AUDIO=1`,音频 retry 后 `audio_status=ready`
|
||||||
- [ ] 验证结果已记录到 `docs/planning/demo-validation-log.md`
|
- [ ] 验证结果已记录到 `docs/planning/demo-validation-log.md`
|
||||||
|
|
||||||
@@ -82,8 +83,9 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh
|
|||||||
- 标题和正文
|
- 标题和正文
|
||||||
- 封面状态
|
- 封面状态
|
||||||
- 音频状态
|
- 音频状态
|
||||||
|
- 音频缓存大小与更新时间
|
||||||
- 资产补全/重试入口
|
- 资产补全/重试入口
|
||||||
6. 点击音频播放,说明音频缓存复用。
|
6. 点击音频播放,说明音频缓存复用;必要时清理缓存后重新生成。
|
||||||
|
|
||||||
### 路径 B: 绘本
|
### 路径 B: 绘本
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
- 用户端与管理端 `npm run build` 均通过;生成轨迹组件已支持未终止任务自动轮询。
|
- 用户端与管理端 `npm run build` 均通过;生成轨迹组件已支持未终止任务自动轮询。
|
||||||
- `docker compose up -d --build` 已再次用当前代码重建本地演示栈。
|
- `docker compose up -d --build` 已再次用当前代码重建本地演示栈。
|
||||||
- `./scripts/demo_smoke.sh` 再次通过,并新增断言 `GET /api/generations/provider-analytics` 可以返回跨故事总调用、成功率、任务数、故事数和 Provider 明细。
|
- `./scripts/demo_smoke.sh` 再次通过,并新增断言 `GET /api/generations/provider-analytics` 可以返回跨故事总调用、成功率、任务数、故事数和 Provider 明细。
|
||||||
|
- 新增 Week 2-4 总 backlog 后,`backend/.venv/bin/python -m pytest backend/tests -q` 通过,85 个测试通过。
|
||||||
|
- 音频缓存治理首版已验证:`GET /api/audio/{story_id}/status` 查询状态不触发生成,`DELETE /api/audio/{story_id}/cache` 可清理缓存并让音频重新进入可补全状态。
|
||||||
|
- 时间线联动已验证:阅读事件会生成更完整的 recent_story 记忆,孩子时间线会展示阅读记录和记忆沉淀。
|
||||||
|
- `./scripts/demo_smoke.sh` 已覆盖音频缓存状态查询。
|
||||||
- 后端新增 `partial_ready`、`text_status` 与迁移 `0012_story_text_status` 后,`backend/.venv/bin/python -m pytest backend/tests -q` 通过,82 个测试通过。
|
- 后端新增 `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` 通过。
|
- `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` 均通过。
|
- 用户端与管理端 `npm run build` 均通过。
|
||||||
|
|||||||
77
docs/planning/week-2-to-4-execution-backlog.md
Normal file
77
docs/planning/week-2-to-4-execution-backlog.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# DreamWeaver 求职版重启:Week 2-4 执行 Backlog
|
||||||
|
|
||||||
|
**Version**: 1.0
|
||||||
|
**Date**: 2026-04-18
|
||||||
|
**Scope**: 从可演示 MVP 推进到可讲清楚、可复验、可继续生产化的项目包
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总体目标
|
||||||
|
|
||||||
|
Week 2 已完成演示闭环、统一生成工作流、generation job/event、状态模型、Provider 轨迹和 smoke 验证。Week 3-4 不继续堆大功能,而是围绕 PRD Phase 2 做增强:
|
||||||
|
|
||||||
|
1. 让音频缓存从“可用”变成“可治理、可解释”。
|
||||||
|
2. 让孩子档案、故事、阅读事件、记忆和时间线之间形成更清楚的产品闭环。
|
||||||
|
3. 让 Provider 运营视角从单故事扩展到跨故事摘要。
|
||||||
|
4. 把结果页、语音体验、测试、架构图和演示材料收成求职版项目包。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Week 2 完成状态
|
||||||
|
|
||||||
|
| ID | Workstream | Task | Output | Priority | Status |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| W2-01 | Demo | 固化本地 Docker smoke 脚本 | `scripts/demo_smoke.sh` | P0 | Done |
|
||||||
|
| W2-02 | Demo | 形成求职演示 checklist | `docs/planning/demo-checklist.md` | P0 | Done |
|
||||||
|
| W2-03 | Planning | 输出 Week 2 执行 backlog | `docs/planning/week-2-execution-backlog.md` | P0 | Done |
|
||||||
|
| W2-04 | Product | 写 3 分钟项目讲解稿 | `docs/planning/interview-pitch.md` | P0 | Done |
|
||||||
|
| W2-05 | Frontend | 打磨创建弹窗状态文案 | 用户知道故事/绘本/资产正在生成 | P0 | Done |
|
||||||
|
| W2-06 | Frontend | 强化故事详情页资产状态与重试 CTA | 图片/音频失败时可理解、可操作 | P0 | Done |
|
||||||
|
| W2-07 | Frontend | 强化绘本阅读器降级态 | 缺图、失败、加载中不出现空白体验 | P0 | Done |
|
||||||
|
| W2-08 | Backend | 梳理旧生成 API 兼容层策略 | `docs/technical/api-compatibility.md` | P1 | Done |
|
||||||
|
| W2-09 | Backend | 判断 generation job 是否需要落库 | `docs/technical/generation-job-state.md` | P1 | Done |
|
||||||
|
| W2-10 | QA | 补前端关键路径构建与 smoke 验证记录 | `docs/planning/demo-validation-log.md` | P1 | Done |
|
||||||
|
| W2-11 | Docs | 输出 Week 1 Sprint Review | `docs/planning/week-1-sprint-review.md` | P1 | Done |
|
||||||
|
| W2-12 | Docs | 更新 README 演示前检查流程 | README 本地演示说明 | P1 | Done |
|
||||||
|
| W2-13 | Tech Debt | 清理 Pydantic v2 warning、Dockerfile warning 和旧 TODO | 测试与构建无关键 warning | P1 | Done |
|
||||||
|
| W2-14 | Frontend | 同步管理端生成状态与资产补全体验 | 用户端/管理端状态体验不分叉 | P1 | Done |
|
||||||
|
| W2-15 | Security | 移除管理后台弱默认密码 | 非 debug 管理后台拒绝空/弱密码 | P1 | Done |
|
||||||
|
| W2-16 | Workflow | 轻量落库 generation job/event 与 retryable assets | 生成/资产补全过程可追踪 | P1 | Done |
|
||||||
|
| W2-17 | Workflow | 落地 `partial_ready` 与 `text_status` | 主内容可读、资产待补全、资产失败可区分 | P1 | Done |
|
||||||
|
| W2-18 | Ops | 跨故事 Provider 运营摘要 | `GET /api/generations/provider-analytics` | P1 | Done |
|
||||||
|
| W2-19 | Frontend | 生成轨迹未终止任务自动轮询 | 前端为后台 worker 进度流预留体验 | P1 | Done |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Week 3 计划
|
||||||
|
|
||||||
|
| ID | Workstream | Task | Output | Priority | Status |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| W3-01 | Planning | 固化 Week 2-4 总 backlog | 当前文档 | P0 | Done |
|
||||||
|
| W3-02 | Backend | 音频缓存治理首版 | 音频缓存元信息、stale repair、删除缓存接口 | P0 | Done |
|
||||||
|
| W3-03 | Frontend | 语音体验补充缓存状态 | 故事详情页展示缓存命中/文件大小/更新时间 | P1 | Done |
|
||||||
|
| W3-04 | Product Loop | 时间线增强阅读事件和记忆上下文 | 时间线展示已读、重听、近期记忆来源 | P0 | Done |
|
||||||
|
| W3-05 | Memory | 阅读事件写入更可解释的 memory value | recent_story 包含阅读时长、mode、封面、事件来源 | P1 | Done |
|
||||||
|
| W3-06 | QA | 为 W3-02/W3-04 补测试和 smoke 断言 | 后端测试 + smoke 覆盖新增接口 | P0 | Done |
|
||||||
|
| W3-07 | Docs | 更新 PRD、README、验证日志 | 文档与实现一致 | P1 | Done |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Week 4 计划
|
||||||
|
|
||||||
|
| ID | Workstream | Task | Output | Priority | Status |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| W4-01 | Frontend | 结果页与阅读页体验收尾 | 故事详情、绘本阅读、故事库状态一致 | P0 | Pending |
|
||||||
|
| W4-02 | Docs | 架构图与系统说明 | `docs/technical/architecture.md` 或 Mermaid 图 | P0 | Pending |
|
||||||
|
| W4-03 | Demo | 求职版 Demo 包装 | 演示路径、风险预案、检查命令一页化 | P0 | Pending |
|
||||||
|
| W4-04 | QA | 全量回归与验证记录 | pytest、ruff、前端 build、Docker smoke | P0 | Pending |
|
||||||
|
| W4-05 | Product | 项目复盘与下一阶段路线 | Week 4 review + production backlog | P1 | Pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 执行原则
|
||||||
|
|
||||||
|
- 每次推进 3-5 个能一起验证的任务,不做只改一行的小碎片。
|
||||||
|
- 每批任务必须有明确验证:测试、构建、smoke 或文档验收。
|
||||||
|
- 不扩大到支付、多租户、复杂监控或大规模视觉重做。
|
||||||
|
- 优先保留求职表达价值:能解释用户价值、AI 不确定性、Provider 分层和工程取舍。
|
||||||
@@ -331,7 +331,7 @@ DreamWeaver 是一款面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴
|
|||||||
|
|
||||||
### Phase 2: Enhancements (4 周)
|
### Phase 2: Enhancements (4 周)
|
||||||
|
|
||||||
- 音频缓存与复用
|
- 音频缓存与复用治理
|
||||||
- 记忆系统与时间线联动优化
|
- 记忆系统与时间线联动优化
|
||||||
- Provider 健康状态与成本摘要
|
- Provider 健康状态与成本摘要
|
||||||
- 演示级前端优化,包括结果页、状态页和阅读页体验
|
- 演示级前端优化,包括结果页、状态页和阅读页体验
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ DreamWeaver 当前存在以下工作流层面问题:
|
|||||||
|
|
||||||
### Phase 2: Enhancements
|
### Phase 2: Enhancements
|
||||||
|
|
||||||
- 更进一步的音频缓存策略(如过期、清理与复用治理)
|
- 更进一步的音频缓存策略(如过期策略);当前已支持缓存状态查询和手动清理
|
||||||
- 更细粒度资产状态
|
- 更细粒度资产状态
|
||||||
- 阅读位置恢复
|
- 阅读位置恢复
|
||||||
- 工作流相关日志与监控
|
- 工作流相关日志与监控
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ import BaseButton from '../components/ui/BaseButton.vue'
|
|||||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||||
import EmptyState from '../components/ui/EmptyState.vue'
|
import EmptyState from '../components/ui/EmptyState.vue'
|
||||||
import {
|
import {
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
FlagIcon,
|
FlagIcon,
|
||||||
CalendarIcon,
|
HeartIcon,
|
||||||
|
CalendarIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ExclamationCircleIcon
|
ExclamationCircleIcon
|
||||||
} from '@heroicons/vue/24/solid'
|
} from '@heroicons/vue/24/solid'
|
||||||
|
|
||||||
interface TimelineEvent {
|
interface TimelineEvent {
|
||||||
date: string
|
date: string
|
||||||
type: 'story' | 'achievement' | 'milestone'
|
type: 'story' | 'achievement' | 'milestone' | 'reading_event' | 'memory'
|
||||||
title: string
|
title: string
|
||||||
description: string | null
|
description: string | null
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
@@ -41,19 +42,23 @@ const profileId = route.params.id as string
|
|||||||
const profileName = ref('') // We might need to fetch profile details separately or store it
|
const profileName = ref('') // We might need to fetch profile details separately or store it
|
||||||
|
|
||||||
function getIcon(type: string) {
|
function getIcon(type: string) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'milestone': return FlagIcon
|
case 'milestone': return FlagIcon
|
||||||
case 'story': return BookOpenIcon
|
case 'story': return BookOpenIcon
|
||||||
case 'achievement': return TrophyIcon
|
case 'reading_event': return CalendarIcon
|
||||||
|
case 'memory': return HeartIcon
|
||||||
|
case 'achievement': return TrophyIcon
|
||||||
default: return SparklesIcon
|
default: return SparklesIcon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getColor(type: string) {
|
function getColor(type: string) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'milestone': return 'text-blue-500'
|
case 'milestone': return 'text-blue-500'
|
||||||
case 'story': return 'text-purple-500'
|
case 'story': return 'text-purple-500'
|
||||||
case 'achievement': return 'text-yellow-500'
|
case 'reading_event': return 'text-emerald-500'
|
||||||
|
case 'memory': return 'text-pink-500'
|
||||||
|
case 'achievement': return 'text-yellow-500'
|
||||||
default: return 'text-gray-500'
|
default: return 'text-gray-500'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +88,7 @@ async function fetchTimeline() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEventClick(event: TimelineEvent) {
|
function handleEventClick(event: TimelineEvent) {
|
||||||
if (event.type === 'story' && event.metadata?.story_id) {
|
if ((event.type === 'story' || event.type === 'reading_event' || event.type === 'memory') && event.metadata?.story_id) {
|
||||||
if (event.metadata.mode === 'storybook') {
|
if (event.metadata.mode === 'storybook') {
|
||||||
router.push(`/storybook/view/${event.metadata.story_id}`)
|
router.push(`/storybook/view/${event.metadata.story_id}`)
|
||||||
} else {
|
} else {
|
||||||
@@ -175,11 +180,17 @@ onMounted(fetchTimeline)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Role Badge -->
|
<!-- Role Badge -->
|
||||||
<div v-if="event.type === 'achievement'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-yellow-50 text-yellow-700 text-xs font-bold border border-yellow-200">
|
<div v-if="event.type === 'achievement'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-yellow-50 text-yellow-700 text-xs font-bold border border-yellow-200">
|
||||||
<TrophyIcon class="h-3 w-3 mr-1" /> 成就解锁
|
<TrophyIcon class="h-3 w-3 mr-1" /> 成就解锁
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-else-if="event.type === 'reading_event'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-emerald-50 text-emerald-700 text-xs font-bold border border-emerald-200">
|
||||||
</div>
|
<CalendarIcon class="h-3 w-3 mr-1" /> 阅读记录
|
||||||
|
</div>
|
||||||
|
<div v-else-if="event.type === 'memory'" class="mt-4 inline-flex items-center px-3 py-1 rounded-full bg-pink-50 text-pink-700 text-xs font-bold border border-pink-200">
|
||||||
|
<HeartIcon class="h-3 w-3 mr-1" /> 记忆沉淀
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -37,10 +37,25 @@ interface Story {
|
|||||||
}> | null
|
}> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AudioCacheStatus {
|
||||||
|
story_id: number
|
||||||
|
audio_ready: boolean
|
||||||
|
cache_exists: boolean
|
||||||
|
cache_size_bytes: number | null
|
||||||
|
cache_updated_at: string | null
|
||||||
|
generation_status: string
|
||||||
|
text_status: string
|
||||||
|
image_status: string
|
||||||
|
audio_status: string
|
||||||
|
last_error: string | null
|
||||||
|
retryable_assets: Array<'image' | 'audio'>
|
||||||
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const story = ref<Story | null>(null)
|
const story = ref<Story | null>(null)
|
||||||
|
const audioCacheStatus = ref<AudioCacheStatus | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const imageLoading = ref(false)
|
const imageLoading = ref(false)
|
||||||
const audioLoading = ref(false)
|
const audioLoading = ref(false)
|
||||||
@@ -60,6 +75,12 @@ const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status))
|
|||||||
const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false)
|
const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false)
|
||||||
const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false)
|
const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false)
|
||||||
const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
|
const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
|
||||||
|
const audioCacheLabel = computed(() => {
|
||||||
|
if (!audioCacheStatus.value?.cache_exists) return '暂无缓存'
|
||||||
|
const size = audioCacheStatus.value.cache_size_bytes ?? 0
|
||||||
|
const kb = Math.max(1, Math.round(size / 1024))
|
||||||
|
return `已缓存 · ${kb} KB`
|
||||||
|
})
|
||||||
const assetGuidance = computed(() => {
|
const assetGuidance = computed(() => {
|
||||||
if (story.value?.generation_status === 'degraded_completed') {
|
if (story.value?.generation_status === 'degraded_completed') {
|
||||||
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
|
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
|
||||||
@@ -85,6 +106,7 @@ async function refreshStorySnapshot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
story.value = data
|
story.value = data
|
||||||
|
await refreshAudioStatus().catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStory() {
|
async function fetchStory() {
|
||||||
@@ -137,6 +159,7 @@ async function loadAudio() {
|
|||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
audioUrl.value = URL.createObjectURL(blob)
|
audioUrl.value = URL.createObjectURL(blob)
|
||||||
await refreshStorySnapshot().catch(() => undefined)
|
await refreshStorySnapshot().catch(() => undefined)
|
||||||
|
await refreshAudioStatus().catch(() => undefined)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '音频加载失败'
|
error.value = e instanceof Error ? e.message : '音频加载失败'
|
||||||
await refreshStorySnapshot().catch(() => undefined)
|
await refreshStorySnapshot().catch(() => undefined)
|
||||||
@@ -159,6 +182,7 @@ async function retryAudio() {
|
|||||||
audioUrl.value = null
|
audioUrl.value = null
|
||||||
await loadAudio()
|
await loadAudio()
|
||||||
}
|
}
|
||||||
|
await refreshAudioStatus().catch(() => undefined)
|
||||||
await generationTraceRef.value?.refresh()
|
await generationTraceRef.value?.refresh()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '音频生成失败'
|
error.value = e instanceof Error ? e.message : '音频生成失败'
|
||||||
@@ -168,6 +192,28 @@ async function retryAudio() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshAudioStatus() {
|
||||||
|
if (!story.value) return
|
||||||
|
audioCacheStatus.value = await api.get<AudioCacheStatus>(`/api/audio/${story.value.id}/status`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAudioCache() {
|
||||||
|
if (!story.value) return
|
||||||
|
|
||||||
|
audioLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
audioCacheStatus.value = await api.delete<AudioCacheStatus>(`/api/audio/${story.value.id}/cache`)
|
||||||
|
audioUrl.value = null
|
||||||
|
story.value = await api.get<Story>(`/api/stories/${story.value.id}`)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '清理音频缓存失败'
|
||||||
|
} finally {
|
||||||
|
audioLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
if (!audioRef.value) return
|
if (!audioRef.value) return
|
||||||
|
|
||||||
@@ -202,6 +248,16 @@ function formatTime(seconds: number) {
|
|||||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) return '暂无'
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(value))
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteStory() {
|
async function deleteStory() {
|
||||||
if (!story.value) return
|
if (!story.value) return
|
||||||
|
|
||||||
@@ -329,6 +385,12 @@ onUnmounted(() => {
|
|||||||
<p class="text-sm text-gray-500 leading-6">
|
<p class="text-sm text-gray-500 leading-6">
|
||||||
{{ audioMeta.description }} 音频首次生成后会缓存复用,状态记录的是当前可播放结果。
|
{{ audioMeta.description }} 音频首次生成后会缓存复用,状态记录的是当前可播放结果。
|
||||||
</p>
|
</p>
|
||||||
|
<div class="mt-3 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 text-xs text-gray-500">
|
||||||
|
<div>{{ audioCacheLabel }}</div>
|
||||||
|
<div v-if="audioCacheStatus?.cache_updated_at" class="mt-1">
|
||||||
|
更新时间:{{ formatDateTime(audioCacheStatus.cache_updated_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="canRetryAudio"
|
v-if="canRetryAudio"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -339,6 +401,16 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
{{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }}
|
{{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-if="audioCacheStatus?.cache_exists"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
:loading="audioLoading"
|
||||||
|
class="mt-3 w-full"
|
||||||
|
@click="clearAudioCache"
|
||||||
|
>
|
||||||
|
清理缓存
|
||||||
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ assert_jq "$story_image_json" '.generation_status == "partial_ready" and .image_
|
|||||||
assert_jq "$story_image_json" '(.retryable_assets | index("image")) == null and (.retryable_assets | index("audio")) != null' "story image retry should leave only audio retryable"
|
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}'
|
echo "$story_image_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets}'
|
||||||
|
|
||||||
|
say "Checking story audio cache status"
|
||||||
|
story_audio_status_json="$(get_json "$APP_URL/api/audio/$story_id/status")"
|
||||||
|
assert_jq "$story_audio_status_json" '.audio_ready == false and .cache_exists == false and .audio_status == "not_requested"' "story audio status should not generate audio"
|
||||||
|
echo "$story_audio_status_json" | jq '{story_id,audio_ready,cache_exists,audio_status,retryable_assets}'
|
||||||
|
|
||||||
if [[ "$SMOKE_AUDIO" == "1" ]]; then
|
if [[ "$SMOKE_AUDIO" == "1" ]]; then
|
||||||
say "Retrying story audio"
|
say "Retrying story audio"
|
||||||
story_audio_json="$(post_json "$APP_URL/api/generations/$story_id/retry-assets" '{"assets":["audio"]}')"
|
story_audio_json="$(post_json "$APP_URL/api/generations/$story_id/retry-assets" '{"assets":["audio"]}')"
|
||||||
@@ -106,6 +111,8 @@ if [[ "$SMOKE_AUDIO" == "1" ]]; then
|
|||||||
echo "Unexpected audio response: $audio_probe" >&2
|
echo "Unexpected audio response: $audio_probe" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
story_audio_status_json="$(get_json "$APP_URL/api/audio/$story_id/status")"
|
||||||
|
assert_jq "$story_audio_status_json" '.audio_ready == true and .cache_exists == true and .cache_size_bytes > 0' "story audio should have cache metadata after generation"
|
||||||
echo "$story_audio_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets}'
|
echo "$story_audio_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets}'
|
||||||
else
|
else
|
||||||
say "Skipping audio smoke; set SMOKE_AUDIO=1 to include TTS"
|
say "Skipping audio smoke; set SMOKE_AUDIO=1 to include TTS"
|
||||||
|
|||||||
Reference in New Issue
Block a user