"""Build a structured action plan from a natural-language prompt (or JSON bulk).""" from __future__ import annotations import json from core.db import SessionLocal from apps.ai_core.ollama_client import get_llm from apps.ai_core.tools import describe_for_prompt, get_tool, validate_args from apps.catalog.models import Category, Product from apps.orders.models import Order SYSTEM_PROMPT = """You are an admin assistant for an e-commerce shop. You help the operator perform tasks by producing a STRUCTURED PLAN of tool calls. You MUST NEVER execute anything. You only propose cards that the operator confirms. Output format (STRICT): Reply with ONLY a JSON object of the shape {"cards": [ ... ]}. Each card: {"tool": "", "args": {...}, "missing": [], "preview": "German summary", "notes": ""}. Rules: - Only use tools from the provided TOOL CATALOG. If no tool applies, return {"cards": []}. - NEVER emit a single card that aggregates multiple items. If the user provides JSON with multiple items (bulk) → produce ONE card per item. If the user asks to change something about ALL or MULTIPLE existing products/categories → produce ONE card per matching item from the SHOP STATE snapshot, with its exact id. Examples: "setze alle preise auf 1" on 3 products → 3 cards, each {"tool":"catalog.product.update","args":{"id":,"price":1},...} NOT one card with id=null or "all". - Numbers must be numbers in JSON (not strings). Omit optional fields instead of sending null. - Stay concise in "preview". """ def _shop_state_snapshot() -> dict: """Compact snapshot of current shop state for the planner. Keep it small.""" db = SessionLocal() try: products = [ {"id": p.id, "sku": p.sku, "name_de": (p.name or {}).get("de", ""), "price": float(p.price)} for p in db.query(Product).order_by(Product.id).all() ] categories = [ {"id": c.id, "slug": c.slug, "name_de": (c.name or {}).get("de", "")} for c in db.query(Category).order_by(Category.id).all() ] orders = [ {"id": o.id, "status": o.status, "total": float(o.total)} for o in db.query(Order).order_by(Order.id.desc()).limit(20).all() ] return {"products": products, "categories": categories, "orders": orders} finally: db.close() def build_plan(user_prompt: str) -> list[dict]: tools = describe_for_prompt(role="admin") state = _shop_state_snapshot() user_msg = ( f"TOOL CATALOG (JSON):\n{json.dumps(tools, ensure_ascii=False)}\n\n" f"SHOP STATE (current products, categories, recent orders):\n{json.dumps(state, ensure_ascii=False)}\n\n" f"USER REQUEST:\n{user_prompt}\n\n" "Reply with ONLY the JSON object described in the rules." ) try: result = get_llm().chat_json(SYSTEM_PROMPT, user_msg) except Exception as e: # noqa: BLE001 return [ { "tool": "_error", "args": {}, "missing": [], "preview": f"Planer-Fehler: {e}", "notes": "LLM antwortete nicht verwertbar. Prompt umformulieren.", } ] if isinstance(result, list): cards = result elif isinstance(result, dict) and isinstance(result.get("cards"), list): cards = result["cards"] else: cards = [] # Validate and annotate clean: list[dict] = [] for card in cards: if not isinstance(card, dict): continue name = card.get("tool", "") args = card.get("args") or {} spec = get_tool(name) if spec: # Trust only server-side required-field validation, not LLM-supplied missing missing = validate_args(spec, args) clean.append( { "tool": name, "args": args, "missing": missing, "preview": card.get("preview", ""), "notes": card.get("notes", ""), "schema": spec.args_schema, } ) else: clean.append( { "tool": name, "args": args, "missing": list(card.get("missing") or []), "preview": card.get("preview", f"Unbekanntes Tool: {name}"), "notes": "tool not in catalog", "schema": {"type": "object", "properties": {}}, } ) return clean