feat(tts): System-Flag speak (ja/nein) pro Antwort statt <voice>-Tag-Hack

Stefans Idee: die QUELLE entscheidet ueber Vorlesen, nicht der Reply-Inhalt.
Fast-Path (reiner Steuerbefehl) = speak=False (stumm); ARIA-Antworten
(local/claude) = speak=True (vorlesen ok — eine Ansage ist sogar nett).

- agent.chat() gibt jetzt (reply, answered_by, speak) zurueck; main.py
  ChatOut.speak; background.py-Aufrufer angepasst.
- bridge: liest speak aus /chat, reicht es an _process_core_response; bei
  speak=False wird TTS uebersprungen — VOR jeder <voice>-Logik.

Robust: unabhaengig davon, ob die Skill-fast_pattern-Reply ein <voice></voice>
enthaelt (das verlor ARIA beim semantischen Skill-Rebuild — genau der Bug).
App braucht keinen Change: kein xtts_request -> kein Audio -> stumm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 14:55:12 +02:00
co-authored by Claude Opus 4.8
parent 2dfe6fd9c3
commit 0a2e59d756
4 changed files with 23 additions and 6 deletions
+5 -3
View File
@@ -1299,7 +1299,9 @@ class Agent:
self.conversation.add("assistant", fast_reply, project_id=active_project_id) self.conversation.add("assistant", fast_reply, project_id=active_project_id)
if active_project_id: if active_project_id:
projects_mod.touch_project(active_project_id) projects_mod.touch_project(active_project_id)
return fast_reply, "fast-path" # Fast-Path = reiner Steuerbefehl → NICHT vorlesen (speak=False).
# System-Flag statt <voice>-Tag: robust, unabhaengig vom Skill-Inhalt.
return fast_reply, "fast-path", False
# 1. User-Turn an die Konversation # 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source, self.conversation.add("user", user_message, source=source,
@@ -1313,7 +1315,7 @@ class Agent:
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude. # teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
local_reply = self._try_local_fast_lane(user_message, active_project_id) local_reply = self._try_local_fast_lane(user_message, active_project_id)
if local_reply is not None: if local_reply is not None:
return local_reply, "local" return local_reply, "local", True # ARIA-Antwort → vorlesen ok
# 2. Hot Memory (alle pinned Punkte) # 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned() hot = self.store.list_pinned()
@@ -1514,7 +1516,7 @@ class Agent:
# 7. Assistant-Turn (final reply) in die Conversation # 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply, self.conversation.add("assistant", final_reply,
project_id=active_project_id) project_id=active_project_id)
return final_reply, "claude" return final_reply, "claude", True # ARIA-Antwort → vorlesen ok
# ── Tool-Dispatcher ─────────────────────────────────────── # ── Tool-Dispatcher ───────────────────────────────────────
+1 -1
View File
@@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None:
try: try:
agent = agent_factory() agent = agent_factory()
reply, _ = agent.chat(prompt, source="trigger") reply, _, _ = agent.chat(prompt, source="trigger")
events = agent.pop_events() events = agent.pop_events()
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80]) logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]}) triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
+5 -1
View File
@@ -633,6 +633,9 @@ class ChatOut(BaseModel):
# Welcher Backend die Antwort erzeugt hat: "local" (Qwen), "claude", # Welcher Backend die Antwort erzeugt hat: "local" (Qwen), "claude",
# "fast-path" (Skill/Regex). Fuer den Quell-Badge in Diagnostic. # "fast-path" (Skill/Regex). Fuer den Quell-Badge in Diagnostic.
answered_by: str = "claude" answered_by: str = "claude"
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
speak: bool = True
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die # Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der # ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet. # UI landet.
@@ -716,7 +719,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken — # Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen. # chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
reply, answered_by = await loop.run_in_executor( reply, answered_by, speak = await loop.run_in_executor(
None, None,
lambda: a.chat( lambda: a.chat(
body.message, source=body.source, project_id=pid, body.message, source=body.source, project_id=pid,
@@ -739,6 +742,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
events=a.pop_events(), events=a.pop_events(),
project_id=pid, project_id=pid,
answered_by=answered_by, answered_by=answered_by,
speak=speak,
) )
finally: finally:
_project_pending[pid] = [ _project_pending[pid] = [
+12 -1
View File
@@ -1313,6 +1313,13 @@ class ARIABridge:
"timestamp": int(asyncio.get_event_loop().time() * 1000), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
# System-Flag vom Brain: reine Steuerbefehle (Fast-Path) NICHT vorlesen.
# Robust und unabhaengig von <voice>-Tags im Text (die ARIA beim
# Skill-Rebuild verlieren kann) — die Quelle entscheidet, nicht der Inhalt.
if isinstance(payload, dict) and payload.get("speak") is False:
logger.info("[core] TTS uebersprungen (speak=False — Fast-Path-Steuerbefehl)")
return
# TTS ueber XTTS (XTTS-Bridge auf Gaming-PC) # TTS ueber XTTS (XTTS-Bridge auf Gaming-PC)
if not (getattr(self, 'tts_enabled', True) and should_speak(self.current_mode, is_critical)): if not (getattr(self, 'tts_enabled', True) and should_speak(self.current_mode, is_critical)):
logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name) logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name)
@@ -1640,6 +1647,9 @@ class ARIABridge:
# Welcher Backend geantwortet hat (local/claude/fast-path) — fuer den # Welcher Backend geantwortet hat (local/claude/fast-path) — fuer den
# Quell-Badge in Diagnostic. # Quell-Badge in Diagnostic.
answered_by = (data.get("answered_by") or "claude").strip() answered_by = (data.get("answered_by") or "claude").strip()
# Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag
# vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text.
speak = data.get("speak", True)
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created) # Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen # damit sie in der UI vor der Reply auftauchen
@@ -1707,7 +1717,8 @@ class ARIABridge:
# metadata mitschickt). # metadata mitschickt).
try: try:
await self._process_core_response(reply, {"projectId": turn_project_id, await self._process_core_response(reply, {"projectId": turn_project_id,
"answeredBy": answered_by}) "answeredBy": answered_by,
"speak": speak})
except Exception: except Exception:
logger.exception("[brain] _process_core_response Fehler") logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id) await self._emit_activity("idle", "", project_id=project_id)