feat(patterns): GET /users/patterns — agrégation erreurs récurrentes + exercices long terme + indice de préparation (Sprint 3.6c)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a394ce8429
commit
c48ae8d443
6 changed files with 1055 additions and 0 deletions
511
src/controllers/__tests__/patternsController.test.ts
Normal file
511
src/controllers/__tests__/patternsController.test.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { Hono } from 'hono'
|
||||
import type { AppVariables } from '../../middleware/auth'
|
||||
|
||||
vi.mock('../../lib/supabase', () => ({
|
||||
supabase: {
|
||||
auth: { getUser: vi.fn() },
|
||||
from: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/deepseek', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../lib/deepseek')>('../../lib/deepseek')
|
||||
return {
|
||||
...actual,
|
||||
generatePatternExercices: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { supabase } from '../../lib/supabase'
|
||||
import { generatePatternExercices } from '../../lib/deepseek'
|
||||
import usersRoutes from '../../routes/users'
|
||||
import {
|
||||
aggregatePatterns,
|
||||
computePreparationIndex,
|
||||
type ProductionForAnalysis,
|
||||
} from '../patternsController'
|
||||
|
||||
// ─── Helpers communs ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildProfile(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: 'user-prem',
|
||||
email: 'premium@test.com',
|
||||
plan: 'premium',
|
||||
simulations_used: 0,
|
||||
stripe_customer_id: null,
|
||||
stripe_subscription_id: null,
|
||||
plan_expires_at: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mockAuth(profile: ReturnType<typeof buildProfile>) {
|
||||
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
|
||||
data: { user: { id: profile.id, email: profile.email } as any },
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(supabase.from).mockReturnValueOnce({
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
single: vi.fn(() => ({ data: profile, error: null })),
|
||||
})),
|
||||
})),
|
||||
} as any)
|
||||
}
|
||||
|
||||
/** Mock from('productions').select(...).eq(...).not(...).order(...).limit(...) */
|
||||
function mockProductionsQuery(rows: unknown[]) {
|
||||
const limitFn = vi.fn(() => ({ data: rows, error: null }))
|
||||
const orderFn = vi.fn(() => ({ limit: limitFn }))
|
||||
const notFn = vi.fn(() => ({ order: orderFn }))
|
||||
const eqFn = vi.fn(() => ({ not: notFn }))
|
||||
const selectFn = vi.fn(() => ({ eq: eqFn }))
|
||||
vi.mocked(supabase.from).mockReturnValueOnce({ select: selectFn } as any)
|
||||
return { selectFn, eqFn, notFn, orderFn, limitFn }
|
||||
}
|
||||
|
||||
/** Mock from('pattern_analyses').select().eq().order().limit().maybeSingle() */
|
||||
function mockLastAnalysis(data: unknown) {
|
||||
const maybeSingleFn = vi.fn(() => ({ data, error: null }))
|
||||
const limitFn = vi.fn(() => ({ maybeSingle: maybeSingleFn }))
|
||||
const orderFn = vi.fn(() => ({ limit: limitFn }))
|
||||
const eqFn = vi.fn(() => ({ order: orderFn }))
|
||||
const selectFn = vi.fn(() => ({ eq: eqFn }))
|
||||
vi.mocked(supabase.from).mockReturnValueOnce({ select: selectFn } as any)
|
||||
return { maybeSingleFn, limitFn, orderFn, eqFn, selectFn }
|
||||
}
|
||||
|
||||
/** Mock from('pattern_analyses').insert(...).select(...).single() */
|
||||
function mockInsertAnalysis(created_at: string) {
|
||||
const singleFn = vi.fn(() => ({ data: { created_at }, error: null }))
|
||||
const selectFn = vi.fn(() => ({ single: singleFn }))
|
||||
const insertFn = vi.fn(() => ({ select: selectFn }))
|
||||
vi.mocked(supabase.from).mockReturnValueOnce({ insert: insertFn } as any)
|
||||
return { insertFn, selectFn, singleFn }
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const app = new Hono<{ Variables: AppVariables }>()
|
||||
app.route('/users', usersRoutes)
|
||||
return app
|
||||
}
|
||||
|
||||
// ─── Fonctions pures ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('aggregatePatterns', () => {
|
||||
function prod(id: string, erreurs: unknown): ProductionForAnalysis {
|
||||
return { id, score: 14, created_at: '2026-04-22T12:00:00Z', erreurs_codes: erreurs }
|
||||
}
|
||||
|
||||
it('code à 3 occurrences sur 5 → pattern confirmé frequency=3', () => {
|
||||
const patterns = aggregatePatterns([
|
||||
prod('1', [{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null }]),
|
||||
prod('2', [{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null }]),
|
||||
prod('3', [{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null }]),
|
||||
prod('4', []),
|
||||
prod('5', []),
|
||||
])
|
||||
expect(patterns).toHaveLength(1)
|
||||
expect(patterns[0]).toMatchObject({
|
||||
code: 'accord_sujet_verbe',
|
||||
critere: 'competence_grammaticale',
|
||||
frequency: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('code à 2 occurrences → PAS un pattern (seuil 3)', () => {
|
||||
const patterns = aggregatePatterns([
|
||||
prod('1', [{ code: 'repetition_lexicale', critere: 'competence_lexicale', description: null }]),
|
||||
prod('2', [{ code: 'repetition_lexicale', critere: 'competence_lexicale', description: null }]),
|
||||
prod('3', []),
|
||||
prod('4', []),
|
||||
prod('5', []),
|
||||
])
|
||||
expect(patterns).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('code à 5/5 → frequency=5', () => {
|
||||
const patterns = aggregatePatterns(
|
||||
Array.from({ length: 5 }).map((_, i) =>
|
||||
prod(String(i), [
|
||||
{ code: 'virgule_exces', critere: 'competence_grammaticale', description: null },
|
||||
]),
|
||||
),
|
||||
)
|
||||
expect(patterns[0]?.frequency).toBe(5)
|
||||
})
|
||||
|
||||
it('deux patterns confirmés triés par fréquence DESC', () => {
|
||||
const patterns = aggregatePatterns([
|
||||
prod('1', [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', description: null },
|
||||
]),
|
||||
prod('2', [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', description: null },
|
||||
]),
|
||||
prod('3', [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', description: null },
|
||||
]),
|
||||
prod('4', [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
]),
|
||||
prod('5', []),
|
||||
])
|
||||
expect(patterns).toHaveLength(2)
|
||||
expect(patterns[0]?.code).toBe('accord_sujet_verbe')
|
||||
expect(patterns[0]?.frequency).toBe(4)
|
||||
expect(patterns[1]?.code).toBe('connecteurs_repetes')
|
||||
expect(patterns[1]?.frequency).toBe(3)
|
||||
})
|
||||
|
||||
it('code "autre" : descriptions différentes comptées séparément', () => {
|
||||
const patterns = aggregatePatterns([
|
||||
prod('1', [{ code: 'autre', critere: 'coherence_cohesion', description: 'erreur A' }]),
|
||||
prod('2', [{ code: 'autre', critere: 'coherence_cohesion', description: 'erreur A' }]),
|
||||
prod('3', [{ code: 'autre', critere: 'coherence_cohesion', description: 'erreur A' }]),
|
||||
prod('4', [{ code: 'autre', critere: 'coherence_cohesion', description: 'erreur B' }]),
|
||||
prod('5', [{ code: 'autre', critere: 'coherence_cohesion', description: 'erreur B' }]),
|
||||
])
|
||||
// "erreur A" 3/5 → confirmé ; "erreur B" 2/5 → non confirmé
|
||||
expect(patterns).toHaveLength(1)
|
||||
expect(patterns[0]?.description).toBe('erreur A')
|
||||
expect(patterns[0]?.frequency).toBe(3)
|
||||
})
|
||||
|
||||
it('dédoublonnage intra-production : même code 2x dans le même rapport ne compte qu\'une fois', () => {
|
||||
const patterns = aggregatePatterns([
|
||||
prod('1', [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
]),
|
||||
prod('2', [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
]),
|
||||
prod('3', []),
|
||||
prod('4', []),
|
||||
prod('5', []),
|
||||
])
|
||||
expect(patterns).toHaveLength(0) // seulement 2/5 distinctes → non confirmé
|
||||
})
|
||||
|
||||
it('code hors taxonomie → ignoré', () => {
|
||||
const patterns = aggregatePatterns([
|
||||
prod('1', [{ code: 'accord_sujet_verbe', critere: 'critere_invente', description: null }]),
|
||||
prod('2', [{ code: 'accord_sujet_verbe', critere: 'critere_invente', description: null }]),
|
||||
prod('3', [{ code: 'accord_sujet_verbe', critere: 'critere_invente', description: null }]),
|
||||
prod('4', []),
|
||||
prod('5', []),
|
||||
])
|
||||
expect(patterns).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computePreparationIndex', () => {
|
||||
function prodAt(score: number, date: string): ProductionForAnalysis {
|
||||
return { id: date, score, created_at: date, erreurs_codes: [] }
|
||||
}
|
||||
|
||||
it('scores élevés + réguliers → score > 70, message "NCLC 9+"', () => {
|
||||
const result = computePreparationIndex([
|
||||
prodAt(16, '2026-04-22T12:00:00Z'),
|
||||
prodAt(15, '2026-04-20T12:00:00Z'),
|
||||
prodAt(16, '2026-04-18T12:00:00Z'),
|
||||
prodAt(15, '2026-04-16T12:00:00Z'),
|
||||
prodAt(14, '2026-04-14T12:00:00Z'),
|
||||
])
|
||||
expect(result.score).toBeGreaterThan(70)
|
||||
expect(result.message).toMatch(/NCLC 9/)
|
||||
})
|
||||
|
||||
it('scores bas + intervalles espacés → score < 40, message "Continuez"', () => {
|
||||
// Scores moyens ~5/20 + intervalles de ~30 jours → régularité 15, moy 25, trend 50
|
||||
const result = computePreparationIndex([
|
||||
prodAt(5, '2026-04-22T12:00:00Z'),
|
||||
prodAt(6, '2026-03-22T12:00:00Z'),
|
||||
prodAt(4, '2026-02-20T12:00:00Z'),
|
||||
prodAt(5, '2026-01-20T12:00:00Z'),
|
||||
prodAt(6, '2025-12-20T12:00:00Z'),
|
||||
])
|
||||
expect(result.score).toBeLessThan(40)
|
||||
expect(result.message).toMatch(/Continuez/)
|
||||
})
|
||||
|
||||
it('scores moyens → score entre 40 et 70, message "Bonne progression"', () => {
|
||||
const result = computePreparationIndex([
|
||||
prodAt(11, '2026-04-22T12:00:00Z'),
|
||||
prodAt(12, '2026-04-20T12:00:00Z'),
|
||||
prodAt(11, '2026-04-18T12:00:00Z'),
|
||||
prodAt(12, '2026-04-16T12:00:00Z'),
|
||||
prodAt(11, '2026-04-14T12:00:00Z'),
|
||||
])
|
||||
expect(result.score).toBeGreaterThanOrEqual(40)
|
||||
expect(result.score).toBeLessThanOrEqual(70)
|
||||
expect(result.message).toMatch(/Bonne progression/)
|
||||
})
|
||||
|
||||
it('score clampé entre 0 et 100', () => {
|
||||
const result = computePreparationIndex([
|
||||
prodAt(20, '2026-04-22T12:00:00Z'),
|
||||
prodAt(20, '2026-04-21T12:00:00Z'),
|
||||
prodAt(20, '2026-04-20T12:00:00Z'),
|
||||
prodAt(20, '2026-04-19T12:00:00Z'),
|
||||
prodAt(20, '2026-04-18T12:00:00Z'),
|
||||
])
|
||||
expect(result.score).toBeLessThanOrEqual(100)
|
||||
expect(result.score).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Route GET /users/patterns ────────────────────────────────────────────────
|
||||
|
||||
describe('GET /users/patterns — gate plan', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('sans JWT → 401 AUTH_REQUIRED', async () => {
|
||||
const app = createApp()
|
||||
const res = await app.request('/users/patterns')
|
||||
expect(res.status).toBe(401)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('AUTH_REQUIRED')
|
||||
})
|
||||
|
||||
it('plan free → 403 PLAN_INSUFFICIENT', async () => {
|
||||
mockAuth(buildProfile({ plan: 'free' }))
|
||||
const app = createApp()
|
||||
const res = await app.request('/users/patterns', {
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('PLAN_INSUFFICIENT')
|
||||
})
|
||||
|
||||
it('plan standard → 403 PLAN_INSUFFICIENT', async () => {
|
||||
mockAuth(buildProfile({ plan: 'standard' }))
|
||||
const app = createApp()
|
||||
const res = await app.request('/users/patterns', {
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('PLAN_INSUFFICIENT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /users/patterns — premium', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('< 5 productions → { ready: false, minimum: 5, current: N }', async () => {
|
||||
mockAuth(buildProfile())
|
||||
mockProductionsQuery([
|
||||
{ id: '1', score: 14, created_at: '2026-04-22T00:00:00Z', erreurs_codes: [] },
|
||||
{ id: '2', score: 15, created_at: '2026-04-21T00:00:00Z', erreurs_codes: [] },
|
||||
{ id: '3', score: 13, created_at: '2026-04-20T00:00:00Z', erreurs_codes: [] },
|
||||
])
|
||||
|
||||
const app = createApp()
|
||||
const res = await app.request('/users/patterns', {
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body).toEqual({ ready: false, minimum: 5, current: 3 })
|
||||
})
|
||||
|
||||
it('cache hit (analyse plus récente que dernière prod) → pas d\'appel DeepSeek', async () => {
|
||||
mockAuth(buildProfile())
|
||||
mockProductionsQuery([
|
||||
{ id: '1', score: 14, created_at: '2026-04-20T12:00:00Z', erreurs_codes: [] },
|
||||
{ id: '2', score: 15, created_at: '2026-04-19T12:00:00Z', erreurs_codes: [] },
|
||||
{ id: '3', score: 13, created_at: '2026-04-18T12:00:00Z', erreurs_codes: [] },
|
||||
{ id: '4', score: 14, created_at: '2026-04-17T12:00:00Z', erreurs_codes: [] },
|
||||
{ id: '5', score: 14, created_at: '2026-04-16T12:00:00Z', erreurs_codes: [] },
|
||||
])
|
||||
mockLastAnalysis({
|
||||
created_at: '2026-04-22T00:00:00Z', // plus récent que la prod la plus récente
|
||||
patterns: [{ code: 'virgule_exces', critere: 'competence_grammaticale', frequency: 3 }],
|
||||
exercises: [],
|
||||
preparation_index: 65,
|
||||
preparation_message: 'Bonne progression — visez NCLC 7-8',
|
||||
analyzed_count: 5,
|
||||
})
|
||||
|
||||
const app = createApp()
|
||||
const res = await app.request('/users/patterns', {
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.ready).toBe(true)
|
||||
expect(body.preparation_index.score).toBe(65)
|
||||
expect(body.last_analysis).toBe('2026-04-22T00:00:00Z')
|
||||
// Cache hit → DeepSeek non appelé
|
||||
expect(vi.mocked(generatePatternExercices)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cache miss (prod postérieure à last_analysis) → recompute + insert', async () => {
|
||||
mockAuth(buildProfile())
|
||||
const recent = '2026-04-22T12:00:00Z'
|
||||
mockProductionsQuery([
|
||||
{
|
||||
id: '1',
|
||||
score: 14,
|
||||
created_at: recent,
|
||||
erreurs_codes: [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
score: 15,
|
||||
created_at: '2026-04-20T12:00:00Z',
|
||||
erreurs_codes: [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
score: 14,
|
||||
created_at: '2026-04-18T12:00:00Z',
|
||||
erreurs_codes: [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
score: 15,
|
||||
created_at: '2026-04-16T12:00:00Z',
|
||||
erreurs_codes: [],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
score: 14,
|
||||
created_at: '2026-04-14T12:00:00Z',
|
||||
erreurs_codes: [],
|
||||
},
|
||||
])
|
||||
mockLastAnalysis({
|
||||
created_at: '2026-04-10T00:00:00Z', // antérieur à la prod la plus récente
|
||||
patterns: [],
|
||||
exercises: [],
|
||||
preparation_index: 50,
|
||||
preparation_message: 'Bonne progression — visez NCLC 7-8',
|
||||
analyzed_count: 5,
|
||||
})
|
||||
|
||||
vi.mocked(generatePatternExercices).mockResolvedValueOnce([
|
||||
{
|
||||
code: 'accord_sujet_verbe',
|
||||
critere: 'competence_grammaticale',
|
||||
diagnostic: 'Erreurs d\'accord récurrentes.',
|
||||
exercice: {
|
||||
consigne: 'Corrigez.',
|
||||
exemple: 'les enfants joue',
|
||||
correction: 'les enfants jouent',
|
||||
astuce: 'Vérifiez le sujet avant le verbe.',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const inserts = mockInsertAnalysis('2026-04-22T13:00:00Z')
|
||||
|
||||
const app = createApp()
|
||||
const res = await app.request('/users/patterns', {
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.ready).toBe(true)
|
||||
expect(body.patterns).toHaveLength(1)
|
||||
expect(body.patterns[0].code).toBe('accord_sujet_verbe')
|
||||
expect(body.exercises).toHaveLength(1)
|
||||
expect(body.exercises[0].exercice.astuce).toBe('Vérifiez le sujet avant le verbe.')
|
||||
expect(vi.mocked(generatePatternExercices)).toHaveBeenCalledTimes(1)
|
||||
expect(inserts.insertFn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aucun pattern confirmé → DeepSeek non appelé, exercises=[]', async () => {
|
||||
mockAuth(buildProfile())
|
||||
mockProductionsQuery([
|
||||
{ id: '1', score: 14, created_at: '2026-04-22T12:00:00Z', erreurs_codes: [] },
|
||||
{ id: '2', score: 15, created_at: '2026-04-20T12:00:00Z', erreurs_codes: [] },
|
||||
{ id: '3', score: 14, created_at: '2026-04-18T12:00:00Z', erreurs_codes: [] },
|
||||
{ id: '4', score: 15, created_at: '2026-04-16T12:00:00Z', erreurs_codes: [] },
|
||||
{ id: '5', score: 14, created_at: '2026-04-14T12:00:00Z', erreurs_codes: [] },
|
||||
])
|
||||
mockLastAnalysis(null)
|
||||
mockInsertAnalysis('2026-04-22T13:00:00Z')
|
||||
|
||||
const app = createApp()
|
||||
const res = await app.request('/users/patterns', {
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.ready).toBe(true)
|
||||
expect(body.patterns).toEqual([])
|
||||
expect(body.exercises).toEqual([])
|
||||
expect(vi.mocked(generatePatternExercices)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('DeepSeek échoue → dégradation gracieuse (exercises=[], persistance OK)', async () => {
|
||||
mockAuth(buildProfile())
|
||||
mockProductionsQuery([
|
||||
{
|
||||
id: '1',
|
||||
score: 14,
|
||||
created_at: '2026-04-22T12:00:00Z',
|
||||
erreurs_codes: [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
score: 14,
|
||||
created_at: '2026-04-20T12:00:00Z',
|
||||
erreurs_codes: [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
score: 14,
|
||||
created_at: '2026-04-18T12:00:00Z',
|
||||
erreurs_codes: [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
|
||||
],
|
||||
},
|
||||
{ id: '4', score: 14, created_at: '2026-04-16T12:00:00Z', erreurs_codes: [] },
|
||||
{ id: '5', score: 14, created_at: '2026-04-14T12:00:00Z', erreurs_codes: [] },
|
||||
])
|
||||
mockLastAnalysis(null)
|
||||
vi.mocked(generatePatternExercices).mockRejectedValueOnce(new Error('DeepSeek timeout'))
|
||||
mockInsertAnalysis('2026-04-22T13:00:00Z')
|
||||
|
||||
const app = createApp()
|
||||
const res = await app.request('/users/patterns', {
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.ready).toBe(true)
|
||||
expect(body.patterns).toHaveLength(1)
|
||||
expect(body.exercises).toEqual([]) // dégradation gracieuse
|
||||
})
|
||||
})
|
||||
336
src/controllers/patternsController.ts
Normal file
336
src/controllers/patternsController.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
/**
|
||||
* Contrôleur Analyse patterns — Sprint 3.6c.
|
||||
*
|
||||
* Flux GET /users/patterns :
|
||||
* 1. Charger les 5 dernières productions corrigées (rapport != null).
|
||||
* 2. Si < 5 → retourner { ready: false, minimum, current }.
|
||||
* 3. Sinon : vérifier le cache `pattern_analyses`.
|
||||
* - Cache hit (aucune prod postérieure à la dernière analyse) → retourner le cache.
|
||||
* - Cache miss → agréger patterns + calculer indice + générer exercices DeepSeek → insert.
|
||||
* 4. Retourner le snapshot.
|
||||
*
|
||||
* Agrégation : un pattern est confirmé si un code d'erreur apparaît dans
|
||||
* ≥ 3 productions sur 5 (cf. PARCOURS_UTILISATEURS.md §Analyse patterns).
|
||||
*
|
||||
* Formule indice de préparation (cf. décision session 2026-04-22) :
|
||||
* final = 60% × score_moyen_normalisé + 20% × régularité + 20% × tendance
|
||||
*/
|
||||
|
||||
import { supabase } from '../lib/supabase.js'
|
||||
import {
|
||||
generatePatternExercices,
|
||||
type PatternExerciceItem,
|
||||
} from '../lib/deepseek.js'
|
||||
import { isValidCritere, type Critere } from '../lib/taxonomieErreurs.js'
|
||||
import type { AuthProfile } from '../middleware/auth.js'
|
||||
|
||||
const ANALYSIS_WINDOW = 5
|
||||
const PATTERN_THRESHOLD = 3
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PatternEntry {
|
||||
code: string
|
||||
critere: Critere
|
||||
frequency: number // 3, 4 ou 5
|
||||
description: string | null // non-null uniquement pour code === 'autre'
|
||||
}
|
||||
|
||||
export interface PreparationIndex {
|
||||
score: number // 0-100 entier
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ProductionForAnalysis {
|
||||
id: string
|
||||
score: number | null
|
||||
created_at: string
|
||||
erreurs_codes: unknown
|
||||
}
|
||||
|
||||
export interface PatternsNotReady {
|
||||
ready: false
|
||||
minimum: number
|
||||
current: number
|
||||
}
|
||||
|
||||
export interface PatternsReady {
|
||||
ready: true
|
||||
patterns: PatternEntry[]
|
||||
exercises: PatternExerciceItem[]
|
||||
preparation_index: PreparationIndex
|
||||
analyzed_productions: number
|
||||
last_analysis: string
|
||||
}
|
||||
|
||||
export type PatternsResult = PatternsNotReady | PatternsReady
|
||||
|
||||
type ControllerError = {
|
||||
error: true
|
||||
code: string
|
||||
message: string
|
||||
status: number
|
||||
}
|
||||
|
||||
// ── Agrégation — fonctions pures ─────────────────────────────────────────
|
||||
|
||||
interface RawErreurCode {
|
||||
code: string
|
||||
critere: Critere
|
||||
description: string | null
|
||||
}
|
||||
|
||||
function normalizeErreursCodes(raw: unknown): RawErreurCode[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: RawErreurCode[] = []
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'object' || item === null) continue
|
||||
const o = item as { code?: unknown; critere?: unknown; description?: unknown }
|
||||
if (typeof o.code !== 'string' || typeof o.critere !== 'string') continue
|
||||
if (!isValidCritere(o.critere)) continue
|
||||
out.push({
|
||||
code: o.code,
|
||||
critere: o.critere,
|
||||
description: typeof o.description === 'string' ? o.description : null,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrège les codes d'erreurs sur N productions et retourne les patterns
|
||||
* confirmés (frequency ≥ PATTERN_THRESHOLD).
|
||||
*
|
||||
* Le code `autre` est distingué par sa description — deux erreurs `autre`
|
||||
* avec des descriptions différentes ne sont PAS regroupées.
|
||||
*/
|
||||
export function aggregatePatterns(
|
||||
productions: ProductionForAnalysis[],
|
||||
): PatternEntry[] {
|
||||
const counts = new Map<string, PatternEntry>()
|
||||
|
||||
for (const prod of productions) {
|
||||
const erreurs = normalizeErreursCodes(prod.erreurs_codes)
|
||||
// Dédoublonnage INTRA-production : un même code ne compte qu'une fois par prod.
|
||||
const seen = new Set<string>()
|
||||
for (const e of erreurs) {
|
||||
const key =
|
||||
e.code === 'autre'
|
||||
? `${e.critere}|${e.code}|${e.description ?? ''}`
|
||||
: `${e.critere}|${e.code}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
const existing = counts.get(key)
|
||||
if (existing) {
|
||||
existing.frequency += 1
|
||||
} else {
|
||||
counts.set(key, {
|
||||
code: e.code,
|
||||
critere: e.critere,
|
||||
frequency: 1,
|
||||
description: e.code === 'autre' ? e.description : null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(counts.values())
|
||||
.filter((p) => p.frequency >= PATTERN_THRESHOLD)
|
||||
.sort((a, b) => {
|
||||
if (b.frequency !== a.frequency) return b.frequency - a.frequency
|
||||
return a.critere.localeCompare(b.critere)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Indice de préparation ────────────────────────────────────────────────
|
||||
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!
|
||||
}
|
||||
|
||||
function linearTrend(scores: number[]): number {
|
||||
// Régression linéaire simple : pente sur X = [0, 1, ..., n-1].
|
||||
const n = scores.length
|
||||
if (n < 2) return 0
|
||||
const xMean = (n - 1) / 2
|
||||
const yMean = scores.reduce((a, b) => a + b, 0) / n
|
||||
let num = 0
|
||||
let den = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
num += (i - xMean) * (scores[i]! - yMean)
|
||||
den += (i - xMean) ** 2
|
||||
}
|
||||
return den === 0 ? 0 : num / den
|
||||
}
|
||||
|
||||
export function computePreparationIndex(
|
||||
productions: ProductionForAnalysis[],
|
||||
): PreparationIndex {
|
||||
// Productions triées du plus ANCIEN au plus RÉCENT pour la tendance
|
||||
// (l'appel externe passe la liste DESC — on inverse ici).
|
||||
const ordered = [...productions].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||
)
|
||||
|
||||
const scores = ordered
|
||||
.map((p) => p.score)
|
||||
.filter((s): s is number => typeof s === 'number')
|
||||
|
||||
if (scores.length === 0) {
|
||||
return { score: 0, message: 'Continuez à vous entraîner régulièrement' }
|
||||
}
|
||||
|
||||
// 1. Score moyen normalisé (0-100 sur /20)
|
||||
const avg = scores.reduce((a, b) => a + b, 0) / scores.length
|
||||
const scoreAvgNorm = (avg / 20) * 100
|
||||
|
||||
// 2. Régularité (médiane des intervalles en jours)
|
||||
const intervals: number[] = []
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
const prev = new Date(ordered[i - 1]!.created_at).getTime()
|
||||
const curr = new Date(ordered[i]!.created_at).getTime()
|
||||
intervals.push((curr - prev) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
const medianInterval = median(intervals)
|
||||
const regularityScore =
|
||||
medianInterval < 3 ? 100 : medianInterval < 7 ? 70 : medianInterval < 14 ? 40 : 15
|
||||
|
||||
// 3. Tendance (pente linéaire)
|
||||
const slope = linearTrend(scores)
|
||||
const trendScore = slope > 0.1 ? 100 : slope < -0.1 ? 0 : 50
|
||||
|
||||
const final = Math.round(scoreAvgNorm * 0.6 + regularityScore * 0.2 + trendScore * 0.2)
|
||||
const clamped = Math.max(0, Math.min(100, final))
|
||||
|
||||
const message =
|
||||
clamped < 40
|
||||
? 'Continuez à vous entraîner régulièrement'
|
||||
: clamped <= 70
|
||||
? 'Bonne progression — visez NCLC 7-8'
|
||||
: 'Vous êtes en bonne voie pour NCLC 9+'
|
||||
|
||||
return { score: clamped, message }
|
||||
}
|
||||
|
||||
// ── Orchestration principale ─────────────────────────────────────────────
|
||||
|
||||
export async function list(
|
||||
profile: AuthProfile,
|
||||
): Promise<{ data: PatternsResult } | ControllerError> {
|
||||
// 1. Charger les 5 dernières productions corrigées
|
||||
const { data: productions, error: fetchErr } = await supabase
|
||||
.from('productions')
|
||||
.select('id, score, created_at, erreurs_codes')
|
||||
.eq('user_id', profile.id)
|
||||
.not('rapport', 'is', null)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(ANALYSIS_WINDOW)
|
||||
|
||||
if (fetchErr) {
|
||||
return {
|
||||
error: true,
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Impossible de charger les productions.',
|
||||
status: 500,
|
||||
}
|
||||
}
|
||||
|
||||
const prods = (productions ?? []) as ProductionForAnalysis[]
|
||||
|
||||
if (prods.length < ANALYSIS_WINDOW) {
|
||||
return {
|
||||
data: {
|
||||
ready: false,
|
||||
minimum: ANALYSIS_WINDOW,
|
||||
current: prods.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Cache : dernière analyse pour cet user
|
||||
const { data: lastAnalysis } = await supabase
|
||||
.from('pattern_analyses')
|
||||
.select('*')
|
||||
.eq('user_id', profile.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
const latestProdDate = prods[0]!.created_at
|
||||
const cacheFresh =
|
||||
lastAnalysis !== null &&
|
||||
new Date(lastAnalysis.created_at as string).getTime() >=
|
||||
new Date(latestProdDate).getTime()
|
||||
|
||||
if (cacheFresh && lastAnalysis) {
|
||||
return {
|
||||
data: {
|
||||
ready: true,
|
||||
patterns: lastAnalysis.patterns as PatternEntry[],
|
||||
exercises: lastAnalysis.exercises as PatternExerciceItem[],
|
||||
preparation_index: {
|
||||
score: lastAnalysis.preparation_index as number,
|
||||
message: lastAnalysis.preparation_message as string,
|
||||
},
|
||||
analyzed_productions: lastAnalysis.analyzed_count as number,
|
||||
last_analysis: lastAnalysis.created_at as string,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Cache miss → recompute
|
||||
const patterns = aggregatePatterns(prods)
|
||||
const preparation = computePreparationIndex(prods)
|
||||
|
||||
let exercises: PatternExerciceItem[] = []
|
||||
if (patterns.length > 0) {
|
||||
try {
|
||||
exercises = await generatePatternExercices(patterns)
|
||||
} catch (err) {
|
||||
console.error('[patternsController.list] generatePatternExercices failed', {
|
||||
userId: profile.id,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
// Dégradation gracieuse : on persiste l'analyse sans exercices.
|
||||
exercises = []
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Persister
|
||||
const { data: inserted, error: insertErr } = await supabase
|
||||
.from('pattern_analyses')
|
||||
.insert({
|
||||
user_id: profile.id,
|
||||
productions_ids: prods.map((p) => p.id),
|
||||
patterns,
|
||||
exercises,
|
||||
preparation_index: preparation.score,
|
||||
preparation_message: preparation.message,
|
||||
analyzed_count: prods.length,
|
||||
})
|
||||
.select('created_at')
|
||||
.single()
|
||||
|
||||
if (insertErr || !inserted) {
|
||||
return {
|
||||
error: true,
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Impossible de sauvegarder l\'analyse.',
|
||||
status: 500,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
ready: true,
|
||||
patterns,
|
||||
exercises,
|
||||
preparation_index: preparation,
|
||||
analyzed_productions: prods.length,
|
||||
last_analysis: inserted.created_at as string,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import sujetsRoutes from './routes/sujets.js'
|
|||
import correctionsRoutes from './routes/corrections.js'
|
||||
import stripeRoutes from './routes/stripe.js'
|
||||
import createT2LiveRoutes from './routes/t2live.js'
|
||||
import usersRoutes from './routes/users.js'
|
||||
|
||||
const app = new Hono()
|
||||
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app })
|
||||
|
|
@ -38,6 +39,7 @@ app.route('/sujets', sujetsRoutes)
|
|||
app.route('/corrections', correctionsRoutes)
|
||||
app.route('/stripe', stripeRoutes)
|
||||
app.route('/t2', createT2LiveRoutes(upgradeWebSocket))
|
||||
app.route('/users', usersRoutes)
|
||||
|
||||
const port = Number(process.env.PORT) || 3000
|
||||
|
||||
|
|
|
|||
|
|
@ -609,6 +609,149 @@ export async function generateExercices(input: ExercicesInput): Promise<Exercice
|
|||
}))
|
||||
}
|
||||
|
||||
// ── Sprint 3.6c — Exercices long terme (patterns Premium) ──────────────
|
||||
|
||||
export interface PatternInput {
|
||||
code: string
|
||||
critere: Critere
|
||||
frequency: number
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export interface PatternExerciceItem {
|
||||
code: string
|
||||
critere: Critere
|
||||
diagnostic: string
|
||||
exercice: {
|
||||
consigne: string
|
||||
exemple: string
|
||||
correction: string
|
||||
astuce: string
|
||||
}
|
||||
}
|
||||
|
||||
const PATTERN_EXERCICES_SYSTEM = `Tu es un coach spécialisé dans la préparation au TCF Canada (Test de connaissance du français).
|
||||
|
||||
Un candidat commet systématiquement les mêmes erreurs sur ses 5 dernières productions écrites. Tu dois produire UN exercice ciblé par pattern d'erreur récurrent identifié.
|
||||
|
||||
CONTEXTE :
|
||||
- Ces exercices sont des exercices LONG TERME destinés à corriger des faiblesses structurelles récurrentes.
|
||||
- Ils sont DISTINCTS des exercices individuels générés après chaque correction (qui ciblent une production spécifique).
|
||||
- Tu n'as PAS accès au texte du candidat. Tes exemples doivent être génériques et représentatifs de l'erreur.
|
||||
|
||||
RÈGLES :
|
||||
1. Un exercice par pattern en entrée, dans le même ordre.
|
||||
2. Le diagnostic explique en 1-2 phrases POURQUOI cette erreur est problématique pour le TCF Canada.
|
||||
3. La consigne demande au candidat de corriger ou reformuler une phrase.
|
||||
4. L'exemple est une phrase incorrecte illustrant le pattern (inventée, pas tirée du candidat).
|
||||
5. La correction est la version correcte de l'exemple.
|
||||
6. L'astuce est un procédé mnémotechnique, une règle pratique ou un réflexe de relecture que le candidat doit appliquer APRÈS avoir rédigé son texte pour détecter et corriger cette erreur lui-même. Formulée comme un conseil direct et actionnable.
|
||||
Exemples d'astuces :
|
||||
- Subjonctif : "Après 'bien que', 'pourvu que', 'avant que' → le verbe qui suit est TOUJOURS au subjonctif. Relisez votre texte en cherchant ces expressions."
|
||||
- Accords : "Relisez chaque phrase en pointant du doigt le sujet et son verbe. S'ils sont éloignés, vérifiez l'accord."
|
||||
- Connecteurs : "Après rédaction, surlignez tous vos connecteurs. Si le même revient plus de 2 fois, remplacez-en un."
|
||||
7. Niveau de langue : NCLC 7-9 (ni trop simple, ni trop littéraire).
|
||||
8. Les exemples doivent être en contexte TCF Canada : courriels, lettres formelles, essais argumentatifs, situations professionnelles canadiennes.
|
||||
|
||||
FORMAT DE SORTIE — JSON strict, aucun texte avant ni après :
|
||||
{
|
||||
"exercises": [
|
||||
{
|
||||
"code": "<code_taxonomie>",
|
||||
"critere": "<critere>",
|
||||
"diagnostic": "<1-2 phrases>",
|
||||
"exercice": {
|
||||
"consigne": "<instruction au candidat>",
|
||||
"exemple": "<phrase incorrecte>",
|
||||
"correction": "<phrase corrigée>",
|
||||
"astuce": "<procédé mnémotechnique ou réflexe de relecture>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
function buildPatternExercicesUserPrompt(patterns: PatternInput[]): string {
|
||||
const lines = patterns.map((p) => {
|
||||
const desc = p.description ? ` — « ${p.description} »` : ''
|
||||
return `- ${p.code} (${p.critere}) — apparu ${p.frequency}/5 fois${desc}`
|
||||
})
|
||||
return `Voici les patterns d'erreurs récurrents détectés sur les 5 dernières productions du candidat :
|
||||
|
||||
${lines.join('\n')}
|
||||
|
||||
Produis un exercice ciblé par pattern. JSON strict uniquement.`
|
||||
}
|
||||
|
||||
export async function generatePatternExercices(
|
||||
patterns: PatternInput[],
|
||||
): Promise<PatternExerciceItem[]> {
|
||||
if (patterns.length === 0) return []
|
||||
|
||||
const response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-chat',
|
||||
messages: [
|
||||
{ role: 'system', content: PATTERN_EXERCICES_SYSTEM },
|
||||
{ role: 'user', content: buildPatternExercicesUserPrompt(patterns) },
|
||||
],
|
||||
temperature: 0.4,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`DeepSeek API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
choices?: { message?: { content?: string } }[]
|
||||
}
|
||||
const content = data.choices?.[0]?.message?.content
|
||||
if (!content) throw new Error('DeepSeek API: réponse vide')
|
||||
|
||||
const parsed = JSON.parse(content) as { exercises?: unknown }
|
||||
if (!Array.isArray(parsed.exercises)) {
|
||||
throw new Error('Réponse DeepSeek invalide : exercises doit être un tableau')
|
||||
}
|
||||
|
||||
const out: PatternExerciceItem[] = []
|
||||
for (const raw of parsed.exercises as unknown[]) {
|
||||
const item = raw as Record<string, unknown>
|
||||
const ex = item.exercice as Record<string, unknown> | undefined
|
||||
if (
|
||||
typeof item.code !== 'string' ||
|
||||
typeof item.critere !== 'string' ||
|
||||
typeof item.diagnostic !== 'string' ||
|
||||
!ex ||
|
||||
typeof ex.consigne !== 'string' ||
|
||||
typeof ex.exemple !== 'string' ||
|
||||
typeof ex.correction !== 'string' ||
|
||||
typeof ex.astuce !== 'string'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (!isValidCritere(item.critere)) continue
|
||||
out.push({
|
||||
code: item.code,
|
||||
critere: item.critere,
|
||||
diagnostic: item.diagnostic,
|
||||
exercice: {
|
||||
consigne: ex.consigne,
|
||||
exemple: ex.exemple,
|
||||
correction: ex.correction,
|
||||
astuce: ex.astuce,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── EO (Expression Orale) — inchangé par Sprint 3.6a ────────────────────
|
||||
|
||||
export interface EOCritere {
|
||||
|
|
|
|||
26
src/routes/users.ts
Normal file
26
src/routes/users.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Routes /users/* — Sprint 3.6c.
|
||||
*
|
||||
* GET /users/patterns : analyse des patterns récurrents (Premium uniquement).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { authMiddleware } from '../middleware/auth.js'
|
||||
import type { AppVariables } from '../middleware/auth.js'
|
||||
import { planMiddleware } from '../middleware/plan.js'
|
||||
import * as patternsController from '../controllers/patternsController.js'
|
||||
|
||||
const users = new Hono<{ Variables: AppVariables }>()
|
||||
|
||||
users.get('/patterns', authMiddleware, planMiddleware('pattern_analysis'), async (c) => {
|
||||
const profile = c.get('profile')
|
||||
const result = await patternsController.list(profile)
|
||||
|
||||
if ('error' in result) {
|
||||
return c.json(result, result.status as 500)
|
||||
}
|
||||
|
||||
return c.json(result.data, 200)
|
||||
})
|
||||
|
||||
export default users
|
||||
37
supabase/migrations/005_sprint_3_6c_pattern_analyses.sql
Normal file
37
supabase/migrations/005_sprint_3_6c_pattern_analyses.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
-- Sprint 3.6c — Analyse patterns (Premium).
|
||||
--
|
||||
-- Table pattern_analyses : snapshot des patterns récurrents détectés sur les
|
||||
-- 5 dernières productions corrigées + exercices long terme + indice de préparation.
|
||||
--
|
||||
-- Stratégie d'invalidation : on INSERT un nouveau row à chaque recompute (pas
|
||||
-- d'UPDATE), pour garder un historique des analyses. La plus récente est
|
||||
-- récupérée via ORDER BY created_at DESC LIMIT 1.
|
||||
--
|
||||
-- À exécuter manuellement via `supabase db push` (Règle F).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pattern_analyses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
productions_ids UUID[] NOT NULL,
|
||||
patterns JSONB NOT NULL,
|
||||
exercises JSONB NOT NULL,
|
||||
preparation_index INTEGER NOT NULL,
|
||||
preparation_message TEXT NOT NULL,
|
||||
analyzed_count INTEGER NOT NULL DEFAULT 5,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE pattern_analyses
|
||||
DROP CONSTRAINT IF EXISTS pattern_analyses_preparation_index_check,
|
||||
ADD CONSTRAINT pattern_analyses_preparation_index_check
|
||||
CHECK (preparation_index BETWEEN 0 AND 100);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS pattern_analyses_user_created_idx
|
||||
ON pattern_analyses (user_id, created_at DESC);
|
||||
|
||||
ALTER TABLE pattern_analyses ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Utilisateur voit ses analyses" ON pattern_analyses;
|
||||
CREATE POLICY "Utilisateur voit ses analyses"
|
||||
ON pattern_analyses FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
Loading…
Add table
Add a link
Reference in a new issue