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.

Thales & Claude | March 30, 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