synesis-coder 0.8.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.
@@ -0,0 +1,4 @@
1
+ """synesis-coder — Processador de codificação qualitativa guiado por template.
2
+
3
+ Invoca o compilador Synesis como única interface com o projeto.
4
+ """
@@ -0,0 +1,18 @@
1
+ """Entry point para invocação via `python -m synesis_coder`."""
2
+
3
+ import sys
4
+
5
+ # Força UTF-8 em stdin/stdout/stderr no Windows, onde o encoding padrão
6
+ # do console (cp1252) corromperia texto com acentos e caracteres especiais.
7
+ if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
8
+ import io
9
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
10
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
11
+ if sys.stdin.encoding and sys.stdin.encoding.lower() != "utf-8":
12
+ import io
13
+ sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")
14
+
15
+ from synesis_coder.cli import main
16
+
17
+ if __name__ == "__main__":
18
+ main()
@@ -0,0 +1,333 @@
1
+ """block_assembler.py - dict de valores → texto Synesis determinístico (Opção 3).
2
+
3
+ Purpose:
4
+ Monta a moldura estrutural inteira de blocos SOURCE/ITEM a partir do template
5
+ e de um bibref já validado. O LLM contribui APENAS valores (via JSON); o Python
6
+ emite palavras-chave de bloco, nomes de campo, indentação, `@{bibref}` e as
7
+ setas `->` de chains. Isso elimina por construção fence markdown, campo
8
+ desconhecido (E022), separador errado de CODE (E033/E015) e sintaxe de chain.
9
+
10
+ Components:
11
+ - assemble_items(ctx, bibref, data): envelope {"items": [...]} → N blocos ITEM
12
+ - assemble_source(ctx, bibref, data): objeto → um bloco SOURCE
13
+ - _render_field(spec, value): valor tipado → linha(s) Synesis
14
+
15
+ Dependencies:
16
+ - synesis.ast.nodes: FieldType (despacho por tipo de campo)
17
+
18
+ Generated conforming to: Synesis Specification v1.1
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from typing import List
25
+
26
+ from synesis.ast.nodes import FieldType
27
+
28
+ _INDENT = " " # 4 espaços — indentação canônica de campo Synesis
29
+
30
+
31
+ def assemble_items(ctx: dict, bibref: str, data: dict) -> str:
32
+ """Monta N blocos ITEM a partir do envelope JSON `{"items": [...]}`.
33
+
34
+ Args:
35
+ ctx: Contexto do projeto (define item_fields como fonte da verdade).
36
+ bibref: Referência já validada (sem `@`); o assembler prefixa `@`.
37
+ data: Dict do LLM. Aceita `{"items": [obj, ...]}` ou um único objeto
38
+ (tratado como N=1) por robustez.
39
+
40
+ Returns:
41
+ String com os blocos `ITEM @bibref ... END ITEM` concatenados.
42
+ """
43
+ items = _extract_item_list(data)
44
+ fields = ctx["item_fields"]
45
+ required = set(ctx.get("required_item", []))
46
+ blocks = [_assemble_block("ITEM", bibref, fields, obj, required) for obj in items]
47
+ return "\n\n".join(blocks)
48
+
49
+
50
+ def assemble_source(ctx: dict, bibref: str, data: dict) -> str:
51
+ """Monta um único bloco SOURCE a partir de um objeto de valores."""
52
+ fields = ctx["source_fields"]
53
+ required = set(ctx.get("required_source", []))
54
+ return _assemble_block("SOURCE", bibref, fields, data, required)
55
+
56
+
57
+ def assemble_ontology(ctx: dict, code: str, data: dict) -> str:
58
+ """Monta um único bloco ONTOLOGY a partir de um objeto de valores.
59
+
60
+ Diferente de SOURCE/ITEM, o cabeçalho de ontologia é `ONTOLOGY <code>` (sem
61
+ `@`), então usa `with_at=False`. Como o Python emite a moldura inteira
62
+ (keyword, nomes de campo, `END ONTOLOGY`), o LLM nunca digita sintaxe de
63
+ bloco — nenhuma linha alucinada (ex.: `ITEM x TYPE variable`) pode alcançar
64
+ o `.syno`, pois só os campos declarados em ONTOLOGY FIELDS são emitidos.
65
+ """
66
+ fields = ctx["ontology_fields"]
67
+ required = set(ctx.get("required_ontology", []))
68
+ return _assemble_block("ONTOLOGY", code, fields, data, required, with_at=False)
69
+
70
+
71
+ def _extract_item_list(data: dict) -> List[dict]:
72
+ """Normaliza a entrada para uma lista de objetos-ITEM."""
73
+ if isinstance(data, dict) and "items" in data and isinstance(data["items"], list):
74
+ return [obj for obj in data["items"] if isinstance(obj, dict)]
75
+ if isinstance(data, dict):
76
+ return [data]
77
+ if isinstance(data, list):
78
+ return [obj for obj in data if isinstance(obj, dict)]
79
+ return []
80
+
81
+
82
+ _NA = "NA" # sentinel para campos REQUIRED que o LLM não preencheu
83
+
84
+
85
+ def _assemble_block(
86
+ keyword: str, bibref: str, fields: dict, obj: dict, required: set,
87
+ with_at: bool = True,
88
+ ) -> str:
89
+ """Emite um bloco `KEYWORD @bibref ... END KEYWORD` para um objeto de valores.
90
+
91
+ Campos são emitidos na ordem do template. Chaves extras no JSON (não
92
+ presentes em `fields`) são ignoradas — a moldura nunca produz campo
93
+ desconhecido. Campos OPTIONAL ausentes no JSON são simplesmente omitidos.
94
+ Campos REQUIRED ausentes ou vazios recebem o valor "NA" (Not Available),
95
+ garantindo conformidade estrutural mesmo quando o LLM não extrai o dado.
96
+
97
+ `with_at=True` (SOURCE/ITEM) prefixa a referência com `@`; `with_at=False`
98
+ (ONTOLOGY) usa o nome do conceito diretamente, sem `@`.
99
+ """
100
+ ref = bibref.lstrip("@").strip()
101
+ header = f"{keyword} @{ref}" if with_at else f"{keyword} {ref}"
102
+ lines: List[str] = [header]
103
+
104
+ for name, spec in fields.items():
105
+ # Campos com origem-de-valor externa (ON BIBLIOGRAPHY / ON DATASET) NÃO
106
+ # são materializados no .syn: o valor vive no .bib/registro TOML e é
107
+ # resolvido na compilação. Emitir `NA` aqui gravaria um valor errado e
108
+ # violaria a convenção "o .syn não materializa a fonte externa" (§11.3).
109
+ if getattr(spec, "value_origin", "document") in ("bibliography", "dataset"):
110
+ continue
111
+ value = obj.get(name)
112
+ if not _has_value(value):
113
+ if name in required:
114
+ lines.append(_INDENT + f"{name}: {_NA}")
115
+ continue
116
+ for line in _render_field(name, spec, value):
117
+ lines.append(_INDENT + line)
118
+
119
+ lines.append(f"END {keyword}")
120
+ return "\n".join(lines)
121
+
122
+
123
+ def _render_field(name: str, spec, value) -> List[str]:
124
+ """Converte um valor tipado em uma ou mais linhas `campo: valor` Synesis."""
125
+ ftype = spec.type
126
+
127
+ if ftype == FieldType.CODE:
128
+ joined = _render_code(value)
129
+ return [f"{name}: {joined}"] if joined else []
130
+
131
+ if ftype == FieldType.CHAIN:
132
+ return [f"{name}: {chain_str}" for chain_str in _render_chains(value)]
133
+
134
+ # Demais tipos: valor escalar textualizado, newlines normalizadas.
135
+ rendered = _scalar_to_text(value)
136
+ if not rendered:
137
+ return []
138
+ return [f"{name}: {rendered}"]
139
+
140
+
141
+ def _render_code(value) -> str:
142
+ """Junta valores CODE com `, ` (separador exigido pelo compilador).
143
+
144
+ Aceita lista (caminho JSON) ou string (caminho de fallback/texto livre).
145
+ Normaliza para lowercase para coincidir com normalize_code() do compilador,
146
+ evitando que variantes de case (ex: Graduacao_Curso / graduacao_curso)
147
+ apareçam como códigos distintos no code_index.
148
+ """
149
+ if isinstance(value, list):
150
+ parts = [str(v).strip().lower() for v in value if str(v).strip()]
151
+ return ", ".join(parts)
152
+ return str(value).strip().lower()
153
+
154
+
155
+ def _render_chains(value) -> List[str]:
156
+ """Converte hops {source, relation, target} em strings `A -> rel -> B`.
157
+
158
+ Hops contíguos (`target[i] == source[i+1]`) são interleavados numa só linha
159
+ multi-hop (`A -> r1 -> B -> r2 -> C`). O Python insere todas as setas — o
160
+ LLM nunca digita `->`. Conceitos são normalizados (snake_case, sem espaços)
161
+ como defesa extra contra E015, já que source/target são texto livre.
162
+ """
163
+ if not isinstance(value, list):
164
+ # Fallback: string de chain já formada pelo LLM (caminho texto livre).
165
+ return [str(value).strip()] if str(value).strip() else []
166
+
167
+ hops = [h for h in value if isinstance(h, dict)]
168
+ if not hops:
169
+ return []
170
+
171
+ lines: List[str] = []
172
+ current: List[str] = [] # elementos interleavados do encadeamento atual
173
+
174
+ for hop in hops:
175
+ src = _normalize_concept(hop.get("source", ""))
176
+ rel = str(hop.get("relation", "")).strip()
177
+ tgt = _normalize_concept(hop.get("target", ""))
178
+ if not (src and tgt):
179
+ continue
180
+
181
+ # Omite o rótulo quando vazio ou é o sentinel "__untyped__" (injetado pelo
182
+ # schema_builder em templates sem RELATIONS). Qualquer outro valor —
183
+ # incluindo "linked_to" — é tratado como relação legítima e preservado.
184
+ if rel and rel != "__untyped__":
185
+ elements = [rel, tgt]
186
+ else:
187
+ elements = [tgt]
188
+
189
+ if current and current[-1] == src:
190
+ current.extend(elements)
191
+ else:
192
+ if current:
193
+ lines.append(" -> ".join(current))
194
+ current = [src] + elements
195
+
196
+ if current:
197
+ lines.append(" -> ".join(current))
198
+
199
+ return lines
200
+
201
+
202
+ def _scalar_to_text(value) -> str:
203
+ """Textualiza um valor escalar, normalizando newlines que quebrariam o parser."""
204
+ if isinstance(value, bool):
205
+ return "true" if value else "false"
206
+ if isinstance(value, list):
207
+ # Lista para campo não-CODE/não-CHAIN: juntar com vírgula como degradação.
208
+ return ", ".join(str(v).strip() for v in value if str(v).strip())
209
+ text = str(value).strip()
210
+ # O parser LALR é orientado a linha; colapsar quebras internas em espaço.
211
+ text = re.sub(r"\s*\n\s*", " ", text)
212
+ return text
213
+
214
+
215
+ def _normalize_concept(s: str) -> str:
216
+ """Normaliza um conceito de chain: trim, lowercase, sem espaços (snake_case)."""
217
+ s = str(s).strip().lower()
218
+ if not s:
219
+ return ""
220
+ return re.sub(r"\s+", "_", s)
221
+
222
+
223
+ def _has_value(value) -> bool:
224
+ """True se o valor é preenchido (espelha synesis.semantic.validator._has_value)."""
225
+ if value is None:
226
+ return False
227
+ if isinstance(value, str):
228
+ return value.strip() != ""
229
+ if isinstance(value, list):
230
+ return len(value) > 0
231
+ return True
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Normalização de indentação para o caminho de TEXTO LIVRE
236
+ # ---------------------------------------------------------------------------
237
+
238
+ _BLOCK_START = re.compile(r"^\s*(SOURCE|ITEM|ONTOLOGY)\b", re.IGNORECASE)
239
+ _BLOCK_END = re.compile(r"^\s*END\s+(SOURCE|ITEM|ONTOLOGY)\b", re.IGNORECASE)
240
+
241
+ _ITEM_BLOCK = re.compile(r"^[ \t]*ITEM\b.*?^[ \t]*END\s+ITEM\b", re.MULTILINE | re.DOTALL)
242
+
243
+
244
+ def dedupe_item_blocks(text: str) -> tuple[str, int]:
245
+ """Remove blocos ITEM duplicados, preservando a primeira ocorrência e a ordem.
246
+
247
+ Modelos fracos podem entrar em loop degenerativo e re-emitir o mesmo ITEM
248
+ até esgotar o orçamento de tokens. Medido em produção (2026-07-30,
249
+ `inclusionai/ling-2.6-flash`): 121 ITEMs para 22 únicos num só registro,
250
+ com um mesmo `criterio` repetido 88 vezes.
251
+
252
+ Nada no pipeline detectava: ITEMs repetidos são sintaticamente válidos (um
253
+ código PODE reaparecer em trechos diferentes), então o compilador aceita, o
254
+ schema não limita contagem e a guarda de idempotência só rejeita PERDA de
255
+ blocos. O `document_mode` já tinha dedup para o caso análogo de chunks
256
+ sobrepostos; os modos abstract/dataset não.
257
+
258
+ A chave é o texto do bloco normalizado (espaços colapsados), não uma
259
+ heurística semântica: só remove o que é literalmente o mesmo ITEM. Dois
260
+ ITEMs com o mesmo `criterio` mas trechos distintos são preservados.
261
+
262
+ Returns:
263
+ (texto_sem_duplicatas, quantidade_removida)
264
+ """
265
+ seen: set[str] = set()
266
+ removed = 0
267
+
268
+ def _keep(match: re.Match) -> str:
269
+ nonlocal removed
270
+ key = re.sub(r"\s+", " ", match.group(0)).strip()
271
+ if key in seen:
272
+ removed += 1
273
+ return ""
274
+ seen.add(key)
275
+ return match.group(0)
276
+
277
+ result = _ITEM_BLOCK.sub(_keep, text)
278
+ if removed:
279
+ # Colapsa as lacunas deixadas pelos blocos removidos.
280
+ result = re.sub(r"\n{3,}", "\n\n", result).strip()
281
+ return result, removed
282
+
283
+
284
+ def count_item_blocks(text: str) -> int:
285
+ """Conta blocos ITEM completos (abertura + fechamento) num texto Synesis."""
286
+ return len(_ITEM_BLOCK.findall(text))
287
+
288
+
289
+ def normalize_indentation(text: str) -> str:
290
+ """Reescreve a indentação de um texto Synesis para a forma canônica.
291
+
292
+ A indentação é FORMA, não conteúdo — não deveria depender do modelo. No
293
+ caminho JSON o `_assemble_block` já emite `_INDENT` por construção; este
294
+ helper leva a mesma garantia ao caminho de texto livre, onde o LLM digita
295
+ o bloco inteiro.
296
+
297
+ Medido em produção (2026-07-30, `inclusionai/ling-2.6-flash`): o modelo
298
+ emitiu campos de SOURCE sem indentação alguma, o parser LALR rejeitou com
299
+ `Token inesperado TEXT_LINE`, e as 3 tentativas de correção falharam —
300
+ perdendo o registro por um detalhe tipográfico que o Python resolve de
301
+ graça. Outro registro usou 2 espaços em vez de 4.
302
+
303
+ Regras (deliberadamente mínimas, para não alterar conteúdo):
304
+ - Linha de abertura (`SOURCE`/`ITEM`/`ONTOLOGY`) e de fechamento (`END ...`)
305
+ vão para a coluna 0.
306
+ - Qualquer outra linha não-vazia DENTRO de um bloco recebe `_INDENT`.
307
+ - Linhas fora de blocos (comentários de cabeçalho, `# ERRO`) ficam intactas.
308
+ - Linhas vazias são preservadas.
309
+
310
+ Note que continuações multi-linha de um valor também são normalizadas para
311
+ um nível — o parser é orientado a linha e não distingue níveis internos.
312
+ """
313
+ out: List[str] = []
314
+ inside = False
315
+
316
+ for line in text.splitlines():
317
+ if not line.strip():
318
+ out.append("")
319
+ continue
320
+
321
+ if _BLOCK_END.match(line):
322
+ out.append(line.strip())
323
+ inside = False
324
+ continue
325
+
326
+ if _BLOCK_START.match(line):
327
+ out.append(line.strip())
328
+ inside = True
329
+ continue
330
+
331
+ out.append(_INDENT + line.strip() if inside else line)
332
+
333
+ return "\n".join(out)