foxygit / RPA-Remisser Log in
commits tags

/rpa/schema.py · 60.51 KB

raw
"""Auktoritativ, maskinläsbar beskrivning av alla stegtyper i ett flöde.

Detta är den gemensamma källan för:
  * webui:t (fältrendering - i dagsläget speglat i index.html:s FIELD_DEFS)
  * MCP-servern (rpa/mcp_server.py - verktyget describe_steps / resursen rpa://schema)
  * NL-till-flöde (rpa/assist.py - systemprompt + validering av AI:ns svar)

Innehållet är härlett ur rpa/flow_engine.py (_execute_step) och
webui/templates/index.html (FIELD_DEFS / I18N). Ändras en stegtyp i motorn ska
den speglas här - tests/test_schema.py har en drift-vakt som fångar glapp.

Fältens "kind":
  text    - fri text (kan innehålla {variabel} om interpolate=True)
  number  - heltal/decimal
  select  - ett av "options"
"""

from __future__ import annotations

import json
import re
from typing import Any

# --------------------------------------------------------------------------
# Byggstenar
# --------------------------------------------------------------------------

# Fälten som pekar ut ett element (samma som SELECTOR_FIELDS i index.html).
SELECTOR_FIELDS: list[dict] = [
    {
        "key": "by",
        "kind": "select",
        "options": ["text", "css", "role"],
        "default": "text",
        "label_sv": "Hitta via",
        "label_en": "Find by",
        "help": "text = synlig text (regex, skiftlägesokänsligt), css = CSS-selector, role = ARIA-roll",
    },
    {
        "key": "value",
        "kind": "text",
        "interpolate": True,
        "label_sv": "Text / CSS-selector / rollnamn",
        "label_en": "Text / CSS selector / role name",
        "help": "Tolkas enligt 'by'. {variabel} fungerar för by=text och by=role, inte för by=css.",
    },
    {
        "key": "role",
        "kind": "text",
        "label_sv": "Roll (om by=role, ex. link, button)",
        "label_en": "Role (if by=role, e.g. link, button)",
    },
    {
        "key": "scope",
        "kind": "select",
        "options": ["page", "item"],
        "default": "page",
        "label_sv": "Sökområde",
        "label_en": "Search scope",
        "help": "item begränsar sökningen till aktuell loop-post (bara inuti en loop med DOM-poster).",
    },
    {
        "key": "nth",
        "kind": "number",
        "default": 0,
        "label_sv": "Index (0 = första matchning)",
        "label_en": "Index (0 = first match)",
    },
]

_TIMEOUT_FIELD = {
    "key": "timeout",
    "kind": "number",
    "default": 15,
    "label_sv": "Timeout (sekunder)",
    "label_en": "Timeout (seconds)",
}

# Villkorstyper för "if"-grenar (CONDITION_KINDS / CONDITION_FIELD_DEFS i index.html).
_VAR_TEXT_FIELDS = [
    {"key": "var", "kind": "text", "required": True, "label_sv": "Variabelnamn", "label_en": "Variable name"},
    # inte required: att jämföra mot tomt ("är variabeln tom?") är ett giltigt villkor
    {"key": "text", "kind": "text", "label_sv": "Jämför med (tomt = jämför mot tom sträng)",
     "label_en": "Compare with (empty = compare to empty string)"},
]

CONDITION_KINDS: list[dict] = [
    {
        "kind": "element_exists",
        "label_sv": "Element finns",
        "label_en": "Element exists",
        "fields": SELECTOR_FIELDS,
    },
    {
        "kind": "element_not_exists",
        "label_sv": "Element finns inte",
        "label_en": "Element does not exist",
        "fields": SELECTOR_FIELDS,
    },
    {
        "kind": "var_equals",
        "label_sv": "Variabel är lika med",
        "label_en": "Variable equals",
        "fields": _VAR_TEXT_FIELDS,
    },
    {
        "kind": "var_not_equals",
        "label_sv": "Variabel är INTE lika med",
        "label_en": "Variable does NOT equal",
        "fields": _VAR_TEXT_FIELDS,
    },
    {
        "kind": "var_contains",
        "label_sv": "Variabel innehåller",
        "label_en": "Variable contains",
        "fields": _VAR_TEXT_FIELDS,
    },
]

CONDITION_KIND_NAMES = [c["kind"] for c in CONDITION_KINDS]

# Ett flöde kan deklarera indata-variabler (flow["inputs"]) som matas in vid
# körning. Detta beskriver ett fälts form för editorn i webui:t.
INPUT_FIELDS: list[dict] = [
    {"key": "name", "kind": "text", "required": True,
     "label_sv": "Variabelnamn", "label_en": "Variable name",
     "help": "Används som {name} i stegen."},
    {"key": "label", "kind": "text",
     "label_sv": "Etikett (visas i kördialogen)", "label_en": "Label (shown in the run dialog)"},
    {"key": "default", "kind": "text",
     "label_sv": "Standardvärde", "label_en": "Default value"},
    {"key": "options", "kind": "text",
     "label_sv": "Alternativ (komma-separerade → rullista)", "label_en": "Options (comma-separated → dropdown)"},
    {"key": "description", "kind": "text",
     "label_sv": "Beskrivning", "label_en": "Description"},
]

# Körningsinställningar (spegel av DEFAULT_SETTINGS i rpa/browser.py + notify).
SETTINGS_SCHEMA: list[dict] = [
    {"key": "headless", "kind": "bool", "default": False,
     "label_sv": "Headless (inget synligt fönster)", "label_en": "Headless (no visible window)",
     "help": "Kräver en redan giltig inloggningssession - BankID/2FA går inte att godkänna headless."},
    {"key": "skip_browser", "kind": "bool", "default": False,
     "label_sv": "Starta ingen webbläsare (rena desktop-flöden)",
     "label_en": "Don't start a browser (desktop-only flows)",
     "help": "Tvingar bort webbläsaren. Upptäcks annars automatiskt när flödet saknar webbläsarsteg. Ett webbläsarsteg med detta på ger fel."},
    {"key": "viewport_width", "kind": "number", "default": 1280,
     "label_sv": "Fönsterbredd", "label_en": "Viewport width"},
    {"key": "viewport_height", "kind": "number", "default": 900,
     "label_sv": "Fönsterhöjd", "label_en": "Viewport height"},
    {"key": "locale", "kind": "text", "default": "sv-SE", "label_sv": "Locale", "label_en": "Locale"},
    {"key": "slow_mo", "kind": "number", "default": 0,
     "label_sv": "Slow motion (ms mellan åtgärder)", "label_en": "Slow motion (ms between actions)"},
    {"key": "notify_on_complete", "kind": "bool", "default": False,
     "label_sv": "Skrivbordsnotis när körningen är klar", "label_en": "Desktop notification when the run finishes"},
]

CLOSE_MODES = ["always", "on_success", "never"]

# Nycklar som får finnas på vilket steg som helst (flow_engine._run_steps).
COMMON_STEP_FIELDS: list[dict] = [
    {"key": "label", "kind": "text", "label_sv": "Etikett", "label_en": "Label",
     "help": "Namnger steget som mål för ett 'jump_to_label'-steg."},
    {"key": "enabled", "kind": "bool", "default": True,
     "label_sv": "Aktiverat", "label_en": "Enabled",
     "help": "false = steget (och ev. inre steg) hoppas över vid körning."},
    {"key": "on_error", "kind": "select", "options": ["stop", "continue"], "default": "stop",
     "label_sv": "Vid fel", "label_en": "On error",
     "help": "continue loggar felet och kör vidare. download/save_attachment/capture_pdf fortsätter alltid."},
]


def _sel(*extra: dict) -> list[dict]:
    return [*SELECTOR_FIELDS, *extra]


# Selector-fält för Windows UI Automation (pywinauto) - annan modell än webben.
DESKTOP_SELECTOR_FIELDS: list[dict] = [
    {
        "key": "by",
        "kind": "select",
        "options": ["name", "auto_id", "control_type", "class_name"],
        "default": "name",
        "label_sv": "Hitta via",
        "label_en": "Find by",
        "help": "name = kontrollens synliga text (regex), auto_id = AutomationId, control_type = t.ex. Button/Edit, class_name = fönsterklass",
    },
    {"key": "value", "kind": "text", "interpolate": True,
     "label_sv": "Söksträng", "label_en": "Search string"},
    {"key": "control_type", "kind": "text",
     "label_sv": "Kontrolltyp (valfri extra filtrering, t.ex. Button)",
     "label_en": "Control type (optional extra filter, e.g. Button)"},
    {"key": "window", "kind": "text",
     "label_sv": "Fönstertitel (regex, tomt = aktuellt fönster)",
     "label_en": "Window title (regex, blank = current window)"},
    {"key": "nth", "kind": "number", "default": 0,
     "label_sv": "Index (0 = första)", "label_en": "Index (0 = first)"},
]


def _dsel(*extra: dict) -> list[dict]:
    return [*DESKTOP_SELECTOR_FIELDS, *extra]


# --------------------------------------------------------------------------
# Stegtyperna
# --------------------------------------------------------------------------

STEP_TYPES: list[dict] = [
    # ---- Navigering ----
    {
        "type": "goto", "category": "navigation",
        "label_sv": "Gå till URL", "label_en": "Go to URL",
        "help": "Navigerar webbläsaren till en URL (wait_until=domcontentloaded).",
        "fields": [{"key": "url", "kind": "text", "required": True, "label_sv": "URL", "label_en": "URL"}],
    },
    {
        "type": "wait", "category": "navigation",
        "label_sv": "Vänta (sekunder)", "label_en": "Wait (seconds)",
        "help": "Fast paus.",
        "fields": [{"key": "seconds", "kind": "number", "default": 1,
                    "label_sv": "Sekunder", "label_en": "Seconds"}],
    },
    {
        "type": "wait_for_load", "category": "navigation",
        "label_sv": "Vänta på att sidan laddas", "label_en": "Wait for page to load",
        "help": "Väntar på domcontentloaded.", "fields": [],
    },
    {
        "type": "go_back", "category": "navigation",
        "label_sv": "Gå tillbaka (webbläsarhistorik)", "label_en": "Go back (browser history)",
        "help": "Webbläsarens bakåtknapp - t.ex. för att fortsätta en loop efter att ha klickat in på en post.",
        "fields": [],
    },
    {
        "type": "wait_for_login", "category": "navigation",
        "label_sv": "Vänta på inloggning", "label_en": "Wait for login",
        "help": "Pausar tills sidan visar inloggad status (för manuell BankID/2FA).",
        "fields": [
            {"key": "timeout", "kind": "number", "default": 180,
             "label_sv": "Timeout (sekunder)", "label_en": "Timeout (seconds)"},
            {"key": "indicators", "kind": "text",
             "label_sv": "Text som visar inloggad (komma-separerad, tomt = standard)",
             "label_en": "Logged-in text (comma-separated, empty = default)"},
        ],
    },
    {
        "type": "wait_for_selector", "category": "navigation",
        "label_sv": "Vänta på element", "label_en": "Wait for element",
        "help": "Pausar tills ett element via text/CSS/roll finns.",
        "selector": True, "fields": _sel(dict(_TIMEOUT_FIELD)),
    },
    {
        "type": "read_url", "category": "navigation",
        "label_sv": "Läs aktuell URL", "label_en": "Read current URL",
        "help": "Sparar den aktuella sidans URL i en variabel. Med 'pattern' plockas bara en regex-träff ut (namngiven grupp (?P<value>…) om den finns, annars hela träffen) – t.ex. ett ärende-id ur adressen.",
        "fields": [
            {"key": "var", "kind": "text", "required": True,
             "label_sv": "Spara som variabel", "label_en": "Save as variable"},
            {"key": "pattern", "kind": "text",
             "label_sv": "Regex-utplock (valfritt)", "label_en": "Regex extraction (optional)"},
        ],
        "produces_vars": ["var"],
    },
    # ---- Interaktion ----
    {
        "type": "click", "category": "interaction",
        "label_sv": "Klicka", "label_en": "Click",
        "help": "Hittar ett element och klickar, väntar sedan på domcontentloaded.",
        "selector": True, "fields": _sel(),
    },
    {
        "type": "type", "category": "interaction",
        "label_sv": "Skriv i textfält", "label_en": "Type in field",
        "help": "Fyller i ett fält (fill). 'text' stödjer {variabel}.",
        "selector": True,
        "fields": _sel({"key": "text", "kind": "text", "interpolate": True, "required": True,
                        "label_sv": "Text att skriva", "label_en": "Text to type"}),
    },
    {
        "type": "press_key", "category": "interaction",
        "label_sv": "Tryck tangent", "label_en": "Press key",
        "help": "Trycker en tangent. Utan value/by: mot hela sidan.",
        "fields": [
            {"key": "key", "kind": "text", "default": "Enter", "required": True,
             "label_sv": "Tangent (ex. Enter, Tab, Escape)", "label_en": "Key (e.g. Enter, Tab, Escape)"},
            {"key": "value", "kind": "text", "interpolate": True,
             "label_sv": "Text / CSS-selector / rollnamn (tomt = hela sidan)",
             "label_en": "Text / CSS selector / role name (empty = whole page)"},
            {"key": "by", "kind": "select", "options": ["text", "css", "role"], "default": "text",
             "label_sv": "Hitta via (om value ifylld)", "label_en": "Find by (if value is set)"},
            {"key": "role", "kind": "text", "label_sv": "Roll (om by=role)", "label_en": "Role (if by=role)"},
            {"key": "scope", "kind": "select", "options": ["page", "item"], "default": "page",
             "label_sv": "Sökområde", "label_en": "Search scope"},
            {"key": "nth", "kind": "number", "default": 0,
             "label_sv": "Index (0 = första)", "label_en": "Index (0 = first)"},
        ],
    },
    {
        "type": "select_option", "category": "interaction",
        "label_sv": "Välj i dropdown", "label_en": "Select in dropdown",
        "help": "Väljer ett alternativ (synlig text) i en <select>.",
        "selector": True,
        "fields": _sel({"key": "option", "kind": "text", "required": True,
                        "label_sv": "Alternativ (synlig text)", "label_en": "Option (visible text)"}),
    },
    {
        "type": "check", "category": "interaction",
        "label_sv": "Kryssa checkbox/radio", "label_en": "Check checkbox/radio",
        "help": "Kryssar i eller ur en checkbox/radioknapp.",
        "selector": True,
        "fields": _sel({"key": "checked", "kind": "select", "options": ["true", "false"], "default": "true",
                        "label_sv": "Kryssa i (false = kryssa ur)", "label_en": "Check (false = uncheck)"}),
    },
    # ---- Data / variabler ----
    {
        "type": "extract_text", "category": "data",
        "label_sv": "Läs text till variabel", "label_en": "Read text into variable",
        "help": "Läser ett elements textinnehåll och sparar det i en variabel.",
        "selector": True,
        "fields": _sel({"key": "var", "kind": "text", "required": True,
                        "label_sv": "Spara som variabel", "label_en": "Save as variable"}),
        "produces_vars": ["var"],
    },
    {
        "type": "extract_row", "category": "data",
        "label_sv": "Extrahera rad (flera kolumner)", "label_en": "Extract row (multiple columns)",
        "help": "Läser flera namngivna fält (celler) från aktuell post till en rad-dict och lägger den i listvariabeln 'var'. Ger riktiga CSV-kolumner.",
        "fields": [
            {"key": "var", "kind": "text", "required": True,
             "label_sv": "Spara som variabel (lista)", "label_en": "Save as variable (list)"},
            {"key": "scope", "kind": "select", "options": ["page", "item"], "default": "item",
             "label_sv": "Sökområde", "label_en": "Search scope"},
            {"key": "fields", "kind": "field_list", "required": True,
             "label_sv": "Kolumner", "label_en": "Columns",
             "help": "Lista av {name, by, value, role?} - name blir kolumnrubrik."},
        ],
        "produces_vars": ["var"],
    },
    {
        "type": "set_var", "category": "data",
        "label_sv": "Sätt variabel", "label_en": "Set variable",
        "help": "Sätter en variabel till ett literalt värde ('text' stödjer {variabel}).",
        "fields": [
            {"key": "var", "kind": "text", "required": True, "label_sv": "Variabelnamn", "label_en": "Variable name"},
            {"key": "text", "kind": "text", "interpolate": True, "label_sv": "Värde", "label_en": "Value"},
        ],
        "produces_vars": ["var"],
    },
    {
        "type": "increment", "category": "data",
        "label_sv": "Öka variabel", "label_en": "Increment variable",
        "help": "Ökar en heltalsvariabel med 'by' (negativt = minska). Saknad/icke-numerisk = 0.",
        "fields": [
            {"key": "var", "kind": "text", "required": True, "label_sv": "Variabelnamn", "label_en": "Variable name"},
            {"key": "by", "kind": "number", "default": 1,
             "label_sv": "Öka med (negativt = minska)", "label_en": "Increase by (negative = decrease)"},
        ],
        "produces_vars": ["var"],
    },
    {
        "type": "clear_var", "category": "data",
        "label_sv": "Nollställ variabel", "label_en": "Clear variable",
        "help": "Tar bort en variabel. Lägg först i en loop för att nollställa en samlar-lista per varv.",
        "fields": [{"key": "var", "kind": "text", "required": True,
                    "label_sv": "Variabelnamn", "label_en": "Variable name"}],
    },
    {
        "type": "list_append", "category": "data",
        "label_sv": "Lägg till i lista", "label_en": "Append to list",
        "help": "Lägger till i en listvariabel. Läser från ett element om selector satt, annars literalt 'text'.",
        "fields": [
            {"key": "var", "kind": "text", "required": True, "label_sv": "Listvariabel", "label_en": "List variable"},
            {"key": "text", "kind": "text", "interpolate": True,
             "label_sv": "Literalt värde (om selector nedan tom)", "label_en": "Literal value (if selector below is empty)"},
            {"key": "value", "kind": "text", "interpolate": True,
             "label_sv": "Eller: Text / CSS-selector / rollnamn", "label_en": "Or: Text / CSS selector / role name"},
            {"key": "by", "kind": "select", "options": ["text", "css", "role"], "default": "text",
             "label_sv": "Hitta via (om value ifylld)", "label_en": "Find by (if value is set)"},
            {"key": "role", "kind": "text", "label_sv": "Roll (om by=role)", "label_en": "Role (if by=role)"},
            {"key": "scope", "kind": "select", "options": ["page", "item"], "default": "page",
             "label_sv": "Sökområde", "label_en": "Search scope"},
            {"key": "nth", "kind": "number", "default": 0,
             "label_sv": "Index (0 = första)", "label_en": "Index (0 = first)"},
        ],
        "produces_vars": ["var"],
    },
    {
        "type": "save_var", "category": "data",
        "label_sv": "Spara variabel (JSON/CSV)", "label_en": "Save variable (JSON/CSV)",
        "help": "Sparar en variabel till data/exports/. Format gissas från filändelsen.",
        "fields": [
            {"key": "var", "kind": "text", "required": True,
             "label_sv": "Variabel att spara", "label_en": "Variable to save"},
            {"key": "filename", "kind": "text", "required": True,
             "label_sv": "Filnamn i data/exports/ (.json eller .csv)",
             "label_en": "Filename in data/exports/ (.json or .csv)"},
            {"key": "format", "kind": "select", "options": ["", "json", "csv"], "default": "",
             "label_sv": "Format (tomt = gissa från filnamn)", "label_en": "Format (empty = guess from filename)"},
            {"key": "mode", "kind": "select", "options": ["overwrite", "append"], "default": "overwrite",
             "label_sv": "Läge", "label_en": "Mode"},
        ],
        "consumes_vars": ["var"],
    },
    {
        "type": "load_var", "category": "data",
        "label_sv": "Läs in variabel (JSON/CSV)", "label_en": "Load variable (JSON/CSV)",
        "help": "Läser tillbaka en tidigare sparad fil från data/exports/ till en variabel.",
        "fields": [
            {"key": "var", "kind": "text", "required": True,
             "label_sv": "Spara som variabel", "label_en": "Save as variable"},
            {"key": "filename", "kind": "text", "required": True,
             "label_sv": "Filnamn i data/exports/", "label_en": "Filename in data/exports/"},
        ],
        "produces_vars": ["var"],
    },
    {
        "type": "run_script", "category": "data",
        "label_sv": "Kör Python-script", "label_en": "Run Python script",
        "help": "Kör ett separat Python-script (sökväg relativt projektroten eller absolut). Loggar stdout/stderr, kan spara stdout som variabel (JSON om möjligt). Flödet stoppas vid felkod.",
        "fields": [
            {"key": "path", "kind": "text", "required": True,
             "label_sv": "Sökväg till Python-script", "label_en": "Path to Python script"},
            {"key": "args", "kind": "text", "label_sv": "Argument (mellanslagsseparerade)",
             "label_en": "Arguments (space-separated)"},
            {"key": "timeout", "kind": "number", "default": 60,
             "label_sv": "Timeout (sekunder)", "label_en": "Timeout (seconds)"},
            {"key": "var", "kind": "text",
             "label_sv": "Spara stdout som variabel (valfritt)", "label_en": "Save stdout as variable (optional)"},
        ],
        "produces_vars": ["var"],
    },
    {
        "type": "screenshot", "category": "data",
        "label_sv": "Ta skärmdump", "label_en": "Take screenshot",
        "help": "Sparar en skärmdump i data/screenshots/.",
        "fields": [{"key": "name", "kind": "text", "default": "screenshot",
                    "label_sv": "Filnamn (utan .png)", "label_en": "Filename (without .png)"}],
    },
    {
        "type": "list", "category": "data",
        "label_sv": "Lista poster", "label_en": "List items",
        "help": "Hittar alla element som matchar en CSS-selector och sparar antal+selector i 'var' (för en efterföljande loop).",
        "fields": [
            {"key": "value", "kind": "text", "required": True,
             "label_sv": "CSS-selector", "label_en": "CSS selector"},
            {"key": "var", "kind": "text", "required": True,
             "label_sv": "Spara som variabel", "label_en": "Save as variable"},
        ],
        "produces_vars": ["var"],
    },
    {
        "type": "ocr_read", "category": "data",
        "label_sv": "OCR – läs text ur bild/PDF", "label_en": "OCR – read text from image/PDF",
        "help": "Optisk teckenläsning (Windows). Källa: en inskannad PDF (data/downloads/), ett fönster, ett skärmområde eller en bildfil. Med 'pattern' plockas bara en regex-träff ut (namngiven grupp (?P<value>…) om den finns, annars hela träffen). Med 'near' returneras värdet bredvid/under en etikett.",
        "fields": [
            {"key": "source", "kind": "select", "options": ["pdf", "window", "region", "image"], "default": "pdf",
             "label_sv": "Källa", "label_en": "Source"},
            {"key": "file", "kind": "text", "interpolate": True, "visible_when": {"source": "pdf"},
             "label_sv": "PDF-filnamn i data/downloads/ (stödjer {variabel})",
             "label_en": "PDF filename in data/downloads/ (supports {variable})"},
            {"key": "window", "kind": "text", "interpolate": True, "visible_when": {"source": "window"},
             "label_sv": "Fönstertitel (regex, tomt = aktuellt)", "label_en": "Window title (regex, empty = current)"},
            {"key": "x", "kind": "number", "default": 0, "visible_when": {"source": "region"},
             "label_sv": "X", "label_en": "X"},
            {"key": "y", "kind": "number", "default": 0, "visible_when": {"source": "region"},
             "label_sv": "Y", "label_en": "Y"},
            {"key": "w", "kind": "number", "default": 0, "visible_when": {"source": "region"},
             "label_sv": "Bredd", "label_en": "Width"},
            {"key": "h", "kind": "number", "default": 0, "visible_when": {"source": "region"},
             "label_sv": "Höjd", "label_en": "Height"},
            {"key": "image", "kind": "text", "interpolate": True, "visible_when": {"source": "image"},
             "label_sv": "Bildfil (data/desktop_images/ eller absolut sökväg)",
             "label_en": "Image file (data/desktop_images/ or absolute path)"},
            {"key": "lang", "kind": "text", "default": "sv-SE",
             "label_sv": "OCR-språk", "label_en": "OCR language"},
            {"key": "var", "kind": "text", "required": True,
             "label_sv": "Spara resultat i variabel", "label_en": "Save result to variable"},
            {"key": "pattern", "kind": "text",
             "label_sv": "Regex-utplock (valfritt)", "label_en": "Regex extraction (optional)"},
            {"key": "near", "kind": "text", "interpolate": True,
             "label_sv": "Etikett att läsa värdet bredvid (valfritt)",
             "label_en": "Label to read the value next to (optional)"},
            {"key": "near_mode", "kind": "select", "options": ["same_line", "below"], "default": "same_line",
             "label_sv": "Var värdet står", "label_en": "Where the value sits"},
        ],
        "produces_vars": ["var"],
    },
    {
        "type": "prompt", "category": "data",
        "label_sv": "Fråga användaren (paus)", "label_en": "Ask the user (pause)",
        "help": "Pausar körningen och visar en ruta på skärmen. confirm = OK/Avbryt (Avbryt stoppar flödet). input = ett textfält -> variabel. fields = visa de angivna variablerna redigerbara, så användaren kan rätta t.ex. OCR-fel innan de skrivs in.",
        "fields": [
            {"key": "mode", "kind": "select", "options": ["confirm", "input", "fields"], "default": "confirm",
             "label_sv": "Läge", "label_en": "Mode"},
            {"key": "message", "kind": "text", "interpolate": True, "required": True,
             "label_sv": "Meddelande (stödjer {variabel})", "label_en": "Message (supports {variable})"},
            {"key": "var", "kind": "text", "visible_when": {"mode": "input"},
             "label_sv": "Spara svaret i variabel", "label_en": "Save answer to variable"},
            {"key": "fields", "kind": "text", "visible_when": {"mode": "fields"},
             "label_sv": "Variabler att visa (komma-separerade)", "label_en": "Variables to show (comma-separated)"},
            {"key": "timeout", "kind": "number", "default": 300,
             "label_sv": "Timeout (sekunder)", "label_en": "Timeout (seconds)"},
        ],
        "produces_vars": ["var"],
    },
    {
        "type": "lookup", "category": "data",
        "label_sv": "Slå upp i tabell", "label_en": "Look up in table",
        "help": "Slår upp 'key' i en JSON-map. Kartan kommer antingen från en inläst variabel (from_var, via 'Läs in variabel') eller en JSON-fil i data/exports/. Resultatet (kan vara ett objekt – då fungerar {var.fält} i efterföljande steg) sparas i 'var'.",
        "fields": [
            {"key": "from_var", "kind": "text",
             "label_sv": "Karta från variabel", "label_en": "Map from variable"},
            {"key": "file", "kind": "text", "interpolate": True,
             "label_sv": "…eller JSON-fil i data/exports/", "label_en": "…or JSON file in data/exports/"},
            {"key": "key", "kind": "text", "interpolate": True, "required": True,
             "label_sv": "Nyckel (stödjer {variabel}, ex. {grupp}|{specialitet})",
             "label_en": "Key (supports {variable}, e.g. {grupp}|{specialitet})"},
            {"key": "var", "kind": "text", "required": True,
             "label_sv": "Spara resultat i variabel", "label_en": "Save result to variable"},
            {"key": "ci", "kind": "bool", "default": True,
             "label_sv": "Skiftlägesokänslig matchning", "label_en": "Case-insensitive match"},
            {"key": "on_missing", "kind": "text", "default": "error",
             "label_sv": "Om nyckeln saknas: 'error', 'empty' eller ett defaultvärde",
             "label_en": "If key missing: 'error', 'empty' or a default value"},
        ],
        "consumes_vars": ["from_var"],
        "produces_vars": ["var"],
    },
    # ---- Kontrollflöde ----
    {
        "type": "if", "category": "control",
        "label_sv": "Om / annars om / annars", "label_en": "If / else if / else",
        "help": "Utvärderar grenarnas villkor uppifrån. Första sanna grenens utgång följs (case0, case1, …); matchar ingen följs 'else'. Villkoren ligger i noden; grenarnas steg är noder du kopplar till respektive utgång.",
        "control": "if", "fields": [],
    },
    {
        "type": "loop", "category": "control",
        "label_sv": "Loop (för varje post)", "label_en": "Loop (for each item)",
        "help": "Itererar över ett 'list'-resultat (DOM-poster) ELLER en vanlig listvariabel. Utgången 'each' körs en gång per varv (item/index sätts); kroppens sista nod ska kopplas TILLBAKA till loopens 'in'. Efter sista varvet följs 'done'.",
        "control": "loop",
        "fields": [
            {"key": "over", "kind": "text", "required": True,
             "label_sv": "Loopa över variabel", "label_en": "Loop over variable"},
            {"key": "item_var", "kind": "text", "default": "item",
             "label_sv": "Variabel för aktuell post", "label_en": "Current item variable"},
            {"key": "index_var", "kind": "text", "default": "index",
             "label_sv": "Variabel för varvräknare (1, 2, 3 …)", "label_en": "Iteration counter variable (1, 2, 3 …)"},
        ],
        "consumes_vars": ["over"],
        "produces_vars": ["item_var", "index_var"],
    },
    {
        "type": "stop", "category": "control",
        "label_sv": "Stoppa flödet", "label_en": "Stop the flow",
        "help": "Avbryter körningen med ett felmeddelande (t.ex. när ett ärende inte kan hanteras mekaniskt). 'message' stödjer {variabel}.",
        "fields": [
            {"key": "message", "kind": "text", "interpolate": True, "default": "Flödet stoppades.",
             "label_sv": "Meddelande", "label_en": "Message"},
        ],
    },
    {
        "type": "call_flow", "category": "control",
        "label_sv": "Anropa subflöde", "label_en": "Call subflow",
        "help": "Kör ett annat flöde som ett steg. 'target' = ett externt sparat flödesnamn ELLER '#<subflow-id>' för en lokal subgraf i samma fil. 'in' mappar värden till subflödets inputs (stödjer {variabel}); 'out' kopierar subflödets slutvariabler tillbaka. Vanliga handtag in/out.",
        "fields": [
            {"key": "target", "kind": "text", "required": True,
             "label_sv": "Målflöde (namn eller #subflow-id)",
             "label_en": "Target flow (name or #subflow-id)"},
        ],
    },
    # ---- Filer ----
    {
        "type": "download", "category": "files",
        "label_sv": "Ladda ner", "label_en": "Download",
        "help": "Klickar ett element och sparar den nedladdade filen i data/downloads/. Fortsätter alltid vid fel.",
        "selector": True, "fields": _sel(),
    },
    {
        "type": "save_attachment", "category": "files",
        "label_sv": "Ladda ner bilaga", "label_en": "Download attachment",
        "help": "Hittar en <a> i/under elementet och sparar filen i data/downloads/ (via href-hämtning eller klick-fångst). Fortsätter alltid vid fel.",
        "selector": True,
        "fields": _sel(
            {"key": "filename", "kind": "text", "interpolate": True,
             "label_sv": "Filnamn i data/downloads/ (tomt = från URL; stödjer {variabel})",
             "label_en": "Filename in data/downloads/ (blank = from URL; supports {variable})"},
            {"key": "var", "kind": "text",
             "label_sv": "Lägg sparad sökväg i den här listvariabeln (valfritt)",
             "label_en": "Append saved path to this list variable (optional)"},
            dict(_TIMEOUT_FIELD),
        ),
        "produces_vars": ["var"],
    },
    {
        "type": "capture_pdf", "category": "files",
        "label_sv": "Fånga PDF (skriv ut-knapp)", "label_en": "Capture PDF (print button)",
        "help": "För 'Skriv ut'-knappar som öppnar skrivardialogen. Neutraliserar window.print, klickar ev. triggern (selector valfri) och fångar PDF:en. Sparar i data/downloads/. Fortsätter alltid vid fel.",
        "selector": True,
        "fields": _sel(
            {"key": "filename", "kind": "text", "interpolate": True, "default": "utskrift.pdf",
             "label_sv": "PDF-filnamn i data/downloads/ (stödjer {variabel})",
             "label_en": "PDF filename in data/downloads/ (supports {variable})"},
            {"key": "var", "kind": "text",
             "label_sv": "Spara sökväg som variabel", "label_en": "Save path as variable"},
            dict(_TIMEOUT_FIELD),
        ),
        "produces_vars": ["var"],
    },
    {
        "type": "merge_json", "category": "files",
        "label_sv": "Skriv nyckel till JSON-fil", "label_en": "Write key to JSON file",
        "help": "data/exports/<file>[key] = value (skapar filen om den saknas). För kom-ihåg-tabeller: fråga användaren om ett värde med prompt och spara det här, så finns det nästa körning (läs tillbaka med 'Läs in variabel' + lookup).",
        "fields": [
            {"key": "file", "kind": "text", "interpolate": True, "required": True,
             "label_sv": "JSON-filnamn i data/exports/", "label_en": "JSON filename in data/exports/"},
            {"key": "key", "kind": "text", "interpolate": True, "required": True,
             "label_sv": "Nyckel (stödjer {variabel})", "label_en": "Key (supports {variable})"},
            {"key": "value", "kind": "text", "interpolate": True,
             "label_sv": "Värde (stödjer {variabel})", "label_en": "Value (supports {variable})"},
        ],
    },
    # ---- Desktop (Windows) ----
    {
        "type": "desktop_launch", "category": "desktop",
        "label_sv": "Starta app", "label_en": "Launch app",
        "help": "Startar ett program och gör dess fönster till 'aktuellt fönster'. Windows-only.",
        "fields": [
            {"key": "path", "kind": "text", "required": True,
             "label_sv": "Sökväg / kommando (t.ex. notepad.exe)", "label_en": "Path / command (e.g. notepad.exe)"},
            {"key": "args", "kind": "text", "label_sv": "Argument", "label_en": "Arguments"},
            {"key": "cwd", "kind": "text", "label_sv": "Arbetskatalog (valfri)", "label_en": "Working directory (optional)"},
            {"key": "title", "kind": "text",
             "label_sv": "Fönstertitel att vänta på (regex; behövs för paketerade appar)",
             "label_en": "Window title to wait for (regex; needed for packaged apps)"},
            {"key": "timeout", "kind": "number", "default": 20,
             "label_sv": "Timeout (sekunder)", "label_en": "Timeout (seconds)"},
        ],
    },
    {
        "type": "desktop_connect", "category": "desktop",
        "label_sv": "Anslut till fönster", "label_en": "Connect to window",
        "help": "Ansluter till ett redan körande fönster (titel som regex) och gör det till aktuellt fönster.",
        "fields": [
            {"key": "title", "kind": "text", "required": True,
             "label_sv": "Fönstertitel (regex)", "label_en": "Window title (regex)"},
            {"key": "timeout", "kind": "number", "default": 20,
             "label_sv": "Timeout (sekunder)", "label_en": "Timeout (seconds)"},
        ],
    },
    {
        "type": "desktop_wait_for_window", "category": "desktop",
        "label_sv": "Vänta på fönster", "label_en": "Wait for window",
        "help": "Pausar tills ett fönster med matchande titel finns.",
        "fields": [
            {"key": "title", "kind": "text", "required": True,
             "label_sv": "Fönstertitel (regex)", "label_en": "Window title (regex)"},
            {"key": "timeout", "kind": "number", "default": 20,
             "label_sv": "Timeout (sekunder)", "label_en": "Timeout (seconds)"},
        ],
    },
    {
        "type": "desktop_focus_window", "category": "desktop",
        "label_sv": "Fokusera fönster", "label_en": "Focus window",
        "help": "Tar fram fönstret i förgrunden. Tom titel = aktuellt fönster.",
        "fields": [
            {"key": "title", "kind": "text",
             "label_sv": "Fönstertitel (regex, tomt = aktuellt)", "label_en": "Window title (regex, blank = current)"},
            {"key": "timeout", "kind": "number", "default": 10,
             "label_sv": "Timeout (sekunder)", "label_en": "Timeout (seconds)"},
        ],
    },
    {
        "type": "desktop_click", "category": "desktop",
        "label_sv": "Klicka (kontroll)", "label_en": "Click (control)",
        "help": "Hittar en UI-kontroll via Windows UI Automation och klickar (riktigt musklick).",
        "fields": _dsel({"key": "double", "kind": "select", "options": ["false", "true"], "default": "false",
                         "label_sv": "Dubbelklick", "label_en": "Double click"}),
    },
    {
        "type": "desktop_type", "category": "desktop",
        "label_sv": "Skriv i kontroll", "label_en": "Type in control",
        "help": "Skriver text i ett fält. 'text' stödjer {variabel}.",
        "fields": _dsel({"key": "text", "kind": "text", "interpolate": True, "required": True,
                         "label_sv": "Text att skriva", "label_en": "Text to type"}),
    },
    {
        "type": "desktop_press_key", "category": "desktop",
        "label_sv": "Tryck tangenter", "label_en": "Press keys",
        "help": "Skickar tangenttryck till fönstret (pywinauto-syntax: ^s = Ctrl+S, {ENTER}, %{F4} …). Tom titel = aktuellt fönster.",
        "fields": [
            {"key": "keys", "kind": "text", "required": True, "interpolate": True,
             "label_sv": "Tangenter (^s, {ENTER}, {TAB} …)", "label_en": "Keys (^s, {ENTER}, {TAB} …)"},
            {"key": "window", "kind": "text",
             "label_sv": "Fönstertitel (regex, tomt = aktuellt)", "label_en": "Window title (regex, blank = current)"},
        ],
    },
    {
        "type": "desktop_read_text", "category": "desktop",
        "label_sv": "Läs kontrolltext", "label_en": "Read control text",
        "help": "Läser en kontrolls text/värde till en variabel.",
        "fields": _dsel({"key": "var", "kind": "text", "required": True,
                         "label_sv": "Spara som variabel", "label_en": "Save as variable"}),
        "produces_vars": ["var"],
    },
    {
        "type": "desktop_screenshot", "category": "desktop",
        "label_sv": "Skärmdump (desktop)", "label_en": "Screenshot (desktop)",
        "help": "Sparar en skärmdump av aktuellt fönster (eller helskärm om inget fönster) i data/screenshots/.",
        "fields": [
            {"key": "name", "kind": "text", "default": "desktop",
             "label_sv": "Filnamn (utan .png)", "label_en": "Filename (without .png)"},
            {"key": "window", "kind": "text",
             "label_sv": "Fönstertitel (regex, tomt = aktuellt/helskärm)", "label_en": "Window title (regex, blank = current/full screen)"},
        ],
    },
    {
        "type": "desktop_click_image", "category": "desktop",
        "label_sv": "Klicka på bild", "label_en": "Click image",
        "help": "Letar upp en referensbild på skärmen (pyautogui) och klickar i dess mitt. För appar utan tillgänglighets-API.",
        "fields": [
            {"key": "image", "kind": "image", "required": True,
             "label_sv": "Referensbild (data/desktop_images/)",
             "label_en": "Reference image (data/desktop_images/)"},
            {"key": "confidence", "kind": "number", "default": 0.9,
             "label_sv": "Träffsäkerhet 0–1", "label_en": "Match confidence 0–1"},
            {"key": "timeout", "kind": "number", "default": 20,
             "label_sv": "Timeout (sekunder)", "label_en": "Timeout (seconds)"},
            {"key": "double", "kind": "select", "options": ["false", "true"], "default": "false",
             "label_sv": "Dubbelklick", "label_en": "Double click"},
            {"key": "button", "kind": "select", "options": ["left", "right", "middle"], "default": "left",
             "label_sv": "Musknapp", "label_en": "Mouse button"},
        ],
    },
    {
        "type": "desktop_wait_for_image", "category": "desktop",
        "label_sv": "Vänta på bild", "label_en": "Wait for image",
        "help": "Pausar tills en referensbild syns på skärmen.",
        "fields": [
            {"key": "image", "kind": "image", "required": True,
             "label_sv": "Referensbild (data/desktop_images/)", "label_en": "Reference image (data/desktop_images/)"},
            {"key": "confidence", "kind": "number", "default": 0.9,
             "label_sv": "Träffsäkerhet 0–1", "label_en": "Match confidence 0–1"},
            {"key": "timeout", "kind": "number", "default": 20,
             "label_sv": "Timeout (sekunder)", "label_en": "Timeout (seconds)"},
        ],
    },
    {
        "type": "desktop_click_xy", "category": "desktop",
        "label_sv": "Klicka på koordinat", "label_en": "Click coordinates",
        "help": "Klickar på absoluta skärmkoordinater (pyautogui).",
        "fields": [
            {"key": "x", "kind": "number", "required": True, "label_sv": "X", "label_en": "X"},
            {"key": "y", "kind": "number", "required": True, "label_sv": "Y", "label_en": "Y"},
            {"key": "double", "kind": "select", "options": ["false", "true"], "default": "false",
             "label_sv": "Dubbelklick", "label_en": "Double click"},
            {"key": "button", "kind": "select", "options": ["left", "right", "middle"], "default": "left",
             "label_sv": "Musknapp", "label_en": "Mouse button"},
        ],
    },
]

STEP_TYPE_NAMES = [s["type"] for s in STEP_TYPES]
STEP_BY_TYPE = {s["type"]: s for s in STEP_TYPES}


# --------------------------------------------------------------------------
# Handtag per nodtyp (för canvasen + kant-validering)
# --------------------------------------------------------------------------

def node_handles(node_or_type) -> dict:
    """{"inputs": ["in"], "outputs": [...]}. `if`-noder får ett utgångshandtag
    per gren (case0..caseN) + else; `loop` får each + done."""
    if isinstance(node_or_type, dict):
        t = node_or_type.get("type")
        n_branches = len(node_or_type.get("branches", []) or [1])
    else:
        t = node_or_type
        n_branches = 1
    spec = STEP_BY_TYPE.get(t, {})
    ctrl = spec.get("control")
    if ctrl == "if":
        return {"inputs": ["in"], "outputs": [f"case{i}" for i in range(n_branches)] + ["else"]}
    if ctrl == "loop":
        return {"inputs": ["in"], "outputs": ["each", "done"]}
    return {"inputs": ["in"], "outputs": ["out"]}


# --------------------------------------------------------------------------
# Validering
# --------------------------------------------------------------------------

def validate_graph(flow: Any, *, strict: bool = True) -> list[str]:
    """Strukturell validering av nod/kant-grafen. Tom lista = giltigt.

    strict=False hoppar över "obligatoriskt fält saknas" - webui:t autosparar
    halvfärdiga flöden. MCP och NL-till-flöde använder strict=True."""
    errors: list[str] = []
    if not isinstance(flow, dict):
        return ["Flödet måste vara ett objekt med 'nodes' och 'edges'."]

    nodes = flow.get("nodes", [])
    edges = flow.get("edges", [])
    if not isinstance(nodes, list):
        return ["'nodes' måste vara en lista."]
    if not isinstance(edges, list):
        errors.append("'edges' måste vara en lista.")
        edges = []

    ids: set[str] = set()
    handles_by_id: dict[str, dict] = {}
    for i, node in enumerate(nodes):
        p = f"nodes[{i}]"
        if not isinstance(node, dict):
            errors.append(f"{p}: noden måste vara ett objekt.")
            continue
        nid = node.get("id")
        if not nid or not isinstance(nid, str):
            errors.append(f"{p}.id: saknas.")
            continue
        if nid in ids:
            errors.append(f"{p}.id: dubblett {nid!r}.")
        ids.add(nid)
        handles_by_id[nid] = node_handles(node)
        _validate_node_fields(node, f"nod {nid!r}", errors, strict)

    for i, e in enumerate(edges):
        p = f"edges[{i}]"
        if not isinstance(e, dict):
            errors.append(f"{p}: kanten måste vara ett objekt.")
            continue
        s, t = e.get("source"), e.get("target")
        if s not in ids:
            errors.append(f"{p}: source pekar på okänd nod {s!r}.")
        if t not in ids:
            errors.append(f"{p}: target pekar på okänd nod {t!r}.")
        sh = e.get("sourceHandle") or "out"
        if s in handles_by_id and sh not in handles_by_id[s]["outputs"]:
            errors.append(f"{p}: nod {s!r} har inget utgångshandtag {sh!r}.")

    # exakt en start
    if nodes:
        targeted = {e.get("target") for e in edges if isinstance(e, dict)}
        starts = [n["id"] for n in nodes if isinstance(n, dict) and n.get("id") not in targeted]
        if flow.get("start") and flow["start"] not in ids:
            errors.append(f"start: okänd nod {flow['start']!r}.")
        if not flow.get("start") and len(starts) > 1:
            errors.append(f"Flera startnoder ({len(starts)}) - koppla ihop dem eller sätt 'start'.")
        if not starts and not flow.get("start"):
            errors.append("Ingen startnod (alla noder har inkommande kanter).")

    settings = flow.get("settings", {})
    if isinstance(settings, dict):
        allowed = {s["key"] for s in SETTINGS_SCHEMA}
        for key in settings:
            if key not in allowed:
                errors.append(f"settings.{key}: okänd inställning.")

    _validate_subflows(flow, errors, strict)
    return errors


def _iter_graphs(flow: dict):
    """(label, graph-dict) för huvudgrafen och varje subflow."""
    yield "", flow
    for sf in flow.get("subflows", []) or []:
        if isinstance(sf, dict):
            yield f"subflow {sf.get('name') or sf.get('id')!r}: ", sf


def _validate_subflows(flow: dict, errors: list[str], strict: bool) -> None:
    subflows = flow.get("subflows")
    if subflows in (None, []):
        return
    if not isinstance(subflows, list):
        errors.append("'subflows' måste vara en lista.")
        return

    local_ids: set[str] = set()
    for i, sf in enumerate(subflows):
        if not isinstance(sf, dict):
            errors.append(f"subflows[{i}]: måste vara ett objekt.")
            continue
        sid = sf.get("id")
        if not sid or not isinstance(sid, str):
            errors.append(f"subflows[{i}].id: saknas.")
        elif sid in local_ids:
            errors.append(f"subflows[{i}].id: dubblett {sid!r}.")
        else:
            local_ids.add(sid)
        # nod/kant-validering per subgraf (utan settings/subflows-rekursion)
        for err in validate_graph(
            {"nodes": sf.get("nodes", []), "edges": sf.get("edges", []), "start": sf.get("start")},
            strict=strict,
        ):
            errors.append(f"subflow {sf.get('name') or sid!r}: {err}")

    # call_flow-mål: #lokala id måste finnas; lokala anrop får inte bilda cykel
    calls: dict[str, set[str]] = {}
    for label, graph in _iter_graphs(flow):
        owner = graph.get("id", "#main")
        calls.setdefault(owner, set())
        for node in graph.get("nodes", []) or []:
            if not isinstance(node, dict) or node.get("type") != "call_flow":
                continue
            target = str(node.get("target", ""))
            if target.startswith("#"):
                tid = target[1:]
                if tid not in local_ids:
                    errors.append(f"{label}call_flow: okänd lokal subflow {target!r}.")
                else:
                    calls[owner].add(tid)

    # cykeldetektering bland lokala anrop
    WHITE, GREY, BLACK = 0, 1, 2
    color = {k: WHITE for k in calls}

    def visit(n: str) -> bool:
        color[n] = GREY
        for m in calls.get(n, ()):
            if color.get(m, WHITE) == GREY or (color.get(m, WHITE) == WHITE and visit(m)):
                return True
        color[n] = BLACK
        return False

    for n in list(calls):
        if color[n] == WHITE and visit(n):
            errors.append("subflows: cirkulärt call_flow-anrop mellan lokala subgrafer.")
            break


def _validate_node_fields(node: dict, label: str, errors: list[str], strict: bool) -> None:
    t = node.get("type")
    if t not in STEP_BY_TYPE:
        errors.append(f"{label}.type: okänd nodtyp {t!r} (tillåtna: {', '.join(STEP_TYPE_NAMES)}).")
        return
    spec = STEP_BY_TYPE[t]

    if "on_error" in node and node["on_error"] not in ("stop", "continue"):
        errors.append(f"{label}.on_error: måste vara 'stop' eller 'continue'.")
    if "enabled" in node and not isinstance(node["enabled"], bool):
        errors.append(f"{label}.enabled: måste vara true/false.")

    if strict:
        for f in spec["fields"]:
            if f.get("required") and _is_blank(node.get(f["key"])):
                errors.append(f"{label}.{f['key']}: obligatoriskt för '{t}'.")

    for f in spec["fields"]:
        if f.get("kind") != "select" or not f.get("options"):
            continue
        val = node.get(f["key"])
        if val not in (None, "") and val not in f["options"]:
            errors.append(f"{label}.{f['key']}: måste vara ett av {', '.join(o for o in f['options'] if o)}.")

    if t == "if":
        branches = node.get("branches")
        if not isinstance(branches, list) or not branches:
            errors.append(f"{label}.branches: 'if' kräver minst en gren.")
        else:
            for bi, br in enumerate(branches):
                if not isinstance(br, dict):
                    errors.append(f"{label}.branches[{bi}]: måste vara ett objekt.")
                    continue
                _validate_condition(br.get("condition", {}), f"{label}.branches[{bi}].condition", errors)
    elif t == "extract_row":
        fields = node.get("fields", [])
        if not isinstance(fields, list) or not fields:
            errors.append(f"{label}.fields: extract_row kräver minst en kolumn.")
        else:
            for fi, col in enumerate(fields):
                if not isinstance(col, dict) or _is_blank(col.get("name")):
                    errors.append(f"{label}.fields[{fi}].name: kolumnen måste ha ett namn.")
    elif t == "call_flow":
        if strict and _is_blank(node.get("target")):
            errors.append(f"{label}.target: obligatoriskt för 'call_flow'.")
        for key, req in (("in", ("name",)), ("out", ("from", "to"))):
            entries = node.get(key)
            if entries in (None, []):
                continue
            if not isinstance(entries, list):
                errors.append(f"{label}.{key}: måste vara en lista.")
                continue
            for ei, m in enumerate(entries):
                if not isinstance(m, dict) or any(_is_blank(m.get(k)) for k in req):
                    errors.append(f"{label}.{key}[{ei}]: kräver {', '.join(req)}.")
    elif t == "prompt":
        if strict and node.get("mode") == "input" and _is_blank(node.get("var")):
            errors.append(f"{label}.var: obligatoriskt för prompt med mode='input'.")
    elif t == "lookup":
        if strict and _is_blank(node.get("from_var")) and _is_blank(node.get("file")):
            errors.append(f"{label}: lookup kräver 'from_var' eller 'file'.")


def _validate_condition(cond: Any, path: str, errors: list[str]) -> None:
    if not isinstance(cond, dict):
        errors.append(f"{path}: villkoret måste vara ett objekt.")
        return
    kind = cond.get("kind")
    spec = next((c for c in CONDITION_KINDS if c["kind"] == kind), None)
    if spec is None:
        errors.append(f"{path}.kind: okänt villkor {kind!r} (tillåtna: {', '.join(CONDITION_KIND_NAMES)}).")
        return
    for f in spec["fields"]:
        if f.get("required") and _is_blank(cond.get(f["key"])):
            errors.append(f"{path}.{f['key']}: obligatoriskt för villkoret '{kind}'.")


def _is_blank(value: Any) -> bool:
    return value is None or (isinstance(value, str) and value.strip() == "")


# --------------------------------------------------------------------------
# JSON Schema + LLM-referens
# --------------------------------------------------------------------------

def flow_json_schema() -> dict:
    """JSON Schema för ett flöde i grafform ({nodes, edges}). Vägledning till
    LLM:en - den precisa kontrollen görs av validate_graph()."""
    all_field_keys: set[str] = set()
    for spec in STEP_TYPES:
        for f in spec["fields"]:
            all_field_keys.add(f["key"])
    for f in COMMON_STEP_FIELDS:
        all_field_keys.add(f["key"])

    node_props: dict[str, Any] = {
        "id": {"type": "string"},
        "type": {"type": "string", "enum": STEP_TYPE_NAMES},
        "label": {"type": "string"},
        "enabled": {"type": "boolean"},
        "on_error": {"type": "string", "enum": ["stop", "continue"]},
        "position": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}}},
        "branches": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {"condition": {"$ref": "#/$defs/condition"}},
                "required": ["condition"],
            },
        },
        "fields": {"type": "array", "items": {"type": "object"}},
        "in": {
            "type": "array",
            "description": "call_flow: värden till subflödets inputs.",
            "items": {"type": "object", "properties": {"name": {"type": "string"}, "value": {"type": "string"}},
                      "required": ["name"]},
        },
        "out": {
            "type": "array",
            "description": "call_flow: subflödets variabler -> anroparens variabler.",
            "items": {"type": "object", "properties": {"from": {"type": "string"}, "to": {"type": "string"}},
                      "required": ["from", "to"]},
        },
    }
    for key in sorted(all_field_keys):
        node_props.setdefault(key, {"type": ["string", "number", "boolean"]})

    return {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
            "nodes": {"type": "array", "items": {"$ref": "#/$defs/node"}},
            "edges": {"type": "array", "items": {"$ref": "#/$defs/edge"}},
            "start": {"type": "string", "description": "Id på startnoden."},
            "settings": {
                "type": "object",
                "properties": {s["key"]: _setting_schema(s) for s in SETTINGS_SCHEMA},
            },
            "subflows": {
                "type": "array",
                "description": "Lokala namngivna subgrafer som kan anropas med call_flow (target '#<id>').",
                "items": {
                    "type": "object",
                    "properties": {
                        "id": {"type": "string"},
                        "name": {"type": "string"},
                        "nodes": {"type": "array", "items": {"$ref": "#/$defs/node"}},
                        "edges": {"type": "array", "items": {"$ref": "#/$defs/edge"}},
                        "start": {"type": "string"},
                        "inputs": {"type": "array", "items": {"type": "object"}},
                    },
                    "required": ["id", "name", "nodes", "edges"],
                },
            },
            "explanation": {"type": "string", "description": "Kort förklaring på svenska av vad flödet gör."},
        },
        "required": ["nodes", "edges"],
        "$defs": {
            "node": {"type": "object", "properties": node_props, "required": ["id", "type"]},
            "edge": {
                "type": "object",
                "properties": {
                    "id": {"type": "string"},
                    "source": {"type": "string"},
                    "target": {"type": "string"},
                    "sourceHandle": {"type": "string",
                                     "description": "out (default), eller case0/case1/…/else för if, each/done för loop"},
                    "targetHandle": {"type": "string", "description": "in (default)"},
                },
                "required": ["source", "target"],
            },
            "condition": {
                "type": "object",
                "properties": {
                    "kind": {"type": "string", "enum": CONDITION_KIND_NAMES},
                    "by": {"type": "string", "enum": ["text", "css", "role"]},
                    "value": {"type": "string"},
                    "role": {"type": "string"},
                    "scope": {"type": "string", "enum": ["page", "item"]},
                    "nth": {"type": "number"},
                    "var": {"type": "string"},
                    "text": {"type": "string"},
                },
                "required": ["kind"],
            },
        },
    }


def _setting_schema(s: dict) -> dict:
    return {
        "bool": {"type": "boolean"},
        "number": {"type": "number"},
        "text": {"type": "string"},
    }[s["kind"]]


def step_reference_markdown() -> str:
    """Människo/LLM-läsbar referens över alla stegtyper, genererad ur STEP_TYPES.
    Bäddas in i systemprompten i rpa/assist.py."""
    out: list[str] = []
    out.append("## Flödesmodell (nod/kant-graf)\n")
    out.append(
        "Ett flöde är JSON: `{\"nodes\": [...], \"edges\": [...], \"start\": \"<nod-id>\", \"settings\": {...}}`.\n"
        "En nod: `{\"id\": \"n1\", \"type\": \"<typ>\", ...fält...}`. Alla fältvärden är strängar om inget annat sägs.\n"
        "En kant: `{\"source\": \"n1\", \"target\": \"n2\", \"sourceHandle\": \"out\", \"targetHandle\": \"in\"}`.\n"
        "Kör-ordningen bestäms av kanterna. Efter en vanlig nod följs `out`.\n"
        "- **`if`**: fält `branches: [{condition}]`. Utgångar `case0`, `case1`, … (första sanna) + `else`. "
        "Koppla varje grens noder till respektive utgång.\n"
        "- **`loop`**: utgång `each` (körs per varv, sätter `item`/`index`) och `done` (efter sista varvet). "
        "Kroppens SISTA nod måste ha en kant TILLBAKA till loop-nodens `in`.\n"
        "- **`call_flow`**: kör ett annat flöde som ett steg. `target` = externt flödesnamn eller "
        "`#<subflow-id>`. `in: [{name, value}]` mappar mot subflödets `inputs` ({variabel} tillåts); "
        "`out: [{from, to}]` kopierar subflödets slutvariabler tillbaka. Vanliga handtag `in`/`out`.\n"
        "- Ingen `jump_to_label` – rita en kant i stället.\n"
        "\nValfri toppnyckel `subflows: [{id, name, nodes, edges, start, inputs}]` – lokala namngivna "
        "subgrafer som `call_flow` kan anropa med `target: \"#<id>\"`.\n"
    )
    by_cat: dict[str, list[dict]] = {}
    for spec in STEP_TYPES:
        by_cat.setdefault(spec["category"], []).append(spec)

    cat_labels = {
        "navigation": "Navigering", "interaction": "Interaktion", "data": "Data / variabler",
        "control": "Kontrollflöde", "files": "Filer", "desktop": "Desktop (Windows)",
    }
    for cat, specs in by_cat.items():
        out.append(f"\n### {cat_labels.get(cat, cat)}\n")
        for spec in specs:
            out.append(f"- **`{spec['type']}`** – {spec['label_sv']}. {spec.get('help', '')}")
            for f in spec["fields"]:
                bits = [f"`{f['key']}`"]
                if f.get("required"):
                    bits.append("(obl.)")
                if f.get("options"):
                    bits.append("= " + " | ".join(repr(o) for o in f["options"] if o))
                if f.get("default") not in (None, ""):
                    bits.append(f"(default {f['default']!r})")
                if f.get("help"):
                    bits.append("– " + f["help"])
                out.append(f"    - {' '.join(bits)}")
            if spec.get("control") == "if":
                out.append("    - `branches`: lista av {condition}; utgångar case0…caseN + else")
            if spec.get("control") == "loop":
                out.append("    - utgångar: `each` (per varv), `done` (klar). Kroppen kopplar tillbaka till `in`.")
            if spec["type"] == "extract_row":
                out.append("    - `fields`: lista av {name, by, value, role?} - name blir kolumnrubrik")
            if spec["type"] == "call_flow":
                out.append("    - `in`: lista av {name, value}; `out`: lista av {from, to}")

    out.append("\n### Villkor (för `if`-grenar)\n")
    for c in CONDITION_KINDS:
        keys = ", ".join(f"`{f['key']}`" for f in c["fields"])
        out.append(f"- `{c['kind']}` – {c['label_sv']} (fält: {keys})")

    out.append("\n### Interpolering\n")
    out.append(
        "Textfält (by=text/role-värden samt literala textfält i `type`/`set_var`/"
        "`list_append`/`save_attachment.filename`/`capture_pdf.filename`) kan innehålla "
        "`{variabelnamn}` eller `{variabelnamn.fält}`. Fungerar INTE i CSS-selectors, "
        "URL:er eller andra filnamn. `{name}` löser även flödets deklarerade "
        "indata-variabler (flödesnyckeln `inputs`), som matas in vid körning.\n"
    )
    out.append("\n### Inställningar (`settings`)\n")
    for s in SETTINGS_SCHEMA:
        out.append(f"- `{s['key']}` ({s['kind']}, default {s['default']!r}) – {s['label_sv']}")

    return "\n".join(out)


def engine_step_types_from_source(*engine_sources: str) -> set[str]:
    """Plockar ut nodtyperna som motorn faktiskt hanterar, för drift-vakten i
    tests/test_schema.py. Skanna flow_engine.py + graph_engine.py: matchar
    `if t == "..."` / `elif t == "..."` samt desktop_*-literalerna."""
    src = "\n".join(engine_sources)
    found = set(re.findall(r'\b(?:el)?if t == "([a-z_]+)":', src))
    found |= set(re.findall(r'"(desktop_[a-z_]+)"', src))
    return found


if __name__ == "__main__":  # snabb manuell inspektion
    print(json.dumps({"step_types": STEP_TYPE_NAMES}, ensure_ascii=False, indent=2))
    print(step_reference_markdown())