Merge pull request #145 from valueonag/feat/demo-system-readieness

trustee agent fix
This commit is contained in:
Patrick Motsch 2026-04-27 08:08:32 +02:00 committed by GitHub
commit 4d7ccb0418
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 211 additions and 0 deletions

View file

@ -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.

View file

@ -339,6 +339,12 @@ def _buildSchemaContext(
JournalLine for "Saldo per <date>"),
* 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.

View file

@ -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