Übersetzt natürlichsprachige Optimierungsaufgaben (Deutsch) per LLM in eine geprüfte Modellspezifikation, erzeugt Solver-Code und führt ihn in einer bubblewrap-Sandbox aus. Deterministische Prüfungen ohne LLM, Report als Markdown/HTML, Benchmark mit 19 verifizierten Fällen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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)
|