init
This commit is contained in:
161
backend/llm.py
Normal file
161
backend/llm.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Call-Schicht über agents.py: Template-Rendering, JSON-Parsen, Infra-Behandlung,
|
||||
Hedge, Panels, Ledger, Budget.
|
||||
|
||||
Fehlerklassen bewusst getrennt (Lektion 1/2/68):
|
||||
- Inhaltsfehler (ungültiges JSON, leere Antwort) → begrenzte Restarts, dann None.
|
||||
- Infra-Fehler (429/Timeout/Netz) → eigener Zähler mit Backoff, erschöpft → LaufPause
|
||||
(fail-closed: der Lauf pausiert fortsetzbar, statt mit Teilergebnis weiterzulaufen)."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
import agents
|
||||
import jsonx
|
||||
import ledger
|
||||
import prompts
|
||||
from config import HEDGE_NACH_S, INFRA_BACKOFF_BASE, INFRA_MAX_RETRIES, timeout_fuer
|
||||
|
||||
log = logging.getLogger("creator2.llm")
|
||||
|
||||
MAX_RESTARTS = 2 # inhaltliche Neuversuche pro Call
|
||||
|
||||
|
||||
class LaufPause(Exception):
|
||||
"""Infrastruktur erschöpft — Lauf pausieren, nicht weiterrechnen."""
|
||||
|
||||
|
||||
class Kontext:
|
||||
"""Ein Lauf: wandert durch alle Ebenen, trägt Ledger-Zuordnung."""
|
||||
|
||||
def __init__(self, run_id: int, topic: str, provider: str):
|
||||
self.run_id = run_id
|
||||
self.topic = topic
|
||||
self.provider = provider
|
||||
self.ebene = ""
|
||||
|
||||
|
||||
_INFRA_MARKER = ("429", "rate limit", "rate_limit", "timeout", "timed out", "connection",
|
||||
"network", "overloaded", "unavailable", "database is locked")
|
||||
|
||||
|
||||
def _ist_infra(err: str) -> bool:
|
||||
e = err.lower()
|
||||
return any(m in e for m in _INFRA_MARKER)
|
||||
|
||||
|
||||
async def _roher_call(key: str, prompt: str, timeout: int, ctx: Kontext,
|
||||
role: str, caps: str) -> agents.AgentErgebnis:
|
||||
"""Ein Agent-Call mit Stall-Hedge: läuft der Slot max(HEDGE_NACH_S, timeout/2)
|
||||
ohne Ergebnis, startet genau EIN Zwilling; das erste valide Ergebnis gewinnt."""
|
||||
schwelle = max(HEDGE_NACH_S, timeout / 2) if HEDGE_NACH_S > 0 else 0
|
||||
|
||||
async def einer(k: str):
|
||||
return await agents.run_agent(k, prompt, timeout, provider=ctx.provider,
|
||||
role=role, capabilities=caps)
|
||||
|
||||
haupt = asyncio.ensure_future(einer(key))
|
||||
if not schwelle:
|
||||
return await haupt
|
||||
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
|
||||
if fertig:
|
||||
return haupt.result()
|
||||
zwilling = asyncio.ensure_future(einer(f"{key}-h"))
|
||||
try:
|
||||
for aufgabe in asyncio.as_completed([haupt, zwilling]):
|
||||
res = await aufgabe
|
||||
if res.ok:
|
||||
return res
|
||||
return res # beide fertig, keins ok → letztes Ergebnis zur Diagnose
|
||||
finally:
|
||||
for t in (haupt, zwilling):
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
|
||||
|
||||
async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
|
||||
role: str = "judge", caps: str = "none", schritt: str | None = None,
|
||||
n: int = 0, item: str = "", erwartet=dict):
|
||||
"""Ein LLM-Call → geparstes JSON (erwartet: dict|list) oder roher Text (erwartet=str).
|
||||
None nur nach erschöpften inhaltlichen Restarts. Ledger + Budget immer."""
|
||||
prompt, thash = prompts.render(template, **werte)
|
||||
timeout = timeout_fuer(schritt or stage, n)
|
||||
infra_rest = INFRA_MAX_RETRIES
|
||||
inhalt_rest = MAX_RESTARTS
|
||||
versuch = 0
|
||||
while True:
|
||||
versuch += 1
|
||||
key = f"{ctx.topic}-{ctx.ebene}-{stage}-{item or 'x'}-{versuch}"
|
||||
start = time.monotonic()
|
||||
status, tokens, err, wait_ms = "error", None, "", 0
|
||||
try:
|
||||
res = await _roher_call(key, prompt, timeout, ctx, role, caps)
|
||||
tokens, err = res.tokens, res.err
|
||||
wait_ms = int(res.wait_s * 1000)
|
||||
if res.ok:
|
||||
if erwartet is str:
|
||||
status = "ok"
|
||||
return res.text
|
||||
daten = jsonx.parse(res.text)
|
||||
if daten is not None and isinstance(daten, erwartet):
|
||||
status = "ok"
|
||||
return daten
|
||||
status, err = "parse", f"unparsbar/falscher Typ: {res.text[:200]}"
|
||||
elif _ist_infra(res.err):
|
||||
status = "infra"
|
||||
else:
|
||||
status = "error"
|
||||
err = res.err
|
||||
except asyncio.TimeoutError:
|
||||
status = "infra"
|
||||
err = "timeout"
|
||||
finally:
|
||||
ledger.log_call(ctx.run_id, ebene=ctx.ebene, stage=stage, item=item,
|
||||
template=template, template_hash=thash, role=role,
|
||||
provider=ctx.provider, status=status,
|
||||
dur_ms=int((time.monotonic() - start) * 1000),
|
||||
wait_ms=wait_ms,
|
||||
tokens=tokens, meta={"err": err[:300]} if err else None)
|
||||
ledger.budget_pruefen(ctx.run_id)
|
||||
if status == "infra":
|
||||
infra_rest -= 1
|
||||
if infra_rest < 0:
|
||||
raise LaufPause(f"{stage}: Infrastruktur erschöpft ({err})")
|
||||
pause = INFRA_BACKOFF_BASE * 2 ** (INFRA_MAX_RETRIES - infra_rest - 1)
|
||||
agents.drossel_melden(pause) # global: auch Timeouts/CLI-429 bremsen alle
|
||||
log.warning("%s: Infra-Fehler (%s), Pause %.0fs", key, err[:80], pause)
|
||||
await asyncio.sleep(pause)
|
||||
continue
|
||||
inhalt_rest -= 1
|
||||
if inhalt_rest < 0:
|
||||
log.warning("%s: inhaltlich erschöpft (%s)", key, err[:120])
|
||||
return None
|
||||
log.info("%s: Neuversuch (%s)", key, err[:80])
|
||||
|
||||
|
||||
async def alle(coros) -> list:
|
||||
"""gather-Variante, die bei der ersten Exception (LaufPause/Budget) die
|
||||
Geschwister ABBRICHT — sonst brennen Hunderte laufende Repair-Tasks weiter."""
|
||||
tasks = [asyncio.ensure_future(c) for c in coros]
|
||||
try:
|
||||
return await asyncio.gather(*tasks)
|
||||
except BaseException:
|
||||
for t in tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
raise
|
||||
|
||||
|
||||
async def panel(ctx: Kontext, groesse: int, **kw) -> list:
|
||||
"""`groesse` unabhängige Calls, alle Ergebnisse (None-gefiltert). Fällt genau eine
|
||||
Stimme eines 2er-Panels aus, ersetzt EIN Ersatz-Richter (Lektion 20), dann
|
||||
entscheidet der Aufrufer über Konsens."""
|
||||
item = kw.get("item", "")
|
||||
stimmen = await asyncio.gather(*(call(ctx, **{**kw, "item": f"{item}-p{i + 1}"})
|
||||
for i in range(groesse)))
|
||||
gueltig = [s for s in stimmen if s is not None]
|
||||
if len(gueltig) == groesse - 1 and groesse >= 2:
|
||||
ersatz = await call(ctx, **{**kw, "item": f"{item}-pE"})
|
||||
if ersatz is not None:
|
||||
gueltig.append(ersatz)
|
||||
return gueltig
|
||||
Reference in New Issue
Block a user