From 5a40b54524c3ea4d1b9725a16ea8165037b8ce75 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 23:40:59 +0200
Subject: [PATCH] fixed data source
---
.../datamodels/datamodelFeatureDataSource.py | 6 +-
modules/features/commcoach/mainCommcoach.py | 21 ++-
modules/features/teamsbot/mainTeamsbot.py | 21 ++-
modules/features/trustee/mainTrustee.py | 24 ++-
.../workspace/routeFeatureWorkspace.py | 149 +++++++++++++++++-
.../services/serviceAgent/featureDataAgent.py | 15 +-
.../serviceAgent/featureDataProvider.py | 70 ++++++--
.../services/serviceAgent/mainServiceAgent.py | 6 +
8 files changed, 282 insertions(+), 30 deletions(-)
diff --git a/modules/datamodels/datamodelFeatureDataSource.py b/modules/datamodels/datamodelFeatureDataSource.py
index 80ceb03c..02de0a67 100644
--- a/modules/datamodels/datamodelFeatureDataSource.py
+++ b/modules/datamodels/datamodelFeatureDataSource.py
@@ -6,7 +6,7 @@ A FeatureDataSource links a FeatureInstance table (DATA_OBJECT) to a workspace
so the agent can query structured feature data (e.g. TrusteePosition rows).
"""
-from typing import Optional
+from typing import Dict, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
@@ -39,6 +39,10 @@ class FeatureDataSource(PowerOnModel):
description="Whether this data source should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
+ recordFilter: Optional[Dict[str, str]] = Field(
+ default=None,
+ description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
+ )
registerModelLabels(
diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py
index 9d949e13..d21da056 100644
--- a/modules/features/commcoach/mainCommcoach.py
+++ b/modules/features/commcoach/mainCommcoach.py
@@ -36,12 +36,22 @@ DATA_OBJECTS = [
{
"objectKey": "data.feature.commcoach.CoachingContext",
"label": {"en": "Coaching Context", "de": "Coaching-Kontext", "fr": "Contexte coaching"},
- "meta": {"table": "CoachingContext", "fields": ["id", "title", "category", "status"]}
+ "meta": {
+ "table": "CoachingContext",
+ "fields": ["id", "title", "category", "status"],
+ "isParent": True,
+ "displayFields": ["title", "category", "status"],
+ }
},
{
"objectKey": "data.feature.commcoach.CoachingSession",
"label": {"en": "Coaching Session", "de": "Coaching-Session", "fr": "Session coaching"},
- "meta": {"table": "CoachingSession", "fields": ["id", "contextId", "status", "summary"]}
+ "meta": {
+ "table": "CoachingSession",
+ "fields": ["id", "contextId", "status", "summary"],
+ "parentTable": "CoachingContext",
+ "parentKey": "contextId",
+ }
},
{
"objectKey": "data.feature.commcoach.CoachingMessage",
@@ -51,7 +61,12 @@ DATA_OBJECTS = [
{
"objectKey": "data.feature.commcoach.CoachingTask",
"label": {"en": "Coaching Task", "de": "Coaching-Aufgabe", "fr": "Tache coaching"},
- "meta": {"table": "CoachingTask", "fields": ["id", "contextId", "title", "status"]}
+ "meta": {
+ "table": "CoachingTask",
+ "fields": ["id", "contextId", "title", "status"],
+ "parentTable": "CoachingContext",
+ "parentKey": "contextId",
+ }
},
{
"objectKey": "data.feature.commcoach.CoachingScore",
diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py
index afdce822..ea6d3b01 100644
--- a/modules/features/teamsbot/mainTeamsbot.py
+++ b/modules/features/teamsbot/mainTeamsbot.py
@@ -39,17 +39,32 @@ DATA_OBJECTS = [
{
"objectKey": "data.feature.teamsbot.TeamsbotSession",
"label": {"en": "Session", "de": "Sitzung", "fr": "Session"},
- "meta": {"table": "TeamsbotSession", "fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"]}
+ "meta": {
+ "table": "TeamsbotSession",
+ "fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
+ "isParent": True,
+ "displayFields": ["botName", "status", "startedAt"],
+ }
},
{
"objectKey": "data.feature.teamsbot.TeamsbotTranscript",
"label": {"en": "Transcript", "de": "Transkript", "fr": "Transcription"},
- "meta": {"table": "TeamsbotTranscript", "fields": ["id", "sessionId", "speaker", "text", "timestamp"]}
+ "meta": {
+ "table": "TeamsbotTranscript",
+ "fields": ["id", "sessionId", "speaker", "text", "timestamp"],
+ "parentTable": "TeamsbotSession",
+ "parentKey": "sessionId",
+ }
},
{
"objectKey": "data.feature.teamsbot.TeamsbotBotResponse",
"label": {"en": "Bot Response", "de": "Bot-Antwort", "fr": "Réponse du bot"},
- "meta": {"table": "TeamsbotBotResponse", "fields": ["id", "sessionId", "responseText", "detectedIntent"]}
+ "meta": {
+ "table": "TeamsbotBotResponse",
+ "fields": ["id", "sessionId", "responseText", "detectedIntent"],
+ "parentTable": "TeamsbotSession",
+ "parentKey": "sessionId",
+ }
},
{
"objectKey": "data.feature.teamsbot.*",
diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py
index 45824b1b..2fd82bc5 100644
--- a/modules/features/trustee/mainTrustee.py
+++ b/modules/features/trustee/mainTrustee.py
@@ -58,10 +58,25 @@ UI_OBJECTS = [
# DATA Objects for RBAC catalog (tables/entities)
# Used for AccessRules on data-level permissions
DATA_OBJECTS = [
+ {
+ "objectKey": "data.feature.trustee.TrusteeOrganisation",
+ "label": {"en": "Organisation", "de": "Organisation", "fr": "Organisation"},
+ "meta": {
+ "table": "TrusteeOrganisation",
+ "fields": ["id", "label", "enabled"],
+ "isParent": True,
+ "displayFields": ["label"],
+ }
+ },
{
"objectKey": "data.feature.trustee.TrusteePosition",
"label": {"en": "Position", "de": "Position", "fr": "Position"},
- "meta": {"table": "TrusteePosition", "fields": ["id", "label", "description", "organisationId"]}
+ "meta": {
+ "table": "TrusteePosition",
+ "fields": ["id", "label", "description", "organisationId"],
+ "parentTable": "TrusteeOrganisation",
+ "parentKey": "organisationId",
+ }
},
{
"objectKey": "data.feature.trustee.TrusteeDocument",
@@ -71,7 +86,12 @@ DATA_OBJECTS = [
{
"objectKey": "data.feature.trustee.TrusteeAccountingConfig",
"label": {"en": "Accounting Config", "de": "Buchhaltungs-Konfiguration", "fr": "Config. comptable"},
- "meta": {"table": "TrusteeAccountingConfig", "fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"]}
+ "meta": {
+ "table": "TrusteeAccountingConfig",
+ "fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"],
+ "parentTable": "TrusteeOrganisation",
+ "parentKey": "organisationId",
+ }
},
{
"objectKey": "data.feature.trustee.TrusteeAccountingSync",
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index 7feef4db..ae0154dc 100644
--- a/modules/features/workspace/routeFeatureWorkspace.py
+++ b/modules/features/workspace/routeFeatureWorkspace.py
@@ -270,12 +270,19 @@ def _buildFeatureDataSourceContext(featureDataSourceIds: List[str]) -> str:
tableFields = obj.get("meta", {}).get("fields", [])
break
+ recordFilter = fds.get("recordFilter")
+ filterLine = ""
+ if recordFilter and isinstance(recordFilter, dict):
+ filterParts = [f"{k} = {v}" for k, v in recordFilter.items()]
+ filterLine = f"\n recordFilter: {', '.join(filterParts)} (data is scoped to this record)"
+
parts.append(
f"- featureInstanceId: {fiId}\n"
f" feature: {featureCode}\n"
f" instance: \"{instanceLabel}\"\n"
f" table: {tableName} ({label})\n"
f" fields: {', '.join(tableFields) if tableFields else 'all'}"
+ f"{filterLine}"
)
except Exception as e:
logger.warning(f"Error loading FeatureDataSource {fdsId}: {e}")
@@ -1336,8 +1343,8 @@ async def listFeatureConnections(
instanceId: str = Path(...),
context: RequestContext = Depends(getRequestContext),
):
- """List feature instances the user has access to across ALL mandates."""
- _validateInstanceAccess(instanceId, context)
+ """List feature instances the user has access to, scoped to the workspace mandate."""
+ wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.security.rbacCatalog import getCatalogService
from modules.datamodels.datamodelUam import Mandate
@@ -1352,8 +1359,14 @@ async def listFeatureConnections(
if not userMandates:
return JSONResponse({"featureConnectionsByMandate": []})
+ allowedMandateIds = {um.mandateId for um in userMandates}
+ if wsMandateId and wsMandateId in allowedMandateIds:
+ allowedMandateIds = {wsMandateId}
+
mandateLabels: dict = {}
for um in userMandates:
+ if um.mandateId not in allowedMandateIds:
+ continue
try:
rows = rootIf.db.getRecordset(Mandate, recordFilter={"id": um.mandateId})
if rows:
@@ -1365,6 +1378,8 @@ async def listFeatureConnections(
byMandate: dict = {}
seenIds: set = set()
for um in userMandates:
+ if um.mandateId not in allowedMandateIds:
+ continue
allInstances = rootIf.getFeatureInstancesByMandate(um.mandateId)
for inst in allInstances:
if inst.id in seenIds:
@@ -1418,7 +1433,7 @@ async def listFeatureConnectionTables(
context: RequestContext = Depends(getRequestContext),
):
"""List data tables (DATA_OBJECTS) for a feature instance, filtered by RBAC."""
- _validateInstanceAccess(instanceId, context)
+ wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.security.rbacCatalog import getCatalogService
@@ -1428,6 +1443,8 @@ async def listFeatureConnectionTables(
raise HTTPException(status_code=404, detail="Feature instance not found")
mandateId = str(inst.mandateId) if inst.mandateId else None
+ if wsMandateId and mandateId and mandateId != wsMandateId:
+ raise HTTPException(status_code=403, detail="Feature instance does not belong to workspace mandate")
catalog = getCatalogService()
try:
@@ -1448,16 +1465,132 @@ async def listFeatureConnectionTables(
tables = []
for obj in accessible:
meta = obj.get("meta", {})
- tables.append({
+ node = {
"objectKey": obj.get("objectKey", ""),
"tableName": meta.get("table", ""),
"label": obj.get("label", {}),
"fields": meta.get("fields", []),
- })
+ }
+ if meta.get("isParent"):
+ node["isParent"] = True
+ node["displayFields"] = meta.get("displayFields", [])
+ if meta.get("parentTable"):
+ node["parentTable"] = meta["parentTable"]
+ node["parentKey"] = meta.get("parentKey", "")
+ tables.append(node)
return JSONResponse({"tables": tables})
+@router.get("/{instanceId}/feature-connections/{fiId}/parent-objects/{tableName}")
+@limiter.limit("120/minute")
+async def listParentObjects(
+ request: Request,
+ instanceId: str = Path(...),
+ fiId: str = Path(..., description="Feature instance ID"),
+ tableName: str = Path(..., description="Parent table name from DATA_OBJECTS"),
+ context: RequestContext = Depends(getRequestContext),
+):
+ """List records from a parent table so the user can pick a specific record to scope data."""
+ wsMandateId, _ = _validateInstanceAccess(instanceId, context)
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ from modules.security.rbacCatalog import getCatalogService
+
+ rootIf = getRootInterface()
+ inst = rootIf.getFeatureInstance(fiId)
+ if not inst:
+ raise HTTPException(status_code=404, detail="Feature instance not found")
+
+ featureCode = inst.featureCode
+ mandateId = str(inst.mandateId) if inst.mandateId else ""
+ if wsMandateId and mandateId and mandateId != wsMandateId:
+ raise HTTPException(status_code=403, detail="Feature instance does not belong to workspace mandate")
+ catalog = getCatalogService()
+
+ parentObj = None
+ for obj in catalog.getDataObjects(featureCode):
+ meta = obj.get("meta", {})
+ if meta.get("table") == tableName and meta.get("isParent"):
+ parentObj = obj
+ break
+ if not parentObj:
+ raise HTTPException(status_code=400, detail=f"Table '{tableName}' is not a registered parent table")
+
+ displayFields = parentObj["meta"].get("displayFields", [])
+ selectCols = ', '.join(f'"{f}"' for f in (["id"] + displayFields)) if displayFields else "*"
+
+ from modules.connectors.connectorDbPostgre import DatabaseConnector
+ from modules.shared.configuration import APP_CONFIG
+ featureDbName = f"poweron_{featureCode.lower()}"
+ featureDbConn = None
+ try:
+ featureDbConn = DatabaseConnector(
+ dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
+ dbDatabase=featureDbName,
+ dbUser=APP_CONFIG.get("DB_USER"),
+ dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
+ dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
+ userId=str(context.user.id),
+ )
+ conn = featureDbConn.connection
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT column_name FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND LOWER(table_name) = LOWER(%s) "
+ "AND column_name IN ('featureInstanceId', 'instanceId')",
+ [tableName],
+ )
+ instanceCols = [row["column_name"] for row in cur.fetchall()]
+ instanceCol = "featureInstanceId" if "featureInstanceId" in instanceCols else "instanceId"
+
+ cur.execute(
+ "SELECT column_name FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND LOWER(table_name) = LOWER(%s) "
+ "AND column_name = 'userId'",
+ [tableName],
+ )
+ hasUserId = cur.rowcount > 0
+
+ sql = (
+ f'SELECT {selectCols} FROM "{tableName}" '
+ f'WHERE "{instanceCol}" = %s'
+ )
+ params = [fiId]
+ if mandateId:
+ sql += ' AND "mandateId" = %s'
+ params.append(mandateId)
+ if hasUserId:
+ sql += ' AND "userId" = %s'
+ params.append(str(context.user.id))
+ sql += ' ORDER BY "id" DESC LIMIT 100'
+ cur.execute(sql, params)
+ rows = []
+ for row in cur.fetchall():
+ r = dict(row)
+ for k, v in r.items():
+ if hasattr(v, "isoformat"):
+ r[k] = v.isoformat()
+ elif isinstance(v, (bytes, bytearray)):
+ r[k] = f""
+ displayParts = [str(r.get(f, "")) for f in displayFields if r.get(f) is not None]
+ rows.append({
+ "id": r.get("id", ""),
+ "displayLabel": " | ".join(displayParts) if displayParts else r.get("id", ""),
+ "fields": {f: r.get(f) for f in displayFields},
+ })
+ except Exception as e:
+ logger.error(f"listParentObjects({tableName}) failed: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to list parent objects: {e}")
+ finally:
+ if featureDbConn:
+ try:
+ featureDbConn.close()
+ except Exception:
+ pass
+
+ return JSONResponse({"parentObjects": rows})
+
+
class CreateFeatureDataSourceRequest(BaseModel):
"""Request body for adding a feature table as data source."""
featureInstanceId: str = Field(description="Feature instance ID")
@@ -1465,6 +1598,7 @@ class CreateFeatureDataSourceRequest(BaseModel):
tableName: str = Field(description="Table name from DATA_OBJECTS")
objectKey: str = Field(description="RBAC object key")
label: str = Field(description="User-visible label")
+ recordFilter: Optional[dict] = Field(default=None, description="Record-level filter for scoping")
@router.post("/{instanceId}/feature-datasources")
@@ -1476,13 +1610,15 @@ async def createFeatureDataSource(
context: RequestContext = Depends(getRequestContext),
):
"""Create a FeatureDataSource for this workspace instance."""
- _validateInstanceAccess(instanceId, context)
+ wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
rootIf = getRootInterface()
inst = rootIf.getFeatureInstance(body.featureInstanceId)
mandateId = str(inst.mandateId) if inst else (str(context.mandateId) if context.mandateId else "")
+ if wsMandateId and mandateId and mandateId != wsMandateId:
+ raise HTTPException(status_code=403, detail="Feature instance does not belong to workspace mandate")
fds = FeatureDataSource(
featureInstanceId=body.featureInstanceId,
@@ -1493,6 +1629,7 @@ async def createFeatureDataSource(
mandateId=mandateId,
userId=str(context.user.id),
workspaceInstanceId=instanceId,
+ recordFilter=body.recordFilter,
)
created = rootIf.db.recordCreate(FeatureDataSource, fds.model_dump())
return JSONResponse(created if isinstance(created, dict) else fds.model_dump())
diff --git a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
index e36745df..8ef0bfcc 100644
--- a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
@@ -38,6 +38,7 @@ async def runFeatureDataAgent(
aiCallFn: Callable[[AiCallRequest], Awaitable[AiCallResponse]],
dbConnector,
instanceLabel: str = "",
+ tableFilters: Optional[Dict[str, Dict[str, str]]] = None,
) -> str:
"""Run the feature data sub-agent and return the textual result.
@@ -51,13 +52,14 @@ async def runFeatureDataAgent(
aiCallFn: AI call function (with billing).
dbConnector: DatabaseConnector for queries.
instanceLabel: Human-readable instance name for context.
+ tableFilters: Per-table record filters from FeatureDataSource.recordFilter.
Returns:
Plain-text answer produced by the sub-agent.
"""
provider = FeatureDataProvider(dbConnector)
- registry = _buildSubAgentTools(provider, featureInstanceId, mandateId)
+ registry = _buildSubAgentTools(provider, featureInstanceId, mandateId, tableFilters or {})
for tbl in selectedTables:
meta = tbl.get("meta", {})
@@ -103,9 +105,18 @@ def _buildSubAgentTools(
provider: FeatureDataProvider,
featureInstanceId: str,
mandateId: str,
+ tableFilters: Dict[str, Dict[str, str]] = None,
) -> ToolRegistry:
"""Register browseTable and queryTable as sub-agent tools."""
registry = ToolRegistry()
+ _tableFilters = tableFilters or {}
+
+ def _recordFilterToList(tableName: str) -> Optional[List[Dict[str, Any]]]:
+ """Convert a recordFilter dict to a list of {field, op, value} filter dicts."""
+ rf = _tableFilters.get(tableName)
+ if not rf:
+ return None
+ return [{"field": k, "op": "=", "value": v} for k, v in rf.items()]
async def _browseTable(args: Dict[str, Any], context: Dict[str, Any]):
tableName = args.get("tableName", "")
@@ -121,6 +132,7 @@ def _buildSubAgentTools(
fields=fields,
limit=min(limit, 200),
offset=offset,
+ extraFilters=_recordFilterToList(tableName),
)
return ToolResult(
toolCallId="", toolName="browseTable",
@@ -147,6 +159,7 @@ def _buildSubAgentTools(
orderBy=orderBy,
limit=min(limit, 200),
offset=offset,
+ extraFilters=_recordFilterToList(tableName),
)
return ToolResult(
toolCallId="", toolName="queryTable",
diff --git a/modules/serviceCenter/services/serviceAgent/featureDataProvider.py b/modules/serviceCenter/services/serviceAgent/featureDataProvider.py
index 40bf0c6b..25a0ff95 100644
--- a/modules/serviceCenter/services/serviceAgent/featureDataProvider.py
+++ b/modules/serviceCenter/services/serviceAgent/featureDataProvider.py
@@ -69,28 +69,36 @@ class FeatureDataProvider:
fields: List[str] = None,
limit: int = 50,
offset: int = 0,
+ extraFilters: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""List rows from a feature table with pagination.
Returns ``{"rows": [...], "total": N, "limit": L, "offset": O}``.
"""
_validateTableName(tableName)
- scopeFilter = _buildScopeFilter(tableName, featureInstanceId, mandateId)
+ conn = self._db.connection
+ scopeFilter = _buildScopeFilter(tableName, featureInstanceId, mandateId, dbConnection=conn)
+ extraWhere, extraParams = _buildFilterClauses(extraFilters)
+
+ fullWhere = scopeFilter["where"]
+ allParams = list(scopeFilter["params"])
+ if extraWhere:
+ fullWhere += " AND " + extraWhere
+ allParams.extend(extraParams)
try:
- conn = self._db.connection
with conn.cursor() as cur:
- countSql = f'SELECT COUNT(*) FROM "{tableName}" WHERE {scopeFilter["where"]}'
- cur.execute(countSql, scopeFilter["params"])
+ countSql = f'SELECT COUNT(*) FROM "{tableName}" WHERE {fullWhere}'
+ cur.execute(countSql, allParams)
total = cur.fetchone()["count"] if cur.rowcount else 0
selectCols = ", ".join(f'"{f}"' for f in fields) if fields else "*"
dataSql = (
f'SELECT {selectCols} FROM "{tableName}" '
- f'WHERE {scopeFilter["where"]} '
+ f'WHERE {fullWhere} '
f'ORDER BY "id" LIMIT %s OFFSET %s'
)
- cur.execute(dataSql, scopeFilter["params"] + [limit, offset])
+ cur.execute(dataSql, allParams + [limit, offset])
rows = [_serializeRow(dict(r)) for r in cur.fetchall()]
return {"rows": rows, "total": total, "limit": limit, "offset": offset}
@@ -108,14 +116,19 @@ class FeatureDataProvider:
orderBy: str = None,
limit: int = 50,
offset: int = 0,
+ extraFilters: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""Query a feature table with optional filters.
``filters`` is a list of ``{"field": "x", "op": "=", "value": "y"}``.
+ ``extraFilters`` are mandatory record-level scoping filters injected by the pipeline.
"""
_validateTableName(tableName)
- scopeFilter = _buildScopeFilter(tableName, featureInstanceId, mandateId)
- extraWhere, extraParams = _buildFilterClauses(filters)
+ conn = self._db.connection
+ scopeFilter = _buildScopeFilter(tableName, featureInstanceId, mandateId, dbConnection=conn)
+
+ combinedFilters = list(filters or []) + list(extraFilters or [])
+ extraWhere, extraParams = _buildFilterClauses(combinedFilters if combinedFilters else None)
fullWhere = scopeFilter["where"]
allParams = list(scopeFilter["params"])
@@ -124,7 +137,6 @@ class FeatureDataProvider:
allParams.extend(extraParams)
try:
- conn = self._db.connection
with conn.cursor() as cur:
countSql = f'SELECT COUNT(*) FROM "{tableName}" WHERE {fullWhere}'
cur.execute(countSql, allParams)
@@ -149,6 +161,34 @@ class FeatureDataProvider:
# helpers
# ------------------------------------------------------------------
+_instanceColCache: Dict[str, str] = {}
+
+
+def _resolveInstanceColumn(tableName: str, dbConnection=None) -> str:
+ """Detect whether the table uses ``instanceId`` or ``featureInstanceId``."""
+ if tableName in _instanceColCache:
+ return _instanceColCache[tableName]
+ if dbConnection:
+ try:
+ with dbConnection.cursor() as cur:
+ cur.execute(
+ "SELECT column_name FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND LOWER(table_name) = LOWER(%s) "
+ "AND column_name IN ('featureInstanceId', 'instanceId')",
+ [tableName],
+ )
+ cols = [row["column_name"] for row in cur.fetchall()]
+ if "featureInstanceId" in cols:
+ _instanceColCache[tableName] = "featureInstanceId"
+ return "featureInstanceId"
+ if "instanceId" in cols:
+ _instanceColCache[tableName] = "instanceId"
+ return "instanceId"
+ except Exception:
+ pass
+ return "instanceId"
+
+
def _validateTableName(tableName: str):
if not tableName or not _isValidIdentifier(tableName):
raise ValueError(f"Invalid table name: {tableName}")
@@ -159,17 +199,19 @@ def _isValidIdentifier(name: str) -> bool:
return name.isidentifier()
-def _buildScopeFilter(tableName: str, featureInstanceId: str, mandateId: str) -> Dict[str, Any]:
+def _buildScopeFilter(tableName: str, featureInstanceId: str, mandateId: str, dbConnection=None) -> Dict[str, Any]:
"""Build the mandatory WHERE clause that scopes rows to the feature instance.
- Feature tables usually have either ``featureInstanceId`` or a combination
- of ``mandateId`` + an org/context FK. We try ``featureInstanceId`` first,
- then fall back to ``mandateId``.
+ Feature tables use either ``instanceId`` (commcoach, teamsbot) or
+ ``featureInstanceId`` (trustee) as the FK. We detect the actual column
+ from ``information_schema`` when a DB connection is provided.
"""
+ instanceCol = _resolveInstanceColumn(tableName, dbConnection)
+
conditions = []
params = []
- conditions.append('"featureInstanceId" = %s')
+ conditions.append(f'"{instanceCol}" = %s')
params.append(featureInstanceId)
if mandateId:
diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
index 9a702aa0..08950ea3 100644
--- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
@@ -3192,11 +3192,16 @@ def _registerCoreTools(registry: ToolRegistry, services):
from modules.security.rbacCatalog import getCatalogService
catalog = getCatalogService()
+ tableFilters = {}
if not featureDataSources:
selectedTables = catalog.getDataObjects(featureCode)
else:
allObjs = {o["meta"]["table"]: o for o in catalog.getDataObjects(featureCode) if "meta" in o and "table" in o.get("meta", {})}
selectedTables = [allObjs[ds["tableName"]] for ds in featureDataSources if ds.get("tableName") in allObjs]
+ for ds in featureDataSources:
+ rf = ds.get("recordFilter")
+ if rf and isinstance(rf, dict) and ds.get("tableName"):
+ tableFilters[ds["tableName"]] = rf
if not selectedTables:
return ToolResult(
@@ -3239,6 +3244,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
aiCallFn=_subAgentAiCall,
dbConnector=featureDbConn,
instanceLabel=instanceLabel,
+ tableFilters=tableFilters,
)
finally:
try: