setup models

This commit is contained in:
Marcos Lohmann
2026-10-07 13:43:09 -03:00
parent aeee6c7de1
commit 84ea9a5307
75 changed files with 5093 additions and 58 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
*
!pyproject.toml
!dmb/
!adapters/
!harness/
!contracts/
!config/
!env/requirements/
!env/artifacts-manifest.json
!models/src/*.tar.gz
!models/runtimes/llama-b11081-linux-x64-cpu/
!models/runtimes/llama-b11081-linux-x64-cuda/
**/__pycache__
!infra/oci/session.py

2
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.venv/ .venv*
.pytest_cache/ .pytest_cache/
# Pesos e caches de modelos (ficam no bucket `artefatos`) # Pesos e caches de modelos (ficam no bucket `artefatos`)

View File

@@ -32,14 +32,19 @@ Cada candidato é exposto por um adaptador local com o mesmo contrato JEV (`POST
| [docs/02-desenho-experimental.md](docs/02-desenho-experimental.md) | Tarefas de decisão, formato dos dados, partições, métricas e regras de isolamento | | [docs/02-desenho-experimental.md](docs/02-desenho-experimental.md) | Tarefas de decisão, formato dos dados, partições, métricas e regras de isolamento |
| [docs/03-plano-de-tarefas.md](docs/03-plano-de-tarefas.md) | As 78 microtarefas organizadas em fases, com dependências e adaptações ao contexto de roteamento | | [docs/03-plano-de-tarefas.md](docs/03-plano-de-tarefas.md) | As 78 microtarefas organizadas em fases, com dependências e adaptações ao contexto de roteamento |
| [docs/04-questoes-em-aberto.md](docs/04-questoes-em-aberto.md) | Decisões de projeto (Q1–Q13) e os mecanismos automatizados que as aplicam | | [docs/04-questoes-em-aberto.md](docs/04-questoes-em-aberto.md) | Decisões de projeto (Q1–Q13) e os mecanismos automatizados que as aplicam |
| [docs/05-ambiente-de-execucao.md](docs/05-ambiente-de-execucao.md) | Onde e como os scripts rodam: jobs e notebooks do OCI Data Science (a máquina local é só IDE) |
| [config/benchmark.yaml](config/benchmark.yaml) | Parâmetros e critérios pré-registrados, congelados na tarefa 62 | | [config/benchmark.yaml](config/benchmark.yaml) | Parâmetros e critérios pré-registrados, congelados na tarefa 62 |
## Estrutura do repositório ## Estrutura do repositório
``` ```
config/ Configuração pré-registrada (benchmark.yaml) config/ Configuração pré-registrada (benchmark.yaml)
infra/oci/ Compartment, tags, orçamento, IAM, VCN, buckets, Data Science (tarefas 1–10, 17, 78) dmb/ Código comum: contrato JEV, formato canônico, servidor /v1/systemone, isolamento de rede
env/ Imagem de contêiner e manifesto de versões (11–12) tests/ Testes de contrato, formato canônico, servidor e isolamento
infra/oci/ Compartment, IAM, VCN, buckets, Data Science, VM e NAT (tarefas 1–10, 17, 78)
infra/ds/ Jobs do Data Science: build dos ambientes, execução do benchmark e logs
infra/vm/ remote.sh para a VM de trabalho (bloqueada; ver docs/05)
env/ Aquisição de artefatos, manifesto, Dockerfile por candidato e dependências fixadas (11–16)
contracts/jev/ Contrato JEV jev-compat-v1: esquemas, mapeamento de tipos, fixtures de conformidade (36–41) contracts/jev/ Contrato JEV jev-compat-v1: esquemas, mapeamento de tipos, fixtures de conformidade (36–41)
data/taxonomy/ Taxonomia de intenções/agentes e definição das tarefas de decisão (20–23) data/taxonomy/ Taxonomia de intenções/agentes e definição das tarefas de decisão (20–23)
data/raw/ Enunciados coletados e anonimizados — não versionar dados sensíveis (24–25) data/raw/ Enunciados coletados e anonimizados — não versionar dados sensíveis (24–25)

14
adapters/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
"""Adaptadores JEV locais. Cada candidato roda num ambiente próprio (dependências incompatíveis)."""
from importlib import import_module
REGISTRY = {
"laya": "adapters.laya.adapter:LayaAdapter",
"semif": "adapters.semif.adapter:SemIfAdapter",
"gliner": "adapters.gliner.adapter:GlinerAdapter",
"rizzo_flow": "adapters.rizzo_flow.adapter:RizzoFlowAdapter",
}
def adapter_class(name: str):
module, cls = REGISTRY[name].split(":")
return getattr(import_module(module), cls)

View File

@@ -0,0 +1,55 @@
"""Adaptador do GLiNER2.5-multi-Decide via classify_text (tarefa 44).
O GLiNER devolve só o rótulo vencedor no modo de rótulo único. Para obter a
distribuição completa, cada pergunta é enviada com `multi_label=True`,
`class_act="softmax"` e `cls_threshold=0`: o modelo calcula o mesmo softmax do modo
de rótulo único e devolve todas as opções com sua probabilidade.
"""
from __future__ import annotations
from dmb.adapter import Adapter
from dmb.jev import ContextLimit, answer_from_distribution, option_descriptions
class GlinerAdapter(Adapter):
name = "gliner"
probabilities_source = "native"
def load(self) -> None:
from gliner2 import AutoExtractor
from tokenizers import Tokenizer
self.model = AutoExtractor.from_pretrained(str(self.model_dir))
if self.device not in ("auto", "cpu"):
self.model.to(self.device)
elif self.device == "auto":
import torch
if torch.cuda.is_available():
self.model.to("cuda")
self.model.eval()
self.tokenizer = Tokenizer.from_file(str(self.model_dir / "tokenizer.json"))
def native_context_limit(self) -> int:
return int(getattr(self.model.config, "max_len", 512) or 512)
def count_tokens(self, text: str) -> int:
return len(self.tokenizer.encode(text, add_special_tokens=False).ids)
def decide(self, state: str, questions: dict) -> tuple[dict, int]:
tokens = self.count_tokens(state)
if tokens > self.context_limit:
raise ContextLimit(f"{tokens} tokens > limite {self.context_limit}")
# As instruções entram como prefixo do texto: o classify_text não tem campo de pergunta.
answers = {}
for name, question in questions.items():
labels = option_descriptions(question)
text = f"Pergunta: {question['instructions']}\n{state}"
raw = self.model.classify_text(
text,
{name: {"labels": labels, "multi_label": True, "class_act": "softmax",
"cls_threshold": 0.0}},
include_confidence=True,
)[name]
dist = {item["label"]: item["confidence"] for item in raw}
answers[name] = answer_from_distribution(question, dist)
return answers, tokens

51
adapters/laya/adapter.py Normal file
View File

@@ -0,0 +1,51 @@
"""Adaptador do Laya multilingual (tarefa 42)."""
from __future__ import annotations
from pathlib import Path
from dmb.adapter import Adapter
from dmb.jev import ContextLimit, answer_from_distribution, option_keys
class LayaAdapter(Adapter):
name = "laya"
# Laya arredonda as probabilidades em 4 casas; o piso comum de dmb.jev cobre os zeros.
probabilities_source = "native"
def load(self) -> None:
import shutil
import tempfile
import laya
from tokenizers import Tokenizer
# laya.load() regrava tokenizer/tokenizer_config.json no próprio diretório
# (_fix_tokenizer_config). Carregamos de uma cópia para não alterar os artefatos verificados.
self._workdir = Path(tempfile.mkdtemp(prefix="dmb-laya-")) / "model"
shutil.copytree(self.model_dir, self._workdir)
self.agent = laya.load(str(self._workdir))
self.tokenizer = Tokenizer.from_file(str(self.model_dir / "tokenizer" / "tokenizer.json"))
def native_context_limit(self) -> int:
return 1024 # padrão do checkpoint; 256 desses tokens vão para pergunta + opções
def count_tokens(self, text: str) -> int:
return len(self.tokenizer.encode(text, add_special_tokens=False).ids)
def decide(self, state: str, questions: dict) -> tuple[dict, int]:
result = self.agent.predict(state, questions, max_len=self.context_limit)
usage = result.get("usage", {})
if usage.get("truncated") or usage.get("state_tokens_dropped"):
raise ContextLimit(f"Laya truncou o estado: {usage}")
answers = {}
for name, question in questions.items():
raw = result["answers"][name]
if question["type"] == "noul":
p = float(raw["noul"])
dist = {"true": p, "false": 1 - p}
else:
dist = raw["probabilities"]
if question["type"] == "score":
dist = {k: dist[k] for k in option_keys(question)}
answers[name] = answer_from_distribution(question, dist, raw.get("confidence"))
return answers, int(usage.get("input_tokens", 0))

View File

@@ -0,0 +1,59 @@
"""Adaptador do Rizzo Flow 4B (GGUF Q8_0 sobre llama.cpp) com prompts spark-decisions-v3 (tarefa 45)."""
from __future__ import annotations
from pathlib import Path
from dmb.adapter import Adapter
from dmb.jev import ContextLimit, answer_from_distribution, option_keys
GGUF_FILE = "spark-x2.5-4b-rizzo-flow-lora-q8_0.gguf"
class RizzoFlowAdapter(Adapter):
name = "rizzo_flow"
probabilities_source = "native"
def __init__(self, *args, runtime_dir: str | Path | None = None, **kwargs):
super().__init__(*args, **kwargs)
self.runtime_dir = runtime_dir
def load(self) -> None:
from rizzo_flow.backend_llama import LlamaBackend
from rizzo_flow.compat import model_name
from rizzo_flow.engine import Engine
device = "cuda" if self.device == "cuda" else ("cpu" if self.device == "cpu" else "auto")
backend = LlamaBackend.load(self.model_dir / GGUF_FILE, device=device, ctx=self.context_limit,
runtime_dir=self.runtime_dir)
self.engine = Engine(backend, ctx=self.context_limit)
self.served = model_name(backend.metadata)
def native_context_limit(self) -> int:
return 2048 # comprimento máximo dos prompts de treino do LoRA
def count_tokens(self, text: str) -> int:
return len(self.engine.backend.tokenizer.encode(text))
def decide(self, state: str, questions: dict) -> tuple[dict, int]:
from rizzo_flow.compat import SystemOneRequest, from_native, to_native
request = SystemOneRequest.model_validate(
{"model": self.served, "state": state, "questions": questions})
native, options = to_native(request)
try:
raw = from_native(request, self.engine.decide(native), options, self.served)
except ValueError as error:
if "context" in str(error).lower() or "tokens" in str(error).lower():
raise ContextLimit(str(error)) from error
raise
raw = raw if isinstance(raw, dict) else raw.model_dump()
answers = {}
for name, question in questions.items():
a = raw["answers"][name]
if question["type"] == "noul":
p = float(a["noul"])
dist = {"true": p, "false": 1 - p}
else:
dist = {k: a["probabilities"][k] for k in option_keys(question)}
answers[name] = answer_from_distribution(question, dist, a.get("confidence"))
return answers, int(raw.get("usage", {}).get("input_tokens", 0))

45
adapters/semif/adapter.py Normal file
View File

@@ -0,0 +1,45 @@
"""Adaptador do SemIf (modo direct) com Qwen3.5-4B fixado (tarefa 43)."""
from __future__ import annotations
from dmb.adapter import Adapter
from dmb.jev import ContextLimit, answer_from_distribution, option_descriptions
class SemIfAdapter(Adapter):
name = "semif"
probabilities_source = "native"
def load(self) -> None:
from semif_phase1 import core, direct
dev = self.device
if dev == "auto":
import torch
dev = "cuda" if torch.cuda.is_available() else "cpu"
self._direct = direct
self.model, self.tokenizer, self.metadata = core.load_causal_model(
str(self.model_dir), self.revision, device=dev, dtype="bfloat16")
def native_context_limit(self) -> int:
return 8192
def count_tokens(self, text: str) -> int:
return len(self.tokenizer.encode(text, add_special_tokens=False))
def decide(self, state: str, questions: dict) -> tuple[dict, int]:
answers, total_tokens = {}, 0
for name, question in questions.items():
options = option_descriptions(question)
row = {"id": name, "state": state, "question": question["instructions"],
"options": [{"id": k, "description": v} for k, v in options.items()]}
try:
out = self._direct.score(self.model, self.tokenizer, row, self.metadata,
max_tokens=self.context_limit)
except ValueError as error:
if "exceed limit" in str(error):
raise ContextLimit(str(error)) from error
raise
dist = dict(zip(out["option_ids"], out["probabilities"]))
answers[name] = answer_from_distribution(question, dist)
total_tokens += out["input_tokens"]
return answers, total_tokens

View File

@@ -5,7 +5,7 @@
contract: contract:
version: jev-compat-v1 # Q1 version: jev-compat-v1 # Q1
endpoint: /v1/systemone endpoint: /v1/systemone
max_options: 20 # Q2: inclui sem_correspondencia max_options: 16 # Q2: inclui sem_correspondencia; limite do SemIf (A–P)
fallback_option: sem_correspondencia fallback_option: sem_correspondencia
require_zero_output_tokens: true require_zero_output_tokens: true

View File

@@ -0,0 +1,203 @@
{
"contract": "jev-compat-v1",
"cases": [
{
"name": "valid_choice_noul",
"expect": "ok",
"request": {
"model": "any",
"state": "Contexto:\nAgente ativo: nenhum\nSessões suspensas: nenhuma\n\nMensagem: quero cancelar o serviço",
"questions": {
"D1": {
"type": "choice",
"instructions": "Qual intenção o usuário expressa na Mensagem?",
"criteria": {
"pagamento": "Pagar uma conta ou consultar um pagamento",
"cancelamento": "Cancelar um serviço contratado",
"suporte_tecnico": "Resolver um problema técnico ou falha de funcionamento",
"sem_correspondencia": "Nenhuma das opções anteriores"
}
},
"D3": {
"type": "noul",
"instructions": "A Mensagem tem informação suficiente para rotear sem perguntar ao usuário?",
"criteria": {
"true": "Sim, a intenção está clara",
"false": "Não, é preciso perguntar ao usuário"
}
}
}
}
},
{
"name": "valid_score",
"expect": "ok",
"request": {
"model": "any",
"state": "Contexto:\nAgente ativo: nenhum\nSessões suspensas: nenhuma\n\nMensagem: isso é muito urgente, preciso agora",
"questions": {
"urgencia": {
"type": "score",
"instructions": "Qual a urgência da Mensagem?",
"criteria": [
"Baixa",
"Média",
"Alta"
]
}
}
}
},
{
"name": "valid_state_object",
"expect": "ok",
"request": {
"model": "any",
"state": {
"mensagem": "oi"
},
"questions": {
"D3": {
"type": "noul",
"instructions": "A Mensagem é uma saudação?",
"criteria": {
"true": "Sim, a intenção está clara",
"false": "Não, é preciso perguntar ao usuário"
}
}
}
}
},
{
"name": "invalid_17_options",
"expect": "invalid_request",
"request": {
"model": "any",
"state": "x",
"questions": {
"q": {
"type": "choice",
"instructions": "?",
"criteria": {
"o0": "opção 0",
"o1": "opção 1",
"o2": "opção 2",
"o3": "opção 3",
"o4": "opção 4",
"o5": "opção 5",
"o6": "opção 6",
"o7": "opção 7",
"o8": "opção 8",
"o9": "opção 9",
"o10": "opção 10",
"o11": "opção 11",
"o12": "opção 12",
"o13": "opção 13",
"o14": "opção 14",
"o15": "opção 15",
"o16": "opção 16"
}
}
}
}
},
{
"name": "invalid_single_option",
"expect": "invalid_request",
"request": {
"model": "any",
"state": "x",
"questions": {
"q": {
"type": "choice",
"instructions": "?",
"criteria": {
"a": "A"
}
}
}
}
},
{
"name": "invalid_noul_missing_false",
"expect": "invalid_request",
"request": {
"model": "any",
"state": "x",
"questions": {
"q": {
"type": "noul",
"instructions": "?",
"criteria": {
"true": "sim"
}
}
}
}
},
{
"name": "invalid_unknown_type",
"expect": "invalid_request",
"request": {
"model": "any",
"state": "x",
"questions": {
"q": {
"type": "rank",
"instructions": "?",
"criteria": {
"a": "A",
"b": "B"
}
}
}
}
},
{
"name": "invalid_empty_state",
"expect": "invalid_request",
"request": {
"model": "any",
"state": "",
"questions": {
"q": {
"type": "noul",
"instructions": "?",
"criteria": {
"true": "Sim, a intenção está clara",
"false": "Não, é preciso perguntar ao usuário"
}
}
}
}
},
{
"name": "invalid_no_questions",
"expect": "invalid_request",
"request": {
"model": "any",
"state": "x",
"questions": {}
}
},
{
"name": "invalid_extra_field",
"expect": "invalid_request",
"request": {
"model": "any",
"state": "x",
"stream": true,
"questions": {
"q": {
"type": "noul",
"instructions": "?",
"criteria": {
"true": "Sim, a intenção está clara",
"false": "Não, é preciso perguntar ao usuário"
}
}
}
}
}
]
}

View File

@@ -0,0 +1,125 @@
{
"contract": "jev-compat-v1",
"options": {
"pagamento": "Pagar uma conta ou consultar um pagamento",
"cancelamento": "Cancelar um serviço contratado",
"suporte_tecnico": "Resolver um problema técnico ou falha de funcionamento",
"sem_correspondencia": "Nenhuma das opções anteriores"
},
"cases": [
{
"name": "choice:pagamento",
"state": "Contexto:\nAgente ativo: nenhum\nSessões suspensas: nenhuma\n\nMensagem: quero pagar a conta deste mês",
"question": {
"type": "choice",
"instructions": "Qual intenção o usuário expressa na Mensagem?",
"criteria": {
"pagamento": "Pagar uma conta ou consultar um pagamento",
"cancelamento": "Cancelar um serviço contratado",
"suporte_tecnico": "Resolver um problema técnico ou falha de funcionamento",
"sem_correspondencia": "Nenhuma das opções anteriores"
}
},
"gold": "pagamento"
},
{
"name": "choice:cancelamento",
"state": "Contexto:\nAgente ativo: nenhum\nSessões suspensas: nenhuma\n\nMensagem: preciso cancelar o meu serviço hoje",
"question": {
"type": "choice",
"instructions": "Qual intenção o usuário expressa na Mensagem?",
"criteria": {
"pagamento": "Pagar uma conta ou consultar um pagamento",
"cancelamento": "Cancelar um serviço contratado",
"suporte_tecnico": "Resolver um problema técnico ou falha de funcionamento",
"sem_correspondencia": "Nenhuma das opções anteriores"
}
},
"gold": "cancelamento"
},
{
"name": "choice:suporte_tecnico",
"state": "Contexto:\nAgente ativo: nenhum\nSessões suspensas: nenhuma\n\nMensagem: o aplicativo trava toda vez que abro",
"question": {
"type": "choice",
"instructions": "Qual intenção o usuário expressa na Mensagem?",
"criteria": {
"pagamento": "Pagar uma conta ou consultar um pagamento",
"cancelamento": "Cancelar um serviço contratado",
"suporte_tecnico": "Resolver um problema técnico ou falha de funcionamento",
"sem_correspondencia": "Nenhuma das opções anteriores"
}
},
"gold": "suporte_tecnico"
},
{
"name": "choice:sem_correspondencia",
"state": "Contexto:\nAgente ativo: nenhum\nSessões suspensas: nenhuma\n\nMensagem: qual a previsão do tempo para amanhã?",
"question": {
"type": "choice",
"instructions": "Qual intenção o usuário expressa na Mensagem?",
"criteria": {
"pagamento": "Pagar uma conta ou consultar um pagamento",
"cancelamento": "Cancelar um serviço contratado",
"suporte_tecnico": "Resolver um problema técnico ou falha de funcionamento",
"sem_correspondencia": "Nenhuma das opções anteriores"
}
},
"gold": "sem_correspondencia"
},
{
"name": "noul:clara",
"state": "Contexto:\nAgente ativo: nenhum\nSessões suspensas: nenhuma\n\nMensagem: quero cancelar o serviço de internet agora",
"question": {
"type": "noul",
"instructions": "A Mensagem tem informação suficiente para rotear sem perguntar ao usuário?",
"criteria": {
"true": "Sim, a intenção está clara",
"false": "Não, é preciso perguntar ao usuário"
}
},
"gold": "true"
},
{
"name": "noul:vaga",
"state": "Contexto:\nAgente ativo: nenhum\nSessões suspensas: nenhuma\n\nMensagem: oi",
"question": {
"type": "noul",
"instructions": "A Mensagem tem informação suficiente para rotear sem perguntar ao usuário?",
"criteria": {
"true": "Sim, a intenção está clara",
"false": "Não, é preciso perguntar ao usuário"
}
},
"gold": "false"
},
{
"name": "score:alta",
"state": "Contexto:\nAgente ativo: nenhum\nSessões suspensas: nenhuma\n\nMensagem: URGENTE!!! estou sem serviço há três dias e preciso resolver agora",
"question": {
"type": "score",
"instructions": "Qual a urgência da Mensagem?",
"criteria": [
"Baixa",
"Média",
"Alta"
]
},
"gold": "2"
},
{
"name": "score:baixa",
"state": "Contexto:\nAgente ativo: nenhum\nSessões suspensas: nenhuma\n\nMensagem: quando puder, gostaria de saber se existe algum plano novo",
"question": {
"type": "score",
"instructions": "Qual a urgência da Mensagem?",
"criteria": [
"Baixa",
"Média",
"Alta"
]
},
"gold": "0"
}
]
}

View File

@@ -38,7 +38,7 @@
"criteria": { "criteria": {
"type": "object", "type": "object",
"minProperties": 2, "minProperties": 2,
"maxProperties": 20, "maxProperties": 16,
"additionalProperties": {"type": "string", "minLength": 1} "additionalProperties": {"type": "string", "minLength": 1}
} }
} }
@@ -67,7 +67,7 @@
"criteria": { "criteria": {
"type": "array", "type": "array",
"minItems": 2, "minItems": 2,
"maxItems": 20, "maxItems": 16,
"items": {"type": "string", "minLength": 1} "items": {"type": "string", "minLength": 1}
} }
} }

View File

@@ -0,0 +1,155 @@
# Taxonomia de intenções — atendimento de telefonia móvel (genérica, sem marcas).
# Tarefa D1: classificar o enunciado do usuário numa intenção. Não há agentes implementados;
# cada intenção corresponderia a um agente ou fluxo de destino do orquestrador.
# `confusable_with`: pares difíceis usados como distratores na montagem das opções.
version: v1.0
fallback:
id: sem_correspondencia
description: Nenhuma das opções anteriores; pedido fora do escopo do atendimento ou sem intenção identificável
domains:
contas:
- id: conta_segunda_via
description: Obter segunda via da fatura, boleto, código de barras ou código PIX para pagamento
- id: conta_contestacao
description: Contestar valor ou cobrança considerada indevida na fatura e pedir estorno
confusable_with: [vas_cancelamento, roaming_cobranca, conta_explicacao]
- id: conta_explicacao
description: Entender os itens, valores ou aumento da fatura sem necessariamente contestar
confusable_with: [conta_contestacao, plano_consulta]
- id: conta_vencimento
description: Alterar a data de vencimento da fatura
- id: conta_debito_automatico_digital
description: Cadastrar ou cancelar débito automático ou fatura digital por e-mail ou aplicativo
- id: conta_pagamento_confirmacao
description: Confirmar se um pagamento foi reconhecido ou informar que já pagou a conta
confusable_with: [negociacao_religacao]
negociacao:
- id: negociacao_divida
description: Negociar ou parcelar débitos em atraso e obter acordo de pagamento
confusable_with: [conta_segunda_via, negociacao_religacao]
- id: negociacao_religacao
description: Reativar linha bloqueada ou suspensa por falta de pagamento
confusable_with: [conta_pagamento_confirmacao, suporte_sem_sinal]
recarga:
- id: recarga_realizar
description: Fazer uma recarga de créditos em linha pré-paga
- id: recarga_saldo
description: Consultar saldo, créditos ou validade da linha pré-paga
confusable_with: [internet_consumo]
- id: recarga_nao_creditada
description: Recarga paga que não caiu ou não foi creditada
confusable_with: [bonus_nao_recebido, conta_pagamento_confirmacao]
plano:
- id: plano_consulta
description: Informações sobre o plano atual, franquia, minutos e serviços incluídos
confusable_with: [beneficios_consulta, internet_consumo]
- id: plano_troca
description: Mudar de plano, fazer upgrade, downgrade ou migrar entre pré, controle e pós
confusable_with: [plano_nova_linha, desconto_retencao]
- id: plano_nova_linha
description: Contratar uma nova linha ou plano adicional, inclusive dependentes
confusable_with: [plano_troca]
internet:
- id: internet_pacote_adicional
description: Comprar pacote extra de internet móvel quando a franquia acaba
confusable_with: [roaming_pacote, bonus_nao_recebido]
- id: internet_consumo
description: Consultar quanto de internet já foi usado ou quanto resta da franquia
confusable_with: [recarga_saldo, plano_consulta]
- id: internet_problema_conexao
description: Internet móvel lenta, instável ou sem conexão de dados
confusable_with: [suporte_sem_sinal, suporte_configuracao]
servicos_adicionais:
- id: vas_cancelamento
description: Cancelar serviço de valor adicionado ou assinatura digital cobrada na conta ou no crédito
confusable_with: [conta_contestacao, cancelamento_linha]
- id: vas_contratacao
description: Assinar serviço adicional como streaming, música, jogos, armazenamento ou seguro
confusable_with: [beneficios_consulta, parceiros_ofertas]
- id: vas_consulta
description: Saber quais serviços adicionais ou assinaturas estão ativos na linha
confusable_with: [plano_consulta, vas_cancelamento]
beneficios_fidelidade:
- id: beneficios_consulta
description: Conhecer ou ativar benefícios e aplicativos inclusos no plano
confusable_with: [vas_contratacao, plano_consulta]
- id: desconto_retencao
description: Pedir desconto, redução de preço ou condição melhor para permanecer
confusable_with: [plano_troca, cancelamento_linha, negociacao_divida]
- id: fidelidade_pontos
description: Consultar saldo, extrato ou validade de pontos do programa de fidelidade
confusable_with: [recarga_saldo, fidelidade_resgate]
- id: fidelidade_resgate
description: Trocar pontos do programa de fidelidade por produtos, descontos ou aparelhos
confusable_with: [marketplace_compra, fidelidade_pontos]
- id: bonus_nao_recebido
description: Bônus de internet, minutos ou recarga prometido que não foi recebido ou ativado
confusable_with: [recarga_nao_creditada, internet_pacote_adicional]
marketplace:
- id: marketplace_compra
description: Comprar aparelho, acessório ou produto na loja online da operadora ou de parceiros
confusable_with: [fidelidade_resgate, plano_nova_linha]
- id: marketplace_pedido
description: Acompanhar entrega, troca, devolução ou status de pedido feito na loja
confusable_with: [marketplace_compra]
- id: parceiros_ofertas
description: Ofertas, cupons e vantagens de empresas parceiras
confusable_with: [beneficios_consulta, vas_contratacao]
suporte:
- id: suporte_sem_sinal
description: Sem sinal, sem serviço ou sem conseguir fazer ou receber ligações e SMS
confusable_with: [internet_problema_conexao, negociacao_religacao]
- id: suporte_configuracao
description: Configurar aparelho, APN, correio de voz, chamada Wi-Fi ou VoLTE
confusable_with: [internet_problema_conexao, suporte_chip_esim]
- id: suporte_chip_esim
description: Trocar chip, ativar eSIM, chip danificado ou novo aparelho sem reconhecer o chip
confusable_with: [suporte_configuracao, seguranca_perda_roubo]
seguranca:
- id: seguranca_perda_roubo
description: Bloquear linha ou aparelho por perda, furto ou roubo
confusable_with: [suporte_chip_esim, seguranca_fraude]
- id: seguranca_fraude
description: Suspeita de clonagem, golpe, linha usada sem autorização ou contratação não reconhecida
confusable_with: [conta_contestacao, seguranca_perda_roubo]
roaming:
- id: roaming_pacote
description: Ativar roaming internacional ou contratar pacote de viagem
confusable_with: [internet_pacote_adicional, roaming_duvida]
- id: roaming_duvida
description: Saber se o serviço funciona em outro país, tarifas e como usar em viagem
confusable_with: [roaming_pacote]
- id: roaming_cobranca
description: Contestar cobrança de uso no exterior ou roaming indevido
confusable_with: [conta_contestacao]
linha_cadastro:
- id: cadastro_atualizacao
description: Atualizar dados cadastrais, endereço, e-mail ou transferir titularidade
- id: portabilidade
description: Trazer o número de outra operadora ou levar o número para outra
confusable_with: [cancelamento_linha]
- id: cancelamento_linha
description: Cancelar a linha, o plano ou o contrato
confusable_with: [vas_cancelamento, portabilidade, desconto_retencao]
- id: troca_numero
description: Trocar o número da linha
atendimento:
- id: reclamacao_atendimento
description: Reclamar da qualidade do atendimento, de protocolo não resolvido ou pedir ouvidoria
confusable_with: [atendimento_humano]
- id: atendimento_humano
description: Pedir para falar com um atendente humano
confusable_with: [reclamacao_atendimento]
# Enunciados fora de escopo (rótulo de referência: sem_correspondencia).
out_of_scope:
- id: oos_fora_do_dominio
description: Pedido sem relação com telefonia (receitas, clima, esportes, trabalhos escolares, programação)
- id: oos_outra_empresa
description: Assunto de outra empresa ou serviço (banco, companhia aérea, delivery, governo)
- id: oos_conversa
description: Saudação isolada, agradecimento, conversa fiada ou teste do assistente sem pedido
- id: oos_desabafo
description: Desabafo emocional, ofensa ou xingamento sem pedido de atendimento identificável
- id: oos_ininteligivel
description: Texto ininteligível, aleatório ou incompleto demais para ter intenção

1
datagen/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Geração, anotação e montagem do conjunto sintético (tarefas 20–35)."""

188
datagen/annotate.py Normal file
View File

@@ -0,0 +1,188 @@
"""Anotação por três modelos generativos em rodízio (tarefas 30–32).
Para o lote b (10 mensagens embaralhadas): anotadores A = M[b % 3] e B = M[(b+1) % 3], de forma
independente e sem ver o rótulo de geração; desempate T = M[(b+2) % 3] só quando A ≠ B. T vê as duas
propostas (anônimas) e decide; se escolher um terceiro rótulo, o exemplo é excluído (ambiguidade alta).
Uso: python -m datagen.annotate --run /data/runs/datagen-v1 [--workers 6]
Entrada: candidates.jsonl. Saída: annotations.jsonl (bruto, retomável), annotated.jsonl, annotation-report.json.
"""
from __future__ import annotations
import argparse
import json
import random
import threading
from collections import Counter, defaultdict
from concurrent.futures import ThreadPoolExecutor
from itertools import combinations
from pathlib import Path
from dmb.llm import chat, extract_json
from datagen import taxonomy
ANNOTATORS = ["openai.gpt-5.5", "google.gemini-2.5-pro", "xai.grok-4.3"]
BATCH = 10
SEED = 20261007
AMBIGUITY = {"baixa", "media", "alta"}
def system_prompt() -> str:
return (taxonomy.guide() + "\n\n## Taxonomia\n\n" + taxonomy.taxonomy_block()
+ "\n\nResponda somente com JSON válido.")
def annotate_batch(model: str, items: list[dict]) -> dict[str, dict]:
payload = [{"id": it["id"], "mensagem": it["text"]} for it in items]
user = ("Anote cada mensagem. Devolva uma lista JSON com um objeto por mensagem, com as chaves "
"`id`, `rotulo`, `secundarias`, `ambiguidade` e `justificativa`.\n\n"
+ json.dumps(payload, ensure_ascii=False, indent=1))
valid = taxonomy.labels()
out = {}
for attempt in range(2):
pending = [it for it in items if it["id"] not in out]
if not pending:
break
if attempt:
user = user.split("\n\n")[0] + "\n\n" + json.dumps(
[{"id": it["id"], "mensagem": it["text"]} for it in pending], ensure_ascii=False, indent=1)
try:
answer = extract_json(chat(model, system_prompt(), user, temperature=0.0, max_tokens=12000))
except Exception:
continue
for a in answer if isinstance(answer, list) else []:
if not isinstance(a, dict) or a.get("rotulo") not in valid:
continue
out[str(a.get("id"))] = {
"rotulo": a["rotulo"],
"secundarias": [s for s in (a.get("secundarias") or []) if s in valid and s != a["rotulo"]],
"ambiguidade": a.get("ambiguidade") if a.get("ambiguidade") in AMBIGUITY else None,
"justificativa": str(a.get("justificativa") or "")[:300],
}
return out
def adjudicate(model: str, item: dict, a: dict, b: dict) -> dict | None:
user = (
"Duas anotações independentes discordaram. Decida o rótulo correto segundo o guia; você pode "
"escolher uma das duas propostas ou outro rótulo da taxonomia.\n\n"
f"Mensagem: {item['text']}\n\n"
f"Proposta 1: `{a['rotulo']}` — {a['justificativa']}\n"
f"Proposta 2: `{b['rotulo']}` — {b['justificativa']}\n\n"
"Devolva um objeto JSON com `rotulo`, `ambiguidade` e `justificativa`."
)
try:
ans = extract_json(chat(model, system_prompt(), user, temperature=0.0, max_tokens=4000))
except Exception:
return None
if not isinstance(ans, dict) or ans.get("rotulo") not in taxonomy.labels():
return None
return {"rotulo": ans["rotulo"], "ambiguidade": ans.get("ambiguidade"),
"justificativa": str(ans.get("justificativa") or "")[:300]}
def cohen_kappa(pairs: list[tuple[str, str]]) -> float | None:
if not pairs:
return None
n = len(pairs)
po = sum(x == y for x, y in pairs) / n
ca, cb = Counter(x for x, _ in pairs), Counter(y for _, y in pairs)
pe = sum(ca[k] * cb.get(k, 0) for k in ca) / (n * n)
return (po - pe) / (1 - pe) if pe < 1 else 1.0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--run", required=True)
ap.add_argument("--workers", type=int, default=6)
ap.add_argument("--limit", type=int)
args = ap.parse_args()
run = Path(args.run)
items = [json.loads(l) for l in (run / "candidates.jsonl").open()]
random.Random(SEED).shuffle(items)
items = items[: args.limit]
by_id = {it["id"]: it for it in items}
batches = [items[i:i + BATCH] for i in range(0, len(items), BATCH)]
raw_path = run / "annotations.jsonl"
raw = defaultdict(dict) # id -> role -> registro
if raw_path.exists():
for l in raw_path.open():
r = json.loads(l)
raw[r["id"]][r["role"]] = r
lock = threading.Lock()
f = raw_path.open("a")
def save(rec):
with lock:
raw[rec["id"]][rec["role"]] = rec
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
f.flush()
def roles(b):
return {"A": ANNOTATORS[b % 3], "B": ANNOTATORS[(b + 1) % 3], "T": ANNOTATORS[(b + 2) % 3]}
def do_batch(b):
rl = roles(b)
for role in ("A", "B"):
todo = [it for it in batches[b] if role not in raw[it["id"]]]
if todo:
for id_, ann in annotate_batch(rl[role], todo).items():
if id_ in by_id:
save({"id": id_, "role": role, "model": rl[role], **ann})
for it in batches[b]:
r = raw[it["id"]]
if "A" in r and "B" in r and r["A"]["rotulo"] != r["B"]["rotulo"] and "T" not in r:
dec = adjudicate(rl["T"], it, r["A"], r["B"])
if dec:
save({"id": it["id"], "role": "T", "model": rl["T"], **dec})
return b
with ThreadPoolExecutor(args.workers) as pool:
for i, _ in enumerate(pool.map(do_batch, range(len(batches))), 1):
if i % 25 == 0:
print(f"{i}/{len(batches)} lotes anotados", flush=True)
f.close()
# Consolidação
final, status = [], Counter()
pairs = defaultdict(list)
for it in items:
r = raw[it["id"]]
if "A" not in r or "B" not in r:
status["excluido_falha_anotacao"] += 1
continue
a, b = r["A"], r["B"]
pairs[tuple(sorted((a["model"], b["model"])))].append(
(a["rotulo"], b["rotulo"]) if a["model"] < b["model"] else (b["rotulo"], a["rotulo"]))
if a["rotulo"] == b["rotulo"]:
gold, how, why = a["rotulo"], "consenso", a["justificativa"]
elif "T" in r and r["T"]["rotulo"] in (a["rotulo"], b["rotulo"]):
gold, how, why = r["T"]["rotulo"], "desempate", r["T"]["justificativa"]
else:
status["excluido_tres_rotulos" if "T" in r else "excluido_sem_desempate"] += 1
continue
status[how] += 1
amb = [x.get("ambiguidade") for x in (a, b, r.get("T") or {}) if x.get("ambiguidade")]
final.append({**it, "gold_label": gold, "annotation": how, "justification": why,
"annotators": {k: {"model": v["model"], "rotulo": v["rotulo"]} for k, v in r.items()},
"ambiguity": max(amb, key=["baixa", "media", "alta"].index) if amb else None,
"secondary": sorted(set(a.get("secundarias", [])) | set(b.get("secundarias", []))),
"gen_agreement": gold == it["gen_label"]})
with (run / "annotated.jsonl").open("w") as out:
for r in final:
out.write(json.dumps(r, ensure_ascii=False) + "\n")
report = {
"annotators": ANNOTATORS, "items": len(items), "status": dict(status),
"cohen_kappa": {f"{x} × {y}": round(cohen_kappa(p), 4) for (x, y), p in pairs.items()},
"raw_agreement": round(sum(s for s in [status["consenso"]]) / max(1, len(items)), 4),
"agreement_with_generation": round(sum(r["gen_agreement"] for r in final) / max(1, len(final)), 4),
"gold_distribution": dict(Counter(r["gold_label"] for r in final).most_common()),
}
(run / "annotation-report.json").write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
print(json.dumps({k: report[k] for k in ("items", "status", "cohen_kappa", "raw_agreement",
"agreement_with_generation")}, ensure_ascii=False, indent=1))
if __name__ == "__main__":
main()

173
datagen/build.py Normal file
View File

@@ -0,0 +1,173 @@
"""Montagem do conjunto D1: partições, conjuntos de opções, lint e congelamento (tarefas 33–36).
- Partição por `group_id` (lote de geração), estratificada pelo alvo de geração:
intenções (7 lotes) → 3 teste / 2 calibração / 2 desenvolvimento; fora de escopo (9 lotes) → 4 / 2 / 3.
- Conjunto de opções por exemplo (simula o serviço de elegibilidade): k ∈ {4, 8, 12, 16} opções
incluindo `sem_correspondencia` (sempre a última). Distratores: confundíveis → mesmo domínio → aleatórios.
Em 10% dos exemplos dentro do escopo a intenção correta fica fora das opções (rótulo vira
`sem_correspondencia`, `gold_eligible=false`), para medir a abstenção.
- Congelamento: SHA-256 de cada partição em data/splits/MANIFEST.json; cópia em dmb-entrada e dmb-logs-imutaveis.
Uso: python -m datagen.build --run /data/runs/datagen-v1 [--freeze]
"""
from __future__ import annotations
import argparse
import hashlib
import json
import random
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from datagen import taxonomy
ROOT = Path(__file__).resolve().parents[1]
SPLITS_DIR = ROOT / "data" / "splits"
SEED = 20261007
K_CHOICES = [4, 8, 12, 16]
P_GOLD_EXCLUDED = 0.10
QUESTION = "Qual é a intenção principal do cliente na Mensagem?"
MAX_OPTIONS = 16
FALLBACK = "sem_correspondencia"
SPLIT_PATTERN = {"intent": ["test"] * 3 + ["calibration"] * 2 + ["dev"] * 2,
"oos": ["test"] * 4 + ["calibration"] * 2 + ["dev"] * 3}
def assign_splits(rows: list[dict]) -> dict[str, str]:
groups = defaultdict(set)
kind = {}
for r in rows:
groups[r["gen_target"]].add(r["group_id"])
kind[r["gen_target"]] = r["kind"]
out = {}
for target, gids in sorted(groups.items()):
gids = sorted(gids)
random.Random(f"{SEED}-{target}").shuffle(gids)
pattern = SPLIT_PATTERN[kind[target]]
for i, g in enumerate(gids):
out[g] = pattern[i % len(pattern)]
return out
def option_set(row: dict) -> tuple[dict, str, bool]:
tax = taxonomy.load()
intents = tax["intents"]
rng = random.Random(f"{SEED}-{row['id']}")
k = K_CHOICES[int(hashlib.sha256(row["id"].encode()).hexdigest(), 16) % len(K_CHOICES)]
gold = row["gold_label"]
in_scope = gold != FALLBACK
include_gold = in_scope and rng.random() >= P_GOLD_EXCLUDED
chosen = [gold] if include_gold else []
pool = []
if in_scope:
pool += [c for c in intents[gold]["confusable_with"] if c != gold]
pool += [i for i, v in intents.items() if v["domain"] == intents[gold]["domain"] and i != gold]
rest = [i for i in intents if i != gold]
rng.shuffle(rest)
pool += rest
for c in pool:
if len(chosen) >= k - 1:
break
if c not in chosen and c != gold:
chosen.append(c)
rng.shuffle(chosen)
options = {c: intents[c]["description"] for c in chosen}
options[FALLBACK] = tax["fallback"]["description"]
label = gold if include_gold else FALLBACK
return options, label, include_gold or not in_scope
def to_example(row: dict, split: str) -> dict:
options, label, eligible = option_set(row)
return {
"id": f"{row['id']}-D1", "group_id": row["group_id"], "task": "D1", "split": split,
"state": {"active_agent": None, "suspended_sessions": [], "recent_turns": []},
"utterance": row["text"], "question": QUESTION, "question_type": "choice",
"options": options, "gold_label": label,
"metadata": {
"taxonomy_version": row["taxonomy_version"], "source": "synthetic",
"intent_gold": row["gold_label"], "gold_eligible": eligible,
"domain": taxonomy.load()["intents"].get(row["gold_label"], {}).get("domain", "fora_de_escopo"),
"kind": row["kind"], "oos_category": row["gen_target"] if row["kind"] == "oos" else None,
"profile": row["profile"], "sentiment": row.get("sentiment"),
"secondary": row.get("secondary", []), "ambiguity": row.get("ambiguity"),
"annotation": row["annotation"], "gen_agreement": row["gen_agreement"],
"generator": row["generator"], "n_options": len(options),
"length_chars": len(row["text"]),
"length_bucket": "curto" if len(row["text"]) < 60 else "medio" if len(row["text"]) < 200 else "longo",
},
}
def lint(splits: dict[str, list[dict]]) -> list[str]:
errors, seen_ids, group_split = [], set(), {}
for name, rows in splits.items():
for r in rows:
if r["id"] in seen_ids:
errors.append(f"id duplicado {r['id']}")
seen_ids.add(r["id"])
if not 2 <= len(r["options"]) <= MAX_OPTIONS:
errors.append(f"{r['id']}: {len(r['options'])} opções")
if r["gold_label"] not in r["options"]:
errors.append(f"{r['id']}: rótulo fora das opções")
if list(r["options"])[-1] != FALLBACK:
errors.append(f"{r['id']}: fallback não é a última opção")
if group_split.setdefault(r["group_id"], name) != name:
errors.append(f"grupo {r['group_id']} em mais de uma partição")
return errors
def sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--run", required=True)
ap.add_argument("--freeze", action="store_true")
args = ap.parse_args()
run = Path(args.run)
rows = [json.loads(l) for l in (run / "annotated.jsonl").open()]
split_of = assign_splits(rows)
splits = defaultdict(list)
for r in sorted(rows, key=lambda r: r["id"]):
splits[split_of[r["group_id"]]].append(to_example(r, split_of[r["group_id"]]))
errors = lint(splits)
if errors:
raise SystemExit("lint reprovado:\n" + "\n".join(errors[:20]))
SPLITS_DIR.mkdir(parents=True, exist_ok=True)
manifest = {"version": "d1-v1", "taxonomy_version": taxonomy.load()["version"],
"built_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"source_run": str(run), "seed": SEED, "splits": {}}
for name in ("dev", "calibration", "test"):
path = SPLITS_DIR / f"{name}.jsonl"
with path.open("w") as f:
for ex in splits[name]:
f.write(json.dumps(ex, ensure_ascii=False) + "\n")
rows_ = splits[name]
manifest["splits"][name] = {
"file": path.name, "sha256": sha256_file(path), "examples": len(rows_),
"groups": len({r["group_id"] for r in rows_}),
"labels": len({r["gold_label"] for r in rows_}),
"oos": sum(r["metadata"]["kind"] == "oos" for r in rows_),
"gold_not_eligible": sum(not r["metadata"]["gold_eligible"] for r in rows_),
"n_options": dict(sorted(Counter(r["metadata"]["n_options"] for r in rows_).items())),
}
(SPLITS_DIR / "MANIFEST.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
print(json.dumps(manifest["splits"], indent=1))
if args.freeze:
from harness import objstore
stamp = manifest["built_at"].replace(":", "").replace("-", "")
for bucket in ("dmb-entrada", "dmb-logs-imutaveis"):
for f in ["MANIFEST.json"] + [f"{n}.jsonl" for n in ("dev", "calibration", "test")]:
objstore.put(bucket, f"splits/{manifest['version']}/{stamp}/{f}", SPLITS_DIR / f)
for f in ("generated.jsonl", "candidates.jsonl", "annotations.jsonl", "annotated.jsonl",
"prepare-report.json", "annotation-report.json"):
if (run / f).exists():
objstore.put(bucket, f"datagen/{manifest['version']}/{stamp}/{f}", run / f)
print(f"congelado em dmb-entrada e dmb-logs-imutaveis: splits/{manifest['version']}/{stamp}/")
if __name__ == "__main__":
main()

147
datagen/generate.py Normal file
View File

@@ -0,0 +1,147 @@
"""Geração sintética dos enunciados (tarefas 24 e 27).
Cada lote = (intenção ou categoria fora de escopo) × perfil, com N enunciados distintos.
O lote é o `group_id`: enunciados do mesmo lote nunca são separados entre partições.
Geradores alternados (fornecedores distintos dos anotadores). Retomável: lotes já gravados
em <out>/generated.jsonl são pulados.
Uso (na VM): python -m datagen.generate --out /data/runs/datagen [--per-batch 12] [--workers 8]
"""
from __future__ import annotations
import argparse
import json
import random
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from dmb.llm import chat, extract_json
from datagen import taxonomy
GENERATORS = ["meta.llama-4-maverick-17b-128e-instruct-fp8", "cohere.command-a-03-2025"]
SEED = 20261007
PROFILES = {
"curto_neutro": "mensagens curtas e diretas (3 a 12 palavras), tom neutro",
"informal_abreviado": "linguagem informal de chat, com abreviações e gírias (vc, pq, q, tb, blz, mds, pfv)",
"erros_digitacao": "MUITOS erros de digitação em toda mensagem: letras trocadas, faltando ou repetidas, sem acentuação, sem pontuação, tudo minúsculo, palavras grudadas",
"regionalismo": "regionalismos e expressões de diferentes regiões do Brasil (Nordeste, Sul, Minas, Rio, São Paulo, Norte)",
"emocional": "cliente irritado, frustrado, ansioso ou indignado; pode usar caixa alta, exclamações e xingamentos leves, mas com o pedido identificável",
"longo_contexto": "mensagens longas (2 a 5 frases) contando a história do problema antes do pedido",
"indireto_multiplo": "pedido indireto ou implícito; em metade das mensagens inclua também um segundo pedido secundário diferente",
}
OOS_PROFILES = ["curto_neutro", "informal_abreviado", "erros_digitacao", "regionalismo", "emocional",
"longo_contexto", "indireto_multiplo", "curto_neutro", "emocional"]
SYSTEM = (
"Você gera dados sintéticos realistas em português do Brasil para avaliar um classificador de "
"intenções do atendimento digital de uma operadora de telefonia móvel. Escreva como clientes reais "
"digitam no chat ou no aplicativo de mensagens. Nunca use nomes de empresas, marcas ou produtos reais, "
"nem dados pessoais reais: se precisar de números, use valores claramente fictícios. "
"Responda somente com JSON válido."
)
def batch_specs(per_batch: int) -> list[dict]:
tax = taxonomy.load()
specs = []
for intent, info in tax["intents"].items():
for profile in PROFILES:
specs.append({"kind": "intent", "target": intent, "domain": info["domain"], "profile": profile})
for oos in tax["out_of_scope"]:
for i, profile in enumerate(OOS_PROFILES):
specs.append({"kind": "oos", "target": oos, "domain": "fora_de_escopo", "profile": profile, "rep": i})
for i, s in enumerate(specs):
s["group_id"] = f"g{i:04d}-{s['target']}-{s['profile']}" + (f"-{s['rep']}" if "rep" in s else "")
s["generator"] = GENERATORS[i % len(GENERATORS)]
s["n"] = per_batch
return specs
def prompt(spec: dict) -> str:
tax = taxonomy.load()
if spec["kind"] == "intent":
info = tax["intents"][spec["target"]]
others = [k for k in tax["intents"] if k != spec["target"]]
rng = random.Random(f"{SEED}-{spec['group_id']}")
hints = info["confusable_with"] + rng.sample(others, 3)
target = (f"Intenção que TODAS as mensagens devem expressar como pedido principal: "
f"`{spec['target']}` — {info['description']}.\n"
f"Cuidado para não escrever mensagens que se encaixem melhor nestas outras intenções: "
+ "; ".join(f"`{h}` ({tax['intents'][h]['description']})" for h in hints) + ".")
if spec["profile"] == "indireto_multiplo":
secondary = ("Em metade das mensagens inclua um segundo pedido, de intenção diferente da principal, "
"e informe o id em `secundaria` escolhendo entre: " + ", ".join(sorted(tax["intents"]))
+ ". Nas demais, `secundaria`: null.")
else:
secondary = "Cada mensagem deve conter UM ÚNICO pedido, sem pedidos adicionais; `secundaria`: null."
else:
target = (f"As mensagens NÃO devem ter nenhum pedido de atendimento de telefonia. Categoria: "
f"`{spec['target']}` — {tax['out_of_scope'][spec['target']]}.")
secondary = "Use `secundaria`: null."
return (
f"{target}\n\nEstilo do lote: {PROFILES[spec['profile']]}.\n\n"
f"Gere {spec['n']} mensagens diferentes entre si (vocabulário, estrutura e situação variados; "
f"não repita o mesmo início). {secondary}\n\n"
"Formato: lista JSON de objetos com as chaves `texto` (a mensagem), `sentimento` "
"(neutro, positivo, frustrado, irritado, ansioso ou indignado) e `secundaria` (id ou null)."
)
def run_batch(spec: dict) -> list[dict]:
raw = chat(spec["generator"], SYSTEM, prompt(spec), temperature=0.9, max_tokens=4000)
items = extract_json(raw)
rows = []
for j, it in enumerate(items[: spec["n"]]):
text = (it.get("texto") or "").strip()
if not text:
continue
rows.append({
"id": f"{spec['group_id']}-{j:02d}", "group_id": spec["group_id"], "text": text,
"gen_label": spec["target"] if spec["kind"] == "intent" else "sem_correspondencia",
"gen_target": spec["target"], "kind": spec["kind"], "domain": spec["domain"],
"profile": spec["profile"], "sentiment": it.get("sentimento"),
"gen_secondary": it.get("secundaria"), "generator": spec["generator"],
"taxonomy_version": taxonomy.load()["version"],
})
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True)
ap.add_argument("--per-batch", type=int, default=12)
ap.add_argument("--workers", type=int, default=8)
ap.add_argument("--limit", type=int)
args = ap.parse_args()
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
path = out / "generated.jsonl"
done = set()
if path.exists():
done = {json.loads(l)["group_id"] for l in path.open()}
specs = [s for s in batch_specs(args.per_batch) if s["group_id"] not in done][: args.limit]
print(f"{len(specs)} lotes a gerar ({len(done)} já prontos)", flush=True)
lock = threading.Lock()
failures = []
with ThreadPoolExecutor(args.workers) as pool, path.open("a") as f:
futures = {pool.submit(run_batch, s): s for s in specs}
for i, fut in enumerate(as_completed(futures), 1):
spec = futures[fut]
try:
rows = fut.result()
except Exception as error: # um lote ruim não derruba a geração; é refeito na próxima rodada
failures.append((spec["group_id"], str(error)[:120]))
continue
with lock:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
f.flush()
if i % 20 == 0:
print(f"{i}/{len(specs)} lotes", flush=True)
print(f"falhas: {len(failures)}", *failures[:10], sep="\n", flush=True)
if __name__ == "__main__":
main()

81
datagen/prepare.py Normal file
View File

@@ -0,0 +1,81 @@
"""Mascaramento, deduplicação e critérios de inclusão antes da anotação (tarefas 25 e 28).
Uso: python -m datagen.prepare --run /data/runs/datagen-v1
Entrada: generated.jsonl. Saída: candidates.jsonl + prepare-report.json.
"""
from __future__ import annotations
import argparse
import json
import re
import unicodedata
from collections import Counter
from pathlib import Path
# Ordem importa: padrões mais longos primeiro.
PII_PATTERNS = [
("<CARTAO>", re.compile(r"\b\d{4}[ .-]?\d{4}[ .-]?\d{4}[ .-]?\d{4}\b")),
("<CNPJ>", re.compile(r"\b\d{2}\.?\d{3}\.?\d{3}/?\d{4}-?\d{2}\b")),
("<CPF>", re.compile(r"\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b")),
("<EMAIL>", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")),
("<TELEFONE>", re.compile(r"(?:\+?55\s?)?\(?\b\d{2}\)?[\s-]?9?\d{4}[\s-]?\d{4}\b")),
("<CEP>", re.compile(r"\b\d{5}-\d{3}\b")),
]
MIN_CHARS, MAX_CHARS = 2, 700
NEAR_DUP_JACCARD = 0.85
def mask(text: str) -> tuple[str, list[str]]:
found = []
for token, pattern in PII_PATTERNS:
text, n = pattern.subn(token, text)
found += [token] * n
return text, found
def normalize(text: str) -> str:
text = unicodedata.normalize("NFKD", text.lower())
text = "".join(c for c in text if not unicodedata.combining(c))
return re.sub(r"\s+", " ", re.sub(r"[^\w<> ]", " ", text)).strip()
def shingles(text: str, k: int = 5) -> frozenset:
return frozenset(text[i:i + k] for i in range(max(1, len(text) - k + 1)))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--run", required=True)
args = ap.parse_args()
run = Path(args.run)
rows = [json.loads(l) for l in (run / "generated.jsonl").open()]
report = Counter()
kept, seen_exact, kept_shingles = [], set(), []
for r in rows:
text, found = mask(r["text"])
report["pii_masked"] += len(found)
if not (MIN_CHARS <= len(text) <= MAX_CHARS):
report["excluded_length"] += 1
continue
norm = normalize(text)
if norm in seen_exact:
report["excluded_exact_dup"] += 1
continue
sh = shingles(norm)
if any(len(sh & o) / len(sh | o) >= NEAR_DUP_JACCARD for o in kept_shingles
if abs(len(o) - len(sh)) <= 0.3 * len(sh)):
report["excluded_near_dup"] += 1
continue
seen_exact.add(norm)
kept_shingles.append(sh)
kept.append({**r, "text": text, "pii_masked": found})
with (run / "candidates.jsonl").open("w") as f:
for r in kept:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
report.update(generated=len(rows), kept=len(kept))
(run / "prepare-report.json").write_text(json.dumps(dict(report), indent=2) + "\n")
print(json.dumps(dict(report)))
if __name__ == "__main__":
main()

40
datagen/taxonomy.py Normal file
View File

@@ -0,0 +1,40 @@
"""Leitura da taxonomia e do guia de anotação."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
TAXONOMY = ROOT / "data" / "taxonomy" / "intents-v1.yaml"
GUIDE = ROOT / "docs" / "06-guia-de-anotacao.md"
@lru_cache(maxsize=None)
def load() -> dict:
raw = yaml.safe_load(TAXONOMY.read_text())
intents = {}
for domain, items in raw["domains"].items():
for it in items:
intents[it["id"]] = {"domain": domain, "description": it["description"],
"confusable_with": it.get("confusable_with", [])}
return {"version": raw["version"], "fallback": raw["fallback"], "intents": intents,
"out_of_scope": {o["id"]: o["description"] for o in raw["out_of_scope"]}}
def guide() -> str:
text = GUIDE.read_text()
return text.split("<!-- GUIA:INICIO -->")[1].split("<!-- GUIA:FIM -->")[0].strip()
def taxonomy_block() -> str:
t = load()
lines = [f"- `{k}` ({v['domain']}): {v['description']}" for k, v in t["intents"].items()]
lines.append(f"- `{t['fallback']['id']}`: {t['fallback']['description']}")
return "\n".join(lines)
def labels() -> set[str]:
t = load()
return set(t["intents"]) | {t["fallback"]["id"]}

1
dmb/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Benchmark de modelos de decisão PT-BR: código comum."""

50
dmb/adapter.py Normal file
View File

@@ -0,0 +1,50 @@
"""Interface comum dos adaptadores JEV locais (tarefas 42–47)."""
from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
class Adapter(ABC):
"""Um candidato servido atrás de POST /v1/systemone.
`decide` recebe o estado já serializado (texto) e as perguntas no formato JEV e
devolve as respostas JEV de cada pergunta, mais o total de tokens de entrada.
"""
name: str
adapter_version = "0.1.0"
probabilities_source = "native"
def __init__(self, model_dir: str | Path, revision: str, device: str = "auto",
context_limit: int | None = None):
self.model_dir = Path(model_dir)
self.revision = revision
self.device = device
self._context_limit = context_limit
@property
def model_id(self) -> str:
return f"{self.name}@{self.revision[:12]}"
@property
def context_limit(self) -> int:
return self._context_limit or self.native_context_limit()
@abstractmethod
def load(self) -> None: ...
@abstractmethod
def native_context_limit(self) -> int: ...
@abstractmethod
def count_tokens(self, text: str) -> int: ...
@abstractmethod
def decide(self, state: str, questions: dict) -> tuple[dict, int]: ...
def describe(self) -> dict:
return {"name": self.name, "model_id": self.model_id, "revision": self.revision,
"adapter_version": self.adapter_version, "device": self.device,
"context_limit": self.context_limit,
"probabilities_source": self.probabilities_source}

76
dmb/canonical.py Normal file
View File

@@ -0,0 +1,76 @@
"""Formato canônico, serialização do estado e conversão para JEV (tarefas 36, 53 e 54)."""
from __future__ import annotations
from typing import Callable, Iterator
from .jev import validate_request
SERIALIZATION_VERSION = "state-text-v1"
def iter_questions(example: dict) -> Iterator[dict]:
"""Um exemplo traz uma pergunta (campos na raiz) ou várias (`questions`, tarefa 65)."""
if "questions" in example:
yield from example["questions"]
else:
yield {k: example[k] for k in ("question", "question_type", "options", "gold_label") if k in example} | {
"name": example["task"]}
def serialize_state(state: dict, utterance: str, n_turns: int) -> str:
"""Regra única de serialização para todos os candidatos (state-text-v1)."""
lines = ["Contexto:",
f"Agente ativo: {state.get('active_agent') or 'nenhum'}"]
suspended = state.get("suspended_sessions") or []
lines.append("Sessões suspensas: " + (", ".join(suspended) if suspended else "nenhuma"))
turns = (state.get("recent_turns") or [])[-n_turns:] if n_turns > 0 else []
if turns:
lines.append("Turnos anteriores:")
for t in turns:
who = "Usuário" if t["role"] == "user" else f"Agente ({t.get('agent_id', '?')})"
lines.append(f"- {who}: {t['text']}")
lines += ["", f"Mensagem: {utterance}"]
return "\n".join(lines)
def fit_state(state: dict, utterance: str, n_turns: int, budget: int,
count_tokens: Callable[[str], int]) -> tuple[str, dict]:
"""Truncamento comum: descarta primeiro os turnos mais antigos; o enunciado só é
cortado no fim se sozinho estourar o orçamento."""
for n in range(n_turns, -1, -1):
text = serialize_state(state, utterance, n)
tokens = count_tokens(text)
if tokens <= budget:
return text, {"turns_used": n, "tokens": tokens, "truncated": n < n_turns}
lo, hi = 0, len(utterance)
while lo < hi:
mid = (lo + hi + 1) // 2
if count_tokens(serialize_state(state, utterance[:mid], 0)) <= budget:
lo = mid
else:
hi = mid - 1
text = serialize_state(state, utterance[:lo], 0)
return text, {"turns_used": 0, "tokens": count_tokens(text), "truncated": True,
"utterance_chars_kept": lo}
def jev_question(q: dict) -> dict:
kind = q.get("question_type", "choice")
if kind == "score":
criteria = list(q["options"].values()) if isinstance(q["options"], dict) else list(q["options"])
else:
criteria = dict(q["options"])
return {"type": kind, "instructions": q["question"], "criteria": criteria}
def to_jev_request(example: dict, model: str, n_turns: int, budget: int | None = None,
count_tokens: Callable[[str], int] | None = None) -> tuple[dict, dict]:
if budget is not None and count_tokens is not None:
state, info = fit_state(example["state"], example["utterance"], n_turns, budget, count_tokens)
else:
state, info = serialize_state(example["state"], example["utterance"], n_turns), {"truncated": False}
request = {"model": model, "state": state,
"questions": {q["name"]: jev_question(q) for q in iter_questions(example)}}
validate_request(request)
info["serialization"] = SERIALIZATION_VERSION
return request, info

128
dmb/jev.py Normal file
View File

@@ -0,0 +1,128 @@
"""Contrato jev-compat-v1: validação, erros padronizados e montagem das respostas (tarefas 37–40)."""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
import jsonschema
CONTRACT_VERSION = "jev-compat-v1"
SCHEMA_DIR = Path(__file__).resolve().parents[1] / "contracts" / "jev" / "schemas"
# Piso numérico comum a todos os candidatos: o Laya arredonda em 4 casas e
# probabilidades exatamente 0 tornariam a log-loss infinita.
PROB_FLOOR = 1e-6
class JevError(Exception):
status = 500
error_type = "internal"
def __init__(self, message: str):
super().__init__(message)
self.message = message
def body(self) -> dict:
return {"detail": {"error_type": self.error_type, "message": self.message}}
class InvalidRequest(JevError):
status = 422
error_type = "invalid_request"
class ContextLimit(JevError):
status = 422
error_type = "context"
class UnknownOption(JevError):
status = 502
error_type = "unknown_option"
class ModelUnavailable(JevError):
status = 503
error_type = "unavailable"
@lru_cache(maxsize=None)
def _validator(name: str) -> jsonschema.Draft202012Validator:
schema = json.loads((SCHEMA_DIR / f"{name}.schema.json").read_text())
return jsonschema.Draft202012Validator(schema)
def validate_request(request: dict) -> None:
errors = sorted(_validator("request").iter_errors(request), key=lambda e: list(e.path))
if errors:
e = errors[0]
where = "/".join(str(p) for p in e.path) or "<raiz>"
raise InvalidRequest(f"{where}: {e.message}")
def response_errors(response: dict) -> list[str]:
return [f"{'/'.join(map(str, e.path)) or '<raiz>'}: {e.message}"
for e in _validator("response").iter_errors(response)]
def option_keys(question: dict) -> list[str]:
"""Chaves das opções na ordem declarada (tarefa 49)."""
kind = question["type"]
if kind == "choice":
return list(question["criteria"])
if kind == "noul":
return ["true", "false"]
return [str(i) for i in range(len(question["criteria"]))]
def option_descriptions(question: dict) -> dict[str, str]:
kind = question["type"]
if kind == "score":
return {str(i): d for i, d in enumerate(question["criteria"])}
return {k: question["criteria"][k] for k in option_keys(question)}
def normalize(probabilities: dict[str, float], keys: list[str]) -> dict[str, float]:
"""Mesmas chaves, mesma ordem, piso numérico e soma 1."""
if set(probabilities) != set(keys):
extra = sorted(set(probabilities) - set(keys))
missing = sorted(set(keys) - set(probabilities))
raise UnknownOption(f"opções divergentes: extras={extra} ausentes={missing}")
clipped = {k: max(float(probabilities[k]), PROB_FLOOR) for k in keys}
total = sum(clipped.values())
return {k: v / total for k, v in clipped.items()}
def choice_answer(probabilities: dict[str, float], question: dict, confidence: float | None = None) -> dict:
keys = option_keys(question)
p = normalize(probabilities, keys)
choice = max(keys, key=p.__getitem__)
return {"type": "choice", "choice": choice,
"confidence": float(confidence) if confidence is not None else p[choice],
"probabilities": p}
def noul_answer(p_true: float) -> dict:
p = min(max(float(p_true), PROB_FLOOR), 1 - PROB_FLOOR)
return {"type": "noul", "noul": p}
def score_answer(probabilities: dict[str, float], question: dict, confidence: float | None = None) -> dict:
keys = option_keys(question)
p = normalize(probabilities, keys)
best = max(keys, key=p.__getitem__)
return {"type": "score", "score": sum(int(k) * v for k, v in p.items()),
"confidence": float(confidence) if confidence is not None else p[best],
"probabilities": p, "legend": option_descriptions(question)}
def answer_from_distribution(question: dict, probabilities: dict[str, float],
confidence: float | None = None) -> dict:
"""Monta a resposta JEV de qualquer tipo a partir de uma distribuição sobre option_keys()."""
kind = question["type"]
if kind == "choice":
return choice_answer(probabilities, question, confidence)
if kind == "noul":
p = normalize(probabilities, ["true", "false"])
return noul_answer(p["true"])
return score_answer(probabilities, question, confidence)

91
dmb/llm.py Normal file
View File

@@ -0,0 +1,91 @@
"""Cliente mínimo do OCI Generative AI (chat on-demand) para geração e anotação de dados.
Autenticação: resource principal (jobs/notebooks), instance principal (VM, DMB_OCI_AUTH=instance_principal)
ou a chave de API em .secrets/ (máquina local).
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
from functools import lru_cache
from pathlib import Path
GENAI_REGION = os.environ.get("DMB_GENAI_REGION", "us-chicago-1")
ROOT = Path(__file__).resolve().parents[1]
@lru_cache(maxsize=None)
def _client_and_compartment():
import oci
endpoint = f"https://inference.generativeai.{GENAI_REGION}.oci.oraclecloud.com"
retry = oci.retry.DEFAULT_RETRY_STRATEGY
if os.environ.get("OCI_RESOURCE_PRINCIPAL_VERSION"):
signer = oci.auth.signers.get_resource_principals_signer()
client = oci.generative_ai_inference.GenerativeAiInferenceClient(
{}, signer=signer, service_endpoint=endpoint, retry_strategy=retry, timeout=(10, 300))
elif os.environ.get("DMB_OCI_AUTH") == "instance_principal":
signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner()
client = oci.generative_ai_inference.GenerativeAiInferenceClient(
{}, signer=signer, service_endpoint=endpoint, retry_strategy=retry, timeout=(10, 300))
else:
sys.path.insert(0, str(ROOT / "infra" / "oci"))
from session import load_config
client = oci.generative_ai_inference.GenerativeAiInferenceClient(
load_config(region=GENAI_REGION), service_endpoint=endpoint, retry_strategy=retry, timeout=(10, 300))
compartment = os.environ.get("DMB_COMPARTMENT_OCID") or os.environ.get("JOB_COMPARTMENT_OCID")
if not compartment:
raise RuntimeError("defina DMB_COMPARTMENT_OCID")
return client, compartment
def chat(model: str, system: str, user: str, temperature: float = 0.7, max_tokens: int = 4000,
attempts: int = 4) -> str:
import oci
from oci.generative_ai_inference import models as m
client, compartment = _client_and_compartment()
if model.startswith("cohere."):
request = m.CohereChatRequest(preamble_override=system, message=user, temperature=temperature,
max_tokens=max_tokens)
else:
msgs = [m.SystemMessage(content=[m.TextContent(text=system)]),
m.UserMessage(content=[m.TextContent(text=user)])]
kwargs = {"messages": msgs, "temperature": temperature}
# modelos de raciocínio da OpenAI só aceitam max_completion_tokens e temperatura padrão
if model.startswith("openai.gpt-5") or model.startswith("openai.o"):
kwargs.pop("temperature")
kwargs["max_completion_tokens"] = max_tokens
else:
kwargs["max_tokens"] = max_tokens
request = m.GenericChatRequest(**kwargs)
details = m.ChatDetails(compartment_id=compartment, serving_mode=m.OnDemandServingMode(model_id=model),
chat_request=request)
for attempt in range(attempts):
try:
resp = client.chat(details).data.chat_response
if model.startswith("cohere."):
return resp.text
return "".join(c.text for c in resp.choices[0].message.content if getattr(c, "text", None))
except oci.exceptions.ServiceError as error:
if error.status in (429, 500, 502, 503, 504) and attempt < attempts - 1:
time.sleep(5 * 2 ** attempt)
continue
raise
def extract_json(text: str):
"""Primeiro objeto ou lista JSON do texto (tolera cercas ```json)."""
text = re.sub(r"^```(?:json)?|```$", "", text.strip(), flags=re.M).strip()
for opener, closer in (("[", "]"), ("{", "}")):
start = text.find(opener)
end = text.rfind(closer)
if start != -1 and end > start:
try:
return json.loads(text[start:end + 1])
except json.JSONDecodeError:
continue
raise ValueError(f"resposta sem JSON válido: {text[:200]!r}")

71
dmb/offline.py Normal file
View File

@@ -0,0 +1,71 @@
"""Isolamento de rede dentro do processo (tarefa 19, camada de aplicação).
A camada de rede (VCN sem rota de saída) é a garantia principal; esta camada faz o
processo falhar de forma explícita se um candidato tentar abrir conexão externa.
"""
from __future__ import annotations
import ipaddress
import os
import socket
OFFLINE_ENV = {
"HF_HUB_OFFLINE": "1",
"TRANSFORMERS_OFFLINE": "1",
"HF_DATASETS_OFFLINE": "1",
"HF_HUB_DISABLE_TELEMETRY": "1",
"USE_TF": "0",
}
class NetworkAccessBlocked(RuntimeError):
pass
def _is_local(address) -> bool:
if isinstance(address, tuple):
host = address[0]
else:
return True # sockets AF_UNIX
if host in ("localhost", ""):
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def enforce_offline() -> None:
os.environ.update(OFFLINE_ENV)
if getattr(socket.socket.connect, "_dmb_guard", False):
return
original_connect = socket.socket.connect
original_connect_ex = socket.socket.connect_ex
def guarded_connect(self, address):
if not _is_local(address):
raise NetworkAccessBlocked(f"conexão externa bloqueada durante o benchmark: {address}")
return original_connect(self, address)
def guarded_connect_ex(self, address):
if not _is_local(address):
raise NetworkAccessBlocked(f"conexão externa bloqueada durante o benchmark: {address}")
return original_connect_ex(self, address)
guarded_connect._dmb_guard = True
socket.socket.connect = guarded_connect
socket.socket.connect_ex = guarded_connect_ex
def self_test() -> dict:
"""Prova, dentro do processo, que o bloqueio está ativo: uma conexão externa tem de falhar."""
probe = socket.socket()
try:
probe.connect(("pypi.org", 443))
return {"process_guard": "FALHOU: conexão externa aberta"}
except NetworkAccessBlocked:
return {"process_guard": "ok"}
except OSError as error: # sem rede nenhuma: também prova que não há saída
return {"process_guard": f"ok (rede indisponível: {type(error).__name__})"}
finally:
probe.close()

93
dmb/server.py Normal file
View File

@@ -0,0 +1,93 @@
"""Servidor local POST /v1/systemone, idêntico para todos os candidatos (tarefas 46–48, 50)."""
from __future__ import annotations
import json
import os
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from .adapter import Adapter
from .jev import (CONTRACT_VERSION, InvalidRequest, JevError, ModelUnavailable, UnknownOption,
option_keys, response_errors, validate_request)
def build_response(adapter: Adapter, request: dict, answers: dict, input_tokens: int,
latency_ms: float) -> dict:
if list(answers) != list(request["questions"]):
raise UnknownOption(f"perguntas respondidas {list(answers)} != {list(request['questions'])}")
for name, question in request["questions"].items():
answer = answers[name]
if answer.get("type") != question["type"]:
raise UnknownOption(f"{name}: tipo {answer.get('type')} != {question['type']}")
if question["type"] in ("choice", "score"):
keys = option_keys(question)
if list(answer["probabilities"]) != keys:
raise UnknownOption(f"{name}: ordem ou chaves das opções alteradas")
if question["type"] == "choice" and answer["choice"] not in keys:
raise UnknownOption(f"{name}: escolha {answer['choice']!r} fora de criteria")
response = {
"model": adapter.model_id,
"answers": answers,
"usage": {"input_tokens": int(input_tokens), "output_tokens": 0},
"metadata": {
"latency_ms": latency_ms,
"model_id": adapter.model_id,
"run_id": os.environ.get("DMB_RUN_ID", "local"),
"adapter_version": adapter.adapter_version,
"contract_version": CONTRACT_VERSION,
"probabilities_source": adapter.probabilities_source,
},
}
errors = response_errors(response)
if errors:
raise UnknownOption("resposta fora do contrato: " + "; ".join(errors[:3]))
return response
def systemone(adapter: Adapter, request: dict) -> dict:
"""Caminho único usado pelo HTTP e pelo harness em processo."""
validate_request(request)
state = request["state"]
if not isinstance(state, str):
# Todo adaptador recebe texto; estados estruturados viram JSON canônico.
state = json.dumps(state, ensure_ascii=False, sort_keys=True)
started = time.perf_counter()
answers, input_tokens = adapter.decide(state, request["questions"])
latency_ms = (time.perf_counter() - started) * 1000
return build_response(adapter, request, answers, input_tokens, latency_ms)
def create_app(adapter: Adapter) -> FastAPI:
state = {"ready": False}
@asynccontextmanager
async def lifespan(_app):
started = time.perf_counter()
adapter.load()
state.update(ready=True, load_seconds=time.perf_counter() - started)
yield
app = FastAPI(title=f"dmb {adapter.name}", version=adapter.adapter_version, lifespan=lifespan)
@app.get("/health")
def health():
return {"status": "ready" if state["ready"] else "loading", **adapter.describe(),
"load_seconds": state.get("load_seconds")}
@app.post("/v1/systemone")
async def endpoint(http_request: Request):
try:
try:
body = await http_request.json()
except ValueError as error:
raise InvalidRequest(f"JSON inválido: {error}") from error
if not state["ready"]:
raise ModelUnavailable("modelo ainda carregando")
return systemone(adapter, body)
except JevError as error:
return JSONResponse(status_code=error.status, content=error.body())
return app

View File

@@ -1,5 +1,7 @@
# 02 — Desenho experimental # 02 — Desenho experimental
> **Escopo do primeiro ciclo (revisão de 2026-10-07).** Não há agentes implementados. Por isso o ciclo avalia só a **classificação de intenções (D1)** no domínio de **atendimento de telefonia móvel**, com a taxonomia `data/taxonomy/intents-v1.yaml` (42 intenções em 13 domínios). Pedidos fora de escopo, enunciados emocionais e casos com várias intenções entram como exemplos de D1, com rótulo `sem_correspondencia` ou a intenção principal. D2 a D5 ficam descritas abaixo para os próximos ciclos. Os dados são **100% sintéticos** e a anotação é feita por **três modelos generativos** (ver §4).
## 1. Hipóteses ## 1. Hipóteses
- **H1 (acurácia).** Pelo menos um candidato atinge, em PT-BR, uma acurácia de roteamento (D1/D2) compatível com o uso no orquestrador. O critério é a não inferioridade em relação ao baseline LLM (margem de 3 p.p.); ver [04, Q5](04-questoes-em-aberto.md#q5--critérios-de-sucesso-pré-registrados). - **H1 (acurácia).** Pelo menos um candidato atinge, em PT-BR, uma acurácia de roteamento (D1/D2) compatível com o uso no orquestrador. O critério é a não inferioridade em relação ao baseline LLM (margem de 3 p.p.); ver [04, Q5](04-questoes-em-aberto.md#q5--critérios-de-sucesso-pré-registrados).
@@ -8,12 +10,12 @@
## 2. Tarefas de decisão ## 2. Tarefas de decisão
Cada tarefa tem **pergunta**, **opções com descrição** e **regra de decisão** (tarefa 21). No primeiro ciclo, cada questão tem **no máximo 20 opções**, incluindo `sem_correspondencia` (tarefas 22–23). Cada tarefa tem **pergunta**, **opções com descrição** e **regra de decisão** (tarefa 21). No primeiro ciclo, cada questão tem **no máximo 16 opções**, incluindo `sem_correspondencia` (tarefas 22–23; limite do SemIf, ver Q2).
### D1 — Classificação de intenção ### D1 — Classificação de intenção
- **Pergunta:** "Qual intenção do usuário este enunciado expressa?" - **Pergunta:** "Qual intenção do usuário este enunciado expressa?"
- **Opções:** as intenções elegíveis do exemplo, de 2 a 19, mais `sem_correspondencia`. Cada opção tem uma descrição curta, do mesmo jeito que viria do catálogo de agentes ou intenções. - **Opções:** as intenções elegíveis do exemplo, de 1 a 15, mais `sem_correspondencia`. Cada opção tem uma descrição curta, do mesmo jeito que viria do catálogo de agentes ou intenções.
- **Regra:** escolher a intenção principal que o próximo agente precisa atender. Se nenhuma se aplica, escolher `sem_correspondencia`. - **Regra:** escolher a intenção principal que o próximo agente precisa atender. Se nenhuma se aplica, escolher `sem_correspondencia`.
### D2 — Continuidade de sessão ### D2 — Continuidade de sessão
@@ -83,6 +85,18 @@ Cada tarefa tem **pergunta**, **opções com descrição** e **regra de decisão
## 4. Dados ## 4. Dados
**Primeiro ciclo: pipeline sintético (`datagen/`), executado na VM.**
| Etapa | Implementação | Tarefas |
|-------|---------------|---------|
| Geração | `datagen.generate`: 42 intenções × 7 perfis + 5 categorias fora de escopo × 9 lotes; 12 enunciados por lote. Geradores alternados `meta.llama-4-maverick` e `cohere.command-a` (fornecedores distintos dos anotadores). Perfis: curto/neutro, informal/abreviado, erros de digitação, regionalismo, emocional, longo com contexto, indireto ou com várias intenções | 24, 27 |
| Preparação | `datagen.prepare`: mascaramento de dados pessoais (CPF, CNPJ, cartão, telefone, e-mail, CEP), deduplicação exata e aproximada (Jaccard ≥ 0,85 em 5-gramas), limites de comprimento | 25, 28 |
| Anotação | `datagen.annotate`: três modelos (`openai.gpt-5.5`, `google.gemini-2.5-pro`, `xai.grok-4.3`) em rodízio por lote; dois anotam de forma independente, sem ver o rótulo de geração, e o terceiro desempata. Três rótulos distintos → exemplo excluído. Instruções: [06-guia-de-anotacao.md](06-guia-de-anotacao.md) | 29–32 |
| Montagem | `datagen.build`: partição por lote de geração (3/2/2 lotes por intenção para teste/calibração/dev), conjuntos de 4, 8, 12 ou 16 opções simulando a elegibilidade (10% sem a intenção correta → `sem_correspondencia`), lint e congelamento com SHA-256 em `dmb-entrada` e `dmb-logs-imutaveis` | 33–36 |
Itens abaixo descrevem o desenho geral (válido também para dados reais em ciclos futuros).
- **Unidade de amostragem:** conversa (`group_id`). Cada turno anotado gera um ou mais exemplos (um por tarefa de decisão). - **Unidade de amostragem:** conversa (`group_id`). Cada turno anotado gera um ou mais exemplos (um por tarefa de decisão).
- **Fontes:** enunciados representativos do domínio de destino, anonimizados (tarefas 24–25). Se houver dados sintéticos, eles ficam identificados em `metadata` e são reportados em separado. - **Fontes:** enunciados representativos do domínio de destino, anonimizados (tarefas 24–25). Se houver dados sintéticos, eles ficam identificados em `metadata` e são reportados em separado.
- **Estratificação (tarefa 26):** domínio, comprimento, ambiguidade, classe esperada, posição do turno e presença de agente ativo ou de sessões suspensas. - **Estratificação (tarefa 26):** domínio, comprimento, ambiguidade, classe esperada, posição do turno e presença de agente ativo ou de sessões suspensas.

View File

@@ -45,39 +45,48 @@ As fases F0–F1 (infra) e F2–F3 (dados) podem andar em paralelo.
- [ ] **9.** Criar uma rota temporária de saída por NAT Gateway, só para a aquisição inicial de dependências e pesos. - [ ] **9.** Criar uma rota temporária de saída por NAT Gateway, só para a aquisição inicial de dependências e pesos.
- ↳ Remover a rota logo após a tarefa 16 e passar pelo gate de egresso antes de qualquer execução. *(Q3)* - ↳ Remover a rota logo após a tarefa 16 e passar pelo gate de egresso antes de qualquer execução. *(Q3)*
- ↳ Criar e ativar **só quando necessário**, com `infra/oci/nat.py on`. Para desligar: `nat.py off`, que fecha a rota e bloqueia o NAT. Na tarefa 78: `nat.py off --delete`. - ↳ Criar e ativar **só quando necessário**, com `infra/oci/nat.py on`. Para desligar: `nat.py off`, que fecha a rota e bloqueia o NAT. Na tarefa 78: `nat.py off --delete`.
- [ ] **10.** Validar que os jobs acessam os buckets sem internet pública. - [x] **10.** Validar que os jobs acessam os buckets sem internet pública.
- ↳ Adiado para quando o primeiro job existir (depois da tarefa 11). - ↳ Validado em 2026-10-07 com jobs do Data Science em `dmb-subnet-jobs`. Os pesos do Rizzo (4,4 GB) desceram pela Service Gateway em 28 s, com SHA-256 conferido.
## F1 — Ambiente e artefatos ## F1 — Ambiente e artefatos
- [ ] **11.** Criar uma imagem de contêiner base com Python, CUDA, PyTorch, Transformers, Hugging Face Hub e ferramentas de medição. - [x] **11.** Criar uma imagem de contêiner base com Python, CUDA, PyTorch, Transformers, Hugging Face Hub e ferramentas de medição.
- ↳ Incluir um exportador OpenTelemetry, para que a telemetria do harness tenha o mesmo formato da observabilidade do orquestrador. - ↳ Incluir um exportador OpenTelemetry, para que a telemetria do harness tenha o mesmo formato da observabilidade do orquestrador.
- [ ] **12.** Fixar num manifesto as versões do sistema, dos drivers CUDA, do Python, das bibliotecas e da imagem de contêiner. - ↳ **Substituído na prática por ambientes Python publicados no bucket** (`infra/ds/build_env.sh` → `dmb-artefatos/envs/<candidato>/`), porque os jobs do Data Science dispensam contêiner próprio. O CUDA vem das wheels `cu128` do PyTorch e o Python é o 3.11.9 base dos jobs.
- [ ] **13.** Baixar os pesos, tokenizadores, revisões e licenças aprovados de cada candidato. *(Q4)* - ↳ `env/Dockerfile` com `--build-arg CANDIDATE=...`: **uma imagem por candidato**, porque o SemIf exige `transformers==5.17.0` e o GLiNER exige `transformers<5`. Os pesos ficam fora da imagem. Falta fazer o build e o push para o OCIR, na VM.
- [x] **12.** Fixar num manifesto as versões do sistema, dos drivers CUDA, do Python, das bibliotecas e da imagem de contêiner.
- [x] **13.** Baixar os pesos, tokenizadores, revisões e licenças aprovados de cada candidato. *(Q4)*
- ↳ `env/acquire.py`: todas as licenças são Apache-2.0 ou MIT (incluindo o Qwen3.5-4B). Rizzo: GGUF Q8_0 (padrão do projeto) + llama.cpp b11081. **Não foi preciso NAT**: a aquisição rodou fora da VCN e enviou direto ao bucket.
- ↳ A aquisição falha se a licença estiver fora da allowlist de `config/benchmark.yaml`. O contexto efetivo de cada candidato é medido e registrado. - ↳ A aquisição falha se a licença estiver fora da allowlist de `config/benchmark.yaml`. O contexto efetivo de cada candidato é medido e registrado.
- ↳ Incluir as dependências específicas de cada candidato (pacote GLiNER, runtime SemIf, interface Rizzo Flow) e a revisão fixada de `Qwen/Qwen3.5-4B`. - ↳ Incluir as dependências específicas de cada candidato (pacote GLiNER, runtime SemIf, interface Rizzo Flow) e a revisão fixada de `Qwen/Qwen3.5-4B`.
- [ ] **14.** Calcular o SHA-256 de cada artefato baixado. - [x] **14.** Calcular o SHA-256 de cada artefato baixado.
- [ ] **15.** Copiar os pesos e dependências aprovados para o bucket `artefatos`. - [x] **15.** Copiar os pesos e dependências aprovados para o bucket `artefatos`.
- [ ] **16.** Registrar no manifesto a origem, a revisão, a licença, o hash, a data de aquisição e o tamanho de cada artefato. - [x] **16.** Registrar no manifesto a origem, a revisão, a licença, o hash, a data de aquisição e o tamanho de cada artefato.
- [ ] **17.** Desabilitar a saída pública dos jobs de benchmark. - [x] **17.** Desabilitar a saída pública dos jobs de benchmark.
- [ ] **18.** Carregar cada candidato só a partir dos artefatos locais. - ↳ O gate de egresso foi aprovado nas execuções em sub-rede privada: huggingface.co, pypi.org e github.com ficaram inacessíveis.
- [x] **18.** Carregar cada candidato só a partir dos artefatos locais.
- ↳ Usar `HF_HUB_OFFLINE=1`, `TRANSFORMERS_OFFLINE=1` e um cache apontado para os artefatos verificados. - ↳ Usar `HF_HUB_OFFLINE=1`, `TRANSFORMERS_OFFLINE=1` e um cache apontado para os artefatos verificados.
- [ ] **19.** Fazer o job falhar se algum candidato tentar baixar arquivos durante a inferência. - ↳ `harness/job.py` baixa do bucket só o que está no manifesto e confere o SHA-256. Os quatro candidatos já carregaram offline, a partir dos artefatos locais.
- [x] **19.** Fazer o job falhar se algum candidato tentar baixar arquivos durante a inferência.
- ↳ Duas camadas: as variáveis de ambiente offline e o bloqueio de rede da VCN. Um teste negativo prova que a falha ocorre. - ↳ Duas camadas: as variáveis de ambiente offline e o bloqueio de rede da VCN. Um teste negativo prova que a falha ocorre.
- ↳ Camada de aplicação em `dmb/offline.py` (bloqueia conexões fora do loopback), com teste em `tests/test_offline.py`. O gate de egresso de `harness/job.py` cobre a camada de rede.
## F2 — Tarefas de decisão e taxonomia ## F2 — Tarefas de decisão e taxonomia
- [ ] **20.** Definir a taxonomia de decisões a avaliar em PT-BR. - [x] **20.** Definir a taxonomia de decisões a avaliar em PT-BR.
- ↳ **Primeiro ciclo: só D1 (classificação de intenções), atendimento de telefonia móvel.** `data/taxonomy/intents-v1.yaml`: 42 intenções em 13 domínios + 5 categorias fora de escopo.
- ↳ Taxonomia de **intenções** (D1), ligadas aos **agentes de destino** do catálogo A2A, mais as decisões de **continuidade de sessão** (D2), **desambiguação** (D3) e **várias intenções** (D4). D5 é opcional. Ver [02 §2](02-desenho-experimental.md#2-tarefas-de-decisão). - ↳ Taxonomia de **intenções** (D1), ligadas aos **agentes de destino** do catálogo A2A, mais as decisões de **continuidade de sessão** (D2), **desambiguação** (D3) e **várias intenções** (D4). D5 é opcional. Ver [02 §2](02-desenho-experimental.md#2-tarefas-de-decisão).
- [ ] **21.** Escrever a pergunta, as opções, as descrições das opções e a regra de decisão de cada tarefa. - [x] **21.** Escrever a pergunta, as opções, as descrições das opções e a regra de decisão de cada tarefa.
- ↳ As descrições das opções seguem o estilo de um *agent card*: curtas e orientadas à capacidade do agente. - ↳ As descrições das opções seguem o estilo de um *agent card*: curtas e orientadas à capacidade do agente.
- [ ] **22.** Limitar o primeiro ciclo a no máximo 20 opções por questão, para garantir compatibilidade com o Laya. *(Q2)* - [x] **22.** Limitar o primeiro ciclo a no máximo 20 opções por questão, para garantir compatibilidade com o Laya. *(Q2)*
- ↳ Os subconjuntos de opções por exemplo simulam o serviço de elegibilidade de intenções. - ↳ Os subconjuntos de opções por exemplo simulam o serviço de elegibilidade de intenções.
- [ ] **23.** Incluir uma opção explícita de `outro` ou `sem correspondência` quando a taxonomia não for exaustiva. - ↳ **Limite efetivo: 16 opções**, por causa do SemIf (Q2).
- [x] **23.** Incluir uma opção explícita de `outro` ou `sem correspondência` quando a taxonomia não for exaustiva.
## F3 — Dados e anotação ## F3 — Dados e anotação
- [ ] **24.** Coletar textos PT-BR representativos do domínio de destino. *(Q7)* - [ ] **24.** Coletar textos PT-BR representativos do domínio de destino. *(Q7, Q15)*
- ↳ **100% sintéticos** (`datagen.generate`, na VM).
- ↳ A unidade é a **conversa com vários turnos**, não o enunciado isolado. Cada turno guarda o estado (agente ativo e sessões suspensas). - ↳ A unidade é a **conversa com vários turnos**, não o enunciado isolado. Cada turno guarda o estado (agente ativo e sessões suspensas).
- [ ] **25.** Remover ou mascarar dados pessoais, segredos e identificadores que o benchmark não precisa. - [ ] **25.** Remover ou mascarar dados pessoais, segredos e identificadores que o benchmark não precisa.
- [ ] **26.** Separar os textos por domínio, comprimento, grau de ambiguidade e classe esperada. - [ ] **26.** Separar os textos por domínio, comprimento, grau de ambiguidade e classe esperada.
@@ -85,9 +94,11 @@ As fases F0–F1 (infra) e F2–F3 (dados) podem andar em paralelo.
- [ ] **27.** Criar exemplos adversariais com abreviações, erros de ortografia, regionalismos, textos curtos e várias intenções. - [ ] **27.** Criar exemplos adversariais com abreviações, erros de ortografia, regionalismos, textos curtos e várias intenções.
- ↳ Acrescentar respostas curtas que dependem do contexto ("sim", "o segundo"), troca de assunto, retorno a um assunto anterior e pedidos fora de escopo. - ↳ Acrescentar respostas curtas que dependem do contexto ("sim", "o segundo"), troca de assunto, retorno a um assunto anterior e pedidos fora de escopo.
- [ ] **28.** Definir os critérios de inclusão e exclusão dos exemplos. - [ ] **28.** Definir os critérios de inclusão e exclusão dos exemplos.
- [ ] **29.** Produzir o guia de anotação humana. - [x] **29.** Produzir o guia de anotação humana.
- ↳ [06-guia-de-anotacao.md](06-guia-de-anotacao.md), usado literalmente pelos anotadores.
- ↳ O guia cobre D1–D4 com exemplos-limite (por exemplo, quando um enunciado curto continua a tarefa do agente ativo e quando inicia um novo agente). - ↳ O guia cobre D1–D4 com exemplos-limite (por exemplo, quando um enunciado curto continua a tarefa do agente ativo e quando inicia um novo agente).
- [ ] **30.** Anotar cada exemplo com dois avaliadores independentes. - [ ] **30.** Anotar cada exemplo com dois avaliadores independentes.
- ↳ Avaliadores = modelos generativos em rodízio (`openai.gpt-5.5`, `google.gemini-2.5-pro`, `xai.grok-4.3`). *(Q15)*
- [ ] **31.** Resolver as discordâncias com a revisão de um terceiro avaliador. - [ ] **31.** Resolver as discordâncias com a revisão de um terceiro avaliador.
- [ ] **32.** Registrar, para cada exemplo, a resposta de referência, a justificativa e a versão da taxonomia. - [ ] **32.** Registrar, para cada exemplo, a resposta de referência, a justificativa e a versão da taxonomia.
- ↳ Reportar também a concordância entre anotadores. - ↳ Reportar também a concordância entre anotadores.
@@ -99,27 +110,29 @@ As fases F0–F1 (infra) e F2–F3 (dados) podem andar em paralelo.
## F4 — Contrato JEV e adaptadores ## F4 — Contrato JEV e adaptadores
- [ ] **36.** Criar um formato canônico de entrada com `id`, `state`, `question`, `options`, `gold_label` e metadados. - [x] **36.** Criar um formato canônico de entrada com `id`, `state`, `question`, `options`, `gold_label` e metadados.
- ↳ Ver [02 §3](02-desenho-experimental.md#3-formato-canônico-de-entrada-tarefa-36), que inclui a **regra única de serialização** de `state`. - ↳ Ver [02 §3](02-desenho-experimental.md#3-formato-canônico-de-entrada-tarefa-36), que inclui a **regra única de serialização** de `state`.
- [ ] **37.** Definir a versão do contrato JEV compatível a usar no benchmark: `POST /v1/systemone`, esquema de requisição, esquema de resposta e códigos de erro. *(Q1)* - [x] **37.** Definir a versão do contrato JEV compatível a usar no benchmark: `POST /v1/systemone`, esquema de requisição, esquema de resposta e códigos de erro. *(Q1)*
- ↳ Versão `jev-compat-v1`. Os esquemas já estão em `contracts/jev/schemas/`; falta revisar os códigos de erro no código dos adaptadores. - ↳ Versão `jev-compat-v1`. Os esquemas já estão em `contracts/jev/schemas/`; falta revisar os códigos de erro no código dos adaptadores.
- [ ] **38.** Definir o mapeamento obrigatório entre o contrato JEV e os tipos internos `choice`, `noul` e `score`. - [x] **38.** Definir o mapeamento obrigatório entre o contrato JEV e os tipos internos `choice`, `noul` e `score`.
- [ ] **39.** Definir a regra de conversão de probabilidades, confiança, níveis ordinais, opções descritas e campos de uso. - [x] **39.** Definir a regra de conversão de probabilidades, confiança, níveis ordinais, opções descritas e campos de uso.
- [ ] **40.** Definir o comportamento padronizado para entradas inválidas, limite de contexto, timeout, opção desconhecida e indisponibilidade do modelo. - [x] **40.** Definir o comportamento padronizado para entradas inválidas, limite de contexto, timeout, opção desconhecida e indisponibilidade do modelo.
- ↳ Na prática, uma falha do modelo de decisão vira desambiguação ou fallback no orquestrador. No benchmark, essas falhas contam como erro e são reportadas à parte. - ↳ Na prática, uma falha do modelo de decisão vira desambiguação ou fallback no orquestrador. No benchmark, essas falhas contam como erro e são reportadas à parte.
- [ ] **41.** Criar testes de conformidade com requisições e respostas JEV congeladas. - [x] **41.** Criar testes de conformidade com requisições e respostas JEV congeladas.
- [ ] **42.** Criar um adaptador JEV local para o Laya com o checkpoint multilíngue fixado. - ↳ `contracts/jev/fixtures/conformance.json` + `tests/` (35 testes).
- [ ] **43.** Criar um adaptador JEV local para o SemIf em modo `direct`, com a revisão do Qwen fixada. - [x] **42.** Criar um adaptador JEV local para o Laya com o checkpoint multilíngue fixado.
- [ ] **44.** Criar um adaptador JEV local para o GLiNER Decide usando `classify_text`. - [x] **43.** Criar um adaptador JEV local para o SemIf em modo `direct`, com a revisão do Qwen fixada.
- [ ] **45.** Criar um adaptador JEV local para o Rizzo Flow usando sua interface de decisões. *(Q4)* - [x] **44.** Criar um adaptador JEV local para o GLiNER Decide usando `classify_text`.
- [ ] **46.** Expor cada adaptador no mesmo caminho local `POST /v1/systemone`, sem dependência de rede externa. - [x] **45.** Criar um adaptador JEV local para o Rizzo Flow usando sua interface de decisões. *(Q4)*
- [ ] **47.** Garantir que cada adaptador devolva a resposta JEV completa, incluindo escolha, distribuição de probabilidades, confiança e metadados de uso, quando disponíveis. - [x] **46.** Expor cada adaptador no mesmo caminho local `POST /v1/systemone`, sem dependência de rede externa.
- [ ] **48.** Normalizar as saídas adicionais de execução num registro interno com `latency_ms`, `model_id`, `run_id` e versão do adaptador. - [x] **47.** Garantir que cada adaptador devolva a resposta JEV completa, incluindo escolha, distribuição de probabilidades, confiança e metadados de uso, quando disponíveis.
- [x] **48.** Normalizar as saídas adicionais de execução num registro interno com `latency_ms`, `model_id`, `run_id` e versão do adaptador.
- ↳ O registro é emitido como span OpenTelemetry e também gravado em arquivo de resultados. - ↳ O registro é emitido como span OpenTelemetry e também gravado em arquivo de resultados.
- [ ] **49.** Validar que os adaptadores preservam a mesma ordem e a mesma descrição das opções. - [x] **49.** Validar que os adaptadores preservam a mesma ordem e a mesma descrição das opções.
- [ ] **50.** Validar que trocar de candidato não altera a requisição JEV nem o formato da resposta JEV. - [x] **50.** Validar que trocar de candidato não altera a requisição JEV nem o formato da resposta JEV.
- [ ] **51.** Implementar testes de sanidade com decisões triviais, opções permutadas e os três tipos de pergunta. - [x] **51.** Implementar testes de sanidade com decisões triviais, opções permutadas e os três tipos de pergunta.
- ↳ Incluir um teste de invariância à permutação das opções em D1 e D2 (a mesma escolha com outra ordem). - ↳ Incluir um teste de invariância à permutação das opções em D1 e D2 (a mesma escolha com outra ordem).
- ↳ `harness/sanity.py`. Primeira rodada em CPU (2026-10-07): sem violações de contrato nos 4 candidatos. Acertos: SemIf 8/8, Laya 7/8, Rizzo 7/8, GLiNER 5/8. Invariância à permutação: 4/4 em todos. Os próximos testes rodam na OCI.
## F5 — Controle de execução ## F5 — Controle de execução
@@ -132,6 +145,7 @@ As fases F0–F1 (infra) e F2–F3 (dados) podem andar em paralelo.
- [ ] **55.** Executar um piloto de 50 exemplos por candidato em CPU. *(Q11)* - [ ] **55.** Executar um piloto de 50 exemplos por candidato em CPU. *(Q11)*
- [ ] **56.** Executar um piloto de 50 exemplos por candidato em GPU. - [ ] **56.** Executar um piloto de 50 exemplos por candidato em GPU.
- ↳ GPU em **sa-saopaulo-1, A10.1**, uma por vez *(Q14)*. Sanidade em GPU já em andamento.
- [ ] **57.** Medir memória de GPU, memória RAM, tempo de carga, tempo de aquecimento e falhas do piloto. - [ ] **57.** Medir memória de GPU, memória RAM, tempo de carga, tempo de aquecimento e falhas do piloto.
- [ ] **58.** Ajustar apenas os parâmetros operacionais necessários para eliminar falhas de execução. - [ ] **58.** Ajustar apenas os parâmetros operacionais necessários para eliminar falhas de execução.

View File

@@ -7,7 +7,7 @@ Todas as questões foram resolvidas em 2026-10-07. O critério foi preferir a **
| # | Questão | Decisão | Mecanismo automatizado | | # | Questão | Decisão | Mecanismo automatizado |
|---|---------|---------|------------------------| |---|---------|---------|------------------------|
| Q1 | Contrato JEV | Esquema público do `POST /v1/systemone` (`state` + `questions` tipadas → `answers` + `usage`), congelado como `jev-compat-v1` | JSON Schema em `contracts/jev/schemas/` e testes de conformidade | | Q1 | Contrato JEV | Esquema público do `POST /v1/systemone` (`state` + `questions` tipadas → `answers` + `usage`), congelado como `jev-compat-v1` | JSON Schema em `contracts/jev/schemas/` e testes de conformidade |
| Q2 | 20 opções incluem o fallback? | Sim: no máximo 19 opções de domínio + `sem_correspondencia` | Validação de esquema (`maxProperties: 20`) e lint do dataset | | Q2 | 20 opções incluem o fallback? | Sim, e o limite comum cai para **16**: até 15 opções de domínio + `sem_correspondencia` (revisado: o SemIf aceita só 16) | Validação de esquema (`maxProperties: 16`) e lint do dataset |
| Q3 | NAT × isolamento | Desassociar a rota NAT logo após a tarefa 16 | Gate de egresso: o pipeline só segue se a sonda de saída **falhar** | | Q3 | NAT × isolamento | Desassociar a rota NAT logo após a tarefa 16 | Gate de egresso: o pipeline só segue se a sonda de saída **falhar** |
| Q4 | Interfaces e licenças | Interfaces mapeadas por candidato (abaixo). Só licenças da allowlist | O script de aquisição lê a licença nos metadados do Hub e falha fora da allowlist | | Q4 | Interfaces e licenças | Interfaces mapeadas por candidato (abaixo). Só licenças da allowlist | O script de aquisição lê a licença nos metadados do Hub e falha fora da allowlist |
| Q5 | Critérios de sucesso | SLO de latência fixo + não inferioridade relativa ao baseline | Avaliação automática dos critérios em `config/benchmark.yaml` | | Q5 | Critérios de sucesso | SLO de latência fixo + não inferioridade relativa ao baseline | Avaliação automática dos critérios em `config/benchmark.yaml` |
@@ -18,10 +18,25 @@ Todas as questões foram resolvidas em 2026-10-07. O critério foi preferir a **
| Q10 | Calibração | Temperature scaling por candidato × tarefa, só na partição de calibração | Ajuste por NLL e congelamento com hash | | Q10 | Calibração | Temperature scaling por candidato × tarefa, só na partição de calibração | Ajuste por NLL e congelamento com hash |
| Q11 | Piloto em CPU | Mantido, com timeout. Inviabilidade declarada automaticamente | Regra p95 > timeout → "inviável em CPU" | | Q11 | Piloto em CPU | Mantido, com timeout. Inviabilidade declarada automaticamente | Regra p95 > timeout → "inviável em CPU" |
| Q12 | Tamanho do teste | ≥ 1.300 decisões D1 no teste final (análise de poder) | `analysis/sample_size.py`, com recálculo do MDE | | Q12 | Tamanho do teste | ≥ 1.300 decisões D1 no teste final (análise de poder) | `analysis/sample_size.py`, com recálculo do MDE |
| Q14 | GPU | A10.1 em **sa-saopaulo-1**, um job por vez (limite da região). Sem VCN disponível lá: rede gerenciada + isolamento no processo (`DMB_ISOLATION=app`) | `infra/ds/gpu_queue.py`, `infra/oci/replicate.py`, autoteste `dmb.offline.self_test` |
| Q15 | Dados e anotação | 100% sintéticos; anotação por três modelos generativos em rodízio | `datagen/` (ver [02 §4](02-desenho-experimental.md#4-dados)) |
| Q13 | Centro de custo e orçamento | **Orçamento em aberto**: o uso está coberto por cota interna de engenharia. A estimativa de custo continua no relatório | A estimativa é calculada por script (tarefa 70) | | Q13 | Centro de custo e orçamento | **Orçamento em aberto**: o uso está coberto por cota interna de engenharia. A estimativa de custo continua no relatório | A estimativa é calculada por script (tarefa 70) |
--- ---
## Q14 — GPU em São Paulo (revisão de 2026-10-07)
- Em us-chicago-1 não há capacidade de A10, e A100 e V100 têm limite zero. Os jobs de GPU rodam em **sa-saopaulo-1, `VM.GPU.A10.1`**, com limite de **uma GPU por vez** (fila em `infra/ds/gpu_queue.py`).
- Pesos e ambientes são replicados do lado do servidor para `dmb-artefatos-gru` (`infra/oci/replicate.py`); os resultados vão para `dmb-resultados-gru`.
- O limite de VCNs da região está esgotado. Os jobs de GPU usam **rede gerenciada**, e o isolamento passa a valer no processo: variáveis offline, bloqueio de sockets fora do loopback e um **autoteste a cada execução** (`process_guard`). O gate de egresso registra que há saída de rede, mas não aborta (`DMB_ISOLATION=app`). É um desvio documentado em relação à tarefa 17. Se uma VCN for liberada em São Paulo, os jobs voltam para uma sub-rede privada.
- Todas as medições de GPU usam o mesmo shape e a mesma região. As de CPU continuam em us-chicago-1.
## Q15 — Dados sintéticos e anotação por LLMs (revisão de 2026-10-07)
- Substitui Q7: os dados são 100% sintéticos, incluindo fora de escopo e enunciados emocionais. Não há dados reais neste ciclo.
- A anotação humana (tarefas 30–31) é substituída por três modelos generativos de fornecedores distintos, em rodízio de papéis. O gerador nunca é um dos anotadores.
- Limitação para o relatório: os rótulos de referência refletem o consenso de LLMs, não de pessoas. A concordância entre anotadores (kappa por par) e com o rótulo de geração é reportada.
## Q1 — Contrato JEV `jev-compat-v1` ## Q1 — Contrato JEV `jev-compat-v1`
Base: a forma pública do `POST /v1/systemone`, a mesma reproduzida pelas implementações abertas (OpenJev/SemIf). Base: a forma pública do `POST /v1/systemone`, a mesma reproduzida pelas implementações abertas (OpenJev/SemIf).
@@ -76,7 +91,7 @@ Base: a forma pública do `POST /v1/systemone`, a mesma reproduzida pelas implem
## Q2 — Limite de opções ## Q2 — Limite de opções
O limite de 20 opções inclui `sem_correspondencia`: no máximo 19 opções de domínio + 1. O dataset é validado por esquema. Um exemplo com mais de 20 opções reprova o lint e impede o congelamento (tarefa 35). O limite inclui `sem_correspondencia`. **Revisão (2026-10-07):** o código do SemIf (`semif_phase1.core.LETTERS = "A…P"`) aceita no máximo 16 opções. O Laya recomenda até ~20 e o Rizzo aceita 26. O limite comum passa a ser **16: até 15 opções de domínio + 1**. O dataset é validado por esquema. Um exemplo com mais de 16 opções reprova o lint e impede o congelamento (tarefa 35).
## Q3 — NAT e isolamento de rede ## Q3 — NAT e isolamento de rede

View File

@@ -0,0 +1,60 @@
# 05 — Ambiente de execução
A máquina local é **só IDE e repositório**. Todo script de benchmark roda na OCI (região `us-chicago-1`, compartment `decision-models`), no OCI Data Science.
## Componentes
| Componente | Rede | Uso |
|------------|------|-----|
| **Jobs de build** (`infra/ds/build_env.sh`) | Rede gerenciada do serviço, com internet | Criam o ambiente Python de cada candidato com as versões fixadas em `env/requirements/` e o publicam em `dmb-artefatos/envs/<candidato>/` (pacote + `latest.json` com SHA-256 + manifesto de versões) |
| **Jobs de benchmark** (`infra/ds/run_job.sh` → `harness/job.py`) | Sub-rede privada `dmb-subnet-jobs`, **sem internet**, só Service Gateway | Gate de egresso, download dos pesos e do ambiente do bucket (SHA-256 conferido), inferência com bloqueio de rede no processo e envio dos resultados para `dmb-resultados/runs/<run_id>/` |
| **Notebook `dmb-notebook-cpu`** | Rede gerenciada | Análise interativa dos resultados. Um notebook de GPU é criado só quando houver trabalho interativo em GPU |
| Máquina local | — | Edição, testes unitários rápidos e disparo dos jobs (`infra/ds/jobs.py`) |
O código vai para cada job como artefato zip, gerado na hora a partir do repositório local, sem `.secrets/`, `models/` e venvs. Dentro do job, o processo pai usa o Python base do serviço (3.11.9, com SDK OCI e resource principal). A inferência roda no Python do ambiente do candidato.
## Comandos
```bash
# job genérico (CPU flex ou GPU), com espera e logs
PYTHONPATH=infra/oci .venv/bin/python infra/ds/jobs.py run --name <nome> --entrypoint <script> \
[--shape VM.Standard.E4.Flex --ocpus 4 --memory 32 | --shape VM.GPU.A10.1] [--private] \
[--env DMB_CANDIDATE=laya DMB_MODE=sanity DMB_DEVICE=auto] [--wait]
# build do ambiente de um candidato
... jobs.py run --name build-laya --entrypoint infra/ds/build_env.sh --ocpus 4 --memory 32 --env DMB_CANDIDATE=laya
# sanidade de um candidato na sub-rede privada (CPU ou GPU)
... jobs.py run --name sanity-laya --entrypoint infra/ds/run_job.sh --private \
--env DMB_CANDIDATE=laya DMB_MODE=sanity --wait
... jobs.py run --name sanity-laya-gpu --entrypoint infra/ds/run_job.sh --private --shape VM.GPU.A10.1 \
--env DMB_CANDIDATE=laya DMB_MODE=sanity --wait
```
## VM de trabalho
| Item | Valor |
|------|-------|
| Instância | `instance-20261007-1206`: `VM.Standard.E6.Flex`, 8 OCPU (16 vCPU), 88 GB, Oracle Linux 9, sub-rede `dmb-subnet-dev` (SSH só do IP autorizado) |
| Disco | Boot volume de 200 GB (raiz `/`) + block volume `dmb-compute-vol` de 1 TB (iSCSI, XFS) montado em **`/data`**: `models/`, `envs/`, `runs/`, `repo/` |
| Acesso à OCI | Instance principal (política `dmb-workloads`), sem chave de API na VM |
| Preparação | `infra/vm/bootstrap.sh` (Python 3.12, git, rsync, SDK OCI em `~/.venv-oci`) |
A VM roda o trabalho de CPU que não precisa do isolamento de rede dos jobs: preparação de dados, análise e orquestração. Do lado local:
```bash
infra/vm/remote.sh sync # envia o repositório para /data/repo (sem .secrets, models, venvs)
infra/vm/remote.sh run '<comando>' # sincroniza e executa na VM
infra/vm/remote.sh ssh # shell na VM
```
A VM foi criada pelo console: o lançamento de instâncias pela API, com a chave do usuário, é negado neste compartment (`404` em `LaunchInstance`). Por isso `infra/oci/provision.py` só registra a VM e seus volumes no catálogo, sem tentar criá-la. O `env/Dockerfile` (uma imagem por candidato) fica como alternativa caso se queira contêineres próprios (BYOC/OCIR).
## Divisão do trabalho
| Trabalho | Onde |
|----------|------|
| Execuções que valem como medição (piloto, dev, calibração, teste final) | Jobs do Data Science na sub-rede privada, em CPU (`VM.Standard.E4.Flex`) ou GPU (`VM.GPU.A10.1`) |
| Builds de ambiente | Jobs com rede gerenciada |
| Preparação de dados, análise, relatórios | VM (`/data`) ou notebook |
| Edição e disparo | Máquina local |

View File

@@ -0,0 +1,49 @@
# 06 — Guia de anotação (tarefa 29)
Este guia é usado **literalmente** como instrução dos anotadores. São três modelos generativos de fornecedores distintos, em rodízio entre os papéis de anotador e de desempate (ver [02-desenho-experimental.md §4](02-desenho-experimental.md#4-dados)). As intenções e suas descrições estão em `data/taxonomy/intents-v1.yaml`.
<!-- GUIA:INICIO -->
## Tarefa
Você recebe mensagens que clientes de uma operadora de telefonia móvel enviaram ao canal de atendimento digital (chat ou aplicativo de mensagens). Para cada mensagem, escolha **uma intenção principal** da taxonomia: o que o cliente quer que o atendimento faça **agora**. Se nenhuma intenção se aplicar, use `sem_correspondencia`.
## Regras
1. **Intenção principal = ação pedida.** Rotule pelo que o cliente quer que aconteça, não pelo assunto mencionado de passagem.
2. **Tom não muda a intenção.** Irritação, ansiedade, ironia ou xingamentos não alteram o rótulo. "Que absurdo, cobraram um serviço que eu nunca pedi, quero meu dinheiro de volta" → `conta_contestacao`.
3. **Desabafo sem pedido** (só reclamação emocional, ofensa ou xingamento sem ação identificável) → `sem_correspondencia`.
4. **Reclamação do próprio atendimento** (demora, protocolo sem solução, pedido de ouvidoria) → `reclamacao_atendimento`. Pedido explícito para falar com uma pessoa → `atendimento_humano`, mesmo que o motivo seja citado.
5. **Várias intenções na mesma mensagem:** escolha a que o cliente pede primeiro ou com mais ênfase e liste as demais em `secundarias`.
6. **Saudação + pedido:** ignore a saudação. Saudação, agradecimento ou teste isolado → `sem_correspondencia`.
7. **Fora do domínio** (outro tipo de empresa, assuntos gerais, texto ininteligível) → `sem_correspondencia`.
8. **Mensagem vaga demais** para identificar qualquer intenção ("preciso resolver um problema", "me ajuda") → `sem_correspondencia` com `ambiguidade` alta.
## Pares difíceis
| Situação | Rótulo |
|----------|--------|
| Diz que a cobrança é indevida e/ou pede estorno | `conta_contestacao` |
| Só quer entender a fatura ou o aumento | `conta_explicacao` |
| Cobrança indevida de uso no exterior | `roaming_cobranca` |
| Quer cancelar um serviço adicional ou assinatura (mesmo citando a cobrança) | `vas_cancelamento` |
| Linha bloqueada por falta de pagamento e quer reativar | `negociacao_religacao` |
| Já pagou e quer confirmar se o pagamento caiu | `conta_pagamento_confirmacao` |
| Quer negociar ou parcelar o que está atrasado | `negociacao_divida` |
| Problema só com internet móvel (dados) | `internet_problema_conexao` |
| Sem sinal ou sem conseguir ligar ou receber chamadas e SMS | `suporte_sem_sinal` |
| Recarga paga que não caiu | `recarga_nao_creditada` |
| Bônus prometido que não veio | `bonus_nao_recebido` |
| Quer pagar menos para continuar ou ameaça sair pedindo condição melhor | `desconto_retencao` |
| Quer encerrar a linha sem pedir condição | `cancelamento_linha` |
| Levar ou trazer o número de outra operadora | `portabilidade` |
| Perdeu ou teve o aparelho roubado | `seguranca_perda_roubo` |
| Linha clonada, golpe ou contratação que não reconhece | `seguranca_fraude` |
## Resposta
Para cada mensagem, devolva:
- `rotulo`: um id da taxonomia ou `sem_correspondencia`;
- `secundarias`: lista (possivelmente vazia) de outros ids presentes;
- `ambiguidade`: `baixa`, `media` ou `alta`;
- `justificativa`: uma frase curta.
<!-- GUIA:FIM -->

0
env/.gitkeep vendored
View File

64
env/Dockerfile vendored Normal file
View File

@@ -0,0 +1,64 @@
# Imagem de um candidato do benchmark (tarefas 11–12).
# Uma imagem por candidato: as dependências do SemIf (transformers 5.x) e do GLiNER
# (transformers<5) não convivem. Os pesos NÃO entram na imagem: o job os baixa do bucket
# dmb-artefatos e confere o SHA-256 contra env/artifacts-manifest.json.
#
# docker build -f env/Dockerfile --build-arg CANDIDATE=laya -t dmb-laya .
#
# syntax=docker/dockerfile:1.7
ARG BASE_IMAGE=python:3.12-slim-bookworm@sha256:34386ef0cb081344d7ec1c103ba398e6e9f64e9ab3a1509accc92a4e24a07258
FROM ${BASE_IMAGE}
ARG BASE_IMAGE
ARG CANDIDATE
ARG TORCH_INDEX=https://download.pytorch.org/whl/cu128
RUN test -n "${CANDIDATE}"
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
HF_HUB_OFFLINE=1 \
TRANSFORMERS_OFFLINE=1 \
HF_DATASETS_OFFLINE=1 \
HF_HUB_DISABLE_TELEMETRY=1 \
USE_TF=0 \
DMB_CANDIDATE=${CANDIDATE} \
DMB_BASE_IMAGE=${BASE_IMAGE} \
DMB_MODELS_ROOT=/models \
RIZZO_LLAMA_DIR=/opt/runtimes/llama-b11081-linux-x64-cuda
RUN apt-get update \
&& apt-get install -y --no-install-recommends libgomp1 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/dmb
COPY env/requirements/ env/requirements/
RUN pip install -r env/requirements/common.txt \
&& pip install --extra-index-url ${TORCH_INDEX} -r env/requirements/${CANDIDATE}.txt
# Código-fonte dos runtimes em commits fixados (mesmos tarballs do bucket).
COPY models/src/*.tar.gz /opt/src/
COPY models/runtimes/llama-b11081-linux-x64-cpu /opt/runtimes/llama-b11081-linux-x64-cpu
COPY models/runtimes/llama-b11081-linux-x64-cuda /opt/runtimes/llama-b11081-linux-x64-cuda
RUN set -e; cd /opt/src; \
case "${CANDIDATE}" in \
semif) tar xzf SemIf-OpenJev-*.tar.gz && pip install --no-deps ./SemIf-OpenJev ;; \
rizzo_flow) tar xzf rizzo-flow-*.tar.gz && pip install --no-deps ./rizzo-flow ;; \
esac; \
if [ "${CANDIDATE}" != rizzo_flow ]; then rm -rf /opt/runtimes; fi; \
rm -f /opt/src/*.tar.gz
COPY pyproject.toml ./
COPY dmb/ dmb/
COPY adapters/ adapters/
COPY harness/ harness/
COPY contracts/ contracts/
COPY infra/oci/session.py infra/oci/session.py
COPY config/ config/
COPY env/artifacts-manifest.json env/artifacts-manifest.json
# Manifesto de versões da imagem (tarefa 12).
RUN python -m harness.image_manifest > /opt/dmb/image-manifest.json
ENTRYPOINT ["python", "-m", "harness.job"]

175
env/acquire.py vendored Normal file
View File

@@ -0,0 +1,175 @@
"""Aquisição de artefatos dos candidatos (tarefas 13–16).
Baixa pesos, tokenizadores e licenças nas revisões fixadas, empacota o código-fonte
dos runtimes em commits fixados, calcula SHA-256, envia tudo ao bucket `dmb-artefatos`
e grava o manifesto em env/artifacts-manifest.json.
Uso: PYTHONPATH=infra/oci .venv/bin/python env/acquire.py [--skip-upload]
"""
import argparse
import hashlib
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
import oci
from huggingface_hub import HfApi, snapshot_download
from oci.object_storage import UploadManager
from session import load_config
ROOT = Path(__file__).resolve().parents[1]
LOCAL = ROOT / "models"
MANIFEST = ROOT / "env" / "artifacts-manifest.json"
BUCKET = "dmb-artefatos"
LICENSE_ALLOWLIST = {"apache-2.0", "mit"}
HF_MODELS = [
{"candidate": "laya", "repo": "convaiinnovations/laya-multilingual",
"revision": "1720e3e3357cfe1e281542e223f8273b0890ca34", "allow": None},
{"candidate": "gliner", "repo": "fastino/GLiNER2.5-multi-Decide",
"revision": "a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f", "allow": None, "ignore": ["*.png"]},
{"candidate": "semif", "repo": "Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a", "allow": None},
{"candidate": "rizzo_flow", "repo": "rizzoaiacademy/rizzo-flow",
"revision": "55633c8cbd2b826bd3eefdeb05310450996649df",
"allow": ["spark-x2.5-4b-rizzo-flow-lora-q8_0.gguf", "LICENSE", "README.md", "training/history.json"],
"expected_sha256": {"spark-x2.5-4b-rizzo-flow-lora-q8_0.gguf":
"dbec3c89d33984772324e65a8ed56b48e958e301b856cb870a7c1b385d01691a"}},
]
GIT_SOURCES = [
{"candidate": "semif", "repo": "https://github.com/TheoLeeCJ/SemIf-OpenJev",
"commit": "23cf1f39fc9534fe81437200959b6dfc7106e45a", "license": "mit"},
{"candidate": "rizzo_flow", "repo": "https://github.com/Rizzo-AI-Academy/rizzo-flow",
"commit": "b9ba007ee4d2928bbab5b1d8bfe9009c3696b6de", "license": "apache-2.0"},
]
# Runtime llama.cpp do Rizzo Flow (release b11081): arquivos oficiais baixados por
# `rizzo download --only runtime --runtime {cpu,cuda}`, que confere o SHA-256 fixado pelo projeto.
RUNTIME_ARCHIVES = [
"llama-b11081-bin-ubuntu-x64.tar.gz",
"llama-b11081-bin-ubuntu-cuda-13.4-x64.tar.gz",
"cudart-llama-b11081-bin-ubuntu-cuda-13.4-x64.tar.gz",
]
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 22), b""):
h.update(chunk)
return h.hexdigest()
def fetch_hf(spec: dict) -> list[dict]:
info = HfApi().model_info(spec["repo"], revision=spec["revision"])
lic = (info.card_data or {}).get("license") if info.card_data else None
if (lic or "").lower() not in LICENSE_ALLOWLIST:
sys.exit(f"licença fora da allowlist: {spec['repo']} -> {lic}")
target = LOCAL / spec["candidate"] / spec["repo"].replace("/", "__") / spec["revision"]
snapshot_download(spec["repo"], revision=spec["revision"], local_dir=target,
allow_patterns=spec.get("allow"), ignore_patterns=spec.get("ignore"))
entries = []
for f in sorted(p for p in target.rglob("*") if p.is_file() and ".cache" not in p.parts):
rel = f.relative_to(target).as_posix()
digest = sha256(f)
expected = spec.get("expected_sha256", {}).get(rel)
if expected and expected != digest:
sys.exit(f"SHA-256 divergente em {spec['repo']}/{rel}")
entries.append({
"candidate": spec["candidate"], "kind": "hf_model", "origin": f"hf://{spec['repo']}",
"revision": spec["revision"], "license": lic, "path": rel, "sha256": digest,
"bytes": f.stat().st_size, "local": str(f),
"object": f"models/{spec['candidate']}/{spec['repo'].replace('/', '__')}/{spec['revision']}/{rel}",
})
return entries
def fetch_git(spec: dict) -> list[dict]:
name = spec["repo"].rstrip("/").split("/")[-1]
work = LOCAL / "src" / name
if not (work / ".git").exists():
subprocess.run(["git", "clone", "-q", spec["repo"], str(work)], check=True)
subprocess.run(["git", "-C", str(work), "fetch", "-q", "origin", spec["commit"]], check=False)
subprocess.run(["git", "-C", str(work), "checkout", "-q", spec["commit"]], check=True)
tarball = LOCAL / "src" / f"{name}-{spec['commit'][:12]}.tar.gz"
with open(tarball, "wb") as out:
subprocess.run(["git", "-C", str(work), "archive", "--format=tar.gz",
f"--prefix={name}/", spec["commit"]], stdout=out, check=True)
return [{
"candidate": spec["candidate"], "kind": "source", "origin": spec["repo"],
"revision": spec["commit"], "license": spec["license"], "path": tarball.name,
"sha256": sha256(tarball), "bytes": tarball.stat().st_size, "local": str(tarball),
"object": f"src/{spec['candidate']}/{tarball.name}",
}]
def fetch_runtimes() -> list[dict]:
downloads = LOCAL / "runtimes" / "downloads"
missing = [a for a in RUNTIME_ARCHIVES if not (downloads / a).is_file()]
if missing:
sys.exit(f"runtimes ausentes {missing}: rode `rizzo download --only runtime --runtime cpu|cuda` em models/")
return [{
"candidate": "rizzo_flow", "kind": "runtime", "origin": "https://github.com/ggml-org/llama.cpp/releases/tag/b11081",
"revision": "b11081", "license": "mit", "path": name, "sha256": sha256(downloads / name),
"bytes": (downloads / name).stat().st_size, "local": str(downloads / name),
"object": f"runtimes/rizzo_flow/{name}",
} for name in RUNTIME_ARCHIVES]
def upload(entries: list[dict]) -> None:
cfg = load_config()
client = oci.object_storage.ObjectStorageClient(cfg)
ns = client.get_namespace().data
manager = UploadManager(client, allow_parallel_uploads=True, parallel_process_count=8)
for e in entries:
try:
head = client.head_object(ns, BUCKET, e["object"])
if head.headers.get("opc-meta-sha256") == e["sha256"]:
print("já no bucket:", e["object"])
continue
except oci.exceptions.ServiceError as err:
if err.status != 404:
raise
if sha256(Path(e["local"])) != e["sha256"]:
sys.exit(f"{e['local']} mudou depois do cálculo do hash; abortando o envio")
print(f"enviando {e['object']} ({e['bytes'] / 1e9:.2f} GB)", flush=True)
manager.upload_file(ns, BUCKET, e["object"], e["local"],
metadata={"sha256": e["sha256"], "revision": e["revision"]})
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--skip-upload", action="store_true")
args = ap.parse_args()
entries = []
for spec in HF_MODELS:
print("baixando", spec["repo"], flush=True)
entries += fetch_hf(spec)
for spec in GIT_SOURCES:
print("empacotando", spec["repo"], flush=True)
entries += fetch_git(spec)
entries += fetch_runtimes()
if not args.skip_upload:
upload(entries)
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
manifest = {
"schema": "dmb-artifacts-v1",
"acquired_at": now,
"bucket": BUCKET,
"license_allowlist": sorted(LICENSE_ALLOWLIST),
"artifacts": [{k: v for k, v in e.items() if k != "local"} for e in entries],
}
MANIFEST.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
total = sum(e["bytes"] for e in entries)
print(f"{len(entries)} artefatos, {total / 1e9:.2f} GB; manifesto em {MANIFEST.relative_to(ROOT)}")
if __name__ == "__main__":
main()

440
env/artifacts-manifest.json vendored Normal file
View File

@@ -0,0 +1,440 @@
{
"schema": "dmb-artifacts-v1",
"acquired_at": "2026-10-07T14:45:40+00:00",
"bucket": "dmb-artefatos",
"license_allowlist": [
"apache-2.0",
"mit"
],
"artifacts": [
{
"candidate": "laya",
"kind": "hf_model",
"origin": "hf://convaiinnovations/laya-multilingual",
"revision": "1720e3e3357cfe1e281542e223f8273b0890ca34",
"license": "apache-2.0",
"path": ".gitattributes",
"sha256": "caf3ca02d5e883f643e939cbed54f94237f94c171797f0dfea5e25c7e33315b3",
"bytes": 1580,
"object": "models/laya/convaiinnovations__laya-multilingual/1720e3e3357cfe1e281542e223f8273b0890ca34/.gitattributes"
},
{
"candidate": "laya",
"kind": "hf_model",
"origin": "hf://convaiinnovations/laya-multilingual",
"revision": "1720e3e3357cfe1e281542e223f8273b0890ca34",
"license": "apache-2.0",
"path": "README.md",
"sha256": "e85d16c3fe35fe7094ef51a7cfa81144a5cc07558e5faddcff753a8153e38a0b",
"bytes": 9346,
"object": "models/laya/convaiinnovations__laya-multilingual/1720e3e3357cfe1e281542e223f8273b0890ca34/README.md"
},
{
"candidate": "laya",
"kind": "hf_model",
"origin": "hf://convaiinnovations/laya-multilingual",
"revision": "1720e3e3357cfe1e281542e223f8273b0890ca34",
"license": "apache-2.0",
"path": "config.json",
"sha256": "7add7c041b53f7091e65908553af79ab3f077946e5f2312d510ae13b17c7c134",
"bytes": 160,
"object": "models/laya/convaiinnovations__laya-multilingual/1720e3e3357cfe1e281542e223f8273b0890ca34/config.json"
},
{
"candidate": "laya",
"kind": "hf_model",
"origin": "hf://convaiinnovations/laya-multilingual",
"revision": "1720e3e3357cfe1e281542e223f8273b0890ca34",
"license": "apache-2.0",
"path": "encoder/config.json",
"sha256": "83f6916d13ef0f556ac461f28308dc2bffa7ebeadee8ec9e2db5812020ea5bb4",
"bytes": 1938,
"object": "models/laya/convaiinnovations__laya-multilingual/1720e3e3357cfe1e281542e223f8273b0890ca34/encoder/config.json"
},
{
"candidate": "laya",
"kind": "hf_model",
"origin": "hf://convaiinnovations/laya-multilingual",
"revision": "1720e3e3357cfe1e281542e223f8273b0890ca34",
"license": "apache-2.0",
"path": "model.safetensors",
"sha256": "9d628fd971b700382ac6f65920a86f149777b2e748e0c955fb3b19695aa8f204",
"bytes": 643835514,
"object": "models/laya/convaiinnovations__laya-multilingual/1720e3e3357cfe1e281542e223f8273b0890ca34/model.safetensors"
},
{
"candidate": "laya",
"kind": "hf_model",
"origin": "hf://convaiinnovations/laya-multilingual",
"revision": "1720e3e3357cfe1e281542e223f8273b0890ca34",
"license": "apache-2.0",
"path": "rl_agent_config.json",
"sha256": "25061739243b617ad88d1219ba6f8a9c86c5881ca28df024fa2d9b3b2fcc30c6",
"bytes": 472,
"object": "models/laya/convaiinnovations__laya-multilingual/1720e3e3357cfe1e281542e223f8273b0890ca34/rl_agent_config.json"
},
{
"candidate": "laya",
"kind": "hf_model",
"origin": "hf://convaiinnovations/laya-multilingual",
"revision": "1720e3e3357cfe1e281542e223f8273b0890ca34",
"license": "apache-2.0",
"path": "tokenizer/tokenizer.json",
"sha256": "609d8f4c067cd3950f88594c5a802616cea245823836ef5848ee4fc40aab5b6f",
"bytes": 34363188,
"object": "models/laya/convaiinnovations__laya-multilingual/1720e3e3357cfe1e281542e223f8273b0890ca34/tokenizer/tokenizer.json"
},
{
"candidate": "laya",
"kind": "hf_model",
"origin": "hf://convaiinnovations/laya-multilingual",
"revision": "1720e3e3357cfe1e281542e223f8273b0890ca34",
"license": "apache-2.0",
"path": "tokenizer/tokenizer_config.json",
"sha256": "424b69444bf7b5809dc2cd2e36d0bd71b8055124dd24274d6db3c655d38205e7",
"bytes": 502,
"object": "models/laya/convaiinnovations__laya-multilingual/1720e3e3357cfe1e281542e223f8273b0890ca34/tokenizer/tokenizer_config.json"
},
{
"candidate": "gliner",
"kind": "hf_model",
"origin": "hf://fastino/GLiNER2.5-multi-Decide",
"revision": "a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f",
"license": "apache-2.0",
"path": ".gitattributes",
"sha256": "6af2fc6fb7f8b4e8d6d4ff0b968e3353767f677c9b6973e38127780ec3563842",
"bytes": 1640,
"object": "models/gliner/fastino__GLiNER2.5-multi-Decide/a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f/.gitattributes"
},
{
"candidate": "gliner",
"kind": "hf_model",
"origin": "hf://fastino/GLiNER2.5-multi-Decide",
"revision": "a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f",
"license": "apache-2.0",
"path": "README.md",
"sha256": "989d19265d83c9a3886577b5d3851e6c58b585f15d5b2912e925655b201fc42d",
"bytes": 17002,
"object": "models/gliner/fastino__GLiNER2.5-multi-Decide/a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f/README.md"
},
{
"candidate": "gliner",
"kind": "hf_model",
"origin": "hf://fastino/GLiNER2.5-multi-Decide",
"revision": "a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f",
"license": "apache-2.0",
"path": "SKILL.md",
"sha256": "c88242139bdbde7194b1c05bfc77cd5a9ef2d975040a15708211a09700296e32",
"bytes": 6511,
"object": "models/gliner/fastino__GLiNER2.5-multi-Decide/a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f/SKILL.md"
},
{
"candidate": "gliner",
"kind": "hf_model",
"origin": "hf://fastino/GLiNER2.5-multi-Decide",
"revision": "a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f",
"license": "apache-2.0",
"path": "config.json",
"sha256": "be5123080c0f3f01b938bc46a5dd0d7a2e515a34f6df798dfd70ed04c277c8bf",
"bytes": 3152,
"object": "models/gliner/fastino__GLiNER2.5-multi-Decide/a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f/config.json"
},
{
"candidate": "gliner",
"kind": "hf_model",
"origin": "hf://fastino/GLiNER2.5-multi-Decide",
"revision": "a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f",
"license": "apache-2.0",
"path": "encoder_config/config.json",
"sha256": "d0ebbcb8b458e285a39e12cc315cbaf3d1c6f631e7281e6b22dd5b4071183f83",
"bytes": 858,
"object": "models/gliner/fastino__GLiNER2.5-multi-Decide/a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f/encoder_config/config.json"
},
{
"candidate": "gliner",
"kind": "hf_model",
"origin": "hf://fastino/GLiNER2.5-multi-Decide",
"revision": "a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f",
"license": "apache-2.0",
"path": "model.safetensors",
"sha256": "9efe0f88c99f2aa794452e9559dc60e98d60d9fa2bf1b60cf2710411b6da5b4e",
"bytes": 1149461028,
"object": "models/gliner/fastino__GLiNER2.5-multi-Decide/a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f/model.safetensors"
},
{
"candidate": "gliner",
"kind": "hf_model",
"origin": "hf://fastino/GLiNER2.5-multi-Decide",
"revision": "a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f",
"license": "apache-2.0",
"path": "tokenizer.json",
"sha256": "c62446df87ae18ec98b133f8f84fc449a07cc89bbf8ef192a4cb5f9c53777a7a",
"bytes": 16035853,
"object": "models/gliner/fastino__GLiNER2.5-multi-Decide/a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f/tokenizer.json"
},
{
"candidate": "gliner",
"kind": "hf_model",
"origin": "hf://fastino/GLiNER2.5-multi-Decide",
"revision": "a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f",
"license": "apache-2.0",
"path": "tokenizer_config.json",
"sha256": "fd4a31dc2f1f17e31638c5f0e783b81cdb2fbe6bddd116a8d9e5d50d78148cf1",
"bytes": 646,
"object": "models/gliner/fastino__GLiNER2.5-multi-Decide/a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f/tokenizer_config.json"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": ".gitattributes",
"sha256": "34448b82c17d60fec9b65b1f093c115ddbaadc04beb1b0140b6bfed2e012a930",
"bytes": 1570,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/.gitattributes"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "LICENSE",
"sha256": "bbedc3fda3305820b977265f01b8619d87570a6739de3a5582c3464840f1e57a",
"bytes": 11544,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/LICENSE"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "README.md",
"sha256": "1406be1b6b8fd8a6545870da516912804756593628a1d0fb0a7965211e82a7bb",
"bytes": 77661,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/README.md"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "chat_template.jinja",
"sha256": "a4aee8afcf2e0711942cf848899be66016f8d14a889ff9ede07bca099c28f715",
"bytes": 7756,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/chat_template.jinja"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "config.json",
"sha256": "ddc63e1c717afa86c865bb5e01313d89d72bb53b97ad4a8a03ba8510c0621670",
"bytes": 3161,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/config.json"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "merges.txt",
"sha256": "a9d356d7bdf1ef4949e3e748e95b8e10ad9d4e2e838eddc38a0a7b6b94d1db8d",
"bytes": 3353259,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/merges.txt"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "model.safetensors-00001-of-00002.safetensors",
"sha256": "26a93f066e1916adb13453dae5a0c707c0fbc71299ed98779571a907b8e74c61",
"bytes": 5329398688,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/model.safetensors-00001-of-00002.safetensors"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "model.safetensors-00002-of-00002.safetensors",
"sha256": "cb544bd9bfae93dc59b0f22b292f5933573854a7f9b97835c67060d7d910e188",
"bytes": 3990429408,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/model.safetensors-00002-of-00002.safetensors"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "model.safetensors.index.json",
"sha256": "cf3f798ee02ba45f9622aa8892a47369ab667d0afbf154ee7c2212de42e6302d",
"bytes": 76196,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/model.safetensors.index.json"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "preprocessor_config.json",
"sha256": "27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516",
"bytes": 390,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/preprocessor_config.json"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "tokenizer.json",
"sha256": "5f9e4d4901a92b997e463c1f46055088b6cca5ca61a6522d1b9f64c4bb81cb42",
"bytes": 12807982,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/tokenizer.json"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "tokenizer_config.json",
"sha256": "316230d6a809701f4db5ea8f8fc862bc3a6f3229c937c174e674ff3ca0a64ac8",
"bytes": 16710,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/tokenizer_config.json"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "video_preprocessor_config.json",
"sha256": "7768af27c1fafa9cc9011c1dc20067e03f8915e03b63504550e11d5066986d13",
"bytes": 385,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/video_preprocessor_config.json"
},
{
"candidate": "semif",
"kind": "hf_model",
"origin": "hf://Qwen/Qwen3.5-4B",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"license": "apache-2.0",
"path": "vocab.json",
"sha256": "ce99b4cb2983d118806ce0a8b777a35b093e2000a503ebde25853284c9dfa003",
"bytes": 6722759,
"object": "models/semif/Qwen__Qwen3.5-4B/851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a/vocab.json"
},
{
"candidate": "rizzo_flow",
"kind": "hf_model",
"origin": "hf://rizzoaiacademy/rizzo-flow",
"revision": "55633c8cbd2b826bd3eefdeb05310450996649df",
"license": "apache-2.0",
"path": "LICENSE",
"sha256": "525c465ab952779d2d360a9ff36f7f49ae018a23d80302ff839a850a77784c36",
"bytes": 11338,
"object": "models/rizzo_flow/rizzoaiacademy__rizzo-flow/55633c8cbd2b826bd3eefdeb05310450996649df/LICENSE"
},
{
"candidate": "rizzo_flow",
"kind": "hf_model",
"origin": "hf://rizzoaiacademy/rizzo-flow",
"revision": "55633c8cbd2b826bd3eefdeb05310450996649df",
"license": "apache-2.0",
"path": "README.md",
"sha256": "dc38bc120b71453db49bab091363c046bcbcbb59d08afbbc7d1ce355b01f2191",
"bytes": 9315,
"object": "models/rizzo_flow/rizzoaiacademy__rizzo-flow/55633c8cbd2b826bd3eefdeb05310450996649df/README.md"
},
{
"candidate": "rizzo_flow",
"kind": "hf_model",
"origin": "hf://rizzoaiacademy/rizzo-flow",
"revision": "55633c8cbd2b826bd3eefdeb05310450996649df",
"license": "apache-2.0",
"path": "spark-x2.5-4b-rizzo-flow-lora-q8_0.gguf",
"sha256": "dbec3c89d33984772324e65a8ed56b48e958e301b856cb870a7c1b385d01691a",
"bytes": 4375021216,
"object": "models/rizzo_flow/rizzoaiacademy__rizzo-flow/55633c8cbd2b826bd3eefdeb05310450996649df/spark-x2.5-4b-rizzo-flow-lora-q8_0.gguf"
},
{
"candidate": "rizzo_flow",
"kind": "hf_model",
"origin": "hf://rizzoaiacademy/rizzo-flow",
"revision": "55633c8cbd2b826bd3eefdeb05310450996649df",
"license": "apache-2.0",
"path": "training/history.json",
"sha256": "8feb23ded52b22b095907261659b9a9972f607c0a810cee5628b8b2cebfa227f",
"bytes": 27088,
"object": "models/rizzo_flow/rizzoaiacademy__rizzo-flow/55633c8cbd2b826bd3eefdeb05310450996649df/training/history.json"
},
{
"candidate": "semif",
"kind": "source",
"origin": "https://github.com/TheoLeeCJ/SemIf-OpenJev",
"revision": "23cf1f39fc9534fe81437200959b6dfc7106e45a",
"license": "mit",
"path": "SemIf-OpenJev-23cf1f39fc95.tar.gz",
"sha256": "bb31ca475740e98b5091d2ee82b80d82568a5d19833cf612a8d28d43889073d4",
"bytes": 8807630,
"object": "src/semif/SemIf-OpenJev-23cf1f39fc95.tar.gz"
},
{
"candidate": "rizzo_flow",
"kind": "source",
"origin": "https://github.com/Rizzo-AI-Academy/rizzo-flow",
"revision": "b9ba007ee4d2928bbab5b1d8bfe9009c3696b6de",
"license": "apache-2.0",
"path": "rizzo-flow-b9ba007ee4d2.tar.gz",
"sha256": "5921e70b7d9f592c649b88002f010736a8d2b0cb6de081d078e779ea1d916a62",
"bytes": 7999256,
"object": "src/rizzo_flow/rizzo-flow-b9ba007ee4d2.tar.gz"
},
{
"candidate": "rizzo_flow",
"kind": "runtime",
"origin": "https://github.com/ggml-org/llama.cpp/releases/tag/b11081",
"revision": "b11081",
"license": "mit",
"path": "llama-b11081-bin-ubuntu-x64.tar.gz",
"sha256": "1e4afeb3985ff8877c3811b7fa994faf7731fb54ad90ff0b68e96fb0a5493c45",
"bytes": 16983830,
"object": "runtimes/rizzo_flow/llama-b11081-bin-ubuntu-x64.tar.gz"
},
{
"candidate": "rizzo_flow",
"kind": "runtime",
"origin": "https://github.com/ggml-org/llama.cpp/releases/tag/b11081",
"revision": "b11081",
"license": "mit",
"path": "llama-b11081-bin-ubuntu-cuda-13.4-x64.tar.gz",
"sha256": "71984fb15c371a34e79b773000a712ea6a7ff228e62b6ee6313a7881897ea931",
"bytes": 149582659,
"object": "runtimes/rizzo_flow/llama-b11081-bin-ubuntu-cuda-13.4-x64.tar.gz"
},
{
"candidate": "rizzo_flow",
"kind": "runtime",
"origin": "https://github.com/ggml-org/llama.cpp/releases/tag/b11081",
"revision": "b11081",
"license": "mit",
"path": "cudart-llama-b11081-bin-ubuntu-cuda-13.4-x64.tar.gz",
"sha256": "c86d7a48e65d8bda58f79a288599508c8b0e57e711118c30b8e1228faf7dc898",
"bytes": 440231544,
"object": "runtimes/rizzo_flow/cudart-llama-b11081-bin-ubuntu-cuda-13.4-x64.tar.gz"
}
]
}

7
env/requirements/common.txt vendored Normal file
View File

@@ -0,0 +1,7 @@
# Comum a todas as imagens (servidor JEV, harness, acesso ao Object Storage).
jsonschema==4.26.0
fastapi==0.142.2
uvicorn==0.54.0
pydantic==2.13.5
pyyaml==6.0.3
oci==2.187.2

11
env/requirements/gliner.txt vendored Normal file
View File

@@ -0,0 +1,11 @@
torch==2.10.0
transformers==4.57.6
tokenizers==0.22.2
safetensors==0.8.0
huggingface_hub==0.36.2
numpy==2.4.6 # última compatível com o Python 3.11 dos jobs
peft==0.21.2
protobuf==7.36.2
sentencepiece==0.2.2
tiktoken==0.14.0
gliner2==2.0.0

7
env/requirements/laya.txt vendored Normal file
View File

@@ -0,0 +1,7 @@
torch==2.10.0
transformers==4.57.6
tokenizers==0.22.2
safetensors==0.8.0
huggingface_hub==0.36.2
numpy==2.4.6 # última compatível com o Python 3.11 dos jobs
laya==0.3.29

2
env/requirements/rizzo_flow.txt vendored Normal file
View File

@@ -0,0 +1,2 @@
# Sem torch: o Rizzo Flow roda sobre o llama.cpp b11081 (runtimes copiados para a imagem).
jinja2==3.1.6

10
env/requirements/semif.txt vendored Normal file
View File

@@ -0,0 +1,10 @@
# Versões fixadas pelo pyproject do SemIf (commit 23cf1f39fc95).
torch==2.10.0
transformers==5.17.0
accelerate==1.12.0
safetensors==0.8.0
huggingface-hub==1.31.0
tokenizers==0.23.2
numpy==2.2.6
sentencepiece==0.2.1
protobuf==7.36.1

View File

33
harness/candidates.py Normal file
View File

@@ -0,0 +1,33 @@
"""Construção dos adaptadores a partir do manifesto de artefatos."""
from __future__ import annotations
import json
from pathlib import Path
from adapters import adapter_class
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "env" / "artifacts-manifest.json"
MODEL_REPO = {
"laya": "convaiinnovations/laya-multilingual",
"semif": "Qwen/Qwen3.5-4B",
"gliner": "fastino/GLiNER2.5-multi-Decide",
"rizzo_flow": "rizzoaiacademy/rizzo-flow",
}
def model_location(candidate: str, models_root: Path) -> tuple[Path, str]:
"""Diretório local e revisão fixada do candidato, segundo o manifesto."""
manifest = json.loads(MANIFEST.read_text())
repo = MODEL_REPO[candidate]
entry = next(a for a in manifest["artifacts"]
if a["candidate"] == candidate and a["kind"] == "hf_model" and a["origin"] == f"hf://{repo}")
rev = entry["revision"]
return models_root / candidate / repo.replace("/", "__") / rev, rev
def build_adapter(candidate: str, models_root: str | Path, device: str = "auto",
context_limit: int | None = None, **extra):
model_dir, revision = model_location(candidate, Path(models_root))
return adapter_class(candidate)(model_dir, revision, device=device,
context_limit=context_limit, **extra)

29
harness/image_manifest.py Normal file
View File

@@ -0,0 +1,29 @@
"""Imprime o manifesto de versões do ambiente atual (tarefa 12)."""
import json
import os
import platform
import sys
from importlib import metadata
def collect() -> dict:
packages = sorted((d.metadata["Name"], d.version) for d in metadata.distributions())
info = {
"candidate": os.environ.get("DMB_CANDIDATE"),
"python": sys.version.split()[0],
"platform": platform.platform(),
"base_image": os.environ.get("DMB_BASE_IMAGE"),
"packages": {name: version for name, version in packages},
}
try:
import torch
info["torch_cuda"] = torch.version.cuda
info["cudnn"] = torch.backends.cudnn.version()
except ImportError:
pass
return info
if __name__ == "__main__":
json.dump(collect(), sys.stdout, indent=2)
print()

193
harness/job.py Normal file
View File

@@ -0,0 +1,193 @@
"""Ponto de entrada dos jobs do OCI Data Science (tarefas 10, 17–19).
O processo pai fala só com o Object Storage (via Service Gateway): baixa os artefatos do
candidato, confere o SHA-256 contra o manifesto, roda o gate de egresso e, no fim, envia
resultados e logs. A inferência roda num processo filho com o bloqueio de rede ativo.
Variáveis: DMB_CANDIDATE, DMB_MODE (gate|sanity), DMB_DEVICE (auto|cpu|cuda), DMB_RUN_ID,
DMB_MODELS_ROOT. O ambiente Python do candidato vem de dmb-artefatos/envs/<candidato>/latest.json,
publicado pelo job de build (infra/ds/build_env.sh).
"""
from __future__ import annotations
import hashlib
import json
import os
import socket
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "env" / "artifacts-manifest.json"
BUCKET_ARTIFACTS = os.environ.get("DMB_BUCKET_ARTIFACTS", "dmb-artefatos")
BUCKET_RESULTS = os.environ.get("DMB_BUCKET_RESULTS", "dmb-resultados")
EGRESS_PROBES = [("huggingface.co", 443), ("pypi.org", 443), ("github.com", 443)]
def oci_clients():
import oci
if os.environ.get("OCI_RESOURCE_PRINCIPAL_VERSION"):
signer = oci.auth.signers.get_resource_principals_signer()
client = oci.object_storage.ObjectStorageClient({}, signer=signer)
else:
sys.path.insert(0, str(ROOT / "infra" / "oci"))
from session import load_config
client = oci.object_storage.ObjectStorageClient(load_config())
return client, client.get_namespace().data
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 22), b""):
h.update(chunk)
return h.hexdigest()
def fetch_artifacts(candidate: str, models_root: Path) -> dict:
"""Tarefa 18: só artefatos do manifesto, com hash conferido."""
client, ns = oci_clients()
manifest = json.loads(MANIFEST.read_text())
entries = [a for a in manifest["artifacts"] if a["candidate"] == candidate and a["kind"] == "hf_model"]
started, total = time.perf_counter(), 0
for a in entries:
target = models_root / a["object"].removeprefix("models/")
if not (target.is_file() and target.stat().st_size == a["bytes"] and sha256(target) == a["sha256"]):
target.parent.mkdir(parents=True, exist_ok=True)
obj = client.get_object(ns, BUCKET_ARTIFACTS, a["object"])
with target.open("wb") as f:
for chunk in obj.data.raw.stream(1 << 22, decode_content=False):
f.write(chunk)
if sha256(target) != a["sha256"]:
raise SystemExit(f"SHA-256 divergente: {a['object']}")
total += a["bytes"]
return {"files": len(entries), "bytes": total, "seconds": round(time.perf_counter() - started, 1)}
def install_env(candidate: str) -> tuple[Path, dict]:
"""Baixa o ambiente publicado pelo build, confere o SHA-256 e extrai em $HOME/envs."""
import tarfile
from harness import objstore
home = Path(os.environ.get("HOME", "/home/datascience"))
latest = json.loads(objstore.get(BUCKET_ARTIFACTS, f"envs/{candidate}/latest.json",
Path("/tmp/latest.json")).read_text())
if Path(latest["home"]) != home:
raise SystemExit(f"ambiente construído para HOME={latest['home']}, job em {home}")
tarball = objstore.get(BUCKET_ARTIFACTS, latest["object"], Path("/tmp/env.tar.gz"))
if sha256(tarball) != latest["sha256"]:
raise SystemExit("SHA-256 do ambiente divergente")
with tarfile.open(tarball) as tar:
tar.extractall(home)
tarball.unlink()
return home / "envs" / candidate / "bin" / "python", latest
def install_rizzo_cuda_runtime() -> Path:
"""llama.cpp CUDA compilado para a imagem dos jobs (infra/ds/build_llama_cuda.sh)."""
import tarfile
from harness import objstore
home = Path(os.environ.get("HOME", "/home/datascience"))
latest = json.loads(objstore.get(BUCKET_ARTIFACTS, "runtimes/rizzo_flow/cuda-latest.json",
Path("/tmp/cuda-latest.json")).read_text())
tarball = objstore.get(BUCKET_ARTIFACTS, latest["object"], Path("/tmp/llama-cuda.tar.gz"))
if sha256(tarball) != latest["sha256"]:
raise SystemExit("SHA-256 do runtime CUDA divergente")
with tarfile.open(tarball) as tar:
tar.extractall(home)
return home / latest["dir"]
def has_gpu() -> bool:
return subprocess.run(["sh", "-c", "command -v nvidia-smi && nvidia-smi -L"],
capture_output=True).returncode == 0
def egress_gate() -> dict:
"""Tarefas 17 e 19: o gate passa só se nenhuma saída para a internet funcionar."""
probes = []
for host, port in EGRESS_PROBES:
try:
with socket.create_connection((host, port), timeout=5):
outcome = "CONECTOU"
except OSError as error:
outcome = f"bloqueado ({type(error).__name__})"
probes.append({"host": host, "port": port, "outcome": outcome})
return {"passed": all(p["outcome"] != "CONECTOU" for p in probes), "probes": probes}
def upload_dir(local: Path, bucket: str, prefix: str) -> list[str]:
client, ns = oci_clients()
sent = []
for f in sorted(p for p in local.rglob("*") if p.is_file()):
name = f"{prefix}/{f.relative_to(local).as_posix()}"
with f.open("rb") as body:
client.put_object(ns, bucket, name, body)
sent.append(name)
return sent
def main():
candidate = os.environ["DMB_CANDIDATE"]
mode = os.environ.get("DMB_MODE", "sanity")
device = os.environ.get("DMB_DEVICE", "auto")
run_id = os.environ.setdefault(
"DMB_RUN_ID", f"{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}-{candidate}-{mode}")
models_root = Path(os.environ.get("DMB_MODELS_ROOT", Path.home() / "models"))
if device == "auto":
device = "cuda" if has_gpu() else "cpu"
out = Path("/tmp/dmb-out") / run_id
out.mkdir(parents=True, exist_ok=True)
run = {"run_id": run_id, "candidate": candidate, "mode": mode, "device": device,
"started_at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
image_manifest = ROOT / "image-manifest.json"
if image_manifest.is_file():
run["image"] = json.loads(image_manifest.read_text())
run["egress_gate"] = egress_gate()
# DMB_ISOLATION=app: só onde não há sub-rede privada (região de GPU sem VCN disponível).
# A rede tem saída; o isolamento fica a cargo do bloqueio no processo (dmb.offline + self_test).
run["isolation"] = os.environ.get("DMB_ISOLATION", "network")
if not run["egress_gate"]["passed"] and mode != "gate" and run["isolation"] != "app":
run["status"] = "abortado: gate de egresso reprovado"
else:
if mode != "gate":
run["artifacts"] = fetch_artifacts(candidate, models_root)
python, run["env"] = install_env(candidate)
child_env = dict(os.environ)
if candidate == "rizzo_flow":
# CPU: binário oficial; CUDA: build próprio (o oficial exige GLIBC 2.38).
child_env["RIZZO_LLAMA_DIR"] = str(
install_rizzo_cuda_runtime() if device == "cuda"
else python.parents[1] / "runtimes" / "llama-b11081-linux-x64-cpu")
run["rizzo_runtime"] = child_env["RIZZO_LLAMA_DIR"]
cmd = [str(python), "-m", f"harness.{mode}", "--candidate", candidate,
"--models-root", str(models_root), "--device", device, "--out", str(out)]
proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, env=child_env)
print(proc.stdout[-4000:])
print(proc.stderr[-4000:], file=sys.stderr)
(out / "stdout.log").write_text(proc.stdout)
(out / "stderr.log").write_text(proc.stderr)
run["exit_code"] = proc.returncode
run["status"] = "ok" if proc.returncode == 0 else "falhou"
else:
run["status"] = "ok" if run["egress_gate"]["passed"] else "gate reprovado"
run["finished_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
(out / "run.json").write_text(json.dumps(run, indent=2, ensure_ascii=False) + "\n")
print(json.dumps({k: run[k] for k in ("run_id", "status", "egress_gate")}, ensure_ascii=False))
sent = upload_dir(out, BUCKET_RESULTS, f"runs/{run_id}")
print(f"{len(sent)} arquivos enviados para {BUCKET_RESULTS}/runs/{run_id}")
sys.exit(0 if run["status"] == "ok" else 1)
if __name__ == "__main__":
main()

67
harness/objstore.py Normal file
View File

@@ -0,0 +1,67 @@
"""Acesso mínimo ao Object Storage, usável com o Python base dos jobs (resource principal).
python -m harness.objstore get <bucket> <objeto> <destino>
python -m harness.objstore put <bucket> <objeto> <origem> [--sha256]
"""
from __future__ import annotations
import hashlib
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def client():
import oci
if os.environ.get("OCI_RESOURCE_PRINCIPAL_VERSION"):
c = oci.object_storage.ObjectStorageClient({}, signer=oci.auth.signers.get_resource_principals_signer())
elif os.environ.get("DMB_OCI_AUTH") == "instance_principal":
signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner()
c = oci.object_storage.ObjectStorageClient({"region": signer.region}, signer=signer)
else:
sys.path.insert(0, str(ROOT / "infra" / "oci"))
from session import load_config
c = oci.object_storage.ObjectStorageClient(load_config())
return c, c.get_namespace().data
def sha256(path: Path) -> str:
h = hashlib.sha256()
with Path(path).open("rb") as f:
for chunk in iter(lambda: f.read(1 << 22), b""):
h.update(chunk)
return h.hexdigest()
def get(bucket: str, name: str, dest: Path) -> Path:
c, ns = client()
dest = Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
obj = c.get_object(ns, bucket, name)
with dest.open("wb") as f:
for chunk in obj.data.raw.stream(1 << 22, decode_content=False):
f.write(chunk)
return dest
def put(bucket: str, name: str, src: Path, metadata: dict | None = None) -> None:
from oci.object_storage import UploadManager
c, ns = client()
UploadManager(c, allow_parallel_uploads=True, parallel_process_count=8).upload_file(
ns, bucket, name, str(src), metadata=metadata or {})
if __name__ == "__main__":
cmd, bucket, name, path = sys.argv[1:5]
if cmd == "get":
get(bucket, name, Path(path))
elif cmd == "put":
meta = {"sha256": sha256(Path(path))} if "--sha256" in sys.argv else {}
put(bucket, name, Path(path), meta)
print(meta.get("sha256", ""))
else:
sys.exit(__doc__)

99
harness/sanity.py Normal file
View File

@@ -0,0 +1,99 @@
"""Conformidade e sanidade de um candidato real (tarefas 41, 49–51).
Executa as fixtures de conformidade e os casos de sanidade (decisões triviais, os três
tipos de pergunta e a mesma pergunta com as opções em ordem invertida) e grava um
relatório JSON. Falha (código 1) só por violação de contrato; os acertos de sanidade
são reportados, não exigidos.
Uso: python -m harness.sanity --candidate laya --models-root models --device cpu --out reports/sanity
"""
import argparse
import json
import platform
import sys
import time
from pathlib import Path
from dmb.offline import enforce_offline
FIXTURES = Path(__file__).resolve().parents[1] / "contracts" / "jev" / "fixtures"
def predicted(answer: dict) -> str:
if answer["type"] == "noul":
return "true" if answer["noul"] >= 0.5 else "false"
return max(answer["probabilities"], key=answer["probabilities"].get)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--candidate", required=True)
ap.add_argument("--models-root", default="models")
ap.add_argument("--device", default="auto")
ap.add_argument("--out", default="reports/sanity")
args = ap.parse_args()
enforce_offline()
from dmb.offline import self_test
guard = self_test()
if not guard["process_guard"].startswith("ok"):
sys.exit(f"bloqueio de rede inativo: {guard}")
from dmb.jev import JevError
from dmb.server import systemone
from harness.candidates import build_adapter
adapter = build_adapter(args.candidate, args.models_root, args.device)
started = time.perf_counter()
adapter.load()
load_s = time.perf_counter() - started
violations, conformance = [], []
for case in json.loads((FIXTURES / "conformance.json").read_text())["cases"]:
try:
systemone(adapter, case["request"])
outcome = "ok"
except JevError as error:
outcome = error.error_type
conformance.append({"name": case["name"], "expected": case["expect"], "got": outcome})
if outcome != case["expect"]:
violations.append(f"conformance:{case['name']}: esperado {case['expect']}, obtido {outcome}")
sanity = []
for case in json.loads((FIXTURES / "sanity.json").read_text())["cases"]:
q = case["question"]
request = {"model": adapter.model_id, "state": case["state"], "questions": {"q": q}}
r = systemone(adapter, request)
row = {"name": case["name"], "gold": case["gold"], "pred": predicted(r["answers"]["q"]),
"latency_ms": round(r["metadata"]["latency_ms"], 1), "answer": r["answers"]["q"]}
if q["type"] == "choice":
flipped = dict(reversed(list(q["criteria"].items())))
r2 = systemone(adapter, {**request, "questions": {"q": {**q, "criteria": flipped}}})
if list(r2["answers"]["q"]["probabilities"]) != list(flipped):
violations.append(f"sanity:{case['name']}: ordem das opções não preservada")
row["pred_permuted"] = predicted(r2["answers"]["q"])
row["permutation_invariant"] = row["pred_permuted"] == row["pred"]
row["correct"] = row["pred"] == row["gold"]
sanity.append(row)
report = {
"candidate": adapter.describe(), "device": args.device, "host": platform.node(),
"load_seconds": round(load_s, 2), **guard,
"conformance": conformance, "sanity": sanity,
"summary": {
"contract_violations": violations,
"sanity_correct": f"{sum(r['correct'] for r in sanity)}/{len(sanity)}",
"permutation_invariant": f"{sum(r.get('permutation_invariant', False) for r in sanity)}"
f"/{sum('permutation_invariant' in r for r in sanity)}",
},
}
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
path = out / f"{args.candidate}-{args.device}.json"
path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
print(json.dumps(report["summary"], ensure_ascii=False), "load_s=", report["load_seconds"])
print("relatório:", path)
sys.exit(1 if violations else 0)
if __name__ == "__main__":
main()

31
harness/serve.py Normal file
View File

@@ -0,0 +1,31 @@
"""Sobe um candidato em POST /v1/systemone, sem acesso à rede externa.
Uso: python -m harness.serve --candidate laya --models-root models [--device cpu] [--port 8080]
"""
import argparse
from dmb.offline import enforce_offline
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--candidate", required=True)
ap.add_argument("--models-root", default="models")
ap.add_argument("--device", default="auto")
ap.add_argument("--context-limit", type=int)
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8080)
args = ap.parse_args()
enforce_offline()
import uvicorn
from dmb.server import create_app
from harness.candidates import build_adapter
adapter = build_adapter(args.candidate, args.models_root, args.device, args.context_limit)
uvicorn.run(create_app(adapter), host=args.host, port=args.port, log_level="warning")
if __name__ == "__main__":
main()

31
harness/verify_bucket.py Normal file
View File

@@ -0,0 +1,31 @@
"""Audita o bucket de artefatos: o conteúdo real de cada objeto bate com o SHA-256 do manifesto?
Uso (na VM ou num job): python -m harness.verify_bucket
"""
import hashlib
import json
import sys
from pathlib import Path
from harness.objstore import client
MANIFEST = Path(__file__).resolve().parents[1] / "env" / "artifacts-manifest.json"
def main():
c, ns = client()
manifest = json.loads(MANIFEST.read_text())
bad = 0
for a in manifest["artifacts"]:
h = hashlib.sha256()
for chunk in c.get_object(ns, manifest["bucket"], a["object"]).data.raw.stream(1 << 22, decode_content=False):
h.update(chunk)
ok = h.hexdigest() == a["sha256"]
bad += not ok
print(("ok " if ok else "DIVERGENTE ") + a["object"], flush=True)
print(f"{len(manifest['artifacts']) - bad}/{len(manifest['artifacts'])} artefatos conferem")
sys.exit(1 if bad else 0)
if __name__ == "__main__":
main()

53
infra/ds/build_env.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Job de build (rede gerenciada, com internet): cria o ambiente Python do candidato com as
# versões fixadas de env/requirements/, empacota e publica em dmb-artefatos/envs/<candidato>/.
# Os jobs de benchmark (sub-rede privada) só baixam esse pacote; nunca instalam da internet.
set -euo pipefail
CAND="${DMB_CANDIDATE:?defina DMB_CANDIDATE}"
CODE="$(cd "$(dirname "$0")/../.." && pwd)"
BASE_PY=/opt/conda/bin/python3
ENV_DIR="$HOME/envs/$CAND"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
cd "$CODE"
echo "== build $CAND em $ENV_DIR (código $DMB_CODE_REV)"
rm -rf "$ENV_DIR"
"$BASE_PY" -m venv "$ENV_DIR"
"$ENV_DIR/bin/pip" install -q --upgrade "pip==25.2"
"$ENV_DIR/bin/pip" install -q -r env/requirements/common.txt "pytest==8.4.2" "httpx==0.28.1"
"$ENV_DIR/bin/pip" install -q --extra-index-url https://download.pytorch.org/whl/cu128 -r "env/requirements/$CAND.txt"
src_object() { # objeto do tarball de código-fonte do candidato, segundo o manifesto
"$BASE_PY" -c "import json,sys; m=json.load(open('env/artifacts-manifest.json')); print(next(a['object'] for a in m['artifacts'] if a['candidate']=='$CAND' and a['kind']=='source'))"
}
case "$CAND" in
semif|rizzo_flow)
OBJ="$(src_object)"
"$BASE_PY" -m harness.objstore get dmb-artefatos "$OBJ" /tmp/src.tar.gz
mkdir -p /tmp/src && tar xzf /tmp/src.tar.gz -C /tmp/src
"$ENV_DIR/bin/pip" install -q --no-deps /tmp/src/*/
;;
esac
if [ "$CAND" = rizzo_flow ]; then
# Runtime llama.cpp b11081 (CPU e CUDA), com o SHA-256 conferido pelo próprio Rizzo Flow.
(cd "$ENV_DIR" && "$ENV_DIR/bin/rizzo" download --only runtime --runtime cpu \
&& "$ENV_DIR/bin/rizzo" download --only runtime --runtime cuda && rm -rf runtimes/downloads)
fi
"$ENV_DIR/bin/python" -m harness.image_manifest > "$ENV_DIR/env-manifest.json"
"$ENV_DIR/bin/python" -m pytest -q tests 2>&1 | tail -2
TARBALL="/tmp/$CAND-env.tar.gz"
tar czf "$TARBALL" -C "$HOME" "envs/$CAND"
OBJECT="envs/$CAND/$STAMP.tar.gz"
SHA="$("$BASE_PY" -m harness.objstore put dmb-artefatos "$OBJECT" "$TARBALL" --sha256)"
SIZE="$(stat -c %s "$TARBALL")"
cat > /tmp/latest.json <<JSON
{"candidate": "$CAND", "object": "$OBJECT", "sha256": "$SHA", "bytes": $SIZE,
"built_at": "$STAMP", "code_rev": "${DMB_CODE_REV:-}", "python": "$("$ENV_DIR/bin/python" --version 2>&1)",
"home": "$HOME"}
JSON
"$BASE_PY" -m harness.objstore put dmb-artefatos "envs/$CAND/latest.json" /tmp/latest.json
"$BASE_PY" -m harness.objstore put dmb-artefatos "envs/$CAND/$STAMP-env-manifest.json" "$ENV_DIR/env-manifest.json"
cat /tmp/latest.json
echo "== ok"

51
infra/ds/build_llama_cuda.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Job de build (rede gerenciada): compila o llama.cpp com CUDA para a imagem dos jobs do Data Science.
# Os binários oficiais CUDA do llama.cpp exigem GLIBC 2.38, ausente nessa imagem. Compilamos o mesmo
# commit fixado pelo Rizzo Flow, com sysroot glibc 2.17, libstdc++ estática e CUDA 12.8 (sm_80/sm_86),
# no mesmo layout dos pacotes oficiais (backends como plug-ins, libs CUDA no mesmo diretório).
set -euo pipefail
COMMIT=161755f29e415e2c33efe906e91843c068efd664 # rizzo_flow.llama_release.COMMIT (b11081)
CODE="$(cd "$(dirname "$0")/../.." && pwd)"
BASE_PY=/opt/conda/bin/python3
TC="$HOME/toolchain"
OUT_NAME="llama-b11081-linux-x64-cuda-dsjobs"
OUT="$HOME/$OUT_NAME"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
echo "== glibc da imagem: $(ldd --version | head -1)"
/opt/conda/bin/conda create -q -y -p "$TC" -c conda-forge \
"cuda-nvcc=12.8" "cuda-cudart-dev=12.8" "libcublas-dev=12.8" "cuda-version=12.8" \
cmake ninja git "gxx_linux-64=13" "sysroot_linux-64=2.17" >/dev/null
export PATH="$TC/bin:$PATH" CUDAToolkit_ROOT="$TC" CUDACXX="$TC/bin/nvcc"
export CC="$TC/bin/x86_64-conda-linux-gnu-gcc" CXX="$TC/bin/x86_64-conda-linux-gnu-g++"
git clone -q https://github.com/ggml-org/llama.cpp /tmp/llama.cpp
git -C /tmp/llama.cpp checkout -q "$COMMIT"
cmake -S /tmp/llama.cpp -B /tmp/build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=ON -DGGML_BACKEND_DL=ON -DGGML_NATIVE=OFF -DGGML_CPU_ALL_VARIANTS=ON \
-DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES="80;86" -DLLAMA_CURL=OFF \
-DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_SERVER=OFF -DLLAMA_BUILD_TOOLS=OFF \
-DCMAKE_SHARED_LINKER_FLAGS="-static-libstdc++ -static-libgcc" \
-DCMAKE_BUILD_RPATH_USE_ORIGIN=ON -DCMAKE_INSTALL_RPATH='$ORIGIN' -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \
>/tmp/cmake.log
cmake --build /tmp/build -j "$(nproc)" >/tmp/build.log 2>&1 || { tail -50 /tmp/build.log; exit 1; }
rm -rf "$OUT" && mkdir -p "$OUT"
find /tmp/build/bin -maxdepth 1 -name "*.so" -exec cp -L {} "$OUT/" \;
for lib in libcudart.so.12 libcublas.so.12 libcublasLt.so.12; do cp -L "$TC/lib/$lib" "$OUT/"; done
ls -la "$OUT"
echo "== dependências não resolvidas:"; for f in "$OUT"/*.so*; do LD_LIBRARY_PATH="$OUT" ldd "$f" | grep "not found" || true; done
echo "== maior GLIBC exigida:"; objdump -T "$OUT"/*.so* 2>/dev/null | grep -o "GLIBC_[0-9.]*" | sort -uV | tail -1
cd "$CODE"
TARBALL="/tmp/$OUT_NAME.tar.gz"
tar czf "$TARBALL" -C "$HOME" "$OUT_NAME"
OBJECT="runtimes/rizzo_flow/$OUT_NAME-$STAMP.tar.gz"
SHA="$("$BASE_PY" -m harness.objstore put dmb-artefatos "$OBJECT" "$TARBALL" --sha256)"
cat > /tmp/cuda-latest.json <<JSON
{"object": "$OBJECT", "sha256": "$SHA", "dir": "$OUT_NAME", "llama_commit": "$COMMIT",
"cuda": "12.8", "archs": "80;86", "built_at": "$STAMP", "code_rev": "${DMB_CODE_REV:-}"}
JSON
"$BASE_PY" -m harness.objstore put dmb-artefatos runtimes/rizzo_flow/cuda-latest.json /tmp/cuda-latest.json
cat /tmp/cuda-latest.json
echo "== ok"

37
infra/ds/gpu_probe.py Normal file
View File

@@ -0,0 +1,37 @@
"""Encontra um shape de GPU com capacidade: roda o job em cada shape, em ordem, até um não falhar
por falta de capacidade. Imprime o shape usado e o estado final.
Uso: PYTHONPATH=infra/oci:infra/ds .venv/bin/python infra/ds/gpu_probe.py <candidato> [shape ...]
"""
import sys
import jobs
DEFAULT_SHAPES = ["VM.GPU.A10.1", "VM.GPU.A10.2", "VM.GPU.A100.B40G.1", "VM.GPU.A100.80G.1", "VM.GPU3.1"]
def main():
args = sys.argv[1:]
region = None
if args and args[0].startswith("--region="):
region = args.pop(0).split("=", 1)[1]
candidate, shapes = args[0], args[1:] or DEFAULT_SHAPES
for shape in shapes:
try:
run_id = jobs.run_job(f"sanity-{candidate}-gpu", "infra/ds/run_job.sh", shape, None, None, region is None,
{"DMB_CANDIDATE": candidate, "DMB_MODE": "sanity", "DMB_DEVICE": "auto"},
storage_gb=60, region=region)
except jobs.oci.exceptions.ServiceError as error:
print(f"indisponível: {shape} ({error.code})", flush=True)
continue
r = jobs.wait(run_id, poll=20)
if "no capacity" in (r.lifecycle_details or "").lower():
print(f"sem capacidade: {shape}", flush=True)
continue
print(f"SHAPE {shape} -> {r.lifecycle_state} {run_id}", flush=True)
return
print("nenhum shape com capacidade", flush=True)
if __name__ == "__main__":
main()

47
infra/ds/gpu_queue.py Normal file
View File

@@ -0,0 +1,47 @@
"""Fila de jobs de GPU (o limite em sa-saopaulo-1 é de uma A10 por vez).
Uso: PYTHONPATH=infra/oci:infra/ds .venv/bin/python infra/ds/gpu_queue.py <modo> <candidato> [...] \
[--after <job_run_ocid>] [--shape VM.GPU.A10.1]
Grava "candidato run_ocid estado" em runs/gpu-queue.log.
"""
import argparse
import time
from pathlib import Path
import jobs
import provision
LOG = Path(__file__).resolve().parents[2] / "runs" / "gpu-queue.log"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("mode")
ap.add_argument("candidates", nargs="+")
ap.add_argument("--after")
ap.add_argument("--shape", default="VM.GPU.A10.1")
ap.add_argument("--storage", type=int, default=100)
args = ap.parse_args()
LOG.parent.mkdir(exist_ok=True)
if args.after:
jobs.wait(args.after, poll=30)
for cand in args.candidates:
while True:
try:
run_id = jobs.run_job(f"{args.mode}-{cand}-gpu", "infra/ds/run_job.sh", args.shape, None, None,
False, {"DMB_CANDIDATE": cand, "DMB_MODE": args.mode, "DMB_DEVICE": "auto"},
storage_gb=args.storage, region=provision.GPU_REGION)
break
except jobs.oci.exceptions.ServiceError as error:
if error.code != "LimitExceeded":
raise
time.sleep(60) # outra GPU ainda ocupada
r = jobs.wait(run_id, poll=30)
line = f"{cand} {run_id} {r.lifecycle_state} {(r.lifecycle_details or '')[:80]}"
print(line, flush=True)
with LOG.open("a") as f:
f.write(line + "\n")
if __name__ == "__main__":
main()

187
infra/ds/jobs.py Normal file
View File

@@ -0,0 +1,187 @@
"""Execução no OCI Data Science: a máquina local só empacota o código e dispara jobs.
Uso:
PYTHONPATH=infra/oci .venv/bin/python infra/ds/jobs.py run --name probe --entrypoint infra/ds/probe.sh \
[--shape VM.Standard.E4.Flex --ocpus 2 --memory 16 | --shape VM.GPU.A10.1] [--private] \
[--env K=V ...] [--wait]
PYTHONPATH=infra/oci .venv/bin/python infra/ds/jobs.py logs <job_run_ocid>
--private usa a sub-rede dmb-subnet-jobs (sem internet; só Service Gateway).
Sem --private, o job usa a rede gerenciada do serviço (com internet), só para builds.
"""
from __future__ import annotations
import argparse
import io
import subprocess
import sys
import time
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
import oci
from session import load_config
import provision
ROOT = Path(__file__).resolve().parents[2]
EXCLUDE_DIRS = {".git", ".secrets", "models", "reports", "runs", "__pycache__", ".pytest_cache"}
LOG_GROUP = "dmb-logs"
def compartment(cfg_home) -> str:
return provision.find_compartment(oci.identity.IdentityClient(cfg_home), cfg_home["tenancy"])
def code_zip() -> tuple[bytes, str]:
"""Snapshot do repositório local (sem segredos, modelos e venvs)."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr(zipfile.ZipInfo("dmb/"), "")
for p in sorted(ROOT.rglob("*")):
rel = p.relative_to(ROOT)
if any(part in EXCLUDE_DIRS or part.startswith(".venv") for part in rel.parts):
continue
if p.is_dir():
z.writestr(zipfile.ZipInfo(f"dmb/{rel.as_posix()}/"), "")
continue
info = zipfile.ZipInfo.from_file(p, f"dmb/{rel.as_posix()}")
info.compress_type = zipfile.ZIP_DEFLATED
z.writestr(info, p.read_bytes())
try:
rev = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"],
capture_output=True, text=True).stdout.strip() or "sem-commit"
except OSError:
rev = "sem-commit"
return buf.getvalue(), rev
def ensure_log_group(cfg, cid) -> str:
lc = oci.logging.LoggingManagementClient(cfg)
lg = next((g for g in lc.list_log_groups(cid, display_name=LOG_GROUP).data), None)
if lg is None:
lc.create_log_group(oci.logging.models.CreateLogGroupDetails(
compartment_id=cid, display_name=LOG_GROUP, description="Logs dos jobs do benchmark",
freeform_tags=provision.TAGS))
for _ in range(30):
lg = next((g for g in lc.list_log_groups(cid, display_name=LOG_GROUP).data), None)
if lg:
break
time.sleep(5)
return lg.id
def subnet_id(cfg, cid) -> str:
vn = oci.core.VirtualNetworkClient(cfg)
return next(s.id for s in vn.list_subnets(cid, display_name="dmb-subnet-jobs").data
if s.lifecycle_state == "AVAILABLE")
def run_job(name, entrypoint, shape, ocpus, memory, private, env, storage_gb=100, max_minutes=240, region=None):
cfg, home = load_config(region=region) if region else load_config(), load_config(region="us-ashburn-1")
cid = compartment(home)
ds = oci.data_science.DataScienceClient(cfg)
project = next(p.id for p in ds.list_projects(cid, lifecycle_state="ACTIVE").data
if p.display_name == "benchmark-decisoes-ptbr")
log_group = ensure_log_group(cfg, cid)
flex = shape.endswith(".Flex")
infra = oci.data_science.models.StandaloneJobInfrastructureConfigurationDetails(
job_infrastructure_type="STANDALONE" if private else "ME_STANDALONE",
shape_name=shape, block_storage_size_in_gbs=storage_gb,
subnet_id=subnet_id(cfg, cid) if private else None,
job_shape_config_details=oci.data_science.models.JobShapeConfigDetails(
ocpus=ocpus, memory_in_gbs=memory) if flex else None)
payload, rev = code_zip()
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
env = {"JOB_RUN_ENTRYPOINT": f"dmb/{entrypoint}", "DMB_CODE_REV": rev, **env}
if cfg["region"] == provision.GPU_REGION:
env.setdefault("DMB_BUCKET_ARTIFACTS", f"dmb-artefatos{provision.GPU_SUFFIX}")
env.setdefault("DMB_BUCKET_RESULTS", f"dmb-resultados{provision.GPU_SUFFIX}")
if not private:
env.setdefault("DMB_ISOLATION", "app")
job = ds.create_job(oci.data_science.models.CreateJobDetails(
project_id=project, compartment_id=cid, display_name=f"dmb-{name}-{stamp}",
freeform_tags=provision.TAGS,
job_configuration_details=oci.data_science.models.DefaultJobConfigurationDetails(
job_type="DEFAULT", environment_variables=env,
maximum_runtime_in_minutes=max_minutes),
job_infrastructure_configuration_details=infra,
job_log_configuration_details=oci.data_science.models.JobLogConfigurationDetails(
enable_logging=True, enable_auto_log_creation=True, log_group_id=log_group))).data
ds.create_job_artifact(job.id, payload, content_disposition="attachment; filename=dmb.zip")
run = ds.create_job_run(oci.data_science.models.CreateJobRunDetails(
project_id=project, compartment_id=cid, job_id=job.id,
display_name=f"dmb-{name}-{stamp}", freeform_tags=provision.TAGS)).data
print(f"job {job.id}\nrun {run.id}", flush=True)
return run.id
def region_of(ocid: str) -> str:
return ocid.split(".")[3] if ocid.count(".") >= 4 and ocid.split(".")[3] else load_config()["region"]
def wait(run_id, poll=30):
ds = oci.data_science.DataScienceClient(load_config(region=region_of(run_id)))
while True:
r = ds.get_job_run(run_id).data
if r.lifecycle_state in ("SUCCEEDED", "FAILED", "CANCELED", "DELETED"):
print("estado:", r.lifecycle_state, r.lifecycle_details or "", flush=True)
return r
time.sleep(poll)
def logs(run_id, limit=500):
cfg = load_config(region=region_of(run_id))
ds = oci.data_science.DataScienceClient(cfg)
r = ds.get_job_run(run_id).data
if not r.log_details:
print("sem log configurado ainda")
return
search = oci.loggingsearch.LogSearchClient(cfg)
start = r.time_accepted - timedelta(minutes=5)
end = (r.time_finished or datetime.now(timezone.utc)) + timedelta(minutes=10)
query = (f'search "{r.compartment_id}/{r.log_details.log_group_id}/{r.log_details.log_id}" '
"| sort by datetime asc")
try:
res = search.search_logs(oci.loggingsearch.models.SearchLogsDetails(
time_start=start, time_end=end, search_query=query, is_return_field_info=False), limit=limit).data
except oci.exceptions.ServiceError as error:
print(f"logs indisponíveis ({error.status} {error.code}); log_id={r.log_details.log_id}")
return
for item in res.results or []:
print(item.data.get("logContent", {}).get("data", {}).get("message", ""))
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("run")
r.add_argument("--name", required=True)
r.add_argument("--entrypoint", required=True)
r.add_argument("--shape", default="VM.Standard.E4.Flex")
r.add_argument("--ocpus", type=float, default=2)
r.add_argument("--memory", type=float, default=16)
r.add_argument("--storage", type=int, default=100)
r.add_argument("--private", action="store_true")
r.add_argument("--env", nargs="*", default=[])
r.add_argument("--wait", action="store_true")
r.add_argument("--region", help="padrão: us-chicago-1; sa-saopaulo-1 para GPU")
lg = sub.add_parser("logs")
lg.add_argument("run_id")
args = ap.parse_args()
if args.cmd == "logs":
logs(args.run_id)
return
env = dict(kv.split("=", 1) for kv in args.env)
run_id = run_job(args.name, args.entrypoint, args.shape, args.ocpus, args.memory, args.private,
env, storage_gb=args.storage, region=args.region)
if args.wait:
result = wait(run_id)
time.sleep(60) # ingestão dos logs
logs(run_id)
sys.exit(0 if result.lifecycle_state == "SUCCEEDED" else 1)
if __name__ == "__main__":
main()

11
infra/ds/probe.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Sonda do ambiente padrão dos jobs do Data Science.
set -x
whoami; echo "HOME=$HOME PWD=$PWD"; cat /etc/os-release | head -3; uname -m
which python3 python conda curl tar unzip git 2>&1
python3 --version 2>&1; python3 -c "import oci; print('oci', oci.__version__)" 2>&1
df -h $HOME /tmp 2>&1 | tail -3; nproc; free -g | head -2
nvidia-smi 2>&1 | head -5
env | grep -E '^(JOB_|OCI_|CONDA|DMB_|NB_)' | sed -E 's/(TOKEN|KEY|SECRET)=.*/\1=***/' | sort
ls "$PWD" | head; ls /home/datascience 2>&1 | head
timeout 5 curl -sI https://pypi.org | head -1 || echo "sem internet"

38
infra/ds/results.py Normal file
View File

@@ -0,0 +1,38 @@
"""Resumo das execuções gravadas em dmb-resultados/runs/.
Uso: PYTHONPATH=infra/oci .venv/bin/python infra/ds/results.py [filtro]
"""
import json
import sys
import oci
from session import load_config
BUCKET = "dmb-resultados"
def main():
needle = sys.argv[1] if len(sys.argv) > 1 else ""
c = oci.object_storage.ObjectStorageClient(load_config())
ns = c.get_namespace().data
names = [o.name for o in oci.pagination.list_call_get_all_results(
c.list_objects, ns, BUCKET, prefix="runs/").data.objects]
for name in sorted(n for n in names if n.endswith("/run.json") and needle in n):
run = json.loads(c.get_object(ns, BUCKET, name).data.content)
line = {k: run.get(k) for k in ("run_id", "candidate", "device", "status")}
line["gate"] = run.get("egress_gate", {}).get("passed")
prefix = name.rsplit("/", 1)[0]
for n in names:
if n.startswith(prefix) and n.endswith(".json") and n != name:
rep = json.loads(c.get_object(ns, BUCKET, n).data.content)
if "summary" in rep:
line |= rep["summary"] | {"load_s": rep.get("load_seconds")}
lat = [r["latency_ms"] for r in rep.get("sanity", [])]
if lat:
line["latency_ms_median"] = sorted(lat)[len(lat) // 2]
print(json.dumps(line, ensure_ascii=False))
if __name__ == "__main__":
main()

6
infra/ds/run_job.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Entrada dos jobs de benchmark: usa o Python base do job (SDK OCI + resource principal)
# para o processo pai; a inferência roda no ambiente do candidato (ver harness/job.py).
set -euo pipefail
cd "$(dirname "$0")/../.."
exec /opt/conda/bin/python3 -m harness.job

View File

@@ -1,8 +1,13 @@
"""Provisionamento idempotente da fase F0 (tarefas 1, 2, 5, 6, 7, 8). """Provisionamento idempotente da fase F0 (tarefas 1, 2, 5, 6, 7, 8) e do ambiente de execução.
Ambiente de execução: a máquina local é só IDE e repositório. Os scripts rodam na VM
dmb-vm (sub-rede de desenvolvimento, SSH só do IP autorizado) e em notebooks e jobs do
OCI Data Science. A sub-rede privada dmb-subnet-jobs continua sem saída para a internet.
Uso: PYTHONPATH=infra/oci .venv/bin/python infra/oci/provision.py Uso: PYTHONPATH=infra/oci .venv/bin/python infra/oci/provision.py
Registra os artefatos criados em .secrets/artefatos-OCI.md. Registra os artefatos criados em .secrets/artefatos-OCI.md.
""" """
import os
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -16,7 +21,19 @@ TAGS = {"projeto": "benchmark-decisoes-ptbr", "ambiente": "benchmark"} # centro
BUCKETS = ["entrada", "artefatos", "resultados", "logs-imutaveis"] BUCKETS = ["entrada", "artefatos", "resultados", "logs-imutaveis"]
VCN_CIDR = "10.20.0.0/16" VCN_CIDR = "10.20.0.0/16"
SUBNET_CIDR = "10.20.1.0/24" SUBNET_CIDR = "10.20.1.0/24"
DEV_SUBNET_CIDR = "10.20.2.0/24"
SSH_ALLOWED_CIDR = os.environ.get("DMB_SSH_CIDR", "203.0.113.1/32")
# VM de trabalho criada pelo usuário no console (o lançamento via API é negado neste compartment).
VM_NAME = "instance-20261007-1206"
VM_SSH_KEY = ".secrets/ssh/ssh-key-oci.key"
VM_USER = "opc"
NOTEBOOK_NAME = "dmb-notebook-cpu"
NOTEBOOK_SHAPE = ("VM.Standard.E4.Flex", 4, 32)
ARTIFACTS_FILE = CRED_DIR.parent / "artefatos-OCI.md" ARTIFACTS_FILE = CRED_DIR.parent / "artefatos-OCI.md"
# Região secundária só para jobs de GPU (A10 sem capacidade em us-chicago-1).
GPU_REGION = "sa-saopaulo-1"
GPU_SUFFIX = "-gru"
GPU_BUCKETS = ["artefatos", "resultados"]
artifacts = [] artifacts = []
@@ -64,21 +81,22 @@ def ensure_ds_project(cfg, cid):
proj = ds.create_project(oci.data_science.models.CreateProjectDetails( proj = ds.create_project(oci.data_science.models.CreateProjectDetails(
compartment_id=cid, display_name=name, freeform_tags=TAGS, compartment_id=cid, display_name=name, freeform_tags=TAGS,
description="Benchmark de modelos de decisão PT-BR")).data description="Benchmark de modelos de decisão PT-BR")).data
record("Data Science Project", name, proj.id, REGION) record("Data Science Project", name, proj.id, cfg["region"])
return proj.id
def ensure_buckets(cfg, cid): def ensure_buckets(cfg, cid, suffix="", buckets=BUCKETS):
os_ = oci.object_storage.ObjectStorageClient(cfg) os_ = oci.object_storage.ObjectStorageClient(cfg)
ns = os_.get_namespace().data ns = os_.get_namespace().data
existing = {b.name for b in oci.pagination.list_call_get_all_results(os_.list_buckets, ns, cid).data} existing = {b.name for b in oci.pagination.list_call_get_all_results(os_.list_buckets, ns, cid).data}
for short in BUCKETS: for short in buckets:
name = f"dmb-{short}" name = f"dmb-{short}{suffix}"
if name not in existing: if name not in existing:
os_.create_bucket(ns, oci.object_storage.models.CreateBucketDetails( os_.create_bucket(ns, oci.object_storage.models.CreateBucketDetails(
name=name, compartment_id=cid, public_access_type="NoPublicAccess", name=name, compartment_id=cid, public_access_type="NoPublicAccess",
storage_tier="Standard", versioning="Enabled", freeform_tags=TAGS)) storage_tier="Standard", versioning="Enabled", freeform_tags=TAGS))
b = os_.get_bucket(ns, name).data b = os_.get_bucket(ns, name).data
record("Object Storage Bucket", f"{name} (namespace {ns})", b.id, REGION) record("Object Storage Bucket", f"{name} (namespace {ns})", b.id, cfg["region"])
def ensure_network(cfg, cid): def ensure_network(cfg, cid):
@@ -92,7 +110,7 @@ def ensure_network(cfg, cid):
compartment_id=cid, display_name="dmb-vcn", cidr_blocks=[VCN_CIDR], compartment_id=cid, display_name="dmb-vcn", cidr_blocks=[VCN_CIDR],
dns_label="dmbvcn", freeform_tags=TAGS)).data dns_label="dmbvcn", freeform_tags=TAGS)).data
vcn = comp(vn, vn.get_vcn(vcn.id), "lifecycle_state", "AVAILABLE").data vcn = comp(vn, vn.get_vcn(vcn.id), "lifecycle_state", "AVAILABLE").data
record("VCN", f"dmb-vcn ({VCN_CIDR})", vcn.id, REGION) record("VCN", f"dmb-vcn ({VCN_CIDR})", vcn.id, cfg["region"])
svc = next(s for s in vn.list_services().data if s.cidr_block.startswith("all-") and "services-in-oracle-services-network" in s.cidr_block) svc = next(s for s in vn.list_services().data if s.cidr_block.startswith("all-") and "services-in-oracle-services-network" in s.cidr_block)
sgw = next((g for g in vn.list_service_gateways(cid, vcn_id=vcn.id).data sgw = next((g for g in vn.list_service_gateways(cid, vcn_id=vcn.id).data
@@ -102,7 +120,7 @@ def ensure_network(cfg, cid):
compartment_id=cid, vcn_id=vcn.id, display_name="dmb-sgw", freeform_tags=TAGS, compartment_id=cid, vcn_id=vcn.id, display_name="dmb-sgw", freeform_tags=TAGS,
services=[oci.core.models.ServiceIdRequestDetails(service_id=svc.id)])).data services=[oci.core.models.ServiceIdRequestDetails(service_id=svc.id)])).data
sgw = comp(vn, vn.get_service_gateway(sgw.id), "lifecycle_state", "AVAILABLE").data sgw = comp(vn, vn.get_service_gateway(sgw.id), "lifecycle_state", "AVAILABLE").data
record("Service Gateway", f"dmb-sgw ({svc.cidr_block})", sgw.id, REGION) record("Service Gateway", f"dmb-sgw ({svc.cidr_block})", sgw.id, cfg["region"])
rt = next((r for r in vn.list_route_tables(cid, vcn_id=vcn.id, display_name="dmb-rt-privada").data rt = next((r for r in vn.list_route_tables(cid, vcn_id=vcn.id, display_name="dmb-rt-privada").data
if r.lifecycle_state == "AVAILABLE"), None) if r.lifecycle_state == "AVAILABLE"), None)
@@ -113,7 +131,7 @@ def ensure_network(cfg, cid):
destination=svc.cidr_block, destination_type="SERVICE_CIDR_BLOCK", destination=svc.cidr_block, destination_type="SERVICE_CIDR_BLOCK",
network_entity_id=sgw.id, description="Object Storage e serviços OCI via Service Gateway")])).data network_entity_id=sgw.id, description="Object Storage e serviços OCI via Service Gateway")])).data
rt = comp(vn, vn.get_route_table(rt.id), "lifecycle_state", "AVAILABLE").data rt = comp(vn, vn.get_route_table(rt.id), "lifecycle_state", "AVAILABLE").data
record("Route Table", "dmb-rt-privada (somente Service Gateway)", rt.id, REGION) record("Route Table", "dmb-rt-privada (somente Service Gateway)", rt.id, cfg["region"])
sl = next((s for s in vn.list_security_lists(cid, vcn_id=vcn.id, display_name="dmb-sl-privada").data sl = next((s for s in vn.list_security_lists(cid, vcn_id=vcn.id, display_name="dmb-sl-privada").data
if s.lifecycle_state == "AVAILABLE"), None) if s.lifecycle_state == "AVAILABLE"), None)
@@ -131,7 +149,7 @@ def ensure_network(cfg, cid):
oci.core.models.IngressSecurityRule(source=VCN_CIDR, protocol="all", description="Tráfego interno da VCN"), oci.core.models.IngressSecurityRule(source=VCN_CIDR, protocol="all", description="Tráfego interno da VCN"),
])).data ])).data
sl = comp(vn, vn.get_security_list(sl.id), "lifecycle_state", "AVAILABLE").data sl = comp(vn, vn.get_security_list(sl.id), "lifecycle_state", "AVAILABLE").data
record("Security List", "dmb-sl-privada (egresso só para serviços OCI e VCN)", sl.id, REGION) record("Security List", "dmb-sl-privada (egresso só para serviços OCI e VCN)", sl.id, cfg["region"])
sub = next((s for s in vn.list_subnets(cid, vcn_id=vcn.id, display_name="dmb-subnet-jobs").data sub = next((s for s in vn.list_subnets(cid, vcn_id=vcn.id, display_name="dmb-subnet-jobs").data
if s.lifecycle_state == "AVAILABLE"), None) if s.lifecycle_state == "AVAILABLE"), None)
@@ -141,13 +159,14 @@ def ensure_network(cfg, cid):
dns_label="jobs", prohibit_public_ip_on_vnic=True, route_table_id=rt.id, dns_label="jobs", prohibit_public_ip_on_vnic=True, route_table_id=rt.id,
security_list_ids=[sl.id], freeform_tags=TAGS)).data security_list_ids=[sl.id], freeform_tags=TAGS)).data
sub = comp(vn, vn.get_subnet(sub.id), "lifecycle_state", "AVAILABLE").data sub = comp(vn, vn.get_subnet(sub.id), "lifecycle_state", "AVAILABLE").data
record("Subnet (privada)", f"dmb-subnet-jobs ({SUBNET_CIDR})", sub.id, REGION) record("Subnet (privada)", f"dmb-subnet-jobs ({SUBNET_CIDR})", sub.id, cfg["region"])
# O NAT é criado só por nat.py; aqui ele só é registrado, se existir. # O NAT é criado só por nat.py; aqui ele só é registrado, se existir.
for nat in vn.list_nat_gateways(cid, vcn_id=vcn.id).data: for nat in vn.list_nat_gateways(cid, vcn_id=vcn.id).data:
if nat.lifecycle_state == "AVAILABLE": if nat.lifecycle_state == "AVAILABLE":
status = "bloqueado" if nat.block_traffic else "ATIVO" status = "bloqueado" if nat.block_traffic else "ATIVO"
record("NAT Gateway (temporário)", f"{nat.display_name} ({status})", nat.id, REGION) record("NAT Gateway (temporário)", f"{nat.display_name} ({status})", nat.id, cfg["region"])
return vcn.id
USER_FAMILIES = [ USER_FAMILIES = [
@@ -157,6 +176,9 @@ USER_FAMILIES = [
"logging-family", "logging-family",
"repos", "repos",
"generative-ai-family", "generative-ai-family",
"instance-family",
"volume-family",
"instance-agent-command-family",
] ]
@@ -192,18 +214,130 @@ def ensure_policies(idn, cid, user_id):
f"Allow any-user to use log-content in {comp} where all {{{job}}}", f"Allow any-user to use log-content in {comp} where all {{{job}}}",
f"Allow any-user to read repos in {comp} where all {{{job}}}", f"Allow any-user to read repos in {comp} where all {{{job}}}",
f"Allow service datascience to use virtual-network-family in {comp}", f"Allow service datascience to use virtual-network-family in {comp}",
# copy_object entre regiões (réplica para a região de GPU)
f"Allow service objectstorage-{REGION} to manage object-family in {comp}",
f"Allow service objectstorage-{GPU_REGION} to manage object-family in {comp}",
] ]
ensure_policy(idn, cid, "dmb-jobs-menor-privilegio", ensure_policy(idn, cid, "dmb-jobs-menor-privilegio",
"Jobs de benchmark: leitura de entrada/artefatos e escrita de resultados/logs (tarefa 7)", "Jobs de benchmark: leitura de entrada/artefatos e escrita de resultados/logs (tarefa 7)",
job_stmts) job_stmts)
def ensure_workload_policy(idn, cid):
"""VM (instance principal) e notebooks do Data Science (resource principal), sem dynamic groups."""
comp = f"compartment id {cid}"
who = (f"all {{request.principal.compartment.id = '{cid}', "
"any {request.principal.type = 'instance', request.principal.type = 'datasciencenotebooksession'}}")
stmts = [f"Allow any-user to {verb} in {comp} where {who}" for verb in (
"read buckets",
"manage objects",
"manage data-science-family",
"manage repos",
"use virtual-network-family",
"use log-groups",
"use log-content",
"use generative-ai-family",
"read compartments",
)]
ensure_policy(idn, cid, "dmb-workloads", "VM de trabalho e notebooks do benchmark", stmts)
def ensure_dev_network(vn, cid, vcn_id):
cfg = {"region": REGION} # a sub-rede de desenvolvimento só existe na região principal
igw = next((g for g in vn.list_internet_gateways(cid, vcn_id=vcn_id).data
if g.lifecycle_state == "AVAILABLE"), None)
if igw is None:
igw = vn.create_internet_gateway(oci.core.models.CreateInternetGatewayDetails(
compartment_id=cid, vcn_id=vcn_id, display_name="dmb-igw-dev", is_enabled=True,
freeform_tags=TAGS)).data
igw = oci.wait_until(vn, vn.get_internet_gateway(igw.id), "lifecycle_state", "AVAILABLE").data
record("Internet Gateway", "dmb-igw-dev (só para a sub-rede de desenvolvimento)", igw.id, cfg["region"])
rt = next((r for r in vn.list_route_tables(cid, vcn_id=vcn_id, display_name="dmb-rt-dev").data
if r.lifecycle_state == "AVAILABLE"), None)
if rt is None:
rt = vn.create_route_table(oci.core.models.CreateRouteTableDetails(
compartment_id=cid, vcn_id=vcn_id, display_name="dmb-rt-dev", freeform_tags=TAGS,
route_rules=[oci.core.models.RouteRule(destination="0.0.0.0/0", destination_type="CIDR_BLOCK",
network_entity_id=igw.id)])).data
rt = oci.wait_until(vn, vn.get_route_table(rt.id), "lifecycle_state", "AVAILABLE").data
record("Route Table", "dmb-rt-dev (internet via IGW)", rt.id, cfg["region"])
ingress = [oci.core.models.IngressSecurityRule(
source=SSH_ALLOWED_CIDR, protocol="6", description="SSH só do IP autorizado",
tcp_options=oci.core.models.TcpOptions(destination_port_range=oci.core.models.PortRange(min=22, max=22)))]
sl = next((x for x in vn.list_security_lists(cid, vcn_id=vcn_id, display_name="dmb-sl-dev").data
if x.lifecycle_state == "AVAILABLE"), None)
if sl is None:
sl = vn.create_security_list(oci.core.models.CreateSecurityListDetails(
compartment_id=cid, vcn_id=vcn_id, display_name="dmb-sl-dev", freeform_tags=TAGS,
ingress_security_rules=ingress,
egress_security_rules=[oci.core.models.EgressSecurityRule(destination="0.0.0.0/0", protocol="all")])).data
sl = oci.wait_until(vn, vn.get_security_list(sl.id), "lifecycle_state", "AVAILABLE").data
elif [r.source for r in sl.ingress_security_rules] != [SSH_ALLOWED_CIDR]:
vn.update_security_list(sl.id, oci.core.models.UpdateSecurityListDetails(
ingress_security_rules=ingress, egress_security_rules=sl.egress_security_rules))
record("Security List", f"dmb-sl-dev (SSH de {SSH_ALLOWED_CIDR})", sl.id, cfg["region"])
sub = next((x for x in vn.list_subnets(cid, vcn_id=vcn_id, display_name="dmb-subnet-dev").data
if x.lifecycle_state == "AVAILABLE"), None)
if sub is None:
sub = vn.create_subnet(oci.core.models.CreateSubnetDetails(
compartment_id=cid, vcn_id=vcn_id, display_name="dmb-subnet-dev", cidr_block=DEV_SUBNET_CIDR,
dns_label="dev", prohibit_public_ip_on_vnic=False, route_table_id=rt.id,
security_list_ids=[sl.id], freeform_tags=TAGS)).data
sub = oci.wait_until(vn, vn.get_subnet(sub.id), "lifecycle_state", "AVAILABLE").data
record("Subnet (desenvolvimento)", f"dmb-subnet-dev ({DEV_SUBNET_CIDR})", sub.id, cfg["region"])
return sub.id
def ensure_vm(cfg, cid):
"""Registra a VM de trabalho, os volumes anexados e o acesso SSH. Não cria instâncias."""
cc = oci.core.ComputeClient(cfg)
vm = next((i for i in cc.list_instances(cid, display_name=VM_NAME).data
if i.lifecycle_state not in ("TERMINATED", "TERMINATING")), None)
if vm is None:
print(f"aviso: VM {VM_NAME} não encontrada")
return None
vn = oci.core.VirtualNetworkClient(cfg)
bs = oci.core.BlockstorageClient(cfg)
vnic = vn.get_vnic(cc.list_vnic_attachments(cid, instance_id=vm.id).data[0].vnic_id).data
record("Compute Instance", f"{VM_NAME} ({vm.shape}, {vm.shape_config.ocpus:.0f} OCPU, "
f"{vm.shape_config.memory_in_gbs:.0f} GB, Oracle Linux 9, IP público {vnic.public_ip}, "
f"usuário {VM_USER}, chave {VM_SSH_KEY})", vm.id, cfg["region"])
for att in cc.list_boot_volume_attachments(vm.availability_domain, cid, instance_id=vm.id).data:
bv = bs.get_boot_volume(att.boot_volume_id).data
record("Boot Volume", f"{bv.display_name} ({bv.size_in_gbs} GB, raiz /)", bv.id, cfg["region"])
for att in cc.list_volume_attachments(cid, instance_id=vm.id).data:
if att.lifecycle_state == "ATTACHED":
v = bs.get_volume(att.volume_id).data
record("Block Volume", f"{v.display_name} ({v.size_in_gbs} GB, {att.attachment_type}, "
f"{att.device}, montado em /data)", v.id, cfg["region"])
return vnic.public_ip
def ensure_notebook(cfg, cid, project_id):
ds = oci.data_science.DataScienceClient(cfg)
nb = next((n for n in ds.list_notebook_sessions(cid, project_id=project_id).data
if n.display_name == NOTEBOOK_NAME and n.lifecycle_state not in ("DELETED", "DELETING")), None)
if nb is None:
shape, ocpus, mem = NOTEBOOK_SHAPE
nb = ds.create_notebook_session(oci.data_science.models.CreateNotebookSessionDetails(
compartment_id=cid, project_id=project_id, display_name=NOTEBOOK_NAME, freeform_tags=TAGS,
notebook_session_config_details=oci.data_science.models.NotebookSessionConfigDetails(
shape=shape, block_storage_size_in_gbs=200,
notebook_session_shape_config_details=oci.data_science.models.NotebookSessionShapeConfigDetails(
ocpus=ocpus, memory_in_gbs=mem)))).data
record("Data Science Notebook Session", f"{NOTEBOOK_NAME} ({NOTEBOOK_SHAPE[0]}, rede gerenciada, "
f"estado {nb.lifecycle_state})", nb.id, cfg["region"])
def write_artifacts(): def write_artifacts():
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
lines = [ lines = [
"# Artefatos OCI — benchmark de modelos de decisão", "# Artefatos OCI — benchmark de modelos de decisão",
"", "",
f"Atualizado em {now} por `infra/oci/provision.py`. Região dos recursos: `{REGION}`.", f"Atualizado em {now} por `infra/oci/provision.py`. Região principal: `{REGION}`; GPU também em `{GPU_REGION}`.",
f"Tags livres aplicadas: {', '.join(f'`{k}={v}`' for k, v in TAGS.items())} (`centro_custo` pendente, Q13).", f"Tags livres aplicadas: {', '.join(f'`{k}={v}`' for k, v in TAGS.items())} (`centro_custo` pendente, Q13).",
"", "",
"| Tipo | Nome | OCID | Região |", "| Tipo | Nome | OCID | Região |",
@@ -219,9 +353,24 @@ def main():
idn = oci.identity.IdentityClient(home) idn = oci.identity.IdentityClient(home)
cid = find_compartment(idn, home["tenancy"]) cid = find_compartment(idn, home["tenancy"])
ensure_policies(idn, cid, home["user"]) ensure_policies(idn, cid, home["user"])
ensure_ds_project(cfg, cid) ensure_workload_policy(idn, cid)
project_id = ensure_ds_project(cfg, cid)
ensure_buckets(cfg, cid) ensure_buckets(cfg, cid)
ensure_network(cfg, cid) vcn_id = ensure_network(cfg, cid)
ensure_dev_network(oci.core.VirtualNetworkClient(cfg), cid, vcn_id)
ensure_vm(cfg, cid)
ensure_notebook(cfg, cid, project_id)
gru = load_config(region=GPU_REGION)
ensure_ds_project(gru, cid)
ensure_buckets(gru, cid, suffix=GPU_SUFFIX, buckets=GPU_BUCKETS)
try:
ensure_network(gru, cid)
except oci.exceptions.ServiceError as error:
if error.code != "LimitExceeded":
raise
# Sem VCN disponível na região: jobs de GPU usam rede gerenciada + isolamento no processo.
print(f"aviso: VCN indisponível em {GPU_REGION} ({error.message[:80]})")
write_artifacts() write_artifacts()
for a in artifacts: for a in artifacts:
print(" | ".join(a)) print(" | ".join(a))

63
infra/oci/replicate.py Normal file
View File

@@ -0,0 +1,63 @@
"""Réplica server-side dos artefatos para a região de GPU (copy_object entre regiões).
Copia os pesos (models/) e o ambiente atual de cada candidato (envs/<c>/latest.json e o
pacote que ele referencia). Objetos já presentes com o mesmo tamanho são pulados.
Uso: PYTHONPATH=infra/oci .venv/bin/python infra/oci/replicate.py [--wait]
"""
import json
import sys
import time
import oci
import provision
from session import load_config
SRC_BUCKET = "dmb-artefatos"
DST_BUCKET = f"dmb-artefatos{provision.GPU_SUFFIX}"
def main():
src = oci.object_storage.ObjectStorageClient(load_config())
dst = oci.object_storage.ObjectStorageClient(load_config(region=provision.GPU_REGION))
ns = src.get_namespace().data
objects = oci.pagination.list_call_get_all_results(
src.list_objects, ns, SRC_BUCKET, fields="size").data.objects
sizes = {o.name: o.size for o in objects}
wanted = [n for n in sizes if n.startswith("models/")]
for cand in ("laya", "gliner", "semif", "rizzo_flow"):
latest = f"envs/{cand}/latest.json"
if latest in sizes:
obj = json.loads(src.get_object(ns, SRC_BUCKET, latest).data.content)["object"]
wanted += [latest, obj, obj.replace(".tar.gz", "-env-manifest.json")]
cuda = "runtimes/rizzo_flow/cuda-latest.json"
if cuda in sizes:
wanted += [cuda, json.loads(src.get_object(ns, SRC_BUCKET, cuda).data.content)["object"]]
existing = {o.name: o.size for o in oci.pagination.list_call_get_all_results(
dst.list_objects, ns, DST_BUCKET, fields="size").data.objects}
requests = []
for name in wanted:
if name not in sizes:
continue
if existing.get(name) == sizes[name] and not name.endswith("latest.json"):
continue
r = src.copy_object(ns, SRC_BUCKET, oci.object_storage.models.CopyObjectDetails(
source_object_name=name, destination_region=provision.GPU_REGION,
destination_namespace=ns, destination_bucket=DST_BUCKET, destination_object_name=name))
requests.append((name, r.headers["opc-work-request-id"]))
total = sum(sizes[n] for n, _ in requests)
print(f"{len(requests)} cópias iniciadas ({total / 1e9:.2f} GB) para {DST_BUCKET} em {provision.GPU_REGION}")
if "--wait" in sys.argv:
pending = dict(requests)
while pending:
for name, wr in list(pending.items()):
status = src.get_work_request(wr).data.status
if status in ("COMPLETED", "FAILED", "CANCELED"):
print(status, name, flush=True)
pending.pop(name)
time.sleep(20)
if __name__ == "__main__":
main()

14
infra/vm/bootstrap.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Preparação idempotente da VM de trabalho (Oracle Linux 9). Rodar na VM:
# bash infra/vm/bootstrap.sh
set -euo pipefail
sudo dnf install -y -q git rsync tmux jq python3.12 python3.12-pip python3.12-devel gcc gcc-c++ >/dev/null
[ -d "$HOME/.venv-oci" ] || python3.12 -m venv "$HOME/.venv-oci"
"$HOME/.venv-oci/bin/pip" install -q --upgrade pip
"$HOME/.venv-oci/bin/pip" install -q "oci==2.187.2" oci-cli "huggingface_hub==0.36.2" jsonschema fastapi uvicorn pyyaml pytest httpx
grep -q OCI_CLI_AUTH "$HOME/.bashrc" || cat >> "$HOME/.bashrc" <<'RC'
export OCI_CLI_AUTH=instance_principal
export DMB_OCI_AUTH=instance_principal
export PATH=$HOME/.venv-oci/bin:$PATH
RC
echo "bootstrap ok: $(python3.12 --version), oci $("$HOME/.venv-oci/bin/python" -c 'import oci;print(oci.__version__)')"

26
infra/vm/remote.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Máquina local = IDE + repositório; execução na VM dmb-vm.
# infra/vm/remote.sh sync # envia o repositório (sem .secrets, models, venvs)
# infra/vm/remote.sh run <comando...> # sincroniza e roda o comando na VM, dentro do repositório
# infra/vm/remote.sh ssh # abre um shell na VM
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
KEY="$ROOT/.secrets/ssh/ssh-key-oci.key"
IP="$(grep -oE 'IP público [0-9.]+' "$ROOT/.secrets/artefatos-OCI.md" | head -1 | awk '{print $3}')"
[ -n "$IP" ] || { echo "IP da VM não encontrado em .secrets/artefatos-OCI.md" >&2; exit 1; }
SSH=(ssh -i "$KEY" -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30 "opc@$IP")
REMOTE_DIR=/data/repo
sync() {
rsync -az --delete -e "ssh -i $KEY -o StrictHostKeyChecking=accept-new" \
--exclude '.git/' --exclude '.secrets/' --exclude 'models/' --exclude '.venv*/' \
--exclude '__pycache__/' --exclude 'reports/sanity/' --exclude 'runs/' \
"$ROOT/" "opc@$IP:$REMOTE_DIR/"
}
case "${1:-}" in
sync) sync ;;
run) shift; sync; "${SSH[@]}" "cd $REMOTE_DIR && bash -lc $(printf '%q' "$*")" ;;
ssh) "${SSH[@]}" ;;
*) sed -n '2,6p' "$0"; exit 1 ;;
esac

16
pyproject.toml Normal file
View File

@@ -0,0 +1,16 @@
[project]
name = "dmb"
version = "0.1.0"
description = "Benchmark de modelos de decisão PT-BR para roteamento em orquestrador multiagente"
requires-python = ">=3.11"
dependencies = ["jsonschema>=4.23", "fastapi>=0.115", "uvicorn>=0.34", "pyyaml>=6"]
[project.optional-dependencies]
test = ["pytest>=8", "httpx>=0.27"]
[tool.setuptools.packages.find]
include = ["dmb*", "adapters*", "harness*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

View File

@@ -0,0 +1,219 @@
{
"candidate": {
"name": "gliner",
"model_id": "gliner@a35a0cd3b7a0",
"revision": "a35a0cd3b7a0f00f2effc576f454cd48fa98aa5f",
"adapter_version": "0.1.0",
"device": "cpu",
"context_limit": 4096,
"probabilities_source": "native"
},
"device": "cpu",
"host": "host-local",
"load_seconds": 15.93,
"conformance": [
{
"name": "valid_choice_noul",
"expected": "ok",
"got": "ok"
},
{
"name": "valid_score",
"expected": "ok",
"got": "ok"
},
{
"name": "valid_state_object",
"expected": "ok",
"got": "ok"
},
{
"name": "invalid_17_options",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_single_option",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_noul_missing_false",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_unknown_type",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_empty_state",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_no_questions",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_extra_field",
"expected": "invalid_request",
"got": "invalid_request"
}
],
"sanity": [
{
"name": "choice:pagamento",
"gold": "pagamento",
"pred": "pagamento",
"latency_ms": 303.8,
"answer": {
"type": "choice",
"choice": "pagamento",
"confidence": 0.6344146988487307,
"probabilities": {
"pagamento": 0.6344146988487307,
"cancelamento": 0.016834578235374244,
"suporte_tecnico": 0.022163211344910134,
"sem_correspondencia": 0.326587511570985
}
},
"pred_permuted": "pagamento",
"permutation_invariant": true,
"correct": true
},
{
"name": "choice:cancelamento",
"gold": "cancelamento",
"pred": "cancelamento",
"latency_ms": 297.9,
"answer": {
"type": "choice",
"choice": "cancelamento",
"confidence": 0.8718586499105494,
"probabilities": {
"pagamento": 0.010419030596174071,
"cancelamento": 0.8718586499105494,
"suporte_tecnico": 0.012619386333082717,
"sem_correspondencia": 0.10510293316019376
}
},
"pred_permuted": "cancelamento",
"permutation_invariant": true,
"correct": true
},
{
"name": "choice:suporte_tecnico",
"gold": "suporte_tecnico",
"pred": "sem_correspondencia",
"latency_ms": 218.5,
"answer": {
"type": "choice",
"choice": "sem_correspondencia",
"confidence": 0.6342991318760811,
"probabilities": {
"pagamento": 0.020201073094038888,
"cancelamento": 0.04278706948440038,
"suporte_tecnico": 0.30271272554547957,
"sem_correspondencia": 0.6342991318760811
}
},
"pred_permuted": "sem_correspondencia",
"permutation_invariant": true,
"correct": false
},
{
"name": "choice:sem_correspondencia",
"gold": "sem_correspondencia",
"pred": "sem_correspondencia",
"latency_ms": 226.3,
"answer": {
"type": "choice",
"choice": "sem_correspondencia",
"confidence": 0.860428139738716,
"probabilities": {
"pagamento": 0.028759410242109536,
"cancelamento": 0.03164806754541749,
"suporte_tecnico": 0.07916438247375696,
"sem_correspondencia": 0.860428139738716
}
},
"pred_permuted": "sem_correspondencia",
"permutation_invariant": true,
"correct": true
},
{
"name": "noul:clara",
"gold": "true",
"pred": "false",
"latency_ms": 184.3,
"answer": {
"type": "noul",
"noul": 0.2368283016907438
},
"correct": false
},
{
"name": "noul:vaga",
"gold": "false",
"pred": "false",
"latency_ms": 227.3,
"answer": {
"type": "noul",
"noul": 0.20341360543566903
},
"correct": true
},
{
"name": "score:alta",
"gold": "2",
"pred": "0",
"latency_ms": 189.4,
"answer": {
"type": "score",
"score": 0.9812671227395942,
"confidence": 0.36140790832709446,
"probabilities": {
"0": 0.36140790832709446,
"1": 0.29591706060621686,
"2": 0.3426750310666887
},
"legend": {
"0": "Baixa",
"1": "Média",
"2": "Alta"
}
},
"correct": false
},
{
"name": "score:baixa",
"gold": "0",
"pred": "0",
"latency_ms": 158.1,
"answer": {
"type": "score",
"score": 0.4816726286216157,
"confidence": 0.6750846907546932,
"probabilities": {
"0": 0.6750846907546932,
"1": 0.16815798986899794,
"2": 0.15675731937630888
},
"legend": {
"0": "Baixa",
"1": "Média",
"2": "Alta"
}
},
"correct": true
}
],
"summary": {
"contract_violations": [],
"sanity_correct": "5/8",
"permutation_invariant": "4/4"
}
}

View File

@@ -0,0 +1,219 @@
{
"candidate": {
"name": "laya",
"model_id": "laya@1720e3e3357c",
"revision": "1720e3e3357cfe1e281542e223f8273b0890ca34",
"adapter_version": "0.1.0",
"device": "cpu",
"context_limit": 1024,
"probabilities_source": "native"
},
"device": "cpu",
"host": "host-local",
"load_seconds": 8.4,
"conformance": [
{
"name": "valid_choice_noul",
"expected": "ok",
"got": "ok"
},
{
"name": "valid_score",
"expected": "ok",
"got": "ok"
},
{
"name": "valid_state_object",
"expected": "ok",
"got": "ok"
},
{
"name": "invalid_17_options",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_single_option",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_noul_missing_false",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_unknown_type",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_empty_state",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_no_questions",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_extra_field",
"expected": "invalid_request",
"got": "invalid_request"
}
],
"sanity": [
{
"name": "choice:pagamento",
"gold": "pagamento",
"pred": "pagamento",
"latency_ms": 114.4,
"answer": {
"type": "choice",
"choice": "pagamento",
"confidence": 0.9977,
"probabilities": {
"pagamento": 0.9997979802020598,
"cancelamento": 1.0000980096049412e-06,
"suporte_tecnico": 1.0000980096049412e-06,
"sem_correspondencia": 0.00020001960192098824
}
},
"pred_permuted": "pagamento",
"permutation_invariant": true,
"correct": true
},
{
"name": "choice:cancelamento",
"gold": "cancelamento",
"pred": "cancelamento",
"latency_ms": 137.1,
"answer": {
"type": "choice",
"choice": "cancelamento",
"confidence": 1.0,
"probabilities": {
"pagamento": 9.99997000009e-07,
"cancelamento": 0.999997000009,
"suporte_tecnico": 9.99997000009e-07,
"sem_correspondencia": 9.99997000009e-07
}
},
"pred_permuted": "cancelamento",
"permutation_invariant": true,
"correct": true
},
{
"name": "choice:suporte_tecnico",
"gold": "suporte_tecnico",
"pred": "suporte_tecnico",
"latency_ms": 154.9,
"answer": {
"type": "choice",
"choice": "suporte_tecnico",
"confidence": 0.2935,
"probabilities": {
"pagamento": 0.0496049604960496,
"cancelamento": 0.047204720472047206,
"suporte_tecnico": 0.5706570657065706,
"sem_correspondencia": 0.33253325332533257
}
},
"pred_permuted": "suporte_tecnico",
"permutation_invariant": true,
"correct": true
},
{
"name": "choice:sem_correspondencia",
"gold": "sem_correspondencia",
"pred": "sem_correspondencia",
"latency_ms": 115.2,
"answer": {
"type": "choice",
"choice": "sem_correspondencia",
"confidence": 0.7909,
"probabilities": {
"pagamento": 0.0034996500349965005,
"cancelamento": 0.0044995500449955,
"suporte_tecnico": 0.0642935706429357,
"sem_correspondencia": 0.9277072292770723
}
},
"pred_permuted": "sem_correspondencia",
"permutation_invariant": true,
"correct": true
},
{
"name": "noul:clara",
"gold": "true",
"pred": "true",
"latency_ms": 99.9,
"answer": {
"type": "noul",
"noul": 0.9982
},
"correct": true
},
{
"name": "noul:vaga",
"gold": "false",
"pred": "false",
"latency_ms": 88.8,
"answer": {
"type": "noul",
"noul": 0.1563
},
"correct": true
},
{
"name": "score:alta",
"gold": "2",
"pred": "2",
"latency_ms": 97.6,
"answer": {
"type": "score",
"score": 1.1202,
"confidence": 0.01,
"probabilities": {
"0": 0.2718,
"1": 0.3362,
"2": 0.392
},
"legend": {
"0": "Baixa",
"1": "Média",
"2": "Alta"
}
},
"correct": true
},
{
"name": "score:baixa",
"gold": "0",
"pred": "1",
"latency_ms": 91.0,
"answer": {
"type": "score",
"score": 1.0998999999999999,
"confidence": 0.1514,
"probabilities": {
"0": 0.1477,
"1": 0.6047,
"2": 0.2476
},
"legend": {
"0": "Baixa",
"1": "Média",
"2": "Alta"
}
},
"correct": false
}
],
"summary": {
"contract_violations": [],
"sanity_correct": "7/8",
"permutation_invariant": "4/4"
}
}

View File

@@ -0,0 +1,219 @@
{
"candidate": {
"name": "rizzo_flow",
"model_id": "rizzo_flow@55633c8cbd2b",
"revision": "55633c8cbd2b826bd3eefdeb05310450996649df",
"adapter_version": "0.1.0",
"device": "cpu",
"context_limit": 2048,
"probabilities_source": "native"
},
"device": "cpu",
"host": "host-local",
"load_seconds": 6.18,
"conformance": [
{
"name": "valid_choice_noul",
"expected": "ok",
"got": "ok"
},
{
"name": "valid_score",
"expected": "ok",
"got": "ok"
},
{
"name": "valid_state_object",
"expected": "ok",
"got": "ok"
},
{
"name": "invalid_17_options",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_single_option",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_noul_missing_false",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_unknown_type",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_empty_state",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_no_questions",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_extra_field",
"expected": "invalid_request",
"got": "invalid_request"
}
],
"sanity": [
{
"name": "choice:pagamento",
"gold": "pagamento",
"pred": "pagamento",
"latency_ms": 7483.9,
"answer": {
"type": "choice",
"choice": "pagamento",
"confidence": 0.9939581449644029,
"probabilities": {
"pagamento": 0.9954686087233023,
"cancelamento": 0.0014294878384867886,
"suporte_tecnico": 0.0006663578744809516,
"sem_correspondencia": 0.0024355455637299515
}
},
"pred_permuted": "pagamento",
"permutation_invariant": true,
"correct": true
},
{
"name": "choice:cancelamento",
"gold": "cancelamento",
"pred": "cancelamento",
"latency_ms": 7733.7,
"answer": {
"type": "choice",
"choice": "cancelamento",
"confidence": 0.9959894668208951,
"probabilities": {
"pagamento": 0.0006611097629735292,
"cancelamento": 0.9969921001156713,
"suporte_tecnico": 0.0005694308917635679,
"sem_correspondencia": 0.0017773592295916517
}
},
"pred_permuted": "cancelamento",
"permutation_invariant": true,
"correct": true
},
{
"name": "choice:suporte_tecnico",
"gold": "suporte_tecnico",
"pred": "suporte_tecnico",
"latency_ms": 7883.9,
"answer": {
"type": "choice",
"choice": "suporte_tecnico",
"confidence": 0.9550325369120006,
"probabilities": {
"pagamento": 0.0009793333603067293,
"cancelamento": 0.002014121845135763,
"suporte_tecnico": 0.9662744026840004,
"sem_correspondencia": 0.030732142110557085
}
},
"pred_permuted": "suporte_tecnico",
"permutation_invariant": true,
"correct": true
},
{
"name": "choice:sem_correspondencia",
"gold": "sem_correspondencia",
"pred": "sem_correspondencia",
"latency_ms": 7948.3,
"answer": {
"type": "choice",
"choice": "sem_correspondencia",
"confidence": 0.9885549303273677,
"probabilities": {
"pagamento": 0.0036670988032871665,
"cancelamento": 0.0022098453219148453,
"suporte_tecnico": 0.002706858129272241,
"sem_correspondencia": 0.9914161977455258
}
},
"pred_permuted": "sem_correspondencia",
"permutation_invariant": true,
"correct": true
},
{
"name": "noul:clara",
"gold": "true",
"pred": "true",
"latency_ms": 6893.7,
"answer": {
"type": "noul",
"noul": 0.9479776743420092
},
"correct": true
},
{
"name": "noul:vaga",
"gold": "false",
"pred": "true",
"latency_ms": 6730.7,
"answer": {
"type": "noul",
"noul": 0.5076455176696537
},
"correct": false
},
{
"name": "score:alta",
"gold": "2",
"pred": "2",
"latency_ms": 6445.7,
"answer": {
"type": "score",
"score": 1.9453121647986211,
"confidence": 0.9510302861376827,
"probabilities": {
"0": 0.02204135929316713,
"1": 0.01060511661504444,
"2": 0.9673535240917883
},
"legend": {
"0": "Baixa",
"1": "Média",
"2": "Alta"
}
},
"correct": true
},
{
"name": "score:baixa",
"gold": "0",
"pred": "0",
"latency_ms": 6312.4,
"answer": {
"type": "score",
"score": 0.2695747926209778,
"confidence": 0.6046525975421342,
"probabilities": {
"0": 0.7364350650280895,
"1": 0.25755507732284305,
"2": 0.006009857649067364
},
"legend": {
"0": "Baixa",
"1": "Média",
"2": "Alta"
}
},
"correct": true
}
],
"summary": {
"contract_violations": [],
"sanity_correct": "7/8",
"permutation_invariant": "4/4"
}
}

View File

@@ -0,0 +1,219 @@
{
"candidate": {
"name": "semif",
"model_id": "semif@851bf6e806ef",
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
"adapter_version": "0.1.0",
"device": "cpu",
"context_limit": 8192,
"probabilities_source": "native"
},
"device": "cpu",
"host": "host-local",
"load_seconds": 6.55,
"conformance": [
{
"name": "valid_choice_noul",
"expected": "ok",
"got": "ok"
},
{
"name": "valid_score",
"expected": "ok",
"got": "ok"
},
{
"name": "valid_state_object",
"expected": "ok",
"got": "ok"
},
{
"name": "invalid_17_options",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_single_option",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_noul_missing_false",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_unknown_type",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_empty_state",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_no_questions",
"expected": "invalid_request",
"got": "invalid_request"
},
{
"name": "invalid_extra_field",
"expected": "invalid_request",
"got": "invalid_request"
}
],
"sanity": [
{
"name": "choice:pagamento",
"gold": "pagamento",
"pred": "pagamento",
"latency_ms": 15726.0,
"answer": {
"type": "choice",
"choice": "pagamento",
"confidence": 0.9999576137177726,
"probabilities": {
"pagamento": 0.9999576137177726,
"cancelamento": 2.753528218203348e-05,
"suporte_tecnico": 6.962009617274983e-06,
"sem_correspondencia": 7.888990428051513e-06
}
},
"pred_permuted": "pagamento",
"permutation_invariant": true,
"correct": true
},
{
"name": "choice:cancelamento",
"gold": "cancelamento",
"pred": "cancelamento",
"latency_ms": 16084.8,
"answer": {
"type": "choice",
"choice": "cancelamento",
"confidence": 0.9982468109808258,
"probabilities": {
"pagamento": 0.001700633028283725,
"cancelamento": 0.9982468109808258,
"suporte_tecnico": 3.114818042829917e-05,
"sem_correspondencia": 2.1407810462216812e-05
}
},
"pred_permuted": "cancelamento",
"permutation_invariant": true,
"correct": true
},
{
"name": "choice:suporte_tecnico",
"gold": "suporte_tecnico",
"pred": "suporte_tecnico",
"latency_ms": 15810.0,
"answer": {
"type": "choice",
"choice": "suporte_tecnico",
"confidence": 0.9985997601915109,
"probabilities": {
"pagamento": 0.0013249226202503434,
"cancelamento": 3.5307991870190554e-05,
"suporte_tecnico": 0.9985997601915109,
"sem_correspondencia": 4.0009196368602506e-05
}
},
"pred_permuted": "suporte_tecnico",
"permutation_invariant": true,
"correct": true
},
{
"name": "choice:sem_correspondencia",
"gold": "sem_correspondencia",
"pred": "sem_correspondencia",
"latency_ms": 15911.3,
"answer": {
"type": "choice",
"choice": "sem_correspondencia",
"confidence": 0.9868525351071245,
"probabilities": {
"pagamento": 0.0096747618254165,
"cancelamento": 0.0027718656714616763,
"suporte_tecnico": 0.0007008373959974223,
"sem_correspondencia": 0.9868525351071245
}
},
"pred_permuted": "sem_correspondencia",
"permutation_invariant": true,
"correct": true
},
{
"name": "noul:clara",
"gold": "true",
"pred": "true",
"latency_ms": 13647.4,
"answer": {
"type": "noul",
"noul": 0.9465966702001758
},
"correct": true
},
{
"name": "noul:vaga",
"gold": "false",
"pred": "false",
"latency_ms": 13233.5,
"answer": {
"type": "noul",
"noul": 0.014063627043245475
},
"correct": true
},
{
"name": "score:alta",
"gold": "2",
"pred": "2",
"latency_ms": 13650.8,
"answer": {
"type": "score",
"score": 1.992728895910905,
"confidence": 0.9963221948103022,
"probabilities": {
"0": 0.003593298899397119,
"1": 8.450629030069868e-05,
"2": 0.9963221948103022
},
"legend": {
"0": "Baixa",
"1": "Média",
"2": "Alta"
}
},
"correct": true
},
{
"name": "score:baixa",
"gold": "0",
"pred": "0",
"latency_ms": 13731.5,
"answer": {
"type": "score",
"score": 0.6879537693865432,
"confidence": 0.4373487435378189,
"probabilities": {
"0": 0.4373487435378189,
"1": 0.4373487435378189,
"2": 0.12530251292436217
},
"legend": {
"0": "Baixa",
"1": "Média",
"2": "Alta"
}
},
"correct": true
}
],
"summary": {
"contract_violations": [],
"sanity_correct": "8/8",
"permutation_invariant": "4/4"
}
}

71
tests/test_canonical.py Normal file
View File

@@ -0,0 +1,71 @@
from dmb.canonical import fit_state, serialize_state, to_jev_request
STATE = {
"active_agent": "agente_a",
"suspended_sessions": ["agente_b"],
"recent_turns": [
{"role": "user", "text": "primeiro turno"},
{"role": "agent", "agent_id": "agente_a", "text": "resposta do agente"},
{"role": "user", "text": "segundo turno"},
],
}
EXAMPLE = {
"id": "conv-1-t3-D2", "group_id": "conv-1", "task": "D2", "state": STATE,
"utterance": "pode ser o segundo",
"question": "Como o orquestrador deve tratar este turno?", "question_type": "choice",
"options": {"continuar_agente_ativo": "Continua a tarefa em curso",
"retomar_sessao:agente_b": "Volta ao assunto do agente_b",
"iniciar_novo_agente": "Intenção nova",
"desambiguar": "Não dá para decidir"},
"gold_label": "continuar_agente_ativo",
}
def words(text):
return len(text.split())
def test_serialization_is_deterministic_and_ends_with_utterance():
text = serialize_state(STATE, "pode ser o segundo", 2)
assert text == serialize_state(STATE, "pode ser o segundo", 2)
assert text.endswith("Mensagem: pode ser o segundo")
assert "primeiro turno" not in text and "segundo turno" in text
assert "Agente ativo: agente_a" in text
def test_zero_turns_has_no_history():
assert "Turnos anteriores" not in serialize_state(STATE, "x", 0)
def test_fit_drops_oldest_turns_first():
full = words(serialize_state(STATE, "pode ser o segundo", 3))
text, info = fit_state(STATE, "pode ser o segundo", 3, full - 1, words)
assert info["turns_used"] < 3 and info["truncated"]
assert text.endswith("Mensagem: pode ser o segundo")
def test_fit_cuts_utterance_tail_only_as_last_resort():
long = "palavra " * 200
text, info = fit_state(STATE, long, 2, 30, words)
assert info["turns_used"] == 0 and info["utterance_chars_kept"] < len(long)
assert words(text) <= 30
def test_to_jev_request_keeps_option_order():
request, info = to_jev_request(EXAMPLE, "m", n_turns=2)
q = request["questions"]["D2"]
assert list(q["criteria"]) == list(EXAMPLE["options"])
assert q["type"] == "choice" and info["serialization"] == "state-text-v1"
def test_multi_question_example():
example = dict(EXAMPLE)
for key in ("question", "question_type", "options", "gold_label"):
example.pop(key)
clara = {"true": "sim", "false": "não"}
example["questions"] = [
{"name": "pede_x", "question": "Pede X?", "question_type": "noul", "options": clara, "gold_label": "true"},
{"name": "pede_y", "question": "Pede Y?", "question_type": "noul", "options": clara, "gold_label": "false"},
]
request, _ = to_jev_request(example, "m", n_turns=0)
assert list(request["questions"]) == ["pede_x", "pede_y"]

53
tests/test_contract.py Normal file
View File

@@ -0,0 +1,53 @@
import json
from pathlib import Path
import pytest
from dmb import jev
FIXTURES = Path(__file__).resolve().parents[1] / "contracts" / "jev" / "fixtures"
CASES = json.loads((FIXTURES / "conformance.json").read_text())["cases"]
@pytest.mark.parametrize("case", CASES, ids=[c["name"] for c in CASES])
def test_request_fixtures(case):
if case["expect"] == "ok":
jev.validate_request(case["request"])
else:
with pytest.raises(jev.InvalidRequest):
jev.validate_request(case["request"])
def test_normalize_keeps_declared_order_and_floor():
p = jev.normalize({"b": 0.0, "a": 1.0}, ["a", "b"])
assert list(p) == ["a", "b"]
assert p["b"] > 0 and abs(sum(p.values()) - 1) < 1e-12
def test_normalize_rejects_unknown_option():
with pytest.raises(jev.UnknownOption):
jev.normalize({"a": 0.5, "z": 0.5}, ["a", "b"])
def test_answers_match_response_schema():
choice_q = {"type": "choice", "instructions": "?", "criteria": {"a": "A", "b": "B"}}
score_q = {"type": "score", "instructions": "?", "criteria": ["baixa", "alta"]}
response = {
"model": "m",
"answers": {
"c": jev.choice_answer({"a": 0.2, "b": 0.8}, choice_q),
"n": jev.noul_answer(1.0),
"s": jev.score_answer({"0": 0.25, "1": 0.75}, score_q),
},
"usage": {"input_tokens": 3, "output_tokens": 0},
}
assert jev.response_errors(response) == []
assert response["answers"]["c"]["choice"] == "b"
assert response["answers"]["s"]["score"] == pytest.approx(0.75)
assert response["answers"]["n"]["noul"] < 1
def test_output_tokens_must_be_zero():
response = {"model": "m", "answers": {"n": jev.noul_answer(0.5)},
"usage": {"input_tokens": 1, "output_tokens": 5}}
assert jev.response_errors(response)

35
tests/test_offline.py Normal file
View File

@@ -0,0 +1,35 @@
import os
import socket
import pytest
from dmb.offline import NetworkAccessBlocked, enforce_offline, self_test
def test_external_connection_is_blocked():
enforce_offline()
assert os.environ["HF_HUB_OFFLINE"] == "1"
s = socket.socket()
try:
with pytest.raises(NetworkAccessBlocked):
s.connect(("93.184.216.34", 443))
finally:
s.close()
def test_loopback_is_allowed():
enforce_offline()
server = socket.socket()
server.bind(("127.0.0.1", 0))
server.listen(1)
client = socket.socket()
try:
client.connect(server.getsockname())
finally:
client.close()
server.close()
def test_self_test_reports_guard():
enforce_offline()
assert self_test()["process_guard"].startswith("ok")

85
tests/test_server.py Normal file
View File

@@ -0,0 +1,85 @@
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from dmb.adapter import Adapter
from dmb.jev import answer_from_distribution, option_descriptions, response_errors
from dmb.server import create_app
FIXTURES = Path(__file__).resolve().parents[1] / "contracts" / "jev" / "fixtures"
CASES = json.loads((FIXTURES / "conformance.json").read_text())["cases"]
class FakeAdapter(Adapter):
"""Escolhe a opção cuja descrição compartilha mais palavras com o estado."""
name = "fake"
def load(self):
pass
def native_context_limit(self):
return 10_000
def count_tokens(self, text):
return len(text.split())
def decide(self, state, questions):
words = set(str(state).lower().split())
answers = {}
for name, q in questions.items():
scores = {k: 1 + len(words & set(d.lower().split())) for k, d in option_descriptions(q).items()}
total = sum(scores.values())
answers[name] = answer_from_distribution(q, {k: v / total for k, v in scores.items()})
return answers, self.count_tokens(str(state))
class BrokenAdapter(FakeAdapter):
def decide(self, state, questions):
answers, n = super().decide(state, questions)
for a in answers.values():
if "probabilities" in a:
a["probabilities"] = dict(reversed(list(a["probabilities"].items())))
return answers, n
@pytest.fixture
def client():
with TestClient(create_app(FakeAdapter("unused", "0" * 40))) as c:
yield c
@pytest.mark.parametrize("case", CASES, ids=[c["name"] for c in CASES])
def test_conformance(client, case):
r = client.post("/v1/systemone", json=case["request"])
if case["expect"] == "ok":
assert r.status_code == 200, r.text
body = r.json()
assert response_errors(body) == []
assert list(body["answers"]) == list(case["request"]["questions"])
for name, q in case["request"]["questions"].items():
if q["type"] == "choice":
assert list(body["answers"][name]["probabilities"]) == list(q["criteria"])
assert body["usage"]["output_tokens"] == 0
assert {"latency_ms", "model_id", "run_id", "adapter_version"} <= body["metadata"].keys()
else:
assert r.status_code == 422
assert r.json()["detail"]["error_type"] == "invalid_request"
def test_malformed_json(client):
r = client.post("/v1/systemone", content=b"{nope", headers={"content-type": "application/json"})
assert r.status_code == 422
def test_reordered_options_are_rejected():
with TestClient(create_app(BrokenAdapter("unused", "0" * 40))) as c:
r = c.post("/v1/systemone", json=CASES[0]["request"])
assert r.status_code == 502
assert r.json()["detail"]["error_type"] == "unknown_option"
def test_health(client):
body = client.get("/health").json()
assert body["status"] == "ready" and body["name"] == "fake"