Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 095a10aaf0 | |||
| e3a224478d | |||
| 61c9183033 |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 10901
|
versionCode 10902
|
||||||
versionName "0.1.9.1"
|
versionName "0.1.9.2"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ import android.Manifest
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.media.AudioFormat
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioManager
|
||||||
import android.media.AudioRecord
|
import android.media.AudioRecord
|
||||||
|
import android.media.AudioRecordingConfiguration
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.media.audiofx.AcousticEchoCanceler
|
import android.media.audiofx.AcousticEchoCanceler
|
||||||
import android.media.audiofx.AutomaticGainControl
|
import android.media.audiofx.AutomaticGainControl
|
||||||
import android.media.audiofx.NoiseSuppressor
|
import android.media.audiofx.NoiseSuppressor
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -104,6 +109,20 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
|
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
|
||||||
private var recordingStartedMs: Long = 0L
|
private var recordingStartedMs: Long = 0L
|
||||||
|
|
||||||
|
// Audio-Sharing mit anderen Apps:
|
||||||
|
// Wenn z.B. WhatsApp eine Sprachnachricht aufnimmt, dann hält ARIAs
|
||||||
|
// VOICE_COMMUNICATION-Lock zwar das System nicht offiziell exklusiv,
|
||||||
|
// aber die Foreground-App bekommt nur Stille — die WhatsApp-Aufnahme
|
||||||
|
// ist tonlos. Loesung: AudioRecordingCallback hoeren, sobald eine andere
|
||||||
|
// App das Mic anfordert → unsere AudioRecord freigeben (externallyPaused=true).
|
||||||
|
// Wenn die andere App fertig ist → reaktivieren. Wakeword pausiert solange.
|
||||||
|
private var recordingCallback: AudioManager.AudioRecordingCallback? = null
|
||||||
|
@Volatile private var externallyPaused: Boolean = false
|
||||||
|
private val mainHandler: Handler by lazy { Handler(Looper.getMainLooper()) }
|
||||||
|
private val audioManager: AudioManager by lazy {
|
||||||
|
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word.
|
* Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word.
|
||||||
* modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy")
|
* modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy")
|
||||||
@@ -167,54 +186,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val minBuf = AudioRecord.getMinBufferSize(
|
acquireAndStartRecording()
|
||||||
SAMPLE_RATE,
|
|
||||||
AudioFormat.CHANNEL_IN_MONO,
|
|
||||||
AudioFormat.ENCODING_PCM_16BIT,
|
|
||||||
).coerceAtLeast(CHUNK_SAMPLES * 2 * 4)
|
|
||||||
|
|
||||||
// VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten
|
|
||||||
// automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit
|
|
||||||
// ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel
|
|
||||||
// zur TTS-Wiedergabe gelauscht wird.
|
|
||||||
val record = AudioRecord(
|
|
||||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
|
||||||
SAMPLE_RATE,
|
|
||||||
AudioFormat.CHANNEL_IN_MONO,
|
|
||||||
AudioFormat.ENCODING_PCM_16BIT,
|
|
||||||
minBuf,
|
|
||||||
)
|
|
||||||
if (record.state != AudioRecord.STATE_INITIALIZED) {
|
|
||||||
record.release()
|
|
||||||
promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt?)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
audioRecord = record
|
|
||||||
|
|
||||||
// Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete
|
|
||||||
// benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon
|
|
||||||
// mitbringt. Failure ist nicht kritisch (continue ohne Effects).
|
|
||||||
try {
|
|
||||||
if (AcousticEchoCanceler.isAvailable()) {
|
|
||||||
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
|
|
||||||
Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
|
|
||||||
try {
|
|
||||||
if (NoiseSuppressor.isAvailable()) {
|
|
||||||
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
|
|
||||||
try {
|
|
||||||
if (AutomaticGainControl.isAvailable()) {
|
|
||||||
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
|
|
||||||
|
|
||||||
resetInferenceState()
|
|
||||||
running.set(true)
|
|
||||||
record.startRecording()
|
|
||||||
recordingStartedMs = System.currentTimeMillis()
|
|
||||||
|
|
||||||
// PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
|
// PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
|
||||||
// die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet.
|
// die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet.
|
||||||
@@ -231,10 +203,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
|
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
|
// AudioRecordingCallback registrieren: andere Apps (WhatsApp-
|
||||||
isDaemon = true
|
// Sprachnachricht, Telefonate etc.) wollen das Mic — wir geben
|
||||||
start()
|
// es vorruebergehend frei statt sie ins Leere recorden zu lassen.
|
||||||
}
|
registerRecordingCallback()
|
||||||
|
|
||||||
Log.i(TAG, "Lauschen gestartet (model=$modelName)")
|
Log.i(TAG, "Lauschen gestartet (model=$modelName)")
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
@@ -247,6 +219,75 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reine AudioRecord + Effects + Capture-Thread-Acquisition. Wirft bei
|
||||||
|
* Fehler — Caller faengt + reportet. Kein WakeLock, keine Callbacks. */
|
||||||
|
private fun acquireAndStartRecording() {
|
||||||
|
val minBuf = AudioRecord.getMinBufferSize(
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
).coerceAtLeast(CHUNK_SAMPLES * 2 * 4)
|
||||||
|
|
||||||
|
// VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten
|
||||||
|
// automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit
|
||||||
|
// ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel
|
||||||
|
// zur TTS-Wiedergabe gelauscht wird.
|
||||||
|
val record = AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
minBuf,
|
||||||
|
)
|
||||||
|
if (record.state != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
record.release()
|
||||||
|
throw IllegalStateException("AudioRecord nicht initialisiert (Mikro belegt?)")
|
||||||
|
}
|
||||||
|
audioRecord = record
|
||||||
|
|
||||||
|
// Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete
|
||||||
|
// benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon
|
||||||
|
// mitbringt. Failure ist nicht kritisch (continue ohne Effects).
|
||||||
|
try {
|
||||||
|
if (AcousticEchoCanceler.isAvailable()) {
|
||||||
|
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
|
||||||
|
Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
|
||||||
|
try {
|
||||||
|
if (NoiseSuppressor.isAvailable()) {
|
||||||
|
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
|
||||||
|
try {
|
||||||
|
if (AutomaticGainControl.isAvailable()) {
|
||||||
|
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
|
||||||
|
|
||||||
|
resetInferenceState()
|
||||||
|
running.set(true)
|
||||||
|
record.startRecording()
|
||||||
|
recordingStartedMs = System.currentTimeMillis()
|
||||||
|
|
||||||
|
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reine AudioRecord + Effects + Capture-Thread-Release. Sicher (catch all).
|
||||||
|
* Kein WakeLock-Release, kein Unregistrieren der Callbacks. */
|
||||||
|
private fun stopAndReleaseRecording() {
|
||||||
|
running.set(false)
|
||||||
|
try { captureThread?.join(1500) } catch (_: InterruptedException) {}
|
||||||
|
captureThread = null
|
||||||
|
try { audioRecord?.stop() } catch (_: Exception) {}
|
||||||
|
try { audioRecord?.release() } catch (_: Exception) {}
|
||||||
|
audioRecord = null
|
||||||
|
releaseAudioEffects()
|
||||||
|
}
|
||||||
|
|
||||||
private fun releaseAudioEffects() {
|
private fun releaseAudioEffects() {
|
||||||
try { aec?.release() } catch (_: Exception) {}
|
try { aec?.release() } catch (_: Exception) {}
|
||||||
try { ns?.release() } catch (_: Exception) {}
|
try { ns?.release() } catch (_: Exception) {}
|
||||||
@@ -256,15 +297,9 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun stop(promise: Promise) {
|
fun stop(promise: Promise) {
|
||||||
running.set(false)
|
unregisterRecordingCallback()
|
||||||
try {
|
externallyPaused = false
|
||||||
captureThread?.join(1500)
|
stopAndReleaseRecording()
|
||||||
} catch (_: InterruptedException) {}
|
|
||||||
captureThread = null
|
|
||||||
try { audioRecord?.stop() } catch (_: Exception) {}
|
|
||||||
try { audioRecord?.release() } catch (_: Exception) {}
|
|
||||||
audioRecord = null
|
|
||||||
releaseAudioEffects()
|
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
Log.i(TAG, "Lauschen gestoppt")
|
Log.i(TAG, "Lauschen gestoppt")
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
@@ -272,18 +307,94 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun dispose(promise: Promise) {
|
fun dispose(promise: Promise) {
|
||||||
running.set(false)
|
unregisterRecordingCallback()
|
||||||
try { captureThread?.join(1000) } catch (_: InterruptedException) {}
|
externallyPaused = false
|
||||||
captureThread = null
|
stopAndReleaseRecording()
|
||||||
try { audioRecord?.stop() } catch (_: Exception) {}
|
|
||||||
try { audioRecord?.release() } catch (_: Exception) {}
|
|
||||||
audioRecord = null
|
|
||||||
releaseAudioEffects()
|
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
disposeSessions()
|
disposeSessions()
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── External-Mic-Sharing (AudioRecordingCallback) ──────────────────────
|
||||||
|
//
|
||||||
|
// Wenn eine andere App das Mic anfordert (WhatsApp-Voicenote, Telefonie,
|
||||||
|
// Sprach-Suche im Browser etc.), kriegt die zwar formal Audio — aber
|
||||||
|
// unsere VOICE_COMMUNICATION-Pipeline blockiert die naively neue Aufnahme
|
||||||
|
// mit Stille (Android-Audio-Policy). Loesung: AudioRecordingCallback
|
||||||
|
// beobachten, andere Recorder-Sessions detecten, und unsere Pipeline
|
||||||
|
// temporaer freigeben. Sobald die andere App fertig ist → reaktivieren.
|
||||||
|
//
|
||||||
|
// Effekt: Wake-Word funktioniert solange nicht — fairer Kompromiss.
|
||||||
|
|
||||||
|
private fun registerRecordingCallback() {
|
||||||
|
if (recordingCallback != null) return
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||||
|
Log.i(TAG, "AudioRecordingCallback nicht verfuegbar (API < 24) — Mic-Sharing inaktiv")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val cb = object : AudioManager.AudioRecordingCallback() {
|
||||||
|
override fun onRecordingConfigChanged(configs: MutableList<AudioRecordingConfiguration>?) {
|
||||||
|
handleRecordingConfigChange(configs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
audioManager.registerAudioRecordingCallback(cb, mainHandler)
|
||||||
|
recordingCallback = cb
|
||||||
|
Log.i(TAG, "AudioRecordingCallback registriert — beobachtet andere Mic-User")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "registerAudioRecordingCallback failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterRecordingCallback() {
|
||||||
|
val cb = recordingCallback ?: return
|
||||||
|
try { audioManager.unregisterAudioRecordingCallback(cb) } catch (_: Exception) {}
|
||||||
|
recordingCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleRecordingConfigChange(configs: MutableList<AudioRecordingConfiguration>?) {
|
||||||
|
if (configs == null) return
|
||||||
|
// Unsere eigene Session anhand der audioSessionId filtern. Wenn wir
|
||||||
|
// gerade keinen AudioRecord halten (externallyPaused), ist alles
|
||||||
|
// andere "extern" — dann zaehlt jeder Eintrag.
|
||||||
|
val ourSessionId = audioRecord?.audioSessionId
|
||||||
|
val externalActive = configs.any {
|
||||||
|
ourSessionId == null || it.clientAudioSessionId != ourSessionId
|
||||||
|
}
|
||||||
|
if (running.get() && externalActive) {
|
||||||
|
Log.i(TAG, "Andere App nutzt Mic — Wake-Word pausiert (configs=${configs.size})")
|
||||||
|
externallyPaused = true
|
||||||
|
stopAndReleaseRecording()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (externallyPaused && !externalActive) {
|
||||||
|
Log.i(TAG, "Mic wieder frei — Wake-Word reaktiviert in 300ms")
|
||||||
|
// Kurze Pause: der "andere" hat eben losgelassen, Audio-Stack braucht
|
||||||
|
// ein paar ms bis VOICE_COMMUNICATION wieder sauber initialisiert.
|
||||||
|
mainHandler.postDelayed({
|
||||||
|
if (!externallyPaused) return@postDelayed // schon resumed
|
||||||
|
// Sicherheitscheck: wenn inzwischen jemand wieder rein ist
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
val cur = audioManager.activeRecordingConfigurations
|
||||||
|
if (cur != null && cur.isNotEmpty()) {
|
||||||
|
Log.i(TAG, "Resume verworfen — anderer Mic-User noch da (${cur.size})")
|
||||||
|
return@postDelayed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
externallyPaused = false
|
||||||
|
try {
|
||||||
|
acquireAndStartRecording()
|
||||||
|
Log.i(TAG, "Wake-Word nach External-Pause reaktiviert")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Resume nach External-Pause failed: ${e.message}")
|
||||||
|
// bleiben unten — falls anderer App das Mic doch wieder
|
||||||
|
// freigibt, feuert der Callback erneut.
|
||||||
|
externallyPaused = true
|
||||||
|
}
|
||||||
|
}, 300L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun releaseWakeLock() {
|
private fun releaseWakeLock() {
|
||||||
try {
|
try {
|
||||||
wakeLock?.takeIf { it.isHeld }?.release()
|
wakeLock?.takeIf { it.isHeld }?.release()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.9.1",
|
"version": "0.1.9.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
+90
-111
@@ -127,6 +127,25 @@ META_TOOLS = [
|
|||||||
"items": {"type": "object"},
|
"items": {"type": "object"},
|
||||||
"description": "Argumente-Schema [{name, type, required, description}]",
|
"description": "Argumente-Schema [{name, type, required, description}]",
|
||||||
},
|
},
|
||||||
|
"fast_patterns": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "object"},
|
||||||
|
"description": (
|
||||||
|
"OPTIONAL — fuer 'reines Steuern'-Skills (Licht an/aus, Spotify "
|
||||||
|
"pause/next, Rollade hoch/runter etc.) eine Liste von "
|
||||||
|
"[{match, args, reply}] eintragen. Wenn ein User-Befehl gegen "
|
||||||
|
"match (anchored Regex, case-insensitive) matched, ruft das "
|
||||||
|
"Brain run_skill(name, args) DIREKT auf und gibt reply zurueck — "
|
||||||
|
"ohne Claude (~5s Latenz gespart). Match wird gegen den "
|
||||||
|
"normalisierten Text (lowercase, Endsatzzeichen weg) gemacht; "
|
||||||
|
"schreibe Patterns mit ^...$ damit nur exakte Befehle matchen "
|
||||||
|
"und nicht Teilstrings (z.B. ^pause$ statt pause). NICHT fuer "
|
||||||
|
"Skills mit kreativem Output / parametrisierter Logik — die "
|
||||||
|
"brauchen Claude. Beispiel: "
|
||||||
|
"[{\"match\":\"^pause$\",\"args\":{\"path\":\"/v1/me/player/pause\",\"method\":\"PUT\"},"
|
||||||
|
"\"reply\":\"Spotify: pausiert ⏸\"}]"
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["name", "description", "entry_code"],
|
"required": ["name", "description", "entry_code"],
|
||||||
},
|
},
|
||||||
@@ -193,6 +212,16 @@ META_TOOLS = [
|
|||||||
"Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV."
|
"Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"fast_patterns": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "object"},
|
||||||
|
"description": (
|
||||||
|
"Optional komplette Fast-Path-Patterns-Liste UEBERSCHREIBEN — "
|
||||||
|
"[{match, args, reply}]. Siehe skill_create-Beschreibung fuer "
|
||||||
|
"Format. Leere Liste = alle Fast-Paths entfernen (alles geht "
|
||||||
|
"wieder durch Claude). Wenn nicht angegeben: bleibt unberuehrt."
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["name"],
|
"required": ["name"],
|
||||||
},
|
},
|
||||||
@@ -782,61 +811,26 @@ META_TOOLS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ── Spotify Fast-Path ──────────────────────────────────────────────────
|
# ── Fast-Path (Skill-deklariert) ───────────────────────────────────────
|
||||||
#
|
#
|
||||||
# Einfache Media-Commands (nächster Track, Pause, lauter, ...) gehen
|
# Skills koennen in ihrem Manifest `fast_patterns` deklarieren — eine Liste
|
||||||
# direkt aufs spotify-Skill statt durch die volle Claude-Reasoning-Pipeline.
|
# von {match: regex, args: dict, reply: str}. Wenn ein User-Text gegen
|
||||||
# Latenz: ~1-1.5s statt 5-10s. Stefan-Bug 06/2026: "ARIA braucht ewig nur
|
# ein Pattern matcht, ruft das Brain direkt run_skill(name, args) auf und
|
||||||
# fuer 'nächster Track'". Wenn ein Pattern nicht matcht, faellt der Call
|
# returnt `reply` an den User — Claude wird komplett uebersprungen. Spart
|
||||||
# wie bisher in die normale chat()-Loop und Claude entscheidet — keine
|
# 5-10s LLM-Latenz pro "reines Steuern"-Befehl.
|
||||||
# Funktionalitaet geht verloren.
|
|
||||||
#
|
#
|
||||||
# Patterns sind anchored (^...$) gegen normalisierten Text (lowercase,
|
# Patterns sollten anchored (^...$) gegen den normalisierten Text (lower-
|
||||||
# Endsatzzeichen weg, Whitespace gestrafft). Bewusst eng gefasst: lieber
|
# case, Endsatzzeichen weg, Whitespace gestrafft) geschrieben sein. Lieber
|
||||||
# einmal in Claude fallen als ein Kontextsatz wie "ich war kurz zurueck"
|
# eng matchen als breit — false-positives sind teurer als ein Cache-Miss.
|
||||||
# faelschlich als "previous track" interpretieren.
|
#
|
||||||
_SPOTIFY_FAST_PATTERNS: list[tuple[str, str, str, Optional[int]]] = [
|
# Diese Logik ist generisch — ARIA deklariert die Patterns selbst beim
|
||||||
# (regex, action, http-method, volume-delta)
|
# skill_create / skill_update, das Brain orchestriert nur.
|
||||||
# NEXT
|
|
||||||
(r"^(naechster|nächster|naechste|nächste) (track|song|titel|lied)$", "next", "POST", None),
|
|
||||||
(r"^(weiter|skip|ueberspringen|überspringen|ueberspring|überspring)$", "next", "POST", None),
|
|
||||||
# PREVIOUS
|
|
||||||
(r"^(vorheriger|vorheriges|letzter|letztes) (track|song|titel|lied)$", "previous", "POST", None),
|
|
||||||
(r"^(zurueck|zurück)$", "previous", "POST", None),
|
|
||||||
# PAUSE
|
|
||||||
(r"^(pause|pausiere|pausieren|stop|stopp|halt)$", "pause", "PUT", None),
|
|
||||||
(r"^(musik|spotify) (pause|aus|stop|stopp)$", "pause", "PUT", None),
|
|
||||||
# PLAY / RESUME
|
|
||||||
(r"^(play|weiterspielen|weiter spielen|fortsetzen|abspielen)$", "play", "PUT", None),
|
|
||||||
(r"^(musik|spotify) (an|wieder an|weiter|fortsetzen)$", "play", "PUT", None),
|
|
||||||
# VOLUME — Delta wird auf den aktuell ermittelten Volume-Wert aufaddiert
|
|
||||||
(r"^(lauter|musik lauter|spotify lauter|volume hoch|lautstärke hoch)$", "volume", "PUT", 10),
|
|
||||||
(r"^(leiser|musik leiser|spotify leiser|volume runter|lautstärke runter)$", "volume", "PUT", -10),
|
|
||||||
(r"^(viel lauter|deutlich lauter)$", "volume", "PUT", 20),
|
|
||||||
(r"^(viel leiser|deutlich leiser)$", "volume", "PUT", -20),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
def _normalize_for_fast_match(text: str) -> str:
|
||||||
def _spotify_fast_match(text: str) -> Optional[tuple[str, str, Optional[int]]]:
|
|
||||||
"""Returns (action, method, volume_delta) wenn ein Pattern matcht — sonst None."""
|
|
||||||
norm = (text or "").strip().lower()
|
norm = (text or "").strip().lower()
|
||||||
norm = re.sub(r"[.!?]+$", "", norm)
|
norm = re.sub(r"[.!?]+$", "", norm)
|
||||||
norm = re.sub(r"\s+", " ", norm)
|
norm = re.sub(r"\s+", " ", norm)
|
||||||
if not norm:
|
return norm
|
||||||
return None
|
|
||||||
for rx, action, method, delta in _SPOTIFY_FAST_PATTERNS:
|
|
||||||
if re.match(rx, norm):
|
|
||||||
return action, method, delta
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _run_spotify_call(path: str, method: str, body: Optional[dict] = None) -> dict:
|
|
||||||
"""Fuehrt einen Spotify-Skill-Call aus. Skill-Args: path, method, body (JSON-String).
|
|
||||||
Returns das run_skill-Ergebnis."""
|
|
||||||
args: dict = {"path": path, "method": method}
|
|
||||||
if body is not None:
|
|
||||||
args["body"] = json.dumps(body)
|
|
||||||
return skills_mod.run_skill("spotify", args, timeout_sec=15)
|
|
||||||
|
|
||||||
|
|
||||||
def _skill_to_tool(s: dict) -> dict:
|
def _skill_to_tool(s: dict) -> dict:
|
||||||
@@ -906,71 +900,52 @@ class Agent:
|
|||||||
self._pending_events = []
|
self._pending_events = []
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def _try_spotify_fast_path(self, user_message: str) -> Optional[str]:
|
def _try_skill_fast_path(self, user_message: str) -> Optional[str]:
|
||||||
"""Wenn die Nachricht ein einfacher Media-Command ist, direkt aufs
|
"""Iteriert ueber alle aktiven Skills und probiert deren fast_patterns
|
||||||
spotify-Skill routen und ein kurzes Reply zurueckgeben — Claude wird
|
gegen den normalisierten User-Text. Erster Treffer gewinnt — Skill
|
||||||
komplett uebersprungen. Returnt None wenn kein Pattern matcht oder das
|
wird direkt aufgerufen, Reply geht ohne Claude zurueck.
|
||||||
spotify-Skill nicht installiert ist (dann faellt's normal in Claude)."""
|
|
||||||
m = _spotify_fast_match(user_message)
|
|
||||||
if m is None:
|
|
||||||
return None
|
|
||||||
action, method, delta = m
|
|
||||||
|
|
||||||
# Skill muss installiert + aktiv sein. Sonst Fall-Through zu Claude.
|
Returnt None wenn kein Pattern matcht. Bei Skill-Ausfuehrungs-Fehler
|
||||||
try:
|
(ok=False) wird eine ehrliche Fehler-Reply gegeben statt durch Claude
|
||||||
manifest = skills_mod.read_manifest("spotify")
|
zu fallen — sonst kostet ein gescheiterter Fast-Path doppelt (~1s
|
||||||
except Exception:
|
Skill-Versuch + 5-10s Claude). Bei unerwarteter Exception fallen wir
|
||||||
manifest = None
|
durch zu Claude (Claude kann ggf. besser diagnostizieren)."""
|
||||||
if not manifest or not manifest.get("active", True):
|
norm = _normalize_for_fast_match(user_message)
|
||||||
logger.info("[spotify-fast] skill nicht verfuegbar — fall through zu Claude")
|
if not norm:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info("[spotify-fast] match action=%s method=%s delta=%s msg=%r",
|
active_skills = [s for s in skills_mod.list_skills(active_only=False)
|
||||||
action, method, delta, user_message[:60])
|
if s.get("active", True)]
|
||||||
|
for skill in active_skills:
|
||||||
def _err_reply(label: str, res: dict) -> str:
|
patterns = skill.get("fast_patterns") or []
|
||||||
# ok=False kommt von 401 (nicht eingeloggt), 404 (kein aktives
|
if not patterns:
|
||||||
# Gerät) etc. — Skill schreibt den Spotify-Error nach stderr.
|
continue
|
||||||
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
|
skill_name = skill.get("name") or ""
|
||||||
hint = (tail[-1] if tail else "")[:120]
|
for pat in patterns:
|
||||||
return f"Spotify: {label} fehlgeschlagen — {hint or 'siehe Brain-Log'}"
|
rx = pat.get("match") or ""
|
||||||
|
if not rx:
|
||||||
try:
|
continue
|
||||||
if action == "next":
|
|
||||||
res = _run_spotify_call("/v1/me/player/next", method)
|
|
||||||
return "Spotify: nächster Track ⏭" if res.get("ok") else _err_reply("Skip", res)
|
|
||||||
if action == "previous":
|
|
||||||
res = _run_spotify_call("/v1/me/player/previous", method)
|
|
||||||
return "Spotify: vorheriger Track ⏮" if res.get("ok") else _err_reply("Zurück", res)
|
|
||||||
if action == "pause":
|
|
||||||
res = _run_spotify_call("/v1/me/player/pause", method)
|
|
||||||
return "Spotify: pausiert ⏸" if res.get("ok") else _err_reply("Pause", res)
|
|
||||||
if action == "play":
|
|
||||||
res = _run_spotify_call("/v1/me/player/play", method)
|
|
||||||
return "Spotify: spielt ▶" if res.get("ok") else _err_reply("Play", res)
|
|
||||||
if action == "volume" and delta is not None:
|
|
||||||
state = _run_spotify_call("/v1/me/player", "GET")
|
|
||||||
if not state.get("ok"):
|
|
||||||
return _err_reply("Lautstärke-Status", state)
|
|
||||||
cur_vol = 50
|
|
||||||
try:
|
try:
|
||||||
out = (state.get("stdout") or "").strip()
|
if not re.match(rx, norm, re.IGNORECASE):
|
||||||
if out:
|
continue
|
||||||
data = json.loads(out)
|
except re.error:
|
||||||
dev = data.get("device") or {}
|
# Sollte durch _normalize_fast_patterns rausgefiltert sein.
|
||||||
cur_vol = int(dev.get("volume_percent", 50))
|
continue
|
||||||
|
args = pat.get("args") or {}
|
||||||
|
reply = pat.get("reply") or f"{skill_name}: ok"
|
||||||
|
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
|
||||||
|
skill_name, rx, user_message[:60])
|
||||||
|
try:
|
||||||
|
res = skills_mod.run_skill(skill_name, dict(args), timeout_sec=15)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("[spotify-fast] volume-state parse: %s", exc)
|
logger.warning("[fast-path] %s exception — fall through zu Claude: %s",
|
||||||
new_vol = max(0, min(100, cur_vol + delta))
|
skill_name, exc)
|
||||||
res = _run_spotify_call(f"/v1/me/player/volume?volume_percent={new_vol}", "PUT")
|
return None
|
||||||
if not res.get("ok"):
|
if not res.get("ok"):
|
||||||
return _err_reply("Lautstärke", res)
|
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
|
||||||
arrow = "🔊" if delta > 0 else "🔉"
|
hint = (tail[-1] if tail else "")[:120]
|
||||||
return f"Spotify: Lautstärke {new_vol}% {arrow}"
|
return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
|
||||||
except Exception as exc:
|
return reply
|
||||||
logger.warning("[spotify-fast] action=%s exception — fall through zu Claude: %s",
|
|
||||||
action, exc)
|
|
||||||
return None
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
|
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
|
||||||
@@ -985,9 +960,10 @@ class Agent:
|
|||||||
# Events vom letzten Turn weglassen
|
# Events vom letzten Turn weglassen
|
||||||
self._pending_events = []
|
self._pending_events = []
|
||||||
|
|
||||||
# Spotify Fast-Path: einfache Media-Commands ueberspringen Claude komplett.
|
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
|
||||||
# Spart 4-9s Latenz fuer 'naechster Track', 'Pause', 'lauter' etc.
|
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
|
||||||
fast_reply = self._try_spotify_fast_path(user_message)
|
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
|
||||||
|
fast_reply = self._try_skill_fast_path(user_message)
|
||||||
if fast_reply is not None:
|
if fast_reply is not None:
|
||||||
self.conversation.add("user", user_message, source=source)
|
self.conversation.add("user", user_message, source=source)
|
||||||
self.conversation.add("assistant", fast_reply)
|
self.conversation.add("assistant", fast_reply)
|
||||||
@@ -1133,6 +1109,7 @@ class Agent:
|
|||||||
args=arguments.get("args", []),
|
args=arguments.get("args", []),
|
||||||
pip_packages=arguments.get("pip_packages", []),
|
pip_packages=arguments.get("pip_packages", []),
|
||||||
config_schema=arguments.get("config_schema") or None,
|
config_schema=arguments.get("config_schema") or None,
|
||||||
|
fast_patterns=arguments.get("fast_patterns") or None,
|
||||||
author="aria",
|
author="aria",
|
||||||
)
|
)
|
||||||
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
||||||
@@ -1196,6 +1173,8 @@ class Agent:
|
|||||||
patch["pip_packages"] = arguments["pip_packages"]
|
patch["pip_packages"] = arguments["pip_packages"]
|
||||||
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
|
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
|
||||||
patch["config_schema"] = arguments["config_schema"]
|
patch["config_schema"] = arguments["config_schema"]
|
||||||
|
if "fast_patterns" in arguments and isinstance(arguments["fast_patterns"], list):
|
||||||
|
patch["fast_patterns"] = arguments["fast_patterns"]
|
||||||
if not patch:
|
if not patch:
|
||||||
return "FEHLER: keine Felder zum Update angegeben."
|
return "FEHLER: keine Felder zum Update angegeben."
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -45,6 +45,54 @@ logger = logging.getLogger("aria-brain")
|
|||||||
QDRANT_HOST = os.environ.get("QDRANT_HOST", "aria-qdrant")
|
QDRANT_HOST = os.environ.get("QDRANT_HOST", "aria-qdrant")
|
||||||
QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
|
QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
|
||||||
|
|
||||||
|
def _seed_spotify_fast_patterns() -> None:
|
||||||
|
"""One-shot Migration: schreibt Standard-Steuer-Patterns ins Spotify-Skill
|
||||||
|
wenn das Skill existiert + aktiv ist + noch keine fast_patterns hat.
|
||||||
|
|
||||||
|
Nach diesem Run kann ARIA die Patterns frei via skill_update aendern."""
|
||||||
|
manifest = skills_mod.read_manifest("spotify")
|
||||||
|
if not manifest:
|
||||||
|
logger.info("[migrate] spotify skill nicht vorhanden — nichts zu tun")
|
||||||
|
return
|
||||||
|
if manifest.get("fast_patterns"):
|
||||||
|
logger.info("[migrate] spotify hat schon fast_patterns (%d) — skip",
|
||||||
|
len(manifest["fast_patterns"]))
|
||||||
|
return
|
||||||
|
default_patterns = [
|
||||||
|
# NEXT
|
||||||
|
{"match": r"^(naechster|nächster|naechste|nächste) (track|song|titel|lied)$",
|
||||||
|
"args": {"path": "/v1/me/player/next", "method": "POST"},
|
||||||
|
"reply": "Spotify: nächster Track ⏭"},
|
||||||
|
{"match": r"^(weiter|skip|ueberspringen|überspringen|ueberspring|überspring)$",
|
||||||
|
"args": {"path": "/v1/me/player/next", "method": "POST"},
|
||||||
|
"reply": "Spotify: nächster Track ⏭"},
|
||||||
|
# PREVIOUS
|
||||||
|
{"match": r"^(vorheriger|vorheriges|letzter|letztes) (track|song|titel|lied)$",
|
||||||
|
"args": {"path": "/v1/me/player/previous", "method": "POST"},
|
||||||
|
"reply": "Spotify: vorheriger Track ⏮"},
|
||||||
|
{"match": r"^(zurueck|zurück)$",
|
||||||
|
"args": {"path": "/v1/me/player/previous", "method": "POST"},
|
||||||
|
"reply": "Spotify: vorheriger Track ⏮"},
|
||||||
|
# PAUSE
|
||||||
|
{"match": r"^(pause|pausiere|pausieren|stop|stopp|halt)$",
|
||||||
|
"args": {"path": "/v1/me/player/pause", "method": "PUT"},
|
||||||
|
"reply": "Spotify: pausiert ⏸"},
|
||||||
|
{"match": r"^(musik|spotify) (pause|aus|stop|stopp)$",
|
||||||
|
"args": {"path": "/v1/me/player/pause", "method": "PUT"},
|
||||||
|
"reply": "Spotify: pausiert ⏸"},
|
||||||
|
# PLAY
|
||||||
|
{"match": r"^(play|weiterspielen|weiter spielen|fortsetzen|abspielen)$",
|
||||||
|
"args": {"path": "/v1/me/player/play", "method": "PUT"},
|
||||||
|
"reply": "Spotify: spielt ▶"},
|
||||||
|
{"match": r"^(musik|spotify) (an|wieder an|weiter|fortsetzen)$",
|
||||||
|
"args": {"path": "/v1/me/player/play", "method": "PUT"},
|
||||||
|
"reply": "Spotify: spielt ▶"},
|
||||||
|
]
|
||||||
|
skills_mod.update_skill("spotify", {"fast_patterns": default_patterns})
|
||||||
|
logger.info("[migrate] spotify fast_patterns gesetzt (%d Eintraege)",
|
||||||
|
len(default_patterns))
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Beim Brain-Start: System-Seed-Regeln idempotent in DB schreiben,
|
"""Beim Brain-Start: System-Seed-Regeln idempotent in DB schreiben,
|
||||||
@@ -54,6 +102,15 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("Lifespan: seed_rules angewendet (%s)", result)
|
logger.info("Lifespan: seed_rules angewendet (%s)", result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Lifespan: seed_rules fehlgeschlagen — Brain startet trotzdem (%s)", exc)
|
logger.exception("Lifespan: seed_rules fehlgeschlagen — Brain startet trotzdem (%s)", exc)
|
||||||
|
|
||||||
|
# Einmalige Migration: Spotify-Skill ohne fast_patterns kriegt die Standard-
|
||||||
|
# Patterns injiziert. Idempotent — wenn schon welche da sind, nichts tun.
|
||||||
|
# ARIA kann sie spaeter via skill_update beliebig erweitern/ersetzen.
|
||||||
|
try:
|
||||||
|
_seed_spotify_fast_patterns()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Lifespan: spotify fast_patterns Migration: %s", exc)
|
||||||
|
|
||||||
task = asyncio.create_task(background_mod.run_loop(agent))
|
task = asyncio.create_task(background_mod.run_loop(agent))
|
||||||
logger.info("Lifespan: Trigger-Loop gestartet")
|
logger.info("Lifespan: Trigger-Loop gestartet")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -131,6 +131,54 @@ SEED_RULES: List[dict] = [
|
|||||||
"Skill-Friedhof und Stefan muss aufraeumen."
|
"Skill-Friedhof und Stefan muss aufraeumen."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/fast-patterns-for-control",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: fast_patterns fuer reines Steuern (spart 5-10s Latenz)",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Wenn Du einen Skill baust oder aktualisierst, der **reine Steuer-"
|
||||||
|
"Befehle** behandelt (Licht an/aus, Spotify pause/next, Rollade "
|
||||||
|
"hoch/runter, Heizung +1°), trag ins Manifest `fast_patterns` ein. "
|
||||||
|
"Format pro Eintrag: `{match: \"^regex$\", args: {...}, reply: \"Text\"}`.\n"
|
||||||
|
"\n"
|
||||||
|
"Wirkung: das Brain matched eingehende User-Texte BEVOR Claude gerufen "
|
||||||
|
"wird. Match → run_skill(name, args) direkt → reply zurueck → Claude "
|
||||||
|
"uebersprungen. Stefan spart 5-10 Sekunden pro Befehl. Praktisch "
|
||||||
|
"Pflicht im Auto, wo Latenz nervt.\n"
|
||||||
|
"\n"
|
||||||
|
"REGELN beim Patterns schreiben:\n"
|
||||||
|
" - Mit `^` und `$` anchorn — sonst matched `pause` mitten in `pause "
|
||||||
|
"die musik dann erzaehl mir nen witz` und zerschiesst den Befehl.\n"
|
||||||
|
" - Case-insensitive (Brain matched mit re.IGNORECASE), Endsatzzeichen "
|
||||||
|
"werden vorher entfernt — schreibe Lowercase ohne Punkt.\n"
|
||||||
|
" - Mehrere Varianten = mehrere Eintraege (`^pause$`, `^pausiere$`, "
|
||||||
|
"`^stop$`). Sprachlich wechselt Stefan zwischen synonymen Kurzformen.\n"
|
||||||
|
" - reply = kurze Bestaetigung in genau einem Satz, gerne mit Emoji.\n"
|
||||||
|
"\n"
|
||||||
|
"NIE fast_patterns fuer:\n"
|
||||||
|
" - Skills mit kreativem Output (zusammenfassen, generieren, raten).\n"
|
||||||
|
" - Skills mit Parametern die aus Freitext extrahiert werden muessten "
|
||||||
|
" ('spiele jazz' geht nicht — was ist 'jazz'? Lass Claude entscheiden).\n"
|
||||||
|
" - Skills mit Multi-Step-Logik (z.B. Volumen +10 = erst Status holen, "
|
||||||
|
" rechnen, setzen). Wenn unbedingt: in den Skill-Code packen und "
|
||||||
|
" dem Skill einen `action`-Arg geben.\n"
|
||||||
|
"\n"
|
||||||
|
"Beispiel komplett:\n"
|
||||||
|
"```\n"
|
||||||
|
"fast_patterns = [\n"
|
||||||
|
" {\"match\": \"^pause$\",\n"
|
||||||
|
" \"args\": {\"path\": \"/v1/me/player/pause\", \"method\": \"PUT\"},\n"
|
||||||
|
" \"reply\": \"Spotify: pausiert ⏸\"}\n"
|
||||||
|
"]\n"
|
||||||
|
"```\n"
|
||||||
|
"\n"
|
||||||
|
"Stefan-Hinweis 06/2026: das war frueher hardcoded in agent.py fuer "
|
||||||
|
"Spotify und musste fuer jeden neuen Steuer-Skill nachgepflegt werden. "
|
||||||
|
"Jetzt steckt's pro Skill im Manifest — dein Job, ARIA, das gleich "
|
||||||
|
"mitzudenken wenn der Use-Case passt."
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"migration_key": "seed/skill-rule/no-hardcoded-credentials",
|
"migration_key": "seed/skill-rule/no-hardcoded-credentials",
|
||||||
"type": "rule",
|
"type": "rule",
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ def create_skill(
|
|||||||
pip_packages: Optional[list[str]] = None,
|
pip_packages: Optional[list[str]] = None,
|
||||||
author: str = "aria",
|
author: str = "aria",
|
||||||
config_schema: Optional[list] = None,
|
config_schema: Optional[list] = None,
|
||||||
|
fast_patterns: Optional[list] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
||||||
|
|
||||||
@@ -213,6 +214,7 @@ def create_skill(
|
|||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"author": author,
|
"author": author,
|
||||||
"config_schema": _normalize_config_schema(config_schema),
|
"config_schema": _normalize_config_schema(config_schema),
|
||||||
|
"fast_patterns": _normalize_fast_patterns(fast_patterns),
|
||||||
"version_history": [],
|
"version_history": [],
|
||||||
}
|
}
|
||||||
write_manifest(name, manifest)
|
write_manifest(name, manifest)
|
||||||
@@ -261,6 +263,38 @@ def _normalize_config_schema(schema: Optional[list]) -> list:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_fast_patterns(patterns: Optional[list]) -> list:
|
||||||
|
"""Filter + Normalisiert fast_patterns. Erwartet Liste von Dicts mit:
|
||||||
|
- match (str) : Regex, wird gegen normalisierten User-Text (lowercase,
|
||||||
|
Endsatzzeichen weg, Whitespace gestrafft) gematched.
|
||||||
|
Sollte mit ^...$ anchored sein damit keine Teilmatches
|
||||||
|
reinrutschen. re.IGNORECASE wird automatisch gesetzt.
|
||||||
|
- args (dict?): Args fuer run_skill — leerer Dict wenn weggelassen.
|
||||||
|
- reply (str) : Fixe Antwort die ohne Claude an den User geht.
|
||||||
|
|
||||||
|
Patterns mit kaputter Regex werden ausgefiltert + geloggt — sonst wuerde
|
||||||
|
der ganze Fast-Path-Pass jedes Mal crashen wenn ARIA mal ein Pattern
|
||||||
|
falsch baut."""
|
||||||
|
if not patterns:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for p in patterns:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
match = (p.get("match") or "").strip()
|
||||||
|
reply = (p.get("reply") or "").strip()
|
||||||
|
if not match or not reply:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
re.compile(match)
|
||||||
|
except re.error as exc:
|
||||||
|
logger.warning("fast_patterns: Regex %r kaputt — geskippt: %s", match, exc)
|
||||||
|
continue
|
||||||
|
args = p.get("args") if isinstance(p.get("args"), dict) else {}
|
||||||
|
out.append({"match": match, "args": args, "reply": reply[:300]})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
|
def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
|
||||||
venv = skill_dir / "venv"
|
venv = skill_dir / "venv"
|
||||||
logger.info("venv erstellen: %s", venv)
|
logger.info("venv erstellen: %s", venv)
|
||||||
@@ -307,6 +341,8 @@ def update_skill(name: str, patch: dict) -> dict:
|
|||||||
manifest[k] = v
|
manifest[k] = v
|
||||||
if "config_schema" in patch:
|
if "config_schema" in patch:
|
||||||
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
||||||
|
if "fast_patterns" in patch:
|
||||||
|
manifest["fast_patterns"] = _normalize_fast_patterns(patch["fast_patterns"])
|
||||||
|
|
||||||
# Code austauschen
|
# Code austauschen
|
||||||
if "entry_code" in patch and patch["entry_code"]:
|
if "entry_code" in patch and patch["entry_code"]:
|
||||||
|
|||||||
Reference in New Issue
Block a user