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.
- synesis_coder/__init__.py +4 -0
- synesis_coder/__main__.py +18 -0
- synesis_coder/block_assembler.py +333 -0
- synesis_coder/cli.py +1010 -0
- synesis_coder/debug_log.py +535 -0
- synesis_coder/llm_client.py +1320 -0
- synesis_coder/modes/__init__.py +1 -0
- synesis_coder/modes/abstract_mode.py +500 -0
- synesis_coder/modes/critique_mode.py +561 -0
- synesis_coder/modes/dataset_mode.py +300 -0
- synesis_coder/modes/document_mode.py +1049 -0
- synesis_coder/modes/finetune_mode.py +595 -0
- synesis_coder/modes/incorporate_mode.py +585 -0
- synesis_coder/modes/item_mode.py +113 -0
- synesis_coder/modes/normalize_mode.py +707 -0
- synesis_coder/modes/ontology_mode.py +475 -0
- synesis_coder/modes/refine_mode.py +515 -0
- synesis_coder/modes/suggest_mode.py +221 -0
- synesis_coder/project_loader.py +552 -0
- synesis_coder/prompt_builder.py +1541 -0
- synesis_coder/prompt_dump.py +169 -0
- synesis_coder/runtime_info.py +124 -0
- synesis_coder/schema_builder.py +319 -0
- synesis_coder/synr_io.py +390 -0
- synesis_coder/text_cleaner.py +128 -0
- synesis_coder/token_usage.py +182 -0
- synesis_coder/validator.py +603 -0
- synesis_coder-0.8.0.dist-info/METADATA +656 -0
- synesis_coder-0.8.0.dist-info/RECORD +34 -0
- synesis_coder-0.8.0.dist-info/WHEEL +5 -0
- synesis_coder-0.8.0.dist-info/entry_points.txt +2 -0
- synesis_coder-0.8.0.dist-info/licenses/LICENSE +661 -0
- synesis_coder-0.8.0.dist-info/licenses/LICENSE.exception +133 -0
- synesis_coder-0.8.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|