update
This commit is contained in:
@@ -1,8 +1,23 @@
|
||||
"""Anbindung an MiniMax über das Anthropic-Messages-Format. Kein Fallback: fehlt die
|
||||
Konfiguration oder antwortet der Provider nicht, bricht der Aufruf mit sichtbarer
|
||||
Meldung ab."""
|
||||
"""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
|
||||
|
||||
@@ -12,6 +27,73 @@ 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
|
||||
@@ -39,6 +121,67 @@ def pruefe_konfiguration():
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user