Merge branch 'main' into mathieu/calendar-integration-rebased

This commit is contained in:
Igor Monadical
2025-09-16 15:53:18 -04:00
committed by GitHub
9 changed files with 108 additions and 114 deletions

View File

@@ -1,5 +1,18 @@
# Changelog # Changelog
## [0.11.0](https://github.com/Monadical-SAS/reflector/compare/v0.10.0...v0.11.0) (2025-09-16)
### Features
* remove profanity filter that was there for conference ([#652](https://github.com/Monadical-SAS/reflector/issues/652)) ([b42f7cf](https://github.com/Monadical-SAS/reflector/commit/b42f7cfc606783afcee792590efcc78b507468ab))
### Bug Fixes
* zulip and consent handler on the file pipeline ([#645](https://github.com/Monadical-SAS/reflector/issues/645)) ([5f143fe](https://github.com/Monadical-SAS/reflector/commit/5f143fe3640875dcb56c26694254a93189281d17))
* zulip stream and topic selection in share dialog ([#644](https://github.com/Monadical-SAS/reflector/issues/644)) ([c546e69](https://github.com/Monadical-SAS/reflector/commit/c546e69739e68bb74fbc877eb62609928e5b8de6))
## [0.10.0](https://github.com/Monadical-SAS/reflector/compare/v0.9.0...v0.10.0) (2025-09-11) ## [0.10.0](https://github.com/Monadical-SAS/reflector/compare/v0.9.0...v0.10.0) (2025-09-11)

View File

@@ -26,7 +26,6 @@ dependencies = [
"prometheus-fastapi-instrumentator>=6.1.0", "prometheus-fastapi-instrumentator>=6.1.0",
"sentencepiece>=0.1.99", "sentencepiece>=0.1.99",
"protobuf>=4.24.3", "protobuf>=4.24.3",
"profanityfilter>=2.0.6",
"celery>=5.3.4", "celery>=5.3.4",
"redis>=5.0.1", "redis>=5.0.1",
"python-jose[cryptography]>=3.3.0", "python-jose[cryptography]>=3.3.0",

View File

@@ -12,7 +12,7 @@ from pathlib import Path
import av import av
import structlog import structlog
from celery import shared_task from celery import chain, shared_task
from reflector.asynctask import asynctask from reflector.asynctask import asynctask
from reflector.db.rooms import rooms_controller from reflector.db.rooms import rooms_controller
@@ -26,6 +26,8 @@ from reflector.logger import logger
from reflector.pipelines.main_live_pipeline import ( from reflector.pipelines.main_live_pipeline import (
PipelineMainBase, PipelineMainBase,
broadcast_to_sockets, broadcast_to_sockets,
task_cleanup_consent,
task_pipeline_post_to_zulip,
) )
from reflector.processors import ( from reflector.processors import (
AudioFileWriterProcessor, AudioFileWriterProcessor,
@@ -379,6 +381,28 @@ class PipelineMainFile(PipelineMainBase):
await processor.flush() await processor.flush()
@shared_task
@asynctask
async def task_send_webhook_if_needed(*, transcript_id: str):
"""Send webhook if this is a room recording with webhook configured"""
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript:
return
if transcript.source_kind == SourceKind.ROOM and transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.webhook_url:
logger.info(
"Dispatching webhook",
transcript_id=transcript_id,
room_id=room.id,
webhook_url=room.webhook_url,
)
send_transcript_webhook.delay(
transcript_id, room.id, event_id=uuid.uuid4().hex
)
@shared_task @shared_task
@asynctask @asynctask
async def task_pipeline_file_process(*, transcript_id: str): async def task_pipeline_file_process(*, transcript_id: str):
@@ -406,16 +430,10 @@ async def task_pipeline_file_process(*, transcript_id: str):
await pipeline.set_status(transcript_id, "error") await pipeline.set_status(transcript_id, "error")
raise raise
# Trigger webhook if this is a room recording with webhook configured # Run post-processing chain: consent cleanup -> zulip -> webhook
if transcript.source_kind == SourceKind.ROOM and transcript.room_id: post_chain = chain(
room = await rooms_controller.get_by_id(transcript.room_id) task_cleanup_consent.si(transcript_id=transcript_id),
if room and room.webhook_url: task_pipeline_post_to_zulip.si(transcript_id=transcript_id),
logger.info( task_send_webhook_if_needed.si(transcript_id=transcript_id),
"Dispatching webhook task", )
transcript_id=transcript_id, post_chain.delay()
room_id=room.id,
webhook_url=room.webhook_url,
)
send_transcript_webhook.delay(
transcript_id, room.id, event_id=uuid.uuid4().hex
)

View File

@@ -4,11 +4,8 @@ import tempfile
from pathlib import Path from pathlib import Path
from typing import Annotated, TypedDict from typing import Annotated, TypedDict
from profanityfilter import ProfanityFilter
from pydantic import BaseModel, Field, PrivateAttr from pydantic import BaseModel, Field, PrivateAttr
from reflector.redis_cache import redis_cache
class DiarizationSegment(TypedDict): class DiarizationSegment(TypedDict):
"""Type definition for diarization segment containing speaker information""" """Type definition for diarization segment containing speaker information"""
@@ -20,9 +17,6 @@ class DiarizationSegment(TypedDict):
PUNC_RE = re.compile(r"[.;:?!…]") PUNC_RE = re.compile(r"[.;:?!…]")
profanity_filter = ProfanityFilter()
profanity_filter.set_censor("*")
class AudioFile(BaseModel): class AudioFile(BaseModel):
name: str name: str
@@ -124,21 +118,11 @@ def words_to_segments(words: list[Word]) -> list[TranscriptSegment]:
class Transcript(BaseModel): class Transcript(BaseModel):
translation: str | None = None translation: str | None = None
words: list[Word] = None words: list[Word] = []
@property
def raw_text(self):
# Uncensored text
return "".join([word.text for word in self.words])
@redis_cache(prefix="profanity", duration=3600 * 24 * 7)
def _get_censored_text(self, text: str):
return profanity_filter.censor(text).strip()
@property @property
def text(self): def text(self):
# Censored text return "".join([word.text for word in self.words])
return self._get_censored_text(self.raw_text)
@property @property
def human_timestamp(self): def human_timestamp(self):
@@ -170,12 +154,6 @@ class Transcript(BaseModel):
word.start += offset word.start += offset
word.end += offset word.end += offset
def clone(self):
words = [
Word(text=word.text, start=word.start, end=word.end) for word in self.words
]
return Transcript(text=self.text, translation=self.translation, words=words)
def as_segments(self) -> list[TranscriptSegment]: def as_segments(self) -> list[TranscriptSegment]:
return words_to_segments(self.words) return words_to_segments(self.words)

23
server/uv.lock generated
View File

@@ -1340,15 +1340,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" },
] ]
[[package]]
name = "inflection"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.1.0" version = "2.1.0"
@@ -2313,18 +2304,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/74/c1/bb7e334135859c3a92ec399bc89293ea73f28e815e35b43929c8db6af030/primePy-1.3-py3-none-any.whl", hash = "sha256:5ed443718765be9bf7e2ff4c56cdff71b42140a15b39d054f9d99f0009e2317a", size = 4040, upload-time = "2018-05-29T17:18:17.53Z" }, { url = "https://files.pythonhosted.org/packages/74/c1/bb7e334135859c3a92ec399bc89293ea73f28e815e35b43929c8db6af030/primePy-1.3-py3-none-any.whl", hash = "sha256:5ed443718765be9bf7e2ff4c56cdff71b42140a15b39d054f9d99f0009e2317a", size = 4040, upload-time = "2018-05-29T17:18:17.53Z" },
] ]
[[package]]
name = "profanityfilter"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "inflection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8d/03/08740b5e0800f9eb9f675c149a497a3f3735e7b04e414bcce64136e7e487/profanityfilter-2.1.0.tar.gz", hash = "sha256:0ede04e92a9d7255faa52b53776518edc6586dda828aca677c74b5994dfdd9d8", size = 7910, upload-time = "2024-11-25T22:31:51.194Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/03/eb18f72dc6e6398e75e3762677f18ab3a773a384b18efd3ed9119844e892/profanityfilter-2.1.0-py2.py3-none-any.whl", hash = "sha256:e1bc07012760fd74512a335abb93a36877831ed26abab78bfe31bebb68f8c844", size = 7483, upload-time = "2024-11-25T22:31:50.129Z" },
]
[[package]] [[package]]
name = "prometheus-client" name = "prometheus-client"
version = "0.22.1" version = "0.22.1"
@@ -3133,7 +3112,6 @@ dependencies = [
{ name = "llama-index-llms-openai-like" }, { name = "llama-index-llms-openai-like" },
{ name = "nltk" }, { name = "nltk" },
{ name = "openai" }, { name = "openai" },
{ name = "profanityfilter" },
{ name = "prometheus-fastapi-instrumentator" }, { name = "prometheus-fastapi-instrumentator" },
{ name = "protobuf" }, { name = "protobuf" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
@@ -3210,7 +3188,6 @@ requires-dist = [
{ name = "llama-index-llms-openai-like", specifier = ">=0.4.0" }, { name = "llama-index-llms-openai-like", specifier = ">=0.4.0" },
{ name = "nltk", specifier = ">=3.8.1" }, { name = "nltk", specifier = ">=3.8.1" },
{ name = "openai", specifier = ">=1.59.7" }, { name = "openai", specifier = ">=1.59.7" },
{ name = "profanityfilter", specifier = ">=2.0.6" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=6.1.0" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=6.1.0" },
{ name = "protobuf", specifier = ">=4.24.3" }, { name = "protobuf", specifier = ">=4.24.3" },
{ name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "psycopg2-binary", specifier = ">=2.9.10" },

View File

@@ -33,17 +33,27 @@ export default function Player(props: PlayerProps) {
const topicsRef = useRef(props.topics); const topicsRef = useRef(props.topics);
const [firstRender, setFirstRender] = useState<boolean>(true); const [firstRender, setFirstRender] = useState<boolean>(true);
const keyHandler = (e) => { const shouldIgnoreHotkeys = (target: EventTarget | null) => {
if (e.key == " ") { return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement
);
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === " ") {
if (e.repeat) return;
if (shouldIgnoreHotkeys(e.target)) return;
e.preventDefault();
wavesurfer?.playPause(); wavesurfer?.playPause();
} }
}; };
useEffect(() => { useEffect(() => {
document.addEventListener("keyup", keyHandler); document.addEventListener("keydown", keyHandler);
return () => { return () => {
document.removeEventListener("keyup", keyHandler); document.removeEventListener("keydown", keyHandler);
}; };
}); }, [wavesurfer]);
// Waveform setup // Waveform setup
useEffect(() => { useEffect(() => {

View File

@@ -14,8 +14,7 @@ import {
Checkbox, Checkbox,
Combobox, Combobox,
Spinner, Spinner,
useFilter, createListCollection,
useListCollection,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { TbBrandZulip } from "react-icons/tb"; import { TbBrandZulip } from "react-icons/tb";
import { import {
@@ -48,8 +47,6 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
const { data: topics = [] } = useZulipTopics(selectedStreamId); const { data: topics = [] } = useZulipTopics(selectedStreamId);
const postToZulipMutation = useTranscriptPostToZulip(); const postToZulipMutation = useTranscriptPostToZulip();
const { contains } = useFilter({ sensitivity: "base" });
const streamItems = useMemo(() => { const streamItems = useMemo(() => {
return streams.map((stream: Stream) => ({ return streams.map((stream: Stream) => ({
label: stream.name, label: stream.name,
@@ -64,17 +61,21 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
})); }));
}, [topics]); }, [topics]);
const { collection: streamItemsCollection, filter: streamItemsFilter } = const streamCollection = useMemo(
useListCollection({ () =>
initialItems: streamItems, createListCollection({
filter: contains, items: streamItems,
}); }),
[streamItems],
);
const { collection: topicItemsCollection, filter: topicItemsFilter } = const topicCollection = useMemo(
useListCollection({ () =>
initialItems: topicItems, createListCollection({
filter: contains, items: topicItems,
}); }),
[topicItems],
);
// Update selected stream ID when stream changes // Update selected stream ID when stream changes
useEffect(() => { useEffect(() => {
@@ -156,15 +157,12 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
<Flex align="center" gap={2}> <Flex align="center" gap={2}>
<Text>#</Text> <Text>#</Text>
<Combobox.Root <Combobox.Root
collection={streamItemsCollection} collection={streamCollection}
value={stream ? [stream] : []} value={stream ? [stream] : []}
onValueChange={(e) => { onValueChange={(e) => {
setTopic(undefined); setTopic(undefined);
setStream(e.value[0]); setStream(e.value[0]);
}} }}
onInputValueChange={(e) =>
streamItemsFilter(e.inputValue)
}
openOnClick={true} openOnClick={true}
positioning={{ positioning={{
strategy: "fixed", strategy: "fixed",
@@ -181,7 +179,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
<Combobox.Positioner> <Combobox.Positioner>
<Combobox.Content> <Combobox.Content>
<Combobox.Empty>No streams found</Combobox.Empty> <Combobox.Empty>No streams found</Combobox.Empty>
{streamItemsCollection.items.map((item) => ( {streamItems.map((item) => (
<Combobox.Item key={item.value} item={item}> <Combobox.Item key={item.value} item={item}>
{item.label} {item.label}
</Combobox.Item> </Combobox.Item>
@@ -197,12 +195,9 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
<Flex align="center" gap={2}> <Flex align="center" gap={2}>
<Text visibility="hidden">#</Text> <Text visibility="hidden">#</Text>
<Combobox.Root <Combobox.Root
collection={topicItemsCollection} collection={topicCollection}
value={topic ? [topic] : []} value={topic ? [topic] : []}
onValueChange={(e) => setTopic(e.value[0])} onValueChange={(e) => setTopic(e.value[0])}
onInputValueChange={(e) =>
topicItemsFilter(e.inputValue)
}
openOnClick openOnClick
selectionBehavior="replace" selectionBehavior="replace"
skipAnimationOnMount={true} skipAnimationOnMount={true}
@@ -222,7 +217,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
<Combobox.Positioner> <Combobox.Positioner>
<Combobox.Content> <Combobox.Content>
<Combobox.Empty>No topics found</Combobox.Empty> <Combobox.Empty>No topics found</Combobox.Empty>
{topicItemsCollection.items.map((item) => ( {topicItems.map((item) => (
<Combobox.Item key={item.value} item={item}> <Combobox.Item key={item.value} item={item}>
{item.label} {item.label}
<Combobox.ItemIndicator /> <Combobox.ItemIndicator />

View File

@@ -3,8 +3,10 @@
import createClient from "openapi-fetch"; import createClient from "openapi-fetch";
import type { paths } from "../reflector-api"; import type { paths } from "../reflector-api";
import createFetchClient from "openapi-react-query"; import createFetchClient from "openapi-react-query";
import { assertExistsAndNonEmptyString } from "./utils"; import { assertExistsAndNonEmptyString, parseNonEmptyString } from "./utils";
import { isBuildPhase } from "./next"; import { isBuildPhase } from "./next";
import { getSession } from "next-auth/react";
import { assertExtendedToken } from "./types";
export const API_URL = !isBuildPhase export const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString( ? assertExistsAndNonEmptyString(
@@ -21,29 +23,29 @@ export const client = createClient<paths>({
baseUrl: API_URL, baseUrl: API_URL,
}); });
const waitForAuthTokenDefinitivePresenceOrAbscence = async () => { // will assert presence/absence of login initially
let tries = 0; const initialSessionPromise = getSession();
let time = 0;
const STEP = 100; const waitForAuthTokenDefinitivePresenceOrAbsence = async () => {
while (currentAuthToken === undefined) { const initialSession = await initialSessionPromise;
await new Promise((resolve) => setTimeout(resolve, STEP)); if (currentAuthToken === undefined) {
time += STEP; currentAuthToken =
tries++; initialSession === null
// most likely first try is more than enough, if it's more there's already something weird happens ? null
if (tries > 10) { : assertExtendedToken(initialSession).accessToken;
// even when there's no auth assumed at all, we probably should explicitly call configureApiAuth(null)
throw new Error(
`Could not get auth token definitive presence/absence in ${time}ms. not calling configureApiAuth?`,
);
}
} }
// otherwise already overwritten by external forces
return currentAuthToken;
}; };
client.use({ client.use({
async onRequest({ request }) { async onRequest({ request }) {
await waitForAuthTokenDefinitivePresenceOrAbscence(); const token = await waitForAuthTokenDefinitivePresenceOrAbsence();
if (currentAuthToken) { if (token !== null) {
request.headers.set("Authorization", `Bearer ${currentAuthToken}`); request.headers.set(
"Authorization",
`Bearer ${parseNonEmptyString(token)}`,
);
} }
// XXX Only set Content-Type if not already set (FormData will set its own boundary) // XXX Only set Content-Type if not already set (FormData will set its own boundary)
// This is a work around for uploading file, we're passing a formdata // This is a work around for uploading file, we're passing a formdata

View File

@@ -21,7 +21,7 @@ export interface CustomSession extends Session {
// assumption that JWT is JWTWithAccessToken - we set it in jwt callback of auth; typing isn't strong around there // assumption that JWT is JWTWithAccessToken - we set it in jwt callback of auth; typing isn't strong around there
// but the assumption is crucial to auth working // but the assumption is crucial to auth working
export const assertExtendedToken = <T>( export const assertExtendedToken = <T>(
t: T, t: Exclude<T, null | undefined>,
): T & { ): T & {
accessTokenExpires: number; accessTokenExpires: number;
accessToken: string; accessToken: string;
@@ -45,7 +45,7 @@ export const assertExtendedToken = <T>(
}; };
export const assertExtendedTokenAndUserId = <U, T extends { user?: U }>( export const assertExtendedTokenAndUserId = <U, T extends { user?: U }>(
t: T, t: Exclude<T, null | undefined>,
): T & { ): T & {
accessTokenExpires: number; accessTokenExpires: number;
accessToken: string; accessToken: string;
@@ -55,7 +55,7 @@ export const assertExtendedTokenAndUserId = <U, T extends { user?: U }>(
} => { } => {
const extendedToken = assertExtendedToken(t); const extendedToken = assertExtendedToken(t);
if (typeof (extendedToken.user as any)?.id === "string") { if (typeof (extendedToken.user as any)?.id === "string") {
return t as T & { return t as Exclude<T, null | undefined> & {
accessTokenExpires: number; accessTokenExpires: number;
accessToken: string; accessToken: string;
user: U & { user: U & {
@@ -67,7 +67,9 @@ export const assertExtendedTokenAndUserId = <U, T extends { user?: U }>(
}; };
// best attempt to check the session is valid // best attempt to check the session is valid
export const assertCustomSession = <S extends Session>(s: S): CustomSession => { export const assertCustomSession = <T extends Session>(
s: Exclude<T, null | undefined>,
): CustomSession => {
const r = assertExtendedTokenAndUserId(s); const r = assertExtendedTokenAndUserId(s);
// no other checks for now // no other checks for now
return r as CustomSession; return r as CustomSession;