From 7a7450b85771231d0414b39d5dd0bce1b77353a8 Mon Sep 17 00:00:00 2001 From: freemind25 Date: Sun, 28 Jun 2026 17:59:07 +0000 Subject: [PATCH 1/2] feat: PWA complete (manifest + service worker), Transfer Learning UI wired, toggle buttons - PWA: manifest.json with icons, Apple meta tags, service worker (cache-first for assets, network-first for HTML) - Service worker: pre-caches app shell, font caching, offline fallback - Transfer Learning: panel now accessible via toggle button, linked to ML detector's custom model - Added 'Outils' toolbar with Plagiat and Transfer Learning toggle buttons - VS Code extension already functional (scan file, scan workspace, clear, inline decorations, diagnostics) - 26/26 tests, build successful --- index.html | 8 ++ public/manifest.json | 26 ++++ public/sw.js | 134 ++++++++++++++++++ .../TextCleaner/TransferLearningPanel.tsx | 6 +- src/components/TextCleaner/index.tsx | 24 +++- src/main.tsx | 9 ++ 6 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 public/manifest.json create mode 100644 public/sw.js diff --git a/index.html b/index.html index 89b20fd..935e63b 100644 --- a/index.html +++ b/index.html @@ -21,6 +21,14 @@ + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..6cfc978 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "UnRobot", + "short_name": "UnRobot", + "description": "Détection et nettoyage de texte IA — 100% local, hors ligne", + "start_url": "/", + "display": "standalone", + "background_color": "#09090b", + "theme_color": "#6366f1", + "orientation": "any", + "lang": "fr", + "categories": ["productivity", "utilities"], + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} \ No newline at end of file diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..bd8953e --- /dev/null +++ b/public/sw.js @@ -0,0 +1,134 @@ +/// + +const CACHE_NAME = "unrobot-v1"; + +// App shell — ressources à mettre en cache au premier chargement +const APP_SHELL = [ + "/", + "/index.html", + "/manifest.json", + "/icons/icon-192.png", + "/icons/icon-512.png", +]; + +// Ressources externes préconnectées (fonts) +const FONT_ORIGIN = "https://fonts.googleapis.com"; +const FONT_STATIC_ORIGIN = "https://fonts.gstatic.com"; + +// Extensions à mettre en cache avec stratégie cache-first +const CACHE_FIRST_EXTENSIONS = [ + ".js", + ".css", + ".png", + ".svg", + ".woff", + ".woff2", + ".wasm", +]; + +// Installation : pré-cache l'app shell +self.addEventListener("install", (event: ExtendableEvent) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(APP_SHELL)) + .then(() => (self as unknown as ServiceWorkerGlobalScope).skipWaiting()) + ); +}); + +// Activation : nettoie les anciens caches +self.addEventListener("activate", (event: ExtendableEvent) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)) + ) + ) + .then(() => (self as unknown as ServiceWorkerGlobalScope).clients.claim()) + ); +}); + +// Fetch : stratégie hybride +self.addEventListener("fetch", (event: FetchEvent) => { + const url = new URL(event.request.url); + + // Ne pas intercepter les requêtes non-GET + if (event.request.method !== "GET") return; + + // Ne pas intercepter les requêtes d'API ou Chrome extensions + if (url.protocol === "chrome-extension:") return; + + // Fonts : cache-first avec cache séparé + if (url.origin === FONT_ORIGIN || url.origin === FONT_STATIC_ORIGIN) { + event.respondWith(cacheFirst(event.request, "unrobot-fonts")); + return; + } + + // Fichiers statiques (.js, .css, images, wasm) : cache-first + const isStaticAsset = CACHE_FIRST_EXTENSIONS.some((ext) => + url.pathname.endsWith(ext) + ); + if (isStaticAsset && url.origin === self.location.origin) { + event.respondWith(cacheFirst(event.request, CACHE_NAME)); + return; + } + + // HTML (navigation) : network-first avec fallback cache + if (event.request.mode === "navigate") { + event.respondWith(networkFirst(event.request, CACHE_NAME)); + return; + } +}); + +/** + * Cache-first : cherche en cache, sinon fetch et met en cache. + */ +async function cacheFirst( + request: Request, + cacheName: string +): Promise { + const cached = await caches.match(request); + if (cached) return cached; + + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + cache.put(request, response.clone()); + } + return response; + } catch { + // Hors ligne et pas en cache : retourner une réponse vide + return new Response("Hors ligne", { status: 503, statusText: "Hors ligne" }); + } +} + +/** + * Network-first : essaie le réseau, fallback sur le cache. + */ +async function networkFirst( + request: Request, + cacheName: string +): Promise { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + cache.put(request, response.clone()); + } + return response; + } catch { + const cached = await caches.match(request); + if (cached) return cached; + // Dernier recours : l'app shell HTML + const shell = await caches.match("/index.html"); + if (shell) return shell; + return new Response("Hors ligne", { status: 503, statusText: "Hors ligne" }); + } +} + +export {}; \ No newline at end of file diff --git a/src/components/TextCleaner/TransferLearningPanel.tsx b/src/components/TextCleaner/TransferLearningPanel.tsx index 264168d..f9428d3 100755 --- a/src/components/TextCleaner/TransferLearningPanel.tsx +++ b/src/components/TextCleaner/TransferLearningPanel.tsx @@ -1,8 +1,8 @@ import React, { useState, useCallback } from "react"; import { toast } from "sonner"; import { - trainCustomModel, loadModelsFromIDB, deleteModelFromIDB, saveDatasetToIDB, - loadDatasetFromIDB, listDatasetsFromIDB, + trainCustomModel, loadModelsFromIDB, deleteModelFromIDB, saveModelToIDB, + saveDatasetToIDB, loadDatasetFromIDB, listDatasetsFromIDB, type LabeledText, type TrainingProgress, type CustomModel, DEFAULT_TRAINING_CONFIG, } from "@/lib/transfer"; import { Brain, Play, Square, Trash2, Download, Upload, Check, X, Loader2, Cpu, Save, FolderOpen } from "lucide-react"; @@ -117,7 +117,7 @@ export const TransferLearningPanel: React.FC = ({ on

Modèle personnalisé

-

Transfer Learning in-browser · TensorFlow.js

+

Transfer Learning in-browser · Pur JavaScript

{activeModel && ( diff --git a/src/components/TextCleaner/index.tsx b/src/components/TextCleaner/index.tsx index 635d403..cf70f37 100644 --- a/src/components/TextCleaner/index.tsx +++ b/src/components/TextCleaner/index.tsx @@ -16,12 +16,14 @@ import { AIAnalysis } from "./AIAnalysis"; import { ModeSelector } from "./ModeSelector"; import { WriterProfilePanel } from "./WriterProfilePanel"; import { PlagiarismPanel } from "./PlagiarismPanel"; +import { TransferLearningPanel } from "./TransferLearningPanel"; import { EXAMPLE_TEXTS } from "@/data/exampleTexts"; import { Button } from "@/components/ui/button"; -import { FileText, FileJson, FileDown } from "lucide-react"; +import { FileText, FileJson, FileDown, ShieldCheck, Brain } from "lucide-react"; import { downloadReportJSON, downloadReportPDF } from "@/lib/report"; import { downloadBlob } from "@/lib/utils"; import type { HybridAnalysis } from "@/lib/ml/types"; +import type { CustomModel } from "@/lib/transfer"; export const TextCleaner: React.FC = () => { const { @@ -49,7 +51,7 @@ export const TextCleaner: React.FC = () => { } = useTextCleaner(); const { analyzeText } = useAIDetector(); - const { modelState, modelInfo, analyzeWithML, isMLInitializing } = useMLDetector(); + const { modelState, modelInfo, analyzeWithML, isMLInitializing, customModel, setCustomModel } = useMLDetector(); const { checkPlagiarism, addRef, removeRef, clearAllRefs, references, corpusSize, lastResult: plagiarismResult, isChecking: isCheckingPlagiarism, importFile } = usePlagiarism(); const [isCopied, setIsCopied] = useState(false); @@ -57,6 +59,7 @@ export const TextCleaner: React.FC = () => { const [analysis, setAnalysis] = useState(null); const [hybrid, setHybrid] = useState(null); const [showPlagiarism, setShowPlagiarism] = useState(false); + const [showTransferLearning, setShowTransferLearning] = useState(false); const hasText = text.trim().length > 0; @@ -194,6 +197,16 @@ export const TextCleaner: React.FC = () => { isCheckingPlagiarism={isCheckingPlagiarism} /> +
+ Outils : + + +
+
Rapport : + + {/* Barre de progression globale toujours visible */} +
+
+
+ + {open && ( +
+ {/* Grille des 4 indices */} +
+ {INDICES.map(({ key, label: indexLabel, unit, inverted, description, range }) => { + const raw = result[key] as number; + const progress = indexProgress(key, raw); + const iColor = indexColor(progress); + return ( +
+
+ + {indexLabel} + + + + + + + +

{description}

+

{range}

+ {inverted && ( +

+ ↓ Valeur basse = meilleur +

+ )} +
+
+
+ + {raw} + {unit} + +
+
+
+
+
+ ); + })} +
+ + {/* Métriques brutes */} +
+

Métriques du texte

+
+ {[ + { label: "Mots", value: metrics.wordCount }, + { label: "Phrases", value: metrics.sentenceCount }, + { label: "Mots/phrase", value: metrics.avgWordsPerSentence }, + { label: "Syllabes/mot", value: metrics.avgSyllablesPerWord }, + { label: "Mots longs", value: `${metrics.longWordRatio}%` }, + { label: "Chars/mot", value: metrics.avgCharsPerWord }, + ].map(({ label: mLabel, value }) => ( +
+ {value} + {mLabel} +
+ ))} +
+
+ + {/* Note épistémique */} +

+ Ces indices fournissent des estimations comparatives, pas des verdicts absolus. + Un texte technique spécialisé peut obtenir un score faible sans que cela soit un défaut + pour son audience cible. +

+
+ )} +
+ + ); +}; diff --git a/src/components/TextCleaner/index.tsx b/src/components/TextCleaner/index.tsx index cf70f37..9319c7a 100644 --- a/src/components/TextCleaner/index.tsx +++ b/src/components/TextCleaner/index.tsx @@ -17,6 +17,7 @@ import { ModeSelector } from "./ModeSelector"; import { WriterProfilePanel } from "./WriterProfilePanel"; import { PlagiarismPanel } from "./PlagiarismPanel"; import { TransferLearningPanel } from "./TransferLearningPanel"; +import { ReadabilityLab } from "./ReadabilityLab"; import { EXAMPLE_TEXTS } from "@/data/exampleTexts"; import { Button } from "@/components/ui/button"; import { FileText, FileJson, FileDown, ShieldCheck, Brain } from "lucide-react"; @@ -226,6 +227,8 @@ export const TextCleaner: React.FC = () => { + {hasText && } + {showTransferLearning && ( setCustomModel(m)} diff --git a/src/lib/__tests__/readability.test.ts b/src/lib/__tests__/readability.test.ts new file mode 100644 index 0000000..b5628cd --- /dev/null +++ b/src/lib/__tests__/readability.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { analyzeReadability } from "../readability"; + +// Texte simple : phrases courtes, mots courants +const SIMPLE_TEXT = + "Le chat dort sur le tapis. Il fait beau dehors. Les enfants jouent dans le jardin. " + + "La maison est grande. Le soleil brille. On mange à midi. La soupe est chaude."; + +// Texte académique/technique : phrases longues, vocabulaire soutenu +const COMPLEX_TEXT = + "La morphologie urbaine, entendue comme l'étude des formes de la ville et de leurs " + + "transformations au cours du temps, constitue un champ disciplinaire à la croisée de " + + "l'architecture, de l'urbanisme et de la géographie. Les analyses typo-morphologiques, " + + "développées notamment par l'école italienne avec Saverio Muratori puis Gianfranco " + + "Caniggia, permettent d'appréhender la structure des tissus urbains à travers la " + + "combinaison des types architecturaux et des tracés parcellaires. La permanence des " + + "infrastructures viaires constitue ainsi un invariant structurant dans l'évolution " + + "diachronique des configurations spatiales."; + +describe("analyzeReadability", () => { + it("retourne null pour un texte trop court", () => { + expect(analyzeReadability("Bonjour.")).toBeNull(); + expect(analyzeReadability("")).toBeNull(); + }); + + it("retourne un résultat valide pour un texte suffisant", () => { + const r = analyzeReadability(SIMPLE_TEXT); + expect(r).not.toBeNull(); + expect(r!.metrics.wordCount).toBeGreaterThan(0); + expect(r!.metrics.sentenceCount).toBeGreaterThan(0); + }); + + it("le texte simple obtient un score global plus élevé que le texte complexe", () => { + const simple = analyzeReadability(SIMPLE_TEXT)!; + const complex = analyzeReadability(COMPLEX_TEXT)!; + expect(simple.globalScore).toBeGreaterThan(complex.globalScore); + }); + + it("Flesch-Kincaid est dans la plage 0–100", () => { + const r = analyzeReadability(SIMPLE_TEXT)!; + expect(r.fleschKincaid).toBeGreaterThanOrEqual(0); + expect(r.fleschKincaid).toBeLessThanOrEqual(100); + }); + + it("LIX est plus faible pour le texte simple", () => { + const simple = analyzeReadability(SIMPLE_TEXT)!; + const complex = analyzeReadability(COMPLEX_TEXT)!; + expect(simple.lix).toBeLessThan(complex.lix); + }); + + it("Gunning Fog est plus faible pour le texte simple", () => { + const simple = analyzeReadability(SIMPLE_TEXT)!; + const complex = analyzeReadability(COMPLEX_TEXT)!; + expect(simple.gunningFog).toBeLessThan(complex.gunningFog); + }); + + it("le texte simple reçoit un label lisible", () => { + const r = analyzeReadability(SIMPLE_TEXT)!; + expect(["Très accessible", "Accessible"]).toContain(r.label); + }); + + it("le texte complexe reçoit un label avancé ou technique", () => { + const r = analyzeReadability(COMPLEX_TEXT)!; + expect(["Niveau avancé", "Très technique", "Niveau intermédiaire"]).toContain(r.label); + }); + + it("les métriques brutes sont cohérentes entre elles", () => { + const r = analyzeReadability(SIMPLE_TEXT)!; + expect(r.metrics.avgWordsPerSentence).toBeGreaterThan(0); + expect(r.metrics.avgSyllablesPerWord).toBeGreaterThanOrEqual(1); + expect(r.metrics.longWordRatio).toBeGreaterThanOrEqual(0); + expect(r.metrics.longWordRatio).toBeLessThanOrEqual(100); + }); + + it("le score global est dans la plage 0–100", () => { + const simple = analyzeReadability(SIMPLE_TEXT)!; + const complex = analyzeReadability(COMPLEX_TEXT)!; + expect(simple.globalScore).toBeGreaterThanOrEqual(0); + expect(simple.globalScore).toBeLessThanOrEqual(100); + expect(complex.globalScore).toBeGreaterThanOrEqual(0); + expect(complex.globalScore).toBeLessThanOrEqual(100); + }); + + it("détecte la lisibilité d'un texte urbanistique réaliste", () => { + const text = + "Le plan local d'urbanisme intercommunal définit les règles générales d'utilisation " + + "du sol applicables dans les zones urbaines et les zones à urbaniser. Les orientations " + + "d'aménagement et de programmation précisent les conditions d'aménagement de certains " + + "secteurs qui nécessitent une attention particulière."; + const r = analyzeReadability(text)!; + expect(r).not.toBeNull(); + // Un texte réglementaire doit être classé au moins intermédiaire + expect(r.globalScore).toBeLessThan(75); + }); +}); diff --git a/src/lib/readability.ts b/src/lib/readability.ts new file mode 100644 index 0000000..fa32cb1 --- /dev/null +++ b/src/lib/readability.ts @@ -0,0 +1,245 @@ +/** + * Readability Lab — UnRobot + * 4 indices de lisibilité calibrés pour le français, 100 % local. + * + * Indices implémentés : + * 1. Flesch-Kincaid Reading Ease (adapté français, Kandel & Moles 1958) + * 2. LIX — Läsbarhetsindex (Björnsson 1968, langue-agnostique) + * 3. Gunning Fog Index (adapté : mots longs ≥ 3 syllabes en proxy) + * 4. Coleman-Liau Index (opère sur les caractères → langue-agnostique) + * + * Aucune de ces formules n'est une vérité absolue ; elles fournissent + * des estimations comparatives. Toutes les sorties sont accompagnées + * d'un label qualitatif pour éviter de présenter les scores bruts + * comme une précision qu'ils n'ont pas. + */ + +export interface ReadabilityResult { + /** Flesch-Kincaid Reading Ease adapté français (0–100, plus haut = plus lisible) */ + fleschKincaid: number; + /** LIX — Läsbarhetsindex (0–100+, plus bas = plus lisible) */ + lix: number; + /** Gunning Fog adapté français (années d'études estimées, ~6 = école primaire) */ + gunningFog: number; + /** Coleman-Liau Index (années d'études estimées, opère sur les caractères) */ + colemanLiau: number; + /** Score moyen de lisibilité normalisé 0–100 (100 = très lisible) */ + globalScore: number; + /** Label qualitatif du score global */ + label: ReadabilityLabel; + /** Couleur sémantique associée au label */ + color: "green" | "yellow" | "orange" | "red"; + /** Métriques brutes intermédiaires (utiles pour le débogage et l'affichage) */ + metrics: { + wordCount: number; + sentenceCount: number; + avgWordsPerSentence: number; + avgSyllablesPerWord: number; + longWordRatio: number; // proportion de mots ≥ 3 syllabes + avgCharsPerWord: number; + longWordCount: number; // mots ≥ 7 caractères (critère LIX) + }; +} + +export type ReadabilityLabel = + | "Très accessible" + | "Accessible" + | "Niveau intermédiaire" + | "Niveau avancé" + | "Très technique"; + +const MIN_TEXT_LENGTH = 30; + +// ───────────────────────────────────────────────────────────── +// Segmentation +// ───────────────────────────────────────────────────────────── + +function splitSentences(text: string): string[] { + // Séparer sur . ! ? suivis d'un espace ou de fin de chaîne. + // On conserve les abréviations courantes (M., Dr., etc.) grâce + // au test de longueur minimum après split. + return text + .split(/[.!?…]+(?:\s|$)/) + .map((s) => s.trim()) + .filter((s) => s.split(/\s+/).filter(Boolean).length >= 2); +} + +function splitWords(text: string): string[] { + // Retirer la ponctuation et les nombres purs, garder les mots + return text + .replace(/[^a-zA-ZÀ-ÿ\s'-]/g, " ") + .split(/\s+/) + .map((w) => w.replace(/^[-']+|[-']+$/g, "")) + .filter((w) => w.length >= 2); +} + +// ───────────────────────────────────────────────────────────── +// Comptage de syllabes (heuristique française) +// Méthode : compter les voyelles consécutives comme 1 syllabe, +// traiter les diphtongues courantes, plancher à 1. +// Précision estimée : ±0.3 syllabe/mot, suffisant pour les indices. +// ───────────────────────────────────────────────────────────── + +const VOWELS_FR = /[aàâäæeéèêëiîïoôœuùûüy]/gi; + +function countSyllablesFr(word: string): number { + const w = word.toLowerCase(); + // Supprimer le 'e' muet final (très fréquent en français) + const normalized = w.replace(/e(?=[^aeiouyàâäæéèêëîïôœùûüy]$|$)/g, ""); + const vowels = normalized.match(VOWELS_FR); + if (!vowels) return 1; + // Fusionner les voyelles consécutives (diphtongues/triphtongues) + const grouped = normalized.replace(/[aàâäæeéèêëiîïoôœuùûüy]+/gi, "X"); + const count = (grouped.match(/X/g) || []).length; + return Math.max(1, count); +} + +// ───────────────────────────────────────────────────────────── +// Index 1 : Flesch-Kincaid Reading Ease (adaptation française) +// Kandel & Moles (1958) : FK_fr = 207 – (1.015 × mots/phrase) – (73.6 × syllabes/mot) +// Score 0–100 : 90+ = très simple, <30 = très difficile +// ───────────────────────────────────────────────────────────── + +function computeFleschKincaid( + avgWordsPerSentence: number, + avgSyllablesPerWord: number +): number { + const raw = 207 - 1.015 * avgWordsPerSentence - 73.6 * avgSyllablesPerWord; + return Math.round(Math.min(100, Math.max(0, raw))); +} + +// ───────────────────────────────────────────────────────────── +// Index 2 : LIX — Läsbarhetsindex (Björnsson 1968) +// LIX = (mots / phrases) + (mots longs×100 / total mots) +// "Mot long" = ≥ 7 caractères +// Score : <25 très simple, 25-40 simple, 40-50 moyen, 50-60 difficile, >60 très difficile +// ───────────────────────────────────────────────────────────── + +function computeLIX( + wordCount: number, + sentenceCount: number, + longWordCount: number +): number { + if (sentenceCount === 0 || wordCount === 0) return 0; + const raw = wordCount / sentenceCount + (longWordCount * 100) / wordCount; + return Math.round(raw * 10) / 10; +} + +// ───────────────────────────────────────────────────────────── +// Index 3 : Gunning Fog (adapté français) +// GF = 0.4 × (mots/phrase + % mots ≥ 3 syllabes × 100) +// Résultat en années d'études estimées (~6 = école primaire, 12 = bac, 17+ = expert) +// ───────────────────────────────────────────────────────────── + +function computeGunningFog( + avgWordsPerSentence: number, + longWordRatio: number +): number { + const raw = 0.4 * (avgWordsPerSentence + longWordRatio * 100); + return Math.round(raw * 10) / 10; +} + +// ───────────────────────────────────────────────────────────── +// Index 4 : Coleman-Liau +// CLI = 0.0588 × (chars/mot × 100) – 0.296 × (phrases/mot × 100) – 15.8 +// Résultat en années d'études. Langue-agnostique car opère sur les caractères. +// ───────────────────────────────────────────────────────────── + +function computeColemanLiau( + avgCharsPerWord: number, + wordCount: number, + sentenceCount: number +): number { + if (wordCount === 0) return 0; + const L = avgCharsPerWord * 100; // chars pour 100 mots + const S = (sentenceCount / wordCount) * 100; // phrases pour 100 mots + const raw = 0.0588 * L - 0.296 * S - 15.8; + return Math.round(raw * 10) / 10; +} + +// ───────────────────────────────────────────────────────────── +// Score global normalisé 0–100 +// Combine les 4 indices après normalisation sur une échelle commune. +// ───────────────────────────────────────────────────────────── + +function computeGlobalScore( + fk: number, + lix: number, + gf: number, + cli: number +): number { + // FK est déjà 0–100, sens croissant (lisibilité). On le garde tel quel. + // LIX : 0–70+ ; on convertit : 100 – clamp(lix, 0, 70) × (100/70) + const lixNorm = Math.max(0, 100 - (Math.min(lix, 70) / 70) * 100); + // GF : 6–20+ ; 6 = très lisible, 20 = très complexe. Normalisation inverse. + const gfNorm = Math.max(0, 100 - ((Math.min(gf, 20) - 6) / 14) * 100); + // CLI : 1–16+ ; même logique. + const cliNorm = Math.max(0, 100 - ((Math.min(cli, 16) - 1) / 15) * 100); + + const avg = (fk + lixNorm + gfNorm + cliNorm) / 4; + return Math.round(avg); +} + +function labelFromScore(score: number): { + label: ReadabilityLabel; + color: "green" | "yellow" | "orange" | "red"; +} { + if (score >= 75) return { label: "Très accessible", color: "green" }; + if (score >= 55) return { label: "Accessible", color: "yellow" }; + if (score >= 35) return { label: "Niveau intermédiaire", color: "orange" }; + if (score >= 20) return { label: "Niveau avancé", color: "red" }; + return { label: "Très technique", color: "red" }; +} + +// ───────────────────────────────────────────────────────────── +// Entrée publique +// ───────────────────────────────────────────────────────────── + +export function analyzeReadability(text: string): ReadabilityResult | null { + if (!text || text.trim().length < MIN_TEXT_LENGTH) return null; + + const sentences = splitSentences(text); + const words = splitWords(text); + + const wordCount = words.length; + const sentenceCount = Math.max(1, sentences.length); + + if (wordCount < 5) return null; + + const syllableCounts = words.map(countSyllablesFr); + const totalSyllables = syllableCounts.reduce((a, b) => a + b, 0); + const totalChars = words.reduce((a, w) => a + w.length, 0); + + const avgWordsPerSentence = wordCount / sentenceCount; + const avgSyllablesPerWord = totalSyllables / wordCount; + const avgCharsPerWord = totalChars / wordCount; + const longWordCount = words.filter((w) => w.length >= 7).length; + const longSyllableCount = syllableCounts.filter((s) => s >= 3).length; + const longWordRatio = longSyllableCount / wordCount; + + const fk = computeFleschKincaid(avgWordsPerSentence, avgSyllablesPerWord); + const lix = computeLIX(wordCount, sentenceCount, longWordCount); + const gf = computeGunningFog(avgWordsPerSentence, longWordRatio); + const cli = computeColemanLiau(avgCharsPerWord, wordCount, sentenceCount); + const globalScore = Math.min(100, Math.max(0, computeGlobalScore(fk, lix, gf, cli))); + const { label, color } = labelFromScore(globalScore); + + return { + fleschKincaid: fk, + lix, + gunningFog: gf, + colemanLiau: cli, + globalScore, + label, + color, + metrics: { + wordCount, + sentenceCount, + avgWordsPerSentence: Math.round(avgWordsPerSentence * 10) / 10, + avgSyllablesPerWord: Math.round(avgSyllablesPerWord * 10) / 10, + longWordRatio: Math.round(longWordRatio * 100), + avgCharsPerWord: Math.round(avgCharsPerWord * 10) / 10, + longWordCount, + }, + }; +}