Commit Graph

17 Commits

Author SHA1 Message Date
bd078c058e Add Docker image + Gitea CI for Virgo Dev deploy
Containerize Hold Slayer as a single image (one FastAPI process serving
REST/WS/MCP and its built SvelteKit dashboard) for deployment to Virgo Dev
on triton.

- Dockerfile: 3-stage (node builds the dashboard → python wheels → runtime).
  Runs from source via `pip install -e .` so db/database.py resolves
  alembic.ini (via __file__.parent.parent) and migrations run on boot. The
  loose top-level modules (main.py, config.py) also require the source layout.
  pjsua2 is left unbuilt (documented stub media) — fine for a mock-SIP deploy.
- .dockerignore: keep .env and gitignored dashboard build artifacts out of
  the context; the node stage builds a fresh dashboard.
- CI (.gitea/workflows): single-image Trivy scan + build + push to
  git.helu.ca/r/hold-slayer (sha / latest-on-main / semver tags), mirroring
  the Demeter workflow. Requires a PACKAGE_TOKEN Actions secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 13:10:54 -04:00
e7c84885d9 Dashboard authenticates: token prompt + bearer on REST/WS/recordings
The UI never sent the bearer token, so with API_TOKEN set every data
call 401'd and /ws/events was rejected pre-accept (the 403s in the
uvicorn log). The API client now keeps the token in localStorage,
attaches Authorization to every request, prompts once on a 401 and
retries, and appends ?token= to the WebSocket connect — the page's
reconnect loop picks the token up after the first prompt.

require_token also accepts a ?token= query parameter (same convention
as the WebSocket) because <audio> elements fetching recordings can't
set headers; recordingUrl() rides the token there.

The dashboard header's status call moved to a new authenticated
GET /api/v1/status — its old source was the JSON root endpoint that
the dashboard itself replaced at /.

Two new auth tests (query-param accepted / wrong query-param 401);
dashboard rebuilt. Verified live: WS rejected without token and
connected with ?token=, status 200, ?token=wrong 401.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 06:21:55 -04:00
dff21f7d5c Serve dashboard at /, version the REST API under /api/v1
The SvelteKit build was always made for the root (no base path); it
now mounts at / — registered last so /api/v1, /ws, /health, and /mcp
match first — and the JSON root endpoint is gone (its info lives in
/health and gateway_status). REST routers move from /api/* to
/api/v1/*; /ws and /health stay put; /mcp/ unchanged. Dashboard API
client, tests, README, and docs updated; dashboard rebuilt (build/ is
gitignored).

Verified live: / serves the UI, /api/v1 answers 200/401, the old
/api paths 404, MCP still lists 15 tools.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:57:45 -04:00
a8f06462e6 Merge stages 2-6 into main (completes the stacked PR chain #2-#6)
PRs #2-#6 each merged into their stacked base branch rather than main;
feature/stage5-data-layer ended up holding the full accepted stack.
This merge lands that exact tree on main — no new content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:52:28 -04:00
90620fbf80 Merge pull request 'Stage 6: learn_call_flow rebuilt on the learner, docs truth sweep' (#6) from feature/stage6-learner-and-truth into feature/stage5-data-layer
Reviewed-on: #6
2026-07-10 17:49:22 +00:00
ff7ea8623a Stage 6: learn_call_flow rebuilt on the learner, docs truth sweep
The call-flow learner finally gets fed: exploration mode records its
IVR discoveries on the call (ActiveCall.exploration_steps) instead of
throwing them away, persistence stores them in the call record's
metadata, and the rebuilt learn_call_flow MCP tool turns a completed
exploration call into a stored flow via CallFlowLearner — correct
constructor (llm_client from get_llm, heuristic labels when the LLM
is unavailable), build for a new number, merge/refine when a flow
already exists. save_learned_flow/update_flow_from_model keep the
CallFlow↔row mapping in call_persistence.

Test gaps closed: tests/test_learner.py (discoveries→linked steps,
exploration persistence, learn-then-refine through the in-memory MCP
client, no-data and unknown-call answers) and tests/test_websocket.py
(4401 without token, trunk-status-then-replay on connect, per-call
stream filtering).

Docs aligned to code: README (15 tools incl. learn_call_flow, HTTP
not SSE, Python 3.12+, PostgreSQL+Alembic — no SQLite fallback, media
pipeline marked stub-mode until pjsua2 installed, Alembic and honest
/health checked off); docs/mcp-server.md rewritten against the actual
tool surface (hangup not end_call, real params, 3 real resources,
/mcp/ streamable HTTP + bearer auth); architecture/development/
configuration drift fixed.

pyproject: pruned never-imported deps (websockets, librosa,
soundfile, python-multipart).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:45:35 -04:00
f7a11f2f20 Stage 5: Alembic migrations, durable call rows, one transcript truth
Alembic replaces create_all as the schema authority: async env.py
against Base.metadata (CLI and in-app entry paths share it via
config.attributes["connection"]), an autogenerated baseline of the
create_all-era schema, and init_db now runs upgrade head — stamping
the baseline first on a pre-Alembic database so existing deployments
adopt cleanly. create_all remains for tests only.

Calls are durable from the start: CallManager gains an
on_call_created hook (wired to persist_call_on_create) that inserts
an in_progress CallRecord the moment a call is created;
persist_call_on_end finalizes that same row. A SIGKILL mid-call now
leaves an in_progress row instead of erasing the call from history
(verified live against the dev database).

One transcript representation: ActiveCall.transcript_chunks holds
TranscriptEntry (t_offset_ms, speaker, text) — add_transcript stamps
real offsets from connect time, receptionist passes speaker instead
of encoding it into "caller: ..." strings, persisted chunks carry
real seek offsets, and the dead CallRecord.transcript Text column is
dropped by migration. Device.is_online migrates String → Boolean
(with a USING cast for existing rows).

Model de-triplication: CallResponse/CallStatusResponse build via
from_call classmethods (one ActiveCall→response mapping);
DeviceStatus deleted — can_receive_call is a computed field on
Device and the list endpoint returns the domain model; all row↔dict
and row↔domain mapping now lives in call_persistence.py
(record_summary/record_detail/chunk_to_dict + device row functions).

New tests/test_data_layer.py: upgrade-head-matches-models,
pre-Alembic adoption, durable in_progress rows, end-without-create
fallback, transcript offsets, consolidated response models.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:42:52 -04:00
fd88543fb1 Merge pull request 'Stage 1: mount MCP server, bearer auth, outbound-call guards' (#1) from feature/stage1-agent-surface into main
Reviewed-on: #1
2026-07-10 11:07:53 +00:00
4048ce1db6 Stage 4: honest health, explicit error policy, event-bus integrity
Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get
the mock engine; an unconfigured trunk fails startup with guidance
instead of silently degrading. Root-caused why the engine always ran
mock: nested pydantic-settings never read .env (no env_file on the
sub-settings classes) — all 8 now declare it.

/health stops lying: reports engine mode (sippy|mock), a live DB
SELECT 1, trunk registration state with reason, and TTS/STT
availability from their last real request; "healthy" now requires
ready + db + sippy + registered trunk.

Error policy: leaf services (tts/transcription/llm_client) raise and
track availability; call-loop callers catch, publish EventType.ERROR
naming the failed service, and apply an explicit fallback. Persistence
writes get one bounded 3x exponential retry, then an ERROR log — no
more silent data loss.

Event bus: a full subscriber queue drops its oldest event (counted)
instead of silently evicting the subscription; subscribe(replay_last=N)
delivers the advertised history replay, used by /ws/events (25).

Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM;
voicemail polls for early hangup and stops/transcribes/hangs up in
finally; RecordingSession finally keeps its leg_ids so taps detach.

Dead code removed: models/contact.py + Contact table, dtmf_buffer,
transcribe_stream stub, SMS stub in notification.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:01:45 -04:00
67a00defc3 refactor: composition root in lifespan, break core↔services cycle, shared data layer
The gateway was the composition root, device registry, inbound-call
policy, and call-operations service in one class, with core↔services
circular imports papered over by function-local imports, wiring done
by assigning private attributes, and MCP tools duplicating REST query
logic against their own sessions.

Composition:
- main.py's lifespan now builds every service and wires them by
  constructor/registration. gateway.from_config() is gone; core/ no
  longer imports services/ anywhere — the cycle is dead.
- Inbound-call policy moved to ReceptionistService.on_inbound_call
  (routing evaluation, reject/answer, screening dispatch); wired as
  the engine's on_incoming_call by the lifespan. Receptionist deps
  (tts/transcription/recording/routing) are constructor-injected —
  no more gateway._tts reach-through or importing hold_slayer's
  private _get_llm (now services.llm_client.get_llm, shared).
- Hold-slayer launch goes through a mode-handler registry
  (register_mode_handler); the gateway no longer knows the service's
  type. CallManager takes on_call_ended in its constructor.
- build_sip_engine() is a pure function taking explicit callbacks.
- api/routing.py uses the routing service from app.state via a
  proper dependency instead of gateway._routing.

Shared data layer:
- db.session_scope() is the one session convention (get_db wraps it).
- services/call_persistence.py gains the query/write functions and
  the single StoredCallFlow→CallFlow mapper; api/call_flows.py,
  api/call_history.py, and the six DB-touching MCP tools are thin
  wrappers over them — the two surfaces can't drift.
- legs_for_call() replaces the three private _call_legs scans
  (gateway transfer/hangup, REST dtmf, MCP dtmf).

7 new tests (mode-handler launch, on_call_ended hook, receptionist
inbound answer/reject, call-flow CRUD round-trip and history routes
against real SQLite through the shared layer). aiosqlite added to dev
deps for that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:29:01 -04:00
5880b59872 fix: enforce thread ownership at the Sippy/PJSUA2 boundary
Three thread domains were mutating shared dicts with no locks: Sippy's
ED thread wrote _legs/_registered_devices directly from SIP handlers,
the asyncio loop wrote them from make_call/hangup, and
run_in_executor(None, ...) had default-pool threads driving sippy UA
objects. AudioTap.feed() pushed into an asyncio.Queue (not
thread-safe) from the PJSUA2 thread.

New ownership rule, enforced structurally:
- The asyncio loop owns all app-visible state; the only mutator is the
  new _on_engine_event funnel. Sippy handlers extract plain strings on
  the ED thread and post via run_coroutine_threadsafe.
- The ED thread owns sippy objects plus _ed_ua_to_leg/_ed_leg_to_ua;
  loop-side commands (INVITE/BYE/DTMF/trunk register) hop over via
  ED2.callFromThread. UA references no longer live on SipCallLeg.
- AudioTap captures its loop and feed() hops via call_soon_threadsafe.
- Fix ED import: installed sippy 2.x exposes ED2, not ED — the old
  import could never start the event loop.

Also:
- Wire the never-connected on_leg_state_change callback: outbound
  ringing/connected/terminated now reaches CallManager; a call ends
  when its last leg terminates (transfers keep it alive). Adds
  CallManager.unmap_leg/legs_for_call.
- AudioClassifier.classify(): async entry that runs the FFT work in
  asyncio.to_thread and updates history on the loop — all four
  hold_slayer call sites now route through it, fixing both the
  loop-blocking and the 2-of-4 history gap. DTMF Goertzel loop
  replaced by the equivalent vectorized DFT-bin power.
- Task hygiene: gateway.spawn() tracks hold-slayer/receptionist tasks
  and stop() cancels them; recording safety-timeout task is retained
  and cancelled on stop_recording; engine tracks incoming-call
  dispatch tasks.

10 new tests: funnel events from a foreign thread, auto-answer
fallback, AudioTap cross-thread feed, classifier history, leg-state →
call status (including no stomping of ON_HOLD), stop() cancellation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:53:36 -04:00
94fb6cd79d feat: mount MCP server, add bearer auth, and guard outbound calls
The MCP server was created but never mounted — no client could reach
it. Mount it at /mcp/ over streamable HTTP with a combined lifespan,
resolving the gateway lazily so mounting happens at app construction.

Security and safety for the agent surface:
- One static API_TOKEN (SecretStr) enforced across REST (dependency),
  WebSocket (query param/header before accept), and MCP
  (StaticTokenVerifier). Startup refuses tokenless non-loopback binds.
- Emergency numbers (911/9911/112) always refused on make_call, plus a
  MAX_CONCURRENT_CALLS cap; ValueError surfaces as 400/ToolError.
- Safe defaults: debug off, no credential in default DATABASE_URL,
  SIP/LLM/TTS secrets as SecretStr.

Cleanups:
- Delete broken learn_call_flow tool (wrong ctor args, nonexistent
  method) and the never-fed CallAnalytics service; keep
  call_flow_learner for proper wiring later.
- Trim dial_plan to what is actually used (emergency guard, extension
  allocation); delete the unreferenced matcher/normaliser.
- Register call_history before calls so /api/calls/history is no
  longer shadowed by /api/calls/{call_id}.
- fastmcp pinned >=3.0 (http_app + StaticTokenVerifier).

New tests: MCP in-memory client (tool surface, lazy gateway, emergency
refusal, call cap) and API security (401 paths, route order, mount).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:20:24 -04:00
9a84987796 Docs 2026-05-25 14:45:29 -04:00
63f1a270bb feat: add call history API endpoints and TTS service client
Adds read-only access to persisted call records for the dashboard
and implements a client for the Rhema text-to-speech service.

- api/call_history.py: New router providing paged call lists
  and detailed call records with transcript metadata.
- services/tts.py: Async client for OpenAI-compatible TTS
  endpoints (Rhema/Kokoro) used for call-flow steps.
2026-05-22 06:28:33 -04:00
dbdb03beb9 chore(config): update speaches config and ignore sveltekit dashboard
- Simplified .env.example to use localhost SPEACHES_URL
- Removed unused prod_url from SpeachesSettings config
- Added dashboard node_modules and build dirs to .gitignore
- Streamlines local development setup
2026-05-16 18:21:07 -04:00
ecf37658ce feat: add initial Hold Slayer AI telephony gateway implementation
Complete project scaffolding and core implementation of an AI-powered
telephony system that calls companies, navigates IVR menus, waits on
hold, and transfers to the user when a human answers.

Key components:
- FastAPI server with REST API, WebSocket, and MCP (SSE) interfaces
- SIP/VoIP call management via PJSUA2 with RTP audio streaming
- LLM-powered IVR navigation using OpenAI/Anthropic with tool calling
- Hold detection service combining audio analysis and silence detection
- Real-time STT (Whisper/Deepgram) and TTS (OpenAI/Piper) pipelines
- Call recording with per-channel and mixed audio capture
- Event bus (asyncio pub/sub) for real-time client updates
- Web dashboard with live call monitoring
- SQLite persistence via SQLAlchemy with call history and analytics
- Notification support (email, SMS, webhook, desktop)
- Docker Compose deployment with Opal VoIP and Opal Media containers
- Comprehensive test suite with unit, integration, and E2E tests
- Simplified .gitignore and full project documentation in README
2026-03-21 19:23:26 +00:00
c9ff60702b Initial commit 2026-03-21 19:21:33 +00:00