Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions backend/api/routers/glossary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions docs/dubbing/translation-engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
72 changes: 72 additions & 0 deletions tests/test_glossary_scrub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading