Motor de recomendación Qwen — arquitectura validada en vivo (rama engine)

Fecha: 2026-09-16. Proyecto: SPAB / estado_turismo, rama engine

Contexto: el usuario pidió el motor de recomendación turística con Qwen sobre Cloudflare Workers AI, partiendo de cero (“esta rama es solo el llm que hará esto”), con mock de establecimientos (faker) y perfiles de usuario. Debajo va el contenido reusable.

ARQUITECTURA DEL MOTOR (engine/)
 
worker: engine/  wrangler.jsonc (binding "AI", name "engine")
  POST /  body: {"usuario": "sarah"|{...} | "mariana"|"brad"|"sophia"|"yuki",
                 "destino": "Tulum"|"Bacalar"|"Cozumel"}  -> ResultadoRanking
  GET /   -> resumen + perfiles disponibles
 
ESTRUCTURA:
  src/schema.ts     tipos (DestinoContexto, Establecimiento, Usuario, ResultadoRanking)
  src/personas.ts   perfiles derivados de buyer-personas-quintana-roo.md
  src/prompt.ts     SISTEMA + CONTEXTO semanal + USUARIO + TAREA + OUTPUT JSON
  src/filtros.ts    reglas duras + eficiencia + selector destino + ranking determinista
  src/ranking.ts    pipeline: filtros -> prompt -> Qwen(Workers AI) -> parse/validación -> fallback
  src/index.ts      worker (GET/POST)
  scripts/seed.mjs  npm run seed  -> faker (seed 20260916) -> data/seed.json (120 negocios, 3 destinos)
  scripts/smoke.ts  npx tsx scripts/smoke.ts (stub, sin coste) -> valida parse + fallback
  data/seed.json    versionado (el worker NO importa faker en runtime, pesa)
 
REGLAS DEL RANKING:
  Filtros duros (SIEMPRE en el edge, nunca del LLM):
    empleados < 5     (captura local)
    distancia < 800m  (del hospedaje del usuario)
    precio <= $20     (por transacción)
  eficiencia_score = precio_usd / (distancia_m / 80 m/min)  -> USD por minuto de viaje a pie.
  Menor eficiencia = mejor. El LLM modula con el perfil solo como desempate.
 
MODELO: @cf/qwen/qwen3-30b-a3b-fp8
  Params validados en vivo (2026-09-15):
    messages[{role,content}], response_format:{type:"json_object"},
    temperature 0.2, seed 42, max_tokens 4000  <-- CRÍTICO: qwen3 razona primero;
    con max_tokens bajo queda finish_reason:"length" y content:null.
  Respuesta: choices[0].message.content (JSON), message.reasoning es el chain-of-thought.
 
JSON OUTPUT contratado:
  {"ranking":[{"nombre":string,"distancia_m":number,"precio_usd":number,
               "eficiencia_score":number}]}  max 8 entradas, orden ascendente.
 
VALIDACIÓN / FALLBACK:
  Si content no parsea o no valida -> rankingDeterminista() (misma fórmula) y fallback:true.
  El worker nunca devuelve basura; siempre 8 o menos filas válidas.
 
PERFILES (de buyer-personas-quintana-roo.md):
  sarah   -> eficiencia      (spec original: $50/noche, 2 días)
  mariana -> familia/precio  (nacional, Bacalar = "lo que buscabas cuando elegiste Tulum")
  brad    -> nómada          (huye de saturación: Tulum 82% disuade)
  sophia  -> trazabilidad    (qué negocio chico recibe el dinero)
  yuki    -> previsibilidad  (precio fijo, distancia corta, poca fricción)
 
COMANDOS:
  npm install && npm run seed
  npx tsc --noEmit            (typecheck)
  npx tsx scripts/smoke.ts    (pipeline con stub AI, sin coste)
  npm run dev                 (wrangler dev; requiere binding AI real en el account)
 
VALIDADO EN VIVO:
  qwen3-30b-a3b-fp8 (max_tokens 4000): finish_reason "stop", JSON con 8 items,
  orden EXACTO = determinista (0.591,1.36,1.801,2.058,2.399,3.449,4.015,4.436),
  sin inventar nombres. Latencia ~5.5s, usage 2246 tokens.
 
NEXT STEP (cuando haya cuenta/credenciales de Workers):
  wrangler deploy -> npm run dev con binding real -> probar con Sarah y Mariana.