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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user