Prep for the Forgejo CI gate. Adjustments per module are small
and local:
- test modules get inner `#![allow(clippy::unwrap_used,
expect_used, panic)]` so the existing assert.expect()
test idiom keeps working without rewriting every fixture
- the dead_code field that downstream consumers may still
want serialised gets an explicit #[allow(dead_code)]
- manual char/range comparisons fold to the idiomatic forms
(`['…']`, `(2..=5).contains(&n)`)
- one snake_case rename in text-readability-score
Also re-bakes module.wasm so the committed artefact matches
the post-fmt source byte-for-byte.
No behaviour change, no test change. cargo fmt --all -- --check
and cargo clippy --all-targets -- -D warnings now both pass.
Signed-off-by: flemming-it <sf@flemming.it>
172 lines
4.7 KiB
Rust
172 lines
4.7 KiB
Rust
//! `text.readability_score` — Flesch-DE + Verweistiefe + verb-
|
||
//! distance composite.
|
||
//!
|
||
//! The Flesch formula adapted by Amstad for German:
|
||
//! FRE_de = 180 − ASL − (58.5 × ASW)
|
||
//! where ASL = avg sentence length (words), ASW = avg syllables
|
||
//! per word. A FRE_de of ~30 reads like "schwer".
|
||
//!
|
||
//! depth_index is mean inbound citation distance from the
|
||
//! citation graph (if supplied). Composite frust scales both
|
||
//! to 0..10 and averages.
|
||
|
||
#![allow(clippy::result_large_err)]
|
||
|
||
use chain_module_sdk::prelude::*;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct InputNorm {
|
||
paragraphs: Vec<InputParagraph>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct InputParagraph {
|
||
text: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct InputGraph {
|
||
citations: Vec<InputCitation>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct InputCitation {
|
||
#[serde(rename = "type")]
|
||
kind: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct Output {
|
||
flesch_de: f32,
|
||
depth_index: f32,
|
||
composite_frust: f32,
|
||
}
|
||
|
||
#[fai_module]
|
||
pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
|
||
let norm: InputNorm = inputs
|
||
.require_json("norm")
|
||
.map_err(|e| ModuleError::invalid_input(format!("norm shape: {e}")))?;
|
||
|
||
let graph_ivm: Option<InputGraph> = inputs.get("graph").and_then(|p| match p {
|
||
Payload::Json(s) => serde_json::from_str(s).ok(),
|
||
_ => None,
|
||
});
|
||
|
||
let full_text: String = norm
|
||
.paragraphs
|
||
.iter()
|
||
.map(|p| p.text.as_str())
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
|
||
let flesch = flesch_de(&full_text);
|
||
let depth = depth_index(graph_ivm.as_ref());
|
||
let composite = composite_frust(flesch, depth);
|
||
|
||
let out = Output {
|
||
flesch_de: flesch,
|
||
depth_index: depth,
|
||
composite_frust: composite,
|
||
};
|
||
Outputs::new().with_json("score", &out)
|
||
}
|
||
|
||
fn flesch_de(text: &str) -> f32 {
|
||
let sentences = count_sentences(text);
|
||
let words: Vec<&str> = text
|
||
.split(|c: char| c.is_whitespace() || c == ',' || c == ';')
|
||
.filter(|w| !w.is_empty())
|
||
.collect();
|
||
if sentences == 0 || words.is_empty() {
|
||
return 0.0;
|
||
}
|
||
let asl = words.len() as f32 / sentences as f32;
|
||
let total_sylls: usize = words.iter().map(|w| count_syllables(w)).sum();
|
||
let asw = total_sylls as f32 / words.len() as f32;
|
||
(180.0 - asl - 58.5 * asw).clamp(0.0, 100.0)
|
||
}
|
||
|
||
fn count_sentences(text: &str) -> usize {
|
||
text.chars()
|
||
.filter(|c| matches!(c, '.' | '!' | '?'))
|
||
.count()
|
||
.max(1)
|
||
}
|
||
|
||
fn count_syllables(word: &str) -> usize {
|
||
let mut count = 0;
|
||
let mut in_vowel = false;
|
||
for c in word.chars() {
|
||
let v = matches!(
|
||
c.to_ascii_lowercase(),
|
||
'a' | 'e' | 'i' | 'o' | 'u' | 'y' | 'ä' | 'ö' | 'ü'
|
||
);
|
||
if v && !in_vowel {
|
||
count += 1;
|
||
}
|
||
in_vowel = v;
|
||
}
|
||
count.max(1)
|
||
}
|
||
|
||
fn depth_index(graph: Option<&InputGraph>) -> f32 {
|
||
let Some(g) = graph else { return 0.0 };
|
||
let n = g.citations.len() as f32;
|
||
if n == 0.0 {
|
||
0.0
|
||
} else {
|
||
// crude proxy: each i.V.m.-link counts double — that's where
|
||
// the reader has to chase a second norm.
|
||
let weighted: f32 = g
|
||
.citations
|
||
.iter()
|
||
.map(|c| if c.kind == "verweist_iVm" { 2.0 } else { 1.0 })
|
||
.sum();
|
||
(weighted / n).clamp(0.0, 10.0)
|
||
}
|
||
}
|
||
|
||
/// Combine 0..100 FRE (high = easy) and 0..10 depth (high = hard).
|
||
/// Map to a single 0..10 frust score (high = frustrating).
|
||
fn composite_frust(flesch: f32, depth: f32) -> f32 {
|
||
let frust_from_flesch = (100.0 - flesch) / 10.0;
|
||
((frust_from_flesch + depth) / 2.0).clamp(0.0, 10.0)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn easy_german_scores_high_flesch() {
|
||
let f = flesch_de("Das ist ein Test. Er ist kurz.");
|
||
assert!(f > 60.0, "expected easy text to score > 60, got {f}");
|
||
}
|
||
|
||
#[test]
|
||
fn long_compound_german_scores_low_flesch() {
|
||
let f = flesch_de(
|
||
"Die Datenschutzgrundverordnungsanpassungsdurchführungsverordnung \
|
||
betrifft sämtliche Verantwortliche im Geltungsbereich der Verordnung.",
|
||
);
|
||
assert!(f < 30.0, "expected dense text to score < 30, got {f}");
|
||
}
|
||
|
||
#[test]
|
||
fn depth_zero_without_graph() {
|
||
assert_eq!(depth_index(None), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn composite_bounded() {
|
||
for f in [0.0_f32, 50.0, 100.0] {
|
||
for d in [0.0_f32, 5.0, 10.0] {
|
||
let c = composite_frust(f, d);
|
||
assert!((0.0..=10.0).contains(&c));
|
||
}
|
||
}
|
||
}
|
||
}
|