Skip to main content

logicaffeine_web/ui/pages/
heal.rs

1//! LLM Heal: the fairy that mends a reader's English (work/LLM_HEAL.md, DAT-108).
2//!
3//! A person types English straight into the Studio's editor, typos and loose phrasing and all,
4//! and presses Heal. The text goes to the heal worker (`services/heal`), which asks a model for the
5//! same text as clear, idiomatic LOGOS or logic sentences; the fairy flies to the lines the mend
6//! touched and a card shows both versions with Keep and Undo. Nothing lands in the editor until
7//! Keep is pressed. This module is the pure core: the wire shapes, the line diff, the flight's
8//! state machine, the worker's address, the pixelation schedule and the dock bar's words. The
9//! workbench draws it.
10
11use std::ops::Range;
12
13use serde::{Deserialize, Serialize};
14
15/// Where a heal is sent: `LOGOS_HEAL_URL` at build time (nas2's dev build points at a `wrangler
16/// dev` of the worker), else production.
17pub const ENDPOINT: &str = match option_env!("LOGOS_HEAL_URL") {
18    Some(url) => url,
19    None => "https://api.logicaffeine.com/heal",
20};
21
22/// The fairy, served from the site (the L of Z, her quest form).
23pub const FAIRY: &str = "/assets/heal-fairy.png";
24
25/// What the dock bar says when Heal is pressed over an empty editor.
26pub const EMPTY_EDITOR: &str = "Nothing to heal yet. Write some English in the editor first.";
27
28/// What the dock bar says when nothing has changed since the file opened or was last kept.
29pub const NOTHING_NEW: &str = "Nothing new to heal. Change some lines first, then press Heal.";
30
31/// The two Studio modes that heal, as the worker names them.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum Mode {
35    Code,
36    Logic,
37}
38
39/// The body the worker reads: `{ "mode": "code" | "logic", "text": "...", "fragment": true }`, the
40/// text as written. `fragment` says the text is the lines a person just wrote inside a program that
41/// is already right, so the model mends those lines only and answers with them alone.
42#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Request {
44    pub mode: Mode,
45    pub text: String,
46    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
47    pub fragment: bool,
48}
49
50/// The worker's answer: `{ ok: true, healed, model }` or `{ ok: false, error }`.
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct Answer {
53    pub ok: bool,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub healed: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub model: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub error: Option<String>,
60}
61
62/// The healed text from the worker's status and body, or the sentence the card shows instead.
63pub fn outcome(status: u16, body: &str) -> Result<String, String> {
64    let answer: Answer = match serde_json::from_str(body) {
65        Ok(answer) => answer,
66        Err(_) if status == 429 => return Err(reason("rate_limited")),
67        Err(_) => return Err(reason("not_json")),
68    };
69    match (status, answer.ok, answer.healed) {
70        (200, true, Some(healed)) if !healed.trim().is_empty() => Ok(healed),
71        (200, true, _) => Err(reason("empty_answer")),
72        (429, _, _) => Err(reason("rate_limited")),
73        _ => Err(reason(answer.error.as_deref().unwrap_or("failed"))),
74    }
75}
76
77/// The worker's named error as the sentence the card shows.
78pub fn reason(error: &str) -> String {
79    match error {
80        "text_too_long" => "That is more than one heal takes (8 KB). Heal a part of it.".to_string(),
81        "text_missing" => EMPTY_EDITOR.to_string(),
82        "rate_limited" => "Too many heals in a minute. Try again shortly.".to_string(),
83        "upstream_failed" => "The model did not answer. Try again.".to_string(),
84        "empty_answer" | "no_text" => "The model answered with no text. Try again.".to_string(),
85        "origin_not_allowed" => "This page may not heal from here.".to_string(),
86        "unreachable" => "The heal service did not answer. Check the connection and try again.".to_string(),
87        other => format!("Heal failed ({other})."),
88    }
89}
90
91/// The line ranges of `after` the mend touched, by a line diff: identical texts give none, a
92/// one-word fix gives that line alone, lines added give their run, and lines deleted give an
93/// empty range where they were. Spaces at a line's end do not count as a change.
94pub fn changed_lines(before: &str, after: &str) -> Vec<Range<usize>> {
95    line_diff(before, after).after
96}
97
98/// What a line diff touched on each side: the ranges of `before` lines the mend replaced or
99/// removed, and of `after` lines it wrote or added, by the longest common subsequence of lines
100/// (spaces at a line's end ignored). The card marks both sides from this.
101#[derive(Clone, Debug, Default, PartialEq, Eq)]
102pub struct LineDiff {
103    pub before: Vec<Range<usize>>,
104    pub after: Vec<Range<usize>>,
105}
106
107pub fn line_diff(before: &str, after: &str) -> LineDiff {
108    let a: Vec<&str> = before.lines().map(str::trim_end).collect();
109    let b: Vec<&str> = after.lines().map(str::trim_end).collect();
110    // The longest common subsequence of lines, by the usual table.
111    let (n, m) = (a.len(), b.len());
112    let mut table = vec![vec![0usize; m + 1]; n + 1];
113    for i in (0..n).rev() {
114        for j in (0..m).rev() {
115            table[i][j] = if a[i] == b[j] { table[i + 1][j + 1] + 1 } else { table[i + 1][j].max(table[i][j + 1]) };
116        }
117    }
118    fn push(ranges: &mut Vec<Range<usize>>, range: Range<usize>) {
119        match ranges.last_mut() {
120            Some(last) if last.end == range.start => last.end = range.end,
121            _ => ranges.push(range),
122        }
123    }
124    let mut diff = LineDiff::default();
125    let (mut i, mut j) = (0, 0);
126    while i < n || j < m {
127        if i < n && j < m && a[i] == b[j] {
128            i += 1;
129            j += 1;
130        } else if j < m && (i >= n || table[i][j + 1] >= table[i + 1][j]) {
131            push(&mut diff.after, j..j + 1);
132            push(&mut diff.before, i..i);
133            j += 1;
134        } else {
135            push(&mut diff.after, j..j);
136            push(&mut diff.before, i..i + 1);
137            i += 1;
138        }
139    }
140    diff
141}
142
143/// The regions of `current` a person wrote since `baseline` (the text the file opened with, or was
144/// last kept at): the runs of lines that differ, by the line diff, neighbours within one line
145/// merged into one region, and nothing for lines only removed. Only these go to the worker, so a
146/// heal touches what the person typed and never the code that was already there (the user,
147/// 2026-11-02).
148pub fn edited_regions(baseline: &str, current: &str) -> Vec<Range<usize>> {
149    let mut regions: Vec<Range<usize>> = Vec::new();
150    for range in line_diff(baseline, current).after {
151        if range.is_empty() {
152            continue;
153        }
154        match regions.last_mut() {
155            Some(last) if range.start <= last.end + 1 => last.end = last.end.max(range.end),
156            _ => regions.push(range),
157        }
158    }
159    regions
160}
161
162/// The lines of `text` in `region`, as one text to send.
163pub fn region_text(text: &str, region: &Range<usize>) -> String {
164    text.lines().skip(region.start).take(region.len()).collect::<Vec<_>>().join("\n")
165}
166
167/// The mended lines at the indentation the region had: a model that drops a fragment's leading
168/// spaces (the first line of an indented block comes back at the margin) gets them put back, on
169/// every mended line shallower than the region's first line, so the splice keeps the block.
170pub fn reindent(region: &str, mended: &str) -> String {
171    let indent_of = |line: &str| line.len() - line.trim_start().len();
172    let base = region.lines().next().map_or(0, indent_of);
173    let prefix = " ".repeat(base);
174    mended
175        .lines()
176        .map(|line| {
177            if line.trim().is_empty() || indent_of(line) >= base {
178                line.to_string()
179            } else {
180                format!("{prefix}{}", line.trim_start())
181            }
182        })
183        .collect::<Vec<_>>()
184        .join("\n")
185}
186
187/// `text` with each region's lines replaced by the mended lines it got back, last region first so
188/// earlier line numbers stay true; the rest of the text is untouched, byte for byte.
189pub fn splice(text: &str, regions: &[Range<usize>], mended: &[String]) -> String {
190    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
191    for (region, replacement) in regions.iter().zip(mended).rev() {
192        let new_lines: Vec<String> = replacement.lines().map(str::to_string).collect();
193        lines.splice(region.clone(), new_lines);
194    }
195    let mut out = lines.join("\n");
196    if text.ends_with('\n') {
197        out.push('\n');
198    }
199    out
200}
201
202/// Whether two texts say the same thing line for line, spaces at a line's end and blank lines at
203/// the end aside: a mend that only moved whitespace is no mend.
204pub fn same_text(before: &str, after: &str) -> bool {
205    let norm = |t: &str| t.lines().map(str::trim_end).collect::<Vec<_>>().join("\n").trim_end().to_string();
206    norm(before) == norm(after)
207}
208
209/// A line as pieces, each marked whether it differs from the other line: the common prefix and
210/// suffix of words stay plain, what lies between is the mend. For the card to show the word that
211/// changed, not only the line.
212pub fn word_marks<'a>(line: &'a str, other: &str) -> Vec<(&'a str, bool)> {
213    let a: Vec<&str> = line.split_inclusive(' ').collect();
214    let b: Vec<&str> = other.split_inclusive(' ').collect();
215    let prefix = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
216    let suffix = a[prefix..].iter().rev().zip(b[prefix..].iter().rev()).take_while(|(x, y)| x == y).count();
217    let mut out = Vec::new();
218    let mut piece = |range: Range<usize>, marked: bool| {
219        if range.start < range.end {
220            let start = a[..range.start].iter().map(|s| s.len()).sum::<usize>();
221            let end = a[..range.end].iter().map(|s| s.len()).sum::<usize>();
222            out.push((&line[start..end], marked));
223        }
224    };
225    piece(0..prefix, false);
226    piece(prefix..a.len() - suffix, true);
227    piece(a.len() - suffix..a.len(), false);
228    out
229}
230
231/// Where the fairy is and what the card shows.
232#[derive(Clone, Debug, PartialEq, Eq)]
233pub enum Flight {
234    /// On her button.
235    Idle,
236    /// On her way to line `to` while the worker thinks.
237    Flying { to: usize },
238    /// At the line, mending.
239    Mending,
240    /// The mend is back: both texts, and the lines of `after` it touched (`diff` has both sides).
241    Review { before: String, after: String, lines: Vec<Range<usize>>, diff: LineDiff },
242    /// No mend: the reason, in the card's place.
243    Failed(String),
244}
245
246impl Flight {
247    /// Whether Heal is busy (the button waits).
248    pub fn busy(&self) -> bool {
249        matches!(self, Flight::Flying { .. } | Flight::Mending)
250    }
251
252    /// Heal pressed over `text`: the flight begins, or the dock says the editor is empty.
253    pub fn begin(text: &str) -> Flight {
254        if text.trim().is_empty() {
255            Flight::Failed(EMPTY_EDITOR.to_string())
256        } else {
257            Flight::Flying { to: 0 }
258        }
259    }
260
261    /// The fairy reached the line.
262    pub fn arrived(self) -> Flight {
263        match self {
264            Flight::Flying { .. } => Flight::Mending,
265            other => other,
266        }
267    }
268
269    /// The worker answered: a review of both texts, or the reason there is none. A mend that
270    /// changed nothing is reported as such rather than reviewed.
271    pub fn answered(before: String, outcome: Result<String, String>) -> Flight {
272        match outcome {
273            Ok(after) if same_text(&before, &after) => Flight::Failed("Nothing to mend. The text already reads well.".to_string()),
274            Ok(after) => {
275                let diff = line_diff(&before, &after);
276                let lines = diff.after.clone();
277                Flight::Review { before, after, lines, diff }
278            }
279            Err(reason) => Flight::Failed(reason),
280        }
281    }
282
283    /// Keep: the healed text to write into the editor, and the fairy goes home.
284    pub fn keep(self) -> (Flight, Option<String>) {
285        match self {
286            Flight::Review { after, .. } => (Flight::Idle, Some(after)),
287            _ => (Flight::Idle, None),
288        }
289    }
290
291    /// Undo (or closing a failure): the editor is untouched, and the fairy goes home.
292    pub fn undo(self) -> Flight {
293        Flight::Idle
294    }
295
296    /// The line the fairy stands at: the first the mend touched, else where she was sent.
297    pub fn line(&self) -> usize {
298        match self {
299            Flight::Flying { to } => *to,
300            Flight::Review { lines, .. } => lines.first().map_or(0, |r| r.start),
301            _ => 0,
302        }
303    }
304}
305
306/// What the dock bar says while a heal is under way; nothing when the fairy is home.
307pub fn narration(flight: &Flight) -> Option<String> {
308    match flight {
309        Flight::Idle => None,
310        Flight::Flying { .. } | Flight::Mending => Some("Healing\u{2026}".to_string()),
311        Flight::Review { lines, .. } => {
312            let n: usize = lines.iter().map(|r| r.len().max(1)).sum();
313            Some(format!("Healed {n} line{}. Keep or Undo.", if n == 1 { "" } else { "s" }))
314        }
315        Flight::Failed(reason) => Some(reason.clone()),
316    }
317}
318
319/// The order in which `len` characters dissolve, deterministic from `seed`, so the two halves of
320/// the animation mirror: the new text forms in the reverse of this order.
321pub fn pixel_steps(len: usize, seed: u64) -> Vec<usize> {
322    let mut order: Vec<usize> = (0..len).collect();
323    let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15).max(1);
324    for i in (1..len).rev() {
325        state ^= state << 13;
326        state ^= state >> 7;
327        state ^= state << 17;
328        let j = (state % (i as u64 + 1)) as usize;
329        order.swap(i, j);
330    }
331    order
332}
333
334/// POST the text to the worker. On the web, the answer as [`outcome`] reads it; on the server
335/// there is nothing to send to.
336pub async fn send(mode: Mode, text: &str, fragment: bool) -> Result<String, String> {
337    #[cfg(target_arch = "wasm32")]
338    {
339        let body = Request { mode, text: text.to_string(), fragment };
340        let Ok(request) = gloo_net::http::Request::post(ENDPOINT).json(&body) else {
341            return Err(reason("unreachable"));
342        };
343        match request.send().await {
344            Ok(response) => {
345                let status = response.status();
346                let body = response.text().await.unwrap_or_default();
347                outcome(status, &body)
348            }
349            Err(_) => Err(reason("unreachable")),
350        }
351    }
352    #[cfg(not(target_arch = "wasm32"))]
353    {
354        let _ = (mode, text, fragment);
355        Err(reason("unreachable"))
356    }
357}
358
359#[cfg(test)]
360#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/heal/tests.rs"]
361mod tests;