mirror of
https://github.com/mcloh/decision-models-benchmark.git
synced 2026-11-05 22:04:12 +00:00
setup models
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
2
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.venv*
|
||||
.pytest_cache/
|
||||
|
||||
# Pesos e caches de modelos (ficam no bucket `artefatos`)
|
||||
|
||||
@@ -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/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/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 |
|
||||
|
||||
## Estrutura do repositório
|
||||
|
||||
```
|
||||
config/ Configuração pré-registrada (benchmark.yaml)
|
||||
infra/oci/ Compartment, tags, orçamento, IAM, VCN, buckets, Data Science (tarefas 1–10, 17, 78)
|
||||
env/ Imagem de contêiner e manifesto de versões (11–12)
|
||||
dmb/ Código comum: contrato JEV, formato canônico, servidor /v1/systemone, isolamento de rede
|
||||
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)
|
||||
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)
|
||||
|
||||
14
adapters/__init__.py
Normal file
14
adapters/__init__.py
Normal 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)
|
||||
55
adapters/gliner/adapter.py
Normal file
55
adapters/gliner/adapter.py
Normal 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
51
adapters/laya/adapter.py
Normal 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))
|
||||
59
adapters/rizzo_flow/adapter.py
Normal file
59
adapters/rizzo_flow/adapter.py
Normal 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
45
adapters/semif/adapter.py
Normal 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
|
||||
@@ -5,7 +5,7 @@
|
||||
contract:
|
||||
version: jev-compat-v1 # Q1
|
||||
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
|
||||
require_zero_output_tokens: true
|
||||
|
||||
|
||||
203
contracts/jev/fixtures/conformance.json
Normal file
203
contracts/jev/fixtures/conformance.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
125
contracts/jev/fixtures/sanity.json
Normal file
125
contracts/jev/fixtures/sanity.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
"criteria": {
|
||||
"type": "object",
|
||||
"minProperties": 2,
|
||||
"maxProperties": 20,
|
||||
"maxProperties": 16,
|
||||
"additionalProperties": {"type": "string", "minLength": 1}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@
|
||||
"criteria": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 20,
|
||||
"maxItems": 16,
|
||||
"items": {"type": "string", "minLength": 1}
|
||||
}
|
||||
}
|
||||
|
||||
155
data/taxonomy/intents-v1.yaml
Normal file
155
data/taxonomy/intents-v1.yaml
Normal 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
1
datagen/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Geração, anotação e montagem do conjunto sintético (tarefas 20–35)."""
|
||||
188
datagen/annotate.py
Normal file
188
datagen/annotate.py
Normal 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
173
datagen/build.py
Normal 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
147
datagen/generate.py
Normal 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
81
datagen/prepare.py
Normal 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
40
datagen/taxonomy.py
Normal 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
1
dmb/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Benchmark de modelos de decisão PT-BR: código comum."""
|
||||
50
dmb/adapter.py
Normal file
50
dmb/adapter.py
Normal 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
76
dmb/canonical.py
Normal 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
128
dmb/jev.py
Normal 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
91
dmb/llm.py
Normal 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
71
dmb/offline.py
Normal 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
93
dmb/server.py
Normal 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
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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
|
||||
|
||||
- **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
|
||||
|
||||
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
|
||||
|
||||
- **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`.
|
||||
|
||||
### 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
|
||||
|
||||
**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).
|
||||
- **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.
|
||||
|
||||
@@ -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.
|
||||
- ↳ 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`.
|
||||
- [ ] **10.** Validar que os jobs acessam os buckets sem internet pública.
|
||||
- ↳ Adiado para quando o primeiro job existir (depois da tarefa 11).
|
||||
- [x] **10.** Validar que os jobs acessam os buckets sem internet pública.
|
||||
- ↳ 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
|
||||
|
||||
- [ ] **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.
|
||||
- [ ] **12.** Fixar num manifesto as versões do sistema, dos drivers CUDA, do Python, das bibliotecas e da imagem de contêiner.
|
||||
- [ ] **13.** Baixar os pesos, tokenizadores, revisões e licenças aprovados de cada candidato. *(Q4)*
|
||||
- ↳ **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.
|
||||
- ↳ `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.
|
||||
- ↳ 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.
|
||||
- [ ] **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.
|
||||
- [ ] **17.** Desabilitar a saída pública dos jobs de benchmark.
|
||||
- [ ] **18.** Carregar cada candidato só a partir dos artefatos locais.
|
||||
- [x] **14.** Calcular o SHA-256 de cada artefato baixado.
|
||||
- [x] **15.** Copiar os pesos e dependências aprovados para o bucket `artefatos`.
|
||||
- [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.
|
||||
- [x] **17.** Desabilitar a saída pública dos jobs de benchmark.
|
||||
- ↳ 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.
|
||||
- [ ] **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.
|
||||
- ↳ 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
|
||||
|
||||
- [ ] **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).
|
||||
- [ ] **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.
|
||||
- [ ] **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.
|
||||
- [ ] **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
|
||||
|
||||
- [ ] **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).
|
||||
- [ ] **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.
|
||||
@@ -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.
|
||||
- ↳ 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.
|
||||
- [ ] **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).
|
||||
- [ ] **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.
|
||||
- [ ] **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.
|
||||
@@ -99,27 +110,29 @@ As fases F0–F1 (infra) e F2–F3 (dados) podem andar em paralelo.
|
||||
|
||||
## 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`.
|
||||
- [ ] **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.
|
||||
- [ ] **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.
|
||||
- [ ] **40.** Definir o comportamento padronizado para entradas inválidas, limite de contexto, timeout, opção desconhecida e indisponibilidade do modelo.
|
||||
- [x] **38.** Definir o mapeamento obrigatório entre o contrato JEV e os tipos internos `choice`, `noul` e `score`.
|
||||
- [x] **39.** Definir a regra de conversão de probabilidades, confiança, níveis ordinais, opções descritas e campos de uso.
|
||||
- [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.
|
||||
- [ ] **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.
|
||||
- [ ] **43.** Criar um adaptador JEV local para o SemIf em modo `direct`, com a revisão do Qwen fixada.
|
||||
- [ ] **44.** Criar um adaptador JEV local para o GLiNER Decide usando `classify_text`.
|
||||
- [ ] **45.** Criar um adaptador JEV local para o Rizzo Flow usando sua interface de decisões. *(Q4)*
|
||||
- [ ] **46.** Expor cada adaptador no mesmo caminho local `POST /v1/systemone`, sem dependência de rede externa.
|
||||
- [ ] **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.
|
||||
- [ ] **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] **41.** Criar testes de conformidade com requisições e respostas JEV congeladas.
|
||||
- ↳ `contracts/jev/fixtures/conformance.json` + `tests/` (35 testes).
|
||||
- [x] **42.** Criar um adaptador JEV local para o Laya com o checkpoint multilíngue fixado.
|
||||
- [x] **43.** Criar um adaptador JEV local para o SemIf em modo `direct`, com a revisão do Qwen fixada.
|
||||
- [x] **44.** Criar um adaptador JEV local para o GLiNER Decide usando `classify_text`.
|
||||
- [x] **45.** Criar um adaptador JEV local para o Rizzo Flow usando sua interface de decisões. *(Q4)*
|
||||
- [x] **46.** Expor cada adaptador no mesmo caminho local `POST /v1/systemone`, sem dependência de rede externa.
|
||||
- [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.
|
||||
- [ ] **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.
|
||||
- [ ] **51.** Implementar testes de sanidade com decisões triviais, opções permutadas e os três tipos de pergunta.
|
||||
- [x] **49.** Validar que os adaptadores preservam a mesma ordem e a mesma descrição das opções.
|
||||
- [x] **50.** Validar que trocar de candidato não altera a requisição JEV nem o formato da resposta JEV.
|
||||
- [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).
|
||||
- ↳ `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
|
||||
|
||||
@@ -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)*
|
||||
- [ ] **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.
|
||||
- [ ] **58.** Ajustar apenas os parâmetros operacionais necessários para eliminar falhas de execução.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|---|---------|---------|------------------------|
|
||||
| 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** |
|
||||
| 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` |
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
60
docs/05-ambiente-de-execucao.md
Normal file
60
docs/05-ambiente-de-execucao.md
Normal 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 |
|
||||
49
docs/06-guia-de-anotacao.md
Normal file
49
docs/06-guia-de-anotacao.md
Normal 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
0
env/.gitkeep
vendored
64
env/Dockerfile
vendored
Normal file
64
env/Dockerfile
vendored
Normal 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
175
env/acquire.py
vendored
Normal 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
440
env/artifacts-manifest.json
vendored
Normal 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
7
env/requirements/common.txt
vendored
Normal 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
11
env/requirements/gliner.txt
vendored
Normal 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
7
env/requirements/laya.txt
vendored
Normal 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
2
env/requirements/rizzo_flow.txt
vendored
Normal 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
10
env/requirements/semif.txt
vendored
Normal 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
|
||||
33
harness/candidates.py
Normal file
33
harness/candidates.py
Normal 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
29
harness/image_manifest.py
Normal 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
193
harness/job.py
Normal 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
67
harness/objstore.py
Normal 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
99
harness/sanity.py
Normal 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
31
harness/serve.py
Normal 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
31
harness/verify_bucket.py
Normal 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
53
infra/ds/build_env.sh
Executable 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
51
infra/ds/build_llama_cuda.sh
Executable 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
37
infra/ds/gpu_probe.py
Normal 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
47
infra/ds/gpu_queue.py
Normal 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
187
infra/ds/jobs.py
Normal 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
11
infra/ds/probe.sh
Executable 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
38
infra/ds/results.py
Normal 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
6
infra/ds/run_job.sh
Executable 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
|
||||
@@ -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
|
||||
Registra os artefatos criados em .secrets/artefatos-OCI.md.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -16,7 +21,19 @@ TAGS = {"projeto": "benchmark-decisoes-ptbr", "ambiente": "benchmark"} # centro
|
||||
BUCKETS = ["entrada", "artefatos", "resultados", "logs-imutaveis"]
|
||||
VCN_CIDR = "10.20.0.0/16"
|
||||
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"
|
||||
# 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 = []
|
||||
|
||||
@@ -64,21 +81,22 @@ def ensure_ds_project(cfg, cid):
|
||||
proj = ds.create_project(oci.data_science.models.CreateProjectDetails(
|
||||
compartment_id=cid, display_name=name, freeform_tags=TAGS,
|
||||
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)
|
||||
ns = os_.get_namespace().data
|
||||
existing = {b.name for b in oci.pagination.list_call_get_all_results(os_.list_buckets, ns, cid).data}
|
||||
for short in BUCKETS:
|
||||
name = f"dmb-{short}"
|
||||
for short in buckets:
|
||||
name = f"dmb-{short}{suffix}"
|
||||
if name not in existing:
|
||||
os_.create_bucket(ns, oci.object_storage.models.CreateBucketDetails(
|
||||
name=name, compartment_id=cid, public_access_type="NoPublicAccess",
|
||||
storage_tier="Standard", versioning="Enabled", freeform_tags=TAGS))
|
||||
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):
|
||||
@@ -92,7 +110,7 @@ def ensure_network(cfg, cid):
|
||||
compartment_id=cid, display_name="dmb-vcn", cidr_blocks=[VCN_CIDR],
|
||||
dns_label="dmbvcn", freeform_tags=TAGS)).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)
|
||||
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,
|
||||
services=[oci.core.models.ServiceIdRequestDetails(service_id=svc.id)])).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
|
||||
if r.lifecycle_state == "AVAILABLE"), None)
|
||||
@@ -113,7 +131,7 @@ def ensure_network(cfg, cid):
|
||||
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
|
||||
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
|
||||
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"),
|
||||
])).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
|
||||
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,
|
||||
security_list_ids=[sl.id], freeform_tags=TAGS)).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.
|
||||
for nat in vn.list_nat_gateways(cid, vcn_id=vcn.id).data:
|
||||
if nat.lifecycle_state == "AVAILABLE":
|
||||
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 = [
|
||||
@@ -157,6 +176,9 @@ USER_FAMILIES = [
|
||||
"logging-family",
|
||||
"repos",
|
||||
"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 read repos in {comp} where all {{{job}}}",
|
||||
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",
|
||||
"Jobs de benchmark: leitura de entrada/artefatos e escrita de resultados/logs (tarefa 7)",
|
||||
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():
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
lines = [
|
||||
"# 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).",
|
||||
"",
|
||||
"| Tipo | Nome | OCID | Região |",
|
||||
@@ -219,9 +353,24 @@ def main():
|
||||
idn = oci.identity.IdentityClient(home)
|
||||
cid = find_compartment(idn, home["tenancy"])
|
||||
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_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()
|
||||
for a in artifacts:
|
||||
print(" | ".join(a))
|
||||
|
||||
63
infra/oci/replicate.py
Normal file
63
infra/oci/replicate.py
Normal 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
14
infra/vm/bootstrap.sh
Executable 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
26
infra/vm/remote.sh
Executable 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
16
pyproject.toml
Normal 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 = ["."]
|
||||
219
reports/sanity/gliner-cpu.json
Normal file
219
reports/sanity/gliner-cpu.json
Normal 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"
|
||||
}
|
||||
}
|
||||
219
reports/sanity/laya-cpu.json
Normal file
219
reports/sanity/laya-cpu.json
Normal 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"
|
||||
}
|
||||
}
|
||||
219
reports/sanity/rizzo_flow-cpu.json
Normal file
219
reports/sanity/rizzo_flow-cpu.json
Normal 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"
|
||||
}
|
||||
}
|
||||
219
reports/sanity/semif-cpu.json
Normal file
219
reports/sanity/semif-cpu.json
Normal 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
71
tests/test_canonical.py
Normal 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
53
tests/test_contract.py
Normal 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
35
tests/test_offline.py
Normal 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
85
tests/test_server.py
Normal 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"
|
||||
Reference in New Issue
Block a user