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