# Zero PII -asiakastiedustelu – Osa 2: Profiileja, signaaleja ja segmenttejä

<!--category-- Product, Privacy, Segmentation, C# -->
<datetime class="hidden">2025-12-26T20:00</datetime>

Sisään [Osa 1](/blog/zero-pii-customer-intelligence-part1) Käsittelimme filosofiaa ja sarjakatsausta. In [Osa 1.1](/blog/zero-pii-customer-intelligence-part1-1) rakensimme näytedatageneraattorin.

Nyt rakennetaan ydinjärjestelmä. Tässä osassa keskitytään seuraaviin asioihin:

1. **Nolla-PII-profiiliarkkitehtuuri** - Ekseeriset istunnot vs. pysyvät profiilit
2. **Signaalit ja painot** - Mitä seuraamme ja miten se kerääntyy
3. **Segmentin määritelmät** - Fuzzyn jäsenyys painotettujen sääntöjen kanssa (päätapahtuma)
4. **Outbox-kuvion esikatselu** - Luotettava tapahtumajulkaisu (yksityiskohta osassa 3)

Tämä on **Otosprojekti** (`Mostlylucid.SegmentCommerce`) – pieni verkkokauppademo, joka näyttää ydinkuviot. Konsepteja on tarkoituksellisesti yksinkertaistettu osoittamaan selkeästi, mutta se sisältää myös todelliset infrastruktuurikuviot (outbox-pohjainen viestinkäsittely, taustatyön käsittely, JSONB-indeksointi), jotka skaalautuisivat laaja-alaisesti tuotantoon.

Ajattele tätä "tuotantokuviona yhden sovelluksen muodossa". Sama koodirakenne toimii riippumatta siitä, pyöritätkö yhtä prosessia tai jakeletko palveluja.

[TOC]

## Teknologian valinnat

Valitsimme **yksinkertainen, yhtenäinen pino** Näyte on pidettävä puhtaana samalla, kun se osoittaa tuotantomalleja.

### PostgreSQL + pgvector: Yksi tietokanta

Erillisten vektoritietokantojen, viestijonojen ja välimuistikerrosten sijaan käytämme **PostgreSQL**:

```mermaid
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** Joustavat skeemat (ei erillistä NOSQL:ää)
- **pgvector** upotukset (ei Qdrant/Pinecone)
- **KUNNOSTELU/NEUVOTTELU** työpaikkoja varten (ei Redis/RabbitMQ)
- **tsvektori** täystekstiksi (ei Elastista hakua)

Yksi yhteysallas, yksi apujoukko, yksi laukaisu.

### HTMX + Alppi.js: Server-Driven UI

```html
<!-- Instant search (no page reload) -->
<input 
    hx-get="/api/search" 
    hx-trigger="keyup changed delay:300ms" 
    hx-target="#results" />
```

- **HTMX**: Palvelin tekee osittaisia (ei JSON → templating)
- **Alppi.js**: Reaktiivisuus ilman rakennusaskelta
- **Progressiivinen parannus**: Toimii ilman JS:ää, parempi sen kanssa

SPA:n kaltainen UX, jossa on serverinpoiston yksinkertaisuus.

### ASP.NET Core: Jaettavat mallit

Yksi sovellus, mutta kuvioiden skaala:

- **Outbox** (DB-tapahtumat) → Vaihda viestibussiin
- **Työjono** (LISTA/NÖYTÄKIRJA) → Vaihdetaan työntekijäpooliin
- **Istunnon keräilijä** → Vaihda erilliseen API-rajapintaan

Aloita yksinkertaisesti ja jaa tarvittaessa.

### Mitä me välttelimme

- Ei erillistä vektoritietokantaa (pgvector on sijoitettu)
- Ei JS-kehystä (HTMX + Alppi on yksinkertaisempi)
- Mikropalveluita ei vielä ole (mallistot toimivat monoliittisesti tai jakautuvat)
- Ei Docker Composite rönsyilyä (yksi DB, yksi sovellus)

## Nolla PII -haaste

Perinteinen käyttäjäseuranta tallentaa tunnistettavia tietoja: nimiä, sähköposteja, käyttäytymiseen liittyviä käyttäjätunnuksia. GDPR- ja yksityisyydensuoja-asetukset tekevät tästä yhä ongelmallisemman. Lähestymistapamme on erilainen:

**Me säilytämme käyttäytymismalleja, emme henkilöllisyyksiä.**

```mermaid
flowchart LR
    subgraph "Traditional Approach"
        User1[User: john@email.com] --> 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
```

Avainnäkemys: **Sinun ei tarvitse tietää, kuka on joku, joka tietää, mistä on kiinnostunut**.

## Arkkitehtuurin yleiskatsaus

Järjestelmässä on kolme ydinkerrosta:

```mermaid
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. **Istunnon keräilijä** - Kaappaa käyttäytymisen signaaleja (näkymiä, klikkauksia, kärryjä lisää) - **Vain muistin sisällä**
2. **Jatkuva profiili** - nostaa korkea-arvoisia signaaleja, laskee korkoja - **tietokannan tukemat**
3. **Segmenttipalvelu** - Arvioi profiileja sääntöjen vastaisesti, määrää jäsenyyksiä

**Kriittinen ero**: Sessiot ovat ohimeneviä, profiilit sinnikkäitä. Tämä ei ole toteutus yksityiskohta, vaan yksityisyysarkkitehtuurin päätös.

## Istuntoprofiilit: Tiukka muisti (LFU Cache)

Istunnot **tiukasti muistissa**He asuvat täällä. `IMemoryCache` ja liukuvan vanhenemisen ja **älä koskaan koske tietokantaan**.

Tämä on vaikea arkkitehtoninen rajoitus: istuntotiedot eivät voi jatkua. Se kerää signaaleja vierailun aikana ja häätää 30 minuutin toimettomuuden jälkeen LFU:n (vähiten käytetty) välimuistikäytännön kautta muistin paineessa.

### SessionProfile: In-Memory-malli

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

### Säilytä IMemoryCachessa (tai IDivedCachessa) liukuvan lopun kanssa

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

**Kovat rajoitteet:**

- **Vahingossa pysyvyys nolla**: Sessiot suorassa lähetyksessä **Ainoastaan** välimuistissa (IMemoryCache tai IDiveddedCache, kuten Redis)
- **LFU:n häätö**: Vähäkäyttöiset istunnot häädetään ensin muistipaineiden alla
- **Liukastumisen päättyminen**: 30 minuuttia edellisestä toiminnasta
- **Evition takaisinkutsu**Viimeinen mahdollisuus nostaa korkea-arvoisia signaaleja ennen kuin ne katoavat
- **Ei toipumista**: Sovella uudelleen = kaikki istunnot hukattu (ellei käytä IDiveddedCachea, mutta silti effemeral)

### Korotuspäätös (Eviction)

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

**Miksi häätää takaisin?**

Tämä on **Ainoastaan** Turvallinen kohta päättää sinnikkyydestä. Kun välimuisti häätää istunnon:

1. Tunnemme koko istuntohistorian
2. Voimme arvioida kokonaisuutta
3. Vältämme matala-arvoisten istuntojen tallentamista (yksisivuinen näkymä, pomppiminen)
4. Takaamme, että istunnot eivät vahingossa jatku

Jos emme kohota häädön aikana, istunto on **iäksi poissa**Tämä on suunnitelma.

### SessionContext: Mitä seuraamme (Turvallisesti)

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

Huomaa, mitä on **ei** Tässä: IP-osoitteet, käyttäjä-agentit, täydelliset URL-osoitteet, jäljityspikselit. **kontekstikuviot**, ei tunnistettavia tietoja.

## Mitä ovat signaalit?

A **signaali** Sen sijaan, että jäljitämme "kuka", jäljitämme "mitä tapahtui".

Tämä konsepti on peräisin [hetkelliset signaalit](/blog/ephemeral-signals)-lyhytikäiset faktat, jotka esiintyvät suljetussa ikkunassa ja vanhenevat luonnollisesti. `"api.rate_limited"` tai `"gateway.slow"` koordinoimaan käyttäytymistä ilman tiukkaa kytkentää.

Sovellamme tässä samaa kaavaa käyttäjän käyttäytymiseen:

- **Tuotekuva** → `product_view` signaali (paino: 0,10)
- **Lisää ostoskoriin** → `add_to_cart` Signaali (paino: 0,35)
- **Ostot** → `purchase` Signaali (paino: 1,0)

Signaalit ovat **hetkellinen** (istunnosta luovutaan) **nolla-PII** (ei henkilöllisyyttä) ja **painotettu** (tietoinen).

### Asiakkaiden sormenjälkitunnistus (Zero-Cookie Identification)

Yhdistääksemme istunnot ilman evästeiden seuraamista, käytämme **Asiakkaiden sormenjälkien ottaminen**. Selain laskee hash signaaleista (aikavyöhyke, näytön tarkkuus, WebGL-palvelin, kankaan sormenjälki) ja lähettää **vain hasista** Kohtiin `/api/fingerprint`.

Palvelin sitten **HMACs joka hash** Salaisella avaimella, joka tekee siitä paikannetun ja käyttökelvottoman muualla.

Tämä koodi on mukautettu minun [bottihavaitsemisprojekti](/blog/botdetection-introduction)Sama tekniikka, eri tarkoitus.

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

**Palvelimen puoli:**

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

Nyt meillä on vakaa, paikan päällä varustettu tunniste ilman evästeitä tai paikallista tallenninta. [Koko sormenjälki.js-lähde](https://github.com/scottgal/mostlylucidweb/blob/main/Mostlylucid.SegmentCommerce/ClientFingerprint/fingerprint.js) (mukautettu suurimmaksi osaksi lucid.bottihavainnosta).

## Signaalityypit ja -painot

Erilaisilla toimilla on erilaiset aietasot. **peruspainot**.

### SignalTypes: Painon hierarkia

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

**Miksi tällä on merkitystä:**

- Yhden sivun näkymä (`0.01`) ei hallitse signaalia
- Lisää ostoskoriin (`0.35`) on vahva aiesignaali
- Ostot (`1.00`) on vahvin signaali

Tämä hierarkia estää "ajetun selaamisen" saastuttamasta profiilia.

## SessionCollector: Tallennussignaalit (Cache-Only)

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

**Nopea, koska:**

- Puhdasta muistia (ei DB:tä)
- Ei sarjanumerointia yleisellä tasolla (ImemoryCache)
- Ei verkkopuheluja (paikallinen välimuisti)

## Jatkuvat profiilit: Kohonneet signaalit

Kun istunto osoittaa suurta aikomusta (auto lisää, ostokset), nostamme signaaleja **jatkuva profiili**.

### ContinuousProfileEntity: Pitkän aikavälin profiili

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

**Ei vieläkään PII:tä**:

- `ProfileKey` on HMAC- hasis (ei palautuva)
- `IdentificationMode` kertoo, miten se tunnistettiin (sormenjälki/eväste/login)
- Kaikki tiedot ovat **käyttäytymiseen liittyviä signaaleja**, ei henkilökohtaisia tietoja

### Nousu: Session → Profile

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

**Kun nousu tapahtuu:**

- Kärryn jälkeen lisää (korkea aikomus)
- Ostoksen jälkeen (muunnos)
- Kun käyttäjä valitsee sinnikkyyden (sormenjälki/eväste/login)

## Segmentin määritelmät: Lahjoitus

Olemme kiusanneet segmenttejä kahteen osaan. Nyt toimitetaan. Segmentit ovat **actionable output** kaikesta tuosta signaalikokoelmasta he vastaavat: "Millainen shoppailija tämä on?"

### Miksi Fuzzyn jäsenyys?

Perinteinen segmentointi on binääristä: olet joko segmentissä tai et. Tämä aiheuttaa ongelmia:

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

Neljällä ostoksella varustettua asiakasta kohdellaan samalla tavalla kuin nollalla varustettua asiakasta.

**Fuzzy-segmentointi** antaa jokaiselle profiilille pisteet (0-1):

Ostot Binary, 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
2. nro 0.4
4 Nro 0.8 Nro 0.8 Nro 0.8 Nro 0.8 Nro 0.0 Nro 0.0 Nro 0.0 Nro 0.0 Nro 0.0 Nro 0.0 Nro 0.0 Nro 0.0 Nro 0.0 Nro 0.0 Nro 0.0 Nro 0.0 Nro 0.0 Nro 0.0 Nro 0.0.
5+ Kyllä 1,0

Nyt voit personoida suhteellisesti: "lähes korkea-arvoisia" asiakkaita kohdellaan hieman eri tavalla kuin "ei lähelläkään".

### Segmenttimäärittelyrakenne

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

### Sääntötyypit: Mitä voit tarkistaa

Kullakin säännöllä arvioidaan profiilin yhtä ulottuvuutta:

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

### Säännöt Operaattorit

```csharp
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
}
```

### Sääntöjen yhdistelmämenetelmät

Kuinka monta sääntöä yhdistyvät lopulliseksi tulokseksi:

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

**Painotettu** on yleisintä – se antaa sanoa, että "kategorian kiinnostuksella on merkitystä 60 prosentilla, rehellisyydellä 30 prosentilla, brändin affiniteettilla 10 prosentilla".

### Todellisia segmenttejä

Tässä ovat näyteprojektin oletussegmentit:

#### 1. Teknologiainnostajat

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

**Arviointiesimerkki:**

- Profile on `interests.tech = 0.72`, `affinities.gadgets = 0.31`, `affinities.electronics = 0.15`
- Sääntö 1: 0,72 > = 0,4 → pisteet 1,0 (ylityskynnys)
- Sääntö 2: 0,31 > = 0,2 → pisteet 1,0 (ylityskynnys)
- Sääntö 3: 0,15 < 0,2 → pisteet 0,75 (osittain: 0,15/0,2)
- Lopullinen: (1,0 x 0,6 + 1,0 x 0,2 + 0,75 x 0,2) / 1,0 = **0.95**
- 0,95 > = 0,35 kynnys → **Jäsen 95 prosentin itseluottamuksella**

#### 2. Karttojen hylkääjät

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

Tämä saa "lisää kärryille, mutta ei osta" -kuvion, joka on täydellinen elvytyskampanjoita varten.

#### 3. Korkea-arvoiset asiakkaat

```csharp
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. Kaupankäyntiä harjoittavat metsästäjät

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

### Kaikki oletuserät

Otokseen kuuluu 10 segmenttiä, jotka kattavat yhteiset sähköisen kaupankäynnin mallit:

Segmentissä Iconin avainsäännöt Käytä asiaa
|---------|------|-----------|----------|
Korkea-arvoiset asiakkaat 3+ ostokset, suuret kulut, VIP-kohtelu, kanta-asiakasohjelmat
Tech-innostajat Tech-intressit, vempainten affiniteetti Tech-tuotesuositukset
Fashion Forward Muoti kiinnostaa, useita vierailuja tyylisuositukset
Alhaisen hintaluokan kauppiaat suosivat kauppoja ja myynti-ilmoituksia
Uusia vierailijoita ≤ 2 istuntoa, ei ostoksia, ensiostotarjouksia
Cart Hylkääjät 3+ -kärry lisää, vähän ostoksia Elvytyssähköposteja
Etusivu Kiinnostuksen kohteet, tuore toiminta Home product cross-sell
Kuntoilu Aktiivinen Urheilu Kiinnostus, terveysominaisuudet Kuntoilutuotteiden painotus
Uskollisia asiakkaita 5+ ostoksia, 10+ istuntoa Pidätys, palkitseminen
Korkeat signaalit, laaja-alainen vertailutyökalu, yksityiskohtaiset tiedot

## SegmentSegmentService: Tietojenkäsittelyjäsenyys

Erytropoietiini `SegmentService` arvioi profiileja kaikkien segmentin sääntöjen mukaisesti:

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

### Selittävyys Sisäänrakennettu

Jokainen jäsentulos sisältää **todelliset arvot** se johti maalintekoon:

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

Käyttäjät voivat **katso tarkkaan, miksi** Tämä on tärkeää läpinäkyvyyden ja GDPR:n noudattamisen kannalta.

## Signaalivirta: Päätepiste

Näin tuotekuvasta tulee segmentin jäsen:

```mermaid
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
```

## Outbox-kuvio (esikatselu)

Kaikki merkittävät toimet kulkevat **Outbox-kuvio**Pääjärjestämisjärjestelmämme:

```mermaid
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
```

**Miksi?** Liiketiedot ja tapahtumat kirjoitetaan yhdessä tapahtumassa. Tapahtumat eivät voi hävitä. Epäonnistumiset yrittävät uudelleen automaattisesti eksponentiaalisella takaiskulla.

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

**3 osa** kattaa koko outbox-toteutuksen: työjonon, LINENEN/NOTIFYn pikapoimintaan, uudelleen kokeilemaan logiikkaa ja skaalauskuvioita.

## Mitä seuraavaksi?

Tässä osassa käsiteltiin seuraavia seikkoja:

- **Nolla-PII-profiiliarkkitehtuuri** (hetkelliset hetket vs. pysyvät profiilit)
- **Segmentin määritelmät** Sumealla jäsenyydellä ja painotetuilla säännöillä
- **Segmenttipalvelu** jossa on sisäänrakennettu selitys

**3 osa** mennään syvemmälle:

- **Outbox-mallien toteutus** - luotettava tapahtumajulkaisu ja yksittäinen reititin
- **Työjono** PostgreSQL:n kanssa `SKIP LOCKED` jaettavaksi
- **KUNNOSTELU/NEUVOTTELU** pikatyöpoimintaan (ei kannatuskyselyitä)
- **UI ja läpinäkyvyys** - "Sinun etusi" -kojelauta ja segmentin tutkimusmatkailija

---


*Segmentit ovat tämän järjestelmän toimintakelpoinen tuloste. Ne vastaavat "mikäs shoppailija tämä on?" sumeilla pisteillä, eivät binääriämpäreillä. Ja jokainen käyttäjä näkee tarkalleen, miksi he ovat segmentissä.*