feat: add week 3 audio and timeline enhancements

This commit is contained in:
2026-04-18 22:10:48 +08:00
parent 4d54c144a8
commit 70efaf3ccf
20 changed files with 606 additions and 56 deletions

View File

@@ -135,6 +135,8 @@ npm run build
| GET | `/api/generations/{story_id}/jobs` | 查询故事生成与重试历史 |
| GET | `/api/generations/{story_id}/provider-stats` | 查询 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/{story_id}` | 故事详情 |
| DELETE | `/api/stories/{story_id}` | 删除故事 |
@@ -147,6 +149,7 @@ npm run build
- `docs/product/unified-generation-workflow-prd.md`:统一生成工作流 PRD
- `docs/planning/week-1-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-validation-log.md`:本地 Docker 演示验证记录
- `docs/planning/interview-pitch.md`3 分钟项目讲解稿

View File

@@ -10,6 +10,7 @@ import {
BookOpenIcon,
TrophyIcon,
FlagIcon,
HeartIcon,
CalendarIcon,
ChevronLeftIcon,
ExclamationCircleIcon
@@ -17,7 +18,7 @@ import {
interface TimelineEvent {
date: string
type: 'story' | 'achievement' | 'milestone'
type: 'story' | 'achievement' | 'milestone' | 'reading_event' | 'memory'
title: string
description: string | null
image_url: string | null
@@ -44,6 +45,8 @@ function getIcon(type: string) {
switch (type) {
case 'milestone': return FlagIcon
case 'story': return BookOpenIcon
case 'reading_event': return CalendarIcon
case 'memory': return HeartIcon
case 'achievement': return TrophyIcon
default: return SparklesIcon
}
@@ -53,6 +56,8 @@ function getColor(type: string) {
switch (type) {
case 'milestone': return 'text-blue-500'
case 'story': return 'text-purple-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'
}
@@ -83,7 +88,7 @@ async function fetchTimeline() {
}
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') {
router.push(`/storybook/view/${event.metadata.story_id}`)
} else {
@@ -178,6 +183,12 @@ onMounted(fetchTimeline)
<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" /> 成就解锁
</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">
<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>

View File

@@ -37,10 +37,25 @@ interface Story {
}> | 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 router = useRouter()
const story = ref<Story | null>(null)
const audioCacheStatus = ref<AudioCacheStatus | null>(null)
const loading = ref(true)
const imageLoading = 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 canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false)
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(() => {
if (story.value?.generation_status === 'degraded_completed') {
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
@@ -85,6 +106,7 @@ async function refreshStorySnapshot() {
}
story.value = data
await refreshAudioStatus().catch(() => undefined)
}
async function fetchStory() {
@@ -137,6 +159,7 @@ async function loadAudio() {
const blob = await response.blob()
audioUrl.value = URL.createObjectURL(blob)
await refreshStorySnapshot().catch(() => undefined)
await refreshAudioStatus().catch(() => undefined)
} catch (e) {
error.value = e instanceof Error ? e.message : '音频加载失败'
await refreshStorySnapshot().catch(() => undefined)
@@ -159,6 +182,7 @@ async function retryAudio() {
audioUrl.value = null
await loadAudio()
}
await refreshAudioStatus().catch(() => undefined)
await generationTraceRef.value?.refresh()
} catch (e) {
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() {
if (!audioRef.value) return
@@ -202,6 +248,16 @@ function formatTime(seconds: number) {
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() {
if (!story.value) return
@@ -329,6 +385,12 @@ onUnmounted(() => {
<p class="text-sm text-gray-500 leading-6">
{{ audioMeta.description }} 音频首次生成后会缓存复用状态记录的是当前可播放结果
</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
v-if="canRetryAudio"
size="sm"
@@ -339,6 +401,16 @@ onUnmounted(() => {
>
{{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }}
</BaseButton>
<BaseButton
v-if="audioCacheStatus?.cache_exists"
size="sm"
variant="secondary"
:loading="audioLoading"
class="mt-3 w-full"
@click="clearAudioCache"
>
清理缓存
</BaseButton>
</div>
</div>

View File

@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import require_user
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()
@@ -67,7 +67,7 @@ class TimelineEvent(BaseModel):
"""Timeline event item."""
date: str
type: Literal["story", "achievement", "milestone"]
type: Literal["story", "achievement", "milestone", "reading_event", "memory"]
title: str
description: str | None = None
image_url: str | None = None
@@ -241,7 +241,10 @@ async def get_profile_timeline(
# 2. Stories
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():
events.append(TimelineEvent(
@@ -252,7 +255,89 @@ async def get_profile_timeline(
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(
select(StoryUniverse).where(StoryUniverse.child_profile_id == profile_id)
)

View File

@@ -109,7 +109,11 @@ async def create_reading_event(
value={
"story_id": story.id,
"title": story.title,
"mode": story.mode,
"image_url": story.image_url,
"event_type": payload.event_type,
"reading_time": payload.reading_time,
"source": "reading_event",
},
base_weight=weight,
last_used_at=datetime.now(timezone.utc),

View File

@@ -24,6 +24,7 @@ from app.schemas.story_schemas import (
GenerationRequest,
GenerationResponse,
StoryAssetRetryRequest,
StoryAudioStatusResponse,
StorybookRequest,
StorybookResponse,
StoryDetailResponse,
@@ -399,6 +400,26 @@ async def get_story_audio(
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])
async def get_story_achievements(
story_id: int,

View File

@@ -154,6 +154,16 @@ class StoryImageResponse(StoryStatusMixin):
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):
"""Retry selected generated assets for a story."""

View File

@@ -2,11 +2,23 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
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:
"""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()
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:
"""Persist story audio and return the saved file path."""

View File

@@ -25,6 +25,8 @@ from app.services.adapters.storybook.primary import Storybook
from app.services.adapters.text.models import StoryOutput
from app.services.audio_storage import (
audio_cache_exists,
delete_audio_cache,
get_audio_cache_metadata,
read_audio_cache,
write_story_audio_cache,
)
@@ -1538,6 +1540,63 @@ async def generate_story_audio(
raise HTTPException(status_code=500, detail="Audio generation failed")
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,
user_id: str,

View File

@@ -65,6 +65,23 @@ async def test_create_reading_event_updates_stats_and_memory(
assert len(items) == 1
assert items[0].type == "recent_story"
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(
"/api/reading-events",

View File

@@ -256,6 +256,53 @@ class TestAudio:
assert detail["generation_status"] == "partial_ready"
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(
self,
auth_client: TestClient,

View File

@@ -18,6 +18,9 @@
- `planning/week-2-execution-backlog.md`
Week 2 执行 backlog。用于继续推进演示闭环、前端状态体验和面试材料。
- `planning/week-2-to-4-execution-backlog.md`
Week 2 到 Week 4 总执行 backlog。用于跟踪 Phase 2 音频缓存、时间线联动、Provider 运营摘要和 Demo 包装。
- `planning/demo-checklist.md`
求职演示检查清单。用于演示前确认 Docker 环境、核心链路、话术和风险预案。

View File

@@ -63,6 +63,7 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh
- [ ] 绘本图片 retry 后 `image_status=ready`
- [ ] 绘本阅读页能看到生成轨迹和资源重试历史
- [ ] `/admin/providers/capabilities` 返回 `text/image/tts/storybook`
- [ ] `/api/audio/{story_id}/status` 能查询音频缓存状态且不触发生成
- [ ] 如果启用 `SMOKE_AUDIO=1`,音频 retry 后 `audio_status=ready`
- [ ] 验证结果已记录到 `docs/planning/demo-validation-log.md`
@@ -82,8 +83,9 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh
- 标题和正文
- 封面状态
- 音频状态
- 音频缓存大小与更新时间
- 资产补全/重试入口
6. 点击音频播放,说明音频缓存复用。
6. 点击音频播放,说明音频缓存复用;必要时清理缓存后重新生成
### 路径 B: 绘本

View File

@@ -10,6 +10,10 @@
- 用户端与管理端 `npm run build` 均通过;生成轨迹组件已支持未终止任务自动轮询。
- `docker compose up -d --build` 已再次用当前代码重建本地演示栈。
- `./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 个测试通过。
- `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` 均通过。

View 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 分层和工程取舍。

View File

@@ -331,7 +331,7 @@ DreamWeaver 是一款面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴
### Phase 2: Enhancements (4 周)
- 音频缓存与复用
- 音频缓存与复用治理
- 记忆系统与时间线联动优化
- Provider 健康状态与成本摘要
- 演示级前端优化,包括结果页、状态页和阅读页体验

View File

@@ -466,7 +466,7 @@ DreamWeaver 当前存在以下工作流层面问题:
### Phase 2: Enhancements
- 更进一步的音频缓存策略(如过期、清理与复用治理)
- 更进一步的音频缓存策略(如过期策略);当前已支持缓存状态查询和手动清理
- 更细粒度资产状态
- 阅读位置恢复
- 工作流相关日志与监控

View File

@@ -10,6 +10,7 @@ import {
BookOpenIcon,
TrophyIcon,
FlagIcon,
HeartIcon,
CalendarIcon,
ChevronLeftIcon,
ExclamationCircleIcon
@@ -17,7 +18,7 @@ import {
interface TimelineEvent {
date: string
type: 'story' | 'achievement' | 'milestone'
type: 'story' | 'achievement' | 'milestone' | 'reading_event' | 'memory'
title: string
description: string | null
image_url: string | null
@@ -44,6 +45,8 @@ function getIcon(type: string) {
switch (type) {
case 'milestone': return FlagIcon
case 'story': return BookOpenIcon
case 'reading_event': return CalendarIcon
case 'memory': return HeartIcon
case 'achievement': return TrophyIcon
default: return SparklesIcon
}
@@ -53,6 +56,8 @@ function getColor(type: string) {
switch (type) {
case 'milestone': return 'text-blue-500'
case 'story': return 'text-purple-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'
}
@@ -83,7 +88,7 @@ async function fetchTimeline() {
}
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') {
router.push(`/storybook/view/${event.metadata.story_id}`)
} else {
@@ -178,6 +183,12 @@ onMounted(fetchTimeline)
<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" /> 成就解锁
</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">
<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>

View File

@@ -37,10 +37,25 @@ interface Story {
}> | 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 router = useRouter()
const story = ref<Story | null>(null)
const audioCacheStatus = ref<AudioCacheStatus | null>(null)
const loading = ref(true)
const imageLoading = 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 canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false)
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(() => {
if (story.value?.generation_status === 'degraded_completed') {
return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。'
@@ -85,6 +106,7 @@ async function refreshStorySnapshot() {
}
story.value = data
await refreshAudioStatus().catch(() => undefined)
}
async function fetchStory() {
@@ -137,6 +159,7 @@ async function loadAudio() {
const blob = await response.blob()
audioUrl.value = URL.createObjectURL(blob)
await refreshStorySnapshot().catch(() => undefined)
await refreshAudioStatus().catch(() => undefined)
} catch (e) {
error.value = e instanceof Error ? e.message : '音频加载失败'
await refreshStorySnapshot().catch(() => undefined)
@@ -159,6 +182,7 @@ async function retryAudio() {
audioUrl.value = null
await loadAudio()
}
await refreshAudioStatus().catch(() => undefined)
await generationTraceRef.value?.refresh()
} catch (e) {
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() {
if (!audioRef.value) return
@@ -202,6 +248,16 @@ function formatTime(seconds: number) {
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() {
if (!story.value) return
@@ -329,6 +385,12 @@ onUnmounted(() => {
<p class="text-sm text-gray-500 leading-6">
{{ audioMeta.description }} 音频首次生成后会缓存复用状态记录的是当前可播放结果
</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
v-if="canRetryAudio"
size="sm"
@@ -339,6 +401,16 @@ onUnmounted(() => {
>
{{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }}
</BaseButton>
<BaseButton
v-if="audioCacheStatus?.cache_exists"
size="sm"
variant="secondary"
:loading="audioLoading"
class="mt-3 w-full"
@click="clearAudioCache"
>
清理缓存
</BaseButton>
</div>
</div>

View File

@@ -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"
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
say "Retrying story 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
exit 1
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}'
else
say "Skipping audio smoke; set SMOKE_AUDIO=1 to include TTS"