fix: Nebenpfade behalten Kontext – Gegenprobe, --von, --resolve, UNDERSTAND_RETRIES=-1

- UNDERSTAND_RETRIES=-1 (A/B-Arm der Verstehensstufe) stürzte seit dem Ablehnungstor ab
- Unlösbar-Gegenprobe bekommt Verständnis, Datenprofil und Datendateien; ihre Lösung
  durchläuft dieselbe Abnahme (_assess_run)
- --von pruefung behält Verständnis, Abbildungen und Datenprofil
- --resolve überschreibt frühere Befunde nicht mehr (nur Zulässigkeit neu)
- --daten wirkt auch mit --resolve und --von
- Prüfung und Korrektur sehen das Datenprofil
- Reparaturmeldung nennt alle Befundarten zugleich
- Test der Balkenhöhe prüft die Höhe tatsächlich; unbenutzte Imports entfernt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-18 13:22:53 +02:00
commit 3c4d2fb92c
10 changed files with 182 additions and 59 deletions

View file

@ -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. - 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. - 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. - `--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 ### Report nachträglich erzeugen

View file

@ -5,13 +5,11 @@ import sys
from pathlib import Path from pathlib import Path
from typing import List 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 checks.review import needs_work, review_assumptions, revision_reasons, select_best
from core.llm import DEFAULT_MODEL, LLMSession from core.llm import DEFAULT_MODEL, LLMSession
from core.model_schema import ( from core.model_schema import (
AssumptionReview,
SolverStatus, SolverStatus,
CheckIssue,
CheckSeverity, CheckSeverity,
OptimizationModelSpecification, OptimizationModelSpecification,
PipelineResult, 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). # "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. # 1 = einmal unabhängig neu modellieren und erneut lösen, bevor "unlösbar" berichtet wird.
INFEASIBLE_RECHECK = int(os.environ.get("INFEASIBLE_RECHECK", "1")) 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")) UNDERSTAND_RETRIES = int(os.environ.get("UNDERSTAND_RETRIES", "1"))
task_description = """ 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) ...") log(f"[{label}] Übernehme gespeicherte Spezifikation ({len(spec.constraints)} Restriktionen) ...")
issues = check_spec(spec, task, understanding) issues = check_spec(spec, task, understanding)
log(f"[{label}] Prüfe Annahmen ...") log(f"[{label}] Prüfe Annahmen ...")
review = review_assumptions(session, task, spec) review = review_assumptions(session, task, spec, data_prompt)
candidates = [(spec, issues, review)] candidates = [(spec, issues, review)]
revisions = 0 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} ...") log(f"[{label}] Probleme gefunden – Korrekturrunde {revisions}/{max_revisions} ...")
for grund in revision_reasons(review, issues): for grund in revision_reasons(review, issues):
log(f" Grund: {grund}") 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) 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)) candidates.append((spec, issues, review))
selected = select_best(candidates) selected = select_best(candidates)
@ -147,7 +145,7 @@ INFEASIBLE_HINT = (
def _recheck_infeasible(session, task: str, model: str, result: PipelineResult, 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.""" """'unlösbar' nicht ungeprüft glauben: unabhängig neu modellieren und erneut lösen."""
status = result.solver_run.status.value status = result.solver_run.status.value
if INFEASIBLE_RECHECK < 1: 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 ...") log(f"[{model}] Solver meldet '{status}' – Gegenprobe mit unabhängiger Modellierung ...")
zweiter = _model_attempt(session, task, max_revisions, f"{model} Gegenprobe", 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: if not zweiter["review"].objective_check.matches:
log(" Gegenprobe verworfen: Zielfunktion der neuen Modellierung passt nicht zur Frage.") log(" Gegenprobe verworfen: Zielfunktion der neuen Modellierung passt nicht zur Frage.")
result.reliable = False result.reliable = False
@ -166,13 +165,16 @@ def _recheck_infeasible(session, task: str, model: str, result: PipelineResult,
spec = zweiter["spec"] spec = zweiter["spec"]
log(f"[{model}] Gegenprobe: Solver-Code erzeugen und starten ({spec.target_stack.value}) ...") 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: 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.") 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.solver_code, result.solver_run = code, run
result.restarts += 1 result.restarts += 1
_assess_run(result) # die neue Lösung durchläuft dieselbe Abnahme wie die erste
return result return result
if run.status.value == status: 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, result.solver_code, result.solver_run = solve(session, task, spec, retries=solver_retries, log=log,
understanding=result.understanding, understanding=result.understanding,
data_files=data_files, data_prompt=data_prompt) 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 run = result.solver_run
log(f"Solver: {run.status.value}, Zielwert: {run.objective_value}" + (f"\n {run.error}" if run.error else "")) log(f"Solver: {run.status.value}, Zielwert: {run.objective_value}" + (f"\n {run.error}" if run.error else ""))
if run.feasibility: if run.feasibility:
@ -220,10 +231,6 @@ def _solver_stage(session, task: str, result: PipelineResult, model: str,
if result.solver_code.data_assumptions: if result.solver_code.data_assumptions:
log(f" Mit angenommenen Daten gelöst ({len(result.solver_code.data_assumptions)} Annahmen).") 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: def _log_usage(session, result: PipelineResult) -> None:
result.usage = session.usage result.usage = session.usage
@ -268,7 +275,7 @@ def run_pipeline(
if figur.uncertain: if figur.uncertain:
log(f" {figur.id} – unsicher gelesen: " + "; ".join(figur.uncertain)) log(f" {figur.id} – unsicher gelesen: " + "; ".join(figur.uncertain))
task = with_transcriptions(task, understanding.figures) 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 # Keine Entscheidung verlangt: jedes Modell wäre erfunden, jede Zahl ohne Frage dahinter
grund = f"Keine Optimierungsaufgabe: {understanding.reason}" grund = f"Keine Optimierungsaufgabe: {understanding.reason}"
log(f"ABGELEHNT – {grund} Es wird weder modelliert noch gerechnet.") log(f"ABGELEHNT – {grund} Es wird weder modelliert noch gerechnet.")
@ -319,12 +326,14 @@ STAGES = ("pruefung", "solver")
def continue_pipeline( def continue_pipeline(
result_json: str, stage: str, model: str = DEFAULT_MODEL, max_revisions: int = MAX_REVISIONS, 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: ) -> PipelineResult:
"""Gespeichertes Ergebnis ab einer Stufe fortsetzen – die davor liegenden LLM-Aufrufe entfallen. """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 '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: if stage not in STAGES:
raise ValueError(f"Unbekannte Stufe '{stage}' (erlaubt: {', '.join(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: if stored.rejected or stored.specification is None:
raise ValueError(f"{result_json} ist eine abgelehnte Aufgabe (keine Optimierungsaufgabe) – nichts fortzusetzen") raise ValueError(f"{result_json} ist eine abgelehnte Aufgabe (keine Optimierungsaufgabe) – nichts fortzusetzen")
session = session or LLMSession(model) 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.") log(f"Setze {result_json} ab Stufe '{stage}' fort – Extraktion entfällt.")
if stage == "solver": if stage == "solver":
result = stored.model_copy(update={ checks = check_spec(stored.specification, task, understanding) # deterministisch, kostet nichts
"checks": check_spec(stored.specification, task), # deterministisch, kostet nichts problems = _unresolved({"review": stored.review, "issues": checks})
"solver_code": None, "solver_run": None, "usage": None, update = {"checks": checks}
"reliable": True, "reliability_issues": [],
})
problems = _unresolved({"review": stored.review, "issues": result.checks})
result.reliable, result.reliability_issues = not problems, problems
else: 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) problems = _unresolved(attempt)
result = PipelineResult( update = {"specification": attempt["spec"], "review": attempt["review"], "revisions": attempt["revisions"],
task=task, specification=attempt["spec"], review=attempt["review"], revisions=attempt["revisions"], "selected_revision": attempt["selected"], "restarts": 0, "checks": attempt["issues"]}
selected_revision=attempt["selected"], checks=attempt["issues"], # Verständnis, Abbildungen und Datenprofil bleiben erhalten; alles ab der Fortsetzungsstufe entsteht neu
reliable=not problems, reliability_issues=problems, 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: for issue in result.checks:
log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}") log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}")
if result.reliability_issues: if result.reliability_issues:
log("ERGEBNIS UNZUVERLÄSSIG: " + "; ".join(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) _log_usage(session, result)
return 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. """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")) result = PipelineResult.model_validate_json(Path(result_json).read_text(encoding="utf-8"))
if not result.solver_code: 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 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 ...") 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.feasibility = check_feasibility(run, result.specification)
run.warnings += check_sensitivity(run, result.specification.objective.sense.value) run.warnings += check_sensitivity(run, result.specification.objective.sense.value)
run.warnings += check_multistart(run, result.specification.objective.sense.value) run.warnings += check_multistart(run, result.specification.objective.sense.value)
result.solver_run = run result.solver_run = run
result.reliable = not run.feasibility # Frühere Befunde (Zielgröße, Abbildung, Datenherkunft) gelten weiter – nur die Zulässigkeit ist neu geprüft
result.reliability_issues = [f"Zulässigkeitsprüfung: {p}" for p in run.feasibility] 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 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 "")) log(f"Solver: {run.status.value}, Zielwert: {run.objective_value}" + (f"\n {run.error}" if run.error else ""))
for problem in run.feasibility: for problem in run.feasibility:
@ -416,7 +433,8 @@ if __name__ == "__main__":
"'solver' (auch den Prüfbericht behalten, nur Solver-Code neu erzeugen)") "'solver' (auch den Prüfbericht behalten, nur Solver-Code neu erzeugen)")
parser.add_argument("--daten", nargs="+", metavar="DATEI", parser.add_argument("--daten", nargs="+", metavar="DATEI",
help="beigelegte Datendateien (CSV/Excel); ihr Profil geht in die Prompts, " 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", parser.add_argument("--data", metavar="DATEN.JSON",
help="mit --resolve: Eingabewerte (DATA) des Programms ganz oder teilweise ersetzen") 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)") 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") parser.error("--von endet immer mit der Solver-Stufe; --no-solve wäre wirkungslos")
if args.resolve: 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: 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: else:
try: try:
result = run_pipeline( result = run_pipeline(

View file

@ -9,14 +9,18 @@ from core.model_schema import AssumptionReview, AssumptionVerdict, CheckIssue, C
REVIEW_PROMPT_FILE = Path(__file__).parent / "Pruef-Prompt.txt" 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( return session.create(
AssumptionReview, AssumptionReview,
[ [
{"role": "system", "content": REVIEW_PROMPT_FILE.read_text(encoding="utf-8")}, {"role": "system", "content": REVIEW_PROMPT_FILE.read_text(encoding="utf-8")},
{ {
"role": "user", "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", step="Prüfung",

View file

@ -35,7 +35,7 @@ def text_to_model(session: LLMSession, task: str,
def revise_model( def revise_model(
session: LLMSession, task: str, spec: OptimizationModelSpecification, review: AssumptionReview, session: LLMSession, task: str, spec: OptimizationModelSpecification, review: AssumptionReview,
issues: List[CheckIssue] = (), issues: List[CheckIssue] = (), data_prompt: str = "",
) -> OptimizationModelSpecification: ) -> OptimizationModelSpecification:
errors = [i for i in issues if i.severity == CheckSeverity.ERROR] 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" 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. # 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] 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" 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( return session.create(
OptimizationModelSpecification, OptimizationModelSpecification,
[ [
@ -50,7 +51,7 @@ def revise_model(
{ {
"role": "user", "role": "user",
"content": ( "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"BISHERIGE MODELLSPEZIFIKATION:\n{spec.model_dump_json(indent=2)}\n\n"
f"PRÜFBERICHT:\n{review.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" f"FEHLER DER AUTOMATISCHEN KONSISTENZPRÜFUNG:\n{check_text}\n\n"

View file

@ -1031,13 +1031,14 @@ def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification,
attempt = 1 attempt = 1
while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data or run.unsolved_scenarios while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data or run.unsolved_scenarios
or einheiten) and attempt <= retries: or einheiten) and attempt <= retries:
fehler = run.error or (("Die Lösung hält das Modell nicht ein:\n- " + "\n- ".join(run.feasibility)) # Alle Befundarten zugleich melden – sonst kostet jede weitere Art einen eigenen Reparaturversuch
if run.feasibility else fehler = run.error or "\n\n".join(
("Eingabedaten nicht ausgewiesen:\n- " + "\n- ".join(run.undeclared_data)) titel + ":\n- " + "\n- ".join(befunde)
if run.undeclared_data else for titel, befunde in (("Die Lösung hält das Modell nicht ein", run.feasibility),
("Szenario ohne Ergebnis:\n- " + "\n- ".join(run.unsolved_scenarios)) ("Eingabedaten nicht ausgewiesen", run.undeclared_data),
if run.unsolved_scenarios else ("Szenario ohne Ergebnis", run.unsolved_scenarios),
("Einheit in der Antwort erfunden:\n- " + "\n- ".join(einheiten))) ("Einheit in der Antwort erfunden", einheiten))
if befunde)
log(f"Solver-Code fehlgeschlagen – Reparaturversuch {attempt}/{retries}: {fehler[:200]}") log(f"Solver-Code fehlgeschlagen – Reparaturversuch {attempt}/{retries}: {fehler[:200]}")
solver_code = generate_solver_code(session, task, spec, previous=solver_code, previous_error=fehler, solver_code = generate_solver_code(session, task, spec, previous=solver_code, previous_error=fehler,
understanding=understanding, data_prompt=data_prompt) understanding=understanding, data_prompt=data_prompt)

View file

@ -1,4 +1,5 @@
"""Inline-SVG-Diagramme: gültiges SVG, korrekte Geometrie, Einbindung in den Report.""" """Inline-SVG-Diagramme: gültiges SVG, korrekte Geometrie, Einbindung in den Report."""
import re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import pytest 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 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")]] [p.get("d") for p in parse(svg).findall(".//{http://www.w3.org/2000/svg}path")]]
assert widths[0] > widths[1] 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")} # Balkenhöhe = unterer Rand (letztes „x,y“ vor „H… Z“) minus oberer Rand (Startpunkt „M x,y“)
assert charts.BAR_HEIGHT <= 24 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(): def test_negative_values_use_the_other_colour_role():

View file

@ -457,7 +457,6 @@ if __name__ == "__main__":
def test_report_shows_data_profile_and_scenarios(tmp_path): def test_report_shows_data_profile_and_scenarios(tmp_path):
from core.model_schema import Scenario
from reporting.report import build_document, to_markdown from reporting.report import build_document, to_markdown
datei = tmp_path / "werte.csv" datei = tmp_path / "werte.csv"
datei.write_text("name,menge\nA,3\nB,4\n") 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) result = run_pipeline("Minimiere die Kosten.", session=session, max_revisions=0, max_restarts=0, solver_retries=1)
assert not result.reliable assert not result.reliable
assert any(p.startswith("Szenario ohne Ergebnis") for p in result.reliability_issues) 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

View file

@ -63,7 +63,6 @@ def test_infeasible_data_is_reported_not_crashing():
# --- CLI-Modus: gespeichertes Ergebnis neu rechnen --- # --- CLI-Modus: gespeichertes Ergebnis neu rechnen ---
def gespeichertes_ergebnis(tmp_path): def gespeichertes_ergebnis(tmp_path):
import json
from core.model_schema import PipelineResult, SolverCode from core.model_schema import PipelineResult, SolverCode
from test_model_checks import make_spec from test_model_checks import make_spec
from test_review import make_review 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))) neu = resolve_result(str(gespeichertes_ergebnis(tmp_path)))
assert neu.specification.problem_class.value.startswith("Linear") assert neu.specification.problem_class.value.startswith("Linear")
assert neu.solver_run.feasibility == [] 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

View file

@ -140,7 +140,7 @@ def test_normal_decomposition_has_no_magnitude_warning():
def test_report_shows_multistart_stability(): 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 reporting.report import build_document, to_markdown
from test_pipeline import FakeSession from test_pipeline import FakeSession
from TextToModel import run_pipeline from TextToModel import run_pipeline

View file

@ -482,7 +482,6 @@ def test_path_must_use_declared_arcs():
def test_path_length_must_match_objective(): def test_path_length_must_match_objective():
from core.model_schema import SolverRun
run = graph_run() run = graph_run()
run.objective_value = 7.0 # 4 + 5 = 9 run.objective_value = 7.0 # 4 + 5 = 9
assert any("weicht vom Zielwert" in w for w in check_graph(run, "minimize")) 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(): 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 # 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 from solving.solver_stage import verify_scenarios
code = SZENARIO_CODE.replace(' basis = loesen(DATA)\n', code = SZENARIO_CODE.replace(' basis = loesen(DATA)\n',
' basis = loesen(DATA)\n if DATA["preis"] > 15:\n' ' basis = loesen(DATA)\n if DATA["preis"] > 15:\n'