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