skillok meeting
169ce64e1705fd64e48ee357a816bbdb90bab01d
Перейти к файлу
Код
meeting src-taurisrcanalysis.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 г.
analysis.rs
//! Разбор встречи: что модель обязана вернуть, как её об этом просить и как
//! собрать из ответа `summary.md`.
//!
//! Модель отвечает JSON строго по [`schema`] (structured outputs OpenRouter,
//! `strict: true`). Схема и структура [`Analysis`] описывают одно и то же, и
//! тест `schema_matches_struct` держит их вместе: поле, добавленное в одно
//! место и забытое в другом, падает тестом, а не молча пропадает из разбора.

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use crate::transcript::{format_timestamp, Transcript};

/// Системный промпт разбора. Лежит файлом, чтобы его правили без Rust.
pub const SYSTEM_PROMPT: &str = include_str!("../../prompts/analysis.md");

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Analysis {
    pub title: String,
    pub summary: String,
    pub review: Review,
    pub decisions: Vec<Decision>,
    pub tasks: Vec<Task>,
    pub open_questions: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Review {
    pub achieved: Vec<String>,
    pub open: Vec<String>,
    pub off_track: Vec<String>,
    pub verdict: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Decision {
    pub text: String,
    pub source_segment: usize,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Task {
    pub text: String,
    pub owner: Option<String>,
    pub due: Option<String>,
    pub quote: String,
    pub source_segment: usize,
}

#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InvalidAnalysis {
    #[error("ответ модели не совпал со схемой: {0}")]
    Shape(String),
    #[error("{what} ссылается на реплику #{index}, а в расшифровке их {len}")]
    SegmentOutOfRange {
        what: &'static str,
        index: usize,
        len: usize,
    },
    #[error("пустое поле «{0}»")]
    Blank(&'static str),
}

/// JSON Schema ответа. Только то, что понимают строгие режимы провайдеров:
/// без `minimum`, `format` и прочих ограничений — их проверяет [`Analysis::validate`].
pub fn schema() -> Value {
    let string = json!({ "type": "string" });
    let strings = json!({ "type": "array", "items": { "type": "string" } });
    let nullable = json!({ "anyOf": [{ "type": "string" }, { "type": "null" }] });
    let segment = json!({ "type": "integer", "description": "Номер реплики N из #N" });
    json!({
        "type": "object",
        "additionalProperties": false,
        "required": ["title", "summary", "review", "decisions", "tasks", "open_questions"],
        "properties": {
            "title": string,
            "summary": string,
            "review": {
                "type": "object",
                "additionalProperties": false,
                "required": ["achieved", "open", "off_track", "verdict"],
                "properties": {
                    "achieved": strings,
                    "open": strings,
                    "off_track": strings,
                    "verdict": string
                }
            },
            "decisions": {
                "type": "array",
                "items": {
                    "type": "object",
                    "additionalProperties": false,
                    "required": ["text", "source_segment"],
                    "properties": { "text": string, "source_segment": segment }
                }
            },
            "tasks": {
                "type": "array",
                "items": {
                    "type": "object",
                    "additionalProperties": false,
                    "required": ["text", "owner", "due", "quote", "source_segment"],
                    "properties": {
                        "text": string,
                        "owner": nullable,
                        "due": nullable,
                        "quote": string,
                        "source_segment": segment
                    }
                }
            },
            "open_questions": strings
        }
    })
}

/// Сообщение пользователя для модели: название, если оно уже есть, и
/// пронумерованная расшифровка.
pub fn user_message(title: &str, transcript: &Transcript) -> String {
    format!(
        "Встреча: {title}\nДлительность: {}\n\nРасшифровка:\n{}",
        format_timestamp(transcript.duration_ms()),
        transcript.to_prompt_text()
    )
}

impl Analysis {
    /// Разбирает ответ модели и проверяет то, чего схема выразить не может.
    pub fn from_model_json(raw: &str, transcript: &Transcript) -> Result<Self, InvalidAnalysis> {
        let analysis: Analysis =
            serde_json::from_str(raw).map_err(|e| InvalidAnalysis::Shape(e.to_string()))?;
        analysis.validate(transcript)?;
        Ok(analysis)
    }

    pub fn validate(&self, transcript: &Transcript) -> Result<(), InvalidAnalysis> {
        if self.title.trim().is_empty() {
            return Err(InvalidAnalysis::Blank("title"));
        }
        if self.summary.trim().is_empty() {
            return Err(InvalidAnalysis::Blank("summary"));
        }
        let len = transcript.segments.len();
        let check = |what, index: usize| {
            if index < len {
                Ok(())
            } else {
                Err(InvalidAnalysis::SegmentOutOfRange { what, index, len })
            }
        };
        for d in &self.decisions {
            check("решение", d.source_segment)?;
        }
        for t in &self.tasks {
            check("задача", t.source_segment)?;
        }
        Ok(())
    }

    /// `summary.md`: конспект, ревью, решения, задачи, вопросы. Время у
    /// решений и задач берётся из реплики, на которую сослалась модель.
    pub fn to_markdown(&self, transcript: &Transcript) -> String {
        let at = |i: usize| {
            transcript
                .segments
                .get(i)
                .map(|s| format!(" `{}`", format_timestamp(s.start_ms)))
                .unwrap_or_default()
        };
        let mut out = format!("# {}\n\n{}\n\n## Ревью\n\n", self.title, self.summary);
        push_list(&mut out, "Чего добились", &self.review.achieved);
        push_list(&mut out, "Осталось открытым", &self.review.open);
        push_list(&mut out, "Где ушли в сторону", &self.review.off_track);
        out.push_str(&format!("**Итог:** {}\n\n", self.review.verdict));

        out.push_str("## Решения\n\n");
        if self.decisions.is_empty() {
            out.push_str("Явных решений не было.\n\n");
        } else {
            for d in &self.decisions {
                out.push_str(&format!("- {}{}\n", d.text, at(d.source_segment)));
            }
            out.push('\n');
        }

        out.push_str("## Задачи\n\n");
        if self.tasks.is_empty() {
            out.push_str("Задач не прозвучало.\n\n");
        } else {
            for t in &self.tasks {
                let owner = t.owner.as_deref().unwrap_or("исполнитель не назван");
                let due = t.due.as_deref().map(|d| format!(", {d}")).unwrap_or_default();
                out.push_str(&format!(
                    "- [ ] {} — {owner}{due}{}\n  > {}\n",
                    t.text,
                    at(t.source_segment),
                    t.quote
                ));
            }
            out.push('\n');
        }

        if !self.open_questions.is_empty() {
            out.push_str("## Открытые вопросы\n\n");
            for q in &self.open_questions {
                out.push_str(&format!("- {q}\n"));
            }
            out.push('\n');
        }
        out
    }
}

fn push_list(out: &mut String, heading: &str, items: &[String]) {
    if items.is_empty() {
        return;
    }
    out.push_str(&format!("**{heading}**\n\n"));
    for item in items {
        out.push_str(&format!("- {item}\n"));
    }
    out.push('\n');
}

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

    fn transcript() -> Transcript {
        Transcript::parse(
            "[00:00:01] Анна: Отчёт нужен к пятнице.\n[00:00:09] Борис: Возьму. Срок: пятница.\n",
        )
        .unwrap()
    }

    fn sample() -> Value {
        json!({
            "title": "Отчёт",
            "summary": "Договорились об отчёте.",
            "review": { "achieved": ["Нашли исполнителя"], "open": [], "off_track": [], "verdict": "Коротко и по делу." },
            "decisions": [{ "text": "Отчёт делает Борис", "source_segment": 1 }],
            "tasks": [{ "text": "Сделать отчёт", "owner": "Борис", "due": "к пятнице", "quote": "Возьму.", "source_segment": 1 }],
            "open_questions": ["Нужны ли графики?"]
        })
    }

    #[test]
    fn parses_and_validates_a_good_answer() {
        let a = Analysis::from_model_json(&sample().to_string(), &transcript()).unwrap();
        assert_eq!(a.tasks[0].owner.as_deref(), Some("Борис"));
    }

    #[test]
    fn null_owner_and_due_are_allowed() {
        let mut v = sample();
        v["tasks"][0]["owner"] = Value::Null;
        v["tasks"][0]["due"] = Value::Null;
        let a = Analysis::from_model_json(&v.to_string(), &transcript()).unwrap();
        let md = a.to_markdown(&transcript());
        assert!(
            md.contains("- [ ] Сделать отчёт — исполнитель не назван `00:00:09`"),
            "{md}"
        );
    }

    #[test]
    fn rejects_reference_past_the_transcript() {
        let mut v = sample();
        v["tasks"][0]["source_segment"] = json!(7);
        assert_eq!(
            Analysis::from_model_json(&v.to_string(), &transcript()),
            Err(InvalidAnalysis::SegmentOutOfRange {
                what: "задача",
                index: 7,
                len: 2
            })
        );
    }

    #[test]
    fn rejects_unknown_and_missing_fields() {
        let mut extra = sample();
        extra["mood"] = json!("хорошее");
        assert!(matches!(
            Analysis::from_model_json(&extra.to_string(), &transcript()),
            Err(InvalidAnalysis::Shape(_))
        ));
        let mut missing = sample();
        missing.as_object_mut().unwrap().remove("tasks");
        assert!(matches!(
            Analysis::from_model_json(&missing.to_string(), &transcript()),
            Err(InvalidAnalysis::Shape(_))
        ));
    }

    /// Каждый объект схемы строгий, все его поля обязательны, и набор полей
    /// совпадает с тем, что пишет `serde` для структуры.
    #[test]
    fn schema_matches_struct() {
        fn walk(schema: &Value, value: &Value, path: &str) {
            if let Some(props) = schema.get("properties").and_then(Value::as_object) {
                assert_eq!(
                    schema["additionalProperties"],
                    json!(false),
                    "{path}: не строгий объект"
                );
                let mut required: Vec<&str> = schema["required"]
                    .as_array()
                    .unwrap()
                    .iter()
                    .map(|v| v.as_str().unwrap())
                    .collect();
                let mut declared: Vec<&str> = props.keys().map(String::as_str).collect();
                let mut actual: Vec<&str> = value.as_object().unwrap().keys().map(String::as_str).collect();
                required.sort();
                declared.sort();
                actual.sort();
                assert_eq!(required, declared, "{path}: не все поля обязательны");
                assert_eq!(declared, actual, "{path}: схема и структура разошлись");
                for (k, sub) in props {
                    walk(sub, &value[k], &format!("{path}.{k}"));
                }
            } else if let Some(items) = schema.get("items") {
                if let Some(first) = value.as_array().and_then(|a| a.first()) {
                    walk(items, first, &format!("{path}[]"));
                }
            }
        }
        let a = Analysis::from_model_json(&sample().to_string(), &transcript()).unwrap();
        walk(&schema(), &serde_json::to_value(&a).unwrap(), "$");
    }

    #[test]
    fn markdown_has_every_section() {
        let a = Analysis::from_model_json(&sample().to_string(), &transcript()).unwrap();
        let md = a.to_markdown(&transcript());
        for needle in [
            "# Отчёт",
            "## Ревью",
            "**Чего добились**",
            "**Итог:** Коротко и по делу.",
            "## Решения",
            "- Отчёт делает Борис `00:00:09`",
            "## Задачи",
            "- [ ] Сделать отчёт — Борис, к пятнице `00:00:09`",
            "  > Возьму.",
            "## Открытые вопросы",
        ] {
            assert!(md.contains(needle), "нет «{needle}» в\n{md}");
        }
        assert!(!md.contains("Где ушли в сторону"), "пустой список не выводится");
    }

    #[test]
    fn prompt_is_loaded_and_user_message_numbers_segments() {
        assert!(SYSTEM_PROMPT.contains("source_segment"));
        let msg = user_message("Планёрка", &transcript());
        assert!(msg.starts_with("Встреча: Планёрка\nДлительность: 00:00:09\n"));
        assert!(msg.contains("#1 [00:00:09] Борис: Возьму. Срок: пятница."));
    }
}