Ctrl K
ComunidadGuías…

sim_persona.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Stand-in for Syl: tail a manual-inbox.jsonl, write a reply per message.

Replaces the persona + bridge + FileOutbound for mic-free, session-free tests of
the voice loop. It tails the same inbox `bot-spren send` (or local_sim_send)
writes, and appends a reply to the reply file in FileOutbound's exact block
format, so brain_via_bridge() cannot tell it from a real persona.

The "brain" here is a trivial canned responder — enough to prove the
wake -> STT -> brain -> TTS loop end to end. The real brain is Syl.

Use as a daemon thread inside a test (SimPersona(...).start()), or as a
standalone process: `python sim_persona.py --inbox <f> --reply <f>`.
"""

from __future__ import annotations

import argparse
import json
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable


def canned_reply(text: str) -> str:
    """A trivial deterministic brain for loop tests. The real brain is Syl."""
    t = text.lower()
    if "two plus two" in t or "2 plus 2" in t or "2+2" in t:
        return "Two plus two is four."
    if "capital of france" in t:
        return "The capital of France is Paris."
    if "time" in t:
        return "I cannot check the time yet, but that is coming."
    # Echo so a test can assert the transcript made the round trip.
    return f"You said: {text.strip()}"


def file_outbound_append(
    reply_path: Path, payload: str, delivery_id: str, event_id: str | None = None
) -> None:
    """Append one block in FileOutbound's exact on-disk format.

    When event_id is given, stamp it into the header so the bridge can match the
    reply to its request by identity.
    """
    ts = datetime.now(timezone.utc).isoformat()
    reply_path.parent.mkdir(parents=True, exist_ok=True)
    header = f"--- {ts} delivery_id={delivery_id}"
    if event_id is not None:
        header += f" event_id={event_id}"
    header += " ---"
    with reply_path.open("a", encoding="utf-8") as f:
        f.write(f"\n{header}\n{payload}\n")


class SimPersona:
    """Tail an inbox file and reply to each new line. Use as a daemon thread."""

    def __init__(
        self,
        inbox_path: str | Path,
        reply_path: str | Path,
        reply_fn: Callable[[str], str] = canned_reply,
        poll_s: float = 0.2,
        echo_event_id: bool = True,
    ) -> None:
        self.inbox = Path(inbox_path)
        self.reply = Path(reply_path)
        self.reply_fn = reply_fn
        self.poll_s = poll_s
        # Echo each request's event_id into its reply block header so the bridge can
        # match by identity. Tests can turn this off to exercise the legacy positional
        # fallback.
        self.echo_event_id = echo_event_id
        self._offset = 0
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None

    def _tick(self) -> None:
        if not self.inbox.exists():
            return
        data = self.inbox.read_bytes()
        if len(data) <= self._offset:
            return
        new = data[self._offset :]
        nl = new.rfind(b"\n")
        if nl == -1:
            return  # only a partial line so far
        complete = new[: nl + 1]
        self._offset += len(complete)
        for raw in complete.split(b"\n"):
            if not raw.strip():
                continue
            try:
                entry = json.loads(raw.decode())
                payload = entry["payload"]
            except (json.JSONDecodeError, KeyError, UnicodeDecodeError):
                continue
            # Strip the optional "system\n\nUser: <text>" wrapper to get the words.
            text = payload.split("User: ", 1)[-1] if "User: " in payload else payload
            eid = entry.get("event_id") if self.echo_event_id else None
            file_outbound_append(
                self.reply, self.reply_fn(text), str(uuid.uuid4()), event_id=eid
            )

    def start(self) -> None:
        self._thread = threading.Thread(
            target=self._run, daemon=True, name="sim-persona"
        )
        self._thread.start()

    def _run(self) -> None:
        while not self._stop.wait(self.poll_s):
            self._tick()

    def stop(self) -> None:
        self._stop.set()
        if self._thread is not None:
            self._thread.join(timeout=2)


def main() -> int:
    p = argparse.ArgumentParser(description="Syl stand-in for voice-loop tests")
    p.add_argument("--inbox", required=True, help="manual-inbox.jsonl to tail")
    p.add_argument("--reply", required=True, help="reply file to append to")
    args = p.parse_args()
    sim = SimPersona(args.inbox, args.reply)
    sim.start()
    print(f"sim_persona watching {args.inbox} -> {args.reply} (Ctrl-C to stop)")
    try:
        if sim._thread is not None:
            sim._thread.join()
    except KeyboardInterrupt:
        sim.stop()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())