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

@@ -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,
@@ -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])

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,
)
@@ -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,

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,