KeiSeiKit-1.0/_primitives/_rust/kei-buddy/src/state.rs
Parfii-bot 87d7b1c5c4 feat(kei-buddy): AskLanguage i18n + real proposeTopicSources + voice handling
Three follow-up atomics on top of the contacts/topics/sync wave.

## 1. AskLanguage state + ru/en localisation (default en)

New state `AskLanguage` inserted between `Intro` and `AskName`. Intro now
sends a bilingual greeting + language picker. AskLanguage parses
en/english/1/ru/русский/2/etc → persona_patch{"language":"<code>"} →
transitions to AskName with that language's prompt.

All later prompts (AskName / AskTone / AskInterests / AskHobbies /
TopicSpecifics / TopicNowLater / TopicResearch / AskSchedule / Ready)
read persona.language via Lang::from_persona and dispatch through
Strings::* helpers — two language tables, no fallthrough.

Back-compat migration: existing chats without `language` key (like the
user currently in topic_now_later) get an implicit "ru" patch on next
turn so their Russian onboarding continues without regression.

New files: strings.rs (164), machine_lang.rs (145).
Modified: state.rs (+AskLanguage variant), machine.rs (Intro→AskLanguage,
AskLanguage arm, migration guard), machine_helpers.rs, machine_tests.rs.

5 new tests (intro_to_ask_language, ask_language_en, ask_language_ru,
ask_language_invalid, migration_sets_ru_when_language_missing).

## 2. Real proposeTopicSources — removed TODO(phase2) stub

machine_lang.rs::step_topic_research now calls
extractor.extract(prompt, topic_title) with a {name, url, why} schema.
Parses JSON, formats numbered source list, transitions to TopicSources.

Failure paths (LLM error, empty array): graceful fallback prompt asking
user to suggest their own — still transitions to TopicSources so flow
doesn't deadlock.

3 new tests in machine_tests_topic_research.rs:
topic_research_yes_proposes_sources,
topic_research_yes_empty_sources_still_advances,
topic_research_no_skips_topic_sources.

## 3. Voice-message handling (Telegram voice/audio → STT → text pipeline)

kei-telegram-webhook: added Voice/Audio sub-structs on Message and
WebhookEvent::Voice variant. classify() detects message.voice OR
message.audio. 2 new tests in event.rs.

kei-buddy/src/voice.rs (178 LOC):
VoiceHandler { bot_token, stt: Arc<dyn SttBackend>, http }
transcribe_file(file_id, mime_type) does:
  1. GET https://api.telegram.org/bot{token}/getFile?file_id=...
  2. GET https://api.telegram.org/file/bot{token}/{file_path}
  3. SttRequest { audio_bytes, mime_type, language: None } → backend.transcribe
  4. Returns transcript text.
2 wiremock tests (download chain + 500 error mapping).

serve.rs adds voice: Option<Arc<VoiceHandler>> to BuddyContext;
on_event Voice arm: whitelist check → transcribe → handle_text (same
pipeline as if user typed). Voice unavailable: warn + ignore.

serve_runner.rs builds VoiceHandler from KEI_BUDDY_STT_BACKEND env.

kei-stt added as optional dep gated by serve feature. Default backend
whisper-local (no extra build deps).

TTS reply path deferred (next atomic).

## Verify

  * cargo check --workspace: PASS
  * cargo test -p kei-buddy --lib: 55 passed / 0 failed (was 41 → 50 → 53 → 55)
  * cargo test -p kei-telegram-webhook --lib: 7 passed (was 5, +2 voice)
  * cargo build -p kei-buddy --release: PASS (23.7s)

NOT deployed yet — three new things to roll out next:
  * новые миграции (нет — БД без изменений)
  * новые env: KEI_BUDDY_STT_BACKEND (optional)
  * установка faster-whisper / piper-tts на сервер для STT
    (без него Voice event просто warn-логируется и игнорируется)
2026-05-12 17:49:06 +08:00

77 lines
2.7 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
//! Onboarding state-machine enum.
//!
//! Ported from `keisei-marketplace/src/lib/keibuddy/chat-onboard.ts`.
//! Each variant corresponds to one `Step` in the TypeScript source.
//!
//! Transitions live in `machine::handle_step` — the `next()` stub
//! has been removed as part of the TS→Rust port.
use serde::{Deserialize, Serialize};
/// 12-state onboarding finite-state machine.
///
/// Extends the TypeScript `Step` union with `ask_language` as the second
/// step (right after `intro`):
/// `intro | ask_language | ask_name | ask_tone | ask_interests | ask_hobbies |
/// topic_specifics | topic_now_later | topic_research |
/// topic_sources | ask_schedule | ready`
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnboardState {
/// Initial greeting — bot explains itself.
Intro,
/// Collecting language preference (en / ru). Default: en.
AskLanguage,
/// Collecting user's display name.
AskName,
/// Collecting preferred communication tone.
AskTone,
/// Collecting list of interests.
AskInterests,
/// Collecting list of hobbies.
AskHobbies,
/// Per-topic: "what specifically interests you here?"
TopicSpecifics,
/// Per-topic: "discuss now or save for later?"
TopicNowLater,
/// Per-topic: "want ongoing source monitoring?"
TopicResearch,
/// Per-topic: "here are proposed sources, which to add?"
TopicSources,
/// Collecting digest schedule (morning/evening hours + timezone).
AskSchedule,
/// Onboarding complete; regular conversation mode.
Ready,
}
#[cfg(test)]
mod tests {
use super::*;
/// Smoke test: every variant round-trips through JSON serialisation.
#[test]
fn all_variants_serde_roundtrip() {
let variants = [
OnboardState::Intro,
OnboardState::AskLanguage,
OnboardState::AskName,
OnboardState::AskTone,
OnboardState::AskInterests,
OnboardState::AskHobbies,
OnboardState::TopicSpecifics,
OnboardState::TopicNowLater,
OnboardState::TopicResearch,
OnboardState::TopicSources,
OnboardState::AskSchedule,
OnboardState::Ready,
];
for variant in &variants {
let json = serde_json::to_string(variant)
.unwrap_or_else(|e| panic!("serialize {:?}: {e}", variant));
let back: OnboardState = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("deserialize {:?} from {json:?}: {e}", variant));
assert_eq!(variant, &back, "round-trip failed for {:?}", variant);
}
}
}