setup models

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

1
datagen/__init__.py Normal file
View File

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

188
datagen/annotate.py Normal file
View File

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

173
datagen/build.py Normal file
View File

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

147
datagen/generate.py Normal file
View File

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

81
datagen/prepare.py Normal file
View File

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

40
datagen/taxonomy.py Normal file
View File

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