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:
Hermann_Kitio 2026-04-22 22:06:14 +03:00
parent a394ce8429
commit c48ae8d443
6 changed files with 1055 additions and 0 deletions

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

View 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,
},
}
}

View file

@ -10,6 +10,7 @@ import sujetsRoutes from './routes/sujets.js'
import correctionsRoutes from './routes/corrections.js' import correctionsRoutes from './routes/corrections.js'
import stripeRoutes from './routes/stripe.js' import stripeRoutes from './routes/stripe.js'
import createT2LiveRoutes from './routes/t2live.js' import createT2LiveRoutes from './routes/t2live.js'
import usersRoutes from './routes/users.js'
const app = new Hono() const app = new Hono()
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app }) const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app })
@ -38,6 +39,7 @@ app.route('/sujets', sujetsRoutes)
app.route('/corrections', correctionsRoutes) app.route('/corrections', correctionsRoutes)
app.route('/stripe', stripeRoutes) app.route('/stripe', stripeRoutes)
app.route('/t2', createT2LiveRoutes(upgradeWebSocket)) app.route('/t2', createT2LiveRoutes(upgradeWebSocket))
app.route('/users', usersRoutes)
const port = Number(process.env.PORT) || 3000 const port = Number(process.env.PORT) || 3000

View file

@ -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 ──────────────────── // ── EO (Expression Orale) — inchangé par Sprint 3.6a ────────────────────
export interface EOCritere { export interface EOCritere {

26
src/routes/users.ts Normal file
View 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

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