diff --git a/CHANGELOG.md b/CHANGELOG.md index 534142f5a..7863df9f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ metadata and the backend fallback mirror it. ### Fixed +- Glossary auto-extraction preserves existing terms across Unicode case variants (#2638) — thanks @rudycelekli! + - MCP speech tools wait through model loading and progress-extended CPU renders instead of timing out before the backend (#2609) ## [0.5.7] — 2026-10-05 diff --git a/backend/api/routers/glossary.py b/backend/api/routers/glossary.py index 178c1a172..0ab78b220 100644 --- a/backend/api/routers/glossary.py +++ b/backend/api/routers/glossary.py @@ -270,22 +270,24 @@ def auto_extract(project_id: str, req: AutoExtractRequest): # Dedupe against existing (case-insensitive on source). with db_conn() as conn: existing = conn.execute( - "SELECT LOWER(source) AS src FROM glossary_terms WHERE project_id = ?", + "SELECT source FROM glossary_terms WHERE project_id = ?", (project_id,), ).fetchall() - existing_srcs = {r["src"] for r in existing} + # SQLite LOWER only folds ASCII in the bundled build; apply the same + # Unicode case folding to saved and proposed terms so manual entries win. + existing_srcs = {r["source"].casefold() for r in existing} inserted = 0 now = time.time() for src, tgt, note in proposed: - if src.lower() in existing_srcs: + if src.casefold() in existing_srcs: continue conn.execute( "INSERT INTO glossary_terms (id, project_id, source, target, note, auto, created_at) " "VALUES (?, ?, ?, ?, ?, 1, ?)", (str(uuid.uuid4())[:12], project_id, src, tgt, note, now), ) - existing_srcs.add(src.lower()) + existing_srcs.add(src.casefold()) inserted += 1 rows = conn.execute( diff --git a/docs/dubbing/translation-engines.md b/docs/dubbing/translation-engines.md index 090effae9..b1b57700c 100644 --- a/docs/dubbing/translation-engines.md +++ b/docs/dubbing/translation-engines.md @@ -292,3 +292,7 @@ Pasted, picked, and downloaded captions, and SubRip files imported into Stories, Downloaded rolling captions are deduplicated across their brief repeated bridge cues before translation. Ordinary consecutive cues keep intentional repeated words and phrases, even when their timestamps touch. New WebVTT imports retain source cue syntax alongside decoded text, preserving literal escaped tags and entities on unchanged exports. Edited text and older projects retain the legacy markup interpretation. + +### Project glossary deduplication + +Glossary auto-extraction keeps an existing manual or automatic source term when the proposal differs only in Unicode letter case, including accented Latin and Cyrillic names. Comparison keys use Unicode case folding, so `Straße`/`STRASSE` and Greek sigma variants also match; the saved source spelling and human target remain unchanged. Deduplication is scoped to the current project, so a term saved in another project does not suppress a new proposal. diff --git a/tests/test_glossary_scrub.py b/tests/test_glossary_scrub.py index 2d0211917..7d3399d04 100644 --- a/tests/test_glossary_scrub.py +++ b/tests/test_glossary_scrub.py @@ -102,3 +102,75 @@ class _Handle: out = glossary.auto_extract("proj-reasoning", _req(target_lang="es", segments=[{"text": "Hello Marcus"}])) assert out["proposed"] == 1 assert [t["target"] for t in out["terms"] if t["source"] == "Marcus"] == ["Marcus"] + + +@pytest.mark.parametrize("source, proposed_source", [ + ("École", "École"), + ("ÉCOLE", "école"), + ("МОСКВА", "Москва"), + ("München", "MÜNCHEN"), + ("Marcus", "MARCUS"), + ("Straße", "STRASSE"), + ("STRASSE", "Straße"), + ("ΟΣ", "οσ"), + ("οσ", "ος"), +]) +def test_auto_extract_preserves_existing_unicode_term(monkeypatch, tmp_path, source, proposed_source): + """Both sides of source deduplication must use Unicode-aware casing.""" + import sqlite3 + from types import SimpleNamespace + from api.routers import glossary + from core import db + from services import llm_skills + + path = tmp_path / "glossary.db" + with sqlite3.connect(path) as conn: + conn.executescript(db._BASE_SCHEMA) + monkeypatch.setattr(db, "DB_PATH", path) + original = glossary.add_term("project", glossary.GlossaryTerm( + source=source, target="manual choice", note="keep this")) + other = glossary.add_term("another-project", glossary.GlossaryTerm( + source="Rudy", target="other project")) + body = f"{proposed_source} || automatic choice || candidate\nRudy || Rudy || name\n" + completion = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=body))]) + handle = SimpleNamespace(model="test", timeout=1, client=SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kwargs: completion)))) + monkeypatch.setattr(llm_skills, "resolve_skill_client", lambda sid: handle) + + result = glossary.auto_extract("project", _req(target_lang="en", segments=[{"text": source}])) + + assert result["proposed"] == 2 + assert result["inserted"] == 1 + terms = {term["source"]: term for term in result["terms"]} + assert set(terms) == {source, "Rudy"} + assert terms[source] == original + assert terms[source]["auto"] is False + assert terms["Rudy"]["auto"] is True + assert glossary.list_terms("another-project") == [other] + + +@pytest.mark.parametrize("first, second", [("Straße", "STRASSE"), ("οσ", "ος")]) +def test_auto_extract_deduplicates_unicode_variants_in_same_batch(monkeypatch, tmp_path, first, second): + import sqlite3 + from types import SimpleNamespace + from api.routers import glossary + from core import db + from services import llm_skills + + path = tmp_path / "glossary.db" + with sqlite3.connect(path) as conn: + conn.executescript(db._BASE_SCHEMA) + monkeypatch.setattr(db, "DB_PATH", path) + body = f"{first} || first choice || term\n{second} || second choice || variant\n" + completion = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=body))]) + handle = SimpleNamespace(model="test", timeout=1, client=SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kwargs: completion)))) + monkeypatch.setattr(llm_skills, "resolve_skill_client", lambda sid: handle) + + result = glossary.auto_extract("project", _req(target_lang="en", segments=[{"text": first}])) + + assert result["proposed"] == 2 + assert result["inserted"] == 1 + assert len(result["terms"]) == 1 + assert result["terms"][0]["source"] == first + assert result["terms"][0]["target"] == "first choice"