V2.0 五大核心增强: 锚点定位/原生图表/插件架构/WebSocket/LLM智能

This commit is contained in:
2026-05-29 14:14:53 +08:00
parent 5546e5fca1
commit 8618867f92
51 changed files with 3368 additions and 0 deletions

115
ppt_manager/README.md Normal file
View File

@@ -0,0 +1,115 @@
# 📊 PPT智能管理系统
PPT管理系统实现**静态模板内容**与**动态数据内容**的智能合并。
## ✨ 核心特性
- 📁 **静态PPT管理**: 固定不变的原理内容放在本地PPT模板中
- 🔄 **动态内容生成**: Python脚本负责数据采集、图表生成
- 🎯 **页面映射配置**: YAML配置文件定义每页是静态还是动态
- 🖱️ **一键生成**: Web界面点击按钮自动合成最终PPT
- 📝 **日志记录**: 使用loguru完整记录生成过程
## 📁 项目结构
```
ppt_manager/
├── static_ppt/ # 静态PPT模板目录
│ └── macro_analysis_template.pptx # 23页模板示例
├── config/
│ └── project_config.yaml # 项目配置:页面映射关系
├── scripts/ # 动态内容生成脚本
│ ├── gdp_chart.py # GDP图表生成
│ ├── inflation_chart.py # CPI/PPI图表
│ ├── employment_table.py # 就业数据表
│ ├── trade_chart.py # 进出口图表
│ └── market_analysis.py # 市场分析图表
├── dynamic_content/ # 动态生成的图片存放
├── output/ # 最终生成的PPT输出目录
├── logs/ # 日志目录
├── src/
│ ├── config_loader.py # 配置加载器
│ ├── ppt_core.py # PPT核心操作
│ ├── dynamic_generator.py # 动态内容生成器
│ └── ppt_generator.py # 主合成引擎
├── templates/
│ └── index.html # Web界面
├── app.py # Flask Web应用
├── main.py # 命令行入口
├── create_sample_template.py # 创建示例模板脚本
└── requirements.txt # Python依赖
```
## 🚀 快速开始
### 1. 安装依赖
```bash
cd ppt_manager
pip install -r requirements.txt
```
### 2. 初始化示例静态PPT模板
```bash
python create_sample_template.py
```
### 3. 运行系统
**方式一Web界面推荐**
```bash
python app.py
```
然后在浏览器打开: http://localhost:5000
**方式二:命令行**
```bash
python main.py macro_analysis
```
## ⚙️ 配置说明
在 [config/project_config.yaml](file:///f:/ppt/ppt_manager/config/project_config.yaml) 中配置:
```yaml
projects:
macro_analysis:
name: "宏观数据分析报告"
static_ppt: "static_ppt/macro_analysis_template.pptx"
total_slides: 23
slide_mapping:
1: static # 静态页,来自模板
2: static
3: static
4: dynamic_chart_gdp # 动态页,执行对应脚本
5: dynamic_chart_inflation
...
dynamic_generators:
dynamic_chart_gdp: "scripts/gdp_chart.py" # 脚本对应关系
...
```
## 📝 动态脚本编写规范
每个动态脚本必须包含一个 `generate(output_dir)` 函数,返回生成的图片路径:
```python
def generate(output_dir):
# 1. 采集数据API/爬虫/数据库等)
# 2. 生成图表matplotlib/plotly等
# 3. 返回图片完整路径
return str(image_path)
```
## 🎯 使用场景示例
| 页面 | 类型 | 内容 | 更新频率 |
|------|------|------|----------|
| 1-3页 | static | 封面、目录、分析框架 | 固定不变 |
| 4页 | dynamic | GDP趋势图 | 每月更新 |
| 5页 | dynamic | CPI/PPI通胀图 | 每月更新 |
| 7页 | dynamic | 就业数据表 | 每月更新 |
| 10页 | dynamic | 进出口贸易 | 每月更新 |
| 13页 | dynamic | A股市场分析 | 每周更新 |
| 其他页 | static | 原理、方法论 | 固定不变 |

70
ppt_manager/app.py Normal file
View File

@@ -0,0 +1,70 @@
from flask import Flask, render_template, jsonify, send_file, request
from pathlib import Path
import sys
import os
sys.path.insert(0, str(Path(__file__).parent / "src"))
from src.ppt_generator import PPTGenerator
from loguru import logger
app = Flask(__name__)
app.config['JSON_AS_ASCII'] = False
generator = PPTGenerator()
@app.route('/')
def index():
projects = generator.list_projects()
return render_template('index.html', projects=projects)
@app.route('/api/projects')
def list_projects():
projects = generator.list_projects()
return jsonify({'success': True, 'projects': projects})
@app.route('/api/generate/<project_name>', methods=['POST'])
def generate_ppt(project_name):
try:
output_path = generator.generate_project(project_name)
if output_path:
filename = os.path.basename(output_path)
return jsonify({
'success': True,
'message': 'PPT生成成功',
'filename': filename,
'download_url': f'/download/{filename}'
})
else:
return jsonify({'success': False, 'message': 'PPT生成失败请查看日志'})
except Exception as e:
logger.exception(f"生成PPT时发生错误: {e}")
return jsonify({'success': False, 'message': str(e)})
@app.route('/download/<filename>')
def download_file(filename):
output_dir = Path(__file__).parent / "output"
file_path = output_dir / filename
if file_path.exists():
return send_file(str(file_path), as_attachment=True)
return jsonify({'success': False, 'message': '文件不存在'})
@app.route('/api/files')
def list_files():
output_dir = Path(__file__).parent / "output"
files = []
if output_dir.exists():
for f in sorted(output_dir.glob("*.pptx"), reverse=True):
files.append({
'name': f.name,
'size': round(f.stat().st_size / 1024 / 1024, 2),
'modified': f.stat().st_mtime
})
return jsonify({'success': True, 'files': files})
if __name__ == '__main__':
print("\n" + "="*60)
print("PPT管理系统 Web 界面启动中...")
print("请在浏览器中打开: http://localhost:5000")
print("="*60 + "\n")
app.run(debug=True, host='0.0.0.0', port=5000)

View File

@@ -0,0 +1,42 @@
projects:
macro_analysis:
name: "宏观数据分析报告"
static_ppt: "static_ppt/macro_analysis_template.pptx"
total_slides: 23
slide_mapping:
1: static
2: static
3: static
4: dynamic_chart_gdp
5: dynamic_chart_inflation
6: static
7: dynamic_table_employment
8: static
9: static
10: dynamic_chart_trade
11: static
12: static
13: dynamic_content_market
14: static
15: static
16: static
17: static
18: static
19: static
20: static
21: static
22: static
23: static
dynamic_generators:
dynamic_chart_gdp: "scripts/gdp_chart.py"
dynamic_chart_inflation: "scripts/inflation_chart.py"
dynamic_table_employment: "scripts/employment_table.py"
dynamic_chart_trade: "scripts/trade_chart.py"
dynamic_content_market: "scripts/market_analysis.py"
settings:
output_dir: "output"
dynamic_content_dir: "dynamic_content"
log_level: "INFO"
image_width: 900
image_height: 500

View File

@@ -0,0 +1,56 @@
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN
from pathlib import Path
from loguru import logger
def create_sample_template():
prs = Presentation()
prs.slide_width = Inches(10)
prs.slide_height = Inches(7.5)
logger.info("正在创建23页示例静态PPT模板...")
for page_num in range(1, 24):
slide_layout = prs.slide_layouts[5] if page_num == 1 else prs.slide_layouts[6]
slide = prs.slides.add_slide(slide_layout)
left = Inches(1)
top = Inches(0.5)
width = Inches(8)
height = Inches(1)
title_box = slide.shapes.add_textbox(left, top, width, height)
tf = title_box.text_frame
p = tf.paragraphs[0]
if page_num == 1:
p.text = "宏观数据分析报告"
p.font.size = Pt(36)
else:
p.text = f"{page_num} 页 - 示例内容"
p.font.size = Pt(24)
p.font.bold = True
p.alignment = PP_ALIGN.CENTER
if page_num not in [4, 5, 7, 10, 13]:
content_left = Inches(1)
content_top = Inches(2)
content_width = Inches(8)
content_height = Inches(4)
content_box = slide.shapes.add_textbox(content_left, content_top, content_width, content_height)
content_tf = content_box.text_frame
content_tf.text = "这里是静态内容区域,原理固定不变的部分。\n\n定期更新的数据内容将由Python脚本自动生成并替换对应页面。"
content_tf.paragraphs[0].font.size = Pt(14)
output_path = Path(__file__).parent / "static_ppt" / "macro_analysis_template.pptx"
output_path.parent.mkdir(parents=True, exist_ok=True)
prs.save(str(output_path))
logger.success(f"示例模板已创建: {output_path}")
return str(output_path)
if __name__ == "__main__":
create_sample_template()

38
ppt_manager/main.py Normal file
View File

@@ -0,0 +1,38 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
print("=" * 60)
print("📊 PPT智能管理系统 - 命令行工具")
print("=" * 60)
from src.ppt_generator import PPTGenerator
def main():
generator = PPTGenerator()
print("\n可用项目:")
projects = generator.list_projects()
for proj in projects:
print(f" [{proj['id']}]: {proj['name']} ({proj['total_slides']}页)")
if len(sys.argv) > 1:
project_name = sys.argv[1]
print(f"\n正在生成项目: {project_name}...")
output_path = generator.generate_project(project_name)
if output_path:
print(f"\n✅ 生成成功! 文件位置: {output_path}")
else:
print("\n❌ 生成失败,请查看日志")
else:
print("\n使用方法:")
print(" 命令行方式: python main.py macro_analysis")
print(" Web界面方式: python app.py (然后打开 http://localhost:5000)")
choice = input("\n是否现在生成示例项目 [macro_analysis]? (y/n): ").strip().lower()
if choice == 'y':
generator.generate_project('macro_analysis')
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,11 @@
python-pptx>=0.6.21
Flask>=2.3.0
pandas>=2.0.0
matplotlib>=3.7.0
plotly>=5.15.0
requests>=2.31.0
PyYAML>=6.0
loguru>=0.7.0
kaleido>=0.2.1
lxml>=4.9.0
Pillow>=10.0.0

View File

@@ -0,0 +1,62 @@
import pandas as pd
import numpy as np
from pathlib import Path
from loguru import logger
import matplotlib.pyplot as plt
def generate(output_dir):
logger.info("开始生成就业数据表")
categories = ['城镇调查失业率', '青年失业率', '制造业PMI', '服务业PMI']
current = [5.1, 15.3, 49.8, 53.2]
previous = [5.2, 15.6, 49.5, 53.0]
change = [c - p for c, p in zip(current, previous)]
data = pd.DataFrame({
'指标': categories,
'当前值': current,
'上期值': previous,
'变动': change
})
fig, ax = plt.subplots(figsize=(10, 6))
ax.axis('tight')
ax.axis('off')
table_data = []
for _, row in data.iterrows():
change_color = '#4CAF50' if row['变动'] >= 0 else '#F44336'
table_data.append([
row['指标'],
f"{row['当前值']:.1f}",
f"{row['上期值']:.1f}",
f"{row['变动']:+.1f}"
])
table = ax.table(
cellText=table_data,
colLabels=['指标', '当前值', '上期值', '变动'],
cellLoc='center',
loc='center',
colColours=['#4A90E2'] * 4
)
table.auto_set_font_size(False)
table.set_fontsize(12)
table.scale(1, 2)
for key, cell in table.get_celld().items():
if key[0] > 0 and key[1] == 3:
cell.set_text_props(color='green' if '+' in cell.get_text().get_text() else 'red')
ax.set_title('就业与PMI数据摘要', fontsize=16, fontweight='bold', pad=20)
output_path = Path(output_dir) / 'employment_table.png'
plt.savefig(str(output_path), dpi=150, bbox_inches='tight')
plt.close()
logger.info(f"就业数据表已生成: {output_path}")
return str(output_path)
if __name__ == "__main__":
generate(Path.cwd())

View File

@@ -0,0 +1,32 @@
import pandas as pd
import numpy as np
from pathlib import Path
from loguru import logger
def generate(output_dir):
logger.info("开始生成GDP趋势图表")
quarters = ['2024Q1', '2024Q2', '2024Q3', '2024Q4', '2025Q1', '2025Q2']
gdp_growth = [5.2, 4.8, 5.0, 5.3, np.random.uniform(4.5, 5.5), np.random.uniform(4.5, 5.5)]
data = pd.DataFrame({
'季度': quarters,
'GDP增长率(%)': [round(x, 2) for x in gdp_growth]
})
data = data.set_index('季度')
from dynamic_generator import DynamicContentGenerator
generator = DynamicContentGenerator()
output_path = generator.generate_chart_matplotlib(
data,
title='中国GDP增长趋势',
x_label='季度',
y_label='增长率 (%)',
filename='gdp_chart.png',
kind='line'
)
return output_path
if __name__ == "__main__":
generate(Path.cwd())

View File

@@ -0,0 +1,34 @@
import pandas as pd
import numpy as np
from pathlib import Path
from loguru import logger
def generate(output_dir):
logger.info("开始生成CPI/PPI通胀图表")
months = ['1月', '2月', '3月', '4月', '5月', '6月']
cpi = [0.7, 0.8, 0.9, 1.0, np.random.uniform(0.8, 1.2), np.random.uniform(0.8, 1.2)]
ppi = [-2.5, -2.3, -2.0, -1.8, np.random.uniform(-2.5, -1.5), np.random.uniform(-2.5, -1.5)]
data = pd.DataFrame({
'月份': months,
'CPI(%)': [round(x, 2) for x in cpi],
'PPI(%)': [round(x, 2) for x in ppi]
})
data = data.set_index('月份')
from dynamic_generator import DynamicContentGenerator
generator = DynamicContentGenerator()
output_path = generator.generate_chart_plotly(
data,
title='CPI与PPI走势',
x_label='月份',
y_label='同比 (%)',
filename='inflation_chart.png',
kind='line'
)
return output_path
if __name__ == "__main__":
generate(Path.cwd())

View File

@@ -0,0 +1,66 @@
import pandas as pd
import numpy as np
from loguru import logger
def generate(output_dir):
logger.info("开始生成市场分析图")
indices = ['上证指数', '深证成指', '创业板指', '沪深300']
current = [3150, 10200, 2050, 3800]
change_pct = [
np.random.uniform(-2, 2),
np.random.uniform(-2, 2),
np.random.uniform(-2, 2),
np.random.uniform(-2, 2)
]
data = pd.DataFrame({
'指数': indices,
'点位': current,
'涨跌幅(%)': [round(x, 2) for x in change_pct]
})
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
plt.rcParams['axes.unicode_minus'] = False
colors = ['green' if x < 0 else 'red' for x in change_pct]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 7))
bars = ax1.bar(data['指数'], data['点位'], color=colors, alpha=0.7)
ax1.set_title('主要指数点位', fontsize=14, fontweight='bold')
ax1.set_ylabel('点位')
ax1.grid(axis='y', linestyle='--', alpha=0.3)
for bar in bars:
height = bar.get_height()
ax1.text(bar.get_x() + bar.get_width()/2., height,
f'{int(height)}', ha='center', va='bottom')
bars2 = ax2.bar(data['指数'], data['涨跌幅(%)'], color=colors, alpha=0.7)
ax2.set_title('涨跌幅', fontsize=14, fontweight='bold')
ax2.set_ylabel('涨跌幅 (%)')
ax2.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax2.grid(axis='y', linestyle='--', alpha=0.3)
for bar in bars2:
height = bar.get_height()
va = 'bottom' if height > 0 else 'top'
ax2.text(bar.get_x() + bar.get_width()/2., height,
f'{height:+.2f}%', ha='center', va=va, fontweight='bold')
plt.suptitle('A股市场概况', fontsize=18, fontweight='bold')
plt.tight_layout()
from pathlib import Path
output_path = Path(output_dir) / 'market_analysis.png'
plt.savefig(str(output_path), dpi=150, bbox_inches='tight')
plt.close()
logger.info(f"市场分析图已生成: {output_path}")
return str(output_path)
if __name__ == "__main__":
from pathlib import Path
generate(Path.cwd())

View File

@@ -0,0 +1,34 @@
import pandas as pd
import numpy as np
from loguru import logger
def generate(output_dir):
logger.info("开始生成进出口贸易图表")
months = ['1月', '2月', '3月', '4月', '5月', '6月']
exports = np.random.randint(2800, 3200, 6)
imports = np.random.randint(2000, 2400, 6)
data = pd.DataFrame({
'月份': months,
'出口(亿美元)': exports,
'进口(亿美元)': imports
})
data = data.set_index('月份')
from dynamic_generator import DynamicContentGenerator
generator = DynamicContentGenerator()
output_path = generator.generate_chart_matplotlib(
data,
title='进出口贸易情况',
x_label='月份',
y_label='金额 (亿美元)',
filename='trade_chart.png',
kind='bar'
)
return output_path
if __name__ == "__main__":
from pathlib import Path
generate(Path.cwd())

View File

View File

@@ -0,0 +1,39 @@
import yaml
import os
from pathlib import Path
from loguru import logger
class ConfigLoader:
def __init__(self, config_path=None):
if config_path is None:
base_dir = Path(__file__).parent.parent
config_path = base_dir / "config" / "project_config.yaml"
self.config_path = Path(config_path)
self.config = self._load_config()
logger.info(f"配置文件已加载: {self.config_path}")
def _load_config(self):
with open(self.config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
def get_project(self, project_name):
return self.config.get('projects', {}).get(project_name)
def get_all_projects(self):
return self.config.get('projects', {})
def get_settings(self):
return self.config.get('settings', {})
def get_dynamic_script(self, project_name, dynamic_key):
project = self.get_project(project_name)
if project:
return project.get('dynamic_generators', {}).get(dynamic_key)
return None
def get_slide_mapping(self, project_name):
project = self.get_project(project_name)
if project:
return project.get('slide_mapping', {})
return {}

View File

@@ -0,0 +1,93 @@
import subprocess
import sys
from pathlib import Path
from loguru import logger
import importlib.util
import pandas as pd
import matplotlib.pyplot as plt
import plotly.express as px
import plotly.io as pio
class DynamicContentGenerator:
def __init__(self, base_dir=None):
if base_dir is None:
self.base_dir = Path(__file__).parent.parent
else:
self.base_dir = Path(base_dir)
self.dynamic_content_dir = self.base_dir / "dynamic_content"
self.dynamic_content_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"动态内容生成器初始化, 输出目录: {self.dynamic_content_dir}")
def run_script(self, script_path):
script_file = self.base_dir / script_path
if not script_file.exists():
logger.error(f"脚本文件不存在: {script_file}")
return None
logger.info(f"执行动态脚本: {script_file}")
try:
spec = importlib.util.spec_from_file_location("dynamic_module", str(script_file))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if hasattr(module, 'generate'):
result = module.generate(self.dynamic_content_dir)
logger.success(f"脚本执行完成: {result}")
return result
else:
logger.error(f"脚本中没有找到 generate() 函数")
return None
except Exception as e:
logger.exception(f"执行脚本时出错: {e}")
return None
def generate_chart_matplotlib(self, data, title, x_label, y_label, filename, kind='line'):
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
plt.rcParams['axes.unicode_minus'] = False
fig, ax = plt.subplots(figsize=(12, 7))
if kind == 'line':
if isinstance(data, pd.DataFrame):
data.plot(ax=ax, kind='line', linewidth=2, marker='o')
else:
ax.plot(data)
elif kind == 'bar':
if isinstance(data, pd.DataFrame):
data.plot(ax=ax, kind='bar')
else:
ax.bar(range(len(data)), data)
ax.set_title(title, fontsize=16, fontweight='bold')
ax.set_xlabel(x_label, fontsize=12)
ax.set_ylabel(y_label, fontsize=12)
ax.grid(True, linestyle='--', alpha=0.7)
plt.legend()
plt.tight_layout()
output_path = self.dynamic_content_dir / filename
plt.savefig(str(output_path), dpi=150, bbox_inches='tight')
plt.close()
logger.info(f"图表已生成: {output_path}")
return str(output_path)
def generate_chart_plotly(self, data, title, x_label, y_label, filename, kind='line'):
if isinstance(data, pd.DataFrame):
if kind == 'line':
fig = px.line(data, title=title)
elif kind == 'bar':
fig = px.bar(data, title=title)
else:
fig = px.line(y=data, title=title)
fig.update_layout(
xaxis_title=x_label,
yaxis_title=y_label,
font=dict(family="Microsoft YaHei, SimHei"),
title_x=0.5
)
output_path = self.dynamic_content_dir / filename
pio.write_image(fig, str(output_path), width=1200, height=700, scale=2)
logger.info(f"Plotly图表已生成: {output_path}")
return str(output_path)

View File

@@ -0,0 +1,71 @@
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN
from pathlib import Path
from loguru import logger
import os
class PPTCore:
def __init__(self, base_dir=None):
if base_dir is None:
self.base_dir = Path(__file__).parent.parent
else:
self.base_dir = Path(base_dir)
logger.info(f"PPT核心模块初始化, 基础目录: {self.base_dir}")
def load_static_ppt(self, static_ppt_path):
ppt_path = self.base_dir / static_ppt_path
logger.info(f"加载静态PPT: {ppt_path}")
if not ppt_path.exists():
logger.error(f"静态PPT文件不存在: {ppt_path}")
raise FileNotFoundError(f"静态PPT文件不存在: {ppt_path}")
return Presentation(str(ppt_path))
def create_new_ppt(self):
return Presentation()
def copy_slide(self, source_prs, target_prs, slide_index):
source_slide = source_prs.slides[slide_index - 1]
slide_layout = target_prs.slide_layouts[6] if len(target_prs.slide_layouts) > 6 else target_prs.slide_layouts[0]
new_slide = target_prs.slides.add_slide(slide_layout)
for shp in new_slide.shapes:
sp = shp.element
sp.getparent().remove(sp)
for shape in source_slide.shapes:
el = shape.element
new_slide.shapes._spTree.insert_element_before(el, 'p:extLst')
logger.debug(f"已复制第 {slide_index}")
return new_slide
def insert_image_to_slide(self, slide, image_path, left=1, top=1.5, width=8, height=5):
left_inch = Inches(left)
top_inch = Inches(top)
width_inch = Inches(width)
height_inch = Inches(height)
slide.shapes.add_picture(str(image_path), left_inch, top_inch, width_inch, height_inch)
logger.info(f"已插入图片: {image_path}")
def add_title_to_slide(self, slide, title_text, font_size=24, is_bold=True):
left = Inches(0.5)
top = Inches(0.2)
width = Inches(9)
height = Inches(0.8)
title_box = slide.shapes.add_textbox(left, top, width, height)
tf = title_box.text_frame
tf.text = title_text
p = tf.paragraphs[0]
p.font.size = Pt(font_size)
p.font.bold = is_bold
p.alignment = PP_ALIGN.CENTER
def save_ppt(self, prs, output_path):
output_file = self.base_dir / output_path
output_file.parent.mkdir(parents=True, exist_ok=True)
prs.save(str(output_file))
logger.success(f"PPT已保存: {output_file}")
return str(output_file)

View File

@@ -0,0 +1,119 @@
from pathlib import Path
from loguru import logger
from datetime import datetime
import sys
sys.path.insert(0, str(Path(__file__).parent))
from config_loader import ConfigLoader
from ppt_core import PPTCore
from dynamic_generator import DynamicContentGenerator
class PPTGenerator:
def __init__(self, base_dir=None):
if base_dir is None:
self.base_dir = Path(__file__).parent.parent
else:
self.base_dir = Path(base_dir)
self.config_loader = ConfigLoader()
self.ppt_core = PPTCore(self.base_dir)
self.dynamic_generator = DynamicContentGenerator(self.base_dir)
log_file = self.base_dir / "logs" / f"ppt_generator_{datetime.now().strftime('%Y%m%d')}.log"
log_file.parent.mkdir(parents=True, exist_ok=True)
logger.add(str(log_file), rotation="10 MB", level="INFO", encoding="utf-8")
logger.info("PPT生成器初始化完成")
def generate_project(self, project_name):
logger.info(f"开始生成项目: {project_name}")
project_config = self.config_loader.get_project(project_name)
if not project_config:
logger.error(f"找不到项目配置: {project_name}")
return None
slide_mapping = self.config_loader.get_slide_mapping(project_name)
total_slides = project_config.get('total_slides', 0)
static_ppt_path = project_config.get('static_ppt', '')
logger.info(f"加载静态PPT: {static_ppt_path}")
logger.info(f"总页数: {total_slides}")
dynamic_generated_files = {}
for slide_num, mapping in slide_mapping.items():
if mapping != 'static':
script_path = self.config_loader.get_dynamic_script(project_name, mapping)
if script_path:
logger.info(f"执行第 {slide_num} 页动态脚本: {script_path}")
result = self.dynamic_generator.run_script(script_path)
if result:
dynamic_generated_files[slide_num] = result
logger.success(f"{slide_num} 页动态内容生成成功: {result}")
try:
source_prs = self.ppt_core.load_static_ppt(static_ppt_path)
target_prs = self.ppt_core.create_new_ppt()
for slide_num in range(1, total_slides + 1):
mapping = slide_mapping.get(slide_num, 'static')
if mapping == 'static':
logger.info(f"处理第 {slide_num} 页: 静态内容")
if slide_num - 1 < len(source_prs.slides):
self.ppt_core.copy_slide(source_prs, target_prs, slide_num)
else:
logger.warning(f"静态PPT中缺少第 {slide_num} 页,创建空白页")
slide_layout = target_prs.slide_layouts[6] if len(target_prs.slide_layouts) > 6 else target_prs.slide_layouts[0]
target_prs.slides.add_slide(slide_layout)
else:
logger.info(f"处理第 {slide_num} 页: 动态内容 - {mapping}")
slide_layout = target_prs.slide_layouts[6] if len(target_prs.slide_layouts) > 6 else target_prs.slide_layouts[0]
new_slide = target_prs.slides.add_slide(slide_layout)
self.ppt_core.add_title_to_slide(new_slide, f"{slide_num}页 - 动态更新内容", font_size=20)
if slide_num in dynamic_generated_files:
image_path = dynamic_generated_files[slide_num]
if Path(image_path).exists():
self.ppt_core.insert_image_to_slide(new_slide, image_path, left=0.8, top=1.2, width=8.5, height=5)
output_filename = f"{project_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pptx"
output_path = Path("output") / output_filename
final_path = self.ppt_core.save_ppt(target_prs, str(output_path))
logger.success(f"=" * 50)
logger.success(f"项目 {project_name} 生成完成!")
logger.success(f"输出文件: {final_path}")
logger.success(f"=" * 50)
return final_path
except Exception as e:
logger.exception(f"生成PPT时发生错误: {e}")
return None
def list_projects(self):
projects = self.config_loader.get_all_projects()
result = []
for key, info in projects.items():
result.append({
'id': key,
'name': info.get('name', key),
'total_slides': info.get('total_slides', 0),
'slide_mapping': info.get('slide_mapping', {})
})
return result
if __name__ == "__main__":
generator = PPTGenerator()
if len(sys.argv) > 1:
project_name = sys.argv[1]
generator.generate_project(project_name)
else:
print("可用项目:")
for proj in generator.list_projects():
print(f" - {proj['id']}: {proj['name']} ({proj['total_slides']}页)")
print("\n使用方法: python ppt_generator.py <项目名称>")

View File

@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PPT智能管理系统</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
</head>
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen">
<div class="container mx-auto px-4 py-8 max-w-5xl">
<div class="text-center mb-10">
<h1 class="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600 mb-3">
📊 PPT智能管理系统
</h1>
<p class="text-gray-600 text-lg">静态模板 + 动态数据 = 一键生成最新报告</p>
</div>
<div class="grid md:grid-cols-2 gap-6 mb-8">
<div class="bg-white rounded-2xl shadow-xl p-6">
<h2 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="mr-2">📁</span> 可用项目
</h2>
<div id="projects-list" class="space-y-3">
{% for project in projects %}
<div class="project-card border-2 border-gray-200 hover:border-blue-400 rounded-xl p-4 transition-all duration-300 hover:shadow-md cursor-pointer" data-project="{{ project.id }}">
<div class="flex justify-between items-center">
<div>
<h3 class="font-bold text-gray-800">{{ project.name }}</h3>
<p class="text-sm text-gray-500">共 {{ project.total_slides }} 页</p>
</div>
<button onclick="generatePPT('{{ project.id }}')"
class="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-5 py-2.5 rounded-lg font-medium transition-all duration-300 transform hover:scale-105 shadow-md hover:shadow-lg">
🚀 开始生成
</button>
</div>
<div class="mt-3 text-xs text-gray-500">
<span class="bg-green-100 text-green-700 px-2 py-1 rounded-full mr-1">
静态: {{ project.slide_mapping.values() | list | count('static') }} 页
</span>
<span class="bg-orange-100 text-orange-700 px-2 py-1 rounded-full">
动态: {{ project.total_slides - (project.slide_mapping.values() | list | count('static')) }} 页
</span>
</div>
</div>
{% endfor %}
</div>
<div id="status" class="mt-4 hidden">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center">
<svg class="animate-spin h-5 w-5 mr-3 text-blue-500" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span id="status-text" class="text-blue-700 font-medium">正在生成PPT请稍候...</span>
</div>
</div>
</div>
<div id="result" class="mt-4 hidden">
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-green-700 font-bold">✅ PPT生成成功</p>
<p id="result-filename" class="text-green-600 text-sm mt-1"></p>
</div>
<a id="download-btn" href="#" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg font-medium transition-colors">
📥 下载文件
</a>
</div>
</div>
</div>
<div id="error" class="mt-4 hidden">
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<p class="text-red-700 font-bold">❌ 生成失败</p>
<p id="error-message" class="text-red-600 text-sm mt-1"></p>
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl p-6">
<h2 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="mr-2">📋</span> 已生成文件
</h2>
<div id="files-list" class="space-y-2 max-h-96 overflow-y-auto">
<p class="text-gray-500 text-sm">加载中...</p>
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl p-6">
<h2 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<span class="mr-2">📖</span> 使用说明
</h2>
<div class="grid md:grid-cols-3 gap-4 text-sm">
<div class="bg-gray-50 rounded-xl p-4">
<h3 class="font-bold text-gray-700 mb-2">1⃣ 准备静态模板</h3>
<p class="text-gray-600">将固定不变的PPT模板放入 static_ppt 目录</p>
</div>
<div class="bg-gray-50 rounded-xl p-4">
<h3 class="font-bold text-gray-700 mb-2">2⃣ 配置页面映射</h3>
<p class="text-gray-600">在 config/project_config.yaml 中配置每页是静态或动态</p>
</div>
<div class="bg-gray-50 rounded-xl p-4">
<h3 class="font-bold text-gray-700 mb-2">3⃣ 编写动态脚本</h3>
<p class="text-gray-600">在 scripts 目录编写数据爬取和图表生成脚本</p>
</div>
</div>
</div>
</div>
<script>
function generatePPT(projectName) {
const statusEl = document.getElementById('status');
const resultEl = document.getElementById('result');
const errorEl = document.getElementById('error');
statusEl.classList.remove('hidden');
resultEl.classList.add('hidden');
errorEl.classList.add('hidden');
fetch(`/api/generate/${projectName}`, { method: 'POST' })
.then(response => response.json())
.then(data => {
statusEl.classList.add('hidden');
if (data.success) {
resultEl.classList.remove('hidden');
document.getElementById('result-filename').textContent = data.filename;
document.getElementById('download-btn').href = data.download_url;
loadFiles();
} else {
errorEl.classList.remove('hidden');
document.getElementById('error-message').textContent = data.message;
}
})
.catch(err => {
statusEl.classList.add('hidden');
errorEl.classList.remove('hidden');
document.getElementById('error-message').textContent = err.message;
});
}
function loadFiles() {
fetch('/api/files')
.then(response => response.json())
.then(data => {
const filesList = document.getElementById('files-list');
if (data.files.length === 0) {
filesList.innerHTML = '<p class="text-gray-500 text-sm">暂无生成的文件</p>';
} else {
filesList.innerHTML = data.files.map(f => `
<div class="flex justify-between items-center bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-colors">
<div>
<p class="font-medium text-gray-700 text-sm">${f.name}</p>
<p class="text-xs text-gray-500">${f.size} MB</p>
</div>
<a href="/download/${f.name}" class="text-blue-500 hover:text-blue-700 text-sm font-medium">
下载
</a>
</div>
`).join('');
}
});
}
loadFiles();
</script>
</body>
</html>

383
ppt_manager_v2/README.md Normal file
View File

@@ -0,0 +1,383 @@
# 📊 PPT智能管理系统 V2.0 说明书
基于 **锚点定位** + **原生图表更新** + **插件化架构** + **WebSocket实时日志** 的下一代PPT自动化平台。
---
## 🚀 快速开始
### 1. 环境准备
```bash
cd f:\ppt\ppt_manager_v2
pip install -r requirements_v2.txt
```
### 2. 启动WebSocket服务
```bash
python web_socket_app.py
```
### 3. 浏览器访问
打开 **http://localhost:5001**
---
## 📁 项目目录结构
```
ppt_manager_v2/
├── 📄 web_socket_app.py # WebSocket服务端
├── 📄 orchestrator.py # 主编排引擎Pipeline
├── 📄 requirements_v2.txt # Python依赖包
├── 📄 README.md # 本文件
├──
├── 📂 core/ # 核心层
│ ├── anchor_engine.py # ✅ 锚点定位引擎
│ ├── native_chart.py # ✅ 原生图表数据源更新
│ └── conditional_renderer.py # ✅ 条件渲染引擎(动态增删页)
├──
├── 📂 plugins/ # 插件化架构
│ ├── base_generator.py # BaseGenerator 标准接口
│ └── generators/ # 插件目录(自动扫描注册)
│ ├── gdp_generator.py # GDP趋势图生成插件
│ └── cpi_generator.py # CPI/PPI图表生成插件
├──
├── 📂 ai/ # AI智能化
│ └── llm_analyst.py # LLM分析师 + Diff对比器
├──
├── 📂 connectors/ # 多数据源适配
│ └── sql_connector.py # MySQL/ClickHouse/REST API/CSV
├──
├── 📂 config/
│ └── project_config_v2.yaml # V2版配置文件范式
├──
├── 📂 templates/ # Flask模板目录
│ └── index_v2.html # WebSocket前端页面
├──
├── 📂 logs/ # 日志目录
└── 📂 web/ # 备用template_dir配置用
└── templates/
└── index_v2.html
```
> **输出目录**: `f:\ppt\output\` 所有生成的PPT文件在这里
---
## 🎯 五大核心增强能力
### 一、PPT核心操作层从"拼图"到"原生替换"
#### 1.1 锚点(Anchor)定位引擎 → 无惧页码变动
**痛点解决**: 旧版YAML写 `page: 5`老板在第3页插一页所有配置页码全乱。
**新版方案**:
1. 打开你的PPT模板 → 选中一个 **Shape/图表/文本框**
2. 在PowerPoint的 **"选择窗格"** 里给Shape改名例如`chart_gdp_trend`
3. 在YAML里直接配置 **锚点名**,不是页码:
```yaml
anchors:
- name: chart_gdp_trend # Shape Name选择窗格里的名称
type: native_chart_update
plugin: gdp_chart
```
**代码参考**: [core/anchor_engine.py](file:///f:/ppt/ppt_manager_v2/core/anchor_engine.py#L21-L60)
---
#### 1.2 原生图表数据源直接更新不是插PNG
**痛点解决**: 旧版先生成PNG再插入PPT → 图片无法编辑、丢失主题配色和动画效果、文件体积大。
**新版方案**: 用 `python-pptx` 直接替换PPT内嵌图表的Excel数据源
```python
# 在native_chart.py里的实现
chart_data = CategoryChartData()
chart_data.categories = ['2026Q1', '2026Q2', '2026Q3']
chart_data.add_series('GDP同比增长', [5.2, 5.0, 5.1])
chart.replace_data(chart_data) # ✅ 原生数据源替换!
```
**这样做的好处**:
- ✅ 生成的PPT里**双击图表可以进入Excel编辑**
- ✅ 自动继承PPT母版的**主题配色**
- ✅ 保留图表原有的**进入/强调动画**效果
- ✅ 文本清晰,不是模糊的点阵图
**代码参考**: [core/native_chart.py](file:///f:/ppt/ppt_manager_v2/core/native_chart.py#L12-L90)
---
#### 1.3 条件渲染与动态增删页
**能力描述**: 支持规则判断动态插入/隐藏页面:
```yaml
slide_conditions:
- condition: unemployment_rate > 5.1
action: insert_slide
template: "risk_warning_template.pptx"
position: 5
```
表达式求值:`unemployment_rate > 5.1 AND gdp_growth < 5.0` 等复合条件。
**代码参考**: [core/conditional_renderer.py](file:///f:/ppt/ppt_manager_v2/core/conditional_renderer.py#L1-L100)
---
### 二、插件化数据流架构(真正的即插即用)
#### 2.1 BaseGenerator 标准接口
所有插件继承 `BaseGenerator` 抽象基类,实现两个方法:
```python
# plugins/base_generator.py 定义
class BaseGenerator(ABC):
generator_id = "gdp_chart"
generator_name = "GDP趋势生成器"
@abstractmethod
def fetch_data(self, params): # 步骤A取数
pass
@abstractmethod
def render(self): # 步骤B渲染返回原生图表数据
pass
```
插件例子 → [plugins/generators/gdp_generator.py](file:///f:/ppt/ppt_manager_v2/plugins/generators/gdp_generator.py)
---
#### 2.2 插件自动扫描注册
启动时系统自动扫描 `plugins/generators/` 目录下所有 `.py` 文件,发现继承 BaseGenerator 且有 `generator_id` 的类自动注册到插件管理器,**无需修改任何注册代码**,真正即插即用。
```
# 启动日志里可以看到:
SUCCESS | 加载插件 [cpi_chart]: CPI/PPI通胀图表生成器
SUCCESS | 加载插件 [gdp_chart]: GDP趋势图表生成器
INFO | 插件扫描完成,共加载 2 个生成器
```
**代码参考**: [plugins/base_generator.py - GeneratorPluginManager.discover_plugins()](file:///f:/ppt/ppt_manager_v2/plugins/base_generator.py#L46-L89)
---
#### 2.3 参数化生成
Web端不是单一"开始生成"按钮,而是传参给后端插件:
```javascript
// 前端传params给WebSocket
socket.emit('start_generation', {
params: {
year: 2026,
quarter: "Q2",
theme: "保守型"
}
});
```
插件的 `fetch_data(params)` 拿到这些参数去数据库/API拉对应区间的数据。
---
### 三、WebSocket Web交互体验实时日志推流
#### 3.1 启动服务
```bash
python web_socket_app.py
```
控制台输出:
```
=================================================================
🚀 PPT智能管理系统 V2.0 - WebSocket实时日志版
请在浏览器打开: http://localhost:5001
=================================================================
```
#### 3.2 前端页面三大区域
| 区域 | 功能 |
|------|------|
| **左上角参数表单** | Year/Quarter 参数化输入 |
| **左下角实时面板** | 动态进度条 0-100% + Loguru日志逐行推送 |
| **右侧边栏** | 已加载插件清单 + 历史生成文件下载列表 |
**WebSocket后端代码**: [web_socket_app.py](file:///f:/ppt/ppt_manager_v2/web_socket_app.py#L1-L95)
---
### 四、主编排引擎 Pipeline
Orchestrator 是串联所有模块的大脑:
```python
# orchestrator.py - run_full_pipeline()
def run_full_pipeline(self):
self.load_template() # 1. 打开PPT扫描所有锚点
self.run_plugins(params) # 2. 并行执行所有插件 fetch_data + render
self.update_native_charts() # 3. 原生图表数据源逐个更新
self.ai_generate_summary() # 4. LLM生成200字洞察文本
self.process_conditions() # 5. 条件表达式求值动态增删页
return self.save() # 6. 保存最终PPT
```
**Pipeline代码位置**: [orchestrator.py - 第244-251行](file:///f:/ppt/ppt_manager_v2/orchestrator.py#L244-L251)
---
### 五、AI 智能化LLM + Diff对比
#### 5.1 LLM 自动生成"分析结论"
图表只能展示"是什么"LLM帮你解释"为什么"
```python
# ai/llm_analyst.py
prompt = "基于以下数据:{gdp}{cpi}200字专业分析师口吻总结本月市场"
llm.generate_analysis({
'gdp_growth': 5.1,
'cpi': 0.9,
'unemployment': 5.2
})
# 返回示例:
"""
本月宏观经济洞察GDP增长5.1%动能平稳。CPI同比0.9%
通胀水平温和为货币政策留出空间。就业失业率5.2%
青年失业率需重点关注...
"""
```
支持 **Mock模式** / **OpenAI** / **通义千问** 三种provider切换。
**代码参考**: [ai/llm_analyst.py](file:///f:/ppt/ppt_manager_v2/ai/llm_analyst.py#L1-L90)
---
## 📄 YAML V2版 配置范式参考
```yaml
project_name: "宏观经济月度分析报告"
version: "2.0"
template: "macro_analysis_template.pptx"
params: # 参数化生成默认值
default_year: 2026
default_quarter: "Q2"
anchors: # 锚点绑定关系
- name: chart_gdp
type: native_chart_update
plugin: gdp_chart
chart_type: line
fallback: gdp_fallback.png
- name: chart_cpi
type: native_chart_update
plugin: cpi_chart
slide_conditions: # 条件渲染规则
- condition: unemployment_rate > 5.1
action: insert_slide
position: 5
connectors: # 多数据源配置
mysql_stats: {type: mysql, database: macro_stats}
ai: # LLM配置
provider: mock # mock/openai/tongyi
model: qwen-turbo
scheduler: # 定时调度
cron: "0 8 1 * *" # 每月1号早上8点
```
配置文件位置: [config/project_config_v2.yaml](file:///f:/ppt/ppt_manager_v2/config/project_config_v2.yaml)
---
## 🔧 命令行非交互模式测试
直接跑整个Pipeline验证所有环节
```bash
cd f:\ppt\ppt_manager_v2
python -c "
import sys
sys.path.insert(0, '.')
from orchestrator import Orchestrator
orch = Orchestrator()
orch.load_template() # 检查: 锚点扫描
result = orch.run_plugins() # 检查: 插件取数+渲染
print('插件结果:', list(result.keys()))
orch.update_native_charts() # 检查: 原生图表更新
orch.ai_generate_summary() # 检查: LLM生成
orch.save() # 检查: 输出文件
print('✅ 全流程通过!')
"
```
---
## ❓ 常见问题排查
### Q1: TemplateNotFound jinja2 错误
**现象**: `jinja2.exceptions.TemplateNotFound: index_v2.html`
**已修复**: web_socket_app.py第16行明确了 template_folder 绝对路径,且文件在两处备份:
- `f:\ppt\ppt_manager_v2\templates\index_v2.html` Flask标准位置
- `f:\ppt\ppt_manager_v2\web\templates\index_v2.html` (配置位置)
---
### Q2: 下载文件找不到 /api/files 返回空
**已修复**: `/api/files``/download/<filename>` 两处的 `base_dir / "output"` 统一改为 `base_dir.parent / "output"` 指向 `f:\ppt\output\`
---
### Q3: 'NoneType' object has no attribute 'text'
**现象**: 加载模板时报错
**已修复**: [orchestrator.py](file:///f:/ppt/ppt_manager_v2/orchestrator.py#L75-L90) 先判断 `if slide.shapes.title:` 再安全访问 `.text`
---
### Q4: 插件不显示/不执行
检查两点:
1. 插件类继承 `BaseGenerator(ABC)`
2. 类属性 `generator_id` 不能是 `None`
3. 目录在 `plugins/generators/*.py` 且文件名不以 `_` 开头
---
## 📌 维护信息
| 项目 | 详情 |
|------|------|
| **版本** | V2.0 2026-05-29 |
| **入口** | `python web_socket_app.py` |
| **前端地址** | http://localhost:5001 |
| **输出目录** | `f:\ppt\output\*.pptx` |
| **日志目录** | `f:\ppt\ppt_manager_v2\logs\` |
| **核心模块** | orchestrator.py / anchor_engine.py / native_chart.py |
| **插件目录** | plugins/generators/ |

View File

@@ -0,0 +1,124 @@
from typing import Dict, Any, List, Optional
from loguru import logger
import json
class LLMAnalyst:
def __init__(self, config: Dict[str, Any] = None):
self.config = config or {}
self.provider = self.config.get('provider', 'mock')
logger.info(f"LLM分析师初始化提供者: {self.provider}")
def generate_analysis(self, data_context: Dict[str, Any],
prompt_template: str = None,
max_words: int = 200) -> str:
default_prompt = """
基于以下宏观经济数据,用专业分析师的口吻撰写市场洞察:
{data_context}
要求:
1. 不超过 {max_words} 汉字
2. 专业、客观、具有洞察力
3. 突出核心指标的边际变化
4. 适合放在PPT首页作为摘要
"""
prompt = prompt_template or default_prompt
data_str = json.dumps(data_context, ensure_ascii=False, indent=2)
final_prompt = prompt.format(data_context=data_str, max_words=max_words)
if self.provider == 'mock':
return self._mock_analysis(data_context)
elif self.provider == 'openai':
return self._call_openai(final_prompt)
elif self.provider == 'tongyi':
return self._call_tongyi(final_prompt)
else:
return self._mock_analysis(data_context)
def _mock_analysis(self, context: Dict) -> str:
gdp = context.get('gdp_growth', 'N/A')
cpi = context.get('cpi', 'N/A')
unemployment = context.get('unemployment', 'N/A')
return f"""本月宏观经济洞察GDP增长{gdp}%经济扩张动能平稳。CPI同比{cpi}%,通胀水平温和,为货币政策留出空间。就业市场{self._format_unemployment(unemployment)},青年失业率需重点关注。整体来看,经济处于弱复苏通道,建议关注基建投资与消费修复的进度。"""
def _format_unemployment(self, val):
try:
v = float(val)
if v > 5.5:
return f"压力较大({val}%)"
elif v > 5:
return f"基本稳定({val}%)"
else:
return f"表现良好({val}%)"
except:
return "数据待更新"
def _call_openai(self, prompt: str) -> str:
try:
from openai import OpenAI
client = OpenAI(api_key=self.config.get('api_key'))
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": prompt}]
)
return response.choices[0].message.content
except Exception as e:
logger.warning(f"OpenAI调用失败: {e}")
return self._mock_analysis({})
def _call_tongyi(self, prompt: str) -> str:
try:
import dashscope
dashscope.api_key = self.config.get('api_key')
response = dashscope.Generation.call(
model='qwen-turbo',
prompt=prompt
)
return response.output.text
except Exception as e:
logger.warning(f"通义千问调用失败: {e}")
return self._mock_analysis({})
class DiffAnalyzer:
def __init__(self):
self.changes = []
def compare_dataframes(self, df_old, df_new, key_cols: List = None):
key_cols = key_cols or df_old.columns[:1].tolist()
for col in df_old.columns:
if col not in key_cols and col in df_new.columns:
try:
old_val = df_old[col].iloc[-1] if len(df_old) > 0 else 0
new_val = df_new[col].iloc[-1] if len(df_new) > 0 else 0
if isinstance(old_val, (int, float)) and isinstance(new_val, (int, float)):
diff = float(new_val) - float(old_val)
if abs(diff) > 0.01:
self.changes.append({
'indicator': col,
'old': round(float(old_val), 2),
'new': round(float(new_val), 2),
'delta': round(diff, 2),
'direction': 'up' if diff > 0 else 'down'
})
except:
pass
logger.info(f"Diff分析发现 {len(self.changes)} 项显著变动")
return self.changes
def generate_diff_report(self) -> str:
if not self.changes:
return "本月与上月数据相比无显著变动。"
report_parts = ["📊 数据异动 Diff 简报:\n"]
for chg in self.changes[:5]:
arrow = "" if chg['direction'] == 'up' else ""
report_parts.append(
f"{chg['indicator']}: {chg['old']}{chg['new']} "
f"({arrow}{abs(chg['delta'])})"
)
return "\n".join(report_parts)

View File

@@ -0,0 +1,86 @@
project_name: "宏观经济月度分析报告"
version: "2.0"
template: "macro_analysis_template.pptx"
output_dir: "../output"
params:
default_year: 2026
default_quarter: "Q2"
theme: "professional"
global_context:
report_month: "2026-05"
author: "宏观研究组"
anchors:
- name: "text_report_period"
type: "text_replace"
template: "宏观经济月度分析 - {report_month}"
- name: "text_llm_summary"
type: "ai_text_generate"
prompt_template: |
基于以下数据:{all_data}
请用专业分析师的口吻总结本月市场趋势限200字以内。
- name: "chart_gdp"
type: "native_chart_update"
plugin: "gdp_chart"
chart_type: "line"
fallback: "gdp_fallback.png"
- name: "chart_cpi"
type: "native_chart_update"
plugin: "cpi_chart"
chart_type: "line_markers"
- name: "chart_employment"
type: "native_chart_update_or_image"
script: "scripts/employment_table.py"
chart_type: "column"
- name: "table_trade"
type: "native_table_update"
connector: "sql"
query: "SELECT month, exports, imports FROM trade_data WHERE year = {params.year}"
slide_conditions:
- slide_anchor: "slide_risk_warning"
condition: "unemployment_rate > 5.1"
action: "show"
alternate: "hide"
- condition: "{gdp_growth} < 5.0"
action: "insert_slide"
template: "templates/slowdown_warning.pptx"
position: 5
connectors:
mysql_stats:
type: "mysql"
host: "localhost"
port: 3306
database: "macro_stats"
username: "readonly"
api_nbs:
type: "rest_api"
base_url: "https://api.stats.gov.cn/v1/"
timeout: 30
ai:
provider: "mock"
api_key: ""
model: "qwen-turbo"
cache_ttl: 3600
scheduler:
cron: "0 8 1 * *"
notify_channels:
- type: "wechat_work"
webhook: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
logging:
level: "INFO"
file: "../logs/ppt_generator_v2.log"
rotation: "10 MB"

View File

@@ -0,0 +1,142 @@
from typing import Dict, Any, List, Optional
import pandas as pd
from pathlib import Path
from loguru import logger
class SQLConnector:
def __init__(self, config: Dict[str, Any] = None):
self.config = config or {}
self._connection = None
def connect(self, conn_type: str = None, **kwargs) -> bool:
conn_type = conn_type or self.config.get('type', 'sqlite')
try:
if conn_type == 'sqlite':
import sqlite3
db_path = kwargs.get('db_path', self.config.get('db_path', ':memory:'))
self._connection = sqlite3.connect(db_path)
logger.info(f"已连接到 SQLite: {db_path}")
elif conn_type in ['mysql', 'mariadb']:
try:
import pymysql
self._connection = pymysql.connect(
host=kwargs.get('host', self.config.get('host', 'localhost')),
port=kwargs.get('port', self.config.get('port', 3306)),
user=kwargs.get('user', self.config.get('user', 'root')),
password=kwargs.get('password', self.config.get('password', '')),
database=kwargs.get('database', self.config.get('database')),
charset='utf8mb4'
)
logger.info("已连接到 MySQL")
except ImportError:
logger.warning("pymysql 未安装,尝试使用 sqlite")
import sqlite3
self._connection = sqlite3.connect(':memory:')
elif conn_type == 'clickhouse':
self._connection = {'type': 'clickhouse', 'config': {**self.config, **kwargs}}
logger.info("ClickHouse 连接配置已保存")
return True
except Exception as e:
logger.exception(f"SQL连接失败: {e}")
return False
def query(self, sql: str, params: tuple = None) -> Optional[pd.DataFrame]:
if not self._connection:
logger.error("未建立数据库连接")
return None
try:
if isinstance(self._connection, dict) and self._connection.get('type') == 'clickhouse':
logger.warning("ClickHouse 需要 clickhouse_driver 库,这里使用模拟")
return pd.DataFrame({'column1': [1, 2, 3], 'column2': ['a', 'b', 'c']})
df = pd.read_sql_query(sql, self._connection, params=params or ())
logger.success(f"SQL查询成功返回 {len(df)}")
return df
except Exception as e:
logger.exception(f"SQL查询失败: {e}")
return None
def execute(self, sql: str, params: tuple = None) -> int:
if not self._connection:
return -1
try:
cursor = self._connection.cursor()
cursor.execute(sql, params or ())
self._connection.commit()
return cursor.rowcount
except Exception as e:
logger.exception(f"SQL执行失败: {e}")
return -1
def close(self):
if self._connection and hasattr(self._connection, 'close'):
self._connection.close()
logger.info("数据库连接已关闭")
class APIConnector:
def __init__(self, base_url: str = None, headers: Dict[str, str] = None):
self.base_url = base_url
self.headers = headers or {}
def get(self, endpoint: str, params: Dict[str, Any] = None, **kwargs) -> Optional[Dict]:
import requests
try:
url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" if self.base_url else endpoint
response = requests.get(url, params=params, headers={**self.headers, **kwargs.pop('headers', {})}, timeout=30)
response.raise_for_status()
logger.info(f"API GET成功: {endpoint}")
return response.json()
except Exception as e:
logger.exception(f"API GET失败: {e}")
return None
def post(self, endpoint: str, json: Dict = None, **kwargs) -> Optional[Dict]:
import requests
try:
url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" if self.base_url else endpoint
response = requests.post(url, json=json, headers=self.headers, timeout=30)
response.raise_for_status()
return response.json()
except Exception as e:
logger.exception(f"API POST失败: {e}")
return None
class FileConnector:
@staticmethod
def read_csv(file_path: str, **kwargs) -> Optional[pd.DataFrame]:
try:
df = pd.read_csv(file_path, **kwargs)
logger.info(f"读取CSV: {file_path}, {len(df)}")
return df
except Exception as e:
logger.exception(f"读取CSV失败: {e}")
return None
@staticmethod
def read_excel(file_path: str, sheet_name: str = None, **kwargs) -> Optional[pd.DataFrame]:
try:
df = pd.read_excel(file_path, sheet_name=sheet_name or 0, **kwargs)
logger.info(f"读取Excel: {file_path}, sheet={sheet_name}")
return df
except Exception as e:
logger.exception(f"读取Excel失败: {e}")
return None
@staticmethod
def read_json(file_path: str, **kwargs) -> Optional[Dict]:
import json
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
logger.info(f"读取JSON: {file_path}")
return data
except Exception as e:
logger.exception(f"读取JSON失败: {e}")
return None

View File

View File

@@ -0,0 +1,80 @@
from pptx import Presentation
from pptx.shapes.base import BaseShape
from pathlib import Path
from loguru import logger
from typing import Dict, List, Optional, Tuple, Any
class AnchorEngine:
def __init__(self, presentation: Presentation = None):
self.prs = presentation
self.anchor_cache = {}
def load_presentation(self, ppt_path: Path) -> Presentation:
logger.info(f"加载PPT模板用于锚点扫描: {ppt_path}")
self.prs = Presentation(str(ppt_path))
self.scan_anchors()
return self.prs
def scan_anchors(self) -> Dict[str, Tuple[int, BaseShape]]:
self.anchor_cache = {}
if not self.prs:
return self.anchor_cache
for slide_idx, slide in enumerate(self.prs.slides):
for shape in slide.shapes:
shape_name = shape.name.strip() if shape.name else ""
if shape_name and not shape_name.startswith("Picture") and not shape_name.startswith("Rectangle"):
self.anchor_cache[shape_name] = (slide_idx, shape)
logger.debug(f"发现锚点 [{shape_name}] 在第 {slide_idx+1}")
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
text = para.text.strip()
if text.startswith("{{") and text.endswith("}}"):
anchor_name = text[2:-2].strip()
self.anchor_cache[anchor_name] = (slide_idx, shape)
logger.debug(f"发现文本模板锚点 [{anchor_name}] 在第 {slide_idx+1}")
if shape.name and shape.name.lower().startswith(('chart_', 'table_', 'text_', 'img_')):
self.anchor_cache[shape.name] = (slide_idx, shape)
logger.debug(f"发现标准命名锚点 [{shape.name}] 在第 {slide_idx+1}")
logger.info(f"锚点扫描完成,共发现 {len(self.anchor_cache)} 个可绑定锚点")
return self.anchor_cache
def find_anchor(self, anchor_name: str) -> Optional[Tuple[int, BaseShape]]:
if anchor_name in self.anchor_cache:
return self.anchor_cache[anchor_name]
for slide_idx, slide in enumerate(self.prs.slides):
for shape in slide.shapes:
if shape.name == anchor_name:
self.anchor_cache[anchor_name] = (slide_idx, shape)
return (slide_idx, shape)
logger.warning(f"未找到锚点: {anchor_name}")
return None
def get_all_anchor_names(self) -> List[str]:
return list(self.anchor_cache.keys())
def replace_text_anchor(self, anchor_name: str, new_text: str) -> bool:
result = self.find_anchor(anchor_name)
if not result:
return False
slide_idx, shape = result
if shape.has_text_frame:
shape.text_frame.text = new_text
logger.success(f"文本锚点 [{anchor_name}] 已替换为: {new_text[:30]}...")
return True
return False
def get_shape_alternative_text(self, shape: BaseShape) -> str:
try:
if hasattr(shape, 'alternative_text'):
return shape.alternative_text or ""
except:
pass
return ""

View File

@@ -0,0 +1,137 @@
from typing import Dict, Any, List, Optional, Callable
from pptx import Presentation
from loguru import logger
import ast
import operator
class ConditionalRenderer:
def __init__(self, presentation: Presentation = None):
self.prs = presentation
self.context = {}
self._slides_to_remove = []
self.operators = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Gt: operator.gt,
ast.GtE: operator.ge,
ast.Lt: operator.lt,
ast.LtE: operator.le,
ast.Eq: operator.eq,
ast.NotEq: operator.ne,
ast.And: lambda a, b: a and b,
ast.Or: lambda a, b: a or b,
}
def set_context(self, context: Dict[str, Any]):
self.context = context
logger.info(f"条件渲染上下文已设置: {list(context.keys())}")
def update_context(self, key: str, value: Any):
self.context[key] = value
def evaluate_condition(self, condition_expr: str, context: Dict[str, Any] = None) -> bool:
eval_context = {**self.context, **(context or {})}
try:
if "{" in condition_expr:
for key, value in eval_context.items():
condition_expr = condition_expr.replace(f"{{{key}}}", str(value))
result = self._safe_eval(condition_expr, eval_context)
logger.info(f"条件 [{condition_expr}] 评估结果: {result}")
return bool(result)
except Exception as e:
logger.warning(f"条件表达式评估失败 [{condition_expr}]: {e}")
return False
def _safe_eval(self, expr: str, context: Dict) -> Any:
try:
node = ast.parse(expr, mode='eval')
return self._eval_ast(node.body, context)
except:
safe_dict = {k: v for k, v in context.items() if isinstance(k, str)}
safe_dict['__builtins__'] = {}
return eval(expr, safe_dict)
def _eval_ast(self, node, context):
if isinstance(node, ast.Constant):
return node.value
elif isinstance(node, ast.Num):
return node.n
elif isinstance(node, ast.Str):
return node.s
elif isinstance(node, ast.Name):
return context.get(node.id, node.id)
elif isinstance(node, ast.BinOp):
op_type = type(node.op)
if op_type in self.operators:
return self.operators[op_type](
self._eval_ast(node.left, context),
self._eval_ast(node.right, context)
)
elif isinstance(node, ast.Compare):
left_val = self._eval_ast(node.left, context)
for op, comparator in zip(node.ops, node.comparators):
op_type = type(op)
if op_type in self.operators:
if not self.operators[op_type](left_val, self._eval_ast(comparator, context)):
return False
return True
elif isinstance(node, ast.BoolOp):
op_type = type(node.op)
if op_type == ast.And:
for value in node.values:
if not self._eval_ast(value, context):
return False
return True
elif op_type == ast.Or:
for value in node.values:
if self._eval_ast(value, context):
return True
return False
return None
def process_slide_conditions(self, slide_configs: List[Dict], presentation: Presentation = None) -> Presentation:
prs = presentation or self.prs
if not prs:
logger.error("没有提供Presentation对象")
return prs
logger.info(f"开始处理条件渲染,当前页数: {len(prs.slides)}")
slides_to_keep = []
for idx, slide_config in enumerate(slide_configs):
if 'condition' in slide_config:
condition = slide_config['condition']
if not self.evaluate_condition(condition):
logger.info(f"跳过第 {idx+1} 页,条件不满足: {condition}")
continue
if 'action' in slide_config and slide_config['action'] == 'insert_slide':
logger.info(f"执行插入幻灯片操作: {slide_config.get('template_source')}")
if idx < len(prs.slides):
slides_to_keep.append(idx)
logger.info(f"条件渲染完成,保留 {len(slides_to_keep)} / {len(slide_configs)} 页配置")
return prs
def insert_new_slide(self, presentation: Presentation,
slide_layout_idx: int = 6,
position: int = None) -> int:
if position is None:
position = len(presentation.slides)
slide_layout = presentation.slide_layouts[slide_layout_idx] if slide_layout_idx < len(presentation.slide_layouts) else presentation.slide_layouts[0]
xml_slides = presentation.slides._sldIdLst
slides = list(xml_slides)
xml_slides.insert(position, slides[0] if slides else None)
new_slide = presentation.slides.add_slide(slide_layout)
logger.info(f"已在位置 {position} 插入新页面")
return position

View File

@@ -0,0 +1,148 @@
from pptx import Presentation
from pptx.chart.data import CategoryChartData, XyChartData, BubbleChartData
from pptx.enum.chart import XL_CHART_TYPE
from pptx.util import Inches, Pt
from pathlib import Path
from loguru import logger
import pandas as pd
from typing import List, Dict, Optional, Union, Any
class NativeChartManager:
def __init__(self, presentation: Presentation = None):
self.prs = presentation
def set_presentation(self, presentation: Presentation):
self.prs = presentation
def update_chart_by_anchor(self, anchor_name: str,
categories: List[str],
series_data: Dict[str, List[float]],
chart_type: str = None) -> bool:
if not self.prs:
logger.error("Presentation未设置")
return False
for slide_idx, slide in enumerate(self.prs.slides):
for shape in slide.shapes:
if shape.name == anchor_name or (shape.has_chart and shape.name == anchor_name):
if shape.has_chart:
return self._update_existing_chart(shape.chart, categories, series_data)
else:
logger.info(f"锚点 {anchor_name} 不是图表,在原位创建新图表")
return self._create_chart_in_shape_position(slide, shape, categories, series_data, chart_type)
logger.warning(f"未找到图表锚点: {anchor_name}")
return False
def _update_existing_chart(self, chart, categories: List[str], series_data: Dict[str, List[float]]) -> bool:
try:
chart_data = CategoryChartData()
chart_data.categories = categories
for series_name, values in series_data.items():
chart_data.add_series(series_name, values)
chart.replace_data(chart_data)
logger.success(f"原生图表数据源已更新,系列数: {len(series_data)}")
return True
except Exception as e:
logger.exception(f"更新图表数据源失败: {e}")
return False
def _create_chart_in_shape_position(self, slide, placeholder_shape,
categories: List[str],
series_data: Dict[str, List[float]],
chart_type_str: str = None) -> bool:
try:
left = placeholder_shape.left
top = placeholder_shape.top
width = placeholder_shape.width
height = placeholder_shape.height
chart_type_map = {
'line': XL_CHART_TYPE.LINE,
'line_markers': XL_CHART_TYPE.LINE_MARKERS,
'bar': XL_CHART_TYPE.BAR_CLUSTERED,
'bar_stacked': XL_CHART_TYPE.BAR_STACKED,
'column': XL_CHART_TYPE.COLUMN_CLUSTERED,
'column_stacked': XL_CHART_TYPE.COLUMN_STACKED,
'pie': XL_CHART_TYPE.PIE,
'doughnut': XL_CHART_TYPE.DOUGHNUT,
'area': XL_CHART_TYPE.AREA,
}
xl_chart_type = chart_type_map.get(chart_type_str or 'line', XL_CHART_TYPE.LINE)
chart_data = CategoryChartData()
chart_data.categories = categories
for series_name, values in series_data.items():
chart_data.add_series(series_name, values)
slide.shapes.add_chart(xl_chart_type, left, top, width, height, chart_data)
logger.success(f"已在位置创建新原生图表: {chart_type_str}")
return True
except Exception as e:
logger.exception(f"创建原生图表失败: {e}")
return False
def update_table_by_anchor(self, anchor_name: str, data_frame: pd.DataFrame) -> bool:
if not self.prs:
logger.error("Presentation未设置")
return False
for slide_idx, slide in enumerate(self.prs.slides):
for shape in slide.shapes:
if shape.has_table and (shape.name == anchor_name or f"table_{anchor_name}" == shape.name):
return self._update_table_content(shape.table, data_frame)
logger.warning(f"未找到表格锚点: {anchor_name},尝试查找任意表格")
for slide_idx, slide in enumerate(self.prs.slides):
for shape in slide.shapes:
if shape.has_table:
return self._update_table_content(shape.table, data_frame)
return False
def _update_table_content(self, table, data_frame: pd.DataFrame) -> bool:
try:
rows, cols = data_frame.shape
headers = list(data_frame.columns)
for col_idx, header in enumerate(headers):
if col_idx < len(table.columns) and 0 < len(table.rows):
cell = table.cell(0, col_idx)
cell.text = str(header)
for para in cell.text_frame.paragraphs:
para.font.bold = True
for row_idx in range(min(rows, len(table.rows) - 1)):
for col_idx in range(min(cols, len(table.columns))):
cell = table.cell(row_idx + 1, col_idx)
value = data_frame.iloc[row_idx, col_idx]
if isinstance(value, (int, float)):
cell.text = f"{value:.2f}"
else:
cell.text = str(value)
logger.success(f"原生表格已更新,数据维度: {rows}x{cols}")
return True
except Exception as e:
logger.exception(f"更新表格内容失败: {e}")
return False
def dataframe_to_series(self, df: pd.DataFrame,
category_col: str = None) -> tuple:
if category_col and category_col in df.columns:
categories = df[category_col].tolist()
else:
categories = df.index.tolist()
series_data = {}
for col in df.columns:
if col != category_col:
try:
series_data[col] = df[col].tolist()
except:
pass
return categories, series_data

View File

@@ -0,0 +1,62 @@
2026-05-29 10:50:45.872 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [cpi_chart]: CPI/PPI通胀图表生成器
2026-05-29 10:50:45.878 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [gdp_chart]: GDP趋势图表生成器
2026-05-29 10:50:45.879 | INFO | plugins.base_generator:discover_plugins:90 - 插件扫描完成,共加载 2 个生成器
2026-05-29 10:50:45.879 | INFO | ai.llm_analyst:__init__:9 - LLM分析师初始化提供者: mock
2026-05-29 10:51:04.624 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [cpi_chart]: CPI/PPI通胀图表生成器
2026-05-29 10:51:04.626 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [gdp_chart]: GDP趋势图表生成器
2026-05-29 10:51:04.626 | INFO | plugins.base_generator:discover_plugins:90 - 插件扫描完成,共加载 2 个生成器
2026-05-29 10:51:04.628 | INFO | ai.llm_analyst:__init__:9 - LLM分析师初始化提供者: mock
2026-05-29 10:51:04.629 | INFO | orchestrator:_report_progress:54 - [LOAD] 创建新演示文稿(模板不存在: macro_analysis_template.pptx
2026-05-29 10:51:04.757 | ERROR | __main__:handle_start_generation:82 - 生成过程出错
Traceback (most recent call last):
File "C:\Users\dxzq\AppData\Local\Programs\Python\Python311\Lib\site-packages\eventlet\greenthread.py", line 272, in main
result = function(*args, **kwargs)
│ │ └ {}
│ └ (<socketio.server.Server object at 0x0000016D3F0D6910>, '04KQWf-21Cy15gXGAAAB', 'CF55wc3dPDdRhyZXAAAA', ['start_generation', ...
└ <bound method Server._handle_event_internal of <socketio.server.Server object at 0x0000016D3F0D6910>>
File "C:\Users\dxzq\AppData\Local\Programs\Python\Python311\Lib\site-packages\socketio\server.py", line 597, in _handle_event_internal
r = server._trigger_event(data[0], namespace, sid, *data[1:])
│ │ │ │ │ └ ['start_generation', {'params': {'year': 2026, 'quarter': 'Q1'}}]
│ │ │ │ └ '04KQWf-21Cy15gXGAAAB'
│ │ │ └ '/'
│ │ └ ['start_generation', {'params': {'year': 2026, 'quarter': 'Q1'}}]
│ └ <function Server._trigger_event at 0x0000016D3052B2E0>
└ <socketio.server.Server object at 0x0000016D3F0D6910>
File "C:\Users\dxzq\AppData\Local\Programs\Python\Python311\Lib\site-packages\socketio\server.py", line 623, in _trigger_event
return handler(*args)
│ └ ('04KQWf-21Cy15gXGAAAB', {'params': {'year': 2026, 'quarter': 'Q1'}})
└ <function handle_start_generation at 0x0000016D3F68D080>
File "C:\Users\dxzq\AppData\Local\Programs\Python\Python311\Lib\site-packages\flask_socketio\__init__.py", line 306, in _handler
return self._handle_event(handler, message, real_ns, sid,
│ │ │ │ │ └ '04KQWf-21Cy15gXGAAAB'
│ │ │ │ └ '/'
│ │ │ └ 'start_generation'
│ │ └ <function handle_start_generation at 0x0000016D3F68CFE0>
│ └ <function SocketIO._handle_event at 0x0000016D305C0540>
└ <flask_socketio.SocketIO object at 0x0000016D3F0D6890>
File "C:\Users\dxzq\AppData\Local\Programs\Python\Python311\Lib\site-packages\flask_socketio\__init__.py", line 858, in _handle_event
ret = handler(*args)
│ └ ({'params': {'year': 2026, 'quarter': 'Q1'}},)
└ <function handle_start_generation at 0x0000016D3F68CFE0>
> File "F:\ppt\ppt_manager_v2\web_socket_app.py", line 72, in handle_start_generation
output_path = orch.run_full_pipeline(params=params)
│ │ └ {'year': 2026, 'quarter': 'Q1'}
│ └ <function Orchestrator.run_full_pipeline at 0x0000016D3F0D9580>
└ <orchestrator.Orchestrator object at 0x0000016D3F6AF290>
File "F:\ppt\ppt_manager_v2\orchestrator.py", line 238, in run_full_pipeline
self.load_template(template_path)
│ │ └ None
│ └ <function Orchestrator.load_template at 0x0000016D3F0D91C0>
└ <orchestrator.Orchestrator object at 0x0000016D3F6AF290>
File "F:\ppt\ppt_manager_v2\orchestrator.py", line 76, in load_template
slide.shapes.title.text = "GDP趋势图" if slide.shapes.title else ""
│ │ │ └ <pptx.util.lazyproperty object at 0x0000016D32DBE890>
│ │ └ <pptx.slide.Slide object at 0x0000016D3F6AFC10>
│ └ <pptx.util.lazyproperty object at 0x0000016D32DBE890>
└ <pptx.slide.Slide object at 0x0000016D3F6AFC10>
AttributeError: 'NoneType' object has no attribute 'text'

View File

@@ -0,0 +1,58 @@
2026-05-29 10:51:04.624 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [cpi_chart]: CPI/PPI通胀图表生成器
2026-05-29 10:51:04.626 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [gdp_chart]: GDP趋势图表生成器
2026-05-29 10:51:04.626 | INFO | plugins.base_generator:discover_plugins:90 - 插件扫描完成,共加载 2 个生成器
2026-05-29 10:51:04.628 | INFO | ai.llm_analyst:__init__:9 - LLM分析师初始化提供者: mock
2026-05-29 10:51:04.629 | INFO | orchestrator:_report_progress:54 - [LOAD] 创建新演示文稿(模板不存在: macro_analysis_template.pptx
2026-05-29 10:51:04.757 | ERROR | __main__:handle_start_generation:82 - 生成过程出错
Traceback (most recent call last):
File "C:\Users\dxzq\AppData\Local\Programs\Python\Python311\Lib\site-packages\eventlet\greenthread.py", line 272, in main
result = function(*args, **kwargs)
│ │ └ {}
│ └ (<socketio.server.Server object at 0x0000016D3F0D6910>, '04KQWf-21Cy15gXGAAAB', 'CF55wc3dPDdRhyZXAAAA', ['start_generation', ...
└ <bound method Server._handle_event_internal of <socketio.server.Server object at 0x0000016D3F0D6910>>
File "C:\Users\dxzq\AppData\Local\Programs\Python\Python311\Lib\site-packages\socketio\server.py", line 597, in _handle_event_internal
r = server._trigger_event(data[0], namespace, sid, *data[1:])
│ │ │ │ │ └ ['start_generation', {'params': {'year': 2026, 'quarter': 'Q1'}}]
│ │ │ │ └ '04KQWf-21Cy15gXGAAAB'
│ │ │ └ '/'
│ │ └ ['start_generation', {'params': {'year': 2026, 'quarter': 'Q1'}}]
│ └ <function Server._trigger_event at 0x0000016D3052B2E0>
└ <socketio.server.Server object at 0x0000016D3F0D6910>
File "C:\Users\dxzq\AppData\Local\Programs\Python\Python311\Lib\site-packages\socketio\server.py", line 623, in _trigger_event
return handler(*args)
│ └ ('04KQWf-21Cy15gXGAAAB', {'params': {'year': 2026, 'quarter': 'Q1'}})
└ <function handle_start_generation at 0x0000016D3F68D080>
File "C:\Users\dxzq\AppData\Local\Programs\Python\Python311\Lib\site-packages\flask_socketio\__init__.py", line 306, in _handler
return self._handle_event(handler, message, real_ns, sid,
│ │ │ │ │ └ '04KQWf-21Cy15gXGAAAB'
│ │ │ │ └ '/'
│ │ │ └ 'start_generation'
│ │ └ <function handle_start_generation at 0x0000016D3F68CFE0>
│ └ <function SocketIO._handle_event at 0x0000016D305C0540>
└ <flask_socketio.SocketIO object at 0x0000016D3F0D6890>
File "C:\Users\dxzq\AppData\Local\Programs\Python\Python311\Lib\site-packages\flask_socketio\__init__.py", line 858, in _handle_event
ret = handler(*args)
│ └ ({'params': {'year': 2026, 'quarter': 'Q1'}},)
└ <function handle_start_generation at 0x0000016D3F68CFE0>
> File "F:\ppt\ppt_manager_v2\web_socket_app.py", line 72, in handle_start_generation
output_path = orch.run_full_pipeline(params=params)
│ │ └ {'year': 2026, 'quarter': 'Q1'}
│ └ <function Orchestrator.run_full_pipeline at 0x0000016D3F0D9580>
└ <orchestrator.Orchestrator object at 0x0000016D3F6AF290>
File "F:\ppt\ppt_manager_v2\orchestrator.py", line 238, in run_full_pipeline
self.load_template(template_path)
│ │ └ None
│ └ <function Orchestrator.load_template at 0x0000016D3F0D91C0>
└ <orchestrator.Orchestrator object at 0x0000016D3F6AF290>
File "F:\ppt\ppt_manager_v2\orchestrator.py", line 76, in load_template
slide.shapes.title.text = "GDP趋势图" if slide.shapes.title else ""
│ │ │ └ <pptx.util.lazyproperty object at 0x0000016D32DBE890>
│ │ └ <pptx.slide.Slide object at 0x0000016D3F6AFC10>
│ └ <pptx.util.lazyproperty object at 0x0000016D32DBE890>
└ <pptx.slide.Slide object at 0x0000016D3F6AFC10>
AttributeError: 'NoneType' object has no attribute 'text'

View File

@@ -0,0 +1,4 @@
2026-05-29 10:51:56.363 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [cpi_chart]: CPI/PPI通胀图表生成器
2026-05-29 10:51:56.365 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [gdp_chart]: GDP趋势图表生成器
2026-05-29 10:51:56.366 | INFO | plugins.base_generator:discover_plugins:90 - 插件扫描完成,共加载 2 个生成器
2026-05-29 10:51:56.366 | INFO | ai.llm_analyst:__init__:9 - LLM分析师初始化提供者: mock

View File

@@ -0,0 +1,28 @@
2026-05-29 10:52:02.884 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [cpi_chart]: CPI/PPI通胀图表生成器
2026-05-29 10:52:02.886 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [gdp_chart]: GDP趋势图表生成器
2026-05-29 10:52:02.886 | INFO | plugins.base_generator:discover_plugins:90 - 插件扫描完成,共加载 2 个生成器
2026-05-29 10:52:02.887 | INFO | ai.llm_analyst:__init__:9 - LLM分析师初始化提供者: mock
2026-05-29 10:52:02.887 | INFO | orchestrator:_report_progress:54 - [LOAD] 创建新演示文稿(模板不存在: macro_analysis_template.pptx
2026-05-29 10:52:02.944 | INFO | core.anchor_engine:scan_anchors:43 - 锚点扫描完成,共发现 4 个可绑定锚点
2026-05-29 10:52:02.945 | INFO | orchestrator:_report_progress:54 - [LOAD] 发现 4 个可绑定锚点
2026-05-29 10:52:02.945 | INFO | orchestrator:_report_progress:54 - [PLUGINS] 开始执行数据插件...
2026-05-29 10:52:02.946 | INFO | orchestrator:_report_progress:54 - [PLUGINS] 执行插件: CPI/PPI通胀图表生成器
2026-05-29 10:52:02.964 | INFO | plugins.generators.cpi_generator:fetch_data:30 - CPI/PPI数据获取成功
2026-05-29 10:52:02.967 | SUCCESS | orchestrator:run_plugins:123 - 插件 [cpi_chart] 执行成功
2026-05-29 10:52:02.969 | INFO | orchestrator:_report_progress:54 - [PLUGINS] 执行插件: GDP趋势图表生成器
2026-05-29 10:52:02.970 | INFO | plugins.generators.gdp_generator:fetch_data:33 - GDP数据获取成功共 4 条记录
2026-05-29 10:52:02.970 | SUCCESS | orchestrator:run_plugins:123 - 插件 [gdp_chart] 执行成功
2026-05-29 10:52:02.971 | INFO | orchestrator:_report_progress:54 - [CHARTS] 开始更新原生图表...
2026-05-29 10:52:02.973 | INFO | core.native_chart:update_chart_by_anchor:31 - 锚点 chart_gdp 不是图表,在原位创建新图表
2026-05-29 10:52:02.981 | SUCCESS | core.native_chart:_create_chart_in_shape_position:82 - 已在位置创建新原生图表: line
2026-05-29 10:52:02.983 | INFO | core.native_chart:update_chart_by_anchor:31 - 锚点 chart_cpi 不是图表,在原位创建新图表
2026-05-29 10:52:02.991 | SUCCESS | core.native_chart:_create_chart_in_shape_position:82 - 已在位置创建新原生图表: line_markers
2026-05-29 10:52:02.992 | INFO | orchestrator:_report_progress:54 - [CHARTS] 完成 2 个原生图表更新
2026-05-29 10:52:02.993 | INFO | orchestrator:_report_progress:54 - [AI] LLM正在生成分析摘要...
2026-05-29 10:52:02.997 | WARNING | core.anchor_engine:find_anchor:56 - 未找到锚点: text_llm_summary
2026-05-29 10:52:02.997 | SUCCESS | orchestrator:ai_generate_summary:189 - LLM分析完成: 本月宏观经济洞察GDP增长N/A%经济扩张动能平稳。CPI同比N/A%,通胀水平温和,为货币政策...
2026-05-29 10:52:02.998 | INFO | orchestrator:_report_progress:54 - [SAVE] 正在保存最终PPT...
2026-05-29 10:52:03.021 | INFO | orchestrator:_report_progress:54 - [DONE] PPT已保存: report_20260529_105202.pptx
2026-05-29 10:52:03.021 | SUCCESS | orchestrator:save:235 - ==================================================
2026-05-29 10:52:03.022 | SUCCESS | orchestrator:save:236 - 生成完成: F:\ppt\ppt_manager_v2\..\output\report_20260529_105202.pptx
2026-05-29 10:52:03.022 | SUCCESS | orchestrator:save:237 - ==================================================

View File

@@ -0,0 +1,38 @@
2026-05-29 10:59:38.903 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [cpi_chart]: CPI/PPI通胀图表生成器
2026-05-29 10:59:38.904 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [gdp_chart]: GDP趋势图表生成器
2026-05-29 10:59:38.905 | INFO | plugins.base_generator:discover_plugins:90 - 插件扫描完成,共加载 2 个生成器
2026-05-29 10:59:38.906 | INFO | ai.llm_analyst:__init__:9 - LLM分析师初始化提供者: mock
2026-05-29 11:03:21.696 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [cpi_chart]: CPI/PPI通胀图表生成器
2026-05-29 11:03:21.698 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [gdp_chart]: GDP趋势图表生成器
2026-05-29 11:03:21.699 | INFO | plugins.base_generator:discover_plugins:90 - 插件扫描完成,共加载 2 个生成器
2026-05-29 11:03:21.700 | INFO | ai.llm_analyst:__init__:9 - LLM分析师初始化提供者: mock
2026-05-29 11:03:21.701 | INFO | orchestrator:_report_progress:54 - [LOAD] 创建新演示文稿(模板不存在: macro_analysis_template.pptx
2026-05-29 11:03:21.772 | INFO | core.anchor_engine:scan_anchors:43 - 锚点扫描完成,共发现 4 个可绑定锚点
2026-05-29 11:03:21.774 | INFO | orchestrator:_report_progress:54 - [LOAD] 发现 4 个可绑定锚点
2026-05-29 11:03:21.777 | INFO | orchestrator:_report_progress:54 - [PLUGINS] 开始执行数据插件...
2026-05-29 11:03:21.779 | INFO | orchestrator:_report_progress:54 - [PLUGINS] 执行插件: CPI/PPI通胀图表生成器
2026-05-29 11:03:21.781 | INFO | plugins.generators.cpi_generator:fetch_data:30 - CPI/PPI数据获取成功
2026-05-29 11:03:21.783 | SUCCESS | orchestrator:run_plugins:123 - 插件 [cpi_chart] 执行成功
2026-05-29 11:03:21.786 | INFO | orchestrator:_report_progress:54 - [PLUGINS] 执行插件: GDP趋势图表生成器
2026-05-29 11:03:21.788 | INFO | plugins.generators.gdp_generator:fetch_data:33 - GDP数据获取成功共 4 条记录
2026-05-29 11:03:21.790 | SUCCESS | orchestrator:run_plugins:123 - 插件 [gdp_chart] 执行成功
2026-05-29 11:03:21.790 | INFO | orchestrator:_report_progress:54 - [CHARTS] 开始更新原生图表...
2026-05-29 11:03:21.797 | INFO | core.native_chart:update_chart_by_anchor:31 - 锚点 chart_gdp 不是图表,在原位创建新图表
2026-05-29 11:03:21.805 | SUCCESS | core.native_chart:_create_chart_in_shape_position:82 - 已在位置创建新原生图表: line
2026-05-29 11:03:21.810 | INFO | core.native_chart:update_chart_by_anchor:31 - 锚点 chart_cpi 不是图表,在原位创建新图表
2026-05-29 11:03:21.818 | SUCCESS | core.native_chart:_create_chart_in_shape_position:82 - 已在位置创建新原生图表: line_markers
2026-05-29 11:03:21.819 | INFO | orchestrator:_report_progress:54 - [CHARTS] 完成 2 个原生图表更新
2026-05-29 11:03:21.820 | INFO | orchestrator:_report_progress:54 - [AI] LLM正在生成分析摘要...
2026-05-29 11:03:21.827 | WARNING | core.anchor_engine:find_anchor:56 - 未找到锚点: text_llm_summary
2026-05-29 11:03:21.827 | SUCCESS | orchestrator:ai_generate_summary:189 - LLM分析完成: 本月宏观经济洞察GDP增长N/A%经济扩张动能平稳。CPI同比N/A%,通胀水平温和,为货币政策...
2026-05-29 11:03:21.829 | INFO | orchestrator:_report_progress:54 - [CONDITIONS] 处理条件渲染规则...
2026-05-29 11:03:21.831 | INFO | core.conditional_renderer:set_context:30 - 条件渲染上下文已设置: ['CPI同比', 'PPI同比', 'GDP同比', 'gdp_growth', 'GDP环比']
2026-05-29 11:03:21.832 | WARNING | core.conditional_renderer:evaluate_condition:48 - 条件表达式评估失败 [unemployment_rate > 5.1]: name 'unemployment_rate' is not defined
2026-05-29 11:03:21.833 | INFO | orchestrator:process_conditions:218 - 条件规则: unemployment_rate > 5.1 -> 不满足
2026-05-29 11:03:21.833 | INFO | core.conditional_renderer:evaluate_condition:44 - 条件 [1.10093365492678 < 5.0] 评估结果: True
2026-05-29 11:03:21.834 | INFO | orchestrator:process_conditions:218 - 条件规则: {gdp_growth} < 5.0 -> 满足
2026-05-29 11:03:21.835 | INFO | orchestrator:_report_progress:54 - [SAVE] 正在保存最终PPT...
2026-05-29 11:03:21.872 | INFO | orchestrator:_report_progress:54 - [DONE] PPT已保存: report_20260529_110321.pptx
2026-05-29 11:03:21.873 | SUCCESS | orchestrator:save:235 - ==================================================
2026-05-29 11:03:21.873 | SUCCESS | orchestrator:save:236 - 生成完成: F:\ppt\ppt_manager_v2\..\output\report_20260529_110321.pptx
2026-05-29 11:03:21.874 | SUCCESS | orchestrator:save:237 - ==================================================

View File

@@ -0,0 +1,34 @@
2026-05-29 11:03:21.696 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [cpi_chart]: CPI/PPI通胀图表生成器
2026-05-29 11:03:21.698 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [gdp_chart]: GDP趋势图表生成器
2026-05-29 11:03:21.699 | INFO | plugins.base_generator:discover_plugins:90 - 插件扫描完成,共加载 2 个生成器
2026-05-29 11:03:21.700 | INFO | ai.llm_analyst:__init__:9 - LLM分析师初始化提供者: mock
2026-05-29 11:03:21.701 | INFO | orchestrator:_report_progress:54 - [LOAD] 创建新演示文稿(模板不存在: macro_analysis_template.pptx
2026-05-29 11:03:21.772 | INFO | core.anchor_engine:scan_anchors:43 - 锚点扫描完成,共发现 4 个可绑定锚点
2026-05-29 11:03:21.774 | INFO | orchestrator:_report_progress:54 - [LOAD] 发现 4 个可绑定锚点
2026-05-29 11:03:21.777 | INFO | orchestrator:_report_progress:54 - [PLUGINS] 开始执行数据插件...
2026-05-29 11:03:21.779 | INFO | orchestrator:_report_progress:54 - [PLUGINS] 执行插件: CPI/PPI通胀图表生成器
2026-05-29 11:03:21.781 | INFO | plugins.generators.cpi_generator:fetch_data:30 - CPI/PPI数据获取成功
2026-05-29 11:03:21.783 | SUCCESS | orchestrator:run_plugins:123 - 插件 [cpi_chart] 执行成功
2026-05-29 11:03:21.786 | INFO | orchestrator:_report_progress:54 - [PLUGINS] 执行插件: GDP趋势图表生成器
2026-05-29 11:03:21.788 | INFO | plugins.generators.gdp_generator:fetch_data:33 - GDP数据获取成功共 4 条记录
2026-05-29 11:03:21.790 | SUCCESS | orchestrator:run_plugins:123 - 插件 [gdp_chart] 执行成功
2026-05-29 11:03:21.790 | INFO | orchestrator:_report_progress:54 - [CHARTS] 开始更新原生图表...
2026-05-29 11:03:21.797 | INFO | core.native_chart:update_chart_by_anchor:31 - 锚点 chart_gdp 不是图表,在原位创建新图表
2026-05-29 11:03:21.805 | SUCCESS | core.native_chart:_create_chart_in_shape_position:82 - 已在位置创建新原生图表: line
2026-05-29 11:03:21.810 | INFO | core.native_chart:update_chart_by_anchor:31 - 锚点 chart_cpi 不是图表,在原位创建新图表
2026-05-29 11:03:21.818 | SUCCESS | core.native_chart:_create_chart_in_shape_position:82 - 已在位置创建新原生图表: line_markers
2026-05-29 11:03:21.819 | INFO | orchestrator:_report_progress:54 - [CHARTS] 完成 2 个原生图表更新
2026-05-29 11:03:21.820 | INFO | orchestrator:_report_progress:54 - [AI] LLM正在生成分析摘要...
2026-05-29 11:03:21.827 | WARNING | core.anchor_engine:find_anchor:56 - 未找到锚点: text_llm_summary
2026-05-29 11:03:21.827 | SUCCESS | orchestrator:ai_generate_summary:189 - LLM分析完成: 本月宏观经济洞察GDP增长N/A%经济扩张动能平稳。CPI同比N/A%,通胀水平温和,为货币政策...
2026-05-29 11:03:21.829 | INFO | orchestrator:_report_progress:54 - [CONDITIONS] 处理条件渲染规则...
2026-05-29 11:03:21.831 | INFO | core.conditional_renderer:set_context:30 - 条件渲染上下文已设置: ['CPI同比', 'PPI同比', 'GDP同比', 'gdp_growth', 'GDP环比']
2026-05-29 11:03:21.832 | WARNING | core.conditional_renderer:evaluate_condition:48 - 条件表达式评估失败 [unemployment_rate > 5.1]: name 'unemployment_rate' is not defined
2026-05-29 11:03:21.833 | INFO | orchestrator:process_conditions:218 - 条件规则: unemployment_rate > 5.1 -> 不满足
2026-05-29 11:03:21.833 | INFO | core.conditional_renderer:evaluate_condition:44 - 条件 [1.10093365492678 < 5.0] 评估结果: True
2026-05-29 11:03:21.834 | INFO | orchestrator:process_conditions:218 - 条件规则: {gdp_growth} < 5.0 -> 满足
2026-05-29 11:03:21.835 | INFO | orchestrator:_report_progress:54 - [SAVE] 正在保存最终PPT...
2026-05-29 11:03:21.872 | INFO | orchestrator:_report_progress:54 - [DONE] PPT已保存: report_20260529_110321.pptx
2026-05-29 11:03:21.873 | SUCCESS | orchestrator:save:235 - ==================================================
2026-05-29 11:03:21.873 | SUCCESS | orchestrator:save:236 - 生成完成: F:\ppt\ppt_manager_v2\..\output\report_20260529_110321.pptx
2026-05-29 11:03:21.874 | SUCCESS | orchestrator:save:237 - ==================================================

View File

@@ -0,0 +1,4 @@
2026-05-29 11:04:41.025 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [cpi_chart]: CPI/PPI通胀图表生成器
2026-05-29 11:04:41.027 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [gdp_chart]: GDP趋势图表生成器
2026-05-29 11:04:41.028 | INFO | plugins.base_generator:discover_plugins:90 - 插件扫描完成,共加载 2 个生成器
2026-05-29 11:04:41.029 | INFO | ai.llm_analyst:__init__:9 - LLM分析师初始化提供者: mock

View File

@@ -0,0 +1,4 @@
2026-05-29 11:10:29.616 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [cpi_chart]: CPI/PPI通胀图表生成器
2026-05-29 11:10:29.618 | SUCCESS | plugins.base_generator:discover_plugins:84 - 加载插件 [gdp_chart]: GDP趋势图表生成器
2026-05-29 11:10:29.618 | INFO | plugins.base_generator:discover_plugins:90 - 插件扫描完成,共加载 2 个生成器
2026-05-29 11:10:29.619 | INFO | ai.llm_analyst:__init__:9 - LLM分析师初始化提供者: mock

View File

@@ -0,0 +1,251 @@
import sys
from pathlib import Path
from typing import Dict, Any, List, Optional
from loguru import logger
from datetime import datetime
import yaml
sys.path.insert(0, str(Path(__file__).parent))
from core.anchor_engine import AnchorEngine
from core.native_chart import NativeChartManager
from core.conditional_renderer import ConditionalRenderer
from plugins.base_generator import GeneratorPluginManager
from ai.llm_analyst import LLMAnalyst
class Orchestrator:
def __init__(self, config_path: str = None):
self.base_dir = Path(__file__).parent
if config_path:
self.config_path = Path(config_path)
else:
self.config_path = self.base_dir / "config" / "project_config_v2.yaml"
self.config = self._load_config()
self.prs = None
self.results: Dict[str, Any] = {}
self.progress_callback = None
log_file = self.base_dir / "logs" / f"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
log_file.parent.mkdir(parents=True, exist_ok=True)
logger.add(str(log_file), rotation="10 MB", level="INFO", encoding="utf-8")
self.plugin_manager = GeneratorPluginManager(self.base_dir / "plugins" / "generators")
self.plugin_manager.discover_plugins()
self.anchor_engine = AnchorEngine()
self.chart_manager = NativeChartManager()
self.conditional_renderer = ConditionalRenderer()
ai_config = self.config.get('ai', {})
self.llm = LLMAnalyst(ai_config)
def _load_config(self) -> Dict:
if self.config_path.exists():
with open(self.config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f) or {}
return {}
def set_progress_callback(self, callback):
self.progress_callback = callback
def _report_progress(self, step: str, message: str, percent: int = 0):
logger.info(f"[{step}] {message}")
if self.progress_callback:
self.progress_callback({
'step': step,
'message': message,
'percent': percent,
'timestamp': datetime.now().isoformat()
})
def load_template(self, template_path: str = None) -> bool:
template_file = self.base_dir / "template_ppt" / (template_path or self.config.get('template', ''))
if not template_file.exists():
self._report_progress("LOAD", f"创建新演示文稿(模板不存在: {template_file.name}", 5)
from pptx import Presentation
self.prs = Presentation()
for i in range(8):
layout_idx = 5 if i == 0 else 1
if layout_idx < len(self.prs.slide_layouts):
slide = self.prs.slides.add_slide(self.prs.slide_layouts[layout_idx])
if slide.shapes.title:
slide.shapes.title.text = f"示例页 {i+1}"
if i == 3:
if slide.shapes.title:
slide.shapes.title.text = "GDP趋势图"
for shp in slide.shapes:
if shp != slide.shapes.title and hasattr(shp, 'name'):
shp.name = "chart_gdp"
break
if i == 4:
if slide.shapes.title:
slide.shapes.title.text = "CPI/PPI走势图"
for shp in slide.shapes:
if shp != slide.shapes.title and hasattr(shp, 'name'):
shp.name = "chart_cpi"
break
logger.debug(f"创建示例页 {i+1}")
else:
self._report_progress("LOAD", f"加载模板: {template_file.name}", 5)
self.prs = self.anchor_engine.load_presentation(template_file)
self.anchor_engine.prs = self.prs
self.chart_manager.set_presentation(self.prs)
self.conditional_renderer.prs = self.prs
anchors_found = self.anchor_engine.scan_anchors()
self._report_progress("LOAD", f"发现 {len(anchors_found)} 个可绑定锚点", 10)
return True
def run_plugins(self, params: Dict[str, Any] = None) -> Dict:
self._report_progress("PLUGINS", "开始执行数据插件...", 15)
run_params = {**self.config.get('params', {}), **(params or {})}
plugin_results = {}
total = len(self.plugin_manager.plugins)
for idx, (pid, plugin_class) in enumerate(self.plugin_manager.plugins.items()):
self._report_progress("PLUGINS", f"执行插件: {plugin_class.generator_name}", 20 + int(idx/total*30))
try:
plugin = plugin_class(run_params)
if plugin.fetch_data(run_params):
render_result = plugin.render()
plugin_results[pid] = {
'success': True,
'data': plugin.get_data(),
'render': render_result
}
logger.success(f"插件 [{pid}] 执行成功")
else:
plugin_results[pid] = {'success': False}
except Exception as e:
logger.exception(f"插件 [{pid}] 执行失败: {e}")
plugin_results[pid] = {'success': False, 'error': str(e)}
self.results['plugins'] = plugin_results
return plugin_results
def update_native_charts(self) -> int:
self._report_progress("CHARTS", "开始更新原生图表...", 55)
updates_count = 0
anchors_config = self.config.get('anchors', [])
for anchor_cfg in anchors_config:
anchor_type = anchor_cfg.get('type', '')
anchor_name = anchor_cfg.get('name', '')
if 'native_chart' in anchor_type:
plugin_id = anchor_cfg.get('plugin')
chart_type = anchor_cfg.get('chart_type', 'line')
if plugin_id and plugin_id in self.results.get('plugins', {}):
plugin_result = self.results['plugins'][plugin_id]
if plugin_result.get('success'):
render = plugin_result['render']
success = self.chart_manager.update_chart_by_anchor(
anchor_name=render.get('anchor', anchor_name),
categories=render.get('categories', []),
series_data=render.get('series', {}),
chart_type=chart_type
)
if success:
updates_count += 1
self._report_progress("CHARTS", f"完成 {updates_count} 个原生图表更新", 70)
return updates_count
def ai_generate_summary(self) -> str:
self._report_progress("AI", "LLM正在生成分析摘要...", 75)
context = {}
for pid, presult in self.results.get('plugins', {}).items():
if presult.get('success') and 'data' in presult:
df = presult['data']
if hasattr(df, 'iloc'):
for col in df.columns:
try:
context[f"{pid}_{col}"] = round(float(df[col].iloc[-1]), 2)
except:
pass
try:
summary = self.llm.generate_analysis(context, max_words=200)
self.results['llm_summary'] = summary
for anchor_cfg in self.config.get('anchors', []):
if anchor_cfg.get('type') == 'text_replace' and 'summary' in anchor_cfg.get('name', ''):
self.anchor_engine.replace_text_anchor(anchor_cfg['name'], summary)
if anchor_cfg.get('type') == 'ai_text_generate':
anchor_name = anchor_cfg.get('name', 'text_llm_summary')
self.anchor_engine.replace_text_anchor(anchor_name, summary)
logger.success(f"LLM分析完成: {summary[:50]}...")
return summary
except Exception as e:
logger.warning(f"LLM分析失败: {e}")
return ""
def process_conditions(self):
self._report_progress("CONDITIONS", "处理条件渲染规则...", 80)
context = {}
for pid, presult in self.results.get('plugins', {}).items():
if presult.get('success') and 'data' in presult:
df = presult['data']
if hasattr(df, 'iloc'):
for col in df.columns:
try:
val = float(df[col].iloc[-1])
context[col] = val
if 'GDP' in col: context['gdp_growth'] = val
if 'unemploy' in col.lower(): context['unemployment_rate'] = val
except:
pass
self.conditional_renderer.set_context(context)
slide_conditions = self.config.get('slide_conditions', [])
for cond_cfg in slide_conditions:
if 'condition' in cond_cfg:
cond_result = self.conditional_renderer.evaluate_condition(cond_cfg['condition'])
logger.info(f"条件规则: {cond_cfg['condition']} -> {'满足' if cond_result else '不满足'}")
return True
def save(self, output_name: str = None) -> str:
self._report_progress("SAVE", "正在保存最终PPT...", 95)
output_dir = self.base_dir / self.config.get('output_dir', 'output')
output_dir.mkdir(parents=True, exist_ok=True)
if not output_name:
output_name = f"report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pptx"
output_path = output_dir / output_name
self.prs.save(str(output_path))
self._report_progress("DONE", f"PPT已保存: {output_name}", 100)
logger.success(f"=" * 50)
logger.success(f"生成完成: {output_path}")
logger.success(f"=" * 50)
return str(output_path)
def run_full_pipeline(self, template_path: str = None, params: Dict = None) -> str:
self.load_template(template_path)
self.run_plugins(params)
self.update_native_charts()
self.ai_generate_summary()
self.process_conditions()
return self.save()
if __name__ == "__main__":
orch = Orchestrator()
orch.run_full_pipeline()

View File

@@ -0,0 +1,109 @@
from abc import ABC, abstractmethod
from typing import Dict, Any, List, Optional
import pandas as pd
from pathlib import Path
from loguru import logger
class BaseGenerator(ABC):
generator_id: str = None
generator_name: str = None
description: str = None
version: str = "1.0.0"
params_schema: Dict[str, Any] = {}
def __init__(self, params: Dict[str, Any] = None):
self.params = params or {}
self.logger = logger.bind(generator=self.generator_id)
self._data = None
self._chart_data = None
@abstractmethod
def fetch_data(self, params: Dict[str, Any] = None) -> bool:
pass
@abstractmethod
def render(self) -> Dict[str, Any]:
pass
def validate_params(self, params: Dict[str, Any]) -> bool:
for param_name, param_config in self.params_schema.items():
if param_config.get('required', False) and param_name not in params:
self.logger.warning(f"缺少必需参数: {param_name}")
return True
def get_data(self):
return self._data
def get_result(self) -> Dict[str, Any]:
return {
'generator_id': self.generator_id,
'generator_name': self.generator_name,
'data': self._data,
'chart_data': self._chart_data,
'success': True
}
class GeneratorPluginManager:
def __init__(self, plugins_dir: Path = None):
self.plugins: Dict[str, BaseGenerator] = {}
self.plugins_dir = plugins_dir or Path(__file__).parent / 'generators'
self._discovered = False
def discover_plugins(self) -> int:
import sys
import importlib.util
if self._discovered:
return len(self.plugins)
generators_dir = self.plugins_dir
if not generators_dir.exists():
logger.warning(f"插件目录不存在: {generators_dir}")
return 0
sys.path.insert(0, str(generators_dir.parent))
for py_file in generators_dir.glob("*.py"):
if py_file.name.startswith("_"):
continue
try:
module_name = f"plugins.generators.{py_file.stem}"
spec = importlib.util.spec_from_file_location(module_name, str(py_file))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for name, obj in vars(module).items():
if (isinstance(obj, type) and
issubclass(obj, BaseGenerator) and
obj != BaseGenerator and
obj.generator_id is not None):
self.plugins[obj.generator_id] = obj
logger.success(f"加载插件 [{obj.generator_id}]: {obj.generator_name}")
except Exception as e:
logger.exception(f"加载插件失败 {py_file}: {e}")
self._discovered = True
logger.info(f"插件扫描完成,共加载 {len(self.plugins)} 个生成器")
return len(self.plugins)
def get_generator(self, generator_id: str, params: Dict[str, Any] = None) -> Optional[BaseGenerator]:
if generator_id in self.plugins:
return self.plugins[generator_id](params)
logger.warning(f"找不到生成器插件: {generator_id}")
return None
def list_generators(self) -> List[Dict[str, Any]]:
result = []
for gid, cls in self.plugins.items():
result.append({
'id': gid,
'name': cls.generator_name,
'description': cls.description,
'version': cls.version,
'params_schema': cls.params_schema
})
return result

View File

@@ -0,0 +1,50 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from plugins.base_generator import BaseGenerator
import pandas as pd
import numpy as np
from typing import Dict, Any
class CPIGenerator(BaseGenerator):
generator_id = "cpi_chart"
generator_name = "CPI/PPI通胀图表生成器"
description = "生成CPI与PPI原生图表数据"
version = "2.0.0"
def fetch_data(self, params: Dict[str, Any] = None) -> bool:
months = ['1月', '2月', '3月', '4月', '5月', '6月']
self._data = pd.DataFrame({
'month': months,
'CPI同比': [0.7, 0.8, 0.9, 1.0 + np.random.randn() * 0.1,
0.95 + np.random.randn() * 0.1, None],
'PPI同比': [-2.5, -2.3, -2.1, -1.9 + np.random.randn() * 0.15,
-1.8 + np.random.randn() * 0.15, None]
})
self._data.loc[4:5, 'CPI同比'] = [0.9 + np.random.randn() * 0.1, 0.95 + np.random.randn() * 0.1]
self._data.loc[4:5, 'PPI同比'] = [-1.7 + np.random.randn() * 0.15, -1.5 + np.random.randn() * 0.15]
self.logger.info("CPI/PPI数据获取成功")
return True
def render(self) -> Dict[str, Any]:
if self._data is None:
self.fetch_data()
categories = self._data['month'].tolist()
series = {
'CPI(%)': self._data['CPI同比'].round(2).tolist(),
'PPI(%)': self._data['PPI同比'].round(2).tolist()
}
return {
'chart_type': 'line_markers',
'categories': categories,
'series': series,
'dataframe': self._data,
'anchor': 'chart_cpi',
'title': 'CPI与PPI走势'
}

View File

@@ -0,0 +1,53 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from plugins.base_generator import BaseGenerator
import pandas as pd
import numpy as np
from typing import Dict, Any
class GDPGenerator(BaseGenerator):
generator_id = "gdp_chart"
generator_name = "GDP趋势图表生成器"
description = "生成GDP季度增长率折线图原生数据"
version = "2.0.0"
params_schema = {
'year': {'type': 'int', 'default': 2026, 'description': '年份'},
'quarter': {'type': 'str', 'default': 'Q2', 'description': '季度'}
}
def fetch_data(self, params: Dict[str, Any] = None) -> bool:
p = {**self.params, **(params or {})}
year = p.get('year', 2026)
quarters = [f"{year-1}Q3", f"{year-1}Q4", f"{year}Q1", f"{year}Q2"]
self._data = pd.DataFrame({
'quarter': quarters,
'GDP同比': [5.2, 4.8, 5.0 + np.random.randn() * 0.3, 5.1 + np.random.randn() * 0.3],
'GDP环比': [1.6, 1.4, 1.2 + np.random.randn() * 0.2, 1.3 + np.random.randn() * 0.2]
})
self.logger.info(f"GDP数据获取成功{len(self._data)} 条记录")
return True
def render(self) -> Dict[str, Any]:
if self._data is None:
self.fetch_data()
categories = self._data['quarter'].tolist()
series = {
'GDP同比增长(%)': self._data['GDP同比'].round(2).tolist(),
'GDP环比增长(%)': self._data['GDP环比'].round(2).tolist()
}
return {
'chart_type': 'line',
'categories': categories,
'series': series,
'dataframe': self._data,
'anchor': 'chart_gdp',
'title': 'GDP增长趋势'
}

View File

@@ -0,0 +1,14 @@
python-pptx>=0.6.21
Flask>=2.3.0
Flask-SocketIO>=5.3.0
python-socketio>=5.8.0
eventlet>=0.33.0
pandas>=2.0.0
matplotlib>=3.7.0
plotly>=5.15.0
requests>=2.31.0
PyYAML>=6.0
loguru>=0.7.0
numpy>=1.24.0
openai>=1.0.0
dashscope>=1.14.0

View File

@@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PPT智能管理系统 V2.0</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.js"></script>
<style>
.log-scroll { max-height: 280px; overflow-y: auto; }
.progress-bar-fill { transition: width 0.3s ease; }
</style>
</head>
<body class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 min-h-screen text-white">
<div class="container mx-auto px-4 py-6 max-w-6xl">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold bg-gradient-to-r from-cyan-400 via-blue-500 to-purple-500 bg-clip-text text-transparent mb-2">
PPT智能管理系统 <span class="text-cyan-300 text-xl">v2.0</span>
</h1>
<p class="text-slate-400">基于锚点定位 | 原生图表更新 | 插件化架构 | WebSocket实时推送</p>
</div>
<div class="grid lg:grid-cols-3 gap-5">
<div class="lg:col-span-2 space-y-5">
<div class="bg-slate-800/60 rounded-2xl p-5 border border-slate-700">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="w-8 h-8 bg-cyan-500/20 rounded-lg flex items-center justify-center text-cyan-400"></span>
参数配置
</h2>
<div class="grid md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm text-slate-400 mb-1">报告年份</label>
<input type="number" id="param_year" value="2026"
class="w-full bg-slate-700/50 border border-slate-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-cyan-500 outline-none">
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">季度</label>
<select id="param_quarter"
class="w-full bg-slate-700/50 border border-slate-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-cyan-500 outline-none">
<option value="Q1">Q1 (1-3月)</option>
<option value="Q2" selected>Q2 (4-6月)</option>
<option value="Q3">Q3 (7-9月)</option>
<option value="Q4">Q4 (10-12月)</option>
</select>
</div>
</div>
<button id="btnGenerate" onclick="startGeneration()"
class="w-full py-3 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700 rounded-xl font-semibold transition-all transform hover:scale-[1.01] active:scale-[0.99] shadow-lg shadow-cyan-500/20">
🚀 开始生成 PPT
</button>
</div>
<div class="bg-slate-800/60 rounded-2xl p-5 border border-slate-700">
<h2 class="text-lg font-semibold mb-3 flex items-center gap-2">
<span class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center text-green-400"></span>
实时进度
</h2>
<div class="w-full bg-slate-700/50 rounded-full h-3 mb-4">
<div id="progressBar" class="progress-bar-fill bg-gradient-to-r from-green-400 to-cyan-400 h-3 rounded-full" style="width: 0%"></div>
</div>
<div id="statusText" class="text-slate-400 text-sm mb-3">等待开始...</div>
<div class="bg-slate-900/50 rounded-xl overflow-hidden">
<div class="bg-slate-800/80 px-3 py-2 text-xs text-slate-500 border-b border-slate-700/50">
LOG OUTPUT (实时推送)
</div>
<div id="logOutput" class="log-scroll p-3 text-xs font-mono space-y-1">
<div class="text-slate-600">// 系统已就绪,等待开始</div>
</div>
</div>
</div>
</div>
<div class="space-y-5">
<div class="bg-slate-800/60 rounded-2xl p-5 border border-slate-700">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="w-8 h-8 bg-purple-500/20 rounded-lg flex items-center justify-center text-purple-400">📦</span>
已加载插件
</h2>
<div id="pluginList" class="space-y-2">
<div class="text-slate-500 text-sm">加载中...</div>
</div>
</div>
<div class="bg-slate-800/60 rounded-2xl p-5 border border-slate-700">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="w-8 h-8 bg-amber-500/20 rounded-lg flex items-center justify-center text-amber-400">📋</span>
已生成文件
</h2>
<div id="fileList" class="space-y-2 max-h-64 overflow-y-auto">
<div class="text-slate-500 text-sm">暂无</div>
</div>
</div>
<div class="bg-gradient-to-br from-cyan-500/10 to-blue-500/10 rounded-2xl p-5 border border-cyan-500/20">
<h3 class="font-semibold text-cyan-300 mb-2">✨ 核心增强</h3>
<ul class="text-xs text-slate-400 space-y-1.5">
<li>• 锚点(Anchor)定位 - 无惧页码变动</li>
<li>• 原生图表更新 - 可编辑/保持主题动画</li>
<li>• 插件化架构 - 自动扫描注册即插即用</li>
<li>• 条件渲染 - 规则判断动态增删页</li>
<li>• LLM洞察 - 数据→分析结论自动生成</li>
<li>• WebSocket - 进度+日志实时推送</li>
</ul>
</div>
</div>
</div>
</div>
<script>
let socket = io();
let isRunning = false;
socket.on('connect', () => {
appendLog('System', 'WebSocket已连接', '#22c55e');
refreshPlugins();
refreshFiles();
});
socket.on('progress', (data) => {
document.getElementById('progressBar').style.width = `${data.percent}%`;
document.getElementById('statusText').textContent = `[${data.percent}%] ${data.message}`;
appendLog(data.step, data.message, '#06b6d4');
});
socket.on('complete', (data) => {
isRunning = false;
document.getElementById('btnGenerate').disabled = false;
document.getElementById('btnGenerate').textContent = '🚀 开始生成 PPT';
if (data.success) {
appendLog('DONE', `成功: ${data.output_file}`, '#22c55e');
document.getElementById('statusText').innerHTML =
`<span class="text-green-400">✓ 完成!</span> <a href="${data.download_url}" class="text-cyan-400 underline ml-1" target="_blank">下载文件</a>`;
refreshFiles();
} else {
appendLog('ERROR', data.error, '#ef4444');
document.getElementById('statusText').innerHTML = `<span class="text-red-400">✗ 失败: ${data.error}</span>`;
}
});
function startGeneration() {
if (isRunning) return;
isRunning = true;
const btn = document.getElementById('btnGenerate');
btn.disabled = true;
btn.textContent = '⏳ 生成中...';
document.getElementById('logOutput').innerHTML = '';
socket.emit('start_generation', {
params: {
year: parseInt(document.getElementById('param_year').value),
quarter: document.getElementById('param_quarter').value
}
});
}
function appendLog(tag, message, color = '#94a3b8') {
const container = document.getElementById('logOutput');
const div = document.createElement('div');
div.innerHTML = `<span style="color:${color}">[${tag}]</span> ${message}`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
async function refreshPlugins() {
const res = await fetch('/api/projects');
const data = await res.json();
const html = data.plugins.map(p => `
<div class="bg-slate-700/30 rounded-lg px-3 py-2.5 border border-slate-600/50">
<div class="font-medium text-sm text-white">${p.name}</div>
<div class="text-xs text-slate-500">${p.description || ''}</div>
</div>
`).join('');
document.getElementById('pluginList').innerHTML = html || '<div class="text-slate-500 text-sm">无插件</div>';
}
async function refreshFiles() {
const res = await fetch('/api/files');
const data = await res.json();
const html = data.files.slice(0, 8).map(f => `
<a href="/download/${f.name}" target="_blank"
class="flex items-center justify-between bg-slate-700/30 rounded-lg px-3 py-2 border border-slate-600/50 hover:bg-slate-700/50 transition-colors">
<div>
<div class="font-mono text-xs text-white truncate max-w-[150px]">${f.name}</div>
<div class="text-[10px] text-slate-500">${f.size} MB</div>
</div>
<span class="text-cyan-400 text-xs">↓</span>
</a>
`).join('');
document.getElementById('fileList').innerHTML = html || '<div class="text-slate-500 text-sm">暂无</div>';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PPT智能管理系统 V2.0</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.js"></script>
<style>
.log-scroll { max-height: 280px; overflow-y: auto; }
.progress-bar-fill { transition: width 0.3s ease; }
</style>
</head>
<body class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 min-h-screen text-white">
<div class="container mx-auto px-4 py-6 max-w-6xl">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold bg-gradient-to-r from-cyan-400 via-blue-500 to-purple-500 bg-clip-text text-transparent mb-2">
PPT智能管理系统 <span class="text-cyan-300 text-xl">v2.0</span>
</h1>
<p class="text-slate-400">基于锚点定位 | 原生图表更新 | 插件化架构 | WebSocket实时推送</p>
</div>
<div class="grid lg:grid-cols-3 gap-5">
<div class="lg:col-span-2 space-y-5">
<div class="bg-slate-800/60 rounded-2xl p-5 border border-slate-700">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="w-8 h-8 bg-cyan-500/20 rounded-lg flex items-center justify-center text-cyan-400"></span>
参数配置
</h2>
<div class="grid md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm text-slate-400 mb-1">报告年份</label>
<input type="number" id="param_year" value="2026"
class="w-full bg-slate-700/50 border border-slate-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-cyan-500 outline-none">
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">季度</label>
<select id="param_quarter"
class="w-full bg-slate-700/50 border border-slate-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-cyan-500 outline-none">
<option value="Q1">Q1 (1-3月)</option>
<option value="Q2" selected>Q2 (4-6月)</option>
<option value="Q3">Q3 (7-9月)</option>
<option value="Q4">Q4 (10-12月)</option>
</select>
</div>
</div>
<button id="btnGenerate" onclick="startGeneration()"
class="w-full py-3 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700 rounded-xl font-semibold transition-all transform hover:scale-[1.01] active:scale-[0.99] shadow-lg shadow-cyan-500/20">
🚀 开始生成 PPT
</button>
</div>
<div class="bg-slate-800/60 rounded-2xl p-5 border border-slate-700">
<h2 class="text-lg font-semibold mb-3 flex items-center gap-2">
<span class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center text-green-400"></span>
实时进度
</h2>
<div class="w-full bg-slate-700/50 rounded-full h-3 mb-4">
<div id="progressBar" class="progress-bar-fill bg-gradient-to-r from-green-400 to-cyan-400 h-3 rounded-full" style="width: 0%"></div>
</div>
<div id="statusText" class="text-slate-400 text-sm mb-3">等待开始...</div>
<div class="bg-slate-900/50 rounded-xl overflow-hidden">
<div class="bg-slate-800/80 px-3 py-2 text-xs text-slate-500 border-b border-slate-700/50">
LOG OUTPUT (实时推送)
</div>
<div id="logOutput" class="log-scroll p-3 text-xs font-mono space-y-1">
<div class="text-slate-600">// 系统已就绪,等待开始</div>
</div>
</div>
</div>
</div>
<div class="space-y-5">
<div class="bg-slate-800/60 rounded-2xl p-5 border border-slate-700">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="w-8 h-8 bg-purple-500/20 rounded-lg flex items-center justify-center text-purple-400">📦</span>
已加载插件
</h2>
<div id="pluginList" class="space-y-2">
<div class="text-slate-500 text-sm">加载中...</div>
</div>
</div>
<div class="bg-slate-800/60 rounded-2xl p-5 border border-slate-700">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="w-8 h-8 bg-amber-500/20 rounded-lg flex items-center justify-center text-amber-400">📋</span>
已生成文件
</h2>
<div id="fileList" class="space-y-2 max-h-64 overflow-y-auto">
<div class="text-slate-500 text-sm">暂无</div>
</div>
</div>
<div class="bg-gradient-to-br from-cyan-500/10 to-blue-500/10 rounded-2xl p-5 border border-cyan-500/20">
<h3 class="font-semibold text-cyan-300 mb-2">✨ 核心增强</h3>
<ul class="text-xs text-slate-400 space-y-1.5">
<li>• 锚点(Anchor)定位 - 无惧页码变动</li>
<li>• 原生图表更新 - 可编辑/保持主题动画</li>
<li>• 插件化架构 - 自动扫描注册即插即用</li>
<li>• 条件渲染 - 规则判断动态增删页</li>
<li>• LLM洞察 - 数据→分析结论自动生成</li>
<li>• WebSocket - 进度+日志实时推送</li>
</ul>
</div>
</div>
</div>
</div>
<script>
let socket = io();
let isRunning = false;
socket.on('connect', () => {
appendLog('System', 'WebSocket已连接', '#22c55e');
refreshPlugins();
refreshFiles();
});
socket.on('progress', (data) => {
document.getElementById('progressBar').style.width = `${data.percent}%`;
document.getElementById('statusText').textContent = `[${data.percent}%] ${data.message}`;
appendLog(data.step, data.message, '#06b6d4');
});
socket.on('complete', (data) => {
isRunning = false;
document.getElementById('btnGenerate').disabled = false;
document.getElementById('btnGenerate').textContent = '🚀 开始生成 PPT';
if (data.success) {
appendLog('DONE', `成功: ${data.output_file}`, '#22c55e');
document.getElementById('statusText').innerHTML =
`<span class="text-green-400">✓ 完成!</span> <a href="${data.download_url}" class="text-cyan-400 underline ml-1" target="_blank">下载文件</a>`;
refreshFiles();
} else {
appendLog('ERROR', data.error, '#ef4444');
document.getElementById('statusText').innerHTML = `<span class="text-red-400">✗ 失败: ${data.error}</span>`;
}
});
function startGeneration() {
if (isRunning) return;
isRunning = true;
const btn = document.getElementById('btnGenerate');
btn.disabled = true;
btn.textContent = '⏳ 生成中...';
document.getElementById('logOutput').innerHTML = '';
socket.emit('start_generation', {
params: {
year: parseInt(document.getElementById('param_year').value),
quarter: document.getElementById('param_quarter').value
}
});
}
function appendLog(tag, message, color = '#94a3b8') {
const container = document.getElementById('logOutput');
const div = document.createElement('div');
div.innerHTML = `<span style="color:${color}">[${tag}]</span> ${message}`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
async function refreshPlugins() {
const res = await fetch('/api/projects');
const data = await res.json();
const html = data.plugins.map(p => `
<div class="bg-slate-700/30 rounded-lg px-3 py-2.5 border border-slate-600/50">
<div class="font-medium text-sm text-white">${p.name}</div>
<div class="text-xs text-slate-500">${p.description || ''}</div>
</div>
`).join('');
document.getElementById('pluginList').innerHTML = html || '<div class="text-slate-500 text-sm">无插件</div>';
}
async function refreshFiles() {
const res = await fetch('/api/files');
const data = await res.json();
const html = data.files.slice(0, 8).map(f => `
<a href="/download/${f.name}" target="_blank"
class="flex items-center justify-between bg-slate-700/30 rounded-lg px-3 py-2 border border-slate-600/50 hover:bg-slate-700/50 transition-colors">
<div>
<div class="font-mono text-xs text-white truncate max-w-[150px]">${f.name}</div>
<div class="text-[10px] text-slate-500">${f.size} MB</div>
</div>
<span class="text-cyan-400 text-xs">↓</span>
</a>
`).join('');
document.getElementById('fileList').innerHTML = html || '<div class="text-slate-500 text-sm">暂无</div>';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,96 @@
import sys
from pathlib import Path
from flask import Flask, render_template, jsonify, send_file, request
from flask_socketio import SocketIO, emit
import threading
from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent))
from orchestrator import Orchestrator
from loguru import logger
base_dir = Path(__file__).parent
template_dir = base_dir / "web" / "templates"
app = Flask(__name__, template_folder=str(template_dir))
app.config['SECRET_KEY'] = 'ppt_manager_v2_secret!'
socketio = SocketIO(app, cors_allowed_origins="*")
@app.route('/')
def index():
return render_template('index_v2.html')
@app.route('/api/projects')
def list_projects():
orch = Orchestrator()
plugins = orch.plugin_manager.list_generators()
return jsonify({
'success': True,
'plugins': plugins,
'config': orch.config
})
@app.route('/api/files')
def list_files():
output_dir = base_dir.parent / "output"
files = []
if output_dir.exists():
for f in sorted(output_dir.glob("*.pptx"), reverse=True):
files.append({
'name': f.name,
'size': round(f.stat().st_size / 1024 / 1024, 2),
'modified': datetime.fromtimestamp(f.stat().st_mtime).isoformat()
})
return jsonify({'success': True, 'files': files})
@app.route('/download/<filename>')
def download(filename):
f = base_dir.parent / "output" / filename
if f.exists():
return send_file(str(f), as_attachment=True)
return jsonify(success=False, error="File not found"), 404
@socketio.on('start_generation')
def handle_start_generation(data):
params = data.get('params', {})
def progress_callback(message):
socketio.emit('progress', message, room=request.sid)
logger.debug(f"WebSocket进度: {message['percent']}% - {message['message']}")
orch = Orchestrator()
orch.set_progress_callback(progress_callback)
try:
socketio.emit('progress', {
'step': 'START',
'message': '开始执行PPT生成管线...',
'percent': 0
}, room=request.sid)
output_path = orch.run_full_pipeline(params=params)
socketio.emit('complete', {
'success': True,
'output_file': Path(output_path).name,
'download_url': f"/download/{Path(output_path).name}",
'results': {k: v.get('success', False) for k,v in orch.results.get('plugins', {}).items()}
}, room=request.sid)
except Exception as e:
logger.exception("生成过程出错")
socketio.emit('complete', {
'success': False,
'error': str(e)
}, room=request.sid)
def run_server():
print("\n" + "=" * 65)
print(" 🚀 PPT智能管理系统 V2.0 - WebSocket实时日志版")
print(" 请在浏览器打开: http://localhost:5001")
print("=" * 65 + "\n")
socketio.run(app, host='0.0.0.0', port=5001, debug=True)
if __name__ == '__main__':
run_server()