# 06 — Contrato HTTP do serviço leitor (mac mini)

Baseado em PLANO.md (arquitetura + secção Segurança "Mini") e `ocr-mini/RESULTADOS-2026-09-22.md` (formato já validado do binário `ocr`).

## 1. Visão geral

Serviço FastAPI (Python), correndo via `launchd` no mac mini (10.0.10.231), atrás de firewall restrito ao IP do CT assistente. HTTPS com certificado próprio (Fase 6 prevê evoluir para certificado de cliente). Autenticação por `Authorization: Bearer <token>`. Concorrência 1 (um pedido de análise de cada vez). Recebe sempre bytes + um `id` opaco — **nunca caminhos de ficheiro** (o mini não monta a NAS).

## 2. Autenticação

```
Authorization: Bearer <token>
```

Token fixo, partilhado entre o CT assistente e o mini, gerado na Fase 1 e guardado só nos dois lados (não em repositório). Pedido sem token válido → `401`.

## 3. `POST /v1/ocr`

Leitura por Apple Vision — o mesmo motor e formato já validados pelo binário `ocr` (`ocr.swift`, ver `RESULTADOS-2026-09-22.md`: "devolve JSON (linhas, confiança, posição) por página").

### Pedido

`multipart/form-data`:
- `ficheiro`: o documento (imagem ou PDF).
- `id`: identificador opaco do documento (não um caminho — ex. hash ou uuid gerado pelo CT).

### Resposta `200`

```json
{
  "id": "abc123",
  "paginas": [
    {
      "pagina": 1,
      "linhas": [
        { "texto": "REPÚBLICA PORTUGUESA", "confianca": 0.98, "posicao": { "x": 120, "y": 45, "largura": 300, "altura": 22 } }
      ]
    }
  ]
}
```

Estrutura exata de `linhas[].posicao` a confirmar contra o output real do binário `ocr` — **A CONFIRMAR**: o `ocr.swift` já existe e foi testado, mas este documento não teve acesso ao JSON de saída literal, só à descrição ("linhas, confiança, posição"); antes da Fase 1, confirmar o schema campo a campo contra uma chamada real ao binário.

## 4. `POST /v1/analisar-documento`

Chama o Qwen (LM Studio, localhost no mini) só nos tipos/campos que a política de confiança manda (04-POLITICA-CONFIANCA.md: RC sempre, morada como comparador, nome/nascimento/naturalidade se houver divergência, cartão PSP só se número ilegível). Recebe o texto já extraído (ou a imagem original, quando o papel do Qwen é leitura independente sobre a imagem, não sobre o texto do Vision — princípio 2 do plano: "nunca sobre o texto do Vision, senão não é segunda fonte").

### Pedido

`multipart/form-data`:
- `ficheiro`: a imagem original (para os casos de leitura independente) — ou omitido quando o pedido é só de comparação semântica sobre texto já extraído (ver `tipo=comparacao`).
- `tipo`: um dos tipos de documento da secção 6, ou `comparacao` para o papel de comparador semântico.
- `schema`: nome do JSON Schema a validar a resposta (um dos da secção 6).
- Para `tipo=comparacao`: campos adicionais em vez de ficheiro — `valor_a`, `valor_b`, `campo` (ex. duas moradas escritas de forma diferente).

### Resposta `200`

```json
{
  "id": "abc123",
  "tipo": "rc",
  "campos": { "val_rc": "2026-12-10", "finalidade": "exercício da atividade de segurança privada" },
  "confianca": 0.9,
  "valido_contra_schema": true
}
```

Para `tipo=comparacao`:
```json
{
  "equivalente": true,
  "explicacao": "mesma rua, abreviatura 'R' expandida para 'Rua', mesmo número e código postal"
}
```

O Qwen nunca decide sozinho (princípio 2 do plano) — a resposta é sempre um input para o motor/RH, nunca grava nada diretamente.

## 5. `GET /v1/saude`

```json
{ "ok": true, "lm_studio": "carregado", "modelo": "qwen3-vl-27b", "fila": 0 }
```

Sem `sudo`/`powermetrics` no MVP (regra explícita do plano: "sem powermetrics no MVP"; temperatura/potência ficam para a Fase 6, "decisão consciente"). Usado pela webapp para mostrar "Leitor: Qwen carregado" na fila (mockup do plano).

## 6. Limites, erros e concorrência

| Aspeto | Regra |
|---|---|
| Tamanho máximo do ficheiro | A CONFIRMAR valor exato — o plano só diz "limites de tamanho/páginas/MIME" sem fixar números; propor um teto alinhado com os scans reais observados (~9-10 MB para 1 página em alta resolução, ver `RESULTADOS-2026-09-22.md`) — decidir na Fase 1 |
| Páginas máximas por PDF | A CONFIRMAR — mesma nota |
| MIME | Verificado por conteúdo, não por extensão (mesma regra da rota de documentos na webapp) |
| Concorrência | 1 análise de cada vez (fila local no serviço); pedidos extra esperam ou recebem `429` |
| Timeout | A CONFIRMAR valor exato (proposta: alinhar com o tempo observado — 0,5s por imagem no teste de 22/09, mas o Qwen pode ser bem mais lento; medir na Fase 1) |
| Erros | `400` payload inválido, `401` token inválido/ausente, `413` ficheiro excede o limite, `422` MIME não suportado, `429` ocupado (concorrência 1), `500` falha interna (nunca deve incluir conteúdo do documento na mensagem de erro) |
| Temporários | Diretório `0700`, apagado sempre em `finally` — nunca persistir bytes do documento além do pedido em curso |
| Logs | Sem prompts nem imagens (verificado na prática, não assumido — item da checklist de segurança em `00-THREAT-MODEL.md`) |

## 7. JSON Schemas por tipo de documento

Schemas de validação da resposta de `/v1/analisar-documento`. Campos alinhados com `01-MANIFESTO-SCHEMA.json` — o leitor nunca inventa um campo que o manifesto não aceite.

### `cartao_psp`

```json
{
  "type": "object",
  "required": ["numero", "categoria_texto"],
  "properties": {
    "numero": { "type": "string", "pattern": "^[0-9]{9}$" },
    "categoria_texto": { "type": "string", "enum": ["Vigilante", "Segurança-Porteiro", "Ass. Recinto Desportivo", "Ass. Recinto Espetáculos"] },
    "categoria_sufixo": { "type": "string", "pattern": "^(VIG|SPR|ARD|ARE)$" },
    "validade": { "type": "string", "format": "date" },
    "titular": { "type": "string" }
  }
}
```

### `cc_frente`

```json
{
  "type": "object",
  "properties": {
    "nome": { "type": "string" },
    "ncc": { "type": "string", "maxLength": 10 },
    "nascimento": { "type": "string", "format": "date" },
    "validade_cc": { "type": "string", "format": "date" },
    "nif": { "type": "string", "pattern": "^[0-9]{9}$" }
  }
}
```

### `cc_verso`

```json
{
  "type": "object",
  "properties": {
    "niss": { "type": "string", "pattern": "^[0-9]{11}$" },
    "filho_de": { "type": "string" },
    "mrz_linha2": { "type": "string", "description": "nascimento, check, sexo, validade AAMMDD, para conferência cruzada" }
  }
}
```

### `cc_digital`

```json
{
  "type": "object",
  "properties": {
    "nome": { "type": "string" },
    "ncc": { "type": "string", "maxLength": 10 },
    "nascimento": { "type": "string", "format": "date" },
    "validade_cc": { "type": "string", "format": "date" },
    "formato_origem": { "type": "string", "const": "app_govpt", "description": "datas dd-mm-aaaa, distinto do CC físico (dd mm aaaa)" }
  }
}
```

### `rc`

```json
{
  "type": "object",
  "required": ["val_rc", "finalidade"],
  "properties": {
    "val_rc": { "type": "string", "format": "date", "description": "data de 'CÓDIGO VIGENTE ATÉ', nunca a emissão" },
    "finalidade": { "type": "string" },
    "naturalidade": { "type": "string" },
    "concelho": { "type": "string" },
    "nacionalidade": { "type": "string" },
    "condenacao": { "type": "boolean" },
    "condenacao_texto": { "type": "string" }
  }
}
```

### `morada`

```json
{
  "type": "object",
  "properties": {
    "morada_original": { "type": "string" },
    "tipo_documento_real": { "type": "string", "description": "ex. 'fatura EDP', 'certidão AT', 'carta verde de seguro' — o ficheiro raramente é mesmo um comprovativo de morada" }
  }
}
```

### `iban`

```json
{
  "type": "object",
  "required": ["iban"],
  "properties": {
    "iban": { "type": "string", "pattern": "^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$" },
    "mascarado": { "type": "boolean", "description": "true se o documento mostra o IBAN parcialmente oculto (ex. talão multibanco) — não serve, iban deve ficar vazio no manifesto" }
  }
}
```

A CONFIRMAR:
- Estrutura exata de `/v1/ocr` (`posicao`) contra o output real do binário `ocr.swift`.
- Limites numéricos de tamanho/páginas/timeout (a medir na Fase 1, o plano não os fixa).
- Se `/v1/analisar-documento` aceita mesmo tanto "ficheiro" (leitura independente) como "comparação semântica sobre texto" no mesmo endpoint, ou se compensa separar em dois endpoints — desenho proposto aqui para não multiplicar contratos, a confirmar com o utilizador antes da Fase 1.

## 8. `POST /v1/contrato/paginar-pdf` (24/09/2026)

Acrescentado ao leitor-mini para reaproveitar a paginação dinâmica (Word) da
skill `fazer-contrato` do Mac principal, sem duplicar a lógica: os módulos
relevantes (`paginacao.py`) foram copiados para
`leitor-mini/contratos/paginacao.py`, anotados com a origem e a data, sem
alterações de lógica (só o caminho `WORD_TMP` difere, para não colidir com a
pasta usada pela skill no Mac principal). Implementação completa em
`leitor-mini/app.py`; ver `leitor-mini/README.md` secção "Paginação/exportação
de contratos (Word)" para o registo do teste real e do passo manual de TCC
pendente.

Esta é só a parte do mini — a integração no CT (Conversor/Gerador a chamar
este endpoint, fallback para LibreOffice quando o Word estiver indisponível)
não está feita aqui; é trabalho de outra sessão sobre `app/`.

### Pedido

`multipart/form-data`:
- `ficheiro`: o `.docx` do contrato já gerado e preenchido pelo Gerador do CT
  (a paginação estática — `keepNext`, quebra/secção antes do ANEXO I — já foi
  aplicada do lado do CT; este endpoint só trata da parte que precisa mesmo
  de abrir o Word).
- `id`: identificador opaco (nunca um caminho).
- `revisao` (opcional, `1`/`true`; 24/09/2026): o `.docx` traz os valores
  preenchidos marcados com realce `green`/`cyan`/`lightGray` (DocxEngine em modo
  revisão). O mini pagina esse documento, exporta primeiro o PDF de revisão
  (com as cores), retira essas três cores do `.docx` (o amarelo dos [FALTA]
  fica) e exporta o PDF final. A resposta ganha `pdf_revisao_base64`;
  `docx_base64` e `pdf_base64` vêm sempre sem as cores. O realce não mexe no
  layout: as duas exportações têm o mesmo número de páginas (verificado com o
  modelo Termo Certo: 14 e 14, 96 s no total).

Autenticação igual aos outros endpoints (`Authorization: Bearer <token>`).

### Resposta `200`

```json
{
  "id": "abc123",
  "docx_base64": "...",
  "pdf_base64": "...",
  "paginas": 14,
  "correcoes": [
    "iteracao 1: removidos 46 paragrafos vazios no topo de pagina",
    "iteracao 2: removidos 2 paragrafos vazios no topo de pagina"
  ],
  "word_versao": "16.113.2",
  "tempo_s": 92.508
}
```

`docx_base64` é o `.docx` já arrumado pela paginação dinâmica (não é o mesmo
ficheiro que entrou — pode ter menos parágrafos vazios e/ou uma página em
branco extra antes do ANEXO I). `pdf_base64` é o PDF exportado pelo Word a
partir desse `.docx` já arrumado. `correcoes` é o log das operações da
paginação dinâmica mais avisos de título órfão, se algum.

### Erros

| Código | Motivo |
|---|---|
| `400` | ficheiro vazio |
| `401` | token em falta/inválido |
| `413` | ficheiro excede 10 MB |
| `422` | ficheiro não é um `.docx` válido (não é zip, ou falta `word/document.xml`) |
| `503` | Microsoft Word indisponível (não respondeu e a tentativa de o abrir falhou/excedeu 30s) — o CT deve fazer fallback para LibreOffice |
| `504` | timeout do pedido (180s) |
| `500` | falha interna (nunca inclui conteúdo do documento na mensagem) |

### Limites e concorrência

- Ficheiro máximo 10 MB (contratos são pequenos; limite distinto do de
  `/v1/ocr`/`/v1/analisar-documento`, que aceitam scans até 25 MB).
- MIME verificado por conteúdo (assinatura zip `PK\x03\x04` + presença de
  `word/document.xml`), nunca por extensão.
- Lock próprio (`WORD_LOCK`), separado do lock do Qwen — um pedido de
  paginação/exportação de cada vez, independente da fila de análise por IA.
- Timeout do pedido: 180s.
- Temporários dentro de
  `~/Library/Containers/com.microsoft.Word/Data/tmp/leitor-mini/` (0700,
  únicos por pedido via `secrets.token_hex`), apagados sempre em `finally` —
  é a única pasta que o Word abre por AppleScript sem pedir autorização de
  cada vez (fora dela, erro `-1708`).
- Logs sem conteúdo de documentos — só `id`, número de páginas, tempo e
  código de erro.

### `GET /v1/saude` — campo `word`

Passa a incluir `{"word": "disponivel"|"indisponivel", "word_versao": "..."}`,
obtido por `tell application "Microsoft Word" to get name`/`get version`
(timeout 5s), em thread separada para não bloquear o event loop.

### Pendente (TCC)

O serviço quando corrido pelo `launchd` (LaunchAgent) ainda não tem "Acesso
Total ao Disco" concedido ao binário Python do venv, pelo que a escrita
dentro do contentor do Word falha com `PermissionError` sob o serviço real —
ver `leitor-mini/README.md` para o passo manual exato. A automação do Word
por AppleScript (osascript) já funciona sob o `launchd` sem esse passo; só o
acesso direto do Python ao contentor precisa dele.
