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/__init__.py +3 -0
- wuptracker/capture.py +342 -0
- wuptracker/cli.py +187 -0
- wuptracker/common.py +382 -0
- wuptracker/config.py +156 -0
- wuptracker/configtool.py +561 -0
- wuptracker/generate.py +282 -0
- wuptracker/llm.py +315 -0
- wuptracker/paths.py +25 -0
- wuptracker/profiles.py +293 -0
- wuptracker-0.1.0.dist-info/METADATA +442 -0
- wuptracker-0.1.0.dist-info/RECORD +15 -0
- wuptracker-0.1.0.dist-info/WHEEL +4 -0
- wuptracker-0.1.0.dist-info/entry_points.txt +2 -0
- wuptracker-0.1.0.dist-info/licenses/LICENSE +21 -0
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"", ""]
|
|
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)
|