Merge pull request #145 from valueonag/feat/demo-system-readieness
trustee agent fix
This commit is contained in:
commit
4d7ccb0418
3 changed files with 211 additions and 0 deletions
|
|
@ -633,6 +633,107 @@ def getDataObjects() -> List[Dict[str, Any]]:
|
||||||
return DATA_OBJECTS
|
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:
|
def registerFeature(catalogService) -> bool:
|
||||||
"""
|
"""
|
||||||
Register this feature's RBAC objects in the catalog.
|
Register this feature's RBAC objects in the catalog.
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,12 @@ def _buildSchemaContext(
|
||||||
JournalLine for "Saldo per <date>"),
|
JournalLine for "Saldo per <date>"),
|
||||||
* format date filters as UNIX timestamps when the type is float,
|
* format date filters as UNIX timestamps when the type is float,
|
||||||
* recognise FK relations even though the tools cannot JOIN.
|
* 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] = []
|
tableNames: List[str] = []
|
||||||
tableBlocks: List[str] = []
|
tableBlocks: List[str] = []
|
||||||
|
|
@ -393,14 +399,69 @@ def _buildSchemaContext(
|
||||||
"about the link in your answer.",
|
"about the link in your answer.",
|
||||||
"- When a table has period-bucketed aggregates (opening/closing balances or totals per "
|
"- 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.",
|
"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.",
|
"- 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.",
|
"- 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.",
|
"- 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.",
|
"- 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)
|
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:
|
def _buildTableSchemaBlock(tableName: str, tableLabel: str, fields: List[str]) -> str:
|
||||||
"""Render a single table's schema block, enriched from its Pydantic model.
|
"""Render a single table's schema block, enriched from its Pydantic model.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,3 +134,52 @@ def test_buildTableSchemaBlock_journalLineHasNoBookingDate():
|
||||||
)
|
)
|
||||||
assert "Table: TrusteeDataJournalLine" in block
|
assert "Table: TrusteeDataJournalLine" in block
|
||||||
assert "bookingDate" not 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue