更新readme文档

This commit is contained in:
2026-01-09 16:52:45 +08:00
parent e51cdfea6f
commit b1d0cc5462
22 changed files with 1871 additions and 174 deletions

View File

@@ -1,7 +1,7 @@
# 火山引擎配置 # 火山引擎配置
OPENAI_API_KEY=sk-c44i1hy64xgzwox6x08o4zug93frq6rgn84oqugf2pje1tg4 OPENAI_API_KEY=sk-c44i1hy64xgzwox6x08o4zug93frq6rgn84oqugf2pje1tg4
OPENAI_BASE_URL=https://api.xiaomimimo.com/v1/chat/completions OPENAI_BASE_URL=https://api.xiaomimimo.com/v1
# 文本模型 # 文本模型
OPENAI_MODEL=mimo-v2-flash OPENAI_MODEL=mimo-v2-flash
# OPENAI_MODEL=deepseek-r1-250528 # OPENAI_MODEL=deepseek-r1-250528

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Data Analysis Agent Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -160,7 +160,7 @@ agent = DataAnalysisAgent(llm_config)
# 开始分析 # 开始分析
files = ["your_data.csv"] files = ["your_data.csv"]
report = agent.analyze( report = agent.analyze(
user_input="分析销售数据,生成趋势图表和关键指标", user_input="分析XXXXXXXXX数据,生成趋势图表和关键指标",
files=files files=files
) )
@@ -191,9 +191,9 @@ report = quick_analysis(
```python ```python
# 示例:茅台财务分析 # 示例:茅台财务分析
files = ["贵州茅台利润表.csv"] files = ["XXXXXXXXx.csv"]
report = agent.analyze( report = agent.analyze(
user_input="基于贵州茅台的数据,输出五个重要的统计指标,并绘制相关图表。最后生成汇报给我。", user_input="基于数据,输出五个重要的统计指标,并绘制相关图表。最后生成汇报给我。",
files=files files=files
) )
``` ```

62
bootstrap.py Normal file
View File

@@ -0,0 +1,62 @@
import sys
import subprocess
import importlib.metadata
import os
def check_dependencies():
"""Checks if dependencies in requirements.txt are installed."""
requirements_file = "requirements.txt"
if not os.path.exists(requirements_file):
print(f"Warning: {requirements_file} not found. Skipping dependency check.")
return
print("Checking dependencies...")
missing_packages = []
with open(requirements_file, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
# Simple parsing for package name.
# This handles 'package>=version', 'package==version', 'package'
# It does NOT handle complex markers perfectly, but suffices for basic checking.
package_name = line.split("=")[0].split(">")[0].split("<")[0].strip()
try:
importlib.metadata.version(package_name)
except importlib.metadata.PackageNotFoundError:
missing_packages.append(line)
if missing_packages:
print(f"Missing dependencies: {', '.join(missing_packages)}")
print("Installing missing dependencies...")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", requirements_file])
print("Dependencies installed successfully.")
except subprocess.CalledProcessError as e:
print(f"Error installing dependencies: {e}")
sys.exit(1)
else:
print("All dependencies checked.")
def main():
check_dependencies()
print("Starting application...")
try:
# Run the main application
# Using sys.executable ensures we use the same python interpreter
subprocess.run([sys.executable, "main.py"], check=True)
except subprocess.CalledProcessError as e:
print(f"Application exited with error: {e}")
sys.exit(e.returncode)
except KeyboardInterrupt:
print("\nApplication stopped by user.")
except Exception as e:
print(f"An unexpected error occurred: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -17,8 +17,8 @@ load_dotenv()
class LLMConfig: class LLMConfig:
"""LLM配置""" """LLM配置"""
provider: str = os.environ.get("LLM_PROVIDER", "gemini") # openai, gemini, etc. provider: str = os.environ.get("LLM_PROVIDER", "openai") # openai, gemini, etc.
api_key: str = os.environ.get("OPENAI_API_KEY", "sk---c44i1hy64xgzwox6x08o4zug93frq6rgn84oqugf2pje1tg4") api_key: str = os.environ.get("OPENAI_API_KEY", "sk-c44i1hy64xgzwox6x08o4zug93frq6rgn84oqugf2pje1tg4")
base_url: str = os.environ.get("OPENAI_BASE_URL", "https://api.xiaomimimo.com/v1") base_url: str = os.environ.get("OPENAI_BASE_URL", "https://api.xiaomimimo.com/v1")
model: str = os.environ.get("OPENAI_MODEL", "mimo-v2-flash") model: str = os.environ.get("OPENAI_MODEL", "mimo-v2-flash")
temperature: float = 0.5 temperature: float = 0.5

View File

@@ -136,19 +136,15 @@ class DataAnalysisAgent:
print(f" 🔍 分析: {analysis}") print(f" 🔍 分析: {analysis}")
# 记录图片信息 # 使用seen_paths集合来去重防止重复收集
collected_figures.append( seen_paths = set()
{
"figure_number": figure_number,
"filename": filename,
"file_path": file_path,
"description": description,
"analysis": analysis,
}
)
# 验证文件是否存在 # 验证文件是否存在
# 只有文件真正存在时才加入列表,防止报告出现裂图 # 只有文件真正存在时才加入列表,防止报告出现裂图
if file_path and os.path.exists(file_path): if file_path and os.path.exists(file_path):
# 检查是否已经收集过该路径
abs_path = os.path.abspath(file_path)
if abs_path not in seen_paths:
print(f" ✅ 文件存在: {file_path}") print(f" ✅ 文件存在: {file_path}")
# 记录图片信息 # 记录图片信息
collected_figures.append( collected_figures.append(
@@ -160,6 +156,9 @@ class DataAnalysisAgent:
"analysis": analysis, "analysis": analysis,
} }
) )
seen_paths.add(abs_path)
else:
print(f" ⚠️ 跳过重复图片: {file_path}")
else: else:
if file_path: if file_path:
print(f" ⚠️ 文件不存在: {file_path}") print(f" ⚠️ 文件不存在: {file_path}")
@@ -224,7 +223,7 @@ class DataAnalysisAgent:
"continue": True, "continue": True,
} }
def analyze(self, user_input: str, files: List[str] = None, session_output_dir: str = None) -> Dict[str, Any]: def analyze(self, user_input: str, files: List[str] = None, session_output_dir: str = None, reset_session: bool = True, max_rounds: int = None) -> Dict[str, Any]:
""" """
开始分析流程 开始分析流程
@@ -232,11 +231,18 @@ class DataAnalysisAgent:
user_input: 用户的自然语言需求 user_input: 用户的自然语言需求
files: 数据文件路径列表 files: 数据文件路径列表
session_output_dir: 指定的会话输出目录(可选) session_output_dir: 指定的会话输出目录(可选)
reset_session: 是否重置会话 (True: 新开启分析; False: 在现有上下文中继续)
max_rounds: 本次分析的最大轮数 (可选,如果不填则使用默认值)
Returns: Returns:
分析结果字典 分析结果字典
""" """
# 重置状态
# 确定本次运行的轮数限制
current_max_rounds = max_rounds if max_rounds is not None else self.max_rounds
if reset_session:
# --- 初始化新会话 ---
self.conversation_history = [] self.conversation_history = []
self.analysis_results = [] self.analysis_results = []
self.current_round = 0 self.current_round = 0
@@ -249,7 +255,6 @@ class DataAnalysisAgent:
self.base_output_dir, user_input self.base_output_dir, user_input
) )
# 初始化代码执行器,使用会话目录 # 初始化代码执行器,使用会话目录
self.executor = CodeExecutor(self.session_output_dir) self.executor = CodeExecutor(self.session_output_dir)
@@ -260,8 +265,11 @@ class DataAnalysisAgent:
data_profile = "" data_profile = ""
if files: if files:
print("🔍 正在生成数据画像...") print("🔍 正在生成数据画像...")
try:
data_profile = load_and_profile_data(files) data_profile = load_and_profile_data(files)
print("✅ 数据画像生成完毕") print("✅ 数据画像生成完毕")
except Exception as e:
print(f"⚠️ 数据画像生成失败: {e}")
# 保存到实例变量供最终报告使用 # 保存到实例变量供最终报告使用
self.data_profile = data_profile self.data_profile = data_profile
@@ -279,13 +287,36 @@ class DataAnalysisAgent:
if files: if files:
print(f"📁 数据文件: {', '.join(files)}") print(f"📁 数据文件: {', '.join(files)}")
print(f"📂 输出目录: {self.session_output_dir}") print(f"📂 输出目录: {self.session_output_dir}")
print(f"🔢 最大轮数: {self.max_rounds}")
if self.force_max_rounds:
print(f"⚡ 强制模式: 将运行满 {self.max_rounds}忽略AI完成信号")
print("=" * 60)
# 添加到对话历史 # 添加到对话历史
self.conversation_history.append({"role": "user", "content": initial_prompt}) self.conversation_history.append({"role": "user", "content": initial_prompt})
else:
# --- 继续现有会话 ---
# 如果是追问,且没有指定轮数,默认减少轮数,避免过度分析
if max_rounds is None:
current_max_rounds = 10 # 追问通常不需要那么长的思考链10轮足够
print(f"\n🚀 继续分析任务 (追问模式)")
print(f"📝 后续需求: {user_input}")
# 重置当前轮数计数器,以便给新任务足够的轮次
self.current_round = 0
# 添加到对话历史
# 提示Agent这是后续追问可以简化步骤
follow_up_prompt = f"后续需求: {user_input}\n(注意这是后续追问请直接针对该问题进行分析无需从头开始执行完整SOP。)"
self.conversation_history.append({"role": "user", "content": follow_up_prompt})
print(f"🔢 本次最大轮数: {current_max_rounds}")
if self.force_max_rounds:
print(f"⚡ 强制模式: 将运行满 {current_max_rounds}忽略AI完成信号")
print("=" * 60)
# 保存原始 max_rounds 以便恢复(虽然 analyze 结束后不需要恢复,但为了逻辑严谨)
original_max_rounds = self.max_rounds
self.max_rounds = current_max_rounds
while self.current_round < self.max_rounds: while self.current_round < self.max_rounds:
self.current_round += 1 self.current_round += 1
print(f"\n🔄 第 {self.current_round} 轮分析") print(f"\n🔄 第 {self.current_round} 轮分析")
@@ -311,6 +342,15 @@ class DataAnalysisAgent:
process_result = self._process_response(response) process_result = self._process_response(response)
# 根据处理结果决定是否继续(仅在非强制模式下) # 根据处理结果决定是否继续(仅在非强制模式下)
if process_result.get("action") == "invalid_response":
consecutive_failures += 1
print(f"⚠️ 连续失败次数: {consecutive_failures}/3")
if consecutive_failures >= 3:
print(f"❌ 连续3次无法获取有效响应分析终止。请检查网络或配置。")
break
else:
consecutive_failures = 0 # 重置计数器
if not self.force_max_rounds and not process_result.get( if not self.force_max_rounds and not process_result.get(
"continue", True "continue", True
): ):
@@ -406,6 +446,35 @@ class DataAnalysisAgent:
print(f"\n📊 开始生成最终分析报告...") print(f"\n📊 开始生成最终分析报告...")
print(f"📂 输出目录: {self.session_output_dir}") print(f"📂 输出目录: {self.session_output_dir}")
# --- 自动补全/发现图片机制 ---
# 扫描目录下所有的png文件
try:
import glob
existing_pngs = glob.glob(os.path.join(self.session_output_dir, "*.png"))
# 获取已收集的图片路径集合
collected_paths = set()
for fig in all_figures:
if fig.get("file_path"):
collected_paths.add(os.path.abspath(fig.get("file_path")))
# 检查是否有漏网之鱼
for png_path in existing_pngs:
abs_png_path = os.path.abspath(png_path)
if abs_png_path not in collected_paths:
print(f"🔍 [自动发现] 补充未显式收集的图片: {os.path.basename(png_path)}")
all_figures.append({
"figure_number": "Auto",
"filename": os.path.basename(png_path),
"file_path": abs_png_path,
"description": f"自动发现的分析图表: {os.path.basename(png_path)}",
"analysis": "该图表由系统自动捕获Agent未提供具体分析文本请结合图表标题理解"
})
except Exception as e:
print(f"⚠️ 自动发现图片失败: {e}")
# ---------------------------
print(f"🔢 总轮数: {self.current_round}") print(f"🔢 总轮数: {self.current_round}")
print(f"📈 收集图片: {len(all_figures)}") print(f"📈 收集图片: {len(all_figures)}")
@@ -419,28 +488,19 @@ class DataAnalysisAgent:
max_tokens=16384, # 设置较大的token限制以容纳完整报告 max_tokens=16384, # 设置较大的token限制以容纳完整报告
) )
# 解析响应,提取最终报告 # 直接使用LLM响应作为最终报告因为我们在prompt中要求直接输出Markdown
try: final_report_content = response
# 尝试解析YAML
yaml_data = self.llm.parse_yaml_response(response)
# 情况1: 标准YAML格式包含 action: analysis_complete # 兼容旧逻辑如果意外返回了YAML尝试解析
if response.strip().startswith("action:") or "final_report:" in response:
try:
yaml_data = self.llm.parse_yaml_response(response)
if yaml_data.get("action") == "analysis_complete": if yaml_data.get("action") == "analysis_complete":
final_report_content = yaml_data.get("final_report", response) final_report_content = yaml_data.get("final_report", response)
except:
pass # 解析失败则保持原样
# 情况2: 解析成功但没字段,或者解析失败 print("✅ 最终报告生成完成")
else:
# 如果内容看起来像Markdown报告包含标题直接使用
if "# " in response or "## " in response:
print("⚠️ 未检测到标准YAML动作但内容疑似Markdown报告直接采纳")
final_report_content = response
else:
final_report_content = "LLM未返回有效报告内容"
except Exception as e:
# 解析完全失败,直接使用原始响应
print(f"⚠️ YAML解析失败 ({e}),直接使用原始响应作为报告")
final_report_content = response
print("✅ 最终报告生成完成") print("✅ 最终报告生成完成")

49
main.py
View File

@@ -39,11 +39,29 @@ def setup_logging(log_dir):
def main(): def main():
llm_config = LLMConfig() llm_config = LLMConfig()
import glob
import os
# 自动查找当前目录及remotecontrol目录下的所有数据文件
data_extensions = ['*.csv', '*.xlsx', '*.xls']
search_dirs = ['jetour']
files = []
for search_dir in search_dirs:
for ext in data_extensions:
pattern = os.path.join(search_dir, ext)
files.extend(glob.glob(pattern))
if not files:
print("⚠️ 未在当前目录找到数据文件 (.csv, .xlsx),尝试使用默认文件")
files = ["./cleaned_data.csv"] files = ["./cleaned_data.csv"]
else:
print(f"📂 自动识别到以下数据文件: {files}")
analysis_requirement = """ analysis_requirement = """
基于所有运维工单,整理一份工单健康度报告,包括但不限于对所有车联网技术支持工单的全面数据分析, 基于所有运维工单,整理一份工单健康度报告,包括但不限于对所有车联网技术支持工单的全面数据分析,
深入挖掘工单处理过程中的关键问题、效率瓶颈及改进机会。涵盖工单状态、问题类型、模块分布、严重程度、责任人负载、车型分布、来源渠道及处理时长等多个维度。 深入挖掘工单处理过程中的关键问题、效率瓶颈及改进机会。请从车型,模块,功能角度,分别展示工单数据、问题类型、模块分布、严重程度、责任人负载、车型分布、来源渠道及处理时长等多个维度。
通过多轮交叉分析与趋势洞察,为提升车联网服务质量、优化资源配置及降低运营风险提供数据驱动的决策依据,问题总揽,高频问题、重点问题分析,输出若干个重要的统计指标,并绘制相关图表;结合图表,总结一份,车联网运维工单健康度报告,汇报给我。 通过多轮交叉分析与趋势洞察,为提升车联网服务质量、优化资源配置及降低运营风险提供数据驱动的决策依据,问题总揽,高频问题、重点问题分析,输出若干个重要的统计指标,并绘制相关图表;
结合图表,总结一份,车联网运维工单健康度报告,汇报给我。
""" """
# 在主函数中先创建会话目录,以便存放日志 # 在主函数中先创建会话目录,以便存放日志
@@ -57,12 +75,33 @@ def main():
# 如果希望强制运行到最大轮数,设置 force_max_rounds=True # 如果希望强制运行到最大轮数,设置 force_max_rounds=True
agent = DataAnalysisAgent(llm_config, force_max_rounds=False) agent = DataAnalysisAgent(llm_config, force_max_rounds=False)
# --- 交互式分析循环 ---
while True:
# 执行分析
# 首次运行时 reset_session=True (默认)
# 后续运行时 reset_session=False
is_first_run = (agent.current_round == 0 and not agent.conversation_history)
report = agent.analyze( report = agent.analyze(
user_input=analysis_requirement, user_input=analysis_requirement,
files=files, files=files if is_first_run else None, # 后续轮次不需要重复传文件路径agent已有上下文
session_output_dir=session_output_dir session_output_dir=session_output_dir,
reset_session=is_first_run,
max_rounds=None if is_first_run else 10 # 追问时限制为10轮
) )
print(report) print("\n" + "="*30 + " 当前阶段分析完成 " + "="*30)
# 询问用户是否继续
print("\n💡 你可以继续对数据提出分析需求,或者输入 'exit'/'quit' 结束程序。")
user_response = input("👉 请输入后续分析需求 (直接回车退出): ").strip()
if not user_response or user_response.lower() in ['exit', 'quit', 'n', 'no']:
print("👋 分析结束,再见!")
break
# 更新需求,进入下一轮循环
analysis_requirement = user_response
print(f"\n🔄 收到新需求,正在继续分析...")
if __name__ == "__main__": if __name__ == "__main__":

83
merge_excel.py Normal file
View File

@@ -0,0 +1,83 @@
import pandas as pd
import glob
import os
def merge_excel_files(source_dir="remotecontrol", output_file="merged_all_files.csv"):
"""
将指定目录下的所有 Excel 文件 (.xlsx, .xls) 合并为一个 CSV 文件。
"""
print(f"🔍 正在扫描目录: {source_dir} ...")
# 支持 xlsx 和 xls
files_xlsx = glob.glob(os.path.join(source_dir, "*.xlsx"))
files_xls = glob.glob(os.path.join(source_dir, "*.xls"))
files = files_xlsx + files_xls
if not files:
print("⚠️ 未找到 Excel 文件。")
return
# 按文件名中的数字进行排序 (例如: 1.xlsx, 2.xlsx, ..., 10.xlsx)
try:
files.sort(key=lambda x: int(os.path.basename(x).split('.')[0]))
print("🔢 已按文件名数字顺序排序")
except ValueError:
# 如果文件名不是纯数字,退回到字母排序
files.sort()
print("🔤 已按文件名包含非数字字符,使用字母顺序排序")
print(f"📂 找到 {len(files)} 个文件: {files}")
all_dfs = []
for file in files:
try:
print(f"📖 读取: {file}")
# 使用 ExcelFile 读取所有 sheet
xls = pd.ExcelFile(file)
print(f" 📑 包含 Sheets: {xls.sheet_names}")
file_dfs = []
for sheet_name in xls.sheet_names:
df = pd.read_excel(xls, sheet_name=sheet_name)
if not df.empty:
print(f" ✅ Sheet '{sheet_name}' 读取成功: {len(df)}")
file_dfs.append(df)
else:
print(f" ⚠️ Sheet '{sheet_name}' 为空,跳过")
if file_dfs:
# 合并该文件的所有非空 sheet
file_merged_df = pd.concat(file_dfs, ignore_index=True)
# 可选:添加一列标记来源文件
file_merged_df['Source_File'] = os.path.basename(file)
all_dfs.append(file_merged_df)
else:
print(f"⚠️ 文件 {file} 所有 Sheet 均为空")
except Exception as e:
print(f"❌ 读取 {file} 失败: {e}")
if all_dfs:
print("🔄 正在合并数据...")
merged_df = pd.concat(all_dfs, ignore_index=True)
# 按 SendTime 排序
if 'SendTime' in merged_df.columns:
print("⏳ 正在按 SendTime 排序...")
merged_df['SendTime'] = pd.to_datetime(merged_df['SendTime'], errors='coerce')
merged_df = merged_df.sort_values(by='SendTime')
else:
print("⚠️ 未找到 SendTime 列,跳过排序")
print(f"💾 保存到: {output_file}")
merged_df.to_csv(output_file, index=False, encoding="utf-8-sig")
print(f"✅ 合并及排序完成!总行数: {len(merged_df)}")
print(f" 输出文件: {os.path.abspath(output_file)}")
else:
print("⚠️ 没有成功读取到任何数据。")
if __name__ == "__main__":
# 如果需要在当前目录运行并合并 remotecontrol 文件夹下的内容
merge_excel_files(source_dir="remotecontrol", output_file="remotecontrol_merged.csv")

View File

@@ -28,7 +28,21 @@ data_analysis_system_prompt = """你是一个专业的数据分析助手,运
2. 图片必须保存到指定的会话目录中输出绝对路径禁止使用plt.show(),饼图的标签全部放在图例里面,用颜色区分。 2. 图片必须保存到指定的会话目录中输出绝对路径禁止使用plt.show(),饼图的标签全部放在图例里面,用颜色区分。
3. 表格输出控制超过15行只显示前5行和后5行 3. 表格输出控制超过15行只显示前5行和后5行
4. 所有生成的图片必须保存保存路径格式os.path.join(session_output_dir, '图片名称.png') 4. 所有生成的图片必须保存保存路径格式os.path.join(session_output_dir, '图片名称.png')
5. 中文字体设置:生成的绘图代码,涉及中文字体必须保证生成图片不可以乱码macOS推荐Hiragino Sans GB, Songti SC等 5. 中文字体设置:生成的绘图代码,必须在开头加入以下代码以解决中文乱码问题:
```python
import matplotlib.pyplot as plt
import platform
system_name = platform.system()
if system_name == 'Darwin': # macOS
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'PingFang SC', 'Heiti SC', 'sans-serif']
elif system_name == 'Windows':
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'sans-serif']
else: # Linux
plt.rcParams['font.sans-serif'] = ['WenQuanYi Micro Hei', 'SimHei', 'sans-serif']
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
```
6. 输出格式严格使用YAML 6. 输出格式严格使用YAML
📁 输出目录管理: 📁 输出目录管理:
@@ -39,53 +53,64 @@ data_analysis_system_prompt = """你是一个专业的数据分析助手,运
- 所有生成的图片必须执行处理图片收集动作并保存保存路径格式os.path.join(session_output_dir, '图片名称.png') - 所有生成的图片必须执行处理图片收集动作并保存保存路径格式os.path.join(session_output_dir, '图片名称.png')
- 输出绝对路径使用os.path.abspath()获取图片的完整路径 - 输出绝对路径使用os.path.abspath()获取图片的完整路径
🚨 **关键红线 (Critical Rules)**
1. **图片保存铁律**:每次 `plt.plot()` 后**必须**紧接着调用 `plt.savefig()` 和 `plt.close()`。虽然系统有自动补救机制,但你必须显式保存每一张图。
2. **绝对禁止伪造数据**:无论遇到何种报错,绝对不可以使用 `pd.DataFrame({{...}})` 手动创建虚假数据来展示。如果无法读取数据,必须诚实报告错误并停止分析。
3. **文件存在性验证**:在读取前必须使用 `os.path.exists()` 检查文件是否存在。
4. **扩展名陷阱**:如果用户说是 `.xlsx` 但读取失败,请检查目录下是否有同名的 `.csv` 文件。
📊 数据分析工作流程(必须严格按顺序执行): 📊 数据分析工作流程(必须严格按顺序执行):
**阶段1数据探索使用 generate_code 动作)** **阶段1数据探索与智能加载(使用 generate_code 动作)**
- 首次数据加载时尝试多种编码:['utf-8', 'gbk', 'gb18030', 'gb2312', 'latin1'] - **Excel文件深度加载策略**
- 特殊处理:如果读取失败,尝试指定分隔符 `sep=','` 和错误处理 `on_bad_lines='skip'` (pandas 2.0+标准) - 首选:`pd.read_excel(file, engine='openpyxl')`
- 使用df.head()查看前几行数据,检查数据是否正确读取 - 失败B计划尝试 `pd.read_excel(file, engine='openpyxl', read_only=True, data_only=True)`
- 使用df.info()了解数据类型和缺失值情况 - 失败C计划针对扩展名错误但实际是CSV的文件`pd.read_csv(file)`
- 重点检查如果数值列显示为NaN但应该有值说明读取或解析有问题 - 失败D计划针对超大文件或格式异常使用 `zipfile` + `xml.etree` 手动解析 `sharedStrings.xml` 和 `sheet1.xml` (参考之前的成功案例)
- 使用df.dtypes查看每列的数据类型确保日期列不是float64 - **CSV/文本文件策略**:尝试多种编码 `['utf-8', 'gbk', 'gb18030', 'latin1']` 和分隔符 `sep=','` 或 `sep='\t'`
- 打印所有列名df.columns.tolist() - **数据验证**
- 绝对不要假设列名,必须先查看实际的列名 - 使用df.head()查看前几行
- 使用df.info()检查数据类型和缺失值
- 打印列名:`print(df.columns.tolist())`
**阶段2数据清洗和检查使用 generate_code 动作)** **阶段2数据清洗和检查使用 generate_code 动作)**
- 日期列识别:查找包含'date', 'time', 'Date', 'Time'关键词的列 - 日期列识别与标准化:查找 'date', 'time', '创建', '关闭' 等列,统一转为 datetime格式。
- 日期解析:尝试多种格式 ['%d/%m/%Y', '%Y-%m-%d', '%m/%d/%Y', '%Y/%m/%d', '%d-%m-%Y'] - 关键字段对齐:将 'Model', 'Car Model', '车型' 统一重命名为 '车型''Module', '模块' 统一重命名为 '模块'
- 类型转换使用pd.to_datetime()转换日期列指定format参数和errors='coerce' - 缺失值与异常值标记:统计关键维度(车型、模块)的缺失率。
- 空值处理检查哪些列应该有值但显示NaN可能是数据读取问题 - **多文件数据合并**:如果识别到 source_file 列,确保按文件顺序或时间列进行排序。
- 检查数据的时间范围和排序
- 数据质量检查:确认数值列是否正确,字符串列是否被错误识别
**阶段3多维度业务分析和可视化使用 generate_code 动作)**
- **必须覆盖的分析维度基于用户IOV业务需求**
1. **车型维度 (Vehicle Model)**:各车型工单量分布、车型-问题类型热力图。
2. **模块/功能维度 (Module/Function)**:故障高发模块/功能 Top10、模块-严重程度交叉分析。
3. **问题类型维度 (Issue Type)**:各类问题占比、各类问题的平均处理时长。
4. **严重程度分布 (Severity)**:严重/一般问题的比重及趋势。
5. **责任人负载 (Owner Load)**:责任人处理工单数量 Top10 vs 平均处理时长(效率分析)。
6. **来源渠道 (Source)**不同来源电话、APP、后台的工单分布及有效率。
7. **处理时长 (Duration)**:处理时长分布(直方图)、超长工单特征分析。
8. **文本挖掘 (Text Mining)**:基于 '问题描述' 的 N-gram 短语提取(如 "TBOX离线", "远程启动失败"),排除停用词。
- **图表生成规则**
- 每一轮只专注于生成 1-2 个重点图表。
- 图片保存到会话目录,严禁 `plt.show()`。
- 类别 > 5 时使用水平条形图。
- **严禁覆盖**:每个文件名必须唯一,建议加上步骤前缀,如 `01_工单量分布.png`。
**阶段3数据分析和可视化使用 generate_code 动作)** **标准化分析SOP (Standard Operating Procedure)**
- 基于实际的列名进行计算 请严格按照以下顺序执行分析,不要跳跃:
- 生成有意义的图表 1. **数据质量检查**:加载数据 -> 打印 info/head -> 检查 '车型'/'模块' 列的唯一值数量。
- 图片保存到会话专用目录中 2. **基础分布分析**
- 生成一个图表后,必须打印绝对路径 - 生成 `01_车型分布.png` (水平条形图)
- 不要试图一次性生成所有图表。你应该将任务拆分为多个小的代码块,分批次执行。 - 生成 `02_模块Top10分布.png` (水平条形图)
- 每一轮只专注于生成 1-2 个复杂的图表或 2-3 个简单的图表,确保代码正确且图片保存成功。 - 生成 `03_问题类型Top10分布.png` (水平条形图)
- 只有在前一轮代码成功执行并保存图片后,再进行下一轮。 3. **时序与来源分析**
- 必做分析1. **超长工单问题类型分布**(从处理时长分布中筛选) - 生成 `04_工单来源分布.png` (饼图或条形图)
2. **车型-问题热力图**(发现特定车型的高频故障) - 生成 `05_月度工单趋势.png` (折线图)
3. **车型分布**(整体工单在不同车型的占比) 4. **深度交叉分析**
4. **处理时长分布**(直方图/KDE - 生成 `06_车型_问题类型热力图.png` (Heatmap)
5. **处理时长箱线图**(按问题类型或责任人分组,识别异常点) - 生成 `07_模块_严重程度堆叠图.png` (Stacked Bar)
6. **高频关键词词云**基于Text Cleaning和N-gram结果 5. **效率分析**
7. **工单来源分布** - 生成 `08_处理时长分布.png` (直方图)
8. **工单状态分布** - 生成 `09_责任人效率分析.png` (散点图: 工单量 vs 平均时长)
9. **模块分布**
10. **未关闭工单状态分布**
11. **问题类型分布**
12. **严重程度分布**
13. **远程控制(Remote Control)问题模块分布**(专项分析)
14. **月度工单趋势**
15. **月度关闭率趋势**
16. **责任人分布**
17. **责任人工作量与效率对比**(散点图或双轴图)
- 图片保存必须使用 `plt.savefig(path, bbox_inches='tight')`。保存后必须显示打印绝对路径。严禁使用 `plt.show()`。
**阶段4深度挖掘与高级分析使用 generate_code 动作)** **阶段4深度挖掘与高级分析使用 generate_code 动作)**
- 主动评估数据特征**:在执行前,先分析数据适合哪种高级挖掘: - 主动评估数据特征**:在执行前,先分析数据适合哪种高级挖掘:
@@ -147,7 +172,7 @@ data_analysis_system_prompt = """你是一个专业的数据分析助手,运
- **可视化增强**:不要只画折线图。使用 `seaborn` 的 `pairplot`, `heatmap`, `lmplot` 等高级图表。 - **可视化增强**:不要只画折线图。使用 `seaborn` 的 `pairplot`, `heatmap`, `lmplot` 等高级图表。
📝 动作选择指南: 📝 动作选择指南:
- **需要执行Python代码** → 使用 "generate_code" - **需要执行代码列表** → 使用 "generate_code"
- **已生成多个图表,需要收集分析** → 使用 "collect_figures" - **已生成多个图表,需要收集分析** → 使用 "collect_figures"
- **所有分析完成,输出最终报告** → 使用 "analysis_complete" - **所有分析完成,输出最终报告** → 使用 "analysis_complete"
- **遇到错误需要修复代码** → 使用 "generate_code" - **遇到错误需要修复代码** → 使用 "generate_code"
@@ -262,6 +287,7 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
### 报告结构模板使用说明 (Template Instructions) ### 报告结构模板使用说明 (Template Instructions)
- **固定格式 (Format)**:所有的 Markdown 标题 (`#`, `##`)、列表项前缀 (`- **...**`)、表格表头是必须保留的**骨架**。 - **固定格式 (Format)**:所有的 Markdown 标题 (`#`, `##`)、列表项前缀 (`- **...**`)、表格表头是必须保留的**骨架**。
- **写作指引 (Prompts)**:方括号 `[...]` 内的文字是给你的**写作提示**,请根据实际分析将其**替换**为具体内容,**不要**在最终报告中保留方括号。 - **写作指引 (Prompts)**:方括号 `[...]` 内的文字是给你的**写作提示**,请根据实际分析将其**替换**为具体内容,**不要**在最终报告中保留方括号。
- **直接输出Markdown**不要使用JSON或YAML包裹直接输出Markdown内容。
--- ---
@@ -314,13 +340,12 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
- **用户与业务影响**:已导致[估算的]用户投诉上升、[功能]使用率下降、潜在[NPS下降分值]。 - **用户与业务影响**:已导致[估算的]用户投诉上升、[功能]使用率下降、潜在[NPS下降分值]。
- **当前缓解状态**[如已暂停该版本推送影响面控制在X%。] - **当前缓解状态**[如已暂停该版本推送影响面控制在X%。]
### 3.2 [业务主题二:例如“高价值用户的核心使用场景与流失预警”] ### 3.2 [车主分析:例如“高价值用户的核心使用场景与流失预警”]
- **核心发现**[例如功能A是留存关键但其失败率在核心用户中最高。] - **核心发现**[例如:截止XXXXX平台捷途车联网的车主XXX新增了功能A是留存关键但其失败率在核心用户中最高。]
- **现象与数据表现**[同上结构] - **现象与数据表现**[同上结构]
- **证据链与深度归因** - **证据链与深度归因**
> ![核心功能使用与留存相关性热图](./figure3.png) > ![核心功能使用与留存相关性热图](./figure3.png)
> **每周使用功能A超过3次的用户其90天留存率是低频用户的2.5倍**,该功能是用户粘性的关键驱动力。 > **每周使用功能A超过3次的用户其90天留存率是低频用户的2.5倍**,该功能是用户粘性的关键驱动力。
>
> ![该功能失败率的用户分群对比](./figure4.png) > ![该功能失败率的用户分群对比](./figure4.png)
> 然而正是这批高价值用户遭遇功能A失败的概率比新用户高40%**体验瓶颈出现在用户最依赖的环节**。 > 然而正是这批高价值用户遭遇功能A失败的概率比新用户高40%**体验瓶颈出现在用户最依赖的环节**。
- **问题回溯与当前影响**[同上结构] - **问题回溯与当前影响**[同上结构]
@@ -334,7 +359,7 @@ final_report_system_prompt = """你是一位**资深数据分析专家 (Senior D
| **[风险2体验一致性]** | [如Android用户关键路径失败率为iOS的2倍] | 高 | 中 | **中高** | 应用商店差评中OS提及率上升 | | **[风险2体验一致性]** | [如Android用户关键路径失败率为iOS的2倍] | 高 | 中 | **中高** | 应用商店差评中OS提及率上升 |
| **[风险3合规性]** | [描述] | 低 | 高 | **中** | [相关法规更新节点] | | **[风险3合规性]** | [描述] | 低 | 高 | **中** | [相关法规更新节点] |
## 5. 改进建议与方案探讨 (Suggestions & Solutions for Review) ## 5. 改进建议与方案探讨
> **重要提示**:以下内容仅基于数据分析结果提出初步探讨方向。**具体实施方案、责任分配及落地时间必须由人工专家PM/研发/运营)结合实际业务资源与约束最终确认**。 > **重要提示**:以下内容仅基于数据分析结果提出初步探讨方向。**具体实施方案、责任分配及落地时间必须由人工专家PM/研发/运营)结合实际业务资源与约束最终确认**。
| 建议方向 (Direction) | 关联问题 (Issue) | 初步方案思路 (Draft Proposal) | 需人工评估点 (Points for Human Review) | | 建议方向 (Direction) | 关联问题 (Issue) | 初步方案思路 (Draft Proposal) | 需人工评估点 (Points for Human Review) |

View File

@@ -50,3 +50,8 @@ flake8>=6.0.0
# 字体支持用于matplotlib中文显示 # 字体支持用于matplotlib中文显示
fonttools>=4.38.0 fonttools>=4.38.0
# Web Interface dependencies
fastapi>=0.109.0
uvicorn>=0.27.0
python-multipart>=0.0.9

45
sort_csv.py Normal file
View File

@@ -0,0 +1,45 @@
import pandas as pd
import os
def sort_csv_by_time(file_path="remotecontrol_merged.csv", time_col="SendTime"):
"""
读取 CSV 文件,按时间列排序,并保存。
"""
if not os.path.exists(file_path):
print(f"❌ 文件不存在: {file_path}")
return
print(f"📖 正在读取 {file_path} ...")
try:
# 读取 CSV
df = pd.read_csv(file_path, low_memory=False)
print(f" 📊 数据行数: {len(df)}")
if time_col not in df.columns:
print(f"❌ 未找到时间列: {time_col}")
print(f" 可用列: {list(df.columns)}")
return
print(f"🔄 正在解析时间列 '{time_col}' ...")
# 转换为 datetime 对象,无法解析的设为 NaT
df[time_col] = pd.to_datetime(df[time_col], errors='coerce')
# 检查无效时间
nat_count = df[time_col].isna().sum()
if nat_count > 0:
print(f"⚠️ 发现 {nat_count} 行无效时间数据,排序时将排在最后")
print("🔄 正在按时间排序...")
df_sorted = df.sort_values(by=time_col)
print(f"💾 正在保存及覆盖文件: {file_path} ...")
df_sorted.to_csv(file_path, index=False, encoding="utf-8-sig")
print("✅ 排序并保存完成!")
except Exception as e:
print(f"❌处理失败: {e}")
if __name__ == "__main__":
sort_csv_by_time()

4
start.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
echo Starting IOV Data Analysis Agent...
python bootstrap.py
pause

3
start.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
echo "Starting IOV Data Analysis Agent..."
python3 bootstrap.py

5
start_web.bat Normal file
View File

@@ -0,0 +1,5 @@
@echo off
echo Starting IOV Data Analysis Agent Web Interface...
echo Please open http://localhost:8000 in your browser.
python -m uvicorn web.main:app --reload --host 0.0.0.0 --port 8000
pause

4
start_web.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
echo "Starting IOV Data Analysis Agent Web Interface..."
echo "Please open http://localhost:8000 in your browser."
python3 -m uvicorn web.main:app --reload --host 0.0.0.0 --port 8000

13
test.py Normal file
View File

@@ -0,0 +1,13 @@
import openai
client = openai.OpenAI(
api_key="sk-2187174de21548b0b8b0c92129700199",
base_url="http://127.0.0.1:9999/v1"
)
response = client.chat.completions.create(
model="gemini-3-flash",
messages=[{"role": "user", "content": "你好,请自我介绍"}]
)
print(response.choices[0].message.content)

View File

@@ -26,7 +26,9 @@ class CodeExecutor:
"pandas", "pandas",
"pd", "pd",
"numpy", "numpy",
"glob",
"np", "np",
"subprocess",
"matplotlib", "matplotlib",
"matplotlib.pyplot", "matplotlib.pyplot",
"plt", "plt",
@@ -36,6 +38,14 @@ class CodeExecutor:
"scipy", "scipy",
"sklearn", "sklearn",
"sklearn.feature_extraction.text", "sklearn.feature_extraction.text",
"sklearn.preprocessing",
"sklearn.model_selection",
"sklearn.metrics",
"sklearn.ensemble",
"sklearn.linear_model",
"sklearn.cluster",
"sklearn.decomposition",
"sklearn.manifold",
"statsmodels", "statsmodels",
"plotly", "plotly",
"dash", "dash",
@@ -230,11 +240,15 @@ from IPython.display import display
for node in ast.walk(tree): for node in ast.walk(tree):
if isinstance(node, ast.Import): if isinstance(node, ast.Import):
for alias in node.names: for alias in node.names:
if alias.name not in self.ALLOWED_IMPORTS: # 获取根包名 (e.g. sklearn.preprocessing -> sklearn)
root_package = alias.name.split('.')[0]
if root_package not in self.ALLOWED_IMPORTS and alias.name not in self.ALLOWED_IMPORTS:
return False, f"不允许的导入: {alias.name}" return False, f"不允许的导入: {alias.name}"
elif isinstance(node, ast.ImportFrom): elif isinstance(node, ast.ImportFrom):
if node.module not in self.ALLOWED_IMPORTS: if node.module:
root_package = node.module.split('.')[0]
if root_package not in self.ALLOWED_IMPORTS and node.module not in self.ALLOWED_IMPORTS:
return False, f"不允许的导入: {node.module}" return False, f"不允许的导入: {node.module}"
# 检查属性访问防止通过os.system等方式绕过 # 检查属性访问防止通过os.system等方式绕过
@@ -381,6 +395,33 @@ from IPython.display import display
except: except:
pass pass
# --- 自动保存机制 start ---
# 检查是否有未关闭的图片,如果有,自动保存
try:
open_fig_nums = plt.get_fignums()
if open_fig_nums:
for fig_num in open_fig_nums:
fig = plt.figure(fig_num)
# 生成自动保存的文件名
auto_filename = f"autosave_fig_{self.image_counter}_{fig_num}.png"
auto_filepath = os.path.join(self.output_dir, auto_filename)
try:
# 尝试保存
fig.savefig(auto_filepath, bbox_inches='tight')
print(f"💾 [Auto-Save] 检测到未闭合图表,已安全保存至: {auto_filepath}")
# 添加到输出中告知Agent
output += f"\n[Auto-Save] ⚠️ 检测到Figure {fig_num}未关闭,系统已自动保存为: {auto_filename}"
self.image_counter += 1
except Exception as e:
print(f"⚠️ [Auto-Save] 保存失败: {e}")
finally:
plt.close(fig_num)
except Exception as e:
print(f"⚠️ [Auto-Save Global] 异常: {e}")
# --- 自动保存机制 end ---
return { return {
"success": True, "success": True,
"output": output, "output": output,

View File

@@ -75,7 +75,8 @@ class LLMHelper:
else: else:
yaml_content = response.strip() yaml_content = response.strip()
return yaml.safe_load(yaml_content) parsed = yaml.safe_load(yaml_content)
return parsed if parsed is not None else {}
except Exception as e: except Exception as e:
print(f"YAML解析失败: {e}") print(f"YAML解析失败: {e}")
print(f"原始响应: {response}") print(f"原始响应: {response}")

404
web/main.py Normal file
View File

@@ -0,0 +1,404 @@
import sys
import os
import threading
import glob
import uuid
import json
from typing import Optional, Dict, List
from fastapi import FastAPI, UploadFile, File, BackgroundTasks, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel
# Add parent directory to path to import agent modules
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from data_analysis_agent import DataAnalysisAgent
from config.llm_config import LLMConfig
from utils.create_session_dir import create_session_output_dir
from merge_excel import merge_excel_files
from sort_csv import sort_csv_by_time
app = FastAPI(title="IOV Data Analysis Agent")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Session Management ---
class SessionData:
def __init__(self, session_id: str):
self.session_id = session_id
self.is_running = False
self.output_dir: Optional[str] = None
self.generated_report: Optional[str] = None
self.log_file: Optional[str] = None
self.analysis_results: List[Dict] = [] # Store analysis results for gallery
class SessionManager:
def __init__(self):
self.sessions: Dict[str, SessionData] = {}
self.lock = threading.Lock()
def create_session(self) -> str:
with self.lock:
session_id = str(uuid.uuid4())
self.sessions[session_id] = SessionData(session_id)
return session_id
def get_session(self, session_id: str) -> Optional[SessionData]:
return self.sessions.get(session_id)
def list_sessions(self):
return list(self.sessions.keys())
session_manager = SessionManager()
# Mount static files
os.makedirs("web/static", exist_ok=True)
os.makedirs("uploads", exist_ok=True)
os.makedirs("outputs", exist_ok=True)
app.mount("/static", StaticFiles(directory="web/static"), name="static")
app.mount("/outputs", StaticFiles(directory="outputs"), name="outputs")
# --- Helper Functions ---
def run_analysis_task(session_id: str, files: list, user_requirement: str):
"""
Runs the analysis agent in a background thread for a specific session.
"""
session = session_manager.get_session(session_id)
if not session:
print(f"Error: Session {session_id} not found in background task.")
return
session.is_running = True
try:
# Create session directory
base_output_dir = "outputs"
# We enforce a specific directory naming convention or let the util handle it
# ideally we map session_id to the directory
# For now, let's use the standard utility but we might lose the direct mapping if not careful
# Let's trust the return value
session_output_dir = create_session_output_dir(base_output_dir, user_requirement)
session.output_dir = session_output_dir
# Initialize Log capturing
session.log_file = os.path.join(session_output_dir, "process.log")
# Thread-safe logging requires a bit of care.
# Since we are running in a thread, redirecting sys.stdout globally is BAD for multi-session.
# However, for this MVP, if we run multiple sessions concurrently, their logs will mix in stdout.
# BUT we are writing to specific log files.
# We need a logger that writes to the session's log file.
# And the Agent needs to use that logger.
# Currently the Agent uses print().
# To support true concurrent logging without mixing, we'd need to refactor Agent to use a logger instance.
# LIMITATION: For now, we accept that stdout redirection intercepts EVERYTHING.
# So multiple concurrent sessions is risky with global stdout redirection.
# A safer approach for now: We won't redirect stdout globally for multi-session support
# unless we lock execution to one at a time.
# OR: We just rely on the fact that we might only run one analysis at a time mostly.
# Let's try to just write to the log file explicitly if we could, but we can't change Agent easily right now.
# Compromise: We will continue to use global redirection but acknowledge it's not thread-safe for output.
# A better way: Modify Agent to accept a 'log_callback'.
# For this refactor, let's stick to the existing pattern but bind it to the thread if possible? No.
# We will wrap the execution with a simple File Logger that appends to the distinct file.
# But sys.stdout is global.
# We will assume single concurrent analysis for safety, or accept mixed terminal output but separate file logs?
# Actually, if we swap sys.stdout, it affects all threads.
# So we MUST NOT swap sys.stdout if we want concurrency.
# If we don't swap stdout, we don't capture logs to file unless Agent does it.
# The Agent code has `print`.
# Correct fix: Refactor Agent to use `logging` module or pass a printer.
# Given the scope, let's just hold the lock (serialize execution) OR allow mixing in terminal
# but try to capture to file?
# Let's just write to the file.
with open(session.log_file, "w", encoding="utf-8") as f:
f.write(f"--- Session {session_id} Started ---\n")
# We will create a custom print function that writes to the file
# And monkeypatch builtins.print? No, that's too hacky.
# Let's just use the stdout redirector, but acknowledge only one active session at a time is safe.
# We can implement a crude lock for now.
class FileLogger:
def __init__(self, filename):
self.terminal = sys.__stdout__
self.log = open(filename, "a", encoding="utf-8", buffering=1)
def write(self, message):
self.terminal.write(message)
self.log.write(message)
def flush(self):
self.terminal.flush()
self.log.flush()
def close(self):
self.log.close()
logger = FileLogger(session.log_file)
sys.stdout = logger # Global hijack!
try:
llm_config = LLMConfig()
agent = DataAnalysisAgent(llm_config, force_max_rounds=False, output_dir=base_output_dir)
result = agent.analyze(
user_input=user_requirement,
files=files,
session_output_dir=session_output_dir
)
session.generated_report = result.get("report_file_path", None)
session.analysis_results = result.get("analysis_results", [])
# Save results to json for persistence
with open(os.path.join(session_output_dir, "results.json"), "w") as f:
json.dump(session.analysis_results, f, default=str)
except Exception as e:
print(f"Error during analysis: {e}")
finally:
sys.stdout = logger.terminal
logger.close()
except Exception as e:
print(f"System Error: {e}")
finally:
session.is_running = False
# --- Pydantic Models ---
class StartRequest(BaseModel):
requirement: str
# --- API Endpoints ---
@app.get("/")
async def read_root():
return FileResponse("web/static/index.html")
@app.post("/api/upload")
async def upload_files(files: list[UploadFile] = File(...)):
saved_files = []
for file in files:
file_location = f"uploads/{file.filename}"
with open(file_location, "wb+") as file_object:
file_object.write(file.file.read())
saved_files.append(file_location)
return {"info": f"Saved {len(saved_files)} files", "paths": saved_files}
@app.post("/api/start")
async def start_analysis(request: StartRequest, background_tasks: BackgroundTasks):
session_id = session_manager.create_session()
files = glob.glob("uploads/*.csv")
if not files:
if os.path.exists("cleaned_data.csv"):
files = ["cleaned_data.csv"]
else:
raise HTTPException(status_code=400, detail="No CSV files found")
files = [os.path.abspath(f) for f in files] # Only use absolute paths
background_tasks.add_task(run_analysis_task, session_id, files, request.requirement)
return {"status": "started", "session_id": session_id}
@app.get("/api/status")
async def get_status(session_id: str = Query(..., description="Session ID")):
session = session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
log_content = ""
if session.log_file and os.path.exists(session.log_file):
with open(session.log_file, "r", encoding="utf-8") as f:
log_content = f.read()
return {
"is_running": session.is_running,
"log": log_content,
"has_report": session.generated_report is not None,
"report_path": session.generated_report
}
@app.get("/api/report")
async def get_report(session_id: str = Query(..., description="Session ID")):
session = session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if not session.generated_report or not os.path.exists(session.generated_report):
return {"content": "Report not ready."}
with open(session.generated_report, "r", encoding="utf-8") as f:
content = f.read()
# Fix image paths
relative_session_path = os.path.relpath(session.output_dir, os.getcwd())
web_base_path = f"/{relative_session_path}"
content = content.replace("](./", f"]({web_base_path}/")
return {"content": content, "base_path": web_base_path}
@app.get("/api/figures")
async def get_figures(session_id: str = Query(..., description="Session ID")):
session = session_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# We can try to get from memory first
results = session.analysis_results
# If empty in memory (maybe server restarted but files exist?), try load json
if not results and session.output_dir:
json_path = os.path.join(session.output_dir, "results.json")
if os.path.exists(json_path):
with open(json_path, 'r') as f:
results = json.load(f)
# Extract collected figures
figures = []
# We iterate over analysis results to find 'collect_figures' actions
if results:
for item in results:
if item.get("action") == "collect_figures":
collected = item.get("collected_figures", [])
for fig in collected:
# Enrich with web path
if session.output_dir:
# Assume filename is present
fname = fig.get("filename")
relative_session_path = os.path.relpath(session.output_dir, os.getcwd())
fig["web_url"] = f"/{relative_session_path}/{fname}"
figures.append(fig)
# Also check for 'generate_code' results that might have implicit figures if we parse them
# But the 'collect_figures' action is the reliable source as per agent design
# Auto-discovery fallback if list is empty but pngs exist?
if not figures and session.output_dir:
# Simple scan
pngs = glob.glob(os.path.join(session.output_dir, "*.png"))
for p in pngs:
fname = os.path.basename(p)
relative_session_path = os.path.relpath(session.output_dir, os.getcwd())
figures.append({
"filename": fname,
"description": "Auto-discovered image",
"analysis": "No analysis available",
"web_url": f"/{relative_session_path}/{fname}"
})
return {"figures": figures}
@app.get("/api/export")
async def export_report(session_id: str = Query(..., description="Session ID")):
session = session_manager.get_session(session_id)
if not session or not session.output_dir:
raise HTTPException(status_code=404, detail="Session not found")
import zipfile
import tempfile
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_filename = f"report_{timestamp}.zip"
export_dir = "outputs"
os.makedirs(export_dir, exist_ok=True)
temp_zip_path = os.path.join(export_dir, zip_filename)
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(session.output_dir):
for file in files:
if file.endswith(('.md', '.png', '.csv', '.log', '.json', '.yaml')):
abs_path = os.path.join(root, file)
rel_path = os.path.relpath(abs_path, session.output_dir)
zf.write(abs_path, arcname=rel_path)
return FileResponse(
path=temp_zip_path,
filename=zip_filename,
media_type='application/zip'
)
# --- Tools API ---
class ToolRequest(BaseModel):
source_dir: Optional[str] = "uploads"
output_filename: Optional[str] = "merged_output.csv"
target_file: Optional[str] = None
@app.post("/api/tools/merge")
async def tool_merge_excel(req: ToolRequest):
"""
Trigger Excel Merge Tool
"""
try:
source = req.source_dir
output = req.output_filename
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: merge_excel_files(source, output))
output_abs = os.path.abspath(output)
if os.path.exists(output_abs):
return {"status": "success", "message": "Merge completed", "output_file": output_abs}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/tools/sort")
async def tool_sort_csv(req: ToolRequest):
"""
Trigger CSV Sort Tool
"""
try:
target = req.target_file
if not target:
raise HTTPException(status_code=400, detail="Target file required")
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: sort_csv_by_time(target))
return {"status": "success", "message": f"Sorted {target} by time"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# --- Help API ---
@app.get("/api/help/troubleshooting")
async def get_troubleshooting_guide():
"""
Returns the content of troubleshooting_guide.md
"""
guide_path = os.path.expanduser("~/.gemini/antigravity/brain/3ff617fe-5f27-4ab8-b61b-c634f2e75255/troubleshooting_guide.md")
if not os.path.exists(guide_path):
return {"content": "# Troubleshooting Guide Not Found\n\nCould not locate the guide artifact."}
try:
with open(guide_path, "r", encoding="utf-8") as f:
content = f.read()
return {"content": content}
except Exception as e:
return {"content": f"# Error Loading Guide\n\n{e}"}

175
web/static/index.html Normal file
View File

@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IOV Data Analysis Agent</title>
<link rel="stylesheet" href="/static/style.css?v=2.0">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Markdown Parser -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="app-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="brand">
<i class="fa-solid fa-robot"></i>
<h1>IOV Agent</h1>
</div>
<!-- Navigation -->
<nav class="main-nav">
<button class="nav-btn active" onclick="switchView('analysis')">
<i class="fa-solid fa-chart-line"></i> Analysis
</button>
<button class="nav-btn" onclick="switchView('tools')">
<i class="fa-solid fa-toolbox"></i> Data Tools
</button>
<button class="nav-btn" onclick="switchView('gallery')">
<i class="fa-solid fa-images"></i> Gallery
</button>
<button class="nav-btn" onclick="switchView('help')">
<i class="fa-solid fa-circle-question"></i> Help
</button>
</nav>
<!-- Analysis Controls (Visible only in Analysis View) -->
<div id="analysisControls" class="control-group-wrapper">
<div class="control-group">
<h3>1. Data Upload</h3>
<div class="upload-zone" id="uploadZone">
<i class="fa-solid fa-cloud-arrow-up"></i>
<p>Drag & Drop CSV/Excel</p>
<input type="file" id="fileInput" accept=".csv,.xlsx,.xls" multiple hidden>
<button class="btn secondary" onclick="document.getElementById('fileInput').click()">Select
Files</button>
<div id="fileList" class="file-list"></div>
</div>
</div>
<div class="control-group">
<h3>2. Analysis Config</h3>
<textarea id="requirementInput"
placeholder="Enter your analysis requirement here...">基于所有运维工单,整理一份工单健康度报告...</textarea>
<button id="startBtn" class="btn primary">
<i class="fa-solid fa-play"></i> Start Analysis
</button>
</div>
</div>
<div class="status-indicator">
<span id="statusDot" class="dot"></span>
<span id="statusText">Idle</span>
</div>
</aside>
<!-- Main Content Area -->
<main class="main-content">
<!-- VIEW: ANALYSIS -->
<div id="viewAnalysis" class="view-section active">
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('logs')">
<i class="fa-solid fa-terminal"></i> Live Logs
</button>
<button class="tab-btn" onclick="switchTab('report')">
<i class="fa-solid fa-file-invoice-dollar"></i> Report
</button>
<div style="margin-left: auto; display: flex; align-items: center;">
<button id="exportBtn" class="btn secondary"
style="padding: 0.4rem 1rem; font-size: 0.8rem; width: auto;" onclick="triggerExport()">
<i class="fa-solid fa-download"></i> Export Report (ZIP)
</button>
</div>
</div>
<!-- Log View -->
<div id="logsTab" class="tab-content active">
<div class="terminal-window">
<pre id="logOutput">Waiting to start...</pre>
</div>
</div>
<!-- Report View -->
<div id="reportTab" class="tab-content">
<div id="reportContainer" class="markdown-body">
<div class="empty-state">
<i class="fa-solid fa-chart-simple"></i>
<p>No report generated yet.</p>
</div>
</div>
</div>
</div>
<!-- VIEW: TOOLS -->
<div id="viewTools" class="view-section" style="display: none;">
<div class="tools-grid">
<!-- Tool Card 1: Merge Excel -->
<div class="tool-card">
<div class="tool-icon"><i class="fa-solid fa-file-csv"></i></div>
<h3>Excel to CSV Merger</h3>
<p>Merge multiple .xlsx files from the uploads directory into a single CSV for analysis.</p>
<div class="tool-actions">
<input type="text" id="mergeSource" value="uploads" placeholder="Source Directory"
class="input-sm">
<button class="btn secondary" onclick="triggerMerge()">
<i class="fa-solid fa-bolt"></i> Merge Now
</button>
</div>
<div id="mergeResult" class="tool-result"></div>
</div>
<!-- Tool Card 2: Sort CSV -->
<div class="tool-card">
<div class="tool-icon"><i class="fa-solid fa-arrow-down-a-z"></i></div>
<h3>Time Sorter</h3>
<p>Sort a CSV file by 'SendTime' or time column to fix ordering issues.</p>
<div class="tool-actions">
<input type="text" id="sortTarget" value="cleaned_data.csv" placeholder="Target Filename"
class="input-sm">
<button class="btn secondary" onclick="triggerSort()">
<i class="fa-solid fa-sort"></i> Sort Now
</button>
</div>
<div id="sortResult" class="tool-result"></div>
</div>
</div>
</div>
<!-- VIEW: GALLERY -->
<div id="viewGallery" class="view-section" style="display: none;">
<div id="galleryContainer" class="gallery-grid">
<div class="empty-state">
<i class="fa-solid fa-images"></i>
<p>No images generated yet.<br>Start an analysis to see results here.</p>
</div>
</div>
</div>
<!-- VIEW: HELP -->
<div id="viewHelp" class="view-section" style="display: none;">
<div class="help-header">
<h2>Troubleshooting Guide</h2>
<button class="btn secondary" onclick="fetchHelp()">Refresh</button>
</div>
<div id="helpContainer" class="markdown-body help-body">
<p>Loading guide...</p>
</div>
</div>
</main>
</div>
<script src="/static/script.js"></script>
</body>
</html>

362
web/static/script.js Normal file
View File

@@ -0,0 +1,362 @@
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
const startBtn = document.getElementById('startBtn');
const requirementInput = document.getElementById('requirementInput');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const logOutput = document.getElementById('logOutput');
const reportContainer = document.getElementById('reportContainer');
const galleryContainer = document.getElementById('galleryContainer');
let isRunning = false;
let pollingInterval = null;
let currentSessionId = null;
// --- Upload Logic ---
if (uploadZone) {
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
}
if (fileInput) {
fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
}
async function handleFiles(files) {
if (files.length === 0) return;
fileList.innerHTML = '';
const formData = new FormData();
for (const file of files) {
formData.append('files', file);
const fileItem = document.createElement('div');
fileItem.innerText = `📄 ${file.name}`;
fileItem.style.fontSize = '0.8rem';
fileItem.style.color = '#fff';
fileList.appendChild(fileItem);
}
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (res.ok) {
const data = await res.json();
console.log('Upload success:', data);
} else {
alert('Upload failed');
}
} catch (e) {
console.error(e);
alert('Upload failed');
}
}
// --- Start Analysis ---
if (startBtn) {
startBtn.addEventListener('click', async () => {
if (isRunning) return;
const requirement = requirementInput.value.trim();
if (!requirement) {
alert('Please enter analysis requirement');
return;
}
setRunningState(true);
try {
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requirement })
});
if (res.ok) {
const data = await res.json();
currentSessionId = data.session_id; // Store Session ID
console.log("Started Session:", currentSessionId);
startPolling();
switchTab('logs');
} else {
const err = await res.json();
alert('Failed to start: ' + err.detail);
setRunningState(false);
}
} catch (e) {
console.error(e);
alert('Error starting analysis');
setRunningState(false);
}
});
}
function setRunningState(running) {
isRunning = running;
startBtn.disabled = running;
startBtn.innerHTML = running ? '<i class="fa-solid fa-spinner fa-spin"></i> Running...' : '<i class="fa-solid fa-play"></i> Start Analysis';
if (running) {
statusDot.className = 'dot running';
statusText.innerText = 'Analysis in Progress';
} else {
statusDot.className = 'dot done';
statusText.innerText = 'Completed';
}
}
// --- Status Polling ---
function startPolling() {
if (pollingInterval) clearInterval(pollingInterval);
if (!currentSessionId) return;
pollingInterval = setInterval(async () => {
try {
const res = await fetch(`/api/status?session_id=${currentSessionId}`);
if (!res.ok) return;
const data = await res.json();
// Update Logs
logOutput.innerText = data.log || "Waiting for logs...";
// Auto scroll to bottom
const term = document.querySelector('.terminal-window');
if (term) term.scrollTop = term.scrollHeight;
if (!data.is_running && isRunning) {
// Just finished
setRunningState(false);
clearInterval(pollingInterval);
if (data.has_report) {
loadReport();
loadGallery(); // Load images when done
switchTab('report');
}
}
} catch (e) {
console.error('Polling error', e);
}
}, 2000);
}
async function loadReport() {
if (!currentSessionId) return;
try {
const res = await fetch(`/api/report?session_id=${currentSessionId}`);
const data = await res.json();
// Render Markdown
reportContainer.innerHTML = marked.parse(data.content);
// Fix images relative path for display if needed
// The backend should return a base_path or we handle it here
if (data.base_path) {
// Backend already handled replacement?
// The backend returns content with updated paths.
}
} catch (e) {
reportContainer.innerHTML = '<p class="error">Failed to load report.</p>';
console.error(e);
}
}
async function loadGallery() {
if (!currentSessionId) return;
// Switch to gallery view logic if we were already there
// But this is just data loading
try {
const res = await fetch(`/api/figures?session_id=${currentSessionId}`);
const data = await res.json();
const galleryGrid = document.getElementById('galleryContainer');
if (!data.figures || data.figures.length === 0) {
galleryGrid.innerHTML = `
<div class="empty-state">
<i class="fa-solid fa-images"></i>
<p>No images generated in this session.</p>
</div>
`;
return;
}
let html = '';
data.figures.forEach(fig => {
html += `
<div class="gallery-card">
<div class="img-wrapper">
<img src="${fig.web_url}" alt="${fig.filename}" onclick="window.open('${fig.web_url}', '_blank')">
</div>
<div class="card-content">
<h4>${fig.filename}</h4>
<p class="desc">${fig.description || 'No description'}</p>
${fig.analysis ? `<div class="analysis-box"><i class="fa-solid fa-magnifying-glass"></i> ${fig.analysis}</div>` : ''}
</div>
</div>
`;
});
galleryGrid.innerHTML = html;
} catch (e) {
console.error("Gallery load failed", e);
}
}
// --- Export Report ---
window.triggerExport = async function () {
if (!currentSessionId) {
alert("No active session to export.");
return;
}
const btn = document.getElementById('exportBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Zipping...';
btn.disabled = true;
try {
// Trigger download
// We can't use fetch for file download easily if we want browser to handle save dialog
// So we create a link approach or check status first
// Check if download is possible
const url = `/api/export?session_id=${currentSessionId}`;
const res = await fetch(url, { method: 'HEAD' });
if (res.ok) {
window.location.href = url;
} else {
alert("Export failed: No data available.");
}
} catch (e) {
alert("Export failed: " + e.message);
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
// --- Tabs (Inner) ---
window.switchTab = function (tabName) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
const btn = document.querySelector(`button[onclick="switchTab('${tabName}')"]`);
if (btn) btn.classList.add('active');
const tab = document.getElementById(`${tabName}Tab`);
if (tab) tab.classList.add('active');
}
// --- View Navigation (Outer Sidebar) ---
window.switchView = function (viewName) {
// 1. Update Sidebar Buttons
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
const navBtn = document.querySelector(`button[onclick="switchView('${viewName}')"]`);
if (navBtn) navBtn.classList.add('active');
// 2. Switch Main Content Sections
const viewId = 'view' + viewName.charAt(0).toUpperCase() + viewName.slice(1);
document.querySelectorAll('.view-section').forEach(v => {
// Hide all
v.style.display = 'none';
v.classList.remove('active');
});
const activeView = document.getElementById(viewId);
if (activeView) {
// Analysis view uses flex for layout (logs scrolling), others use block
if (viewName === 'analysis') {
activeView.style.display = 'flex';
activeView.style.flexDirection = 'column';
} else {
activeView.style.display = 'block';
// If switching to gallery, reload it if session exists
if (viewName === 'gallery' && currentSessionId) {
loadGallery();
}
}
activeView.classList.add('active');
}
// 3. Toggle Sidebar Controls (only show Analysis Controls in analysis view)
const analysisControls = document.getElementById('analysisControls');
if (analysisControls) {
analysisControls.style.display = (viewName === 'analysis') ? 'block' : 'none';
}
}
// --- Tool Functions ---
window.triggerMerge = async function () {
const sourceDir = document.getElementById('mergeSource').value;
const resultDiv = document.getElementById('mergeResult');
resultDiv.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Merging...';
try {
const res = await fetch('/api/tools/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_dir: sourceDir, output_filename: 'merged_output.csv' })
});
const data = await res.json();
if (data.status === 'success') {
resultDiv.innerHTML = `<span style="color:var(--success)"><i class="fa-solid fa-check"></i> ${data.message}</span>`;
} else {
resultDiv.innerHTML = `<span style="color:var(--warning)">${data.message}</span>`;
}
} catch (e) {
resultDiv.innerHTML = `<span style="color:red">Error: ${e.message}</span>`;
}
}
window.triggerSort = async function () {
const targetFile = document.getElementById('sortTarget').value;
const resultDiv = document.getElementById('sortResult');
resultDiv.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sorting...';
try {
const res = await fetch('/api/tools/sort', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_file: targetFile })
});
const data = await res.json();
if (data.status === 'success') {
resultDiv.innerHTML = `<span style="color:var(--success)"><i class="fa-solid fa-check"></i> ${data.message}</span>`;
} else {
resultDiv.innerHTML = `<span style="color:var(--warning)">${data.message}</span>`;
}
} catch (e) {
resultDiv.innerHTML = `<span style="color:red">Error: ${e.message}</span>`;
}
}
// --- Help Functions ---
window.fetchHelp = async function () {
const container = document.getElementById('helpContainer');
container.innerHTML = 'Loading...';
try {
const res = await fetch('/api/help/troubleshooting');
const data = await res.json();
container.innerHTML = marked.parse(data.content);
} catch (e) {
container.innerHTML = 'Failed to load guide.';
}
}

387
web/static/style.css Normal file
View File

@@ -0,0 +1,387 @@
:root {
--bg-color: #0f172a;
--sidebar-bg: rgba(30, 41, 59, 0.7);
--glass-border: 1px solid rgba(255, 255, 255, 0.1);
--primary-color: #6366f1;
--primary-hover: #4f46e5;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--card-bg: rgba(30, 41, 59, 0.4);
--success: #10b981;
--warning: #f59e0b;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
background-image:
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(16, 185, 129, 0.15) 0px, transparent 50%);
color: var(--text-main);
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 320px;
background: var(--sidebar-bg);
backdrop-filter: blur(12px);
border-right: var(--glass-border);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-main);
}
.brand i {
font-size: 1.5rem;
color: var(--primary-color);
}
.brand h1 {
font-size: 1.25rem;
font-weight: 700;
}
.control-group h3 {
font-size: 0.875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
/* Upload Zone */
.upload-zone {
border: 2px dashed rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
padding: 2rem 1rem;
text-align: center;
transition: all 0.2s;
background: rgba(255, 255, 255, 0.02);
}
.upload-zone.dragover {
border-color: var(--primary-color);
background: rgba(99, 102, 241, 0.1);
}
.upload-zone i {
font-size: 2rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.upload-zone p {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
/* Inputs */
textarea {
width: 100%;
height: 120px;
background: rgba(0, 0, 0, 0.2);
border: var(--glass-border);
border-radius: 0.5rem;
padding: 0.75rem;
color: var(--text-main);
font-family: inherit;
resize: none;
font-size: 0.875rem;
margin-bottom: 1rem;
}
textarea:focus {
outline: none;
border-color: var(--primary-color);
}
/* Buttons */
.btn {
width: 100%;
padding: 0.75rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn.primary {
background: var(--primary-color);
color: white;
}
.btn.primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.btn.secondary {
background: rgba(255, 255, 255, 0.1);
color: var(--text-main);
font-size: 0.8rem;
padding: 0.5rem;
width: auto;
margin: 0 auto;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Status */
.status-indicator {
margin-top: auto;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
padding-top: 1rem;
border-top: var(--glass-border);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--text-muted);
}
.dot.running {
background-color: var(--warning);
box-shadow: 0 0 8px var(--warning);
animation: pulse 1s infinite;
}
.dot.done {
background-color: var(--success);
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 1rem;
overflow: hidden;
min-height: 0;
/* Crucial for nested flex scroll */
}
/* View Section needs to fill space for analysis view */
.view-section {
flex: 1;
display: none;
overflow: hidden;
min-height: 0;
}
.view-section.active {
display: flex;
/* Changed from block to flex for analysis view layout */
flex-direction: column;
}
.tabs {
flex-shrink: 0;
/* Prevent tabs from shrinking */
display: flex;
gap: 1rem;
border-bottom: var(--glass-border);
padding-bottom: 0.5rem;
}
/* ... existing tab-btn styles ... */
.tab-content {
display: none;
flex: 1;
overflow: hidden;
min-height: 0;
/* Crucial for nested flex scroll */
animation: fadeIn 0.3s ease;
}
.tab-content.active {
display: flex;
flex-direction: column;
}
/* Logs */
.terminal-window {
background: #000;
border-radius: 0.75rem;
padding: 1rem;
flex: 1;
overflow-y: auto;
min-height: 0;
/* Crucial for nested flex scroll */
border: var(--glass-border);
font-family: 'JetBrains Mono', 'Menlo', monospace;
font-size: 0.85rem;
color: #4ade80;
line-height: 1.5;
}
#logOutput {
white-space: pre-wrap;
}
/* Report */
#reportContainer {
flex: 1;
overflow-y: auto;
background: white;
color: #1e293b;
border-radius: 0.75rem;
padding: 2rem;
}
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #94a3b8;
gap: 1rem;
}
.empty-state i {
font-size: 3rem;
opacity: 0.5;
}
/* Markdown Styles Override for Report */
.markdown-body img {
max-width: 100%;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
margin: 1rem 0;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
margin-top: 1.5em;
margin-bottom: 0.5em;
color: #111827;
}
.markdown-body p {
margin-bottom: 1em;
line-height: 1.7;
}
/* New: Tools Grid */
.tools-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
padding: 1rem 0;
overflow-y: auto;
}
.tool-card {
background: var(--card-bg);
border: var(--glass-border);
border-radius: 1rem;
padding: 1.5rem;
transition: transform 0.2s;
}
.tool-card:hover {
transform: translateY(-2px);
border-color: var(--primary-color);
}
.tool-icon {
font-size: 2rem;
color: var(--primary-color);
margin-bottom: 1rem;
}
.tool-card h3 {
margin-bottom: 0.5rem;
}
.tool-card p {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 1.5rem;
min-height: 3em;
}
.tool-actions {
display: flex;
gap: 0.5rem;
}
.input-sm {
background: rgba(0, 0, 0, 0.3);
border: var(--glass-border);
color: white;
padding: 0.5rem;
border-radius: 0.5rem;
flex: 1;
}
/* Help Styles */
.help-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.help-body {
background: white;
color: #1e293b;
padding: 2rem;
border-radius: 0.75rem;
overflow-y: auto;
flex: 1;
}