"""Profil beigelegter Datendateien – Zeilen, Spalten, Typen, Spannweiten, Lücken. Bewusst **ohne LLM**: Das Fremdprojekt MM-Agent lässt eine Datenbeschreibung vom Modell schreiben (`DataDescription.summary`). Gerechnet ist billiger, reproduzierbar und nicht halluzinierbar – und das Profil ist damit selbst eine geprüfte Tatsache, auf die sich die späteren Stufen berufen dürfen. """ from pathlib import Path import pandas as pd from core.model_schema import ColumnProfile, DataFileProfile SAMPLE_ROWS = 3 # so viele Beispielwerte je Spalte in den Prompt MAX_COLUMNS = 40 # breitere Tabellen werden abgeschnitten, damit der Prompt nicht platzt def _read(path: Path) -> pd.DataFrame: """CSV (mit Trennzeichen-Erkennung) oder Excel einlesen.""" if path.suffix.lower() in (".xlsx", ".xls"): return pd.read_excel(path) # sep=None + engine="python" erkennt Komma, Semikolon und Tabulator selbst return pd.read_csv(path, sep=None, engine="python") def _as_numbers(spalte: pd.Series) -> pd.Series: """Zahlen einer Spalte, auch bei deutscher Schreibweise ('1.234,56' als Text eingelesen).""" zahlen = pd.to_numeric(spalte, errors="coerce") vorhanden = spalte.notna().sum() if vorhanden and zahlen.notna().sum() < vorhanden and pd.api.types.is_string_dtype(spalte): deutsch = pd.to_numeric( spalte.astype(str).str.replace(".", "", regex=False).str.replace(",", ".", regex=False), errors="coerce") if deutsch.notna().sum() > zahlen.notna().sum(): return deutsch return zahlen def _column(name: str, spalte: pd.Series) -> ColumnProfile: zahlen = _as_numbers(spalte) # Eine Spalte gilt als Zahlenspalte, wenn die große Mehrheit der belegten Werte Zahlen sind vorhanden = max(int(spalte.notna().sum()), 1) numerisch = zahlen.notna().sum() >= 0.8 * vorhanden and not pd.api.types.is_bool_dtype(spalte) beispiele = [str(v) for v in spalte.dropna().unique()[:SAMPLE_ROWS]] return ColumnProfile( name=str(name), dtype="Zahl" if numerisch else "Text", missing=int(spalte.isna().sum()), minimum=float(zahlen.min()) if numerisch and zahlen.notna().any() else None, maximum=float(zahlen.max()) if numerisch and zahlen.notna().any() else None, mean=float(zahlen.mean()) if numerisch and zahlen.notna().any() else None, samples=beispiele, ) def profile_file(path) -> DataFileProfile: """Eine Datei einlesen und ihr Profil bestimmen; unlesbare Dateien melden den Fehler.""" path = Path(path) try: tabelle = _read(path) except Exception as exc: # kaputte Datei darf den Lauf nicht abbrechen return DataFileProfile(name=path.name, rows=0, columns=[], error=f"{type(exc).__name__}: {exc}") spalten = [_column(name, tabelle[name]) for name in list(tabelle.columns)[:MAX_COLUMNS]] return DataFileProfile(name=path.name, rows=int(len(tabelle)), columns=spalten, truncated_columns=max(0, len(tabelle.columns) - MAX_COLUMNS)) def profile_files(paths) -> list: return [profile_file(p) for p in paths or []] def to_prompt(profiles) -> str: """Das Profil als knapper Text für die Prompts – Spaltennamen sind das Wichtigste.""" if not profiles: return "" teile = ["BEIGELEGTE DATEN (deterministisch ermittelt, nicht geschätzt):"] for p in profiles: if p.error: teile.append(f"- {p.name}: NICHT LESBAR ({p.error})") continue teile.append(f"- {p.name}: {p.rows} Zeilen, {len(p.columns)} Spalten" + (f" (weitere {p.truncated_columns} Spalten nicht gezeigt)" if p.truncated_columns else "")) for s in p.columns: spanne = (f", von {s.minimum:g} bis {s.maximum:g}, Mittel {s.mean:g}" if s.minimum is not None else "") luecken = f", {s.missing} fehlend" if s.missing else "" teile.append(f" {s.name} ({s.dtype}{spanne}{luecken}): {', '.join(s.samples)}") teile.append("Lies diese Dateien im Programm über ihren Dateinamen (sie liegen im Arbeitsverzeichnis). " "Jede abgeleitete Kennzahl wird aus den Daten berechnet, nie geschätzt.") return "\n".join(teile)