d93e31a829c96025e18e4ceed0cf1552df5934c8
Ветки
Перейти к файлуt
A
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 г.
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());
}
}