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:
@@ -0,0 +1 @@
|
||||
"""Pflege-Agent mit Human-in-the-Loop."""
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user