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 ADDED
@@ -0,0 +1,328 @@
1
+ """
2
+ Coach turn as a LangGraph state machine.
3
+
4
+ Layout:
5
+
6
+ START
7
+
8
+
9
+ classify_turn (cheap LLM: Haiku / Flash / DeepSeek chat)
10
+
11
+
12
+ match_plan (no LLM: string overlap against plan.answers_prep)
13
+
14
+
15
+ route (no LLM: decides which model to use in compose)
16
+
17
+
18
+ compose_reply (chosen LLM; streams if streaming=True)
19
+
20
+
21
+ END
22
+
23
+ Design rules:
24
+ * Fast path: simple/small-talk turns can skip the expensive model.
25
+ * Any node can fall back to the current `respond()` pipeline on failure.
26
+ * Streaming is opt-in — CLI turns it on, batch runs (research) keep sync.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ from typing import Optional, TypedDict, Literal
32
+
33
+ from providers import respond, PROVIDERS
34
+
35
+
36
+ TurnType = Literal["question", "statement", "small_talk", "pivot", "instruction"]
37
+
38
+
39
+ class TurnState(TypedDict, total=False):
40
+ # ─── Input (set by caller) ────────────────────────────────────────
41
+ transcript: str
42
+ speaker: str # "you" | "interviewer" | "unknown"
43
+ plan: Optional[dict]
44
+ briefing_present: bool
45
+ system_prompt: str
46
+ history: list[dict]
47
+ provider: str # default LLM provider
48
+ model: str # default LLM model
49
+
50
+ # ─── Intermediate ─────────────────────────────────────────────────
51
+ turn_type: Optional[TurnType]
52
+ matched_answer_idx: Optional[int]
53
+ matched_answer_text: Optional[str]
54
+ routing_decision: str # "cheap" | "default" | "premium"
55
+ routing_reason: str
56
+
57
+ # ─── Output ───────────────────────────────────────────────────────
58
+ reply: str
59
+ error: Optional[str]
60
+
61
+
62
+ # ─── Model tier registry ──────────────────────────────────────────────
63
+ # Given a provider, pick the cheap/default/premium models.
64
+ TIER_MAP = {
65
+ "anthropic": {
66
+ "cheap": "claude-haiku-4-5",
67
+ "default": "claude-sonnet-4-6",
68
+ "premium": "claude-opus-4-7",
69
+ },
70
+ "openai": {
71
+ "cheap": "gpt-5-mini",
72
+ "default": "gpt-5-mini",
73
+ "premium": "gpt-5",
74
+ },
75
+ "deepseek": {
76
+ "cheap": "deepseek-chat",
77
+ "default": "deepseek-chat",
78
+ "premium": "deepseek-reasoner",
79
+ },
80
+ "gemini": {
81
+ "cheap": "gemini-2.5-flash",
82
+ "default": "gemini-2.5-flash",
83
+ "premium": "gemini-2.5-pro",
84
+ },
85
+ }
86
+
87
+
88
+ def _tier_model(provider: str, tier: str, fallback: str) -> str:
89
+ return TIER_MAP.get(provider, {}).get(tier, fallback)
90
+
91
+
92
+ # ─── Nodes ────────────────────────────────────────────────────────────
93
+
94
+ CLASSIFY_SYSTEM = (
95
+ "You classify a single conversation turn. Output ONE token only, no "
96
+ "prose, no punctuation. Valid outputs: question | statement | small_talk | "
97
+ "pivot | instruction. "
98
+ "\n\n"
99
+ "Definitions:\n"
100
+ " question = the speaker is asking something that expects an answer\n"
101
+ " statement = the speaker is asserting something (not asking)\n"
102
+ " small_talk = greetings, filler, weather, non-substantive\n"
103
+ " pivot = a section-change signal ('let's move on', 'next topic')\n"
104
+ " instruction = the speaker is telling the user what to do\n"
105
+ )
106
+
107
+
108
+ SMALL_TALK_MARKERS = {
109
+ "hello", "hi", "hey", "how are you", "nice to meet",
110
+ "good morning", "good afternoon", "good evening", "thanks",
111
+ "thank you", "cheers", "bye", "goodbye", "see you",
112
+ }
113
+ PIVOT_MARKERS = {
114
+ "let's move on", "next topic", "moving on", "shall we",
115
+ "next question", "let's talk about", "one more thing",
116
+ }
117
+
118
+
119
+ def _fast_classify(transcript: str, speaker: str) -> Optional[TurnType]:
120
+ """Fast heuristic. Returns None when unsure (fall back to LLM)."""
121
+ t = transcript.lower().strip()
122
+ if not t:
123
+ return "small_talk"
124
+ words = t.split()
125
+ n = len(words)
126
+
127
+ # Very short utterances are almost always small_talk
128
+ if n < 4:
129
+ return "small_talk"
130
+
131
+ # Explicit greeting / closing markers
132
+ for m in SMALL_TALK_MARKERS:
133
+ if m in t and n < 12:
134
+ return "small_talk"
135
+
136
+ # Pivot signals
137
+ for m in PIVOT_MARKERS:
138
+ if m in t:
139
+ return "pivot"
140
+
141
+ # Ends with ? → question
142
+ if t.endswith("?"):
143
+ return "question"
144
+
145
+ # Speaker heuristics: interviewer utterances are usually questions or
146
+ # instructions; user utterances are usually statements.
147
+ if speaker == "interviewer" and n > 6:
148
+ # Contains a wh-word or "how" → almost certainly a question
149
+ wh = {"what", "why", "how", "when", "where", "which", "who",
150
+ "tell me", "walk me", "explain", "describe", "could you", "can you"}
151
+ for w in wh:
152
+ if w in t:
153
+ return "question"
154
+ return "question" # default for interviewer
155
+
156
+ if speaker == "you" and n > 6:
157
+ return "statement"
158
+
159
+ return None # let the LLM classifier decide
160
+
161
+
162
+ def classify_node(state: TurnState) -> TurnState:
163
+ """Fast heuristic → LLM classifier only if ambiguous."""
164
+ transcript = state.get("transcript", "").strip()
165
+ speaker = state.get("speaker", "unknown")
166
+
167
+ fast = _fast_classify(transcript, speaker)
168
+ if fast is not None:
169
+ state["turn_type"] = fast
170
+ return state
171
+
172
+ # Ambiguous — call cheap-tier classifier.
173
+ provider = state["provider"]
174
+ cheap_model = _tier_model(provider, "cheap", state["model"])
175
+ try:
176
+ raw = respond(
177
+ provider=provider, model=cheap_model,
178
+ transcript=transcript, history=[],
179
+ system_prompt=CLASSIFY_SYSTEM,
180
+ )
181
+ cleaned = raw.strip().lower().split()[0].strip(".,;:!?")
182
+ if cleaned in ("question", "statement", "small_talk", "pivot", "instruction"):
183
+ state["turn_type"] = cleaned # type: ignore
184
+ else:
185
+ state["turn_type"] = "question"
186
+ except Exception as e:
187
+ state["error"] = f"classify: {e}"
188
+ state["turn_type"] = "question"
189
+ return state
190
+
191
+
192
+ def match_plan_node(state: TurnState) -> TurnState:
193
+ """Try to match the transcript to a prepared answer in the plan. No LLM."""
194
+ plan = state.get("plan")
195
+ if not plan:
196
+ return state
197
+
198
+ prep = plan.get("answers_prep") or []
199
+ if not prep:
200
+ return state
201
+
202
+ transcript_words = {w.lower().strip(".,;:!?") for w in state.get("transcript", "").split()}
203
+ best_idx = -1
204
+ best_overlap = 0
205
+ for i, ans in enumerate(prep):
206
+ ans_words = {w.lower().strip(".,;:!?") for w in ans.split() if len(w) > 3}
207
+ overlap = len(transcript_words & ans_words)
208
+ if overlap > best_overlap:
209
+ best_overlap = overlap
210
+ best_idx = i
211
+ if best_overlap >= 2: # meaningful overlap (2+ substantive words)
212
+ state["matched_answer_idx"] = best_idx
213
+ state["matched_answer_text"] = prep[best_idx]
214
+ return state
215
+
216
+
217
+ def route_node(state: TurnState) -> TurnState:
218
+ """Decide which model tier to use for the compose step. No LLM."""
219
+ turn_type = state.get("turn_type") or "question"
220
+ matched = state.get("matched_answer_text") is not None
221
+ briefing = state.get("briefing_present", False)
222
+
223
+ if turn_type == "small_talk":
224
+ state["routing_decision"] = "cheap"
225
+ state["routing_reason"] = "small talk — cheap model is enough"
226
+ elif turn_type == "pivot":
227
+ state["routing_decision"] = "cheap"
228
+ state["routing_reason"] = "pivot — no reasoning needed"
229
+ elif matched:
230
+ state["routing_decision"] = "cheap"
231
+ state["routing_reason"] = "matched a prepared answer — cheap model can compose"
232
+ elif turn_type == "question" and briefing:
233
+ state["routing_decision"] = "default"
234
+ state["routing_reason"] = "question needs briefing-informed answer"
235
+ elif turn_type == "question":
236
+ state["routing_decision"] = "default"
237
+ state["routing_reason"] = "question with no briefing — default is fine"
238
+ else:
239
+ state["routing_decision"] = "default"
240
+ state["routing_reason"] = "default tier"
241
+ return state
242
+
243
+
244
+ def compose_node(state: TurnState) -> TurnState:
245
+ """Actually compose the SAY/ANALYSIS/WHY response."""
246
+ provider = state["provider"]
247
+ default_model = state["model"]
248
+ tier = state.get("routing_decision", "default")
249
+ model = _tier_model(provider, tier, default_model)
250
+
251
+ # Prepend a note to the transcript so the model knows the turn type +
252
+ # matched answer if any.
253
+ prefix_lines = [f"[TURN TYPE: {state.get('turn_type', 'question')}]"]
254
+ if state.get("matched_answer_text"):
255
+ prefix_lines.append(
256
+ f"[MATCHES PREPARED ANSWER #{state['matched_answer_idx']}: "
257
+ f"{state['matched_answer_text']}]\n"
258
+ "Use this preparation as the backbone of SAY, but adapt to what "
259
+ "was actually asked."
260
+ )
261
+ prefixed_transcript = "\n".join(prefix_lines) + "\n\n" + state["transcript"]
262
+
263
+ try:
264
+ # We call respond() with a COPY of history so the classifier & match
265
+ # nodes don't pollute the persistent conversation memory.
266
+ history_copy = list(state.get("history", []))
267
+ reply = respond(
268
+ provider=provider, model=model,
269
+ transcript=prefixed_transcript, history=history_copy,
270
+ system_prompt=state["system_prompt"],
271
+ )
272
+ state["reply"] = reply
273
+ except Exception as e:
274
+ state["reply"] = ""
275
+ state["error"] = f"compose: {e}"
276
+ return state
277
+
278
+
279
+ # ─── Graph builder ────────────────────────────────────────────────────
280
+
281
+ def build_graph():
282
+ from langgraph.graph import StateGraph, START, END
283
+
284
+ g = StateGraph(TurnState)
285
+ g.add_node("classify", classify_node)
286
+ g.add_node("match_plan", match_plan_node)
287
+ g.add_node("route", route_node)
288
+ g.add_node("compose", compose_node)
289
+
290
+ g.add_edge(START, "classify")
291
+ g.add_edge("classify", "match_plan")
292
+ g.add_edge("match_plan", "route")
293
+ g.add_edge("route", "compose")
294
+ g.add_edge("compose", END)
295
+ return g.compile()
296
+
297
+
298
+ # Module-level singleton so we don't rebuild every turn.
299
+ _GRAPH = None
300
+
301
+
302
+ def run_turn(
303
+ transcript: str,
304
+ speaker: str,
305
+ system_prompt: str,
306
+ history: list[dict],
307
+ provider: str,
308
+ model: str,
309
+ plan: Optional[dict] = None,
310
+ briefing_present: bool = False,
311
+ ) -> tuple[str, TurnState]:
312
+ """Run one turn through the graph. Returns (reply, final_state)."""
313
+ global _GRAPH
314
+ if _GRAPH is None:
315
+ _GRAPH = build_graph()
316
+
317
+ initial: TurnState = {
318
+ "transcript": transcript,
319
+ "speaker": speaker,
320
+ "plan": plan,
321
+ "briefing_present": briefing_present,
322
+ "system_prompt": system_prompt,
323
+ "history": history,
324
+ "provider": provider,
325
+ "model": model,
326
+ }
327
+ final = _GRAPH.invoke(initial)
328
+ return final.get("reply", ""), final
config.py ADDED
@@ -0,0 +1,170 @@
1
+ """
2
+ Configuration: choose LLM + STT providers, store API keys in .env, remember choice.
3
+
4
+ Files:
5
+ .env — API keys (git-ignored). Loaded on startup.
6
+ ~/.config/interview-coach/config.json — last-used choices
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from providers import PROVIDERS, key_status
15
+ from transcribers import STT_PROVIDERS, stt_key_status
16
+
17
+
18
+ PROJECT_DIR = Path(__file__).parent
19
+ ENV_PATH = PROJECT_DIR / ".env"
20
+ CONFIG_DIR = Path.home() / ".config" / "interview-coach"
21
+ CONFIG_PATH = CONFIG_DIR / "config.json"
22
+
23
+
24
+ def load_env():
25
+ """Load .env into os.environ (does not override existing vars)."""
26
+ if not ENV_PATH.exists():
27
+ return
28
+ for line in ENV_PATH.read_text().splitlines():
29
+ line = line.strip()
30
+ if not line or line.startswith("#") or "=" not in line:
31
+ continue
32
+ k, v = line.split("=", 1)
33
+ k, v = k.strip(), v.strip().strip('"').strip("'")
34
+ os.environ.setdefault(k, v)
35
+
36
+
37
+ def load_config() -> dict:
38
+ if CONFIG_PATH.exists():
39
+ try:
40
+ return json.loads(CONFIG_PATH.read_text())
41
+ except Exception:
42
+ pass
43
+ return {}
44
+
45
+
46
+ def save_config(cfg: dict):
47
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
48
+ CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
49
+
50
+
51
+ def _write_env_var(key: str, value: str):
52
+ """Append or replace a KEY=value line in .env (chmod 600)."""
53
+ lines = []
54
+ found = False
55
+ if ENV_PATH.exists():
56
+ for line in ENV_PATH.read_text().splitlines():
57
+ if line.startswith(f"{key}="):
58
+ lines.append(f"{key}={value}")
59
+ found = True
60
+ else:
61
+ lines.append(line)
62
+ if not found:
63
+ lines.append(f"{key}={value}")
64
+ ENV_PATH.write_text("\n".join(lines) + "\n")
65
+ os.chmod(ENV_PATH, 0o600)
66
+ os.environ[key] = value
67
+
68
+
69
+ def _pick(kind: str, catalog: dict, current: str | None) -> tuple[str, str]:
70
+ """Interactive picker for a provider + model. Returns (provider, model)."""
71
+ keys = list(catalog.keys())
72
+ print(f"\n Choose a {kind} provider:\n")
73
+ for i, p in enumerate(keys, 1):
74
+ env = catalog[p].get("env")
75
+ if env is None:
76
+ state = "✓ no key needed"
77
+ elif os.environ.get(env):
78
+ state = "✓ key set"
79
+ else:
80
+ state = " needs key"
81
+ marker = " ← current" if p == current else ""
82
+ print(f" {i}. {p:<10} [{state}]{marker}")
83
+
84
+ while True:
85
+ raw = input(f"\n Pick {kind} provider (1-{len(keys)}, Enter to keep current): ").strip()
86
+ if raw == "" and current:
87
+ provider = current
88
+ break
89
+ if raw.isdigit() and 1 <= int(raw) <= len(keys):
90
+ provider = keys[int(raw) - 1]
91
+ break
92
+ print(" Invalid choice.")
93
+
94
+ cfg = catalog[provider]
95
+ models = list(cfg["models"].keys())
96
+ print(f"\n Choose a {provider} model:\n")
97
+ for i, m in enumerate(models, 1):
98
+ tag = " (default)" if m == cfg["default_model"] else ""
99
+ print(f" {i}. {m:<26} — {cfg['models'][m]}{tag}")
100
+
101
+ while True:
102
+ raw = input(f"\n Pick model (1-{len(models)}, Enter for default): ").strip()
103
+ if raw == "":
104
+ model = cfg["default_model"]
105
+ break
106
+ if raw.isdigit() and 1 <= int(raw) <= len(models):
107
+ model = models[int(raw) - 1]
108
+ break
109
+ print(" Invalid choice.")
110
+
111
+ env_var = cfg.get("env")
112
+ if env_var and not os.environ.get(env_var):
113
+ print(f"\n No {env_var} found.")
114
+ key = input(f" Paste your {provider} API key (or Enter to skip): ").strip()
115
+ if key:
116
+ _write_env_var(env_var, key)
117
+ print(f" Saved to {ENV_PATH} (chmod 600).")
118
+ else:
119
+ print(f" Skipped — set {env_var} before running.")
120
+
121
+ return provider, model
122
+
123
+
124
+ def interactive_setup() -> dict:
125
+ """First-run wizard: pick LLM + STT provider/model, paste keys if missing."""
126
+ print("\n" + "─" * 60)
127
+ print(" CLI Interview Recorder — Setup")
128
+ print("─" * 60)
129
+ saved = load_config()
130
+
131
+ print("\n" + "=" * 60)
132
+ print(" STEP 1 of 2 — Language model (answers your interview questions)")
133
+ print("=" * 60)
134
+ llm_provider, llm_model = _pick("LLM", PROVIDERS, saved.get("provider"))
135
+
136
+ print("\n" + "=" * 60)
137
+ print(" STEP 2 of 2 — Speech-to-text (transcribes the audio)")
138
+ print("=" * 60)
139
+ stt_provider, stt_model = _pick("STT", STT_PROVIDERS, saved.get("stt_provider"))
140
+
141
+ choice = {
142
+ "provider": llm_provider,
143
+ "model": llm_model,
144
+ "stt_provider": stt_provider,
145
+ "stt_model": stt_model,
146
+ }
147
+ save_config(choice)
148
+ print(f"\n Saved to {CONFIG_PATH}")
149
+ print("─" * 60 + "\n")
150
+ return choice
151
+
152
+
153
+ def show_status():
154
+ print("\n LLM providers:")
155
+ for provider, has_key in key_status().items():
156
+ marker = "✓" if has_key else "✗"
157
+ env = PROVIDERS[provider]["env"]
158
+ print(f" {marker} {provider:<10} ({env})")
159
+
160
+ print("\n STT providers:")
161
+ for provider, has_key in stt_key_status().items():
162
+ marker = "✓" if has_key else "✗"
163
+ env = STT_PROVIDERS[provider].get("env") or "no key needed"
164
+ print(f" {marker} {provider:<10} ({env})")
165
+
166
+ saved = load_config()
167
+ if saved:
168
+ print(f"\n Saved LLM: {saved.get('provider')} / {saved.get('model')}")
169
+ print(f" Saved STT: {saved.get('stt_provider', 'local')} / {saved.get('stt_model', 'small')}")
170
+ print()