Ampel / ampel (push) Successful in 26s
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>
139 lines
6.0 KiB
Python
139 lines
6.0 KiB
Python
"""
|
||
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
|
||
|
||
|