Files
Home-Assistant/custom_components/ha_washdata/learning.py
T
2026-06-14 02:01:49 -04:00

792 lines
32 KiB
Python

"""Learning and self-tuning logic for WashData."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Optional, TYPE_CHECKING, cast
import numpy as np
from homeassistant.core import HomeAssistant
from homeassistant.helpers import translation
from homeassistant.helpers.dispatcher import async_dispatcher_send
import homeassistant.util.dt as dt_util
from .const import (
CONF_AUTO_LABEL_CONFIDENCE,
CONF_DURATION_TOLERANCE,
CONF_END_ENERGY_THRESHOLD,
CONF_LEARNING_CONFIDENCE,
CONF_MIN_OFF_GAP,
CONF_MIN_POWER,
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
CONF_OFF_DELAY,
CONF_PROFILE_DURATION_TOLERANCE,
CONF_PROFILE_MATCH_INTERVAL,
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
CONF_RUNNING_DEAD_ZONE,
CONF_SAMPLING_INTERVAL,
CONF_START_THRESHOLD_W,
CONF_STOP_THRESHOLD_W,
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS,
CONF_WATCHDOG_INTERVAL,
DEFAULT_AUTO_LABEL_CONFIDENCE,
DEFAULT_DURATION_TOLERANCE,
DEFAULT_LEARNING_CONFIDENCE,
DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS,
DOMAIN,
SIGNAL_WASHER_UPDATE,
)
from .suggestion_engine import SuggestionEngine
from .log_utils import DeviceLoggerAdapter
if TYPE_CHECKING:
from .profile_store import ProfileStore
_LOGGER = logging.getLogger(__name__)
class StatisticalModel:
"""Helper to track running stats for a metric."""
def __init__(self, max_samples: int = 200) -> None:
self._samples: list[float] = []
self._max_samples = max_samples
self._last_update: datetime | None = None
self._stats: dict[str, Any] = {"median": None, "p95": None, "count": 0}
def add_sample(self, value: float, now: datetime) -> None:
"""Add a sample and update stats."""
self._samples.append(value)
if len(self._samples) > self._max_samples:
self._samples = self._samples[-self._max_samples:]
self._last_update = now
self._compute_stats()
def _compute_stats(self) -> None:
if not self._samples:
self._stats = {"median": None, "p95": None, "count": 0}
return
arr = np.array(self._samples)
self._stats = {
"median": float(np.median(arr)),
"p95": float(np.percentile(arr, 95)),
"count": int(len(self._samples)),
}
@property
def median(self) -> float | None:
"""Return the median of samples."""
return self._stats.get("median")
@property
def p95(self) -> float | None:
"""Return the 95th percentile of samples."""
return self._stats.get("p95")
@property
def count(self) -> int:
"""Return the number of samples."""
return self._stats.get("count", 0)
class LearningManager:
"""Manages cycle learning, user feedback, and auto-tuning."""
def __init__(
self,
hass: HomeAssistant,
entry_id: str,
profile_store: "ProfileStore",
device_type: str | None = None,
device_name: str = "",
) -> None:
"""Initialize the learning manager."""
self._logger = DeviceLoggerAdapter(_LOGGER, device_name)
self.hass = hass
self.entry_id = entry_id
self.profile_store = profile_store
self.device_type = device_type
self.suggestion_engine = SuggestionEngine(
hass, entry_id, profile_store, device_type
)
# Operational Stats
self._sample_interval_model = StatisticalModel(max_samples=200)
self._last_suggestion_update: datetime | None = None
self._last_batch_simulation_count: int = 0 # track when to re-run batch
def _apply_suggestions_and_notify(self, suggestions: dict[str, Any]) -> None:
"""Apply suggestions and notify once when they become actionable."""
if not suggestions:
return
_actionable_keys = (
CONF_MIN_POWER,
CONF_OFF_DELAY,
CONF_WATCHDOG_INTERVAL,
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
CONF_SAMPLING_INTERVAL,
CONF_PROFILE_MATCH_INTERVAL,
CONF_AUTO_LABEL_CONFIDENCE,
CONF_DURATION_TOLERANCE,
CONF_PROFILE_DURATION_TOLERANCE,
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
CONF_MIN_OFF_GAP,
CONF_STOP_THRESHOLD_W,
CONF_START_THRESHOLD_W,
CONF_END_ENERGY_THRESHOLD,
CONF_RUNNING_DEAD_ZONE,
)
# Drop suggestions whose value already matches the current config - so
# that applied suggestions don't immediately reappear on the next cycle.
entry = self.hass.config_entries.async_get_entry(self.entry_id)
current_options: dict[str, Any] = {}
if entry:
current_options = {**entry.data, **entry.options}
filtered_suggestions: dict[str, Any] = {}
for key, data in suggestions.items():
if isinstance(data, dict) and "value" in data:
current_val = current_options.get(key)
suggested_val = data["value"]
if current_val is not None and suggested_val is not None:
try:
if float(current_val) == float(suggested_val):
self.profile_store.delete_suggestion(key)
continue # already applied, remove stale entry
except (TypeError, ValueError):
pass
filtered_suggestions[key] = data
if not filtered_suggestions:
return
def _count_actionable(s: dict) -> int:
return sum(
1 for k in _actionable_keys
if isinstance(s.get(k), dict) and s[k].get("value") is not None
)
current = self.profile_store.get_suggestions()
before_count = _count_actionable(current) if isinstance(current, dict) else 0
self.suggestion_engine.apply_suggestions(filtered_suggestions)
updated = self.profile_store.get_suggestions()
after_count = _count_actionable(updated) if isinstance(updated, dict) else 0
if before_count == 0 and after_count > 0:
device_title = entry.title if entry else DOMAIN
self.hass.async_create_task(
self._async_send_suggestions_ready_notification(device_title, after_count)
)
def process_power_reading(
self, _power: float, now: datetime, last_reading_time: datetime | None
) -> None:
"""Ingest power reading metadata for statistical analysis."""
if last_reading_time:
delta = (now - last_reading_time).total_seconds()
# Ignore ultra-small jitter (<0.1s) and massive gaps (>1800s - likely downtime)
if 0.1 < delta < 1800:
self._sample_interval_model.add_sample(delta, now)
# Periodically update suggestions based on operational stats
if (
self._last_suggestion_update is None
or (now - self._last_suggestion_update).total_seconds() > 300 # Check every 5 mins
):
self._update_operational_suggestions(now)
def process_cycle_end(
self,
cycle_data: dict[str, Any],
detected_profile: str | None = None,
confidence: float = 0.0,
predicted_duration: float | None = None,
match_result: Any | None = None,
) -> None:
"""Analyze completed cycle for learning.
Args:
cycle_data: Completed cycle data
detected_profile: Profile name detected
confidence: Match confidence score (0.0-1.0)
predicted_duration: Expected duration in seconds
match_result: MatchResult from profile_store.async_match_profile() (optional)
"""
# 1. Trigger background simulation to find optimal parameters for this cycle
if cycle_data.get("power_data"):
# Offload to executor since simulation can be heavy
self.hass.async_create_task(self._async_run_simulation(cycle_data))
# 2. Check if we should request feedback
self._maybe_request_feedback(
cycle_data, detected_profile, confidence, predicted_duration, match_result
)
# 3. Update model-based suggestions (durations etc)
self._update_model_suggestions(dt_util.now())
# 4. Run multi-cycle batch simulation when enough new labeled cycles have accumulated
self._maybe_run_batch_simulation()
def _maybe_run_batch_simulation(self) -> None:
"""Schedule a batch simulation when enough new labeled cycles have arrived."""
_BATCH_MIN = 5
_BATCH_RERUN_DELTA = 5 # Re-run every 5 new labeled cycles
labeled_cycles = [
c for c in self.profile_store.get_past_cycles()
if isinstance(c, dict)
and c.get("profile_name")
and c.get("profile_name") != "noise"
and c.get("power_data")
and c.get("status") in ("completed", "force_stopped")
]
current_count = len(labeled_cycles)
if current_count < _BATCH_MIN:
return
if (current_count - self._last_batch_simulation_count) < _BATCH_RERUN_DELTA:
return
self._last_batch_simulation_count = current_count
self.hass.async_create_task(self._async_run_batch_simulation(labeled_cycles, current_count))
async def _async_run_batch_simulation(self, cycles: list[dict[str, Any]], expected_count: int) -> None:
"""Run multi-cycle batch simulation asynchronously."""
try:
new_suggestions = await self.hass.async_add_executor_job(
self.suggestion_engine.run_batch_simulation, cycles
)
if new_suggestions:
self._apply_suggestions_and_notify(new_suggestions)
self._logger.debug(
"Batch simulation (%d cycles) produced suggestions: %s",
len(cycles),
list(new_suggestions.keys()),
)
except Exception as e: # pylint: disable=broad-exception-caught
self._logger.error("Batch simulation failed: %s", e)
async def _async_run_simulation(self, cycle_data: dict[str, Any]) -> None:
"""Run simulation asynchronously."""
try:
# Simulation runner derives optimal thresholds
# Offload to executor since simulation can be heavy (CPU bound)
new_suggestions = await self.hass.async_add_executor_job(
self.suggestion_engine.run_simulation, cycle_data
)
if new_suggestions:
self._apply_suggestions_and_notify(new_suggestions)
self._logger.debug("Post-cycle simulation completed with suggestions: %s", new_suggestions.keys())
except Exception as e:
self._logger.error("Background simulation failed: %s", e)
def _update_operational_suggestions(self, now: datetime) -> None:
"""Generate suggestions for operational parameters (intervals, timeouts)."""
if self._sample_interval_model.count < 20:
return
p95 = self._sample_interval_model.p95
median = self._sample_interval_model.median
if p95 is None or median is None:
return
suggestions = self.suggestion_engine.generate_operational_suggestions(p95, median)
self._apply_suggestions_and_notify(suggestions)
self._last_suggestion_update = now
def _update_model_suggestions(self, now: datetime) -> None:
"""Generate suggestions for model parameters (tolerances, ratios)."""
suggestions = self.suggestion_engine.generate_model_suggestions()
self._apply_suggestions_and_notify(suggestions)
async def _async_send_suggestions_ready_notification(
self, device_title: str, suggestions_count: int
) -> None:
"""Send a one-time persistent notification when suggestions become available."""
try:
notification_id = f"ha_washdata_suggestions_ready_{self.entry_id}"
translations = await translation.async_get_translations(
self.hass, self.hass.config.language, "options", {DOMAIN}
)
default_title = "WashData: Suggested Settings Ready ({device})"
default_msg = (
"The **Suggested Settings** sensor now reports **{count}** actionable recommendations.\n\n"
"To review and apply them: **Settings > Devices & Services > WashData > Configure > "
"Advanced Settings > Apply Suggested Values**.\n\n"
"Suggestions are optional and shown for review before you save."
)
title_template = translations.get(
f"component.{DOMAIN}.options.error.suggestions_ready_notification_title",
default_title,
)
msg_template = translations.get(
f"component.{DOMAIN}.options.error.suggestions_ready_notification_message",
default_msg,
)
title = title_template.format(device=device_title)
message = msg_template.format(count=suggestions_count)
await self.hass.services.async_call(
"persistent_notification",
"create",
{
"message": message,
"title": title,
"notification_id": notification_id,
},
)
except Exception: # pylint: disable=broad-exception-caught
self._logger.exception("Failed to create suggestions-ready notification")
def _set_suggestion(self, key: str, value: Any, reason: str) -> None:
"""Persist a suggested setting."""
current: Any = self.profile_store.get_suggestions().get(key, {})
if isinstance(current, dict):
current_dict = cast(dict[str, Any], current)
if current_dict.get("value") == value:
return # No change
self.profile_store.set_suggestion(key, value, reason=reason)
# We fire a background save task if possible, or rely on next periodic save.
# Since learning manager doesn't hold reference to hass task creation easily,
# we can just rely on ProfileStore's periodic save or trigger one if referenced.
# Ideally ProfileStore handles dirtiness.
# But wait, Manager calls save periodically. We should just mark it dirty?
# ProfileStore.async_save() is needed.
# We'll just trigger it via hass if available.
if self.hass:
self.hass.async_create_task(self.profile_store.async_save())
def _maybe_request_feedback(
self,
cycle_data: dict[str, Any],
detected_profile: str | None,
confidence: float,
predicted_duration: float | None,
match_result: Any | None = None,
) -> None:
"""Check if feedback should be requested for this completed cycle."""
if (
not predicted_duration
or not detected_profile
or detected_profile in ("off", "detecting...")
):
# No match was made, don't request feedback
return
# Get the cycle ID from the cycle_data
cycle_id = cycle_data.get("id")
if not cycle_id:
self._logger.warning("Cycle data missing ID, cannot request feedback")
return
# Get Configured Thresholds
entry = self.hass.config_entries.async_get_entry(self.entry_id)
if not entry:
return
auto_label_conf = entry.options.get(
CONF_AUTO_LABEL_CONFIDENCE, DEFAULT_AUTO_LABEL_CONFIDENCE
)
learning_conf = entry.options.get(
CONF_LEARNING_CONFIDENCE, DEFAULT_LEARNING_CONFIDENCE
)
duration_tol = entry.options.get(
CONF_DURATION_TOLERANCE, DEFAULT_DURATION_TOLERANCE
)
# Auto-label if very high confidence
if confidence >= auto_label_conf:
labeled = self.auto_label_high_confidence(
cycle_id=cycle_id,
profile_name=detected_profile,
confidence=confidence,
confidence_threshold=auto_label_conf,
)
if labeled:
# Rebuild envelope first, then persist (issue #131)
self.hass.async_create_task(
self._async_rebuild_and_save_profile(detected_profile)
)
self._logger.debug("Auto-labeled high-confidence cycle %s", cycle_id)
return
# Skip low-confidence matches below learning threshold
if confidence < learning_conf:
self._logger.debug(
"Skipping feedback for low-confidence match (conf=%.2f < %.2f)",
confidence,
learning_conf,
)
return
actual_duration = cycle_data.get("duration", 0)
# Request feedback via learning manager for moderate confidence
self.request_cycle_verification(
cycle_id=cycle_id,
detected_profile=detected_profile,
confidence=confidence,
estimated_duration=predicted_duration,
actual_duration=actual_duration,
duration_tolerance=duration_tol,
match_result=match_result,
)
# Persist pending feedback request so it survives restart
self.hass.async_create_task(self.profile_store.async_save())
# Create user-visible notification (skipped when suppressed via option).
# Use `is True` so that un-configured mock objects in tests don't
# accidentally suppress notifications by being truthy.
suppress = entry.options.get(
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS,
DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS,
) is True
if not suppress:
self.hass.async_create_task(
self._async_send_feedback_notification(
entry.title, cycle_data, detected_profile, confidence
)
)
async def _async_send_feedback_notification(
self, device_title: str, cycle_data: dict[str, Any], profile: str, confidence: float
) -> None:
"""Send a persistent notification for feedback (Async with translation)."""
try:
cycle_id = cycle_data.get("id", "unknown")
start_ts = cycle_data.get("start_time")
end_ts = dt_util.now() # Approximate, or pass actual end time
# Format times
t_str = ""
if start_ts:
try:
s_dt = datetime.fromisoformat(str(start_ts)) if isinstance(start_ts, str) else start_ts
s_local = dt_util.as_local(s_dt)
e_local = dt_util.as_local(end_ts)
t_str = f"{s_local.strftime('%H:%M')} - {e_local.strftime('%H:%M')}"
except Exception:
t_str = "Just now"
notification_id = f"ha_washdata_feedback_{self.entry_id}_{cycle_id}"
# Load translations (from en.json / localization files)
# We use "options" category to access the error keys where we stored these strings
translations = await translation.async_get_translations(
self.hass, self.hass.config.language, "options", {DOMAIN}
)
# Default templates
default_title = "WashData: Verify Cycle ({device})"
default_msg = (
"**Device**: {device}\n"
"**Program**: {program} ({confidence}% confidence)\n"
"**Time**: {time}\n\n"
"WashData needs your help to verify this detected cycle.\n\n"
"Please go to **Settings > Devices & Services > WashData > Configure > Learning Feedbacks** to confirm or correct this result."
)
title_template = translations.get(
f"component.{DOMAIN}.options.error.feedback_notification_title", default_title
)
msg_template = translations.get(
f"component.{DOMAIN}.options.error.feedback_notification_message", default_msg
)
# Confidence as percentage
conf_pct = int(confidence * 100)
title = title_template.format(device=device_title)
message = msg_template.format(
device=device_title,
program=profile,
confidence=conf_pct,
time=t_str
)
# Use standard service call
await self.hass.services.async_call(
"persistent_notification",
"create",
{
"message": message,
"title": title,
"notification_id": notification_id,
},
)
except Exception: # pylint: disable=broad-exception-caught
self._logger.exception("Failed to create feedback notification")
def _send_feedback_notification(
self, device_title: str, cycle_data: dict[str, Any], profile: str, confidence: float
) -> None:
"""Deprecated sync wrapper."""
self.hass.async_create_task(
self._async_send_feedback_notification(
device_title, cycle_data, profile, confidence
)
)
def request_cycle_verification(
self,
cycle_id: str,
detected_profile: Optional[str],
confidence: float,
estimated_duration: Optional[float],
actual_duration: float,
duration_tolerance: float = 0.10,
match_result: Any | None = None,
) -> None:
"""Request user verification for a detected cycle."""
duration_match_pct = (
(actual_duration / estimated_duration * 100) if estimated_duration else 0
)
tolerance_pct = duration_tolerance * 100
is_close_match = (
estimated_duration and abs(duration_match_pct - 100) <= tolerance_pct
)
# Extract match ranking from MatchResult if available (for UI visualization)
ranking_summary: list[dict[str, Any]] = []
if match_result and hasattr(match_result, "ranking") and match_result.ranking:
for cand in match_result.ranking[:5]: # Store top 5
try:
ranking_summary.append({
"name": cand.get("name", "Unknown"),
"score": float(cand.get("score", 0.0)),
"metrics": cand.get("metrics", {}),
"profile_duration": float(cand.get("profile_duration", 0.0)),
})
except (TypeError, ValueError, KeyError, AttributeError):
continue
feedback_req: dict[str, Any] = {
"cycle_id": cycle_id,
"detected_profile": detected_profile,
"confidence": confidence,
"estimated_duration": estimated_duration,
"actual_duration": actual_duration,
"duration_match_pct": duration_match_pct,
"is_close_match": is_close_match,
"created_at": dt_util.now().isoformat(),
"user_response": None,
"expires_at": None,
"ranking": ranking_summary, # Top candidates for UI display
}
self.profile_store.add_pending_feedback(cycle_id, feedback_req)
est_min = int(estimated_duration / 60) if estimated_duration else 0
self._logger.info(
"Feedback requested for cycle %s: profile='%s' (conf=%.2f), "
"est=%smin, actual=%smin (%.0f%%)",
cycle_id,
detected_profile,
confidence,
est_min,
int(actual_duration / 60),
duration_match_pct,
)
def auto_label_high_confidence(
self,
cycle_id: str,
profile_name: str,
confidence: float,
confidence_threshold: float,
) -> bool:
"""Auto-label a cycle with high confidence."""
if confidence < confidence_threshold:
return False
# Reuse existing internal logic
self._auto_label_cycle(cycle_id, profile_name)
# Verify it was labeled (cycle found)
cycles = self.profile_store.get_past_cycles()
cycle = next((c for c in cycles if c["id"] == cycle_id), None)
return bool(cycle and cycle.get("auto_labeled"))
async def async_submit_cycle_feedback(
self,
cycle_id: str,
user_confirmed: bool,
corrected_profile: Optional[str] = None,
corrected_duration: Optional[float] = None,
notes: str = "",
dismiss: bool = False,
) -> bool:
"""Submit user feedback for a cycle."""
pending = self.profile_store.get_pending_feedback().get(cycle_id)
if not pending:
return False
# Parse corrected_duration before writing to history so a bad value
# never leaves a partially-applied state.
duration_sec: float | None = None
if corrected_duration is not None:
try:
duration_sec = float(corrected_duration)
except (TypeError, ValueError):
self._logger.warning(
"Invalid corrected_duration %r for cycle %s, ignoring",
corrected_duration,
cycle_id,
)
feedback_record: dict[str, Any] = {
"cycle_id": cycle_id,
"original_detected_profile": pending["detected_profile"],
"original_confidence": pending["confidence"],
"user_confirmed": user_confirmed,
"corrected_profile": corrected_profile,
"corrected_duration": duration_sec,
"notes": notes,
"submitted_at": dt_util.now().isoformat(),
}
self.profile_store.get_feedback_history()[cycle_id] = feedback_record
# Track which profiles need envelope rebuild (issue #131)
profiles_to_rebuild: set[str] = set()
if dismiss:
# Just dismiss, no action
pass
elif user_confirmed:
profile_name = pending.get("detected_profile")
if isinstance(profile_name, str) and profile_name:
self._auto_label_cycle(cycle_id, profile_name, duration_sec)
if duration_sec is not None:
cycles = self.profile_store.get_past_cycles()
confirmed_cycle = next((c for c in cycles if c["id"] == cycle_id), None)
if confirmed_cycle:
confirmed_cycle["duration"] = duration_sec
profiles_to_rebuild.add(profile_name)
else:
# Correction path: only use corrected_profile when user_confirmed is False.
# Duration-only corrections (no profile specified) are handled by the elif branch below.
target_profile = corrected_profile
detected_profile_name = pending.get("detected_profile")
if isinstance(target_profile, str) and target_profile:
self._apply_correction_learning(
cycle_id, target_profile, duration_sec
)
profiles_to_rebuild.add(target_profile)
if (
isinstance(detected_profile_name, str)
and detected_profile_name
and detected_profile_name != target_profile
):
profiles_to_rebuild.add(detected_profile_name)
elif duration_sec is not None:
# No valid profile could be determined, but a duration correction was
# explicitly provided - apply it directly to the cycle so the value
# is never silently dropped.
cycles = self.profile_store.get_past_cycles()
cycle_to_fix = next((c for c in cycles if c["id"] == cycle_id), None)
if cycle_to_fix:
cycle_to_fix["duration"] = duration_sec
cycle_to_fix["manual_duration"] = duration_sec
existing_profile = cycle_to_fix.get("profile_name")
if isinstance(existing_profile, str) and existing_profile:
profiles_to_rebuild.add(existing_profile)
else:
self._logger.warning(
"Duration correction skipped: cycle %s not found in past_cycles",
cycle_id,
)
# Remove from pending (add_pending_feedback was wrapper, remove is direct)
if cycle_id in self.profile_store.get_pending_feedback():
del self.profile_store.get_pending_feedback()[cycle_id]
# Rebuild envelopes for all modified profiles to recalculate min/max/avg (issue #131)
for profile_name in profiles_to_rebuild:
try:
await self.profile_store.async_rebuild_envelope(profile_name)
except Exception as e: # pylint: disable=broad-exception-caught
self._logger.error("Failed to rebuild envelope for profile '%s': %s", profile_name, e)
# Persist changes
await self.profile_store.async_save()
# Trigger UI and sensor refresh (Issue #155)
async_dispatcher_send(self.hass, f"ha_washdata_update_{self.entry_id}")
return True
def _auto_label_cycle(self, cycle_id: str, profile_name: str, manual_duration: float | None = None) -> None:
cycles = self.profile_store.get_past_cycles()
cycle = next((c for c in cycles if c["id"] == cycle_id), None)
if cycle:
cycle["profile_name"] = profile_name
cycle["auto_labeled"] = True
if manual_duration:
cycle["manual_duration"] = manual_duration
def _apply_correction_learning(
self,
cycle_id: str,
corrected_profile: str,
corrected_duration: Optional[float] = None,
) -> None:
"""Apply user correction to a cycle (fix for issue #131).
Note: We do not update avg_duration here with EMA. Instead, the envelope
rebuild in async_submit_cycle_feedback() will recalculate all statistics
(min/max/avg) from labeled cycles, ensuring accuracy.
"""
self._auto_label_cycle(cycle_id, corrected_profile, corrected_duration)
if corrected_duration is not None:
cycles = self.profile_store.get_past_cycles()
cycle = next((c for c in cycles if c["id"] == cycle_id), None)
if cycle:
cycle["duration"] = corrected_duration
# Profile stats will be recalculated when envelope is rebuilt
async def _async_rebuild_profile_envelope(self, profile_name: str) -> None:
"""Async helper to rebuild a profile's envelope (issue #131 fix).
This wraps async_rebuild_envelope with error handling for safe task scheduling.
"""
try:
await self.profile_store.async_rebuild_envelope(profile_name)
self._logger.debug("Rebuilt envelope for profile '%s'", profile_name)
except Exception as e: # pylint: disable=broad-exception-caught
self._logger.error("Failed to rebuild envelope for profile '%s': %s", profile_name, e)
async def _async_rebuild_and_save_profile(self, detected_profile: str) -> None:
"""Rebuild profile envelope then persist in deterministic order."""
await self._async_rebuild_profile_envelope(detected_profile)
await self.profile_store.async_save()
def get_pending_feedback(self) -> dict[str, dict[str, Any]]:
"""Return pending feedback requests."""
return dict(self.profile_store.get_pending_feedback())
def get_feedback_history(self, limit: int = 20) -> list[dict[str, Any]]:
"""Return submitted feedback history."""
items = list(self.profile_store.get_feedback_history().values())
items.sort(key=lambda x: x.get("submitted_at", ""), reverse=True)
return items[:limit]