Skip to content

Commit ac0c7cd

Browse files
PathKnowerclaude
andcommitted
fix(transcription): key the cache by engine, not just by message
A cached telegram transcript was answering groq requests. The lookup ran before the engine was resolved and matched any source, so an explicit engine=groq silently returned the native text - and the native engine drops the recording's last speech segment in roughly 2 of 3 recordings, a loss that is invisible in the text itself. The cache is now keyed by (chat_id, message_id, source) and the tool resolves the engine before looking anything up. Unpinned callers (listing render, backfill skip check) still accept any source and prefer the default engine, so display and quota behaviour are unchanged. Existing caches are rebuilt in place on first use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 83332e0 commit ac0c7cd

3 files changed

Lines changed: 125 additions & 14 deletions

File tree

telegram_mcp/tools/messages.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,9 +1021,11 @@ async def transcribe_voice(
10211021
Requires Telegram Premium on this account; polls briefly (up to ~20s)
10221022
while Telegram finishes a long recording.
10231023
1024-
Results are cached by (chat_id, message_id) - a repeat call for an
1025-
already-transcribed message returns the cached text without hitting
1026-
either API again.
1024+
Results are cached per engine, by (chat_id, message_id, engine) - a
1025+
repeat call with the same engine returns the cached text without
1026+
hitting either API again. Asking for an engine that has no cached
1027+
result transcribes with it, even when the other engine's text is
1028+
already cached.
10271029
10281030
The returned text is a machine transcript, not a verbatim quote: proper
10291031
names, punctuation and occasional words drift under both engines.
@@ -1045,7 +1047,16 @@ async def transcribe_voice(
10451047
entity = await resolve_entity(chat_id, cl)
10461048
numeric_chat_id = get_marked_id(entity)
10471049

1048-
cached = transcription.get_cached_transcript(numeric_chat_id, message_id)
1050+
chosen_engine = (engine or transcription.default_engine()).strip().lower()
1051+
if chosen_engine not in transcription.ENGINES:
1052+
return f"Invalid engine '{engine}'. Use 'telegram' or 'groq'."
1053+
1054+
# Pinned to the chosen engine on purpose: a cached telegram transcript
1055+
# must not answer a groq request. The native engine drops the last
1056+
# speech segment and the loss cannot be seen in the text.
1057+
cached = transcription.get_cached_transcript(
1058+
numeric_chat_id, message_id, source=chosen_engine
1059+
)
10491060
if cached is not None:
10501061
return json.dumps(
10511062
{
@@ -1066,9 +1077,6 @@ async def transcribe_voice(
10661077
if not transcription.is_transcribable(msg):
10671078
return f"Message {message_id} has no voice message or video note to transcribe."
10681079

1069-
chosen_engine = (engine or transcription.default_engine()).strip().lower()
1070-
if chosen_engine not in transcription.ENGINES:
1071-
return f"Invalid engine '{engine}'. Use 'telegram' or 'groq'."
10721080
if chosen_engine == "groq" and not os.getenv("GROQ_API_KEY"):
10731081
return (
10741082
"GROQ_API_KEY is not configured on this server. "

telegram_mcp/transcription.py

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,11 @@ def _connect() -> sqlite3.Connection:
118118
duration INTEGER,
119119
lang TEXT,
120120
created_at TEXT NOT NULL,
121-
PRIMARY KEY (chat_id, message_id)
121+
PRIMARY KEY (chat_id, message_id, source)
122122
)
123123
"""
124124
)
125+
_migrate_source_into_key(conn)
125126
conn.commit()
126127
try:
127128
os.chmod(path, 0o600)
@@ -130,14 +131,65 @@ def _connect() -> sqlite3.Connection:
130131
return conn
131132

132133

133-
def get_cached_transcript(chat_id: int, message_id: int) -> Optional[dict]:
134+
def _migrate_source_into_key(conn: sqlite3.Connection) -> None:
135+
"""Older builds keyed the cache on (chat_id, message_id) alone, so a cheap
136+
telegram transcript permanently shadowed the groq one - including for
137+
callers that asked for groq explicitly. Rebuild such a table in place."""
138+
row = conn.execute(
139+
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'transcripts'"
140+
).fetchone()
141+
if not row or not row[0]:
142+
return
143+
if "PRIMARY KEY (chat_id, message_id, source)" in row[0]:
144+
return
145+
conn.executescript(
146+
"""
147+
ALTER TABLE transcripts RENAME TO transcripts_legacy;
148+
CREATE TABLE transcripts (
149+
chat_id INTEGER NOT NULL,
150+
message_id INTEGER NOT NULL,
151+
source TEXT NOT NULL,
152+
text TEXT NOT NULL,
153+
duration INTEGER,
154+
lang TEXT,
155+
created_at TEXT NOT NULL,
156+
PRIMARY KEY (chat_id, message_id, source)
157+
);
158+
INSERT OR IGNORE INTO transcripts
159+
(chat_id, message_id, source, text, duration, lang, created_at)
160+
SELECT chat_id, message_id, source, text, duration, lang, created_at
161+
FROM transcripts_legacy;
162+
DROP TABLE transcripts_legacy;
163+
"""
164+
)
165+
166+
167+
def get_cached_transcript(
168+
chat_id: int, message_id: int, source: Optional[str] = None
169+
) -> Optional[dict]:
170+
"""Cached transcript for a message.
171+
172+
``source`` pins the engine: asking for groq must never be answered with a
173+
telegram transcript, because the native engine drops the recording's last
174+
segment and the loss is invisible in the text. Callers that only want to
175+
display whatever exists (listings, backfill skip checks) pass None and get
176+
the default engine's row when there is one, any row otherwise.
177+
"""
134178
conn = _connect()
135179
try:
136-
row = conn.execute(
137-
"SELECT source, text, duration, lang, created_at FROM transcripts "
138-
"WHERE chat_id = ? AND message_id = ?",
139-
(chat_id, message_id),
140-
).fetchone()
180+
if source is not None:
181+
row = conn.execute(
182+
"SELECT source, text, duration, lang, created_at FROM transcripts "
183+
"WHERE chat_id = ? AND message_id = ? AND source = ?",
184+
(chat_id, message_id, source),
185+
).fetchone()
186+
else:
187+
row = conn.execute(
188+
"SELECT source, text, duration, lang, created_at FROM transcripts "
189+
"WHERE chat_id = ? AND message_id = ? "
190+
"ORDER BY source = ? DESC, created_at DESC LIMIT 1",
191+
(chat_id, message_id, default_engine()),
192+
).fetchone()
141193
finally:
142194
conn.close()
143195
if row is None:

tests/test_transcription.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,57 @@ def test_cache_keyed_by_chat_and_message_independently(transcript_cache_dir):
107107
assert transcription.get_cached_transcript(1, 999) is None
108108

109109

110+
def test_cache_pinned_to_an_engine_never_serves_the_other_engines_text(transcript_cache_dir):
111+
"""The whole point of choosing groq is that the native engine drops the
112+
recording's last segment. A telegram transcript answering a groq request
113+
would hand back that truncated text with no way to tell."""
114+
transcription.save_transcript(1, 2, "telegram", "truncated tail")
115+
assert transcription.get_cached_transcript(1, 2, source="groq") is None
116+
assert transcription.get_cached_transcript(1, 2, source="telegram")["text"] == (
117+
"truncated tail"
118+
)
119+
120+
121+
def test_cache_keeps_both_engines_side_by_side(transcript_cache_dir):
122+
transcription.save_transcript(1, 2, "telegram", "native text")
123+
transcription.save_transcript(1, 2, "groq", "groq text")
124+
assert transcription.get_cached_transcript(1, 2, source="telegram")["text"] == "native text"
125+
assert transcription.get_cached_transcript(1, 2, source="groq")["text"] == "groq text"
126+
127+
128+
def test_legacy_cache_keyed_without_engine_is_migrated_in_place(transcript_cache_dir):
129+
"""Builds before the fix keyed on (chat_id, message_id) alone. Rows must
130+
survive the rebuild, and the pinned lookup must start working on them."""
131+
import sqlite3
132+
133+
path = transcription._cache_db_path()
134+
path.parent.mkdir(parents=True, exist_ok=True)
135+
conn = sqlite3.connect(str(path))
136+
conn.executescript(
137+
"""
138+
CREATE TABLE transcripts (
139+
chat_id INTEGER NOT NULL,
140+
message_id INTEGER NOT NULL,
141+
source TEXT NOT NULL,
142+
text TEXT NOT NULL,
143+
duration INTEGER,
144+
lang TEXT,
145+
created_at TEXT NOT NULL,
146+
PRIMARY KEY (chat_id, message_id)
147+
);
148+
INSERT INTO transcripts VALUES (1, 2, 'telegram', 'old row', 23, 'ru', '2026-08-20');
149+
"""
150+
)
151+
conn.commit()
152+
conn.close()
153+
154+
assert transcription.get_cached_transcript(1, 2, source="telegram")["text"] == "old row"
155+
assert transcription.get_cached_transcript(1, 2, source="groq") is None
156+
transcription.save_transcript(1, 2, "groq", "new row")
157+
assert transcription.get_cached_transcript(1, 2, source="groq")["text"] == "new row"
158+
assert transcription.get_cached_transcript(1, 2, source="telegram")["text"] == "old row"
159+
160+
110161
@pytest.mark.skipif(os.name == "nt", reason="POSIX permissions only")
111162
def test_cache_file_and_directory_get_restrictive_permissions(transcript_cache_dir):
112163
transcription.save_transcript(1, 2, "groq", "hi")

0 commit comments

Comments
 (0)