ADR 0013: compilatore YAML di workflow Dify → Python puro + advisor statico di ottimizzazione
Compilare un file .yml di workflow Dify in Python imperativo indipendente dai framework attraverso una IR de-Dify-izzata (parse → IR → codegen), con un advisor statico di ottimizzazione a otto regole — PyYAML dietro un nuovo extra [dify], completamente sincrono, zero nuove dipendenze nel core.
Stato: accepted · Data: 2026-06-24
Record immutabile. Esente dal tracciamento della deriva (nessun
covers). Sostituire, non modificare.
Correlato a 0009 (nessun lock-in di framework + solo licenze permissive),
0011 (lo standard python-project-standard) e all'ADR sui confini
della famiglia Spine (repository di famiglia, docs/adr/0001 — cuciture di famiglia / protocollo LLM).
Rilasciato in rag-spine 0.4.0.
Contesto
Dify è una popolare piattaforma low-code di orchestrazione di applicazioni LLM; i suoi workflow si esportano come
.yml (DSL). Molti utenti hanno un workflow / advanced-chat funzionante costruito in Dify ma vogliono
uscire dal lock-in della piattaforma: un pezzo di Python puro indipendente dai framework, leggibile, versionabile
ed eseguibile offline. L'identità di RAGSpine è esattamente il "backend indipendente dai
framework" (ADR 0009: nessun lock-in di framework + dipendenze
esclusivamente con licenze permissive), quindi compilare un workflow Dify in Python puro imperativo — distribuendo
suggerimenti statici di ottimizzazione lungo il percorso — si sposa naturalmente con la direzione del prodotto.
Vincoli rigidi: la famiglia è completamente sincrona; il core resta a zero SDK; le chiamate LLM passano
attraverso la cucitura corespine.LLMProvider.chat; il MockProvider offline è il default;
il gate delle sole licenze permissive regge; mypy --strict; pydantic solo al confine.
Decisione
.yml → parse → IR → codegen + optimize — tre stadi disaccoppiati da un'unica IR de-Dify-izzata.
- parse —
yaml.safe_load(PyYAML, MIT, supera il gate delle licenze) legge il documento in un dict, validato da modelli di confine pydantic v2 (extra='allow'tollera i campi sconosciuti, quindi un nuovo campo Dify non rompe mai il parser). pydantic compare solo in questo stadio. PyYAML è importato in modo lazy dietro un nuovo extra opzionale[dify]; il coredependenciesresta intatto. - IR —
DifyDoc → WorkflowIR. I nodi si normalizzano in sottoclassiIRNodedi dataclass frozen; i riferimenti alle variabili (value_selector/{{#nodeId.field#}}) si normalizzano inVarRef/Literal/TemplateValue; gli archi portanosource_handle; l'ordinamento topologico di Kahn producetopo_ordereparallel_layers; un ciclo sollevaCyclicGraph. Pura stdlib — zero pydantic, e zero concetti Dify trapelano sotto questo strato. - codegen —
WorkflowIR → GeneratedCode. La topologia si appiattisce in uno script imperativodef run_workflow(inputs, *, provider=None) -> dict; i nodi LLM diventanoprovider.chat(messages); gli strati paralleli usanoconcurrent.futures.ThreadPoolExecutor(non viene generato alcun async — la famiglia è completamente sincrona). Pura stdlib. - optimize —
WorkflowIR → list[Suggestion]: otto regole puramente statiche (zero chiamate API, non tocca mai l'ambiente live; i tetti d'ambiente sono iniettabili) — PARALLEL_001/002, BOTTLE_001/002, CACHE_001, RESOURCE_001/002, LLM_001.
Approda in src/ragspine/dify/{parse,ir,codegen,optimize}/ + api.py + errors.py. La facade:
compile_dify_yaml(source, *, target='ragspine', provider_expr='MockProvider()', emit_trace=False, analyze=True) -> CompileResult(code, suggestions, ir);
analyze(source, *, env=None) -> list[Suggestion]; a basso livello
parse_dify_yaml / lower_to_ir / generate_code. CLI: ragspine dify compile <path>.
I sei default dell'MVP (§7 — questioni aperte, default risolti)
Per far avanzare l'MVP, sei questioni aperte hanno adottato default ragionevoli (rivedibili in review):
- Runtime di destinazione — di default uno script imperativo ragspine puro; un parametro
target=è riservato come cucitura, con un target di orchestrazione spineagent lasciato per P7. - Ambito dell'ottimizzazione — regole puramente statiche (zero API). Profiling dinamico / stima reale dei token rimandati.
- Nodi
code— resi inline come funzioni locali generate, con un commento che segnala l'"assunzione di fiducia sulla sorgente" (un nodo code di Dify esegue già codice utente arbitrario; il compilatore mantiene lo stesso confine di fiducia e non lo mette in sandbox). - Nodi non supportati (http-request / tool / knowledge-retrieval / parameter-extractor /
plugin) — generano funzioni hook con
raise NotImplementedError+ una docstring dettagliata (producono uno scheletro eseguibile, mai il fallimento dell'intera compilazione); registrati esplicitamente inGeneratedCode.warnings. - Modalità dell'app — prima
workflow+advanced-chat(con nodianswer).conversation_variables/ memoria / stato di conversazione multi-turno lasciati per P7. - Dipendenza YAML — un nuovo extra opzionale
[dify](PyYAML,safe_load); il coredependenciesresta intatto.
Alternative considerate (respinte)
.yml → codediretto (senza IR). Respinta — i tre stadi sarebbero fortemente accoppiati, aggiungere un backend di destinazione (spineagent) o fare analisi statica sarebbe difficile; la IR è il fulcro condiviso per l'ottimizzatore e per i molteplici target.- Un interprete a runtime (eseguire il
.ymlal volo). Respinta — significherebbe costruire un altro mini runtime Dify, vanificando l'intero scopo di compilare in Python puro di qualità libreria e abbandonare la piattaforma, e contraddicendo la posizione indipendente dai framework dell'ADR 0009. - Generare codice async. Respinta — la famiglia è completamente sincrona;
ThreadPoolExecutorcopre il parallelismo ed è più leggibile ed eseguibile offline. - Mettere PyYAML nel core
dependencies. Respinta — solo lo stadio di parse ne ha bisogno; l'import lazy + l'extra[dify]mantengono vero il principio "import ragspinenon trascina alcuna dipendenza pesante".
Conseguenze
- Un nuovo extra
[dify](PyYAML); il coredependenciese il core offline restano invariati. - I nodi non supportati producono uno scheletro eseguibile + warning — gli utenti ottengono uno scaffolding che possono completare direttamente, non una compilazione fallita.
- Lo strato IR riserva punti di estensione per target futuri (orchestrazione spineagent, stima reale dei token, ottimizzazione dinamica).
- Il record era inizialmente proposed: i sei default attendevano la review; ogni modifica segue il principio "sostituire, non modificare" (un nuovo record o un cambio di stato).
Seguito P7 (2026-06-24): accepted
Il testo storico della decisione sopra resta intatto (sostituire, non modificare). Questa sezione fa convergere i default del §7 #1 / #4 da proposed ad accepted e registra le aggiunte P7; lo stato del record passa
proposed → accepteddi conseguenza.
- Default #1 (runtime di destinazione) → accettato e fatto avanzare.
target='spineagent'è implementato (MVP, percorso minimo eseguibile offline). Un workflow con struttura tool-use (≥ 1 nodo tool) si mappa suCoordinator/FunctionCallingAgentdi spineagent, punto di ingressorun_agent(inputs, *, provider=None) -> AgentResult; senza nodi tool sollevaDifyCompileError(code='dify.no_agent_structure')suggerendotarget='ragspine'. - Default #4 (nodi non supportati) → raffinato e accettato. Tre tipi di nodo —
knowledge-retrieval / parameter-extractor / tool — passano dagli hook
NotImplementedErroralla generazione di codice reale: knowledge-retrieval →build_narrative_retriever + retrieve(KNOWLEDGE_CHUNK_DBper default':memory:', uno store vuoto offline); parameter-extractor →provider.chat(tools=[function-tool schema])che fa il parsing ditool_calls; tool → un placeholder@function_tooldi spineagent + il suo punto di invocazione. Solo i nodi che necessitano genuinamente di effetti collaterali esterni (http-request, …) restano hook — un compilatore non può evocare effetti collaterali esterni, e lasciare uno scheletro completabile è il comportamento onesto. - Novità: il pass di fold
answer_question(codegen/fold.py, attivo per default, commutato dafold_answer_question): quando la IR corrisponde strutturalmente allo scheletro Q&Astart → knowledge-retrieval → llm(context points at that retrieval) → answer/end, si ripiega in un'unica chiamataragspine.answer_question(...)— più corta e più corretta del cablaggio manuale di retrieve + chat (riscrittura "not found" integrata di prevenzione delle risposte inventate + provenienza). - Riaffermato: il compilatore in sé non aggiunge alcuna dipendenza di runtime — il codice generato importa
le primitive di recupero di ragspine / spineagent, ma gli import propri di
ragspine.difyrestano puliti;mypy --strict/ ruff /filterwarnings=errortutti verdi.
ADR 0012: Adottare la giunzione LLM di corespine — chat / ChatCompletion al posto di create_message / ProviderResponse
Spostare il Protocol LLMProvider nel core condiviso della famiglia (corespine) e adottare la forma chat-completions di OpenAI; aggiungere un throttle TPM attivo opzionale tramite RateLimitedProvider di corespine.
ADR 0014: Workflow Dify come servizio (analyze / compile / run) con esecuzione sicura a tre livelli
Estende il servizio ragspine esistente con tre endpoint Dify a livello di fiducia crescente — analyze e compile sempre attivi, run disabilitato per impostazione predefinita dietro un gate statico sugli import, una sandbox in-process ristretta e (su Linux) isolamento in sottoprocesso; il provider è sempre deciso lato server.