feat: enhance outline generation with custom audience and description parameters

This commit is contained in:
sligter
2026-06-16 20:10:16 +08:00
parent d76c993378
commit eefa6e9fdd
13 changed files with 457 additions and 33 deletions
+11 -4
View File
@@ -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
)
+3
View File
@@ -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")
@@ -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"),
@@ -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 ""
@@ -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:
@@ -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()
@@ -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),
+119 -18
View File
@@ -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,
@@ -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"),
@@ -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';
@@ -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
+84
View File
@@ -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"
@@ -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",