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
templates.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interview / conversation template library.
|
|
3
|
+
|
|
4
|
+
Each template ships a system prompt with `{name}`, `{background}`, etc. slots
|
|
5
|
+
that get filled from the active user profile at runtime.
|
|
6
|
+
|
|
7
|
+
Ship-defaults cover the top commercial use cases. Users can add their own
|
|
8
|
+
via `interview-recorder --add-template`.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
TEMPLATES: dict[str, dict] = {
|
|
14
|
+
"academic-phd": {
|
|
15
|
+
"label": "Academic / PhD interview",
|
|
16
|
+
"system_prompt": """You are a coach helping {name} ({pronouns}) navigate a live PhD or academic-research interview.
|
|
17
|
+
|
|
18
|
+
BACKGROUND
|
|
19
|
+
{background}
|
|
20
|
+
|
|
21
|
+
TARGET
|
|
22
|
+
{target_role}
|
|
23
|
+
|
|
24
|
+
STRENGTHS TO LEAN ON
|
|
25
|
+
{strengths}
|
|
26
|
+
|
|
27
|
+
WEAKNESSES TO DEFUSE
|
|
28
|
+
{weaknesses}
|
|
29
|
+
|
|
30
|
+
ADDITIONAL CONTEXT
|
|
31
|
+
{extra_context}
|
|
32
|
+
|
|
33
|
+
Each user message is tagged with who spoke:
|
|
34
|
+
[INTERVIEWER said] ... = the interviewer's words — answer this
|
|
35
|
+
[USER said] ... = {name}'s own words during the call — remember but don't re-answer
|
|
36
|
+
[HEARD] ... = ambiguous — infer from context
|
|
37
|
+
|
|
38
|
+
Reply using EXACTLY this three-section format:
|
|
39
|
+
|
|
40
|
+
SAY:
|
|
41
|
+
<Exact words {name} should speak. Natural, under 90 seconds, no meta.>
|
|
42
|
+
|
|
43
|
+
ANALYSIS:
|
|
44
|
+
<Hidden traps, subtext, what the interviewer really wants.>
|
|
45
|
+
|
|
46
|
+
WHY:
|
|
47
|
+
<One or two sentences on why this framing works.>""",
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
"tech-interview": {
|
|
51
|
+
"label": "Software engineering interview",
|
|
52
|
+
"system_prompt": """You coach {name} ({pronouns}) through a live software-engineering interview (behavioral, system-design, or coding-explanation).
|
|
53
|
+
|
|
54
|
+
BACKGROUND
|
|
55
|
+
{background}
|
|
56
|
+
|
|
57
|
+
TARGET ROLE
|
|
58
|
+
{target_role}
|
|
59
|
+
|
|
60
|
+
STRENGTHS
|
|
61
|
+
{strengths}
|
|
62
|
+
|
|
63
|
+
WEAKNESSES
|
|
64
|
+
{weaknesses}
|
|
65
|
+
|
|
66
|
+
CONTEXT
|
|
67
|
+
{extra_context}
|
|
68
|
+
|
|
69
|
+
Tags in the conversation:
|
|
70
|
+
[INTERVIEWER said] ... = the interviewer — answer this
|
|
71
|
+
[USER said] ... = {name} — remember, don't re-answer
|
|
72
|
+
[HEARD] ... = ambiguous
|
|
73
|
+
|
|
74
|
+
For behavioral questions, use STAR structure (Situation, Task, Action, Result).
|
|
75
|
+
For system-design, name the constraints out loud before diving in.
|
|
76
|
+
For coding, narrate the approach before the code.
|
|
77
|
+
|
|
78
|
+
Reply in this exact format:
|
|
79
|
+
|
|
80
|
+
SAY:
|
|
81
|
+
<Exact words to speak. Concise, structured, natural.>
|
|
82
|
+
|
|
83
|
+
ANALYSIS:
|
|
84
|
+
<What the interviewer is really testing (comms, depth, red-flag detection).>
|
|
85
|
+
|
|
86
|
+
WHY:
|
|
87
|
+
<One-line justification for the framing choice.>""",
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
"product-management": {
|
|
91
|
+
"label": "Product / PM interview",
|
|
92
|
+
"system_prompt": """You coach {name} ({pronouns}) through a product-management interview (product-sense, execution, strategy, or estimation).
|
|
93
|
+
|
|
94
|
+
BACKGROUND
|
|
95
|
+
{background}
|
|
96
|
+
|
|
97
|
+
TARGET ROLE
|
|
98
|
+
{target_role}
|
|
99
|
+
|
|
100
|
+
STRENGTHS
|
|
101
|
+
{strengths}
|
|
102
|
+
|
|
103
|
+
WEAKNESSES
|
|
104
|
+
{weaknesses}
|
|
105
|
+
|
|
106
|
+
CONTEXT
|
|
107
|
+
{extra_context}
|
|
108
|
+
|
|
109
|
+
Tags:
|
|
110
|
+
[INTERVIEWER said] ... = interviewer
|
|
111
|
+
[USER said] ... = {name} (remember, don't re-answer)
|
|
112
|
+
[HEARD] ... = ambiguous
|
|
113
|
+
|
|
114
|
+
Frameworks to reach for:
|
|
115
|
+
- Product-sense: CIRCLES or persona → pain → solution → metric
|
|
116
|
+
- Execution: define success metric first
|
|
117
|
+
- Strategy: market → position → moat
|
|
118
|
+
- Estimation: state assumptions explicitly
|
|
119
|
+
|
|
120
|
+
Reply format:
|
|
121
|
+
|
|
122
|
+
SAY:
|
|
123
|
+
<Words to speak. Lead with structure ("Let me break this into three parts...").>
|
|
124
|
+
|
|
125
|
+
ANALYSIS:
|
|
126
|
+
<What the interviewer is testing; likely follow-ups.>
|
|
127
|
+
|
|
128
|
+
WHY:
|
|
129
|
+
<Why this framing wins.>""",
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
"sales-discovery": {
|
|
133
|
+
"label": "Sales / discovery call",
|
|
134
|
+
"system_prompt": """You coach {name} ({pronouns}) through a live sales discovery or customer call.
|
|
135
|
+
|
|
136
|
+
BACKGROUND
|
|
137
|
+
{background}
|
|
138
|
+
|
|
139
|
+
TARGET OUTCOME
|
|
140
|
+
{target_role}
|
|
141
|
+
|
|
142
|
+
STRENGTHS
|
|
143
|
+
{strengths}
|
|
144
|
+
|
|
145
|
+
WEAKNESSES
|
|
146
|
+
{weaknesses}
|
|
147
|
+
|
|
148
|
+
CONTEXT
|
|
149
|
+
{extra_context}
|
|
150
|
+
|
|
151
|
+
Tags:
|
|
152
|
+
[PROSPECT said] ... = the prospect — respond to this
|
|
153
|
+
[USER said] ... = {name} — remember, don't re-answer
|
|
154
|
+
[HEARD] ... = ambiguous
|
|
155
|
+
|
|
156
|
+
Note: substitute [PROSPECT said] wherever you see [INTERVIEWER said].
|
|
157
|
+
|
|
158
|
+
Reply format:
|
|
159
|
+
|
|
160
|
+
SAY:
|
|
161
|
+
<Words to speak. Discovery-style: acknowledge → mirror pain → open-ended follow-up.>
|
|
162
|
+
|
|
163
|
+
ANALYSIS:
|
|
164
|
+
<Buying signal? Objection? Where in the funnel?>
|
|
165
|
+
|
|
166
|
+
WHY:
|
|
167
|
+
<Why this move advances the deal.>""",
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
"medical-residency": {
|
|
171
|
+
"label": "Medical residency / clinical interview",
|
|
172
|
+
"system_prompt": """You coach {name} ({pronouns}) through a residency, fellowship, or clinical-position interview.
|
|
173
|
+
|
|
174
|
+
BACKGROUND
|
|
175
|
+
{background}
|
|
176
|
+
|
|
177
|
+
TARGET PROGRAM
|
|
178
|
+
{target_role}
|
|
179
|
+
|
|
180
|
+
STRENGTHS
|
|
181
|
+
{strengths}
|
|
182
|
+
|
|
183
|
+
WEAKNESSES
|
|
184
|
+
{weaknesses}
|
|
185
|
+
|
|
186
|
+
CONTEXT
|
|
187
|
+
{extra_context}
|
|
188
|
+
|
|
189
|
+
Tags:
|
|
190
|
+
[INTERVIEWER said] ... = interviewer
|
|
191
|
+
[USER said] ... = {name} — context, don't re-answer
|
|
192
|
+
|
|
193
|
+
Reply format:
|
|
194
|
+
|
|
195
|
+
SAY:
|
|
196
|
+
<Words to speak. Weave clinical experience, empathy, and program-fit into every answer.>
|
|
197
|
+
|
|
198
|
+
ANALYSIS:
|
|
199
|
+
<What the interviewer is screening for; red flags to avoid.>
|
|
200
|
+
|
|
201
|
+
WHY:
|
|
202
|
+
<Why this framing lands.>""",
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
"general": {
|
|
206
|
+
"label": "General voice assistant",
|
|
207
|
+
"system_prompt": """You are a helpful voice assistant for {name} ({pronouns}).
|
|
208
|
+
|
|
209
|
+
BACKGROUND
|
|
210
|
+
{background}
|
|
211
|
+
|
|
212
|
+
CONTEXT
|
|
213
|
+
{extra_context}
|
|
214
|
+
|
|
215
|
+
Reply in three sections:
|
|
216
|
+
|
|
217
|
+
SAY:
|
|
218
|
+
<Direct spoken answer.>
|
|
219
|
+
|
|
220
|
+
ANALYSIS:
|
|
221
|
+
<Reasoning or caveats.>
|
|
222
|
+
|
|
223
|
+
WHY:
|
|
224
|
+
<One-line justification.>""",
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def get_template(template_id: str) -> dict:
|
|
230
|
+
if template_id not in TEMPLATES:
|
|
231
|
+
raise ValueError(f"Unknown template: {template_id}")
|
|
232
|
+
return TEMPLATES[template_id]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def render_prompt(template_id: str, profile: dict, plan: dict | None = None) -> str:
|
|
236
|
+
tpl = get_template(template_id)
|
|
237
|
+
fields = {
|
|
238
|
+
"name": profile.get("name") or "the user",
|
|
239
|
+
"pronouns": profile.get("pronouns") or "they/them",
|
|
240
|
+
"background": profile.get("background") or "(not specified)",
|
|
241
|
+
"target_role": profile.get("target_role") or "(not specified)",
|
|
242
|
+
"strengths": profile.get("strengths") or "(not specified)",
|
|
243
|
+
"weaknesses": profile.get("weaknesses") or "(not specified)",
|
|
244
|
+
"extra_context": profile.get("extra_context") or "(none)",
|
|
245
|
+
}
|
|
246
|
+
base = tpl["system_prompt"].format(**fields)
|
|
247
|
+
|
|
248
|
+
if plan:
|
|
249
|
+
from meeting_plans import render_plan_for_prompt
|
|
250
|
+
plan_block = render_plan_for_prompt(plan)
|
|
251
|
+
addendum = f"""
|
|
252
|
+
|
|
253
|
+
════════════════════════════════════════════════════════
|
|
254
|
+
MEETING-SPECIFIC PLAN — treat this as HIGH-PRIORITY context.
|
|
255
|
+
════════════════════════════════════════════════════════
|
|
256
|
+
|
|
257
|
+
{plan_block}
|
|
258
|
+
|
|
259
|
+
════════════════════════════════════════════════════════
|
|
260
|
+
|
|
261
|
+
WHEN THE PLAN IS ACTIVE, your SAY / ANALYSIS / WHY response should:
|
|
262
|
+
• Reference the section of the plan the turn belongs to when it fits
|
|
263
|
+
(e.g. "This is the moment for [Section 4 — How AI fits]").
|
|
264
|
+
• Point out when {fields['name']} is about to walk into a listed trap.
|
|
265
|
+
• Note if a listed question hasn't been asked yet and this is a chance.
|
|
266
|
+
• Note if a listed commitment can be secured in this turn.
|
|
267
|
+
• Nudge the conversation toward the next section when the current one
|
|
268
|
+
is winding down.
|
|
269
|
+
"""
|
|
270
|
+
base = base + addendum
|
|
271
|
+
|
|
272
|
+
return base
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def list_template_choices() -> list[tuple[str, str]]:
|
|
276
|
+
return [(tid, t["label"]) for tid, t in TEMPLATES.items()]
|
transcriber.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Backwards-compat shim — real logic is in transcribers.py."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from transcribers import transcribe as _dispatch, preload_local
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def load_model(size: str = "base"):
|
|
7
|
+
preload_local(size)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def transcribe(audio: np.ndarray, model_size: str = "base",
|
|
11
|
+
provider: str = "local") -> str:
|
|
12
|
+
return _dispatch(audio, provider=provider, model=model_size)
|
transcribers.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-provider speech-to-text dispatch.
|
|
3
|
+
|
|
4
|
+
Each transcriber exposes:
|
|
5
|
+
transcribe(audio: np.ndarray, model: str) -> str
|
|
6
|
+
|
|
7
|
+
`audio` is a float32 mono numpy array at 16 kHz (what Whisper expects and what
|
|
8
|
+
`recorder.py` produces). Cloud providers get it re-encoded to WAV bytes.
|
|
9
|
+
|
|
10
|
+
Supported:
|
|
11
|
+
- local (openai-whisper, offline, no key)
|
|
12
|
+
- groq (Whisper on Groq LPU — very fast, cheap)
|
|
13
|
+
- openai (Whisper-1 + GPT-4o transcribe family)
|
|
14
|
+
- deepgram (Nova-3, streaming-quality accuracy)
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import io
|
|
19
|
+
import os
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SAMPLE_RATE = 16000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
STT_PROVIDERS = {
|
|
27
|
+
"local": {
|
|
28
|
+
"env": None,
|
|
29
|
+
"models": {
|
|
30
|
+
"tiny": "tiny — <1s latency, low accuracy",
|
|
31
|
+
"base": "base — quick, moderate accuracy",
|
|
32
|
+
"small": "small — recommended for real calls (local)",
|
|
33
|
+
"medium": "medium — slower, higher accuracy",
|
|
34
|
+
},
|
|
35
|
+
"default_model": "small",
|
|
36
|
+
},
|
|
37
|
+
"groq": {
|
|
38
|
+
"env": "GROQ_API_KEY",
|
|
39
|
+
"models": {
|
|
40
|
+
"whisper-large-v3-turbo": "Fastest — sub-second on LPU (default)",
|
|
41
|
+
"whisper-large-v3": "Highest accuracy, still fast",
|
|
42
|
+
},
|
|
43
|
+
"default_model": "whisper-large-v3-turbo",
|
|
44
|
+
"base_url": "https://api.groq.com/openai/v1",
|
|
45
|
+
},
|
|
46
|
+
"openai": {
|
|
47
|
+
"env": "OPENAI_API_KEY",
|
|
48
|
+
"models": {
|
|
49
|
+
"whisper-1": "Classic Whisper API",
|
|
50
|
+
"gpt-4o-mini-transcribe": "Newer, cheaper, accurate (default)",
|
|
51
|
+
"gpt-4o-transcribe": "Most accurate cloud Whisper",
|
|
52
|
+
},
|
|
53
|
+
"default_model": "gpt-4o-mini-transcribe",
|
|
54
|
+
},
|
|
55
|
+
"deepgram": {
|
|
56
|
+
"env": "DEEPGRAM_API_KEY",
|
|
57
|
+
"models": {
|
|
58
|
+
"nova-3": "Fastest + most accurate (default)",
|
|
59
|
+
"nova-2": "Prior generation",
|
|
60
|
+
},
|
|
61
|
+
"default_model": "nova-3",
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _audio_to_wav_bytes(audio: np.ndarray) -> bytes:
|
|
67
|
+
import soundfile as sf
|
|
68
|
+
buf = io.BytesIO()
|
|
69
|
+
sf.write(buf, audio, SAMPLE_RATE, format="WAV", subtype="PCM_16")
|
|
70
|
+
return buf.getvalue()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ─── Local Whisper ─────────────────────────────────────────────────────────
|
|
74
|
+
_local_model = None
|
|
75
|
+
_local_size = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _transcribe_local(audio: np.ndarray, model: str) -> str:
|
|
79
|
+
global _local_model, _local_size
|
|
80
|
+
import whisper
|
|
81
|
+
if _local_model is None or _local_size != model:
|
|
82
|
+
print(f" [Loading Whisper {model} model...]")
|
|
83
|
+
_local_model = whisper.load_model(model)
|
|
84
|
+
_local_size = model
|
|
85
|
+
print(" [Model ready]")
|
|
86
|
+
result = _local_model.transcribe(audio, fp16=False, language="en")
|
|
87
|
+
return result["text"].strip()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ─── OpenAI-compatible (OpenAI + Groq) ─────────────────────────────────────
|
|
91
|
+
_openai_clients: dict = {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _transcribe_openai_compat(provider: str, audio: np.ndarray, model: str) -> str:
|
|
95
|
+
from openai import OpenAI
|
|
96
|
+
if provider not in _openai_clients:
|
|
97
|
+
cfg = STT_PROVIDERS[provider]
|
|
98
|
+
key = os.environ.get(cfg["env"])
|
|
99
|
+
if not key:
|
|
100
|
+
raise RuntimeError(f"Missing env var {cfg['env']}")
|
|
101
|
+
kwargs = {"api_key": key}
|
|
102
|
+
if "base_url" in cfg:
|
|
103
|
+
kwargs["base_url"] = cfg["base_url"]
|
|
104
|
+
_openai_clients[provider] = OpenAI(**kwargs)
|
|
105
|
+
client = _openai_clients[provider]
|
|
106
|
+
|
|
107
|
+
wav = _audio_to_wav_bytes(audio)
|
|
108
|
+
resp = client.audio.transcriptions.create(
|
|
109
|
+
model=model,
|
|
110
|
+
file=("audio.wav", wav, "audio/wav"),
|
|
111
|
+
language="en",
|
|
112
|
+
)
|
|
113
|
+
return resp.text.strip()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ─── Deepgram ──────────────────────────────────────────────────────────────
|
|
117
|
+
def _transcribe_deepgram(audio: np.ndarray, model: str) -> str:
|
|
118
|
+
import requests
|
|
119
|
+
key = os.environ.get("DEEPGRAM_API_KEY")
|
|
120
|
+
if not key:
|
|
121
|
+
raise RuntimeError("Missing env var DEEPGRAM_API_KEY")
|
|
122
|
+
|
|
123
|
+
wav = _audio_to_wav_bytes(audio)
|
|
124
|
+
resp = requests.post(
|
|
125
|
+
"https://api.deepgram.com/v1/listen",
|
|
126
|
+
params={"model": model, "language": "en", "smart_format": "true"},
|
|
127
|
+
headers={
|
|
128
|
+
"Authorization": f"Token {key}",
|
|
129
|
+
"Content-Type": "audio/wav",
|
|
130
|
+
},
|
|
131
|
+
data=wav,
|
|
132
|
+
timeout=30,
|
|
133
|
+
)
|
|
134
|
+
resp.raise_for_status()
|
|
135
|
+
data = resp.json()
|
|
136
|
+
return data["results"]["channels"][0]["alternatives"][0]["transcript"].strip()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ─── Dispatch ──────────────────────────────────────────────────────────────
|
|
140
|
+
def transcribe(audio: np.ndarray, provider: str = "local", model: str = "small") -> str:
|
|
141
|
+
if audio is None or len(audio) == 0:
|
|
142
|
+
return ""
|
|
143
|
+
if provider == "local":
|
|
144
|
+
return _transcribe_local(audio, model)
|
|
145
|
+
if provider in ("openai", "groq"):
|
|
146
|
+
return _transcribe_openai_compat(provider, audio, model)
|
|
147
|
+
if provider == "deepgram":
|
|
148
|
+
return _transcribe_deepgram(audio, model)
|
|
149
|
+
raise ValueError(f"Unknown STT provider: {provider}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def preload_local(model: str):
|
|
153
|
+
"""Warm up the local Whisper model so first turn isn't laggy."""
|
|
154
|
+
_transcribe_local(np.zeros(SAMPLE_RATE // 10, dtype=np.float32), model)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def stt_key_status() -> dict:
|
|
158
|
+
return {
|
|
159
|
+
p: (True if cfg["env"] is None else bool(os.environ.get(cfg["env"])))
|
|
160
|
+
for p, cfg in STT_PROVIDERS.items()
|
|
161
|
+
}
|