mirror of
https://github.com/mcloh/decision-models-benchmark.git
synced 2026-11-05 22:04:12 +00:00
setup models
This commit is contained in:
1
datagen/__init__.py
Normal file
1
datagen/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Geração, anotação e montagem do conjunto sintético (tarefas 20–35)."""
|
||||
188
datagen/annotate.py
Normal file
188
datagen/annotate.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Anotação por três modelos generativos em rodízio (tarefas 30–32).
|
||||
|
||||
Para o lote b (10 mensagens embaralhadas): anotadores A = M[b % 3] e B = M[(b+1) % 3], de forma
|
||||
independente e sem ver o rótulo de geração; desempate T = M[(b+2) % 3] só quando A ≠ B. T vê as duas
|
||||
propostas (anônimas) e decide; se escolher um terceiro rótulo, o exemplo é excluído (ambiguidade alta).
|
||||
|
||||
Uso: python -m datagen.annotate --run /data/runs/datagen-v1 [--workers 6]
|
||||
Entrada: candidates.jsonl. Saída: annotations.jsonl (bruto, retomável), annotated.jsonl, annotation-report.json.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
from collections import Counter, defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from itertools import combinations
|
||||
from pathlib import Path
|
||||
|
||||
from dmb.llm import chat, extract_json
|
||||
from datagen import taxonomy
|
||||
|
||||
ANNOTATORS = ["openai.gpt-5.5", "google.gemini-2.5-pro", "xai.grok-4.3"]
|
||||
BATCH = 10
|
||||
SEED = 20261007
|
||||
AMBIGUITY = {"baixa", "media", "alta"}
|
||||
|
||||
|
||||
def system_prompt() -> str:
|
||||
return (taxonomy.guide() + "\n\n## Taxonomia\n\n" + taxonomy.taxonomy_block()
|
||||
+ "\n\nResponda somente com JSON válido.")
|
||||
|
||||
|
||||
def annotate_batch(model: str, items: list[dict]) -> dict[str, dict]:
|
||||
payload = [{"id": it["id"], "mensagem": it["text"]} for it in items]
|
||||
user = ("Anote cada mensagem. Devolva uma lista JSON com um objeto por mensagem, com as chaves "
|
||||
"`id`, `rotulo`, `secundarias`, `ambiguidade` e `justificativa`.\n\n"
|
||||
+ json.dumps(payload, ensure_ascii=False, indent=1))
|
||||
valid = taxonomy.labels()
|
||||
out = {}
|
||||
for attempt in range(2):
|
||||
pending = [it for it in items if it["id"] not in out]
|
||||
if not pending:
|
||||
break
|
||||
if attempt:
|
||||
user = user.split("\n\n")[0] + "\n\n" + json.dumps(
|
||||
[{"id": it["id"], "mensagem": it["text"]} for it in pending], ensure_ascii=False, indent=1)
|
||||
try:
|
||||
answer = extract_json(chat(model, system_prompt(), user, temperature=0.0, max_tokens=12000))
|
||||
except Exception:
|
||||
continue
|
||||
for a in answer if isinstance(answer, list) else []:
|
||||
if not isinstance(a, dict) or a.get("rotulo") not in valid:
|
||||
continue
|
||||
out[str(a.get("id"))] = {
|
||||
"rotulo": a["rotulo"],
|
||||
"secundarias": [s for s in (a.get("secundarias") or []) if s in valid and s != a["rotulo"]],
|
||||
"ambiguidade": a.get("ambiguidade") if a.get("ambiguidade") in AMBIGUITY else None,
|
||||
"justificativa": str(a.get("justificativa") or "")[:300],
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def adjudicate(model: str, item: dict, a: dict, b: dict) -> dict | None:
|
||||
user = (
|
||||
"Duas anotações independentes discordaram. Decida o rótulo correto segundo o guia; você pode "
|
||||
"escolher uma das duas propostas ou outro rótulo da taxonomia.\n\n"
|
||||
f"Mensagem: {item['text']}\n\n"
|
||||
f"Proposta 1: `{a['rotulo']}` — {a['justificativa']}\n"
|
||||
f"Proposta 2: `{b['rotulo']}` — {b['justificativa']}\n\n"
|
||||
"Devolva um objeto JSON com `rotulo`, `ambiguidade` e `justificativa`."
|
||||
)
|
||||
try:
|
||||
ans = extract_json(chat(model, system_prompt(), user, temperature=0.0, max_tokens=4000))
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(ans, dict) or ans.get("rotulo") not in taxonomy.labels():
|
||||
return None
|
||||
return {"rotulo": ans["rotulo"], "ambiguidade": ans.get("ambiguidade"),
|
||||
"justificativa": str(ans.get("justificativa") or "")[:300]}
|
||||
|
||||
|
||||
def cohen_kappa(pairs: list[tuple[str, str]]) -> float | None:
|
||||
if not pairs:
|
||||
return None
|
||||
n = len(pairs)
|
||||
po = sum(x == y for x, y in pairs) / n
|
||||
ca, cb = Counter(x for x, _ in pairs), Counter(y for _, y in pairs)
|
||||
pe = sum(ca[k] * cb.get(k, 0) for k in ca) / (n * n)
|
||||
return (po - pe) / (1 - pe) if pe < 1 else 1.0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--run", required=True)
|
||||
ap.add_argument("--workers", type=int, default=6)
|
||||
ap.add_argument("--limit", type=int)
|
||||
args = ap.parse_args()
|
||||
run = Path(args.run)
|
||||
items = [json.loads(l) for l in (run / "candidates.jsonl").open()]
|
||||
random.Random(SEED).shuffle(items)
|
||||
items = items[: args.limit]
|
||||
by_id = {it["id"]: it for it in items}
|
||||
batches = [items[i:i + BATCH] for i in range(0, len(items), BATCH)]
|
||||
|
||||
raw_path = run / "annotations.jsonl"
|
||||
raw = defaultdict(dict) # id -> role -> registro
|
||||
if raw_path.exists():
|
||||
for l in raw_path.open():
|
||||
r = json.loads(l)
|
||||
raw[r["id"]][r["role"]] = r
|
||||
lock = threading.Lock()
|
||||
f = raw_path.open("a")
|
||||
|
||||
def save(rec):
|
||||
with lock:
|
||||
raw[rec["id"]][rec["role"]] = rec
|
||||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
f.flush()
|
||||
|
||||
def roles(b):
|
||||
return {"A": ANNOTATORS[b % 3], "B": ANNOTATORS[(b + 1) % 3], "T": ANNOTATORS[(b + 2) % 3]}
|
||||
|
||||
def do_batch(b):
|
||||
rl = roles(b)
|
||||
for role in ("A", "B"):
|
||||
todo = [it for it in batches[b] if role not in raw[it["id"]]]
|
||||
if todo:
|
||||
for id_, ann in annotate_batch(rl[role], todo).items():
|
||||
if id_ in by_id:
|
||||
save({"id": id_, "role": role, "model": rl[role], **ann})
|
||||
for it in batches[b]:
|
||||
r = raw[it["id"]]
|
||||
if "A" in r and "B" in r and r["A"]["rotulo"] != r["B"]["rotulo"] and "T" not in r:
|
||||
dec = adjudicate(rl["T"], it, r["A"], r["B"])
|
||||
if dec:
|
||||
save({"id": it["id"], "role": "T", "model": rl["T"], **dec})
|
||||
return b
|
||||
|
||||
with ThreadPoolExecutor(args.workers) as pool:
|
||||
for i, _ in enumerate(pool.map(do_batch, range(len(batches))), 1):
|
||||
if i % 25 == 0:
|
||||
print(f"{i}/{len(batches)} lotes anotados", flush=True)
|
||||
f.close()
|
||||
|
||||
# Consolidação
|
||||
final, status = [], Counter()
|
||||
pairs = defaultdict(list)
|
||||
for it in items:
|
||||
r = raw[it["id"]]
|
||||
if "A" not in r or "B" not in r:
|
||||
status["excluido_falha_anotacao"] += 1
|
||||
continue
|
||||
a, b = r["A"], r["B"]
|
||||
pairs[tuple(sorted((a["model"], b["model"])))].append(
|
||||
(a["rotulo"], b["rotulo"]) if a["model"] < b["model"] else (b["rotulo"], a["rotulo"]))
|
||||
if a["rotulo"] == b["rotulo"]:
|
||||
gold, how, why = a["rotulo"], "consenso", a["justificativa"]
|
||||
elif "T" in r and r["T"]["rotulo"] in (a["rotulo"], b["rotulo"]):
|
||||
gold, how, why = r["T"]["rotulo"], "desempate", r["T"]["justificativa"]
|
||||
else:
|
||||
status["excluido_tres_rotulos" if "T" in r else "excluido_sem_desempate"] += 1
|
||||
continue
|
||||
status[how] += 1
|
||||
amb = [x.get("ambiguidade") for x in (a, b, r.get("T") or {}) if x.get("ambiguidade")]
|
||||
final.append({**it, "gold_label": gold, "annotation": how, "justification": why,
|
||||
"annotators": {k: {"model": v["model"], "rotulo": v["rotulo"]} for k, v in r.items()},
|
||||
"ambiguity": max(amb, key=["baixa", "media", "alta"].index) if amb else None,
|
||||
"secondary": sorted(set(a.get("secundarias", [])) | set(b.get("secundarias", []))),
|
||||
"gen_agreement": gold == it["gen_label"]})
|
||||
with (run / "annotated.jsonl").open("w") as out:
|
||||
for r in final:
|
||||
out.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
report = {
|
||||
"annotators": ANNOTATORS, "items": len(items), "status": dict(status),
|
||||
"cohen_kappa": {f"{x} × {y}": round(cohen_kappa(p), 4) for (x, y), p in pairs.items()},
|
||||
"raw_agreement": round(sum(s for s in [status["consenso"]]) / max(1, len(items)), 4),
|
||||
"agreement_with_generation": round(sum(r["gen_agreement"] for r in final) / max(1, len(final)), 4),
|
||||
"gold_distribution": dict(Counter(r["gold_label"] for r in final).most_common()),
|
||||
}
|
||||
(run / "annotation-report.json").write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
|
||||
print(json.dumps({k: report[k] for k in ("items", "status", "cohen_kappa", "raw_agreement",
|
||||
"agreement_with_generation")}, ensure_ascii=False, indent=1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
173
datagen/build.py
Normal file
173
datagen/build.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Montagem do conjunto D1: partições, conjuntos de opções, lint e congelamento (tarefas 33–36).
|
||||
|
||||
- Partição por `group_id` (lote de geração), estratificada pelo alvo de geração:
|
||||
intenções (7 lotes) → 3 teste / 2 calibração / 2 desenvolvimento; fora de escopo (9 lotes) → 4 / 2 / 3.
|
||||
- Conjunto de opções por exemplo (simula o serviço de elegibilidade): k ∈ {4, 8, 12, 16} opções
|
||||
incluindo `sem_correspondencia` (sempre a última). Distratores: confundíveis → mesmo domínio → aleatórios.
|
||||
Em 10% dos exemplos dentro do escopo a intenção correta fica fora das opções (rótulo vira
|
||||
`sem_correspondencia`, `gold_eligible=false`), para medir a abstenção.
|
||||
- Congelamento: SHA-256 de cada partição em data/splits/MANIFEST.json; cópia em dmb-entrada e dmb-logs-imutaveis.
|
||||
|
||||
Uso: python -m datagen.build --run /data/runs/datagen-v1 [--freeze]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from datagen import taxonomy
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPLITS_DIR = ROOT / "data" / "splits"
|
||||
SEED = 20261007
|
||||
K_CHOICES = [4, 8, 12, 16]
|
||||
P_GOLD_EXCLUDED = 0.10
|
||||
QUESTION = "Qual é a intenção principal do cliente na Mensagem?"
|
||||
MAX_OPTIONS = 16
|
||||
FALLBACK = "sem_correspondencia"
|
||||
SPLIT_PATTERN = {"intent": ["test"] * 3 + ["calibration"] * 2 + ["dev"] * 2,
|
||||
"oos": ["test"] * 4 + ["calibration"] * 2 + ["dev"] * 3}
|
||||
|
||||
|
||||
def assign_splits(rows: list[dict]) -> dict[str, str]:
|
||||
groups = defaultdict(set)
|
||||
kind = {}
|
||||
for r in rows:
|
||||
groups[r["gen_target"]].add(r["group_id"])
|
||||
kind[r["gen_target"]] = r["kind"]
|
||||
out = {}
|
||||
for target, gids in sorted(groups.items()):
|
||||
gids = sorted(gids)
|
||||
random.Random(f"{SEED}-{target}").shuffle(gids)
|
||||
pattern = SPLIT_PATTERN[kind[target]]
|
||||
for i, g in enumerate(gids):
|
||||
out[g] = pattern[i % len(pattern)]
|
||||
return out
|
||||
|
||||
|
||||
def option_set(row: dict) -> tuple[dict, str, bool]:
|
||||
tax = taxonomy.load()
|
||||
intents = tax["intents"]
|
||||
rng = random.Random(f"{SEED}-{row['id']}")
|
||||
k = K_CHOICES[int(hashlib.sha256(row["id"].encode()).hexdigest(), 16) % len(K_CHOICES)]
|
||||
gold = row["gold_label"]
|
||||
in_scope = gold != FALLBACK
|
||||
include_gold = in_scope and rng.random() >= P_GOLD_EXCLUDED
|
||||
chosen = [gold] if include_gold else []
|
||||
pool = []
|
||||
if in_scope:
|
||||
pool += [c for c in intents[gold]["confusable_with"] if c != gold]
|
||||
pool += [i for i, v in intents.items() if v["domain"] == intents[gold]["domain"] and i != gold]
|
||||
rest = [i for i in intents if i != gold]
|
||||
rng.shuffle(rest)
|
||||
pool += rest
|
||||
for c in pool:
|
||||
if len(chosen) >= k - 1:
|
||||
break
|
||||
if c not in chosen and c != gold:
|
||||
chosen.append(c)
|
||||
rng.shuffle(chosen)
|
||||
options = {c: intents[c]["description"] for c in chosen}
|
||||
options[FALLBACK] = tax["fallback"]["description"]
|
||||
label = gold if include_gold else FALLBACK
|
||||
return options, label, include_gold or not in_scope
|
||||
|
||||
|
||||
def to_example(row: dict, split: str) -> dict:
|
||||
options, label, eligible = option_set(row)
|
||||
return {
|
||||
"id": f"{row['id']}-D1", "group_id": row["group_id"], "task": "D1", "split": split,
|
||||
"state": {"active_agent": None, "suspended_sessions": [], "recent_turns": []},
|
||||
"utterance": row["text"], "question": QUESTION, "question_type": "choice",
|
||||
"options": options, "gold_label": label,
|
||||
"metadata": {
|
||||
"taxonomy_version": row["taxonomy_version"], "source": "synthetic",
|
||||
"intent_gold": row["gold_label"], "gold_eligible": eligible,
|
||||
"domain": taxonomy.load()["intents"].get(row["gold_label"], {}).get("domain", "fora_de_escopo"),
|
||||
"kind": row["kind"], "oos_category": row["gen_target"] if row["kind"] == "oos" else None,
|
||||
"profile": row["profile"], "sentiment": row.get("sentiment"),
|
||||
"secondary": row.get("secondary", []), "ambiguity": row.get("ambiguity"),
|
||||
"annotation": row["annotation"], "gen_agreement": row["gen_agreement"],
|
||||
"generator": row["generator"], "n_options": len(options),
|
||||
"length_chars": len(row["text"]),
|
||||
"length_bucket": "curto" if len(row["text"]) < 60 else "medio" if len(row["text"]) < 200 else "longo",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def lint(splits: dict[str, list[dict]]) -> list[str]:
|
||||
errors, seen_ids, group_split = [], set(), {}
|
||||
for name, rows in splits.items():
|
||||
for r in rows:
|
||||
if r["id"] in seen_ids:
|
||||
errors.append(f"id duplicado {r['id']}")
|
||||
seen_ids.add(r["id"])
|
||||
if not 2 <= len(r["options"]) <= MAX_OPTIONS:
|
||||
errors.append(f"{r['id']}: {len(r['options'])} opções")
|
||||
if r["gold_label"] not in r["options"]:
|
||||
errors.append(f"{r['id']}: rótulo fora das opções")
|
||||
if list(r["options"])[-1] != FALLBACK:
|
||||
errors.append(f"{r['id']}: fallback não é a última opção")
|
||||
if group_split.setdefault(r["group_id"], name) != name:
|
||||
errors.append(f"grupo {r['group_id']} em mais de uma partição")
|
||||
return errors
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--run", required=True)
|
||||
ap.add_argument("--freeze", action="store_true")
|
||||
args = ap.parse_args()
|
||||
run = Path(args.run)
|
||||
rows = [json.loads(l) for l in (run / "annotated.jsonl").open()]
|
||||
split_of = assign_splits(rows)
|
||||
splits = defaultdict(list)
|
||||
for r in sorted(rows, key=lambda r: r["id"]):
|
||||
splits[split_of[r["group_id"]]].append(to_example(r, split_of[r["group_id"]]))
|
||||
errors = lint(splits)
|
||||
if errors:
|
||||
raise SystemExit("lint reprovado:\n" + "\n".join(errors[:20]))
|
||||
SPLITS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
manifest = {"version": "d1-v1", "taxonomy_version": taxonomy.load()["version"],
|
||||
"built_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"source_run": str(run), "seed": SEED, "splits": {}}
|
||||
for name in ("dev", "calibration", "test"):
|
||||
path = SPLITS_DIR / f"{name}.jsonl"
|
||||
with path.open("w") as f:
|
||||
for ex in splits[name]:
|
||||
f.write(json.dumps(ex, ensure_ascii=False) + "\n")
|
||||
rows_ = splits[name]
|
||||
manifest["splits"][name] = {
|
||||
"file": path.name, "sha256": sha256_file(path), "examples": len(rows_),
|
||||
"groups": len({r["group_id"] for r in rows_}),
|
||||
"labels": len({r["gold_label"] for r in rows_}),
|
||||
"oos": sum(r["metadata"]["kind"] == "oos" for r in rows_),
|
||||
"gold_not_eligible": sum(not r["metadata"]["gold_eligible"] for r in rows_),
|
||||
"n_options": dict(sorted(Counter(r["metadata"]["n_options"] for r in rows_).items())),
|
||||
}
|
||||
(SPLITS_DIR / "MANIFEST.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
|
||||
print(json.dumps(manifest["splits"], indent=1))
|
||||
if args.freeze:
|
||||
from harness import objstore
|
||||
stamp = manifest["built_at"].replace(":", "").replace("-", "")
|
||||
for bucket in ("dmb-entrada", "dmb-logs-imutaveis"):
|
||||
for f in ["MANIFEST.json"] + [f"{n}.jsonl" for n in ("dev", "calibration", "test")]:
|
||||
objstore.put(bucket, f"splits/{manifest['version']}/{stamp}/{f}", SPLITS_DIR / f)
|
||||
for f in ("generated.jsonl", "candidates.jsonl", "annotations.jsonl", "annotated.jsonl",
|
||||
"prepare-report.json", "annotation-report.json"):
|
||||
if (run / f).exists():
|
||||
objstore.put(bucket, f"datagen/{manifest['version']}/{stamp}/{f}", run / f)
|
||||
print(f"congelado em dmb-entrada e dmb-logs-imutaveis: splits/{manifest['version']}/{stamp}/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
147
datagen/generate.py
Normal file
147
datagen/generate.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Geração sintética dos enunciados (tarefas 24 e 27).
|
||||
|
||||
Cada lote = (intenção ou categoria fora de escopo) × perfil, com N enunciados distintos.
|
||||
O lote é o `group_id`: enunciados do mesmo lote nunca são separados entre partições.
|
||||
Geradores alternados (fornecedores distintos dos anotadores). Retomável: lotes já gravados
|
||||
em <out>/generated.jsonl são pulados.
|
||||
|
||||
Uso (na VM): python -m datagen.generate --out /data/runs/datagen [--per-batch 12] [--workers 8]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
from dmb.llm import chat, extract_json
|
||||
from datagen import taxonomy
|
||||
|
||||
GENERATORS = ["meta.llama-4-maverick-17b-128e-instruct-fp8", "cohere.command-a-03-2025"]
|
||||
SEED = 20261007
|
||||
|
||||
PROFILES = {
|
||||
"curto_neutro": "mensagens curtas e diretas (3 a 12 palavras), tom neutro",
|
||||
"informal_abreviado": "linguagem informal de chat, com abreviações e gírias (vc, pq, q, tb, blz, mds, pfv)",
|
||||
"erros_digitacao": "MUITOS erros de digitação em toda mensagem: letras trocadas, faltando ou repetidas, sem acentuação, sem pontuação, tudo minúsculo, palavras grudadas",
|
||||
"regionalismo": "regionalismos e expressões de diferentes regiões do Brasil (Nordeste, Sul, Minas, Rio, São Paulo, Norte)",
|
||||
"emocional": "cliente irritado, frustrado, ansioso ou indignado; pode usar caixa alta, exclamações e xingamentos leves, mas com o pedido identificável",
|
||||
"longo_contexto": "mensagens longas (2 a 5 frases) contando a história do problema antes do pedido",
|
||||
"indireto_multiplo": "pedido indireto ou implícito; em metade das mensagens inclua também um segundo pedido secundário diferente",
|
||||
}
|
||||
OOS_PROFILES = ["curto_neutro", "informal_abreviado", "erros_digitacao", "regionalismo", "emocional",
|
||||
"longo_contexto", "indireto_multiplo", "curto_neutro", "emocional"]
|
||||
|
||||
SYSTEM = (
|
||||
"Você gera dados sintéticos realistas em português do Brasil para avaliar um classificador de "
|
||||
"intenções do atendimento digital de uma operadora de telefonia móvel. Escreva como clientes reais "
|
||||
"digitam no chat ou no aplicativo de mensagens. Nunca use nomes de empresas, marcas ou produtos reais, "
|
||||
"nem dados pessoais reais: se precisar de números, use valores claramente fictícios. "
|
||||
"Responda somente com JSON válido."
|
||||
)
|
||||
|
||||
|
||||
def batch_specs(per_batch: int) -> list[dict]:
|
||||
tax = taxonomy.load()
|
||||
specs = []
|
||||
for intent, info in tax["intents"].items():
|
||||
for profile in PROFILES:
|
||||
specs.append({"kind": "intent", "target": intent, "domain": info["domain"], "profile": profile})
|
||||
for oos in tax["out_of_scope"]:
|
||||
for i, profile in enumerate(OOS_PROFILES):
|
||||
specs.append({"kind": "oos", "target": oos, "domain": "fora_de_escopo", "profile": profile, "rep": i})
|
||||
for i, s in enumerate(specs):
|
||||
s["group_id"] = f"g{i:04d}-{s['target']}-{s['profile']}" + (f"-{s['rep']}" if "rep" in s else "")
|
||||
s["generator"] = GENERATORS[i % len(GENERATORS)]
|
||||
s["n"] = per_batch
|
||||
return specs
|
||||
|
||||
|
||||
def prompt(spec: dict) -> str:
|
||||
tax = taxonomy.load()
|
||||
if spec["kind"] == "intent":
|
||||
info = tax["intents"][spec["target"]]
|
||||
others = [k for k in tax["intents"] if k != spec["target"]]
|
||||
rng = random.Random(f"{SEED}-{spec['group_id']}")
|
||||
hints = info["confusable_with"] + rng.sample(others, 3)
|
||||
target = (f"Intenção que TODAS as mensagens devem expressar como pedido principal: "
|
||||
f"`{spec['target']}` — {info['description']}.\n"
|
||||
f"Cuidado para não escrever mensagens que se encaixem melhor nestas outras intenções: "
|
||||
+ "; ".join(f"`{h}` ({tax['intents'][h]['description']})" for h in hints) + ".")
|
||||
if spec["profile"] == "indireto_multiplo":
|
||||
secondary = ("Em metade das mensagens inclua um segundo pedido, de intenção diferente da principal, "
|
||||
"e informe o id em `secundaria` escolhendo entre: " + ", ".join(sorted(tax["intents"]))
|
||||
+ ". Nas demais, `secundaria`: null.")
|
||||
else:
|
||||
secondary = "Cada mensagem deve conter UM ÚNICO pedido, sem pedidos adicionais; `secundaria`: null."
|
||||
else:
|
||||
target = (f"As mensagens NÃO devem ter nenhum pedido de atendimento de telefonia. Categoria: "
|
||||
f"`{spec['target']}` — {tax['out_of_scope'][spec['target']]}.")
|
||||
secondary = "Use `secundaria`: null."
|
||||
return (
|
||||
f"{target}\n\nEstilo do lote: {PROFILES[spec['profile']]}.\n\n"
|
||||
f"Gere {spec['n']} mensagens diferentes entre si (vocabulário, estrutura e situação variados; "
|
||||
f"não repita o mesmo início). {secondary}\n\n"
|
||||
"Formato: lista JSON de objetos com as chaves `texto` (a mensagem), `sentimento` "
|
||||
"(neutro, positivo, frustrado, irritado, ansioso ou indignado) e `secundaria` (id ou null)."
|
||||
)
|
||||
|
||||
|
||||
def run_batch(spec: dict) -> list[dict]:
|
||||
raw = chat(spec["generator"], SYSTEM, prompt(spec), temperature=0.9, max_tokens=4000)
|
||||
items = extract_json(raw)
|
||||
rows = []
|
||||
for j, it in enumerate(items[: spec["n"]]):
|
||||
text = (it.get("texto") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
rows.append({
|
||||
"id": f"{spec['group_id']}-{j:02d}", "group_id": spec["group_id"], "text": text,
|
||||
"gen_label": spec["target"] if spec["kind"] == "intent" else "sem_correspondencia",
|
||||
"gen_target": spec["target"], "kind": spec["kind"], "domain": spec["domain"],
|
||||
"profile": spec["profile"], "sentiment": it.get("sentimento"),
|
||||
"gen_secondary": it.get("secundaria"), "generator": spec["generator"],
|
||||
"taxonomy_version": taxonomy.load()["version"],
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--per-batch", type=int, default=12)
|
||||
ap.add_argument("--workers", type=int, default=8)
|
||||
ap.add_argument("--limit", type=int)
|
||||
args = ap.parse_args()
|
||||
out = Path(args.out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
path = out / "generated.jsonl"
|
||||
done = set()
|
||||
if path.exists():
|
||||
done = {json.loads(l)["group_id"] for l in path.open()}
|
||||
specs = [s for s in batch_specs(args.per_batch) if s["group_id"] not in done][: args.limit]
|
||||
print(f"{len(specs)} lotes a gerar ({len(done)} já prontos)", flush=True)
|
||||
lock = threading.Lock()
|
||||
failures = []
|
||||
with ThreadPoolExecutor(args.workers) as pool, path.open("a") as f:
|
||||
futures = {pool.submit(run_batch, s): s for s in specs}
|
||||
for i, fut in enumerate(as_completed(futures), 1):
|
||||
spec = futures[fut]
|
||||
try:
|
||||
rows = fut.result()
|
||||
except Exception as error: # um lote ruim não derruba a geração; é refeito na próxima rodada
|
||||
failures.append((spec["group_id"], str(error)[:120]))
|
||||
continue
|
||||
with lock:
|
||||
for r in rows:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
f.flush()
|
||||
if i % 20 == 0:
|
||||
print(f"{i}/{len(specs)} lotes", flush=True)
|
||||
print(f"falhas: {len(failures)}", *failures[:10], sep="\n", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
81
datagen/prepare.py
Normal file
81
datagen/prepare.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Mascaramento, deduplicação e critérios de inclusão antes da anotação (tarefas 25 e 28).
|
||||
|
||||
Uso: python -m datagen.prepare --run /data/runs/datagen-v1
|
||||
Entrada: generated.jsonl. Saída: candidates.jsonl + prepare-report.json.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
# Ordem importa: padrões mais longos primeiro.
|
||||
PII_PATTERNS = [
|
||||
("<CARTAO>", re.compile(r"\b\d{4}[ .-]?\d{4}[ .-]?\d{4}[ .-]?\d{4}\b")),
|
||||
("<CNPJ>", re.compile(r"\b\d{2}\.?\d{3}\.?\d{3}/?\d{4}-?\d{2}\b")),
|
||||
("<CPF>", re.compile(r"\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b")),
|
||||
("<EMAIL>", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")),
|
||||
("<TELEFONE>", re.compile(r"(?:\+?55\s?)?\(?\b\d{2}\)?[\s-]?9?\d{4}[\s-]?\d{4}\b")),
|
||||
("<CEP>", re.compile(r"\b\d{5}-\d{3}\b")),
|
||||
]
|
||||
MIN_CHARS, MAX_CHARS = 2, 700
|
||||
NEAR_DUP_JACCARD = 0.85
|
||||
|
||||
|
||||
def mask(text: str) -> tuple[str, list[str]]:
|
||||
found = []
|
||||
for token, pattern in PII_PATTERNS:
|
||||
text, n = pattern.subn(token, text)
|
||||
found += [token] * n
|
||||
return text, found
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
text = unicodedata.normalize("NFKD", text.lower())
|
||||
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||
return re.sub(r"\s+", " ", re.sub(r"[^\w<> ]", " ", text)).strip()
|
||||
|
||||
|
||||
def shingles(text: str, k: int = 5) -> frozenset:
|
||||
return frozenset(text[i:i + k] for i in range(max(1, len(text) - k + 1)))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--run", required=True)
|
||||
args = ap.parse_args()
|
||||
run = Path(args.run)
|
||||
rows = [json.loads(l) for l in (run / "generated.jsonl").open()]
|
||||
report = Counter()
|
||||
kept, seen_exact, kept_shingles = [], set(), []
|
||||
for r in rows:
|
||||
text, found = mask(r["text"])
|
||||
report["pii_masked"] += len(found)
|
||||
if not (MIN_CHARS <= len(text) <= MAX_CHARS):
|
||||
report["excluded_length"] += 1
|
||||
continue
|
||||
norm = normalize(text)
|
||||
if norm in seen_exact:
|
||||
report["excluded_exact_dup"] += 1
|
||||
continue
|
||||
sh = shingles(norm)
|
||||
if any(len(sh & o) / len(sh | o) >= NEAR_DUP_JACCARD for o in kept_shingles
|
||||
if abs(len(o) - len(sh)) <= 0.3 * len(sh)):
|
||||
report["excluded_near_dup"] += 1
|
||||
continue
|
||||
seen_exact.add(norm)
|
||||
kept_shingles.append(sh)
|
||||
kept.append({**r, "text": text, "pii_masked": found})
|
||||
with (run / "candidates.jsonl").open("w") as f:
|
||||
for r in kept:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
report.update(generated=len(rows), kept=len(kept))
|
||||
(run / "prepare-report.json").write_text(json.dumps(dict(report), indent=2) + "\n")
|
||||
print(json.dumps(dict(report)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
40
datagen/taxonomy.py
Normal file
40
datagen/taxonomy.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Leitura da taxonomia e do guia de anotação."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TAXONOMY = ROOT / "data" / "taxonomy" / "intents-v1.yaml"
|
||||
GUIDE = ROOT / "docs" / "06-guia-de-anotacao.md"
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def load() -> dict:
|
||||
raw = yaml.safe_load(TAXONOMY.read_text())
|
||||
intents = {}
|
||||
for domain, items in raw["domains"].items():
|
||||
for it in items:
|
||||
intents[it["id"]] = {"domain": domain, "description": it["description"],
|
||||
"confusable_with": it.get("confusable_with", [])}
|
||||
return {"version": raw["version"], "fallback": raw["fallback"], "intents": intents,
|
||||
"out_of_scope": {o["id"]: o["description"] for o in raw["out_of_scope"]}}
|
||||
|
||||
|
||||
def guide() -> str:
|
||||
text = GUIDE.read_text()
|
||||
return text.split("<!-- GUIA:INICIO -->")[1].split("<!-- GUIA:FIM -->")[0].strip()
|
||||
|
||||
|
||||
def taxonomy_block() -> str:
|
||||
t = load()
|
||||
lines = [f"- `{k}` ({v['domain']}): {v['description']}" for k, v in t["intents"].items()]
|
||||
lines.append(f"- `{t['fallback']['id']}`: {t['fallback']['description']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def labels() -> set[str]:
|
||||
t = load()
|
||||
return set(t["intents"]) | {t["fallback"]["id"]}
|
||||
Reference in New Issue
Block a user