//! `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, } #[derive(Debug, Deserialize)] struct InputParagraph { text: String, } #[derive(Debug, Deserialize)] struct InputGraph { citations: Vec, } #[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 { let norm: InputNorm = inputs .require_json("norm") .map_err(|e| ModuleError::invalid_input(format!("norm shape: {e}")))?; let graph_ivm: Option = 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::>() .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)); } } } }