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.
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
+ }