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