core: Policy, Pflege-Agent (HITL), Healer, Lifecycle-Pipeline, Graduierung + Konfiguration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 00:43:26 +02:00
co-authored by Claude Opus 5.5
parent f79e2a8556
commit f2da893f71
28 changed files with 2179 additions and 10 deletions
+1
View File
@@ -0,0 +1 @@
"""Pflege-Agent mit Human-in-the-Loop."""
+127
View File
@@ -0,0 +1,127 @@
"""Ausführung von Aktionen (geteilt von Pflege-Agent und Healer)."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from ..calibration import effective_context
from ..healer.drivers import DryRunDriver, InfraDriver, Notifier
from ..lifecycle import Lifecycle
from ..registry import Registry
from ..skills import SkillLibrary
from ..store import Store
INFRA_ACTIONS = {"restart_container", "restart_vm", "reboot_host"}
class ActionError(Exception):
pass
class ActionExecutor:
"""Interne Zustandsänderungen laufen direkt; Infrastruktur-Aktionen nur, wenn ausdrücklich freigeschaltet."""
def __init__(self, *, store: Store, registry: Registry, library: SkillLibrary, lifecycle: Lifecycle,
infra: InfraDriver | None = None, notifier: Notifier | None = None, dry_run: bool = True,
enabled_infra_actions: set[str] | None = None, on_skills_changed: Callable[[], None] | None = None):
self.store = store
self.registry = registry
self.library = library
self.lifecycle = lifecycle
self.dry = DryRunDriver()
self.infra = infra
self.notifier = notifier or self.dry
self.dry_run = dry_run
self.enabled_infra_actions = enabled_infra_actions or set()
self.on_skills_changed = on_skills_changed or (lambda: None)
def infra_driver(self, kind: str) -> InfraDriver:
if self.dry_run or kind not in self.enabled_infra_actions or self.infra is None:
return self.dry
return self.infra
def execute(self, kind: str, params: dict[str, Any]) -> dict[str, Any]:
handler = getattr(self, f"_do_{kind}", None)
if handler is None:
raise ActionError(f"Unbekannte Aktion {kind}")
try:
return {"ok": True, "message": handler(**params)}
except (KeyError, TypeError, ValueError, LookupError, RuntimeError) as exc:
raise ActionError(f"{kind}: {type(exc).__name__}: {exc}") from exc
# --- Infrastruktur ----------------------------------------------------------------------
def _do_restart_container(self, host: str, container: str, **_: Any) -> str:
return self.infra_driver("restart_container").restart_container(host, container)
def _do_restart_vm(self, pve_node: str, vmid: str, **_: Any) -> str:
return self.infra_driver("restart_vm").restart_vm(pve_node, str(vmid))
def _do_reboot_host(self, pve_node: str, **_: Any) -> str:
return self.infra_driver("reboot_host").reboot_host(pve_node)
def _do_notify(self, text: str, severity: str = "info", **_: Any) -> str:
return self.notifier.notify(text, severity)
# --- Registry / Skills ------------------------------------------------------------------
def _do_mark_slot(self, slot: str, status: str, reason: str = "", set_by: str = "maintenance", **_: Any) -> str:
self.registry.set_status(slot, status, reason=reason, set_by=set_by) # type: ignore[arg-type]
return f"Slot {slot} → {status}"
def _do_demote_skill(self, skill: str, reason: str = "", **_: Any) -> str:
self.library.set_state(skill, status="demoted", reason=reason)
self.on_skills_changed()
return f"Skill {skill} auf Stufe 0 zurückgestuft"
def _do_promote_skill(self, skill: str, stage: int = 1, **_: Any) -> str:
self.library.set_state(skill, status="active", stage=int(stage), reason="freigegeben")
self.on_skills_changed()
return f"Skill {skill} auf Stufe {stage} freigegeben"
def _do_retire_skill(self, skill: str, **_: Any) -> str:
self.library.set_state(skill, status="retired", reason="stillgelegt")
self.on_skills_changed()
return f"Skill {skill} stillgelegt"
def _do_request_graduation(self, skill: str, **_: Any) -> str:
if not self.library.has(skill):
raise KeyError(f"Skill {skill} unbekannt")
return self.lifecycle.jobs.submit("graduate_skill", {"id": f"graduate:{skill}", "skill": skill})
def _do_merge_skills(self, a: str, b: str, **_: Any) -> str:
return self.lifecycle.jobs.submit("merge_skills", {"id": f"merge:{a}+{b}", "skills": [a, b]})
def _do_recalibrate_context(self, slot: str, target: float = 0.9, min_n: int = 20, **_: Any) -> str:
rows = [r for r in self.store.list_cases(stage=None) if r["slot"] == slot
and r["prompt_tokens"] is not None and r["passed"] is not None]
value = effective_context(((r["prompt_tokens"], bool(r["passed"])) for r in rows), target=target,
min_n=min_n)
if value is None:
return f"Slot {slot}: zu wenige Daten für Kalibrierung ({len(rows)} Fälle)"
self.registry.set_calibrated_context(slot, value)
return f"Slot {slot}: effektiver Kontext = {value} Tokens"
# --- Lifecycle --------------------------------------------------------------------------
def _do_create_candidate(self, kind: str, slot: str, target: dict[str, Any], **_: Any) -> str:
cid = self.lifecycle.create(kind, slot, target) # type: ignore[arg-type]
self.lifecycle.start(cid)
return f"Kandidat {cid} ({kind}) angelegt und Build gestartet"
def _do_train_adapter(self, cluster: str, slot: str = "tier1-general", **_: Any) -> str:
base = self.registry.slot(slot).base_model
return self._do_create_candidate("adapter", slot, {"cluster": cluster, "base_model": base})
def _do_promote_candidate(self, candidate: str, **_: Any) -> str:
self.lifecycle.promote(candidate)
return f"Kandidat {candidate} übernommen"
def _do_rollback_candidate(self, slot: str | None = None, candidate: str | None = None, **_: Any) -> str:
if candidate is None:
if slot is None:
raise ValueError("slot oder candidate nötig")
latest = self.lifecycle.latest_promoted(slot)
if latest is None:
raise LookupError(f"Kein übernommener Kandidat für {slot}")
candidate = latest["id"]
self.lifecycle.rollback(candidate)
return f"Kandidat {candidate} zurückgerollt"
+448
View File
@@ -0,0 +1,448 @@
"""Pflege-Agent: Service-Routine, Vorschläge, Freigaben per Prompt."""
from __future__ import annotations
import re
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any, Protocol
from pydantic import BaseModel, Field
from ..cases import pass_rate
from ..embeddings import SkillIndex
from ..lifecycle import Lifecycle
from ..llm import ChatClient, LLMError
from ..policy import Policy
from ..registry import Registry
from ..skills import SkillLibrary
from ..store import Store
from ..util import load_yaml, now
from .actions import ActionError, ActionExecutor
CONTEXT_SIGNALS = ("context_exceeded", "compact_prompt", "larger_slot", "output_truncated", "context_overflow")
class MaintenanceConfig(BaseModel):
demote_below: float = 0.80
min_cases: int = 20
window_cases: int = 50
promote_min_rate: float = 0.95
merge_similarity: float = 0.92
degraded_hours: float = 1.0
context_signal_limit: int = 10
adapter_min_cases: int = 300
adapter_target_rate: float = 0.90
lookback_days: float = 7.0
@classmethod
def from_file(cls, path: str | Path) -> MaintenanceConfig:
return cls(**load_yaml(path))
class Proposal(BaseModel):
seq: int
kind: str
params: dict[str, Any] = Field(default_factory=dict)
summary: str = ""
reason: str = ""
risk: str = "medium"
state: str = "open"
source: str = ""
created_at: float | None = None
decided_at: float | None = None
decided_by: str | None = None
result: dict[str, Any] | None = None
@property
def id(self) -> str:
return f"P-{self.seq}"
def line(self) -> str:
return f"{self.id} [{self.risk}] {self.summary}" + (f" – {self.reason}" if self.reason else "")
class RoutineReport(BaseModel):
findings: list[str] = Field(default_factory=list)
proposals: list[Proposal] = Field(default_factory=list)
auto_executed: list[str] = Field(default_factory=list)
text: str = ""
class CommandReply(BaseModel):
text: str
proposals: list[Proposal] = Field(default_factory=list)
class IntentMapper(Protocol):
def map(self, text: str) -> str | None: ...
HELP = """Befehle:
- status · hilfe · routine · vorschläge
- genehmige P-3 [P-5 …] · genehmige alle mit risiko niedrig · lehne P-3 ab: <Grund>
- wechsle <slot> auf <basismodell> · trainiere adapter für <cluster> · rollback <slot>
- stufe <skill> zurück · markiere <slot> als ok|degraded|unavailable"""
class LLMIntentMapper:
"""Bildet Freitext auf genau einen bekannten Befehl ab – führt nie selbst etwas aus."""
def __init__(self, chat: ChatClient, model: str = "tier1-general"):
self.chat = chat
self.model = model
def map(self, text: str) -> str | None:
system = ("Übersetze die Nachricht in GENAU einen Befehl aus dieser Liste oder antworte NONE.\n"
+ HELP + "\nAntworte nur mit dem Befehl.")
try:
res = self.chat.chat(self.model, [{"role": "system", "content": system},
{"role": "user", "content": text}], max_tokens=40, temperature=0)
except LLMError:
return None
out = res.content.strip().strip("`").splitlines()[0] if res.content.strip() else ""
return None if not out or out.upper() == "NONE" else out
_ID = r"p-?\d+"
COMMANDS: list[tuple[str, re.Pattern[str]]] = [
(name, re.compile(pattern, re.IGNORECASE)) for name, pattern in [
("status", r"^(status|überblick|overview)$"),
("help", r"^(hilfe|help|\?)$"),
("routine", r"^((starte\s+(die\s+)?)?service-?routine|routine)$"),
("list", r"^((zeige\s+)?(offene\s+)?vorschläge|proposals)$"),
("approve_low", r"^(genehmige|approve)\s+alle\s+(mit\s+)?risiko\s+(niedrig|low)$"),
("approve", rf"^(genehmige|approve)\s+(?P<ids>{_ID}([\s,]+(und\s+)?{_ID})*)$"),
("reject", rf"^(lehne\s+(?P<id>{_ID})\s+ab|reject\s+(?P<id2>{_ID}))(\s*[:,]?\s*(weil\s+)?(?P<reason>.+))?$"),
("switch_base", r"^wechsle\s+(?P<slot>[\w.-]+)\s+auf\s+(?P<base>[\w./:@-]+)$"),
("train_adapter", r"^trainiere\s+(einen\s+)?adapter\s+für\s+(den\s+cluster\s+)?(?P<cluster>[\w.-]+)$"),
("rollback", r"^rollback\s+(?P<slot>[\w.-]+)$"),
("demote", r"^(stufe\s+(?P<skill>[\w.-]+)\s+zurück|degradiere\s+(?P<skill2>[\w.-]+))$"),
("mark", r"^markiere\s+(?P<slot>[\w.-]+)\s+(als\s+)?(?P<status>ok|degraded|unavailable)$"),
("confirm", r"^(ja|yes|j|ok)$"),
("cancel", r"^(nein|no|n|abbrechen)$"),
]
]
def parse_command(text: str) -> tuple[str, dict[str, str]] | None:
cleaned = text.strip().rstrip(".!")
for name, pattern in COMMANDS:
m = pattern.match(cleaned)
if m:
return name, {k: v for k, v in m.groupdict().items() if v is not None}
return None
def _seq(token: str) -> int:
return int(re.sub(r"\D", "", token))
class MaintenanceAgent:
def __init__(self, *, store: Store, registry: Registry, library: SkillLibrary, index: SkillIndex,
policy: Policy, actions: ActionExecutor, lifecycle: Lifecycle,
config: MaintenanceConfig | None = None, intent_mapper: IntentMapper | None = None,
clock: Callable[[], float] = now, wallclock: Callable[[], datetime] = datetime.now):
self.store = store
self.registry = registry
self.library = library
self.index = index
self.policy = policy
self.actions = actions
self.lifecycle = lifecycle
self.config = config or MaintenanceConfig()
self.intent_mapper = intent_mapper
self.clock = clock
self.wallclock = wallclock
self._pending: dict[str, str] = {} # Sitzung → zu bestätigender Befehl
# --- Vorschläge -------------------------------------------------------------------------
def _load(self, seq: int) -> Proposal:
row = self.store.get_proposal(seq)
if row is None:
raise KeyError(f"P-{seq} unbekannt")
return Proposal(**row)
def proposals(self, state: str | None = "open") -> list[Proposal]:
return [Proposal(**r) for r in self.store.list_proposals(state=state)]
def propose(self, kind: str, params: dict[str, Any], summary: str, *, reason: str = "",
source: str = "routine", dedup_key: str | None = None, force_approval: bool = False) -> Proposal:
key = dedup_key or f"{kind}:{sorted(params.items())}"
existing = self.store.open_proposal_with_key(key)
if existing:
return Proposal(**existing)
mode = self.policy.decide(kind)
if kind == "mark_slot" and params.get("status") == "ok":
force_approval = True # Wieder-Freischalten ist die unsichere Richtung
seq = self.store.add_proposal(kind=kind, params=params, summary=summary, reason=reason,
risk=self.policy.risk(kind), source=source, dedup_key=key)
if mode == "deny":
self.store.update_proposal(seq, state="rejected", decided_by="policy", decided_at=self.clock(),
result={"message": "durch Richtlinie verboten"})
elif mode == "auto" and not force_approval:
return self.approve(seq, by="policy")
return self._load(seq)
def approve(self, seq: int, by: str = "human") -> Proposal:
p = self._load(seq)
if p.state != "open":
raise ValueError(f"{p.id} ist {p.state}")
self.store.update_proposal(seq, state="approved", decided_by=by, decided_at=self.clock())
if not self.policy.in_window(p.kind, self.wallclock()):
self.store.update_proposal(seq, result={"deferred": True, "message": "wartet auf Wartungsfenster"})
return self._load(seq)
return self._execute(seq)
def _execute(self, seq: int) -> Proposal:
p = self._load(seq)
try:
result = self.actions.execute(p.kind, p.params)
self.store.update_proposal(seq, state="executed", result=result)
except ActionError as exc:
self.store.update_proposal(seq, state="failed", result={"ok": False, "message": str(exc)})
return self._load(seq)
def reject(self, seq: int, by: str = "human", reason: str = "") -> Proposal:
p = self._load(seq)
if p.state != "open":
raise ValueError(f"{p.id} ist {p.state}")
self.store.update_proposal(seq, state="rejected", decided_by=by, decided_at=self.clock(),
result={"message": reason or "abgelehnt"})
if p.kind == "promote_candidate":
self.lifecycle.reject(p.params["candidate"], f"abgelehnt: {reason}")
return self._load(seq)
def run_deferred(self) -> list[Proposal]:
done = []
for p in self.proposals(state="approved"):
if (p.result or {}).get("deferred") and self.policy.in_window(p.kind, self.wallclock()):
done.append(self._execute(p.seq))
return done
# --- Service-Routine --------------------------------------------------------------------
def run_routine(self) -> RoutineReport:
report = RoutineReport()
c = self.config
since = self.clock() - c.lookback_days * 86400
def add(p: Proposal) -> None:
if p.state == "executed" and p.decided_by == "policy":
report.auto_executed.append(f"{p.id}: {p.summary} → {(p.result or {}).get('message', '')}")
else:
report.proposals.append(p)
# 1. Drift / 2. Graduierungskandidaten
for skill in self.library.routable():
if skill.meta.status == "active" and skill.meta.stage >= 1:
rows = self.store.list_cases(skill=skill.name, min_stage=1, limit=c.window_cases)
rate = pass_rate(rows)
n = len([r for r in rows if r["passed"] is not None])
if rate is not None and n >= c.min_cases and rate < c.demote_below:
report.findings.append(f"Drift: {skill.name} Pass-Rate {rate:.0%} (n={n})")
add(self.propose("demote_skill", {"skill": skill.name, "reason": f"Pass-Rate {rate:.0%}"},
f"Skill {skill.name} auf Stufe 0 zurückstufen",
reason=f"Pass-Rate {rate:.0%} < {c.demote_below:.0%} (n={n})"))
else:
rows = self.store.list_cases(skill=skill.name, stage=0, limit=c.window_cases)
rate = pass_rate(rows)
n = len([r for r in rows if r["passed"] is not None])
if rate is not None and n >= c.min_cases and rate >= c.promote_min_rate:
report.findings.append(f"Graduierungskandidat: {skill.name} ({rate:.0%} auf Stufe 0, n={n})")
add(self.propose("request_graduation", {"skill": skill.name},
f"Graduierungslauf für {skill.name} anstoßen",
reason=f"stabil auf Stufe 0 ({rate:.0%}, n={n})"))
# 3. ähnliche Skills
for a, b, sim in self.index.pairwise_similar(c.merge_similarity):
if self.library.has(a) and self.library.has(b) and self.library.get(a).routable \
and self.library.get(b).routable:
report.findings.append(f"Ähnliche Skills: {a} ~ {b} ({sim:.2f})")
add(self.propose("merge_skills", {"a": a, "b": b}, f"Skills {a} und {b} zusammenführen",
reason=f"Ähnlichkeit {sim:.2f}"))
# 4. degradierte Slots
for slot in self.registry.slots():
state = self.store.get_slot_state(slot.name) or {}
age_h = (self.clock() - (state.get("updated_at") or self.clock())) / 3600
if not slot.available and age_h >= c.degraded_hours:
report.findings.append(f"Slot {slot.name} seit {age_h:.1f} h {slot.status} ({slot.reason or '-'})")
add(self.propose("mark_slot", {"slot": slot.name, "status": "ok", "reason": "nach Prüfung"},
f"Slot {slot.name} wieder auf ok setzen",
reason=f"{slot.status} seit {age_h:.1f} h – vorher Ursache prüfen"))
# 5. Kontext-Signale
totals: dict[str, int] = {}
for kind in CONTEXT_SIGNALS:
for subject, count in self.store.event_subjects(kind, since=since).items():
totals[subject] = totals.get(subject, 0) + count
for subject, count in sorted(totals.items()):
if self.registry.has_slot(subject) and count >= c.context_signal_limit:
report.findings.append(f"Kontext-Signale auf {subject}: {count} in {c.lookback_days:g} Tagen")
add(self.propose("recalibrate_context", {"slot": subject},
f"Effektiven Kontext von {subject} neu kalibrieren", reason=f"{count} Signale"))
# 6. Adapter-Bedarf
open_clusters = {c_["target"].get("cluster") for c_ in self.store.list_candidates()
if c_["kind"] == "adapter" and c_["state"] not in {"promoted", "rejected", "failed",
"rolled_back"}}
clusters: dict[str, str] = {}
for skill in self.library.routable():
clusters.setdefault(skill.cluster, skill.meta.slot)
for cluster, slot in sorted(clusters.items()):
if cluster in open_clusters or not self.registry.has_slot(slot):
continue
if not self.registry.slot(slot).spec.supports_lora:
continue
train = self.store.list_cases(cluster=cluster, split="train", passed=True)
if len(train) < c.adapter_min_cases:
continue
weak = self.store.list_cases(cluster=cluster, min_stage=1, limit=c.window_cases)
rate = pass_rate(weak)
if rate is None or rate < c.adapter_target_rate:
report.findings.append(f"Adapter-Bedarf: {cluster} ({len(train)} Trainingsfälle)")
add(self.propose("train_adapter", {"cluster": cluster, "slot": slot},
f"LoRA-Adapter für {cluster} trainieren",
reason=f"{len(train)} verifizierte Fälle, Stufe-1-Rate "
f"{'–' if rate is None else f'{rate:.0%}'}"))
# 7. Lifecycle: Canary auswerten, Freigaben vorschlagen
for cand in self.store.list_candidates(state="canary"):
m = self.lifecycle.canary_metrics(cand["id"])
if m["n"] >= self.lifecycle.config.min_n and m["pass_rate"] is not None:
after = self.lifecycle.report(cand["id"], "canary", m)
report.auto_executed.append(f"Canary {cand['id'][:8]} ausgewertet → {after['state']}")
for cand in self.store.list_candidates(state="awaiting_promotion"):
add(self.propose("promote_candidate", {"candidate": cand["id"]},
f"{cand['kind']} für {cand['slot']} übernehmen ({cand['target']})",
reason="alle Prüfstufen bestanden", source="lifecycle"))
for p in self.run_deferred():
report.auto_executed.append(f"{p.id} im Wartungsfenster ausgeführt: {p.summary}")
open_all = self.proposals("open")
report.text = self._render_report(report, open_all)
return report
@staticmethod
def _render_report(report: RoutineReport, open_all: list[Proposal]) -> str:
lines = ["Service-Routine"]
lines.append("\nBefunde:" if report.findings else "\nKeine Auffälligkeiten.")
lines += [f"- {f}" for f in report.findings]
if report.auto_executed:
lines.append("\nAutomatisch erledigt:")
lines += [f"- {a}" for a in report.auto_executed]
if open_all:
lines.append("\nOffene Vorschläge (genehmige P-x / lehne P-x ab):")
lines += [f"- {p.line()}" for p in open_all]
return "\n".join(lines)
# --- Befehle ----------------------------------------------------------------------------
def handle_command(self, text: str, user: str = "human", session: str = "default") -> CommandReply:
parsed = parse_command(text)
if parsed is None:
if self.intent_mapper is not None:
mapped = self.intent_mapper.map(text)
if mapped and parse_command(mapped) and parse_command(mapped)[0] not in {"confirm", "cancel"}:
self._pending[session] = mapped
return CommandReply(text=f"Meinst du: `{mapped}`? Antworte mit „ja“.")
return CommandReply(text="Das habe ich nicht verstanden.\n" + HELP)
name, args = parsed
if name == "confirm":
pending = self._pending.pop(session, None)
if not pending:
return CommandReply(text="Es gibt nichts zu bestätigen.")
return self.handle_command(pending, user, session)
if name == "cancel":
self._pending.pop(session, None)
return CommandReply(text="Abgebrochen.")
try:
return getattr(self, f"_cmd_{name}")(args, user)
except (KeyError, ValueError) as exc:
return CommandReply(text=f"Fehler: {exc}")
def _cmd_help(self, args: dict[str, str], user: str) -> CommandReply:
return CommandReply(text=HELP)
def _cmd_status(self, args: dict[str, str], user: str) -> CommandReply:
lines = ["Slots:"]
lines += [f"- {s.name}: {s.status} (Basis {s.base_model}, Kontext {s.effective_context})"
for s in self.registry.slots()]
by_status: dict[str, int] = {}
for s in self.library.all():
by_status[s.meta.status] = by_status.get(s.meta.status, 0) + 1
lines.append("Skills: " + ", ".join(f"{k} {v}" for k, v in sorted(by_status.items())))
running = [c for c in self.store.list_candidates() if c["state"] not in
{"promoted", "rejected", "failed", "rolled_back"}]
lines.append(f"Kandidaten in Arbeit: {len(running)}")
lines.append(f"Offene Vorschläge: {len(self.proposals('open'))}")
return CommandReply(text="\n".join(lines))
def _cmd_routine(self, args: dict[str, str], user: str) -> CommandReply:
report = self.run_routine()
return CommandReply(text=report.text, proposals=report.proposals)
def _cmd_list(self, args: dict[str, str], user: str) -> CommandReply:
props = self.proposals("open")
if not props:
return CommandReply(text="Keine offenen Vorschläge.")
return CommandReply(text="\n".join(p.line() for p in props), proposals=props)
def _decided_text(self, p: Proposal) -> str:
msg = (p.result or {}).get("message", "")
return f"{p.id} {p.state}" + (f": {msg}" if msg else "")
def _cmd_approve(self, args: dict[str, str], user: str) -> CommandReply:
ids = re.findall(_ID, args["ids"], re.IGNORECASE)
done = [self.approve(_seq(i), by=user) for i in ids]
return CommandReply(text="\n".join(self._decided_text(p) for p in done), proposals=done)
def _cmd_approve_low(self, args: dict[str, str], user: str) -> CommandReply:
done = [self.approve(p.seq, by=user) for p in self.proposals("open") if p.risk == "low"]
if not done:
return CommandReply(text="Keine offenen Vorschläge mit niedrigem Risiko.")
return CommandReply(text="\n".join(self._decided_text(p) for p in done), proposals=done)
def _cmd_reject(self, args: dict[str, str], user: str) -> CommandReply:
p = self.reject(_seq(args.get("id") or args["id2"]), by=user, reason=args.get("reason", ""))
return CommandReply(text=self._decided_text(p), proposals=[p])
def _new(self, kind: str, params: dict[str, Any], summary: str, user: str) -> CommandReply:
p = self.propose(kind, params, summary, reason=f"Auftrag von {user}", source="command", force_approval=True)
return CommandReply(text=f"Vorschlag {p.line()}\nBestätige mit „genehmige {p.id}“.", proposals=[p])
def _require_slot(self, slot: str) -> None:
if not self.registry.has_slot(slot):
raise KeyError(f"Slot {slot} unbekannt")
def _cmd_switch_base(self, args: dict[str, str], user: str) -> CommandReply:
self._require_slot(args["slot"])
return self._new("create_candidate",
{"kind": "base_model", "slot": args["slot"], "target": {"base_model": args["base"]}},
f"Basismodell von {args['slot']} auf {args['base']} wechseln (Pipeline, inkl. "
"Neutraining der Adapter)", user)
def _cmd_train_adapter(self, args: dict[str, str], user: str) -> CommandReply:
slot = next((s.meta.slot for s in self.library.all() if s.cluster == args["cluster"]), "tier1-general")
return self._new("train_adapter", {"cluster": args["cluster"], "slot": slot},
f"LoRA-Adapter für {args['cluster']} trainieren", user)
def _cmd_rollback(self, args: dict[str, str], user: str) -> CommandReply:
self._require_slot(args["slot"])
return self._new("rollback_candidate", {"slot": args["slot"]},
f"Letzte Übernahme auf {args['slot']} zurückrollen", user)
def _cmd_demote(self, args: dict[str, str], user: str) -> CommandReply:
skill = args.get("skill") or args["skill2"]
if not self.library.has(skill):
raise KeyError(f"Skill {skill} unbekannt")
return self._new("demote_skill", {"skill": skill, "reason": f"Auftrag von {user}"},
f"Skill {skill} auf Stufe 0 zurückstufen", user)
def _cmd_mark(self, args: dict[str, str], user: str) -> CommandReply:
self._require_slot(args["slot"])
return self._new("mark_slot", {"slot": args["slot"], "status": args["status"].lower(),
"reason": f"Auftrag von {user}", "set_by": user},
f"Slot {args['slot']} als {args['status'].lower()} markieren", user)