fix: resolve STT AttributeError and int/str TypeError in teamsbot service

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
patrick-motsch 2026-02-19 00:09:00 +01:00
parent 7778325e5e
commit 2d7da8a66d
5 changed files with 54 additions and 286 deletions

View file

@ -490,7 +490,7 @@ def _getEffectiveConfig(instanceId: str, userId: str, interface) -> TeamsbotConf
overrides[field] = value
if overrides:
return baseConfig.model_copy(update=overrides)
return TeamsbotConfig.model_validate({**baseConfig.model_dump(), **overrides})
return baseConfig
@ -1228,7 +1228,7 @@ async def botWebsocket(
if value is not None:
overrides[field] = value
if overrides:
config = config.model_copy(update=overrides)
config = TeamsbotConfig.model_validate({**config.model_dump(), **overrides})
logger.info(f"Browser Bot WebSocket: Applied user settings overrides: {list(overrides.keys())}")
service = TeamsbotService(originalUser, mandateId, instanceId, config)

View file

@ -400,38 +400,30 @@ class TeamsbotService:
if len(audioBytes) < 1000:
return
# Use the existing Google Cloud Speech connector for STT
speechConnector = voiceInterface.getSpeechConnector() if voiceInterface else None
if not speechConnector or not hasattr(speechConnector, 'speech_client'):
logger.warning(f"[AudioChunk] No speech client available for session {sessionId}")
if not voiceInterface:
logger.warning(f"[AudioChunk] No voice interface available for session {sessionId}")
return
from google.cloud import speech
config = speech.RecognitionConfig(
encoding=speech.RecognitionConfig.AudioEncoding.LINEAR16,
sample_rate_hertz=sampleRate,
language_code=self.config.language or "de-DE",
enable_automatic_punctuation=True,
sttResult = await voiceInterface.speechToText(
audioContent=audioBytes,
language=self.config.language or "de-DE",
sampleRate=sampleRate,
)
audio = speech.RecognitionAudio(content=audioBytes)
response = speechConnector.speech_client.recognize(config=config, audio=audio)
for result in response.results:
if result.alternatives:
text = result.alternatives[0].transcript.strip()
if text:
logger.info(f"[AudioChunk] STT result: {text[:80]}...")
await self._processTranscript(
sessionId=sessionId,
speaker="Meeting Audio",
text=text,
isFinal=True,
interface=interface,
voiceInterface=voiceInterface,
websocket=websocket,
source="audioCapture",
)
if sttResult and sttResult.get("success") and sttResult.get("text"):
text = sttResult["text"].strip()
if text:
logger.info(f"[AudioChunk] STT result: {text[:80]}...")
await self._processTranscript(
sessionId=sessionId,
speaker="Meeting Audio",
text=text,
isFinal=True,
interface=interface,
voiceInterface=voiceInterface,
websocket=websocket,
source="audioCapture",
)
except Exception as e:
logger.error(f"[AudioChunk] STT error for session {sessionId}: {type(e).__name__}: {e}")

View file

@ -1,6 +1,6 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Trustee models: TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract, TrusteeDocument, TrusteePosition, TrusteePositionDocument."""
"""Trustee models: TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract, TrusteeDocument, TrusteePosition."""
from typing import Optional
from pydantic import BaseModel, Field
@ -384,9 +384,8 @@ registerModelLabels(
class TrusteePosition(BaseModel):
"""Contains booking positions (expense entries).
Note: organisationId and contractId removed as per architecture decision:
- The feature instance IS the organisation
- Contracts are eliminated from the model
Each position references exactly one source document via documentId (1:N relationship).
One document (e.g. bank statement) can generate many positions.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@ -397,6 +396,16 @@ class TrusteePosition(BaseModel):
"frontend_required": False
}
)
documentId: Optional[str] = Field(
default=None,
description="Reference to TrusteeDocument.id (source document that generated this position)",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": False,
"frontend_options": "/api/trustee/{instanceId}/documents/options"
}
)
valuta: Optional[str] = Field(
default=None,
description="Value date (ISO format: YYYY-MM-DD)",
@ -538,6 +547,7 @@ registerModelLabels(
{"en": "Position", "fr": "Position", "de": "Position"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"documentId": {"en": "Document", "fr": "Document", "de": "Dokument"},
"valuta": {"en": "Value Date", "fr": "Date de valeur", "de": "Valutadatum"},
"transactionDateTime": {"en": "Transaction Date/Time", "fr": "Date/Heure de transaction", "de": "Transaktionszeitpunkt"},
"company": {"en": "Company", "fr": "Entreprise", "de": "Firma"},
@ -555,71 +565,3 @@ registerModelLabels(
)
class TrusteePositionDocument(BaseModel):
"""Cross-reference table linking positions to documents (many-to-many).
Note: organisationId and contractId removed as per architecture decision:
- The feature instance IS the organisation
- Contracts are eliminated from the model
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique link ID",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
documentId: str = Field(
description="Reference to TrusteeDocument.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "/api/trustee/{instanceId}/documents/options"
}
)
positionId: str = Field(
description="Reference to TrusteePosition.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "/api/trustee/{instanceId}/positions/options"
}
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"frontend_hidden": True
}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"frontend_hidden": True
}
)
# System attributes are automatically set by DatabaseConnector
registerModelLabels(
"TrusteePositionDocument",
{"en": "Position-Document Link", "fr": "Lien Position-Document", "de": "Position-Dokument-Verknüpfung"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"documentId": {"en": "Document", "fr": "Document", "de": "Dokument"},
"positionId": {"en": "Position", "fr": "Position", "de": "Position"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
},
)

View file

@ -23,7 +23,6 @@ from .datamodelFeatureTrustee import (
TrusteeContract,
TrusteeDocument,
TrusteePosition,
TrusteePositionDocument,
)
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
@ -1107,10 +1106,8 @@ class TrusteeObjects:
def deleteDocument(self, documentId: str) -> bool:
"""Delete a document.
All position-document cross-table entries (TrusteePositionDocument) referencing
this document are deleted first, then the document.
Positions referencing this document will have their documentId set to None.
"""
# Get existing document to check creator
existingRecords = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId})
existing = existingRecords[0] if existingRecords else None
@ -1120,12 +1117,17 @@ class TrusteeObjects:
createdBy = existing.get("_createdBy")
# Check system RBAC permission (userreport can only delete their own records)
if not self.checkCombinedPermission(TrusteeDocument, "delete", recordCreatedBy=createdBy):
logger.warning(f"User {self.userId} lacks permission to delete document")
return False
self._deletePositionDocumentLinksForDocument(documentId)
# Clear documentId on positions that reference this document
positions = self.db.getRecordset(TrusteePosition, recordFilter={"documentId": documentId})
for pos in positions:
posId = pos.get("id")
if posId:
self.db.recordModify(TrusteePosition, posId, {"documentId": None})
return self.db.recordDelete(TrusteeDocument, documentId)
# ===== Position CRUD =====
@ -1276,12 +1278,7 @@ class TrusteeObjects:
return TrusteePosition(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")})
def deletePosition(self, positionId: str) -> bool:
"""Delete a position.
All position-document cross-table entries (TrusteePositionDocument) referencing
this position are deleted first, then the position.
"""
# Get existing position to check creator
"""Delete a position."""
existingRecords = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId})
existing = existingRecords[0] if existingRecords else None
@ -1291,177 +1288,27 @@ class TrusteeObjects:
createdBy = existing.get("_createdBy")
# Check system RBAC permission (userreport can only delete their own records)
if not self.checkCombinedPermission(TrusteePosition, "delete", recordCreatedBy=createdBy):
logger.warning(f"User {self.userId} lacks permission to delete position")
return False
self._deletePositionDocumentLinksForPosition(positionId)
return self.db.recordDelete(TrusteePosition, positionId)
# ===== Position-Document Link CRUD =====
# ===== Position-Document Queries =====
def createPositionDocument(self, data: Dict[str, Any]) -> Optional[TrusteePositionDocument]:
"""Create a new position-document link.
Note: organisationId and contractId removed - feature instance IS the organisation.
Permission is checked via system RBAC (feature-level access).
"""
# Check system RBAC permission
if not self.checkCombinedPermission(TrusteePositionDocument, "create"):
logger.warning(f"User {self.userId} lacks permission to create position-document link")
return None
# Auto-set context fields
data["mandateId"] = self.mandateId
data["featureInstanceId"] = self.featureInstanceId
import uuid
linkId = data.get("id") or str(uuid.uuid4())
data["id"] = linkId
createdRecord = self.db.recordCreate(TrusteePositionDocument, data)
if createdRecord and createdRecord.get("id"):
return TrusteePositionDocument(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
return None
def getPositionDocument(self, linkId: str) -> Optional[TrusteePositionDocument]:
"""Get a single position-document link by ID."""
records = self.db.getRecordset(TrusteePositionDocument, recordFilter={"id": linkId})
if not records:
return None
return TrusteePositionDocument(**{k: v for k, v in records[0].items() if not k.startswith("_")})
def updatePositionDocument(self, linkId: str, data: Dict[str, Any]) -> Optional[TrusteePositionDocument]:
"""Update a position-document link."""
# Check permission
if not self.checkCombinedPermission(TrusteePositionDocument, "update"):
logger.warning(f"User {self.userId} lacks permission to update position-document link")
return None
# Verify link exists and belongs to this instance
existing = self.db.getRecordset(TrusteePositionDocument, recordFilter={"id": linkId})
if not existing:
logger.warning(f"Position-document link {linkId} not found")
return None
existingRecord = existing[0]
if existingRecord.get("featureInstanceId") != self.featureInstanceId:
logger.warning(f"Link {linkId} belongs to different instance")
return None
# Prevent changing context fields
data.pop("id", None)
data.pop("mandateId", None)
data.pop("featureInstanceId", None)
updatedRecord = self.db.recordModify(TrusteePositionDocument, linkId, data)
if updatedRecord and updatedRecord.get("id"):
return TrusteePositionDocument(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")})
return None
def getAllPositionDocuments(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
"""Get all position-document links with RBAC filtering + feature-level access filtering."""
# Step 1: System RBAC filtering with per-row permissions
def getPositionsByDocument(self, documentId: str) -> List[TrusteePosition]:
"""Get all positions that reference a specific document (1:N via documentId FK)."""
records = getRecordsetWithRBAC(
connector=self.db,
modelClass=TrusteePositionDocument,
currentUser=self.currentUser,
recordFilter=None,
orderBy="id",
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
enrichPermissions=True,
featureCode=self.FEATURE_CODE
)
totalItems = len(records)
if params:
pageSize = params.pageSize or 20
page = params.page or 1
startIdx = (page - 1) * pageSize
endIdx = startIdx + pageSize
items = records[startIdx:endIdx]
totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
else:
items = records
totalPages = 1
page = 1
pageSize = totalItems
return PaginatedResult(
items=items,
totalItems=totalItems,
totalPages=totalPages
)
def getDocumentsForPosition(self, positionId: str) -> List[TrusteePositionDocument]:
"""Get all documents linked to a position."""
# Step 1: System RBAC filtering
links = getRecordsetWithRBAC(
connector=self.db,
modelClass=TrusteePositionDocument,
currentUser=self.currentUser,
recordFilter={"positionId": positionId},
orderBy="id",
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
)
return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links]
def getPositionsForDocument(self, documentId: str) -> List[TrusteePositionDocument]:
"""Get all positions linked to a document."""
# Step 1: System RBAC filtering
links = getRecordsetWithRBAC(
connector=self.db,
modelClass=TrusteePositionDocument,
modelClass=TrusteePosition,
currentUser=self.currentUser,
recordFilter={"documentId": documentId},
orderBy="id",
orderBy="valuta",
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
)
return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links]
def deletePositionDocument(self, linkId: str) -> bool:
"""Delete a position-document link.
Note: organisationId and contractId removed - feature instance IS the organisation.
"""
# Get existing link to check creator
existingRecords = self.db.getRecordset(TrusteePositionDocument, recordFilter={"id": linkId})
existing = existingRecords[0] if existingRecords else None
if not existing:
logger.warning(f"Position-document link {linkId} not found")
return False
createdBy = existing.get("_createdBy")
# Check system RBAC permission (userreport can only delete their own records)
if not self.checkCombinedPermission(TrusteePositionDocument, "delete", recordCreatedBy=createdBy):
logger.warning(f"User {self.userId} lacks permission to delete position-document link")
return False
return self.db.recordDelete(TrusteePositionDocument, linkId)
def _deletePositionDocumentLinksForDocument(self, documentId: str) -> None:
"""Delete all position-document cross-table entries referencing this document."""
links = self.db.getRecordset(TrusteePositionDocument, recordFilter={"documentId": documentId})
for link in links:
linkId = link.get("id")
if linkId:
self.db.recordDelete(TrusteePositionDocument, linkId)
def _deletePositionDocumentLinksForPosition(self, positionId: str) -> None:
"""Delete all position-document cross-table entries referencing this position."""
links = self.db.getRecordset(TrusteePositionDocument, recordFilter={"positionId": positionId})
for link in links:
linkId = link.get("id")
if linkId:
self.db.recordDelete(TrusteePositionDocument, linkId)
return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
# ===== Trustee-specific Access Check =====
@ -1583,7 +1430,7 @@ class TrusteeObjects:
# operate role: CRUD for contracts, documents, positions
if "operate" in roles:
if modelClass in (TrusteeContract, TrusteeDocument, TrusteePosition, TrusteePositionDocument):
if modelClass in (TrusteeContract, TrusteeDocument, TrusteePosition):
return True
# operate can read organisations
if modelClass == TrusteeOrganisation and operation == "read":
@ -1591,7 +1438,7 @@ class TrusteeObjects:
# userreport role: CRUD own records for documents/positions
if "userreport" in roles:
if modelClass in (TrusteeDocument, TrusteePosition, TrusteePositionDocument):
if modelClass in (TrusteeDocument, TrusteePosition):
# For create, always allowed
if operation == "create":
return True

View file

@ -33,11 +33,6 @@ UI_OBJECTS = [
"label": {"en": "Documents", "de": "Dokumente", "fr": "Documents"},
"meta": {"area": "documents"}
},
{
"objectKey": "ui.feature.trustee.position-documents",
"label": {"en": "Position Documents", "de": "Positions-Dokumente", "fr": "Documents de position"},
"meta": {"area": "position-documents"}
},
{
"objectKey": "ui.feature.trustee.expense-import",
"label": {"en": "Expense Import", "de": "Spesen Import", "fr": "Import de dépenses"},
@ -63,11 +58,6 @@ DATA_OBJECTS = [
"label": {"en": "Document", "de": "Dokument", "fr": "Document"},
"meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]}
},
{
"objectKey": "data.feature.trustee.TrusteePositionDocument",
"label": {"en": "Position-Document Assignment", "de": "Position-Dokument Zuordnung", "fr": "Assignation Position-Document"},
"meta": {"table": "TrusteePositionDocument", "fields": ["id", "positionId", "documentId"]}
},
{
"objectKey": "data.feature.trustee.*",
"label": {"en": "All Trustee Data", "de": "Alle Treuhand-Daten", "fr": "Toutes les données fiduciaires"},
@ -148,7 +138,6 @@ TEMPLATE_ROLES = [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.position-documents", "view": True},
# Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
]
@ -165,12 +154,10 @@ TEMPLATE_ROLES = [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.position-documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
# Own records only (MY level) - explizite Regeln pro Tabelle
# Own records only (MY level)
{"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.trustee.TrusteePositionDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
]
},
]