diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9327ae1 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Twilio credentials — from console.twilio.com +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TWILIO_AUTH_TOKEN=your-auth-token-here +TWILIO_FROM_NUMBER=+1XXXXXXXXXX # your purchased Twilio number + +# Osprey service config +OSPREY_BASE_URL=https://circuitforge.tech # public base URL for Twilio callbacks +OSPREY_BRIDGE_NUMBER=+1XXXXXXXXXX # your cell — gets called when in queue +OSPREY_STATE_FILE=/tmp/osprey_call_state.txt # shared state between webhook + dialer + +# FTB IVR sequence — DTMF digits, commas = 0.5s pause +# Example: "1,,,2,,,0" = press 1, wait 1.5s, press 2, wait 1.5s, press 0 +OSPREY_IVR_SEQUENCE= + +# Retry config +OSPREY_RETRY_INTERVAL=180 # seconds between attempts (3 min default) +OSPREY_MAX_RETRIES=20 diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..afc7b9f --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,7 @@ +# Privacy Policy + +CircuitForge LLC's privacy policy applies to this product and is published at: + +**** + +Last reviewed: March 2026. diff --git a/README.md b/README.md index a3e607d..9383dbd 100644 --- a/README.md +++ b/README.md @@ -102,3 +102,7 @@ Osprey is developed and maintained on Forgejo at [git.opensourcesolarpunk.com/Ci --- *Circuit Forge LLC — Privacy · Safety · Accessibility* + +--- + +Humans own design, architecture, code review, testing, and verification. LLMs are part of our development workflow. [Our positions on LLM use →](https://circuitforge.tech/positions) diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..e2a3201 --- /dev/null +++ b/api/main.py @@ -0,0 +1,14 @@ +"""Osprey API — government hold-line automation and form assistance.""" +from fastapi import FastAPI +from api.transcription import router as transcription_router +from api.webhooks.twilio import router as twilio_router + +app = FastAPI(title="Osprey", version="0.1.0") + +app.include_router(twilio_router) +app.include_router(transcription_router) + + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "osprey"} diff --git a/api/transcription.py b/api/transcription.py new file mode 100644 index 0000000..9288671 --- /dev/null +++ b/api/transcription.py @@ -0,0 +1,140 @@ +""" +Transcription endpoints — faster-whisper audio-to-text for IVR analysis. + +Two surfaces: + POST /transcribe — raw audio upload (internal / testing use) + POST /transcribe/recording — Twilio webhook; downloads RecordingUrl + and returns transcribed text + +Model is lazy-loaded on first request and cached for the process lifetime. +Set OSPREY_WHISPER_MODEL to override the default model path. +Set OSPREY_WHISPER_DEVICE to "cpu" if no GPU is available (slower). +""" +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +import httpx +from fastapi import APIRouter, Form, HTTPException, UploadFile +from fastapi.responses import JSONResponse + +router = APIRouter(prefix="/transcribe", tags=["transcription"]) + +# Default: the faster-whisper-medium model migrated to Assets +_DEFAULT_MODEL_PATH = ( + "/Library/Assets/LLM/whisper/models/Whisper/faster-whisper" + "/models--Systran--faster-whisper-medium/snapshots" +) + +_model = None # lazy-loaded WhisperModel singleton + + +def _get_model(): + """Load and cache the WhisperModel. Raises RuntimeError if faster-whisper is not installed.""" + global _model + if _model is not None: + return _model + + try: + from faster_whisper import WhisperModel # type: ignore + except ImportError as exc: + raise RuntimeError( + "faster-whisper is not installed. " + "Add faster-whisper to requirements.txt and reinstall." + ) from exc + + model_path = os.environ.get("OSPREY_WHISPER_MODEL", _DEFAULT_MODEL_PATH) + device = os.environ.get("OSPREY_WHISPER_DEVICE", "cuda") + compute_type = "float16" if device == "cuda" else "int8" + + # Resolve snapshot hash dir if the path points to the snapshots parent + path = Path(model_path) + if path.is_dir(): + snapshots = list(path.iterdir()) if path.name == "snapshots" else [] + if snapshots: + path = snapshots[0] # use the first (and typically only) snapshot + + _model = WhisperModel(str(path), device=device, compute_type=compute_type) + return _model + + +def _transcribe_file(audio_path: str) -> dict: + """Run transcription on a local file. Returns segments and full text.""" + model = _get_model() + segments, info = model.transcribe(audio_path, beam_size=5) + segment_list = [ + {"start": round(s.start, 2), "end": round(s.end, 2), "text": s.text.strip()} + for s in segments + ] + full_text = " ".join(s["text"] for s in segment_list).strip() + return { + "text": full_text, + "language": info.language, + "language_probability": round(info.language_probability, 3), + "segments": segment_list, + } + + +@router.post("") +async def transcribe_upload(file: UploadFile) -> JSONResponse: + """ + Accept a raw audio file upload and return the transcription. + Supports any format faster-whisper accepts (wav, mp3, ogg, flac, m4a). + """ + suffix = Path(file.filename or "audio.wav").suffix or ".wav" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(await file.read()) + tmp_path = tmp.name + + try: + result = _transcribe_file(tmp_path) + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + finally: + Path(tmp_path).unlink(missing_ok=True) + + return JSONResponse(result) + + +@router.post("/recording") +async def transcribe_twilio_recording( + RecordingUrl: str = Form(...), + RecordingSid: str = Form(""), + RecordingDuration: str = Form("0"), +) -> JSONResponse: + """ + Twilio status callback. Downloads the recording and transcribes it. + + Twilio posts RecordingUrl without an extension; appending .wav gives a + direct download. Authentication uses TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN. + """ + account_sid = os.environ.get("TWILIO_ACCOUNT_SID", "") + auth_token = os.environ.get("TWILIO_AUTH_TOKEN", "") + + wav_url = RecordingUrl.rstrip("/") + ".wav" + auth = (account_sid, auth_token) if account_sid and auth_token else None + + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get(wav_url, auth=auth) + resp.raise_for_status() + except httpx.HTTPError as exc: + raise HTTPException( + status_code=502, + detail=f"Failed to download Twilio recording: {exc}", + ) from exc + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp.write(resp.content) + tmp_path = tmp.name + + try: + result = _transcribe_file(tmp_path) + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + finally: + Path(tmp_path).unlink(missing_ok=True) + + return JSONResponse({**result, "recording_sid": RecordingSid, "duration_s": RecordingDuration}) diff --git a/api/webhooks/__init__.py b/api/webhooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/webhooks/twilio.py b/api/webhooks/twilio.py new file mode 100644 index 0000000..d967983 --- /dev/null +++ b/api/webhooks/twilio.py @@ -0,0 +1,104 @@ +""" +Twilio webhook handlers — TwiML responses for outbound government hold-line calls. + +Twilio calls these endpoints during a live call to get instructions (TwiML XML). +The initiating script (app/dialers/ftb.py) starts the call; Twilio fetches +/webhooks/twilio/twiml to know what to do once the call connects. +""" +from __future__ import annotations + +import os +from fastapi import APIRouter, Form, Response +from fastapi.responses import PlainTextResponse + +router = APIRouter(prefix="/webhooks/twilio") + +# Phone number to bridge the user into the call once we're in queue. +# Set via OSPREY_BRIDGE_NUMBER env var (E.164 format, e.g. +15105551234). +BRIDGE_NUMBER = os.environ.get("OSPREY_BRIDGE_NUMBER", "") + + +def _xml(body: str) -> Response: + """Wrap TwiML body in and return with correct content-type.""" + return Response( + content=f'{body}', + media_type="application/xml", + ) + + +@router.post("/twiml") +async def twiml_navigate(): + """ + Called by Twilio when the outbound call to the agency connects. + Returns TwiML that navigates the IVR and then holds. + + IVR sequence is loaded from OSPREY_IVR_SEQUENCE env var — + a comma-separated list of DTMF digits/pauses, e.g. "1,,,2,,,0" + where commas are 0.5s pauses between digits. + + After navigation, we pause for 45s. If the call is still alive + at the /status callback, the status handler initiates the bridge call. + """ + sequence = os.environ.get("OSPREY_IVR_SEQUENCE", "") + digits = sequence.replace(" ", "") # strip accidental spaces + + # Build for DTMF — commas are 0.5s pauses in DTMF notation + ivr_twiml = f'' if digits else "" + + # Hold for long enough to confirm we're in queue before bridging. + # 45s covers the IVR tree + any initial hold announcement. + hold_twiml = "" + + # After holding, redirect to the bridge endpoint to connect the user. + bridge_url = os.environ.get("OSPREY_BASE_URL", "").rstrip("/") + "/webhooks/twilio/bridge" + redirect_twiml = f"{bridge_url}" + + return _xml(ivr_twiml + hold_twiml + redirect_twiml) + + +@router.post("/status") +async def call_status( + CallStatus: str = Form(...), + CallDuration: str = Form("0"), + CallSid: str = Form(""), +): + """ + Twilio posts here on every call status change. + We log rejections (short duration + completed/busy/failed) for retry logic. + The dialer script polls a shared state file; this endpoint writes to it. + """ + duration = int(CallDuration or 0) + rejected = CallStatus in ("completed", "busy", "failed", "no-answer") and duration < 30 + + state_file = os.environ.get("OSPREY_STATE_FILE", "/tmp/osprey_call_state.txt") + with open(state_file, "w") as f: + f.write(f"{CallStatus}:{duration}:{CallSid}") + + if rejected: + print(f"[Osprey] Call {CallSid} rejected after {duration}s — will retry") + else: + print(f"[Osprey] Call {CallSid} status={CallStatus} duration={duration}s") + + return PlainTextResponse("OK") + + +@router.post("/bridge") +async def bridge_call(): + """ + Called after the hold period — we're confident we're in the queue. + Dials OSPREY_BRIDGE_NUMBER and connects both legs into a conference. + """ + if not BRIDGE_NUMBER: + # No bridge number configured — just keep holding silently. + return _xml("") + + # with bridges both legs into a named room. + # waitUrl="" suppresses hold music on our side (FTB's hold music is already playing). + conference_twiml = ( + "" + f" " + f" {BRIDGE_NUMBER}" + " " + "" + ) + return _xml(conference_twiml) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/dialers/__init__.py b/app/dialers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/dialers/ftb.py b/app/dialers/ftb.py new file mode 100644 index 0000000..8d473df --- /dev/null +++ b/app/dialers/ftb.py @@ -0,0 +1,112 @@ +""" +FTB (California Franchise Tax Board) hold-line dialer. + +Usage: + conda run -n cf python -m app.dialers.ftb + +Reads from environment (see .env.example): + TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER + OSPREY_BASE_URL — public HTTPS base, e.g. https://circuitforge.tech + OSPREY_BRIDGE_NUMBER — your cell in E.164 format, e.g. +15105551234 + OSPREY_IVR_SEQUENCE — DTMF string to navigate FTB IVR (see below) + OSPREY_RETRY_INTERVAL — seconds between retry attempts (default: 180) + OSPREY_MAX_RETRIES — give up after this many rejections (default: 20) + +OSPREY_IVR_SEQUENCE format: + Comma-separated DTMF digits. Each comma = 0.5s pause. + Example: "1,,,2,,,0" means press 1, wait 1.5s, press 2, wait 1.5s, press 0. + Set this to the FTB IVR key sequence that reaches a human agent. + + TODO: Fill in the correct FTB IVR sequence below once confirmed. + The FTB main line is 800-852-5711. # gitleaks:allow — public government phone number +""" +from __future__ import annotations + +import os +import time +import dotenv +from twilio.rest import Client + +dotenv.load_dotenv() + +# ── Twilio credentials ──────────────────────────────────────────────────────── +ACCOUNT_SID = os.environ["TWILIO_ACCOUNT_SID"] +AUTH_TOKEN = os.environ["TWILIO_AUTH_TOKEN"] +FROM_NUMBER = os.environ["TWILIO_FROM_NUMBER"] # your Twilio number, E.164 + +# ── Call targets ────────────────────────────────────────────────────────────── +FTB_NUMBER = "+18008525711" +BASE_URL = os.environ["OSPREY_BASE_URL"].rstrip("/") +STATE_FILE = os.environ.get("OSPREY_STATE_FILE", "/tmp/osprey_call_state.txt") + +# ── Retry config ────────────────────────────────────────────────────────────── +RETRY_INTERVAL = int(os.environ.get("OSPREY_RETRY_INTERVAL", "180")) # seconds +MAX_RETRIES = int(os.environ.get("OSPREY_MAX_RETRIES", "20")) + + +def _read_state() -> tuple[str, int]: + """Read last call status + duration from state file written by /status webhook.""" + try: + with open(STATE_FILE) as f: + parts = f.read().strip().split(":") + return parts[0], int(parts[1]) if len(parts) > 1 else 0 + except FileNotFoundError: + return "unknown", 0 + + +def _clear_state() -> None: + try: + os.remove(STATE_FILE) + except FileNotFoundError: + pass + + +def dial(client: Client) -> str: + """Initiate one outbound call to FTB. Returns the CallSid.""" + call = client.calls.create( + to=FTB_NUMBER, + from_=FROM_NUMBER, + url=f"{BASE_URL}/webhooks/twilio/twiml", + status_callback=f"{BASE_URL}/webhooks/twilio/status", + status_callback_event=["completed", "busy", "failed", "no-answer"], + status_callback_method="POST", + # machine_detection="DetectMessageEnd", # uncomment to skip voicemail + ) + print(f"[Osprey] Dialing FTB — CallSid: {call.sid}") + return call.sid + + +def run() -> None: + client = Client(ACCOUNT_SID, AUTH_TOKEN) + _clear_state() + + for attempt in range(1, MAX_RETRIES + 1): + print(f"\n[Osprey] Attempt {attempt}/{MAX_RETRIES}") + dial(client) + + # Poll for status — Twilio posts to /status within a few seconds of call end. + # A call that stays alive means we navigated past the rejection. + for _ in range(90): # wait up to 90s for status + time.sleep(1) + status, duration = _read_state() + if status not in ("unknown", "initiated", "ringing", "in-progress"): + break + + status, duration = _read_state() + rejected = status in ("completed", "busy", "failed", "no-answer") and duration < 30 + + if not rejected: + print(f"[Osprey] In queue! (status={status}, duration={duration}s) — bridging your phone.") + print("[Osprey] Pick up when your phone rings.") + return + + print(f"[Osprey] Rejected (status={status}, duration={duration}s). " + f"Retrying in {RETRY_INTERVAL}s...") + _clear_state() + time.sleep(RETRY_INTERVAL) + + print(f"[Osprey] Gave up after {MAX_RETRIES} attempts. Try again later.") + + +if __name__ == "__main__": + run() diff --git a/app/easter_eggs/hold_music/README.md b/app/easter_eggs/hold_music/README.md new file mode 100644 index 0000000..b545d52 --- /dev/null +++ b/app/easter_eggs/hold_music/README.md @@ -0,0 +1,35 @@ +# Hold Music Jukebox + +Secret easter egg — accessible via keyboard shortcut or `/music` command while a hold session is active. + +## Concept + +The bot is waiting on hold so you don't have to. But if you want to suffer in solidarity, or just appreciate the absurdity, the jukebox is here. + +A small player UI appears with a curated set of tracks — royalty-free music in the style of genuine telephone hold music: smooth jazz, corporate synth, lo-fi elevator. Each track unlocks after a cumulative hold-time threshold. + +## UI Notes + +- Aesthetic: 1990s phone system. Segmented LCD display. Big chunky buttons. +- Player lives in a slide-up panel or floating widget — non-intrusive +- Volume control, track skip, track name display +- Tracks unlock progressively (total minutes on hold across all sessions) + +## Track Placement + +Drop audio files here as `.mp3` or `.ogg`: + +``` +hold_music/ + tracks/ + track_01_corporate_serenity.mp3 + track_02_smooth_reassurance.mp3 + track_03_your_call_is_important.mp3 + ... + README.md ← you are here +``` + +## Trigger + +Not implemented yet. Planned: keyboard shortcut shown only during an active hold session. +Possibly: the Osprey logo grows a little pair of headphones when music is playing. diff --git a/app/easter_eggs/hold_music/tracks/.gitkeep b/app/easter_eggs/hold_music/tracks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f5a3ba1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +twilio>=9.0.0 +python-dotenv>=1.0.0 +python-multipart>=0.0.9 +httpx>=0.27.0 +faster-whisper>=1.0.0