"""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": "<nod-id>", "target": "<nod-id>", "sourceHandle": "out", "targetHandle": "in"}`.
Vanliga noder följs via `out`. `if`: en kant per gren från `case0`,`case1`,… + `else`. `loop`: `each` (kroppen) och `done` (efteråt); kroppens sista nod kopplar TILLBAKA till loop-nodens `in`.
- Ge varje nod ett kort unikt `id`. Sätt `start` till första nodens id.
- Ändrar användaren ett befintligt flöde: behåll de noder/kanter som fortfarande gäller, lägg till/ta bort/justera resten.
- Använd bara nodtyper och fält som finns i referensen nedan. Hitta inte på fält.
- Selector-fält: föredra `by="text"` med synlig text, eller `by="role"`. `by="css"` bara när text/roll inte räcker.
- Ett typiskt inloggat flöde börjar med `goto` följt av `wait_for_login`.
- Loopar över en lista: `list` (CSS-selector) → `loop` (over = samma variabel). Inuti loopen kan inre steg sätta `scope="item"`.
- 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