Deploy Decompress MiniCPM demo
Browse files- README.md +69 -6
- app.py +19 -0
- apps/__init__.py +1 -0
- apps/decompress/__init__.py +1 -0
- apps/decompress/app.py +123 -0
- apps/decompress/corpus/stress_sources.json +44 -0
- apps/decompress/runtime.py +1021 -0
- apps/decompress/static/decompress.css +432 -0
- apps/decompress/static/decompress.js +20 -0
- engine/CONTROLLER_NOTES.md +42 -0
- engine/CONVERSATION_NOTES.md +58 -0
- engine/__init__.py +2 -0
- engine/bocpd.py +144 -0
- engine/brain.py +119 -0
- engine/controller.py +321 -0
- engine/conversation.py +259 -0
- engine/live_brain.py +151 -0
- engine/probe.py +240 -0
- engine/traces.py +180 -0
- engine/viz.py +98 -0
- requirements.txt +8 -0
README.md
CHANGED
|
@@ -1,13 +1,76 @@
|
|
| 1 |
---
|
| 2 |
title: Decompress
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.18.0
|
| 8 |
-
python_version: '3.
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Decompress
|
| 3 |
+
emoji: 🌿
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.18.0
|
| 8 |
+
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
+
startup_duration_timeout: 45min
|
| 11 |
+
pinned: true
|
| 12 |
+
license: mit
|
| 13 |
+
short_description: A MiniCPM voice companion that knows when not to talk.
|
| 14 |
+
tags:
|
| 15 |
+
- gradio
|
| 16 |
+
- build-small-hackathon
|
| 17 |
+
- stress-management
|
| 18 |
+
- wellness
|
| 19 |
+
- full-duplex
|
| 20 |
+
- training-free
|
| 21 |
+
- rag
|
| 22 |
+
- track:backyard
|
| 23 |
+
- sponsor:openbmb
|
| 24 |
+
- sponsor:cohere
|
| 25 |
+
- sponsor:modal
|
| 26 |
+
- sponsor:openai
|
| 27 |
+
- achievement:offbrand
|
| 28 |
+
- tiny-titan
|
| 29 |
+
- minicpm
|
| 30 |
+
- cohere
|
| 31 |
+
models:
|
| 32 |
+
- openbmb/MiniCPM3-4B
|
| 33 |
+
- CohereLabs/cohere-transcribe-03-2026
|
| 34 |
+
- hexgrad/Kokoro-82M
|
| 35 |
+
- sentence-transformers/all-MiniLM-L6-v2
|
| 36 |
---
|
| 37 |
|
| 38 |
+
# Decompress
|
| 39 |
+
|
| 40 |
+
Decompress is a calm, bounded voice/text check-in companion. The point is not
|
| 41 |
+
just what it says; it is when it chooses not to say anything. The same
|
| 42 |
+
training-free when-to-speak controller from Pitch or Perish drives the timing:
|
| 43 |
+
MiniCPM exposes surprise, hidden-state deltas, readiness, and turn-end
|
| 44 |
+
probability, then the controller waits, backchannels, or responds at the pause.
|
| 45 |
+
|
| 46 |
+
## Backyard Use
|
| 47 |
+
|
| 48 |
+
This is the practical track version of the method: a small companion for a
|
| 49 |
+
five-minute decompression ritual after work, before sleep, or between stressful
|
| 50 |
+
tasks. It gives one grounded next step and cites the source used by the RAG
|
| 51 |
+
retriever. It is wellness support only, not medical advice, diagnosis, therapy,
|
| 52 |
+
or crisis care.
|
| 53 |
+
|
| 54 |
+
## Tiny Titan / Sponsor Stack
|
| 55 |
+
|
| 56 |
+
- **OpenBMB:** `openbmb/MiniCPM3-4B` is the load-bearing brain for NLL,
|
| 57 |
+
hidden states, readiness, and generated companion lines.
|
| 58 |
+
- **Cohere:** `CohereLabs/cohere-transcribe-03-2026` powers push-to-talk voice
|
| 59 |
+
input before the exact same text-streamed controller runs.
|
| 60 |
+
- **Modal:** MiniCPM and Cohere ASR run as protected A10G endpoints with one
|
| 61 |
+
warm container during judging.
|
| 62 |
+
- **OpenAI:** the repo history includes Codex-attributed implementation commits.
|
| 63 |
+
- **Small weights:** the app uses <=4B LLM/ASR models, Kokoro-82M for CPU TTS,
|
| 64 |
+
and all-MiniLM-L6-v2 for local RAG retrieval.
|
| 65 |
+
|
| 66 |
+
## Sources
|
| 67 |
+
|
| 68 |
+
The shipped corpus is intentionally small and conservative. It paraphrases
|
| 69 |
+
open-access stress-management, CBT, breathwork, and grounding sources from WHO,
|
| 70 |
+
NHS, NIH NCCIH, University of Rochester Medical Center, and Scientific Reports,
|
| 71 |
+
with citations displayed in the UI.
|
| 72 |
+
|
| 73 |
+
## Links
|
| 74 |
+
|
| 75 |
+
- Demo video: PLACEHOLDER - add recording link after capture.
|
| 76 |
+
- Social post: PLACEHOLDER - add post link after publishing.
|
app.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
from apps.decompress.app import CSS, JS, build_app
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
demo = build_app()
|
| 9 |
+
demo.queue(default_concurrency_limit=4)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
if __name__ == "__main__":
|
| 13 |
+
demo.launch(
|
| 14 |
+
server_name="0.0.0.0",
|
| 15 |
+
server_port=int(os.getenv("PORT", "7860")),
|
| 16 |
+
show_error=True,
|
| 17 |
+
css=CSS,
|
| 18 |
+
js=JS,
|
| 19 |
+
)
|
apps/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
apps/decompress/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Decompress Gradio app package."""
|
apps/decompress/app.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Iterator
|
| 8 |
+
|
| 9 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 10 |
+
if str(ROOT) not in sys.path:
|
| 11 |
+
sys.path.insert(0, str(ROOT))
|
| 12 |
+
|
| 13 |
+
import gradio as gr
|
| 14 |
+
|
| 15 |
+
from apps.decompress import runtime
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
APP_DIR = Path(__file__).resolve().parent
|
| 19 |
+
CSS = (APP_DIR / "static" / "decompress.css").read_text(encoding="utf-8")
|
| 20 |
+
JS = (APP_DIR / "static" / "decompress.js").read_text(encoding="utf-8")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def stream_checkin(checkin_text: str, tau: float, voice_enabled: bool) -> Iterator[tuple[str, str | None, dict]]:
|
| 24 |
+
yield from runtime.run_checkin_stream(checkin_text, tau, enable_tts=voice_enabled)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def stream_voice_checkin(audio_clip: str | None, tau: float, voice_enabled: bool) -> Iterator[tuple[str, str | None, dict, str]]:
|
| 28 |
+
yield from runtime.run_voice_checkin_stream(audio_clip, tau, enable_tts=voice_enabled)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def build_app() -> gr.Blocks:
|
| 32 |
+
initial_tau = float(os.getenv("DECOMPRESS_TAU", "0.92"))
|
| 33 |
+
initial_html = (
|
| 34 |
+
runtime.preview_room_html(initial_tau)
|
| 35 |
+
if os.getenv("DECOMPRESS_PREVIEW_STATE", "0") == "1"
|
| 36 |
+
else runtime.initial_room_html(initial_tau)
|
| 37 |
+
)
|
| 38 |
+
with gr.Blocks(title="Decompress", fill_width=True) as demo:
|
| 39 |
+
room = gr.HTML(value=initial_html, elem_id="decompress-room-output")
|
| 40 |
+
with gr.Row(elem_id="decompress-control-row"):
|
| 41 |
+
checkin = gr.Textbox(
|
| 42 |
+
value=runtime.SAMPLE_CHECKIN,
|
| 43 |
+
label="What is on your mind?",
|
| 44 |
+
lines=6,
|
| 45 |
+
max_lines=10,
|
| 46 |
+
elem_id="decompress-input",
|
| 47 |
+
)
|
| 48 |
+
with gr.Column(elem_id="decompress-controls", scale=0):
|
| 49 |
+
tau = gr.Slider(
|
| 50 |
+
minimum=0.65,
|
| 51 |
+
maximum=1.45,
|
| 52 |
+
value=initial_tau,
|
| 53 |
+
step=0.05,
|
| 54 |
+
label="Companion timing",
|
| 55 |
+
elem_id="decompress-tau",
|
| 56 |
+
)
|
| 57 |
+
voice = gr.Checkbox(
|
| 58 |
+
value=os.getenv("DECOMPRESS_TTS", "1").lower() not in {"0", "false"},
|
| 59 |
+
label="Kokoro voice",
|
| 60 |
+
elem_id="decompress-tts-toggle",
|
| 61 |
+
)
|
| 62 |
+
run = gr.Button("Start check-in", elem_id="run-decompress", variant="primary")
|
| 63 |
+
mic = gr.Audio(
|
| 64 |
+
sources=["microphone", "upload"],
|
| 65 |
+
type="filepath",
|
| 66 |
+
format="wav",
|
| 67 |
+
label="Speak your check-in",
|
| 68 |
+
elem_id="decompress-mic",
|
| 69 |
+
)
|
| 70 |
+
voice_run = gr.Button("Use voice check-in", elem_id="run-decompress-voice", variant="secondary")
|
| 71 |
+
asr_text = gr.Textbox(
|
| 72 |
+
value="",
|
| 73 |
+
label="Cohere transcript",
|
| 74 |
+
lines=3,
|
| 75 |
+
max_lines=5,
|
| 76 |
+
interactive=False,
|
| 77 |
+
elem_id="decompress-transcript",
|
| 78 |
+
)
|
| 79 |
+
audio = gr.Audio(
|
| 80 |
+
label="Latest companion voice",
|
| 81 |
+
type="filepath",
|
| 82 |
+
autoplay=True,
|
| 83 |
+
interactive=False,
|
| 84 |
+
elem_id="decompress-audio",
|
| 85 |
+
)
|
| 86 |
+
status = gr.JSON(label="Run state", visible=False)
|
| 87 |
+
|
| 88 |
+
tau.change(fn=runtime.set_live_tau, inputs=tau, outputs=None, queue=False)
|
| 89 |
+
run.click(
|
| 90 |
+
fn=stream_checkin,
|
| 91 |
+
inputs=[checkin, tau, voice],
|
| 92 |
+
outputs=[room, audio, status],
|
| 93 |
+
api_name="stream_checkin",
|
| 94 |
+
)
|
| 95 |
+
voice_run.click(
|
| 96 |
+
fn=stream_voice_checkin,
|
| 97 |
+
inputs=[mic, tau, voice],
|
| 98 |
+
outputs=[room, audio, status, asr_text],
|
| 99 |
+
api_name="stream_voice_checkin",
|
| 100 |
+
)
|
| 101 |
+
return demo
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def main(argv: list[str] | None = None) -> None:
|
| 105 |
+
parser = argparse.ArgumentParser(description="Launch the Decompress Gradio demo.")
|
| 106 |
+
parser.add_argument("--server-name", default=os.getenv("GRADIO_SERVER_NAME", "127.0.0.1"))
|
| 107 |
+
parser.add_argument("--server-port", type=int, default=int(os.getenv("GRADIO_SERVER_PORT", "7861")))
|
| 108 |
+
parser.add_argument("--share", action="store_true", default=os.getenv("GRADIO_SHARE", "0") == "1")
|
| 109 |
+
args = parser.parse_args(argv)
|
| 110 |
+
demo = build_app()
|
| 111 |
+
demo.queue(default_concurrency_limit=4)
|
| 112 |
+
demo.launch(
|
| 113 |
+
server_name=args.server_name,
|
| 114 |
+
server_port=args.server_port,
|
| 115 |
+
share=args.share,
|
| 116 |
+
show_error=True,
|
| 117 |
+
css=CSS,
|
| 118 |
+
js=JS,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
if __name__ == "__main__":
|
| 123 |
+
main()
|
apps/decompress/corpus/stress_sources.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "who-doing-what-matters",
|
| 4 |
+
"title": "Doing What Matters in Times of Stress",
|
| 5 |
+
"publisher": "World Health Organization",
|
| 6 |
+
"url": "https://www.who.int/publications/i/item/9789240003927",
|
| 7 |
+
"text": "A practical stress check-in can start by grounding attention in the present, noticing what is happening in the body and room, and then choosing one small action that fits the person's values. The guide frames these as short self-help skills for coping with adversity, not as medical treatment."
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"id": "nhs-self-help-cbt",
|
| 11 |
+
"title": "Self-help CBT techniques",
|
| 12 |
+
"publisher": "NHS Every Mind Matters",
|
| 13 |
+
"url": "https://www.nhs.uk/every-mind-matters/mental-wellbeing-tips/self-help-cbt-techniques/",
|
| 14 |
+
"text": "CBT-inspired self-help treats thoughts, feelings, and behavior as linked. For everyday stress, a useful move is to notice an unhelpful thought pattern, step back from it, and break a problem into one practical next action."
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"id": "nhs-thought-record",
|
| 18 |
+
"title": "Thought record",
|
| 19 |
+
"publisher": "NHS Every Mind Matters",
|
| 20 |
+
"url": "https://www.nhs.uk/every-mind-matters/mental-wellbeing-tips/self-help-cbt-techniques/thought-record/",
|
| 21 |
+
"text": "A thought record is a CBT exercise for writing down a situation, feelings, the unhelpful thought, evidence for and against it, and a more balanced alternative. In a brief companion flow, this can be simplified into: name the thought, name one fact, and choose a kinder neutral sentence."
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"id": "nccih-relaxation",
|
| 25 |
+
"title": "Mind and Body Approaches for Stress and Anxiety: What the Science Says",
|
| 26 |
+
"publisher": "NIH NCCIH",
|
| 27 |
+
"url": "https://www.nccih.nih.gov/health/providers/digest/mind-and-body-approaches-for-stress-science",
|
| 28 |
+
"text": "Relaxation techniques may be helpful for stress-related symptoms and are generally considered safe for healthy people, while some people can have negative experiences. Keep suggestions gentle, optional, and brief; do not present them as treatment."
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"id": "urmc-54321",
|
| 32 |
+
"title": "5-4-3-2-1 Coping Technique for Anxiety",
|
| 33 |
+
"publisher": "University of Rochester Medical Center",
|
| 34 |
+
"url": "https://www.urmc.rochester.edu/behavioral-health-partners/bhp-blog/april-2018/5-4-3-2-1-coping-technique-for-anxiety",
|
| 35 |
+
"text": "The 5-4-3-2-1 grounding exercise redirects attention to the present by naming things the person can see, feel, hear, smell, and taste. It is useful when thoughts are bouncing around and the person needs a concrete anchor."
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"id": "breathwork-meta",
|
| 39 |
+
"title": "Effect of breathwork on stress and mental health",
|
| 40 |
+
"publisher": "Scientific Reports",
|
| 41 |
+
"url": "https://www.nature.com/articles/s41598-022-27247-y",
|
| 42 |
+
"text": "A meta-analysis found breathwork may help stress and mental health outcomes, with cautions about heterogeneity. In this app, breathing prompts should stay simple and optional, such as a longer exhale or one slow breath."
|
| 43 |
+
}
|
| 44 |
+
]
|
apps/decompress/runtime.py
ADDED
|
@@ -0,0 +1,1021 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import time
|
| 8 |
+
from dataclasses import asdict, dataclass, field, replace
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Iterator
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from engine.brain import BrainSignals
|
| 15 |
+
from engine.controller import Action, ControllerConfig, ControllerTick, WhenToSpeakController
|
| 16 |
+
from engine.conversation import TranscriptChunk
|
| 17 |
+
from engine.live_brain import BrainClient, Dialogue, signals_from_raw
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 21 |
+
APP_DIR = Path(__file__).resolve().parent
|
| 22 |
+
EVAL_DIR = ROOT / "eval"
|
| 23 |
+
DECOMPRESS_LOG_PATH = EVAL_DIR / "decompress_conversation_log.json"
|
| 24 |
+
AUDIO_DIR = EVAL_DIR / "decompress_audio"
|
| 25 |
+
CORPUS_PATH = APP_DIR / "corpus" / "stress_sources.json"
|
| 26 |
+
|
| 27 |
+
AGENT_ID = "decompress"
|
| 28 |
+
DISPLAY_NAME = "Decompress"
|
| 29 |
+
DEFAULT_EMBEDDER = "sentence-transformers/all-MiniLM-L6-v2"
|
| 30 |
+
SAMPLE_CHECKIN = (
|
| 31 |
+
"I had a tense day at work and I keep replaying a meeting in my head. "
|
| 32 |
+
"My shoulders are tight, I feel behind on everything, and I just want five minutes "
|
| 33 |
+
"to come down before I try to sleep."
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
CALM_BACKCHANNELS = [
|
| 37 |
+
"Mm. Keep going.",
|
| 38 |
+
"I'm here with you.",
|
| 39 |
+
"That sounds heavy.",
|
| 40 |
+
"Take your time.",
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
FALLBACK_LINES = [
|
| 44 |
+
"Let's slow it down: one easy breath, then name one thing you can feel.",
|
| 45 |
+
"Make it smaller: unclench your jaw and choose one next tiny step.",
|
| 46 |
+
"Park one sentence on paper, then let the next exhale be longer.",
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
_LIVE_TAU = 0.92
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass(frozen=True)
|
| 53 |
+
class RetrievedSource:
|
| 54 |
+
id: str
|
| 55 |
+
title: str
|
| 56 |
+
publisher: str
|
| 57 |
+
url: str
|
| 58 |
+
text: str
|
| 59 |
+
score: float
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class DecompressStats:
|
| 64 |
+
backchannels: int = 0
|
| 65 |
+
take_floors: int = 0
|
| 66 |
+
interrupts: int = 0
|
| 67 |
+
holds: int = 0
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class DecompressState:
|
| 72 |
+
tau: float
|
| 73 |
+
brain_mode: str
|
| 74 |
+
status: str = "ready"
|
| 75 |
+
step: int = 0
|
| 76 |
+
transcript: list[dict[str, Any]] = field(default_factory=list)
|
| 77 |
+
decision: dict[str, Any] = field(default_factory=dict)
|
| 78 |
+
citations: list[dict[str, Any]] = field(default_factory=list)
|
| 79 |
+
stats: DecompressStats = field(default_factory=DecompressStats)
|
| 80 |
+
tts_error: str | None = None
|
| 81 |
+
asr_text: str | None = None
|
| 82 |
+
asr_latency_ms: float | None = None
|
| 83 |
+
asr_model_name: str | None = None
|
| 84 |
+
embedding_model: str = "not-loaded"
|
| 85 |
+
response_latency_ms: float | None = None
|
| 86 |
+
model_name: str | None = None
|
| 87 |
+
device_name: str | None = None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class EndpointDecompressBrainClient:
|
| 91 |
+
"""BrainClient backed by the protected MiniCPM Modal HTTPS endpoint."""
|
| 92 |
+
|
| 93 |
+
def __init__(self, endpoint_url: str | None = None, *, timeout_s: float = 240.0) -> None:
|
| 94 |
+
self.endpoint_url = (endpoint_url or os.environ["DECOMPRESS_BRAIN_ENDPOINT_URL"]).rstrip("/")
|
| 95 |
+
self.bearer_token = os.getenv("DECOMPRESS_BRAIN_ENDPOINT_TOKEN") or os.getenv("ENDPOINT_AUTH_TOKEN")
|
| 96 |
+
self.transcribe_endpoint_url = (
|
| 97 |
+
os.getenv("DECOMPRESS_TRANSCRIBE_ENDPOINT_URL") or self.endpoint_url
|
| 98 |
+
).rstrip("/")
|
| 99 |
+
self.transcribe_bearer_token = os.getenv("DECOMPRESS_TRANSCRIBE_ENDPOINT_TOKEN") or self.bearer_token
|
| 100 |
+
if not self.bearer_token:
|
| 101 |
+
raise RuntimeError("DECOMPRESS_BRAIN_ENDPOINT_TOKEN is required for endpoint mode")
|
| 102 |
+
self.timeout_s = timeout_s
|
| 103 |
+
|
| 104 |
+
def step(
|
| 105 |
+
self,
|
| 106 |
+
agent_id: str,
|
| 107 |
+
system_prompt: str,
|
| 108 |
+
dialogue_so_far: Dialogue,
|
| 109 |
+
new_user_text: str,
|
| 110 |
+
silence_flag: bool,
|
| 111 |
+
) -> dict[str, object]:
|
| 112 |
+
raw = self.step_many(
|
| 113 |
+
[{"agent_id": agent_id, "system_prompt": system_prompt}],
|
| 114 |
+
dialogue_so_far,
|
| 115 |
+
new_user_text,
|
| 116 |
+
silence_flag,
|
| 117 |
+
)
|
| 118 |
+
return dict(raw.get("results", {}).get(agent_id, {}))
|
| 119 |
+
|
| 120 |
+
def step_many(
|
| 121 |
+
self,
|
| 122 |
+
agent_payloads: list[dict[str, str]],
|
| 123 |
+
dialogue_so_far: Dialogue,
|
| 124 |
+
new_user_text: str,
|
| 125 |
+
silence_flag: bool,
|
| 126 |
+
) -> dict[str, object]:
|
| 127 |
+
return self._post(
|
| 128 |
+
"/step_many",
|
| 129 |
+
{
|
| 130 |
+
"agent_payloads": agent_payloads,
|
| 131 |
+
"dialogue_so_far": dialogue_so_far,
|
| 132 |
+
"new_user_text": new_user_text,
|
| 133 |
+
"silence_flag": silence_flag,
|
| 134 |
+
},
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
def generate(self, agent_id: str, system_prompt: str, dialogue: Dialogue) -> dict[str, object]:
|
| 138 |
+
return self._post(
|
| 139 |
+
"/generate",
|
| 140 |
+
{"agent_id": agent_id, "system_prompt": system_prompt, "dialogue": dialogue},
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
def transcribe(self, audio_path: str, language: str = "en") -> dict[str, object]:
|
| 144 |
+
import requests
|
| 145 |
+
|
| 146 |
+
headers = self._auth_headers(self.transcribe_bearer_token)
|
| 147 |
+
with Path(audio_path).open("rb") as audio_file:
|
| 148 |
+
files = {"file": (Path(audio_path).name, audio_file, "audio/wav")}
|
| 149 |
+
data = {"language": language}
|
| 150 |
+
response = requests.post(
|
| 151 |
+
f"{self.transcribe_endpoint_url}/transcribe",
|
| 152 |
+
files=files,
|
| 153 |
+
data=data,
|
| 154 |
+
headers=headers,
|
| 155 |
+
timeout=max(self.timeout_s, 600.0),
|
| 156 |
+
)
|
| 157 |
+
response.raise_for_status()
|
| 158 |
+
return dict(response.json())
|
| 159 |
+
|
| 160 |
+
def _post(self, path: str, payload: dict[str, object]) -> dict[str, object]:
|
| 161 |
+
import requests
|
| 162 |
+
|
| 163 |
+
response = requests.post(
|
| 164 |
+
f"{self.endpoint_url}{path}",
|
| 165 |
+
json=payload,
|
| 166 |
+
headers={"Content-Type": "application/json", **self._auth_headers(self.bearer_token)},
|
| 167 |
+
timeout=self.timeout_s,
|
| 168 |
+
)
|
| 169 |
+
response.raise_for_status()
|
| 170 |
+
return dict(response.json())
|
| 171 |
+
|
| 172 |
+
@staticmethod
|
| 173 |
+
def _auth_headers(bearer_token: str | None) -> dict[str, str]:
|
| 174 |
+
if not bearer_token:
|
| 175 |
+
return {}
|
| 176 |
+
return {"Authorization": f"Bearer {bearer_token}"}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
class DemoDecompressBrainClient:
|
| 180 |
+
"""Deterministic local stand-in for tests and UI work."""
|
| 181 |
+
|
| 182 |
+
def __init__(self) -> None:
|
| 183 |
+
self.calls = 0
|
| 184 |
+
|
| 185 |
+
def step(
|
| 186 |
+
self,
|
| 187 |
+
agent_id: str,
|
| 188 |
+
system_prompt: str,
|
| 189 |
+
dialogue_so_far: Dialogue,
|
| 190 |
+
new_user_text: str,
|
| 191 |
+
silence_flag: bool,
|
| 192 |
+
) -> dict[str, object]:
|
| 193 |
+
raw = self.step_many(
|
| 194 |
+
[{"agent_id": agent_id, "system_prompt": system_prompt}],
|
| 195 |
+
dialogue_so_far,
|
| 196 |
+
new_user_text,
|
| 197 |
+
silence_flag,
|
| 198 |
+
)
|
| 199 |
+
return dict(raw["results"][agent_id])
|
| 200 |
+
|
| 201 |
+
def step_many(
|
| 202 |
+
self,
|
| 203 |
+
agent_payloads: list[dict[str, str]],
|
| 204 |
+
dialogue_so_far: Dialogue,
|
| 205 |
+
new_user_text: str,
|
| 206 |
+
silence_flag: bool,
|
| 207 |
+
) -> dict[str, object]:
|
| 208 |
+
del dialogue_so_far
|
| 209 |
+
start = time.perf_counter()
|
| 210 |
+
self.calls += 1
|
| 211 |
+
text = new_user_text.lower()
|
| 212 |
+
stress_words = sum(word in text for word in ["tense", "replaying", "tight", "behind", "sleep", "work"])
|
| 213 |
+
shift = any(word in text for word in ["but", "then", "before", "after"])
|
| 214 |
+
results = {}
|
| 215 |
+
for payload in agent_payloads:
|
| 216 |
+
hidden = np.zeros(10, dtype=np.float32)
|
| 217 |
+
hidden[0] = 1.0
|
| 218 |
+
hidden[(self.calls % 5) + 1] = 0.35
|
| 219 |
+
if shift:
|
| 220 |
+
hidden[7] = 1.2
|
| 221 |
+
if silence_flag:
|
| 222 |
+
hidden[8] = 1.0
|
| 223 |
+
hidden /= max(float(np.linalg.norm(hidden)), 1.0)
|
| 224 |
+
p_end = 0.94 if silence_flag else (0.68 if new_user_text.strip().endswith((".", "?", "!")) else 0.18)
|
| 225 |
+
results[payload["agent_id"]] = {
|
| 226 |
+
"ok": True,
|
| 227 |
+
"agent_id": payload["agent_id"],
|
| 228 |
+
"surprise": float(2.0 + stress_words * 0.7 + self.calls * 0.03),
|
| 229 |
+
"hidden": hidden.tolist(),
|
| 230 |
+
"readiness": 0.72 if silence_flag else 0.48,
|
| 231 |
+
"p_end": p_end,
|
| 232 |
+
"latency_ms": 3.0,
|
| 233 |
+
"model_name": "demo-minicpm-brain",
|
| 234 |
+
"device_name": "cpu-test",
|
| 235 |
+
}
|
| 236 |
+
return {
|
| 237 |
+
"ok": True,
|
| 238 |
+
"results": results,
|
| 239 |
+
"batch_latency_ms": (time.perf_counter() - start) * 1000.0,
|
| 240 |
+
"model_name": "demo-minicpm-brain",
|
| 241 |
+
"device_name": "cpu-test",
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
def generate(self, agent_id: str, system_prompt: str, dialogue: Dialogue) -> dict[str, object]:
|
| 245 |
+
del agent_id, system_prompt
|
| 246 |
+
text = _dialogue_text(dialogue, "")
|
| 247 |
+
line = fallback_line(text)
|
| 248 |
+
return {
|
| 249 |
+
"ok": True,
|
| 250 |
+
"agent_id": AGENT_ID,
|
| 251 |
+
"reply_text": line,
|
| 252 |
+
"raw_reply_text": line,
|
| 253 |
+
"reply_source": "demo",
|
| 254 |
+
"latency_ms": 4.0,
|
| 255 |
+
"model_name": "demo-minicpm-brain",
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
def transcribe(self, audio_path: str, language: str = "en") -> dict[str, object]:
|
| 259 |
+
del audio_path, language
|
| 260 |
+
return {
|
| 261 |
+
"ok": True,
|
| 262 |
+
"text": SAMPLE_CHECKIN,
|
| 263 |
+
"language": "en",
|
| 264 |
+
"latency_ms": 5.0,
|
| 265 |
+
"duration_s": 4.0,
|
| 266 |
+
"model_name": "demo-cohere-transcribe",
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
class KokoroCalmSpeaker:
|
| 271 |
+
"""Lazy Kokoro TTS wrapper; text still works if TTS fails."""
|
| 272 |
+
|
| 273 |
+
def __init__(self, enabled: bool = True) -> None:
|
| 274 |
+
self.enabled = enabled
|
| 275 |
+
self.last_error: str | None = None
|
| 276 |
+
self._pipeline: Any | None = None
|
| 277 |
+
|
| 278 |
+
def synthesize(self, text: str, tag: str) -> str | None:
|
| 279 |
+
if not self.enabled or not text.strip():
|
| 280 |
+
return None
|
| 281 |
+
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
| 282 |
+
output = AUDIO_DIR / f"{tag}_decompress.wav"
|
| 283 |
+
try:
|
| 284 |
+
os.environ.setdefault("HF_HOME", str(ROOT / ".hf-cache"))
|
| 285 |
+
import soundfile as sf
|
| 286 |
+
from kokoro import KPipeline
|
| 287 |
+
|
| 288 |
+
if self._pipeline is None:
|
| 289 |
+
self._pipeline = KPipeline(lang_code="a")
|
| 290 |
+
chunks = []
|
| 291 |
+
for _, _, audio in self._pipeline(text, voice=os.getenv("DECOMPRESS_VOICE", "af_heart"), speed=0.92):
|
| 292 |
+
chunks.append(np.asarray(audio, dtype=np.float32))
|
| 293 |
+
if not chunks:
|
| 294 |
+
raise RuntimeError("Kokoro returned no audio chunks")
|
| 295 |
+
waveform = np.concatenate(chunks)
|
| 296 |
+
sf.write(output, waveform, 24000)
|
| 297 |
+
self.last_error = None
|
| 298 |
+
return str(output)
|
| 299 |
+
except Exception as exc: # noqa: BLE001 - keep demo alive on CPU/TTS failures.
|
| 300 |
+
self.last_error = f"{type(exc).__name__}: {exc}"
|
| 301 |
+
return None
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
class DecompressRetriever:
|
| 305 |
+
def __init__(self, corpus_path: Path = CORPUS_PATH, model_name: str = DEFAULT_EMBEDDER) -> None:
|
| 306 |
+
self.corpus_path = corpus_path
|
| 307 |
+
self.model_name = model_name
|
| 308 |
+
self.docs = json.loads(corpus_path.read_text(encoding="utf-8"))
|
| 309 |
+
self.embedding_model = "lexical-fallback"
|
| 310 |
+
self._model: Any | None = None
|
| 311 |
+
self._embeddings: np.ndarray | None = None
|
| 312 |
+
self._load_error: str | None = None
|
| 313 |
+
|
| 314 |
+
def retrieve(self, query: str, *, k: int = 2) -> list[RetrievedSource]:
|
| 315 |
+
query = " ".join(query.strip().split()) or "stress breathing grounding"
|
| 316 |
+
if self._ensure_sentence_embeddings():
|
| 317 |
+
assert self._model is not None and self._embeddings is not None
|
| 318 |
+
query_vec = np.asarray(self._model.encode([query], normalize_embeddings=True), dtype=np.float32)[0]
|
| 319 |
+
scores = np.dot(self._embeddings, query_vec)
|
| 320 |
+
else:
|
| 321 |
+
scores = np.asarray([_lexical_score(query, doc["text"]) for doc in self.docs], dtype=np.float32)
|
| 322 |
+
|
| 323 |
+
ranked = sorted(enumerate(scores.tolist()), key=lambda item: item[1], reverse=True)[:k]
|
| 324 |
+
return [
|
| 325 |
+
RetrievedSource(
|
| 326 |
+
id=str(self.docs[index]["id"]),
|
| 327 |
+
title=str(self.docs[index]["title"]),
|
| 328 |
+
publisher=str(self.docs[index]["publisher"]),
|
| 329 |
+
url=str(self.docs[index]["url"]),
|
| 330 |
+
text=str(self.docs[index]["text"]),
|
| 331 |
+
score=float(score),
|
| 332 |
+
)
|
| 333 |
+
for index, score in ranked
|
| 334 |
+
]
|
| 335 |
+
|
| 336 |
+
def _ensure_sentence_embeddings(self) -> bool:
|
| 337 |
+
if self._embeddings is not None:
|
| 338 |
+
return True
|
| 339 |
+
if self._load_error is not None:
|
| 340 |
+
return False
|
| 341 |
+
try:
|
| 342 |
+
from sentence_transformers import SentenceTransformer
|
| 343 |
+
|
| 344 |
+
os.environ.setdefault("HF_HOME", str(ROOT / ".hf-cache"))
|
| 345 |
+
self._model = SentenceTransformer(self.model_name)
|
| 346 |
+
texts = [f"{doc['title']}. {doc['text']}" for doc in self.docs]
|
| 347 |
+
self._embeddings = np.asarray(self._model.encode(texts, normalize_embeddings=True), dtype=np.float32)
|
| 348 |
+
self.embedding_model = self.model_name
|
| 349 |
+
return True
|
| 350 |
+
except Exception as exc: # noqa: BLE001 - local tests should not require the embedder.
|
| 351 |
+
self._load_error = f"{type(exc).__name__}: {exc}"
|
| 352 |
+
self.embedding_model = f"lexical-fallback ({self._load_error})"
|
| 353 |
+
return False
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
_RETRIEVER: DecompressRetriever | None = None
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def get_retriever() -> DecompressRetriever:
|
| 360 |
+
global _RETRIEVER
|
| 361 |
+
if _RETRIEVER is None:
|
| 362 |
+
_RETRIEVER = DecompressRetriever(model_name=os.getenv("DECOMPRESS_EMBEDDER", DEFAULT_EMBEDDER))
|
| 363 |
+
return _RETRIEVER
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def set_live_tau(value: float) -> None:
|
| 367 |
+
global _LIVE_TAU
|
| 368 |
+
_LIVE_TAU = float(value)
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def chunk_checkin(text: str, *, words_per_chunk: int = 11) -> list[TranscriptChunk]:
|
| 372 |
+
words = re.findall(r"\S+", text.strip())
|
| 373 |
+
if not words:
|
| 374 |
+
words = re.findall(r"\S+", SAMPLE_CHECKIN)
|
| 375 |
+
chunks = [
|
| 376 |
+
TranscriptChunk(" ".join(words[index : index + words_per_chunk]), silence_flag=False)
|
| 377 |
+
for index in range(0, len(words), words_per_chunk)
|
| 378 |
+
]
|
| 379 |
+
chunks = [
|
| 380 |
+
TranscriptChunk(chunk.text if index == len(chunks) - 1 else chunk.text.rstrip(".?!"), chunk.silence_flag)
|
| 381 |
+
for index, chunk in enumerate(chunks)
|
| 382 |
+
]
|
| 383 |
+
if chunks:
|
| 384 |
+
chunks[-1] = TranscriptChunk(chunks[-1].text, silence_flag=True)
|
| 385 |
+
return chunks
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def gentle_controller_config(tau: float) -> ControllerConfig:
|
| 389 |
+
return ControllerConfig(
|
| 390 |
+
tau=float(tau),
|
| 391 |
+
w_surprise=0.25,
|
| 392 |
+
w_change=0.20,
|
| 393 |
+
w_readiness=0.65,
|
| 394 |
+
w_end=1.35,
|
| 395 |
+
w_barge=0.0,
|
| 396 |
+
min_readiness=0.08,
|
| 397 |
+
take_floor_p_end=0.68,
|
| 398 |
+
interrupt_p_end_max=0.05,
|
| 399 |
+
backchannel_p_end_max=0.40,
|
| 400 |
+
backchannel_tau_fraction=0.75,
|
| 401 |
+
barge_tau_fraction=10.0,
|
| 402 |
+
turn_end_tau_discount=0.55,
|
| 403 |
+
refractory_steps=2,
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def make_brain_client(mode: str | None = None) -> tuple[BrainClient, str]:
|
| 408 |
+
selected = (mode or os.getenv("DECOMPRESS_BRAIN", "modal")).strip().lower()
|
| 409 |
+
if selected in {"fake", "demo", "local"}:
|
| 410 |
+
return DemoDecompressBrainClient(), "demo"
|
| 411 |
+
return EndpointDecompressBrainClient(), "modal"
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def initial_room_html(tau: float | None = None, brain_mode: str | None = None) -> str:
|
| 415 |
+
state = DecompressState(
|
| 416 |
+
tau=float(tau if tau is not None else _LIVE_TAU),
|
| 417 |
+
brain_mode=brain_mode or os.getenv("DECOMPRESS_BRAIN", "modal"),
|
| 418 |
+
status="ready",
|
| 419 |
+
)
|
| 420 |
+
state.citations = [asdict(source) for source in get_retriever().retrieve(SAMPLE_CHECKIN, k=2)]
|
| 421 |
+
state.embedding_model = get_retriever().embedding_model
|
| 422 |
+
return render_room(state)
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def preview_room_html(tau: float | None = None) -> str:
|
| 426 |
+
latest = initial_room_html(tau, brain_mode="demo")
|
| 427 |
+
for latest, _, _ in run_checkin_stream(SAMPLE_CHECKIN, tau or _LIVE_TAU, brain_mode="fake", enable_tts=False, save_log=False):
|
| 428 |
+
pass
|
| 429 |
+
return latest
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def run_checkin_stream(
|
| 433 |
+
checkin_text: str,
|
| 434 |
+
tau: float,
|
| 435 |
+
*,
|
| 436 |
+
brain_mode: str | None = None,
|
| 437 |
+
enable_tts: bool | None = None,
|
| 438 |
+
save_log: bool = True,
|
| 439 |
+
) -> Iterator[tuple[str, str | None, dict[str, Any]]]:
|
| 440 |
+
set_live_tau(tau)
|
| 441 |
+
selected_tts = enable_tts if enable_tts is not None else os.getenv("DECOMPRESS_TTS", "1").lower() not in {"0", "false"}
|
| 442 |
+
state = DecompressState(tau=float(tau), brain_mode=brain_mode or os.getenv("DECOMPRESS_BRAIN", "modal"), status="connecting")
|
| 443 |
+
speaker = KokoroCalmSpeaker(enabled=bool(selected_tts))
|
| 444 |
+
yield from _safe_run_checkin_stream(checkin_text, state, speaker, save_log=save_log)
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def run_voice_checkin_stream(
|
| 448 |
+
audio_clip: Any,
|
| 449 |
+
tau: float,
|
| 450 |
+
*,
|
| 451 |
+
brain_mode: str | None = None,
|
| 452 |
+
enable_tts: bool | None = None,
|
| 453 |
+
save_log: bool = True,
|
| 454 |
+
) -> Iterator[tuple[str, str | None, dict[str, Any], str]]:
|
| 455 |
+
set_live_tau(tau)
|
| 456 |
+
selected_tts = enable_tts if enable_tts is not None else os.getenv("DECOMPRESS_TTS", "1").lower() not in {"0", "false"}
|
| 457 |
+
state = DecompressState(tau=float(tau), brain_mode=brain_mode or os.getenv("DECOMPRESS_BRAIN", "modal"), status="transcribing")
|
| 458 |
+
speaker = KokoroCalmSpeaker(enabled=bool(selected_tts))
|
| 459 |
+
state.transcript.append(
|
| 460 |
+
{
|
| 461 |
+
"kind": "system",
|
| 462 |
+
"speaker": "Cohere Transcribe",
|
| 463 |
+
"step": 0,
|
| 464 |
+
"text": "Listening to the recorded check-in.",
|
| 465 |
+
"action": "ASR",
|
| 466 |
+
}
|
| 467 |
+
)
|
| 468 |
+
yield render_room(state), None, _status_payload(state), ""
|
| 469 |
+
|
| 470 |
+
try:
|
| 471 |
+
audio_path = _audio_clip_path(audio_clip)
|
| 472 |
+
client, resolved_mode = make_brain_client(state.brain_mode)
|
| 473 |
+
state.brain_mode = resolved_mode
|
| 474 |
+
if not hasattr(client, "transcribe"):
|
| 475 |
+
raise RuntimeError("selected brain client does not support transcription")
|
| 476 |
+
raw_asr = client.transcribe(audio_path, language="en") # type: ignore[attr-defined]
|
| 477 |
+
if not raw_asr.get("ok", False):
|
| 478 |
+
raise RuntimeError(str(raw_asr.get("failure", "transcription failed")))
|
| 479 |
+
transcript = " ".join(str(raw_asr.get("text", "")).strip().split())
|
| 480 |
+
if not transcript:
|
| 481 |
+
raise RuntimeError("Cohere Transcribe returned an empty transcript")
|
| 482 |
+
state.asr_text = transcript
|
| 483 |
+
state.asr_latency_ms = float(raw_asr.get("latency_ms", 0.0) or 0.0)
|
| 484 |
+
state.asr_model_name = str(raw_asr.get("model_name", ""))
|
| 485 |
+
state.transcript.append(
|
| 486 |
+
{
|
| 487 |
+
"kind": "system",
|
| 488 |
+
"speaker": "Cohere transcript",
|
| 489 |
+
"step": 0,
|
| 490 |
+
"text": transcript,
|
| 491 |
+
"action": "ASR",
|
| 492 |
+
}
|
| 493 |
+
)
|
| 494 |
+
yield render_room(state), None, _status_payload(state), transcript
|
| 495 |
+
|
| 496 |
+
for html_frame, audio_path_out, status in _run_with_client(
|
| 497 |
+
transcript,
|
| 498 |
+
state,
|
| 499 |
+
speaker,
|
| 500 |
+
client,
|
| 501 |
+
save_log=save_log,
|
| 502 |
+
):
|
| 503 |
+
yield html_frame, audio_path_out, status, transcript
|
| 504 |
+
except Exception as exc: # noqa: BLE001 - surface failures in the app shell.
|
| 505 |
+
state.status = "failed"
|
| 506 |
+
state.transcript.append(
|
| 507 |
+
{
|
| 508 |
+
"kind": "system",
|
| 509 |
+
"speaker": "room",
|
| 510 |
+
"step": state.step,
|
| 511 |
+
"text": f"Voice path failed: {type(exc).__name__}: {exc}",
|
| 512 |
+
"action": "ERROR",
|
| 513 |
+
}
|
| 514 |
+
)
|
| 515 |
+
yield render_room(state), None, _status_payload(state), state.asr_text or ""
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def _safe_run_checkin_stream(
|
| 519 |
+
checkin_text: str,
|
| 520 |
+
state: DecompressState,
|
| 521 |
+
speaker: KokoroCalmSpeaker,
|
| 522 |
+
*,
|
| 523 |
+
save_log: bool,
|
| 524 |
+
) -> Iterator[tuple[str, str | None, dict[str, Any]]]:
|
| 525 |
+
try:
|
| 526 |
+
client, resolved_mode = make_brain_client(state.brain_mode)
|
| 527 |
+
state.brain_mode = resolved_mode
|
| 528 |
+
yield from _run_with_client(checkin_text, state, speaker, client, save_log=save_log)
|
| 529 |
+
except Exception as exc: # noqa: BLE001 - keep the visible shell alive.
|
| 530 |
+
state.status = "failed"
|
| 531 |
+
state.transcript.append(
|
| 532 |
+
{
|
| 533 |
+
"kind": "system",
|
| 534 |
+
"speaker": "room",
|
| 535 |
+
"step": state.step,
|
| 536 |
+
"text": f"Backend failed: {type(exc).__name__}: {exc}",
|
| 537 |
+
"action": "ERROR",
|
| 538 |
+
}
|
| 539 |
+
)
|
| 540 |
+
yield render_room(state), None, _status_payload(state)
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
def _run_with_client(
|
| 544 |
+
checkin_text: str,
|
| 545 |
+
state: DecompressState,
|
| 546 |
+
speaker: KokoroCalmSpeaker,
|
| 547 |
+
client: BrainClient,
|
| 548 |
+
*,
|
| 549 |
+
save_log: bool,
|
| 550 |
+
) -> Iterator[tuple[str, str | None, dict[str, Any]]]:
|
| 551 |
+
retriever = get_retriever()
|
| 552 |
+
controller = WhenToSpeakController([AGENT_ID], config=gentle_controller_config(state.tau))
|
| 553 |
+
dialogue: Dialogue = []
|
| 554 |
+
current_user_text = ""
|
| 555 |
+
events: list[dict[str, Any]] = []
|
| 556 |
+
chunks = chunk_checkin(checkin_text)
|
| 557 |
+
state.status = "listening"
|
| 558 |
+
state.embedding_model = retriever.embedding_model
|
| 559 |
+
yield render_room(state), None, _status_payload(state)
|
| 560 |
+
|
| 561 |
+
for step_index, chunk in enumerate(chunks, start=1):
|
| 562 |
+
state.step = step_index
|
| 563 |
+
state.tau = _LIVE_TAU
|
| 564 |
+
controller.config = gentle_controller_config(_LIVE_TAU)
|
| 565 |
+
query_text = _join_text(current_user_text, chunk.text)
|
| 566 |
+
retrieved = retriever.retrieve(query_text, k=2)
|
| 567 |
+
state.citations = [asdict(source) for source in retrieved]
|
| 568 |
+
state.embedding_model = retriever.embedding_model
|
| 569 |
+
system_prompt = companion_system_prompt(retrieved)
|
| 570 |
+
|
| 571 |
+
dialogue_before = _dialogue_with_current_user(dialogue, current_user_text)
|
| 572 |
+
raw = client.step_many(
|
| 573 |
+
[{"agent_id": AGENT_ID, "system_prompt": system_prompt}],
|
| 574 |
+
dialogue_before,
|
| 575 |
+
chunk.text,
|
| 576 |
+
chunk.silence_flag,
|
| 577 |
+
)
|
| 578 |
+
raw_result = dict(raw.get("results", {}).get(AGENT_ID, {}))
|
| 579 |
+
signals = signals_from_raw(raw_result)
|
| 580 |
+
current_user_text = _join_text(current_user_text, chunk.text)
|
| 581 |
+
tick = controller.tick({AGENT_ID: signals}, floor_holder="human")
|
| 582 |
+
state.decision = _decision_payload(tick)
|
| 583 |
+
state.model_name = str(raw.get("model_name", "") or raw_result.get("model_name", "") or "")
|
| 584 |
+
state.device_name = str(raw.get("device_name", "") or raw_result.get("device_name", "") or "")
|
| 585 |
+
state.transcript.append({"kind": "user", "speaker": "You", "step": step_index, "text": chunk.text})
|
| 586 |
+
event = _event_payload(step_index, chunk, tick, raw, raw_result)
|
| 587 |
+
|
| 588 |
+
audio_path: str | None = None
|
| 589 |
+
decision = tick.decisions[AGENT_ID]
|
| 590 |
+
if decision.action == Action.BACKCHANNEL:
|
| 591 |
+
line = backchannel_line(step_index, current_user_text)
|
| 592 |
+
state.stats.backchannels += 1
|
| 593 |
+
dialogue.append({"role": "assistant", "speaker": AGENT_ID, "text": line})
|
| 594 |
+
state.transcript.append(
|
| 595 |
+
{
|
| 596 |
+
"kind": "companion backchannel",
|
| 597 |
+
"speaker": DISPLAY_NAME,
|
| 598 |
+
"step": step_index,
|
| 599 |
+
"text": line,
|
| 600 |
+
"action": Action.BACKCHANNEL.value,
|
| 601 |
+
}
|
| 602 |
+
)
|
| 603 |
+
audio_path = speaker.synthesize(line, f"step{step_index:02d}_backchannel")
|
| 604 |
+
elif tick.winner:
|
| 605 |
+
if current_user_text:
|
| 606 |
+
dialogue.append({"role": "user", "speaker": "you", "text": current_user_text})
|
| 607 |
+
current_user_text = ""
|
| 608 |
+
generated = client.generate(AGENT_ID, system_prompt, dialogue)
|
| 609 |
+
line = sanitize_spoken_line(str(generated.get("reply_text", "")), dialogue)
|
| 610 |
+
dialogue.append({"role": "assistant", "speaker": AGENT_ID, "text": line})
|
| 611 |
+
state.response_latency_ms = float(generated.get("latency_ms", 0.0) or 0.0)
|
| 612 |
+
state.transcript.append(
|
| 613 |
+
{
|
| 614 |
+
"kind": "companion",
|
| 615 |
+
"speaker": DISPLAY_NAME,
|
| 616 |
+
"step": step_index,
|
| 617 |
+
"text": line,
|
| 618 |
+
"action": decision.action.value,
|
| 619 |
+
}
|
| 620 |
+
)
|
| 621 |
+
event["generated"] = dict(generated, reply_text=line)
|
| 622 |
+
if decision.action == Action.INTERRUPT:
|
| 623 |
+
state.stats.interrupts += 1
|
| 624 |
+
else:
|
| 625 |
+
state.stats.take_floors += 1
|
| 626 |
+
audio_path = speaker.synthesize(line, f"step{step_index:02d}_reply")
|
| 627 |
+
elif chunk.silence_flag:
|
| 628 |
+
state.stats.holds += 1
|
| 629 |
+
|
| 630 |
+
if speaker.last_error:
|
| 631 |
+
state.tts_error = speaker.last_error
|
| 632 |
+
events.append(event)
|
| 633 |
+
yield render_room(state), audio_path, _status_payload(state)
|
| 634 |
+
|
| 635 |
+
if current_user_text:
|
| 636 |
+
dialogue.append({"role": "user", "speaker": "you", "text": current_user_text})
|
| 637 |
+
|
| 638 |
+
if state.stats.take_floors == 0:
|
| 639 |
+
retrieved = retriever.retrieve(_dialogue_text(dialogue, ""), k=2)
|
| 640 |
+
state.citations = [asdict(source) for source in retrieved]
|
| 641 |
+
system_prompt = companion_system_prompt(retrieved)
|
| 642 |
+
generated = client.generate(AGENT_ID, system_prompt, dialogue)
|
| 643 |
+
line = sanitize_spoken_line(str(generated.get("reply_text", "")), dialogue)
|
| 644 |
+
dialogue.append({"role": "assistant", "speaker": AGENT_ID, "text": line})
|
| 645 |
+
state.stats.take_floors += 1
|
| 646 |
+
state.response_latency_ms = float(generated.get("latency_ms", 0.0) or 0.0)
|
| 647 |
+
state.transcript.append(
|
| 648 |
+
{
|
| 649 |
+
"kind": "companion",
|
| 650 |
+
"speaker": DISPLAY_NAME,
|
| 651 |
+
"step": state.step,
|
| 652 |
+
"text": line,
|
| 653 |
+
"action": Action.TAKE_FLOOR.value,
|
| 654 |
+
}
|
| 655 |
+
)
|
| 656 |
+
audio_path = speaker.synthesize(line, f"step{state.step:02d}_final")
|
| 657 |
+
yield render_room(state), audio_path, _status_payload(state)
|
| 658 |
+
|
| 659 |
+
state.status = "complete"
|
| 660 |
+
if save_log:
|
| 661 |
+
_save_decompress_log(state, dialogue, events)
|
| 662 |
+
yield render_room(state), None, _status_payload(state)
|
| 663 |
+
|
| 664 |
+
|
| 665 |
+
def companion_system_prompt(sources: list[RetrievedSource]) -> str:
|
| 666 |
+
source_lines = [
|
| 667 |
+
f"[{index}] {source.publisher}, {source.title}: {source.text}"
|
| 668 |
+
for index, source in enumerate(sources, start=1)
|
| 669 |
+
]
|
| 670 |
+
return "\n".join(
|
| 671 |
+
[
|
| 672 |
+
"You are Decompress, a calm five-minute de-stress companion.",
|
| 673 |
+
"Use the grounding notes below for practical, non-medical suggestions.",
|
| 674 |
+
"Listen first. Use short backchannel-like wording if the user is still talking.",
|
| 675 |
+
"When the user pauses, give one gentle next step in one sentence.",
|
| 676 |
+
"Do not diagnose, do not prescribe, and do not imply therapy or medical care.",
|
| 677 |
+
"If the user may be in immediate danger, tell them to contact emergency support or a trusted person now.",
|
| 678 |
+
"Grounding notes:",
|
| 679 |
+
*source_lines,
|
| 680 |
+
]
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def render_room(state: DecompressState) -> str:
|
| 685 |
+
decision = state.decision or _empty_decision()
|
| 686 |
+
transcript = "\n".join(_render_feed_item(item) for item in state.transcript) or _empty_transcript()
|
| 687 |
+
citations = "\n".join(_render_citation(item) for item in state.citations) or _empty_citations()
|
| 688 |
+
score = min(100.0, max(0.0, float(decision.get("urge", 0.0)) / max(state.tau * 1.45, 1.0) * 100.0))
|
| 689 |
+
tau_pos = min(100.0, max(0.0, state.tau / max(state.tau * 1.45, 1.0) * 100.0))
|
| 690 |
+
action = str(decision.get("action", "SILENT"))
|
| 691 |
+
tts_note = f"<span class='soft-note'>TTS: {_escape(state.tts_error)}</span>" if state.tts_error else ""
|
| 692 |
+
return f"""
|
| 693 |
+
<main id="decompress-room" class="decompress-room" data-status="{_escape(state.status)}">
|
| 694 |
+
<section class="calm-hero">
|
| 695 |
+
<div class="breath-dial" aria-hidden="true"><span></span></div>
|
| 696 |
+
<div class="hero-copy">
|
| 697 |
+
<div class="kicker">DECOMPRESS</div>
|
| 698 |
+
<h1>A companion that waits for the right beat.</h1>
|
| 699 |
+
<p>MiniCPM listens through the when-to-speak controller, then answers with one grounded next step.</p>
|
| 700 |
+
</div>
|
| 701 |
+
<div class="session-chip">
|
| 702 |
+
<span>Brain {_escape(state.brain_mode.upper())}</span>
|
| 703 |
+
<span>Step {_escape(str(state.step))}</span>
|
| 704 |
+
<span>τ {_escape(f"{state.tau:.2f}")}</span>
|
| 705 |
+
{tts_note}
|
| 706 |
+
</div>
|
| 707 |
+
</section>
|
| 708 |
+
<section class="companion-grid">
|
| 709 |
+
<article class="companion-card {action.lower()}">
|
| 710 |
+
<div class="card-top">
|
| 711 |
+
<div class="moon-mark">D</div>
|
| 712 |
+
<div>
|
| 713 |
+
<h2>Decompress</h2>
|
| 714 |
+
<p>Calm timing, not assistant chatter.</p>
|
| 715 |
+
</div>
|
| 716 |
+
</div>
|
| 717 |
+
<div class="urge-readout">
|
| 718 |
+
<span>urge</span>
|
| 719 |
+
<strong>{float(decision.get("urge", 0.0)):.2f}</strong>
|
| 720 |
+
</div>
|
| 721 |
+
<div class="soft-meter" style="--fill:{score:.1f}%; --tau:{tau_pos:.1f}%;">
|
| 722 |
+
<div class="soft-fill"></div><i></i>
|
| 723 |
+
</div>
|
| 724 |
+
<div class="signal-row">
|
| 725 |
+
<span>{_escape(action)}</span>
|
| 726 |
+
<span>ready {float(decision.get("readiness", 0.0)):.2f}</span>
|
| 727 |
+
<span>end {float(decision.get("p_end", 0.0)):.2f}</span>
|
| 728 |
+
</div>
|
| 729 |
+
<div class="stats-line">
|
| 730 |
+
<span>{state.stats.backchannels} backchannels</span>
|
| 731 |
+
<span>{state.stats.take_floors} replies</span>
|
| 732 |
+
<span>{state.stats.holds} holds</span>
|
| 733 |
+
</div>
|
| 734 |
+
</article>
|
| 735 |
+
<article class="transcript-panel">
|
| 736 |
+
<div class="panel-title"><span>Check-in transcript</span><em>{_escape(state.status)}</em></div>
|
| 737 |
+
<div class="decompress-scroll">{transcript}</div>
|
| 738 |
+
</article>
|
| 739 |
+
<aside class="grounding-panel">
|
| 740 |
+
<div class="panel-title"><span>Grounded by</span><em>{_escape(state.embedding_model)}</em></div>
|
| 741 |
+
<div class="citation-list">{citations}</div>
|
| 742 |
+
<p class="disclaimer">Wellness support only. Not medical advice, diagnosis, or crisis care.</p>
|
| 743 |
+
</aside>
|
| 744 |
+
</section>
|
| 745 |
+
</main>
|
| 746 |
+
""".strip()
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
def sanitize_spoken_line(reply: str, dialogue: Dialogue | None = None) -> str:
|
| 750 |
+
candidate = re.sub(r"<think>.*?</think>", "", reply, flags=re.IGNORECASE | re.DOTALL)
|
| 751 |
+
candidate = candidate.replace("<think>", "").replace("</think>", "")
|
| 752 |
+
for line in candidate.splitlines() or [candidate]:
|
| 753 |
+
cleaned = _strip_prefix(line)
|
| 754 |
+
if _looks_spoken(cleaned):
|
| 755 |
+
return _truncate(cleaned)
|
| 756 |
+
return fallback_line(_dialogue_text(dialogue or [], ""))
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
def fallback_line(text: str) -> str:
|
| 760 |
+
lowered = text.lower()
|
| 761 |
+
if any(fragment in lowered for fragment in ("hurt myself", "kill myself", "suicide", "not safe")):
|
| 762 |
+
return "If you might be unsafe, contact emergency support or a trusted person now."
|
| 763 |
+
if "sleep" in lowered or "late" in lowered:
|
| 764 |
+
return "Park one sentence on paper, then let the next exhale be longer."
|
| 765 |
+
if "work" in lowered or "meeting" in lowered or "behind" in lowered:
|
| 766 |
+
return "Make work smaller: unclench your jaw and choose one next tiny step."
|
| 767 |
+
stable_index = sum(ord(char) for char in lowered) % len(FALLBACK_LINES)
|
| 768 |
+
return FALLBACK_LINES[stable_index]
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
def backchannel_line(step: int, text: str) -> str:
|
| 772 |
+
seed = step + len(text)
|
| 773 |
+
return CALM_BACKCHANNELS[seed % len(CALM_BACKCHANNELS)]
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
def _decision_payload(tick: ControllerTick) -> dict[str, Any]:
|
| 777 |
+
decision = tick.decisions[AGENT_ID]
|
| 778 |
+
return {
|
| 779 |
+
"action": decision.action.value,
|
| 780 |
+
"urge": decision.urge,
|
| 781 |
+
"z_surprise": decision.z_surprise,
|
| 782 |
+
"change_score": decision.change_score,
|
| 783 |
+
"readiness": decision.readiness,
|
| 784 |
+
"p_end": decision.p_end,
|
| 785 |
+
"hidden_delta": decision.hidden_delta,
|
| 786 |
+
"map_run_length": decision.map_run_length,
|
| 787 |
+
"winner": tick.winner == AGENT_ID,
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
def _event_payload(
|
| 792 |
+
step: int,
|
| 793 |
+
chunk: TranscriptChunk,
|
| 794 |
+
tick: ControllerTick,
|
| 795 |
+
raw: dict[str, object],
|
| 796 |
+
raw_result: dict[str, object],
|
| 797 |
+
) -> dict[str, Any]:
|
| 798 |
+
return {
|
| 799 |
+
"step": step,
|
| 800 |
+
"new_user_text": chunk.text,
|
| 801 |
+
"silence_flag": chunk.silence_flag,
|
| 802 |
+
"winner": tick.winner,
|
| 803 |
+
"model_name": raw.get("model_name") or raw_result.get("model_name"),
|
| 804 |
+
"device_name": raw.get("device_name") or raw_result.get("device_name"),
|
| 805 |
+
"batch_latency_ms": raw.get("batch_latency_ms"),
|
| 806 |
+
"decision": _decision_payload(tick),
|
| 807 |
+
"brain_latency_ms": raw_result.get("latency_ms"),
|
| 808 |
+
"surprise": raw_result.get("surprise"),
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
|
| 812 |
+
def _empty_decision() -> dict[str, Any]:
|
| 813 |
+
return {
|
| 814 |
+
"action": Action.SILENT.value,
|
| 815 |
+
"urge": 0.0,
|
| 816 |
+
"readiness": 0.0,
|
| 817 |
+
"p_end": 0.0,
|
| 818 |
+
"change_score": 0.0,
|
| 819 |
+
"z_surprise": 0.0,
|
| 820 |
+
"winner": False,
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
def _status_payload(state: DecompressState) -> dict[str, Any]:
|
| 825 |
+
return {
|
| 826 |
+
"status": state.status,
|
| 827 |
+
"tau": state.tau,
|
| 828 |
+
"brain_mode": state.brain_mode,
|
| 829 |
+
"stats": asdict(state.stats),
|
| 830 |
+
"citations": state.citations,
|
| 831 |
+
"tts_error": state.tts_error,
|
| 832 |
+
"asr_text": state.asr_text,
|
| 833 |
+
"asr_latency_ms": state.asr_latency_ms,
|
| 834 |
+
"asr_model_name": state.asr_model_name,
|
| 835 |
+
"embedding_model": state.embedding_model,
|
| 836 |
+
"response_latency_ms": state.response_latency_ms,
|
| 837 |
+
"model_name": state.model_name,
|
| 838 |
+
"device_name": state.device_name,
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
|
| 842 |
+
def _save_decompress_log(state: DecompressState, dialogue: Dialogue, events: list[dict[str, Any]]) -> None:
|
| 843 |
+
EVAL_DIR.mkdir(parents=True, exist_ok=True)
|
| 844 |
+
payload = {
|
| 845 |
+
"brain_mode": state.brain_mode,
|
| 846 |
+
"tau": state.tau,
|
| 847 |
+
"status": state.status,
|
| 848 |
+
"stats": asdict(state.stats),
|
| 849 |
+
"citations": state.citations,
|
| 850 |
+
"asr_text": state.asr_text,
|
| 851 |
+
"asr_latency_ms": state.asr_latency_ms,
|
| 852 |
+
"asr_model_name": state.asr_model_name,
|
| 853 |
+
"embedding_model": state.embedding_model,
|
| 854 |
+
"response_latency_ms": state.response_latency_ms,
|
| 855 |
+
"model_name": state.model_name,
|
| 856 |
+
"device_name": state.device_name,
|
| 857 |
+
"transcript": state.transcript,
|
| 858 |
+
"dialogue": dialogue,
|
| 859 |
+
"events": events,
|
| 860 |
+
}
|
| 861 |
+
DECOMPRESS_LOG_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
| 862 |
+
|
| 863 |
+
|
| 864 |
+
def _audio_clip_path(audio_clip: Any) -> str:
|
| 865 |
+
if audio_clip is None:
|
| 866 |
+
raise ValueError("record a check-in clip before starting")
|
| 867 |
+
if isinstance(audio_clip, (str, os.PathLike)):
|
| 868 |
+
path = Path(audio_clip)
|
| 869 |
+
elif isinstance(audio_clip, dict):
|
| 870 |
+
path = Path(str(audio_clip.get("path") or audio_clip.get("name") or ""))
|
| 871 |
+
elif isinstance(audio_clip, (tuple, list)) and audio_clip:
|
| 872 |
+
path = Path(str(audio_clip[0]))
|
| 873 |
+
else:
|
| 874 |
+
raise TypeError(f"unsupported audio clip value: {type(audio_clip).__name__}")
|
| 875 |
+
if not path.exists():
|
| 876 |
+
raise FileNotFoundError(str(path))
|
| 877 |
+
return str(path)
|
| 878 |
+
|
| 879 |
+
|
| 880 |
+
def _dialogue_with_current_user(dialogue: Dialogue, current_user_text: str) -> Dialogue:
|
| 881 |
+
snapshot = [dict(turn) for turn in dialogue]
|
| 882 |
+
snapshot.append({"role": "user", "speaker": "you", "text": current_user_text})
|
| 883 |
+
return snapshot
|
| 884 |
+
|
| 885 |
+
|
| 886 |
+
def _join_text(left: str, right: str) -> str:
|
| 887 |
+
left = left.strip()
|
| 888 |
+
right = right.strip()
|
| 889 |
+
if not left:
|
| 890 |
+
return right
|
| 891 |
+
if not right:
|
| 892 |
+
return left
|
| 893 |
+
return f"{left} {right}"
|
| 894 |
+
|
| 895 |
+
|
| 896 |
+
def _dialogue_text(dialogue: Dialogue, new_user_text: str) -> str:
|
| 897 |
+
parts = [turn.get("text", "") for turn in dialogue]
|
| 898 |
+
parts.append(new_user_text)
|
| 899 |
+
return " ".join(parts).lower()
|
| 900 |
+
|
| 901 |
+
|
| 902 |
+
def _strip_prefix(line: str) -> str:
|
| 903 |
+
candidate = line.strip().strip("\"'")
|
| 904 |
+
candidate = re.sub(r"^[\-\*\d\.\)\s]+", "", candidate).strip()
|
| 905 |
+
for prefix in ["Decompress:", "Assistant:", "Companion:", "Sentence:", "Spoken line:"]:
|
| 906 |
+
if candidate.lower().startswith(prefix.lower()):
|
| 907 |
+
candidate = candidate[len(prefix) :].strip()
|
| 908 |
+
candidate = re.sub(r"^(certainly,?\s+|happy to,?\s+|let's\s+|i think\s+)", "", candidate, flags=re.IGNORECASE)
|
| 909 |
+
if candidate:
|
| 910 |
+
candidate = candidate[0].upper() + candidate[1:]
|
| 911 |
+
return candidate.strip().strip("\"'")
|
| 912 |
+
|
| 913 |
+
|
| 914 |
+
def _looks_spoken(candidate: str) -> bool:
|
| 915 |
+
if len(candidate) < 8:
|
| 916 |
+
return False
|
| 917 |
+
lowered = candidate.lower()
|
| 918 |
+
blocked = [
|
| 919 |
+
"the user",
|
| 920 |
+
"recent transcript",
|
| 921 |
+
"analysis",
|
| 922 |
+
"markdown",
|
| 923 |
+
"medical advice",
|
| 924 |
+
"diagnosis",
|
| 925 |
+
"as a therapist",
|
| 926 |
+
"decompress:",
|
| 927 |
+
"assistant:",
|
| 928 |
+
"<",
|
| 929 |
+
"{",
|
| 930 |
+
]
|
| 931 |
+
if any(fragment in lowered for fragment in blocked):
|
| 932 |
+
return False
|
| 933 |
+
alpha = sum(char.isalpha() for char in candidate)
|
| 934 |
+
if alpha / max(len(candidate), 1) < 0.45:
|
| 935 |
+
return False
|
| 936 |
+
return len(candidate.split()) <= 28
|
| 937 |
+
|
| 938 |
+
|
| 939 |
+
def _truncate(candidate: str) -> str:
|
| 940 |
+
parts = re.split(r"(?<=[.!?;])\s+", candidate, maxsplit=1)
|
| 941 |
+
sentence = parts[0].strip() if parts else candidate.strip()
|
| 942 |
+
words = sentence.split()
|
| 943 |
+
if len(words) > 24:
|
| 944 |
+
sentence = " ".join(words[:24]).rstrip(",;:") + "."
|
| 945 |
+
return sentence[:220].rstrip()
|
| 946 |
+
|
| 947 |
+
|
| 948 |
+
def _render_feed_item(item: dict[str, Any]) -> str:
|
| 949 |
+
kind = str(item.get("kind", "system")).replace(" ", "-")
|
| 950 |
+
action = str(item.get("action", ""))
|
| 951 |
+
step = item.get("step", "")
|
| 952 |
+
speaker = _escape(str(item.get("speaker", "")))
|
| 953 |
+
text = _escape(str(item.get("text", "")))
|
| 954 |
+
return f"""
|
| 955 |
+
<div class="soft-feed {kind}">
|
| 956 |
+
<div class="feed-meta"><span>{speaker}</span><em>step {step}</em></div>
|
| 957 |
+
<div class="feed-text">{text}</div>
|
| 958 |
+
{f'<b>{_escape(action)}</b>' if action else ''}
|
| 959 |
+
</div>
|
| 960 |
+
""".strip()
|
| 961 |
+
|
| 962 |
+
|
| 963 |
+
def _empty_transcript() -> str:
|
| 964 |
+
return """
|
| 965 |
+
<div class="soft-feed system">
|
| 966 |
+
<div class="feed-meta"><span>Room</span><em>ready</em></div>
|
| 967 |
+
<div class="feed-text">Type or record what is on your mind. The companion will wait for the pause.</div>
|
| 968 |
+
</div>
|
| 969 |
+
""".strip()
|
| 970 |
+
|
| 971 |
+
|
| 972 |
+
def _render_citation(item: dict[str, Any]) -> str:
|
| 973 |
+
title = _escape(str(item.get("title", "")))
|
| 974 |
+
publisher = _escape(str(item.get("publisher", "")))
|
| 975 |
+
url = _escape(str(item.get("url", "")))
|
| 976 |
+
score = float(item.get("score", 0.0) or 0.0)
|
| 977 |
+
return f"""
|
| 978 |
+
<a class="citation-card" href="{url}" target="_blank" rel="noreferrer">
|
| 979 |
+
<strong>{title}</strong>
|
| 980 |
+
<span>{publisher}</span>
|
| 981 |
+
<em>match {score:.2f}</em>
|
| 982 |
+
</a>
|
| 983 |
+
""".strip()
|
| 984 |
+
|
| 985 |
+
|
| 986 |
+
def _empty_citations() -> str:
|
| 987 |
+
return "<div class='citation-empty'>Sources appear when the check-in starts.</div>"
|
| 988 |
+
|
| 989 |
+
|
| 990 |
+
def _lexical_score(query: str, text: str) -> float:
|
| 991 |
+
query_tokens = _tokens(query)
|
| 992 |
+
text_tokens = _tokens(text)
|
| 993 |
+
if not query_tokens or not text_tokens:
|
| 994 |
+
return 0.0
|
| 995 |
+
overlap = query_tokens & text_tokens
|
| 996 |
+
return float(len(overlap) / max(len(query_tokens), 1))
|
| 997 |
+
|
| 998 |
+
|
| 999 |
+
def _tokens(text: str) -> set[str]:
|
| 1000 |
+
stop = {
|
| 1001 |
+
"the",
|
| 1002 |
+
"and",
|
| 1003 |
+
"for",
|
| 1004 |
+
"that",
|
| 1005 |
+
"with",
|
| 1006 |
+
"you",
|
| 1007 |
+
"your",
|
| 1008 |
+
"this",
|
| 1009 |
+
"are",
|
| 1010 |
+
"was",
|
| 1011 |
+
"have",
|
| 1012 |
+
"has",
|
| 1013 |
+
"but",
|
| 1014 |
+
"not",
|
| 1015 |
+
"one",
|
| 1016 |
+
}
|
| 1017 |
+
return {token for token in re.findall(r"[a-z]{3,}", text.lower()) if token not in stop}
|
| 1018 |
+
|
| 1019 |
+
|
| 1020 |
+
def _escape(value: str) -> str:
|
| 1021 |
+
return html.escape(value, quote=True)
|
apps/decompress/static/decompress.css
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--ink: #1f2a2c;
|
| 3 |
+
--paper: #f4f7f5;
|
| 4 |
+
--mist: #dce8e3;
|
| 5 |
+
--sage: #6e9b8f;
|
| 6 |
+
--moss: #355c55;
|
| 7 |
+
--blue: #8fb7c7;
|
| 8 |
+
--rose: #d88d84;
|
| 9 |
+
--plum: #59465d;
|
| 10 |
+
--line: rgba(31, 42, 44, 0.18);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
body,
|
| 14 |
+
.gradio-container {
|
| 15 |
+
background:
|
| 16 |
+
linear-gradient(135deg, rgba(31, 42, 44, 0.035) 25%, transparent 25%) 0 0 / 34px 34px,
|
| 17 |
+
linear-gradient(145deg, #eef4f1 0%, #d7e5e1 46%, #edf2f6 100%) !important;
|
| 18 |
+
color: var(--ink) !important;
|
| 19 |
+
font-family: "Aptos", "Segoe UI", sans-serif !important;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.gradio-container {
|
| 23 |
+
max-width: none !important;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
#decompress-control-row {
|
| 27 |
+
width: min(1180px, calc(100vw - 36px));
|
| 28 |
+
margin: 18px auto 28px;
|
| 29 |
+
align-items: stretch;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
#decompress-input textarea,
|
| 33 |
+
#decompress-transcript textarea {
|
| 34 |
+
border: 1px solid rgba(31, 42, 44, 0.18) !important;
|
| 35 |
+
background: rgba(255, 255, 255, 0.68) !important;
|
| 36 |
+
color: var(--ink) !important;
|
| 37 |
+
border-radius: 6px !important;
|
| 38 |
+
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75) !important;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
#decompress-controls {
|
| 42 |
+
min-width: 280px;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
#run-decompress,
|
| 46 |
+
#run-decompress-voice {
|
| 47 |
+
border-radius: 6px !important;
|
| 48 |
+
border: 0 !important;
|
| 49 |
+
min-height: 44px !important;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
#run-decompress {
|
| 53 |
+
background: var(--moss) !important;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
#run-decompress-voice {
|
| 57 |
+
background: rgba(89, 70, 93, 0.12) !important;
|
| 58 |
+
color: var(--plum) !important;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.decompress-room {
|
| 62 |
+
position: relative;
|
| 63 |
+
width: min(1180px, calc(100vw - 36px));
|
| 64 |
+
margin: 24px auto 0;
|
| 65 |
+
padding: 0;
|
| 66 |
+
color: var(--ink);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.calm-hero {
|
| 70 |
+
min-height: 270px;
|
| 71 |
+
display: grid;
|
| 72 |
+
grid-template-columns: minmax(170px, 230px) 1fr auto;
|
| 73 |
+
gap: 26px;
|
| 74 |
+
align-items: center;
|
| 75 |
+
border-bottom: 1px solid var(--line);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.breath-dial {
|
| 79 |
+
width: min(19vw, 210px);
|
| 80 |
+
min-width: 150px;
|
| 81 |
+
aspect-ratio: 1;
|
| 82 |
+
border-radius: 50%;
|
| 83 |
+
border: 1px solid rgba(53, 92, 85, 0.26);
|
| 84 |
+
display: grid;
|
| 85 |
+
place-items: center;
|
| 86 |
+
background:
|
| 87 |
+
linear-gradient(135deg, rgba(255, 255, 255, 0.72), rgba(220, 232, 227, 0.48)),
|
| 88 |
+
repeating-conic-gradient(from 0deg, rgba(53, 92, 85, 0.18) 0deg 3deg, transparent 3deg 12deg);
|
| 89 |
+
box-shadow: 0 20px 50px rgba(31, 42, 44, 0.12);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.breath-dial span {
|
| 93 |
+
width: 54%;
|
| 94 |
+
aspect-ratio: 1;
|
| 95 |
+
border-radius: 50%;
|
| 96 |
+
border: 1px solid rgba(216, 141, 132, 0.55);
|
| 97 |
+
animation: breathe 5.8s ease-in-out infinite;
|
| 98 |
+
background: rgba(255, 255, 255, 0.36);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
@keyframes breathe {
|
| 102 |
+
0%,
|
| 103 |
+
100% {
|
| 104 |
+
transform: scale(0.82);
|
| 105 |
+
opacity: 0.62;
|
| 106 |
+
}
|
| 107 |
+
50% {
|
| 108 |
+
transform: scale(1.08);
|
| 109 |
+
opacity: 1;
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.hero-copy .kicker {
|
| 114 |
+
color: var(--moss);
|
| 115 |
+
font-size: 0.78rem;
|
| 116 |
+
letter-spacing: 0.22em;
|
| 117 |
+
font-weight: 800;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.hero-copy h1 {
|
| 121 |
+
margin: 10px 0 12px;
|
| 122 |
+
font-size: clamp(2.2rem, 5vw, 5.8rem);
|
| 123 |
+
line-height: 0.95;
|
| 124 |
+
letter-spacing: 0;
|
| 125 |
+
max-width: 760px;
|
| 126 |
+
color: var(--ink);
|
| 127 |
+
font-family: Georgia, "Times New Roman", serif;
|
| 128 |
+
font-weight: 500;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.hero-copy p {
|
| 132 |
+
max-width: 660px;
|
| 133 |
+
font-size: 1.04rem;
|
| 134 |
+
line-height: 1.6;
|
| 135 |
+
color: rgba(31, 42, 44, 0.75);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.session-chip {
|
| 139 |
+
display: grid;
|
| 140 |
+
gap: 8px;
|
| 141 |
+
align-self: start;
|
| 142 |
+
justify-items: end;
|
| 143 |
+
padding-top: 20px;
|
| 144 |
+
font-size: 0.78rem;
|
| 145 |
+
color: rgba(31, 42, 44, 0.7);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.session-chip span {
|
| 149 |
+
border: 1px solid var(--line);
|
| 150 |
+
border-radius: 999px;
|
| 151 |
+
padding: 6px 10px;
|
| 152 |
+
background: rgba(255, 255, 255, 0.45);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.soft-note {
|
| 156 |
+
max-width: 260px;
|
| 157 |
+
border-color: rgba(216, 141, 132, 0.35) !important;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.companion-grid {
|
| 161 |
+
display: grid;
|
| 162 |
+
grid-template-columns: 320px minmax(360px, 1fr) 300px;
|
| 163 |
+
gap: 18px;
|
| 164 |
+
margin-top: 18px;
|
| 165 |
+
align-items: stretch;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.companion-card,
|
| 169 |
+
.transcript-panel,
|
| 170 |
+
.grounding-panel {
|
| 171 |
+
border: 1px solid var(--line);
|
| 172 |
+
background: rgba(255, 255, 255, 0.48);
|
| 173 |
+
box-shadow: 0 18px 45px rgba(31, 42, 44, 0.08);
|
| 174 |
+
backdrop-filter: blur(16px);
|
| 175 |
+
border-radius: 8px;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.companion-card {
|
| 179 |
+
padding: 18px;
|
| 180 |
+
min-height: 330px;
|
| 181 |
+
display: flex;
|
| 182 |
+
flex-direction: column;
|
| 183 |
+
justify-content: space-between;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.companion-card.take_floor,
|
| 187 |
+
.companion-card.backchannel {
|
| 188 |
+
border-color: rgba(53, 92, 85, 0.5);
|
| 189 |
+
box-shadow: 0 18px 55px rgba(53, 92, 85, 0.2);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.card-top {
|
| 193 |
+
display: flex;
|
| 194 |
+
gap: 13px;
|
| 195 |
+
align-items: center;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.moon-mark {
|
| 199 |
+
width: 48px;
|
| 200 |
+
aspect-ratio: 1;
|
| 201 |
+
border-radius: 50%;
|
| 202 |
+
display: grid;
|
| 203 |
+
place-items: center;
|
| 204 |
+
background: var(--ink);
|
| 205 |
+
color: var(--paper);
|
| 206 |
+
font-family: Georgia, "Times New Roman", serif;
|
| 207 |
+
font-size: 1.35rem;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.card-top h2 {
|
| 211 |
+
margin: 0;
|
| 212 |
+
font-family: Georgia, "Times New Roman", serif;
|
| 213 |
+
font-weight: 500;
|
| 214 |
+
font-size: 1.7rem;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.card-top p {
|
| 218 |
+
margin: 2px 0 0;
|
| 219 |
+
color: rgba(31, 42, 44, 0.66);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.urge-readout {
|
| 223 |
+
display: flex;
|
| 224 |
+
justify-content: space-between;
|
| 225 |
+
align-items: end;
|
| 226 |
+
margin-top: 32px;
|
| 227 |
+
color: rgba(31, 42, 44, 0.68);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.urge-readout strong {
|
| 231 |
+
font-size: 2.35rem;
|
| 232 |
+
font-family: Georgia, "Times New Roman", serif;
|
| 233 |
+
color: var(--moss);
|
| 234 |
+
font-weight: 500;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.soft-meter {
|
| 238 |
+
height: 18px;
|
| 239 |
+
border: 1px solid rgba(31, 42, 44, 0.18);
|
| 240 |
+
border-radius: 999px;
|
| 241 |
+
position: relative;
|
| 242 |
+
overflow: hidden;
|
| 243 |
+
background: rgba(255, 255, 255, 0.55);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.soft-fill {
|
| 247 |
+
width: var(--fill);
|
| 248 |
+
height: 100%;
|
| 249 |
+
background: linear-gradient(90deg, var(--blue), var(--sage), var(--rose));
|
| 250 |
+
transition: width 360ms ease;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.soft-meter i {
|
| 254 |
+
position: absolute;
|
| 255 |
+
left: var(--tau);
|
| 256 |
+
top: -5px;
|
| 257 |
+
bottom: -5px;
|
| 258 |
+
width: 2px;
|
| 259 |
+
background: var(--plum);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.signal-row,
|
| 263 |
+
.stats-line {
|
| 264 |
+
display: flex;
|
| 265 |
+
flex-wrap: wrap;
|
| 266 |
+
gap: 7px;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.signal-row span,
|
| 270 |
+
.stats-line span {
|
| 271 |
+
padding: 6px 8px;
|
| 272 |
+
border-radius: 999px;
|
| 273 |
+
background: rgba(31, 42, 44, 0.06);
|
| 274 |
+
color: rgba(31, 42, 44, 0.68);
|
| 275 |
+
font-size: 0.78rem;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.transcript-panel,
|
| 279 |
+
.grounding-panel {
|
| 280 |
+
min-height: 330px;
|
| 281 |
+
display: flex;
|
| 282 |
+
flex-direction: column;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.panel-title {
|
| 286 |
+
height: 44px;
|
| 287 |
+
display: flex;
|
| 288 |
+
align-items: center;
|
| 289 |
+
justify-content: space-between;
|
| 290 |
+
padding: 0 14px;
|
| 291 |
+
border-bottom: 1px solid var(--line);
|
| 292 |
+
color: rgba(31, 42, 44, 0.68);
|
| 293 |
+
text-transform: uppercase;
|
| 294 |
+
font-size: 0.72rem;
|
| 295 |
+
letter-spacing: 0.1em;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.panel-title em {
|
| 299 |
+
font-style: normal;
|
| 300 |
+
text-transform: none;
|
| 301 |
+
letter-spacing: 0;
|
| 302 |
+
max-width: 180px;
|
| 303 |
+
overflow: hidden;
|
| 304 |
+
text-overflow: ellipsis;
|
| 305 |
+
white-space: nowrap;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.decompress-scroll {
|
| 309 |
+
flex: 1;
|
| 310 |
+
max-height: 390px;
|
| 311 |
+
overflow-y: auto;
|
| 312 |
+
padding: 14px;
|
| 313 |
+
display: flex;
|
| 314 |
+
flex-direction: column;
|
| 315 |
+
gap: 10px;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.soft-feed {
|
| 319 |
+
border-left: 3px solid rgba(31, 42, 44, 0.16);
|
| 320 |
+
padding: 10px 12px;
|
| 321 |
+
background: rgba(255, 255, 255, 0.42);
|
| 322 |
+
border-radius: 0 6px 6px 0;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.soft-feed.companion,
|
| 326 |
+
.soft-feed.companion-backchannel {
|
| 327 |
+
border-left-color: var(--moss);
|
| 328 |
+
background: rgba(110, 155, 143, 0.13);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.soft-feed.system {
|
| 332 |
+
border-left-color: var(--blue);
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.feed-meta {
|
| 336 |
+
display: flex;
|
| 337 |
+
justify-content: space-between;
|
| 338 |
+
color: rgba(31, 42, 44, 0.58);
|
| 339 |
+
font-size: 0.74rem;
|
| 340 |
+
margin-bottom: 5px;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.feed-meta em {
|
| 344 |
+
font-style: normal;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.feed-text {
|
| 348 |
+
line-height: 1.48;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.soft-feed b {
|
| 352 |
+
display: inline-block;
|
| 353 |
+
margin-top: 7px;
|
| 354 |
+
font-size: 0.7rem;
|
| 355 |
+
color: var(--moss);
|
| 356 |
+
letter-spacing: 0.08em;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.citation-list {
|
| 360 |
+
padding: 14px;
|
| 361 |
+
display: grid;
|
| 362 |
+
gap: 10px;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
.citation-card,
|
| 366 |
+
.citation-empty {
|
| 367 |
+
display: block;
|
| 368 |
+
text-decoration: none;
|
| 369 |
+
color: var(--ink);
|
| 370 |
+
padding: 12px;
|
| 371 |
+
border-radius: 6px;
|
| 372 |
+
border: 1px solid rgba(31, 42, 44, 0.13);
|
| 373 |
+
background: rgba(255, 255, 255, 0.38);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.citation-card strong {
|
| 377 |
+
display: block;
|
| 378 |
+
font-family: Georgia, "Times New Roman", serif;
|
| 379 |
+
font-size: 1.02rem;
|
| 380 |
+
font-weight: 500;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
.citation-card span,
|
| 384 |
+
.citation-card em,
|
| 385 |
+
.citation-empty {
|
| 386 |
+
display: block;
|
| 387 |
+
margin-top: 5px;
|
| 388 |
+
font-size: 0.78rem;
|
| 389 |
+
color: rgba(31, 42, 44, 0.62);
|
| 390 |
+
font-style: normal;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
.disclaimer {
|
| 394 |
+
margin: auto 14px 14px;
|
| 395 |
+
padding-top: 14px;
|
| 396 |
+
border-top: 1px solid var(--line);
|
| 397 |
+
color: rgba(31, 42, 44, 0.64);
|
| 398 |
+
font-size: 0.82rem;
|
| 399 |
+
line-height: 1.45;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
@media (max-width: 980px) {
|
| 403 |
+
.calm-hero,
|
| 404 |
+
.companion-grid {
|
| 405 |
+
grid-template-columns: 1fr;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.session-chip {
|
| 409 |
+
justify-items: start;
|
| 410 |
+
grid-template-columns: repeat(3, max-content);
|
| 411 |
+
padding-top: 0;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.breath-dial {
|
| 415 |
+
width: 150px;
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
@media (max-width: 640px) {
|
| 420 |
+
.decompress-room,
|
| 421 |
+
#decompress-control-row {
|
| 422 |
+
width: min(100vw - 20px, 1180px);
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
.hero-copy h1 {
|
| 426 |
+
font-size: 2.4rem;
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
.session-chip {
|
| 430 |
+
grid-template-columns: 1fr;
|
| 431 |
+
}
|
| 432 |
+
}
|
apps/decompress/static/decompress.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(() => {
|
| 2 |
+
const scrollTranscript = () => {
|
| 3 |
+
const pane = document.querySelector("#decompress-room .decompress-scroll");
|
| 4 |
+
if (pane) pane.scrollTop = pane.scrollHeight;
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
const observer = new MutationObserver(scrollTranscript);
|
| 8 |
+
const start = () => {
|
| 9 |
+
const root = document.querySelector("#decompress-room-output");
|
| 10 |
+
if (!root) return;
|
| 11 |
+
observer.observe(root, { childList: true, subtree: true });
|
| 12 |
+
scrollTranscript();
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
if (document.readyState === "loading") {
|
| 16 |
+
document.addEventListener("DOMContentLoaded", start);
|
| 17 |
+
} else {
|
| 18 |
+
start();
|
| 19 |
+
}
|
| 20 |
+
})();
|
engine/CONTROLLER_NOTES.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# WhenToSpeak Controller Notes
|
| 2 |
+
|
| 3 |
+
The controller is training-free. It consumes signals from a `Brain` interface and
|
| 4 |
+
never loads or calls a model itself. The live brain must provide, for each
|
| 5 |
+
incremental transcript update and each agent context, mean token surprise, a
|
| 6 |
+
last-layer hidden vector, readiness, and turn-end probability.
|
| 7 |
+
|
| 8 |
+
## Signals
|
| 9 |
+
|
| 10 |
+
- `surprise`: mean per-token negative log-likelihood of the newly added user
|
| 11 |
+
tokens, teacher-forced.
|
| 12 |
+
- `hidden`: mean last-layer hidden-state vector for the newly added tokens.
|
| 13 |
+
- `readiness`: speculative reply confidence for this agent. Draft about eight
|
| 14 |
+
tokens and compute `readiness = 1 / (1 + mean_token_entropy)`.
|
| 15 |
+
- `p_end`: heuristic probability that the human turn is complete. The live loop
|
| 16 |
+
should combine trailing silence, sentence-final punctuation, and high EOS
|
| 17 |
+
probability.
|
| 18 |
+
|
| 19 |
+
## Urge
|
| 20 |
+
|
| 21 |
+
Each agent keeps an online running mean/std of surprise and uses the current
|
| 22 |
+
z-score. Hidden-state cosine deltas feed Adams-MacKay BOCPD; a collapse in MAP
|
| 23 |
+
run-length becomes the change-point score.
|
| 24 |
+
|
| 25 |
+
```text
|
| 26 |
+
U_t = w_surprise*z(surprise)
|
| 27 |
+
+ w_change*changepoint_score
|
| 28 |
+
+ w_readiness*readiness
|
| 29 |
+
+ w_end*p_end
|
| 30 |
+
+ w_barge*max(z(surprise), 0)*readiness*(1 - p_end)
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
`tau` is the single global conversational-aggressiveness knob. Lower `tau` makes
|
| 34 |
+
the panel take the floor sooner; higher `tau` makes it wait.
|
| 35 |
+
|
| 36 |
+
## Arbitration
|
| 37 |
+
|
| 38 |
+
Each tick is deterministic. Agents first classify local intent as `SILENT`,
|
| 39 |
+
`BACKCHANNEL`, `TAKE_FLOOR`, or `INTERRUPT`. Only the highest-urge agent above
|
| 40 |
+
`tau` may take the floor or interrupt. Non-winning agents may still backchannel
|
| 41 |
+
if their urge clears the derived backchannel threshold. A short refractory period
|
| 42 |
+
prevents repeated firing on adjacent ASR updates.
|
engine/CONVERSATION_NOTES.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Live Brain + Conversation Notes
|
| 2 |
+
|
| 3 |
+
This phase keeps the controller training-free and model-agnostic. The real model
|
| 4 |
+
lives behind `modal_app/brain_modal.py`; local code only asks for `BrainSignals`
|
| 5 |
+
and generated replies.
|
| 6 |
+
|
| 7 |
+
## Flow
|
| 8 |
+
|
| 9 |
+
1. The text stream feeds incremental word groups plus an optional silence flag.
|
| 10 |
+
2. `Conversation` sends the current dialogue prefix and newest user chunk to
|
| 11 |
+
`LiveBrainPanel.step_all()`.
|
| 12 |
+
3. Modal computes per-agent surprise, hidden vector, readiness, and `p_end`.
|
| 13 |
+
4. `WhenToSpeakController` arbitrates `SILENT`, `BACKCHANNEL`, `TAKE_FLOOR`, or
|
| 14 |
+
`INTERRUPT`.
|
| 15 |
+
5. On `TAKE_FLOOR` or `INTERRUPT`, `Conversation` calls Modal `generate()` and
|
| 16 |
+
splices the short investor reply into the dialogue.
|
| 17 |
+
|
| 18 |
+
The sample pitch deliberately includes a weak claim: "ten thousand stores and
|
| 19 |
+
zero churn after launching last week." The expected demo behavior is an investor
|
| 20 |
+
interrupt or floor-take near that claim, with the generated line recorded in
|
| 21 |
+
`eval/conversation_log.json`.
|
| 22 |
+
|
| 23 |
+
Run the real text-streamed demo with:
|
| 24 |
+
|
| 25 |
+
```text
|
| 26 |
+
uv run modal run modal_app/brain_modal.py
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
## Modal
|
| 30 |
+
|
| 31 |
+
The live brain tries `nvidia/Llama-3.1-Nemotron-Nano-4B-v1.1` first and falls
|
| 32 |
+
back to `Qwen/Qwen2.5-3B-Instruct`. We keep `HF_HOME=/cache` on a Modal Volume so
|
| 33 |
+
weights persist across runs.
|
| 34 |
+
|
| 35 |
+
## Recorded Demo
|
| 36 |
+
|
| 37 |
+
The committed `eval/conversation_log.json` was produced by:
|
| 38 |
+
|
| 39 |
+
```text
|
| 40 |
+
uv run modal run modal_app/brain_modal.py
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
Latest measured run: `nvidia/Llama-3.1-Nemotron-Nano-4B-v1.1` on `NVIDIA A10`.
|
| 44 |
+
Total wall time was 49.1 s because the run included Modal container/model startup.
|
| 45 |
+
|
| 46 |
+
At step 3 the controller interrupted the planted weak claim. The winning agent
|
| 47 |
+
was `ruthless_skeptic` with urge 1.47 and readiness 0.67. At step 7 the panel now
|
| 48 |
+
takes the floor at turn end.
|
| 49 |
+
|
| 50 |
+
```text
|
| 51 |
+
Ruthless Skeptic: Zero churn after one week is not churn data.
|
| 52 |
+
Vision Optimist: Show cohorts, paid conversion, and retention.
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
Generation caveat: Nemotron-Nano produced malformed text for these two `generate()`
|
| 56 |
+
calls, so `eval/conversation_log.json` records `reply_source: "fallback"` for both.
|
| 57 |
+
The timing signals and controller decisions are still real Modal/Nemotron outputs;
|
| 58 |
+
the fallback only guards the spoken text until the generator prompt/model is improved.
|
engine/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training-free WhenToSpeak controller components."""
|
| 2 |
+
|
engine/bocpd.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
import sys
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
+
DEFAULT_DUMP_PATH = ROOT / "eval" / "probe_dump.npz"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True)
|
| 16 |
+
class BocpdResult:
|
| 17 |
+
step: int
|
| 18 |
+
value: float
|
| 19 |
+
cp_prob: float
|
| 20 |
+
map_run_length: int
|
| 21 |
+
change_point: bool
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def logsumexp(values: np.ndarray) -> float:
|
| 25 |
+
max_value = float(np.max(values))
|
| 26 |
+
if not math.isfinite(max_value):
|
| 27 |
+
return max_value
|
| 28 |
+
return max_value + math.log(float(np.sum(np.exp(values - max_value))))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def student_t_logpdf(x: float, mu: np.ndarray, kappa: np.ndarray, alpha: np.ndarray, beta: np.ndarray) -> np.ndarray:
|
| 32 |
+
nu = 2.0 * alpha
|
| 33 |
+
scale = np.sqrt(beta * (kappa + 1.0) / (alpha * kappa))
|
| 34 |
+
z = (x - mu) / scale
|
| 35 |
+
return (
|
| 36 |
+
np.vectorize(math.lgamma)((nu + 1.0) / 2.0)
|
| 37 |
+
- np.vectorize(math.lgamma)(nu / 2.0)
|
| 38 |
+
- 0.5 * np.log(nu * math.pi)
|
| 39 |
+
- np.log(scale)
|
| 40 |
+
- ((nu + 1.0) / 2.0) * np.log1p((z * z) / nu)
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def update_nig(
|
| 45 |
+
x: float,
|
| 46 |
+
mu: np.ndarray,
|
| 47 |
+
kappa: np.ndarray,
|
| 48 |
+
alpha: np.ndarray,
|
| 49 |
+
beta: np.ndarray,
|
| 50 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
| 51 |
+
next_kappa = kappa + 1.0
|
| 52 |
+
next_mu = (kappa * mu + x) / next_kappa
|
| 53 |
+
next_alpha = alpha + 0.5
|
| 54 |
+
next_beta = beta + 0.5 * kappa * (x - mu) ** 2 / next_kappa
|
| 55 |
+
return next_mu, next_kappa, next_alpha, next_beta
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def run_bocpd(
|
| 59 |
+
values: np.ndarray,
|
| 60 |
+
hazard: float = 1.0 / 50.0,
|
| 61 |
+
prior_mu: float = 0.0,
|
| 62 |
+
prior_kappa: float = 1.0e-3,
|
| 63 |
+
prior_alpha: float = 1.0,
|
| 64 |
+
prior_beta: float = 1.0,
|
| 65 |
+
) -> list[BocpdResult]:
|
| 66 |
+
log_hazard = math.log(hazard)
|
| 67 |
+
log_growth_factor = math.log1p(-hazard)
|
| 68 |
+
|
| 69 |
+
log_run_probs = np.asarray([0.0], dtype=np.float64)
|
| 70 |
+
mu = np.asarray([prior_mu], dtype=np.float64)
|
| 71 |
+
kappa = np.asarray([prior_kappa], dtype=np.float64)
|
| 72 |
+
alpha = np.asarray([prior_alpha], dtype=np.float64)
|
| 73 |
+
beta = np.asarray([prior_beta], dtype=np.float64)
|
| 74 |
+
|
| 75 |
+
results: list[BocpdResult] = []
|
| 76 |
+
previous_map_run_length: int | None = None
|
| 77 |
+
|
| 78 |
+
for step, x_value in enumerate(values, start=1):
|
| 79 |
+
x = float(x_value)
|
| 80 |
+
predictive = student_t_logpdf(x, mu, kappa, alpha, beta)
|
| 81 |
+
|
| 82 |
+
growth_probs = log_run_probs + predictive + log_growth_factor
|
| 83 |
+
cp_prob = logsumexp(log_run_probs + predictive + log_hazard)
|
| 84 |
+
new_log_run_probs = np.concatenate(([cp_prob], growth_probs))
|
| 85 |
+
normalizer = logsumexp(new_log_run_probs)
|
| 86 |
+
new_log_run_probs -= normalizer
|
| 87 |
+
|
| 88 |
+
grown_mu, grown_kappa, grown_alpha, grown_beta = update_nig(x, mu, kappa, alpha, beta)
|
| 89 |
+
mu = np.concatenate(([prior_mu], grown_mu))
|
| 90 |
+
kappa = np.concatenate(([prior_kappa], grown_kappa))
|
| 91 |
+
alpha = np.concatenate(([prior_alpha], grown_alpha))
|
| 92 |
+
beta = np.concatenate(([prior_beta], grown_beta))
|
| 93 |
+
log_run_probs = new_log_run_probs
|
| 94 |
+
|
| 95 |
+
map_run_length = int(np.argmax(log_run_probs))
|
| 96 |
+
cp_probability = float(np.exp(log_run_probs[0]))
|
| 97 |
+
change_point = bool(
|
| 98 |
+
map_run_length == 0
|
| 99 |
+
or (previous_map_run_length is not None and map_run_length < previous_map_run_length)
|
| 100 |
+
)
|
| 101 |
+
results.append(
|
| 102 |
+
BocpdResult(
|
| 103 |
+
step=step,
|
| 104 |
+
value=x,
|
| 105 |
+
cp_prob=cp_probability,
|
| 106 |
+
map_run_length=map_run_length,
|
| 107 |
+
change_point=change_point,
|
| 108 |
+
)
|
| 109 |
+
)
|
| 110 |
+
previous_map_run_length = map_run_length
|
| 111 |
+
|
| 112 |
+
return results
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def main() -> None:
|
| 116 |
+
dump_path = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_DUMP_PATH
|
| 117 |
+
if not dump_path.is_absolute():
|
| 118 |
+
dump_path = ROOT / dump_path
|
| 119 |
+
|
| 120 |
+
if not dump_path.exists():
|
| 121 |
+
print(f"No probe dump found at {dump_path}")
|
| 122 |
+
return
|
| 123 |
+
|
| 124 |
+
dump = np.load(dump_path, allow_pickle=True)
|
| 125 |
+
failure = str(dump.get("failure", ""))
|
| 126 |
+
values = np.asarray(dump["nll_series"], dtype=np.float64)
|
| 127 |
+
if values.size == 0:
|
| 128 |
+
print("No NLL samples found; skipping BOCPD.")
|
| 129 |
+
if failure:
|
| 130 |
+
print(f"Probe failure: {failure}")
|
| 131 |
+
return
|
| 132 |
+
|
| 133 |
+
print("step | nll | cp_prob | map_run_length | change_point")
|
| 134 |
+
print("-----|-----|---------|----------------|-------------")
|
| 135 |
+
for result in run_bocpd(values):
|
| 136 |
+
flag = "YES" if result.change_point else "no"
|
| 137 |
+
print(
|
| 138 |
+
f"{result.step:>4} | {result.value:.4f} | {result.cp_prob:.4f} | "
|
| 139 |
+
f"{result.map_run_length:>14} | {flag}"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
if __name__ == "__main__":
|
| 144 |
+
main()
|
engine/brain.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Iterable, Protocol, Sequence
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass(frozen=True)
|
| 11 |
+
class BrainSignals:
|
| 12 |
+
"""Signals emitted by one agent-context brain for one transcript update.
|
| 13 |
+
|
| 14 |
+
`readiness` is the controller-facing score for a speculative short reply:
|
| 15 |
+
|
| 16 |
+
readiness = 1 / (1 + mean_token_entropy)
|
| 17 |
+
|
| 18 |
+
A low-entropy draft means this agent has a confident next move. `p_end` is a
|
| 19 |
+
turn-completion heuristic: in the live loop it should combine trailing
|
| 20 |
+
silence, sentence-final punctuation, and/or high EOS probability.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
surprise: float
|
| 24 |
+
hidden: np.ndarray
|
| 25 |
+
readiness: float
|
| 26 |
+
p_end: float
|
| 27 |
+
|
| 28 |
+
def __post_init__(self) -> None:
|
| 29 |
+
hidden = np.asarray(self.hidden, dtype=np.float32)
|
| 30 |
+
if hidden.ndim != 1:
|
| 31 |
+
raise ValueError("hidden must be a 1-D float32 vector")
|
| 32 |
+
if not 0.0 <= float(self.readiness) <= 1.0:
|
| 33 |
+
raise ValueError("readiness must be in [0, 1]")
|
| 34 |
+
if not 0.0 <= float(self.p_end) <= 1.0:
|
| 35 |
+
raise ValueError("p_end must be in [0, 1]")
|
| 36 |
+
object.__setattr__(self, "hidden", hidden)
|
| 37 |
+
object.__setattr__(self, "surprise", float(self.surprise))
|
| 38 |
+
object.__setattr__(self, "readiness", float(self.readiness))
|
| 39 |
+
object.__setattr__(self, "p_end", float(self.p_end))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class Brain(Protocol):
|
| 43 |
+
"""Interface the live instrumented LLM must satisfy for one agent context."""
|
| 44 |
+
|
| 45 |
+
def next_signals(self) -> BrainSignals:
|
| 46 |
+
"""Return signals for the newest incremental transcript update."""
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class ReplayBrain:
|
| 50 |
+
"""Deterministic `Brain` backed by precomputed signal samples."""
|
| 51 |
+
|
| 52 |
+
def __init__(self, samples: Sequence[BrainSignals | dict[str, object]], *, name: str = "replay") -> None:
|
| 53 |
+
self.name = name
|
| 54 |
+
self._samples = [coerce_signals(sample) for sample in samples]
|
| 55 |
+
self._index = 0
|
| 56 |
+
|
| 57 |
+
def __len__(self) -> int:
|
| 58 |
+
return len(self._samples)
|
| 59 |
+
|
| 60 |
+
def __iter__(self) -> Iterable[BrainSignals]:
|
| 61 |
+
return iter(self._samples)
|
| 62 |
+
|
| 63 |
+
def reset(self) -> None:
|
| 64 |
+
self._index = 0
|
| 65 |
+
|
| 66 |
+
def next_signals(self) -> BrainSignals:
|
| 67 |
+
if self._index >= len(self._samples):
|
| 68 |
+
raise StopIteration(f"ReplayBrain {self.name!r} is exhausted")
|
| 69 |
+
sample = self._samples[self._index]
|
| 70 |
+
self._index += 1
|
| 71 |
+
return sample
|
| 72 |
+
|
| 73 |
+
@classmethod
|
| 74 |
+
def from_npz(
|
| 75 |
+
cls,
|
| 76 |
+
path: str | Path,
|
| 77 |
+
*,
|
| 78 |
+
readiness: Sequence[float] | None = None,
|
| 79 |
+
p_end: Sequence[float] | None = None,
|
| 80 |
+
name: str | None = None,
|
| 81 |
+
) -> "ReplayBrain":
|
| 82 |
+
dump = np.load(path, allow_pickle=True)
|
| 83 |
+
surprises = np.asarray(dump["nll_series"], dtype=np.float32)
|
| 84 |
+
hidden = np.asarray(dump["hidden_states"], dtype=np.float32)
|
| 85 |
+
if hidden.ndim != 2:
|
| 86 |
+
raise ValueError("hidden_states in npz must be a 2-D matrix")
|
| 87 |
+
|
| 88 |
+
n_steps = int(surprises.shape[0])
|
| 89 |
+
readiness_values = (
|
| 90 |
+
np.asarray(readiness, dtype=np.float32)
|
| 91 |
+
if readiness is not None
|
| 92 |
+
else np.linspace(0.35, 0.75, n_steps, dtype=np.float32)
|
| 93 |
+
)
|
| 94 |
+
p_end_values = np.asarray(p_end, dtype=np.float32) if p_end is not None else np.zeros(n_steps, dtype=np.float32)
|
| 95 |
+
if n_steps:
|
| 96 |
+
p_end_values[-1] = max(float(p_end_values[-1]), 0.95)
|
| 97 |
+
|
| 98 |
+
samples = [
|
| 99 |
+
BrainSignals(
|
| 100 |
+
surprise=float(surprises[index]),
|
| 101 |
+
hidden=hidden[index],
|
| 102 |
+
readiness=float(readiness_values[index]),
|
| 103 |
+
p_end=float(p_end_values[index]),
|
| 104 |
+
)
|
| 105 |
+
for index in range(n_steps)
|
| 106 |
+
]
|
| 107 |
+
return cls(samples, name=name or Path(path).stem)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def coerce_signals(sample: BrainSignals | dict[str, object]) -> BrainSignals:
|
| 111 |
+
if isinstance(sample, BrainSignals):
|
| 112 |
+
return sample
|
| 113 |
+
return BrainSignals(
|
| 114 |
+
surprise=float(sample["surprise"]),
|
| 115 |
+
hidden=np.asarray(sample["hidden"], dtype=np.float32),
|
| 116 |
+
readiness=float(sample["readiness"]),
|
| 117 |
+
p_end=float(sample["p_end"]),
|
| 118 |
+
)
|
| 119 |
+
|
engine/controller.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from enum import Enum
|
| 5 |
+
from typing import Mapping
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from engine.bocpd import run_bocpd
|
| 10 |
+
from engine.brain import BrainSignals, coerce_signals
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Action(str, Enum):
|
| 14 |
+
SILENT = "SILENT"
|
| 15 |
+
BACKCHANNEL = "BACKCHANNEL"
|
| 16 |
+
TAKE_FLOOR = "TAKE_FLOOR"
|
| 17 |
+
INTERRUPT = "INTERRUPT"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass(frozen=True)
|
| 21 |
+
class ControllerConfig:
|
| 22 |
+
w_surprise: float = 0.70
|
| 23 |
+
w_change: float = 1.30
|
| 24 |
+
w_readiness: float = 0.80
|
| 25 |
+
w_end: float = 1.20
|
| 26 |
+
w_barge: float = 0.60
|
| 27 |
+
negative_surprise_weight: float = 0.25
|
| 28 |
+
tau: float = 1.60
|
| 29 |
+
backchannel_tau_fraction: float = 0.70
|
| 30 |
+
barge_tau_fraction: float = 0.50
|
| 31 |
+
take_floor_p_end: float = 0.70
|
| 32 |
+
interrupt_p_end_max: float = 0.35
|
| 33 |
+
backchannel_p_end_max: float = 0.35
|
| 34 |
+
min_readiness: float = 0.45
|
| 35 |
+
refractory_steps: int = 2
|
| 36 |
+
surprise_z_cap: float = 3.0
|
| 37 |
+
change_hazard: float = 0.35
|
| 38 |
+
change_prior_kappa: float = 0.20
|
| 39 |
+
change_prior_alpha: float = 0.75
|
| 40 |
+
change_prior_beta: float = 0.20
|
| 41 |
+
change_z_cap: float = 3.0
|
| 42 |
+
change_z_threshold: float = 1.15
|
| 43 |
+
turn_end_tau_discount: float = 0.35
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def backchannel_tau(self) -> float:
|
| 47 |
+
return self.backchannel_tau_fraction * self.tau
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def barge_tau(self) -> float:
|
| 51 |
+
return self.barge_tau_fraction * self.tau
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class RunningStats:
|
| 56 |
+
n: int = 0
|
| 57 |
+
mean: float = 0.0
|
| 58 |
+
m2: float = 0.0
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def std(self) -> float:
|
| 62 |
+
if self.n < 2:
|
| 63 |
+
return 1.0
|
| 64 |
+
return max((self.m2 / (self.n - 1)) ** 0.5, 1.0e-6)
|
| 65 |
+
|
| 66 |
+
def zscore(self, value: float, cap: float) -> float:
|
| 67 |
+
if self.n < 2:
|
| 68 |
+
return 0.0
|
| 69 |
+
z_value = (float(value) - self.mean) / self.std
|
| 70 |
+
return float(np.clip(z_value, -cap, cap))
|
| 71 |
+
|
| 72 |
+
def update(self, value: float) -> None:
|
| 73 |
+
self.n += 1
|
| 74 |
+
delta = float(value) - self.mean
|
| 75 |
+
self.mean += delta / self.n
|
| 76 |
+
self.m2 += delta * (float(value) - self.mean)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@dataclass
|
| 80 |
+
class AgentState:
|
| 81 |
+
surprise_stats: RunningStats = field(default_factory=RunningStats)
|
| 82 |
+
previous_hidden: np.ndarray | None = None
|
| 83 |
+
hidden_deltas: list[float] = field(default_factory=list)
|
| 84 |
+
hidden_delta_z: list[float] = field(default_factory=list)
|
| 85 |
+
hidden_delta_stats: RunningStats = field(default_factory=RunningStats)
|
| 86 |
+
previous_map_run_length: int | None = None
|
| 87 |
+
refractory_until: int = 0
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@dataclass(frozen=True)
|
| 91 |
+
class AgentDecision:
|
| 92 |
+
agent_id: str
|
| 93 |
+
action: Action
|
| 94 |
+
urge: float
|
| 95 |
+
z_surprise: float
|
| 96 |
+
change_score: float
|
| 97 |
+
readiness: float
|
| 98 |
+
p_end: float
|
| 99 |
+
hidden_delta: float
|
| 100 |
+
map_run_length: int
|
| 101 |
+
refractory: bool
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@dataclass(frozen=True)
|
| 105 |
+
class ControllerTick:
|
| 106 |
+
step: int
|
| 107 |
+
floor_holder: str
|
| 108 |
+
winner: str | None
|
| 109 |
+
decisions: dict[str, AgentDecision]
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class WhenToSpeakController:
|
| 113 |
+
"""Training-free multi-agent timing controller."""
|
| 114 |
+
|
| 115 |
+
def __init__(self, agent_ids: list[str], config: ControllerConfig | None = None) -> None:
|
| 116 |
+
if not agent_ids:
|
| 117 |
+
raise ValueError("agent_ids must not be empty")
|
| 118 |
+
self.agent_ids = list(agent_ids)
|
| 119 |
+
self.config = config or ControllerConfig()
|
| 120 |
+
self.states = {agent_id: AgentState() for agent_id in self.agent_ids}
|
| 121 |
+
self.step = 0
|
| 122 |
+
self.floor_holder = "human"
|
| 123 |
+
|
| 124 |
+
def reset(self) -> None:
|
| 125 |
+
self.states = {agent_id: AgentState() for agent_id in self.agent_ids}
|
| 126 |
+
self.step = 0
|
| 127 |
+
self.floor_holder = "human"
|
| 128 |
+
|
| 129 |
+
def tick(
|
| 130 |
+
self,
|
| 131 |
+
signals_by_agent: Mapping[str, BrainSignals | dict[str, object]],
|
| 132 |
+
*,
|
| 133 |
+
floor_holder: str | None = None,
|
| 134 |
+
) -> ControllerTick:
|
| 135 |
+
self.step += 1
|
| 136 |
+
if floor_holder is not None:
|
| 137 |
+
self.floor_holder = floor_holder
|
| 138 |
+
|
| 139 |
+
scored: dict[str, AgentDecision] = {}
|
| 140 |
+
proposed: dict[str, Action] = {}
|
| 141 |
+
for agent_id in self.agent_ids:
|
| 142 |
+
if agent_id not in signals_by_agent:
|
| 143 |
+
raise KeyError(f"missing signals for agent {agent_id!r}")
|
| 144 |
+
signal = coerce_signals(signals_by_agent[agent_id])
|
| 145 |
+
decision = self._score_agent(agent_id, signal)
|
| 146 |
+
scored[agent_id] = decision
|
| 147 |
+
proposed[agent_id] = decision.action
|
| 148 |
+
|
| 149 |
+
winner = self._floor_winner(scored)
|
| 150 |
+
final_decisions: dict[str, AgentDecision] = {}
|
| 151 |
+
for agent_id, decision in scored.items():
|
| 152 |
+
action = decision.action
|
| 153 |
+
if action in {Action.TAKE_FLOOR, Action.INTERRUPT} and agent_id != winner:
|
| 154 |
+
action = Action.BACKCHANNEL if self._may_backchannel(decision) else Action.SILENT
|
| 155 |
+
final_decisions[agent_id] = AgentDecision(
|
| 156 |
+
agent_id=decision.agent_id,
|
| 157 |
+
action=action,
|
| 158 |
+
urge=decision.urge,
|
| 159 |
+
z_surprise=decision.z_surprise,
|
| 160 |
+
change_score=decision.change_score,
|
| 161 |
+
readiness=decision.readiness,
|
| 162 |
+
p_end=decision.p_end,
|
| 163 |
+
hidden_delta=decision.hidden_delta,
|
| 164 |
+
map_run_length=decision.map_run_length,
|
| 165 |
+
refractory=decision.refractory,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
for agent_id, decision in final_decisions.items():
|
| 169 |
+
if decision.action in {Action.TAKE_FLOOR, Action.INTERRUPT}:
|
| 170 |
+
self.states[agent_id].refractory_until = self.step + self.config.refractory_steps
|
| 171 |
+
|
| 172 |
+
if winner is not None:
|
| 173 |
+
self.floor_holder = winner
|
| 174 |
+
|
| 175 |
+
return ControllerTick(
|
| 176 |
+
step=self.step,
|
| 177 |
+
floor_holder=self.floor_holder,
|
| 178 |
+
winner=winner,
|
| 179 |
+
decisions=final_decisions,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
def _score_agent(self, agent_id: str, signal: BrainSignals) -> AgentDecision:
|
| 183 |
+
state = self.states[agent_id]
|
| 184 |
+
z_surprise = state.surprise_stats.zscore(signal.surprise, self.config.surprise_z_cap)
|
| 185 |
+
hidden_delta, change_score, map_run_length = self._change_features(state, signal.hidden)
|
| 186 |
+
barge = self.config.w_barge * max(z_surprise, 0.0) * signal.readiness * (1.0 - signal.p_end)
|
| 187 |
+
surprise_term = z_surprise if z_surprise >= 0.0 else self.config.negative_surprise_weight * z_surprise
|
| 188 |
+
urge = (
|
| 189 |
+
self.config.w_surprise * surprise_term
|
| 190 |
+
+ self.config.w_change * change_score
|
| 191 |
+
+ self.config.w_readiness * signal.readiness
|
| 192 |
+
+ self.config.w_end * signal.p_end
|
| 193 |
+
+ barge
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
refractory = self.step <= state.refractory_until
|
| 197 |
+
action = self._classify(urge, z_surprise, change_score, signal, refractory)
|
| 198 |
+
|
| 199 |
+
state.surprise_stats.update(signal.surprise)
|
| 200 |
+
state.previous_hidden = signal.hidden.astype(np.float32, copy=True)
|
| 201 |
+
return AgentDecision(
|
| 202 |
+
agent_id=agent_id,
|
| 203 |
+
action=action,
|
| 204 |
+
urge=float(urge),
|
| 205 |
+
z_surprise=float(z_surprise),
|
| 206 |
+
change_score=float(change_score),
|
| 207 |
+
readiness=signal.readiness,
|
| 208 |
+
p_end=signal.p_end,
|
| 209 |
+
hidden_delta=float(hidden_delta),
|
| 210 |
+
map_run_length=int(map_run_length),
|
| 211 |
+
refractory=refractory,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
def _change_features(self, state: AgentState, hidden: np.ndarray) -> tuple[float, float, int]:
|
| 215 |
+
if state.previous_hidden is None:
|
| 216 |
+
return 0.0, 0.0, 0
|
| 217 |
+
|
| 218 |
+
hidden_delta = cosine_distance(state.previous_hidden, hidden)
|
| 219 |
+
delta_z = state.hidden_delta_stats.zscore(hidden_delta, self.config.change_z_cap)
|
| 220 |
+
state.hidden_delta_stats.update(hidden_delta)
|
| 221 |
+
state.hidden_deltas.append(hidden_delta)
|
| 222 |
+
state.hidden_delta_z.append(delta_z)
|
| 223 |
+
results = run_bocpd(
|
| 224 |
+
np.asarray(state.hidden_delta_z, dtype=np.float64),
|
| 225 |
+
hazard=self.config.change_hazard,
|
| 226 |
+
prior_kappa=self.config.change_prior_kappa,
|
| 227 |
+
prior_alpha=self.config.change_prior_alpha,
|
| 228 |
+
prior_beta=self.config.change_prior_beta,
|
| 229 |
+
)
|
| 230 |
+
latest = results[-1]
|
| 231 |
+
previous_map = state.previous_map_run_length
|
| 232 |
+
state.previous_map_run_length = latest.map_run_length
|
| 233 |
+
if previous_map is None:
|
| 234 |
+
return hidden_delta, 0.0, latest.map_run_length
|
| 235 |
+
|
| 236 |
+
collapsed = latest.map_run_length < previous_map
|
| 237 |
+
collapse_ratio = (previous_map - latest.map_run_length) / max(previous_map, 1)
|
| 238 |
+
collapse_score = max(1.0, collapse_ratio) if collapsed else 0.0
|
| 239 |
+
posterior_score = max(0.0, (latest.cp_prob - self.config.change_hazard) / max(1.0 - self.config.change_hazard, 1.0e-9))
|
| 240 |
+
z_score = max(0.0, abs(delta_z) - self.config.change_z_threshold) / max(
|
| 241 |
+
self.config.change_z_cap - self.config.change_z_threshold,
|
| 242 |
+
1.0e-9,
|
| 243 |
+
)
|
| 244 |
+
change_score = max(collapse_score, posterior_score, z_score)
|
| 245 |
+
return hidden_delta, float(change_score), latest.map_run_length
|
| 246 |
+
|
| 247 |
+
def _classify(
|
| 248 |
+
self,
|
| 249 |
+
urge: float,
|
| 250 |
+
z_surprise: float,
|
| 251 |
+
change_score: float,
|
| 252 |
+
signal: BrainSignals,
|
| 253 |
+
refractory: bool,
|
| 254 |
+
) -> Action:
|
| 255 |
+
if refractory:
|
| 256 |
+
return Action.SILENT
|
| 257 |
+
|
| 258 |
+
ready = signal.readiness >= self.config.min_readiness
|
| 259 |
+
human_has_floor = self.floor_holder == "human"
|
| 260 |
+
barge_signal = max(z_surprise, 0.0) * signal.readiness * (1.0 - signal.p_end)
|
| 261 |
+
floor_tau = self._effective_tau(signal.p_end)
|
| 262 |
+
|
| 263 |
+
if human_has_floor and ready and signal.p_end >= self.config.take_floor_p_end and urge >= floor_tau:
|
| 264 |
+
return Action.TAKE_FLOOR
|
| 265 |
+
if (
|
| 266 |
+
human_has_floor
|
| 267 |
+
and ready
|
| 268 |
+
and signal.p_end <= self.config.interrupt_p_end_max
|
| 269 |
+
and urge >= floor_tau
|
| 270 |
+
and barge_signal >= self.config.barge_tau
|
| 271 |
+
):
|
| 272 |
+
return Action.INTERRUPT
|
| 273 |
+
if (
|
| 274 |
+
human_has_floor
|
| 275 |
+
and ready
|
| 276 |
+
and signal.p_end <= self.config.backchannel_p_end_max
|
| 277 |
+
and change_score > 0.0
|
| 278 |
+
and urge >= self.config.backchannel_tau
|
| 279 |
+
):
|
| 280 |
+
return Action.BACKCHANNEL
|
| 281 |
+
if (
|
| 282 |
+
human_has_floor
|
| 283 |
+
and ready
|
| 284 |
+
and urge >= self.config.backchannel_tau
|
| 285 |
+
and signal.p_end <= self.config.backchannel_p_end_max
|
| 286 |
+
):
|
| 287 |
+
return Action.BACKCHANNEL
|
| 288 |
+
return Action.SILENT
|
| 289 |
+
|
| 290 |
+
def _floor_winner(self, decisions: Mapping[str, AgentDecision]) -> str | None:
|
| 291 |
+
contenders = [
|
| 292 |
+
decision
|
| 293 |
+
for decision in decisions.values()
|
| 294 |
+
if decision.action in {Action.TAKE_FLOOR, Action.INTERRUPT}
|
| 295 |
+
and decision.urge >= self._effective_tau(decision.p_end)
|
| 296 |
+
]
|
| 297 |
+
if not contenders:
|
| 298 |
+
return None
|
| 299 |
+
return max(contenders, key=lambda decision: (decision.urge, -self.agent_ids.index(decision.agent_id))).agent_id
|
| 300 |
+
|
| 301 |
+
def _may_backchannel(self, decision: AgentDecision) -> bool:
|
| 302 |
+
return (
|
| 303 |
+
not decision.refractory
|
| 304 |
+
and self.floor_holder == "human"
|
| 305 |
+
and decision.urge >= self.config.backchannel_tau
|
| 306 |
+
and decision.p_end <= self.config.backchannel_p_end_max
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
def _effective_tau(self, p_end: float) -> float:
|
| 310 |
+
discount = self.config.turn_end_tau_discount * float(np.clip(p_end, 0.0, 1.0))
|
| 311 |
+
return self.config.tau * max(0.20, 1.0 - discount)
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def cosine_distance(left: np.ndarray, right: np.ndarray) -> float:
|
| 315 |
+
left_vec = np.asarray(left, dtype=np.float32)
|
| 316 |
+
right_vec = np.asarray(right, dtype=np.float32)
|
| 317 |
+
denom = float(np.linalg.norm(left_vec) * np.linalg.norm(right_vec))
|
| 318 |
+
if denom <= 1.0e-12:
|
| 319 |
+
return 0.0
|
| 320 |
+
similarity = float(np.dot(left_vec, right_vec) / denom)
|
| 321 |
+
return float(np.clip(1.0 - similarity, 0.0, 2.0))
|
engine/conversation.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
from dataclasses import asdict, dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Sequence
|
| 9 |
+
|
| 10 |
+
from engine.controller import Action, ControllerConfig, ControllerTick, WhenToSpeakController
|
| 11 |
+
from engine.live_brain import BrainClient, Dialogue, LiveBrainPanel, Persona
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 15 |
+
DEFAULT_LOG_PATH = ROOT / "eval" / "conversation_log.json"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class TranscriptChunk:
|
| 20 |
+
text: str
|
| 21 |
+
silence_flag: bool = False
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass(frozen=True)
|
| 25 |
+
class ConversationResult:
|
| 26 |
+
events: list[dict[str, Any]]
|
| 27 |
+
dialogue: Dialogue
|
| 28 |
+
personas: list[Persona]
|
| 29 |
+
total_latency_ms: float
|
| 30 |
+
model_name: str
|
| 31 |
+
device_name: str
|
| 32 |
+
generated_examples: list[dict[str, Any]]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def default_personas() -> list[Persona]:
|
| 36 |
+
return [
|
| 37 |
+
Persona(
|
| 38 |
+
agent_id="numbers_vc",
|
| 39 |
+
display_name="Numbers VC",
|
| 40 |
+
system_prompt=(
|
| 41 |
+
"You are a numbers-obsessed venture investor. Be blunt, specific, and quantitative. "
|
| 42 |
+
"Ask for denominators, cohorts, margins, contract evidence, and arithmetic that actually closes."
|
| 43 |
+
),
|
| 44 |
+
),
|
| 45 |
+
Persona(
|
| 46 |
+
agent_id="vision_optimist",
|
| 47 |
+
display_name="Vision Optimist",
|
| 48 |
+
system_prompt=(
|
| 49 |
+
"You are a big-vision optimist. You look for the huge version of the company, but your "
|
| 50 |
+
"questions are crisp and founder-facing when the story needs a missing bridge."
|
| 51 |
+
),
|
| 52 |
+
),
|
| 53 |
+
Persona(
|
| 54 |
+
agent_id="ruthless_skeptic",
|
| 55 |
+
display_name="Ruthless Skeptic",
|
| 56 |
+
system_prompt=(
|
| 57 |
+
"You are a ruthless startup skeptic. Interrupt bad claims in plain English. No pleasantries, "
|
| 58 |
+
"no throat-clearing, no softening. Be sharp without being long."
|
| 59 |
+
),
|
| 60 |
+
),
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def sample_pitch_stream() -> list[TranscriptChunk]:
|
| 65 |
+
return [
|
| 66 |
+
TranscriptChunk("so basically our startup helps small retailers manage inventory"),
|
| 67 |
+
TranscriptChunk("we connect to their point of sale and purchase orders"),
|
| 68 |
+
TranscriptChunk("we already have ten thousand stores and zero churn after launching last week"),
|
| 69 |
+
TranscriptChunk("then we predict stockouts and write reorder suggestions automatically"),
|
| 70 |
+
TranscriptChunk("we are converting pilots into paid contracts this month"),
|
| 71 |
+
TranscriptChunk("so we think this becomes the operating system for local retail"),
|
| 72 |
+
TranscriptChunk("that's the pitch", silence_flag=True),
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def demo_controller_config() -> ControllerConfig:
|
| 77 |
+
return ControllerConfig(
|
| 78 |
+
tau=0.85,
|
| 79 |
+
min_readiness=0.08,
|
| 80 |
+
w_surprise=0.85,
|
| 81 |
+
w_barge=0.85,
|
| 82 |
+
w_readiness=0.75,
|
| 83 |
+
w_end=1.05,
|
| 84 |
+
backchannel_tau_fraction=0.72,
|
| 85 |
+
barge_tau_fraction=0.50,
|
| 86 |
+
turn_end_tau_discount=0.45,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class Conversation:
|
| 91 |
+
def __init__(
|
| 92 |
+
self,
|
| 93 |
+
personas: list[Persona],
|
| 94 |
+
brain_panel: LiveBrainPanel,
|
| 95 |
+
controller: WhenToSpeakController | None = None,
|
| 96 |
+
) -> None:
|
| 97 |
+
self.personas = personas
|
| 98 |
+
self.brain_panel = brain_panel
|
| 99 |
+
self.controller = controller or WhenToSpeakController(
|
| 100 |
+
brain_panel.agent_ids,
|
| 101 |
+
config=demo_controller_config(),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
def run(self, stream: Sequence[TranscriptChunk]) -> ConversationResult:
|
| 105 |
+
started = time.perf_counter()
|
| 106 |
+
dialogue: Dialogue = []
|
| 107 |
+
current_user_text = ""
|
| 108 |
+
events: list[dict[str, Any]] = []
|
| 109 |
+
generated_examples: list[dict[str, Any]] = []
|
| 110 |
+
|
| 111 |
+
for step_index, chunk in enumerate(stream, start=1):
|
| 112 |
+
dialogue_before = _dialogue_with_current_user(dialogue, current_user_text)
|
| 113 |
+
signals = self.brain_panel.step_all(dialogue_before, chunk.text, chunk.silence_flag)
|
| 114 |
+
current_user_text = _join_text(current_user_text, chunk.text)
|
| 115 |
+
tick = self.controller.tick(signals, floor_holder="human")
|
| 116 |
+
event = self._event(step_index, chunk, tick)
|
| 117 |
+
|
| 118 |
+
winner = tick.winner
|
| 119 |
+
if winner is not None:
|
| 120 |
+
if current_user_text:
|
| 121 |
+
dialogue.append({"role": "user", "speaker": "founder", "text": current_user_text})
|
| 122 |
+
current_user_text = ""
|
| 123 |
+
|
| 124 |
+
generated = self.brain_panel.generate(winner, dialogue)
|
| 125 |
+
reply_text = str(generated.get("reply_text", ""))
|
| 126 |
+
if reply_text:
|
| 127 |
+
dialogue.append({"role": "assistant", "speaker": winner, "text": reply_text})
|
| 128 |
+
event["generated"] = {
|
| 129 |
+
"agent_id": winner,
|
| 130 |
+
"reply_text": reply_text,
|
| 131 |
+
"reply_source": generated.get("reply_source"),
|
| 132 |
+
"raw_reply_text": generated.get("raw_reply_text"),
|
| 133 |
+
"latency_ms": generated.get("latency_ms"),
|
| 134 |
+
"model_name": generated.get("model_name"),
|
| 135 |
+
}
|
| 136 |
+
generated_examples.append(event["generated"])
|
| 137 |
+
|
| 138 |
+
events.append(event)
|
| 139 |
+
|
| 140 |
+
if current_user_text:
|
| 141 |
+
dialogue.append({"role": "user", "speaker": "founder", "text": current_user_text})
|
| 142 |
+
|
| 143 |
+
raw = self.brain_panel.last_raw or {}
|
| 144 |
+
return ConversationResult(
|
| 145 |
+
events=events,
|
| 146 |
+
dialogue=dialogue,
|
| 147 |
+
personas=self.personas,
|
| 148 |
+
total_latency_ms=(time.perf_counter() - started) * 1000.0,
|
| 149 |
+
model_name=str(raw.get("model_name", "")),
|
| 150 |
+
device_name=str(raw.get("device_name", "")),
|
| 151 |
+
generated_examples=generated_examples,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
def _event(self, step_index: int, chunk: TranscriptChunk, tick: ControllerTick) -> dict[str, Any]:
|
| 155 |
+
raw = self.brain_panel.last_raw or {}
|
| 156 |
+
decisions = {}
|
| 157 |
+
for agent_id, decision in tick.decisions.items():
|
| 158 |
+
brain_raw = self.brain_panel.last_results.get(agent_id, {})
|
| 159 |
+
decisions[agent_id] = {
|
| 160 |
+
"action": decision.action.value,
|
| 161 |
+
"urge": decision.urge,
|
| 162 |
+
"z_surprise": decision.z_surprise,
|
| 163 |
+
"change_score": decision.change_score,
|
| 164 |
+
"readiness": decision.readiness,
|
| 165 |
+
"p_end": decision.p_end,
|
| 166 |
+
"hidden_delta": decision.hidden_delta,
|
| 167 |
+
"map_run_length": decision.map_run_length,
|
| 168 |
+
"brain_latency_ms": brain_raw.get("latency_ms"),
|
| 169 |
+
"surprise": brain_raw.get("surprise"),
|
| 170 |
+
}
|
| 171 |
+
return {
|
| 172 |
+
"step": step_index,
|
| 173 |
+
"new_user_text": chunk.text,
|
| 174 |
+
"silence_flag": chunk.silence_flag,
|
| 175 |
+
"winner": tick.winner,
|
| 176 |
+
"floor_holder": tick.floor_holder,
|
| 177 |
+
"batch_latency_ms": raw.get("batch_latency_ms"),
|
| 178 |
+
"model_name": raw.get("model_name"),
|
| 179 |
+
"device_name": raw.get("device_name"),
|
| 180 |
+
"decisions": decisions,
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def save_conversation_log(result: ConversationResult, path: str | Path = DEFAULT_LOG_PATH) -> Path:
|
| 185 |
+
output = Path(path)
|
| 186 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 187 |
+
data = {
|
| 188 |
+
"model_name": result.model_name,
|
| 189 |
+
"device_name": result.device_name,
|
| 190 |
+
"total_latency_ms": result.total_latency_ms,
|
| 191 |
+
"personas": [asdict(persona) for persona in result.personas],
|
| 192 |
+
"events": result.events,
|
| 193 |
+
"dialogue": result.dialogue,
|
| 194 |
+
"generated_examples": result.generated_examples,
|
| 195 |
+
}
|
| 196 |
+
output.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
| 197 |
+
return output
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def readable_log(result: ConversationResult) -> str:
|
| 201 |
+
persona_names = {persona.agent_id: persona.display_name for persona in result.personas}
|
| 202 |
+
lines = [
|
| 203 |
+
f"Model: {result.model_name or 'unknown'} on {result.device_name or 'unknown'}",
|
| 204 |
+
f"Total wall latency: {result.total_latency_ms:.1f} ms",
|
| 205 |
+
]
|
| 206 |
+
for event in result.events:
|
| 207 |
+
lines.append(f"[{event['step']}] USER + {event['new_user_text']!r} silence={event['silence_flag']}")
|
| 208 |
+
for agent_id, decision in event["decisions"].items():
|
| 209 |
+
action = decision["action"]
|
| 210 |
+
if action == Action.SILENT.value:
|
| 211 |
+
continue
|
| 212 |
+
lines.append(
|
| 213 |
+
" "
|
| 214 |
+
f"{persona_names.get(agent_id, agent_id)} -> {action} "
|
| 215 |
+
f"urge={decision['urge']:.2f} readiness={decision['readiness']:.2f} "
|
| 216 |
+
f"p_end={decision['p_end']:.2f}"
|
| 217 |
+
)
|
| 218 |
+
if "generated" in event:
|
| 219 |
+
generated = event["generated"]
|
| 220 |
+
lines.append(f" {persona_names.get(generated['agent_id'], generated['agent_id'])}: {generated['reply_text']}")
|
| 221 |
+
return "\n".join(lines)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _dialogue_with_current_user(dialogue: Dialogue, current_user_text: str) -> Dialogue:
|
| 225 |
+
snapshot = [dict(turn) for turn in dialogue]
|
| 226 |
+
snapshot.append({"role": "user", "speaker": "founder", "text": current_user_text})
|
| 227 |
+
return snapshot
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _join_text(left: str, right: str) -> str:
|
| 231 |
+
left = left.strip()
|
| 232 |
+
right = right.strip()
|
| 233 |
+
if not left:
|
| 234 |
+
return right
|
| 235 |
+
if not right:
|
| 236 |
+
return left
|
| 237 |
+
return f"{left} {right}"
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def run_demo(log_path: str | Path = DEFAULT_LOG_PATH, client: BrainClient | None = None) -> ConversationResult:
|
| 241 |
+
personas = default_personas()
|
| 242 |
+
panel = LiveBrainPanel(personas, client=client)
|
| 243 |
+
conversation = Conversation(personas, panel)
|
| 244 |
+
result = conversation.run(sample_pitch_stream())
|
| 245 |
+
save_conversation_log(result, log_path)
|
| 246 |
+
print(readable_log(result))
|
| 247 |
+
print(f"Wrote {log_path}")
|
| 248 |
+
return result
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def main(argv: list[str] | None = None) -> None:
|
| 252 |
+
parser = argparse.ArgumentParser(description="Run the text-streamed WhenToSpeak conversation demo.")
|
| 253 |
+
parser.add_argument("--log-path", default=str(DEFAULT_LOG_PATH))
|
| 254 |
+
args = parser.parse_args(argv)
|
| 255 |
+
run_demo(args.log_path)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
if __name__ == "__main__":
|
| 259 |
+
main()
|
engine/live_brain.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Protocol
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from engine.brain import Brain, BrainSignals
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
Dialogue = list[dict[str, str]]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class BrainClient(Protocol):
|
| 15 |
+
def step(
|
| 16 |
+
self,
|
| 17 |
+
agent_id: str,
|
| 18 |
+
system_prompt: str,
|
| 19 |
+
dialogue_so_far: Dialogue,
|
| 20 |
+
new_user_text: str,
|
| 21 |
+
silence_flag: bool,
|
| 22 |
+
) -> dict[str, object]:
|
| 23 |
+
...
|
| 24 |
+
|
| 25 |
+
def step_many(
|
| 26 |
+
self,
|
| 27 |
+
agent_payloads: list[dict[str, str]],
|
| 28 |
+
dialogue_so_far: Dialogue,
|
| 29 |
+
new_user_text: str,
|
| 30 |
+
silence_flag: bool,
|
| 31 |
+
) -> dict[str, object]:
|
| 32 |
+
...
|
| 33 |
+
|
| 34 |
+
def generate(self, agent_id: str, system_prompt: str, dialogue: Dialogue) -> dict[str, object]:
|
| 35 |
+
...
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ModalBrainClient:
|
| 39 |
+
"""Thin lazy wrapper around `modal_app.brain_modal` remote functions."""
|
| 40 |
+
|
| 41 |
+
def __init__(self) -> None:
|
| 42 |
+
from modal_app import brain_modal
|
| 43 |
+
|
| 44 |
+
self._brain_modal = brain_modal
|
| 45 |
+
|
| 46 |
+
def step(
|
| 47 |
+
self,
|
| 48 |
+
agent_id: str,
|
| 49 |
+
system_prompt: str,
|
| 50 |
+
dialogue_so_far: Dialogue,
|
| 51 |
+
new_user_text: str,
|
| 52 |
+
silence_flag: bool,
|
| 53 |
+
) -> dict[str, object]:
|
| 54 |
+
return self._brain_modal.step.remote(agent_id, system_prompt, dialogue_so_far, new_user_text, silence_flag)
|
| 55 |
+
|
| 56 |
+
def step_many(
|
| 57 |
+
self,
|
| 58 |
+
agent_payloads: list[dict[str, str]],
|
| 59 |
+
dialogue_so_far: Dialogue,
|
| 60 |
+
new_user_text: str,
|
| 61 |
+
silence_flag: bool,
|
| 62 |
+
) -> dict[str, object]:
|
| 63 |
+
return self._brain_modal.step_many.remote(agent_payloads, dialogue_so_far, new_user_text, silence_flag)
|
| 64 |
+
|
| 65 |
+
def generate(self, agent_id: str, system_prompt: str, dialogue: Dialogue) -> dict[str, object]:
|
| 66 |
+
return self._brain_modal.generate.remote(agent_id, system_prompt, dialogue)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@dataclass(frozen=True)
|
| 70 |
+
class Persona:
|
| 71 |
+
agent_id: str
|
| 72 |
+
display_name: str
|
| 73 |
+
system_prompt: str
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class LiveBrain(Brain):
|
| 77 |
+
"""One-agent Brain implementation backed by Modal."""
|
| 78 |
+
|
| 79 |
+
def __init__(self, persona: Persona, client: BrainClient | None = None) -> None:
|
| 80 |
+
self.persona = persona
|
| 81 |
+
self.client = client or ModalBrainClient()
|
| 82 |
+
self.last_raw: dict[str, object] | None = None
|
| 83 |
+
self._latest: BrainSignals | None = None
|
| 84 |
+
|
| 85 |
+
def step(self, dialogue_so_far: Dialogue, new_user_text: str, silence_flag: bool) -> BrainSignals:
|
| 86 |
+
raw = self.client.step(
|
| 87 |
+
self.persona.agent_id,
|
| 88 |
+
self.persona.system_prompt,
|
| 89 |
+
dialogue_so_far,
|
| 90 |
+
new_user_text,
|
| 91 |
+
silence_flag,
|
| 92 |
+
)
|
| 93 |
+
self.last_raw = raw
|
| 94 |
+
self._latest = signals_from_raw(raw)
|
| 95 |
+
return self._latest
|
| 96 |
+
|
| 97 |
+
def next_signals(self) -> BrainSignals:
|
| 98 |
+
if self._latest is None:
|
| 99 |
+
raise RuntimeError("LiveBrain has no queued signals; call step() first")
|
| 100 |
+
return self._latest
|
| 101 |
+
|
| 102 |
+
def generate(self, dialogue: Dialogue) -> dict[str, object]:
|
| 103 |
+
return self.client.generate(self.persona.agent_id, self.persona.system_prompt, dialogue)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class LiveBrainPanel:
|
| 107 |
+
"""Batched Modal brain calls for a multi-agent panel."""
|
| 108 |
+
|
| 109 |
+
def __init__(self, personas: list[Persona], client: BrainClient | None = None) -> None:
|
| 110 |
+
if not personas:
|
| 111 |
+
raise ValueError("personas must not be empty")
|
| 112 |
+
self.personas = personas
|
| 113 |
+
self.client = client or ModalBrainClient()
|
| 114 |
+
self.last_raw: dict[str, object] | None = None
|
| 115 |
+
self.last_results: dict[str, dict[str, object]] = {}
|
| 116 |
+
|
| 117 |
+
@property
|
| 118 |
+
def agent_ids(self) -> list[str]:
|
| 119 |
+
return [persona.agent_id for persona in self.personas]
|
| 120 |
+
|
| 121 |
+
def step_all(self, dialogue_so_far: Dialogue, new_user_text: str, silence_flag: bool) -> dict[str, BrainSignals]:
|
| 122 |
+
payloads = [
|
| 123 |
+
{"agent_id": persona.agent_id, "system_prompt": persona.system_prompt}
|
| 124 |
+
for persona in self.personas
|
| 125 |
+
]
|
| 126 |
+
raw = self.client.step_many(payloads, dialogue_so_far, new_user_text, silence_flag)
|
| 127 |
+
self.last_raw = raw
|
| 128 |
+
results = raw.get("results", {}) if isinstance(raw, dict) else {}
|
| 129 |
+
self.last_results = {agent_id: dict(result) for agent_id, result in results.items()}
|
| 130 |
+
return {agent_id: signals_from_raw(result) for agent_id, result in self.last_results.items()}
|
| 131 |
+
|
| 132 |
+
def generate(self, agent_id: str, dialogue: Dialogue) -> dict[str, object]:
|
| 133 |
+
persona = self.persona(agent_id)
|
| 134 |
+
return self.client.generate(agent_id, persona.system_prompt, dialogue)
|
| 135 |
+
|
| 136 |
+
def persona(self, agent_id: str) -> Persona:
|
| 137 |
+
for persona in self.personas:
|
| 138 |
+
if persona.agent_id == agent_id:
|
| 139 |
+
return persona
|
| 140 |
+
raise KeyError(f"unknown persona {agent_id!r}")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def signals_from_raw(raw: dict[str, object]) -> BrainSignals:
|
| 144 |
+
if not raw.get("ok", False):
|
| 145 |
+
raise RuntimeError(str(raw.get("failure", "brain call failed")))
|
| 146 |
+
return BrainSignals(
|
| 147 |
+
surprise=float(raw["surprise"]),
|
| 148 |
+
hidden=np.asarray(raw["hidden"], dtype=np.float32),
|
| 149 |
+
readiness=float(raw["readiness"]),
|
| 150 |
+
p_end=float(raw["p_end"]),
|
| 151 |
+
)
|
engine/probe.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import shutil
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
import torch.nn.functional as F
|
| 11 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 15 |
+
EVAL_DIR = ROOT / "eval"
|
| 16 |
+
DUMP_PATH = EVAL_DIR / "probe_dump.npz"
|
| 17 |
+
CANDIDATE_MODELS = [
|
| 18 |
+
"nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16",
|
| 19 |
+
"Qwen/Qwen2.5-3B-Instruct",
|
| 20 |
+
"openbmb/MiniCPM3-4B",
|
| 21 |
+
]
|
| 22 |
+
TRANSCRIPT_CHUNKS = [
|
| 23 |
+
"so basically",
|
| 24 |
+
"our startup uses",
|
| 25 |
+
"ai to help",
|
| 26 |
+
"small businesses",
|
| 27 |
+
"manage inventory",
|
| 28 |
+
"and we think",
|
| 29 |
+
"the market is huge",
|
| 30 |
+
"and honestly",
|
| 31 |
+
"we already have",
|
| 32 |
+
"like a thousand",
|
| 33 |
+
"users and",
|
| 34 |
+
"growing fast",
|
| 35 |
+
]
|
| 36 |
+
MIN_MODEL_DOWNLOAD_FREE_BYTES = 6 * 1024**3
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def configure_local_caches() -> None:
|
| 40 |
+
os.environ.setdefault("HF_HOME", str(ROOT / ".hf-cache"))
|
| 41 |
+
os.environ.setdefault("TRANSFORMERS_CACHE", str(ROOT / ".hf-cache" / "transformers"))
|
| 42 |
+
os.environ.setdefault("TORCH_HOME", str(ROOT / ".torch-cache"))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def cuda_summary() -> torch.device:
|
| 46 |
+
print(f"torch.__version__ = {torch.__version__}")
|
| 47 |
+
print(f"torch.version.cuda = {torch.version.cuda}")
|
| 48 |
+
print(f"torch.cuda.is_available() = {torch.cuda.is_available()}")
|
| 49 |
+
if torch.cuda.is_available():
|
| 50 |
+
print(f"torch.cuda.get_device_name(0) = {torch.cuda.get_device_name(0)}")
|
| 51 |
+
return torch.device("cuda:0")
|
| 52 |
+
|
| 53 |
+
print("LOUD CUDA FALLBACK: CUDA/Blackwell is not available in this torch environment; using CPU.")
|
| 54 |
+
return torch.device("cpu")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def common_prefix_len(previous: list[int], current: list[int]) -> int:
|
| 58 |
+
length = 0
|
| 59 |
+
for left, right in zip(previous, current):
|
| 60 |
+
if left != right:
|
| 61 |
+
break
|
| 62 |
+
length += 1
|
| 63 |
+
return length
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def save_failure(failure: str) -> None:
|
| 67 |
+
EVAL_DIR.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
np.savez(
|
| 69 |
+
DUMP_PATH,
|
| 70 |
+
nll_series=np.asarray([], dtype=np.float32),
|
| 71 |
+
hidden_states=np.empty((0, 0), dtype=np.float32),
|
| 72 |
+
update_ms=np.asarray([], dtype=np.float32),
|
| 73 |
+
added_text=np.asarray([], dtype=object),
|
| 74 |
+
model=np.asarray("", dtype=object),
|
| 75 |
+
device=np.asarray("cpu", dtype=object),
|
| 76 |
+
dtype=np.asarray("", dtype=object),
|
| 77 |
+
failure=np.asarray(failure, dtype=object),
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def load_first_model(device: torch.device) -> tuple[object, object, str, float, float] | None:
|
| 82 |
+
free_bytes = shutil.disk_usage(ROOT).free
|
| 83 |
+
local_files_only = free_bytes < MIN_MODEL_DOWNLOAD_FREE_BYTES
|
| 84 |
+
if local_files_only:
|
| 85 |
+
free_gib = free_bytes / 1024**3
|
| 86 |
+
needed_gib = MIN_MODEL_DOWNLOAD_FREE_BYTES / 1024**3
|
| 87 |
+
print(
|
| 88 |
+
"LOUD MODEL DOWNLOAD SKIP: only "
|
| 89 |
+
f"{free_gib:.2f} GiB free; need at least {needed_gib:.1f} GiB to attempt these 3B/4B model downloads. "
|
| 90 |
+
"Trying repo-local cache only."
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
if device.type == "cuda":
|
| 94 |
+
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
| 95 |
+
else:
|
| 96 |
+
dtype = torch.float32
|
| 97 |
+
|
| 98 |
+
failures: list[str] = []
|
| 99 |
+
for model_id in CANDIDATE_MODELS:
|
| 100 |
+
print(f"Attempting model: {model_id}")
|
| 101 |
+
if device.type == "cuda":
|
| 102 |
+
torch.cuda.empty_cache()
|
| 103 |
+
torch.cuda.reset_peak_memory_stats(0)
|
| 104 |
+
|
| 105 |
+
start = time.perf_counter()
|
| 106 |
+
try:
|
| 107 |
+
tokenizer = AutoTokenizer.from_pretrained(
|
| 108 |
+
model_id,
|
| 109 |
+
trust_remote_code=True,
|
| 110 |
+
local_files_only=local_files_only,
|
| 111 |
+
)
|
| 112 |
+
load_kwargs = {
|
| 113 |
+
"trust_remote_code": True,
|
| 114 |
+
"torch_dtype": dtype,
|
| 115 |
+
"low_cpu_mem_usage": True,
|
| 116 |
+
"local_files_only": local_files_only,
|
| 117 |
+
}
|
| 118 |
+
if device.type == "cuda":
|
| 119 |
+
load_kwargs["device_map"] = {"": 0}
|
| 120 |
+
|
| 121 |
+
model = AutoModelForCausalLM.from_pretrained(model_id, **load_kwargs)
|
| 122 |
+
if device.type == "cpu":
|
| 123 |
+
model.to(device)
|
| 124 |
+
model.eval()
|
| 125 |
+
|
| 126 |
+
load_seconds = time.perf_counter() - start
|
| 127 |
+
actual_device = next(model.parameters()).device
|
| 128 |
+
actual_dtype = next(model.parameters()).dtype
|
| 129 |
+
vram_gib = 0.0
|
| 130 |
+
if device.type == "cuda":
|
| 131 |
+
torch.cuda.synchronize()
|
| 132 |
+
vram_gib = torch.cuda.memory_allocated(0) / 1024**3
|
| 133 |
+
print(
|
| 134 |
+
"LOADED "
|
| 135 |
+
f"model={model_id} device={actual_device} dtype={actual_dtype} "
|
| 136 |
+
f"load_seconds={load_seconds:.2f} vram_used_gib={vram_gib:.2f}"
|
| 137 |
+
)
|
| 138 |
+
return tokenizer, model, model_id, load_seconds, vram_gib
|
| 139 |
+
except Exception as exc: # noqa: BLE001 - spike should continue through model fallbacks.
|
| 140 |
+
elapsed = time.perf_counter() - start
|
| 141 |
+
message = f"{model_id} failed after {elapsed:.2f}s: {type(exc).__name__}: {exc}"
|
| 142 |
+
print(message)
|
| 143 |
+
failures.append(message)
|
| 144 |
+
|
| 145 |
+
failure = "No candidate model loaded. " + " | ".join(failures)
|
| 146 |
+
print(f"LOUD PROBE FAILURE: {failure}")
|
| 147 |
+
save_failure(failure)
|
| 148 |
+
return None
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def run_updates(tokenizer: object, model: object, model_id: str) -> None:
|
| 152 |
+
device = next(model.parameters()).device
|
| 153 |
+
previous_ids: list[int] = []
|
| 154 |
+
prefixes: list[str] = []
|
| 155 |
+
running = ""
|
| 156 |
+
for chunk in TRANSCRIPT_CHUNKS:
|
| 157 |
+
running = f"{running} {chunk}".strip()
|
| 158 |
+
prefixes.append(running)
|
| 159 |
+
|
| 160 |
+
nll_series: list[float] = []
|
| 161 |
+
hidden_rows: list[np.ndarray] = []
|
| 162 |
+
update_ms: list[float] = []
|
| 163 |
+
added_text: list[str] = []
|
| 164 |
+
|
| 165 |
+
print("step | added_text | mean_NLL | hidden_dim | update_ms")
|
| 166 |
+
print("-----|------------|----------|------------|----------")
|
| 167 |
+
for step, (chunk, prefix) in enumerate(zip(TRANSCRIPT_CHUNKS, prefixes), start=1):
|
| 168 |
+
if device.type == "cuda":
|
| 169 |
+
torch.cuda.synchronize()
|
| 170 |
+
start = time.perf_counter()
|
| 171 |
+
|
| 172 |
+
encoded = tokenizer(prefix, return_tensors="pt", add_special_tokens=False)
|
| 173 |
+
current_ids = encoded["input_ids"][0].tolist()
|
| 174 |
+
new_start = common_prefix_len(previous_ids, current_ids)
|
| 175 |
+
inputs = {name: tensor.to(device) for name, tensor in encoded.items()}
|
| 176 |
+
|
| 177 |
+
with torch.inference_mode():
|
| 178 |
+
outputs = model(**inputs, output_hidden_states=True)
|
| 179 |
+
|
| 180 |
+
input_ids = inputs["input_ids"]
|
| 181 |
+
logits = outputs.logits[:, :-1, :].float()
|
| 182 |
+
targets = input_ids[:, 1:]
|
| 183 |
+
token_nll = F.cross_entropy(
|
| 184 |
+
logits.reshape(-1, logits.shape[-1]),
|
| 185 |
+
targets.reshape(-1),
|
| 186 |
+
reduction="none",
|
| 187 |
+
).reshape(targets.shape)
|
| 188 |
+
|
| 189 |
+
nll_start = max(new_start, 1) - 1
|
| 190 |
+
new_nll = token_nll[0, nll_start:]
|
| 191 |
+
mean_nll = float(new_nll.mean().detach().cpu()) if new_nll.numel() else float("nan")
|
| 192 |
+
|
| 193 |
+
last_hidden = outputs.hidden_states[-1][0]
|
| 194 |
+
new_hidden = last_hidden[new_start:, :]
|
| 195 |
+
mean_hidden = new_hidden.float().mean(dim=0).detach().cpu()
|
| 196 |
+
|
| 197 |
+
if device.type == "cuda":
|
| 198 |
+
torch.cuda.synchronize()
|
| 199 |
+
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
| 200 |
+
|
| 201 |
+
hidden_vec = mean_hidden.numpy().astype(np.float32)
|
| 202 |
+
nll_series.append(mean_nll)
|
| 203 |
+
hidden_rows.append(hidden_vec)
|
| 204 |
+
update_ms.append(elapsed_ms)
|
| 205 |
+
added_text.append(chunk)
|
| 206 |
+
previous_ids = current_ids
|
| 207 |
+
|
| 208 |
+
print(f"{step:>4} | {chunk} | {mean_nll:.4f} | {hidden_vec.shape[0]} | {elapsed_ms:.2f}")
|
| 209 |
+
|
| 210 |
+
EVAL_DIR.mkdir(parents=True, exist_ok=True)
|
| 211 |
+
hidden_matrix = np.vstack(hidden_rows).astype(np.float32)
|
| 212 |
+
actual_dtype = str(next(model.parameters()).dtype)
|
| 213 |
+
np.savez(
|
| 214 |
+
DUMP_PATH,
|
| 215 |
+
nll_series=np.asarray(nll_series, dtype=np.float32),
|
| 216 |
+
hidden_states=hidden_matrix,
|
| 217 |
+
update_ms=np.asarray(update_ms, dtype=np.float32),
|
| 218 |
+
added_text=np.asarray(added_text, dtype=object),
|
| 219 |
+
model=np.asarray(model_id, dtype=object),
|
| 220 |
+
device=np.asarray(str(device), dtype=object),
|
| 221 |
+
dtype=np.asarray(actual_dtype, dtype=object),
|
| 222 |
+
failure=np.asarray("", dtype=object),
|
| 223 |
+
)
|
| 224 |
+
print(f"Saved {DUMP_PATH}")
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def main() -> None:
|
| 228 |
+
configure_local_caches()
|
| 229 |
+
EVAL_DIR.mkdir(parents=True, exist_ok=True)
|
| 230 |
+
device = cuda_summary()
|
| 231 |
+
loaded = load_first_model(device)
|
| 232 |
+
if loaded is None:
|
| 233 |
+
return
|
| 234 |
+
|
| 235 |
+
tokenizer, model, model_id, _load_seconds, _vram_gib = loaded
|
| 236 |
+
run_updates(tokenizer, model, model_id)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
if __name__ == "__main__":
|
| 240 |
+
main()
|
engine/traces.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Sequence
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from engine.brain import BrainSignals, ReplayBrain
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 13 |
+
DEFAULT_MODAL_DUMP = ROOT / "eval" / "probe_dump_modal.npz"
|
| 14 |
+
DEFAULT_AGENTS = ("investor_a", "investor_b")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class TraceStep:
|
| 19 |
+
signals_by_agent: dict[str, BrainSignals]
|
| 20 |
+
floor_holder: str = "human"
|
| 21 |
+
note: str = ""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass(frozen=True)
|
| 25 |
+
class SignalTrace:
|
| 26 |
+
name: str
|
| 27 |
+
steps: list[TraceStep]
|
| 28 |
+
expected: dict[str, object] = field(default_factory=dict)
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def agent_ids(self) -> list[str]:
|
| 32 |
+
return list(self.steps[0].signals_by_agent.keys()) if self.steps else []
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def synthetic_traces() -> list[SignalTrace]:
|
| 36 |
+
return [
|
| 37 |
+
clean_monologue_trace(),
|
| 38 |
+
surprising_claim_trace(),
|
| 39 |
+
topic_shift_trace(),
|
| 40 |
+
rambling_pause_trace(),
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def load_modal_trace(path: str | Path = DEFAULT_MODAL_DUMP, agent_ids: Sequence[str] = DEFAULT_AGENTS) -> SignalTrace:
|
| 45 |
+
path = Path(path)
|
| 46 |
+
replay = ReplayBrain.from_npz(path, name="modal_probe_replay")
|
| 47 |
+
samples = list(replay)
|
| 48 |
+
steps: list[TraceStep] = []
|
| 49 |
+
for index, sample in enumerate(samples):
|
| 50 |
+
signals: dict[str, BrainSignals] = {}
|
| 51 |
+
for offset, agent_id in enumerate(agent_ids):
|
| 52 |
+
readiness = max(0.0, sample.readiness - 0.08 * offset)
|
| 53 |
+
signals[agent_id] = BrainSignals(
|
| 54 |
+
surprise=sample.surprise,
|
| 55 |
+
hidden=sample.hidden,
|
| 56 |
+
readiness=readiness,
|
| 57 |
+
p_end=sample.p_end,
|
| 58 |
+
)
|
| 59 |
+
steps.append(TraceStep(signals_by_agent=signals, note=f"modal step {index + 1}"))
|
| 60 |
+
return SignalTrace(
|
| 61 |
+
name="modal_probe_replay",
|
| 62 |
+
steps=steps,
|
| 63 |
+
expected={"source": str(path), "n_steps": len(steps)},
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def all_traces() -> list[SignalTrace]:
|
| 68 |
+
traces = synthetic_traces()
|
| 69 |
+
if DEFAULT_MODAL_DUMP.exists():
|
| 70 |
+
traces.append(load_modal_trace(DEFAULT_MODAL_DUMP))
|
| 71 |
+
return traces
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def clean_monologue_trace() -> SignalTrace:
|
| 75 |
+
base = _unit([1, 0, 0, 0, 0, 0, 0, 0])
|
| 76 |
+
steps = _trace_from_series(
|
| 77 |
+
name="clean_monologue_take_floor",
|
| 78 |
+
hidden_series=[_nudge(base, index, 0.01) for index in range(6)],
|
| 79 |
+
surprise=[2.0, 2.1, 2.0, 2.2, 2.1, 2.45],
|
| 80 |
+
readiness_a=[0.25, 0.30, 0.35, 0.40, 0.45, 0.78],
|
| 81 |
+
readiness_b=[0.20, 0.25, 0.30, 0.32, 0.35, 0.52],
|
| 82 |
+
p_end=[0.02, 0.03, 0.04, 0.05, 0.10, 0.95],
|
| 83 |
+
notes=["setup", "details", "still talking", "more context", "closing", "turn complete"],
|
| 84 |
+
)
|
| 85 |
+
return SignalTrace(name="clean_monologue_take_floor", steps=steps, expected={"take_floor_step": 6})
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def surprising_claim_trace() -> SignalTrace:
|
| 89 |
+
base = _unit([1, 0, 0, 0, 0, 0, 0, 0])
|
| 90 |
+
steps = _trace_from_series(
|
| 91 |
+
name="surprising_claim_interrupt",
|
| 92 |
+
hidden_series=[_nudge(base, index, 0.01) for index in range(6)],
|
| 93 |
+
surprise=[2.0, 2.1, 7.3, 3.0, 2.4, 2.2],
|
| 94 |
+
readiness_a=[0.30, 0.42, 0.92, 0.82, 0.55, 0.45],
|
| 95 |
+
readiness_b=[0.25, 0.32, 0.35, 0.36, 0.38, 0.40],
|
| 96 |
+
p_end=[0.02, 0.04, 0.18, 0.20, 0.30, 0.55],
|
| 97 |
+
notes=["setup", "build", "wild claim", "continues", "settles", "handoff"],
|
| 98 |
+
)
|
| 99 |
+
return SignalTrace(name="surprising_claim_interrupt", steps=steps, expected={"interrupt_step": 3})
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def topic_shift_trace() -> SignalTrace:
|
| 103 |
+
topic_a = _unit([1, 0, 0, 0, 0, 0, 0, 0])
|
| 104 |
+
topic_b = _unit([0, 1, 0, 0, 0, 0, 0, 0])
|
| 105 |
+
steps = _trace_from_series(
|
| 106 |
+
name="topic_shift_backchannel",
|
| 107 |
+
hidden_series=[
|
| 108 |
+
_nudge(topic_a, 0, 0.01),
|
| 109 |
+
_nudge(topic_a, 1, 0.01),
|
| 110 |
+
_nudge(topic_a, 2, 0.01),
|
| 111 |
+
_nudge(topic_a, 3, 0.01),
|
| 112 |
+
_nudge(topic_b, 4, 0.01),
|
| 113 |
+
_nudge(topic_b, 5, 0.01),
|
| 114 |
+
_nudge(topic_b, 6, 0.01),
|
| 115 |
+
],
|
| 116 |
+
surprise=[2.0, 2.1, 2.0, 2.1, 2.12, 2.08, 2.06],
|
| 117 |
+
readiness_a=[0.25, 0.30, 0.35, 0.38, 0.50, 0.45, 0.40],
|
| 118 |
+
readiness_b=[0.20, 0.25, 0.28, 0.30, 0.35, 0.34, 0.34],
|
| 119 |
+
p_end=[0.02, 0.04, 0.06, 0.08, 0.22, 0.25, 0.28],
|
| 120 |
+
notes=["topic a", "topic a", "topic a", "topic a", "topic b shift", "topic b", "topic b"],
|
| 121 |
+
)
|
| 122 |
+
return SignalTrace(name="topic_shift_backchannel", steps=steps, expected={"backchannel_step": 5})
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def rambling_pause_trace() -> SignalTrace:
|
| 126 |
+
base = _unit([0, 0, 1, 0, 0, 0, 0, 0])
|
| 127 |
+
steps = _trace_from_series(
|
| 128 |
+
name="rambling_pause_take_floor",
|
| 129 |
+
hidden_series=[_nudge(base, index, 0.015) for index in range(7)],
|
| 130 |
+
surprise=[2.1, 2.0, 2.2, 2.1, 2.0, 2.1, 2.45],
|
| 131 |
+
readiness_a=[0.20, 0.25, 0.30, 0.38, 0.42, 0.42, 0.80],
|
| 132 |
+
readiness_b=[0.18, 0.22, 0.25, 0.30, 0.32, 0.35, 0.50],
|
| 133 |
+
p_end=[0.02, 0.05, 0.10, 0.38, 0.42, 0.44, 0.96],
|
| 134 |
+
notes=["start", "ramble", "ramble", "awkward pause", "holds", "still unsure", "complete"],
|
| 135 |
+
)
|
| 136 |
+
return SignalTrace(name="rambling_pause_take_floor", steps=steps, expected={"hold_step": 4, "take_floor_step": 7})
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _trace_from_series(
|
| 140 |
+
*,
|
| 141 |
+
name: str,
|
| 142 |
+
hidden_series: Sequence[np.ndarray],
|
| 143 |
+
surprise: Sequence[float],
|
| 144 |
+
readiness_a: Sequence[float],
|
| 145 |
+
readiness_b: Sequence[float],
|
| 146 |
+
p_end: Sequence[float],
|
| 147 |
+
notes: Sequence[str],
|
| 148 |
+
) -> list[TraceStep]:
|
| 149 |
+
steps: list[TraceStep] = []
|
| 150 |
+
for index, hidden in enumerate(hidden_series):
|
| 151 |
+
signals = {
|
| 152 |
+
"investor_a": BrainSignals(
|
| 153 |
+
surprise=surprise[index],
|
| 154 |
+
hidden=hidden,
|
| 155 |
+
readiness=readiness_a[index],
|
| 156 |
+
p_end=p_end[index],
|
| 157 |
+
),
|
| 158 |
+
"investor_b": BrainSignals(
|
| 159 |
+
surprise=max(0.0, surprise[index] - 0.2),
|
| 160 |
+
hidden=hidden,
|
| 161 |
+
readiness=readiness_b[index],
|
| 162 |
+
p_end=p_end[index],
|
| 163 |
+
),
|
| 164 |
+
}
|
| 165 |
+
steps.append(TraceStep(signals_by_agent=signals, note=notes[index]))
|
| 166 |
+
return steps
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _unit(values: Sequence[float]) -> np.ndarray:
|
| 170 |
+
vector = np.asarray(values, dtype=np.float32)
|
| 171 |
+
norm = np.linalg.norm(vector)
|
| 172 |
+
if norm == 0:
|
| 173 |
+
return vector
|
| 174 |
+
return vector / norm
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _nudge(base: np.ndarray, index: int, scale: float) -> np.ndarray:
|
| 178 |
+
vector = base.astype(np.float32, copy=True)
|
| 179 |
+
vector[(index % (len(vector) - 1)) + 1] += scale
|
| 180 |
+
return _unit(vector)
|
engine/viz.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import matplotlib
|
| 6 |
+
|
| 7 |
+
matplotlib.use("Agg")
|
| 8 |
+
|
| 9 |
+
import matplotlib.pyplot as plt
|
| 10 |
+
|
| 11 |
+
from engine.controller import Action, ControllerConfig, WhenToSpeakController
|
| 12 |
+
from engine.traces import SignalTrace, surprising_claim_trace
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 16 |
+
DEFAULT_OUTPUT = ROOT / "eval" / "controller_timeline.png"
|
| 17 |
+
ACTION_COLORS = {
|
| 18 |
+
Action.BACKCHANNEL: "tab:blue",
|
| 19 |
+
Action.TAKE_FLOOR: "tab:green",
|
| 20 |
+
Action.INTERRUPT: "tab:red",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def render_timeline(
|
| 25 |
+
trace: SignalTrace | None = None,
|
| 26 |
+
output_path: str | Path = DEFAULT_OUTPUT,
|
| 27 |
+
config: ControllerConfig | None = None,
|
| 28 |
+
) -> Path:
|
| 29 |
+
trace = trace or surprising_claim_trace()
|
| 30 |
+
controller = WhenToSpeakController(trace.agent_ids, config=config)
|
| 31 |
+
ticks = [controller.tick(step.signals_by_agent, floor_holder=step.floor_holder) for step in trace.steps]
|
| 32 |
+
|
| 33 |
+
steps = [tick.step for tick in ticks]
|
| 34 |
+
primary = trace.agent_ids[0]
|
| 35 |
+
surprise = [step.signals_by_agent[primary].surprise for step in trace.steps]
|
| 36 |
+
readiness = [step.signals_by_agent[primary].readiness for step in trace.steps]
|
| 37 |
+
p_end = [step.signals_by_agent[primary].p_end for step in trace.steps]
|
| 38 |
+
change = [tick.decisions[primary].change_score for tick in ticks]
|
| 39 |
+
|
| 40 |
+
fig, axes = plt.subplots(4, 1, figsize=(10, 8), sharex=True)
|
| 41 |
+
fig.suptitle(f"WhenToSpeak controller timeline: {trace.name}", fontsize=13)
|
| 42 |
+
|
| 43 |
+
axes[0].plot(steps, surprise, marker="o", color="tab:orange", label="surprise/NLL")
|
| 44 |
+
axes[0].set_ylabel("surprise")
|
| 45 |
+
axes[0].legend(loc="upper right")
|
| 46 |
+
|
| 47 |
+
axes[1].plot(steps, change, marker="o", color="tab:purple", label="change score")
|
| 48 |
+
axes[1].set_ylabel("change")
|
| 49 |
+
axes[1].legend(loc="upper right")
|
| 50 |
+
|
| 51 |
+
axes[2].plot(steps, readiness, marker="o", color="tab:green", label="readiness")
|
| 52 |
+
axes[2].plot(steps, p_end, marker=".", linestyle="--", color="tab:gray", label="p_end")
|
| 53 |
+
axes[2].set_ylabel("probability")
|
| 54 |
+
axes[2].set_ylim(-0.05, 1.05)
|
| 55 |
+
axes[2].legend(loc="upper right")
|
| 56 |
+
|
| 57 |
+
for agent_id in trace.agent_ids:
|
| 58 |
+
urges = [tick.decisions[agent_id].urge for tick in ticks]
|
| 59 |
+
axes[3].plot(steps, urges, marker="o", label=f"{agent_id} urge")
|
| 60 |
+
axes[3].axhline(controller.config.tau, color="black", linestyle="--", linewidth=1, label="tau")
|
| 61 |
+
axes[3].set_ylabel("urge")
|
| 62 |
+
axes[3].set_xlabel("step")
|
| 63 |
+
axes[3].legend(loc="upper right")
|
| 64 |
+
|
| 65 |
+
for tick in ticks:
|
| 66 |
+
for agent_id, decision in tick.decisions.items():
|
| 67 |
+
if decision.action == Action.SILENT:
|
| 68 |
+
continue
|
| 69 |
+
color = ACTION_COLORS[decision.action]
|
| 70 |
+
axes[3].scatter([tick.step], [decision.urge], color=color, s=90, zorder=5)
|
| 71 |
+
axes[3].annotate(
|
| 72 |
+
decision.action.value,
|
| 73 |
+
(tick.step, decision.urge),
|
| 74 |
+
textcoords="offset points",
|
| 75 |
+
xytext=(0, 8),
|
| 76 |
+
ha="center",
|
| 77 |
+
fontsize=8,
|
| 78 |
+
color=color,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
for axis in axes:
|
| 82 |
+
axis.grid(True, alpha=0.25)
|
| 83 |
+
|
| 84 |
+
output = Path(output_path)
|
| 85 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 86 |
+
fig.tight_layout()
|
| 87 |
+
fig.savefig(output, dpi=160)
|
| 88 |
+
plt.close(fig)
|
| 89 |
+
return output
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def main() -> None:
|
| 93 |
+
output = render_timeline()
|
| 94 |
+
print(f"Wrote {output}")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 2 |
+
gradio==6.18.0
|
| 3 |
+
torch==2.12.0+cpu
|
| 4 |
+
kokoro==0.9.4
|
| 5 |
+
numpy==2.4.6
|
| 6 |
+
requests==2.32.5
|
| 7 |
+
soundfile==0.14.0
|
| 8 |
+
sentence-transformers>=5.0.0,<6.0.0
|