Compare commits

..

42 Commits

Author SHA1 Message Date
f0ee7b531a fix: logout redirect (#802) 2025-12-19 17:19:09 +01:00
37a454f283 chore(main): release 0.24.0 (#793) 2025-12-19 15:00:43 +01:00
964cd78bb6 feat: identify action items (#790)
* Identify action items

* Add action items to mock summary

* Add action items validator

* Remove final prefix from action items

* Make on action items callback required

* Don't mutation action items response

* Assign action items to none on error

* Use timeout constant

* Exclude action items from transcript list
2025-12-18 21:13:47 +01:00
5f458aa4a7 fix: automatically reprocess daily recordings (#797)
* Automatically reprocess recordings

* Restore the comments

* Remove redundant check

* Fix indent

* Add comment about cyclic import
2025-12-18 21:10:04 +01:00
5f7dfadabd fix: retry on workflow timeout (#798) 2025-12-18 20:49:06 +01:00
0bc971ba96 fix: main menu login (#800) 2025-12-18 20:48:39 +01:00
Igor Monadical
c62e3c0753 incorporate daily api undocumented feature (#796)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-12-17 09:51:55 -05:00
Igor Monadical
16284e1ac3 fix: daily video optimisation (#789)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-12-15 15:00:53 -05:00
Igor Monadical
443982617d coolify pull policy (#792)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-12-15 14:54:05 -05:00
Igor Monadical
23023b3cdb update nextjs (#791)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-12-15 13:58:34 -05:00
90c3ecc9c3 chore(main): release 0.23.2 (#786) 2025-12-11 13:37:41 +01:00
d7f140b7d1 fix: build on push tags (#785) 2025-12-11 13:30:36 +01:00
a47a5f5781 chore(main): release 0.23.1 (#784) 2025-12-11 12:43:25 +01:00
0eba147018 fix: populate room_name in transcript GET endpoint (#783)
Fixes monadical/internalai#14
2025-12-11 12:37:59 +01:00
18a27f7b45 Fix image tags (#781) 2025-12-10 13:57:13 -05:00
32a049c134 chore(main): release 0.23.0 (#770) 2025-12-10 13:42:28 +01:00
91650ec65f fix: deploy frontend to coolify (#779)
* Ignore act secrets

* Deploy frontend container to ECR

* Use published image

* Remove ecr workflows

* Trigger coolify deployment

* Deploy on release please pr merge

* Upgrade nextjs

* Update secrets example
2025-12-10 13:35:53 +01:00
Igor Monadical
61f0e29d4c feat: llm retries (#739)
* llm retries no-mistakes

* self-review (no-mistakes)

* self-review (no-mistakes)

* bigger retry intervals by default

* tests and dry

* restore to main state

* parse retries

* json retries (no-mistakes)

* json retries (no-mistakes)

* json retries (no-mistakes)

* json retries (no-mistakes) self-review

* additional network retry test

* more lindt

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-12-05 12:08:21 -05:00
Igor Monadical
ec17ed7b58 fix: celery inspect bug sidestep in restart script (#766)
* celery bug sidestep

* Update server/reflector/services/transcript_process.py

Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>
2025-12-04 09:22:51 -05:00
Igor Monadical
00549f153a feat: dockerhub ci (#772)
* dockerhub ci

* ci test

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-12-03 13:26:08 -05:00
3ad78be762 fix: hide rooms settings instead of disabling (#763)
* Hide rooms settings instead of disabling

* Reset recording trigger
2025-12-03 16:49:17 +01:00
d3a5cd12d2 fix: return participant emails from transcript endpoint (#769)
* Return participant emails from transcript endpoint

* Fix broken test
2025-12-03 16:47:56 +01:00
af921ce927 chore(main): release 0.22.4 (#765) 2025-12-02 17:11:48 -05:00
Igor Monadical
bd5df1ce2e fix: Multitrack mixdown optimisation 2 (#764)
* Revert "fix: Skip mixdown for multitrack (#760)"

This reverts commit b51b7aa917.

* multitrack mixdown optimisation

* return the "good" ui part of "skip mixdown"

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-12-02 17:10:06 -05:00
c8024484b3 chore(main): release 0.22.3 (#761) 2025-12-02 09:08:22 +01:00
28f87c09dc fix: align daily room settings (#759)
* Switch platform ui

* Update room settings based on platform

* Add local and none recording options to daily

* Don't create tokens for unauthentikated users

* Enable knocking for private rooms

* Create new meeting on room settings change

* Always use 2-200 option for daily

* Show recording start trigger for daily

* Fix broken test
2025-12-02 09:06:36 +01:00
dabf7251db chore(main): release 0.22.2 (#756) 2025-12-01 23:39:32 -05:00
Igor Monadical
b51b7aa917 fix: Skip mixdown for multitrack (#760)
* multitrack mixdown optimisation

* skip mixdown for multitrack

* skip mixdown for multitrack

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-12-01 23:35:12 -05:00
Igor Monadical
a8983b4e7e daily auth hotfix (#757)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-11-28 14:52:59 -05:00
Igor Monadical
fe47c46489 fix: daily auto refresh fix (#755)
* daily auto refresh fix

* Update www/app/lib/AuthProvider.tsx

Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>

* Update www/app/[roomName]/components/DailyRoom.tsx

Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>

* fix bot lint

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>
2025-11-27 18:31:03 -05:00
a2bb6a27d6 chore(main): release 0.22.1 (#750) 2025-11-27 16:55:08 +01:00
7f0b728991 fix: participants update from daily (#749)
* Fix participants update from daily

* Use track keys from params
2025-11-27 16:53:26 +01:00
692895c859 chore(main): release 0.22.0 (#748) 2025-11-26 16:53:27 -05:00
Igor Monadical
d63040e2fd feat: Multitrack segmentation (#747)
* segmentation multitrack (no-mistakes)

* segmentation multitrack (no-mistakes)

* self review

* self review

* recording poll daily doc

* filter cam_audio tracks to remove screensharing from daily processing

* pr review

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-11-26 16:21:32 -05:00
8d696aa775 chore(main): release 0.21.0 (#746) 2025-11-26 19:12:02 +01:00
f6ca07505f feat: add transcript format parameter to GET endpoint (#709)
* feat: add transcript format parameter to GET endpoint

Add transcript_format query parameter to /v1/transcripts/{id} endpoint
with support for multiple output formats using discriminated unions.

Formats supported:
- text: Plain speaker dialogue (default)
- text-timestamped: Dialogue with [MM:SS] timestamps
- webvtt-named: WebVTT subtitles with participant names
- json: Structured segments with full metadata

Response models use Pydantic discriminated unions with transcript_format
as discriminator field. POST/PATCH endpoints return GetTranscriptWithParticipants
for minimal responses. GET endpoint returns format-specific models.

* Copy transcript format

* Regenerate types

* Fix transcript formats

* Don't throw inside try

* Remove any type

* Toast share copy errors

* transcript_format exhaustiveness and python idiomatic assert_never

* format_timestamp_mmss clear type definition

* Rename seconds_to_timestamp

* Test transcript format with overlapping speakers

* exact match for vtt multispeaker test

---------

Co-authored-by: Sergey Mankovsky <sergey@monadical.com>
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-11-26 18:51:14 +01:00
Igor Monadical
3aef926203 room creatio hotfix (#744)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-11-25 22:42:09 -05:00
Igor Monadical
0b2c82227d is_owner pass for dailyco (#745)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-11-25 22:41:54 -05:00
Igor Monadical
689c8075cc transcription reprocess doc (#743)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-11-25 17:05:46 -05:00
201671368a chore(main): release 0.20.0 (#740) 2025-11-25 16:32:49 -05:00
Igor Monadical
86d5e26224 feat: transcript restart script (#742)
* transcript restart script

* fix tests?

* remove useless comment

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-11-25 16:28:43 -05:00
9bec39808f feat: link transcript participants (#737)
* Sync authentik users

* Migrate user_id from uid to id

* Fix auth user id

* Fix ci migration test

* Fix meeting token creation

* Move user id migration to a script

* Add user on first login

* Fix migration chain

* Rename uid column to authentik_uid

* Fix broken ws test
2025-11-25 19:13:19 +01:00
67 changed files with 5164 additions and 866 deletions

View File

@@ -1,90 +0,0 @@
name: Build container/push to container registry
on: [workflow_dispatch]
env:
# 950402358378.dkr.ecr.us-east-1.amazonaws.com/reflector
AWS_REGION: us-east-1
ECR_REPOSITORY: reflector
jobs:
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: linux-amd64
arch: amd64
- platform: linux/arm64
runner: linux-arm64
arch: arm64
runs-on: ${{ matrix.runner }}
permissions:
contents: read
outputs:
registry: ${{ steps.login-ecr.outputs.registry }}
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push ${{ matrix.arch }}
uses: docker/build-push-action@v5
with:
context: server
platforms: ${{ matrix.platform }}
push: true
tags: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest-${{ matrix.arch }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
github-token: ${{ secrets.GHA_CACHE_TOKEN }}
provenance: false
create-manifest:
runs-on: ubuntu-latest
needs: [build]
permissions:
deployments: write
contents: read
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@v2
- name: Create and push multi-arch manifest
run: |
# Get the registry URL (since we can't easily access job outputs in matrix)
ECR_REGISTRY=$(aws ecr describe-registry --query 'registryId' --output text).dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com
docker manifest create \
$ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:latest \
$ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:latest-amd64 \
$ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:latest-arm64
docker manifest push $ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:latest
echo "✅ Multi-arch manifest pushed: $ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:latest"

View File

@@ -1,35 +1,31 @@
name: Build and Push Frontend Docker Image
name: Build and Push Backend Docker Image (Docker Hub)
on:
push:
branches:
- main
paths:
- 'www/**'
- '.github/workflows/docker-frontend.yml'
tags:
- "v*"
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-frontend
REGISTRY: docker.io
IMAGE_NAME: monadicalsas/reflector-backend
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: monadicalsas
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
@@ -38,7 +34,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=ref,event=tag
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up Docker Buildx
@@ -47,11 +43,11 @@ jobs:
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./www
file: ./www/Dockerfile
context: ./server
file: ./server/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64

View File

@@ -0,0 +1,70 @@
name: Build and Push Frontend Docker Image
on:
push:
tags:
- "v*"
workflow_dispatch:
env:
REGISTRY: docker.io
IMAGE_NAME: monadicalsas/reflector-frontend
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: monadicalsas
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable={{is_default_branch}}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./www
file: ./www/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
deploy:
needs: build-and-push
runs-on: ubuntu-latest
if: success()
strategy:
matrix:
environment: [reflector-monadical, reflector-media]
environment: ${{ matrix.environment }}
steps:
- name: Trigger Coolify deployment
run: |
curl -X POST "${{ secrets.COOLIFY_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_WEBHOOK_TOKEN }}" \
-f || (echo "Failed to trigger Coolify deployment for ${{ matrix.environment }}" && exit 1)

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ CLAUDE.local.md
www/.env.development
www/.env.production
.playwright-mcp
.secrets

24
.secrets.example Normal file
View File

@@ -0,0 +1,24 @@
# Example secrets file for GitHub Actions workflows
# Copy this to .secrets and fill in your values
# These secrets should be configured in GitHub repository settings:
# Settings > Secrets and variables > Actions
# DockerHub Configuration (required for frontend and backend deployment)
# Create a Docker Hub access token at https://hub.docker.com/settings/security
# Username: monadicalsas
DOCKERHUB_TOKEN=your-dockerhub-access-token
# GitHub Token (required for frontend and backend deployment)
# Used by docker/metadata-action for extracting image metadata
# Can use the default GITHUB_TOKEN or create a personal access token
GITHUB_TOKEN=your-github-token-or-use-default-GITHUB_TOKEN
# Coolify Deployment Webhook (required for frontend deployment)
# Used to trigger automatic deployment after image push
# Configure these secrets in GitHub Environments:
# Each environment should have:
# - COOLIFY_WEBHOOK_URL: The webhook URL for that specific deployment
# - COOLIFY_WEBHOOK_TOKEN: The webhook token (can be the same for both if using same token)
# Optional: GitHub Actions Cache Token (for local testing with act)
GHA_CACHE_TOKEN=your-github-token-or-empty

View File

@@ -1,5 +1,101 @@
# Changelog
## [0.24.0](https://github.com/Monadical-SAS/reflector/compare/v0.23.2...v0.24.0) (2025-12-18)
### Features
* identify action items ([#790](https://github.com/Monadical-SAS/reflector/issues/790)) ([964cd78](https://github.com/Monadical-SAS/reflector/commit/964cd78bb699d83d012ae4b8c96565df25b90a5d))
### Bug Fixes
* automatically reprocess daily recordings ([#797](https://github.com/Monadical-SAS/reflector/issues/797)) ([5f458aa](https://github.com/Monadical-SAS/reflector/commit/5f458aa4a7ec3d00ca5ec49d62fcc8ad232b138e))
* daily video optimisation ([#789](https://github.com/Monadical-SAS/reflector/issues/789)) ([16284e1](https://github.com/Monadical-SAS/reflector/commit/16284e1ac3faede2b74f0d91b50c0b5612af2c35))
* main menu login ([#800](https://github.com/Monadical-SAS/reflector/issues/800)) ([0bc971b](https://github.com/Monadical-SAS/reflector/commit/0bc971ba966a52d719c8c240b47dc7b3bdea4391))
* retry on workflow timeout ([#798](https://github.com/Monadical-SAS/reflector/issues/798)) ([5f7dfad](https://github.com/Monadical-SAS/reflector/commit/5f7dfadabd3e8017406ad3720ba495a59963ee34))
## [0.23.2](https://github.com/Monadical-SAS/reflector/compare/v0.23.1...v0.23.2) (2025-12-11)
### Bug Fixes
* build on push tags ([#785](https://github.com/Monadical-SAS/reflector/issues/785)) ([d7f140b](https://github.com/Monadical-SAS/reflector/commit/d7f140b7d1f4660d5da7a0da1357f68869e0b5cd))
## [0.23.1](https://github.com/Monadical-SAS/reflector/compare/v0.23.0...v0.23.1) (2025-12-11)
### Bug Fixes
* populate room_name in transcript GET endpoint ([#783](https://github.com/Monadical-SAS/reflector/issues/783)) ([0eba147](https://github.com/Monadical-SAS/reflector/commit/0eba1470181c7b9e0a79964a1ef28c09bcbdd9d7))
## [0.23.0](https://github.com/Monadical-SAS/reflector/compare/v0.22.4...v0.23.0) (2025-12-10)
### Features
* dockerhub ci ([#772](https://github.com/Monadical-SAS/reflector/issues/772)) ([00549f1](https://github.com/Monadical-SAS/reflector/commit/00549f153ade922cf4cb6c5358a7d11a39c426d2))
* llm retries ([#739](https://github.com/Monadical-SAS/reflector/issues/739)) ([61f0e29](https://github.com/Monadical-SAS/reflector/commit/61f0e29d4c51eab54ee67af92141fbb171e8ccaa))
### Bug Fixes
* celery inspect bug sidestep in restart script ([#766](https://github.com/Monadical-SAS/reflector/issues/766)) ([ec17ed7](https://github.com/Monadical-SAS/reflector/commit/ec17ed7b587cf6ee143646baaee67a7c017044d4))
* deploy frontend to coolify ([#779](https://github.com/Monadical-SAS/reflector/issues/779)) ([91650ec](https://github.com/Monadical-SAS/reflector/commit/91650ec65f65713faa7ee0dcfb75af427b7c4ba0))
* hide rooms settings instead of disabling ([#763](https://github.com/Monadical-SAS/reflector/issues/763)) ([3ad78be](https://github.com/Monadical-SAS/reflector/commit/3ad78be7628c0d029296b301a0e87236c76b7598))
* return participant emails from transcript endpoint ([#769](https://github.com/Monadical-SAS/reflector/issues/769)) ([d3a5cd1](https://github.com/Monadical-SAS/reflector/commit/d3a5cd12d2d0d9c32af2d5bd9322e030ef69b85d))
## [0.22.4](https://github.com/Monadical-SAS/reflector/compare/v0.22.3...v0.22.4) (2025-12-02)
### Bug Fixes
* Multitrack mixdown optimisation 2 ([#764](https://github.com/Monadical-SAS/reflector/issues/764)) ([bd5df1c](https://github.com/Monadical-SAS/reflector/commit/bd5df1ce2ebf35d7f3413b295e56937a9a28ef7b))
## [0.22.3](https://github.com/Monadical-SAS/reflector/compare/v0.22.2...v0.22.3) (2025-12-02)
### Bug Fixes
* align daily room settings ([#759](https://github.com/Monadical-SAS/reflector/issues/759)) ([28f87c0](https://github.com/Monadical-SAS/reflector/commit/28f87c09dc459846873d0dde65b03e3d7b2b9399))
## [0.22.2](https://github.com/Monadical-SAS/reflector/compare/v0.22.1...v0.22.2) (2025-12-02)
### Bug Fixes
* daily auto refresh fix ([#755](https://github.com/Monadical-SAS/reflector/issues/755)) ([fe47c46](https://github.com/Monadical-SAS/reflector/commit/fe47c46489c5aa0cc538109f7559cc9accb35c01))
* Skip mixdown for multitrack ([#760](https://github.com/Monadical-SAS/reflector/issues/760)) ([b51b7aa](https://github.com/Monadical-SAS/reflector/commit/b51b7aa9176c1a53ba57ad99f5e976c804a1e80c))
## [0.22.1](https://github.com/Monadical-SAS/reflector/compare/v0.22.0...v0.22.1) (2025-11-27)
### Bug Fixes
* participants update from daily ([#749](https://github.com/Monadical-SAS/reflector/issues/749)) ([7f0b728](https://github.com/Monadical-SAS/reflector/commit/7f0b728991c1b9f9aae702c96297eae63b561ef5))
## [0.22.0](https://github.com/Monadical-SAS/reflector/compare/v0.21.0...v0.22.0) (2025-11-26)
### Features
* Multitrack segmentation ([#747](https://github.com/Monadical-SAS/reflector/issues/747)) ([d63040e](https://github.com/Monadical-SAS/reflector/commit/d63040e2fdc07e7b272e85a39eb2411cd6a14798))
## [0.21.0](https://github.com/Monadical-SAS/reflector/compare/v0.20.0...v0.21.0) (2025-11-26)
### Features
* add transcript format parameter to GET endpoint ([#709](https://github.com/Monadical-SAS/reflector/issues/709)) ([f6ca075](https://github.com/Monadical-SAS/reflector/commit/f6ca07505f34483b02270a2ef3bd809e9d2e1045))
## [0.20.0](https://github.com/Monadical-SAS/reflector/compare/v0.19.0...v0.20.0) (2025-11-25)
### Features
* link transcript participants ([#737](https://github.com/Monadical-SAS/reflector/issues/737)) ([9bec398](https://github.com/Monadical-SAS/reflector/commit/9bec39808fc6322612d8b87e922a6f7901fc01c1))
* transcript restart script ([#742](https://github.com/Monadical-SAS/reflector/issues/742)) ([86d5e26](https://github.com/Monadical-SAS/reflector/commit/86d5e26224bb55a0f1cc785aeda52065bb92ee6f))
## [0.19.0](https://github.com/Monadical-SAS/reflector/compare/v0.18.0...v0.19.0) (2025-11-25)

View File

@@ -168,6 +168,12 @@ You can manually process an audio file by calling the process tool:
uv run python -m reflector.tools.process path/to/audio.wav
```
## Reprocessing any transcription
```bash
uv run -m reflector.tools.process_transcript 81ec38d1-9dd7-43d2-b3f8-51f4d34a07cd --sync
```
## Build-time env variables
Next.js projects are more used to NEXT_PUBLIC_ prefixed buildtime vars. We don't have those for the reason we need to serve a ccustomizable prebuild docker container.

View File

@@ -3,10 +3,8 @@
services:
web:
build:
context: ./www
dockerfile: Dockerfile
image: reflector-frontend:latest
image: monadicalsas/reflector-frontend:latest
pull_policy: always
environment:
- KV_URL=${KV_URL:-redis://redis:6379}
- SITE_URL=${SITE_URL}
@@ -36,4 +34,4 @@ services:
- redis_data:/data
volumes:
redis_data:
redis_data:

241
docs/transcript.md Normal file
View File

@@ -0,0 +1,241 @@
# Transcript Formats
The Reflector API provides multiple output formats for transcript data through the `transcript_format` query parameter on the GET `/v1/transcripts/{id}` endpoint.
## Overview
When retrieving a transcript, you can specify the desired format using the `transcript_format` query parameter. The API supports four formats optimized for different use cases:
- **text** - Plain text with speaker names (default)
- **text-timestamped** - Timestamped text with speaker names
- **webvtt-named** - WebVTT subtitle format with participant names
- **json** - Structured JSON segments with full metadata
All formats include participant information when available, resolving speaker IDs to actual names.
## Query Parameter Usage
```
GET /v1/transcripts/{id}?transcript_format={format}
```
### Parameters
- `transcript_format` (optional): The desired output format
- Type: `"text" | "text-timestamped" | "webvtt-named" | "json"`
- Default: `"text"`
## Format Descriptions
### Text Format (`text`)
**Use case:** Simple, human-readable transcript for display or export.
**Format:** Speaker names followed by their dialogue, one line per segment.
**Example:**
```
John Smith: Hello everyone
Jane Doe: Hi there
John Smith: How are you today?
```
**Request:**
```bash
GET /v1/transcripts/{id}?transcript_format=text
```
**Response:**
```json
{
"id": "transcript_123",
"name": "Meeting Recording",
"transcript_format": "text",
"transcript": "John Smith: Hello everyone\nJane Doe: Hi there\nJohn Smith: How are you today?",
"participants": [
{"id": "p1", "speaker": 0, "name": "John Smith"},
{"id": "p2", "speaker": 1, "name": "Jane Doe"}
],
...
}
```
### Text Timestamped Format (`text-timestamped`)
**Use case:** Transcript with timing information for navigation or reference.
**Format:** `[MM:SS]` timestamp prefix before each speaker and dialogue.
**Example:**
```
[00:00] John Smith: Hello everyone
[00:05] Jane Doe: Hi there
[00:12] John Smith: How are you today?
```
**Request:**
```bash
GET /v1/transcripts/{id}?transcript_format=text-timestamped
```
**Response:**
```json
{
"id": "transcript_123",
"name": "Meeting Recording",
"transcript_format": "text-timestamped",
"transcript": "[00:00] John Smith: Hello everyone\n[00:05] Jane Doe: Hi there\n[00:12] John Smith: How are you today?",
"participants": [
{"id": "p1", "speaker": 0, "name": "John Smith"},
{"id": "p2", "speaker": 1, "name": "Jane Doe"}
],
...
}
```
### WebVTT Named Format (`webvtt-named`)
**Use case:** Subtitle files for video players, accessibility tools, or video editing.
**Format:** Standard WebVTT subtitle format with voice tags using participant names.
**Example:**
```
WEBVTT
00:00:00.000 --> 00:00:05.000
<v John Smith>Hello everyone
00:00:05.000 --> 00:00:12.000
<v Jane Doe>Hi there
00:00:12.000 --> 00:00:18.000
<v John Smith>How are you today?
```
**Request:**
```bash
GET /v1/transcripts/{id}?transcript_format=webvtt-named
```
**Response:**
```json
{
"id": "transcript_123",
"name": "Meeting Recording",
"transcript_format": "webvtt-named",
"transcript": "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\n<v John Smith>Hello everyone\n\n...",
"participants": [
{"id": "p1", "speaker": 0, "name": "John Smith"},
{"id": "p2", "speaker": 1, "name": "Jane Doe"}
],
...
}
```
### JSON Format (`json`)
**Use case:** Programmatic access with full timing and speaker metadata.
**Format:** Array of segment objects with speaker information, text content, and precise timing.
**Example:**
```json
[
{
"speaker": 0,
"speaker_name": "John Smith",
"text": "Hello everyone",
"start": 0.0,
"end": 5.0
},
{
"speaker": 1,
"speaker_name": "Jane Doe",
"text": "Hi there",
"start": 5.0,
"end": 12.0
},
{
"speaker": 0,
"speaker_name": "John Smith",
"text": "How are you today?",
"start": 12.0,
"end": 18.0
}
]
```
**Request:**
```bash
GET /v1/transcripts/{id}?transcript_format=json
```
**Response:**
```json
{
"id": "transcript_123",
"name": "Meeting Recording",
"transcript_format": "json",
"transcript": [
{
"speaker": 0,
"speaker_name": "John Smith",
"text": "Hello everyone",
"start": 0.0,
"end": 5.0
},
{
"speaker": 1,
"speaker_name": "Jane Doe",
"text": "Hi there",
"start": 5.0,
"end": 12.0
}
],
"participants": [
{"id": "p1", "speaker": 0, "name": "John Smith"},
{"id": "p2", "speaker": 1, "name": "Jane Doe"}
],
...
}
```
## Response Structure
All formats return the same base transcript metadata with an additional `transcript_format` field and format-specific `transcript` field:
### Common Fields
- `id`: Transcript identifier
- `user_id`: Owner user ID (if authenticated)
- `name`: Transcript name
- `status`: Processing status
- `locked`: Whether transcript is locked for editing
- `duration`: Total duration in seconds
- `title`: Auto-generated or custom title
- `short_summary`: Brief summary
- `long_summary`: Detailed summary
- `created_at`: Creation timestamp
- `share_mode`: Access control setting
- `source_language`: Original audio language
- `target_language`: Translation target language
- `reviewed`: Whether transcript has been reviewed
- `meeting_id`: Associated meeting ID (if applicable)
- `source_kind`: Source type (live, file, room)
- `room_id`: Associated room ID (if applicable)
- `audio_deleted`: Whether audio has been deleted
- `participants`: Array of participant objects with speaker mappings
### Format-Specific Fields
- `transcript_format`: The format identifier (discriminator field)
- `transcript`: The formatted transcript content (string for text/webvtt formats, array for json format)
## Speaker Name Resolution
All formats resolve speaker IDs to participant names when available:
- If a participant exists for the speaker ID, their name is used
- If no participant exists, a default name like "Speaker 0" is generated
- Speaker IDs are integers (0, 1, 2, etc.) assigned during diarization

View File

@@ -0,0 +1,26 @@
"""add_action_items
Revision ID: 05f8688d6895
Revises: bbafedfa510c
Create Date: 2025-12-12 11:57:50.209658
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "05f8688d6895"
down_revision: Union[str, None] = "bbafedfa510c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("transcript", sa.Column("action_items", sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column("transcript", "action_items")

View File

@@ -0,0 +1,38 @@
"""add user table
Revision ID: bbafedfa510c
Revises: 5d6b9df9b045
Create Date: 2025-11-19 21:06:30.543262
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "bbafedfa510c"
down_revision: Union[str, None] = "5d6b9df9b045"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user",
sa.Column("id", sa.String(), nullable=False),
sa.Column("email", sa.String(), nullable=False),
sa.Column("authentik_uid", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("user", schema=None) as batch_op:
batch_op.create_index("idx_user_authentik_uid", ["authentik_uid"], unique=True)
batch_op.create_index("idx_user_email", ["email"], unique=False)
def downgrade() -> None:
op.drop_table("user")

View File

@@ -126,6 +126,7 @@ markers = [
select = [
"I", # isort - import sorting
"F401", # unused imports
"E402", # module level import not at top of file
"PLC0415", # import-outside-top-level - detect inline imports
]

View File

@@ -1,13 +1,19 @@
import asyncio
import functools
from uuid import uuid4
from celery import current_task
from reflector.db import get_database
from reflector.llm import llm_session_id
def asynctask(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
async def run_with_db():
task_id = current_task.request.id if current_task else None
llm_session_id.set(task_id or f"random-{uuid4().hex}")
database = get_database()
await database.connect()
try:

View File

@@ -6,8 +6,10 @@ from jose import JWTError, jwt
from pydantic import BaseModel
from reflector.db.user_api_keys import user_api_keys_controller
from reflector.db.users import user_controller
from reflector.logger import logger
from reflector.settings import settings
from reflector.utils import generate_uuid4
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
@@ -74,9 +76,21 @@ async def _authenticate_user(
if jwt_token:
try:
payload = jwtauth.verify_token(jwt_token)
sub = payload["sub"]
authentik_uid = payload["sub"]
email = payload["email"]
user_infos.append(UserInfo(sub=sub, email=email))
user = await user_controller.get_by_authentik_uid(authentik_uid)
if not user:
logger.info(
f"Creating new user on first login: {authentik_uid} ({email})"
)
user = await user_controller.create_or_update(
id=generate_uuid4(),
authentik_uid=authentik_uid,
email=email,
)
user_infos.append(UserInfo(sub=user.id, email=email))
except JWTError as e:
logger.error(f"JWT error: {e}")
raise HTTPException(status_code=401, detail="Invalid authentication")

View File

@@ -18,6 +18,7 @@ from .requests import (
# Response models
from .responses import (
FinishedRecordingResponse,
MeetingParticipant,
MeetingParticipantsResponse,
MeetingResponse,
@@ -79,6 +80,7 @@ __all__ = [
"MeetingParticipant",
"MeetingResponse",
"RecordingResponse",
"FinishedRecordingResponse",
"RecordingS3Info",
"MeetingTokenResponse",
"WebhookResponse",

View File

@@ -40,6 +40,10 @@ class RoomProperties(BaseModel):
)
enable_chat: bool = Field(default=True, description="Enable in-meeting chat")
enable_screenshare: bool = Field(default=True, description="Enable screen sharing")
enable_knocking: bool = Field(
default=False,
description="Enable knocking for private rooms (allows participants to request access)",
)
start_video_off: bool = Field(
default=False, description="Start with video off for all participants"
)

View File

@@ -68,7 +68,7 @@ class MeetingParticipant(BaseModel):
Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-participants
"""
user_id: NonEmptyString = Field(description="User identifier")
user_id: NonEmptyString | None = Field(None, description="User identifier")
participant_id: NonEmptyString = Field(description="Participant session identifier")
user_name: NonEmptyString | None = Field(None, description="User display name")
join_time: int = Field(description="Join timestamp (Unix epoch seconds)")
@@ -121,7 +121,10 @@ class RecordingS3Info(BaseModel):
class RecordingResponse(BaseModel):
"""
Response from recording retrieval endpoint.
Response from recording retrieval endpoint (network layer).
Duration may be None for recordings still being processed by Daily.
Use FinishedRecordingResponse for recordings ready for processing.
Reference: https://docs.daily.co/reference/rest-api/recordings
"""
@@ -135,7 +138,9 @@ class RecordingResponse(BaseModel):
max_participants: int | None = Field(
None, description="Maximum participants during recording (may be missing)"
)
duration: int = Field(description="Recording duration in seconds")
duration: int | None = Field(
None, description="Recording duration in seconds (None if still processing)"
)
share_token: NonEmptyString | None = Field(
None, description="Token for sharing recording"
)
@@ -149,6 +154,25 @@ class RecordingResponse(BaseModel):
None, description="Meeting session identifier (may be missing)"
)
def to_finished(self) -> "FinishedRecordingResponse | None":
"""Convert to FinishedRecordingResponse if duration is available and status is finished."""
if self.duration is None or self.status != "finished":
return None
return FinishedRecordingResponse(**self.model_dump())
class FinishedRecordingResponse(RecordingResponse):
"""
Recording with confirmed duration - ready for processing.
This model guarantees duration is present and status is finished.
"""
status: Literal["finished"] = Field(
description="Recording status (always 'finished')"
)
duration: int = Field(description="Recording duration in seconds")
class MeetingTokenResponse(BaseModel):
"""

View File

@@ -195,7 +195,6 @@ def parse_recording_error(event: DailyWebhookEvent) -> RecordingErrorPayload:
return RecordingErrorPayload(**event.payload)
# Webhook event type to parser mapping
WEBHOOK_PARSERS = {
"participant.joined": parse_participant_joined,
"participant.left": parse_participant_left,

View File

@@ -31,6 +31,7 @@ import reflector.db.recordings # noqa
import reflector.db.rooms # noqa
import reflector.db.transcripts # noqa
import reflector.db.user_api_keys # noqa
import reflector.db.users # noqa
kwargs = {}
if "postgres" not in settings.DATABASE_URL:

View File

@@ -3,6 +3,7 @@ from typing import Literal
import sqlalchemy as sa
from pydantic import BaseModel, Field
from sqlalchemy import or_
from reflector.db import get_database, metadata
from reflector.utils import generate_uuid4
@@ -35,8 +36,15 @@ class Recording(BaseModel):
status: Literal["pending", "processing", "completed", "failed"] = "pending"
meeting_id: str | None = None
# for multitrack reprocessing
# track_keys can be empty list [] if recording finished but no audio was captured (silence/muted)
# None means not a multitrack recording, [] means multitrack with no tracks
track_keys: list[str] | None = None
@property
def is_multitrack(self) -> bool:
"""True if recording has separate audio tracks (1+ tracks counts as multitrack)."""
return self.track_keys is not None and len(self.track_keys) > 0
class RecordingController:
async def create(self, recording: Recording):
@@ -72,5 +80,35 @@ class RecordingController:
results = await get_database().fetch_all(query)
return [Recording(**row) for row in results]
async def get_multitrack_needing_reprocessing(
self, bucket_name: str
) -> list[Recording]:
"""
Get multitrack recordings that need reprocessing:
- Have track_keys (multitrack)
- Either have no transcript OR transcript has error status
This is more efficient than fetching all recordings and filtering in Python.
"""
from reflector.db.transcripts import (
transcripts, # noqa: PLC0415 cyclic import
)
query = (
recordings.select()
.outerjoin(transcripts, recordings.c.id == transcripts.c.recording_id)
.where(
recordings.c.bucket_name == bucket_name,
recordings.c.track_keys.isnot(None),
or_(
transcripts.c.id.is_(None),
transcripts.c.status == "error",
),
)
)
results = await get_database().fetch_all(query)
recordings_list = [Recording(**row) for row in results]
return [r for r in recordings_list if r.is_multitrack]
recordings_controller = RecordingController()

View File

@@ -44,6 +44,7 @@ transcripts = sqlalchemy.Table(
sqlalchemy.Column("title", sqlalchemy.String),
sqlalchemy.Column("short_summary", sqlalchemy.String),
sqlalchemy.Column("long_summary", sqlalchemy.String),
sqlalchemy.Column("action_items", sqlalchemy.JSON),
sqlalchemy.Column("topics", sqlalchemy.JSON),
sqlalchemy.Column("events", sqlalchemy.JSON),
sqlalchemy.Column("participants", sqlalchemy.JSON),
@@ -164,6 +165,10 @@ class TranscriptFinalLongSummary(BaseModel):
long_summary: str
class TranscriptActionItems(BaseModel):
action_items: dict
class TranscriptFinalTitle(BaseModel):
title: str
@@ -204,6 +209,7 @@ class Transcript(BaseModel):
locked: bool = False
short_summary: str | None = None
long_summary: str | None = None
action_items: dict | None = None
topics: list[TranscriptTopic] = []
events: list[TranscriptEvent] = []
participants: list[TranscriptParticipant] | None = []
@@ -368,7 +374,12 @@ class TranscriptController:
room_id: str | None = None,
search_term: str | None = None,
return_query: bool = False,
exclude_columns: list[str] = ["topics", "events", "participants"],
exclude_columns: list[str] = [
"topics",
"events",
"participants",
"action_items",
],
) -> list[Transcript]:
"""
Get all transcripts

View File

@@ -0,0 +1,98 @@
"""User table for storing Authentik user information."""
from datetime import datetime, timezone
import sqlalchemy
from pydantic import BaseModel, Field
from reflector.db import get_database, metadata
from reflector.utils import generate_uuid4
from reflector.utils.string import NonEmptyString
users = sqlalchemy.Table(
"user",
metadata,
sqlalchemy.Column("id", sqlalchemy.String, primary_key=True),
sqlalchemy.Column("email", sqlalchemy.String, nullable=False),
sqlalchemy.Column("authentik_uid", sqlalchemy.String, nullable=False),
sqlalchemy.Column("created_at", sqlalchemy.DateTime(timezone=True), nullable=False),
sqlalchemy.Column("updated_at", sqlalchemy.DateTime(timezone=True), nullable=False),
sqlalchemy.Index("idx_user_authentik_uid", "authentik_uid", unique=True),
sqlalchemy.Index("idx_user_email", "email", unique=False),
)
class User(BaseModel):
id: NonEmptyString = Field(default_factory=generate_uuid4)
email: NonEmptyString
authentik_uid: NonEmptyString
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class UserController:
@staticmethod
async def get_by_id(user_id: NonEmptyString) -> User | None:
query = users.select().where(users.c.id == user_id)
result = await get_database().fetch_one(query)
return User(**result) if result else None
@staticmethod
async def get_by_authentik_uid(authentik_uid: NonEmptyString) -> User | None:
query = users.select().where(users.c.authentik_uid == authentik_uid)
result = await get_database().fetch_one(query)
return User(**result) if result else None
@staticmethod
async def get_by_email(email: NonEmptyString) -> User | None:
query = users.select().where(users.c.email == email)
result = await get_database().fetch_one(query)
return User(**result) if result else None
@staticmethod
async def create_or_update(
id: NonEmptyString, authentik_uid: NonEmptyString, email: NonEmptyString
) -> User:
existing = await UserController.get_by_authentik_uid(authentik_uid)
now = datetime.now(timezone.utc)
if existing:
query = (
users.update()
.where(users.c.authentik_uid == authentik_uid)
.values(email=email, updated_at=now)
)
await get_database().execute(query)
return User(
id=existing.id,
authentik_uid=authentik_uid,
email=email,
created_at=existing.created_at,
updated_at=now,
)
else:
user = User(
id=id,
authentik_uid=authentik_uid,
email=email,
created_at=now,
updated_at=now,
)
query = users.insert().values(**user.model_dump())
await get_database().execute(query)
return user
@staticmethod
async def list_all() -> list[User]:
query = users.select().order_by(users.c.created_at.desc())
results = await get_database().fetch_all(query)
return [User(**r) for r in results]
@staticmethod
async def get_by_ids(user_ids: list[NonEmptyString]) -> dict[str, User]:
query = users.select().where(users.c.id.in_(user_ids))
results = await get_database().fetch_all(query)
return {user.id: User(**user) for user in results}
user_controller = UserController()

View File

@@ -1,14 +1,32 @@
import logging
from typing import Type, TypeVar
from contextvars import ContextVar
from typing import Generic, Type, TypeVar
from uuid import uuid4
from llama_index.core import Settings
from llama_index.core.output_parsers import PydanticOutputParser
from llama_index.core.program import LLMTextCompletionProgram
from llama_index.core.response_synthesizers import TreeSummarize
from llama_index.core.workflow import (
Context,
Event,
StartEvent,
StopEvent,
Workflow,
step,
)
from llama_index.llms.openai_like import OpenAILike
from pydantic import BaseModel, ValidationError
from workflows.errors import WorkflowTimeoutError
from reflector.utils.retry import retry
T = TypeVar("T", bound=BaseModel)
OutputT = TypeVar("OutputT", bound=BaseModel)
# Session ID for LiteLLM request grouping - set per processing run
llm_session_id: ContextVar[str | None] = ContextVar("llm_session_id", default=None)
logger = logging.getLogger(__name__)
STRUCTURED_RESPONSE_PROMPT_TEMPLATE = """
Based on the following analysis, provide the information in the requested JSON format:
@@ -20,6 +38,158 @@ Analysis:
"""
class LLMParseError(Exception):
"""Raised when LLM output cannot be parsed after retries."""
def __init__(self, output_cls: Type[BaseModel], error_msg: str, attempts: int):
self.output_cls = output_cls
self.error_msg = error_msg
self.attempts = attempts
super().__init__(
f"Failed to parse {output_cls.__name__} after {attempts} attempts: {error_msg}"
)
class ExtractionDone(Event):
"""Event emitted when LLM JSON formatting completes."""
output: str
class ValidationErrorEvent(Event):
"""Event emitted when validation fails."""
error: str
wrong_output: str
class StructuredOutputWorkflow(Workflow, Generic[OutputT]):
"""Workflow for structured output extraction with validation retry.
This workflow handles parse/validation retries only. Network error retries
are handled internally by Settings.llm (OpenAILike max_retries=3).
The caller should NOT wrap this workflow in additional retry logic.
"""
def __init__(
self,
output_cls: Type[OutputT],
max_retries: int = 3,
**kwargs,
):
super().__init__(**kwargs)
self.output_cls: Type[OutputT] = output_cls
self.max_retries = max_retries
self.output_parser = PydanticOutputParser(output_cls)
@step
async def extract(
self, ctx: Context, ev: StartEvent | ValidationErrorEvent
) -> StopEvent | ExtractionDone:
"""Extract structured data from text using two-step LLM process.
Step 1 (first call only): TreeSummarize generates text analysis
Step 2 (every call): Settings.llm.acomplete formats analysis as JSON
"""
current_retries = await ctx.store.get("retries", default=0)
await ctx.store.set("retries", current_retries + 1)
if current_retries >= self.max_retries:
last_error = await ctx.store.get("last_error", default=None)
logger.error(
f"Max retries ({self.max_retries}) reached for {self.output_cls.__name__}"
)
return StopEvent(result={"error": last_error, "attempts": current_retries})
if isinstance(ev, StartEvent):
# First call: run TreeSummarize to get analysis, store in context
prompt = ev.get("prompt")
texts = ev.get("texts")
tone_name = ev.get("tone_name")
if not prompt or not isinstance(texts, list):
raise ValueError(
"StartEvent must contain 'prompt' (str) and 'texts' (list)"
)
summarizer = TreeSummarize(verbose=False)
analysis = await summarizer.aget_response(
prompt, texts, tone_name=tone_name
)
await ctx.store.set("analysis", str(analysis))
reflection = ""
else:
# Retry: reuse analysis from context
analysis = await ctx.store.get("analysis")
if not analysis:
raise RuntimeError("Internal error: analysis not found in context")
wrong_output = ev.wrong_output
if len(wrong_output) > 2000:
wrong_output = wrong_output[:2000] + "... [truncated]"
reflection = (
f"\n\nYour previous response could not be parsed:\n{wrong_output}\n\n"
f"Error:\n{ev.error}\n\n"
"Please try again. Return ONLY valid JSON matching the schema above, "
"with no markdown formatting or extra text."
)
# Step 2: Format analysis as JSON using LLM completion
format_instructions = self.output_parser.format(
"Please structure the above information in the following JSON format:"
)
json_prompt = STRUCTURED_RESPONSE_PROMPT_TEMPLATE.format(
analysis=analysis,
format_instructions=format_instructions + reflection,
)
# Network retries handled by OpenAILike (max_retries=3)
response = await Settings.llm.acomplete(json_prompt)
return ExtractionDone(output=response.text)
@step
async def validate(
self, ctx: Context, ev: ExtractionDone
) -> StopEvent | ValidationErrorEvent:
"""Validate extracted output against Pydantic schema."""
raw_output = ev.output
retries = await ctx.store.get("retries", default=0)
try:
parsed = self.output_parser.parse(raw_output)
if retries > 1:
logger.info(
f"LLM parse succeeded on attempt {retries}/{self.max_retries} "
f"for {self.output_cls.__name__}"
)
return StopEvent(result={"success": parsed})
except (ValidationError, ValueError) as e:
error_msg = self._format_error(e, raw_output)
await ctx.store.set("last_error", error_msg)
logger.error(
f"LLM parse error (attempt {retries}/{self.max_retries}): "
f"{type(e).__name__}: {e}\nRaw response: {raw_output[:500]}"
)
return ValidationErrorEvent(
error=error_msg,
wrong_output=raw_output,
)
def _format_error(self, error: Exception, raw_output: str) -> str:
"""Format error for LLM feedback."""
if isinstance(error, ValidationError):
error_messages = []
for err in error.errors():
field = ".".join(str(loc) for loc in err["loc"])
error_messages.append(f"- {err['msg']} in field '{field}'")
return "Schema validation errors:\n" + "\n".join(error_messages)
else:
return f"Parse error: {str(error)}"
class LLM:
def __init__(self, settings, temperature: float = 0.4, max_tokens: int = 2048):
self.settings_obj = settings
@@ -30,11 +200,12 @@ class LLM:
self.temperature = temperature
self.max_tokens = max_tokens
# Configure llamaindex Settings
self._configure_llamaindex()
def _configure_llamaindex(self):
"""Configure llamaindex Settings with OpenAILike LLM"""
session_id = llm_session_id.get() or f"fallback-{uuid4().hex}"
Settings.llm = OpenAILike(
model=self.model_name,
api_base=self.url,
@@ -44,6 +215,7 @@ class LLM:
is_function_calling_model=False,
temperature=self.temperature,
max_tokens=self.max_tokens,
additional_kwargs={"extra_body": {"litellm_session_id": session_id}},
)
async def get_response(
@@ -60,44 +232,38 @@ class LLM:
texts: list[str],
output_cls: Type[T],
tone_name: str | None = None,
timeout: int | None = None,
) -> T:
"""Get structured output from LLM for non-function-calling models"""
logger = logging.getLogger(__name__)
"""Get structured output from LLM with validation retry via Workflow."""
if timeout is None:
timeout = self.settings_obj.LLM_STRUCTURED_RESPONSE_TIMEOUT
summarizer = TreeSummarize(verbose=True)
response = await summarizer.aget_response(prompt, texts, tone_name=tone_name)
output_parser = PydanticOutputParser(output_cls)
program = LLMTextCompletionProgram.from_defaults(
output_parser=output_parser,
prompt_template_str=STRUCTURED_RESPONSE_PROMPT_TEMPLATE,
verbose=False,
)
format_instructions = output_parser.format(
"Please structure the above information in the following JSON format:"
)
try:
output = await program.acall(
analysis=str(response), format_instructions=format_instructions
async def run_workflow():
workflow = StructuredOutputWorkflow(
output_cls=output_cls,
max_retries=self.settings_obj.LLM_PARSE_MAX_RETRIES + 1,
timeout=timeout,
)
except ValidationError as e:
# Extract the raw JSON from the error details
errors = e.errors()
if errors and "input" in errors[0]:
raw_json = errors[0]["input"]
logger.error(
f"JSON validation failed for {output_cls.__name__}. "
f"Full raw JSON output:\n{raw_json}\n"
f"Validation errors: {errors}"
)
else:
logger.error(
f"JSON validation failed for {output_cls.__name__}. "
f"Validation errors: {errors}"
)
raise
return output
result = await workflow.run(
prompt=prompt,
texts=texts,
tone_name=tone_name,
)
if "error" in result:
error_msg = result["error"] or "Max retries exceeded"
raise LLMParseError(
output_cls=output_cls,
error_msg=error_msg,
attempts=result.get("attempts", 0),
)
return result["success"]
return await retry(run_workflow)(
retry_attempts=3,
retry_backoff_interval=1.0,
retry_backoff_max=30.0,
retry_ignore_exc_types=(WorkflowTimeoutError,),
)

View File

@@ -309,6 +309,7 @@ class PipelineMainFile(PipelineMainBase):
transcript,
on_long_summary_callback=self.on_long_summary,
on_short_summary_callback=self.on_short_summary,
on_action_items_callback=self.on_action_items,
empty_pipeline=self.empty_pipeline,
logger=self.logger,
)
@@ -340,7 +341,6 @@ async def task_send_webhook_if_needed(*, transcript_id: str):
@asynctask
async def task_pipeline_file_process(*, transcript_id: str):
"""Celery task for file pipeline processing"""
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript:
raise Exception(f"Transcript {transcript_id} not found")

View File

@@ -27,6 +27,7 @@ from reflector.db.recordings import recordings_controller
from reflector.db.rooms import rooms_controller
from reflector.db.transcripts import (
Transcript,
TranscriptActionItems,
TranscriptDuration,
TranscriptFinalLongSummary,
TranscriptFinalShortSummary,
@@ -306,6 +307,23 @@ class PipelineMainBase(PipelineRunner[PipelineMessage], Generic[PipelineMessage]
data=final_short_summary,
)
@broadcast_to_sockets
async def on_action_items(self, data):
action_items = TranscriptActionItems(action_items=data.action_items)
async with self.transaction():
transcript = await self.get_transcript()
await transcripts_controller.update(
transcript,
{
"action_items": action_items.action_items,
},
)
return await transcripts_controller.append_event(
transcript=transcript,
event="ACTION_ITEMS",
data=action_items,
)
@broadcast_to_sockets
async def on_duration(self, data):
async with self.transaction():
@@ -465,6 +483,7 @@ class PipelineMainFinalSummaries(PipelineMainFromTopics):
transcript=self._transcript,
callback=self.on_long_summary,
on_short_summary=self.on_short_summary,
on_action_items=self.on_action_items,
),
]

View File

@@ -9,7 +9,10 @@ from av.audio.resampler import AudioResampler
from celery import chain, shared_task
from reflector.asynctask import asynctask
from reflector.dailyco_api import MeetingParticipantsResponse
from reflector.db.transcripts import (
Transcript,
TranscriptParticipant,
TranscriptStatus,
TranscriptWaveform,
transcripts_controller,
@@ -29,7 +32,12 @@ from reflector.processors.audio_waveform_processor import AudioWaveformProcessor
from reflector.processors.types import TitleSummary
from reflector.processors.types import Transcript as TranscriptType
from reflector.storage import Storage, get_transcripts_storage
from reflector.utils.daily import (
filter_cam_audio_tracks,
parse_daily_recording_filename,
)
from reflector.utils.string import NonEmptyString
from reflector.video_platforms.factory import create_platform_client
# Audio encoding constants
OPUS_STANDARD_SAMPLE_RATE = 48000
@@ -414,7 +422,15 @@ class PipelineMainMultitrack(PipelineMainBase):
# Open all containers with cleanup guaranteed
for i, url in enumerate(valid_track_urls):
try:
c = av.open(url)
c = av.open(
url,
options={
# it's trying to stream from s3 by default
"reconnect": "1",
"reconnect_streamed": "1",
"reconnect_delay_max": "5",
},
)
containers.append(c)
except Exception as e:
self.logger.warning(
@@ -443,6 +459,8 @@ class PipelineMainMultitrack(PipelineMainBase):
frame = next(dec)
except StopIteration:
active[i] = False
# causes stream to move on / unclogs memory
inputs[i].push(None)
continue
if frame.sample_rate != target_sample_rate:
@@ -462,8 +480,6 @@ class PipelineMainMultitrack(PipelineMainBase):
mixed.time_base = Fraction(1, target_sample_rate)
await writer.push(mixed)
for in_ctx in inputs:
in_ctx.push(None)
while True:
try:
mixed = sink.pull()
@@ -494,6 +510,90 @@ class PipelineMainMultitrack(PipelineMainBase):
transcript=transcript, event="WAVEFORM", data=waveform
)
async def update_participants_from_daily(
self, transcript: Transcript, track_keys: list[str]
) -> None:
"""Update transcript participants with user_id and names from Daily.co API."""
if not transcript.recording_id:
return
try:
async with create_platform_client("daily") as daily_client:
id_to_name = {}
id_to_user_id = {}
try:
rec_details = await daily_client.get_recording(
transcript.recording_id
)
mtg_session_id = rec_details.mtgSessionId
if mtg_session_id:
try:
payload: MeetingParticipantsResponse = (
await daily_client.get_meeting_participants(
mtg_session_id
)
)
for p in payload.data:
pid = p.participant_id
name = p.user_name
user_id = p.user_id
if name:
id_to_name[pid] = name
if user_id:
id_to_user_id[pid] = user_id
except Exception as e:
self.logger.warning(
"Failed to fetch Daily meeting participants",
error=str(e),
mtg_session_id=mtg_session_id,
exc_info=True,
)
else:
self.logger.warning(
"No mtgSessionId found for recording; participant names may be generic",
recording_id=transcript.recording_id,
)
except Exception as e:
self.logger.warning(
"Failed to fetch Daily recording details",
error=str(e),
recording_id=transcript.recording_id,
exc_info=True,
)
return
cam_audio_keys = filter_cam_audio_tracks(track_keys)
for idx, key in enumerate(cam_audio_keys):
try:
parsed = parse_daily_recording_filename(key)
participant_id = parsed.participant_id
except ValueError as e:
self.logger.error(
"Failed to parse Daily recording filename",
error=str(e),
key=key,
exc_info=True,
)
continue
default_name = f"Speaker {idx}"
name = id_to_name.get(participant_id, default_name)
user_id = id_to_user_id.get(participant_id)
participant = TranscriptParticipant(
id=participant_id, speaker=idx, name=name, user_id=user_id
)
await transcripts_controller.upsert_participant(
transcript, participant
)
except Exception as e:
self.logger.warning(
"Failed to map participant names", error=str(e), exc_info=True
)
async def process(self, bucket_name: str, track_keys: list[str]):
transcript = await self.get_transcript()
async with self.transaction():
@@ -502,9 +602,12 @@ class PipelineMainMultitrack(PipelineMainBase):
{
"events": [],
"topics": [],
"participants": [],
},
)
await self.update_participants_from_daily(transcript, track_keys)
source_storage = get_transcripts_storage()
transcript_storage = source_storage
@@ -669,6 +772,7 @@ class PipelineMainMultitrack(PipelineMainBase):
transcript,
on_long_summary_callback=self.on_long_summary,
on_short_summary_callback=self.on_short_summary,
on_action_items_callback=self.on_action_items,
empty_pipeline=self.empty_pipeline,
logger=self.logger,
)

View File

@@ -89,6 +89,7 @@ async def generate_summaries(
*,
on_long_summary_callback: Callable,
on_short_summary_callback: Callable,
on_action_items_callback: Callable,
empty_pipeline: EmptyPipeline,
logger: structlog.BoundLogger,
):
@@ -96,11 +97,14 @@ async def generate_summaries(
logger.warning("No topics for summary generation")
return
processor = TranscriptFinalSummaryProcessor(
transcript=transcript,
callback=on_long_summary_callback,
on_short_summary=on_short_summary_callback,
)
processor_kwargs = {
"transcript": transcript,
"callback": on_long_summary_callback,
"on_short_summary": on_short_summary_callback,
"on_action_items": on_action_items_callback,
}
processor = TranscriptFinalSummaryProcessor(**processor_kwargs)
processor.set_pipeline(empty_pipeline)
for topic in topics:

View File

@@ -96,6 +96,36 @@ RECAP_PROMPT = dedent(
"""
).strip()
ACTION_ITEMS_PROMPT = dedent(
"""
Identify action items from this meeting transcript. Your goal is to identify what was decided and what needs to happen next.
Look for:
1. **Decisions Made**: Any decisions, choices, or conclusions reached during the meeting. For each decision:
- What was decided? (be specific)
- Who made the decision or was involved? (use actual participant names)
- Why was this decision made? (key factors, reasoning, or rationale)
2. **Next Steps / Action Items**: Any tasks, follow-ups, or actions that were mentioned or assigned. For each action item:
- What specific task needs to be done? (be concrete and actionable)
- Who is responsible? (use actual participant names if mentioned, or "team" if unclear)
- When is it due? (any deadlines, timeframes, or "by next meeting" type commitments)
- What context is needed? (any additional details that help understand the task)
Guidelines:
- Be thorough and identify all action items, even if they seem minor
- Include items that were agreed upon, assigned, or committed to
- Include decisions even if they seem obvious or implicit
- If someone says "I'll do X" or "We should do Y", that's an action item
- If someone says "Let's go with option A", that's a decision
- Use the exact participant names from the transcript
- If no participant name is mentioned, you can leave assigned_to/decided_by as null
Only return empty lists if the transcript contains NO decisions and NO action items whatsoever.
"""
).strip()
STRUCTURED_RESPONSE_PROMPT_TEMPLATE = dedent(
"""
Based on the following analysis, provide the information in the requested JSON format:
@@ -155,6 +185,53 @@ class SubjectsResponse(BaseModel):
)
class ActionItem(BaseModel):
"""A single action item from the meeting"""
task: str = Field(description="The task or action item to be completed")
assigned_to: str | None = Field(
default=None, description="Person or team assigned to this task (name)"
)
assigned_to_participant_id: str | None = Field(
default=None, description="Participant ID if assigned_to matches a participant"
)
deadline: str | None = Field(
default=None, description="Deadline or timeframe mentioned for this task"
)
context: str | None = Field(
default=None, description="Additional context or notes about this task"
)
class Decision(BaseModel):
"""A decision made during the meeting"""
decision: str = Field(description="What was decided")
rationale: str | None = Field(
default=None,
description="Reasoning or key factors that influenced this decision",
)
decided_by: str | None = Field(
default=None, description="Person or group who made the decision (name)"
)
decided_by_participant_id: str | None = Field(
default=None, description="Participant ID if decided_by matches a participant"
)
class ActionItemsResponse(BaseModel):
"""Pydantic model for identified action items"""
decisions: list[Decision] = Field(
default_factory=list,
description="List of decisions made during the meeting",
)
next_steps: list[ActionItem] = Field(
default_factory=list,
description="List of action items and next steps to be taken",
)
class SummaryBuilder:
def __init__(self, llm: LLM, filename: str | None = None, logger=None) -> None:
self.transcript: str | None = None
@@ -166,6 +243,8 @@ class SummaryBuilder:
self.model_name: str = llm.model_name
self.logger = logger or structlog.get_logger()
self.participant_instructions: str | None = None
self.action_items: ActionItemsResponse | None = None
self.participant_name_to_id: dict[str, str] = {}
if filename:
self.read_transcript_from_file(filename)
@@ -189,13 +268,20 @@ class SummaryBuilder:
self.llm = llm
async def _get_structured_response(
self, prompt: str, output_cls: Type[T], tone_name: str | None = None
self,
prompt: str,
output_cls: Type[T],
tone_name: str | None = None,
timeout: int | None = None,
) -> T:
"""Generic function to get structured output from LLM for non-function-calling models."""
# Add participant instructions to the prompt if available
enhanced_prompt = self._enhance_prompt_with_participants(prompt)
return await self.llm.get_structured_response(
enhanced_prompt, [self.transcript], output_cls, tone_name=tone_name
enhanced_prompt,
[self.transcript],
output_cls,
tone_name=tone_name,
timeout=timeout,
)
async def _get_response(
@@ -216,11 +302,19 @@ class SummaryBuilder:
# Participants
# ----------------------------------------------------------------------------
def set_known_participants(self, participants: list[str]) -> None:
def set_known_participants(
self,
participants: list[str],
participant_name_to_id: dict[str, str] | None = None,
) -> None:
"""
Set known participants directly without LLM identification.
This is used when participants are already identified and stored.
They are appended at the end of the transcript, providing more context for the assistant.
Args:
participants: List of participant names
participant_name_to_id: Optional mapping of participant names to their IDs
"""
if not participants:
self.logger.warning("No participants provided")
@@ -231,10 +325,12 @@ class SummaryBuilder:
participants=participants,
)
if participant_name_to_id:
self.participant_name_to_id = participant_name_to_id
participants_md = self.format_list_md(participants)
self.transcript += f"\n\n# Participants\n\n{participants_md}"
# Set instructions that will be automatically added to all prompts
participants_list = ", ".join(participants)
self.participant_instructions = dedent(
f"""
@@ -413,6 +509,92 @@ class SummaryBuilder:
self.recap = str(recap_response)
self.logger.info(f"Quick recap: {self.recap}")
def _map_participant_names_to_ids(
self, response: ActionItemsResponse
) -> ActionItemsResponse:
"""Map participant names in action items to participant IDs."""
if not self.participant_name_to_id:
return response
decisions = []
for decision in response.decisions:
new_decision = decision.model_copy()
if (
decision.decided_by
and decision.decided_by in self.participant_name_to_id
):
new_decision.decided_by_participant_id = self.participant_name_to_id[
decision.decided_by
]
decisions.append(new_decision)
next_steps = []
for item in response.next_steps:
new_item = item.model_copy()
if item.assigned_to and item.assigned_to in self.participant_name_to_id:
new_item.assigned_to_participant_id = self.participant_name_to_id[
item.assigned_to
]
next_steps.append(new_item)
return ActionItemsResponse(decisions=decisions, next_steps=next_steps)
async def identify_action_items(self) -> ActionItemsResponse | None:
"""Identify action items (decisions and next steps) from the transcript."""
self.logger.info("--- identify action items using TreeSummarize")
if not self.transcript:
self.logger.warning(
"No transcript available for action items identification"
)
self.action_items = None
return None
action_items_prompt = ACTION_ITEMS_PROMPT
try:
response = await self._get_structured_response(
action_items_prompt,
ActionItemsResponse,
tone_name="Action item identifier",
timeout=settings.LLM_STRUCTURED_RESPONSE_TIMEOUT,
)
response = self._map_participant_names_to_ids(response)
self.action_items = response
self.logger.info(
f"Identified {len(response.decisions)} decisions and {len(response.next_steps)} action items",
decisions_count=len(response.decisions),
next_steps_count=len(response.next_steps),
)
if response.decisions:
self.logger.debug(
"Decisions identified",
decisions=[d.decision for d in response.decisions],
)
if response.next_steps:
self.logger.debug(
"Action items identified",
tasks=[item.task for item in response.next_steps],
)
if not response.decisions and not response.next_steps:
self.logger.warning(
"No action items identified from transcript",
transcript_length=len(self.transcript),
)
return response
except Exception as e:
self.logger.error(
f"Error identifying action items: {e}",
exc_info=True,
)
self.action_items = None
return None
async def generate_summary(self, only_subjects: bool = False) -> None:
"""
Generate summary by extracting subjects, creating summaries for each, and generating a recap.
@@ -424,6 +606,7 @@ class SummaryBuilder:
await self.generate_subject_summaries()
await self.generate_recap()
await self.identify_action_items()
# ----------------------------------------------------------------------------
# Markdown
@@ -526,8 +709,6 @@ if __name__ == "__main__":
if args.summary:
await sm.generate_summary()
# Note: action items generation has been removed
print("")
print("-" * 80)
print("")

View File

@@ -1,7 +1,12 @@
from reflector.llm import LLM
from reflector.processors.base import Processor
from reflector.processors.summary.summary_builder import SummaryBuilder
from reflector.processors.types import FinalLongSummary, FinalShortSummary, TitleSummary
from reflector.processors.types import (
ActionItems,
FinalLongSummary,
FinalShortSummary,
TitleSummary,
)
from reflector.settings import settings
@@ -27,15 +32,20 @@ class TranscriptFinalSummaryProcessor(Processor):
builder = SummaryBuilder(self.llm, logger=self.logger)
builder.set_transcript(text)
# Use known participants if available, otherwise identify them
if self.transcript and self.transcript.participants:
# Extract participant names from the stored participants
participant_names = [p.name for p in self.transcript.participants if p.name]
if participant_names:
self.logger.info(
f"Using {len(participant_names)} known participants from transcript"
)
builder.set_known_participants(participant_names)
participant_name_to_id = {
p.name: p.id
for p in self.transcript.participants
if p.name and p.id
}
builder.set_known_participants(
participant_names, participant_name_to_id=participant_name_to_id
)
else:
self.logger.info(
"Participants field exists but is empty, identifying participants"
@@ -63,7 +73,6 @@ class TranscriptFinalSummaryProcessor(Processor):
self.logger.warning("No summary to output")
return
# build the speakermap from the transcript
speakermap = {}
if self.transcript:
speakermap = {
@@ -76,8 +85,6 @@ class TranscriptFinalSummaryProcessor(Processor):
speakermap=speakermap,
)
# build the transcript as a single string
# Replace speaker IDs with actual participant names if available
text_transcript = []
unique_speakers = set()
for topic in self.chunks:
@@ -111,4 +118,9 @@ class TranscriptFinalSummaryProcessor(Processor):
)
await self.emit(final_short_summary, name="short_summary")
if self.builder and self.builder.action_items:
action_items = self.builder.action_items.model_dump()
action_items = ActionItems(action_items=action_items)
await self.emit(action_items, name="action_items")
await self.emit(final_long_summary)

View File

@@ -78,7 +78,11 @@ class TranscriptTopicDetectorProcessor(Processor):
"""
prompt = TOPIC_PROMPT.format(text=text)
response = await self.llm.get_structured_response(
prompt, [text], TopicResponse, tone_name="Topic analyzer"
prompt,
[text],
TopicResponse,
tone_name="Topic analyzer",
timeout=settings.LLM_STRUCTURED_RESPONSE_TIMEOUT,
)
return response

View File

@@ -1,6 +1,7 @@
import io
import re
import tempfile
from collections import defaultdict
from pathlib import Path
from typing import Annotated, TypedDict
@@ -16,6 +17,17 @@ class DiarizationSegment(TypedDict):
PUNC_RE = re.compile(r"[.;:?!…]")
SENTENCE_END_RE = re.compile(r"[.?!…]$")
# Max segment length for words_to_segments() - breaks on any punctuation (. ; : ? ! …)
# when segment exceeds this limit. Used for non-multitrack recordings.
MAX_SEGMENT_CHARS = 120
# Max segment length for words_to_segments_by_sentence() - only breaks on sentence-ending
# punctuation (. ? ! …) when segment exceeds this limit. Higher threshold allows complete
# sentences in multitrack recordings where speakers overlap.
# similar number to server/reflector/processors/transcript_liner.py
MAX_SENTENCE_SEGMENT_CHARS = 1000
class AudioFile(BaseModel):
@@ -76,7 +88,6 @@ def words_to_segments(words: list[Word]) -> list[TranscriptSegment]:
# but separate if the speaker changes, or if the punctuation is a . , ; : ? !
segments = []
current_segment = None
MAX_SEGMENT_LENGTH = 120
for word in words:
if current_segment is None:
@@ -106,7 +117,7 @@ def words_to_segments(words: list[Word]) -> list[TranscriptSegment]:
current_segment.end = word.end
have_punc = PUNC_RE.search(word.text)
if have_punc and (len(current_segment.text) > MAX_SEGMENT_LENGTH):
if have_punc and (len(current_segment.text) > MAX_SEGMENT_CHARS):
segments.append(current_segment)
current_segment = None
@@ -116,6 +127,70 @@ def words_to_segments(words: list[Word]) -> list[TranscriptSegment]:
return segments
def words_to_segments_by_sentence(words: list[Word]) -> list[TranscriptSegment]:
"""Group words by speaker, then split into sentences.
For multitrack recordings where words from different speakers are interleaved
by timestamp, this function first groups all words by speaker, then creates
segments based on sentence boundaries within each speaker's words.
This produces cleaner output than words_to_segments() which breaks on every
speaker change, resulting in many tiny segments when speakers overlap.
"""
if not words:
return []
# Group words by speaker, preserving order within each speaker
by_speaker: dict[int, list[Word]] = defaultdict(list)
for w in words:
by_speaker[w.speaker].append(w)
segments: list[TranscriptSegment] = []
for speaker, speaker_words in by_speaker.items():
current_text = ""
current_start: float | None = None
current_end: float = 0.0
for word in speaker_words:
if current_start is None:
current_start = word.start
current_text += word.text
current_end = word.end
# Check for sentence end or max length
is_sentence_end = SENTENCE_END_RE.search(word.text.strip())
is_too_long = len(current_text) >= MAX_SENTENCE_SEGMENT_CHARS
if is_sentence_end or is_too_long:
segments.append(
TranscriptSegment(
text=current_text,
start=current_start,
end=current_end,
speaker=speaker,
)
)
current_text = ""
current_start = None
# Flush remaining words for this speaker
if current_text and current_start is not None:
segments.append(
TranscriptSegment(
text=current_text,
start=current_start,
end=current_end,
speaker=speaker,
)
)
# Sort segments by start time
segments.sort(key=lambda s: s.start)
return segments
class Transcript(BaseModel):
translation: str | None = None
words: list[Word] = []
@@ -154,7 +229,9 @@ class Transcript(BaseModel):
word.start += offset
word.end += offset
def as_segments(self) -> list[TranscriptSegment]:
def as_segments(self, is_multitrack: bool = False) -> list[TranscriptSegment]:
if is_multitrack:
return words_to_segments_by_sentence(self.words)
return words_to_segments(self.words)
@@ -187,6 +264,10 @@ class FinalShortSummary(BaseModel):
duration: float
class ActionItems(BaseModel):
action_items: dict # JSON-serializable dict from ActionItemsResponse
class FinalTitle(BaseModel):
title: str

View File

@@ -0,0 +1,17 @@
"""Schema definitions for transcript format types and segments."""
from typing import Literal
from pydantic import BaseModel
TranscriptFormat = Literal["text", "text-timestamped", "webvtt-named", "json"]
class TranscriptSegment(BaseModel):
"""A single transcript segment with speaker and timing information."""
speaker: int
speaker_name: str
text: str
start: float
end: float

View File

@@ -0,0 +1,171 @@
"""
Transcript processing service - shared logic for HTTP endpoints and Celery tasks.
This module provides result-based error handling that works in both contexts:
- HTTP endpoint: converts errors to HTTPException
- Celery task: converts errors to Exception
"""
from dataclasses import dataclass
from typing import Literal, Union, assert_never
import celery
from celery.result import AsyncResult
from reflector.db.recordings import recordings_controller
from reflector.db.transcripts import Transcript
from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
from reflector.pipelines.main_multitrack_pipeline import (
task_pipeline_multitrack_process,
)
from reflector.utils.string import NonEmptyString
@dataclass
class ProcessError:
detail: NonEmptyString
@dataclass
class FileProcessingConfig:
transcript_id: NonEmptyString
mode: Literal["file"] = "file"
@dataclass
class MultitrackProcessingConfig:
transcript_id: NonEmptyString
bucket_name: NonEmptyString
track_keys: list[str]
mode: Literal["multitrack"] = "multitrack"
ProcessingConfig = Union[FileProcessingConfig, MultitrackProcessingConfig]
PrepareResult = Union[ProcessingConfig, ProcessError]
@dataclass
class ValidationOk:
# transcript currently doesnt always have recording_id
recording_id: NonEmptyString | None
transcript_id: NonEmptyString
@dataclass
class ValidationLocked:
detail: NonEmptyString
@dataclass
class ValidationNotReady:
detail: NonEmptyString
@dataclass
class ValidationAlreadyScheduled:
detail: NonEmptyString
ValidationError = Union[
ValidationNotReady, ValidationLocked, ValidationAlreadyScheduled
]
ValidationResult = Union[ValidationOk, ValidationError]
@dataclass
class DispatchOk:
status: Literal["ok"] = "ok"
@dataclass
class DispatchAlreadyRunning:
status: Literal["already_running"] = "already_running"
DispatchResult = Union[
DispatchOk, DispatchAlreadyRunning, ProcessError, ValidationError
]
async def validate_transcript_for_processing(
transcript: Transcript,
) -> ValidationResult:
if transcript.locked:
return ValidationLocked(detail="Recording is locked")
if transcript.status == "idle":
return ValidationNotReady(detail="Recording is not ready for processing")
if task_is_scheduled_or_active(
"reflector.pipelines.main_file_pipeline.task_pipeline_file_process",
transcript_id=transcript.id,
) or task_is_scheduled_or_active(
"reflector.pipelines.main_multitrack_pipeline.task_pipeline_multitrack_process",
transcript_id=transcript.id,
):
return ValidationAlreadyScheduled(detail="already running")
return ValidationOk(
recording_id=transcript.recording_id, transcript_id=transcript.id
)
async def prepare_transcript_processing(validation: ValidationOk) -> PrepareResult:
"""
Determine processing mode from transcript/recording data.
"""
bucket_name: str | None = None
track_keys: list[str] | None = None
if validation.recording_id:
recording = await recordings_controller.get_by_id(validation.recording_id)
if recording:
bucket_name = recording.bucket_name
track_keys = recording.track_keys
if track_keys is not None and len(track_keys) == 0:
return ProcessError(
detail="No track keys found, must be either > 0 or None",
)
if track_keys is not None and not bucket_name:
return ProcessError(
detail="Bucket name must be specified",
)
if track_keys:
return MultitrackProcessingConfig(
bucket_name=bucket_name, # type: ignore (validated above)
track_keys=track_keys,
transcript_id=validation.transcript_id,
)
return FileProcessingConfig(
transcript_id=validation.transcript_id,
)
def dispatch_transcript_processing(config: ProcessingConfig) -> AsyncResult:
if isinstance(config, MultitrackProcessingConfig):
return task_pipeline_multitrack_process.delay(
transcript_id=config.transcript_id,
bucket_name=config.bucket_name,
track_keys=config.track_keys,
)
elif isinstance(config, FileProcessingConfig):
return task_pipeline_file_process.delay(transcript_id=config.transcript_id)
else:
assert_never(config)
def task_is_scheduled_or_active(task_name: str, **kwargs):
inspect = celery.current_app.control.inspect()
scheduled = inspect.scheduled() or {}
active = inspect.active() or {}
all = scheduled | active
for worker, tasks in all.items():
for task in tasks:
if task["name"] == task_name and task["kwargs"] == kwargs:
return True
return False

View File

@@ -74,6 +74,13 @@ class Settings(BaseSettings):
LLM_API_KEY: str | None = None
LLM_CONTEXT_WINDOW: int = 16000
LLM_PARSE_MAX_RETRIES: int = (
3 # Max retries for JSON/validation errors (total attempts = retries + 1)
)
LLM_STRUCTURED_RESPONSE_TIMEOUT: int = (
300 # Timeout in seconds for structured responses (5 minutes)
)
# Diarization
DIARIZATION_ENABLED: bool = True
DIARIZATION_BACKEND: str = "modal"

View File

@@ -0,0 +1,127 @@
"""
Process transcript by ID - auto-detects multitrack vs file pipeline.
Usage:
uv run -m reflector.tools.process_transcript <transcript_id>
# Or via docker:
docker compose exec server uv run -m reflector.tools.process_transcript <transcript_id>
"""
import argparse
import asyncio
import sys
import time
from typing import Callable
from celery.result import AsyncResult
from reflector.db.transcripts import Transcript, transcripts_controller
from reflector.services.transcript_process import (
FileProcessingConfig,
MultitrackProcessingConfig,
PrepareResult,
ProcessError,
ValidationError,
ValidationResult,
dispatch_transcript_processing,
prepare_transcript_processing,
validate_transcript_for_processing,
)
async def process_transcript_inner(
transcript: Transcript,
on_validation: Callable[[ValidationResult], None],
on_preprocess: Callable[[PrepareResult], None],
) -> AsyncResult:
validation = await validate_transcript_for_processing(transcript)
on_validation(validation)
config = await prepare_transcript_processing(validation)
on_preprocess(config)
return dispatch_transcript_processing(config)
async def process_transcript(transcript_id: str, sync: bool = False) -> None:
"""
Process a transcript by ID, auto-detecting multitrack vs file pipeline.
Args:
transcript_id: The transcript UUID
sync: If True, wait for task completion. If False, dispatch and exit.
"""
from reflector.db import get_database
database = get_database()
await database.connect()
try:
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript:
print(f"Error: Transcript {transcript_id} not found", file=sys.stderr)
sys.exit(1)
print(f"Found transcript: {transcript.title or transcript_id}", file=sys.stderr)
print(f" Status: {transcript.status}", file=sys.stderr)
print(f" Recording ID: {transcript.recording_id or 'None'}", file=sys.stderr)
def on_validation(validation: ValidationResult) -> None:
if isinstance(validation, ValidationError):
print(f"Error: {validation.detail}", file=sys.stderr)
sys.exit(1)
def on_preprocess(config: PrepareResult) -> None:
if isinstance(config, ProcessError):
print(f"Error: {config.detail}", file=sys.stderr)
sys.exit(1)
elif isinstance(config, MultitrackProcessingConfig):
print(f"Dispatching multitrack pipeline", file=sys.stderr)
print(f" Bucket: {config.bucket_name}", file=sys.stderr)
print(f" Tracks: {len(config.track_keys)}", file=sys.stderr)
elif isinstance(config, FileProcessingConfig):
print(f"Dispatching file pipeline", file=sys.stderr)
result = await process_transcript_inner(
transcript, on_validation=on_validation, on_preprocess=on_preprocess
)
if sync:
print("Waiting for task completion...", file=sys.stderr)
while not result.ready():
print(f" Status: {result.state}", file=sys.stderr)
time.sleep(5)
if result.successful():
print("Task completed successfully", file=sys.stderr)
else:
print(f"Task failed: {result.result}", file=sys.stderr)
sys.exit(1)
else:
print(
"Task dispatched (use --sync to wait for completion)", file=sys.stderr
)
finally:
await database.disconnect()
def main():
parser = argparse.ArgumentParser(
description="Process transcript by ID - auto-detects multitrack vs file pipeline"
)
parser.add_argument(
"transcript_id",
help="Transcript UUID to process",
)
parser.add_argument(
"--sync",
action="store_true",
help="Wait for task completion instead of just dispatching",
)
args = parser.parse_args()
asyncio.run(process_transcript(args.transcript_id, sync=args.sync))
if __name__ == "__main__":
main()

View File

@@ -64,6 +64,11 @@ def recording_lock_key(recording_id: NonEmptyString) -> NonEmptyString:
return f"recording:{recording_id}"
def filter_cam_audio_tracks(track_keys: list[str]) -> list[str]:
"""Filter track keys to cam-audio tracks only (skip screen-audio, etc.)."""
return [k for k in track_keys if "cam-audio" in k]
def extract_base_room_name(daily_room_name: DailyRoomName) -> NonEmptyString:
"""
Extract base room name from Daily.co timestamped room name.

View File

@@ -0,0 +1,133 @@
"""Utilities for converting transcript data to various output formats."""
import webvtt
from reflector.db.transcripts import TranscriptParticipant, TranscriptTopic
from reflector.processors.types import (
Transcript as ProcessorTranscript,
)
from reflector.schemas.transcript_formats import TranscriptSegment
from reflector.utils.webvtt import seconds_to_timestamp
def get_speaker_name(
speaker: int, participants: list[TranscriptParticipant] | None
) -> str:
"""Get participant name for speaker or default to 'Speaker N'."""
if participants:
for participant in participants:
if participant.speaker == speaker:
return participant.name
return f"Speaker {speaker}"
def format_timestamp_mmss(seconds: float | int) -> str:
"""Format seconds as MM:SS timestamp."""
minutes = int(seconds // 60)
secs = int(seconds % 60)
return f"{minutes:02d}:{secs:02d}"
def transcript_to_text(
topics: list[TranscriptTopic],
participants: list[TranscriptParticipant] | None,
is_multitrack: bool = False,
) -> str:
"""Convert transcript topics to plain text with speaker names."""
lines = []
for topic in topics:
if not topic.words:
continue
transcript = ProcessorTranscript(words=topic.words)
segments = transcript.as_segments(is_multitrack)
for segment in segments:
speaker_name = get_speaker_name(segment.speaker, participants)
text = segment.text.strip()
lines.append(f"{speaker_name}: {text}")
return "\n".join(lines)
def transcript_to_text_timestamped(
topics: list[TranscriptTopic],
participants: list[TranscriptParticipant] | None,
is_multitrack: bool = False,
) -> str:
"""Convert transcript topics to timestamped text with speaker names."""
lines = []
for topic in topics:
if not topic.words:
continue
transcript = ProcessorTranscript(words=topic.words)
segments = transcript.as_segments(is_multitrack)
for segment in segments:
speaker_name = get_speaker_name(segment.speaker, participants)
timestamp = format_timestamp_mmss(segment.start)
text = segment.text.strip()
lines.append(f"[{timestamp}] {speaker_name}: {text}")
return "\n".join(lines)
def topics_to_webvtt_named(
topics: list[TranscriptTopic],
participants: list[TranscriptParticipant] | None,
is_multitrack: bool = False,
) -> str:
"""Convert transcript topics to WebVTT format with participant names."""
vtt = webvtt.WebVTT()
for topic in topics:
if not topic.words:
continue
transcript = ProcessorTranscript(words=topic.words)
segments = transcript.as_segments(is_multitrack)
for segment in segments:
speaker_name = get_speaker_name(segment.speaker, participants)
text = segment.text.strip()
text = f"<v {speaker_name}>{text}"
caption = webvtt.Caption(
start=seconds_to_timestamp(segment.start),
end=seconds_to_timestamp(segment.end),
text=text,
)
vtt.captions.append(caption)
return vtt.content
def transcript_to_json_segments(
topics: list[TranscriptTopic],
participants: list[TranscriptParticipant] | None,
is_multitrack: bool = False,
) -> list[TranscriptSegment]:
"""Convert transcript topics to a flat list of JSON segments."""
result = []
for topic in topics:
if not topic.words:
continue
transcript = ProcessorTranscript(words=topic.words)
segments = transcript.as_segments(is_multitrack)
for segment in segments:
speaker_name = get_speaker_name(segment.speaker, participants)
result.append(
TranscriptSegment(
speaker=segment.speaker,
speaker_name=speaker_name,
text=segment.text.strip(),
start=segment.start,
end=segment.end,
)
)
return result

View File

@@ -13,7 +13,7 @@ VttTimestamp = Annotated[str, "vtt_timestamp"]
WebVTTStr = Annotated[str, "webvtt_str"]
def _seconds_to_timestamp(seconds: Seconds) -> VttTimestamp:
def seconds_to_timestamp(seconds: Seconds) -> VttTimestamp:
# lib doesn't do that
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
@@ -37,8 +37,8 @@ def words_to_webvtt(words: list[Word]) -> WebVTTStr:
text = f"<v Speaker{segment.speaker}>{text}"
caption = webvtt.Caption(
start=_seconds_to_timestamp(segment.start),
end=_seconds_to_timestamp(segment.end),
start=seconds_to_timestamp(segment.start),
end=seconds_to_timestamp(segment.end),
text=text,
)
vtt.captions.append(caption)

View File

@@ -31,6 +31,7 @@ class DailyClient(VideoPlatformClient):
PLATFORM_NAME: Platform = "daily"
TIMESTAMP_FORMAT = "%Y%m%d%H%M%S"
RECORDING_NONE: RecordingType = "none"
RECORDING_LOCAL: RecordingType = "local"
RECORDING_CLOUD: RecordingType = "cloud"
def __init__(self, config: VideoPlatformConfig):
@@ -54,19 +55,23 @@ class DailyClient(VideoPlatformClient):
timestamp = datetime.now().strftime(self.TIMESTAMP_FORMAT)
room_name = f"{room_name_prefix}{ROOM_PREFIX_SEPARATOR}{timestamp}"
enable_recording = None
if room.recording_type == self.RECORDING_LOCAL:
enable_recording = "local"
elif room.recording_type == self.RECORDING_CLOUD:
enable_recording = "raw-tracks"
properties = RoomProperties(
enable_recording="raw-tracks"
if room.recording_type != self.RECORDING_NONE
else False,
enable_recording=enable_recording,
enable_chat=True,
enable_screenshare=True,
enable_knocking=room.is_locked,
start_video_off=False,
start_audio_off=False,
exp=int(end_date.timestamp()),
)
# Only configure recordings_bucket if recording is enabled
if room.recording_type != self.RECORDING_NONE:
if room.recording_type == self.RECORDING_CLOUD:
daily_storage = get_dailyco_storage()
assert daily_storage.bucket_name, "S3 bucket must be configured"
properties.recordings_bucket = RecordingsBucketConfig(
@@ -172,16 +177,18 @@ class DailyClient(VideoPlatformClient):
async def create_meeting_token(
self,
room_name: DailyRoomName,
enable_recording: bool,
user_id: str | None = None,
) -> str:
start_cloud_recording: bool,
enable_recording_ui: bool,
user_id: NonEmptyString | None = None,
is_owner: bool = False,
) -> NonEmptyString:
properties = MeetingTokenProperties(
room_name=room_name,
user_id=user_id,
start_cloud_recording=enable_recording,
enable_recording_ui=not enable_recording,
start_cloud_recording=start_cloud_recording,
enable_recording_ui=enable_recording_ui,
is_owner=is_owner,
)
request = CreateMeetingTokenRequest(properties=properties)
result = await self._api_client.create_meeting_token(request)
return result.token

View File

@@ -89,7 +89,7 @@ class CreateRoom(BaseModel):
ics_url: Optional[str] = None
ics_fetch_interval: int = 300
ics_enabled: bool = False
platform: Optional[Platform] = None
platform: Platform
class UpdateRoom(BaseModel):
@@ -310,6 +310,22 @@ async def rooms_create_meeting(
room=room, current_time=current_time
)
if meeting is not None:
settings_match = (
meeting.is_locked == room.is_locked
and meeting.room_mode == room.room_mode
and meeting.recording_type == room.recording_type
and meeting.recording_trigger == room.recording_trigger
and meeting.platform == room.platform
)
if not settings_match:
logger.info(
f"Room settings changed for {room_name}, creating new meeting",
room_id=room.id,
old_meeting_id=meeting.id,
)
meeting = None
if meeting is None:
end_date = current_time + timedelta(hours=8)
@@ -337,19 +353,7 @@ async def rooms_create_meeting(
status_code=503, detail="Meeting creation in progress, please try again"
)
if meeting.platform == "daily" and room.recording_trigger != "none":
client = create_platform_client(meeting.platform)
token = await client.create_meeting_token(
meeting.room_name,
enable_recording=True,
user_id=user_id,
)
meeting = meeting.model_copy()
meeting.room_url = add_query_param(meeting.room_url, "t", token)
if meeting.host_room_url:
meeting.host_room_url = add_query_param(meeting.host_room_url, "t", token)
if user_id != room.user_id:
if user_id != room.user_id and meeting.platform == "whereby":
meeting.host_room_url = ""
return meeting
@@ -508,7 +512,8 @@ async def rooms_list_active_meetings(
if user_id != room.user_id:
for meeting in meetings:
meeting.host_room_url = ""
if meeting.platform == "whereby":
meeting.host_room_url = ""
return meetings
@@ -530,7 +535,7 @@ async def rooms_get_meeting(
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
if user_id != room.user_id and not room.is_shared:
if user_id != room.user_id and not room.is_shared and meeting.platform == "whereby":
meeting.host_room_url = ""
return meeting
@@ -560,7 +565,16 @@ async def rooms_join_meeting(
if meeting.end_date <= current_time:
raise HTTPException(status_code=400, detail="Meeting has ended")
if user_id != room.user_id:
meeting.host_room_url = ""
if meeting.platform == "daily" and user_id is not None:
client = create_platform_client(meeting.platform)
token = await client.create_meeting_token(
meeting.room_name,
start_cloud_recording=meeting.recording_type == "cloud",
enable_recording_ui=meeting.recording_type == "local",
user_id=user_id,
is_owner=user_id == room.user_id,
)
meeting = meeting.model_copy()
meeting.room_url = add_query_param(meeting.room_url, "t", token)
return meeting

View File

@@ -1,14 +1,23 @@
from datetime import datetime, timedelta, timezone
from typing import Annotated, Literal, Optional
from typing import Annotated, Literal, Optional, assert_never
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi_pagination import Page
from fastapi_pagination.ext.databases import apaginate
from jose import jwt
from pydantic import AwareDatetime, BaseModel, Field, constr, field_serializer
from pydantic import (
AwareDatetime,
BaseModel,
Discriminator,
Field,
constr,
field_serializer,
)
import reflector.auth as auth
from reflector.db import get_database
from reflector.db.recordings import recordings_controller
from reflector.db.rooms import rooms_controller
from reflector.db.search import (
DEFAULT_SEARCH_LIMIT,
SearchLimit,
@@ -29,9 +38,17 @@ from reflector.db.transcripts import (
TranscriptTopic,
transcripts_controller,
)
from reflector.db.users import user_controller
from reflector.processors.types import Transcript as ProcessorTranscript
from reflector.processors.types import Word
from reflector.schemas.transcript_formats import TranscriptFormat, TranscriptSegment
from reflector.settings import settings
from reflector.utils.transcript_formats import (
topics_to_webvtt_named,
transcript_to_json_segments,
transcript_to_text,
transcript_to_text_timestamped,
)
from reflector.ws_manager import get_ws_manager
from reflector.zulip import (
InvalidMessageError,
@@ -46,6 +63,14 @@ ALGORITHM = "HS256"
DOWNLOAD_EXPIRE_MINUTES = 60
async def _get_is_multitrack(transcript) -> bool:
"""Detect if transcript is from multitrack recording."""
if not transcript.recording_id:
return False
recording = await recordings_controller.get_by_id(transcript.recording_id)
return recording is not None and recording.is_multitrack
def create_access_token(data: dict, expires_delta: timedelta):
to_encode = data.copy()
expire = datetime.now(timezone.utc) + expires_delta
@@ -88,8 +113,86 @@ class GetTranscriptMinimal(BaseModel):
audio_deleted: bool | None = None
class GetTranscript(GetTranscriptMinimal):
participants: list[TranscriptParticipant] | None
class TranscriptParticipantWithEmail(TranscriptParticipant):
email: str | None = None
class GetTranscriptWithParticipants(GetTranscriptMinimal):
participants: list[TranscriptParticipantWithEmail] | None
class GetTranscriptWithText(GetTranscriptWithParticipants):
"""
Transcript response with plain text format.
Format: Speaker names followed by their dialogue, one line per segment.
Example:
John Smith: Hello everyone
Jane Doe: Hi there
"""
transcript_format: Literal["text"] = "text"
transcript: str
class GetTranscriptWithTextTimestamped(GetTranscriptWithParticipants):
"""
Transcript response with timestamped text format.
Format: [MM:SS] timestamp prefix before each speaker and dialogue.
Example:
[00:00] John Smith: Hello everyone
[00:05] Jane Doe: Hi there
"""
transcript_format: Literal["text-timestamped"] = "text-timestamped"
transcript: str
class GetTranscriptWithWebVTTNamed(GetTranscriptWithParticipants):
"""
Transcript response in WebVTT subtitle format with participant names.
Format: Standard WebVTT with voice tags using participant names.
Example:
WEBVTT
00:00:00.000 --> 00:00:05.000
<v John Smith>Hello everyone
"""
transcript_format: Literal["webvtt-named"] = "webvtt-named"
transcript: str
class GetTranscriptWithJSON(GetTranscriptWithParticipants):
"""
Transcript response as structured JSON segments.
Format: Array of segment objects with speaker info, text, and timing.
Example:
[
{
"speaker": 0,
"speaker_name": "John Smith",
"text": "Hello everyone",
"start": 0.0,
"end": 5.0
}
]
"""
transcript_format: Literal["json"] = "json"
transcript: list[TranscriptSegment]
GetTranscript = Annotated[
GetTranscriptWithText
| GetTranscriptWithTextTimestamped
| GetTranscriptWithWebVTTNamed
| GetTranscriptWithJSON,
Discriminator("transcript_format"),
]
class CreateTranscript(BaseModel):
@@ -228,7 +331,7 @@ async def transcripts_search(
)
@router.post("/transcripts", response_model=GetTranscript)
@router.post("/transcripts", response_model=GetTranscriptWithParticipants)
async def transcripts_create(
info: CreateTranscript,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
@@ -272,7 +375,7 @@ class GetTranscriptTopic(BaseModel):
segments: list[GetTranscriptSegmentTopic] = []
@classmethod
def from_transcript_topic(cls, topic: TranscriptTopic):
def from_transcript_topic(cls, topic: TranscriptTopic, is_multitrack: bool = False):
if not topic.words:
# In previous version, words were missing
# Just output a segment with speaker 0
@@ -296,7 +399,7 @@ class GetTranscriptTopic(BaseModel):
start=segment.start,
speaker=segment.speaker,
)
for segment in transcript.as_segments()
for segment in transcript.as_segments(is_multitrack)
]
return cls(
id=topic.id,
@@ -313,8 +416,8 @@ class GetTranscriptTopicWithWords(GetTranscriptTopic):
words: list[Word] = []
@classmethod
def from_transcript_topic(cls, topic: TranscriptTopic):
instance = super().from_transcript_topic(topic)
def from_transcript_topic(cls, topic: TranscriptTopic, is_multitrack: bool = False):
instance = super().from_transcript_topic(topic, is_multitrack)
if topic.words:
instance.words = topic.words
return instance
@@ -329,8 +432,8 @@ class GetTranscriptTopicWithWordsPerSpeaker(GetTranscriptTopic):
words_per_speaker: list[SpeakerWords] = []
@classmethod
def from_transcript_topic(cls, topic: TranscriptTopic):
instance = super().from_transcript_topic(topic)
def from_transcript_topic(cls, topic: TranscriptTopic, is_multitrack: bool = False):
instance = super().from_transcript_topic(topic, is_multitrack)
if topic.words:
words_per_speakers = []
# group words by speaker
@@ -362,14 +465,95 @@ class GetTranscriptTopicWithWordsPerSpeaker(GetTranscriptTopic):
async def transcript_get(
transcript_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
transcript_format: TranscriptFormat = "text",
):
user_id = user["sub"] if user else None
return await transcripts_controller.get_by_id_for_http(
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
is_multitrack = await _get_is_multitrack(transcript)
@router.patch("/transcripts/{transcript_id}", response_model=GetTranscript)
room_name = None
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
room_name = room.name if room else None
participants = []
if transcript.participants:
user_ids = [p.user_id for p in transcript.participants if p.user_id is not None]
users_dict = await user_controller.get_by_ids(user_ids) if user_ids else {}
for p in transcript.participants:
user = users_dict.get(p.user_id) if p.user_id else None
participants.append(
TranscriptParticipantWithEmail(
**p.model_dump(), email=user.email if user else None
)
)
base_data = {
"id": transcript.id,
"user_id": transcript.user_id,
"name": transcript.name,
"status": transcript.status,
"locked": transcript.locked,
"duration": transcript.duration,
"title": transcript.title,
"short_summary": transcript.short_summary,
"long_summary": transcript.long_summary,
"action_items": transcript.action_items,
"created_at": transcript.created_at,
"share_mode": transcript.share_mode,
"source_language": transcript.source_language,
"target_language": transcript.target_language,
"reviewed": transcript.reviewed,
"meeting_id": transcript.meeting_id,
"source_kind": transcript.source_kind,
"room_id": transcript.room_id,
"room_name": room_name,
"audio_deleted": transcript.audio_deleted,
"participants": participants,
}
if transcript_format == "text":
return GetTranscriptWithText(
**base_data,
transcript_format="text",
transcript=transcript_to_text(
transcript.topics, transcript.participants, is_multitrack
),
)
elif transcript_format == "text-timestamped":
return GetTranscriptWithTextTimestamped(
**base_data,
transcript_format="text-timestamped",
transcript=transcript_to_text_timestamped(
transcript.topics, transcript.participants, is_multitrack
),
)
elif transcript_format == "webvtt-named":
return GetTranscriptWithWebVTTNamed(
**base_data,
transcript_format="webvtt-named",
transcript=topics_to_webvtt_named(
transcript.topics, transcript.participants, is_multitrack
),
)
elif transcript_format == "json":
return GetTranscriptWithJSON(
**base_data,
transcript_format="json",
transcript=transcript_to_json_segments(
transcript.topics, transcript.participants, is_multitrack
),
)
else:
assert_never(transcript_format)
@router.patch(
"/transcripts/{transcript_id}", response_model=GetTranscriptWithParticipants
)
async def transcript_update(
transcript_id: str,
info: UpdateTranscript,
@@ -419,9 +603,12 @@ async def transcript_get_topics(
transcript_id, user_id=user_id
)
is_multitrack = await _get_is_multitrack(transcript)
# convert to GetTranscriptTopic
return [
GetTranscriptTopic.from_transcript_topic(topic) for topic in transcript.topics
GetTranscriptTopic.from_transcript_topic(topic, is_multitrack)
for topic in transcript.topics
]
@@ -438,9 +625,11 @@ async def transcript_get_topics_with_words(
transcript_id, user_id=user_id
)
is_multitrack = await _get_is_multitrack(transcript)
# convert to GetTranscriptTopicWithWords
return [
GetTranscriptTopicWithWords.from_transcript_topic(topic)
GetTranscriptTopicWithWords.from_transcript_topic(topic, is_multitrack)
for topic in transcript.topics
]
@@ -459,13 +648,17 @@ async def transcript_get_topics_with_words_per_speaker(
transcript_id, user_id=user_id
)
is_multitrack = await _get_is_multitrack(transcript)
# get the topic from the transcript
topic = next((t for t in transcript.topics if t.id == topic_id), None)
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
# convert to GetTranscriptTopicWithWordsPerSpeaker
return GetTranscriptTopicWithWordsPerSpeaker.from_transcript_topic(topic)
return GetTranscriptTopicWithWordsPerSpeaker.from_transcript_topic(
topic, is_multitrack
)
@router.post("/transcripts/{transcript_id}/zulip")

View File

@@ -1,15 +1,19 @@
from typing import Annotated, Optional
from typing import Annotated, Optional, assert_never
import celery
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import reflector.auth as auth
from reflector.db.recordings import recordings_controller
from reflector.db.transcripts import transcripts_controller
from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
from reflector.pipelines.main_multitrack_pipeline import (
task_pipeline_multitrack_process,
from reflector.services.transcript_process import (
ProcessError,
ValidationAlreadyScheduled,
ValidationError,
ValidationLocked,
ValidationOk,
dispatch_transcript_processing,
prepare_transcript_processing,
validate_transcript_for_processing,
)
router = APIRouter()
@@ -23,68 +27,28 @@ class ProcessStatus(BaseModel):
async def transcript_process(
transcript_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
) -> ProcessStatus:
user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.locked:
raise HTTPException(status_code=400, detail="Transcript is locked")
if transcript.status == "idle":
raise HTTPException(
status_code=400, detail="Recording is not ready for processing"
)
# avoid duplicate scheduling for either pipeline
if task_is_scheduled_or_active(
"reflector.pipelines.main_file_pipeline.task_pipeline_file_process",
transcript_id=transcript_id,
) or task_is_scheduled_or_active(
"reflector.pipelines.main_multitrack_pipeline.task_pipeline_multitrack_process",
transcript_id=transcript_id,
):
return ProcessStatus(status="already running")
# Determine processing mode strictly from DB to avoid S3 scans
bucket_name = None
track_keys: list[str] = []
if transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
if recording:
bucket_name = recording.bucket_name
track_keys = recording.track_keys
if track_keys is not None and len(track_keys) == 0:
raise HTTPException(
status_code=500,
detail="No track keys found, must be either > 0 or None",
)
if track_keys is not None and not bucket_name:
raise HTTPException(
status_code=500, detail="Bucket name must be specified"
)
if track_keys:
task_pipeline_multitrack_process.delay(
transcript_id=transcript_id,
bucket_name=bucket_name,
track_keys=track_keys,
)
validation = await validate_transcript_for_processing(transcript)
if isinstance(validation, ValidationLocked):
raise HTTPException(status_code=400, detail=validation.detail)
elif isinstance(validation, ValidationError):
raise HTTPException(status_code=400, detail=validation.detail)
elif isinstance(validation, ValidationAlreadyScheduled):
return ProcessStatus(status=validation.detail)
elif isinstance(validation, ValidationOk):
pass
else:
# Default single-file pipeline
task_pipeline_file_process.delay(transcript_id=transcript_id)
assert_never(validation)
return ProcessStatus(status="ok")
config = await prepare_transcript_processing(validation)
def task_is_scheduled_or_active(task_name: str, **kwargs):
inspect = celery.current_app.control.inspect()
for worker, tasks in (inspect.scheduled() | inspect.active()).items():
for task in tasks:
if task["name"] == task_name and task["kwargs"] == kwargs:
return True
return False
if isinstance(config, ProcessError):
raise HTTPException(status_code=500, detail=config.detail)
else:
dispatch_transcript_processing(config)
return ProcessStatus(status="ok")

View File

@@ -3,6 +3,7 @@ from typing import Optional
from fastapi import APIRouter, WebSocket
from reflector.auth.auth_jwt import JWTAuth # type: ignore
from reflector.db.users import user_controller
from reflector.ws_manager import get_ws_manager
router = APIRouter()
@@ -29,7 +30,18 @@ async def user_events_websocket(websocket: WebSocket):
try:
payload = JWTAuth().verify_token(token)
user_id = payload.get("sub")
authentik_uid = payload.get("sub")
if authentik_uid:
user = await user_controller.get_by_authentik_uid(authentik_uid)
if user:
user_id = user.id
else:
await websocket.close(code=UNAUTHORISED)
return
else:
await websocket.close(code=UNAUTHORISED)
return
except Exception:
await websocket.close(code=UNAUTHORISED)
return

View File

@@ -38,6 +38,10 @@ else:
"task": "reflector.worker.process.reprocess_failed_recordings",
"schedule": crontab(hour=5, minute=0), # Midnight EST
},
"reprocess_failed_daily_recordings": {
"task": "reflector.worker.process.reprocess_failed_daily_recordings",
"schedule": crontab(hour=5, minute=0), # Midnight EST
},
"poll_daily_recordings": {
"task": "reflector.worker.process.poll_daily_recordings",
"schedule": 180.0, # Every 3 minutes (configurable lookback window)

View File

@@ -2,6 +2,7 @@ import json
import os
import re
from datetime import datetime, timezone
from typing import List
from urllib.parse import unquote
import av
@@ -11,7 +12,7 @@ from celery import shared_task
from celery.utils.log import get_task_logger
from pydantic import ValidationError
from reflector.dailyco_api import MeetingParticipantsResponse
from reflector.dailyco_api import FinishedRecordingResponse, RecordingResponse
from reflector.db.daily_participant_sessions import (
DailyParticipantSession,
daily_participant_sessions_controller,
@@ -21,7 +22,6 @@ from reflector.db.recordings import Recording, recordings_controller
from reflector.db.rooms import rooms_controller
from reflector.db.transcripts import (
SourceKind,
TranscriptParticipant,
transcripts_controller,
)
from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
@@ -38,7 +38,7 @@ from reflector.storage import get_transcripts_storage
from reflector.utils.daily import (
DailyRoomName,
extract_base_room_name,
parse_daily_recording_filename,
filter_cam_audio_tracks,
recording_lock_key,
)
from reflector.video_platforms.factory import create_platform_client
@@ -273,15 +273,7 @@ async def _process_multitrack_recording_inner(
# else: Recording already exists; metadata set at creation time
transcript = await transcripts_controller.get_by_recording_id(recording.id)
if transcript:
await transcripts_controller.update(
transcript,
{
"topics": [],
"participants": [],
},
)
else:
if not transcript:
transcript = await transcripts_controller.add(
"",
source_kind=SourceKind.ROOM,
@@ -294,79 +286,10 @@ async def _process_multitrack_recording_inner(
room_id=room.id,
)
try:
async with create_platform_client("daily") as daily_client:
id_to_name = {}
id_to_user_id = {}
try:
rec_details = await daily_client.get_recording(recording_id)
mtg_session_id = rec_details.mtgSessionId
if mtg_session_id:
try:
payload: MeetingParticipantsResponse = (
await daily_client.get_meeting_participants(mtg_session_id)
)
for p in payload.data:
pid = p.participant_id
assert (
pid is not None
), "panic! participant id cannot be None"
name = p.user_name
user_id = p.user_id
if name:
id_to_name[pid] = name
if user_id:
id_to_user_id[pid] = user_id
except Exception as e:
logger.warning(
"Failed to fetch Daily meeting participants",
error=str(e),
mtg_session_id=mtg_session_id,
exc_info=True,
)
else:
logger.warning(
"No mtgSessionId found for recording; participant names may be generic",
recording_id=recording_id,
)
except Exception as e:
logger.warning(
"Failed to fetch Daily recording details",
error=str(e),
recording_id=recording_id,
exc_info=True,
)
for idx, key in enumerate(track_keys):
try:
parsed = parse_daily_recording_filename(key)
participant_id = parsed.participant_id
except ValueError as e:
logger.error(
"Failed to parse Daily recording filename",
error=str(e),
key=key,
exc_info=True,
)
continue
default_name = f"Speaker {idx}"
name = id_to_name.get(participant_id, default_name)
user_id = id_to_user_id.get(participant_id)
participant = TranscriptParticipant(
id=participant_id, speaker=idx, name=name, user_id=user_id
)
await transcripts_controller.upsert_participant(transcript, participant)
except Exception as e:
logger.warning("Failed to map participant names", error=str(e), exc_info=True)
task_pipeline_multitrack_process.delay(
transcript_id=transcript.id,
bucket_name=bucket_name,
track_keys=track_keys,
track_keys=filter_cam_audio_tracks(track_keys),
)
@@ -391,7 +314,7 @@ async def poll_daily_recordings():
async with create_platform_client("daily") as daily_client:
# latest 100. TODO cursor-based state
api_recordings = await daily_client.list_recordings()
api_recordings: List[RecordingResponse] = await daily_client.list_recordings()
if not api_recordings:
logger.debug(
@@ -399,16 +322,38 @@ async def poll_daily_recordings():
)
return
recording_ids = [rec.id for rec in api_recordings]
finished_recordings: List[FinishedRecordingResponse] = []
for rec in api_recordings:
finished = rec.to_finished()
if finished is None:
logger.debug(
"Skipping unfinished recording",
recording_id=rec.id,
room_name=rec.room_name,
status=rec.status,
)
continue
finished_recordings.append(finished)
if not finished_recordings:
logger.debug(
"No finished recordings found from Daily.co API",
total_api_count=len(api_recordings),
)
return
recording_ids = [rec.id for rec in finished_recordings]
existing_recordings = await recordings_controller.get_by_ids(recording_ids)
existing_ids = {rec.id for rec in existing_recordings}
missing_recordings = [rec for rec in api_recordings if rec.id not in existing_ids]
missing_recordings = [
rec for rec in finished_recordings if rec.id not in existing_ids
]
if not missing_recordings:
logger.debug(
"All recordings already in DB",
api_count=len(api_recordings),
api_count=len(finished_recordings),
existing_count=len(existing_recordings),
)
return
@@ -416,23 +361,25 @@ async def poll_daily_recordings():
logger.info(
"Found recordings missing from DB",
missing_count=len(missing_recordings),
total_api_count=len(api_recordings),
total_api_count=len(finished_recordings),
existing_count=len(existing_recordings),
)
for recording in missing_recordings:
if not recording.tracks:
assert recording.status != "finished", (
f"Recording {recording.id} has status='finished' but no tracks. "
f"Daily.co API guarantees finished recordings have tracks available. "
f"room_name={recording.room_name}"
)
logger.debug(
"No tracks in recording yet",
recording_id=recording.id,
room_name=recording.room_name,
status=recording.status,
)
if recording.status == "finished":
logger.warning(
"Finished recording has no tracks (no audio captured)",
recording_id=recording.id,
room_name=recording.room_name,
)
else:
logger.debug(
"No tracks in recording yet",
recording_id=recording.id,
room_name=recording.room_name,
status=recording.status,
)
continue
track_keys = [t.s3Key for t in recording.tracks if t.type == "audio"]
@@ -724,7 +671,7 @@ async def reprocess_failed_recordings():
Find recordings in Whereby S3 bucket and check if they have proper transcriptions.
If not, requeue them for processing.
Note: Daily.co recordings are processed via webhooks, not this cron job.
Note: Daily.co multitrack recordings are handled by reprocess_failed_daily_recordings.
"""
logger.info("Checking Whereby recordings that need processing or reprocessing")
@@ -777,6 +724,103 @@ async def reprocess_failed_recordings():
return reprocessed_count
@shared_task
@asynctask
async def reprocess_failed_daily_recordings():
"""
Find Daily.co multitrack recordings in the database and check if they have proper transcriptions.
If not, requeue them for processing.
"""
logger.info(
"Checking Daily.co multitrack recordings that need processing or reprocessing"
)
if not settings.DAILYCO_STORAGE_AWS_BUCKET_NAME:
logger.debug(
"DAILYCO_STORAGE_AWS_BUCKET_NAME not configured; skipping Daily recording reprocessing"
)
return 0
bucket_name = settings.DAILYCO_STORAGE_AWS_BUCKET_NAME
reprocessed_count = 0
try:
multitrack_recordings = (
await recordings_controller.get_multitrack_needing_reprocessing(bucket_name)
)
logger.info(
"Found multitrack recordings needing reprocessing",
count=len(multitrack_recordings),
bucket=bucket_name,
)
for recording in multitrack_recordings:
if not recording.meeting_id:
logger.debug(
"Skipping recording without meeting_id",
recording_id=recording.id,
)
continue
meeting = await meetings_controller.get_by_id(recording.meeting_id)
if not meeting:
logger.warning(
"Meeting not found for recording",
recording_id=recording.id,
meeting_id=recording.meeting_id,
)
continue
transcript = None
try:
transcript = await transcripts_controller.get_by_recording_id(
recording.id
)
except ValidationError:
await transcripts_controller.remove_by_recording_id(recording.id)
logger.warning(
"Removed invalid transcript for recording",
recording_id=recording.id,
)
if not recording.track_keys:
logger.warning(
"Recording has no track_keys, cannot reprocess",
recording_id=recording.id,
)
continue
logger.info(
"Queueing Daily recording for reprocessing",
recording_id=recording.id,
room_name=meeting.room_name,
track_count=len(recording.track_keys),
transcript_status=transcript.status if transcript else None,
)
process_multitrack_recording.delay(
bucket_name=bucket_name,
daily_room_name=meeting.room_name,
recording_id=recording.id,
track_keys=recording.track_keys,
)
reprocessed_count += 1
except Exception as e:
logger.error(
"Error checking Daily multitrack recordings",
error=str(e),
exc_info=True,
)
logger.info(
"Daily reprocessing complete",
requeued_count=reprocessed_count,
)
return reprocessed_count
@shared_task
@asynctask
async def trigger_daily_reconciliation() -> None:

View File

@@ -123,6 +123,7 @@ async def send_transcript_webhook(
"target_language": transcript.target_language,
"status": transcript.status,
"frontend_url": frontend_url,
"action_items": transcript.action_items,
},
"room": {
"id": room.id,

View File

@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
Manual Migration Script: Migrate user_id from Authentik UID to internal user.id
This script should be run manually AFTER applying the database schema migrations.
Usage:
AUTHENTIK_API_URL=https://your-authentik-url \
AUTHENTIK_API_TOKEN=your-token \
DATABASE_URL=postgresql://... \
python scripts/migrate_user_ids.py
What this script does:
1. Collects all unique Authentik UIDs currently used in the database
2. Fetches only those users from Authentik API to populate the users table
3. Updates user_id in: user_api_key, transcript, room, meeting_consent
4. Uses user.authentik_uid to lookup the corresponding user.id
The script is idempotent:
- User inserts use ON CONFLICT DO NOTHING (safe if users already exist)
- Update queries only match authentik_uid->uuid pairs (no-op if already migrated)
- Safe to run multiple times without side effects
Prerequisites:
- AUTHENTIK_API_URL environment variable must be set
- AUTHENTIK_API_TOKEN environment variable must be set
- DATABASE_URL environment variable must be set
- Authentik API must be accessible
"""
import asyncio
import os
import sys
from datetime import datetime, timezone
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
TABLES_WITH_USER_ID = ["user_api_key", "transcript", "room", "meeting_consent"]
NULLABLE_USER_ID_TABLES = {"transcript", "meeting_consent"}
AUTHENTIK_PAGE_SIZE = 100
HTTP_TIMEOUT = 30.0
class AuthentikClient:
def __init__(self, api_url: str, api_token: str):
self.api_url = api_url
self.api_token = api_token
def _get_headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self.api_token}",
"Accept": "application/json",
}
async def fetch_all_users(self) -> list[dict[str, Any]]:
all_users = []
page = 1
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
while True:
url = f"{self.api_url}/api/v3/core/users/"
params = {
"page": page,
"page_size": AUTHENTIK_PAGE_SIZE,
"include_groups": "false",
}
print(f" Fetching users from Authentik (page {page})...")
response = await client.get(
url, headers=self._get_headers(), params=params
)
response.raise_for_status()
data = response.json()
results = data.get("results", [])
if not results:
break
all_users.extend(results)
print(f" Fetched {len(results)} users from page {page}")
if not data.get("next"):
break
page += 1
print(f" Total: {len(all_users)} users fetched from Authentik")
return all_users
except httpx.HTTPError as e:
raise Exception(f"Failed to fetch users from Authentik: {e}") from e
async def collect_used_authentik_uids(connection: AsyncConnection) -> set[str]:
print("\nStep 1: Collecting Authentik UIDs from database tables...")
used_authentik_uids = set()
for table in TABLES_WITH_USER_ID:
result = await connection.execute(
text(f'SELECT DISTINCT user_id FROM "{table}" WHERE user_id IS NOT NULL')
)
authentik_uids = [row[0] for row in result.fetchall()]
used_authentik_uids.update(authentik_uids)
print(f" Found {len(authentik_uids)} unique Authentik UIDs in {table}")
print(f" Total unique user IDs found: {len(used_authentik_uids)}")
if used_authentik_uids:
sample_id = next(iter(used_authentik_uids))
if len(sample_id) == 36 and sample_id.count("-") == 4:
print(
f"\n✅ User IDs are already in UUID format (e.g., {sample_id[:20]}...)"
)
print("Migration has already been completed!")
return set()
return used_authentik_uids
def filter_users_by_authentik_uid(
authentik_users: list[dict[str, Any]], used_authentik_uids: set[str]
) -> tuple[list[dict[str, Any]], set[str]]:
used_authentik_users = [
user for user in authentik_users if user.get("uid") in used_authentik_uids
]
missing_ids = used_authentik_uids - {u.get("uid") for u in used_authentik_users}
print(
f" Found {len(used_authentik_users)} matching users in Authentik "
f"(out of {len(authentik_users)} total)"
)
if missing_ids:
print(
f" ⚠ Warning: {len(missing_ids)} Authentik UIDs in database not found in Authentik:"
)
for user_id in sorted(missing_ids):
print(f" - {user_id}")
return used_authentik_users, missing_ids
async def sync_users_to_database(
connection: AsyncConnection, authentik_users: list[dict[str, Any]]
) -> tuple[int, int]:
created = 0
skipped = 0
now = datetime.now(timezone.utc)
for authentik_user in authentik_users:
user_id = authentik_user["uuid"]
authentik_uid = authentik_user["uid"]
email = authentik_user.get("email")
if not email:
print(f" ⚠ Skipping user {authentik_uid} (no email)")
skipped += 1
continue
result = await connection.execute(
text("""
INSERT INTO "user" (id, email, authentik_uid, created_at, updated_at)
VALUES (:id, :email, :authentik_uid, :created_at, :updated_at)
ON CONFLICT (id) DO NOTHING
"""),
{
"id": user_id,
"email": email,
"authentik_uid": authentik_uid,
"created_at": now,
"updated_at": now,
},
)
if result.rowcount > 0:
created += 1
return created, skipped
async def migrate_all_user_ids(connection: AsyncConnection) -> int:
print("\nStep 3: Migrating user_id columns from Authentik UID to internal UUID...")
print("(If no rows are updated, migration may have already been completed)")
total_updated = 0
for table in TABLES_WITH_USER_ID:
null_check = (
f"AND {table}.user_id IS NOT NULL"
if table in NULLABLE_USER_ID_TABLES
else ""
)
query = f"""
UPDATE {table}
SET user_id = u.id
FROM "user" u
WHERE {table}.user_id = u.authentik_uid
{null_check}
"""
print(f" Updating {table}.user_id...")
result = await connection.execute(text(query))
rows = result.rowcount
print(f" ✓ Updated {rows} rows")
total_updated += rows
return total_updated
async def run_migration(
database_url: str, authentik_api_url: str, authentik_api_token: str
) -> None:
engine = create_async_engine(database_url)
try:
async with engine.begin() as connection:
used_authentik_uids = await collect_used_authentik_uids(connection)
if not used_authentik_uids:
print("\n⚠️ No user IDs found in database. Nothing to migrate.")
print("Migration complete (no-op)!")
return
print("\nStep 2: Fetching user data from Authentik and syncing users...")
print("(This script is idempotent - safe to run multiple times)")
print(f"Authentik API URL: {authentik_api_url}")
client = AuthentikClient(authentik_api_url, authentik_api_token)
authentik_users = await client.fetch_all_users()
if not authentik_users:
print("\nERROR: No users returned from Authentik API.")
print(
"Please verify your Authentik configuration and ensure users exist."
)
sys.exit(1)
used_authentik_users, _ = filter_users_by_authentik_uid(
authentik_users, used_authentik_uids
)
created, skipped = await sync_users_to_database(
connection, used_authentik_users
)
if created > 0:
print(f"✓ Created {created} users from Authentik")
else:
print("✓ No new users created (users may already exist)")
if skipped > 0:
print(f" ⚠ Skipped {skipped} users without email")
result = await connection.execute(text('SELECT COUNT(*) FROM "user"'))
user_count = result.scalar()
print(f"✓ Users table now has {user_count} users")
total_updated = await migrate_all_user_ids(connection)
if total_updated > 0:
print(f"\n✅ Migration complete! Updated {total_updated} total rows.")
else:
print(
"\n✅ Migration complete! (No rows updated - migration may have already been completed)"
)
except Exception as e:
print(f"\n❌ ERROR: Migration failed: {e}")
sys.exit(1)
finally:
await engine.dispose()
async def main() -> None:
database_url = os.getenv("DATABASE_URL")
authentik_api_url = os.getenv("AUTHENTIK_API_URL")
authentik_api_token = os.getenv("AUTHENTIK_API_TOKEN")
if not database_url or not authentik_api_url or not authentik_api_token:
print(
"ERROR: DATABASE_URL, AUTHENTIK_API_URL, and AUTHENTIK_API_TOKEN must be set"
)
sys.exit(1)
await run_migration(database_url, authentik_api_url, authentik_api_token)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -318,6 +318,14 @@ async def dummy_storage():
yield
@pytest.fixture
def test_settings():
"""Provide isolated settings for tests to avoid modifying global settings"""
from reflector.settings import Settings
return Settings()
@pytest.fixture(scope="session")
def celery_enable_logging():
return True

View File

@@ -0,0 +1,488 @@
"""Tests for LLM parse error recovery using llama-index Workflow"""
from time import monotonic
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel, Field
from workflows.errors import WorkflowRuntimeError, WorkflowTimeoutError
from reflector.llm import LLM, LLMParseError, StructuredOutputWorkflow
from reflector.utils.retry import RetryException
class TestResponse(BaseModel):
"""Test response model for structured output"""
title: str = Field(description="A title")
summary: str = Field(description="A summary")
confidence: float = Field(description="Confidence score", ge=0, le=1)
def make_completion_response(text: str):
"""Create a mock CompletionResponse with .text attribute"""
response = MagicMock()
response.text = text
return response
class TestLLMParseErrorRecovery:
"""Test parse error recovery with Workflow feedback loop"""
@pytest.mark.asyncio
async def test_parse_error_recovery_with_feedback(self, test_settings):
"""Test that parse errors trigger retry with error feedback"""
llm = LLM(settings=test_settings, temperature=0.4, max_tokens=100)
with (
patch("reflector.llm.TreeSummarize") as mock_summarize,
patch("reflector.llm.Settings") as mock_settings,
):
mock_summarizer = MagicMock()
mock_summarize.return_value = mock_summarizer
# TreeSummarize returns plain text analysis (step 1)
mock_summarizer.aget_response = AsyncMock(
return_value="The analysis shows a test with summary and high confidence."
)
call_count = {"count": 0}
async def acomplete_handler(prompt, *args, **kwargs):
call_count["count"] += 1
if call_count["count"] == 1:
# First JSON formatting call returns invalid JSON
return make_completion_response('{"title": "Test"}')
else:
# Second call should have error feedback in prompt
assert "Your previous response could not be parsed:" in prompt
assert '{"title": "Test"}' in prompt
assert "Error:" in prompt
assert "Please try again" in prompt
return make_completion_response(
'{"title": "Test", "summary": "Summary", "confidence": 0.95}'
)
mock_settings.llm.acomplete = AsyncMock(side_effect=acomplete_handler)
result = await llm.get_structured_response(
prompt="Test prompt", texts=["Test text"], output_cls=TestResponse
)
assert result.title == "Test"
assert result.summary == "Summary"
assert result.confidence == 0.95
# TreeSummarize called once, Settings.llm.acomplete called twice
assert mock_summarizer.aget_response.call_count == 1
assert call_count["count"] == 2
@pytest.mark.asyncio
async def test_max_parse_retry_attempts(self, test_settings):
"""Test that parse error retry stops after max attempts"""
llm = LLM(settings=test_settings, temperature=0.4, max_tokens=100)
with (
patch("reflector.llm.TreeSummarize") as mock_summarize,
patch("reflector.llm.Settings") as mock_settings,
):
mock_summarizer = MagicMock()
mock_summarize.return_value = mock_summarizer
mock_summarizer.aget_response = AsyncMock(return_value="Some analysis")
# Always return invalid JSON from acomplete
mock_settings.llm.acomplete = AsyncMock(
return_value=make_completion_response(
'{"invalid": "missing required fields"}'
)
)
with pytest.raises(LLMParseError, match="Failed to parse"):
await llm.get_structured_response(
prompt="Test prompt", texts=["Test text"], output_cls=TestResponse
)
expected_attempts = test_settings.LLM_PARSE_MAX_RETRIES + 1
# TreeSummarize called once, acomplete called max_retries times
assert mock_summarizer.aget_response.call_count == 1
assert mock_settings.llm.acomplete.call_count == expected_attempts
@pytest.mark.asyncio
async def test_raw_response_logging_on_parse_error(self, test_settings, caplog):
"""Test that raw response is logged when parse error occurs"""
llm = LLM(settings=test_settings, temperature=0.4, max_tokens=100)
with (
patch("reflector.llm.TreeSummarize") as mock_summarize,
patch("reflector.llm.Settings") as mock_settings,
caplog.at_level("ERROR"),
):
mock_summarizer = MagicMock()
mock_summarize.return_value = mock_summarizer
mock_summarizer.aget_response = AsyncMock(return_value="Some analysis")
call_count = {"count": 0}
async def acomplete_handler(*args, **kwargs):
call_count["count"] += 1
if call_count["count"] == 1:
return make_completion_response('{"title": "Test"}') # Invalid
return make_completion_response(
'{"title": "Test", "summary": "Summary", "confidence": 0.95}'
)
mock_settings.llm.acomplete = AsyncMock(side_effect=acomplete_handler)
result = await llm.get_structured_response(
prompt="Test prompt", texts=["Test text"], output_cls=TestResponse
)
assert result.title == "Test"
error_logs = [r for r in caplog.records if r.levelname == "ERROR"]
raw_response_logged = any("Raw response:" in r.message for r in error_logs)
assert raw_response_logged, "Raw response should be logged on parse error"
@pytest.mark.asyncio
async def test_multiple_validation_errors_in_feedback(self, test_settings):
"""Test that validation errors are included in feedback"""
llm = LLM(settings=test_settings, temperature=0.4, max_tokens=100)
with (
patch("reflector.llm.TreeSummarize") as mock_summarize,
patch("reflector.llm.Settings") as mock_settings,
):
mock_summarizer = MagicMock()
mock_summarize.return_value = mock_summarizer
mock_summarizer.aget_response = AsyncMock(return_value="Some analysis")
call_count = {"count": 0}
async def acomplete_handler(prompt, *args, **kwargs):
call_count["count"] += 1
if call_count["count"] == 1:
# Missing title and summary
return make_completion_response('{"confidence": 0.5}')
else:
# Should have schema validation errors in prompt
assert (
"Schema validation errors" in prompt
or "error" in prompt.lower()
)
return make_completion_response(
'{"title": "Test", "summary": "Summary", "confidence": 0.95}'
)
mock_settings.llm.acomplete = AsyncMock(side_effect=acomplete_handler)
result = await llm.get_structured_response(
prompt="Test prompt", texts=["Test text"], output_cls=TestResponse
)
assert result.title == "Test"
assert call_count["count"] == 2
@pytest.mark.asyncio
async def test_success_on_first_attempt(self, test_settings):
"""Test that no retry happens when first attempt succeeds"""
llm = LLM(settings=test_settings, temperature=0.4, max_tokens=100)
with (
patch("reflector.llm.TreeSummarize") as mock_summarize,
patch("reflector.llm.Settings") as mock_settings,
):
mock_summarizer = MagicMock()
mock_summarize.return_value = mock_summarizer
mock_summarizer.aget_response = AsyncMock(return_value="Some analysis")
mock_settings.llm.acomplete = AsyncMock(
return_value=make_completion_response(
'{"title": "Test", "summary": "Summary", "confidence": 0.95}'
)
)
result = await llm.get_structured_response(
prompt="Test prompt", texts=["Test text"], output_cls=TestResponse
)
assert result.title == "Test"
assert result.summary == "Summary"
assert result.confidence == 0.95
assert mock_summarizer.aget_response.call_count == 1
assert mock_settings.llm.acomplete.call_count == 1
class TestStructuredOutputWorkflow:
"""Direct tests for the StructuredOutputWorkflow"""
@pytest.mark.asyncio
async def test_workflow_retries_on_validation_error(self):
"""Test workflow retries when validation fails"""
workflow = StructuredOutputWorkflow(
output_cls=TestResponse,
max_retries=3,
timeout=30,
)
with (
patch("reflector.llm.TreeSummarize") as mock_summarize,
patch("reflector.llm.Settings") as mock_settings,
):
mock_summarizer = MagicMock()
mock_summarize.return_value = mock_summarizer
mock_summarizer.aget_response = AsyncMock(return_value="Some analysis")
call_count = {"count": 0}
async def acomplete_handler(*args, **kwargs):
call_count["count"] += 1
if call_count["count"] < 2:
return make_completion_response('{"title": "Only title"}')
return make_completion_response(
'{"title": "Test", "summary": "Summary", "confidence": 0.9}'
)
mock_settings.llm.acomplete = AsyncMock(side_effect=acomplete_handler)
result = await workflow.run(
prompt="Extract data",
texts=["Some text"],
tone_name=None,
)
assert "success" in result
assert result["success"].title == "Test"
assert call_count["count"] == 2
@pytest.mark.asyncio
async def test_workflow_returns_error_after_max_retries(self):
"""Test workflow returns error after exhausting retries"""
workflow = StructuredOutputWorkflow(
output_cls=TestResponse,
max_retries=2,
timeout=30,
)
with (
patch("reflector.llm.TreeSummarize") as mock_summarize,
patch("reflector.llm.Settings") as mock_settings,
):
mock_summarizer = MagicMock()
mock_summarize.return_value = mock_summarizer
mock_summarizer.aget_response = AsyncMock(return_value="Some analysis")
# Always return invalid JSON
mock_settings.llm.acomplete = AsyncMock(
return_value=make_completion_response('{"invalid": true}')
)
result = await workflow.run(
prompt="Extract data",
texts=["Some text"],
tone_name=None,
)
assert "error" in result
# TreeSummarize called once, acomplete called max_retries times
assert mock_summarizer.aget_response.call_count == 1
assert mock_settings.llm.acomplete.call_count == 2
class TestNetworkErrorRetries:
"""Test that network error retries are handled by OpenAILike, not Workflow"""
@pytest.mark.asyncio
async def test_network_error_propagates_after_openai_retries(self, test_settings):
"""Test that network errors are retried by OpenAILike and then propagate.
Network retries are handled by OpenAILike (max_retries=3), not by our
StructuredOutputWorkflow. This test verifies that network errors propagate
up after OpenAILike exhausts its retries.
"""
llm = LLM(settings=test_settings, temperature=0.4, max_tokens=100)
with (
patch("reflector.llm.TreeSummarize") as mock_summarize,
patch("reflector.llm.Settings") as mock_settings,
):
mock_summarizer = MagicMock()
mock_summarize.return_value = mock_summarizer
mock_summarizer.aget_response = AsyncMock(return_value="Some analysis")
# Simulate network error from acomplete (after OpenAILike retries exhausted)
network_error = ConnectionError("Connection refused")
mock_settings.llm.acomplete = AsyncMock(side_effect=network_error)
# Network error wrapped in WorkflowRuntimeError
with pytest.raises(WorkflowRuntimeError, match="Connection refused"):
await llm.get_structured_response(
prompt="Test prompt", texts=["Test text"], output_cls=TestResponse
)
# acomplete called only once - network error propagates, not retried by Workflow
assert mock_settings.llm.acomplete.call_count == 1
@pytest.mark.asyncio
async def test_network_error_not_retried_by_workflow(self, test_settings):
"""Test that Workflow does NOT retry network errors (OpenAILike handles those).
This verifies the separation of concerns:
- StructuredOutputWorkflow: retries parse/validation errors
- OpenAILike: retries network errors (internally, max_retries=3)
"""
workflow = StructuredOutputWorkflow(
output_cls=TestResponse,
max_retries=3,
timeout=30,
)
with (
patch("reflector.llm.TreeSummarize") as mock_summarize,
patch("reflector.llm.Settings") as mock_settings,
):
mock_summarizer = MagicMock()
mock_summarize.return_value = mock_summarizer
mock_summarizer.aget_response = AsyncMock(return_value="Some analysis")
# Network error should propagate immediately, not trigger Workflow retry
mock_settings.llm.acomplete = AsyncMock(
side_effect=TimeoutError("Request timed out")
)
# Network error wrapped in WorkflowRuntimeError
with pytest.raises(WorkflowRuntimeError, match="Request timed out"):
await workflow.run(
prompt="Extract data",
texts=["Some text"],
tone_name=None,
)
# Only called once - Workflow doesn't retry network errors
assert mock_settings.llm.acomplete.call_count == 1
class TestWorkflowTimeoutRetry:
"""Test timeout retry mechanism in get_structured_response"""
@pytest.mark.asyncio
async def test_timeout_retry_succeeds_on_retry(self, test_settings):
"""Test that WorkflowTimeoutError triggers retry and succeeds"""
llm = LLM(settings=test_settings, temperature=0.4, max_tokens=100)
call_count = {"count": 0}
async def workflow_run_side_effect(*args, **kwargs):
call_count["count"] += 1
if call_count["count"] == 1:
raise WorkflowTimeoutError("Operation timed out after 120 seconds")
return {
"success": TestResponse(
title="Test", summary="Summary", confidence=0.95
)
}
with (
patch("reflector.llm.StructuredOutputWorkflow") as mock_workflow_class,
patch("reflector.llm.TreeSummarize") as mock_summarize,
patch("reflector.llm.Settings") as mock_settings,
):
mock_workflow = MagicMock()
mock_workflow.run = AsyncMock(side_effect=workflow_run_side_effect)
mock_workflow_class.return_value = mock_workflow
mock_summarizer = MagicMock()
mock_summarize.return_value = mock_summarizer
mock_summarizer.aget_response = AsyncMock(return_value="Some analysis")
mock_settings.llm.acomplete = AsyncMock(
return_value=make_completion_response(
'{"title": "Test", "summary": "Summary", "confidence": 0.95}'
)
)
result = await llm.get_structured_response(
prompt="Test prompt", texts=["Test text"], output_cls=TestResponse
)
assert result.title == "Test"
assert result.summary == "Summary"
assert call_count["count"] == 2
@pytest.mark.asyncio
async def test_timeout_retry_exhausts_after_max_attempts(self, test_settings):
"""Test that timeout retry stops after max attempts"""
llm = LLM(settings=test_settings, temperature=0.4, max_tokens=100)
call_count = {"count": 0}
async def workflow_run_side_effect(*args, **kwargs):
call_count["count"] += 1
raise WorkflowTimeoutError("Operation timed out after 120 seconds")
with (
patch("reflector.llm.StructuredOutputWorkflow") as mock_workflow_class,
patch("reflector.llm.TreeSummarize") as mock_summarize,
patch("reflector.llm.Settings") as mock_settings,
):
mock_workflow = MagicMock()
mock_workflow.run = AsyncMock(side_effect=workflow_run_side_effect)
mock_workflow_class.return_value = mock_workflow
mock_summarizer = MagicMock()
mock_summarize.return_value = mock_summarizer
mock_summarizer.aget_response = AsyncMock(return_value="Some analysis")
mock_settings.llm.acomplete = AsyncMock(
return_value=make_completion_response(
'{"title": "Test", "summary": "Summary", "confidence": 0.95}'
)
)
with pytest.raises(RetryException, match="Retry attempts exceeded"):
await llm.get_structured_response(
prompt="Test prompt", texts=["Test text"], output_cls=TestResponse
)
assert call_count["count"] == 3
@pytest.mark.asyncio
async def test_timeout_retry_with_backoff(self, test_settings):
"""Test that exponential backoff is applied between retries"""
llm = LLM(settings=test_settings, temperature=0.4, max_tokens=100)
call_times = []
async def workflow_run_side_effect(*args, **kwargs):
call_times.append(monotonic())
if len(call_times) < 3:
raise WorkflowTimeoutError("Operation timed out after 120 seconds")
return {
"success": TestResponse(
title="Test", summary="Summary", confidence=0.95
)
}
with (
patch("reflector.llm.StructuredOutputWorkflow") as mock_workflow_class,
patch("reflector.llm.TreeSummarize") as mock_summarize,
patch("reflector.llm.Settings") as mock_settings,
):
mock_workflow = MagicMock()
mock_workflow.run = AsyncMock(side_effect=workflow_run_side_effect)
mock_workflow_class.return_value = mock_workflow
mock_summarizer = MagicMock()
mock_summarize.return_value = mock_summarizer
mock_summarizer.aget_response = AsyncMock(return_value="Some analysis")
mock_settings.llm.acomplete = AsyncMock(
return_value=make_completion_response(
'{"title": "Test", "summary": "Summary", "confidence": 0.95}'
)
)
result = await llm.get_structured_response(
prompt="Test prompt", texts=["Test text"], output_cls=TestResponse
)
assert result.title == "Test"
if len(call_times) >= 2:
time_between_calls = call_times[1] - call_times[0]
assert (
time_between_calls >= 1.5
), f"Expected ~2s backoff, got {time_between_calls}s"

View File

@@ -266,7 +266,11 @@ async def mock_summary_processor():
# When flush is called, simulate summary generation by calling the callbacks
async def flush_with_callback():
mock_summary.flush_called = True
from reflector.processors.types import FinalLongSummary, FinalShortSummary
from reflector.processors.types import (
ActionItems,
FinalLongSummary,
FinalShortSummary,
)
if hasattr(mock_summary, "_callback"):
await mock_summary._callback(
@@ -276,12 +280,19 @@ async def mock_summary_processor():
await mock_summary._on_short_summary(
FinalShortSummary(short_summary="Test short summary", duration=10.0)
)
if hasattr(mock_summary, "_on_action_items"):
await mock_summary._on_action_items(
ActionItems(action_items={"test": "action item"})
)
mock_summary.flush = flush_with_callback
def init_with_callback(transcript=None, callback=None, on_short_summary=None):
def init_with_callback(
transcript=None, callback=None, on_short_summary=None, on_action_items=None
):
mock_summary._callback = callback
mock_summary._on_short_summary = on_short_summary
mock_summary._on_action_items = on_action_items
return mock_summary
mock_summary_class.side_effect = init_with_callback

View File

@@ -159,3 +159,78 @@ def test_processor_transcript_segment():
assert segments[3].start == 30.72
assert segments[4].start == 31.56
assert segments[5].start == 32.38
def test_processor_transcript_segment_multitrack_interleaved():
"""Test as_segments(is_multitrack=True) with interleaved speakers.
Multitrack recordings have words from different speakers sorted by start time,
causing frequent speaker alternation. The multitrack mode should group by
speaker first, then split into sentences.
"""
from reflector.processors.types import Transcript, Word
# Simulate real multitrack data: words sorted by start time, speakers interleave
# Speaker 0 says: "Hello there."
# Speaker 1 says: "I'm good."
# When sorted by time, words interleave
transcript = Transcript(
words=[
Word(text="Hello ", start=0.0, end=0.5, speaker=0),
Word(text="I'm ", start=0.5, end=0.8, speaker=1),
Word(text="there.", start=0.5, end=1.0, speaker=0),
Word(text="good.", start=1.0, end=1.5, speaker=1),
]
)
# Default behavior (is_multitrack=False): breaks on every speaker change = 4 segments
segments_default = transcript.as_segments(is_multitrack=False)
assert len(segments_default) == 4
# Multitrack behavior: groups by speaker, then sentences = 2 segments
segments_multitrack = transcript.as_segments(is_multitrack=True)
assert len(segments_multitrack) == 2
# Check content - sorted by start time
assert segments_multitrack[0].speaker == 0
assert segments_multitrack[0].text == "Hello there."
assert segments_multitrack[0].start == 0.0
assert segments_multitrack[0].end == 1.0
assert segments_multitrack[1].speaker == 1
assert segments_multitrack[1].text == "I'm good."
assert segments_multitrack[1].start == 0.5
assert segments_multitrack[1].end == 1.5
def test_processor_transcript_segment_multitrack_overlapping_timestamps():
"""Test multitrack with exactly overlapping timestamps (real Daily.co data pattern)."""
from reflector.processors.types import Transcript, Word
# Real pattern from transcript 38d84d57: words with identical timestamps
transcript = Transcript(
words=[
Word(text="speaking ", start=6.71, end=7.11, speaker=0),
Word(text="Speaking ", start=6.71, end=7.11, speaker=1),
Word(text="at ", start=7.11, end=7.27, speaker=0),
Word(text="at ", start=7.11, end=7.27, speaker=1),
Word(text="the ", start=7.27, end=7.43, speaker=0),
Word(text="the ", start=7.27, end=7.43, speaker=1),
Word(text="same ", start=7.43, end=7.59, speaker=0),
Word(text="same ", start=7.43, end=7.59, speaker=1),
Word(text="time.", start=7.59, end=8.0, speaker=0),
Word(text="time.", start=7.59, end=8.0, speaker=1),
]
)
# Default: 10 segments (one per speaker change)
segments_default = transcript.as_segments(is_multitrack=False)
assert len(segments_default) == 10
# Multitrack: 2 segments (one per speaker sentence)
segments_multitrack = transcript.as_segments(is_multitrack=True)
assert len(segments_multitrack) == 2
# Both should have complete sentences
assert "speaking at the same time." in segments_multitrack[0].text
assert "Speaking at the same time." in segments_multitrack[1].text

View File

@@ -0,0 +1,779 @@
"""Tests for transcript format conversion functionality."""
import pytest
from reflector.db.transcripts import TranscriptParticipant, TranscriptTopic
from reflector.processors.types import Word
from reflector.utils.transcript_formats import (
format_timestamp_mmss,
get_speaker_name,
topics_to_webvtt_named,
transcript_to_json_segments,
transcript_to_text,
transcript_to_text_timestamped,
)
@pytest.mark.asyncio
async def test_get_speaker_name_with_participants():
"""Test speaker name resolution with participants list."""
participants = [
TranscriptParticipant(id="1", speaker=0, name="John Smith"),
TranscriptParticipant(id="2", speaker=1, name="Jane Doe"),
]
assert get_speaker_name(0, participants) == "John Smith"
assert get_speaker_name(1, participants) == "Jane Doe"
assert get_speaker_name(2, participants) == "Speaker 2"
@pytest.mark.asyncio
async def test_get_speaker_name_without_participants():
"""Test speaker name resolution without participants list."""
assert get_speaker_name(0, None) == "Speaker 0"
assert get_speaker_name(1, None) == "Speaker 1"
assert get_speaker_name(5, []) == "Speaker 5"
@pytest.mark.asyncio
async def test_format_timestamp_mmss():
"""Test timestamp formatting to MM:SS."""
assert format_timestamp_mmss(0) == "00:00"
assert format_timestamp_mmss(5) == "00:05"
assert format_timestamp_mmss(65) == "01:05"
assert format_timestamp_mmss(125.7) == "02:05"
assert format_timestamp_mmss(3661) == "61:01"
@pytest.mark.asyncio
async def test_transcript_to_text():
"""Test plain text format conversion."""
topics = [
TranscriptTopic(
id="1",
title="Topic 1",
summary="Summary 1",
timestamp=0.0,
words=[
Word(text="Hello", start=0.0, end=1.0, speaker=0),
Word(text=" world.", start=1.0, end=2.0, speaker=0),
],
),
TranscriptTopic(
id="2",
title="Topic 2",
summary="Summary 2",
timestamp=2.0,
words=[
Word(text="How", start=2.0, end=3.0, speaker=1),
Word(text=" are", start=3.0, end=4.0, speaker=1),
Word(text=" you?", start=4.0, end=5.0, speaker=1),
],
),
]
participants = [
TranscriptParticipant(id="1", speaker=0, name="John Smith"),
TranscriptParticipant(id="2", speaker=1, name="Jane Doe"),
]
result = transcript_to_text(topics, participants)
lines = result.split("\n")
assert len(lines) == 2
assert lines[0] == "John Smith: Hello world."
assert lines[1] == "Jane Doe: How are you?"
@pytest.mark.asyncio
async def test_transcript_to_text_timestamped():
"""Test timestamped text format conversion."""
topics = [
TranscriptTopic(
id="1",
title="Topic 1",
summary="Summary 1",
timestamp=0.0,
words=[
Word(text="Hello", start=0.0, end=1.0, speaker=0),
Word(text=" world.", start=1.0, end=2.0, speaker=0),
],
),
TranscriptTopic(
id="2",
title="Topic 2",
summary="Summary 2",
timestamp=65.0,
words=[
Word(text="How", start=65.0, end=66.0, speaker=1),
Word(text=" are", start=66.0, end=67.0, speaker=1),
Word(text=" you?", start=67.0, end=68.0, speaker=1),
],
),
]
participants = [
TranscriptParticipant(id="1", speaker=0, name="John Smith"),
TranscriptParticipant(id="2", speaker=1, name="Jane Doe"),
]
result = transcript_to_text_timestamped(topics, participants)
lines = result.split("\n")
assert len(lines) == 2
assert lines[0] == "[00:00] John Smith: Hello world."
assert lines[1] == "[01:05] Jane Doe: How are you?"
@pytest.mark.asyncio
async def test_topics_to_webvtt_named():
"""Test WebVTT format conversion with participant names."""
topics = [
TranscriptTopic(
id="1",
title="Topic 1",
summary="Summary 1",
timestamp=0.0,
words=[
Word(text="Hello", start=0.0, end=1.0, speaker=0),
Word(text=" world.", start=1.0, end=2.0, speaker=0),
],
),
]
participants = [
TranscriptParticipant(id="1", speaker=0, name="John Smith"),
]
result = topics_to_webvtt_named(topics, participants)
assert result.startswith("WEBVTT")
assert "<v John Smith>" in result
assert "00:00:00.000 --> 00:00:02.000" in result
assert "Hello world." in result
@pytest.mark.asyncio
async def test_transcript_to_json_segments():
"""Test JSON segments format conversion."""
topics = [
TranscriptTopic(
id="1",
title="Topic 1",
summary="Summary 1",
timestamp=0.0,
words=[
Word(text="Hello", start=0.0, end=1.0, speaker=0),
Word(text=" world.", start=1.0, end=2.0, speaker=0),
],
),
TranscriptTopic(
id="2",
title="Topic 2",
summary="Summary 2",
timestamp=2.0,
words=[
Word(text="How", start=2.0, end=3.0, speaker=1),
Word(text=" are", start=3.0, end=4.0, speaker=1),
Word(text=" you?", start=4.0, end=5.0, speaker=1),
],
),
]
participants = [
TranscriptParticipant(id="1", speaker=0, name="John Smith"),
TranscriptParticipant(id="2", speaker=1, name="Jane Doe"),
]
result = transcript_to_json_segments(topics, participants)
assert len(result) == 2
assert result[0].speaker == 0
assert result[0].speaker_name == "John Smith"
assert result[0].text == "Hello world."
assert result[0].start == 0.0
assert result[0].end == 2.0
assert result[1].speaker == 1
assert result[1].speaker_name == "Jane Doe"
assert result[1].text == "How are you?"
assert result[1].start == 2.0
assert result[1].end == 5.0
@pytest.mark.asyncio
async def test_transcript_formats_with_empty_topics():
"""Test format conversion with empty topics list."""
topics = []
participants = []
assert transcript_to_text(topics, participants) == ""
assert transcript_to_text_timestamped(topics, participants) == ""
assert "WEBVTT" in topics_to_webvtt_named(topics, participants)
assert transcript_to_json_segments(topics, participants) == []
@pytest.mark.asyncio
async def test_transcript_formats_with_empty_words():
"""Test format conversion with topics containing no words."""
topics = [
TranscriptTopic(
id="1",
title="Topic 1",
summary="Summary 1",
timestamp=0.0,
words=[],
),
]
participants = []
assert transcript_to_text(topics, participants) == ""
assert transcript_to_text_timestamped(topics, participants) == ""
assert "WEBVTT" in topics_to_webvtt_named(topics, participants)
assert transcript_to_json_segments(topics, participants) == []
@pytest.mark.asyncio
async def test_transcript_formats_with_multiple_speakers():
"""Test format conversion with multiple speaker changes."""
topics = [
TranscriptTopic(
id="1",
title="Topic 1",
summary="Summary 1",
timestamp=0.0,
words=[
Word(text="Hello", start=0.0, end=1.0, speaker=0),
Word(text=" there.", start=1.0, end=2.0, speaker=0),
Word(text="Hi", start=2.0, end=3.0, speaker=1),
Word(text=" back.", start=3.0, end=4.0, speaker=1),
Word(text="Good", start=4.0, end=5.0, speaker=0),
Word(text=" morning.", start=5.0, end=6.0, speaker=0),
],
),
]
participants = [
TranscriptParticipant(id="1", speaker=0, name="Alice"),
TranscriptParticipant(id="2", speaker=1, name="Bob"),
]
text_result = transcript_to_text(topics, participants)
lines = text_result.split("\n")
assert len(lines) == 3
assert "Alice: Hello there." in lines[0]
assert "Bob: Hi back." in lines[1]
assert "Alice: Good morning." in lines[2]
json_result = transcript_to_json_segments(topics, participants)
assert len(json_result) == 3
assert json_result[0].speaker_name == "Alice"
assert json_result[1].speaker_name == "Bob"
assert json_result[2].speaker_name == "Alice"
@pytest.mark.asyncio
async def test_transcript_formats_with_overlapping_speakers_multitrack():
"""Test format conversion for multitrack recordings with truly interleaved words.
Multitrack recordings have words from different speakers sorted by start time,
causing frequent speaker alternation. This tests the sentence-based segmentation
that groups each speaker's words into complete sentences.
"""
# Real multitrack data: words sorted by start time, speakers interleave
# Alice says: "Hello there." (0.0-1.0)
# Bob says: "I'm good." (0.5-1.5)
# When sorted by time, words interleave: Hello, I'm, there., good.
topics = [
TranscriptTopic(
id="1",
title="Topic 1",
summary="Summary 1",
timestamp=0.0,
words=[
Word(text="Hello ", start=0.0, end=0.5, speaker=0),
Word(text="I'm ", start=0.5, end=0.8, speaker=1),
Word(text="there.", start=0.5, end=1.0, speaker=0),
Word(text="good.", start=1.0, end=1.5, speaker=1),
],
),
]
participants = [
TranscriptParticipant(id="1", speaker=0, name="Alice"),
TranscriptParticipant(id="2", speaker=1, name="Bob"),
]
# With is_multitrack=True, should produce 2 segments (one per speaker sentence)
# not 4 segments (one per speaker change)
webvtt_result = topics_to_webvtt_named(topics, participants, is_multitrack=True)
expected_webvtt = """WEBVTT
00:00:00.000 --> 00:00:01.000
<v Alice>Hello there.
00:00:00.500 --> 00:00:01.500
<v Bob>I'm good.
"""
assert webvtt_result == expected_webvtt
text_result = transcript_to_text(topics, participants, is_multitrack=True)
lines = text_result.split("\n")
assert len(lines) == 2
assert "Alice: Hello there." in lines[0]
assert "Bob: I'm good." in lines[1]
timestamped_result = transcript_to_text_timestamped(
topics, participants, is_multitrack=True
)
timestamped_lines = timestamped_result.split("\n")
assert len(timestamped_lines) == 2
assert "[00:00] Alice: Hello there." in timestamped_lines[0]
assert "[00:00] Bob: I'm good." in timestamped_lines[1]
segments = transcript_to_json_segments(topics, participants, is_multitrack=True)
assert len(segments) == 2
assert segments[0].speaker_name == "Alice"
assert segments[0].text == "Hello there."
assert segments[1].speaker_name == "Bob"
assert segments[1].text == "I'm good."
@pytest.mark.asyncio
async def test_api_transcript_format_text(client):
"""Test GET /transcripts/{id} with transcript_format=text."""
response = await client.post("/transcripts", json={"name": "Test transcript"})
assert response.status_code == 200
tid = response.json()["id"]
from reflector.db.transcripts import (
TranscriptParticipant,
TranscriptTopic,
transcripts_controller,
)
from reflector.processors.types import Word
transcript = await transcripts_controller.get_by_id(tid)
await transcripts_controller.update(
transcript,
{
"participants": [
TranscriptParticipant(
id="1", speaker=0, name="John Smith"
).model_dump(),
TranscriptParticipant(id="2", speaker=1, name="Jane Doe").model_dump(),
]
},
)
await transcripts_controller.upsert_topic(
transcript,
TranscriptTopic(
title="Topic 1",
summary="Summary 1",
timestamp=0,
words=[
Word(text="Hello", start=0, end=1, speaker=0),
Word(text=" world.", start=1, end=2, speaker=0),
],
),
)
response = await client.get(f"/transcripts/{tid}?transcript_format=text")
assert response.status_code == 200
data = response.json()
assert data["transcript_format"] == "text"
assert "transcript" in data
assert "John Smith: Hello world." in data["transcript"]
@pytest.mark.asyncio
async def test_api_transcript_format_text_timestamped(client):
"""Test GET /transcripts/{id} with transcript_format=text-timestamped."""
response = await client.post("/transcripts", json={"name": "Test transcript"})
assert response.status_code == 200
tid = response.json()["id"]
from reflector.db.transcripts import (
TranscriptParticipant,
TranscriptTopic,
transcripts_controller,
)
from reflector.processors.types import Word
transcript = await transcripts_controller.get_by_id(tid)
await transcripts_controller.update(
transcript,
{
"participants": [
TranscriptParticipant(
id="1", speaker=0, name="John Smith"
).model_dump(),
]
},
)
await transcripts_controller.upsert_topic(
transcript,
TranscriptTopic(
title="Topic 1",
summary="Summary 1",
timestamp=0,
words=[
Word(text="Hello", start=65, end=66, speaker=0),
Word(text=" world.", start=66, end=67, speaker=0),
],
),
)
response = await client.get(
f"/transcripts/{tid}?transcript_format=text-timestamped"
)
assert response.status_code == 200
data = response.json()
assert data["transcript_format"] == "text-timestamped"
assert "transcript" in data
assert "[01:05] John Smith: Hello world." in data["transcript"]
@pytest.mark.asyncio
async def test_api_transcript_format_webvtt_named(client):
"""Test GET /transcripts/{id} with transcript_format=webvtt-named."""
response = await client.post("/transcripts", json={"name": "Test transcript"})
assert response.status_code == 200
tid = response.json()["id"]
from reflector.db.transcripts import (
TranscriptParticipant,
TranscriptTopic,
transcripts_controller,
)
from reflector.processors.types import Word
transcript = await transcripts_controller.get_by_id(tid)
await transcripts_controller.update(
transcript,
{
"participants": [
TranscriptParticipant(
id="1", speaker=0, name="John Smith"
).model_dump(),
]
},
)
await transcripts_controller.upsert_topic(
transcript,
TranscriptTopic(
title="Topic 1",
summary="Summary 1",
timestamp=0,
words=[
Word(text="Hello", start=0, end=1, speaker=0),
Word(text=" world.", start=1, end=2, speaker=0),
],
),
)
response = await client.get(f"/transcripts/{tid}?transcript_format=webvtt-named")
assert response.status_code == 200
data = response.json()
assert data["transcript_format"] == "webvtt-named"
assert "transcript" in data
assert "WEBVTT" in data["transcript"]
assert "<v John Smith>" in data["transcript"]
@pytest.mark.asyncio
async def test_api_transcript_format_json(client):
"""Test GET /transcripts/{id} with transcript_format=json."""
response = await client.post("/transcripts", json={"name": "Test transcript"})
assert response.status_code == 200
tid = response.json()["id"]
from reflector.db.transcripts import (
TranscriptParticipant,
TranscriptTopic,
transcripts_controller,
)
from reflector.processors.types import Word
transcript = await transcripts_controller.get_by_id(tid)
await transcripts_controller.update(
transcript,
{
"participants": [
TranscriptParticipant(
id="1", speaker=0, name="John Smith"
).model_dump(),
]
},
)
await transcripts_controller.upsert_topic(
transcript,
TranscriptTopic(
title="Topic 1",
summary="Summary 1",
timestamp=0,
words=[
Word(text="Hello", start=0, end=1, speaker=0),
Word(text=" world.", start=1, end=2, speaker=0),
],
),
)
response = await client.get(f"/transcripts/{tid}?transcript_format=json")
assert response.status_code == 200
data = response.json()
assert data["transcript_format"] == "json"
assert "transcript" in data
assert isinstance(data["transcript"], list)
assert len(data["transcript"]) == 1
assert data["transcript"][0]["speaker"] == 0
assert data["transcript"][0]["speaker_name"] == "John Smith"
assert data["transcript"][0]["text"] == "Hello world."
@pytest.mark.asyncio
async def test_api_transcript_format_default_is_text(client):
"""Test GET /transcripts/{id} defaults to text format."""
response = await client.post("/transcripts", json={"name": "Test transcript"})
assert response.status_code == 200
tid = response.json()["id"]
from reflector.db.transcripts import TranscriptTopic, transcripts_controller
from reflector.processors.types import Word
transcript = await transcripts_controller.get_by_id(tid)
await transcripts_controller.upsert_topic(
transcript,
TranscriptTopic(
title="Topic 1",
summary="Summary 1",
timestamp=0,
words=[
Word(text="Hello", start=0, end=1, speaker=0),
],
),
)
response = await client.get(f"/transcripts/{tid}")
assert response.status_code == 200
data = response.json()
assert data["transcript_format"] == "text"
assert "transcript" in data
@pytest.mark.asyncio
async def test_api_topics_endpoint_multitrack_segmentation(client):
"""Test GET /transcripts/{id}/topics uses sentence-based segmentation for multitrack.
This tests the fix for TASKS2.md - ensuring /topics endpoints correctly detect
multitrack recordings and use sentence-based segmentation instead of fragmenting
on every speaker change.
"""
from datetime import datetime, timezone
from reflector.db.recordings import Recording, recordings_controller
from reflector.db.transcripts import (
TranscriptParticipant,
TranscriptTopic,
transcripts_controller,
)
from reflector.processors.types import Word
# Create a multitrack recording (has track_keys)
recording = Recording(
bucket_name="test-bucket",
object_key="test-key",
recorded_at=datetime.now(timezone.utc),
track_keys=["track1.webm", "track2.webm"], # This makes it multitrack
)
await recordings_controller.create(recording)
# Create transcript linked to the recording
transcript = await transcripts_controller.add(
name="Multitrack Test",
source_kind="file",
recording_id=recording.id,
)
await transcripts_controller.update(
transcript,
{
"participants": [
TranscriptParticipant(id="1", speaker=0, name="Alice").model_dump(),
TranscriptParticipant(id="2", speaker=1, name="Bob").model_dump(),
]
},
)
# Add interleaved words (as they appear in real multitrack data)
await transcripts_controller.upsert_topic(
transcript,
TranscriptTopic(
title="Topic 1",
summary="Summary 1",
timestamp=0,
words=[
Word(text="Hello ", start=0.0, end=0.5, speaker=0),
Word(text="I'm ", start=0.5, end=0.8, speaker=1),
Word(text="there.", start=0.5, end=1.0, speaker=0),
Word(text="good.", start=1.0, end=1.5, speaker=1),
],
),
)
# Test /topics endpoint
response = await client.get(f"/transcripts/{transcript.id}/topics")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
topic = data[0]
# Key assertion: multitrack should produce 2 segments (one per speaker sentence)
# Not 4 segments (one per speaker change)
assert len(topic["segments"]) == 2
# Check content
segment_texts = [s["text"] for s in topic["segments"]]
assert "Hello there." in segment_texts
assert "I'm good." in segment_texts
@pytest.mark.asyncio
async def test_api_topics_endpoint_non_multitrack_segmentation(client):
"""Test GET /transcripts/{id}/topics uses default segmentation for non-multitrack.
Ensures backward compatibility - transcripts without multitrack recordings
should continue using the default speaker-change-based segmentation.
"""
from reflector.db.transcripts import (
TranscriptParticipant,
TranscriptTopic,
transcripts_controller,
)
from reflector.processors.types import Word
# Create transcript WITHOUT recording (defaulted as not multitrack) TODO better heuristic
response = await client.post("/transcripts", json={"name": "Test transcript"})
assert response.status_code == 200
tid = response.json()["id"]
transcript = await transcripts_controller.get_by_id(tid)
await transcripts_controller.update(
transcript,
{
"participants": [
TranscriptParticipant(id="1", speaker=0, name="Alice").model_dump(),
TranscriptParticipant(id="2", speaker=1, name="Bob").model_dump(),
]
},
)
# Add interleaved words
await transcripts_controller.upsert_topic(
transcript,
TranscriptTopic(
title="Topic 1",
summary="Summary 1",
timestamp=0,
words=[
Word(text="Hello ", start=0.0, end=0.5, speaker=0),
Word(text="I'm ", start=0.5, end=0.8, speaker=1),
Word(text="there.", start=0.5, end=1.0, speaker=0),
Word(text="good.", start=1.0, end=1.5, speaker=1),
],
),
)
# Test /topics endpoint
response = await client.get(f"/transcripts/{tid}/topics")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
topic = data[0]
# Non-multitrack: should produce 4 segments (one per speaker change)
assert len(topic["segments"]) == 4
@pytest.mark.asyncio
async def test_api_topics_with_words_endpoint_multitrack(client):
"""Test GET /transcripts/{id}/topics/with-words uses multitrack segmentation."""
from datetime import datetime, timezone
from reflector.db.recordings import Recording, recordings_controller
from reflector.db.transcripts import (
TranscriptParticipant,
TranscriptTopic,
transcripts_controller,
)
from reflector.processors.types import Word
# Create multitrack recording
recording = Recording(
bucket_name="test-bucket",
object_key="test-key-2",
recorded_at=datetime.now(timezone.utc),
track_keys=["track1.webm", "track2.webm"],
)
await recordings_controller.create(recording)
transcript = await transcripts_controller.add(
name="Multitrack Test 2",
source_kind="file",
recording_id=recording.id,
)
await transcripts_controller.update(
transcript,
{
"participants": [
TranscriptParticipant(id="1", speaker=0, name="Alice").model_dump(),
TranscriptParticipant(id="2", speaker=1, name="Bob").model_dump(),
]
},
)
await transcripts_controller.upsert_topic(
transcript,
TranscriptTopic(
title="Topic 1",
summary="Summary 1",
timestamp=0,
words=[
Word(text="Hello ", start=0.0, end=0.5, speaker=0),
Word(text="I'm ", start=0.5, end=0.8, speaker=1),
Word(text="there.", start=0.5, end=1.0, speaker=0),
Word(text="good.", start=1.0, end=1.5, speaker=1),
],
),
)
response = await client.get(f"/transcripts/{transcript.id}/topics/with-words")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
topic = data[0]
# Should have 2 segments (multitrack sentence-based)
assert len(topic["segments"]) == 2
# Should also have words field
assert "words" in topic
assert len(topic["words"]) == 4

View File

@@ -1,5 +1,8 @@
import pytest
from reflector.db.rooms import rooms_controller
from reflector.db.transcripts import transcripts_controller
@pytest.mark.asyncio
async def test_transcript_create(client):
@@ -182,3 +185,51 @@ async def test_transcript_mark_reviewed(authenticated_client, client):
response = await client.get(f"/transcripts/{tid}")
assert response.status_code == 200
assert response.json()["reviewed"] is True
@pytest.mark.asyncio
async def test_transcript_get_returns_room_name(authenticated_client, client):
"""Test that getting a transcript returns its room_name when linked to a room."""
# Create a room
room = await rooms_controller.add(
name="test-room-for-transcript",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
webhook_url="",
webhook_secret="",
)
# Create a transcript linked to the room
transcript = await transcripts_controller.add(
name="transcript-with-room",
source_kind="file",
room_id=room.id,
)
# Get the transcript and verify room_name is returned
response = await client.get(f"/transcripts/{transcript.id}")
assert response.status_code == 200
assert response.json()["room_id"] == room.id
assert response.json()["room_name"] == "test-room-for-transcript"
@pytest.mark.asyncio
async def test_transcript_get_returns_null_room_name_when_no_room(
authenticated_client, client
):
"""Test that room_name is null when transcript has no room."""
response = await client.post("/transcripts", json={"name": "no-room-transcript"})
assert response.status_code == 200
tid = response.json()["id"]
response = await client.get(f"/transcripts/{tid}")
assert response.status_code == 200
assert response.json()["room_id"] is None
assert response.json()["room_name"] is None

View File

@@ -139,10 +139,10 @@ async def test_whereby_recording_uses_file_pipeline(client):
with (
patch(
"reflector.views.transcripts_process.task_pipeline_file_process"
"reflector.services.transcript_process.task_pipeline_file_process"
) as mock_file_pipeline,
patch(
"reflector.views.transcripts_process.task_pipeline_multitrack_process"
"reflector.services.transcript_process.task_pipeline_multitrack_process"
) as mock_multitrack_pipeline,
):
response = await client.post(f"/transcripts/{transcript.id}/process")
@@ -194,10 +194,10 @@ async def test_dailyco_recording_uses_multitrack_pipeline(client):
with (
patch(
"reflector.views.transcripts_process.task_pipeline_file_process"
"reflector.services.transcript_process.task_pipeline_file_process"
) as mock_file_pipeline,
patch(
"reflector.views.transcripts_process.task_pipeline_multitrack_process"
"reflector.services.transcript_process.task_pipeline_multitrack_process"
) as mock_multitrack_pipeline,
):
response = await client.post(f"/transcripts/{transcript.id}/process")

View File

@@ -120,7 +120,15 @@ async def test_user_ws_accepts_valid_token_and_receives_events(appserver_ws_user
host, port = appserver_ws_user
base_ws = f"http://{host}:{port}/v1/events"
token = _make_dummy_jwt("user-abc")
# Create a test user in the database
from reflector.db.users import user_controller
test_uid = "user-abc"
user = await user_controller.create_or_update(
id="test-user-id-abc", authentik_uid=test_uid, email="user-abc@example.com"
)
token = _make_dummy_jwt(test_uid)
subprotocols = ["bearer", token]
# Connect and then trigger an event via HTTP create
@@ -132,12 +140,13 @@ async def test_user_ws_accepts_valid_token_and_receives_events(appserver_ws_user
from reflector.auth import current_user, current_user_optional
# Override auth dependencies so HTTP request is performed as the same user
# Use the internal user.id (not the Authentik UID)
app.dependency_overrides[current_user] = lambda: {
"sub": "user-abc",
"sub": user.id,
"email": "user-abc@example.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "user-abc",
"sub": user.id,
"email": "user-abc@example.com",
}

View File

@@ -22,9 +22,10 @@ AUTHENTIK_CLIENT_SECRET=your-client-secret-here
# API URLs
API_URL=http://127.0.0.1:1250
SERVER_API_URL=http://server:1250
WEBSOCKET_URL=ws://127.0.0.1:1250
AUTH_CALLBACK_URL=http://localhost:3000/auth-callback
# Sentry
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
# SENTRY_IGNORE_API_RESOLUTION_ERROR=1
# SENTRY_IGNORE_API_RESOLUTION_ERROR=1

View File

@@ -15,9 +15,12 @@ import {
createListCollection,
useDisclosure,
Tabs,
Popover,
Text,
HStack,
} from "@chakra-ui/react";
import { useEffect, useMemo, useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import { LuEye, LuEyeOff, LuInfo } from "react-icons/lu";
import useRoomList from "./useRoomList";
import type { components } from "../../reflector-api";
import {
@@ -67,6 +70,11 @@ const recordingTypeOptions: SelectOption[] = [
{ label: "Cloud", value: "cloud" },
];
const platformOptions: SelectOption[] = [
{ label: "Whereby", value: "whereby" },
{ label: "Daily", value: "daily" },
];
const roomInitialState = {
name: "",
zulipAutoPost: false,
@@ -82,6 +90,7 @@ const roomInitialState = {
icsUrl: "",
icsEnabled: false,
icsFetchInterval: 5,
platform: "whereby",
};
export default function RoomsList() {
@@ -99,6 +108,11 @@ export default function RoomsList() {
const recordingTypeCollection = createListCollection({
items: recordingTypeOptions,
});
const platformCollection = createListCollection({
items: platformOptions,
});
const [roomInput, setRoomInput] = useState<null | typeof roomInitialState>(
null,
);
@@ -143,15 +157,24 @@ export default function RoomsList() {
zulipStream: detailedEditedRoom.zulip_stream,
zulipTopic: detailedEditedRoom.zulip_topic,
isLocked: detailedEditedRoom.is_locked,
roomMode: detailedEditedRoom.room_mode,
roomMode:
detailedEditedRoom.platform === "daily"
? "group"
: detailedEditedRoom.room_mode,
recordingType: detailedEditedRoom.recording_type,
recordingTrigger: detailedEditedRoom.recording_trigger,
recordingTrigger:
detailedEditedRoom.platform === "daily"
? detailedEditedRoom.recording_type === "cloud"
? "automatic-2nd-participant"
: "none"
: detailedEditedRoom.recording_trigger,
isShared: detailedEditedRoom.is_shared,
webhookUrl: detailedEditedRoom.webhook_url || "",
webhookSecret: detailedEditedRoom.webhook_secret || "",
icsUrl: detailedEditedRoom.ics_url || "",
icsEnabled: detailedEditedRoom.ics_enabled || false,
icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5,
platform: detailedEditedRoom.platform,
}
: null,
[detailedEditedRoom],
@@ -277,21 +300,32 @@ export default function RoomsList() {
return;
}
const platform: "whereby" | "daily" | null =
room.platform === "whereby" || room.platform === "daily"
? room.platform
: null;
const roomData = {
name: room.name,
zulip_auto_post: room.zulipAutoPost,
zulip_stream: room.zulipStream,
zulip_topic: room.zulipTopic,
is_locked: room.isLocked,
room_mode: room.roomMode,
room_mode: platform === "daily" ? "group" : room.roomMode,
recording_type: room.recordingType,
recording_trigger: room.recordingTrigger,
recording_trigger:
platform === "daily"
? room.recordingType === "cloud"
? "automatic-2nd-participant"
: "none"
: room.recordingTrigger,
is_shared: room.isShared,
webhook_url: room.webhookUrl,
webhook_secret: room.webhookSecret,
ics_url: room.icsUrl,
ics_enabled: room.icsEnabled,
ics_fetch_interval: room.icsFetchInterval,
platform,
};
if (isEditing) {
@@ -339,15 +373,21 @@ export default function RoomsList() {
zulipStream: roomData.zulip_stream,
zulipTopic: roomData.zulip_topic,
isLocked: roomData.is_locked,
roomMode: roomData.room_mode,
roomMode: roomData.platform === "daily" ? "group" : roomData.room_mode, // Daily always uses 2-200
recordingType: roomData.recording_type,
recordingTrigger: roomData.recording_trigger,
recordingTrigger:
roomData.platform === "daily"
? roomData.recording_type === "cloud"
? "automatic-2nd-participant"
: "none"
: roomData.recording_trigger,
isShared: roomData.is_shared,
webhookUrl: roomData.webhook_url || "",
webhookSecret: roomData.webhook_secret || "",
icsUrl: roomData.ics_url || "",
icsEnabled: roomData.ics_enabled || false,
icsFetchInterval: roomData.ics_fetch_interval || 5,
platform: roomData.platform,
});
setEditRoomId(roomId);
setIsEditing(true);
@@ -482,6 +522,52 @@ export default function RoomsList() {
)}
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Platform</Field.Label>
<Select.Root
value={[room.platform]}
onValueChange={(e) => {
const newPlatform = e.value[0] as "whereby" | "daily";
const updates: Partial<typeof room> = {
platform: newPlatform,
};
if (newPlatform === "daily") {
updates.roomMode = "group";
updates.recordingTrigger =
room.recordingType === "cloud"
? "automatic-2nd-participant"
: "none";
} else {
if (room.recordingType !== "cloud") {
updates.recordingTrigger = "none";
}
}
setRoomInput({ ...room, ...updates });
}}
collection={platformCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select platform" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{platformOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Checkbox.Root
name="isLocked"
@@ -504,50 +590,95 @@ export default function RoomsList() {
<Checkbox.Label>Locked room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
{room.platform !== "daily" && (
<Field.Root mt={4}>
<Field.Label>Room size</Field.Label>
<Select.Root
value={[room.roomMode]}
onValueChange={(e) =>
setRoomInput({ ...room, roomMode: e.value[0] })
}
collection={roomModeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select room size" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{roomModeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
)}
<Field.Root mt={4}>
<Field.Label>Room size</Field.Label>
<Select.Root
value={[room.roomMode]}
onValueChange={(e) =>
setRoomInput({ ...room, roomMode: e.value[0] })
}
collection={roomModeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select room size" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{roomModeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Recording type</Field.Label>
<HStack gap={2} alignItems="center">
<Field.Label>Recording type</Field.Label>
<Popover.Root>
<Popover.Trigger asChild>
<IconButton
aria-label="Recording type help"
variant="ghost"
size="xs"
colorPalette="gray"
>
<LuInfo />
</IconButton>
</Popover.Trigger>
<Popover.Positioner>
<Popover.Content>
<Popover.Arrow />
<Popover.Body>
<Text fontSize="sm" lineHeight="1.6">
<strong>None:</strong> No recording will be
created.
<br />
<br />
<strong>Local:</strong> Recording happens on
each participant's device. Files are saved
locally.
<br />
<br />
<strong>Cloud:</strong> Recording happens on
the platform's servers and is available after
the meeting ends.
</Text>
</Popover.Body>
</Popover.Content>
</Popover.Positioner>
</Popover.Root>
</HStack>
<Select.Root
value={[room.recordingType]}
onValueChange={(e) =>
setRoomInput({
...room,
recordingType: e.value[0],
recordingTrigger:
e.value[0] !== "cloud"
onValueChange={(e) => {
const newRecordingType = e.value[0];
const updates: Partial<typeof room> = {
recordingType: newRecordingType,
};
if (room.platform === "daily") {
updates.recordingTrigger =
newRecordingType === "cloud"
? "automatic-2nd-participant"
: "none";
} else {
updates.recordingTrigger =
newRecordingType !== "cloud"
? "none"
: room.recordingTrigger,
})
}
: room.recordingTrigger;
}
setRoomInput({ ...room, ...updates });
}}
collection={recordingTypeCollection}
>
<Select.HiddenSelect />
@@ -571,40 +702,77 @@ export default function RoomsList() {
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Cloud recording start trigger</Field.Label>
<Select.Root
value={[room.recordingTrigger]}
onValueChange={(e) =>
setRoomInput({
...room,
recordingTrigger: e.value[0],
})
}
collection={recordingTriggerCollection}
disabled={room.recordingType !== "cloud"}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select trigger" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTriggerOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
{room.recordingType === "cloud" &&
room.platform !== "daily" && (
<Field.Root mt={4}>
<HStack gap={2} alignItems="center">
<Field.Label>Recording start trigger</Field.Label>
<Popover.Root>
<Popover.Trigger asChild>
<IconButton
aria-label="Recording start trigger help"
variant="ghost"
size="xs"
colorPalette="gray"
>
<LuInfo />
</IconButton>
</Popover.Trigger>
<Popover.Positioner>
<Popover.Content>
<Popover.Arrow />
<Popover.Body>
<Text fontSize="sm" lineHeight="1.6">
<strong>None:</strong> Recording must be
started manually by a participant.
<br />
<br />
<strong>Prompt:</strong> Participants will
be prompted to start recording when they
join.
<br />
<br />
<strong>Automatic:</strong> Recording
starts automatically when a second
participant joins.
</Text>
</Popover.Body>
</Popover.Content>
</Popover.Positioner>
</Popover.Root>
</HStack>
<Select.Root
value={[room.recordingTrigger]}
onValueChange={(e) =>
setRoomInput({
...room,
recordingTrigger: e.value[0],
})
}
collection={recordingTriggerCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select trigger" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTriggerOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
)}
<Field.Root mt={4}>
<Checkbox.Root

View File

@@ -117,15 +117,6 @@ export default function TranscriptDetails(details: TranscriptDetails) {
return <Modal title="Loading" text={"Loading transcript..."} />;
}
if (mp3.error) {
return (
<Modal
title="Transcription error"
text={`There was an error loading the recording. Error: ${mp3.error}`}
/>
);
}
return (
<>
<Grid
@@ -147,7 +138,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
/>
) : !mp3.loading && (waveform.error || mp3.error) ? (
<Box p={4} bg="red.100" borderRadius="md">
<Text>Error loading this recording</Text>
<Text>
Error loading{" "}
{[waveform.error && "waveform", mp3.error && "mp3"]
.filter(Boolean)
.join(" and ")}
</Text>
</Box>
) : (
<Skeleton h={14} />

View File

@@ -1,14 +1,16 @@
import { useState } from "react";
import type { components } from "../../reflector-api";
type GetTranscript = components["schemas"]["GetTranscript"];
import type { components, operations } from "../../reflector-api";
type GetTranscriptWithParticipants =
components["schemas"]["GetTranscriptWithParticipants"];
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { Button, BoxProps, Box } from "@chakra-ui/react";
import { buildTranscriptWithTopics } from "./buildTranscriptWithTopics";
import { useTranscriptParticipants } from "../../lib/apiHooks";
import { Button, BoxProps, Box, Menu, Text } from "@chakra-ui/react";
import { LuChevronDown } from "react-icons/lu";
import { client } from "../../lib/apiClient";
import { toaster } from "../../components/ui/toaster";
type ShareCopyProps = {
finalSummaryElement: HTMLDivElement | null;
transcript: GetTranscript;
transcript: GetTranscriptWithParticipants;
topics: GetTranscriptTopic[];
};
@@ -20,11 +22,33 @@ export default function ShareCopy({
}: ShareCopyProps & BoxProps) {
const [isCopiedSummary, setIsCopiedSummary] = useState(false);
const [isCopiedTranscript, setIsCopiedTranscript] = useState(false);
const participantsQuery = useTranscriptParticipants(transcript?.id || null);
const [isCopying, setIsCopying] = useState(false);
type ApiTranscriptFormat = NonNullable<
operations["v1_transcript_get"]["parameters"]["query"]
>["transcript_format"];
const TRANSCRIPT_FORMATS = [
"text",
"text-timestamped",
"webvtt-named",
"json",
] as const satisfies ApiTranscriptFormat[];
type TranscriptFormat = (typeof TRANSCRIPT_FORMATS)[number];
const TRANSCRIPT_FORMAT_LABELS: { [k in TranscriptFormat]: string } = {
text: "Plain text",
"text-timestamped": "Text + timestamps",
"webvtt-named": "WebVTT (named)",
json: "JSON",
};
const formatOptions = TRANSCRIPT_FORMATS.map((f) => ({
value: f,
label: TRANSCRIPT_FORMAT_LABELS[f],
}));
const onCopySummaryClick = () => {
const text_to_copy = finalSummaryElement?.innerText;
if (text_to_copy) {
navigator.clipboard.writeText(text_to_copy).then(() => {
setIsCopiedSummary(true);
@@ -34,27 +58,91 @@ export default function ShareCopy({
}
};
const onCopyTranscriptClick = () => {
const text_to_copy =
buildTranscriptWithTopics(
topics || [],
participantsQuery?.data || null,
transcript?.title || null,
) || "";
const onCopyTranscriptFormatClick = async (format: TranscriptFormat) => {
try {
setIsCopying(true);
const { data, error } = await client.GET(
"/v1/transcripts/{transcript_id}",
{
params: {
path: { transcript_id: transcript.id },
query: { transcript_format: format },
},
},
);
if (error) {
console.error("Failed to copy transcript:", error);
toaster.create({
duration: 3000,
render: () => (
<Box bg="red.500" color="white" px={4} py={3} borderRadius="md">
<Text fontWeight="bold">Error</Text>
<Text fontSize="sm">Failed to fetch transcript</Text>
</Box>
),
});
return;
}
text_to_copy &&
navigator.clipboard.writeText(text_to_copy).then(() => {
const copiedText =
format === "json"
? JSON.stringify(data?.transcript ?? {}, null, 2)
: String(data?.transcript ?? "");
if (copiedText) {
await navigator.clipboard.writeText(copiedText);
setIsCopiedTranscript(true);
// Reset the copied state after 2 seconds
setTimeout(() => setIsCopiedTranscript(false), 2000);
}
} catch (e) {
console.error("Failed to copy transcript:", e);
toaster.create({
duration: 3000,
render: () => (
<Box bg="red.500" color="white" px={4} py={3} borderRadius="md">
<Text fontWeight="bold">Error</Text>
<Text fontSize="sm">Failed to copy transcript</Text>
</Box>
),
});
} finally {
setIsCopying(false);
}
};
return (
<Box {...boxProps}>
<Button onClick={onCopyTranscriptClick} mr={2} variant="subtle">
{isCopiedTranscript ? "Copied!" : "Copy Transcript"}
</Button>
<Menu.Root
closeOnSelect={true}
lazyMount={true}
positioning={{ gutter: 4 }}
>
<Menu.Trigger asChild>
<Button
mr={2}
variant="subtle"
loading={isCopying}
loadingText="Copying..."
>
{isCopiedTranscript ? "Copied!" : "Copy Transcript"}
<LuChevronDown style={{ marginLeft: 6 }} />
</Button>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content>
{formatOptions.map((opt) => (
<Menu.Item
key={opt.value}
value={opt.value}
_hover={{ backgroundColor: "gray.100" }}
onClick={() => onCopyTranscriptFormatClick(opt.value)}
>
{opt.label}
</Menu.Item>
))}
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
<Button onClick={onCopySummaryClick} variant="subtle">
{isCopiedSummary ? "Copied!" : "Copy Summary"}
</Button>

View File

@@ -2,20 +2,29 @@
import { Spinner, Link } from "@chakra-ui/react";
import { useAuth } from "../lib/AuthProvider";
import { usePathname } from "next/navigation";
import { getLogoutRedirectUrl } from "../lib/auth";
export default function UserInfo() {
const auth = useAuth();
const pathname = usePathname();
const status = auth.status;
const isLoading = status === "loading";
const isAuthenticated = status === "authenticated";
const isRefreshing = status === "refreshing";
const callbackUrl = getLogoutRedirectUrl(pathname);
return isLoading ? (
<Spinner size="xs" className="mx-3" />
) : !isAuthenticated && !isRefreshing ? (
<Link
href="/"
href="#"
className="font-light px-2"
onClick={() => auth.signIn("authentik")}
onClick={(e) => {
e.preventDefault();
auth.signIn("authentik");
}}
>
Log in
</Link>
@@ -23,7 +32,7 @@ export default function UserInfo() {
<Link
href="#"
className="font-light px-2"
onClick={() => auth.signOut({ callbackUrl: "/" })}
onClick={() => auth.signOut({ callbackUrl })}
>
Log out
</Link>

View File

@@ -1,8 +1,8 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { Box } from "@chakra-ui/react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { Box, Spinner, Center, Text } from "@chakra-ui/react";
import { useRouter, useParams } from "next/navigation";
import DailyIframe, { DailyCall } from "@daily-co/daily-js";
import type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider";
@@ -10,6 +10,8 @@ import {
ConsentDialogButton,
recordingTypeRequiresConsent,
} from "../../lib/consent";
import { useRoomJoinMeeting } from "../../lib/apiHooks";
import { assertExists } from "../../lib/utils";
type Meeting = components["schemas"]["Meeting"];
@@ -19,20 +21,46 @@ interface DailyRoomProps {
export default function DailyRoom({ meeting }: DailyRoomProps) {
const router = useRouter();
const params = useParams();
const auth = useAuth();
const status = auth.status;
const authLastUserId = auth.lastUserId;
const containerRef = useRef<HTMLDivElement>(null);
const joinMutation = useRoomJoinMeeting();
const [joinedMeeting, setJoinedMeeting] = useState<Meeting | null>(null);
const roomUrl = meeting?.host_room_url || meeting?.room_url;
const roomName = params?.roomName as string;
const isLoading = status === "loading";
useEffect(() => {
if (authLastUserId === undefined || !meeting?.id || !roomName) return;
const join = async () => {
try {
const result = await joinMutation.mutateAsync({
params: {
path: {
room_name: roomName,
meeting_id: meeting.id,
},
},
});
setJoinedMeeting(result);
} catch (error) {
console.error("Failed to join meeting:", error);
}
};
join();
}, [meeting?.id, roomName, authLastUserId]);
const roomUrl = joinedMeeting?.room_url;
const handleLeave = useCallback(() => {
router.push("/browse");
}, [router]);
useEffect(() => {
if (isLoading || !roomUrl || !containerRef.current) return;
if (authLastUserId === undefined || !roomUrl || !containerRef.current)
return;
let frame: DailyCall | null = null;
let destroyed = false;
@@ -63,19 +91,41 @@ export default function DailyRoom({ meeting }: DailyRoomProps) {
frame.on("joined-meeting", async () => {
try {
await frame.startRecording({ type: "raw-tracks" });
const frameInstance = assertExists(
frame,
"frame object got lost somewhere after frame.on was called",
);
if (meeting.recording_type === "cloud") {
console.log("Starting cloud recording");
await frameInstance.startRecording({ type: "raw-tracks" });
}
} catch (error) {
console.error("Failed to start recording:", error);
}
});
await frame.join({ url: roomUrl });
await frame.join({
url: roomUrl,
sendSettings: {
video: {
// Optimize bandwidth for camera video
// allowAdaptiveLayers automatically adjusts quality based on network conditions
allowAdaptiveLayers: true,
// Use bandwidth-optimized preset as fallback for browsers without adaptive support
maxQuality: "medium",
},
// Note: screenVideo intentionally not configured to preserve full quality for screen shares
},
});
} catch (error) {
console.error("Error creating Daily frame:", error);
}
};
createAndJoin();
createAndJoin().catch((error) => {
console.error("Failed to create and join meeting:", error);
});
return () => {
destroyed = true;
@@ -85,7 +135,23 @@ export default function DailyRoom({ meeting }: DailyRoomProps) {
});
}
};
}, [roomUrl, isLoading, handleLeave]);
}, [roomUrl, authLastUserId, handleLeave]);
if (authLastUserId === undefined) {
return (
<Center width="100vw" height="100vh">
<Spinner size="xl" />
</Center>
);
}
if (joinMutation.isError) {
return (
<Center width="100vw" height="100vh">
<Text color="red.500">Failed to join meeting. Please try again.</Text>
</Center>
);
}
if (!roomUrl) {
return null;

View File

@@ -1,6 +1,6 @@
"use client";
import { createContext, useContext } from "react";
import { createContext, useContext, useRef } from "react";
import { useSession as useNextAuthSession } from "next-auth/react";
import { signOut, signIn } from "next-auth/react";
import { configureApiAuth } from "./apiClient";
@@ -25,6 +25,9 @@ type AuthContextType = (
update: () => Promise<Session | null>;
signIn: typeof signIn;
signOut: typeof signOut;
// TODO probably rename isLoading to isReloading and make THIS field "isLoading"
// undefined is "not known", null is "is certainly logged out"
lastUserId: CustomSession["user"]["id"] | null | undefined;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -41,10 +44,15 @@ const noopAuthContext: AuthContextType = {
signOut: async () => {
throw new Error("signOut not supposed to be called");
},
lastUserId: undefined,
};
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, status, update } = useNextAuthSession();
// referential comparison done in component, must be primitive /or cached
const lastUserId = useRef<CustomSession["user"]["id"] | null | undefined>(
null,
);
const contextValue: AuthContextType = isAuthEnabled
? {
@@ -73,11 +81,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
case "authenticated": {
const customSession = assertCustomSession(session);
if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) {
// warning: call order-dependent
lastUserId.current = null;
// token had expired but next auth still returns "authenticated" so show user unauthenticated state
return {
status: "unauthenticated" as const,
};
} else if (customSession?.accessToken) {
// updates anyways with updated properties below
// warning! execution order conscience, must be ran before reading lastUserId.current below
lastUserId.current = customSession.user.id;
return {
status,
accessToken: customSession.accessToken,
@@ -92,6 +105,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}
case "unauthenticated": {
// warning: call order-dependent
lastUserId.current = null;
return { status: "unauthenticated" as const };
}
default: {
@@ -103,6 +118,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
update,
signIn,
signOut,
// for optimistic cases when we assume "loading" doesn't immediately invalidate the user
lastUserId: lastUserId.current,
}
: noopAuthContext;

View File

@@ -18,3 +18,8 @@ export const LOGIN_REQUIRED_PAGES = [
export const PROTECTED_PAGES = new RegExp(
LOGIN_REQUIRED_PAGES.map((page) => `^${page}$`).join("|"),
);
export function getLogoutRedirectUrl(pathname: string): string {
const transcriptPagePattern = /^\/transcripts\/[^/]+$/;
return transcriptPagePattern.test(pathname) ? pathname : "/";
}

View File

@@ -22,6 +22,32 @@ import { sequenceThrows } from "./errorUtils";
import { featureEnabled } from "./features";
import { getNextEnvVar } from "./nextBuild";
async function getUserId(accessToken: string): Promise<string | null> {
try {
const apiUrl = getNextEnvVar("SERVER_API_URL");
const response = await fetch(`${apiUrl}/v1/me`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
try {
console.error(await response.text());
} catch (e) {
console.error("Failed to parse error response", e);
}
return null;
}
const userInfo = await response.json();
return userInfo.sub || null;
} catch (error) {
console.error("Error fetching user ID from backend:", error);
return null;
}
}
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
const getAuthentikClientId = () => getNextEnvVar("AUTHENTIK_CLIENT_ID");
const getAuthentikClientSecret = () => getNextEnvVar("AUTHENTIK_CLIENT_SECRET");
@@ -122,13 +148,16 @@ export const authOptions = (): AuthOptions =>
},
async session({ session, token }) {
const extendedToken = token as JWTWithAccessToken;
console.log("extendedToken", extendedToken);
const userId = await getUserId(extendedToken.accessToken);
return {
...session,
accessToken: extendedToken.accessToken,
accessTokenExpires: extendedToken.accessTokenExpires,
error: extendedToken.error,
user: {
id: assertExists(extendedToken.sub),
id: assertExistsAndNonEmptyString(userId, "User ID required"),
name: extendedToken.name,
email: extendedToken.email,
},

View File

@@ -696,7 +696,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/webhook": {
"/v1/daily/webhook": {
parameters: {
query?: never;
header?: never;
@@ -708,6 +708,27 @@ export interface paths {
/**
* Webhook
* @description Handle Daily webhook events.
*
* Example webhook payload:
* {
* "version": "1.0.0",
* "type": "recording.ready-to-download",
* "id": "rec-rtd-c3df927c-f738-4471-a2b7-066fa7e95a6b-1692124192",
* "payload": {
* "recording_id": "08fa0b24-9220-44c5-846c-3f116cf8e738",
* "room_name": "Xcm97xRZ08b2dePKb78g",
* "start_ts": 1692124183,
* "status": "finished",
* "max_participants": 1,
* "duration": 9,
* "share_token": "ntDCL5k98Ulq", #gitleaks:allow
* "s3_key": "api-test-1j8fizhzd30c/Xcm97xRZ08b2dePKb78g/1692124183028"
* },
* "event_ts": 1692124192
* }
*
* Daily.co circuit-breaker: After 3+ failed responses (4xx/5xx), webhook
* state→FAILED, stops sending events. Reset: scripts/recreate_daily_webhook.py
*/
post: operations["v1_webhook"];
delete?: never;
@@ -899,81 +920,11 @@ export interface components {
target_language: string;
source_kind?: components["schemas"]["SourceKind"] | null;
};
/**
* DailyWebhookEvent
* @description Daily webhook event structure.
*/
DailyWebhookEvent: {
/** Type */
type: string;
/** Id */
id: string;
/** Ts */
ts: number;
/** Data */
data: {
[key: string]: unknown;
};
};
/** DeletionStatus */
DeletionStatus: {
/** Status */
status: string;
};
/** GetTranscript */
GetTranscript: {
/** Id */
id: string;
/** User Id */
user_id: string | null;
/** Name */
name: string;
/**
* Status
* @enum {string}
*/
status:
| "idle"
| "uploaded"
| "recording"
| "processing"
| "error"
| "ended";
/** Locked */
locked: boolean;
/** Duration */
duration: number;
/** Title */
title: string | null;
/** Short Summary */
short_summary: string | null;
/** Long Summary */
long_summary: string | null;
/** Created At */
created_at: string;
/**
* Share Mode
* @default private
*/
share_mode: string;
/** Source Language */
source_language: string | null;
/** Target Language */
target_language: string | null;
/** Reviewed */
reviewed: boolean;
/** Meeting Id */
meeting_id: string | null;
source_kind: components["schemas"]["SourceKind"];
/** Room Id */
room_id?: string | null;
/** Room Name */
room_name?: string | null;
/** Audio Deleted */
audio_deleted?: boolean | null;
/** Participants */
participants: components["schemas"]["TranscriptParticipant"][] | null;
};
/** GetTranscriptMinimal */
GetTranscriptMinimal: {
/** Id */
@@ -1105,6 +1056,345 @@ export interface components {
*/
words_per_speaker: components["schemas"]["SpeakerWords"][];
};
/**
* GetTranscriptWithJSON
* @description Transcript response as structured JSON segments.
*
* Format: Array of segment objects with speaker info, text, and timing.
* Example:
* [
* {
* "speaker": 0,
* "speaker_name": "John Smith",
* "text": "Hello everyone",
* "start": 0.0,
* "end": 5.0
* }
* ]
*/
GetTranscriptWithJSON: {
/** Id */
id: string;
/** User Id */
user_id: string | null;
/** Name */
name: string;
/**
* Status
* @enum {string}
*/
status:
| "idle"
| "uploaded"
| "recording"
| "processing"
| "error"
| "ended";
/** Locked */
locked: boolean;
/** Duration */
duration: number;
/** Title */
title: string | null;
/** Short Summary */
short_summary: string | null;
/** Long Summary */
long_summary: string | null;
/** Created At */
created_at: string;
/**
* Share Mode
* @default private
*/
share_mode: string;
/** Source Language */
source_language: string | null;
/** Target Language */
target_language: string | null;
/** Reviewed */
reviewed: boolean;
/** Meeting Id */
meeting_id: string | null;
source_kind: components["schemas"]["SourceKind"];
/** Room Id */
room_id?: string | null;
/** Room Name */
room_name?: string | null;
/** Audio Deleted */
audio_deleted?: boolean | null;
/** Participants */
participants: components["schemas"]["TranscriptParticipant"][] | null;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
transcript_format: "json";
/** Transcript */
transcript: components["schemas"]["TranscriptSegment"][];
};
/** GetTranscriptWithParticipants */
GetTranscriptWithParticipants: {
/** Id */
id: string;
/** User Id */
user_id: string | null;
/** Name */
name: string;
/**
* Status
* @enum {string}
*/
status:
| "idle"
| "uploaded"
| "recording"
| "processing"
| "error"
| "ended";
/** Locked */
locked: boolean;
/** Duration */
duration: number;
/** Title */
title: string | null;
/** Short Summary */
short_summary: string | null;
/** Long Summary */
long_summary: string | null;
/** Created At */
created_at: string;
/**
* Share Mode
* @default private
*/
share_mode: string;
/** Source Language */
source_language: string | null;
/** Target Language */
target_language: string | null;
/** Reviewed */
reviewed: boolean;
/** Meeting Id */
meeting_id: string | null;
source_kind: components["schemas"]["SourceKind"];
/** Room Id */
room_id?: string | null;
/** Room Name */
room_name?: string | null;
/** Audio Deleted */
audio_deleted?: boolean | null;
/** Participants */
participants: components["schemas"]["TranscriptParticipant"][] | null;
};
/**
* GetTranscriptWithText
* @description Transcript response with plain text format.
*
* Format: Speaker names followed by their dialogue, one line per segment.
* Example:
* John Smith: Hello everyone
* Jane Doe: Hi there
*/
GetTranscriptWithText: {
/** Id */
id: string;
/** User Id */
user_id: string | null;
/** Name */
name: string;
/**
* Status
* @enum {string}
*/
status:
| "idle"
| "uploaded"
| "recording"
| "processing"
| "error"
| "ended";
/** Locked */
locked: boolean;
/** Duration */
duration: number;
/** Title */
title: string | null;
/** Short Summary */
short_summary: string | null;
/** Long Summary */
long_summary: string | null;
/** Created At */
created_at: string;
/**
* Share Mode
* @default private
*/
share_mode: string;
/** Source Language */
source_language: string | null;
/** Target Language */
target_language: string | null;
/** Reviewed */
reviewed: boolean;
/** Meeting Id */
meeting_id: string | null;
source_kind: components["schemas"]["SourceKind"];
/** Room Id */
room_id?: string | null;
/** Room Name */
room_name?: string | null;
/** Audio Deleted */
audio_deleted?: boolean | null;
/** Participants */
participants: components["schemas"]["TranscriptParticipant"][] | null;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
transcript_format: "text";
/** Transcript */
transcript: string;
};
/**
* GetTranscriptWithTextTimestamped
* @description Transcript response with timestamped text format.
*
* Format: [MM:SS] timestamp prefix before each speaker and dialogue.
* Example:
* [00:00] John Smith: Hello everyone
* [00:05] Jane Doe: Hi there
*/
GetTranscriptWithTextTimestamped: {
/** Id */
id: string;
/** User Id */
user_id: string | null;
/** Name */
name: string;
/**
* Status
* @enum {string}
*/
status:
| "idle"
| "uploaded"
| "recording"
| "processing"
| "error"
| "ended";
/** Locked */
locked: boolean;
/** Duration */
duration: number;
/** Title */
title: string | null;
/** Short Summary */
short_summary: string | null;
/** Long Summary */
long_summary: string | null;
/** Created At */
created_at: string;
/**
* Share Mode
* @default private
*/
share_mode: string;
/** Source Language */
source_language: string | null;
/** Target Language */
target_language: string | null;
/** Reviewed */
reviewed: boolean;
/** Meeting Id */
meeting_id: string | null;
source_kind: components["schemas"]["SourceKind"];
/** Room Id */
room_id?: string | null;
/** Room Name */
room_name?: string | null;
/** Audio Deleted */
audio_deleted?: boolean | null;
/** Participants */
participants: components["schemas"]["TranscriptParticipant"][] | null;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
transcript_format: "text-timestamped";
/** Transcript */
transcript: string;
};
/**
* GetTranscriptWithWebVTTNamed
* @description Transcript response in WebVTT subtitle format with participant names.
*
* Format: Standard WebVTT with voice tags using participant names.
* Example:
* WEBVTT
*
* 00:00:00.000 --> 00:00:05.000
* <v John Smith>Hello everyone
*/
GetTranscriptWithWebVTTNamed: {
/** Id */
id: string;
/** User Id */
user_id: string | null;
/** Name */
name: string;
/**
* Status
* @enum {string}
*/
status:
| "idle"
| "uploaded"
| "recording"
| "processing"
| "error"
| "ended";
/** Locked */
locked: boolean;
/** Duration */
duration: number;
/** Title */
title: string | null;
/** Short Summary */
short_summary: string | null;
/** Long Summary */
long_summary: string | null;
/** Created At */
created_at: string;
/**
* Share Mode
* @default private
*/
share_mode: string;
/** Source Language */
source_language: string | null;
/** Target Language */
target_language: string | null;
/** Reviewed */
reviewed: boolean;
/** Meeting Id */
meeting_id: string | null;
source_kind: components["schemas"]["SourceKind"];
/** Room Id */
room_id?: string | null;
/** Room Name */
room_name?: string | null;
/** Audio Deleted */
audio_deleted?: boolean | null;
/** Participants */
participants: components["schemas"]["TranscriptParticipant"][] | null;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
transcript_format: "webvtt-named";
/** Transcript */
transcript: string;
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
@@ -1233,7 +1523,6 @@ export interface components {
} | null;
/**
* Platform
* @default whereby
* @enum {string}
*/
platform: "whereby" | "daily";
@@ -1325,7 +1614,6 @@ export interface components {
ics_last_etag?: string | null;
/**
* Platform
* @default whereby
* @enum {string}
*/
platform: "whereby" | "daily";
@@ -1377,7 +1665,6 @@ export interface components {
ics_last_etag?: string | null;
/**
* Platform
* @default whereby
* @enum {string}
*/
platform: "whereby" | "daily";
@@ -1523,6 +1810,24 @@ export interface components {
speaker: number | null;
/** Name */
name: string;
/** User Id */
user_id?: string | null;
};
/**
* TranscriptSegment
* @description A single transcript segment with speaker and timing information.
*/
TranscriptSegment: {
/** Speaker */
speaker: number;
/** Speaker Name */
speaker_name: string;
/** Text */
text: string;
/** Start */
start: number;
/** End */
end: number;
};
/** UpdateParticipant */
UpdateParticipant: {
@@ -2311,7 +2616,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GetTranscript"];
"application/json": components["schemas"]["GetTranscriptWithParticipants"];
};
};
/** @description Validation Error */
@@ -2369,7 +2674,13 @@ export interface operations {
};
v1_transcript_get: {
parameters: {
query?: never;
query?: {
transcript_format?:
| "text"
| "text-timestamped"
| "webvtt-named"
| "json";
};
header?: never;
path: {
transcript_id: string;
@@ -2384,7 +2695,11 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GetTranscript"];
"application/json":
| components["schemas"]["GetTranscriptWithText"]
| components["schemas"]["GetTranscriptWithTextTimestamped"]
| components["schemas"]["GetTranscriptWithWebVTTNamed"]
| components["schemas"]["GetTranscriptWithJSON"];
};
};
/** @description Validation Error */
@@ -2450,7 +2765,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GetTranscript"];
"application/json": components["schemas"]["GetTranscriptWithParticipants"];
};
};
/** @description Validation Error */
@@ -3256,11 +3571,7 @@ export interface operations {
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DailyWebhookEvent"];
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
@@ -3271,15 +3582,6 @@ export interface operations {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}

View File

@@ -31,7 +31,7 @@
"ioredis": "^5.7.0",
"jest-worker": "^29.6.2",
"lucide-react": "^0.525.0",
"next": "^15.5.3",
"next": "^15.5.9",
"next-auth": "^4.24.7",
"next-themes": "^0.4.6",
"nuqs": "^2.4.3",

508
www/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff