google keys transferred to account poweron.center.ai

This commit is contained in:
ValueOn AG 2026-05-11 21:26:20 +02:00
parent 7d43d032c3
commit 75e07743a6
13 changed files with 257 additions and 43 deletions

View file

@ -19,7 +19,7 @@ APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2Z
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
@ -39,11 +39,11 @@ Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = 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
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = 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
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.

View file

@ -21,7 +21,7 @@ APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZ
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
@ -41,11 +41,11 @@ Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = 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
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = 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
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.

View file

@ -39,11 +39,11 @@ Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_DATA_REDIRECT_URI = https://api.poweron.swiss/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = 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
Service_GOOGLE_AUTH_REDIRECT_URI =
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = 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
Service_GOOGLE_DATA_REDIRECT_URI =
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.

View file

@ -20,7 +20,7 @@ APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUl
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
@ -40,11 +40,11 @@ Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = 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
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = 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
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.

View file

@ -19,6 +19,30 @@ from modules.shared.voiceCatalog import getDefaultVoice as _catalogDefaultVoice
logger = logging.getLogger(__name__)
def _buildPrimarySttRecognitionFields(
*,
model: str,
lightweight: bool,
) -> Dict[str, Any]:
"""Shared fields for batch + streaming primary RecognitionConfig."""
base: Dict[str, Any] = {
"enable_automatic_punctuation": True,
"model": model,
}
if lightweight:
base["enable_word_time_offsets"] = False
base["enable_word_confidence"] = False
base["max_alternatives"] = 1
base["use_enhanced"] = False
else:
base["enable_word_time_offsets"] = True
base["enable_word_confidence"] = True
base["max_alternatives"] = 3
base["use_enhanced"] = True
return base
# Gemini-TTS speaker IDs from voices.list use short names (e.g. "Kore") and require
# SynthesisInput.prompt + VoiceSelectionParams.model_name (google-cloud-texttospeech >= 2.24.0).
_GEMINI_TTS_DEFAULT_MODEL = "gemini-2.5-flash-tts"
@ -73,7 +97,10 @@ class ConnectorGoogleSpeech:
sampleRate: int = None, channels: int = None,
skipFallbacks: bool = False,
phraseHints: Optional[list] = None,
alternativeLanguages: Optional[list] = None) -> Dict:
alternativeLanguages: Optional[list] = None,
model: str = "latest_long",
lightweight: bool = False,
audioFormat: Optional[str] = None) -> Dict:
"""
Convert speech to text using Google Cloud Speech-to-Text API.
@ -82,6 +109,9 @@ class ConnectorGoogleSpeech:
language: Language code (e.g., 'de-DE', 'en-US')
sample_rate: Audio sample rate (auto-detected if None)
channels: Number of audio channels (auto-detected if None)
model: Google recognition model (e.g. latest_long, latest_short)
lightweight: If True, omit word timings/confidence, single alternative, no enhanced model
audioFormat: If set (webm_opus, linear16, mp3, flac, wav), skip auto-detection
Returns:
Dict containing transcribed text, confidence, and metadata
@ -92,8 +122,24 @@ class ConnectorGoogleSpeech:
logger.warning(f"Invalid sampleRate={sampleRate}, treating as unknown for auto-detection")
sampleRate = None
# Auto-detect audio format if not provided
if sampleRate is None or channels is None:
explicitFormat = (audioFormat or "").strip().lower() or None
if explicitFormat:
if channels is None:
channels = 1
if sampleRate is None:
if explicitFormat == "webm_opus":
sampleRate = 48000
elif explicitFormat == "linear16":
sampleRate = 16000
elif explicitFormat in ("mp3", "flac"):
sampleRate = 44100
elif explicitFormat == "wav":
sampleRate = 16000
else:
sampleRate = 16000
audioFormat = explicitFormat
logger.info(f"STT explicit format: {audioFormat}, {sampleRate}Hz, {channels}ch")
elif sampleRate is None or channels is None:
validation = self.validateAudioFormat(audioContent)
if not validation["valid"]:
return {
@ -156,12 +202,7 @@ class ConnectorGoogleSpeech:
"encoding": encoding,
"audio_channel_count": channels,
"language_code": language,
"enable_automatic_punctuation": True,
"model": "latest_long",
"enable_word_time_offsets": True,
"enable_word_confidence": True,
"max_alternatives": 3,
"use_enhanced": True,
**_buildPrimarySttRecognitionFields(model=model, lightweight=lightweight),
}
if phraseHints:
@ -205,8 +246,7 @@ class ConnectorGoogleSpeech:
sample_rate_hertz=16000,
audio_channel_count=1,
language_code=language,
enable_automatic_punctuation=True,
model="latest_long"
**_buildPrimarySttRecognitionFields(model=model, lightweight=lightweight),
)
try:
response = await asyncio.to_thread(
@ -343,7 +383,7 @@ class ConnectorGoogleSpeech:
"error": "No recognition results (silence or unclear audio)"
}
models = ["latest_long", "phone_call", "latest_short"]
models = list(dict.fromkeys([model, "latest_long", "phone_call", "latest_short"]))
for fallback_config in fallback_configs:
for model in models:
@ -419,6 +459,9 @@ class ConnectorGoogleSpeech:
audioQueue: asyncio.Queue,
language: str = "de-DE",
phraseHints: Optional[list] = None,
model: str = "latest_long",
lightweight: bool = False,
singleUtterance: bool = False,
) -> AsyncGenerator[Dict[str, Any], None]:
"""
Stream audio chunks to Google Cloud Speech-to-Text Streaming API.
@ -429,9 +472,13 @@ class ConnectorGoogleSpeech:
Send (b"", True) to signal end of stream.
language: Language code
phraseHints: Optional boost phrases
model: Google recognition model (e.g. latest_long, latest_short)
lightweight: If True, use non-enhanced primary config (lower latency)
singleUtterance: If True, end stream after first utterance (client should reconnect)
Yields:
Dicts with keys: isFinal, transcript, confidence, stabilityScore, audioDurationSec
Dicts with keys: isFinal, transcript, confidence, stabilityScore, audioDurationSec;
optionally endOfSingleUtterance, reconnectRequired
"""
STREAM_LIMIT_SEC = 290
streamStartTs = time.time()
@ -442,9 +489,7 @@ class ConnectorGoogleSpeech:
"sample_rate_hertz": 48000,
"audio_channel_count": 1,
"language_code": language,
"enable_automatic_punctuation": True,
"model": "latest_long",
"use_enhanced": True,
**_buildPrimarySttRecognitionFields(model=model, lightweight=lightweight),
}
if phraseHints:
configParams["speech_contexts"] = [speech.SpeechContext(phrases=phraseHints, boost=15.0)]
@ -453,7 +498,7 @@ class ConnectorGoogleSpeech:
streamingConfig = speech.StreamingRecognitionConfig(
config=recognitionConfig,
interim_results=True,
single_utterance=False,
single_utterance=singleUtterance,
)
import queue as threadQueue
@ -490,7 +535,22 @@ class ConnectorGoogleSpeech:
)
for response in responseStream:
elapsed = time.time() - streamStartTs
estimatedDurationSec = totalAudioBytes / (48000 * 1 * 2) if totalAudioBytes else 0
durationFromResults = 0.0
for result in response.results:
rt = getattr(result, "result_end_time", None)
if rt is None:
continue
if hasattr(rt, "total_seconds"):
durationFromResults = max(durationFromResults, float(rt.total_seconds()))
else:
durationFromResults = max(
durationFromResults,
float(getattr(rt, "seconds", 0)) + float(getattr(rt, "nanos", 0)) * 1e-9,
)
estimatedDurationSec = durationFromResults if durationFromResults > 0 else (
totalAudioBytes / (48000 * 1 * 2) if totalAudioBytes else 0.0
)
finalTexts = []
interimTexts = []
@ -524,6 +584,13 @@ class ConnectorGoogleSpeech:
"stabilityScore": 0.0,
"audioDurationSec": estimatedDurationSec,
}), loop)
speechEvt = getattr(response, "speech_event_type", None)
if speechEvt and "END_OF_SINGLE_UTTERANCE" in str(speechEvt):
asyncio.run_coroutine_threadsafe(resultOutQ.put({
"endOfSingleUtterance": True,
"audioDurationSec": estimatedDurationSec,
}), loop)
if elapsed >= STREAM_LIMIT_SEC:
logger.info("Streaming STT approaching 5-min limit, client should reconnect")
asyncio.run_coroutine_threadsafe(resultOutQ.put({

View file

@ -1080,6 +1080,8 @@ class CommcoachService:
audioContent=audioContent,
language=language,
skipFallbacks=True,
model="latest_short",
lightweight=True,
)
transcribedText = ""

View file

@ -111,6 +111,14 @@ class TeamsbotMeetingModule(PowerOnModel):
defaultDirectorPrompts: Optional[str] = Field(default=None, description="JSON list of default director prompts")
goals: Optional[str] = Field(default=None, description="Free-text goals")
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
defaultMeetingLink: Optional[str] = Field(
default=None,
description="Default Teams meeting URL for new sessions in this module (user can override)",
)
defaultBotName: Optional[str] = Field(
default=None,
description="Default display name for the bot when starting a session from this module",
)
status: TeamsbotModuleStatus = Field(default=TeamsbotModuleStatus.ACTIVE)
@ -257,6 +265,7 @@ class TeamsbotStartSessionRequest(BaseModel):
"""Request to start a new Teams Bot session."""
meetingLink: str = Field(description="Teams meeting join link (e.g., https://teams.microsoft.com/l/meetup-join/...)")
botName: Optional[str] = Field(default=None, description="Override bot name for this session")
moduleId: Optional[str] = Field(default=None, description="Optional MeetingModule to attach this session to")
connectionId: Optional[str] = Field(default=None, description="Microsoft connection ID for Graph API access")
joinMode: Optional[TeamsbotJoinMode] = Field(default=None, description="How the bot joins: systemBot, anonymous, or userAccount. Defaults to systemBot if credentials configured, else anonymous.")
sessionContext: Optional[str] = Field(default=None, description="Custom context/knowledge to provide to the bot for this session (e.g. meeting agenda, documents, background info)")
@ -277,6 +286,8 @@ class CreateMeetingModuleRequest(BaseModel):
defaultDirectorPrompts: Optional[str] = None
goals: Optional[str] = None
kpiTargets: Optional[str] = None
defaultMeetingLink: Optional[str] = None
defaultBotName: Optional[str] = None
class UpdateMeetingModuleRequest(BaseModel):
@ -287,6 +298,8 @@ class UpdateMeetingModuleRequest(BaseModel):
defaultDirectorPrompts: Optional[str] = None
goals: Optional[str] = None
kpiTargets: Optional[str] = None
defaultMeetingLink: Optional[str] = None
defaultBotName: Optional[str] = None
status: Optional[TeamsbotModuleStatus] = None

View file

@ -290,6 +290,19 @@ def _runMigrations():
migrated = False
# M2: MeetingModule default meeting link / bot name (additive columns)
if _tableExists("TeamsbotMeetingModule"):
for col, sqlType in (
("defaultMeetingLink", "TEXT"),
("defaultBotName", "TEXT"),
):
if not _columnExists("TeamsbotMeetingModule", col):
cur.execute(
f'ALTER TABLE "TeamsbotMeetingModule" ADD COLUMN "{col}" {sqlType} NULL',
)
logger.info(f"Migration M2: Added TeamsbotMeetingModule.{col}")
migrated = True
# M1: Create default Adhoc modules for orphaned sessions
# (only runs if TeamsbotSession table exists with moduleId column
# and there are sessions without a moduleId)

View file

@ -280,6 +280,11 @@ async def startSession(
mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
config = _getInstanceConfig(instanceId)
if body.moduleId:
mod = interface.getModule(body.moduleId)
if not mod or str(mod.get("instanceId") or "") != str(instanceId):
raise HTTPException(status_code=400, detail="Invalid moduleId for this instance")
# Extract and validate meeting URL from user input (handles SafeLinks, invitation text, etc.)
cleanMeetingUrl = _extractTeamsMeetingUrl(body.meetingLink)
@ -288,6 +293,7 @@ async def startSession(
sessionData = TeamsbotSession(
instanceId=instanceId,
mandateId=mandateId,
moduleId=body.moduleId,
meetingLink=cleanMeetingUrl,
botName=body.botName or config.botName,
sessionContext=body.sessionContext,
@ -426,6 +432,54 @@ async def listSessions(
return {"sessions": sessions}
@router.get("/{instanceId}/dashboard/stream")
@limiter.limit("60/minute")
async def streamDashboard(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""
SSE channel for the Teamsbot dashboard: repeated snapshots of sessions and meeting modules.
Push interval: 3s while any own session is pending/joining/active, otherwise 20s.
Same session visibility rules as GET /sessions (own sessions unless platform admin).
"""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = None if context.isPlatformAdmin else str(context.user.id)
activeStatuses = {
TeamsbotSessionStatus.PENDING.value,
TeamsbotSessionStatus.JOINING.value,
TeamsbotSessionStatus.ACTIVE.value,
}
async def eventGenerator():
while True:
sessionRows = []
try:
sessionRows = interface.getSessions(instanceId, includeEnded=True, userId=userId)
moduleRows = interface.getModules(instanceId)
payload = {"type": "dashboardState", "sessions": sessionRows, "modules": moduleRows}
yield f"data: {json.dumps(payload, default=str)}\n\n"
except asyncio.CancelledError:
raise
except Exception as ex:
logger.warning("dashboard stream tick failed: %s", ex)
yield f"data: {json.dumps({'type': 'error', 'message': 'dashboard_tick_failed'})}\n\n"
hasActive = any((s.get("status") in activeStatuses) for s in sessionRows)
await asyncio.sleep(3.0 if hasActive else 20.0)
return StreamingResponse(
eventGenerator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/{instanceId}/sessions/{sessionId}")
@limiter.limit("30/minute")
async def getSession(

View file

@ -1225,6 +1225,7 @@ class TeamsbotService:
skipFallbacks=True,
phraseHints=phraseHints if phraseHints else None,
alternativeLanguages=["en-US"],
audioFormat="linear16",
)
if sttResult and sttResult.get("success") and sttResult.get("text"):

View file

@ -69,7 +69,10 @@ class VoiceObjects:
sampleRate: int = None, channels: int = None,
skipFallbacks: bool = False,
phraseHints: list = None,
alternativeLanguages: list = None) -> Dict[str, Any]:
alternativeLanguages: list = None,
model: str = "latest_long",
lightweight: bool = False,
audioFormat: Optional[str] = None) -> Dict[str, Any]:
"""
Convert speech to text using Google Cloud Speech-to-Text API.
@ -81,6 +84,9 @@ class VoiceObjects:
skipFallbacks: If True, skip fallback attempts (use when audio format is known)
phraseHints: Optional list of phrases to boost recognition (names, terms)
alternativeLanguages: Optional list of additional language codes for multi-language
model: Google STT model (e.g. latest_long, latest_short)
lightweight: If True, omit word-level features and enhanced model
audioFormat: If set (webm_opus, linear16, ...), skip format auto-detection
Returns:
Dict containing transcribed text, confidence, and metadata
@ -97,6 +103,9 @@ class VoiceObjects:
skipFallbacks=skipFallbacks,
phraseHints=phraseHints,
alternativeLanguages=alternativeLanguages,
model=model,
lightweight=lightweight,
audioFormat=audioFormat,
)
if result["success"]:
@ -120,13 +129,23 @@ class VoiceObjects:
audioQueue: asyncio.Queue,
language: str = "de-DE",
phraseHints: Optional[list] = None,
model: str = "latest_long",
lightweight: bool = False,
singleUtterance: bool = False,
) -> AsyncGenerator[Dict[str, Any], None]:
"""
Stream audio to Google Streaming STT and yield interim/final results.
Billing is recorded for each final result.
"""
connector = self._getGoogleSpeechConnector()
async for event in connector.streamingRecognize(audioQueue, language, phraseHints):
async for event in connector.streamingRecognize(
audioQueue,
language,
phraseHints,
model=model,
lightweight=lightweight,
singleUtterance=singleUtterance,
):
if event.get("isFinal") and self.billingCallback:
durationSec = event.get("audioDurationSec", 0)
priceCHF = connector.calculateSttCostCHF(durationSec)

View file

@ -155,12 +155,13 @@ async def sttStream(
Protocol:
Client sends JSON:
{"type": "open", "language": "de-DE"}
{"type": "open", "language": "de-DE", "model": "latest_short", "lightweight": true, "singleUtterance": true}
{"type": "audio", "chunk": "<base64>"}
{"type": "close"}
Server sends JSON:
{"type": "interim", "text": "..."}
{"type": "final", "text": "...", "confidence": 0.95}
{"type": "end_of_single_utterance", "audioDurationSec": 0.0}
{"type": "error", "message": "..."}
{"type": "closed"}
"""
@ -205,7 +206,12 @@ async def sttStream(
logger.warning(f"STT billing pre-flight skipped: {e}")
audioQueue: asyncio.Queue = asyncio.Queue()
language = "de-DE"
sttOpenOptions: Dict[str, Any] = {
"language": "de-DE",
"model": "latest_long",
"lightweight": False,
"singleUtterance": False,
}
streamingTask: Optional[asyncio.Task] = None
voiceInterface: Optional[VoiceObjects] = None
@ -233,10 +239,23 @@ async def sttStream(
voiceInterface.billingCallback = _billingCb
try:
async for event in voiceInterface.streamingSpeechToText(audioQueue, language):
async for event in voiceInterface.streamingSpeechToText(
audioQueue,
sttOpenOptions["language"],
phraseHints=None,
model=sttOpenOptions["model"],
lightweight=sttOpenOptions["lightweight"],
singleUtterance=sttOpenOptions["singleUtterance"],
):
if event.get("reconnectRequired"):
await _sendJson({"type": "reconnect_required"})
return
if event.get("endOfSingleUtterance"):
await _sendJson({
"type": "end_of_single_utterance",
"audioDurationSec": event.get("audioDurationSec", 0.0),
})
continue
if event.get("isFinal"):
if event.get("transcript"):
await _sendJson({"type": "final", "text": event["transcript"], "confidence": event.get("confidence", 0.0)})
@ -258,7 +277,10 @@ async def sttStream(
msgType = (msg.get("type") or "").strip()
if msgType == "open":
language = msg.get("language") or "de-DE"
sttOpenOptions["language"] = msg.get("language") or "de-DE"
sttOpenOptions["model"] = msg.get("model") or "latest_long"
sttOpenOptions["lightweight"] = bool(msg.get("lightweight"))
sttOpenOptions["singleUtterance"] = bool(msg.get("singleUtterance"))
if streamingTask and not streamingTask.done():
await audioQueue.put((b"", True))
streamingTask.cancel()

View file

@ -0,0 +1,23 @@
# Copyright (c) 2025 Patrick Motsch
"""Unit tests for Google STT helper config (no API calls)."""
from modules.connectors.connectorVoiceGoogle import _buildPrimarySttRecognitionFields
def test_buildPrimaryStt_lightweight_stripsHeavyFeatures():
d = _buildPrimarySttRecognitionFields(model="latest_short", lightweight=True)
assert d["model"] == "latest_short"
assert d["enable_word_time_offsets"] is False
assert d["enable_word_confidence"] is False
assert d["max_alternatives"] == 1
assert d["use_enhanced"] is False
assert d["enable_automatic_punctuation"] is True
def test_buildPrimaryStt_full_matchesLegacyDefaults():
d = _buildPrimarySttRecognitionFields(model="latest_long", lightweight=False)
assert d["model"] == "latest_long"
assert d["enable_word_time_offsets"] is True
assert d["enable_word_confidence"] is True
assert d["max_alternatives"] == 3
assert d["use_enhanced"] is True