skillok meeting
d93e31a829c96025e18e4ceed0cf1552df5934c8
Перейти к файлу
Код
meeting src-taurisrctranscript.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 г.
transcript.rs
//! Расшифровка встречи: реплики с временем и говорящим.
//!
//! На этапе 1 реплики будут приходить из распознавания двух дорожек: «я»
//! (микрофон) и «собеседники» (звук Zoom). Пока их даёт импорт текста — тот же
//! формат, которым написаны проверочные расшифровки в `fixtures/transcripts/`:
//!
//! ```text
//! [00:01:23] Анна: текст реплики
//! продолжение той же реплики на следующей строке
//! ```
//!
//! Имя говорящего — только то, что написано в тексте. Узнавания людей по голосу
//! нет и не будет: это биометрия (см. README, «Согласие на запись»).

use serde::{Deserialize, Serialize};

/// Кто говорит. `Me` и `Others` — две дорожки захвата; `Named` — имя из
/// импортированного текста.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "name", rename_all = "snake_case")]
pub enum Speaker {
    Me,
    Others,
    Named(String),
}

impl Speaker {
    pub fn label(&self) -> &str {
        match self {
            Speaker::Me => "Я",
            Speaker::Others => "Собеседники",
            Speaker::Named(name) => name,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Segment {
    /// Начало реплики от начала встречи, миллисекунды.
    pub start_ms: u64,
    pub speaker: Speaker,
    pub text: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Transcript {
    pub segments: Vec<Segment>,
}

#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ParseError {
    #[error("строка {line}: нет метки времени вида [чч:мм:сс] перед первой репликой")]
    TextBeforeFirstSegment { line: usize },
    #[error("строка {line}: метка времени «{stamp}» не разбирается")]
    BadTimestamp { line: usize, stamp: String },
    #[error("строка {line}: после метки времени нет «Имя:»")]
    NoSpeaker { line: usize },
    #[error("строка {line}: время идёт назад")]
    TimeGoesBack { line: usize },
    #[error("в тексте нет ни одной реплики")]
    Empty,
}

impl Transcript {
    /// Разбирает текст формата `[чч:мм:сс] Имя: реплика`. Строки без метки
    /// продолжают предыдущую реплику; пустые строки пропускаются.
    pub fn parse(text: &str) -> Result<Self, ParseError> {
        let mut segments: Vec<Segment> = Vec::new();
        for (index, raw) in text.lines().enumerate() {
            let line = index + 1;
            let trimmed = raw.trim();
            if trimmed.is_empty() {
                continue;
            }
            let Some(rest) = trimmed.strip_prefix('[') else {
                let Some(last) = segments.last_mut() else {
                    return Err(ParseError::TextBeforeFirstSegment { line });
                };
                last.text.push(' ');
                last.text.push_str(trimmed);
                continue;
            };
            let Some((stamp, rest)) = rest.split_once(']') else {
                return Err(ParseError::BadTimestamp {
                    line,
                    stamp: rest.to_string(),
                });
            };
            let start_ms = parse_timestamp(stamp).ok_or_else(|| ParseError::BadTimestamp {
                line,
                stamp: stamp.to_string(),
            })?;
            let Some((name, said)) = rest.split_once(':') else {
                return Err(ParseError::NoSpeaker { line });
            };
            let name = name.trim();
            if name.is_empty() {
                return Err(ParseError::NoSpeaker { line });
            }
            if segments.last().is_some_and(|prev| prev.start_ms > start_ms) {
                return Err(ParseError::TimeGoesBack { line });
            }
            segments.push(Segment {
                start_ms,
                speaker: Speaker::Named(name.to_string()),
                text: said.trim().to_string(),
            });
        }
        if segments.is_empty() {
            return Err(ParseError::Empty);
        }
        Ok(Transcript { segments })
    }

    /// Длительность — по началу последней реплики. Конца реплики у импорта
    /// нет; у записи он появится на этапе 1.
    pub fn duration_ms(&self) -> u64 {
        self.segments.last().map_or(0, |s| s.start_ms)
    }

    /// Текст для модели: каждая реплика с номером, чтобы разбор мог сослаться
    /// на место, где прозвучала задача (`source_segment`).
    pub fn to_prompt_text(&self) -> String {
        let mut out = String::new();
        for (i, s) in self.segments.iter().enumerate() {
            out.push_str(&format!(
                "#{i} [{}] {}: {}\n",
                format_timestamp(s.start_ms),
                s.speaker.label(),
                s.text
            ));
        }
        out
    }

    /// Markdown для файла `transcript.md`.
    pub fn to_markdown(&self, title: &str) -> String {
        let mut out = format!("# {title} — расшифровка\n\n");
        for s in &self.segments {
            out.push_str(&format!(
                "`{}` **{}:** {}\n\n",
                format_timestamp(s.start_ms),
                s.speaker.label(),
                s.text
            ));
        }
        out
    }
}

/// `чч:мм:сс` или `мм:сс` → миллисекунды.
pub fn parse_timestamp(stamp: &str) -> Option<u64> {
    let parts: Vec<&str> = stamp.trim().split(':').collect();
    let nums: Vec<u64> = parts
        .iter()
        .map(|p| {
            if p.len() == 2 || p.len() == 1 {
                p.parse().ok()
            } else {
                None
            }
        })
        .collect::<Option<_>>()?;
    let (h, m, s) = match nums.as_slice() {
        [m, s] => (0, *m, *s),
        [h, m, s] => (*h, *m, *s),
        _ => return None,
    };
    if m >= 60 || s >= 60 {
        return None;
    }
    Some(((h * 60 + m) * 60 + s) * 1000)
}

pub fn format_timestamp(ms: u64) -> String {
    let total = ms / 1000;
    format!("{:02}:{:02}:{:02}", total / 3600, total / 60 % 60, total % 60)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_segments_and_continuations() {
        let t = Transcript::parse(
            "[00:00:05] Анна: Начнём.\n\n[00:01:10] Борис: Отчёт готов,\nно без графиков.\n",
        )
        .unwrap();
        assert_eq!(t.segments.len(), 2);
        assert_eq!(t.segments[1].start_ms, 70_000);
        assert_eq!(t.segments[1].speaker, Speaker::Named("Борис".into()));
        assert_eq!(t.segments[1].text, "Отчёт готов, но без графиков.");
        assert_eq!(t.duration_ms(), 70_000);
    }

    #[test]
    fn colon_inside_the_text_stays_in_the_text() {
        let t = Transcript::parse("[00:10] Анна: Срок: пятница").unwrap();
        assert_eq!(t.segments[0].start_ms, 10_000);
        assert_eq!(t.segments[0].text, "Срок: пятница");
    }

    #[test]
    fn rejects_bad_input_with_line_numbers() {
        assert_eq!(
            Transcript::parse("привет\n[00:00:01] А: б"),
            Err(ParseError::TextBeforeFirstSegment { line: 1 })
        );
        assert_eq!(
            Transcript::parse("[00:99:00] А: б"),
            Err(ParseError::BadTimestamp {
                line: 1,
                stamp: "00:99:00".into()
            })
        );
        assert_eq!(
            Transcript::parse("[00:00:01] без двоеточия"),
            Err(ParseError::NoSpeaker { line: 1 })
        );
        assert_eq!(
            Transcript::parse("[00:00:05] А: б\n[00:00:01] В: г"),
            Err(ParseError::TimeGoesBack { line: 2 })
        );
        assert_eq!(Transcript::parse("  \n"), Err(ParseError::Empty));
    }

    #[test]
    fn timestamps_round_trip() {
        for ms in [0, 59_000, 3_600_000, 5_025_000] {
            assert_eq!(parse_timestamp(&format_timestamp(ms)), Some(ms));
        }
        assert_eq!(parse_timestamp("1:2:3:4"), None);
        assert_eq!(parse_timestamp("abc"), None);
    }

    #[test]
    fn prompt_text_numbers_segments() {
        let t = Transcript::parse("[00:00:01] А: раз\n[00:00:02] Б: два").unwrap();
        assert_eq!(t.to_prompt_text(), "#0 [00:00:01] А: раз\n#1 [00:00:02] Б: два\n");
    }

    /// Проверочные расшифровки из `fixtures/transcripts/` обязаны разбираться:
    /// на них проверяется промпт разбора.
    #[test]
    fn fixtures_parse() {
        for (name, text, segments) in [
            (
                "planerka",
                include_str!("../../fixtures/transcripts/planerka.txt"),
                24,
            ),
            (
                "klient",
                include_str!("../../fixtures/transcripts/klient.txt"),
                15,
            ),
            (
                "pustaya",
                include_str!("../../fixtures/transcripts/pustaya.txt"),
                6,
            ),
        ] {
            let t = Transcript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"));
            assert_eq!(t.segments.len(), segments, "{name}");
        }
    }

    #[test]
    fn capture_speakers_have_fixed_labels() {
        assert_eq!(Speaker::Me.label(), "Я");
        assert_eq!(Speaker::Others.label(), "Собеседники");
        let json = serde_json::to_string(&Speaker::Named("Анна".into())).unwrap();
        assert_eq!(json, r#"{"kind":"named","name":"Анна"}"#);
    }
}