feat: perfiles de jitter intercambiables desde Telegram

Cinco perfiles con distinto punto en el compromiso entre variedad visible y
minutos de presencia de mas. Todos dan saldo 0. Se cambian en caliente con
/perfil, con botones, y al cambiar se vuelven a tirar los dados de todos los
dias del horizonte que no se hayan fichado ya.

                 min/dia  madrugon  entrada 09:00
    discreto       2,8      1,3     2 minutos, moda 75%
    natural        4,1      1,8     5 minutos, moda 51%   <- por defecto
    camuflado      5,5      2,1     4 minutos, moda 43%
    disperso       7,9      3,0     6 minutos, moda 29%
    sin_madrugar   5,7      1,3     2 minutos, moda 77%
    (historico real del usuario:    4 minutos, moda 38%)

La distincion util no es solo el coste total sino DONDE cae. Adelantar la
entrada de la mañana alarga el dia; adelantar la vuelta de comer solo acorta la
comida y se sale a la misma hora. De ahi "sin_madrugar": entrada de la mañana lo
mas pegada posible a las 9 (madrugon 1,26 min/dia, el mas bajo de todos, por
debajo incluso de "discreto") y toda la variedad en la pausa y las salidas.

Para eso los perfiles admiten afinar por tramo con sufijo _w<n>:
entry_median_w0 gana a entry_median para el tramo de la mañana.

tools/perfiles.py mide cada perfil sobre los 226 dias laborables del calendario
real; las cifras de arriba salen de ahi y van guardadas como metadatos para que
/perfil las muestre sin recalcular.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 19:41:50 +02:00
parent a496b1c672
commit a1eef6472f
6 changed files with 339 additions and 30 deletions

View File

@@ -135,7 +135,8 @@ class Service:
logger.info("plan de %s: %s", day, [str(p) for p in plan])
return self.store.get_plan(day)
async def ensure_horizon(self, dias: int = HORIZON_DAYS, force: bool = False) -> None:
async def ensure_horizon(self, dias: int = HORIZON_DAYS, force: bool = False,
force_all: bool = False) -> None:
"""Planifica hoy y los proximos dias, para que la prevision sea exacta."""
cal = await asyncio.to_thread(self.executor.calendar)
hoy = self.today()
@@ -143,7 +144,7 @@ class Service:
for i in range(dias):
d = hoy + timedelta(days=i)
antes = bool(self.store.get_plan(d))
await self._plan_un_dia(d, cal, force=force and i == 0)
await self._plan_un_dia(d, cal, force=force_all or (force and i == 0))
if not antes and self.store.get_plan(d):
nuevos += 1
self._planned_for = hoy
@@ -157,6 +158,13 @@ class Service:
await self.ensure_horizon(force=force)
return self.store.get_plan(self.today())
async def cambiar_perfil(self, nombre: str) -> None:
"""Cambia el perfil y vuelve a tirar los dados de todo lo que no se ha fichado."""
self.store.put("perfil", nombre)
self.store.log("perfil", f"cambiado a {nombre}")
logger.info("perfil de jitter cambiado a %s", nombre)
await self.ensure_horizon(force_all=True)
async def cancel_pending_today(self, reason: str) -> None:
day = self.today()
for r in self.store.pending(day):