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

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

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

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

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

#[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>,
}

#[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>,
}

#[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,
}

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

    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
        };
        Ok(MeetingDetail {
            meeting,
            transcript,
            analysis,
        })
    }

    /// Новая встреча из вставленного текста расшифровки.
    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 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,
        };
        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 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)
    }

    /// Папка встречи. Идентификатор приходит из интерфейса, поэтому в путь
    /// попадают только цифры и дефис — `..` и слэши сюда не пролезут.
    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 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());
    }
}