From 84ea9a5307d349ba5db20206ceb0a28013551a0b Mon Sep 17 00:00:00 2001 From: Marcos Lohmann Date: Wed, 7 Oct 2026 13:43:09 -0300 Subject: [PATCH] setup models --- .dockerignore | 14 + .gitignore | 2 +- README.md | 9 +- adapters/__init__.py | 14 + adapters/gliner/{.gitkeep => __init__.py} | 0 adapters/gliner/adapter.py | 55 +++ adapters/laya/{.gitkeep => __init__.py} | 0 adapters/laya/adapter.py | 51 ++ adapters/rizzo_flow/{.gitkeep => __init__.py} | 0 adapters/rizzo_flow/adapter.py | 59 +++ adapters/semif/{.gitkeep => __init__.py} | 0 adapters/semif/adapter.py | 45 ++ config/benchmark.yaml | 2 +- contracts/jev/fixtures/.gitkeep | 0 contracts/jev/fixtures/conformance.json | 203 ++++++++ contracts/jev/fixtures/sanity.json | 125 +++++ contracts/jev/schemas/request.schema.json | 4 +- data/taxonomy/intents-v1.yaml | 155 ++++++ datagen/__init__.py | 1 + datagen/annotate.py | 188 ++++++++ datagen/build.py | 173 +++++++ datagen/generate.py | 147 ++++++ datagen/prepare.py | 81 ++++ datagen/taxonomy.py | 40 ++ dmb/__init__.py | 1 + dmb/adapter.py | 50 ++ dmb/canonical.py | 76 +++ dmb/jev.py | 128 +++++ dmb/llm.py | 91 ++++ dmb/offline.py | 71 +++ dmb/server.py | 93 ++++ docs/02-desenho-experimental.md | 18 +- docs/03-plano-de-tarefas.md | 80 ++-- docs/04-questoes-em-aberto.md | 19 +- docs/05-ambiente-de-execucao.md | 60 +++ docs/06-guia-de-anotacao.md | 49 ++ env/.gitkeep | 0 env/Dockerfile | 64 +++ env/acquire.py | 175 +++++++ env/artifacts-manifest.json | 440 ++++++++++++++++++ env/requirements/common.txt | 7 + env/requirements/gliner.txt | 11 + env/requirements/laya.txt | 7 + env/requirements/rizzo_flow.txt | 2 + env/requirements/semif.txt | 10 + harness/.gitkeep | 0 analysis/.gitkeep => harness/__init__.py | 0 harness/candidates.py | 33 ++ harness/image_manifest.py | 29 ++ harness/job.py | 193 ++++++++ harness/objstore.py | 67 +++ harness/sanity.py | 99 ++++ harness/serve.py | 31 ++ harness/verify_bucket.py | 31 ++ infra/ds/build_env.sh | 53 +++ infra/ds/build_llama_cuda.sh | 51 ++ infra/ds/gpu_probe.py | 37 ++ infra/ds/gpu_queue.py | 47 ++ infra/ds/jobs.py | 187 ++++++++ infra/ds/probe.sh | 11 + infra/ds/results.py | 38 ++ infra/ds/run_job.sh | 6 + infra/oci/provision.py | 179 ++++++- infra/oci/replicate.py | 63 +++ infra/vm/bootstrap.sh | 14 + infra/vm/remote.sh | 26 ++ pyproject.toml | 16 + reports/sanity/gliner-cpu.json | 219 +++++++++ reports/sanity/laya-cpu.json | 219 +++++++++ reports/sanity/rizzo_flow-cpu.json | 219 +++++++++ reports/sanity/semif-cpu.json | 219 +++++++++ tests/test_canonical.py | 71 +++ tests/test_contract.py | 53 +++ tests/test_offline.py | 35 ++ tests/test_server.py | 85 ++++ 75 files changed, 5093 insertions(+), 58 deletions(-) create mode 100644 .dockerignore create mode 100644 adapters/__init__.py rename adapters/gliner/{.gitkeep => __init__.py} (100%) create mode 100644 adapters/gliner/adapter.py rename adapters/laya/{.gitkeep => __init__.py} (100%) create mode 100644 adapters/laya/adapter.py rename adapters/rizzo_flow/{.gitkeep => __init__.py} (100%) create mode 100644 adapters/rizzo_flow/adapter.py rename adapters/semif/{.gitkeep => __init__.py} (100%) create mode 100644 adapters/semif/adapter.py delete mode 100644 contracts/jev/fixtures/.gitkeep create mode 100644 contracts/jev/fixtures/conformance.json create mode 100644 contracts/jev/fixtures/sanity.json create mode 100644 data/taxonomy/intents-v1.yaml create mode 100644 datagen/__init__.py create mode 100644 datagen/annotate.py create mode 100644 datagen/build.py create mode 100644 datagen/generate.py create mode 100644 datagen/prepare.py create mode 100644 datagen/taxonomy.py create mode 100644 dmb/__init__.py create mode 100644 dmb/adapter.py create mode 100644 dmb/canonical.py create mode 100644 dmb/jev.py create mode 100644 dmb/llm.py create mode 100644 dmb/offline.py create mode 100644 dmb/server.py create mode 100644 docs/05-ambiente-de-execucao.md create mode 100644 docs/06-guia-de-anotacao.md delete mode 100644 env/.gitkeep create mode 100644 env/Dockerfile create mode 100644 env/acquire.py create mode 100644 env/artifacts-manifest.json create mode 100644 env/requirements/common.txt create mode 100644 env/requirements/gliner.txt create mode 100644 env/requirements/laya.txt create mode 100644 env/requirements/rizzo_flow.txt create mode 100644 env/requirements/semif.txt delete mode 100644 harness/.gitkeep rename analysis/.gitkeep => harness/__init__.py (100%) create mode 100644 harness/candidates.py create mode 100644 harness/image_manifest.py create mode 100644 harness/job.py create mode 100644 harness/objstore.py create mode 100644 harness/sanity.py create mode 100644 harness/serve.py create mode 100644 harness/verify_bucket.py create mode 100755 infra/ds/build_env.sh create mode 100755 infra/ds/build_llama_cuda.sh create mode 100644 infra/ds/gpu_probe.py create mode 100644 infra/ds/gpu_queue.py create mode 100644 infra/ds/jobs.py create mode 100755 infra/ds/probe.sh create mode 100644 infra/ds/results.py create mode 100755 infra/ds/run_job.sh create mode 100644 infra/oci/replicate.py create mode 100755 infra/vm/bootstrap.sh create mode 100755 infra/vm/remote.sh create mode 100644 pyproject.toml create mode 100644 reports/sanity/gliner-cpu.json create mode 100644 reports/sanity/laya-cpu.json create mode 100644 reports/sanity/rizzo_flow-cpu.json create mode 100644 reports/sanity/semif-cpu.json create mode 100644 tests/test_canonical.py create mode 100644 tests/test_contract.py create mode 100644 tests/test_offline.py create mode 100644 tests/test_server.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4a76d43 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore index d71d244..e78aaf4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Python __pycache__/ *.py[cod] -.venv/ +.venv* .pytest_cache/ # Pesos e caches de modelos (ficam no bucket `artefatos`) diff --git a/README.md b/README.md index 99ef668..8d5bb7b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..ed9aaf5 --- /dev/null +++ b/adapters/__init__.py @@ -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) diff --git a/adapters/gliner/.gitkeep b/adapters/gliner/__init__.py similarity index 100% rename from adapters/gliner/.gitkeep rename to adapters/gliner/__init__.py diff --git a/adapters/gliner/adapter.py b/adapters/gliner/adapter.py new file mode 100644 index 0000000..1c9ee0a --- /dev/null +++ b/adapters/gliner/adapter.py @@ -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 diff --git a/adapters/laya/.gitkeep b/adapters/laya/__init__.py similarity index 100% rename from adapters/laya/.gitkeep rename to adapters/laya/__init__.py diff --git a/adapters/laya/adapter.py b/adapters/laya/adapter.py new file mode 100644 index 0000000..fc0e01a --- /dev/null +++ b/adapters/laya/adapter.py @@ -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)) diff --git a/adapters/rizzo_flow/.gitkeep b/adapters/rizzo_flow/__init__.py similarity index 100% rename from adapters/rizzo_flow/.gitkeep rename to adapters/rizzo_flow/__init__.py diff --git a/adapters/rizzo_flow/adapter.py b/adapters/rizzo_flow/adapter.py new file mode 100644 index 0000000..e5eb395 --- /dev/null +++ b/adapters/rizzo_flow/adapter.py @@ -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)) diff --git a/adapters/semif/.gitkeep b/adapters/semif/__init__.py similarity index 100% rename from adapters/semif/.gitkeep rename to adapters/semif/__init__.py diff --git a/adapters/semif/adapter.py b/adapters/semif/adapter.py new file mode 100644 index 0000000..8744746 --- /dev/null +++ b/adapters/semif/adapter.py @@ -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 diff --git a/config/benchmark.yaml b/config/benchmark.yaml index eb3e287..fc34236 100644 --- a/config/benchmark.yaml +++ b/config/benchmark.yaml @@ -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 diff --git a/contracts/jev/fixtures/.gitkeep b/contracts/jev/fixtures/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/contracts/jev/fixtures/conformance.json b/contracts/jev/fixtures/conformance.json new file mode 100644 index 0000000..a61676f --- /dev/null +++ b/contracts/jev/fixtures/conformance.json @@ -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" + } + } + } + } + } + ] +} \ No newline at end of file diff --git a/contracts/jev/fixtures/sanity.json b/contracts/jev/fixtures/sanity.json new file mode 100644 index 0000000..7ad9665 --- /dev/null +++ b/contracts/jev/fixtures/sanity.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/contracts/jev/schemas/request.schema.json b/contracts/jev/schemas/request.schema.json index 6146f50..ccfaac1 100644 --- a/contracts/jev/schemas/request.schema.json +++ b/contracts/jev/schemas/request.schema.json @@ -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} } } diff --git a/data/taxonomy/intents-v1.yaml b/data/taxonomy/intents-v1.yaml new file mode 100644 index 0000000..dbcc6a7 --- /dev/null +++ b/data/taxonomy/intents-v1.yaml @@ -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 diff --git a/datagen/__init__.py b/datagen/__init__.py new file mode 100644 index 0000000..ebd5124 --- /dev/null +++ b/datagen/__init__.py @@ -0,0 +1 @@ +"""Geração, anotação e montagem do conjunto sintético (tarefas 20–35).""" diff --git a/datagen/annotate.py b/datagen/annotate.py new file mode 100644 index 0000000..e3a445a --- /dev/null +++ b/datagen/annotate.py @@ -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() diff --git a/datagen/build.py b/datagen/build.py new file mode 100644 index 0000000..1f70c6f --- /dev/null +++ b/datagen/build.py @@ -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() diff --git a/datagen/generate.py b/datagen/generate.py new file mode 100644 index 0000000..499e69f --- /dev/null +++ b/datagen/generate.py @@ -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 /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() diff --git a/datagen/prepare.py b/datagen/prepare.py new file mode 100644 index 0000000..7cda6f9 --- /dev/null +++ b/datagen/prepare.py @@ -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 = [ + ("", re.compile(r"\b\d{4}[ .-]?\d{4}[ .-]?\d{4}[ .-]?\d{4}\b")), + ("", re.compile(r"\b\d{2}\.?\d{3}\.?\d{3}/?\d{4}-?\d{2}\b")), + ("", re.compile(r"\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b")), + ("", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")), + ("", re.compile(r"(?:\+?55\s?)?\(?\b\d{2}\)?[\s-]?9?\d{4}[\s-]?\d{4}\b")), + ("", 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() diff --git a/datagen/taxonomy.py b/datagen/taxonomy.py new file mode 100644 index 0000000..0207464 --- /dev/null +++ b/datagen/taxonomy.py @@ -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("")[1].split("")[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"]} diff --git a/dmb/__init__.py b/dmb/__init__.py new file mode 100644 index 0000000..18bb66f --- /dev/null +++ b/dmb/__init__.py @@ -0,0 +1 @@ +"""Benchmark de modelos de decisão PT-BR: código comum.""" diff --git a/dmb/adapter.py b/dmb/adapter.py new file mode 100644 index 0000000..cd28517 --- /dev/null +++ b/dmb/adapter.py @@ -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} diff --git a/dmb/canonical.py b/dmb/canonical.py new file mode 100644 index 0000000..b4a2698 --- /dev/null +++ b/dmb/canonical.py @@ -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 diff --git a/dmb/jev.py b/dmb/jev.py new file mode 100644 index 0000000..4178b6a --- /dev/null +++ b/dmb/jev.py @@ -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 "" + raise InvalidRequest(f"{where}: {e.message}") + + +def response_errors(response: dict) -> list[str]: + return [f"{'/'.join(map(str, e.path)) or ''}: {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) diff --git a/dmb/llm.py b/dmb/llm.py new file mode 100644 index 0000000..f077464 --- /dev/null +++ b/dmb/llm.py @@ -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}") diff --git a/dmb/offline.py b/dmb/offline.py new file mode 100644 index 0000000..287960c --- /dev/null +++ b/dmb/offline.py @@ -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() diff --git a/dmb/server.py b/dmb/server.py new file mode 100644 index 0000000..3efc2b5 --- /dev/null +++ b/dmb/server.py @@ -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 diff --git a/docs/02-desenho-experimental.md b/docs/02-desenho-experimental.md index 5dad2cf..83b4acf 100644 --- a/docs/02-desenho-experimental.md +++ b/docs/02-desenho-experimental.md @@ -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. diff --git a/docs/03-plano-de-tarefas.md b/docs/03-plano-de-tarefas.md index c05eb9f..1f1de2c 100644 --- a/docs/03-plano-de-tarefas.md +++ b/docs/03-plano-de-tarefas.md @@ -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//`), 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. diff --git a/docs/04-questoes-em-aberto.md b/docs/04-questoes-em-aberto.md index 73a597b..bc05aeb 100644 --- a/docs/04-questoes-em-aberto.md +++ b/docs/04-questoes-em-aberto.md @@ -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 diff --git a/docs/05-ambiente-de-execucao.md b/docs/05-ambiente-de-execucao.md new file mode 100644 index 0000000..236e2a0 --- /dev/null +++ b/docs/05-ambiente-de-execucao.md @@ -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//` (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//` | +| **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 --entrypoint