169ce64e1705fd64e48ee357a816bbdb90bab01d
Ветки
Перейти к файлуt
A
anc
Этап 1: запись встречи в Zoom, живая расшифровка и разбор
- crates/audio: захват звука процесса Zoom и микрофона (WASAPI, process
loopback), формат 16 кГц моно, нарезка на куски по паузам с отсевом
тишины, распознавание начала и конца встречи по звуковой сессии Zoom.
- Ядро: запись двух дорожек → Whisper через OpenRouter → расшифровка,
живой конспект раз в три минуты, по окончании — разбор сам; звук
удаляется после разбора, если не включено хранение.
- Контроллер: слежу за Zoom → прошу предупредить участников → пишу;
запись вручную, пауза, стоп; прерванная запись помечается при запуске.
- Трей, уведомления, крестик прячет окно, запуск вместе с Windows.
- Интерфейс: главный экран с карточкой состояния (задача #1), живая
расшифровка и конспект на экране встречи, новые настройки.
- reqwest на системном TLS: под Windows без C-кода ring.
- 67 тестов; код под Windows проверен компиляцией и clippy с Mac,
на Windows не запускался.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169ce64
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;
use crate::analysis::{self, Analysis, InvalidAnalysis};
use crate::transcript::Transcript;
/// Промпт живого конспекта по ходу встречи.
pub const LIVE_PROMPT: &str = include_str!("../../prompts/live.md");
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)?;
Ok((analysis, usage_of(&body)))
}
/// Распознаёт кусок звука (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))
}
/// Короткий живой конспект по расшифровке, накопленной к этому моменту.
pub async fn live_summary(&self, model: &str, transcript: &Transcript) -> Result<(String, Usage), Error> {
let request = json!({
"model": model,
"max_tokens": 1200,
"messages": [
{ "role": "system", "content": LIVE_PROMPT },
{ "role": "user", "content": transcript.to_prompt_text() }
]
});
let body = self
.send(
self.http
.post(format!("{}/chat/completions", self.base))
.json(&request),
)
.await?;
let text = body["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("")
.trim()
.to_string();
if text.is_empty() {
return Err(Error::Incomplete("пустой ответ".into()));
}
Ok((text, usage_of(&body)))
}
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 })
}
}
}
}
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>) {
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 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 live_summary_returns_plain_text() {
let reply = json!({ "choices": [{ "finish_reason": "stop", "message": { "content": " - Обсуждают отчёт\n" } }] });
let (base, rx) = serve_once(200, &reply.to_string());
let (text, _) = Client::with_base(base, "k")
.live_summary("anthropic/claude-haiku-4.5", &transcript())
.await
.unwrap();
assert_eq!(text, "- Обсуждают отчёт");
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["messages"][0]["content"], LIVE_PROMPT);
assert!(sent.get("response_format").is_none());
}
#[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:?}"
);
}
}