Skip to content

Commit 81bc854

Browse files
alltheseasclaude
andcommitted
Fix false positive translate prompts on short English text
NLLanguageRecognizer misidentifies short English text (e.g. "gut" → German, "vast" → Dutch, "cap" → Catalan), causing a spurious "translate note" button. Two-layer fix in note_language(): 1. Return nil for short Latin-script text (< 11 chars) where detection is unreliable. Non-Latin scripts (CJK, Cyrillic, Arabic, etc.) bypass this guard because the writing system itself is a strong signal. 2. For text < 60 chars, check if user's language appears in top 3 hypotheses with >= 10% confidence before falling back to the old 50% threshold. Tested against 107 sample notes (28 confirmed false positives + 42 additional English + 7 short foreign + 30 longer foreign): false positives drop from 28 to 0, with 0 false negatives introduced. Short non-Latin foreign text (e.g. "Привет", "こんにちは", "你好") is still correctly detected. Changelog-Fixed: Fix translate button appearing on short English notes Closes: #3671 Signed-off-by: alltheseas <[email protected]> Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: alltheseas <[email protected]>
1 parent 7937acc commit 81bc854

3 files changed

Lines changed: 383 additions & 45 deletions

File tree

damus.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1969,6 +1969,7 @@
19691969
E06336AA2B75832100A88E6B /* ImageMetadataTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06336A92B75832100A88E6B /* ImageMetadataTest.swift */; };
19701970
E06336AB2B75850100A88E6B /* img_with_location.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = E06336A82B7582E000A88E6B /* img_with_location.jpeg */; };
19711971
E0E024112B7C19C20075735D /* TranslationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E024102B7C19C20075735D /* TranslationTests.swift */; };
1972+
975D3312B8D24CE7D9DF6DB3 /* LanguageDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04D1B71EB1EE7BB906516BE7 /* LanguageDetectionTests.swift */; };
19721973
E0EE9DD42B8E5FEA00F3002D /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0EE9DD32B8E5FEA00F3002D /* ImageProcessing.swift */; };
19731974
E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; };
19741975
E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E990020E2955F837003BBC5A /* EditMetadataView.swift */; };
@@ -2943,6 +2944,7 @@
29432944
E06336A82B7582E000A88E6B /* img_with_location.jpeg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = img_with_location.jpeg; sourceTree = "<group>"; };
29442945
E06336A92B75832100A88E6B /* ImageMetadataTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageMetadataTest.swift; sourceTree = "<group>"; };
29452946
E0E024102B7C19C20075735D /* TranslationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationTests.swift; sourceTree = "<group>"; };
2947+
04D1B71EB1EE7BB906516BE7 /* LanguageDetectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageDetectionTests.swift; sourceTree = "<group>"; };
29462948
E0EE9DD32B8E5FEA00F3002D /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
29472949
E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsView.swift; sourceTree = "<group>"; };
29482950
E990020E2955F837003BBC5A /* EditMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditMetadataView.swift; sourceTree = "<group>"; };
@@ -3965,6 +3967,7 @@
39653967
D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */,
39663968
B501062C2B363036003874F5 /* AuthIntegrationTests.swift */,
39673969
E0E024102B7C19C20075735D /* TranslationTests.swift */,
3970+
04D1B71EB1EE7BB906516BE7 /* LanguageDetectionTests.swift */,
39683971
E06336A92B75832100A88E6B /* ImageMetadataTest.swift */,
39693972
D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */,
39703973
D72927AC2BAB515C00F93E90 /* RelayURLTests.swift */,
@@ -6450,6 +6453,7 @@
64506453
4C9054852A6AEAA000811EEC /* NdbTests.swift in Sources */,
64516454
75AD872B2AA23A460085EF2C /* Block+Tests.swift in Sources */,
64526455
E0E024112B7C19C20075735D /* TranslationTests.swift in Sources */,
6456+
975D3312B8D24CE7D9DF6DB3 /* LanguageDetectionTests.swift in Sources */,
64536457
F944F56E29EA9CCC0067B3BF /* DamusParseContentTests.swift in Sources */,
64546458
D7BEE6F92D37B37400CF659F /* DraftTests.swift in Sources */,
64556459
B501062D2B363036003874F5 /* AuthIntegrationTests.swift in Sources */,
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
//
2+
// LanguageDetectionTests.swift
3+
// damusTests
4+
//
5+
// Created for testing language detection false positives.
6+
//
7+
8+
import XCTest
9+
import NaturalLanguage
10+
@testable import damus
11+
12+
final class LanguageDetectionTests: XCTestCase {
13+
14+
// 107 sample texts: (text, isEnglish)
15+
//
16+
// English texts should be detected as "en" or nil, never as a foreign language.
17+
// Foreign texts should be detected as their actual language, not "en".
18+
//
19+
// The English set includes 28 phrases confirmed to trigger false positives
20+
// under the old single-hypothesis 50% threshold logic on iOS 26.2.
21+
static let sampleTexts: [(text: String, isEnglish: Bool)] = [
22+
23+
// -- Confirmed false positives under old logic (28 samples) --
24+
// Each of these is English but NLLanguageRecognizer's top hypothesis
25+
// at >= 50% confidence is a non-English language.
26+
("gut", true), // → de 98%
27+
("vast", true), // → nl 93%
28+
("hat", true), // → de 91%
29+
("ez", true), // → hu 90%
30+
("W", true), // → pl 87%
31+
("cap", true), // → ca 83%
32+
("sus", true), // → es 82%
33+
("cope", true), // → es 81%
34+
("no cap", true), // → ca 81%
35+
("no me", true), // → es 81%
36+
("nah", true), // → id 79%
37+
("don\u{2019}t care", true), // → ro 79%
38+
("it\u{2019}s joever", true), // → nl 72%
39+
("idk", true), // → id 70%
40+
("ya know", true), // → id 69%
41+
("vet", true), // → nb 65%
42+
("halt", true), // → de 65%
43+
("nice one", true), // → id 62%
44+
("You\u{2019}re funner", true), // → nb 61%
45+
("water", true), // → nl 58%
46+
("secular", true), // → es 56%
47+
("slim", true), // → tr 56%
48+
("stem", true), // → nl 55%
49+
("gn gn", true), // → id 54%
50+
("menu", true), // → id 54%
51+
("game over", true), // → nl 53%
52+
("general", true), // → es 51%
53+
("gm frens", true), // → ca 51%
54+
55+
// -- Additional short English (22 samples) --
56+
("good morning", true),
57+
("gm", true),
58+
("hello", true),
59+
("thanks", true),
60+
("let\u{2019}s go", true),
61+
("LFG", true),
62+
("GM", true),
63+
("GN", true),
64+
("It\u{2019}s a meme", true),
65+
("sure thing", true),
66+
("sounds good", true),
67+
("that\u{2019}s cool", true),
68+
("love this", true),
69+
("great post", true),
70+
("well said", true),
71+
("based", true),
72+
("this is the way", true),
73+
("facts", true),
74+
("probably nothing", true),
75+
("don\u{2019}t trust, verify", true),
76+
("have fun staying poor", true),
77+
("stack sats", true),
78+
79+
// -- Medium/longer English (20 samples) --
80+
("I just had the best coffee this morning", true),
81+
("Bitcoin is going to change the world", true),
82+
("Has anyone tried the new update?", true),
83+
("This is why I love Nostr", true),
84+
("The weather is beautiful today", true),
85+
("Just deployed a new version of my app", true),
86+
("Happy birthday! Hope you have a great day", true),
87+
("I\u{2019}ve been thinking about this for a while", true),
88+
("Check out this amazing sunset", true),
89+
("Bitcoin fixes this lol", true),
90+
("Not your keys, not your coins", true),
91+
("Does anyone else feel this way?", true),
92+
("I can\u{2019}t believe it\u{2019}s already March", true),
93+
("Nostr is the super app. Because it\u{2019}s actually an ecosystem of apps, all of which make each other better.", true),
94+
("I think the problem with social media is that it incentivizes outrage over thoughtful discussion.", true),
95+
("Just finished reading a great book about the history of cryptography. Highly recommend it.", true),
96+
("The best part about open protocols is that anyone can build on them without asking for permission.", true),
97+
("I spent the whole weekend working on this project and I\u{2019}m really happy with how it turned out.", true),
98+
("The internet was supposed to decentralize power but instead it concentrated it in a few companies.", true),
99+
("Running a relay is one of the most impactful things you can do for the Nostr network right now.", true),
100+
101+
// -- Short foreign texts (7 samples, all < 11 chars) --
102+
// These exercise the non-Latin script bypass in the short-text guard.
103+
("Привет", false), // Russian, 6 chars
104+
("こんにちは", false), // Japanese, 5 chars
105+
("你好", false), // Chinese, 2 chars
106+
("مرحبا", false), // Arabic, 5 chars
107+
("สวัสดี", false), // Thai, 6 chars
108+
("안녕", false), // Korean, 2 chars
109+
("Γεια", false), // Greek, 4 chars
110+
111+
// -- Foreign language texts (30 samples) --
112+
("Bonjour, comment allez-vous aujourd\u{2019}hui?", false),
113+
("Ich bin ein Berliner und ich liebe diese Stadt", false),
114+
("Buenas noches a todos mis amigos", false),
115+
("こんにちは、今日はいい天気ですね", false),
116+
("今天天气真好,出去走走吧", false),
117+
("Привет, как дела? Давно не виделись!", false),
118+
("오늘 날씨가 정말 좋네요", false),
119+
("สวัสดีครับ วันนี้อากาศดีมาก", false),
120+
("مرحبا، كيف حالك اليوم؟", false),
121+
("Bom dia! Tudo bem com você?", false),
122+
("Ciao, come stai? Tutto bene?", false),
123+
("Merhaba, bugün hava çok güzel", false),
124+
("Hej, hur mår du idag?", false),
125+
("Hei, hvordan har du det i dag?", false),
126+
("Tere, kuidas teil läheb?", false),
127+
("Γεια σας, πώς είστε σήμερα;", false),
128+
("Cześć, jak się masz dzisiaj?", false),
129+
("Ahoj, jak se máš dnes?", false),
130+
("Hallo, hoe gaat het met je?", false),
131+
("Xin chào, bạn khỏe không?", false),
132+
("La vie est belle quand on est libre", false),
133+
("Das Leben ist schön wenn man frei ist", false),
134+
("La vida es bella cuando eres libre", false),
135+
("人生は自由であるとき美しい", false),
136+
("Жизнь прекрасна, когда ты свободен", false),
137+
("인생은 자유로울 때 아름답다", false),
138+
("ชีวิตสวยงามเมื่อคุณเป็นอิสระ", false),
139+
("الحياة جميلة عندما تكون حرا", false),
140+
("A vida é bela quando se é livre", false),
141+
("La vita è bella quando sei libero", false),
142+
]
143+
144+
// MARK: - Locale pinning
145+
146+
private var savedLanguages: [Any]?
147+
148+
override func setUp() {
149+
super.setUp()
150+
savedLanguages = UserDefaults.standard.array(forKey: "AppleLanguages")
151+
UserDefaults.standard.set(["en"], forKey: "AppleLanguages")
152+
UserDefaults.standard.synchronize()
153+
}
154+
155+
override func tearDown() {
156+
if let saved = savedLanguages {
157+
UserDefaults.standard.set(saved, forKey: "AppleLanguages")
158+
} else {
159+
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
160+
}
161+
UserDefaults.standard.synchronize()
162+
super.tearDown()
163+
}
164+
165+
// MARK: - Tests
166+
167+
/// Regression test: English phrases must not be detected as foreign.
168+
/// This test FAILS before the fix and PASSES after.
169+
func testShortEnglishPhrasesNotDetectedAsForeign() throws {
170+
try XCTSkipUnless(
171+
localeToLanguage(Locale.current.identifier) == "en",
172+
"Test requires English locale"
173+
)
174+
let expectation = XCTestExpectation(description: "Language detection")
175+
176+
DispatchQueue.global().async {
177+
var failures: [(text: String, detected: String)] = []
178+
179+
for (text, isEnglish) in Self.sampleTexts {
180+
guard isEnglish else { continue }
181+
182+
guard let event = NostrEvent(
183+
content: text,
184+
keypair: test_keypair,
185+
createdAt: UInt32(Date().timeIntervalSince1970)
186+
) else {
187+
XCTFail("Could not create event for '\(text)'")
188+
continue
189+
}
190+
191+
let lang = event.note_language(test_keypair)
192+
193+
// English text must return "en" or nil, never a foreign language.
194+
// Returning a foreign language causes the "translate note" button
195+
// to appear, which is the bug we are fixing.
196+
if let lang, lang != "en" {
197+
failures.append((text: text, detected: lang))
198+
}
199+
}
200+
201+
if !failures.isEmpty {
202+
let report = failures.map { " '\($0.text)' → \($0.detected)" }.joined(separator: "\n")
203+
XCTFail("\(failures.count) English phrases falsely detected as foreign:\n\(report)")
204+
}
205+
206+
expectation.fulfill()
207+
}
208+
209+
wait(for: [expectation], timeout: 30)
210+
}
211+
212+
/// Foreign language texts must still be detected as non-English.
213+
func testForeignTextsDetectedCorrectly() {
214+
let expectation = XCTestExpectation(description: "Language detection")
215+
216+
DispatchQueue.global().async {
217+
var failures: [(text: String, detected: String?)] = []
218+
219+
for (text, isEnglish) in Self.sampleTexts {
220+
guard !isEnglish else { continue }
221+
222+
guard let event = NostrEvent(
223+
content: text,
224+
keypair: test_keypair,
225+
createdAt: UInt32(Date().timeIntervalSince1970)
226+
) else {
227+
XCTFail("Could not create event for '\(text)'")
228+
continue
229+
}
230+
231+
let lang = event.note_language(test_keypair)
232+
233+
// Foreign text should not be detected as English.
234+
if lang == "en" || lang == nil {
235+
failures.append((text: text, detected: lang))
236+
}
237+
}
238+
239+
if !failures.isEmpty {
240+
let report = failures.map { " '\($0.text)' → \($0.detected ?? "nil")" }.joined(separator: "\n")
241+
XCTFail("\(failures.count) foreign phrases incorrectly detected as English or nil:\n\(report)")
242+
}
243+
244+
expectation.fulfill()
245+
}
246+
247+
wait(for: [expectation], timeout: 30)
248+
}
249+
250+
/// Comparison report: prints old vs new detection side by side for all samples.
251+
func testLanguageDetectionComparison() throws {
252+
try XCTSkipUnless(
253+
localeToLanguage(Locale.current.identifier) == "en",
254+
"Test requires English locale"
255+
)
256+
let expectation = XCTestExpectation(description: "Comparison")
257+
258+
DispatchQueue.global().async {
259+
var lines: [String] = []
260+
lines.append("\n=== LANGUAGE DETECTION BEFORE/AFTER COMPARISON (100 notes) ===")
261+
lines.append(self.padRow("Text", "Expect", "Old", "New", "Hypotheses"))
262+
lines.append(String(repeating: "-", count: 110))
263+
264+
var falsePosBefore = 0
265+
var falsePosAfter = 0
266+
var falseNegBefore = 0
267+
var falseNegAfter = 0
268+
let currentLang = localeToLanguage(Locale.current.identifier) ?? "en"
269+
270+
for (text, isEnglish) in Self.sampleTexts {
271+
let recognizer = NLLanguageRecognizer()
272+
recognizer.processString(text)
273+
let hyps = recognizer.languageHypotheses(withMaximum: 3)
274+
275+
// Old logic: single top hypothesis >= 50%
276+
let oldResult: String? = {
277+
guard let top = hyps.max(by: { $0.value < $1.value }),
278+
top.value >= 0.5 else { return nil }
279+
return localeToLanguage(top.key.rawValue)
280+
}()
281+
282+
// New logic: call the actual shipped function
283+
let newResult: String? = {
284+
guard let event = NostrEvent(
285+
content: text,
286+
keypair: test_keypair,
287+
createdAt: UInt32(Date().timeIntervalSince1970)
288+
) else { return nil }
289+
return event.note_language(test_keypair)
290+
}()
291+
292+
let hypStr = hyps.sorted(by: { $0.value > $1.value })
293+
.map { "\(localeToLanguage($0.key.rawValue) ?? "?"):\(String(format: "%.0f%%", $0.value * 100))" }
294+
.joined(separator: " ")
295+
296+
let expectStr = isEnglish ? "en" : "other"
297+
let oldStr = oldResult ?? "nil"
298+
let newStr = newResult ?? "nil"
299+
let textCol = String(text.prefix(41))
300+
lines.append(self.padRow(textCol, expectStr, oldStr, newStr, hypStr))
301+
302+
if isEnglish {
303+
if let o = oldResult, o != "en" { falsePosBefore += 1 }
304+
if let n = newResult, n != "en" { falsePosAfter += 1 }
305+
} else {
306+
if oldResult == "en" || oldResult == nil { falseNegBefore += 1 }
307+
if newResult == "en" || newResult == nil { falseNegAfter += 1 }
308+
}
309+
}
310+
311+
lines.append("\n=== SUMMARY ===")
312+
lines.append("False positives (English wrongly detected as foreign): \(falsePosBefore) before → \(falsePosAfter) after")
313+
lines.append("False negatives (foreign wrongly detected as English/nil): \(falseNegBefore) before → \(falseNegAfter) after")
314+
lines.append("Total samples: \(Self.sampleTexts.count)")
315+
316+
for line in lines { print(line) }
317+
318+
XCTAssertEqual(falsePosAfter, 0, "Expected zero false positives after fix")
319+
XCTAssertEqual(falseNegAfter, 0, "Expected zero false negatives after fix")
320+
321+
expectation.fulfill()
322+
}
323+
324+
wait(for: [expectation], timeout: 30)
325+
}
326+
327+
private func padRow(_ text: String, _ expect: String, _ old: String, _ new: String, _ hyps: String) -> String {
328+
let col1 = text.padding(toLength: 42, withPad: " ", startingAt: 0)
329+
let col2 = expect.padding(toLength: 8, withPad: " ", startingAt: 0)
330+
let col3 = old.padding(toLength: 7, withPad: " ", startingAt: 0)
331+
let col4 = new.padding(toLength: 7, withPad: " ", startingAt: 0)
332+
return "\(col1) \(col2) \(col3) \(col4) \(hyps)"
333+
}
334+
}

0 commit comments

Comments
 (0)