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
main.py
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Interview Coach CLI
|
|
4
|
+
-------------------
|
|
5
|
+
Record audio (mic or system), transcribe with a chosen STT provider, answer with
|
|
6
|
+
a chosen LLM. Multi-profile, multi-template, session-persistent.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
interview-recorder --setup # LLM + STT provider wizard
|
|
10
|
+
interview-recorder --onboard # profile wizard
|
|
11
|
+
interview-recorder --status # provider key status
|
|
12
|
+
interview-recorder # session picker then interview loop
|
|
13
|
+
interview-recorder --live # Zoom/Teams system-audio mode
|
|
14
|
+
interview-recorder --new-session --template tech-interview
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
24
|
+
|
|
25
|
+
from config import load_env, load_config, interactive_setup, show_status, PROVIDERS
|
|
26
|
+
|
|
27
|
+
load_env()
|
|
28
|
+
|
|
29
|
+
from recorder import record_until_keypress, record_until_silence
|
|
30
|
+
from transcribers import STT_PROVIDERS, transcribe as stt_transcribe, preload_local
|
|
31
|
+
from responder import respond
|
|
32
|
+
import sessions as sess
|
|
33
|
+
import profile_store
|
|
34
|
+
from templates import render_prompt, TEMPLATES, list_template_choices
|
|
35
|
+
from onboarding import pick_or_create_profile, pick_template, edit_profile_wizard
|
|
36
|
+
import meeting_plans
|
|
37
|
+
from plan_wizard import pick_plan, create_plan_wizard
|
|
38
|
+
import screen_capture
|
|
39
|
+
from providers import respond_with_image
|
|
40
|
+
from coach_graph import run_turn as coach_run_turn
|
|
41
|
+
|
|
42
|
+
from rich.console import Console
|
|
43
|
+
from rich.panel import Panel
|
|
44
|
+
from rich.table import Table
|
|
45
|
+
from rich.text import Text
|
|
46
|
+
|
|
47
|
+
console = Console()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
APP_NAME = "Interview Coach"
|
|
51
|
+
APP_VERSION = "0.3.0"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_reply(reply: str) -> dict:
|
|
55
|
+
"""Split the LLM reply into SAY / ANALYSIS / WHY sections."""
|
|
56
|
+
sections = {"SAY": "", "ANALYSIS": "", "WHY": ""}
|
|
57
|
+
pattern = re.compile(r"^\s*(SAY|ANALYSIS|WHY)\s*:\s*$", re.MULTILINE | re.IGNORECASE)
|
|
58
|
+
matches = list(pattern.finditer(reply))
|
|
59
|
+
if not matches:
|
|
60
|
+
sections["SAY"] = reply.strip()
|
|
61
|
+
return sections
|
|
62
|
+
for i, m in enumerate(matches):
|
|
63
|
+
key = m.group(1).upper()
|
|
64
|
+
start = m.end()
|
|
65
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(reply)
|
|
66
|
+
sections[key] = reply[start:end].strip()
|
|
67
|
+
return sections
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def print_banner(profile, template_id, live, llm, stt):
|
|
71
|
+
header = Text()
|
|
72
|
+
header.append(f" {APP_NAME} ", style="bold cyan")
|
|
73
|
+
header.append(f"v{APP_VERSION}\n", style="dim")
|
|
74
|
+
header.append(" Profile: ", style="dim")
|
|
75
|
+
header.append(f"{profile['name']}", style="bold yellow")
|
|
76
|
+
if profile["target_role"]:
|
|
77
|
+
header.append(f" โ {profile['target_role']}", style="yellow")
|
|
78
|
+
header.append(f"\n Template: ", style="dim")
|
|
79
|
+
header.append(f"{TEMPLATES[template_id]['label']}", style="magenta")
|
|
80
|
+
header.append(f"\n Source: ", style="dim")
|
|
81
|
+
header.append("SYSTEM AUDIO (live)" if live else "MICROPHONE", style="bold magenta")
|
|
82
|
+
header.append(f"\n STT: ", style="dim")
|
|
83
|
+
header.append(f"{stt[0]} / {stt[1]}", style="green")
|
|
84
|
+
header.append(f"\n LLM: ", style="dim")
|
|
85
|
+
header.append(f"{llm[0]} / {llm[1]}", style="green")
|
|
86
|
+
console.print(Panel(header, border_style="cyan", padding=(1, 2)))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def show_transcript(transcript: str, speaker: str, name: str, turn: int):
|
|
90
|
+
label_map = {
|
|
91
|
+
"interviewer": ("๐ INTERVIEWER", "bright_yellow"),
|
|
92
|
+
"you": (f"๐๏ธ {name.upper()}", "bright_cyan"),
|
|
93
|
+
"unknown": ("๐ง HEARD", "bright_white"),
|
|
94
|
+
}
|
|
95
|
+
label, style = label_map[speaker]
|
|
96
|
+
console.print(
|
|
97
|
+
Panel(
|
|
98
|
+
Text(transcript, style="white"),
|
|
99
|
+
title=f"[bold {style}]{label}[/]",
|
|
100
|
+
subtitle=f"[dim]turn {turn}[/]",
|
|
101
|
+
border_style=style,
|
|
102
|
+
padding=(0, 2),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def show_reply(sections: dict, turn: int):
|
|
108
|
+
say = sections.get("SAY", "").strip()
|
|
109
|
+
analysis = sections.get("ANALYSIS", "").strip()
|
|
110
|
+
why = sections.get("WHY", "").strip()
|
|
111
|
+
|
|
112
|
+
if say:
|
|
113
|
+
console.print(
|
|
114
|
+
Panel(
|
|
115
|
+
Text(say, style="bold white"),
|
|
116
|
+
title="[bold bright_green]๐ฌ SAY THIS[/]",
|
|
117
|
+
subtitle=f"[dim]turn {turn} โ speak this out loud[/]",
|
|
118
|
+
border_style="bright_green",
|
|
119
|
+
padding=(1, 2),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
if analysis:
|
|
123
|
+
console.print(
|
|
124
|
+
Panel(
|
|
125
|
+
Text(analysis, style="white"),
|
|
126
|
+
title="[bold bright_blue]๐ง ANALYSIS[/]",
|
|
127
|
+
border_style="blue",
|
|
128
|
+
padding=(0, 2),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
if why:
|
|
132
|
+
console.print(
|
|
133
|
+
Panel(
|
|
134
|
+
Text(why, style="italic"),
|
|
135
|
+
title="[bold magenta]โจ WHY IT WORKS[/]",
|
|
136
|
+
border_style="magenta",
|
|
137
|
+
padding=(0, 2),
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _default_session_title(template_id: str) -> str:
|
|
143
|
+
from datetime import datetime
|
|
144
|
+
label = TEMPLATES[template_id]["label"]
|
|
145
|
+
return f"{label} โ {datetime.now().strftime('%b %d %H:%M')}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def pick_session(profile_id: int, template_id: str, llm_provider: str, llm_model: str):
|
|
149
|
+
rows = sess.list_sessions(limit=15)
|
|
150
|
+
|
|
151
|
+
def _new():
|
|
152
|
+
title = console.input(
|
|
153
|
+
f" New session title [dim](Enter for '{_default_session_title(template_id)}')[/]: "
|
|
154
|
+
).strip() or _default_session_title(template_id)
|
|
155
|
+
sid = sess.create_session(title, template_id, llm_provider, llm_model)
|
|
156
|
+
# Link session to profile + template (columns added by profile_store migration)
|
|
157
|
+
from sessions import _connect
|
|
158
|
+
with _connect() as conn:
|
|
159
|
+
conn.execute("UPDATE sessions SET profile_id = ?, template_id = ? WHERE id = ?",
|
|
160
|
+
(profile_id, template_id, sid))
|
|
161
|
+
console.print(f"[green] โ New session #{sid}: {title}[/]\n")
|
|
162
|
+
return sid, []
|
|
163
|
+
|
|
164
|
+
if not rows:
|
|
165
|
+
return _new()
|
|
166
|
+
|
|
167
|
+
table = Table(title="Saved sessions", border_style="dim")
|
|
168
|
+
table.add_column("#", style="bold cyan", width=3)
|
|
169
|
+
table.add_column("Title", style="white")
|
|
170
|
+
table.add_column("Template", style="magenta")
|
|
171
|
+
table.add_column("Turns", justify="right", style="green")
|
|
172
|
+
table.add_column("Last updated", style="dim")
|
|
173
|
+
for i, r in enumerate(rows, 1):
|
|
174
|
+
table.add_row(str(i), r["title"], r["mode"], str(r["turns"]),
|
|
175
|
+
r["updated_at"].replace("T", " "))
|
|
176
|
+
console.print(table)
|
|
177
|
+
|
|
178
|
+
while True:
|
|
179
|
+
raw = console.input(
|
|
180
|
+
"\n[bold]Pick session[/]: [cyan]<number>[/]=continue "
|
|
181
|
+
"[bold green]n[/]=new [bold red]d<number>[/]=delete "
|
|
182
|
+
"[dim](Enter=new)[/] โธ "
|
|
183
|
+
).strip().lower()
|
|
184
|
+
if raw in ("", "n"):
|
|
185
|
+
return _new()
|
|
186
|
+
if raw.startswith("d") and raw[1:].isdigit():
|
|
187
|
+
idx = int(raw[1:]) - 1
|
|
188
|
+
if 0 <= idx < len(rows):
|
|
189
|
+
sess.delete_session(rows[idx]["id"])
|
|
190
|
+
console.print(f"[red] โ Deleted '{rows[idx]['title']}'[/]\n")
|
|
191
|
+
return pick_session(profile_id, template_id, llm_provider, llm_model)
|
|
192
|
+
if raw.isdigit():
|
|
193
|
+
idx = int(raw) - 1
|
|
194
|
+
if 0 <= idx < len(rows):
|
|
195
|
+
sid = rows[idx]["id"]
|
|
196
|
+
history = sess.load_messages(sid)
|
|
197
|
+
console.print(
|
|
198
|
+
f"[green] โ Continuing '{rows[idx]['title']}' "
|
|
199
|
+
f"โ {len(history)} messages loaded[/]\n"
|
|
200
|
+
)
|
|
201
|
+
return sid, history
|
|
202
|
+
console.print("[yellow] Invalid choice.[/]")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _run_research_cli(args):
|
|
206
|
+
"""`interview-recorder --research` โ attach a briefing to an existing plan."""
|
|
207
|
+
from plan_wizard import pick_plan
|
|
208
|
+
import research
|
|
209
|
+
|
|
210
|
+
active = profile_store.get_active_profile()
|
|
211
|
+
profile_id = active["id"] if active else None
|
|
212
|
+
|
|
213
|
+
console.print(Panel(
|
|
214
|
+
Text(
|
|
215
|
+
"Research a counterparty and attach a briefing to a meeting plan.\n"
|
|
216
|
+
"Sources: web (Tavily), academic papers (Semantic Scholar), GitHub.",
|
|
217
|
+
style="white",
|
|
218
|
+
),
|
|
219
|
+
title="[bold bright_cyan]๐ Research[/]",
|
|
220
|
+
border_style="bright_cyan", padding=(1, 2),
|
|
221
|
+
))
|
|
222
|
+
|
|
223
|
+
plan_id = pick_plan(profile_id=profile_id)
|
|
224
|
+
if plan_id is None:
|
|
225
|
+
console.print("[yellow] No plan selected.[/]")
|
|
226
|
+
return
|
|
227
|
+
plan = meeting_plans.get_plan(plan_id)
|
|
228
|
+
|
|
229
|
+
name = console.input(
|
|
230
|
+
f"[cyan] Counterparty name to research[/] "
|
|
231
|
+
f"[dim](Enter for '{(plan.get('counterparty') or '').split(',')[0]}')[/]: "
|
|
232
|
+
).strip() or (plan.get("counterparty") or "").split(",")[0].strip()
|
|
233
|
+
if not name:
|
|
234
|
+
console.print("[red] Need a name to research.[/]")
|
|
235
|
+
return
|
|
236
|
+
affiliation = console.input(
|
|
237
|
+
"[cyan] Affiliation / institution / company[/] [dim](optional)[/]: "
|
|
238
|
+
).strip()
|
|
239
|
+
|
|
240
|
+
llm_provider, llm_model = resolve_llm(args)
|
|
241
|
+
|
|
242
|
+
with console.status("[cyan] Gathering sources...[/]", spinner="dots"):
|
|
243
|
+
raw = research.research_counterparty(name, affiliation)
|
|
244
|
+
console.print(
|
|
245
|
+
f"[dim] Web: {len(raw.by_source('web'))} ยท "
|
|
246
|
+
f"Papers: {len(raw.by_source('semantic-scholar'))} ยท "
|
|
247
|
+
f"GitHub: {len(raw.by_source('github'))} ยท "
|
|
248
|
+
f"Errors: {len(raw.errors)}[/]"
|
|
249
|
+
)
|
|
250
|
+
for err in raw.errors:
|
|
251
|
+
console.print(f"[yellow] ! {err}[/]")
|
|
252
|
+
|
|
253
|
+
with console.status("[cyan] Synthesising briefing...[/]", spinner="dots"):
|
|
254
|
+
briefing = research.synthesize_briefing(raw, llm_provider, llm_model)
|
|
255
|
+
|
|
256
|
+
console.print(Panel(
|
|
257
|
+
Text(briefing.strip(), style="white"),
|
|
258
|
+
title="[bold bright_cyan]๐ Briefing[/]",
|
|
259
|
+
border_style="bright_cyan", padding=(1, 2),
|
|
260
|
+
))
|
|
261
|
+
|
|
262
|
+
meeting_plans.update_plan(
|
|
263
|
+
plan_id,
|
|
264
|
+
briefing=briefing,
|
|
265
|
+
raw_research_json=json.dumps(raw.to_dict()),
|
|
266
|
+
)
|
|
267
|
+
console.print(f"\n[green] โ Briefing attached to plan #{plan_id}.[/]")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def resolve_llm(args):
|
|
271
|
+
saved = load_config()
|
|
272
|
+
provider = args.provider or saved.get("provider") or "anthropic"
|
|
273
|
+
if provider not in PROVIDERS:
|
|
274
|
+
raise SystemExit(f"Unknown LLM provider: {provider}")
|
|
275
|
+
if args.llm_model:
|
|
276
|
+
model = args.llm_model
|
|
277
|
+
elif saved.get("provider") == provider and saved.get("model"):
|
|
278
|
+
model = saved["model"]
|
|
279
|
+
else:
|
|
280
|
+
model = PROVIDERS[provider]["default_model"]
|
|
281
|
+
env = PROVIDERS[provider]["env"]
|
|
282
|
+
if not os.environ.get(env):
|
|
283
|
+
raise SystemExit(
|
|
284
|
+
f"\n โ No API key for LLM provider {provider} โ env var {env} not set.\n"
|
|
285
|
+
f" Run: interview-recorder --setup\n"
|
|
286
|
+
)
|
|
287
|
+
return provider, model
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def resolve_stt(args):
|
|
291
|
+
saved = load_config()
|
|
292
|
+
provider = args.stt or saved.get("stt_provider") or "local"
|
|
293
|
+
if provider not in STT_PROVIDERS:
|
|
294
|
+
raise SystemExit(f"Unknown STT provider: {provider}")
|
|
295
|
+
if args.stt_model:
|
|
296
|
+
model = args.stt_model
|
|
297
|
+
elif saved.get("stt_provider") == provider and saved.get("stt_model"):
|
|
298
|
+
model = saved["stt_model"]
|
|
299
|
+
elif args.model and provider == "local":
|
|
300
|
+
model = args.model
|
|
301
|
+
else:
|
|
302
|
+
model = STT_PROVIDERS[provider]["default_model"]
|
|
303
|
+
env = STT_PROVIDERS[provider].get("env")
|
|
304
|
+
if env and not os.environ.get(env):
|
|
305
|
+
raise SystemExit(
|
|
306
|
+
f"\n โ No API key for STT provider {provider} โ env var {env} not set.\n"
|
|
307
|
+
f" Run: interview-recorder --setup\n"
|
|
308
|
+
)
|
|
309
|
+
return provider, model
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def main():
|
|
313
|
+
parser = argparse.ArgumentParser(
|
|
314
|
+
prog="interview-recorder",
|
|
315
|
+
description=f"{APP_NAME} v{APP_VERSION} โ voice-driven interview coach",
|
|
316
|
+
)
|
|
317
|
+
parser.add_argument("--version", action="version", version=f"{APP_NAME} {APP_VERSION}")
|
|
318
|
+
|
|
319
|
+
parser.add_argument("--template", choices=list(TEMPLATES.keys()), default=None,
|
|
320
|
+
help="Interview template (skips picker)")
|
|
321
|
+
parser.add_argument("--live", action="store_true",
|
|
322
|
+
help="Capture SYSTEM AUDIO with auto-stop on silence")
|
|
323
|
+
parser.add_argument("--source", choices=["mic", "system"], default=None)
|
|
324
|
+
parser.add_argument("--silence", type=float, default=1.5)
|
|
325
|
+
parser.add_argument("--speaker", choices=["you", "interviewer", "unknown"], default=None)
|
|
326
|
+
|
|
327
|
+
parser.add_argument("--provider", choices=list(PROVIDERS.keys()), default=None)
|
|
328
|
+
parser.add_argument("--llm-model", default=None)
|
|
329
|
+
parser.add_argument("--stt", choices=list(STT_PROVIDERS.keys()), default=None)
|
|
330
|
+
parser.add_argument("--stt-model", default=None)
|
|
331
|
+
parser.add_argument("--model", choices=["tiny", "base", "small", "medium"], default=None,
|
|
332
|
+
help="Shortcut: local Whisper model size")
|
|
333
|
+
|
|
334
|
+
parser.add_argument("--new-session", action="store_true",
|
|
335
|
+
help="Skip session picker; start a fresh conversation")
|
|
336
|
+
parser.add_argument("--setup", action="store_true",
|
|
337
|
+
help="Provider setup wizard (API keys + models)")
|
|
338
|
+
parser.add_argument("--onboard", action="store_true",
|
|
339
|
+
help="Profile wizard (create / edit / switch)")
|
|
340
|
+
parser.add_argument("--edit-profile", action="store_true",
|
|
341
|
+
help="Edit the active profile")
|
|
342
|
+
parser.add_argument("--make-plan", action="store_true",
|
|
343
|
+
help="Build a meeting plan (structure + questions + traps)")
|
|
344
|
+
parser.add_argument("--no-plan", action="store_true",
|
|
345
|
+
help="Skip the meeting-plan picker this run")
|
|
346
|
+
parser.add_argument("--research", action="store_true",
|
|
347
|
+
help="Research a counterparty and attach briefing to a plan")
|
|
348
|
+
parser.add_argument("--no-graph", action="store_true",
|
|
349
|
+
help="Bypass the LangGraph coach; use the direct single-call path")
|
|
350
|
+
parser.add_argument("--status", action="store_true")
|
|
351
|
+
args = parser.parse_args()
|
|
352
|
+
|
|
353
|
+
if args.status:
|
|
354
|
+
show_status()
|
|
355
|
+
return
|
|
356
|
+
if args.setup:
|
|
357
|
+
interactive_setup()
|
|
358
|
+
return
|
|
359
|
+
if args.onboard:
|
|
360
|
+
pick_or_create_profile()
|
|
361
|
+
return
|
|
362
|
+
if args.edit_profile:
|
|
363
|
+
active = profile_store.get_active_profile()
|
|
364
|
+
if not active:
|
|
365
|
+
console.print("[yellow] No active profile โ run --onboard first.[/]")
|
|
366
|
+
return
|
|
367
|
+
edit_profile_wizard(active["id"])
|
|
368
|
+
return
|
|
369
|
+
if args.make_plan:
|
|
370
|
+
active = profile_store.get_active_profile()
|
|
371
|
+
pid_owner = active["id"] if active else None
|
|
372
|
+
create_plan_wizard(profile_id=pid_owner)
|
|
373
|
+
return
|
|
374
|
+
if args.research:
|
|
375
|
+
_run_research_cli(args)
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
llm_provider, llm_model = resolve_llm(args)
|
|
379
|
+
stt_provider, stt_model = resolve_stt(args)
|
|
380
|
+
|
|
381
|
+
# โโ Profile โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
382
|
+
profile_row = profile_store.get_active_profile()
|
|
383
|
+
if not profile_row:
|
|
384
|
+
profile_id = pick_or_create_profile()
|
|
385
|
+
profile_row = profile_store.get_profile(profile_id)
|
|
386
|
+
profile = dict(profile_row)
|
|
387
|
+
|
|
388
|
+
# โโ Template โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
389
|
+
template_id = args.template or pick_template(default="academic-phd")
|
|
390
|
+
|
|
391
|
+
# โโ Meeting Plan (optional per-conversation game plan) โโโโโโโโโโโโโโโ
|
|
392
|
+
plan_id = None
|
|
393
|
+
plan = None
|
|
394
|
+
if not args.no_plan:
|
|
395
|
+
console.print("\n[bold cyan] Optional: attach a meeting plan[/]")
|
|
396
|
+
console.print("[dim] A plan lets the coach reference sections, traps, and target questions live.[/]")
|
|
397
|
+
plan_id = pick_plan(profile_id=profile["id"])
|
|
398
|
+
if plan_id:
|
|
399
|
+
plan = meeting_plans.get_plan(plan_id)
|
|
400
|
+
|
|
401
|
+
system_prompt = render_prompt(template_id, profile, plan=plan)
|
|
402
|
+
current_section_idx = 0 # tracks which agenda section is active (0-based)
|
|
403
|
+
|
|
404
|
+
source = args.source or ("system" if args.live else "mic")
|
|
405
|
+
default_speaker = "interviewer" if source == "system" else "you"
|
|
406
|
+
speaker = args.speaker or default_speaker
|
|
407
|
+
|
|
408
|
+
print_banner(profile, template_id, args.live,
|
|
409
|
+
(llm_provider, llm_model), (stt_provider, stt_model))
|
|
410
|
+
if stt_provider == "local":
|
|
411
|
+
preload_local(stt_model)
|
|
412
|
+
|
|
413
|
+
# โโ Session โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
414
|
+
if args.new_session:
|
|
415
|
+
title = _default_session_title(template_id)
|
|
416
|
+
session_id = sess.create_session(title, template_id, llm_provider, llm_model)
|
|
417
|
+
from sessions import _connect
|
|
418
|
+
with _connect() as conn:
|
|
419
|
+
conn.execute("UPDATE sessions SET profile_id = ?, template_id = ? WHERE id = ?",
|
|
420
|
+
(profile["id"], template_id, session_id))
|
|
421
|
+
history = []
|
|
422
|
+
console.print(f"[green] โ New session #{session_id}: {title}[/]\n")
|
|
423
|
+
else:
|
|
424
|
+
session_id, history = pick_session(profile["id"], template_id, llm_provider, llm_model)
|
|
425
|
+
|
|
426
|
+
# โโ Interview loop โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
427
|
+
turn = len([m for m in history if m["role"] == "user"]) + 1
|
|
428
|
+
current_source = source
|
|
429
|
+
current_speaker = speaker
|
|
430
|
+
def _handle_screen_capture(session_id, profile, template_id, plan,
|
|
431
|
+
current_section_idx, llm_provider, llm_model, history):
|
|
432
|
+
"""Grab a screenshot and hand it to the vision LLM."""
|
|
433
|
+
console.print()
|
|
434
|
+
raw = console.input(
|
|
435
|
+
"[bold]Capture mode:[/] [bold cyan]r[/]=region-select [bold cyan]f[/]=full-screen "
|
|
436
|
+
"[bold cyan]w[/]=click-a-window [dim](Enter=r)[/] โธ "
|
|
437
|
+
).strip().lower()
|
|
438
|
+
mode = {"r": "region", "f": "full", "w": "window", "": "region"}.get(raw, "region")
|
|
439
|
+
try:
|
|
440
|
+
path = screen_capture.capture(session_id=session_id, mode=mode)
|
|
441
|
+
except Exception as e:
|
|
442
|
+
console.print(f"[red] Screenshot failed: {e}[/]")
|
|
443
|
+
return
|
|
444
|
+
console.print(f"[dim] saved: {path}[/]")
|
|
445
|
+
|
|
446
|
+
# Ask user for optional guidance / question about the screenshot
|
|
447
|
+
user_q = console.input(
|
|
448
|
+
"[cyan] What do you want the coach to do with this?[/] "
|
|
449
|
+
"[dim](Enter for default: analyse + suggest response)[/]\n โธ "
|
|
450
|
+
).strip()
|
|
451
|
+
if not user_q:
|
|
452
|
+
user_q = (
|
|
453
|
+
"Analyse this screenshot. Identify what's on screen (coding question, "
|
|
454
|
+
"diagram, form, etc.). If it's a question or task, give me a clear "
|
|
455
|
+
"answer or approach I can say in the next 30-60 seconds. If it's a "
|
|
456
|
+
"reference diagram, summarise the key points that matter for the "
|
|
457
|
+
"conversation. Keep it tight."
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Build system prompt from active template + plan (same context the
|
|
461
|
+
# regular turn responses get).
|
|
462
|
+
from templates import render_prompt
|
|
463
|
+
system_prompt = render_prompt(template_id, profile, plan=plan)
|
|
464
|
+
|
|
465
|
+
with console.status("[cyan] Analysing screenshot...[/]", spinner="dots"):
|
|
466
|
+
try:
|
|
467
|
+
reply = respond_with_image(
|
|
468
|
+
llm_provider, llm_model, path, user_q, system_prompt,
|
|
469
|
+
)
|
|
470
|
+
except Exception as e:
|
|
471
|
+
console.print(f"[red] Vision LLM error: {e}[/]")
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
console.print(Panel(
|
|
475
|
+
Text(reply.strip(), style="white"),
|
|
476
|
+
title=f"[bold bright_cyan]๐ธ SCREEN ANALYSIS[/]",
|
|
477
|
+
subtitle=f"[dim]{path.name}[/]",
|
|
478
|
+
border_style="cyan", padding=(1, 2),
|
|
479
|
+
))
|
|
480
|
+
|
|
481
|
+
# Also save into session history so future turns can reference it.
|
|
482
|
+
sess.append_message(session_id, "user",
|
|
483
|
+
f"[SCREENSHOT saved at {path.name}] {user_q}",
|
|
484
|
+
speaker="unknown")
|
|
485
|
+
sess.append_message(session_id, "assistant", reply)
|
|
486
|
+
|
|
487
|
+
def _current_section_str() -> str:
|
|
488
|
+
if not plan or not plan.get("sections"):
|
|
489
|
+
return ""
|
|
490
|
+
sections = plan["sections"]
|
|
491
|
+
if current_section_idx >= len(sections):
|
|
492
|
+
return " [dim]plan: complete[/]"
|
|
493
|
+
s = sections[current_section_idx]
|
|
494
|
+
return (
|
|
495
|
+
f" [dim]plan:[/] [bold magenta]ยง{current_section_idx+1}/{len(sections)}[/] "
|
|
496
|
+
f"[magenta]{s.get('title', '')}[/]"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
while True:
|
|
500
|
+
console.print()
|
|
501
|
+
source_hint = "๐๏ธ mic (you)" if current_source == "mic" else "๐ system (interviewer)"
|
|
502
|
+
try:
|
|
503
|
+
plan_hint = _current_section_str()
|
|
504
|
+
plan_keys = " [magenta]p[/]=plan [magenta]ยง[/]=next-ยง [bright_cyan]b[/]=research" if plan else ""
|
|
505
|
+
user_input = console.input(
|
|
506
|
+
f"[bold cyan][Turn {turn}][/] "
|
|
507
|
+
f"[dim]session #{session_id} source:[/] {source_hint}{plan_hint}\n"
|
|
508
|
+
f" [Enter]=go [bold cyan]y[/]=you [bold yellow]i[/]=interviewer "
|
|
509
|
+
f"[bold]l[/]=live [bold bright_cyan]c[/]=screen "
|
|
510
|
+
f"[bold bright_white]t[/]=type-text [bold bright_white]x[/]=extra-context "
|
|
511
|
+
f"[bold green]n[/]=new-session "
|
|
512
|
+
f"[bold magenta]r[/]=rename{plan_keys} [dim]q=quit[/] โธ "
|
|
513
|
+
).strip().lower()
|
|
514
|
+
except (EOFError, KeyboardInterrupt):
|
|
515
|
+
console.print("\n[dim] Goodbye.[/]\n")
|
|
516
|
+
break
|
|
517
|
+
if user_input == "q":
|
|
518
|
+
console.print("\n[dim] Goodbye.[/]\n")
|
|
519
|
+
break
|
|
520
|
+
if user_input == "p" and plan:
|
|
521
|
+
from meeting_plans import render_plan_for_prompt
|
|
522
|
+
console.print(Panel(
|
|
523
|
+
Text(render_plan_for_prompt(plan), style="white"),
|
|
524
|
+
title="[bold magenta]๐ Meeting plan[/]",
|
|
525
|
+
border_style="magenta", padding=(1, 2),
|
|
526
|
+
))
|
|
527
|
+
continue
|
|
528
|
+
if user_input == "c":
|
|
529
|
+
_handle_screen_capture(
|
|
530
|
+
session_id=session_id, profile=profile, template_id=template_id,
|
|
531
|
+
plan=plan, current_section_idx=current_section_idx,
|
|
532
|
+
llm_provider=llm_provider, llm_model=llm_model, history=history,
|
|
533
|
+
)
|
|
534
|
+
continue
|
|
535
|
+
if user_input == "b" and plan:
|
|
536
|
+
# Refresh briefing on the fly
|
|
537
|
+
import research
|
|
538
|
+
name = console.input("[cyan] Counterparty name[/]: ").strip()
|
|
539
|
+
if not name:
|
|
540
|
+
continue
|
|
541
|
+
affiliation = console.input("[cyan] Affiliation (optional)[/]: ").strip()
|
|
542
|
+
with console.status("[cyan] Researching...[/]", spinner="dots"):
|
|
543
|
+
raw = research.research_counterparty(name, affiliation)
|
|
544
|
+
briefing = research.synthesize_briefing(raw, llm_provider, llm_model)
|
|
545
|
+
meeting_plans.update_plan(
|
|
546
|
+
plan["id"], briefing=briefing,
|
|
547
|
+
raw_research_json=json.dumps(raw.to_dict()),
|
|
548
|
+
)
|
|
549
|
+
plan = meeting_plans.get_plan(plan["id"])
|
|
550
|
+
# Re-render system prompt with new briefing
|
|
551
|
+
from templates import render_prompt as _rp
|
|
552
|
+
system_prompt = _rp(template_id, profile, plan=plan)
|
|
553
|
+
console.print("[green] โ Briefing refreshed and injected.[/]\n")
|
|
554
|
+
continue
|
|
555
|
+
if user_input == "t":
|
|
556
|
+
# Type a transcript directly โ for when audio failed OR for
|
|
557
|
+
# pasting text from chat / an email you want the coach to react to.
|
|
558
|
+
console.print(
|
|
559
|
+
"[cyan] Paste / type what was said[/] "
|
|
560
|
+
"[dim](empty line ends multi-line input)[/]"
|
|
561
|
+
)
|
|
562
|
+
lines = []
|
|
563
|
+
while True:
|
|
564
|
+
try:
|
|
565
|
+
line = input(" โธ ")
|
|
566
|
+
except EOFError:
|
|
567
|
+
break
|
|
568
|
+
if line == "":
|
|
569
|
+
break
|
|
570
|
+
lines.append(line)
|
|
571
|
+
typed = "\n".join(lines).strip()
|
|
572
|
+
if not typed:
|
|
573
|
+
continue
|
|
574
|
+
# Ask who "said" this so the tag matches semantics
|
|
575
|
+
who = console.input(
|
|
576
|
+
"[cyan] Whose words are these?[/] "
|
|
577
|
+
"[bold cyan]i[/]=interviewer [bold yellow]y[/]=you "
|
|
578
|
+
"[bold white]u[/]=unknown [dim](Enter=interviewer)[/] โธ "
|
|
579
|
+
).strip().lower()
|
|
580
|
+
typed_speaker = {"i": "interviewer", "y": "you", "u": "unknown", "": "interviewer"}.get(who, "interviewer")
|
|
581
|
+
# Route through the same pipeline as an audio turn
|
|
582
|
+
transcript = typed
|
|
583
|
+
current_speaker = typed_speaker
|
|
584
|
+
show_transcript(transcript, current_speaker, profile["name"], turn)
|
|
585
|
+
speaker_tag = {"you": "[USER said]", "interviewer": "[INTERVIEWER said]", "unknown": "[HEARD]"}[current_speaker]
|
|
586
|
+
section_prefix = ""
|
|
587
|
+
if plan and plan.get("sections") and current_section_idx < len(plan["sections"]):
|
|
588
|
+
s = plan["sections"][current_section_idx]
|
|
589
|
+
section_prefix = f"[CURRENT SECTION ยง{current_section_idx+1}: {s.get('title','')}] "
|
|
590
|
+
tagged = f"{section_prefix}{speaker_tag} {transcript}"
|
|
591
|
+
with console.status("[cyan] Thinking...[/]", spinner="dots"):
|
|
592
|
+
reply = ""
|
|
593
|
+
routing_info = ""
|
|
594
|
+
try:
|
|
595
|
+
reply, fs = coach_run_turn(
|
|
596
|
+
transcript=tagged, speaker=current_speaker,
|
|
597
|
+
system_prompt=system_prompt, history=history,
|
|
598
|
+
provider=llm_provider, model=llm_model,
|
|
599
|
+
plan=plan,
|
|
600
|
+
briefing_present=bool(plan and (plan.get("briefing") or "").strip()),
|
|
601
|
+
)
|
|
602
|
+
tier = fs.get("routing_decision", "default")
|
|
603
|
+
tt = fs.get("turn_type", "?")
|
|
604
|
+
match = "matched" if fs.get("matched_answer_text") else "fresh"
|
|
605
|
+
routing_info = f"[dim] โ turn={tt} ยท tier={tier} ยท {match}[/]"
|
|
606
|
+
except Exception as e:
|
|
607
|
+
console.print(f"[red] [Graph error: {e}][/]")
|
|
608
|
+
continue
|
|
609
|
+
if routing_info:
|
|
610
|
+
console.print(routing_info)
|
|
611
|
+
sess.append_message(session_id, "user", tagged, speaker=current_speaker)
|
|
612
|
+
sess.append_message(session_id, "assistant", reply)
|
|
613
|
+
history.append({"role": "user", "content": tagged})
|
|
614
|
+
history.append({"role": "assistant", "content": reply})
|
|
615
|
+
show_reply(parse_reply(reply), turn)
|
|
616
|
+
turn += 1
|
|
617
|
+
continue
|
|
618
|
+
if user_input == "x":
|
|
619
|
+
# Inject freeform context for the rest of the session.
|
|
620
|
+
console.print(
|
|
621
|
+
"[cyan] Extra context to remember for the rest of this session[/] "
|
|
622
|
+
"[dim](e.g. 'she just mentioned she was on the Anthropic team' or "
|
|
623
|
+
"'change of plan โ he wants to discuss architecture')[/]"
|
|
624
|
+
)
|
|
625
|
+
console.print("[dim] (empty line ends)[/]")
|
|
626
|
+
lines = []
|
|
627
|
+
while True:
|
|
628
|
+
try:
|
|
629
|
+
line = input(" โธ ")
|
|
630
|
+
except EOFError:
|
|
631
|
+
break
|
|
632
|
+
if line == "":
|
|
633
|
+
break
|
|
634
|
+
lines.append(line)
|
|
635
|
+
extra = "\n".join(lines).strip()
|
|
636
|
+
if not extra:
|
|
637
|
+
continue
|
|
638
|
+
# Append to system prompt so every future turn sees it.
|
|
639
|
+
addendum = f"\n\n[LIVE CONTEXT UPDATE โ added during turn {turn}]\n{extra}\n"
|
|
640
|
+
system_prompt = system_prompt + addendum
|
|
641
|
+
console.print("[green] โ Context injected into the coach's system prompt for this session.[/]\n")
|
|
642
|
+
continue
|
|
643
|
+
if user_input in ("ยง", "s") and plan and plan.get("sections"):
|
|
644
|
+
if current_section_idx < len(plan["sections"]):
|
|
645
|
+
current_section_idx += 1
|
|
646
|
+
if current_section_idx >= len(plan["sections"]):
|
|
647
|
+
console.print("[dim] Plan complete โ no more sections.[/]\n")
|
|
648
|
+
else:
|
|
649
|
+
s = plan["sections"][current_section_idx]
|
|
650
|
+
console.print(
|
|
651
|
+
f"[magenta] โ ยง{current_section_idx+1}: {s.get('title','')}[/]\n"
|
|
652
|
+
)
|
|
653
|
+
continue
|
|
654
|
+
if user_input == "n":
|
|
655
|
+
title = console.input(
|
|
656
|
+
f" New session title [dim](Enter for default)[/]: "
|
|
657
|
+
).strip() or _default_session_title(template_id)
|
|
658
|
+
session_id = sess.create_session(title, template_id, llm_provider, llm_model)
|
|
659
|
+
from sessions import _connect
|
|
660
|
+
with _connect() as conn:
|
|
661
|
+
conn.execute("UPDATE sessions SET profile_id = ?, template_id = ? WHERE id = ?",
|
|
662
|
+
(profile["id"], template_id, session_id))
|
|
663
|
+
history = []
|
|
664
|
+
turn = 1
|
|
665
|
+
console.print(f"[green] โ New session #{session_id}: {title}[/]\n")
|
|
666
|
+
continue
|
|
667
|
+
if user_input == "r":
|
|
668
|
+
new_title = console.input(" New title: ").strip()
|
|
669
|
+
if new_title:
|
|
670
|
+
sess.rename_session(session_id, new_title)
|
|
671
|
+
console.print(f"[green] โ Renamed to '{new_title}'[/]\n")
|
|
672
|
+
continue
|
|
673
|
+
if user_input == "y":
|
|
674
|
+
current_source, current_speaker = "mic", "you"
|
|
675
|
+
args.live = False
|
|
676
|
+
elif user_input == "i":
|
|
677
|
+
current_source, current_speaker = "system", "interviewer"
|
|
678
|
+
args.live = False
|
|
679
|
+
elif user_input == "l":
|
|
680
|
+
current_source, current_speaker = "system", "interviewer"
|
|
681
|
+
args.live = True
|
|
682
|
+
|
|
683
|
+
if args.live:
|
|
684
|
+
audio = record_until_silence(source=current_source, speaker=current_speaker,
|
|
685
|
+
silence_duration=args.silence)
|
|
686
|
+
else:
|
|
687
|
+
audio = record_until_keypress(source=current_source, speaker=current_speaker)
|
|
688
|
+
|
|
689
|
+
if len(audio) == 0:
|
|
690
|
+
console.print("[yellow] [No audio captured โ try again][/]")
|
|
691
|
+
continue
|
|
692
|
+
|
|
693
|
+
with console.status("[cyan] Transcribing...[/]", spinner="dots"):
|
|
694
|
+
try:
|
|
695
|
+
transcript = stt_transcribe(audio, provider=stt_provider, model=stt_model)
|
|
696
|
+
except Exception as e:
|
|
697
|
+
console.print(f"[red] [STT error: {e}][/]")
|
|
698
|
+
continue
|
|
699
|
+
|
|
700
|
+
if not transcript:
|
|
701
|
+
console.print("[yellow] [Could not transcribe โ nothing heard][/]")
|
|
702
|
+
continue
|
|
703
|
+
|
|
704
|
+
show_transcript(transcript, current_speaker, profile["name"], turn)
|
|
705
|
+
|
|
706
|
+
speaker_tag = {
|
|
707
|
+
"you": "[USER said]",
|
|
708
|
+
"interviewer": "[INTERVIEWER said]",
|
|
709
|
+
"unknown": "[HEARD]",
|
|
710
|
+
}[current_speaker]
|
|
711
|
+
|
|
712
|
+
# If a plan is active, include the current section marker so the LLM
|
|
713
|
+
# can situate its response within the agenda.
|
|
714
|
+
section_prefix = ""
|
|
715
|
+
if plan and plan.get("sections") and current_section_idx < len(plan["sections"]):
|
|
716
|
+
s = plan["sections"][current_section_idx]
|
|
717
|
+
section_prefix = (
|
|
718
|
+
f"[CURRENT SECTION ยง{current_section_idx+1}: {s.get('title','')}] "
|
|
719
|
+
)
|
|
720
|
+
tagged = f"{section_prefix}{speaker_tag} {transcript}"
|
|
721
|
+
|
|
722
|
+
with console.status("[cyan] Thinking...[/]", spinner="dots"):
|
|
723
|
+
reply = ""
|
|
724
|
+
routing_info = ""
|
|
725
|
+
try:
|
|
726
|
+
if args.no_graph:
|
|
727
|
+
reply = respond(tagged, history, system_prompt,
|
|
728
|
+
provider=llm_provider, model=llm_model)
|
|
729
|
+
else:
|
|
730
|
+
reply, final_state = coach_run_turn(
|
|
731
|
+
transcript=tagged,
|
|
732
|
+
speaker=current_speaker,
|
|
733
|
+
system_prompt=system_prompt,
|
|
734
|
+
history=history,
|
|
735
|
+
provider=llm_provider,
|
|
736
|
+
model=llm_model,
|
|
737
|
+
plan=plan,
|
|
738
|
+
briefing_present=bool(plan and (plan.get("briefing") or "").strip()),
|
|
739
|
+
)
|
|
740
|
+
tier = final_state.get("routing_decision", "default")
|
|
741
|
+
tt = final_state.get("turn_type", "?")
|
|
742
|
+
match = "matched" if final_state.get("matched_answer_text") else "fresh"
|
|
743
|
+
routing_info = (
|
|
744
|
+
f"[dim] โ turn={tt} ยท tier={tier} ยท {match}"
|
|
745
|
+
f"{' โ ' + final_state['routing_reason'] if final_state.get('routing_reason') else ''}[/]"
|
|
746
|
+
)
|
|
747
|
+
if final_state.get("error"):
|
|
748
|
+
console.print(f"[yellow] graph note: {final_state['error']}[/]")
|
|
749
|
+
except Exception as e:
|
|
750
|
+
console.print(f"[red] [Graph error: {e}] โ falling back to direct call[/]")
|
|
751
|
+
try:
|
|
752
|
+
reply = respond(tagged, history, system_prompt,
|
|
753
|
+
provider=llm_provider, model=llm_model)
|
|
754
|
+
except Exception as e2:
|
|
755
|
+
console.print(f"[red] [LLM error: {e2}][/]")
|
|
756
|
+
continue
|
|
757
|
+
|
|
758
|
+
if routing_info:
|
|
759
|
+
console.print(routing_info)
|
|
760
|
+
|
|
761
|
+
sess.append_message(session_id, "user", tagged, speaker=current_speaker)
|
|
762
|
+
sess.append_message(session_id, "assistant", reply)
|
|
763
|
+
|
|
764
|
+
# Keep in-memory history in sync so the next graph turn sees full context.
|
|
765
|
+
# (When using --no-graph, respond() already mutated history in place;
|
|
766
|
+
# skip in that case to avoid double-append.)
|
|
767
|
+
if not args.no_graph:
|
|
768
|
+
history.append({"role": "user", "content": tagged})
|
|
769
|
+
history.append({"role": "assistant", "content": reply})
|
|
770
|
+
|
|
771
|
+
show_reply(parse_reply(reply), turn)
|
|
772
|
+
turn += 1
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
if __name__ == "__main__":
|
|
776
|
+
main()
|