Compare commits

..
1 Commits
Author SHA1 Message Date
evandro 86a4949751 wip: snapshot das alterações não commitadas (2026-10-20)
Origem: D:/Documents/Github/HealthTracking
Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 1
2026-10-20 00:49:37 -03:00
7 changed files with 467 additions and 133 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
{
"hud": {
"mode": "footer",
"position": "bottom-center",
"shortcut": "ctrl+shift+h",
"switchShortcut": "ctrl+.",
"minimizeShortcut": "ctrl+h",
"autoCompactWhileStreaming": true,
"startupNotification": true,
"usageDisplay": "metered",
"contextIndicator": "icon",
"expandedWidth": 42,
"compactWidth": 26,
"minTerminalWidth": 90,
"margin": {
"top": 1,
"right": 1,
"bottom": 1
},
"visibility": {
"context": true,
"project": true,
"worktrees": true,
"mcps": true
}
}
}
-9
View File
@@ -1,9 +0,0 @@
Licenca Proprietaria - Evandro Fonseca Junior (Custom)
1. Uso permitido: uso pessoal e de estudo, desde que os creditos ao autor sejam mantidos visiveis.
2. Proibido: redistribuicao, revenda, sublicenciamento ou uso comercial sem autorizacao expressa por escrito.
3. Uso comercial autorizado: mediante contrato, com repasse de 50% (cinquenta por cento) do valor bruto arrecadado ao autor, alem da manutencao dos creditos.
4. Sem garantias: o software e fornecido "como esta", sem garantias de qualquer tipo.
+46 -85
View File
@@ -1,123 +1,84 @@
<div align="center">
# FightRateRN
<img src="assets/readme/hero.svg" width="100%" alt="FightRate: heart-rate capture for fight training from a BLE chest strap, with a live BPM readout over the BASE, PRESSÃO and REDLINE zones">
React Native Community CLI rebuild of FightRate. This app is **bare React Native only** and does not use Expo.
![React Native 0.86.0](https://img.shields.io/badge/React_Native-0.86.0-E23B2E?style=flat-square)
![Android minSdk 24](https://img.shields.io/badge/Android-minSdk_24-E23B2E?style=flat-square)
![Node ≥ 22.11](https://img.shields.io/badge/Node-%E2%89%A5_22.11-E23B2E?style=flat-square)
![License: proprietary](https://img.shields.io/badge/license-proprietary-E23B2E?style=flat-square)
![Status: work in progress](https://img.shields.io/badge/status-work_in_progress-E23B2E?style=flat-square)
## Visual source of truth
[Design system](FIGHTRATE_DESIGN_SYSTEM.md) · [Literature notes](docs/data/pesquisa.md) · [Issues](https://git.evandro.dev.br/evandro/HealthTracking/issues)
The app follows the selected FightRate direction:
</div>
- `../FIGHTRATE_DESIGN_SYSTEM.md`
- Direction: `01 — Corner Instrument`
The BLE parser decoding one Heart Rate Measurement packet (flags `0x16`: skin contact detected, one RR interval). It is plain TypeScript, so Node runs it straight from a fresh clone, no install:
Do not reuse the deprecated Combat Lab/card-heavy direction.
```console
$ node --experimental-strip-types --no-warnings --input-type=module -e "import { parseHeartRateMeasurement as parse } from './src/ble/heartRateParser.ts'; console.log(parse([0x16, 0xA0, 0x6C, 0x01]))"
{
bpm: 160,
contactSupported: true,
contactDetected: true,
energyExpended: null,
rrIntervalsMs: [ 355.46875 ]
}
```
## Current slice
## What it is
Implemented in this first slice:
FightRate (repository `HealthTracking`) is an Android app that records heart rate continuously from a Bluetooth Low Energy chest strap during combat-sport training. It is a bare React Native app (no Expo): a Kotlin foreground service owns the BLE connection and stores every timestamped BPM sample in SQLite, and the React Native UI reads those samples. UI copy is PT-BR; code identifiers are English.
- Corner Instrument app shell.
- Continuous capture-first home screen.
- History, Sensor, and Insights screens.
- Centralized design tokens in `src/theme`.
- Reusable UI components in `src/components`.
- No Expo dependencies.
- Android `applicationId` aligned to `com.healthtracking.fightrate`.
| Tab | What it does |
|---|---|
| **Capture** | Live BPM, average / peak / sample-count tiles, BLE, battery and background status, and the training chart. Start/stop marks a **training window** inside the continuous stream; monitoring keeps storing samples either way |
| **History** | Saved training windows and continuous-usage blocks (split by gaps), each with average / peak / min BPM; open one to inspect the timestamped data; custom time range |
| **Sensor** | BLE scan, device list, connect, connection phase, signal (RSSI), battery level, BLE services/characteristics diagnostics and a shortcut to Android battery-optimization settings |
| **Insights** | Current/average BPM, range, variability, time in zones, load, heart-rate drift, post-session recovery, HRmax/HRR (marked as needing a profile) and HRV (only when the strap sends RR intervals) |
Not implemented yet:
## Why use it
- Real BLE Magene H003 integration.
- SQLite/local persistence.
- Real timestamped workout chart data.
- Navigation library.
- **Real data only.** Every number on screen is computed from samples a real sensor sent. With no samples the app shows an empty state instead of inventing a peak, zone, load or HRV. Samples outside 30–240 bpm, or reported without skin contact, are discarded.
- **Capture survives the screen turning off.** It runs in a foreground service of type `connectedDevice`, restarts as `START_STICKY`, reconnects with backoff (2 s → 5 s → 10 s → 30 s) and restores the last device after a restart.
- **Built for rounds, not steps.** Fixed zones `BASE <150`, `PRESSÃO 150–169`, `REDLINE 170+`, and a zoomable chart (pinch zoom, pan, tap-to-pin) whose ticks follow the visible span, from half-second to one-minute steps.
Do not use it if you need iOS (there is no native capture module there), zones from your own HRmax or resting heart rate (there is no athlete profile yet), or a signed build you can distribute (see [Project status](#project-status)).
## How it works
1. **BLE client**: `FightRateCaptureService.kt` scans, connects by known device id, subscribes to Heart Rate Measurement `2A37` (service `180D`), reads Battery Level `2A19` and RSSI. After 12 s without a valid sample it reports a stale state and waits for new real data.
2. **Parser**: `HeartRateMeasurementParser.kt` and `src/ble/heartRateParser.ts` read UInt8/UInt16 BPM, skin contact and RR intervals (converted from 1/1024 s to ms).
3. **Storage**: `FightRateCaptureStore.kt` writes `fightrate_rn_capture.db` (schema version 2) with training windows and continuous samples (timestamp, BPM, contact, RR intervals, device).
4. **Bridge**: `FightRateCaptureModule.kt` and `src/native/FightRateCaptureNative.ts` expose start/stop, connect, snapshots and samples, and turn service broadcasts into JS events.
5. **Domain**: pure TypeScript in `src/training/` computes metrics, zones, drift, recovery, usage blocks (a 5-minute gap starts a new block) and the chart viewport.
6. **UI**: a state-driven tab shell in `src/app/FightRateApp.tsx` (no navigation library), Reanimated 4 + Worklets animations and design tokens in `src/theme/`.
Design direction: [`FIGHTRATE_DESIGN_SYSTEM.md`](FIGHTRATE_DESIGN_SYSTEM.md) (**01 — Corner Instrument**, the official one). [`FIGHTRATE_COMBAT_LAB_DESIGN_SYSTEM.md`](FIGHTRATE_COMBAT_LAB_DESIGN_SYSTEM.md) is a deprecated direction kept for history. Specs and plans live in [`docs/superpowers/`](docs/superpowers).
## Quick start
Requirements: Node ≥ 22.11 (`engines` in `package.json`), the Android SDK and a JDK.
## Commands
```bash
git clone https://git.evandro.dev.br/evandro/HealthTracking.git
cd HealthTracking
npm install
npm run typecheck
npm run lint
npm test -- --runInBand
```
Run Metro:
```bash
npm run start # Metro
npm run android # run on a device/emulator
npm run start
```
Debug APK:
Run Android on a device/emulator:
```bash
npm run android
```
Build Android debug APK:
```bash
cd android
./gradlew assembleDebug
# output: android/app/build/outputs/apk/debug/app-debug.apk
```
> [!WARNING]
> **Grant the permissions and exempt the app from battery optimization.** The manifest requests Bluetooth scan/connect, fine location, notifications and a foreground service of type `connectedDevice`. The **Bateria** button on the Sensor tab opens Android's battery-optimization settings (`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` is declared) so background capture isn't throttled.
If Gradle reports `No Java compiler found`, point `JAVA_HOME` to the JBR bundled with Android Studio:
If Gradle reports `No Java compiler found`, point `JAVA_HOME` to Android Studio JBR:
```bash
export JAVA_HOME="<Android Studio install dir>/jbr"
export JAVA_HOME="/c/Users/Evandro/AppData/Local/Programs/Android Studio/jbr"
export PATH="$JAVA_HOME/bin:$PATH"
cd android
./gradlew assembleDebug
```
If React Native autolinking keeps a stale package after renaming Android package paths, clean the generated Gradle caches before rebuilding:
If React Native autolinking keeps a stale package after renaming Android package paths,
clean generated Gradle caches before rebuilding:
```bash
cd ..
rm -rf android/.gradle android/app/build android/build .gradle
cd android && ./gradlew assembleDebug
cd android
./gradlew assembleDebug
```
## Project status
Debug APK output:
Work in progress: `package.json` is `0.0.1`, Android `versionName` is `1.0`, `applicationId` is `com.healthtracking.fightrate`. The last feature work (2026-06-16) shipped the "Instrumento Forte" UI refinement; its record in [`.impeccable.md`](.impeccable.md) lists `npm run typecheck` with 0 errors, `npm test -- --runInBand` with 74/74 passing, and `npm run lint` failing on a pre-existing `jest/globals` configuration error.
Known limits:
- **Android only**: capture exists only in the Kotlin native module; the `ios/` project has no equivalent.
- **No athlete profile yet**: zones use the fixed thresholds above; `%HRmax` zones, Karvonen and full TRIMP are shown as needing a profile. The app doesn't invent HRmax or resting heart rate.
- **HRV needs RR intervals**: without them the dashboard says so instead of estimating.
- **Release signing**: the `release` build type uses the **debug keystore** (`android/app/build.gradle`); generate your own keystore before distributing.
The literature notes in [`docs/data/pesquisa.md`](docs/data/pesquisa.md) state that population tables and formula-based estimates are not a substitute for measured testing.
## License
Proprietary license in [`LICENSE`](LICENSE): personal and study use only, with visible credit to the author. Redistribution, resale, sublicensing and commercial use need written authorization; authorized commercial use is by contract, with 50% of gross revenue paid to the author. Provided "as is", without warranty.
Built on [React Native](https://reactnative.dev/), [Reanimated](https://docs.swmansion.com/react-native-reanimated/), [Gesture Handler](https://docs.swmansion.com/react-native-gesture-handler/), [react-native-svg](https://github.com/software-mansion/react-native-svg), [Lucide](https://lucide.dev/) icons and [JetBrains Mono](https://www.jetbrains.com/lp/mono/).
<div align="center">
<sub>Made by <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
</div>
```text
android/app/build/outputs/apk/debug/app-debug.apk
```
-39
View File
@@ -1,39 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
<title id="title">FightRate</title>
<desc id="desc">FightRate records heart rate from a BLE chest strap during fight training. On the right, a live readout of 160 BPM in the PRESSÃO zone over a session trace that crosses the 150 and 170 BPM thresholds, decoded from the Heart Rate Measurement packet 16 A0 6C 01.</desc>
<rect width="1200" height="420" rx="24" fill="#070605"/>
<g id="title-block" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<text x="64" y="104" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="20" fill="#B9A98E">HealthTracking · Android</text>
<text x="64" y="186" font-size="72" font-weight="700" fill="#F8EAD8">FightRate</text>
<text x="64" y="246" font-size="28" fill="#F8EAD8">Heart-rate capture for fight training</text>
<text x="64" y="282" font-size="28" fill="#F8EAD8">from a BLE chest strap. Real data only.</text>
<text x="64" y="344" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="20" fill="#B9A98E">React Native 0.86 · Kotlin · SQLite</text>
</g>
<g id="project-proof" font-family="ui-monospace, 'SF Mono', Consolas, monospace">
<rect x="632" y="40" width="512" height="340" rx="16" fill="#12100C" stroke="#392D1E" stroke-width="2"/>
<circle cx="672" cy="80" r="7" fill="#E23B2E"/>
<text x="688" y="86" font-size="18" fill="#B9A98E">LIVE</text>
<text x="664" y="150" font-size="60" font-weight="700" fill="#F8EAD8">160</text>
<text x="784" y="150" font-size="20" fill="#B9A98E">BPM</text>
<rect x="976" y="62" width="136" height="36" rx="18" fill="#FFB43D"/>
<text x="1044" y="87" font-size="18" font-weight="700" fill="#070605" text-anchor="middle">PRESSÃO</text>
<g id="zones" font-size="18">
<line x1="664" y1="216" x2="1068" y2="216" stroke="#E23B2E" stroke-width="2" stroke-dasharray="6 6"/>
<line x1="664" y1="256" x2="1068" y2="256" stroke="#FFB43D" stroke-width="2" stroke-dasharray="6 6"/>
<line x1="664" y1="316" x2="1068" y2="316" stroke="#392D1E" stroke-width="2"/>
<text x="1112" y="222" fill="#E23B2E" text-anchor="end">170</text>
<text x="1112" y="262" fill="#FFB43D" text-anchor="end">150</text>
</g>
<polyline fill="none" stroke="#F8EAD8" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"
points="664,304 700,294 736,276 772,252 808,258 844,234 880,208 916,198 952,224 988,246 1024,240 1060,236"/>
<circle cx="1060" cy="236" r="7" fill="#FFB43D"/>
<text x="664" y="354" font-size="20" fill="#B9A98E">2A37 · 16 A0 6C 01 → 160 bpm</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

+288
View File
@@ -0,0 +1,288 @@
# Code Review — whisper-mcp
**Escopo:** `C:\Users\Evandro\.pi\agent\mcp-servers\whisper\`
**Arquivos:** `server.py` (1127), `parakeet_bootstrap.py` (332), `tests/test_parakeet_bootstrap.py` (237), `cli/whisper` (113)
**Lint/Build:** `py_compile` OK · `pytest 26 passed` · sem `git` no diretório
---
## CRITICAL
### C1 · SSRF bypass via HTTP redirect — `server.py:832`
```python
with urllib.request.urlopen(url, timeout=TIMEOUT) as resp:
```
`urllib.request.urlopen` segue `30x` por padrão. Atacante serve URL pública que retorna `302 → http://127.0.0.1:8000/...` e a checagem `_is_private_host` (linha 382) é contornada. `_validate_url` só olha a URL inicial, não a cadeia de redirects.
**Fix:** montar `OpenerDirector` com `HTTPRedirectHandler` que revalida cada destino (ou `no-redirect`), ou extrair IP canônico antes de cada hop.
### C2 · SSRF via DNS rebinding (TOCTOU) — `server.py:333-388`, `:832`
`_is_private_host` resolve o host **uma vez** (linha 354) e libera. Entre o check e `urlopen`, o atacante controla TTL baixo e devolve IP público no primeiro resolve e `127.0.0.1` no segundo (dentro do mesmo `urlopen`).
**Fix:** resolver o IP antes do request, **abrir socket manualmente** conectando ao IP literal e usar `Host:` header — ou usar `requests` + `requests-ossrf` / `httpx` com hook de validação por hop. Mínimo: revalidar o IP em cada redirect.
### C3 · `sh -c` com pipe para URL externa, sem verificação — `parakeet_bootstrap.py:124`
```python
_run(["sh", "-c", "curl -fsSL https://get.docker.com | sh"], timeout=600)
```
- Não usa `shell=False` (mesmo com lista, passa pelo `sh -c`).
- Não valida TLS pinning / assinatura / checksum.
- Executa como root via `sudo systemctl start docker` (linha 127).
- Se o DNS do `get.docker.com` for sequestrado → RCE.
**Fix:** baixar o script primeiro para arquivo, comparar SHA-256 contra valor conhecido, só então executar. Ou usar pacotes nativos do gerenciador de pacotes da distro.
### C4 · Dead code corrompe arquivo do usuário — `server.py:996`
```python
out.write_bytes(pcm) # raw PCM, will be wrapped below
# Write proper WAV
wav_path = _write_wav_to(pcm, sample_rate, out)
```
A primeira escrita é PCD cru (não-WAV); a segunda sobrescreve com WAV válido — porém:
- Se `_write_wav_to` falhar, o arquivo fica como PCD cru no disco do usuário (corrompido).
- Janela de race em que terceiros leriam o arquivo.
**Fix:** remover a linha 996 inteira. `_write_wav_to` já cria o arquivo.
### C5 · FD leak em `tempfile.mkstemp` — `server.py:499`, `:932`
```python
out_path = Path(tempfile.mkstemp(suffix=".wav", prefix="whisper-vad-")[1])
path = Path(tempfile.mkstemp(suffix=".wav", prefix="whisper-rec-")[1])
```
`mkstemp` retorna `(fd, path)`. O `[1]` descarta o `fd`, que vaza (até o GC fechar). Em loops de gravação contínua pode esgotar FDs.
**Fix:** usar `tempfile.NamedTemporaryFile(delete=False, suffix=".wav")` ou `os.close(fd)` antes de descartar.
---
## IMPORTANT
### I1 · Cobertura de testes = 0% em `server.py` (1127 linhas)
Os 26 testes cobrem **apenas** `parakeet_bootstrap.py`. Nenhum teste para: `health`, `transcribe_file`, `transcribe_url`, `record_audio`, `_vad_trim_wav`, `_is_private_host`, `_validate_url`, `LRUCache`, `Metrics`, `WhisperError`, `_post_multipart`. O caminho de maior valor (transcrição + SSRF + cache) está intocado.
**Fix:** mínimo — testes para `_is_private_host` (loopback, IPv6 mapped, IPv4-mapped IPv6, getaddrinfo com múltiplos IPs), `_validate_url` (file://, ftp://, redirect), `_cache_key`, `LRUCache.put` (eviction), `Metrics.snapshot`. Testes de integração marcados como `slow` e gated.
### I2 · `transcribe_url` não usa cache — `server.py:807-879`
O docstring do módulo (linha 21-24) promete "cache LRU local ... evita re-transcrever mesmo áudio". URL baixada é re-transcrita toda vez — `cache_hits`/`cache_misses` não são chamados no path de URL. Comportamento documentado ≠ comportamento real.
**Fix:** hashear `data_bytes` (ou do `tmp_path`) e gravar entrada de cache no fim do path de URL, com `source`=URL.
### I3 · Default port documentado diverge do código — `server.py:35` vs `:92`
```python
# docstring linha 35:
WHISPER_BASE_URL (default ``http://localhost:8000``)
# código linha 92:
BASE_URL = os.environ.get("WHISPER_BASE_URL", "http://localhost:5092").rstrip("/")
```
8000 (Whisper) vs 5092 (Parakeet) — duas fontes da verdade dessincronizadas. README (a refazer) também pode estar errado.
**Fix:** padronizar; de preferência carregar o default de um único constante e fazer o docstring apontar para ele.
### I4 · `logging.basicConfig` no import do módulo — `server.py:83`
```python
logging.basicConfig(level=os.environ.get("WHISPER_LOG_LEVEL", "INFO"), ...)
```
Quem importa `server` (incluindo `cli/whisper`, testes, ferramentas) tem o root logger reconfigurado. O `basicConfig` do chamador (ex.: `parakeet_bootstrap.main` linha 312) é silenciosamente ignorado. Anti-pattern bem conhecido.
**Fix:** remover `basicConfig` daqui; deixar o chamador (entry point) configurar, ou usar `logger.setLevel()` local.
### I5 · Health infla métrica `requests_success` antes de checar — `server.py:635`
```python
def health() -> dict[str, Any]:
METRICS.record_request("health", success=True) # otimismo prematuro
payload = {}
for path in ("/v1/models", "/health"):
try: ...; except: continue
if not payload:
METRICS.record_request("health", success=False) # conta 2 requests!
```
Incrementa `requests_total` duas vezes (uma com `success=True` e outra com `False`) em falha. Comparado a `transcribe_file` que faz o oposto. Inconsistência.
**Fix:** não chamar `record_request` na entrada; decidir uma vez no fim.
### I6 · Duplicação `_write_wav` / `_write_wav_to` — `server.py:930` e `:1033`
Quase idênticos; diferença real é só o destino do `wave.open`. `_write_wav_to` ainda hardcoda `nchannels=1` (linha 1037) enquanto `_write_wav` aceita o param (linha 935) — `record_audio` não propaga `channels`.
**Fix:** uma única função `_write_wav(pcm, sr, channels, dest: Path | None = None)`.
### I7 · Three `except Exception` sem narrowing — `server.py:764`, `:872`, `:984`
Engole `KeyboardInterrupt`, `SystemExit`, `MemoryError`. `# noqa: BLE001` é aceito mas mascara bugs reais. Em `record_audio` linha 984, o broad catch esconde erros de configuração do PyAudio.
**Fix:** capturar `OSError`, `wave.Error`, `pyaudio.PyAudioError` (após import) e deixar `KeyboardInterrupt` subir.
### I8 · `sudo systemctl ...` silencioso — `parakeet_bootstrap.py:127-128`
```python
_run(["sudo", "systemctl", "start", "docker"], check=False, timeout=30)
_run(["sudo", "enable", "docker"], check=False, timeout=30)
```
Se sudo pedir senha e o usuário não conseguir, falha silenciosa — próximo `_docker_daemon_running()` lança `RuntimeError` sem contexto. Idem para `winget install` (linha 107) com timeout 600s sem feedback.
**Fix:** checar `returncode` e emitir mensagem acionável (ex.: "senha sudo necessária; rode manualmente").
### I9 · `_is_private_host` usa só primeira resposta do `getaddrinfo` indiretamente — `server.py:354`
```python
infos = socket.getaddrinfo(host, None)
for info in infos:
ip_str = info[4][0]
```
Percorre todos os IPs do `getaddrinfo` (OK), mas se a chamada for lenta ela acontece em todo `transcribe_url`. Pode cachear por `host` (com TTL curto) — performance, não correção.
**Fix:** opcional, `@functools.lru_cache(maxsize=1024)` com TTL via `time.monotonic`. Apenas nice-to-have.
### I10 · `parakeet_bootstrap` mistura `Optional`/`List` com PEP 604 — `parakeet_bootstrap.py:20`
`from __future__ import annotations` está presente, então `Optional[str]` é coerente com `str | None` em server.py. Não é bug, mas inconsistência estilística.
**Fix:** decidir um estilo. `list[str]`, `str | None` é o padrão moderno.
### I11 · `cache_key` lê o arquivo inteiro do disco para cada request — `server.py:624`
```python
def _cache_key(...): data = file_path.read_bytes()
```
Para um WAV de 25 MB a cada chamada, mesmo se o cache for hit. Aceitável para hit (mas é miss porque hit é verificado **antes** — então ok), porém no caminho de miss é relido e ainda reenviado em `_post_multipart` (linha 538). O `_post_multipart` poderia receber `data` cacheado em memória em vez de re-ler do disco.
**Fix:** opcional — passar bytes em vez de path.
### I12 · TOCTOU em `transcribe_file` — `server.py:700-716`
```python
file_path = Path(path).expanduser().resolve()
if not file_path.is_file(): ...
# mais tarde:
data = file_path.read_bytes()
```
Janela de race entre `is_file()` e `read_bytes()`. Impacto baixo (input do operador), mas é padrão. Também: `Path.resolve()` em Windows pode quebrar em symlinks de rede.
**Fix:** opcional; abrir e validar com `try: open(...)` direto.
### I13 · Limite hardcoded de 600s em `_record_audio_pyaudio` — `server.py:901`
Magic number. `record_audio` (linha 949) aceita `seconds` mas passa a um validador de teto de 600. Inconsistente com o resto do módulo que prefere env-vars.
**Fix:** `WHISPER_RECORD_MAX_SECONDS` env var.
### I14 · Magic numbers no VAD — `server.py:428, :454, :472, :482`
`0.030` (janela 30ms), `32767.0` (peak int16), `30` (ms hardcoded para janela) aparecem em vários lugares. O valor `30` é duplicado nas linhas 472, 475, 482 (são ms-por-janela). Refatorar para `WINDOW_MS = 30` constante.
**Fix:** extrair constantes.
### I15 · Subprocess `check=True` em comandos idempotentes — `parakeet_bootstrap.py:97, :166, :187, :195`
Para `docker inspect` (existência), falha com `CalledProcessError` é informação, não erro. O código captura certo, mas usar `subprocess.CompletedProcess` com `check=False` e checar `returncode` é mais barato e idiomático.
**Fix:** cosmético.
---
## MINOR
### M1 · Comentários-mortos imports — `parakeet_bootstrap.py:12, :19`
```python
import logging
# (os removido)
import platform
# (Path removido)
```
**Fix:** deletar.
### M2 · `pass # OK` — `parakeet_bootstrap.py:273`
```python
if _is_windows() or _linux_distro() is not None or platform.system() == "Linux":
pass # OK
```
Lógica tautológica: as três condições equivalem a `platform.system() in {"Windows","Linux"}` — a chamada de `_linux_distro()` é gratuita e a última condição é estritamente mais ampla. Refatorar.
### M3 · `_emit` ramifica `if cb: ... if not cb: ...` — `parakeet_bootstrap.py:46-54`
Pode ser linear: `if cb: try call; else: log`. Reduz duas branches.
### M4 · `LRUCache.put` log "cache evicted" não remove arquivo — `server.py:306`
Atualmente só remove do índice; se evoluir para cachear áudio no disco, o log vira mentira. Documentar ou remover até a evolução.
### M5 · `_validate_url` retorna `dict | None` — `server.py:375`
Caller precisa de `if err := _validate_url(...)`. Mais idiomático levantar `WhisperError` ou usar `Result`. Cosmético.
### M6 · `record_audio` sem `transcribe_file`-style error handler unificado — `server.py:948-1030`
Três tools com três padrões diferentes de captura de métrica + error wrapping (linhas 697, 814, 969). Considerar decorator `@track_metrics("tool_name")` que envolve a chamada.
**Fix:** refactor — único `with metrics.track("tool"):` helper.
### M7 · `health` expõe `default_model` e `base_url` mesmo em falha — `server.py:648-654`
Em ok=False não há razão para esconder, mas considere suprimir `models: []` vazio (linha 671) — fica confuso.
### M8 · `clamp` ausente em `sample_rate` — `server.py:954, :980`
`record_audio(sample_rate=0)` passa direto para `pyaudio.open(rate=0)` que retorna erro não-amigável. Validar `sample_rate > 0` e ≤ 48000.
### M9 · `cli/whisper` sem shebang funcional cross-platform — `cli/whisper:1`
`#!/usr/bin/env python3` não funciona com o launcher `.exe`/`.bat` em Windows. Já que o README vai ser refeito, mencionar uso via `python cli/whisper`.
### M10 · Teste `test_linux_distro_*` mocka `builtins.open` globalmente — `tests/test_parakeet_bootstrap.py:38-51`
Em Python 3.12+ `pathlib.Path` usa `io.open` que pode não ser capturado por `builtins.open` dependendo do monkeypatch. Hoje funciona, mas é frágil. Use `mock.patch("builtins.open", ...)` com `create=True` ou `mock.patch.object(pb, "open", ...)`.
### M11 · Falta teste de `_wait_healthy` (timeout) — `tests/test_parakeet_bootstrap.py`
A função existe (linha 224) mas não tem teste para o caso de timeout — exatamente o cenário de maior risco de produção.
### M12 · Cache não tem método `close()` / cleanup de temp — `server.py:254`
Se processo for SIGKILL, `index.json` pode ficar corrompido. Adicionar `try/except` no write (já tem) e validar JSON no load (já tem). OK, mas considerar `fcntl.flock` no índice para multi-processo.
### M13 · Sem `__all__` em nenhum módulo
Não é bug, mas o módulo `server` exporta ~15 símbolos públicos. Adicionar `__all__` ajuda tooling e leitores.
### M14 · Tipos: `dict` sem parametrização — `server.py:636`, `:213`
`payload: dict = {}` em vez de `dict[str, Any]`. Idem `result: dict = {}`. `snapshot() -> dict[str, Any]` está ok. Cosmético.
### M15 · `_emit` engole exception do callback sem logar — `parakeet_bootstrap.py:50`
```python
except Exception: # pragma: no cover
pass
```
Pelo menos um `logger.debug` aqui para diagnóstico. Está silencioso.
---
## Veredito
**NÃO pronto para próxima fase** sem corrigir C1–C5 (1-2 horas de trabalho total). A base de código é sólida e idiomática, mas tem um punhado de buratos críticos de segurança (SSRF redirect + DNS rebinding, RCE via curl|sh) e um bug de corrupção de arquivo que pode perder dados do usuário.
**Prioridades de correção:**
1. **C1 + C2** (SSRF) — mesma PR; o `_validate_url` precisa virar um guard revalidado por hop.
2. **C3** (curl|sh) — substituir por download → checksum → exec, ou apenas instruções manuais.
3. **C4** (dead write) — one-liner, remover linha 996.
4. **C5** (FD leak) — `os.close(fd)` ou trocar API.
5. **I1** (testes do server) — bloquear próxima fase sem pelo menos testes de `_is_private_host` e `_post_multipart` mockado.
**Não-bloqueantes (I2–I15):** podem entrar como follow-ups em PRs separados, idealmente junto com I6 (DRY) e I5 (métricas).
**Boas práticas que vale destacar:**
- Thread-safety em `Metrics` e `LRUCache` está correta (lock por seção crítica).
- Retry com backoff exponencial no 5xx (linha 590).
- `TemporaryDirectory` com cleanup automático em `transcribe_url` (linha 829).
- Idempotência real do `parakeet_bootstrap` (testada e correta).
- Tipagem progressiva (`from __future__ import annotations`, `dict[str, Any]`).
- Tratamento de `urllib.error` separado para `HTTPError` vs `URLError`.
+42
View File
@@ -0,0 +1,42 @@
"""Debug mcp.json by counting braces"""
import sys
with open('C:/Users/Evandro/.pi/agent/mcp.json', 'rb') as f:
raw = f.read().decode('utf-8')
# Count braces not in strings
depth = 0
in_string = False
escape = False
for i, c in enumerate(raw):
if escape:
escape = False
continue
if c == '\\':
escape = True
continue
if c == '"':
in_string = not in_string
continue
if in_string:
continue
if c == '{':
depth += 1
elif c == '}':
depth -= 1
print(f'Final depth: {depth}')
# Find chrome-devtools and check if its block is properly closed
idx_cd = raw.find('"chrome-devtools"')
print(f'chrome-devtools at: {idx_cd}')
# Show the block from chrome-devtools
end = raw.find('"id": "speaches"', 0)
if end == -1:
end = len(raw)
# Find where the whisper block ENDS
idx_w = raw.find('"whisper"')
print(f'whisper at: {idx_w}')
# Show lines around line 140
lines = raw.split('\n')
for i in range(max(0, 135), min(len(lines), len(lines))):
print(f'{i+1}: {lines[i]}')
+64
View File
@@ -0,0 +1,64 @@
"""Just rewrite the mcp.json cleanly with the new whisper path"""
import json
import os
import sys
path = 'C:/Users/Evandro/.pi/agent/mcp.json'
with open(path, 'r', encoding='utf-8') as f:
raw = f.read()
# Find the whisper block
start_marker = '"whisper": {'
start_idx = raw.find(start_marker)
if start_idx == -1:
print("Not found")
sys.exit(1)
# Find the matching closing brace
depth = 0
i = start_idx + len(start_marker) - 1
in_string = False
escape = False
while i < len(raw):
c = raw[i]
if escape:
escape = False
i += 1
continue
if c == chr(92): # backslash
escape = True
i += 1
continue
if c == '"':
in_string = not in_string
i += 1
continue
if in_string:
i += 1
continue
if c == '{':
depth += 1
elif c == '}':
depth -= 1
if depth == 0:
end_idx = i + 1
break
i += 1
else:
print("No matching close brace found")
sys.exit(1)
print(f"start={start_idx}, end={end_idx}")
new_whisper = '"whisper": {\n "command": "C:\\\\Users\\\\Evandro\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe",\n "args": [\n "D:/Documents/GitHub/whisper-mcp/server.py"\n ],\n "description": "Whisper transcription via local Speaches/Whisper (faster-whisper-large-v3-turbo). v2.0 with SSRF guard, size limit, retry, structured errors.",\n "timeout": 180,\n "lifecycle": "lazy",\n "idleTimeout": 10,\n "directTools": true,\n "environment": {\n "WHISPER_BASE_URL": "http://localhost:8000",\n "WHISPER_MODEL": "deepdml/faster-whisper-large-v3-turbo-ct2",\n "WHISPER_TIMEOUT": "120",\n "WHISPER_MAX_BYTES": "26214400"\n }\n }'
new_raw = raw[:start_idx] + new_whisper + raw[end_idx:]
try:
parsed = json.loads(new_raw)
print("JSON valid")
with open(path, 'w', encoding='utf-8') as f:
f.write(new_raw)
print("Saved")
except json.JSONDecodeError as e:
print(f"Still invalid: {e}")
start = max(0, e.pos - 100)
end = min(len(new_raw), e.pos + 100)
print(f"Context: {new_raw[start:end]!r}")