v3 : quatrième relecture — coquille en liste blanche, mesurée contre le texte voté

- Une coquille ne touche que des lettres latines, des espaces et la ponctuation
  ordinaire ; tout le reste est figé : chiffres de toute écriture, signes,
  relations, parenthèses, sosies (U+201A, U+2044, pleine chasse), marques
  superposées (U+0336, U+0338), autres alphabets, rayure Markdown, contenu entre
  accents graves, séparateurs et tirets collés à un chiffre.
- Adresses (domaines, liens, courriels) comparées à l'identique.
- Glissement de clavier au cœur du mot seulement (ni les deux premières ni les
  deux dernières lettres : préfixes, nombre, genre, temps, mode) ; mots de
  position ajoutés (dessus/dessous, avant/après…).
- Chaque correction est mesurée contre le texte voté (dernière révision initiale
  ou de fond) : les glissements ne s'additionnent plus ; le texte soumis d'un
  brouillon devient sa révision initiale.
- Déploiement : « previous » = l'image du conteneur en service, pas la dernière
  construite ; migrations inconnues tolérées pour qu'une image précédente démarre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 05:58:36 +02:00
co-authored by Claude Opus 5.5
parent 9203fd2501
commit 1eac6f856a
8 changed files with 236 additions and 206 deletions
+34 -1
View File
@@ -71,6 +71,18 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
Ok(())
}
/// The text the votes were cast on: its last initial or substantive revision.
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive')
ORDER BY id DESC LIMIT 1",
)
.bind(&p.id)
.fetch_optional(&state.db)
.await?;
Ok(row.unwrap_or_else(|| (p.title.clone(), p.body.clone())))
}
async fn record_revision(
state: &AppState,
proposal_id: &str,
@@ -302,7 +314,18 @@ pub async fn edit(
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("closed"));
}
let kind = edit::classify(&format!("{}\n{}", p.title, p.body), &format!("{title}\n{body}"));
// Measured against the text that was voted, not the current one: successive
// typo fixes are judged together and never add up to a change of meaning.
let next = format!("{title}\n{body}");
let kind = if next == format!("{}\n{}", p.title, p.body) {
EditKind::Unchanged
} else {
let (voted_title, voted_body) = voted_text(&state, &p).await?;
match edit::classify(&format!("{voted_title}\n{voted_body}"), &next) {
EditKind::Unchanged => EditKind::Typographic, // back to the voted text
k => k,
}
};
match kind {
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
EditKind::Typographic => {
@@ -379,6 +402,16 @@ pub async fn submit(
.bind(&id)
.execute(&state.db)
.await?;
// A draft is edited freely: the text submitted is the one votes will be cast on.
let initial = sqlx::query("UPDATE revisions SET title = ?, body = ? WHERE proposal_id = ? AND kind = 'initial'")
.bind(&p.title)
.bind(&p.body)
.bind(&id)
.execute(&state.db)
.await?;
if initial.rows_affected() == 0 {
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
}
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "status": "open", "entered": entered })))
}