From eefa6e9fdddaa4091367bf73f86fb0ca7e26ff70 Mon Sep 17 00:00:00 2001 From: sligter <1771322848@qq.com> Date: Tue, 16 Jun 2026 20:10:16 +0800 Subject: [PATCH] feat: enhance outline generation with custom audience and description parameters --- src/landppt/api/landppt_api.py | 15 +- src/landppt/api/models.py | 3 + .../outline/outline_workflow_service.py | 9 +- .../outline/outline_workflow_support.py | 17 +++ .../project_outline_research_service.py | 37 ++++- .../project_outline_streaming_service.py | 4 +- .../outline_generation_routes.py | 6 + .../web/route_modules/outline_support.py | 137 +++++++++++++++--- .../route_modules/project_lifecycle_routes.py | 1 + .../project/todo_board/extra_js_1.html | 47 +++++- .../test_outline_user_context_preservation.py | 95 ++++++++++++ tests/test_outline_workflow_service.py | 84 +++++++++++ tests/test_project_workflow_regressions.py | 35 +++++ 13 files changed, 457 insertions(+), 33 deletions(-) create mode 100644 tests/test_outline_user_context_preservation.py diff --git a/src/landppt/api/landppt_api.py b/src/landppt/api/landppt_api.py index 62f343b..a223765 100644 --- a/src/landppt/api/landppt_api.py +++ b/src/landppt/api/landppt_api.py @@ -900,6 +900,9 @@ async def upload_file_and_generate_outline( focus_content: Optional[str] = Form(None), tech_highlights: Optional[str] = Form(None), target_audience: Optional[str] = Form(None), + custom_audience: Optional[str] = Form(None), + requirements: Optional[str] = Form(None), + description: Optional[str] = Form(None), network_mode: bool = Form(False), # 是否启用联网搜索(与项目创建保持一致) language: str = Form("zh") # 语言参数 ): @@ -947,9 +950,11 @@ async def upload_file_and_generate_outline( context = { 'scenario': scenario, 'target_audience': target_audience or '普通大众', - 'requirements': '', + 'custom_audience': custom_audience or '', + 'requirements': requirements or '', 'ppt_style': ppt_style, - 'description': f'文件数量: {len(files)}', + 'description': description or '', + 'source_summary': f'文件数量: {len(files)}', 'file_processing_mode': file_processing_mode, } @@ -991,7 +996,10 @@ async def upload_file_and_generate_outline( filename=final_filename, topic=topic, scenario=scenario, - requirements="", # API调用暂时没有requirements参数 + requirements=requirements, + target_audience=target_audience, + custom_audience=custom_audience, + description=description, page_count_mode=page_count_mode, min_pages=min_pages, max_pages=max_pages, @@ -1000,7 +1008,6 @@ async def upload_file_and_generate_outline( custom_style_prompt=custom_style_prompt, file_processing_mode=file_processing_mode, content_analysis_depth=content_analysis_depth, - target_audience=target_audience, language=language ) diff --git a/src/landppt/api/models.py b/src/landppt/api/models.py index b9b0137..54a1066 100644 --- a/src/landppt/api/models.py +++ b/src/landppt/api/models.py @@ -92,6 +92,7 @@ class PPTGenerationRequest(BaseModel): user_id: Optional[int] = Field(None, description="User ID for project ownership") # 目标受众和风格相关参数 target_audience: Optional[str] = Field(None, description="Target audience for the PPT") + custom_audience: Optional[str] = Field(None, description="Custom audience details") ppt_style: str = Field("general", description="PPT style: 'general', 'conference', 'custom'") custom_style_prompt: Optional[str] = Field(None, description="Custom style prompt") include_transition_pages: bool = Field(False, description="Whether to add transition slides between major sections") @@ -201,6 +202,8 @@ class FileOutlineGenerationRequest(BaseModel): scenario: str = Field("general", description="PPT scenario type") requirements: Optional[str] = Field(None, description="Specific requirements from user") target_audience: Optional[str] = Field(None, description="Target audience for the PPT") + custom_audience: Optional[str] = Field(None, description="Custom audience details") + description: Optional[str] = Field(None, description="Additional description or requirements") language: str = Field("zh", description="Language for the PPT content: 'zh' for Chinese, 'en' for English") page_count_mode: str = Field("ai_decide", description="Page count mode: 'ai_decide', 'custom_range', 'fixed'") min_pages: Optional[int] = Field(8, description="Minimum pages for custom_range mode") diff --git a/src/landppt/services/outline/outline_workflow_service.py b/src/landppt/services/outline/outline_workflow_service.py index 7756e75..5afafbd 100644 --- a/src/landppt/services/outline/outline_workflow_service.py +++ b/src/landppt/services/outline/outline_workflow_service.py @@ -16,6 +16,7 @@ from ...utils.thread_pool import run_blocking_io from .outline_workflow_support import ( build_file_info, build_processing_stats, + build_project_requirements_text, build_transition_page_requirement_text, build_validation_requirements, create_outline_from_file_content, @@ -83,7 +84,7 @@ class OutlineWorkflowService: try: logger.info("Streaming file outline generation for %s", request.filename) project_requirements = ( - (getattr(request, "requirements", "") or "") + build_project_requirements_text(request) + build_transition_page_requirement_text(request) ) try: @@ -99,7 +100,7 @@ class OutlineWorkflowService: project_scenario=request.scenario or "general", project_requirements=project_requirements, target_audience=getattr(request, "target_audience", "General audience"), - custom_audience="", + custom_audience=getattr(request, "custom_audience", "") or "", ppt_style=getattr(request, "ppt_style", "general"), custom_style_prompt=getattr(request, "custom_style_prompt", ""), page_count_mode=getattr(request, "page_count_mode", "ai_decide"), @@ -182,7 +183,7 @@ class OutlineWorkflowService: try: logger.info("Generating file outline for %s", request.filename) project_requirements = ( - (getattr(request, "requirements", "") or "") + build_project_requirements_text(request) + build_transition_page_requirement_text(request) ) try: @@ -198,7 +199,7 @@ class OutlineWorkflowService: project_scenario=request.scenario or "general", project_requirements=project_requirements, target_audience=getattr(request, "target_audience", "General audience"), - custom_audience="", + custom_audience=getattr(request, "custom_audience", "") or "", ppt_style=getattr(request, "ppt_style", "general"), custom_style_prompt=getattr(request, "custom_style_prompt", ""), page_count_mode=getattr(request, "page_count_mode", "ai_decide"), diff --git a/src/landppt/services/outline/outline_workflow_support.py b/src/landppt/services/outline/outline_workflow_support.py index c5b3581..d3eb695 100644 --- a/src/landppt/services/outline/outline_workflow_support.py +++ b/src/landppt/services/outline/outline_workflow_support.py @@ -15,6 +15,9 @@ def build_validation_requirements(request: Any, outline_title: str) -> Dict[str, return { "topic": getattr(request, "topic", None) or outline_title or "Document Presentation", "target_audience": getattr(request, "target_audience", None) or "General audience", + "custom_audience": getattr(request, "custom_audience", None) or "", + "requirements": getattr(request, "requirements", None) or "", + "description": getattr(request, "description", None) or "", "focus_content": list(getattr(request, "focus_content", []) or []), "tech_highlights": list(getattr(request, "tech_highlights", []) or []), "page_count_settings": { @@ -27,6 +30,20 @@ def build_validation_requirements(request: Any, outline_title: str) -> Dict[str, } +def build_project_requirements_text(request: Any) -> str: + parts = [] + + requirements = (getattr(request, "requirements", None) or "").strip() + if requirements: + parts.append(requirements) + + description = (getattr(request, "description", None) or "").strip() + if description and description not in requirements: + parts.append(f"Additional user instructions:\n{description}") + + return "\n\n".join(parts) + + def build_transition_page_requirement_text(request: Any) -> str: if not bool(getattr(request, "include_transition_pages", False)): return "" diff --git a/src/landppt/services/outline/project_outline_research_service.py b/src/landppt/services/outline/project_outline_research_service.py index 7ccd4fa..b7ab636 100644 --- a/src/landppt/services/outline/project_outline_research_service.py +++ b/src/landppt/services/outline/project_outline_research_service.py @@ -48,6 +48,39 @@ class ProjectOutlineResearchService: def __getattr__(self, name: str): return getattr(self._service, name) + @staticmethod + def _build_user_context_markdown(topic: str, context: Optional[Dict[str, Any]]) -> str: + context = context or {} + lines = ["## User Requirements\n"] + + if topic: + lines.append(f"- Topic: {topic}") + for label, key in ( + ("Scenario", "scenario"), + ("Target audience", "target_audience"), + ("Custom audience", "custom_audience"), + ("PPT style", "ppt_style"), + ("Custom style", "custom_style_prompt"), + ("Source summary", "source_summary"), + ): + raw_value = context.get(key) + value = raw_value.strip() if isinstance(raw_value, str) else raw_value + if value: + lines.append(f"- {label}: {value}") + + requirements = (context.get("requirements") or "").strip() + if requirements: + lines.append("\n### Specific Requirements\n") + lines.append(requirements) + + description = (context.get("description") or "").strip() + if description and description not in requirements: + lines.append("\n### Additional Instructions\n") + lines.append(description) + + lines.append("\n---\n\n") + return "\n".join(lines) + async def _generate_outline_from_research_runtime(self, request: PPTGenerationRequest, page_count_settings: Dict[str, Any]=None) -> Optional[PPTOutline]: if not getattr(self, 'enhanced_research_service', None) and not getattr(self, 'research_service', None): try: @@ -68,6 +101,7 @@ class ProjectOutlineResearchService: research_input_context = { 'scenario': request.scenario, 'target_audience': getattr(request, 'target_audience', '普通大众'), + 'custom_audience': getattr(request, 'custom_audience', ''), 'requirements': request.requirements, 'ppt_style': getattr(request, 'ppt_style', 'general'), 'description': getattr(request, 'description', ''), @@ -135,7 +169,7 @@ class ProjectOutlineResearchService: logger.info('Using %s research report file for outline generation: %s', provider, report_path) try: from ...api.models import FileOutlineGenerationRequest - file_request = FileOutlineGenerationRequest(file_path=report_path, filename=Path(report_path).name, topic=request.topic, scenario=request.scenario, requirements=request.requirements, target_audience=getattr(request, 'target_audience', '普通大众'), ppt_style=getattr(request, 'ppt_style', 'general'), custom_style_prompt=getattr(request, 'custom_style_prompt', ''), include_transition_pages=bool(getattr(request, 'include_transition_pages', False)), page_count_mode=page_count_settings.get('mode', 'ai_decide') if page_count_settings else 'ai_decide', min_pages=page_count_settings.get('min_pages') if page_count_settings else None, max_pages=page_count_settings.get('max_pages') if page_count_settings else None, fixed_pages=page_count_settings.get('fixed_pages') if page_count_settings else None, language=request.language) + file_request = FileOutlineGenerationRequest(file_path=report_path, filename=Path(report_path).name, topic=request.topic, scenario=request.scenario, requirements=request.requirements, target_audience=getattr(request, 'target_audience', '普通大众'), custom_audience=getattr(request, 'custom_audience', ''), description=getattr(request, 'description', ''), ppt_style=getattr(request, 'ppt_style', 'general'), custom_style_prompt=getattr(request, 'custom_style_prompt', ''), include_transition_pages=bool(getattr(request, 'include_transition_pages', False)), page_count_mode=page_count_settings.get('mode', 'ai_decide') if page_count_settings else 'ai_decide', min_pages=page_count_settings.get('min_pages') if page_count_settings else None, max_pages=page_count_settings.get('max_pages') if page_count_settings else None, fixed_pages=page_count_settings.get('fixed_pages') if page_count_settings else None, language=request.language) file_outline_result = await self.generate_outline_from_file(file_request) if file_outline_result.success and file_outline_result.outline: outline_data = file_outline_result.outline @@ -325,6 +359,7 @@ class ProjectOutlineResearchService: merged_content_parts.append(f'# {topic}\n') merged_content_parts.append(f"*整合文档 - 生成时间: {time.strftime('%Y-%m-%d %H:%M:%S')}*\n") merged_content_parts.append('---\n\n') + merged_content_parts.append(self._build_user_context_markdown(topic, context)) if research_markdown and os.path.exists(research_markdown): merged_content_parts.append('## 📡 联网搜索结果\n\n') with open(research_markdown, 'r', encoding='utf-8') as f: diff --git a/src/landppt/services/outline/project_outline_streaming_service.py b/src/landppt/services/outline/project_outline_streaming_service.py index 2ae4e22..83296cd 100644 --- a/src/landppt/services/outline/project_outline_streaming_service.py +++ b/src/landppt/services/outline/project_outline_streaming_service.py @@ -119,7 +119,7 @@ class ProjectOutlineStreamingService: yield await self._build_streaming_research_status_event('research_skip', '已启用联网模式,但研究服务不可用,改为直接生成大纲...', 0.02) return - research_context_data = {'scenario': project.scenario, 'target_audience': confirmed_requirements.get('target_audience', '普通大众'), 'requirements': project.requirements, 'ppt_style': confirmed_requirements.get('ppt_style', 'general'), 'description': confirmed_requirements.get('description', '')} + research_context_data = {'scenario': project.scenario, 'target_audience': confirmed_requirements.get('target_audience', '普通大众'), 'custom_audience': confirmed_requirements.get('custom_audience', ''), 'requirements': confirmed_requirements.get('requirements', project.requirements), 'ppt_style': confirmed_requirements.get('ppt_style', 'general'), 'description': confirmed_requirements.get('description', '')} research_language = 'zh' if project.project_metadata and isinstance(project.project_metadata, dict): research_language = project.project_metadata.get('language', 'zh') @@ -251,7 +251,7 @@ class ProjectOutlineStreamingService: language = 'zh' if project.project_metadata and isinstance(project.project_metadata, dict): language = project.project_metadata.get('language', 'zh') - file_request = FileOutlineGenerationRequest(file_path=report_path, filename=Path(report_path).name, topic=confirmed_requirements.get('topic', project.topic), scenario=confirmed_requirements.get('type', project.scenario), requirements=confirmed_requirements.get('requirements', project.requirements), language=language, page_count_mode=confirmed_requirements.get('page_count_settings', {}).get('mode', 'ai_decide'), min_pages=confirmed_requirements.get('page_count_settings', {}).get('min_pages', 8), max_pages=confirmed_requirements.get('page_count_settings', {}).get('max_pages', 15), fixed_pages=confirmed_requirements.get('page_count_settings', {}).get('fixed_pages', 10), ppt_style=confirmed_requirements.get('ppt_style', 'general'), custom_style_prompt=confirmed_requirements.get('custom_style_prompt'), include_transition_pages=bool(confirmed_requirements.get('include_transition_pages', False)), target_audience=confirmed_requirements.get('target_audience', '普通大众'), custom_audience=confirmed_requirements.get('custom_audience'), file_processing_mode='markitdown', content_analysis_depth='fast') + file_request = FileOutlineGenerationRequest(file_path=report_path, filename=Path(report_path).name, topic=confirmed_requirements.get('topic', project.topic), scenario=confirmed_requirements.get('type', project.scenario), requirements=confirmed_requirements.get('requirements', project.requirements), description=confirmed_requirements.get('description'), language=language, page_count_mode=confirmed_requirements.get('page_count_settings', {}).get('mode', 'ai_decide'), min_pages=confirmed_requirements.get('page_count_settings', {}).get('min_pages', 8), max_pages=confirmed_requirements.get('page_count_settings', {}).get('max_pages', 15), fixed_pages=confirmed_requirements.get('page_count_settings', {}).get('fixed_pages', 10), ppt_style=confirmed_requirements.get('ppt_style', 'general'), custom_style_prompt=confirmed_requirements.get('custom_style_prompt'), include_transition_pages=bool(confirmed_requirements.get('include_transition_pages', False)), target_audience=confirmed_requirements.get('target_audience', '普通大众'), custom_audience=confirmed_requirements.get('custom_audience'), file_processing_mode='markitdown', content_analysis_depth='fast') structured_outline = None llm_call_count = 0 last_ping_at = time.time() diff --git a/src/landppt/web/route_modules/outline_generation_routes.py b/src/landppt/web/route_modules/outline_generation_routes.py index 7e4d28e..ba8df70 100644 --- a/src/landppt/web/route_modules/outline_generation_routes.py +++ b/src/landppt/web/route_modules/outline_generation_routes.py @@ -233,6 +233,7 @@ async def generate_outline( language=language, network_mode=network_mode, target_audience=confirmed_requirements.get('target_audience', '普通大众'), + custom_audience=confirmed_requirements.get('custom_audience'), ppt_style=confirmed_requirements.get('ppt_style', 'general'), custom_style_prompt=confirmed_requirements.get('custom_style_prompt'), include_transition_pages=bool(confirmed_requirements.get('include_transition_pages', False)), @@ -339,6 +340,7 @@ async def regenerate_outline( language=language, network_mode=network_mode, target_audience=confirmed_requirements.get('target_audience', '普通大众'), + custom_audience=confirmed_requirements.get('custom_audience'), ppt_style=confirmed_requirements.get('ppt_style', 'general'), custom_style_prompt=confirmed_requirements.get('custom_style_prompt'), include_transition_pages=bool(confirmed_requirements.get('include_transition_pages', False)), @@ -441,6 +443,8 @@ async def regenerate_outline( scenario=project_request.scenario, requirements=confirmed_requirements.get('requirements', ''), target_audience=confirmed_requirements.get('target_audience', '普通大众'), + custom_audience=confirmed_requirements.get('custom_audience'), + description=confirmed_requirements.get('description'), language=language, page_count_mode=page_count_settings.get('mode', 'ai_decide'), min_pages=page_count_settings.get('min_pages', 5), @@ -681,6 +685,8 @@ async def generate_file_outline( scenario='general', requirements=final_reqs, target_audience=confirmed_requirements.get('target_audience', '普通大众'), + custom_audience=confirmed_requirements.get('custom_audience'), + description=confirmed_requirements.get('description'), language=language, page_count_mode=confirmed_requirements.get('page_count_settings', {}).get('mode', 'ai_decide'), min_pages=confirmed_requirements.get('page_count_settings', {}).get('min_pages', 8), diff --git a/src/landppt/web/route_modules/outline_support.py b/src/landppt/web/route_modules/outline_support.py index 979bc9f..c2d3d66 100644 --- a/src/landppt/web/route_modules/outline_support.py +++ b/src/landppt/web/route_modules/outline_support.py @@ -78,6 +78,72 @@ def _get_project_network_mode(project: PPTProject) -> bool: return False +def _build_source_outline_context( + *, + scenario: str, + target_audience: str, + requirements_text: str, + ppt_style: str, + custom_style_prompt: Optional[str], + confirmed_requirements: Dict[str, Any], + source_summary: str, + file_processing_mode: str, +) -> Dict[str, Any]: + return { + "scenario": scenario, + "target_audience": target_audience or "General audience", + "custom_audience": confirmed_requirements.get("custom_audience") or "", + "requirements": requirements_text or "", + "ppt_style": ppt_style, + "custom_style_prompt": custom_style_prompt or "", + "description": confirmed_requirements.get("description") or "", + "source_summary": source_summary, + "file_processing_mode": file_processing_mode, + } + + +def _build_source_outline_user_brief_markdown( + *, + topic: str, + scenario: str, + target_audience: str, + requirements: Optional[str], + description: Optional[str], + custom_audience: Optional[str], + ppt_style: str, + custom_style_prompt: Optional[str], + source_summary: str, +) -> str: + lines = ["\n## User Requirements\n"] + if topic and topic.strip(): + lines.append(f"- Topic: {topic.strip()}") + if scenario: + lines.append(f"- Scenario: {scenario}") + if target_audience: + lines.append(f"- Target audience: {target_audience}") + if custom_audience: + lines.append(f"- Custom audience: {custom_audience}") + if ppt_style: + lines.append(f"- PPT style: {ppt_style}") + if custom_style_prompt: + lines.append(f"- Custom style: {custom_style_prompt}") + if source_summary: + lines.append(f"- Source summary: {source_summary}") + + requirements_text = (requirements or "").strip() + if requirements_text: + lines.append("\n### Specific Requirements\n") + lines.append(requirements_text) + + description_text = (description or "").strip() + if description_text and description_text not in requirements_text: + lines.append("\n### Additional Instructions\n") + lines.append(description_text) + + lines.append("\n---\n") + return "\n".join(lines) + + async def _save_uploaded_files_for_confirmed_requirements(file_uploads: List[UploadFile]) -> Dict[str, Any]: from ...services.file_processor import FileProcessor @@ -216,14 +282,16 @@ async def _prepare_uploaded_source_outline_request( filename_for_request = primary_filename or file_entries[0].get("filename") or "uploaded_file" if network_mode and topic: - context = { - "scenario": scenario, - "target_audience": target_audience or "普通观众", - "requirements": requirements_text or "", - "ppt_style": ppt_style, - "description": f"文件数量: {len(file_entries)}", - "file_processing_mode": file_processing_mode, - } + context = _build_source_outline_context( + scenario=scenario, + target_audience=target_audience, + requirements_text=requirements_text, + ppt_style=ppt_style, + custom_style_prompt=custom_style_prompt, + confirmed_requirements=confirmed_requirements, + source_summary=f"File count: {len(file_entries)}", + file_processing_mode=file_processing_mode, + ) merged_file_path = await user_ppt_service.conduct_research_and_merge_with_files( topic=topic, language=language, @@ -270,6 +338,8 @@ async def _prepare_uploaded_source_outline_request( scenario=scenario, requirements=requirements_text, target_audience=target_audience, + custom_audience=confirmed_requirements.get("custom_audience"), + description=confirmed_requirements.get("description"), language=language, page_count_mode=page_count_mode, min_pages=min_pages, @@ -352,6 +422,9 @@ async def _generate_outline_from_confirmed_sources( file_processing_mode=file_processing_mode, content_analysis_depth=content_analysis_depth, requirements=requirements_text, + description=confirmed_requirements.get("description"), + custom_audience=confirmed_requirements.get("custom_audience"), + include_transition_pages=bool(confirmed_requirements.get("include_transition_pages", False)), scenario=scenario, language=language, user_id=user_id, @@ -385,14 +458,16 @@ async def _generate_outline_from_confirmed_sources( filename_for_request = primary_filename or file_entries[0].get("filename") or "uploaded_file" if network_mode and topic: - context = { - "scenario": scenario, - "target_audience": target_audience or "普通观众", - "requirements": requirements_text or "", - "ppt_style": ppt_style, - "description": f"文件数量: {len(file_entries)}", - "file_processing_mode": file_processing_mode, - } + context = _build_source_outline_context( + scenario=scenario, + target_audience=target_audience, + requirements_text=requirements_text, + ppt_style=ppt_style, + custom_style_prompt=custom_style_prompt, + confirmed_requirements=confirmed_requirements, + source_summary=f"File count: {len(file_entries)}", + file_processing_mode=file_processing_mode, + ) merged_file_path = await user_ppt_service.conduct_research_and_merge_with_files( topic=topic, language=language, @@ -438,6 +513,8 @@ async def _generate_outline_from_confirmed_sources( scenario=scenario, requirements=requirements_text, target_audience=target_audience, + custom_audience=confirmed_requirements.get("custom_audience"), + description=confirmed_requirements.get("description"), language=language, page_count_mode=page_count_mode, min_pages=min_pages, @@ -903,6 +980,9 @@ async def _process_url_sources_for_outline( file_processing_mode: str, content_analysis_depth: str, requirements: str = None, + description: str = None, + custom_audience: str = None, + include_transition_pages: bool = False, scenario: str = "general", language: str = "zh", user_id: int = None, @@ -1017,6 +1097,19 @@ async def _process_url_sources_for_outline( markdown_parts.append("# URL Source Content\n") if topic and topic.strip(): markdown_parts.append(f"\nTopic: {topic.strip()}\n") + markdown_parts.append( + _build_source_outline_user_brief_markdown( + topic=topic, + scenario=scenario, + target_audience=target_audience, + requirements=requirements, + description=description, + custom_audience=custom_audience, + ppt_style=ppt_style, + custom_style_prompt=custom_style_prompt, + source_summary=f"URL count: {len(source_urls)}", + ) + ) if processed_file_sources: markdown_parts.append("\n## Downloaded File Sources\n") @@ -1060,6 +1153,8 @@ async def _process_url_sources_for_outline( scenario=scenario, requirements=requirements, target_audience=target_audience, + custom_audience=custom_audience, + description=description, language=language, page_count_mode=page_count_mode, min_pages=min_pages, @@ -1067,7 +1162,7 @@ async def _process_url_sources_for_outline( fixed_pages=fixed_pages, ppt_style=ppt_style, custom_style_prompt=custom_style_prompt, - include_transition_pages=False, + include_transition_pages=include_transition_pages, file_processing_mode=file_processing_mode, content_analysis_depth=content_analysis_depth, ) @@ -1168,6 +1263,8 @@ async def _process_uploaded_files_for_outline( file_processing_mode: str, content_analysis_depth: str, requirements: str = None, + description: str = None, + custom_audience: str = None, enable_web_search: bool = False, # 新增参数 scenario: str = "general", # 新增参数 language: str = "zh", # 新增参数 @@ -1231,9 +1328,11 @@ async def _process_uploaded_files_for_outline( context = { 'scenario': scenario, 'target_audience': target_audience or '普通大众', + 'custom_audience': custom_audience or '', 'requirements': requirements or '', 'ppt_style': ppt_style, - 'description': f'文件数量: {len(files)}', + 'description': description or '', + 'source_summary': f'文件数量: {len(files)}', 'file_processing_mode': file_processing_mode, } @@ -1273,6 +1372,8 @@ async def _process_uploaded_files_for_outline( scenario=scenario, requirements=requirements, target_audience=target_audience, + custom_audience=custom_audience, + description=description, language=language, page_count_mode=page_count_mode, min_pages=min_pages, diff --git a/src/landppt/web/route_modules/project_lifecycle_routes.py b/src/landppt/web/route_modules/project_lifecycle_routes.py index acd5b42..1ce1151 100644 --- a/src/landppt/web/route_modules/project_lifecycle_routes.py +++ b/src/landppt/web/route_modules/project_lifecycle_routes.py @@ -177,6 +177,7 @@ async def start_project_workflow( language=language, network_mode=network_mode, target_audience=confirmed_requirements.get("target_audience", "普通大众"), + custom_audience=confirmed_requirements.get("custom_audience"), ppt_style=confirmed_requirements.get("ppt_style", "general"), custom_style_prompt=confirmed_requirements.get("custom_style_prompt"), description=confirmed_requirements.get("description"), 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 b8253eb..b9fa0d6 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 @@ -11,6 +11,10 @@ const initialProjectSlides = {{ (project.slides_data if project and project.slides_data else []) | tojson }}; const hasInitialSlidesHtml = {{ 'true' if project and project.slides_html else 'false' }}; const hasConfirmedRequirements = {{ 'true' if project and project.confirmed_requirements else 'false' }}; + {% set outline_stage = todo_board.stages | selectattr('id', 'equalto', 'outline_generation') | first %} + const initialOutlineStageStatus = {{ (outline_stage.status if outline_stage else '') | tojson }}; + let outlineResumeAutoStartScheduled = false; + let outlineHasGeneratedInSession = false; const hasProjectLanguage = ( projectMetadata && typeof projectMetadata === 'object' && @@ -607,6 +611,10 @@ ); } + function hasAnyOutlineSlides() { + return hasInitialOutlineSlides() || outlineHasGeneratedInSession; + } + function hasInitialPPTOutput() { return Boolean( (Array.isArray(initialProjectSlides) && initialProjectSlides.length > 0) || @@ -615,13 +623,38 @@ } function shouldExposePPTStartButton(pptStageStatus = null) { - if (!hasInitialOutlineSlides() || hasInitialPPTOutput()) { + if (!hasAnyOutlineSlides() || hasInitialPPTOutput()) { return false; } return !pptStageStatus || ['pending', 'failed'].includes(pptStageStatus); } + function shouldResumeOutlineGenerationOnLoad() { + if (!hasConfirmedRequirements || hasAnyOutlineSlides() || hasInitialPPTOutput()) { + return false; + } + + return ['pending', 'running', ''].includes(initialOutlineStageStatus); + } + + function scheduleOutlineResumeAutoStart(delayMs = 500) { + if (outlineResumeAutoStartScheduled || outlineGenerationStarted || hasAnyOutlineSlides()) { + return; + } + + outlineResumeAutoStartScheduled = true; + window.setTimeout(() => { + outlineResumeAutoStartScheduled = false; + if (outlineGenerationStarted || hasAnyOutlineSlides()) { + return; + } + + console.log('Resuming outline generation from saved todo state.'); + startOutlineGenerationNew(); + }, delayMs); + } + function hydrateOutlineSectionFromProjectState() { if (!shouldRenderInitialOutlineSection()) { return false; @@ -771,12 +804,17 @@ return; } - if (hasInitialOutlineSlides()) { + if (hasAnyOutlineSlides()) { hydrateOutlineSectionFromProjectState(); console.log('Saved outline exists, skipping auto-start outline generation.'); return; } + if (shouldResumeOutlineGenerationOnLoad()) { + scheduleOutlineResumeAutoStart(); + return; + } + // Check if outline section is visible and outline generation should start const outlineSection = document.getElementById('outline-section'); if (outlineSection && outlineSection.style.display !== 'none') { @@ -1158,7 +1196,7 @@ lastOutlineErrorRetryHandler = 'regenerateOutlineNew'; const forceRegenerate = options && options.forceRegenerate === true; - if (!forceRegenerate && hasInitialOutlineSlides()) { + if (!forceRegenerate && hasAnyOutlineSlides()) { console.log('Saved outline exists, hydrating instead of starting outline generation.'); hydrateOutlineSectionFromProjectState(); return; @@ -3170,7 +3208,7 @@ const requirementsCompleted = requirementsStage?.querySelector('.stage-status-icon')?.textContent === '✓'; const outlineStatus = outlineStage?.querySelector('.stage-status-icon')?.textContent; - if (hasInitialOutlineSlides()) { + if (hasAnyOutlineSlides()) { hydrateOutlineSectionFromProjectState(); console.log('Saved outline exists, skipping workflow auto-start.'); return; @@ -4131,6 +4169,7 @@ return; } + outlineHasGeneratedInSession = true; renderOutlineContentNew(parsedOutline, outlineContainer); if (loadingDiv) loadingDiv.style.display = 'none'; diff --git a/tests/test_outline_user_context_preservation.py b/tests/test_outline_user_context_preservation.py new file mode 100644 index 0000000..46191dc --- /dev/null +++ b/tests/test_outline_user_context_preservation.py @@ -0,0 +1,95 @@ +import sys +import types +from pathlib import Path +from types import SimpleNamespace + +import pytest + +if "bs4" not in sys.modules: + sys.modules["bs4"] = types.SimpleNamespace(BeautifulSoup=object, Comment=object) + +if "tavily" not in sys.modules: + sys.modules["tavily"] = types.SimpleNamespace(TavilyClient=object) + +if "langchain_core.documents" not in sys.modules: + langchain_core_module = sys.modules.setdefault( + "langchain_core", + types.ModuleType("langchain_core"), + ) + documents_module = types.ModuleType("langchain_core.documents") + documents_module.Document = object + sys.modules["langchain_core.documents"] = documents_module + setattr(langchain_core_module, "documents", documents_module) + +from landppt.services.outline.project_outline_research_service import ProjectOutlineResearchService + + +class _OutlineResearchStubService: + enhanced_research_service = None + enhanced_report_generator = None + + def _initialize_research_services(self): + return None + + +@pytest.mark.asyncio +async def test_research_file_merge_preserves_user_requirements(monkeypatch, tmp_path): + service = ProjectOutlineResearchService(_OutlineResearchStubService()) + + fake_module = types.ModuleType("landppt.services.file_processor") + + class _FakeFileProcessor: + async def process_file(self, file_path, filename, file_processing_mode=None): + return SimpleNamespace( + processed_content=f"{filename}:{file_processing_mode or 'default'}:{file_path}" + ) + + fake_module.FileProcessor = _FakeFileProcessor + monkeypatch.setitem(sys.modules, "landppt.services.file_processor", fake_module) + monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path)) + + merged_path = await service.conduct_research_and_merge_with_files( + topic="Test Topic", + language="zh", + file_paths=["dummy.txt"], + context={ + "file_processing_mode": "markitdown", + "requirements": "Highlight operational risk", + "description": "Prioritize executive action items", + "custom_audience": "CFO office", + "source_summary": "File count: 1", + }, + ) + + merged_content = Path(merged_path).read_text(encoding="utf-8") + assert "## User Requirements" in merged_content + assert "Highlight operational risk" in merged_content + assert "Prioritize executive action items" in merged_content + assert "CFO office" in merged_content + assert merged_content.index("## User Requirements") < merged_content.index("dummy.txt") + assert "markitdown" in merged_content + assert str(tmp_path) in str(Path(merged_path).parent.parent) + + +def test_url_source_outline_user_brief_preserves_requirements(): + from landppt.web.route_modules.outline_support import ( + _build_source_outline_user_brief_markdown, + ) + + brief = _build_source_outline_user_brief_markdown( + topic="AI Strategy", + scenario="business", + target_audience="Executives", + requirements="Use an investment committee perspective", + description="Avoid generic market overview", + custom_audience="Investment committee", + ppt_style="general", + custom_style_prompt="Concise and evidence-led", + source_summary="URL count: 2", + ) + + assert "## User Requirements" in brief + assert "Use an investment committee perspective" in brief + assert "Avoid generic market overview" in brief + assert "Investment committee" in brief + assert "URL count: 2" in brief diff --git a/tests/test_outline_workflow_service.py b/tests/test_outline_workflow_service.py index 53d4808..001e730 100644 --- a/tests/test_outline_workflow_service.py +++ b/tests/test_outline_workflow_service.py @@ -5,6 +5,7 @@ import pytest from landppt.services.outline.outline_workflow_service import OutlineWorkflowService from landppt.services.outline.outline_workflow_support import ( + build_project_requirements_text, create_outline_from_file_content, get_chunk_size_from_request, get_slides_range_from_request, @@ -20,6 +21,8 @@ def _build_request(**overrides): "scenario": "general", "requirements": "Keep it concise", "target_audience": "Leadership", + "custom_audience": "", + "description": "", "language": "zh", "page_count_mode": "ai_decide", "min_pages": 8, @@ -79,6 +82,18 @@ def test_outline_workflow_support_builds_fixed_page_outline(): assert outline["slides"][-1]["page_number"] == 4 +def test_outline_workflow_support_combines_requirements_and_description(): + request = _build_request( + requirements="Highlight security risks", + description="Prioritize mitigation roadmap", + ) + + project_requirements = build_project_requirements_text(request) + + assert "Highlight security risks" in project_requirements + assert "Prioritize mitigation roadmap" in project_requirements + + @pytest.mark.asyncio async def test_outline_workflow_service_falls_back_when_generator_is_unavailable(tmp_path): source_file = tmp_path / "fallback.md" @@ -120,3 +135,72 @@ async def test_outline_workflow_service_falls_back_when_generator_is_unavailable assert result.file_info["used_summeryanyfile"] is False assert result.outline["slides"][0]["title"] == "Quarterly Review" assert workflow._service.validation_requirements["topic"] == "Quarterly Review" + + +@pytest.mark.asyncio +async def test_outline_workflow_service_passes_user_context_to_summeryanyfile(tmp_path): + source_file = tmp_path / "source.md" + source_file.write_text("# Market\nContext", encoding="utf-8") + + request = _build_request( + file_path=str(source_file), + filename=source_file.name, + requirements="Highlight security risks", + description="Prioritize mitigation roadmap", + custom_audience="Board audit committee", + ) + + class FakeOutline: + total_pages = 1 + + def to_dict(self): + return { + "title": "Quarterly Review", + "slides": [ + { + "page_number": 1, + "title": "Quarterly Review", + "content_points": ["Context"], + "slide_type": "title", + } + ], + } + + class FakeGenerator: + def __init__(self): + self.kwargs = None + + async def generate_from_file(self, file_path, **kwargs): + self.kwargs = kwargs + return FakeOutline() + + class DummyService: + def __init__(self): + self.validation_requirements = None + + def _standardize_summeryfile_outline(self, outline): + return outline + + async def _validate_and_repair_outline_json(self, outline, requirements): + self.validation_requirements = requirements + return outline + + def _extract_summeryanyfile_llm_call_count(self, _generator): + return 1 + + fake_generator = FakeGenerator() + workflow = OutlineWorkflowService(DummyService()) + + async def _create_fake_generator(_request): + return fake_generator, tmp_path + + workflow._create_outline_generator = _create_fake_generator + + result = await workflow.generate_outline_from_file(request) + + assert result.success is True + assert "Highlight security risks" in fake_generator.kwargs["project_requirements"] + assert "Prioritize mitigation roadmap" in fake_generator.kwargs["project_requirements"] + assert fake_generator.kwargs["custom_audience"] == "Board audit committee" + assert workflow._service.validation_requirements["custom_audience"] == "Board audit committee" + assert workflow._service.validation_requirements["description"] == "Prioritize mitigation roadmap" diff --git a/tests/test_project_workflow_regressions.py b/tests/test_project_workflow_regressions.py index e20cb35..4e85968 100644 --- a/tests/test_project_workflow_regressions.py +++ b/tests/test_project_workflow_regressions.py @@ -197,6 +197,41 @@ def test_todo_board_preserves_saved_outline_before_auto_starting_generation(): assert "Saved outline exists, skipping workflow auto-start." in script +def test_todo_board_resumes_interrupted_outline_generation_on_reload(): + script = _read("src/landppt/web/templates/components/project/todo_board/extra_js_1.html") + + assert "initialOutlineStageStatus" in script + assert "shouldResumeOutlineGenerationOnLoad" in script + assert "['pending', 'running', ''].includes(initialOutlineStageStatus)" in script + assert "Resuming outline generation from saved todo state." in script + + +def test_todo_board_template_injects_outline_stage_status(): + from jinja2 import Environment, FileSystemLoader + + env = Environment(loader=FileSystemLoader(ROOT / "src/landppt/web/templates")) + rendered = env.get_template("components/project/todo_board/extra_js_1.html").render( + todo_board={ + "task_id": "project-1", + "stages": [ + {"id": "requirements_confirmation", "status": "completed"}, + {"id": "outline_generation", "status": "running"}, + {"id": "ppt_creation", "status": "pending"}, + ], + }, + project={ + "project_metadata": {}, + "outline": None, + "slides_data": [], + "slides_html": "", + "confirmed_requirements": {"topic": "Demo"}, + }, + ) + + assert 'const initialOutlineStageStatus = "running";' in rendered + assert "const hasConfirmedRequirements = true;" in rendered + + def test_slide_record_from_payload_preserves_outline_metadata(): record = DatabaseService._slide_record_from_payload( "project-1",