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