feat: asked-field envelope, standalone form components, branched forms
This commit is contained in:
parent
0d43b0e8fb
commit
f27be044b4
26 changed files with 3651 additions and 104 deletions
|
|
@ -91,6 +91,8 @@ 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).
|
||||
|
||||
|
|
@ -101,15 +103,22 @@ def kirche_node(
|
|||
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"))
|
||||
from kirche.form_inputs import CustomInputForm
|
||||
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(CustomInputForm, "name", None) or "CustomInputForm"
|
||||
inst = CustomInputForm()
|
||||
comp_name = getattr(component_cls, "name", None) or component_cls.__name__
|
||||
inst = component_cls()
|
||||
if code_override is not None:
|
||||
inst._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)
|
||||
|
|
@ -147,14 +156,18 @@ def kirche_node(
|
|||
# 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.
|
||||
# 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"] = " "
|
||||
field_def["value"] = (
|
||||
[" "] if field_def.get("_input_type") == "MultiselectInput" else " "
|
||||
)
|
||||
return node_dict
|
||||
|
||||
|
||||
|
|
@ -406,6 +419,128 @@ class ApprovalCallbackComponent(Component):
|
|||
"""
|
||||
|
||||
|
||||
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
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
@ -544,6 +679,108 @@ def build_intake_form() -> dict:
|
|||
)
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -551,6 +788,8 @@ def main() -> None:
|
|||
"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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue