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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ outputs/*

# Generated evidence from scripts/. Regenerable; keep deliberately, not by accident.
scripts/*.csv
scripts/probe-artifacts/

# Claude Code local overrides
.claude/settings.local.json
39 changes: 35 additions & 4 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import inspect
import json as json_mod
import logging
import os
Expand Down Expand Up @@ -759,12 +760,42 @@ async def transcribe(request: Request):

# --- Helper functions ---

# Parameter names deepgram-sdk's connect() actually enumerates as keywords.
# Computed from the installed SDK, never hardcoded: the set grows between SDK
# releases, and a hardcoded copy silently rots into the bug below.
_SDK_CONNECT_KWARGS = frozenset(
inspect.signature(AsyncDeepgramClient(api_key="_").listen.v1.connect).parameters
) - {"self", "request_options"}


def _params_to_sdk_kwargs(raw_params: dict) -> dict:
"""Convert frontend params dict to deepgram-sdk 6.x keyword args.
model is required by connect() — default to nova-2 if not provided.
"""Convert a frontend params dict into deepgram-sdk 6.x connect() arguments.

Splits into two buckets, because the SDK enumerates only ~28 of Deepgram's
query parameters as keywords and raises TypeError on anything else:

AsyncV1Client.connect() got an unexpected keyword argument 'keyterms'

That killed the stream for `keyterms`, and equally for `filler_words`,
`no_delay`, `word_confidence`, `alternatives`, `diarize_version` and
`entity_prompt`, all of which are real Deepgram params the UI exposes.
Anything the SDK does not name is forwarded verbatim through
RequestOptions.additional_query_parameters, so it still reaches the wire.

DO NOT "fix" a future occurrence by deleting the param from the UI. Add it
to nothing: the split handles unknown names automatically, and widens on its
own when a newer SDK starts naming them.

model is required by connect(), so default it.
"""
kwargs = serialize_params(raw_params, Mode.STREAMING)
kwargs.setdefault("model", "nova-2")
serialized = serialize_params(raw_params, Mode.STREAMING)
serialized.setdefault("model", "nova-2")

kwargs = {k: v for k, v in serialized.items() if k in _SDK_CONNECT_KWARGS}
passthrough = {k: v for k, v in serialized.items() if k not in _SDK_CONNECT_KWARGS}
if passthrough:
logger.debug("params not named by the SDK, sent as query: %s", sorted(passthrough))
kwargs["request_options"] = {"additional_query_parameters": passthrough}
return kwargs


Expand Down
1 change: 0 additions & 1 deletion config/defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
"redact": [],
"keyterms": [],
"entity_prompt": "",
"keywords": "",
"search": "",
"replace": "",
"topics": false,
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ dev = [
# [project] dependencies: the async app must never ship the sync
# websocket-client path again (see test_websocket_client_not_a_runtime_dep).
"websocket-client>=1.9.0",
# scripts/probe_ui.py only. A UI change needs a real-browser artifact; a
# passing suite says nothing about the rendered DOM. The pip package is all
# CI installs — the browser binary is a separate opt-in
# (`uv run playwright install chromium`), so this does not slow the test job.
"playwright>=1.61.0",
]

[tool.pytest.ini_options]
Expand Down
124 changes: 124 additions & 0 deletions scripts/probe_ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Visual/DOM probe for UI changes: proves a field rendered, or is really gone.

WHY THIS EXISTS: a passing test suite is not evidence about the DOM. Contract
tests assert on fixtures whose shape we wrote ourselves, so they pass while the
rendered page differs. Any change to templates/index.html or static/app.js needs
an artifact from a real browser, and without a committed harness every such
change re-invents this setup from scratch.

Usage:
# Terminal 1
DEEPGRAM_API_KEY=probe-key uv run uvicorn app:app --port 8899
# Terminal 2
uv run python scripts/probe_ui.py
uv run python scripts/probe_ui.py --expect-absent Keywords --expect-present Keyterms

Requires the browser binary once: `uv run playwright install chromium`.

Writes screenshots next to this script under probe-artifacts/, which is
gitignored: attach them to the PR, do not commit them.
"""
import argparse
import asyncio
import json
import sys
from pathlib import Path

from playwright.async_api import async_playwright

ARTIFACTS = Path(__file__).resolve().parent / "probe-artifacts"


async def probe(url: str, expect_present: list[str], expect_absent: list[str]) -> int:
ARTIFACTS.mkdir(exist_ok=True)
failures: list[str] = []

async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page(viewport={"width": 1500, "height": 1400})

console_errors: list[str] = []
page.on("console", lambda m: m.type == "error" and console_errors.append(m.text))
page.on("pageerror", lambda e: console_errors.append(f"pageerror: {e}"))

try:
await page.goto(url, wait_until="networkidle")
except Exception as exc:
# A raw ERR_CONNECTION_REFUSED traceback reads like a probe bug. It
# almost always means the app is not running; say so.
await browser.close()
print(f"could not load {url}: {exc}", file=sys.stderr)
print(
"\nIs the app running? Start it with:\n"
" DEEPGRAM_API_KEY=probe-key uv run uvicorn app:app --port 8899",
file=sys.stderr,
)
return 1
await page.wait_for_timeout(1200)

# Expand every collapsible param section, or a field can be "absent"
# only because its accordion happened to be shut.
await page.evaluate("""() => {
const d = Alpine.$data(document.querySelector('[x-data]'));
Object.keys(d.sections || {}).forEach(k => d.sections[k] = true);
}""")
await page.wait_for_timeout(700)

labels = await page.eval_on_selector_all(
".field-label", "els => els.map(e => e.textContent.trim())"
)
bindings = await page.eval_on_selector_all(
"[x-model]", "els => els.map(e => e.getAttribute('x-model'))"
)
params = await page.evaluate(
"() => JSON.parse(JSON.stringify("
"Alpine.$data(document.querySelector('[x-data]')).params))"
)

print(f"fields rendered: {len(labels)}")
print(json.dumps(sorted(labels), indent=1))
print(f"\nconsole errors: {console_errors or 'none'}")
if console_errors:
failures.append(f"console errors on load: {console_errors}")

for name in expect_present:
if name not in labels:
failures.append(f"expected field {name!r} to render; it did not")

for name in expect_absent:
# Check the label, the x-model binding, AND the live Alpine state.
# A removed label with a live binding left behind still ships the
# param, which is the failure this triple-check catches.
key = name.lower()
if name in labels:
failures.append(f"{name!r} still has a rendered label")
if f"params.{key}" in bindings:
failures.append(f"{name!r} still has an x-model binding")
if key in params:
failures.append(f"{key!r} is still in the live Alpine params state")

await page.screenshot(path=str(ARTIFACTS / "ui_full.png"), full_page=True)
print(f"\nartifact: {ARTIFACTS / 'ui_full.png'}")
await browser.close()

if failures:
print("\nFAILED:")
for f in failures:
print(f" - {f}")
return 1
print("\nOK: all DOM expectations met")
return 0


def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--url", default="http://127.0.0.1:8899/")
ap.add_argument("--expect-present", nargs="*", default=[])
ap.add_argument("--expect-absent", nargs="*", default=[])
a = ap.parse_args()
return asyncio.run(probe(a.url, a.expect_present, a.expect_absent))


if __name__ == "__main__":
sys.exit(main())
19 changes: 14 additions & 5 deletions scripts/test_rrhaphy_keyterms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@
Runs triplicates without keyterms, then triplicates with keyterms.
Uses the /api/tts-transcribe endpoint on deepgram-python-stt.fly.dev.
"""
import os
import httpx
import json
import csv
import json
import os
import sys
from datetime import datetime
from pathlib import Path

import httpx
from dotenv import load_dotenv

# Reads APP_ACCESS_TOKEN from the repo's gitignored .env so the harness gets the
# privileged (un-rate-limited) tier. Must run before the getenv calls below.
load_dotenv()

# Output lands next to this script. DO NOT hardcode an absolute /coding path:
# it pins the harness to one machine.
SCRIPT_DIR = Path(__file__).resolve().parent
Expand Down Expand Up @@ -59,8 +65,11 @@ def run_test(use_keyterms: bool) -> list[dict]:
rows = []
stt_params = {"model": STT_MODEL, "smart_format": True}
if use_keyterms:
# keyterms with intensifier boost
stt_params["keyterms"] = list(TERMS)
# Keyterm Prompting: bare terms, no intensifiers (that is `keywords`).
# Canonical Deepgram wire name. stt.options.PARAM_ALIASES also maps the
# UI's plural "keyterms", but be explicit here: sending the wrong name
# to the batch API is silently ignored, which invalidated an earlier run.
stt_params["keyterm"] = list(TERMS)

label = "WITH keyterms" if use_keyterms else "WITHOUT keyterms"
print(f"\n{'='*60}")
Expand Down
12 changes: 9 additions & 3 deletions scripts/test_rrhaphy_multivoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@
10 Deepgram TTS voices × 8 terms × 3 trials × 2 conditions (no keyterms / with keyterms).
Uses /api/tts-transcribe on deepgram-python-stt.fly.dev.
"""
import os
import httpx
import csv
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed

import httpx
from dotenv import load_dotenv

# Reads APP_ACCESS_TOKEN from the repo's gitignored .env so the harness gets the
# privileged (un-rate-limited) tier. Must run before the getenv calls below.
load_dotenv()

# Output lands next to this script. DO NOT hardcode an absolute /coding path:
# it pins the harness to one machine.
Expand Down
2 changes: 0 additions & 2 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,6 @@ function appData() {
redact: [],
keyterms: [],
entity_prompt: '',
keywords: '',
search: '',
replace: '',
topics: false,
Expand Down Expand Up @@ -1098,7 +1097,6 @@ function appData() {
redact: [],
keyterms: [],
entity_prompt: '',
keywords: '',
search: '',
replace: '',
topics: false,
Expand Down
Loading