Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ jobs:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}

persist-credentials: true

- name: Set up Python
uses: actions/setup-python@v5
with:
Expand All @@ -69,11 +70,21 @@ jobs:
run: |
git diff --exit-code || echo "formatted=true" >> $GITHUB_OUTPUT

- name: Import GPG key
if: steps.verify_diff.outputs.formatted == 'true' && github.event_name == 'push'
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
git_user_signingkey: true
git_commit_gpgsign: true
git_config_global: true

- name: Commit and push if changed
if: steps.verify_diff.outputs.formatted == 'true' && github.event_name == 'push'
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git config --global user.email 'noreply@github.com'
git add -A
git commit -m "style: auto-format Python code with black and isort"
git commit -S -m "style: auto-format Python code with black and isort"
git push
13 changes: 12 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,23 @@ OLLAMA_SERVER_PORT=YOUR_PORT
LM_STUDIO_SERVER_URL=YOUR_LM_STUDIO_SERVER_URL_HERE
LM_STUDIO_SERVER_PORT=YOUR_PORT

# Change to either 'ollama' or 'lm_studio' depending on which LLM server you want to use
# Change to either 'ollama', 'lm_studio', or 'cloud' depending on which LLM server you want to use
LLM_PROVIDER=YOUR_LLM_PROVIDER_HERE

# Change to the model name you want to use from your LLM server (ensure the model is already installed on the server)
LLM_MODEL_NAME=YOUR_LLM_MODEL_NAME_HERE

# Cloud API settings (only needed if using 'cloud' provider for LLM or transcription)
# Use OpenAI API or any OpenAI-compatible API (e.g., Together AI, OpenRouter, Azure OpenAI, etc.)
CLOUD_API_BASE_URL=https://api.openai.com/v1
CLOUD_API_KEY=YOUR_API_KEY_HERE

# Transcription settings
# Change to either 'local' or 'cloud' - local uses Whisper models, cloud uses OpenAI-compatible API
TRANSCRIPTION_PROVIDER=local
# Model name to use for cloud transcription (whisper-large-v3 is default, OpenAI uses whisper-1)
TRANSCRIPTION_MODEL_NAME=whisper-large-v3

# Authorization header for API access
# To generate a new secure token, run: openssl rand -hex 32
API_AUTH_TOKEN=YOUR_AUTH_TOKEN_HERE
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies = [
"faster-whisper>=1.2.1",
"mlx-whisper>=0.4.3",
"python-dotenv>=1.2.1",
"requests>=2.32.0",
"torch>=2.8.0",
"uvicorn>=0.39.0",
]
70 changes: 53 additions & 17 deletions backend/services/text_cleaner.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,42 @@

llm_provider = os.getenv("LLM_PROVIDER")
llm_model_name = os.getenv("LLM_MODEL_NAME")
cloud_api_base_url = os.getenv("CLOUD_API_BASE_URL")
cloud_api_key = os.getenv("CLOUD_API_KEY")

# Define the system prompt for disfluency removal, not recommended to modify
system_prompt = (
"You are a text normalization system for spoken transcripts. "
"Your task is disfluency removal only. "
"Remove filler words (e.g., 'uh', 'um', 'like'), false starts, repetitions, "
"and self-corrections while preserving the original meaning, intent, and tone. "
"Do not summarize or paraphrase unless required to remove a disfluency. "
"Do not add or infer new information. "
"Preserve punctuation, capitalization, technical terms, acronyms, "
"and domain-specific language."
"Output only the cleaned transcript without any additional commentary or explanations."
)


def remove_disfluencies(text):
# Define the system prompt for text cleaning task
system_prompt = """You are a transcript cleaning system that removes disfluencies from spoken text.

Your task:
1. Remove filler words: "uh", "um", "like", "ahh", etc.
2. Remove false starts and self-corrections - keep ONLY the final intended statement
3. Remove phrases like "actually", "wait no", "scratch that", "I meant" when they signal corrections
4. Preserve all technical terms EXACTLY as spoken (npm, PostgreSQL, React, Socket.io, etc.)
5. Preserve the speaker's final intent and meaning

Examples:
Input: "I want to use Docker and Kubernetes. Actually no, just Docker."
Output: "I want to use Docker."

Input: "Let's use MongoDB. Let's use Postgres instead."
Output: "Let's use Postgres."

Input: "I want WebSockets. Wait no, I meant Socket.io and Redis."
Output: "I want Socket.io and Redis."

Input: "We need npm packages like React, Next.js, and Tailwind CSS."
Output: "We need npm packages like React, Next.js, and Tailwind CSS."

Critical rules:
- When someone changes their mind ("actually no", "instead", "wait no"), ONLY keep the final decision
- Do NOT keep both the old and new choices
- Technical terms are sacred - never change them
- Output ONLY the cleaned text, no explanations

Clean this transcript:"""


def clean_text(text):
"""Sends the input text to an external AI model for disfluency removal."""
if llm_provider == "lm_studio":
api_url = os.getenv("LM_STUDIO_SERVER_URL")
Expand All @@ -32,6 +52,13 @@ def remove_disfluencies(text):
api_url = os.getenv("OLLAMA_SERVER_URL")
api_port = os.getenv("OLLAMA_SERVER_PORT")
endpoint = f"{api_url}:{api_port}/api/chat"
elif llm_provider == "cloud":
# Ensure base URL ends with /chat/completions for OpenAI-compatible APIs
base_url = cloud_api_base_url.rstrip("/")
if not base_url.endswith("/chat/completions"):
endpoint = f"{base_url}/chat/completions"
else:
endpoint = base_url
else:
raise ValueError(f"Unsupported LLM provider: {llm_provider}")

Expand All @@ -46,12 +73,19 @@ def remove_disfluencies(text):
"max_tokens": 2000,
}

response = requests.post(endpoint, json=payload, timeout=180)
headers = {}
if llm_provider == "cloud":
if not cloud_api_key:
raise ValueError("CLOUD_API_KEY must be set when using cloud provider")
headers["Authorization"] = f"Bearer {cloud_api_key}"
headers["Content-Type"] = "application/json"

response = requests.post(endpoint, json=payload, headers=headers, timeout=180)
response.raise_for_status()

result = response.json()

if llm_provider == "lm_studio":
if llm_provider == "lm_studio" or llm_provider == "cloud":
cleaned_text = result["choices"][0]["message"]["content"]
elif llm_provider == "ollama":
cleaned_text = result["message"]["content"]
Expand All @@ -68,3 +102,5 @@ def remove_disfluencies(text):
raise Exception(f"Error calling LLM API: {str(e)}")
except (KeyError, IndexError) as e:
raise Exception(f"Error parsing LLM response: {str(e)}")


53 changes: 52 additions & 1 deletion backend/services/transcription.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,22 @@
import platform
import tempfile

from dotenv import load_dotenv

load_dotenv()

transcription_provider = os.getenv("TRANSCRIPTION_PROVIDER", "local")
cloud_api_base_url = os.getenv("CLOUD_API_BASE_URL")
cloud_api_key = os.getenv("CLOUD_API_KEY")
transcription_model_name = os.getenv("TRANSCRIPTION_MODEL_NAME", "whisper-large-v3")


def _init_whisper():
"""Initialize the best Whisper engine based on the available hardware."""
# Skip initialization if using cloud provider
if transcription_provider == "cloud":
return "cloud", None

system = platform.system()
machine = platform.machine()

Expand Down Expand Up @@ -37,9 +50,45 @@ def _init_whisper():
ENGINE, MODEL = _init_whisper()


def _transcribe_cloud(audio_path: str) -> str:
"""Transcribe audio using OpenAI-compatible API"""
import requests

if not cloud_api_key:
raise ValueError("CLOUD_API_KEY must be set when using cloud transcription provider")

# Prepare the endpoint URL
base_url = cloud_api_base_url.rstrip("/")
if not base_url.endswith("/audio/transcriptions"):
endpoint = f"{base_url}/audio/transcriptions"
else:
endpoint = base_url

# Prepare the request
headers = {
"Authorization": f"Bearer {cloud_api_key}"
}

with open(audio_path, "rb") as audio_file:
files = {
"file": audio_file
}
data = {
"model": transcription_model_name
}

response = requests.post(endpoint, headers=headers, files=files, data=data, timeout=180)
response.raise_for_status()

result = response.json()
return result["text"]


def transcribe_audio(audio_path: str) -> str:
"""Transcribe audio file to text"""
if ENGINE == "mlx":
if ENGINE == "cloud":
return _transcribe_cloud(audio_path)
elif ENGINE == "mlx":
import mlx_whisper

result = mlx_whisper.transcribe(
Expand All @@ -65,3 +114,5 @@ def transcribe_base64(audio_base64: str) -> str:
return transcribe_audio(temp_path)
finally:
os.unlink(temp_path)


Loading
Loading