skillok meeting
v1.1
Перейти к файлу
Код
meeting src-taurisrcopenrouter.rs
anc Меню как у skillok.org, живая расшифровка и ответ ИИ, длинные записи (#13) - Логотип убран. Шапка и меню как у skillok.org: кнопка ☰ открывает панель слева (Главная, Записать, Добавить аудиофайл, записи с поиском, Настройки), закрытие крестиком, кликом мимо, Esc и переходом; крошки «Skillok Meet / запись». Список записей с поиском — на главной. - Во время записи один общий уровень вместо «Вы» и «Собеседники». - Расшифровка по ходу: реплики приходят по порядку времени (кусок уходит, когда готовы все куски раньше), текст растёт на экране. - Ответ ИИ потоком (stream: true): конспект, задачи, ревью и свой запрос пишутся на глазах. - Длинные записи: дорожка не читается в память целиком — нарезка блоками по 10 с, каждый кусок читается с диска перед отправкой; чужой файл один раз перегоняется во временный WAV. Прогон 2 ч × 2 дорожки с подделкой распознавания: 1600 кусков, последняя реплика 01:59:55, пик памяти 25 МБ. - Говорящий в файле без дорожек — «Спикер 1». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 3938872 30 сентября 2026 г.
openrouter.rs
//! Клиент OpenRouter: проверка ключа и разбор встречи.
//!
//! Один ключ пользователя на всё: распознавание речи и команды ИИ над заметкой.
//! Документация:
//! - ключ: `GET /api/v1/key` — https://openrouter.ai/docs/api/reference/limits
//! - речь: `POST /api/v1/audio/transcriptions` —
//!   https://openrouter.ai/docs/guides/overview/multimodal/stt
//! - строгий JSON: `response_format.json_schema` —
//!   https://openrouter.ai/docs/guides/features/structured-outputs

use std::time::Duration;

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use base64::Engine;

pub const DEFAULT_BASE: &str = "https://openrouter.ai/api/v1";

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("OpenRouter не принял ключ — проверьте его в настройках")]
    Unauthorized,
    #[error("На счёте OpenRouter не хватает средств")]
    PaymentRequired,
    #[error("OpenRouter просит подождать: слишком много запросов")]
    RateLimited,
    #[error("OpenRouter ответил {status}: {message}")]
    Http { status: u16, message: String },
    #[error("Нет связи с OpenRouter: {0}")]
    Network(String),
    #[error("Модель не закончила ответ ({0}) — попробуйте ещё раз или другую модель")]
    Incomplete(String),
}

/// Что показать в настройках после проверки ключа.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KeyInfo {
    pub label: Option<String>,
    /// Остаток лимита ключа в долларах; `None` — лимита нет.
    pub limit_remaining: Option<f64>,
    /// Потрачено по ключу за всё время, доллары.
    pub usage: Option<f64>,
    pub is_free_tier: bool,
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Usage {
    pub prompt_tokens: u64,
    pub completion_tokens: u64,
    /// Стоимость запроса в долларах, если OpenRouter её сообщил.
    pub cost: Option<f64>,
}

pub struct Client {
    http: reqwest::Client,
    base: String,
    key: String,
}

impl Client {
    pub fn new(key: impl Into<String>) -> Self {
        Self::with_base(DEFAULT_BASE, key)
    }

    pub fn with_base(base: impl Into<String>, key: impl Into<String>) -> Self {
        let http = reqwest::Client::builder()
            .connect_timeout(Duration::from_secs(15))
            // Итоговый разбор долгой встречи на большой модели идёт минуты.
            .timeout(Duration::from_secs(600))
            .build()
            .expect("reqwest client");
        Self {
            http,
            base: base.into().trim_end_matches('/').to_string(),
            key: key.into(),
        }
    }

    pub async fn key_info(&self) -> Result<KeyInfo, Error> {
        let body = self.send(self.http.get(format!("{}/key", self.base))).await?;
        // Ответ завёрнут в `data`; держим и голый вид на случай смены формата.
        let data = body.get("data").unwrap_or(&body);
        Ok(KeyInfo {
            label: data.get("label").and_then(Value::as_str).map(str::to_string),
            limit_remaining: data.get("limit_remaining").and_then(Value::as_f64),
            usage: data.get("usage").and_then(Value::as_f64),
            is_free_tier: data.get("is_free_tier").and_then(Value::as_bool).unwrap_or(false),
        })
    }

    /// Распознаёт кусок звука (WAV). Время реплик — от начала куска.
    pub async fn transcribe(
        &self,
        model: &str,
        wav: &[u8],
        language: Option<&str>,
    ) -> Result<Transcription, Error> {
        let mut request = json!({
            "model": model,
            "input_audio": {
                "data": base64::engine::general_purpose::STANDARD.encode(wav),
                "format": "wav"
            },
            "temperature": 0,
            "response_format": "verbose_json",
            "timestamp_granularities": ["segment"]
        });
        if let Some(lang) = language {
            request["language"] = json!(lang);
        }
        let body = self
            .send(
                self.http
                    .post(format!("{}/audio/transcriptions", self.base))
                    .json(&request),
            )
            .await?;
        Ok(Transcription::from_response(&body))
    }

    /// Ответ модели простым текстом (Markdown): команда ИИ над заметкой.
    /// Ответ идёт потоком (`stream: true`), и `on_text` получает каждый новый
    /// кусок — человек видит, как пишется конспект, а не ждёт минуту пустой
    /// экран. Документация: https://openrouter.ai/docs/api/reference/streaming
    pub async fn complete(
        &self,
        model: &str,
        system: &str,
        user: &str,
        mut on_text: impl FnMut(&str),
    ) -> Result<(String, Usage), Error> {
        let request = json!({
            "model": model,
            "max_tokens": 16000,
            "stream": true,
            "usage": { "include": true },
            "messages": [
                { "role": "system", "content": system },
                { "role": "user", "content": user }
            ]
        });
        let mut response = self
            .http
            .post(format!("{}/chat/completions", self.base))
            .json(&request)
            .bearer_auth(&self.key)
            .header("X-Title", "Skillok Meet")
            .send()
            .await
            .map_err(|e| Error::Network(e.to_string()))?;
        let status = response.status().as_u16();
        let is_stream = response
            .headers()
            .get(reqwest::header::CONTENT_TYPE)
            .and_then(|v| v.to_str().ok())
            .is_some_and(|v| v.contains("text/event-stream"));
        if !(200..=299).contains(&status) || !is_stream {
            // Ошибка или ответ целиком, без потока, — разбирается как обычно.
            let text = response.text().await.map_err(|e| Error::Network(e.to_string()))?;
            let body = parse_reply(status, &text)?;
            let (text, usage) = answer_of(&body)?;
            on_text(&text);
            return Ok((text, usage));
        }
        let mut sse = SseAnswer::default();
        let mut pending = Vec::new();
        while let Some(bytes) = response
            .chunk()
            .await
            .map_err(|e| Error::Network(e.to_string()))?
        {
            pending.extend_from_slice(&bytes);
            // Событие — строка `data: {...}`; строка может прийти по частям.
            while let Some(end) = pending.iter().position(|&b| b == b'\n') {
                let line: Vec<u8> = pending.drain(..=end).collect();
                if let Some(delta) = sse.line(String::from_utf8_lossy(&line).trim())? {
                    on_text(&delta);
                }
            }
        }
        sse.finish()
    }

    async fn send(&self, request: reqwest::RequestBuilder) -> Result<Value, Error> {
        let response = request
            .bearer_auth(&self.key)
            .header("X-Title", "Skillok Meet")
            .send()
            .await
            .map_err(|e| Error::Network(e.to_string()))?;
        let status = response.status().as_u16();
        let text = response.text().await.map_err(|e| Error::Network(e.to_string()))?;
        parse_reply(status, &text)
    }
}

fn parse_reply(status: u16, text: &str) -> Result<Value, Error> {
    match status {
        200..=299 => serde_json::from_str(text).map_err(|e| Error::Http {
            status,
            message: format!("ответ не JSON: {e}"),
        }),
        401 | 403 => Err(Error::Unauthorized),
        402 => Err(Error::PaymentRequired),
        429 => Err(Error::RateLimited),
        _ => {
            let message = serde_json::from_str::<Value>(text)
                .ok()
                .and_then(|v| v["error"]["message"].as_str().map(str::to_string))
                .unwrap_or_else(|| text.chars().take(300).collect());
            Err(Error::Http { status, message })
        }
    }
}

/// Ответ целиком: текст, если модель его закончила.
fn answer_of(body: &Value) -> Result<(String, Usage), Error> {
    let choice = &body["choices"][0];
    let finish = choice["finish_reason"].as_str().unwrap_or("");
    let text = choice["message"]["content"]
        .as_str()
        .unwrap_or("")
        .trim()
        .to_string();
    finished(finish, text, usage_of(body))
}

fn finished(finish: &str, text: String, usage: Usage) -> Result<(String, Usage), Error> {
    if finish != "stop" || text.is_empty() {
        let why = if finish.is_empty() {
            "пустой ответ"
        } else {
            finish
        };
        return Err(Error::Incomplete(why.to_string()));
    }
    Ok((text, usage))
}

/// Сборка ответа из потока событий `data: {...}`. Строки `: ...` — сигнал
/// «ещё работаю» от OpenRouter, их пропускаем.
#[derive(Default)]
struct SseAnswer {
    text: String,
    finish: String,
    usage: Usage,
}

impl SseAnswer {
    /// Разбирает строку потока; возвращает новый кусок текста, если он есть.
    fn line(&mut self, line: &str) -> Result<Option<String>, Error> {
        let Some(data) = line.strip_prefix("data:") else {
            return Ok(None);
        };
        let data = data.trim();
        if data.is_empty() || data == "[DONE]" {
            return Ok(None);
        }
        let Ok(event) = serde_json::from_str::<Value>(data) else {
            return Ok(None);
        };
        if let Some(message) = event["error"]["message"].as_str() {
            // Ошибка посреди потока: статус уже 200, причина — в событии.
            let status = event["error"]["code"].as_u64().unwrap_or(500) as u16;
            return Err(Error::Http {
                status,
                message: message.to_string(),
            });
        }
        if event["usage"].is_object() {
            self.usage = usage_of(&event);
        }
        let choice = &event["choices"][0];
        if let Some(f) = choice["finish_reason"].as_str() {
            self.finish = f.to_string();
        }
        let delta = choice["delta"]["content"].as_str().unwrap_or("");
        if delta.is_empty() {
            return Ok(None);
        }
        self.text.push_str(delta);
        Ok(Some(delta.to_string()))
    }

    fn finish(self) -> Result<(String, Usage), Error> {
        finished(&self.finish, self.text.trim().to_string(), self.usage)
    }
}

fn usage_of(body: &Value) -> Usage {
    Usage {
        prompt_tokens: body["usage"]["prompt_tokens"].as_u64().unwrap_or(0),
        completion_tokens: body["usage"]["completion_tokens"].as_u64().unwrap_or(0),
        cost: body["usage"]["cost"].as_f64(),
    }
}

/// Реплика из распознавания; время — от начала куска.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SttSegment {
    pub start_ms: u64,
    pub text: String,
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Transcription {
    pub segments: Vec<SttSegment>,
    pub cost: Option<f64>,
}

impl Transcription {
    /// Сегменты с временем, если модель их дала; иначе весь текст одной
    /// репликой с начала куска. Пустые и выдуманные на тишине фразы выброшены.
    fn from_response(body: &Value) -> Self {
        let mut segments: Vec<SttSegment> = body["segments"]
            .as_array()
            .map(|list| {
                list.iter()
                    .map(|s| SttSegment {
                        start_ms: (s["start"].as_f64().unwrap_or(0.0).max(0.0) * 1000.0) as u64,
                        text: s["text"].as_str().unwrap_or("").trim().to_string(),
                    })
                    .collect()
            })
            .unwrap_or_default();
        if segments.is_empty() {
            let text = body["text"].as_str().unwrap_or("").trim().to_string();
            segments.push(SttSegment { start_ms: 0, text });
        }
        segments.retain(|s| !s.text.is_empty() && !is_hallucination(&s.text));
        Transcription {
            segments,
            cost: body["usage"]["cost"].as_f64(),
        }
    }
}

/// Фразы, которые Whisper сочиняет на тишине и шуме: он учился на видео с
/// субтитрами, и концовки роликов попали в его «словарь». Реплика, целиком
/// состоящая из такой фразы, выбрасывается.
pub fn is_hallucination(text: &str) -> bool {
    const MARKERS: [&str; 10] = [
        "субтитр",
        "dimatorzok",
        "продолжение следует",
        "спасибо за просмотр",
        "подписывайтесь на канал",
        "подпишись на канал",
        "ставьте лайк",
        "редактор субтитров",
        "thank you for watching",
        "thanks for watching",
    ];
    let lower = text.to_lowercase();
    let words = lower.split_whitespace().count();
    words <= 12 && MARKERS.iter().any(|m| lower.contains(m))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::{Read, Write};
    use std::net::TcpListener;
    use std::sync::mpsc;

    /// Заглушка одного HTTP-ответа. Возвращает адрес и канал, по которому
    /// придёт полученный запрос целиком.
    fn serve_once(status: u16, body: &str) -> (String, mpsc::Receiver<String>) {
        serve_once_as(status, "application/json", body)
    }

    fn serve_once_as(status: u16, content_type: &str, body: &str) -> (String, mpsc::Receiver<String>) {
        let content_type = content_type.to_string();
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();
        let body = body.to_string();
        let (tx, rx) = mpsc::channel();
        std::thread::spawn(move || {
            let (mut stream, _) = listener.accept().unwrap();
            let mut buf = Vec::new();
            let mut chunk = [0u8; 8192];
            loop {
                let n = stream.read(&mut chunk).unwrap();
                buf.extend_from_slice(&chunk[..n]);
                let text = String::from_utf8_lossy(&buf);
                if let Some(head_end) = text.find("\r\n\r\n") {
                    let len = text[..head_end]
                        .lines()
                        .find_map(|l| {
                            l.to_ascii_lowercase()
                                .strip_prefix("content-length:")
                                .map(|v| v.trim().parse::<usize>().unwrap())
                        })
                        .unwrap_or(0);
                    if buf.len() >= head_end + 4 + len {
                        break;
                    }
                }
                if n == 0 {
                    break;
                }
            }
            tx.send(String::from_utf8_lossy(&buf).to_string()).unwrap();
            let reply = format!(
                "HTTP/1.1 {status} X\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
                body.len()
            );
            stream.write_all(reply.as_bytes()).unwrap();
        });
        (format!("http://{addr}"), rx)
    }

    #[tokio::test]
    async fn key_info_reads_data_and_sends_bearer() {
        let (base, rx) = serve_once(
            200,
            r#"{"data":{"label":"sk-or-v1-abc...","limit_remaining":4.5,"usage":0.5,"is_free_tier":false}}"#,
        );
        let info = Client::with_base(base, "k-123").key_info().await.unwrap();
        assert_eq!(info.limit_remaining, Some(4.5));
        assert_eq!(info.label.as_deref(), Some("sk-or-v1-abc..."));
        let req = rx.recv().unwrap();
        assert!(req.starts_with("GET /key "), "{req}");
        assert!(
            req.to_ascii_lowercase().contains("authorization: bearer k-123"),
            "{req}"
        );
    }

    #[tokio::test]
    async fn statuses_become_readable_errors() {
        for (status, check) in [
            (
                401u16,
                (|e: &Error| matches!(e, Error::Unauthorized)) as fn(&Error) -> bool,
            ),
            (402, |e| matches!(e, Error::PaymentRequired)),
            (429, |e| matches!(e, Error::RateLimited)),
            (
                500,
                |e| matches!(e, Error::Http { status: 500, message } if message == "boom"),
            ),
        ] {
            let (base, _rx) = serve_once(status, r#"{"error":{"message":"boom"}}"#);
            let err = Client::with_base(base, "k").key_info().await.unwrap_err();
            assert!(check(&err), "{status}: {err:?}");
        }
    }

    #[tokio::test]
    async fn transcribe_sends_base64_wav_and_reads_segments() {
        let reply = json!({
            "text": "Привет. Продолжение следует...",
            "segments": [
                { "start": 0.4, "end": 1.2, "text": " Привет, коллеги." },
                { "start": 2.0, "end": 3.1, "text": " Продолжение следует..." },
                { "start": 3.5, "end": 4.0, "text": "  " }
            ],
            "usage": { "seconds": 4.2, "cost": 0.0003 }
        });
        let (base, rx) = serve_once(200, &reply.to_string());
        let wav = meet_audio::pcm::encode_wav(&[1, 2, 3, 4]);
        let t = Client::with_base(base, "k")
            .transcribe("openai/whisper-large-v3-turbo", &wav, Some("ru"))
            .await
            .unwrap();
        assert_eq!(
            t.segments,
            vec![SttSegment {
                start_ms: 400,
                text: "Привет, коллеги.".into()
            }]
        );
        assert_eq!(t.cost, Some(0.0003));

        let req = rx.recv().unwrap();
        assert!(req.starts_with("POST /audio/transcriptions "), "{req}");
        let sent: Value = serde_json::from_str(req.split("\r\n\r\n").nth(1).unwrap()).unwrap();
        assert_eq!(sent["model"], "openai/whisper-large-v3-turbo");
        assert_eq!(sent["language"], "ru");
        assert_eq!(sent["input_audio"]["format"], "wav");
        let decoded = base64::engine::general_purpose::STANDARD
            .decode(sent["input_audio"]["data"].as_str().unwrap())
            .unwrap();
        assert_eq!(decoded, wav);
        assert_eq!(sent["response_format"], "verbose_json");
    }

    #[tokio::test]
    async fn transcribe_without_segments_uses_text_and_auto_language() {
        let (base, rx) = serve_once(200, r#"{"text":" Да, слышу. "}"#);
        let t = Client::with_base(base, "k")
            .transcribe("m", b"RIFF", None)
            .await
            .unwrap();
        assert_eq!(
            t.segments,
            vec![SttSegment {
                start_ms: 0,
                text: "Да, слышу.".into()
            }]
        );
        let req = rx.recv().unwrap();
        let sent: Value = serde_json::from_str(req.split("\r\n\r\n").nth(1).unwrap()).unwrap();
        assert!(
            sent.get("language").is_none(),
            "без языка — Whisper определяет сам"
        );
    }

    #[test]
    fn whisper_silence_phrases_are_dropped_but_real_speech_is_kept() {
        for junk in [
            "Продолжение следует...",
            "Субтитры сделал DimaTorzok",
            "Спасибо за просмотр!",
            "Редактор субтитров А.Семкин Корректор А.Егорова",
            "Thank you for watching.",
        ] {
            assert!(is_hallucination(junk), "{junk}");
        }
        for real in [
            "Спасибо, продолжаем.",
            "Давайте подпишем договор до пятницы.",
            "Я посмотрел субтитры к ролику, и там в третьей минуте ошибка в названии компании, давайте её поправим до публикации.",
        ] {
            assert!(!is_hallucination(real), "{real}");
        }
    }

    #[tokio::test]
    async fn complete_returns_plain_text() {
        let reply = json!({ "choices": [{ "finish_reason": "stop", "message": { "content": "  ## Задачи\n\n- [ ] Отчёт\n" } }] });
        let (base, rx) = serve_once(200, &reply.to_string());
        let (text, _) = Client::with_base(base, "k")
            .complete("anthropic/claude-opus-5.5", "система", "заметка", |_| {})
            .await
            .unwrap();
        assert_eq!(text, "## Задачи\n\n- [ ] Отчёт");
        let req = rx.recv().unwrap();
        assert!(req.starts_with("POST /chat/completions "), "{req}");
        let sent: Value = serde_json::from_str(req.split("\r\n\r\n").nth(1).unwrap()).unwrap();
        assert_eq!(sent["model"], "anthropic/claude-opus-5.5");
        assert_eq!(sent["messages"][0]["content"], "система");
        assert_eq!(sent["messages"][1]["content"], "заметка");
    }

    #[tokio::test]
    async fn truncated_answer_is_an_error() {
        let reply =
            json!({ "choices": [{ "finish_reason": "length", "message": { "content": "Начало ответа" } }] });
        let (base, _rx) = serve_once(200, &reply.to_string());
        let err = Client::with_base(base, "k")
            .complete("m", "s", "u", |_| {})
            .await
            .unwrap_err();
        assert!(
            matches!(err, Error::Incomplete(ref r) if r == "length"),
            "{err:?}"
        );
    }

    #[tokio::test]
    async fn complete_streams_text_as_it_is_written() {
        let events = [
            ": OPENROUTER PROCESSING",
            r###"data: {"choices":[{"delta":{"content":"## Кон"}}]}"###,
            r#"data: {"choices":[{"delta":{"content":"спект\n\nТекст."}}]}"#,
            r#"data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"cost":0.01}}"#,
            "data: [DONE]",
        ];
        let body = events.map(|e| format!("{e}\n\n")).concat();
        let (base, rx) = serve_once_as(200, "text/event-stream", &body);
        let mut pieces = Vec::new();
        let (text, usage) = Client::with_base(base, "k")
            .complete("m", "s", "u", |t| pieces.push(t.to_string()))
            .await
            .unwrap();
        assert_eq!(pieces, ["## Кон", "спект\n\nТекст."]);
        assert_eq!(text, "## Конспект\n\nТекст.");
        assert_eq!(usage.cost, Some(0.01));
        let req = rx.recv().unwrap();
        let sent: Value = serde_json::from_str(req.split("\r\n\r\n").nth(1).unwrap()).unwrap();
        assert_eq!(sent["stream"], true);
    }

    #[tokio::test]
    async fn stream_error_and_cut_answer_are_errors() {
        let body = "data: {\"choices\":[{\"delta\":{\"content\":\"Нача\"}}]}\n\ndata: {\"error\":{\"code\":502,\"message\":\"provider down\"}}\n\n";
        let (base, _rx) = serve_once_as(200, "text/event-stream", body);
        let err = Client::with_base(base, "k")
            .complete("m", "s", "u", |_| {})
            .await
            .unwrap_err();
        assert!(
            matches!(err, Error::Http { status: 502, ref message } if message == "provider down"),
            "{err:?}"
        );
        let body =
            "data: {\"choices\":[{\"delta\":{\"content\":\"Нача\"},\"finish_reason\":\"length\"}]}\n\n";
        let (base, _rx) = serve_once_as(200, "text/event-stream", body);
        let err = Client::with_base(base, "k")
            .complete("m", "s", "u", |_| {})
            .await
            .unwrap_err();
        assert!(
            matches!(err, Error::Incomplete(ref r) if r == "length"),
            "{err:?}"
        );
    }
}