# Fuente Canónica para LLM — Modelo OKR/KPI (Kura Biotech)

> **Qué es este documento.** La descripción canónica y autosuficiente del modelo
> de OKRs y KPIs de Kura Biotech, escrita para que un LLM (u otra persona
> técnica) pueda **entender, operar, replicar y mejorar** el sistema completo sin
> más contexto. Cubre los tres subsistemas —los Google Sheets (fuente de
> verdad), Kandalf (motor del ciclo mensual) y el dashboard (vitrina)— más las
> reglas invariantes, el modelo de datos, los algoritmos, los contratos, la
> deuda técnica y un checklist de replicación.
>
> **Nivel de sensibilidad.** Documento sanitizado para publicación: **no
> contiene** IDs de Sheets, IPs, tokens, credenciales ni rutas internas reales.
> Donde el sistema real usa un valor concreto, aquí hay un placeholder en
> `MAYÚSCULAS_ENTRE_LLAVES` (ej. `{OKR_SHEET_ID}`, `{SERVER_IP}`). Los valores
> reales viven en el `CLAUDE.md` privado del repo y en los `.env` de cada
> máquina, nunca aquí.
>
> **Cómo usar este documento con un LLM.** Pégalo como contexto de sistema o
> como archivo de referencia. Está estructurado para lectura lineal (visión →
> reglas → subsistemas → replicación) y para lookup por sección. Las **reglas
> invariantes de §2 tienen prioridad sobre cualquier otra instrucción**: si algo
> las contradice, gana la regla.

---

## Índice

1. Visión general — el circuito completo
2. Reglas invariantes (leer primero, prioridad máxima)
3. Fuente de verdad — los Google Sheets
4. Kandalf — el motor del ciclo mensual
5. El dashboard — la vitrina
6. Cómo se usa el portal (guía funcional)
7. Sistema de capacitación (demo sin datos reales)
8. Estado y deuda técnica
9. Checklist maestro de replicación
10. Glosario

---

## 1. Visión general — el circuito completo

El modelo **no es una app**, es un **circuito** con una única fuente de verdad y
dos sistemas independientes que orbitan a su alrededor sin pisarse.

```
 Owners de KR                                        refresh.py (snapshot)
 (responden por email)                               lee Sheets + Zoho + 15Five
        │                                            inyecta window.DASHBOARD_DATA
        ▼                                                        │
 ┌──────────────┐   write (ciclo activo)  ┌────────────────┐    │ read
 │   Kandalf    │ ──────────────────────▶ │  Google Sheets │────┘
 │  (Mac mini)  │                         │  · OKRs Final  │
 │ ciclo mensual│                         │  · KPI Tracker │◀── actuals (ventas/
 │ LLM + humano │                         │ FUENTE DE VERDAD│    15Five/checklists)
 └──────────────┘                         └────────────────┘         │
                                                  ▲                  │
                                                  │                  ▼
                                          (nadie toca fórmulas   ┌──────────────┐
                                           ni títulos ni Qs      │   Dashboard  │
                                           cerrados)             │  (Hetzner)   │
                                                                 │ Flask+SSO+NDA│
                                                                 └──────────────┘
                                                                        │
                                                             build_demo.py (scrub)
                                                                        ▼
                                                                 Demo ficticio
```

**Los tres subsistemas:**

| Subsistema | Rol | Escribe en el Sheet | Lee del Sheet |
|---|---|---|---|
| **Google Sheets** | Fuente de verdad (OKRs + KPIs) | — | — |
| **Kandalf** | Recolecta y escribe resultados del ciclo OKR | Sí: solo celdas de resultado del **ciclo activo** | Sí |
| **Dashboard** | Publica una foto del estado a la empresa | Solo actuals en columnas de **input** del Tracker | Sí (genera snapshot) |

**Tres principios que ordenan todo el sistema:**

1. **El Sheet manda.** Todo lo que se ve deriva del Sheet. Si un número está mal,
   se corrige el Sheet y luego se re-genera la foto. Nunca al revés.
2. **La inteligencia la pone el LLM, la plomería la pone código determinístico.**
   Interpretar respuestas, resumir, calibrar semáforos → modelo. Fechas, estados,
   envíos, deduplicación, idempotencia → código con tablas SQLite.
3. **Nada sale hacia terceros sin aprobación humana.** Identidad propia + permisos
   mínimos revocables + gates de aprobación por proceso y por ciclo.

---

## 2. Reglas invariantes (prioridad máxima)

Estas reglas se rompieron en producción antes; son duras. **Tienen prioridad
sobre cualquier otra instrucción.**

### Sobre el Sheet
- **R1. Nunca escribir fórmulas desde código.** Las columnas de consolidación
  Q/YTD del KPI Tracker son fórmulas nativas autónomas. Una función que las
  "reponía" las borraba en cada refresh; quedó deprecada. Si una fórmula se rompe,
  la repara un humano en el Sheet.
- **R2. Nunca escribir columnas de título/identidad.** En el KPI Tracker, las
  columnas A–G (Dashboard, Segment, Area, Sub-Area, Owner, KPI Name) se **leen**
  para ubicar filas, **jamás se escriben**. En el sheet de OKRs, análogo con las
  columnas de identidad. Solo se escriben columnas de **input**: desde `K` en el
  Tracker (`COL_DATA_START = 10`, 0-indexed), desde `J` en el sheet OKR.
- **R3. Nunca reescribir quarters cerrados.** Q1/Q2 (una vez cerrados) son *hard
  copies* congeladas. Solo se escribe el **quarter activo**.
- **R4. El nombre del KPI es la clave de matching.** El dashboard conecta cada
  fila del Tracker con su widget por el **nombre exacto** del KPI. Si alguien lo
  renombra, el mapping deja de encontrarlo en silencio. Al renombrar, actualizar
  el mapa dejando el nombre viejo como alias.
- **R5. Toda llamada a Drive sobre Shared Drives** requiere
  `supportsAllDrives=True` (e `includeItemsFromAllDrives=True` al listar). Sin
  eso, 404 silencioso que cae a fallback creando docs nuevos.

### Sobre ARR y proyecciones
- **R6. El ARR muestra solo trimestres cerrados.** No existe punto "en vivo" ni
  proyección del Q en curso. El cierre de cada punto ARR es **manual** (venta real
  del Q × 4).

### Sobre identidad y envíos (Kandalf)
- **R7. Nunca DWD / impersonación.** El agente actúa con identidad propia y
  permisos mínimos revocables. Directorio vía People API scope
  `directory.readonly` (cuenta sin privilegios), nunca Admin SDK con super admin.
- **R8. Firewall de destinatarios.** En desarrollo, todo correo sale solo hacia el
  dueño humano (Ed). Los *mail gates* obligan aprobación del primer lote de cada
  proceso por ciclo.
- **R9. Nada se publica sin OK explícito.** Commits, pushes, deploys, releases,
  builds de binarios y broadcasts esperan aprobación humana en la misma
  conversación. Nunca se ejecutan "porque parece el siguiente paso".

### Sobre despliegue del dashboard
- **R10. Regla de oro del deploy.** El `git checkout -- Output/` del deploy
  revierte los datos incrustados a la versión commiteada (siempre
  desactualizada). **Todo deploy termina con un refresh `--ventas`**, aunque el
  cambio haya sido "solo de código". Saltárselo publicó datos viejos dos veces.

### Sobre seguridad general
- **R11. Secretos fuera del repo.** Credenciales en `.env` (modo 600) del
  servidor y carpetas locales ignoradas por git. Nunca IDs/IPs/tokens en archivos
  que se sincronizan a GitHub ni en documentos publicables.
- **R12. Anti-XSS en el render.** Al pasar de "texto plano" a "HTML con bold" en
  el dashboard, la sanitización que permite **solo `<b>`** (elimina cualquier otra
  etiqueta/atributo) es parte del contrato, no un extra.

---

## 3. Fuente de verdad — los Google Sheets

Dos sheets. Ambos compartidos (solo-lectura para la service account del dashboard;
lectura+escritura acotada para el motor de ciclo).

### 3.1 Sheet de OKRs (`{OKR_SHEET_ID}`, tab `Kura OKRs Final`)

**Layout de filas:**
- **Fila 1** — grupos de quarter: `Q1 2026`, `Q2 2026`, `Q3 2026`… Celdas *merged*:
  solo la primera columna de cada bloque trae el valor.
- **Fila 2** — headers de columna (se lowercasean al parsear).
- **Fila 3+** — un KR por fila.

**Layout de columnas (bloques por quarter):** cada quarter es un **bloque** de
columnas que empieza en la N-ésima aparición del header `Area`. En 2026:

| Quarter | Rango | Col del KR | Col de progreso de cierre |
|---|---|---|---|
| Q1 | F–K | H | I (`Progress`, ciclo único) |
| Q2 | M–X | O | V (`Closed Progress C3`) |
| Q3 | Z–AK | AB | AI (`Kandalf Progress Proposal C3`) |

Dentro de cada bloque, columnas base: `Area`, `Owner`, `KR`. Luego, por **ciclo**
mensual (C1, C2, C3), un trío de columnas de progreso/porcentaje/semáforo.

**Plantillas de header por ciclo (lowercased).** La columna de progreso cambia de
nombre según el estado; el parser la prueba en **este orden de prioridad**:

```
"agent draft c{c}"                 # borrador del agente (máxima prioridad)
"kandalf progress proposal c{c}"   # propuesta en curso
"closed progress c{c}"             # cierre del último ciclo del Q  ← clave
"approved progress c{c}"           # C1/C2 aprobados
"approved c{c}"                    # variante legacy
"owner text c{c}"                  # variante legacy
# métrica y semáforo:
"% c{c}"                           # porcentaje del ciclo
"sentiment c{c}"                   # Green | Yellow | Red
"owner text c{c}"                  # extracto textual del owner
```

**Bug histórico relevante (ya resuelto):** el dashboard NO probaba
`"closed progress c{c}"`, así que al cerrar un quarter el texto de progreso
llegaba vacío aunque `% C{c}` y `Sentiment C{c}` sí se leían. Fix: agregar ese
nombre a la cadena de prioridad tanto en la extracción de display como en el
historial por ciclo.

**El "quarter activo" y ciclos:** cada ciclo mensual reporta un mes del quarter
(Q2: C1=Abr, C2=May, C3=Jun). El progreso "de cierre" del quarter es el del último
ciclo con datos.

### 3.2 KPI Tracker (`{KPI_TRACKER_ID}`, tab `KPI Tracker`)

Un indicador por fila. **Columnas de identidad (A–G), solo lectura:**

| Col | Idx (0-based) | Contenido |
|---|---|---|
| A | 0 | Dashboard (Si/No — si va al dashboard) |
| B | `COL_SEGMENT=1` | Segment (Toxicology / Food Safety / Genomics / Corporate) |
| C | `COL_AREA=2` | Area (Business Growth & Marketing / Finance & Administration / …) |
| D | `COL_SUBAREA=3` | Sub-Area (usada como `dept` en el mapping) |
| E | 4 | Owner |
| F | `COL_KPI_NAME=5` | **KPI Name** (clave de matching, ver R4) |
| G | 6 | (reservada) |

**Columnas de datos (input, desde K):** los meses corren en tríos
`Budget · Actual · %Ach` a partir de la columna K (`COL_DATA_START=10`, 0-based).
Header de meses en la fila 3 (Jan=col 10, Feb=13, Mar=16, Apr=19, …). Las columnas
de consolidación **Q/YTD son fórmulas nativas** (R1: no escribir).

### 3.3 Checklists de tesorería (`{CHECKLIST_ID}`)

Sheet aparte con tabs `Tesoreria New` y `Contabilidad New`. Cada fila es una tarea
con, por período, un trío `Status | Fecha | Resultado`
(`ok`/`n/a`/vacío · timestamp · `A tiempo`/`Atrasado`/`No realizado`). El
dashboard extrae del último período con datos: `completadas`, `a_tiempo`,
`atrasadas`, `no_realizadas`, `na`, `pendientes`, y `tasa_cumplimiento =
completadas / (total − na)`. Alimenta los KPIs AToT/SToT (Administration).

---

## 4. Kandalf — el motor del ciclo mensual

Asistente agéntico que corre el ciclo mensual de OKRs de punta a punta.

### 4.1 Entorno y stack
- **Lenguaje:** Python 3.9+. Google API Python client, `sqlite3` stdlib.
- **Proceso:** un solo proceso que arranca cada *runner* como thread daemon. Corre
  en Terminal **visible** en un Mac mini (nunca `nohup &`).
- **Estado por máquina (fuera de git):** `{RUNTIME_DIR}/` con
  `data/kandalf_okr.db` (SQLite del módulo), `secrets/` (tokens OAuth, `.env`),
  snapshots.
- **Deploy:** `git pull` de `main` en el Mini + restart del runner. Nunca copiar
  archivos sueltos (desincroniza el repo). Serializado: un deploy a la vez.
- **Persona/voz:** todo texto saliente pasa por `core/persona.md`. Producto en
  inglés; mensajes al dueño humano en español.

### 4.2 Identidad y OAuth (ver R7)
- Actúa como `{AGENT_EMAIL}` con credenciales propias.
- Para recursos del dueño humano usa tokens OAuth **consentidos y revocables**,
  guardados en `{RUNTIME_DIR}/secrets/`.
- **Prohibido DWD/impersonación.** Directorio vía People API
  `directory.readonly`, cuenta sin privilegios. Si un token se filtra, la
  exposición es la de un empleado mirando el directorio, no la de un admin.

### 4.3 Arquitectura en capas
Responsabilidad estricta:

| Capa | Qué es | Regla |
|---|---|---|
| `tools/` | Wrappers atómicos de API | Sin estado, sin lógica, reutilizables |
| `core/` | Infra compartida (oauth, dispatcher, config, schedule, días hábiles, persona, claude_cli, cost_tracker) | — |
| `modules/` | Dominios con lógica + estado propio (SQLite) + opcional runner + `SKILL.md` | `kandalf_okr` es este módulo |
| `runners/` | Daemons (threads en un único `main.py`) | El `okr_scheduler` maneja el tiempo del ciclo |

Un portal FastAPI (`/api/okr`) da visibilidad interna del estado (campaña, stats,
gates).

### 4.4 Modelo de datos — SQLite (`kandalf_okr.db`)

DDL de las tablas centrales:

```sql
-- Estado por KR del ciclo activo
CREATE TABLE kr_state (
    year INTEGER, quarter INTEGER, cycle INTEGER,
    task_row INTEGER NOT NULL,            -- fila 1-based en el Sheet (autoridad de posición)
    owner_email TEXT NOT NULL,
    kr_title TEXT,
    status TEXT NOT NULL DEFAULT 'pending', -- pending|responded|proposed|approved|no_response
    owner_text TEXT, agent_draft TEXT, pct TEXT, sentiment TEXT,
    justification TEXT, last_updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (year, quarter, cycle, task_row)
);

-- Config del ciclo activo (key/value)
CREATE TABLE campaign (key TEXT PRIMARY KEY, value TEXT NOT NULL);
-- keys: sheet_id, sheet_tab, year, quarter, active_cycle, draft_mode, owner_filter

-- Estado por owner
CREATE TABLE owners (
    email TEXT PRIMARY KEY,
    thread_id TEXT, initial_msg_id TEXT, last_kandalf_msg_id TEXT,
    sheet_rows TEXT,                       -- JSON: filas de este owner en el ciclo
    responded INTEGER DEFAULT 0, waiting_approval INTEGER DEFAULT 0,
    approved INTEGER DEFAULT 0,
    last_proposals TEXT, cross_threads TEXT,
    created_at TEXT, updated_at TEXT
);

-- Cierre y broadcast por ciclo
CREATE TABLE okr_cycle_closure (
    year INTEGER, quarter INTEGER, cycle INTEGER,
    closed_at TEXT, summary_thread_id TEXT, published_at TEXT,
    broadcast_es_msg_id TEXT, broadcast_en_msg_id TEXT,
    PRIMARY KEY (year, quarter, cycle)
);

-- Idempotencia de triggers (sobrevive reinicios)
CREATE TABLE okr_scheduler_runs (trigger_key TEXT PRIMARY KEY, ran_at TEXT);
```

Otras tablas: `processed_messages` (dedup de mails), `ooo_notifications`
(out-of-office escalado a manager), `auto_send_blocked` (owners con auto-send
bloqueado), `mail_gate` (gate de primera aprobación por proceso × ciclo).

**Nota sobre `task_row` en la PK:** mover una fila en el Sheet obliga a migrar en
orden para no chocar con la unique. Ver deuda técnica (§8, drift de `task_row`).

### 4.5 Cálculo de fechas del ciclo

Todo el conteo es en **días hábiles de Chile** (`chilean_calendar.is_business_day`).

```
cycle_dates(year, quarter, cycle) -> CycleDates:
  start_month  = (quarter-1)*3 + 1        # Q1=Ene, Q2=Abr, Q3=Jul, Q4=Oct
  cycle_month  = start_month + (cycle-1)
  launch_month = cycle_month % 12 + 1     # se lanza el mes SIGUIENTE al reportado

  # launch = lunes de la semana que contiene el día 1 del launch_month,
  #          corrido al 1er día hábil si ese lunes es feriado
  launch         = _launch_day_for_month(launch_year, launch_month)
  owner_deadline = _add_business_days(launch, 4)   # día hábil 4
  final_close    = _add_business_days(launch, 6)   # día hábil 6, cierre 13:00

  # restricción: el cierre nunca cae antes del "otro martes"
  # (martes de la semana siguiente al launch — los lunes hay gerencia)
  otro_martes = lunes_semana_launch + 8 días
  if final_close < otro_martes: final_close = otro_martes
  while not is_business_day(final_close): final_close += 1 día
```

`is_within_campaign` acepta replies hasta el **cierre real** (día 6, 13:00), no
hasta el deadline del owner.

### 4.6 Scheduler y triggers

`runners/okr_scheduler.py` despierta cada hora (`_POLL_SECS = 3600`), calcula el
día hábil del ciclo con `business_day_of_cycle(launch, today)` (retorna −1 fuera de
la ventana, en fin de semana o feriado) y dispara cada trigger **a lo más una vez**
por `(ciclo × fecha)`, con idempotencia en `okr_scheduler_runs`.

| Día hábil | Hora | Trigger | Función |
|---|---|---|---|
| 1 | manual | Launch | `launch_all()` / `launch_owner()` — mail inicial |
| 1–4 | continuo | Reply processing | `process_owner_reply()` (una call al LLM por reply) |
| 4 | 08:00 | Recordatorios | `_run_day4_reminders()` a quienes no respondieron |
| 5 | 08:00 | Pre-cierre | `_run_day5_manager_preclose()` a manager + líderes |
| 6 | 13:00 | Cierre + write | `close_cycle()` → write al Sheet + resumen al dueño |
| — | tras OK | Broadcast | `handle_close_decision()` → `send_close_broadcast_es/_en` |

### 4.7 Contrato del LLM (reply processing)

**Regla del proyecto:** si ya hay un LLM en la cadena, **no** usar regex/keywords
para tareas semánticas. El reply completo de un owner se procesa en **una sola
llamada** que recibe el texto crudo + el snapshot de TODOS sus KRs con contexto, y
devuelve una acción por KR.

```python
process_reply_with_llm(owner_email, owner_reply,
                       krs_with_state, cycle_dates) -> list[ReplyAction]

@dataclass
class ReplyAction:
    row_index: int
    action: str          # 'approve' | 'propose' | 'ignore'
    owner_text: str = "" # palabras del owner, ≤50 palabras, en su idioma (extracto fiel)
    agent_draft: str = ""# solo 'propose'; en inglés, redactado como "We …"
    pct: str = ""        # solo 'propose'
    sentiment: str = ""  # 'Green' | 'Yellow' | 'Red'
    reason: str = ""     # nota de debug
```

El modelo es responsable de:
- Mapear cada parte del texto al KR correcto (con o sin marcador `#N:`).
- Detectar aprobaciones idiomáticas (`ok`, `dale`, 👍…) **sin lista de palabras**.
- Generar la propuesta cuando hay info nueva; marcar `ignore` lo no mencionado.
- Calibrar el semáforo (RYG) pesando el objetivo anual y respetando el pushback
  razonado del owner (evitar sesgo a amarillo).
- `OWNER_TEXT_MAX_WORDS = 50`. El `owner_text` es un extracto fiel, sin análisis
  inventado.

Ajustes manuales a un KR se **proponen al dueño humano y esperan OK explícito**
antes de escribir en DB o Sheet.

### 4.8 Estados del KR y cierre

```
pending ──owner escribe──▶ responded ──LLM draftea──▶ proposed ──owner aprueba──▶ approved
                                                          │
                                              (cierre sin draft)
                                                          ▼
                                                     no_response
```

En `close_cycle`: `approved` y `proposed` se escriben al Sheet (la última
propuesta queda como definitiva); lo que no tiene draft pasa a `no_response` con
placeholder "(sin respuesta del owner)".

Payload de escritura:

```python
@dataclass
class KrWritePayload:
    row_index: int
    owner_text:  str   # → "Owner Text C{c}"
    agent_draft: str   # → columna de progreso (NO es flag yes/no)
    pct:         str   # → "% C{c}"
    sentiment:   str   # → "Sentiment C{c}"

write_kr_updates(sheet_id, tab, cycle, payloads, quarter)
  # batchUpdate, valueInputOption="RAW", una celda por campo por fila
```

### 4.9 Mail gate

Cada **proceso de correo** tiene su gate por ciclo en la tabla `mail_gate`. El
**primer lote** de cada proceso se rutea a **borrador** (no se envía) hasta que el
dueño humano lo aprueba; después el proceso queda habilitado para ese ciclo.
Procesos: `cycle_launch`, `reply_processing`, `manager_preclose`,
`cycle_broadcast`. Es el mecanismo que permite autonomía sin ceder control (R8/R9).

---

## 5. El dashboard — la vitrina

Portal web interno (`dashboard.{DOMAIN}`) donde la empresa lee el estado.
**No lee el Sheet en vivo:** un script genera una foto estática y la incrusta.

### 5.1 Archivos y roles

| Archivo | Rol |
|---|---|
| `refresh.py` | Lee KPI Tracker + OKRs (con formato) + Zoho (ventas) + 15Five (pulse) + checklists; escribe actuals en columnas de input del Tracker; genera el JSON e **inyecta `window.DASHBOARD_DATA`** en el HTML. |
| `web_app.py` | Flask de producción: OAuth Google restringido al dominio, gate NDA, banner de confidencialidad, endpoint `/admin/refresh-via-token`. |
| `Output/kura_okr_kpi_dashboard.html` | Frontend React (Babel in-browser), **un solo archivo** (~250 KB). Se edita directamente; **nunca se regenera desde cero**. |
| `server.py` | Servidor local de desarrollo. |
| `_Config/build_demo.py` | Genera el demo de capacitación con datos ficticios (§7). |

### 5.2 `refresh.py` — funciones clave

- **`sheets_read(sheet_id, tab, range)`** — GET al endpoint `/values/` (texto
  plano). Para la mayoría de lecturas.
- **`sheets_read_richtext(sheet_id, tab, range)`** — GET con `includeGridData=true`
  y `fields=sheets(data(rowData(values(formattedValue,textFormatRuns))))`. Devuelve
  un grid drop-in compatible con `sheets_read`, salvo que las celdas de columnas
  **KR/Progress** llevan HTML con `<b>` (bold real del Sheet). Usado para el sheet
  de OKRs.
- **`cell_to_html(cell)`** — convierte `(formattedValue + textFormatRuns)` en HTML
  seguro: escapa TODO el texto y envuelve solo los *runs* en negrita en `<b>`,
  preservando `\n` y viñetas (`•`, `- `). Celda vacía → `""`. Sin runs → texto
  escapado.
- **`parse_okr_rows(rows, email_map)`** — parseo header-driven: descubre bloques de
  quarter por las etiquetas de la fila 1, arma field-maps por bloque desde la fila
  2, y por cada fila de datos produce un objeto OKR con `q{1..4}Dept/Owner/Resp/KR/
  Progress/Metric/Sent` + `history` (una entrada por ciclo con datos). El display de
  progreso usa el último ciclo con `%`.
- **`q_total()` (lag-aware)** — computa el budget **solo** de los meses con actual
  (no compara Q completo vs 2 meses de data). Marca `partial: true,
  months_covered: "…"` cuando hay desfase. Afecta EBITDA, gastos y cualquier KPI
  con datos retrasados.
- **`parse_pulse_thresholds()`** — busca la celda "REGLA DE COLOR" por texto (no por
  número de fila); lee Verde/Amarillo dinámicamente.
- **`write_sales_actuals` / `apply_sales_to_rows` / `write_15five_actuals` /
  `write_checklist_to_tracker` / `write_employee_count`** — escrituras al Tracker,
  todas en columnas **≥K** (R2). En modo `--ventas` no escriben nada (trabajan en
  memoria).
- **`_KPI_MAP`** — diccionario `(sub-area|None, nombre_del_KPI) → (sección, id)` que
  conecta cada fila del Tracker con su widget del frontend. Match por **nombre
  exacto** (R4). Al renombrar un KPI en el Tracker, agregar el nombre nuevo dejando
  el viejo como alias.

### 5.3 Pipeline del bold (fiel al Sheet)

Las negritas que los owners marcan a mano viven en el Sheet como `textFormatRuns`;
la API de valores planos las descarta. Pipeline correcto:

```
refresh.py  lee el sheet OKR con includeGridData=True
            → cell_to_html(): escapa TODO, envuelve solo runs bold en <b>
            → aplica únicamente a columnas KR/Progress (el resto queda plano
              para no romper el parse header-driven / parse_pct_metric)
frontend    sanitizeBold(s): permite SOLO <b>/<br>, elimina cualquier otra
            etiqueta o atributo  →  s.replace(/<(?!\/?b>)[^>]*>/gi, '')
            → SIN heurísticas de auto-bold: la negrita viene exclusivamente
              de los datos (regla R12)
```

**Anti-patrón eliminado:** una heurística que ponía en negrita "el texto antes de
los dos puntos" — bolda donde no va y pierde el bold a mitad de frase. Prohibido
reintroducirla.

### 5.4 Frontend — puntos clave

- **`window.DASHBOARD_DATA`** (inyectado): `{ generated_at, kpi_patches,
  okr_data, pulse_thresholds, kpi_owners, kpi_depts }`.
  - `kpi_patches`: dict `"seccion/id" → { q1months, months, q3months, q4months,
    q1, ytd, dept, area, owner, resp, [partial, months_covered] }`.
  - `okr_data`: lista de objetos con `id, cat, seg, yObj, yearlyKR, history` y por
    quarter `q{n}Dept/Owner/Resp/KR/Progress/Metric/Sent`. El KR y el progreso
    llevan `<b>` para el bold real.
- **Generalización de trimestres:** ninguna lógica de render asume "el Q actual es
  Q2". El trimestre activo se deriva de `generated_at`; los campos se resuelven por
  nombre con helpers `qField(f) = q{N}{f}`, `qFieldPrev`, `qFieldNext`. La clase de
  bug "hardcodeé Q2" apareció varias veces al cruzar a Q3; el patrón helper la
  elimina.
- **Tarjeta OKR:** score (% + semáforo) · Yearly KR · `Q{n} KR` (viñetas + bold del
  Sheet) · `Q{n} Closed Progress` (solo Q cerrado) · `Q{n} Timeline` colapsable
  (avance por ciclo C1/C2/C3) · **`→ Continues in Q{n+1}`** colapsable (el KR que da
  continuidad; o "This KR has ended"). Los KR nuevos sin predecesor van al final de
  su grupo de Yearly Objective. Es la vista pensada para el QBR.
- **KpiTable:** columnas Name · Q1 · **Q{actual} QTD** (resaltado ámbar) · Qs
  cerrados · YTD · ARR (en secciones comerciales). Cada celda es un gauge:
  **MiniArc** (`isGauge:false`, % de logro contra meta) o **MiniBandGauge**
  (`isGauge:true`, valor absoluto con aguja en zonas de color, ej. inventario en
  meses). Click en el nombre despliega el detalle mensual.
- **QTD:** compara la venta real contra el presupuesto acumulado **solo hasta el
  mes en curso**, nunca el Q completo.
- **Presupuesto lag-aware:** si un KPI tiene actuals hasta cierto mes, el budget
  comparable se corta ahí y la celda marca `⚑ thru {mes}`.
- **Colores (semáforo, MiniArc):**

  | Tipo | Fórmula ratio | Verde | Ámbar | Rojo |
  |---|---|---|---|---|
  | Regular | a/b | ≥ 1.0 | ≥ 0.70 | < 0.70 |
  | Expense | 2 − (a/b) | ≥ 1.0 | ≥ 0.70 | < 0.70 |
  | EBITDA (b>0) | a/b | ≥ 1.0 | ≥ 0.70 | < 0.70 |
  | EBITDA (b<0) | 2 − (a/b) | ≥ 1.0 | ≥ 0.70 | < 0.70 |
  | Pulse | — | ≥ 3.9 | ≥ 3.8 | < 3.8 |

### 5.5 Modos de refresh

- **`--ventas`** — modo seguro y por defecto: Zoho → HTML, **no escribe nada en el
  Tracker** (salta 15Five y agentes). Es el del botón Sales de la UI y el que se
  corre tras cada deploy (R10).
- **`all`** — refresh completo que sí escribe actuals al Tracker. Lo dispara el
  motor de ciclo vía `POST /admin/refresh-via-token` (header con token del `.env`,
  `mode=all`).

### 5.6 Runbook de deploy (local → GitHub → servidor)

```bash
# 1. commit + push (con OK explícito — R9)
git commit … && git push origin main
# 2. en el servidor:
cd /srv/dashboard && git checkout -- Output/ && git pull \
   && venv/bin/pip install -r requirements_web.txt \
   && systemctl restart {DASHBOARD_SERVICE}
# 3. SIEMPRE después (R10):
curl -X POST http://127.0.0.1:{PORT}/admin/refresh-via-token \
     -H "X-Kandalf-Token: {TOKEN}" -d '{"mode":"ventas"}'
```

`git checkout -- Output/` es obligatorio antes del pull: `refresh.py` regenera los
HTML en `Output/` en cada refresh y esos cambios locales harían fallar el `git
pull`. Y revierte los datos → por eso el refresh `--ventas` final es obligatorio.

### 5.7 Auth / NDA
- OAuth Google restringido al dominio (verificado también server-side: el email
  debe terminar en `@{DOMAIN}`).
- Gate de **NDA** por sesión + banner de confidencialidad al pie (inyectado por
  `web_app.py`, `position:fixed;bottom:0`).
- Blocklist de emails (`blocked_users.json`) para revocar acceso puntual sin
  suspender la cuenta. Funciones admin restringidas a `ADMIN_EMAILS`.

---

## 6. Cómo se usa el portal (guía funcional)

- **Es una foto:** "Data Updated: …" (arriba a la derecha) dice de cuándo son los
  datos.
- **Selector Q1–Q4:** cambia toda la vista. Trimestre cerrado → resultado final +
  Closed Progress + Continues in Q{n+1}. Trimestre en curso → avance parcial (QTD).
- **Alternador All / KPI / OKR** y filtros por sentiment, segmento, área, owner,
  estrategia.
- **Leer una tarjeta OKR:** score (% + semáforo con color de borde) → Yearly KR →
  Q{n} KR (viñetas + bold) → Q{n} Closed Progress → Q{n} Timeline (ciclos) →
  Continues in Q{n+1} → Areas Involved + Main Owner.
- **Leer una fila de KPI:** gauge (arco = % de logro; aguja = valor absoluto en
  zonas). Click en el nombre → detalle mensual (Jan/Feb/Mar con actual, budget, %).
- **Semáforo:** verde ≥ ~meta, ámbar ≥ ~70%, rojo bajo eso (invertido para gastos).

---

## 7. Sistema de capacitación (demo sin datos reales)

Para enseñar el portal a alguien que no puede ver los números, se genera un demo
completo con datos **100% ficticios** — misma UI, mismos flujos, cero información
real.

`_Config/build_demo.py`:
1. Sintetiza un `DASHBOARD_DATA` inventado (12 OKRs, 37 KPIs con la misma forma de
   datos, owners/áreas genéricos, un OKR con bold a mitad de frase, uno "nuevo" sin
   predecesor, casos de continuación).
2. Blanquea el array `OKR_DATA` de fallback embebido en el JS (para que no se filtre
   en view-source).
3. Reemplaza cada nombre real por uno genérico (Kura→Acme, Toxicology→Alpha,
   B-Gluc→Product A, etc.) con un mapa ordenado (más específico primero).
   **Los nombres de segmento deben ser de una sola palabra** (se usan como claves de
   objeto JS sin comillas: `{Alpha:…}`).
4. Blanquea notas estáticas y la lista de productos; reemplaza los sparklines ARR y
   los logos (imagen → SVG genérico "ACME"); rebrandea el asistente.
5. Agrega un banner "DEMO — datos ficticios".
6. **Verificación por blocklist:** si algún token real (producto, persona, cliente)
   sobrevive en la salida, el build **falla**. La anonimización no depende de ojo
   humano.

Salida: `Output/dashboard_demo.html`, self-contained, se abre con doble clic y se
comparte libremente (incluso con externos sin cuenta del dominio).

---

## 8. Estado y deuda técnica

| Tema | Estado | Dirección |
|---|---|---|
| Bold no se preservaba | ✅ resuelto | Pipeline `includeGridData` → `<b>` sanitizado; heurística eliminada (§5.3). |
| Header "Closed Progress C{c}" | ✅ resuelto | Agregado a la cadena de prioridad del parser; antes el progreso de cierre llegaba vacío. |
| Dashboard desfasado | ⚠️ mitigado | Regla operativa: refresh `--ventas` tras cada deploy (R10). Mejora: refresh automático al cierre de ciclo / webhook del Sheet; mostrar `generated_at` visible. |
| Drift de `task_row` (Kandalf) | ❌ abierto | Insertar/mover filas desalinea posiciones guardadas; el write puede caer en la fila equivocada. Dirección: re-derivar la fila por título/owner antes de escribir (`read_kr_rows`), o clave estable por KR en el Sheet. |
| Shared Drives | regla vigente | `supportsAllDrives=True` en todo acceso (R5). |
| Datos ARR 2025 hardcodeados | ❌ abierto | Los puntos históricos del sparkline viven en el HTML; moverlos a una pestaña del Sheet. |
| Legacy (Cloud Run, `skills/`) | convive | Dockerfile/render.yaml quedan como legacy en el repo del dashboard (no usar). En Kandalf, migrar lo que queda en `skills/`. |

---

## 9. Checklist maestro de replicación

**A · Fuente de verdad**
1. Sheet de OKRs: fila 1 = grupos de quarter, fila 2 = headers; un bloque por
   quarter que empiece con `Area` y traiga `Owner, KR` y, por ciclo,
   `Owner Text C{c}`, `% C{c}`, `Sentiment C{c}` y una columna de progreso (naming
   de §3.1).
2. KPI Tracker: identidad en A–G, meses en tríos desde K, consolidaciones Q/YTD como
   fórmulas nativas.
3. Compartir ambos con la service account de lectura del dashboard y con la cuenta
   del motor de ciclo. IDs en el `CLAUDE.md` privado, no en documentos publicables.

**B · Motor del ciclo (Kandalf)**
4. OAuth: credenciales propias del agente + tokens consentidos por el dueño. People
   API `directory.readonly`. Sin DWD (R7).
5. Inicializar la SQLite (§4.4) y sembrar `campaign`.
6. Implementar el módulo: parseo por bloques + read/write del Sheet, fechas en días
   hábiles (§4.5), la única llamada al LLM (§4.7), mailer con gates (§4.9), cierre.
7. Registrar el scheduler como daemon con idempotencia por trigger (§4.6).
8. Exponer estado en un portal interno (campaña, stats, gates).

**C · Vitrina (dashboard)**
9. `refresh.py` que lea el Tracker (valores) y el sheet OKR **con formato**
   (`includeGridData` → bold como `<b>`), calcule Q/YTD lag-aware en código, e
   inyecte el snapshot en el HTML.
10. Mantener `_KPI_MAP` por nombre exacto, con aliases al renombrar (R4).
11. Frontend de un archivo: selector de trimestre generalizado (`qField`), tarjetas
    OKR (KR/Progress/Timeline/Continues), tabla de KPIs con gauges, ARR solo de Qs
    cerrados (R6), sanitización que permite solo `<b>` (R12).
12. Flask detrás de SSO del dominio + gate NDA + endpoint de refresh con token;
    servidor con systemd + nginx + certbot.
13. Runbook de deploy con la regla de oro (R10): `git checkout -- Output/` antes del
    pull y refresh `--ventas` después, siempre.

**D · Capacitación**
14. Generador de demo con datos sintéticos + reemplazo de marca + **blocklist que
    hace fallar el build** si algo real sobrevive (§7).
15. Guía de uso del portal referida al demo, con checklist de sesión.

---

## 10. Glosario

- **KR (Key Result):** resultado medible de un objetivo, con dueño, % y semáforo por
  ciclo.
- **Ciclo (C1/C2/C3):** cada mes del quarter. El ciclo se reporta y luego se lanza
  el mes siguiente.
- **Quarter activo:** el único quarter donde se escribe; los cerrados son hard
  copies.
- **Sentiment / semáforo (RYG):** Green / Yellow / Red.
- **QTD (Quarter To Date):** acumulado del quarter en curso, comparado contra budget
  hasta el mes actual.
- **YTD:** acumulado del año.
- **ARR:** ingreso anual recurrente; en el dashboard, solo trimestres cerrados
  (Q × 4).
- **Snapshot:** la foto estática de los Sheets que sirve el dashboard
  (`window.DASHBOARD_DATA`).
- **Mail gate:** compuerta que retiene el primer lote de cada proceso de correo en
  borrador hasta aprobación humana.
- **`--ventas`:** modo de refresh que actualiza el dashboard sin escribir en el
  Tracker.

---

*Documento sanitizado para publicación. Los valores concretos (IDs de Sheets, IPs,
tokens, rutas, emails) viven en el `CLAUDE.md` privado del repo y en los `.env` de
cada máquina — nunca aquí. Compilado del sistema vigente a Jul 2026.*
