forked from yvv/decision
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
8.0 KiB
Rust
233 lines
8.0 KiB
Rust
//! Rule-based editing assistant — no AI yet (Yvv, 2026-10-21: « D'abord des
|
||
//! règles simples, ensuite on mettra de l'IA, mais locale »).
|
||
//!
|
||
//! It never decides. It says, while the author writes:
|
||
//! - « this text seems to carry several decisions — split it? » with the parts;
|
||
//! - « this text is close to <unit> — make it a variant instead? »;
|
||
//! - « this text is long ».
|
||
//!
|
||
//! The local AI planned later plugs in behind the same `Hint` type.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use unicode_normalization::UnicodeNormalization;
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||
pub enum Hint {
|
||
/// Several decisions in one text: the suggested parts, in order.
|
||
Split { parts: Vec<String> },
|
||
/// Close to an existing unit or proposal.
|
||
Similar { target: String, score: f64 },
|
||
/// Longer than a unit usually is.
|
||
Long { chars: usize },
|
||
}
|
||
|
||
pub const LONG_UNIT_CHARS: usize = 700;
|
||
pub const SIMILARITY_HINT: f64 = 0.45;
|
||
|
||
/// Markers of a commitment or a rule, folded (lowercase, no accents).
|
||
const DECISION_MARKERS: &[&str] = &[
|
||
"je m'engage",
|
||
"je m engage",
|
||
"j'ai ",
|
||
"je suis ",
|
||
"je me suis",
|
||
"je verifie",
|
||
"je ne ",
|
||
"doit ",
|
||
"doivent ",
|
||
"s'engage",
|
||
"est tenu",
|
||
"est interdit",
|
||
"obligatoire",
|
||
"il est interdit",
|
||
"verifiez",
|
||
"ne certifiez",
|
||
"contactez",
|
||
"assurez",
|
||
"i commit",
|
||
"i have ",
|
||
"must ",
|
||
"shall ",
|
||
"me comprometo",
|
||
"debe ",
|
||
];
|
||
|
||
const STOPWORDS: &[&str] = &[
|
||
"le", "la", "les", "un", "une", "des", "de", "du", "d", "l", "et", "ou", "a", "au", "aux", "en", "que", "qui",
|
||
"je", "j", "me", "m", "se", "s", "ce", "cet", "cette", "ces", "par", "pour", "sur", "dans", "avec", "est", "sont",
|
||
"the", "of", "and", "to", "in", "is", "el", "los", "las", "y", "que", "con", "por", "para",
|
||
];
|
||
|
||
fn fold(s: &str) -> String {
|
||
s.nfd()
|
||
.filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
|
||
.flat_map(char::to_lowercase)
|
||
.map(|c| if c == '’' { '\'' } else { c })
|
||
.collect()
|
||
}
|
||
|
||
fn is_decision(segment: &str) -> bool {
|
||
let f = fold(segment);
|
||
DECISION_MARKERS.iter().any(|m| f.contains(m))
|
||
}
|
||
|
||
/// Paragraphs, list items: the natural cut lines of a modular text.
|
||
fn segments(text: &str) -> Vec<String> {
|
||
let mut out: Vec<String> = Vec::new();
|
||
let mut current = String::new();
|
||
let flush = |current: &mut String, out: &mut Vec<String>| {
|
||
let t = current.trim();
|
||
if !t.is_empty() {
|
||
out.push(t.to_owned());
|
||
}
|
||
current.clear();
|
||
};
|
||
for line in text.lines() {
|
||
let trimmed = line.trim_start();
|
||
let is_item = trimmed.starts_with("- ")
|
||
|| trimmed.starts_with("* ")
|
||
|| trimmed
|
||
.split_once(['.', ')'])
|
||
.is_some_and(|(n, _)| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()));
|
||
if trimmed.is_empty() || is_item {
|
||
flush(&mut current, &mut out);
|
||
}
|
||
if !trimmed.is_empty() {
|
||
if !current.is_empty() {
|
||
current.push('\n');
|
||
}
|
||
current.push_str(line);
|
||
}
|
||
}
|
||
flush(&mut current, &mut out);
|
||
out
|
||
}
|
||
|
||
/// Sentences of a single paragraph (split after . ! ? followed by a space).
|
||
fn sentences(paragraph: &str) -> Vec<String> {
|
||
let mut out = Vec::new();
|
||
let mut current = String::new();
|
||
let mut chars = paragraph.chars().peekable();
|
||
while let Some(c) = chars.next() {
|
||
current.push(c);
|
||
if matches!(c, '.' | '!' | '?') && chars.peek().is_some_and(|n| n.is_whitespace()) {
|
||
out.push(current.trim().to_owned());
|
||
current.clear();
|
||
}
|
||
}
|
||
if !current.trim().is_empty() {
|
||
out.push(current.trim().to_owned());
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The parts to propose separately, or `None` when the text holds one decision.
|
||
pub fn split_suggestion(text: &str) -> Option<Vec<String>> {
|
||
let segs = segments(text);
|
||
if segs.len() >= 2 && segs.iter().filter(|s| is_decision(s)).count() >= 2 {
|
||
return Some(segs);
|
||
}
|
||
if segs.len() == 1 {
|
||
let sents = sentences(&segs[0]);
|
||
let committing = sents.iter().filter(|s| is_decision(s)).count();
|
||
if sents.len() >= 2 && committing >= 2 {
|
||
return Some(sents);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn content_words(text: &str) -> Vec<String> {
|
||
fold(text)
|
||
.split(|c: char| !c.is_alphanumeric())
|
||
.filter(|w| w.chars().count() > 2 && !STOPWORDS.contains(w))
|
||
.map(str::to_owned)
|
||
.collect()
|
||
}
|
||
|
||
/// Jaccard similarity over content words and their bigrams.
|
||
pub fn similarity(a: &str, b: &str) -> f64 {
|
||
use std::collections::BTreeSet;
|
||
let grams = |t: &str| -> BTreeSet<String> {
|
||
let w = content_words(t);
|
||
let mut set: BTreeSet<String> = w.iter().cloned().collect();
|
||
set.extend(w.windows(2).map(|p| format!("{} {}", p[0], p[1])));
|
||
set
|
||
};
|
||
let (x, y) = (grams(a), grams(b));
|
||
if x.is_empty() || y.is_empty() {
|
||
return 0.0;
|
||
}
|
||
let inter = x.intersection(&y).count() as f64;
|
||
let union = x.union(&y).count() as f64;
|
||
inter / union
|
||
}
|
||
|
||
/// All hints for a draft, given the texts it may collide with (`(id, text)`).
|
||
pub fn hints(text: &str, others: &[(String, String)]) -> Vec<Hint> {
|
||
let mut out = Vec::new();
|
||
if let Some(parts) = split_suggestion(text) {
|
||
out.push(Hint::Split { parts });
|
||
}
|
||
let mut close: Vec<(String, f64)> =
|
||
others.iter().map(|(id, t)| (id.clone(), similarity(text, t))).filter(|(_, s)| *s >= SIMILARITY_HINT).collect();
|
||
close.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||
out.extend(close.into_iter().take(3).map(|(target, score)| Hint::Similar { target, score }));
|
||
let chars = text.chars().count();
|
||
if chars > LONG_UNIT_CHARS {
|
||
out.push(Hint::Long { chars });
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn two_commitments_in_one_text_are_flagged() {
|
||
let text = "Je m'engage sur l'honneur à n'avoir qu'un seul compte.\n\nJe m'engage à ne certifier que des personnes qui respectent cet engagement.";
|
||
let parts = split_suggestion(text).expect("two decisions");
|
||
assert_eq!(parts.len(), 2);
|
||
assert!(parts[0].starts_with("Je m'engage sur"));
|
||
}
|
||
|
||
#[test]
|
||
fn two_commitments_in_one_paragraph() {
|
||
let text = "Je m'engage à vérifier la clé. Je m'engage aussi à rester joignable.";
|
||
assert_eq!(split_suggestion(text).map(|p| p.len()), Some(2));
|
||
}
|
||
|
||
#[test]
|
||
fn a_list_of_rules_is_flagged_but_one_explained_commitment_is_not() {
|
||
let list = "- Vérifiez la clé avec la personne.\n- Contactez les certifieurs existants.";
|
||
assert_eq!(split_suggestion(list).map(|p| p.len()), Some(2));
|
||
let single = "Je m'engage sur l'honneur à n'avoir qu'un seul compte. C'est le socle de la toile.";
|
||
assert_eq!(split_suggestion(single), None);
|
||
}
|
||
|
||
#[test]
|
||
fn preamble_like_text_is_not_split() {
|
||
let text = "Le présent acte définit les obligations réciproques des membres. Cet acte est l'unique relation contractuelle de notre toile.";
|
||
assert_eq!(split_suggestion(text), None);
|
||
}
|
||
|
||
#[test]
|
||
fn similarity_finds_a_variant() {
|
||
let current = "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1.";
|
||
let variant = "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.";
|
||
let other = "J'ai vérifié avec la personne certifiée qu'elle a généré son document de révocation.";
|
||
assert!(similarity(current, variant) >= SIMILARITY_HINT);
|
||
assert!(similarity(current, other) < 0.2);
|
||
let h = hints(variant, &[("E1".into(), current.into()), ("E6".into(), other.into())]);
|
||
assert!(matches!(&h[..], [Hint::Similar { target, .. }] if target == "E1"));
|
||
}
|
||
|
||
#[test]
|
||
fn long_text() {
|
||
let text = "mot ".repeat(200);
|
||
assert!(hints(&text, &[]).iter().any(|h| matches!(h, Hint::Long { .. })));
|
||
}
|
||
}
|