commits
tags
"""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())