SurfSense/surfsense_backend/app/services/language_detector.py
Ojārs Kapteinis fdef50e78d feat: Implement local-first European AI architecture with Mistral NeMo and TildeOpen
- Add three-tier LLM architecture (Mistral NeMo, TildeOpen, Gemini fallback)
- Fix context window handling for mistral-nemo (128K tokens)
- Add LiteLLM context override to prevent 1M token bug
- Remove Google Analytics tracking from frontend
- Add migration and installation documentation
- Optimize for CPU-only inference on 32GB RAM servers

Performance improvements:
- 95% reduction in API costs
- Response times: 5-25 seconds (down from 30-120s)
- Better Latvian language quality with TildeOpen
- Eliminated timeout errors

Architecture changes:
- Primary: Mistral NeMo 12B (France, local via Ollama)
- Grammar: TildeOpen 30B (Latvia, local via Ollama)
- Fallback: Gemini 2.0 Flash (Google API, emergency only)

Technical fixes:
- Fixed LiteLLM reporting incorrect 1M token context (actual: 128K)
- Created mistral-nemo:128k model with proper num_ctx parameter
- Added context window override in backend utils
- Comprehensive security patterns in .gitignore

Documentation:
- MIGRATION_LOCAL_LLM.md: Complete architecture and history
- INSTALLATION_LOCAL_LLM.md: Step-by-step deployment guide
- PR_DESCRIPTION.md: Detailed PR description
- sync-from-production.sh: Secure deployment script

Tested on production at https://ai.kapteinis.lv since November 17, 2025.

Co-authored-by: Ojārs Kapteiņš <ojars@kapteinis.lv>
Co-authored-by: Claude AI Assistant <code@anthropic.com>
2025-11-17 19:58:20 +02:00

261 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Language detection service for European languages.
Detects which of the 34 European languages supported by TildeOpen a text is written in.
"""
import re
from typing import Optional
# Language detection patterns
# Each language has common words and character patterns
LANGUAGE_PATTERNS = {
# Baltic languages
"lv": { # Latvian
"name": "Latvian",
"keywords": ["un", "ir", "var", "kas", "ar", "par", "no", "uz", "vai", "", "bet", "", "", "es", "tu", "viņš", "mēs"],
"chars": "āčēģīķļņšūž",
},
"lt": { # Lithuanian
"name": "Lithuanian",
"keywords": ["ir", "kad", "su", "kas", "yra", "bet", "tai", "jo", "ar", "į", "", "per", "taip", "ne", "", "tu"],
"chars": "ąčęėįšųūž",
},
"et": { # Estonian
"name": "Estonian",
"keywords": ["ja", "on", "ei", "see", "kas", "või", "kui", "et", "mis", "kes", "ma", "sa", "ta", "me", "te", "nad"],
"chars": "äöüõšž",
},
# Slavic languages
"pl": { # Polish
"name": "Polish",
"keywords": ["i", "w", "na", "z", "do", "nie", "się", "jest", "to", "o", "że", "ale", "jak", "czy", "tak", "co"],
"chars": "ąćęłńóśźż",
},
"cs": { # Czech
"name": "Czech",
"keywords": ["a", "je", "v", "na", "se", "to", "z", "o", "že", "s", "pro", "není", "jak", "ale", "jsem", "být"],
"chars": "áčďéěíňóřšťúůýž",
},
"sk": { # Slovak
"name": "Slovak",
"keywords": ["a", "je", "v", "na", "sa", "to", "z", "o", "že", "s", "ako", "nie", "by", "som", "ale", "pre"],
"chars": "áäčďéíľňóôŕšťúýž",
},
"sl": { # Slovenian
"name": "Slovenian",
"keywords": ["in", "je", "v", "na", "z", "da", "se", "ne", "s", "o", "ki", "so", "za", "ali", "kot", "pa"],
"chars": "čšž",
},
"hr": { # Croatian
"name": "Croatian",
"keywords": ["i", "u", "je", "na", "se", "da", "s", "za", "ne", "o", "su", "kao", "iz", "ili", "biti", "koji"],
"chars": "čćđšž",
},
"sr": { # Serbian
"name": "Serbian",
"keywords": ["i", "u", "je", "na", "se", "da", "s", "za", "ne", "o", "su", "kao", "iz", "ili", "biti", "koji"],
"chars": "čćđšž",
},
"bs": { # Bosnian
"name": "Bosnian",
"keywords": ["i", "u", "je", "na", "se", "da", "s", "za", "ne", "o", "su", "kao", "iz", "ili", "biti", "koji"],
"chars": "čćđšž",
},
"bg": { # Bulgarian
"name": "Bulgarian",
"keywords": ["и", "в", "на", "е", "не", "за", "да", "с", "от", "се", "че", "като", "то", "са", "по", "но"],
"chars": "абвгдежзийклмнопрстуфхцчшщъьюя",
},
"mk": { # Macedonian
"name": "Macedonian",
"keywords": ["и", "во", "на", "е", "не", "за", "да", "со", "од", "се", "што", "како", "тоа", "се", "по", "но"],
"chars": "абвгдѓежзѕијклљмнњопрстќуфхцчџш",
},
"ru": { # Russian
"name": "Russian",
"keywords": ["и", "в", "не", "на", "я", "что", "он", "с", "а", "как", "это", "то", "все", "она", "так", "его"],
"chars": "абвгдеёжзийклмнопрстуфхцчшщъыьэюя",
},
"uk": { # Ukrainian
"name": "Ukrainian",
"keywords": ["і", "в", "не", "на", "що", "з", "у", "та", "він", "це", "як", "до", "за", "я", "по", "але"],
"chars": "абвгґдеєжзиіїйклмнопрстуфхцчшщьюя",
},
# Romance languages
"ro": { # Romanian
"name": "Romanian",
"keywords": ["și", "în", "de", "la", "cu", "a", "pentru", "ce", "este", "", "din", "pe", "nu", "sau", "dar", "mai"],
"chars": "ăâîșț",
},
"it": { # Italian
"name": "Italian",
"keywords": ["e", "di", "il", "la", "che", "a", "è", "per", "in", "un", "una", "non", "con", "le", "si", "da"],
"chars": "àèéìòù",
},
"es": { # Spanish
"name": "Spanish",
"keywords": ["y", "de", "el", "la", "que", "a", "en", "es", "un", "por", "con", "no", "una", "para", "los", "se"],
"chars": "áéíñóú",
},
"pt": { # Portuguese
"name": "Portuguese",
"keywords": ["e", "de", "o", "a", "que", "é", "do", "da", "em", "um", "para", "com", "não", "uma", "os", "no"],
"chars": "ãáàâçéêíóôõú",
},
"fr": { # French
"name": "French",
"keywords": ["et", "de", "le", "la", "à", "un", "une", "est", "en", "que", "pour", "dans", "ce", "il", "qui", "ne"],
"chars": "àâçéèêëîïôùûü",
},
# Germanic languages
"de": { # German
"name": "German",
"keywords": ["und", "der", "die", "das", "in", "ist", "zu", "den", "mit", "von", "ein", "eine", "nicht", "sich", "auf", "für"],
"chars": "äöüß",
},
"nl": { # Dutch
"name": "Dutch",
"keywords": ["de", "het", "een", "van", "en", "in", "is", "dat", "op", "te", "voor", "met", "niet", "zijn", "aan", "er"],
"chars": "ëïé",
},
"sv": { # Swedish
"name": "Swedish",
"keywords": ["och", "i", "att", "det", "som", "är", "en", "", "för", "av", "med", "till", "den", "ett", "har", "om"],
"chars": "åäö",
},
"da": { # Danish
"name": "Danish",
"keywords": ["og", "i", "at", "det", "er", "en", "til", "", "som", "af", "for", "med", "ikke", "har", "den", "de"],
"chars": "æøå",
},
"no": { # Norwegian
"name": "Norwegian",
"keywords": ["og", "i", "det", "er", "til", "en", "", "som", "at", "av", "for", "med", "ikke", "har", "den", "de"],
"chars": "æøå",
},
"is": { # Icelandic
"name": "Icelandic",
"keywords": ["og", "í", "", "er", "sem", "á", "til", "um", "með", "fyrir", "ekki", "en", "það", "við", "af", "var"],
"chars": "áðéíóúýþæö",
},
# Finno-Ugric languages
"fi": { # Finnish
"name": "Finnish",
"keywords": ["ja", "on", "ei", "se", "että", "oli", "olla", "joka", "mutta", "tai", "kun", "vain", "niin", "kuin", "jos", "hän"],
"chars": "äö",
},
"hu": { # Hungarian
"name": "Hungarian",
"keywords": ["a", "az", "és", "is", "volt", "van", "hogy", "nem", "egy", "mint", "azt", "már", "csak", "még", "ami", "volt"],
"chars": "áéíóöőúüű",
},
# Greek
"el": { # Greek
"name": "Greek",
"keywords": ["και", "το", "της", "την", "του", "στο", "με", "για", "από", "που", "είναι", "στη", "στην", "ότι", "δεν", "τα"],
"chars": "αβγδεζηθικλμνξοπρστυφχψω",
},
# Maltese
"mt": { # Maltese
"name": "Maltese",
"keywords": ["u", "li", "ta", "fl", "għal", "ma", "il", "tal", "f", "b", "minn", "jew", "kif", "bħal", "meta", "għax"],
"chars": "ċġħż",
},
# Turkish
"tr": { # Turkish
"name": "Turkish",
"keywords": ["ve", "bir", "bu", "da", "de", "ile", "için", "mi", "ne", "var", "daha", "olarak", "çok", "gibi", "ancak", "ya"],
"chars": "çğıöşü",
},
# Albanian
"sq": { # Albanian
"name": "Albanian",
"keywords": ["dhe", "i", "", "e", "", "për", "me", "", "një", "nga", "është", "si", "por", "ka", "u", "nga"],
"chars": "çë",
},
# English (for completeness, but won't trigger grammar check)
"en": { # English
"name": "English",
"keywords": ["the", "and", "is", "to", "of", "a", "in", "that", "it", "was", "for", "on", "are", "with", "as", "be"],
"chars": "",
},
}
def detect_language(text: str) -> Optional[str]:
"""
Detect which European language a text is written in.
Args:
text: The text to analyze
Returns:
ISO 639-1 language code (e.g., 'lv', 'et', 'pl') or None if no language detected
"""
if not text or len(text) < 10:
return None
# Require at least 3 words
words = re.findall(r'\w+', text.lower())
if len(words) < 3:
return None
text_lower = text.lower()
# Score each language
scores = {}
for lang_code, lang_data in LANGUAGE_PATTERNS.items():
score = 0
# Check for special characters
if lang_data["chars"]:
for char in lang_data["chars"]:
if char in text_lower:
score += 3 # Special chars are strong indicators
# Check for common keywords
keyword_matches = 0
for keyword in lang_data["keywords"]:
# Count occurrences as whole words
pattern = r'\b' + re.escape(keyword) + r'\b'
matches = len(re.findall(pattern, text_lower))
if matches > 0:
keyword_matches += 1
score += matches * 2
# Require at least 2 keyword matches for short texts or 3 for longer
min_keyword_matches = 2 if len(words) < 20 else 3
if keyword_matches < min_keyword_matches:
score = 0
scores[lang_code] = score
# Find the highest scoring language
if not scores:
return None
max_score = max(scores.values())
if max_score == 0:
return None
# Get the language with highest score
detected_lang = max(scores.items(), key=lambda x: x[1])[0]
return detected_lang
def get_language_name(lang_code: str) -> str:
"""
Get the full name of a language from its code.
Args:
lang_code: ISO 639-1 language code
Returns:
Full language name or the code if not found
"""
if lang_code in LANGUAGE_PATTERNS:
return LANGUAGE_PATTERNS[lang_code]["name"]
return lang_code