feat: initial Osprey scaffold — FastAPI API, app structure, and PRIVACY.md
This commit is contained in:
parent
2bc6813462
commit
0705e198c4
14 changed files with 440 additions and 0 deletions
17
.env.example
Normal file
17
.env.example
Normal 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
7
PRIVACY.md
Normal 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.
|
||||
|
|
@ -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
0
api/__init__.py
Normal file
14
api/main.py
Normal file
14
api/main.py
Normal 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
140
api/transcription.py
Normal 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
0
api/webhooks/__init__.py
Normal file
104
api/webhooks/twilio.py
Normal file
104
api/webhooks/twilio.py
Normal 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
0
app/__init__.py
Normal file
0
app/dialers/__init__.py
Normal file
0
app/dialers/__init__.py
Normal file
112
app/dialers/ftb.py
Normal file
112
app/dialers/ftb.py
Normal 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()
|
||||
35
app/easter_eggs/hold_music/README.md
Normal file
35
app/easter_eggs/hold_music/README.md
Normal 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.
|
||||
0
app/easter_eggs/hold_music/tracks/.gitkeep
Normal file
0
app/easter_eggs/hold_music/tracks/.gitkeep
Normal file
7
requirements.txt
Normal file
7
requirements.txt
Normal 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
|
||||
Loading…
Reference in a new issue