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.
- coach_graph.py +328 -0
- config.py +170 -0
- interview_coach_cli-0.3.0.dist-info/METADATA +479 -0
- interview_coach_cli-0.3.0.dist-info/RECORD +22 -0
- interview_coach_cli-0.3.0.dist-info/WHEEL +5 -0
- interview_coach_cli-0.3.0.dist-info/entry_points.txt +3 -0
- interview_coach_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
- interview_coach_cli-0.3.0.dist-info/top_level.txt +16 -0
- main.py +776 -0
- meeting_plans.py +218 -0
- onboarding.py +189 -0
- plan_wizard.py +175 -0
- profile_store.py +118 -0
- providers.py +252 -0
- recorder.py +178 -0
- research.py +314 -0
- responder.py +8 -0
- screen_capture.py +129 -0
- sessions.py +116 -0
- templates.py +276 -0
- transcriber.py +12 -0
- transcribers.py +161 -0
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()
|