perf: memo GridCard, fuse save fns, fix slash tab active color
This commit is contained in:
211
docs/referral-system-design.md
Normal file
211
docs/referral-system-design.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Memento — Spécification Technique & Design du Système de Parrainage (Referral)
|
||||
|
||||
Ce document présente l'architecture complète d'un système de parrainage (referral) natif pour **Momento**, intégré à notre base de données PostgreSQL (Prisma) et à Stripe.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectifs du Système
|
||||
|
||||
1. **Viralité (PLG)** : Encourager les utilisateurs existants à partager Momento pour acquérir de nouveaux clients sans budget publicitaire (CAC proche de 0).
|
||||
2. **Double Récompense (Win-Win)** :
|
||||
- **Le Filleul (Invité)** obtient une réduction immédiate lors de son premier abonnement (ex. `-10 % sur son abonnement PRO`).
|
||||
- **Le Parrain (Hôte)** obtient une récompense lors du premier paiement de son filleul (ex. **1 mois gratuit** appliqué directement sur sa prochaine facture Stripe, ou **+100 crédits IA** récurrents).
|
||||
3. **Simplicité & Automatisation** : Le parrainage doit se faire par lien unique et être validé automatiquement via notre webhook Stripe actuel.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture de Données (Prisma)
|
||||
|
||||
Pour suivre les relations de parrainage et distribuer les récompenses de manière sécurisée et sans doublons, nous proposons d'étendre le schéma Prisma actuel.
|
||||
|
||||
```prisma
|
||||
// memento-note/prisma/schema.prisma
|
||||
|
||||
model User {
|
||||
// ... champs existants ...
|
||||
|
||||
// Système de Parrainage
|
||||
referralCode String @unique // Code unique généré pour l'utilisateur (ex: "SEPEHR50")
|
||||
referredById String? // ID du parrain qui l'a invité
|
||||
referredBy User? @relation("UserReferrals", fields: [referredById], references: [id])
|
||||
referrals User[] @relation("UserReferrals")
|
||||
|
||||
referralRewards ReferralReward[] // Historique des récompenses distribuées
|
||||
}
|
||||
|
||||
model ReferralReward {
|
||||
id String @id @default(cuid())
|
||||
userId String // Le parrain récompensé
|
||||
refereeId String // Le filleul qui a déclenché la récompense
|
||||
rewardType String // "FREE_MONTH" (Stripe) ou "AI_CREDITS" (Redis)
|
||||
status String @default("PENDING") // "PENDING", "COMPLETED", "FAILED"
|
||||
stripeTxId String? // ID de la transaction de crédit Stripe si applicable
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Le Parcours Utilisateur pas à pas
|
||||
|
||||
### 3.1 Génération et Partage du Lien
|
||||
1. Chaque utilisateur inscrit reçoit automatiquement un code unique lors de la création de son compte (généré à partir de son nom + 4 chiffres uniques, ou un cuid court).
|
||||
2. Un nouvel onglet **"Parrainage"** est ajouté dans `/settings/billing` :
|
||||
- Affiche son code promo de parrainage : `MOMENTO-SEPEHR-1234`
|
||||
- Affiche son lien d'invitation unique : `https://memento-note.com/signup?ref=MOMENTO-SEPEHR-1234`
|
||||
- Affiche un bouton de partage rapide (LinkedIn, X, Email, Clipboard).
|
||||
|
||||
### 3.2 Inscription du Filleul (Cookie & localStorage)
|
||||
1. Le filleul clique sur le lien `https://memento-note.com/signup?ref=MOMENTO-SEPEHR-1234`.
|
||||
2. Le frontend intercepte le paramètre `ref` dans l'URL et le stocke de manière persistante dans un cookie ou dans le `localStorage` (durée : 30 jours).
|
||||
3. Lorsque le filleul valide son formulaire d'inscription :
|
||||
- Le serveur lit le code promo de parrainage dans la requête.
|
||||
- S'il est valide, il associe le `referredById` du nouveau compte `User` à l'ID du parrain.
|
||||
|
||||
### 3.3 Achat de l'Abonnement et Déclenchement de la Récompense
|
||||
1. Lors du checkout sur le plan Pro, le filleul utilise le code promo Stripe `MOMENTO-SEPEHR-1234` (qui est configuré dans votre Stripe Dashboard comme un code promotionnel avec une réduction de 10%).
|
||||
2. Une fois le paiement validé, Stripe émet l'événement `checkout.session.completed` à notre webhook `/api/billing/webhook`.
|
||||
3. Notre helper `sync-subscription-from-stripe.ts` traite l'activation de l'abonnement :
|
||||
- Il vérifie en base de données si le nouvel abonné possède un parrain (`referredById !== null`).
|
||||
- Il vérifie si une récompense pour ce couple Parrain/Filleul a déjà été créée (évite le double-déclenchement).
|
||||
- Il déclenche la distribution des récompenses.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implémentation Technique des Récompenses
|
||||
|
||||
### Option A (Recommandée) : Offrir 1 mois gratuit sur Stripe au Parrain
|
||||
Pour récompenser le parrain de manière entièrement automatisée, nous utilisons le système de **Solde Client (Customer Balance)** de Stripe. On ajoute un crédit négatif (ex. `-9.90 €`) sur le compte client Stripe du parrain. Lors de son prochain renouvellement, Stripe déduira automatiquement ce crédit, ce qui lui offrira son mois gratuit.
|
||||
|
||||
```typescript
|
||||
// lib/billing/referral.ts
|
||||
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function processReferralReward(refereeUserId: string) {
|
||||
// 1. Récupérer le filleul et son parrain
|
||||
const referee = await prisma.user.findUnique({
|
||||
where: { id: refereeUserId },
|
||||
include: { subscription: true }
|
||||
});
|
||||
|
||||
if (!referee || !referee.referredById) return;
|
||||
|
||||
const parrainId = referee.referredById;
|
||||
|
||||
// 2. Vérifier si la récompense est déjà octroyée
|
||||
const existingReward = await prisma.referralReward.findFirst({
|
||||
where: { userId: parrainId, refereeId: refereeUserId }
|
||||
});
|
||||
if (existingReward) return;
|
||||
|
||||
// 3. Récupérer l'abonnement Stripe du parrain
|
||||
const parrainSub = await prisma.subscription.findUnique({
|
||||
where: { userId: parrainId }
|
||||
});
|
||||
|
||||
if (!parrainSub || !parrainSub.stripeCustomerId) {
|
||||
// Si le parrain n'est pas encore client Stripe ou n'a pas d'abonnement actif,
|
||||
// on peut lui créditer des crédits IA en bonus à la place
|
||||
await awardAiCredits(parrainId, 200); // ex: +200 crédits IA
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 4. Créer une transaction de solde Stripe (Créditer son compte de 9,90 €)
|
||||
const amountInCents = 990; // Le prix du plan PRO
|
||||
const balanceTransaction = await stripe.customers.createBalanceTransaction(
|
||||
parrainSub.stripeCustomerId,
|
||||
{
|
||||
amount: -amountInCents, // Un montant NÉGATIF crédite le client Stripe
|
||||
currency: 'eur',
|
||||
description: `Récompense de parrainage pour l'invitation de ${referee.email}`,
|
||||
}
|
||||
);
|
||||
|
||||
// 5. Enregistrer la récompense dans notre base PostgreSQL
|
||||
await prisma.referralReward.create({
|
||||
data: {
|
||||
userId: parrainId,
|
||||
refereeId: refereeUserId,
|
||||
rewardType: 'FREE_MONTH',
|
||||
status: 'COMPLETED',
|
||||
stripeTxId: balanceTransaction.id
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[Referral] Parrain ${parrainId} récompensé avec succès d'un mois gratuit !`);
|
||||
} catch (err) {
|
||||
console.error('[Referral] Erreur lors de l\'octroi du crédit Stripe :', err);
|
||||
|
||||
await prisma.referralReward.create({
|
||||
data: {
|
||||
userId: parrainId,
|
||||
refereeId: refereeUserId,
|
||||
rewardType: 'FREE_MONTH',
|
||||
status: 'FAILED'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option B : Offrir des crédits IA (Redis) au Parrain
|
||||
Si le parrain a un compte gratuit, on peut simplement augmenter son quota de crédits IA ou lui offrir des crédits "boost" à vie.
|
||||
|
||||
```typescript
|
||||
// lib/billing/referral.ts
|
||||
import { redis } from '@/lib/redis';
|
||||
|
||||
async function awardAiCredits(userId: string, amount: number) {
|
||||
// Ajouter des crédits bonus dans Redis pour la période en cours
|
||||
const period = getCurrentPeriodKey();
|
||||
const key = `usage:${userId}:semantic_search:bonus`;
|
||||
await redis.incrby(key, amount);
|
||||
|
||||
await prisma.referralReward.create({
|
||||
data: {
|
||||
userId,
|
||||
refereeId: refereeUserId,
|
||||
rewardType: 'AI_CREDITS',
|
||||
status: 'COMPLETED'
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Intégration Webhook Stripe (Finalisation)
|
||||
|
||||
Dans le webhook `app/api/billing/webhook/route.ts`, lors du traitement de `checkout.session.completed`, il suffit d'appeler notre fonction de parrainage :
|
||||
|
||||
```typescript
|
||||
// app/api/billing/webhook/route.ts
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const userId = session.metadata?.userId;
|
||||
|
||||
if (userId) {
|
||||
// 1. Synchroniser l'abonnement
|
||||
await syncSubscriptionFromStripe(subscription, userId);
|
||||
|
||||
// 2. Traiter le parrainage si applicable
|
||||
await processReferralReward(userId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Plan de Lancement & Coupon Stripe (No-Code)
|
||||
|
||||
Pour que ce système fonctionne immédiatement :
|
||||
1. Créez un **Coupon** général de `10%` sur Stripe nommé "Parrainage Filleul".
|
||||
2. Dans Stripe, créez un **Code de Promotion orienté client** attaché à ce coupon.
|
||||
3. Configurez ce code promo avec le motif suivant : **Autoriser les codes de promotion générés par les utilisateurs** ou créez un code parrain par défaut.
|
||||
4. Dans le code de l'application, nous générons automatiquement le code de parrainage de l'utilisateur lors de son premier partage et l'associons à son compte.
|
||||
761
docs/story-chunk-embeddings.md
Normal file
761
docs/story-chunk-embeddings.md
Normal file
@@ -0,0 +1,761 @@
|
||||
# Story: Embeddings par Fragments — Indexation Sémantique Multi-Niveaux
|
||||
|
||||
> **Epic:** Fondation IA — Recherche & Insights Sémantiques
|
||||
> **ID:** US-CHUNK-EMBEDDINGS
|
||||
> **Priority:** Critical — Fondation pour Workstreams B (AI Overview), Memory Echo précis, Insights
|
||||
> **Status:** ready-for-dev
|
||||
> **Depends on:** pgvector existant (✅), `embedding.service.ts` (✅), `NoteEmbedding` (✅)
|
||||
> **Blocks:** US-AI-OVERVIEW, US-CALENDAR (indépendant), qualité Memory Echo et Insights
|
||||
> **Inspiration:** AppFlowy `flowy-ai/src/embeddings/` — chunking + dedup par hash de contenu
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
### Problème actuel
|
||||
|
||||
Momento stocke **un seul vecteur par note** (`NoteEmbedding` dans `prisma/schema.prisma:366-374`). L'embedding est généré par `EmbeddingService.generateNoteEmbedding()` (`lib/ai/services/embedding.service.ts:43-63`) qui :
|
||||
|
||||
1. Concatène titre + corps en plain text
|
||||
2. Découpe en chunks de 6000 chars (`splitPlainTextForEmbeddingChunks`)
|
||||
3. Embed chaque chunk via l'API
|
||||
4. **Mean-pool** les vecteurs en un seul vecteur moyen
|
||||
|
||||
Cette approche a trois limites majeures :
|
||||
|
||||
| Limite | Impact |
|
||||
|--------|--------|
|
||||
| **Perte de précision** — une note de 5000 mots sur 10 sujets différents a un vecteur moyen flou | Memory Echo rate les connexions spécifiques à une section |
|
||||
| **Re-embed complet à chaque modif** — corriger une typo re-embedde toute la note | Coût API × nombre d'éditions ; latence inutile |
|
||||
| **Pas de snippets de match** — la recherche retourne un score global mais pas le passage précis | L'utilisateur ne sait pas *pourquoi* une note match |
|
||||
|
||||
### Solution proposée
|
||||
|
||||
Inspiré d'AppFlowy (`flowy-ai/src/embeddings/document_indexer.rs`, `scheduler.rs`) :
|
||||
|
||||
1. **Découper chaque note en fragments sémantiques** (~1000 chars, coupure aux limites de paragraphes)
|
||||
2. **Embedder chaque fragment indépendamment** dans une nouvelle table `NoteEmbeddingChunk`
|
||||
3. **Hasher le contenu de chaque fragment** (`sha256`) pour ne re-embed que les fragments modifiés
|
||||
4. **Rechercher au niveau fragment** avec agrégation par note → snippets précis en résultat
|
||||
5. **Garder `NoteEmbedding` existant** pour rétro-compat (recherche globale, clustering)
|
||||
|
||||
### Comparaison AppFlowy → Momento
|
||||
|
||||
| Aspect | AppFlowy (Rust) | Momento (TypeScript) |
|
||||
|--------|-----------------|---------------------|
|
||||
| Chunking | `text_splitter` crate, 1000 chars / 200 overlap | `lib/text/note-chunking.ts`, même valeurs |
|
||||
| Hash | `xxhash64` (Rust) | `crypto.createHash('sha256')` (Node natif) |
|
||||
| Vector DB | SQLite-vec (local) | pgvector (PostgreSQL, déjà en prod) |
|
||||
| Scheduler | Tokio channels (generate → write) | `p-queue` ou Bull (Redis déjà présent) |
|
||||
| Dedup | Compare `fragment_id` en DB | Identique — `@@unique([noteId, fragmentId])` |
|
||||
| Embedding model | `nomic-embed-text` (Ollama local) | `text-embedding-3-small` (OpenAI, déjà configuré) |
|
||||
|
||||
---
|
||||
|
||||
## Migration Prisma requise
|
||||
|
||||
```prisma
|
||||
model NoteEmbeddingChunk {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
fragmentId String // sha256(noteId + content) — stable pour le dedup
|
||||
chunkIndex Int // ordre du fragment dans la note
|
||||
content String // texte plain du fragment (~1000 chars)
|
||||
charCount Int // longueur du fragment (audit / debug)
|
||||
embedding Unsupported("vector(1536)")?
|
||||
embeddingModel String? @default("text-embedding-3-small")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([noteId, fragmentId])
|
||||
@@index([noteId])
|
||||
@@index([fragmentId])
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **Migration additive uniquement** — safe, pas de perte de données. La table `NoteEmbedding` existante est conservée intacte.
|
||||
|
||||
### Étapes de migration
|
||||
|
||||
1. **Dump DB obligatoire** : `bash /home/devparsa/dev/Momento/dump-db.sh` — vérifier ≥1Mo — « OUI » explicite utilisateur
|
||||
2. `npx prisma migrate dev --name add_note_embedding_chunks`
|
||||
3. Créer l'index HNSW sur la colonne `embedding` :
|
||||
```sql
|
||||
CREATE INDEX "NoteEmbeddingChunk_embedding_hnsw_idx"
|
||||
ON "NoteEmbeddingChunk"
|
||||
USING hnsw ("embedding" vector_cosine_ops)
|
||||
WITH (m = 16, ef_construction = 64);
|
||||
```
|
||||
> L'index HNSW doit être créé manuellement (Prisma ne gère pas les index vectoriels). À mettre dans la migration SQL brute.
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
### US-CHUNK-1 : Chunking sémantique d'une note
|
||||
|
||||
**En tant que** système d'indexation,
|
||||
**Je veux** découper le contenu d'une note en fragments sémantiques cohérents,
|
||||
**Afin de** produire des embeddings précis au niveau section plutôt qu'au niveau note entière.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Étant donné** une note avec un titre et un contenu HTML/plain
|
||||
- **Quand** le service de chunking est appelé
|
||||
- **Alors** le contenu est converti en plain text (via `stripHtmlToPlainText` existant)
|
||||
- **Et** découpé aux limites de paragraphes (`\n\n`) en groupes de ~1000 caractères max
|
||||
- **Et** chaque fragment chevauche le précédent de ~200 caractères (préserve le contexte aux frontières)
|
||||
- **Et** chaque fragment reçoit un `fragmentId` = `sha256(noteId + "::" + fragmentContent)` (stable, déterministe)
|
||||
- **Et** si un paragraphe dépasse 1000 chars, il est sous-découpé à la phrase la plus proche (regex sur `. ! ? ؟ !。`)
|
||||
- **Et** si le contenu total fait moins de 1000 chars, un seul fragment est produit
|
||||
- **Et** les fragments vides (< 10 chars après trim) sont filtrés
|
||||
|
||||
#### Paramètres de chunking :
|
||||
|
||||
```typescript
|
||||
const CHUNK_TARGET_CHARS = 1000 // taille cible par fragment
|
||||
const CHUNK_OVERLAP_CHARS = 200 // chevauchement entre fragments consécutifs
|
||||
const MIN_FRAGMENT_CHARS = 10 // en dessous, le fragment est ignoré
|
||||
const MAX_PARAGRAPH_SPLIT = 1500 // au-dessus, un paragraphe est sous-découpé
|
||||
```
|
||||
|
||||
> Ces valeurs sont alignées sur AppFlowy (`document_indexer.rs:31`) et la recherche RAG standard (medium chunks 256-1024 tokens).
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-2 : Indexation incrémentale avec dedup
|
||||
|
||||
**En tant que** système,
|
||||
**Je veux** ne re-embed que les fragments qui ont réellement changé,
|
||||
**Afin de** réduire le coût API et la latence lors des éditions mineures.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Étant donné** une note qui a déjà des fragments indexés en DB
|
||||
- **Quand** l'utilisateur modifie un paragraphe et sauvegarde
|
||||
- **Alors** le service :
|
||||
1. Récupère les `fragmentId`s existants : `SELECT fragmentId FROM "NoteEmbeddingChunk" WHERE noteId = $1`
|
||||
2. Chunk le contenu actuel → produit de nouveaux fragments avec leurs `fragmentId`s
|
||||
3. Compare les hash :
|
||||
- **Fragments inchangés** (hash identique) → **skip**, pas d'embed
|
||||
- **Fragments nouveaux** (hash absent en DB) → **embed et insère**
|
||||
- **Fragments supprimés** (hash en DB mais absent des nouveaux) → **DELETE de la DB**
|
||||
4. Embed uniquement les fragments nouveaux (batch de max 100 fragments par appel API)
|
||||
5. Insère en DB via upsert transactionnel
|
||||
- **Et** si l'utilisateur corrige une typo dans un paragraphe, seul ce fragment est re-embeddé (pas toute la note)
|
||||
- **Et** si l'utilisateur ajoute un nouveau paragraphe à la fin, seul le nouveau fragment est embeddé
|
||||
- **Et** les fragments supprimés sont nettoyés (pas d'orphelins)
|
||||
|
||||
#### Gestion de la concurrence :
|
||||
|
||||
- Utiliser un **verrou par noteId** (mutex en mémoire ou lock Redis `lock:chunk-index:{noteId}`)
|
||||
- Si une indexation est déjà en cours pour cette note, la nouvelle requête est mise en file d'attente (ou débounce 3s puis prend le relais)
|
||||
- Pas de race condition sur les upserts (`@@unique([noteId, fragmentId])` + `ON CONFLICT DO UPDATE`)
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-3 : Pipeline d'indexation asynchrone
|
||||
|
||||
**En tant que** système,
|
||||
**Je veux** que l'indexation des fragments se fasse en arrière-plan sans bloquer la sauvegarde,
|
||||
**Afin de** préserver la fluidité de l'édition.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Quand** une note est sauvegardée (débounce existant dans le flow de save)
|
||||
- **Alors** l'indexation est déclenchée **de manière asynchrone** (fire-and-forget côté serveur)
|
||||
- **Et** l'utilisateur ne perçoit aucune latence liée à l'embedding
|
||||
- **Et** l'indexation utilise une **queue avec concurrence limitée** (max 4 embeddings simultanés) pour éviter le rate-limit API
|
||||
- **Et** en cas d'erreur API (rate limit, timeout), le fragment est **réessayé** avec backoff exponentiel (3 tentatives max)
|
||||
- **Et** en cas d'échec définitif, l'erreur est loggée mais ne bloque pas l'application
|
||||
|
||||
#### Implémentation de la queue :
|
||||
|
||||
Utiliser `p-queue` (léger, pas de dépendance Redis supplémentaire) ou Bull (Redis déjà présent à `memento-redis:6379`). Préférer **`p-queue`** pour simplicité — un singleton process-level :
|
||||
|
||||
```typescript
|
||||
import PQueue from 'p-queue'
|
||||
const chunkEmbeddingQueue = new PQueue({ concurrency: 4 })
|
||||
```
|
||||
|
||||
> Si Momento passe multi-process (PM2 cluster), migrer vers Bull. Pour l'instant, single-process Next.js → `p-queue` suffit.
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-4 : Recherche sémantique au niveau fragment
|
||||
|
||||
**En tant qu'** utilisateur,
|
||||
**Je veux** que la recherche sémantique trouve des passages précis dans mes notes,
|
||||
**Afin de** comprendre pourquoi une note correspond à ma recherche.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Étant donné** que l'utilisateur saisit une requête dans la SearchModal
|
||||
- **Quand** la recherche sémantique s'exécute
|
||||
- **Alors** le système effectue **deux recherches en parallèle** :
|
||||
1. **Recherche fragment-level** (nouvelle) : `pgvector` sur `NoteEmbeddingChunk.embedding`
|
||||
2. **Recherche note-level** (existante) : `pgvector` sur `NoteEmbedding.embedding` + FTS `tsvector`
|
||||
- **Et** les résultats sont fusionnés via RRF (Reciprocal Rank Fusion, déjà implémenté dans `semantic-search.service.ts`)
|
||||
- **Et** chaque résultat inclut désormais un champ `matchedSnippets: string[]` (top 3 fragments par score, max 200 chars chacun, avec `...` aux coupures)
|
||||
- **Et** les snippets sont affichés dans l'UI de recherche sous le titre de la note (mise en gras du terme si présent)
|
||||
|
||||
#### Requête SQL fragment-level :
|
||||
|
||||
```sql
|
||||
-- Recherche vectorielle au niveau fragment, agrégée par note
|
||||
WITH chunk_scores AS (
|
||||
SELECT
|
||||
c."noteId",
|
||||
MAX(1 - (c."embedding"::vector <=> $1::vector)) AS best_score,
|
||||
ARRAY_AGG(c.content ORDER BY 1 - (c."embedding"::vector <=> $1::vector) DESC) AS ranked_contents
|
||||
FROM "NoteEmbeddingChunk" c
|
||||
JOIN "Note" n ON n.id = c."noteId"
|
||||
WHERE n."userId" = $2
|
||||
AND c."embedding" IS NOT NULL
|
||||
AND 1 - (c."embedding"::vector <=> $1::vector) >= $3 -- threshold
|
||||
GROUP BY c."noteId"
|
||||
LIMIT $4
|
||||
)
|
||||
SELECT
|
||||
cs."noteId",
|
||||
cs.best_score,
|
||||
cs.ranked_contents[1:3] AS top_snippets -- top 3 fragments
|
||||
FROM chunk_scores cs
|
||||
ORDER BY cs.best_score DESC
|
||||
```
|
||||
|
||||
> Le `[1:3]` est du slicing PostgreSQL natif sur les arrays. Chaque snippet est tronqué à 200 chars côté application.
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-5 : Memory Echo précis au niveau fragment
|
||||
|
||||
**En tant qu'** utilisateur,
|
||||
**Je veux** que Memory Echo détecte des connexions entre sections spécifiques de notes,
|
||||
**Afin de** découvrir des résonances même entre notes majoritairement différentes.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Étant donné** deux notes A et B indexées en fragments
|
||||
- **Quand** Memory Echo calcule la similarité entre A et B
|
||||
- **Alors** au lieu de comparer `embedding(A)` vs `embedding(B)` (note entière)
|
||||
- **Le système compare** la **meilleure paire de fragments** : `MAX(similarity(fragmentAi, fragmentBj))` sur tous les cross-joins
|
||||
- **Et** si la meilleure paire dépasse le seuil (`SEMANTIC_SIMILARITY_FLOOR`), une connexion est créée
|
||||
- **Et** l'insight Memory Echo affiche le **snippet précis** qui a résonné (pas juste le score global)
|
||||
- **Et** le filtre temporel (`MIN_DAYS_APART`) reste identique
|
||||
|
||||
#### Requête SQL (cross-join fragment) :
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
a."noteId" AS note1_id,
|
||||
b."noteId" AS note2_id,
|
||||
MAX(1 - (a."embedding"::vector <=> b."embedding"::vector)) AS best_fragment_similarity,
|
||||
-- Récupérer le contenu des fragments qui matchent le mieux
|
||||
(
|
||||
SELECT a2.content
|
||||
FROM "NoteEmbeddingChunk" a2
|
||||
WHERE a2."noteId" = a."noteId"
|
||||
AND 1 - (a2."embedding"::vector <=> b."embedding"::vector) = MAX(1 - (a."embedding"::vector <=> b."embedding"::vector))
|
||||
LIMIT 1
|
||||
) AS note1_snippet,
|
||||
(
|
||||
SELECT b2.content
|
||||
FROM "NoteEmbeddingChunk" b2
|
||||
WHERE b2."noteId" = b."noteId"
|
||||
AND 1 - (a2."embedding"::vector <=> b2."embedding"::vector) = MAX(...)
|
||||
LIMIT 1
|
||||
) AS note2_snippet
|
||||
FROM "NoteEmbeddingChunk" a
|
||||
JOIN "NoteEmbeddingChunk" b ON a."noteId" < b."noteId" -- évite les doublons et self-joins
|
||||
JOIN "Note" na ON na.id = a."noteId"
|
||||
JOIN "Note" nb ON nb.id = b."noteId"
|
||||
WHERE na."userId" = $1
|
||||
AND nb."userId" = $1
|
||||
AND a."embedding" IS NOT NULL
|
||||
AND b."embedding" IS NOT NULL
|
||||
GROUP BY a."noteId", b."noteId"
|
||||
HAVING MAX(1 - (a."embedding"::vector <=> b."embedding"::vector)) >= $2
|
||||
```
|
||||
|
||||
> ⚠️ **Performance** : ce cross-join est coûteux. L'exécuter uniquement pour la note qui vient d'être indexée (pas sur toutes les paires à chaque fois). Pour la note N nouvellement indexée : `JOIN` ses fragments contre TOUS les autres fragments, `MAX()` par noteId cible, filtrer par seuil.
|
||||
|
||||
#### Flux Memory Echo mis à jour :
|
||||
|
||||
```
|
||||
Note N sauvegardée → chunks indexés → pour chaque autre note M :
|
||||
best_similarity = MAX over (fragment_N_i, fragment_M_j)
|
||||
if best_similarity >= THRESHOLD and passesTimeDiversityFilter(N, M):
|
||||
create MemoryEchoInsight with note1_snippet + note2_snippet
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-6 : Migration rétroactive des notes existantes
|
||||
|
||||
**En tant qu'** admin/système,
|
||||
**Je veux** indexer en fragments toutes les notes qui ont déjà un embedding global,
|
||||
**Afin de** bénéficier de la précision fragment-level sur le corpus existant.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Étant donné** que des notes ont un `NoteEmbedding` mais pas de `NoteEmbeddingChunk`
|
||||
- **Quand** l'admin lance le script de migration
|
||||
- **Alors** pour chaque note (batch de 50) :
|
||||
1. Récupérer le plain text (titre + corps)
|
||||
2. Chunker en fragments
|
||||
3. Embedder chaque fragment (respect du rate-limit API)
|
||||
4. Insérer en DB
|
||||
- **Et** une barre de progression affiche : `1234 / 5678 notes (21%) — ETA: ~8 min`
|
||||
- **Et** le script est **interruptible** (Ctrl+C safe — reprend là où il s'est arrêté via un checkpoint)
|
||||
- **Et** le script est **idempotent** (les notes déjà chunkées sont skip)
|
||||
- **Et** le script logge les erreurs par note mais continue
|
||||
- **Et** le coût API estimé est affiché avant confirmation
|
||||
|
||||
#### Script :
|
||||
|
||||
```bash
|
||||
# Lancement manuel (pas en CI)
|
||||
npx tsx scripts/migrate-chunk-embeddings.ts --batch-size=50 --concurrency=4
|
||||
```
|
||||
|
||||
#### Estimation de coût :
|
||||
|
||||
- ~1 note moyenne = ~3000 chars = ~4 fragments
|
||||
- `text-embedding-3-small` : $0.02 / 1M tokens ≈ $0.02 / 750K chars
|
||||
- 1000 notes × 4 fragments × 1000 chars = 4M chars ≈ $0.10
|
||||
- **Négligeable** pour la plupart des corpus
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-7 : Affichage des snippets dans la SearchModal
|
||||
|
||||
**En tant qu'** utilisateur,
|
||||
**Je veux** voir les passages précis qui correspondent à ma recherche,
|
||||
**Afin de** évaluer rapidement la pertinence d'un résultat.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Étant donné** des résultats de recherche avec `matchedSnippets`
|
||||
- **Quand** ils sont affichés dans la SearchModal
|
||||
- **Alors** sous chaque titre de note, les top 1-2 snippets sont affichés (max 150 chars visibles, `...` aux coupures)
|
||||
- **Et** le terme de recherche est **surligné en gras** dans les snippets
|
||||
- **Et** les snippets sont en couleur secondaire (text-muted, plus petit que le titre)
|
||||
- **Et** si aucun snippet fragment-level n'est disponible (note pas encore migrée), le comportement actuel est conservé (extrait du début de la note)
|
||||
|
||||
#### Design :
|
||||
|
||||
- Consulter le prototype `architectural-grid/` → `SearchModal` pour le design exact des snippets dans les résultats
|
||||
- Style Google-like : snippet gris sous le titre, terme de recherche en gras
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-8 : Nettoyage à la suppression de note
|
||||
|
||||
**En tant que** système,
|
||||
**Je veux** que les fragments d'une note supprimée soient automatiquement supprimés,
|
||||
**Afin de** ne pas laisser de vecteurs orphelins.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Quand** une note est supprimée
|
||||
- **Alors** tous ses `NoteEmbeddingChunk` sont supprimés en cascade (`onDelete: Cascade` dans le schéma Prisma)
|
||||
- **Et** aucun vecteur orphelin ne subsiste
|
||||
- **Et** les Memory Echo référençant cette note sont nettoyés (comportement existant)
|
||||
|
||||
> Le `onDelete: Cascade` sur la relation `note` dans le schéma gère cela automatiquement au niveau DB.
|
||||
|
||||
---
|
||||
|
||||
## Fichiers à créer / modifier
|
||||
|
||||
| Fichier | Action | Notes |
|
||||
|---------|--------|-------|
|
||||
| `prisma/schema.prisma` | Modifier | Ajouter modèle `NoteEmbeddingChunk` + relation inverse sur `Note` |
|
||||
| `prisma/migrations/xxx_add_note_embedding_chunks/` | Créer | Migration + SQL brut pour index HNSW |
|
||||
| `lib/text/note-chunking.ts` | **Créer** | Logique de chunking sémantique + hash |
|
||||
| `lib/ai/services/chunk-indexing.service.ts` | **Créer** | Service d'indexation incrémentale (dedup, queue, retry) |
|
||||
| `lib/ai/services/semantic-search.service.ts` | Modifier | Ajouter `vectorChunkSearch()` + fusion RRF avec existant + snippets |
|
||||
| `lib/ai/services/memory-echo.service.ts` | Modifier | Similarité au niveau fragment + snippets dans insights |
|
||||
| `lib/ai/services/embedding.service.ts` | Modifier | Ajouter `generateChunkEmbeddings(texts: string[])` (batch optimisé) |
|
||||
| `app/api/notes/[id]/route.ts` (ou hook de save) | Modifier | Déclencher `chunkIndexingService.indexNote()` après save |
|
||||
| `components/search/search-result-item.tsx` (ou équivalent) | Modifier | Afficher `matchedSnippets` |
|
||||
| `scripts/migrate-chunk-embeddings.ts` | **Créer** | Script de migration rétroactive avec checkpoint |
|
||||
| `locales/en.json` + `locales/fr.json` | Modifier | Clés `search.snippets.*` (minimal) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture technique
|
||||
|
||||
### Pipeline d'indexation (flow complet)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Éditeur (TipTap) │
|
||||
│ User tape → debounce 2s → POST /api/notes/[id] (save) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Note Save Handler (server-side) │
|
||||
│ 1. Sauvegarde contenu en DB (existant) │
|
||||
│ 2. Re-embed NoteEmbedding global (existant, conservé) │
|
||||
│ 3. fire-and-forget → chunkIndexingService.indexNote(noteId) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ (async, non-bloquant)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ChunkIndexingService.indexNote(noteId) │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. Récupérer note (title + content) depuis DB │ │
|
||||
│ │ 2. plain = prepareNoteTextForEmbedding(title, content) │ │
|
||||
│ │ 3. chunks = chunkNoteContent(noteId, plain) │ │
|
||||
│ │ → [{fragmentId, content, chunkIndex}, ...] │ │
|
||||
│ │ 4. existingIds = SELECT fragmentId WHERE noteId │ │
|
||||
│ │ 5. newChunks = chunks WHERE fragmentId NOT IN existing │ │
|
||||
│ │ 6. staleIds = existingIds NOT IN chunks.fragmentIds │ │
|
||||
│ │ 7. DELETE WHERE noteId AND fragmentId IN staleIds │ │
|
||||
│ │ 8. Queue: embed newChunks → INSERT │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ p-queue (concurrency: 4) │
|
||||
│ Pour chaque nouveau fragment : │
|
||||
│ embedding = await embeddingService.embedPlainText(content) │
|
||||
│ INSERT INTO NoteEmbeddingChunk (fragmentId, content, │
|
||||
│ embedding, ...) ON CONFLICT DO UPDATE │
|
||||
│ Retry: 3 tentatives, backoff exponentiel (1s, 2s, 4s) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Chunking — algorithme détaillé
|
||||
|
||||
```typescript
|
||||
// lib/text/note-chunking.ts
|
||||
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
const CHUNK_TARGET_CHARS = 1000
|
||||
const CHUNK_OVERLAP_CHARS = 200
|
||||
const MIN_FRAGMENT_CHARS = 10
|
||||
const MAX_PARAGRAPH_BEFORE_SPLIT = 1500
|
||||
|
||||
export interface NoteChunk {
|
||||
fragmentId: string
|
||||
content: string
|
||||
chunkIndex: number
|
||||
charCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Découpe une note en fragments sémantiques.
|
||||
* Port TS de flowy-ai/src/embeddings/document_indexer.rs
|
||||
*/
|
||||
export function chunkNoteContent(
|
||||
noteId: string,
|
||||
plainText: string,
|
||||
): NoteChunk[] {
|
||||
const normalized = plainText.trim()
|
||||
if (normalized.length < MIN_FRAGMENT_CHARS) return []
|
||||
|
||||
// 1. Split par paragraphes (double newline)
|
||||
const paragraphs = normalized
|
||||
.split(/\n\s*\n/)
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length >= MIN_FRAGMENT_CHARS)
|
||||
|
||||
if (paragraphs.length === 0) return []
|
||||
|
||||
// 2. Sous-découper les paragraphes trop longs
|
||||
const atomicParagraphs: string[] = []
|
||||
for (const para of paragraphs) {
|
||||
if (para.length > MAX_PARAGRAPH_BEFORE_SPLIT) {
|
||||
atomicParagraphs.push(...splitLongParagraph(para, CHUNK_TARGET_CHARS))
|
||||
} else {
|
||||
atomicParagraphs.push(para)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Grouper les paragraphes en chunks de ~CHUNK_TARGET_CHARS
|
||||
const groups = groupParagraphsByMaxContentLen(
|
||||
atomicParagraphs,
|
||||
CHUNK_TARGET_CHARS,
|
||||
CHUNK_OVERLAP_CHARS,
|
||||
)
|
||||
|
||||
// 4. Hash + construire les NoteChunk
|
||||
const chunks: NoteChunk[] = []
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const content = groups[i]
|
||||
if (content.length < MIN_FRAGMENT_CHARS) continue
|
||||
|
||||
const fragmentId = hashFragment(noteId, content)
|
||||
// Dédup : si le même fragmentId existe déjà (paragraphe répété), skip
|
||||
if (chunks.some((c) => c.fragmentId === fragmentId)) continue
|
||||
|
||||
chunks.push({
|
||||
fragmentId,
|
||||
content,
|
||||
chunkIndex: i,
|
||||
charCount: content.length,
|
||||
})
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
function hashFragment(noteId: string, content: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${noteId}::${content}`)
|
||||
.digest('hex')
|
||||
.slice(0, 32) // 32 chars = 128 bits, largement suffisant pour le dedup
|
||||
}
|
||||
|
||||
function splitLongParagraph(para: string, maxLen: number): string[] {
|
||||
// Coupe aux fins de phrase (. ! ? ؟ !。) les plus proches de maxLen
|
||||
const sentences = para.split(/(?<=[.!?؟!。])\s+/)
|
||||
const chunks: string[] = []
|
||||
let current = ''
|
||||
|
||||
for (const sentence of sentences) {
|
||||
if ((current + ' ' + sentence).length > maxLen && current) {
|
||||
chunks.push(current.trim())
|
||||
current = sentence
|
||||
} else {
|
||||
current = current ? `${current} ${sentence}` : sentence
|
||||
}
|
||||
}
|
||||
if (current.trim()) chunks.push(current.trim())
|
||||
|
||||
// Si une phrase seule dépasse maxLen, coupe dur au mot le plus proche
|
||||
return chunks.flatMap((chunk) =>
|
||||
chunk.length > maxLen * 1.5 ? hardSplitByWords(chunk, maxLen) : [chunk],
|
||||
)
|
||||
}
|
||||
|
||||
function hardSplitByWords(text: string, maxLen: string): string[] {
|
||||
// Coupe au mot le plus proche de maxLen
|
||||
// ...
|
||||
}
|
||||
|
||||
/**
|
||||
* Groupe les paragraphes en chunks de taille ~maxContentLen
|
||||
* avec un overlap optionnel pour préserver le contexte.
|
||||
* Port de group_paragraphs_by_max_content_len (document_indexer.rs:147)
|
||||
*/
|
||||
function groupParagraphsByMaxContentLen(
|
||||
paragraphs: string[],
|
||||
maxLen: number,
|
||||
overlap: number,
|
||||
): string[] {
|
||||
if (paragraphs.length === 0) return []
|
||||
if (overlap > maxLen) overlap = maxLen / 2
|
||||
|
||||
const result: string[] = []
|
||||
let current = ''
|
||||
|
||||
for (const para of paragraphs) {
|
||||
if (current.length + para.length > maxLen && current) {
|
||||
result.push(current.trim())
|
||||
// Préserve l'overlap : reprend les derniers `overlap` chars
|
||||
const tail = current.slice(-overlap)
|
||||
current = tail + para
|
||||
} else {
|
||||
current = current ? `${current}\n\n${para}` : para
|
||||
}
|
||||
}
|
||||
if (current.trim()) result.push(current.trim())
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
### Recherche fragment-level — intégration dans SemanticSearchService
|
||||
|
||||
```typescript
|
||||
// Ajout à lib/ai/services/semantic-search.service.ts
|
||||
|
||||
export interface SearchResult {
|
||||
noteId: string
|
||||
title: string | null
|
||||
content: string
|
||||
score: number
|
||||
matchType: 'exact' | 'related'
|
||||
language?: string | null
|
||||
matchedSnippets?: string[] // NOUVEAU — top fragments qui matchent
|
||||
}
|
||||
|
||||
class SemanticSearchService {
|
||||
// ... existant ...
|
||||
|
||||
/**
|
||||
* NOUVEAU — Recherche vectorielle au niveau fragment.
|
||||
* Retourne les notes triées par meilleure similarité de fragment.
|
||||
*/
|
||||
private async vectorChunkSearch(
|
||||
queryEmbedding: number[],
|
||||
userId: string,
|
||||
threshold: number,
|
||||
notebookId: string | null,
|
||||
limit: number,
|
||||
): Promise<SearchResult[]> {
|
||||
const vecStr = `[${queryEmbedding.join(',')}]`
|
||||
const params: any[] = [vecStr, userId, threshold, limit]
|
||||
|
||||
let notebookJoin = ''
|
||||
if (notebookId) {
|
||||
notebookJoin = `AND n."notebookId" = $${params.length + 1}`
|
||||
params.push(notebookId)
|
||||
}
|
||||
|
||||
const sql = `
|
||||
WITH chunk_scores AS (
|
||||
SELECT
|
||||
c."noteId",
|
||||
MAX(1 - (c."embedding"::vector <=> $1::vector)) AS best_score,
|
||||
ARRAY_AGG(
|
||||
c.content
|
||||
ORDER BY 1 - (c."embedding"::vector <=> $1::vector) DESC
|
||||
) AS ranked_contents
|
||||
FROM "NoteEmbeddingChunk" c
|
||||
JOIN "Note" n ON n.id = c."noteId"
|
||||
WHERE n."userId" = $2
|
||||
AND c."embedding" IS NOT NULL
|
||||
AND 1 - (c."embedding"::vector <=> $1::vector) >= $3
|
||||
${notebookJoin}
|
||||
GROUP BY c."noteId"
|
||||
LIMIT $4
|
||||
)
|
||||
SELECT
|
||||
cs."noteId",
|
||||
n.title,
|
||||
cs.best_score,
|
||||
cs.ranked_contents[1:3] AS top_snippets
|
||||
FROM chunk_scores cs
|
||||
JOIN "Note" n ON n.id = cs."noteId"
|
||||
ORDER BY cs.best_score DESC
|
||||
`
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe(sql, ...params)
|
||||
return rows.map((row: any) => ({
|
||||
noteId: row.noteId,
|
||||
title: row.title,
|
||||
content: row.top_snippets?.[0] ?? '',
|
||||
score: row.best_score,
|
||||
matchType: 'related' as const,
|
||||
matchedSnippets: (row.top_snippets ?? []).map((s: string) =>
|
||||
s.length > 200 ? s.slice(0, 200) + '...' : s,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche hybride mise à jour :
|
||||
* FTS + vectorChunkSearch + vectorNoteSearch → RRF fusion
|
||||
*/
|
||||
async search(query: string, userId: string, opts: SearchOptions): Promise<SearchResult[]> {
|
||||
// ... existant : FTS ...
|
||||
|
||||
// NOUVEAU : recherche fragment-level en parallèle de la recherche note-level
|
||||
const [keywordResults, noteResults, chunkResults] = await Promise.all([
|
||||
this.keywordSearch(query, userId, opts),
|
||||
this.vectorSearch(queryEmbedding, userId, opts),
|
||||
this.vectorChunkSearch(queryEmbedding, userId, opts.threshold, opts.notebookId, opts.limit),
|
||||
])
|
||||
|
||||
// RRF sur les 3 sources
|
||||
const fused = this.reciprocalRankFusion([
|
||||
...keywordResults,
|
||||
...noteResults,
|
||||
...chunkResults,
|
||||
])
|
||||
|
||||
// Merge snippets : si un résultat a des snippets des chunks, les propager
|
||||
return this.mergeSnippets(fused, chunkResults)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risques et mitigations
|
||||
|
||||
| Risque | Probabilité | Impact | Mitigation |
|
||||
|--------|-------------|--------|------------|
|
||||
| Cross-join fragment coûteux pour Memory Echo | Moyenne | Latence | N'exécuter que pour la note nouvellement indexée (pas sur tout le corpus à chaque fois) |
|
||||
| Coût API embedding x4-5 (plus de fragments) | Faible | $$ | Dedup par hash → seule une fraction est re-embeddée après la migration initiale |
|
||||
| Rate-limit API OpenAI | Moyenne | Blocage | Queue avec concurrence 4 + retry backoff exponentiel |
|
||||
| Migration longue sur gros corpus | Faible | UX admin | Script batch avec checkpoint, interruptible, reprise auto |
|
||||
| Index HNSW non créé (oubli migration) | Faible | Recherche lente | Vérifier l'existence de l'index au démarrage (`getDbDimension` déjà existe pour ça) |
|
||||
| Fragments orphelins si note déplacée entre carnets | Faible | Bruit | `onDelete: Cascade` + nettoyage au move (déjà géré pour `NoteEmbedding`) |
|
||||
|
||||
---
|
||||
|
||||
## Métriques à tracker
|
||||
|
||||
| Métrique | Objectif | Mesure |
|
||||
|----------|----------|--------|
|
||||
| Précision Memory Echo | +30% connexions pertinentes | Taux de feedback positif sur les insights |
|
||||
| Latence recherche sémantique | < 200ms (P95) | Telemetry sur `semantic-search.service.ts` |
|
||||
| Coût API embedding / mois | Stable vs actuel | Compter les appels `embedPlainText` |
|
||||
| Snippets affichés dans recherche | > 80% des résultats | Telemetry sur SearchModal |
|
||||
| Fragments par note (moyenne) | 3-5 | Requête analytique sur `NoteEmbeddingChunk` |
|
||||
|
||||
---
|
||||
|
||||
## Clés i18n (minimal — la majorité est invisible côté UI)
|
||||
|
||||
```json
|
||||
{
|
||||
"search": {
|
||||
"snippets": {
|
||||
"matchedSection": "Section correspondante"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> La fonctionnalité est principalement backend — peu de libellés visibles. Les snippets sont du contenu de note (pas du texte à traduire).
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'implémentation recommandé
|
||||
|
||||
1. **Migration Prisma** — modèle `NoteEmbeddingChunk` + index HNSW
|
||||
2. **`lib/text/note-chunking.ts`** — logique de chunking pure (testable isolément)
|
||||
3. **`lib/ai/services/chunk-indexing.service.ts`** — service d'indexation (dedup, queue)
|
||||
4. **Intégration au hook de save** — déclenchement async
|
||||
5. **Script de migration** — indexer le corpus existant
|
||||
6. **Recherche fragment-level** — `vectorChunkSearch` + fusion RRF + snippets
|
||||
7. **UI snippets dans SearchModal** — affichage des passages précis
|
||||
8. **Memory Echo fragment-level** — similarité par meilleure paire de fragments
|
||||
9. **Validation end-to-end** — créer une note longue, vérifier chunks en DB, rechercher, Memory Echo
|
||||
|
||||
---
|
||||
|
||||
## Définition de Done (DoD)
|
||||
|
||||
- [ ] Migration Prisma appliquée en dev, dump DB validé avant
|
||||
- [ ] `chunkNoteContent()` produit des fragments cohérents (vérifié sur 5 notes de test : courte, longue, multilingue FR/FA, HTML clippé, vide)
|
||||
- [ ] Indexation incrémentale : modifier une typo ne re-embed qu'un fragment (vérifié en DB)
|
||||
- [ ] Recherche sémantique retourne des `matchedSnippets` pertinents
|
||||
- [ ] SearchModal affiche les snippets avec le terme surligné
|
||||
- [ ] Memory Echo détecte des connexions au niveau section
|
||||
- [ ] Script de migration complète le corpus sans erreur
|
||||
- [ ] Aucune régression sur la recherche existante (note-level toujours fonctionnelle)
|
||||
- [ ] `NoteEmbedding` (global) toujours maintenu pour rétro-compat
|
||||
- [ ] Pas de `revalidatePath` systématique (mutations optimistes)
|
||||
- [ ] Pas de tests écrits sauf demande explicite (AGENTS.md)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Conservation de `NoteEmbedding`** : la table existante reste le source of truth pour le clustering (`clustering.service.ts`) et la recherche globale. Les chunks sont une **couche additive** qui améliore la précision, pas un remplacement.
|
||||
- **AppFlowy utilise `nomic-embed-text` (768 dims, local via Ollama)**. Momento utilise `text-embedding-3-small` (1536 dims, OpenAI). La dimension est différente — le schéma `NoteEmbeddingChunk` doit spécifier `vector(1536)`.
|
||||
- **Performance pgvector** : l'index HNSW est crucial pour les requêtes fragment-level. Sans index, un scan séquentiel sur des milliers de fragments serait prohibitif. L'index doit être créé dans la migration SQL brute.
|
||||
- **Persan / RTL** : le chunking par paragraphes fonctionne indépendamment de la langue. Le split par fin de phrase (`؟ !。`) couvre les scripts RTL et CJK. Vérifier que les embeddings `text-embedding-3-small` gèrent bien le persan (déjà validé pour la recherche existante).
|
||||
@@ -449,3 +449,44 @@ Devrait retourner :
|
||||
| Enterprise | Sur devis | Sur devis | — |
|
||||
|
||||
Devise : EUR (configurable dans Stripe Dashboard pour multi-devises).
|
||||
|
||||
---
|
||||
|
||||
## 11. Bons de réduction & Codes de promotion (Coupons & Promo Codes)
|
||||
|
||||
Momento intègre le support natif et sécurisé de Stripe pour les codes promotionnels lors du paiement en Embedded Checkout via l'attribut `allow_promotion_codes: true` dans `/api/billing/create-checkout/route.ts`.
|
||||
|
||||
### 11.1 Concepts Clés : Bon de réduction (Coupon) vs Code de promotion (Promo Code)
|
||||
Dans Stripe, la gestion des remises se fait en deux niveaux :
|
||||
1. **Le Bon de réduction (Coupon)** : C'est la règle financière sous-jacente (ex. `-20 % sur l'abonnement pendant 6 mois` ou `-10 € à vie`). Un coupon n'est pas vu par le client final sous cette forme.
|
||||
2. **Le Code de promotion (Promo Code)** : C'est la chaîne de caractères réelle saisie par le client (ex. `WELCOME20`, `LAUNCH50`). Un code promo est obligatoirement rattaché à un Coupon. Vous pouvez créer plusieurs codes promos pour un seul et même coupon (ex. `INFLUENCEUR1` et `INFLUENCEUR2` qui appliquent tous les deux la même réduction de -10%).
|
||||
|
||||
### 11.2 Comment créer un Code Promo sur Stripe (Dashboard)
|
||||
1. Connectez-vous à votre **Stripe Dashboard** (en mode test ou live selon votre cible).
|
||||
2. Rendez-vous dans **Produits** (Products) → **Bons de réduction** (Coupons) → bouton **+ Nouveau**.
|
||||
3. Remplissez les informations du Coupon :
|
||||
- **Nom** : Le nom interne pour vous y retrouver (ex: `Lancement 50%`).
|
||||
- **Type de réduction** : Pourcentage (ex: `50%`) ou Montant fixe (ex: `10 €`).
|
||||
- **Durée** :
|
||||
- *Une seule fois* (appliqué uniquement sur la première facture).
|
||||
- *Plusieurs mois* (Spécifier le nombre de mois, ex: 3 mois).
|
||||
- *Pour toujours* (appliqué à vie sur toutes les factures de l'abonnement).
|
||||
4. Sous le bloc **Codes de promotion**, cochez **"Créer un code de promotion orienté client"** :
|
||||
- **Code** : Saisissez le code en majuscules (ex: `LAUNCH50`).
|
||||
- **Limites d'utilisation** (optionnel) :
|
||||
- Nombre d'utilisations max (ex: limité aux 100 premiers utilisateurs).
|
||||
- Date limite de validité (ex: valable uniquement jusqu'au 31 décembre).
|
||||
- Limiter aux clients n'ayant jamais payé.
|
||||
- Restriction à des plans spécifiques (ex: restreindre ce code promo uniquement au produit `Momento Pro`).
|
||||
5. Cliquez sur **Créer le bon de réduction**.
|
||||
|
||||
### 11.3 Comment désactiver ou supprimer un Code Promo
|
||||
1. Allez dans **Produits** → **Bons de réduction** → **Codes de promotion**.
|
||||
2. Cliquez sur les `...` à côté du code promo concerné.
|
||||
3. Sélectionnez **Désactiver** (Deactivate) : le code ne sera plus utilisable par aucun client, mais les clients en ayant déjà bénéficié conserveront leur réduction active selon la durée définie (ex. s'ils ont déjà eu les 3 mois, Stripe continue de l'appliquer jusqu'à la fin de la période).
|
||||
4. Si vous souhaitez supprimer définitivement un coupon global et annuler la réduction pour tout le monde (y compris les abonnés actuels), allez dans **Bons de Réduction**, cliquez sur le coupon puis sur **Supprimer**.
|
||||
|
||||
### 11.4 Mode Test vs Production
|
||||
* **En local / Test** : Créez vos codes de promotion dans Stripe en ayant activé le commutateur **Mode Test** (Test Mode). Les codes créés ici ne fonctionneront qu'avec vos clés d'API de test (`sk_test_...`).
|
||||
* **En production / Live** : Désactivez le Mode Test sur Stripe pour passer en mode réel, et créez les coupons dans la section Live. Ils ne fonctionneront qu'avec vos clés d'API réelles (`sk_live_...`).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user