From c48ae8d443a6f01436039543b43f7b095526d844 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Wed, 22 Apr 2026 22:06:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(patterns):=20GET=20/users/patterns=20?= =?UTF-8?q?=E2=80=94=20agr=C3=A9gation=20erreurs=20r=C3=A9currentes=20+=20?= =?UTF-8?q?exercices=20long=20terme=20+=20indice=20de=20pr=C3=A9paration?= =?UTF-8?q?=20(Sprint=203.6c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/patternsController.test.ts | 511 ++++++++++++++++++ src/controllers/patternsController.ts | 336 ++++++++++++ src/index.ts | 2 + src/lib/deepseek.ts | 143 +++++ src/routes/users.ts | 26 + .../005_sprint_3_6c_pattern_analyses.sql | 37 ++ 6 files changed, 1055 insertions(+) create mode 100644 src/controllers/__tests__/patternsController.test.ts create mode 100644 src/controllers/patternsController.ts create mode 100644 src/routes/users.ts create mode 100644 supabase/migrations/005_sprint_3_6c_pattern_analyses.sql diff --git a/src/controllers/__tests__/patternsController.test.ts b/src/controllers/__tests__/patternsController.test.ts new file mode 100644 index 0000000..fd1f630 --- /dev/null +++ b/src/controllers/__tests__/patternsController.test.ts @@ -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('../../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> = {}) { + 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) { + 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 + }) +}) diff --git a/src/controllers/patternsController.ts b/src/controllers/patternsController.ts new file mode 100644 index 0000000..0de7dbb --- /dev/null +++ b/src/controllers/patternsController.ts @@ -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() + + 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() + 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, + }, + } +} diff --git a/src/index.ts b/src/index.ts index fdc8079..2acbffb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/lib/deepseek.ts b/src/lib/deepseek.ts index bd7c849..faeb616 100644 --- a/src/lib/deepseek.ts +++ b/src/lib/deepseek.ts @@ -609,6 +609,149 @@ export async function generateExercices(input: ExercicesInput): Promise", + "critere": "", + "diagnostic": "<1-2 phrases>", + "exercice": { + "consigne": "", + "exemple": "", + "correction": "", + "astuce": "" + } + } + ] +}` + +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 { + 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 + const ex = item.exercice as Record | 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 { diff --git a/src/routes/users.ts b/src/routes/users.ts new file mode 100644 index 0000000..3ab315b --- /dev/null +++ b/src/routes/users.ts @@ -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 diff --git a/supabase/migrations/005_sprint_3_6c_pattern_analyses.sql b/supabase/migrations/005_sprint_3_6c_pattern_analyses.sql new file mode 100644 index 0000000..8bfa3fa --- /dev/null +++ b/supabase/migrations/005_sprint_3_6c_pattern_analyses.sql @@ -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);