Ü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>
188 lines
9.3 KiB
Python
188 lines
9.3 KiB
Python
"""Inline-SVG-Diagramme für den HTML-Report – ohne externe Bibliotheken, aus den geprüften Zahlen der Ergebnistabellen.
|
||
|
||
Formen (siehe dataviz-Richtlinie): Magnitudenvergleich -> waagerechte Balken; Zerlegung eines Zielwerts
|
||
in Zu-/Abschläge -> Wasserfall. Farben über CSS-Rollen (--viz-*), damit Hell/Dunkel in einer Stelle umschaltet:
|
||
divergierendes Paar Blau/Rot (validiert), Gesamtwert in neutraler Textfarbe.
|
||
"""
|
||
import html
|
||
|
||
BAR_HEIGHT = 22 # <= 24px, Balken füllt das Band nicht ganz
|
||
BAR_GAP = 10 # Luft zwischen Balken (>= 2px Surface-Gap)
|
||
LABEL_WIDTH = 150
|
||
VALUE_WIDTH = 92
|
||
PADDING = 12
|
||
RADIUS = 4 # abgerundetes Datenende
|
||
|
||
|
||
def _fmt(value: float, unit: str = "") -> str:
|
||
text = f"{value:,.6g}".replace(",", " ")
|
||
return f"{text}{unit}"
|
||
|
||
|
||
def _text(x, y, content, role="ink", anchor="start", size=12, weight="normal"):
|
||
return (f'<text x="{x:.1f}" y="{y:.1f}" text-anchor="{anchor}" font-size="{size}" '
|
||
f'font-weight="{weight}" fill="var(--viz-{role})">{html.escape(str(content))}</text>')
|
||
|
||
|
||
def _bar(x, y, width, height, color_role, negative=False):
|
||
"""Balken mit abgerundetem Datenende und eckigem Ende an der Nulllinie."""
|
||
width = max(width, 1.0)
|
||
r = min(RADIUS, width / 2)
|
||
if negative: # wächst nach links -> linkes Ende gerundet
|
||
path = (f"M{x + width:.1f},{y:.1f} H{x + r:.1f} Q{x:.1f},{y:.1f} {x:.1f},{y + r:.1f} "
|
||
f"V{y + height - r:.1f} Q{x:.1f},{y + height:.1f} {x + r:.1f},{y + height:.1f} "
|
||
f"H{x + width:.1f} Z")
|
||
else:
|
||
path = (f"M{x:.1f},{y:.1f} H{x + width - r:.1f} Q{x + width:.1f},{y:.1f} {x + width:.1f},{y + r:.1f} "
|
||
f"V{y + height - r:.1f} Q{x + width:.1f},{y + height:.1f} {x + width - r:.1f},{y + height:.1f} "
|
||
f"H{x:.1f} Z")
|
||
return f'<path d="{path}" fill="var(--viz-{color_role})"/>'
|
||
|
||
|
||
def _frame(width, height, title, body) -> str:
|
||
return (f'<svg class="viz" viewBox="0 0 {width} {height:.0f}" width="100%" height="{height:.0f}" '
|
||
f'role="img" aria-label="{html.escape(title)}" xmlns="http://www.w3.org/2000/svg">'
|
||
f"{body}</svg>")
|
||
|
||
|
||
def bar_chart(labels, values, title, unit="") -> str:
|
||
"""Waagerechte Balken für Beiträge je Objekt; negative Werte links der Nulllinie (Blau/Rot)."""
|
||
if not labels:
|
||
return ""
|
||
width = 720
|
||
plot_x = LABEL_WIDTH + PADDING
|
||
plot_width = width - plot_x - VALUE_WIDTH - PADDING
|
||
height = PADDING * 2 + len(labels) * (BAR_HEIGHT + BAR_GAP)
|
||
|
||
low, high = min(0.0, min(values)), max(0.0, max(values))
|
||
span = (high - low) or 1.0
|
||
zero_x = plot_x + plot_width * (0 - low) / span
|
||
|
||
parts = [f'<line x1="{zero_x:.1f}" y1="{PADDING - 4}" x2="{zero_x:.1f}" y2="{height - PADDING + 4}" '
|
||
f'stroke="var(--viz-grid)" stroke-width="1"/>']
|
||
for n, (label, value) in enumerate(zip(labels, values)):
|
||
y = PADDING + n * (BAR_HEIGHT + BAR_GAP)
|
||
length = plot_width * abs(value) / span
|
||
negative = value < 0
|
||
x = zero_x - length if negative else zero_x
|
||
parts.append(f"<g><title>{html.escape(f'{label}: {_fmt(value, unit)}')}</title>")
|
||
parts.append(_bar(x, y, length, BAR_HEIGHT, "neg" if negative else "pos", negative))
|
||
parts.append("</g>")
|
||
parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, label, "muted", anchor="end"))
|
||
value_x = x - 6 if negative else x + length + 6
|
||
parts.append(_text(value_x, y + BAR_HEIGHT * 0.72, _fmt(value, unit), "ink",
|
||
anchor="end" if negative else "start"))
|
||
return _frame(width, height, title, "".join(parts))
|
||
|
||
|
||
def waterfall_chart(labels, deltas, total_label, total_value, title, unit="") -> str:
|
||
"""Zielwert-Zerlegung: Zu- und Abschläge als Stufen, Gesamtwert als neutraler Balken ab Null."""
|
||
if not labels:
|
||
return ""
|
||
width = 720
|
||
plot_x = LABEL_WIDTH + PADDING
|
||
plot_width = width - plot_x - VALUE_WIDTH - PADDING
|
||
rows = len(labels) + 1
|
||
height = PADDING * 2 + rows * (BAR_HEIGHT + BAR_GAP)
|
||
|
||
running, levels = 0.0, []
|
||
for delta in deltas:
|
||
levels.append((running, running + delta))
|
||
running += delta
|
||
bounds = [v for pair in levels for v in pair] + [0.0, total_value]
|
||
low, high = min(bounds), max(bounds)
|
||
span = (high - low) or 1.0
|
||
scale = lambda v: plot_x + plot_width * (v - low) / span
|
||
|
||
parts = [f'<line x1="{scale(0):.1f}" y1="{PADDING - 4}" x2="{scale(0):.1f}" y2="{height - PADDING + 4}" '
|
||
f'stroke="var(--viz-grid)" stroke-width="1"/>']
|
||
for n, (label, delta, (start, end)) in enumerate(zip(labels, deltas, levels)):
|
||
y = PADDING + n * (BAR_HEIGHT + BAR_GAP)
|
||
x0, x1 = sorted((scale(start), scale(end)))
|
||
negative = delta < 0
|
||
parts.append(f"<g><title>{html.escape(f'{label}: {_fmt(delta, unit)}')}</title>")
|
||
parts.append(_bar(x0, y, x1 - x0, BAR_HEIGHT, "neg" if negative else "pos", negative))
|
||
parts.append("</g>")
|
||
parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, label, "muted", anchor="end"))
|
||
parts.append(_text(x1 + 6, y + BAR_HEIGHT * 0.72, _fmt(delta, unit), "ink"))
|
||
if n + 1 < len(labels): # Verbindungslinie zur nächsten Stufe
|
||
next_y = y + BAR_HEIGHT + BAR_GAP
|
||
parts.append(f'<line x1="{scale(end):.1f}" y1="{y + BAR_HEIGHT:.1f}" x2="{scale(end):.1f}" '
|
||
f'y2="{next_y:.1f}" stroke="var(--viz-grid)" stroke-width="1" stroke-dasharray="2 2"/>')
|
||
|
||
y = PADDING + len(labels) * (BAR_HEIGHT + BAR_GAP)
|
||
x0, x1 = sorted((scale(0), scale(total_value)))
|
||
parts.append(f"<g><title>{html.escape(f'{total_label}: {_fmt(total_value, unit)}')}</title>")
|
||
parts.append(_bar(x0, y, x1 - x0, BAR_HEIGHT, "total", total_value < 0))
|
||
parts.append("</g>")
|
||
parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, total_label, "ink", anchor="end", weight="600"))
|
||
parts.append(_text(x1 + 6, y + BAR_HEIGHT * 0.72, _fmt(total_value, unit), "ink", weight="600"))
|
||
return _frame(width, height, title, "".join(parts))
|
||
|
||
|
||
# --- Graphenzeichnung (Knoten und Bögen) ---
|
||
|
||
NODE_RADIUS = 17
|
||
ARC_BOW = 34 # Wölbung je Spannweite, damit parallele Bögen sich nicht überdecken
|
||
GRAPH_PADDING = 26
|
||
|
||
|
||
def _arrow_marker(name: str, role: str) -> str:
|
||
return (f'<marker id="{name}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" '
|
||
f'orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--viz-{role})"/></marker>')
|
||
|
||
|
||
def graph_chart(nodes, arcs, path=(), title="Graph", unit="") -> str:
|
||
"""Knoten-Bogen-Diagramm mit hervorgehobenem Weg.
|
||
|
||
nodes: Knotennamen in Reihenfolge, arcs: (von, nach, gewicht), path: Knotenfolge des Weges.
|
||
Knoten liegen auf einer Waagerechten, Bögen wölben sich darüber (Rückwärtsbögen darunter);
|
||
die Wölbung wächst mit der Spannweite, damit sich parallele Bögen nicht überdecken.
|
||
"""
|
||
if not nodes or not arcs or len(nodes) > 14:
|
||
return ""
|
||
index = {name: i for i, name in enumerate(nodes)}
|
||
if any(a[0] not in index or a[1] not in index for a in arcs):
|
||
return ""
|
||
|
||
weg = {(a, b) for a, b in zip(path, path[1:])}
|
||
step = max(96, min(150, 820 // max(len(nodes) - 1, 1)))
|
||
breite = 2 * GRAPH_PADDING + step * (len(nodes) - 1) + 2 * NODE_RADIUS
|
||
spannen_oben = [abs(index[b] - index[a]) for a, b, _ in arcs if index[b] > index[a]]
|
||
spannen_unten = [abs(index[b] - index[a]) for a, b, _ in arcs if index[b] < index[a]]
|
||
oben = ARC_BOW * max(spannen_oben, default=0) / 2 + 30
|
||
unten = ARC_BOW * max(spannen_unten, default=0) / 2 + 30
|
||
mitte = GRAPH_PADDING + oben
|
||
hoehe = mitte + unten + GRAPH_PADDING
|
||
|
||
def x(name):
|
||
return GRAPH_PADDING + NODE_RADIUS + step * index[name]
|
||
|
||
teile = [f"<defs>{_arrow_marker('pfeil', 'grid')}{_arrow_marker('pfeil-weg', 'pos')}</defs>"]
|
||
for start, ziel, gewicht in arcs:
|
||
auf_weg = (start, ziel) in weg
|
||
rolle, marker = ("pos", "pfeil-weg") if auf_weg else ("grid", "pfeil")
|
||
x1, x2 = x(start), x(ziel)
|
||
rueckwaerts = index[ziel] < index[start]
|
||
spanne = abs(index[ziel] - index[start])
|
||
bogen = ARC_BOW * spanne * (1 if rueckwaerts else -1)
|
||
# Kreisrand statt Mittelpunkt als Anfang/Ende, damit die Pfeilspitze sichtbar bleibt
|
||
richtung = 1 if x2 > x1 else -1
|
||
x1 += richtung * NODE_RADIUS
|
||
x2 -= richtung * NODE_RADIUS
|
||
steuer_y = mitte + bogen
|
||
teile.append(f'<path d="M{x1:.1f},{mitte:.1f} Q{(x1 + x2) / 2:.1f},{steuer_y:.1f} {x2:.1f},{mitte:.1f}" '
|
||
f'fill="none" stroke="var(--viz-{rolle})" stroke-width="{3 if auf_weg else 1.5}" '
|
||
f'marker-end="url(#{marker})"/>')
|
||
teile.append(_text((x1 + x2) / 2, mitte + bogen / 2 + (20 if rueckwaerts else -10), _fmt(gewicht, unit),
|
||
role=rolle if auf_weg else "ink", anchor="middle", size=12,
|
||
weight="bold" if auf_weg else "normal"))
|
||
|
||
for name in nodes:
|
||
auf_weg = name in set(path)
|
||
teile.append(f'<circle cx="{x(name):.1f}" cy="{mitte:.1f}" r="{NODE_RADIUS}" '
|
||
f'fill="var(--viz-surface)" stroke="var(--viz-{"pos" if auf_weg else "grid"})" '
|
||
f'stroke-width="{3 if auf_weg else 1.5}"/>')
|
||
teile.append(_text(x(name), mitte + 4, name, anchor="middle", size=13,
|
||
weight="bold" if auf_weg else "normal"))
|
||
return _frame(breite, hoehe, title, "".join(teile))
|