onju-v2/pipeline/conversation/__init__.py
justLV dccb6ced15 Stream agentic LLM responses, add contextual stall classifier, rename backends
- SSE sentence-level streaming: consume agent deltas, split on sentence
  boundaries (handles no-space chunk joins), synthesize+send each sentence
  as it forms; intermediate sends keep mic_timeout=0
- Gemini-backed stall classifier for agentic mode only: narrow to
  retrieval-only, pass prev user/assistant for context awareness, avoid
  action promises the stall can't honor, sub-second latency via
  reasoning_effort=none
- Rename backends: local -> conversational, managed -> agentic
  (files, classes, config keys)
- PTT interrupt fix: set device.interrupted when button-press frames
  arrive mid-response and keep buffering so the next utterance captures
  cleanly instead of being dropped
- Startup summary log showing ASR, LLM, STALL, and TTS config at a glance
- run.sh launcher with Homebrew libopus path for macOS
- voice_prompt config for per-turn agentic reminders; inline continuity
  note injection so the agent knows what the stall just said aloud
- README section on streaming, stalls, and the first-turn OpenClaw caveat
2026-04-12 13:55:59 -07:00

47 lines
1.8 KiB
Python

import re
from typing import AsyncIterator
from pipeline.conversation.base import ConversationBackend
from pipeline.conversation.conversational import ConversationalBackend
from pipeline.conversation.agentic import AgenticBackend
# Primary: punctuation followed by whitespace (safe, standard).
_SENTENCE_END = re.compile(r"[.!?\n]+\s+")
# Fallback: punctuation with no space, but only when preceded by a lowercase
# letter and followed by uppercase. Catches OpenClaw's chunk-boundary joins
# ("now.The") without breaking abbreviations like "U.S." (uppercase before dot).
_SENTENCE_END_NOSPACE = re.compile(r"(?<=[a-z])[.!?]+(?=[A-Z])")
async def sentence_chunks(deltas: AsyncIterator[str]) -> AsyncIterator[str]:
"""Buffer text deltas and yield one sentence at a time, plus any trailing
fragment when the stream ends."""
buffer = ""
async for delta in deltas:
buffer += delta
while True:
m = _SENTENCE_END.search(buffer)
if not m:
m = _SENTENCE_END_NOSPACE.search(buffer)
if not m:
break
sentence = buffer[: m.end()].strip()
buffer = buffer[m.end():]
if sentence:
yield sentence
tail = buffer.strip()
if tail:
yield tail
def create_backend(config: dict, device_id: str) -> ConversationBackend:
"""Create a conversation backend based on config."""
conv_cfg = config["conversation"]
backend = conv_cfg.get("backend", "conversational")
if backend == "conversational":
return ConversationalBackend(conv_cfg["conversational"], device_id)
elif backend == "agentic":
return AgenticBackend(conv_cfg["agentic"], device_id)
else:
raise ValueError(f"Unknown conversation backend: {backend}")