diff --git a/skills/README.md b/skills/README.md index e7c8192..7bae0e2 100644 --- a/skills/README.md +++ b/skills/README.md @@ -4,6 +4,14 @@ This repository includes reusable Codex skills under `skills/`. ## Available +- `landppt-skills` + - Path: `skills/landppt-skills/SKILL.md` + - Purpose: Independent LandPPT-style workflow for native editable PPTX generation, speaker scripts, narration audio, and explainer video export. + - Includes: + - Requirement, outline, prompt, and native PPTX guidance + - Self-contained local scripts for outline validation, reference PPTX analysis, native template/PPTX generation, narration, and video export + - No dependency on the LandPPT web service, database, protected routes, commercial conversion services, or HTML slide rendering + - `landppt-ppt-generation` - Path: `skills/landppt-ppt-generation/SKILL.md` - Purpose: End-to-end LandPPT PPT generation and post-edit operations via user API key. diff --git a/src/landppt/services/outline/project_outline_streaming_service.py b/src/landppt/services/outline/project_outline_streaming_service.py index 860325d..2ae4e22 100644 --- a/src/landppt/services/outline/project_outline_streaming_service.py +++ b/src/landppt/services/outline/project_outline_streaming_service.py @@ -300,6 +300,20 @@ class ProjectOutlineStreamingService: from ..file_outline_utils import extract_saved_file_outline, should_force_file_outline_regeneration force_file_outline_regeneration = should_force_file_outline_regeneration(project.confirmed_requirements or {}) ignore_saved_outline = bool(force_regenerate or force_file_outline_regeneration) + existing_outline = project.outline if isinstance(project.outline, dict) else None + existing_slides = existing_outline.get('slides', []) if existing_outline else [] + if existing_slides and not ignore_saved_outline: + import json + logger.info( + 'Project %s already has an outline with %s slides, streaming saved outline', + project_id, + len(existing_slides), + ) + await self._update_outline_generation_stage(project_id, existing_outline) + yield f"data: {json.dumps({'status': {'step': 'cached', 'message': '已加载已有大纲', 'progress': 1.0}}, ensure_ascii=False)}\n\n" + yield f"data: {json.dumps({'outline': existing_outline}, ensure_ascii=False)}\n\n" + yield f"data: {json.dumps({'done': True, 'llm_call_count': 0})}\n\n" + return if ignore_saved_outline: logger.info( 'Project %s requested fresh outline generation, skipping saved outline cache', diff --git a/src/landppt/web/route_modules/outline_generation_routes.py b/src/landppt/web/route_modules/outline_generation_routes.py index c014986..76d0e17 100644 --- a/src/landppt/web/route_modules/outline_generation_routes.py +++ b/src/landppt/web/route_modules/outline_generation_routes.py @@ -42,6 +42,38 @@ async def stream_outline_generation( raise HTTPException(status_code=404, detail="Project not found") user_ppt_service = get_ppt_service_for_user(user.id) + confirmed_requirements = project.confirmed_requirements or {} + force_file_outline_regeneration = bool( + confirmed_requirements.get("force_file_outline_regeneration") + ) + existing_outline = project.outline if isinstance(project.outline, dict) else None + existing_slides = existing_outline.get("slides", []) if existing_outline else [] + + if existing_slides and not force_regenerate and not force_file_outline_regeneration: + async def generate_saved_outline(): + import json + try: + await user_ppt_service._update_outline_generation_stage(project_id, existing_outline) + except Exception as stage_error: + logger.warning( + "Failed to mark saved outline stage completed for project %s: %s", + project_id, + stage_error, + ) + yield f"data: {json.dumps({'status': {'step': 'cached', 'message': '已加载已有大纲', 'progress': 1.0}}, ensure_ascii=False)}\n\n" + yield f"data: {json.dumps({'outline': existing_outline}, ensure_ascii=False)}\n\n" + yield f"data: {json.dumps({'done': True, 'llm_call_count': 0})}\n\n" + + return StreamingResponse( + generate_saved_outline(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + _, outline_settings = await user_ppt_service.get_role_provider_async("outline") outline_provider_name = outline_settings.get("provider") @@ -67,11 +99,7 @@ async def stream_outline_generation( async def generate(): billed = False - confirmed_requirements = project.confirmed_requirements or {} content_source = confirmed_requirements.get("content_source") - force_file_outline_regeneration = bool( - confirmed_requirements.get("force_file_outline_regeneration") - ) force_fresh_generation = bool(force_regenerate or force_file_outline_regeneration) if force_fresh_generation: logger.info( diff --git a/src/landppt/web/templates/components/project/todo_board/extra_js_1.html b/src/landppt/web/templates/components/project/todo_board/extra_js_1.html index 5436945..b8253eb 100644 --- a/src/landppt/web/templates/components/project/todo_board/extra_js_1.html +++ b/src/landppt/web/templates/components/project/todo_board/extra_js_1.html @@ -771,6 +771,12 @@ return; } + if (hasInitialOutlineSlides()) { + hydrateOutlineSectionFromProjectState(); + console.log('Saved outline exists, skipping auto-start outline generation.'); + return; + } + // Check if outline section is visible and outline generation should start const outlineSection = document.getElementById('outline-section'); if (outlineSection && outlineSection.style.display !== 'none') { @@ -1152,6 +1158,12 @@ lastOutlineErrorRetryHandler = 'regenerateOutlineNew'; const forceRegenerate = options && options.forceRegenerate === true; + if (!forceRegenerate && hasInitialOutlineSlides()) { + console.log('Saved outline exists, hydrating instead of starting outline generation.'); + hydrateOutlineSectionFromProjectState(); + return; + } + // 标记为已开始 outlineGenerationStarted = true; @@ -3158,6 +3170,12 @@ const requirementsCompleted = requirementsStage?.querySelector('.stage-status-icon')?.textContent === '✓'; const outlineStatus = outlineStage?.querySelector('.stage-status-icon')?.textContent; + if (hasInitialOutlineSlides()) { + hydrateOutlineSectionFromProjectState(); + console.log('Saved outline exists, skipping workflow auto-start.'); + return; + } + // Auto-start outline generation if requirements just confirmed or if requirements completed and outline pending if ((fromRequirements || requirementsCompleted) && outlineStatus === '⏳') { // Requirements confirmed, automatically start outline generation (second step) diff --git a/tests/test_project_workflow_regressions.py b/tests/test_project_workflow_regressions.py index 78d0f7b..d6924dc 100644 --- a/tests/test_project_workflow_regressions.py +++ b/tests/test_project_workflow_regressions.py @@ -134,3 +134,11 @@ async def test_database_service_filters_projects_by_effective_status_after_conve assert response.total == 1 assert [project.project_id for project in response.projects] == ["derived-in-progress"] + + +def test_todo_board_preserves_saved_outline_before_auto_starting_generation(): + script = _read("src/landppt/web/templates/components/project/todo_board/extra_js_1.html") + + assert "Saved outline exists, skipping auto-start outline generation." in script + assert "Saved outline exists, hydrating instead of starting outline generation." in script + assert "Saved outline exists, skipping workflow auto-start." in script diff --git a/tests/test_runtime_provider_and_outline_streaming.py b/tests/test_runtime_provider_and_outline_streaming.py index 919afcb..54d063b 100644 --- a/tests/test_runtime_provider_and_outline_streaming.py +++ b/tests/test_runtime_provider_and_outline_streaming.py @@ -388,3 +388,41 @@ async def test_generate_outline_streaming_force_regenerate_skips_saved_outline(m assert stub.stage_updates assert project.outline["title"] == "fresh" assert any('"done": true' in chunk for chunk in events) + + +@pytest.mark.asyncio +async def test_generate_outline_streaming_replays_saved_outline_without_regeneration(monkeypatch): + project = SimpleNamespace( + topic="cached topic", + outline={ + "title": "cached", + "slides": [{"page_number": 1, "title": "cached"}], + "metadata": {}, + }, + confirmed_requirements={"content_source": "manual"}, + project_metadata={}, + todo_board=None, + updated_at=0, + ) + stub = _OutlineStreamingFreshGenerationStubService(project) + service = ProjectOutlineStreamingService(stub) + + async def _unexpected_run_streaming_outline_research(*args, **kwargs): + raise AssertionError("saved outline should not trigger fresh generation") + + monkeypatch.setattr( + service, + "_run_streaming_outline_research", + _unexpected_run_streaming_outline_research, + raising=False, + ) + + events = [] + async for chunk in service.generate_outline_streaming("project-1"): + events.append(chunk) + + assert stub.project_manager.status_updates == [] + assert stub.stage_updates == [("project-1", project.outline)] + assert any('"step": "cached"' in chunk for chunk in events) + assert any('"outline"' in chunk and '"cached"' in chunk for chunk in events) + assert any('"llm_call_count": 0' in chunk for chunk in events)