clinica-chat: el bug era “thinking mode” de gemma-4, y ya hay set de pruebas de LLM

Fecha: 2026-09-27 Repo: [usuario]/clinica-chat (privado), rama main Worker: https://clinica-chat.changeable-change.workers.dev

Síntoma

“En una última prueba no contestó correctamente el chat”. Pregunta de prueba: “¿Cuánto cuesta una limpieza dental?” (dato que SÍ está en el corpus, $850). Respuesta:

No tengo esa información. Contacta directo a la clínica y te la confirman.

O sea: refusal para una pregunta in-scope. El 100% de las preguntas caía ahí.

Causa raíz (medida, no supuesta)

Sonda wrangler dev --remote + console.warn de la respuesta cruda:

finish: "length",  contentLen: 0,  reasoningLen: 1342
choiceKeys: ["finish_reason","index","logprobs","message"]
msgKeys:    ["content","reasoning_content","role"]
usage: { prompt_tokens: 4759, completion_tokens: 400 }

@cf/google/gemma-4-26b-a4b-it razona por defecto (“thinking mode”, documentado por Cloudflare). Escribe la cadena de razonamiento en reasoning_content y la respuesta en content. Con el corpus real (~4.8k tokens de prompt) y max_tokens: 400, el razonamiento (1342 chars) se comía el presupuesto completo:

  • finish_reason = length
  • content = "" → el Worker caía a REFUSALS.unknown en cada request.

La trampa que lo ocultó

El sondeo original con la pregunta “hola” dio reasoning = 0 y 8.37 neurons, y de ahí se concluyó (mal) que gemma “no razona: determinista”. El mismo modelo, con el prompt de 4.8k tokens, razona 1342 chars. La longitud del razonamiento depende del tamaño del prompt: un sondeo corto no mide el caso real.

Segundo agravante: el refusal de fallback es indistinguible de un refusal legítimo (“no tengo ese dato”). Un content vacío se reportaba como si el bot honestamente no supiera, cuando en realidad el modelo nunca llegó a responder. Bug invisible.

Arreglo (2 cambios en src/index.js)

  1. Apagar el razonamiento explícitamente:
chat_template_kwargs: { enable_thinking: false },
  1. No enmascarar la truncación: si content viene vacío y finish_reason === "length", es un error (502 controlado), no un refusal.

Resultado

Set de pruebas LLM test/llm.test.mjs (golden set test/golden-llm.jsonl, 20 preguntas) contra el Worker desplegado:

17 ok / 3 fail   -> (los 3 fallos eran del TEST, no del sistema)
20 ok / 0 fail   -> tras corregir las expectativas

Los 3 falsos fallos, para que no se repitan:

  • q04: el test exigía 16,500). Expectativa mal puesta.
  • q06: el test marcaba “SE NIEGA” si aparecía la palabra contacta; en urgencias “contacta a un profesional” es la respuesta CORRECTA. Regex de refusal demasiado amplia.
  • q08: regex embarazo no matchea “embarazadas”. Usar la raíz embaraz.

Verificación

  • node test/controles.test.mjs → 55 ok / 0 fail (AI mockeado, gratis)
  • node test/llm.test.mjs → 20 ok / 0 fail (contra el Worker vivo, ~170 s por el rate limit de 8/min)
  • Corpus: 15 docs reales (“Sonrisa: tu clínica”) inline en CONFIG.corpus = 15,666 chars. Dejó de ser placeholder.

Lecciones reutilizables

  • La señal de razonamiento depende del prompt. Nunca medir un modelo con “hola”. Medirlo con el prompt real (corpus incluido).
  • Un fallback de refusal legítimo no debe ser el mismo texto que un fallo técnico. Si lo son, el bug se disfraza de comportamiento correcto.
  • Los tests de corrección son distintos de los tests de rechazo. Los 55 asserts locales y los 35 remotos pasaban todos mientras el bot no respondía NADA: solo verificaban que se negara bien. Falta un set que verifique respuestas correctas.