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:
2026-05-30 00:02:45 +02:00
parent add303970b
commit 0540c49c66
6 changed files with 639 additions and 1 deletions
+19 -1
View File
@@ -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,
+70
View File
@@ -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:
+26
View File
@@ -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:
+31
View File
@@ -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",
+460
View File
@@ -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 {})
+33
View File
@@ -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():