foxygit / RPA-Remisser Log in
commits tags

/rpa/assist.py · 6.84 KB

raw
"""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