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 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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