wahnsinn vibe
This commit is contained in:
72
backend/apps/ai_admin/__init__.py
Normal file
72
backend/apps/ai_admin/__init__.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.db import get_db
|
||||
from core.security import require_admin
|
||||
|
||||
from apps.ai_core.models import AIAuditLog
|
||||
from apps.ai_core.tools import get_tool, validate_args
|
||||
|
||||
from .planner import build_plan
|
||||
from .tool_defs import register_all
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
class PlanIn(BaseModel):
|
||||
prompt: str
|
||||
|
||||
|
||||
class ExecuteCardIn(BaseModel):
|
||||
tool: str
|
||||
args: dict = {}
|
||||
|
||||
|
||||
class ExecuteIn(BaseModel):
|
||||
cards: list[ExecuteCardIn]
|
||||
|
||||
|
||||
@router.post("/plan")
|
||||
def plan_endpoint(body: PlanIn):
|
||||
if not body.prompt.strip():
|
||||
raise HTTPException(400, "Empty prompt")
|
||||
cards = build_plan(body.prompt)
|
||||
return {"cards": cards}
|
||||
|
||||
|
||||
@router.post("/execute")
|
||||
def execute_endpoint(
|
||||
body: ExecuteIn,
|
||||
claims: dict = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_id = int(claims["sub"])
|
||||
results = []
|
||||
for card in body.cards:
|
||||
spec = get_tool(card.tool)
|
||||
if not spec:
|
||||
results.append({"tool": card.tool, "ok": False, "error": "unknown tool"})
|
||||
db.add(AIAuditLog(user_id=user_id, tool=card.tool, args=card.args, result={"error": "unknown tool"}, ok=False))
|
||||
db.commit()
|
||||
continue
|
||||
missing = validate_args(spec, card.args)
|
||||
if missing:
|
||||
results.append({"tool": card.tool, "ok": False, "error": f"missing: {missing}"})
|
||||
db.add(AIAuditLog(user_id=user_id, tool=card.tool, args=card.args, result={"missing": missing}, ok=False))
|
||||
db.commit()
|
||||
continue
|
||||
try:
|
||||
res = spec.handler(card.args, db)
|
||||
results.append({"tool": card.tool, "ok": True, "result": res})
|
||||
db.add(AIAuditLog(user_id=user_id, tool=card.tool, args=card.args, result=res, ok=True))
|
||||
db.commit()
|
||||
except Exception as e: # noqa: BLE001
|
||||
results.append({"tool": card.tool, "ok": False, "error": str(e)})
|
||||
db.add(AIAuditLog(user_id=user_id, tool=card.tool, args=card.args, result={"error": str(e)}, ok=False))
|
||||
db.commit()
|
||||
return {"results": results}
|
||||
|
||||
|
||||
def on_load() -> None:
|
||||
register_all()
|
||||
6
backend/apps/ai_admin/manifest.yaml
Normal file
6
backend/apps/ai_admin/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: ai_admin
|
||||
version: 0.1.0
|
||||
depends_on: [core, auth, catalog, orders, ai_core]
|
||||
conflicts_with: []
|
||||
required: false
|
||||
provides: []
|
||||
113
backend/apps/ai_admin/planner.py
Normal file
113
backend/apps/ai_admin/planner.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""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
|
||||
|
||||
|
||||
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": "<tool-name>", "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":<real 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()
|
||||
]
|
||||
return {"products": products, "categories": categories}
|
||||
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):\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
|
||||
227
backend/apps/ai_admin/tool_defs.py
Normal file
227
backend/apps/ai_admin/tool_defs.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Tool definitions — Admin-facing actions the KI can plan.
|
||||
|
||||
Each tool: name, description, JSON Schema for args, and a handler that is only
|
||||
ever called from the `execute` endpoint after the user confirmed the Card.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.events import event_bus
|
||||
from core.settings_service import set_setting
|
||||
|
||||
from apps.ai_core.tools import ToolSpec, register_tool
|
||||
from apps.catalog.models import Category, Product
|
||||
from apps.catalog.projector import project_category, project_product
|
||||
|
||||
|
||||
# ---- settings.update ------------------------------------------------
|
||||
|
||||
|
||||
def _handler_settings_update(args: dict, db: Session) -> dict:
|
||||
key = args["key"]
|
||||
value = args["value"]
|
||||
set_setting(db, key, value)
|
||||
return {"key": key, "value": value}
|
||||
|
||||
|
||||
SETTINGS_UPDATE = ToolSpec(
|
||||
name="settings.update",
|
||||
description="Update a shop-wide setting (e.g. shop name, currency, support email).",
|
||||
args_schema={
|
||||
"type": "object",
|
||||
"required": ["key", "value"],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Setting key, e.g. 'core.shop_name'.",
|
||||
},
|
||||
"value": {
|
||||
"description": "New value (string / number / boolean).",
|
||||
},
|
||||
},
|
||||
},
|
||||
handler=_handler_settings_update,
|
||||
examples=[
|
||||
{"key": "core.shop_name", "value": "TEST123"},
|
||||
{"key": "core.support_email", "value": "help@example.com"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---- catalog.product.create ----------------------------------------
|
||||
|
||||
|
||||
def _coalesce(value, default):
|
||||
"""Return default if value is None or missing; otherwise the value."""
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def _handler_product_create(args: dict, db: Session) -> dict:
|
||||
if db.query(Product).filter_by(sku=args["sku"]).first():
|
||||
raise ValueError(f"SKU already exists: {args['sku']}")
|
||||
name_de = _coalesce(args.get("name_de"), "")
|
||||
name_en = _coalesce(args.get("name_en"), name_de)
|
||||
desc_de = _coalesce(args.get("description_de"), "")
|
||||
desc_en = _coalesce(args.get("description_en"), desc_de)
|
||||
category_id = args.get("category_id")
|
||||
if category_id in ("", 0):
|
||||
category_id = None
|
||||
p = Product(
|
||||
sku=args["sku"],
|
||||
name={"de": name_de, "en": name_en},
|
||||
description={"de": desc_de, "en": desc_en},
|
||||
price=float(args["price"]),
|
||||
currency=_coalesce(args.get("currency"), "EUR") or "EUR",
|
||||
stock=int(_coalesce(args.get("stock"), 0) or 0),
|
||||
active=bool(_coalesce(args.get("active"), True)),
|
||||
image_url=_coalesce(args.get("image_url"), "") or "",
|
||||
category_id=category_id,
|
||||
attributes=_coalesce(args.get("attributes"), {}) or {},
|
||||
)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
project_product(db, p.id)
|
||||
event_bus.publish("product.created", {"id": p.id, "sku": p.sku}, db=db)
|
||||
return {"id": p.id, "sku": p.sku}
|
||||
|
||||
|
||||
PRODUCT_CREATE = ToolSpec(
|
||||
name="catalog.product.create",
|
||||
description="Create a new product in the catalog.",
|
||||
args_schema={
|
||||
"type": "object",
|
||||
"required": ["sku", "name_de", "price"],
|
||||
"properties": {
|
||||
"sku": {"type": "string"},
|
||||
"name_de": {"type": "string"},
|
||||
"name_en": {"type": "string"},
|
||||
"description_de": {"type": "string"},
|
||||
"description_en": {"type": "string"},
|
||||
"price": {"type": "number"},
|
||||
"currency": {"type": "string", "default": "EUR"},
|
||||
"stock": {"type": "integer", "default": 0},
|
||||
"active": {"type": "boolean", "default": True},
|
||||
"image_url": {"type": "string"},
|
||||
"category_id": {"type": "integer"},
|
||||
"attributes": {"type": "object"},
|
||||
},
|
||||
},
|
||||
handler=_handler_product_create,
|
||||
examples=[
|
||||
{
|
||||
"sku": "TS-GREEN-M",
|
||||
"name_de": "Grünes T-Shirt",
|
||||
"name_en": "Green T-Shirt",
|
||||
"price": 19.90,
|
||||
"stock": 42,
|
||||
"attributes": {"color": "green", "size": "M"},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---- catalog.product.update ----------------------------------------
|
||||
|
||||
|
||||
def _handler_product_update(args: dict, db: Session) -> dict:
|
||||
pid = int(args["id"])
|
||||
p = db.get(Product, pid)
|
||||
if not p:
|
||||
raise ValueError(f"Product {pid} not found")
|
||||
if "name_de" in args or "name_en" in args:
|
||||
p.name = {
|
||||
"de": args.get("name_de", p.name.get("de", "")),
|
||||
"en": args.get("name_en", p.name.get("en", "")),
|
||||
}
|
||||
if "description_de" in args or "description_en" in args:
|
||||
p.description = {
|
||||
"de": args.get("description_de", p.description.get("de", "")),
|
||||
"en": args.get("description_en", p.description.get("en", "")),
|
||||
}
|
||||
for f in ("price", "currency", "stock", "active", "image_url", "category_id"):
|
||||
if f in args:
|
||||
setattr(p, f, args[f])
|
||||
if "attributes" in args:
|
||||
p.attributes = args["attributes"]
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
project_product(db, p.id)
|
||||
event_bus.publish("product.updated", {"id": p.id}, db=db)
|
||||
return {"id": p.id, "sku": p.sku}
|
||||
|
||||
|
||||
PRODUCT_UPDATE = ToolSpec(
|
||||
name="catalog.product.update",
|
||||
description="Update fields of an existing product.",
|
||||
args_schema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name_de": {"type": "string"},
|
||||
"name_en": {"type": "string"},
|
||||
"description_de": {"type": "string"},
|
||||
"description_en": {"type": "string"},
|
||||
"price": {"type": "number"},
|
||||
"stock": {"type": "integer"},
|
||||
"active": {"type": "boolean"},
|
||||
"image_url": {"type": "string"},
|
||||
"category_id": {"type": "integer"},
|
||||
"attributes": {"type": "object"},
|
||||
},
|
||||
},
|
||||
handler=_handler_product_update,
|
||||
examples=[{"id": 5, "price": 24.90, "stock": 10}],
|
||||
)
|
||||
|
||||
|
||||
# ---- catalog.category.create --------------------------------------
|
||||
|
||||
|
||||
def _handler_category_create(args: dict, db: Session) -> dict:
|
||||
if db.query(Category).filter_by(slug=args["slug"]).first():
|
||||
raise ValueError(f"Slug exists: {args['slug']}")
|
||||
c = Category(
|
||||
slug=args["slug"],
|
||||
name={"de": args.get("name_de", ""), "en": args.get("name_en", args.get("name_de", ""))},
|
||||
parent_id=args.get("parent_id"),
|
||||
sort_order=int(args.get("sort_order", 0)),
|
||||
)
|
||||
db.add(c)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
project_category(db, c.id)
|
||||
event_bus.publish("category.created", {"id": c.id}, db=db)
|
||||
return {"id": c.id, "slug": c.slug}
|
||||
|
||||
|
||||
CATEGORY_CREATE = ToolSpec(
|
||||
name="catalog.category.create",
|
||||
description="Create a new category.",
|
||||
args_schema={
|
||||
"type": "object",
|
||||
"required": ["slug", "name_de"],
|
||||
"properties": {
|
||||
"slug": {"type": "string"},
|
||||
"name_de": {"type": "string"},
|
||||
"name_en": {"type": "string"},
|
||||
"parent_id": {"type": "integer"},
|
||||
"sort_order": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
handler=_handler_category_create,
|
||||
examples=[{"slug": "accessoires", "name_de": "Accessoires", "name_en": "Accessories"}],
|
||||
)
|
||||
|
||||
|
||||
# ---- registration -------------------------------------------------
|
||||
|
||||
|
||||
ALL_TOOLS = [SETTINGS_UPDATE, PRODUCT_CREATE, PRODUCT_UPDATE, CATEGORY_CREATE]
|
||||
|
||||
|
||||
def register_all() -> None:
|
||||
for t in ALL_TOOLS:
|
||||
register_tool(t)
|
||||
Reference in New Issue
Block a user