feat(rapport): Sprint 3.6b — RapportPage enrichie, exercices dynamiques, production modèle, sélecteur NCLC

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-22 20:14:38 +03:00
parent 8390e8b873
commit f51caa1b75
22 changed files with 1357 additions and 297 deletions

View file

@ -79,20 +79,61 @@ export interface SimulationState {
contenu: string | null
sujet: SujetData | null
rapport: SimulationRapport | null
// Sprint 3.6a — nouveaux champs backend (null/pending si rapport non encore corrigé)
nclc_cible: 9 | 10 | null
exercices: SimulationExercice[] | null
exercices_status: SimulationJobStatus
modele: SimulationProductionModele | null
modele_status: SimulationJobStatus
}
export type SimulationJobStatus = 'pending' | 'ready' | 'error'
/**
* Rapport tel que stocké par le backend (sans `simulation_id` porté par SimulationState).
* Miroir de `EERapport` côté backend ; -exposé ici pour éviter l'import circulaire
* avec `entities/report/types.ts`.
* Rapport tel que stocké par le backend (sans `simulation_id`, `exercices`, `modele`
* qui sont portés par SimulationState). Miroir de `CorrectionRapport` côté backend
* (Sprint 3.6a). -exposé ici pour éviter l'import circulaire avec
* `entities/report/types.ts`.
*/
export interface SimulationRapport {
score: number
nclc: number
feedback_court: string
criteres: { nom: string; score: number; commentaire: string }[]
erreurs: string[]
modele: string
idees: string[]
exercices: string[]
nclc_cible: 9 | 10
revelation: { croyance: string; realite: string; consequence: string }
diagnostic: string
criteres: {
nom: string
score: number
commentaire: string
exemple: string
suggestion: string
astuce: string
}[]
conseil_nclc: { nclc_cible: string; ecart: string; action_prioritaire: string }
erreurs_codes: { code: string; critere: string; description: string | null }[]
}
export interface SimulationExercice {
difficulte: 'facile' | 'intermediaire' | 'difficile'
theme: string
diagnostic: string
consigne: string
extrait: string
indice: string
correction: string
explication: string
}
export interface SimulationProductionModele {
production_modele_propre: string
notes_pedagogiques: { passage: string; explication: string }[]
transformations: { original: string; ameliore: string; explication: string }[]
message: string
nclc_modele?: number
nclc_obtenu?: number
score_cible?: number
tcf_word_count?: number
tcf_word_min?: number
tcf_word_max?: number
tcf_truncated?: boolean
}

View file

@ -1,36 +1,29 @@
/**
* Tests de la logique de floutage isSectionVisible().
* Tests matrice de floutage Sprint 3.6b.
*
* Nouvelle matrice :
* - Floutables : criteres (detailed_report), exercices + modele (tips)
* - Toujours visibles : score, nclc, revelation, diagnostic, conseil_nclc
*
* Couverture : 5 sections floutables × 3 plans = 15 tests.
* Source de vérité : PLANS_TARIFAIRES.md §2.
*
* detailed_report : criteres, erreurs false pour Free, true pour Standard+
* tips : modele, idees, exercices false pour Free, true pour Standard+
*/
import { describe, it, expect } from 'vitest'
import { isSectionVisible } from '../lib'
import { isSectionVisible, groupErreursByCritere, ecartVsCible, critereCodeFromNom } from '../lib'
import type { ErreurCode } from '../types'
describe('isSectionVisible — plan free', () => {
it('criteres : non visible (detailed_report = false)', () => {
expect(isSectionVisible('free', 'criteres')).toBe(false)
})
it('erreurs : non visible (detailed_report = false)', () => {
expect(isSectionVisible('free', 'erreurs')).toBe(false)
it('exercices : non visible (tips = false)', () => {
expect(isSectionVisible('free', 'exercices')).toBe(false)
})
it('modele : non visible (tips = false)', () => {
expect(isSectionVisible('free', 'modele')).toBe(false)
})
it('idees : non visible (tips = false)', () => {
expect(isSectionVisible('free', 'idees')).toBe(false)
})
it('exercices : non visible (tips = false)', () => {
expect(isSectionVisible('free', 'exercices')).toBe(false)
})
})
describe('isSectionVisible — plan standard', () => {
@ -38,41 +31,77 @@ describe('isSectionVisible — plan standard', () => {
expect(isSectionVisible('standard', 'criteres')).toBe(true)
})
it('erreurs : visible (detailed_report = true)', () => {
expect(isSectionVisible('standard', 'erreurs')).toBe(true)
it('exercices : visible (tips = true)', () => {
expect(isSectionVisible('standard', 'exercices')).toBe(true)
})
it('modele : visible (tips = true)', () => {
expect(isSectionVisible('standard', 'modele')).toBe(true)
})
it('idees : visible (tips = true)', () => {
expect(isSectionVisible('standard', 'idees')).toBe(true)
})
it('exercices : visible (tips = true)', () => {
expect(isSectionVisible('standard', 'exercices')).toBe(true)
})
})
describe('isSectionVisible — plan premium', () => {
it('criteres : visible (detailed_report = true)', () => {
it('criteres : visible', () => {
expect(isSectionVisible('premium', 'criteres')).toBe(true)
})
it('erreurs : visible (detailed_report = true)', () => {
expect(isSectionVisible('premium', 'erreurs')).toBe(true)
})
it('modele : visible (tips = true)', () => {
expect(isSectionVisible('premium', 'modele')).toBe(true)
})
it('idees : visible (tips = true)', () => {
expect(isSectionVisible('premium', 'idees')).toBe(true)
})
it('exercices : visible (tips = true)', () => {
it('exercices : visible', () => {
expect(isSectionVisible('premium', 'exercices')).toBe(true)
})
it('modele : visible', () => {
expect(isSectionVisible('premium', 'modele')).toBe(true)
})
})
describe('groupErreursByCritere', () => {
it('regroupe les erreurs par code critère', () => {
const erreurs: ErreurCode[] = [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', description: null },
{ code: 'virgule_exces', critere: 'competence_grammaticale', description: null },
]
const grouped = groupErreursByCritere(erreurs)
expect(grouped.competence_grammaticale).toHaveLength(2)
expect(grouped.coherence_cohesion).toHaveLength(1)
expect(grouped.competence_lexicale).toHaveLength(0)
expect(grouped.adequation_tache).toHaveLength(0)
})
it('retourne les 4 critères même sur liste vide', () => {
const grouped = groupErreursByCritere([])
expect(Object.keys(grouped)).toHaveLength(4)
expect(grouped.competence_grammaticale).toEqual([])
})
})
describe('ecartVsCible', () => {
it('NCLC 9 atteint (score = 14)', () => {
expect(ecartVsCible(14, 9)).toEqual({ points: 0, atteint: true })
})
it('NCLC 9 non atteint (score = 12)', () => {
expect(ecartVsCible(12, 9)).toEqual({ points: 2, atteint: false })
})
it('NCLC 10 atteint (score = 18)', () => {
expect(ecartVsCible(18, 10)).toEqual({ points: 0, atteint: true })
})
it('score supérieur à cible : atteint + points=0', () => {
expect(ecartVsCible(20, 9)).toEqual({ points: 0, atteint: true })
})
})
describe('critereCodeFromNom', () => {
it('mappe les 4 libellés officiels', () => {
expect(critereCodeFromNom('Adéquation à la tâche et au registre')).toBe('adequation_tache')
expect(critereCodeFromNom('Cohérence et cohésion du discours')).toBe('coherence_cohesion')
expect(critereCodeFromNom('Compétence lexicale')).toBe('competence_lexicale')
expect(critereCodeFromNom('Compétence grammaticale')).toBe('competence_grammaticale')
})
it('retourne null sur un libellé inconnu', () => {
expect(critereCodeFromNom('Autre chose')).toBeNull()
})
})

View file

@ -31,7 +31,17 @@ export function getReport(id: string): Promise<Report> {
message: 'Simulation en cours — rédaction pas encore corrigée.',
}
}
return { ...state.rapport, simulation_id: state.simulation_id }
// Sprint 3.6b : reconstruit un Report en combinant rapport (correction) +
// exercices / modele (jobs fire-and-forget, portés par SimulationState).
return {
...state.rapport,
simulation_id: state.simulation_id,
erreurs_codes: state.rapport.erreurs_codes as Report['erreurs_codes'],
exercices: state.exercices as Report['exercices'],
exercices_status: state.exercices_status,
modele: state.modele as Report['modele'],
modele_status: state.modele_status,
}
})
}

View file

@ -4,22 +4,24 @@
* Import cross-entity volontaire et documenté :
* entities/report entities/user/lib (hasAccess, Plan)
* Justification : la logique de floutage dépend intrinsèquement des permissions
* utilisateur. Exception validée cf. ARCHITECTURE.md §3 et étape 13c.
* utilisateur. Exception validée cf. ARCHITECTURE.md §3.
*
* Règle D : aucun `if (plan === 'xxx')` tout passe par hasAccess().
* Règle H : cette logique vit ici, jamais dans les composants features/.
*
* Sprint 3.6b : nouvelle matrice `revelation`, `diagnostic`, `conseil_nclc`
* sont visibles tous plans (pas listés ici). Seuls `criteres`, `exercices`,
* `modele` sont floutés selon le plan (cf. types.ts BlurableSection).
*/
import { hasAccess } from '@/entities/user/lib'
import type { Plan } from '@/entities/user/lib'
import type { BlurableSection } from './types'
import type { BlurableSection, Critere, ErreurCode, CritereCode } from './types'
const SECTION_FEATURE: Record<BlurableSection, 'detailed_report' | 'tips'> = {
criteres: 'detailed_report',
erreurs: 'detailed_report',
modele: 'tips',
idees: 'tips',
exercices: 'tips',
modele: 'tips',
}
/**
@ -32,3 +34,56 @@ const SECTION_FEATURE: Record<BlurableSection, 'detailed_report' | 'tips'> = {
export function isSectionVisible(plan: Plan, section: BlurableSection): boolean {
return hasAccess(plan, SECTION_FEATURE[section])
}
/**
* Regroupe les codes d'erreurs par critère pour affichage dans les cartes critère.
*
* Le backend retourne `erreurs_codes` en top-level ; l'UI les affiche
* contextualisés dans chaque carte critère correspondante.
*/
export function groupErreursByCritere(
erreursCodes: ErreurCode[],
): Record<CritereCode, ErreurCode[]> {
const acc: Record<CritereCode, ErreurCode[]> = {
adequation_tache: [],
coherence_cohesion: [],
competence_lexicale: [],
competence_grammaticale: [],
}
for (const err of erreursCodes) {
acc[err.critere].push(err)
}
return acc
}
/**
* Mappe le nom d'un critère (libellé humain backend) vers son code taxonomie,
* pour rattacher `erreurs_codes` à la bonne carte critère côté UI.
*/
const CRITERE_NOM_TO_CODE: Record<string, CritereCode> = {
'Adéquation à la tâche et au registre': 'adequation_tache',
'Cohérence et cohésion du discours': 'coherence_cohesion',
'Compétence lexicale': 'competence_lexicale',
'Compétence grammaticale': 'competence_grammaticale',
}
export function critereCodeFromNom(nom: string): CritereCode | null {
return CRITERE_NOM_TO_CODE[nom] ?? null
}
/**
* Calcule l'écart en points /20 entre le score obtenu et l'objectif NCLC cible.
* Barème TCF Canada (cf. Prompt_maître.md §Barème) : NCLC 9 14/20, NCLC 10 16/20.
*/
const NCLC_MIN_SCORE: Record<number, number> = { 7: 10, 8: 12, 9: 14, 10: 16 }
export function ecartVsCible(score: number, nclcCible: number): {
points: number
atteint: boolean
} {
const minScore = NCLC_MIN_SCORE[nclcCible] ?? NCLC_MIN_SCORE[9]!
const points = Math.max(0, minScore - score)
return { points, atteint: score >= minScore }
}
export type { Critere }

View file

@ -1,41 +1,118 @@
/**
* Types publics du domaine `report`.
* Types publics du domaine `report` Sprint 3.6b.
*
* Un `Report` est retourné par le backend après correction d'une production
* (DeepSeek pour EE, Gemini pour EO). Le backend renvoie directement le payload
* métier pas d'enveloppe (cf. ARCHITECTURE.md §5).
* Structure alignée sur le backend Sprint 3.6a (prompt maître + production
* modèle + exercices fire-and-forget). Aucune enveloppe : le payload est
* retourné directement (cf. ARCHITECTURE.md §5).
*
* Visibilité par section selon le plan (cf. PLANS_TARIFAIRES.md §2) :
* - score, nclc, feedback_court toujours visibles (tous plans)
* - criteres, erreurs detailed_report (Standard+)
* - modele, idees, exercices tips (Standard+)
* - score, nclc, nclc_cible, revelation, diagnostic, conseil_nclc tous plans
* - criteres (exemple/suggestion/astuce) detailed_report (Standard+)
* - exercices, modele tips (Standard+)
*
* La logique de floutage vit dans lib.ts via isSectionVisible() jamais dans
* les composants (Règle H). Voir aussi SEC-05 : ces champs contiennent du texte
* IA potentiellement malveillant les rendre via react-markdown, jamais via
* dangerouslySetInnerHTML.
* SEC-05 : textes IA rendus via react-markdown, jamais dangerouslySetInnerHTML.
*/
/** Codes taxonomie d'erreurs — valeurs exhaustives dans `TAXONOMIE_ERREURS.md` v1.0. */
export type CritereCode =
| 'adequation_tache'
| 'coherence_cohesion'
| 'competence_lexicale'
| 'competence_grammaticale'
export interface ErreurCode {
code: string
critere: CritereCode
description: string | null
}
export interface Critere {
nom: string
score: number
score: number // 0-5
commentaire: string
exemple: string
suggestion: string
astuce: string
}
export interface Revelation {
croyance: string
realite: string
consequence: string
}
export interface ConseilNclc {
nclc_cible: string // ex. "NCLC 9"
ecart: string
action_prioritaire: string
}
export type Difficulte = 'facile' | 'intermediaire' | 'difficile'
/** Libellés affichés pour chaque niveau de difficulté. */
export const DIFFICULTE_LABEL: Record<Difficulte, string> = {
facile: 'Facile',
intermediaire: 'Moyen',
difficile: 'Difficile',
}
export interface Exercice {
difficulte: Difficulte
theme: string
diagnostic: string
consigne: string
extrait: string
indice: string
correction: string
explication: string
}
export interface NotePedagogique {
passage: string
explication: string
}
export interface Transformation {
original: string
ameliore: string
explication: string
}
export interface ProductionModele {
production_modele_propre: string
notes_pedagogiques: NotePedagogique[]
transformations: Transformation[]
message: string
// Métadonnées backend — non affichées côté UI mais exposées pour complétude.
nclc_modele?: number
nclc_obtenu?: number
score_cible?: number
tcf_word_count?: number
tcf_word_min?: number
tcf_word_max?: number
tcf_truncated?: boolean
}
export type JobStatus = 'pending' | 'ready' | 'error'
export type NclcCible = 9 | 10
/**
* Rapport de correction renvoyé par `POST /corrections/ee` ou `POST /corrections/eo`.
* Timeout 30 s (DeepSeek/Gemini cf. api.ts).
* Rapport de correction renvoyé par `GET /simulations/:id` (et ressources dérivées).
*/
export interface Report {
simulation_id: string
score: number // /20
nclc: number // estimation NCLC, ex. 7.5
feedback_court: string // 2-3 lignes — toujours visible
criteres: Critere[] // feature : detailed_report
erreurs: string[] // feature : detailed_report
modele: string // feature : tips — 1re phrase visible pour Free
idees: string[] // feature : tips — 1er item visible pour Free
exercices: string[] // feature : tips — 1er item visible pour Free
nclc: number // NCLC atteint — ex. 8
nclc_cible: NclcCible
revelation: Revelation
diagnostic: string
criteres: Critere[]
conseil_nclc: ConseilNclc
erreurs_codes: ErreurCode[] // top-level — regroupés par critère côté UI
exercices: Exercice[] | null
exercices_status: JobStatus
modele: ProductionModele | null
modele_status: JobStatus
}
/** Corps de `POST /corrections/ee`. */
@ -43,6 +120,7 @@ export interface CorrectEePayload {
simulationId: string
contenu: string
tache: string
nclc_cible?: NclcCible // défaut backend : 9
}
/**
@ -55,5 +133,14 @@ export interface CorrectEoPayload {
tache: string
}
/** Sections du rapport dont la visibilité dépend du plan. */
export type BlurableSection = 'criteres' | 'erreurs' | 'modele' | 'idees' | 'exercices'
/**
* Sections du rapport dont la visibilité dépend du plan (Sprint 3.6b).
*
* - `criteres` gate `detailed_report` : floute les champs exemple/suggestion/astuce
* + codes d'erreurs pour les utilisateurs Free.
* - `exercices` / `modele` gate `tips`.
*
* `revelation`, `diagnostic`, `conseil_nclc`, `score`, `nclc` ne sont **pas**
* des `BlurableSection` : elles sont visibles pour tous les plans (cf. PLANS_TARIFAIRES.md §2).
*/
export type BlurableSection = 'criteres' | 'exercices' | 'modele'