diff --git a/solving/Solver-Prompt.txt b/solving/Solver-Prompt.txt index abd22c5..d0194f4 100644 --- a/solving/Solver-Prompt.txt +++ b/solving/Solver-Prompt.txt @@ -59,6 +59,7 @@ Geldbeträge und Rundung: Ergebnisausgabe: - Die Funktion `report(status, objective_value, solution, tables, headline, constraints, variables, sensitivity, relaxation_bound, multistart, graph, answers, scenarios)` ist bereits vordefiniert (sie gibt das Ergebnis lesbar und maschinenlesbar aus). Definiere sie NICHT selbst und importiere nichts dafür. +- Schreibweise: Bezeichner, `DATA`-Schlüssel, Restriktionsnamen und Kommentare dürfen Umlaute umschreiben (`Kapazitaet_M1`) – das wird nicht beanstandet. **Lesertexte** dagegen erscheinen im Report und stehen in echtem Deutsch mit ä, ö, ü, ß: `headline`, `answers`, Tabellen (`title`, `columns`, Textzellen, `note`), `scenarios` (`name`, `description`) und `graph` (`node_meaning`, `arc_meaning`). Die Datei ist UTF-8. [→ repair_texts] - Rufe `report(...)` genau einmal auf, innerhalb von `main()`: - status: "optimal", "feasible", "infeasible", "unbounded" oder "error" - objective_value: Wert der Zielfunktion der Spezifikation (im ursprünglichen Sinn/Maßstab) als float, oder None @@ -122,5 +123,4 @@ Ergebnisausgabe: 3. "Bindende Restriktionen": welche Grenzen die Lösung ausreizt, mit Spalten ["Restriktion", "Wert", "Grenze", "bindend"] und Werten aus der Lösung (z. B. "Höchstgewicht A02" | "30,00 %" | "30,00 %" | "ja"). Nur Tatsachen aus der Lösung, keine Bewertung; ohne objective_column. Zahlen in der objective_column immer im Maßstab von objective_value (nicht in Prozent umrechnen); Datenspalten dagegen in lesbaren Einheiten als Text ("1,63 %", "0,74 %", "36 h"). - Weitere print-Ausgaben sind erlaubt, aber nicht nötig. -- Alle Texte auf Deutsch mit echten Umlauten (ä, ö, ü, ß), nie 'ae'/'oe'/'ue'; die Dateien sind UTF-8. [→ check_text_quality] - Liefere im Feld code nur den reinen Python-Code ohne Markdown-Codeblock. diff --git a/solving/solver_stage.py b/solving/solver_stage.py index ec66b4a..c0a4f2a 100644 --- a/solving/solver_stage.py +++ b/solving/solver_stage.py @@ -586,38 +586,61 @@ UMLAUT_STEMS = { "beruecksicht": "berücksicht", "erfuell": "erfüll", "gemaess": "gemäß", "taeglich": "täglich", "jaehrl": "jährl", "kuerzest": "kürzest", "laenge": "länge", "staerk": "stärk", "hoeh": "höh", "fruehest": "frühest", "spaetest": "spätest", "baell": "bäll", "heisst": "heißt", + "betraeg": "beträg", "ausfuehr": "ausführ", "abzueg": "abzüg", "enthaelt": "enthält", "tatsaechl": "tatsächl", } -TRANSLITERATIONS = re.compile(r"\b\w*(?:" + "|".join(UMLAUT_STEMS) + r")\w*\b", re.IGNORECASE) _STEM_PATTERN = re.compile("|".join(UMLAUT_STEMS), re.IGNORECASE) -def fix_transliterations(text: str) -> str: - """'guenstigste' -> 'günstigste'. Die Warnung bleibt; der Report soll trotzdem lesbar sein. +_UMLAUT_WORD = re.compile(r"\b\w*[äöüÄÖÜß]\w*\b") +_ASCII = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"}) - Der Solver-Prompt verlangt echte Umlaute, das LLM hält sich nicht daran (in fast jedem Lauf - mindestens einmal beobachtet). Eine Warnung, die nichts bewirkt, hilft dem Leser nicht. + +def umlaut_map(aufgabe: str, *weitere: str) -> dict: + """Wörterbuch aus dem Lauf selbst: Umschreibung -> Original, z. B. 'volatilitaet' -> 'volatilität'. + + Das LLM schreibt im erzeugten Python-Code ASCII (beobachtet: Code-Strings 0 echte gegen 60 + umschriebene Umlaute, dieselbe Spezifikation 128 echte). Eine feste Stammliste läuft jeder neuen + Aufgabe hinterher; die Wörter, die im Report vorkommen, stehen aber mit Umlaut in Aufgabe und + Spezifikation. Steht die ASCII-Form im Aufgabentext selbst (Namen wie „Mueller"), wird sie nicht + ersetzt. Geprüft wird dafür nur die Aufgabe: die Spezifikation enthält ASCII-Bezeichner des LLM + (beobachtet: Restriktion `Kardinalitaet` verhinderte die Korrektur von „Kardinalitaet“ im Text). """ - def ersetze(treffer): - richtig = UMLAUT_STEMS[treffer.group(0).lower()] - return richtig.capitalize() if treffer.group(0)[0].isupper() else richtig + quelle = " ".join(t or "" for t in (aufgabe, *weitere)) + vorhanden = {w.lower() for w in re.findall(r"\w+", aufgabe or "")} + zuordnung = {} + for wort in {m.group(0) for m in _UMLAUT_WORD.finditer(quelle)}: + umschrieben = wort.translate(_ASCII).lower() + if len(wort) >= 4 and umschrieben not in vorhanden: + zuordnung[umschrieben] = wort.lower() + return zuordnung - return _STEM_PATTERN.sub(ersetze, text or "") + +def fix_transliterations(text: str, extra: dict | None = None) -> str: + """'guenstigste' -> 'günstigste': erst Wörter aus dem Lauf (`umlaut_map`), dann die feste Stammliste. + + Nur für Lesertexte. Bezeichner und Restriktionsnamen bleiben ASCII – der Namensabgleich hängt daran. + """ + def gross_wie(vorlage, richtig): + return richtig[:1].upper() + richtig[1:] if vorlage[:1].isupper() else richtig + + text = text or "" + if extra: + muster = re.compile(r"\b(?:" + "|".join(sorted(map(re.escape, extra), key=len, reverse=True)) + r")\b", + re.IGNORECASE) + text = muster.sub(lambda t: gross_wie(t.group(0), extra[t.group(0).lower()]), text) + return _STEM_PATTERN.sub(lambda t: gross_wie(t.group(0), UMLAUT_STEMS[t.group(0).lower()]), text) def check_text_quality(run, summary: str = "") -> list: - """Texte für Menschen prüfen (ohne LLM): umschriebene Umlaute und unerklärte Fachbegriffe. + """Texte für Menschen prüfen (ohne LLM): unerklärte Fachbegriffe in Antwortsatz und Tabellen. - Der Report ist ein Dokument für Entscheider; 'guenstigste' statt 'günstigste' gehört dort nicht hin, - und ein Fachbegriff in einer Tabellenspalte muss vorher erklärt worden sein. + Umschriebene Umlaute werden nicht mehr gemeldet, sondern still berichtigt (`repair_texts`): + die Warnung stand in fast jedem Lauf und bewirkte nichts – im Code sind Umschreibungen erlaubt. """ texte = [("headline", run.headline or "")] for table in run.tables: texte.append((f"Tabelle '{table.title}'", " ".join([table.title, *map(str, table.columns), table.note or ""]))) befunde = [] - for wo, text in texte: - woerter = sorted({m.group(0) for m in TRANSLITERATIONS.finditer(text)}) - if woerter: - befunde.append(f"{wo}: Umlaute umschrieben statt ausgeschrieben ({', '.join(woerter)}).") if summary: offen = unexplained_jargon(" ".join(text for _, text in texte), summary) if offen: @@ -711,23 +734,23 @@ def check_generated_data(code: str, solver_code, spec=None) -> list: "Ergebnis nicht anzusehen, dass es nicht auf den Daten der Aufgabe beruht."] -def repair_texts(run) -> None: - """Umschriebene Umlaute in allen Texten für Menschen berichtigen (nach der Prüfung).""" - run.headline = fix_transliterations(run.headline) if run.headline else run.headline +def repair_texts(run, extra: dict | None = None) -> None: + """Umschriebene Umlaute in allen Texten für Menschen berichtigen; `extra` aus `umlaut_map`.""" + run.headline = fix_transliterations(run.headline, extra) if run.headline else run.headline for antwort in run.answers: - antwort.text = fix_transliterations(antwort.text) + antwort.text = fix_transliterations(antwort.text, extra) for tabelle in run.tables: - tabelle.title = fix_transliterations(tabelle.title) - tabelle.columns = [fix_transliterations(c) for c in tabelle.columns] - tabelle.note = fix_transliterations(tabelle.note) if tabelle.note else tabelle.note - tabelle.rows = [[fix_transliterations(z) if isinstance(z, str) else z for z in zeile] + tabelle.title = fix_transliterations(tabelle.title, extra) + tabelle.columns = [fix_transliterations(c, extra) for c in tabelle.columns] + tabelle.note = fix_transliterations(tabelle.note, extra) if tabelle.note else tabelle.note + tabelle.rows = [[fix_transliterations(z, extra) if isinstance(z, str) else z for z in zeile] for zeile in tabelle.rows] for szenario in run.scenarios: - szenario.name = fix_transliterations(szenario.name) - szenario.description = fix_transliterations(szenario.description) + szenario.name = fix_transliterations(szenario.name, extra) + szenario.description = fix_transliterations(szenario.description, extra) if run.graph is not None: - run.graph.node_meaning = fix_transliterations(run.graph.node_meaning) - run.graph.arc_meaning = fix_transliterations(run.graph.arc_meaning) + run.graph.node_meaning = fix_transliterations(run.graph.node_meaning, extra) + run.graph.arc_meaning = fix_transliterations(run.graph.arc_meaning, extra) def check_answers(run, understanding, task: str = "", data_summary: str = "") -> list: @@ -821,7 +844,9 @@ def _check_and_run(code: str, timeout: int, spec: OptimizationModelSpecification run.warnings += check_multistart(run, spec.objective.sense.value) run.warnings += check_graph(run, spec.objective.sense.value) run.warnings += check_text_quality(run, spec.executive_summary) - repair_texts(run) # erst melden, dann beheben: der Report soll lesbar sein + # Umlaute still berichtigen – mit den Wörtern, die in Aufgabe und Spezifikation mit Umlaut stehen + repair_texts(run, umlaut_map(task, spec.model_dump_json(), + understanding.model_dump_json() if understanding else "")) run.warnings += check_answers(run, understanding, task, data_summary) run.warnings += verify_scenarios(run, code, timeout, data_files) return run diff --git a/tests/test_result_tables.py b/tests/test_result_tables.py index 541d55c..0551d7d 100644 --- a/tests/test_result_tables.py +++ b/tests/test_result_tables.py @@ -156,15 +156,15 @@ def test_report_shows_multistart_stability(): assert "nicht nachweislich das globale Optimum" in markdown -def test_transliterated_umlauts_are_reported(): - # beobachtet: "Der guenstigste Ersatzplan", Tabelle "Gewaehlte Nutzungszeitraeume" +def test_transliterated_umlauts_are_no_longer_reported(): + # Umschreibungen im Code sind erlaubt; Lesertexte werden still berichtigt statt gemeldet from core.model_schema import ResultTable, SolverRun, SolverStatus from solving.solver_stage import check_text_quality run = SolverRun(status=SolverStatus.OPTIMAL, headline="Der guenstigste Plan kostet 29 €.", tables=[ResultTable(title="Gewaehlte Zeitraeume", columns=["Kauf"], rows=[])]) - befunde = check_text_quality(run) - assert any("guenstigste" in b for b in befunde) - assert any("Gewaehlte" in b and "Zeitraeume" in b for b in befunde) + assert check_text_quality(run) == [] + befunde = check_text_quality(run, summary="Es wird ein Plan gewählt.") + assert not any("Umlaut" in b for b in befunde) def test_correct_german_is_not_flagged(): @@ -177,16 +177,14 @@ def test_correct_german_is_not_flagged(): assert check_text_quality(run) == [] -def test_transliterations_are_repaired_after_being_reported(): - # Die Warnung dokumentiert den Prompt-Verstoss, der Report soll trotzdem lesbar sein +def test_transliterations_are_repaired_silently(): from core.model_schema import Answer, ResultTable, SolverRun, SolverStatus - from solving.solver_stage import check_text_quality, fix_transliterations, repair_texts + from solving.solver_stage import repair_texts run = SolverRun(status=SolverStatus.OPTIMAL, headline="Der guenstigste Plan kostet 29 €.", answers=[Answer(question_id="a", text="Die Loesung waehlt zwei Aktien.")], tables=[ResultTable(title="Gewaehlte Zeitraeume", columns=["Naechste Periode"], rows=[["gewaehlt", 3]], note="Gemaess Aufgabe.")]) - assert check_text_quality(run) # erst melden - repair_texts(run) # dann beheben + repair_texts(run) assert run.headline == "Der günstigste Plan kostet 29 €." assert run.answers[0].text == "Die Lösung wählt zwei Aktien." assert run.tables[0].title == "Gewählte Zeiträume" @@ -205,3 +203,28 @@ def test_repair_covers_stems_seen_in_the_lot_sizing_case(): # beobachtet in wagner_whitin_dijkstra: "110 Baelle", "Das heisst:" im Antworttext from solving.solver_stage import fix_transliterations assert fix_transliterations("Das heisst: 110 Baelle, der Ball") == "Das heißt: 110 Bälle, der Ball" + + +def test_words_from_the_run_repair_what_the_fixed_list_misses(): + # beobachtet in "Portfolio Nr. 2": Volatilitaet, Kardinalitaet, vollstaendigen, binaere im Report + from core.model_schema import Answer, ConstraintValue, SolverRun, SolverStatus + from solving.solver_stage import repair_texts, umlaut_map + aufgabe = "Datenlieferant: Mueller GmbH." + spezifikation = ("Minimiere die Volatilität unter der Kardinalität k; nur Titel mit vollständigen Reihen, " + "binäre Auswahl. Restriktion: Kardinalitaet") # ASCII-Bezeichner des LLM + run = SolverRun(status=SolverStatus.OPTIMAL, + headline="Volatilitaet 12 %, Kardinalitaet eingehalten.", + answers=[Answer(question_id="1", text="Nur Titel mit vollstaendigen Reihen, binaere Variablen; Quelle: Mueller GmbH.")], + constraints=[ConstraintValue(name="Kardinalitaet", lhs=20, sense="==", rhs=20)]) + repair_texts(run, umlaut_map(aufgabe, spezifikation)) + assert run.headline == "Volatilität 12 %, Kardinalität eingehalten." + assert run.answers[0].text == "Nur Titel mit vollständigen Reihen, binäre Variablen; Quelle: Mueller GmbH." + assert run.constraints[0].name == "Kardinalitaet" # Bezeichner bleibt: Namensabgleich hängt daran + + +def test_ascii_form_present_in_the_task_is_not_replaced(): + # steht die ASCII-Form selbst im Text (z. B. ein Name), ist sie echt + from solving.solver_stage import fix_transliterations, umlaut_map + zuordnung = umlaut_map("Herr Müller und die Firma Mueller liefern.") + assert "mueller" not in zuordnung + assert fix_transliterations("Firma Mueller", zuordnung) == "Firma Mueller"