RxVoice Guard Voice QA API
Build a FastAPI service that evaluates synthetic medication transcripts.
Introduction
30 Second Summary
Medication names can look almost right after a voice recording becomes text. A single missing syllable can hide inside an otherwise convincing transcript.
In this project, you will build a FastAPI service inside Docker for sending synthetic recordings to the Deepgram Speech-to-Text API with Nova-3 Pharma. You will turn each transcript into deterministic evidence that shows whether every required medication term survived.
What You'll Build
Your synthetic medication recording becomes a structured pass or fail report that pinpoints every drug name the speech model captured or missed.
By the end of this project, you'll have:
- A live transcription flow where you upload a synthetic M4A, WAV, or MP3 recording through the interactive API docs. You see a Nova-3 Pharma transcript with confidence, request ID, audio duration, and processing time.
- Deterministic QA evidence that turns expected pharmaceutical terms into coverage, matched terms, missing terms, and a clear pass or fail result.
- Repeatable container tests that prove the API works without a network call or Deepgram credit.
- Secret Mission: Flag likely spelling near misses for missing drug names while keeping exact matching as the only pass condition.
Are there any prerequisites?
You need Docker Desktop on macOS, a code editor, a free Deepgram account, and experience with Python plus external APIs. Use only synthetic speech because this learning project must never receive real patient data or protected health information.
Before We Start
Before any hands-on work, let’s define the purpose of RxVoice Guard. You’ll commit to evaluating synthetic pharmaceutical audio while keeping patient data and protected health information outside this learning project.
Set Up a Reproducible Workspace
RxVoice Guard needs the same Python runtime each time you build it. Your Mac has Python 3, but its minor version is unconfirmed.
A pinned Docker container image supplies python:3.11.18-slim without changing your local installation. That keeps dependency behavior consistent before you write application code.
In this step, get ready to:
- Create the local workspace structure.
- Export a Deepgram API key in your current Terminal session.
- Verify the pinned Python runtime with Docker.
Create the workspace
The app/ directory will hold the service code. The tests/ directory will hold its automated checks.
Each directory starts with an empty __init__.py package file. These files establish the exact starting artifact state for the project.
- Press Cmd+Space on macOS to open Spotlight.
- Type Terminal into Spotlight.
- Press Enter to open Terminal.
You will see a shell prompt where you can enter commands.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
The cd command changes Terminal's current location to your Desktop. This keeps rxvoice-guard easy to find.
- Create the workspace directories plus both empty package files by running these commands:
mkdir -p rxvoice-guard/app rxvoice-guard/tests
cd rxvoice-guard
touch app/__init__.py tests/__init__.py
What do these commands create?
- The mkdir command creates the rxvoice-guard/app/ directory plus the rxvoice-guard/tests/ directory.
- The cd command moves Terminal into rxvoice-guard.
- The touch command creates both empty __init__.py files.
- Confirm the package files exist by running this command:
ls app tests
What should you see?
- The app: section contains __init__.py.
- The tests: section contains __init__.py.
Package files missing?
- Check that your Terminal prompt shows the rxvoice-guard directory.
- Rerun the workspace creation commands if either directory is missing.
Help me diagnose my missing workspace files.
Good work. Your workspace now has the two package markers that every later module builds around.
✔️ Awesome, I've got everything!
Both package files are empty. Your workspace matches the required starting artifact state.
ⓧ I'd like to double check the full code
Both complete files contain zero lines of code. Their empty contents are intentional.
- Confirm that app/__init__.py contains no text.
- Confirm that tests/__init__.py contains no text.
Prepare the Deepgram credential
Deepgram uses an API key to authenticate transcription requests. The credential connects later API calls to your Deepgram project.
Credential screens can feel high stakes. This key stays out of the project files.
- Open the Deepgram Console in your browser.
- Complete the account registration prompts if the Console asks you to register.
- Sign in to the Deepgram Console.
- Select the generated project.
- Select Settings.
- Select API Keys.
- Select Create a New API Key.
- Select Create Key.
Copy the key before leaving
Deepgram shows the secret once. The full key disappears after you select Got it.
- Copy the secret immediately.
- Select Got it after copying the secret.
- Switch back to the Terminal window from earlier.
- Export the key in your current Terminal session by replacing your-api-key-here in the command below:
export DEEPGRAM_API_KEY="your-api-key-here"
What does this command do?
The export command stores the credential in an environment variable named DEEPGRAM_API_KEY.
Processes launched from this Terminal session can read the key. The secret never enters your source files.
Terminal returns to a fresh prompt when the export succeeds.
Key export rejected?
- Keep the double quotes around the secret.
- Create a fresh key if the original secret was exposed outside your Terminal.
Help me export my Deepgram API key safely.
Verify the pinned Python runtime
The official python:3.11.18-slim image supplies the runtime used throughout this project. Your local Python minor version cannot change the version inside this container.
- Press Cmd+Space on macOS to open Spotlight.
- Type Docker Desktop into Spotlight.
- Press Enter to start Docker Desktop.
- Wait for Docker Desktop to finish starting.
The first container run may take a few minutes while Docker downloads the image. A quiet wait during the download is normal.
Before you run the check, which Python version do you expect the container to report?
- Verify the pinned runtime by running this command:
docker run --rm python:3.11.18-slim python --version
What does this command do?
- The docker run command starts a temporary container from python:3.11.18-slim.
- The --rm flag removes the temporary container after the version check exits.
- The final Python command prints the runtime version from inside the container.
You will see Python 3.11.18. That output proves the project can use its pinned runtime regardless of your Mac's local Python version.
Docker check not running?
- Confirm that Docker Desktop has finished starting.
- Rerun the version command after Docker Desktop is ready.
- Check your internet connection if the image download stops.
Help me diagnose my Docker runtime check.
That is the foundation locked in. Next, you will build the first FastAPI route inside the pinned runtime.
Build the Health API
Your pinned Python runtime in Docker is ready. The application itself has never started.
A small health route is the first visible slice of your REST API. FastAPI will serve that route from the same container used throughout the project.
In this step, get ready to:
- Pin the six packages used by the service.
- Define a health route that returns a JSON response.
- Run the packaged service through Docker.
Pin the API dependencies
A requirements file gives every image build the same package versions. This keeps the API reproducible across machines.
- Create requirements.txt inside rxvoice-guard by adding the code below:
fastapi==0.162.0
httpx==0.28.1
pydantic==2.14.2
python-multipart==0.0.33
pytest==9.2.0
uvicorn==0.54.0
What do these dependencies do?
- FastAPI defines the web application.
- HTTPX supports outbound HTTP requests.
- Pydantic validates typed data.
- The python-multipart package supports file uploads.
- The pytest test runner verifies application behavior.
- Uvicorn serves the FastAPI application.
- Save requirements.txt.
- Confirm requirements.txt appears beside app/ in your editor's file tree.
Is requirements.txt in the wrong place?
- Check that requirements.txt sits directly inside rxvoice-guard.
- Compare all six package pins with the code block above.
Help me fix my requirements file placement or package pins.
A health endpoint gives the container one simple behavior to prove. The response also gives you a concrete signal that routing works.
- Create app/main.py inside rxvoice-guard by adding the code below:
from fastapi import FastAPI
app = FastAPI(title="RxVoice Guard", version="1.0.0")
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
What does this code do?
- The app variable stores the FastAPI application.
- The @app.get("/health") decorator connects a GET request to the health route.
- The asynchronous health() function returns the service status.
- Save app/main.py.
- Confirm main.py appears beneath app/ in your editor's file tree.
Is main.py outside the app folder?
- Move main.py beneath the existing app/ directory.
- Keep the existing empty app/__init__.py file in place.
Help me check my FastAPI file structure.
Package the API in Docker
The build context can contain local files that the service does not need. A .dockerignore file keeps secrets plus generated caches outside the image.
- Create .dockerignore inside rxvoice-guard by adding the code below:
.env
.git
.venv
__pycache__
.pytest_cache
*.pyc
What does this file exclude?
These patterns exclude local secrets plus version-control data. They also exclude Python cache files from the build context.
- Save .dockerignore.
- Confirm .dockerignore appears directly inside rxvoice-guard.
Can't see .dockerignore?
- Enable hidden-file visibility in your editor's file tree.
- Check that the filename starts with a single full stop.
Help me locate my hidden Docker ignore file.
A Dockerfile turns the source files into a runnable image. Its instruction order lets unchanged dependencies reuse an earlier build layer.
- Create Dockerfile inside rxvoice-guard by adding the code below:
FROM python:3.11.18-slim
WORKDIR /app
COPY requirements.txt ./
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
How does this image work?
- The FROM instruction selects the pinned Python image.
- The WORKDIR instruction sets /app as the container's application directory.
- The first COPY instruction places requirements.txt before the source code.
- The RUN instruction installs every pinned dependency without retaining the pip cache.
- The second COPY instruction adds the app/ package.
- The EXPOSE instruction documents port 8000.
- The CMD instruction starts Uvicorn when the container runs.
- Save Dockerfile.
- Confirm Dockerfile appears directly inside rxvoice-guard.
Is Dockerfile saved with an extension?
- Rename the file to exactly Dockerfile.
- Remove any automatic .txt extension from the filename.
Help me fix my Dockerfile name or location.
Choose the second tab if you want to compare every file created in this step.
✔️ Awesome, I've got everything!
Your four files now define the first runnable version of RxVoice Guard.
ⓧ I'd like to double check the full code
- Compare each file with the reference below.
fastapi==0.162.0
httpx==0.28.1
pydantic==2.14.2
python-multipart==0.0.33
pytest==9.2.0
uvicorn==0.54.0
from fastapi import FastAPI
app = FastAPI(title="RxVoice Guard", version="1.0.0")
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
.env
.git
.venv
__pycache__
.pytest_cache
*.pyc
FROM python:3.11.18-slim
WORKDIR /app
COPY requirements.txt ./
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Build and inspect the service
A container image packages the runtime with your source files. Building it now tests the dependency pins plus the Dockerfile instructions.
The first build can spend extra time downloading packages. Later builds can reuse unchanged layers.
- Build the rxvoice-guard image from your current Terminal directory by running this command:
docker build -t rxvoice-guard .
What does this command do?
The build reads Dockerfile from the current directory. The -t flag assigns the image the name rxvoice-guard.
When the build finishes, you'll see a successful build summary for the tagged image.
Did the image fail to build?
- Confirm Docker Desktop is still running.
- Confirm your Terminal is inside the rxvoice-guard directory.
- Compare the filename Dockerfile with the reference above.
Help me diagnose my Docker image build.
The image now contains the health route. Running it publishes the service to your Mac through port 8000.
The server keeps this Terminal occupied until you stop it with Control-C.
Before you run the image, what response do you think the health route returns?
- Start the service container by running this command:
docker run --rm -p 8000:8000 rxvoice-guard
What does this command do?
- The --rm flag removes the container after it stops.
- The -p flag publishes the container's port through your Mac.
- The 8000:8000 mapping connects your browser to the API.
- Visit http://127.0.0.1:8000/health in a browser tab.
You'll see {"status":"ok"}. This proves the route answers from inside the container.
- Visit http://127.0.0.1:8000/docs in the same browser.
You'll see the interactive API documentation with the GET /health operation.
Can't load the health route?
- Keep the Terminal running while you check the browser.
- Confirm the Terminal shows the server running on port 8000.
- Check that the browser address ends with /health.
Help me diagnose why my containerized health route is unavailable.
That first live slice is working. Your container now serves a documented health response.
- Switch back to the Terminal running the container.
- Press Control-C to stop the server.
The foreground process ends. The --rm flag removes the stopped container.
Your health API proves the service starts cleanly inside its pinned image. Next, you'll send synthetic audio through the transcription provider.
Transcribe Synthetic Audio
Your containerized FastAPI service can prove that it is healthy. It cannot turn your synthetic medication recording into text yet.
This step connects the service to the Deepgram Speech-to-Text API. You will expose the provider behind a typed upload route that returns the transcript plus the metadata needed for quality checks.
In this step, get ready to:
- Define the transcription response model and provider integration.
- Expose a typed audio upload route with explicit error handling.
- Record synthetic speech and submit it through the interactive API documentation.
Build the provider boundary
A response model gives every successful transcription the same structure. The provider class keeps authentication and response parsing outside your route.
- Create app/models.py inside the app folder by using your editor's file sidebar.
- Paste this response model into app/models.py:
from pydantic import BaseModel
class TranscriptionResult(BaseModel):
model: str
transcript: str
confidence: float
request_id: str
audio_duration_seconds: float
processing_time_ms: float
What does this model define?
- The Pydantic model defines the exact shape that POST /transcribe returns.
- The transcript fields describe the provider result.
- The request ID and timing fields make each response traceable.
- Save app/models.py.
- Confirm that models.py now appears inside the app folder in your editor's file sidebar.
Cannot find the model file?
Check that models.py sits inside rxvoice-guard/app. A file created beside the app folder cannot be imported as app.models.
Help me check the location and contents of my transcription response model.
- Create app/transcriber.py inside the app folder by using your editor's file sidebar.
- Paste this provider configuration into app/transcriber.py:
import os
from time import perf_counter
import httpx
from app.models import TranscriptionResult
DEEPGRAM_URL = "https://api.deepgram.com/v1/listen"
MODEL = "nova-3-pharma"
class DeepgramTranscriber:
def __init__(self, api_key: str | None = None) -> None:
self.api_key = api_key or os.getenv("DEEPGRAM_API_KEY")
if not self.api_key:
raise RuntimeError("DEEPGRAM_API_KEY is not set")
How is the provider configured?
- The endpoint sends prerecorded audio to Deepgram through a direct REST API request.
- The MODEL constant selects nova-3-pharma for pharmaceutical vocabulary.
- The constructor reads DEEPGRAM_API_KEY from the container environment.
- The missing-key check stops the request before any network call begins.
- Save app/transcriber.py.
- Rebuild the image to confirm that Docker can copy the two new modules by running:
docker build -t rxvoice-guard .
What does this build prove?
The Docker build copies the updated app folder into the service image. A successful build confirms that both new files are available to the container.
You should see the image build complete with the rxvoice-guard tag.
Does the build fail while copying files?
Confirm that your terminal is still inside the rxvoice-guard workspace. The build context must contain Dockerfile and the app folder.
Help me diagnose why my RxVoice Guard image cannot copy app/models.py or app/transcriber.py.
- Place this method inside DeepgramTranscriber directly below the __init__ method:
def transcribe(
self, audio_bytes: bytes, content_type: str | None
) -> TranscriptionResult:
headers = {"Authorization": f"Token {self.api_key}"}
if content_type:
headers["Content-Type"] = content_type
started = perf_counter()
response = httpx.post(
DEEPGRAM_URL,
params={"model": MODEL, "smart_format": "true"},
headers=headers,
content=audio_bytes,
timeout=30.0,
)
response.raise_for_status()
processing_time_ms = round((perf_counter() - started) * 1000, 2)
payload = response.json()
metadata = payload["metadata"]
alternative = payload["results"]["channels"][0]["alternatives"][0]
return TranscriptionResult(
model=MODEL,
transcript=alternative["transcript"],
confidence=alternative["confidence"],
request_id=metadata["request_id"],
audio_duration_seconds=metadata["duration"],
processing_time_ms=processing_time_ms,
)
What does the transcription method do?
- The authorization header passes the exported API key to Deepgram.
- The HTTPX request sends the uploaded bytes as the request body.
- The query parameters enable nova-3-pharma and smart formatting.
- The final model extracts the transcript and provider metadata into one typed result.
- Save app/transcriber.py.
- Rebuild the image to confirm that the completed provider module is included by running:
docker build -t rxvoice-guard .
Why rebuild the image?
The image contains a copy of your source files from build time. Rebuilding replaces that copy with the completed provider implementation.
The build should complete successfully. Your provider boundary is now packaged inside the image.
Seeing a Python syntax failure?
Check that transcribe() has the same four-space indentation as __init__(). Confirm that every line inside the method has another four spaces.
Help me compare the indentation in my DeepgramTranscriber class with the target code.
- Compare your two new files with the complete references below.
✔️ Awesome, I've got everything!
Great. Confirm that both files are saved before you connect them to the API route.
ⓧ I'd like to double check the full code
from pydantic import BaseModel
class TranscriptionResult(BaseModel):
model: str
transcript: str
confidence: float
request_id: str
audio_duration_seconds: float
processing_time_ms: float
What should this file contain?
This file contains the single response model used by the transcription route and provider class.
import os
from time import perf_counter
import httpx
from app.models import TranscriptionResult
DEEPGRAM_URL = "https://api.deepgram.com/v1/listen"
MODEL = "nova-3-pharma"
class DeepgramTranscriber:
def __init__(self, api_key: str | None = None) -> None:
self.api_key = api_key or os.getenv("DEEPGRAM_API_KEY")
if not self.api_key:
raise RuntimeError("DEEPGRAM_API_KEY is not set")
def transcribe(
self, audio_bytes: bytes, content_type: str | None
) -> TranscriptionResult:
headers = {"Authorization": f"Token {self.api_key}"}
if content_type:
headers["Content-Type"] = content_type
started = perf_counter()
response = httpx.post(
DEEPGRAM_URL,
params={"model": MODEL, "smart_format": "true"},
headers=headers,
content=audio_bytes,
timeout=30.0,
)
response.raise_for_status()
processing_time_ms = round((perf_counter() - started) * 1000, 2)
payload = response.json()
metadata = payload["metadata"]
alternative = payload["results"]["channels"][0]["alternatives"][0]
return TranscriptionResult(
model=MODEL,
transcript=alternative["transcript"],
confidence=alternative["confidence"],
request_id=metadata["request_id"],
audio_duration_seconds=metadata["duration"],
processing_time_ms=processing_time_ms,
)
What should this file contain?
This file contains the provider endpoint and model configuration. It also contains the class that sends audio bytes and parses the response.
Expose the upload route
The route needs an injectable provider so later tests can replace the live network call. A shared upload helper keeps provider failures mapped to consistent HTTP responses.
- Return to app/main.py in your editor.
- Replace the existing FastAPI import with this import group:
from typing import Annotated
import httpx
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile
from app.models import TranscriptionResult
from app.transcriber import DeepgramTranscriber
Why are these imports needed?
- The upload types describe the multipart audio input.
- The dependency types let FastAPI supply a transcriber to the route.
- The exception types turn local and provider failures into HTTP responses.
- Save app/main.py.
- Add these functions directly below the existing app = FastAPI(title="RxVoice Guard", version="1.0.0") line:
def get_transcriber() -> DeepgramTranscriber:
return DeepgramTranscriber()
async def transcribe_upload(
audio: UploadFile, transcriber: DeepgramTranscriber
) -> TranscriptionResult:
audio_bytes = await audio.read()
if not audio_bytes:
raise HTTPException(status_code=400, detail="The audio file is empty")
try:
return transcriber.transcribe(audio_bytes, audio.content_type)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except httpx.HTTPError as exc:
raise HTTPException(
status_code=502, detail="The transcription provider request failed"
) from exc
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise HTTPException(
status_code=502, detail="The transcription provider returned an invalid response"
) from exc
How does the upload helper protect the route?
- The get_transcriber() dependency creates the live provider class.
- The helper rejects an empty upload with status 400.
- A missing API configuration becomes status 503.
- Provider and response failures become status 502.
- Save app/main.py.
- Rebuild the image to check that the provider imports and upload helper load by running:
docker build -t rxvoice-guard .
What does this checkpoint confirm?
The rebuild packages the response model and provider helper together. A successful result confirms that Docker can assemble the updated application source.
You should see the build finish successfully with the existing image tag.
Does an import fail during the build?
Check that the filenames are exactly app/models.py and app/transcriber.py. Confirm that each import matches the class name defined in that file.
Help me trace an import failure between app/main.py, app/models.py, and app/transcriber.py.
- Add this route below the existing health() function in app/main.py:
@app.post("/transcribe", response_model=TranscriptionResult)
async def transcribe_audio(
audio: Annotated[
UploadFile,
File(description="Synthetic pharmaceutical audio only"),
],
transcriber: Annotated[DeepgramTranscriber, Depends(get_transcriber)],
) -> TranscriptionResult:
return await transcribe_upload(audio, transcriber)
What does the route add?
- The POST /transcribe operation accepts one multipart file named audio.
- The response model documents each field in the interactive API documentation.
- Dependency injection keeps the provider replaceable without changing the route.
- Save app/main.py.
- Compare the completed route file with the full reference below.
✔️ Awesome, I've got everything!
Your upload route is ready. Double-check that app/main.py is saved before you rebuild the service.
ⓧ I'd like to double check the full code
from typing import Annotated
import httpx
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile
from app.models import TranscriptionResult
from app.transcriber import DeepgramTranscriber
app = FastAPI(title="RxVoice Guard", version="1.0.0")
def get_transcriber() -> DeepgramTranscriber:
return DeepgramTranscriber()
async def transcribe_upload(
audio: UploadFile, transcriber: DeepgramTranscriber
) -> TranscriptionResult:
audio_bytes = await audio.read()
if not audio_bytes:
raise HTTPException(status_code=400, detail="The audio file is empty")
try:
return transcriber.transcribe(audio_bytes, audio.content_type)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except httpx.HTTPError as exc:
raise HTTPException(
status_code=502, detail="The transcription provider request failed"
) from exc
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise HTTPException(
status_code=502, detail="The transcription provider returned an invalid response"
) from exc
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/transcribe", response_model=TranscriptionResult)
async def transcribe_audio(
audio: Annotated[
UploadFile,
File(description="Synthetic pharmaceutical audio only"),
],
transcriber: Annotated[DeepgramTranscriber, Depends(get_transcriber)],
) -> TranscriptionResult:
return await transcribe_upload(audio, transcriber)
What should the completed file contain?
The completed file preserves the health route. It adds the dependency and upload helper before exposing the transcription operation.
Record and transcribe synthetic audio
A live transcription needs a local audio file with safe test content. Voice Memos gives you a short recording that can be uploaded without using real patient information.
- Press Cmd+Space to open Spotlight on your Mac.
- Type Voice Memos into Spotlight.
- Press Enter to open Voice Memos.
- Begin a new recording by selecting the record control in the lower-left corner.
- Read this sentence aloud: “The synthetic medication list contains metoprolol succinate, lisinopril, and atorvastatin.”
- Select Done to save the recording.
- Drag the recording into a Finder window for your Desktop.
Keep the recording synthetic
Use only the provided medication sentence. Do not include names or any real patient information in this learning project.
- Switch back to the terminal in the rxvoice-guard workspace.
- Rebuild the image with the completed route by running:
docker build -t rxvoice-guard .
Why build one more time?
This build captures the finished app/main.py route alongside the response model and provider class. The resulting image contains the complete transcription service.
You should see the image rebuild successfully.
- Start the service with the exported API key by running:
docker run --rm -p 8000:8000 -e DEEPGRAM_API_KEY rxvoice-guard
What does this command pass into the service?
- The -p 8000:8000 option publishes the API on your Mac.
- The -e DEEPGRAM_API_KEY option passes the exported key into the container without placing it in a project file.
- The --rm option removes the container after it stops.
The server keeps this terminal occupied while it runs. Leave it running during the upload check.
Does the container stop immediately?
Confirm that DEEPGRAM_API_KEY is still exported in this Terminal session. If you switched Terminal windows, return to the session where you exported it.
Help me diagnose why my RxVoice Guard container stops or cannot read DEEPGRAM_API_KEY.
Before you submit the recording, do you think a transcript alone can prove that every critical drug name survived correctly?
- Open http://127.0.0.1:8000/docs in your browser.
- Expand the POST /transcribe operation.
- Enable the interactive inputs for the operation.
- Select your synthetic recording in the audio field.
- Submit the request.
You should see a successful JSON response containing model, transcript, confidence, request_id, audio_duration_seconds, and processing_time_ms.
That is the provider boundary working. Your synthetic recording now travels through the container and returns a typed Nova-3 Pharma result.
Did the upload return an error?
- Reselect the recording if the response has status 400. An empty upload cannot be transcribed.
- Return to the Terminal session with the exported key if the response has status 503.
- Check your Deepgram account access and network connection if the response has status 502.
- Help me diagnose my failed synthetic audio transcription request.
The route returns useful text, but you still have to inspect every critical drug name manually. That shortfall is the evidence this first transcription version exposes.
Your service can now transcribe synthetic pharmaceutical audio with operational metadata. Next, you will turn that raw transcript into deterministic QA evidence.
Evaluate Critical Terms
Your /transcribe route now turns a synthetic recording into structured text. You can inspect confidence plus request metadata alongside the transcript.
A transcript can still hide a missing drug name. This step adds deterministic evaluation for every required phrase.
Exact normalized matching gives your API a repeatable pass criterion. The response shows the evidence behind that result.
In this step, get ready to:
- Define typed models for term checks plus evaluation results.
- Normalize transcript text before checking exact phrase boundaries.
- Expose an /evaluate route that accepts audio plus a JSON term list.
Define the evaluation evidence
The evaluation needs a stable shape before the matching logic can produce results. Each term receives its own check while the overall response collects coverage plus transcription evidence.
- Return to app/models.py in the editor from earlier.
- Add these response models below TranscriptionResult:
class TermCheck(BaseModel):
term: str
matched: bool
class EvaluationResponse(BaseModel):
evaluation_id: str
passed: bool
coverage: float
matched_terms: list[str]
missing_terms: list[str]
checks: list[TermCheck]
transcription: TranscriptionResult
What do these models represent?
- Pydantic uses TermCheck to record the original term plus its exact-match result.
- EvaluationResponse combines the overall decision with the evidence needed to audit it.
- transcription keeps the provider result attached to the evaluation report.
- Save app/models.py.
- Check the file with your editor's Python diagnostics.
You should see the new classes without a syntax warning.
Seeing a model error?
Check that both new classes align with class TranscriptionResult. Confirm that BaseModel remains imported at the top.
Help me compare my response models with the required structure.
Speech transcripts can vary in letter case plus punctuation. Unicode normalization gives equivalent text a consistent representation before matching begins.
- Select the app folder in your editor's file sidebar.
- Create evaluator.py inside the app folder.
- Build the evaluator by pasting this code into app/evaluator.py:
import re
import unicodedata
from app.models import TermCheck
def normalize_text(value: str) -> str:
normalized = unicodedata.normalize("NFKC", value).casefold()
normalized = re.sub(r"[^\w]+", " ", normalized)
return " ".join(normalized.split())
def evaluate_terms(
transcript: str, expected_terms: list[str]
) -> tuple[list[TermCheck], float]:
normalized_transcript = normalize_text(transcript)
checks: list[TermCheck] = []
for term in expected_terms:
normalized_term = normalize_text(term)
pattern = rf"(?<!\w){re.escape(normalized_term)}(?!\w)"
checks.append(
TermCheck(
term=term,
matched=bool(re.search(pattern, normalized_transcript)),
)
)
matched_count = sum(check.matched for check in checks)
coverage = round(matched_count / len(checks), 3)
return checks, coverage
How does exact matching work?
- normalize_text() standardizes Unicode plus letter case.
- re.sub() turns punctuation into spaces.
- split() collapses repeated whitespace before matching.
- evaluate_terms() uses word boundaries so a required phrase must appear as a complete phrase.
- coverage divides the number of matched checks by the number of expected terms.
- Save app/evaluator.py.
- Switch back to the Terminal running the service.
- Stop the current container by pressing Control-C.
- Rebuild the image with the evaluator files by running:
docker build -t rxvoice-guard .
Why rebuild the image?
The image contains a copied snapshot of the app folder. Rebuilding places your new models plus evaluator inside that snapshot.
The Docker build should complete before returning control to your Terminal.
Did the image fail to build?
Check the indentation in app/evaluator.py. Confirm that every opening parenthesis has a matching closing parenthesis.
Help me diagnose my evaluator build failure.
- Start the rebuilt service with your exported API key by running:
docker run --rm -p 8000:8000 -e DEEPGRAM_API_KEY rxvoice-guard
What does this run do?
The command starts the rebuilt image on port 8000. It passes the exported API key into the temporary container.
- Return to http://127.0.0.1:8000/health in your browser.
You should still see {"status":"ok"}. Good progress. The existing transcription API still starts with the new evaluation files included.
✔️ Awesome, I've got everything!
Great. Save app/models.py before moving on.
ⓧ I'd like to double check the full code
from pydantic import BaseModel
class TranscriptionResult(BaseModel):
model: str
transcript: str
confidence: float
request_id: str
audio_duration_seconds: float
processing_time_ms: float
class TermCheck(BaseModel):
term: str
matched: bool
class EvaluationResponse(BaseModel):
evaluation_id: str
passed: bool
coverage: float
matched_terms: list[str]
missing_terms: list[str]
checks: list[TermCheck]
transcription: TranscriptionResult
What should match?
This complete file contains the original transcription model plus both evaluation models. Compare every field name with your saved app/models.py.
✔️ Awesome, I've got everything!
Great. Save app/evaluator.py before continuing.
ⓧ I'd like to double check the full code
import re
import unicodedata
from app.models import TermCheck
def normalize_text(value: str) -> str:
normalized = unicodedata.normalize("NFKC", value).casefold()
normalized = re.sub(r"[^\w]+", " ", normalized)
return " ".join(normalized.split())
def evaluate_terms(
transcript: str, expected_terms: list[str]
) -> tuple[list[TermCheck], float]:
normalized_transcript = normalize_text(transcript)
checks: list[TermCheck] = []
for term in expected_terms:
normalized_term = normalize_text(term)
pattern = rf"(?<!\w){re.escape(normalized_term)}(?!\w)"
checks.append(
TermCheck(
term=term,
matched=bool(re.search(pattern, normalized_transcript)),
)
)
matched_count = sum(check.matched for check in checks)
coverage = round(matched_count / len(checks), 3)
return checks, coverage
What should match?
This complete file contains text normalization plus boundary-aware phrase evaluation. Compare its imports plus indentation with your saved app/evaluator.py.
Validate the expected term input
A multipart form sends expected_terms as text. The API must parse that text before it can reject malformed lists or empty terms.
- Return to the import section at the top of app/main.py.
- Locate this current import group:
from typing import Annotated
import httpx
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile
from app.models import TranscriptionResult
from app.transcriber import DeepgramTranscriber
What does the current group support?
These imports support the existing health plus transcription routes. The evaluation route needs JSON parsing plus form handling.
- Replace that import group with this expanded version:
import json
from typing import Annotated
from uuid import uuid4
import httpx
from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
from app.evaluator import evaluate_terms
from app.models import EvaluationResponse, TranscriptionResult
from app.transcriber import DeepgramTranscriber
Why add these imports?
- json converts the form field into a Python list.
- uuid4 gives every evaluation report its own identifier.
- Form declares the text field beside the uploaded file.
- evaluate_terms connects the route to your deterministic matcher.
- Find get_transcriber() near the top of app/main.py.
- Add this parser directly below get_transcriber():
def parse_expected_terms(raw_terms: str) -> list[str]:
try:
parsed = json.loads(raw_terms)
except json.JSONDecodeError as exc:
raise ValueError("expected_terms must be a JSON array of strings") from exc
if not isinstance(parsed, list) or not parsed:
raise ValueError("expected_terms must be a non-empty JSON array")
if not all(isinstance(item, str) and item.strip() for item in parsed):
raise ValueError("every expected term must be a non-empty string")
return list(dict.fromkeys(item.strip() for item in parsed))
What does the parser enforce?
- json.loads() parses the submitted text.
- The first condition rejects values that are not non-empty lists.
- The second condition rejects blank strings plus non-string items.
- dict.fromkeys() removes duplicate terms while keeping their original order.
- Save app/main.py.
- Switch back to the Terminal running the service.
- Stop the current container by pressing Control-C.
- Rebuild the image with the parser by running:
docker build -t rxvoice-guard .
Why rebuild again?
This build checks that app.main can import the evaluator plus its new response model. It also copies the parser into the image.
- Start the updated service by running:
docker run --rm -p 8000:8000 -e DEEPGRAM_API_KEY rxvoice-guard
What does this verify?
Starting the service proves that the expanded imports resolve inside the container. The API key remains available for the final live evaluation.
- Refresh http://127.0.0.1:8000/health in your browser.
You should see {"status":"ok"} again. Your parser now loads without disrupting the existing API.
Did the service stop at startup?
Compare the expanded imports with the code above. A misspelled evaluator name or response model prevents app.main from loading.
Help me fix my evaluation imports or parser.
Add and verify the evaluation route
The new route composes three pieces you already have. It validates the term list before reusing the transcription helper plus exact matcher.
Start with the route contract so FastAPI can document both multipart fields. This first runnable slice also maps invalid term lists to a 400 response.
- Return to the bottom of app/main.py.
- Add this route contract below transcribe_audio():
@app.post("/evaluate", response_model=EvaluationResponse)
async def evaluate_audio(
audio: Annotated[
UploadFile,
File(description="Synthetic pharmaceutical audio only"),
],
expected_terms: Annotated[
str,
Form(
description='JSON array such as ["metoprolol succinate", "lisinopril"]'
),
],
transcriber: Annotated[DeepgramTranscriber, Depends(get_transcriber)],
) -> EvaluationResponse:
try:
terms = parse_expected_terms(expected_terms)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
What does the route contract define?
- audio accepts the same synthetic upload used by /transcribe.
- expected_terms accepts the JSON array as a multipart form field.
- Depends(get_transcriber) preserves the injectable provider boundary.
- HTTPException converts parser failures into explicit client errors.
- Save app/main.py.
- Switch back to the Terminal running the service.
- Stop the current container by pressing Control-C.
- Rebuild the route contract by running:
docker build -t rxvoice-guard .
What does this build check?
The build copies the route contract into the image. The next container start confirms that its multipart annotations load correctly.
- Start the route contract by running:
docker run --rm -p 8000:8000 -e DEEPGRAM_API_KEY rxvoice-guard
What does this start prove?
The running application proves that FastAPI accepted the route declaration. Its interactive documentation can now display the audio plus term inputs.
- Refresh http://127.0.0.1:8000/docs in your browser.
- Locate the new POST /evaluate operation.
You should see fields for audio plus expected_terms. This proves the multipart contract is registered.
Missing the evaluation operation?
Confirm that the route sits below transcribe_audio(). Check that the decorator uses /evaluate.
Help me find why POST /evaluate is missing from my API documentation.
The final response block runs transcription once. It converts every check into matched plus missing lists before assigning the overall pass result.
- Return to evaluate_audio() in app/main.py.
- Add this response block directly after the except ValueError block:
transcription = await transcribe_upload(audio, transcriber)
checks, coverage = evaluate_terms(transcription.transcript, terms)
matched_terms = [check.term for check in checks if check.matched]
missing_terms = [check.term for check in checks if not check.matched]
return EvaluationResponse(
evaluation_id=str(uuid4()),
passed=not missing_terms,
coverage=coverage,
matched_terms=matched_terms,
missing_terms=missing_terms,
checks=checks,
transcription=transcription,
)
How is the report assembled?
- transcribe_upload() reuses the existing provider call plus its error handling.
- evaluate_terms() produces one check per expected term plus the coverage score.
- matched_terms contains the terms whose exact normalized phrases were found.
- missing_terms contains the terms that failed exact matching.
- passed becomes true only when the missing list is empty.
- Save app/main.py.
- Switch back to the Terminal running the service.
- Stop the current container by pressing Control-C.
- Rebuild the complete evaluation route by running:
docker build -t rxvoice-guard .
Why is this the final rebuild?
This build packages the complete parser plus route logic. The resulting image can return evaluation evidence from a live request.
- Start the completed API by running:
docker run --rm -p 8000:8000 -e DEEPGRAM_API_KEY rxvoice-guard
What is running now?
The container now serves health checks plus transcription plus evaluation. It keeps the evaluation result in memory.
This check sends one synthetic recording to Deepgram. The live call consumes account credit for this tutorial request.
Before you submit the request, do you expect the transcript to preserve all three pharmaceutical terms exactly?
- Return to http://127.0.0.1:8000/docs in your browser.
- Expand the POST /evaluate operation.
- Enable editing for the operation.
- Select your synthetic Voice Memos recording with the audio picker.
- Enter ["metoprolol succinate", "lisinopril", "atorvastatin"] in the expected_terms field.
- Submit the evaluation request.
You should see coverage plus matched_terms plus missing_terms plus checks plus passed in the response.
An exact transcript produces full coverage plus an empty missing list. Any altered pharmaceutical phrase appears as explicit missing-term evidence.
That's the key upgrade. Your API now explains whether every required phrase survived the speech pipeline.
Did the evaluation request fail?
Confirm that expected_terms contains a JSON array with double-quoted strings. Confirm that the uploaded recording contains synthetic information only.
If the provider request fails, check that DEEPGRAM_API_KEY remains exported in the Terminal that started the container.
Help me debug my POST /evaluate request without exposing my API key.
✔️ Awesome, I've got everything!
Great. Save app/main.py while the completed container keeps running.
ⓧ I'd like to double check the full code
import json
from typing import Annotated
from uuid import uuid4
import httpx
from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
from app.evaluator import evaluate_terms
from app.models import EvaluationResponse, TranscriptionResult
from app.transcriber import DeepgramTranscriber
app = FastAPI(title="RxVoice Guard", version="1.0.0")
def get_transcriber() -> DeepgramTranscriber:
return DeepgramTranscriber()
def parse_expected_terms(raw_terms: str) -> list[str]:
try:
parsed = json.loads(raw_terms)
except json.JSONDecodeError as exc:
raise ValueError("expected_terms must be a JSON array of strings") from exc
if not isinstance(parsed, list) or not parsed:
raise ValueError("expected_terms must be a non-empty JSON array")
if not all(isinstance(item, str) and item.strip() for item in parsed):
raise ValueError("every expected term must be a non-empty string")
return list(dict.fromkeys(item.strip() for item in parsed))
async def transcribe_upload(
audio: UploadFile, transcriber: DeepgramTranscriber
) -> TranscriptionResult:
audio_bytes = await audio.read()
if not audio_bytes:
raise HTTPException(status_code=400, detail="The audio file is empty")
try:
return transcriber.transcribe(audio_bytes, audio.content_type)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except httpx.HTTPError as exc:
raise HTTPException(
status_code=502, detail="The transcription provider request failed"
) from exc
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise HTTPException(
status_code=502, detail="The transcription provider returned an invalid response"
) from exc
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/transcribe", response_model=TranscriptionResult)
async def transcribe_audio(
audio: Annotated[
UploadFile,
File(description="Synthetic pharmaceutical audio only"),
],
transcriber: Annotated[DeepgramTranscriber, Depends(get_transcriber)],
) -> TranscriptionResult:
return await transcribe_upload(audio, transcriber)
@app.post("/evaluate", response_model=EvaluationResponse)
async def evaluate_audio(
audio: Annotated[
UploadFile,
File(description="Synthetic pharmaceutical audio only"),
],
expected_terms: Annotated[
str,
Form(
description='JSON array such as ["metoprolol succinate", "lisinopril"]'
),
],
transcriber: Annotated[DeepgramTranscriber, Depends(get_transcriber)],
) -> EvaluationResponse:
try:
terms = parse_expected_terms(expected_terms)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
transcription = await transcribe_upload(audio, transcriber)
checks, coverage = evaluate_terms(transcription.transcript, terms)
matched_terms = [check.term for check in checks if check.matched]
missing_terms = [check.term for check in checks if not check.matched]
return EvaluationResponse(
evaluation_id=str(uuid4()),
passed=not missing_terms,
coverage=coverage,
matched_terms=matched_terms,
missing_terms=missing_terms,
checks=checks,
transcription=transcription,
)
What should match?
This complete file preserves the existing health plus transcription behavior. It adds the parser plus the finished /evaluate route.
Your service now turns a transcript into deterministic QA evidence. Next, you will prove that behavior with mocked provider tests inside the container.
Test and Package the Service
Your evaluation route now turns a transcript into objective term evidence. The next challenge is proving that behavior repeatedly.
Live Deepgram calls depend on the network. They also spend account credit.
You'll use FastAPI dependency injection with pytest. The rebuilt Docker image carries the tests beside the service.
In this step, get ready to:
- Replace the live transcription provider with a predictable fake during tests.
- Cover the service's three core behaviors with mocked checks.
- Verify both execution modes inside the final image.
Package the test folder
A container can only run files copied into its image. The final image needs the test suite beside the application code.
- Return to the Terminal session running the service.
- Press Control-C to stop the foreground container.
- Switch back to Dockerfile in your editor.
- Find this line:
COPY app ./app
What does this line do?
This instruction copies the app directory into the image. The current image has no test files to discover.
- Add the test copy instruction directly below the existing line.
- Compare your edit with this pair:
COPY app ./app
COPY tests ./tests
Why copy both folders?
The first instruction packages the service. The second instruction gives pytest access to tests/test_api.py inside the same image.
- Save Dockerfile.
- Rebuild the image with the new test directory by running this command:
docker build -t rxvoice-guard .
What does this command do?
Docker rebuilds the rxvoice-guard image from the updated instructions. The build now includes the existing tests directory.
The build should complete successfully. Docker can reuse unchanged dependency layers from the earlier build.
Build not completing?
Confirm Docker Desktop is running. Check that COPY tests ./tests sits directly below COPY app ./app.
Help me diagnose this Docker build failure.
✔️ Awesome, I've got everything!
Your image can now package application code plus containerized tests.
ⓧ I'd like to double check the full code
FROM python:3.11.18-slim
WORKDIR /app
COPY requirements.txt ./
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY tests ./tests
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
How is this file organized?
The image installs the pinned dependencies before copying the source folders. Its default command still starts the FastAPI service.
Create mocked provider tests
A fake transcriber returns a fixed response for every test request. This keeps the suite predictable while avoiding network calls and Deepgram credit use.
- Use your editor's new-file control inside the tests folder to create test_api.py.
- Paste this provider setup at the top of tests/test_api.py:
import json
from fastapi.testclient import TestClient
from app.main import app, get_transcriber
from app.models import TranscriptionResult
class FakeTranscriber:
def transcribe(
self, audio_bytes: bytes, content_type: str | None
) -> TranscriptionResult:
return TranscriptionResult(
model="nova-3-pharma",
transcript=(
"The synthetic medication list contains metoprolol succinate "
"and lisinopril."
),
confidence=0.98,
request_id="test-request-id",
audio_duration_seconds=4.2,
processing_time_ms=12.5,
)
What does this code do?
- TestClient sends requests directly to the FastAPI application during the suite.
- FakeTranscriber follows the same transcribe() interface as the real provider class.
- Its fixed transcript includes two expected medication terms. The transcript intentionally omits atorvastatin for the missing-term test.
- Save tests/test_api.py.
- Confirm the editor shows FakeTranscriber inside the new file.
Seeing import warnings?
Check that test_api.py is inside the existing tests folder. Keep each import aligned at the left edge of the file.
Help me fix the imports in my mocked API test.
- Append the dependency override plus the first test below FakeTranscriber by copying this code:
def override_transcriber() -> FakeTranscriber:
return FakeTranscriber()
app.dependency_overrides[get_transcriber] = override_transcriber
client = TestClient(app)
def test_health() -> None:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
How does the override work?
- The dependency override replaces get_transcriber whenever a tested route asks for a transcriber.
- The shared client sends requests to the application in memory.
- The health test checks both the HTTP status and the exact JSON response.
- Save tests/test_api.py.
- Rebuild the image with the first test by running this command:
docker build -t rxvoice-guard .
Why rebuild again?
The previous image contains the empty test package from the start of this step. Rebuilding copies the new tests/test_api.py into the image.
Before you run the suite, how many tests do you expect pytest to discover at this point?
- Run the current containerized test suite with this command:
docker run --rm rxvoice-guard pytest -q
What does this command prove?
The container starts pytest instead of the image's default web server command. The temporary container is removed after the test process exits.
You should see one successful test. That first green check proves the image can discover and run the suite.
Test not discovered?
Confirm the file is named test_api.py. Confirm the function is named test_health.
Help me find why pytest is not discovering my health test.
The health check proves the test harness works. The next checks exercise both provider-backed routes with the same fake response.
- Append the transcription test below test_health by copying this code:
def test_transcribe_uses_fake_provider() -> None:
response = client.post(
"/transcribe",
files={"audio": ("synthetic.m4a", b"fake-audio", "audio/mp4")},
)
assert response.status_code == 200
assert response.json()["request_id"] == "test-request-id"
assert "metoprolol succinate" in response.json()["transcript"]
What does this test prove?
The test submits fake audio through the multipart upload route. The fixed request ID and transcript phrase prove that the dependency override supplied the response.
- Save tests/test_api.py.
- Confirm the editor shows test_transcribe_uses_fake_provider below the health test.
Transcription test looks malformed?
Keep the files dictionary inside the client.post() call. Check the indentation of all three assertions.
Help me debug the fake-provider transcription test.
- Append the missing-term evaluation test below the transcription test by copying this code:
def test_evaluate_reports_a_missing_critical_term() -> None:
response = client.post(
"/evaluate",
files={"audio": ("synthetic.m4a", b"fake-audio", "audio/mp4")},
data={
"expected_terms": json.dumps(
["metoprolol succinate", "lisinopril", "atorvastatin"]
)
},
)
body = response.json()
assert response.status_code == 200
assert body["passed"] is False
assert body["coverage"] == 0.667
assert body["missing_terms"] == ["atorvastatin"]
What does this evaluation test prove?
- The form field sends the expected terms as JSON through the same multipart request as the audio.
- The fake transcript matches two of the three required terms.
- The assertions preserve the missing-term evidence through the API response.
- Save tests/test_api.py.
- Confirm the file ends with the missing_terms assertion for atorvastatin.
Evaluation test showing the wrong coverage?
Confirm the fake transcript contains metoprolol succinate plus lisinopril. Confirm its transcript omits atorvastatin.
Help me diagnose the missing-term evaluation assertions.
✔️ Awesome, I've got everything!
Your test file now covers health, fake-provider transcription, and missing-term evaluation.
ⓧ I'd like to double check the full code
import json
from fastapi.testclient import TestClient
from app.main import app, get_transcriber
from app.models import TranscriptionResult
class FakeTranscriber:
def transcribe(
self, audio_bytes: bytes, content_type: str | None
) -> TranscriptionResult:
return TranscriptionResult(
model="nova-3-pharma",
transcript=(
"The synthetic medication list contains metoprolol succinate "
"and lisinopril."
),
confidence=0.98,
request_id="test-request-id",
audio_duration_seconds=4.2,
processing_time_ms=12.5,
)
def override_transcriber() -> FakeTranscriber:
return FakeTranscriber()
app.dependency_overrides[get_transcriber] = override_transcriber
client = TestClient(app)
def test_health() -> None:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_transcribe_uses_fake_provider() -> None:
response = client.post(
"/transcribe",
files={"audio": ("synthetic.m4a", b"fake-audio", "audio/mp4")},
)
assert response.status_code == 200
assert response.json()["request_id"] == "test-request-id"
assert "metoprolol succinate" in response.json()["transcript"]
def test_evaluate_reports_a_missing_critical_term() -> None:
response = client.post(
"/evaluate",
files={"audio": ("synthetic.m4a", b"fake-audio", "audio/mp4")},
data={
"expected_terms": json.dumps(
["metoprolol succinate", "lisinopril", "atorvastatin"]
)
},
)
body = response.json()
assert response.status_code == 200
assert body["passed"] is False
assert body["coverage"] == 0.667
assert body["missing_terms"] == ["atorvastatin"]
How is this test file organized?
The file defines one deterministic provider replacement. Three tests then exercise the public API through a shared TestClient.
Verify both container modes
The completed image has two jobs to prove. It must pass the isolated suite before it serves the live API again.
- Rebuild the image with the complete test file by running this command:
docker build -t rxvoice-guard .
What enters the final image?
This build packages the unchanged service plus the completed test suite. The pinned dependency set remains the same.
Before you run the completed suite, do you expect any test to contact Deepgram?
- Run all three mocked tests inside the rebuilt image with this command:
docker run --rm rxvoice-guard pytest -q
What should this prove?
The suite checks health, transcription, and evaluation through the application's routes. The dependency override keeps every result inside the container.
You should see 3 passed. You now have repeatable evidence without network access or API spend.
One of the three tests failing?
Read the failing test name first. Compare only that function with the full-file reference above.
Rebuild the image after every saved test change. An older image keeps the previous copy of tests/test_api.py.
Help me debug the failing containerized test.
The tests replace the provider only inside the test process. A normal container run still creates the real transcriber through get_transcriber.
The next command occupies the Terminal until you stop the service with Control-C. Keep that Terminal session running during the browser checks.
- Start the final service container with the exported API key by running this command:
docker run --rm --name rxvoice-guard -p 8000:8000 -e DEEPGRAM_API_KEY rxvoice-guard
How does this run differ?
The image uses its default Uvicorn command because no test command replaces it. Docker forwards the existing DEEPGRAM_API_KEY value into the live service container.
Service container not starting?
Confirm the earlier container stopped before this run. Confirm Docker Desktop remains available.
Help me diagnose why the final rxvoice-guard service container will not start.
Before you check the service, do you expect the test-only dependency override to change the live health response?
- Open http://127.0.0.1:8000/health in your browser.
You should see {"status":"ok"}. This confirms the packaged service still starts normally.
- Open http://127.0.0.1:8000/docs in your browser.
You should see GET /health, POST /transcribe, and POST /evaluate in the interactive documentation. Your final image now serves every route and carries its own repeatable test evidence.
Secret mission
Diagnose Near-Miss Drug Names
A missing-term result identifies a failed pharmaceutical phrase, but the transcript still needs manual inspection to reveal a likely spelling error. Add near-miss diagnostics that surface the closest candidate while preserving exact matching as the only pass condition.
Clean Up Your Resources
Clean Up Your Resources
Every live transcription consumes Deepgram account credit. Decide whether to keep your resources available, pause your work, or delete everything.
Cost warning
Tutorial usage should fit inside Deepgram's $200 starting credit. Each live transcription consumes part of that credit.
Mocked tests do not call Deepgram. Use them whenever you only need to verify application behavior.
Resources you used:
- A local source directory named rxvoice-guard.
- A local Docker image named rxvoice-guard.
- A Deepgram project API key used for live transcription requests.
- A synthetic recording exported from Apple Voice Memos.
Keep everything running
No action is needed. Choose this if you plan to demonstrate the API or continue improving its evaluation logic.
- Keep the rxvoice-guard source directory for future development.
- Keep the local rxvoice-guard image for repeatable API runs.
- Keep the Deepgram project API key private for future live transcription requests.
- Keep the synthetic recording for demonstrations that contain no real patient information.
Pause - I'll come back to this later
Shut down running processes to free local resources while preserving everything needed to continue later.
- Stop the foreground API container by pressing Control-C in its Terminal if it is still running.
- Keep the rxvoice-guard source directory for your next session.
- Keep the local rxvoice-guard image to avoid rebuilding it.
- Close the current Terminal session to clear its temporary DEEPGRAM_API_KEY export.
- Keep the Deepgram project API key private for your next live run.
- Keep the synthetic recording for your next evaluation.
Delete - I don't want to use this again
Remove all project resources when you no longer plan to use the API. Deletion is final, but you can rebuild the project from this walkthrough.
- Stop any running foreground container by pressing Control-C in its Terminal.
- Remove the local rxvoice-guard image by running this command:
docker image rm rxvoice-guard
What does this command do?
This removes the local Docker image named rxvoice-guard. Your source directory remains available until you delete it separately.
- Return to Docker Desktop to confirm the rxvoice-guard image is absent.
Image still present?
- Stop any running container that uses the rxvoice-guard image.
- Retry the image removal command.
Help me remove the local rxvoice-guard Docker image.
- Use Finder to locate the rxvoice-guard directory.
- Delete the rxvoice-guard directory from Finder.
- Confirm the rxvoice-guard directory no longer appears in Finder.
- Use Finder to locate the synthetic recording exported from Apple Voice Memos.
- Delete the synthetic recording from Finder.
- Confirm the synthetic recording no longer appears in Finder.
What revoking the key changes
Revoking the project key makes its copied value unusable. This prevents future live transcription requests from using that credential.
- Return to the generated project in the Deepgram Console from earlier.
- Select Settings.
- Select API Keys.
- Locate the API key created for this project.
- Select the key's trash can icon.
- Confirm the project key no longer appears in the list.
- Close the Terminal session that contains the exported DEEPGRAM_API_KEY.
Cleanup is complete. Your source directory, Docker image, project key, and synthetic recording are no longer available.
Nice Work!
Nice Work!
You did it! Your containerized FastAPI service now turns synthetic pharmaceutical recordings into auditable voice QA evidence.
You built a backend that separates provider output from deterministic acceptance checks. You also proved its behavior without relying on live network calls.
You learned how to:
- Integrated the Deepgram Speech-to-Text API with Nova-3 Pharma to return typed transcripts with confidence, request metadata, audio duration, and processing time.
- Built deterministic critical-term evaluation that reports coverage, matched terms, missing terms, and a strict pass or fail result.
- Used dependency injection to replace Deepgram with a fake provider in mocked tests that pass inside the pinned Docker image.
- Secret Mission: Added near-miss diagnostics that identify likely spelling mistakes while preserving exact matching as the only pass condition.
Ready to quiz yourself?