diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index b4797d3..0c3fc91 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -375,6 +375,8 @@ Ist das Modell gut, aber das Programm gescheitert, muss nicht alles neu bezahlt - Der Aufgabentext kommt aus dem gespeicherten Ergebnis; keine Aufgabendatei angeben. - Die deterministischen Checks laufen immer neu. Passt die Zielgröße laut Prüfbericht nicht, startet auch hier kein Solver. - `--von` und `--resolve` schließen sich aus; eine abgelehnte Aufgabe lässt sich nicht fortsetzen. +- Verständnis (Teilfragen) und Abbildungen bleiben erhalten. **Beigelegte Datendateien** sind im Ergebnis nur als Profil gespeichert: bei `--von` und `--resolve` mit `--daten datei.csv …` erneut angeben, sonst findet das Programm sie nicht. +- `--resolve` prüft nur die Zulässigkeit neu; frühere Befunde (z. B. falsche Zielgröße) bleiben bestehen. ### Report nachträglich erzeugen diff --git a/TextToModel.py b/TextToModel.py index 53dddac..c24a1ff 100644 --- a/TextToModel.py +++ b/TextToModel.py @@ -5,13 +5,11 @@ import sys from pathlib import Path from typing import List -from checks.model_checks import check_figure_readings, check_spec, check_understanding, has_errors +from checks.model_checks import check_figure_readings, check_spec, check_understanding from checks.review import needs_work, review_assumptions, revision_reasons, select_best from core.llm import DEFAULT_MODEL, LLMSession from core.model_schema import ( - AssumptionReview, SolverStatus, - CheckIssue, CheckSeverity, OptimizationModelSpecification, PipelineResult, @@ -31,7 +29,7 @@ MAX_RESTARTS = int(os.environ.get("MAX_RESTARTS", "1")) # "unlösbar" kann ein Modellierungsfehler sein (beobachtet bei qwen3-235b: Aufgabe war lösbar). # 1 = einmal unabhängig neu modellieren und erneut lösen, bevor "unlösbar" berichtet wird. INFEASIBLE_RECHECK = int(os.environ.get("INFEASIBLE_RECHECK", "1")) -# Ein Nachfassen, wenn die Verstehensstufe Zahlen oder Teilfragen übersehen hat (0 schaltet die Stufe ab) +# Nachfassen, wenn die Verstehensstufe Zahlen oder Teilfragen übersehen hat (0 = kein Nachfassen, -1 = Stufe aus) UNDERSTAND_RETRIES = int(os.environ.get("UNDERSTAND_RETRIES", "1")) task_description = """ @@ -107,7 +105,7 @@ def _model_attempt(session, task: str, max_revisions: int, label: str, hint: str log(f"[{label}] Übernehme gespeicherte Spezifikation ({len(spec.constraints)} Restriktionen) ...") issues = check_spec(spec, task, understanding) log(f"[{label}] Prüfe Annahmen ...") - review = review_assumptions(session, task, spec) + review = review_assumptions(session, task, spec, data_prompt) candidates = [(spec, issues, review)] revisions = 0 @@ -116,9 +114,9 @@ def _model_attempt(session, task: str, max_revisions: int, label: str, hint: str log(f"[{label}] Probleme gefunden – Korrekturrunde {revisions}/{max_revisions} ...") for grund in revision_reasons(review, issues): log(f" Grund: {grund}") - spec = revise_model(session, task, spec, review, issues) + spec = revise_model(session, task, spec, review, issues, data_prompt) issues = check_spec(spec, task, understanding) - review = review_assumptions(session, task, spec) + review = review_assumptions(session, task, spec, data_prompt) candidates.append((spec, issues, review)) selected = select_best(candidates) @@ -147,7 +145,7 @@ INFEASIBLE_HINT = ( def _recheck_infeasible(session, task: str, model: str, result: PipelineResult, - max_revisions: int, solver_retries: int) -> PipelineResult: + max_revisions: int, solver_retries: int, data_files=(), data_prompt: str = "") -> PipelineResult: """'unlösbar' nicht ungeprüft glauben: unabhängig neu modellieren und erneut lösen.""" status = result.solver_run.status.value if INFEASIBLE_RECHECK < 1: @@ -157,7 +155,8 @@ def _recheck_infeasible(session, task: str, model: str, result: PipelineResult, log(f"[{model}] Solver meldet '{status}' – Gegenprobe mit unabhängiger Modellierung ...") zweiter = _model_attempt(session, task, max_revisions, f"{model} Gegenprobe", - hint=INFEASIBLE_HINT.format(status=status)) + hint=INFEASIBLE_HINT.format(status=status), + understanding=result.understanding, data_prompt=data_prompt) if not zweiter["review"].objective_check.matches: log(" Gegenprobe verworfen: Zielfunktion der neuen Modellierung passt nicht zur Frage.") result.reliable = False @@ -166,13 +165,16 @@ def _recheck_infeasible(session, task: str, model: str, result: PipelineResult, spec = zweiter["spec"] log(f"[{model}] Gegenprobe: Solver-Code erzeugen und starten ({spec.target_stack.value}) ...") - code, run = solve(session, task, spec, retries=solver_retries, log=log) + code, run = solve(session, task, spec, retries=solver_retries, log=log, understanding=result.understanding, + data_files=data_files, data_prompt=data_prompt) if run.status in (SolverStatus.OPTIMAL, SolverStatus.FEASIBLE) and not run.feasibility: log(f" Gegenprobe widerlegt '{status}': Zielwert {run.objective_value} – diese Lösung wird verwendet.") - result.specification, result.review, result.checks = spec, zweiter["review"], check_spec(spec, task) + result.specification, result.review = spec, zweiter["review"] + result.checks = check_spec(spec, task, result.understanding) result.solver_code, result.solver_run = code, run result.restarts += 1 + _assess_run(result) # die neue Lösung durchläuft dieselbe Abnahme wie die erste return result if run.status.value == status: @@ -199,6 +201,15 @@ def _solver_stage(session, task: str, result: PipelineResult, model: str, result.solver_code, result.solver_run = solve(session, task, spec, retries=solver_retries, log=log, understanding=result.understanding, data_files=data_files, data_prompt=data_prompt) + _assess_run(result) + if result.solver_run.status in (SolverStatus.INFEASIBLE, SolverStatus.UNBOUNDED): + result = _recheck_infeasible(session, task, model, result, max_revisions, solver_retries, + data_files=data_files, data_prompt=data_prompt) + return result + + +def _assess_run(result: PipelineResult) -> None: + """Abnahme eines Solver-Laufs: Befunde, die das Ergebnis unzuverlässig machen, in `result` übernehmen.""" run = result.solver_run log(f"Solver: {run.status.value}, Zielwert: {run.objective_value}" + (f"\n {run.error}" if run.error else "")) if run.feasibility: @@ -220,10 +231,6 @@ def _solver_stage(session, task: str, result: PipelineResult, model: str, if result.solver_code.data_assumptions: log(f" Mit angenommenen Daten gelöst ({len(result.solver_code.data_assumptions)} Annahmen).") - if run.status in (SolverStatus.INFEASIBLE, SolverStatus.UNBOUNDED): - result = _recheck_infeasible(session, task, model, result, max_revisions, solver_retries) - return result - def _log_usage(session, result: PipelineResult) -> None: result.usage = session.usage @@ -268,7 +275,7 @@ def run_pipeline( if figur.uncertain: log(f" {figur.id} – unsicher gelesen: " + "; ".join(figur.uncertain)) task = with_transcriptions(task, understanding.figures) - if not understanding.is_optimization_task: + if understanding is not None and not understanding.is_optimization_task: # Keine Entscheidung verlangt: jedes Modell wäre erfunden, jede Zahl ohne Frage dahinter grund = f"Keine Optimierungsaufgabe: {understanding.reason}" log(f"ABGELEHNT – {grund} Es wird weder modelliert noch gerechnet.") @@ -319,12 +326,14 @@ STAGES = ("pruefung", "solver") def continue_pipeline( result_json: str, stage: str, model: str = DEFAULT_MODEL, max_revisions: int = MAX_REVISIONS, - solver_retries: int = SOLVER_RETRIES, session: LLMSession | None = None, + solver_retries: int = SOLVER_RETRIES, session: LLMSession | None = None, data_files=(), ) -> PipelineResult: """Gespeichertes Ergebnis ab einer Stufe fortsetzen – die davor liegenden LLM-Aufrufe entfallen. 'pruefung' behält die Spezifikation und prüft/korrigiert sie neu; 'solver' übernimmt auch den - Prüfbericht und erzeugt nur den Solver-Code neu. Aufgabentext kommt aus dem gespeicherten Ergebnis. + Prüfbericht und erzeugt nur den Solver-Code neu. Aufgabentext, Verständnis und Abbildungen kommen aus + dem gespeicherten Ergebnis; beigelegte Daten (`data_files`) sind dort nur als Profil gespeichert und + müssen erneut angegeben werden, sonst findet das Programm seine Dateien nicht. """ if stage not in STAGES: raise ValueError(f"Unbekannte Stufe '{stage}' (erlaubt: {', '.join(STAGES)})") @@ -332,39 +341,45 @@ def continue_pipeline( if stored.rejected or stored.specification is None: raise ValueError(f"{result_json} ist eine abgelehnte Aufgabe (keine Optimierungsaufgabe) – nichts fortzusetzen") session = session or LLMSession(model) - task = stored.task + task, understanding = stored.task, stored.understanding + profile = data_profile.profile_files(data_files) if data_files else stored.data_files + data_prompt = data_profile.to_prompt(profile) + if stored.data_files and not data_files: + log("WARNUNG – das gespeicherte Ergebnis hatte beigelegte Daten; ohne --daten findet das Programm sie nicht.") log(f"Setze {result_json} ab Stufe '{stage}' fort – Extraktion entfällt.") if stage == "solver": - result = stored.model_copy(update={ - "checks": check_spec(stored.specification, task), # deterministisch, kostet nichts - "solver_code": None, "solver_run": None, "usage": None, - "reliable": True, "reliability_issues": [], - }) - problems = _unresolved({"review": stored.review, "issues": result.checks}) - result.reliable, result.reliability_issues = not problems, problems + checks = check_spec(stored.specification, task, understanding) # deterministisch, kostet nichts + problems = _unresolved({"review": stored.review, "issues": checks}) + update = {"checks": checks} else: - attempt = _model_attempt(session, task, max_revisions, model, spec=stored.specification) + attempt = _model_attempt(session, task, max_revisions, model, spec=stored.specification, + understanding=understanding, data_prompt=data_prompt) problems = _unresolved(attempt) - result = PipelineResult( - task=task, specification=attempt["spec"], review=attempt["review"], revisions=attempt["revisions"], - selected_revision=attempt["selected"], checks=attempt["issues"], - reliable=not problems, reliability_issues=problems, - ) + update = {"specification": attempt["spec"], "review": attempt["review"], "revisions": attempt["revisions"], + "selected_revision": attempt["selected"], "restarts": 0, "checks": attempt["issues"]} + # Verständnis, Abbildungen und Datenprofil bleiben erhalten; alles ab der Fortsetzungsstufe entsteht neu + result = stored.model_copy(update={ + **update, "data_files": profile, "solver_code": None, "solver_run": None, "usage": None, + "infeasible_confirmed": False, "reliable": not problems, "reliability_issues": problems, + }) for issue in result.checks: log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}") if result.reliability_issues: log("ERGEBNIS UNZUVERLÄSSIG: " + "; ".join(result.reliability_issues)) - result = _solver_stage(session, task, result, model, max_revisions, solver_retries) + result = _solver_stage(session, task, result, model, max_revisions, solver_retries, + data_files=data_files, data_prompt=data_prompt) _log_usage(session, result) return result -def resolve_result(result_json: str, data_file: str | None = None, solver_timeout: int = 60) -> PipelineResult: +def resolve_result(result_json: str, data_file: str | None = None, solver_timeout: int = 60, + data_files=()) -> PipelineResult: """Gespeichertes Ergebnis mit (geänderten) Daten neu rechnen – ohne einen einzigen LLM-Aufruf. - Das erzeugte Programm hält alle Eingabezahlen in DATA; nur diese werden ersetzt. + Das erzeugte Programm hält alle Eingabezahlen in DATA; nur diese werden ersetzt. Liest es beigelegte + Dateien, müssen sie über `data_files` erneut angegeben werden. """ result = PipelineResult.model_validate_json(Path(result_json).read_text(encoding="utf-8")) if not result.solver_code: @@ -372,14 +387,16 @@ def resolve_result(result_json: str, data_file: str | None = None, solver_timeou data = json.loads(Path(data_file).read_text(encoding="utf-8")) if data_file else None log(f"Rechne {result_json} neu" + (f" mit Daten aus {data_file}" if data_file else "") + " – ohne LLM ...") - run = run_solver_code(result.solver_code.code, timeout=solver_timeout, data=data) + run = run_solver_code(result.solver_code.code, timeout=solver_timeout, data=data, data_files=data_files) run.feasibility = check_feasibility(run, result.specification) run.warnings += check_sensitivity(run, result.specification.objective.sense.value) run.warnings += check_multistart(run, result.specification.objective.sense.value) result.solver_run = run - result.reliable = not run.feasibility - result.reliability_issues = [f"Zulässigkeitsprüfung: {p}" for p in run.feasibility] + # Frühere Befunde (Zielgröße, Abbildung, Datenherkunft) gelten weiter – nur die Zulässigkeit ist neu geprüft + result.reliability_issues = ([p for p in result.reliability_issues if not p.startswith("Zulässigkeitsprüfung: ")] + + [f"Zulässigkeitsprüfung: {p}" for p in run.feasibility]) + result.reliable = not result.reliability_issues result.usage = None # keine LLM-Nutzung in diesem Lauf log(f"Solver: {run.status.value}, Zielwert: {run.objective_value}" + (f"\n {run.error}" if run.error else "")) for problem in run.feasibility: @@ -416,7 +433,8 @@ if __name__ == "__main__": "'solver' (auch den Prüfbericht behalten, nur Solver-Code neu erzeugen)") parser.add_argument("--daten", nargs="+", metavar="DATEI", help="beigelegte Datendateien (CSV/Excel); ihr Profil geht in die Prompts, " - "die Dateien selbst ins Arbeitsverzeichnis des Programms") + "die Dateien selbst ins Arbeitsverzeichnis des Programms " + "(mit --resolve/--von erneut angeben, gespeichert ist nur ihr Profil)") parser.add_argument("--data", metavar="DATEN.JSON", help="mit --resolve: Eingabewerte (DATA) des Programms ganz oder teilweise ersetzen") parser.add_argument("--report", metavar="DATEI", help="Report mit gerenderten Formeln und Solver-Code (.md oder .html)") @@ -439,9 +457,10 @@ if __name__ == "__main__": parser.error("--von endet immer mit der Solver-Stufe; --no-solve wäre wirkungslos") if args.resolve: - result = resolve_result(args.resolve, args.data) + result = resolve_result(args.resolve, args.data, data_files=args.daten or ()) elif args.von: - result = continue_pipeline(args.von[1], args.von[0], model=args.model, max_revisions=args.max_revisions) + result = continue_pipeline(args.von[1], args.von[0], model=args.model, max_revisions=args.max_revisions, + data_files=args.daten or ()) else: try: result = run_pipeline( diff --git a/checks/review.py b/checks/review.py index 24555d6..c1fe70d 100644 --- a/checks/review.py +++ b/checks/review.py @@ -9,14 +9,18 @@ from core.model_schema import AssumptionReview, AssumptionVerdict, CheckIssue, C REVIEW_PROMPT_FILE = Path(__file__).parent / "Pruef-Prompt.txt" -def review_assumptions(session: LLMSession, task: str, spec: OptimizationModelSpecification) -> AssumptionReview: +def review_assumptions(session: LLMSession, task: str, spec: OptimizationModelSpecification, + data_prompt: str = "") -> AssumptionReview: + # Mit beigelegten Daten gehört ihr Profil zum Aufgabentext – sonst beurteilt die Prüfung Annahmen + # über Spalten, die sie nie gesehen hat. + aufgabe = f"{task}\n\n{data_prompt}" if data_prompt else task return session.create( AssumptionReview, [ {"role": "system", "content": REVIEW_PROMPT_FILE.read_text(encoding="utf-8")}, { "role": "user", - "content": f"AUFGABENTEXT:\n{task}\n\nMODELLSPEZIFIKATION:\n{spec.model_dump_json(indent=2)}", + "content": f"AUFGABENTEXT:\n{aufgabe}\n\nMODELLSPEZIFIKATION:\n{spec.model_dump_json(indent=2)}", }, ], step="Prüfung", diff --git a/modeling/extraction.py b/modeling/extraction.py index d68af97..27f8ab6 100644 --- a/modeling/extraction.py +++ b/modeling/extraction.py @@ -35,7 +35,7 @@ def text_to_model(session: LLMSession, task: str, def revise_model( session: LLMSession, task: str, spec: OptimizationModelSpecification, review: AssumptionReview, - issues: List[CheckIssue] = (), + issues: List[CheckIssue] = (), data_prompt: str = "", ) -> OptimizationModelSpecification: errors = [i for i in issues if i.severity == CheckSeverity.ERROR] check_text = "\n".join(f"- [{i.code}] {i.message}" for i in errors) or "keine" @@ -43,6 +43,7 @@ def revise_model( # sollen sie mit behoben werden – sonst erreicht z. B. unerklärter Fachjargon das LLM nie. hinweise = [i for i in issues if i.severity == CheckSeverity.WARNING] hinweis_text = "\n".join(f"- [{i.code}] {i.message}" for i in hinweise) or "keine" + aufgabe = f"{task}\n\n{data_prompt}" if data_prompt else task # Datenprofil wie bei der Extraktion return session.create( OptimizationModelSpecification, [ @@ -50,7 +51,7 @@ def revise_model( { "role": "user", "content": ( - f"AUFGABENTEXT:\n{task}\n\n" + f"AUFGABENTEXT:\n{aufgabe}\n\n" f"BISHERIGE MODELLSPEZIFIKATION:\n{spec.model_dump_json(indent=2)}\n\n" f"PRÜFBERICHT:\n{review.model_dump_json(indent=2)}\n\n" f"FEHLER DER AUTOMATISCHEN KONSISTENZPRÜFUNG:\n{check_text}\n\n" diff --git a/solving/solver_stage.py b/solving/solver_stage.py index 89850ed..fade47c 100644 --- a/solving/solver_stage.py +++ b/solving/solver_stage.py @@ -1031,13 +1031,14 @@ def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification, attempt = 1 while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data or run.unsolved_scenarios or einheiten) and attempt <= retries: - fehler = run.error or (("Die Lösung hält das Modell nicht ein:\n- " + "\n- ".join(run.feasibility)) - if run.feasibility else - ("Eingabedaten nicht ausgewiesen:\n- " + "\n- ".join(run.undeclared_data)) - if run.undeclared_data else - ("Szenario ohne Ergebnis:\n- " + "\n- ".join(run.unsolved_scenarios)) - if run.unsolved_scenarios else - ("Einheit in der Antwort erfunden:\n- " + "\n- ".join(einheiten))) + # Alle Befundarten zugleich melden – sonst kostet jede weitere Art einen eigenen Reparaturversuch + fehler = run.error or "\n\n".join( + titel + ":\n- " + "\n- ".join(befunde) + for titel, befunde in (("Die Lösung hält das Modell nicht ein", run.feasibility), + ("Eingabedaten nicht ausgewiesen", run.undeclared_data), + ("Szenario ohne Ergebnis", run.unsolved_scenarios), + ("Einheit in der Antwort erfunden", einheiten)) + if befunde) log(f"Solver-Code fehlgeschlagen – Reparaturversuch {attempt}/{retries}: {fehler[:200]}") solver_code = generate_solver_code(session, task, spec, previous=solver_code, previous_error=fehler, understanding=understanding, data_prompt=data_prompt) diff --git a/tests/test_charts.py b/tests/test_charts.py index 536af6e..f78a3ea 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -1,4 +1,5 @@ """Inline-SVG-Diagramme: gültiges SVG, korrekte Geometrie, Einbindung in den Report.""" +import re import xml.etree.ElementTree as ET import pytest @@ -33,8 +34,12 @@ def test_bars_are_proportional_and_capped_in_thickness(): widths = [float(d.split("H")[1].split(" ")[0].split(",")[0]) for d in [p.get("d") for p in parse(svg).findall(".//{http://www.w3.org/2000/svg}path")]] assert widths[0] > widths[1] - heights = {float(p.get("d").split("V")[1].split(" ")[0]) for p in parse(svg).findall(".//{http://www.w3.org/2000/svg}path")} - assert charts.BAR_HEIGHT <= 24 + # Balkenhöhe = unterer Rand (letztes „x,y“ vor „H… Z“) minus oberer Rand (Startpunkt „M x,y“) + heights = set() + for p in parse(svg).findall(".//{http://www.w3.org/2000/svg}path"): + ys = [float(y) for y in re.findall(r"[\d.]+,([\d.]+)", p.get("d"))] + heights.add(max(ys) - min(ys)) + assert heights == {float(charts.BAR_HEIGHT)} and charts.BAR_HEIGHT <= 24 def test_negative_values_use_the_other_colour_role(): diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 86ddee5..d3a062e 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -457,7 +457,6 @@ if __name__ == "__main__": def test_report_shows_data_profile_and_scenarios(tmp_path): - from core.model_schema import Scenario from reporting.report import build_document, to_markdown datei = tmp_path / "werte.csv" datei.write_text("name,menge\nA,3\nB,4\n") @@ -609,3 +608,84 @@ def test_persistent_scenario_without_result_is_unreliable(): result = run_pipeline("Minimiere die Kosten.", session=session, max_revisions=0, max_restarts=0, solver_retries=1) assert not result.reliable assert any(p.startswith("Szenario ohne Ergebnis") for p in result.reliability_issues) + + +# --- Nebenpfade: Verstehensstufe aus, Gegenprobe und --von mit vollem Kontext --- + +def test_pipeline_runs_with_understanding_stage_disabled(monkeypatch): + # UNDERSTAND_RETRIES=-1 war der A/B-Arm der Verstehensstufe; das Ablehnungstor griff dann auf None zu + import TextToModel + monkeypatch.setattr(TextToModel, "UNDERSTAND_RETRIES", -1) + session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + assert result.understanding is None and not result.rejected + assert session.steps[0] == "Extraktion" + assert result.solver_run.status == SolverStatus.OPTIMAL + + +def test_infeasible_recheck_keeps_understanding_and_data_files(tmp_path, monkeypatch): + # Ohne Datendateien fand das Programm der Gegenprobe seine CSV nicht, ohne Verständnis fehlten die Antworten + import TextToModel + aufrufe = [] + echtes_solve = TextToModel.solve + + def protokoll(*args, **kwargs): + aufrufe.append(kwargs) + return echtes_solve(*args, **kwargs) + + monkeypatch.setattr(TextToModel, "solve", protokoll) + csv = tmp_path / "werte.csv" + csv.write_text("a,b\n1,2\n", encoding="utf-8") + session = FakeSession(specs=[GOOD, GOOD], reviews=[OK, OK], + codes=[SolverCode(code=INFEASIBLE_CODE), SolverCode(code=STRUCTURED_CODE)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, data_files=[str(csv)]) + assert result.restarts == 1 and len(aufrufe) == 2 + gegenprobe = aufrufe[1] + assert gegenprobe["understanding"] is not None + assert list(gegenprobe["data_files"]) == [str(csv)] and "werte.csv" in gegenprobe["data_prompt"] + assert "werte.csv" in session.tasks[1] # auch die neue Extraktion sieht das Datenprofil + + +def test_continue_from_review_keeps_understanding(tmp_path): + from TextToModel import continue_pipeline + path = stored_result(tmp_path) + session = FakeSession([], [OK], [SolverCode(code=STRUCTURED_CODE)]) + result = continue_pipeline(str(path), "pruefung", session=session) + assert result.understanding is not None + assert [q.id for q in result.understanding.sub_questions] == ["1"] + assert "ZU BEANTWORTENDE TEILFRAGEN" in session.letzter_prompt # der Solver bekommt die Teilfragen + + +def test_review_and_revision_see_the_data_profile(): + from checks.review import review_assumptions + from modeling.extraction import revise_model + session = FakeSession([GOOD], [OK], []) + review_assumptions(session, "Aufgabe", GOOD, "BEIGELEGTE DATEN: kurse.csv") + assert "kurse.csv" in session.letzter_prompt + revise_model(session, "Aufgabe", GOOD, OK, data_prompt="BEIGELEGTE DATEN: kurse.csv") + assert "kurse.csv" in session.letzter_prompt + + +def test_repair_message_names_every_kind_of_finding(): + # Vorher erfuhr das LLM nur von der ersten Befundart und verbrauchte für die zweite einen eigenen Versuch + from solving.solver_stage import solve + code = ''' +import numpy as np + + +def main(): + """Verletzt die Restriktion und erzeugt Zahlen per Zufall, ohne sie auszuweisen.""" + np.random.seed(0) + report("optimal", 9.0, {"x": 1.0}, + constraints=[{"name": "Bedarf", "lhs": 1.0, "sense": ">=", "rhs": 4.0}], + variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0, "ub": None}]) + + +if __name__ == "__main__": + main() +''' + session = FakeSession([], [], [SolverCode(code=code), SolverCode(code=code)]) + solve(session, "Aufgabe", GOOD, retries=1) + assert session.steps == ["Solver-Code", "Solver-Reparatur"] + assert "hält das Modell nicht ein" in session.letzter_prompt + assert "Eingabedaten nicht ausgewiesen" in session.letzter_prompt diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 6ef51c6..3ac183e 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -63,7 +63,6 @@ def test_infeasible_data_is_reported_not_crashing(): # --- CLI-Modus: gespeichertes Ergebnis neu rechnen --- def gespeichertes_ergebnis(tmp_path): - import json from core.model_schema import PipelineResult, SolverCode from test_model_checks import make_spec from test_review import make_review @@ -96,3 +95,17 @@ def test_resolve_keeps_specification_and_checks_feasibility(tmp_path): neu = resolve_result(str(gespeichertes_ergebnis(tmp_path))) assert neu.specification.problem_class.value.startswith("Linear") assert neu.solver_run.feasibility == [] + + +def test_resolve_keeps_earlier_reliability_issues(tmp_path): + # Neurechnen prüft nur die Zulässigkeit neu – eine falsche Zielgröße bleibt ein Befund + from core.model_schema import PipelineResult + from TextToModel import resolve_result + pfad = gespeichertes_ergebnis(tmp_path) + alt = PipelineResult.model_validate_json(pfad.read_text(encoding="utf-8")) + alt.reliable = False + alt.reliability_issues = ["Zielfunktion passt nicht zur Frage", "Zulässigkeitsprüfung: alter Befund"] + pfad.write_text(alt.model_dump_json(), encoding="utf-8") + neu = resolve_result(str(pfad)) + assert neu.reliability_issues == ["Zielfunktion passt nicht zur Frage"] # alter Zulässigkeitsbefund entfällt + assert not neu.reliable diff --git a/tests/test_result_tables.py b/tests/test_result_tables.py index 72af4e3..3f3cbb7 100644 --- a/tests/test_result_tables.py +++ b/tests/test_result_tables.py @@ -140,7 +140,7 @@ def test_normal_decomposition_has_no_magnitude_warning(): def test_report_shows_multistart_stability(): - from core.model_schema import Multistart, SolverRun + from core.model_schema import Multistart from reporting.report import build_document, to_markdown from test_pipeline import FakeSession from TextToModel import run_pipeline diff --git a/tests/test_solver_stage.py b/tests/test_solver_stage.py index 57dd5d3..66e4b33 100644 --- a/tests/test_solver_stage.py +++ b/tests/test_solver_stage.py @@ -482,7 +482,6 @@ def test_path_must_use_declared_arcs(): def test_path_length_must_match_objective(): - from core.model_schema import SolverRun run = graph_run() run.objective_value = 7.0 # 4 + 5 = 9 assert any("weicht vom Zielwert" in w for w in check_graph(run, "minimize")) @@ -757,7 +756,6 @@ def test_ordinary_programs_are_not_suspected(): def test_scenario_failing_without_error_text_gets_a_reason(): # beobachtet (Portfolio Nr. 2): Szenario ohne Lösung im Zeitlimit, Warnung endete mit leerem Grund - from core.model_schema import Scenario from solving.solver_stage import verify_scenarios code = SZENARIO_CODE.replace(' basis = loesen(DATA)\n', ' basis = loesen(DATA)\n if DATA["preis"] > 15:\n'