From 70efaf3ccf1d001b433aa43861a70871bcb99cb3 Mon Sep 17 00:00:00 2001 From: Yuyan Date: Sat, 18 Apr 2026 22:10:48 +0800 Subject: [PATCH] feat: add week 3 audio and timeline enhancements --- README.md | 3 + .../src/views/ChildProfileTimeline.vue | 55 ++++++----- admin-frontend/src/views/StoryDetail.vue | 72 ++++++++++++++ backend/app/api/profiles.py | 93 ++++++++++++++++++- backend/app/api/reading_events.py | 4 + backend/app/api/stories.py | 25 ++++- backend/app/schemas/story_schemas.py | 10 ++ backend/app/services/audio_storage.py | 45 +++++++++ backend/app/services/story_service.py | 65 ++++++++++++- backend/tests/test_reading_events.py | 17 ++++ backend/tests/test_stories.py | 47 ++++++++++ docs/README.md | 3 + docs/planning/demo-checklist.md | 4 +- docs/planning/demo-validation-log.md | 4 + .../planning/week-2-to-4-execution-backlog.md | 77 +++++++++++++++ docs/product/job-search-relaunch-prd.md | 2 +- .../unified-generation-workflow-prd.md | 2 +- frontend/src/views/ChildProfileTimeline.vue | 55 ++++++----- frontend/src/views/StoryDetail.vue | 72 ++++++++++++++ scripts/demo_smoke.sh | 7 ++ 20 files changed, 606 insertions(+), 56 deletions(-) create mode 100644 docs/planning/week-2-to-4-execution-backlog.md diff --git a/README.md b/README.md index 8373e80..d2f8da1 100644 --- a/README.md +++ b/README.md @@ -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 分钟项目讲解稿 diff --git a/admin-frontend/src/views/ChildProfileTimeline.vue b/admin-frontend/src/views/ChildProfileTimeline.vue index a1c6e7d..d7b3882 100644 --- a/admin-frontend/src/views/ChildProfileTimeline.vue +++ b/admin-frontend/src/views/ChildProfileTimeline.vue @@ -6,18 +6,19 @@ import BaseButton from '../components/ui/BaseButton.vue' import LoadingSpinner from '../components/ui/LoadingSpinner.vue' import EmptyState from '../components/ui/EmptyState.vue' import { - SparklesIcon, - BookOpenIcon, - TrophyIcon, - FlagIcon, - CalendarIcon, + SparklesIcon, + BookOpenIcon, + TrophyIcon, + FlagIcon, + HeartIcon, + CalendarIcon, ChevronLeftIcon, ExclamationCircleIcon } from '@heroicons/vue/24/solid' -interface TimelineEvent { - date: string - type: 'story' | 'achievement' | 'milestone' +interface TimelineEvent { + date: string + type: 'story' | 'achievement' | 'milestone' | 'reading_event' | 'memory' title: string description: 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 function getIcon(type: string) { - switch (type) { - case 'milestone': return FlagIcon - case 'story': return BookOpenIcon - case 'achievement': return TrophyIcon + 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 } } function getColor(type: string) { - switch (type) { - case 'milestone': return 'text-blue-500' - case 'story': return 'text-purple-500' - case 'achievement': return 'text-yellow-500' + 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 { @@ -175,11 +180,17 @@ onMounted(fetchTimeline) -
- 成就解锁 -
- - +
+ 成就解锁 +
+
+ 阅读记录 +
+
+ 记忆沉淀 +
+ + diff --git a/admin-frontend/src/views/StoryDetail.vue b/admin-frontend/src/views/StoryDetail.vue index c8818e8..9931b8d 100644 --- a/admin-frontend/src/views/StoryDetail.vue +++ b/admin-frontend/src/views/StoryDetail.vue @@ -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(null) +const audioCacheStatus = ref(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(`/api/audio/${story.value.id}/status`) +} + +async function clearAudioCache() { + if (!story.value) return + + audioLoading.value = true + error.value = '' + + try { + audioCacheStatus.value = await api.delete(`/api/audio/${story.value.id}/cache`) + audioUrl.value = null + story.value = await api.get(`/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(() => {

{{ audioMeta.description }} 音频首次生成后会缓存复用,状态记录的是当前可播放结果。

+
+
{{ audioCacheLabel }}
+
+ 更新时间:{{ formatDateTime(audioCacheStatus.cache_updated_at) }} +
+
{ > {{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }} + + 清理缓存 + diff --git a/backend/app/api/profiles.py b/backend/app/api/profiles.py index 3ad30f9..23aaaa4 100644 --- a/backend/app/api/profiles.py +++ b/backend/app/api/profiles.py @@ -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) ) diff --git a/backend/app/api/reading_events.py b/backend/app/api/reading_events.py index f5b3f78..98b44e8 100644 --- a/backend/app/api/reading_events.py +++ b/backend/app/api/reading_events.py @@ -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), diff --git a/backend/app/api/stories.py b/backend/app/api/stories.py index 9cc4388..e4473db 100644 --- a/backend/app/api/stories.py +++ b/backend/app/api/stories.py @@ -24,6 +24,7 @@ from app.schemas.story_schemas import ( GenerationRequest, GenerationResponse, StoryAssetRetryRequest, + StoryAudioStatusResponse, StorybookRequest, StorybookResponse, StoryDetailResponse, @@ -395,8 +396,28 @@ async def get_story_audio( db: AsyncSession = Depends(get_db), ): """Get story audio (MP3).""" - audio_bytes = await story_service.generate_story_audio(story_id, user.id, db) - return Response(content=audio_bytes, media_type="audio/mpeg") + audio_bytes = await story_service.generate_story_audio(story_id, user.id, db) + 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]) diff --git a/backend/app/schemas/story_schemas.py b/backend/app/schemas/story_schemas.py index 5bda4f7..42c4324 100644 --- a/backend/app/schemas/story_schemas.py +++ b/backend/app/schemas/story_schemas.py @@ -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.""" diff --git a/backend/app/services/audio_storage.py b/backend/app/services/audio_storage.py index 82919f3..8f5c280 100644 --- a/backend/app/services/audio_storage.py +++ b/backend/app/services/audio_storage.py @@ -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.""" diff --git a/backend/app/services/story_service.py b/backend/app/services/story_service.py index 2d10927..2eecfd9 100644 --- a/backend/app/services/story_service.py +++ b/backend/app/services/story_service.py @@ -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, ) @@ -1536,9 +1538,66 @@ async def generate_story_audio( raise 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, user_id: str, db: AsyncSession, diff --git a/backend/tests/test_reading_events.py b/backend/tests/test_reading_events.py index 65a94ca..e4ef0fd 100644 --- a/backend/tests/test_reading_events.py +++ b/backend/tests/test_reading_events.py @@ -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", diff --git a/backend/tests/test_stories.py b/backend/tests/test_stories.py index 9425916..451e0f0 100644 --- a/backend/tests/test_stories.py +++ b/backend/tests/test_stories.py @@ -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, diff --git a/docs/README.md b/docs/README.md index ba2eb61..02243f4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 环境、核心链路、话术和风险预案。 diff --git a/docs/planning/demo-checklist.md b/docs/planning/demo-checklist.md index be99cc7..0bfefe9 100644 --- a/docs/planning/demo-checklist.md +++ b/docs/planning/demo-checklist.md @@ -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: 绘本 diff --git a/docs/planning/demo-validation-log.md b/docs/planning/demo-validation-log.md index 10f2204..2da8b56 100644 --- a/docs/planning/demo-validation-log.md +++ b/docs/planning/demo-validation-log.md @@ -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` 均通过。 diff --git a/docs/planning/week-2-to-4-execution-backlog.md b/docs/planning/week-2-to-4-execution-backlog.md new file mode 100644 index 0000000..dd6a409 --- /dev/null +++ b/docs/planning/week-2-to-4-execution-backlog.md @@ -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 分层和工程取舍。 diff --git a/docs/product/job-search-relaunch-prd.md b/docs/product/job-search-relaunch-prd.md index c679e96..16b1198 100644 --- a/docs/product/job-search-relaunch-prd.md +++ b/docs/product/job-search-relaunch-prd.md @@ -331,7 +331,7 @@ DreamWeaver 是一款面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴 ### Phase 2: Enhancements (4 周) -- 音频缓存与复用 +- 音频缓存与复用治理 - 记忆系统与时间线联动优化 - Provider 健康状态与成本摘要 - 演示级前端优化,包括结果页、状态页和阅读页体验 diff --git a/docs/product/unified-generation-workflow-prd.md b/docs/product/unified-generation-workflow-prd.md index 67771ec..c6d5416 100644 --- a/docs/product/unified-generation-workflow-prd.md +++ b/docs/product/unified-generation-workflow-prd.md @@ -466,7 +466,7 @@ DreamWeaver 当前存在以下工作流层面问题: ### Phase 2: Enhancements -- 更进一步的音频缓存策略(如过期、清理与复用治理) +- 更进一步的音频缓存策略(如过期策略);当前已支持缓存状态查询和手动清理 - 更细粒度资产状态 - 阅读位置恢复 - 工作流相关日志与监控 diff --git a/frontend/src/views/ChildProfileTimeline.vue b/frontend/src/views/ChildProfileTimeline.vue index a1c6e7d..d7b3882 100644 --- a/frontend/src/views/ChildProfileTimeline.vue +++ b/frontend/src/views/ChildProfileTimeline.vue @@ -6,18 +6,19 @@ import BaseButton from '../components/ui/BaseButton.vue' import LoadingSpinner from '../components/ui/LoadingSpinner.vue' import EmptyState from '../components/ui/EmptyState.vue' import { - SparklesIcon, - BookOpenIcon, - TrophyIcon, - FlagIcon, - CalendarIcon, + SparklesIcon, + BookOpenIcon, + TrophyIcon, + FlagIcon, + HeartIcon, + CalendarIcon, ChevronLeftIcon, ExclamationCircleIcon } from '@heroicons/vue/24/solid' -interface TimelineEvent { - date: string - type: 'story' | 'achievement' | 'milestone' +interface TimelineEvent { + date: string + type: 'story' | 'achievement' | 'milestone' | 'reading_event' | 'memory' title: string description: 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 function getIcon(type: string) { - switch (type) { - case 'milestone': return FlagIcon - case 'story': return BookOpenIcon - case 'achievement': return TrophyIcon + 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 } } function getColor(type: string) { - switch (type) { - case 'milestone': return 'text-blue-500' - case 'story': return 'text-purple-500' - case 'achievement': return 'text-yellow-500' + 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 { @@ -175,11 +180,17 @@ onMounted(fetchTimeline) -
- 成就解锁 -
- - +
+ 成就解锁 +
+
+ 阅读记录 +
+
+ 记忆沉淀 +
+ + diff --git a/frontend/src/views/StoryDetail.vue b/frontend/src/views/StoryDetail.vue index 7a4864c..310f1d9 100644 --- a/frontend/src/views/StoryDetail.vue +++ b/frontend/src/views/StoryDetail.vue @@ -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(null) +const audioCacheStatus = ref(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(`/api/audio/${story.value.id}/status`) +} + +async function clearAudioCache() { + if (!story.value) return + + audioLoading.value = true + error.value = '' + + try { + audioCacheStatus.value = await api.delete(`/api/audio/${story.value.id}/cache`) + audioUrl.value = null + story.value = await api.get(`/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(() => {

{{ audioMeta.description }} 音频首次生成后会缓存复用,状态记录的是当前可播放结果。

+
+
{{ audioCacheLabel }}
+
+ 更新时间:{{ formatDateTime(audioCacheStatus.cache_updated_at) }} +
+
{ > {{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }} + + 清理缓存 + diff --git a/scripts/demo_smoke.sh b/scripts/demo_smoke.sh index 5666a81..016f911 100755 --- a/scripts/demo_smoke.sh +++ b/scripts/demo_smoke.sh @@ -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"