89 lines
4.2 KiB
Python
89 lines
4.2 KiB
Python
|
|
"""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 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)
|