|
1 | | -"""Transcript handler for capturing and storing STT output to S3.""" |
| 1 | +"""Transcript handler for capturing and storing STT output to S3 or local files.""" |
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import json |
6 | 6 | from dataclasses import dataclass, field |
7 | 7 | from datetime import UTC, datetime |
| 8 | +from pathlib import Path |
8 | 9 | from typing import TYPE_CHECKING, Protocol |
9 | 10 |
|
10 | 11 | import boto3 |
@@ -52,10 +53,27 @@ def to_dict(self) -> dict: |
52 | 53 | } |
53 | 54 |
|
54 | 55 |
|
55 | | -class S3UploaderProtocol(Protocol): |
56 | | - """Protocol for S3 upload operations.""" |
| 56 | +class TranscriptStorageProtocol(Protocol): |
| 57 | + """Protocol for transcript storage operations. |
57 | 58 |
|
58 | | - def upload_transcript(self, transcript: TranscriptData, key: str) -> bool: ... |
| 59 | + Implementations can save transcripts to various backends (local files, S3, etc.). |
| 60 | + """ |
| 61 | + |
| 62 | + def save_transcript(self, transcript: TranscriptData, key: str) -> bool: |
| 63 | + """Save a transcript to storage. |
| 64 | +
|
| 65 | + Args: |
| 66 | + transcript: The transcript data to save |
| 67 | + key: The storage key/path for the transcript |
| 68 | +
|
| 69 | + Returns: |
| 70 | + True if save succeeded, False otherwise |
| 71 | + """ |
| 72 | + ... |
| 73 | + |
| 74 | + |
| 75 | +# Keep S3UploaderProtocol for backward compatibility |
| 76 | +S3UploaderProtocol = TranscriptStorageProtocol |
59 | 77 |
|
60 | 78 |
|
61 | 79 | class S3Uploader: |
@@ -136,28 +154,92 @@ def upload_transcript(self, transcript: TranscriptData, key: str) -> bool: |
136 | 154 | logger.error(f"Failed to upload transcript to S3: {e}") |
137 | 155 | return False |
138 | 156 |
|
| 157 | + def save_transcript(self, transcript: TranscriptData, key: str) -> bool: |
| 158 | + """Save transcript JSON to S3 (alias for upload_transcript). |
| 159 | +
|
| 160 | + Args: |
| 161 | + transcript: The transcript data to save |
| 162 | + key: The S3 object key (path within bucket) |
| 163 | +
|
| 164 | + Returns: |
| 165 | + True if save succeeded, False otherwise |
| 166 | + """ |
| 167 | + return self.upload_transcript(transcript, key) |
| 168 | + |
| 169 | + |
| 170 | +class LocalTranscriptStorage: |
| 171 | + """Handles saving transcripts to local filesystem.""" |
| 172 | + |
| 173 | + def __init__(self, output_dir: str | Path = "temp"): |
| 174 | + """Initialize the local transcript storage. |
| 175 | +
|
| 176 | + Args: |
| 177 | + output_dir: Directory to save transcript files (default: temp/) |
| 178 | + """ |
| 179 | + self.output_dir = Path(output_dir) |
| 180 | + logger.debug(f"LocalTranscriptStorage initialized with output_dir={output_dir}") |
| 181 | + |
| 182 | + def save_transcript(self, transcript: TranscriptData, key: str) -> bool: |
| 183 | + """Save transcript JSON to local filesystem. |
| 184 | +
|
| 185 | + Args: |
| 186 | + transcript: The transcript data to save |
| 187 | + key: The file path relative to output_dir (e.g., transcripts/room-session.json) |
| 188 | +
|
| 189 | + Returns: |
| 190 | + True if save succeeded, False otherwise |
| 191 | + """ |
| 192 | + # Build full path |
| 193 | + output_path = self.output_dir / key |
| 194 | + |
| 195 | + try: |
| 196 | + # Create parent directories if needed |
| 197 | + output_path.parent.mkdir(parents=True, exist_ok=True) |
| 198 | + |
| 199 | + # Write JSON file |
| 200 | + json_content = json.dumps(transcript.to_dict(), indent=2) |
| 201 | + output_path.write_text(json_content, encoding="utf-8") |
| 202 | + |
| 203 | + logger.info(f"Saved transcript to {output_path.absolute()}") |
| 204 | + return True |
| 205 | + except OSError as e: |
| 206 | + logger.error(f"Failed to save transcript to local file: {e}") |
| 207 | + return False |
| 208 | + |
| 209 | + # Alias for backward compatibility |
| 210 | + def upload_transcript(self, transcript: TranscriptData, key: str) -> bool: |
| 211 | + """Alias for save_transcript for backward compatibility.""" |
| 212 | + return self.save_transcript(transcript, key) |
| 213 | + |
139 | 214 |
|
140 | 215 | class TranscriptHandler: |
141 | 216 | """Handles capturing and storing conversation transcripts.""" |
142 | 217 |
|
143 | 218 | def __init__( |
144 | 219 | self, |
145 | 220 | room_name: str, |
146 | | - s3_uploader: S3UploaderProtocol | None = None, |
| 221 | + storage: TranscriptStorageProtocol | None = None, |
147 | 222 | session_id: str | None = None, |
| 223 | + *, |
| 224 | + # Backward compatibility alias |
| 225 | + s3_uploader: TranscriptStorageProtocol | None = None, |
148 | 226 | ): |
149 | 227 | """Initialize the transcript handler. |
150 | 228 |
|
151 | 229 | Args: |
152 | 230 | room_name: Name of the LiveKit room |
153 | | - s3_uploader: S3 uploader instance for storing transcripts |
| 231 | + storage: Storage instance for saving transcripts (local or S3) |
154 | 232 | session_id: Unique session identifier for matching audio/transcript files |
| 233 | + s3_uploader: Deprecated alias for storage parameter (backward compatibility) |
155 | 234 | """ |
156 | 235 | self.transcript = TranscriptData( |
157 | 236 | room_name=room_name, |
158 | 237 | session_start=datetime.now(UTC).isoformat(), |
159 | 238 | ) |
160 | | - self.s3_uploader = s3_uploader |
| 239 | + # Support both 'storage' and deprecated 's3_uploader' parameter |
| 240 | + self.storage = storage or s3_uploader |
| 241 | + # Keep s3_uploader as alias for backward compatibility |
| 242 | + self.s3_uploader = self.storage |
161 | 243 | # Use provided session_id or generate one |
162 | 244 | self.session_id = session_id or datetime.now(UTC).strftime("%Y%m%d-%H%M%S") |
163 | 245 |
|
@@ -199,22 +281,30 @@ def add_agent_transcript(self, text: str, is_final: bool = True) -> None: |
199 | 281 | self.transcript.entries.append(entry) |
200 | 282 | logger.debug(f"Agent transcript: {text}") |
201 | 283 |
|
202 | | - async def finalize_and_upload(self) -> bool: |
203 | | - """Finalize the transcript and upload to S3. |
| 284 | + async def finalize_and_save(self) -> bool: |
| 285 | + """Finalize the transcript and save to storage (local or S3). |
204 | 286 |
|
205 | 287 | Returns: |
206 | | - True if upload succeeded or no uploader configured, False on failure |
| 288 | + True if save succeeded or no storage configured, False on failure |
207 | 289 | """ |
208 | 290 | self.transcript.session_end = datetime.now(UTC).isoformat() |
209 | 291 |
|
210 | | - if not self.s3_uploader: |
211 | | - logger.warning("No S3 uploader configured, transcript not saved") |
| 292 | + if not self.storage: |
| 293 | + logger.warning("No transcript storage configured, transcript not saved") |
212 | 294 | return True |
213 | 295 |
|
214 | 296 | # Use session_id for filename to match audio recording |
215 | 297 | key = f"transcripts/{self.transcript.room_name}-{self.session_id}.json" |
216 | 298 |
|
217 | | - return self.s3_uploader.upload_transcript(self.transcript, key) |
| 299 | + return self.storage.save_transcript(self.transcript, key) |
| 300 | + |
| 301 | + async def finalize_and_upload(self) -> bool: |
| 302 | + """Alias for finalize_and_save (backward compatibility). |
| 303 | +
|
| 304 | + Returns: |
| 305 | + True if save succeeded or no storage configured, False on failure |
| 306 | + """ |
| 307 | + return await self.finalize_and_save() |
218 | 308 |
|
219 | 309 | def get_transcript_text(self) -> str: |
220 | 310 | """Get the transcript as plain text. |
|
0 commit comments