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:
48
app/bot.py
48
app/bot.py
@@ -13,6 +13,8 @@ from telegram import (BotCommand, BotCommandScopeChat, InlineKeyboardButton,
|
||||
from telegram.constants import ParseMode
|
||||
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
|
||||
|
||||
from app import jitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENTRADA = 0
|
||||
@@ -234,57 +236,43 @@ class BotUI:
|
||||
|
||||
async def cmd_proximos(self, update: Update, ctx) -> None:
|
||||
try:
|
||||
ndias = max(1, min(60, int(ctx.args[0]))) if ctx.args else 14
|
||||
ndias = max(1, min(45, int(ctx.args[0]))) if ctx.args else 14
|
||||
except ValueError:
|
||||
ndias = 14
|
||||
|
||||
cal = await asyncio.to_thread(self.svc.executor.calendar)
|
||||
j = self.svc.cfg["jitter"]
|
||||
hoy = self.svc.today()
|
||||
icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑",
|
||||
"skipped": "⤵️", "failed": "🟥"}
|
||||
lines = [f"<b>Previsión · próximos {ndias} días</b>"]
|
||||
laborables = 0
|
||||
|
||||
for i in range(ndias):
|
||||
d = hoy + timedelta(days=i)
|
||||
cabecera = f"<b>{fecha_corta(d)}</b>"
|
||||
cab = f"<b>{fecha_corta(d)}</b>"
|
||||
|
||||
ov = self.svc.store.get_override(d)
|
||||
if ov and ov["action"] == "off":
|
||||
lines.append(f"{cabecera} 🚫 <i>{ov['reason'] or 'desactivado a mano'}</i>")
|
||||
lines.append(f"{cab} 🚫 <i>{ov['reason'] or 'desactivado a mano'}</i>")
|
||||
continue
|
||||
if not cal.is_workday(d):
|
||||
lines.append(f"{cabecera} 🌴 <i>{cal.why_not_workday(d)}</i>")
|
||||
lines.append(f"{cab} 🌴 <i>{cal.why_not_workday(d)}</i>")
|
||||
continue
|
||||
|
||||
laborables += 1
|
||||
if d == hoy and self.svc.store.get_plan(d):
|
||||
icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑",
|
||||
"skipped": "⤵️", "failed": "🟥"}
|
||||
trozos = []
|
||||
for r in self.svc.store.get_plan(d):
|
||||
t = datetime.fromisoformat(r["planned_at"])
|
||||
trozos.append(f"{icon.get(r['state'], '·')}{t:%H:%M:%S}")
|
||||
lines.append(f"{cabecera} {' '.join(trozos)}")
|
||||
continue
|
||||
|
||||
# dias futuros: el instante exacto se sortea esa madrugada, pero el
|
||||
# rango en que caera si es exacto
|
||||
trozos = []
|
||||
for w in cal.windows(d):
|
||||
ini = datetime.combine(d, datetime.min.time())
|
||||
ent = ini + timedelta(minutes=w.start)
|
||||
sal = ini + timedelta(minutes=w.end)
|
||||
trozos.append(
|
||||
f"{ent - timedelta(seconds=j['entry_before_max']):%H:%M}"
|
||||
f"-{ent - timedelta(seconds=j['entry_before_min']):%H:%M}"
|
||||
f" → {sal + timedelta(seconds=j['exit_after_min']):%H:%M}"
|
||||
f"-{sal + timedelta(seconds=j['exit_after_max']):%H:%M}")
|
||||
lines.append(f"{cabecera} <code>{' · '.join(trozos)}</code>")
|
||||
plan = self.svc.store.get_plan(d)
|
||||
if plan:
|
||||
trozos = [f"{icon.get(r['state'], '·')}"
|
||||
f"{datetime.fromisoformat(r['planned_at']):%H:%M:%S}"
|
||||
for r in plan]
|
||||
lines.append(f"{cab} <code>{' '.join(trozos)}</code>")
|
||||
else:
|
||||
# mas alla del horizonte que se planifica por adelantado
|
||||
tramos = " ".join(str(w) for w in cal.windows(d))
|
||||
lines.append(f"{cab} <code>{tramos}</code> <i>(sin planificar aún)</i>")
|
||||
|
||||
b = await asyncio.to_thread(self.svc.executor.balance)
|
||||
lines.append(f"\n{laborables} días laborables · saldo acumulado <b>{hm(b['total'])}</b>")
|
||||
lines.append("<i>Las horas futuras son el rango en que caerá el fichaje; "
|
||||
"el segundo exacto se sortea esa madrugada.</i>")
|
||||
await update.effective_message.reply_text("\n".join(lines),
|
||||
parse_mode=ParseMode.HTML)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user