interview-coach-cli 0.3.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.
providers.py ADDED
@@ -0,0 +1,252 @@
1
+ """
2
+ Multi-provider LLM support.
3
+
4
+ Each provider exposes:
5
+ respond(transcript: str, history: list, system_prompt: str, model: str) -> str
6
+
7
+ `history` is a list of {"role": "user"|"assistant", "content": str}. The provider
8
+ mutates it in place with the new turn (user message then assistant reply) so the
9
+ caller can persist the running conversation.
10
+
11
+ Supported providers:
12
+ - anthropic (Claude Opus 4.7 / Sonnet 4.6 / Haiku 4.5)
13
+ - openai (GPT-5, GPT-5-mini, GPT-4o)
14
+ - deepseek (DeepSeek V4 chat, DeepSeek Reasoner) via OpenAI-compatible API
15
+ - gemini (Gemini 2.5 Pro / 2.5 Flash)
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+
22
+ PROVIDERS = {
23
+ "anthropic": {
24
+ "env": "ANTHROPIC_API_KEY",
25
+ "models": {
26
+ "claude-opus-4-7": "Claude Opus 4.7 — most capable",
27
+ "claude-sonnet-4-6": "Claude Sonnet 4.6 — balanced (default)",
28
+ "claude-haiku-4-5": "Claude Haiku 4.5 — fastest, cheapest",
29
+ },
30
+ "default_model": "claude-sonnet-4-6",
31
+ },
32
+ "openai": {
33
+ "env": "OPENAI_API_KEY",
34
+ "models": {
35
+ "gpt-5": "GPT-5 — flagship",
36
+ "gpt-5-mini": "GPT-5 mini — fast + cheap",
37
+ "gpt-4o": "GPT-4o — mature, real-time",
38
+ },
39
+ "default_model": "gpt-5-mini",
40
+ },
41
+ "deepseek": {
42
+ "env": "DEEPSEEK_API_KEY",
43
+ "models": {
44
+ "deepseek-chat": "DeepSeek V4 chat — fast, very cheap",
45
+ "deepseek-reasoner": "DeepSeek Reasoner — chain-of-thought",
46
+ },
47
+ "default_model": "deepseek-chat",
48
+ "base_url": "https://api.deepseek.com",
49
+ },
50
+ "gemini": {
51
+ "env": "GEMINI_API_KEY",
52
+ "models": {
53
+ "gemini-2.5-pro": "Gemini 2.5 Pro — most capable",
54
+ "gemini-2.5-flash": "Gemini 2.5 Flash — fast",
55
+ },
56
+ "default_model": "gemini-2.5-flash",
57
+ },
58
+ }
59
+
60
+
61
+ # ─── Anthropic ─────────────────────────────────────────────────────────────
62
+ _anthropic_client = None
63
+
64
+
65
+ def _respond_anthropic(transcript, history, system_prompt, model):
66
+ global _anthropic_client
67
+ import anthropic
68
+ if _anthropic_client is None:
69
+ _anthropic_client = anthropic.Anthropic()
70
+
71
+ history.append({"role": "user", "content": transcript})
72
+ msg = _anthropic_client.messages.create(
73
+ model=model,
74
+ max_tokens=1024,
75
+ system=system_prompt,
76
+ messages=history,
77
+ )
78
+ reply = msg.content[0].text
79
+ history.append({"role": "assistant", "content": reply})
80
+ return reply
81
+
82
+
83
+ # ─── OpenAI + DeepSeek (OpenAI-compatible) ─────────────────────────────────
84
+ _openai_clients: dict = {}
85
+
86
+
87
+ def _openai_like(provider: str, transcript, history, system_prompt, model):
88
+ from openai import OpenAI
89
+ if provider not in _openai_clients:
90
+ cfg = PROVIDERS[provider]
91
+ key = os.environ.get(cfg["env"])
92
+ if not key:
93
+ raise RuntimeError(f"Missing env var {cfg['env']}")
94
+ kwargs = {"api_key": key}
95
+ if "base_url" in cfg:
96
+ kwargs["base_url"] = cfg["base_url"]
97
+ _openai_clients[provider] = OpenAI(**kwargs)
98
+ client = _openai_clients[provider]
99
+
100
+ history.append({"role": "user", "content": transcript})
101
+ messages = [{"role": "system", "content": system_prompt}] + history
102
+ resp = client.chat.completions.create(
103
+ model=model,
104
+ messages=messages,
105
+ max_tokens=1024,
106
+ )
107
+ reply = resp.choices[0].message.content
108
+ history.append({"role": "assistant", "content": reply})
109
+ return reply
110
+
111
+
112
+ # ─── Gemini ────────────────────────────────────────────────────────────────
113
+ _gemini_client = None
114
+
115
+
116
+ def _respond_gemini(transcript, history, system_prompt, model):
117
+ global _gemini_client
118
+ from google import genai
119
+ from google.genai import types
120
+ if _gemini_client is None:
121
+ _gemini_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
122
+
123
+ history.append({"role": "user", "content": transcript})
124
+ contents = []
125
+ for m in history:
126
+ role = "user" if m["role"] == "user" else "model"
127
+ contents.append({"role": role, "parts": [{"text": m["content"]}]})
128
+
129
+ resp = _gemini_client.models.generate_content(
130
+ model=model,
131
+ contents=contents,
132
+ config=types.GenerateContentConfig(
133
+ system_instruction=system_prompt,
134
+ max_output_tokens=1024,
135
+ ),
136
+ )
137
+ reply = resp.text
138
+ history.append({"role": "assistant", "content": reply})
139
+ return reply
140
+
141
+
142
+ # ─── Dispatch ──────────────────────────────────────────────────────────────
143
+ def respond(provider: str, model: str, transcript: str, history: list, system_prompt: str) -> str:
144
+ if provider == "anthropic":
145
+ return _respond_anthropic(transcript, history, system_prompt, model)
146
+ if provider in ("openai", "deepseek"):
147
+ return _openai_like(provider, transcript, history, system_prompt, model)
148
+ if provider == "gemini":
149
+ return _respond_gemini(transcript, history, system_prompt, model)
150
+ raise ValueError(f"Unknown provider: {provider}")
151
+
152
+
153
+ # ─── Vision: analyse an image (screenshot) with a text prompt ──────────────
154
+ import base64
155
+ from pathlib import Path
156
+
157
+
158
+ def _read_image_b64(image_path: str | Path) -> str:
159
+ return base64.b64encode(Path(image_path).read_bytes()).decode()
160
+
161
+
162
+ def respond_with_image(provider: str, model: str, image_path: str | Path,
163
+ prompt: str, system_prompt: str = "") -> str:
164
+ """Vision-model call. Returns text response about the image."""
165
+ if provider == "anthropic":
166
+ return _vision_anthropic(model, image_path, prompt, system_prompt)
167
+ if provider in ("openai", "deepseek"):
168
+ return _vision_openai_compat(provider, model, image_path, prompt, system_prompt)
169
+ if provider == "gemini":
170
+ return _vision_gemini(model, image_path, prompt, system_prompt)
171
+ raise ValueError(f"Vision not implemented for provider: {provider}")
172
+
173
+
174
+ def _vision_anthropic(model, image_path, prompt, system_prompt):
175
+ global _anthropic_client
176
+ import anthropic
177
+ if _anthropic_client is None:
178
+ _anthropic_client = anthropic.Anthropic()
179
+ b64 = _read_image_b64(image_path)
180
+ msg = _anthropic_client.messages.create(
181
+ model=model,
182
+ max_tokens=1500,
183
+ system=system_prompt or (
184
+ "You are a live coach analysing a screenshot the user just showed "
185
+ "you during a conversation. Explain what's on screen, identify the "
186
+ "task or question, and give the user something they can say or do "
187
+ "in the next 30 seconds."
188
+ ),
189
+ messages=[{
190
+ "role": "user",
191
+ "content": [
192
+ {"type": "image",
193
+ "source": {"type": "base64", "media_type": "image/png", "data": b64}},
194
+ {"type": "text", "text": prompt},
195
+ ],
196
+ }],
197
+ )
198
+ return msg.content[0].text
199
+
200
+
201
+ def _vision_openai_compat(provider, model, image_path, prompt, system_prompt):
202
+ from openai import OpenAI
203
+ if provider not in _openai_clients:
204
+ cfg = PROVIDERS[provider]
205
+ key = os.environ.get(cfg["env"])
206
+ if not key:
207
+ raise RuntimeError(f"Missing env var {cfg['env']}")
208
+ kwargs = {"api_key": key}
209
+ if "base_url" in cfg:
210
+ kwargs["base_url"] = cfg["base_url"]
211
+ _openai_clients[provider] = OpenAI(**kwargs)
212
+ client = _openai_clients[provider]
213
+ b64 = _read_image_b64(image_path)
214
+ messages = []
215
+ if system_prompt:
216
+ messages.append({"role": "system", "content": system_prompt})
217
+ messages.append({
218
+ "role": "user",
219
+ "content": [
220
+ {"type": "image_url",
221
+ "image_url": {"url": f"data:image/png;base64,{b64}"}},
222
+ {"type": "text", "text": prompt},
223
+ ],
224
+ })
225
+ resp = client.chat.completions.create(model=model, messages=messages, max_tokens=1500)
226
+ return resp.choices[0].message.content
227
+
228
+
229
+ def _vision_gemini(model, image_path, prompt, system_prompt):
230
+ global _gemini_client
231
+ from google import genai
232
+ from google.genai import types
233
+ if _gemini_client is None:
234
+ _gemini_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
235
+ image_bytes = Path(image_path).read_bytes()
236
+ resp = _gemini_client.models.generate_content(
237
+ model=model,
238
+ contents=[
239
+ types.Part.from_bytes(data=image_bytes, mime_type="image/png"),
240
+ prompt,
241
+ ],
242
+ config=types.GenerateContentConfig(
243
+ system_instruction=system_prompt or None,
244
+ max_output_tokens=1500,
245
+ ),
246
+ )
247
+ return resp.text
248
+
249
+
250
+ def key_status() -> dict:
251
+ """Return {provider: bool} — True if API key env var is set."""
252
+ return {p: bool(os.environ.get(cfg["env"])) for p, cfg in PROVIDERS.items()}
recorder.py ADDED
@@ -0,0 +1,178 @@
1
+ import os
2
+ import queue
3
+ import subprocess
4
+ import threading
5
+ import time
6
+
7
+ import numpy as np
8
+ import sounddevice as sd
9
+
10
+ from rich.console import Console
11
+ from rich.live import Live
12
+ from rich.text import Text
13
+ from rich.spinner import Spinner
14
+
15
+
16
+ SAMPLE_RATE = 16000 # Whisper expects 16kHz
17
+
18
+ _console = Console()
19
+
20
+
21
+ # Distinct spinner style per speaker (visible cue: who's mic'd)
22
+ SPINNER_STYLES = {
23
+ "you": {"spinner": "bouncingBar", "style": "bold bright_cyan", "icon": "🎙️", "label": "YOU are speaking"},
24
+ "interviewer": {"spinner": "earth", "style": "bold bright_yellow", "icon": "👂", "label": "Listening to interviewer"},
25
+ "unknown": {"spinner": "dots", "style": "bold white", "icon": "🎧", "label": "Recording"},
26
+ }
27
+
28
+
29
+ def _detect_system_monitor() -> str | None:
30
+ try:
31
+ out = subprocess.check_output(
32
+ ["pw-cli", "ls", "Node"], stderr=subprocess.DEVNULL, text=True
33
+ )
34
+ except Exception:
35
+ return None
36
+ for line in out.splitlines():
37
+ line = line.strip()
38
+ if line.startswith("node.name") and ".HiFi__hw_sofhdadsp__sink" in line:
39
+ return line.split('=', 1)[1].strip().strip('"') + ".monitor"
40
+ for line in out.splitlines():
41
+ line = line.strip()
42
+ if line.startswith("node.name") and "alsa_output" in line and "sink" in line:
43
+ return line.split('=', 1)[1].strip().strip('"') + ".monitor"
44
+ return None
45
+
46
+
47
+ def _set_source(source: str) -> str:
48
+ if source == "mic":
49
+ os.environ.pop("PULSE_SOURCE", None)
50
+ return "microphone"
51
+ monitor = _detect_system_monitor()
52
+ if not monitor:
53
+ _console.print(" [yellow][Could not find system-audio monitor — falling back to mic][/]")
54
+ os.environ.pop("PULSE_SOURCE", None)
55
+ return "microphone (fallback)"
56
+ os.environ["PULSE_SOURCE"] = monitor
57
+ return "system audio"
58
+
59
+
60
+ def _live_status(speaker: str, extra: str = "") -> Live:
61
+ """Build a Live spinner scoped to the recording session."""
62
+ cfg = SPINNER_STYLES.get(speaker, SPINNER_STYLES["unknown"])
63
+ text = Text(f" {cfg['icon']} {cfg['label']}", style=cfg["style"])
64
+ if extra:
65
+ text.append(f" {extra}", style="dim italic")
66
+ return Live(
67
+ Spinner(cfg["spinner"], text=text, style=cfg["style"]),
68
+ console=_console,
69
+ refresh_per_second=12,
70
+ transient=True,
71
+ )
72
+
73
+
74
+ def record_until_keypress(source: str = "mic", speaker: str = "you") -> np.ndarray:
75
+ """Record from source until user presses Enter. Shows a per-speaker spinner."""
76
+ label = _set_source(source)
77
+ chunks = []
78
+
79
+ def callback(indata, frames, t, status):
80
+ chunks.append(indata.copy())
81
+
82
+ stop_flag = threading.Event()
83
+
84
+ def wait_for_enter():
85
+ try:
86
+ input()
87
+ finally:
88
+ stop_flag.set()
89
+
90
+ reader = threading.Thread(target=wait_for_enter, daemon=True)
91
+ reader.start()
92
+
93
+ with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="float32",
94
+ callback=callback), _live_status(
95
+ speaker, f"({label}) — press Enter to stop"
96
+ ):
97
+ while not stop_flag.is_set():
98
+ time.sleep(0.05)
99
+
100
+ if not chunks:
101
+ return np.array([], dtype=np.float32)
102
+ return np.concatenate(chunks, axis=0).flatten()
103
+
104
+
105
+ def record_until_silence(
106
+ source: str = "system",
107
+ speaker: str = "interviewer",
108
+ silence_threshold: float = 0.01,
109
+ silence_duration: float = 1.5,
110
+ max_duration: float = 90.0,
111
+ min_speech_duration: float = 0.5,
112
+ ) -> np.ndarray:
113
+ """Auto-stop recording after `silence_duration` seconds of quiet."""
114
+ label = _set_source(source)
115
+
116
+ q: queue.Queue = queue.Queue()
117
+
118
+ def callback(indata, frames, t, status):
119
+ q.put(indata.copy())
120
+
121
+ frame_ms = 100
122
+ block = int(SAMPLE_RATE * frame_ms / 1000)
123
+
124
+ chunks = []
125
+ started_speaking = False
126
+ speech_time = 0.0
127
+ silence_time = 0.0
128
+
129
+ cfg = SPINNER_STYLES.get(speaker, SPINNER_STYLES["unknown"])
130
+
131
+ def build_spinner(started: bool, elapsed: float) -> Spinner:
132
+ base = Text(f" {cfg['icon']} {cfg['label']}", style=cfg["style"])
133
+ base.append(f" ({label})", style="dim")
134
+ if started:
135
+ base.append(f" • captured {elapsed:.1f}s", style="dim italic green")
136
+ else:
137
+ base.append(" • waiting for speech…", style="dim italic")
138
+ return Spinner(cfg["spinner"], text=base, style=cfg["style"])
139
+
140
+ with sd.InputStream(
141
+ samplerate=SAMPLE_RATE, channels=1, dtype="float32",
142
+ blocksize=block, callback=callback,
143
+ ), Live(build_spinner(False, 0.0), console=_console,
144
+ refresh_per_second=10, transient=True) as live:
145
+ start = time.time()
146
+ speech_seconds = 0.0
147
+ while True:
148
+ try:
149
+ buf = q.get(timeout=0.5)
150
+ except queue.Empty:
151
+ if time.time() - start >= max_duration:
152
+ break
153
+ continue
154
+
155
+ rms = float(np.sqrt(np.mean(buf ** 2)))
156
+ elapsed = time.time() - start
157
+
158
+ if rms > silence_threshold:
159
+ if not started_speaking:
160
+ started_speaking = True
161
+ chunks.append(buf)
162
+ speech_time += frame_ms / 1000
163
+ speech_seconds = speech_time
164
+ silence_time = 0.0
165
+ elif started_speaking:
166
+ chunks.append(buf)
167
+ silence_time += frame_ms / 1000
168
+ if silence_time >= silence_duration and speech_time >= min_speech_duration:
169
+ break
170
+
171
+ live.update(build_spinner(started_speaking, speech_seconds))
172
+
173
+ if elapsed >= max_duration:
174
+ break
175
+
176
+ if not chunks:
177
+ return np.array([], dtype=np.float32)
178
+ return np.concatenate(chunks, axis=0).flatten()