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.