"""Contrato de saída do Case Forge — anatomia de um caso de ensino + nota de ensino. Fonte da anatomia: diretrizes GVcasos (FGV) + The Case Centre / Emerald. Este módulo é a fonte única da verdade do formato. É usado em dois pontos: 1. Geração sintética — `CASE_SCHEMA` guia o structured output do modelo grande. 2. Gate de qualidade — `validate_case()` rejeita pares malformados antes do treino. Sem dependências obrigatórias. Se `jsonschema` estiver instalado, faz a validação estrutural completa do schema; sempre roda as regras de domínio (as que importam pro gênero: dilema sem resposta vazada, ≤4 objetivos, blocos presentes). """ from __future__ import annotations # --------------------------------------------------------------------------- # JSON Schema — contrato de um par (caso + nota de ensino) para treino/geração # --------------------------------------------------------------------------- CASE_SCHEMA: dict = { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "TeachingCasePair", "type": "object", "required": ["language", "domain", "title", "case", "teaching_note"], "additionalProperties": False, "properties": { "language": {"type": "string", "enum": ["pt", "en"]}, "domain": {"type": "string", "minLength": 2}, # ex.: "estratégia", "marketing", "gestão pública" "title": {"type": "string", "minLength": 4}, # ---- O CASO ------------------------------------------------------- "case": { "type": "object", "required": [ "hook", "protagonist", "decision_point", "context", "data", "alternatives", "closing", ], "additionalProperties": False, "properties": { # Abertura: tempo, lugar, protagonista e o dilema + ponto de decisão. "hook": {"type": "string", "minLength": 40}, # O decisor (quem o leitor "veste"). "protagonist": {"type": "string", "minLength": 2}, # A pergunta concreta que o protagonista precisa decidir. "decision_point": {"type": "string", "minLength": 10}, # Contexto: organização, setor, atores envolvidos. "context": {"type": "string", "minLength": 80}, # Dados/evidência: fatos objetivos, idealmente cada um com fonte. "data": { "type": "array", "minItems": 3, "items": {"type": "string", "minLength": 10}, }, # Exhibits/anexos opcionais (tabelas, quadros). "exhibits": { "type": "array", "items": { "type": "object", "required": ["title", "content"], "properties": { "title": {"type": "string"}, "content": {"type": "string"}, }, }, }, # Argumentos: alternativas para cada caminho de decisão. "alternatives": { "type": "array", "minItems": 2, "items": {"type": "string", "minLength": 15}, }, # Fechamento: revisita o dilema NO ponto de decisão — sem revelar a escolha. "closing": {"type": "string", "minLength": 30}, "references": {"type": "array", "items": {"type": "string"}}, }, }, # ---- A NOTA DE ENSINO -------------------------------------------- "teaching_note": { "type": "object", "required": [ "summary", "audience", "managerial_relevance", "learning_objectives", "theoretical_anchor", "discussion_plan", "discussion_questions", "analysis", "closure", ], "additionalProperties": False, "properties": { "summary": {"type": "string", "minLength": 60}, "audience": {"type": "string", "minLength": 10}, # curso, nível, pré-requisitos "managerial_relevance": {"type": "string", "minLength": 20}, # Objetivos de aprendizagem: ≤4, taxonomia de Bloom, mensuráveis. "learning_objectives": { "type": "array", "minItems": 1, "maxItems": 4, "items": {"type": "string", "minLength": 8}, }, "data_sources": {"type": "string"}, "theoretical_anchor": { "type": "array", "minItems": 1, "items": {"type": "string", "minLength": 3}, }, # Plano de discussão / board plan: blocos com tempo e atividade. "discussion_plan": { "type": "array", "minItems": 2, "items": { "type": "object", "required": ["block", "minutes"], "properties": { "block": {"type": "string"}, "minutes": {"type": "integer", "minimum": 1}, "activity": {"type": "string"}, }, }, }, # Questões de discussão: alinhadas aos objetivos, complexas. "discussion_questions": { "type": "array", "minItems": 2, "items": {"type": "string", "minLength": 10}, }, "analysis": {"type": "string", "minLength": 60}, # respostas esperadas "closure": {"type": "string", "minLength": 20}, # síntese final "epilogue": {"type": "string"}, # opcional: "o que aconteceu depois" "bibliography": {"type": "array", "items": {"type": "string"}}, }, }, }, } # Frases que, se aparecerem no fechamento, sugerem que a DECISÃO vazou. # O caso deve parar no ponto de decisão — o epílogo (na nota) é o lugar do desfecho. _DECISION_LEAK_PT = ( "decidiu", "optou por", "escolheu", "acabou ", "no fim das contas", "ao final, ", "resolveu ", "a empresa então ", ) _DECISION_LEAK_EN = ( "decided to", "chose to", "ended up", "in the end", "ultimately decided", "opted to", "the company then", ) def validate_case(obj: dict) -> tuple[bool, list[str], list[str]]: """Valida um par caso+nota contra o contrato. Retorna (ok, errors, warnings). `ok` é False se houver qualquer error. Warnings não bloqueiam, mas sinalizam risco de qualidade do gênero. """ errors: list[str] = [] warnings: list[str] = [] # 1) Validação estrutural completa via jsonschema, se disponível. try: import jsonschema # type: ignore validator = jsonschema.Draft202012Validator(CASE_SCHEMA) for err in sorted(validator.iter_errors(obj), key=str): loc = "/".join(str(p) for p in err.path) or "" errors.append(f"schema[{loc}]: {err.message}") except ModuleNotFoundError: # Fallback mínimo: só checa presença dos blocos de topo. for key in ("language", "domain", "title", "case", "teaching_note"): if not obj.get(key): errors.append(f"schema: campo obrigatório ausente/vazio: {key}") warnings.append("jsonschema não instalado — validação estrutural parcial.") case = obj.get("case") or {} note = obj.get("teaching_note") or {} # 2) Regras de domínio do gênero (o que o schema sozinho não pega). # 2a) Dilema sem resposta vazada no fechamento do caso. lang = obj.get("language", "pt") closing = (case.get("closing") or "").lower() leaks = _DECISION_LEAK_EN if lang == "en" else _DECISION_LEAK_PT hit = next((p for p in leaks if p in closing), None) if hit: warnings.append( f"fechamento pode revelar a decisão (encontrado: {hit!r}); " "o caso deve parar no ponto de decisão — o desfecho vai no epílogo da nota." ) # 2b) ≤4 objetivos (hard) — reforço além do schema p/ o caso sem jsonschema. objs = note.get("learning_objectives") or [] if len(objs) > 4: errors.append(f"nota: {len(objs)} objetivos de aprendizagem (>4).") if not objs: errors.append("nota: nenhum objetivo de aprendizagem.") # 2c) Alinhamento questões ↔ objetivos (soft). qs = note.get("discussion_questions") or [] if objs and len(qs) < len(objs): warnings.append( f"menos questões de discussão ({len(qs)}) que objetivos ({len(objs)}); " "idealmente cada objetivo é coberto por ≥1 questão." ) # 2d) Fontes FABRICADAS (soft) — o gênero NÃO deve inventar citações com # nome de relatório/instituto/ano. Sinalizamos pra o auditor limpar. (Antes # premiávamos "dado com fonte", o que incentivava o modelo a fabricar — invertido.) data = case.get("data") or [] if any(_looks_fabricated_source(d) for d in data): warnings.append( "algum dado parece citar uma FONTE FABRICADA (ex.: 'Fonte: Relatório …, 2023'); " "números são ilustrativos — remova citações inventadas." ) return (len(errors) == 0, errors, warnings) def _looks_fabricated_source(text: str) -> bool: """Heurística: o dado cita uma 'fonte' com cara de inventada (rótulo + ano).""" import re t = text.lower() has_cite = ("fonte" in t or "source" in t or "relatório" in t or "report" in t or "pesquisa de mercado" in t or "según" in t) has_year = bool(re.search(r"\b(19|20)\d{2}\b", t)) return has_cite and has_year __all__ = ["CASE_SCHEMA", "validate_case"]