"""Carregamento e validação do banco de itens do benchmark cego.""" from __future__ import annotations import glob import hashlib import json import os import re import unicodedata DOMINIOS = ["juridica", "medica", "proptech", "compliance", "bank", "energia", "negocios"] IDIOMAS = ["pt", "en", "zh"] TIPOS = ["mcq", "open", "calc"] PONTOS = {"mcq": 2, "calc": 3, "open": 5} MAX_PONTOS_DOMINIO = 60 * 2 + 25 * 5 + 15 * 3 # 290 _ID_RE = re.compile(r"^(JUR|MED|PRO|CMP|BNK|ENE|NEG)-(PT|EN|ZH)-\d{3}$") _PREFIXO = { "juridica": "JUR", "medica": "MED", "proptech": "PRO", "compliance": "CMP", "bank": "BNK", "energia": "ENE", "negocios": "NEG", } def _norm(texto: str) -> str: t = unicodedata.normalize("NFKD", texto or "") t = "".join(c for c in t if not unicodedata.combining(c)) t = re.sub(r"[^\w\s]", " ", t.lower()) return re.sub(r"\s+", " ", t).strip() def load_bank(bank_dir: str) -> list[dict]: itens: list[dict] = [] for dominio in DOMINIOS: caminho = os.path.join(bank_dir, f"{dominio}.jsonl") if not os.path.exists(caminho): raise FileNotFoundError(f"arquivo do banco ausente: {caminho}") with open(caminho, "r", encoding="utf-8") as fh: for n, linha in enumerate(fh, 1): linha = linha.strip() if not linha: continue try: item = json.loads(linha) except json.JSONDecodeError as exc: # noqa: TRY003 raise ValueError(f"{caminho}:{n} JSON inválido: {exc}") from exc item["_src"] = f"{caminho}:{n}" itens.append(item) return itens def validate(itens: list[dict]) -> list[str]: """Retorna a lista de erros de schema/consistência (vazia = banco íntegro).""" erros: list[str] = [] vistos: dict[str, str] = {} vistos_texto: dict[str, str] = {} for it in itens: iid = str(it.get("id", "?")) src = it.get("_src", "?") if not _ID_RE.match(iid): erros.append(f"{iid}: id fora do padrão --") if iid in vistos: erros.append(f"{iid}: id duplicado (também em {vistos[iid]})") vistos[iid] = src dom = it.get("domain") if dom not in DOMINIOS: erros.append(f"{iid}: domain inválido ({dom!r})") elif _PREFIXO[dom] != iid.split("-")[0]: erros.append(f"{iid}: prefixo não corresponde ao domínio {dom}") lang = it.get("lang") if lang not in IDIOMAS: erros.append(f"{iid}: lang inválido ({lang!r})") elif iid.split("-")[1].lower() != lang: erros.append(f"{iid}: sufixo de idioma não corresponde a lang={lang}") tipo = it.get("type") if tipo not in TIPOS: erros.append(f"{iid}: type inválido ({tipo!r})") diff = it.get("diff") if not isinstance(diff, int) or not 1 <= diff <= 5: erros.append(f"{iid}: diff deve ser inteiro 1..5 (veio {diff!r})") pts = it.get("pts") if tipo in PONTOS and pts != PONTOS[tipo]: erros.append(f"{iid}: pts={pts!r} incompatível com type={tipo} (esperado {PONTOS[tipo]})") q = it.get("q") minimo = 10 if lang == "zh" else 20 if not isinstance(q, str) or len(q.strip()) < minimo: erros.append(f"{iid}: enunciado ausente ou curto demais") chave = _norm(q or "") if chave and chave in vistos_texto: erros.append(f"{iid}: enunciado duplicado de {vistos_texto[chave]}") vistos_texto[chave] = iid if tipo == "mcq": opts = it.get("opts") if not isinstance(opts, list) or len(opts) != 4: erros.append(f"{iid}: mcq exige 4 alternativas") elif len({_norm(o) for o in opts}) != 4: erros.append(f"{iid}: alternativas repetidas") if not isinstance(it.get("a"), int) or not 0 <= it.get("a", -1) <= 3: erros.append(f"{iid}: gabarito 'a' deve ser 0..3") if not str(it.get("why", "")).strip(): erros.append(f"{iid}: mcq exige justificativa 'why'") elif tipo == "calc": if not isinstance(it.get("val"), (int, float)): erros.append(f"{iid}: calc exige valor numérico 'val'") if not isinstance(it.get("tol"), (int, float)) or it.get("tol", 0) <= 0: erros.append(f"{iid}: calc exige tolerância positiva 'tol'") elif tipo == "open": rub = it.get("rubric") min_crit = 6 if lang == "zh" else 10 if not isinstance(rub, list) or not 3 <= len(rub) <= 5: erros.append(f"{iid}: open exige rubrica com 3 a 5 critérios") elif any(not isinstance(c, str) or len(c.strip()) < min_crit for c in rub): erros.append(f"{iid}: critério de rubrica vazio ou curto") # recortes obrigatórios (art. 10 do regulamento) for dom in DOMINIOS: sub = [i for i in itens if i.get("domain") == dom] if len(sub) != 100: erros.append(f"{dom}: {len(sub)} itens (esperado 100)") for lang in IDIOMAS: n = sum(1 for i in sub if i.get("lang") == lang) if n < 33: erros.append(f"{dom}/{lang}: apenas {n} itens (mínimo 33)") for tipo in TIPOS: n = sum(1 for i in sub if i.get("type") == tipo) if n == 0: erros.append(f"{dom}/{tipo}: nenhum item") graus = {i.get("diff") for i in sub} if len(graus) < 3: erros.append(f"{dom}: apenas {len(graus)} graus de dificuldade distintos (mínimo 3)") avancados = sum(1 for i in sub if isinstance(i.get("diff"), int) and i["diff"] >= 4) if avancados < 5: erros.append(f"{dom}: {avancados} itens em grau 4 ou 5 (mínimo 5)") if len(itens) != 700: erros.append(f"total de itens = {len(itens)} (esperado 700)") return erros def contagens(itens: list[dict]) -> dict: out: dict = {"total": len(itens), "por_dominio": {}, "por_idioma": {}, "por_tipo": {}, "por_dificuldade": {}} for chave, campo in (("por_dominio", "domain"), ("por_idioma", "lang"), ("por_tipo", "type"), ("por_dificuldade", "diff")): acc: dict = {} for it in itens: k = str(it.get(campo)) acc[k] = acc.get(k, 0) + 1 out[chave] = dict(sorted(acc.items())) return out def split_calibracao(itens: list[dict], frac: float, seed: int) -> tuple[list[dict], list[dict]]: """Separa calibração (frac) e prova, estratificado por domínio e idioma.""" grupos: dict[tuple, list[dict]] = {} for it in itens: grupos.setdefault((it.get("domain"), it.get("lang")), []).append(it) calib: list[dict] = [] prova: list[dict] = [] for chave in sorted(grupos): lista = sorted(grupos[chave], key=lambda i: i["id"]) n_cal = max(1, round(len(lista) * frac)) idx = _indices_embaralhados(len(lista), seed, chave) for pos, item in enumerate(lista): (calib if pos < n_cal else prova).append(item) del idx return calib, prova def _indices_embaralhados(n: int, seed: int, chave: tuple) -> list[int]: base = int(hashlib.sha256(f"{seed}|{chave}".encode()).hexdigest()[:16], 16) idx = list(range(n)) # Fisher-Yates determinístico estado = base for i in range(n - 1, 0, -1): estado = (estado * 6364136223846793005 + 1442695040888963407) % (1 << 64) j = estado % (i + 1) idx[i], idx[j] = idx[j], idx[i] return idx def injetar_canarios(itens: list[dict], rodada: str) -> list[dict]: """Insere canário único por item (art. 15) e devolve hash do banco.""" for it in itens: digest = hashlib.sha256(f"{rodada}|{it['id']}|{it.get('q','')}".encode()).hexdigest() it["canary"] = f"CN-{digest[:10].upper()}" return itens def bank_hash(itens: list[dict]) -> str: h = hashlib.sha256() for it in sorted(itens, key=lambda i: i["id"]): h.update(json.dumps(it, sort_keys=True, ensure_ascii=False).encode()) h.update(b"\n") return h.hexdigest() def arquivos_banco(bank_dir: str) -> list[str]: return sorted(glob.glob(os.path.join(bank_dir, "*.jsonl"))) if __name__ == "__main__": import sys alvo = sys.argv[1] if len(sys.argv) > 1 else "banco" dados = load_bank(alvo) problemas = validate(dados) print(json.dumps({"total": len(dados), "contagens": contagens(dados), "erros": problemas}, ensure_ascii=False, indent=2)) sys.exit(1 if problemas else 0)