fix: antwortendes Modell erfassen; 0.040, LaTeX-Dezimalkomma und Teilfragen-Überschriften

- CallRecord.responded_model / LLMUsage.responded_models: tatsächlich antwortendes
  Modell (beim Alias ~deepseek/deepseek-flash-latest) in Log, Report und Benchmark
- normalize_number: führende Null ist nie Tausender (0.040 wurde als 40 gelesen)
- numbers_in: LaTeX-Dezimalkomma 0{,}08 als eine Zahl
- question_ids_in: '### Teil N' als Teilfrage; ausdrückliche Marker haben Vorrang
  vor nummerierten Listen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-17 19:30:19 +02:00
commit 8bd3c2e7e1
8 changed files with 88 additions and 10 deletions

View file

@ -229,6 +229,8 @@ def _log_usage(session, result: PipelineResult) -> None:
result.usage = session.usage
cost = f", Kosten ${session.usage.cost_usd:.4f}" if session.usage.cost_usd is not None else ""
providers = f", Anbieter: {session.usage.providers}" if session.usage.providers else ""
if session.usage.responded_models:
providers += f", Modell laut Antwort: {session.usage.responded_models}"
usage = session.usage
cache = (f" (davon {usage.cached_tokens} aus dem Cache, {100 * usage.cached_tokens / usage.prompt_tokens:.0f} %)"
if usage.prompt_tokens else "")

View file

@ -125,6 +125,7 @@ def run_case(case: dict, model: str, run_no: int) -> dict:
objective_value=None, objective_ok=False, questions=fragen, answered=0, answer_findings=0,
task_ok=False, solver_error="; ".join(result.reliability_issues), reliable=False, restarts=0,
providers=result.usage.providers if result.usage else {},
responded_models=result.usage.responded_models if result.usage else {},
llm_calls=result.usage.calls if result.usage else 0,
reasoning_tokens=result.usage.reasoning_tokens if result.usage else 0,
cost_usd=result.usage.cost_usd if result.usage else None,
@ -159,6 +160,7 @@ def run_case(case: dict, model: str, run_no: int) -> dict:
reliable=result.reliable,
restarts=result.restarts,
providers=result.usage.providers,
responded_models=result.usage.responded_models,
llm_calls=result.usage.calls,
reasoning_tokens=result.usage.reasoning_tokens,
cost_usd=result.usage.cost_usd,
@ -208,6 +210,12 @@ def print_table(rows):
cost = sum(r.get("cost_usd") or 0 for r in mine)
print(f" {model}: Ergebnis korrekt {hits}/{len(mine)}, Aufgabe erfüllt {erfuellt}/{len(mine)}, Routing korrekt {routing}/{len(mine)}, "
f"Ø {seconds:.0f} s pro Lauf, Kosten gesamt ${cost:.4f} (ohne abgebrochene Läufe)")
antwortend = {}
for r in mine:
for name, n in (r.get("responded_models") or {}).items():
antwortend[name] = antwortend.get(name, 0) + n
if antwortend and set(antwortend) != {model}:
print(f" Modell laut Antwort: {antwortend}")
for r in rows:
if r.get("error") or r.get("solver_error"):

View file

@ -113,7 +113,7 @@ NUMBER_PATTERN = re.compile(r"(?<![A-Za-z0-9_])\d+(?:[.,]\d+)*(?![A-Za-z0-9_])")
# Teilfragen-Marker am Zeilenanfang: "a)", "- b)", "**Teil 2:**", "**Teil 2:** Text", "1." – bewusst eng, um
# Aufzählungen im Fließtext nicht als Frage zu zählen. Englische Marker ("Part 2", "Question 1", "Task 3")
# gehören dazu: sonst fehlt bei englischen Aufgaben das Nachfassen bei übersehenen Teilfragen.
QUESTION_PATTERN = re.compile(r"^[\s\-*>]*\**\s*(?:\(?([a-h])\)|(?:Teil|Aufgabe|Frage|Part|Question|Task)\s+(\d+)|([1-9])\.)\s*[:.)]?\**\s+\S",
QUESTION_PATTERN = re.compile(r"^[\s\-*>]*\**\s*(?:\(?([a-h])\)|(?:#{1,6}\s*)?(?:Teil|Aufgabe|Frage|Part|Question|Task)\s+(\d+)|([1-9])\.)\s*[:.)]?\**\s+\S",
re.MULTILINE)
@ -136,7 +136,8 @@ def normalize_number(roh: str) -> str:
roh = roh.replace(".", "").replace(",", ".")
elif "." in roh:
teile = roh.split(".")
if all(len(t) == 3 for t in teile[1:]) and len(teile[0]) <= 3:
# „0.040“ ist nie eine Tausenderschreibweise – vorher wurde es zu 40 (portfolio_qp, Rückrechnung 17.09.2026)
if all(len(t) == 3 for t in teile[1:]) and len(teile[0]) <= 3 and teile[0] != "0":
roh = "".join(teile) # 1.234 bzw. 12.345.678
wert = float(roh)
return str(int(wert)) if wert.is_integer() else f"{wert:g}" # große Zahlen exakt, nicht 1.23457e+06
@ -145,7 +146,9 @@ def normalize_number(roh: str) -> str:
def numbers_in(text: str) -> Set[str]:
"""Zahlen eines Textes in einheitlicher Schreibweise."""
gefunden = set()
for roh in NUMBER_PATTERN.findall(text or ""):
# LaTeX schreibt das Dezimalkomma als {,} (0{,}08) – sonst zerfiele die Zahl in 0 und 08
text = re.sub(r"(?<=\d)\{,\}(?=\d)", ",", text or "")
for roh in NUMBER_PATTERN.findall(text):
try:
gefunden.add(normalize_number(roh))
except ValueError:
@ -154,8 +157,15 @@ def numbers_in(text: str) -> Set[str]:
def question_ids_in(text: str) -> Set[str]:
"""Kennungen der Teilfragen, die im Aufgabentext erkennbar sind."""
return {(a or b or c).lower() for a, b, c in QUESTION_PATTERN.findall(text or "")}
"""Kennungen der Teilfragen, die im Aufgabentext erkennbar sind.
Nennt der Text Teilfragen ausdrücklich („### Teil 3“, „Aufgabe 2“), zählen bloße Nummerierungen („1.“) nicht –
beobachtet: eine nummerierte Liste von Restriktionen galt als acht Teilfragen, die als Überschriften gesetzten
eigentlichen Teilaufgaben wurden übersehen.
"""
treffer = QUESTION_PATTERN.findall(text or "")
ausdruecklich = any(b for _, b, _ in treffer)
return {(a or b or c).lower() for a, b, c in treffer if not (ausdruecklich and c and not (a or b))}
def _covers(genannt: str, erkannt: str) -> bool:

View file

@ -146,6 +146,13 @@ class LLMSession:
provider = getattr(completion, "provider", None) # OpenRouter: tatsächlich antwortender Anbieter
if provider:
self.usage.providers[provider] = self.usage.providers.get(provider, 0) + 1
# Beim Alias (~deepseek/deepseek-flash-latest) steht erst in der Antwort, welches Modell gerechnet hat;
# ohne diese Angabe wären Läufe vor und nach einem Modellwechsel des Alias nicht zu unterscheiden.
antwortend = getattr(completion, "model", None) or getattr(completion, "model_version", None)
if not isinstance(antwortend, str):
antwortend = None
if antwortend:
self.usage.responded_models[antwortend] = self.usage.responded_models.get(antwortend, 0) + 1
usage = getattr(completion, "usage", None)
if usage is not None: # OpenAI-Format (OpenRouter)
self.usage.prompt_tokens += usage.prompt_tokens or 0
@ -157,7 +164,7 @@ class LLMSession:
cost = getattr(usage, "cost", None)
if cost is not None:
self.usage.cost_usd = (self.usage.cost_usd or 0.0) + cost
self._log_call(step, seconds, before, getattr(completion, "provider", None), model)
self._log_call(step, seconds, before, getattr(completion, "provider", None), model, antwortend)
return
meta = getattr(completion, "usage_metadata", None)
if meta is not None: # Gemini-Format, keine Kostenangabe
@ -165,11 +172,11 @@ class LLMSession:
self.usage.completion_tokens += meta.candidates_token_count or 0
self.usage.reasoning_tokens += meta.thoughts_token_count or 0
self.usage.cached_tokens += getattr(meta, "cached_content_token_count", 0) or 0
self._log_call(step, seconds, before, getattr(completion, "provider", None), model)
self._log_call(step, seconds, before, getattr(completion, "provider", None), model, antwortend)
def _log_call(self, step, seconds, before, provider, model=None) -> None:
def _log_call(self, step, seconds, before, provider, model=None, responded_model=None) -> None:
self.usage.call_records.append(CallRecord(
step=step, seconds=round(seconds, 1), provider=provider, model=model,
step=step, seconds=round(seconds, 1), provider=provider, model=model, responded_model=responded_model,
completion_tokens=self.usage.completion_tokens - before[0],
reasoning_tokens=self.usage.reasoning_tokens - before[1],
prompt_tokens=self.usage.prompt_tokens - before[2],

View file

@ -427,7 +427,9 @@ class CallRecord(BaseModel):
prompt_tokens: int = 0
cached_tokens: int = Field(default=0, description="Input-Tokens aus dem Anbieter-Cache (billiger)")
provider: Optional[str] = None
model: Optional[str] = Field(default=None, description="Tatsächlich benutztes Modell (kann je Stufe abweichen)")
model: Optional[str] = Field(default=None, description="Angefragtes Modell (kann je Stufe abweichen)")
responded_model: Optional[str] = Field(
default=None, description="Modell laut Antwort – bei Aliasen wie ~deepseek/deepseek-flash-latest das tatsächliche")
class LLMUsage(BaseModel):
@ -440,6 +442,8 @@ class LLMUsage(BaseModel):
cached_tokens: int = Field(default=0, description="davon Input-Tokens aus dem Anbieter-Cache")
cost_usd: Optional[float] = Field(default=None, description="nur OpenRouter liefert Kosten")
providers: Dict[str, int] = Field(default_factory=dict, description="Antwortender Anbieter -> Anzahl Aufrufe (OpenRouter)")
responded_models: Dict[str, int] = Field(
default_factory=dict, description="Tatsächlich antwortendes Modell -> Anzahl Aufrufe (bei Aliasen vergleichsentscheidend)")
call_records: List[CallRecord] = Field(default_factory=list)
def by_step(self) -> Dict[str, dict]:

View file

@ -359,6 +359,8 @@ def build_document(result: PipelineResult, model: str = "") -> list:
+ (f", Neustarts: {result.restarts}" if result.restarts else ""))
if result.usage and result.usage.providers:
meta.append("Anbieter: " + ", ".join(f"{name} ({n}×)" for name, n in result.usage.providers.items()))
if result.usage and result.usage.responded_models:
meta.append("Modell laut Antwort: " + ", ".join(f"{name} ({n}×)" for name, n in result.usage.responded_models.items()))
doc.append(("list", meta))
doc += _result_section(result)

View file

@ -163,3 +163,29 @@ def test_understanding_and_figure_reading_share_their_own_model(monkeypatch):
monkeypatch.setenv("LLM_MODEL_UNDERSTANDING", "bild/modell")
assert model_for_step("Verstehen", "a/b") == "bild/modell"
assert model_for_step("Abbildungen", "a/b") == "bild/modell"
# --- Tatsächlich antwortendes Modell (wichtig bei Aliasen wie ~deepseek/deepseek-flash-latest) ---
def test_responded_model_is_recorded_per_call_and_summed():
from types import SimpleNamespace as NS
session, _ = fake_session(model="~deepseek/deepseek-flash-latest")
def antwort(modell):
return NS(provider="Together", model=modell,
usage=NS(prompt_tokens=10, completion_tokens=5, cost=0.0001,
completion_tokens_details=None, prompt_tokens_details=None))
session._record(antwort("deepseek/deepseek-v4.1-flash"), step="Verstehen", model="~deepseek/deepseek-flash-latest")
session._record(antwort("deepseek/deepseek-v4.2-flash"), step="Extraktion", model="~deepseek/deepseek-flash-latest")
assert [(r.model, r.responded_model) for r in session.usage.call_records] == [
("~deepseek/deepseek-flash-latest", "deepseek/deepseek-v4.1-flash"),
("~deepseek/deepseek-flash-latest", "deepseek/deepseek-v4.2-flash")]
assert session.usage.responded_models == {"deepseek/deepseek-v4.1-flash": 1, "deepseek/deepseek-v4.2-flash": 1}
def test_missing_or_odd_model_field_is_ignored():
from types import SimpleNamespace as NS
session, _ = fake_session()
session._record(NS(provider=None, model=object(), usage=NS(prompt_tokens=1, completion_tokens=1, cost=None,
completion_tokens_details=None, prompt_tokens_details=None)))
assert session.usage.responded_models == {} and session.usage.call_records[0].responded_model is None

View file

@ -476,3 +476,22 @@ def test_bold_marker_with_colon_inside_is_recognised():
from checks.model_checks import QUESTION_PATTERN
for zeile in ("**Teil 1:** Daten laden", "* **Question 2:** Solve", "**a)** Graph zeichnen"):
assert QUESTION_PATTERN.search(zeile), zeile
def test_leading_zero_is_never_a_thousands_separator():
from checks.model_checks import normalize_number
assert normalize_number("0.040") == "0.04" # vorher 40 (portfolio_qp)
assert normalize_number("1.234") == "1234" # deutsche Tausender unverändert
def test_latex_decimal_comma_is_one_number():
from checks.model_checks import numbers_in
assert numbers_in(r"\mu_A=0{,}08, \sigma=0{,}040") == {"0.08", "0.04"}
def test_heading_markers_win_over_numbered_lists():
from checks.model_checks import question_ids_in
text = ("## Restriktionen\n\n1. **Kassenbestand** je Periode\n\n2. **Lagerbilanz** je Produkt\n\n"
"### Teil 1: Formulierung\nKlassifizieren Sie das Problem.\n\n### Teil 2: Lösung\nLösen Sie das Modell.")
assert question_ids_in(text) == {"1", "2"}
assert question_ids_in("1. Berechnen Sie x.\n2. Deuten Sie y.\n3. Vergleichen Sie.") == {"1", "2", "3"}