Skip to content

Release: spoken-language transcription, CI repair, transcript translation - #46

Merged
Chaddacus merged 16 commits into
mainfrom
dev
Sep 1, 2026
Merged

Chaddacus merged 16 commits into
mainfrom
dev

Conversation

@Chaddacus

@Chaddacus Chaddacus commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Release: spoken-language transcription, CI repair, transcript translation

devmain. Human gate #1: this merge needs Chad's click. The Linode deploy (/root/clio, docker-compose.prod.yml) follows this merge.

What dev carries beyond main

PR Change Status
#45 Transcribe in the spoken language (Deepgram nova-3 detect_language); CORS fix for X-Request-ID proven, CI green
#47 CI on Python 3.12 and covering dev; health probe exempt from the anonymous throttle; e2e stack deterministic; 401 refresh loop fix; folder PATCH IDOR test first green pipeline
#48 Transcript translation as a stored artifact (apps.translations) behind a Claude provider boundary; invalidation on re-transcription and transcript edit; capability contract, eval suite, ADR-005 3 adversarial review rounds, CI green on 20aa404

64 files changed, +2605 / −112.

Production impact

  • Transcription behaviour changes for everyone: Spanish audio now yields a Spanish transcript.
  • Translation stays disabled in production until ANTHROPIC_API_KEY is set for the backend and celery containers. Without the key the endpoint answers 503 and the UI hides the control.
  • New migration: translations.0001_initial (runs on container start).
  • Translation capability is NOT READY by Standard 9 until pytest evals/translation -m ai_eval passes with a live key.

Deploy steps after merge

ssh linode
cd /root/clio && git pull
docker compose -f docker-compose.prod.yml up -d --build backend celery frontend
curl -fsS https://<host>/api/health/

…t-ID

A user reported Spanish audio coming back as English. The report predates the
Deepgram cutover (#38); the current auto path (detect_language=true) already
keeps the spoken language, and Deepgram's `multi` mode was rejected because it
wrecks Korean and Chinese (ADR-004 has the probe table). So this change adds
the proof and hardening rather than a provider change:

- backend/tests/fixtures/audio/: short synthetic clips (macOS voices) for
  es-MX, es-ES, en-US, ko, zh and an English-then-Spanish mix.
- tests/test_language_detection.py: recorded-response tests pinning that auto
  asks for detection and stores whatever Deepgram detects, for every offered
  language; Spanish text is stored verbatim.
- tests/live/: real fixtures through real Deepgram, `pytest -m live`
  (excluded from the default run via pytest.ini).
- frontend/e2e/tests/multilingual-upload.spec.js: uploads the Spanish and the
  mixed clip through the real UI and asserts Spanish text plus a Spanish badge
  (opt-in with CLIO_LIVE_E2E=1).
- NoteDetailPage: the language badge now shows "Spanish" instead of "es",
  carries a lang attribute and a test id; the option list is hoisted so the
  badge and the re-transcribe dialog share it.

Found while running the browser proof: the frontend sends X-Request-ID on
note creation (#41) but CORS_ALLOW_HEADERS did not list it, so on any
cross-origin deployment (dev stack, CI e2e) the browser dropped the POST after
preflight. Added the header and tests/test_cors_preflight.py. Production is
same-origin and was not affected.
…language

Review finding: the badge text is an English label (Spanish), so lang="es"
declared the wrong language for that content. The detected code is now exposed
as data-language for tests and tooling; the e2e assertion follows.
Prove spoken-language transcription end to end; fix CORS for X-Request-ID
The CI workflow pinned Python 3.9 while the backend ships on 3.12
(backend/Dockerfile). Test collection died on PEP 604 syntax, so pytest ran
nothing and reported 31% coverage against a 60% floor; mypy never reached
apps/ at all. Every backend job now runs 3.12, the runtime the code targets.

Backend:
- mypy.ini python_version 3.9 -> 3.12. At 3.9 mypy aborts inside site-packages
  (anyio uses match statements) before checking a single project file.
- backend-typecheck gains DEBUG/SECRET_KEY. The django-stubs plugin imports
  config.settings, which refuses to load without them; the job failed with
  ImproperlyConfigured before mypy produced any output.
- FolderSerializer installs its user-scoped `parent` field through get_fields()
  instead of a class attribute. DRF's own Field.parent owns that name on the
  base class, so the declarative form reads as an override of an unrelated
  attribute. Same field map, same IDOR scoping.
- VoiceNoteListSerializer.folder gets the element-type annotation
  PrimaryKeyRelatedField needs when it is read-only.

Frontend: `npm run build` under CI=true treats eslint warnings as errors, and
there were 17 of them (npm run lint saw only 12 -- its glob is not recursive).
All fixed in the source, none suppressed:
- Dropped imports and bindings nothing read: DocumentTextIcon, MicrophoneIcon
  (x2), ExclamationTriangleIcon, PerformanceIndicator and its never-rendered
  showPerformanceIndicator prop, getPerformanceStatusColor, and four leftovers
  from the disabled performance manager in useAudioRecorder.
- RegisterPage's two href="#" anchors became plain text. The app has no /terms
  or /privacy route, and LoginPage already states the same notice as text.
- speech.d.ts declares SpeechRecognitionConstructor instead of redeclaring the
  SpeechRecognition name as both interface and var.
- PerformanceManager reads its CPU-benchmark accumulator so the timed loop
  cannot be optimised away, and drops an unused getCurrentTier() call.
- startRecording's dependency array is now accurate: state.recordingTime and
  updateAudioLevel move behind refs. Adding recordingTime directly would rebuild
  the callback every second, and updateAudioLevel is declared below it. The ref
  also fixes the diagnostic that always logged a duration of 0.
- MicrophonePermission's mount probe declares its dependency; the callback stays
  stable via a ref because RecordPage passes a fresh handler identity each
  render, which would otherwise loop.

Triggers now include dev on push and pull_request, so PRs into dev get checks.

playwright.config.js ignores production-sweep.spec.js everywhere. It drives
https://clio.chadacus.dev and registers real accounts there; the e2e job would
have run it against production.
…der boundary

New apps.translations module: NoteTranslation rows per (note, target language),
a TranslationProvider protocol with a Claude implementation using structured
output, a Celery task that validates the provider output against the unit
contract before storing text and per-segment translations, and a REST adapter
at /api/notes/<id>/translations/ (GET reports whether the server can translate;
POST dispatches or returns the stored row). The transcript is never modified.

Frontend: TranslationPanel on the note page with disabled, empty, pending,
failed, and completed states, including translated speaker turns aligned by
segment id; shared transcript helpers moved to utils/transcript.ts.

Governance: capability contract, deterministic eval suite (marker ai_eval,
fails closed without ANTHROPIC_API_KEY), ADR-005, .claude/verification.json,
README env and testing notes. Compose files pass the new env through.
comprehensive-harness.spec.js expected an h1 of "Record Voice Note". No such
string exists in frontend/src -- RecordPage.tsx renders "New Voice Note"
(line 168) -- so the assertion failed against a correct page. The two sibling
assertions in the same block ("Ready to record" from RecorderControls,
"Recording Tips" from RecordPage) do match the source and are unchanged.

production-sweep.spec.js carries the same stale assertion but is excluded from
automated runs, so it is left alone.
The e2e suite's registration and login tests share the anon bucket (60/minute)
with the health check, so the health spec failed with 429 once the whole suite
ran in one worker. A liveness probe must never be throttled; uptime checks hit
it too. Adds a test that pins the exemption.
conftest disables throttling for the suite, so the first version passed with or
without the exemption. Restore the production throttle on the base view for
this test, tighten the anon rate, and prove with a control request that the
throttle bites before asserting the probe stays 200.
… volume mount

- Plumb THROTTLE_ANON_RATE through docker-compose.yml and raise it for the CI
  e2e job only; one runner address sends the suite's anonymous traffic inside
  the production 60/minute window. Production default unchanged.
- celery now waits for the backend to be healthy: both images carry /app/media
  and both mounted the empty media_files volume at once, which failed with
  'mkdir ...: file exists' on the second CI run.
- playwright.config.js comment now says what testIgnore really does.
- Folder update path gets the IDOR regression test review asked for.
…the provider boundary

- Re-transcription and a manual transcript edit now call the translations
  module's public invalidate_translations_for_note; stale rows were shown under
  a fresh header and their per-turn view broke on the new segment ids.
- The Claude provider uses messages.create with a JSON-schema output_config and
  validates the text itself, so truncation (stop_reason max_tokens) and
  malformed output become controlled failures instead of a pydantic exception
  escaping the boundary; failure reasons never carry model output.
- Retry policy: transient provider failures (rate limit, 5xx, connection) are
  retried with the row left pending; refusals, truncation, schema and contract
  violations, and configuration errors fail fast. Unexpected exceptions are
  logged by type only.
- Compose passes CLIO_TRANSLATION_EFFORT and CLIO_TRANSLATION_TIMEOUT_SECONDS.
- TranslationPanel renders a load-error state with Retry instead of vanishing.
- Tests: invalidation on retranscribe/edit, segments untouched, retry policy,
  provider boundary with a faked SDK client; Playwright load-error case.
- Contract and ADR updated with invalidation and retry policy.
The response interceptor retried every 401 through /auth/refresh/, including
the refresh call's own 401, so a visitor with no session looped profile ->
refresh -> refresh ... and never left the loading state. Locally the anon
throttle (60/minute) turned the loop into a 429 within a second, which is why
the redirect-to-login tests passed; the raised CI throttle exposed it. The
refresh request is now excluded from the retry path, so its 401 rejects and the
existing redirect runs.
ci: run the pipeline on 3.12, fix the gates it was hiding, and cover dev
…schema definition

- Task writes are conditional UPDATEs on the row pk; zero rows means the note
  was re-transcribed or edited while the provider call was in flight and the
  result (or failure) is discarded. A plain save() re-inserted the stale row.
- OUTPUT_SCHEMA is derived from _TranslationOut (refs inlined, titles dropped,
  objects closed) and a test binds it to the SDK's own transform of the model.
- Unexpected task exceptions are logged by type without a traceback, and the
  test observes the real log records through caplog's handler.
QuerySet.update() bypasses auto_now, so the row's updated_at froze at creation;
the panel orders translations by it and it is provenance for the artifact.
Translate a note's transcript into a chosen language (Claude provider, stored beside the transcript)
@Chaddacus Chaddacus changed the title Release: spoken-language transcription proof + CORS fix (PR #45) Release: spoken-language transcription, CI repair, transcript translation Sep 1, 2026
@Chaddacus
Chaddacus merged commit 5d5d245 into main Sep 1, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant