From e201fa3358948d8b1551fb524c97e2f85259e200 Mon Sep 17 00:00:00 2001 From: Yuyan Date: Sat, 18 Apr 2026 12:55:20 +0800 Subject: [PATCH] feat: add unified generation entrypoint --- AGENTS.md | 6 +- README.md | 8 +- .../src/components/CreateStoryModal.vue | 44 +++--- backend/app/api/stories.py | 48 ++++++- backend/app/schemas/story_schemas.py | 32 +++++ backend/app/services/story_service.py | 90 ++++++++++++ backend/tests/test_stories.py | 129 ++++++++++++++++++ docs/planning/week-1-execution-backlog.md | 12 +- .../unified-generation-workflow-prd.md | 14 +- frontend/src/components/CreateStoryModal.vue | 42 +++--- 10 files changed, 358 insertions(+), 67 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a9a1586..6e92ca0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,9 +137,9 @@ See `backend/.env.example` for required variables: | GET | `/auth/{provider}/signin` | OAuth login | | GET | `/auth/dev/signin` | Local dev login | | GET | `/auth/session` | Get current user | -| POST | `/api/stories/generate/full` | Generate story + assets | -| POST | `/api/storybook/generate` | Generate storybook | -| POST | `/api/stories/{id}/assets/retry` | Retry cover/audio assets | +| POST | `/api/generations` | Unified story/storybook generation | +| GET | `/api/generations/{id}` | Get generated result | +| POST | `/api/generations/{id}/retry-assets` | Retry generated assets | | GET | `/api/stories` | List stories (paginated) | | GET/DELETE | `/api/stories/{id}` | Story CRUD | | CRUD | `/api/profiles` | User profiles | diff --git a/README.md b/README.md index d6be393..245077f 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ cd ../admin-frontend npm run build ``` -当前已知情况:完整后端测试可通过;全量 ruff 仍有少量历史 lint 债,优先处理核心演示链路与新增代码。 +当前已知情况:完整后端测试可通过;全量 ruff 可通过。前端生产构建建议优先通过 Docker 验证,确保与本地演示环境一致。 ## 核心接口 @@ -124,9 +124,9 @@ npm run build | GET | `/auth/github/signin` | GitHub OAuth 登录 | | GET | `/auth/google/signin` | Google OAuth 登录 | | GET | `/auth/session` | 当前会话 | -| POST | `/api/stories/generate/full` | 生成故事并尝试生成封面 | -| POST | `/api/storybook/generate` | 生成绘本 | -| POST | `/api/stories/{story_id}/assets/retry` | 统一重试封面/语音资源 | +| POST | `/api/generations` | 统一生成故事或绘本 | +| GET | `/api/generations/{story_id}` | 统一读取生成结果 | +| POST | `/api/generations/{story_id}/retry-assets` | 统一重试封面/语音资源 | | GET | `/api/stories` | 故事列表 | | GET | `/api/stories/{story_id}` | 故事详情 | | DELETE | `/api/stories/{story_id}` | 删除故事 | diff --git a/admin-frontend/src/components/CreateStoryModal.vue b/admin-frontend/src/components/CreateStoryModal.vue index e83cc19..8f6faaa 100644 --- a/admin-frontend/src/components/CreateStoryModal.vue +++ b/admin-frontend/src/components/CreateStoryModal.vue @@ -133,37 +133,35 @@ async function generateStory() { } loading.value = true - error.value = '' - - try { - const payload: Record = { - type: inputType.value, - data: inputData.value, - education_theme: educationTheme.value || undefined, - } - if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value - if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value - - if (outputMode.value === 'storybook') { - const response = await api.post('/api/storybook/generate', { - keywords: inputData.value, - education_theme: educationTheme.value || undefined, - generate_images: true, - page_count: 6, - child_profile_id: selectedProfileId.value || undefined, - universe_id: selectedUniverseId.value || undefined - }) - + error.value = '' + + try { + const requestedOutputMode = + inputType.value === 'full_story' ? 'story' : outputMode.value === 'storybook' ? 'storybook' : 'story' + const payload: Record = { + output_mode: requestedOutputMode, + type: inputType.value, + data: inputData.value, + education_theme: educationTheme.value || undefined, + generate_images: true, + page_count: 6, + } + if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value + if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value + + if (requestedOutputMode === 'storybook') { + const response = await api.post('/api/generations', payload) + storybookStore.setStorybook(response) close() const storybookPath = response.id ? `/storybook/view/${response.id}` : '/storybook/view' router.push(storybookPath) } else { - const result = await api.post('/api/stories/generate/full', payload) + const result = await api.post('/api/generations', payload) const query: Record = {} if (result.errors && Object.keys(result.errors).length > 0) { if (result.errors.image) query.imageError = '1' - } + } close() router.push({ path: `/story/${result.id}`, query }) } diff --git a/backend/app/api/stories.py b/backend/app/api/stories.py index 13acfa5..033e7e5 100644 --- a/backend/app/api/stories.py +++ b/backend/app/api/stories.py @@ -17,6 +17,8 @@ from app.schemas.story_schemas import ( AchievementItem, FullStoryResponse, GenerateRequest, + GenerationRequest, + GenerationResponse, StoryAssetRetryRequest, StorybookRequest, StorybookResponse, @@ -37,13 +39,45 @@ logger = get_logger(__name__) router = APIRouter() RATE_LIMIT_WINDOW = 60 # seconds -RATE_LIMIT_REQUESTS = 10 - - -@router.post("/stories/generate", response_model=StoryResponse) -async def generate_story( - request: GenerateRequest, - user: User = Depends(require_user), +RATE_LIMIT_REQUESTS = 10 + + +@router.post("/generations", response_model=GenerationResponse) +async def create_generation( + request: GenerationRequest, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Create a story or storybook through the unified generation workflow.""" + await check_rate_limit(f"story:{user.id}", RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW) + return await story_service.generate_generation_service(request, user.id, db) + + +@router.get("/generations/{story_id}", response_model=StoryDetailResponse) +async def get_generation( + story_id: int, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Get a generated story/storybook through the unified generation API.""" + return await story_service.get_story_detail(story_id, user.id, db) + + +@router.post("/generations/{story_id}/retry-assets", response_model=StoryDetailResponse) +async def retry_generation_assets( + story_id: int, + payload: StoryAssetRetryRequest, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Retry generated assets through the unified generation API.""" + return await story_service.retry_story_assets(story_id, user.id, payload.assets, db) + + +@router.post("/stories/generate", response_model=StoryResponse) +async def generate_story( + request: GenerateRequest, + user: User = Depends(require_user), db: AsyncSession = Depends(get_db), ): """Generate or enhance a story.""" diff --git a/backend/app/schemas/story_schemas.py b/backend/app/schemas/story_schemas.py index 95ba6ef..4bb780f 100644 --- a/backend/app/schemas/story_schemas.py +++ b/backend/app/schemas/story_schemas.py @@ -29,6 +29,19 @@ class GenerateRequest(BaseModel): universe_id: str | None = None +class GenerationRequest(BaseModel): + """Unified generation request for story and storybook outputs.""" + + output_mode: Literal["story", "storybook"] = Field(default="story") + type: Literal["keywords", "full_story"] = Field(default="keywords") + data: str = Field(..., min_length=1, max_length=MAX_DATA_LENGTH) + education_theme: str | None = Field(default=None, max_length=MAX_EDU_THEME_LENGTH) + generate_images: bool = Field(default=True) + page_count: int = Field(default=6, ge=4, le=12) + child_profile_id: str | None = None + universe_id: str | None = None + + class StoryResponse(StoryStatusMixin): """Story generation response.""" @@ -99,6 +112,25 @@ class StorybookResponse(StoryStatusMixin): cover_url: str | None = None +class GenerationResponse(StoryStatusMixin): + """Unified generation response for the target workflow API.""" + + id: int + title: str + mode: str + story_text: str | None = None + pages: list[StorybookPageResponse] | None = None + cover_prompt: str | None = None + image_url: str | None = None + cover_url: str | None = None + audio_ready: bool = False + errors: dict[str, str | None] = Field(default_factory=dict) + main_character: str | None = None + art_style: str | None = None + child_profile_id: str | None = None + universe_id: str | None = None + + class StoryDetailResponse(StoryStatusMixin): """Story detail response for both stories and storybooks.""" diff --git a/backend/app/services/story_service.py b/backend/app/services/story_service.py index f283e61..ad6a098 100644 --- a/backend/app/services/story_service.py +++ b/backend/app/services/story_service.py @@ -13,6 +13,8 @@ from app.schemas.story_schemas import ( AchievementItem, FullStoryResponse, GenerateRequest, + GenerationRequest, + GenerationResponse, StorybookPageResponse, StorybookRequest, StorybookResponse, @@ -385,6 +387,94 @@ async def generate_storybook_service( audio_status=story.audio_status, last_error=story.last_error, ) + + +async def generate_generation_service( + request: GenerationRequest, + user_id: str, + db: AsyncSession, +) -> GenerationResponse: + """Unified generation workflow entry point for stories and storybooks.""" + + if request.output_mode == "storybook": + storybook = await generate_storybook_service( + StorybookRequest( + keywords=request.data, + page_count=request.page_count, + education_theme=request.education_theme, + generate_images=request.generate_images, + child_profile_id=request.child_profile_id, + universe_id=request.universe_id, + ), + user_id, + db, + ) + if storybook.id is None: + raise HTTPException(status_code=500, detail="Storybook generation did not persist.") + + saved_story = await get_story_detail(storybook.id, user_id, db) + return GenerationResponse( + id=storybook.id, + title=storybook.title, + mode="storybook", + pages=storybook.pages, + cover_prompt=storybook.cover_prompt, + image_url=storybook.cover_url, + cover_url=storybook.cover_url, + main_character=storybook.main_character, + art_style=storybook.art_style, + generation_status=storybook.generation_status, + image_status=storybook.image_status, + audio_status=storybook.audio_status, + last_error=storybook.last_error, + child_profile_id=saved_story.child_profile_id, + universe_id=saved_story.universe_id, + ) + + generate_request = GenerateRequest( + type=request.type, + data=request.data, + education_theme=request.education_theme, + child_profile_id=request.child_profile_id, + universe_id=request.universe_id, + ) + + if request.generate_images: + story = await generate_full_story_service(generate_request, user_id, db) + return GenerationResponse( + id=story.id, + title=story.title, + mode=story.mode, + story_text=story.story_text, + cover_prompt=story.cover_prompt, + image_url=story.image_url, + cover_url=story.image_url, + audio_ready=story.audio_ready, + errors=story.errors, + generation_status=story.generation_status, + image_status=story.image_status, + audio_status=story.audio_status, + last_error=story.last_error, + child_profile_id=story.child_profile_id, + universe_id=story.universe_id, + ) + + story = await generate_and_save_story(generate_request, user_id, db) + return GenerationResponse( + id=story.id, + title=story.title, + mode=story.mode, + story_text=story.story_text, + cover_prompt=story.cover_prompt, + image_url=story.image_url, + cover_url=story.image_url, + generation_status=story.generation_status, + image_status=story.image_status, + audio_status=story.audio_status, + last_error=story.last_error, + child_profile_id=story.child_profile_id, + universe_id=story.universe_id, + ) # ==================== Missing Endpoints Logic (for Issue #5) ==================== diff --git a/backend/tests/test_stories.py b/backend/tests/test_stories.py index b05eee2..5e9ca18 100644 --- a/backend/tests/test_stories.py +++ b/backend/tests/test_stories.py @@ -367,6 +367,135 @@ class TestGenerateFull: assert call_kwargs["education_theme"] == "勇气与友谊" +class TestUnifiedGenerations: + """Tests for the target unified generation API.""" + + def test_create_generation_without_auth(self, client: TestClient): + response = client.post( + "/api/generations", + json={"output_mode": "story", "type": "keywords", "data": "小兔子, 森林"}, + ) + assert response.status_code == 401 + + def test_create_story_generation_success( + self, + auth_client: TestClient, + mock_text_provider, + mock_image_provider, + ): + response = auth_client.post( + "/api/generations", + json={ + "output_mode": "story", + "type": "keywords", + "data": "小兔子, 森林, 勇气", + "generate_images": True, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] is not None + assert data["mode"] == "generated" + assert data["story_text"] == "从前有一只小兔子。" + assert data["image_url"] == "https://example.com/image.png" + assert data["cover_url"] == "https://example.com/image.png" + assert data["pages"] is None + assert data["generation_status"] == "completed" + assert data["image_status"] == "ready" + assert data["audio_status"] == "not_requested" + assert data["errors"] == {} + + def test_create_story_generation_without_assets( + self, + auth_client: TestClient, + mock_text_provider, + ): + response = auth_client.post( + "/api/generations", + json={ + "output_mode": "story", + "type": "keywords", + "data": "小兔子, 森林", + "generate_images": False, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["mode"] == "generated" + assert data["image_url"] is None + assert data["generation_status"] == "narrative_ready" + assert data["image_status"] == "not_requested" + + def test_create_storybook_generation_success(self, auth_client: TestClient): + with patch( + "app.services.story_service.generate_storybook", + new_callable=AsyncMock, + ) as mock_storybook: + with patch( + "app.services.story_service.generate_image", + new_callable=AsyncMock, + ) as mock_image: + mock_storybook.return_value = build_storybook_output() + mock_image.side_effect = [ + "https://example.com/storybook-cover.png", + "https://example.com/storybook-page-1.png", + "https://example.com/storybook-page-2.png", + ] + + response = auth_client.post( + "/api/generations", + json={ + "output_mode": "storybook", + "type": "keywords", + "data": "森林, 发光, 友情", + "page_count": 6, + "generate_images": True, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] is not None + assert data["mode"] == "storybook" + assert data["story_text"] is None + assert len(data["pages"]) == 2 + assert data["cover_url"] == "https://example.com/storybook-cover.png" + assert data["image_url"] == "https://example.com/storybook-cover.png" + assert data["main_character"] == "小兔子露露" + assert data["art_style"] == "温暖水彩" + assert data["generation_status"] == "completed" + assert data["image_status"] == "ready" + assert data["audio_status"] == "not_requested" + + def test_get_generation_alias(self, auth_client: TestClient, test_story): + response = auth_client.get(f"/api/generations/{test_story.id}") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == test_story.id + assert data["title"] == test_story.title + assert data["mode"] == "generated" + + def test_retry_generation_assets_alias( + self, + auth_client: TestClient, + degraded_story_with_text, + mock_image_provider, + ): + response = auth_client.post( + f"/api/generations/{degraded_story_with_text.id}/retry-assets", + json={"assets": ["image"]}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["image_url"] == "https://example.com/image.png" + assert data["generation_status"] == "completed" + assert data["image_status"] == "ready" + + class TestImageGenerateSuccess: """Tests for successful cover generation.""" diff --git a/docs/planning/week-1-execution-backlog.md b/docs/planning/week-1-execution-backlog.md index 8e63521..f45ab0b 100644 --- a/docs/planning/week-1-execution-backlog.md +++ b/docs/planning/week-1-execution-backlog.md @@ -53,14 +53,20 @@ - 已新增数据库迁移: - `0009_add_story_generation_statuses.py` - `0010_add_story_audio_cache_path.py` -- 已完成一轮后端回归验证:`backend/` 下 `pytest -q` 结果为 `53 passed` +- 已完成一轮后端回归验证:`backend/` 下 `pytest -q` 结果为 `63 passed` +- 已完成全量后端 lint 清理:`ruff check app tests` 可通过 - 已修复 admin-frontend 构建阻塞,主前端与管理端前端均可生产构建 - 已落地首版统一资产重试入口:`POST /api/stories/{story_id}/assets/retry` +- 已建立目标态统一生成入口: + - `POST /api/generations` + - `GET /api/generations/{story_id}` + - `POST /api/generations/{story_id}/retry-assets` +- 用户前端与 admin 前端创建弹窗已切换到统一生成入口 ### What Is In Progress -- 统一状态模型已落地,但统一 service workflow 仍未真正收束成单一路径 -- 普通故事、完整生成、绘本生成仍存在多条 service / API 路径 +- 统一状态模型与统一外部 API 已落地,但内部 service workflow 仍未真正收束成单一路径 +- 旧生成 API 仍保留为兼容层,后续需要继续降低重复实现 - 资产补全已经具备统一重试入口首版,但仍需要继续抽象统一补全过程和 generation job 边界 ### What Is Still Pending diff --git a/docs/product/unified-generation-workflow-prd.md b/docs/product/unified-generation-workflow-prd.md index 4149b80..23e082b 100644 --- a/docs/product/unified-generation-workflow-prd.md +++ b/docs/product/unified-generation-workflow-prd.md @@ -17,7 +17,7 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本 ## Implementation Snapshot -**Updated**: 2026-04-18 morning +**Updated**: 2026-04-18 afternoon 当前代码已经从“纯目标态设计”进入“部分落地”阶段,主要进展如下: @@ -34,13 +34,17 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本 - 故事音频已支持首次生成后缓存复用 - `degraded_completed` 已在服务层和前端语义中落地 - 已新增首版统一资产重试入口:`POST /api/stories/{story_id}/assets/retry` +- 已新增目标态统一生成 API: + - `POST /api/generations` + - `GET /api/generations/{story_id}` + - `POST /api/generations/{story_id}/retry-assets` +- 用户前端与 admin 前端创建弹窗已切换到 `POST /api/generations` - 故事详情页封面补全已切换到统一资产重试入口 - 管理端前端构建阻塞已修复,主前端与 admin 前端均可完成生产构建 ### Still Missing -- 统一的 `POST /api/generations` 风格入口尚未建立 -- 普通故事、完整生成、绘本生成仍通过多条 service 路径实现 +- 普通故事、完整生成、绘本生成已有统一外部入口,但内部仍通过兼容 service 路径编排 - 统一资产重试入口仍是首版:已覆盖普通故事封面、绘本缺失插图、故事音频,但尚未抽象成完整 generation job 模型 - `partial_ready`、`retryable_assets` 等更细粒度状态仍停留在目标态 @@ -56,8 +60,8 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本 DreamWeaver 当前存在以下工作流层面问题: -1. **生成入口不统一** - 普通故事走 `/api/stories/generate`,完整故事走 `/api/stories/generate/full`,绘本走 `/api/storybook/generate`,前端对结果的处理也不同。 +1. **生成入口正在统一中** + 当前前端已切到 `/api/generations`,旧的 `/api/stories/generate`、`/api/stories/generate/full`、`/api/storybook/generate` 仍作为兼容入口保留。下一步重点是继续收束 service 内部路径。 2. **保存与资产补全过程不统一** 有的流程先存文本再补图,有的流程只返回绘本对象并依赖前端 store,有的流程不考虑音频状态。 diff --git a/frontend/src/components/CreateStoryModal.vue b/frontend/src/components/CreateStoryModal.vue index fbb808c..1537dab 100644 --- a/frontend/src/components/CreateStoryModal.vue +++ b/frontend/src/components/CreateStoryModal.vue @@ -134,33 +134,31 @@ async function generateStory() { } loading.value = true - error.value = '' - - try { - const payload: Record = { - type: inputType.value, - data: inputData.value, - education_theme: educationTheme.value || undefined, - } - if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value - if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value - - if (outputMode.value === 'storybook') { - const response = await api.post('/api/storybook/generate', { - keywords: inputData.value, - education_theme: educationTheme.value || undefined, - generate_images: true, - page_count: 6, - child_profile_id: selectedProfileId.value || undefined, - universe_id: selectedUniverseId.value || undefined - }) - + error.value = '' + + try { + const requestedOutputMode = + inputType.value === 'full_story' ? 'story' : outputMode.value === 'storybook' ? 'storybook' : 'story' + const payload: Record = { + output_mode: requestedOutputMode, + type: inputType.value, + data: inputData.value, + education_theme: educationTheme.value || undefined, + generate_images: true, + page_count: 6, + } + if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value + if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value + + if (requestedOutputMode === 'storybook') { + const response = await api.post('/api/generations', payload) + storybookStore.setStorybook(response) close() const storybookPath = response.id ? `/storybook/view/${response.id}` : '/storybook/view' router.push(storybookPath) } else { - const result = await api.post('/api/stories/generate/full', payload) + const result = await api.post('/api/generations', payload) close() router.push(`/story/${result.id}`) }