feat: 四层架构全面增强
安全与稳定性: - 移除硬编码 API Key,改用 .env + 环境变量 - LLM 调用统一重试机制(指数退避,3 次重试,处理 429/5xx/超时) - 中文字体检测增强(CJK 关键词兜底 + 无字体时英文 fallback) - 缺失 API Key 给出友好提示而非崩溃 分析能力提升: - 异常检测新增 z-score 检测(标准差>2 标记异常) - 新增变异系数 CV 检测(数据波动性) - 新增零值/缺失检测 - 上下文管理器升级为关键词语义匹配(替代简单取最近 2 条) 用户体验: - 报告自动保存为 Markdown(reports/ 目录) - 新增 export 命令导出查询结果为 CSV - 新增 reports 命令查看已保存报告 - CLI 支持 readline 命令历史(方向键翻阅) - CSV 导入工具重写:自动列名映射、容错处理、dry-run 模式 - 新增 .env.example 配置模板
This commit is contained in:
61
agent.py
61
agent.py
@@ -9,10 +9,11 @@ Layer 4: Context 上下文记忆
|
||||
Output: Reporter + Chart + Consolidator
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from core.config import DB_PATH, MAX_EXPLORATION_ROUNDS, PLAYBOOK_DIR, CHARTS_DIR
|
||||
from core.config import DB_PATH, MAX_EXPLORATION_ROUNDS, PLAYBOOK_DIR, CHARTS_DIR, PROJECT_ROOT
|
||||
from core.schema import extract_schema, schema_to_text
|
||||
from core.sandbox import SandboxExecutor
|
||||
from layers.planner import Planner
|
||||
@@ -47,6 +48,10 @@ class DataAnalysisAgent:
|
||||
# 累积图表
|
||||
self._all_charts: list[dict] = []
|
||||
|
||||
# 报告输出目录
|
||||
self.reports_dir = os.path.join(PROJECT_ROOT, "reports")
|
||||
os.makedirs(self.reports_dir, exist_ok=True)
|
||||
|
||||
# 自动生成 Playbook
|
||||
if not self.playbook_mgr.playbooks:
|
||||
print("\n🤖 [Playbook] 未发现预设剧本,AI 自动生成中...")
|
||||
@@ -145,6 +150,9 @@ class DataAnalysisAgent:
|
||||
# Layer 4: 记录上下文
|
||||
self.context.add_session(question=question, plan=plan, steps=steps, insights=insights, report=report)
|
||||
|
||||
# 自动保存报告
|
||||
self.save_report(report, question, charts=charts)
|
||||
|
||||
return report
|
||||
|
||||
def full_report(self, question: str = "") -> str:
|
||||
@@ -173,3 +181,54 @@ class DataAnalysisAgent:
|
||||
def close(self):
|
||||
"""释放资源"""
|
||||
self.executor.close()
|
||||
|
||||
def save_report(self, report: str, question: str, charts: list[dict] | None = None) -> str:
|
||||
"""将报告保存为 Markdown 文件,返回文件路径"""
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
# 取问题前 20 字作为文件名
|
||||
import re
|
||||
safe_q = re.sub(r'[^\w\u4e00-\u9fff]', '_', question)[:20].strip('_')
|
||||
fname = f"{ts}_{safe_q}.md"
|
||||
fpath = os.path.join(self.reports_dir, fname)
|
||||
|
||||
with open(fpath, "w", encoding="utf-8") as f:
|
||||
f.write(f"# 分析报告: {question}\n\n")
|
||||
f.write(f"_生成时间: {time.strftime('%Y-%m-%d %H:%M:%S')}_\n\n")
|
||||
f.write(report)
|
||||
if charts:
|
||||
f.write("\n\n---\n\n## 📊 图表索引\n\n")
|
||||
for c in charts:
|
||||
f.write(f"### {c['title']}\n![{c['title']}]({os.path.abspath(c['path'])})\n\n")
|
||||
|
||||
print(f" 💾 报告已保存: {fpath}")
|
||||
return fpath
|
||||
|
||||
def export_data(self, steps: list, format: str = "csv") -> str | None:
|
||||
"""导出探索结果为 CSV"""
|
||||
import csv
|
||||
import io
|
||||
|
||||
all_rows = []
|
||||
all_cols = set()
|
||||
for step in steps:
|
||||
if step.success and step.rows:
|
||||
for row in step.rows:
|
||||
row["_query"] = step.purpose
|
||||
all_rows.append(row)
|
||||
all_cols.update(row.keys())
|
||||
|
||||
if not all_rows:
|
||||
return None
|
||||
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
fname = f"export_{ts}.csv"
|
||||
fpath = os.path.join(self.reports_dir, fname)
|
||||
|
||||
cols = sorted(all_cols)
|
||||
with open(fpath, "w", encoding="utf-8-sig", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=cols)
|
||||
writer.writeheader()
|
||||
writer.writerows(all_rows)
|
||||
|
||||
print(f" 📁 数据已导出: {fpath} ({len(all_rows)} 行)")
|
||||
return fpath
|
||||
|
||||
Reference in New Issue
Block a user