Zero PII Customer Intelligence — Parte 2: Perfiles, señales y segmentos (Español (Spanish))

Zero PII Customer Intelligence — Parte 2: Perfiles, señales y segmentos

Wednesday, 31 December 2025

//

21 minute read

In Parte 1 hemos cubierto la filosofía y la visión general de la serie. Parte 1.1 Construimos el generador de datos de la muestra.

Ahora vamos a construir el sistema central. Esta parte se centra en:

  1. Arquitectura de perfil Cero-PII - Sesiones efímeras vs perfiles persistentes
  2. Señales y pesos - Lo que rastreamos y cómo se acumula
  3. Definiciones de segmentos - Membresía difusa con reglas ponderadas (el evento principal)
  4. Previsualización del patrón de outbox - Publicación fiable de eventos (detallada en la parte 3)

Esta es la proyecto de muestra (Mostlylucid.SegmentCommerce)—una pequeña demo de comercio electrónico que muestra los patrones centrales. Se simplifica intencionalmente para demostrar conceptos claramente, pero también incluye patrones de infraestructura real (manejo de mensajes basado en la bandeja de salida, procesamiento de trabajos de fondo, indexación JSONB) que escalarías horizontalmente en la producción.

Piense en esto como "patrones de producción en un factor de forma de una sola aplicación". La misma estructura de código funciona si usted está ejecutando un proceso o distribuyendo entre los servicios.

Opciones tecnológicas

Elegimos un pila simple, cohesiva mantener la muestra clara al tiempo que se demuestran los patrones de producción.

PostgreSQL + pgvector: Una base de datos

En lugar de bases de datos vectoriales separadas, colas de mensajes y capas de caché, usamos PostgreSQL:

flowchart LR
    App[ASP.NET Core] --> PG[(PostgreSQL)]
    
    PG --> JSONB[JSONB<br/>interests]
    PG --> Vector[pgvector<br/>embeddings]
    PG --> Queue[Queue tables<br/>jobs, outbox]
    PG --> FTS[Full-text<br/>tsvector]
    
    style PG stroke:#2f9e44,stroke-width:3px
    style JSONB stroke:#1971c2,stroke-width:2px
    style Vector stroke:#1971c2,stroke-width:2px
    style Queue stroke:#1971c2,stroke-width:2px
    style FTS stroke:#1971c2,stroke-width:2px
  • JSONB para esquemas flexibles (sin NoSQL separado)
  • pgvector para incrustaciones (sin Qdrant/Pinecone)
  • ESCUCHA/NOTICIA para puestos de trabajo (no Redis/RabbitMQ)
  • tsvector para texto completo (sin Elasticsearch)

Una piscina de conexión, una copia de seguridad, un despliegue.

HTMX + Alpine.js: interfaz de usuario impulsada por el servidor

<!-- Instant search (no page reload) -->
<input 
    hx-get="/api/search" 
    hx-trigger="keyup changed delay:300ms" 
    hx-target="#results" />
  • HTMX: Servidor renderiza parciales (no JSON → templating)
  • Alpine.js: Reactividad sin un paso de construcción
  • Mejora progresiva: Funciona sin JS, mejor con ella

UX tipo SPA con simplicidad de representación del servidor.

ASP.NET Core: Patrones distribuibles

Aplicación única, pero escala de patrones:

  • Caja de salida (Eventos BA) → swap para bus de mensajes
  • Cola de trabajo (LISTEN/NOTIFY) → swap por grupo de trabajadores
  • Colector de sesiones → swap por API separada

Comience simple. Distribuya cuando sea necesario.

Lo que evitamos

  • No hay una base de datos vectorial separada (pgvector está ubicado en el mismo lugar)
  • Sin marco JS (HTMX + Alpine es más simple)
  • Todavía no hay microservicios (los patrones funcionan en monolito o se distribuyen)
  • No Docker Compose expansión (una DB, una aplicación)

El desafío de la Cero-PII

El seguimiento de usuarios tradicionales almacena información identificable: nombres, correos electrónicos, identificaciones de usuario vinculadas al comportamiento. Las regulaciones del RGPD y la privacidad hacen que esto sea cada vez más problemático.

Almacenamos patrones de comportamiento, no identidades.

flowchart LR
    subgraph "Traditional Approach"
        User1[User: [email protected]] --> Behavior1[Bought headphones]
        Behavior1 --> PII1[(PII Database)]
    end
    
    subgraph "Zero-PII Approach"
        User2[Anonymous Session] --> Behavior2[Bought headphones]
        Behavior2 --> Pattern[(Category: tech<br/>Signal weight: 1.0)]
    end
    
    style PII1 stroke:#c92a2a,stroke-width:3px
    style Pattern stroke:#2f9e44,stroke-width:3px

La visión clave: Usted no necesita saber QUIÉN es alguien para saber lo que está interesado en.

Descripción general de la arquitectura

El sistema tiene tres capas centrales:

flowchart TB
    Browser[Browser] --> Session[Session Collector]
    Session --> Profile[Persistent Profile]
    Profile --> Segments[Segment Service]
    
    Session -->|in-memory only| Cache[(IMemoryCache)]
    Profile -->|elevated signals| DB[(PostgreSQL + JSONB)]
    Segments -->|memberships| UI[Segment Explorer UI]
    
    style Session stroke:#1971c2,stroke-width:3px
    style Profile stroke:#2f9e44,stroke-width:3px
    style Segments stroke:#fab005,stroke-width:3px
    style Cache stroke:#e64980,stroke-width:2px
  1. Recopilador de sesiones - Captura señales de comportamiento (vistas, clics, cart adds) - sólo en memoria
  2. Perfil persistente - Eleva las señales de alto valor, calcula los intereses - respaldado por la base de datos
  3. Servicio de segmentos - Evalúa los perfiles en función de las normas, asigna a los miembros

Diferencia crítica: Las sesiones son efímeras. Los perfiles son persistentes. Esto no es un detalle de implementación—es una decisión de arquitectura de privacidad.

Perfiles de la sesión: En memoria estricta (LFU Cache)

Los períodos de sesiones son: estrictamente en memoria. Viven en IMemoryCache con vencimiento deslizante y nunca toque la base de datos.

Esta es una restricción arquitectónica dura: los datos de sesión no pueden persistir. Recopila señales durante una visita y desaloja después de 30 minutos de inactividad a través de la política de caché LFU (menos usada) bajo presión de memoria.

SessionProfile: El modelo en memoria

// Mostlylucid.SegmentCommerce/Models/SessionProfile.cs
public class SessionProfile
{
    public string SessionKey { get; set; } = string.Empty;

    // Category interest scores: { "tech": 0.75, "fashion": 0.25 }
    public Dictionary<string, double> Interests { get; set; } = new();

    // Detailed signal counts: { "tech": { "product_view": 5, "add_to_cart": 1 } }
    public Dictionary<string, Dictionary<string, int>> Signals { get; set; } = new();

    // Products viewed this session
    public List<int> ViewedProducts { get; set; } = new();

    // Session context (device, referrer domain, time-of-day)
    public SessionContext? Context { get; set; }

    // Aggregates
    public double TotalWeight { get; set; }
    public int SignalCount { get; set; }
    public int PageViews { get; set; }
    public int ProductViews { get; set; }
    public int CartAdds { get; set; }

    // Timestamps
    public DateTime StartedAt { get; set; } = DateTime.UtcNow;
    public DateTime LastActivityAt { get; set; } = DateTime.UtcNow;

    // Link to persistent profile (if fingerprint resolved)
    public Guid? PersistentProfileId { get; set; }
}

Conservar en IMemoryCache (o IDistributedCache) con Caducidad Deslizante

// Mostlylucid.SegmentCommerce/Services/Profiles/SessionCollector.cs
_cache.Set(sessionKey, sessionProfile, new MemoryCacheEntryOptions
{
    SlidingExpiration = TimeSpan.FromMinutes(30),
    Priority = CacheItemPriority.Normal, // LFU eviction under memory pressure
    
    // CRITICAL: Eviction callback decides whether to elevate to persistent profile
    PostEvictionCallbacks =
    {
        new PostEvictionCallbackRegistration
        {
            EvictionCallback = async (key, value, reason, state) =>
            {
                if (value is SessionProfile session && ShouldElevate(session))
                {
                    // Only NOW do we write to database (persistent profile)
                    await ElevateToProfileAsync(session);
                }
                // Otherwise: session is gone forever
            }
        }
    }
});

Dificultades:

  • Cero persistencia accidental: Sesiones en vivo sólo en caché (ImemoryCache o IDistributedCache como Redis)
  • Desahucio de la LFU: Sesiones de bajo uso desalojan primero bajo presión de memoria
  • Expiración deslizante: 30 minutos de la última actividad
  • Retrollamada de evacuación: Última oportunidad de elevar señales de alto valor antes de que se pierdan
  • Sin recuperación: Reiniciar la aplicación = todas las sesiones perdidas (a menos que se utilice IDistributedCache, pero aún efímero)

Decisión de elevación (sobre desalojo)

private bool ShouldElevate(SessionProfile session)
{
    // Elevate if:
    // - User added to cart (high intent)
    // - User completed purchase (conversion)
    // - Fingerprint was resolved (identity established)
    // - Session weight exceeds threshold (engaged visitor)
    
    return session.CartAdds > 0 
        || session.TotalWeight > 5.0 
        || session.PersistentProfileId.HasValue;
}

¿Por qué la llamada de desalojo?

Esta es la sólo punto seguro para decidir la persistencia. Para el momento en que la caché desaloja la sesión:

  1. Conocemos la historia completa de la sesión
  2. Podemos evaluar el compromiso total
  3. Evitamos almacenar sesiones de bajo valor (vista de una sola página, rebote)
  4. Garantizamos que las sesiones no persistan accidentalmente

Si no nos elevamos durante el desalojo, la sesión es se fue para siempreEste es el diseño.

SessionContext: Lo que rastreamos (seguramente)

public class SessionContext
{
    public string? DeviceType { get; set; }        // "mobile", "desktop"
    public string? EntryPath { get; set; }         // "/products/tech" (no query params)
    public string? ReferrerDomain { get; set; }    // "google.com" (domain only, not full URL)
    public string? TimeOfDay { get; set; }         // "morning", "afternoon"
    public string? DayType { get; set; }           // "weekday", "weekend"
}

Note lo que es no aquí: direcciones IP, agentes de usuario, URLs completas, píxeles de seguimiento. patrones de contexto, información no identificable.

¿Qué son las señales?

A señal es un hecho conductual capturado durante una acción del usuario. En lugar de rastrear "quién", rastreamos "lo que pasó".

Este concepto viene de Señales efímeras— hechos de corta duración que existen en una ventana limitada y envejecen naturalmente. En ese sistema, las operaciones emiten señales como "api.rate_limited" o "gateway.slow" para coordinar el comportamiento sin acoplamiento apretado.

Aquí aplicamos el mismo patrón al comportamiento del usuario:

  • Vista del producto → product_view señal (peso: 0,10)
  • Añadir al carrito → add_to_cart señal (peso: 0,35)
  • Compra → purchase señal (peso: 1,00)

Las señales son efímero (expirar con el período de sesiones), cero-PII (sin identidad), y ponderados (consciente de la intención).

Para enlazar sesiones sin seguimiento de cookies, utilizamos huellas dactilares del cliente. El navegador calcula un hash a partir de señales (zona horaria, resolución de pantalla, renderizador WebGL, huella digital de lienzo) y envía sólo el hachís a /api/fingerprint.

El servidor entonces HMAC que hachís con una llave secreta, haciéndola inutilizable en otro lugar.

Este código está adaptado de mi Proyecto de detección de bots, donde se utiliza para identificar raspadores. La misma técnica, diferente propósito.

// Mostlylucid.SegmentCommerce/ClientFingerprint/fingerprint.js
// Collect signals (browser capabilities, not PII)
var signals = [
    Intl.DateTimeFormat().resolvedOptions().timeZone,
    navigator.language,
    screen.width + 'x' + screen.height,
    // ... (see full code)
];

// Hash locally
var hash = hash(signals.join('|'));

// Send only the hash via sendBeacon
navigator.sendBeacon('/api/fingerprint', JSON.stringify({ h: hash }));

Parte del servidor:

// Server HMACs the client hash with a secret key
var profileKey = HMACSHA256(clientHash + secretKey);

Ahora tenemos un identificador estable, con alcance de sitio sin cookies o localStorage. fuente completa de la huella dactilar.js (adaptado principalmente a partir de lucid.botdetection).

Tipos de señales y pesos

Diferentes acciones tienen diferentes niveles de intención. Pesos básicos.

Tipos de Señal: La Jerarquía del Peso

// Mostlylucid.SegmentCommerce/Data/Entities/Profiles/SignalEntity.cs
public static class SignalTypes
{
    // Passive signals (low intent)
    public const string PageView = "page_view";              // 0.01
    public const string CategoryBrowse = "category_browse";  // 0.03
    public const string ProductImpression = "product_impression"; // 0.02

    // Active signals (medium intent)
    public const string ProductView = "product_view";        // 0.10
    public const string ProductClick = "product_click";      // 0.08
    public const string Search = "search";                   // 0.05

    // High-intent signals
    public const string AddToCart = "add_to_cart";           // 0.35
    public const string AddToWishlist = "add_to_wishlist";   // 0.25
    public const string ViewCart = "view_cart";              // 0.15
    public const string BeginCheckout = "begin_checkout";    // 0.40

    // Conversion signals (highest intent)
    public const string Purchase = "purchase";               // 1.00
    public const string Review = "review";                   // 0.60
    public const string Share = "share";                     // 0.50

    public static readonly Dictionary<string, double> BaseWeights = new()
    {
        { PageView, 0.01 },
        { ProductView, 0.10 },
        { AddToCart, 0.35 },
        { Purchase, 1.00 },
        // ... (see full code for complete list)
    };

    public static double GetBaseWeight(string signalType)
    {
        return BaseWeights.GetValueOrDefault(signalType, 0.05);
    }
}

Por qué esto importa:

  • Una vista de una sola página (0.01) no dominará la señal
  • Añadiendo a la cesta (0.35) es una fuerte señal de intención
  • Compra (1.00) es la señal más fuerte

Esta jerarquía impide que la "navegación por carretera" contamine el perfil.

ReuniónRecolector: Señales de grabación (sólo por caché)

// Mostlylucid.SegmentCommerce/Services/Profiles/SessionCollector.cs
public async Task<SessionProfile> RecordSignalAsync(
    SessionSignalInput input, CancellationToken ct = default)
{
    var sessionKey = input.SessionKey;
    
    // Get or create session FROM CACHE (never DB)
    var session = _cache.Get<SessionProfile>(sessionKey);
    
    if (session == null)
    {
        session = new SessionProfile
        {
            SessionKey = sessionKey,
            StartedAt = DateTime.UtcNow
        };
    }

    session.LastActivityAt = DateTime.UtcNow;

    var weight = input.Weight ?? SignalTypes.GetBaseWeight(input.SignalType);

    // Update in-memory aggregates
    session.TotalWeight += weight;
    session.SignalCount++;

    if (!string.IsNullOrEmpty(input.Category))
    {
        session.Interests.TryGetValue(input.Category, out var currentScore);
        session.Interests[input.Category] = currentScore + weight;
    }

    if (input.SignalType == SignalTypes.AddToCart)
    {
        session.CartAdds++;
    }

    // Put back in cache with sliding expiration
    _cache.Set(sessionKey, session, new MemoryCacheEntryOptions
    {
        SlidingExpiration = TimeSpan.FromMinutes(30),
        Priority = CacheItemPriority.Normal,
        PostEvictionCallbacks = { /* elevation callback */ }
    });

    return session;
}

Rápido porque:

  • Pura memoria (ninguna DB escribe)
  • Sin gastos generales de serialización (IMemoryCache)
  • Sin llamadas de red (caché local)

Perfiles persistentes: Señales elevadas

Cuando una sesión muestra alta intención (carta añade, compras), elevamos las señales a un perfil persistente.

PersistenteProfileEntity: El perfil a largo plazo

// Mostlylucid.SegmentCommerce/Data/Entities/Profiles/PersistentProfileEntity.cs
[Table("persistent_profiles")]
public class PersistentProfileEntity
{
    [Key]
    public Guid Id { get; set; } = Guid.NewGuid();

    [Required]
    [MaxLength(256)]
    public string ProfileKey { get; set; } = string.Empty;

    // How this profile is identified (Fingerprint, Cookie, Identity)
    public ProfileIdentificationMode IdentificationMode { get; set; }

    // Behavioral data (all JSONB)
    [Column("interests", TypeName = "jsonb")]
    public Dictionary<string, double> Interests { get; set; } = new();

    [Column("affinities", TypeName = "jsonb")]
    public Dictionary<string, double> Affinities { get; set; } = new();

    [Column("brand_affinities", TypeName = "jsonb")]
    public Dictionary<string, double> BrandAffinities { get; set; } = new();

    [Column("price_preferences", TypeName = "jsonb")]
    public PricePreferences? PricePreferences { get; set; }

    [Column("traits", TypeName = "jsonb")]
    public Dictionary<string, bool> Traits { get; set; } = new();

    // Computed segments
    public ProfileSegments Segments { get; set; } = ProfileSegments.None;

    [Column("llm_segments", TypeName = "jsonb")]
    public Dictionary<string, double>? LlmSegments { get; set; }

    // Vector embedding for similarity matching
    [Column("embedding", TypeName = "vector(384)")]
    public Vector? Embedding { get; set; }

    // Statistics
    public int TotalSessions { get; set; }
    public int TotalSignals { get; set; }
    public int TotalPurchases { get; set; }
    public int TotalCartAdds { get; set; }

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

Todavía cero PII:

  • ProfileKey es un hachís HMAC (no reversible)
  • IdentificationMode nos dice cómo fue identificado (impresión dactilar/cookie/login)
  • Todos los datos son señales de comportamiento, no información personal

Elevación: Sesión → Perfil

public async Task ElevateToProfileAsync(
    SessionProfileEntity session, 
    PersistentProfileEntity profile, 
    CancellationToken ct = default)
{
    if (session.IsElevated)
        return;

    // Merge interests (use higher value)
    foreach (var (category, score) in session.Interests)
    {
        if (!profile.Interests.ContainsKey(category) || 
            profile.Interests[category] < score)
        {
            profile.Interests[category] = score;
        }
    }

    // Update stats
    profile.TotalSessions++;
    profile.TotalSignals += session.SignalCount;
    profile.TotalCartAdds += session.CartAdds;
    profile.LastSeenAt = DateTime.UtcNow;
    profile.UpdatedAt = DateTime.UtcNow;

    // Mark session as elevated
    session.IsElevated = true;
    session.PersistentProfileId = profile.Id;

    // Clear segment cache (will be recomputed)
    profile.SegmentsComputedAt = null;
    profile.EmbeddingComputedAt = null;

    await _db.SaveChangesAsync(ct);
}

Cuando la elevación ocurre:

  • Después de un carrito añadir (intento alto)
  • Después de una compra (conversión)
  • Cuando el usuario opta por la persistencia (impresión dactilar/cookie/login)

Definiciones del segmento: El pago

Nos hemos burlado de segmentos para dos partes. Ahora vamos a entregar. Segmentos son los salida accionable de toda esa colección de señales—responden "¿qué tipo de comprador es este?"

¿Por qué Membresía Fuzzy?

La segmentación tradicional es binaria: o estás en el segmento o no lo estás. Esto crea problemas:

flowchart LR
    subgraph "Binary Segmentation"
        Profile1[3 purchases] -->|"Threshold: 5"| Out[NOT High-Value]
        Profile2[5 purchases] -->|"Threshold: 5"| In[High-Value]
    end

Un cliente con 4 compras es tratado idénticamente a uno con 0. Eso está mal.

Segmentación difusa da a cada perfil una puntuación (0-1):

Compras # # binarias # # Fuzzy Score

|-----------|--------|-------------| 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 No 0.4 4 No 0.8 5+ Sí 1,0

Ahora se puede personalizar proporcionalmente: los clientes "casi de alto valor" reciben un trato ligeramente diferente al de "ninguna parte cercana".

La estructura de definición del segmento

// Mostlylucid.SegmentCommerce/Services/Segments/SegmentDefinition.cs
public class SegmentDefinition
{
    public string Id { get; set; }           // "tech-enthusiast"
    public string Name { get; set; }          // "Tech Enthusiasts"
    public string Description { get; set; }   // "Users with strong interest in technology"
    public string Icon { get; set; }          // "🔧"
    public string Color { get; set; }         // "#3b82f6"

    public List<SegmentRule> Rules { get; set; } = [];

    // How rules combine: All (AND), Any (OR), Weighted (sum)
    public RuleCombination Combination { get; set; } = RuleCombination.Weighted;

    // Minimum score to be "a member" (0-1)
    public double MembershipThreshold { get; set; } = 0.3;

    public List<string> Tags { get; set; } = [];  // For filtering/grouping
}

Tipos de reglas: Lo que puede comprobar

Cada regla evalúa una dimensión del perfil:

public enum RuleType
{
    CategoryInterest,  // Check interests.tech, interests.fashion, etc.
    BrandAffinity,     // Check brandAffinities.Sony, brandAffinities.Nike, etc.
    PriceRange,        // Check price preferences (budget vs luxury)
    Trait,             // Check boolean traits (prefersDeals, browsesExtensively)
    Statistic,         // Check totalPurchases, totalSessions, totalCartAdds
    TagAffinity,       // Check affinities.gadgets, affinities.organic, etc.
    Recency,           // Check days since last activity
    Expression         // Custom expressions (advanced)
}

Operadores de reglas

public enum RuleOperator
{
    GreaterThan,       // value > threshold
    GreaterOrEqual,    // value >= threshold
    LessThan,          // value < threshold
    LessOrEqual,       // value <= threshold
    Equal,             // value == threshold
    NotEqual,          // value != threshold
    Contains,          // for array/string checks
    Between,           // for ranges
    In, NotIn          // for set membership
}

Métodos de combinación de reglas

Cómo múltiples reglas se combinan en una puntuación final:

public enum RuleCombination
{
    All,      // AND logic: Score = min(all rule scores). All rules must pass.
    Any,      // OR logic: Score = max(all rule scores). Any rule can pass.
    Weighted  // Weighted sum: Score = Σ(rule.weight × rule.score) / Σ(rule.weight)
}

Coeficientes ponderados es más común — le permite decir "el interés de la categoría importa 60%, la actualidad importa 30%, la afinidad de la marca importa 10%".

Ejemplos de segmentos reales

Estos son los segmentos predeterminados en el proyecto de muestra:

1. Entusiasmos tecnológicos

new SegmentDefinition
{
    Id = "tech-enthusiast",
    Name = "Tech Enthusiasts",
    Description = "Users with strong interest in technology products",
    Icon = "🔧",
    Color = "#3b82f6",
    MembershipThreshold = 0.35,
    Rules =
    [
        new() { 
            Type = RuleType.CategoryInterest, 
            Field = "interests.tech", 
            Operator = RuleOperator.GreaterOrEqual, 
            Value = 0.4, 
            Weight = 0.6,  // 60% of score
            Description = "Tech interest > 40%" 
        },
        new() { 
            Type = RuleType.TagAffinity, 
            Field = "affinities.gadgets", 
            Operator = RuleOperator.GreaterOrEqual, 
            Value = 0.2, 
            Weight = 0.2,  // 20% of score
            Description = "Likes gadgets" 
        },
        new() { 
            Type = RuleType.TagAffinity, 
            Field = "affinities.electronics", 
            Operator = RuleOperator.GreaterOrEqual, 
            Value = 0.2, 
            Weight = 0.2,  // 20% of score
            Description = "Likes electronics" 
        }
    ]
}

Ejemplo de evaluación:

  • Perfil tiene interests.tech = 0.72, affinities.gadgets = 0.31, affinities.electronics = 0.15
  • Regla 1: 0,72 = 0,4 → puntuación 1,0 (supera el umbral)
  • Regla 2: 0,31 = 0,2 → puntuación 1,0 (supera el umbral)
  • Regla 3: 0,15 < 0,2 → puntuación 0,75 (parcial: 0,15/0,2)
  • Final: (1,0×0,6 + 1,0×0,2 + 0,75×0,2) / 1,0 = 0.95
  • 0,95 > = 0,35 umbral → Miembro con confianza del 95%

2. Abandonadores de carros

new SegmentDefinition
{
    Id = "cart-abandoner",
    Name = "Cart Abandoners",
    Description = "Users who add items to cart but don't complete purchase",
    Icon = "🛒",
    Color = "#ef4444",
    MembershipThreshold = 0.4,
    Rules =
    [
        new() { 
            Type = RuleType.Statistic, 
            Field = "totalCartAdds", 
            Operator = RuleOperator.GreaterOrEqual, 
            Value = 3, 
            Weight = 0.5, 
            Description = "3+ cart adds" 
        },
        new() { 
            Type = RuleType.Statistic, 
            Field = "totalPurchases", 
            Operator = RuleOperator.LessThan, 
            Value = 2, 
            Weight = 0.5, 
            Description = "Few purchases" 
        }
    ]
}

Esto capta el patrón de "añadir al carrito pero no comprar", perfecto para las campañas de recuperación.

3. Clientes de alto valor

new SegmentDefinition
{
    Id = "high-value",
    Name = "High-Value Customers",
    Description = "Customers who make frequent purchases and spend above average",
    Icon = "💎",
    Color = "#8b5cf6",
    MembershipThreshold = 0.4,
    Rules =
    [
        new() { 
            Type = RuleType.Statistic, 
            Field = "totalPurchases", 
            Operator = RuleOperator.GreaterOrEqual, 
            Value = 3, 
            Weight = 0.4, 
            Description = "3+ purchases" 
        },
        new() { 
            Type = RuleType.PriceRange, 
            Field = "priceRange", 
            Value = "100-10000",  // High-end shoppers
            Weight = 0.3, 
            Description = "High price range" 
        },
        new() { 
            Type = RuleType.Recency, 
            Field = "lastSeen", 
            Operator = RuleOperator.LessThan, 
            Value = 30, 
            Weight = 0.3, 
            Description = "Active in last 30 days" 
        }
    ]
}

4. Cazadores de trueques

new SegmentDefinition
{
    Id = "bargain-hunter",
    Name = "Bargain Hunters",
    Description = "Price-sensitive shoppers who love deals and discounts",
    Icon = "🏷️",
    Color = "#22c55e",
    MembershipThreshold = 0.3,
    Rules =
    [
        new() { 
            Type = RuleType.PriceRange, 
            Field = "priceRange", 
            Value = "0-75",  // Budget shoppers
            Weight = 0.5, 
            Description = "Low price preference" 
        },
        new() { 
            Type = RuleType.Trait, 
            Field = "traits.prefersDeals", 
            Value = true, 
            Weight = 0.3, 
            Description = "Prefers deals" 
        },
        new() { 
            Type = RuleType.Statistic, 
            Field = "totalCartAdds", 
            Operator = RuleOperator.GreaterThan, 
            Value = 5, 
            Weight = 0.2, 
            Description = "Shops around" 
        }
    ]
}

Todos los segmentos predeterminados

La muestra incluye 10 segmentos que cubren patrones comunes de comercio electrónico:

Segmento Icono Reglas clave Caso de uso |---------|------|-----------|----------| Clientes de alto valor 3+ compras, alto gasto, tratamiento VIP reciente , programas de lealtad Entusiastas de la tecnología Intereses de la tecnología, afinidad del gadget Recomendaciones de productos de la tecnología Moda hacia adelante Interés de moda, visitas múltiples Recomendaciones de estilo Cazadores de negociación Rango de precio bajo, prefiere ofertas Notificaciones de venta Nuevos Visitantes ≤2 sesiones, sin compras A bordo, ofertas de primera compra Carrito Abandonadores 3+ carrito añade, pocas compras Emails de recuperación Inicio Entusiastas Interés de la casa, actividad reciente Home producto cross-sell Fitness Activo Interés deportivo, rasgos de salud Fitness foco del producto Clientes leales 5+ compras, 10+ sesiones Retención, recompensas Investigadores Altas señales, explora ampliamente Herramientas de comparación, información detallada

SegmentService: Membresías de Computación

Los SegmentService evalúa los perfiles en función de todas las reglas del segmento:

// Mostlylucid.SegmentCommerce/Services/Segments/SegmentService.cs
public SegmentMembership EvaluateSegment(ProfileData profile, SegmentDefinition segment)
{
    var ruleScores = new List<RuleScore>();
    
    foreach (var rule in segment.Rules)
    {
        var (score, actualValue) = EvaluateRule(profile, rule);
        ruleScores.Add(new RuleScore
        {
            RuleDescription = rule.Description,
            Score = score,
            Weight = rule.Weight,
            ActualValue = actualValue  // For transparency
        });
    }

    // Combine based on segment's combination method
    double finalScore = segment.Combination switch
    {
        RuleCombination.All => ruleScores.Min(r => r.Score),
        RuleCombination.Any => ruleScores.Max(r => r.Score),
        RuleCombination.Weighted => ComputeWeightedScore(ruleScores),
        _ => 0
    };

    return new SegmentMembership
    {
        SegmentId = segment.Id,
        SegmentName = segment.Name,
        Score = Math.Round(finalScore, 3),
        IsMember = finalScore >= segment.MembershipThreshold,
        RuleScores = ruleScores,
        Confidence = score switch  // Human-readable
        {
            >= 0.8 => "Very High",
            >= 0.6 => "High",
            >= 0.4 => "Medium",
            >= 0.2 => "Low",
            _ => "Very Low"
        }
    };
}

Explicabilidad incorporada

Cada resultado de la membresía incluye el valores reales que llevó a la puntuación:

// What the UI receives:
{
    "segmentId": "tech-enthusiast",
    "segmentName": "Tech Enthusiasts",
    "score": 0.95,
    "isMember": true,
    "confidence": "Very High",
    "ruleScores": [
        { "description": "Tech interest > 40%", "score": 1.0, "actualValue": "0.72" },
        { "description": "Likes gadgets", "score": 1.0, "actualValue": "0.31" },
        { "description": "Likes electronics", "score": 0.75, "actualValue": "0.15" }
    ]
}

Los usuarios pueden ver exactamente por qué Esto es fundamental para la transparencia y el cumplimiento del GDPR.

Flujo de señal: extremo a extremo

Así es como una vista de producto se convierte en una membresía de segmento:

sequenceDiagram
    participant Browser
    participant Cache as SessionCache
    participant Outbox as Outbox
    participant Segment as SegmentService

    Browser->>Cache: Product view (category: "tech")
    Cache->>Cache: Update in-memory session
    Note over Cache: interests.tech += 0.10
    
    Browser->>Cache: Add to cart (high intent)
    Cache->>Outbox: Publish elevation event
    Outbox->>Outbox: Write to outbox table
    
    Note over Outbox: Background worker processes
    Outbox->>Segment: Elevate to PersistentProfile
    
    Segment->>Segment: Evaluate segment rules
    Note over Segment: interests.tech: 0.72 >= 0.40 ✓<br/>Score: 0.95, IsMember: true
    Segment-->>Browser: Segment memberships + explanations

El patrón de la caja de salida (Preview)

Todas las acciones significativas fluyen a través de la patrón de salida—nuestro principal mecanismo de orquestación:

flowchart LR
    Action[Cart Add] --> TX[Single Transaction]
    TX --> DB[(Save + Outbox)]
    DB --> Worker[Background Worker]
    Worker --> Route[Route to Handlers]
    
    style TX stroke:#2f9e44,stroke-width:3px

¿Por qué? Los datos y eventos de negocios se escriben en una sola transacción. Los eventos no se pueden perder. Los fracasos se reintentan automáticamente con un retroceso exponencial.

// Every action publishes to outbox in the same transaction
await using var transaction = await _db.Database.BeginTransactionAsync(ct);

cart.Items.Add(new CartItem { ProductId = productId });
await _db.SaveChangesAsync(ct);

_outbox.Publish(OutboxEventTypes.ProductAddedToCart, new { ProductId = productId });
await _db.SaveChangesAsync(ct);

await transaction.CommitAsync(ct);
// Event is now guaranteed to be processed

Parte 3 cubre la implementación completa de outbox: la cola de trabajo, LISTEN/NOTIFY para la recogida instantánea, reintentar la lógica y escalar patrones.

¿Qué sigue?

Esta parte abarcaba:

  • Arquitectura de perfil Cero-PII (sesiones efímeras frente a perfiles persistentes)
  • Definiciones de segmentos con miembros borrosos y normas ponderadas
  • El SegmentService con explicación incorporada

Parte 3 profundiza en:

  • Implementación del patrón de salida - publicación fiable de eventos y el router único
  • Cola de trabajo con PostgreSQL SKIP LOCKED para el procesamiento distribuido
  • ESCUCHA/NOTICIA para la recogida instantánea de trabajo (sin votación)
  • La interfaz de usuario y la transparencia - el salpicadero "Tus Intereses" y el explorador de segmento

Los segmentos son la salida accionable de este sistema. Ellos responden "¿qué tipo de comprador es esto?" con puntuaciones borrosas, no cubos binarios. Y cada usuario puede ver exactamente por qué están en un segmento.

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.