Files
sillyhome-next/app/static/index.html
Otto fa250216be
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
BEHAVIOR-001: learn and predict actuator actions
2026-06-14 10:37:59 +02:00

291 lines
12 KiB
HTML

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SillyHome Next</title>
<style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; }
body { margin: 0; }
header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); }
h1,h2,h3 { margin: 0 0 12px; }
header p { margin: 5px 0; color: #c3d1dc; }
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; }
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
.wide { grid-column: 1 / -1; }
.ok { color: #66dfa9; }
.warn { color: #f3c969; }
.bad { color: #ff8f8f; }
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
select,button { box-sizing: border-box; width: 100%; border-radius: 7px; border: 1px solid #3b4b5b; padding: 10px; background: #101820; color: #fff; }
button { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
button.secondary { background: #37495c; }
button.danger { background: #7b3434; }
table { width: 100%; border-collapse: collapse; font-size: .92rem; }
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
ul { margin: 8px 0; padding-left: 18px; }
.notice { border-left: 4px solid #66dfa9; padding-left: 10px; }
.grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
.muted { color:#9fb0be; }
</style>
</head>
<body>
<header>
<h1>SillyHome Next</h1>
<p>Du wählst nur die Aktoren. SillyHome findet Kontext, lernt Gewohnheiten und trifft Vorhersagen im Shadow-Modus.</p>
<p class="notice">Geschaltet wird erst nach deiner ausdrücklichen Freigabe pro Aktor.</p>
</header>
<main>
<section>
<h2>Systemstatus</h2>
<div id="status">Prüfung läuft ...</div>
<div class="chips" id="status-chips"></div>
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
</section>
<section>
<h2>Aktor freigeben</h2>
<p class="muted">Nach der Auswahl analysiert SillyHome automatisch passende Sensoren, Zustände und Historie.</p>
<label for="actuator-select">Home-Assistant-Aktor</label>
<select id="actuator-select"></select>
<button onclick="configureActuator()">Auswählen und Lernen starten</button>
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
</section>
<section class="wide">
<h2>Ausgewählte Aktoren</h2>
<div id="configured-actuators">Noch nicht geladen.</div>
</section>
<section class="wide">
<h2>Automatisch erkannter Lernkontext</h2>
<div id="actuator-detail" class="muted">Wähle einen Aktor aus der Liste.</div>
</section>
</main>
<script>
const escapeHtml = value => String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
let currentActuatorId = null;
async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
const body = response.status === 204 ? null : await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body?.detail || `${response.status} ${response.statusText}`);
return body;
}
function lifecycleLabel(record) {
const labels = {
trained: "lernt",
pending_history: "sammelt Historie",
pending_assignment: "sucht Kontext",
review_required: "geringe Zuordnungssicherheit",
archived: "wartet auf Kontext",
orphaned: "Aktor nicht gefunden",
};
return labels[record.lifecycle.status] || record.lifecycle.status;
}
function statusClass(record) {
if (record.lifecycle.status === "trained") return "ok";
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn";
return "bad";
}
function behaviorLabel(record) {
if (record.behavior.mode === "active") return "aktiv freigegeben";
if (record.behavior.status === "trained") return "Shadow-Vorhersage";
if (record.behavior.status === "blocked") return "Lernen blockiert";
return "sammelt Handlungen";
}
async function loadOverview() {
const status = document.getElementById("status");
const chips = document.getElementById("status-chips");
try {
const [health, ml, reconciliation, actuators] = await Promise.all([
api("health"),
api("ml/health"),
api("v1/actuators/reconciliation/state"),
api("v1/actuators"),
]);
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
`<span class="chip">Aktoren: ${actuators.length}</span>`,
`<span class="chip">Aktive Modelle: ${reconciliation.trained_models}</span>`,
].join("");
} catch (error) {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = "";
}
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
}
async function loadActuatorDiscovery() {
const select = document.getElementById("actuator-select");
try {
const [available, configured] = await Promise.all([
api("v1/actuators/discovery"),
api("v1/actuators"),
]);
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
const choices = available.filter(entity => !configuredIds.has(entity.entity_id));
select.innerHTML = choices.length
? choices.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`).join("")
: "<option value=''>Alle erkannten Aktoren sind ausgewählt</option>";
} catch (error) {
select.innerHTML = `<option value="">${escapeHtml(error.message)}</option>`;
}
}
async function configureActuator() {
const actuatorId = document.getElementById("actuator-select").value;
const result = document.getElementById("actuator-config-result");
if (!actuatorId) return;
result.textContent = "Kontext wird automatisch analysiert ...";
try {
const record = await api("v1/actuators", {
method: "POST",
body: JSON.stringify({actuator_entity_id: actuatorId}),
});
currentActuatorId = record.actuator_entity_id;
result.textContent = `${record.actuator_entity_id}: ${lifecycleLabel(record)}.`;
await loadOverview();
await showActuator(record.actuator_entity_id);
} catch (error) {
result.textContent = error.message;
}
}
async function loadConfiguredActuators() {
const box = document.getElementById("configured-actuators");
try {
const rows = await api("v1/actuators");
box.innerHTML = rows.length ? `
<table>
<tr><th>Aktor</th><th>Verhaltensmodell</th><th>Handlungen</th><th>Vorhersage</th><th></th></tr>
${rows.map(record => `
<tr>
<td>${escapeHtml(record.actuator_entity_id)}</td>
<td class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</td>
<td>${record.behavior.sample_count}</td>
<td>${record.behavior.prediction
? `${escapeHtml(record.behavior.prediction.target_state)} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
: "-"}</td>
<td>
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details</button>
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
</td>
</tr>
`).join("")}
</table>` : "<p>Noch keine Aktoren ausgewählt.</p>";
} catch (error) {
box.textContent = error.message;
}
}
async function showActuator(actuatorId) {
currentActuatorId = actuatorId;
const box = document.getElementById("actuator-detail");
try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
const contexts = [
record.assignment.selected_numeric_entity_id,
...record.assignment.selected_context_entity_ids,
].filter(Boolean);
const evidence = [...record.numeric_candidates, ...record.context_candidates]
.filter(candidate => contexts.includes(candidate.entity_id))
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join("");
const prediction = record.behavior.prediction;
const activationButton = record.behavior.mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false)">Autonomes Schalten stoppen</button>`
: record.behavior.status === "trained"
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true)">Lernen und Schalten freigeben</button>`
: "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</p>";
box.innerHTML = `
<div class="grid-two">
<div>
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
<p><strong>Zuordnung:</strong> automatisch</p>
<p><strong>Sicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
<p><strong>Bewertung:</strong> ${escapeHtml(record.assignment.reason)}</p>
</div>
<div>
<h3>Verhaltensmodell</h3>
<p><strong>Modus:</strong> ${escapeHtml(behaviorLabel(record))}</p>
<p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p>
<p><strong>Davon eindeutig Benutzer:</strong> ${record.behavior.high_confidence_sample_count}</p>
<p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
<p><strong>Status:</strong> ${escapeHtml(record.behavior.reason)}</p>
${activationButton}
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Vorhersage jetzt prüfen</button>
</div>
</div>
<h3>Aktuelle Vorhersage</h3>
${prediction
? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} ${prediction.executed ? "<span class='ok'>Ausgeführt.</span>" : "<span class='muted'>Nicht ausgeführt.</span>"}</p>`
: "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"}
<h3>Automatisch verwendeter Kontext</h3>
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
`;
} catch (error) {
box.textContent = error.message;
}
}
async function evaluateActuator(actuatorId) {
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/evaluate`, {method: "POST"});
await loadConfiguredActuators();
await showActuator(actuatorId);
} catch (error) {
alert(error.message);
}
}
async function setActivation(actuatorId, active) {
const question = active
? `${actuatorId} wirklich für autonomes Lernen und Schalten freigeben?`
: `${actuatorId} wieder in den Shadow-Modus setzen?`;
if (!confirm(question)) return;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/activation`, {
method: "POST",
body: JSON.stringify({active}),
});
await loadConfiguredActuators();
await showActuator(actuatorId);
} catch (error) {
alert(error.message);
}
}
async function removeActuator(actuatorId) {
if (!confirm(`${actuatorId} aus SillyHome entfernen?`)) return;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}`, {method: "DELETE"});
if (currentActuatorId === actuatorId) {
currentActuatorId = null;
document.getElementById("actuator-detail").textContent = "Wähle einen Aktor aus der Liste.";
}
await loadOverview();
} catch (error) {
alert(error.message);
}
}
loadOverview();
</script>
</body>
</html>