Add safety dashboard and decision transparency
This commit is contained in:
@@ -72,6 +72,7 @@
|
||||
.bad { color: var(--bad); }
|
||||
label { display:block; margin:9px 0 4px; color:#c3d2df; font-weight:700; }
|
||||
select,input,button { width:100%; border-radius:8px; border:1px solid #3b4b5b; padding:10px; background:#111821; color:#fff; font:inherit; min-width:0; }
|
||||
input[type="checkbox"] { width:auto; min-width:0; vertical-align:middle; margin-right:8px; }
|
||||
select[multiple] { min-height:150px; }
|
||||
button { min-height:42px; margin-top:10px; background:var(--accent); color:#211204; border:0; font-weight:850; cursor:pointer; }
|
||||
button.secondary { background:var(--complement-soft); color:#dff6ff; border:1px solid #22607c; }
|
||||
@@ -96,6 +97,9 @@
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
|
||||
.metric { background:var(--panel-soft); border:1px solid var(--border); border-radius:8px; padding:8px; min-width:0; overflow-wrap:anywhere; word-break:break-word; }
|
||||
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
|
||||
.decision-list { display:grid; gap:8px; margin:10px 0; }
|
||||
.decision-row { background:#121922; border:1px solid var(--border); border-radius:8px; padding:9px; min-width:0; overflow-wrap:anywhere; }
|
||||
.decision-row header { padding:0; border:0; background:transparent; display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
|
||||
.actions button { flex:1 1 180px; margin-top:0; }
|
||||
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
|
||||
@@ -232,6 +236,7 @@
|
||||
<div id="status">Prüfung läuft ...</div>
|
||||
<div class="chips" id="status-chips"></div>
|
||||
<div id="dashboard-stats" class="metric-grid"></div>
|
||||
<div id="job-queue" class="decision-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="guide-panel" id="guide">
|
||||
@@ -486,6 +491,7 @@ function renderDashboardStatus(dashboard) {
|
||||
const status = document.getElementById("status");
|
||||
const chips = document.getElementById("status-chips");
|
||||
const stats = document.getElementById("dashboard-stats");
|
||||
const jobsBox = document.getElementById("job-queue");
|
||||
const system = dashboard.system || {};
|
||||
const cache = dashboard.cache || {};
|
||||
const actuators = dashboard.actuators || [];
|
||||
@@ -498,6 +504,8 @@ function renderDashboardStatus(dashboard) {
|
||||
).length;
|
||||
const trainedCount = actuators.filter(record => record.behavior_status === "trained").length;
|
||||
const sampleTotal = actuators.reduce((sum, record) => sum + Number(record.sample_count || 0), 0);
|
||||
const jobs = dashboard.jobs?.jobs || [];
|
||||
const runningJobs = jobs.filter(job => job.status === "running").length;
|
||||
const cacheLabel = cache.available
|
||||
? `Cache aktuell mit ${cache.entity_count} Entities`
|
||||
: "Cache wird nach Discovery aufgebaut";
|
||||
@@ -513,6 +521,7 @@ function renderDashboardStatus(dashboard) {
|
||||
`<span class="chip">Aktoren: ${escapeHtml(system.configured_actuators ?? 0)}</span>`,
|
||||
`<span class="chip">Lernbereit: ${escapeHtml(system.trained_models ?? 0)}</span>`,
|
||||
`<span class="chip">Prüfen: ${escapeHtml(system.review_required ?? 0)}</span>`,
|
||||
`<span class="chip">Jobs aktiv: ${escapeHtml(runningJobs)}</span>`,
|
||||
].join("");
|
||||
stats.innerHTML = [
|
||||
`<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml(actuators.length)} Geräte</div>`,
|
||||
@@ -523,6 +532,21 @@ function renderDashboardStatus(dashboard) {
|
||||
`<div class="metric"><strong>Discovery-Gruppen</strong>${escapeHtml(discoveryGroups.length)} Kategorien</div>`,
|
||||
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
|
||||
].join("");
|
||||
jobsBox.innerHTML = jobs.length ? `
|
||||
<h3>Job-Queue</h3>
|
||||
${jobs.slice(-6).reverse().map(job => `
|
||||
<div class="decision-row">
|
||||
<header>
|
||||
<strong>${escapeHtml(job.kind)}${job.target ? `: ${escapeHtml(job.target)}` : ""}</strong>
|
||||
<span class="chip">${escapeHtml(job.status)}</span>
|
||||
</header>
|
||||
<p class="muted">${escapeHtml(job.summary || "Keine Zusammenfassung")}</p>
|
||||
<p class="muted">Start: ${escapeHtml(job.started_at || "offen")} · Dauer: ${escapeHtml(job.duration_ms == null ? "läuft/offen" : `${job.duration_ms} ms`)}</p>
|
||||
${job.error ? `<p class="bad">${escapeHtml(job.error)}</p>` : ""}
|
||||
${job.status === "failed" ? "<p class='warn'>Retry: Aktion im Dashboard erneut starten; der nächste Lauf schreibt einen neuen Queue-Eintrag.</p>" : ""}
|
||||
</div>
|
||||
`).join("")}
|
||||
` : "";
|
||||
}
|
||||
|
||||
async function loadSummaryData() {
|
||||
@@ -873,6 +897,71 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
`).join("")}</ul>`
|
||||
: "<p class='muted'>Noch keine Kontext-Entity ausgewählt.</p>";
|
||||
const prediction = record.behavior.prediction;
|
||||
const safety = record.behavior.safety || {};
|
||||
const blockers = record.behavior.safety_blockers || [];
|
||||
const decisionFactors = record.behavior.decision_factors || [];
|
||||
const knowledge = record.behavior.knowledge || [];
|
||||
const assumptions = record.behavior.assumptions || [];
|
||||
const uncertainties = record.behavior.uncertainties || [];
|
||||
const safetyControls = `
|
||||
<details class="manual-context" open>
|
||||
<summary>Sicherheit und manuelles Gegensteuern</summary>
|
||||
<div class="inline-controls">
|
||||
<div>
|
||||
<label for="safety-stage">Freigabestufe</label>
|
||||
<select id="safety-stage">
|
||||
${["observe", "suggest", "shadow", "partial", "active"].map(stage => `
|
||||
<option value="${stage}" ${safety.stage === stage ? "selected" : ""}>${stage}</option>
|
||||
`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="safety-confidence">Mindest-Sicherheit in %</label>
|
||||
<input id="safety-confidence" type="number" min="0" max="100" step="1" value="${Math.round((safety.min_confidence ?? 0.82) * 100)}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="safety-cooldown">Cooldown Sekunden</label>
|
||||
<input id="safety-cooldown" type="number" min="0" step="10" value="${safety.cooldown_seconds ?? ""}" placeholder="Standard">
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
<input id="safety-manual-block" type="checkbox" ${safety.manual_block ? "checked" : ""}>
|
||||
Manuelle Sicherheitssperre aktiv
|
||||
</label>
|
||||
${blockers.length ? `<p class="warn">Aktuelle Blocker: ${blockers.map(escapeHtml).join(" ")}</p>` : "<p class='ok'>Keine lokalen Sicherheitsblocker für die aktuelle Vorhersage.</p>"}
|
||||
<button class="secondary" onclick="saveSafetyProfile('${escapeHtml(record.actuator_entity_id)}')">Sicherheitsprofil speichern</button>
|
||||
</details>
|
||||
`;
|
||||
const decisionArchive = `
|
||||
<details class="manual-context" open>
|
||||
<summary>Entscheidungsakte</summary>
|
||||
<div class="grid-two">
|
||||
<div>
|
||||
<h3>Wissen</h3>
|
||||
<ul>${knowledge.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine gesicherten Punkte gespeichert.</li>"}</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Annahmen</h3>
|
||||
<ul>${assumptions.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine Annahmen gespeichert.</li>"}</ul>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Unsicherheit</h3>
|
||||
<ul>${uncertainties.map(item => `<li>${escapeHtml(item)}</li>`).join("") || "<li>Keine Unsicherheit gespeichert.</li>"}</ul>
|
||||
<h3>Beitragsfaktoren</h3>
|
||||
<div class="decision-list">
|
||||
${decisionFactors.length ? decisionFactors.map(factor => `
|
||||
<div class="decision-row">
|
||||
<header>
|
||||
<strong>${escapeHtml(factor.label)}</strong>
|
||||
<span class="chip">${Math.round((factor.contribution || 0) * 100)} % Beitrag</span>
|
||||
</header>
|
||||
<p class="muted">${escapeHtml(factor.entity_id || factor.factor_type)} · Zustand: ${escapeHtml(factor.state || "offen")} · Gewicht: ${Math.round((factor.weight || 0) * 100)} %</p>
|
||||
<p>${(factor.evidence || []).map(escapeHtml).join(" ")}</p>
|
||||
</div>
|
||||
`).join("") : "<p class='muted'>Noch keine aktuelle Entscheidungsfaktoren berechnet.</p>"}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
const learnedAutomationActions = record.behavior.patterns.filter(
|
||||
pattern => pattern.source === "automation",
|
||||
).length;
|
||||
@@ -982,6 +1071,8 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', true)">Vorhersage korrekt</button>
|
||||
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', false)">Vorhersage falsch</button>
|
||||
</div>
|
||||
${safetyControls}
|
||||
${decisionArchive}
|
||||
<h3>Passende Home-Assistant-Automationen</h3>
|
||||
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
|
||||
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
|
||||
@@ -1184,6 +1275,36 @@ async function sendFeedback(actuatorId, correct) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSafetyProfile(actuatorId) {
|
||||
const confidence = Number(document.getElementById("safety-confidence")?.value || 82);
|
||||
const cooldownRaw = document.getElementById("safety-cooldown")?.value || "";
|
||||
const cooldown = cooldownRaw === "" ? null : Math.max(0, Number(cooldownRaw));
|
||||
const profile = {
|
||||
stage: document.getElementById("safety-stage")?.value || "shadow",
|
||||
manual_block: Boolean(document.getElementById("safety-manual-block")?.checked),
|
||||
min_confidence: Math.max(0, Math.min(100, Number.isFinite(confidence) ? confidence : 82)) / 100,
|
||||
cooldown_seconds: Number.isFinite(cooldown) ? cooldown : null,
|
||||
rules: [
|
||||
{rule_id: "activation_ready", label: "Nur nach Lernfreigabe aktiv schalten", enabled: true, blocking: true, reason: "Der Aktor muss genug eindeutiges Verhalten gelernt haben."},
|
||||
{rule_id: "confidence_threshold", label: "Mindest-Sicherheit einhalten", enabled: true, blocking: true, reason: "Vorhersagen unter der Schaltschwelle bleiben im Shadow-Modus."},
|
||||
{rule_id: "cooldown", label: "Sicherheits-Cooldown gegen Hin-und-her-Schalten", enabled: true, blocking: true, reason: "Gleiche Zielzustände werden nicht zu schnell wiederholt."},
|
||||
{rule_id: "manual_block", label: "Manuelle Sperre respektieren", enabled: true, blocking: true, reason: "Nutzer können jeden Aktor sofort blockieren."},
|
||||
],
|
||||
note: "Sicherheitsprofil im Dashboard gespeichert",
|
||||
};
|
||||
try {
|
||||
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/safety`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({safety: profile}),
|
||||
});
|
||||
invalidateDashboardCache();
|
||||
await loadConfiguredActuators();
|
||||
await showActuator(actuatorId, "Sicherheitsprofil gespeichert.");
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
|
||||
const question = active
|
||||
? pauseMatchingAutomations
|
||||
|
||||
Reference in New Issue
Block a user