kirche-einfach/backend/scripts/make_flow_fixtures.py

877 lines
30 KiB
Python

"""Generate Langflow-export-format flow JSON fixtures.
Uses lfx's own serializers (Component.to_frontend_node /
build_custom_component_template), so the output is byte-faithful to what the
Langflow UI would export. Run with the backend venv (has the `lfx` dev dep)::
uv run python scripts/make_flow_fixtures.py
Writes to two places:
- backend/tests/fixtures/flows/ (test fixtures, never deployed as-is)
- backend/flows/ (canonical source of truth, synced into DB + LFX)
Then verify::
uv run lfx validate --level 4 backend/tests/fixtures/flows/
"""
from __future__ import annotations
import importlib
import json
import sys
import uuid
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
FIXTURE_DIR = REPO_ROOT / "backend" / "tests" / "fixtures" / "flows"
FLOW_DIR = REPO_ROOT / "backend" / "flows"
HANDLE_QUOTE = "\u0153" # ReactFlow handle-id quoting used by Langflow exports
FORM_CORE_PATH = REPO_ROOT / "backend" / "components" / "kirche" / "_form_core.py"
INLINE_MARKER = "# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()"
def _inline_core() -> str:
"""Body of _form_core.py minus docstring/future/std-imports (already present)."""
text = FORM_CORE_PATH.read_text(encoding="utf-8")
lines = text.splitlines()
body: list[str] = []
skip_docstring = True
in_docstring = False
for line in lines:
stripped = line.strip()
if skip_docstring:
if stripped.startswith('"""') and not in_docstring:
if stripped.count('"""') >= 2:
skip_docstring = False
else:
in_docstring = True
continue
if in_docstring:
if '"""' in stripped:
in_docstring = False
skip_docstring = False
continue
if stripped in (
"from __future__ import annotations",
"import json",
"import re",
"",
):
continue
skip_docstring = False
if stripped in ("from __future__ import annotations", "import json", "import re"):
continue
body.append(line)
return "\n".join(body).strip() + "\n"
def _compose_component_code(source: str) -> str:
"""Replace the sibling import + marker with inlined core (LFX self-contained)."""
core = _inline_core()
out: list[str] = []
skipping_import = False
for line in source.splitlines():
stripped = line.strip()
if not skipping_import and stripped.startswith(
("from kirche._form_core import", "from ._form_core import")
):
out.append(core.rstrip())
# Multi-line parenthesized import: skip continuation lines
# up to and including the closing paren.
if "(" in stripped and ")" not in stripped:
skipping_import = True
continue
if skipping_import:
if ")" in stripped:
skipping_import = False
continue
if "_form_core inlined by" in stripped:
continue
out.append(line)
composed = "\n".join(out)
assert "from kirche" not in composed, "sibling import survived inlining"
assert "from ._" not in composed, "relative import survived inlining"
return composed
# --------------------------------------------------------------------------
# node builders
# --------------------------------------------------------------------------
def _normalize(node_dict: dict, node_id: str, comp_name: str, x: int, y: int) -> dict:
# to_frontend_node() returns an envelope {"data": {...}, "id": ...};
# build_custom_component_template() returns the bare node dict.
if "data" not in node_dict:
node_dict = {
"data": {"node": node_dict, "type": comp_name, "id": node_id},
"id": node_id,
}
else:
node_dict["id"] = node_id
node_dict["data"]["id"] = node_id
node_dict["data"]["type"] = comp_name
node_dict["data"]["showNode"] = True
node_dict["measured"] = {"width": 320, "height": 200}
node_dict["position"] = {"x": x, "y": y}
node_dict["type"] = "genericNode"
return node_dict
def builtin_node(
module_path: str,
class_name: str,
node_id: str,
values: dict | None = None,
position: tuple[int, int] = (0, 0),
) -> dict:
mod = importlib.import_module(module_path)
cls = getattr(mod, class_name)
inst = cls()
node_dict = _normalize(inst.to_frontend_node(), node_id, cls.name, *position)
for field, value in (values or {}).items():
node_dict["data"]["node"]["template"][field]["value"] = value
return node_dict
def custom_node(
code: str,
node_id: str,
values: dict | None = None,
position: tuple[int, int] = (0, 0),
) -> dict:
from lfx.custom import validate as validate_mod
from lfx.custom.utils import build_custom_component_template
cls = validate_mod.create_class(code, validate_mod.extract_class_name(code))
inst = cls()
inst._code = code
bare, _ = build_custom_component_template(inst)
node_dict = _normalize(bare, node_id, "CustomComponent", *position)
for field, value in (values or {}).items():
node_dict["data"]["node"]["template"][field]["value"] = value
return node_dict
def kirche_node(
node_id: str,
values: dict | None = None,
position: tuple[int, int] = (0, 0),
component_cls=None,
code_override: str | None = None,
) -> dict:
"""A node from our GUI-editable category lib (backend/components/kirche).
Built with lfx's own frontend-node serialization, exactly like
``Component.to_frontend_node()`` — minus its final ``Template.from_dict``
round-trip: lfx 1.12.x cannot re-instantiate ``MultiselectInput`` (it
emits ``list``, the constructor forbids extras; their own builtins fail
the same way). The template dict before that round-trip is complete and
faithful, so we use it directly and append the ``code`` field by hand
(mirroring the shape ``build_custom_component_template`` produces).
``component_cls`` builds a node from a variant class (e.g. a flow whose
fields differ from ``CustomInputForm``); ``code_override`` embeds that
variant's source (``set_class_code`` cannot inspect exec-built classes).
"""
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
if component_cls is None:
from kirche.form_inputs import CustomInputForm as component_cls
from lfx.template.frontend_node.custom_components import (
ComponentFrontendNode,
)
comp_name = getattr(component_cls, "name", None) or component_cls.__name__
inst = component_cls()
if code_override is not None:
inst._code = _compose_component_code(code_override)
frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst))
inst._map_parameters_on_frontend_node(frontend_node)
node = frontend_node.to_dict(keep_name=False)
node = inst._update_template(node)
inst._map_parameters_on_template(node["template"])
if not getattr(inst, "_code", None):
inst.set_class_code()
# Always compose: default path may already carry thin source with the
# sibling import (Component base sets _code on init); override path is
# pre-composed but re-composing is idempotent (no import lines left).
inst._code = _compose_component_code(inst._code)
node["template"]["code"] = {
"advanced": True,
"api_editable": False,
"dynamic": True,
"fileTypes": [],
"info": "",
"list": False,
"load_from_db": False,
"multiline": True,
"name": "code",
"password": False,
"placeholder": "",
"required": True,
"show": True,
"title_case": False,
"type": "code",
"value": inst._code,
}
node_dict = {
"data": {"node": node, "type": comp_name, "id": node_id},
"id": node_id,
}
node_dict = _normalize(node_dict, node_id, comp_name, *position)
for field, value in (values or {}).items():
node_dict["data"]["node"]["template"][field]["value"] = value
# lfx validate --level 4 errors on required inputs with no value and no
# incoming edge. Our form fields are filled at runtime via the
# form_values envelope (collect() merges), never via edges — so required
# template fields carry a single-space sentinel value. The form parser
# (app/flows/input_schema.py) treats whitespace-only values as "no
# default", keeping the web form clean. Multiselect needs a list to
# survive runtime assignment, so it gets [" "] (skipped the same way
# via the enum-mismatch rule).
for field_def in node_dict["data"]["node"]["template"].values():
if (
isinstance(field_def, dict)
and field_def.get("required")
and field_def.get("value") in (None, "", [], {})
):
field_def["value"] = (
[" "] if field_def.get("_input_type") == "MultiselectInput" else " "
)
return node_dict
# --------------------------------------------------------------------------
# edges
# --------------------------------------------------------------------------
def _output_def(node: dict, output_name: str) -> dict:
for out in node["data"]["node"]["outputs"]:
if out["name"] == output_name:
return out
raise KeyError(f"output {output_name!r} not in {node['id']}")
def _input_def(node: dict, input_name: str) -> dict:
field = node["data"]["node"]["template"][input_name]
return field
def edge(src: dict, src_output: str, dst: dict, dst_input: str) -> dict:
out = _output_def(src, src_output)
inp = _input_def(dst, dst_input)
source_handle = {
"dataType": src["data"]["type"],
"id": src["id"],
"name": src_output,
"output_types": out.get("types", []),
}
target_handle = {
"fieldName": dst_input,
"id": dst["id"],
"inputTypes": inp.get("input_types", []),
"type": inp.get("type", "other"),
}
sh = json.dumps(source_handle).replace('"', HANDLE_QUOTE)
th = json.dumps(target_handle).replace('"', HANDLE_QUOTE)
return {
"animated": False,
"className": "",
"data": {"sourceHandle": source_handle, "targetHandle": target_handle},
"id": f"xy-edge__{sh}-{th}",
"selected": False,
"source": src["id"],
"sourceHandle": sh,
"target": dst["id"],
"targetHandle": th,
}
# --------------------------------------------------------------------------
# flow document
# --------------------------------------------------------------------------
def flow_doc(name: str, description: str, nodes: list[dict], edges: list[dict]) -> dict:
return {
"data": {
"nodes": nodes,
"edges": edges,
"viewport": {"x": 0, "y": 0, "zoom": 1},
},
"description": description,
"endpoint_name": None,
"icon": None,
"icon_bg_color": None,
"id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{name}")),
"is_component": False,
"locked": False,
"mcp_enabled": False,
"name": name,
"tags": [],
"webhook": False,
}
# --------------------------------------------------------------------------
# custom component code
# --------------------------------------------------------------------------
RULE_CHECK_CODE = """from lfx.custom import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
import json
class RuleCheckComponent(Component):
display_name = "Rule Check"
description = "Bewertet Betrag gegen Schwelle aus JSON-Eingabe."
inputs = [
MessageTextInput(name="payload", display_name="Payload"),
]
outputs = [
Output(display_name="Verdict", name="verdict", method="check"),
]
def _text(self, value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def check(self) -> Message:
try:
data = json.loads(self._text(self.payload) or "{}")
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
# Die API ruft Flows immer mit Huelle auf
# {"payload": {...}, "run_id": ..., ...}; rohe Payloads gehen auch.
if isinstance(data.get("payload"), dict):
data = data["payload"]
try:
betrag = float(data.get("betrag", 0) or 0)
except (TypeError, ValueError):
betrag = 0.0
try:
schwelle = float(data.get("schwelle", 200000) or 200000)
except (TypeError, ValueError):
schwelle = 200000.0
return Message(text="PRUEFUNG" if betrag > schwelle else "OK")
"""
ENVELOPE_CODE = """from lfx.custom import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
import json
class EnvelopeComponent(Component):
display_name = "Envelope"
description = "Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext."
inputs = [
MessageTextInput(name="raw", display_name="Raw Input"),
]
outputs = [
Output(display_name="Payload", name="payload", method="get_payload"),
Output(display_name="Run ID", name="run_id", method="get_run_id"),
Output(
display_name="Callback URL",
name="callback_url",
method="get_callback_url",
),
Output(
display_name="Continuation",
name="continuation",
method="get_continuation",
),
Output(
display_name="Callback Token",
name="callback_token",
method="get_callback_token",
),
]
def _text(self, value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def _env(self) -> dict:
if not hasattr(self, "_env_cache"):
try:
self._env_cache = json.loads(self._text(self.raw) or "{}")
except Exception:
self._env_cache = {}
if not isinstance(self._env_cache, dict):
self._env_cache = {}
return self._env_cache
def get_payload(self) -> Message:
return Message(text=json.dumps(self._env().get("payload", {})))
def get_run_id(self) -> Message:
return Message(text=str(self._env().get("run_id", "")))
def get_callback_url(self) -> Message:
return Message(text=str(self._env().get("callback_url", "")))
def get_continuation(self) -> Message:
value = self._env().get("continuation_flow_id")
return Message(text=str(value) if value else "")
def get_callback_token(self) -> Message:
return Message(text=str(self._env().get("callback_token", "")))
"""
APPROVAL_CALLBACK_CODE = """from lfx.custom import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
class ApprovalCallbackComponent(Component):
display_name = "Approval Callback"
description = "Meldet einen Sonderfall per HTTP an unsere API und terminiert."
inputs = [
MessageTextInput(name="trigger", display_name="Trigger"),
MessageTextInput(name="run_id", display_name="Run ID"),
MessageTextInput(name="callback_url", display_name="Callback URL"),
MessageTextInput(
name="callback_token",
display_name="Callback Token",
),
MessageTextInput(
name="continuation_flow_id",
display_name="Continuation Flow ID",
),
MessageTextInput(name="facts", display_name="Facts"),
]
outputs = [
Output(display_name="Result", name="result", method="notify"),
]
def _text(self, value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def notify(self) -> Message:
import httpx
import json
trigger = self._text(self.trigger).strip()
if not trigger:
# Zweig wurde nicht genommen: kein Seiteneffekt.
return Message(text="")
try:
facts = json.loads(self._text(self.facts) or "{}")
except Exception:
facts = {}
url = self._text(self.callback_url).rstrip("/") + "/api/approvals"
body = {
"run_id": self._text(self.run_id),
"title": f"Freigabe erforderlich ({trigger})",
"payload": {"verdict": trigger, "facts": facts},
"continuation_flow_id": self._text(self.continuation_flow_id) or None,
}
headers = {"X-Callback-Token": self._text(self.callback_token)}
try:
response = httpx.post(url, json=body, headers=headers, timeout=10.0)
response.raise_for_status()
except Exception as exc:
return Message(text=f"CALLBACK_FAILED: {exc}")
return Message(text="PENDING_HUMAN_APPROVAL")
"""
GEHALT_CODE = """from lfx.custom import Component
from lfx.io import HandleInput, Output
from lfx.schema import Data, Message
try:
from lfx.schema import DataFrame
except Exception: # pragma: no cover
DataFrame = None
import json
class GehaltsrechnerComponent(Component):
display_name = "Gehaltsrechner"
description = (
"Calculates the annual salary (Gehalt) for church employees based on their role "
"(rolle) and level (einstufung). Use this when you need to determine the yearly "
"salary for roles such as Assistenz, Vikar:in, Messner:in or Pfarrer:in."
)
icon = "Calculator"
inputs = [
HandleInput(
name="input_data",
display_name="Employee Data",
input_types=["JSON", "Data", "Message", "DataFrame", "Table"],
info=(
"Employee data as JSON/Data/Message/DataFrame. Must contain "
"'rolle' (and 'einstufung' for Pfarrer:in), plus 'mitarbeiter_name' and 'geburtsdatum'."
),
),
]
outputs = [
Output(
name="salary_text",
display_name="Salary Text",
method="calculate_salary",
),
]
# Salary mapping for simple roles
BASE_SALARY = {
"Assistenz": 42000,
"Vikar:in": 48000,
"Messner:in": 36000,
}
# Salary mapping for Pfarrer:in per Einstufung
PFARRER_SALARY = {
"Stufe 1": 55000,
"Stufe 2": 65000,
"Stufe 3": 75000,
}
def _extract_data(self, value) -> dict | None:
'''Normalize the incoming value into a plain dict.'''
if isinstance(value, dict):
return value
if isinstance(value, Data):
return value.data if isinstance(value.data, dict) else None
if isinstance(value, Message):
value = value.text
if DataFrame is not None and isinstance(value, DataFrame):
try:
records = value.to_dict(orient="records")
if records:
return records[0]
except Exception:
return None
return None
if isinstance(value, str):
text = value.strip()
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
return parsed
if isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
return parsed[0]
except (json.JSONDecodeError, TypeError, ValueError):
return None
return None
def calculate_salary(self) -> Message:
try:
data = self._extract_data(self.input_data)
if not isinstance(data, dict):
return Message(
text="Error: Could not parse employee data. Please provide a valid JSON object."
)
rolle = data.get("rolle")
einstufung = data.get("einstufung")
name = data.get("mitarbeiter_name", "unknown")
if not rolle:
return Message(
text="Error: Missing required field 'rolle' in employee data."
)
if rolle in self.BASE_SALARY:
salary = self.BASE_SALARY[rolle]
elif rolle == "Pfarrer:in":
if einstufung not in self.PFARRER_SALARY:
return Message(
text=(
"Error: For role 'Pfarrer:in', a valid 'einstufung' "
"(Stufe 1, Stufe 2 or Stufe 3) is required."
)
)
salary = self.PFARRER_SALARY[einstufung]
else:
return Message(text=f"Error: Unknown role '{rolle}'.")
einstufung_display = einstufung if einstufung else "-"
return Message(
text=(
f"Mitarbeiter:{name} | Rolle:{rolle} | "
f"Einstufung:{einstufung_display} | Gehalt:{salary} EUR pro Jahr"
)
)
except Exception as exc: # never raise, always return a clear error string
return Message(text=f"Error: {exc}")
"""
# --------------------------------------------------------------------------
# flows
# --------------------------------------------------------------------------
ROUTER_VALUES = {
"operator": "equals",
"match_text": "PRUEFUNG",
"true_case_message": "Sonderfall: menschliche Pruefung erforderlich",
"false_case_message": "Standardfall: automatisch freigegeben",
}
def build_simple_rule() -> dict:
entry = builtin_node(
"lfx.components.input_output.chat",
"ChatInput",
"ChatInput-entry",
position=(0, 200),
)
rule = custom_node(RULE_CHECK_CODE, "CustomComponent-rule", position=(400, 200))
router = builtin_node(
"lfx.components.flow_controls.conditional_router",
"ConditionalRouterComponent",
"ConditionalRouter-router",
values=ROUTER_VALUES,
position=(800, 200),
)
out_ok = builtin_node(
"lfx.components.input_output.chat_output",
"ChatOutput",
"ChatOutput-ok",
position=(1200, 60),
)
out_sonder = builtin_node(
"lfx.components.input_output.chat_output",
"ChatOutput",
"ChatOutput-sonder",
position=(1200, 340),
)
nodes = [entry, rule, router, out_ok, out_sonder]
edges = [
edge(entry, "message", rule, "payload"),
edge(rule, "verdict", router, "input_text"),
edge(router, "false_result", out_ok, "input_value"),
edge(router, "true_result", out_sonder, "input_value"),
]
return flow_doc(
"simple_rule",
"Regelpruefung ohne Seiteneffekt (Spike-Fixture, kein LLM).",
nodes,
edges,
)
def build_approval_handoff() -> dict:
entry = builtin_node(
"lfx.components.input_output.chat",
"ChatInput",
"ChatInput-entry",
position=(0, 300),
)
envelope = custom_node(ENVELOPE_CODE, "CustomComponent-envelope", position=(400, 300))
rule = custom_node(RULE_CHECK_CODE, "CustomComponent-rule", position=(800, 300))
router = builtin_node(
"lfx.components.flow_controls.conditional_router",
"ConditionalRouterComponent",
"ConditionalRouter-router",
values=ROUTER_VALUES,
position=(1200, 300),
)
callback = custom_node(
APPROVAL_CALLBACK_CODE,
"CustomComponent-callback",
position=(1600, 160),
)
out_pending = builtin_node(
"lfx.components.input_output.chat_output",
"ChatOutput",
"ChatOutput-pending",
position=(2000, 160),
)
out_ok = builtin_node(
"lfx.components.input_output.chat_output",
"ChatOutput",
"ChatOutput-ok",
position=(2000, 440),
)
nodes = [entry, envelope, rule, router, callback, out_pending, out_ok]
edges = [
edge(entry, "message", envelope, "raw"),
edge(envelope, "payload", rule, "payload"),
edge(rule, "verdict", router, "input_text"),
edge(router, "true_result", callback, "trigger"),
edge(envelope, "run_id", callback, "run_id"),
edge(envelope, "callback_url", callback, "callback_url"),
edge(envelope, "callback_token", callback, "callback_token"),
edge(envelope, "continuation", callback, "continuation_flow_id"),
edge(envelope, "payload", callback, "facts"),
edge(callback, "result", out_pending, "input_value"),
edge(router, "false_result", out_ok, "input_value"),
]
return flow_doc(
"approval_handoff",
"Sonderfall meldet sich per HTTP an unsere API und terminiert (Spike).",
nodes,
edges,
)
def build_intake_form() -> dict:
entry = builtin_node(
"lfx.components.input_output.chat",
"ChatInput",
"ChatInput-entry",
position=(0, 200),
)
form = kirche_node(
"CustomInputForm-intake",
position=(400, 200),
)
out = builtin_node(
"lfx.components.input_output.chat_output",
"ChatOutput",
"ChatOutput-collected",
position=(800, 200),
)
nodes = [entry, form, out]
edges = [
edge(entry, "message", form, "form_values"),
edge(form, "form_data", out, "input_value"),
]
return flow_doc(
"intake_form",
"Structured intake via CustomInputForm (parsed into a web form).",
nodes,
edges,
)
def build_bauvorhaben() -> dict:
"""Branched Bauvorhaben form via the standalone Bauvorhaven component."""
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
from kirche.bauvorhaben_form import CustomInputBauvorhaben
entry = builtin_node(
"lfx.components.input_output.chat",
"ChatInput",
"ChatInput-entry",
position=(0, 200),
)
inst = CustomInputBauvorhaben()
if not getattr(inst, "_code", None):
inst.set_class_code()
form = kirche_node(
"CustomInputBauvorhaben-bauvorhaben",
position=(400, 200),
component_cls=CustomInputBauvorhaben,
code_override=inst._code,
)
# Logical question order: RJSF renders schema properties in insertion
# order and the parser derives ui:order from it (lfx emits alphabetical).
order = [
"form_values",
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
]
template = form["data"]["node"]["template"]
form["data"]["node"]["template"] = {k: template[k] for k in order} | {
k: v for k, v in template.items() if k not in order
}
out = builtin_node(
"lfx.components.input_output.chat_output",
"ChatOutput",
"ChatOutput-collected",
position=(800, 200),
)
nodes = [entry, form, out]
edges = [
edge(entry, "message", form, "form_values"),
edge(form, "form_data", out, "input_value"),
]
return flow_doc(
"bauvorhaben",
"Bauvorhaben: verzweigtes Formular mit mehrstufigen Abhängigkeiten.",
nodes,
edges,
)
def build_mitarbeiter() -> dict:
"""Hiring form via the standalone Mitarbeiter component + Gehaltsrechner."""
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
from kirche.mitarbeiter_form import CustomInputMitarbeiter
entry = builtin_node(
"lfx.components.input_output.chat",
"ChatInput",
"ChatInput-entry",
position=(0, 200),
)
inst = CustomInputMitarbeiter()
if not getattr(inst, "_code", None):
inst.set_class_code()
form = kirche_node(
"CustomInputMitarbeiter-mitarbeiter",
position=(400, 200),
component_cls=CustomInputMitarbeiter,
code_override=inst._code,
)
# Logical question order (see build_bauvorhaben).
order = ["form_values", "rolle", "einstufung", "mitarbeiter_name", "geburtsdatum"]
template = form["data"]["node"]["template"]
form["data"]["node"]["template"] = {k: template[k] for k in order} | {
k: v for k, v in template.items() if k not in order
}
gehalt = custom_node(GEHALT_CODE, "Gehaltsrechner-gehalt", position=(800, 200))
out = builtin_node(
"lfx.components.input_output.chat_output",
"ChatOutput",
"ChatOutput-collected",
position=(1200, 200),
)
nodes = [entry, form, gehalt, out]
edges = [
edge(entry, "message", form, "form_values"),
edge(form, "form_data", gehalt, "input_data"),
edge(gehalt, "salary_text", out, "input_value"),
]
return flow_doc(
"mitarbeiter_einstellen",
"Mitarbeiter einstellen: Formular mit Gehaltsrechner.",
nodes,
edges,
)
def main() -> None:
FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
FLOW_DIR.mkdir(parents=True, exist_ok=True)
flows = {
"simple_rule.json": build_simple_rule(),
"approval_handoff.json": build_approval_handoff(),
"intake_form.json": build_intake_form(),
"bauvorhaben.json": build_bauvorhaben(),
"mitarbeiter_einstellen.json": build_mitarbeiter(),
}
for filename, doc in flows.items():
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
for target in (FIXTURE_DIR / filename, FLOW_DIR / filename):
# Explicit encoding + LF: the default locale/newline handling
# corrupts non-ASCII flow code on Windows (cp1252) and dirties
# every line with CRLF.
target.write_text(payload, encoding="utf-8", newline="\n")
print(f"wrote {target}")
if __name__ == "__main__":
sys.exit(main())