skillok meeting
d93e31a829c96025e18e4ceed0cf1552df5934c8
Перейти к файлу
Код
meeting src-taurisrcopenrouter.rs
anc Этап 0: каркас Tauri 2, разбор встречи через OpenRouter, стиль skillok.org - Ядро на Rust: расшифровка и импорт текста, схема разбора со строгой проверкой ответа модели, клиент OpenRouter (проверка ключа, разбор), настройки и ключ в хранилище паролей ОС, встречи папками на диске. - Интерфейс: экраны «Встречи», «Встреча» (расшифровка, конспект, задачи), «Настройки»; токены, шрифты и кнопки как на skillok.org. - Промпт разбора prompts/analysis.md и проверочные расшифровки. - 30 тестов ядра; клиент OpenRouter проверяется против локальной заглушки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> d93e31a 30 сентября 2026 г.
openrouter.rs
//! Клиент OpenRouter: проверка ключа и разбор встречи.
//!
//! Один ключ пользователя на всё: на этапе 1 через него же пойдёт расшифровка
//! (`/audio/transcriptions`). Документация:
//! - ключ: `GET /api/v1/key` — https://openrouter.ai/docs/api/reference/limits
//! - строгий 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 crate::analysis::{self, Analysis, InvalidAnalysis};
use crate::transcript::Transcript;

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),
    #[error("Модель вернула негодный разбор: {0}")]
    BadAnswer(#[from] InvalidAnalysis),
}

/// Что показать в настройках после проверки ключа.
#[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),
        })
    }

    /// Итоговый разбор встречи моделью `model` строго по [`analysis::schema`].
    pub async fn analyze(
        &self,
        model: &str,
        title: &str,
        transcript: &Transcript,
    ) -> Result<(Analysis, Usage), Error> {
        let request = json!({
            "model": model,
            "max_tokens": 16000,
            "messages": [
                { "role": "system", "content": analysis::SYSTEM_PROMPT },
                { "role": "user", "content": analysis::user_message(title, transcript) }
            ],
            "response_format": {
                "type": "json_schema",
                "json_schema": { "name": "meeting_analysis", "strict": true, "schema": analysis::schema() }
            },
            // Только провайдеры, которые действительно держат строгую схему.
            "provider": { "require_parameters": true }
        });
        let body = self
            .send(
                self.http
                    .post(format!("{}/chat/completions", self.base))
                    .json(&request),
            )
            .await?;

        let choice = &body["choices"][0];
        let finish = choice["finish_reason"].as_str().unwrap_or("");
        if finish != "stop" {
            return Err(Error::Incomplete(if finish.is_empty() {
                "нет ответа".into()
            } else {
                finish.into()
            }));
        }
        let content = choice["message"]["content"].as_str().unwrap_or("");
        let analysis = Analysis::from_model_json(content, transcript)?;
        let 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(),
        };
        Ok((analysis, usage))
    }

    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()))?;
        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 })
            }
        }
    }
}

#[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>) {
        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: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
                body.len()
            );
            stream.write_all(reply.as_bytes()).unwrap();
        });
        (format!("http://{addr}"), rx)
    }

    fn transcript() -> Transcript {
        Transcript::parse("[00:00:01] Анна: Отчёт к пятнице.\n[00:00:05] Борис: Возьму.").unwrap()
    }

    #[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 analyze_sends_strict_schema_and_parses_answer() {
        let content = json!({
            "title": "Отчёт", "summary": "Договорились.",
            "review": { "achieved": [], "open": [], "off_track": [], "verdict": "Ок." },
            "decisions": [], "open_questions": [],
            "tasks": [{ "text": "Отчёт", "owner": "Борис", "due": "к пятнице", "quote": "Возьму.", "source_segment": 1 }]
        });
        let reply = json!({
            "choices": [{ "finish_reason": "stop", "message": { "content": content.to_string() } }],
            "usage": { "prompt_tokens": 1200, "completion_tokens": 300, "cost": 0.0108 }
        });
        let (base, rx) = serve_once(200, &reply.to_string());
        let (a, usage) = Client::with_base(base, "k")
            .analyze("anthropic/claude-opus-5.5", "Планёрка", &transcript())
            .await
            .unwrap();
        assert_eq!(a.tasks[0].owner.as_deref(), Some("Борис"));
        assert_eq!(
            usage,
            Usage {
                prompt_tokens: 1200,
                completion_tokens: 300,
                cost: Some(0.0108)
            }
        );

        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["response_format"]["json_schema"]["strict"], true);
        assert_eq!(
            sent["response_format"]["json_schema"]["schema"],
            analysis::schema()
        );
        assert_eq!(sent["provider"]["require_parameters"], true);
        assert!(sent["messages"][1]["content"]
            .as_str()
            .unwrap()
            .contains("#1 [00:00:05] Борис: Возьму."));
    }

    #[tokio::test]
    async fn truncated_answer_is_not_parsed() {
        let reply =
            json!({ "choices": [{ "finish_reason": "length", "message": { "content": "{\"title\":" } }] });
        let (base, _rx) = serve_once(200, &reply.to_string());
        let err = Client::with_base(base, "k")
            .analyze("m", "t", &transcript())
            .await
            .unwrap_err();
        assert!(
            matches!(err, Error::Incomplete(ref r) if r == "length"),
            "{err:?}"
        );
    }

    #[tokio::test]
    async fn answer_pointing_past_transcript_is_rejected() {
        let content = json!({
            "title": "T", "summary": "S",
            "review": { "achieved": [], "open": [], "off_track": [], "verdict": "V" },
            "decisions": [{ "text": "D", "source_segment": 9 }], "tasks": [], "open_questions": []
        });
        let reply = json!({ "choices": [{ "finish_reason": "stop", "message": { "content": content.to_string() } }] });
        let (base, _rx) = serve_once(200, &reply.to_string());
        let err = Client::with_base(base, "k")
            .analyze("m", "t", &transcript())
            .await
            .unwrap_err();
        assert!(
            matches!(
                err,
                Error::BadAnswer(InvalidAnalysis::SegmentOutOfRange { index: 9, .. })
            ),
            "{err:?}"
        );
    }
}