KeiSeiKit-1.0/_primitives/_rust/kei-buddy/src/serve_runner.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

83 lines
3.1 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
//! `run_serve` — store construction + HTTP listener bootstrap.
//! Extracted from serve.rs to keep both files ≤ 200 LOC.
use std::sync::Arc;
use serde_json::json;
use tracing::warn;
use crate::{
chat_log::ChatLog,
contacts::Contacts,
extractor::LlmExtractor,
serve::{BuddyContext, ServeConfig},
store::SqliteBuddyStore,
topics::Topics,
voice::VoiceHandler,
};
/// Start the HTTP server (entry-point called from the binary).
pub async fn run_serve(cfg: ServeConfig) -> anyhow::Result<()> {
init_tracing();
let store = Arc::new(SqliteBuddyStore::from_path(&cfg.db_path)?);
let allowed_chat_ids = Arc::new(cfg.allowed_chat_ids);
let http = reqwest::Client::new();
let chat_log = Arc::new(ChatLog::from_path(&cfg.chat_log_db_path)?);
let topics = Arc::new(Topics::from_path(&cfg.topics_db_path)?);
let contacts = Arc::new(Contacts::from_path(&cfg.contacts_db_path)?);
let voice = build_voice_handler(cfg.stt_backend.as_deref(), &cfg.bot_token);
#[cfg(feature = "extractor-openai")]
{
if let (Some(proxy), Some(key)) = (cfg.llm_proxy_url, cfg.llm_api_key) {
let model = cfg.llm_model.unwrap_or_else(|| "gpt-4o-mini".to_string());
tracing::info!(model = %model, "using OpenAiExtractor (LiteLLM-compatible)");
let extractor = Arc::new(crate::extractor::openai::OpenAiExtractor::new_with_model(
proxy, key, model,
));
return start_listener(cfg.port, BuddyContext {
secret: cfg.webhook_secret,
bot_token: cfg.bot_token,
store, extractor, http, allowed_chat_ids, chat_log, topics, contacts, voice,
}).await;
}
}
warn!("no LLM extractor configured — using MockExtractor");
let extractor = Arc::new(crate::extractor::MockExtractor::new(json!({})));
start_listener(cfg.port, BuddyContext {
secret: cfg.webhook_secret,
bot_token: cfg.bot_token,
store, extractor, http, allowed_chat_ids, chat_log, topics, contacts, voice,
}).await
}
fn build_voice_handler(stt_backend: Option<&str>, bot_token: &str) -> Option<Arc<VoiceHandler>> {
let name = stt_backend?;
std::env::set_var("KEI_STT_BACKEND", name);
match kei_stt::from_env() {
Ok(stt) => Some(Arc::new(VoiceHandler::new(bot_token.to_string(), Arc::from(stt)))),
Err(e) => {
tracing::warn!(backend = name, error = %e, "STT init failed; voice disabled");
None
}
}
}
async fn start_listener<E>(port: u16, ctx: BuddyContext<E>) -> anyhow::Result<()>
where
E: LlmExtractor + Send + Sync + 'static,
{
let router = crate::serve::build_router(ctx);
let addr = format!("0.0.0.0:{}", port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!(addr = %addr, "kei-buddy listening");
axum::serve(listener, router).await?;
Ok(())
}
fn init_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
let _ = fmt().with_env_filter(EnvFilter::from_default_env()).try_init();
}