freoa-verifica 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. freoa_verifica/__init__.py +5 -0
  2. freoa_verifica/acervo.py +161 -0
  3. freoa_verifica/aliases.py +150 -0
  4. freoa_verifica/authority.py +213 -0
  5. freoa_verifica/bateria.py +333 -0
  6. freoa_verifica/bench.py +275 -0
  7. freoa_verifica/captura.py +402 -0
  8. freoa_verifica/citation.py +1253 -0
  9. freoa_verifica/cli.py +423 -0
  10. freoa_verifica/connectors/__init__.py +21 -0
  11. freoa_verifica/connectors/base.py +213 -0
  12. freoa_verifica/connectors/bcb.py +296 -0
  13. freoa_verifica/connectors/challenge.py +196 -0
  14. freoa_verifica/connectors/cvm_colegiado.py +427 -0
  15. freoa_verifica/connectors/cvm_normas.py +618 -0
  16. freoa_verifica/connectors/datajud.py +283 -0
  17. freoa_verifica/connectors/guarda.py +159 -0
  18. freoa_verifica/connectors/httputil.py +219 -0
  19. freoa_verifica/connectors/planalto.py +316 -0
  20. freoa_verifica/connectors/planalto_urls.json +148 -0
  21. freoa_verifica/connectors/senado.py +339 -0
  22. freoa_verifica/connectors/stf.py +430 -0
  23. freoa_verifica/connectors/stj.py +811 -0
  24. freoa_verifica/connectors/tcu.py +311 -0
  25. freoa_verifica/connectors/tjdft.py +402 -0
  26. freoa_verifica/connectors/tjsp.py +353 -0
  27. freoa_verifica/contrato.py +23 -0
  28. freoa_verifica/courts.py +22 -0
  29. freoa_verifica/data/corpus_semente.json +79 -0
  30. freoa_verifica/data/locucoes-juridicas-1.json +16 -0
  31. freoa_verifica/data/snapshots-oficiais.json +20 -0
  32. freoa_verifica/data/tesauro-1.json +26 -0
  33. freoa_verifica/data/timbres-cvm.json +8 -0
  34. freoa_verifica/derivados.py +319 -0
  35. freoa_verifica/fronteira.py +81 -0
  36. freoa_verifica/identidade_http.py +38 -0
  37. freoa_verifica/identity.py +91 -0
  38. freoa_verifica/ids.py +25 -0
  39. freoa_verifica/limites.py +73 -0
  40. freoa_verifica/mcp_server.py +458 -0
  41. freoa_verifica/parsers/__init__.py +27 -0
  42. freoa_verifica/parsers/bcb_json.py +90 -0
  43. freoa_verifica/parsers/cvm_docx.py +762 -0
  44. freoa_verifica/parsers/html_devices.py +1285 -0
  45. freoa_verifica/parsers/isolated.py +269 -0
  46. freoa_verifica/parsers/model.py +195 -0
  47. freoa_verifica/parsers/paths.py +392 -0
  48. freoa_verifica/parsers/pdf_dispositivos.py +279 -0
  49. freoa_verifica/parsers/planalto.py +89 -0
  50. freoa_verifica/parsers/sentinel.py +269 -0
  51. freoa_verifica/registro.py +244 -0
  52. freoa_verifica/rest.py +337 -0
  53. freoa_verifica/rotulos.py +42 -0
  54. freoa_verifica/sanitizacao.py +113 -0
  55. freoa_verifica/search.py +146 -0
  56. freoa_verifica/semente.py +1070 -0
  57. freoa_verifica/service.py +3371 -0
  58. freoa_verifica/situacao.py +459 -0
  59. freoa_verifica/snapshot.py +1151 -0
  60. freoa_verifica/store/__init__.py +6 -0
  61. freoa_verifica/store/blobs.py +98 -0
  62. freoa_verifica/store/db.py +142 -0
  63. freoa_verifica/store/migrations/0001_inicial.sql +251 -0
  64. freoa_verifica/store/migrations/0002_parsed_document.sql +19 -0
  65. freoa_verifica/store/migrations/0003_sync_state.sql +15 -0
  66. freoa_verifica/store/migrations/0004_parse_identity.sql +14 -0
  67. freoa_verifica/store/migrations/005_work_year.sql +6 -0
  68. freoa_verifica/store/migrations/006_rubrica_fts.sql +14 -0
  69. freoa_verifica/store/migrations/007_work_status.sql +11 -0
  70. freoa_verifica/store/migrations/008_fts_staging.sql +7 -0
  71. freoa_verifica/store/migrations/009_meta.sql +5 -0
  72. freoa_verifica/store/migrations/010_candidato_ao_vivo.sql +4 -0
  73. freoa_verifica/store/migrations/011_candidato_retroativo.sql +6 -0
  74. freoa_verifica/store/migrations/012_candidato_por_documento.py +27 -0
  75. freoa_verifica/store/migrations/013_origem_nao_publicada.py +7 -0
  76. freoa_verifica/store/migrations/014_interpretacao_da_captura.py +15 -0
  77. freoa_verifica/store/migrations/015_interpretacao_legada.py +15 -0
  78. freoa_verifica/store/migrations/016_conferencia_legada.py +11 -0
  79. freoa_verifica/store/migrations/__init__.py +1 -0
  80. freoa_verifica/store/repo.py +817 -0
  81. freoa_verifica/temporal.py +649 -0
  82. freoa_verifica/textcmp.py +253 -0
  83. freoa_verifica/texto_resposta.py +578 -0
  84. freoa_verifica/transport.py +1459 -0
  85. freoa_verifica/trava.py +130 -0
  86. freoa_verifica/verify.py +809 -0
  87. freoa_verifica-1.3.0.dist-info/METADATA +153 -0
  88. freoa_verifica-1.3.0.dist-info/RECORD +92 -0
  89. freoa_verifica-1.3.0.dist-info/WHEEL +5 -0
  90. freoa_verifica-1.3.0.dist-info/entry_points.txt +3 -0
  91. freoa_verifica-1.3.0.dist-info/licenses/LICENSE +662 -0
  92. freoa_verifica-1.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """FREOA Verifica — verificação de citações jurídicas contra fontes oficiais."""
2
+
3
+ __version__ = "1.3.0"
4
+
5
+ from freoa_verifica.contrato import CONTRATO_VERSAO # noqa: E402,F401
@@ -0,0 +1,161 @@
1
+ """Acervo efetivo (contrato C7): manifesto do snapshot x conteúdo do home."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import sqlite3
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ MANIFESTO_LOCAL = "acervo-manifesto.json"
13
+ # Mínimo embutido na versão, usado quando nenhum snapshot foi instalado.
14
+ # shortcut: valor-guarda (home vazio alerta); fixar com os totais do snapshot
15
+ # publicado a cada release.
16
+ MINIMO_EMBUTIDO = {"obras": 1, "decisoes": 0}
17
+
18
+ _OBRAS_SQL = "SELECT authority||':'||kind||':'||number||'/'||year FROM work"
19
+ _DECISOES_SQL = (
20
+ "SELECT court||'|'||COALESCE(class,'')||'|'||COALESCE(number,'')||'|'||"
21
+ "COALESCE(judgment_date,'')||'|'||kind FROM decision"
22
+ )
23
+
24
+
25
+ def _ids(conn: sqlite3.Connection, sql: str) -> list[str]:
26
+ return sorted(r[0] for r in conn.execute(sql).fetchall())
27
+
28
+
29
+ def _digest(ids: list[str]) -> str:
30
+ return hashlib.sha256("\n".join(ids).encode("utf-8")).hexdigest()
31
+
32
+
33
+ def derived_version(conn: sqlite3.Connection) -> str | None:
34
+ try:
35
+ row = conn.execute("SELECT value FROM meta WHERE key='derived_version'").fetchone()
36
+ except sqlite3.Error:
37
+ return None
38
+ return str(row[0]) if row else None
39
+
40
+
41
+ def manifest_fields(conn: sqlite3.Connection, db_sha256: str | None = None) -> dict[str, Any]:
42
+ """Campos do manifesto publicados pelo snapshot (obras, decisões, derivados, hashes)."""
43
+ obras, decisoes = _ids(conn, _OBRAS_SQL), _ids(conn, _DECISOES_SQL)
44
+ return {
45
+ "obras": len(obras),
46
+ "decisoes": len(decisoes),
47
+ "derived_version": derived_version(conn),
48
+ "obras_ids": obras,
49
+ "obras_ids_sha256": _digest(obras),
50
+ "decisoes_ids_sha256": _digest(decisoes),
51
+ "db_sha256": db_sha256,
52
+ }
53
+
54
+
55
+ def save_installed(home: Path, manifest: dict[str, Any]) -> None:
56
+ keys = ("obras", "decisoes", "derived_version", "obras_ids", "obras_ids_sha256",
57
+ "decisoes_ids_sha256", "db_sha256", "vinculado", "snapshot_sha256", "snapshot_derived_version",
58
+ "vinculo_motivo")
59
+ (Path(home) / MANIFESTO_LOCAL).write_text(
60
+ json.dumps({k: manifest.get(k) for k in keys}, ensure_ascii=False, sort_keys=True),
61
+ encoding="utf-8",
62
+ )
63
+
64
+
65
+ def status(conn: sqlite3.Connection, home: Path) -> dict[str, Any] | None:
66
+ """None se o acervo atende ao manifesto instalado (ou ao mínimo); senão o aviso."""
67
+ path = Path(home) / MANIFESTO_LOCAL
68
+ try:
69
+ ref = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
70
+ except (OSError, ValueError):
71
+ ref = None
72
+ origem = "snapshot_instalado" if ref else "minimo_embutido"
73
+ ref = ref or MINIMO_EMBUTIDO
74
+ obras, decisoes = _ids(conn, _OBRAS_SQL), _ids(conn, _DECISOES_SQL)
75
+ motivos: list[str] = []
76
+ if len(obras) < int(ref.get("obras") or 0):
77
+ motivos.append("obras_abaixo_do_manifesto")
78
+ if len(decisoes) < int(ref.get("decisoes") or 0):
79
+ motivos.append("decisoes_abaixo_do_manifesto")
80
+ if ref.get("obras_ids") is not None:
81
+ if set(ref["obras_ids"]) - set(obras):
82
+ motivos.append("obras_ausentes_do_manifesto")
83
+ elif ref.get("obras_ids_sha256") and len(obras) == ref.get("obras") and _digest(obras) != ref["obras_ids_sha256"]:
84
+ motivos.append("obras_ids_divergentes")
85
+ if (ref.get("decisoes_ids_sha256") and len(decisoes) == ref.get("decisoes")
86
+ and _digest(decisoes) != ref["decisoes_ids_sha256"]):
87
+ motivos.append("decisoes_ids_divergentes")
88
+ dv = ref.get("derived_version")
89
+ if dv is not None and derived_version(conn) != str(dv):
90
+ motivos.append("derived_version_divergente")
91
+ from freoa_verifica.derivados import compativel
92
+
93
+ if not compativel(conn):
94
+ motivos.append("derivados_desatualizados")
95
+ from freoa_verifica.derivados import REASON_NAO_PUBLICADA, origem_nao_publicada
96
+
97
+ if origem_nao_publicada(conn):
98
+ motivos.append(REASON_NAO_PUBLICADA)
99
+ if not motivos:
100
+ return None
101
+ return {
102
+ "codigo": "acervo_incompleto",
103
+ "referencia": origem,
104
+ "motivos": motivos,
105
+ "obras": len(obras),
106
+ "decisoes": len(decisoes),
107
+ }
108
+
109
+
110
+ def verificar_digest_instalado(provenance: dict[str, Any]) -> dict[str, Any]:
111
+ """Compara o digest do snapshot instalado com o da lista embutida para esta versão do pacote."""
112
+ from freoa_verifica import __version__
113
+ from freoa_verifica.snapshot import _da_versao_instalada, _snapshots_oficiais
114
+
115
+ digest = provenance.get("snapshot_sha256")
116
+ esperados = [e["sha256"] for e in _snapshots_oficiais() if _da_versao_instalada(e)]
117
+ detalhe = {"versao": __version__, "instalado": digest, "esperados": esperados}
118
+ if not digest:
119
+ return {"nome": "snapshot_digest", "status": "alerta", "detalhe": {**detalhe, "motivo": "sem snapshot instalado"}}
120
+ ok = digest in esperados
121
+ return {"nome": "snapshot_digest", "status": "ok" if ok else "alerta",
122
+ "detalhe": {**detalhe, "confere": ok}}
123
+
124
+
125
+ def attestation_instalacao(provenance: dict[str, Any] | None = None) -> dict[str, Any]:
126
+ """Registro do instalador (instalacao.json) confrontado com esta instalação.
127
+
128
+ `ok` exige instalação concluída, versão igual à do pacote, attestation verificada e, havendo snapshot
129
+ instalado, o mesmo digest gravado pelo instalador. Ausente ou divergente é `alerta`.
130
+ """
131
+ from freoa_verifica import __version__
132
+
133
+ base = os.environ.get("LOCALAPPDATA") or str(Path.home() / ".local" / "share")
134
+ try:
135
+ data = json.loads((Path(base) / "freoa-verifica" / "instalacao.json").read_text(encoding="utf-8-sig"))
136
+ if not isinstance(data, dict):
137
+ raise ValueError
138
+ except (OSError, ValueError):
139
+ return {"nome": "attestation", "status": "alerta",
140
+ "detalhe": {"attestation_verificada": None, "motivos": ["sem_registro_do_instalador"]}}
141
+ motivos = []
142
+ if data.get("concluida") is not True:
143
+ motivos.append("instalacao_nao_concluida")
144
+ if data.get("versao") != __version__:
145
+ motivos.append("versao_divergente")
146
+ if data.get("attestation_verificada") is not True:
147
+ motivos.append("attestation_nao_verificada")
148
+ instalado = (provenance or {}).get("snapshot_sha256")
149
+ if provenance is not None and instalado and data.get("digest_snapshot") != instalado:
150
+ motivos.append("digest_snapshot_divergente")
151
+ detalhe = {k: data.get(k) for k in ("attestation_verificada", "versao", "concluida", "sha256_wheel", "digest_snapshot")}
152
+ return {"nome": "attestation", "status": "alerta" if motivos else "ok",
153
+ "detalhe": {**detalhe, "versao_instalada": __version__, "motivos": motivos}}
154
+
155
+
156
+ def installed_provenance(home: Path) -> dict[str, Any]:
157
+ try:
158
+ data = json.loads((Path(home) / MANIFESTO_LOCAL).read_text(encoding="utf-8"))
159
+ return {k: data.get(k) for k in ("vinculado", "snapshot_sha256", "snapshot_derived_version", "vinculo_motivo")}
160
+ except (OSError, ValueError, AttributeError):
161
+ return {"vinculado": False}
@@ -0,0 +1,150 @@
1
+ """Apelidos estáveis de obras normativas brasileiras → WorkKey."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class WorkKey:
10
+ """Chave de obra normativa (autoridade + espécie + número + ano)."""
11
+
12
+ authority: str # federal|cvm|cmn|bcb|stf|stj
13
+ kind: str
14
+ number: str
15
+ year: int
16
+
17
+
18
+ # Apelidos canônicos (chave = fold_alias; valor = WorkKey).
19
+ _LSA = WorkKey("federal", "lei", "6404", 1976)
20
+ _CVM_LEI = WorkKey("federal", "lei", "6385", 1976)
21
+ _CC = WorkKey("federal", "lei", "10406", 2002)
22
+ _CPC = WorkKey("federal", "lei", "13105", 2015)
23
+ _CDC = WorkKey("federal", "lei", "8078", 1990)
24
+ _LRF_FALENCIAS = WorkKey("federal", "lei", "11101", 2005)
25
+ _LRF_RESPONSABILIDADE = WorkKey("federal", "lei_complementar", "101", 2000)
26
+ _CF = WorkKey("federal", "constituicao", "1988", 1988)
27
+
28
+ # LRF é ambíguo: Lei 11.101/2005 (falências) vs LC 101/2000 (responsabilidade fiscal).
29
+ LRF_CANDIDATES: tuple[WorkKey, WorkKey] = (_LRF_FALENCIAS, _LRF_RESPONSABILIDADE)
30
+
31
+ # Apelidos curtos que exigem contexto normativo (art., §, inciso, do/da).
32
+ CONTEXT_REQUIRED_ALIASES: frozenset[str] = frozenset({"cc", "cf"})
33
+
34
+ ALIASES: dict[str, WorkKey] = {
35
+ "lei das s.a.": _LSA,
36
+ "lei das s.a": _LSA,
37
+ "lei das sa": _LSA,
38
+ "lsa": _LSA,
39
+ "lei das sociedades por acoes": _LSA,
40
+ "lei do mercado de capitais": _CVM_LEI,
41
+ "lei da cvm": _CVM_LEI,
42
+ "codigo civil": _CC,
43
+ "cc": _CC,
44
+ "cpc": _CPC,
45
+ "codigo de processo civil": _CPC,
46
+ "cdc": _CDC,
47
+ "codigo de defesa do consumidor": _CDC,
48
+ "lei de falencias": _LRF_FALENCIAS,
49
+ # "lrf" tratado à parte (ambíguo) — não entra em ALIASES unívoco
50
+ "lei anticorrupcao": WorkKey("federal", "lei", "12846", 2013),
51
+ "lgpd": WorkKey("federal", "lei", "13709", 2018),
52
+ "marco da securitizacao": WorkKey("federal", "lei", "14430", 2022),
53
+ "lei de arbitragem": WorkKey("federal", "lei", "9307", 1996),
54
+ "constituicao": _CF,
55
+ "cf": _CF,
56
+ "cf/88": _CF,
57
+ "adct": _CF,
58
+ "ato das disposicoes constitucionais transitorias": _CF,
59
+ "constituicao federal": _CF,
60
+ }
61
+
62
+ # Formas de superfície (com e sem acento) para regex de extração.
63
+ ALIAS_SURFACES: list[tuple[str, WorkKey]] = [
64
+ ("Lei das Sociedades por Ações", _LSA),
65
+ ("Lei das Sociedades por Acoes", _LSA),
66
+ ("Lei das S.A.", _LSA),
67
+ ("Lei das S.A", _LSA),
68
+ ("LSA", _LSA),
69
+ ("Lei do Mercado de Capitais", _CVM_LEI),
70
+ ("Lei da CVM", _CVM_LEI),
71
+ ("Código Civil", _CC),
72
+ ("Codigo Civil", _CC),
73
+ ("CC", _CC),
74
+ ("Código de Processo Civil", _CPC),
75
+ ("Codigo de Processo Civil", _CPC),
76
+ ("CPC", _CPC),
77
+ ("Código de Defesa do Consumidor", _CDC),
78
+ ("Codigo de Defesa do Consumidor", _CDC),
79
+ ("CDC", _CDC),
80
+ ("Lei de Falências", _LRF_FALENCIAS),
81
+ ("Lei de Falencias", _LRF_FALENCIAS),
82
+ ("Lei de Recuperação e Falências", _LRF_FALENCIAS),
83
+ ("Lei de Recuperacao e Falencias", _LRF_FALENCIAS),
84
+ ("Lei de Recuperação Judicial e Falências", _LRF_FALENCIAS),
85
+ ("Lei de Recuperacao Judicial e Falencias", _LRF_FALENCIAS),
86
+ ("Lei de Recuperação de Empresas e Falências", _LRF_FALENCIAS),
87
+ ("LRF", _LRF_FALENCIAS), # ambiguidade resolvida em citation.extract
88
+ ("Lei Anticorrupção", WorkKey("federal", "lei", "12846", 2013)),
89
+ ("Lei Anticorrupcao", WorkKey("federal", "lei", "12846", 2013)),
90
+ ("LGPD", WorkKey("federal", "lei", "13709", 2018)),
91
+ ("Marco da Securitização", WorkKey("federal", "lei", "14430", 2022)),
92
+ ("Marco da Securitizacao", WorkKey("federal", "lei", "14430", 2022)),
93
+ ("Lei de Arbitragem", WorkKey("federal", "lei", "9307", 1996)),
94
+ ("Constituição Federal", _CF),
95
+ ("Constituicao Federal", _CF),
96
+ ("Constituição", _CF),
97
+ ("Constituicao", _CF),
98
+ ("Ato das Disposições Constitucionais Transitórias", _CF),
99
+ ("Ato das Disposicoes Constitucionais Transitorias", _CF),
100
+ ("ADCT", _CF),
101
+ ("CF/88", _CF),
102
+ ("CF", _CF),
103
+ ]
104
+
105
+
106
+ def fold_alias(text: str) -> str:
107
+ """Normaliza texto para busca em ALIASES (minúsculas, sem diacríticos)."""
108
+ import unicodedata
109
+
110
+ nfkd = unicodedata.normalize("NFKD", text)
111
+ bare = "".join(c for c in nfkd if not unicodedata.combining(c))
112
+ return " ".join(bare.lower().split())
113
+
114
+
115
+ def lookup_alias(text: str) -> WorkKey | None:
116
+ key = fold_alias(text)
117
+ if key == "lrf":
118
+ return None # ambíguo — ver lookup_lrf / citation
119
+ return ALIASES.get(key)
120
+
121
+
122
+ def lookup_lrf(text: str, surrounding: str | None = None) -> list[WorkKey]:
123
+ """Resolve LRF: se o entorno explicita a lei, devolve só ela; senão ambas."""
124
+ ctx = surrounding or text
125
+ folded = fold_alias(ctx)
126
+ # LC 101 / Lei Complementar 101 / responsabilidade fiscal
127
+ if re_search_lc101(folded) or "responsabilidade fiscal" in folded:
128
+ return [_LRF_RESPONSABILIDADE]
129
+ # Lei 11.101 / falências
130
+ if (
131
+ "11.101" in ctx
132
+ or "11101" in folded.replace(".", "")
133
+ or "falenc" in folded
134
+ or "recuperacao judicial" in folded
135
+ or "recuperação judicial" in fold_alias(ctx)
136
+ ):
137
+ return [_LRF_FALENCIAS]
138
+ return list(LRF_CANDIDATES)
139
+
140
+
141
+ def re_search_lc101(folded: str) -> bool:
142
+ import re
143
+
144
+ return bool(
145
+ re.search(
146
+ r"\b(?:lc|lei\s+complementar)\s*(?:n[º°o.]?\s*)?101\b",
147
+ folded,
148
+ re.I,
149
+ )
150
+ )
@@ -0,0 +1,213 @@
1
+ """Atributos verificáveis de autoridade e rótulo A–E (regra pública)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ RULES_VERSION = "autoridade-1"
9
+
10
+ # Espécies reconhecidas (forma canônica).
11
+ SPECIES = frozenset(
12
+ {
13
+ "sumula_vinculante",
14
+ "controle_concentrado",
15
+ "repercussao_geral",
16
+ "repetitivo",
17
+ "irdr_iac",
18
+ "sumula",
19
+ "orgao_cupula",
20
+ "acordao",
21
+ "monocratica",
22
+ "administrativa",
23
+ "informativo",
24
+ }
25
+ )
26
+
27
+ # Órgãos de cúpula / colegiados superiores (rótulo C) — só judiciais.
28
+ CUPULA_ORGANS = frozenset(
29
+ {
30
+ "pleno",
31
+ "plenario",
32
+ "tribunal pleno",
33
+ "corte especial",
34
+ "secao",
35
+ "primeira secao",
36
+ "segunda secao",
37
+ "terceira secao",
38
+ "orgao especial",
39
+ }
40
+ )
41
+
42
+ CONTROLE_CLASSES = frozenset({"adi", "adc", "adpf", "ado"})
43
+
44
+ # Cortes administrativas: nunca promovidas por órgão de cúpula.
45
+ _ADMIN_COURTS = frozenset({"CVM", "TCU", "BCB", "CMN"})
46
+
47
+ _SPECIES_ALIASES: dict[str, str] = {
48
+ "acordao": "acordao",
49
+ "acordaos": "acordao",
50
+ "monocratica": "monocratica",
51
+ "decisao monocratica": "monocratica",
52
+ "decisoes monocraticas": "monocratica",
53
+ "administrativa": "administrativa",
54
+ "controle concentrado": "controle_concentrado",
55
+ "controle_concentrado": "controle_concentrado",
56
+ "repercussao geral": "repercussao_geral",
57
+ "repercussao_geral": "repercussao_geral",
58
+ "repetitivo": "repetitivo",
59
+ "tema repetitivo": "repetitivo",
60
+ "irdr": "irdr_iac",
61
+ "iac": "irdr_iac",
62
+ "irdr_iac": "irdr_iac",
63
+ "irdr/iac": "irdr_iac",
64
+ "sumula": "sumula",
65
+ "sumula vinculante": "sumula_vinculante",
66
+ "sumula_vinculante": "sumula_vinculante",
67
+ "orgao_cupula": "orgao_cupula",
68
+ "informativo": "informativo",
69
+ }
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class AuthorityAttributes:
74
+ species: str
75
+ court: str
76
+ organ: str | None = None
77
+ class_name: str | None = None
78
+
79
+ def __post_init__(self) -> None:
80
+ if self.species not in SPECIES:
81
+ raise ValueError(f"espécie desconhecida: {self.species}")
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class AuthorityLabel:
86
+ label: str # A..E
87
+ rule: str
88
+ attributes: AuthorityAttributes
89
+ rules_version: str = RULES_VERSION
90
+
91
+
92
+ def normalize_species(species: str) -> str:
93
+ """Normaliza espécie (acórdão/ACÓRDÃO/Acordao → acordao)."""
94
+ key = _norm(species)
95
+ if key in _SPECIES_ALIASES:
96
+ return _SPECIES_ALIASES[key]
97
+ # remove underscores duplicados / espaços já tratados por _norm
98
+ compact = key.replace(" ", "_")
99
+ if compact in SPECIES:
100
+ return compact
101
+ if compact in _SPECIES_ALIASES:
102
+ return _SPECIES_ALIASES[compact]
103
+ raise ValueError(f"espécie desconhecida: {species}")
104
+
105
+
106
+ def classify_authority(
107
+ *,
108
+ species: str,
109
+ court: str,
110
+ organ: str | None = None,
111
+ class_name: str | None = None,
112
+ ) -> AuthorityLabel:
113
+ """Deriva rótulo A–E e devolve sempre atributos + regra aplicada."""
114
+ species_n = normalize_species(species)
115
+ # Preserva grafia comprovada do órgão (ex.: Plenário), só normaliza internamente.
116
+ attrs = AuthorityAttributes(
117
+ species=species_n,
118
+ court=(court or "").strip().upper(),
119
+ organ=organ,
120
+ class_name=class_name,
121
+ )
122
+ label, rule = _derive(attrs)
123
+ return AuthorityLabel(label=label, rule=rule, attributes=attrs)
124
+
125
+
126
+ def _derive(attrs: AuthorityAttributes) -> tuple[str, str]:
127
+ species = attrs.species
128
+ court = attrs.court
129
+ organ_norm = _norm(attrs.organ)
130
+ class_norm = _norm(attrs.class_name)
131
+
132
+ # Exclusões antes de qualquer promoção (monocrática / administrativa).
133
+ if species == "monocratica":
134
+ return "E", "especie=monocratica"
135
+ if species == "administrativa" or court in _ADMIN_COURTS:
136
+ return "E", "administrativa_ou_corte_administrativa"
137
+
138
+ # A — súmula vinculante; ADI/ADC/ADPF colegiados; súmulas STF/STJ
139
+ if species == "sumula_vinculante":
140
+ return "A", "especie=sumula_vinculante"
141
+ if species == "controle_concentrado" or class_norm in CONTROLE_CLASSES:
142
+ return "A", "controle_concentrado_ou_classe_adi_adc_adpf"
143
+ if species == "sumula" and court in {"STF", "STJ"}:
144
+ return "A", f"sumula_{court.lower()}"
145
+
146
+ # B — tema RG, repetitivo, IRDR/IAC
147
+ if species == "repercussao_geral":
148
+ return "B", "especie=repercussao_geral"
149
+ if species == "repetitivo":
150
+ return "B", "especie=repetitivo"
151
+ if species == "irdr_iac":
152
+ return "B", "especie=irdr_iac"
153
+
154
+ # C — Pleno / Corte Especial / Seções / Órgão Especial (judiciais)
155
+ if species == "orgao_cupula" or _is_cupula(organ_norm):
156
+ return "C", "orgao_cupula_ou_colegiado_superior"
157
+
158
+ # D — acórdão de turma/câmara (padrão residual de julgamento colegiado)
159
+ if species in {"acordao", "informativo"}:
160
+ return "D", "acordao_turma_camara_ou_equivalente"
161
+
162
+ return "E", "residual_especie_" + species
163
+
164
+
165
+ def _norm(value: str | None) -> str:
166
+ if not value:
167
+ return ""
168
+ return (
169
+ value.strip()
170
+ .lower()
171
+ .replace("á", "a")
172
+ .replace("ã", "a")
173
+ .replace("à", "a")
174
+ .replace("é", "e")
175
+ .replace("ê", "e")
176
+ .replace("í", "i")
177
+ .replace("ó", "o")
178
+ .replace("ô", "o")
179
+ .replace("õ", "o")
180
+ .replace("ú", "u")
181
+ .replace("ç", "c")
182
+ )
183
+
184
+
185
+ def _is_cupula(organ_norm: str) -> bool:
186
+ if not organ_norm:
187
+ return False
188
+ if organ_norm in CUPULA_ORGANS:
189
+ return True
190
+ return any(
191
+ token in organ_norm
192
+ for token in (
193
+ "pleno",
194
+ "plenario",
195
+ "corte especial",
196
+ "orgao especial",
197
+ "secao",
198
+ )
199
+ )
200
+
201
+
202
+ def as_dict(label: AuthorityLabel) -> dict[str, Any]:
203
+ return {
204
+ "label": label.label,
205
+ "rule": label.rule,
206
+ "rules_version": label.rules_version,
207
+ "attributes": {
208
+ "species": label.attributes.species,
209
+ "court": label.attributes.court,
210
+ "organ": label.attributes.organ,
211
+ "class_name": label.attributes.class_name,
212
+ },
213
+ }