Skip to content

rejifald/langtell

Repository files navigation

langtell

Tell me the language.

langtell infers the language of short strings — titles, snippets, headlines — by fusing evidence from many signals into a single weighted verdict with a confidence score and an auditable trail. It reads the tells: the script and distinctive letters of the text, the <html lang> / og:locale / meta tags of the page it came from, the HTTP Content-Language header, and — optionally — heavier statistical engines like franc or the on-device Chrome AI language detector.

It is not another trigram detector competing with franc/cld3/tinyld. Those answer "what language is this body of text?" from the characters alone. langtell answers "what language is this title, given the page, transport, and source it arrived in?" — and shows its work.

Status: early. The core detector (candidate-relative script/letter scoring, the BCP-47-aware fuser with the context-vs-script guard, and the opt-in franc and Chrome AI engines) is implemented and tested. The API below reflects the committed design.

Why

  • Short strings beat statistical detectors. A two-word title gives franc too little to chew on. langtell leans on script ranges, distinctive letters, and out-of-band metadata that a pure text detector never sees.
  • Auditable, not magic. Every verdict carries the list of signals that produced it (evidence[]), each with its kind, language, confidence, and raw value — so you can debug why a title was classified the way it was.
  • Pay only for what you use. The zero-dependency core (script + HTML + header signals) is fully synchronous. Heavy engines (franc's trigram tables, the browser detector) live behind their own subpaths and only enter your bundle — and only run — when you opt in.

Quick start

import { compile } from "langtell";
import { uk, ru, en } from "langtell/profiles"; // ready-made roster data

// compile() does the per-roster setup once; call the returned fn many times.
const detect = compile({ candidates: [uk, ru, en] });

const result = detect({
  text: "Їжак Сонік",
  html, // optional: <html lang>, og:locale, meta content-language
  responseHeaders, // optional: HTTP Content-Language
});
// → { language: "uk", confidence: 0.9x, evidence: [{ kind: "title-script", ... }, ...] }

Add the franc engine — it stays behind its own import door so its trigram tables never reach a bundle that doesn't use it. franc runs in-process and synchronously, so detect stays synchronous:

import { compile } from "langtell";
import { uk, ru, en } from "langtell/profiles";
import { createFrancEngine } from "langtell/franc";

const candidates = [uk, ru, en];
const detect = compile({ candidates, engines: [createFrancEngine(candidates)] });
const result = detect({ text, html, responseHeaders });

Register the on-device Chrome AI engine and the return type becomes Promise automatically, because that engine is async:

import { compile } from "langtell";
import { uk, ru, en } from "langtell/profiles";
import { chromeAiEngine } from "langtell/chrome-ai";

const detect = compile({ candidates: [uk, ru, en], engines: [chromeAiEngine] });
const result = await detect({ text }); // Promise<Classification>

Need more than "what language + how sure"? The default Classification collapses the candidate-relative ladder into one confidence float. When you need the raw structure — which rung decided (distinctive letters → function words → frequent words → optional trigram backstop) and the integer margin (the winner's lead over the runner-up) — reach for the opt-in langtell/classify door. It stays zero-dependency and franc-free; scoring is relative to the roster you pass in.

import { classifyBySnippet } from "langtell/classify";
import { uk, ru } from "langtell/profiles";

classifyBySnippet("Слава Україні", [uk, ru]);
// → { language: "uk", margin: 2, rung: 1, discriminating: true }  (a distinctive letter)
classifyBySnippet("Кофе и чай", [uk, ru]);
// → { language: "ru", margin: 1, rung: "2a", … }                  (a function-word marker)

This powers per-rung safety gates ("act only when a weak rung clears a high margin") and diagnostics — uses a single confidence number can't serve. The high-level compile/detect/fuse output is unchanged; this is purely additive.

Roster-free Cyrillic fast-path

langtell/classify scores a snippet relative to a roster you pass in. langtell/cyrillic is the opposite trade: a fixed, zero-config discriminator for the four Cyrillic languages langtell profiles — Ukrainian, Russian, Belarusian, Bulgarian — decided purely by distinctive letters, with no roster, no tokenization, and no franc. Reach for it when you just need "is this Russian? is this Ukrainian?" on a hot path and don't want to assemble a candidate set.

import { detectCyrillicLanguage, isUkrainian } from "langtell/cyrillic";

detectCyrillicLanguage("Їжак"); // → { language: "uk", … }  ї is uniquely Ukrainian
detectCyrillicLanguage("жёлтый"); // → { language: "ru", … }  ё / ы are Russian
detectCyrillicLanguage("съм българин"); // → { language: "bg", … }  ъ used as a vowel, repeated
detectCyrillicLanguage("подъезд"); // → { language: "ru", … }  lone ъ in a short word
isUkrainian("Слава Україні"); // → true

It returns "unknown" rather than guessing when the signals are insufficient — no Cyrillic at all, a uk/ru tie, or only an ambiguous э. The CyrillicVerdict also carries the raw ukScore / ruScore tallies behind the call. Zero-dependency and side-effect-free; escalate to classifyBySnippet or a franc-backed source when letter signals aren't enough.

API at a glance

Export Role
compile(config) Build a configured detect function (does the precompute once).
detect(input) The compiled detector. Sync or Promise, by config — see below.
evidenceFromText(text, candidates?) Producer: roster-relative script + distinctive-letter signals. Zero-dep, sync.
evidenceFromHtml(html) Producer: <html lang>, meta content-language, og:locale. Zero-dep, sync.
evidenceFromHeaders(h) Producer: HTTP Content-Language. Zero-dep, sync.
normalizeBCP47(tag) Normalize a BCP-47 tag/alias to a canonical code (uk-UA/uauk).
fuse(evidence, opts?) Weighted blend + "context never overrides clear script" guard.
langtell/profiles Ready-made LanguageProfile data (uk/ru/be/bg/en). Opt-in (carries word data).
langtell/classify Opt-in structured snippet verdict ({ language, margin, rung }). Zero-dep.
langtell/cyrillic Opt-in roster-free Cyrillic fast-path (detectCyrillicLanguage, isRussian/isUkrainian). Zero-dep, sync.
langtell/franc Opt-in franc engine (pulls trigram tables). Sync.
langtell/chrome-ai Opt-in on-device Chrome AI engine (browser). Async.

detect returns a plain Classification when every registered source is synchronous, and Promise<Classification> the moment an async engine is in the mix — the type reflects the config, so you never guess whether to await. See DESIGN.md for the full architecture.

Prior art

  • franc — trigram detection over 400+ languages. langtell can use it as one engine, but works on short strings where franc has too little signal, and fuses it with page/transport metadata.
  • cld3, tinyld, languagedetect — statistical text-only detectors. langtell differs by combining script logic with out-of-band evidence and emitting an auditable trail.

License

MIT

About

Tell me the language — evidence-fusion language detection for short strings, with an auditable confidence trail.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors