Compare commits

...

8 Commits

Author SHA1 Message Date
dc4b737daa chore(main): release 0.16.0 (#711) 2025-10-24 16:18:49 -06:00
Igor Monadical
0baff7abf7 transcript ui copy button placement (#712)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-10-24 16:52:02 -04:00
Igor Monadical
962c40e2b6 feat: search date filter (#710)
* search date filter

* search date filter

* search date filter

* search date filter

* pr comment

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-10-23 20:16:43 -04:00
Igor Monadical
3c4b9f2103 chore: error reporting and naming (#708)
* chore: error reporting and naming

* chore: error reporting and naming

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-10-22 13:45:08 -04:00
Igor Monadical
c6c035aacf removal of email-verified from /me (#707)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-10-21 14:49:33 -04:00
c086b91445 chore(main): release 0.15.0 (#706) 2025-10-21 08:30:22 -06:00
Igor Monadical
9a258abc02 feat: api tokens (#705)
* feat: api tokens (vibe)

* self-review

* remove token terminology + pr comments (vibe)

* return email_verified

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-10-20 12:55:25 -04:00
af86c47f1d chore(main): release 0.14.0 (#670) 2025-10-08 14:57:31 -06:00
24 changed files with 968 additions and 98 deletions

View File

@@ -1,4 +1,4 @@
name: Deploy to Amazon ECS
name: Build container/push to container registry
on: [workflow_dispatch]

View File

@@ -1,5 +1,36 @@
# Changelog
## [0.16.0](https://github.com/Monadical-SAS/reflector/compare/v0.15.0...v0.16.0) (2025-10-24)
### Features
* search date filter ([#710](https://github.com/Monadical-SAS/reflector/issues/710)) ([962c40e](https://github.com/Monadical-SAS/reflector/commit/962c40e2b6428ac42fd10aea926782d7a6f3f902))
## [0.15.0](https://github.com/Monadical-SAS/reflector/compare/v0.14.0...v0.15.0) (2025-10-20)
### Features
* api tokens ([#705](https://github.com/Monadical-SAS/reflector/issues/705)) ([9a258ab](https://github.com/Monadical-SAS/reflector/commit/9a258abc0209b0ac3799532a507ea6a9125d703a))
## [0.14.0](https://github.com/Monadical-SAS/reflector/compare/v0.13.1...v0.14.0) (2025-10-08)
### Features
* Add calendar event data to transcript webhook payload ([#689](https://github.com/Monadical-SAS/reflector/issues/689)) ([5f6910e](https://github.com/Monadical-SAS/reflector/commit/5f6910e5131b7f28f86c9ecdcc57fed8412ee3cd))
* container build for www / github ([#672](https://github.com/Monadical-SAS/reflector/issues/672)) ([969bd84](https://github.com/Monadical-SAS/reflector/commit/969bd84fcc14851d1a101412a0ba115f1b7cde82))
* docker-compose for production frontend ([#664](https://github.com/Monadical-SAS/reflector/issues/664)) ([5bf64b5](https://github.com/Monadical-SAS/reflector/commit/5bf64b5a41f64535e22849b4bb11734d4dbb4aae))
### Bug Fixes
* restore feature boolean logic ([#671](https://github.com/Monadical-SAS/reflector/issues/671)) ([3660884](https://github.com/Monadical-SAS/reflector/commit/36608849ec64e953e3be456172502762e3c33df9))
* security review ([#656](https://github.com/Monadical-SAS/reflector/issues/656)) ([5d98754](https://github.com/Monadical-SAS/reflector/commit/5d98754305c6c540dd194dda268544f6d88bfaf8))
* update transcript list on reprocess ([#676](https://github.com/Monadical-SAS/reflector/issues/676)) ([9a71af1](https://github.com/Monadical-SAS/reflector/commit/9a71af145ee9b833078c78d0c684590ab12e9f0e))
* upgrade nemo toolkit ([#678](https://github.com/Monadical-SAS/reflector/issues/678)) ([eef6dc3](https://github.com/Monadical-SAS/reflector/commit/eef6dc39037329b65804297786d852dddb0557f9))
## [0.13.1](https://github.com/Monadical-SAS/reflector/compare/v0.13.0...v0.13.1) (2025-09-22)

View File

@@ -1,3 +1,29 @@
## API Key Management
### Finding Your User ID
```bash
# Get your OAuth sub (user ID) - requires authentication
curl -H "Authorization: Bearer <your_jwt>" http://localhost:1250/v1/me
# Returns: {"sub": "your-oauth-sub-here", "email": "...", ...}
```
### Creating API Keys
```bash
curl -X POST http://localhost:1250/v1/user/api-keys \
-H "Authorization: Bearer <your_jwt>" \
-H "Content-Type: application/json" \
-d '{"name": "My API Key"}'
```
### Using API Keys
```bash
# Use X-API-Key header instead of Authorization
curl -H "X-API-Key: <your_api_key>" http://localhost:1250/v1/transcripts
```
## AWS S3/SQS usage clarification
Whereby.com uploads recordings directly to our S3 bucket when meetings end.

View File

@@ -0,0 +1,38 @@
"""add user api keys
Revision ID: 9e3f7b2a4c8e
Revises: dc035ff72fd5
Create Date: 2025-10-17 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9e3f7b2a4c8e"
down_revision: Union[str, None] = "dc035ff72fd5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user_api_key",
sa.Column("id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("key_hash", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("user_api_key", schema=None) as batch_op:
batch_op.create_index("idx_user_api_key_hash", ["key_hash"], unique=True)
batch_op.create_index("idx_user_api_key_user_id", ["user_id"], unique=False)
def downgrade() -> None:
op.drop_table("user_api_key")

View File

@@ -26,6 +26,7 @@ from reflector.views.transcripts_upload import router as transcripts_upload_rout
from reflector.views.transcripts_webrtc import router as transcripts_webrtc_router
from reflector.views.transcripts_websocket import router as transcripts_websocket_router
from reflector.views.user import router as user_router
from reflector.views.user_api_keys import router as user_api_keys_router
from reflector.views.user_websocket import router as user_ws_router
from reflector.views.whereby import router as whereby_router
from reflector.views.zulip import router as zulip_router
@@ -91,6 +92,7 @@ app.include_router(transcripts_websocket_router, prefix="/v1")
app.include_router(transcripts_webrtc_router, prefix="/v1")
app.include_router(transcripts_process_router, prefix="/v1")
app.include_router(user_router, prefix="/v1")
app.include_router(user_api_keys_router, prefix="/v1")
app.include_router(user_ws_router, prefix="/v1")
app.include_router(zulip_router, prefix="/v1")
app.include_router(whereby_router, prefix="/v1")

View File

@@ -1,14 +1,16 @@
from typing import Annotated, Optional
from typing import Annotated, List, Optional
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import BaseModel
from reflector.db.user_api_keys import user_api_keys_controller
from reflector.logger import logger
from reflector.settings import settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
jwt_public_key = open(f"reflector/auth/jwt/keys/{settings.AUTH_JWT_PUBLIC_KEY}").read()
jwt_algorithm = settings.AUTH_JWT_ALGORITHM
@@ -26,7 +28,7 @@ class JWTException(Exception):
class UserInfo(BaseModel):
sub: str
email: str
email: Optional[str] = None
def __getitem__(self, key):
return getattr(self, key)
@@ -58,34 +60,53 @@ def authenticated(token: Annotated[str, Depends(oauth2_scheme)]):
return None
def current_user(
token: Annotated[Optional[str], Depends(oauth2_scheme)],
jwtauth: JWTAuth = Depends(),
):
if token is None:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
payload = jwtauth.verify_token(token)
sub = payload["sub"]
email = payload["email"]
return UserInfo(sub=sub, email=email)
except JWTError as e:
logger.error(f"JWT error: {e}")
raise HTTPException(status_code=401, detail="Invalid authentication")
async def _authenticate_user(
jwt_token: Optional[str],
api_key: Optional[str],
jwtauth: JWTAuth,
) -> UserInfo | None:
user_infos: List[UserInfo] = []
if api_key:
user_api_key = await user_api_keys_controller.verify_key(api_key)
if user_api_key:
user_infos.append(UserInfo(sub=user_api_key.user_id, email=None))
if jwt_token:
try:
payload = jwtauth.verify_token(jwt_token)
sub = payload["sub"]
email = payload["email"]
user_infos.append(UserInfo(sub=sub, email=email))
except JWTError as e:
logger.error(f"JWT error: {e}")
raise HTTPException(status_code=401, detail="Invalid authentication")
def current_user_optional(
token: Annotated[Optional[str], Depends(oauth2_scheme)],
jwtauth: JWTAuth = Depends(),
):
# we accept no token, but if one is provided, it must be a valid one.
if token is None:
if len(user_infos) == 0:
return None
try:
payload = jwtauth.verify_token(token)
sub = payload["sub"]
email = payload["email"]
return UserInfo(sub=sub, email=email)
except JWTError as e:
logger.error(f"JWT error: {e}")
raise HTTPException(status_code=401, detail="Invalid authentication")
if len(set([x.sub for x in user_infos])) > 1:
raise JWTException(
status_code=401,
detail="Invalid authentication: more than one user provided",
)
return user_infos[0]
async def current_user(
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
api_key: Annotated[Optional[str], Depends(api_key_header)],
jwtauth: JWTAuth = Depends(),
):
user = await _authenticate_user(jwt_token, api_key, jwtauth)
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
async def current_user_optional(
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
api_key: Annotated[Optional[str], Depends(api_key_header)],
jwtauth: JWTAuth = Depends(),
):
return await _authenticate_user(jwt_token, api_key, jwtauth)

View File

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

View File

@@ -135,6 +135,8 @@ class SearchParameters(BaseModel):
user_id: str | None = None
room_id: str | None = None
source_kind: SourceKind | None = None
from_datetime: datetime | None = None
to_datetime: datetime | None = None
class SearchResultDB(BaseModel):
@@ -402,6 +404,14 @@ class SearchController:
base_query = base_query.where(
transcripts.c.source_kind == params.source_kind
)
if params.from_datetime:
base_query = base_query.where(
transcripts.c.created_at >= params.from_datetime
)
if params.to_datetime:
base_query = base_query.where(
transcripts.c.created_at <= params.to_datetime
)
if params.query_text is not None:
order_by = sqlalchemy.desc(sqlalchemy.text("rank"))

View File

@@ -0,0 +1,90 @@
import hmac
import secrets
from datetime import datetime, timezone
from hashlib import sha256
import sqlalchemy
from pydantic import BaseModel, Field
from reflector.db import get_database, metadata
from reflector.settings import settings
from reflector.utils import generate_uuid4
from reflector.utils.string import NonEmptyString
user_api_keys = sqlalchemy.Table(
"user_api_key",
metadata,
sqlalchemy.Column("id", sqlalchemy.String, primary_key=True),
sqlalchemy.Column("user_id", sqlalchemy.String, nullable=False),
sqlalchemy.Column("key_hash", sqlalchemy.String, nullable=False),
sqlalchemy.Column("name", sqlalchemy.String, nullable=True),
sqlalchemy.Column("created_at", sqlalchemy.DateTime(timezone=True), nullable=False),
sqlalchemy.Index("idx_user_api_key_hash", "key_hash", unique=True),
sqlalchemy.Index("idx_user_api_key_user_id", "user_id"),
)
class UserApiKey(BaseModel):
id: NonEmptyString = Field(default_factory=generate_uuid4)
user_id: NonEmptyString
key_hash: NonEmptyString
name: NonEmptyString | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class UserApiKeyController:
@staticmethod
def generate_key() -> NonEmptyString:
return secrets.token_urlsafe(48)
@staticmethod
def hash_key(key: NonEmptyString) -> str:
return hmac.new(
settings.SECRET_KEY.encode(), key.encode(), digestmod=sha256
).hexdigest()
@classmethod
async def create_key(
cls,
user_id: NonEmptyString,
name: NonEmptyString | None = None,
) -> tuple[UserApiKey, NonEmptyString]:
plaintext = cls.generate_key()
api_key = UserApiKey(
user_id=user_id,
key_hash=cls.hash_key(plaintext),
name=name,
)
query = user_api_keys.insert().values(**api_key.model_dump())
await get_database().execute(query)
return api_key, plaintext
@classmethod
async def verify_key(cls, plaintext_key: NonEmptyString) -> UserApiKey | None:
key_hash = cls.hash_key(plaintext_key)
query = user_api_keys.select().where(
user_api_keys.c.key_hash == key_hash,
)
result = await get_database().fetch_one(query)
return UserApiKey(**result) if result else None
@staticmethod
async def list_by_user_id(user_id: NonEmptyString) -> list[UserApiKey]:
query = (
user_api_keys.select()
.where(user_api_keys.c.user_id == user_id)
.order_by(user_api_keys.c.created_at.desc())
)
results = await get_database().fetch_all(query)
return [UserApiKey(**r) for r in results]
@staticmethod
async def delete_key(key_id: NonEmptyString, user_id: NonEmptyString) -> bool:
query = user_api_keys.delete().where(
(user_api_keys.c.id == key_id) & (user_api_keys.c.user_id == user_id)
)
result = await get_database().execute(query)
return result > 0
user_api_keys_controller = UserApiKeyController()

View File

@@ -426,7 +426,12 @@ async def task_pipeline_file_process(*, transcript_id: str):
await pipeline.process(audio_file)
except Exception:
except Exception as e:
logger.error(
f"File pipeline failed for transcript {transcript_id}: {type(e).__name__}: {str(e)}",
exc_info=True,
transcript_id=transcript_id,
)
await pipeline.set_status(transcript_id, "error")
raise

View File

@@ -56,6 +56,16 @@ class FileTranscriptModalProcessor(FileTranscriptProcessor):
},
follow_redirects=True,
)
if response.status_code != 200:
error_body = response.text
self.logger.error(
"Modal API error",
audio_url=data.audio_url,
status_code=response.status_code,
error_body=error_body,
)
response.raise_for_status()
result = response.json()

View File

@@ -34,8 +34,16 @@ TOPIC_PROMPT = dedent(
class TopicResponse(BaseModel):
"""Structured response for topic detection"""
title: str = Field(description="A descriptive title for the topic being discussed")
summary: str = Field(description="A concise 1-2 sentence summary of the discussion")
title: str = Field(
description="A descriptive title for the topic being discussed",
validation_alias="Title",
)
summary: str = Field(
description="A concise 1-2 sentence summary of the discussion",
validation_alias="Summary",
)
model_config = {"populate_by_name": True}
class TranscriptTopicDetectorProcessor(Processor):

View File

@@ -5,7 +5,7 @@ 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 BaseModel, Field, constr, field_serializer
from pydantic import AwareDatetime, BaseModel, Field, constr, field_serializer
import reflector.auth as auth
from reflector.db import get_database
@@ -133,6 +133,21 @@ SearchOffsetParam = Annotated[
SearchOffsetBase, Query(description="Number of results to skip")
]
SearchFromDatetimeParam = Annotated[
AwareDatetime | None,
Query(
alias="from",
description="Filter transcripts created on or after this datetime (ISO 8601 with timezone)",
),
]
SearchToDatetimeParam = Annotated[
AwareDatetime | None,
Query(
alias="to",
description="Filter transcripts created on or before this datetime (ISO 8601 with timezone)",
),
]
class SearchResponse(BaseModel):
results: list[SearchResult]
@@ -174,18 +189,23 @@ async def transcripts_search(
offset: SearchOffsetParam = 0,
room_id: Optional[str] = None,
source_kind: Optional[SourceKind] = None,
from_datetime: SearchFromDatetimeParam = None,
to_datetime: SearchToDatetimeParam = None,
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional)
] = None,
):
"""
Full-text search across transcript titles and content.
"""
"""Full-text search across transcript titles and content."""
if not user and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user["sub"] if user else None
if from_datetime and to_datetime and from_datetime > to_datetime:
raise HTTPException(
status_code=400, detail="'from' must be less than or equal to 'to'"
)
search_params = SearchParameters(
query_text=parse_search_query_param(q),
limit=limit,
@@ -193,6 +213,8 @@ async def transcripts_search(
user_id=user_id,
room_id=room_id,
source_kind=source_kind,
from_datetime=from_datetime,
to_datetime=to_datetime,
)
results, total = await search_controller.search_transcripts(search_params)

View File

@@ -11,7 +11,6 @@ router = APIRouter()
class UserInfo(BaseModel):
sub: str
email: Optional[str]
email_verified: Optional[bool]
@router.get("/me")

View File

@@ -0,0 +1,62 @@
from datetime import datetime
from typing import Annotated
import structlog
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import reflector.auth as auth
from reflector.db.user_api_keys import user_api_keys_controller
from reflector.utils.string import NonEmptyString
router = APIRouter()
logger = structlog.get_logger(__name__)
class CreateApiKeyRequest(BaseModel):
name: NonEmptyString | None = None
class ApiKeyResponse(BaseModel):
id: NonEmptyString
user_id: NonEmptyString
name: NonEmptyString | None
created_at: datetime
class CreateApiKeyResponse(ApiKeyResponse):
key: NonEmptyString
@router.post("/user/api-keys", response_model=CreateApiKeyResponse)
async def create_api_key(
req: CreateApiKeyRequest,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
api_key_model, plaintext = await user_api_keys_controller.create_key(
user_id=user["sub"],
name=req.name,
)
return CreateApiKeyResponse(
**api_key_model.model_dump(),
key=plaintext,
)
@router.get("/user/api-keys", response_model=list[ApiKeyResponse])
async def list_api_keys(
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
api_keys = await user_api_keys_controller.list_by_user_id(user["sub"])
return [ApiKeyResponse(**k.model_dump()) for k in api_keys]
@router.delete("/user/api-keys/{key_id}")
async def delete_api_key(
key_id: NonEmptyString,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
deleted = await user_api_keys_controller.delete_key(key_id, user["sub"])
if not deleted:
raise HTTPException(status_code=404)
return {"status": "ok"}

View File

@@ -0,0 +1,256 @@
from datetime import datetime, timedelta, timezone
import pytest
from reflector.db import get_database
from reflector.db.search import SearchParameters, search_controller
from reflector.db.transcripts import SourceKind, transcripts
@pytest.mark.asyncio
class TestDateRangeIntegration:
async def setup_test_transcripts(self):
# Use a test user_id that will match in our search parameters
test_user_id = "test-user-123"
test_data = [
{
"id": "test-before-range",
"created_at": datetime(2024, 1, 15, tzinfo=timezone.utc),
"title": "Before Range Transcript",
"user_id": test_user_id,
},
{
"id": "test-start-boundary",
"created_at": datetime(2024, 6, 1, tzinfo=timezone.utc),
"title": "Start Boundary Transcript",
"user_id": test_user_id,
},
{
"id": "test-middle-range",
"created_at": datetime(2024, 6, 15, tzinfo=timezone.utc),
"title": "Middle Range Transcript",
"user_id": test_user_id,
},
{
"id": "test-end-boundary",
"created_at": datetime(2024, 6, 30, 23, 59, 59, tzinfo=timezone.utc),
"title": "End Boundary Transcript",
"user_id": test_user_id,
},
{
"id": "test-after-range",
"created_at": datetime(2024, 12, 31, tzinfo=timezone.utc),
"title": "After Range Transcript",
"user_id": test_user_id,
},
]
for data in test_data:
full_data = {
"id": data["id"],
"name": data["id"],
"status": "ended",
"locked": False,
"duration": 60.0,
"created_at": data["created_at"],
"title": data["title"],
"short_summary": "Test summary",
"long_summary": "Test long summary",
"share_mode": "public",
"source_kind": SourceKind.FILE,
"audio_deleted": False,
"reviewed": False,
"user_id": data["user_id"],
}
await get_database().execute(transcripts.insert().values(**full_data))
return test_data
async def cleanup_test_transcripts(self, test_data):
"""Clean up test transcripts."""
for data in test_data:
await get_database().execute(
transcripts.delete().where(transcripts.c.id == data["id"])
)
@pytest.mark.asyncio
async def test_filter_with_from_datetime_only(self):
"""Test filtering with only from_datetime parameter."""
test_data = await self.setup_test_transcripts()
test_user_id = "test-user-123"
try:
params = SearchParameters(
query_text=None,
from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc),
to_datetime=None,
user_id=test_user_id,
)
results, total = await search_controller.search_transcripts(params)
# Should include: start_boundary, middle, end_boundary, after
result_ids = [r.id for r in results]
assert "test-before-range" not in result_ids
assert "test-start-boundary" in result_ids
assert "test-middle-range" in result_ids
assert "test-end-boundary" in result_ids
assert "test-after-range" in result_ids
finally:
await self.cleanup_test_transcripts(test_data)
@pytest.mark.asyncio
async def test_filter_with_to_datetime_only(self):
"""Test filtering with only to_datetime parameter."""
test_data = await self.setup_test_transcripts()
test_user_id = "test-user-123"
try:
params = SearchParameters(
query_text=None,
from_datetime=None,
to_datetime=datetime(2024, 6, 30, tzinfo=timezone.utc),
user_id=test_user_id,
)
results, total = await search_controller.search_transcripts(params)
result_ids = [r.id for r in results]
assert "test-before-range" in result_ids
assert "test-start-boundary" in result_ids
assert "test-middle-range" in result_ids
assert "test-end-boundary" not in result_ids
assert "test-after-range" not in result_ids
finally:
await self.cleanup_test_transcripts(test_data)
@pytest.mark.asyncio
async def test_filter_with_both_datetimes(self):
test_data = await self.setup_test_transcripts()
test_user_id = "test-user-123"
try:
params = SearchParameters(
query_text=None,
from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc),
to_datetime=datetime(
2024, 7, 1, tzinfo=timezone.utc
), # Inclusive of 6/30
user_id=test_user_id,
)
results, total = await search_controller.search_transcripts(params)
result_ids = [r.id for r in results]
assert "test-before-range" not in result_ids
assert "test-start-boundary" in result_ids
assert "test-middle-range" in result_ids
assert "test-end-boundary" in result_ids
assert "test-after-range" not in result_ids
finally:
await self.cleanup_test_transcripts(test_data)
@pytest.mark.asyncio
async def test_date_filter_with_room_and_source_kind(self):
test_data = await self.setup_test_transcripts()
test_user_id = "test-user-123"
try:
params = SearchParameters(
query_text=None,
from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc),
to_datetime=datetime(2024, 7, 1, tzinfo=timezone.utc),
source_kind=SourceKind.FILE,
room_id=None,
user_id=test_user_id,
)
results, total = await search_controller.search_transcripts(params)
for result in results:
assert result.source_kind == SourceKind.FILE
assert result.created_at >= datetime(2024, 6, 1, tzinfo=timezone.utc)
assert result.created_at <= datetime(2024, 7, 1, tzinfo=timezone.utc)
finally:
await self.cleanup_test_transcripts(test_data)
@pytest.mark.asyncio
async def test_empty_results_for_future_dates(self):
test_data = await self.setup_test_transcripts()
test_user_id = "test-user-123"
try:
params = SearchParameters(
query_text=None,
from_datetime=datetime(2099, 1, 1, tzinfo=timezone.utc),
to_datetime=datetime(2099, 12, 31, tzinfo=timezone.utc),
user_id=test_user_id,
)
results, total = await search_controller.search_transcripts(params)
assert results == []
assert total == 0
finally:
await self.cleanup_test_transcripts(test_data)
@pytest.mark.asyncio
async def test_date_only_input_handling(self):
test_data = await self.setup_test_transcripts()
test_user_id = "test-user-123"
try:
# Pydantic will parse date-only strings to datetime at midnight
from_dt = datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc)
to_dt = datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc)
params = SearchParameters(
query_text=None,
from_datetime=from_dt,
to_datetime=to_dt,
user_id=test_user_id,
)
results, total = await search_controller.search_transcripts(params)
result_ids = [r.id for r in results]
assert "test-middle-range" in result_ids
assert "test-before-range" not in result_ids
assert "test-after-range" not in result_ids
finally:
await self.cleanup_test_transcripts(test_data)
class TestDateValidationEdgeCases:
"""Edge case tests for datetime validation."""
def test_timezone_aware_comparison(self):
"""Test that timezone-aware comparisons work correctly."""
# PST time (UTC-8)
pst = timezone(timedelta(hours=-8))
pst_dt = datetime(2024, 6, 15, 8, 0, 0, tzinfo=pst)
# UTC time equivalent (8AM PST = 4PM UTC)
utc_dt = datetime(2024, 6, 15, 16, 0, 0, tzinfo=timezone.utc)
assert pst_dt == utc_dt
def test_mixed_timezone_input(self):
"""Test handling mixed timezone inputs."""
pst = timezone(timedelta(hours=-8))
ist = timezone(timedelta(hours=5, minutes=30))
from_date = datetime(2024, 6, 15, 0, 0, 0, tzinfo=pst) # PST midnight
to_date = datetime(2024, 6, 15, 23, 59, 59, tzinfo=ist) # IST end of day
assert from_date.tzinfo is not None
assert to_date.tzinfo is not None
assert from_date < to_date

View File

@@ -0,0 +1,70 @@
import pytest
from reflector.db.user_api_keys import user_api_keys_controller
@pytest.mark.asyncio
async def test_api_key_creation_and_verification():
api_key_model, plaintext = await user_api_keys_controller.create_key(
user_id="test_user",
name="Test API Key",
)
verified = await user_api_keys_controller.verify_key(plaintext)
assert verified is not None
assert verified.user_id == "test_user"
assert verified.name == "Test API Key"
invalid = await user_api_keys_controller.verify_key("fake_key")
assert invalid is None
@pytest.mark.asyncio
async def test_api_key_hashing():
_, plaintext = await user_api_keys_controller.create_key(
user_id="test_user_2",
)
api_keys = await user_api_keys_controller.list_by_user_id("test_user_2")
assert len(api_keys) == 1
assert api_keys[0].key_hash != plaintext
@pytest.mark.asyncio
async def test_generate_api_key_uniqueness():
key1 = user_api_keys_controller.generate_key()
key2 = user_api_keys_controller.generate_key()
assert key1 != key2
@pytest.mark.asyncio
async def test_hash_api_key_deterministic():
key = "test_key_123"
hash1 = user_api_keys_controller.hash_key(key)
hash2 = user_api_keys_controller.hash_key(key)
assert hash1 == hash2
@pytest.mark.asyncio
async def test_get_by_user_id_empty():
api_keys = await user_api_keys_controller.list_by_user_id("nonexistent_user")
assert api_keys == []
@pytest.mark.asyncio
async def test_get_by_user_id_multiple():
user_id = "multi_key_user"
_, plaintext1 = await user_api_keys_controller.create_key(
user_id=user_id,
name="API Key 1",
)
_, plaintext2 = await user_api_keys_controller.create_key(
user_id=user_id,
name="API Key 2",
)
api_keys = await user_api_keys_controller.list_by_user_id(user_id)
assert len(api_keys) == 2
names = {k.name for k in api_keys}
assert names == {"API Key 1", "API Key 2"}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import React from "react";
import Markdown from "react-markdown";
import "../../../styles/markdown.css";
@@ -16,17 +16,15 @@ import {
} from "@chakra-ui/react";
import { LuPen } from "react-icons/lu";
import { useError } from "../../../(errors)/errorContext";
import ShareAndPrivacy from "../shareAndPrivacy";
type FinalSummaryProps = {
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
onUpdate?: (newSummary) => void;
transcript: GetTranscript;
topics: GetTranscriptTopic[];
onUpdate: (newSummary: string) => void;
finalSummaryRef: React.Dispatch<React.SetStateAction<HTMLDivElement | null>>;
};
export default function FinalSummary(props: FinalSummaryProps) {
const finalSummaryRef = useRef<HTMLParagraphElement>(null);
const [isEditMode, setIsEditMode] = useState(false);
const [preEditSummary, setPreEditSummary] = useState("");
const [editedSummary, setEditedSummary] = useState("");
@@ -35,10 +33,10 @@ export default function FinalSummary(props: FinalSummaryProps) {
const updateTranscriptMutation = useTranscriptUpdate();
useEffect(() => {
setEditedSummary(props.transcriptResponse?.long_summary || "");
}, [props.transcriptResponse?.long_summary]);
setEditedSummary(props.transcript?.long_summary || "");
}, [props.transcript?.long_summary]);
if (!props.topicsResponse || !props.transcriptResponse) {
if (!props.topics || !props.transcript) {
return null;
}
@@ -54,9 +52,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
long_summary: newSummary,
},
});
if (props.onUpdate) {
props.onUpdate(newSummary);
}
props.onUpdate(newSummary);
console.log("Updated long summary:", updatedTranscript);
} catch (err) {
console.error("Failed to update long summary:", err);
@@ -75,7 +71,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
};
const onSaveClick = () => {
updateSummary(editedSummary, props.transcriptResponse.id);
updateSummary(editedSummary, props.transcript.id);
setIsEditMode(false);
};
@@ -133,11 +129,6 @@ export default function FinalSummary(props: FinalSummaryProps) {
>
<LuPen />
</IconButton>
<ShareAndPrivacy
finalSummaryRef={finalSummaryRef}
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
/>
</>
)}
</Flex>
@@ -153,7 +144,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
mt={2}
/>
) : (
<div ref={finalSummaryRef} className="markdown">
<div ref={props.finalSummaryRef} className="markdown">
<Markdown>{editedSummary}</Markdown>
</div>
)}

View File

@@ -41,6 +41,8 @@ export default function TranscriptDetails(details: TranscriptDetails) {
waiting || mp3.audioDeleted === true,
);
const useActiveTopic = useState<Topic | null>(null);
const [finalSummaryElement, setFinalSummaryElement] =
useState<HTMLDivElement | null>(null);
useEffect(() => {
if (waiting) {
@@ -124,9 +126,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
<TranscriptTitle
title={transcript.data?.title || "Unnamed Transcript"}
transcriptId={transcriptId}
onUpdate={(newTitle) => {
onUpdate={() => {
transcript.refetch().then(() => {});
}}
transcript={transcript.data || null}
topics={topics.topics}
finalSummaryElement={finalSummaryElement}
/>
</Flex>
{mp3.audioDeleted && (
@@ -148,11 +153,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
{transcript.data && topics.topics ? (
<>
<FinalSummary
transcriptResponse={transcript.data}
topicsResponse={topics.topics}
transcript={transcript.data}
topics={topics.topics}
onUpdate={() => {
transcript.refetch();
transcript.refetch().then(() => {});
}}
finalSummaryRef={setFinalSummaryElement}
/>
</>
) : (

View File

@@ -26,9 +26,9 @@ import { useAuth } from "../../lib/AuthProvider";
import { featureEnabled } from "../../lib/features";
type ShareAndPrivacyProps = {
finalSummaryRef: any;
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
finalSummaryElement: HTMLDivElement | null;
transcript: GetTranscript;
topics: GetTranscriptTopic[];
};
type ShareOption = { value: ShareMode; label: string };
@@ -48,7 +48,7 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const [isOwner, setIsOwner] = useState(false);
const [shareMode, setShareMode] = useState<ShareOption>(
shareOptionsData.find(
(option) => option.value === props.transcriptResponse.share_mode,
(option) => option.value === props.transcript.share_mode,
) || shareOptionsData[0],
);
const [shareLoading, setShareLoading] = useState(false);
@@ -70,7 +70,7 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
try {
const updatedTranscript = await updateTranscriptMutation.mutateAsync({
params: {
path: { transcript_id: props.transcriptResponse.id },
path: { transcript_id: props.transcript.id },
},
body: requestBody,
});
@@ -90,8 +90,8 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const userId = auth.status === "authenticated" ? auth.user?.id : null;
useEffect(() => {
setIsOwner(!!(requireLogin && userId === props.transcriptResponse.user_id));
}, [userId, props.transcriptResponse.user_id]);
setIsOwner(!!(requireLogin && userId === props.transcript.user_id));
}, [userId, props.transcript.user_id]);
return (
<>
@@ -171,19 +171,19 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
<Flex gap={2} mb={2}>
{requireLogin && (
<ShareZulip
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
transcript={props.transcript}
topics={props.topics}
disabled={toShareMode(shareMode.value) === "private"}
/>
)}
<ShareCopy
finalSummaryRef={props.finalSummaryRef}
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
finalSummaryElement={props.finalSummaryElement}
transcript={props.transcript}
topics={props.topics}
/>
</Flex>
<ShareLink transcriptId={props.transcriptResponse.id} />
<ShareLink transcriptId={props.transcript.id} />
</Dialog.Body>
</Dialog.Content>
</Dialog.Positioner>

View File

@@ -5,34 +5,35 @@ type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { Button, BoxProps, Box } from "@chakra-ui/react";
type ShareCopyProps = {
finalSummaryRef: any;
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
finalSummaryElement: HTMLDivElement | null;
transcript: GetTranscript;
topics: GetTranscriptTopic[];
};
export default function ShareCopy({
finalSummaryRef,
transcriptResponse,
topicsResponse,
finalSummaryElement,
transcript,
topics,
...boxProps
}: ShareCopyProps & BoxProps) {
const [isCopiedSummary, setIsCopiedSummary] = useState(false);
const [isCopiedTranscript, setIsCopiedTranscript] = useState(false);
const onCopySummaryClick = () => {
let text_to_copy = finalSummaryRef.current?.innerText;
const text_to_copy = finalSummaryElement?.innerText;
text_to_copy &&
if (text_to_copy) {
navigator.clipboard.writeText(text_to_copy).then(() => {
setIsCopiedSummary(true);
// Reset the copied state after 2 seconds
setTimeout(() => setIsCopiedSummary(false), 2000);
});
}
};
const onCopyTranscriptClick = () => {
let text_to_copy =
topicsResponse
topics
?.map((topic) => topic.transcript)
.join("\n\n")
.replace(/ +/g, " ")

View File

@@ -26,8 +26,8 @@ import {
import { featureEnabled } from "../../lib/features";
type ShareZulipProps = {
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
transcript: GetTranscript;
topics: GetTranscriptTopic[];
disabled: boolean;
};
@@ -88,14 +88,14 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
}, [stream, streams]);
const handleSendToZulip = async () => {
if (!props.transcriptResponse) return;
if (!props.transcript) return;
if (stream && topic) {
try {
await postToZulipMutation.mutateAsync({
params: {
path: {
transcript_id: props.transcriptResponse.id,
transcript_id: props.transcript.id,
},
query: {
stream,

View File

@@ -2,14 +2,22 @@ import { useState } from "react";
import type { components } from "../../reflector-api";
type UpdateTranscript = components["schemas"]["UpdateTranscript"];
type GetTranscript = components["schemas"]["GetTranscript"];
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { useTranscriptUpdate } from "../../lib/apiHooks";
import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react";
import { LuPen } from "react-icons/lu";
import ShareAndPrivacy from "./shareAndPrivacy";
type TranscriptTitle = {
title: string;
transcriptId: string;
onUpdate?: (newTitle: string) => void;
onUpdate: (newTitle: string) => void;
// share props
transcript: GetTranscript | null;
topics: GetTranscriptTopic[] | null;
finalSummaryElement: HTMLDivElement | null;
};
const TranscriptTitle = (props: TranscriptTitle) => {
@@ -29,9 +37,7 @@ const TranscriptTitle = (props: TranscriptTitle) => {
},
body: requestBody,
});
if (props.onUpdate) {
props.onUpdate(newTitle);
}
props.onUpdate(newTitle);
console.log("Updated transcript title:", newTitle);
} catch (err) {
console.error("Failed to update transcript:", err);
@@ -62,11 +68,11 @@ const TranscriptTitle = (props: TranscriptTitle) => {
}
setIsEditing(false);
};
const handleChange = (e) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setDisplayedTitle(e.target.value);
};
const handleKeyDown = (e) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
updateTitle(displayedTitle, props.transcriptId);
setIsEditing(false);
@@ -111,6 +117,13 @@ const TranscriptTitle = (props: TranscriptTitle) => {
>
<LuPen />
</IconButton>
{props.transcript && props.topics && (
<ShareAndPrivacy
finalSummaryElement={props.finalSummaryElement}
transcript={props.transcript}
topics={props.topics}
/>
)}
</Flex>
)}
</>

View File

@@ -4,6 +4,23 @@
*/
export interface paths {
"/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Health */
get: operations["health"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/metrics": {
parameters: {
query?: never;
@@ -587,6 +604,41 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/user/api-keys": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Api Keys */
get: operations["v1_list_api_keys"];
put?: never;
/** Create Api Key */
post: operations["v1_create_api_key"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/user/api-keys/{key_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete Api Key */
delete: operations["v1_delete_api_key"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/zulip/streams": {
parameters: {
query?: never;
@@ -648,6 +700,26 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** ApiKeyResponse */
ApiKeyResponse: {
/**
* Id
* @description A non-empty string
*/
id: string;
/**
* User Id
* @description A non-empty string
*/
user_id: string;
/** Name */
name: string | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** AudioWaveform */
AudioWaveform: {
/** Data */
@@ -707,6 +779,36 @@ export interface components {
*/
updated_at: string;
};
/** CreateApiKeyRequest */
CreateApiKeyRequest: {
/** Name */
name?: string | null;
};
/** CreateApiKeyResponse */
CreateApiKeyResponse: {
/**
* Id
* @description A non-empty string
*/
id: string;
/**
* User Id
* @description A non-empty string
*/
user_id: string;
/** Name */
name: string | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
/**
* Key
* @description A non-empty string
*/
key: string;
};
/** CreateParticipant */
CreateParticipant: {
/** Speaker */
@@ -1431,8 +1533,6 @@ export interface components {
sub: string;
/** Email */
email: string | null;
/** Email Verified */
email_verified: boolean | null;
};
/** ValidationError */
ValidationError: {
@@ -1509,6 +1609,26 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
health: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
metrics: {
parameters: {
query?: never;
@@ -2158,6 +2278,10 @@ export interface operations {
offset?: number;
room_id?: string | null;
source_kind?: components["schemas"]["SourceKind"] | null;
/** @description Filter transcripts created on or after this datetime (ISO 8601 with timezone) */
from?: string | null;
/** @description Filter transcripts created on or before this datetime (ISO 8601 with timezone) */
to?: string | null;
};
header?: never;
path?: never;
@@ -2899,6 +3023,90 @@ export interface operations {
};
};
};
v1_list_api_keys: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiKeyResponse"][];
};
};
};
};
v1_create_api_key: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateApiKeyRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CreateApiKeyResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_delete_api_key: {
parameters: {
query?: never;
header?: never;
path: {
key_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_zulip_get_streams: {
parameters: {
query?: never;