From 2e7f7a9126348d469d26e3c3646e846a852f47c5 Mon Sep 17 00:00:00 2001 From: daopunk Date: Mon, 27 Jul 2026 00:42:09 -0500 Subject: [PATCH] docs: fit in-chat translation into product suite --- .agents/docs/DEVELOPMENT.md | 8 +- AGENTS.md | 2 + README.md | 4 +- .../src/commands/translate_service.rs | 104 +------------- crates/signal-bot/src/commands/voice.rs | 128 +----------------- docker/phala.translation.yaml | 2 +- docs/in-chat-translation.md | 53 ++++++++ docs/language-threads.md | 2 +- docs/parallel-translation.md | 2 +- docs/two-cvm-architecture.md | 12 +- 10 files changed, 78 insertions(+), 239 deletions(-) create mode 100644 docs/in-chat-translation.md diff --git a/.agents/docs/DEVELOPMENT.md b/.agents/docs/DEVELOPMENT.md index 8a3e882..fa0de40 100644 --- a/.agents/docs/DEVELOPMENT.md +++ b/.agents/docs/DEVELOPMENT.md @@ -5,11 +5,11 @@ Interoperable Signal products (see [issue #10](https://github.com/BreadchainCoop/sigstack-bot/issues/10)): 1. **Voice transcription** — own CVM (`BOT__ROLE=transcription`) -2. **In-chat group translation** — translation CVM (`BOT__ROLE=translation`) -3. **Parallel Translation** — planned on translation CVM -4. **Language Threads** — translation CVM (see [`docs/language-threads.md`](../docs/language-threads.md)) +2. **In-chat group translation** — translation CVM (see [`docs/in-chat-translation.md`](../../docs/in-chat-translation.md)) +3. **Parallel Translation** — translation CVM MVP (see [`docs/parallel-translation.md`](../../docs/parallel-translation.md)) +4. **Language Threads** — translation CVM (see [`docs/language-threads.md`](../../docs/language-threads.md)) -Architecture overview: [`docs/two-cvm-architecture.md`](../docs/two-cvm-architecture.md). +Architecture overview: [`docs/two-cvm-architecture.md`](../../docs/two-cvm-architecture.md). Fork legacy removed: general AI chat, tool use (`crates/tools`), x402 payments, in-memory conversation store. diff --git a/AGENTS.md b/AGENTS.md index 249e23f..5e462e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,8 @@ docker compose -f docker/compose.translation.yaml --env-file docker/translation. |-----|-----| | [`.agents/docs/DEVELOPMENT.md`](.agents/docs/DEVELOPMENT.md) | TEE trust model, `BOT__ROLE`, Phala dual-CVM ops | | [`docs/two-cvm-architecture.md`](docs/two-cvm-architecture.md) | Architecture diagram and compose/Phala split | +| [`docs/in-chat-translation.md`](docs/in-chat-translation.md) | In-chat (group) bilingual auto/manual translate | +| [`docs/parallel-translation.md`](docs/parallel-translation.md) | Parallel Translation product behavior | | [`docs/language-threads.md`](docs/language-threads.md) | Language Threads product behavior | | [`.agents/skills/`](.agents/skills/) | Vendored skills (Rust, Docker, Stripe) | | [`.agents/rules/`](.agents/rules/) | Agent rules (e.g. commit message subjects) | diff --git a/README.md b/README.md index 828ee9e..554654a 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ Not a general AI chat assistant. Conversation history, tool-calling, and x402 cr | Voice transcription | `BOT__ROLE=transcription` | Own CVM / Compose stack (with Whisper) | | In-chat (group) translation | `BOT__ROLE=translation` | Shared translation CVM | | Language Threads | `BOT__ROLE=translation` | Shared translation CVM | -| Parallel Translation | planned | Shared translation CVM | +| Parallel Translation | `BOT__ROLE=translation` (MVP) | Shared translation CVM | Pair products by adding **both bots** (two phone numbers) to the same Signal group. Signal is the bus — there is no Docker network between CVMs. -Details: [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md) · [docs/language-threads.md](docs/language-threads.md) +Details: [docs/two-cvm-architecture.md](docs/two-cvm-architecture.md) · [docs/in-chat-translation.md](docs/in-chat-translation.md) · [docs/parallel-translation.md](docs/parallel-translation.md) · [docs/language-threads.md](docs/language-threads.md) ## Architecture diff --git a/crates/signal-bot/src/commands/translate_service.rs b/crates/signal-bot/src/commands/translate_service.rs index 2195510..4236764 100644 --- a/crates/signal-bot/src/commands/translate_service.rs +++ b/crates/signal-bot/src/commands/translate_service.rs @@ -8,25 +8,6 @@ use whatlang::Lang; const MIN_DETECT_CONFIDENCE: f64 = 0.2; -/// Resolve source/target languages for `!translate-on` on a voice note. -pub fn resolve_translate_all_voice_pair( - mode: &GroupTranslateMode, - whisper_lang: Option<&str>, - transcript: &str, -) -> Option<(&'static Language, &'static Language)> { - for code in voice_language_candidates(whisper_lang, transcript) { - if let Some(normalized) = normalize_for_translate_all_pair(mode, &code) { - if let (Some(target), Some(source)) = ( - mode.target_for_source(&normalized), - mode.source_language(&normalized), - ) { - return Some((source, target)); - } - } - } - None -} - /// Map a detected code into one side of the active pair when possible. fn normalize_for_translate_all_pair(mode: &GroupTranslateMode, code: &str) -> Option { let code = code.to_lowercase(); @@ -43,27 +24,7 @@ fn normalize_for_translate_all_pair(mode: &GroupTranslateMode, code: &str) -> Op None } -fn voice_language_candidates(whisper_lang: Option<&str>, transcript: &str) -> Vec { - let mut codes = Vec::new(); - let mut push = |code: &str| { - if !codes.iter().any(|c| c == code) { - codes.push(code.to_string()); - } - }; - - if let Some(lang) = whisper_lang { - push(lang); - } - if let Some(lang) = detect_text_language_voice(transcript) { - push(&lang); - } - for hint in casual_language_hints(transcript) { - push(hint); - } - codes -} - -/// Like [`detect_text_language`] but tuned for short Whisper transcripts. +/// Like [`detect_text_language`] but tuned for short / casual messages. fn detect_text_language_voice(text: &str) -> Option { const MIN_VOICE_CONFIDENCE: f64 = 0.08; @@ -222,23 +183,6 @@ pub fn format_text_auto_translation(target: &Language, translation: &str) -> Str format!("{} {}", target.flag, translation.trim()) } -/// Voice auto-translate reply: transcript + translation in one quote-reply. -pub fn format_voice_auto_translation( - source: &Language, - transcript: &str, - target: &Language, - translation: &str, -) -> String { - format!( - "📝 ({}) {}\n{} ({}) {}", - source.code, - transcript.trim(), - target.flag, - target.code, - translation.trim() - ) -} - /// Resolve target language for a text message in translate-all mode. pub fn target_for_message_text( mode: &GroupTranslateMode, @@ -291,55 +235,21 @@ mod tests { } #[test] - fn resolve_voice_pair_from_whisper_english() { - let mode = GroupTranslateMode::new( - resolve_language("es").unwrap(), - resolve_language("en").unwrap(), - ); - let pair = resolve_translate_all_voice_pair( - &mode, - Some("en"), - "Hello, I'm speaking in English now. How was your day?", - ); - let (source, target) = pair.expect("should resolve en -> es"); - assert_eq!(source.code, "en"); - assert_eq!(target.code, "es"); - } - - #[test] - fn resolve_voice_pair_spanish_from_whisper_or_hints() { - let mode = GroupTranslateMode::new( - resolve_language("es").unwrap(), - resolve_language("en").unwrap(), - ); - let pair = - resolve_translate_all_voice_pair(&mode, Some("es"), "Como está? Como foi tu dia oi?") - .or_else(|| { - resolve_translate_all_voice_pair(&mode, None, "Como está? Como foi tu dia oi?") - }); - let (source, target) = pair.expect("should resolve es -> en"); - assert_eq!(source.code, "es"); - assert_eq!(target.code, "en"); - } - - #[test] - fn resolve_voice_pair_maps_portuguese_to_spanish_in_es_en_pair() { + fn resolve_text_pair_maps_portuguese_to_spanish_in_es_en_pair() { let mode = GroupTranslateMode::new( resolve_language("es").unwrap(), resolve_language("en").unwrap(), ); - let pair = resolve_translate_all_voice_pair(&mode, Some("pt"), "Como foi tu dia?"); - let (source, target) = pair.expect("pt should map to es in es/en pair"); + let pair = resolve_translate_all_text_pair(&mode, "Como foi tu dia?"); + let (source, target) = pair.expect("pt-like text should map to es in es/en pair"); assert_eq!(source.code, "es"); assert_eq!(target.code, "en"); } #[test] - fn format_voice_bilingual() { + fn format_text_auto_includes_flag() { let es = resolve_language("es").unwrap(); - let en = resolve_language("en").unwrap(); - let out = format_voice_auto_translation(es, "Hola", en, "Hello"); - assert!(out.contains("📝 (es) Hola")); - assert!(out.contains("🇺🇸 (en) Hello")); + let out = format_text_auto_translation(es, " Buenos días "); + assert_eq!(out, format!("{} Buenos días", es.flag)); } } diff --git a/crates/signal-bot/src/commands/voice.rs b/crates/signal-bot/src/commands/voice.rs index 0eba6ed..8745d18 100644 --- a/crates/signal-bot/src/commands/voice.rs +++ b/crates/signal-bot/src/commands/voice.rs @@ -1,18 +1,13 @@ //! Implicit voice note handler — transcribe via Whisper and quote-reply. -use crate::commands::translate_service::{ - format_voice_auto_translation, near_ai_translate, resolve_translate_all_voice_pair, -}; use crate::commands::CommandHandler; use crate::error::AppResult; -use crate::group_preferences_store::GroupPreferencesStore; use crate::transcribe_store::TranscribeStore; use crate::voice_attachment_cache::VoiceAttachmentCache; use async_trait::async_trait; -use near_ai_client::NearAiClient; use signal_client::{Attachment, BotMessage, SignalClient}; use std::sync::Arc; -use tracing::{debug, info, instrument, warn}; +use tracing::{info, instrument, warn}; use whisper_client::{WhisperClient, WhisperError}; #[cfg_attr(not(test), allow(dead_code))] @@ -23,8 +18,6 @@ pub struct VoiceHandler { signal: Arc, reply_prefix: String, max_attachment_bytes: usize, - group_translate: Option>, - near_ai: Option>, transcribe_store: Option>, voice_cache: Option>, } @@ -41,8 +34,6 @@ impl VoiceHandler { signal, reply_prefix: reply_prefix.into(), max_attachment_bytes, - group_translate: None, - near_ai: None, transcribe_store: None, voice_cache: None, } @@ -58,16 +49,6 @@ impl VoiceHandler { self } - pub fn with_translate_all( - mut self, - store: Arc, - near_ai: Arc, - ) -> Self { - self.group_translate = Some(store); - self.near_ai = Some(near_ai); - self - } - pub fn format_transcript(text: &str, prefix: &str) -> String { format!("{prefix}\n{text}") } @@ -97,81 +78,6 @@ impl VoiceHandler { } } } - - async fn translate_all_voice( - &self, - _message: &BotMessage, - audio: &Attachment, - bytes: &[u8], - group_id: &str, - ) -> AppResult { - let store = self - .group_translate - .as_ref() - .expect("translate-all store required"); - let near_ai = self - .near_ai - .as_ref() - .expect("near_ai required for translate-all"); - let mode = store - .get(group_id) - .expect("translate-all mode must be active"); - - let filename = Self::attachment_filename(audio); - let content_type = &audio.content_type; - - let transcript = self - .whisper - .transcribe(bytes, &filename, content_type) - .await?; - let transcript_text = transcript.trimmed_text(); - let whisper_lang = transcript.language.as_deref(); - - let (source, target) = - match resolve_translate_all_voice_pair(&mode, whisper_lang, transcript_text) { - Some(pair) => pair, - None => { - info!( - group_id, - whisper_lang, - "Voice transcript language not in translate-all pair — transcript only" - ); - return Ok(Self::format_transcript(transcript_text, &self.reply_prefix)); - } - }; - - debug!( - group_id, - whisper_lang, - source_lang = source.code, - target_lang = target.code, - whisper_translate = target.code == "en" && source.code != "en", - "translate-all voice pipeline" - ); - - let translation = if target.code == "en" && source.code != "en" { - match self - .whisper - .translate_to_english(bytes, &filename, content_type) - .await - { - Ok(r) => r.trimmed_text().to_string(), - Err(e) => { - warn!("Whisper translate failed, falling back to NEAR AI: {}", e); - near_ai_translate(near_ai, transcript_text, target).await? - } - } - } else { - near_ai_translate(near_ai, transcript_text, target).await? - }; - - Ok(format_voice_auto_translation( - source, - transcript_text, - target, - &translation, - )) - } } #[async_trait] @@ -232,38 +138,6 @@ impl CommandHandler for VoiceHandler { return Ok("Voice note too long (max 5 min). Send a shorter clip.".into()); } - let translate_all_active = message.group_id.as_ref().is_some_and(|gid| { - self.group_translate - .as_ref() - .is_some_and(|s| s.is_active(gid)) - }); - - if translate_all_active { - let group_id = message.group_id.as_deref().unwrap(); - let store = self.group_translate.as_ref().unwrap(); - if store.allow_message(group_id) { - match self - .translate_all_voice(message, audio, &bytes, group_id) - .await - { - Ok(response) => { - info!( - source = %message.source, - chars = response.len(), - "Voice note translated (translate-all)" - ); - return Ok(response); - } - Err(e) => { - warn!("translate-all voice failed: {}", e); - return Ok("Could not transcribe voice note. Try again later.".into()); - } - } - } else { - warn!(group_id, "translate-all rate limited — transcript only"); - } - } - let filename = Self::attachment_filename(audio); let result = self .whisper diff --git a/docker/phala.translation.yaml b/docker/phala.translation.yaml index 531020c..4eb28a9 100644 --- a/docker/phala.translation.yaml +++ b/docker/phala.translation.yaml @@ -1,5 +1,5 @@ # Phala Cloud TEE — translation CVM (4 GB / tdx.medium) -# Hosts in-chat translation + Language Threads (+ Parallel Translation later). +# Hosts in-chat translation + Parallel Translation + Language Threads. # # Build & push (linux/amd64): # docker buildx build --platform linux/amd64 -t YOUR_DOCKERHUB/signal-bot-tee:latest -f docker/Dockerfile --push . diff --git a/docs/in-chat-translation.md b/docs/in-chat-translation.md new file mode 100644 index 0000000..4c21fd2 --- /dev/null +++ b/docs/in-chat-translation.md @@ -0,0 +1,53 @@ +# In-chat (group) translation + +Status: **MVP implemented** on the translation bot. + +One **bilingual** Signal group (e.g. English + Spanish). The bot detects which side of the pair a message is on and quote-replies with the other language in the **same** main thread. + +Distinct from [Parallel Translation](parallel-translation.md) (monolingual main + one parallel group) and [Language Threads](language-threads.md) (bilingual main + N sidecars). + +## Setup + +In the group: + +```text +!translate-on es en +``` + +- Stores a bilingual pair for this group (`lang_a` ↔ `lang_b`) +- Order does not matter for detection (either side maps to the other) + +Stop: + +```text +!translate-off +``` + +Menus: `!help` → `!translation` → `!in-chat` + +## Behavior + +| Mode | How | Effect | +|------|-----|--------| +| **Auto** | `!translate-on` active | Every non-command group text message: detect language → if it matches one side of the pair → NEAR translate → quote-reply with `{flag} {translation}` | +| **Manual** | Reply to a message with `!translate ` | Translate only that quoted message | + +Not dual-post: the original stays as the human message; the bot only quote-replies the translation. + +Skip when language is undetected or not in the pair. Bot messages are never processed (`BotIdentity`). Rate-limited per group (`TRANSLATE_ALL__MAX_MESSAGES_PER_MINUTE`). + +Voice notes: the **transcription** bot posts a transcript in-group; with auto-translate on, the **translation** bot then intercepts that text like any other message. + +## Mutual exclusion + +Cannot enable in-chat auto-translate while Parallel (`!parallel-on`) is active, and vice versa. + +## Key code + +| Area | Path | +|------|------| +| Auto on/off + intercept | [`crates/signal-bot/src/commands/translate_all.rs`](../crates/signal-bot/src/commands/translate_all.rs) | +| Quote `!translate` | [`crates/signal-bot/src/commands/translate.rs`](../crates/signal-bot/src/commands/translate.rs) | +| Detect / format helpers | [`crates/signal-bot/src/commands/translate_service.rs`](../crates/signal-bot/src/commands/translate_service.rs) | +| Prefs (`GroupTranslateMode`) | [`crates/signal-bot/src/group_preferences_store.rs`](../crates/signal-bot/src/group_preferences_store.rs) | +| Menus | [`crates/signal-bot/src/commands/menu_locale.rs`](../crates/signal-bot/src/commands/menu_locale.rs) (`!in-chat`) | diff --git a/docs/language-threads.md b/docs/language-threads.md index 4e89712..43fab78 100644 --- a/docs/language-threads.md +++ b/docs/language-threads.md @@ -37,7 +37,7 @@ Main (bilingual) Aliases: `!translate-me on es`, `!translation-me-on es`, etc. -**Also on the translation bot** (separate products): in-chat `!translate-on` / `!translate-off` and quote `!translate` (via `!in-chat` menu); Parallel via `!parallel` / `!parallel-on`. Language Threads refactor is deferred — see product hub `!translation`. +**Also on the translation bot** (separate products): [in-chat translation](in-chat-translation.md) (`!translate-on` / quote `!translate` via `!in-chat`); [Parallel Translation](parallel-translation.md) (`!parallel` / `!parallel-on`). Language Threads refactor is deferred — see product hub `!translation`. **Not registered:** `!ask`, DM chat, voice/`!transcribe*` (transcription CVM). diff --git a/docs/parallel-translation.md b/docs/parallel-translation.md index 884ba79..eb5a74e 100644 --- a/docs/parallel-translation.md +++ b/docs/parallel-translation.md @@ -4,7 +4,7 @@ Status: **MVP implemented** on the translation bot. One **monolingual** main Signal group plus one **parallel** Signal group. The bot translates both directions so each lane stays in its language. -Distinct from [Language Threads](language-threads.md) (bilingual main + N sidecars). +Distinct from [In-chat translation](in-chat-translation.md) (bilingual main, quote-reply in-thread) and [Language Threads](language-threads.md) (bilingual main + N sidecars). ## Setup diff --git a/docs/two-cvm-architecture.md b/docs/two-cvm-architecture.md index 90d9ed1..6b8b724 100644 --- a/docs/two-cvm-architecture.md +++ b/docs/two-cvm-architecture.md @@ -65,12 +65,12 @@ Deploy each compose to its **own** CVM. Do not co-locate Whisper with the transl ## Products on each CVM -| Product | CVM | -|---------|-----| -| Voice transcription | Transcription | -| In-chat (group) translation | Translation | -| Parallel Translation (not implemented yet) | Translation | -| Language Threads | Translation | +| Product | CVM | Doc | +|---------|-----|-----| +| Voice transcription | Transcription | — | +| In-chat (group) translation | Translation | [in-chat-translation.md](in-chat-translation.md) | +| Parallel Translation (MVP) | Translation | [parallel-translation.md](parallel-translation.md) | +| Language Threads | Translation | [language-threads.md](language-threads.md) | ## Why split