"""NL → flöde: tar en instruktion på naturligt språk (svenska) och ett ev. befintligt flöde och ber en Claude-modell generera/ändra flödes-JSON (grafform). Samma stegschema (rpa/schema.py) som webui:t och MCP-servern använder ligger till grund för systemprompten och för valideringen av svaret. Nyckel: ANTHROPIC_API_KEY i miljön, eller en profil via `ant auth login`. Modell: env RPA_ASSIST_MODEL (default claude-opus-5). """ from __future__ import annotations import json import os from typing import Any from . import schema DEFAULT_MODEL = "claude-opus-5" _EMIT_TOOL = "emit_flow" class AssistUnavailable(RuntimeError): """Ingen API-nyckel/inloggning konfigurerad - assistenten kan inte köras.""" class AssistError(RuntimeError): """Modellen svarade men resultatet gick inte att tolka till ett giltigt flöde.""" def _client(): try: import anthropic except ModuleNotFoundError as exc: # pragma: no cover raise AssistUnavailable( "Paketet 'anthropic' saknas - kör: pip install -r requirements.txt" ) from exc has_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN")) if not has_key and not _has_cli_profile(): raise AssistUnavailable( "Ingen Claude-nyckel hittad. Sätt miljövariabeln ANTHROPIC_API_KEY " "(eller kör `ant auth login`) och starta om servern." ) try: return anthropic.Anthropic() except Exception as exc: # noqa: BLE001 # pragma: no cover raise AssistUnavailable(f"Kunde inte skapa Anthropic-klienten: {exc}") from exc def _has_cli_profile() -> bool: from pathlib import Path cfg = Path.home() / ".config" / "anthropic" return cfg.exists() and any(cfg.iterdir()) _SYSTEM = """Du är en assistent som bygger automationsflöden för ett Playwright-baserat RPA-verktyg. Du får en instruktion på svenska och (ibland) ett befintligt flöde. Svara genom att anropa verktyget `emit_flow` med den kompletta, uppdaterade grafen ({nodes, edges}). Regler: - Ett flöde är en graf: `nodes` (noder med platta fält) + `edges` (kanter som binder ihop dem). Ingen nästling. - Varje kant: `{"source": "", "target": "", "sourceHandle": "out", "targetHandle": "in"}`. Vanliga noder följs via `out`. `if`: en kant per gren från `case0`,`case1`,… + `else`. `loop`: `each` (kroppen) och `done` (efteråt); kroppens sista nod kopplar TILLBAKA till loop-nodens `in`. - Ge varje nod ett kort unikt `id`. Sätt `start` till första nodens id. - Ändrar användaren ett befintligt flöde: behåll de noder/kanter som fortfarande gäller, lägg till/ta bort/justera resten. - Använd bara nodtyper och fält som finns i referensen nedan. Hitta inte på fält. - Selector-fält: föredra `by="text"` med synlig text, eller `by="role"`. `by="css"` bara när text/roll inte räcker. - Ett typiskt inloggat flöde börjar med `goto` följt av `wait_for_login`. - Loopar över en lista: `list` (CSS-selector) → `loop` (over = samma variabel). Inuti loopen kan inre steg sätta `scope="item"`. - Sätt en kort `explanation` (svenska) som sammanfattar vad flödet gör. - Är instruktionen otydlig: gör ett rimligt antagande och nämn det i `explanation`. {reference} """ def _tool_schema() -> dict: fjs = schema.flow_json_schema() return { "name": _EMIT_TOOL, "description": "Leverera hela grafen (nodes + edges + start + settings + explanation).", "input_schema": { "type": "object", "properties": fjs["properties"], "required": ["nodes", "edges"], "$defs": fjs["$defs"], }, } def generate_flow(instruction: str, current_flow: dict | None = None, *, model: str | None = None) -> dict: """Returnerar {"nodes": [...], "edges": [...], "start": ..., "settings": {...}, "explanation": "..."}. Kastar AssistUnavailable om ingen nyckel finns, AssistError om modellen inte lyckas producera ett giltigt flöde (även efter en reparationsrunda). """ if not (instruction or "").strip(): raise AssistError("Tom instruktion.") client = _client() model = model or os.environ.get("RPA_ASSIST_MODEL") or DEFAULT_MODEL system = _SYSTEM.replace("{reference}", schema.step_reference_markdown()) user_parts = [f"Instruktion:\n{instruction.strip()}"] if current_flow and current_flow.get("nodes"): user_parts.append( "Befintligt flöde att utgå från:\n" + json.dumps( { "nodes": current_flow.get("nodes", []), "edges": current_flow.get("edges", []), "start": current_flow.get("start"), "settings": current_flow.get("settings", {}), "subflows": current_flow.get("subflows", []), }, ensure_ascii=False, indent=2, ) ) messages: list[dict[str, Any]] = [{"role": "user", "content": "\n\n".join(user_parts)}] tool = _tool_schema() for attempt in range(2): response = client.messages.create( model=model, max_tokens=16000, system=system, thinking={"type": "adaptive"}, tools=[tool], tool_choice={"type": "tool", "name": _EMIT_TOOL}, messages=messages, ) flow = _extract_flow(response) if flow is None: raise AssistError("Modellen anropade inte verktyget emit_flow.") normalized = { "format": "graph", "nodes": flow.get("nodes", []), "edges": flow.get("edges", []), "settings": flow.get("settings", {}), } if flow.get("start"): normalized["start"] = flow["start"] if flow.get("subflows"): normalized["subflows"] = flow["subflows"] errors = schema.validate_graph(normalized, strict=True) if not errors: normalized["explanation"] = str(flow.get("explanation", "")).strip() return normalized if attempt == 0: messages.append({"role": "assistant", "content": response.content}) messages.append({ "role": "user", "content": ( "Flödet du skickade har valideringsfel. Rätta dem och anropa " "emit_flow igen med hela flödet:\n- " + "\n- ".join(errors) ), }) continue raise AssistError("Flödet klarade inte valideringen:\n- " + "\n- ".join(errors)) raise AssistError("Kunde inte generera ett giltigt flöde.") # pragma: no cover def _extract_flow(response) -> dict | None: for block in response.content: if getattr(block, "type", None) == "tool_use" and block.name == _EMIT_TOOL: return block.input if isinstance(block.input, dict) else json.loads(block.input) return None