wuptracker 0.1.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.
wuptracker/generate.py ADDED
@@ -0,0 +1,282 @@
1
+ """Gera o writeup final em Markdown a partir de uma sessão capturada.
2
+
3
+ Uso:
4
+ python generate.py sessions/2026-09-10_14-32_skynet/
5
+ python generate.py sessions/... --profile generico
6
+ python generate.py sessions/... --style corrido
7
+ python generate.py sessions/... --style tecnico,linkedin,resumo
8
+ python generate.py # lista sessões disponíveis e pede escolha
9
+
10
+ --profile (thm|generico): domínio; padrão vem de meta.json.
11
+ --style (tecnico|corrido|resumo|blog|linkedin, separados por vírgula): formato;
12
+ padrão em config.DEFAULT_STYLE.
13
+ """
14
+
15
+ import datetime as dt
16
+ import json
17
+ import os
18
+ import sys
19
+
20
+ from . import common
21
+ from . import config
22
+ from . import llm
23
+ from . import profiles
24
+
25
+
26
+ def pick_session():
27
+ base = config.SESSIONS_DIR
28
+ if not os.path.isdir(base):
29
+ sys.exit(f"[!] Pasta '{base}' não existe. Rode capture.py primeiro.")
30
+ dirs = sorted(d for d in os.listdir(base)
31
+ if os.path.isdir(os.path.join(base, d)))
32
+ if not dirs:
33
+ sys.exit(f"[!] Nenhuma sessão em '{base}'.")
34
+ print("Sessões disponíveis:")
35
+ for i, d in enumerate(dirs, 1):
36
+ print(f" {i}. {d}")
37
+ choice = input("Escolha o número: ").strip()
38
+ try:
39
+ return os.path.join(base, dirs[int(choice) - 1])
40
+ except (ValueError, IndexError):
41
+ sys.exit("[!] Escolha inválida.")
42
+
43
+
44
+ def load_analysis(captures_dir, filename):
45
+ path = os.path.join(captures_dir, f"{filename}.json")
46
+ try:
47
+ with open(path, encoding="utf-8") as f:
48
+ return json.load(f)
49
+ except (OSError, json.JSONDecodeError):
50
+ return {}
51
+
52
+
53
+ def build_context(session_dir):
54
+ captures_dir = os.path.join(session_dir, "captures")
55
+
56
+ with open(os.path.join(session_dir, "meta.json"), encoding="utf-8") as f:
57
+ meta = json.load(f)
58
+
59
+ session_path = os.path.join(session_dir, "session.json")
60
+ if os.path.exists(session_path):
61
+ with open(session_path, encoding="utf-8") as f:
62
+ entries = json.load(f).get("captures", [])
63
+ else:
64
+ entries = []
65
+
66
+ entries = sorted(entries, key=lambda c: c.get("filename", ""))
67
+ embed = getattr(config, "EMBED_IMAGES", True)
68
+
69
+ blocks, tools, phases, images = [], set(), set(), []
70
+ for e in entries:
71
+ a = load_analysis(captures_dir, e["filename"])
72
+ phase = a.get("phase") or e.get("phase") or "misc"
73
+ phases.add(phase)
74
+ tool = a.get("tool")
75
+ if tool:
76
+ tools.add(str(tool).lower())
77
+ # commands (thm) / actions (generico)
78
+ cmds = (a.get("commands") or []) + (a.get("actions") or [])
79
+ finds = a.get("findings") or []
80
+ app = a.get("tool") or a.get("app")
81
+ km = a.get("is_key_moment")
82
+ reason = a.get("key_moment_reason")
83
+
84
+ img_line = ""
85
+ img_file = a.get("image") or e.get("image")
86
+ if img_file and not os.path.exists(os.path.join(captures_dir, img_file)):
87
+ img_file = e["filename"] + ".png" # fallback p/ sessões antigas
88
+ if embed and img_file and os.path.exists(os.path.join(captures_dir, img_file)):
89
+ stem = os.path.splitext(img_file)[0]
90
+ ext = ".webp" if getattr(config, "WEBP_IMAGES", True) else \
91
+ os.path.splitext(img_file)[1]
92
+ out_name = stem + ext
93
+ images.append((img_file, out_name)) # (origem, nome no assets/)
94
+ img_line = f"\nImagem: assets/{out_name}"
95
+
96
+ blocks.append(
97
+ f"[{e.get('timestamp','??:??:??')}] {str(phase).upper()}: "
98
+ f"{a.get('title') or e.get('title','(sem título)')}\n"
99
+ f"Descrição: {a.get('description','')}\n"
100
+ f"App/ferramenta: {app or '—'}\n"
101
+ f"Ações/comandos: {'; '.join(cmds) if cmds else '—'}\n"
102
+ f"Findings: {'; '.join(finds) if finds else '—'}\n"
103
+ f"Momento-chave: {'sim' if km else 'não'}"
104
+ f"{' — ' + reason if km and reason else ''}"
105
+ f"{img_line}"
106
+ )
107
+
108
+ return (meta, entries, "\n\n".join(blocks),
109
+ sorted(tools), sorted(phases), images)
110
+
111
+
112
+ def _strip_fence(md):
113
+ if md.startswith("```"):
114
+ lines = md.split("\n")
115
+ md = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
116
+ return md.strip()
117
+
118
+
119
+ def generate(session_dir, styles=None, profile_override=None, export_dir=None,
120
+ open_after=None):
121
+ """Gera um ou mais writeups para a sessão. Retorna a lista de arquivos .md."""
122
+ session_dir = session_dir.rstrip("/")
123
+ if not os.path.isdir(session_dir):
124
+ sys.exit(f"[!] Sessão não encontrada: {session_dir}")
125
+
126
+ meta, entries, ctx, tools, phases, images = build_context(session_dir)
127
+ if not entries:
128
+ sys.exit("[!] Sessão sem capturas.")
129
+
130
+ profile = profiles.resolve_domain(
131
+ profile_override or meta.get("profile") or "thm")
132
+ if isinstance(styles, str):
133
+ styles = styles.split(",")
134
+ styles = [profiles.resolve_style(s) for s in
135
+ (styles or getattr(config, "DEFAULT_STYLE", "tecnico").split(","))]
136
+
137
+ room = meta.get("room", os.path.basename(session_dir))
138
+ duration = meta.get("duration", "desconhecida")
139
+ base = os.path.basename(session_dir)
140
+
141
+ common_fields = dict(
142
+ room_name=room, duration=duration, total_captures=len(entries),
143
+ tools_list=", ".join(tools) if tools else "não identificadas",
144
+ captures_context=ctx, fase_tags=", ".join(phases),
145
+ data_hoje=dt.date.today().isoformat(),
146
+ )
147
+
148
+ llm.preflight()
149
+ outputs, dirs_with_assets = [], set()
150
+ for style in styles:
151
+ prompt = profiles.build_writeup_prompt(profile, style, **common_fields)
152
+ embed = bool(style in profiles.STYLES_WITH_IMAGES and images)
153
+
154
+ print(common.c_cyan(
155
+ f"Gerando writeup [{profile}/{style}] de '{room}' "
156
+ f"({len(entries)} capturas)…"))
157
+ markdown = _strip_fence(llm.generate_text(prompt))
158
+
159
+ out_dir = os.path.join(config.WRITEUPS_DIR, base) if embed \
160
+ else config.WRITEUPS_DIR
161
+ os.makedirs(out_dir, exist_ok=True)
162
+
163
+ stem = "writeup" if style == "tecnico" else f"writeup-{style}"
164
+ fname = f"{stem}.md" if embed else \
165
+ (f"{base}.md" if style == "tecnico" else f"{base}-{style}.md")
166
+ out = os.path.join(out_dir, fname)
167
+
168
+ if embed:
169
+ markdown = _embed_images(markdown, images, entries, session_dir, out_dir)
170
+ dirs_with_assets.add(out_dir)
171
+
172
+ with open(out, "w", encoding="utf-8") as f:
173
+ f.write(markdown + "\n")
174
+ outputs.append(out)
175
+ print(common.c_green(f"✓ {out}"))
176
+ if embed:
177
+ print(f" {len(set(images))} imagem(ns) em "
178
+ f"{os.path.join(out_dir, 'assets')}/")
179
+
180
+ if export_dir:
181
+ _export(outputs, dirs_with_assets, export_dir)
182
+
183
+ if open_after if open_after is not None else config.OPEN_AFTER_GENERATE:
184
+ if outputs:
185
+ common.open_in_editor(outputs[0])
186
+ return outputs
187
+
188
+
189
+ def _export(outputs, dirs_with_assets, export_dir):
190
+ import shutil
191
+
192
+ export_dir = os.path.expanduser(export_dir)
193
+ os.makedirs(export_dir, exist_ok=True)
194
+ for md in outputs:
195
+ shutil.copy2(md, os.path.join(export_dir, os.path.basename(md)))
196
+ for d in dirs_with_assets:
197
+ src = os.path.join(d, "assets")
198
+ if os.path.isdir(src):
199
+ shutil.copytree(src, os.path.join(export_dir, "assets"),
200
+ dirs_exist_ok=True)
201
+ print(common.c_green(f"↗ exportado para {export_dir}"))
202
+
203
+
204
+ def main():
205
+ args = sys.argv[1:]
206
+ prof_override = _take_opt(args, "--profile")
207
+ style_arg = _take_opt(args, "--style")
208
+ export_dir = _take_opt(args, "--export")
209
+ open_after = True if "--open" in args else None
210
+ args = [a for a in args if a != "--open"]
211
+
212
+ session_dir = args[0] if args else pick_session()
213
+ generate(session_dir, style_arg, prof_override, export_dir, open_after)
214
+
215
+
216
+ def _take_opt(args, flag):
217
+ if flag in args:
218
+ i = args.index(flag)
219
+ try:
220
+ val = args[i + 1]
221
+ except IndexError:
222
+ sys.exit(f"[!] {flag} requer um valor.")
223
+ del args[i:i + 2]
224
+ return val
225
+ return None
226
+
227
+
228
+ def _embed_images(markdown, images, entries, session_dir, out_dir):
229
+ """images: lista de (arquivo_origem, nome_no_assets). Otimiza cada imagem
230
+ (WebP/resize) para writeups/<sessão>/assets/ e injeta as que o modelo não
231
+ referenciou numa galeria no fim."""
232
+ import shutil
233
+
234
+ assets_dir = os.path.join(out_dir, "assets")
235
+ os.makedirs(assets_dir, exist_ok=True)
236
+ captures_dir = os.path.join(session_dir, "captures")
237
+
238
+ saved_bytes = orig_bytes = 0
239
+ for src_name, out_name in dict.fromkeys(images):
240
+ src = os.path.join(captures_dir, src_name)
241
+ if not os.path.exists(src):
242
+ continue
243
+ stem = os.path.join(assets_dir, os.path.splitext(out_name)[0])
244
+ written = common.optimize_image(src, stem)
245
+ if not written: # otimização falhou -> cópia crua
246
+ written = os.path.join(assets_dir, src_name)
247
+ shutil.copy2(src, written)
248
+ orig_bytes += os.path.getsize(src)
249
+ saved_bytes += os.path.getsize(written)
250
+
251
+ if orig_bytes:
252
+ pct = round(100 * saved_bytes / orig_bytes)
253
+ print(common.c_dim(
254
+ f" imagens: {_hsize(orig_bytes)} → {_hsize(saved_bytes)} ({pct}%)"))
255
+
256
+ out_names = [o for _, o in dict.fromkeys(images)]
257
+ used = {o for o in out_names if f"assets/{o}" in markdown}
258
+ missing = [o for o in out_names if o not in used]
259
+ if missing:
260
+ gallery = ["", "## Capturas", ""]
261
+ # mapeia nome-no-assets -> entrada, via stem do arquivo de origem
262
+ stem_to_entry = {os.path.splitext(e.get("image", ""))[0]: e for e in entries}
263
+ for o in missing:
264
+ e = stem_to_entry.get(os.path.splitext(o)[0], {})
265
+ cap = e.get("title", o)
266
+ ts = e.get("timestamp", "")
267
+ gallery += [f"**[{ts}]** {cap}" if ts else f"**{cap}**", "",
268
+ f"![{cap}](assets/{o})", ""]
269
+ markdown = markdown.rstrip() + "\n" + "\n".join(gallery)
270
+ return markdown
271
+
272
+
273
+ def _hsize(n):
274
+ for unit in ("B", "KB", "MB"):
275
+ if n < 1024:
276
+ return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
277
+ n /= 1024
278
+ return f"{n:.1f}GB"
279
+
280
+
281
+ if __name__ == "__main__":
282
+ main()
wuptracker/llm.py ADDED
@@ -0,0 +1,315 @@
1
+ """Camada de backend do LLM — abstrai qual IA analisa as imagens e escreve o writeup.
2
+
3
+ Backends (config.BACKEND):
4
+
5
+ claude_cli CLI do Claude Code (`claude -p`). Usa a cota da sua assinatura
6
+ Pro/Max — sem cobrança por token, sem API key. Requer `claude` no PATH.
7
+ anthropic API da Anthropic (ANTHROPIC_API_KEY). Cobra por token.
8
+ ollama Modelo local via Ollama (http://localhost:11434). Grátis, offline,
9
+ privado. Requer um modelo com visão baixado (llava, llama3.2-vision,
10
+ qwen2.5vl, moondream, minicpm-v...).
11
+ openai Qualquer endpoint compatível com a API OpenAI (OpenAI, Groq,
12
+ OpenRouter, LM Studio, llama.cpp server...). Config: OPENAI_BASE_URL,
13
+ OPENAI_MODEL, OPENAI_API_KEY.
14
+ gemini API do Google Gemini (GEMINI_API_KEY). Tem um tier gratuito
15
+ generoso. Config: GEMINI_MODEL.
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import urllib.error
24
+ import urllib.request
25
+
26
+ from . import config
27
+ from . import paths
28
+
29
+ try:
30
+ from dotenv import load_dotenv
31
+
32
+ load_dotenv(paths.ENV_FILE)
33
+ load_dotenv() # também o .env do diretório atual, se houver (dev/legado)
34
+ except Exception:
35
+ pass
36
+
37
+ BACKENDS = ("claude_cli", "anthropic", "ollama", "openai", "gemini")
38
+ _ALIASES = {
39
+ "api": "anthropic", "claude": "claude_cli", "cli": "claude_cli",
40
+ "openai_compat": "openai", "compat": "openai", "local": "ollama",
41
+ "google": "gemini",
42
+ }
43
+
44
+
45
+ def resolve_backend(name):
46
+ name = (name or "claude_cli").strip().lower()
47
+ return _ALIASES.get(name, name)
48
+
49
+
50
+ def _backend():
51
+ return resolve_backend(getattr(config, "BACKEND", "claude_cli"))
52
+
53
+
54
+ # ==========================================================================
55
+ # API pública
56
+ # ==========================================================================
57
+ def preflight():
58
+ """Valida que o backend escolhido está utilizável; encerra com msg clara."""
59
+ b = _backend()
60
+ if b not in BACKENDS:
61
+ sys.exit(f"[!] BACKEND inválido: {b!r}. Use: {', '.join(BACKENDS)}")
62
+
63
+ if b == "claude_cli":
64
+ if not shutil.which("claude"):
65
+ sys.exit("[!] comando 'claude' não encontrado no PATH. Instale o "
66
+ "Claude Code ou troque o backend:\n"
67
+ " wuptracker config backend ollama")
68
+ elif b == "anthropic":
69
+ _anthropic_client()
70
+ elif b == "ollama":
71
+ host = _ollama_host()
72
+ try:
73
+ tags = _http_json("GET", f"{host}/api/tags", timeout=5)
74
+ except Exception as e:
75
+ sys.exit(f"[!] Ollama inacessível em {host} ({e}).\n"
76
+ " Rode 'ollama serve' ou ajuste 'wuptracker config set "
77
+ "ollama-host ...'")
78
+ model = _ollama_model()
79
+ have = {m.get("name", "").split(":")[0] for m in tags.get("models", [])}
80
+ if model.split(":")[0] not in have:
81
+ print(f"[i] modelo Ollama '{model}' não está baixado — rode: "
82
+ f"ollama pull {model}", file=sys.stderr)
83
+ elif b == "openai":
84
+ base = _openai_base()
85
+ local = "localhost" in base or "127.0.0.1" in base
86
+ if not _openai_key() and not local:
87
+ sys.exit("[!] OPENAI_API_KEY ausente.\n"
88
+ " wuptracker config api-key <sua-chave>\n"
89
+ " (ou aponte OPENAI_BASE_URL para um servidor local)")
90
+ elif b == "gemini":
91
+ if not _gemini_key():
92
+ sys.exit("[!] GEMINI_API_KEY ausente. Pegue uma (com tier grátis) em "
93
+ "https://aistudio.google.com/apikey e rode:\n"
94
+ " wuptracker config api-key <sua-chave>")
95
+
96
+
97
+ def analyze_image(png_path, prompt, timeout=180):
98
+ """Analisa um screenshot. Retorna o texto bruto da resposta do modelo."""
99
+ b = _backend()
100
+ if b == "claude_cli":
101
+ return _cli_analyze_image(png_path, prompt, timeout)
102
+ img = _jpeg_b64(png_path)
103
+ if b == "anthropic":
104
+ return _anthropic_msg(prompt, img)
105
+ if b == "ollama":
106
+ return _ollama_chat(prompt, images=[img], timeout=timeout)
107
+ if b == "openai":
108
+ return _openai_chat(prompt, img_b64=img, timeout=timeout)
109
+ if b == "gemini":
110
+ return _gemini_generate(prompt, img_b64=img, timeout=timeout)
111
+ raise RuntimeError(f"backend não suportado: {b}")
112
+
113
+
114
+ def generate_text(prompt, timeout=600):
115
+ """Gera texto a partir de um prompt puro. Retorna o texto bruto."""
116
+ b = _backend()
117
+ if b == "claude_cli":
118
+ return _cli_generate_text(prompt, timeout)
119
+ if b == "anthropic":
120
+ return _anthropic_msg(prompt, None)
121
+ if b == "ollama":
122
+ return _ollama_chat(prompt, images=None, timeout=timeout)
123
+ if b == "openai":
124
+ return _openai_chat(prompt, img_b64=None, timeout=timeout)
125
+ if b == "gemini":
126
+ return _gemini_generate(prompt, img_b64=None, timeout=timeout)
127
+ raise RuntimeError(f"backend não suportado: {b}")
128
+
129
+
130
+ # ==========================================================================
131
+ # Helpers HTTP / imagem
132
+ # ==========================================================================
133
+ def _jpeg_b64(png_path):
134
+ from . import common
135
+
136
+ return common.png_to_jpeg_b64(png_path)
137
+
138
+
139
+ def _http_json(method, url, body=None, headers=None, timeout=120):
140
+ data = json.dumps(body).encode() if body is not None else None
141
+ req = urllib.request.Request(
142
+ url, data=data, method=method,
143
+ headers={"Content-Type": "application/json", **(headers or {})})
144
+ try:
145
+ with urllib.request.urlopen(req, timeout=timeout) as r:
146
+ return json.loads(r.read().decode())
147
+ except urllib.error.HTTPError as e:
148
+ detail = e.read().decode(errors="replace")[:400]
149
+ raise RuntimeError(f"HTTP {e.code}: {detail}")
150
+
151
+
152
+ # ==========================================================================
153
+ # Backend: claude CLI
154
+ # ==========================================================================
155
+ def _cli_base_cmd():
156
+ cmd = ["claude", "-p", "--output-format", "text"]
157
+ model = getattr(config, "CLAUDE_CLI_MODEL", None)
158
+ if model:
159
+ cmd += ["--model", model]
160
+ return cmd
161
+
162
+
163
+ def _cli_run(cmd, stdin_text, timeout):
164
+ proc = subprocess.run(cmd, input=stdin_text, capture_output=True,
165
+ text=True, timeout=timeout)
166
+ if proc.returncode != 0:
167
+ raise RuntimeError(f"claude CLI saiu com código {proc.returncode}: "
168
+ f"{(proc.stderr or proc.stdout).strip()[:500]}")
169
+ out = proc.stdout.strip()
170
+ if not out:
171
+ raise RuntimeError("claude CLI não retornou saída.")
172
+ return out
173
+
174
+
175
+ def _cli_analyze_image(png_path, prompt, timeout):
176
+ abs_png = os.path.abspath(png_path)
177
+ cmd = _cli_base_cmd() + ["--add-dir", os.path.dirname(abs_png)]
178
+ return _cli_run(cmd, f"{prompt}\n\nAnalise o screenshot no arquivo: {abs_png}",
179
+ timeout)
180
+
181
+
182
+ def _cli_generate_text(prompt, timeout):
183
+ return _cli_run(_cli_base_cmd(), prompt, timeout)
184
+
185
+
186
+ # ==========================================================================
187
+ # Backend: Anthropic API
188
+ # ==========================================================================
189
+ _client = None
190
+
191
+
192
+ def _anthropic_client():
193
+ global _client
194
+ if _client is not None:
195
+ return _client
196
+ try:
197
+ from anthropic import Anthropic
198
+ except ImportError:
199
+ sys.exit("[!] pacote 'anthropic' não instalado (backend 'anthropic'). "
200
+ "pip install anthropic")
201
+ key = os.environ.get("ANTHROPIC_API_KEY")
202
+ if not key:
203
+ sys.exit("[!] ANTHROPIC_API_KEY ausente. wuptracker config api-key <chave>")
204
+ _client = Anthropic(api_key=key)
205
+ return _client
206
+
207
+
208
+ def _anthropic_msg(prompt, img_b64):
209
+ content = []
210
+ if img_b64:
211
+ content.append({"type": "image", "source": {
212
+ "type": "base64", "media_type": "image/jpeg", "data": img_b64}})
213
+ content.append({"type": "text", "text": prompt})
214
+ max_tokens = (config.ANALYZE_MAX_TOKENS if img_b64
215
+ else config.GENERATE_MAX_TOKENS)
216
+ resp = _anthropic_client().messages.create(
217
+ model=config.CLAUDE_MODEL, max_tokens=max_tokens,
218
+ messages=[{"role": "user", "content": content}])
219
+ return "".join(b.text for b in resp.content if b.type == "text").strip()
220
+
221
+
222
+ # ==========================================================================
223
+ # Backend: Ollama (local)
224
+ # ==========================================================================
225
+ def _ollama_host():
226
+ return getattr(config, "OLLAMA_HOST", "http://localhost:11434").rstrip("/")
227
+
228
+
229
+ def _ollama_model():
230
+ return getattr(config, "OLLAMA_MODEL", "llava")
231
+
232
+
233
+ def _ollama_chat(prompt, images, timeout):
234
+ msg = {"role": "user", "content": prompt}
235
+ if images:
236
+ msg["images"] = images
237
+ body = {"model": _ollama_model(), "messages": [msg], "stream": False,
238
+ "options": {"temperature": 0.2}}
239
+ out = _http_json("POST", f"{_ollama_host()}/api/chat", body, timeout=timeout)
240
+ text = (out.get("message") or {}).get("content", "").strip()
241
+ if not text:
242
+ raise RuntimeError(f"Ollama não retornou conteúdo: {str(out)[:300]}")
243
+ return text
244
+
245
+
246
+ # ==========================================================================
247
+ # Backend: OpenAI-compatible (OpenAI, Groq, OpenRouter, LM Studio, llama.cpp…)
248
+ # ==========================================================================
249
+ def _openai_base():
250
+ return getattr(config, "OPENAI_BASE_URL",
251
+ "https://api.openai.com/v1").rstrip("/")
252
+
253
+
254
+ def _openai_model():
255
+ return getattr(config, "OPENAI_MODEL", "gpt-4o-mini")
256
+
257
+
258
+ def _openai_key():
259
+ return os.environ.get("OPENAI_API_KEY", "")
260
+
261
+
262
+ def _openai_chat(prompt, img_b64, timeout):
263
+ if img_b64:
264
+ content = [
265
+ {"type": "text", "text": prompt},
266
+ {"type": "image_url",
267
+ "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}},
268
+ ]
269
+ else:
270
+ content = prompt
271
+ body = {"model": _openai_model(),
272
+ "messages": [{"role": "user", "content": content}],
273
+ "temperature": 0.2}
274
+ headers = {}
275
+ if _openai_key():
276
+ headers["Authorization"] = f"Bearer {_openai_key()}"
277
+ out = _http_json("POST", f"{_openai_base()}/chat/completions",
278
+ body, headers, timeout)
279
+ try:
280
+ return out["choices"][0]["message"]["content"].strip()
281
+ except (KeyError, IndexError, TypeError):
282
+ raise RuntimeError(f"resposta inesperada do endpoint: {str(out)[:300]}")
283
+
284
+
285
+ # ==========================================================================
286
+ # Backend: Google Gemini
287
+ # ==========================================================================
288
+ def _gemini_key():
289
+ return os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY", "")
290
+
291
+
292
+ def _gemini_model():
293
+ return getattr(config, "GEMINI_MODEL", "gemini-2.0-flash")
294
+
295
+
296
+ def _gemini_generate(prompt, img_b64, timeout):
297
+ parts = [{"text": prompt}]
298
+ if img_b64:
299
+ parts.append({"inline_data": {"mime_type": "image/jpeg", "data": img_b64}})
300
+ body = {"contents": [{"parts": parts}],
301
+ "generationConfig": {"temperature": 0.2}}
302
+ url = ("https://generativelanguage.googleapis.com/v1beta/models/"
303
+ f"{_gemini_model()}:generateContent")
304
+ out = _http_json("POST", url, body,
305
+ headers={"x-goog-api-key": _gemini_key()}, timeout=timeout)
306
+ try:
307
+ cand = out["candidates"][0]
308
+ text = "".join(p.get("text", "") for p in cand["content"]["parts"])
309
+ if not text.strip():
310
+ raise RuntimeError(
311
+ f"Gemini retornou vazio (finishReason="
312
+ f"{cand.get('finishReason')}): {str(out)[:300]}")
313
+ return text.strip()
314
+ except (KeyError, IndexError, TypeError):
315
+ raise RuntimeError(f"resposta inesperada do Gemini: {str(out)[:300]}")
wuptracker/paths.py ADDED
@@ -0,0 +1,25 @@
1
+ """Caminhos padrão do sistema (XDG Base Directory), com fallback sensato.
2
+
3
+ - Config (config.json, .env): $XDG_CONFIG_HOME/wuptracker (~/.config/wuptracker)
4
+ - Dados (sessions/): $XDG_DATA_HOME/wuptracker (~/.local/share/wuptracker)
5
+ - Writeups: ./writeups no diretório onde o comando é chamado
6
+ (o writeup é um artefato do seu trabalho ali, não um dado global da ferramenta)
7
+ """
8
+
9
+ import os
10
+
11
+ CONFIG_DIR = os.path.join(
12
+ os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"),
13
+ "wuptracker")
14
+ DATA_DIR = os.path.join(
15
+ os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"),
16
+ "wuptracker")
17
+
18
+ CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
19
+ ENV_FILE = os.path.join(CONFIG_DIR, ".env")
20
+ SESSIONS_DIR = os.path.join(DATA_DIR, "sessions")
21
+
22
+
23
+ def ensure_dirs():
24
+ os.makedirs(CONFIG_DIR, exist_ok=True)
25
+ os.makedirs(DATA_DIR, exist_ok=True)