TheraPy is a local-first, voice-based, therapy-informed companion. It is two things at once, and the second one is the part most contributors miss:
- A real-time voice loop — mic → speech-to-text → LLM → text-to-speech → speaker, over WebRTC.
- An AI system with production-grade traceability — every model interaction is captured as durable, replayable evidence so behavior can be reproduced and evaluated, not just observed.
The mental model to hold the whole time:
build → observe → capture evidence → replay → evaluate → ship
Everything below serves that loop. For product scope and configuration reference see
../README.md; for the design contract and the numbered requirements
(SPEC §…) referenced throughout the code see SPEC.md.
git clone https://github.com/jsugg/TheraPy && cd TheraPy
cp .env.example .env # defaults are safe and fully local
make up # build + start the stack (Docker)
make status # wait until the therapy container is "healthy"
open http://localhost:8000 # or http://<machine-name>:8000 to install the PWATo talk to a model you need one of:
ANTHROPIC_API_KEY=…in.env(default provider), or- a local Ollama (
THERAPY_LLM=ollama, see §5).
src/, tests/, and scripts/ are bind-mounted read-only into the container, so
your edits are live — but the server process is not always reloaded automatically:
| You changed… | Do this |
|---|---|
UI (JS/CSS/HTML under src/) |
just reload the browser |
| Python | make restart — restarts uvicorn to pick up the code |
| A test | nothing — make test / make e2e read it directly |
A dependency (pyproject.toml/uv.lock) |
make rebuild |
⚠️ The container is the supported environment.uv syncon the host is only for IDE/tooling (types, autocomplete) and fails on macOS x86_64 — some wheels have no Intel-mac build. Never rely on the host venv to run the app; usemake up.
⚠️ If tests suddenly fail withStopIterationon every case, the bind-mountedpyproject.toml/uv.lockinode went stale after an edit. Fix:make restart.
There are two deliberately separate telemetry paths. Contributors must know which is which, because putting the wrong data in the wrong one is the single most damaging mistake you can make in this codebase.
| Restricted interaction plane | Broad operations plane | |
|---|---|---|
| Purpose | Reproduce & evaluate any AI interaction deterministically | Operate the service without sensitive content |
| Contains | Exact prompts, ordered messages, system instructions, memory context, retrieved passages, tool calls & outputs, provider-native requests, streamed deltas, completions, provider error bodies, transcripts | Latency, counts, durations, sizes, statuses, bounded enums, correlation IDs |
| Store | Application-owned SQLite journal (durable, replayable — the source of truth) | stdout JSON logs → OTel Collector → Grafana / metrics |
| Export | Optional → Phoenix (OpenInference / OTel GenAI) | OTLP → Collector |
| Owner module | observability/journal.py, interactions.py, exporters.py |
observability/logging.py, telemetry.py, metrics.py |
🔒 The rule: never put prompts, transcripts, memories, retrieved documents, or tool payloads into the broad plane. Routing enforces this (
observability/routing.py) and a canary gate proves it (make obs-canary-scan) — but it is your responsibility first.
All defaults are safe: capture is local, and nothing leaves the host. Genuine user data, public exposure, and remote export are promotion triggers that each require an explicit new threat-model decision before the corresponding gate is flipped (see §9).
Browser / PWA http://localhost:8000
│
WebRTC audio + DataChannel (events / live transcript)
│ ⇄ coturn TURN relay (for phone-over-Tailscale)
▼
FastAPI server src/therapy/server
┌──────────────┼───────────────────────────────┐
▼ ▼ ▼
Pipecat pipeline Memory + knowledge Observability
(live voice loop) subsystem (two planes)
│ │ │
Multilingual Whisper SQLite sessions & data Restricted plane
STT (faster-whisper, summarizer.complete() → → SQLite journal (truth)
per-utterance ES/EN/PT) (non-realtime LLM) → optional export → Phoenix
│ retrieval / context
realtime LLM assembly Broad plane
make_llm_service() → stdout JSON
│ → OTel Collector → Grafana
Kokoro TTS → audio back to the browser
Two distinct LLM call shapes exist, and the codebase names them explicitly
(observability/model.py::ProviderPath):
- Realtime (
PIPECAT_LLM_SERVICE) — the streaming voice reply, built byintegrations/pipecat/pipeline.py::make_llm_service. Evidence includes stream/tool deltas. - Non-realtime (
COMPLETION_CLIENT) — single-shot completions for memory & knowledge (memory.summarizer.complete,knowledge.distill, …).
Every audited call site is registered in LLM_BOUNDARY_MANIFEST with the evidence it must
capture and its failure policy (capture failures are FAIL_OPEN_WITH_GAP — they never
break the user, they record a gap).
| Layer | Component | Options (default) | Swap point |
|---|---|---|---|
| Voice in | STT | faster-whisper, multilingual ES/EN/PT (THERAPY_WHISPER_MODEL=small) |
perception/stt.py |
| Brain — realtime | streaming LLM | anthropic | openrouter | ollama (THERAPY_LLM=anthropic) |
integrations/pipecat/pipeline.py::make_llm_service |
| Brain — non-realtime | completion LLM | same providers | memory/summarizer.py, knowledge/distill.py |
| Voice out | TTS | Kokoro (THERAPY_VOICE_{EN,ES,PT}) |
speech/tts.py |
| Memory | storage | SQLite | memory/store.py |
| Retrieval | embeddings / context | local pipeline | knowledge/ |
| Transport | WebRTC + TURN | coturn (turn compose service) |
compose.yaml |
| Restricted telemetry | journal / export | SQLite journal → Phoenix | observability/journal.py, exporters.py |
| Broad telemetry | logs / metrics / traces | stdout JSON → Collector → Grafana | observability/{logging,telemetry,metrics}.py |
THERAPY_LLM_MODEL overrides the model for whichever provider is selected; the per-provider
defaults live in make_llm_service (the source of truth — don't hard-code model IDs elsewhere).
Required
- Docker (the runtime and the test bed)
- Python via
uv(host tooling / IDE only — see the caveat in §0) - A provider API key or a local Ollama
- A browser with microphone permission
Optional (observability stacks)
- Phoenix (restricted-plane trace inspection)
- Grafana + OTel Collector (broad-plane dashboards)
git clone https://github.com/jsugg/TheraPy && cd TheraPy
cp .env.example .env
# edit .env: set ANTHROPIC_API_KEY, or configure Ollama (see §5)
make up
make logs # follow startup; Ctrl-C to detach (the stack keeps running)Then open http://localhost:8000 on the host, or http://<machine-name>:8000 from another
device on the LAN/tailnet to install the PWA. .env.example is thoroughly commented — it is
the canonical reference for every knob (speech, VAD, sessions, crisis contacts, OCR, push, TURN).
Everything routes through make (run make help for the live list). Pass extra pytest flags
with ARGS, e.g. make test ARGS="-k memory -x".
Container lifecycle
| Command | Does |
|---|---|
make up |
build if needed + (re)start the stack in the background |
make restart |
restart the server to pick up Python edits (no rebuild) |
make rebuild |
clean image rebuild (use when dependencies change) |
make down |
stop the stack |
make status |
container status + health |
make logs |
follow server logs |
make shell |
interactive shell in the running container |
Tests & quality
| Command | Does |
|---|---|
make test |
unit + integration in the container (the real test bed) |
make test-unit / make test-integration |
just one suite |
make e2e |
all browser e2e (auto-installs Chromium + Firefox) |
make lint |
Ruff (host) |
make typecheck |
Pyright in the container (the supported runtime) |
make coverage |
full suite + coverage report + COV_MIN fail-under gate (default 80) |
make check |
lint + typecheck + coverage — the pre-push gate (see §11) |
make hooks |
install the repo git hooks (.githooks) into your clone |
There is no CI.
make checkrun locally is the gate. Install the hooks (make hooks) socheckruns on push automatically (bypass any hook with--no-verify).
Observability gates
| Command | Does |
|---|---|
make obs-canary-scan |
routing/secret canary gate over the fixture corpus (the leak check) |
make obs-fixture-hash |
reproducible identity of the observability fixture corpus |
make obs-fixtures |
regenerate golden interaction fixtures + canaries |
make obs-baseline |
telemetry off/on workload baseline against a running instance |
make obs-dashboards |
regenerate the six Grafana dashboards deterministically |
Ollama runs on the host; the server runs in the container and reaches it via
host.docker.internal:
# host
ollama serve
ollama pull pedrolucas/smollm3:3b-q4_k_m # default local model — CPU-friendly es/en/pt
# .env
THERAPY_LLM=ollama
OLLAMA_BASE_URL=http://host.docker.internal:11434/v1What is and isn't offline:
- STT (faster-whisper) and TTS (Kokoro) are already local and deterministic given the same model + audio — no network.
- The LLM is the only component that needs the network unless you use Ollama, which makes the whole loop offline.
- Both the realtime path (
make_llm_service) and the non-realtime path (summarizer.complete) honorTHERAPY_LLM, so switching provider switches both.
⚠️ STT performance trap:THERAPY_WHISPER_MODELdefaults tosmallfor a reason. On CPU (no GPU),large-v3-turbomeasured warm time-to-first-audio jumping from ~9–13s to 34–124s. Don't bump the model for "quality" on a CPU host — real STT gains are gated on the GPU/VPS migration.
Both planes are off/local by default. You opt in per plane.
| Var | Default | Meaning |
|---|---|---|
THERAPY_CAPTURE_MODE |
runtime |
disabled | runtime | evaluation |
THERAPY_INTERACTION_BACKEND |
journal |
journal | phoenix |
THERAPY_INTERACTION_JOURNAL |
$THERAPY_DATA_DIR/interaction-journal.sqlite3 |
journal file path |
THERAPY_INTERACTION_RETENTION_DAYS |
30 |
unacknowledged records never expire |
THERAPY_INTERACTION_REMOTE_EXPORT |
0 |
egress gate — must be explicitly flipped |
THERAPY_OTLP_RESTRICTED_ENDPOINT |
— | e.g. http://phoenix:6006 with the Phoenix profile |
docker compose --profile llm-observability up -d phoenix # http://127.0.0.1:6006| Var | Default | Meaning |
|---|---|---|
THERAPY_OTEL_ENABLED |
0 |
broad traces/metrics export |
THERAPY_OTLP_BROAD_ENDPOINT |
http://localhost:4318 |
Collector OTLP endpoint |
THERAPY_CLIENT_TELEMETRY |
0 |
browser-side telemetry (a third, front-end source) |
THERAPY_LOG_LEVEL |
INFO |
|
THERAPY_ENVIRONMENT |
development |
development | test | dogfood | vps-test |
docker compose --profile observability up -d # Collector + Grafana
# Grafana → http://127.0.0.1:3000Both UIs bind to
127.0.0.1only — loopback by design; OTLP and scrape traffic stay internal. There are two profiles, and they map onto the two planes:llm-observability= Phoenix (restricted),observability= Collector + Grafana (broad).
The observability package is intentionally framework-free. Learn the boundaries before you touch it (each module's docstring cites the plan section it implements):
src/therapy/observability/
config.py Strict, frozen configuration (env → typed config)
model.py Vendor-neutral contracts (ProviderPath, LLM_BOUNDARY_MANIFEST, event kinds)
context.py Trace / interaction correlation context
routing.py Plane classification, denylist, canary scanning
logging.py Broad-plane JSON logging + third-party logger policy
telemetry.py Owned OTel bootstrap — the ONLY module importing the OTel SDK
metrics.py Logical instrument manifest with bounded attribute sets
interactions.py Canonical interaction record: frozen, typed, exactly serialized
journal.py Dedicated SQLite interaction journal (the evidence store)
exporters.py Backend-neutral interaction export
capture.py Interaction capture service + failure policy
health.py Component health snapshots + the readiness model
replay.py Deterministic, network-free replay of restricted journals
src/therapy/integrations/pipecat/
observability.py Pipecat-only telemetry adapter — the ONLY Pipecat-aware observability code
🚧 Architectural boundary: no observability logic belongs inside Pipecat-specific code. Pipecat is one pipeline framework among possible others; keep the contracts in
observability/vendor-neutral and letintegrations/pipecat/observability.pybe a thin adapter.telemetry.pyis the only place allowed to import the OTel SDK.
The differentiator. A change is not "done" because it runs — it's done when its captured evidence still replays and evaluates cleanly.
edit code → run fixtures → capture journal → replay deterministically
→ compare metrics / evaluations → approve or investigate → ship
tests/fixtures/observability/
interactions/ provider requests, streams, tool calls, failures
speech/ ES / EN / PT, silence, code-switching
research/ documents, OCR, embeddings, retrieval
behavior/ safety, policy, grounding
pipecat/ pipeline-adapter fixtures
Regenerate goldens with make obs-fixtures; prove the corpus is byte-stable with
make obs-fixture-hash.
replay.py is a library; the CLI is scripts/observability/replay_interaction.py. It
reconstructs and verifies one interaction (it requires the interaction id, not just the
journal):
docker compose exec therapy uv run --no-dev python \
scripts/observability/replay_interaction.py \
--journal /data/interaction-journal.sqlite3 \
--interaction-id <id> [--json]Exit code: 0 verified · 1 not verified · 2 error. Replay is network-free,
provider-free, and deterministic — that's what makes it usable as a regression oracle.
The restricted plane may contain deeply sensitive content. Treat it accordingly.
- Synthetic-only scope. Defaults capture locally and nothing leaves the host.
THERAPY_INTERACTION_REMOTE_EXPORT=0is the egress gate; genuine data, public exposure, or remote export are promotion triggers that need an explicit new threat-model decision — not a casual env flip. - Never commit real conversations. Tests use synthetic fixtures only. Journals live under
the data volume (
$THERAPY_DATA_DIR, git-ignored) — never add one to a commit, an issue, or an upload. - Keep sensitive content out of the broad plane. Prove it with
make obs-canary-scanbefore you push. - UIs are loopback-only (
127.0.0.1). Keep them that way in dev; exposing them is a threat-model decision, not a convenience.
Your data is local-first (SPEC §8) and yours to inspect or destroy — export or wipe the
container's data volume directly (the delete is irreversible; it wipes /data):
docker compose exec therapy uv run --no-dev python -m therapy.memory export > therapy-data.json
docker compose exec therapy uv run --no-dev python -m therapy.memory delete --yesAlways start here
curl -s localhost:8000/health # -> {"status":"ok","version":"…"}
make status # container health"Tests fail with StopIteration on every case" — stale bind-mount inode after editing
pyproject.toml/uv.lock. → make restart.
"Voice works but the LLM reply fails"
- Find the interaction in the journal and replay it (§8).
- Inspect the captured
PROVIDER_EVENT/TERMINAL_ERRORevidence (the raw provider error body). - Follow the correlation id across planes.
"Latency increased" — Grafana (broad plane): STT latency, retrieval latency, LLM
time-to-first-token, TTS synthesis latency. Baseline the overhead with make obs-baseline. Quick server-side numbers without
Grafana: docker compose logs therapy | grep TTFA (client time-to-first-audio, per turn).
"The model's behavior changed" — Phoenix (restricted plane): diff the prompt, memory context, retrieval context, and completion between a known-good captured interaction and the new one.
Each item maps to a real command — don't eyeball it:
-
make checkpasses — lint + Pyright + suite w/ coverage floor (the pre-push gate) -
make e2epasses (if you touched voice/UI/transport) - Voice loop verified —
docker compose exec therapy uv run --no-dev python scripts/verify_voice_text_loop.py - Memory continuity verified —
docker compose exec therapy uv run --no-dev python scripts/verify_memory_continuity.py -
make obs-canary-scanpasses — no sensitive data reachable by the broad plane -
make obs-fixture-hashunchanged (or intentionally regenerated withmake obs-fixtures) — replay stays deterministic - Metric attribute sets remain bounded (
observability/metrics.py) -
.env.exampleupdated if you added a knob
⚠️ v1 is single-user: a new WebRTC connection preempts the running pipeline. Don't run the voice/relay verifications against a container a browser tab (or another dev) is actively using — the connections evict each other and cross-contaminate results. See the reliability notes in../README.md.
Coverage floor is ratcheted via
COV_MIN(currently 80). If you add code, add tests — the floor only goes up.
../README.md— product overview, configuration, crisis-contact setup, WebRTC/PWA notesSPEC.md— the design contract and the numbered requirements (SPEC §…) the code cites.env.example— every environment knob, commentedmake help— the authoritative, live list of developer commands