From d9fcea54ff134b1e1d3510f88791b8d7eb3aafee Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 27 Apr 2026 08:07:37 +0200
Subject: [PATCH] trustee agent fix
---
modules/features/trustee/mainTrustee.py | 101 ++++++++++++++++++
.../services/serviceAgent/featureDataAgent.py | 61 +++++++++++
.../services/test_featureDataAgent_schema.py | 49 +++++++++
3 files changed, 211 insertions(+)
diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py
index 020aeda5..fba4346a 100644
--- a/modules/features/trustee/mainTrustee.py
+++ b/modules/features/trustee/mainTrustee.py
@@ -633,6 +633,107 @@ def getDataObjects() -> List[Dict[str, Any]]:
return DATA_OBJECTS
+# ---------------------------------------------------------------------------
+# Feature Data Sub-Agent — domain hints
+# ---------------------------------------------------------------------------
+# Appended to the sub-agent's system prompt so it understands the Swiss
+# KMU chart-of-accounts conventions, the period-month semantics of
+# TrusteeDataAccountBalance, and the canonical query patterns. Without this
+# the agent invents wrong patterns: e.g. SUMming closingBalance across
+# years/months (closingBalance is already a balance) or guessing that 5400
+# (Materialaufwand) is a bank account.
+
+_AGENT_DOMAIN_HINTS = """\
+TRUSTEE DOMAIN HINTS (Swiss KMU accounting):
+
+CHART OF ACCOUNTS — account number prefixes (KMU-Kontenrahmen):
+ 1xxx = Aktiven (assets)
+ 100x Kasse / cash on hand (e.g. 1000 Hauptkasse)
+ 102x Bank / Post (e.g. 1020 ZKB, 1021 PostFinance, 1025 UBS)
+ 105x Wertschriften
+ 11xx Forderungen aus Lieferungen und Leistungen (receivables)
+ 14xx Anlagevermögen (fixed assets)
+ 2xxx = Passiven (liabilities + equity)
+ 20xx Verbindlichkeiten (payables)
+ 28xx Eigenkapital (equity)
+ 3xxx = Ertrag (revenue)
+ 4xxx = Material-/Warenaufwand
+ 5xxx = Personalaufwand
+ 6xxx = übriger betrieblicher Aufwand
+ 8xxx = ausserordentliches / betriebsfremdes Ergebnis
+ 9xxx = Abschluss
+
+ TrusteeDataAccount.accountType is one of: asset / liability / revenue /
+ expense / closing (derived from the first digit when the source system
+ doesn't provide it).
+ → "Bankkonten" = accountNumber LIKE '102%'.
+ → "Kassenkonten" = accountNumber LIKE '100%'.
+ → "Liquide Mittel / Cash" = accountNumber LIKE '10%'.
+
+PERIOD CONVENTION on TrusteeDataAccountBalance:
+ periodMonth = 0 → annual total for periodYear (use this for
+ "Saldo per 31.12.YYYY" / "Stand Jahresende")
+ periodMonth = 1..12 → monthly snapshot (use the month the question
+ refers to, e.g. "per Ende März" → periodMonth=3)
+ closingBalance is the balance AT THE END of the period; openingBalance
+ is the balance AT THE START.
+
+CANONICAL QUERY PATTERNS:
+
+1) "Banksaldo per 31.12.2025" (single tool call, no aggregate):
+ queryTable(
+ tableName="TrusteeDataAccountBalance",
+ filters=[
+ {"field": "periodYear", "op": "=", "value": 2025},
+ {"field": "periodMonth", "op": "=", "value": 0},
+ {"field": "accountNumber", "op": "LIKE", "value": "102%"}
+ ],
+ fields=["accountNumber", "closingBalance", "currency"]
+ )
+ → return the rows as-is. Sum them ONLY in the final answer if the user
+ asked for a total across banks; otherwise list per account.
+
+2) "Saldo Konto 1020 per Ende 2024":
+ queryTable(
+ tableName="TrusteeDataAccountBalance",
+ filters=[
+ {"field": "accountNumber", "op": "=", "value": "1020"},
+ {"field": "periodYear", "op": "=", "value": 2024},
+ {"field": "periodMonth", "op": "=", "value": 0}
+ ],
+ fields=["closingBalance", "currency"]
+ )
+
+3) "Welche Konten gehören zu welchem Typ?" — query TrusteeDataAccount.
+
+4) "Buchungen im März 2025" — bookingDate is a UTC unix-seconds float on
+ TrusteeDataJournalEntry. Convert: 2025-03-01 → 1740787200.0,
+ 2025-04-01 → 1743465600.0, then filter '>= 1740787200.0' AND
+ '< 1743465600.0'. NEVER compare bookingDate against ISO strings.
+
+ANTI-PATTERNS (do NOT do this):
+- aggregateTable(SUM, closingBalance, GROUP BY accountNumber) without
+ period filters — closingBalance is already a balance per period; summing
+ multiple periods produces nonsense (e.g. 7 years × 13 periods = ~90×
+ the real saldo).
+- Using debitTotal/creditTotal as a balance — those are turnovers, not
+ balances. The balance is closingBalance.
+- Picking the top-N accounts by SUM and assuming they are "the most
+ important". Account 5400 (Materialaufwand) and 3434 (Erlöskorrekturen)
+ are NOT bank accounts.
+"""
+
+
+def getAgentDomainHints() -> str:
+ """Return Trustee-specific guidance for the Feature Data Sub-Agent.
+
+ The text is appended verbatim to the sub-agent's system prompt by
+ ``featureDataAgent._buildSchemaContext``. Keep it concise and
+ pattern-driven — every line costs tokens on every sub-agent call.
+ """
+ return _AGENT_DOMAIN_HINTS
+
+
def registerFeature(catalogService) -> bool:
"""
Register this feature's RBAC objects in the catalog.
diff --git a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
index 8c6fd6ab..aa2d332d 100644
--- a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
@@ -339,6 +339,12 @@ def _buildSchemaContext(
JournalLine for "Saldo per "),
* format date filters as UNIX timestamps when the type is float,
* recognise FK relations even though the tools cannot JOIN.
+
+ On top of the generic schema block we append feature-specific domain
+ hints (e.g. Swiss KMU chart-of-accounts conventions for trustee) when
+ the feature module exports a ``getAgentDomainHints()`` function. This
+ lets each feature teach the sub-agent its own jargon without polluting
+ the generic agent code.
"""
tableNames: List[str] = []
tableBlocks: List[str] = []
@@ -393,14 +399,69 @@ def _buildSchemaContext(
"about the link in your answer.",
"- When a table has period-bucketed aggregates (opening/closing balances or totals per "
"period), prefer it over recomputing the same aggregate from raw transactional rows.",
+ "- NEVER apply SUM/AVG to columns that already represent a balance, closing/opening "
+ "total or aggregate (e.g. closingBalance, openingBalance, debitTotal, creditTotal, "
+ "*Balance, *Total). These are already aggregated per period — summing them across "
+ "periods produces meaningless numbers. Use queryTable with explicit period filters "
+ "instead, then pick the single matching row.",
"- CRITICAL: Return data as compact JSON, NOT as markdown tables or prose.",
"- Do NOT reformat, rewrite, or narrate the tool results. Return the raw data directly.",
"- If the question asks for rows, return them as a JSON array. Do NOT generate a markdown table.",
"- Keep your answer SHORT. The caller is a machine, not a human.",
]
+
+ domainHints = _loadFeatureDomainHints(featureCode)
+ if domainHints:
+ parts.extend(["", domainHints.strip()])
+
return "\n".join(parts)
+def _loadFeatureDomainHints(featureCode: str) -> str:
+ """Pull optional domain-specific hints from the feature's main module.
+
+ Each feature can expose ``getAgentDomainHints() -> str`` in its
+ ``mainXxx.py``. The returned text is appended verbatim to the
+ sub-agent's system prompt, so features can teach the agent their own
+ domain jargon (chart-of-accounts conventions, period semantics, query
+ patterns) without coupling the generic agent code to any one feature.
+
+ Failures (missing module, missing hook, exception inside the hook) are
+ swallowed and just yield an empty hints block — domain hints are a
+ best-effort enhancement, not a hard dependency.
+ """
+ if not featureCode:
+ return ""
+ try:
+ from modules.system.registry import loadFeatureMainModules
+ except Exception:
+ return ""
+
+ try:
+ mainModules = loadFeatureMainModules() or {}
+ except Exception as exc:
+ logger.debug("Domain-hints lookup: cannot load main modules (%s)", exc)
+ return ""
+
+ module = mainModules.get(featureCode) or mainModules.get(featureCode.lower())
+ if module is None:
+ return ""
+
+ hook = getattr(module, "getAgentDomainHints", None)
+ if not callable(hook):
+ return ""
+
+ try:
+ hints = hook()
+ except Exception as exc:
+ logger.warning("Feature '%s' getAgentDomainHints() raised: %s", featureCode, exc)
+ return ""
+
+ if not isinstance(hints, str):
+ return ""
+ return hints
+
+
def _buildTableSchemaBlock(tableName: str, tableLabel: str, fields: List[str]) -> str:
"""Render a single table's schema block, enriched from its Pydantic model.
diff --git a/tests/unit/services/test_featureDataAgent_schema.py b/tests/unit/services/test_featureDataAgent_schema.py
index 7bc3f19a..ef37753b 100644
--- a/tests/unit/services/test_featureDataAgent_schema.py
+++ b/tests/unit/services/test_featureDataAgent_schema.py
@@ -134,3 +134,52 @@ def test_buildTableSchemaBlock_journalLineHasNoBookingDate():
)
assert "Table: TrusteeDataJournalLine" in block
assert "bookingDate" not in block
+
+
+def test_buildSchemaContext_forbidsSummingAggregateFields():
+ """The most damaging anti-pattern in trustee queries: SUMming closingBalance
+ across periods. Without this rule the agent reports 11 mio for an account
+ whose real closing balance is 48k. The generic prompt must call this out so
+ every feature benefits, not just trustee."""
+ selected = [_trusteeAccountBalanceObj()]
+ prompt = _buildSchemaContext(
+ featureCode="trustee",
+ instanceLabel="Demo AG",
+ selectedTables=selected,
+ requestLang="de",
+ )
+ assert "NEVER apply SUM/AVG to columns that already represent a balance" in prompt
+ assert "closingBalance" in prompt
+
+
+def test_buildSchemaContext_appendsTrusteeDomainHints():
+ """When the feature module exposes getAgentDomainHints(), the schema prompt
+ must include those hints so the sub-agent knows e.g. that 102x are bank
+ accounts and periodMonth=0 is the annual total."""
+ selected = [_trusteeAccountBalanceObj()]
+ prompt = _buildSchemaContext(
+ featureCode="trustee",
+ instanceLabel="Demo AG",
+ selectedTables=selected,
+ requestLang="de",
+ )
+ assert "TRUSTEE DOMAIN HINTS" in prompt
+ assert "102x Bank / Post" in prompt
+ assert "periodMonth = 0" in prompt
+ assert "ANTI-PATTERNS" in prompt
+ assert 'LIKE \'102%\'' in prompt or "LIKE '102%'" in prompt
+
+
+def test_buildSchemaContext_skipsHintsForFeaturesWithoutHook():
+ """Features that don't export getAgentDomainHints() should produce a prompt
+ without the trailing hints block. Verified by using a feature code that
+ cannot resolve to a main module (registry returns None)."""
+ selected = [_trusteeAccountBalanceObj()]
+ prompt = _buildSchemaContext(
+ featureCode="nosuchfeature",
+ instanceLabel="",
+ selectedTables=selected,
+ requestLang="de",
+ )
+ assert "TRUSTEE DOMAIN HINTS" not in prompt
+ assert "Keep your answer SHORT" in prompt