Skip to content

Commit dce9074

Browse files
authored
[feat] Add internationalization to SAP (#2210)
we can now handle: * accents / ligature errors by the LLM (both including and excluding them). Validated for enums, literals, and class keys. * class keys may now allow for whitespace mismatches, case-insensitivity, etc. <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > This PR adds internationalization support by enhancing string matching to handle accents, ligatures, and case-insensitivity, with extensive tests to validate the changes. > > - **Behavior**: > - Enhances `match_string` in `match_string.rs` to handle accents, ligatures, and case-insensitivity. > - Supports whitespace mismatches and case-insensitivity for class keys. > - **Dependencies**: > - Adds `unicode-normalization` to `Cargo.toml` and `Cargo.lock` for accent handling. > - **Tests**: > - Adds `test_international.rs` with tests for accented and unaccented inputs across enums, literals, and class keys. > - Updates existing tests in `test_class.rs` and `test_enum.rs` to validate new matching logic. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=BoundaryML%2Fbaml&utm_source=github&utm_medium=referral)<sup> for 4f3e967. You can [customize](https://app.ellipsis.dev/BoundaryML/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> <!-- ELLIPSIS_HIDDEN -->
1 parent c104174 commit dce9074

10 files changed

Lines changed: 1345 additions & 10 deletions

File tree

engine/Cargo.lock

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

engine/baml-lib/jsonish/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ either = "1.10.0"
3535
test-log = "0.2.16"
3636
regex.workspace = true
3737
thiserror = "2.0.9"
38+
unicode-normalization = "0.1.24"
3839

3940
[dev-dependencies]
4041
assert-json-diff = "2.0.2"

engine/baml-lib/jsonish/src/deserializer/coercer/coerce_literal.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ impl TypeCoercer for LiteralValue {
8989
// second element is the list of aliases.
9090
let candidates = vec![(literal_str.as_str(), vec![literal_str.clone()])];
9191

92-
let literal_match = match_string(ctx, target, Some(value), &candidates)?;
92+
let literal_match = match_string(ctx, target, Some(value), &candidates, true)?;
9393

9494
Ok(BamlValueWithFlags::String(literal_match))
9595
}

engine/baml-lib/jsonish/src/deserializer/coercer/coerce_primitive.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ pub(super) fn coerce_bool(
273273
vec!["false".into(), "False".into(), "FALSE".into()],
274274
),
275275
],
276+
true,
276277
) {
277278
Ok(val) => match val.value().as_str() {
278279
"true" => Ok(BamlValueWithFlags::Bool(

engine/baml-lib/jsonish/src/deserializer/coercer/ir_ref/coerce_class.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ use internal_baml_jinja::types::{Class, Name};
88
use super::ParsingContext;
99
use crate::deserializer::{
1010
coercer::{
11-
array_helper, field_type::validate_asserts, run_user_checks, DefaultValue, ParsingError,
12-
TypeCoercer,
11+
array_helper,
12+
field_type::validate_asserts,
13+
match_string::{match_string, matches_string_to_string},
14+
run_user_checks, DefaultValue, ParsingError, TypeCoercer,
1315
},
1416
deserialize_flags::{DeserializerConditions, Flag},
1517
types::BamlValueWithFlags,
@@ -93,7 +95,7 @@ impl TypeCoercer for Class {
9395
if let Some(field) = self
9496
.fields
9597
.iter()
96-
.find(|(name, ..)| name.rendered_name().trim() == key.trim())
98+
.find(|(name, ..)| matches_string_to_string(ctx, key, name.rendered_name()))
9799
{
98100
let scope = ctx.enter_scope(field.0.real_name());
99101
let parsed = field.1.coerce(&scope, &field.1, Some(v));

engine/baml-lib/jsonish/src/deserializer/coercer/ir_ref/coerce_enum.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ impl TypeCoercer for Enum {
4949
.find_enum(self.name.real_name())
5050
.map_or(vec![], |class| class.constraints.clone());
5151

52-
let variant_match = match_string(ctx, target, value, &enum_match_candidates(self))?;
52+
let variant_match = match_string(ctx, target, value, &enum_match_candidates(self), true)?;
5353
let enum_match = apply_constraints(
5454
target,
5555
vec![],

engine/baml-lib/jsonish/src/deserializer/coercer/match_string.rs

Lines changed: 209 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,32 @@ use crate::{
1717
jsonish,
1818
};
1919

20+
pub(super) fn matches_string_to_string(
21+
parsing_context: &ParsingContext,
22+
raw_value: &str,
23+
parse_into: &str,
24+
) -> bool {
25+
match_string(
26+
parsing_context,
27+
&TypeIR::string(),
28+
Some(&crate::jsonish::Value::String(
29+
raw_value.to_string(),
30+
baml_types::CompletionState::Complete,
31+
)),
32+
&[(parse_into, vec![parse_into.to_string()])],
33+
false,
34+
)
35+
.is_ok()
36+
}
37+
2038
/// Heuristic match of different possible values against an input string.
2139
pub(super) fn match_string(
2240
parsing_context: &ParsingContext,
2341
target: &TypeIR,
2442
value: Option<&jsonish::Value>,
2543
// List of (name, [aliases]) tuples.
2644
candidates: &[(&str, Vec<String>)],
45+
allow_substring_match: bool,
2746
) -> Result<ValueWithFlags<String>, ParsingError> {
2847
// Get rid of nulls.
2948
let value = match value {
@@ -52,7 +71,9 @@ pub(super) fn match_string(
5271
let match_context = jsonish_string.trim();
5372

5473
// First attempt, case sensitive match ignoring possible pucntuation.
55-
if let Some(string_match) = string_match_strategy(match_context, candidates, &mut flags) {
74+
if let Some(string_match) =
75+
string_match_strategy(match_context, candidates, &mut flags, allow_substring_match)
76+
{
5677
return try_match_only_once(parsing_context, target, string_match, flags);
5778
}
5879

@@ -69,7 +90,22 @@ pub(super) fn match_string(
6990
}));
7091

7192
// Second attempt, case sensitive match without punctuation.
72-
if let Some(string_match) = string_match_strategy(&match_context, &candidates, &mut flags) {
93+
if let Some(string_match) = string_match_strategy(
94+
&match_context,
95+
&candidates,
96+
&mut flags,
97+
allow_substring_match,
98+
) {
99+
return try_match_only_once(parsing_context, target, string_match, flags);
100+
}
101+
102+
// Third attempt, case sensitive match without punctuation.
103+
if let Some(string_match) = string_match_strategy(
104+
&match_context,
105+
&candidates,
106+
&mut flags,
107+
allow_substring_match,
108+
) {
73109
return try_match_only_once(parsing_context, target, string_match, flags);
74110
}
75111

@@ -84,7 +120,12 @@ pub(super) fn match_string(
84120
});
85121

86122
// There goes our last hope :)
87-
if let Some(string_match) = string_match_strategy(&match_context, &candidates, &mut flags) {
123+
if let Some(string_match) = string_match_strategy(
124+
&match_context,
125+
&candidates,
126+
&mut flags,
127+
allow_substring_match,
128+
) {
88129
return try_match_only_once(parsing_context, target, string_match, flags);
89130
}
90131

@@ -97,6 +138,26 @@ fn strip_punctuation(s: &str) -> String {
97138
.collect::<String>()
98139
}
99140

141+
/// Remove accents from characters to enable fuzzy matching of unaccented input
142+
/// against accented aliases/candidates.
143+
fn remove_accents(s: &str) -> String {
144+
use unicode_normalization::UnicodeNormalization;
145+
146+
// Handle ligatures separately since they're not combining marks
147+
let s = s
148+
.replace('ß', "ss")
149+
.replace('æ', "ae")
150+
.replace('Æ', "AE")
151+
.replace('ø', "o")
152+
.replace('Ø', "O")
153+
.replace('œ', "oe")
154+
.replace('Œ', "OE");
155+
156+
s.nfkd()
157+
.filter(|c| !unicode_normalization::char::is_combining_mark(*c))
158+
.collect()
159+
}
160+
100161
/// Helper function to return a single string match result.
101162
///
102163
/// Multiple results will yield an error.
@@ -132,15 +193,47 @@ fn string_match_strategy<'c>(
132193
value_str: &str,
133194
candidates: &'c [(&'c str, Vec<String>)],
134195
flags: &mut DeserializerConditions,
196+
allow_substring_match: bool,
135197
) -> Option<&'c str> {
136-
// Try and look for an exact match against valid values.
198+
log::info!("string_match_strategy: {value_str}");
199+
log::info!(
200+
"candidates:\n{}",
201+
candidates
202+
.iter()
203+
.map(|(c, v)| format!(
204+
"{c} -> {}",
205+
v.iter()
206+
.map(|v| format!("\"{v}\""))
207+
.collect::<Vec<_>>()
208+
.join(", ")
209+
))
210+
.collect::<Vec<_>>()
211+
.join("\n")
212+
);
213+
// Strategy 1: Try exact case-sensitive match
137214
for (candidate, valid_values) in candidates {
138215
if valid_values.iter().any(|v| v == value_str) {
139-
// We did nothing fancy, so no extra flags.
216+
// No flags since we found an exact match.
140217
return Some(candidate);
141218
}
142219
}
143220

221+
// Strategy 2: Try unaccented case-sensitive match
222+
let unaccented_value_str = remove_accents(value_str);
223+
for (candidate, valid_values) in candidates {
224+
if valid_values
225+
.iter()
226+
.any(|v| remove_accents(v) == unaccented_value_str)
227+
{
228+
// No flags since we found an exact match.
229+
return Some(candidate);
230+
}
231+
}
232+
233+
if !allow_substring_match {
234+
return None;
235+
}
236+
144237
// (start_index, end_index, valid_name, variant)
145238
// TODO: Consider using a struct with named fields instead of a 4-tuple.
146239
let mut all_matches: Vec<(usize, usize, &'c str, &'c str)> = Vec::new();
@@ -155,6 +248,21 @@ fn string_match_strategy<'c>(
155248
}
156249
}
157250

251+
// No substring match at all for any variant, early return.
252+
if all_matches.is_empty() {
253+
// Try to see if we can find any substring matches in the unaccented
254+
// value string.
255+
for (variant, valid_names) in candidates {
256+
for valid_name in valid_names {
257+
let unaccented_valid_name = remove_accents(valid_name);
258+
for (start_idx, _) in unaccented_value_str.match_indices(&unaccented_valid_name) {
259+
let end_idx = start_idx + valid_name.len();
260+
all_matches.push((start_idx, end_idx, valid_name, variant));
261+
}
262+
}
263+
}
264+
}
265+
158266
// No substring match at all for any variant, early return.
159267
if all_matches.is_empty() {
160268
return None;
@@ -218,3 +326,99 @@ fn string_match_strategy<'c>(
218326
// No match found.
219327
None
220328
}
329+
330+
#[cfg(test)]
331+
mod tests {
332+
use super::*;
333+
334+
#[test]
335+
fn test_remove_accents_etude() {
336+
assert_eq!(remove_accents("étude"), "etude");
337+
}
338+
339+
#[test]
340+
fn test_remove_accents_francais() {
341+
assert_eq!(remove_accents("français"), "francais");
342+
}
343+
344+
#[test]
345+
fn test_remove_accents_espanol() {
346+
assert_eq!(remove_accents("Español"), "Espanol");
347+
}
348+
349+
#[test]
350+
fn test_remove_accents_portugues() {
351+
assert_eq!(remove_accents("português"), "portugues");
352+
}
353+
354+
#[test]
355+
fn test_remove_accents_medium() {
356+
assert_eq!(remove_accents("médium"), "medium");
357+
}
358+
359+
#[test]
360+
fn test_remove_accents_grun() {
361+
assert_eq!(remove_accents("Grün"), "Grun");
362+
}
363+
364+
#[test]
365+
fn test_remove_accents_uber() {
366+
assert_eq!(remove_accents("Über"), "Uber");
367+
}
368+
369+
#[test]
370+
fn test_remove_accents_strasse() {
371+
assert_eq!(remove_accents("Straße"), "Strasse");
372+
}
373+
374+
#[test]
375+
fn test_remove_accents_stadt() {
376+
assert_eq!(remove_accents("Stadt"), "Stadt");
377+
}
378+
379+
#[test]
380+
fn test_remove_accents_ae_ligature() {
381+
assert_eq!(remove_accents("æ"), "ae");
382+
assert_eq!(remove_accents("Æ"), "AE");
383+
}
384+
385+
#[test]
386+
fn test_remove_accents_o_ligature() {
387+
assert_eq!(remove_accents("ø"), "o");
388+
assert_eq!(remove_accents("Ø"), "O");
389+
}
390+
391+
#[test]
392+
fn test_remove_accents_oe_ligature() {
393+
assert_eq!(remove_accents("œ"), "oe");
394+
assert_eq!(remove_accents("Œ"), "OE");
395+
}
396+
397+
#[test]
398+
fn test_remove_accents_danish_word() {
399+
assert_eq!(remove_accents("København"), "Kobenhavn");
400+
}
401+
402+
#[test]
403+
fn test_remove_accents_french_word() {
404+
assert_eq!(remove_accents("cœur"), "coeur");
405+
assert_eq!(remove_accents("œuvre"), "oeuvre");
406+
}
407+
408+
#[test]
409+
fn test_remove_accents_mixed_ligatures() {
410+
assert_eq!(
411+
remove_accents("Straße ældre øl œuvre"),
412+
"Strasse aeldre ol oeuvre"
413+
);
414+
}
415+
416+
#[test]
417+
fn test_remove_accents_non_alphanumeric() {
418+
// It correctly leaves non-alphanumeric ASCII and other scripts alone, but converts ligatures
419+
assert_eq!(
420+
remove_accents("ß, æ, ø, œ, こんにちは, 🦄"),
421+
"ss, ae, o, oe, こんにちは, 🦄"
422+
);
423+
}
424+
}

engine/baml-lib/jsonish/src/tests/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod test_class_2;
99
mod test_code;
1010
mod test_constraints;
1111
mod test_enum;
12+
mod test_international;
1213
mod test_lists;
1314
mod test_literals;
1415
mod test_maps;

0 commit comments

Comments
 (0)