commit 021482898e275b8f6727e1b2e2d56256b6bd1ba0
Author: Jens Kristoffersson <jenkr@vgregion.se>
AuthorDate: Tue Sep 8 13:47:19 2026 +0200
Commit: Jens Kristoffersson <jenkr@vgregion.se>
CommitDate: Tue Sep 8 13:47:19 2026 +0200
Akta grafmotor (fas 2): MCP, export och AI-assistent till grafform
- rpa/mcp_server.py: save_flow/validate_flow tar nodes+edges+start (gamla
steps accepteras och migreras). get_flow/run_flow ger/kor grafen.
Instruktionstexten beskriver nod/kant-modellen.
- rpa/export.py: det exporterade scriptet baddar in NODES/EDGES/START och
kor grafmotorn. --set/--var oforandrade.
- rpa/assist.py: systemprompten beskriver grafen, emit_flow emitterar
{nodes, edges, start}, validering via validate_graph. Reparationsrundan kvar.
- webui/app.py: Flow/BlockBody/AssistRequest tar nodes/edges/start
(steps kvar tills frontend ar over). assist-endpointen migrerar indata.
- tests/test_assist.py omskriven for grafutdata. Hela sviten gron (85).
---
rpa/assist.py | 37 ++++++++++++++++++++++++------------
rpa/export.py | 15 ++++++++++++---
rpa/mcp_server.py | 53 +++++++++++++++++++++++++++++++++++++++-------------
tests/test_assist.py | 17 ++++++++++-------
webui/app.py | 31 ++++++++++++++++++++++++------
5 files changed, 112 insertions(+), 41 deletions(-)
diff --git a/rpa/assist.py b/rpa/assist.py
index d126401..70528f9 100644
--- a/rpa/assist.py
+++ b/rpa/assist.py
@@ -1,5 +1,5 @@
"""NL → flöde: tar en instruktion på naturligt språk (svenska) och ett ev.
-befintligt flöde och ber en Claude-modell generera/ändra flödes-JSON.
+befintligt flöde och ber en Claude-modell generera/ändra flödes-JSON (grafform).
Samma stegschema (rpa/schema.py) som webui:t och MCP-servern använder ligger
till grund för systemprompten och för valideringen av svaret.
@@ -58,11 +58,15 @@ def _has_cli_profile() -> bool:
_SYSTEM = """Du är en assistent som bygger automationsflöden för ett Playwright-baserat RPA-verktyg.
Du får en instruktion på svenska och (ibland) ett befintligt flöde. Svara genom att
-anropa verktyget `emit_flow` med det kompletta, uppdaterade flödet.
+anropa verktyget `emit_flow` med den kompletta, uppdaterade grafen ({nodes, edges}).
Regler:
-- Ändrar användaren ett befintligt flöde: behåll de steg som fortfarande gäller, lägg till/ta bort/justera resten.
-- Använd bara stegtyper och fält som finns i referensen nedan. Hitta inte på fält.
+- Ett flöde är en graf: `nodes` (noder med platta fält) + `edges` (kanter som binder ihop dem). Ingen nästling.
+- Varje kant: `{"source": "<nod-id>", "target": "<nod-id>", "sourceHandle": "out", "targetHandle": "in"}`.
+ Vanliga noder följs via `out`. `if`: en kant per gren från `case0`,`case1`,… + `else`. `loop`: `each` (kroppen) och `done` (efteråt); kroppens sista nod kopplar TILLBAKA till loop-nodens `in`.
+- Ge varje nod ett kort unikt `id`. Sätt `start` till första nodens id.
+- Ändrar användaren ett befintligt flöde: behåll de noder/kanter som fortfarande gäller, lägg till/ta bort/justera resten.
+- Använd bara nodtyper och fält som finns i referensen nedan. Hitta inte på fält.
- Selector-fält: föredra `by="text"` med synlig text, eller `by="role"`. `by="css"` bara när text/roll inte räcker.
- Ett typiskt inloggat flöde börjar med `goto` följt av `wait_for_login`.
- Loopar över en lista: `list` (CSS-selector) → `loop` (over = samma variabel). Inuti loopen kan inre steg sätta `scope="item"`.
@@ -77,18 +81,18 @@ def _tool_schema() -> dict:
fjs = schema.flow_json_schema()
return {
"name": _EMIT_TOOL,
- "description": "Leverera det kompletta flödet (steps + settings + explanation).",
+ "description": "Leverera hela grafen (nodes + edges + start + settings + explanation).",
"input_schema": {
"type": "object",
"properties": fjs["properties"],
- "required": ["steps"],
+ "required": ["nodes", "edges"],
"$defs": fjs["$defs"],
},
}
def generate_flow(instruction: str, current_flow: dict | None = None, *, model: str | None = None) -> dict:
- """Returnerar {"steps": [...], "settings": {...}, "explanation": "..."}.
+ """Returnerar {"nodes": [...], "edges": [...], "start": ..., "settings": {...}, "explanation": "..."}.
Kastar AssistUnavailable om ingen nyckel finns, AssistError om modellen inte
lyckas producera ett giltigt flöde (även efter en reparationsrunda).
@@ -98,14 +102,19 @@ def generate_flow(instruction: str, current_flow: dict | None = None, *, model:
client = _client()
model = model or os.environ.get("RPA_ASSIST_MODEL") or DEFAULT_MODEL
- system = _SYSTEM.format(reference=schema.step_reference_markdown())
+ system = _SYSTEM.replace("{reference}", schema.step_reference_markdown())
user_parts = [f"Instruktion:\n{instruction.strip()}"]
- if current_flow and current_flow.get("steps"):
+ if current_flow and current_flow.get("nodes"):
user_parts.append(
"Befintligt flöde att utgå från:\n"
+ json.dumps(
- {"steps": current_flow.get("steps", []), "settings": current_flow.get("settings", {})},
+ {
+ "nodes": current_flow.get("nodes", []),
+ "edges": current_flow.get("edges", []),
+ "start": current_flow.get("start"),
+ "settings": current_flow.get("settings", {}),
+ },
ensure_ascii=False,
indent=2,
)
@@ -129,10 +138,14 @@ def generate_flow(instruction: str, current_flow: dict | None = None, *, model:
raise AssistError("Modellen anropade inte verktyget emit_flow.")
normalized = {
- "steps": flow.get("steps", []),
+ "format": "graph",
+ "nodes": flow.get("nodes", []),
+ "edges": flow.get("edges", []),
"settings": flow.get("settings", {}),
}
- errors = schema.validate_flow(normalized)
+ if flow.get("start"):
+ normalized["start"] = flow["start"]
+ errors = schema.validate_graph(normalized, strict=True)
if not errors:
normalized["explanation"] = str(flow.get("explanation", "")).strip()
return normalized
diff --git a/rpa/export.py b/rpa/export.py
index 4fe9ea0..236dfd4 100644
--- a/rpa/export.py
+++ b/rpa/export.py
@@ -7,6 +7,8 @@ beteendet blir identiskt med att köra flödet via "Kör flöde".
from pprint import pformat
+from .migrate import flow_to_graph
+
SCRIPT_TEMPLATE = '''#!/usr/bin/env python
"""Fristående script, exporterat från flödet "{name}" (RPA-verktyg).
@@ -27,7 +29,11 @@ import argparse
from rpa import config
from rpa.flow_engine import run_flow
-STEPS = {steps_json}
+NODES = {nodes_json}
+
+EDGES = {edges_json}
+
+START = {start_json}
SETTINGS = {settings_json}
@@ -73,7 +79,7 @@ def main() -> None:
settings["headless"] = True
run_flow(
- STEPS,
+ {{"format": "graph", "nodes": NODES, "edges": EDGES, "start": START}},
log=print,
download_dir=config.DOWNLOAD_DIR,
close_mode=args.close_mode,
@@ -93,10 +99,13 @@ def generate_standalone_script(name: str, flow: dict) -> str:
def src(value):
return pformat(value, indent=2, width=100, sort_dicts=False)
+ g = flow_to_graph(flow)
return SCRIPT_TEMPLATE.format(
name=name,
filename=name,
- steps_json=src(flow.get("steps", [])),
+ nodes_json=src(g.get("nodes", [])),
+ edges_json=src(g.get("edges", [])),
+ start_json=src(g.get("start")),
settings_json=src(flow.get("settings", {})),
inputs_json=src(flow.get("inputs", [])),
input_sets_json=src(flow.get("input_sets", {})),
diff --git a/rpa/mcp_server.py b/rpa/mcp_server.py
index bfe8b4b..fd65c34 100644
--- a/rpa/mcp_server.py
+++ b/rpa/mcp_server.py
@@ -25,6 +25,7 @@ from mcp.server import MCPServer
from . import blocks, flows, runs, schema
from .flows import FlowNotFound, InvalidFlow, InvalidFlowName
+from .migrate import flow_to_graph
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
log = logging.getLogger("rpa.mcp")
@@ -33,8 +34,9 @@ mcp = MCPServer(
"rpa-remisser",
instructions=(
"Bygg och kör webbautomationsflöden. Anropa describe_steps() först för att "
- "se alla stegtyper och fält. Flöden sparas som JSON och körs i en synlig "
- "webbläsare mot en beständig inloggningssession."
+ "se alla nodtyper, fält och handtagsmodellen. Ett flöde är en graf: "
+ "nodes (platta noder) + edges (kanter). Flöden sparas som JSON och körs i "
+ "en synlig webbläsare mot en beständig inloggningssession."
),
)
@@ -84,7 +86,7 @@ def list_flows() -> list[str]:
@mcp.tool()
def get_flow(name: str) -> dict:
- """Hämtar ett sparat flöde ({steps, settings})."""
+ """Hämtar ett sparat flöde i grafform ({nodes, edges, start, settings})."""
try:
return flows.load_flow(name)
except FlowNotFound:
@@ -93,20 +95,38 @@ def get_flow(name: str) -> dict:
return {"error": str(exc)}
+def _as_graph(nodes, edges, start, settings, steps) -> dict:
+ """Bygg ett flödesdict från antingen nodes/edges eller det gamla steps-formatet."""
+ if nodes is not None:
+ return {"format": "graph", "nodes": nodes, "edges": edges or [],
+ "start": start, "settings": settings or {}}
+ return flow_to_graph({"steps": steps or [], "settings": settings or {}})
+
+
@mcp.tool()
-def save_flow(name: str, steps: list, settings: dict | None = None) -> dict:
- """Skapar eller skriver över ett flöde. 'steps' är listan av steg (se
- describe_steps). Returnerar valideringsfel utan att spara om flödet är ogiltigt."""
- errors = schema.validate_flow({"steps": steps, "settings": settings or {}})
+def save_flow(
+ name: str,
+ nodes: list | None = None,
+ edges: list | None = None,
+ start: str | None = None,
+ settings: dict | None = None,
+ steps: list | None = None,
+) -> dict:
+ """Skapar eller skriver över ett flöde. Ange grafen som 'nodes' + 'edges'
+ (se describe_steps för nodtyper och handtag). Det gamla 'steps'-formatet
+ accepteras också och migreras. Returnerar valideringsfel utan att spara om
+ flödet är ogiltigt."""
+ g = _as_graph(nodes, edges, start, settings, steps)
+ errors = schema.validate_graph(g, strict=True)
if errors:
return {"ok": False, "errors": errors}
try:
- flows.save_flow(name, {"steps": steps, "settings": settings or {}})
+ flows.save_flow(name, g, strict=True)
except InvalidFlowName as exc:
return {"ok": False, "errors": [str(exc)]}
except InvalidFlow as exc:
return {"ok": False, "errors": exc.errors}
- return {"ok": True, "name": name, "step_count": len(steps)}
+ return {"ok": True, "name": name, "node_count": len(g["nodes"])}
@mcp.tool()
@@ -120,9 +140,16 @@ def delete_flow(name: str) -> dict:
@mcp.tool()
-def validate_flow(steps: list, settings: dict | None = None) -> dict:
- """Validerar ett flöde utan att spara det."""
- errors = schema.validate_flow({"steps": steps, "settings": settings or {}})
+def validate_flow(
+ nodes: list | None = None,
+ edges: list | None = None,
+ start: str | None = None,
+ settings: dict | None = None,
+ steps: list | None = None,
+) -> dict:
+ """Validerar ett flöde (grafform) utan att spara det."""
+ g = _as_graph(nodes, edges, start, settings, steps)
+ errors = schema.validate_graph(g, strict=True)
return {"valid": not errors, "errors": errors}
@@ -183,7 +210,7 @@ def run_flow(
resolved.update({str(k): str(v) for k, v in initial_vars.items()})
run_id = runs.start_run(
- flow.get("steps", []),
+ flow,
close_mode=close_mode,
settings=flow.get("settings", {}),
initial_vars=resolved,
diff --git a/tests/test_assist.py b/tests/test_assist.py
index 24e435f..a454071 100644
--- a/tests/test_assist.py
+++ b/tests/test_assist.py
@@ -38,29 +38,32 @@ def _patch(monkeypatch, replies):
def test_generate_flow_happy_path(monkeypatch):
_patch(monkeypatch, [{
- "steps": [{"type": "goto", "url": "https://example.com/"}],
+ "nodes": [{"id": "n1", "type": "goto", "url": "https://example.com/"}],
+ "edges": [],
+ "start": "n1",
"settings": {},
"explanation": "Går till example.com.",
}])
result = assist.generate_flow("gå till example.com")
- assert result["steps"][0]["url"] == "https://example.com/"
+ assert result["nodes"][0]["url"] == "https://example.com/"
+ assert result["start"] == "n1"
assert result["explanation"] == "Går till example.com."
def test_generate_flow_repairs_once(monkeypatch):
client = _patch(monkeypatch, [
- {"steps": [{"type": "goto"}], "settings": {}}, # url saknas -> ogiltigt
- {"steps": [{"type": "goto", "url": "https://ok.se/"}], "settings": {}}, # reparerat
+ {"nodes": [{"id": "n1", "type": "goto"}], "edges": [], "settings": {}}, # url saknas -> ogiltigt
+ {"nodes": [{"id": "n1", "type": "goto", "url": "https://ok.se/"}], "edges": [], "settings": {}},
])
result = assist.generate_flow("gå till ok.se")
- assert result["steps"][0]["url"] == "https://ok.se/"
+ assert result["nodes"][0]["url"] == "https://ok.se/"
assert len(client.messages.calls) == 2
def test_generate_flow_gives_up_after_repair(monkeypatch):
_patch(monkeypatch, [
- {"steps": [{"type": "goto"}], "settings": {}},
- {"steps": [{"type": "goto"}], "settings": {}},
+ {"nodes": [{"id": "n1", "type": "goto"}], "edges": [], "settings": {}},
+ {"nodes": [{"id": "n1", "type": "goto"}], "edges": [], "settings": {}},
])
with pytest.raises(assist.AssistError):
assist.generate_flow("trasigt")
diff --git a/webui/app.py b/webui/app.py
index f456c60..381c2e5 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -23,7 +23,12 @@ CLOSE_MODES = set(schema.CLOSE_MODES)
class Flow(BaseModel):
- steps: list = []
+ # Grafform. `steps` accepteras fortfarande (migreras vid inläsning) tills
+ # frontend är helt övergången.
+ nodes: list | None = None
+ edges: list | None = None
+ start: str | None = None
+ steps: list | None = None
settings: dict = {}
layout: dict = {}
inputs: list = []
@@ -37,6 +42,9 @@ class RunOptions(BaseModel):
class AssistRequest(BaseModel):
instruction: str
+ nodes: list = []
+ edges: list = []
+ start: str | None = None
steps: list = []
settings: dict = {}
@@ -160,7 +168,10 @@ def capture_desktop_image(req: CaptureRequest) -> dict:
class BlockBody(BaseModel):
name: str = ""
description: str = ""
- steps: list = []
+ nodes: list | None = None
+ edges: list | None = None
+ start: str | None = None
+ steps: list | None = None
settings: dict = {}
@@ -210,7 +221,8 @@ def get_flow(name: str) -> dict:
try:
return flows.load_flow(name)
except FlowNotFound:
- return {"steps": [], "settings": {}, "layout": {}}
+ return {"format": "graph", "nodes": [], "edges": [], "start": None,
+ "settings": {}, "layout": {}, "inputs": [], "input_sets": {}}
@app.put("/api/flows/{name}")
@@ -235,10 +247,17 @@ def assist_flow(name: str, req: AssistRequest) -> dict:
"""NL → flöde. Genererar/ändrar ett flöde utifrån en instruktion men SPARAR
INTE - klienten visar förslaget och användaren trycker Spara/Kör själv."""
_require_name(name)
+ from rpa.migrate import flow_to_graph
+
+ current = flow_to_graph({
+ "nodes": req.nodes or None,
+ "edges": req.edges,
+ "start": req.start,
+ "steps": req.steps,
+ "settings": req.settings,
+ })
try:
- result = assist.generate_flow(
- req.instruction, current_flow={"steps": req.steps, "settings": req.settings}
- )
+ result = assist.generate_flow(req.instruction, current_flow=current)
except assist.AssistUnavailable as exc:
raise HTTPException(501, str(exc)) from exc
except assist.AssistError as exc: