diff --git a/README.md b/README.md index 8d5bb7b..fea5ae6 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ O resultado é um relatório técnico com uma recomendação. Todos os resultado Cada candidato é exposto por um adaptador local com o mesmo contrato JEV (`POST /v1/systemone`). Assim, o *harness* não sabe qual modelo está respondendo. +## Andamento + +O andamento por fase fica no topo de [docs/03-plano-de-tarefas.md](docs/03-plano-de-tarefas.md), regenerado com `python tools/plan_progress.py` a cada atualização do plano. + +**Primeiro ciclo:** classificação de intenções (D1) no atendimento de telefonia móvel, com dados 100% sintéticos anotados por três modelos generativos. CPU em us-chicago-1 (sub-rede privada) e GPU A10 em sa-saopaulo-1. + ## Documentação | Documento | Conteúdo | @@ -32,6 +38,7 @@ 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/06-guia-de-anotacao.md](docs/06-guia-de-anotacao.md) | Guia de anotação, usado literalmente pelos anotadores | | [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 | @@ -46,12 +53,14 @@ infra/ds/ Jobs do Data Science: build dos ambientes, execução do ben 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/taxonomy/ Taxonomia de intenções (intents-v1.yaml: 42 intenções, 13 domínios) (20–23) +datagen/ Pipeline sintético: geração, mascaramento/deduplicação, anotação por 3 LLMs, partições (24–36) data/raw/ Enunciados coletados e anonimizados — não versionar dados sensíveis (24–25) data/annotated/ Anotações, adjudicação e referência (29–32) data/splits/ Partições dev / calibração / teste congeladas (33–35) adapters// Adaptadores JEV locais por candidato (42–51) -harness/ Execução, controle de seeds/lotes/truncamento, coleta de medições (52–66) +harness/ Execução nos jobs (job.py), sanidade, servidor, auditoria do bucket (18–19, 41, 51–66) +tools/ Utilitários do repositório (andamento do plano) analysis/ Métricas, cortes, análise de erros e gráficos (67–74) reports/ Relatório técnico e anexos de reprodução (75–76) ``` diff --git a/docs/03-plano-de-tarefas.md b/docs/03-plano-de-tarefas.md index 1f1de2c..0072e26 100644 --- a/docs/03-plano-de-tarefas.md +++ b/docs/03-plano-de-tarefas.md @@ -19,13 +19,34 @@ flowchart LR As fases F0–F1 (infra) e F2–F3 (dados) podem andar em paralelo. + +**Andamento em 2026-10-07:** 40 de 78 tarefas concluídas (51%), 8 em andamento. + +| Fase | Concluídas | Em andamento | Pendentes | +|------|-----------|--------------|-----------| +| F0 — Infraestrutura OCI | 6 | 2 | 2 | +| F1 — Ambiente e artefatos | 9 | 0 | 0 | +| F2 — Tarefas de decisão e taxonomia | 4 | 0 | 0 | +| F3 — Dados e anotação | 5 | 6 | 1 | +| F4 — Contrato JEV e adaptadores | 16 | 0 | 0 | +| F5 — Controle de execução | 0 | 0 | 3 | +| F6 — Piloto | 0 | 0 | 4 | +| F7 — Desenvolvimento e calibração | 0 | 0 | 4 | +| F8 — Teste final | 0 | 0 | 4 | +| F9 — Métricas e análise | 0 | 0 | 8 | +| F10 — Relatório e encerramento | 0 | 0 | 4 | +| **Total** | **40** | **8** | **30** | + +Legenda: `[x]` concluída · `[~]` em andamento ou parcial · `[ ]` pendente. + + --- ## F0 — Infraestrutura OCI - [x] **1.** Criar um compartment OCI dedicado ao benchmark. - - ↳ `root//decision-models`. Recursos em `us-chicago-1`. OCIDs em `.secrets/artefatos-OCI.md` (fora do Git). -- [ ] **2.** Aplicar as tags `projeto=benchmark-decisoes-ptbr`, `ambiente=benchmark` e `centro_custo=` aos recursos do projeto. *(Q13)* + - ↳ `/decision-models` (caminho em `.secrets/oci-settings.json`). Recursos em `us-chicago-1`. OCIDs em `.secrets/artefatos-OCI.md` (fora do Git). +- [~] **2.** Aplicar as tags `projeto=benchmark-decisoes-ptbr`, `ambiente=benchmark` e `centro_custo=` aos recursos do projeto. *(Q13)* - ↳ Parcial: `projeto` e `ambiente` aplicadas como tags livres por `infra/oci/provision.py`. Falta `centro_custo`. - [ ] **3.** Criar um orçamento para o compartment. - ↳ **Em aberto por decisão (2026-10-07):** o uso está coberto por cota interna de engenharia. Não será criado no primeiro ciclo. @@ -33,7 +54,7 @@ As fases F0–F1 (infra) e F2–F3 (dados) podem andar em paralelo. - ↳ Em aberto, porque depende da tarefa 3. - [x] **5.** Criar um projeto no OCI Data Science. - ↳ `benchmark-decisoes-ptbr`. -- [ ] **6.** Criar buckets no Object Storage para `entrada`, `artefatos`, `resultados` e `logs-imutaveis`. +- [~] **6.** Criar buckets no Object Storage para `entrada`, `artefatos`, `resultados` e `logs-imutaveis`. - ↳ Criados com o prefixo `dmb-` (nomes de bucket são únicos no namespace), privados e com versionamento. - ↳ Pendente: a regra de retenção (WORM) de `dmb-logs-imutaveis`, que depende do prazo de retenção (tarefa 77). - [x] **7.** Definir políticas IAM de menor privilégio para leitura dos dados, escrita dos resultados e execução dos jobs. @@ -42,8 +63,9 @@ As fases F0–F1 (infra) e F2–F3 (dados) podem andar em paralelo. - ↳ `dmb-jobs-menor-privilegio`: os jobs (`request.principal.type='datasciencejob'`) leem `dmb-entrada`/`dmb-artefatos`, escrevem em `dmb-resultados`/`dmb-logs-imutaveis`, usam logs e repos. Inclui a política de serviço do Data Science para a VCN. - [x] **8.** Definir uma VCN privada para os jobs, com acesso ao Object Storage por Service Gateway. - ↳ `dmb-vcn` 10.20.0.0/16, sub-rede privada `dmb-subnet-jobs`. A rota e a security list só permitem tráfego para serviços OCI pelo Service Gateway. -- [ ] **9.** Criar uma rota temporária de saída por NAT Gateway, só para a aquisição inicial de dependências e pesos. +- [x] **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)* + - ↳ **Não foi necessário:** a aquisição (tarefas 13–16) rodou fora da VCN e enviou os artefatos direto ao bucket; os ambientes são montados por jobs de build com rede gerenciada. `infra/oci/nat.py` fica disponível. - ↳ 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`. - [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. @@ -85,26 +107,29 @@ As fases F0–F1 (infra) e F2–F3 (dados) podem andar em paralelo. ## F3 — Dados e anotação -- [ ] **24.** Coletar textos PT-BR representativos do domínio de destino. *(Q7, Q15)* - - ↳ **100% sintéticos** (`datagen.generate`, na VM). +- [x] **24.** Coletar textos PT-BR representativos do domínio de destino. *(Q7, Q15)* + - ↳ **100% sintéticos** (`datagen.generate`, na VM): 4.068 enunciados em 339 lotes (42 intenções × 7 perfis + 5 categorias fora de escopo × 9 lotes), geradores `llama-4-maverick` e `command-a` alternados. - ↳ 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. +- [x] **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. - ↳ Acrescentar a posição do turno e a presença de agente ativo ou de sessões suspensas. -- [ ] **27.** Criar exemplos adversariais com abreviações, erros de ortografia, regionalismos, textos curtos e várias intenções. + - ↳ Primeiro ciclo (só D1): metadados `domain`, `length_bucket`, `ambiguity`, `profile`, `sentiment`, `kind` e `n_options` gravados por `datagen.build`. +- [x] **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. + - ↳ Perfis de geração: informal/abreviado, erros de digitação, regionalismo, emocional, longo, indireto/múltiplo; fora de escopo em 5 categorias (inclui desabafo emocional sem pedido). +- [x] **28.** Definir os critérios de inclusão e exclusão dos exemplos. + - ↳ Exclui: duplicatas exatas e aproximadas, comprimento fora de 2–700 caracteres, três rótulos distintos na anotação e falha de anotação. Resultado do `prepare`: 4.057 de 4.068 mantidos (9 duplicatas exatas, 2 aproximadas; 4 trechos mascarados). - [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. +- [~] **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. +- [~] **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. -- [ ] **33.** Separar os dados em desenvolvimento, calibração e teste final por grupo de origem. *(Q12)* +- [~] **33.** Separar os dados em desenvolvimento, calibração e teste final por grupo de origem. *(Q12)* - ↳ O grupo é `group_id`, ou seja, a conversa. -- [ ] **34.** Impedir que textos derivados do mesmo caso apareçam em mais de uma partição. +- [~] **34.** Impedir que textos derivados do mesmo caso apareçam em mais de uma partição. - [ ] **35.** Congelar o conjunto de teste final antes de qualquer ajuste de parâmetros. - ↳ Registrar o hash do arquivo congelado e guardar uma cópia em `logs-imutaveis`. @@ -143,9 +168,10 @@ As fases F0–F1 (infra) e F2–F3 (dados) podem andar em paralelo. ## F6 — Piloto -- [ ] **55.** Executar um piloto de 50 exemplos por candidato em CPU. *(Q11)* +- [ ] **55.** Executar um piloto de 50 exemplos por candidato em CPU. + - ↳ Sanidade em CPU (us-chicago-1, sub-rede privada) aprovada nos 4 candidatos: Laya 125 ms, GLiNER 194 ms, Rizzo Flow 7,3 s, SemIf 16,4 s (latência mediana). *(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. + - ↳ GPU em **sa-saopaulo-1, A10.1**, uma por vez *(Q14)*. Sanidade em GPU: Laya 21 ms, GLiNER 16 ms, SemIf 66 ms (latência mediana). Rizzo Flow exigiu compilar o llama.cpp com CUDA para a glibc da imagem dos jobs (`infra/ds/build_llama_cuda.sh`). - [ ] **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/05-ambiente-de-execucao.md b/docs/05-ambiente-de-execucao.md index 236e2a0..0779542 100644 --- a/docs/05-ambiente-de-execucao.md +++ b/docs/05-ambiente-de-execucao.md @@ -31,6 +31,33 @@ PYTHONPATH=infra/oci .venv/bin/python infra/ds/jobs.py run --name --entry --env DMB_CANDIDATE=laya DMB_MODE=sanity --wait ``` +## GPU (sa-saopaulo-1) + +| Item | Valor | +|------|-------| +| Shape | `VM.GPU.A10.1`, **uma GPU por vez** (limite da região) — `infra/ds/gpu_queue.py` enfileira | +| Dados | `dmb-artefatos-gru` (réplica server-side com `infra/oci/replicate.py`), resultados em `dmb-resultados-gru` | +| Rede | Gerenciada (limite de VCNs da região esgotado). Isolamento no processo + autoteste `process_guard` (`DMB_ISOLATION=app`) | +| Rizzo Flow em CUDA | O binário oficial do llama.cpp exige GLIBC 2.38; a imagem dos jobs tem 2.34. `infra/ds/build_llama_cuda.sh` compila o mesmo commit (`161755f`) com CUDA 12.8, sysroot glibc 2.17 e libstdc++ estática (maior GLIBC exigida: 2.27), publicado em `runtimes/rizzo_flow/cuda-latest.json` | + +```bash +PYTHONPATH=infra/oci:infra/ds .venv/bin/python infra/ds/gpu_queue.py sanity laya gliner semif rizzo_flow +``` + +## Dados sintéticos (na VM) + +```bash +export DMB_OCI_AUTH=instance_principal DMB_COMPARTMENT_OCID= +python -m datagen.generate --out /data/runs/datagen-v1 # OCI Generative AI (us-chicago-1) +python -m datagen.prepare --run /data/runs/datagen-v1 +python -m datagen.annotate --run /data/runs/datagen-v1 +python -m datagen.build --run /data/runs/datagen-v1 --freeze +``` + +## Configuração local sensível + +Fora do Git, em `.secrets/`: credenciais OCI, chaves SSH, o catálogo de artefatos com OCIDs (`artefatos-OCI.md`) e `oci-settings.json` (caminho do compartimento pai e CIDR autorizado para SSH; variáveis `DMB_PARENT_COMPARTMENT` e `DMB_SSH_CIDR` têm precedência). `tests/test_sensitive.py` falha se OCIDs, chaves, IPs públicos ou valores de `.secrets/oci-settings.json` aparecerem em arquivos versionados. + ## VM de trabalho | Item | Valor | diff --git a/harness/sanity.py b/harness/sanity.py index 8de1009..5d217f7 100644 --- a/harness/sanity.py +++ b/harness/sanity.py @@ -9,7 +9,6 @@ Uso: python -m harness.sanity --candidate laya --models-root models --device cpu """ import argparse import json -import platform import sys import time from pathlib import Path @@ -76,7 +75,7 @@ def main(): sanity.append(row) report = { - "candidate": adapter.describe(), "device": args.device, "host": platform.node(), + "candidate": adapter.describe(), "device": args.device, "load_seconds": round(load_s, 2), **guard, "conformance": conformance, "sanity": sanity, "summary": { diff --git a/infra/oci/provision.py b/infra/oci/provision.py index ab7b0b7..a990b84 100644 --- a/infra/oci/provision.py +++ b/infra/oci/provision.py @@ -13,16 +13,17 @@ from datetime import datetime, timezone import oci -from session import CRED_DIR, REGION, load_config +from session import CRED_DIR, REGION, load_config, settings -PARENT_PATH = "root/" +# Caminho do compartimento pai e CIDR do SSH ficam fora do Git: .secrets/oci-settings.json +PARENT_PATH = settings()["parent_compartment_path"] COMPARTMENT_NAME = "decision-models" TAGS = {"projeto": "benchmark-decisoes-ptbr", "ambiente": "benchmark"} # centro_custo pendente (Q13) BUCKETS = ["entrada", "artefatos", "resultados", "logs-imutaveis"] VCN_CIDR = "10.20.0.0/16" SUBNET_CIDR = "10.20.1.0/24" DEV_SUBNET_CIDR = "10.20.2.0/24" -SSH_ALLOWED_CIDR = os.environ.get("DMB_SSH_CIDR", "203.0.113.1/32") +SSH_ALLOWED_CIDR = settings()["ssh_allowed_cidr"] # VM de trabalho criada pelo usuário no console (o lançamento via API é negado neste compartment). VM_NAME = "instance-20261007-1206" VM_SSH_KEY = ".secrets/ssh/ssh-key-oci.key" diff --git a/infra/oci/session.py b/infra/oci/session.py index 028ca78..20c88ac 100644 --- a/infra/oci/session.py +++ b/infra/oci/session.py @@ -1,5 +1,7 @@ """Sessão OCI do benchmark a partir das credenciais em .secrets/oci-credentials/.""" import configparser +import json +import os from pathlib import Path import oci @@ -9,6 +11,16 @@ CRED_DIR = ROOT / ".secrets" / "oci-credentials" REGION = "us-chicago-1" +def settings() -> dict: + """Configuração local fora do Git (.secrets/oci-settings.json); variáveis DMB_* têm precedência.""" + path = CRED_DIR.parent / "oci-settings.json" + data = json.loads(path.read_text()) if path.is_file() else {} + return { + "parent_compartment_path": os.environ.get("DMB_PARENT_COMPARTMENT", data.get("parent_compartment_path")), + "ssh_allowed_cidr": os.environ.get("DMB_SSH_CIDR", data.get("ssh_allowed_cidr")), + } + + def load_config(region: str = REGION, profile: str = "DEFAULT") -> dict: parser = configparser.ConfigParser() parser.read(CRED_DIR / "profile.config") diff --git a/reports/sanity/gliner-cpu.json b/reports/sanity/gliner-cpu.json index a032fe2..e80199f 100644 --- a/reports/sanity/gliner-cpu.json +++ b/reports/sanity/gliner-cpu.json @@ -9,7 +9,6 @@ "probabilities_source": "native" }, "device": "cpu", - "host": "host-local", "load_seconds": 15.93, "conformance": [ { diff --git a/reports/sanity/laya-cpu.json b/reports/sanity/laya-cpu.json index 8674d5c..c92155b 100644 --- a/reports/sanity/laya-cpu.json +++ b/reports/sanity/laya-cpu.json @@ -9,7 +9,6 @@ "probabilities_source": "native" }, "device": "cpu", - "host": "host-local", "load_seconds": 8.4, "conformance": [ { diff --git a/reports/sanity/rizzo_flow-cpu.json b/reports/sanity/rizzo_flow-cpu.json index 5bc8aaf..c52afc5 100644 --- a/reports/sanity/rizzo_flow-cpu.json +++ b/reports/sanity/rizzo_flow-cpu.json @@ -9,7 +9,6 @@ "probabilities_source": "native" }, "device": "cpu", - "host": "host-local", "load_seconds": 6.18, "conformance": [ { diff --git a/reports/sanity/semif-cpu.json b/reports/sanity/semif-cpu.json index 8d941aa..bbeb13e 100644 --- a/reports/sanity/semif-cpu.json +++ b/reports/sanity/semif-cpu.json @@ -9,7 +9,6 @@ "probabilities_source": "native" }, "device": "cpu", - "host": "host-local", "load_seconds": 6.55, "conformance": [ { diff --git a/tests/test_offline.py b/tests/test_offline.py index 8d88e82..4798991 100644 --- a/tests/test_offline.py +++ b/tests/test_offline.py @@ -12,7 +12,7 @@ def test_external_connection_is_blocked(): s = socket.socket() try: with pytest.raises(NetworkAccessBlocked): - s.connect(("93.184.216.34", 443)) + s.connect(("203.0.113.10", 443)) # TEST-NET-3 (RFC 5737) finally: s.close() diff --git a/tests/test_sensitive.py b/tests/test_sensitive.py new file mode 100644 index 0000000..9b9eb45 --- /dev/null +++ b/tests/test_sensitive.py @@ -0,0 +1,52 @@ +"""Nada sensível nos arquivos versionados: OCIDs, chaves, IPs públicos, namespace ou dados de .secrets/.""" +import ipaddress +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PATTERNS = [ + re.compile(r"ocid1\.[a-z0-9]+\.oc1\."), + re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), + re.compile(r"\bfingerprint\s*=\s*[0-9a-f:]{20,}"), +] +IPV4 = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b") + + +def tracked_files(): + out = subprocess.run(["git", "-C", str(ROOT), "ls-files", "-co", "--exclude-standard"], + capture_output=True, text=True, check=True).stdout.split() + return [ROOT / f for f in out if (ROOT / f).is_file() and not f.startswith(".secrets/")] + + +def local_secrets() -> list[str]: + """Valores de .secrets/oci-settings.json não podem aparecer em arquivos versionados.""" + path = ROOT / ".secrets" / "oci-settings.json" + if not path.is_file(): + return [] + values = [] + for v in json.loads(path.read_text()).values(): + values += [v] + [part for part in str(v).replace("/", " ").split() if len(part) > 5 and part != "root"] + return values + + +def test_no_sensitive_content(): + secrets = local_secrets() + problems = [] + for f in tracked_files(): + try: + text = f.read_text() + except UnicodeDecodeError: + continue + rel = f.relative_to(ROOT) + problems += [f"{rel}: {p.pattern}" for p in PATTERNS if p.search(text)] + problems += [f"{rel}: valor de .secrets/oci-settings.json" for s in secrets if s and s in text] + for ip in IPV4.findall(text): + try: + addr = ipaddress.ip_address(ip) + except ValueError: + continue + if addr.is_global: + problems.append(f"{rel}: IP público {ip}") + assert not problems, "\n".join(problems) diff --git a/tools/plan_progress.py b/tools/plan_progress.py new file mode 100644 index 0000000..25e6a9d --- /dev/null +++ b/tools/plan_progress.py @@ -0,0 +1,42 @@ +"""Atualiza o bloco de andamento de docs/03-plano-de-tarefas.md a partir das caixas de cada tarefa. + +Legenda: [x] concluída · [~] em andamento/parcial · [ ] pendente. +Uso: python tools/plan_progress.py +""" +import re +from datetime import date +from pathlib import Path + +PLAN = Path(__file__).resolve().parents[1] / "docs" / "03-plano-de-tarefas.md" +START, END = "", "" + + +def main(): + text = PLAN.read_text() + phases, current = [], None + for line in text.splitlines(): + if line.startswith("## F"): + current = [line[3:].strip(), 0, 0, 0] + phases.append(current) + m = re.match(r"- \[(x| |~)\] \*\*\d+\.\*\*", line) + if m and current: + current[{"x": 1, "~": 2, " ": 3}[m.group(1)]] += 1 + rows = ["| Fase | Concluídas | Em andamento | Pendentes |", "|------|-----------|--------------|-----------|"] + totals = [0, 0, 0] + for name, done, doing, todo in phases: + rows.append(f"| {name} | {done} | {doing} | {todo} |") + totals = [totals[0] + done, totals[1] + doing, totals[2] + todo] + rows.append(f"| **Total** | **{totals[0]}** | **{totals[1]}** | **{totals[2]}** |") + block = (f"{START}\n**Andamento em {date.today():%Y-%m-%d}:** {totals[0]} de {sum(totals)} tarefas concluídas " + f"({100 * totals[0] // sum(totals)}%), {totals[1]} em andamento.\n\n" + "\n".join(rows) + + "\n\nLegenda: `[x]` concluída · `[~]` em andamento ou parcial · `[ ]` pendente.\n" + END) + if START in text: + text = re.sub(re.escape(START) + ".*?" + re.escape(END), block, text, flags=re.S) + else: + text = text.replace("\n---\n", f"\n{block}\n\n---\n", 1) + PLAN.write_text(text) + print(block) + + +if __name__ == "__main__": + main()