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:
parent
8390e8b873
commit
f51caa1b75
22 changed files with 1357 additions and 297 deletions
|
|
@ -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 ; ré-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). Ré-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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue