agentsociety2.skills.analysis.output 源代码

"""Output helpers for the analysis tool layer."""

from __future__ import annotations

import base64
import html as html_module
import mimetypes
import shutil
from dataclasses import dataclass
from hashlib import sha1
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

try:
    import pandas as pd
except ModuleNotFoundError:  # pragma: no cover - optional dependency
    pd = None

from agentsociety2.logger import get_logger

from .chart_export import (
    EDA_HUB_ENTRIES,
    brand_header_html,
    eda_datatable_css,
    eda_hub_page_css,
    eda_index_page_css,
    ensure_brand_icon,
    export_plotly_html,
    html_font_links,
    html_tab_switcher_script,
)
from .models import (
    DIR_ARTIFACTS,
    DIR_EXPERIMENT_PREFIX,
    DIR_HYPOTHESIS_PREFIX,
    DIR_REPORT_ASSETS,
    DIR_RUN,
    SUPPORTED_IMAGE_FORMATS,
    ReportAsset,
)
from .utils import _sanitize_id

logger = get_logger()


[文档] @dataclass class ReportPaths: """Report file paths.""" markdown: Path html: Path markdown_zh: Optional[Path] = None html_zh: Optional[Path] = None markdown_en: Optional[Path] = None html_en: Optional[Path] = None assets_dir: Optional[Path] = None
[文档] class AssetManager: """Discover and normalize analysis assets."""
[文档] def __init__(self, workspace_path: Path): self.workspace_path = Path(workspace_path) self.logger = logger
[文档] def discover_assets( self, experiment_id: str, hypothesis_id: str, ) -> List[ReportAsset]: """Discover image assets under `run/artifacts`.""" hid = _sanitize_id(hypothesis_id) eid = _sanitize_id(experiment_id) asset_path = ( self.workspace_path / f"{DIR_HYPOTHESIS_PREFIX}{hid}" / f"{DIR_EXPERIMENT_PREFIX}{eid}" / DIR_RUN / DIR_ARTIFACTS ) assets: List[ReportAsset] = [] if not asset_path.exists(): return assets for file_path in asset_path.rglob("*"): if file_path.suffix.lower() not in SUPPORTED_IMAGE_FORMATS: continue relative_path = file_path.relative_to(asset_path) assets.append( ReportAsset( asset_id=self._build_asset_id("viz", relative_path), asset_type="visualization", title=self._format_title(file_path.stem), file_path=str(file_path), description=f"Generated visualization: {relative_path.as_posix()}", file_size=file_path.stat().st_size, ) ) return assets
[文档] def process_assets( self, assets: List[ReportAsset], output_dir: Path, *, include_embedded_data: bool = True, ) -> Dict[str, Any]: """Copy assets into `assets/`; optionally generate base64 payloads.""" assets_dir = output_dir / DIR_REPORT_ASSETS assets_dir.mkdir(exist_ok=True) ensure_brand_icon(assets_dir) processed: Dict[str, Any] = {} used_names: set[str] = set() for asset in assets: source_path = Path(asset.file_path) if not source_path.exists(): continue dest_name = self._build_unique_dest_name( source_path, asset.asset_id, used_names ) dest_path = assets_dir / dest_name if source_path.resolve() != dest_path.resolve(): shutil.copy2(source_path, dest_path) embedded = None if ( include_embedded_data and source_path.suffix.lower() in SUPPORTED_IMAGE_FORMATS ): with open(source_path, "rb") as file_handle: encoded = base64.b64encode(file_handle.read()).decode("utf-8") mime = ( mimetypes.guess_type(source_path.name) or ("image/png", None) )[0] embedded = f"data:{mime};base64,{encoded}" processed[asset.asset_id] = { "title": asset.title, "local_path": str(dest_path), "relative_path": f"{DIR_REPORT_ASSETS}/{dest_name}", "embedded_data": embedded, "description": asset.description, } return processed
def _build_asset_id(self, prefix: str, relative_path: Path) -> str: normalized = relative_path.as_posix() digest = sha1(normalized.encode("utf-8")).hexdigest()[:10] return f"{prefix}_{relative_path.stem}_{digest}" def _build_unique_dest_name( self, source_path: Path, asset_id: str, used_names: set[str], ) -> str: candidate = source_path.name if candidate not in used_names: used_names.add(candidate) return candidate suffix = source_path.suffix stem = source_path.stem unique_name = f"{stem}_{asset_id[-10:]}{suffix}" counter = 1 while unique_name in used_names: unique_name = f"{stem}_{asset_id[-10:]}_{counter}{suffix}" counter += 1 used_names.add(unique_name) return unique_name def _format_title(self, filename: str) -> str: title = filename.replace("_", " ").replace("-", " ") return " ".join(word.capitalize() for word in title.split())
AssetProcessor = AssetManager
[文档] class EDAGenerator: """Generate exploratory data analysis artifacts."""
[文档] def __init__(self): self.logger = logger
[文档] def resolve_table_selection( self, reader, tables: Optional[List[str]], ) -> Tuple[List[str], List[str], List[str]]: """Return requested, selected, and invalid table names.""" available_tables = reader.read_schema().tables if tables is None: return available_tables, available_tables, [] requested_tables: List[str] = [] seen = set() for table in tables: name = (table or "").strip() if not name or name in seen: continue seen.add(name) requested_tables.append(name) available_set = set(available_tables) selected_tables = [ table for table in requested_tables if table in available_set ] invalid_tables = [ table for table in requested_tables if table not in available_set ] return requested_tables, selected_tables, invalid_tables
def _resolve_tables(self, reader, tables: Optional[List[str]]) -> List[str]: _, selected_tables, _ = self.resolve_table_selection(reader, tables) return selected_tables
[文档] def generate_quick_stats( self, db_path: Path, max_rows: int = 5000, tables: Optional[List[str]] = None, ) -> Optional[str]: if not db_path.exists(): return None from .data import DataReader, DatabaseSchema reader = DataReader(db_path) schema = reader.read_schema() selected_tables = self._resolve_tables(reader, tables) filtered_schema = DatabaseSchema( tables=selected_tables, columns={table: schema.columns.get(table, []) for table in selected_tables}, row_counts={ table: schema.row_counts.get(table, 0) for table in selected_tables }, markdown=schema.markdown, ) stats = reader.compute_stats(filtered_schema) return stats.quick_stats_md
[文档] def generate_missingno_report( self, db_path: Path, output_dir: Path, max_rows: int = 50000, tables: Optional[List[str]] = None, ) -> Optional[Path]: """Generate a missing-value report with missingno.""" if not db_path.exists(): return None from .data import DataReader reader = DataReader(db_path) selected_tables = self._resolve_tables(reader, tables) sample = reader.read_sample_data(tables=selected_tables, limit=max_rows) if not sample: return None if pd is None: self.logger.warning("pandas 未安装,跳过 missingno 报告生成") return None all_dfs = [] for table_name, data in sample.items(): if data and len(data) > 0: df = pd.DataFrame(data) df.columns = [f"{table_name}.{col}" for col in df.columns] all_dfs.append(df) if not all_dfs: return None combined_df = pd.concat(all_dfs, axis=1) if len(combined_df.columns) > 50: missing_counts = combined_df.isnull().sum() top_missing = missing_counts.nlargest(50).index.tolist() combined_df = combined_df[top_missing] output_dir.mkdir(parents=True, exist_ok=True) try: import matplotlib.pyplot as plt import missingno as msno out_file = output_dir / "eda_missingno.html" _fig, axes = plt.subplots(2, 2, figsize=(16, 12)) try: msno.matrix(combined_df, ax=axes[0, 0], fontsize=8) axes[0, 0].set_title("Missing Value Matrix", fontsize=12) except Exception: axes[0, 0].text( 0.5, 0.5, "Matrix plot failed", ha="center", va="center" ) axes[0, 0].set_title("Missing Value Matrix (Error)") try: msno.bar(combined_df, ax=axes[0, 1], fontsize=8) axes[0, 1].set_title("Missing Value Bar Chart", fontsize=12) except Exception: axes[0, 1].text(0.5, 0.5, "Bar plot failed", ha="center", va="center") axes[0, 1].set_title("Missing Value Bar (Error)") try: if len(combined_df.columns) > 1: msno.heatmap(combined_df, ax=axes[1, 0], fontsize=8) axes[1, 0].set_title( "Missing Value Correlation Heatmap", fontsize=12 ) else: axes[1, 0].text( 0.5, 0.5, "Need >1 columns", ha="center", va="center" ) axes[1, 0].set_title("Correlation Heatmap (Skipped)") except Exception: axes[1, 0].text(0.5, 0.5, "Heatmap failed", ha="center", va="center") axes[1, 0].set_title("Correlation Heatmap (Error)") try: if len(combined_df.columns) > 1: msno.dendrogram(combined_df, ax=axes[1, 1], fontsize=8) axes[1, 1].set_title("Missing Value Dendrogram", fontsize=12) else: axes[1, 1].text( 0.5, 0.5, "Need >1 columns", ha="center", va="center" ) axes[1, 1].set_title("Dendrogram (Skipped)") except Exception: axes[1, 1].text(0.5, 0.5, "Dendrogram failed", ha="center", va="center") axes[1, 1].set_title("Dendrogram (Error)") plt.tight_layout() plt.savefig( str(out_file).replace(".html", ".png"), dpi=150, bbox_inches="tight" ) plt.close() html_content = f"""<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Missing Value Analysis (missingno)</title> <style> body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; }} h1 {{ color: #333; }} .summary {{ background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0; }} .stats {{ margin-top: 10px; }} img {{ max-width: 100%; height: auto; margin: 20px 0; border: 1px solid #ddd; }} </style> </head> <body> <h1>Missing Value Analysis</h1> <div class="summary"> <p><strong>Tool</strong>: missingno - Missing data visualization</p> <p><strong>Total Columns Analyzed</strong>: {len(combined_df.columns)}</p> <p><strong>Total Rows</strong>: {len(combined_df)}</p> <div class="stats"> <p><strong>Total Missing Values</strong>: {combined_df.isnull().sum().sum()}</p> <p><strong>Columns with Missing</strong>: {(combined_df.isnull().sum() > 0).sum()}</p> </div> </div> <img src="eda_missingno.png" alt="Missing Value Visualization"> </body> </html>""" out_file.write_text(html_content, encoding="utf-8") self.logger.info("生成 missingno 缺失值报告: %s", out_file) return out_file except ImportError: self.logger.warning("missingno 未安装,跳过缺失值可视化") return None except Exception as exc: self.logger.warning("missingno 生成失败: %s", exc) return None
[文档] def generate_correlation_report( self, db_path: Path, output_dir: Path, max_rows: int = 50000, tables: Optional[List[str]] = None, ) -> Optional[Path]: """Generate a correlation report.""" if not db_path.exists(): return None from .data import DataReader reader = DataReader(db_path) selected_tables = self._resolve_tables(reader, tables) sample = reader.read_sample_data(tables=selected_tables, limit=max_rows) if not sample: return None if pd is None: self.logger.warning("pandas 未安装,跳过相关性报告生成") return None output_dir.mkdir(parents=True, exist_ok=True) generated_files = [] for table_name, data in sample.items(): if not data or len(data) < 2: continue df = pd.DataFrame(data) numeric_df = df.select_dtypes(include=["number"]) if len(numeric_df.columns) < 2: continue try: import matplotlib.pyplot as plt import seaborn as sns corr_matrix = numeric_df.corr() _fig, ax = plt.subplots(figsize=(12, 10)) sns.heatmap( corr_matrix, annot=True, fmt=".2f", cmap="RdBu_r", center=0, square=True, linewidths=0.5, ax=ax, ) ax.set_title(f"Correlation Matrix: {table_name}", fontsize=14) safe_name = "".join( c if c.isalnum() or c in "_-" else "_" for c in table_name ) out_file = output_dir / f"correlation_{safe_name}.png" plt.tight_layout() plt.savefig(str(out_file), dpi=150, bbox_inches="tight") plt.close() generated_files.append((table_name, str(out_file))) self.logger.info( "生成相关性矩阵: %s (表: %s, %d 列)", out_file, table_name, len(numeric_df.columns), ) except Exception as exc: self.logger.warning("生成表 %s 的相关性矩阵失败: %s", table_name, exc) if not generated_files: return None index_file = output_dir / "correlation_index.html" rows = "\n".join( f'<tr><td>{name}</td><td><img src="{Path(path).name}" style="max-width:800px"></td></tr>' for name, path in generated_files ) html_content = f"""<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Correlation Analysis</title> <style> body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; }} h1 {{ color: #333; }} table {{ border-collapse: collapse; width: 100%; }} td {{ padding: 20px; border-bottom: 1px solid #ddd; }} img {{ max-width: 100%; }} </style> </head> <body> <h1>Correlation Analysis Report</h1> <table> <tr><th>Table</th><th>Correlation Matrix</th></tr> {rows} </table> </body> </html>""" index_file.write_text(html_content, encoding="utf-8") return index_file
[文档] def generate_ydata_profile( self, db_path: Path, output_dir: Path, max_rows: int = 10000, tables: Optional[List[str]] = None, ) -> Optional[Path]: """Generate a ydata-profiling report.""" if not db_path.exists(): return None from .data import DataReader reader = DataReader(db_path) selected_tables = self._resolve_tables(reader, tables) sample = reader.read_sample_data(tables=selected_tables, limit=max_rows) if not sample: return None if pd is None: self.logger.warning("pandas 未安装,跳过 ydata-profiling 报告生成") return None non_empty_tables = { table_name: data for table_name, data in sample.items() if data and len(data) > 0 } if not non_empty_tables: return None output_dir.mkdir(parents=True, exist_ok=True) try: from ydata_profiling import ProfileReport if len(non_empty_tables) == 1: table_name = next(iter(non_empty_tables.keys())) df = pd.DataFrame(non_empty_tables[table_name]) out_file = output_dir / "eda_profile.html" profile = ProfileReport(df, title=f"EDA: {table_name}", minimal=True) profile.to_file(str(out_file)) self.logger.info( "生成 ydata-profiling 报告: %s (表: %s, %d 行)", out_file, table_name, len(df), ) return out_file generated_files = [] for table_name, data in non_empty_tables.items(): df = pd.DataFrame(data) if df.empty: continue safe_name = "".join( c if c.isalnum() or c in "_-" else "_" for c in table_name ) table_file = output_dir / f"eda_profile_{safe_name}.html" try: profile = ProfileReport( df, title=f"EDA: {table_name}", minimal=True ) profile.to_file(str(table_file)) generated_files.append((table_name, table_file.name, len(df))) self.logger.info( "生成 ydata-profiling 表报告: %s (表: %s, %d 行)", table_file, table_name, len(df), ) except Exception as exc: self.logger.warning( "生成表 %s 的 EDA 报告失败: %s", table_name, exc ) if not generated_files: return None index_file = output_dir / "eda_profile.html" index_content = self._build_eda_index_html( generated_files, "ydata-profiling" ) index_file.write_text(index_content, encoding="utf-8") self.logger.info( "生成 EDA 索引页: %s (%d 张表)", index_file, len(generated_files) ) return index_file except Exception as exc: self.logger.debug("ydata-profiling 生成失败: %s", exc) return None
def _build_eda_index_html( self, table_files: List[Tuple[str, str, int]], tool_name: str, ) -> str: rows = "\n".join( f'<tr><td><a href="{filename}">{name}</a></td><td class="num">{row_count}</td></tr>' for name, filename, row_count in table_files ) subtitle = f"{tool_name} · 选择表打开完整画像" header = brand_header_html( "交互式 EDA 索引", subtitle, variant="hub", lang="zh", ) return f"""<!DOCTYPE html> <html lang="zh"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>EDA Index ({tool_name})</title> {html_font_links()} <style>{eda_index_page_css()}</style> </head> <body> <div class="index-shell"> {header} <div class="index-body"> <p class="panel-intro">分析报告可通过 <code>iframe</code> 嵌入下方各表画像。</p> <table> <tr><th>表</th><th class="num">行数</th></tr> {rows} </table> </div> </div> </body> </html>"""
[文档] def generate_sweetviz_profile( self, db_path: Path, output_dir: Path, max_rows: int = 10000, tables: Optional[List[str]] = None, ) -> Optional[Path]: """Generate a Sweetviz report.""" if not db_path.exists(): return None from .data import DataReader reader = DataReader(db_path) selected_tables = self._resolve_tables(reader, tables) sample = reader.read_sample_data(tables=selected_tables, limit=max_rows) if not sample: return None if pd is None: self.logger.warning("pandas 未安装,跳过 Sweetviz 报告生成") return None non_empty_tables = { table_name: data for table_name, data in sample.items() if data and len(data) > 0 } if not non_empty_tables: return None output_dir.mkdir(parents=True, exist_ok=True) try: import sweetviz as sv if len(non_empty_tables) == 1: table_name = next(iter(non_empty_tables.keys())) df = pd.DataFrame(non_empty_tables[table_name]) out_file = output_dir / "eda_sweetviz.html" report = sv.analyze(df) report.show_html(str(out_file), open_browser=False) if out_file.exists(): self.logger.info( "生成 Sweetviz 报告: %s (表: %s, %d 行)", out_file, table_name, len(df), ) return out_file return None generated_files = [] for table_name, data in non_empty_tables.items(): df = pd.DataFrame(data) if df.empty: continue safe_name = "".join( c if c.isalnum() or c in "_-" else "_" for c in table_name ) table_file = output_dir / f"eda_sweetviz_{safe_name}.html" try: report = sv.analyze(df) report.show_html(str(table_file), open_browser=False) if table_file.exists(): generated_files.append((table_name, table_file.name, len(df))) self.logger.info( "生成 Sweetviz 表报告: %s (表: %s, %d 行)", table_file, table_name, len(df), ) except Exception as exc: self.logger.warning( "生成表 %s 的 Sweetviz 报告失败: %s", table_name, exc ) if not generated_files: return None index_file = output_dir / "eda_sweetviz.html" index_content = self._build_eda_index_html(generated_files, "Sweetviz") index_file.write_text(index_content, encoding="utf-8") self.logger.info( "生成 Sweetviz EDA 索引页: %s (%d 张表)", index_file, len(generated_files), ) return index_file except Exception as exc: self.logger.debug("Sweetviz 生成失败: %s", exc) return None
def _read_non_empty_tables( self, db_path: Path, output_dir: Path, max_rows: int, tables: Optional[List[str]], ) -> Dict[str, "pd.DataFrame"]: if pd is None or not db_path.exists(): return {} from .data import DataReader reader = DataReader(db_path) selected_tables = self._resolve_tables(reader, tables) sample = reader.read_sample_data(tables=selected_tables, limit=max_rows) frames: Dict[str, pd.DataFrame] = {} for table_name, data in (sample or {}).items(): if not data: continue df = pd.DataFrame(data) if not df.empty: frames[table_name] = df return frames
[文档] def generate_pygwalker_profile( self, db_path: Path, output_dir: Path, max_rows: int = 10000, tables: Optional[List[str]] = None, ) -> Optional[Path]: frames = self._read_non_empty_tables(db_path, output_dir, max_rows, tables) if not frames: return None output_dir.mkdir(parents=True, exist_ok=True) try: import pygwalker as pyg except ImportError: self.logger.warning("pygwalker 未安装,跳过拖拽探索报告") return None generated: List[Tuple[str, str, int]] = [] for table_name, df in frames.items(): safe_name = "".join( c if c.isalnum() or c in "_-" else "_" for c in table_name ) out_file = output_dir / f"eda_pygwalker_{safe_name}.html" html = pyg.to_html(df, embed_lib=True) out_file.write_text(html, encoding="utf-8") generated.append((table_name, out_file.name, len(df))) self.logger.info("生成 PyGWalker 报告: %s", out_file) if not generated: return None if len(generated) == 1: single = output_dir / generated[0][1] hub = output_dir / "eda_pygwalker.html" if hub != single: hub.write_text(single.read_text(encoding="utf-8"), encoding="utf-8") return hub index_file = output_dir / "eda_pygwalker.html" index_file.write_text( self._build_eda_index_html(generated, "PyGWalker"), encoding="utf-8" ) return index_file
[文档] def generate_datatable_profile( self, db_path: Path, output_dir: Path, max_rows: int = 5000, tables: Optional[List[str]] = None, ) -> Optional[Path]: frames = self._read_non_empty_tables(db_path, output_dir, max_rows, tables) if not frames: return None output_dir.mkdir(parents=True, exist_ok=True) generated: List[Tuple[str, str, int]] = [] for table_name, df in frames.items(): safe_name = "".join( c if c.isalnum() or c in "_-" else "_" for c in table_name ) out_file = output_dir / f"eda_datatable_{safe_name}.html" out_file.write_text( self._build_sortable_datatable_html(table_name, df), encoding="utf-8", ) generated.append((table_name, out_file.name, len(df))) self.logger.info("生成可排序数据表: %s", out_file) index_file = output_dir / "eda_datatable.html" if len(generated) == 1: index_file.write_text( (output_dir / generated[0][1]).read_text(encoding="utf-8"), encoding="utf-8", ) else: index_file.write_text( self._build_eda_index_html(generated, "Interactive tables"), encoding="utf-8", ) return index_file
def _build_sortable_datatable_html( self, table_name: str, df: "pd.DataFrame" ) -> str: preview = df.head(500) esc = html_module.escape headers = "".join(f"<th>{esc(str(col))}</th>" for col in preview.columns) rows = [] for _, row in preview.iterrows(): cells = "".join(f"<td>{esc(str(row[col]))}</td>" for col in preview.columns) rows.append(f"<tr>{cells}</tr>") body = "\n".join(rows) truncated = len(df) > len(preview) note = ( f"显示前 {len(preview)} / {len(df)} 行(完整数据见 sqlite)" if truncated else f"共 {len(df)} 行" ) return f"""<!DOCTYPE html> <html lang="zh"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Table: {esc(table_name)}</title> {html_font_links()} <style>{eda_datatable_css()}</style> </head> <body> <div class="standalone-wrap"> {brand_header_html(esc(table_name), esc(note), variant="standalone", lang="zh")} <div class="standalone-body"> <p class="meta">{note}</p> <div class="toolbar"> <input type="search" id="filter" placeholder="筛选任意列…" /> </div> <div class="wrap"> <table id="tbl"> <thead><tr>{headers}</tr></thead> <tbody id="tbody">{body}</tbody> </table> </div> <script> (function () {{ var tbl = document.getElementById("tbl"); var filter = document.getElementById("filter"); var tbody = document.getElementById("tbody"); var rows = Array.prototype.slice.call(tbody.querySelectorAll("tr")); filter.addEventListener("input", function () {{ var q = filter.value.toLowerCase(); rows.forEach(function (tr) {{ tr.style.display = tr.textContent.toLowerCase().indexOf(q) >= 0 ? "" : "none"; }}); }}); tbl.querySelectorAll("th").forEach(function (th, idx) {{ th.addEventListener("click", function () {{ var asc = th.dataset.asc !== "1"; tbl.querySelectorAll("th").forEach(function (h) {{ delete h.dataset.asc; }}); th.dataset.asc = asc ? "1" : "0"; rows.sort(function (a, b) {{ var av = (a.children[idx] && a.children[idx].textContent) || ""; var bv = (b.children[idx] && b.children[idx].textContent) || ""; var an = parseFloat(av), bn = parseFloat(bv); if (!isNaN(an) && !isNaN(bn)) return asc ? an - bn : bn - an; return asc ? av.localeCompare(bv) : bv.localeCompare(av); }}); rows.forEach(function (tr) {{ tbody.appendChild(tr); }}); }}); }}); }})(); </script> </div></div> </body> </html>"""
[文档] def generate_plotly_profile( self, db_path: Path, output_dir: Path, max_rows: int = 8000, tables: Optional[List[str]] = None, ) -> Optional[Path]: frames = self._read_non_empty_tables(db_path, output_dir, max_rows, tables) if not frames: return None output_dir.mkdir(parents=True, exist_ok=True) try: import plotly.express as px except ImportError: self.logger.warning("plotly 未安装,跳过 Plotly 概览") return None generated: List[Tuple[str, str, int]] = [] for table_name, df in frames.items(): numeric = df.select_dtypes(include=["number"]) if numeric.shape[1] < 1: continue cols = list(numeric.columns[:8]) safe_name = "".join( c if c.isalnum() or c in "_-" else "_" for c in table_name ) out_file = output_dir / f"eda_plotly_{safe_name}.html" if len(cols) >= 2: fig = px.scatter_matrix( numeric[cols], dimensions=cols, title=f"Numeric overview: {table_name}", ) else: fig = px.histogram( numeric, x=cols[0], title=f"Distribution: {table_name}.{cols[0]}", ) export_plotly_html(fig, out_file) generated.append((table_name, out_file.name, len(df))) self.logger.info("生成 Plotly 概览: %s", out_file) if not generated: return None index_file = output_dir / "eda_plotly.html" index_file.write_text( self._build_eda_index_html(generated, "Plotly"), encoding="utf-8" ) return index_file
[文档] def generate_eda_hub( self, output_dir: Path, *, title: str = "AgentSociety EDA Hub", ) -> Path: output_dir.mkdir(parents=True, exist_ok=True) assets_dir = output_dir.parent / "assets" ensure_brand_icon(assets_dir) entries = [] for spec in EDA_HUB_ENTRIES: path = output_dir / spec["file"] if path.is_file(): entries.append(spec) hub_path = output_dir / "eda_hub.html" hub_path.write_text( self._build_eda_hub_html(entries, title=title, lang="zh"), encoding="utf-8", ) self.logger.info("生成 EDA Hub: %s (%d 入口)", hub_path, len(entries)) return hub_path
def _build_eda_hub_html( self, entries: List[Dict[str, str]], *, title: str, lang: str = "zh", ) -> str: zh = lang.startswith("zh") subtitle = ( "多模式交互式数据探索 · 嵌入报告请使用 data/eda_hub.html" if zh else "Multi-mode interactive EDA · embed via data/eda_hub.html" ) header = brand_header_html(title, subtitle, variant="hub", lang=lang) if not entries: body = '<p class="hub-empty">暂无 EDA 产物。请先运行 <code>run-eda --type bundle</code>。</p>' else: tab_buttons: List[str] = [] tab_panels: List[str] = [] for idx, spec in enumerate(entries): active = " active" if idx == 0 else "" tab_id = spec["key"] label = spec["zh"] if zh else spec["en"] desc = spec["desc_zh"] if zh else spec["desc_en"] icon = spec["icon"] tab_buttons.append( f'<button type="button" class="tab{active}" data-tab="{tab_id}" ' f'role="tab" aria-selected="{"true" if idx == 0 else "false"}">' f'<span class="tab-icon" aria-hidden="true">{icon}</span>' f"<span>{label}</span></button>" ) if spec["mode"] == "markdown": md_path = spec["file"] body_inner = ( f'<p class="panel-intro"><strong>{label}</strong> — {desc}</p>' f'<p class="iframe-hint">' f'<a href="{md_path}" target="_blank" rel="noopener">' f'{"打开 Markdown" if zh else "Open Markdown"}</a></p>' ) else: open_lbl = "新标签页打开" if zh else "Open in new tab" body_inner = ( f'<p class="panel-intro"><strong>{label}</strong> — {desc}</p>' f'<p class="iframe-hint"><a href="{spec["file"]}" target="_blank" rel="noopener">{open_lbl}</a></p>' f'<iframe class="eda-frame" src="{spec["file"]}" title="{label}" loading="lazy"></iframe>' ) tab_panels.append( f'<div class="tab-panel{active} {spec["panel"]}" data-panel="{tab_id}" role="tabpanel">' f"{body_inner}</div>" ) tabs_html = ( f'<div class="tab-root"><div class="tab-bar" role="tablist">{"".join(tab_buttons)}</div>' f'{"".join(tab_panels)}</div>' ) body = tabs_html return f"""<!DOCTYPE html> <html lang="{"zh" if zh else "en"}"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{title}</title> {html_font_links()} <style>{eda_hub_page_css()}</style> </head> <body> <div class="hub-shell"> {header} <div class="hub-body">{body}</div> </div> {html_tab_switcher_script()} </body> </html>"""
[文档] def generate_eda_bundle( self, db_path: Path, output_dir: Path, profiles: Optional[List[str]] = None, tables: Optional[List[str]] = None, max_rows: int = 10000, ) -> Tuple[List[str], Optional[Path]]: default_profiles = [ "quick-stats", "ydata", "pygwalker", "datatable", "plotly-profile", ] active = profiles or default_profiles output_dir.mkdir(parents=True, exist_ok=True) ensure_brand_icon(output_dir.parent / "assets") files: List[str] = [] runners = { "quick-stats": lambda: self._bundle_quick_stats( db_path, output_dir, tables ), "ydata": lambda: self.generate_ydata_profile( db_path, output_dir, max_rows=max_rows, tables=tables ), "sweetviz": lambda: self.generate_sweetviz_profile( db_path, output_dir, max_rows=max_rows, tables=tables ), "missingno": lambda: self.generate_missingno_report( db_path, output_dir, tables=tables ), "correlation": lambda: self.generate_correlation_report( db_path, output_dir, tables=tables ), "pygwalker": lambda: self.generate_pygwalker_profile( db_path, output_dir, max_rows=max_rows, tables=tables ), "datatable": lambda: self.generate_datatable_profile( db_path, output_dir, max_rows=max_rows, tables=tables ), "plotly-profile": lambda: self.generate_plotly_profile( db_path, output_dir, max_rows=max_rows, tables=tables ), } for name in active: if name in ("eda-hub", "bundle"): continue runner = runners.get(name) if not runner: continue result = runner() if name == "quick-stats": qs = output_dir / "eda_quick_stats.md" if qs.exists(): files.append(str(qs)) elif result is not None: files.append(str(result)) hub = self.generate_eda_hub(output_dir) files.append(str(hub)) return files, hub
def _bundle_quick_stats( self, db_path: Path, output_dir: Path, tables: Optional[List[str]], ) -> Optional[str]: content = self.generate_quick_stats(db_path, tables=tables) if content is None: return None path = output_dir / "eda_quick_stats.md" path.write_text(content, encoding="utf-8") return content