fix: el jitter era demasiado regular; planificar por adelantado

El 96% de las entradas caian en solo dos minutos (08:58 / 08:59). El historial
real del usuario tiene 4 valores repartidos, o sea que el bot resultaba MAS
regular que una persona.

Tres cosas estaban mal:

- La deriva de la costumbre se aplicaba SUMANDO segundos. Cuando salia negativa
  hundia el valor contra el suelo y amontonaba medio año en el mismo minuto,
  justo lo contrario de lo que pretendia. Ahora va en escala logaritmica:
  multiplica el centro en vez de sumarse. Solo con eso la moda baja del 50% al
  39%, sin coste ninguno.

- La distribucion diaria era uniforme. Una lognormal da mas minutos distintos
  por menos presencia extra, porque la mayoria de los dias caen baratos cerca de
  la hora y solo unos pocos se van lejos. Ademas es la forma real de la llegada
  de alguien que intenta ser puntual.

- El tope recortaba con min(), asi que un 3% de los dias caia exactamente en el
  limite y se veia como un muro en el histograma. Ahora resortea.

El compromiso de fondo no tiene truco: solo se puede variar hacia atras, y
alejarse cuesta presencia. Perfiles medidos sobre los 226 dias laborables del
año, los tres con saldo 0:

    discreto    2,8 min/dia   entrada 2 minutos, moda 75%
    natural     4,2 min/dia   entrada 5 minutos, moda 51%   <- por defecto
    camuflado   5,5 min/dia   entrada 4 minutos, moda 43%

Ademas, /proximos mostraba una banda estimada que ocultaba la cola. Ahora se
planifican 21 dias por adelantado y se muestran las horas exactas, con lo que
la prevision es real y se puede decidir sobre ella. El plan guardado se
revalida contra el calendario de Timenet y se regenera si cambia el tipo de
jornada de un dia. Google Calendar se consulta con una sola autenticacion para
todo el horizonte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 19:36:38 +02:00
parent 365f676a1f
commit a496b1c672
5 changed files with 133 additions and 87 deletions

View File

@@ -27,6 +27,7 @@ logger = logging.getLogger(__name__)
ANNOUNCE_LEAD = timedelta(minutes=12) # cuanta antelacion tiene el killswitch
MAX_SLEEP = 60.0
REPLAN_HOUR = 4 # hora a la que se planifica el dia
HORIZON_DAYS = 21 # dias que se planifican por adelantado
class Service:
@@ -41,6 +42,7 @@ class Service:
self._wake = asyncio.Event()
self._stop = asyncio.Event()
self._planned_for: date | None = None
self._gcal = None
# ------------------------------------------------------------------ basico
def now(self) -> datetime:
@@ -78,7 +80,9 @@ class Service:
return None
try:
from app import google_calendar
ev = await asyncio.to_thread(lambda: google_calendar.GoogleCalendar().getEvent(day))
if self._gcal is None: # una sola autenticacion para todo el horizonte
self._gcal = await asyncio.to_thread(google_calendar.GoogleCalendar)
ev = await asyncio.to_thread(self._gcal.getEvent, day)
if ev and isinstance(ev, str) and not ev.startswith("Error"):
return ev
if ev and str(ev).startswith("Error"):
@@ -89,43 +93,70 @@ class Service:
return None
# --------------------------------------------------------------------- plan
async def ensure_plan(self, force: bool = False) -> list:
day = self.today()
if self._planned_for == day and not force:
return self.store.get_plan(day)
cal = await asyncio.to_thread(self.executor.calendar)
self._planned_for = day
def _plan_al_dia(self, day: date, cal) -> bool:
"""El plan guardado sigue cuadrando con el calendario de Timenet."""
rows = self.store.get_plan(day)
if not rows:
return False
esperado = [m for w in cal.windows(day) for m in (w.start, w.end)]
real = []
for r in rows:
t = datetime.fromisoformat(r["target"])
real.append(t.hour * 60 + t.minute)
return real == esperado
async def _plan_un_dia(self, day: date, cal, force: bool = False) -> list:
"""Planifica un dia concreto. Devuelve las filas guardadas."""
if self.is_paused():
logger.info("servicio en pausa, no se planifica")
return []
ov = self.store.get_override(day)
if ov and ov["action"] == "off":
logger.info("%s desactivado a mano: %s", day, ov["reason"])
return []
if not cal.is_workday(day):
motivo = cal.why_not_workday(day)
logger.info("%s no laborable: %s", day, motivo)
self.store.log("plan", f"{day} no laborable: {motivo}")
if day == self.today():
self.store.log("plan", f"{day} no laborable: {cal.why_not_workday(day)}")
return []
# ya planificado y el calendario no ha cambiado
if not force and self._plan_al_dia(day, cal):
return self.store.get_plan(day)
ausencia = await self._personal_absence(day)
if ausencia:
self.store.set_override(day, "off", f"Google Calendar: {ausencia}")
await self.notify(f"🌴 Hoy no se ficha: <i>{ausencia}</i> (Google Calendar)")
if day == self.today():
await self.notify(f"🌴 Hoy no se ficha: <i>{ausencia}</i> (Google Calendar)")
return []
plan = self.planner.plan_day(cal, day, random.Random())
self.store.save_plan(day, self.planner.rows_for_store(plan))
self.store.log("plan", f"{day} planificado: " +
", ".join(f"{p.label[:3]} {p.planned_at:%H:%M:%S}" for p in plan))
logger.info("plan de %s: %s", day, [str(p) for p in plan])
self.wake()
return self.store.get_plan(day)
async def ensure_horizon(self, dias: int = HORIZON_DAYS, force: 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()
nuevos = 0
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)
if not antes and self.store.get_plan(d):
nuevos += 1
self._planned_for = hoy
if nuevos:
self.store.log("plan", f"planificados {nuevos} dias nuevos hasta "
f"{hoy + timedelta(days=dias - 1)}")
self.wake()
async def ensure_plan(self, force: bool = False) -> list:
"""Plan de hoy. `force` lo vuelve a tirar aunque ya exista."""
await self.ensure_horizon(force=force)
return self.store.get_plan(self.today())
async def cancel_pending_today(self, reason: str) -> None:
day = self.today()
for r in self.store.pending(day):
@@ -185,7 +216,7 @@ class Service:
async def tick(self) -> None:
now = self.now()
if self._planned_for != now.date():
await self.ensure_plan()
await self.ensure_horizon()
if self.is_paused():
return