diff --git a/ppt_manager/README.md b/ppt_manager/README.md new file mode 100644 index 0000000..fe6bea1 --- /dev/null +++ b/ppt_manager/README.md @@ -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 | 原理、方法论 | 固定不变 | diff --git a/ppt_manager/app.py b/ppt_manager/app.py new file mode 100644 index 0000000..c945666 --- /dev/null +++ b/ppt_manager/app.py @@ -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/', 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/') +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) diff --git a/ppt_manager/config/project_config.yaml b/ppt_manager/config/project_config.yaml new file mode 100644 index 0000000..8cff72a --- /dev/null +++ b/ppt_manager/config/project_config.yaml @@ -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 diff --git a/ppt_manager/create_sample_template.py b/ppt_manager/create_sample_template.py new file mode 100644 index 0000000..d30992e --- /dev/null +++ b/ppt_manager/create_sample_template.py @@ -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() diff --git a/ppt_manager/main.py b/ppt_manager/main.py new file mode 100644 index 0000000..7045af5 --- /dev/null +++ b/ppt_manager/main.py @@ -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() diff --git a/ppt_manager/requirements.txt b/ppt_manager/requirements.txt new file mode 100644 index 0000000..b32f329 --- /dev/null +++ b/ppt_manager/requirements.txt @@ -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 diff --git a/ppt_manager/scripts/employment_table.py b/ppt_manager/scripts/employment_table.py new file mode 100644 index 0000000..0cc6afa --- /dev/null +++ b/ppt_manager/scripts/employment_table.py @@ -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()) diff --git a/ppt_manager/scripts/gdp_chart.py b/ppt_manager/scripts/gdp_chart.py new file mode 100644 index 0000000..4c43d15 --- /dev/null +++ b/ppt_manager/scripts/gdp_chart.py @@ -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()) diff --git a/ppt_manager/scripts/inflation_chart.py b/ppt_manager/scripts/inflation_chart.py new file mode 100644 index 0000000..f8f0fc5 --- /dev/null +++ b/ppt_manager/scripts/inflation_chart.py @@ -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()) diff --git a/ppt_manager/scripts/market_analysis.py b/ppt_manager/scripts/market_analysis.py new file mode 100644 index 0000000..51ef3d9 --- /dev/null +++ b/ppt_manager/scripts/market_analysis.py @@ -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()) diff --git a/ppt_manager/scripts/trade_chart.py b/ppt_manager/scripts/trade_chart.py new file mode 100644 index 0000000..ccfdfb6 --- /dev/null +++ b/ppt_manager/scripts/trade_chart.py @@ -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()) diff --git a/ppt_manager/src/__init__.py b/ppt_manager/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ppt_manager/src/config_loader.py b/ppt_manager/src/config_loader.py new file mode 100644 index 0000000..9d1f775 --- /dev/null +++ b/ppt_manager/src/config_loader.py @@ -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 {} diff --git a/ppt_manager/src/dynamic_generator.py b/ppt_manager/src/dynamic_generator.py new file mode 100644 index 0000000..2fa363c --- /dev/null +++ b/ppt_manager/src/dynamic_generator.py @@ -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) diff --git a/ppt_manager/src/ppt_core.py b/ppt_manager/src/ppt_core.py new file mode 100644 index 0000000..b02c75d --- /dev/null +++ b/ppt_manager/src/ppt_core.py @@ -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) diff --git a/ppt_manager/src/ppt_generator.py b/ppt_manager/src/ppt_generator.py new file mode 100644 index 0000000..8ef0bd9 --- /dev/null +++ b/ppt_manager/src/ppt_generator.py @@ -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 <项目名称>") diff --git a/ppt_manager/templates/index.html b/ppt_manager/templates/index.html new file mode 100644 index 0000000..e8a6c73 --- /dev/null +++ b/ppt_manager/templates/index.html @@ -0,0 +1,179 @@ + + + + + + PPT智能管理系统 + + + + +
+
+

+ 📊 PPT智能管理系统 +

+

静态模板 + 动态数据 = 一键生成最新报告

+
+ +
+
+

+ 📁 可用项目 +

+ +
+ {% for project in projects %} +
+
+
+

{{ project.name }}

+

共 {{ project.total_slides }} 页

+
+ +
+
+ + 静态: {{ project.slide_mapping.values() | list | count('static') }} 页 + + + 动态: {{ project.total_slides - (project.slide_mapping.values() | list | count('static')) }} 页 + +
+
+ {% endfor %} +
+ + + + + + +
+ +
+

+ 📋 已生成文件 +

+
+

加载中...

+
+
+
+ +
+

+ 📖 使用说明 +

+
+
+

1️⃣ 准备静态模板

+

将固定不变的PPT模板放入 static_ppt 目录

+
+
+

2️⃣ 配置页面映射

+

在 config/project_config.yaml 中配置每页是静态或动态

+
+
+

3️⃣ 编写动态脚本

+

在 scripts 目录编写数据爬取和图表生成脚本

+
+
+
+
+ + + + diff --git a/ppt_manager_v2/README.md b/ppt_manager_v2/README.md new file mode 100644 index 0000000..ff486e5 --- /dev/null +++ b/ppt_manager_v2/README.md @@ -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/` 两处的 `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/ | + diff --git a/ppt_manager_v2/__pycache__/orchestrator.cpython-311.pyc b/ppt_manager_v2/__pycache__/orchestrator.cpython-311.pyc new file mode 100644 index 0000000..3e3bd15 Binary files /dev/null and b/ppt_manager_v2/__pycache__/orchestrator.cpython-311.pyc differ diff --git a/ppt_manager_v2/ai/__pycache__/llm_analyst.cpython-311.pyc b/ppt_manager_v2/ai/__pycache__/llm_analyst.cpython-311.pyc new file mode 100644 index 0000000..3f2ef34 Binary files /dev/null and b/ppt_manager_v2/ai/__pycache__/llm_analyst.cpython-311.pyc differ diff --git a/ppt_manager_v2/ai/llm_analyst.py b/ppt_manager_v2/ai/llm_analyst.py new file mode 100644 index 0000000..9efd320 --- /dev/null +++ b/ppt_manager_v2/ai/llm_analyst.py @@ -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) diff --git a/ppt_manager_v2/config/project_config_v2.yaml b/ppt_manager_v2/config/project_config_v2.yaml new file mode 100644 index 0000000..bdf3da4 --- /dev/null +++ b/ppt_manager_v2/config/project_config_v2.yaml @@ -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" diff --git a/ppt_manager_v2/connectors/sql_connector.py b/ppt_manager_v2/connectors/sql_connector.py new file mode 100644 index 0000000..03ace05 --- /dev/null +++ b/ppt_manager_v2/connectors/sql_connector.py @@ -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 diff --git a/ppt_manager_v2/core/__init__.py b/ppt_manager_v2/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ppt_manager_v2/core/__pycache__/__init__.cpython-311.pyc b/ppt_manager_v2/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..fc6abc4 Binary files /dev/null and b/ppt_manager_v2/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/ppt_manager_v2/core/__pycache__/anchor_engine.cpython-311.pyc b/ppt_manager_v2/core/__pycache__/anchor_engine.cpython-311.pyc new file mode 100644 index 0000000..e3d7410 Binary files /dev/null and b/ppt_manager_v2/core/__pycache__/anchor_engine.cpython-311.pyc differ diff --git a/ppt_manager_v2/core/__pycache__/conditional_renderer.cpython-311.pyc b/ppt_manager_v2/core/__pycache__/conditional_renderer.cpython-311.pyc new file mode 100644 index 0000000..5172e4d Binary files /dev/null and b/ppt_manager_v2/core/__pycache__/conditional_renderer.cpython-311.pyc differ diff --git a/ppt_manager_v2/core/__pycache__/native_chart.cpython-311.pyc b/ppt_manager_v2/core/__pycache__/native_chart.cpython-311.pyc new file mode 100644 index 0000000..83cf34d Binary files /dev/null and b/ppt_manager_v2/core/__pycache__/native_chart.cpython-311.pyc differ diff --git a/ppt_manager_v2/core/anchor_engine.py b/ppt_manager_v2/core/anchor_engine.py new file mode 100644 index 0000000..77ba9af --- /dev/null +++ b/ppt_manager_v2/core/anchor_engine.py @@ -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 "" diff --git a/ppt_manager_v2/core/conditional_renderer.py b/ppt_manager_v2/core/conditional_renderer.py new file mode 100644 index 0000000..6d4b558 --- /dev/null +++ b/ppt_manager_v2/core/conditional_renderer.py @@ -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 diff --git a/ppt_manager_v2/core/native_chart.py b/ppt_manager_v2/core/native_chart.py new file mode 100644 index 0000000..eb69808 --- /dev/null +++ b/ppt_manager_v2/core/native_chart.py @@ -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 diff --git a/ppt_manager_v2/logs/run_20260529_105045.log b/ppt_manager_v2/logs/run_20260529_105045.log new file mode 100644 index 0000000..5208b8e --- /dev/null +++ b/ppt_manager_v2/logs/run_20260529_105045.log @@ -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) + │ │ └ {} + │ └ (, '04KQWf-21Cy15gXGAAAB', 'CF55wc3dPDdRhyZXAAAA', ['start_generation', ... + └ > + 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'}}] + │ └ + └ + 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'}}) + └ + 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' + │ │ └ + │ └ + └ + 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'}},) + └ + +> 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'} + │ └ + └ + + File "F:\ppt\ppt_manager_v2\orchestrator.py", line 238, in run_full_pipeline + self.load_template(template_path) + │ │ └ None + │ └ + └ + + File "F:\ppt\ppt_manager_v2\orchestrator.py", line 76, in load_template + slide.shapes.title.text = "GDP趋势图" if slide.shapes.title else "" + │ │ │ └ + │ │ └ + │ └ + └ + +AttributeError: 'NoneType' object has no attribute 'text' diff --git a/ppt_manager_v2/logs/run_20260529_105104.log b/ppt_manager_v2/logs/run_20260529_105104.log new file mode 100644 index 0000000..ea680e7 --- /dev/null +++ b/ppt_manager_v2/logs/run_20260529_105104.log @@ -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) + │ │ └ {} + │ └ (, '04KQWf-21Cy15gXGAAAB', 'CF55wc3dPDdRhyZXAAAA', ['start_generation', ... + └ > + 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'}}] + │ └ + └ + 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'}}) + └ + 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' + │ │ └ + │ └ + └ + 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'}},) + └ + +> 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'} + │ └ + └ + + File "F:\ppt\ppt_manager_v2\orchestrator.py", line 238, in run_full_pipeline + self.load_template(template_path) + │ │ └ None + │ └ + └ + + File "F:\ppt\ppt_manager_v2\orchestrator.py", line 76, in load_template + slide.shapes.title.text = "GDP趋势图" if slide.shapes.title else "" + │ │ │ └ + │ │ └ + │ └ + └ + +AttributeError: 'NoneType' object has no attribute 'text' diff --git a/ppt_manager_v2/logs/run_20260529_105156.log b/ppt_manager_v2/logs/run_20260529_105156.log new file mode 100644 index 0000000..a81cab4 --- /dev/null +++ b/ppt_manager_v2/logs/run_20260529_105156.log @@ -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 diff --git a/ppt_manager_v2/logs/run_20260529_105202.log b/ppt_manager_v2/logs/run_20260529_105202.log new file mode 100644 index 0000000..289e841 --- /dev/null +++ b/ppt_manager_v2/logs/run_20260529_105202.log @@ -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 - ================================================== diff --git a/ppt_manager_v2/logs/run_20260529_105938.log b/ppt_manager_v2/logs/run_20260529_105938.log new file mode 100644 index 0000000..7fb609e --- /dev/null +++ b/ppt_manager_v2/logs/run_20260529_105938.log @@ -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 - ================================================== diff --git a/ppt_manager_v2/logs/run_20260529_110321.log b/ppt_manager_v2/logs/run_20260529_110321.log new file mode 100644 index 0000000..db097b8 --- /dev/null +++ b/ppt_manager_v2/logs/run_20260529_110321.log @@ -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 - ================================================== diff --git a/ppt_manager_v2/logs/run_20260529_110441.log b/ppt_manager_v2/logs/run_20260529_110441.log new file mode 100644 index 0000000..012e1bb --- /dev/null +++ b/ppt_manager_v2/logs/run_20260529_110441.log @@ -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 diff --git a/ppt_manager_v2/logs/run_20260529_111029.log b/ppt_manager_v2/logs/run_20260529_111029.log new file mode 100644 index 0000000..fbca313 --- /dev/null +++ b/ppt_manager_v2/logs/run_20260529_111029.log @@ -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 diff --git a/ppt_manager_v2/orchestrator.py b/ppt_manager_v2/orchestrator.py new file mode 100644 index 0000000..35cdcb5 --- /dev/null +++ b/ppt_manager_v2/orchestrator.py @@ -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() diff --git a/ppt_manager_v2/plugins/__pycache__/base_generator.cpython-311.pyc b/ppt_manager_v2/plugins/__pycache__/base_generator.cpython-311.pyc new file mode 100644 index 0000000..1bba32c Binary files /dev/null and b/ppt_manager_v2/plugins/__pycache__/base_generator.cpython-311.pyc differ diff --git a/ppt_manager_v2/plugins/base_generator.py b/ppt_manager_v2/plugins/base_generator.py new file mode 100644 index 0000000..f496d7a --- /dev/null +++ b/ppt_manager_v2/plugins/base_generator.py @@ -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 diff --git a/ppt_manager_v2/plugins/generators/__init__.py b/ppt_manager_v2/plugins/generators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ppt_manager_v2/plugins/generators/__pycache__/cpi_generator.cpython-311.pyc b/ppt_manager_v2/plugins/generators/__pycache__/cpi_generator.cpython-311.pyc new file mode 100644 index 0000000..7d8ec46 Binary files /dev/null and b/ppt_manager_v2/plugins/generators/__pycache__/cpi_generator.cpython-311.pyc differ diff --git a/ppt_manager_v2/plugins/generators/__pycache__/gdp_generator.cpython-311.pyc b/ppt_manager_v2/plugins/generators/__pycache__/gdp_generator.cpython-311.pyc new file mode 100644 index 0000000..75b3bbe Binary files /dev/null and b/ppt_manager_v2/plugins/generators/__pycache__/gdp_generator.cpython-311.pyc differ diff --git a/ppt_manager_v2/plugins/generators/cpi_generator.py b/ppt_manager_v2/plugins/generators/cpi_generator.py new file mode 100644 index 0000000..ba73613 --- /dev/null +++ b/ppt_manager_v2/plugins/generators/cpi_generator.py @@ -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走势' + } diff --git a/ppt_manager_v2/plugins/generators/gdp_generator.py b/ppt_manager_v2/plugins/generators/gdp_generator.py new file mode 100644 index 0000000..081ab19 --- /dev/null +++ b/ppt_manager_v2/plugins/generators/gdp_generator.py @@ -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增长趋势' + } diff --git a/ppt_manager_v2/requirements_v2.txt b/ppt_manager_v2/requirements_v2.txt new file mode 100644 index 0000000..ae88488 --- /dev/null +++ b/ppt_manager_v2/requirements_v2.txt @@ -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 diff --git a/ppt_manager_v2/templates/index_v2.html b/ppt_manager_v2/templates/index_v2.html new file mode 100644 index 0000000..0e0d3ef --- /dev/null +++ b/ppt_manager_v2/templates/index_v2.html @@ -0,0 +1,201 @@ + + + + + + PPT智能管理系统 V2.0 + + + + + +
+
+

+ PPT智能管理系统 v2.0 +

+

基于锚点定位 | 原生图表更新 | 插件化架构 | WebSocket实时推送

+
+ +
+
+
+

+ + 参数配置 +

+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+

+ + 实时进度 +

+ +
+
+
+ +
等待开始...
+ +
+
+ LOG OUTPUT (实时推送) +
+
+
// 系统已就绪,等待开始
+
+
+
+
+ +
+
+

+ 📦 + 已加载插件 +

+
+
加载中...
+
+
+ +
+

+ 📋 + 已生成文件 +

+
+
暂无
+
+
+ +
+

✨ 核心增强

+
    +
  • • 锚点(Anchor)定位 - 无惧页码变动
  • +
  • • 原生图表更新 - 可编辑/保持主题动画
  • +
  • • 插件化架构 - 自动扫描注册即插即用
  • +
  • • 条件渲染 - 规则判断动态增删页
  • +
  • • LLM洞察 - 数据→分析结论自动生成
  • +
  • • WebSocket - 进度+日志实时推送
  • +
+
+
+
+
+ + + + diff --git a/ppt_manager_v2/web/templates/index_v2.html b/ppt_manager_v2/web/templates/index_v2.html new file mode 100644 index 0000000..0e0d3ef --- /dev/null +++ b/ppt_manager_v2/web/templates/index_v2.html @@ -0,0 +1,201 @@ + + + + + + PPT智能管理系统 V2.0 + + + + + +
+
+

+ PPT智能管理系统 v2.0 +

+

基于锚点定位 | 原生图表更新 | 插件化架构 | WebSocket实时推送

+
+ +
+
+
+

+ + 参数配置 +

+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+

+ + 实时进度 +

+ +
+
+
+ +
等待开始...
+ +
+
+ LOG OUTPUT (实时推送) +
+
+
// 系统已就绪,等待开始
+
+
+
+
+ +
+
+

+ 📦 + 已加载插件 +

+
+
加载中...
+
+
+ +
+

+ 📋 + 已生成文件 +

+
+
暂无
+
+
+ +
+

✨ 核心增强

+
    +
  • • 锚点(Anchor)定位 - 无惧页码变动
  • +
  • • 原生图表更新 - 可编辑/保持主题动画
  • +
  • • 插件化架构 - 自动扫描注册即插即用
  • +
  • • 条件渲染 - 规则判断动态增删页
  • +
  • • LLM洞察 - 数据→分析结论自动生成
  • +
  • • WebSocket - 进度+日志实时推送
  • +
+
+
+
+
+ + + + diff --git a/ppt_manager_v2/web_socket_app.py b/ppt_manager_v2/web_socket_app.py new file mode 100644 index 0000000..0260de6 --- /dev/null +++ b/ppt_manager_v2/web_socket_app.py @@ -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/') +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()