230 lines
9.5 KiB
Python
230 lines
9.5 KiB
Python
"""Zwei Wege zum Modell, je nach Aufgabe.
|
|
|
|
`frage` geht über MiniMax im Anthropic-Messages-Format: die Prüfungen sind hundert
|
|
Anfragen je Lauf und leben von der Temperaturstreuung — das muss billig und
|
|
parallelisierbar sein.
|
|
|
|
`frage_lokal` geht über die angemeldete Claude-CLI, dasselbe Abo wie die
|
|
Agenten-Übergabe. Die Gespräche sind einzelne Aufrufe und leben von Urteilskraft;
|
|
dort zählt Qualität, nicht Durchsatz.
|
|
|
|
Welche Werkzeuge dabei offenstehen, entscheidet die Betriebsart, nicht das Modell:
|
|
Planung liest den Projektcode, Recherche sucht im Web, und keine der beiden kann,
|
|
was die andere darf. Die Prüfungen bekommen gar nichts — dort ist der Katalog die
|
|
einzige zulässige Quelle.
|
|
|
|
Kein Fallback zwischen beiden: fehlt ein Weg, bricht der Aufruf mit sichtbarer
|
|
Meldung ab, statt still auf das schwächere Modell auszuweichen."""
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import httpx
|
|
|
|
import config
|
|
|
|
ZEITLIMIT = 180.0
|
|
MAX_TOKENS = 8000
|
|
API_VERSION = "2023-06-01"
|
|
|
|
GRUNDLAGE = (
|
|
"claude", "-p", "--model", "opus", "--output-format", "json",
|
|
# Spart ein Fünftel der Grundlast; was hier gilt, steht ohnehin im eigenen Prompt.
|
|
"--exclude-dynamic-system-prompt-sections",
|
|
# Ohne das erbt der Aufruf, was am Rechner sonst eingerichtet ist: MCP-Server,
|
|
# Workflows, Hooks, ein fremder Antwortstil. Ein Gespräch über Feature-Karten hat
|
|
# in keinem Postfach etwas zu suchen.
|
|
"--safe-mode",
|
|
)
|
|
|
|
# Was in keiner Betriebsart erlaubt ist. Beide Gespräche holen fremden Text in den
|
|
# Kontext — das eine Suchtreffer, das andere Quelltext — und fremder Text darf keinen
|
|
# Terminplan anlegen, keine Nachricht verschicken und keine Agenten starten. Was
|
|
# künftig an Werkzeugen dazukommt, steht hier nicht: beim Nachziehen der CLI gehört
|
|
# diese Liste geprüft.
|
|
GESPERRT = (
|
|
"Write", "Edit", "NotebookEdit", "Bash", "Task", "TodoWrite",
|
|
"Workflow", "Skill", "ReportFindings", "ScheduleWakeup", "DesignSync",
|
|
"CronCreate", "CronDelete", "CronList", "RemoteTrigger", "PushNotification",
|
|
"SendMessage", "Monitor", "EnterWorktree", "ExitWorktree",
|
|
"TaskCreate", "TaskGet", "TaskList", "TaskOutput", "TaskStop", "TaskUpdate",
|
|
)
|
|
|
|
|
|
def _kommando(erlaubt: tuple, gesperrt: tuple, zuege: int) -> tuple:
|
|
"""Ein Aufruf mit genau den Werkzeugen einer Betriebsart.
|
|
|
|
`--allowed-tools` ist nicht bloß die Umkehrung von `--disallowed-tools`: ohne die
|
|
Vorabfreigabe fragt die CLI je Suche und je Datei nach Erlaubnis und bekommt im
|
|
Stapelbetrieb keine Antwort. Der Versuch endet dann als Absage, nicht als Fehler.
|
|
|
|
`ToolSearch` steht nirgends: Suche, Abruf und Lesen sind nachladbar, ohne das
|
|
Werkzeug käme das Modell gar nicht an sie heran. Was es nachlädt, ist trotzdem
|
|
gesperrt — die Sperre wirkt beim Aufruf, nicht beim Suchen.
|
|
"""
|
|
return (*GRUNDLAGE,
|
|
"--allowed-tools", *erlaubt,
|
|
"--disallowed-tools", *GESPERRT, *gesperrt,
|
|
"--max-turns", str(zuege))
|
|
|
|
|
|
# Planen heißt: an den Karten arbeiten, mit dem Code als Beleg. Das Web bleibt zu —
|
|
# was das Projekt können soll, entscheidet die Person, nicht eine fremde Seite.
|
|
PLANUNG_KOMMANDO = _kommando(
|
|
("Read", "Glob", "Grep"), ("WebSearch", "WebFetch"), 20)
|
|
# Ohne eingestelltes Arbeitsverzeichnis gibt es nichts zu lesen. Lesen bliebe hier
|
|
# nicht folgenlos: ohne gesetzten Ordner liefe die Suche im Serververzeichnis, und
|
|
# das Modell beschriebe planer2 statt des Projekts.
|
|
PLANUNG_OHNE_CODE = _kommando(
|
|
(), ("Read", "Glob", "Grep", "WebSearch", "WebFetch"), 1)
|
|
# Recherche geht nach draußen und nirgendwo sonst: kein Blick in Dateien, damit
|
|
# nichts aus dem Projekt in einer Suchanfrage landet.
|
|
RECHERCHE_KOMMANDO = _kommando(
|
|
("WebSearch", "WebFetch"), ("Read", "Glob", "Grep"), 24)
|
|
|
|
# Opus denkt länger als MiniMax, der Prozessstart kostet extra, und Suchen wie
|
|
# Codelesen legen mehrere Abrufe obendrauf.
|
|
CHAT_ZEITLIMIT = 600.0
|
|
|
|
# Nur fürs Log: welcher Weg gerade lief. Ohne den Namen sagt eine Zeitangabe nichts,
|
|
# denn dieselbe Sekundenzahl ist bei einem Zug teuer und bei zwanzig billig.
|
|
WEGE = {
|
|
PLANUNG_KOMMANDO: "planung+code",
|
|
PLANUNG_OHNE_CODE: "planung",
|
|
RECHERCHE_KOMMANDO: "recherche",
|
|
}
|
|
|
|
# Der Provider drosselt bei vielen gleichzeitigen Anfragen
|
|
WIEDERHOLBAR = (429, 500, 502, 503, 504)
|
|
WIEDERHOLUNGEN = 6
|
|
PAUSE = 2.0
|
|
# Länger als das zu warten hilft nicht mehr; dann ist die Drossel zu weit offen
|
|
HOECHSTPAUSE = 60.0
|
|
|
|
|
|
class NichtKonfiguriert(ValueError):
|
|
"""Zugangsdaten fehlen. Eigener Typ, damit die Route das nicht mit
|
|
„Prüfung läuft bereits" verwechselt — beides wäre sonst ein ValueError."""
|
|
|
|
|
|
def pruefe_konfiguration():
|
|
fehlend = [
|
|
name
|
|
for name, wert in (
|
|
("MINIMAX_API_KEY", config.MINIMAX_API_KEY),
|
|
("MINIMAX_MODELL", config.MINIMAX_MODELL),
|
|
("MINIMAX_URL", config.MINIMAX_URL),
|
|
)
|
|
if not wert
|
|
]
|
|
if fehlend:
|
|
raise NichtKonfiguriert(f"Nicht konfiguriert: {', '.join(fehlend)} in .env fehlt")
|
|
|
|
|
|
async def frage_lokal(system: str, inhalt: str,
|
|
kommando: tuple = PLANUNG_OHNE_CODE,
|
|
ordner: str | None = None) -> str:
|
|
"""Eine Runde über die Claude-CLI — der Text der Antwort.
|
|
|
|
Der Prompt geht über die Standardeingabe: Katalog und Kartenstand sind zu lang,
|
|
um sie als Kommandozeilenargument zu übergeben. Keine Temperatur — die CLI kennt
|
|
keine; für einen einzelnen Zug braucht es auch keine Streuung.
|
|
|
|
`ordner` ist das Verzeichnis, in dem der Aufruf läuft. Es entscheidet, was ein
|
|
erlaubtes Lesen erreicht; ohne Angabe erbt der Prozess das Serververzeichnis, und
|
|
das ist nur zulässig, solange gar nicht gelesen werden darf.
|
|
"""
|
|
try:
|
|
prozess = await asyncio.create_subprocess_exec(
|
|
*kommando, "--system-prompt", system,
|
|
cwd=ordner,
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
except FileNotFoundError:
|
|
raise NichtKonfiguriert(
|
|
"Claude-CLI nicht gefunden — „claude“ muss im Pfad liegen und angemeldet sein"
|
|
)
|
|
try:
|
|
aus, err = await asyncio.wait_for(
|
|
prozess.communicate(inhalt.encode()), CHAT_ZEITLIMIT)
|
|
except asyncio.TimeoutError:
|
|
prozess.kill()
|
|
raise RuntimeError(f"Claude antwortet nicht innerhalb von {CHAT_ZEITLIMIT:.0f} s")
|
|
if prozess.returncode != 0:
|
|
raise RuntimeError(f"Claude bricht ab: {err.decode()[-300:].strip() or 'ohne Meldung'}")
|
|
try:
|
|
daten = json.loads(aus)
|
|
except ValueError:
|
|
raise RuntimeError("Antwort der Claude-CLI ist kein JSON")
|
|
if daten.get("is_error"):
|
|
raise RuntimeError(f"Claude meldet einen Fehler: {daten.get('result')}")
|
|
_notiere(kommando, daten)
|
|
text = str(daten.get("result") or "")
|
|
if not text.strip():
|
|
raise RuntimeError("Leere Antwort von Claude")
|
|
return text
|
|
|
|
|
|
def _notiere(kommando: tuple, daten: dict):
|
|
"""Wohin die Wartezeit ging — ins Serverlog, eine Zeile je Aufruf.
|
|
|
|
Die CLI legt es ohnehin bei; ungelesen bleibt nur die Frage „wieso dauert das so
|
|
lange" offen. Ein Zug ist ein Modellaufruf: jede Suche, jeder Abruf, jedes Lesen
|
|
zählt einzeln. Sind die Züge hoch, hat das Modell gearbeitet; ist einer teuer, war
|
|
der Prompt lang.
|
|
"""
|
|
zuege = daten.get("num_turns") or 0
|
|
api = (daten.get("duration_api_ms") or 0) / 1000
|
|
print(f"[claude] {WEGE.get(kommando, 'aufruf')}: {api:.0f}s, {zuege} Züge, "
|
|
f"{(api / zuege if zuege else api):.0f}s/Zug, "
|
|
f"${daten.get('total_cost_usd') or 0:.3f}", flush=True)
|
|
|
|
|
|
def _pause(res, versuch: int) -> float:
|
|
"""Wie lange bis zum nächsten Versuch — der Provider darf es selbst sagen."""
|
|
gesagt = res.headers.get("retry-after")
|
|
try:
|
|
return min(float(gesagt), HOECHSTPAUSE)
|
|
except (TypeError, ValueError):
|
|
return min(PAUSE * (2 ** versuch), HOECHSTPAUSE)
|
|
|
|
|
|
async def frage(system: str, inhalt: str, temperatur: float = 0.0) -> str:
|
|
"""Eine Anfrage, eine Antwort — der Text der Modellantwort.
|
|
Kalt geroutet: Temperatur 0, kein Thinking; die Prüfung soll nicht raten.
|
|
Für Mehrheitsentscheide wird sie bewusst angehoben, sonst wären die Stimmen gleich."""
|
|
pruefe_konfiguration()
|
|
daten = {
|
|
"model": config.MINIMAX_MODELL,
|
|
"max_tokens": MAX_TOKENS,
|
|
"system": system,
|
|
"messages": [{"role": "user", "content": inhalt}],
|
|
"temperature": temperatur,
|
|
"thinking": {"type": "disabled"},
|
|
}
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(ZEITLIMIT, connect=30)) as client:
|
|
for versuch in range(WIEDERHOLUNGEN):
|
|
res = await client.post(
|
|
config.MINIMAX_URL,
|
|
headers={"x-api-key": config.MINIMAX_API_KEY,
|
|
"anthropic-version": API_VERSION},
|
|
json=daten,
|
|
)
|
|
if res.status_code not in WIEDERHOLBAR or versuch == WIEDERHOLUNGEN - 1:
|
|
break
|
|
# Zu viele Anfragen oder Serverfehler: warten und noch einmal.
|
|
# Ohne das fielen bei großen Läufen ganze Stimmen aus — und ein
|
|
# fehlendes Urteil sah aus wie „nichts gefunden".
|
|
await asyncio.sleep(_pause(res, versuch))
|
|
if res.status_code != 200:
|
|
raise RuntimeError(f"Provider antwortet mit HTTP {res.status_code}")
|
|
antwort = res.json()
|
|
text = "".join(
|
|
b.get("text", "") for b in antwort.get("content", []) if b.get("type") == "text"
|
|
)
|
|
if not text.strip():
|
|
raise RuntimeError(f"Leere Antwort (stop_reason={antwort.get('stop_reason')})")
|
|
return text
|