Files
decision/crates/ld-engine/src/assist.rs
T
YvvandClaude Opus 5.5 67ddee28b2 v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- 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>
2026-10-21 02:04:42 +02:00

233 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 { .. })));
}
}