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 г.
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] Борис: Возьму. Срок: пятница."));
}
}