/* utils-plan-parser.jsx — convierte el Markdown que devuelve el LLM en una * estructura tipada para el calendario de planes. * * Es tolerante porque el LLM no siempre cumple el formato pedido: * * - Encabezados de semana pueden ser `## Semana 1`, `### Week 1`, `**Semana 1**`, * `Semana 1:` o incluso solo `Semana 1` sin marcado. * - Días pueden ir como `- Lun:`, `**Lun**:`, `Lunes -`, `Mon —`, etc. * - Las sesiones pueden venir en una sola línea o desglosadas con bullets anidados. * * Si el markdown no matchea nada parseable, devolvemos `null` y la UI cae a * mostrar el texto crudo con `` (ya implementado en screen-agents). * * Output: * { * title: string, * summary: string, // párrafo final largo (narrativa "por qué este plan") * weeks: [{ * n: number, * label: string, * days: [{ * dow: 0..6 (lun=0…dom=6) | -1 si no se pudo determinar, * label: "Lun" | "Mar" | …, * sessions: [{ * name: string, * intensity: string | null, // "Z2", "Z3-Z4", "RPE 4", … * duration_min: number | null, * load: number | null, * notes: string | null, // resto de la línea/bullets nested * tone: "rest"|"easy"|"long"|"hard"|"strength", * }], * }], * }], * } */ const _DOW_TABLE = (() => { // Multiples idiomas y abreviaturas. La clave es la forma normalizada. const map = {}; const add = (idx, label, aliases) => { aliases.forEach((a) => { map[a.toLowerCase()] = { idx, label }; }); }; add(0, "Lun", ["lun", "lunes", "mon", "monday", "l"]); add(1, "Mar", ["mar", "martes", "tue", "tues", "tuesday", "m"]); add(2, "Mié", ["mié", "mie", "miercoles", "miércoles", "wed", "wednesday", "x"]); add(3, "Jue", ["jue", "jueves", "thu", "thur", "thurs", "thursday", "j"]); add(4, "Vie", ["vie", "viernes", "fri", "friday", "v"]); add(5, "Sáb", ["sab", "sáb", "sabado", "sábado", "sat", "saturday", "s"]); add(6, "Dom", ["dom", "domingo", "sun", "sunday", "d"]); return map; })(); const _DOW_LABELS = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"]; /* Inferencia de "tono" para colorear el calendario. Heurísticas en orden de * prioridad: descanso > fuerza > duro/intervalos > largo > suave. */ function inferTone(text) { const t = (text || "").toLowerCase(); if (!t.trim()) return "rest"; if (/(descanso|rest day|rest|off day|libre|movilidad|recuperaci(o|ó)n activa)/.test(t)) return "rest"; if (/(fuerza|strength|gym|pesas|core|squat|deadlift|sentadilla|peso muerto)/.test(t)) return "strength"; if (/(intervalos|intervals|tempo|threshold|umbral|series|cuestas|hill repeats|fartlek|vo2|sprint|test|carrera de|race)/.test(t)) return "hard"; if (/\b(z[34]\b|z3-?z?4|z4|zona\s*[34])\b/.test(t)) return "hard"; if (/(largo|long run|long ride|tirada larga)/.test(t)) return "long"; if (/(easy|suave|recovery|z2|zona\s*2|endurance|rodaje|base)/.test(t)) return "easy"; return "easy"; // default seguro: suave } /* "60 minutos" / "60 min" / "1h30" / "1:30" / "90'" → minutos. * Devuelve la primera duración encontrada (la "principal" — el LLM suele * escribir la duración total al inicio de la sesión y los desgloses después). */ function parseDurationMinutes(text) { if (!text) return null; // Una sesión real nunca pasa de ~10h. Si el regex saca un valor mayor, // casi seguro estamos parseando un total semanal o un mismatch (clock time, // sumas erradas, etc.) — devolver null en vez de mostrar "59h51". const MAX_REASONABLE_MIN = 600; const guard = (v) => (v != null && v > 0 && v <= MAX_REASONABLE_MIN ? v : null); const s = String(text).toLowerCase(); // 1h30, 1h 30, 1h30min, 1 hora 30 — captura horas+minutos. let m = s.match(/(\d+)\s*h(?:oras?)?\s*(\d+)\s*(?:m|min|'|´)/); if (m) { const h = parseInt(m[1], 10); const mm = parseInt(m[2], 10); if (mm < 60) return guard(h * 60 + mm); } // 1h45 (sin sufijo de minutos), 1h m = s.match(/(\d+)\s*h(?:oras?)?\s*(\d+)?\b/); if (m) { const h = parseInt(m[1], 10); const mm = m[2] ? parseInt(m[2], 10) : 0; if (mm < 60) return guard(h * 60 + mm); } // 1:30 — sólo si parece duración (h <= 10 y mm < 60). Hora del día como // "06:00 - 07:30" se descarta acá. m = s.match(/\b(\d+):(\d{2})\b/); if (m) { const h = parseInt(m[1], 10); const mm = parseInt(m[2], 10); if (h <= 10 && mm < 60) return guard(h * 60 + mm); } // 60' / 60′ / 60´ / 90 min / 90 minutos m = s.match(/(\d+)\s*(?:['′´]|min(?:ut(?:os|es)?)?\b)/); if (m) return guard(parseInt(m[1], 10)); return null; } /* "Z2" / "Z3-Z4" / "zona 2" / "RPE 4" / "FC 140" — la primera coincidencia. */ function parseIntensity(text) { if (!text) return null; const t = String(text); let m = t.match(/\bZ\s*\d(?:\s*[-–]\s*Z?\s*\d)?\b/i); if (m) return m[0].replace(/\s+/g, "").toUpperCase(); m = t.match(/\bzona\s*\d(?:\s*[-–]\s*\d)?\b/i); if (m) return m[0].replace(/zona\s*/i, "Z").replace(/\s+/g, ""); m = t.match(/\bRPE\s*\d+(?:\s*[-–]\s*\d+)?\b/i); if (m) return m[0].replace(/\s+/g, " ").toUpperCase(); return null; } /* "carga 90" / "load 110" / "TSS 75" → número. */ function parseLoad(text) { if (!text) return null; const m = String(text).match(/\b(?:carga|load|tss)\s*[:=]?\s*(\d{2,3})\b/i); return m ? parseInt(m[1], 10) : null; } /* Heurística: identificar el deporte principal de una sesión por palabras * clave. El LLM tiende a empezar la línea con el deporte ("Carrera · 60' Z2" * o "Bici · 75' Z2"). Devuelve un id corto o null. */ function _inferSport(text) { if (!text) return null; const t = String(text).toLowerCase(); if (/\bcarrera|running|trote|run\b/.test(t)) return "running"; if (/\bbici|bike|cycling|ciclismo|spinning\b/.test(t)) return "cycling"; if (/\bnataci|swim|piscina\b/.test(t)) return "swimming"; if (/\btrail\b/.test(t)) return "trail_running"; if (/\bgym|fuerza|pesas|strength|press\b/.test(t)) return "strength"; if (/\brem(o|ar)|rowing\b/.test(t)) return "rowing"; if (/\byoga|movilidad|stretch\b/.test(t)) return "yoga"; if (/\btrekking|hiking|caminata\b/.test(t)) return "hiking"; if (/\btriatl[oó]n|triathlon\b/.test(t)) return "triathlon"; return null; } /* Detecta si una línea empieza con un día de la semana y devuelve * `{ dow, label, rest }` con el resto de la línea. Si no, null. */ function _matchDayLine(rawLine) { // Quitar bullets, números, asteriscos al inicio. let line = rawLine .replace(/^\s*[-*+•]\s*/, "") .replace(/^\s*\d+\.\s*/, "") .replace(/^\s*\*\*([^*]+)\*\*/, "$1") // **Lun**: … .replace(/^\s*__([^_]+)__/, "$1"); line = line.trim(); if (!line) return null; // Tomar la primera "palabra" delimitada por : / – / — / -. const m = line.match(/^([A-Za-zÁÉÍÓÚáéíóúñÑ]+)\.?\s*[:\-–—]\s*(.*)$/); if (!m) return null; const dayKey = m[1].toLowerCase().replace(/\.$/, ""); const entry = _DOW_TABLE[dayKey]; if (!entry) return null; return { dow: entry.idx, label: entry.label, rest: (m[2] || "").trim() }; } /* Detecta encabezado de semana. Devuelve número de semana o null. */ function _matchWeekHeading(rawLine) { const line = rawLine .replace(/^\s*#+\s*/, "") // ## … .replace(/^\s*\*\*(.+?)\*\*\s*$/, "$1") // **…** .replace(/^\s*__(.+?)__\s*$/, "$1") .trim(); // "Semana 1", "Semana 1:", "Semana 1 — base", "Week 1", "Week 1 - base". const m = line.match(/^(?:semana|week)\s*[#nº]?\s*(\d+)\b/i); if (m) return { n: parseInt(m[1], 10), label: line }; return null; } /* Extrae nombre de sesión + estructura del resto de la línea. * * "Z2 endurance · 60' zona 2 · carga 70" * "Tempo 30' · 10' E + 30' tempo + 10' E · carga 95" * "Largo Z2 — 1h45' zona 2" */ function _parseSession(rest) { if (!rest) return null; // Cuts robustos: ` · `, ` — `, ` -- `, ` | `. Si no hay separadores, name = rest. const parts = rest.split(/\s+[·•|–—]\s+|\s{2,}--\s+/); const name = (parts[0] || rest).trim(); const tail = parts.slice(1).join(" · "); const all = rest; const tone = inferTone(`${name} ${tail}`); const intensity = parseIntensity(all); const duration_min = parseDurationMinutes(all); const load = parseLoad(all); // notes = todo lo que sobra (sin name) si hay extra info. const notes = tail || null; return { name: name || "Sesión", intensity, duration_min, load, notes, tone, }; } /* Limpia inline markdown básico (negritas, italics) para mostrar texto plano. */ function _stripInline(s) { if (!s) return s; return String(s) .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/__([^_]+)__/g, "$1") .replace(/_([^_]+)_/g, "$1") .replace(/`([^`]+)`/g, "$1") .trim(); } function parseTrainingPlan(markdown) { if (!markdown || typeof markdown !== "string") return null; // Algunos modelos envuelven toda la respuesta en ```markdown ... ```. Sin // strip, los headings ## quedan dentro de un code block invisible para el // resto del parser y la primera "Semana" nunca matchea. const stripper = (typeof window !== "undefined" && window.stripWrappingCodeFence) ? window.stripWrappingCodeFence : (s) => { const m = String(s).replace(/\r\n/g, "\n").match(/^\s*```[a-zA-Z]*\s*\n([\s\S]*?)\n```\s*$/); return m ? m[1] : s; }; const lines = stripper(markdown).replace(/\r\n/g, "\n").split("\n"); let title = null; const weeks = []; let currentWeek = null; let summaryLines = []; let inSummarySection = false; // Pase principal. for (let i = 0; i < lines.length; i++) { const raw = lines[i]; const line = raw.trimEnd(); if (!line.trim()) continue; // Título del plan: primer h1 antes de cualquier semana. if (!title && /^\s*#\s+/.test(line) && weeks.length === 0) { title = _stripInline(line.replace(/^\s*#\s+/, "")); continue; } // ¿Sección "Por qué" / "Resumen" / "Notas"? Activa modo summary. const lowered = line.replace(/^\s*#+\s*/, "").trim().toLowerCase(); if ( /^\s*#+\s*/.test(line) && /^(por qu(é|e)|resumen|notas|justificaci(ó|o)n|narrativa|why|rationale|summary|observaciones)\b/.test(lowered) ) { inSummarySection = true; currentWeek = null; continue; } // Encabezado de semana. const wk = _matchWeekHeading(line); if (wk) { inSummarySection = false; currentWeek = { n: wk.n, label: _stripInline(wk.label), days: [] }; weeks.push(currentWeek); continue; } // Línea de día (solo si estamos dentro de una semana). if (currentWeek) { const day = _matchDayLine(line); if (day) { const session = _parseSession(_stripInline(day.rest)); // Si el día ya existe en la semana (LLM partió una sesión en 2 líneas), añadimos. const existing = currentWeek.days.find((d) => d.dow === day.dow); if (existing) { if (session) existing.sessions.push(session); } else { currentWeek.days.push({ dow: day.dow, label: day.label, sessions: session ? [session] : [], }); } continue; } // Bullet anidado / continuación. Detectamos "Principal" y "Alternativa" // (con o sin negritas) para que el plan multi-deporte se renderice // como dos opciones por sesión en vez de quedar como notas planas. if (/^\s+[-*•]\s+/.test(raw) || /^\s{2,}\S/.test(raw)) { const lastDay = currentWeek.days[currentWeek.days.length - 1]; if (lastDay && lastDay.sessions.length) { const lastSession = lastDay.sessions[lastDay.sessions.length - 1]; const stripped = _stripInline(line.replace(/^\s*[-*•]\s*/, "")); const variantMatch = stripped.match(/^(Principal|Alternativa|Alt|Primary|Alternative)\s*[::]\s*(.+)$/i); if (variantMatch) { const kind = /^(alt|alternativ)/i.test(variantMatch[1]) ? "alt" : "primary"; const body = variantMatch[2].trim(); const sessionParts = _parseSession(body) || { name: body, notes: null }; const variant = { kind, name: sessionParts.name, intensity: sessionParts.intensity, duration_min: sessionParts.duration_min, load: sessionParts.load, notes: sessionParts.notes, tone: sessionParts.tone || lastSession.tone, sport: _inferSport(body), }; lastSession.variants = lastSession.variants || []; // Reemplazar variant del mismo kind si el LLM repite (último gana). lastSession.variants = lastSession.variants.filter((v) => v.kind !== kind); lastSession.variants.push(variant); // Si la sesión "principal" del header estaba vacía, propagamos el // nombre de la variante principal para que el chip se vea poblado. if (kind === "primary" && (!lastSession.intensity && !lastSession.duration_min)) { lastSession.intensity = sessionParts.intensity ?? lastSession.intensity; lastSession.duration_min = sessionParts.duration_min ?? lastSession.duration_min; } } else { lastSession.notes = lastSession.notes ? `${lastSession.notes} · ${stripped}` : stripped; } } continue; } } // Si no encajó en nada y estamos en summary, acumulamos. if (inSummarySection || (!currentWeek && weeks.length > 0)) { summaryLines.push(_stripInline(line)); } } // Si no encontramos ninguna semana, no podemos parsear → null. if (!weeks.length) return null; // Asegurar que cada semana tenga 7 entradas (rellenar días vacíos como descanso). for (const w of weeks) { const present = new Set(w.days.map((d) => d.dow)); for (let dow = 0; dow < 7; dow++) { if (!present.has(dow)) { w.days.push({ dow, label: _DOW_LABELS[dow], sessions: [] }); } } w.days.sort((a, b) => a.dow - b.dow); } // Si no hubo summary explícito, agarrar último párrafo largo del documento. let summary = summaryLines.join("\n").trim(); if (!summary) { // Buscar el párrafo final más largo después de la última semana. const lastWeekIdx = (() => { for (let i = lines.length - 1; i >= 0; i--) { if (_matchWeekHeading(lines[i].trim())) return i; } return -1; })(); if (lastWeekIdx >= 0) { const tail = lines.slice(lastWeekIdx).join("\n"); // Tomar el último bloque de texto plano (sin bullets) más largo. const blocks = tail.split(/\n\s*\n/).map((b) => b.trim()).filter(Boolean); const candidate = blocks .reverse() .find((b) => b.length > 80 && !_matchWeekHeading(b.split("\n")[0]) && !/^[-*•]/.test(b)); if (candidate) summary = _stripInline(candidate); } } return { title: title || "Plan de entrenamiento", summary: summary || "", weeks, }; } window.parseTrainingPlan = parseTrainingPlan; window._planParserInternals = { inferTone, parseDurationMinutes, parseIntensity, parseLoad, };