# leitor-mini

Servico HTTPS "leitor" que corre no mac mini (ruipedro@10.0.30.231), atras de
firewall restrito, e expoe OCR (Apple Vision, binario `~/ocr/ocr` ja
existente) e analise por Qwen (LM Studio local) para os documentos do
processo de admissao. Contrato completo em
`/Volumes/www/assistente/docs/06-CONTRATO-LEITOR-MINI.md` e decisoes em
`07-DECISOES-FASE0.md`.

Nunca recebe caminhos de ficheiro -- so bytes + um `id` opaco. Nada persiste:
os temporarios sao apagados sempre em `finally`.

## Instalacao (no mini, sem sudo)

```bash
# 1. Python via Homebrew (brew ja existe no mini)
/opt/homebrew/bin/brew install python@3.13

# 2. Codigo copiado do Mac por rsync para ~/leitor (ver secao "Deploy" abaixo)

# 3. venv + dependencias
cd ~/leitor
/opt/homebrew/bin/python3.13 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt

# 4. Certificado autoassinado (CN=10.0.30.231, SAN IP)
mkdir -p ~/leitor/certs
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout ~/leitor/certs/leitor.key \
  -out ~/leitor/certs/leitor.crt \
  -days 825 \
  -subj "/CN=10.0.30.231" \
  -addext "subjectAltName=IP:10.0.30.231"

# 5. Token: gerado automaticamente na primeira arranque em ~/leitor/.env
#    (LEITOR_TOKEN=...), ou manualmente com:
openssl rand -hex 32   # colar em ~/leitor/.env como LEITOR_TOKEN=<valor>

# 6. Diretorio de logs
mkdir -p ~/leitor/logs

# 7. launchd
mkdir -p ~/Library/LaunchAgents
cp pt.segunor.leitor.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/pt.segunor.leitor.plist
launchctl kickstart -k gui/$(id -u)/pt.segunor.leitor
```

## Deploy (a partir deste Mac)

O codigo-fonte vive em `/Volumes/www/assistente/leitor-mini/` (este
diretorio). Para copiar para o mini:

```bash
export COPYFILE_DISABLE=1
rsync -a --exclude '.venv' --exclude 'certs' --exclude 'logs' --exclude '.env' \
  /Volumes/www/assistente/leitor-mini/ ruipedro@10.0.30.231:~/leitor/
```

Nao apagar `certs/`, `.env` nem `logs/` no destino -- ficam so no mini,
gerados la (o `.env` tem o token, nunca deve viajar pelo rsync a partir da
fonte porque a fonte nao o tem).

Depois de copiar codigo novo, reiniciar o servico:

```bash
ssh ruipedro@10.0.30.231 "launchctl kickstart -k gui/\$(id -u ruipedro)/pt.segunor.leitor"
```

## Endpoints (prefixo `/v1`)

Todos exigem `Authorization: Bearer <token>` (token em `~/leitor/.env` no
mini, nunca no repositorio).

- `GET /v1/saude` -- estado do LM Studio, modelo carregado, RAM (`vm_stat`),
  versao do servico, tamanho da fila (0 ou 1).
- `POST /v1/ocr` -- multipart `ficheiro` + `id`. Corre o binario `~/ocr/ocr`
  num temporario `0700` e devolve o JSON tal e qual (mais `id`).
- `POST /v1/analisar-documento` -- multipart `ficheiro`, `id`, `tipo`
  (`cartao_psp`, `cc_frente`, `cc_verso`, `cc_digital`, `rc`, `morada`,
  `iban`). Envia a imagem (ou paginas do PDF, max. 30) ao Qwen com o prompt
  fechado de `prompts.py` e valida a resposta contra `schemas/<tipo>.json`.
  Devolve `{id, tipo, modelo, dados, bruto_valido}`.
- `POST /v1/comparar` -- JSON `{campo, valor_a, valor_b}` -> `{equivalente,
  motivo}`. So texto, nunca imagens.
- `POST /v1/contrato/paginar-pdf` -- multipart `ficheiro` (.docx) + `id`.
  Pagina o contrato com o Microsoft Word e exporta o PDF final. Ver secao
  "Paginacao/exportacao de contratos (Word)" abaixo e
  `/Volumes/www/assistente/docs/06-CONTRATO-LEITOR-MINI.md`.

## Limites e seguranca

- Ficheiro maximo 25 MB; PDF maximo 30 paginas; timeout 180 s por pedido.
- MIME verificado por assinatura binaria (magic bytes), nao por extensao:
  jpeg, png, heic, tiff, pdf. Qualquer outro -> `422`.
- Um pedido de cada vez ao Qwen (`asyncio.Lock`); OCR nao tem esse limite
  (motor local, Vision).
- HTTPS com certificado autoassinado; o servico ouve exclusivamente em
  `10.0.30.231:8090` (nunca `0.0.0.0` nem so `localhost`).
- Logs sem conteudo de documentos -- so `id`, `tipo`, tempos e codigos.
- Temporarios em diretorio `0700`, apagados sempre em `finally`.

## PASSO MANUAL -- firewall `pf` (precisa de sudo, nao feito por esta skill)

O servico ouve em `10.0.30.231:8090` mas nao ha firewall a filtrar quem lhe
chega. Falta restringir a porta 8090 aos IPs do CT assistente (dev e prod):
`192.168.69.150` (dev) e a rede `10.0.30.0/24` (ou o IP especifico do CT
assistente em prod, a confirmar).

Passos sugeridos (a correr manualmente no mini, com sudo):

```bash
sudo tee /etc/pf.anchors/leitor <<'EOF'
table <leitor_permitidos> { 192.168.69.150, 10.0.30.0/24 }
block in proto tcp from any to 10.0.30.231 port 8090
pass in proto tcp from <leitor_permitidos> to 10.0.30.231 port 8090
EOF

sudo tee -a /etc/pf.conf <<'EOF'
anchor "leitor"
load anchor "leitor" from "/etc/pf.anchors/leitor"
EOF

sudo pfctl -f /etc/pf.conf
sudo pfctl -e   # se o pf ainda nao estiver ativo
```

Confirmar a lista de IPs exata do CT assistente (dev e prod) antes de
aplicar -- os valores acima sao os conhecidos a 2026-09-22, a confirmar.

## Paginacao/exportacao de contratos (Word)

`POST /v1/contrato/paginar-pdf` reaproveita a logica de paginacao dinamica da
skill `fazer-contrato` (Mac principal) para acabar de arrumar um `.docx` de
contrato ja gerado (o CT trata da paginacao estatica e do preenchimento; este
endpoint so trata da parte que precisa mesmo do Word) e exportar o PDF final.

- Modulos copiados de `~/.claude/skills/fazer-contrato/paginacao.py`
  (historico 02-04/09/2026) para `leitor-mini/contratos/paginacao.py`, sem
  alteracoes de logica -- so o caminho `WORD_TMP` muda (subpasta propria
  `leitor-mini` dentro do contentor do Word, para nunca colidir com a
  `fazer-contrato` da skill).
- Requisitos: **Microsoft Word tem de estar aberto e com sessao iniciada** no
  mini. O endpoint tenta abri-lo (`open -a "Microsoft Word"`) se nao estiver a
  responder e espera ate 30s; se continuar indisponivel, devolve `503` (o CT
  faz fallback para LibreOffice).
- Lock proprio (`WORD_LOCK`, `asyncio.Lock`), separado do `QWEN_LOCK`: um
  pedido de paginacao/exportacao de cada vez, independente da fila do Qwen.
- Limite do ficheiro: 10 MB. MIME verificado por conteudo (zip com
  `word/document.xml`), nunca por extensao. Timeout do pedido: 180s.
- O `.docx` recebido e copiado para
  `~/Library/Containers/com.microsoft.Word/Data/tmp/leitor-mini/` (0700) --
  **a unica pasta que o Word abre por AppleScript sem pedir autorizacao de
  cada vez**; fora dela (ex. `/private/tmp`) o Word devolve o erro `-1708`.
  Os temporarios (`.docx` e `.pdf`) sao sempre apagados em `finally`.
- Resposta: `{id, docx_base64, pdf_base64, paginas, correcoes: [...],
  word_versao, tempo_s}`. `docx_base64` e o `.docx` ja arrumado pela
  paginacao dinamica (paragrafos vazios no topo de pagina removidos, pagina
  em branco antes do ANEXO I quando calhava em pagina par); `correcoes` e o
  log dessas operacoes mais avisos de titulo orfao, se algum.

### PASSO MANUAL -- Acesso Total ao Disco (TCC) para o launchd controlar o Word

Confirmado por teste real (23/09/2026 -> escrito aqui a 24/09/2026):

- **A automacao do Word por AppleScript ja funciona** sob o launchd (o
  `GET /v1/saude` do servico ja devolve `"word":"disponivel"` mesmo corrido
  pelo LaunchAgent -- nao houve pedido de autorizacao de Automacao).
- **O acesso do proprio Python ao contentor do Word falha sob o launchd**: a
  primeira chamada real a `/v1/contrato/paginar-pdf` pelo servico do
  LaunchAgent devolveu `500` com `PermissionError: [Errno 1] Operation not
  permitted` a criar/alterar permissoes da pasta
  `~/Library/Containers/com.microsoft.Word/Data/tmp/leitor-mini`. Reproduzido
  isoladamente com um LaunchAgent de teste (mesma falha) e confirmado que
  correndo o mesmo codigo por SSH (sessao interativa ou comando remoto,
  `ssh ruipedro@10.0.30.231 ...`) **funciona sem problema** -- a sessao SSH ja
  tem essa permissao concedida, o job do launchd nao.
- **Falta conceder "Acesso Total ao Disco" ao binario Python do venv** para o
  servico launchd poder escrever dentro do contentor do Word. Passo manual
  (precisa do utilizador, GUI, nao scriptavel por SSH):
  1. No mini, abrir **Definicoes > Privacidade e Seguranca > Acesso Total ao
     Disco**.
  2. Clicar em "+" e navegar ate ao binario real (o `.venv/bin/python3.13`
     e um symlink; adicionar o alvo real, confirmar com
     `readlink -f ~/leitor/.venv/bin/python3.13` -- a 24/09/2026 era
     `/opt/homebrew/Cellar/python@3.13/3.13.15/Frameworks/Python.framework/Versions/3.13/bin/python3.13`,
     mas muda se o Homebrew atualizar o `python@3.13`; confirmar sempre antes
     de reaplicar).
  3. Adicionar esse binario a lista e confirmar que o interruptor fica
     ativo.
  4. Reiniciar o servico:
     `ssh ruipedro@10.0.30.231 "launchctl kickstart -k gui/\$(id -u ruipedro)/pt.segunor.leitor"`.
  5. Confirmar com um pedido real a `/v1/contrato/paginar-pdf` (nao basta o
     `/v1/saude`, que so testa a Automacao, nao o Acesso Total ao Disco).
- **Gotcha**: se o Homebrew atualizar `python@3.13`, o caminho real do
  binario muda e a permissao concedida deixa de se aplicar (macOS liga a
  permissao ao caminho do executavel) -- reconferir com `readlink -f` e
  reconceder depois de qualquer `brew upgrade python@3.13`.
- Enquanto este passo nao for feito, o endpoint responde `500`
  (`PermissionError`) sob o servico real; a logica em si esta validada (ver
  "Testes realizados" abaixo, teste corrido manualmente via `uvicorn` por
  SSH numa porta separada, com o servico launchd intocado).

## Testes realizados

Ver `TESTES-2026-09-22.md` neste diretorio para o registo completo dos
testes reais (com tempos) contra `https://10.0.30.231:8090`.

### `/v1/contrato/paginar-pdf` -- 24/09/2026

Testado com o modelo real
`/Volumes/Assistente/Modelos contratos/Contrato_Termo_Certo_v2.docx` (texto
de exemplo do proprio modelo, sem dados pessoais), enviado a partir do CT
`assistente-dev` (192.168.69.150). Como o servico launchd ainda nao tem o
Acesso Total ao Disco (ver secao acima), a corrida real foi feita com um
`uvicorn` manual via SSH numa porta separada (8091), sem tocar no servico
launchd (8090):

- `HTTP 200`, tempo total ~92,6s (dentro do timeout de 180s).
- `paginas: 14` (o modelo usado nao passa pela paginacao estatica do
  Gerador do CT -- so pela dinamica deste endpoint -- por isso nao e
  diretamente comparavel aos 15 paginas dos contratos publicados pela skill
  `fazer-contrato`, que ja incluem essa 1a passagem; a diferenca de 1 pagina
  e consistente com essa etapa em falta, nao com um erro de paginacao).
  `correcoes`: 3 iteracoes a remover paragrafos vazios no topo de pagina (46,
  2 e 1 paragrafos); o ANEXO I ja calhava em pagina impar (13), sem precisar
  de pagina em branco extra.
- `pdffonts` no `.pdf` devolvido confirma fontes **AptosDisplay** (regular,
  negrito, italico) embutidas e subconjuntadas, par com Cambria,
  TimesNewRomanPSMT e ArialMT.
- `pdftotext -layout`: bloco "Santo Tirso, ..." + assinaturas (PRIMEIRA/
  SEGUNDO OUTORGANTE) fica todo na pagina 13, sem quebra a meio.
- Confirmado por teste real (nao so pela leitura do codigo) que `GET
  /v1/saude` corrido pelo servico launchd real (porta 8090) ja devolve
  `"word":"disponivel","word_versao":"16.113.2"`.

## Notas

- O modelo Qwen (`qwen/qwen3.8-27b`, visao) e servido pelo LM Studio em
  `http://localhost:1234/v1`; o leitor desliga o raciocinio com
  `"reasoning_effort":"none"`.
- `GET /v1/saude` nao usa `sudo` nem `powermetrics` (decisao explicita:
  temperatura/potencia ficam para uma fase futura).
- Nada fora de `~/leitor` e `~/Library/LaunchAgents` e tocado no mini; nada
  fora de `/Volumes/www/assistente/leitor-mini` e tocado neste Mac.
