quicksnap 0.2.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.
- quicksnap/__init__.py +3 -0
- quicksnap/__main__.py +3 -0
- quicksnap/ai.py +256 -0
- quicksnap/cli.py +1280 -0
- quicksnap/git_integration.py +243 -0
- quicksnap/models.py +176 -0
- quicksnap/storage.py +308 -0
- quicksnap-0.2.0.dist-info/METADATA +420 -0
- quicksnap-0.2.0.dist-info/RECORD +13 -0
- quicksnap-0.2.0.dist-info/WHEEL +5 -0
- quicksnap-0.2.0.dist-info/entry_points.txt +2 -0
- quicksnap-0.2.0.dist-info/licenses/LICENSE +21 -0
- quicksnap-0.2.0.dist-info/top_level.txt +1 -0
quicksnap/__init__.py
ADDED
quicksnap/__main__.py
ADDED
quicksnap/ai.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _has_httpx() -> bool:
|
|
9
|
+
try:
|
|
10
|
+
import httpx
|
|
11
|
+
return True
|
|
12
|
+
except ImportError:
|
|
13
|
+
return False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AIServiceError(Exception):
|
|
17
|
+
def __init__(self, message: str, status_code: int = 0):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def generate_with_local_ai(context: dict, model: str = "llama3") -> Optional[dict]:
|
|
23
|
+
if not _has_httpx():
|
|
24
|
+
return None
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
prompt = _build_prompt(context)
|
|
28
|
+
try:
|
|
29
|
+
resp = httpx.post(
|
|
30
|
+
"http://localhost:11434/api/generate",
|
|
31
|
+
json={"model": model, "prompt": prompt, "stream": False},
|
|
32
|
+
timeout=120,
|
|
33
|
+
)
|
|
34
|
+
if resp.status_code == 200:
|
|
35
|
+
text = resp.json().get("response", "")
|
|
36
|
+
return _parse_ai_response(text)
|
|
37
|
+
return None
|
|
38
|
+
except httpx.ConnectError:
|
|
39
|
+
return None
|
|
40
|
+
except httpx.TimeoutException:
|
|
41
|
+
return None
|
|
42
|
+
except Exception:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def generate_with_openai(context: dict, model: str = "gpt-4o-mini") -> Optional[dict]:
|
|
47
|
+
if not _has_httpx():
|
|
48
|
+
return None
|
|
49
|
+
import httpx
|
|
50
|
+
|
|
51
|
+
api_key = os.environ.get("OPENAI_API_KEY")
|
|
52
|
+
if not api_key:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
prompt = _build_prompt(context)
|
|
56
|
+
try:
|
|
57
|
+
resp = httpx.post(
|
|
58
|
+
"https://api.openai.com/v1/chat/completions",
|
|
59
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
60
|
+
json={
|
|
61
|
+
"model": model,
|
|
62
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
63
|
+
"temperature": 0.3,
|
|
64
|
+
},
|
|
65
|
+
timeout=60,
|
|
66
|
+
)
|
|
67
|
+
if resp.status_code == 401:
|
|
68
|
+
raise AIServiceError("Invalid OpenAI API key", 401)
|
|
69
|
+
if resp.status_code == 429:
|
|
70
|
+
raise AIServiceError("OpenAI rate limited", 429)
|
|
71
|
+
resp.raise_for_status()
|
|
72
|
+
text = resp.json()["choices"][0]["message"]["content"]
|
|
73
|
+
return _parse_ai_response(text)
|
|
74
|
+
except AIServiceError:
|
|
75
|
+
raise
|
|
76
|
+
except Exception:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def generate_with_anthropic(context: dict, model: str = "claude-3-5-sonnet-20241022") -> Optional[dict]:
|
|
81
|
+
if not _has_httpx():
|
|
82
|
+
return None
|
|
83
|
+
import httpx
|
|
84
|
+
|
|
85
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
86
|
+
if not api_key:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
prompt = _build_prompt(context)
|
|
90
|
+
try:
|
|
91
|
+
resp = httpx.post(
|
|
92
|
+
"https://api.anthropic.com/v1/messages",
|
|
93
|
+
headers={
|
|
94
|
+
"x-api-key": api_key,
|
|
95
|
+
"anthropic-version": "2023-06-01",
|
|
96
|
+
},
|
|
97
|
+
json={
|
|
98
|
+
"model": model,
|
|
99
|
+
"max_tokens": 2000,
|
|
100
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
101
|
+
},
|
|
102
|
+
timeout=60,
|
|
103
|
+
)
|
|
104
|
+
if resp.status_code == 401:
|
|
105
|
+
raise AIServiceError("Invalid Anthropic API key", 401)
|
|
106
|
+
if resp.status_code == 429:
|
|
107
|
+
raise AIServiceError("Anthropic rate limited", 429)
|
|
108
|
+
resp.raise_for_status()
|
|
109
|
+
text = resp.json()["content"][0]["text"]
|
|
110
|
+
return _parse_ai_response(text)
|
|
111
|
+
except AIServiceError:
|
|
112
|
+
raise
|
|
113
|
+
except Exception:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def generate_with_gemini(context: dict, model: str = "gemini-1.5-flash") -> Optional[dict]:
|
|
118
|
+
if not _has_httpx():
|
|
119
|
+
return None
|
|
120
|
+
import httpx
|
|
121
|
+
|
|
122
|
+
api_key = os.environ.get("GEMINI_API_KEY")
|
|
123
|
+
if not api_key:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
prompt = _build_prompt(context)
|
|
127
|
+
try:
|
|
128
|
+
resp = httpx.post(
|
|
129
|
+
f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
|
|
130
|
+
headers={"x-goog-api-key": api_key},
|
|
131
|
+
json={"contents": [{"parts": [{"text": prompt}]}]},
|
|
132
|
+
timeout=60,
|
|
133
|
+
)
|
|
134
|
+
if resp.status_code == 400:
|
|
135
|
+
raise AIServiceError("Invalid Gemini API key or model", 400)
|
|
136
|
+
resp.raise_for_status()
|
|
137
|
+
data = resp.json()
|
|
138
|
+
candidates = data.get("candidates", [])
|
|
139
|
+
if candidates:
|
|
140
|
+
text = candidates[0].get("content", {}).get("parts", [{}])[0].get("text", "")
|
|
141
|
+
return _parse_ai_response(text)
|
|
142
|
+
return None
|
|
143
|
+
except AIServiceError:
|
|
144
|
+
raise
|
|
145
|
+
except Exception:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def generate_ai_summary(context: dict, mode: str = "auto") -> tuple[Optional[dict], str]:
|
|
150
|
+
try:
|
|
151
|
+
if mode == "local":
|
|
152
|
+
return generate_with_local_ai(context), ""
|
|
153
|
+
elif mode == "openai":
|
|
154
|
+
return generate_with_openai(context), ""
|
|
155
|
+
elif mode == "anthropic":
|
|
156
|
+
return generate_with_anthropic(context), ""
|
|
157
|
+
elif mode == "gemini":
|
|
158
|
+
return generate_with_gemini(context), ""
|
|
159
|
+
|
|
160
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
161
|
+
return generate_with_openai(context), ""
|
|
162
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
163
|
+
return generate_with_anthropic(context), ""
|
|
164
|
+
if os.environ.get("GEMINI_API_KEY"):
|
|
165
|
+
return generate_with_gemini(context), ""
|
|
166
|
+
result = generate_with_local_ai(context)
|
|
167
|
+
if result is None:
|
|
168
|
+
return None, "No AI configured. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, or install Ollama."
|
|
169
|
+
return result, ""
|
|
170
|
+
except AIServiceError as e:
|
|
171
|
+
error_map = {
|
|
172
|
+
401: "Invalid API key. Check your environment variable.",
|
|
173
|
+
429: "Rate limited. Wait a moment and try again.",
|
|
174
|
+
400: "Invalid request. Check your API key and model name.",
|
|
175
|
+
}
|
|
176
|
+
msg = error_map.get(e.status_code, f"AI error: {e}")
|
|
177
|
+
return None, msg
|
|
178
|
+
except Exception as e:
|
|
179
|
+
return None, f"Unexpected error: {e}"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _build_prompt(context: dict) -> str:
|
|
183
|
+
diff = context.get("diff", "")[:3000]
|
|
184
|
+
commits = "\n".join(context.get("commit_messages", []))
|
|
185
|
+
files = "\n".join(context.get("files_changed", []))
|
|
186
|
+
|
|
187
|
+
return f"""You are helping an engineer document their work for future interviews.
|
|
188
|
+
|
|
189
|
+
Git repository: {context.get('repo_name', 'unknown')}
|
|
190
|
+
Branch: {context.get('branch', 'unknown')}
|
|
191
|
+
Files changed: {files}
|
|
192
|
+
Recent commits: {commits}
|
|
193
|
+
|
|
194
|
+
Diff:
|
|
195
|
+
{diff}
|
|
196
|
+
|
|
197
|
+
Generate a JSON response with these fields:
|
|
198
|
+
{{
|
|
199
|
+
"title": "A concise, descriptive title for this change",
|
|
200
|
+
"problem": "What problem was being solved (2-3 sentences)",
|
|
201
|
+
"context": "Technical context and scale",
|
|
202
|
+
"challenge": "What made this difficult",
|
|
203
|
+
"alternatives": ["alternative 1", "alternative 2", "alternative 3"],
|
|
204
|
+
"decision": "Why this approach was chosen",
|
|
205
|
+
"tradeoffs": ["tradeoff 1", "tradeoff 2"],
|
|
206
|
+
"result": "Measurable outcome if possible",
|
|
207
|
+
"lessons": "Key lessons learned",
|
|
208
|
+
"surprises": "What surprised you during this work?",
|
|
209
|
+
"risks_taken": "What was the biggest risk you took?",
|
|
210
|
+
"assumptions": "What assumption did you make that proved correct or incorrect?",
|
|
211
|
+
"almost_failed": "What almost went wrong?",
|
|
212
|
+
"advice": "What advice would you give your future self about this?",
|
|
213
|
+
"tags": ["tag1", "tag2", "tag3"],
|
|
214
|
+
"star": {{
|
|
215
|
+
"situation": "...",
|
|
216
|
+
"task": "...",
|
|
217
|
+
"action": "...",
|
|
218
|
+
"result": "..."
|
|
219
|
+
}},
|
|
220
|
+
"bullets": [
|
|
221
|
+
"Resume bullet point 1",
|
|
222
|
+
"Resume bullet point 2"
|
|
223
|
+
]
|
|
224
|
+
}}
|
|
225
|
+
|
|
226
|
+
Respond ONLY with valid JSON. Be specific and technical."""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _parse_ai_response(text: str) -> Optional[dict]:
|
|
230
|
+
text = text.strip()
|
|
231
|
+
if text.startswith("```"):
|
|
232
|
+
lines = text.splitlines()
|
|
233
|
+
lines = lines[1:]
|
|
234
|
+
if lines and lines[-1].strip() == "```":
|
|
235
|
+
lines = lines[:-1]
|
|
236
|
+
text = "\n".join(lines)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
result = json.loads(text)
|
|
240
|
+
if isinstance(result, dict):
|
|
241
|
+
return result
|
|
242
|
+
return None
|
|
243
|
+
except json.JSONDecodeError:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
start = text.find("{")
|
|
247
|
+
end = text.rfind("}")
|
|
248
|
+
if start != -1 and end != -1 and end > start:
|
|
249
|
+
try:
|
|
250
|
+
result = json.loads(text[start:end + 1])
|
|
251
|
+
if isinstance(result, dict):
|
|
252
|
+
return result
|
|
253
|
+
except json.JSONDecodeError:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
return None
|