feat(brain): skill_scaffold — Templates statt Skill aus dem Nichts
Variante C: niedrigere Huerde zum Skill-Bau. Statt einen kompletten
Python-Skill via skill_create zu generieren (~100 Zeilen Code, teuer in
Tokens und fehleranfaellig), waehlt ARIA ein Template + minimale params,
Brain expandiert das Skelett in ~1s zu fertigem Skill.
Beobachtung: ARIA driftet bei Spotify, PDF etc. zu Bash-curl statt
einen Skill zu bauen, weil die Skill-Bau-Huerde zu hoch ist (Code,
README, args, pip_packages, config_schema). Mit Templates ist die
Huerde minimal.
Neue Module:
- aria-brain/skill_templates.py: drei mitgelieferte Templates
- oauth-api: OAuth2-API (Spotify, GitHub, Reddit, Google, Discord, ...).
Token via BRAIN_INTERNAL_URL/oauth/<s>/token mit Auto-Refresh.
Args: method/path/body/base_url
- apikey-api: API mit statischem Key (OpenWeather, OpenAI, Twilio).
Key liegt im config_schema -> CFG_<NAME> ENV, KEIN hardcoden.
Konfigurierbar: auth_header (Authorization|X-Api-Key), auth_prefix.
- file-process: Skelett fuer File-In/File-Out (PDF, Bild, JSON).
process()-Funktion ist Stub, ARIA fuellt sie via skill_update.
Templates nutzen Token-Replacement statt f-Strings (sonst Konflikt
mit dem skill-internen Python-Code).
- aria-brain/skills.py: scaffold_skill(name, template, params, author)
wrappt create_skill mit den expandierten Feldern.
- aria-brain/agent.py: neues Brain-Tool skill_scaffold mit detaillierter
Description (Template-Liste + params-Schema). Dispatcher-Handler
schickt skill_created Side-Channel-Event analog zu skill_create.
- aria-brain/main.py: POST /skills/scaffold + GET /skills/templates
(letzteres listet alle Templates fuer UI/Diagnostic).
- 11. seed_rule scaffold-reflex: bei 2x derselben API per Bash-curl
SOFORT skill_scaffold rufen. Belohnung explizit benannt
("welches lied" von 20s auf 3s).
README mit Skills-Scaffold-Tabelle ergaenzt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -388,7 +388,7 @@ Chat-Turn an die richtigen Patterns.
|
||||
|
||||
### Skill-Regeln (seed_rules)
|
||||
|
||||
`aria-brain/seed_rules.py` enthaelt 10 `type=rule, pinned=true,
|
||||
`aria-brain/seed_rules.py` enthaelt 11 `type=rule, pinned=true,
|
||||
source=seed`-Memories, die bei jedem Brain-Start idempotent in die
|
||||
Vector-DB geschrieben werden (`migration_key`-basiert). Sie tauchen in
|
||||
jedem Chat-Turn im Hot-Memory-Block auf:
|
||||
@@ -402,8 +402,26 @@ jedem Chat-Turn im Hot-Memory-Block auf:
|
||||
- **oauth-reauth-reflex** — bei 401: ZUERST `oauth_get_token` (Auto-Refresh), nur bei dessen Fehler `oauth_authorize`
|
||||
- **no-skill-drift** — kein Drift vom Skill zu Ad-hoc-Bash-Befehlen. Skill kaputt? `skill_logs` + `skill_update`. Niemals nur SAGEN „ich baue dir einen Skill", wenn `skill_create` nicht wirklich gefeuert wird
|
||||
- **runtime-topology** (architektur) — ARIA laeuft als `claude`-CLI-Subprocess IM aria-proxy Container (alpine — kein python3/jq), NICHT im aria-brain. `/data/skills/` und `BRAIN_INTERNAL_URL` existieren dort nicht. Brain-Resources via Brain-Tools (`oauth_get_token`, `memory_search`, `run_<skill>` …), nicht via Bash. SSH zur VM-Host via `ssh aria@host` (Key liegt im Proxy)
|
||||
- **scaffold-reflex** — wenn dieselbe externe API 2× via Bash-curl angerufen wurde: SOFORT `skill_scaffold` aufrufen. Niedrige Hürde (Template statt vollständiger Skill-Code) → höhere Adoption
|
||||
- **external-api-auth-strategy** — OAuth2 → `oauth_get_token`, sonst `config_schema`, NIEMALS hardcoden
|
||||
|
||||
### Skill-Scaffold (Templates)
|
||||
|
||||
Statt jedes Mal einen kompletten Skill aus dem Nichts zu generieren,
|
||||
ruft ARIA `skill_scaffold(name, template, params)` — Brain expandiert
|
||||
ein passendes Skelett. Massiv niedrigere Hürde gegen Skill-Drift.
|
||||
|
||||
Drei mitgelieferte Templates (`aria-brain/skill_templates.py`):
|
||||
|
||||
| Template | Wofür | params |
|
||||
|---|---|---|
|
||||
| `oauth-api` | Spotify, GitHub, Reddit, Google, Discord — Token aus Brain mit Auto-Refresh | `{service: "spotify", base_url?}` |
|
||||
| `apikey-api` | OpenWeather, OpenAI, Twilio — statischer Key in `config_schema` → `CFG_<NAME>` ENV | `{api_name, key_env, auth_header?, auth_prefix?, base_url}` |
|
||||
| `file-process` | PDF/Bild/JSON-Wandler — Input aus `/shared/uploads/`, Output zurueck. `process()`-Stub, danach `skill_update` mit echtem Code | `{output_ext}` |
|
||||
|
||||
HTTP: `POST /skills/scaffold` + `GET /skills/templates` (Liste mit Param-Doku).
|
||||
Nach Scaffold optional `skill_update` falls Custom-Logik gebraucht wird.
|
||||
|
||||
Im Gegensatz zu `aria-data/brain-import/` (User-Saatgut, gitignored,
|
||||
manueller Diagnostic-Klick) gehoeren seed_rules zum Brain-Code und werden
|
||||
mit jedem Deploy ausgerollt. Editieren = `SEED_RULES`-Liste anpassen,
|
||||
|
||||
@@ -186,6 +186,47 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "skill_scaffold",
|
||||
"description": (
|
||||
"ERSTE WAHL fuer Skill-Bau wenn das Muster zu einem Template passt — "
|
||||
"Brain expandiert das Skelett, Du sparst Dir das vollstaendige "
|
||||
"Python-Programm zu generieren. Wenn Stefan eine externe API "
|
||||
"mehrmals nutzt: SOFORT `skill_scaffold` statt jedes Mal "
|
||||
"ad-hoc Bash-curl.\n\n"
|
||||
"Verfuegbare Templates:\n"
|
||||
" - **oauth-api**: OAuth2-API (Spotify, GitHub, Reddit, Google, Discord, …). "
|
||||
"Token kommt vom Brain mit Auto-Refresh. params: "
|
||||
"`{service:'spotify', base_url?:'https://...'}`\n"
|
||||
" - **apikey-api**: API mit statischem Key (OpenWeather, OpenAI, Twilio). "
|
||||
"Key liegt im skill.json config_schema → CFG_<NAME> ENV. params: "
|
||||
"`{api_name:'OpenWeather', key_env:'OWM_API_KEY', auth_header?:'Authorization', auth_prefix?:'Bearer ', base_url:'https://...'}`\n"
|
||||
" - **file-process**: Skelett fuer Datei-In/Datei-Out (PDF, Bild, JSON umformen). "
|
||||
"process()-Funktion ist Stub — danach `skill_update` mit echtem Code. params: "
|
||||
"`{output_ext:'txt'}`\n\n"
|
||||
"Nach Scaffold kannst Du das Skelett via `skill_update` weiter "
|
||||
"anpassen falls noetig (mehr pip_packages, andere args, …). "
|
||||
"Aber meistens reicht das Template direkt.\n\n"
|
||||
"Wenn kein Template passt: erst pruefen ob Du wirklich ein "
|
||||
"kustomes brauchst, sonst lieber Template + Update."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string",
|
||||
"description": "Skill-Name (kebab-case, ohne Versionssuffix)"},
|
||||
"template": {"type": "string",
|
||||
"enum": ["oauth-api", "apikey-api", "file-process"],
|
||||
"description": "Eines der drei Templates"},
|
||||
"params": {"type": "object",
|
||||
"description": "Template-spezifische Parameter (siehe description)"},
|
||||
},
|
||||
"required": ["name", "template"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -945,6 +986,35 @@ class Agent:
|
||||
},
|
||||
})
|
||||
return f"OK — Skill '{manifest['name']}' erstellt (active={manifest['active']})."
|
||||
if name == "skill_scaffold":
|
||||
skill_name = (arguments.get("name") or "").strip()
|
||||
template = (arguments.get("template") or "").strip()
|
||||
params = arguments.get("params") or {}
|
||||
if not skill_name or not template:
|
||||
return "FEHLER: name + template erforderlich."
|
||||
try:
|
||||
manifest = skills_mod.scaffold_skill(
|
||||
name=skill_name, template=template, params=params, author="aria",
|
||||
)
|
||||
except ValueError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
# Side-Channel-Event analog zu skill_create
|
||||
self._pending_events.append({
|
||||
"type": "skill_created",
|
||||
"skill": {
|
||||
"name": manifest["name"],
|
||||
"description": manifest.get("description", ""),
|
||||
"execution": manifest.get("execution", ""),
|
||||
"active": manifest.get("active", True),
|
||||
"setup_error": manifest.get("setup_error"),
|
||||
"scaffolded_from": template,
|
||||
},
|
||||
})
|
||||
return (
|
||||
f"OK — Skill '{manifest['name']}' aus Template '{template}' angelegt. "
|
||||
f"active={manifest['active']}. "
|
||||
f"Falls noetig: skill_update fuer custom Code, skill_set_config fuer secrets."
|
||||
)
|
||||
if name == "skill_list":
|
||||
items = skills_mod.list_skills(active_only=False)
|
||||
if not items:
|
||||
|
||||
@@ -798,6 +798,32 @@ def skills_get(name: str):
|
||||
return {"manifest": m, "readme": readme}
|
||||
|
||||
|
||||
class SkillScaffold(BaseModel):
|
||||
name: str
|
||||
template: str # oauth-api | apikey-api | file-process
|
||||
params: dict = Field(default_factory=dict)
|
||||
author: str = "stefan"
|
||||
|
||||
|
||||
@app.get("/skills/templates")
|
||||
def skills_templates_list():
|
||||
"""Liste der verfuegbaren Templates — fuer UI und Dokumentation."""
|
||||
import skill_templates as st
|
||||
return {"templates": st.list_templates()}
|
||||
|
||||
|
||||
@app.post("/skills/scaffold")
|
||||
def skills_scaffold(body: SkillScaffold):
|
||||
"""Baut einen Skill aus einem Template (oauth-api / apikey-api / file-process)."""
|
||||
try:
|
||||
return skills_mod.scaffold_skill(
|
||||
name=body.name, template=body.template,
|
||||
params=body.params, author=body.author,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
@app.post("/skills/create")
|
||||
def skills_create(body: SkillCreate):
|
||||
try:
|
||||
|
||||
@@ -208,6 +208,37 @@ SEED_RULES: List[dict] = [
|
||||
"Brain-Resources: erst denken, sonst Brain-Tool nehmen."
|
||||
),
|
||||
},
|
||||
{
|
||||
"migration_key": "seed/skill-rule/scaffold-reflex",
|
||||
"type": "rule",
|
||||
"title": "Skill-Regel: skill_scaffold-Reflex bei wiederholten API-Calls",
|
||||
"category": "skills",
|
||||
"content": (
|
||||
"Wenn Du dieselbe externe API in einer Session 2x oder oefter "
|
||||
"via Bash-curl angerufen hast: HOER AUF und rufe SOFORT "
|
||||
"`skill_scaffold` auf. Drei Spotify-Calls hintereinander, "
|
||||
"drei Wetter-Abfragen, drei Calendar-Events — JEDES dieser "
|
||||
"Pattern schreit nach einem Skill.\n"
|
||||
"\n"
|
||||
"skill_scaffold ist die niedrige Hürde: Du musst keinen "
|
||||
"kompletten Python-Skill schreiben. Du waehlst nur:\n"
|
||||
" - oauth-api fuer OAuth2-Services (Spotify, GitHub, Reddit, "
|
||||
"Google, Discord) -- Token kommt vom Brain mit Auto-Refresh\n"
|
||||
" - apikey-api fuer statische Keys (OpenWeather, OpenAI, "
|
||||
"Twilio) -- Key landet im config_schema, Stefan setzt ihn in "
|
||||
"Diagnostic\n"
|
||||
" - file-process fuer Datei-In/Datei-Out (PDF, Bild, Daten)\n"
|
||||
"\n"
|
||||
"Brain expandiert das Template in ~1s zu einem fertigen Skill. "
|
||||
"Falls Du was Spezielles brauchst: erst Scaffold, dann "
|
||||
"`skill_update` mit Anpassung. NICHT umgekehrt — und schon gar "
|
||||
"nicht das fuenfte Mal das gleiche Bash-Skript bauen.\n"
|
||||
"\n"
|
||||
"Belohnung: ein Spotify-Skill macht 'welches lied laeuft' in "
|
||||
"1 Tool-Call (~3s) statt 5 Bash-Roundtrips (~20s). Stefan "
|
||||
"merkt das sofort und ist zufriedener."
|
||||
),
|
||||
},
|
||||
{
|
||||
"migration_key": "seed/skill-rule/external-api-auth-strategy",
|
||||
"type": "rule",
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
"""
|
||||
Skill-Templates — Boilerplate fuer haeufige Skill-Pattern.
|
||||
|
||||
ARIA muss nicht jedes Mal einen kompletten Python-Skill aus dem Nichts
|
||||
generieren. Sie ruft `skill_scaffold(name, template, params)`, Brain
|
||||
expandiert das Template und legt den Skill an. Hoehere Skill-Adoption
|
||||
weil niedrigere Bauh-Huerde.
|
||||
|
||||
Templates sind ueber Token-Replacement parametrisiert (kein f-String —
|
||||
das wuerde mit dem skill-internen Python-Code kollidieren).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Callable
|
||||
|
||||
|
||||
# ── Hilfsfunktion ────────────────────────────────────────────────────
|
||||
|
||||
def _replace_tokens(s: str, tokens: dict) -> str:
|
||||
"""Ersetzt {{TOKEN}}-Platzhalter durch Werte. Robust gegen f-String-
|
||||
Konflikte im Python-Code des Skills."""
|
||||
out = s
|
||||
for k, v in tokens.items():
|
||||
out = out.replace("{{" + k + "}}", str(v))
|
||||
return out
|
||||
|
||||
|
||||
# ── Template 1: oauth-api ────────────────────────────────────────────
|
||||
# Wrappt eine OAuth2-API. Token kommt aus dem Brain (Auto-Refresh).
|
||||
|
||||
_OAUTH_API_CODE = '''"""
|
||||
{{NAME}} — OAuth2-API-Wrapper fuer {{SERVICE}}.
|
||||
|
||||
Holt Token vom Brain (Auto-Refresh) und ruft HTTP-Endpoints der {{SERVICE}}-API.
|
||||
Keine hardcoded Credentials — alles ueber das zentrale OAuth-System.
|
||||
|
||||
Args (alle als ENV ARG_<NAME>):
|
||||
ARG_METHOD = GET | POST | PUT | DELETE | PATCH (Default GET)
|
||||
ARG_PATH = API-Pfad inkl. Query-String (z.B. /v1/me/player)
|
||||
ARG_BODY = JSON-Body als String (optional, fuer POST/PUT/PATCH)
|
||||
ARG_BASE_URL = Override der Default-Base-URL (optional)
|
||||
|
||||
Exit-Codes: 0 ok, 1 Fehler, 2 nicht autorisiert (Re-Login noetig)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BRAIN_URL = os.environ.get("BRAIN_INTERNAL_URL", "http://localhost:8080")
|
||||
DEFAULT_BASE_URL = "{{DEFAULT_BASE_URL}}"
|
||||
SERVICE = "{{SERVICE}}"
|
||||
|
||||
|
||||
def get_token() -> str:
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"{BRAIN_URL}/oauth/{SERVICE}/token", timeout=10,
|
||||
) as r:
|
||||
return json.loads(r.read())["access_token"]
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", "replace")[:400]
|
||||
if e.code == 401:
|
||||
print(f"NICHT AUTORISIERT: {SERVICE}-Token abgelaufen oder nie gesetzt. "
|
||||
f"ARIA-Tool 'oauth_authorize' nutzen. Details: {body}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print(f"Token-Holen fehlgeschlagen: HTTP {e.code} - {body}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Token-Holen fehlgeschlagen: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
method = (os.environ.get("ARG_METHOD") or "GET").upper()
|
||||
path = (os.environ.get("ARG_PATH") or "").strip()
|
||||
body_raw = (os.environ.get("ARG_BODY") or "").strip()
|
||||
base_url = (os.environ.get("ARG_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
||||
if not path:
|
||||
print(json.dumps({"ok": False, "error": "ARG_PATH erforderlich"}), file=sys.stderr)
|
||||
return 1
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
url = base_url + path
|
||||
headers = {"Authorization": f"Bearer {get_token()}"}
|
||||
data = None
|
||||
if body_raw and method in ("POST", "PUT", "PATCH"):
|
||||
data = body_raw.encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
txt = r.read().decode("utf-8")
|
||||
parsed = json.loads(txt) if txt and txt[:1] in "[{" else txt
|
||||
print(json.dumps({"ok": True, "status": r.status, "data": parsed},
|
||||
ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except urllib.error.HTTPError as e:
|
||||
txt = e.read().decode("utf-8", "replace")
|
||||
try: parsed = json.loads(txt)
|
||||
except Exception: parsed = txt[:800]
|
||||
print(json.dumps({"ok": False, "status": e.code, "error": parsed},
|
||||
ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
'''
|
||||
|
||||
_OAUTH_API_README = '''# {{NAME}}
|
||||
|
||||
OAuth2-API-Wrapper fuer **{{SERVICE}}**. Generiert via `skill_scaffold(template="oauth-api")`.
|
||||
|
||||
Holt den Token vom Brain (Auto-Refresh) und macht beliebige HTTP-Calls gegen
|
||||
die {{SERVICE}}-API. Keine hardcoded Credentials — die Auth-Pipeline laeuft
|
||||
zentral ueber das Brain-OAuth-System.
|
||||
|
||||
## Voraussetzung
|
||||
|
||||
- OAuth-App fuer **{{SERVICE}}** im Brain registriert (Diagnostic → OAuth-Apps → client_id + client_secret eintragen)
|
||||
- Einmaliges `oauth_authorize {{SERVICE}}` zum Initial-Login
|
||||
|
||||
## Args
|
||||
|
||||
| Name | Default | Beschreibung |
|
||||
|------|---------|--------------|
|
||||
| method | GET | HTTP-Methode (GET/POST/PUT/DELETE/PATCH) |
|
||||
| path | - | API-Pfad mit Query-String (z.B. `/v1/me/player`) |
|
||||
| body | - | JSON-Body fuer POST/PUT/PATCH |
|
||||
| base_url | {{DEFAULT_BASE_URL}} | Override der Base-URL falls Sub-API |
|
||||
|
||||
## Beispiele
|
||||
|
||||
```
|
||||
method=GET path=/v1/me/player # Was laeuft?
|
||||
method=POST path=/v1/me/player/next # Skip
|
||||
method=PUT path=/v1/me/player/volume?volume_percent=40 # Volume 40
|
||||
```
|
||||
|
||||
Antwort: `{ok, status, data}` als JSON. Bei Fehler `ok=false`.
|
||||
'''
|
||||
|
||||
|
||||
def _oauth_api(name: str, params: dict) -> dict:
|
||||
service = (params.get("service") or name).strip().lower()
|
||||
default_base_url = params.get("base_url") or f"https://api.{service}.com"
|
||||
tokens = {
|
||||
"NAME": name,
|
||||
"SERVICE": service,
|
||||
"DEFAULT_BASE_URL": default_base_url,
|
||||
}
|
||||
return {
|
||||
"entry_code": _replace_tokens(_OAUTH_API_CODE, tokens),
|
||||
"readme": _replace_tokens(_OAUTH_API_README, tokens),
|
||||
"pip_packages": [],
|
||||
"args": [
|
||||
{"name": "method", "type": "string", "required": False,
|
||||
"description": "HTTP-Methode (Default GET)"},
|
||||
{"name": "path", "type": "string", "required": True,
|
||||
"description": "API-Pfad inkl. Query-String, z.B. /v1/me/player"},
|
||||
{"name": "body", "type": "string", "required": False,
|
||||
"description": "JSON-Body fuer POST/PUT/PATCH"},
|
||||
{"name": "base_url", "type": "string", "required": False,
|
||||
"description": f"Override der Base-URL (Default {default_base_url})"},
|
||||
],
|
||||
"config_schema": [],
|
||||
"description": f"OAuth2-API-Wrapper fuer {service}. Token kommt vom Brain (Auto-Refresh).",
|
||||
}
|
||||
|
||||
|
||||
# ── Template 2: apikey-api ───────────────────────────────────────────
|
||||
# Wrappt eine API die mit statischem API-Key/Bearer-Token arbeitet.
|
||||
# Key liegt in skill.json::config_schema und wird via CFG_<KEY> ENV
|
||||
# durchgereicht — kein hardcoden, Stefan setzt's in Diagnostic.
|
||||
|
||||
_APIKEY_API_CODE = '''"""
|
||||
{{NAME}} — API-Wrapper fuer {{API_NAME}} mit statischem Key.
|
||||
|
||||
Schluessel kommt aus dem Skill-Config (CFG_{{KEY_ENV}}) — Stefan setzt
|
||||
ihn im Diagnostic-UI bzw. App, NICHT hardcoded.
|
||||
|
||||
Args:
|
||||
ARG_METHOD = GET | POST | PUT | DELETE (Default GET)
|
||||
ARG_PATH = API-Pfad inkl. Query-String
|
||||
ARG_BODY = JSON-Body (optional)
|
||||
ARG_BASE_URL = Override der Default-Base-URL
|
||||
|
||||
Exit-Codes: 0 ok, 1 Fehler, 2 Key nicht gesetzt
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_BASE_URL = "{{DEFAULT_BASE_URL}}"
|
||||
AUTH_HEADER = "{{AUTH_HEADER}}" # z.B. "Authorization" oder "X-Api-Key"
|
||||
AUTH_PREFIX = "{{AUTH_PREFIX}}" # z.B. "Bearer " oder leer
|
||||
|
||||
|
||||
def main() -> int:
|
||||
key = os.environ.get("CFG_{{KEY_ENV}}", "").strip()
|
||||
if not key:
|
||||
print(json.dumps({"ok": False,
|
||||
"error": "API-Key nicht gesetzt — in Diagnostic Skill-Config '{{KEY_ENV}}' eintragen"}),
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
method = (os.environ.get("ARG_METHOD") or "GET").upper()
|
||||
path = (os.environ.get("ARG_PATH") or "").strip()
|
||||
body_raw = (os.environ.get("ARG_BODY") or "").strip()
|
||||
base_url = (os.environ.get("ARG_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
||||
if not path:
|
||||
print(json.dumps({"ok": False, "error": "ARG_PATH erforderlich"}), file=sys.stderr)
|
||||
return 1
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
url = base_url + path
|
||||
headers = {AUTH_HEADER: f"{AUTH_PREFIX}{key}"}
|
||||
data = None
|
||||
if body_raw and method in ("POST", "PUT", "PATCH"):
|
||||
data = body_raw.encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
txt = r.read().decode("utf-8")
|
||||
parsed = json.loads(txt) if txt and txt[:1] in "[{" else txt
|
||||
print(json.dumps({"ok": True, "status": r.status, "data": parsed},
|
||||
ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except urllib.error.HTTPError as e:
|
||||
txt = e.read().decode("utf-8", "replace")
|
||||
try: parsed = json.loads(txt)
|
||||
except Exception: parsed = txt[:800]
|
||||
print(json.dumps({"ok": False, "status": e.code, "error": parsed},
|
||||
ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
'''
|
||||
|
||||
_APIKEY_API_README = '''# {{NAME}}
|
||||
|
||||
API-Wrapper fuer **{{API_NAME}}** mit statischem API-Key. Generiert via
|
||||
`skill_scaffold(template="apikey-api")`.
|
||||
|
||||
Schluessel ist NICHT im Code, sondern im Skill-Config (`CFG_{{KEY_ENV}}`).
|
||||
Stefan setzt ihn in Diagnostic → Skills → Detail → Konfiguration.
|
||||
|
||||
## Args
|
||||
|
||||
| Name | Default | Beschreibung |
|
||||
|------|---------|--------------|
|
||||
| method | GET | HTTP-Methode |
|
||||
| path | - | API-Pfad mit Query-String |
|
||||
| body | - | JSON-Body |
|
||||
| base_url | {{DEFAULT_BASE_URL}} | Override |
|
||||
|
||||
## Config (in Diagnostic einstellen)
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|------|-----|--------------|
|
||||
| {{KEY_ENV}} | password | API-Key fuer {{API_NAME}} |
|
||||
'''
|
||||
|
||||
|
||||
def _apikey_api(name: str, params: dict) -> dict:
|
||||
api_name = params.get("api_name") or name
|
||||
key_env = (params.get("key_env") or "API_KEY").upper()
|
||||
# safe: nur Buchstaben/Zahlen/Underscore
|
||||
key_env = re.sub(r"[^A-Z0-9_]", "_", key_env)
|
||||
auth_header = params.get("auth_header") or "Authorization"
|
||||
auth_prefix = params.get("auth_prefix") if "auth_prefix" in params else "Bearer "
|
||||
default_base_url = params.get("base_url") or "https://api.example.com"
|
||||
tokens = {
|
||||
"NAME": name,
|
||||
"API_NAME": api_name,
|
||||
"KEY_ENV": key_env,
|
||||
"AUTH_HEADER": auth_header,
|
||||
"AUTH_PREFIX": auth_prefix,
|
||||
"DEFAULT_BASE_URL": default_base_url,
|
||||
}
|
||||
return {
|
||||
"entry_code": _replace_tokens(_APIKEY_API_CODE, tokens),
|
||||
"readme": _replace_tokens(_APIKEY_API_README, tokens),
|
||||
"pip_packages": [],
|
||||
"args": [
|
||||
{"name": "method", "type": "string", "required": False,
|
||||
"description": "HTTP-Methode (Default GET)"},
|
||||
{"name": "path", "type": "string", "required": True,
|
||||
"description": "API-Pfad inkl. Query-String"},
|
||||
{"name": "body", "type": "string", "required": False,
|
||||
"description": "JSON-Body fuer POST/PUT"},
|
||||
{"name": "base_url", "type": "string", "required": False,
|
||||
"description": "Override der Base-URL"},
|
||||
],
|
||||
"config_schema": [
|
||||
{"name": key_env, "type": "password", "label": f"{api_name} API-Key",
|
||||
"secret": True, "description": f"Persoenlicher API-Key fuer {api_name}"},
|
||||
],
|
||||
"description": f"API-Wrapper fuer {api_name} (Key aus CFG_{key_env}).",
|
||||
}
|
||||
|
||||
|
||||
# ── Template 3: file-process ─────────────────────────────────────────
|
||||
# Nimmt eine Datei aus /shared/uploads/, ruft eine User-Funktion drauf
|
||||
# auf, schreibt das Resultat nach /shared/uploads/. Skelett — ARIA fuellt
|
||||
# die `process()`-Funktion danach via skill_update mit dem echten Code.
|
||||
|
||||
_FILE_PROCESS_CODE = '''"""
|
||||
{{NAME}} — File-Processing-Skelett.
|
||||
|
||||
Liest eine Eingabe-Datei aus /shared/uploads/, ruft process() auf,
|
||||
schreibt Output zurueck nach /shared/uploads/.
|
||||
|
||||
Args:
|
||||
ARG_INPUT = Pfad zur Eingabedatei (z.B. /shared/uploads/foo.pdf)
|
||||
ARG_OUTPUT = Optional Pfad fuer Output (Default: <input>.{{OUTPUT_EXT}})
|
||||
|
||||
ARIA-Hinweis: die process()-Funktion ist ein Stub — passe sie via
|
||||
skill_update an deine Aufgabe an. pip_packages bei Bedarf via
|
||||
skill_update ergaenzen (z.B. pypdf, Pillow, reportlab).
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
def process(input_path: str, output_path: str) -> None:
|
||||
"""Eigentlicher Verarbeitungs-Schritt. Hier kommt der Code rein."""
|
||||
# STUB: kopiert die Datei einfach. ARIA: ueberschreibe diese Funktion.
|
||||
shutil.copy(input_path, output_path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
inp = (os.environ.get("ARG_INPUT") or "").strip()
|
||||
if not inp:
|
||||
print("FEHLER: ARG_INPUT erforderlich", file=sys.stderr)
|
||||
return 1
|
||||
if not os.path.exists(inp):
|
||||
print(f"FEHLER: Eingabe nicht gefunden: {inp}", file=sys.stderr)
|
||||
return 1
|
||||
out = (os.environ.get("ARG_OUTPUT") or "").strip()
|
||||
if not out:
|
||||
base, _ = os.path.splitext(inp)
|
||||
out = f"{base}.{{OUTPUT_EXT}}"
|
||||
try:
|
||||
process(inp, out)
|
||||
except Exception as e:
|
||||
print(f"FEHLER bei process(): {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(out) # stdout = Pfad zur Ausgabe-Datei, ARIA kann den dem User zurueckgeben
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
'''
|
||||
|
||||
_FILE_PROCESS_README = '''# {{NAME}}
|
||||
|
||||
File-Processing-Skelett (`skill_scaffold(template="file-process")`).
|
||||
|
||||
Liest eine Datei aus `/shared/uploads/`, ruft die `process()`-Funktion auf,
|
||||
schreibt das Resultat zurueck. Die `process()`-Funktion ist initial ein
|
||||
Stub (kopiert nur) — ARIA passt sie via `skill_update` an die konkrete
|
||||
Aufgabe an.
|
||||
|
||||
## Args
|
||||
|
||||
| Name | Default | Beschreibung |
|
||||
|------|---------|--------------|
|
||||
| input | - | Eingabedatei (z.B. /shared/uploads/foo.pdf) |
|
||||
| output | `<input>.{{OUTPUT_EXT}}` | Ausgabepfad (optional) |
|
||||
|
||||
stdout = Pfad zur erzeugten Datei → ARIA kann ihn dem User zurueckgeben.
|
||||
'''
|
||||
|
||||
|
||||
def _file_process(name: str, params: dict) -> dict:
|
||||
output_ext = (params.get("output_ext") or "out").strip().lstrip(".")
|
||||
output_ext = re.sub(r"[^a-zA-Z0-9]", "", output_ext) or "out"
|
||||
tokens = {
|
||||
"NAME": name,
|
||||
"OUTPUT_EXT": output_ext,
|
||||
}
|
||||
return {
|
||||
"entry_code": _replace_tokens(_FILE_PROCESS_CODE, tokens),
|
||||
"readme": _replace_tokens(_FILE_PROCESS_README, tokens),
|
||||
"pip_packages": [],
|
||||
"args": [
|
||||
{"name": "input", "type": "string", "required": True,
|
||||
"description": "Eingabedatei (z.B. /shared/uploads/foo.pdf)"},
|
||||
{"name": "output", "type": "string", "required": False,
|
||||
"description": f"Output-Pfad (Default <input>.{output_ext})"},
|
||||
],
|
||||
"config_schema": [],
|
||||
"description": f"File-Processing-Skelett (Input → process() → Output.{output_ext}).",
|
||||
}
|
||||
|
||||
|
||||
# ── Registry ────────────────────────────────────────────────────────
|
||||
|
||||
TEMPLATES: dict[str, Callable[[str, dict], dict]] = {
|
||||
"oauth-api": _oauth_api,
|
||||
"apikey-api": _apikey_api,
|
||||
"file-process": _file_process,
|
||||
}
|
||||
|
||||
|
||||
def list_templates() -> list[dict]:
|
||||
"""Liste aller verfuegbaren Templates mit Kurzbeschreibung — fuer UI/Tool-Doku."""
|
||||
return [
|
||||
{
|
||||
"name": "oauth-api",
|
||||
"description": "OAuth2-API-Wrapper (Spotify, GitHub, Reddit, Google, …). "
|
||||
"Token kommt vom Brain mit Auto-Refresh. Args: method/path/body.",
|
||||
"params": ["service (str, OAuth-Service-Name)", "base_url (str, optional)"],
|
||||
},
|
||||
{
|
||||
"name": "apikey-api",
|
||||
"description": "API-Wrapper fuer Services mit statischem API-Key "
|
||||
"(OpenWeather, OpenAI, Twilio, …). Key liegt im Skill-Config "
|
||||
"und kommt als CFG_<NAME> ENV — kein hardcode.",
|
||||
"params": ["api_name (str)", "key_env (str, ENV-Name fuer den Key)",
|
||||
"auth_header (str, default 'Authorization')",
|
||||
"auth_prefix (str, default 'Bearer ')",
|
||||
"base_url (str)"],
|
||||
},
|
||||
{
|
||||
"name": "file-process",
|
||||
"description": "Skelett fuer File-In/File-Out-Operationen "
|
||||
"(PDF konvertieren, Bild bearbeiten, JSON umformen). "
|
||||
"process()-Funktion ist Stub, ARIA fuellt sie via skill_update.",
|
||||
"params": ["output_ext (str, Datei-Endung des Outputs)"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def expand(name: str, template: str, params: dict | None = None) -> dict:
|
||||
"""Expandiert ein Template zu einem fertigen Skill-Spec.
|
||||
|
||||
Returns: dict mit entry_code / readme / pip_packages / args /
|
||||
config_schema / description — direkt an create_skill weitergebbar.
|
||||
|
||||
Wirft ValueError wenn das Template nicht existiert.
|
||||
"""
|
||||
fn = TEMPLATES.get(template)
|
||||
if not fn:
|
||||
raise ValueError(
|
||||
f"Template '{template}' unbekannt. Verfuegbar: {sorted(TEMPLATES.keys())}"
|
||||
)
|
||||
return fn(name, params or {})
|
||||
@@ -347,6 +347,39 @@ def update_skill(name: str, patch: dict) -> dict:
|
||||
return manifest
|
||||
|
||||
|
||||
def scaffold_skill(
|
||||
name: str,
|
||||
template: str,
|
||||
params: Optional[dict] = None,
|
||||
author: str = "aria",
|
||||
) -> dict:
|
||||
"""Baut einen Skill aus einem Template-Skelett. ARIA muss nicht jedes Mal
|
||||
einen kompletten Python-Skill schreiben — sie waehlt ein Template und
|
||||
optionale Parameter, Brain expandiert das zu fertigem Code.
|
||||
|
||||
Templates siehe `skill_templates.TEMPLATES`. Konkret:
|
||||
- 'oauth-api' : params={service, base_url?}
|
||||
- 'apikey-api': params={api_name, key_env, auth_header?, auth_prefix?, base_url?}
|
||||
- 'file-process': params={output_ext?}
|
||||
|
||||
Wirft ValueError wenn Template unbekannt oder Name kollidiert.
|
||||
Sonst: ruft intern create_skill mit den expandierten Feldern auf.
|
||||
"""
|
||||
import skill_templates as _st
|
||||
spec = _st.expand(name, template, params or {})
|
||||
return create_skill(
|
||||
name=name,
|
||||
description=spec["description"],
|
||||
execution="local-venv",
|
||||
entry_code=spec["entry_code"],
|
||||
readme=spec["readme"],
|
||||
args=spec["args"],
|
||||
pip_packages=spec["pip_packages"],
|
||||
config_schema=spec["config_schema"],
|
||||
author=author,
|
||||
)
|
||||
|
||||
|
||||
def delete_skill(name: str) -> None:
|
||||
d = _skill_dir(name)
|
||||
if not d.exists():
|
||||
|
||||
Reference in New Issue
Block a user