KeiSeiKit-1.0/_primitives/_rust/kei-buddy/src/machine.rs
Parfii-bot 26dc8c85f7 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

251 lines
11 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
//! Onboarding state-machine: `handle_step` (12-arm FSM match).
//! Helpers → machine_helpers.rs. Tests → machine_tests.rs.
//!
//! LOC exception: file is allowed up to 260 LOC (Constructor Pattern §thresholds).
use serde_json::{json, Value};
use crate::error::BuddyError;
use crate::extractor::{
LlmExtractor, prompt_list, prompt_name, prompt_now_or_later,
prompt_schedule, prompt_tone, prompt_topic_specifics, TONES,
};
use crate::machine_helpers::{
build_topic_state, clamp_hour, describe_schedule, extract_string, finish_topic,
format_list, parse_source_selection, str_list,
};
use crate::machine_lang::{
ask_schedule_lang, backfill_language, build_ready_response, handle_ask_language,
step_topic_research,
};
use crate::state::OnboardState;
use crate::strings::{Lang, Strings};
use crate::transition::StepOutput;
/// Advance the onboarding FSM by one user message.
/// Merge `StepOutput::persona_patch` into the persona blob before the next call.
/// `__topic_state` in the patch tracks the per-topic loop; keep it in blob.
pub async fn handle_step<E: LlmExtractor>(
state: &OnboardState,
user_text: &str,
persona: &Value,
extractor: &E,
) -> Result<StepOutput, BuddyError> {
// Back-compat migration: chats that started before language selection was
// added will have no `language` key. Treat them as Russian so existing
// in-progress threads keep their original language.
// Skipped for Intro / AskLanguage (language not yet chosen) and Ready
// (onboarding complete, no need to persist migration patch).
let migration_patch = match state {
OnboardState::Intro | OnboardState::AskLanguage | OnboardState::Ready => None,
_ => backfill_language(persona),
};
let lang = Lang::from_persona(persona);
let mut out = step_dispatch(state, user_text, persona, extractor, lang).await?;
// Merge migration patch when present.
if let Some(mp) = migration_patch {
if let (Some(obj), Some(mp_obj)) = (
out.persona_patch.as_object_mut(),
mp.as_object(),
) {
for (k, v) in mp_obj {
obj.entry(k).or_insert_with(|| v.clone());
}
}
}
Ok(out)
}
async fn step_dispatch<E: LlmExtractor>(
state: &OnboardState,
user_text: &str,
persona: &Value,
extractor: &E,
lang: Lang,
) -> Result<StepOutput, BuddyError> {
match state {
OnboardState::Intro => Ok(StepOutput {
next_state: OnboardState::AskLanguage,
response_text: Strings::intro_ask_language().to_owned(),
persona_patch: json!({}),
}),
OnboardState::AskLanguage => Ok(handle_ask_language(user_text).unwrap_or_else(|| {
StepOutput {
next_state: OnboardState::AskLanguage,
response_text: Strings::invalid_language().to_owned(),
persona_patch: json!({}),
}
})),
OnboardState::AskName => {
let v = extractor.extract(prompt_name(), user_text).await?;
let name: String = v["name"]
.as_str()
.unwrap_or(user_text.trim())
.chars().take(40).collect();
let step2 = match lang { Lang::En => "Step 2/5.", Lang::Ru => "Шаг 2/5." };
let ok = match lang { Lang::En => "Got it,", Lang::Ru => "Отлично," };
Ok(StepOutput {
next_state: OnboardState::AskTone,
response_text: format!(
"{ok} *{name}*.\n\n*{step2}* {}",
Strings::ask_tone(lang)
),
persona_patch: json!({ "name": name }),
})
}
OnboardState::AskTone => {
let v = extractor.extract(prompt_tone(), user_text).await?;
let raw = v["tone"].as_str().unwrap_or("").to_lowercase();
let tone = if TONES.contains(&raw.as_str()) { raw } else { "friendly".to_owned() };
let step3 = match lang { Lang::En => "Step 3/5.", Lang::Ru => "Шаг 3/5." };
let ok = match lang { Lang::En => "Tone:", Lang::Ru => "Тон:" };
Ok(StepOutput {
next_state: OnboardState::AskInterests,
response_text: format!(
"{ok} *{tone}*.\n\n*{step3}* {}",
Strings::ask_interests(lang)
),
persona_patch: json!({ "tone": tone }),
})
}
OnboardState::AskInterests => {
let prompt = prompt_list("interests");
let v = extractor.extract(&prompt, user_text).await?;
let interests = str_list(&v["items"]);
let step4 = match lang { Lang::En => "Step 4/5.", Lang::Ru => "Шаг 4/5." };
let label = match lang { Lang::En => "Interests:", Lang::Ru => "Интересы:" };
Ok(StepOutput {
next_state: OnboardState::AskHobbies,
response_text: format!(
"{label} {}.\n\n*{step4}* {}",
format_list(&interests),
Strings::ask_hobbies(lang)
),
persona_patch: json!({ "interests": interests }),
})
}
OnboardState::AskHobbies => step_ask_hobbies(user_text, persona, extractor, lang).await,
OnboardState::TopicSpecifics => {
let v = extractor.extract(prompt_topic_specifics(), user_text).await?;
let specifics = str_list(&v["aspects"]);
let cur_name = extract_string(&persona["current_topic"], "name");
let understood = match lang { Lang::En => "Got it on", Lang::Ru => "Понял по" };
Ok(StepOutput {
next_state: OnboardState::TopicNowLater,
response_text: format!(
"{understood} *{cur_name}*: {}.\n\n{}",
format_list(&specifics),
Strings::topic_now_later(lang)
),
persona_patch: json!({ "current_topic_specifics": specifics }),
})
}
OnboardState::TopicNowLater => {
let v = extractor.extract(prompt_now_or_later(), user_text).await?;
let defer = v["decision"].as_str().unwrap_or("later") != "now";
let cur_name = extract_string(&persona["current_topic"], "name");
let body = build_now_later_msg(lang, &cur_name, defer);
Ok(StepOutput {
next_state: OnboardState::TopicResearch,
response_text: format!("{body}\n\n{}", Strings::topic_research(lang)),
persona_patch: json!({ "current_topic_defer": defer }),
})
}
OnboardState::TopicResearch => step_topic_research(user_text, persona, extractor, lang).await,
OnboardState::TopicSources => {
let cur = &persona["current_topic"];
let cur_name = extract_string(cur, "name");
let kind_interest = extract_string(cur, "kind").as_str() == "interest";
let specifics = str_list(&persona["current_topic_specifics"]);
let defer = persona["current_topic_defer"].as_bool().unwrap_or(true);
let proposed: Vec<Value> = persona["current_topic_proposed"].as_array().cloned().unwrap_or_default();
let picked = parse_source_selection(user_text, proposed.len());
Ok(finish_topic(persona, &cur_name, kind_interest, &specifics, defer, true, &proposed, &picked))
}
OnboardState::AskSchedule => {
let v = extractor.extract(prompt_schedule(), user_text).await?;
let morning = clamp_hour(&v["morning"]);
let evening = clamp_hour(&v["evening"]);
let tz = v["timezone"].as_str().filter(|s| s.len() <= 64).unwrap_or("UTC").to_owned();
let tone = persona["tone"].as_str().unwrap_or("friendly");
let interests = str_list(&persona["interests"]);
let sched_str = describe_schedule(morning, evening, &tz);
Ok(build_ready_response(lang, tone, &interests, &sched_str, morning, evening, &tz))
}
OnboardState::Ready => Ok(StepOutput {
next_state: OnboardState::Ready,
response_text: String::new(),
persona_patch: json!({}),
}),
}
}
// ─── arm helpers ─────────────────────────────────────────────────────────────
fn build_now_later_msg(lang: Lang, cur_name: &str, defer: bool) -> String {
match (lang, defer) {
(Lang::En, false) => format!("Ok, we'll discuss *{cur_name}* in detail after setup. Noted."),
(Lang::En, true) => format!("Saved *{cur_name}* for later."),
(Lang::Ru, false) => format!("Окей, обсудим *{cur_name}* подробно когда закончим настройку. Запомнил."),
(Lang::Ru, true) => format!("Отложил *{cur_name}* на потом."),
}
}
async fn step_ask_hobbies<E: LlmExtractor>(
user_text: &str,
persona: &Value,
extractor: &E,
lang: Lang,
) -> Result<StepOutput, BuddyError> {
let prompt = prompt_list("hobbies");
let v = extractor.extract(&prompt, user_text).await?;
let hobbies = str_list(&v["items"]);
let interests = str_list(&persona["interests"]);
let queue: Vec<Value> = interests
.iter().map(|n| json!({"name": n, "kind": "interest"}))
.chain(hobbies.iter().map(|n| json!({"name": n, "kind": "hobby"})))
.collect();
let hobbies_label = match lang { Lang::En => "Hobbies:", Lang::Ru => "Хобби:" };
if queue.is_empty() {
return Ok(ask_schedule_lang(
&json!({ "hobbies": hobbies }),
&format!("{hobbies_label} {}.", format_list(&hobbies)),
lang,
));
}
let next_topic = queue[0].clone();
let topic_name = next_topic["name"].as_str().unwrap_or("?").to_owned();
let ts = build_topic_state(&queue[1..], 0, json!({}));
let mut patch = ts;
patch["hobbies"] = json!(hobbies);
patch["current_topic"] = next_topic;
let prefix_str = Strings::topic_specifics_prefix(lang);
let question_str = Strings::topic_specifics_question(lang);
Ok(StepOutput {
next_state: OnboardState::TopicSpecifics,
response_text: format!(
"{hobbies_label} {}.\n\n{prefix_str} *{topic_name}*.\n\n{question_str}",
format_list(&hobbies)
),
persona_patch: patch,
})
}
// Tests live in machine_tests.rs (Constructor Pattern: separate test module).
#[cfg(test)]
#[path = "machine_tests.rs"]
mod machine_tests;