Fix: Topic word labels translate to selected language

Topics API now accepts lang= parameter. When lang=de, the word
labels are translated from English via Kaikki translations:
"eye, pupil, iris" → "Auge, Pupille, Iris"

Frontend sends searchLang to /topics endpoint and displays
display_words (translated) instead of words (English).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-29 12:38:35 +02:00
parent 6f43224fda
commit 387219682d
2 changed files with 40 additions and 9 deletions

View File

@@ -432,11 +432,15 @@ async def api_enrich_images(word_ids: List[str] = None):
@router.get("/topics") @router.get("/topics")
async def api_get_topics(q: str = Query("", description="Search topic or word")): async def api_get_topics(
q: str = Query("", description="Search topic or word"),
lang: str = Query("en", description="Display language for word labels"),
):
"""Find topics matching a search word. Returns related word lists. """Find topics matching a search word. Returns related word lists.
If q matches a topic name → returns that topic. If q matches a topic name → returns that topic.
If q matches a word in any topic → returns all topics containing that word. If q matches a word in any topic → returns all topics containing that word.
Words are returned with translations if lang != en.
""" """
from vocabulary.db import get_pool from vocabulary.db import get_pool
pool = await get_pool() pool = await get_pool()
@@ -451,10 +455,37 @@ async def api_get_topics(q: str = Query("", description="Search topic or word"))
ORDER BY word_count DESC ORDER BY word_count DESC
""", f"%{q_lower}%", q_lower) """, f"%{q_lower}%", q_lower)
return { # Translate word labels if not English
"topics": [{"topic": r["topic"], "words": list(r["words"]), "word_count": r["word_count"]} for r in rows], topics = []
"query": q, for r in rows:
} en_words = list(r["words"])
display_words = en_words
if lang != "en":
# Batch-lookup translations from Kaikki
translated = []
for w in en_words[:20]: # Limit to 20 for speed
tr_row = await conn.fetchrow(
"SELECT translations FROM vocabulary_kaikki WHERE lang = 'en' AND lower(word) = $1 LIMIT 1",
w.lower(),
)
if tr_row and tr_row["translations"]:
import json as _json
tr = tr_row["translations"]
if isinstance(tr, str):
tr = _json.loads(tr)
tr_text = tr.get(lang, {}).get("text", "")
translated.append(tr_text if tr_text else w)
else:
translated.append(w)
display_words = translated + en_words[20:]
topics.append({
"topic": r["topic"],
"words": en_words,
"display_words": display_words,
"word_count": r["word_count"],
})
return {"topics": topics, "query": q, "lang": lang}
class TranslateRequest(BaseModel): class TranslateRequest(BaseModel):

View File

@@ -38,7 +38,7 @@ export default function VocabularyPage() {
const [posFilter, setPosFilter] = useState('') const [posFilter, setPosFilter] = useState('')
const [diffFilter, setDiffFilter] = useState(0) const [diffFilter, setDiffFilter] = useState(0)
const [searchLang, setSearchLang] = useState('en') const [searchLang, setSearchLang] = useState('en')
const [topics, setTopics] = useState<{ topic: string; words: string[]; word_count: number }[]>([]) const [topics, setTopics] = useState<{ topic: string; words: string[]; display_words?: string[]; word_count: number }[]>([])
const [showTopics, setShowTopics] = useState(false) const [showTopics, setShowTopics] = useState(false)
// Unit builder // Unit builder
@@ -89,7 +89,7 @@ export default function VocabularyPage() {
// Also search for matching topics // Also search for matching topics
if (query.trim()) { if (query.trim()) {
const topicResp = await fetch(`${getApiBase()}/api/vocabulary/topics?q=${encodeURIComponent(query)}`) const topicResp = await fetch(`${getApiBase()}/api/vocabulary/topics?q=${encodeURIComponent(query)}&lang=${searchLang}`)
if (topicResp.ok) { if (topicResp.ok) {
const topicData = await topicResp.json() const topicData = await topicResp.json()
setTopics(topicData.topics || []) setTopics(topicData.topics || [])
@@ -259,8 +259,8 @@ export default function VocabularyPage() {
</button> </button>
</div> </div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{topic.words.slice(0, 15).map(w => ( {(topic.display_words || topic.words).slice(0, 15).map((w: string, i: number) => (
<span key={w} className={`text-xs px-2 py-0.5 rounded-full ${isDark ? 'bg-white/5 text-white/50' : 'bg-slate-100 text-slate-500'}`}>{w}</span> <span key={i} className={`text-xs px-2 py-0.5 rounded-full ${isDark ? 'bg-white/5 text-white/50' : 'bg-slate-100 text-slate-500'}`}>{w}</span>
))} ))}
{topic.words.length > 15 && ( {topic.words.length > 15 && (
<span className={`text-xs px-2 py-0.5 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>+{topic.words.length - 15}</span> <span className={`text-xs px-2 py-0.5 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>+{topic.words.length - 15}</span>