case-forge / data /schema.py
nextmarte's picture
Modal-only inference: app calls deployed case-forge-serve (v3); drop GPU libs; CPU Space
ab4e510 verified
Raw
History Blame Contribute Delete
10.1 kB
"""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"]