diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000000..4440e479ea1
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,35 @@
+# Repository Guidelines
+
+## Project Structure & Module Organization
+- `app/`: Rails MVC, views (HAML), components, and `app/javascript` (Vite/React/TS).
+- `spec/`: RSpec tests (models, controllers, system, GraphQL, etc.).
+- `lib/`: internal libraries and cops; `config/`: environments, routes, initializers.
+- `db/`: migrations and seeds; `public/`: static assets; `bin/`: helper scripts.
+- Docs in `doc/` and API docs tooling in `api_doc/`.
+
+## Build, Test, and Development Commands
+- Setup: `bin/setup` (deps, DB, assets). Update: `bin/update`.
+- Run locally: `bin/dev` (Overmind runs web, jobs, and `bin/vite`). Alt: `overmind start -f Procfile.dev`.
+- Ruby tests: `bin/rspec` or `bin/rake spec`. Example: `bin/rspec spec/models/user_spec.rb:12`.
+- JS tests: `bun run test` (Vitest). Coverage: `bun run coverage`.
+- Lint all: `bin/rake lint`. Specific: `bundle exec rubocop`, `bun run lint:js`, `bun run lint:types`, `bun run lint:css`.
+
+## Coding Style & Naming Conventions
+- Ruby: 2-space indent, Rails/RSpec cops via RuboCop (`.rubocop.yml`). Files `snake_case.rb`; classes `CamelCase`.
+- Views: HAML checked by `haml-lint`.
+- Frontend: TypeScript + React via Vite. Use PascalCase for components, camelCase for variables; keep code in `app/javascript`.
+- Formatting: Prettier for styles and TS; ESLint rules in `eslint.config.ts`.
+
+## Testing Guidelines
+- Frameworks: RSpec (+ Capybara/Playwright) and Vitest.
+- Ruby specs end with `_spec.rb` under `spec/…`. System specs may require Chrome; run visibly: `NO_HEADLESS=1 bin/rspec spec/system`.
+- JS unit tests live alongside code as `*.test.ts(x)`.
+- Coverage: SimpleCov (Ruby) and Vitest coverage; keep meaningful assertions.
+
+## Commit & Pull Request Guidelines
+- Commits: imperative mood, focused scope; reference issues (e.g., `Fix: redeliver webhooks (#123)`).
+- PRs: clear description, linked issues, screenshots for UI changes, migration notes, and added/updated tests. Ensure CI is green.
+
+## Security & Configuration Tips
+- Do not commit secrets. Use `.env`/`.env.test` (dotenv) locally.
+- Prereqs: PostgreSQL ≥ 15, Redis (Sidekiq), ImageMagick with restricted policy. Sidekiq dev: `overmind start -f Procfile.sidekiq.dev`.
diff --git a/Gemfile b/Gemfile
index 8087a31ea0f..0aae67357a7 100644
--- a/Gemfile
+++ b/Gemfile
@@ -14,6 +14,7 @@ gem 'administrate-field-enum' # Allow using Field::Enum in administrate
gem 'after_commit_everywhere'
gem 'ancestry'
gem 'anchored'
+gem 'anthropic'
gem 'bcrypt'
gem 'bootsnap', '>= 1.4.4', require: false # Reduces boot times through caching; required in config/boot.rb
gem 'browser'
@@ -60,6 +61,7 @@ gem 'json_schemer'
gem 'jwt'
gem 'kaminari'
gem 'kredis'
+gem 'langchainrb'
gem 'listen' # Required by ActiveSupport::EventedFileUpdateChecker
gem 'lograge'
gem 'logstash-event'
@@ -91,6 +93,7 @@ gem 'redcarpet'
gem 'redis'
gem 'rexml' # add missing gem due to ruby3 (https://github.com/Shopify/bootsnap/issues/325)
gem 'rqrcode'
+gem 'ruby-openai'
gem 'saml_idp'
gem 'sassc-rails' # Use SCSS for stylesheets
gem 'sentry-delayed_job'
diff --git a/Gemfile.lock b/Gemfile.lock
index dd49618a6e2..b4b657bb1a9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -125,6 +125,10 @@ GEM
ancestry (4.3.3)
activerecord (>= 5.2.6)
anchored (1.1.0)
+ anthropic (0.3.2)
+ event_stream_parser (>= 0.3.0, < 2.0.0)
+ faraday (>= 1)
+ faraday-multipart (>= 1)
anyway_config (2.7.2)
ruby-next-core (~> 1.0)
ast (2.4.3)
@@ -142,6 +146,7 @@ GEM
descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0)
thread_safe (~> 0.3, >= 0.3.1)
+ baran (0.1.12)
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.5.0)
@@ -260,6 +265,7 @@ GEM
tzinfo
ethon (0.17.0)
ffi (>= 1.15.0)
+ event_stream_parser (1.0.0)
excon (1.3.0)
logger
factory_bot (6.5.5)
@@ -273,17 +279,12 @@ GEM
faraday-jwt (0.1.0)
faraday (~> 2.0)
json-jwt (~> 1.16)
+ faraday-multipart (1.1.0)
+ multipart-post (~> 2.0)
faraday-net_http (3.4.1)
net-http (>= 0.5.0)
ffi (1.17.2)
- ffi (1.17.2-aarch64-linux-gnu)
- ffi (1.17.2-aarch64-linux-musl)
- ffi (1.17.2-arm-linux-gnu)
- ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin)
- ffi (1.17.2-x86_64-darwin)
- ffi (1.17.2-x86_64-linux-gnu)
- ffi (1.17.2-x86_64-linux-musl)
fiber-storage (1.0.1)
flipper (1.3.6)
concurrent-ruby (< 2)
@@ -361,10 +362,6 @@ GEM
hana (1.3.7)
hashdiff (1.2.1)
hashie (5.0.0)
- herb (0.7.4-aarch64-linux-gnu)
- herb (0.7.4-aarch64-linux-musl)
- herb (0.7.4-arm-linux-gnu)
- herb (0.7.4-arm-linux-musl)
herb (0.7.4-arm64-darwin)
herb (0.7.4-x86_64-darwin)
herb (0.7.4-x86_64-linux-gnu)
@@ -414,6 +411,8 @@ GEM
bindata
faraday (~> 2.0)
faraday-follow_redirects
+ json-schema (4.3.1)
+ addressable (>= 2.8)
json_schemer (2.4.0)
bigdecimal
hana (~> 1.3)
@@ -440,6 +439,13 @@ GEM
activemodel (>= 6.0.0)
activesupport (>= 6.0.0)
redis (>= 4.2, < 6)
+ langchainrb (0.19.3)
+ baran (~> 0.1.9)
+ csv
+ json-schema (~> 4)
+ matrix
+ pragmatic_segmenter (~> 0.3.0)
+ zeitwerk (~> 2.5)
language_server-protocol (3.17.0.5)
launchy (3.1.1)
addressable (~> 2.8)
@@ -496,6 +502,7 @@ GEM
multi_json (1.17.0)
multi_xml (0.7.2)
bigdecimal (~> 3.1)
+ multipart-post (2.4.1)
mustermann (3.0.4)
ruby2_keywords (~> 0.0.1)
mutex_m (0.3.0)
@@ -511,14 +518,6 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
- nokogiri (1.18.10-aarch64-linux-gnu)
- racc (~> 1.4)
- nokogiri (1.18.10-aarch64-linux-musl)
- racc (~> 1.4)
- nokogiri (1.18.10-arm-linux-gnu)
- racc (~> 1.4)
- nokogiri (1.18.10-arm-linux-musl)
- racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-darwin)
@@ -567,18 +566,14 @@ GEM
racc
pdf-core (0.9.0)
pg (1.6.2)
- pg (1.6.2-aarch64-linux)
- pg (1.6.2-aarch64-linux-musl)
pg (1.6.2-arm64-darwin)
- pg (1.6.2-x86_64-darwin)
- pg (1.6.2-x86_64-linux)
- pg (1.6.2-x86_64-linux-musl)
phonelib (0.10.12)
playwright-ruby-client (1.55.0)
concurrent-ruby (>= 1.1.6)
mime-types (>= 3.0)
pp (0.6.2)
prettyprint
+ pragmatic_segmenter (0.3.24)
prawn (2.4.0)
pdf-core (~> 0.9.0)
ttfunk (~> 1.7)
@@ -777,6 +772,10 @@ GEM
ruby-graphviz (1.2.5)
rexml
ruby-next-core (1.1.2)
+ ruby-openai (7.3.1)
+ event_stream_parser (>= 0.3.0, < 2.0.0)
+ faraday (>= 1)
+ faraday-multipart (>= 1)
ruby-pg-extras (5.6.13)
pg
terminal-table
@@ -992,10 +991,6 @@ GEM
zxcvbn (1.0.0)
PLATFORMS
- aarch64-linux-gnu
- aarch64-linux-musl
- arm-linux-gnu
- arm-linux-musl
arm64-darwin
x86_64-darwin
x86_64-linux
@@ -1013,6 +1008,7 @@ DEPENDENCIES
after_commit_everywhere
ancestry
anchored
+ anthropic
axe-core-rspec
bcrypt
benchmark-ips
@@ -1071,6 +1067,7 @@ DEPENDENCIES
jwt
kaminari
kredis
+ langchainrb
launchy
letter_opener_web
listen
@@ -1120,6 +1117,7 @@ DEPENDENCIES
rubocop-performance
rubocop-rails
rubocop-rspec
+ ruby-openai
saml_idp
sassc-rails
selenium-devtools
diff --git a/app/components/llm/header_component.rb b/app/components/llm/header_component.rb
new file mode 100644
index 00000000000..22933b8e634
--- /dev/null
+++ b/app/components/llm/header_component.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module LLM
+ class HeaderComponent < ApplicationComponent
+ attr_reader :llm_rule_suggestion
+ def initialize(llm_rule_suggestion:)
+ @llm_rule_suggestion = llm_rule_suggestion
+ end
+
+ def call
+ accordion_id = "llm-accordion-#{SecureRandom.hex(4)}"
+ timestamp = I18n.l(llm_rule_suggestion.created_at, format: :llm_stepper_last_refresh)
+
+ tag.div do
+ safe_join([
+ tag.p(I18n.t('llm.stepper_component.last_refresh', timestamp:), class: 'fr-hint-text'),
+ tag.section(class: 'fr-accordion fr-mt-2w fr-mb-3w') do
+ tag.h3(class: 'fr-accordion__title') do
+ tag.button(
+ 'Comment fonctionne ce module d\'amélioration ?',
+ type: :button,
+ class: 'fr-accordion__btn',
+ 'aria-controls' => accordion_id,
+ 'aria-expanded' => 'false'
+ )
+ end +
+ tag.div(class: 'fr-collapse', id: accordion_id) do
+ tag.div(class: 'fr-mt-1w') do
+ safe_join([
+ tag.p('Ce module est une assistance à la bonne création de votre formulaire.', class: 'fr-mb-0'),
+ tag.p('Nous analysons l\'intégralité des champs du formulaire afin de suggérer automatiquement des améliorations.', class: 'fr-mb-0'),
+ tag.p('Les suggestions d\'amélioration peuvent porter sur :', class: 'fr-mb-0'),
+ tag.ul do
+ safe_join([
+ tag.li(safe_join(['Les ', tag.strong('libellés des champs'), ' : mise à jour des libellés de champs détectés comme trop longs, en majuscules ou difficiles à comprendre.'])),
+ tag.li(safe_join(['La ', tag.strong('structure du formulaire'), ' : amélioration de la structure du formulaire en réorganisant les champs et en ajoutant des sections.'])),
+ tag.li(safe_join(['La ', tag.strong('demande unique d\'information ("Dites-le nous une fois")'), ' : suppression des champs détectés comme redondants ou en doublon.'])),
+ tag.li(safe_join(['La ', tag.strong('bonne utilisation des types de champs'), ' : transformation de certains champs en d\'autres types de champs, plus adaptés au regard de l\'information attendue.'])),
+ ])
+ end,
+ tag.p('Vous êtes libre de choisir les suggestions que vous souhaitez appliquer.'),
+ ])
+ end
+ end
+ end,
+ ])
+ end
+ end
+ end
+end
diff --git a/app/components/llm/improve_label_item_component.html.erb b/app/components/llm/improve_label_item_component.html.erb
new file mode 100644
index 00000000000..23b3c944c00
--- /dev/null
+++ b/app/components/llm/improve_label_item_component.html.erb
@@ -0,0 +1,14 @@
+
+ <%= checkbox do %>
+ <%= original_tdc.libelle %>
+ <% if libelle_changed? %>
+ → <%= payload['libelle'] %>
+ <% end %>
+
+ <% if item.justification.present? %>
+
+ <%= item.justification %>
+
+ <% end %>
+ <% end %>
+
diff --git a/app/components/llm/improve_label_item_component.rb b/app/components/llm/improve_label_item_component.rb
new file mode 100644
index 00000000000..d2c6221f5d9
--- /dev/null
+++ b/app/components/llm/improve_label_item_component.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class LLM::ImproveLabelItemComponent < LLM::SuggestionItemComponent
+ ACCEPTED_VALUE = LLMRuleSuggestionItem.verify_statuses.fetch(:accepted)
+ SKIPPED_VALUE = LLMRuleSuggestionItem.verify_statuses.fetch(:skipped)
+
+ def self.step_title
+ "Amélioration des libellés"
+ end
+
+ def self.step_summary
+ "Acceptez ou refusez les propositions de nouveaux libellés pour les champs de votre formulaire."
+ end
+
+ def render?
+ original_tdc.present?
+ end
+
+ def original_tdc
+ @original_tdc ||= tdc_for(item.stable_id)
+ end
+
+ def payload
+ @payload ||= item.payload || {}
+ end
+
+ def libelle_changed?
+ payload['libelle'].present? && payload['libelle'] != original_tdc.libelle
+ end
+
+ def confidence_badge
+ return if item.confidence.blank?
+
+ content_tag(:span, "confiance: #{item.confidence}", class: 'fr-badge')
+ end
+
+ def checkbox
+ safe_join([
+ form_builder.check_box(:verify_status, {}, ACCEPTED_VALUE, SKIPPED_VALUE),
+ form_builder.label(:verify_status, class: 'fr-label') do
+ capture { yield if block_given? }
+ end,
+ ])
+ end
+end
diff --git a/app/components/llm/improve_structure_item_component.html.erb b/app/components/llm/improve_structure_item_component.html.erb
new file mode 100644
index 00000000000..d65097d125e
--- /dev/null
+++ b/app/components/llm/improve_structure_item_component.html.erb
@@ -0,0 +1,44 @@
+
+
+
+ <% if op_kind == 'add' %>
+ Ajout de section
+ <%= payload['libelle'] %>
+ <% elsif op_kind == 'update' && original_tdc %>
+ Réorganisation
+ <%= original_tdc.libelle %>
+ <% end %>
+
+
+
+
+ <% if op_kind == 'update' %>
+ - Nouvelle position : <%= position %>
+ <% if payload.key?('mandatory') %>
+ - Champ obligatoire : <%= mandatory? ? 'Oui' : 'Non' %>
+ <% end %>
+ <% elsif op_kind == 'add' %>
+ - Position : <%= position %>
+ <% if type_champ.present? %>
+ - Type : <%= type_champ %>
+ <% end %>
+ <% end %>
+
+
+ <% if item.justification.present? %>
+
+ <%= confidence_badge %>
+ <%= item.justification %>
+
+ <% end %>
+
+
+
+
+
diff --git a/app/components/llm/improve_structure_item_component.rb b/app/components/llm/improve_structure_item_component.rb
new file mode 100644
index 00000000000..62332d7a740
--- /dev/null
+++ b/app/components/llm/improve_structure_item_component.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class LLM::ImproveStructureItemComponent < LLM::SuggestionItemComponent
+ ACCEPTED_VALUE = LLMRuleSuggestionItem.verify_statuses.fetch(:accepted)
+ SKIPPED_VALUE = LLMRuleSuggestionItem.verify_statuses.fetch(:skipped)
+
+ def self.step_title
+ "Amélioration de la structure"
+ end
+
+ def self.step_summary
+ "Acceptez ou refusez les propositions de modifications de la structure de votre formulaire."
+ end
+
+ def original_tdc
+ @original_tdc ||= tdc_for(item.stable_id)
+ end
+
+ def payload
+ @payload ||= item.payload || {}
+ end
+
+ def op_kind
+ item.op_kind
+ end
+
+ def position
+ payload['position']
+ end
+
+ def mandatory?
+ payload['mandatory']
+ end
+
+ def type_champ
+ payload['type_champ']
+ end
+
+ def confidence_badge
+ return if item.confidence.blank?
+
+ content_tag(:span, "confiance: #{item.confidence}", class: 'fr-badge fr-mr-1w')
+ end
+
+ def checkbox(label_class: 'fr-label')
+ safe_join([
+ form_builder.check_box(:verify_status, {}, ACCEPTED_VALUE, SKIPPED_VALUE),
+ form_builder.label(:verify_status, class: label_class) do
+ capture { yield if block_given? }
+ end,
+ ])
+ end
+end
diff --git a/app/components/llm/stepper_component.rb b/app/components/llm/stepper_component.rb
new file mode 100644
index 00000000000..d44c8472b87
--- /dev/null
+++ b/app/components/llm/stepper_component.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module LLM
+ class StepperComponent < StepperBaseComponent
+ delegate :llm_rule_suggestion, to: :step_component
+ delegate :procedure_revision, :rule, to: :llm_rule_suggestion
+ delegate :procedure, to: :procedure_revision
+
+ def initialize(step_component:)
+ super(step_component:)
+ end
+
+ def back_link
+ helpers.link_to(
+ "Revenir à l'écran de gestion",
+ helpers.admin_procedure_path(procedure),
+ class: 'fr-link fr-icon-arrow-left-line fr-link--icon--left fr-icon--sm'
+ )
+ end
+
+ def title
+ "Amélioration de la qualité du formulaire « #{procedure.libelle} »"
+ end
+
+ def step_title
+ case rule
+ when 'improve_label'
+ "Amélioration des libellés"
+ when 'improve_structure'
+ "Amélioration de la structure"
+ end
+ end
+
+ def next_step_title
+ case rule
+ when 'improve_label'
+ "Amélioration de la structure"
+ when 'improve_structure'
+ "À venir..."
+ end
+ end
+
+ def current_step
+ case rule
+ when 'improve_label'
+ 1
+ when 'improve_structure'
+ 2
+ end
+ end
+
+ def step_count
+ 4
+ end
+ end
+end
diff --git a/app/components/llm/suggestion_form_component.html.erb b/app/components/llm/suggestion_form_component.html.erb
new file mode 100644
index 00000000000..669eb46260a
--- /dev/null
+++ b/app/components/llm/suggestion_form_component.html.erb
@@ -0,0 +1,25 @@
+<%= title %>
+<%= render Dsfr::CalloutComponent.new(title: "À quoi sert cette règle ?", theme: :neutral) do |callout| %>
+ <% callout.with_body do %>
+ <%= summary %>
+ <% end %>
+<% end %>
+
+
+ <%= suggestions_count %> <%= 'suggestion'.pluralize(suggestions_count) %>
+
+ <%= form_for llm_rule_suggestion,
+ url: accept_simplification_admin_procedure_types_de_champ_path(procedure, llm_rule_suggestion),
+ method: :post do |form| %>
+ <% llm_rule_suggestion.llm_rule_suggestion_items.each do |llm_rule_suggestion_item| %>
+ <%= form.fields_for :llm_rule_suggestion_items, llm_rule_suggestion_item do |ff| %>
+ <%= render item_component.new(form_builder: ff) %>
+ <% end %>
+ <% end %>
+
+
+ - <%= link_to("Annuler et revenir à l'écran de gestion", back_link, class: "fr-btn fr-btn--secondary") %>
+ - <%= form.submit("Appliquer les suggestions et poursuivre", class: "fr-btn") %>
+
+ <% end %>
+
\ No newline at end of file
diff --git a/app/components/llm/suggestion_form_component.rb b/app/components/llm/suggestion_form_component.rb
new file mode 100644
index 00000000000..5c14c3b043b
--- /dev/null
+++ b/app/components/llm/suggestion_form_component.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class LLM::SuggestionFormComponent < ApplicationComponent
+ attr_reader :llm_rule_suggestion
+
+ def initialize(llm_rule_suggestion:)
+ @llm_rule_suggestion = llm_rule_suggestion
+ end
+
+ delegate :rule, to: :llm_rule_suggestion
+
+ def step_rule
+ rule
+ end
+
+ def title = item_component.step_title
+ def summary = item_component.step_summary
+ def item_component = llm_rule_suggestion.view_component
+
+ def procedure_revision
+ llm_rule_suggestion.procedure_revision
+ end
+
+ def procedure
+ procedure_revision.procedure
+ end
+
+ def back_link
+ helpers.admin_procedure_path(procedure)
+ end
+
+ def suggestions_count
+ llm_rule_suggestion.llm_rule_suggestion_items.size
+ end
+
+ private
+
+ def render?
+ llm_rule_suggestion.present?
+ end
+end
diff --git a/app/components/llm/suggestion_item_component.rb b/app/components/llm/suggestion_item_component.rb
new file mode 100644
index 00000000000..0ccf60769b5
--- /dev/null
+++ b/app/components/llm/suggestion_item_component.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class LLM::SuggestionItemComponent < ApplicationComponent
+ attr_reader :form_builder
+
+ def initialize(form_builder:)
+ @form_builder = form_builder
+ end
+
+ def item
+ form_builder.object
+ end
+
+ def llm_rule_suggestion
+ item.llm_rule_suggestion
+ end
+
+ def revision
+ llm_rule_suggestion.procedure_revision
+ end
+
+ def procedure
+ revision.procedure
+ end
+
+ def tdc_for(stable_id)
+ prtdc_index[stable_id]&.type_de_champ
+ end
+
+ def position_for(stable_id)
+ prtdc_index[stable_id]&.position
+ end
+
+ def prtdc_index
+ @prtdc_index ||= revision.revision_types_de_champ_public.index_by(&:stable_id)
+ end
+end
diff --git a/app/components/password_complexity_component/password_complexity_component.html.haml b/app/components/password_complexity_component/password_complexity_component.html.haml
index 028475ef108..6fad80e2f1d 100644
--- a/app/components/password_complexity_component/password_complexity_component.html.haml
+++ b/app/components/password_complexity_component/password_complexity_component.html.haml
@@ -2,5 +2,6 @@
#password_hint{ class: alert_classes }
= tag.send(heading_level, title, class: 'fr-alert__title', aria: { live: :polite, atomic: true})
+ = title
- if !success?
= t(".hint_html")
diff --git a/app/components/procedure/card/ai_component.rb b/app/components/procedure/card/ai_component.rb
new file mode 100644
index 00000000000..9eb010b53a7
--- /dev/null
+++ b/app/components/procedure/card/ai_component.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class Procedure::Card::AiComponent < ApplicationComponent
+ delegate :score, to: :linter
+ delegate :rate, to: :linter
+ delegate :perfect_rate?, to: :linter
+
+ attr_reader :procedure
+
+ def initialize(procedure:)
+ @procedure = procedure
+ end
+
+ def linter
+ @linter = ProcedureLinter.new(procedure, procedure.draft_revision)
+ end
+
+ def render?
+ rate_ok?
+ end
+
+ def rate
+ "#{@linter.rate} / #{@linter.top_rate}"
+ end
+
+ def errors_count
+ @linter.score
+ end
+
+ def rate_ok?
+ perfect_rate?.inspect
+ end
+end
diff --git a/app/components/procedure/card/ai_component/ai_component.html.haml b/app/components/procedure/card/ai_component/ai_component.html.haml
new file mode 100644
index 00000000000..b0b2ec493fc
--- /dev/null
+++ b/app/components/procedure/card/ai_component/ai_component.html.haml
@@ -0,0 +1,18 @@
+.fr-col-6.fr-col-md-4.fr-col-lg-3
+ = link_to simplify_index_admin_procedure_types_de_champ_path(@procedure), id: 'administrateurs', class: 'fr-tile fr-enlarge-link' do
+ .fr-tile__body.flex.column.align-center.justify-between
+ - if perfect_rate?
+ %p.fr-badge.fr-badge--success Validé
+ - else
+ %p.fr-badge.fr-badge--warning À faire
+ %div
+ .line-count.fr-my-1w
+ %p.fr-tag.fr-tag-error= "#{errors_count} suggestions"
+ %h3.fr-h6
+ Qualité du formulaire
+ .fr-tile-subtitle
+ %p Appliquez nos suggestions d'amélioration
+ %p.fr-hint-text.fr-mb-0.fr-mt-1v
+ %strong Score actuel du formulaire
+ = render Procedure::SimplifyGaugeComponent.new(@procedure, @procedure.draft_revision)
+ %p.fr-btn.fr-btn--tertiary= t('views.shared.actions.edit')
diff --git a/app/components/procedure/simplify_gauge_component.rb b/app/components/procedure/simplify_gauge_component.rb
new file mode 100644
index 00000000000..a947d06b2d7
--- /dev/null
+++ b/app/components/procedure/simplify_gauge_component.rb
@@ -0,0 +1,12 @@
+class Procedure::SimplifyGaugeComponent < ApplicationComponent
+ def initialize(procedure, revision)
+ @linter = ProcedureLinter.new(procedure, revision)
+ end
+
+ def call
+ safe_join([
+ tag.div(id: 'password_hint', class: "password-complexity complexity-#{@linter.rate / 2 - 1}"),
+ tag.strong(class: 'text-center fr-hint-text') { @linter.quali_score }
+ ])
+ end
+end
diff --git a/app/components/referentiels/stepper_component.rb b/app/components/referentiels/stepper_component.rb
index c72e073addd..5184a3d032b 100644
--- a/app/components/referentiels/stepper_component.rb
+++ b/app/components/referentiels/stepper_component.rb
@@ -1,41 +1,32 @@
# frozen_string_literal: true
-class Referentiels::StepperComponent < ViewComponent::Base
- attr_reader :referentiel, :type_de_champ, :procedure, :step_component
+class Referentiels::StepperComponent < StepperBaseComponent
+ delegate :referentiel, :type_de_champ, :procedure, to: :step_component
- def initialize(referentiel:, type_de_champ:, procedure:, step_component:)
- @referentiel = referentiel
- @type_de_champ = type_de_champ
- @procedure = procedure
- @step_component = step_component
+ def initialize(step_component:)
+ super(step_component:)
end
def back_link
- opts = { class: 'fr-link fr-icon-arrow-left-line fr-link--icon--left fr-icon--sm' }
+ helpers.link_to(back_link_label, back_path, class: 'fr-link fr-icon-arrow-left-line fr-link--icon--left fr-icon--sm')
+ end
+ def title
if type_de_champ.public?
- link_to "Champs du formulaire", champs_admin_procedure_path(procedure), opts
+ "Configuration du champ « #{type_de_champ.libelle} »"
else
- link_to "Annotations privées", annotations_admin_procedure_path(procedure), opts
+ "Configuration de l'annotation privée « #{type_de_champ.libelle} »"
end
end
- def title
- "Configuration #{type_de_champ.public? ? 'du champ' : 'de l\'annotation privée'} « #{type_de_champ.libelle} »"
- end
-
- def step_state
- "Étape #{current_step} sur #{step_count}"
- end
-
def step_title
- if step_component == Referentiels::NewFormComponent || (step_component == Referentiels::ConfigurationErrorComponent && referentiel.exact_match?)
+ if step_component == Referentiels::NewFormComponent || step_component == Referentiels::ConfigurationErrorComponent && referentiel.exact_match?
"Requête"
elsif step_component == Referentiels::MappingFormComponent
"Réponse et mapping"
elsif step_component == Referentiels::PrefillAndDisplayComponent
"Pré remplissage des champs et/ou affichage des données récupérées"
- elsif step_component == Referentiels::AutocompleteConfigurationComponent || (step_component == Referentiels::ConfigurationErrorComponent && referentiel.autocomplete?)
+ elsif step_component == Referentiels::AutocompleteConfigurationComponent || step_component == Referentiels::ConfigurationErrorComponent && referentiel.autocomplete?
"Configuration de l'autocomplétion"
end
end
@@ -70,4 +61,18 @@ def current_step
def step_count
referentiel.mode == 'exact_match' ? 3 : 4
end
+
+ private
+
+ def back_link_label
+ type_de_champ.public? ? 'Champs du formulaire' : 'Annotations privées'
+ end
+
+ def back_path
+ if type_de_champ.public?
+ helpers.champs_admin_procedure_path(procedure)
+ else
+ helpers.annotations_admin_procedure_path(procedure)
+ end
+ end
end
diff --git a/app/components/stepper_base_component.rb b/app/components/stepper_base_component.rb
new file mode 100644
index 00000000000..0634701da4e
--- /dev/null
+++ b/app/components/stepper_base_component.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class StepperBaseComponent < ViewComponent::Base
+ attr_reader :step_component
+
+ renders_one :header
+
+ def initialize(step_component:)
+ @step_component = step_component
+ end
+
+ def back_link
+ raise NotImplementedError
+ end
+
+ def title
+ raise NotImplementedError
+ end
+
+ def step_title
+ raise NotImplementedError
+ end
+
+ def next_step_title
+ nil
+ end
+
+ def current_step
+ raise NotImplementedError
+ end
+
+ def step_count
+ raise NotImplementedError
+ end
+
+ def step_state
+ "Étape #{current_step} sur #{step_count}"
+ end
+
+ def subtitle
+ nil
+ end
+end
diff --git a/app/components/referentiels/stepper_component/stepper_component.html.haml b/app/components/stepper_base_component/stepper_base_component.html.haml
similarity index 76%
rename from app/components/referentiels/stepper_component/stepper_component.html.haml
rename to app/components/stepper_base_component/stepper_base_component.html.haml
index dc970eeb00c..74ac633653c 100644
--- a/app/components/referentiels/stepper_component/stepper_component.html.haml
+++ b/app/components/stepper_base_component/stepper_base_component.html.haml
@@ -2,15 +2,17 @@
= back_link
%h3.fr-my-3w
= title
+ - if header?
+ = header
.fr-stepper
%h2.fr-stepper__title
= step_title
%span.fr-stepper__state= step_state
- .fr-stepper__steps{ data: { "fr-current-step" => current_step, "fr-steps" =>step_count } }
+ .fr-stepper__steps{ data: { "fr-current-step" => current_step, "fr-steps" => step_count } }
%p.fr-stepper__details
%span.fr-text--bold Étape suivante :
= next_step_title
- = render step_component.new(referentiel: , type_de_champ:, procedure:)
+ = render step_component
diff --git a/app/controllers/administrateurs/types_de_champ_controller.rb b/app/controllers/administrateurs/types_de_champ_controller.rb
index ac9a3bdce40..6222aa0ec9c 100644
--- a/app/controllers/administrateurs/types_de_champ_controller.rb
+++ b/app/controllers/administrateurs/types_de_champ_controller.rb
@@ -1,9 +1,12 @@
# frozen_string_literal: true
+require 'digest'
+
module Administrateurs
class TypesDeChampController < AdministrateurController
include ActiveSupport::NumberHelper
include CsvParsingConcern
+ include ActionView::Helpers::TagHelper
before_action :retrieve_procedure
before_action :reload_procedure_with_includes, only: [:destroy]
@@ -163,13 +166,59 @@ def import_referentiel
ReferentielItem.insert_all(items_to_insert)
end
+ end
- @coordinate = draft.coordinate_for(type_de_champ)
- @morphed = [champ_component_from(@coordinate)]
+ def enqueue_simplify
+ unless Flipper.enabled?(:llm_nightly_improve_procedure, @procedure)
+ return redirect_to(simplify_index_admin_procedure_types_de_champ_path(@procedure), alert: "Fonctionnalité indisponible pour cette démarche.")
+ end
+
+ if llm_rule_suggestion_scope.exists?(state: [:queued, :running])
+ redirect_to(simplify_index_admin_procedure_types_de_champ_path(@procedure), alert: "Une analyse est déjà en cours pour cette version de la démarche.")
+ else
+ LLM::ImproveProcedureJob.perform_now(@procedure)
+ redirect_to(simplify_index_admin_procedure_types_de_champ_path(@procedure), notice: "Analyse de la démarche lancée. Revenez dans quelques minutes pour consulter les suggestions.")
+ end
+ end
+
+ def simplify
+ suggestion = llm_rule_suggestion_scope.completed
+ .where(id: params[:llm_suggestion_rule_id])
+ .order(created_at: :desc)
+ .first
+ if suggestion
+ @llm_rule_suggestion = suggestion
+ else
+ redirect_to simplify_index_admin_procedure_types_de_champ_path(@procedure), alert: "Suggestion non trouvée"
+ end
+ end
+
+ def accept_simplification
+ @llm_rule_suggestion = llm_rule_suggestion_scope.completed.includes(:llm_rule_suggestion_items).where(id: params[:llm_suggestion_rule_id]).first
+ return redirect_to(simplify_index_admin_procedure_types_de_champ_path(@procedure), alert: "Suggestion non trouvée") unless @llm_rule_suggestion
+
+ @llm_rule_suggestion.assign_attributes(llm_rule_suggestion_items_attributes)
+ @llm_rule_suggestion.save!
+ @procedure.draft_revision.apply_changes(@llm_rule_suggestion.changes_to_apply)
+
+ redirect_to simplify_index_admin_procedure_types_de_champ_path(@procedure)
+ end
+
+ def llm_rule_suggestion_scope
+ LLMRuleSuggestion.where(procedure_revision_id: draft.id, schema_hash: current_schema_hash)
+ end
+
+ def llm_rule_suggestion_items_attributes
+ params.require(:llm_rule_suggestion)
+ .permit(llm_rule_suggestion_items_attributes: [:id, :verify_status])
end
private
+ def current_schema_hash
+ @current_schema_hash ||= Digest::SHA256.hexdigest(draft.schema_to_llm.to_json)
+ end
+
def changing_of_type?(type_de_champ)
type_de_champ_update_params['type_champ'].present? && (type_de_champ_update_params['type_champ'] != type_de_champ.type_champ)
end
@@ -259,5 +308,12 @@ def referentiel_file
def marcel_content_type
Marcel::MimeType.for(referentiel_file.read, name: referentiel_file.original_filename, declared_type: referentiel_file.content_type)
end
+
+ def load_suggestion(rule, revision)
+ end
+
+ def allowed_rule?(rule)
+ rule.in?([LLM::LabelImprover::TOOL_NAME])
+ end
end
end
diff --git a/app/jobs/cron/llm_enqueue_nightly_improve_procedure_job.rb b/app/jobs/cron/llm_enqueue_nightly_improve_procedure_job.rb
new file mode 100644
index 00000000000..a8f4c4d7498
--- /dev/null
+++ b/app/jobs/cron/llm_enqueue_nightly_improve_procedure_job.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Cron::LLMEnqueueNightlyImproveProcedureJob < Cron::CronJob
+ self.schedule_expression = "every day at 02:30"
+
+ def perform(*_args)
+ Procedure
+ .order(updated_at: :desc)
+ .find_each(batch_size: 200) do |procedure|
+ next unless Flipper.enabled?(:llm_nightly_improve_procedure, procedure)
+
+ LLM::ImproveProcedureJob.perform_later(procedure)
+ end
+ end
+end
diff --git a/app/jobs/llm/generate_rule_suggestion_job.rb b/app/jobs/llm/generate_rule_suggestion_job.rb
new file mode 100644
index 00000000000..b907b2d6a5e
--- /dev/null
+++ b/app/jobs/llm/generate_rule_suggestion_job.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class LLM::GenerateRuleSuggestionJob < ApplicationJob
+ queue_as :default
+
+ rescue_from(StandardError) do |exception|
+ Sentry.capture_exception(exception, level: :error)
+ end
+
+ def perform(suggestion)
+ suggestion.update!(state: :running)
+ items = service(suggestion).generate_for(suggestion)
+ if items.any?
+ LLMRuleSuggestionItem.transaction do
+ suggestion.llm_rule_suggestion_items.delete_all
+ LLMRuleSuggestionItem.insert_all!(items.map { it.merge(llm_rule_suggestion_id: suggestion.id) })
+ end
+ end
+ suggestion.update!(state: :completed)
+ rescue StandardError => e
+ suggestion.update!(state: :failed)
+ raise e
+ end
+
+ private
+
+ def service(suggestion)
+ @runner ||= LLM::Runner.new
+ @service ||= begin
+ case suggestion.rule
+ when 'improve_label'
+ return LLM::LabelImprover.new(runner: @runner)
+ when 'improve_structure'
+ return LLM::StructureImprover.new(runner: @runner)
+ else
+ raise "Unknown rule: #{suggestion.rule}"
+ end
+ end
+ end
+end
diff --git a/app/jobs/llm/improve_procedure_job.rb b/app/jobs/llm/improve_procedure_job.rb
new file mode 100644
index 00000000000..2fa28664490
--- /dev/null
+++ b/app/jobs/llm/improve_procedure_job.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'digest'
+
+module LLM
+ class ImproveProcedureJob < ApplicationJob
+ queue_as :default
+
+ def perform(procedure)
+ return unless Flipper.enabled?(:llm_nightly_improve_procedure, procedure)
+
+ procedure_revision = procedure.draft_revision
+ return unless procedure_revision
+
+ schema_hash = Digest::SHA256.hexdigest(procedure_revision.schema_to_llm.to_json)
+
+ available_rules.each do |rule|
+ suggestion = LLMRuleSuggestion.find_or_initialize_by(procedure_revision:, schema_hash:, rule:)
+
+ next if suggestion.persisted? && !suggestion.failed?
+ suggestion.state = :queued
+ suggestion.save!
+ LLM::GenerateRuleSuggestionJob.perform_later(suggestion)
+ end
+ end
+
+ private
+
+ def available_rules
+ [
+ LLM::LabelImprover::TOOL_NAME,
+ LLM::StructureImprover::TOOL_NAME
+ ]
+ end
+ end
+end
diff --git a/app/lib/procedure_linter.rb b/app/lib/procedure_linter.rb
new file mode 100644
index 00000000000..e569d765763
--- /dev/null
+++ b/app/lib/procedure_linter.rb
@@ -0,0 +1,130 @@
+class ProcedureLinter
+ attr_reader :procedure, :tdcs, :rules
+
+ RULES = [
+ :uppercase_in_libelles?,
+ :too_long_libelle?,
+ :optional_and_no_condition?,
+ :missnamed_libelle?,
+ :no_header_section?,
+ :nom_prenom_for_individual?,
+ :first_champ_is_siret_for_moral_procedure?,
+ :notice_missing?,
+ :extra_address_champs?,
+ :entreprise_champ_after_siret?
+ ]
+
+ ComputedRule = Data.define(:pass, :details) do
+ def score
+ details.size
+ end
+ end
+
+ Rule = Data.define(:pass, :details) do
+ def score
+ 1
+ end
+ end
+
+ def initialize(procedure, revision)
+ @procedure = procedure
+ @tdcs = revision.types_de_champ_public
+ end
+
+ def quali_score
+ "#{details.values.count(&:pass)}/#{details.values.size}"
+ end
+
+ def perfect_rate?
+ details.values.count(&:pass) == details.values.size
+ end
+
+ def top_rate
+ details.values.size
+ end
+
+ def rate
+ details.values.count(&:pass)
+ end
+
+ def score
+ details.values.sum(&:score)
+ end
+
+ def details
+ @computed ||= RULES.index_with { |method_name| send(method_name) }
+ end
+
+ def too_long_libelle?
+ errored = tdcs.filter { _1.libelle.size > 80 }
+
+ ComputedRule.new(errored.empty?, errored.map { [_1.stable_id, _1.libelle] })
+ end
+
+ def uppercase_in_libelles?
+ errored = tdcs.filter { mostly_uppercase?(_1.libelle) }
+
+ ComputedRule.new(errored.empty?, errored.map { [_1.stable_id, _1.libelle] })
+ end
+
+ def optional_and_no_condition?
+ return Rule.new(true, {}) if tdcs.size < 100 || tdcs.any?(&:condition)
+ mandatory = tdcs.count(&:mandatory?).to_f
+ Rule.new((mandatory / tdcs.size.to_f) > 0.3, { total: tdcs.size, mandatory: mandatory })
+ end
+
+ def missnamed_libelle?
+ forbidden_words_by_ditp = [
+ 'Dans le cadre de', 'Dans le but de', 'En ce qui concerne', 'Par conséquent', "L’administration ", "Le service", "l’agent", "usager", "
+bénéficiaire", "demandeur", "Souscrire une demande", "Proroger", "Stipuler", "Au titre de l’article ", "Se référer au service", "Récépissé", "Faire parvenir", "Recouvrer
+", "Tacite", "Il vous revient de", "Aux fins de"
+ ]
+ errored = tdcs.filter { |tdc| forbidden_words_by_ditp.any? { _1.downcase.in?(tdc.libelle.downcase) } }
+
+ ComputedRule.new(errored.empty?, errored.map { [_1.stable_id, _1.libelle] })
+ end
+
+ def no_header_section?
+ return Rule.new(true, "") if tdcs.size < 20
+
+ Rule.new(tdcs.any?(&:header_section?), "")
+ end
+
+ def nom_prenom_for_individual?
+ errored = tdcs.filter { |tdc| tdc.libelle.match?(/^(pr*)?nom$/i) }
+ ComputedRule.new(errored.empty?, errored.map { [_1.stable_id, _1.libelle] })
+ end
+
+ def first_champ_is_siret_for_moral_procedure?
+ return Rule.new(true, {}) if procedure.for_individual?
+
+ first_tdc_is_siret = tdcs.first.siret?
+ Rule.new(first_tdc_is_siret, [[tdcs.first.stable_id, tdcs.first.libelle]])
+ end
+
+ def notice_missing?
+ Rule.new(procedure.notice.present?, "")
+ end
+
+ def extra_address_champs?
+ errored = tdcs.filter.with_index { |tdc, i| i < tdcs.size - 1 && tdc.address? && tdcs[i + 1].communes? }
+
+ ComputedRule.new(errored.empty?, errored.map { [_1.stable_id, _1.libelle] })
+ end
+
+ def entreprise_champ_after_siret?
+ matchers = %w[adresse entreprise]
+ errored = tdcs.filter.with_index { |tdc, i| i < tdcs.size - 1 && tdc.siret? && matchers.all? { |comp| /#{comp}/i.match?(tdcs[i + 1].libelle) } }
+
+ ComputedRule.new(errored.empty?, errored.map { [_1.stable_id, _1.libelle] })
+ end
+
+ def mostly_uppercase?(sentence)
+ words = sentence.scan(/[A-Za-z]+/) # Extract words, ignoring numbers and symbols
+ return false if words.empty? # Return false if no words found
+
+ uppercase_count = words.count { |word| word == word.upcase }
+
+ (uppercase_count.to_f / words.size) > 0.8
+ end
+end
diff --git a/app/models/concerns/revision_describable_to_llm_concern.rb b/app/models/concerns/revision_describable_to_llm_concern.rb
new file mode 100644
index 00000000000..2b14f14eb20
--- /dev/null
+++ b/app/models/concerns/revision_describable_to_llm_concern.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module RevisionDescribableToLLMConcern
+ def schema_to_llm
+ revision_types_de_champ_public.map do |rtdc|
+ {
+ stable_id: rtdc.stable_id,
+ type: rtdc.type_champ,
+ libelle: rtdc.libelle,
+ mandatory: rtdc.mandatory?,
+ description: rtdc.description,
+ choices: (rtdc.type_de_champ.drop_down_options if rtdc.type_de_champ.choice_type?),
+ position: rtdc.position
+ }.compact
+ end
+ end
+end
diff --git a/app/models/llm_rule_suggestion.rb b/app/models/llm_rule_suggestion.rb
new file mode 100644
index 00000000000..d050d75db2d
--- /dev/null
+++ b/app/models/llm_rule_suggestion.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class LLMRuleSuggestion < ApplicationRecord
+ belongs_to :procedure_revision
+
+ has_many :llm_rule_suggestion_items, dependent: :destroy
+
+ enum :state, { queued: 'queued', running: 'running', completed: 'completed', failed: 'failed' }
+
+ validates :schema_hash, presence: true
+ validates :rule, presence: true
+
+ accepts_nested_attributes_for :llm_rule_suggestion_items
+
+ def llm_rule_suggestion_items_attributes=(attributes)
+ attributes.each do |(_idx, llm_rule_suggestion_items_attribute)|
+ llm_rule_suggestion_item = llm_rule_suggestion_items.find { it.id.to_i == llm_rule_suggestion_items_attribute[:id].to_i }
+ next unless llm_rule_suggestion_item
+
+ if llm_rule_suggestion_items_attribute[:verify_status] == 'accepted'
+ llm_rule_suggestion_item.verify_status = 'accepted'
+ llm_rule_suggestion_item.applied_at = Time.current
+ else
+ llm_rule_suggestion_item.verify_status = 'skipped'
+ llm_rule_suggestion_item.applied_at = nil
+ end
+ end
+ end
+
+ def changes_to_apply
+ llm_rule_suggestion_items.accepted.group_by(&:op_kind).transform_keys(&:to_sym)
+ end
+
+ def view_component
+ case rule
+ when 'improve_label'
+ LLM::ImproveLabelItemComponent
+ when 'improve_structure'
+ LLM::ImproveStructureItemComponent
+ else
+ raise "Unknown LLM rule suggestion view component for rule: #{rule}"
+ end
+ end
+end
diff --git a/app/models/llm_rule_suggestion_item.rb b/app/models/llm_rule_suggestion_item.rb
new file mode 100644
index 00000000000..73635771a41
--- /dev/null
+++ b/app/models/llm_rule_suggestion_item.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class LLMRuleSuggestionItem < ApplicationRecord
+ belongs_to :llm_rule_suggestion
+
+ enum :safety, { safe: 'safe', review: 'review' }
+ enum :verify_status, { pending: 'pending', accepted: 'accepted', skipped: 'skipped' }
+
+ validates :op_kind, presence: true
+ validates :op_kind, inclusion: { in: %w[update add destroy] }
+end
diff --git a/app/models/procedure_revision.rb b/app/models/procedure_revision.rb
index d04241852a1..5d76bf23f77 100644
--- a/app/models/procedure_revision.rb
+++ b/app/models/procedure_revision.rb
@@ -2,11 +2,12 @@
class ProcedureRevision < ApplicationRecord
include Logic
+ include RevisionDescribableToLLMConcern
self.implicit_order_column = :created_at
belongs_to :administrateur, optional: true
belongs_to :procedure, -> { with_discarded }, inverse_of: :revisions, optional: false
belongs_to :dossier_submitted_message, inverse_of: :revisions, optional: true, dependent: :destroy
-
+ has_many :llm_rule_suggestions, dependent: :destroy, inverse_of: :procedure_revision
has_many :dossiers, inverse_of: :revision, foreign_key: :revision_id
has_many :revision_types_de_champ, -> { order(:position, :id) }, class_name: 'ProcedureRevisionTypeDeChamp', foreign_key: :revision_id, dependent: :destroy, inverse_of: :revision
@@ -252,6 +253,44 @@ def conditionable_types_de_champ
types_de_champ_for(scope: :public).filter(&:conditionable?)
end
+ def apply_changes(changes)
+ transaction do
+ changes.fetch(:destroy, []).each { |llm_rule_suggestion_items| remove_type_de_champ(llm_rule_suggestion_items.stable_id) }
+
+ changes.fetch(:update, []).each do |llm_rule_suggestion_items|
+ position, type_champ, libelle, mandatory = llm_rule_suggestion_items.payload.with_indifferent_access.values_at(:position, :type_champ, :libelle, :mandatory)
+ tdc = find_and_ensure_exclusive_use(llm_rule_suggestion_items.stable_id)
+ tdc.update({type_champ:, libelle:, mandatory:}.compact)
+ if position
+ move_type_de_champ(llm_rule_suggestion_items.stable_id, position)
+ end
+ end
+
+ # TODO
+
+ changes.fetch(:add, []).each do |llm_rule_suggestion_items|
+ after_stable_id, type_champ, libelle = llm_rule_suggestion_items.payload.with_indifferent_access.values_at(:after_stable_id, :type_champ, :libelle)
+ after_stable_id = nil if after_stable_id.to_i.zero?
+
+ add_type_de_champ(after_stable_id:, type_champ:, libelle:)
+
+ # if type_champ == 'repetition'
+ # parent_stable_id = tdc.stable_id
+ # children = change[:children]
+
+ # previous_child_stable_id = nil
+ # children.each do |child|
+ # type_champ, libelle = child.values_at(:type_champ, :libelle)
+ # child = add_type_de_champ(parent_stable_id:, type_champ:, libelle:, after_stable_id: previous_child_stable_id)
+ # previous_child_stable_id = child.stable_id
+ # end
+ # else
+
+ # end
+ end
+ end
+ end
+
private
def compute_estimated_fill_duration
diff --git a/app/services/llm/base_improver.rb b/app/services/llm/base_improver.rb
new file mode 100644
index 00000000000..8abcc2540c4
--- /dev/null
+++ b/app/services/llm/base_improver.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module LLM
+ class BaseImprover
+ attr_reader :runner, :logger
+
+ def initialize(runner: nil, logger: Rails.logger)
+ @runner = runner
+ @logger = logger
+ end
+
+ def generate_for(suggestion)
+ messages = build_messages(suggestion)
+ calls = run_tool_call(tool_definition: self.class::TOOL_DEFINITION, messages:)
+ normalize_tool_calls(calls, self.class::TOOL_NAME) { |args| build_item(args) }
+ end
+
+ private
+
+ def build_messages(suggestion)
+ revision = suggestion.procedure_revision
+ schema = revision.schema_to_llm
+
+ [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: format(schema_prompt, schema: JSON.dump(schema)) },
+ { role: 'user', content: rules_prompt },
+ ]
+ end
+
+ def run_tool_call(tool_definition:, messages:)
+ return [] unless runner
+
+ runner.call(messages:, tools: [tool_definition]) || []
+ rescue => e
+ logger.warn("[#{self.class.name}] tool call failed: #{e.class}: #{e.message}")
+ []
+ end
+
+ def normalize_tool_calls(calls, tool_name)
+ calls
+ .filter { |call| call[:name] == tool_name }
+ .map do |call|
+ args = call[:arguments] || {}
+ yield(args)
+ end
+ .compact
+ end
+ end
+end
diff --git a/app/services/llm/label_improver.rb b/app/services/llm/label_improver.rb
new file mode 100644
index 00000000000..414df9cf19f
--- /dev/null
+++ b/app/services/llm/label_improver.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module LLM
+ # Orchestrates improve_label generation using tool-calling.
+ class LabelImprover < BaseImprover
+ TOOL_NAME = 'improve_label'
+ TOOL_DEFINITION = {
+ type: 'function',
+ function: {
+ name: TOOL_NAME,
+ description: 'Format the label improvement as a standardized operation.',
+ parameters: {
+ type: 'object',
+ properties: {
+ update: {
+ type: 'object',
+ properties: {
+ stable_id: { type: 'integer', description: 'Target field stable id' },
+ libelle: { type: 'string', description: 'New label (<= 80 chars, plain language)' },
+ },
+ required: %w[stable_id libelle],
+ },
+ justification: { type: 'string', description: 'Short reason for change' },
+ confidence: { type: 'number', description: '0..1 confidence score' },
+ },
+ required: %w[update],
+ },
+ },
+ }.freeze
+
+ def system_prompt
+ 'Tu es un assistant qui améliore les libellés des champs de formulaires administratifs français.'
+ end
+
+ def schema_prompt
+ <<~TXT
+ Voici le schéma des champs (publics) du formulaire en JSON. Chaque entrée contient un stable_id, un type, un libellé, et éventuellement une description.
+
+ %s
+
+ TXT
+ end
+
+ def rules_prompt
+ <<~TXT
+ Règles:
+ - Propose uniquement si le libellé peut être nettement amélioré (clarté, concision, casse correcte, éviter les majuscules intégrales).
+ - Ne pas proposer si le gain est minime (distance d’édition très faible).
+ - Longueur maximale 80 caractères.
+ - Utiliser l’outil #{TOOL_NAME} pour chaque champ à améliorer (un appel par champ).
+ TXT
+ end
+
+ def build_item(args)
+ update = args['update'].is_a?(Hash) ? args['update'] : {}
+ stable_id = update['stable_id'] || args['stable_id']
+ libelle = (update['libelle'] || args['libelle']).to_s.strip
+ return if stable_id.nil? || libelle.blank?
+
+ {
+ op_kind: 'update',
+ stable_id: stable_id,
+ payload: { 'stable_id' => stable_id, 'libelle' => libelle },
+ safety: 'safe',
+ justification: args['justification'].to_s.presence,
+ confidence: args['confidence'],
+ }
+ end
+ end
+end
diff --git a/app/services/llm/openai_client.rb b/app/services/llm/openai_client.rb
new file mode 100644
index 00000000000..72c60ed4799
--- /dev/null
+++ b/app/services/llm/openai_client.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'langchain'
+
+# Albert uses OpenAI interface.
+
+module LLM
+ class OpenAIClient
+ def self.instance
+ Langchain::LLM::OpenAI.new(
+ api_key: ENV["LLM_API_KEY"],
+ llm_options: {
+ uri_base: ENV.fetch("LLM_URI_BASE"),
+ },
+ default_options: {
+ temperature: ENV.fetch("LLM_TEMPERATURE", 0.2).to_f,
+ chat_model: ENV.fetch("LLM_MODEL_NAME"),
+ }
+ )
+ end
+ end
+end
diff --git a/app/services/llm/revision_improver_service.rb b/app/services/llm/revision_improver_service.rb
new file mode 100644
index 00000000000..47aae7d7382
--- /dev/null
+++ b/app/services/llm/revision_improver_service.rb
@@ -0,0 +1,332 @@
+# frozen_string_literal: true
+
+require 'fileutils'
+require 'langchain'
+require 'json_schemer'
+
+module LLM
+ class RevisionImproverService
+ JSON_OPS_SCHEMA = JSON.parse(
+ Rails.root.join('config/llm/revision_improver_operations_json_schema.json').read
+ ).freeze
+
+ attr_reader :llm, :procedure, :logger, :clock
+ attr_accessor :now
+
+ module Errors
+ class InvalidOutput < StandardError; end
+ class Unavailable < StandardError; end
+ end
+
+ def initialize(procedure, llm: OpenAIClient.instance, logger: Rails.logger, clock: -> { Time.zone.now.to_i })
+ @procedure = procedure
+ @llm = llm
+ @logger = logger
+ @clock = clock
+ end
+
+ # Optional analysis phase. Returns raw assistant content (String).
+ def analyze!
+ llm.chat_parameters.update(
+ temperature: { default: 1.0 },
+ repetition_penalty: { default: 0 },
+ max_tokens: { default: 4096 },
+ response_format: { default: { type: 'json_object' } }
+ )
+ messages = [system_message, *messages_for_analyze]
+ content = run_chat(messages)
+ backup('analysis', content)
+ content
+ end
+
+ # Backward-compat shim
+ def analyze
+ analyze!
+ end
+
+ # Main entry point returning normalized operations:
+ # { destroy: [], update: [], add: [], summary: "..." }
+ def suggest!(analysis: nil)
+ llm.chat_parameters.update(temperature: { default: 0.1 }, repetition_penalty: { default: 0 }, max_tokens: { default: 4096 })
+ messages = [system_message, *messages_for_suggest(analysis)]
+ content = run_chat(messages)
+ backup('suggest', content)
+
+ json = parse_json!(content)
+ validate_ops!(json)
+ normalize_ops(json)
+ rescue Errors::InvalidOutput
+ raise
+ rescue => e
+ logger.warn("[LLM] suggest! failed: #{e.class}: #{e.message}")
+ raise
+ end
+
+ # Backward-compat wrapper for callers expecting {operations:, summary:}
+ def suggest
+ ops = suggest!(analysis: @injected_analysis)
+ { operations: { destroy: ops[:destroy], update: ops[:update], add: ops[:add] }, summary: ops[:summary] }
+ end
+
+ # Backward-compat: allow injecting a precomputed analysis
+ def insert_analysis(analysis)
+ @injected_analysis = analysis
+ end
+
+ private
+
+ def run_chat(messages)
+ assistant = Langchain::Assistant.new(llm:)
+ messages.each { |m| assistant.add_message(**m) }
+ assistant.run!
+ assistant.messages.last.content.to_s
+ end
+
+ def system_message
+ { role: 'system', content: system_prompt }
+ end
+
+ def messages_for_analyze
+ [
+ { role: 'user', content: current_schema_prompt },
+ { role: 'user', content: analyze_prompt },
+ ]
+ end
+
+ def messages_for_suggest(analysis)
+ msgs = [
+ { role: 'user', content: current_schema_prompt },
+ { role: 'user', content: format(restructure_prompt, json_schema: JSON.dump(JSON_OPS_SCHEMA)) },
+ ]
+ analysis ? [{ role: 'assistant', content: analysis }] + msgs : msgs
+ end
+
+ def parse_json!(content)
+ s = content.to_s.strip
+ # Strip Markdown code fences if present, optionally labeled as json
+ if s.start_with?("```")
+ s = s.sub(/\A```(?:json)?\s*/i, "").sub(/```+\s*\z/, "").strip
+ else
+ # Or extract the first fenced JSON block
+ if (m = s.match(/```(?:json)?\s*(\{.*\})\s*```/im))
+ s = m[1].strip
+ end
+ end
+
+ begin
+ JSON.parse(s)
+ rescue JSON::ParserError
+ # Fallback: try parsing the largest JSON-looking object in the text
+ if s.include?("{") && s.include?("}")
+ inner = s[s.index('{')..s.rindex('}')]
+ JSON.parse(inner)
+ else
+ raise Errors::InvalidOutput, 'Non JSON content from LLM'
+ end
+ end
+ rescue JSON::ParserError
+ raise Errors::InvalidOutput, 'Non JSON content from LLM'
+ end
+
+ def validate_ops!(json)
+ # Accept both shapes: {operations:{...}, summary:""} or flat keys {delete/update/add/summary}
+ candidate = if json.key?('operations')
+ json
+ else
+ { 'operations' => json.slice('destroy', 'update', 'add'), 'summary' => json['summary'] }
+ end
+
+ schemer = JSONSchemer.schema(JSON_OPS_SCHEMA)
+ errors = schemer.validate(candidate).to_a
+ raise Errors::InvalidOutput, errors.first&.dig('details') if errors.any?
+ end
+
+ def normalize_ops(json)
+ ops = json['operations'] || json
+ {
+ destroy: Array(ops['destroy']).map { |h| deep_symbolize(h) },
+ update: Array(ops['update']).map { |h| deep_symbolize(h) },
+ add: Array(ops['add']).map { |h| deep_symbolize(h) },
+ summary: json['summary'].to_s,
+ }
+ end
+
+ def deep_symbolize(obj)
+ case obj
+ when Hash
+ obj.transform_keys { |k| k.to_sym rescue k }.transform_values do |v|
+ deep_symbolize(v)
+ end
+ when Array
+ obj.map { |v| deep_symbolize(v) }
+ else
+ obj
+ end
+ end
+
+ def current_schema_prompt
+ template = <<~PROMPT
+ Here is the form schema you need to analyze:
+
+
+ %s
+
+
+ Here's the administrative procedure you'll be working on:
+
+ %s
+
+
+ The applicant's email address, civility, first name, and last name are already known to administration and should not be requested again.
+ PROMPT
+
+ format(template, libelle: procedure.libelle, schema: procedure.published_revision.schema_to_llm.to_json)
+ end
+
+ def system_prompt
+ <<~PROMPT
+ You are an AI assistant specialized in optimizing online forms for French administrative procedures.
+ Your task is to analyze and improve a given form schema, making it more user-friendly,
+ efficient, and compliant with official recommendations.
+ PROMPT
+ end
+
+ def analyze_prompt
+ <<~PROMPT
+ Read carefully these guidelines:
+ 1. Field Types: Use the appropriate field type from the following list:
+ - header_section: For organizing form sections (no user input)
+ - repetition: For repeatable blocks of children fields. User can repeat children fields as many times as he wants.
+ - explication: For providing context or instructions (no user input)
+ - civilite: For selecting "Madame" or "Monsieur". Administration already knows civilite of user
+ - email: For email addresses. Administration already knows email of user
+ - phone: For phone numbers
+ - address: For postal addresses (auto-completed with additional info: commune name and codes, code postal, departement name and code)
+ - communes: For selecting French communes (auto-completed with additional info: code, code postal, departement name and code)
+ - departments: For selecting French departments
+ - text: For short text inputs
+ - textarea: For longer text inputs
+ - integer_number: For whole numbers
+ - decimal_number: For numbers with decimals
+ - date: For date selection
+ - piece_justificative: For document uploads. Do not wrap in a repetition because it supports multiple documents
+ - titre_identite: For secure identity document uploads
+ - checkbox: For single checkboxes
+ - yes_no: For yes/no questions
+ - drop_down_list: For single-choice selections. Choices are configured by administration separately
+ - multiple_drop_down_list: For multiple-choice selections. Choices are configured by administration separately
+
+ 2. Labeling and Descriptions:
+ - Use proper capitalization in labels and descriptions (ie. not in uppercase). This is crucial.
+ - Use consistent, plain language throughout the form
+ - Make labels clear and understandable for all users
+ - Avoid abbreviations, acronyms, and technical jargon
+ - Maintain consistent pronouns ("Vous" or "Nous") when addressing users
+ - Replace negative constructions with positive, action-oriented statements
+ - Keep sentences short with one idea per sentence
+ - Structure all headings and labels uniformly
+ - Provide descriptions only when necessary to clarify the field's purpose or requirements. Descriptions must not be redundant to labels and must not contain formatting exemples or choices.
+ - Avoid trivial or redundant descriptions with labels. They must be really useful.
+ - Write in active voice using present tense
+ - Start conditional statements with the condition
+ - Use standardized field labels (e.g., "Adresse email" not "Adresse de courrier électronique")
+ - Specify document requirements clearly (format, validity, original vs copy)
+
+ 3. Form Structure:
+ - Place essential fields first, following user-centric logic
+ - Remove any fields asking for information already known to the administration. This is absolutely crucial.
+ - Minimize the number of required documents
+ - Add header sections to structure the fields with appropriate level if necessary (level starts at 1)
+ - Apply visibility conditions to dynamically show/hide a field based on another field's exact choice or value. When a field is hidden by its visibility condition, its mandatory rule will be ignored
+ - Use checkboxes for consent fields
+ - Consider information automatically retrieved by certain field types (e.g., address, communes) to avoid redundant questions. This is your main goal.
+
+ 4. Mandatory Fields:
+ - By default, all input fields are considered mandatory (mandatory = true)
+ - Explicitly set mandatory = false for optional fields
+
+ Please analyze the form schema and provide recommendations for improvements. Follow these steps:
+
+ 1. Analyze the overall form structure.
+ 2. For each field in the form:
+ a. Determine carefully if the field should be deleted, updated, or kept as is
+ b. Delete if:
+ - Identifed as redundant with another field
+ - Data is already known by the administration (e.g., email, first name of user, postal code when there's an address field)
+ - the field should be part of a `repetition` structure instead
+ c. Update if:
+ - the label or description is unclear or inappropriate
+ - the label is in uppercase : update field with a proper case
+ - field's visibility should be conditioned by the value of a previous field
+ - field type is not appropriate
+ - Ensure compliance with guidelines
+ 3. Identify where header sections could be added to improve the form structure.
+ 4. Review official recommendations for French administrative forms and note any potential compliance issues
+
+ Structure your analysis in a tags.
+ It's OK for this section to be quite long.
+ PROMPT
+ end
+
+ def restructure_prompt
+ <<~PROMPT
+ Based on your previous analysis, structure ALL fields in a JSON format following this schema:
+ %s
+
+ CRITICAL RULES for field processing:
+ 1. You MUST include ALL original fields in your response, distributed between:
+ - "destroy" category
+ - "update" category
+
+ 2. When a field is added to a repetition structure:
+ - You MUST destroy ALL original standalone versions
+ - This deletion MUST be documented in the "destroy" category
+ - Use justification: "Remplacé par un bloc répétable dynamique"
+
+ 3. For each field in "update" category:
+ - If modifications needed: specify only the changing attributes
+ - Update label with a proper case
+ - If no modifications needed: skip justification
+ - But ALWAYS list the field
+
+ Remember previous analysis guidelines:
+ - Keep relevant repetition structures identified
+ - Preserve planned improvements to descriptions
+ - Follow French administrative standards
+ - Keep identified redundancy removals
+ - Answer labels, descriptions, summary, justification in french.
+
+ Output requirements:
+ - Return ONLY raw JSON. No markdown, no code fences, no commentary.
+ - Do not wrap the JSON in ```json ... ``` blocks.
+
+ Response structure:
+ {
+ "add": [
+ - ALL added fields
+ ],
+ "destroy": [
+ - ALL destroyed fields, including all those moved to repetition
+ - Clear justification for each deletion
+ ],
+ "update": [
+ - ALL remaining fields, including unchanged ones
+ - For modified fields: only changed attributes and a justification
+ ],
+ "summary": {
+ - Brief French summary of implemented changes
+ - Focus on structural improvements
+ - MUST be a string, not an object
+ }
+ }
+ PROMPT
+ end
+
+ def backup(part, content)
+ ts = now || clock.call
+ path = Rails.root.join('tmp/llm', "procedure_#{procedure.id}_#{ts}_#{part}.txt")
+ FileUtils.mkdir_p(path.dirname)
+ File.write(path, content.to_s)
+ end
+ end
+end
diff --git a/app/services/llm/runner.rb b/app/services/llm/runner.rb
new file mode 100644
index 00000000000..ed03a63098e
--- /dev/null
+++ b/app/services/llm/runner.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'json'
+require 'uri'
+
+module LLM
+ class Runner
+ DEFAULT_TIMEOUT = 30
+
+ def initialize(client: nil, model: ENV['LLM_MODEL_NAME'], timeout: DEFAULT_TIMEOUT, logger: Rails.logger)
+ @client = client || (defined?(::LLM::OpenAIClient) ? ::LLM::OpenAIClient.instance : nil)
+ @model = model.presence
+ @timeout = timeout.to_i > 0 ? timeout.to_i : DEFAULT_TIMEOUT
+ @logger = logger
+ end
+
+ # Returns an array of tool call events.
+ # Each event is a Hash: { name:, arguments: Hash }
+ def call(messages:, tools: [])
+ params = {
+ messages: messages,
+ tools: tools,
+ tool_choice: 'auto',
+ temperature: 0,
+ }
+ params[:model] = @model if @model
+
+ response = @client.chat(params)
+ raw = response.respond_to?(:raw_response) ? response.raw_response : response
+ msg = raw.dig('choices', 0, 'message') || {}
+ raw_calls = msg['tool_calls'] || []
+ raw_calls.map do |tc|
+ fn = tc['function'] || {}
+ {
+ name: fn['name'],
+ arguments: parse_args(fn['arguments']),
+ model: @model,
+ }
+ end
+ rescue => e
+ @logger.warn("[LLM::Runner] request failed: #{e.class}: #{e.message}")
+ []
+ end
+
+ private
+
+ def parse_args(str)
+ return {} if str.blank?
+ JSON.parse(str)
+ rescue JSON::ParserError
+ # Attempt to salvage simple key/value pairs; fallback to empty
+ {}
+ end
+ end
+end
diff --git a/app/services/llm/structure_improver.rb b/app/services/llm/structure_improver.rb
new file mode 100644
index 00000000000..172342322e9
--- /dev/null
+++ b/app/services/llm/structure_improver.rb
@@ -0,0 +1,153 @@
+# frozen_string_literal: true
+
+module LLM
+ class StructureImprover < BaseImprover
+ TOOL_NAME = 'improve_structure'
+
+ TOOL_DEFINITION = {
+ type: 'function',
+ function: {
+ name: TOOL_NAME,
+ description: 'Propose une amélioration de la structure du formulaire.',
+ parameters: {
+ type: 'object',
+ properties: {
+ update: {
+ type: 'object',
+ description: 'Mise à jour d’un champ existant.',
+ properties: {
+ stable_id: { type: 'integer', description: 'Identifiant stable du champ à modifier.' },
+ position: { type: ['integer'], description: 'Nouvelle position du champ à modifier.' },
+ mandatory: { type: ['boolean'] },
+ },
+ required: %w[stable_id],
+ },
+ add: {
+ type: 'object',
+ description: 'Ajout d’une nouvelle section.',
+ properties: {
+ after_stable_id: { type: ['integer', 'null'], description: 'Identifiant stable du champ après lequel le champ à ajouter doit être positionné. Utiliser null si le champ à ajouter doit être positionné en premier.' },
+ libelle: { type: 'string', description: 'Libellé de la section (<= 80 chars, plain language)' },
+ description: { type: 'string', description: 'Explication de la section' },
+ },
+ required: %w[after_stable_id libelle],
+ },
+ justification: { type: 'string' },
+ confidence: { type: 'number', minimum: 0, maximum: 1 },
+ },
+ additionalProperties: false,
+ },
+ },
+ }.freeze
+
+ CLASS_SUMMARY = <<~TEXT.squish.freeze
+ Cette règle propose des améliorations non destructives à la structure du formulaire :
+ - Place essential fields first, following user-centric logic
+ - Add sections to structure the fields with appropriate level if necessary (level starts at 1)
+ - Organize fields within sections for better flow
+ TEXT
+
+ class << self
+ def summary = CLASS_SUMMARY
+ def name = 'Amélioration de la structure'
+ def key = 'structure_improve'
+ end
+
+ private
+
+ def system_prompt
+ <<~TXT
+ Tu es un assistant chargé d’améliorer la structure d’un formulaire administratif français.
+ Tu peux ajouter des sections, réordonner des champs ou ajuster leurs propriétés (mandatory).
+ TXT
+ end
+
+ def schema_prompt
+ <<~TXT
+ Voici le schéma des champs (publics) du formulaire en JSON. Chaque entrée contient un identifiant unique (stable_id), un type, un libellé, la position, le caractère obligatoire (mandatory), et éventuellement une description.
+
+ Les type de champ possibles sont :
+ - header_section : pour structurer le formulaire en sections (aucune saisie attendue, uniquement un libelle et optinnelement un description).
+ - repetition : pour des blocs répétables de champs enfants ; l’usager peut répéter le bloc autant de fois qu’il le souhaite.
+ - explication : pour fournir du contexte ou des consignes (aucune saisie attendue).
+ - civilite : pour choisir « Madame » ou « Monsieur » ; l’administration connaît déjà cette information.
+ - email : pour les adresses électroniques ; l’administration connaît déjà l’email de l’usager.
+ - phone : pour les numéros de téléphone.
+ - address : pour les adresses postales (auto-complétées avec commune, codes postaux, département, etc.).
+ - communes : pour sélectionner des communes françaises (auto-complétées avec code, code postal, département, etc.).
+ - departments : pour sélectionner des départements français.
+ - text : pour des champs texte courts.
+ - textarea : pour des champs texte longs.
+ - integer_number : pour des nombres entiers.
+ - decimal_number : pour des nombres décimaux.
+ - date : pour sélectionner une date.
+ - piece_justificative : pour téléverser des pièces justificatives (inutile de l’enfermer dans une répétition : plusieurs fichiers sont déjà possibles).
+ - titre_identite : pour téléverser un titre d’identité de manière sécurisée.
+ - checkbox : pour une case à cocher unique.
+ - yes_no : pour une question à réponse « oui »/« non ».
+ - drop_down_list : pour un choix unique dans une liste déroulante (options configurées ailleurs par l’administration).
+ - multiple_drop_down_list : pour un choix multiple dans une liste déroulante (options configurées ailleurs par l’administration).
+
+ Ce qui délimite les sections, c'est la position des champs "header_section" suivis des champs qui les suivent jusqu'à la prochaine "header_section".
+ L'ordre des champs dans le schema est déterminé par leur position.
+
+
+ %s
+
+ TXT
+ end
+
+ def rules_prompt
+ <<~TXT
+ Règles :
+ - Positionne les champs essentiels en premier
+ - Positionne les champs dans un ordre logique pour l’usager
+ - Utilise `add` pour créer une nouvelle section afin de structurer le formulaire et les champs.
+ - Quand tu ajoutes un champ, positionne-le en utilisant `after_stable_id` pour repositionner un champ ou une section en fonction du stable_id du champ précédent (null pour premier).
+ - Utilise `update` pour repositionner et la propriété position d'un champ existant
+ - Utilise `position` pour repositionner un champ existant
+ - Utilise `mandatory` pour rendre un champ obligatoire ou non
+ - Fournis toujours une justification courte et une confiance 0..1.
+ TXT
+ end
+
+ def build_item(args)
+ if args['add']
+ build_add_item(args)
+ elsif args['update']
+ build_update_item(args)
+ end
+ end
+
+ def build_add_item(args)
+ data = args['add'].is_a?(Hash) ? args['add'].dup : {}
+ payload = data.compact
+ payload['after_stable_id'] = payload['after_stable_id'].to_i
+ payload['type_champ'] = 'header_section'
+
+ {
+ op_kind: 'add',
+ stable_id: nil,
+ payload: payload,
+ safety: 'review',
+ justification: args['justification'].to_s.presence,
+ confidence: args['confidence'],
+ }
+ end
+
+ def build_update_item(args)
+ data = args['update'].is_a?(Hash) ? args['update'].dup : {}
+ stable_id = data['stable_id']
+ payload = data.compact
+
+ {
+ op_kind: 'update',
+ stable_id: stable_id,
+ payload: payload,
+ safety: 'review',
+ justification: args['justification'].to_s.presence,
+ confidence: args['confidence'],
+ }
+ end
+ end
+end
diff --git a/app/views/administrateurs/procedures/champs.html.haml b/app/views/administrateurs/procedures/champs.html.haml
index c65d288805d..2a6ba2980e9 100644
--- a/app/views/administrateurs/procedures/champs.html.haml
+++ b/app/views/administrateurs/procedures/champs.html.haml
@@ -69,6 +69,8 @@
- if @procedure.draft_revision.revision_types_de_champ_public.count > 0
%li
= link_to t('preview', scope: [:layouts, :breadcrumb]), apercu_admin_procedure_path(@procedure), target: "_blank", rel: "noopener", class: 'fr-link fr-mb-2w'
+ %li
+ = link_to "Simplifie 🪄", simplify_index_admin_procedure_types_de_champ_path(@procedure), method: :get, class: 'fr-btn fr-btn--secondary fr-mb-2w fr-ml-2w'
.fr-ml-auto
#autosave-notice.hidden
= render TypesDeChampEditor::EstimatedFillDurationComponent.new(revision: @procedure.draft_revision, is_annotation: false)
diff --git a/app/views/administrateurs/procedures/show.html.haml b/app/views/administrateurs/procedures/show.html.haml
index 9e0091d0179..3d442464f6c 100644
--- a/app/views/administrateurs/procedures/show.html.haml
+++ b/app/views/administrateurs/procedures/show.html.haml
@@ -94,6 +94,7 @@
= render Procedure::Card::PresentationComponent.new(procedure: @procedure)
= render Procedure::Card::ZonesComponent.new(procedure: @procedure) if Rails.application.config.ds_zonage_enabled
= render Procedure::Card::ChampsComponent.new(procedure: @procedure)
+ = render Procedure::Card::AiComponent.new(procedure: @procedure)
= render Procedure::Card::IneligibiliteDossierComponent.new(procedure: @procedure)
= render Procedure::Card::ServiceComponent.new(procedure: @procedure, administrateur: current_administrateur)
= render Procedure::Card::AdministrateursComponent.new(procedure: @procedure)
diff --git a/app/views/administrateurs/referentiels/autocomplete_configuration.html.haml b/app/views/administrateurs/referentiels/autocomplete_configuration.html.haml
index aa268ca792a..3a10f6deab4 100644
--- a/app/views/administrateurs/referentiels/autocomplete_configuration.html.haml
+++ b/app/views/administrateurs/referentiels/autocomplete_configuration.html.haml
@@ -1 +1,7 @@
-= render Referentiels::StepperComponent.new(referentiel: @referentiel, type_de_champ: @type_de_champ, procedure: @procedure, step_component: Referentiels::AutocompleteConfigurationComponent)
+= render Referentiels::StepperComponent.new(
+ step_component: Referentiels::AutocompleteConfigurationComponent.new(
+ referentiel: @referentiel,
+ type_de_champ: @type_de_champ,
+ procedure: @procedure
+ )
+)
diff --git a/app/views/administrateurs/referentiels/configuration_error.html.haml b/app/views/administrateurs/referentiels/configuration_error.html.haml
index 6d5f58bda5e..32323d509fa 100644
--- a/app/views/administrateurs/referentiels/configuration_error.html.haml
+++ b/app/views/administrateurs/referentiels/configuration_error.html.haml
@@ -1 +1,7 @@
-= render Referentiels::StepperComponent.new(referentiel: @referentiel, type_de_champ: @type_de_champ, procedure: @procedure, step_component: Referentiels::ConfigurationErrorComponent)
+= render Referentiels::StepperComponent.new(
+ step_component: Referentiels::ConfigurationErrorComponent.new(
+ referentiel: @referentiel,
+ type_de_champ: @type_de_champ,
+ procedure: @procedure
+ )
+)
diff --git a/app/views/administrateurs/referentiels/mapping_type_de_champ.html.haml b/app/views/administrateurs/referentiels/mapping_type_de_champ.html.haml
index 11a209f37ba..d7cd89a01f3 100644
--- a/app/views/administrateurs/referentiels/mapping_type_de_champ.html.haml
+++ b/app/views/administrateurs/referentiels/mapping_type_de_champ.html.haml
@@ -1 +1,7 @@
-= render Referentiels::StepperComponent.new(referentiel: @referentiel, type_de_champ: @type_de_champ, procedure: @procedure, step_component: Referentiels::MappingFormComponent)
+= render Referentiels::StepperComponent.new(
+ step_component: Referentiels::MappingFormComponent.new(
+ referentiel: @referentiel,
+ type_de_champ: @type_de_champ,
+ procedure: @procedure
+ )
+)
diff --git a/app/views/administrateurs/referentiels/new.html.erb b/app/views/administrateurs/referentiels/new.html.erb
new file mode 100644
index 00000000000..eeaee2ada91
--- /dev/null
+++ b/app/views/administrateurs/referentiels/new.html.erb
@@ -0,0 +1,7 @@
+<%= render Referentiels::StepperComponent.new(
+ step_component: Referentiels::NewFormComponent.new(
+ referentiel: @referentiel,
+ procedure: @procedure,
+ type_de_champ: @type_de_champ
+ )
+) %>
diff --git a/app/views/administrateurs/referentiels/new.html.haml b/app/views/administrateurs/referentiels/new.html.haml
deleted file mode 100644
index 18d00885f27..00000000000
--- a/app/views/administrateurs/referentiels/new.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render Referentiels::StepperComponent.new(referentiel: @referentiel, type_de_champ: @type_de_champ, procedure: @procedure, step_component: Referentiels::NewFormComponent)
diff --git a/app/views/administrateurs/referentiels/prefill_and_display.html.haml b/app/views/administrateurs/referentiels/prefill_and_display.html.haml
index 09a43b09313..53a5755536c 100644
--- a/app/views/administrateurs/referentiels/prefill_and_display.html.haml
+++ b/app/views/administrateurs/referentiels/prefill_and_display.html.haml
@@ -1 +1,7 @@
-= render Referentiels::StepperComponent.new(referentiel: @referentiel, type_de_champ: @type_de_champ, procedure: @procedure, step_component: Referentiels::PrefillAndDisplayComponent)
+= render Referentiels::StepperComponent.new(
+ step_component: Referentiels::PrefillAndDisplayComponent.new(
+ referentiel: @referentiel,
+ type_de_champ: @type_de_champ,
+ procedure: @procedure
+ )
+)
diff --git a/app/views/administrateurs/types_de_champ/simplify.html.haml b/app/views/administrateurs/types_de_champ/simplify.html.haml
new file mode 100644
index 00000000000..39083923879
--- /dev/null
+++ b/app/views/administrateurs/types_de_champ/simplify.html.haml
@@ -0,0 +1,10 @@
+- content_for :title, "Amélioration de la qualité du formulaire « #{@procedure.libelle} »"
+
+= render partial: 'administrateurs/breadcrumbs',
+ locals: { steps: [['Démarches', admin_procedures_path],
+ [@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)],
+ ['Amélioration des champs']] }
+
+= render LLM::StepperComponent.new(step_component: LLM::SuggestionFormComponent.new(llm_rule_suggestion: @llm_rule_suggestion)) do |stepper|
+ - stepper.with_header do
+ = render LLM::HeaderComponent.new(llm_rule_suggestion: @llm_rule_suggestion)
diff --git a/config/env.example.optional b/config/env.example.optional
index cdc82844237..109311ce907 100644
--- a/config/env.example.optional
+++ b/config/env.example.optional
@@ -311,6 +311,7 @@ MAINTENANCE_INSTRUCTEUR_EMAIL=""
# want to stay on delayed job ? set as 'delayed_job'
RAILS_QUEUE_ADAPTER="
+
# RDV Service Public
RDV_SERVICE_PUBLIC_OAUTH_APP_ID=""
RDV_SERVICE_PUBLIC_OAUTH_APP_SECRET=""
@@ -333,3 +334,8 @@ CRISP_CLIENT_KEY=""
CRISP_WEBSITE_ID=""
CRISP_WEBHOOK_SECRET=""
CRISP_INBOX_ID_DEV="" # inbox id hosting dev/technical conversations
+# LLM configuration, with Ollama or OpenAI API interface.
+# For Anthropic, only LLM_API_KEY is used.
+LLM_URI_BASE="" # http://localhost:11434 for ollama
+LLM_MODEL_NAME=""
+LLM_API_KEY=""
diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb
index 9367826b141..c540d18232b 100644
--- a/config/initializers/flipper.rb
+++ b/config/initializers/flipper.rb
@@ -36,6 +36,7 @@ def setup_features(features)
:referentiel_type_de_champ,
:sva,
:switch_domain,
+ :llm_nightly_improve_procedure,
]
def database_exists?
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
index 76f0748460f..a649a87c342 100644
--- a/config/initializers/inflections.rb
+++ b/config/initializers/inflections.rb
@@ -15,6 +15,8 @@
inflect.acronym 'ASN1'
inflect.acronym 'IP'
inflect.acronym 'JSON'
+ inflect.acronym 'LLM'
+ inflect.acronym 'OpenAI'
inflect.acronym 'RNA'
inflect.acronym 'RNF'
inflect.acronym 'URL'
diff --git a/config/llm/prompts.yml b/config/llm/prompts.yml
new file mode 100644
index 00000000000..b4f5fe9a2e8
--- /dev/null
+++ b/config/llm/prompts.yml
@@ -0,0 +1,109 @@
+revision_improver:
+ champs_descriptor: |
+ ## Caractéristiques d'un champ
+ - un type, qui détermine l'ergonomie de saisie présentée à l'usager, et dans certains cas remontera par API des informations complémentaires, comme le code commune ou le numéro de département pour le champ adresse.
+ - un libellé, obligatoire, idéalement de moins de 80 caractères
+ - une description, optionnelle, qui est un complément d'informations au libellé
+ - un attribut qui matérialise une saisie obligatoire ou non
+ - une condition d'affichage, optionnelle, dépendant de valeurs saisies préalablement par l'usager. Si cette condition est vraie le champ sera affiché, sinon il sera masqué, même s'il est obligatoire.
+ - certains types de champs proposent d'autres options listées plus loin.
+
+ En amont du dossier, l'usager aura saisi son prénom et son nom, ou son numéro SIRET, si la démarche est à destination d'une personne physique ou morale.
+
+ ## Types de champ
+
+ Par défaut tous les champs de saisie sont considérés comme obligatoires (mandatory = true)
+
+ ### Titre de section (type = header_section)
+ Un titre ou sous-titre pour organiser des sections de formulaire, sans valeur à saisir.
+ En option, l'attribut `level` de 1 à 3 permet de créer sous-titres. La numérotation des titres est automatique si aucun titre de la démarche ne commence par un numéro.
+ Les attributs description et mandatory ne sont pas disponible.
+
+ ### Bloc répétable (type = repetition)
+ Structure contenant un ou plusieurs champs permettant à l'usager de répéter autant de fois qu'il le veut l'ensemble des champs de cette structure.
+ Exemple: si l'usager doit remplir les prénoms et âge de ses enfants,
+ le formulaire peut être organisé avec un bloc répétable intitulé "Enfants à charge" non obligatoire.
+ A l'intérieur de ce bloc répétable, les champs `text` et `integer` permettent de saisir le prénom et âge de chacun enfant. L'usager pourra en ajouter autant que nécessaire.
+
+ ### Explication (type = explication)
+ Un texte libre fourni par l'administration qui vise à contextualiser certaines informations demandées à l'usager. L'usager ne saisira pas d'information.
+ Seul le libellé et la description sont possibles.
+
+ ### Civilité (type = civilite)
+ Représenté par un bouton radio avec lex choix "Madame" et "Monsieur".
+
+ ### Adresse électronique (type = email)
+ Représenté en html comme un input de type text, avec validation de format d'un email
+
+ ### Téléphone (type = phone)
+ Accepte un numéro de téléphone valide.
+
+ ### Adresse (type = address)
+ Champ de saisie d'adresse postale, représenté par un combobox avec autocompletion par la Base d'Adresses Nationales.
+ Les informations complémentaires sont remontées à l'administration :
+ - numéro de voie, nom de voie, code postal, nom de la commune, code INSEE de la commune, nom de département, numéro de département, nom de la région, numéro de la région
+
+ ### Commune (type = communes)
+ Champ de saisie de de commune, représenté par un combobox avec autocompletion parmi les communes françaises.
+ Les informations complémentaires sont remontées à l'administration :
+ - code INSEE de la commune, nom de département, numéro de département, nom de la région, numéro de la région
+
+ ### Département (type = departments)
+ Liste déroulante des départements français.
+ Les informations complémentaires sont remontées à l'administration :
+ - nom de département, numéro de département, nom de la région, numéro de la région
+
+ ### Région (type = regions)
+ Liste déroulante des régions françaises.
+ Les informations complémentaires sont remontées à l'administration :
+ - nom de la région, numéro de la région
+
+ ### Pays (type = pays)
+ Liste déroulante des pays reconnus par l'ONU.
+
+ ### Numéro IBAN (type = iban)
+ Champ de saisie qui formatte et valide un IBAN.
+
+ ### Numéro SIRET (type = siret)
+ Champ de saisie qui formatte et valide un numéro SIRET.
+ Les informations complémentaires sont remontées à l'administration :
+ - raison sociale, date de début d'activité, adresse de l'établissement (ainsi que tous les champs complémentaires mentionnés pour le champ Adresse), code naf, libellé naf, code effectif
+
+ ### Texte court (type = text)
+ Représenté en html comme un input de type text.
+
+ ### Texte long (type = textarea)
+ Représenté en html comme un textarea.
+
+ ### Nombre décimal (type = decimal_number)
+ Champ de saisie adapté aux nombres qui valide un nombre décimal
+
+ ### Nombre entier (type = integer_number)
+ Champ de saisie adapté aux nombres qui valide un nombre entier
+
+ ### Date (type = date)
+ Représenté sous forme de datepicker.
+
+ ### Date et heure (type = datetime)
+ Représenté sous forme de datepicker avec choix d'heure possible.
+
+ ### Pièce justificative (type = piece_justificative)
+ Permet à l'usager d'envoyer d'1 à 10 documents, dans la limite de 200 Mo par document. Tous les formats sont acceptés.
+
+ ### Titre d'identité (type = titre_identite)
+ Permet à l'usager d'envoyer un fichier PDF ou une image représentant un titre d'identité. Ce document sera hébergé de manière sécurisée.
+
+ ### Case à cocher seule (type = checkbox)
+ Représenté sous forme de checkbox.
+
+ ### Choix simple (type = drop_down_list)
+ Représenté sous forme de liste déroulante ou de combobox avec autocomplétion suivant le nombre de propositions.
+ L'agent doit définir au moins 2 choix dans cette liste dans sous l'attribut `options`
+ L'agent peut permettre à l'usager de renseigner un choix libre, si le champ est configuré avec `other` = true.
+
+ ### Choix multiple (type = multiple_drop_down_list)
+ Représenté sous forme de chechbox ou ou de combobox avec choix multiples suivant le nombre de propositions.
+ L'agent doit définir au moins 2 choix dans cette liste dans sous l'attribut `options`
+
+ ### Oui/Non (type = yes_no)
+ Représenté sous forme de bouton radio avec les choix "Oui" et "Non"
diff --git a/config/llm/revision_improver_json_schema.json b/config/llm/revision_improver_json_schema.json
new file mode 100644
index 00000000000..39fb06387da
--- /dev/null
+++ b/config/llm/revision_improver_json_schema.json
@@ -0,0 +1,104 @@
+{
+ "type": "object",
+ "properties": {
+ "summary": {
+ "type": "string",
+ "description": "Summary of important modifications suggested, shown to agent."
+ },
+ "champs": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "header_section",
+ "repetition",
+ "explication",
+ "civilite",
+ "email",
+ "phone",
+ "address",
+ "communes",
+ "departments",
+ "regions",
+ "pays",
+ "iban",
+ "siret",
+ "text",
+ "textarea",
+ "decimal_number",
+ "integer_number",
+ "date",
+ "datetime",
+ "piece_justificative",
+ "titre_identite",
+ "checkbox",
+ "drop_down_list",
+ "multiple_drop_down_list",
+ "yes_no"
+ ]
+ },
+ "libelle": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "mandatory": {
+ "type": "boolean",
+ "default": true
+ },
+ "level": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 3,
+ "description": "Only for header_section type"
+ },
+ "options": {
+ "type": "array",
+ "minItems": 2,
+ "items": {
+ "type": "string"
+ }
+ },
+ "other": {
+ "type": "boolean",
+ "description": "Only for drop_down_list type"
+ }
+ },
+ "required": ["type", "libelle"],
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "type": { "enum": ["header_section", "explication"] }
+ }
+ },
+ "then": {
+ "not": { "required": ["mandatory"] }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "type": {
+ "enum": ["drop_down_list", "multiple_drop_down_list"]
+ }
+ }
+ },
+ "then": {
+ "required": ["options"]
+ }
+ }
+ ],
+ "additionalProperties": false
+ },
+ "minItems": 1,
+ "description": "List of champs improved."
+ }
+ },
+ "required": ["summary", "revision"],
+ "additionalProperties": false
+}
diff --git a/config/llm/revision_improver_operations_json_schema.json b/config/llm/revision_improver_operations_json_schema.json
new file mode 100644
index 00000000000..7d2ca9ba61e
--- /dev/null
+++ b/config/llm/revision_improver_operations_json_schema.json
@@ -0,0 +1,118 @@
+{
+ "type": "object",
+ "properties": {
+ "operations": {
+ "type": "object",
+ "properties": {
+ "add": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "after_stable_id": { "type": "integer" },
+ "type_champ": { "type": "string" },
+ "libelle": { "type": "string" },
+ "description": { "type": "string" },
+ "mandatory": { "type": "boolean" },
+ "justification": {
+ "type": "string",
+ "description": "1 sentence explaining why this field should be added"
+ },
+ "children": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type_champ": { "type": "string" },
+ "libelle": { "type": "string" },
+ "mandatory": { "type": "boolean" }
+ },
+ "required": ["type_champ", "libelle"]
+ }
+ }
+ },
+ "required": ["after_stable_id", "type_champ", "libelle"]
+ }
+ },
+ "destroy": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "stable_id": {
+ "type": "integer",
+ "description": "stable id of field to remove"
+ },
+ "justification": {
+ "type": "string",
+ "description": "Short sentence explaining why this field should be deleted"
+ }
+ }
+ }
+ },
+ "update": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "stable_id": { "type": "integer" },
+ "type_champ": { "type": "string" },
+ "libelle": { "type": "string" },
+ "description": { "type": "string" },
+ "mandatory": { "type": "boolean" },
+ "justification": {
+ "type": "string",
+ "description": "Short sentence explaining why this field should be updated"
+ },
+ "display_condition": {
+ "type": "object",
+ "description": "Conditional logic to show/hide this field",
+ "properties": {
+ "term": {
+ "type": "string",
+ "enum": ["Logic::Eq"],
+ "description": "Equality comparison operator"
+ },
+ "left": {
+ "type": "object",
+ "description": "Left operand - always refers to a field value",
+ "properties": {
+ "term": {
+ "type": "string",
+ "enum": ["Logic::ChampValue"]
+ },
+ "stable_id": {
+ "type": "integer",
+ "description": "ID of the field to compare"
+ }
+ }
+ },
+ "right": {
+ "type": "object",
+ "description": "Right operand - contains the comparison value",
+ "properties": {
+ "term": {
+ "type": "string",
+ "enum": ["Logic::Constant"]
+ },
+ "value": {
+ "type": ["string", "number", "array"],
+ "description": "Value to compare against"
+ }
+ }
+ }
+ }
+ }
+ },
+ "required": ["stable_id"]
+ }
+ }
+ }
+ },
+ "summary": {
+ "type": "string",
+ "description": "List of important modifications suggested and why, use bullets points"
+ }
+ },
+ "required": ["summary", "operations"]
+}
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 90a05e1ad8f..cabdab729a5 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -852,6 +852,10 @@ fr:
long_datetime: "%A %-d %B %Y à %H:%M"
short_date: "%d/%m/%Y"
short_datetime: "%d/%m/%Y %H:%M"
+ llm_stepper_last_refresh: "%-d %B %Y %Hh%M"
+ llm:
+ stepper_component:
+ last_refresh: "Dernière recherche d'améliorations effectuée le %{timestamp}"
datetime:
formats:
long: "%d %B %y %H:%M"
diff --git a/config/routes.rb b/config/routes.rb
index aa3b3d9a298..4a96b8641d7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -762,6 +762,13 @@
put :notice_explicative
delete :nullify_referentiel
end
+
+ collection do
+ get 'simplify', action: :simplify_index, as: :simplify_index
+ post 'simplify/enqueue', action: :enqueue_simplify, as: :enqueue_simplify
+ get 'simplify/:llm_suggestion_rule_id', action: :simplify, as: :simplify
+ post 'accept_simplification/:llm_suggestion_rule_id', action: :accept_simplification, as: :accept_simplification
+ end
end
resources :mail_templates, only: [:index] do
diff --git a/db/migrate/20250909000100_create_llm_rule_suggestions.rb b/db/migrate/20250909000100_create_llm_rule_suggestions.rb
new file mode 100644
index 00000000000..676ec62e9d3
--- /dev/null
+++ b/db/migrate/20250909000100_create_llm_rule_suggestions.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class CreateLLMRuleSuggestions < ActiveRecord::Migration[7.1]
+ def change
+ create_table :llm_rule_suggestions do |t|
+ t.references :procedure_revision, null: false, foreign_key: { to_table: :procedure_revisions }
+ t.string :schema_hash, null: false
+ t.string :state, null: false, default: 'queued' # queued|running|completed|failed
+ t.jsonb :token_usage
+ t.string :rule, null: false
+
+ t.text :error
+ t.timestamps
+ end
+
+ add_index :llm_rule_suggestions, [:procedure_revision_id, :schema_hash], name: 'index_llm_rule_suggestions_on_revision_and_hash'
+ end
+end
diff --git a/db/migrate/20250909000200_create_llm_rule_suggestion_items.rb b/db/migrate/20250909000200_create_llm_rule_suggestion_items.rb
new file mode 100644
index 00000000000..f1dde8be89a
--- /dev/null
+++ b/db/migrate/20250909000200_create_llm_rule_suggestion_items.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class CreateLLMRuleSuggestionItems < ActiveRecord::Migration[7.1]
+ def change
+ create_table :llm_rule_suggestion_items do |t|
+ t.references :llm_rule_suggestion, null: false, foreign_key: true, index: { name: 'index_items_on_llm_rule_suggestion_id' }
+
+ t.string :model
+
+ t.bigint :stable_id
+ t.string :op_kind, null: true
+ t.jsonb :payload, null: false, default: {}
+
+ t.string :safety, null: false, default: 'safe'
+ t.string :verify_status, null: false, default: 'pending'
+ t.text :justification
+ t.float :confidence
+ t.datetime :applied_at
+
+ t.timestamps
+ end
+
+ add_index :llm_rule_suggestion_items, :stable_id
+ add_index :llm_rule_suggestion_items, :op_kind
+ add_index :llm_rule_suggestion_items, :payload, using: :gin
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1f1ef47c9e1..a183f9d531e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -898,6 +898,38 @@
t.index ["procedure_id"], name: "index_labels_on_procedure_id"
end
+ create_table "llm_rule_suggestion_items", force: :cascade do |t|
+ t.datetime "applied_at"
+ t.float "confidence"
+ t.datetime "created_at", null: false
+ t.text "justification"
+ t.bigint "llm_rule_suggestion_id", null: false
+ t.string "model"
+ t.string "op_kind"
+ t.jsonb "payload", default: {}, null: false
+ t.string "safety", default: "safe", null: false
+ t.bigint "stable_id"
+ t.datetime "updated_at", null: false
+ t.string "verify_status", default: "pending", null: false
+ t.index ["llm_rule_suggestion_id"], name: "index_items_on_llm_rule_suggestion_id"
+ t.index ["op_kind"], name: "index_llm_rule_suggestion_items_on_op_kind"
+ t.index ["payload"], name: "index_llm_rule_suggestion_items_on_payload", using: :gin
+ t.index ["stable_id"], name: "index_llm_rule_suggestion_items_on_stable_id"
+ end
+
+ create_table "llm_rule_suggestions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.text "error"
+ t.bigint "procedure_revision_id", null: false
+ t.string "rule", null: false
+ t.string "schema_hash", null: false
+ t.string "state", default: "queued", null: false
+ t.jsonb "token_usage"
+ t.datetime "updated_at", null: false
+ t.index ["procedure_revision_id", "schema_hash"], name: "index_llm_rule_suggestions_on_revision_and_hash"
+ t.index ["procedure_revision_id"], name: "index_llm_rule_suggestions_on_procedure_revision_id"
+ end
+
create_table "maintenance_tasks_runs", force: :cascade do |t|
t.text "arguments"
t.text "backtrace"
@@ -1466,6 +1498,8 @@
add_foreign_key "instructeurs_procedures", "instructeurs"
add_foreign_key "instructeurs_procedures", "procedures"
add_foreign_key "labels", "procedures"
+ add_foreign_key "llm_rule_suggestion_items", "llm_rule_suggestions"
+ add_foreign_key "llm_rule_suggestions", "procedure_revisions"
add_foreign_key "merge_logs", "users"
add_foreign_key "procedure_paths", "procedures"
add_foreign_key "procedure_presentations", "assign_tos"
diff --git a/lib/tasks/llm.rake b/lib/tasks/llm.rake
new file mode 100644
index 00000000000..a1901e0a6a9
--- /dev/null
+++ b/lib/tasks/llm.rake
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+namespace :llm do
+ desc 'Suggest an improved revision of a procedure'
+ task :improve_procedure, [:procedure_id, :analysis] => :environment do |_t, args|
+ procedure_id = args[:procedure_id]
+ procedure = Procedure.includes(published_revision: :revision_types_de_champ_public).find(procedure_id)
+
+ llm = LLM::RevisionImproverService.new(procedure)
+ if args[:analysis].present?
+ llm.now = args[:analysis].to_i
+ analysis = File.read("tmp/llm/procedure_#{procedure_id}_#{args[:analysis]}_analysis.txt")
+ llm.insert_analysis(analysis)
+ else
+ llm.analyze
+ end
+
+ completion = llm.suggest
+
+ # Écrit le fichier JSON
+ path = "tmp/llm/procedure_#{procedure_id}_#{llm.now}_suggestions.json"
+ File.write(
+ path,
+ JSON.pretty_generate(completion)
+ )
+
+ puts "Operations saved to #{path}"
+ end
+
+ task :lint_procedure, [:procedure_id] => :environment do |_t, args|
+ procedure_id = args[:procedure_id]
+ procedure = Procedure.includes(published_revision: { revision_types_de_champ_public: :type_de_champ })
+ .find(procedure_id)
+
+ procedure_linter = ProcedureLinter.new(procedure, procedure.published_revision)
+
+ PP.pp "#{procedure_id}: rate: #{procedure_linter.rate}, error score: #{procedure_linter.score}"
+ PP.pp procedure_linter.details
+ end
+end
diff --git a/spec/components/llm/stepper_component_spec.rb b/spec/components/llm/stepper_component_spec.rb
new file mode 100644
index 00000000000..83f5b90aa46
--- /dev/null
+++ b/spec/components/llm/stepper_component_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+RSpec.describe LLM::StepperComponent, type: :component do
+ let(:procedure) { create(:procedure) }
+ let(:last_refresh) { Time.zone.local(2025, 10, 13, 14, 40) }
+ let(:rule) { LLM::LabelImprover::TOOL_NAME }
+ let(:llm_rule_suggestion) do
+ create(:llm_rule_suggestion,
+ procedure_revision: procedure.draft_revision,
+ rule: rule,
+ schema_hash: 'schema-hash',
+ state: 'completed').tap { _1.update!(created_at: last_refresh) }
+ end
+ let(:step_component) { LLM::SuggestionFormComponent.new(llm_rule_suggestion:) }
+ subject(:rendered_component) { render_inline(described_class.new(step_component:)) }
+
+ context 'with the label improvement rule' do
+ let(:rule) { LLM::LabelImprover::TOOL_NAME }
+
+ it 'shows the first step and the correct next step' do
+ expect(rendered_component.css('.fr-stepper__state').text).to eq('Étape 1 sur 4')
+ expect(rendered_component.css('.fr-stepper__title').text).to include(LLM::ImproveLabelItemComponent.step_title)
+ expect(rendered_component.css('.fr-stepper__details').text).to include(LLM::ImproveStructureItemComponent.step_title)
+ expect(rendered_component).to have_link('Annuler et revenir à l\'écran de gestion', href: Rails.application.routes.url_helpers.admin_procedure_path(procedure))
+ end
+
+ it 'shows the last refresh timestamp below the title' do
+ expect(rendered_component.text)
+ .to include("Dernière recherche d'améliorations effectuée le 13 octobre 2025 14h40")
+ end
+ end
+
+ context 'with the structure rule' do
+ let(:rule) { LLM::StructureImprover::TOOL_NAME }
+
+ it 'marks the second step and shows no further step' do
+ expect(rendered_component.css('.fr-stepper__state').text).to eq('Étape 2 sur 4')
+ expect(rendered_component.css('.fr-stepper__title').text).to include(LLM::ImproveStructureItemComponent.step_title)
+ expect(rendered_component.css('.fr-stepper__details').text).to include("À venir...")
+ expect(rendered_component).to have_link('Annuler et revenir à l\'écran de gestion', href: Rails.application.routes.url_helpers.admin_procedure_path(procedure))
+ end
+ end
+end
diff --git a/spec/components/llm/suggestion_form_component_spec.rb b/spec/components/llm/suggestion_form_component_spec.rb
new file mode 100644
index 00000000000..4155969e435
--- /dev/null
+++ b/spec/components/llm/suggestion_form_component_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+RSpec.describe LLM::SuggestionFormComponent, type: :component do
+ let(:procedure) do
+ create(:procedure, types_de_champ_public: [{ type: :text, libelle: 'Nom' }])
+ end
+ let(:revision_coordinate) { procedure.draft_revision.revision_types_de_champ_public.first }
+ let(:stable_id) { revision_coordinate.stable_id }
+ let(:rule) { LLM::LabelImprover::TOOL_NAME }
+ let(:llm_rule_suggestion) do
+ create(:llm_rule_suggestion,
+ procedure_revision: procedure.draft_revision,
+ rule: rule,
+ schema_hash: 'schema-hash',
+ state: 'completed')
+ end
+
+ before do
+ create(:llm_rule_suggestion_item,
+ llm_rule_suggestion: llm_rule_suggestion,
+ stable_id: stable_id,
+ payload: { 'stable_id' => stable_id, 'libelle' => 'Nom simplifié' })
+ end
+
+ describe '#render' do
+ subject(:rendered) { render_inline(described_class.new(llm_rule_suggestion: llm_rule_suggestion)) }
+
+ it 'shows the configured title' do
+ expect(rendered.css('h2').text).to include(LLM::ImproveLabelItemComponent.step_title)
+ end
+
+ it 'renders the shared summary' do
+ expect(rendered.text).to include(LLM::ImproveLabelItemComponent.step_summary)
+ end
+ end
+end
diff --git a/spec/components/referentiels/stepper_component_spec.rb b/spec/components/referentiels/stepper_component_spec.rb
index 4c4b42eb205..fa2f76b5482 100644
--- a/spec/components/referentiels/stepper_component_spec.rb
+++ b/spec/components/referentiels/stepper_component_spec.rb
@@ -1,20 +1,27 @@
# frozen_string_literal: true
RSpec.describe Referentiels::StepperComponent, type: :component do
- let(:component) { described_class.new(referentiel:, type_de_champ:, procedure:, step_component: Referentiels::MappingFormComponent) }
let(:referentiel) { create(:api_referentiel, :exact_match) }
let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) }
let(:types_de_champ_public) { [] }
let(:types_de_champ_private) { [] }
+ let(:step_component) do
+ Referentiels::MappingFormComponent.new(
+ referentiel:,
+ type_de_champ:,
+ procedure:
+ )
+ end
- subject { render_inline(component) }
+ subject(:rendered_component) { render_inline(described_class.new(step_component:)) }
context 'when referentiel is private' do
let(:type_de_champ) { procedure.draft_revision.types_de_champ_private.first }
let(:types_de_champ_private) { [{ type: :referentiel, referentiel: }] }
it 'back links goes to annotations' do
- expect(subject).to have_link("Annotations privées", href: Rails.application.routes.url_helpers.annotations_admin_procedure_path(procedure))
+ expect(rendered_component)
+ .to have_link('Annotations privées', href: Rails.application.routes.url_helpers.annotations_admin_procedure_path(procedure))
end
end
@@ -23,7 +30,8 @@
let(:types_de_champ_public) { [{ type: :referentiel, referentiel: }] }
it 'back links goes to champs' do
- expect(subject).to have_link("Champs du formulaire", href: Rails.application.routes.url_helpers.champs_admin_procedure_path(procedure))
+ expect(rendered_component)
+ .to have_link('Champs du formulaire', href: Rails.application.routes.url_helpers.champs_admin_procedure_path(procedure))
end
end
end
diff --git a/spec/controllers/administrateurs/types_de_champ_controller_spec.rb b/spec/controllers/administrateurs/types_de_champ_controller_spec.rb
index fda2ebb80e2..e4314169ea7 100644
--- a/spec/controllers/administrateurs/types_de_champ_controller_spec.rb
+++ b/spec/controllers/administrateurs/types_de_champ_controller_spec.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require 'digest'
+
describe Administrateurs::TypesDeChampController, type: :controller do
let(:procedure) do
create(:procedure,
@@ -398,4 +400,126 @@ def morpheds
end
end
end
+
+ describe '#simplify' do
+ let(:procedure) { create(:procedure, types_de_champ_public:) }
+ let(:types_de_champ_public) { [{ type: :text, libelle: 'Ancien', stable_id: 123 }] }
+ let(:rule) { LLM::LabelImprover::TOOL_NAME }
+ let(:procedure_revision) { procedure.draft_revision }
+ let(:schema_hash) { Digest::SHA256.hexdigest(procedure_revision.schema_to_llm.to_json) }
+ let(:llm_rule_suggestion) { create(:llm_rule_suggestion, procedure_revision:, schema_hash:, state: 'completed', rule: rule) }
+
+ it 'assigns label suggestions from stored LLMRuleSuggestion items' do
+ llm_rule_suggestion_item = create(:llm_rule_suggestion_item,
+ llm_rule_suggestion:,
+ op_kind: 'update',
+ stable_id: 123,
+ payload: { 'stable_id' => 123, 'libelle' => 'Nouveau' },
+ safety: 'safe',
+ justification: 'clarity',
+ confidence: 0.9)
+
+ get :simplify, params: { procedure_id: procedure.id, llm_suggestion_rule_id: llm_rule_suggestion.id }
+
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:llm_rule_suggestion)).to eq(llm_rule_suggestion)
+ end
+
+ it 'redirects when suggestion is not completed' do
+ queued_suggestion = create(:llm_rule_suggestion, procedure_revision:, schema_hash:, state: 'queued', rule: rule)
+
+ get :simplify, params: { procedure_id: procedure.id, llm_suggestion_rule_id: queued_suggestion.id }
+
+ expect(response).to redirect_to(simplify_index_admin_procedure_types_de_champ_path(procedure))
+ expect(flash[:alert]).to eq('Suggestion non trouvée')
+ end
+
+ it 'redirect on unknown llm_suggestion' do
+ get :simplify, params: { procedure_id: procedure.id, llm_suggestion_rule_id: 'unknown_rule' }
+ expect(response).to redirect_to(simplify_index_admin_procedure_types_de_champ_path(procedure))
+ expect(flash[:alert]).to eq("Suggestion non trouvée")
+ end
+ end
+
+ describe '#enqueue_simplify' do
+ let(:procedure) { create(:procedure, :published) }
+ let(:schema_hash) { Digest::SHA256.hexdigest(procedure.draft_revision.schema_to_llm.to_json) }
+
+ before { Flipper.enable_actor(:llm_nightly_improve_procedure, procedure) }
+
+ it 'enqueues the improve procedure job when no analysis is running' do
+ expect(LLM::ImproveProcedureJob).to receive(:perform_now).with(procedure)
+ post :enqueue_simplify, params: { procedure_id: procedure.id }
+
+ expect(response).to redirect_to(simplify_index_admin_procedure_types_de_champ_path(procedure))
+ expect(flash[:notice]).to be_present
+ end
+
+ it 'does not enqueue when analysis already running' do
+ create(:llm_rule_suggestion, procedure_revision: procedure.draft_revision, rule: LLM::LabelImprover::TOOL_NAME, state: 'queued', schema_hash:)
+ expect(LLM::ImproveProcedureJob).not_to receive(:perform_now).with(procedure)
+
+ post :enqueue_simplify, params: { procedure_id: procedure.id }
+
+ expect(response).to redirect_to(simplify_index_admin_procedure_types_de_champ_path(procedure))
+ expect(flash[:alert]).to eq('Une analyse est déjà en cours pour cette version de la démarche.')
+ end
+
+ it 'does not enqueue when feature disabled' do
+ Flipper.disable_actor(:llm_nightly_improve_procedure, procedure)
+ expect(LLM::ImproveProcedureJob).not_to receive(:perform_now).with(procedure)
+
+ post :enqueue_simplify, params: { procedure_id: procedure.id }
+
+ expect(response).to redirect_to(simplify_index_admin_procedure_types_de_champ_path(procedure))
+ expect(flash[:alert]).to eq('Fonctionnalité indisponible pour cette démarche.')
+ end
+
+ it 're-enqueues after a failure' do
+ create(:llm_rule_suggestion, procedure_revision: procedure.draft_revision, rule: LLM::LabelImprover::TOOL_NAME, state: 'failed', schema_hash:)
+ expect(LLM::ImproveProcedureJob).to receive(:perform_now).with(procedure)
+
+ post :enqueue_simplify, params: { procedure_id: procedure.id }
+
+ expect(flash[:notice]).to be_present
+ end
+ end
+
+ describe '#accept_simplification' do
+ let(:procedure) { create(:procedure, :published, types_de_champ_public:) }
+ let(:types_de_champ_public) do
+ [
+ { type: :text, libelle: 'A', stable_id: 123 },
+ { type: :text, libelle: 'B' }
+ ]
+ end
+
+ it 'applies only selected operations from posted changes_json' do
+ suggestion = create(:llm_rule_suggestion, procedure_revision: procedure.draft_revision, rule: LLM::LabelImprover::TOOL_NAME, state: 'completed', schema_hash: Digest::SHA256.hexdigest(procedure.published_revision.schema_to_llm.to_json))
+ accepted_suggestion_item = create(:llm_rule_suggestion_item, llm_rule_suggestion: suggestion, op_kind: 'update', stable_id: 123, payload: { 'stable_id' => 123, 'libelle' => 'Nouveau' }, safety: 'safe', justification: 'clarity', confidence: 0.9)
+ skipped_suggestion_item = create(:llm_rule_suggestion_item, llm_rule_suggestion: suggestion, op_kind: 'update', stable_id: 123, payload: { 'stable_id' => 123, 'libelle' => 'Nouveau' }, safety: 'safe', justification: 'clarity', confidence: 0.9)
+
+ post :accept_simplification, params: {
+ procedure_id: procedure.id,
+ llm_suggestion_rule_id: suggestion.id,
+ llm_rule_suggestion: {
+ llm_rule_suggestion_items_attributes: {
+ "0" => { id: accepted_suggestion_item.id, verify_status: 'accepted' },
+ "1" => { id: skipped_suggestion_item.id, verify_status: 'skipped' }
+ }
+ }
+ }
+
+ expect(response).to redirect_to(simplify_index_admin_procedure_types_de_champ_path(procedure))
+
+ libelles = procedure.draft_revision.reload.types_de_champ_public.map(&:libelle)
+ expect(libelles).to include('Nouveau')
+ expect(libelles).not_to include('A')
+
+ expect(accepted_suggestion_item.reload.verify_status).to eq('accepted')
+ expect(accepted_suggestion_item.applied_at).not_to be_nil
+ expect(skipped_suggestion_item.reload.verify_status).to eq('skipped')
+ expect(skipped_suggestion_item.reload.applied_at).to be_nil
+ end
+ end
end
diff --git a/spec/factories/llm_rule_suggestion.rb b/spec/factories/llm_rule_suggestion.rb
new file mode 100644
index 00000000000..7a79dd54f86
--- /dev/null
+++ b/spec/factories/llm_rule_suggestion.rb
@@ -0,0 +1,8 @@
+FactoryBot.define do
+ factory :llm_rule_suggestion, class: LLMRuleSuggestion do
+ trait :queued do
+ state { 'queued' }
+ rule { LLM::LabelImprover::TOOL_NAME }
+ end
+ end
+end
diff --git a/spec/fixtures/llm/deepseek/deepseek-chat-v3.1.json b/spec/fixtures/llm/deepseek/deepseek-chat-v3.1.json
new file mode 100644
index 00000000000..db2d5c059ed
--- /dev/null
+++ b/spec/fixtures/llm/deepseek/deepseek-chat-v3.1.json
@@ -0,0 +1,285 @@
+{
+ "operations": {
+ "add": [
+ {
+ "after_stable_id": 4326479,
+ "type_champ": "repetition",
+ "libelle": "Enfant à rattacher",
+ "description": "Ajoutez chaque enfant mineur à rattacher. Pour les enfants déjà rattachés, vous n'avez pas à compléter ce formulaire.",
+ "mandatory": false,
+ "justification": "Création d'un bloc répétable structuré pour gérer plusieurs enfants",
+ "children": [
+ {
+ "type_champ": "text",
+ "libelle": "Nom de l'enfant",
+ "mandatory": true
+ },
+ {
+ "type_champ": "text",
+ "libelle": "Prénom(s) de l'enfant",
+ "mandatory": true
+ },
+ {
+ "type_champ": "date",
+ "libelle": "Date de naissance",
+ "mandatory": true
+ },
+ {
+ "type_champ": "text",
+ "libelle": "Numéro de sécurité sociale (13 caractères)",
+ "mandatory": false,
+ "description": "Si l'enfant possède déjà un numéro de sécurité sociale"
+ }
+ ]
+ }
+ ],
+ "destroy": [
+ {
+ "stable_id": 4328648,
+ "justification": "Remplacé par un bloc répétable dynamique"
+ },
+ {
+ "stable_id": 4328672,
+ "justification": "Remplacé par un bloc répétable dynamique"
+ },
+ {
+ "stable_id": 4328673,
+ "justification": "Remplacé par un bloc répétable dynamique"
+ },
+ {
+ "stable_id": 4341941,
+ "justification": "Intégré dans la description des pièces justificatives"
+ }
+ ],
+ "update": [
+ {
+ "stable_id": 4328679,
+ "type_champ": "drop_down_list",
+ "libelle": "Ma caisse MSA",
+ "mandatory": true,
+ "description": "Sélectionnez votre caisse MSA parmi la liste"
+ },
+ {
+ "stable_id": 4326069,
+ "type_champ": "header_section",
+ "libelle": "Parent demandant le rattachement",
+ "mandatory": false
+ },
+ {
+ "stable_id": 4328676,
+ "type_champ": "drop_down_list",
+ "libelle": "Lien de parenté",
+ "mandatory": true
+ },
+ {
+ "stable_id": 4327121,
+ "type_champ": "drop_down_list",
+ "libelle": "Type de rattachement souhaité",
+ "mandatory": true,
+ "description": "Sélectionnez le type de rattachement pour votre/vos enfant(s)"
+ },
+ {
+ "stable_id": 4338657,
+ "type_champ": "drop_down_list",
+ "libelle": "Rattachement à un deuxième parent ?",
+ "mandatory": true,
+ "display_condition": {
+ "term": "Logic::Eq",
+ "left": {
+ "term": "Logic::ChampValue",
+ "stable_id": 4327121
+ },
+ "right": {
+ "term": "Logic::Constant",
+ "value": "principal (vous recevrez les courriers de l'assurance maladie de votre (vos) enfant(s) et effectuer les démarches administrative le(s) concernant)"
+ }
+ },
+ "justification": "Ajout d'une condition d'affichage pour n'afficher ce champ que si le rattachement principal est sélectionné"
+ },
+ {
+ "stable_id": 4340866,
+ "type_champ": "header_section",
+ "libelle": "Deuxième parent demandant le rattachement",
+ "mandatory": false,
+ "display_condition": {
+ "term": "Logic::Eq",
+ "left": {
+ "term": "Logic::ChampValue",
+ "stable_id": 4338657
+ },
+ "right": {
+ "term": "Logic::Constant",
+ "value": "Oui"
+ }
+ },
+ "justification": "Ajout d'une condition d'affichage pour n'afficher cette section que si l'utilisateur souhaite ajouter un deuxième parent"
+ },
+ {
+ "stable_id": 4347011,
+ "type_champ": "explication",
+ "libelle": "Rattachement à titre secondaire",
+ "mandatory": false,
+ "description": "Le remboursement des soins pourra être effectué sur le compte de ce parent si c'est sa carte Vitale qui est présentée. En revanche, il ne sera pas destinataire des courriers de l'assurance maladie concernant l'enfant rattaché.",
+ "display_condition": {
+ "term": "Logic::Eq",
+ "left": {
+ "term": "Logic::ChampValue",
+ "stable_id": 4327121
+ },
+ "right": {
+ "term": "Logic::Constant",
+ "value": "secondaire"
+ }
+ },
+ "justification": "Ajout d'une condition d'affichage contextuelle"
+ },
+ {
+ "stable_id": 4347111,
+ "type_champ": "explication",
+ "libelle": "Rattachement à titre principal",
+ "mandatory": false,
+ "description": "Le remboursement des soins pourra être effectué sur le compte des deux parents selon la carte Vitale présentée. Ce parent sera destinataire des courriers de l'assurance maladie concernant l'enfant rattaché.",
+ "display_condition": {
+ "term": "Logic::Eq",
+ "left": {
+ "term": "Logic::ChampValue",
+ "stable_id": 4327121
+ },
+ "right": {
+ "term": "Logic::Constant",
+ "value": "principal (vous recevrez les courriers de l'assurance maladie de votre (vos) enfant(s) et effectuer les démarches administrative le(s) concernant)"
+ }
+ },
+ "justification": "Ajout d'une condition d'affichage contextuelle"
+ },
+ {
+ "stable_id": 4340865,
+ "type_champ": "text",
+ "libelle": "Nom du deuxième parent",
+ "mandatory": true,
+ "description": "Nom de famille (de naissance) suivi du nom d'usage (facultatif et s'il y a lieu)",
+ "display_condition": {
+ "term": "Logic::Eq",
+ "left": {
+ "term": "Logic::ChampValue",
+ "stable_id": 4338657
+ },
+ "right": {
+ "term": "Logic::Constant",
+ "value": "Oui"
+ }
+ },
+ "justification": "Ajout d'une condition d'affichage pour n'afficher ce champ que si l'utilisateur souhaite ajouter un deuxième parent"
+ },
+ {
+ "stable_id": 4341794,
+ "type_champ": "text",
+ "libelle": "Prénom(s) du deuxième parent",
+ "mandatory": true,
+ "display_condition": {
+ "term": "Logic::Eq",
+ "left": {
+ "term": "Logic::ChampValue",
+ "stable_id": 4338657
+ },
+ "right": {
+ "term": "Logic::Constant",
+ "value": "Oui"
+ }
+ },
+ "justification": "Ajout d'une condition d'affichage pour n'afficher ce champ que si l'utilisateur souhaite ajouter un deuxième parent"
+ },
+ {
+ "stable_id": 4341795,
+ "type_champ": "text",
+ "libelle": "Numéro de sécurité sociale du deuxième parent (13 caractères)",
+ "mandatory": true,
+ "description": "Le numéro de sécurité sociale comporte 13 caractères. Il est inscrit sur la carte vitale, l'attestation de droits maladie ou la carte de mutuelle.",
+ "display_condition": {
+ "term": "Logic::Eq",
+ "left": {
+ "term": "Logic::ChampValue",
+ "stable_id": 4338657
+ },
+ "right": {
+ "term": "Logic::Constant",
+ "value": "Oui"
+ }
+ },
+ "justification": "Ajout d'une condition d'affichage pour n'afficher ce champ que si l'utilisateur souhaite ajouter un deuxième parent"
+ },
+ {
+ "stable_id": 4341797,
+ "type_champ": "drop_down_list",
+ "libelle": "Caisse MSA du deuxième parent",
+ "mandatory": true,
+ "description": "Sélectionnez la caisse MSA du deuxième parent",
+ "display_condition": {
+ "term": "Logic::Eq",
+ "left": {
+ "term": "Logic::ChampValue",
+ "stable_id": 4338657
+ },
+ "right": {
+ "term": "Logic::Constant",
+ "value": "Oui"
+ }
+ },
+ "justification": "Ajout d'une condition d'affichage pour n'afficher ce champ que si l'utilisateur souhaite ajouter un deuxième parent"
+ },
+ {
+ "stable_id": 4346141,
+ "type_champ": "drop_down_list",
+ "libelle": "Lien de parenté du deuxième parent",
+ "mandatory": true,
+ "display_condition": {
+ "term": "Logic::Eq",
+ "left": {
+ "term": "Logic::ChampValue",
+ "stable_id": 4338657
+ },
+ "right": {
+ "term": "Logic::Constant",
+ "value": "Oui"
+ }
+ },
+ "justification": "Ajout d'une condition d'affichage pour n'afficher ce champ que si l'utilisateur souhaite ajouter un deuxième parent"
+ },
+ {
+ "stable_id": 4326477,
+ "type_champ": "header_section",
+ "libelle": "Enfants mineurs à rattacher",
+ "mandatory": false
+ },
+ {
+ "stable_id": 4326479,
+ "type_champ": "repetition",
+ "libelle": "Enfant à rattacher",
+ "mandatory": false,
+ "description": "Ajoutez chaque enfant mineur à rattacher. Pour les enfants déjà rattachés, vous n'avez pas à compléter ce formulaire."
+ },
+ {
+ "stable_id": 4341946,
+ "type_champ": "explication",
+ "libelle": "Pièces justificatives requises",
+ "mandatory": false,
+ "description": "Pour les enfants nés en France : copie du livret de famille à jour ou extrait d'acte de naissance. Pour les enfants nés à l'étranger : copie intégrale de l'acte de naissance avec filiation ET pièce d'identité de l'enfant. Les documents en langues étrangères doivent être accompagnés d'une traduction certifiée."
+ },
+ {
+ "stable_id": 4341860,
+ "type_champ": "header_section",
+ "libelle": "Engagement et signature",
+ "mandatory": false
+ },
+ {
+ "stable_id": 4341897,
+ "type_champ": "checkbox",
+ "libelle": "Je certifie l'exactitude des renseignements fournis",
+ "mandatory": true,
+ "description": "En cochant cette case, vous certifiez que toutes les informations fournies dans cette déclaration sont exactes et complètes.",
+ "justification": "Rendre ce champ obligatoire pour validation de la déclaration"
+ }
+ ]
+ },
+ "summary": "Optimisation du formulaire de rattachement d'enfants mineurs avec : création d'un bloc répétable pour gérer plusieurs enfants, ajout de logique conditionnelle pour afficher dynamiquement les sections du deuxième parent, amélioration des libellés et descriptions pour plus de clarté, suppression des redondances et intégration des informations dans des blocs structurés."
+}
\ No newline at end of file
diff --git a/spec/fixtures/llm_procedure_improvements_stub.txt b/spec/fixtures/llm_procedure_improvements_stub.txt
new file mode 100644
index 00000000000..9138231595e
--- /dev/null
+++ b/spec/fixtures/llm_procedure_improvements_stub.txt
@@ -0,0 +1,301 @@
+{
+ "operations":{
+ "add":[
+ {
+ "after_stable_id":335886,
+ "children":[
+ {
+ "libelle":"Nom de l'enfant",
+ "mandatory":true,
+ "type_champ":"text"
+ },
+ {
+ "libelle":"Prénom de l'enfant",
+ "mandatory":true,
+ "type_champ":"text"
+ },
+ {
+ "libelle":"Date de naissance de l'enfant",
+ "mandatory":true,
+ "type_champ":"date"
+ }
+ ],
+ "description":"",
+ "justification":"Regroupement des champs relatifs aux enfants dans une structure répétable.",
+ "libelle":"Enfant concerné",
+ "mandatory":true,
+ "type_champ":"repetition"
+ }
+ ],
+ "destroy":[
+ {
+ "justification":"Regroupement de deux champs similaires",
+ "stable_id":585372
+ },
+ {
+ "justification":"La civilité est déjà connue par l'administration.",
+ "stable_id":335897
+ },
+ {
+ "justification":"Le nom est déjà connu par l'administration.",
+ "stable_id":335874
+ },
+ {
+ "justification":"Le prénom est déjà connu par l'administration.",
+ "stable_id":335867
+ },
+ {
+ "justification":"L'adresse email est déjà connue par l'administration.",
+ "stable_id":335898
+ },
+ {
+ "justification":"NOM DE L'ENFANT CONCERNE a été placé dans un bloc répétable",
+ "stable_id":335900
+ },
+ {
+ "justification":"PRENOM DE L'ENFANT CONCERNE a été placé dans un bloc répétable",
+ "stable_id":335901
+ },
+ {
+ "justification":"DATE DE NAISSANCE DE L'ENFANT a été placé dans un bloc répétable",
+ "stable_id":335895
+ },
+ {
+ "justification":"NOM DE L'ENFANT CONCERNE 2 a été placé dans un bloc répétable",
+ "stable_id":335892
+ },
+ {
+ "justification":"PRÉNOM DE L'ENFANT CONCERNE 2 a été placé dans un bloc répétable",
+ "stable_id":335890
+ },
+ {
+ "justification":"DATE DE NAISSANCE DE L'ENFANT a été placé dans un bloc répétable",
+ "stable_id":335887
+ },
+ {
+ "justification":"NOM DE L'ENFANT CONCERNE 3 a été placé dans un bloc répétable",
+ "stable_id":335888
+ },
+ {
+ "stable_id":335876,
+ "justification":"Le code postal est inclus dans le champ d'adresse."
+ },
+ {
+ "stable_id":335866,
+ "justification":"La ville est incluse dans le champ d'adresse."
+ },
+ {
+ "justification":"PRÉNOM DE L'ENFANT CONCERNE 3 a été placé dans un bloc répétable",
+ "stable_id":335894
+ }
+ ],
+ "update":[
+ {
+ "description":"Conformément à la règlementation relative à la protection des données à caractère personnel, les informations recueillies par la direction de l’action sociale du personnel et de la qualité de vie au travail (DASPQVT) sont nécessaires afin d’instruire votre demande. La CTM est responsable de ce traitement qui relève d’une obligation légale. Les données sont conservées 24 mois. Sont destinataires de tout ou partie des données : en interne : la direction des finances, le cabinet du PCE. En externe : la paierie territoriale. Vous disposez d'un droit d’information, droit d'accès, droit de rectification, droit à la limitation des données qui vous concernent. Vous pouvez les exercer auprès du service instructeur ou à l’adresse pascoctm972@collectivitedemartinique.mq. La déléguée à la protection des données (DPO) pourra vous apporter des informations sur la protection des données à caractère personnel et sur l’exercice de ces droits à l'adresse dpo@collectivitedemartinique.mq. L’autorité de contrôle pour l’introduction des réclamations relatives est la Commission Nationale Informatique et Libertés.",
+ "justification":"Mise à jour du libellé en casse appropriée.",
+ "libelle":"Mentions RGPD",
+ "mandatory":false,
+ "stable_id":3573130,
+ "type_champ":"explication"
+ },
+ {
+ "stable_id":335868,
+ "type_champ":"address",
+ "justification":"Le champ d'adresse inclut automatiquement le code postal et la ville."
+ },
+ {
+ "description":"J'accepte que mes données personnelles soient utilisées de façon anonyme par la Direction de l'action sociale du personnel et de la qualité de vie au travail, dans la limite de deux ans, pour effectuer des statistiques permettant la promotion d'actions innovantes liées à l’accompagnement social du personnel.",
+ "justification":"Mise à jour du libellé en casse appropriée et changement du type de champ en checkbox.",
+ "libelle":"Consentement utilisation des données personnelles",
+ "mandatory":false,
+ "stable_id":3573135,
+ "type_champ":"checkbox"
+ },
+ {
+ "stable_id":335872,
+ "libelle":"Date de naissance",
+ "justification":"Mise à jour du libellé en casse appropriée."
+ },
+ {
+ "stable_id":335891,
+ "libelle":"Nom de naissance",
+ "justification":"Mise à jour du libellé en casse appropriée."
+ },
+ {
+ "stable_id":335875,
+ "libelle":"Lieu de naissance",
+ "justification":"Mise à jour du libellé en casse appropriée."
+ },
+ {
+ "stable_id":335870,
+ "libelle":"Catégorie d'emploi",
+ "justification":"Mise à jour du libellé en casse appropriée."
+ },
+ {
+ "stable_id":335873,
+ "libelle":"Situation familiale",
+ "justification":"Mise à jour du libellé en casse appropriée."
+ },
+ {
+ "stable_id":585336,
+ "libelle":"N° allocataire CAF",
+ "justification":"Mise à jour du libellé en casse appropriée."
+ },
+ {
+ "stable_id":335882,
+ "libelle":"Direction/service",
+ "justification":"Mise à jour du libellé en casse appropriée."
+ },
+ {
+ "description":"",
+ "justification":"Mise à jour du libellé en casse appropriée.",
+ "libelle":"Type de séjour",
+ "mandatory":false,
+ "stable_id":585367,
+ "type_champ":"multiple_drop_down_list"
+ },
+ {
+ "description":"Les champs concernant le conjoint doivent obligatoirement être remplis en cas de mariage, PACS ou vie maritale.",
+ "justification":"Mise à jour du libellé en casse appropriée.",
+ "libelle":"Attention",
+ "mandatory":false,
+ "stable_id":354937,
+ "type_champ":"explication"
+ },
+ {
+ "stable_id":335883,
+ "libelle":"Nom du conjoint",
+ "justification":"Mise à jour du libellé en casse appropriée."
+ },
+ {
+ "stable_id":335884,
+ "libelle":"Prénom du conjoint",
+ "justification":"Mise à jour du libellé en casse appropriée."
+ },
+ {
+ "stable_id":335878,
+ "libelle":"Date de naissance du conjoint",
+ "justification":"Mise à jour du libellé en casse appropriée."
+ },
+ {
+ "stable_id":335886,
+ "libelle":"Employeur du conjoint",
+ "justification":"Mise à jour du libellé en casse appropriée."
+ },
+ {
+ "stable_id":585748,
+ "libelle":"Nom de l'organisme ayant organisé le séjour",
+ "justification":"Mise à jour du libellé en casse appropriée."
+ },
+ {
+ "description":"",
+ "justification":"Mise à jour du libellé en casse appropriée.",
+ "libelle":"Pièces jointes",
+ "mandatory":false,
+ "stable_id":585261,
+ "type_champ":"header_section"
+ },
+ {
+ "description":"Vos documents doivent être lisibles et de bonne qualité. Les photos de documents ne sont pas exploitables. Privilégiez le format PDF.",
+ "justification":"Mise à jour du libellé en casse appropriée.",
+ "libelle":"Attention",
+ "mandatory":false,
+ "stable_id":718475,
+ "type_champ":"explication"
+ },
+ {
+ "description":"En Martinique (précisez la commune) Hors Martinique (précisez le pays)",
+ "justification":"Mise à jour du libellé en casse appropriée.",
+ "libelle":"Lieu du séjour",
+ "mandatory":true,
+ "stable_id":585752,
+ "type_champ":"text"
+ },
+ {
+ "description":"Je certifie sur l'honneur n'avoir pas perçu de prestation de même nature en faveur du (des) jeune (s) concerné (s) et que les renseignements portés sur la présente demande sont exacts. Je m'engage à signaler immédiatement tout fait nouveau modifiant la présente demande.",
+ "justification":"Mise à jour du libellé en casse appropriée et changement du type de champ en checkbox.",
+ "libelle":"Attestation sur l'honneur",
+ "mandatory":true,
+ "stable_id":585404,
+ "type_champ":"checkbox"
+ },
+ {
+ "stable_id":335883,
+ "display_condition":{
+ "term":"Logic::Eq",
+ "left":{
+ "term":"Logic::ChampValue",
+ "stable_id":335873
+ },
+ "right":{
+ "term":"Logic::Constant",
+ "value":[
+ "Marié(e)",
+ "PACS",
+ "Vie maritale"
+ ]
+ }
+ },
+ "justification":"Le champ ne doit être affiché que si l'utilisateur est marié, en PACS ou en vie maritale."
+ },
+ {
+ "stable_id":335884,
+ "display_condition":{
+ "term":"Logic::Eq",
+ "left":{
+ "term":"Logic::ChampValue",
+ "stable_id":335873
+ },
+ "right":{
+ "term":"Logic::Constant",
+ "value":[
+ "Marié(e)",
+ "PACS",
+ "Vie maritale"
+ ]
+ }
+ },
+ "justification":"Le champ ne doit être affiché que si l'utilisateur est marié, en PACS ou en vie maritale."
+ },
+ {
+ "stable_id":335878,
+ "display_condition":{
+ "term":"Logic::Eq",
+ "left":{
+ "term":"Logic::ChampValue",
+ "stable_id":335873
+ },
+ "right":{
+ "term":"Logic::Constant",
+ "value":[
+ "Marié(e)",
+ "PACS",
+ "Vie maritale"
+ ]
+ }
+ },
+ "justification":"Le champ ne doit être affiché que si l'utilisateur est marié, en PACS ou en vie maritale."
+ },
+ {
+ "stable_id":335886,
+ "display_condition":{
+ "term":"Logic::Eq",
+ "left":{
+ "term":"Logic::ChampValue",
+ "stable_id":335873
+ },
+ "right":{
+ "term":"Logic::Constant",
+ "value":[
+ "Marié(e)",
+ "PACS",
+ "Vie maritale"
+ ]
+ }
+ },
+ "justification":"Le champ ne doit être affiché que si l'utilisateur est marié, en PACS ou en vie maritale."
+ }
+ ]
+ },
+ "summary":"Suppression des champs redondants (civilité, nom, prénom, adresse email) car déjà connus par l'administration. Mise à jour des libellés en casse appropriée. Regroupement des champs relatifs aux enfants dans une structure répétable. Ajout de sections d'en-tête pour une meilleure organisation du formulaire. Application de conditions de visibilité pour les champs liés à la situation familiale."
+}
\ No newline at end of file
diff --git a/spec/jobs/cron/llm_enqueue_nightly_improve_procedure_job_spec.rb b/spec/jobs/cron/llm_enqueue_nightly_improve_procedure_job_spec.rb
new file mode 100644
index 00000000000..c69e5389b19
--- /dev/null
+++ b/spec/jobs/cron/llm_enqueue_nightly_improve_procedure_job_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+describe Cron::LLMEnqueueNightlyImproveProcedureJob, type: :job do
+ subject(:perform) { described_class.perform_now }
+
+ let!(:p1) { create(:procedure, :published) }
+ let!(:p2) { create(:procedure, :published) }
+
+ before { Flipper.enable_actor(:llm_nightly_improve_procedure, p1) }
+
+ it 'enqueues the dedicated job only for procedures with feature enabled' do
+ perform
+
+ expect(LLM::ImproveProcedureJob).to have_been_enqueued.with(p1)
+ expect(LLM::ImproveProcedureJob).not_to have_been_enqueued.with(p2)
+ end
+end
diff --git a/spec/jobs/llm/generate_rule_suggestion_job_spec.rb b/spec/jobs/llm/generate_rule_suggestion_job_spec.rb
new file mode 100644
index 00000000000..1663f85e34b
--- /dev/null
+++ b/spec/jobs/llm/generate_rule_suggestion_job_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe LLM::GenerateRuleSuggestionJob, type: :job do
+ let!(:procedure) { create(:procedure, :published) }
+ let(:suggestion) { create(:llm_rule_suggestion, :queued, procedure_revision: procedure.published_revision, schema_hash: 'cafebabe') }
+
+ subject { described_class.perform_now(suggestion) }
+
+ it 'transitions queued -> completed when the service succeeds' do
+ allow_any_instance_of(LLM::LabelImprover).to receive(:generate_for).and_return([])
+
+ expect { subject }.to change { suggestion.reload.state }.from('queued').to('completed')
+ end
+
+ it 'persists returned items and marks completed' do
+ items = [
+ { op_kind: 'update', stable_id: 1, payload: { 'stable_id' => 1, 'libelle' => 'Libellé 1' }, safety: 'safe' },
+ { op_kind: 'update', stable_id: 2, payload: { 'stable_id' => 2, 'libelle' => 'Libellé 2' }, safety: 'safe' }
+ ]
+ service = instance_double(LLM::LabelImprover, generate_for: items)
+ allow(LLM::LabelImprover).to receive(:new).and_return(service)
+
+ subject
+
+ expect(suggestion.reload.state).to eq('completed')
+ expect(suggestion.llm_rule_suggestion_items.count).to eq(2)
+ expect(suggestion.llm_rule_suggestion_items.order(:stable_id).pluck(:stable_id, :payload)).to eq([
+ [1, { 'stable_id' => 1, 'libelle' => 'Libellé 1' }],
+ [2, { 'stable_id' => 2, 'libelle' => 'Libellé 2' }]
+ ])
+ end
+
+ it 'marks the suggestion as failed when the service raises' do
+ service = instance_double(LLM::LabelImprover)
+ allow(LLM::LabelImprover).to receive(:new).and_return(service)
+ allow(service).to receive(:generate_for).and_raise(StandardError.new('Test error'))
+
+ expect { subject }.not_to raise_error
+ expect(suggestion.reload.state).to eq('failed')
+ end
+end
diff --git a/spec/jobs/llm/improve_procedure_job_spec.rb b/spec/jobs/llm/improve_procedure_job_spec.rb
new file mode 100644
index 00000000000..d5e252d2104
--- /dev/null
+++ b/spec/jobs/llm/improve_procedure_job_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+describe LLM::ImproveProcedureJob, type: :job do
+ subject(:perform) { described_class.perform_now(procedure) }
+
+ let(:procedure) { create(:procedure, :published) }
+
+ before { Flipper.enable_actor(:llm_nightly_improve_procedure, procedure) }
+
+ it 'creates suggestions and enqueues generation jobs for available rules on the draft revision' do
+ expect { perform }.to change { LLMRuleSuggestion.count }.by(2)
+
+ expect(LLM::GenerateRuleSuggestionJob).to have_been_enqueued.exactly(:twice)
+ expect(LLMRuleSuggestion.distinct.pluck(:procedure_revision_id)).to contain_exactly(procedure.draft_revision.id)
+ end
+
+ it 'does not duplicate suggestions when run twice' do
+ perform
+ clear_enqueued_jobs
+
+ expect { perform }.not_to change { LLMRuleSuggestion.count }
+ expect(LLM::GenerateRuleSuggestionJob).not_to have_been_enqueued
+ end
+
+ it 'requeues failed suggestions' do
+ perform
+ LLMRuleSuggestion.update_all(state: :failed)
+ clear_enqueued_jobs
+
+ described_class.perform_now(procedure)
+
+ expect(LLMRuleSuggestion.pluck(:state)).to all(eq('queued'))
+ expect(LLM::GenerateRuleSuggestionJob).to have_been_enqueued.exactly(:twice)
+ end
+end
diff --git a/spec/models/procedure_revision_spec.rb b/spec/models/procedure_revision_spec.rb
index 09b5fe9e82c..b205bb2063a 100644
--- a/spec/models/procedure_revision_spec.rb
+++ b/spec/models/procedure_revision_spec.rb
@@ -1276,4 +1276,36 @@ def second_champ = procedure.draft_revision.types_de_champ_public.second
it { expect(draft.simple_routable_types_de_champ.pluck(:libelle)).to eq(['l2', 'l3', 'l4', 'l5', 'l6']) }
end
+
+ describe "#apply_changes" do
+ let(:procedure) { create(:procedure, types_de_champ_public:) }
+ let(:revision) { procedure.draft_revision }
+ let(:schema_hash) { Digest::SHA256.hexdigest(revision.schema_to_llm.to_json) }
+
+ context 'from LLM::LabelImprover' do
+ let(:types_de_champ_public) { [{ type: :text, libelle: "B", stable_id: 2 }] }
+
+ it "can update libelle" do
+ llm_rule_suggestion = create(:llm_rule_suggestion, procedure_revision: revision, rule: LLM::LabelImprover::TOOL_NAME, schema_hash:)
+ create(:llm_rule_suggestion_item, llm_rule_suggestion:, verify_status: 'accepted',stable_id: 2, op_kind: 'update', payload: { 'stable_id' => 2, 'libelle' => 'B modifié' })
+
+ expect { revision.apply_changes(llm_rule_suggestion.changes_to_apply) }.not_to raise_error
+ libelles = revision.reload.types_de_champ_public.map(&:libelle)
+ expect(libelles).to include("B modifié")
+ end
+ end
+
+ context 'from LLM::StructureImprover' do
+ let(:types_de_champ_public) { [] }
+
+ it "can add header section" do
+ llm_rule_suggestion = create(:llm_rule_suggestion, procedure_revision: revision, rule: LLM::StructureImprover::TOOL_NAME, schema_hash:)
+ create(:llm_rule_suggestion_item, llm_rule_suggestion:, verify_status: 'accepted',stable_id: 2, op_kind: 'add', payload: { 'libelle' => 'Ajouté', type_champ: 'header_section' })
+
+ expect { revision.apply_changes(llm_rule_suggestion.changes_to_apply) }.not_to raise_error
+ libelles = revision.reload.types_de_champ_public.map(&:libelle)
+ expect(libelles).to include("Ajouté")
+ end
+ end
+ end
end
diff --git a/spec/services/llm/label_improver_spec.rb b/spec/services/llm/label_improver_spec.rb
new file mode 100644
index 00000000000..1ac3c22fd9e
--- /dev/null
+++ b/spec/services/llm/label_improver_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe LLM::LabelImprover do
+ let(:schema) do
+ [
+ { 'stable_id' => 1, 'type' => 'text', 'libelle' => 'LIBELLE 1' },
+ { 'stable_id' => 2, 'type' => 'text', 'libelle' => 'Ancien libellé' },
+ { 'stable_id' => 3, 'type' => 'text', 'libelle' => 'Titre' }
+ ]
+ end
+
+ let(:revision) { double('revision', schema_to_llm: schema) }
+ let(:suggestion) { double('suggestion', procedure_revision: revision) }
+
+ describe '#generate_for' do
+ it 'aggregates tool calls into normalized items (no dedup, ignore unrelated tools)' do
+ calls = [
+ { name: 'improve_label', arguments: { 'update' => { 'stable_id' => 1, 'libelle' => 'Libellé 1' }, 'justification' => 'clarity' } },
+ { name: 'improve_label', arguments: { 'update' => { 'stable_id' => 2, 'libelle' => 'Libellé amélioré' } } },
+ # unrelated tool must be ignored
+ { name: 'other_tool', arguments: { 'x' => 1 } }
+ ]
+
+ runner = -> (messages:, tools:) { calls }
+ service = described_class.new(runner: runner)
+
+ items = service.generate_for(suggestion)
+
+ expect(items.size).to eq(2)
+ payloads = items.map { |i| i[:payload] }
+ expect(payloads).to include({ 'stable_id' => 1, 'libelle' => 'Libellé 1' })
+ expect(payloads).to include({ 'stable_id' => 2, 'libelle' => 'Libellé amélioré' })
+
+ expect(items.first).to include(op_kind: 'update', safety: 'safe')
+ expect(items.find { |i| i[:stable_id] == 1 }[:justification]).to eq('clarity')
+ end
+ end
+end
diff --git a/spec/services/llm/revision_improver_service_spec.rb b/spec/services/llm/revision_improver_service_spec.rb
new file mode 100644
index 00000000000..fd47c11a082
--- /dev/null
+++ b/spec/services/llm/revision_improver_service_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe LLM::RevisionImproverService do
+ let(:procedure) { create(:simple_procedure) }
+ let(:llm) { double('llm', chat_parameters: double('params', update: nil)) }
+
+ before do
+ allow(LLM::OpenAIClient).to receive(:instance).and_return(llm)
+ allow(FileUtils).to receive(:mkdir_p)
+ allow(File).to receive(:write)
+ end
+
+ def stub_run_chat(result)
+ allow_any_instance_of(described_class).to receive(:run_chat).and_return(result)
+ end
+
+ it 'returns normalized operations when nested under operations' do
+ json = {
+ operations: { destroy: [{ stable_id: 1 }], update: [], add: [] },
+ summary: 'ok'
+ }.to_json
+ stub_run_chat(json)
+
+ result = described_class.new(procedure).suggest!
+ expect(result[:destroy]).to eq([{ stable_id: 1 }])
+ expect(result[:update]).to eq([])
+ expect(result[:add]).to eq([])
+ expect(result[:summary]).to eq('ok')
+ expect(File).to have_received(:write).at_least(:once)
+ end
+
+ it 'accepts flat keys and maps destroy to destroy' do
+ json = { destroy: [{ stable_id: 2 }], update: [], add: [], summary: 'ok' }.to_json
+ stub_run_chat(json)
+
+ result = described_class.new(procedure).suggest!
+ expect(result[:destroy]).to eq([{ stable_id: 2 }])
+ end
+
+ it 'parses fenced JSON code blocks' do
+ payload = {
+ operations: { destroy: [{ stable_id: 42 }], update: [], add: [] },
+ summary: 'ok'
+ }.to_json
+ stub_run_chat("```json\n#{payload}\n```")
+
+ result = described_class.new(procedure).suggest!
+ expect(result[:destroy]).to eq([{ stable_id: 42 }])
+ expect(result[:summary]).to eq('ok')
+ end
+
+ it 'raises InvalidOutput for non-JSON output' do
+ stub_run_chat('not-json')
+ service = described_class.new(procedure)
+ expect { service.suggest! }.to raise_error(LLM::RevisionImproverService::Errors::InvalidOutput)
+ end
+end
diff --git a/spec/services/llm/runner_spec.rb b/spec/services/llm/runner_spec.rb
new file mode 100644
index 00000000000..214b6949f30
--- /dev/null
+++ b/spec/services/llm/runner_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe LLM::Runner do
+ let(:messages) { [{ role: 'user', content: 'test' }] }
+ let(:tools) { [{ type: 'function', function: { name: 'improve_label', parameters: { type: 'object', properties: {} } } }] }
+
+ it 'parses tool_calls and returns normalized events' do
+ raw = {
+ 'choices' => [
+ {
+ 'message' => {
+ 'tool_calls' => [
+ { 'function' => { 'name' => 'improve_label', 'arguments' => '{"update":{"stable_id":1,"libelle":"Libellé"}}' } }
+ ]
+ }
+ }
+ ]
+ }
+ response = double('response', raw_response: raw)
+ client = double('client', chat: response)
+
+ events = described_class.new(client: client, model: 'openai/gpt-5').call(messages: messages, tools: tools)
+
+ expect(events.size).to eq(1)
+ expect(events.first).to include(name: 'improve_label')
+ expect(events.first[:arguments]).to eq({ 'update' => { 'stable_id' => 1, 'libelle' => 'Libellé' } })
+ end
+
+ it 'returns empty when no tool_calls are present' do
+ raw = { 'choices' => [{ 'message' => { 'tool_calls' => [] } }] }
+ response = double('response', raw_response: raw)
+ client = double('client', chat: response)
+
+ events = described_class.new(client: client).call(messages: messages, tools: tools)
+ expect(events).to eq([])
+ end
+
+ it 'handles malformed arguments gracefully' do
+ raw = {
+ 'model' => 'openai/gpt-5',
+ 'choices' => [
+ {
+ 'message' => {
+ 'tool_calls' => [
+ { 'function' => { 'name' => 'improve_label', 'arguments' => 'not json' } }
+ ]
+ }
+ }
+ ]
+ }
+ response = double('response', raw_response: raw)
+ client = double('client', chat: response)
+
+ events = described_class.new(client: client).call(messages: messages, tools: tools)
+ expect(events.first[:arguments]).to eq({})
+ end
+end
diff --git a/spec/services/llm/structure_improver_spec.rb b/spec/services/llm/structure_improver_spec.rb
new file mode 100644
index 00000000000..7ba194b78e4
--- /dev/null
+++ b/spec/services/llm/structure_improver_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe LLM::StructureImprover do
+ let(:schema) do
+ [
+ { 'stable_id' => 1, 'type' => 'header_section', 'libelle' => 'Informations générales' },
+ { 'stable_id' => 2, 'type' => 'text', 'libelle' => 'Nom' },
+ { 'stable_id' => 3, 'type' => 'text', 'libelle' => 'Adresse' }
+ ]
+ end
+
+ let(:revision) { double('revision', schema_to_llm: schema) }
+ let(:suggestion) { double('suggestion', procedure_revision: revision) }
+
+ describe '#generate_for' do
+ it 'normalises tool calls into structured items' do
+ calls = [
+ {
+ name: described_class::TOOL_NAME,
+ arguments: {
+ 'update' => { 'stable_id' => 2, 'position' => 3, 'mandatory' => false },
+ 'justification' => 'Regrouper sous la section',
+ 'confidence' => 0.8
+ }
+ },
+ {
+ name: described_class::TOOL_NAME,
+ arguments: {
+ 'add' => { 'libelle' => 'Nouvelle section', "after_stable_id" => 1 },
+ 'justification' => 'Clarifier le parcours',
+ 'confidence' => 0.6
+ }
+ },
+ { name: 'other_tool', arguments: { 'x' => 1 } }
+ ]
+
+ service = described_class.new(runner: ->(**) { calls })
+
+ items = service.generate_for(suggestion)
+
+ expect(items.size).to eq(2)
+ expect(items.first).to include(op_kind: 'update', stable_id: 2)
+ expect(items.first[:payload]).to include('position' => 3, 'mandatory' => false)
+ expect(items.second).to include(op_kind: 'add')
+ expect(items.second[:payload]).to include('libelle' => 'Nouvelle section', 'after_stable_id' => 1)
+ end
+ end
+end