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
research.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""
|
|
2
|
+
General-purpose research module.
|
|
3
|
+
|
|
4
|
+
Given a counterparty (a name + optional affiliation), assembles a briefing the
|
|
5
|
+
coach can use during any conversation — job interview, academic call, sales
|
|
6
|
+
discovery, medical consult, whatever.
|
|
7
|
+
|
|
8
|
+
Sources (all optional — each contributes what it can, skips gracefully):
|
|
9
|
+
* Web search (Tavily API) — general web results, news, blog posts, talks
|
|
10
|
+
* Semantic Scholar (free API) — academic papers if the person publishes
|
|
11
|
+
* GitHub REST API (free) — open-source presence if the person builds software
|
|
12
|
+
|
|
13
|
+
The raw findings then get run through the user's chosen LLM to produce a
|
|
14
|
+
condensed "briefing" grouped by domain (background, recent work, tone/style,
|
|
15
|
+
things worth knowing). Findings and briefing are attached to a MeetingPlan so
|
|
16
|
+
the coach can reference them during the live conversation.
|
|
17
|
+
|
|
18
|
+
No source is hardcoded. If you're prepping for a sales call, the academic
|
|
19
|
+
lookup will just return empty and the web search carries the load. If you're
|
|
20
|
+
prepping for a coding interview, the GitHub lookup carries it. The synthesis
|
|
21
|
+
step handles whichever mix comes back.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from dataclasses import dataclass, field, asdict
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
import requests
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
TAVILY_URL = "https://api.tavily.com/search"
|
|
34
|
+
SEMANTIC_SCHOLAR_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
|
|
35
|
+
GITHUB_SEARCH_URL = "https://api.github.com/search/users"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ResearchFinding:
|
|
40
|
+
"""A single piece of retrieved info."""
|
|
41
|
+
source: str # "web" | "semantic-scholar" | "github"
|
|
42
|
+
title: str
|
|
43
|
+
url: str
|
|
44
|
+
snippet: str
|
|
45
|
+
year: Optional[int] = None
|
|
46
|
+
extra: dict = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict:
|
|
49
|
+
return asdict(self)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ResearchResult:
|
|
54
|
+
"""Everything gathered about a counterparty."""
|
|
55
|
+
query_name: str
|
|
56
|
+
affiliation: str
|
|
57
|
+
findings: list[ResearchFinding]
|
|
58
|
+
errors: list[str] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict:
|
|
61
|
+
return {
|
|
62
|
+
"query_name": self.query_name,
|
|
63
|
+
"affiliation": self.affiliation,
|
|
64
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
65
|
+
"errors": self.errors,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def by_source(self, source: str) -> list[ResearchFinding]:
|
|
69
|
+
return [f for f in self.findings if f.source == source]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ─── Tavily web search ────────────────────────────────────────────────────
|
|
73
|
+
def _search_tavily(query: str, max_results: int = 8) -> tuple[list[ResearchFinding], Optional[str]]:
|
|
74
|
+
api_key = os.environ.get("TAVILY_API_KEY")
|
|
75
|
+
if not api_key:
|
|
76
|
+
return [], "TAVILY_API_KEY not set — skipping web search"
|
|
77
|
+
try:
|
|
78
|
+
resp = requests.post(
|
|
79
|
+
TAVILY_URL,
|
|
80
|
+
json={
|
|
81
|
+
"api_key": api_key,
|
|
82
|
+
"query": query,
|
|
83
|
+
"search_depth": "advanced",
|
|
84
|
+
"max_results": max_results,
|
|
85
|
+
"include_answer": False,
|
|
86
|
+
},
|
|
87
|
+
timeout=30,
|
|
88
|
+
)
|
|
89
|
+
resp.raise_for_status()
|
|
90
|
+
except Exception as e:
|
|
91
|
+
return [], f"Tavily error: {e}"
|
|
92
|
+
|
|
93
|
+
data = resp.json()
|
|
94
|
+
out: list[ResearchFinding] = []
|
|
95
|
+
for r in data.get("results", []):
|
|
96
|
+
out.append(ResearchFinding(
|
|
97
|
+
source="web",
|
|
98
|
+
title=r.get("title", "(no title)"),
|
|
99
|
+
url=r.get("url", ""),
|
|
100
|
+
snippet=(r.get("content") or "")[:800],
|
|
101
|
+
))
|
|
102
|
+
return out, None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ─── Semantic Scholar (papers) ────────────────────────────────────────────
|
|
106
|
+
def _search_semantic_scholar(query: str, limit: int = 6) -> tuple[list[ResearchFinding], Optional[str]]:
|
|
107
|
+
try:
|
|
108
|
+
resp = requests.get(
|
|
109
|
+
SEMANTIC_SCHOLAR_URL,
|
|
110
|
+
params={
|
|
111
|
+
"query": query,
|
|
112
|
+
"limit": limit,
|
|
113
|
+
"fields": "title,abstract,year,authors,externalIds,url",
|
|
114
|
+
},
|
|
115
|
+
timeout=30,
|
|
116
|
+
)
|
|
117
|
+
resp.raise_for_status()
|
|
118
|
+
except Exception as e:
|
|
119
|
+
return [], f"Semantic Scholar error: {e}"
|
|
120
|
+
|
|
121
|
+
data = resp.json()
|
|
122
|
+
out: list[ResearchFinding] = []
|
|
123
|
+
for p in data.get("data", []):
|
|
124
|
+
title = p.get("title", "(no title)")
|
|
125
|
+
year = p.get("year")
|
|
126
|
+
abstract = (p.get("abstract") or "")[:800]
|
|
127
|
+
url = p.get("url") or ""
|
|
128
|
+
authors_raw = p.get("authors") or []
|
|
129
|
+
author_names = ", ".join(a.get("name", "") for a in authors_raw[:5])
|
|
130
|
+
snippet = f"Authors: {author_names}\n{abstract}" if abstract else f"Authors: {author_names}"
|
|
131
|
+
out.append(ResearchFinding(
|
|
132
|
+
source="semantic-scholar",
|
|
133
|
+
title=title,
|
|
134
|
+
url=url,
|
|
135
|
+
snippet=snippet,
|
|
136
|
+
year=year,
|
|
137
|
+
extra={"authors": author_names},
|
|
138
|
+
))
|
|
139
|
+
return out, None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ─── GitHub presence ──────────────────────────────────────────────────────
|
|
143
|
+
def _search_github(query: str, limit: int = 3) -> tuple[list[ResearchFinding], Optional[str]]:
|
|
144
|
+
"""Look up GitHub users matching `query`. Returns top candidates."""
|
|
145
|
+
headers = {"Accept": "application/vnd.github+json"}
|
|
146
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
147
|
+
if token:
|
|
148
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
149
|
+
try:
|
|
150
|
+
resp = requests.get(
|
|
151
|
+
GITHUB_SEARCH_URL,
|
|
152
|
+
params={"q": query, "per_page": limit},
|
|
153
|
+
headers=headers,
|
|
154
|
+
timeout=15,
|
|
155
|
+
)
|
|
156
|
+
if resp.status_code == 403:
|
|
157
|
+
return [], "GitHub rate-limited — set GITHUB_TOKEN for more"
|
|
158
|
+
resp.raise_for_status()
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return [], f"GitHub error: {e}"
|
|
161
|
+
|
|
162
|
+
data = resp.json()
|
|
163
|
+
out: list[ResearchFinding] = []
|
|
164
|
+
for u in data.get("items", []):
|
|
165
|
+
login = u.get("login", "")
|
|
166
|
+
try:
|
|
167
|
+
profile_resp = requests.get(
|
|
168
|
+
f"https://api.github.com/users/{login}",
|
|
169
|
+
headers=headers, timeout=10,
|
|
170
|
+
)
|
|
171
|
+
profile = profile_resp.json() if profile_resp.ok else {}
|
|
172
|
+
except Exception:
|
|
173
|
+
profile = {}
|
|
174
|
+
bio = (profile.get("bio") or "").strip()
|
|
175
|
+
name = profile.get("name") or login
|
|
176
|
+
pubs = profile.get("public_repos", 0)
|
|
177
|
+
followers = profile.get("followers", 0)
|
|
178
|
+
snippet = f"{name} — {bio or '(no bio)'} · {pubs} repos · {followers} followers"
|
|
179
|
+
out.append(ResearchFinding(
|
|
180
|
+
source="github",
|
|
181
|
+
title=login,
|
|
182
|
+
url=u.get("html_url", f"https://github.com/{login}"),
|
|
183
|
+
snippet=snippet,
|
|
184
|
+
extra={"login": login, "bio": bio, "pubs": pubs, "followers": followers},
|
|
185
|
+
))
|
|
186
|
+
return out, None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ─── Orchestrator ─────────────────────────────────────────────────────────
|
|
190
|
+
def research_counterparty(
|
|
191
|
+
name: str,
|
|
192
|
+
affiliation: str = "",
|
|
193
|
+
include_web: bool = True,
|
|
194
|
+
include_papers: bool = True,
|
|
195
|
+
include_github: bool = True,
|
|
196
|
+
) -> ResearchResult:
|
|
197
|
+
"""Run every enabled source and collect findings."""
|
|
198
|
+
result = ResearchResult(query_name=name, affiliation=affiliation, findings=[])
|
|
199
|
+
combined_query = f"{name} {affiliation}".strip()
|
|
200
|
+
|
|
201
|
+
if include_web:
|
|
202
|
+
findings, err = _search_tavily(combined_query)
|
|
203
|
+
result.findings.extend(findings)
|
|
204
|
+
if err:
|
|
205
|
+
result.errors.append(err)
|
|
206
|
+
|
|
207
|
+
if include_papers:
|
|
208
|
+
# Search by name only for papers to widen the net
|
|
209
|
+
findings, err = _search_semantic_scholar(name)
|
|
210
|
+
result.findings.extend(findings)
|
|
211
|
+
if err:
|
|
212
|
+
result.errors.append(err)
|
|
213
|
+
|
|
214
|
+
if include_github:
|
|
215
|
+
findings, err = _search_github(name)
|
|
216
|
+
result.findings.extend(findings)
|
|
217
|
+
if err:
|
|
218
|
+
result.errors.append(err)
|
|
219
|
+
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ─── LLM synthesis into a briefing ────────────────────────────────────────
|
|
224
|
+
BRIEFING_SYSTEM_PROMPT = """You are a research assistant preparing a briefing on a person the user is about to have a conversation with. Your goal is a compact, actionable brief the user can skim before the call.
|
|
225
|
+
|
|
226
|
+
Structure your output EXACTLY as follows using markdown headings:
|
|
227
|
+
|
|
228
|
+
# {name}
|
|
229
|
+
One-line description. Who they are, current role/affiliation, what they're known for.
|
|
230
|
+
|
|
231
|
+
## Snapshot
|
|
232
|
+
2-3 bullets — the essentials to remember in the first 30 seconds of the conversation.
|
|
233
|
+
|
|
234
|
+
## Recent focus
|
|
235
|
+
Bullet list of things they seem currently interested in (papers from the last 2 years, projects, public talks, blog posts). Cite sources by title.
|
|
236
|
+
|
|
237
|
+
## Signature themes
|
|
238
|
+
2-4 recurring themes across their work that shape their worldview.
|
|
239
|
+
|
|
240
|
+
## Tone and style
|
|
241
|
+
One paragraph. How do they seem to communicate? Formal / informal? What kind of questions do they typically ask? Do they value directness, depth, humility?
|
|
242
|
+
|
|
243
|
+
## Conversation openings
|
|
244
|
+
3-5 concrete questions or observations the user could raise that would signal genuine engagement with their work.
|
|
245
|
+
|
|
246
|
+
## Watch-outs
|
|
247
|
+
Things to avoid saying / assuming. Anything that would make the user look uninformed.
|
|
248
|
+
|
|
249
|
+
## Sources
|
|
250
|
+
Numbered list of the underlying URLs referenced in the brief, one per line.
|
|
251
|
+
|
|
252
|
+
RULES:
|
|
253
|
+
- Base every claim on the raw findings provided. If findings are thin in some area, say so plainly ("no recent public papers found") rather than inventing.
|
|
254
|
+
- Prefer specifics over generalisations. Cite paper titles, project names, dates.
|
|
255
|
+
- Keep the whole brief under 500 words.
|
|
256
|
+
- Don't editorialise. Sound like a good analyst, not a fan."""
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def synthesize_briefing(
|
|
260
|
+
result: ResearchResult,
|
|
261
|
+
llm_provider: str,
|
|
262
|
+
llm_model: str,
|
|
263
|
+
) -> str:
|
|
264
|
+
"""Run the LLM synthesis pass. Returns a markdown briefing."""
|
|
265
|
+
from providers import respond
|
|
266
|
+
|
|
267
|
+
raw_dump_lines = [
|
|
268
|
+
f"Counterparty name (as searched): {result.query_name}",
|
|
269
|
+
f"Affiliation (as provided): {result.affiliation or '(not provided)'}",
|
|
270
|
+
f"Errors during collection: {result.errors or 'none'}",
|
|
271
|
+
"",
|
|
272
|
+
"─" * 60,
|
|
273
|
+
"RAW FINDINGS (grouped by source):",
|
|
274
|
+
"─" * 60,
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
for source in ("web", "semantic-scholar", "github"):
|
|
278
|
+
items = result.by_source(source)
|
|
279
|
+
if not items:
|
|
280
|
+
raw_dump_lines.append(f"\n[{source.upper()}] (no results)")
|
|
281
|
+
continue
|
|
282
|
+
raw_dump_lines.append(f"\n[{source.upper()}]")
|
|
283
|
+
for i, f in enumerate(items, 1):
|
|
284
|
+
year_bit = f" ({f.year})" if f.year else ""
|
|
285
|
+
raw_dump_lines.append(
|
|
286
|
+
f"\n{i}. {f.title}{year_bit}\n"
|
|
287
|
+
f" URL: {f.url}\n"
|
|
288
|
+
f" {f.snippet}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
raw_dump = "\n".join(raw_dump_lines)
|
|
292
|
+
|
|
293
|
+
user_message = (
|
|
294
|
+
f"Produce the briefing for {result.query_name}"
|
|
295
|
+
f"{' (' + result.affiliation + ')' if result.affiliation else ''} using the "
|
|
296
|
+
f"findings below. Follow the exact structure specified in the system prompt.\n\n"
|
|
297
|
+
+ raw_dump
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# We call respond() directly, bypassing conversation history.
|
|
301
|
+
system_prompt = BRIEFING_SYSTEM_PROMPT.replace("{name}", result.query_name)
|
|
302
|
+
return respond(llm_provider, llm_model, user_message, [], system_prompt)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def run_full_research(
|
|
306
|
+
name: str,
|
|
307
|
+
affiliation: str,
|
|
308
|
+
llm_provider: str,
|
|
309
|
+
llm_model: str,
|
|
310
|
+
) -> tuple[ResearchResult, str]:
|
|
311
|
+
"""One-shot: collect findings + synthesise briefing."""
|
|
312
|
+
result = research_counterparty(name=name, affiliation=affiliation)
|
|
313
|
+
briefing = synthesize_briefing(result, llm_provider, llm_model)
|
|
314
|
+
return result, briefing
|
responder.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Thin wrapper — routes to the chosen provider (see providers.py)."""
|
|
2
|
+
from providers import respond as _provider_respond
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def respond(transcript: str, history: list, system_prompt: str,
|
|
6
|
+
provider: str = "anthropic",
|
|
7
|
+
model: str = "claude-sonnet-4-6") -> str:
|
|
8
|
+
return _provider_respond(provider, model, transcript, history, system_prompt)
|
screen_capture.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Screen capture — grab a shot of what's on the user's screen (Zoom window,
|
|
3
|
+
coding challenge, whiteboard, diagram, etc.) and hand it to a vision LLM for
|
|
4
|
+
analysis.
|
|
5
|
+
|
|
6
|
+
X11 path uses ImageMagick's `import`. Wayland (grim) is supported if present.
|
|
7
|
+
|
|
8
|
+
Screenshots are saved to `~/.local/share/interview-coach/screens/<sessionId>/`
|
|
9
|
+
so users can review them later.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
CAPTURE_DIR = Path.home() / ".local" / "share" / "interview-coach" / "screens"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _now_stamp() -> str:
|
|
26
|
+
return time.strftime("%Y%m%d-%H%M%S")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _detect_backend() -> str:
|
|
30
|
+
if shutil.which("grim"):
|
|
31
|
+
return "grim"
|
|
32
|
+
if shutil.which("gnome-screenshot"):
|
|
33
|
+
return "gnome-screenshot"
|
|
34
|
+
if shutil.which("import"):
|
|
35
|
+
return "import"
|
|
36
|
+
if shutil.which("scrot"):
|
|
37
|
+
return "scrot"
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"No screenshot tool found. Install one: `sudo apt install imagemagick` "
|
|
40
|
+
"(for `import`) or `sudo apt install grim` on Wayland."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
CaptureMode = Literal["full", "region", "window"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def capture(
|
|
48
|
+
session_id: int | str,
|
|
49
|
+
mode: CaptureMode = "region",
|
|
50
|
+
) -> Path:
|
|
51
|
+
"""Grab a screenshot and return its path.
|
|
52
|
+
|
|
53
|
+
Modes:
|
|
54
|
+
full — entire screen, no prompt
|
|
55
|
+
region — interactive drag-select (default; matches how you'd naturally
|
|
56
|
+
point at a coding-question region)
|
|
57
|
+
window — click a window to capture it whole
|
|
58
|
+
"""
|
|
59
|
+
session_dir = CAPTURE_DIR / str(session_id)
|
|
60
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
out = session_dir / f"{_now_stamp()}.png"
|
|
62
|
+
|
|
63
|
+
backend = _detect_backend()
|
|
64
|
+
|
|
65
|
+
if backend == "import":
|
|
66
|
+
cmd = _import_cmd(mode, out)
|
|
67
|
+
elif backend == "grim":
|
|
68
|
+
cmd = _grim_cmd(mode, out)
|
|
69
|
+
elif backend == "gnome-screenshot":
|
|
70
|
+
cmd = _gnome_cmd(mode, out)
|
|
71
|
+
elif backend == "scrot":
|
|
72
|
+
cmd = _scrot_cmd(mode, out)
|
|
73
|
+
else:
|
|
74
|
+
raise RuntimeError(f"Unhandled backend: {backend}")
|
|
75
|
+
|
|
76
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
77
|
+
if result.returncode != 0:
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
f"Screenshot failed ({backend}): {result.stderr.strip() or result.stdout.strip()}"
|
|
80
|
+
)
|
|
81
|
+
if not out.exists() or out.stat().st_size == 0:
|
|
82
|
+
raise RuntimeError("Screenshot appeared to succeed but the file is empty.")
|
|
83
|
+
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ─── Backend command builders ─────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def _import_cmd(mode: CaptureMode, out: Path) -> list[str]:
|
|
90
|
+
if mode == "full":
|
|
91
|
+
return ["import", "-window", "root", str(out)]
|
|
92
|
+
if mode == "window":
|
|
93
|
+
# User clicks the target window; import captures it whole.
|
|
94
|
+
return ["import", str(out)] # default behaviour is window select
|
|
95
|
+
# region: interactive drag-select
|
|
96
|
+
return ["import", str(out)]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _grim_cmd(mode: CaptureMode, out: Path) -> list[str]:
|
|
100
|
+
if mode == "full":
|
|
101
|
+
return ["grim", str(out)]
|
|
102
|
+
if mode == "region":
|
|
103
|
+
# grim + slurp for interactive select; require slurp installed.
|
|
104
|
+
if not shutil.which("slurp"):
|
|
105
|
+
return ["grim", str(out)] # fall back to full
|
|
106
|
+
return ["sh", "-c", f'grim -g "$(slurp)" "{out}"']
|
|
107
|
+
if mode == "window":
|
|
108
|
+
return ["grim", str(out)] # grim has no built-in window picker
|
|
109
|
+
return ["grim", str(out)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _gnome_cmd(mode: CaptureMode, out: Path) -> list[str]:
|
|
113
|
+
if mode == "full":
|
|
114
|
+
return ["gnome-screenshot", "-f", str(out)]
|
|
115
|
+
if mode == "region":
|
|
116
|
+
return ["gnome-screenshot", "-a", "-f", str(out)]
|
|
117
|
+
if mode == "window":
|
|
118
|
+
return ["gnome-screenshot", "-w", "-f", str(out)]
|
|
119
|
+
return ["gnome-screenshot", "-f", str(out)]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _scrot_cmd(mode: CaptureMode, out: Path) -> list[str]:
|
|
123
|
+
if mode == "full":
|
|
124
|
+
return ["scrot", str(out)]
|
|
125
|
+
if mode == "region":
|
|
126
|
+
return ["scrot", "-s", str(out)]
|
|
127
|
+
if mode == "window":
|
|
128
|
+
return ["scrot", "-u", str(out)]
|
|
129
|
+
return ["scrot", str(out)]
|
sessions.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLite-backed session storage.
|
|
3
|
+
|
|
4
|
+
DB lives at ~/.local/share/cli-interview-recorder/sessions.db.
|
|
5
|
+
|
|
6
|
+
A session is a titled conversation with its full history. Messages are appended
|
|
7
|
+
as each turn happens (auto-save on every turn) so nothing is lost on crash.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DB_DIR = Path.home() / ".local" / "share" / "interview-coach"
|
|
18
|
+
DB_PATH = DB_DIR / "sessions.db"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _connect() -> sqlite3.Connection:
|
|
22
|
+
DB_DIR.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
conn = sqlite3.connect(DB_PATH)
|
|
24
|
+
conn.row_factory = sqlite3.Row
|
|
25
|
+
conn.executescript(
|
|
26
|
+
"""
|
|
27
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
title TEXT NOT NULL,
|
|
30
|
+
mode TEXT NOT NULL,
|
|
31
|
+
llm_provider TEXT,
|
|
32
|
+
llm_model TEXT,
|
|
33
|
+
created_at TEXT NOT NULL,
|
|
34
|
+
updated_at TEXT NOT NULL
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
session_id INTEGER NOT NULL,
|
|
39
|
+
role TEXT NOT NULL, -- 'user' | 'assistant'
|
|
40
|
+
speaker TEXT, -- 'you' | 'interviewer' | 'unknown' | NULL for assistant
|
|
41
|
+
content TEXT NOT NULL,
|
|
42
|
+
created_at TEXT NOT NULL,
|
|
43
|
+
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
|
46
|
+
"""
|
|
47
|
+
)
|
|
48
|
+
return conn
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_session(title: str, mode: str,
|
|
52
|
+
llm_provider: str = "", llm_model: str = "") -> int:
|
|
53
|
+
now = datetime.utcnow().isoformat(timespec="seconds")
|
|
54
|
+
with _connect() as conn:
|
|
55
|
+
cur = conn.execute(
|
|
56
|
+
"INSERT INTO sessions (title, mode, llm_provider, llm_model, created_at, updated_at) "
|
|
57
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
58
|
+
(title, mode, llm_provider, llm_model, now, now),
|
|
59
|
+
)
|
|
60
|
+
return cur.lastrowid
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def rename_session(session_id: int, new_title: str):
|
|
64
|
+
with _connect() as conn:
|
|
65
|
+
conn.execute(
|
|
66
|
+
"UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?",
|
|
67
|
+
(new_title, datetime.utcnow().isoformat(timespec="seconds"), session_id),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def delete_session(session_id: int):
|
|
72
|
+
with _connect() as conn:
|
|
73
|
+
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
|
74
|
+
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def append_message(session_id: int, role: str, content: str, speaker: Optional[str] = None):
|
|
78
|
+
now = datetime.utcnow().isoformat(timespec="seconds")
|
|
79
|
+
with _connect() as conn:
|
|
80
|
+
conn.execute(
|
|
81
|
+
"INSERT INTO messages (session_id, role, speaker, content, created_at) "
|
|
82
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
83
|
+
(session_id, role, speaker, content, now),
|
|
84
|
+
)
|
|
85
|
+
conn.execute(
|
|
86
|
+
"UPDATE sessions SET updated_at = ? WHERE id = ?",
|
|
87
|
+
(now, session_id),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def list_sessions(limit: int = 20) -> list[sqlite3.Row]:
|
|
92
|
+
with _connect() as conn:
|
|
93
|
+
cur = conn.execute(
|
|
94
|
+
"SELECT s.id, s.title, s.mode, s.llm_provider, s.llm_model, "
|
|
95
|
+
" s.created_at, s.updated_at, "
|
|
96
|
+
" (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS turns "
|
|
97
|
+
"FROM sessions s ORDER BY s.updated_at DESC LIMIT ?",
|
|
98
|
+
(limit,),
|
|
99
|
+
)
|
|
100
|
+
return cur.fetchall()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_messages(session_id: int) -> list[dict]:
|
|
104
|
+
"""Return messages in the {role, content} shape LLMs expect."""
|
|
105
|
+
with _connect() as conn:
|
|
106
|
+
cur = conn.execute(
|
|
107
|
+
"SELECT role, content FROM messages WHERE session_id = ? ORDER BY id",
|
|
108
|
+
(session_id,),
|
|
109
|
+
)
|
|
110
|
+
return [{"role": r["role"], "content": r["content"]} for r in cur.fetchall()]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_session(session_id: int) -> Optional[sqlite3.Row]:
|
|
114
|
+
with _connect() as conn:
|
|
115
|
+
cur = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,))
|
|
116
|
+
return cur.fetchone()
|