feat: initial Osprey scaffold — FastAPI API, app structure, and PRIVACY.md

This commit is contained in:
pyr0ball 2026-07-11 22:47:13 -07:00
parent 2bc6813462
commit 0705e198c4
14 changed files with 440 additions and 0 deletions

17
.env.example Normal file
View file

@ -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

7
PRIVACY.md Normal file
View file

@ -0,0 +1,7 @@
# Privacy Policy
CircuitForge LLC's privacy policy applies to this product and is published at:
**<https://circuitforge.tech/privacy>**
Last reviewed: March 2026.

View file

@ -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)

0
api/__init__.py Normal file
View file

14
api/main.py Normal file
View file

@ -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"}

140
api/transcription.py Normal file
View file

@ -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 <Record> 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 <Record> 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})

0
api/webhooks/__init__.py Normal file
View file

104
api/webhooks/twilio.py Normal file
View file

@ -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 <Response> and return with correct content-type."""
return Response(
content=f'<?xml version="1.0" encoding="UTF-8"?><Response>{body}</Response>',
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 <Play digits="..."> for DTMF — commas are 0.5s pauses in DTMF notation
ivr_twiml = f'<Play digits="{digits}"/>' 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 = "<Pause length='45'/>"
# 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"<Redirect>{bridge_url}</Redirect>"
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("<Pause length='3600'/>")
# <Dial> with <Conference> bridges both legs into a named room.
# waitUrl="" suppresses hold music on our side (FTB's hold music is already playing).
conference_twiml = (
"<Dial>"
f" <Number statusCallbackEvent='answered' statusCallback='/webhooks/twilio/status'>"
f" {BRIDGE_NUMBER}"
" </Number>"
"</Dial>"
)
return _xml(conference_twiml)

0
app/__init__.py Normal file
View file

0
app/dialers/__init__.py Normal file
View file

112
app/dialers/ftb.py Normal file
View file

@ -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()

View file

@ -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.

7
requirements.txt Normal file
View file

@ -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