| """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 |
|
|
| |
| |
| |
|
|
| 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}, |
| "title": {"type": "string", "minLength": 4}, |
| |
| "case": { |
| "type": "object", |
| "required": [ |
| "hook", "protagonist", "decision_point", |
| "context", "data", "alternatives", "closing", |
| ], |
| "additionalProperties": False, |
| "properties": { |
| |
| "hook": {"type": "string", "minLength": 40}, |
| |
| "protagonist": {"type": "string", "minLength": 2}, |
| |
| "decision_point": {"type": "string", "minLength": 10}, |
| |
| "context": {"type": "string", "minLength": 80}, |
| |
| "data": { |
| "type": "array", |
| "minItems": 3, |
| "items": {"type": "string", "minLength": 10}, |
| }, |
| |
| "exhibits": { |
| "type": "array", |
| "items": { |
| "type": "object", |
| "required": ["title", "content"], |
| "properties": { |
| "title": {"type": "string"}, |
| "content": {"type": "string"}, |
| }, |
| }, |
| }, |
| |
| "alternatives": { |
| "type": "array", |
| "minItems": 2, |
| "items": {"type": "string", "minLength": 15}, |
| }, |
| |
| "closing": {"type": "string", "minLength": 30}, |
| "references": {"type": "array", "items": {"type": "string"}}, |
| }, |
| }, |
| |
| "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}, |
| "managerial_relevance": {"type": "string", "minLength": 20}, |
| |
| "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}, |
| }, |
| |
| "discussion_plan": { |
| "type": "array", |
| "minItems": 2, |
| "items": { |
| "type": "object", |
| "required": ["block", "minutes"], |
| "properties": { |
| "block": {"type": "string"}, |
| "minutes": {"type": "integer", "minimum": 1}, |
| "activity": {"type": "string"}, |
| }, |
| }, |
| }, |
| |
| "discussion_questions": { |
| "type": "array", |
| "minItems": 2, |
| "items": {"type": "string", "minLength": 10}, |
| }, |
| "analysis": {"type": "string", "minLength": 60}, |
| "closure": {"type": "string", "minLength": 20}, |
| "epilogue": {"type": "string"}, |
| "bibliography": {"type": "array", "items": {"type": "string"}}, |
| }, |
| }, |
| }, |
| } |
|
|
| |
| |
| _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] = [] |
|
|
| |
| try: |
| import jsonschema |
|
|
| 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: |
| |
| 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 {} |
|
|
| |
|
|
| |
| 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." |
| ) |
|
|
| |
| 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.") |
|
|
| |
| 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." |
| ) |
|
|
| |
| |
| |
| 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"] |
|
|