feat(outline): implement caching for existing outlines to optimize generation process

This commit is contained in:
sligter
2026-06-12 21:20:33 +08:00
parent 8275827d2b
commit 30c4f0df9b
6 changed files with 118 additions and 4 deletions
+8
View File
@@ -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.
@@ -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',
@@ -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(
@@ -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)
@@ -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
@@ -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)