Skip to main content

formal_ai/summarization/
mod.rs

1//! Formalize-summarize-deformalize pipeline for project descriptions, README
2//! prose, conversation summaries, and chat titles.
3//!
4//! The module is intentionally deterministic: every transformation is a pure
5//! function of its input plus the [`SummarizationConfig`]. No neural model
6//! or external API is consulted. The pipeline has three explicit stages:
7//!
8//! 1. **Formalize.** Free-form prose, Markdown README content, dialog turns,
9//!    or a curated list of [`crate::seed::ProjectStatement`]s is converted
10//!    into a homogeneous `Vec<Statement>`. Each statement is one sentence
11//!    with a coarse `kind` inferred from cue words (purpose, feature,
12//!    install, …) and a numeric `weight` (0–100) that says how important it
13//!    is.
14//! 2. **Summarize.** [`summarize`] applies the configured [`SummarizationMode`]
15//!    and `max_statements` limit. Compressing keeps the highest-weighted
16//!    statements; expanding *adds* paraphrases generated from the NSM
17//!    semantic-prime expansion below.
18//! 3. **Deformalize.** [`deformalize`] renders the surviving statements back
19//!    into a single block of text suitable for display.
20//!
21//! The `apply_semantic_primes` and `apply_compound_words` helpers implement
22//! the configurable "simplify with semantic primes / shorten with compound
23//! words" requirement from PR #174. Both are vocabulary-driven so they can be
24//! extended without touching call sites.
25//!
26//! Higher-level helpers chain the three stages together for the most common
27//! callers:
28//!
29//! - [`describe_project`] — curated GitHub project → language-aware
30//!   description.
31//! - [`describe_readme`] — Markdown README text → language-aware description
32//!   (badges, headings, and fenced code blocks are stripped before
33//!   formalization).
34//! - [`summarize_dialog`] — chat turns → short recap of the conversation.
35//! - [`generate_chat_title`] — chat turns → 1–5 word chat title.
36//! - [`summarize_repository_file`] — repository path + file content → file
37//!   metadata, optional meta-language evidence, embedded Markdown grammars, and
38//!   content summary.
39//! - [`summarize_repository_resource`] — a [`RepositoryEntry`] tree (a file
40//!   **or** a directory/folder of arbitrary depth) → a recursively composed
41//!   summary. Directories decompose into children, summarize each child
42//!   (recursing into subdirectories), and compose the child summaries behind an
43//!   aggregate identity sentence, bounding depth via the mode ladder. This is
44//!   the general entry point that subsumes [`summarize_repository_file`].
45//!
46//! See `ARCHITECTURE.md` § "Project lookups and summarization" for how
47//! `project_lookup` chains the three stages together.
48
49use crate::seed::{ProjectRecord, ProjectStatement};
50
51/// Default cap on the number of retained statements per summary.
52///
53/// Applied when the caller does not supply an explicit `max_statements`
54/// value. Mirrors the vision note in PR #174: "for example not more than
55/// 30 statements (it should be configurable also)". Set via
56/// [`SummarizationConfig::default`] callers that opt into the cap.
57pub const DEFAULT_MAX_STATEMENTS: usize = 30;
58
59/// Coarse classification used by the summarizer to decide which statements
60/// survive a compression pass. Mirrors the `kind "..."` field accepted by
61/// `data/seed/projects.lino`.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum StatementKind {
64    /// "X is Y" — the bare identity of the project / subject.
65    Identity,
66    /// Why the project exists / what problem it solves.
67    Purpose,
68    /// Programming language or runtime.
69    Language,
70    /// Star count or other social proof.
71    Stars,
72    /// A concrete capability the project offers.
73    Feature,
74    /// When the reader should reach for the project.
75    UseCase,
76    /// Installation / setup instructions.
77    Install,
78    /// Example invocation, code snippet, command-line usage.
79    Example,
80    /// Anything else (treated as low-weight by default).
81    #[default]
82    Misc,
83}
84
85impl StatementKind {
86    /// Parse a kind label from a seed `kind "..."` field. Unknown labels
87    /// fall back to [`StatementKind::Misc`] so the data file remains forward-
88    /// compatible with new kinds added in code.
89    #[must_use]
90    pub fn parse(value: &str) -> Self {
91        match value.trim().to_ascii_lowercase().as_str() {
92            "identity" => Self::Identity,
93            "purpose" => Self::Purpose,
94            "language" => Self::Language,
95            "stars" => Self::Stars,
96            "feature" => Self::Feature,
97            "use_case" | "usecase" | "use-case" => Self::UseCase,
98            "install" => Self::Install,
99            "example" => Self::Example,
100            _ => Self::Misc,
101        }
102    }
103
104    /// Map the slug of a `summary_classification_cue` meaning to its kind. The
105    /// seven `summary_kind_*` leaves in `data/seed/meanings-summary.lino` carry
106    /// the cue surfaces that [`classify_sentence`] scans; this resolves the
107    /// meaning that matched back into a [`StatementKind`]. Unknown slugs fall
108    /// back to [`StatementKind::Misc`] so the seed stays forward-compatible.
109    #[must_use]
110    pub fn from_slug(slug: &str) -> Self {
111        match slug {
112            "summary_kind_install" => Self::Install,
113            "summary_kind_example" => Self::Example,
114            "summary_kind_language" => Self::Language,
115            "summary_kind_stars" => Self::Stars,
116            "summary_kind_purpose" => Self::Purpose,
117            "summary_kind_use_case" => Self::UseCase,
118            "summary_kind_feature" => Self::Feature,
119            _ => Self::Misc,
120        }
121    }
122
123    /// `true` when the statement carries information that survives the
124    /// tightest "what is X?" responses (identity, purpose, language, stars).
125    #[must_use]
126    pub const fn is_essential(self) -> bool {
127        matches!(
128            self,
129            Self::Identity | Self::Purpose | Self::Language | Self::Stars
130        )
131    }
132
133    /// `true` when the statement is README boilerplate (install / example)
134    /// that should be omitted from compressed answers.
135    #[must_use]
136    pub const fn is_boilerplate(self) -> bool {
137        matches!(self, Self::Install | Self::Example)
138    }
139}
140
141/// A single normalized statement participating in the summarization pipeline.
142#[derive(Debug, Clone)]
143pub struct Statement {
144    pub text: String,
145    pub kind: StatementKind,
146    pub weight: u8,
147}
148
149impl Statement {
150    /// Build a statement from explicit fields.
151    #[must_use]
152    pub fn new(text: impl Into<String>, kind: StatementKind, weight: u8) -> Self {
153        Self {
154            text: text.into(),
155            kind,
156            weight,
157        }
158    }
159
160    /// Build a statement from a seed [`ProjectStatement`], inferring the
161    /// numeric kind from the seed's text label and clamping the weight.
162    #[must_use]
163    pub fn from_seed(seed: &ProjectStatement) -> Self {
164        Self {
165            text: seed.text.clone(),
166            kind: StatementKind::parse(&seed.kind),
167            weight: seed.weight,
168        }
169    }
170}
171
172/// Compression / expansion target for [`summarize`].
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174pub enum SummarizationMode {
175    /// 1–5 words — return just the project / topic name. Used for chat titles
176    /// and topic labels.
177    Topic,
178    /// ~20% of the source — one or two essential statements.
179    Short,
180    /// ~50% of the source — keep all essential statements plus the highest-
181    /// weighted features.
182    #[default]
183    Standard,
184    /// 100% — every statement, in weight order.
185    Full,
186    /// ~200% — every statement plus NSM-style paraphrases that expand
187    /// compound words into semantic primes.
188    Expand,
189}
190
191impl SummarizationMode {
192    /// Target size relative to the input statement count, expressed as a
193    /// percentage. Used by [`SummarizationConfig::effective_max_statements`]
194    /// when the caller does not pin an explicit cap. Integer math keeps the
195    /// pipeline free of floating-point casts.
196    #[must_use]
197    pub const fn target_percent(self) -> u32 {
198        match self {
199            Self::Topic => 0,
200            Self::Short => 20,
201            Self::Standard => 50,
202            Self::Full => 100,
203            Self::Expand => 200,
204        }
205    }
206
207    /// The next-shorter mode on the detail ladder, used to bound recursion when
208    /// composing nested summaries (a directory describes its children one mode
209    /// shorter than itself). `Topic` is the fixed point: it cannot get shorter.
210    #[must_use]
211    pub const fn one_step_shorter(self) -> Self {
212        match self {
213            Self::Expand | Self::Full => Self::Standard,
214            Self::Standard => Self::Short,
215            Self::Short | Self::Topic => Self::Topic,
216        }
217    }
218}
219
220/// Configuration for the summarization pipeline. Every knob has a sensible
221/// default so the simplest call site can be
222/// `summarize(&statements, &SummarizationConfig::default())`.
223#[derive(Debug, Clone)]
224pub struct SummarizationConfig {
225    pub mode: SummarizationMode,
226    /// Hard cap on output statements. `None` lets [`SummarizationMode`] pick.
227    pub max_statements: Option<usize>,
228    /// Language slug (`en` / `ru` / `hi` / `zh`). Drives compound-word and
229    /// semantic-prime substitution lists.
230    pub language: String,
231    /// Replace compound words with shorter compound forms (default `false`).
232    /// Useful for chat titles where the result should fit in 1–5 words.
233    pub use_compound_words: bool,
234    /// Expand compound or rare words into NSM semantic primes when the mode
235    /// is `Expand`. Off by default to keep `Topic`/`Short`/`Standard` terse.
236    pub use_semantic_primes: bool,
237    /// Strip boilerplate kinds (`install`, `example`) from the output.
238    /// `true` by default — compressed answers should never carry setup steps.
239    pub drop_boilerplate: bool,
240}
241
242impl Default for SummarizationConfig {
243    fn default() -> Self {
244        Self {
245            mode: SummarizationMode::Standard,
246            max_statements: None,
247            language: "en".to_string(),
248            use_compound_words: false,
249            use_semantic_primes: false,
250            drop_boilerplate: true,
251        }
252    }
253}
254
255impl SummarizationConfig {
256    /// Builder helper used by project lookup call sites.
257    #[must_use]
258    pub const fn with_mode(mut self, mode: SummarizationMode) -> Self {
259        self.mode = mode;
260        self
261    }
262
263    /// Builder helper to pin the language.
264    #[must_use]
265    pub fn with_language(mut self, language: impl Into<String>) -> Self {
266        self.language = language.into();
267        self
268    }
269
270    /// Builder helper to clamp the number of statements.
271    #[must_use]
272    pub const fn with_max_statements(mut self, cap: usize) -> Self {
273        self.max_statements = Some(cap);
274        self
275    }
276
277    /// Effective statement cap for the given input size. Combines
278    /// [`SummarizationMode::target_percent`] with the optional explicit cap and
279    /// guarantees at least one statement for any non-empty input.
280    #[must_use]
281    pub fn effective_max_statements(&self, input_count: usize) -> usize {
282        if input_count == 0 {
283            return 0;
284        }
285        let ratio_target = match self.mode {
286            // Topic mode is rendered separately, but still returns at most 1
287            // statement when summarize() is asked to enforce it.
288            SummarizationMode::Topic => 1,
289            SummarizationMode::Full | SummarizationMode::Expand => input_count,
290            other => {
291                // Round-to-nearest using only integer math:
292                //   suggested = round(input_count * percent / 100)
293                let percent = other.target_percent() as usize;
294                let suggested = (input_count * percent + 50) / 100;
295                suggested.max(1)
296            }
297        };
298        self.max_statements
299            .map_or_else(|| ratio_target.max(1), |cap| cap.min(ratio_target).max(1))
300    }
301}
302
303/// Split a paragraph of free-form text into [`Statement`]s. Each sentence
304/// ends at `.`, `!`, `?`, `。`, `…`, the Devanagari danda `।` or double danda
305/// `॥`, or a newline. Empty fragments are dropped.
306///
307/// The dandas matter because Hindi prose ends its sentences with them and not
308/// with a full stop. Without them a Hindi page is one enormous statement, so
309/// anything that ranks or trims sentences — the web-research extract of issue
310/// #771, for one — degrades to returning the whole document.
311#[must_use]
312pub fn formalize(text: &str) -> Vec<Statement> {
313    let mut out = Vec::new();
314    let mut buffer = String::new();
315    for ch in text.chars() {
316        buffer.push(ch);
317        if matches!(ch, '.' | '!' | '?' | '。' | '…' | '।' | '॥' | '\n') {
318            push_sentence(&mut buffer, &mut out);
319        }
320    }
321    push_sentence(&mut buffer, &mut out);
322    out
323}
324
325fn push_sentence(buffer: &mut String, out: &mut Vec<Statement>) {
326    let sentence: String = buffer
327        .chars()
328        .filter(|c| !matches!(c, '\n'))
329        .collect::<String>()
330        .trim()
331        .to_string();
332    buffer.clear();
333    if sentence.is_empty() {
334        return;
335    }
336    let kind = classify_sentence(&sentence);
337    let weight = weight_for_kind(kind);
338    out.push(Statement::new(sentence, kind, weight));
339}
340
341/// Heuristic classifier for prose sentences.
342///
343/// Reasons over the seed registry rather than any hardcoded cue list: it walks
344/// the meanings carrying [`crate::seed::ROLE_SUMMARY_CLASSIFICATION_CUE`] in
345/// declaration order and returns the kind of the first meaning whose surface
346/// fragments occur in the lowercased sentence as a raw substring. The `language`
347/// kind is additionally length-guarded — a sentence that merely contains a
348/// language cue but runs past twelve whitespace words is not a language line, so
349/// it falls through to the later kinds. Every cue fragment, in every supported
350/// language, lives in `data/seed/meanings-summary.lino`; nothing is hardcoded here.
351#[must_use]
352pub fn classify_sentence(sentence: &str) -> StatementKind {
353    let lower = sentence.to_lowercase();
354    let word_count = lower.split_whitespace().count();
355    for meaning in
356        crate::seed::lexicon().meanings_with_role(crate::seed::ROLE_SUMMARY_CLASSIFICATION_CUE)
357    {
358        if !meaning.words().any(|cue| lower.contains(cue)) {
359            continue;
360        }
361        let kind = StatementKind::from_slug(&meaning.slug);
362        // The `language` kind only applies to short identity-style sentences; a
363        // long sentence that merely contains `is a …` keeps scanning so a later
364        // feature/purpose cue can claim it (preserving the original
365        // `&& word_count <= 12` guard that sat on the language arm).
366        if kind == StatementKind::Language && word_count > 12 {
367            continue;
368        }
369        return kind;
370    }
371    StatementKind::Misc
372}
373
374const fn weight_for_kind(kind: StatementKind) -> u8 {
375    match kind {
376        StatementKind::Purpose => 100,
377        StatementKind::Identity => 90,
378        StatementKind::Language => 60,
379        StatementKind::Stars => 55,
380        StatementKind::Feature => 70,
381        StatementKind::UseCase => 65,
382        StatementKind::Install => 10,
383        StatementKind::Example => 15,
384        StatementKind::Misc => 30,
385    }
386}
387
388/// Apply [`SummarizationConfig`] to a slice of statements.
389///
390/// Returns a new vector ordered by weight (descending), capped at the effective
391/// max. Boilerplate is stripped before ranking when `drop_boilerplate` is set,
392/// and `Expand` mode appends NSM paraphrases for the surviving statements.
393#[must_use]
394pub fn summarize(statements: &[Statement], config: &SummarizationConfig) -> Vec<Statement> {
395    if statements.is_empty() {
396        return Vec::new();
397    }
398    let mut filtered: Vec<Statement> = statements
399        .iter()
400        .filter(|s| !(config.drop_boilerplate && s.kind.is_boilerplate()))
401        .cloned()
402        .collect();
403    filtered.sort_by_key(|stmt| core::cmp::Reverse(stmt.weight));
404    let cap = config.effective_max_statements(filtered.len());
405    filtered.truncate(cap);
406
407    if config.mode == SummarizationMode::Expand {
408        // Double the surviving set with NSM paraphrases so the result lands
409        // near the requested ~200% target ratio.
410        let mut expanded: Vec<Statement> = Vec::with_capacity(filtered.len() * 2);
411        for stmt in &filtered {
412            expanded.push(stmt.clone());
413            if config.use_semantic_primes {
414                let mut paraphrase = stmt.clone();
415                paraphrase.text = apply_semantic_primes(&stmt.text, &config.language);
416                paraphrase.weight = stmt.weight.saturating_sub(5);
417                if paraphrase.text != stmt.text {
418                    expanded.push(paraphrase);
419                }
420            }
421        }
422        return expanded;
423    }
424
425    if config.use_compound_words {
426        for stmt in &mut filtered {
427            stmt.text = apply_compound_words(&stmt.text, &config.language);
428        }
429    }
430
431    filtered
432}
433
434/// Render a slice of statements as a single block of text. Statements are
435/// joined with single spaces (after re-punctuation) so the result reads as
436/// continuous prose.
437#[must_use]
438pub fn deformalize(statements: &[Statement]) -> String {
439    statements
440        .iter()
441        .map(|s| {
442            let trimmed = s.text.trim();
443            if trimmed.is_empty() {
444                String::new()
445            } else if ends_with_terminal_punct(trimmed) {
446                trimmed.to_string()
447            } else {
448                format!("{trimmed}.")
449            }
450        })
451        .filter(|s| !s.is_empty())
452        .collect::<Vec<_>>()
453        .join(" ")
454}
455
456fn ends_with_terminal_punct(text: &str) -> bool {
457    text.chars()
458        .last()
459        .is_some_and(|c| matches!(c, '.' | '!' | '?' | '。' | '…' | '।' | '॥' | '」' | '"'))
460}
461
462/// Render the topic label (1–5 words) for the supplied statements.
463///
464/// When `explicit_topic` is non-empty (e.g. `project.topic`) it is returned
465/// verbatim. Otherwise the first content noun of the highest-weight
466/// statement is used.
467#[must_use]
468pub fn to_topic(explicit_topic: &str, statements: &[Statement]) -> String {
469    let candidate = explicit_topic.trim();
470    if !candidate.is_empty() {
471        return clamp_words(candidate, 5);
472    }
473    statements
474        .iter()
475        .max_by_key(|s| s.weight)
476        .map(|s| clamp_words(&s.text, 5))
477        .unwrap_or_default()
478}
479
480fn clamp_words(text: &str, max_words: usize) -> String {
481    text.split_whitespace()
482        .take(max_words)
483        .collect::<Vec<_>>()
484        .join(" ")
485        .trim_end_matches(['.', ',', '!', '?', ';', ':', '…', '」', '"'])
486        .to_string()
487}
488
489/// Substitute a few common compound forms with shorter equivalents.
490/// Vocabulary is intentionally tiny; extending it is a single-line addition.
491#[must_use]
492pub fn apply_compound_words(text: &str, language: &str) -> String {
493    let pairs: &[(&str, &str)] = match language {
494        "ru" => &[
495            ("в которой ", "где "),
496            ("для того чтобы ", "чтобы "),
497            ("к примеру", "например"),
498        ],
499        _ => &[
500            ("in order to ", "to "),
501            ("for the purpose of ", "for "),
502            ("a number of ", "several "),
503            ("user interface", "UI"),
504            ("command line interface", "CLI"),
505            ("artificial intelligence", "AI"),
506        ],
507    };
508    let mut out = text.to_string();
509    for (long, short) in pairs {
510        out = out.replace(long, short);
511    }
512    out
513}
514
515/// Substitute compound or rare words with NSM semantic primes.
516///
517/// See <https://en.wikipedia.org/wiki/Natural_semantic_metalanguage>. This is a
518/// best-effort heuristic — the vocabulary is short and additive, so callers
519/// always see *some* simplification even when the prime is only an
520/// approximation.
521#[must_use]
522pub fn apply_semantic_primes(text: &str, language: &str) -> String {
523    let pairs: &[(&str, &str)] = match language {
524        "ru" => &[
525            ("автоматизация", "когда машина делает"),
526            ("оркестрирует", "управляет вместе"),
527            ("делегирование", "передача работы"),
528            ("детерминированный", "всегда одинаковый"),
529        ],
530        _ => &[
531            ("orchestrates", "controls many"),
532            (
533                "automation of automation",
534                "machine that makes other machines do",
535            ),
536            ("automation", "machine doing"),
537            ("delegating", "giving work to"),
538            ("deterministic", "always the same"),
539            ("multilingual", "in many languages"),
540            ("symbolic", "rule-based"),
541        ],
542    };
543    let mut out = text.to_string();
544    for (compound, prime) in pairs {
545        out = out.replace(compound, prime);
546    }
547    out
548}
549
550/// Build a description from the curated project record.
551///
552/// Centralizes the "look up project → pick statements for language →
553/// summarize → deformalize" pipeline so callers can request `Topic` / `Short`
554/// / `Standard` / `Full` / `Expand` length with one call.
555#[must_use]
556pub fn describe_project(project: &ProjectRecord, config: &SummarizationConfig) -> String {
557    let seed_statements = project.statements_for(&config.language);
558    let statements: Vec<Statement> = seed_statements.iter().map(Statement::from_seed).collect();
559    if config.mode == SummarizationMode::Topic {
560        return to_topic(project.topic_for(&config.language), &statements);
561    }
562    let summarized = summarize(&statements, config);
563    deformalize(&summarized)
564}
565
566mod dialog;
567mod file;
568mod markdown;
569mod resource;
570
571pub use dialog::{formalize_dialog, generate_chat_title, summarize_dialog, DialogTurn};
572pub use file::{
573    formalize_repository_file, summarize_repository_file, EmbeddedGrammarFormalization,
574    MetaLanguageFormalization, RepositoryFileFormalization,
575};
576pub use markdown::{describe_readme, formalize_markdown, strip_markdown_noise};
577pub use resource::{
578    formalize_repository_directory, formalize_repository_resource, summarize_repository_resource,
579    RepositoryDirectoryFormalization, RepositoryEntry, RepositoryResourceFormalization,
580};