Skip to content

Commit e5f8455

Browse files
authored
Merge pull request #13317 from mfo/US/stats-llm
ETQ dev, j'aimerais ne pas me reposer la question des stats du simpliscore
2 parents de02047 + e9d91a4 commit e5f8455

1 file changed

Lines changed: 331 additions & 0 deletions

File tree

lib/tasks/llm_stats.rake

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
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

Comments
 (0)