Files
mission-control-v2/backend/services/router_logic.py
T
HitonabiandClaude Opus 5.5 e9f488b56c
Ampel / ampel (push) Successful in 26s
phase1b: Backend entruempelt und robuster (35 tote Routen raus, Sperren, ehrliche Update-Pruefung)
Ballast raus:
- 35 Routen ohne Nutzer entfernt (agent/*, fit, roles, ctx, drafts, groups, routing/policy,
  system/history, system/self-update, maintenance/reboot, zeitmaschine/inhalt, zeitplan,
  voice/health|metrics|trace|voices|reference|tts). Von 95 auf 60.
- Tote Module geloescht: agent-Router, roles, agent_aktivitaet, metrics_history (samt
  10-s-Sampler), voice_metrics, migrate_config, parse_mc2_timeout, scripts/.
- Unbenutzte Funktionen und Konstanten entfernt (Modell-Upgrade-Empfehlung, Draft-/Kontext-
  Setzer, Konsole, PC-Ausfuehrer-Probe, Routing-Policy-Editor ...).

Robuster:
- Jobs in eigener Prozessgruppe (Abbrechen beendet wirklich alles), Zeitlimit je Job-Art,
  start_job_exklusiv: zwei Klicks starten kein doppeltes Update mehr; alte Jobs raeumen sich auf.
- Update-Pruefung meldet Fehler (pruef_fehler, Lampe "Pruefung unklar") statt "aktuell".
- Nach jedem Update sofort neu pruefen (update_stand) statt 10 Minuten alten Stand zeigen.
- llama-swap-Config: Sperre (RLock + flock) fuer UI, Radar, Aufraeumen und Hirn-Umstellung.
- Hermes-Config: bei Lesefehler nichts schreiben, atomar, mit Sicherung.
- Live-Strom und Gateway-Warnung blockieren den Event-Loop nicht mehr (Lucy, OpenChamber).
- Gateway antwortet bei Engine-Ausfall im OpenAI-Fehlerformat (502) statt nacktem 500.
- Abgestuerzte Waechter-Pruefung wird ein gelber Hinweis statt still zu verschwinden.
- Download laedt nur den gewuenschten Quant (vorher bei Fehlen alle Teile aller Varianten),
  Download-Jobs in Gruppe "download"; HF-Suche kodiert den Suchbegriff.
- Herkunftspruefung: schreibende /api-Aufrufe fremder Webseiten werden abgelehnt (keine
  Anmeldung, User-Entscheid); Skripte, Desktop-Lucy und /v1 unveraendert.
- Modellpfade: Eintragen und Loeschen nur innerhalb von MODELS_DIR.
- Dienste-Liste fragt keine abgebauten Dienste mehr ab (PC-Ausfuehrer haette 3 s gekostet).
- SSE-Fehlerzeilen von /api/voice/chat als gueltiges JSON.
- mission-control-2.service: --timeout-graceful-shutdown 3 (Neustart ohne 10-s-Haenger).

Tests: 92 gruen (neu: Herkunft, Quant-Auswahl, abgestuerzte Pruefung).

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

139 lines
6.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.
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,
)
# 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 256K–1M 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