Compare commits

..

No commits in common. "06ab25fb4732a473e16a97bb3becbb15d863d725" and "0b03399c1a36f9b6f761f56ba306a21074d52e46" have entirely different histories.

53 changed files with 490 additions and 6949 deletions

View file

@ -5,50 +5,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Changed
- Declutter: shared `BaseApiClient` (LFX/Langflow), single `get_db`, `model_validate` mappers, unified LFX/Langflow error mapping, stream logic moved to `runner.py`, `sync_full_pass` single orchestration (API+CLI), shared model base (`utcnow`/`ts_column`), unified auth counters, `httpx` in `deploy_flow.py`.
- Forms: shared `components/kirche/_form_core.py` single source (codegen-inlined for LFX), structured `visible_if` key alongside legacy markers, `LangflowClient.upsert_flow` + `scripts/push_flows_to_langflow.py` (6 canonical flows pushed to Langflow UI).
- Tests (full real LFX kept, 146 green): shared `conftest` helpers (`load_flow_fixture`, `flow_helpers`, `lfx_client`, `anon_client`), `tests/_harness.py` shared with smoke script, parametrized `filename_stem`.
- Frontend: shared `formatApiError`, `<ErrorAlert/>`, `<RunsTable/>`, `URLSearchParams` queries, localized run dates.
### Added
- Branched questionnaires: `[visible_if feld=Wert]` markers in a
`CustomInputForm` field's `info` (code editor) show the field only when
the condition holds — nested to any depth, with `&` for AND conditions
and quotes for values with spaces. The parser emits `allOf`/`if`/`then`
branches (branch-scoped `required`, template-order placement); unknown
triggers, cycles, and unsupported trigger types fail open (always
visible). New demo flow `bauvorhaben.json` (4 levels + sibling branch +
bool trigger). Values of hidden fields are kept and submitted.
- Run envelope carries the asked field names (`fields`, from the
version's form schema): `collect()` emits defaults only for asked
AND currently visible fields (hidden branch fields stay out, like the
frontend never submits them), so templates may declare fields unknown
to the component code and unasked code defaults stay out of the run
output. Without the list (raw dicts, Langflow playground) all visible
declared defaults apply.
- Standalone `CustomInputBauvorhaben` component ("Bauvorhaben Form" in
the kirche palette, zero shared code with `CustomInputForm` — the LFX
runtime executes embedded module source without our package); the
bauvorhaben flow uses it, and Langflow no longer normalizes the node
back to the default form on import.
- Form parser also recognizes user-built `CustomComponent` nodes whose
display name ends with `"Form"` (e.g. Mitarbeiter Einstellen Form) —
same template contract, no kirche base needed.
- Standalone `CustomInputMitarbeiter` component ("Mitarbeiter Einstellen
Form") with hiring flow incl. Gehaltsrechner, rebuilt generator-owned.
- Admin flow table shows one row per flow (latest version, with live
version note when an older version is deployed) instead of one row
per version.
- Step-by-step guide for new workflow modules: `docs/new_workflow_module.md`
(linked from README).
### Changed
- `CustomInputForm.collect()` is now dynamic and returns `Data` (JSON)
instead of `Message`: fields can be renamed, added, or removed in the
Langflow code editor with no `collect()` change, and values travel as a
real JSON object. The form parser keeps `name` keys verbatim (empty
names skipped, collisions suffixed) and the run envelope merges unknown
keys. Note: a terminal `ChatOutput` renders `Data` as a fenced ```json
block, and downstream components should read `.data`, not `.text`.
### Added ### Added
- Flow-Sync ist jetzt ein manueller Full-Pass: `POST /api/flows/sync` - Flow-Sync ist jetzt ein manueller Full-Pass: `POST /api/flows/sync`
(Button "Aus Langflow synchronisieren" in der Verwaltung) holt erst alle (Button "Aus Langflow synchronisieren" in der Verwaltung) holt erst alle

View file

@ -133,14 +133,6 @@ DB + LFX deploy) → commit. File-only alternatives: `scripts/sync_flows.py
watcher deploys file edits automatically. Deleting a file removes the flow watcher deploys file edits automatically. Deleting a file removes the flow
including its runs (git keeps the JSON). including its runs (git keeps the JSON).
New workflow module? Step-by-step guide: `docs/new_workflow_module.md`.
Pull safety: sync matches Langflow flows to files **by name** and
overwrites silently — renaming or repurposing a flow in Langflow (or a
duplicate with the same name) lands in the matched file. Commit before
syncing and review the `pulled` report with `git diff`; uncommitted
work in `backend/flows/` can be overwritten.
## Production (self-hosted — TLS/reverse proxy required, not included) ## Production (self-hosted — TLS/reverse proxy required, not included)
1. Check out the repo on the host, create `.env` from `.env.example` with 1. Check out the repo on the host, create `.env` from `.env.example` with

View file

@ -6,11 +6,16 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from sqlmodel import Session from sqlmodel import Session
from app.db import get_db
from app.models import User from app.models import User
from . import mail, service from . import mail, service
from .deps import SESSION_COOKIE, SESSION_MAX_AGE, get_current_user, require_admin from .deps import (
SESSION_COOKIE,
SESSION_MAX_AGE,
get_current_user,
get_db,
require_admin,
)
from .schemas import ( from .schemas import (
AccessDecisionRead, AccessDecisionRead,
AccessRequestCreate, AccessRequestCreate,
@ -28,11 +33,26 @@ auth_router = APIRouter(prefix="/api/auth", tags=["auth"])
def _read_request(r) -> AccessRequestRead: def _read_request(r) -> AccessRequestRead:
return AccessRequestRead.model_validate(r, from_attributes=True) return AccessRequestRead(
id=r.id,
name=r.name,
email=r.email,
reason=r.reason,
status=r.status,
created_at=r.created_at,
decided_at=r.decided_at,
decided_by=r.decided_by,
)
def _read_me(user: User) -> MeRead: def _read_me(user: User) -> MeRead:
return MeRead.model_validate(user, from_attributes=True) return MeRead(
id=user.id,
email=user.email,
name=user.name,
role=user.role,
tenant_id=user.tenant_id,
)
def _set_session_cookie(response: Response, session_id: str) -> None: def _set_session_cookie(response: Response, session_id: str) -> None:

View file

@ -1,13 +1,14 @@
"""Session-cookie dependencies. Handlers stay thin; DB via app.db.get_db.""" """Session-cookie dependencies. Handlers stay thin; DB via get_session."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterator
from typing import Annotated from typing import Annotated
from fastapi import Depends, HTTPException, Request from fastapi import Depends, HTTPException, Request
from sqlmodel import Session from sqlmodel import Session
from app.db import get_db from app.db import get_session
from app.models import User from app.models import User
from . import service from . import service
@ -16,6 +17,10 @@ SESSION_COOKIE = "kirche_session"
SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds()) SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds())
def get_db() -> Iterator[Session]:
yield from get_session()
def get_optional_user( def get_optional_user(
request: Request, session: Annotated[Session, Depends(get_db)] request: Request, session: Annotated[Session, Depends(get_db)]
) -> User | None: ) -> User | None:

View file

@ -22,6 +22,11 @@ BLUE_LIGHT = "#eef4fb"
INK = "#162f4c" INK = "#162f4c"
MUTED = "#5b6b7c" MUTED = "#5b6b7c"
BLUE = "#1d4a79"
BLUE_LIGHT = "#eef4fb"
INK = "#162f4c"
MUTED = "#5b6b7c"
def magic_link(token: str) -> str: def magic_link(token: str) -> str:
return f"{settings.frontend_url.rstrip('/')}/login?token={token}" return f"{settings.frontend_url.rstrip('/')}/login?token={token}"

View file

@ -15,7 +15,6 @@ from uuid import UUID
from sqlmodel import Session, func, select from sqlmodel import Session, func, select
from app.models import AccessRequest, MagicLinkToken, User, UserSession from app.models import AccessRequest, MagicLinkToken, User, UserSession
from app.models.base import utcnow
from app.settings import settings from app.settings import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -61,6 +60,10 @@ class RateLimitedError(AuthError):
pass pass
def utcnow() -> datetime:
return datetime.now(UTC)
def normalize_email(email: str) -> str: def normalize_email(email: str) -> str:
return email.strip().lower() return email.strip().lower()
@ -89,20 +92,16 @@ def user_count(session: Session) -> int:
return session.exec(select(func.count()).select_from(User)).one() return session.exec(select(func.count()).select_from(User)).one()
def _recent_count(session: Session, model, *filters, hours: int = 1) -> int: def recent_request_count(session: Session, email: str, hours: int = 1) -> int:
since = utcnow() - timedelta(hours=hours) since = utcnow() - timedelta(hours=hours)
return session.exec( return session.exec(
select(func.count()).select_from(model).where(*filters, model.created_at >= since) select(func.count())
).one() .select_from(AccessRequest)
.where(
def recent_request_count(session: Session, email: str, hours: int = 1) -> int:
return _recent_count(
session,
AccessRequest,
AccessRequest.email == normalize_email(email), AccessRequest.email == normalize_email(email),
hours=hours, AccessRequest.created_at >= since,
) )
).one()
def create_access_request( def create_access_request(
@ -166,7 +165,15 @@ def issue_magic_token(session: Session, user: User) -> str:
def recent_token_count(session: Session, user: User, hours: int = 1) -> int: def recent_token_count(session: Session, user: User, hours: int = 1) -> int:
return _recent_count(session, MagicLinkToken, MagicLinkToken.user_id == user.id, hours=hours) since = utcnow() - timedelta(hours=hours)
return session.exec(
select(func.count())
.select_from(MagicLinkToken)
.where(
MagicLinkToken.user_id == user.id,
MagicLinkToken.created_at >= since,
)
).one()
def request_magic_link(session: Session, email: str) -> tuple[User, str]: def request_magic_link(session: Session, email: str) -> tuple[User, str]:

View file

@ -44,11 +44,6 @@ def get_session() -> Iterator[Session]:
yield session yield session
def get_db() -> Iterator[Session]:
"""Single FastAPI DB dependency (all routers import this)."""
yield from get_session()
def create_all(url: str | None = None) -> None: def create_all(url: str | None = None) -> None:
"""Create tables directly (dev convenience; migrations own prod schema).""" """Create tables directly (dev convenience; migrations own prod schema)."""
SQLModel.metadata.create_all(get_engine(url)) SQLModel.metadata.create_all(get_engine(url))

View file

@ -12,24 +12,30 @@ from __future__ import annotations
import json import json
import logging import logging
import secrets import secrets
from collections.abc import AsyncIterator from collections.abc import AsyncIterator, Iterator
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
from uuid import UUID from uuid import UUID
import httpx
from fastapi import APIRouter, Depends, Header, HTTPException, Query from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_user, require_admin from app.auth.deps import get_current_user, require_admin
from app.db import get_db from app.db import get_engine, get_session
from app.models import User from app.models import User
from app.settings import settings from app.settings import settings
from . import input_schema as form_schema from . import input_schema as form_schema
from . import runner, store from . import runner, store
from .deploy import import_from_langflow from .deploy import import_from_langflow, pull_all_from_langflow
from .langflow_client import LangflowClient, LangflowError, LangflowNotFound, LangflowUnavailable from .langflow_client import (
LangflowClient,
LangflowError,
LangflowNotFound,
LangflowUnavailable,
)
from .lfx_client import ( from .lfx_client import (
LfxClient, LfxClient,
LfxConflict, LfxConflict,
@ -54,7 +60,7 @@ from .schemas import (
RunRequest, RunRequest,
SyncReport, SyncReport,
) )
from .sync import lfx_id_for from .sync import lfx_id_for, sync_from_directory
flows_router = APIRouter(prefix="/api/flows", tags=["flows"]) flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"]) approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
@ -62,6 +68,10 @@ approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_db() -> Iterator[Session]:
yield from get_session()
def get_lfx() -> LfxClient: def get_lfx() -> LfxClient:
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None) return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
@ -71,40 +81,60 @@ def get_langflow() -> LangflowClient:
def _read_definition(d) -> FlowDefinitionRead: def _read_definition(d) -> FlowDefinitionRead:
return FlowDefinitionRead.model_validate(d, from_attributes=True) return FlowDefinitionRead(
id=d.id,
name=d.name,
description=d.description,
status=d.status,
source_path=d.source_path,
continuation_flow_id=d.continuation_flow_id,
created_at=d.created_at,
)
def _read_version(v) -> FlowVersionRead: def _read_version(v) -> FlowVersionRead:
return FlowVersionRead.model_validate(v, from_attributes=True) return FlowVersionRead(
id=v.id,
flow_id=v.flow_id,
version_no=v.version_no,
langflow_json=v.langflow_json,
lfx_flow_id=v.lfx_flow_id,
deployed_at=v.deployed_at,
author=v.author,
created_at=v.created_at,
input_schema=v.input_schema,
)
def _read_run(r) -> RunRead: def _read_run(r) -> RunRead:
return RunRead.model_validate(r, from_attributes=True) return RunRead(
id=r.id,
version_id=r.version_id,
tenant_id=r.tenant_id,
status=r.status,
input=r.input,
output=r.output,
session_id=r.session_id,
created_at=r.created_at,
finished_at=r.finished_at,
)
def _read_task(t) -> ApprovalRead: def _read_task(t) -> ApprovalRead:
return ApprovalRead.model_validate(t, from_attributes=True) return ApprovalRead(
id=t.id,
run_id=t.run_id,
def _lfx_http(exc: LfxError) -> HTTPException: version_id=t.version_id,
"""Map LFX domain errors to HTTP (single place, was 10x duplicated).""" tenant_id=t.tenant_id,
if isinstance(exc, LfxInvalidFlow): title=t.title,
return HTTPException(status_code=422, detail=str(exc)) payload=t.payload,
if isinstance(exc, LfxConflict): continuation_flow_id=t.continuation_flow_id,
return HTTPException(status_code=409, detail=str(exc)) approver=t.approver,
if isinstance(exc, LfxUnavailable): status=t.status,
logger.warning("lfx unavailable: %s", exc) decision_note=t.decision_note,
return HTTPException(status_code=503, detail="flow runtime unavailable") created_at=t.created_at,
logger.warning("lfx error: %s", exc) decided_at=t.decided_at,
return HTTPException(status_code=502, detail="flow runtime error") )
def _langflow_http(exc: LangflowError) -> HTTPException:
if isinstance(exc, LangflowUnavailable):
logger.warning("langflow unavailable: %s", exc)
return HTTPException(status_code=503, detail="authoring server unavailable")
logger.warning("langflow error: %s", exc)
return HTTPException(status_code=502, detail="authoring server error")
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201) @flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
@ -134,14 +164,21 @@ async def sync_flows(
_admin: Annotated[User, Depends(require_admin)], _admin: Annotated[User, Depends(require_admin)],
): ):
"""Manual full pass: Langflow -> backend/flows/*.json -> DB + LFX.""" """Manual full pass: Langflow -> backend/flows/*.json -> DB + LFX."""
from .sync import sync_full_pass
try: try:
return await sync_full_pass(session, lfx, langflow, Path(settings.flows_dir)) pulled_result = await pull_all_from_langflow(langflow, Path(settings.flows_dir))
except LangflowUnavailable as exc:
logger.warning("langflow unavailable: %s", exc)
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
except LangflowError as exc: except LangflowError as exc:
raise _langflow_http(exc) from exc logger.warning("langflow error: %s", exc)
raise HTTPException(status_code=502, detail="authoring server error") from exc
try:
report = await sync_from_directory(session, lfx, Path(settings.flows_dir))
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
report.pulled = pulled_result["pulled"]
report.errors = {**pulled_result["errors"], **report.errors}
return report
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead) @flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
@ -199,8 +236,16 @@ async def deploy_version(
flow_id=lfx_id_for(flow_id, version_no), flow_id=lfx_id_for(flow_id, version_no),
replace=True, replace=True,
) )
except LfxInvalidFlow as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except LfxConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except LfxUnavailable as exc:
logger.warning("lfx unavailable: %s", exc)
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
except LfxError as exc: except LfxError as exc:
raise _lfx_http(exc) from exc logger.warning("lfx error: %s", exc)
raise HTTPException(status_code=502, detail="flow runtime error") from exc
return _read_version(store.mark_deployed(session, version, uploaded.id)) return _read_version(store.mark_deployed(session, version, uploaded.id))
@ -245,35 +290,66 @@ async def run_flow(
) )
except LfxNotFound as exc: except LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
except LfxUnavailable as exc:
logger.warning("lfx unavailable: %s", exc)
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
except LfxError as exc: except LfxError as exc:
raise _lfx_http(exc) from exc logger.warning("lfx error: %s", exc)
raise HTTPException(status_code=502, detail="flow runtime error") from exc
return _read_run(run) return _read_run(run)
async def _run_stream( async def _run_stream(
session: Session, lfx: LfxClient, version, data: RunRequest, user: User, definition session: Session, lfx: LfxClient, version, data: RunRequest, user: User, definition
): ):
try: if not version.lfx_flow_id:
run, envelope, lfx_flow_id, sid = await runner.stream_run( raise HTTPException(status_code=422, detail=f"version {version.id} is not deployed to LFX")
run = store.create_run(
session, session,
lfx,
version, version,
payload=data.input, input=data.input,
tenant_id=user.tenant_id, tenant_id=user.tenant_id,
session_id=data.session_id, session_id=data.session_id,
)
envelope = runner.build_envelope(
payload=data.input,
run_id=run.id,
callback_base_url=settings.callback_base_url, callback_base_url=settings.callback_base_url,
continuation_flow_id=definition.continuation_flow_id, continuation_flow_id=definition.continuation_flow_id,
callback_token=settings.callback_token, callback_token=settings.callback_token,
) )
except store.InvalidError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
run_id = run.id run_id = run.id
lfx_flow_id = version.lfx_flow_id
session_id = data.session_id or str(run.id)
async def event_gen() -> AsyncIterator[str]: async def event_gen() -> AsyncIterator[str]:
yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n" yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n"
async for chunk in runner.stream_lfx_chunks(lfx, lfx_flow_id, envelope, sid): try:
async with (
httpx.AsyncClient(
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
) as client,
client.stream(
"POST",
f"/flows/{lfx_flow_id}/stream",
json={"input_value": envelope, "session_id": session_id},
) as response,
):
if response.status_code == 404:
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
response.raise_for_status()
async for chunk in response.aiter_text():
yield chunk yield chunk
status = runner.finalize_streamed_run(run_id) except (LfxNotFound, httpx.HTTPError) as exc:
yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n"
# Finalize from a fresh session: the request session may be closed.
with Session(get_engine()) as fresh:
try:
fresh_run = store.get_run(fresh, run_id)
status = "pending_approval" if runner._task_exists(fresh, run_id) else "completed"
store.set_run_result(fresh, fresh_run, status, {"streamed": True})
except store.NotFoundError:
status = "failed"
yield f"event: run_finished\ndata: {json.dumps({'run_id': str(run_id), 'status': status})}\n\n" yield f"event: run_finished\ndata: {json.dumps({'run_id': str(run_id), 'status': status})}\n\n"
return StreamingResponse(event_gen(), media_type="text/event-stream") return StreamingResponse(event_gen(), media_type="text/event-stream")
@ -300,8 +376,12 @@ async def list_langflow_flows(
"""Browse flows on the Langflow authoring server (admin import source).""" """Browse flows on the Langflow authoring server (admin import source)."""
try: try:
return await langflow.list_flows() return await langflow.list_flows()
except LangflowUnavailable as exc:
logger.warning("langflow unavailable: %s", exc)
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
except LangflowError as exc: except LangflowError as exc:
raise _langflow_http(exc) from exc logger.warning("langflow error: %s", exc)
raise HTTPException(status_code=502, detail="authoring server error") from exc
@flows_router.post("/import", response_model=FlowImportRead, status_code=201) @flows_router.post("/import", response_model=FlowImportRead, status_code=201)
@ -325,14 +405,26 @@ async def import_flow(
) )
except LangflowNotFound as exc: except LangflowNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
except LangflowUnavailable as exc:
logger.warning("langflow unavailable: %s", exc)
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
except LangflowError as exc: except LangflowError as exc:
raise _langflow_http(exc) from exc logger.warning("langflow error: %s", exc)
raise HTTPException(status_code=502, detail="authoring server error") from exc
except store.ConflictError as exc: except store.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
except store.InvalidError as exc: except store.InvalidError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
except LfxInvalidFlow as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except LfxConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except LfxUnavailable as exc:
logger.warning("lfx unavailable: %s", exc)
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
except LfxError as exc: except LfxError as exc:
raise _lfx_http(exc) from exc logger.warning("lfx error: %s", exc)
raise HTTPException(status_code=502, detail="flow runtime error") from exc
return FlowImportRead(definition=_read_definition(definition), version=_read_version(version)) return FlowImportRead(definition=_read_definition(definition), version=_read_version(version))
@ -469,8 +561,10 @@ async def decide_approval(
) )
except LfxNotFound as exc: except LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
except LfxUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except LfxError as exc: except LfxError as exc:
raise _lfx_http(exc) from exc raise HTTPException(status_code=502, detail=str(exc)) from exc
return DecisionRead( return DecisionRead(
task=_read_task(task), task=_read_task(task),
continuation_run=_read_run(continuation_run) if continuation_run else None, continuation_run=_read_run(continuation_run) if continuation_run else None,

View file

@ -1,27 +0,0 @@
"""Shared base for LFX + Langflow async HTTP clients.
Consolidates base_url stripping, header building, per-request client
construction, and HTTPError → domain-error mapping. Subclasses only
declare their error types and endpoint calls.
"""
from __future__ import annotations
import httpx
class BaseApiClient:
"""Thin wrapper around httpx.AsyncClient construction."""
def __init__(self, base_url: str, timeout: float = 60.0, api_key: str | None = None) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.api_key = api_key or ""
def _headers(self) -> dict[str, str]:
return {"x-api-key": self.api_key} if self.api_key else {}
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
)

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from pathlib import Path from pathlib import Path
from sqlmodel import Session from sqlmodel import Session
@ -14,8 +13,6 @@ from . import export, sync
from .langflow_client import LangflowClient, LangflowError from .langflow_client import LangflowClient, LangflowError
from .lfx_client import LfxClient from .lfx_client import LfxClient
logger = logging.getLogger(__name__)
def normalize_exported_flow(exported: dict, langflow_flow_id: str, name: str | None = None) -> dict: def normalize_exported_flow(exported: dict, langflow_flow_id: str, name: str | None = None) -> dict:
"""Reduce a Langflow FlowRead to the canonical fields we persist.""" """Reduce a Langflow FlowRead to the canonical fields we persist."""
@ -43,7 +40,6 @@ async def pull_all_from_langflow(
""" """
flows_dir = Path(flows_dir) flows_dir = Path(flows_dir)
headers = await langflow.list_flows() headers = await langflow.list_flows()
ignored = export.ignored_stems(flows_dir)
pulled: list[str] = [] pulled: list[str] = []
errors: dict[str, str] = {} errors: dict[str, str] = {}
for header in headers: for header in headers:
@ -53,9 +49,6 @@ async def pull_all_from_langflow(
try: try:
exported = await langflow.export_flow(flow_id) exported = await langflow.export_flow(flow_id)
doc = normalize_exported_flow(exported, flow_id) doc = normalize_exported_flow(exported, flow_id)
if export.filename_stem(doc["name"]) in ignored:
logger.warning("pull skips ignored flow %r", doc["name"])
continue
reuse, identical = export.find_reuse_path(flows_dir, doc) reuse, identical = export.find_reuse_path(flows_dir, doc)
if identical and reuse is not None: if identical and reuse is not None:
pulled.append(reuse.stem) pulled.append(reuse.stem)

View file

@ -15,24 +15,6 @@ from pathlib import Path
FLOWS_GLOB = "*.json" FLOWS_GLOB = "*.json"
#: Opt-out list in the flows dir (one stem per line, `#` comments):
#: listed flows stay in Langflow only — never synced, deployed,
#: validated, or shown in the frontend.
IGNORE_FILENAME = ".flowsignore"
def ignored_stems(flows_dir: str | Path) -> set[str]:
"""Stems excluded from the pipeline (empty when no ignore file)."""
try:
text = (Path(flows_dir) / IGNORE_FILENAME).read_text(encoding="utf-8")
except OSError:
return set()
return {
line.strip()
for line in text.splitlines()
if line.strip() and not line.strip().startswith("#")
}
def filename_stem(name: str) -> str: def filename_stem(name: str) -> str:
"""Map a flow name to a filename stem, keeping the original name. """Map a flow name to a filename stem, keeping the original name.
@ -131,7 +113,6 @@ def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None
return write_flow_bytes(flow_path(flows_dir, stem), doc) return write_flow_bytes(flow_path(flows_dir, stem), doc)
def iter_flow_files(flows_dir: str | Path, *, ignored: set[str] | None = None) -> list[Path]: def iter_flow_files(flows_dir: str | Path) -> list[Path]:
"""Sorted list of candidate flow files (top level only, minus ignored).""" """Sorted list of candidate flow files (top level only)."""
skip = ignored if ignored is not None else ignored_stems(flows_dir) return sorted(Path(flows_dir).glob(FLOWS_GLOB))
return sorted(p for p in Path(flows_dir).glob(FLOWS_GLOB) if p.stem not in skip)

View file

@ -1,16 +1,11 @@
"""Derive a web-form schema from a Langflow export. """Derive a web-form schema from a Langflow export.
Looks for form nodes — ``CustomInput*`` (``data.type`` or the inner Looks for ``CustomInput*`` nodes (``data.type`` or the inner
``display_name`` starting with ``CustomInput``, see ``display_name`` starting with ``CustomInput`` — see
``backend/components/kirche/form_inputs.py``) plus user-built ``backend/components/kirche/form_inputs.py``) and maps their ``template``
``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: (minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
* ``name`` → property key (freely renamable in the Langflow code editor; * ``name`` → property key, ``display_name`` → ``title``,
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``, ``info`` → ``description``, ``value`` → ``default``,
``options`` → ``enum``, ``required`` → required array ``options`` → ``enum``, ``required`` → required array
* ``show: false`` fields are plumbing (e.g. the run envelope input), not * ``show: false`` fields are plumbing (e.g. the run envelope input), not
@ -18,13 +13,6 @@ Looks for form nodes — ``CustomInput*`` (``data.type`` or the inner
* ``advanced`` fields render last via ``ui:order`` * ``advanced`` fields render last via ``ui:order``
* ``_input_type`` → JSON type + widget (unknown types fall back to string * ``_input_type`` → JSON type + widget (unknown types fall back to string
so the parser never crashes on new fields) 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 A flow without such a node gets ``{fallback: "textarea"}``: the frontend
renders one free-text box instead of a generated form. renders one free-text box instead of a generated form.
@ -32,19 +20,9 @@ renders one free-text box instead of a generated form.
from __future__ import annotations 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]: def _form_templates(langflow_json: dict) -> list[dict]:
"""Inner node dicts (``data.node``) of all form nodes.""" """Inner node dicts (``data.node``) of all CustomInput* nodes."""
if not isinstance(langflow_json, dict): if not isinstance(langflow_json, dict):
return [] return []
data = langflow_json.get("data") or {} data = langflow_json.get("data") or {}
@ -59,10 +37,6 @@ def _form_templates(langflow_json: dict) -> list[dict]:
display = str(inner.get("display_name") or "") display = str(inner.get("display_name") or "")
if node_type.startswith("CustomInput") or display.startswith("CustomInput"): if node_type.startswith("CustomInput") or display.startswith("CustomInput"):
found.append(inner) 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 return found
@ -138,125 +112,6 @@ def _field_schema(name: str, field: dict) -> tuple[dict, dict]:
return prop, ui 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: def extract_form_schema(langflow_json: dict) -> dict:
"""``{json_schema, ui_schema}``, or with ``fallback: "textarea"``.""" """``{json_schema, ui_schema}``, or with ``fallback: "textarea"``."""
templates = _form_templates(langflow_json) templates = _form_templates(langflow_json)
@ -267,126 +122,31 @@ def extract_form_schema(langflow_json: dict) -> dict:
"fallback": "textarea", "fallback": "textarea",
} }
template = templates[0].get("template") or {} 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 = {} properties: dict = {}
required: list[str] = [] required: list[str] = []
ui_schema: dict = {} ui_schema: dict = {}
normal: list[str] = [] normal: list[str] = []
advanced: list[str] = [] advanced: list[str] = []
for record in records: for key, field in template.items():
name = record["name"] if key == "code" or not isinstance(field, dict):
prop, ui = _field_schema(name, record["field"]) continue
if name not in matches: if field.get("show") is False:
continue
name = field.get("name") or key
prop, ui = _field_schema(name, field)
properties[name] = prop properties[name] = prop
if record["required"]: if field.get("required"):
required.append(name) required.append(name)
else:
record["branch_prop"] = prop
if ui: if ui:
ui_schema[name] = ui ui_schema[name] = ui
(advanced if record["advanced"] else normal).append(name) (advanced if field.get("advanced") else normal).append(name)
json_schema: dict = {"type": "object", "properties": properties, "required": required} if advanced:
if matches: ui_schema["ui:order"] = normal + advanced + ["*"]
rules = [] return {
for name, conds in matches.items(): "json_schema": {
record = by_name[name] "type": "object",
rules.append( "properties": properties,
{ "required": required,
"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 {}),
}, },
"ui_schema": ui_schema,
} }
)
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

View file

@ -8,8 +8,6 @@ from __future__ import annotations
import httpx import httpx
from .base_client import BaseApiClient
class LangflowError(Exception): class LangflowError(Exception):
pass pass
@ -23,16 +21,23 @@ class LangflowUnavailable(LangflowError):
pass pass
class LangflowClient(BaseApiClient): class LangflowClient:
"""Thin async wrapper around the Langflow `/api/v1/flows` endpoints.""" """Thin async wrapper around the Langflow `/api/v1/flows` endpoints."""
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60.0) -> None: def __init__(self, base_url: str, api_key: str = "", timeout: float = 60.0) -> None:
super().__init__(base_url, timeout=timeout, api_key=api_key) self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
def _headers(self) -> dict[str, str]:
return {"x-api-key": self.api_key} if self.api_key else {}
async def list_flows(self) -> list[dict]: async def list_flows(self) -> list[dict]:
"""List flows (header mode keeps payloads small).""" """List flows (header mode keeps payloads small)."""
try: try:
async with self._client() as client: async with httpx.AsyncClient(
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
) as client:
response = await client.get("/api/v1/flows/", params={"header_flows": "true"}) response = await client.get("/api/v1/flows/", params={"header_flows": "true"})
if response.status_code == 404: if response.status_code == 404:
raise LangflowNotFound("flow list not found") raise LangflowNotFound("flow list not found")
@ -47,7 +52,9 @@ class LangflowClient(BaseApiClient):
async def export_flow(self, langflow_flow_id: str) -> dict: async def export_flow(self, langflow_flow_id: str) -> dict:
"""Export one flow as Langflow JSON (FlowRead: id/name/description/data).""" """Export one flow as Langflow JSON (FlowRead: id/name/description/data)."""
try: try:
async with self._client() as client: async with httpx.AsyncClient(
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
) as client:
response = await client.get(f"/api/v1/flows/{langflow_flow_id}") response = await client.get(f"/api/v1/flows/{langflow_flow_id}")
if response.status_code == 404: if response.status_code == 404:
raise LangflowNotFound(f"flow {langflow_flow_id} not found") raise LangflowNotFound(f"flow {langflow_flow_id} not found")
@ -57,24 +64,3 @@ class LangflowClient(BaseApiClient):
raise LangflowError(f"Langflow error {exc.response.status_code}") from exc raise LangflowError(f"Langflow error {exc.response.status_code}") from exc
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
async def upsert_flow(self, doc: dict) -> dict:
"""Create or update a flow in Langflow by name (authoring UI refresh).
Used after flow/component tightening so the visual editor shows the
latest canonical JSON without a container restart.
"""
try:
async with self._client() as client:
existing = await self.list_flows()
match = next((f for f in existing if f.get("name") == doc.get("name")), None)
if match and match.get("id"):
response = await client.patch(f"/api/v1/flows/{match['id']}", json=doc)
else:
response = await client.post("/api/v1/flows/", json=doc)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
raise LangflowError(f"Langflow error {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc

View file

@ -6,8 +6,6 @@ from dataclasses import dataclass
import httpx import httpx
from .base_client import BaseApiClient
class LfxError(Exception): class LfxError(Exception):
"""Base error for LFX communication problems.""" """Base error for LFX communication problems."""
@ -50,11 +48,21 @@ class LfxRunResult:
component: str component: str
class LfxClient(BaseApiClient): class LfxClient:
"""Thin async wrapper around the LFX serve HTTP API.""" """Thin async wrapper around the LFX serve HTTP API."""
def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None: def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None:
super().__init__(base_url, timeout=timeout, api_key=api_key) self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.api_key = api_key
def _headers(self) -> dict[str, str]:
return {"x-api-key": self.api_key} if self.api_key else {}
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
)
async def health(self) -> dict: async def health(self) -> dict:
try: try:

View file

@ -3,18 +3,14 @@
from __future__ import annotations from __future__ import annotations
import json import json
from collections.abc import AsyncIterator
from uuid import UUID from uuid import UUID
import httpx from sqlmodel import Session, select
from sqlmodel import Session
from app.db import get_engine from app.models import ApprovalTask, FlowRun, FlowVersion
from app.models import FlowRun, FlowVersion
from . import input_schema as form_schema
from . import store from . import store
from .lfx_client import LfxClient, LfxNotFound, LfxRunFailed from .lfx_client import LfxClient, LfxRunFailed
from .store import InvalidError from .store import InvalidError
@ -25,25 +21,25 @@ def build_envelope(
callback_base_url: str, callback_base_url: str,
continuation_flow_id: UUID | None, continuation_flow_id: UUID | None,
callback_token: str, callback_token: str,
fields: list[str] | None = None,
) -> str: ) -> str:
"""Run envelope for LFX. ``fields`` (asked form-field names, from the return json.dumps(
version's cached input schema) lets form components emit defaults only {
for asked fields; omitted (None) preserves the legacy shape."""
body: dict = {
"payload": payload, "payload": payload,
"run_id": str(run_id), "run_id": str(run_id),
"callback_url": callback_base_url.rstrip("/"), "callback_url": callback_base_url.rstrip("/"),
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None), "continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
"callback_token": callback_token, "callback_token": callback_token,
} }
if fields is not None: )
body["fields"] = list(fields)
return json.dumps(body)
def _task_exists(session: Session, run_id: UUID) -> bool: def _task_exists(session: Session, run_id: UUID) -> bool:
return store.has_open_task(session, run_id) return (
session.exec(
select(ApprovalTask).where(ApprovalTask.run_id == run_id, ApprovalTask.status == "open")
).first()
is not None
)
async def execute_run( async def execute_run(
@ -70,14 +66,12 @@ async def execute_run(
run = store.create_run( run = store.create_run(
session, version, input=payload, tenant_id=tenant_id, session_id=session_id session, version, input=payload, tenant_id=tenant_id, session_id=session_id
) )
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json)
envelope = build_envelope( envelope = build_envelope(
payload=payload, payload=payload,
run_id=run.id, run_id=run.id,
callback_base_url=callback_base_url, callback_base_url=callback_base_url,
continuation_flow_id=continuation_flow_id, continuation_flow_id=continuation_flow_id,
callback_token=callback_token, callback_token=callback_token,
fields=form_schema.schema_field_names(cached),
) )
try: try:
result = await lfx.run_flow( result = await lfx.run_flow(
@ -95,73 +89,3 @@ async def execute_run(
if _task_exists(session, run.id): if _task_exists(session, run.id):
return store.set_run_result(session, run, "pending_approval", output) return store.set_run_result(session, run, "pending_approval", output)
return store.set_run_result(session, run, "completed", output) return store.set_run_result(session, run, "completed", output)
async def stream_run(
session: Session,
lfx: LfxClient,
version: FlowVersion,
*,
payload: dict,
tenant_id: str,
session_id: str | None,
callback_base_url: str,
continuation_flow_id: UUID | None,
callback_token: str,
) -> tuple[FlowRun, str, str, str]:
"""Create a run + envelope for SSE streaming. Returns (run, envelope, lfx_id, sid).
The actual SSE bytes are produced by the API layer; finalization uses a
fresh session (the request session may be closed when the generator ends).
"""
if not version.lfx_flow_id:
raise InvalidError(f"version {version.id} is not deployed to LFX")
run = store.create_run(
session, version, input=payload, tenant_id=tenant_id, session_id=session_id
)
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json)
envelope = build_envelope(
payload=payload,
run_id=run.id,
callback_base_url=callback_base_url,
continuation_flow_id=continuation_flow_id,
callback_token=callback_token,
fields=form_schema.schema_field_names(cached),
)
return run, envelope, version.lfx_flow_id, session_id or str(run.id)
async def stream_lfx_chunks(
lfx: LfxClient, lfx_flow_id: str, envelope: str, sid: str
) -> AsyncIterator[str]:
"""Yield raw SSE chunks from LFX, mapping transport errors to error events."""
try:
async with (
httpx.AsyncClient(
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
) as client,
client.stream(
"POST",
f"/flows/{lfx_flow_id}/stream",
json={"input_value": envelope, "session_id": sid},
) as response,
):
if response.status_code == 404:
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
response.raise_for_status()
async for chunk in response.aiter_text():
yield chunk
except (LfxNotFound, httpx.HTTPError) as exc:
yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n"
def finalize_streamed_run(run_id: UUID) -> str:
"""Mark a streamed run completed/pending_approval from a fresh session."""
with Session(get_engine()) as fresh:
try:
fresh_run = store.get_run(fresh, run_id)
status = "pending_approval" if store.has_open_task(fresh, run_id) else "completed"
store.set_run_result(fresh, fresh_run, status, {"streamed": True})
except store.NotFoundError:
status = "failed"
return status

View file

@ -132,9 +132,12 @@ def validate_flow_json(flow_json: dict) -> None:
def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion: def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion:
get_definition(session, flow_id) get_definition(session, flow_id)
validate_flow_json(data.langflow_json) validate_flow_json(data.langflow_json)
max_no = session.exec(
select(func.max(FlowVersion.version_no)).where(FlowVersion.flow_id == flow_id)
).one()
version = FlowVersion( version = FlowVersion(
flow_id=flow_id, flow_id=flow_id,
version_no=next_version_no(session, flow_id), version_no=(max_no or 0) + 1,
langflow_json=data.langflow_json, langflow_json=data.langflow_json,
author=data.author, author=data.author,
# Single choke point: sync, import, and API creation all cache the # Single choke point: sync, import, and API creation all cache the
@ -226,16 +229,6 @@ def set_run_result(session: Session, run: FlowRun, status: str, output: dict | N
return run return run
def has_open_task(session: Session, run_id: UUID) -> bool:
"""True when a run has an open approval task (terminal-state mapping)."""
return (
session.exec(
select(ApprovalTask).where(ApprovalTask.run_id == run_id, ApprovalTask.status == "open")
).first()
is not None
)
def get_run(session: Session, run_id: UUID) -> FlowRun: def get_run(session: Session, run_id: UUID) -> FlowRun:
run = session.get(FlowRun, run_id) run = session.get(FlowRun, run_id)
if run is None: if run is None:

View file

@ -186,14 +186,6 @@ async def sync_from_directory(
return report return report
def format_report(report: SyncReport) -> str:
"""One-line summary for startup/watcher logs (keeps main.py thin)."""
return (
f"+{len(report.added)} ~{len(report.updated)} -{len(report.deleted)} "
f"skipped={len(report.skipped)} errors={len(report.errors)}"
)
def default_session_factory() -> Callable[[], Session]: def default_session_factory() -> Callable[[], Session]:
return lambda: Session(get_engine()) return lambda: Session(get_engine())
@ -202,27 +194,6 @@ def default_lfx_factory() -> Callable[[], LfxClient]:
return lambda: LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None) return lambda: LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
async def sync_full_pass(
session: Session,
lfx: LfxClient,
langflow,
flows_dir: str | Path,
) -> SyncReport:
"""Single orchestration: Langflow pull -> files -> DB + LFX.
Shared by POST /api/flows/sync, CLI, and watcher-adjacent paths so the
full-pass logic lives once. Pull errors merge with sync errors.
"""
from .deploy import pull_all_from_langflow
flows_dir = Path(flows_dir)
pulled_result = await pull_all_from_langflow(langflow, flows_dir)
report = await sync_from_directory(session, lfx, flows_dir)
report.pulled = pulled_result["pulled"] # type: ignore[typeddict-item]
report.errors = {**pulled_result["errors"], **report.errors} # type: ignore[typeddict-item]
return report
def run_sync_blocking( def run_sync_blocking(
flows_dir: str | Path, flows_dir: str | Path,
*, *,
@ -240,11 +211,7 @@ def run_sync_blocking(
class _JsonFilter(DefaultFilter): class _JsonFilter(DefaultFilter):
def __call__(self, change, path: str) -> bool: # type: ignore[override] def __call__(self, change, path: str) -> bool: # type: ignore[override]
if not (super().__call__(change, path) and path.endswith(".json")): return super().__call__(change, path) and path.endswith(".json")
return False
# The ignore file itself is tiny; re-read per event so no restart
# is needed after editing it.
return Path(path).stem not in export.ignored_stems(Path(path).parent)
async def watch_forever( async def watch_forever(
@ -279,4 +246,11 @@ async def watch_forever(
except Exception: except Exception:
logger.exception("flow sync failed") logger.exception("flow sync failed")
continue continue
logger.info("flow sync: %s", format_report(report)) logger.info(
"flow sync: +%d ~%d -%d skipped=%d errors=%d",
len(report.added),
len(report.updated),
len(report.deleted),
len(report.skipped),
len(report.errors),
)

View file

@ -9,7 +9,7 @@ from starlette.middleware.body_limit import RequestBodyLimitMiddleware
from .auth.api import access_router, auth_router from .auth.api import access_router, auth_router
from .flows.api import approvals_router, flows_router from .flows.api import approvals_router, flows_router
from .flows.sync import format_report, run_sync_blocking, watch_forever from .flows.sync import run_sync_blocking, watch_forever
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
from .settings import settings from .settings import settings
@ -20,7 +20,14 @@ def _blocking_startup_sync() -> None:
"""One-shot reconciliation at startup (fresh session, never fatal).""" """One-shot reconciliation at startup (fresh session, never fatal)."""
try: try:
report = run_sync_blocking(Path(settings.flows_dir)) report = run_sync_blocking(Path(settings.flows_dir))
logger.info("startup flow sync: %s", format_report(report)) logger.info(
"startup flow sync: +%d ~%d -%d skipped=%d errors=%d",
len(report.added),
len(report.updated),
len(report.deleted),
len(report.skipped),
len(report.errors),
)
for filename, reason in report.errors.items(): for filename, reason in report.errors.items():
logger.warning("startup flow sync: %s: %s", filename, reason) logger.warning("startup flow sync: %s: %s", filename, reason)
except FileNotFoundError as exc: except FileNotFoundError as exc:

View file

@ -1,18 +0,0 @@
"""Shared SQLModel base: UTC timestamps, no copy-paste across models."""
from datetime import UTC, datetime
from sqlalchemy import Column, DateTime
from sqlmodel import Field
def utcnow() -> datetime:
return datetime.now(UTC)
def ts_column() -> Column:
return Column(DateTime(timezone=True))
def ts_field(*, default_factory=utcnow) -> datetime: # type: ignore[no-untyped-def]
return Field(default_factory=default_factory, sa_column=ts_column())

View file

@ -1,12 +1,18 @@
"""Flow engine domain models (SQLModel tables).""" """Flow engine domain models (SQLModel tables)."""
from datetime import datetime from datetime import UTC, datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import JSON, Column, UniqueConstraint from sqlalchemy import JSON, Column, DateTime, UniqueConstraint
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from .base import ts_column, utcnow
def utcnow() -> datetime:
return datetime.now(UTC)
def _ts_column() -> Column:
return Column(DateTime(timezone=True))
def _json_column() -> Column: def _json_column() -> Column:
@ -28,7 +34,7 @@ class FlowDefinition(SQLModel, table=True):
source_path: str | None = Field(default=None, max_length=255) source_path: str | None = Field(default=None, max_length=255)
# Flow to run after a human approval on this flow's runs (nullable). # Flow to run after a human approval on this flow's runs (nullable).
continuation_flow_id: UUID | None = Field(default=None, foreign_key="flow_definitions.id") continuation_flow_id: UUID | None = Field(default=None, foreign_key="flow_definitions.id")
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
class FlowVersion(SQLModel, table=True): class FlowVersion(SQLModel, table=True):
@ -43,9 +49,9 @@ class FlowVersion(SQLModel, table=True):
langflow_json: dict = Field(default_factory=dict, sa_column=_json_column()) langflow_json: dict = Field(default_factory=dict, sa_column=_json_column())
# Flow id under which LFX serves this version (set on deploy). # Flow id under which LFX serves this version (set on deploy).
lfx_flow_id: str | None = Field(default=None, max_length=200) lfx_flow_id: str | None = Field(default=None, max_length=200)
deployed_at: datetime | None = Field(default=None, sa_column=ts_column()) deployed_at: datetime | None = Field(default=None, sa_column=_ts_column())
author: str = Field(default="", max_length=200) author: str = Field(default="", max_length=200)
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
# Cached form schema for this exact JSON (see app/flows/input_schema.py). # Cached form schema for this exact JSON (see app/flows/input_schema.py).
# Computed at version creation; nullable for pre-Phase-3 rows. # Computed at version creation; nullable for pre-Phase-3 rows.
input_schema: dict | None = Field(default=None, sa_column=_json_column()) input_schema: dict | None = Field(default=None, sa_column=_json_column())
@ -64,8 +70,8 @@ class FlowRun(SQLModel, table=True):
input: dict | None = Field(default=None, sa_column=_json_column()) input: dict | None = Field(default=None, sa_column=_json_column())
output: dict | None = Field(default=None, sa_column=_json_column()) output: dict | None = Field(default=None, sa_column=_json_column())
session_id: str | None = Field(default=None, max_length=200) session_id: str | None = Field(default=None, max_length=200)
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
finished_at: datetime | None = Field(default=None, sa_column=ts_column()) finished_at: datetime | None = Field(default=None, sa_column=_ts_column())
class ApprovalTask(SQLModel, table=True): class ApprovalTask(SQLModel, table=True):
@ -84,5 +90,5 @@ class ApprovalTask(SQLModel, table=True):
# open | approved | rejected # open | approved | rejected
status: str = Field(default="open", index=True, max_length=20) status: str = Field(default="open", index=True, max_length=20)
decision_note: str = Field(default="", max_length=2000) decision_note: str = Field(default="", max_length=2000)
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
decided_at: datetime | None = Field(default=None, sa_column=ts_column()) decided_at: datetime | None = Field(default=None, sa_column=_ts_column())

View file

@ -1,11 +1,18 @@
"""Access-management domain models (SQLModel tables).""" """Access-management domain models (SQLModel tables)."""
from datetime import datetime from datetime import UTC, datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from .base import ts_column, utcnow
def utcnow() -> datetime:
return datetime.now(UTC)
def _ts_column() -> Column:
return Column(DateTime(timezone=True))
class User(SQLModel, table=True): class User(SQLModel, table=True):
@ -19,7 +26,7 @@ class User(SQLModel, table=True):
# member | admin # member | admin
role: str = Field(default="member", max_length=20) role: str = Field(default="member", max_length=20)
tenant_id: str = Field(default="default", max_length=200) tenant_id: str = Field(default="default", max_length=200)
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
class AccessRequest(SQLModel, table=True): class AccessRequest(SQLModel, table=True):
@ -33,8 +40,8 @@ class AccessRequest(SQLModel, table=True):
reason: str = Field(default="", max_length=2000) reason: str = Field(default="", max_length=2000)
# pending | approved | rejected # pending | approved | rejected
status: str = Field(default="pending", index=True, max_length=20) status: str = Field(default="pending", index=True, max_length=20)
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
decided_at: datetime | None = Field(default=None, sa_column=ts_column()) decided_at: datetime | None = Field(default=None, sa_column=_ts_column())
decided_by: str | None = Field(default=None, max_length=320) decided_by: str | None = Field(default=None, max_length=320)
@ -46,11 +53,11 @@ class MagicLinkToken(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="users.id", index=True) user_id: UUID = Field(foreign_key="users.id", index=True)
token_hash: str = Field(index=True, unique=True, max_length=64) token_hash: str = Field(index=True, unique=True, max_length=64)
expires_at: datetime = Field(sa_column=ts_column()) expires_at: datetime = Field(sa_column=_ts_column())
# Set before the session row is created, so a reused token can never # Set before the session row is created, so a reused token can never
# mint a second session. # mint a second session.
used_at: datetime | None = Field(default=None, sa_column=ts_column()) used_at: datetime | None = Field(default=None, sa_column=_ts_column())
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
class UserSession(SQLModel, table=True): class UserSession(SQLModel, table=True):
@ -60,6 +67,6 @@ class UserSession(SQLModel, table=True):
id: str = Field(primary_key=True, max_length=128) id: str = Field(primary_key=True, max_length=128)
user_id: UUID = Field(foreign_key="users.id", index=True) user_id: UUID = Field(foreign_key="users.id", index=True)
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
expires_at: datetime = Field(sa_column=ts_column()) expires_at: datetime = Field(sa_column=_ts_column())
revoked_at: datetime | None = Field(default=None, sa_column=ts_column()) revoked_at: datetime | None = Field(default=None, sa_column=_ts_column())

View file

@ -1,6 +1,5 @@
from pathlib import Path from pathlib import Path
from pydantic import computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Repo-root .env (shared with the compose stack). Real environment variables # Repo-root .env (shared with the compose stack). Real environment variables
@ -54,7 +53,6 @@ class Settings(BaseSettings):
smtp_username: str = "" smtp_username: str = ""
smtp_password: str = "" smtp_password: str = ""
@computed_field # type: ignore[misc]
@property @property
def admin_email_list(self) -> list[str]: def admin_email_list(self) -> list[str]:
return [e.strip().lower() for e in self.admin_emails.split(",") if e.strip()] return [e.strip().lower() for e in self.admin_emails.split(",") if e.strip()]

View file

@ -9,13 +9,13 @@ derives a JSON Schema + uiSchema from the node's template
1. Open the Langflow authoring UI (http://localhost:7860) and load the 1. Open the Langflow authoring UI (http://localhost:7860) and load the
flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded). flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded).
2. Edit the `CustomInputForm` node **in its code editor**: every entry in 2. Edit the `CustomInputForm` node: every entry in its `inputs` list is
its `inputs` list is one web-form field. Add an `lfx.io` input, e.g. one web-form field. Add an `lfx.io` input, e.g.
```python ```python
from lfx.io import DropdownInput from lfx.io import DropdownInput
DropdownInput( DropdownInput(
name="room", # JSON-Schema property key (freely renamable) name="room", # JSON-Schema property key
display_name="Room", # form label (title) display_name="Room", # form label (title)
info="Where should it take place", # help text (description) info="Where should it take place", # help text (description)
options=["Chapel", "Hall"], # dropdown choices (enum) options=["Chapel", "Hall"], # dropdown choices (enum)
@ -29,165 +29,23 @@ derives a JSON Schema + uiSchema from the node's template
`DropdownInput` → select, `MultiselectInput` → multi-select, `DropdownInput` → select, `MultiselectInput` → multi-select,
`SliderInput` → slider (needs `range_spec`), `FileInput` → text stub `SliderInput` → slider (needs `range_spec`), `FileInput` → text stub
(real upload later). (real upload later).
3. Nothing else to change in code: `collect()` is dynamic — it emits 3. Add the new field to `collect()` so it reaches the JSON output.
every input except the plumbing ones, so renames, additions, and
removals just work. (The inspector panel can tweak `display_name`,
`info`, `value`, and `options` of existing fields, but `name` and new
fields need the code editor.)
4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden 4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden
input, `show=False`). Submitted web-form values arrive there as input, `show=False`). Submitted web-form values arrive there as
`{"payload": {...}}` and are merged over the defaults — including keys `{"payload": {...}}` and are merged over the defaults. Never make form
the node code does not declare (forward-compatible with renames). fields required *with* an incoming edge — that's the envelope's job.
Never make form fields required *with* an incoming edge — that's the
envelope's job.
5. Export the flow (JSON) into `backend/flows/<name>.json` and commit. 5. Export the flow (JSON) into `backend/flows/<name>.json` and commit.
The API watcher syncs + deploys it automatically; the form schema is The API watcher syncs + deploys it automatically; the form schema is
regenerated per version. regenerated per version.
## Branched questionnaires (`visible_if`)
A field can appear only when other fields have certain values — nested
to any depth. Declare it in the code editor with a marker in `info`:
```python
(
DropdownInput(
name="gebaeude",
display_name="Welches Gebäude soll verändert werden?",
options=["Kirche", "Anderes Gebäude"],
value="Kirche",
required=True,
),
)
(
MultiselectInput(
name="bestandteil",
display_name="Welcher Bestandteil der Kirche?",
options=["Glocke", "Orgel", "Innenraum", "Fenster", "Fassade"],
value=[],
info="Mehrfachauswahl möglich [visible_if gebaeude=Kirche]",
required=True,
),
)
(
DropdownInput(
name="glockenmassnahme",
display_name="Welche Maßnahme an der Glocke?",
options=["Reinigung", "Reparatur", "Neuguss"],
value="Reinigung",
info="[visible_if bestandteil=Glocke]",
),
)
```
Syntax: `[visible_if <feld>=<wert>]`, `&` for AND
(`[visible_if a=X & b=Y]`), double quotes for values with spaces
(`[visible_if gebaeude="Anderes Gebäude"]`). Trigger matching by input
type: Dropdown/Text `const`, Bool `const` (`true`/`false`), Multiselect
`contains`, Int/Float exact number. Nesting is automatic: a field whose
trigger is itself conditional renders one level deeper, without limit.
What the parser does with markers:
- The marker is stripped from the shown help text; the field leaves the
top-level schema and lives only in its `allOf`/`if`/`then` branch, so
RJSF hides it until the condition holds. `ui:order` keeps template
order, so new questions appear directly below their trigger.
- `required` on a branched field applies only while visible.
- Values are always kept: hidden fields still validate and are submitted
(stale values can keep deeper branches satisfied until changed).
- Safety is fail-open: unknown triggers (renamed?), unsupported trigger
types, self-references, and trigger cycles keep the field always
visible instead of hiding data. Renaming a trigger means updating its
markers.
See `backend/flows/bauvorhaben.json` for a full 4-level example
(Gebäude → Bestandteil → Maßnahme → Gussjahr, plus sibling branch and
bool trigger).
## User-built form components (no kirche base needed)
A plain `CustomComponent` whose display name ends with `"Form"` is also
parsed into a web form — same template contract, no base class. To make
submitted values reach it at runtime, add an envelope input and merge it
in the output method (pattern):
```python
from lfx.io import MessageTextInput # add to imports
import json
# add to inputs:
MessageTextInput(name="form_values", display_name="Form values", value="", show=False)
# at the top of the output method:
try:
raw = self.form_values.text if hasattr(self.form_values, "text") else self.form_values
submitted = json.loads(raw or "{}")
except Exception:
submitted = {}
if isinstance(submitted.get("payload"), dict):
submitted = submitted["payload"]
def val(name, default):
return submitted.get(name, default)
```
Then use `val("rolle", self.rolle)` etc., and wire a `ChatInput` node
into `form_values` on the canvas. Branch markers (`[visible_if ...]`
in `info`) work here too — the parser reads the template only.
## Asked fields only (run envelope `fields`)
The API sends the asked field names (from the version's form schema)
in the run envelope's `fields` list. `collect()` then emits defaults
only for asked AND currently visible fields (branch conditions are
evaluated against submitted values over code defaults, so hidden
fields stay out exactly as the frontend never submits them): the
template may declare fields unknown to this code (they arrive via
envelope and merge normally), and unasked code defaults stay out of
the output. Without the list — raw dicts, Langflow playground — all
visible declared defaults apply as before.
## Variant flows (own component per flow)
A flow whose fields differ from `CustomInputForm` gets its own
standalone component (see `bauvorhaven_form.py` → "Bauvorhaben Form"
in the palette): same class pattern, own `inputs` list. Standalone
means it: no imports between form modules — the LFX runtime executes
the embedded module source without our `kirche` package, so every form
component must be self-contained (stdlib + `lfx` only). Copy the file
as the starting point for future forms.
Rule: code-declared inputs must equal template fields
(`test_embedded_code_matches_template_fields` enforces it; required
Multiselect uses the `[" "]` sentinel). Generate node + flow with
`kirche_node(..., component_cls, code_override)` and
`build_bauvorhaben()` in `backend/scripts/make_flow_fixtures.py`
rather than hand-editing JSON. Restart Langflow afterwards so the
palette picks up the new component, then build/upload the flow.
## Output type: Data (JSON), not Message
`collect()` returns `Data(data={...})` — a real JSON object. Consequences:
- A terminal `ChatOutput` renders it as a fenced ```json block; the run
result text is that block, not raw JSON (unwrap before `json.loads`).
- Components consuming `form_data` should read `.data` (or `get_text()`),
not `.text` like with `Message`.
## Conventions the pipeline relies on ## Conventions the pipeline relies on
- `required=True` on a template field: `lfx validate --level 4` demands a - `required=True` on a template field: `lfx validate --level 4` demands a
value or an edge, so the canonical-flow generator value or an edge, so the canonical-flow generator
(`backend/scripts/make_flow_fixtures.py`) writes a single-space (`backend/scripts/make_flow_fixtures.py`) writes a single-space
sentinel value for such fields (`[" "]` for Multiselect, which rejects sentinel value for such fields. The form parser treats whitespace-only
a plain string at runtime). The form parser treats whitespace-only
values as "no default" — don't ship real whitespace defaults. values as "no default" — don't ship real whitespace defaults.
- Field key = `name` (stable, freely renamable but must stay unique and - Field key = `name` (stable); label = `display_name`; help = `info`.
non-empty; collisions get a `_2`/`_3` suffix in the form schema);
label = `display_name`; help = `info`. Renames propagate to downstream
components, which read the JSON keys as-is.
- Unknown future input types degrade to a plain text field in the web - Unknown future input types degrade to a plain text field in the web
form (parser fallback), so adding exotic lfx inputs never breaks runs. form (parser fallback), so adding exotic lfx inputs never breaks runs.
@ -198,9 +56,3 @@ canonical + test-fixture exports:
docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py
docker compose exec -T api uv run pytest tests/test_lfx_validate.py docker compose exec -T api uv run pytest tests/test_lfx_validate.py
Notes: the generator writes UTF-8/LF explicitly (Windows-safe), and
`backend/flows/*.json` may carry Langflow round-trip state (key order)
the generator does not reproduce — if a regen rewrites canonical files
beyond the component's own node, revert them and splice only the changed
node fields (code, description, output types, edge handles) instead.

View file

@ -7,8 +7,6 @@ template into a web form (``app/flows/input_schema.py``); the frontend
renders that schema with RJSF. renders that schema with RJSF.
""" """
from .bauvorhaben_form import CustomInputBauvorhaben
from .form_inputs import CustomInputForm from .form_inputs import CustomInputForm
from .mitarbeiter_form import CustomInputMitarbeiter
__all__ = ["CustomInputBauvorhaben", "CustomInputForm", "CustomInputMitarbeiter"] __all__ = ["CustomInputForm"]

View file

@ -1,147 +0,0 @@
"""Shared form runtime core (single source, codegen-inlined for LFX).
The LFX runtime executes embedded module source without our ``kirche``
package, so flow JSON must stay self-contained. Author components against
this module (``from kirche._form_core import ...`` or ``from ._form_core``
); ``scripts/make_flow_fixtures.py:kirche_node()`` inlines this file's text
into the exported ``code`` so the runtime keeps working with one truth in
the repo.
Supports both the legacy ``[visible_if feld=Wert]`` marker in ``info``
and the structured ``visible_if`` key (list of {field, value} or dict):
structured wins when present, markers stay as deprecated fallback.
"""
from __future__ import annotations
import json
import re
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
#: Reserved input carrying the run envelope — never a form field name.
PLUMBING_INPUTS = frozenset({"form_values", "code"})
def _text(value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def _json_safe(value):
"""Plain JSON value: unwrap LFX wrappers, keep scalars."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
text = getattr(value, "text", None)
if isinstance(text, str):
return text
data = getattr(value, "data", None)
if isinstance(data, dict):
return _json_safe(data)
return str(value)
def _split_conditions(text: str) -> list:
parts: list = []
buf: list = []
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):
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 _field_conditions(info: object, structured=None) -> list:
"""[(trigger, value)] from structured key or info markers.
Structured ``visible_if`` (list of ``{field, value}`` or ``{field: value}``
dict) wins; legacy ``[visible_if ...]`` markers in ``info`` stay as
deprecated fallback.
"""
if structured:
conditions: list = []
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:
return conditions
conditions = []
def _collect(match) -> str:
for token in _split_conditions(match.group(1)):
parsed = _parse_condition(token)
if parsed is not None:
conditions.append(parsed)
return ""
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
return conditions
def collect_form_data(field_names: list, field_values: dict, payload: dict, asked=None) -> dict:
"""Shared collect(): effective values, visibility scope, submitted wins."""
effective = dict(field_values)
for key, val in payload.items():
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
scope = [n for n in (asked if asked is not None else field_names) if n in field_names]
return effective, scope
def parse_envelope(text_value) -> tuple[dict, list | None]:
"""(payload, asked-fields) from the run envelope text."""
try:
envelope = json.loads(_text(text_value) or "{}")
except (json.JSONDecodeError, ValueError, AttributeError):
return {}, None
if not isinstance(envelope, dict):
return {}, None
if isinstance(envelope.get("payload"), dict):
fields = envelope.get("fields")
asked = (
[i for i in fields if i]
if isinstance(fields, list) and all(isinstance(i, str) for i in fields)
else None
)
return envelope["payload"], asked
return envelope, None

View file

@ -1,299 +0,0 @@
"""Bauvorhaben form component for kirche-einfach flows.
Standalone by design: this module shares NO code with ``form_inputs``
(no imports from it). The LFX runtime executes the embedded module
source without our ``kirche`` package, so every form component must be
self-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated
machinery below into a shared import — it would break flow execution.
Copy this file as the starting point for future forms.
A flow that starts with :class:`CustomInputBauvorhaben` declares the
fields of its web form. The API reads the exported node template
(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``
from the ``lfx.io`` input definitions below — one JSON-Schema property
per field, keyed by ``name``:
* ``display_name`` → ``title``, ``info`` → ``description``
(``[visible_if ...]`` markers declare conditional branches, see below),
``value`` → ``default``, ``options`` → ``enum``
* ``required`` → required array (branch-required applies only while visible)
``collect()`` is fully dynamic: it emits every input except the plumbing
one (``form_values``), so fields can be renamed, added, or removed in
the Langflow code editor without touching this method.
When the run envelope names the asked fields (``fields`` list, sent by
the API from the version's form schema), defaults are emitted only for
asked AND currently visible fields. Without the list (raw dict,
Langflow playground), all visible declared defaults apply.
``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.
"""
from __future__ import annotations
import json
import re
from typing import ClassVar
from lfx.custom import Component
from lfx.io import (
BoolInput,
DropdownInput,
IntInput,
MessageTextInput,
MultiselectInput,
Output,
StrInput,
)
from lfx.schema.data import Data
def _text(value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def _json_safe(value):
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
text = getattr(value, "text", None)
if isinstance(text, str):
return text
data = getattr(value, "data", None)
if isinstance(data, dict):
return _json_safe(data)
return str(value)
def _split_conditions(text: str) -> list:
parts: list = []
buf: list = []
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):
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 _field_conditions(info: object) -> list:
"""``[(trigger, value)]`` from a field's ``info`` markers."""
conditions: list = []
def _collect(match) -> str:
for token in _split_conditions(match.group(1)):
parsed = _parse_condition(token)
if parsed is not None:
conditions.append(parsed)
return ""
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
return conditions
#: Reserved input carrying the run envelope — a real runtime input, so it
#: must stay out of the output. Never name a form field ``form_values``.
PLUMBING_INPUTS = frozenset({"form_values"})
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
#: ``app/flows/input_schema.py`` — keep the two in sync).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
class CustomInputBauvorhaben(Component):
display_name = "Bauvorhaben Form"
description = "Collects Bauvorhaben form values into a single JSON object."
icon = "Church"
inputs: ClassVar[list] = [
# Plumbing, not a form field: the API posts submitted web-form
# values as the run envelope (same {"payload": {...}} shape the
# other kirche components use); they arrive here through a ChatInput
# entry node and override the defaults below. Hidden in the GUI and
# skipped by the form parser (show=False).
MessageTextInput(
name="form_values",
display_name="Form values (JSON envelope)",
info="Submitted values as JSON; merged over the defaults",
value="",
show=False,
),
DropdownInput(
name="gebaeude",
display_name="Welches Gebäude soll verändert werden?",
info="",
options=["Kirche", "Anderes Gebäude"],
value="",
required=True,
),
MultiselectInput(
name="bestandteil",
display_name="Welcher Bestandteil der Kirche?",
info="Mehrfachauswahl möglich [visible_if gebaeude=Kirche]",
options=["Glocke", "Orgel", "Innenraum", "Fenster", "Fassade"],
value=[],
required=True,
),
DropdownInput(
name="glockenmassnahme",
display_name="Welche Maßnahme an der Glocke?",
info="[visible_if bestandteil=Glocke]",
options=["Reinigung", "Reparatur", "Neuguss"],
value="Reinigung",
),
IntInput(
name="gussjahr",
display_name="Gussjahr (nur bei Neuguss)?",
info="[visible_if glockenmassnahme=Neuguss]",
value=0,
),
DropdownInput(
name="gebaeudeart",
display_name="Art des anderen Gebäudes?",
info='[visible_if gebaeude="Anderes Gebäude"]',
options=["Wohnhaus", "Scheune", "Sonstiges"],
value="Wohnhaus",
),
BoolInput(
name="denkmalschutz",
display_name="Steht das Gebäude unter Denkmalschutz?",
info="",
value=False,
),
StrInput(
name="denkmalnummer",
display_name="Aktenzeichen Denkmalschutz?",
info="[visible_if denkmalschutz=true]",
value="",
required=True,
),
]
outputs: ClassVar[list] = [
Output(display_name="Form Data", name="form_data", method="collect"),
]
def _field_names(self) -> list:
"""Names of all form fields (every input except the plumbing ones)."""
inputs = getattr(self, "_inputs", None)
if isinstance(inputs, dict) and inputs:
return [name for name in inputs if name not in PLUMBING_INPUTS]
seen = []
declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or []
for inp in declared:
name = getattr(inp, "name", None)
if name and name not in PLUMBING_INPUTS and name not in seen:
seen.append(name)
return seen
def _field_value(self, name: str):
"""Runtime value of one input.
Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would
otherwise resolve to the component attribute instead of its value.
"""
inputs = getattr(self, "_inputs", None)
if isinstance(inputs, dict) and name in inputs:
return getattr(inputs[name], "value", None)
try:
return getattr(self, name, None)
except (AttributeError, ValueError):
return None
def _is_visible(self, name: str, effective: dict) -> bool:
"""Whether a field's branch conditions hold.
Single pass over effective values (submitted first, code defaults
after): no recursion, so trigger cycles cannot hang — a stale or
cyclic marker simply matches or not. Unknown triggers never match.
"""
inputs = getattr(self, "_inputs", None)
info = ""
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
for trigger, expected in _field_conditions(info):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
continue
actual = effective[trigger]
if isinstance(actual, list):
if expected not in actual:
return False
elif actual != expected:
return False
return True
def collect(self) -> Data:
try:
envelope = json.loads(_text(self.form_values) or "{}")
except (json.JSONDecodeError, ValueError, AttributeError):
envelope = {}
payload: dict = {}
asked: list | None = None
if isinstance(envelope, dict):
# Asked-field list counts only in true envelope shape
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
# field literally named "fields" must not confuse it.
if isinstance(envelope.get("payload"), dict):
payload = envelope["payload"]
fields = envelope.get("fields")
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
asked = [item for item in fields if item]
else:
payload = envelope
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items():
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code
# revision does not declare.
for key, val in payload.items():
if isinstance(key, str) and key:
data[key] = _json_safe(val)
return Data(data=data)

View file

@ -11,23 +11,6 @@ keyed by ``name``. Field metadata maps as follows:
* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint * ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint
* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``) * ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)
``collect()`` is fully dynamic: it emits every input except the plumbing
ones (``form_values``, ``code``), so fields can be renamed, added, or
removed in the Langflow code editor without touching this method.
``name`` is the stable JSON key (freely renamable, must stay unique and
non-empty); downstream components read the keys as-is.
When the run envelope names the asked fields (``fields`` list, sent by
the API from the version's form schema), defaults are emitted only for
asked fields — the template may declare fields unknown to this code and
unasked code defaults stay out of the output. Without the list (raw dict,
Langflow playground), all declared defaults apply.
``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the
values travel as a real JSON object. A terminal ``ChatOutput`` renders it
as a fenced ```json block; components consuming it should read ``.data``
(or ``get_text()``), not ``.text``.
``FileInput`` is a stub for now: it carries a string reference only, real ``FileInput`` is a stub for now: it carries a string reference only, real
upload comes later. upload comes later.
""" """
@ -35,7 +18,6 @@ upload comes later.
from __future__ import annotations from __future__ import annotations
import json import json
import re
from typing import ClassVar from typing import ClassVar
from lfx.custom import Component from lfx.custom import Component
@ -54,7 +36,7 @@ from lfx.io import (
SliderInput, SliderInput,
StrInput, StrInput,
) )
from lfx.schema.data import Data from lfx.schema.message import Message
def _text(value) -> str: def _text(value) -> str:
@ -65,93 +47,9 @@ def _text(value) -> str:
return str(value) return str(value)
def _json_safe(value):
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
text = getattr(value, "text", None)
if isinstance(text, str):
return text
data = getattr(value, "data", None)
if isinstance(data, dict):
return _json_safe(data)
return str(value)
def _split_conditions(text: str) -> list:
parts: list = []
buf: list = []
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):
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 _field_conditions(info: object) -> list:
"""``[(trigger, value)]`` from a field's ``info`` markers."""
conditions: list = []
def _collect(match) -> str:
for token in _split_conditions(match.group(1)):
parsed = _parse_condition(token)
if parsed is not None:
conditions.append(parsed)
return ""
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
return conditions
#: Reserved input carrying the run envelope — a real runtime input, so it
#: must stay out of the output (otherwise the whole envelope JSON would
#: leak in as a field value on every path without an asked-fields list).
#: Never name a form field ``form_values``.
PLUMBING_INPUTS = frozenset({"form_values"})
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
#: ``app/flows/input_schema.py`` — keep the two in sync; this module must
#: stay import-light for the LFX runtime and cannot import it).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
class CustomInputForm(Component): class CustomInputForm(Component):
display_name = "CustomInput Form" display_name = "CustomInput Form"
description = "Collects structured form values into a single JSON object." description = "Collects structured form values into a single JSON message."
icon = "ClipboardList" icon = "ClipboardList"
inputs: ClassVar[list] = [ inputs: ClassVar[list] = [
@ -236,90 +134,29 @@ class CustomInputForm(Component):
Output(display_name="Form Data", name="form_data", method="collect"), Output(display_name="Form Data", name="form_data", method="collect"),
] ]
def _field_names(self) -> list: def collect(self) -> Message:
"""Names of all form fields (every input except the plumbing ones).""" data = {
inputs = getattr(self, "_inputs", None) "full_name": _text(self.full_name),
if isinstance(inputs, dict) and inputs: "notes": _text(self.notes),
return [name for name in inputs if name not in PLUMBING_INPUTS] "password": _text(self.password),
seen = [] "age": self.age,
declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or [] "amount": self.amount,
for inp in declared: "newsletter": self.newsletter,
name = getattr(inp, "name", None) "department": _text(self.department),
if name and name not in PLUMBING_INPUTS and name not in seen: "topics": self.topics,
seen.append(name) "satisfaction": self.satisfaction,
return seen "attachment": _text(self.attachment),
}
def _field_value(self, name: str): # Submitted values (via form_values) win over the field defaults.
"""Runtime value of one input. # Accepts the raw dict or the run envelope {"payload": {...}}.
Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would
otherwise resolve to the component attribute instead of its value.
"""
inputs = getattr(self, "_inputs", None)
if isinstance(inputs, dict) and name in inputs:
return getattr(inputs[name], "value", None)
try: try:
return getattr(self, name, None) override = json.loads(_text(self.form_values) or "{}")
except (AttributeError, ValueError): except json.JSONDecodeError:
return None override = {}
if isinstance(override, dict):
def _is_visible(self, name: str, effective: dict) -> bool: if isinstance(override.get("payload"), dict):
"""Whether a field's branch conditions hold. override = override["payload"]
for key in data:
Single pass over effective values (submitted first, code defaults if key in override:
after): no recursion, so trigger cycles cannot hang — a stale or data[key] = override[key]
cyclic marker simply matches or not. Unknown triggers never match. return Message(text=json.dumps(data, ensure_ascii=False))
"""
inputs = getattr(self, "_inputs", None)
info = ""
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
for trigger, expected in _field_conditions(info):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
continue
actual = effective[trigger]
if isinstance(actual, list):
if expected not in actual:
return False
elif actual != expected:
return False
return True
def collect(self) -> Data:
try:
envelope = json.loads(_text(self.form_values) or "{}")
except (json.JSONDecodeError, ValueError, AttributeError):
envelope = {}
payload: dict = {}
asked: list | None = None
if isinstance(envelope, dict):
# Asked-field list counts only in true envelope shape
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
# field literally named "fields" must not confuse it.
if isinstance(envelope.get("payload"), dict):
payload = envelope["payload"]
fields = envelope.get("fields")
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
asked = [item for item in fields if item]
else:
payload = envelope
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items():
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code
# revision does not declare.
for key, val in payload.items():
if isinstance(key, str) and key:
data[key] = _json_safe(val)
return Data(data=data)

View file

@ -1,276 +0,0 @@
"""Mitarbeiter-einstellen form component for kirche-einfach flows.
Standalone by design: this module shares NO code with the other form
modules (no imports from them). The LFX runtime executes the embedded
module source without our ``kirche`` package, so every form component
must be self-contained (stdlib + ``lfx`` only). Do NOT refactor the
duplicated machinery below into a shared import — it would break flow
execution. Copy this file (or ``bauvorhaben_form.py``) as the starting
point for future forms.
A flow that starts with :class:`CustomInputMitarbeiter` declares the
fields of its web form. The API reads the exported node template
(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``
from the ``lfx.io`` input definitions below — one JSON-Schema property
per field, keyed by ``name``:
* ``display_name`` → ``title``, ``info`` → ``description``
(``[visible_if ...]`` markers declare conditional branches),
``value`` → ``default``, ``options`` → ``enum``
* ``required`` → required array (branch-required applies only while visible)
``collect()`` is fully dynamic: it emits every input except the plumbing
one (``form_values``), so fields can be renamed, added, or removed in
the Langflow code editor without touching this method.
When the run envelope names the asked fields (``fields`` list, sent by
the API from the version's form schema), defaults are emitted only for
asked AND currently visible fields. Without the list (raw dict,
Langflow playground), all visible declared defaults apply.
``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.
"""
from __future__ import annotations
import json
import re
from typing import ClassVar
from lfx.custom import Component
from lfx.io import (
DropdownInput,
MessageTextInput,
Output,
StrInput,
)
from lfx.schema.data import Data
def _text(value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def _json_safe(value):
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
text = getattr(value, "text", None)
if isinstance(text, str):
return text
data = getattr(value, "data", None)
if isinstance(data, dict):
return _json_safe(data)
return str(value)
def _split_conditions(text: str) -> list:
parts: list = []
buf: list = []
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):
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 _field_conditions(info: object) -> list:
"""``[(trigger, value)]`` from a field's ``info`` markers."""
conditions: list = []
def _collect(match) -> str:
for token in _split_conditions(match.group(1)):
parsed = _parse_condition(token)
if parsed is not None:
conditions.append(parsed)
return ""
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
return conditions
#: Reserved input carrying the run envelope — a real runtime input, so it
#: must stay out of the output. Never name a form field ``form_values``.
PLUMBING_INPUTS = frozenset({"form_values"})
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
#: ``app/flows/input_schema.py`` — keep the two in sync).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
class CustomInputMitarbeiter(Component):
display_name = "Mitarbeiter Einstellen Form"
description = "Collects hiring form values into a single JSON object."
icon = "UserPlus"
inputs: ClassVar[list] = [
# Plumbing, not a form field: the API posts submitted web-form
# values as the run envelope (same {"payload": {...}} shape the
# other kirche components use); they arrive here through a ChatInput
# entry node and override the defaults below. Hidden in the GUI and
# skipped by the form parser (show=False).
MessageTextInput(
name="form_values",
display_name="Form values (JSON envelope)",
info="Submitted values as JSON; merged over the defaults",
value="",
show=False,
),
DropdownInput(
name="rolle",
display_name="Rolle",
info="",
options=["Assistenz", "Pfarrer:in", "Vikar:in", "Messner:in"],
value="Assistenz",
required=True,
),
DropdownInput(
name="einstufung",
display_name="Einstufung",
info="Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]",
options=["Stufe 1", "Stufe 2", "Stufe 3"],
value="Stufe 1",
),
StrInput(
name="mitarbeiter_name",
display_name="Name",
info="Vor- und Nachname des Mitarbeiters",
value="",
required=True,
),
StrInput(
name="geburtsdatum",
display_name="Geburtsdatum",
info="Datum eingeben (z. B. JJJJ-MM-TT)",
value="",
),
]
outputs: ClassVar[list] = [
Output(display_name="Form Data", name="form_data", method="collect"),
]
def _field_names(self) -> list:
"""Names of all form fields (every input except the plumbing ones)."""
inputs = getattr(self, "_inputs", None)
if isinstance(inputs, dict) and inputs:
return [name for name in inputs if name not in PLUMBING_INPUTS]
seen = []
declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or []
for inp in declared:
name = getattr(inp, "name", None)
if name and name not in PLUMBING_INPUTS and name not in seen:
seen.append(name)
return seen
def _field_value(self, name: str):
"""Runtime value of one input.
Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would
otherwise resolve to the component attribute instead of its value.
"""
inputs = getattr(self, "_inputs", None)
if isinstance(inputs, dict) and name in inputs:
return getattr(inputs[name], "value", None)
try:
return getattr(self, name, None)
except (AttributeError, ValueError):
return None
def _is_visible(self, name: str, effective: dict) -> bool:
"""Whether a field's branch conditions hold.
Single pass over effective values (submitted first, code defaults
after): no recursion, so trigger cycles cannot hang — a stale or
cyclic marker simply matches or not. Unknown triggers never match.
"""
inputs = getattr(self, "_inputs", None)
info = ""
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
for trigger, expected in _field_conditions(info):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
continue
actual = effective[trigger]
if isinstance(actual, list):
if expected not in actual:
return False
elif actual != expected:
return False
return True
def collect(self) -> Data:
try:
envelope = json.loads(_text(self.form_values) or "{}")
except (json.JSONDecodeError, ValueError, AttributeError):
envelope = {}
payload: dict = {}
asked: list | None = None
if isinstance(envelope, dict):
# Asked-field list counts only in true envelope shape
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
# field literally named "fields" must not confuse it.
if isinstance(envelope.get("payload"), dict):
payload = envelope["payload"]
fields = envelope.get("fields")
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
asked = [item for item in fields if item]
else:
payload = envelope
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items():
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code
# revision does not declare.
for key, val in payload.items():
if isinstance(key, str) and key:
data[key] = _json_safe(val)
return Data(data=data)

View file

@ -1,3 +0,0 @@
# Flows excluded from the pipeline (sync, deploy, validation, frontend).
# One filename stem per line; these stay Langflow-only experiments.
i_love_this_app

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -10,20 +10,21 @@ from __future__ import annotations
import argparse import argparse
import json import json
import sys import sys
import urllib.request
import httpx
def post(api: str, path: str, body: dict) -> dict: def post(api: str, path: str, body: dict) -> dict:
request = urllib.request.Request(
api.rstrip("/") + path,
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try: try:
response = httpx.post(api.rstrip("/") + path, json=body, timeout=60.0) with urllib.request.urlopen(request) as response:
try: return {"status": response.status, "body": json.loads(response.read())}
payload = response.json() except urllib.error.HTTPError as exc:
except ValueError: return {"status": exc.code, "body": exc.read().decode()[:2000]}
payload = response.text[:2000]
return {"status": response.status_code, "body": payload}
except httpx.HTTPError as exc:
return {"status": 0, "body": str(exc)[:2000]}
def main() -> int: def main() -> int:

View file

@ -91,8 +91,6 @@ def kirche_node(
node_id: str, node_id: str,
values: dict | None = None, values: dict | None = None,
position: tuple[int, int] = (0, 0), position: tuple[int, int] = (0, 0),
component_cls=None,
code_override: str | None = None,
) -> dict: ) -> dict:
"""A node from our GUI-editable category lib (backend/components/kirche). """A node from our GUI-editable category lib (backend/components/kirche).
@ -103,22 +101,15 @@ def kirche_node(
the same way). The template dict before that round-trip is complete and 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 faithful, so we use it directly and append the ``code`` field by hand
(mirroring the shape ``build_custom_component_template`` produces). (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")) sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
if component_cls is None: from kirche.form_inputs import CustomInputForm
from kirche.form_inputs import CustomInputForm as component_cls
from lfx.template.frontend_node.custom_components import ( from lfx.template.frontend_node.custom_components import (
ComponentFrontendNode, ComponentFrontendNode,
) )
comp_name = getattr(component_cls, "name", None) or component_cls.__name__ comp_name = getattr(CustomInputForm, "name", None) or "CustomInputForm"
inst = component_cls() inst = CustomInputForm()
if code_override is not None:
inst._code = code_override
frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst)) frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst))
inst._map_parameters_on_frontend_node(frontend_node) inst._map_parameters_on_frontend_node(frontend_node)
node = frontend_node.to_dict(keep_name=False) node = frontend_node.to_dict(keep_name=False)
@ -156,18 +147,14 @@ def kirche_node(
# form_values envelope (collect() merges), never via edges — so required # form_values envelope (collect() merges), never via edges — so required
# template fields carry a single-space sentinel value. The form parser # template fields carry a single-space sentinel value. The form parser
# (app/flows/input_schema.py) treats whitespace-only values as "no # (app/flows/input_schema.py) treats whitespace-only values as "no
# default", keeping the web form clean. Multiselect needs a list to # default", keeping the web form clean.
# 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(): for field_def in node_dict["data"]["node"]["template"].values():
if ( if (
isinstance(field_def, dict) isinstance(field_def, dict)
and field_def.get("required") and field_def.get("required")
and field_def.get("value") in (None, "", [], {}) and field_def.get("value") in (None, "", [], {})
): ):
field_def["value"] = ( field_def["value"] = " "
[" "] if field_def.get("_input_type") == "MultiselectInput" else " "
)
return node_dict return node_dict
@ -419,128 +406,6 @@ 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 # flows
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@ -679,108 +544,6 @@ 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: def main() -> None:
FIXTURE_DIR.mkdir(parents=True, exist_ok=True) FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
FLOW_DIR.mkdir(parents=True, exist_ok=True) FLOW_DIR.mkdir(parents=True, exist_ok=True)
@ -788,16 +551,11 @@ def main() -> None:
"simple_rule.json": build_simple_rule(), "simple_rule.json": build_simple_rule(),
"approval_handoff.json": build_approval_handoff(), "approval_handoff.json": build_approval_handoff(),
"intake_form.json": build_intake_form(), "intake_form.json": build_intake_form(),
"bauvorhaben.json": build_bauvorhaben(),
"mitarbeiter_einstellen.json": build_mitarbeiter(),
} }
for filename, doc in flows.items(): for filename, doc in flows.items():
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n" payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
for target in (FIXTURE_DIR / filename, FLOW_DIR / filename): for target in (FIXTURE_DIR / filename, FLOW_DIR / filename):
# Explicit encoding + LF: the default locale/newline handling target.write_text(payload)
# 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}") print(f"wrote {target}")

View file

@ -1,40 +0,0 @@
"""Push canonical backend/flows/*.json into Langflow via API (no restart)."""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
from app.flows.langflow_client import LangflowClient, LangflowError
from app.settings import settings
async def main() -> None:
base = os.environ.get("LANGFLOW_URL", "http://localhost:7860")
key = settings.langflow_api_key or os.environ.get("LANGFLOW_API_KEY", "")
print(f"Langflow: {base} key_len={len(key)}")
client = LangflowClient(base, api_key=key)
try:
flows = await client.list_flows()
print(f"existing: {len(flows)} flows")
for f in flows[:20]:
print(" -", f.get("name"), f.get("id"))
except LangflowError as exc:
print(f"list failed: {exc}")
return
flows_dir = Path(settings.flows_dir)
for path in sorted(flows_dir.glob("*.json")):
if path.name.startswith("."):
continue
doc = json.loads(path.read_text(encoding="utf-8"))
try:
result = await client.upsert_flow(doc)
print(f"pushed {path.name} id={result.get('id', 'ok')}")
except LangflowError as exc:
print(f"push {path.name} failed: {exc}")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -42,28 +42,8 @@ async def run_sync(flows_dir: Path) -> SyncReport:
session.close() session.close()
async def run_full_pass(flows_dir: Path) -> SyncReport:
"""Single orchestration: Langflow pull + file sync (shared with API)."""
reset_engine()
session = Session(get_engine())
try:
lfx = LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
langflow = LangflowClient(settings.langflow_url, api_key=settings.langflow_api_key)
return await sync.sync_full_pass(session, lfx, langflow, flows_dir)
finally:
session.close()
async def main_async(args) -> int: async def main_async(args) -> int:
flows_dir = Path(args.dir or settings.flows_dir) flows_dir = Path(args.dir or settings.flows_dir)
if args.pull and args.sync:
try:
report = await run_full_pass(flows_dir)
except FileNotFoundError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
print(report.model_dump_json(indent=2))
return 1 if report.errors else 0
if args.pull: if args.pull:
pulled = await pull_from_langflow(flows_dir) pulled = await pull_from_langflow(flows_dir)
print(json.dumps(pulled, indent=2, ensure_ascii=False)) print(json.dumps(pulled, indent=2, ensure_ascii=False))

View file

@ -1,68 +0,0 @@
"""Shared e2e harness: free ports, health waits, lfx spawn, migrate, sessions.
Single place for logic duplicated between tests/conftest.py and
scripts/smoke_e2e.py (~60 lines). Full real-LFX runs are kept.
"""
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
def free_port() -> int:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def wait_healthy(url: str, deadline_s: float = 240.0) -> None:
deadline = time.time() + deadline_s
while time.time() < deadline:
try:
with urllib.request.urlopen(url + "/health", timeout=3) as response:
if response.status == 200:
return
except OSError:
time.sleep(1)
raise RuntimeError(f"server did not become healthy: {url}")
def spawn_lfx(flow_dir: Path, port: int) -> subprocess.Popen:
bindir = Path(sys.executable).parent
lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx"
env = {**os.environ, "LANGFLOW_API_KEY": "", "LFX_API_KEY": "test-lfx-key"}
return subprocess.Popen(
[
lfx_bin,
"serve",
"--host",
"127.0.0.1",
"--port",
str(port),
"--flow-dir",
str(flow_dir),
str(flow_dir),
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
)
def migrate_throwaway(backend_dir: Path) -> None:
from alembic import command as alembic_command
from alembic.config import Config
cfg = Config()
cfg.set_main_option("script_location", str(backend_dir / "migrations"))
alembic_command.upgrade(cfg, "head")

View file

@ -278,89 +278,3 @@ def live_server():
yield f"http://127.0.0.1:{port}" yield f"http://127.0.0.1:{port}"
server.should_exit = True server.should_exit = True
thread.join(timeout=15) thread.join(timeout=15)
# --- shared declutter helpers (single place, was copy-pasted across files) ---
FIXTURES_DIR = Path(__file__).parent / "fixtures" / "flows"
def load_flow_fixture(name: str) -> dict:
"""Load a fixture flow JSON (shared; was load_fixture() x3)."""
import json
return json.loads((FIXTURES_DIR / name).read_text(encoding="utf-8"))
def langflow_export_doc(name: str = "fake", flow_id: str = "fake-1") -> dict:
"""Minimal Langflow export envelope (merges _export_doc/_langflow_doc)."""
return {
"id": flow_id,
"name": name,
"description": "",
"data": {"nodes": [], "edges": []},
}
@pytest.fixture()
def anon_client():
"""Unauthenticated ASGI client (was 3x copy-pasted ASGITransport blocks)."""
from httpx import ASGITransport, AsyncClient
from app.main import app
async def _client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
return _client
@pytest.fixture()
def flow_helpers(client, login):
"""API flow factory: create flow/version/deploy/deployed_flow in one place.
Collapses _create_flow/_create_version/_deploy x4 files (~60 lines).
"""
async def _create_flow(name: str = "regeltest") -> dict:
response = await client.post("/api/flows", json={"name": name, "description": "t"})
assert response.status_code == 201, response.text
return response.json()
async def _create_version(flow_id: str, fixture: str = "simple_rule.json") -> dict:
response = await client.post(
f"/api/flows/{flow_id}/versions",
json={"langflow_json": load_flow_fixture(fixture), "author": "test"},
)
assert response.status_code == 201, response.text
return response.json()
async def _deploy(flow_id: str, version_no: int = 1) -> dict:
response = await client.post(f"/api/flows/{flow_id}/versions/{version_no}/deploy")
assert response.status_code == 200, response.text
return response.json()
async def _deployed_flow(name: str = "regeltest", fixture: str = "simple_rule.json") -> tuple:
await login()
flow = await _create_flow(name)
version = await _create_version(flow["id"], fixture)
deployed = await _deploy(flow["id"], version["version_no"])
return flow, deployed
return {
"create_flow": _create_flow,
"create_version": _create_version,
"deploy": _deploy,
"deployed_flow": _deployed_flow,
}
@pytest.fixture()
def lfx_client():
"""LFX client pointed at the real test server (was _lfx() x2)."""
from app.flows.lfx_client import LfxClient
from app.settings import settings
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -2,26 +2,18 @@
import json import json
import pytest
from app.flows import export from app.flows import export
@pytest.mark.parametrize( def test_filename_stem_keeps_original_names():
("name", "expected"), assert export.filename_stem("simple_rule") == "simple_rule"
[ assert export.filename_stem("test_flow") == "test_flow"
("simple_rule", "simple_rule"), assert export.filename_stem("Gehaltserhöhung") == "Gehaltserhöhung"
("test_flow", "test_flow"), assert export.filename_stem("Dachsanierung 200k") == "Dachsanierung 200k"
("Gehaltserhöhung", "Gehaltserhöhung"), assert export.filename_stem(" A/B\\C ") == "A-B-C"
("Dachsanierung 200k", "Dachsanierung 200k"), assert export.filename_stem('a:b*c?d"e<f>g|h') == "a-b-c-d-e-f-g-h"
(" A/B\\C ", "A-B-C"), assert export.filename_stem("trailing...") == "trailing"
('a:b*c?d"e<f>g|h', "a-b-c-d-e-f-g-h"), assert export.filename_stem("") == "flow"
("trailing...", "trailing"),
("", "flow"),
],
)
def test_filename_stem_keeps_original_names(name, expected):
assert export.filename_stem(name) == expected
def test_canonical_bytes_stable_under_key_reorder(): def test_canonical_bytes_stable_under_key_reorder():
@ -51,28 +43,6 @@ def test_iter_flow_files_only_top_level_json(tmp_path):
assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"] assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"]
def test_flowsignore_parsing_and_filtering(tmp_path):
assert export.ignored_stems(tmp_path) == set()
(tmp_path / export.IGNORE_FILENAME).write_text(
"# experiments stay Langflow-only\n\ni_love_this_app\n", encoding="utf-8"
)
assert export.ignored_stems(tmp_path) == {"i_love_this_app"}
(tmp_path / "a.json").write_text("{}")
(tmp_path / "i_love_this_app.json").write_text("{}")
assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"]
# Explicit override still works.
assert export.iter_flow_files(tmp_path, ignored=set()) == [
tmp_path / "a.json",
tmp_path / "i_love_this_app.json",
]
def test_find_reuse_path_ignores_ignored_files(tmp_path):
(tmp_path / export.IGNORE_FILENAME).write_text("alt\n", encoding="utf-8")
(tmp_path / "alt.json").write_text(json.dumps({"name": "Neu"}))
assert export.find_reuse_path(tmp_path, {"name": "Neu"}) == (None, False)
def test_find_reuse_path_matches_identical_content_first(tmp_path): def test_find_reuse_path_matches_identical_content_first(tmp_path):
doc = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}} doc = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}}
other = tmp_path / "other-name.json" other = tmp_path / "other-name.json"

View file

@ -39,173 +39,26 @@ def test_component_contract():
def test_collect_merges_submitted_values_over_defaults(): def test_collect_merges_submitted_values_over_defaults():
from lfx.schema.data import Data from lfx.schema.message import Message
form = CustomInputForm() form = CustomInputForm()
default = form.collect() default = json.loads(form.collect().text)
assert isinstance(default, Data) assert default["full_name"] == ""
assert default.data["full_name"] == "" assert default["department"] == "Music"
assert default.data["department"] == "Music" assert isinstance(form.collect(), Message)
form.form_values = json.dumps({"full_name": "Maria", "age": 41}) form.form_values = json.dumps({"full_name": "Maria", "age": 41})
merged = form.collect().data merged = json.loads(form.collect().text)
assert merged["full_name"] == "Maria" assert merged["full_name"] == "Maria"
assert merged["age"] == 41 assert merged["age"] == 41
assert merged["department"] == "Music" # default kept assert merged["department"] == "Music" # default kept
# Run envelope shape {"payload": {...}} unwraps like the other components. # Run envelope shape {"payload": {...}} unwraps like the other components.
form.form_values = json.dumps({"payload": {"notes": "hi"}}) form.form_values = json.dumps({"payload": {"notes": "hi"}})
assert form.collect().data["notes"] == "hi" assert json.loads(form.collect().text)["notes"] == "hi"
form.form_values = "not json" form.form_values = "not json"
assert form.collect().data["full_name"] == "" assert json.loads(form.collect().text)["full_name"] == ""
def test_collect_picks_up_langflow_added_field():
"""A field added in the Langflow code editor needs no collect() change."""
from lfx.io import StrInput
form = CustomInputForm()
form._inputs["room"] = StrInput(name="room", display_name="Room", info="Where", value="Chapel")
collected = form.collect().data
assert collected["room"] == "Chapel"
assert collected["full_name"] == ""
def test_collect_survives_rename():
"""Renaming `full_name` to `name` drops the old key, keeps the new one."""
from lfx.io import StrInput
form = CustomInputForm()
del form._inputs["full_name"]
form._inputs["name"] = StrInput(name="name", display_name="Name", info="", value="Maria")
collected = form.collect().data
assert "full_name" not in collected
assert collected["name"] == "Maria"
def test_collect_keeps_unknown_envelope_keys():
"""Frontend payloads with renamed keys merge even if undeclared here."""
form = CustomInputForm()
form.form_values = json.dumps({"payload": {"nickname": "Mia"}})
collected = form.collect().data
assert collected["nickname"] == "Mia"
assert collected["department"] == "Music" # default kept
def test_collect_fields_list_restricts_defaults():
"""Asked-field list: unasked code defaults stay out of the output."""
form = CustomInputForm()
form.form_values = json.dumps(
{"payload": {"full_name": "Maria"}, "fields": ["full_name", "age"]}
)
collected = form.collect().data
assert collected == {"full_name": "Maria", "age": 18}
def test_collect_fields_list_fills_missing_from_defaults():
form = CustomInputForm()
form.form_values = json.dumps(
{"payload": {"full_name": "Maria"}, "fields": ["full_name", "department"]}
)
collected = form.collect().data
assert collected == {"full_name": "Maria", "department": "Music"}
def test_collect_fields_list_ignores_undeclared_names():
"""Asked-but-undeclared template fields contribute no Nones."""
form = CustomInputForm()
form.form_values = json.dumps({"payload": {"room": "Chapel"}, "fields": ["full_name", "room"]})
collected = form.collect().data
assert collected == {"full_name": "", "room": "Chapel"}
def test_collect_raw_dict_ignores_fields_key():
"""A raw dict is payload, not envelope: 'fields' is just a value."""
form = CustomInputForm()
form.form_values = json.dumps({"fields": ["x"], "full_name": "Maria"})
collected = form.collect().data
assert collected["fields"] == ["x"]
assert collected["full_name"] == "Maria"
assert collected["age"] == 18 # all defaults apply without envelope
def test_collect_hides_branch_default_until_trigger_matches():
"""Hidden branch fields contribute no defaults (frontend parity)."""
form = CustomInputForm()
form._inputs["notes"].info = "Anything [visible_if department=Youth]"
assert "notes" not in form.collect().data # department defaults to Music
form.form_values = json.dumps({"payload": {"department": "Youth"}})
assert form.collect().data["notes"] == ""
def test_collect_branch_visibility_uses_submitted_triggers():
form = CustomInputForm()
form._inputs["notes"].info = "[visible_if age=41]"
form.form_values = json.dumps({"payload": {"age": 41}, "fields": ["age", "notes"]})
assert form.collect().data["notes"] == ""
form.form_values = json.dumps({"payload": {"age": 18}, "fields": ["age", "notes"]})
assert "notes" not in form.collect().data
def test_collect_dangling_marker_stays_visible():
form = CustomInputForm()
form._inputs["notes"].info = "[visible_if nope=x]"
assert "notes" in form.collect().data
def test_bauvorhaben_component_contract():
from kirche.bauvorhaben_form import CustomInputBauvorhaben
assert CustomInputBauvorhaben.display_name == "Bauvorhaben Form"
inputs = {i.name: i for i in CustomInputBauvorhaben.inputs}
assert sorted(n for n in inputs if n != "form_values") == sorted(
[
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
]
)
assert inputs["form_values"].show is False
outputs = {o.name: o for o in CustomInputBauvorhaben.outputs}
assert set(outputs) == {"form_data"}
def test_bauvorhaben_collect_only_own_fields():
"""Standalone component: no intake leftovers, hidden branches dropped."""
from kirche.bauvorhaben_form import CustomInputBauvorhaben
form = CustomInputBauvorhaben()
default = form.collect().data
assert "full_name" not in default and "age" not in default
assert default["gebaeude"] == "" # class default (template ships " " sentinel)
assert "bestandteil" not in default # hidden (gebaeude default "").
assert "gebaeudeart" not in default
form.form_values = json.dumps(
{
"payload": {"gebaeude": "Kirche", "bestandteil": ["Glocke"]},
"fields": [
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
],
}
)
collected = form.collect().data
assert collected["bestandteil"] == ["Glocke"]
assert collected["glockenmassnahme"] == "Reinigung" # visible branch default
assert "gussjahr" not in collected # hidden branch
assert "gebaeudeart" not in collected
assert "full_name" not in collected
def test_intake_fixture_shape(): def test_intake_fixture_shape():
@ -219,8 +72,6 @@ def test_intake_fixture_shape():
assert field in template, field assert field in template, field
assert template["form_values"]["show"] is False assert template["form_values"]["show"] is False
assert len(template["code"]["value"]) > 200 assert len(template["code"]["value"]) > 200
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
assert outputs["form_data"]["types"] == ["JSON"] # JSON object, not Message
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]} edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
assert ("ChatInput-entry", "CustomInputForm-intake") in edges assert ("ChatInput-entry", "CustomInputForm-intake") in edges
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
@ -248,253 +99,7 @@ async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
) )
assert run.status_code == 200, run.text assert run.status_code == 200, run.text
assert run.json()["status"] == "completed" assert run.json()["status"] == "completed"
# collect() returns Data: the terminal ChatOutput renders it as a result = json.loads(run.json()["output"]["result"])
# fenced ```json block, so unwrap before parsing.
result = _parse_chat_json(run.json()["output"]["result"])
assert result["full_name"] == "Maria Muster" assert result["full_name"] == "Maria Muster"
assert result["age"] == 41 assert result["age"] == 41
assert result["department"] == "Music" # default preserved assert result["department"] == "Music" # default preserved
def _parse_chat_json(text: str) -> dict:
"""Parse terminal ChatOutput text: raw JSON or a ```json fenced block."""
text = text.strip()
if text.startswith("```"):
lines = text.splitlines()
assert lines[0].startswith("```"), text[:60]
assert lines[-1].startswith("```"), text[-60:]
text = "\n".join(lines[1:-1])
return json.loads(text)
def test_embedded_code_matches_template_fields():
"""Node template fields == code-declared inputs (no unasked defaults).
The envelope tolerates drift at runtime, but authoring must stay
honest: what Langflow shows in the code editor must be what the form
asks. Building blocks: lfx's own create_class on the embedded code.
"""
from lfx.custom import validate as validate_mod
cases = {
"intake_form.json": ("CustomInputForm-intake", "CustomInputForm"),
"bauvorhaben.json": ("CustomInputBauvorhaben-bauvorhaben", "CustomInputBauvorhaben"),
"mitarbeiter_einstellen.json": (
"CustomInputMitarbeiter-mitarbeiter",
"CustomInputMitarbeiter",
),
}
for filename, (node_id, class_name) in cases.items():
doc = json.loads((FIXTURES / filename).read_text(encoding="utf-8"))
node = next(n for n in doc["data"]["nodes"] if n["id"] == node_id)
assert node["data"]["type"] == class_name, filename
template = node["data"]["node"]["template"]
template_names = {
field["name"] for field in template.values() if isinstance(field, dict)
} - {"code"}
code = template["code"]["value"]
cls = validate_mod.create_class(code, class_name)
code_names = {inp.name for inp in cls.inputs}
assert code_names == template_names, filename
def test_bauvorhaben_fixture_shape():
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
assert doc["name"] == "bauvorhaben"
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
form = nodes["CustomInputBauvorhaben-bauvorhaben"]
assert form["data"]["type"] == "CustomInputBauvorhaben"
assert form["data"]["node"]["display_name"] == "Bauvorhaben Form"
template = form["data"]["node"]["template"]
assert set(template) >= {
"form_values",
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
"code",
}
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
assert outputs["form_data"]["types"] == ["JSON"]
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
assert ("ChatInput-entry", "CustomInputBauvorhaben-bauvorhaben") in edges
assert ("CustomInputBauvorhaben-bauvorhaben", "ChatOutput-collected") in edges
def test_bauvorhaben_schema_branches():
from app.flows import input_schema as parser
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
out = parser.extract_form_schema(doc)
schema = out["json_schema"]
assert set(schema["properties"]) == {"gebaeude", "denkmalschutz"}
assert schema["required"] == ["gebaeude"]
assert len(schema["allOf"]) == 5
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]}
assert set(by_field) == {
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalnummer",
}
assert by_field["bestandteil"]["if"]["properties"] == {"gebaeude": {"const": "Kirche"}}
assert by_field["glockenmassnahme"]["if"]["properties"] == {
"bestandteil": {"contains": {"const": "Glocke"}}
}
assert by_field["gussjahr"]["if"]["properties"] == {"glockenmassnahme": {"const": "Neuguss"}}
assert by_field["gebaeudeart"]["if"]["properties"] == {"gebaeude": {"const": "Anderes Gebäude"}}
assert by_field["denkmalnummer"]["if"]["properties"] == {"denkmalschutz": {"const": True}}
assert by_field["bestandteil"]["then"]["required"] == ["bestandteil"]
assert by_field["denkmalnummer"]["then"]["required"] == ["denkmalnummer"]
assert "required" not in by_field["gussjahr"]["then"]
# Markers stripped from descriptions.
assert "visible_if" not in out["json_schema"]["allOf"][0]["then"]["properties"][
"bestandteil"
].get("description", "")
assert out["ui_schema"]["ui:order"] == [
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
"*",
]
async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, login):
await login()
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
created = await client.post("/api/flows", json={"name": "bauvorhaben-e2e"})
assert created.status_code == 201, created.text
flow_id = created.json()["id"]
version = await client.post(
f"/api/flows/{flow_id}/versions",
json={"langflow_json": doc, "author": "test"},
)
assert version.status_code == 201, version.text
# Branch rules are cached per version and served via input-schema.
cached = version.json()["input_schema"]
assert len(cached["json_schema"]["allOf"]) == 5
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
assert deployed.status_code == 200, deployed.text
schema = await client.get(f"/api/flows/{flow_id}/input-schema")
assert schema.status_code == 200, schema.text
assert set(schema.json()["json_schema"]["properties"]) == {"gebaeude", "denkmalschutz"}
run = await client.post(
f"/api/flows/{flow_id}/run",
json={
"input": {
"gebaeude": "Kirche",
"bestandteil": ["Glocke", "Orgel"],
"glockenmassnahme": "Neuguss",
"gussjahr": 1890,
"denkmalschutz": True,
"denkmalnummer": "D-123",
}
},
)
assert run.status_code == 200, run.text
assert run.json()["status"] == "completed"
result = _parse_chat_json(run.json()["output"]["result"])
assert result["gebaeude"] == "Kirche"
assert result["bestandteil"] == ["Glocke", "Orgel"]
assert result["gussjahr"] == 1890
assert result["denkmalnummer"] == "D-123"
# Only asked fields: unasked code defaults (age, amount, ...) stay out.
assert set(result) == {
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"denkmalschutz",
"denkmalnummer",
}
def test_mitarbeiter_component_contract():
from kirche.mitarbeiter_form import CustomInputMitarbeiter
assert CustomInputMitarbeiter.display_name == "Mitarbeiter Einstellen Form"
inputs = {i.name: i for i in CustomInputMitarbeiter.inputs}
assert sorted(n for n in inputs if n != "form_values") == sorted(
["rolle", "einstufung", "mitarbeiter_name", "geburtsdatum"]
)
assert inputs["form_values"].show is False
outputs = {o.name: o for o in CustomInputMitarbeiter.outputs}
assert set(outputs) == {"form_data"}
def test_mitarbeiter_collect_branch_default():
"""Einstufung default applies only when Pfarrer:in is selected."""
from kirche.mitarbeiter_form import CustomInputMitarbeiter
form = CustomInputMitarbeiter()
assert "einstufung" not in form.collect().data # rolle defaults to Assistenz
form.form_values = json.dumps({"payload": {"rolle": "Pfarrer:in"}})
assert form.collect().data["einstufung"] == "Stufe 1"
def test_mitarbeiter_fixture_shape():
doc = json.loads((FIXTURES / "mitarbeiter_einstellen.json").read_text(encoding="utf-8"))
assert doc["name"] == "mitarbeiter_einstellen"
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
form = nodes["CustomInputMitarbeiter-mitarbeiter"]
assert form["data"]["type"] == "CustomInputMitarbeiter"
assert form["data"]["node"]["display_name"] == "Mitarbeiter Einstellen Form"
template = form["data"]["node"]["template"]
assert set(template) >= {
"form_values",
"rolle",
"einstufung",
"mitarbeiter_name",
"geburtsdatum",
"code",
}
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
assert outputs["form_data"]["types"] == ["JSON"]
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
assert ("ChatInput-entry", "CustomInputMitarbeiter-mitarbeiter") in edges
assert ("CustomInputMitarbeiter-mitarbeiter", "Gehaltsrechner-gehalt") in edges
assert ("Gehaltsrechner-gehalt", "ChatOutput-collected") in edges
async def test_mitarbeiter_deploy_and_run_salary(client, lfx_server, login):
await login()
doc = json.loads((FIXTURES / "mitarbeiter_einstellen.json").read_text(encoding="utf-8"))
created = await client.post("/api/flows", json={"name": "mitarbeiter-e2e"})
assert created.status_code == 201, created.text
flow_id = created.json()["id"]
version = await client.post(
f"/api/flows/{flow_id}/versions",
json={"langflow_json": doc, "author": "test"},
)
assert version.status_code == 201, version.text
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
assert deployed.status_code == 200, deployed.text
run = await client.post(
f"/api/flows/{flow_id}/run",
json={
"input": {
"rolle": "Pfarrer:in",
"einstufung": "Stufe 2",
"mitarbeiter_name": "Maria Muster",
"geburtsdatum": "1980-01-01",
}
},
)
assert run.status_code == 200, run.text
assert run.json()["status"] == "completed"
result = run.json()["output"]["result"]
assert "65000" in result
assert "Maria Muster" in result

View file

@ -38,16 +38,6 @@ def _doc(*fields, node_type="CustomInputForm", display="CustomInput Form"):
return {"data": {"nodes": [node], "edges": []}} return {"data": {"nodes": [node], "edges": []}}
def _dropdown(name, options, **kw):
return _field(name, "DropdownInput", options=options, **kw)
def _branch_schema(doc):
schema = parser.extract_form_schema(doc)["json_schema"]
assert "allOf" in schema
return schema
def test_type_mapping_table(): def test_type_mapping_table():
doc = _doc( doc = _doc(
_field("a", "StrInput", value="x"), _field("a", "StrInput", value="x"),
@ -202,209 +192,3 @@ async def test_input_schema_endpoint(client, lfx_server, login):
assert body["fallback"] is None assert body["fallback"] is None
assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"} assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"}
assert body["ui_schema"]["password"] == {"ui:widget": "password"} assert body["ui_schema"]["password"] == {"ui:widget": "password"}
def test_free_rename_keys_kept_verbatim():
doc = _doc(
_field("name", "StrInput", display_name="Name", required=True),
_field("favorite room", "StrInput"),
)
schema = parser.extract_form_schema(doc)
assert set(schema["json_schema"]["properties"]) == {"name", "favorite room"}
assert schema["json_schema"]["required"] == ["name"]
def test_empty_name_skipped_and_collision_suffixed():
a = _field("a", "StrInput")
clash = _field("other_key", "StrInput")
clash["name"] = "a" # Langflow rename collision: second field gets a suffix
empty = _field("zzz", "StrInput")
empty["name"] = " "
node = {
"data": {
"type": "CustomInputForm",
"node": {
"display_name": "CustomInput Form",
"template": {"a": a, "other_key": clash, "zzz": empty},
},
}
}
doc = {"data": {"nodes": [node], "edges": []}}
props = parser.extract_form_schema(doc)["json_schema"]["properties"]
assert set(props) == {"a", "a_2"}
def test_visible_if_single_branch():
doc = _doc(
_dropdown("gebaeude", ["Kirche", "Halle"], required=True),
_field(
"bestandteil",
"MultiselectInput",
options=["Glocke", "Orgel"],
info="Bitte wählen [visible_if gebaeude=Kirche]",
required=True,
),
)
out = parser.extract_form_schema(doc)
schema = out["json_schema"]
# Conditional field lives only in its branch, not at root.
assert set(schema["properties"]) == {"gebaeude"}
assert schema["required"] == ["gebaeude"]
assert len(schema["allOf"]) == 1
rule = schema["allOf"][0]
assert rule["if"] == {
"properties": {"gebaeude": {"const": "Kirche"}},
"required": ["gebaeude"],
}
assert rule["then"]["properties"]["bestandteil"]["type"] == "array"
assert rule["then"]["required"] == ["bestandteil"]
# Marker stripped from the shown description.
assert schema["properties"]["gebaeude"]["title"] == "Gebaeude"
assert out["ui_schema"]["ui:order"] == ["gebaeude", "bestandteil", "*"]
def test_visible_if_nested_three_levels():
doc = _doc(
_dropdown("a", ["x", "y"]),
_dropdown("b", ["p", "q"], info="[visible_if a=x]"),
_field("c", "IntInput", info="[visible_if b=p]"),
_field("d", "StrInput", info="[visible_if c=3]"),
)
schema = _branch_schema(doc)
assert set(schema["properties"]) == {"a"}
assert len(schema["allOf"]) == 3
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]}
assert by_field["b"]["if"] == {
"properties": {"a": {"const": "x"}},
"required": ["a"],
}
assert by_field["c"]["if"]["properties"] == {"b": {"const": "p"}}
assert by_field["d"]["if"]["properties"] == {"c": {"const": 3}}
def test_visible_if_and_quoted_bool_multiselect():
doc = _doc(
_dropdown("a", ["x", "y"]),
_field("flag", "BoolInput"),
_field(
"m",
"MultiselectInput",
options=["Glocke", "Orgel"],
info='[visible_if a="x" & flag=true]',
),
)
schema = _branch_schema(doc)
rule = schema["allOf"][0]
assert rule["if"] == {
"properties": {"a": {"const": "x"}, "flag": {"const": True}},
"required": ["a", "flag"],
}
assert rule["then"]["properties"]["m"]["type"] == "array"
def test_visible_if_multiselect_trigger_uses_contains():
doc = _doc(
_field("tags", "MultiselectInput", options=["Glocke", "Orgel"]),
_field("detail", "StrInput", info="[visible_if tags=Glocke]"),
)
schema = _branch_schema(doc)
assert schema["allOf"][0]["if"]["properties"] == {"tags": {"contains": {"const": "Glocke"}}}
def test_visible_if_dangling_unsupported_cycle_fail_open():
doc = _doc(
_dropdown("a", ["x", "y"]),
_field("ok", "StrInput", info="[visible_if a=x]"),
_field("dangling", "StrInput", info="[visible_if nope=x]"),
_field("slider_dep", "StrInput", info="[visible_if slid=x]"),
_field(
"slid",
"SliderInput",
value=3,
range_spec={"min": 1, "max": 5, "step": 1},
),
_field("c1", "StrInput", info="[visible_if c2=x]"),
_field("c2", "StrInput", info="[visible_if c1=x]"),
_field("selfish", "StrInput", info="[visible_if selfish=x]"),
)
schema = parser.extract_form_schema(doc)["json_schema"]
# Only the valid marker produces a branch; the rest stay at root.
assert len(schema["allOf"]) == 1
assert set(schema["properties"]) >= {
"a",
"dangling",
"slider_dep",
"slid",
"c1",
"c2",
"selfish",
}
assert "ok" not in schema["properties"]
def test_no_markers_no_allof():
doc = _doc(_dropdown("a", ["x", "y"]))
schema = parser.extract_form_schema(doc)["json_schema"]
assert "allOf" not in schema
assert set(schema["properties"]) == {"a"}
def test_custom_component_form_display_match():
"""User-built * Form components parse without a kirche base class."""
doc = _doc(
_field("mitarbeiter_name", "StrInput", info="Vor- und Nachname"),
_dropdown("rolle", ["Assistenz", "Pfarrer:in"]),
node_type="CustomComponent",
display="Mitarbeiter Einstellen Form",
)
schema = parser.extract_form_schema(doc)
assert "fallback" not in schema
props = schema["json_schema"]["properties"]
assert set(props) == {"mitarbeiter_name", "rolle"}
assert props["rolle"]["enum"] == ["Assistenz", "Pfarrer:in"]
assert props["mitarbeiter_name"]["description"] == "Vor- und Nachname"
def test_custom_component_non_form_falls_back():
doc = _doc(
_field("payload", "StrInput"),
node_type="CustomComponent",
display="Rule Check",
)
assert parser.extract_form_schema(doc)["fallback"] == "textarea"
def test_real_mitarbeiter_file_parses():
path = Path(__file__).parent.parent / "flows" / "mitarbeiter_einstellen.json"
doc = json.loads(path.read_text(encoding="utf-8"))
schema = parser.extract_form_schema(doc)
assert "fallback" not in schema
props = schema["json_schema"]["properties"]
assert set(props) == {"rolle", "mitarbeiter_name", "geburtsdatum"}
assert schema["json_schema"]["required"] == ["rolle", "mitarbeiter_name"]
# Einstufung is branched (visible only for Pfarrer:in).
assert len(schema["json_schema"]["allOf"]) == 1
rule = schema["json_schema"]["allOf"][0]
assert rule["if"]["properties"] == {"rolle": {"const": "Pfarrer:in"}}
assert set(rule["then"]["properties"]) == {"einstufung"}
def test_schema_field_names_covers_branches():
doc = _doc(
_dropdown("a", ["x", "y"]),
_dropdown("b", ["p", "q"], info="[visible_if a=x]"),
_field("c", "IntInput", info="[visible_if b=p]"),
)
cached = parser.extract_form_schema(doc)
assert parser.schema_field_names(cached) == ["a", "b", "c"]
plain = parser.extract_form_schema(
{
"data": {
"nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}],
"edges": [],
}
}
)
assert parser.schema_field_names(plain) == []
assert parser.schema_field_names({}) == []
assert parser.schema_field_names({"json_schema": None}) == []

View file

@ -9,13 +9,11 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from app.flows import export
FLOW_DIR = Path(__file__).resolve().parent.parent / "flows" FLOW_DIR = Path(__file__).resolve().parent.parent / "flows"
def test_canonical_flows_pass_lfx_validate(): def test_canonical_flows_pass_lfx_validate():
files = export.iter_flow_files(FLOW_DIR) files = sorted(FLOW_DIR.glob("*.json"))
assert files, "no canonical flows found" assert files, "no canonical flows found"
process = subprocess.run( process = subprocess.run(
[ [

View file

@ -44,14 +44,6 @@ async def test_sync_adds_new_flow(session, lfx_server, tmp_path):
assert versions[0].deployed_at is not None assert versions[0].deployed_at is not None
async def test_sync_skips_ignored_files(session, lfx_server, tmp_path):
_seed_dir(tmp_path)
(tmp_path / ".flowsignore").write_text("simple_rule\n", encoding="utf-8")
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
assert report.added == [] and report.errors == {}
assert store.get_definition_by_source_path(session, "simple_rule.json") is None
async def test_sync_skips_unchanged(session, lfx_server, tmp_path): async def test_sync_skips_unchanged(session, lfx_server, tmp_path):
_seed_dir(tmp_path) _seed_dir(tmp_path)
first = await sync.sync_from_directory(session, _lfx(), tmp_path) first = await sync.sync_from_directory(session, _lfx(), tmp_path)

View file

@ -1,101 +0,0 @@
# New workflow module: form flow end to end
How to add a form-based workflow (web form → run → result), learned from
building intake, Bauvorhaben, and Mitarbeiter-Einstellen. Three layers:
component (Langflow palette), flow (canvas), backend parsing (automatic).
## 1. Form component (`backend/components/kirche/<name>_form.py`)
Copy `mitarbeiter_form.py` (small) or `bauvorhaben_form.py` (branching).
Rename the class with a **`CustomInput*` prefix** (e.g.
`CustomInputMitarbeiter`) — the backend parser matches that prefix; a
plain `CustomComponent` name additionally needs a display name ending
in `"Form"`.
Rules, all load-bearing:
- **Self-contained**: stdlib + `lfx` only. NEVER import from sibling
form modules — LFX executes the embedded module source without our
package, and Langflow round-trips the code. Duplicated helpers are
structural, not laziness.
- **Inputs**: one `lfx.io` entry per question (`name` = stable JSON key,
`display_name` = label, `info` = help, `options`/`value`/`required`
as needed) plus the hidden `form_values` plumbing input (reserved
name — never use it for a field).
- **Branching**: `[visible_if feld=Wert]` markers in `info`
(`&` for AND, quotes for spaces). Any depth; trigger types:
Dropdown/Text `const`, Bool, Multiselect `contains`, Int/Float exact.
Stale markers, cycles, and unknown triggers fail open (always visible).
- **Required sentinels**: required fields with empty values carry
`" "` (`[" "]` for Multiselect — a plain string crashes LFX at build,
`[]` fails validation). The parser treats them as "no default".
- **Outputs**: exactly `Output(name="form_data", method="collect")`
returning `Data`. Do not touch `collect()` — it is fully dynamic
(asked + visible defaults, submitted values win).
- Export in `backend/components/kirche/__init__.py`.
Then: `docker compose restart langflow` (components load at startup),
check the palette via `GET /api/v1/all` (or sidebar search, hard-refresh
the browser tab if the entry is missing — the API is source of truth).
## 2. Flow JSON (`backend/flows/<name>.json`)
Never hand-edit: add `build_<name>()` in
`backend/scripts/make_flow_fixtures.py` and run it (from `backend/`).
- Form node via `kirche_node(node_id, component_cls=..., code_override=...)`.
- Logic nodes via `custom_node(CODE, ...)` (keep return annotations like
`-> Message`: edge handles derive output types from them).
- Always wire `ChatInput → form_values` (run envelope), then
`form_data → … → ChatOutput`.
- Reorder template keys to question order (lfx emits alphabetical; RJSF
renders insertion order, and `ui:order` follows it).
- The script writes fixtures + canonical files; `git checkout` the churn
on unrelated flows, keep canonical files CRLF like the repo.
- Custom logic code (e.g. salary math) lives as a `*_CODE` constant in
the script — one owner for template and code.
## 3. Backend parsing (automatic, know the contract)
`app/flows/input_schema.py` turns the node template into
`{json_schema, ui_schema}` per version: `name` → key (verbatim; empty
skipped, collisions suffixed), `display_name`/`info`/`value`/`options`
→ title/description/default/enum, `show: false` skipped, unknown types
degrade to text. Markers become `allOf`/`if`/`then` branches;
branch-`required` applies only while visible.
Runs carry an envelope (`payload`, `fields` = asked names from the
cached schema, run context). `collect()` emits defaults only for asked
AND visible fields; submitted values always win; hidden values are kept
but never inventoried. Raw dicts and the Langflow playground use all
visible declared defaults.
## 4. Langflow round-trip (where flows go to die, if careless)
- Node **type names must be distinct per component** — Langflow
normalizes same-type nodes to the installed class on import and
silently discards variant templates.
- Upload via `POST /api/v1/flows/upload/`, then fetch the flow back and
assert node type, template fields, and code are intact.
- Restart re-seeds `backend/flows/` into Langflow; the sync button pulls
Langflow → files **matched by flow name, overwriting silently**.
Commit before syncing, review the `pulled` report, never rename or
repurpose flows casually.
- Experiments that must stay out: list their stem in
`backend/flows/.flowsignore` (skipped by sync, deploy, validation,
frontend, and future pulls).
## 5. Verify before reporting done
- `uv run pytest` (full suite incl. real-LFX deploy+run e2e) green.
- `uv run ruff check .` clean, `uv run ruff format --check .` clean.
- `uv run lfx validate --level 4 tests/fixtures/flows/` all pass.
- Live: `GET /api/flows/{id}/input-schema` shows `fallback: null`
with expected properties (cached schemas refresh only on new
versions — unchanged files keep their cached schema).
- Browser click-through in Workflows, including a branched path and an
empty submit (branch-required errors).
New behavior ships with tests; schema changes ship as Alembic revisions
(never edit applied migrations); commit on `feat/<short>`, never
directly on `main`.

View file

@ -202,10 +202,10 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({ name, email, reason }), body: JSON.stringify({ name, email, reason }),
}), }),
listAccessRequests: (status?: string) => { listAccessRequests: (status?: string) =>
const qs = new URLSearchParams(status ? { status } : {}).toString(); req<AccessRequest[]>(
return req<AccessRequest[]>(`/api/access/requests${qs ? `?${qs}` : ""}`); `/api/access/requests${status ? `?status=${status}` : ""}`,
}, ),
decideAccess: (id: string, approve: boolean) => decideAccess: (id: string, approve: boolean) =>
req<{ request: AccessRequest; email_sent: boolean }>( req<{ request: AccessRequest; email_sent: boolean }>(
`/api/access/requests/${id}/${approve ? "approve" : "reject"}`, `/api/access/requests/${id}/${approve ? "approve" : "reject"}`,

View file

@ -179,67 +179,40 @@ export default function AdminTab() {
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{flows.map((f) => { {flows.map((f) =>
const list = versions[f.id] ?? []; (versions[f.id] ?? []).map((v, i) => (
const latest = list[list.length - 1]; <Table.Tr key={v.id}>
if (!latest) {
return (
<Table.Tr key={f.id}>
<Table.Td> <Table.Td>
{i === 0 && (
<>
<strong>{f.name}</strong>{" "} <strong>{f.name}</strong>{" "}
<Text size="xs" c="dimmed" component="span"> <Text size="xs" c="dimmed" component="span">
{f.source_path ? `(${f.source_path})` : "(API)"} {f.source_path ? `(${f.source_path})` : "(API)"}
</Text> </Text>
</Table.Td> </>
<Table.Td>–</Table.Td>
<Table.Td>
<StatusBadge status="pending" />
</Table.Td>
<Table.Td>–</Table.Td>
<Table.Td />
</Table.Tr>
);
}
const live = [...list]
.reverse()
.find((v) => v.deployed_at && v.id !== latest.id);
return (
<Table.Tr key={latest.id}>
<Table.Td>
<strong>{f.name}</strong>{" "}
<Text size="xs" c="dimmed" component="span">
{f.source_path ? `(${f.source_path})` : "(API)"}
</Text>
</Table.Td>
<Table.Td>
v{latest.version_no}
{live && (
<Text size="xs" c="dimmed" component="span">
{" "}
(live: v{live.version_no})
</Text>
)} )}
</Table.Td> </Table.Td>
<Table.Td>v{v.version_no}</Table.Td>
<Table.Td> <Table.Td>
{latest.deployed_at ? ( {v.deployed_at ? (
<StatusBadge status="deployed" /> <StatusBadge status="deployed" />
) : ( ) : (
<StatusBadge status="pending" /> <StatusBadge status="pending" />
)} )}
</Table.Td> </Table.Td>
<Table.Td>{latest.author || "–"}</Table.Td> <Table.Td>{v.author || "–"}</Table.Td>
<Table.Td> <Table.Td>
<Button <Button
size="compact-sm" size="compact-sm"
variant="light" variant="light"
onClick={() => void onDeploy(f.id, latest.version_no)} onClick={() => void onDeploy(f.id, v.version_no)}
> >
Deployen Deployen
</Button> </Button>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
); )),
})} )}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
)} )}

View file

@ -1,11 +0,0 @@
import { Alert } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
export function ErrorAlert({ message }: { message: string | null }) {
if (!message) return null;
return (
<Alert color="red" icon={<IconAlertCircle size={18} />}>
{message}
</Alert>
);
}

View file

@ -1,30 +0,0 @@
import { Code, Table } from "@mantine/core";
import StatusBadge from "./StatusBadge";
import type { Run } from "../api";
export function RunsTable({ runs }: { runs: Run[] }) {
return (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Th>Ergebnis</Table.Th>
<Table.Th>Erstellt</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{runs.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<StatusBadge status={r.status} />
</Table.Td>
<Table.Td>
<Code>{r.output?.result ?? r.output?.error ?? "–"}</Code>
</Table.Td>
<Table.Td>{new Date(r.created_at).toLocaleString("de-DE")}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}

View file

@ -3,19 +3,19 @@ import Form from "@rjsf/mantine";
import validator from "@rjsf/validator-ajv8"; import validator from "@rjsf/validator-ajv8";
import type { RJSFSchema, UiSchema } from "@rjsf/utils"; import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import { import {
Alert,
Button, Button,
Card, Card,
Code, Code,
Select, Select,
Table,
Text, Text,
Textarea, Textarea,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { IconPlayerPlay } from "@tabler/icons-react"; import { IconAlertCircle, IconPlayerPlay } from "@tabler/icons-react";
import { ApiError, api, type FlowDefinition, type InputSchema, type Run } from "../api"; import { ApiError, api, type FlowDefinition, type InputSchema, type Run } from "../api";
import { RunsTable } from "../components/RunsTable"; import StatusBadge from "../components/StatusBadge";
import { ErrorAlert } from "../components/ErrorAlert";
import { formatApiError } from "../utils";
export default function WorkflowsPage() { export default function WorkflowsPage() {
const [flows, setFlows] = useState<FlowDefinition[]>([]); const [flows, setFlows] = useState<FlowDefinition[]>([]);
@ -29,7 +29,7 @@ export default function WorkflowsPage() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const fail = useCallback((e: unknown) => { const fail = useCallback((e: unknown) => {
setError(formatApiError(e)); setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -85,9 +85,9 @@ export default function WorkflowsPage() {
Workflows Workflows
</Title> </Title>
{error && ( {error && (
<div style={{ marginBottom: 12 }}> <Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
<ErrorAlert message={error} /> {error}
</div> </Alert>
)} )}
<Select <Select
label="Flow" label="Flow"
@ -154,7 +154,28 @@ export default function WorkflowsPage() {
{runs.length === 0 ? ( {runs.length === 0 ? (
<Text c="dimmed">Noch keine Läufe.</Text> <Text c="dimmed">Noch keine Läufe.</Text>
) : ( ) : (
<RunsTable runs={runs} /> <Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Th>Mandant</Table.Th>
<Table.Th>Ergebnis</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{runs.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<StatusBadge status={r.status} />
</Table.Td>
<Table.Td>{r.tenant_id}</Table.Td>
<Table.Td>
<Code>{r.output?.result ?? r.output?.error ?? "–"}</Code>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)} )}
</div> </div>
); );

View file

@ -1,9 +0,0 @@
/* Shared frontend helpers: error formatting, config (was scattered). */
import { ApiError } from "./api";
export function formatApiError(e: unknown): string {
if (e instanceof ApiError) return `${e.status}: ${e.message}`;
return String(e);
}
export const LANGFLOW_URL_FALLBACK = "http://localhost:7860";