Back to 0cron
0cron

"Todos los dias a las 9am": Parsing de programacion en lenguaje natural

Como construimos un parser NLP de 152 lineas en Rust que convierte ingles simple como 'every day at 9am' en expresiones cron -- sin LLM.

Juste Thales Gnimavo & Claude | March 26, 2026 6 min 0cron
EN/ FR/ ES
0cronnlpregexrustcronuxparsing

A nadie le gusta escribir expresiones cron.

En serio. 0 9 <em> </em> * no es algo que un humano deberia tener que teclear en 2026. Es una notacion disenada para computadoras en 1975, y cada vez que un desarrollador tiene que buscar cual campo es el dia de la semana y cual es el mes, colectivamente perdemos otra hora de productividad humana. Hemos enviado sondas a Jupiter. Deberiamos poder decir "every day at 9am" y dejar que la maquina resuelva el resto.

Esa conviccion se convirtio en la funcionalidad estrella de 0cron.dev. Cuando un usuario crea una tarea, puede escribir ingles simple -- "every Monday at 2pm", "weekdays at 6am", "first day of the month at 9:30am" -- y el sistema lo convierte a una expresion cron valida detras de escenas. Sin documentacion requerida. Sin juego de adivinanzas de cinco campos.

El parser completo son 152 lineas de Rust. Usa regex. No un LLM. No un arbol de dependencias que importa la mitad de crates.io. Solo coincidencia de patrones contra las frases que la gente realmente usa cuando habla de horarios.

La decision UX: por que importa el ingles simple

0cron apunta a dos audiencias: desarrolladores que se sienten comodos con la sintaxis cron pero la encuentran molesta, y usuarios semi-tecnicos que saben que necesitan ejecutar un script en un horario pero no pueden recordar si */5 significa cada cinco minutos o cada quinto del mes.

Para el primer grupo, todavia aceptamos expresiones cron crudas. Para el segundo grupo -- y honestamente, para el primer grupo tambien, porque todos prefieren la claridad -- queriamos algo radical: escribe lo que quieres decir en ingles, y simplemente funciona.

Por que no un LLM?

Rechazamos esto inmediatamente, por tres razones:

Latencia. Una llamada LLM anade 200-800ms a lo que deberia ser una interaccion de interfaz instantanea.

Costo. 0cron es $1.99/mes con tareas ilimitadas. Si cada creacion de tarea dispara una llamada LLM, la economia no funciona.

Determinismo. Las expresiones cron deben ser exactas. No hay espacio para "interpretacion creativa". Si un LLM ocasionalmente parsea "every Tuesday" como 0 0 <em> </em> 3 en lugar de 0 0 <em> </em> 2, la tarea de ese usuario se ejecuta el miercoles y pierden confianza en la plataforma. Un regex o coincide o no. No hay riesgo de alucinacion.

La funcion principal: 152 lineas de claridad

rustpub fn parse_natural_language(input: &str) -> AppResult<String> {
    let input = input.trim().to_lowercase();

    // "every minute"
    if input == "every minute" {
        return Ok("* * * * *".to_string());
    }

    // "every N minutes"
    if let Some(caps) = re(r"^every (\d+) minutes?$").captures(&input) {
        let n: u32 = caps[1].parse().unwrap_or(1);
        return Ok(format!("*/{n} * * * *"));
    }

    // "every day at H:MMam/pm"
    if let Some(caps) = re(r"^every day at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$")
        .captures(&input)
    {
        let (hour, minute) = parse_time(&caps[1], caps.get(2).map(|m| m.as_str()), &caps[3])?;
        return Ok(format!("{minute} {hour} * * *"));
    }

    // "every [weekday] at H:MMam/pm"
    if let Some(caps) = re(
        r"^every (monday|tuesday|wednesday|thursday|friday|saturday|sunday) at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$"
    ).captures(&input) {
        let dow = weekday_to_cron(&caps[1]);
        let (hour, minute) = parse_time(&caps[2], caps.get(3).map(|m| m.as_str()), &caps[4])?;
        return Ok(format!("{minute} {hour} * * {dow}"));
    }

    // "weekdays at H:MMam/pm"
    if let Some(caps) = re(r"^weekdays at (\d{1,2})(?::(\d{2}))?\s*(am|pm)$")
        .captures(&input)
    {
        let (hour, minute) = parse_time(&caps[1], caps.get(2).map(|m| m.as_str()), &caps[3])?;
        return Ok(format!("{minute} {hour} * * 1-5"));
    }

    // ... and more patterns

    Err(AppError::Validation(format!(
        "Unrecognized schedule pattern: '{input}'. Try patterns like \
         'every day at 9am', 'every Monday at 2pm', 'every 15 minutes', \
         'weekdays at 6am'."
    )))
}

Legibilidad de arriba a abajo. Cada patron es un bloque autocontenido: un comentario explicando que coincide, un regex, extraccion de grupos capturados, y una declaracion de retorno.

Retornos tempranos. Cada patron retorna inmediatamente si coincide. No hay maquina de estado, no hay acumulador, no hay ambiguedad sobre cual patron gano.

Normalizacion por adelantado. La primera linea hace input.trim().to_lowercase(). Esto elimina una categoria entera de casos borde.

El helper parse_time: AM/PM sin lagrimas

rustfn parse_time(hour_str: &str, min_str: Option<&str>, ampm: &str) -> AppResult<(u32, u32)> {
    let mut hour: u32 = hour_str.parse()
        .map_err(|_| AppError::Validation("Invalid hour".to_string()))?;
    let minute: u32 = min_str.unwrap_or("0").parse()
        .map_err(|_| AppError::Validation("Invalid minute".to_string()))?;
    if ampm == "pm" && hour != 12 {
        hour += 12;
    } else if ampm == "am" && hour == 12 {
        hour = 0;
    }
    Ok((hour, minute))
}

La logica de conversion AM/PM tiene exactamente dos casos especiales:

  • PM y hora no es 12: Sumar 12. Asi 2pm se convierte en 14, 11pm se convierte en 23. Pero 12pm se queda en 12 (mediodia).
  • AM y hora es 12: Establecer a 0. Asi 12am se convierte en 0 (medianoche). Todas las demas horas AM se quedan como estan.

Mensajes de error que ensenan

rustErr(AppError::Validation(format!(
    "Unrecognized schedule pattern: '{input}'. Try patterns like \
     'every day at 9am', 'every Monday at 2pm', 'every 15 minutes', \
     'weekdays at 6am'."
)))

Esto no es un error generico "Invalid input". Hace tres cosas: hace eco de la entrada, proporciona ejemplos concretos, y cubre los casos de uso mas comunes. Un usuario puede copiar uno de estos ejemplos textualmente y modificarlo.

Lo que deliberadamente no soportamos

Horarios sub-minuto. "Every 30 seconds" no esta soportado. Las expresiones cron tienen granularidad minima de un minuto.

Patrones complejos multi-dia. "Every Monday and Wednesday at 9am" no se parsea actualmente. Los usuarios avanzados pueden ingresar cron crudo para horarios multi-dia.

Horarios relativos. "Every 2 hours starting at 8am" implica estado. En lugar de producir algo que casi-pero-no-del-todo coincide con la intencion del usuario, rechazamos la entrada.


Esta es la Parte 4 de una serie de 10 partes sobre la construccion de 0cron.dev.

#ArticuloEnfoque
1Por que el mundo necesita un servicio cron de 2 dolaresAnalisis de mercado y filosofia de precios
24 agentes, 1 producto: Construyendo 0cron en una sola sesionConstruccion en paralelo con 4 agentes Claude
3Construyendo un motor de programacion cron en RustAxum, sorted sets de Redis, ejecutor de tareas
4"Todos los dias a las 9am": Parsing de programacion en lenguaje naturalEste articulo
5Notificaciones multicanal: Email, Slack, Discord, Telegram, WebhooksDespacho de notificaciones en 5 canales
6Integracion Stripe para un SaaS de $1.99/mesFacturacion, pruebas gratuitas y manejo de webhooks
7De HTML estatico a dashboard SvelteKit en una nocheArquitectura frontend y runes Svelte 5
8Monitoreo heartbeat: cuando tu tarea deberia hacerte pingModelo de monitor, pings y periodos de gracia
9Secretos encriptados, claves API y seguridadAES-256-GCM, auth con clave API, firma HMAC
10De Abidjan a produccion: Lanzando 0cron.devLa historia completa y que viene despues
Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude deblo

El prompt decía que lo anunciara. El modelo no lo hizo. Así que lo hizo el worker: cerrar un silencio de 15 segundos en Déblo Voice

Déblo Voice se quedaba en silencio unos 15 segundos cada vez que una pregunta difícil activaba una búsqueda profunda. El prompt ya exigía un anuncio, pero el modelo de audio nativo emite la llamada a la función como su primer token. Además, el silencio tenía dos partes, la ejecución de la herramienta y la latencia de la respuesta, así que ningún prompt podía cerrarlo. Llevamos la garantía al worker de LiveKit: un anuncio pregrabado con la voz y el idioma de la sesión, y un suave sonido de tecleo mientras el agente piensa. Probado en producción el 16 de octubre de 2026: 14 y 17,5 segundos de silencio del lado del modelo, unos 0,4 segundos percibidos por el usuario.

13 min Oct 16, 2026
deblodeblo-voicevoice-aigemini-live +17
Thales & Claude zerosuite

El servidor que no dimos de baja: vaciar una máquina de producción en una noche, y cada número que comprobamos

Una noche, seis aplicaciones migradas fila por fila, tres copias de seguridad vacías, una sin destino y un servidor cuyo futuro cambió tres veces.

19 min Oct 2, 2026
caspclaude-codeclaude-opus-5.5infrastructure +10
Thales & Claude zerosuite

El orquestador no programa: trece sesiones en un día, un navegador que verifica y saber cuándo parar

Un día, una plataforma privada de un cliente, trece sesiones headless de Claude encadenadas por un orquestador que nunca escribe código. Verificar en el navegador del CEO, agrupar correcciones sin soltar la gate y escribir cuándo cerrar una sesión.

19 min Sep 30, 2026
caspclaude-codeclaude-opus-5.5multi-session +12