foxygit / RPA-Remisser Log in
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: