lingo-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev 8af89d2cc9f8
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 7efc97d0dde0b2a3e729ccf9
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
src/lib/answer.ts
// Grading typed answers the way a patient teacher would: case and punctuation never matter,
// accents are optional (with a gentle note), English contractions count, and a one-letter typo in a
// longer word passes with a note. A wrong ending (gato/gata, un/una) is a real mistake, not a typo.

const CONTRACTIONS: Record<string, string> = {
  "i'm": "i am", "you're": "you are", "he's": "he is", "she's": "she is", "it's": "it is", "we're": "we are",
  "they're": "they are", "that's": "that is", "what's": "what is", "where's": "where is", "who's": "who is",
  "there's": "there is", "here's": "here is", "how's": "how is", "when's": "when is", "i've": "i have",
  "you've": "you have", "we've": "we have", "they've": "they have", "i'll": "i will", "you'll": "you will",
  "he'll": "he will", "she'll": "she will", "we'll": "we will", "they'll": "they will", "it'll": "it will",
  "i'd": "i would", "you'd": "you would", "he'd": "he would", "she'd": "she would", "we'd": "we would",
  "they'd": "they would", "don't": "do not", "doesn't": "does not", "didn't": "did not", "isn't": "is not",
  "aren't": "are not", "wasn't": "was not", "weren't": "were not", "can't": "can not", "cannot": "can not",
  "won't": "will not", "wouldn't": "would not", "shouldn't": "should not", "couldn't": "could not",
  "haven't": "have not", "hasn't": "has not", "let's": "let us",
};
const BRITISH: Record<string, string> = {
  colour: "color", colours: "colors", favourite: "favorite", neighbour: "neighbor", centre: "center", theatre: "theater",
  mum: "mom", okay: "ok", grey: "gray", travelling: "traveling", programme: "program", metre: "meter", metres: "meters",
};
const NUMBERS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve",
  "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty"];

/** Words of a sentence, lower-cased, without punctuation. Apostrophes stay inside English words. */
export function words(s: string, english: boolean): string[] {
  let t = s.toLowerCase().replace(/[’‘`´]/g, "'").replace(/ /g, " ");
  t = t.replace(/[¿¡?!.,;:«»"“”„()\[\]…–—-]/g, " ");
  const out: string[] = [];
  for (let w of t.split(/\s+/)) {
    if (!w) continue;
    if (english) {
      w = w.replace(/^'+|'+$/g, "");
      const c = CONTRACTIONS[w];
      if (c) { out.push(...c.split(" ")); continue; }
      if (/^\d+$/.test(w) && Number(w) <= 20) w = NUMBERS[Number(w)];
      w = BRITISH[w] || w;
      out.push(w);
    } else {
      // l'eau, j'ai, dell'acqua: the apostrophe joins two words.
      out.push(...w.split("'").filter(Boolean));
    }
  }
  return out;
}

export const fold = (s: string) =>
  s.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/ß/g, "ss").replace(/œ/g, "oe").replace(/æ/g, "ae");

function distance(a: string, b: string): number {
  const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...new Array(b.length).fill(0)]);
  for (let j = 1; j <= b.length; j++) d[0][j] = j;
  for (let i = 1; i <= a.length; i++)
    for (let j = 1; j <= b.length; j++)
      d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
  return d[a.length][b.length];
}

/** A slip of one letter inside a longer word, away from its ending (endings carry gender and number). */
function isTypo(a: string, b: string): boolean {
  return Math.min(a.length, b.length) >= 5 && a.slice(-2) === b.slice(-2) && distance(a, b) === 1;
}

export interface Grade { ok: boolean; note?: string; best: string }

/** Grades `input` against every accepted answer. `best` is the answer to show. */
export function grade(input: string, accepted: string[], english: boolean): Grade {
  const mine = words(input, english);
  if (!mine.length) return { ok: false, best: accepted[0] };
  let typo: string | null = null;
  let accent: string | null = null;
  for (const a of accepted) {
    const theirs = words(a, english);
    if (theirs.join(" ") === mine.join(" ")) return { ok: true, best: a };
    if (theirs.length !== mine.length) continue;
    const fm = mine.map(fold);
    const ft = theirs.map(fold);
    if (fm.join(" ") === ft.join(" ")) { accent ??= a; continue; }
    const diffs = fm.map((w, i) => (w === ft[i] ? -1 : i)).filter((i) => i >= 0);
    if (diffs.length === 1 && isTypo(fm[diffs[0]], ft[diffs[0]])) typo ??= a;
  }
  if (accent) return { ok: true, best: accent, note: `Mind the accents: ${accent}` };
  if (typo) return { ok: true, best: typo, note: `You have a typo: ${typo}` };
  return { ok: false, best: accepted[0] };
}

/** How much of the target a spoken attempt got right, from 0 to 1 (word order matters). */
export function spokenMatch(heard: string, target: string): number {
  const a = words(heard, false).map(fold);
  const b = words(target, false).map(fold);
  if (!b.length) return 0;
  const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
  for (let i = 1; i <= a.length; i++)
    for (let j = 1; j <= b.length; j++)
      dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
  return dp[a.length][b.length] / b.length;
}

Change history

Point OpenApps links to openapps.free
8af89d2cc9 · 17 hours ago
Bind agent address in policy
0018350275 · 19 hours ago
Use OpenApps service URLs and add contribution strip
805365daf4 · 19 hours ago
Bounded source sync
3f2fb3b265 · 2 days ago
Bounded source sync
7dcb580324 · 2 days ago
Bounded source sync
dcd05c0944 · 2 days ago