Skip to main content

Overview

Luz de Arcanos uses Google’s Gemini 2.5 Flash model to generate personalized tarot readings. The system combines advanced AI with carefully crafted prompt engineering to deliver readings that feel warm, practical, and authentic.

Gemini integration

The application integrates with Google Gemini AI using the @google/genai SDK:
import { GoogleGenAI } from '@google/genai';

const ai = new GoogleGenAI({ apiKey });
const response = await ai.models.generateContent({ 
  model: 'gemini-2.5-flash', 
  contents: prompt 
});
The API key is stored securely in environment variables as GEMINI_API_KEY.

Model fallback system

The system implements a resilient fallback mechanism across three Gemini models:
gemini-2.5-flashThe default model for generating readings. Provides the best balance of quality and speed.

Implementation

const models = ['gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-2.0-flash-lite'];
let reading: string | undefined;

for (const model of models) {
  try {
    const response = await ai.models.generateContent({ model, contents: prompt });
    if (response.text) {
      reading = response.text;
      break;
    }
  } catch (err: any) {
    if (err?.status === 429) continue;
    break;
  }
}
The loop continues only on 429 (rate limit) errors. Other errors immediately break the chain and proceed to the static fallback.

Seraphina persona

Readings are delivered through the voice of Seraphina, a tarot reader designed to be empathetic and practical. The persona is defined through prompt engineering:
  • Warm but direct: Friendly tone without excessive mysticism
  • Practical guidance: Advice that can be applied immediately
  • Clear language: Avoids archaic or overly poetic terms
  • Concise: Readings limited to 250 words maximum
  • Constructive: Positive and realistic, never fatalistic

Prompt structure

The system sends a carefully structured prompt to the AI:
const prompt = `Actúa como Seraphina, una tarotista profesional y empática. 
Tu estilo es cálido pero directo y práctico. No usas palabras complicadas 
ni eres excesivamente mística. Tu objetivo es ayudar a la persona con 
consejos que pueda aplicar mañana mismo.

DATOS:
- Nombre: ${name}
- Pregunta: "${question}"
- Cartas: ${cardsText}

REGLAS DE ESCRITURA:
1. Sé breve: La lectura completa NO debe superar las 250 palabras.
2. Lenguaje claro: Habla como una amiga sabia, no como un libro antiguo.
3. Estructura directa:
   - APERTURA: Hola ${name}, vamos a ver qué dicen las cartas.
   - PASADO: Lo que te trajo aquí.
   - PRESENTE: Lo que pasa ahora.
   - FUTURO: Lo que viene.
   - CONSEJO: Un paso práctico a seguir.
4. Tono: Positivo y constructivo, pero realista.
`;
The system automatically rejects questions about health, medical diagnoses, or pregnancy, redirecting users to consult healthcare professionals instead.

Card data formatting

Before sending to the AI, card data is formatted to include position, orientation, and keywords:
const positions = ['Pasado', 'Presente', 'Futuro'] as const;
const cardsText = cards
  .map((card, i) => {
    const orientation = card.reversed ? 'invertida' : 'al derecho';
    const keywords = card.reversed ? card.reversedKeywords : card.uprightKeywords;
    return `• ${positions[i]}: ${card.name} (${orientation}) — ${card.description}. Energías: ${keywords.join(', ')}.`;
  })
  .join('\n');

Example output

• Pasado: El Loco (al derecho) — El inicio de un viaje, potencial ilimitado. 
  Energías: nuevos comienzos, aventura, inocencia, libertad.
• Presente: La Torre (invertida) — Derrumbe de estructuras falsas. 
  Energías: catástrofe evitada, resistencia, miedo al cambio, crisis interna.
• Futuro: El Sol (al derecho) — Radiante éxito, claridad de propósito. 
  Energías: alegría, éxito, vitalidad, claridad.

Fallback reading system

If all AI models fail, the system generates a static reading using a template:
function getFallbackReading(name: string, cards: Array<{ name: string; reversed?: boolean }>) {
  const [past, present, future] = cards;
  return `Hola ${name}, vamos a ver qué dicen las cartas.

En el pasado, ${past.name} ${past.reversed ? 'invertida' : 'al derecho'} marca el punto 
de partida de lo que estás viviendo.

En el presente, ${present.name} ${present.reversed ? 'invertida' : 'al derecho'} refleja 
lo que está pasando ahora mismo.

Para el futuro, ${future.name} ${future.reversed ? 'invertida' : 'al derecho'} muestra 
hacia dónde se dirigen las cosas si seguís el camino actual.

Mi consejo: revisá qué de tu pasado todavía estás cargando sin necesidad, 
y enfocate en lo que sí podés cambiar hoy.`;
}

reading ??= getFallbackReading(name, cards);
The nullish coalescing operator (??=) ensures the fallback is only used if no AI model succeeded.

Complete action handler

The full consultation action in src/actions/index.ts:36-94:
export const server = {
  tarot: {
    consult: defineAction({
      input: z.object({
        name: z.string().min(1).max(60),
        question: z.string().min(1).max(500),
        cards: z.array(CardSchema).length(3),
      }),
      handler: async ({ name, question, cards }) => {
        const apiKey = import.meta.env.GEMINI_API_KEY;
        if (!apiKey) {
          throw new Error('GEMINI_API_KEY no configurada');
        }

        const ai = new GoogleGenAI({ apiKey });
        const models = ['gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-2.0-flash-lite'];
        let reading: string | undefined;

        for (const model of models) {
          try {
            const response = await ai.models.generateContent({ model, contents: prompt });
            if (response.text) {
              reading = response.text;
              break;
            }
          } catch (err: any) {
            if (err?.status === 429) continue;
            break;
          }
        }

        reading ??= getFallbackReading(name, cards);
        return { reading };
      },
    }),
  },
};

Input validation

All user inputs are validated using Zod schemas:
  • Name: 1-60 characters
  • Question: 1-500 characters
  • Cards: Exactly 3 cards with complete card data
input: z.object({
  name: z.string().min(1).max(60),
  question: z.string().min(1).max(500),
  cards: z.array(CardSchema).length(3),
})

Build docs developers (and LLMs) love