Spaces:
Running on Zero
Running on Zero
| """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 "<root>" | |
| 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"] | |