clawft_plugin/voice/
transcript_log.rs1use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use tokio::io::AsyncWriteExt;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TranscriptEntry {
14 pub timestamp: String,
16 pub speaker: String,
18 pub text: String,
20 pub source: String,
22 #[serde(skip_serializing_if = "Option::is_none")]
24 pub confidence: Option<f32>,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub language: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub duration_ms: Option<u64>,
31}
32
33pub struct TranscriptLogger {
40 path: PathBuf,
41}
42
43impl TranscriptLogger {
44 pub fn new(workspace: &Path, session_id: &str) -> std::io::Result<Self> {
48 let dir = workspace.join(".clawft").join("transcripts");
49 std::fs::create_dir_all(&dir)?;
50 let path = dir.join(format!("{session_id}.jsonl"));
51 Ok(Self { path })
52 }
53
54 pub async fn log(&self, entry: &TranscriptEntry) -> std::io::Result<()> {
56 let mut line = serde_json::to_string(entry)
57 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
58 line.push('\n');
59
60 let mut file = tokio::fs::OpenOptions::new()
61 .create(true)
62 .append(true)
63 .open(&self.path)
64 .await?;
65
66 file.write_all(line.as_bytes()).await?;
67 file.flush().await?;
68 Ok(())
69 }
70
71 pub async fn read_all(&self) -> std::io::Result<Vec<TranscriptEntry>> {
73 let content = tokio::fs::read_to_string(&self.path).await?;
74 let entries: Vec<TranscriptEntry> = content
75 .lines()
76 .filter(|l| !l.is_empty())
77 .filter_map(|l| serde_json::from_str(l).ok())
78 .collect();
79 Ok(entries)
80 }
81
82 pub fn path(&self) -> &Path {
84 &self.path
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 #[test]
93 fn transcript_entry_serde_roundtrip() {
94 let entry = TranscriptEntry {
95 timestamp: "2026-02-24T12:00:00Z".into(),
96 speaker: "user".into(),
97 text: "hello world".into(),
98 source: "local".into(),
99 confidence: Some(0.95),
100 language: Some("en".into()),
101 duration_ms: Some(1500),
102 };
103 let json = serde_json::to_string(&entry).unwrap();
104 let restored: TranscriptEntry = serde_json::from_str(&json).unwrap();
105 assert_eq!(restored.text, "hello world");
106 assert_eq!(restored.speaker, "user");
107 assert!((restored.confidence.unwrap() - 0.95).abs() < f32::EPSILON);
108 }
109
110 #[test]
111 fn transcript_entry_optional_fields_omitted() {
112 let entry = TranscriptEntry {
113 timestamp: "2026-02-24T12:00:00Z".into(),
114 speaker: "agent".into(),
115 text: "hi".into(),
116 source: "cloud:openai-whisper".into(),
117 confidence: None,
118 language: None,
119 duration_ms: None,
120 };
121 let json = serde_json::to_string(&entry).unwrap();
122 assert!(!json.contains("confidence"));
123 assert!(!json.contains("language"));
124 assert!(!json.contains("duration_ms"));
125 }
126
127 #[tokio::test]
128 async fn logger_write_and_read() {
129 let tmp_dir = std::env::temp_dir().join("clawft_test_transcript");
130 let _ = std::fs::remove_dir_all(&tmp_dir);
131
132 let logger = TranscriptLogger::new(&tmp_dir, "test-session-001").unwrap();
133 assert!(logger.path().to_string_lossy().contains("test-session-001.jsonl"));
134
135 let entry1 = TranscriptEntry {
137 timestamp: "2026-02-24T12:00:00Z".into(),
138 speaker: "user".into(),
139 text: "what time is it".into(),
140 source: "local".into(),
141 confidence: Some(0.85),
142 language: Some("en".into()),
143 duration_ms: Some(2000),
144 };
145 let entry2 = TranscriptEntry {
146 timestamp: "2026-02-24T12:00:01Z".into(),
147 speaker: "agent".into(),
148 text: "it is noon".into(),
149 source: "local".into(),
150 confidence: None,
151 language: None,
152 duration_ms: None,
153 };
154
155 logger.log(&entry1).await.unwrap();
156 logger.log(&entry2).await.unwrap();
157
158 let entries = logger.read_all().await.unwrap();
160 assert_eq!(entries.len(), 2);
161 assert_eq!(entries[0].speaker, "user");
162 assert_eq!(entries[0].text, "what time is it");
163 assert_eq!(entries[1].speaker, "agent");
164 assert_eq!(entries[1].text, "it is noon");
165
166 let _ = std::fs::remove_dir_all(&tmp_dir);
168 }
169
170 #[test]
171 fn logger_creates_directory() {
172 let tmp_dir = std::env::temp_dir().join("clawft_test_transcript_dir");
173 let _ = std::fs::remove_dir_all(&tmp_dir);
174
175 let logger = TranscriptLogger::new(&tmp_dir, "dir-test").unwrap();
176 assert!(logger.path().parent().unwrap().exists());
177
178 let _ = std::fs::remove_dir_all(&tmp_dir);
179 }
180}