serigy 0.2.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.
serigy/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """Serigy — inferência pública (pip).
2
+
3
+ Uso:
4
+ from serigy import SerigyModel, Escolha, Verdadeiro
5
+ model = SerigyModel("wendellperbar/serigy-jev")
6
+ out = model.predict("texto da notícia...")
7
+ result = model.system_one(state, {"q": Verdadeiro(instructions="…?")})
8
+
9
+ Após cada upload no Hub, atualize DEFAULT_HUB_REVISION em cliente.py
10
+ (pin atual: 785ef7e842fb82409af027b4f9ee217a4e72f9d7).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from serigy.cliente import DEFAULT_REPO, SerigyModel
16
+ from serigy.primitivos.schemas import (
17
+ Escolha,
18
+ Indice,
19
+ Prioridade,
20
+ SystemOneRequest,
21
+ SystemOneResult,
22
+ Verdadeiro,
23
+ )
24
+
25
+ __all__ = [
26
+ "SerigyModel",
27
+ "DEFAULT_REPO",
28
+ "Escolha",
29
+ "Indice",
30
+ "Verdadeiro",
31
+ "Prioridade",
32
+ "SystemOneRequest",
33
+ "SystemOneResult",
34
+ ]
35
+
36
+ try:
37
+ from importlib.metadata import PackageNotFoundError, version
38
+
39
+ try:
40
+ __version__ = version("serigy")
41
+ except PackageNotFoundError:
42
+ __version__ = "0.2.0"
43
+ except ImportError: # pragma: no cover
44
+ __version__ = "0.2.0"
serigy/__main__.py ADDED
@@ -0,0 +1,112 @@
1
+ """CLI: python -m serigy --texto "..." | --json '{...}' | --stdin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from serigy.cliente import DEFAULT_REPO, SerigyModel, _saida_publica_system_one
12
+ from serigy.primitivos.schemas import SystemOneRequest
13
+
14
+
15
+ def _carregar_json(args: argparse.Namespace) -> dict[str, Any]:
16
+ if args.stdin:
17
+ return json.load(sys.stdin)
18
+ assert args.json is not None
19
+ raw = args.json
20
+ caminho = Path(raw)
21
+ if caminho.is_file():
22
+ return json.loads(caminho.read_text(encoding="utf-8"))
23
+ return json.loads(raw)
24
+
25
+
26
+ def main(argv: list[str] | None = None) -> int:
27
+ parser = argparse.ArgumentParser(
28
+ prog="serigy",
29
+ description="Inferência Serigy (schema público I/O)",
30
+ )
31
+ parser.add_argument(
32
+ "texto_posicional",
33
+ nargs="?",
34
+ default=None,
35
+ help="Texto da notícia (alternativa a --texto)",
36
+ )
37
+ parser.add_argument("--texto", default=None, help="Texto da notícia (predict)")
38
+ parser.add_argument("--fonte", default=None, help="Fonte opcional (predict)")
39
+ parser.add_argument(
40
+ "--json",
41
+ default=None,
42
+ help="System One: JSON (arquivo ou string) com state + questions",
43
+ )
44
+ parser.add_argument(
45
+ "--stdin",
46
+ action="store_true",
47
+ help="System One: ler envelope JSON de stdin",
48
+ )
49
+ parser.add_argument(
50
+ "--model",
51
+ default=DEFAULT_REPO,
52
+ help=f"Hub id ou pasta local (default: {DEFAULT_REPO})",
53
+ )
54
+ parser.add_argument(
55
+ "--revision",
56
+ default=None,
57
+ help="Revision Hub (commit/tag); default: SERIGY_HUB_REVISION / pin do pacote",
58
+ )
59
+ parser.add_argument(
60
+ "--allow-custom-models",
61
+ action="store_true",
62
+ help="Permite model_id/embedding fora da allowlist (só se confiar no checkpoint)",
63
+ )
64
+ parser.add_argument(
65
+ "--hf-token",
66
+ default=None,
67
+ help="Token HF (ou env HF_TOKEN)",
68
+ )
69
+ parser.add_argument(
70
+ "--warm",
71
+ action="store_true",
72
+ help="Pré-carrega o sensor antes da predição",
73
+ )
74
+ args = parser.parse_args(argv)
75
+
76
+ modo_system_one = bool(args.json or args.stdin)
77
+ texto = args.texto or args.texto_posicional
78
+ if modo_system_one and texto:
79
+ parser.error("use --texto OU --json/--stdin, não ambos")
80
+ if not modo_system_one and not texto:
81
+ parser.error("informe --texto, argumento posicional, --json ou --stdin")
82
+
83
+ print(f"carregando {args.model}…", file=sys.stderr)
84
+ model = SerigyModel(
85
+ args.model,
86
+ token=args.hf_token,
87
+ warm=args.warm,
88
+ revision=args.revision,
89
+ allow_custom_models=args.allow_custom_models,
90
+ )
91
+
92
+ if modo_system_one:
93
+ payload = _carregar_json(args)
94
+ req = SystemOneRequest.model_validate(payload)
95
+ result = model.system_one(req.state, req.questions)
96
+ out = _saida_publica_system_one(result)
97
+ print(json.dumps(out, ensure_ascii=False, indent=2))
98
+ return 0
99
+
100
+ out = model.predict(texto, fonte=args.fonte)
101
+ print(
102
+ f"→ {out['predicao']} | classe={out['classe_ordinal']} "
103
+ f"| score={out['score_final']:.3f} "
104
+ f"| confiança={out['confianca_calibrada']:.3f}",
105
+ file=sys.stderr,
106
+ )
107
+ print(json.dumps(out, ensure_ascii=False, indent=2))
108
+ return 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ raise SystemExit(main())
serigy/api/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """Schemas + motor de inferência (uso interno; API pública = SerigyModel)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from serigy.api.schemas import (
8
+ ComponentesConfianca,
9
+ CriteriosAvaliacao,
10
+ InputEco,
11
+ MetricasInferencia,
12
+ NivelOrdinal,
13
+ Noticia,
14
+ NoticiaRequest,
15
+ OutputPredicao,
16
+ PesosComponentes,
17
+ PredicaoResponse,
18
+ ProbabilidadesOrdinais,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from serigy.api.inferencia import MotorInferencia, predizer
23
+
24
+ __all__ = [
25
+ "Noticia",
26
+ "NivelOrdinal",
27
+ "CriteriosAvaliacao",
28
+ "NoticiaRequest",
29
+ "InputEco",
30
+ "ComponentesConfianca",
31
+ "PesosComponentes",
32
+ "ProbabilidadesOrdinais",
33
+ "OutputPredicao",
34
+ "MetricasInferencia",
35
+ "PredicaoResponse",
36
+ "MotorInferencia",
37
+ "predizer",
38
+ ]
39
+
40
+
41
+ def __getattr__(name: str) -> Any:
42
+ if name in ("MotorInferencia", "predizer"):
43
+ from serigy.api import inferencia as _inf
44
+
45
+ return getattr(_inf, name)
46
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,484 @@
1
+ """Inferência end-to-end: NoticiaRequest → PredicaoResponse (FASE 5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import time
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ from serigy.api.schemas import (
15
+ ComponentesConfianca,
16
+ InputEco,
17
+ MetricasInferencia,
18
+ NoticiaRequest,
19
+ OutputPredicao,
20
+ PesosComponentes,
21
+ PredicaoResponse,
22
+ ProbabilidadesOrdinais,
23
+ )
24
+ from serigy.components.sistema import SistemaConfianca
25
+ from serigy.config import CONFIG_PADRAO, ConfiguracaoSerigy, _raiz_repositorio
26
+ from serigy.dados.extrator import ExtratorHiddenStates, ResultadoExtracao
27
+ from serigy.checkpoint import carregar_sistema
28
+ from serigy.utils.ordinal import aplicar_cabeca_ordinal
29
+
30
+
31
+ def _como_float(x: Any) -> float:
32
+ """Extrai escalar de array NumPy / lista / float."""
33
+ arr = np.asarray(x, dtype=np.float64)
34
+ if arr.size == 0:
35
+ raise ValueError("valor vazio")
36
+ return float(arr.reshape(-1)[0])
37
+
38
+
39
+ def resolver_checkpoint(config: ConfiguracaoSerigy | None = None) -> Path:
40
+ """
41
+ Resolve caminho do `.safetensors`.
42
+
43
+ Aceita:
44
+ - arquivo `.safetensors`
45
+ - diretório com `serigy.safetensors` / `serigy_jev.safetensors`
46
+ - único `.safetensors` no diretório
47
+ """
48
+ config = config or CONFIG_PADRAO
49
+ raiz = _raiz_repositorio()
50
+ base = Path(config.checkpoint_dir)
51
+ if not base.is_absolute():
52
+ base = raiz / base
53
+
54
+ if base.is_file() and base.suffix == ".safetensors":
55
+ return base
56
+
57
+ preferidos = (
58
+ "serigy.safetensors",
59
+ "serigy_jev.safetensors",
60
+ "modelo.safetensors",
61
+ "checkpoint.safetensors",
62
+ )
63
+ if base.is_dir():
64
+ for nome in preferidos:
65
+ candidato = base / nome
66
+ if candidato.is_file():
67
+ return candidato
68
+ encontrados = sorted(base.glob("*.safetensors"))
69
+ if encontrados:
70
+ return encontrados[0]
71
+
72
+ raise FileNotFoundError(
73
+ f"Checkpoint não encontrado em '{config.checkpoint_dir}'. "
74
+ "Baixe pesos do Hub (wendellperbar/serigy-jev) ou ajuste "
75
+ "SERIGY_CHECKPOINT_DIR / SerigyModel(caminho_local)."
76
+ )
77
+
78
+
79
+ def gerar_request_id(texto: str) -> str:
80
+ """ID: serigy_<sha256[:16]><uuid8>."""
81
+ digest = hashlib.sha256(texto.encode("utf-8")).hexdigest()[:16]
82
+ return f"serigy_{digest}{uuid.uuid4().hex[:8]}"
83
+
84
+
85
+ def _preparar_batch(extracao: ResultadoExtracao) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
86
+ """Garante shapes de batch=1 para o SistemaConfianca."""
87
+ h = np.asarray(extracao.h_final, dtype=np.float64)
88
+ e = np.asarray(extracao.e_ref, dtype=np.float64)
89
+ layers = np.asarray(extracao.h_layers, dtype=np.float64)
90
+
91
+ if h.ndim == 1:
92
+ h = h[None, :]
93
+ if e.ndim == 1:
94
+ e = e[None, :]
95
+ # Cconv aceita (L, dim); se vier (L, 1, dim) ok também
96
+ if layers.ndim == 2:
97
+ pass
98
+ elif layers.ndim == 3 and layers.shape[1] == 1:
99
+ layers = layers[:, 0, :]
100
+ return h, e, layers
101
+
102
+
103
+ class MotorInferencia:
104
+ """
105
+ Orquestra extrator (sensor) + SistemaConfianca + cabeça ordinal.
106
+
107
+ Lazy-load do checkpoint; sensor sob demanda na primeira `predizer`.
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ config: ConfiguracaoSerigy | None = None,
113
+ *,
114
+ caminho_checkpoint: Path | str | None = None,
115
+ extrator: ExtratorHiddenStates | None = None,
116
+ sistema: SistemaConfianca | None = None,
117
+ sigma: float | None = None,
118
+ prioridade_via_heads: bool | None = None,
119
+ ):
120
+ """
121
+ ``prioridade_via_heads``:
122
+ - ``None`` (auto): usa head nativa se existir ``serigy_heads.safetensors``
123
+ e ``prioridade_modo == "nativa"``; senão RBF legado.
124
+ - ``True`` / ``False``: força o caminho.
125
+ """
126
+ self.config = config or CONFIG_PADRAO
127
+ self._caminho_checkpoint = (
128
+ Path(caminho_checkpoint) if caminho_checkpoint else None
129
+ )
130
+ self._extrator = extrator
131
+ self._sistema = sistema
132
+ self._sigma = sigma
133
+ self._heads = None
134
+ self._prioridade_via_heads = prioridade_via_heads
135
+ self._heads_carregadas_do_disco = False
136
+
137
+ @property
138
+ def sigma(self) -> float:
139
+ self._garantir_sistema()
140
+ assert self._sigma is not None
141
+ return self._sigma
142
+
143
+ @property
144
+ def sistema(self) -> SistemaConfianca:
145
+ self._garantir_sistema()
146
+ assert self._sistema is not None
147
+ return self._sistema
148
+
149
+ def _garantir_sistema(self) -> None:
150
+ if self._sistema is not None and self._sigma is not None:
151
+ return
152
+ caminho = self._caminho_checkpoint or resolver_checkpoint(self.config)
153
+ self._sistema, self._sigma = carregar_sistema(caminho, config=self.config)
154
+
155
+ def _garantir_extrator(self) -> ExtratorHiddenStates:
156
+ if self._extrator is None:
157
+ self._extrator = ExtratorHiddenStates(self.config)
158
+ return self._extrator
159
+
160
+ def aquecer(self, *, carregar_sensor: bool = True) -> None:
161
+ """
162
+ Carrega checkpoint (+ sensor) antecipadamente.
163
+
164
+ Use no REPL para pagar o custo de Qwen uma vez só.
165
+ Com sensor, anexa MiniLM às heads (perguntas/opções/níveis).
166
+ """
167
+ self._garantir_sistema()
168
+ if carregar_sensor:
169
+ self._garantir_extrator().aquecer()
170
+ self._garantir_heads()
171
+ self._sincronizar_embedder_heads()
172
+
173
+ def _deve_usar_prioridade_heads(self) -> bool:
174
+ if self._prioridade_via_heads is False:
175
+ return False
176
+ if self._prioridade_via_heads is True:
177
+ return True
178
+ # auto: só se pesos treinados estiverem no disco (evita regressão com init aleatório)
179
+ path = self._caminho_heads()
180
+ return path is not None and path.is_file()
181
+
182
+ def _ordinal_de_heads(
183
+ self,
184
+ h_vec: np.ndarray,
185
+ criterios: Any,
186
+ *,
187
+ score_torre: float,
188
+ ) -> Any:
189
+ """Prioridade nativa no tronco; ``score_final`` da resposta = torre (compat API)."""
190
+ from serigy.primitivos.prioridade_util import criterios_para_prioridade_question
191
+ from serigy.utils.ordinal import ResultadoOrdinal
192
+
193
+ heads = self._garantir_heads()
194
+ if getattr(heads, "prioridade_modo", "nativa") == "rbf":
195
+ return aplicar_cabeca_ordinal(
196
+ score_torre, criterios, sigma=self._sigma or CONFIG_PADRAO.ordinal_sigma
197
+ )
198
+ q = criterios_para_prioridade_question(criterios)
199
+ ans = heads.avaliar_prioridade(h_vec, q, score_final=score_torre)
200
+ probs = ans.probabilities
201
+ return ResultadoOrdinal(
202
+ score_final=float(np.clip(score_torre, 0.0, 1.0)),
203
+ probabilidades=(
204
+ float(probs["0"]),
205
+ float(probs["1"]),
206
+ float(probs["2"]),
207
+ ),
208
+ classe_ordinal=int(ans.classe_ordinal),
209
+ predicao=ans.predicao,
210
+ entropia_shannon=float(ans.entropia_shannon),
211
+ confianca_calibrada=float(ans.confianca_calibrada),
212
+ )
213
+
214
+ def predizer_de_features(
215
+ self,
216
+ request: NoticiaRequest | dict[str, Any],
217
+ extracao: ResultadoExtracao,
218
+ *,
219
+ tempo_ms: float = 0.0,
220
+ request_id: str | None = None,
221
+ ) -> PredicaoResponse:
222
+ """Forward a partir de features já extraídas (testes / cache)."""
223
+ if isinstance(request, dict):
224
+ request = NoticiaRequest.model_validate(request)
225
+
226
+ self._garantir_sistema()
227
+ assert self._sistema is not None and self._sigma is not None
228
+
229
+ h, e, layers = _preparar_batch(extracao)
230
+ bruto = self._sistema(
231
+ estado_oculto=h,
232
+ embedding_referencia=e,
233
+ estados_camadas=layers,
234
+ )
235
+ csem = _como_float(bruto["score_csem"])
236
+ cconv = _como_float(bruto["score_cconv"])
237
+ clearned = _como_float(bruto["score_clearned"])
238
+ score_final = _como_float(bruto["score_final"])
239
+ h_vec = np.asarray(h, dtype=np.float64).reshape(-1)
240
+
241
+ w = self._sistema.config.pesos_normalizados
242
+ if self._deve_usar_prioridade_heads():
243
+ ordinal = self._ordinal_de_heads(
244
+ h_vec, request.criterios_avaliacao, score_torre=score_final
245
+ )
246
+ else:
247
+ ordinal = aplicar_cabeca_ordinal(
248
+ score_final,
249
+ request.criterios_avaliacao,
250
+ sigma=self._sigma,
251
+ )
252
+
253
+ return PredicaoResponse(
254
+ model=self.config.model_version,
255
+ timestamp=datetime.now(timezone.utc),
256
+ input=InputEco(
257
+ texto=request.noticia.texto,
258
+ fonte=request.noticia.fonte,
259
+ timestamp=request.noticia.timestamp,
260
+ ),
261
+ criterios=request.criterios_avaliacao.mapa_labels(),
262
+ output=OutputPredicao(
263
+ predicao=ordinal.predicao,
264
+ classe_ordinal=ordinal.classe_ordinal, # type: ignore[arg-type]
265
+ score_final=ordinal.score_final,
266
+ confianca_calibrada=ordinal.confianca_calibrada,
267
+ entropia_shannon=ordinal.entropia_shannon,
268
+ componentes=ComponentesConfianca(
269
+ csem=csem, cconv=cconv, clearned=clearned
270
+ ),
271
+ pesos=PesosComponentes(
272
+ w_csem=w[0], w_cconv=w[1], w_clearned=w[2]
273
+ ),
274
+ probabilidades=ProbabilidadesOrdinais.model_validate(
275
+ ordinal.probs_dict()
276
+ ),
277
+ ),
278
+ metricas=MetricasInferencia(
279
+ tempo_ms=float(tempo_ms),
280
+ input_tokens=int(extracao.input_tokens),
281
+ output_tokens=0,
282
+ ),
283
+ request_id=request_id or gerar_request_id(request.noticia.texto),
284
+ )
285
+
286
+ def predizer(
287
+ self,
288
+ request: NoticiaRequest | dict[str, Any],
289
+ *,
290
+ request_id: str | None = None,
291
+ ) -> PredicaoResponse:
292
+ """Pipeline completo: texto → sensor → componentes → ordinal → response."""
293
+ if isinstance(request, dict):
294
+ request = NoticiaRequest.model_validate(request)
295
+
296
+ t0 = time.perf_counter()
297
+ extracao = self._garantir_extrator().extrair(request.noticia.texto)
298
+ tempo_ms = (time.perf_counter() - t0) * 1000.0
299
+
300
+ return self.predizer_de_features(
301
+ request,
302
+ extracao,
303
+ tempo_ms=tempo_ms,
304
+ request_id=request_id,
305
+ )
306
+
307
+ def computar_score_final(
308
+ self,
309
+ texto: str,
310
+ *,
311
+ extracao: ResultadoExtracao | None = None,
312
+ ) -> tuple[float, int, float]:
313
+ """
314
+ Uma passagem sensor+sistema → (score_final, input_tokens, tempo_ms).
315
+
316
+ Usado por system_one (adapters) para não reextrair por pergunta.
317
+ """
318
+ score_final, _h, tokens, tempo = self.computar_representacao(
319
+ texto, extracao=extracao
320
+ )
321
+ return score_final, tokens, tempo
322
+
323
+ def computar_representacao(
324
+ self,
325
+ texto: str,
326
+ *,
327
+ extracao: ResultadoExtracao | None = None,
328
+ ) -> tuple[float, np.ndarray, int, float]:
329
+ """
330
+ Sensor+sistema → (score_final, h_state, input_tokens, tempo_ms).
331
+
332
+ ``h_state`` alimenta as perguntas tipadas System One.
333
+ """
334
+ self._garantir_sistema()
335
+ assert self._sistema is not None
336
+
337
+ t0 = time.perf_counter()
338
+ if extracao is None:
339
+ extracao = self._garantir_extrator().extrair(texto)
340
+ tempo_ms = (time.perf_counter() - t0) * 1000.0
341
+ else:
342
+ tempo_ms = 0.0
343
+
344
+ h, e, layers = _preparar_batch(extracao)
345
+ bruto = self._sistema(
346
+ estado_oculto=h,
347
+ embedding_referencia=e,
348
+ estados_camadas=layers,
349
+ )
350
+ score_final = _como_float(bruto["score_final"])
351
+ h_vec = np.asarray(h, dtype=np.float64).reshape(-1)
352
+ return score_final, h_vec, int(extracao.input_tokens), float(tempo_ms)
353
+
354
+ def _caminho_heads(self) -> Path | None:
355
+ caminho = self._caminho_checkpoint
356
+ if caminho is None:
357
+ try:
358
+ caminho = resolver_checkpoint(self.config)
359
+ except FileNotFoundError:
360
+ return None
361
+ from serigy.primitivos.persist import caminho_heads_ao_lado
362
+
363
+ return caminho_heads_ao_lado(caminho)
364
+
365
+ def _garantir_heads(self) -> Any:
366
+ if getattr(self, "_heads", None) is not None:
367
+ return self._heads
368
+
369
+ from serigy.primitivos.encode import HashTextEmbedder
370
+ from serigy.primitivos.heads import ConfigHeads, SistemaHeads
371
+ from serigy.primitivos.persist import carregar_heads
372
+
373
+ dim_q = int(self.config.embedding_dim)
374
+ heads_path = self._caminho_heads()
375
+ if heads_path is not None and heads_path.is_file():
376
+ # embedder=None → HashTextEmbedder com seed gravado no checkpoint
377
+ self._heads = carregar_heads(heads_path, embedder=None)
378
+ self._heads_carregadas_do_disco = True
379
+ else:
380
+ # dim_h: projeção do h do sensor; dim_q alinhado ao MiniLM
381
+ self._heads = SistemaHeads(
382
+ ConfigHeads(dim_h=256, dim_q=dim_q, dim_z=256),
383
+ embedder=HashTextEmbedder(dim_q, seed=7),
384
+ )
385
+ self._heads_carregadas_do_disco = False
386
+ self._sincronizar_embedder_heads()
387
+ return self._heads
388
+
389
+ def _sincronizar_embedder_heads(self) -> None:
390
+ """Se MiniLM já estiver carregado no extrator, usa-o nas perguntas."""
391
+ if self._heads is None:
392
+ return
393
+ if getattr(self._heads, "_embedder_fonte", None) == "minilm":
394
+ return
395
+ ext = self._extrator
396
+ if ext is None or getattr(ext, "_sentence_model", None) is None:
397
+ return
398
+ from serigy.primitivos.encode import CallableTextEmbedder
399
+
400
+ dim_q = int(self._heads.config.dim_q)
401
+
402
+ def _batch(textos: list[str]) -> np.ndarray:
403
+ return np.asarray(ext.embed_texts(textos), dtype=np.float64)
404
+
405
+ self._heads.definir_embedder(
406
+ CallableTextEmbedder(_batch, dim_q, batch=True),
407
+ fonte="minilm",
408
+ )
409
+
410
+ def system_one(
411
+ self,
412
+ state: str | dict[str, Any] | list[Any] | Any,
413
+ questions: dict[str, Any] | None = None,
414
+ *,
415
+ extracao: ResultadoExtracao | None = None,
416
+ usar_heads: bool | None = None,
417
+ ) -> Any:
418
+ """
419
+ System One: um state, N perguntas tipadas.
420
+
421
+ ``usar_heads`` é interno (testes/monorepo):
422
+ - ``None`` (default): usa sidecar de heads se existir; senão caminho legado
423
+ - ``True`` / ``False``: força o caminho
424
+ """
425
+ from serigy.primitivos.system_one import (
426
+ normalizar_request,
427
+ system_one_de_heads,
428
+ system_one_de_score,
429
+ )
430
+
431
+ texto, qs, model = normalizar_request(
432
+ state,
433
+ questions,
434
+ model=self.config.model_version,
435
+ )
436
+ self._garantir_sistema()
437
+ assert self._sigma is not None
438
+ score_final, h_vec, tokens, _tempo = self.computar_representacao(
439
+ texto, extracao=extracao
440
+ )
441
+ modelo = model or self.config.model_version
442
+ if usar_heads is None:
443
+ heads_path = self._caminho_heads()
444
+ if heads_path is not None and heads_path.is_file():
445
+ usar_heads = True
446
+ else:
447
+ # Adapters legados só cobrem Escolha N=3; N≠3 exige heads.
448
+ from serigy.primitivos.schemas import EscolhaQuestion
449
+
450
+ usar_heads = any(
451
+ isinstance(q, EscolhaQuestion) and len(q.criteria) != 3
452
+ for q in qs.values()
453
+ )
454
+ if usar_heads:
455
+ heads = self._garantir_heads()
456
+ self._sincronizar_embedder_heads()
457
+ return system_one_de_heads(
458
+ h_vec,
459
+ qs,
460
+ heads,
461
+ score_final=score_final,
462
+ model=modelo,
463
+ input_tokens=tokens,
464
+ sigma=self._sigma,
465
+ )
466
+ return system_one_de_score(
467
+ score_final,
468
+ qs,
469
+ model=modelo,
470
+ input_tokens=tokens,
471
+ sigma=self._sigma,
472
+ )
473
+
474
+
475
+ def predizer(
476
+ request: NoticiaRequest | dict[str, Any],
477
+ *,
478
+ config: ConfiguracaoSerigy | None = None,
479
+ caminho_checkpoint: Path | str | None = None,
480
+ ) -> PredicaoResponse:
481
+ """Atalho one-shot (preferir `MotorInferencia` para reuso do sensor)."""
482
+ return MotorInferencia(
483
+ config=config, caminho_checkpoint=caminho_checkpoint
484
+ ).predizer(request)