Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -575,9 +575,19 @@ def _dashboard_perception_recent_getter(


def _dashboard_state_getter() -> str:
"""Return the current State of Dotty. Without a perception bus the
bridge can't know — fall through to 'idle' so the dashboard renders
safely. Real state is mirrored on dotty-behaviour."""
"""Return the current State mirrored by dotty-behaviour.

The deployment has one robot, so use the first valid per-device state.
If behaviour is unavailable or has not observed a state transition yet,
retain the dashboard's historical safe fallback of ``idle``.
"""
states = _dashboard_perception_state_getter()
for device_state in states.values():
if not isinstance(device_state, dict):
continue
current = device_state.get("current_state")
if isinstance(current, str) and current:
return current
return "idle"


Expand Down
24 changes: 14 additions & 10 deletions custom-providers/pi_voice/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,16 +147,20 @@ LLM providers.
pi's `docs/rpc.md`; `assistantMessageEvent` filtering rule from the
spike telemetry.

## Open questions still on the table

- **Memory write-back.** PiVoiceLLM does not yet persist conversation
turns or remember-markers. Memory write-back belongs in the pi
extension: a small write (sqlite_brain_db.write) triggered by a
`[REMEMBER: …]` marker in the final assistant text, plus a per-turn
log row.
- **Persona file location.** The pi extension reads from
`/mnt/user/appdata/dotty-pi/persona/` (bind-mounted into the container).
Wiring is stable; runtime persona-swap mechanism TBD.
## Runtime prompt policy and persona state

The production Pi command uses `--no-context-files`, so files under the
bind-mounted `/root/.pi/persona/` tree are intentionally not loaded into voice
turns. They are live operator state and `deploy-dotty-pi.sh` does not overwrite
them. Voice-critical routing and output policy must therefore live in the
versioned `PiVoiceLLM` per-turn prompt (`pi_voice.py` and `textUtils.py`), which
is shipped by `deploy-bridge-unraid.sh`. Do not rely on a repo persona edit for
a production voice fix unless a separate, explicit persona-management workflow
is introduced.

Conversation logging and explicit memory writes are implemented by the
`dotty-pi-ext` `agent_end` listener and `remember` tools respectively; the old
`[REMEMBER: …]` marker protocol is not part of the live PiVoiceLLM path.

## See also

Expand Down
17 changes: 13 additions & 4 deletions custom-providers/pi_voice/pi_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,19 @@ def iter_turn_text(self, prompt: str) -> Iterator[str]:

if ftype == "message_update":
ame = frame.get("assistantMessageEvent")
if isinstance(ame, dict) and ame.get("type") == "text_delta":
delta = ame.get("delta")
if isinstance(delta, str) and delta:
yield delta
if isinstance(ame, dict):
if ame.get("type") == "text_delta":
delta = ame.get("delta")
if isinstance(delta, str) and delta:
yield delta
elif ame.get("type") == "toolcall_end":
tool_call = ame.get("toolCall")
if isinstance(tool_call, dict):
logger.info(
"PiClient: tool call name=%s id=%s",
tool_call.get("name", "unknown"),
tool_call.get("id", "unknown"),
)
# thinking_delta, thinking_start, thinking_end and any
# other message_update sub-types are filtered out here.
continue
Expand Down
116 changes: 107 additions & 9 deletions custom-providers/pi_voice/pi_voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@

from __future__ import annotations

import json
import os
import unicodedata
from pathlib import Path
from typing import Iterator

from .pi_client import PiClient, PiClientError, make_default_pi_client
Expand Down Expand Up @@ -61,6 +64,8 @@ def setup_logging(): # type: ignore[no-redef]
# by absolute path. Both code paths end up with the same module.
try:
from core.utils.textUtils import ( # type: ignore
ALLOWED_EMOJIS,
FALLBACK_EMOJI,
build_turn_suffix,
filter_tts_stream,
)
Expand All @@ -73,6 +78,8 @@ def setup_logging(): # type: ignore[no-redef]
assert _spec is not None and _spec.loader is not None
_tu = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(_tu)
ALLOWED_EMOJIS = _tu.ALLOWED_EMOJIS # type: ignore[attr-defined]
FALLBACK_EMOJI = _tu.FALLBACK_EMOJI # type: ignore[attr-defined]
build_turn_suffix = _tu.build_turn_suffix # type: ignore[attr-defined]
filter_tts_stream = _tu.filter_tts_stream # type: ignore[attr-defined]

Expand All @@ -82,6 +89,18 @@ def setup_logging(): # type: ignore[no-redef]


def _read_kid_mode() -> bool:
"""Read the shared runtime toggle, falling back to startup config."""
state_file = Path(os.environ.get(
"DOTTY_KID_MODE_STATE", "/var/lib/dotty-bridge/state/kid-mode",
))
try:
value = state_file.read_text().strip().lower()
if value in ("true", "1", "yes"):
return True
if value in ("false", "0", "no"):
return False
except OSError:
pass
return os.environ.get("DOTTY_KID_MODE", "true").lower() in ("1", "true", "yes")


Expand All @@ -91,18 +110,95 @@ def _last_user_text(dialogue: list[dict]) -> str:
entry is the utterance we want pi to react to."""
for msg in reversed(dialogue):
if msg.get("role") == "user":
return str(msg.get("content") or "")
return _normalise_user_content(msg.get("content"))
return ""


def _normalise_user_content(content: object) -> str:
"""Extract xiaozhi's user text without leaking its JSON envelope to pi.

Depending on where the dialogue was assembled, ``content`` may already be
plain text, a ``{"content": "..."}`` mapping, or the JSON encoding of that
mapping. Unknown JSON is kept verbatim: silently discarding or reshaping a
genuine user utterance would be worse than passing it through.
"""
if isinstance(content, dict):
inner = content.get("content")
return inner if isinstance(inner, str) else str(content or "")
if not isinstance(content, str):
return str(content or "")
stripped = content.strip()
if stripped.startswith("{") and stripped.endswith("}"):
try:
decoded = json.loads(stripped)
except json.JSONDecodeError:
return content
if isinstance(decoded, dict) and isinstance(decoded.get("content"), str):
return decoded["content"]
return content


_VOICE_TOOL_ROUTING = (
"\n\nVOICE TOOL ROUTING: Decide whether to use a registered tool before "
"composing the spoken reply. Use the matching tool when the user asks you "
"to remember or recall, play a song, look or take a photo, or solve a "
"question that needs careful reasoning. Call it first and use its result. "
"Never claim an action succeeded unless its tool succeeded. The reply "
"constraints below apply only to final spoken text, not to tool calls."
)


def _wrap_with_sandwich(user_text: str, kid_mode: bool) -> str:
"""Append the HARD CONSTRAINTS suffix to the user's text via the shared
textUtils.build_turn_suffix contract — emoji-prefix
rule, English-only, length caps, kid-mode topic filtering. Without
this Dotty drifts into Chinese, multi-paragraph replies, and (in
kid_mode) unsafe topics, since qwen3.5:4b's base behaviour doesn't
encode any of those constraints."""
return user_text + build_turn_suffix(kid_mode)
return user_text + _VOICE_TOOL_ROUTING + build_turn_suffix(kid_mode)


def _enforce_leading_emoji(chunks: Iterator[str]) -> Iterator[str]:
"""Guarantee the firmware's leading-glyph face contract.

Pi is prompted to start with an allowed emoji, but model compliance is not
an output guarantee. Buffer only leading whitespace, then either pass an
allowed emoji through or replace a missing/disallowed leading glyph with
the neutral fallback before the model text.
"""
leading: list[str] = []
saw_content = False
for chunk in chunks:
if not chunk:
continue
if not saw_content:
leading.append(chunk)
so_far = "".join(leading).lstrip()
if not so_far:
continue
saw_content = True
if not any(so_far.startswith(emoji) for emoji in ALLOWED_EMOJIS):
yield f"{FALLBACK_EMOJI} "
# Do not leave a disallowed model emoji after the fallback:
# `😐 ❤️ hello` still violates the exactly-one-face contract.
# Consume the leading symbol plus emoji presentation/joiner
# codepoints, while leaving ordinary punctuation and text.
if so_far and unicodedata.category(so_far[0]) == "So":
end = 1
while end < len(so_far) and (
so_far[end] in ("\ufe0f", "\u200d")
or 0x1F3FB <= ord(so_far[end]) <= 0x1F3FF
or unicodedata.category(so_far[end]) == "So"
):
end += 1
so_far = so_far[end:].lstrip()
if so_far:
yield so_far
continue
yield chunk

if not saw_content:
yield f"{FALLBACK_EMOJI} (no response)"


class LLMProvider(LLMProviderBase):
Expand All @@ -112,9 +208,8 @@ def __init__(self, config: dict, *, client: PiClient | None = None):
self._container = config.get("container_name") or os.environ.get(
"DOTTY_PI_CONTAINER", "dotty-pi",
)
# KID_MODE is a process-start snapshot. Toggling kid_mode requires
# a container restart, which already happens via the bridge's
# existing restart path.
# Initial value is logged for diagnostics. response() refreshes this
# from the bridge/xiaozhi shared state file on every turn.
self._kid_mode = _read_kid_mode()
# `client` is injected by tests; production passes None to get
# the env-configured default.
Expand All @@ -129,9 +224,10 @@ def __init__(self, config: dict, *, client: PiClient | None = None):
# xiaozhi-server's voice loop calls this as a sync generator.
# Each yielded string becomes a TTS chunk.
def response(self, session_id, dialogue, **kwargs) -> Iterator[str]:
self._kid_mode = _read_kid_mode()
user_text = _last_user_text(dialogue)
if not user_text:
yield "(empty turn)"
yield f"{FALLBACK_EMOJI} (empty turn)"
return
prompt = _wrap_with_sandwich(user_text, self._kid_mode)

Expand All @@ -148,9 +244,11 @@ def response(self, session_id, dialogue, **kwargs) -> Iterator[str]:
# #157: kid-mode blocked-content filter on TTS-bound output.
# Full-turn buffered — the filter drains the pi RPC stream through
# agent_end before making an atomic allow/replace decision.
# kid_mode off is a transparent passthrough.
# Emoji enforcement precedes filtering, matching OpenAICompat: in
# kid mode the filter still makes one atomic whole-turn decision;
# outside it, chunks stream after the first meaningful one.
for chunk in filter_tts_stream(
self._client.iter_turn_text(prompt),
_enforce_leading_emoji(self._client.iter_turn_text(prompt)),
self._kid_mode,
on_hit=self._on_filter_hit,
):
Expand All @@ -159,7 +257,7 @@ def response(self, session_id, dialogue, **kwargs) -> Iterator[str]:
logger.error("PiVoiceLLM turn failed: %s", exc)
for line in self._client.recent_stderr()[-5:]:
logger.error(" pi.stderr: %s", line)
yield "(brain offline — try again in a moment)"
yield f"{FALLBACK_EMOJI} (brain offline — try again in a moment)"

def _on_filter_hit(self, tier: str, match) -> None:
# Local logging only — the Prometheus counter / safety ring live in
Expand Down
32 changes: 32 additions & 0 deletions custom-providers/pi_voice/tests/test_pi_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,38 @@ def feed():
client.close()


class TestToolCallTelemetry(unittest.TestCase):
def test_completed_tool_call_is_logged_at_info_without_arguments(self):
fake = FakePopen()
client = make_client(fake)
try:
def feed():
fake.emit({
"id": "turn-1", "type": "response",
"command": "prompt", "success": True,
})
fake.emit({
"type": "message_update",
"assistantMessageEvent": {
"type": "toolcall_end",
"toolCall": {
"type": "toolCall", "id": "tool-7",
"name": "remember", "arguments": {"fact": "private"},
},
},
})
fake.emit({"type": "agent_end"})

threading.Thread(target=feed, daemon=True).start()
with self.assertLogs("pi_client", level="INFO") as logs:
self.assertEqual(list(client.iter_turn_text("remember this")), [])
joined = "\n".join(logs.output)
self.assertIn("tool call name=remember id=tool-7", joined)
self.assertNotIn("private", joined)
finally:
client.close()


class TestUiAutoCancel(unittest.TestCase):
def test_dialog_methods_get_auto_cancelled(self):
fake = FakePopen()
Expand Down
Loading
Loading