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
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()
|