Files
mission-control-v2/backend/services/router_logic.py
T
HitonabiandClaude Opus 5.5 91aa16eee1
Ampel / ampel (push) Failing after 21s
bilder: Hirn und Coder sehen selbst - ueber Bild-Zwillinge, Text bleibt mit Draft schnell
llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500 "failed to process
speculative batch", b11057 und b11157 geprueft; speculative.n_max=0 je Anfrage hilft nicht).
Darum bekommen Hirn und Coder je einen Bild-Zwilling: gleiche Gewichte plus Projektor, ohne
Draft (vision, coder-bild), in einer eigenen llama-swap-Gruppe, die den Coder nicht verdraengt.
Probe 24.09.: beide 8/8 Bildmerkmale; Hirn-Zwilling 68 t/s, Coder-Zwilling 12,5 t/s.

Bild-Weiche v3 im Gateway: Bild im aktuellen Schritt geht an den Zwilling der Rolle, aeltere
Bilder werden einmal beschrieben (gemerkt) und als Text mitgeschickt, damit der Rest einer
Agenten-Aufgabe wieder beim schnellen Modell laeuft. Qwen3-VL gibt "vision" ab, der Coder
verliert den Projektor, der mit Draft nur HTTP 500 lieferte.

Radar misst die Bildfaehigkeit des heutigen Modells ueber dessen Zwilling (sonst gewaenne
jeder bildfaehige Kandidat mit "versteht Bilder"). Pruefstand: Coder darf vor dem Aendern
lesen (Version 3). Modelle-Seite zeigt "Bilder: ja" ueber den Zwilling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:02:06 +02:00

156 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Lane-Routing für den eingebauten MC2-Gateway (:9001/v1).
Zwei virtuelle Lanes, die Clients/IDEs auswählen — der Router pickt das echte Modell:
- **chat** (= altes `auto`): Alltag → `fast`, schwer/lang → `heavy`.
- **coding**: Code-Arbeit → `coder`; riesiger/architektonischer Kontext → `heavy`;
triviale Kurzfrage ohne Code → `fast` (Tempo).
Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy
(routing_policy.py, hot-reload; Env = Defaults). Lucy läuft NICHT hierüber — die ist der
Hermes-Agent (:8642), eigene Ebene.
"""
import re
from services.routing_policy import load_policy
# Modell-Aliases & Zeichen-Schwellen liegen jetzt in der UI-editierbaren Policy
# (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars
# sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code.
# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind.
LANES = ["coding", "chat"]
LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat
_HEAVY_KW = re.compile(
r"\b(beweis|prove|theorem|komplex|complex|schwierig|"
r"think\s*hard|reason\s*carefully|tief\s*nachdenk|optimi[sz]e|"
r"root\s*cause|analy[sz]e\s+deeply|step[-\s]?by[-\s]?step)\b",
re.IGNORECASE,
)
# Coding-spezifische „das ist groß/architektonisch" Signale → heavy statt coder.
_CODING_HEAVY_KW = re.compile(
r"\b(architekt|architect|system[-\s]?design|refactor\s+the\s+(whole|entire)|"
r"ganze[ns]?\s+(architektur|codebase|projekt)|migrat\w+\s+(the\s+)?(whole|entire|gesamte)|"
r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b",
re.IGNORECASE,
)
# Code-Indikatoren — verhindert, dass echte Code-Anfragen als „trivial" auf fast abrutschen.
# Bewusst breit (inkl. natürlichsprachiger Coding-Begriffe DE+EN): in der coding-Lane soll im Zweifel
# `coder` gewinnen; nur echte Nicht-Code-Kürze ("hallo", "wie spät") rutscht auf fast.
_CODE_HINT = re.compile(
r"```|\bdef \b|\bclass \b|\bimport \b|\bfunction\b|=>|;\s*$|"
r"\.(py|ts|tsx|js|jsx|go|rs|java|cpp|c|rb|php|sql)\b|/src/|traceback|stack\s*trace|"
r"\b(funktion|function|bug|fix|fehler|error|exception|implementier\w*|schreib\w*|"
r"code\w*|coden|test\w*|klasse|method\w*|methode|refactor\w*|kompil\w*|compile|"
r"build|deploy|debug|script|skript|api|endpoint|query|regex|json|yaml|"
r"npm|pip|git|docker|terminal|shell|command)\b",
re.IGNORECASE | re.MULTILINE,
)
# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet.
# Seit 24.09.2026 sind vision und coder-bild die Bild-Zwillinge von Hirn und Coder (gleiche Gewichte
# plus Bild-Projektor, ohne Draft — llama.cpp kann Draft und Bild nicht zusammen, HTTP 500).
VISION_CAPABLE = {"vision", "coder-bild", "scout"}
IMAGE_PART_TYPES = {"image_url", "input_image", "image"}
def ist_coder_alias(alias: str) -> bool:
"""Coder-Ziele: coder, heavy (derselbe Coder) und rohe Coder-IDs."""
a = (alias or "").lower()
return "coder" in a or a == "heavy"
def bilder_aufteilen(body: dict) -> tuple[list[tuple[dict, int]], list[tuple[dict, int]]]:
"""Bild-Parts in (frisch, alt) teilen. Frisch = nach der letzten Antwort des Modells (der Schritt,
um den es gerade geht: neue Nutzer-Nachricht oder Werkzeug-Ergebnis). Alt = davor — das Modell hat
sie schon gesehen und seine Schlüsse im Verlauf. Jeder Eintrag ist (Nachricht, Index im content)."""
msgs = [m for m in body.get("messages") or [] if isinstance(m, dict)]
letzte_antwort = max((i for i, m in enumerate(msgs) if m.get("role") == "assistant"), default=-1)
frisch: list[tuple[dict, int]] = []
alt: list[tuple[dict, int]] = []
for i, m in enumerate(msgs):
if not isinstance(m.get("content"), list):
continue
for j, part in enumerate(m["content"]):
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
(frisch if i > letzte_antwort else alt).append((m, j))
return frisch, alt
def bild_ziel(alias: str, vision_alias: str, coder_vision_alias: str | None) -> str:
"""Bild-Zwilling für eine Anfrage mit frischem Bild: Coder-Ziele an den Coder-Zwilling (falls es ihn
gibt), alles andere an den Hirn-Zwilling (vision)."""
if coder_vision_alias and ist_coder_alias(alias):
return coder_vision_alias
return vision_alias
def has_image(body: dict) -> bool:
"""True, wenn irgendeine Nachricht einen Bild-Part enthaelt (OpenAI-multimodaler
content: eine Liste mit einem {"type": "image_url"|"input_image"|"image", ...}-Teil)."""
for m in body.get("messages") or []:
if not isinstance(m, dict):
continue
content = m.get("content")
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
return True
return False
def _text_of(body: dict) -> str:
msgs = body.get("messages") or []
# Multimodaler content ist eine Liste — nur die Text-Parts fuers Komplexitaets-Routing
# zusammenziehen (ein dict/Liste als str() wuerde die Zeichen-Schwelle verfaelschen).
out = []
for m in msgs:
if not isinstance(m, dict):
continue
c = m.get("content")
if isinstance(c, str):
out.append(c)
elif isinstance(c, list):
for part in c:
if isinstance(part, dict) and part.get("type") == "text":
out.append(str(part.get("text") or ""))
return "\n".join(out)
def _route_chat(text: str, n: int) -> tuple[str, str]:
p = load_policy()
if n > p["heavy_chars"]:
return p["heavy"], f"langer Kontext ({n} > {p['heavy_chars']} Zeichen)"
if _HEAVY_KW.search(text):
return p["heavy"], "Komplexitäts-Schlüsselwort erkannt"
return p["fast"], "Standard"
def _route_coding(text: str, n: int) -> tuple[str, str]:
# Agentisches Coden (OpenCode/RooCode/…) bleibt IMMER beim dedizierten Coder — NIE heavy/fast
# (das sind Allzweck-Modelle, schwächer bei Code). Die Qwen-Coder packen 256K1M Kontext selbst,
# langer Repo-Kontext ist bei Agenten der Normalfall und darf NICHT zu heavy umrouten.
# (Phase 2b: warme schnelle Coder-Stufe coder_lite als Default + coder als Eskalation.)
p = load_policy()
if p["coder_lite"] and not _CODING_HEAVY_KW.search(text) and n <= p["coding_escalate_chars"]:
return p["coder_lite"], "Coding (schneller Coder)"
return p["coder"], "Coding -> starker Coder"
def choose_for_lane(lane: str, body: dict) -> tuple[str, str]:
"""Wählt das echte Modell-Alias für eine Lane. Gibt (alias, begründung) zurück."""
lane = LANE_ALIASES.get((lane or "chat").lower(), (lane or "chat").lower())
text = _text_of(body)
n = len(text)
if lane == "coding":
return _route_coding(text, n)
return _route_chat(text, n) # chat + alles Unbekannte
def choose_model(body: dict) -> tuple[str, str]:
"""Rückwärtskompatibel: altes `model:auto` == chat-Lane."""
return choose_for_lane("chat", body)