V2.0 五大核心增强: 锚点定位/原生图表/插件架构/WebSocket/LLM智能
This commit is contained in:
115
ppt_manager/README.md
Normal file
115
ppt_manager/README.md
Normal 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
70
ppt_manager/app.py
Normal 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)
|
||||||
42
ppt_manager/config/project_config.yaml
Normal file
42
ppt_manager/config/project_config.yaml
Normal 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
|
||||||
56
ppt_manager/create_sample_template.py
Normal file
56
ppt_manager/create_sample_template.py
Normal 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
38
ppt_manager/main.py
Normal 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()
|
||||||
11
ppt_manager/requirements.txt
Normal file
11
ppt_manager/requirements.txt
Normal 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
|
||||||
62
ppt_manager/scripts/employment_table.py
Normal file
62
ppt_manager/scripts/employment_table.py
Normal 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())
|
||||||
32
ppt_manager/scripts/gdp_chart.py
Normal file
32
ppt_manager/scripts/gdp_chart.py
Normal 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())
|
||||||
34
ppt_manager/scripts/inflation_chart.py
Normal file
34
ppt_manager/scripts/inflation_chart.py
Normal 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())
|
||||||
66
ppt_manager/scripts/market_analysis.py
Normal file
66
ppt_manager/scripts/market_analysis.py
Normal 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())
|
||||||
34
ppt_manager/scripts/trade_chart.py
Normal file
34
ppt_manager/scripts/trade_chart.py
Normal 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())
|
||||||
0
ppt_manager/src/__init__.py
Normal file
0
ppt_manager/src/__init__.py
Normal file
39
ppt_manager/src/config_loader.py
Normal file
39
ppt_manager/src/config_loader.py
Normal 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 {}
|
||||||
93
ppt_manager/src/dynamic_generator.py
Normal file
93
ppt_manager/src/dynamic_generator.py
Normal 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)
|
||||||
71
ppt_manager/src/ppt_core.py
Normal file
71
ppt_manager/src/ppt_core.py
Normal 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)
|
||||||
119
ppt_manager/src/ppt_generator.py
Normal file
119
ppt_manager/src/ppt_generator.py
Normal 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 <项目名称>")
|
||||||
179
ppt_manager/templates/index.html
Normal file
179
ppt_manager/templates/index.html
Normal 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
383
ppt_manager_v2/README.md
Normal 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/ |
|
||||||
|
|
||||||
BIN
ppt_manager_v2/__pycache__/orchestrator.cpython-311.pyc
Normal file
BIN
ppt_manager_v2/__pycache__/orchestrator.cpython-311.pyc
Normal file
Binary file not shown.
BIN
ppt_manager_v2/ai/__pycache__/llm_analyst.cpython-311.pyc
Normal file
BIN
ppt_manager_v2/ai/__pycache__/llm_analyst.cpython-311.pyc
Normal file
Binary file not shown.
124
ppt_manager_v2/ai/llm_analyst.py
Normal file
124
ppt_manager_v2/ai/llm_analyst.py
Normal 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)
|
||||||
86
ppt_manager_v2/config/project_config_v2.yaml
Normal file
86
ppt_manager_v2/config/project_config_v2.yaml
Normal 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"
|
||||||
142
ppt_manager_v2/connectors/sql_connector.py
Normal file
142
ppt_manager_v2/connectors/sql_connector.py
Normal 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
|
||||||
0
ppt_manager_v2/core/__init__.py
Normal file
0
ppt_manager_v2/core/__init__.py
Normal file
BIN
ppt_manager_v2/core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
ppt_manager_v2/core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
ppt_manager_v2/core/__pycache__/anchor_engine.cpython-311.pyc
Normal file
BIN
ppt_manager_v2/core/__pycache__/anchor_engine.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
ppt_manager_v2/core/__pycache__/native_chart.cpython-311.pyc
Normal file
BIN
ppt_manager_v2/core/__pycache__/native_chart.cpython-311.pyc
Normal file
Binary file not shown.
80
ppt_manager_v2/core/anchor_engine.py
Normal file
80
ppt_manager_v2/core/anchor_engine.py
Normal 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 ""
|
||||||
137
ppt_manager_v2/core/conditional_renderer.py
Normal file
137
ppt_manager_v2/core/conditional_renderer.py
Normal 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
|
||||||
148
ppt_manager_v2/core/native_chart.py
Normal file
148
ppt_manager_v2/core/native_chart.py
Normal 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
|
||||||
62
ppt_manager_v2/logs/run_20260529_105045.log
Normal file
62
ppt_manager_v2/logs/run_20260529_105045.log
Normal 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'
|
||||||
58
ppt_manager_v2/logs/run_20260529_105104.log
Normal file
58
ppt_manager_v2/logs/run_20260529_105104.log
Normal 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'
|
||||||
4
ppt_manager_v2/logs/run_20260529_105156.log
Normal file
4
ppt_manager_v2/logs/run_20260529_105156.log
Normal 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
|
||||||
28
ppt_manager_v2/logs/run_20260529_105202.log
Normal file
28
ppt_manager_v2/logs/run_20260529_105202.log
Normal 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 - ==================================================
|
||||||
38
ppt_manager_v2/logs/run_20260529_105938.log
Normal file
38
ppt_manager_v2/logs/run_20260529_105938.log
Normal 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 - ==================================================
|
||||||
34
ppt_manager_v2/logs/run_20260529_110321.log
Normal file
34
ppt_manager_v2/logs/run_20260529_110321.log
Normal 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 - ==================================================
|
||||||
4
ppt_manager_v2/logs/run_20260529_110441.log
Normal file
4
ppt_manager_v2/logs/run_20260529_110441.log
Normal 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
|
||||||
4
ppt_manager_v2/logs/run_20260529_111029.log
Normal file
4
ppt_manager_v2/logs/run_20260529_111029.log
Normal 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
|
||||||
251
ppt_manager_v2/orchestrator.py
Normal file
251
ppt_manager_v2/orchestrator.py
Normal 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()
|
||||||
Binary file not shown.
109
ppt_manager_v2/plugins/base_generator.py
Normal file
109
ppt_manager_v2/plugins/base_generator.py
Normal 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
|
||||||
0
ppt_manager_v2/plugins/generators/__init__.py
Normal file
0
ppt_manager_v2/plugins/generators/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
50
ppt_manager_v2/plugins/generators/cpi_generator.py
Normal file
50
ppt_manager_v2/plugins/generators/cpi_generator.py
Normal 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走势'
|
||||||
|
}
|
||||||
53
ppt_manager_v2/plugins/generators/gdp_generator.py
Normal file
53
ppt_manager_v2/plugins/generators/gdp_generator.py
Normal 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增长趋势'
|
||||||
|
}
|
||||||
14
ppt_manager_v2/requirements_v2.txt
Normal file
14
ppt_manager_v2/requirements_v2.txt
Normal 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
|
||||||
201
ppt_manager_v2/templates/index_v2.html
Normal file
201
ppt_manager_v2/templates/index_v2.html
Normal 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>
|
||||||
201
ppt_manager_v2/web/templates/index_v2.html
Normal file
201
ppt_manager_v2/web/templates/index_v2.html
Normal 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>
|
||||||
96
ppt_manager_v2/web_socket_app.py
Normal file
96
ppt_manager_v2/web_socket_app.py
Normal 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()
|
||||||
Reference in New Issue
Block a user