skillok meeting
169ce64e1705fd64e48ee357a816bbdb90bab01d
Перейти к файлу
Код
meeting src-taurisrcmeetings.rs
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 г.
meetings.rs
//! Встречи на диске пользователя. Каждая — своя папка:
//!
//! ```text
//! <корень>/20260930-154300/
//!   meeting.json     — название, когда, откуда, модель и цена разбора
//!   transcript.json  — реплики
//!   transcript.md    — расшифровка для людей
//!   analysis.json    — разбор (после «Разобрать»)
//!   summary.md       — разбор для людей; это его сохраним в Skillok на этапе 2
//!   live.md          — живой конспект, пока идёт запись
//!   audio/me.wav, audio/others.wav — дорожки записи; удаляются после разбора,
//!                      если в настройках не сказано хранить звук
//! ```
//!
//! Запись меняет встречу много раз в минуту из разных задач (реплики, живой
//! конспект, разбор), поэтому каждое изменение «прочитал — поменял — записал»
//! идёт под одним замком: иначе две задачи затирали бы друг друга.

use std::path::{Path, PathBuf};
use std::sync::Mutex;

use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};

use crate::analysis::Analysis;
use crate::openrouter::Usage;
use crate::transcript::{ParseError, Segment, Speaker, Transcript};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Source {
    /// Текст расшифровки, который пользователь вставил сам.
    Import,
    /// Запись Zoom или микрофона.
    Recording,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MeetingState {
    /// Идёт запись: реплики ещё прибывают.
    Recording,
    #[default]
    Done,
    /// Программа закрылась посреди записи; что успело — сохранено.
    Interrupted,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Meeting {
    pub id: String,
    pub title: String,
    pub created_at: DateTime<Local>,
    pub source: Source,
    pub duration_ms: u64,
    pub segments: usize,
    pub analysis: Option<AnalysisMeta>,
    #[serde(default)]
    pub state: MeetingState,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnalysisMeta {
    pub model: String,
    pub at: DateTime<Local>,
    pub usage: Usage,
    pub tasks: usize,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct MeetingDetail {
    pub meeting: Meeting,
    pub transcript: Transcript,
    pub analysis: Option<Analysis>,
    pub live_summary: Option<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("встреча «{0}» не найдена")]
    NotFound(String),
    #[error("название встречи не может быть пустым")]
    BlankTitle,
    #[error("расшифровка не разбирается: {0}")]
    Parse(#[from] ParseError),
    #[error("файл встречи: {0}")]
    Io(#[from] std::io::Error),
    #[error("файл встречи повреждён: {0}")]
    Corrupt(#[from] serde_json::Error),
}

pub struct Store {
    root: PathBuf,
    write: Mutex<()>,
}

impl Store {
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self {
            root: root.into(),
            write: Mutex::new(()),
        }
    }

    fn lock(&self) -> std::sync::MutexGuard<'_, ()> {
        self.write.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Все встречи, свежие сверху. Папка с повреждённым `meeting.json`
    /// пропускается, а не роняет весь список.
    pub fn list(&self) -> Result<Vec<Meeting>, Error> {
        let entries = match std::fs::read_dir(&self.root) {
            Ok(e) => e,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
            Err(e) => return Err(e.into()),
        };
        let mut out = Vec::new();
        for entry in entries.flatten() {
            let file = entry.path().join("meeting.json");
            if let Ok(text) = std::fs::read_to_string(&file) {
                if let Ok(m) = serde_json::from_str::<Meeting>(&text) {
                    out.push(m);
                }
            }
        }
        out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(b.id.cmp(&a.id)));
        Ok(out)
    }

    pub fn get(&self, id: &str) -> Result<MeetingDetail, Error> {
        let dir = self.dir(id)?;
        let meeting: Meeting = read_json(&dir.join("meeting.json"))?;
        let transcript: Transcript = read_json(&dir.join("transcript.json"))?;
        let analysis_path = dir.join("analysis.json");
        let analysis = if analysis_path.exists() {
            Some(read_json(&analysis_path)?)
        } else {
            None
        };
        let live_summary = std::fs::read_to_string(dir.join("live.md")).ok();
        Ok(MeetingDetail {
            meeting,
            transcript,
            analysis,
            live_summary,
        })
    }

    /// Новая встреча из вставленного текста расшифровки.
    pub fn import(&self, title: &str, text: &str, now: DateTime<Local>) -> Result<Meeting, Error> {
        let title = title.trim();
        if title.is_empty() {
            return Err(Error::BlankTitle);
        }
        let transcript = Transcript::parse(text)?;
        let _guard = self.lock();
        let id = self.free_id(now)?;
        let dir = self.root.join(&id);
        std::fs::create_dir_all(&dir)?;
        let meeting = Meeting {
            id,
            title: title.to_string(),
            created_at: now,
            source: Source::Import,
            duration_ms: transcript.duration_ms(),
            segments: transcript.segments.len(),
            analysis: None,
            state: MeetingState::Done,
        };
        write_json(&dir.join("transcript.json"), &transcript)?;
        std::fs::write(dir.join("transcript.md"), transcript.to_markdown(title))?;
        // meeting.json пишется последним: пока его нет, список встречу не видит.
        write_json(&dir.join("meeting.json"), &meeting)?;
        Ok(meeting)
    }

    pub fn save_analysis(
        &self,
        id: &str,
        analysis: &Analysis,
        model: &str,
        usage: Usage,
        now: DateTime<Local>,
    ) -> Result<Meeting, Error> {
        let _guard = self.lock();
        let dir = self.dir(id)?;
        let mut meeting: Meeting = read_json(&dir.join("meeting.json"))?;
        let transcript: Transcript = read_json(&dir.join("transcript.json"))?;
        write_json(&dir.join("analysis.json"), analysis)?;
        std::fs::write(dir.join("summary.md"), analysis.to_markdown(&transcript))?;
        meeting.analysis = Some(AnalysisMeta {
            model: model.to_string(),
            at: now,
            usage,
            tasks: analysis.tasks.len(),
        });
        write_json(&dir.join("meeting.json"), &meeting)?;
        Ok(meeting)
    }

    /// Новая встреча под запись: пустая расшифровка, состояние «идёт запись».
    pub fn create_recording(&self, title: &str, now: DateTime<Local>) -> Result<Meeting, Error> {
        let _guard = self.lock();
        let id = self.free_id(now)?;
        let dir = self.root.join(&id);
        std::fs::create_dir_all(dir.join("audio"))?;
        let meeting = Meeting {
            id,
            title: title.to_string(),
            created_at: now,
            source: Source::Recording,
            duration_ms: 0,
            segments: 0,
            analysis: None,
            state: MeetingState::Recording,
        };
        let empty = Transcript::default();
        write_json(&dir.join("transcript.json"), &empty)?;
        std::fs::write(dir.join("transcript.md"), empty.to_markdown(title))?;
        write_json(&dir.join("meeting.json"), &meeting)?;
        Ok(meeting)
    }

    /// Дописывает реплики. Реплики двух дорожек приходят не по порядку
    /// (кусок «собеседников» распознаётся дольше), поэтому расшифровка
    /// каждый раз упорядочивается по времени.
    pub fn append_segments(&self, id: &str, new: &[Segment]) -> Result<Meeting, Error> {
        let _guard = self.lock();
        let dir = self.dir(id)?;
        let mut meeting: Meeting = read_json(&dir.join("meeting.json"))?;
        let mut transcript: Transcript = read_json(&dir.join("transcript.json"))?;
        transcript.segments.extend_from_slice(new);
        transcript.segments.sort_by_key(|s| s.start_ms);
        write_json(&dir.join("transcript.json"), &transcript)?;
        std::fs::write(dir.join("transcript.md"), transcript.to_markdown(&meeting.title))?;
        meeting.segments = transcript.segments.len();
        meeting.duration_ms = meeting.duration_ms.max(transcript.duration_ms());
        write_json(&dir.join("meeting.json"), &meeting)?;
        Ok(meeting)
    }

    pub fn save_live_summary(&self, id: &str, text: &str) -> Result<(), Error> {
        let _guard = self.lock();
        let dir = self.dir(id)?;
        std::fs::write(dir.join("live.md"), text)?;
        Ok(())
    }

    /// Запись закончилась: настоящая длительность — по звуку, а не по последней реплике.
    pub fn finish_recording(&self, id: &str, duration_ms: u64) -> Result<Meeting, Error> {
        let _guard = self.lock();
        let dir = self.dir(id)?;
        let mut meeting: Meeting = read_json(&dir.join("meeting.json"))?;
        meeting.duration_ms = meeting.duration_ms.max(duration_ms);
        meeting.state = MeetingState::Done;
        write_json(&dir.join("meeting.json"), &meeting)?;
        Ok(meeting)
    }

    /// Встречи, оставшиеся «в записи» после падения или выключения
    /// компьютера, помечаются прерванными. Зовётся при запуске программы.
    pub fn recover_interrupted(&self) -> Result<Vec<String>, Error> {
        let _guard = self.lock();
        let mut ids = Vec::new();
        for m in self.list()? {
            if m.state == MeetingState::Recording {
                let mut m = m;
                m.state = MeetingState::Interrupted;
                write_json(&self.root.join(&m.id).join("meeting.json"), &m)?;
                ids.push(m.id);
            }
        }
        Ok(ids)
    }

    /// Файл дорожки записи.
    pub fn audio_path(&self, id: &str, speaker: &Speaker) -> Result<PathBuf, Error> {
        let dir = self.dir(id)?.join("audio");
        std::fs::create_dir_all(&dir)?;
        let name = match speaker {
            Speaker::Me => "me",
            Speaker::Others => "others",
            Speaker::Named(_) => "named",
        };
        Ok(dir.join(format!("{name}.wav")))
    }

    pub fn delete_audio(&self, id: &str) -> Result<(), Error> {
        let dir = self.dir(id)?.join("audio");
        match std::fs::remove_dir_all(&dir) {
            Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
            _ => Ok(()),
        }
    }

    /// Папка встречи. Идентификатор приходит из интерфейса, поэтому в путь
    /// попадают только цифры и дефис — `..` и слэши сюда не пролезут.
    fn dir(&self, id: &str) -> Result<PathBuf, Error> {
        let valid = !id.is_empty() && id.len() <= 32 && id.chars().all(|c| c.is_ascii_digit() || c == '-');
        let dir = self.root.join(id);
        if valid && dir.join("meeting.json").is_file() {
            Ok(dir)
        } else {
            Err(Error::NotFound(id.to_string()))
        }
    }

    fn free_id(&self, now: DateTime<Local>) -> Result<String, Error> {
        let base = now.format("%Y%m%d-%H%M%S").to_string();
        let mut id = base.clone();
        let mut n = 1;
        while self.root.join(&id).exists() {
            n += 1;
            id = format!("{base}-{n}");
        }
        Ok(id)
    }
}

fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, Error> {
    Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
}

fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), Error> {
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, serde_json::to_vec_pretty(value)?)?;
    std::fs::rename(&tmp, path)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::analysis::Review;
    use chrono::TimeZone;

    fn at(h: u32, m: u32) -> DateTime<Local> {
        Local.with_ymd_and_hms(2026, 9, 30, h, m, 0).unwrap()
    }

    const TEXT: &str = "[00:00:01] Анна: Отчёт к пятнице.\n[00:02:05] Борис: Возьму.";

    #[test]
    fn import_list_get() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path().join("meetings"));
        assert!(store.list().unwrap().is_empty(), "нет папки — пустой список");

        let a = store.import("Планёрка", TEXT, at(10, 0)).unwrap();
        let b = store.import("Созвон", TEXT, at(11, 0)).unwrap();
        assert_eq!(a.id, "20260930-100000");
        assert_eq!(a.duration_ms, 125_000);
        assert_eq!(a.segments, 2);

        let ids: Vec<_> = store.list().unwrap().into_iter().map(|m| m.id).collect();
        assert_eq!(ids, vec![b.id.clone(), a.id.clone()], "свежие сверху");

        let detail = store.get(&a.id).unwrap();
        assert_eq!(detail.transcript.segments.len(), 2);
        assert!(detail.analysis.is_none());
        let md = std::fs::read_to_string(store.root().join(&a.id).join("transcript.md")).unwrap();
        assert!(md.contains("`00:02:05` **Борис:** Возьму."), "{md}");
    }

    #[test]
    fn same_second_gets_a_new_id() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path());
        let a = store.import("Раз", TEXT, at(10, 0)).unwrap();
        let b = store.import("Два", TEXT, at(10, 0)).unwrap();
        assert_eq!(a.id, "20260930-100000");
        assert_eq!(b.id, "20260930-100000-2");
    }

    #[test]
    fn bad_input_creates_nothing() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path());
        assert!(matches!(
            store.import("  ", TEXT, at(10, 0)),
            Err(Error::BlankTitle)
        ));
        assert!(matches!(
            store.import("Т", "без меток", at(10, 0)),
            Err(Error::Parse(_))
        ));
        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
    }

    #[test]
    fn ids_cannot_escape_the_root() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path().join("m"));
        std::fs::create_dir_all(dir.path().join("secret")).unwrap();
        std::fs::write(dir.path().join("secret/meeting.json"), "{}").unwrap();
        for id in ["../secret", "..", "", "a/b", "20260930-100000"] {
            assert!(matches!(store.get(id), Err(Error::NotFound(_))), "{id}");
        }
    }

    #[test]
    fn broken_meeting_is_skipped_in_list() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path());
        store.import("Целая", TEXT, at(10, 0)).unwrap();
        std::fs::create_dir_all(dir.path().join("20260930-090000")).unwrap();
        std::fs::write(dir.path().join("20260930-090000/meeting.json"), "{битый").unwrap();
        assert_eq!(store.list().unwrap().len(), 1);
    }

    #[test]
    fn recording_lifecycle() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path());
        let m = store.create_recording("Встреча в Zoom", at(10, 0)).unwrap();
        assert_eq!(m.state, MeetingState::Recording);
        assert!(store.get(&m.id).unwrap().transcript.segments.is_empty());

        let seg = |ms, speaker: Speaker, text: &str| Segment {
            start_ms: ms,
            speaker,
            text: text.into(),
        };
        store
            .append_segments(&m.id, &[seg(9_000, Speaker::Others, "Слышно?")])
            .unwrap();
        let after = store
            .append_segments(&m.id, &[seg(2_000, Speaker::Me, "Всем привет")])
            .unwrap();
        assert_eq!(after.segments, 2);
        let t = store.get(&m.id).unwrap().transcript;
        assert_eq!(
            t.segments[0].text, "Всем привет",
            "реплики упорядочены по времени"
        );

        store.save_live_summary(&m.id, "- Здороваются").unwrap();
        assert_eq!(
            store.get(&m.id).unwrap().live_summary.as_deref(),
            Some("- Здороваются")
        );

        let wav = store.audio_path(&m.id, &Speaker::Me).unwrap();
        std::fs::write(&wav, b"x").unwrap();
        let done = store.finish_recording(&m.id, 65_000).unwrap();
        assert_eq!(done.state, MeetingState::Done);
        assert_eq!(done.duration_ms, 65_000);
        store.delete_audio(&m.id).unwrap();
        assert!(!wav.exists());
        store.delete_audio(&m.id).unwrap();
    }

    #[test]
    fn unfinished_recording_is_marked_interrupted_on_start() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path());
        let rec = store.create_recording("Оборвалась", at(10, 0)).unwrap();
        let imported = store.import("Целая", TEXT, at(11, 0)).unwrap();
        assert_eq!(store.recover_interrupted().unwrap(), vec![rec.id.clone()]);
        assert_eq!(
            store.get(&rec.id).unwrap().meeting.state,
            MeetingState::Interrupted
        );
        assert_eq!(store.get(&imported.id).unwrap().meeting.state, MeetingState::Done);
    }

    #[test]
    fn old_meeting_json_without_state_reads_as_done() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path());
        let m = store.import("Старая", TEXT, at(10, 0)).unwrap();
        let path = dir.path().join(&m.id).join("meeting.json");
        let mut v: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
        v.as_object_mut().unwrap().remove("state");
        std::fs::write(&path, v.to_string()).unwrap();
        assert_eq!(store.get(&m.id).unwrap().meeting.state, MeetingState::Done);
    }

    #[test]
    fn save_analysis_writes_summary_and_meta() {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::new(dir.path());
        let m = store.import("Планёрка", TEXT, at(10, 0)).unwrap();
        let analysis = Analysis {
            title: "Отчёт".into(),
            summary: "Договорились.".into(),
            review: Review {
                achieved: vec![],
                open: vec![],
                off_track: vec![],
                verdict: "Ок.".into(),
            },
            decisions: vec![],
            tasks: vec![],
            open_questions: vec![],
        };
        let usage = Usage {
            prompt_tokens: 10,
            completion_tokens: 5,
            cost: Some(0.01),
        };
        let saved = store
            .save_analysis(&m.id, &analysis, "anthropic/claude-opus-5.5", usage, at(10, 5))
            .unwrap();
        assert_eq!(
            saved.analysis.as_ref().unwrap().model,
            "anthropic/claude-opus-5.5"
        );

        let detail = store.get(&m.id).unwrap();
        assert_eq!(detail.analysis, Some(analysis));
        assert_eq!(detail.meeting.analysis.unwrap().usage.cost, Some(0.01));
        assert!(store.root().join(&m.id).join("summary.md").is_file());
    }
}