diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9a439..b8b518d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.7.3 - 2026-06-14 +- Automatische Kontextzuordnung ignoriert generische Bereiche wie `Monitoring`, + damit System-/Disk-/Überhitzungssensoren nicht fälschlich Lichtaktoren erklären +- Aktor-Auswahl auf tatsächlich sicher steuerbare Domains begrenzt: + `light`, `switch`, `cover`, `fan`, `humidifier` +- Neue manuelle Kontext-Zuordnung pro Aktor: Haupt-Messsensor optional setzen und + mehrere relevante Kontext-Entities wie PIR, Außenhelligkeit, Luftfeuchtigkeit + oder andere Lichtzustände auswählen +- Dashboard-Dropdown durch echtes Select plus Suche ersetzt; mobile Bedienung und + Aktor-Details enthalten Speichern/Neu-laden-Aktionen für manuelle Kontextwahl + ## 0.7.2 - 2026-06-14 - Home-Assistant-Entity-Metadaten werden in Batches gelesen, damit große HA- Installationen nicht mehr am Template-Ausgabe-Limit scheitern diff --git a/addon/config.yaml b/addon/config.yaml index b13f328..0a2de0d 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.7.2" +version: "0.7.3" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/actuators/lifecycle.py b/app/actuators/lifecycle.py index f6c3a8d..a216af5 100644 --- a/app/actuators/lifecycle.py +++ b/app/actuators/lifecycle.py @@ -13,6 +13,7 @@ from app.actuators.models import ( AssignmentSource, LifecycleAuditEntry, LifecycleStatus, + ManualOverride, ModelLifecycleState, ReconciliationState, model_id_for_actuator, @@ -47,6 +48,7 @@ _STOPWORDS = frozenset( "light", "licht", "lichtschalter", + "monitoring", "power", "sensor", "state", @@ -55,6 +57,7 @@ _STOPWORDS = frozenset( "value", } ) +_GENERIC_AREA_NAMES = frozenset({"monitoring", "system", "technik"}) _NUMERIC_AUTO_ACCEPT_SCORE = 0.82 _NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5 _NUMERIC_MIN_MARGIN = 0.18 @@ -93,6 +96,67 @@ class ActuatorReconciliationService: self._registry.archive(model_id) self._store.delete(actuator_entity_id) + def set_manual_assignment( + self, + actuator_entity_id: str, + *, + numeric_entity_id: str | None, + context_entity_ids: list[str], + note: str | None = None, + ) -> ActuatorRecord: + now = datetime.now(timezone.utc) + record = self._store.get(actuator_entity_id) + entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()} + actuator = entities.get(actuator_entity_id) + if actuator is None: + raise KeyError("Aktuator-Konfiguration nicht gefunden.") + selected_context_ids = list(dict.fromkeys(context_entity_ids)) + selected_ids = [ + entity_id + for entity_id in [numeric_entity_id, *selected_context_ids] + if entity_id + ] + missing = [entity_id for entity_id in selected_ids if entity_id not in entities] + if missing: + raise ValueError(f"Unbekannte Home-Assistant-Entity: {', '.join(missing)}") + if actuator_entity_id in selected_ids: + raise ValueError("Der Aktor selbst kann nicht als Kontextsensor verwendet werden.") + + override = ManualOverride( + numeric_entity_id=numeric_entity_id, + context_entity_ids=selected_context_ids, + updated_at=now, + note=note, + ) + assignment = self._manual_assignment(override) + lifecycle = self._reconcile_lifecycle( + actuator=actuator, + assignment=assignment, + lifecycle=record.lifecycle.model_copy(update={"last_reconciled_at": now}), + now=now, + ) + updated = record.model_copy( + update={ + "assignment": assignment, + "manual_override": override, + "numeric_candidates": _merge_manual_candidates( + record.numeric_candidates, + entities, + [numeric_entity_id] if numeric_entity_id else [], + role=EntityRole.MEASUREMENT, + ), + "context_candidates": _merge_manual_candidates( + record.context_candidates, + entities, + selected_context_ids, + role=EntityRole.CONTEXT, + ), + "lifecycle": lifecycle, + "updated_at": now, + } + ) + return self._store.upsert(updated) + def reconcile_all(self, trigger: str = "manual") -> ReconciliationState: state = self._store.load_reconciliation_state().model_copy( update={ @@ -198,10 +262,14 @@ class ActuatorReconciliationService: ), context=True, ) - assignment = self._select_assignment( - actuator=actuator, - numeric_candidates=numeric_candidates, - context_candidates=context_candidates, + assignment = ( + self._manual_assignment(record.manual_override) + if record.manual_override is not None + else self._select_assignment( + actuator=actuator, + numeric_candidates=numeric_candidates, + context_candidates=context_candidates, + ) ) lifecycle = self._reconcile_lifecycle( actuator=actuator, @@ -212,7 +280,7 @@ class ActuatorReconciliationService: updated = record.model_copy( update={ "assignment": assignment, - "manual_override": None, + "manual_override": record.manual_override, "numeric_candidates": numeric_candidates, "context_candidates": context_candidates, "lifecycle": lifecycle, @@ -228,6 +296,23 @@ class ActuatorReconciliationService: ) return updated + @staticmethod + def _manual_assignment(override: ManualOverride) -> AssignmentSelection: + selected_context_ids = list(dict.fromkeys(override.context_entity_ids)) + selected_count = len(selected_context_ids) + (1 if override.numeric_entity_id else 0) + return AssignmentSelection( + selected_numeric_entity_id=override.numeric_entity_id, + selected_context_entity_ids=selected_context_ids, + source=AssignmentSource.MANUAL, + confidence=1.0 if selected_count else 0.0, + review_required=selected_count == 0, + reason=( + f"Manuell festgelegt: {selected_count} Kontext-Entity(s) werden verwendet." + if selected_count + else "Manuelle Zuordnung enthält noch keine Kontext-Entities." + ), + ) + def _select_assignment( self, *, @@ -522,7 +607,12 @@ def _score_candidate( if overlap: score += min(0.4, 0.1 * len(overlap)) evidence.append(f"Gemeinsame Tokens: {', '.join(overlap[:4])}") - if actuator.area_name and entity.area_name and actuator.area_name == entity.area_name: + if ( + actuator.area_name + and entity.area_name + and actuator.area_name == entity.area_name + and actuator.area_name.lower() not in _GENERIC_AREA_NAMES + ): score += 0.35 evidence.append(f"Gleicher Bereich: {actuator.area_name}") if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id: @@ -550,6 +640,49 @@ def _score_candidate( return round(min(score, 1.0), 4), evidence +def _merge_manual_candidates( + candidates: list[AssignmentCandidate], + entities: dict[str, HaEntitySummary], + selected_entity_ids: list[str], + *, + role: EntityRole, +) -> list[AssignmentCandidate]: + by_id = {candidate.entity_id: candidate for candidate in candidates} + for entity_id in selected_entity_ids: + existing = by_id.get(entity_id) + if existing is not None: + by_id[entity_id] = existing.model_copy( + update={ + "auto_accepted": True, + "confidence": 1.0, + "evidence": [ + *existing.evidence, + "Manuell vom Nutzer als relevant festgelegt.", + ], + } + ) + continue + entity = entities.get(entity_id) + if entity is None: + continue + by_id[entity_id] = AssignmentCandidate( + entity_id=entity.entity_id, + domain=entity.domain, + role=role, + device_class=entity.device_class, + state_class=entity.state_class, + unit_of_measurement=entity.unit_of_measurement, + friendly_name=entity.friendly_name, + area_name=entity.area_name, + device_name=entity.device_name, + score=1.0, + confidence=1.0, + auto_accepted=True, + evidence=["Manuell vom Nutzer als relevant festgelegt."], + ) + return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id)) + + def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]: if context: return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"}) diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index de8bf47..374e0c4 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -14,6 +14,19 @@ from app.ha.models import HaEntitySummary from app.ha.reader import HaReader router = APIRouter(prefix="/v1/actuators", tags=["actuators"]) +_MANUAL_CONTEXT_DOMAINS = frozenset({ + "binary_sensor", + "climate", + "cover", + "device_tracker", + "fan", + "humidifier", + "light", + "person", + "sensor", + "switch", + "weather", +}) class ConfigureActuatorRequest(BaseModel): @@ -32,6 +45,12 @@ class AutomationControlRequest(BaseModel): enabled: bool +class ManualAssignmentRequest(BaseModel): + numeric_entity_id: str | None = Field(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") + context_entity_ids: list[str] = Field(default_factory=list) + note: str | None = Field(default=None, max_length=500) + + @router.get("/discovery", response_model=list[HaEntitySummary]) def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]: entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} @@ -42,6 +61,22 @@ def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaE return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities] +@router.get("/context-options", response_model=list[HaEntitySummary]) +def context_options(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]: + return sorted( + [ + entity + for entity in ha_reader.read_entities() + if entity.domain in _MANUAL_CONTEXT_DOMAINS + ], + key=lambda entity: ( + entity.area_name or "", + entity.friendly_name or entity.entity_id, + entity.entity_id, + ), + ) + + @router.get("", response_model=list[ActuatorRecord]) def list_configured(request: Request) -> list[ActuatorRecord]: return _service(request).list_configured() @@ -116,6 +151,27 @@ def set_activation( raise HTTPException(status_code=409, detail=str(exc)) from exc +@router.post("/{actuator_entity_id}/assignment", response_model=ActuatorRecord) +def set_manual_assignment( + actuator_entity_id: str, + payload: ManualAssignmentRequest, + request: Request, +) -> ActuatorRecord: + try: + record = _service(request).set_manual_assignment( + actuator_entity_id, + numeric_entity_id=payload.numeric_entity_id, + context_entity_ids=payload.context_entity_ids, + note=payload.note, + ) + _behavior(request).train(record.actuator_entity_id) + return _behavior(request).evaluate(record.actuator_entity_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + @router.post( "/{actuator_entity_id}/related-automations/refresh", response_model=ActuatorRecord, diff --git a/app/ha/discovery.py b/app/ha/discovery.py index b10d130..e56343c 100644 --- a/app/ha/discovery.py +++ b/app/ha/discovery.py @@ -82,18 +82,11 @@ _BINARY_CONTEXT_CLASSES = frozenset({ "window", }) _ACTUATOR_DOMAINS = frozenset({ - "button", - "climate", "cover", "fan", "humidifier", "light", - "lock", - "scene", - "select", - "siren", "switch", - "valve", }) _CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "sun", "weather", "zone"}) _LEARNABLE_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "weather"}) diff --git a/app/main.py b/app/main.py index ab733a1..90fd207 100644 --- a/app/main.py +++ b/app/main.py @@ -100,7 +100,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", - version="0.7.2", + version="0.7.3", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index 38964e6..d6c4219 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -26,6 +26,7 @@ .bad { color: #ff8f8f; } label { display: block; margin: 9px 0 4px; color: #b9c9d6; } select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; } + select[multiple] { min-height:190px; } button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; } button.secondary { background: #37495c; } button.danger { background: #7b3434; } @@ -49,6 +50,7 @@ .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; } + .manual-context { margin-top:14px; background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; } code { color:#cfe0ec; overflow-wrap:anywhere; } @media (max-width: 760px) { header { padding:18px 14px; } @@ -121,6 +123,10 @@ + +
Noch kein Aktor ausgewählt.
@@ -145,6 +151,8 @@ const escapeHtml = value => String(value ?? "") .replaceAll('"', """) .replaceAll("'", "'"); let currentActuatorId = null; +let actuatorChoices = []; +let contextOptions = []; async function api(path, options = {}) { const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options}); @@ -209,28 +217,52 @@ async function loadOverview() { status.innerHTML = `${escapeHtml(error.message)}
`; chips.innerHTML = ""; } - await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]); + await Promise.all([loadActuatorDiscovery(), loadContextOptions(), loadConfiguredActuators()]); } async function loadActuatorDiscovery() { const options = document.getElementById("actuator-options"); + 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)); - options.innerHTML = choices.map(entity => + actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id)); + options.innerHTML = actuatorChoices.map(entity => `` ).join(""); + select.innerHTML = [ + ``, + ...actuatorChoices.map(entity => + `` + ), + ].join(""); } catch (error) { options.innerHTML = ""; + select.innerHTML = ``; } } +async function loadContextOptions() { + try { + contextOptions = await api("v1/actuators/context-options"); + } catch (error) { + contextOptions = []; + } +} + +function selectActuatorFromList() { + const value = document.getElementById("actuator-select").value; + if (value) document.getElementById("actuator-input").value = value; +} + async function configureActuator() { - const actuatorId = document.getElementById("actuator-input").value.trim(); + const actuatorId = ( + document.getElementById("actuator-input").value.trim() + || document.getElementById("actuator-select").value.trim() + ); const result = document.getElementById("actuator-config-result"); if (!actuatorId) return; result.textContent = "Kontext wird automatisch analysiert ..."; @@ -309,6 +341,37 @@ async function showActuator(actuatorId, evaluationMessage = "") { pattern => pattern.source === "automation", ).length; const relatedAutomations = record.behavior.related_automations || []; + const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []); + const numericOptions = contextOptions.filter(entity => entity.domain === "sensor"); + const manualAssignment = ` +Hier kannst du Sensoren und Zustände ergänzen, die deiner Meinung nach wichtig für den Schaltvorgang sind. Beispiele: PIR, Helligkeit außen, Luftfeuchtigkeit innen/außen oder ein Lichtzustand.
+ + + + +Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.
"} + ${manualAssignment} `; document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); } catch (error) { @@ -373,6 +437,27 @@ async function showActuator(actuatorId, evaluationMessage = "") { } } +async function saveManualAssignment(actuatorId) { + const numericEntityId = document.getElementById("manual-numeric-select").value || null; + const contextEntityIds = Array.from( + document.getElementById("manual-context-select").selectedOptions, + ).map(option => option.value); + try { + await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, { + method: "POST", + body: JSON.stringify({ + numeric_entity_id: numericEntityId, + context_entity_ids: contextEntityIds, + note: "Manuell im Dashboard gesetzt", + }), + }); + await loadConfiguredActuators(); + await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert."); + } catch (error) { + alert(error.message); + } +} + async function evaluateActuator(actuatorId) { try { const record = await api( diff --git a/pyproject.toml b/pyproject.toml index 2e52ea4..e87488c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "0.7.2" +version = "0.7.3" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/actuators/test_lifecycle.py b/tests/actuators/test_lifecycle.py index 5fae549..0e03eb3 100644 --- a/tests/actuators/test_lifecycle.py +++ b/tests/actuators/test_lifecycle.py @@ -7,7 +7,6 @@ from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.models import ( AssignmentSource, LifecycleStatus, - ManualOverride, model_id_for_actuator, ) from app.actuators.store import ActuatorStore @@ -240,7 +239,45 @@ def test_reconciliation_does_not_cross_assign_other_room_light_energy( assert record.lifecycle.status is LifecycleStatus.ARCHIVED -def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: Path) -> None: +def test_reconciliation_ignores_generic_monitoring_area_for_automatic_context( + tmp_path: Path, +) -> None: + start = datetime(2026, 6, 1, tzinfo=timezone.utc) + entities = [ + HaEntitySummary( + entity_id="light.abstellkammer", + domain="light", + friendly_name="Licht Abstellkammer", + area_name="Monitoring", + ), + HaEntitySummary( + entity_id="binary_sensor.disk_overheating", + domain="binary_sensor", + device_class="problem", + friendly_name="Max. fehlerhafte Sektoren ueberschritten", + area_name="Monitoring", + ), + HaEntitySummary( + entity_id="sensor.router_power", + domain="sensor", + device_class="power", + state_class="measurement", + unit_of_measurement="W", + friendly_name="Router Leistung", + area_name="Monitoring", + ), + ] + service = _service(tmp_path, entities, {"sensor.router_power": _points(8, start, 1.0)}) + + record = service.configure_actuator("light.abstellkammer") + + assert record.assignment.selected_numeric_entity_id is None + assert record.assignment.selected_context_entity_ids == [] + assert record.assignment.review_required is True + assert record.lifecycle.status is LifecycleStatus.ARCHIVED + + +def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None: start = datetime(2026, 6, 1, tzinfo=timezone.utc) entities = [ HaEntitySummary( @@ -273,21 +310,18 @@ def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: "sensor.abstellkammer_power": _points(8, start, 30.0), } service = _service(tmp_path, entities, history) - configured = service.configure_actuator("light.abstellkammer") - legacy = configured.model_copy( - update={ - "manual_override": ManualOverride( - numeric_entity_id="sensor.abstellkammer_power", - context_entity_ids=[], - note="Alte manuelle Zuordnung", - ) - } + service.configure_actuator("light.abstellkammer") + service.set_manual_assignment( + "light.abstellkammer", + numeric_entity_id="sensor.abstellkammer_power", + context_entity_ids=["sensor.abstellkammer_illuminance"], + note="Manuell wichtiger Sensor", ) - service._store.upsert(legacy) restarted = _service(tmp_path, entities, history) record = restarted.reconcile_actuator("light.abstellkammer") - assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_illuminance" - assert record.assignment.source.value == "automatic" - assert record.manual_override is None + assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_power" + assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"] + assert record.assignment.source is AssignmentSource.MANUAL + assert record.manual_override is not None diff --git a/tests/api/test_actuators.py b/tests/api/test_actuators.py index 236f79d..1af9ab5 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -179,14 +179,39 @@ def test_actuator_api_configures_reconciles_and_removes(tmp_path: Path) -> None: assert client.get("/v1/actuators").json() == [] -def test_manual_override_endpoint_is_not_exposed(tmp_path: Path) -> None: +def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None: with TestClient(app) as client: _install_service(tmp_path) client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"}) response = client.post( - "/v1/actuators/light.abstellkammer/override", - json={"numeric_entity_id": "sensor.abstellkammer_illuminance"}, + "/v1/actuators/light.abstellkammer/assignment", + json={ + "numeric_entity_id": "sensor.abstellkammer_illuminance", + "context_entity_ids": ["binary_sensor.abstellkammer_motion"], + "note": "Manuell gesetzt", + }, ) - assert response.status_code == 404 + assert response.status_code == 200 + payload = response.json() + assert payload["assignment"]["source"] == "manual" + assert payload["assignment"]["selected_numeric_entity_id"] == ( + "sensor.abstellkammer_illuminance" + ) + assert payload["assignment"]["selected_context_entity_ids"] == [ + "binary_sensor.abstellkammer_motion" + ] + + +def test_context_options_returns_learnable_entities(tmp_path: Path) -> None: + with TestClient(app) as client: + _install_service(tmp_path) + + response = client.get("/v1/actuators/context-options") + + assert response.status_code == 200 + entity_ids = {item["entity_id"] for item in response.json()} + assert "sensor.abstellkammer_illuminance" in entity_ids + assert "binary_sensor.abstellkammer_motion" in entity_ids + assert "light.abstellkammer" in entity_ids diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 80ae1dc..c278aed 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -12,6 +12,7 @@ def test_dashboard_is_served_at_root() -> None: assert "So gehst du vor" in response.text assert "Gerät zum Lernen auswählen" in response.text assert "Entitätsname oder Gerät aus Home Assistant" in response.text + assert "Oder aus Liste wählen" in response.text assert "Wie gewohnt bedienen" in response.text assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text @@ -21,6 +22,9 @@ def test_dashboard_is_served_at_root() -> None: assert "Pausieren" in response.text assert "Davon erkannte HA-Automationen" in response.text assert "Aktuelle Situation auswerten" in response.text + assert "Kontext selbst festlegen" in response.text + assert "Diese Kontext-Auswahl speichern" in response.text + assert "manual-context-select" in response.text assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text assert "Kein frischer passender Sensorwechsel erkannt" in response.text assert "Vorhersage jetzt prüfen" not in response.text