"""Bild-Weiche v3 (24.09.2026): frische Bilder an den Bild-Zwilling, ältere als Beschreibung.""" import asyncio import copy from typing import ClassVar from routers import gateway_proxy from services.router_logic import bild_ziel, bilder_aufteilen, ist_coder_alias BILD_A = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}} BILD_B = {"type": "image_url", "image_url": {"url": "data:image/png;base64,BBBB"}} def verlauf() -> dict: """Agenten-Ablauf: Bild A im ersten Schritt, danach Werkzeug-Aufruf, jetzt Bild B als Ergebnis.""" return {"model": "coder", "messages": [ {"role": "system", "content": "Du bist ein Coding-Agent."}, {"role": "user", "content": [BILD_A, {"type": "text", "text": "Was zeigt der Fehler?"}]}, {"role": "assistant", "content": "", "tool_calls": [ {"id": "c1", "type": "function", "function": {"name": "screenshot", "arguments": "{}"}}]}, {"role": "tool", "tool_call_id": "c1", "content": "Bildschirmfoto folgt."}, {"role": "user", "content": [BILD_B, {"type": "text", "text": "Bildschirmfoto aus screenshot."}]}, ]} def test_frisch_ist_was_nach_der_letzten_antwort_kommt(): frisch, alt = bilder_aufteilen(verlauf()) assert [m["content"][i] for m, i in frisch] == [BILD_B] assert [m["content"][i] for m, i in alt] == [BILD_A] def test_ohne_antwort_sind_alle_bilder_frisch(): frisch, alt = bilder_aufteilen({"messages": [{"role": "user", "content": [BILD_A, BILD_B]}]}) assert len(frisch) == 2 and alt == [] def test_coder_ziele_gehen_an_den_coder_zwilling_alles_andere_an_vision(): assert ist_coder_alias("coder") and ist_coder_alias("heavy") and not ist_coder_alias("fast") assert bild_ziel("coder", "vision", "coder-bild") == "coder-bild" assert bild_ziel("heavy", "vision", "coder-bild") == "coder-bild" assert bild_ziel("fast", "vision", "coder-bild") == "vision" assert bild_ziel("coder", "vision", None) == "vision" # ohne Coder-Zwilling: Hirn-Zwilling class _FalscherClient: """Ersetzt httpx.AsyncClient: zählt Beschreibungs-Anfragen und antwortet mit fester Beschreibung.""" anfragen: ClassVar[list] = [] def __init__(self, *a, **kw): pass async def __aenter__(self): return self async def __aexit__(self, *a): return False async def post(self, url, json): _FalscherClient.anfragen.append(json) class Antwort: status_code = 200 text = "" @staticmethod def json(): return {"choices": [{"message": {"content": "Fehlermeldung: PORT 4711 belegt"}}]} return Antwort() def test_aeltere_bilder_werden_einmal_beschrieben_und_gemerkt(monkeypatch): monkeypatch.setattr(gateway_proxy.httpx, "AsyncClient", _FalscherClient) monkeypatch.setattr(gateway_proxy, "_BESCHREIBUNGEN", gateway_proxy.OrderedDict()) _FalscherClient.anfragen = [] body = verlauf() _, alt = bilder_aufteilen(body) assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is True ersetzt = body["messages"][1]["content"][0] assert ersetzt["type"] == "text" and "PORT 4711" in ersetzt["text"] assert body["messages"][4]["content"][0] == BILD_B # das frische Bild bleibt ein Bild anfrage = _FalscherClient.anfragen[0] assert anfrage["model"] == "vision" and anfrage["chat_template_kwargs"] == {"enable_thinking": False} # Nächster Schritt schickt denselben Verlauf erneut: aus dem Gedächtnis, keine zweite Anfrage. nochmal = verlauf() _, alt = bilder_aufteilen(nochmal) assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is True assert len(_FalscherClient.anfragen) == 1 def test_zu_viele_neue_bilder_bleiben_drin(monkeypatch): monkeypatch.setattr(gateway_proxy, "_BESCHREIBUNGEN", gateway_proxy.OrderedDict()) monkeypatch.setattr(gateway_proxy, "_BILD_MAX", 1) body = {"messages": [{"role": "user", "content": [BILD_A, BILD_B]}, {"role": "assistant", "content": "ok"}, {"role": "user", "content": "und jetzt?"}]} vorher = copy.deepcopy(body) _, alt = bilder_aufteilen(body) assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is False assert body == vorher