kirche-einfach/backend/app/flows/input_schema.py

392 lines
15 KiB
Python

"""Derive a web-form schema from a Langflow export.
Looks for form nodes — ``CustomInput*`` (``data.type`` or the inner
``display_name`` starting with ``CustomInput``, see
``backend/components/kirche/form_inputs.py``) plus user-built
``CustomComponent`` nodes whose display name ends with ``"Form"``
(e.g. ``Mitarbeiter Einstellen Form``) — and maps their ``template``
(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
* ``name`` → property key (freely renamable in the Langflow code editor;
kept verbatim so frontend payload keys match the runtime output —
only empty names are skipped and collisions get a ``_2``/``_3`` suffix),
``display_name`` → ``title``,
``info`` → ``description``, ``value`` → ``default``,
``options`` → ``enum``, ``required`` → required array
* ``show: false`` fields are plumbing (e.g. the run envelope input), not
form fields, and are skipped
* ``advanced`` fields render last via ``ui:order``
* ``_input_type`` → JSON type + widget (unknown types fall back to string
so the parser never crashes on new fields)
* ``[visible_if ...]`` markers in ``info`` → conditional branches
(``allOf`` with ``if``/``then`` per dependent field, arbitrarily nested;
see ``_extract_visibility``). The marker is stripped from the shown
description. Preferred: structured ``visible_if`` key (list of
``{field, value}`` or ``{field: value}``) — no quoting mini-language,
rename-refactorable, validated at sync time with warnings instead of
silent fail-open. Markers stay as deprecated fallback.
A flow without such a node gets ``{fallback: "textarea"}``: the frontend
renders one free-text box instead of a generated form.
"""
from __future__ import annotations
import re
#: ``[visible_if gebaeude=Kirche & bestandteil="Glocke, Orgel"]`` in a
#: field's ``info``. Authored in the Langflow code editor; the parser strips
#: the marker from the shown description and turns the conditions into
#: ``allOf``/``if``/``then`` branches (draft-07 — the ``dependencies``/
#: ``if`` keywords this stack's RJSF + ajv build honors; ``dependentSchemas``
#: is ignored there, so it is never emitted).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
def _form_templates(langflow_json: dict) -> list[dict]:
"""Inner node dicts (``data.node``) of all form nodes."""
if not isinstance(langflow_json, dict):
return []
data = langflow_json.get("data") or {}
nodes = data.get("nodes") or []
found = []
for node in nodes:
if not isinstance(node, dict):
continue
node_data = node.get("data") or {}
node_type = str(node_data.get("type") or "")
inner = node_data.get("node") or {}
display = str(inner.get("display_name") or "")
if node_type.startswith("CustomInput") or display.startswith("CustomInput"):
found.append(inner)
elif node_type == "CustomComponent" and display.endswith("Form"):
# User-built form components (e.g. "Mitarbeiter Einstellen
# Form"): same template contract, no kirche base needed.
found.append(inner)
return found
def _field_schema(name: str, field: dict) -> tuple[dict, dict]:
"""(JSON-Schema property, uiSchema entry) for one template field."""
input_type = str(field.get("_input_type") or "")
prop: dict = {}
ui: dict = {}
title = field.get("display_name") or name
info = field.get("info") or ""
if title:
prop["title"] = title
if info:
prop["description"] = info
options = field.get("options") or []
if input_type in ("StrInput", "MessageTextInput"):
prop["type"] = "string"
elif input_type == "MultilineInput":
prop["type"] = "string"
ui["ui:widget"] = "textarea"
elif input_type == "SecretStrInput":
prop["type"] = "string"
ui["ui:widget"] = "password"
elif input_type == "IntInput":
prop["type"] = "integer"
elif input_type == "FloatInput":
prop["type"] = "number"
elif input_type == "BoolInput":
prop["type"] = "boolean"
elif input_type == "DropdownInput":
prop["type"] = "string"
if options:
prop["enum"] = list(options)
elif input_type == "MultiselectInput":
prop["type"] = "array"
items: dict = {"type": "string"}
if options:
items["enum"] = list(options)
prop["items"] = items
prop["uniqueItems"] = True
elif input_type == "SliderInput":
prop["type"] = "number"
spec = field.get("range_spec") or {}
if spec.get("min") is not None:
prop["minimum"] = spec["min"]
if spec.get("max") is not None:
prop["maximum"] = spec["max"]
if spec.get("step"):
prop["multipleOf"] = spec["step"]
ui["ui:widget"] = "range"
elif input_type == "FileInput":
# Stub for now: string reference only, real upload comes later.
prop["type"] = "string"
else:
prop["type"] = "string"
value = field.get("value")
if value is not None:
# Whitespace-only string values are the flow generator's sentinel
# for "required, filled at runtime" (see make_flow_fixtures.py) —
# not a real default for the web form.
if isinstance(value, str) and not value.strip():
return prop, ui
# A default outside its enum would fail validation on touch.
enum = prop.get("enum")
if not (enum is not None and value not in enum):
# bool is an int subclass; keep integer defaults clean.
if prop.get("type") == "integer" and isinstance(value, bool):
pass
else:
prop["default"] = value
return prop, ui
def _split_conditions(text: str) -> list[str]:
"""Split ``a=X & b="Y Z"`` on ``&`` outside double quotes."""
parts: list[str] = []
buf: list[str] = []
quoted = False
for char in text:
if char == '"':
quoted = not quoted
buf.append(char)
elif char == "&" and not quoted:
parts.append("".join(buf))
buf = []
else:
buf.append(char)
parts.append("".join(buf))
return [part.strip() for part in parts if part.strip()]
def _parse_condition(token: str) -> tuple[str, object] | None:
"""``feld=Wert`` → ``(feld, wert)`` (bool/number coerced, quotes stripped)."""
field, sep, value = token.partition("=")
field, value = field.strip(), value.strip()
if not sep or not field or not value:
return None
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
value = value[1:-1]
lowered = value.lower()
if lowered == "true":
return field, True
if lowered == "false":
return field, False
try:
return field, int(value)
except ValueError:
pass
try:
return field, float(value)
except ValueError:
pass
return field, value
def _extract_visibility(
info: str, field: dict | None = None
) -> tuple[str, list[tuple[str, object]]]:
"""(marker-free description, conditions) for a field's ``info`` text.
Structured ``visible_if`` on the field wins (list of {field, value} or
dict); legacy ``[visible_if ...]`` markers in ``info`` stay as fallback.
"""
if field is not None:
structured = field.get("visible_if")
if structured:
conditions: list[tuple[str, object]] = []
items = structured.items() if isinstance(structured, dict) else structured
for item in items or []:
if isinstance(item, dict) and "field" in item:
conditions.append((item["field"], item.get("value")))
elif isinstance(item, (list, tuple)) and len(item) == 2:
conditions.append((item[0], item[1]))
if conditions:
clean = re.sub(r"\s+", " ", (info or "")).strip()
return clean, conditions
conditions = []
def _collect(match: re.Match) -> str:
for token in _split_conditions(match.group(1)):
parsed = _parse_condition(token)
if parsed is not None:
conditions.append(parsed)
return ""
clean = _VISIBLE_RE.sub(_collect, info or "")
clean = re.sub(r"\s+", " ", clean).strip()
return clean, conditions
def _match_schema(trigger_field: dict, value: object) -> dict | None:
"""JSON-Schema fragment matching one condition, or None (unsupported)."""
input_type = str(trigger_field.get("_input_type") or "")
if input_type == "MultiselectInput":
return {"contains": {"const": value}}
if input_type == "BoolInput":
return {"const": value} if isinstance(value, bool) else None
if input_type in (
"StrInput",
"MessageTextInput",
"MultilineInput",
"SecretStrInput",
"DropdownInput",
):
return {"const": value} if isinstance(value, str) else None
if input_type in ("IntInput", "FloatInput"):
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return {"const": value}
return None
def _cyclic_fields(edges: dict[str, set[str]]) -> set[str]:
"""Fields on a trigger→dependent cycle (fail-open candidates)."""
cyclic: set[str] = set()
visiting: list[str] = []
def _visit(node: str) -> None:
visiting.append(node)
for target in edges.get(node, ()):
if target in visiting:
cyclic.update(visiting[visiting.index(target) :])
elif target not in cyclic:
_visit(target)
visiting.pop()
for node in edges:
if node not in cyclic:
_visit(node)
return cyclic
def extract_form_schema(langflow_json: dict) -> dict:
"""``{json_schema, ui_schema}``, or with ``fallback: "textarea"``."""
templates = _form_templates(langflow_json)
if not templates:
return {
"json_schema": {"type": "object", "properties": {}},
"ui_schema": {},
"fallback": "textarea",
}
template = templates[0].get("template") or {}
# First pass: sanitize names (existing rules) and parse markers.
records: list[dict] = []
taken: set[str] = set()
for key, field in template.items():
if key == "code" or not isinstance(field, dict):
continue
if field.get("show") is False:
continue
raw_name = field.get("name") or key
name = str(raw_name).strip()
if not name:
continue
if name in taken:
suffix = 2
while f"{name}_{suffix}" in taken:
suffix += 1
name = f"{name}_{suffix}"
taken.add(name)
clean_info, conditions = _extract_visibility(str(field.get("info") or ""), field)
records.append(
{
"name": name,
"field": {**field, "info": clean_info},
"required": bool(field.get("required")),
"advanced": bool(field.get("advanced")),
"conditions": conditions,
}
)
by_name = {record["name"]: record for record in records}
# Validate conditions: known trigger + supported match. Anything else
# fails open (field stays always visible) so a stale marker can never
# hide data or break validation.
matches: dict[str, list[tuple[str, dict]]] = {}
for record in records:
resolved: list[tuple[str, dict]] = []
for trigger, value in record["conditions"]:
trigger_record = by_name.get(trigger)
match = _match_schema(trigger_record["field"], value) if trigger_record else None
if trigger_record is None or match is None or trigger == record["name"]:
resolved = []
break
resolved.append((trigger, match))
if resolved:
matches[record["name"]] = resolved
for name in _cyclic_fields(
{name: {trigger for trigger, _ in conds} for name, conds in matches.items()}
):
del matches[name]
# Emit: conditional fields live only in their if/then branch (that is
# what hides them in RJSF); everything else stays at root.
properties: dict = {}
required: list[str] = []
ui_schema: dict = {}
normal: list[str] = []
advanced: list[str] = []
for record in records:
name = record["name"]
prop, ui = _field_schema(name, record["field"])
if name not in matches:
properties[name] = prop
if record["required"]:
required.append(name)
else:
record["branch_prop"] = prop
if ui:
ui_schema[name] = ui
(advanced if record["advanced"] else normal).append(name)
json_schema: dict = {"type": "object", "properties": properties, "required": required}
if matches:
rules = []
for name, conds in matches.items():
record = by_name[name]
rules.append(
{
"if": {
"properties": {trigger: match for trigger, match in conds},
"required": [trigger for trigger, _ in conds],
},
"then": {
"properties": {name: record["branch_prop"]},
**({"required": [name]} if record["required"] else {}),
},
}
)
json_schema["allOf"] = rules
ui_schema["ui:order"] = normal + advanced + ["*"]
elif advanced:
ui_schema["ui:order"] = normal + advanced + ["*"]
return {"json_schema": json_schema, "ui_schema": ui_schema}
def schema_field_names(cached: dict) -> list[str]:
"""All asked field names of a cached form schema (root + branches).
Used for the run envelope's ``fields`` list so the runtime emits
defaults only for asked fields. Order: root properties first, then
branch discoveries.
"""
if not isinstance(cached, dict):
return []
json_schema = cached.get("json_schema") or {}
names: list[str] = []
def _walk(node: object) -> None:
if not isinstance(node, dict):
return
for subschema in (node.get("then"), node.get("else")):
if isinstance(subschema, dict):
for key in subschema.get("properties") or {}:
if key not in names:
names.append(key)
_walk(subschema)
for key in ("allOf", "oneOf", "anyOf"):
items = node.get(key) or []
if isinstance(items, list):
for item in items:
_walk(item)
for key in json_schema.get("properties") or {}:
if key not in names:
names.append(key)
_walk(json_schema)
return names