|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require Rails.root.join("lib", "tasks", "task_helper") |
| 4 | + |
| 5 | +FEATURE_LAUNCH = Date.new(2026, 4, 1) |
| 6 | +RULES = %w[improve_label improve_types improve_structure cleaner].freeze |
| 7 | +STATES = %w[publiee brouillon close depubliee].freeze |
| 8 | +PROCEDURE_JOIN = { llm_rule_suggestion: { procedure_revision: :procedure } }.freeze |
| 9 | + |
| 10 | +namespace :llm do |
| 11 | + desc "Reporting Simpliscore : impact global (stock) + pilotage (flux 3 mois)" |
| 12 | + task stats: :environment do |
| 13 | + llm_stats_report |
| 14 | + end |
| 15 | +end |
| 16 | + |
| 17 | +def llm_stats_report |
| 18 | + today = Date.current |
| 19 | + months = rolling_months(today, 3) |
| 20 | + |
| 21 | + puts "=== Simpliscore — Reporting #{today.strftime('%d/%m/%Y')} ===\n\n" |
| 22 | + |
| 23 | + # ============================================= |
| 24 | + # PARTIE 1 — IMPACT GLOBAL (STOCK) |
| 25 | + # ============================================= |
| 26 | + stock = snapshot_stock(today) |
| 27 | + beta = snapshot_beta |
| 28 | + |
| 29 | + puts "## 1. Impact global (depuis le lancement)\n\n" |
| 30 | + |
| 31 | + puts "| Métrique | Total | dont beta (avant avril) |" |
| 32 | + puts "|---|---|---|" |
| 33 | + puts "| Démarches améliorées | #{fmt(stock[:procedures])} | #{fmt(beta[:procedures])} |" |
| 34 | + puts "| Suggestions acceptées | #{fmt(stock[:accepted])} | #{fmt(beta[:accepted])} |" |
| 35 | + puts "| Taux d'acceptation global | #{format("%.1f", stock[:acceptance_rate])}% | #{format("%.1f", beta[:acceptance_rate])}% |" |
| 36 | + puts "| Mises à jour | #{fmt(stock[:by_op]['update'] || 0)} | #{fmt(beta[:by_op]['update'] || 0)} |" |
| 37 | + puts "| Ajouts de sections | #{fmt(stock[:by_op]['add'] || 0)} | #{fmt(beta[:by_op]['add'] || 0)} |" |
| 38 | + puts "| Suppressions | #{fmt(stock[:by_op]['destroy'] || 0)} | #{fmt(beta[:by_op]['destroy'] || 0)} |" |
| 39 | + |
| 40 | + puts "\n**Par état de démarche :**\n\n" |
| 41 | + puts "| État | Démarches | Acceptations | Taux |" |
| 42 | + puts "|---|---|---|---|" |
| 43 | + STATES.each do |state| |
| 44 | + procs = stock[:procedures_by_state][state] || 0 |
| 45 | + acc = stock[:accepted_by_state][state] || 0 |
| 46 | + items = stock[:items_by_state][state] || 0 |
| 47 | + rate = items > 0 ? (acc.to_f / items * 100).round(1) : 0 |
| 48 | + puts "| #{state} | #{fmt(procs)} | #{fmt(acc)} | #{rate}% |" |
| 49 | + end |
| 50 | + |
| 51 | + puts "\n**Par règle :**\n\n" |
| 52 | + puts "| Règle | Acceptées | Suggestions | Taux |" |
| 53 | + puts "|---|---|---|---|" |
| 54 | + RULES.each do |rule| |
| 55 | + acc = stock[:by_rule][rule] || 0 |
| 56 | + items = stock[:items_by_rule][rule] || 0 |
| 57 | + rate = items > 0 ? (acc.to_f / items * 100).round(1) : 0 |
| 58 | + puts "| #{rule.tr('_', ' ')} | #{fmt(acc)} | #{fmt(items)} | #{rate}% |" |
| 59 | + end |
| 60 | + |
| 61 | + # ============================================= |
| 62 | + # PARTIE 2 — PILOTAGE (FLUX 3 MOIS GLISSANTS) |
| 63 | + # ============================================= |
| 64 | + fluxes = months.map { |range| [range, snapshot_flux(range)] } |
| 65 | + |
| 66 | + puts "\n\n## 2. Pilotage (flux mensuel)\n\n" |
| 67 | + |
| 68 | + headers = ["KPI"] + months.map { |r| "#{r.first.strftime('%d/%m')}→#{r.max.strftime('%d/%m')}" } + ["Tendance"] |
| 69 | + |
| 70 | + puts table(headers, [ |
| 71 | + flux_row("Suggestions générées", fluxes.map { it[1][:items] }), |
| 72 | + flux_row("Décisions prises", fluxes.map { it[1][:decided] }), |
| 73 | + flux_row("Acceptées", fluxes.map { it[1][:accepted] }), |
| 74 | + flux_row("Taux d'acceptation", fluxes.map { it[1][:acceptance_rate] }, suffix: "%"), |
| 75 | + flux_row("Démarches touchées", fluxes.map { it[1][:procedures] }), |
| 76 | + ]) |
| 77 | + |
| 78 | + puts "\n**Flux par règle (acceptations) :**\n\n" |
| 79 | + rule_rows = RULES.map do |rule| |
| 80 | + flux_row(rule.tr('_', ' '), fluxes.map { it[1][:by_rule][rule] || 0 }) |
| 81 | + end |
| 82 | + puts table(headers, rule_rows) |
| 83 | + |
| 84 | + puts "\n**Flux par règle (taux d'acceptation) :**\n\n" |
| 85 | + rule_rate_rows = RULES.map do |rule| |
| 86 | + flux_row(rule.tr('_', ' '), fluxes.map do |_r, f| |
| 87 | + items = f[:items_by_rule][rule] || 0 |
| 88 | + acc = f[:by_rule][rule] || 0 |
| 89 | + items > 0 ? (acc.to_f / items * 100).round(1) : 0 |
| 90 | + end, suffix: "%") |
| 91 | + end |
| 92 | + puts table(headers, rule_rate_rows) |
| 93 | + |
| 94 | + puts "\n**Flux par état de démarche (acceptations) :**\n\n" |
| 95 | + state_rows = STATES.map do |state| |
| 96 | + flux_row(state, fluxes.map { it[1][:accepted_by_state][state] || 0 }) |
| 97 | + end |
| 98 | + puts table(headers, state_rows) |
| 99 | + |
| 100 | + # ============================================= |
| 101 | + # PARTIE 3 — ANALYSE LLM |
| 102 | + # ============================================= |
| 103 | + puts "\n\n## 3. Analyse LLM\n\n" |
| 104 | + |
| 105 | + stats_for_llm = build_llm_context(stock, beta, fluxes, months) |
| 106 | + |
| 107 | + messages = [ |
| 108 | + { role: 'system', content: llm_system_prompt }, |
| 109 | + { role: 'user', content: stats_for_llm }, |
| 110 | + ] |
| 111 | + |
| 112 | + begin |
| 113 | + prev_level = Langchain.logger.level |
| 114 | + Langchain.logger.level = Logger::FATAL |
| 115 | + raw_response = LLM::OpenAIClient.instance.chat({ |
| 116 | + messages:, |
| 117 | + temperature: 0.3, |
| 118 | + model: ENV['LLM_MODEL_NAME'], |
| 119 | + }) |
| 120 | + raw = raw_response.respond_to?(:raw_response) ? raw_response.raw_response : raw_response |
| 121 | + content = raw.dig('choices', 0, 'message', 'content') |
| 122 | + puts content || "Pas de réponse du LLM" |
| 123 | + rescue => e |
| 124 | + puts "Erreur LLM (#{e.class}): #{e.message}" |
| 125 | + ensure |
| 126 | + Langchain.logger.level = prev_level |
| 127 | + end |
| 128 | + |
| 129 | + puts "\n=== Fin ===" |
| 130 | +end |
| 131 | + |
| 132 | +# --- Données --- |
| 133 | + |
| 134 | +def rolling_months(today, count) |
| 135 | + Array.new(count) do |i| |
| 136 | + period_end = today - (count - 1 - i).months |
| 137 | + period_start = period_end - 1.month |
| 138 | + period_start = [period_start, FEATURE_LAUNCH].max |
| 139 | + period_start...[period_end, today].min |
| 140 | + end |
| 141 | +end |
| 142 | + |
| 143 | +def snapshot_beta |
| 144 | + items = LLMRuleSuggestionItem.joins(:llm_rule_suggestion) |
| 145 | + .where(llm_rule_suggestions: { created_at: ...FEATURE_LAUNCH }) |
| 146 | + |
| 147 | + accepted_items = items.where(verify_status: "accepted", applied_at: ...FEATURE_LAUNCH) |
| 148 | + items_count = items.count |
| 149 | + accepted_count = accepted_items.count |
| 150 | + |
| 151 | + { |
| 152 | + accepted: accepted_count, |
| 153 | + acceptance_rate: items_count > 0 ? (accepted_count.to_f / items_count * 100).round(1) : 0, |
| 154 | + procedures: accepted_items.joins(PROCEDURE_JOIN).distinct.count("procedures.id"), |
| 155 | + by_op: accepted_items.group(:op_kind).count, |
| 156 | + } |
| 157 | +end |
| 158 | + |
| 159 | +def snapshot_stock(cutoff) |
| 160 | + items = LLMRuleSuggestionItem.joins(:llm_rule_suggestion) |
| 161 | + |
| 162 | + accepted_items = items.where(verify_status: "accepted", applied_at: ...cutoff) |
| 163 | + items_count = items.count |
| 164 | + accepted_count = accepted_items.count |
| 165 | + |
| 166 | + { |
| 167 | + accepted: accepted_count, |
| 168 | + acceptance_rate: items_count > 0 ? (accepted_count.to_f / items_count * 100).round(1) : 0, |
| 169 | + procedures: accepted_items.joins(PROCEDURE_JOIN).distinct.count("procedures.id"), |
| 170 | + by_op: accepted_items.group(:op_kind).count, |
| 171 | + by_rule: accepted_items.joins(:llm_rule_suggestion).group("llm_rule_suggestions.rule").count, |
| 172 | + items_by_rule: items.joins(:llm_rule_suggestion).group("llm_rule_suggestions.rule").count, |
| 173 | + procedures_by_state: accepted_items.joins(PROCEDURE_JOIN).distinct("procedures.id").group("procedures.aasm_state").count("procedures.id"), |
| 174 | + accepted_by_state: accepted_items.joins(PROCEDURE_JOIN).group("procedures.aasm_state").count, |
| 175 | + items_by_state: items.joins(PROCEDURE_JOIN).group("procedures.aasm_state").count, |
| 176 | + } |
| 177 | +end |
| 178 | + |
| 179 | +def snapshot_flux(range) |
| 180 | + items_created = LLMRuleSuggestionItem.joins(:llm_rule_suggestion) |
| 181 | + .where(llm_rule_suggestions: { created_at: range }) |
| 182 | + |
| 183 | + accepted = LLMRuleSuggestionItem.where(verify_status: "accepted", applied_at: range) |
| 184 | + skipped = LLMRuleSuggestionItem.where(verify_status: "skipped", updated_at: range) |
| 185 | + |
| 186 | + accepted_count = accepted.count |
| 187 | + skipped_count = skipped.count |
| 188 | + decided = accepted_count + skipped_count |
| 189 | + |
| 190 | + { |
| 191 | + items: items_created.count, |
| 192 | + accepted: accepted_count, |
| 193 | + skipped: skipped_count, |
| 194 | + decided: decided, |
| 195 | + acceptance_rate: decided > 0 ? (accepted_count.to_f / decided * 100).round(1) : 0, |
| 196 | + procedures: accepted.joins(PROCEDURE_JOIN).distinct.count("procedures.id"), |
| 197 | + by_rule: accepted.joins(:llm_rule_suggestion).group("llm_rule_suggestions.rule").count, |
| 198 | + items_by_rule: items_created.joins(:llm_rule_suggestion).group("llm_rule_suggestions.rule").count, |
| 199 | + accepted_by_state: accepted.joins(PROCEDURE_JOIN).group("procedures.aasm_state").count, |
| 200 | + } |
| 201 | +end |
| 202 | + |
| 203 | +# --- Formatage --- |
| 204 | + |
| 205 | +def fmt(number) |
| 206 | + number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1 ').reverse |
| 207 | +end |
| 208 | + |
| 209 | +def trend(values) |
| 210 | + return "—" if values.size < 2 || values.all?(&:zero?) |
| 211 | + |
| 212 | + recent = values.last(2) |
| 213 | + return "→" if recent[0] == 0 && recent[1] == 0 |
| 214 | + |
| 215 | + delta = recent[1] - recent[0] |
| 216 | + pct = recent[0] != 0 ? (delta.to_f / recent[0].abs * 100) : (delta > 0 ? 100 : -100) |
| 217 | + |
| 218 | + if pct > 15 then "↑" |
| 219 | + elsif pct > 5 then "↗" |
| 220 | + elsif pct > -5 then "→" |
| 221 | + elsif pct > -15 then "↘" |
| 222 | + else "↓" |
| 223 | + end |
| 224 | +end |
| 225 | + |
| 226 | +def flux_row(label, values, suffix: nil) |
| 227 | + formatted = values.map { |v| suffix ? "#{format("%.1f", v)}#{suffix}" : fmt(v) } |
| 228 | + [label] + formatted + [trend(values)] |
| 229 | +end |
| 230 | + |
| 231 | +def table(headers, rows) |
| 232 | + cells = headers.map(&:to_s) |
| 233 | + lines = [] |
| 234 | + lines << "| #{cells.join(' | ')} |" |
| 235 | + lines << "|#{'---|' * cells.size}" |
| 236 | + rows.each { |row| lines << "| #{row.join(' | ')} |" } |
| 237 | + lines.join("\n") |
| 238 | +end |
| 239 | + |
| 240 | +# --- LLM --- |
| 241 | + |
| 242 | +def build_llm_context(stock, beta, fluxes, months) |
| 243 | + <<~MD |
| 244 | + ## Impact global (stock, beta + post-lancement) |
| 245 | + Démarches améliorées : #{fmt(stock[:procedures])} (dont #{fmt(beta[:procedures])} en beta avant avril 2026) |
| 246 | + Suggestions acceptées : #{fmt(stock[:accepted])} (dont #{fmt(beta[:accepted])} en beta) — taux global : #{format("%.1f", stock[:acceptance_rate])}% |
| 247 | + Par op : #{stock[:by_op].map { "#{it.first}=#{it.last}" }.join(", ")} |
| 248 | + Par règle (acceptées/total → taux) : |
| 249 | + #{RULES.map do |r| |
| 250 | + acc = stock[:by_rule][r] || 0; tot = stock[:items_by_rule][r] || 0 |
| 251 | + rate = tot > 0 ? (acc.to_f / tot * 100).round(1) : 0 |
| 252 | + " #{r} : #{acc}/#{tot} (#{rate}%)" |
| 253 | + end.join("\n")} |
| 254 | + Par état (démarches améliorées) : #{STATES.map { |s| "#{s}=#{stock[:procedures_by_state][s] || 0}" }.join(", ")} |
| 255 | +
|
| 256 | + ## Flux (3 périodes glissantes de ~30 jours) |
| 257 | + #{fluxes.map do |range, f| |
| 258 | + days = (range.max - range.first).to_i |
| 259 | + <<~MONTH |
| 260 | + #{range.first.strftime('%d/%m/%Y')} → #{range.max.strftime('%d/%m/%Y')} (#{days} jours) : |
| 261 | + Suggestions générées : #{f[:items]} |
| 262 | + Décisions : #{f[:decided]} (#{f[:accepted]} acceptées, #{f[:skipped]} ignorées) |
| 263 | + Taux d'acceptation : #{format("%.1f", f[:acceptance_rate])}% |
| 264 | + Démarches touchées : #{f[:procedures]} |
| 265 | + Par règle : #{RULES.map { |r| "#{r}=#{f[:by_rule][r] || 0}" }.join(", ")} |
| 266 | + Par état : #{STATES.map { |s| "#{s}=#{f[:accepted_by_state][s] || 0}" }.join(", ")} |
| 267 | + MONTH |
| 268 | + end.join("\n")} |
| 269 | + MD |
| 270 | +end |
| 271 | +
|
| 272 | +def llm_system_prompt |
| 273 | + <<~PROMPT |
| 274 | + Tu es un analyste data au sein d'une équipe produit d'un service public numérique français |
| 275 | + (demarches-simplifiees.fr). Tu analyses les données d'une feature interne appelée "Simpliscore" |
| 276 | + lancée en avril 2026, qui utilise l'IA pour suggérer des améliorations aux formulaires administratifs. |
| 277 | +
|
| 278 | + Contexte métier : |
| 279 | + - Les administrateurs de démarches créent des formulaires pour les usagers (citoyens, entreprises) |
| 280 | + - Simpliscore génère des suggestions d'amélioration : meilleurs libellés, ajout de sections, correction de types de champs, nettoyage |
| 281 | + - Les admins peuvent accepter ou ignorer chaque suggestion |
| 282 | + - Les 4 règles s'exécutent successivement dans un tunnel, dans cet ordre : |
| 283 | + 1. improve_label (libellés) — appliquée à tous les champs, donc volume le plus élevé |
| 284 | + 2. improve_structure (sections) — s'exécute sur le formulaire déjà amélioré par improve_label |
| 285 | + 3. improve_types (types de champs) — s'exécute après les deux précédentes |
| 286 | + 4. cleaner (suppression de champs inutiles) — dernière étape, volume le plus faible |
| 287 | + Le volume décroissant par règle est structurel : chaque règle s'applique sur un périmètre de plus en plus réduit |
| 288 | +
|
| 289 | + États des démarches : |
| 290 | + - publiee : en production, visible par les usagers (impact réel sur les citoyens) |
| 291 | + - brouillon : en construction (l'admin prépare son formulaire) |
| 292 | + - close/depubliee : fermées, mais peuvent être clonées pour créer de nouvelles démarches |
| 293 | +
|
| 294 | + Historique : |
| 295 | + - Avant avril 2026 (beta), les suggestions étaient auto-générées en batch nocturne sur un large |
| 296 | + volume de démarches, sans initiative de l'administrateur. Ce mécanisme a été retiré (erreurs 429). |
| 297 | + - Depuis avril 2026 (lancement), les suggestions sont générées uniquement à l'initiative de l'admin. |
| 298 | + - Le stock inclut toutes les suggestions (beta + post-lancement). Les suggestions beta non traitées |
| 299 | + gonflent le dénominateur stock, ce qui explique l'écart entre le taux stock (~22%) et le taux flux (~47%). |
| 300 | +
|
| 301 | + Les données sont présentées en deux vues : |
| 302 | + - Stock : impact cumulé depuis le lancement (pour mesurer l'impact global) |
| 303 | + Le taux d'acceptation stock = acceptées / total des suggestions générées |
| 304 | + - Flux : 3 périodes glissantes de ~30 jours (pour détecter les tendances) |
| 305 | + Le taux d'acceptation flux = acceptées / décisions prises (acceptées + ignorées) |
| 306 | + Ces deux taux ont des dénominateurs différents — ne pas les comparer directement |
| 307 | +
|
| 308 | + Attention aux pièges d'interprétation : |
| 309 | + - L'écart entre taux stock (~22%) et taux flux (~47%) est expliqué par le batch beta (voir Historique). |
| 310 | + Ne le signale pas comme une anomalie ou une question ouverte. |
| 311 | + - La première période peut être plus courte que 30 jours (capée au lancement en avril 2026). |
| 312 | + La durée en jours est indiquée pour chaque période. Ne compare les volumes absolus |
| 313 | + qu'entre périodes de même durée. Si une période est plus courte, normalise mentalement. |
| 314 | + - Le volume décroissant par règle n'est PAS un signal d'adoption : c'est structurel (tunnel séquentiel). |
| 315 | + Ne le signale pas comme un point d'attention. |
| 316 | + - Les démarches close/depubliée peuvent être clonées — des améliorations sur ces démarches |
| 317 | + ont un impact indirect sur les futures démarches créées par clonage. |
| 318 | +
|
| 319 | + Ta mission : interpréter les données, pas faire des recommandations. |
| 320 | + Produis une synthèse structurée en markdown : |
| 321 | + 1. **Lecture des données** — que disent les chiffres ? Quelles tendances, quelles ruptures, quels signaux faibles ? |
| 322 | + 2. **Questions ouvertes** — que faudrait-il creuser pour mieux comprendre ? Quelles hypothèses les données ne permettent pas de trancher ? |
| 323 | +
|
| 324 | + Règles : |
| 325 | + - Sois factuel et nuancé. Cite les chiffres. |
| 326 | + - Ne fais pas de recommandations d'action ni de suggestions produit — ce n'est pas ton rôle. |
| 327 | + - Si une donnée est ambiguë ou insuffisante pour conclure, dis-le. |
| 328 | + - Compare les périodes flux uniquement entre elles, pas avec le stock. |
| 329 | + - Écris en français, 300 mots max. |
| 330 | + PROMPT |
| 331 | +end |
0 commit comments