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

@@ -13,7 +13,7 @@ from telegram import (BotCommand, BotCommandScopeChat, InlineKeyboardButton,
from telegram.constants import ParseMode
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
from app import jitter
from app import jitter, profiles
logger = logging.getLogger(__name__)
@@ -101,6 +101,7 @@ MENU: list[tuple[str, str]] = [
("hoy", "Plan y estado de hoy"),
("saldo", "Saldo del mes y acumulado"),
("plan", "Ver el plan y re-tirar los dados"),
("perfil", "Ver y cambiar el perfil de jitter"),
("proximos", "Previsión de los próximos días [nº de días]"),
("fichar", "Fichar ENTRADA ahora"),
("desfichar", "Fichar SALIDA ahora"),
@@ -131,7 +132,7 @@ class BotUI:
cmds = {
"start": self.cmd_hoy, "help": self.cmd_help, "ayuda": self.cmd_help,
"hoy": self.cmd_hoy, "saldo": self.cmd_saldo, "plan": self.cmd_plan,
"proximos": self.cmd_proximos,
"proximos": self.cmd_proximos, "perfil": self.cmd_perfil,
"off": self.cmd_off, "on": self.cmd_on,
"fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar,
"pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar,
@@ -276,6 +277,56 @@ class BotUI:
await update.effective_message.reply_text("\n".join(lines),
parse_mode=ParseMode.HTML)
def _perfil_texto(self) -> str:
actual = self.svc.store.get("perfil", profiles.DEFECTO)
lines = ["<b>Perfil de jitter</b>",
"Cuánta variedad tienen las horas de fichaje, y lo que cuesta "
"en minutos de estar ahí. Todos dan saldo 0.\n"]
for n in profiles.nombres():
st = profiles.stats(n)
marca = "▶️ " if n == actual else "· "
lines.append(
f"{marca}<b>{profiles.titulo(n)}</b> — {st['coste']:.1f} min/día "
f"(madrugón {st['madrugon']:.1f}) · {st['mins']} minutos, moda {st['moda']}%")
lines.append(f" <i>{profiles.resumen(n)}</i>")
lines.append("\n<i>madrugón = de esos minutos, los que te levantas antes "
"por la entrada de la mañana. Tu histórico real: 4 minutos, moda 38%.</i>")
return "\n".join(lines)
def _perfil_teclado(self) -> InlineKeyboardMarkup:
actual = self.svc.store.get("perfil", profiles.DEFECTO)
botones = [InlineKeyboardButton(
("✅ " if n == actual else "") + profiles.titulo(n),
callback_data=f"{CB}:perfil:{n}") for n in profiles.nombres()]
filas = [botones[i:i + 2] for i in range(0, len(botones), 2)]
return InlineKeyboardMarkup(filas)
async def cmd_perfil(self, update: Update, ctx) -> None:
if ctx.args:
n = ctx.args[0].strip().lower()
if not profiles.existe(n):
await update.effective_message.reply_text(
"No conozco ese perfil. Son: " + ", ".join(profiles.nombres()))
return
await self.svc.cambiar_perfil(n)
await update.effective_message.reply_text(
f"✅ Perfil <b>{profiles.titulo(n)}</b>. Replanificados los días futuros.",
parse_mode=ParseMode.HTML)
return
await update.effective_message.reply_text(
self._perfil_texto(), parse_mode=ParseMode.HTML,
reply_markup=self._perfil_teclado())
async def _cambiar_perfil(self, q, nombre: str) -> None:
if not profiles.existe(nombre):
await q.answer("Perfil desconocido")
return
await q.answer(f"Cambiando a {profiles.titulo(nombre)}…")
await self.svc.cambiar_perfil(nombre)
await q.edit_message_text(
self._perfil_texto() + "\n\n<b>Replanificados los días futuros.</b>",
parse_mode=ParseMode.HTML, reply_markup=self._perfil_teclado())
async def cmd_off(self, update: Update, ctx) -> None:
days = parse_range(ctx.args, self.svc.today())
if not days:
@@ -348,10 +399,16 @@ class BotUI:
if not self._mine(update):
await q.answer()
return
partes = q.data.split(":")
action = partes[1] if len(partes) > 1 else ""
if action == "perfil":
await self._cambiar_perfil(q, partes[2])
return
try:
_, action, day_s, idx_s = q.data.split(":")
day, idx = date.fromisoformat(day_s), int(idx_s)
except ValueError:
day, idx = date.fromisoformat(partes[2]), int(partes[3])
except (IndexError, ValueError):
await q.answer("Botón no reconocido")
return

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):

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from app import jitter
from app import jitter, profiles
from app.schedule import Calendar
logger = logging.getLogger(__name__)
@@ -46,7 +46,19 @@ class Planner:
self.cfg = cfg
self.store = store
self.tz = ZoneInfo(cfg.get("timezone", "Europe/Madrid"))
self.j = cfg["jitter"]
@property
def perfil(self) -> str:
"""Perfil activo. Se lee del store en cada plan, para poder cambiarlo en caliente."""
if self.store:
nombre = self.store.get("perfil", self.cfg.get("perfil", profiles.DEFECTO))
if profiles.existe(nombre):
return nombre
return self.cfg.get("perfil", profiles.DEFECTO)
@property
def j(self) -> dict:
return profiles.params(self.perfil)
# ------------------------------------------------------------------ drift
def _drift_key(self, window_idx: int, typ: int) -> str:
@@ -55,7 +67,8 @@ class Planner:
def _drift(self, window_idx: int, typ: int, rng: random.Random) -> float:
key = self._drift_key(window_idx, typ)
prev = float(self.store.get(key, 0.0)) if self.store else 0.0
nxt = jitter.next_drift(prev, self.j["drift_sigma"], self.j["drift_clamp"], rng)
j = self.j
nxt = jitter.next_drift(prev, j["drift_sigma"], j["drift_clamp"], rng)
if self.store:
self.store.put(key, nxt)
return nxt
@@ -69,6 +82,7 @@ class Planner:
return []
rng = rng or random.Random()
j = self.j
out: list[PlannedCheck] = []
idx = 0
@@ -78,19 +92,24 @@ class Planner:
# ENTRADA: siempre antes de la hora teorica. El tope se acota ademas
# con el margen de entrada que declara el propio tipo de jornada.
tope = min(self.j["entry_cap"], max(w.entry_margin * 60 // 2, 60)) if w.entry_margin \
else self.j["entry_cap"]
cap = profiles.valor(j, "entry", "cap", wi)
tope = min(cap, max(w.entry_margin * 60 // 2, 60)) if w.entry_margin else cap
off = jitter.offset_seconds(
self.j["entry_floor"], self.j["entry_median"], self.j["entry_sigma"],
profiles.valor(j, "entry", "floor", wi),
profiles.valor(j, "entry", "median", wi),
profiles.valor(j, "entry", "sigma", wi),
tope, self._drift(wi, ENTRADA, rng), rng)
off = max(off, self.j["entry_guard"])
off = max(off, j["entry_guard"])
out.append(PlannedCheck(idx, ENTRADA, wi, start, start - timedelta(seconds=off)))
idx += 1
# SALIDA: siempre despues
off = jitter.offset_seconds(
self.j["exit_floor"], self.j["exit_median"], self.j["exit_sigma"],
self.j["exit_cap"], self._drift(wi, SALIDA, rng), rng)
profiles.valor(j, "exit", "floor", wi),
profiles.valor(j, "exit", "median", wi),
profiles.valor(j, "exit", "sigma", wi),
profiles.valor(j, "exit", "cap", wi),
self._drift(wi, SALIDA, rng), rng)
out.append(PlannedCheck(idx, SALIDA, wi, end, end + timedelta(seconds=off)))
idx += 1

122
app/profiles.py Normal file
View File

@@ -0,0 +1,122 @@
"""Perfiles de jitter intercambiables.
El compromiso de fondo no tiene truco: Timenet solo cuenta dentro de la ventana
teorica, asi que solo se puede variar hacia el lado seguro, y alejarse cuesta
presencia real. Mas variedad visible = mas minutos ahi. Cada perfil elige un punto
de ese compromiso.
Un perfil puede afinar por tramo con sufijo `_w<n>`: `entry_median_w0` gana a
`entry_median` para el tramo 0. Sirve para repartir el coste donde menos molesta:
adelantar la entrada de la mañana ALARGA el dia, mientras que adelantar la vuelta
de comer solo ACORTA la comida y se sale a la misma hora.
"""
from __future__ import annotations
# Valores comunes. Un perfil solo declara lo que cambia.
BASE = {
"entry_floor": 12,
"entry_median": 58,
"entry_sigma": 0.95,
"entry_cap": 330,
"exit_floor": 10,
"exit_median": 42,
"exit_sigma": 0.95,
"exit_cap": 300,
"drift_sigma": 0.30,
"drift_clamp": 0.75,
"entry_guard": 12,
}
PERFILES: dict[str, dict] = {
"discreto": {
"titulo": "Discreto",
"resumen": "Lo más barato. Casi siempre justo antes de la hora.",
"stats": {'coste': 2.82, 'madrugon': 1.29, 'mins': 2, 'moda': 75},
"params": {
"entry_median": 42, "entry_sigma": 0.80, "entry_cap": 240,
"exit_median": 30, "exit_sigma": 0.80, "exit_cap": 210,
},
},
"natural": {
"titulo": "Natural",
"resumen": "Equilibrio entre variedad y minutos de más.",
"stats": {'coste': 4.14, 'madrugon': 1.81, 'mins': 5, 'moda': 51},
"params": {
"entry_median": 58, "entry_sigma": 0.95, "entry_cap": 330,
"exit_median": 42, "exit_sigma": 0.95, "exit_cap": 300,
},
},
"camuflado": {
"titulo": "Camuflado",
"resumen": "Se parece a tu histórico real. Cuesta algo más.",
"stats": {'coste': 5.47, 'madrugon': 2.08, 'mins': 4, 'moda': 43},
"params": {
"entry_median": 75, "entry_sigma": 1.05, "entry_cap": 450,
"exit_median": 55, "exit_sigma": 1.05, "exit_cap": 420,
},
},
"disperso": {
"titulo": "Disperso",
"resumen": "Máxima variedad. Paga bastantes minutos por ella.",
"stats": {'coste': 7.87, 'madrugon': 2.99, 'mins': 6, 'moda': 29},
"params": {
"entry_median": 110, "entry_sigma": 1.15, "entry_cap": 600,
"exit_median": 80, "exit_sigma": 1.15, "exit_cap": 540,
},
},
"sin_madrugar": {
"titulo": "Sin madrugar",
"resumen": "Entrada de la mañana pegada a las 9. La variedad se la come "
"la pausa de comer y la salida, así no te levantas antes.",
"stats": {'coste': 5.68, 'madrugon': 1.26, 'mins': 2, 'moda': 77},
"params": {
# tramo 0 = mañana: lo mas ajustado posible, no alarga el dia
"entry_median_w0": 35, "entry_sigma_w0": 0.70, "entry_cap_w0": 150,
# tramo 1 = vuelta de comer: generoso, solo acorta la comida
"entry_median_w1": 120, "entry_sigma_w1": 1.15, "entry_cap_w1": 600,
# salidas: generosas, no obligan a madrugar
"exit_median": 75, "exit_sigma": 1.10, "exit_cap": 480,
},
},
}
DEFECTO = "natural"
def existe(nombre: str) -> bool:
return nombre in PERFILES
def nombres() -> list[str]:
return list(PERFILES)
def params(nombre: str) -> dict:
"""Parametros efectivos del perfil, con BASE por debajo."""
p = dict(BASE)
p.update(PERFILES.get(nombre, PERFILES[DEFECTO])["params"])
return p
def titulo(nombre: str) -> str:
return PERFILES.get(nombre, {}).get("titulo", nombre)
def resumen(nombre: str) -> str:
return PERFILES.get(nombre, {}).get("resumen", "")
def stats(nombre: str) -> dict:
"""Medidas sobre los 226 dias laborables del año. Ver tools/perfiles.py.
coste = presencia total de mas, min/dia
madrugon = de esos, los que te levantas antes por la entrada de la mañana
mins = minutos distintos visibles en la entrada de las 09:00
moda = cuota del minuto mas frecuente
"""
return PERFILES.get(nombre, {}).get("stats", {})
def valor(p: dict, clase: str, campo: str, wi: int):
"""`entry_median_w0` gana a `entry_median` para el tramo 0."""
return p.get(f"{clase}_{campo}_w{wi}", p[f"{clase}_{campo}"])