Skip to main content

clawft_plugin/voice/
transcript_log.rs

1//! Voice session transcript logging.
2//!
3//! [`TranscriptLogger`] writes voice session transcripts to JSONL files
4//! in the workspace directory. Each line is a JSON-serialized
5//! [`TranscriptEntry`].
6
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use tokio::io::AsyncWriteExt;
10
11/// A single entry in the voice transcript log.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TranscriptEntry {
14    /// ISO 8601 timestamp.
15    pub timestamp: String,
16    /// Speaker identifier ("user", "agent", or diarized speaker label).
17    pub speaker: String,
18    /// Transcribed or synthesized text.
19    pub text: String,
20    /// Source of transcription ("local", "cloud:openai-whisper", etc.).
21    pub source: String,
22    /// Confidence score (0.0-1.0) for STT entries.
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub confidence: Option<f32>,
25    /// Detected language code.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub language: Option<String>,
28    /// Duration of the audio segment in milliseconds.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub duration_ms: Option<u64>,
31}
32
33/// Appends voice transcript entries to a JSONL file.
34///
35/// Log files are stored at: `{workspace}/.clawft/transcripts/{session_key}.jsonl`
36///
37/// This logger is append-only and does not require locking for
38/// single-session use.
39pub struct TranscriptLogger {
40    path: PathBuf,
41}
42
43impl TranscriptLogger {
44    /// Create a new logger for the given session.
45    ///
46    /// Creates the transcript directory if it does not exist.
47    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    /// Append a transcript entry to the log file.
55    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    /// Read all entries from the log file.
72    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    /// Path to the log file.
83    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        // Write two entries
136        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        // Read back
159        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        // Cleanup
167        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}