refactor: tighten backend, flows, frontend, tests; push flows to Langflow
This commit is contained in:
parent
6688fb08c6
commit
06ab25fb47
31 changed files with 783 additions and 358 deletions
|
|
@ -12,30 +12,24 @@ from __future__ import annotations
|
|||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user, require_admin
|
||||
from app.db import get_engine, get_session
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
from app.settings import settings
|
||||
|
||||
from . import input_schema as form_schema
|
||||
from . import runner, store
|
||||
from .deploy import import_from_langflow, pull_all_from_langflow
|
||||
from .langflow_client import (
|
||||
LangflowClient,
|
||||
LangflowError,
|
||||
LangflowNotFound,
|
||||
LangflowUnavailable,
|
||||
)
|
||||
from .deploy import import_from_langflow
|
||||
from .langflow_client import LangflowClient, LangflowError, LangflowNotFound, LangflowUnavailable
|
||||
from .lfx_client import (
|
||||
LfxClient,
|
||||
LfxConflict,
|
||||
|
|
@ -60,7 +54,7 @@ from .schemas import (
|
|||
RunRequest,
|
||||
SyncReport,
|
||||
)
|
||||
from .sync import lfx_id_for, sync_from_directory
|
||||
from .sync import lfx_id_for
|
||||
|
||||
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
|
||||
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
|
||||
|
|
@ -68,10 +62,6 @@ approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_db() -> Iterator[Session]:
|
||||
yield from get_session()
|
||||
|
||||
|
||||
def get_lfx() -> LfxClient:
|
||||
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
||||
|
||||
|
|
@ -81,60 +71,40 @@ def get_langflow() -> LangflowClient:
|
|||
|
||||
|
||||
def _read_definition(d) -> FlowDefinitionRead:
|
||||
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,
|
||||
)
|
||||
return FlowDefinitionRead.model_validate(d, from_attributes=True)
|
||||
|
||||
|
||||
def _read_version(v) -> FlowVersionRead:
|
||||
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,
|
||||
)
|
||||
return FlowVersionRead.model_validate(v, from_attributes=True)
|
||||
|
||||
|
||||
def _read_run(r) -> RunRead:
|
||||
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,
|
||||
)
|
||||
return RunRead.model_validate(r, from_attributes=True)
|
||||
|
||||
|
||||
def _read_task(t) -> ApprovalRead:
|
||||
return ApprovalRead(
|
||||
id=t.id,
|
||||
run_id=t.run_id,
|
||||
version_id=t.version_id,
|
||||
tenant_id=t.tenant_id,
|
||||
title=t.title,
|
||||
payload=t.payload,
|
||||
continuation_flow_id=t.continuation_flow_id,
|
||||
approver=t.approver,
|
||||
status=t.status,
|
||||
decision_note=t.decision_note,
|
||||
created_at=t.created_at,
|
||||
decided_at=t.decided_at,
|
||||
)
|
||||
return ApprovalRead.model_validate(t, from_attributes=True)
|
||||
|
||||
|
||||
def _lfx_http(exc: LfxError) -> HTTPException:
|
||||
"""Map LFX domain errors to HTTP (single place, was 10x duplicated)."""
|
||||
if isinstance(exc, LfxInvalidFlow):
|
||||
return HTTPException(status_code=422, detail=str(exc))
|
||||
if isinstance(exc, LfxConflict):
|
||||
return HTTPException(status_code=409, detail=str(exc))
|
||||
if isinstance(exc, LfxUnavailable):
|
||||
logger.warning("lfx unavailable: %s", exc)
|
||||
return HTTPException(status_code=503, detail="flow runtime unavailable")
|
||||
logger.warning("lfx error: %s", exc)
|
||||
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)
|
||||
|
|
@ -164,21 +134,14 @@ async def sync_flows(
|
|||
_admin: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Manual full pass: Langflow -> backend/flows/*.json -> DB + LFX."""
|
||||
from .sync import sync_full_pass
|
||||
|
||||
try:
|
||||
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
|
||||
return await sync_full_pass(session, lfx, langflow, Path(settings.flows_dir))
|
||||
except LangflowError as 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))
|
||||
raise _langflow_http(exc) from exc
|
||||
except FileNotFoundError as 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)
|
||||
|
|
@ -236,16 +199,8 @@ async def deploy_version(
|
|||
flow_id=lfx_id_for(flow_id, version_no),
|
||||
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:
|
||||
logger.warning("lfx error: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="flow runtime error") from exc
|
||||
raise _lfx_http(exc) from exc
|
||||
return _read_version(store.mark_deployed(session, version, uploaded.id))
|
||||
|
||||
|
||||
|
|
@ -290,69 +245,35 @@ async def run_flow(
|
|||
)
|
||||
except LfxNotFound as 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:
|
||||
logger.warning("lfx error: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="flow runtime error") from exc
|
||||
raise _lfx_http(exc) from exc
|
||||
return _read_run(run)
|
||||
|
||||
|
||||
async def _run_stream(
|
||||
session: Session, lfx: LfxClient, version, data: RunRequest, user: User, definition
|
||||
):
|
||||
if not version.lfx_flow_id:
|
||||
raise HTTPException(status_code=422, detail=f"version {version.id} is not deployed to LFX")
|
||||
run = store.create_run(
|
||||
session,
|
||||
version,
|
||||
input=data.input,
|
||||
tenant_id=user.tenant_id,
|
||||
session_id=data.session_id,
|
||||
)
|
||||
envelope = runner.build_envelope(
|
||||
payload=data.input,
|
||||
run_id=run.id,
|
||||
callback_base_url=settings.callback_base_url,
|
||||
continuation_flow_id=definition.continuation_flow_id,
|
||||
callback_token=settings.callback_token,
|
||||
fields=form_schema.schema_field_names(
|
||||
version.input_schema or form_schema.extract_form_schema(version.langflow_json)
|
||||
),
|
||||
)
|
||||
try:
|
||||
run, envelope, lfx_flow_id, sid = await runner.stream_run(
|
||||
session,
|
||||
lfx,
|
||||
version,
|
||||
payload=data.input,
|
||||
tenant_id=user.tenant_id,
|
||||
session_id=data.session_id,
|
||||
callback_base_url=settings.callback_base_url,
|
||||
continuation_flow_id=definition.continuation_flow_id,
|
||||
callback_token=settings.callback_token,
|
||||
)
|
||||
except store.InvalidError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
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]:
|
||||
yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n"
|
||||
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
|
||||
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"
|
||||
async for chunk in runner.stream_lfx_chunks(lfx, lfx_flow_id, envelope, sid):
|
||||
yield chunk
|
||||
status = runner.finalize_streamed_run(run_id)
|
||||
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")
|
||||
|
|
@ -379,12 +300,8 @@ async def list_langflow_flows(
|
|||
"""Browse flows on the Langflow authoring server (admin import source)."""
|
||||
try:
|
||||
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:
|
||||
logger.warning("langflow error: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="authoring server error") from exc
|
||||
raise _langflow_http(exc) from exc
|
||||
|
||||
|
||||
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
|
||||
|
|
@ -408,26 +325,14 @@ async def import_flow(
|
|||
)
|
||||
except LangflowNotFound as 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:
|
||||
logger.warning("langflow error: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="authoring server error") from exc
|
||||
raise _langflow_http(exc) from exc
|
||||
except store.ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except store.InvalidError as 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:
|
||||
logger.warning("lfx error: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="flow runtime error") from exc
|
||||
raise _lfx_http(exc) from exc
|
||||
return FlowImportRead(definition=_read_definition(definition), version=_read_version(version))
|
||||
|
||||
|
||||
|
|
@ -564,10 +469,8 @@ async def decide_approval(
|
|||
)
|
||||
except LfxNotFound as 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:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
raise _lfx_http(exc) from exc
|
||||
return DecisionRead(
|
||||
task=_read_task(task),
|
||||
continuation_run=_read_run(continuation_run) if continuation_run else None,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue