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
meeting_plans.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Meeting Plans — per-conversation game plans.
|
|
3
|
+
|
|
4
|
+
A MeetingPlan captures the shape of a specific upcoming conversation so the
|
|
5
|
+
coach can give structured, context-aware guidance during it. Think of it as
|
|
6
|
+
what a prep coach would write for you before a big call.
|
|
7
|
+
|
|
8
|
+
Fields mirror how you'd think about ANY high-stakes conversation:
|
|
9
|
+
* purpose — what you want out of this specific conversation
|
|
10
|
+
* counterparty — who you're talking with (name + role)
|
|
11
|
+
* duration_min — planned length, so section allocations make sense
|
|
12
|
+
* sections — ordered agenda blocks (title, minutes, goal)
|
|
13
|
+
* answers_prep — things you're prepared to be asked about
|
|
14
|
+
* questions_ask — questions you want to ask them
|
|
15
|
+
* traps_avoid — mistakes to preempt (e.g. "don't oversell the AI angle")
|
|
16
|
+
* commitments — concrete outcomes you want to leave with
|
|
17
|
+
|
|
18
|
+
Storage lives in the same SQLite DB as sessions/profiles.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import sqlite3
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from sessions import _connect
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ensure_schema():
|
|
31
|
+
with _connect() as conn:
|
|
32
|
+
conn.executescript(
|
|
33
|
+
"""
|
|
34
|
+
CREATE TABLE IF NOT EXISTS meeting_plans (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
profile_id INTEGER,
|
|
37
|
+
title TEXT NOT NULL,
|
|
38
|
+
counterparty TEXT,
|
|
39
|
+
purpose TEXT,
|
|
40
|
+
duration_min INTEGER,
|
|
41
|
+
sections_json TEXT NOT NULL DEFAULT '[]',
|
|
42
|
+
answers_json TEXT NOT NULL DEFAULT '[]',
|
|
43
|
+
questions_json TEXT NOT NULL DEFAULT '[]',
|
|
44
|
+
traps_json TEXT NOT NULL DEFAULT '[]',
|
|
45
|
+
commitments_json TEXT NOT NULL DEFAULT '[]',
|
|
46
|
+
notes TEXT DEFAULT '',
|
|
47
|
+
created_at TEXT NOT NULL,
|
|
48
|
+
updated_at TEXT NOT NULL
|
|
49
|
+
);
|
|
50
|
+
"""
|
|
51
|
+
)
|
|
52
|
+
# Add plan_id to sessions so a session can be linked to a plan
|
|
53
|
+
try:
|
|
54
|
+
conn.execute("ALTER TABLE sessions ADD COLUMN plan_id INTEGER")
|
|
55
|
+
except sqlite3.OperationalError:
|
|
56
|
+
pass
|
|
57
|
+
# Add briefing + raw research to plans (migration for older DBs)
|
|
58
|
+
for col in ("briefing TEXT DEFAULT ''", "raw_research_json TEXT DEFAULT ''"):
|
|
59
|
+
try:
|
|
60
|
+
conn.execute(f"ALTER TABLE meeting_plans ADD COLUMN {col}")
|
|
61
|
+
except sqlite3.OperationalError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_ensure_schema()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _now() -> str:
|
|
69
|
+
return datetime.utcnow().isoformat(timespec="seconds")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_plan(
|
|
73
|
+
title: str,
|
|
74
|
+
profile_id: Optional[int] = None,
|
|
75
|
+
counterparty: str = "",
|
|
76
|
+
purpose: str = "",
|
|
77
|
+
duration_min: int = 45,
|
|
78
|
+
sections: Optional[list[dict]] = None,
|
|
79
|
+
answers_prep: Optional[list[str]] = None,
|
|
80
|
+
questions_ask: Optional[list[str]] = None,
|
|
81
|
+
traps_avoid: Optional[list[str]] = None,
|
|
82
|
+
commitments: Optional[list[str]] = None,
|
|
83
|
+
notes: str = "",
|
|
84
|
+
) -> int:
|
|
85
|
+
now = _now()
|
|
86
|
+
with _connect() as conn:
|
|
87
|
+
cur = conn.execute(
|
|
88
|
+
"""
|
|
89
|
+
INSERT INTO meeting_plans (
|
|
90
|
+
profile_id, title, counterparty, purpose, duration_min,
|
|
91
|
+
sections_json, answers_json, questions_json, traps_json,
|
|
92
|
+
commitments_json, notes, created_at, updated_at
|
|
93
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
94
|
+
""",
|
|
95
|
+
(
|
|
96
|
+
profile_id, title, counterparty, purpose, duration_min,
|
|
97
|
+
json.dumps(sections or []),
|
|
98
|
+
json.dumps(answers_prep or []),
|
|
99
|
+
json.dumps(questions_ask or []),
|
|
100
|
+
json.dumps(traps_avoid or []),
|
|
101
|
+
json.dumps(commitments or []),
|
|
102
|
+
notes, now, now,
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
return cur.lastrowid
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def list_plans(profile_id: Optional[int] = None, limit: int = 30) -> list[sqlite3.Row]:
|
|
109
|
+
with _connect() as conn:
|
|
110
|
+
if profile_id is not None:
|
|
111
|
+
cur = conn.execute(
|
|
112
|
+
"SELECT * FROM meeting_plans WHERE profile_id = ? "
|
|
113
|
+
"ORDER BY updated_at DESC LIMIT ?",
|
|
114
|
+
(profile_id, limit),
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
cur = conn.execute(
|
|
118
|
+
"SELECT * FROM meeting_plans ORDER BY updated_at DESC LIMIT ?",
|
|
119
|
+
(limit,),
|
|
120
|
+
)
|
|
121
|
+
return cur.fetchall()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_plan(plan_id: int) -> Optional[dict]:
|
|
125
|
+
with _connect() as conn:
|
|
126
|
+
cur = conn.execute("SELECT * FROM meeting_plans WHERE id = ?", (plan_id,))
|
|
127
|
+
row = cur.fetchone()
|
|
128
|
+
if not row:
|
|
129
|
+
return None
|
|
130
|
+
d = dict(row)
|
|
131
|
+
for k in ("sections", "answers", "questions", "traps", "commitments"):
|
|
132
|
+
d[k if k == "sections" else f"{k}_prep" if k == "answers" else
|
|
133
|
+
f"{k}_ask" if k == "questions" else
|
|
134
|
+
f"{k}_avoid" if k == "traps" else
|
|
135
|
+
k] = json.loads(d.pop(f"{k}_json", "[]"))
|
|
136
|
+
# Clean up mapping
|
|
137
|
+
d.setdefault("answers_prep", d.pop("answers", []) if "answers" in d else [])
|
|
138
|
+
d.setdefault("questions_ask", d.pop("questions", []) if "questions" in d else [])
|
|
139
|
+
d.setdefault("traps_avoid", d.pop("traps", []) if "traps" in d else [])
|
|
140
|
+
return d
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def update_plan(plan_id: int, **fields):
|
|
144
|
+
if not fields:
|
|
145
|
+
return
|
|
146
|
+
now = _now()
|
|
147
|
+
list_fields = {
|
|
148
|
+
"sections": "sections_json",
|
|
149
|
+
"answers_prep": "answers_json",
|
|
150
|
+
"questions_ask": "questions_json",
|
|
151
|
+
"traps_avoid": "traps_json",
|
|
152
|
+
"commitments": "commitments_json",
|
|
153
|
+
}
|
|
154
|
+
sets = []
|
|
155
|
+
vals: list = []
|
|
156
|
+
for k, v in fields.items():
|
|
157
|
+
if k in list_fields:
|
|
158
|
+
sets.append(f"{list_fields[k]} = ?")
|
|
159
|
+
vals.append(json.dumps(v))
|
|
160
|
+
else:
|
|
161
|
+
sets.append(f"{k} = ?")
|
|
162
|
+
vals.append(v)
|
|
163
|
+
sets.append("updated_at = ?")
|
|
164
|
+
vals.append(now)
|
|
165
|
+
vals.append(plan_id)
|
|
166
|
+
with _connect() as conn:
|
|
167
|
+
conn.execute(f"UPDATE meeting_plans SET {', '.join(sets)} WHERE id = ?", vals)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def delete_plan(plan_id: int):
|
|
171
|
+
with _connect() as conn:
|
|
172
|
+
conn.execute("DELETE FROM meeting_plans WHERE id = ?", (plan_id,))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def render_plan_for_prompt(plan: dict) -> str:
|
|
176
|
+
"""Format a plan for injection into the LLM system prompt."""
|
|
177
|
+
lines = [
|
|
178
|
+
f"MEETING PLAN — {plan.get('title', '(untitled)')}",
|
|
179
|
+
f"Counterparty: {plan.get('counterparty') or '(not specified)'}",
|
|
180
|
+
f"Purpose: {plan.get('purpose') or '(not specified)'}",
|
|
181
|
+
f"Planned duration: {plan.get('duration_min') or '?'} minutes",
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
sections = plan.get("sections", []) or []
|
|
185
|
+
if sections:
|
|
186
|
+
lines.append("\nAGENDA")
|
|
187
|
+
for i, s in enumerate(sections, 1):
|
|
188
|
+
title = s.get("title", "(untitled)")
|
|
189
|
+
minutes = s.get("minutes", "?")
|
|
190
|
+
goal = s.get("goal", "")
|
|
191
|
+
lines.append(f" {i}. [{minutes} min] {title}")
|
|
192
|
+
if goal:
|
|
193
|
+
lines.append(f" Goal: {goal}")
|
|
194
|
+
|
|
195
|
+
for label, key in [
|
|
196
|
+
("ANSWERS TO PREPARE", "answers_prep"),
|
|
197
|
+
("QUESTIONS TO ASK THEM", "questions_ask"),
|
|
198
|
+
("TRAPS TO AVOID", "traps_avoid"),
|
|
199
|
+
("COMMITMENTS TO SECURE", "commitments"),
|
|
200
|
+
]:
|
|
201
|
+
items = plan.get(key, []) or []
|
|
202
|
+
if items:
|
|
203
|
+
lines.append(f"\n{label}")
|
|
204
|
+
for it in items:
|
|
205
|
+
lines.append(f" • {it}")
|
|
206
|
+
|
|
207
|
+
notes = plan.get("notes", "").strip()
|
|
208
|
+
if notes:
|
|
209
|
+
lines.append(f"\nNOTES\n{notes}")
|
|
210
|
+
|
|
211
|
+
briefing = (plan.get("briefing") or "").strip()
|
|
212
|
+
if briefing:
|
|
213
|
+
lines.append("\n" + "─" * 60)
|
|
214
|
+
lines.append("COUNTERPARTY BRIEFING (from research):")
|
|
215
|
+
lines.append("─" * 60)
|
|
216
|
+
lines.append(briefing)
|
|
217
|
+
|
|
218
|
+
return "\n".join(lines)
|
onboarding.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Onboarding wizard: profile creation, template selection, profile management.
|
|
3
|
+
|
|
4
|
+
Runs automatically when no profile exists; can be re-run with --onboard.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
import profile_store
|
|
14
|
+
from templates import list_template_choices
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _multiline_input(prompt: str, placeholder: str = "") -> str:
|
|
20
|
+
"""Read multi-line input until an empty line."""
|
|
21
|
+
console.print(f"[cyan]{prompt}[/]")
|
|
22
|
+
if placeholder:
|
|
23
|
+
console.print(f"[dim] Example: {placeholder}[/]")
|
|
24
|
+
console.print("[dim] (Blank line ends input, Enter to skip)[/]")
|
|
25
|
+
lines = []
|
|
26
|
+
while True:
|
|
27
|
+
try:
|
|
28
|
+
line = input(" ▸ ")
|
|
29
|
+
except EOFError:
|
|
30
|
+
break
|
|
31
|
+
if line == "":
|
|
32
|
+
break
|
|
33
|
+
lines.append(line)
|
|
34
|
+
return "\n".join(lines)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_profile_wizard() -> int:
|
|
38
|
+
console.print(
|
|
39
|
+
Panel(
|
|
40
|
+
Text("Let's build your profile — this is what tailors every answer.",
|
|
41
|
+
style="bold white"),
|
|
42
|
+
title="[bold bright_green]👤 New profile[/]",
|
|
43
|
+
border_style="bright_green",
|
|
44
|
+
padding=(1, 2),
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
name = ""
|
|
49
|
+
while not name:
|
|
50
|
+
name = console.input("[cyan] Your name[/]: ").strip()
|
|
51
|
+
pronouns = console.input("[cyan] Pronouns[/] [dim](she/her, he/him, they/them)[/]: ").strip() or "they/them"
|
|
52
|
+
|
|
53
|
+
console.print()
|
|
54
|
+
background = _multiline_input(
|
|
55
|
+
"Your background",
|
|
56
|
+
"AI/software engineer with a BSc in CS from KNUST. Have stuttered since childhood.",
|
|
57
|
+
)
|
|
58
|
+
console.print()
|
|
59
|
+
target_role = console.input(
|
|
60
|
+
"[cyan] What role or opportunity are you preparing for?[/]\n ▸ "
|
|
61
|
+
).strip()
|
|
62
|
+
console.print()
|
|
63
|
+
strengths = _multiline_input(
|
|
64
|
+
"Strengths to lean on (things you want the coach to surface)",
|
|
65
|
+
"Cross-domain thinking; lived experience of stuttering; ML systems.",
|
|
66
|
+
)
|
|
67
|
+
console.print()
|
|
68
|
+
weaknesses = _multiline_input(
|
|
69
|
+
"Weaknesses to defuse (things to preempt without dwelling on)",
|
|
70
|
+
"No formal neuroscience training yet; first-time PhD applicant.",
|
|
71
|
+
)
|
|
72
|
+
console.print()
|
|
73
|
+
extra = _multiline_input(
|
|
74
|
+
"Anything else the coach should know (upcoming calls, key people, quirks)?",
|
|
75
|
+
"Call with Dr. Toyomura late Sep; Dr. Höbler Nov 6.",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
pid = profile_store.create_profile(
|
|
79
|
+
name=name,
|
|
80
|
+
pronouns=pronouns,
|
|
81
|
+
background=background,
|
|
82
|
+
target_role=target_role,
|
|
83
|
+
strengths=strengths,
|
|
84
|
+
weaknesses=weaknesses,
|
|
85
|
+
extra_context=extra,
|
|
86
|
+
)
|
|
87
|
+
profile_store.set_active_profile(pid)
|
|
88
|
+
console.print(f"\n[green] ✓ Profile #{pid} created and set active.[/]\n")
|
|
89
|
+
return pid
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def pick_or_create_profile() -> int:
|
|
93
|
+
"""If any profiles exist, let user pick/create. Otherwise run first-time wizard."""
|
|
94
|
+
profiles = profile_store.list_profiles()
|
|
95
|
+
|
|
96
|
+
if not profiles:
|
|
97
|
+
console.print(
|
|
98
|
+
Panel(
|
|
99
|
+
Text("First time here? Let's set up your profile.",
|
|
100
|
+
style="bold white"),
|
|
101
|
+
title="[bold bright_cyan]Welcome[/]",
|
|
102
|
+
border_style="bright_cyan",
|
|
103
|
+
padding=(1, 2),
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return create_profile_wizard()
|
|
107
|
+
|
|
108
|
+
table = Table(title="Your profiles", border_style="dim")
|
|
109
|
+
table.add_column("#", style="bold cyan", width=3)
|
|
110
|
+
table.add_column("Name", style="white")
|
|
111
|
+
table.add_column("Target", style="yellow")
|
|
112
|
+
table.add_column("Active", style="green")
|
|
113
|
+
for i, p in enumerate(profiles, 1):
|
|
114
|
+
active_mark = "✓" if p["is_active"] else ""
|
|
115
|
+
table.add_row(str(i), p["name"], p["target_role"] or "-", active_mark)
|
|
116
|
+
console.print(table)
|
|
117
|
+
|
|
118
|
+
while True:
|
|
119
|
+
raw = console.input(
|
|
120
|
+
"\n[bold]Pick profile[/]: [cyan]<number>[/] "
|
|
121
|
+
"[bold green]n[/]=new [bold red]d<number>[/]=delete "
|
|
122
|
+
"[dim](Enter=keep active)[/] ▸ "
|
|
123
|
+
).strip().lower()
|
|
124
|
+
|
|
125
|
+
active = profile_store.get_active_profile()
|
|
126
|
+
if raw == "" and active:
|
|
127
|
+
return active["id"]
|
|
128
|
+
if raw == "n":
|
|
129
|
+
return create_profile_wizard()
|
|
130
|
+
if raw.startswith("d") and raw[1:].isdigit():
|
|
131
|
+
idx = int(raw[1:]) - 1
|
|
132
|
+
if 0 <= idx < len(profiles):
|
|
133
|
+
profile_store.delete_profile(profiles[idx]["id"])
|
|
134
|
+
console.print(f"[red] ✗ Deleted profile '{profiles[idx]['name']}'[/]\n")
|
|
135
|
+
return pick_or_create_profile()
|
|
136
|
+
if raw.isdigit():
|
|
137
|
+
idx = int(raw) - 1
|
|
138
|
+
if 0 <= idx < len(profiles):
|
|
139
|
+
pid = profiles[idx]["id"]
|
|
140
|
+
profile_store.set_active_profile(pid)
|
|
141
|
+
return pid
|
|
142
|
+
console.print("[yellow] Invalid choice.[/]")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def pick_template(default: str = "academic-phd") -> str:
|
|
146
|
+
choices = list_template_choices()
|
|
147
|
+
|
|
148
|
+
table = Table(title="Interview / conversation templates", border_style="dim")
|
|
149
|
+
table.add_column("#", style="bold cyan", width=3)
|
|
150
|
+
table.add_column("Template", style="white")
|
|
151
|
+
table.add_column("ID", style="dim")
|
|
152
|
+
for i, (tid, label) in enumerate(choices, 1):
|
|
153
|
+
marker = " ← default" if tid == default else ""
|
|
154
|
+
table.add_row(str(i), label + marker, tid)
|
|
155
|
+
console.print(table)
|
|
156
|
+
|
|
157
|
+
while True:
|
|
158
|
+
raw = console.input(
|
|
159
|
+
f"\n[bold]Pick template (1-{len(choices)}, Enter for default)[/] ▸ "
|
|
160
|
+
).strip()
|
|
161
|
+
if raw == "":
|
|
162
|
+
return default
|
|
163
|
+
if raw.isdigit() and 1 <= int(raw) <= len(choices):
|
|
164
|
+
return choices[int(raw) - 1][0]
|
|
165
|
+
console.print("[yellow] Invalid choice.[/]")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def edit_profile_wizard(profile_id: int):
|
|
169
|
+
p = profile_store.get_profile(profile_id)
|
|
170
|
+
if not p:
|
|
171
|
+
console.print("[red] Profile not found.[/]")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
console.print(f"\n[bold]Editing profile:[/] {p['name']}\n")
|
|
175
|
+
fields = ["name", "pronouns", "background", "target_role",
|
|
176
|
+
"strengths", "weaknesses", "extra_context"]
|
|
177
|
+
updates = {}
|
|
178
|
+
for f in fields:
|
|
179
|
+
current = p[f] or ""
|
|
180
|
+
preview = (current[:60] + "…") if len(current) > 60 else current
|
|
181
|
+
console.print(f"[dim] {f}: {preview}[/]")
|
|
182
|
+
new = console.input(f" [cyan]{f}[/] [dim](Enter to keep)[/] ▸ ").strip()
|
|
183
|
+
if new:
|
|
184
|
+
updates[f] = new
|
|
185
|
+
if updates:
|
|
186
|
+
profile_store.update_profile(profile_id, **updates)
|
|
187
|
+
console.print(f"\n[green] ✓ Updated {len(updates)} field(s).[/]\n")
|
|
188
|
+
else:
|
|
189
|
+
console.print("\n[dim] No changes.[/]\n")
|
plan_wizard.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive wizard to build a MeetingPlan.
|
|
3
|
+
|
|
4
|
+
Two paths:
|
|
5
|
+
* `create_plan_wizard()` — fully interactive, asks every field
|
|
6
|
+
* `create_plan_from_template(name, profile_id)` — preload common shapes
|
|
7
|
+
|
|
8
|
+
The templates encode structures like the one drafted for the Eric Jackson call
|
|
9
|
+
so users can start from a working plan and just fill in specifics.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
import meeting_plans
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _multiline(label: str, placeholder: str = "") -> str:
|
|
24
|
+
console.print(f"[cyan] {label}[/]")
|
|
25
|
+
if placeholder:
|
|
26
|
+
console.print(f"[dim] e.g. {placeholder}[/]")
|
|
27
|
+
console.print("[dim] (empty line ends)[/]")
|
|
28
|
+
lines = []
|
|
29
|
+
while True:
|
|
30
|
+
try:
|
|
31
|
+
line = input(" ▸ ")
|
|
32
|
+
except EOFError:
|
|
33
|
+
break
|
|
34
|
+
if line == "":
|
|
35
|
+
break
|
|
36
|
+
lines.append(line)
|
|
37
|
+
return "\n".join(lines)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _list_input(label: str, placeholder: str = "") -> list[str]:
|
|
41
|
+
console.print(f"[cyan] {label}[/]")
|
|
42
|
+
if placeholder:
|
|
43
|
+
console.print(f"[dim] e.g. {placeholder}[/]")
|
|
44
|
+
console.print("[dim] (one per line; empty line ends)[/]")
|
|
45
|
+
items = []
|
|
46
|
+
while True:
|
|
47
|
+
try:
|
|
48
|
+
line = input(f" {len(items)+1}. ").strip()
|
|
49
|
+
except EOFError:
|
|
50
|
+
break
|
|
51
|
+
if not line:
|
|
52
|
+
break
|
|
53
|
+
items.append(line)
|
|
54
|
+
return items
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _sections_input() -> list[dict]:
|
|
58
|
+
console.print("[cyan] Agenda sections[/] [dim](title, minutes, goal — empty title to finish)[/]")
|
|
59
|
+
sections = []
|
|
60
|
+
while True:
|
|
61
|
+
try:
|
|
62
|
+
title = input(f" Section {len(sections)+1} title ▸ ").strip()
|
|
63
|
+
except EOFError:
|
|
64
|
+
break
|
|
65
|
+
if not title:
|
|
66
|
+
break
|
|
67
|
+
try:
|
|
68
|
+
minutes_raw = input(f" minutes ▸ ").strip()
|
|
69
|
+
minutes = int(minutes_raw) if minutes_raw.isdigit() else 5
|
|
70
|
+
goal = input(f" goal ▸ ").strip()
|
|
71
|
+
except EOFError:
|
|
72
|
+
break
|
|
73
|
+
sections.append({"title": title, "minutes": minutes, "goal": goal})
|
|
74
|
+
return sections
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_plan_wizard(profile_id: int | None = None) -> int:
|
|
78
|
+
console.print(Panel(
|
|
79
|
+
Text("Meeting plan — set the shape of the conversation so the coach can guide you live.",
|
|
80
|
+
style="bold white"),
|
|
81
|
+
title="[bold bright_cyan]📋 New meeting plan[/]",
|
|
82
|
+
border_style="bright_cyan",
|
|
83
|
+
padding=(1, 2),
|
|
84
|
+
))
|
|
85
|
+
|
|
86
|
+
title = ""
|
|
87
|
+
while not title:
|
|
88
|
+
title = console.input("[cyan] Plan title[/] [dim](e.g. 'Call with Dr. Eric Jackson — NYU')[/]: ").strip()
|
|
89
|
+
counterparty = console.input("[cyan] Counterparty[/] [dim](name + role)[/]: ").strip()
|
|
90
|
+
purpose = console.input("[cyan] Purpose of this conversation[/]:\n ▸ ").strip()
|
|
91
|
+
duration_raw = console.input("[cyan] Planned duration in minutes[/] [dim](Enter for 45)[/]: ").strip()
|
|
92
|
+
duration_min = int(duration_raw) if duration_raw.isdigit() else 45
|
|
93
|
+
|
|
94
|
+
console.print()
|
|
95
|
+
sections = _sections_input()
|
|
96
|
+
console.print()
|
|
97
|
+
answers_prep = _list_input("Things you should be ready to ANSWER",
|
|
98
|
+
"Why research and not just personal? / How your AI background fits")
|
|
99
|
+
console.print()
|
|
100
|
+
questions_ask = _list_input("Questions you want to ASK them",
|
|
101
|
+
"Are you taking students for 2027? / Who else would you point me to?")
|
|
102
|
+
console.print()
|
|
103
|
+
traps_avoid = _list_input("Traps to avoid",
|
|
104
|
+
"Don't oversell AI angle / Don't dwell on personal story")
|
|
105
|
+
console.print()
|
|
106
|
+
commitments = _list_input("Concrete commitments to leave with",
|
|
107
|
+
"Follow-up email in 2 weeks / Reading list from them / Next call date")
|
|
108
|
+
console.print()
|
|
109
|
+
notes = _multiline("Any other notes",
|
|
110
|
+
"'She emphasises variability in her 2021 paper'")
|
|
111
|
+
|
|
112
|
+
pid = meeting_plans.create_plan(
|
|
113
|
+
title=title,
|
|
114
|
+
profile_id=profile_id,
|
|
115
|
+
counterparty=counterparty,
|
|
116
|
+
purpose=purpose,
|
|
117
|
+
duration_min=duration_min,
|
|
118
|
+
sections=sections,
|
|
119
|
+
answers_prep=answers_prep,
|
|
120
|
+
questions_ask=questions_ask,
|
|
121
|
+
traps_avoid=traps_avoid,
|
|
122
|
+
commitments=commitments,
|
|
123
|
+
notes=notes,
|
|
124
|
+
)
|
|
125
|
+
console.print(f"\n[green] ✓ Plan #{pid} saved.[/]\n")
|
|
126
|
+
return pid
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def pick_plan(profile_id: int | None = None) -> int | None:
|
|
130
|
+
"""Show a picker. Returns plan_id, or None if user picked 'no plan'."""
|
|
131
|
+
rows = meeting_plans.list_plans(profile_id=profile_id, limit=20)
|
|
132
|
+
if not rows:
|
|
133
|
+
console.print("[dim] No meeting plans saved.[/]")
|
|
134
|
+
raw = console.input(
|
|
135
|
+
"[bold]Create one now?[/] [green]y[/]=create [dim]Enter[/]=skip ▸ "
|
|
136
|
+
).strip().lower()
|
|
137
|
+
if raw == "y":
|
|
138
|
+
return create_plan_wizard(profile_id=profile_id)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
table = Table(title="Meeting plans", border_style="dim")
|
|
142
|
+
table.add_column("#", style="bold cyan", width=3)
|
|
143
|
+
table.add_column("Title", style="white")
|
|
144
|
+
table.add_column("Counterparty", style="yellow")
|
|
145
|
+
table.add_column("Sections", style="green", justify="right")
|
|
146
|
+
table.add_column("Updated", style="dim")
|
|
147
|
+
for i, r in enumerate(rows, 1):
|
|
148
|
+
import json
|
|
149
|
+
n_sections = len(json.loads(r["sections_json"] or "[]"))
|
|
150
|
+
table.add_row(
|
|
151
|
+
str(i), r["title"], r["counterparty"] or "-",
|
|
152
|
+
str(n_sections), r["updated_at"].replace("T", " "),
|
|
153
|
+
)
|
|
154
|
+
console.print(table)
|
|
155
|
+
|
|
156
|
+
while True:
|
|
157
|
+
raw = console.input(
|
|
158
|
+
"\n[bold]Pick plan[/]: [cyan]<n>[/]=use [bold green]n[/]=new "
|
|
159
|
+
"[bold red]d<n>[/]=delete [dim]Enter[/]=no plan ▸ "
|
|
160
|
+
).strip().lower()
|
|
161
|
+
if raw == "":
|
|
162
|
+
return None
|
|
163
|
+
if raw == "n":
|
|
164
|
+
return create_plan_wizard(profile_id=profile_id)
|
|
165
|
+
if raw.startswith("d") and raw[1:].isdigit():
|
|
166
|
+
idx = int(raw[1:]) - 1
|
|
167
|
+
if 0 <= idx < len(rows):
|
|
168
|
+
meeting_plans.delete_plan(rows[idx]["id"])
|
|
169
|
+
console.print(f"[red] ✗ Deleted '{rows[idx]['title']}'[/]")
|
|
170
|
+
return pick_plan(profile_id=profile_id)
|
|
171
|
+
if raw.isdigit():
|
|
172
|
+
idx = int(raw) - 1
|
|
173
|
+
if 0 <= idx < len(rows):
|
|
174
|
+
return rows[idx]["id"]
|
|
175
|
+
console.print("[yellow] Invalid choice.[/]")
|
profile_store.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
User profile storage — one profile per user, stored in the sessions DB.
|
|
3
|
+
|
|
4
|
+
A profile is the personal context the LLM needs to give tailored answers:
|
|
5
|
+
name, background, target role, key strengths, weaknesses to defuse, and
|
|
6
|
+
free-form context. No hardcoded names anywhere in the codebase.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from sessions import _connect
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ensure_schema():
|
|
18
|
+
with _connect() as conn:
|
|
19
|
+
conn.executescript(
|
|
20
|
+
"""
|
|
21
|
+
CREATE TABLE IF NOT EXISTS profiles (
|
|
22
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
+
name TEXT NOT NULL,
|
|
24
|
+
pronouns TEXT,
|
|
25
|
+
background TEXT,
|
|
26
|
+
target_role TEXT,
|
|
27
|
+
strengths TEXT,
|
|
28
|
+
weaknesses TEXT,
|
|
29
|
+
extra_context TEXT,
|
|
30
|
+
is_active INTEGER NOT NULL DEFAULT 0,
|
|
31
|
+
created_at TEXT NOT NULL,
|
|
32
|
+
updated_at TEXT NOT NULL
|
|
33
|
+
);
|
|
34
|
+
"""
|
|
35
|
+
)
|
|
36
|
+
# Add profile_id column to sessions (migration for older DBs)
|
|
37
|
+
try:
|
|
38
|
+
conn.execute("ALTER TABLE sessions ADD COLUMN profile_id INTEGER")
|
|
39
|
+
except sqlite3.OperationalError:
|
|
40
|
+
pass
|
|
41
|
+
try:
|
|
42
|
+
conn.execute("ALTER TABLE sessions ADD COLUMN template_id TEXT")
|
|
43
|
+
except sqlite3.OperationalError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_ensure_schema()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def create_profile(name: str, **fields) -> int:
|
|
51
|
+
now = datetime.utcnow().isoformat(timespec="seconds")
|
|
52
|
+
cols = ["name", "pronouns", "background", "target_role",
|
|
53
|
+
"strengths", "weaknesses", "extra_context", "is_active",
|
|
54
|
+
"created_at", "updated_at"]
|
|
55
|
+
vals = [
|
|
56
|
+
name,
|
|
57
|
+
fields.get("pronouns", ""),
|
|
58
|
+
fields.get("background", ""),
|
|
59
|
+
fields.get("target_role", ""),
|
|
60
|
+
fields.get("strengths", ""),
|
|
61
|
+
fields.get("weaknesses", ""),
|
|
62
|
+
fields.get("extra_context", ""),
|
|
63
|
+
0,
|
|
64
|
+
now, now,
|
|
65
|
+
]
|
|
66
|
+
with _connect() as conn:
|
|
67
|
+
cur = conn.execute(
|
|
68
|
+
f"INSERT INTO profiles ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})",
|
|
69
|
+
vals,
|
|
70
|
+
)
|
|
71
|
+
pid = cur.lastrowid
|
|
72
|
+
# If no active profile exists, make this the active one
|
|
73
|
+
if get_active_profile() is None:
|
|
74
|
+
set_active_profile(pid)
|
|
75
|
+
return pid
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def list_profiles() -> list[sqlite3.Row]:
|
|
79
|
+
with _connect() as conn:
|
|
80
|
+
cur = conn.execute(
|
|
81
|
+
"SELECT * FROM profiles ORDER BY is_active DESC, updated_at DESC"
|
|
82
|
+
)
|
|
83
|
+
return cur.fetchall()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_profile(profile_id: int) -> Optional[sqlite3.Row]:
|
|
87
|
+
with _connect() as conn:
|
|
88
|
+
cur = conn.execute("SELECT * FROM profiles WHERE id = ?", (profile_id,))
|
|
89
|
+
return cur.fetchone()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_active_profile() -> Optional[sqlite3.Row]:
|
|
93
|
+
with _connect() as conn:
|
|
94
|
+
cur = conn.execute("SELECT * FROM profiles WHERE is_active = 1 LIMIT 1")
|
|
95
|
+
return cur.fetchone()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def set_active_profile(profile_id: int):
|
|
99
|
+
with _connect() as conn:
|
|
100
|
+
conn.execute("UPDATE profiles SET is_active = 0")
|
|
101
|
+
conn.execute("UPDATE profiles SET is_active = 1 WHERE id = ?", (profile_id,))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def update_profile(profile_id: int, **fields):
|
|
105
|
+
if not fields:
|
|
106
|
+
return
|
|
107
|
+
now = datetime.utcnow().isoformat(timespec="seconds")
|
|
108
|
+
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
|
109
|
+
with _connect() as conn:
|
|
110
|
+
conn.execute(
|
|
111
|
+
f"UPDATE profiles SET {set_clause}, updated_at = ? WHERE id = ?",
|
|
112
|
+
[*fields.values(), now, profile_id],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def delete_profile(profile_id: int):
|
|
117
|
+
with _connect() as conn:
|
|
118
|
+
conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,))
|