Noll PII-kundinformation – Del 2: Profiler, signaler och segment (Svenska (Swedish))

Noll PII-kundinformation – Del 2: Profiler, signaler och segment

Friday, 26 December 2025

//

21 minute read

Till Häfte 1 Vi gick igenom filosofin och serieöversikten. Häfte 1.1 Vi byggde provdatageneratorn.

Låt oss nu bygga kärnsystemet. Den här delen fokuserar på:

  1. Zero-PII-profilarkitektur - Efemerala sessioner vs ihållande profiler
  2. Signaler och vikter - Vad vi spårar och hur det ackumuleras
  3. Definitioner av segment - Fuzzy medlemskap med viktade regler (huvudhändelsen)
  4. Förhandsgranskning av utkorgsmönster - Tillförlitlig händelsepublicering (anges i del 3)

Det här är Urvalsprojekt (Mostlylucid.SegmentCommerce) – en liten e-handelsdemo som visar kärnan mönster. Det är avsiktligt förenklat för att visa begrepp tydligt, men det innehåller också verkliga infrastrukturmönster (utkorg-baserad meddelandehantering, bakgrundsjobb bearbetning, JSONB indexering) som du skulle skala horisontellt i produktionen.

Tänk på detta som "produktionsmönster i en app formfaktor." Samma kodstruktur fungerar oavsett om du kör en process eller distribuerar mellan olika tjänster.

Teknologival

Vi valde en enkel, sammanhållen stack för att hålla provet klart samtidigt som produktionsmönster demonstreras.

PostgreSQL + pgvector: En databas

Istället för separata vektordatabaser, meddelandeköer och cachelager använder vi oss av 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 Ordförande för flexibla scheman (ingen separat NoSQL)
  • Pgvector Ordförande för inbäddning (ingen Qdrant/Pinecone)
  • LISTA/INTIFIKA för jobb (ingen Redis/RabbitMQ)
  • för tsvets för fulltext (ingen Elasticsearch)

En anslutningspool, en förstärkning, en utplacering.

HTMX + Alpine.js: Serverdrivet användargränssnitt

<!-- Instant search (no page reload) -->
<input 
    hx-get="/api/search" 
    hx-trigger="keyup changed delay:300ms" 
    hx-target="#results" />
  • HTMX Ordförande: Servern gör partiella (ingen JSON → templating)
  • Alpina.js: Reaktivitet utan byggsteg
  • Progressiv förbättring: Fungerar utan JS, bättre med det

SPA-liknande UX med server-renderande enkelhet.

ASP.NET Core: Distribuerbara mönster

En app, men mönsterskala:

  • Utkorg (DB händelser) → byta för meddelande buss
  • Jobbkö (LISTEN/NOTIFY) → byte av personalpool
  • Sessionssamlare → Växla för separat API

Börja enkelt. Distribuera vid behov.

Vad vi undvek

  • Ingen separat vektordatabas (pgvector är lokaliserad)
  • Inget JS-ramverk (HTMX + Alpine är enklare)
  • Inga mikrotjänster ännu (mönster fungerar i monolit eller distribueras)
  • Ingen Docker Komposiera sprawl (en DB, en app)

Zero-PII-utmaningen

Traditionell användarspårning lagrar identifierbar information: namn, e-post, användar-ID kopplat till beteende. GDPR och sekretessregler gör detta allt mer problematiskt. Vårt tillvägagångssätt är annorlunda:

Vi lagrar beteendemönster, inte identiteter.

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

Den viktigaste insikten: du behöver inte veta VEM någon är att veta VAD de är intresserade av.

Översikt över arkitekturen

Systemet har tre kärnskikt:

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. Sessionssamlare - Fångar beteendesignaler (vyer, klick, kundvagn lägger) - Endast inom minne
  2. Persistent profil - Upphöjer högvärdiga signaler, beräknar intressen - databasstödd
  3. Segmenttjänst - Utvärderar profiler mot regler, tilldelar medlemskap

Kritisk åtskillnad: Sessionerna är efemerala. Profiler är ihållande. Det här är inte en genomförande detalj – det är ett beslut om integritetsarkitektur.

Sessionsprofiler: Strängt minne (LFU Cache)

Sessionerna är strikt in-minne. De bor i IMemoryCache med glidande utgång och Rör aldrig databasen.

Detta är en hård arkitektonisk begränsning: sessionsdata kan inte bestå. Den samlar signaler under ett besök och vräkningar efter 30 minuters inaktivitet via LFU (minst vanligt förekommande) cachepolicy under minnestryck.

Sessionsfil: Modellen i minne

// 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; }
}

Förvaras i ImoryCache (eller I DistributedCache) med Sliding Expiration

// 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
            }
        }
    }
});

Svåra begränsningar:

  • Noll oavsiktlig persistens: Sessionerna live Endast i cache (MemoryCache eller I DistributedCache som Redis)
  • Avhysning från LFU: Låganvända sessioner vräkning först under minnestryck
  • Glidande utgång: 30 minuter från sista aktiviteten
  • Återkallelse av vräkning: Sista chansen att höja högvärdiga signaler innan de går förlorade
  • Ingen återhämtning: App starta om = alla sessioner förlorade (om inte använda IDistributedCache, men fortfarande efemeral)

Höjdbeslut (vid vräkning)

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;
}

Varför avhyser du mig?

Det här är Endast säker punkt för att bestämma varaktighet. När cache vräker sessionen:

  1. Vi känner till hela sessionens historia
  2. Vi kan utvärdera det totala engagemanget
  3. Vi undviker att lagra lågvärdiga sessioner (ensidig vy, studs)
  4. Vi garanterar att sessioner inte av misstag fortsätter

Om vi inte höjer under vräkning, är sessionen borta för alltidDet här är designen.

Sessionstext: Vad vi spårar (säkert)

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"
}

Lägg märke till vad som är inte Här: IP-adresser, användaragenter, fullständiga webbadresser, spårning pixlar. Vi fångar kontextmönster, ej identifierbar information.

Vad är signaler?

En förteckning över de behöriga myndigheter som avses i punkt 1 i denna artikel ska upprättas i enlighet med det förfarande som avses i artikel 4 i förordning (EU) nr 952/2013. signal I stället för att spåra "vem" spårar vi "vad som hände".

Detta koncept kommer från Efemära signaler—kortlivade fakta som finns i ett avgränsat fönster och åldras ut naturligt. I det systemet sänder operationer ut signaler som "api.rate_limited" eller "gateway.slow" för att samordna beteendet utan täta kopplingar.

Här tillämpar vi samma mönster på användarens beteende:

  • Produktvyproduct_view signal (vikt: 0,10)
  • Lägg till i kundvagnenadd_to_cart signal (vikt: 0,35)
  • Inköppurchase signal (vikt: 1,00)

Signaler är Förgängliga (upphör med sessionen). Noll-PII (ingen identitet) och viktad Jag är medveten om detta.

För att länka sessioner utan att spåra cookies använder vi Fingeravtryck från klientsidan. Webbläsaren beräknar en hash från signaler (timezone, skärmupplösning, WebGL render, canvas fingeravtryck) och skickar bara haschen till /api/fingerprint.

Servern sedan HMACs som hash med en hemlig nyckel, vilket gör den plats-scoped och oanvändbar någon annanstans.

Denna kod är anpassad från min Botdetekteringsprojekt, där det används för att identifiera skrapor. Samma teknik, annat syfte.

// 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 }));

Serversidan:

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

Nu har vi en stabil, plats-skopad identifierare utan cookies eller localStorage. Se Fullständigt fingeravtryck.js källa (anpassad från mestadels lucid.botdetektion).

Signaltyper och vikter

Olika åtgärder har olika intentionsnivåer. Vi modellerar detta med Grundvikter.

Signaltyper: Vikthierarkin

// 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);
    }
}

Därför är detta viktigt:

  • En vy på en sida (0.01) inte kommer att dominera signalen
  • Lägga till i kundvagnen (0.35) är en stark intention signal
  • Inköp (1.00) är den starkaste signalen

Denna hierarki förhindrar att "drive-by surfing" förorenar profilen.

Sessionskollektor: Inspelningssignaler (endast cache)

// 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;
}

Snabbt eftersom:

  • Ren in-minne (ingen DB skriver)
  • Ingen serialisering overhead (MemoryCache)
  • Inga nätverkssamtal (lokal cache)

Persistenta profiler: Förhöjda signaler

När en session visar hög intention (cart lägger till, köper), höjer vi signaler till en persistent profil.

PersistentProfileEntity: Den långsiktiga profilen

// 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;
}

Fortfarande noll PII:

  • ProfileKey är en HMAC- hash (inte reversibel)
  • IdentificationMode berättar hur det identifierades (fingeravtryck/cookies/login)
  • Alla uppgifter är beteendesignaler, inte personuppgifter

Höjd: Session → Profil

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);
}

När höjning sker:

  • Efter en kundvagn lägg till (hög intention)
  • Efter ett köp (konvertering)
  • När användaren väljer att fortsätta (fingeravtryck/cookies/login)

Segmentdefinitioner: Utbetalningen

Vi har retat segment för två delar. åtgärdbar utmatning av all denna signalsamling – de svarar "vad för slags shoppare är detta?"

Varför fuzzy medlemskap?

Traditionell segmentering är binär: du är antingen i segmentet eller inte. Detta skapar problem:

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

En kund med 4 köp behandlas identiskt med en med 0. Det är fel.

Fuzzy segmentering ger varje profil en poäng (0-1):

på köpet på Binary på Fuzzy Score |-----------|--------|-------------| Obligatoriska uppgifter som krävs för att utföra de uppgifter som avses i punkt 1 i denna artikel. 2 0. 4 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. på grund av att det inte finns tillräckligt med tid för att utföra de uppgifter som krävs för att utföra de uppgifter som krävs för att utföra de uppgifter som krävs för att utföra de uppgifter som krävs för att utföra de uppgifter som krävs för att utföra de uppgifter som avses i punkt 1 i denna artikel. 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -

Nu kan du anpassa proportionellt: "nästan högt värde" kunder får något annorlunda behandling än "ingenstans nära".

SegmentDefinitionsstrukturen

// 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
}

Regeltyper: Vad du kan kontrollera

Varje regel utvärderar en dimension av profilen:

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)
}

Reglerande verksamhetsutövare

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
}

Regelkombinationsmetoder

Hur flera regler kombineras till en slutpoäng:

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)
}

Viktad är vanligast – det låter dig säga "kategori intresse spelar roll 60%, recency spelar roll 30%, varumärke affinitet spelar 10%."

Exempel på verkliga segment

Här är standardsegmenten i urvalsprojektet:

1. Tech Entusiasts

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" 
        }
    ]
}

Exempel på utvärdering:

  • Profilen har interests.tech = 0.72, affinities.gadgets = 0.31, affinities.electronics = 0.15
  • Regel 1: 0,72 >= 0,4 → score 1,0 (överstiger tröskelvärdet)
  • Regel 2: 0.31 > = 0,2 → score 1.0 (överstiger tröskelvärdet)
  • Regel 3: 0,15 < 0,2 → score 0,75 (delvis: 0,15/0,2)
  • Slutlig: (1, 0× 0, 6 + 1, 0× 0, 2 + 0, 75× 0, 2) / 1, 0 = 0.95
  • 0,95 > = 0,35 tröskel → Medlem med 95 % förtroende

2. Cart övergivna

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" 
        }
    ]
}

Detta fångar "Lägger till kundvagnen men köper inte" mönster – perfekt för återhämtningskampanjer.

3. Högvärdeskunder

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. Förhörsjägare

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" 
        }
    ]
}

Alla förvalda segment

Urvalet omfattar 10 segment som omfattar gemensamma e-handelsmönster:

på Segment på Icon på nyckelregler på användningsfall på |---------|------|-----------|----------| Högvärdeskunder på 3+ köp, hög konsumtion, senaste VIP-behandling, lojalitetsprogram . Teknologi Entusiasts på Tech intresse, gadget affinitet på Tech produkt rekommendationer Fashion Forward med modeintresse, flera besök och stilrekommendationer Low price range, preferes deals och säljanmälningar på nya besökare på på ≤ 2 sessioner, inga inköp ombord, erbjudanden om första köp på kundvagnen Överlämnare och 3+ kundvagnen lägger till, få inköp på Recovery e-post med Hem Entusiasts och hem intresse, senaste aktiviteten och hem produkt korsförsäljning Sportintresse, hälsoegenskaper, fitness produktfokus 5+ inköp, 10+ sessioner, bevarande, belöningar på forskare med hög signal, bläddrar i stor utsträckning på Jämförelse verktyg, detaljerad information .

Segmentservice: Computing medlemskap

och SegmentService utvärderar profiler mot alla segmentregler:

// 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"
        }
    };
}

Inbyggd förklaringsförmåga

Varje medlemskap resultat inkluderar faktiska värden som ledde till poängen:

// 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" }
    ]
}

Användare kan se exakt varför Detta är avgörande för transparens och GDPR-efterlevnad.

Signalflöde: End-to-End

Så här blir en produktvy ett segmentmedlemskap:

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

Utkorgsmönstret (förhandsgranskning)

Alla betydande åtgärder går genom Utkorgsmönster—Vår primära orkestreringsmekanism:

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

Varför? Affärsdata och händelser skrivs i en enda transaktion. Händelser kan inte gå förlorade. Misslyckanden försök automatiskt med exponentiell backoff.

// 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

Häfte 3 täcker den fullständiga utkorgen implementationen: jobbkön, LISTEN/NOTIFY för omedelbar pickup, försök logik och skalmönster.

Vad händer härnäst?

Denna del omfattade följande:

  • Zero-PII-profilarkitektur (ephemerala sessioner vs ihållande profiler)
  • Definitioner av segment med luddigt medlemskap och viktade regler
  • Segmenttjänsten med inbyggd förklaringsförmåga

Häfte 3 går djupare in i:

  • Genomförande av utkorgsmönster - tillförlitlig händelsepublicering och en enda router
  • Jobbkö med PostgreSQL SKIP LOCKED för distribuerad bearbetning
  • LISTA/INTIFIKA för omedelbar jobbhämtning (ingen röstning)
  • UI och öppenhet - "Dina intressen" instrumentpanelen och segment Explorer

Segment är den verkställbara utgången av detta system. De svarar "vilken typ av shopper är detta?" med fuzzy poäng, inte binära hinkar. Och varje användare kan se exakt varför de är i ett segment.

Finding related posts...
logo

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