This commit is contained in:
team3
2026-09-07 07:44:00 +02:00
parent 5d42b4cfc5
commit c6bbed4049
250 changed files with 13372 additions and 6203 deletions

View File

@@ -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")