Metadata-Version: 2.4
Name: ncm-classificador
Version: 0.2.0
Summary: Classificador fiscal NCM: top-3 + confiança calibrada + abstenção, offline na sua CPU
Project-URL: Modelo, https://huggingface.co/DominuZ/ncm-classificador-bertimbau
Project-URL: Benchmark, https://huggingface.co/datasets/DominuZ/rfb-bench
Author: DominuZ
License-Expression: MIT
License-File: LICENSE
Keywords: bert,cclasstrib,classificacao-fiscal,ncm,nfe,nota-fiscal,onnx,reforma-tributaria
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Natural Language :: Portuguese (Brazilian)
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial :: Accounting
Requires-Python: >=3.12
Requires-Dist: numpy>=2.0
Requires-Dist: onnxruntime>=1.20
Requires-Dist: rich>=13.9
Requires-Dist: tokenizers>=0.20
Provides-Extra: api
Requires-Dist: fastapi>=0.115; extra == 'api'
Requires-Dist: uvicorn>=0.32; extra == 'api'
Description-Content-Type: text/markdown

# ncm-classificador

Classificador fiscal NCM: descrição livre de item de NF-e → top-3 códigos NCM com
confiança calibrada e abstenção. Roda offline, na sua CPU — nenhum dado sai da máquina.

> **Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação
> é do contribuinte/contador.**

---

## Instalação

### Via pip (com suporte à API)

```bash
pip install ncm-classificador[api]
```

Sem a flag `[api]`, instala apenas a CLI (sem dependências do servidor FastAPI).

---

## Uso

A CLI resolve o pacote-modelo nesta ordem: `--pacote DIR` > env `NCM_PACOTE` > `~/.ncm/pacote`.

### 1. Classificar item único

```bash
ncm classificar "PARAFUSO SEXT ZINC M8X40 DIN933"
```

**Saída real** (tabela `rich` — código + confiança calibrada; a descrição textual do NCM
não faz parte do pacote exportado):
```
                    NCM para: PARAFUSO SEXT ZINC M8X40 DIN933
┌───────────────┬─────────────────────┐
│ candidato NCM │ confiança calibrada │
├───────────────┼─────────────────────┤
│ 73181500      │               99.7% │
│ 73181100      │                0.1% │
│ 86079900      │                0.0% │
└───────────────┴─────────────────────┘
Ferramenta de apoio · caráter orientativo · a responsabilidade pela
classificação é do contribuinte/contador.
```

Quando o item abstém, uma linha extra em destaque aparece antes do disclaimer:
`ABSTEVE — confiança insuficiente; escale a um contador` — ou, quando a posição
fecha mesmo sem fechar a folha, `RESPOSTA PARCIAL` (ver a seção dedicada mais
abaixo).

Com `--json`, a saída é uma linha NDJSON (mesmo shape usado pela API — ver adiante).
Repare no campo `resposta_parcial`: ele está sempre presente, e vem `null` quando não
há posição confiante o bastante para fechar (é o caso deste item, que já responde
direto na folha):
```bash
ncm classificar "PARAFUSO SEXT ZINC M8X40 DIN933" --json
```
```json
{"descricao": "PARAFUSO SEXT ZINC M8X40 DIN933", "top3": [{"ncm8": "73181500", "confianca": 0.996774}, {"ncm8": "73181100", "confianca": 0.000645}, {"ncm8": "86079900", "confianca": 0.000174}], "abstem": false, "resposta_parcial": null, "disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.", "versao_pacote": "f-1"}
```

### 2. Classificar lote (arquivo CSV)

```bash
ncm lote itens.csv > resultado.csv
```

Não existe `--saida`: o resultado sai no stdout e é redirecionado com `>`. (`--json`
também é aceito e emite uma linha NDJSON por item, no mesmo formato do item 1.)

**Entrada (itens.csv, coluna obrigatória `descricao`):**
```
descricao
PARAFUSO SEXT ZINC M8X40 DIN933
ARROZ(CLASSIFICAÇÂO SEM CARACTERÍSTICAS)
FEIJÃO(CLASSIFICAÇÂO SEM CARACTERÍSTICAS)
OVINO DOMESTICO SANSIBEL - 190KG
```

**Saída real (resultado.csv)** — colunas `ncm8_N`/`confianca_N` (não `ncm_N`), campo
`abstem` (não `abstencao`), e duas colunas novas no fim, `nivel_parcial`/`codigo_parcial`
(script que só lê `abstem` continua funcionando sem alteração):
```
descricao,ncm8_1,confianca_1,ncm8_2,confianca_2,ncm8_3,confianca_3,abstem,nivel_parcial,codigo_parcial
PARAFUSO SEXT ZINC M8X40 DIN933,73181500,0.996774,73181100,0.000645,86079900,0.000174,false,,
ARROZ(CLASSIFICAÇÂO SEM CARACTERÍSTICAS),10062020,0.301446,10061092,0.141093,10061091,0.107488,true,posicao,1006
FEIJÃO(CLASSIFICAÇÂO SEM CARACTERÍSTICAS),07082000,0.368774,07133399,0.191740,07139090,0.065303,true,,
OVINO DOMESTICO SANSIBEL - 190KG,01041019,0.492764,01042010,0.337461,01041090,0.052320,true,posicao,0104
# Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.
```

As três últimas linhas abstêm (`abstem=true`) — a saída real, não um exemplo forjado.
O ARROZ e o OVINO fecham a posição (`nivel_parcial=posicao`, com o código de 4 dígitos
em `codigo_parcial`); o FEIJÃO abstém sem apoio nenhum, e as duas colunas ficam vazias.

### 3. Consultar informações do modelo

```bash
ncm info
```

**Saída real:**
```
                      Pacote de inferência NCM
┌──────────────────────────┬────────────────────────────────────────┐
│ campo                    │ valor                                  │
├──────────────────────────┼────────────────────────────────────────┤
│ versão do pacote         │ f-1                                    │
│ modelo base              │ neuralmind/bert-large-portuguese-cased │
│ corrida / data de export │ f / 2026-07-18                         │
│ temperatura (T)          │ 0.788                                  │
│ pisos de abstenção       │ conf 0.55 · margem 0.1                 │
│ classes (folhas NCM)     │ 9748                                   │
│ integridade sha256       │ ok (conferida ao carregar)             │
└──────────────────────────┴────────────────────────────────────────┘
Ferramenta de apoio · caráter orientativo · a responsabilidade pela
classificação é do contribuinte/contador.
```

`ncm info` mostra proveniência e configuração de calibração do pacote carregado — não
expõe métricas de acurácia/ECE do conjunto de validação (essas vivem nos artefatos de
avaliação do treino, não no pacote de produto).

---

## Resposta parcial de posição

Quando o modelo não tem confiança para fechar os 8 dígitos, mas está bastante seguro
sobre os 4 primeiros (a posição da NCM), ele devolve um apoio extra em vez de abstenção
muda: o campo `resposta_parcial`. Isso só acontece com pacotes de modelo que trazem
`piso_posicao` no `inferencia.json` — o `f-1` do Hugging Face traz; pacotes mais antigos
(como o `d-1`) continuam funcionando normalmente, só que sempre com `resposta_parcial: null`.

`abstem` continua `true` nesse caso — a posição não é uma segunda forma de decisão
automática, é o modelo estreitando o universo de folhas candidatas para o contador
escolher entre elas. Saída real (`--json`, mesmo shape usado pela API):

```json
{"descricao": "ARROZ(CLASSIFICAÇÂO SEM CARACTERÍSTICAS)", "top3": [{"ncm8": "10062020", "confianca": 0.301446}, {"ncm8": "10061092", "confianca": 0.141093}, {"ncm8": "10061091", "confianca": 0.107488}], "abstem": true, "resposta_parcial": {"nivel": "posicao", "codigo": "1006", "confianca": 0.902203, "candidatas_folha": ["10062020", "10061092", "10061091"], "texto": "Faltam os últimos 4 dígitos — refine entre as folhas candidatas com seu contador."}, "disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.", "versao_pacote": "f-1"}
```

Na CLI interativa, isso aparece como `RESPOSTA PARCIAL` (destacada em ciano) em vez do
`ABSTEVE` amarelo — o top-3 continua exibido do mesmo jeito nos dois casos.

No `ncm lote`, o mesmo dado chega em duas colunas no fim do CSV: `nivel_parcial`
(hoje só existe o valor `posicao` — a camada de capítulo foi medida e arquivada por não
bater a barra de precisão pré-registrada, ver o model card) e `codigo_parcial` (os 4
dígitos). Scripts que já leem só a coluna `abstem` continuam funcionando sem qualquer
mudança — as colunas novas vêm depois, no fim da linha.

O nível posição foi medido em dados reais com **93,8% de acerto** (321 itens, barra
pré-registrada era 89,9%) e fecha em **5,6%** das consultas que, de outra forma, seriam
abstenções mudas — na prática, reduz de 25,6% para 19,9% a fatia de consultas reais que
ficam sem nenhum código de apoio.

---

## Servidor API

### Iniciar o servidor

```bash
ncm servir --host 0.0.0.0 --porta 8000
```

### Variáveis de ambiente obrigatórias/opcionais

| Variável | Tipo | Padrão | Descrição |
|----------|------|--------|-----------|
| `NCM_API_KEYS` | string | (obrigatória) | Chave(s) de API separadas por `,` ex: `chave1,chave2`. Sem isto o servidor não sobe (fail-closed) |
| `NCM_RATE_LIMIT_RPM` | int | 120 | Limite de requisições por minuto por chave (token bucket em memória; zera no restart) |
| `NCM_PACOTE` | path | `~/.ncm/pacote` | Caminho até o diretório do pacote-modelo (no Docker, tipicamente `/pacote`, ver seção Docker) |

### Endpoints

#### `POST /v1/classificar`
Classifica um item único.

**Request:**
```bash
curl -X POST http://localhost:8000/v1/classificar \
  -H "X-API-Key: sua-chave-api" \
  -H "Content-Type: application/json" \
  -d '{"descricao": "PARAFUSO SEXT ZINC M8X40 DIN933"}'
```

**Response (200)** — mesmo shape do `--json` da CLI (carga real, capturada rodando o
servidor local; `resposta_parcial` sempre presente, `null` quando não há posição
confiante o bastante):
```json
{
  "descricao": "PARAFUSO SEXT ZINC M8X40 DIN933",
  "top3": [
    {"ncm8": "73181500", "confianca": 0.996774},
    {"ncm8": "73181100", "confianca": 0.000645},
    {"ncm8": "86079900", "confianca": 0.000174}
  ],
  "abstem": false,
  "resposta_parcial": null,
  "disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.",
  "versao_pacote": "f-1"
}
```

`descricao` aceita 1 a 2000 caracteres. Corpo fora desse contrato (campo errado, string
vazia/longa demais) devolve `422` no formato `problem+json` (RFC 9457).

#### `POST /v1/classificar-lote`
Classifica múltiplos itens (máx. 200 por requisição). O corpo é `{"descricoes": [...]}`
— uma lista de strings, **não** uma lista de objetos `{"descricao": ...}`.

**Request:**
```bash
curl -X POST http://localhost:8000/v1/classificar-lote \
  -H "X-API-Key: sua-chave-api" \
  -H "Content-Type: application/json" \
  -d '{
    "descricoes": [
      "PARAFUSO SEXT ZINC M8X40 DIN933",
      "ARROZ(CLASSIFICAÇÂO SEM CARACTERÍSTICAS)"
    ]
  }'
```

**Response (200)** — envelope com `disclaimer`/`versao_pacote` no topo, e cada resultado
com o mesmo shape do endpoint acima (também carregando `disclaimer`/`versao_pacote`).
O segundo item mostra `resposta_parcial` preenchido — o mesmo dado que a CLI devolve
para essa descrição, só que dentro do envelope de lote:
```json
{
  "resultados": [
    {
      "descricao": "PARAFUSO SEXT ZINC M8X40 DIN933",
      "top3": [
        {"ncm8": "73181500", "confianca": 0.996774},
        {"ncm8": "73181100", "confianca": 0.000645},
        {"ncm8": "86079900", "confianca": 0.000174}
      ],
      "abstem": false,
      "resposta_parcial": null,
      "disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.",
      "versao_pacote": "f-1"
    },
    {
      "descricao": "ARROZ(CLASSIFICAÇÂO SEM CARACTERÍSTICAS)",
      "top3": [
        {"ncm8": "10062020", "confianca": 0.301446},
        {"ncm8": "10061092", "confianca": 0.141093},
        {"ncm8": "10061091", "confianca": 0.107488}
      ],
      "abstem": true,
      "resposta_parcial": {
        "nivel": "posicao",
        "codigo": "1006",
        "confianca": 0.902203,
        "candidatas_folha": ["10062020", "10061092", "10061091"],
        "texto": "Faltam os últimos 4 dígitos — refine entre as folhas candidatas com seu contador."
      },
      "disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.",
      "versao_pacote": "f-1"
    }
  ],
  "disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador.",
  "versao_pacote": "f-1"
}
```

O custo no rate limit é `len(descricoes)` tokens do balde — um lote de 50 itens custa 50,
não 1.

#### `GET /v1/info`
Retorna proveniência e configuração de calibração do pacote carregado. Não expõe
métricas de acurácia/ECE (não fazem parte do pacote de produto — ver seção `ncm info`
acima).

**Request:**
```bash
curl -H "X-API-Key: sua-chave-api" http://localhost:8000/v1/info
```

**Response (200)** — capturada rodando o servidor local com o pacote `f-1`. O campo
`piso_posicao` só aparece (não-nulo) em pacotes que trazem a calibração da resposta
parcial; pacotes antigos devolvem `piso_posicao: null` aqui:
```json
{
  "versao_pacote": "f-1",
  "modelo_base": "neuralmind/bert-large-portuguese-cased",
  "corrida": "f",
  "data_export": "2026-07-18",
  "temperatura": 0.7876692056028095,
  "piso_confianca": 0.55,
  "piso_margem": 0.1,
  "piso_posicao": 0.7793302624741045,
  "classes": 9748,
  "disclaimer": "Ferramenta de apoio · caráter orientativo · a responsabilidade pela classificação é do contribuinte/contador."
}
```

#### `GET /healthz`
Verifica saúde do servidor (usado pelo Docker HEALTHCHECK). Não exige `X-API-Key`.

**Request:**
```bash
curl http://localhost:8000/healthz
```

**Response (200):**
```json
{"status": "ok"}
```

---

## Docker

### Build

Deve ser executado **na raiz do repositório** (o Dockerfile copia o wheel de `dist/`):

```bash
# 1. Construir o wheel
uv build packages/ncm-classificador

# 2. Construir a imagem Docker
docker build -f packages/ncm-classificador/Dockerfile -t ncm-classificador .
```

### Run

O pacote-modelo (1,3 GB) **não é embarcado** na imagem — entra como volume:

```bash
docker run \
  -p 8000:8000 \
  -v /caminho/local/do/pacote:/pacote:ro \
  -e NCM_API_KEYS=sua-chave-api \
  -e NCM_RATE_LIMIT_RPM=120 \
  ncm-classificador
```

**Notas:**
- `/caminho/local/do/pacote` é o diretório local com o pacote-modelo baixado do
  Hugging Face (ver seção "Obtendo o pacote-modelo" abaixo)
- `-v ... :ro` monta em modo somente-leitura (segurança)
- Porta exposta: 8000 (ajuste com `-p HOST:8000` conforme necessário)
- O servidor está pronto quando logs mostram `Application startup complete`

---

## Obtendo o pacote-modelo

O modelo treinado é distribuído separadamente, no Hugging Face:
**https://huggingface.co/DominuZ/ncm-classificador-bertimbau** (CC BY 4.0).
O pacote de inferência são estes 5 itens do repositório: `modelo.onnx` (~1,3 GB),
`tokenizer/`, `labels.json`, `inferencia.json` e `manifest.json` — a integridade
(SHA-256 do manifesto) é conferida automaticamente antes de qualquer resposta.

```bash
pip install huggingface_hub
hf download DominuZ/ncm-classificador-bertimbau \
  modelo.onnx labels.json inferencia.json manifest.json tokenizer/tokenizer.json tokenizer/tokenizer_config.json \
  --local-dir ~/.ncm/pacote
```

Uma vez baixado, aponte a variável de ambiente `NCM_PACOTE` para o diretório
(ou use o padrão `~/.ncm/pacote`, que dispensa a variável):

```bash
export NCM_PACOTE=~/.ncm/pacote
ncm classificar "PARAFUSO SEXT ZINC M8X40 DIN933"
ncm servir
```

Ou via Docker:
```bash
docker run -v /seu/caminho/pacote:/pacote -e NCM_PACOTE=/pacote ...
```

---

## Limitações e abstenção

O modelo abstém em casos ambíguos ou com baixa confiança na predição (atualmente 48,8%
dos itens na validação agregada). A abstenção é **um recurso, não um defeito**: itens
abstidos devem ser revisados manualmente pela equipe de conformidade ou contador. Em
dados reais, boa parte dessas abstenções ganha um apoio extra — a resposta parcial de
posição (seção acima) — o que reduz de 25,6% para 19,9% a fatia de consultas reais que
ficam sem nenhum código de apoio.

Exemplos reais de abstenção (saída do `ncm lote`, ver seção de uso acima): o item
`FEIJÃO(CLASSIFICAÇÂO SEM CARACTERÍSTICAS)` recebeu `abstem=true` porque o 1º candidato
(`07082000`) ficou em 36,9% de confiança, abaixo do piso de 55% configurado no pacote
`f-1` — e a posição também não fechou, então é uma abstenção sem apoio nenhum
(`resposta_parcial: null`). Já o item `ARROZ(CLASSIFICAÇÂO SEM CARACTERÍSTICAS)` também
abstém na folha, mas fecha a posição (1006, 90,2% de confiança) — abstenção com apoio
parcial. Na CLI interativa (`ncm classificar`), a primeira aparece como a linha
`ABSTEVE — confiança insuficiente; escale a um contador` logo abaixo da tabela; a
segunda aparece como `RESPOSTA PARCIAL`, com a posição sugerida.

---

## Links e suporte

- **Modelo (pesos + pacote ONNX):** https://huggingface.co/DominuZ/ncm-classificador-bertimbau
  — o model card documenta métricas, calibração, dados de treino e limitações
- **Benchmark público (RFB-bench):** https://huggingface.co/datasets/DominuZ/rfb-bench
- Dúvidas e problemas: use as discussões (aba *Community*) do repositório do modelo
  no Hugging Face

**Licença:** código MIT · pesos do modelo CC BY 4.0
**Versão:** 0.2.0
**Última atualização:** 2026-07-18
