eightstatecli 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
escli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from escli import main
2
+ import sys
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main() or 0)
File without changes
@@ -0,0 +1,438 @@
1
+ """
2
+ escli audio — transcription via AssemblyAI.
3
+
4
+ Usage:
5
+ escli audio transcribe <file-or-url> Upload + transcribe + poll + return
6
+ escli audio status <id> Check transcript status
7
+ escli audio get <id> Fetch completed transcript
8
+ escli audio list List recent transcripts
9
+
10
+ Speaker diarization:
11
+ --speakers Enable speaker labels
12
+ --speakers-expected N Hint: expected number of speakers (1-20)
13
+ --speaker-names A,B,C Identify speakers by name
14
+
15
+ Audio intelligence:
16
+ --sentiment Enable sentiment analysis
17
+ --chapters Enable auto chapters
18
+ --entities Enable entity detection
19
+ --summarize Enable summarization
20
+ --highlights Enable auto highlights
21
+ --topics Enable topic detection (IAB)
22
+ --content-safety Enable content safety detection
23
+
24
+ Transcription options:
25
+ --language CODE Language code (default: auto-detect)
26
+ --dual-channel Enable dual channel transcription
27
+ --multichannel Enable multichannel transcription
28
+ --word-boost W1,W2 Boost accuracy for specific words
29
+ --disfluencies Include filler words (umm, uh)
30
+ --filter-profanity Filter profanity
31
+ --redact-pii Redact personally identifiable info
32
+
33
+ Output:
34
+ --format text|json|srt|vtt Output format (default: text)
35
+ -o, --output FILE Write output to file
36
+ """
37
+
38
+ import argparse
39
+ import json
40
+ import os
41
+ import pathlib
42
+ import sys
43
+ import time
44
+
45
+ from ..services.credentials import get_key_for_service, report_key
46
+
47
+ AAI_BASE = "https://api.assemblyai.com"
48
+ POLL_INTERVAL = 3.0
49
+
50
+
51
+ def _get_api_key() -> str:
52
+ key = get_key_for_service("assemblyai", "ASSEMBLYAI_API_KEY")
53
+ if not key:
54
+ print(" ✗ no AssemblyAI API key. Set ASSEMBLYAI_API_KEY or add one via the dashboard.", file=sys.stderr)
55
+ sys.exit(1)
56
+ return key
57
+
58
+
59
+ def _headers(api_key: str) -> dict:
60
+ return {"Authorization": api_key, "Content-Type": "application/json"}
61
+
62
+
63
+ def _request(method: str, path: str, api_key: str, **kwargs):
64
+ import httpx
65
+ url = f"{AAI_BASE}{path}"
66
+ headers = {"Authorization": api_key}
67
+ if "json_body" in kwargs:
68
+ headers["Content-Type"] = "application/json"
69
+
70
+ resp = httpx.request(
71
+ method, url, headers=headers,
72
+ json=kwargs.get("json_body"),
73
+ content=kwargs.get("content"),
74
+ timeout=kwargs.get("timeout", 30),
75
+ )
76
+ resp.raise_for_status()
77
+ return resp
78
+
79
+
80
+ def _upload_file(filepath: str, api_key: str, quiet: bool = False) -> str:
81
+ """Upload a local file to AssemblyAI, return the upload_url."""
82
+ path = pathlib.Path(filepath)
83
+ if not path.exists():
84
+ print(f" ✗ file not found: {filepath}", file=sys.stderr)
85
+ sys.exit(1)
86
+
87
+ size_mb = path.stat().st_size / (1024 * 1024)
88
+ if not quiet:
89
+ print(f" ▸ uploading {path.name} ({size_mb:.1f} MB)...", file=sys.stderr)
90
+
91
+ with open(path, "rb") as f:
92
+ resp = _request("POST", "/v2/upload", api_key, content=f, timeout=300)
93
+
94
+ return resp.json()["upload_url"]
95
+
96
+
97
+ def _build_transcript_body(args, audio_url: str) -> dict:
98
+ """Build the transcript request body from CLI args."""
99
+ body: dict = {
100
+ "audio_url": audio_url,
101
+ "speech_models": ["universal-3-pro", "universal-2"],
102
+ }
103
+
104
+ # Language
105
+ lang = getattr(args, "language", None)
106
+ if lang:
107
+ body["language_code"] = lang
108
+ else:
109
+ body["language_detection"] = True
110
+
111
+ # Speaker diarization
112
+ if getattr(args, "speakers", False):
113
+ body["speaker_labels"] = True
114
+ if getattr(args, "speakers_expected", None):
115
+ body["speaker_labels"] = True
116
+ body["speakers_expected"] = args.speakers_expected
117
+ if getattr(args, "speaker_names", None):
118
+ body["speaker_labels"] = True
119
+ names = [n.strip() for n in args.speaker_names.split(",")]
120
+ body["speech_understanding"] = {
121
+ "request": {
122
+ "speaker_identification": {
123
+ "speaker_type": "name",
124
+ "known_values": names,
125
+ }
126
+ }
127
+ }
128
+
129
+ # Audio intelligence
130
+ if getattr(args, "sentiment", False):
131
+ body["sentiment_analysis"] = True
132
+ if getattr(args, "chapters", False):
133
+ body["auto_chapters"] = True
134
+ if getattr(args, "entities", False):
135
+ body["entity_detection"] = True
136
+ if getattr(args, "summarize", False):
137
+ body["summarization"] = True
138
+ body["summary_model"] = "informative"
139
+ body["summary_type"] = "bullets"
140
+ if getattr(args, "highlights", False):
141
+ body["auto_highlights"] = True
142
+ if getattr(args, "topics", False):
143
+ body["iab_categories"] = True
144
+ if getattr(args, "content_safety", False):
145
+ body["content_safety"] = True
146
+
147
+ # Transcription options
148
+ if getattr(args, "dual_channel", False):
149
+ body["dual_channel"] = True
150
+ if getattr(args, "multichannel", False):
151
+ body["multichannel"] = True
152
+ if getattr(args, "word_boost", None):
153
+ body["word_boost"] = [w.strip() for w in args.word_boost.split(",")]
154
+ body["boost_param"] = "high"
155
+ if getattr(args, "disfluencies", False):
156
+ body["disfluencies"] = True
157
+ if getattr(args, "filter_profanity", False):
158
+ body["filter_profanity"] = True
159
+ if getattr(args, "redact_pii", False):
160
+ body["redact_pii"] = True
161
+ body["redact_pii_policies"] = [
162
+ "email_address", "phone_number", "person_name",
163
+ "location", "date_of_birth", "credit_card_number",
164
+ ]
165
+
166
+ return body
167
+
168
+
169
+ def _poll(transcript_id: str, api_key: str, quiet: bool = False) -> dict:
170
+ """Poll until transcript is completed or errored."""
171
+ if not quiet:
172
+ print(f" ░░░░░░░░░░░░░░░░░░░░ transcribing...", file=sys.stderr, end="", flush=True)
173
+
174
+ while True:
175
+ resp = _request("GET", f"/v2/transcript/{transcript_id}", api_key)
176
+ data = resp.json()
177
+ status = data.get("status")
178
+
179
+ if status == "completed":
180
+ if not quiet:
181
+ print(f"\r ████████████████████ done ", file=sys.stderr)
182
+ return data
183
+ elif status == "error":
184
+ if not quiet:
185
+ print(f"\r ✗ transcription failed: {data.get('error', 'unknown')}", file=sys.stderr)
186
+ return data
187
+
188
+ time.sleep(POLL_INTERVAL)
189
+
190
+
191
+ def _format_output(data: dict, fmt: str, args) -> str:
192
+ """Format transcript output."""
193
+ if fmt == "json":
194
+ return json.dumps(data, indent=2)
195
+
196
+ if fmt == "srt":
197
+ resp = _request("GET", f"/v2/transcript/{data['id']}/srt", _get_api_key())
198
+ return resp.text
199
+
200
+ if fmt == "vtt":
201
+ resp = _request("GET", f"/v2/transcript/{data['id']}/vtt", _get_api_key())
202
+ return resp.text
203
+
204
+ # text format
205
+ lines = []
206
+
207
+ # Speaker diarization output
208
+ if data.get("utterances"):
209
+ for u in data["utterances"]:
210
+ speaker = u.get("speaker", "?")
211
+ text = u.get("text", "")
212
+ lines.append(f"Speaker {speaker}: {text}")
213
+ elif data.get("text"):
214
+ lines.append(data["text"])
215
+
216
+ # Chapters
217
+ if data.get("chapters"):
218
+ lines.append("\n--- Chapters ---")
219
+ for ch in data["chapters"]:
220
+ lines.append(f"\n## {ch.get('headline', '')}")
221
+ lines.append(ch.get("summary", ""))
222
+
223
+ # Summary
224
+ if data.get("summary"):
225
+ lines.append(f"\n--- Summary ---\n{data['summary']}")
226
+
227
+ # Sentiment
228
+ if data.get("sentiment_analysis_results"):
229
+ lines.append("\n--- Sentiment ---")
230
+ for s in data["sentiment_analysis_results"][:20]:
231
+ lines.append(f" [{s.get('sentiment', '')}] {s.get('text', '')[:80]}")
232
+
233
+ # Entities
234
+ if data.get("entities"):
235
+ lines.append("\n--- Entities ---")
236
+ for e in data["entities"][:20]:
237
+ lines.append(f" {e.get('entity_type', '')}: {e.get('text', '')}")
238
+
239
+ return "\n".join(lines)
240
+
241
+
242
+ # ── Commands ─────────────────────────────────────────────────────
243
+
244
+ def cmd_transcribe(args):
245
+ """Upload (if local file), create transcript, poll, return result."""
246
+ api_key = _get_api_key()
247
+ source = args.source
248
+ t0 = time.time()
249
+
250
+ # Determine audio URL
251
+ if source.startswith("http://") or source.startswith("https://"):
252
+ audio_url = source
253
+ else:
254
+ audio_url = _upload_file(source, api_key, args.quiet)
255
+
256
+ # Build and submit
257
+ body = _build_transcript_body(args, audio_url)
258
+ if not args.quiet:
259
+ print(f" ▸ submitting transcription...", file=sys.stderr)
260
+
261
+ resp = _request("POST", "/v2/transcript", api_key, json_body=body)
262
+ data = resp.json()
263
+ transcript_id = data["id"]
264
+
265
+ if not args.quiet:
266
+ print(f" · id: {transcript_id}", file=sys.stderr)
267
+
268
+ # Poll
269
+ result = _poll(transcript_id, api_key, args.quiet)
270
+ elapsed = round(time.time() - t0, 1)
271
+
272
+ if result.get("status") == "error":
273
+ if args.json:
274
+ print(json.dumps({"success": False, "error": result.get("error"), "id": transcript_id}))
275
+ return 1
276
+
277
+ # Format and output
278
+ fmt = getattr(args, "format", "text") or "text"
279
+ output = _format_output(result, fmt, args)
280
+
281
+ if args.json and fmt != "json":
282
+ print(json.dumps({
283
+ "success": True,
284
+ "id": transcript_id,
285
+ "elapsed_seconds": elapsed,
286
+ "text": result.get("text", ""),
287
+ "speakers": len(set(u.get("speaker", "") for u in result.get("utterances", []))),
288
+ "words": result.get("words", []),
289
+ "utterances": result.get("utterances", []),
290
+ }))
291
+ elif getattr(args, "output", None):
292
+ outpath = pathlib.Path(args.output)
293
+ outpath.write_text(output)
294
+ if not args.quiet:
295
+ print(f" ✓ {outpath} ({elapsed}s)", file=sys.stderr)
296
+ if args.quiet:
297
+ print(str(outpath.resolve()))
298
+ else:
299
+ print(output)
300
+ if not args.quiet and fmt == "text":
301
+ print(f"\n ✓ {elapsed}s · {transcript_id}", file=sys.stderr)
302
+
303
+ return 0
304
+
305
+
306
+ def cmd_status(args):
307
+ """Check transcript status."""
308
+ api_key = _get_api_key()
309
+ resp = _request("GET", f"/v2/transcript/{args.transcript_id}", api_key)
310
+ data = resp.json()
311
+
312
+ if args.json:
313
+ print(json.dumps({"success": True, "id": args.transcript_id, "status": data.get("status"),
314
+ "error": data.get("error")}))
315
+ else:
316
+ status = data.get("status", "unknown")
317
+ print(f" {args.transcript_id}: {status}")
318
+ if data.get("error"):
319
+ print(f" error: {data['error']}")
320
+ return 0
321
+
322
+
323
+ def cmd_get(args):
324
+ """Fetch a completed transcript."""
325
+ api_key = _get_api_key()
326
+ resp = _request("GET", f"/v2/transcript/{args.transcript_id}", api_key)
327
+ data = resp.json()
328
+
329
+ if data.get("status") != "completed":
330
+ if args.json:
331
+ print(json.dumps({"success": False, "status": data.get("status"), "error": data.get("error")}))
332
+ else:
333
+ print(f" ✗ transcript not ready: {data.get('status')}", file=sys.stderr)
334
+ return 1
335
+
336
+ fmt = getattr(args, "format", "text") or "text"
337
+ output = _format_output(data, fmt, args)
338
+
339
+ if getattr(args, "output", None):
340
+ pathlib.Path(args.output).write_text(output)
341
+ print(f" ✓ {args.output}", file=sys.stderr)
342
+ else:
343
+ print(output)
344
+ return 0
345
+
346
+
347
+ def cmd_list(args):
348
+ """List recent transcripts."""
349
+ api_key = _get_api_key()
350
+ resp = _request("GET", "/v2/transcript?limit=20", api_key)
351
+ data = resp.json()
352
+
353
+ transcripts = data.get("transcripts", [])
354
+ if args.json:
355
+ print(json.dumps({"success": True, "transcripts": transcripts, "count": len(transcripts)}))
356
+ return 0
357
+
358
+ if not transcripts:
359
+ print(" No transcripts found.")
360
+ return 0
361
+
362
+ print(f"\n {'ID':<40} {'STATUS':<12} {'CREATED'}")
363
+ print(f" {'─' * 70}")
364
+ for t in transcripts:
365
+ print(f" {t.get('id', ''):<40} {t.get('status', ''):<12} {t.get('created', '')}")
366
+ print()
367
+ return 0
368
+
369
+
370
+ # ── Parser ───────────────────────────────────────────────────────
371
+
372
+ def register(subparsers):
373
+ """Register the audio subcommand group."""
374
+ F = argparse.RawDescriptionHelpFormatter
375
+
376
+ audio_p = subparsers.add_parser(
377
+ "audio", aliases=["au"], help="Audio transcription (AssemblyAI)",
378
+ formatter_class=F,
379
+ epilog="""subcommands:
380
+ transcribe <file-or-url> Transcribe audio with speaker diarization
381
+ status <id> Check transcript status
382
+ get <id> Fetch completed transcript
383
+ list List recent transcripts
384
+
385
+ examples:
386
+ escli audio transcribe meeting.mp3 --speakers
387
+ escli audio transcribe https://example.com/audio.mp3 --speakers-expected 3
388
+ escli audio transcribe call.wav --speakers --sentiment --summarize
389
+ escli audio transcribe interview.mp3 --speaker-names "Alice,Bob" -o transcript.txt
390
+ escli --json --quiet audio transcribe file.mp3 --speakers
391
+ """)
392
+
393
+ audio_subs = audio_p.add_subparsers(dest="audio_command", metavar="subcommand")
394
+
395
+ # transcribe
396
+ tr_p = audio_subs.add_parser("transcribe", aliases=["t"], help="Transcribe audio")
397
+ tr_p.add_argument("source", help="Audio file path or URL")
398
+ tr_p.add_argument("-o", "--output", default=None, help="Write output to file")
399
+ tr_p.add_argument("--format", choices=["text", "json", "srt", "vtt"], default="text", help="Output format")
400
+ # Speaker diarization
401
+ tr_p.add_argument("--speakers", action="store_true", help="Enable speaker labels")
402
+ tr_p.add_argument("--speakers-expected", type=int, default=None, metavar="N", help="Expected speaker count (1-20)")
403
+ tr_p.add_argument("--speaker-names", default=None, metavar="A,B,C", help="Identify speakers by name")
404
+ # Audio intelligence
405
+ tr_p.add_argument("--sentiment", action="store_true", help="Enable sentiment analysis")
406
+ tr_p.add_argument("--chapters", action="store_true", help="Enable auto chapters")
407
+ tr_p.add_argument("--entities", action="store_true", help="Enable entity detection")
408
+ tr_p.add_argument("--summarize", action="store_true", help="Enable summarization")
409
+ tr_p.add_argument("--highlights", action="store_true", help="Enable auto highlights")
410
+ tr_p.add_argument("--topics", action="store_true", help="Enable topic detection (IAB)")
411
+ tr_p.add_argument("--content-safety", action="store_true", help="Enable content safety detection")
412
+ # Transcription options
413
+ tr_p.add_argument("--language", default=None, metavar="CODE", help="Language code (default: auto-detect)")
414
+ tr_p.add_argument("--dual-channel", action="store_true", help="Dual channel transcription")
415
+ tr_p.add_argument("--multichannel", action="store_true", help="Multichannel transcription")
416
+ tr_p.add_argument("--word-boost", default=None, metavar="W1,W2", help="Boost accuracy for words")
417
+ tr_p.add_argument("--disfluencies", action="store_true", help="Include filler words")
418
+ tr_p.add_argument("--filter-profanity", action="store_true", help="Filter profanity")
419
+ tr_p.add_argument("--redact-pii", action="store_true", help="Redact PII")
420
+ tr_p.set_defaults(func=cmd_transcribe)
421
+
422
+ # status
423
+ st_p = audio_subs.add_parser("status", aliases=["s"], help="Check transcript status")
424
+ st_p.add_argument("transcript_id", help="Transcript ID")
425
+ st_p.set_defaults(func=cmd_status)
426
+
427
+ # get
428
+ get_p = audio_subs.add_parser("get", aliases=["g"], help="Fetch completed transcript")
429
+ get_p.add_argument("transcript_id", help="Transcript ID")
430
+ get_p.add_argument("--format", choices=["text", "json", "srt", "vtt"], default="text", help="Output format")
431
+ get_p.add_argument("-o", "--output", default=None, help="Write output to file")
432
+ get_p.set_defaults(func=cmd_get)
433
+
434
+ # list
435
+ list_p = audio_subs.add_parser("list", aliases=["ls"], help="List recent transcripts")
436
+ list_p.set_defaults(func=cmd_list)
437
+
438
+ return audio_p