Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ artifacts/
output/
# Dense-retrieval vectors `gen-assets` writes beside a bank stay local.
src/usersim/engine/assets/financial_services/**/embeddings.parquet
# So do the judge scorecards `evaluate-assets` writes there.
src/usersim/engine/assets/financial_services/**/_eval_scorecard.parquet
src/usersim/engine/assets/financial_services/**/_scorecards/

# IDE
.vscode/
Expand Down
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,13 @@ include = ["usersim*"]
[tool.setuptools.exclude-package-data]
# Retrieval embeds each query with the model that produced the corpus vectors,
# so shipped vectors would tie every install to one embedding model. The
# vectors `gen-assets` writes beside a bank stay local.
"usersim.engine" = ["assets/financial_services/**/embeddings.parquet"]
# vectors `gen-assets` writes beside a bank stay local, as do the judge
# scorecards `evaluate-assets` writes there.
"usersim.engine" = [
"assets/financial_services/**/embeddings.parquet",
"assets/financial_services/**/_eval_scorecard.parquet",
"assets/financial_services/**/_scorecards/*",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
Expand Down
5 changes: 4 additions & 1 deletion scripts/check_dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from the sdist, so a file only the wheel has came from somewhere else, such
as a stale ``build/`` directory;
- the wheel ships the asset banks, and no ``embeddings.parquet`` (retrieval
vectors stay with the embedding model that produced them);
vectors stay with the embedding model that produced them) or
``evaluate-assets`` scorecard;
- the sdist ships no ``tests/``.
"""

Expand Down Expand Up @@ -61,6 +62,8 @@ def check_archives(sdist: Path, wheel: Path) -> list[str]:
problems.append("the wheel ships no asset banks, so every probe would fail at asset resolution")
if any(f.endswith("embeddings.parquet") for f in wheel_files):
problems.append("the wheel ships embeddings.parquet")
if any(f.endswith("_eval_scorecard.parquet") or "/_scorecards/" in f for f in wheel_files):
problems.append("the wheel ships an evaluate-assets scorecard")
tests = [f for f in sdist_files if f.startswith("tests/")]
if tests:
problems.append(f"the sdist ships {len(tests)} file(s) under tests/")
Expand Down
43 changes: 35 additions & 8 deletions src/usersim/asset_gen/financial_services/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,32 @@ def _batches(items: list[Any], cap: int) -> Iterator[list[Any]]:
# ── Phase 1: institution seed + brief config ────────────────────────────


def products_for_prompt(products: list[Product], spec: RegionSpec) -> list[dict[str, Any]]:
"""Each product with a ``display`` rendering of every typed value in the
locale's own notation (``7,90 €``, ``15,9 %``), which the prompts ask the
model to write instead of the raw ``value``."""
from usersim.engine.core.finance_bank import format_amount, format_percent

number_format = spec.number_format or spec.locale
out: list[dict[str, Any]] = []
for product in products:
dumped = product.model_dump()
for var in dumped["variables"].values():
if var["type"] == "money":
var["display"] = format_amount(
var["value"],
currency_symbol=spec.currency_symbol,
number_format=number_format,
drop_zero_cents=True,
)
elif var["type"] == "percent":
var["display"] = format_percent(var["value"], number_format=number_format)
else:
var["display"] = str(var["value"])
out.append(dumped)
return out


def build_institution_seed(spec: RegionSpec):
"""One seed row per institution for the Phase-1 brief (pure)."""
import pandas as pd
Expand All @@ -568,7 +594,7 @@ def build_institution_seed(spec: RegionSpec):
"brand_voice": inst.brand_voice,
"language": spec.language,
"currency_symbol": spec.currency_symbol,
"products_json": json.dumps([p.model_dump() for p in inst.products], ensure_ascii=False),
"products_json": json.dumps(products_for_prompt(inst.products, spec), ensure_ascii=False),
"regulators_json": json.dumps(regulators, ensure_ascii=False),
# Market-specific emphasis: region override else region-neutral
# default, resolved here so the prompt stays free of any one
Expand Down Expand Up @@ -667,8 +693,8 @@ def explode_to_cluster_rows(
"sibling_doc_index": json.dumps(sibling_index, ensure_ascii=False),
}

for product in inst.products:
focus = json.dumps(product.model_dump(), ensure_ascii=False)
for product, prompt_product in zip(inst.products, products_for_prompt(inst.products, spec)):
focus = json.dumps(prompt_product, ensure_ascii=False)
for batch in _batches(product_plans[product.id], max_docs_per_cluster):
rows.append(
{
Expand Down Expand Up @@ -933,10 +959,9 @@ def institution_id(self) -> str:
"# parameters here. Their real schema (signature + args) lives in each tool's\n"
"# discoverable_tool_doc in corpus.yaml -- the assistant learns a tool + its\n"
"# arguments by RETRIEVING that doc via kb_search, then calls it through the\n"
"# call_tool gate (the tau-Banking discoverable-tool mechanic). Putting schemas\n"
"# here would defeat that discovery challenge. Only the always-offered\n"
"# primitives (kb_search / verify_identity) carry a schema; side_effect_class\n"
"# is always set (the scorer uses it).\n"
"# call_tool gate. Putting schemas here would defeat that discovery challenge.\n"
"# Only the always-offered primitives (kb_search / verify_identity) carry a\n"
"# schema; side_effect_class is always set (the scorer uses it).\n"
)


Expand Down Expand Up @@ -1077,9 +1102,11 @@ def _institution_meta(inst: Institution) -> dict[str, Any]:


def _tool_dict(tool: Tool) -> dict[str, Any]:
# A discoverable tool is learned from its retrieved doc, so tools.yaml keeps
# its description empty even when the spec supplies one to steer that doc.
return {
"name": tool.name,
"description": tool.description,
"description": "" if tool.discoverable else tool.description,
"discoverable": tool.discoverable,
"side_effect_class": tool.side_effect_class,
"parameters": tool.parameters,
Expand Down
18 changes: 13 additions & 5 deletions src/usersim/asset_gen/financial_services/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
form in {{ language }} prose, and keep it identical everywhere){% endif %} (id: {{ institution_id }}). Author a concise internal BRIEF that
every downstream document will follow, so the institution's voice and policies stay
consistent. Write ALL prose in {{ language }}; keep product ids and tool/function
names verbatim. Use the {{ currency_symbol }} symbol for money.
names verbatim. Use the {{ currency_symbol }} symbol for money, and write every number
the way {{ language }} writes it: each typed value carries a `display` form, so quote
that form rather than the raw `value`.

Products offered (authoritative typed values -- reference exactly, never invent):
{{ products_json }}
Expand Down Expand Up @@ -115,7 +117,9 @@ def concept_guidance_for(institution_type: str, overrides: dict[str, str] | None
{{ institution_brief }}

Write ALL body prose in {{ language }}. Keep tool/function identifiers, product ids, and
doc_keys VERBATIM. Use the {{ currency_symbol }} symbol for money.
doc_keys VERBATIM. Use the {{ currency_symbol }} symbol for money, and write every number
the way {{ language }} writes it (its decimal mark and digit grouping), never another
language's notation.
TITLES ARE GIVEN, NOT AUTHORED: copy each entry's `topic` into its `title` EXACTLY as
provided -- already in {{ language }} -- and do not translate, shorten, re-word, or
re-punctuate it. The same string is what every sibling document is told to cite when it
Expand All @@ -124,8 +128,9 @@ def concept_guidance_for(institution_type: str, overrides: dict[str, str] | None
{{ language }} prose -- never re-translate it and never vary it between documents, because
readers (and retrieval) rely on one product having one name across the knowledge base.
NUMERIC FIDELITY IS THE #1 REQUIREMENT: reproduce every fee, rate, yield, balance, limit,
threshold, duration, date, and percentage from the focus values EXACTLY as given (same digits,
symbol, %, and units) -- never round, approximate, paraphrase, bucket, omit, or invent a number.
threshold, duration, date, and percentage from the focus values EXACTLY, written as its
`display` form (the same digits, with {{ language }}'s decimal mark, digit grouping, symbol,
%, and units) -- never round, approximate, paraphrase, bucket, omit, or invent a number.
The VALUE is verbatim; its NAME is not. The focus keys are machine field names, so write what
each one means in ordinary prose and NEVER print the key itself: say "an annual interest rate
of 0%", not "an interest_rate_annual of 0%"; "a one-time processing fee of {{ currency_symbol }}499",
Expand Down Expand Up @@ -153,7 +158,7 @@ def concept_guidance_for(institution_type: str, overrides: dict[str, str] | None

Requirements:
- Faithfulness (HIGHEST PRIORITY -- overrides brevity): every numeric value present in the
focus values MUST appear VERBATIM in the document(s) where it belongs. If stating all exact
focus values MUST appear, in its `display` form, in the document(s) where it belongs. If stating all exact
values conflicts with the length target, keep the exact values and cut prose instead -- a
document that rounds, buckets, or omits a value is a failure even if it reads well.
- Length: aim for each body's per-entry `length` target -- a SOFT target that yields to
Expand Down Expand Up @@ -265,6 +270,9 @@ def concept_guidance_for(institution_type: str, overrides: dict[str, str] | None
other numbers in the documents (dates, illustrative examples, regulatory citations) are
EXPECTED and must NOT lower the faithfulness score. If this context lists no product
values (e.g. an institution-wide shared set), there is nothing to contradict -> score 4.
Each value carries a `display` form, its notation in the documents' language: a value
written that way is faithful, while the right digits in another language's notation (a
decimal point where the language uses a decimal comma) are a notation error.
{{ focus_json }}

Generated document set:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# Structured region_spec for en_US — the generation INPUT (data, not code).
# Validated against asset_gen/financial_services/spec.py. The DD pipeline expands
# this into the committed per-institution assets/financial_services/en_US/ bank.
# A locale is a SET of institutions; each spans one or more domains. Fictional
# brands only; do not reuse tau-Banking's fictional bank or product names
# (contamination-avoidance policy).
# A locale is a SET of institutions; each spans one or more domains. Brands are
# fictional.
schema_version: v0.1
region: US
locale: en_US
Expand Down
28 changes: 21 additions & 7 deletions src/usersim/asset_gen/financial_services/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,9 @@ class Tool(BaseModel):
# A region spec that explicitly declares a tool of the same name wins (no dup).
COMMON_UNIVERSAL_OPERATIONS: tuple[tuple[str, SideEffectClass], ...] = (
# Read primitive that lists a VERIFIED customer's accounts (id, type, last4,
# balance). Analogous to tau-Banking's get_user_details: it lets the agent
# resolve a last-4 the customer gives into the account_id that every
# state-changing tool requires, instead of fabricating an id or deflecting.
# balance). It lets the agent resolve a last-4 the customer gives into the
# account_id that every state-changing tool requires, instead of fabricating
# an id or deflecting.
("get_accounts", "read_only"),
("open_account", "state_changing"),
("close_account", "irreversible"),
Expand All @@ -213,8 +213,8 @@ class Tool(BaseModel):
# Deposit-taking institutions (bank / neobank / credit_union) share a servicing
# set; brokerages / robo-advisors share a trading + cash set. Most of these are
# never gold for a task — they exist as realistic, gold-ADJACENT tool docs so
# retrieval must discriminate the right procedure among many similar ones (the
# tau-Knowledge difficulty), and so the discoverable-tool gate is non-trivial.
# retrieval must discriminate the right procedure among many similar ones, and
# so the discoverable-tool gate is non-trivial.
_DEPOSIT_OPS: tuple[tuple[str, SideEffectClass], ...] = (
("transfer_funds", "state_changing"),
("order_replacement_card", "state_changing"),
Expand Down Expand Up @@ -309,17 +309,31 @@ class Institution(BaseModel):
domains: list[Domain] = Field(min_length=1)
products: list[Product] = Field(default_factory=list)
tool_taxonomy: list[Tool] = Field(min_length=1)
#: Common operations of this institution's type that it does not offer, by
#: name: a building-savings bank issues no cards, so it omits the card
#: operations every ``bank`` would otherwise be given.
omit_common_operations: list[str] = Field(default_factory=list)
task_templates: list[TaskTemplate] = Field(min_length=1)

@model_validator(mode="after")
def _inject_common_operations(self) -> "Institution":
"""Merge the common-operations layer (account lifecycle + servicing +
per-type ops) into the tool taxonomy. Runs FIRST so injected tools count
as in-scope for the gold-tool check below; an explicitly-declared tool of
the same name is left untouched (no duplicate)."""
the same name is left untouched (no duplicate), and a name listed in
``omit_common_operations`` is not injected."""
common = {name for name, _ in common_operations_for_type(self.type)}
omitted = set(self.omit_common_operations)
if unknown := sorted(omitted - common):
raise ValueError(
f"{self.id}: omit_common_operations lists {unknown}, which a {self.type!r} is not "
f"given; its common operations are {sorted(common)}"
)
existing = {t.name for t in self.tool_taxonomy}
if both := sorted(omitted & existing):
raise ValueError(f"{self.id}: {both} both declared in tool_taxonomy and omitted")
for name, side_effect in common_operations_for_type(self.type):
if name not in existing:
if name not in existing and name not in omitted:
self.tool_taxonomy.append(Tool(name=name, discoverable=True, side_effect_class=side_effect))
existing.add(name)
return self
Expand Down
32 changes: 31 additions & 1 deletion src/usersim/asset_gen/financial_services/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,33 @@ def _check_numeric_faithfulness(spec: RegionSpec | None, insts: dict[str, _InstD
rep.add("numeric_faithfulness", inst.id, PASS, f"{product.id}: all typed values present")


def _check_number_notation(spec: RegionSpec | None, insts: dict[str, _InstData], rep: ValidationReport) -> None:
"""A decimal-comma locale must not quote amounts or rates in English notation.

``7.9 €`` or ``15.9 %`` in a French or German document is a visible error, and
the numeric check cannot see it: it only asks whether the locale's own form
appears somewhere in a product's documents, so a corpus can pass while most
of its decimals are written the English way.
"""
if spec is None:
return
if _decimal_comma_group_separators(spec.number_format or spec.locale) is None:
return
units = "|".join(re.escape(u) for u in ("%", spec.currency_symbol, spec.currency) if u)
english = re.compile(rf"(?<![\d.,])\d{{1,3}}(?:,\d{{3}})*\.\d{{1,2}}(?=\s?(?:{units}))")
for iid, idata in insts.items():
bad = [d.get("id") for d in idata.documents if english.search(d.get("body", ""))]
if bad:
rep.add(
"number_notation",
iid,
FAIL,
f"{len(bad)}/{len(idata.documents)} doc(s) write an amount or rate with a decimal point: {bad[:3]}",
)
else:
rep.add("number_notation", iid, PASS, "amounts and rates use the locale's decimal comma")


def _check_variable_name_leak(spec: RegionSpec | None, insts: dict[str, _InstData], rep: ValidationReport) -> None:
"""Flag customer-facing prose that prints a spec FIELD KEY.

Expand All @@ -293,7 +320,9 @@ def _check_variable_name_leak(spec: RegionSpec | None, insts: dict[str, _InstDat
idata = insts.get(inst.id)
if idata is None:
continue
keys = {k for p in inst.products for k in p.variables}
# A one-word key such as ``excess`` is also an ordinary word, which an exact
# token match cannot tell apart from prose.
keys = {k for p in inst.products for k in p.variables if "_" in k}
if not keys:
continue
pattern = re.compile(r"\b(" + "|".join(sorted((re.escape(k) for k in keys), key=len, reverse=True)) + r")\b")
Expand Down Expand Up @@ -675,6 +704,7 @@ def validate_bank(bank_dir: str | Path, region_spec: RegionSpec | None = None) -
_check_structural(bank_dir, insts, rep)
_check_coverage(insts, rep)
_check_numeric_faithfulness(region_spec, insts, rep)
_check_number_notation(region_spec, insts, rep)
_check_variable_name_leak(region_spec, insts, rep)
_check_tool_name_leak(insts, rep)
_check_doc_key_leak(insts, rep)
Expand Down
6 changes: 5 additions & 1 deletion src/usersim/engine/assets/financial_services/SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,11 @@ institution offers (`open_account`, `close_account`, `update_contact_info`,
`update_policy`), injected by `asset_gen/financial_services/spec.py`. Most are
never gold: they are realistic, gold-ADJACENT tool docs that make retrieval
(and the discoverable-tool gate) non-trivial, and they stay consistent across
locales.
locales. An institution that does not offer one of them lists it in its region
spec's `omit_common_operations` (a building-savings bank issues no cards), and
one whose local procedure differs declares the tool with a `description` that
steers its generated documentation; `tools.yaml` still ships that description
empty.

## tasks.yaml

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
# parameters here. Their real schema (signature + args) lives in each tool's
# discoverable_tool_doc in corpus.yaml -- the assistant learns a tool + its
# arguments by RETRIEVING that doc via kb_search, then calls it through the
# call_tool gate (the tau-Banking discoverable-tool mechanic). Putting schemas
# here would defeat that discovery challenge. Only the always-offered
# primitives (kb_search / verify_identity) carry a schema; side_effect_class
# is always set (the scorer uses it).
# call_tool gate. Putting schemas here would defeat that discovery challenge.
# Only the always-offered primitives (kb_search / verify_identity) carry a
# schema; side_effect_class is always set (the scorer uses it).
schema_version: v0.1
tools:
- name: kb_search
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
# parameters here. Their real schema (signature + args) lives in each tool's
# discoverable_tool_doc in corpus.yaml -- the assistant learns a tool + its
# arguments by RETRIEVING that doc via kb_search, then calls it through the
# call_tool gate (the tau-Banking discoverable-tool mechanic). Putting schemas
# here would defeat that discovery challenge. Only the always-offered
# primitives (kb_search / verify_identity) carry a schema; side_effect_class
# is always set (the scorer uses it).
# call_tool gate. Putting schemas here would defeat that discovery challenge.
# Only the always-offered primitives (kb_search / verify_identity) carry a
# schema; side_effect_class is always set (the scorer uses it).
schema_version: v0.1
tools:
- name: kb_search
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
# parameters here. Their real schema (signature + args) lives in each tool's
# discoverable_tool_doc in corpus.yaml -- the assistant learns a tool + its
# arguments by RETRIEVING that doc via kb_search, then calls it through the
# call_tool gate (the tau-Banking discoverable-tool mechanic). Putting schemas
# here would defeat that discovery challenge. Only the always-offered
# primitives (kb_search / verify_identity) carry a schema; side_effect_class
# is always set (the scorer uses it).
# call_tool gate. Putting schemas here would defeat that discovery challenge.
# Only the always-offered primitives (kb_search / verify_identity) carry a
# schema; side_effect_class is always set (the scorer uses it).
schema_version: v0.1
tools:
- name: kb_search
Expand Down
Loading
Loading