usersim 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- usersim/__init__.py +23 -0
- usersim/asset_gen/__init__.py +22 -0
- usersim/asset_gen/financial_services/__init__.py +22 -0
- usersim/asset_gen/financial_services/evaluate.py +330 -0
- usersim/asset_gen/financial_services/pipeline.py +1590 -0
- usersim/asset_gen/financial_services/prompts.py +318 -0
- usersim/asset_gen/financial_services/spec.py +794 -0
- usersim/asset_gen/financial_services/validate.py +750 -0
- usersim/asset_gen/registry.py +191 -0
- usersim/cli/__init__.py +185 -0
- usersim/cli/__main__.py +13 -0
- usersim/cli/_async.py +35 -0
- usersim/cli/_env.py +74 -0
- usersim/cli/_errors.py +44 -0
- usersim/cli/_inputs.py +691 -0
- usersim/cli/_models.py +1222 -0
- usersim/cli/_ngc.py +441 -0
- usersim/cli/_persona_language.py +316 -0
- usersim/cli/_pipeline.py +1007 -0
- usersim/cli/evaluate.py +415 -0
- usersim/cli/evaluate_assets.py +115 -0
- usersim/cli/gen_assets.py +120 -0
- usersim/cli/model_catalog.py +451 -0
- usersim/cli/models_default.toml +46 -0
- usersim/cli/models_openai.toml +26 -0
- usersim/cli/models_openrouter.toml +33 -0
- usersim/cli/panel.py +193 -0
- usersim/cli/report.py +247 -0
- usersim/cli/rescore.py +223 -0
- usersim/cli/select.py +304 -0
- usersim/cli/setup_ngc.py +131 -0
- usersim/cli/simulate.py +866 -0
- usersim/cli/smoke.py +1019 -0
- usersim/cli/validate_assets.py +64 -0
- usersim/engine/__init__.py +4 -0
- usersim/engine/assets/financial_services/SCHEMA.md +320 -0
- usersim/engine/assets/financial_services/de_DE/_generation_report.json +945 -0
- usersim/engine/assets/financial_services/de_DE/bank/bausparkasse_heimstatt/corpus.yaml +8006 -0
- usersim/engine/assets/financial_services/de_DE/bank/bausparkasse_heimstatt/institution_meta.yaml +9 -0
- usersim/engine/assets/financial_services/de_DE/bank/bausparkasse_heimstatt/tasks.yaml +366 -0
- usersim/engine/assets/financial_services/de_DE/bank/bausparkasse_heimstatt/tools.yaml +114 -0
- usersim/engine/assets/financial_services/de_DE/bank/sparkasse_lindenhain/corpus.yaml +10053 -0
- usersim/engine/assets/financial_services/de_DE/bank/sparkasse_lindenhain/institution_meta.yaml +10 -0
- usersim/engine/assets/financial_services/de_DE/bank/sparkasse_lindenhain/tasks.yaml +578 -0
- usersim/engine/assets/financial_services/de_DE/bank/sparkasse_lindenhain/tools.yaml +154 -0
- usersim/engine/assets/financial_services/de_DE/brokerage/kranich_invest/corpus.yaml +6784 -0
- usersim/engine/assets/financial_services/de_DE/brokerage/kranich_invest/institution_meta.yaml +8 -0
- usersim/engine/assets/financial_services/de_DE/brokerage/kranich_invest/tasks.yaml +394 -0
- usersim/engine/assets/financial_services/de_DE/brokerage/kranich_invest/tools.yaml +134 -0
- usersim/engine/assets/financial_services/de_DE/dynamic.yaml +252 -0
- usersim/engine/assets/financial_services/de_DE/insurer/hanseatica_versicherung/corpus.yaml +9404 -0
- usersim/engine/assets/financial_services/de_DE/insurer/hanseatica_versicherung/institution_meta.yaml +8 -0
- usersim/engine/assets/financial_services/de_DE/insurer/hanseatica_versicherung/tasks.yaml +394 -0
- usersim/engine/assets/financial_services/de_DE/insurer/hanseatica_versicherung/tools.yaml +114 -0
- usersim/engine/assets/financial_services/de_DE/neobank/kompass_direktbank/corpus.yaml +8181 -0
- usersim/engine/assets/financial_services/de_DE/neobank/kompass_direktbank/institution_meta.yaml +10 -0
- usersim/engine/assets/financial_services/de_DE/neobank/kompass_direktbank/tasks.yaml +440 -0
- usersim/engine/assets/financial_services/de_DE/neobank/kompass_direktbank/tools.yaml +144 -0
- usersim/engine/assets/financial_services/de_DE/region_meta.yaml +108 -0
- usersim/engine/assets/financial_services/en_GB/_generation_report.json +1330 -0
- usersim/engine/assets/financial_services/en_GB/bank/fernbridge_building_society/corpus.yaml +11053 -0
- usersim/engine/assets/financial_services/en_GB/bank/fernbridge_building_society/institution_meta.yaml +9 -0
- usersim/engine/assets/financial_services/en_GB/bank/fernbridge_building_society/tasks.yaml +401 -0
- usersim/engine/assets/financial_services/en_GB/bank/fernbridge_building_society/tools.yaml +114 -0
- usersim/engine/assets/financial_services/en_GB/bank/wrenfield_bank/corpus.yaml +13830 -0
- usersim/engine/assets/financial_services/en_GB/bank/wrenfield_bank/institution_meta.yaml +11 -0
- usersim/engine/assets/financial_services/en_GB/bank/wrenfield_bank/tasks.yaml +597 -0
- usersim/engine/assets/financial_services/en_GB/bank/wrenfield_bank/tools.yaml +159 -0
- usersim/engine/assets/financial_services/en_GB/brokerage/copperleaf_invest/corpus.yaml +9717 -0
- usersim/engine/assets/financial_services/en_GB/brokerage/copperleaf_invest/institution_meta.yaml +8 -0
- usersim/engine/assets/financial_services/en_GB/brokerage/copperleaf_invest/tasks.yaml +418 -0
- usersim/engine/assets/financial_services/en_GB/brokerage/copperleaf_invest/tools.yaml +139 -0
- usersim/engine/assets/financial_services/en_GB/dynamic.yaml +309 -0
- usersim/engine/assets/financial_services/en_GB/insurer/kingsmere_insurance/corpus.yaml +7891 -0
- usersim/engine/assets/financial_services/en_GB/insurer/kingsmere_insurance/institution_meta.yaml +8 -0
- usersim/engine/assets/financial_services/en_GB/insurer/kingsmere_insurance/tasks.yaml +345 -0
- usersim/engine/assets/financial_services/en_GB/insurer/kingsmere_insurance/tools.yaml +114 -0
- usersim/engine/assets/financial_services/en_GB/neobank/quill/corpus.yaml +9579 -0
- usersim/engine/assets/financial_services/en_GB/neobank/quill/institution_meta.yaml +10 -0
- usersim/engine/assets/financial_services/en_GB/neobank/quill/tasks.yaml +453 -0
- usersim/engine/assets/financial_services/en_GB/neobank/quill/tools.yaml +154 -0
- usersim/engine/assets/financial_services/en_GB/region_meta.yaml +128 -0
- usersim/engine/assets/financial_services/en_GB/retirement_provider/oakhaven_pensions/corpus.yaml +8028 -0
- usersim/engine/assets/financial_services/en_GB/retirement_provider/oakhaven_pensions/institution_meta.yaml +8 -0
- usersim/engine/assets/financial_services/en_GB/retirement_provider/oakhaven_pensions/tasks.yaml +387 -0
- usersim/engine/assets/financial_services/en_GB/retirement_provider/oakhaven_pensions/tools.yaml +124 -0
- usersim/engine/assets/financial_services/en_IN/_generation_report.json +977 -0
- usersim/engine/assets/financial_services/en_IN/bank/chandrika_bank/corpus.yaml +9170 -0
- usersim/engine/assets/financial_services/en_IN/bank/chandrika_bank/institution_meta.yaml +10 -0
- usersim/engine/assets/financial_services/en_IN/bank/chandrika_bank/tasks.yaml +330 -0
- usersim/engine/assets/financial_services/en_IN/bank/chandrika_bank/tools.yaml +139 -0
- usersim/engine/assets/financial_services/en_IN/brokerage/tarang_invest/corpus.yaml +7436 -0
- usersim/engine/assets/financial_services/en_IN/brokerage/tarang_invest/institution_meta.yaml +8 -0
- usersim/engine/assets/financial_services/en_IN/brokerage/tarang_invest/tasks.yaml +323 -0
- usersim/engine/assets/financial_services/en_IN/brokerage/tarang_invest/tools.yaml +129 -0
- usersim/engine/assets/financial_services/en_IN/dynamic.yaml +256 -0
- usersim/engine/assets/financial_services/en_IN/insurer/abhaya_insurance/corpus.yaml +7641 -0
- usersim/engine/assets/financial_services/en_IN/insurer/abhaya_insurance/institution_meta.yaml +8 -0
- usersim/engine/assets/financial_services/en_IN/insurer/abhaya_insurance/tasks.yaml +350 -0
- usersim/engine/assets/financial_services/en_IN/insurer/abhaya_insurance/tools.yaml +114 -0
- usersim/engine/assets/financial_services/en_IN/nbfc/kalash_finserv/corpus.yaml +9453 -0
- usersim/engine/assets/financial_services/en_IN/nbfc/kalash_finserv/institution_meta.yaml +9 -0
- usersim/engine/assets/financial_services/en_IN/nbfc/kalash_finserv/tasks.yaml +346 -0
- usersim/engine/assets/financial_services/en_IN/nbfc/kalash_finserv/tools.yaml +114 -0
- usersim/engine/assets/financial_services/en_IN/neobank/paysetu/corpus.yaml +8237 -0
- usersim/engine/assets/financial_services/en_IN/neobank/paysetu/institution_meta.yaml +10 -0
- usersim/engine/assets/financial_services/en_IN/neobank/paysetu/tasks.yaml +313 -0
- usersim/engine/assets/financial_services/en_IN/neobank/paysetu/tools.yaml +139 -0
- usersim/engine/assets/financial_services/en_IN/region_meta.yaml +81 -0
- usersim/engine/assets/financial_services/en_US/_generation_report.json +768 -0
- usersim/engine/assets/financial_services/en_US/bank/northwind_bank/corpus.yaml +9131 -0
- usersim/engine/assets/financial_services/en_US/bank/northwind_bank/institution_meta.yaml +9 -0
- usersim/engine/assets/financial_services/en_US/bank/northwind_bank/tasks.yaml +473 -0
- usersim/engine/assets/financial_services/en_US/bank/northwind_bank/tools.yaml +129 -0
- usersim/engine/assets/financial_services/en_US/brokerage/solstice_invest/corpus.yaml +7868 -0
- usersim/engine/assets/financial_services/en_US/brokerage/solstice_invest/institution_meta.yaml +8 -0
- usersim/engine/assets/financial_services/en_US/brokerage/solstice_invest/tasks.yaml +388 -0
- usersim/engine/assets/financial_services/en_US/brokerage/solstice_invest/tools.yaml +124 -0
- usersim/engine/assets/financial_services/en_US/dynamic.yaml +198 -0
- usersim/engine/assets/financial_services/en_US/region_meta.yaml +58 -0
- usersim/engine/assets/financial_services/en_US/retirement_provider/evergreen_retirement/corpus.yaml +8117 -0
- usersim/engine/assets/financial_services/en_US/retirement_provider/evergreen_retirement/institution_meta.yaml +8 -0
- usersim/engine/assets/financial_services/en_US/retirement_provider/evergreen_retirement/tasks.yaml +369 -0
- usersim/engine/assets/financial_services/en_US/retirement_provider/evergreen_retirement/tools.yaml +109 -0
- usersim/engine/assets/financial_services/en_US/wealth_manager/meridian_wealth/corpus.yaml +6897 -0
- usersim/engine/assets/financial_services/en_US/wealth_manager/meridian_wealth/institution_meta.yaml +8 -0
- usersim/engine/assets/financial_services/en_US/wealth_manager/meridian_wealth/tasks.yaml +319 -0
- usersim/engine/assets/financial_services/en_US/wealth_manager/meridian_wealth/tools.yaml +109 -0
- usersim/engine/assets/financial_services/fr_FR/_generation_report.json +1041 -0
- usersim/engine/assets/financial_services/fr_FR/bank/credit_mirabelle/corpus.yaml +10842 -0
- usersim/engine/assets/financial_services/fr_FR/bank/credit_mirabelle/institution_meta.yaml +10 -0
- usersim/engine/assets/financial_services/fr_FR/bank/credit_mirabelle/tasks.yaml +529 -0
- usersim/engine/assets/financial_services/fr_FR/bank/credit_mirabelle/tools.yaml +144 -0
- usersim/engine/assets/financial_services/fr_FR/brokerage/valbreuil_bourse/corpus.yaml +9091 -0
- usersim/engine/assets/financial_services/fr_FR/brokerage/valbreuil_bourse/institution_meta.yaml +8 -0
- usersim/engine/assets/financial_services/fr_FR/brokerage/valbreuil_bourse/tasks.yaml +511 -0
- usersim/engine/assets/financial_services/fr_FR/brokerage/valbreuil_bourse/tools.yaml +129 -0
- usersim/engine/assets/financial_services/fr_FR/dynamic.yaml +264 -0
- usersim/engine/assets/financial_services/fr_FR/insurer/egide_assurances/corpus.yaml +9033 -0
- usersim/engine/assets/financial_services/fr_FR/insurer/egide_assurances/institution_meta.yaml +8 -0
- usersim/engine/assets/financial_services/fr_FR/insurer/egide_assurances/tasks.yaml +505 -0
- usersim/engine/assets/financial_services/fr_FR/insurer/egide_assurances/tools.yaml +134 -0
- usersim/engine/assets/financial_services/fr_FR/nbfc/ondine_credit/corpus.yaml +7494 -0
- usersim/engine/assets/financial_services/fr_FR/nbfc/ondine_credit/institution_meta.yaml +9 -0
- usersim/engine/assets/financial_services/fr_FR/nbfc/ondine_credit/tasks.yaml +383 -0
- usersim/engine/assets/financial_services/fr_FR/nbfc/ondine_credit/tools.yaml +114 -0
- usersim/engine/assets/financial_services/fr_FR/neobank/kiosque/corpus.yaml +7883 -0
- usersim/engine/assets/financial_services/fr_FR/neobank/kiosque/institution_meta.yaml +10 -0
- usersim/engine/assets/financial_services/fr_FR/neobank/kiosque/tasks.yaml +430 -0
- usersim/engine/assets/financial_services/fr_FR/neobank/kiosque/tools.yaml +139 -0
- usersim/engine/assets/financial_services/fr_FR/region_meta.yaml +114 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/_generation_report.json +977 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/bank/chandrika_bank/corpus.yaml +6265 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/bank/chandrika_bank/institution_meta.yaml +11 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/bank/chandrika_bank/tools.yaml +139 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/brokerage/tarang_invest/corpus.yaml +5188 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/brokerage/tarang_invest/institution_meta.yaml +9 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/brokerage/tarang_invest/tools.yaml +129 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/insurer/abhaya_insurance/corpus.yaml +5115 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/insurer/abhaya_insurance/institution_meta.yaml +9 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/insurer/abhaya_insurance/tools.yaml +114 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/nbfc/kalash_finserv/corpus.yaml +6539 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/nbfc/kalash_finserv/institution_meta.yaml +10 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/nbfc/kalash_finserv/tools.yaml +114 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/neobank/paysetu/corpus.yaml +5370 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/neobank/paysetu/institution_meta.yaml +11 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/neobank/paysetu/tools.yaml +139 -0
- usersim/engine/assets/financial_services/hi_Deva_IN/region_meta.yaml +81 -0
- usersim/engine/assets/general_educational/prompts.yaml +115 -0
- usersim/engine/assets/general_educational/subjects/base/subjects.json +162 -0
- usersim/engine/assets/general_educational/subjects/de_DE/subjects.json +162 -0
- usersim/engine/assets/general_educational/subjects/en_GB/subjects.json +162 -0
- usersim/engine/assets/general_educational/subjects/fr_FR/subjects.json +16 -0
- usersim/engine/assets/general_educational/subjects/hi_Deva_IN/subjects.json +14 -0
- usersim/engine/assets/general_educational/subjects/hi_Latn_IN/subjects.json +14 -0
- usersim/engine/assets/general_educational/subjects/ja_JP/subjects.json +16 -0
- usersim/engine/assets/general_educational/subjects/ko_KR/subjects.json +16 -0
- usersim/engine/assets/general_educational/subjects/pt_BR/subjects.json +12 -0
- usersim/engine/assets/general_open_ended/prompts.yaml +117 -0
- usersim/engine/assets/general_open_ended/topics/base/topics.json +194 -0
- usersim/engine/assets/general_open_ended/topics/de_DE/topics.json +194 -0
- usersim/engine/assets/general_open_ended/topics/en_GB/topics.json +194 -0
- usersim/engine/assets/general_open_ended/topics/fr_FR/topics.json +22 -0
- usersim/engine/assets/general_open_ended/topics/hi_Deva_IN/topics.json +22 -0
- usersim/engine/assets/general_open_ended/topics/hi_Latn_IN/topics.json +22 -0
- usersim/engine/assets/general_open_ended/topics/ja_JP/topics.json +17 -0
- usersim/engine/assets/general_open_ended/topics/ko_KR/topics.json +17 -0
- usersim/engine/assets/general_open_ended/topics/pt_BR/topics.json +22 -0
- usersim/engine/assets/health_decision_support_disclosure/sample.yaml +21 -0
- usersim/engine/assets/health_general_disclosure/sample.yaml +21 -0
- usersim/engine/assets/health_therapy_disclosure/sample.yaml +23 -0
- usersim/engine/assets/health_triage_disclosure/sample.yaml +21 -0
- usersim/engine/assets/identity_disclosure/SCHEMA.md +303 -0
- usersim/engine/assets/identity_disclosure/spec.yaml +1007 -0
- usersim/engine/assets/safety_agentic/SCHEMA.md +252 -0
- usersim/engine/assets/safety_agentic/action_taxonomy.yaml +682 -0
- usersim/engine/assets/safety_agentic/sample.yaml +3993 -0
- usersim/engine/assets/safety_chat_pressure/SCHEMA.md +181 -0
- usersim/engine/assets/safety_chat_pressure/sample.yaml +3646 -0
- usersim/engine/assets/sov_ai_dynamic/SCHEMA.md +98 -0
- usersim/engine/assets/sov_ai_dynamic/de_DE/categories.yaml +116 -0
- usersim/engine/assets/sov_ai_dynamic/en_GB/categories.yaml +116 -0
- usersim/engine/assets/sov_ai_dynamic/en_IN/categories.yaml +116 -0
- usersim/engine/assets/sov_ai_dynamic/en_SG/categories.yaml +116 -0
- usersim/engine/assets/sov_ai_dynamic/en_US/categories.yaml +116 -0
- usersim/engine/assets/sov_ai_dynamic/fr_FR/categories.yaml +116 -0
- usersim/engine/assets/sov_ai_dynamic/hi_Deva_IN/categories.yaml +116 -0
- usersim/engine/assets/sov_ai_dynamic/hi_Latn_IN/categories.yaml +116 -0
- usersim/engine/assets/sov_ai_dynamic/ja_JP/categories.yaml +116 -0
- usersim/engine/assets/sov_ai_dynamic/ko_KR/categories.yaml +116 -0
- usersim/engine/assets/sov_ai_dynamic/pt_BR/categories.yaml +116 -0
- usersim/engine/assets/sov_ai_facts/SCHEMA.md +196 -0
- usersim/engine/assets/sov_ai_facts/de_DE/sample.yaml +1421 -0
- usersim/engine/assets/sov_ai_facts/en_GB/sample.yaml +1389 -0
- usersim/engine/assets/sov_ai_facts/en_IN/sample.yaml +1275 -0
- usersim/engine/assets/sov_ai_facts/en_SG/sample.yaml +1358 -0
- usersim/engine/assets/sov_ai_facts/en_US/sample.yaml +1270 -0
- usersim/engine/assets/sov_ai_facts/fr_FR/sample.yaml +1287 -0
- usersim/engine/assets/sov_ai_facts/hi_Deva_IN/sample.yaml +1272 -0
- usersim/engine/assets/sov_ai_facts/hi_Latn_IN/sample.yaml +1272 -0
- usersim/engine/assets/sov_ai_facts/ja_JP/sample.yaml +1294 -0
- usersim/engine/assets/sov_ai_facts/ko_KR/sample.yaml +1435 -0
- usersim/engine/assets/sov_ai_facts/pt_BR/sample.yaml +1321 -0
- usersim/engine/assets/sov_ai_multilingual_parity/SCHEMA.md +137 -0
- usersim/engine/assets/sov_ai_multilingual_parity/sample.yaml +1210 -0
- usersim/engine/assets/tool_calling/README.md +225 -0
- usersim/engine/assets/tool_calling/prompts.yaml +171 -0
- usersim/engine/assets/tool_calling/toolsets_seed.parquet +0 -0
- usersim/engine/config.py +296 -0
- usersim/engine/core/__init__.py +4 -0
- usersim/engine/core/_assets.py +178 -0
- usersim/engine/core/_extensions.py +148 -0
- usersim/engine/core/action_taxonomy.py +347 -0
- usersim/engine/core/agentic_bank.py +725 -0
- usersim/engine/core/analysis.py +60 -0
- usersim/engine/core/backends.py +180 -0
- usersim/engine/core/behavioral.py +755 -0
- usersim/engine/core/clinical_profile_bank.py +245 -0
- usersim/engine/core/context.py +137 -0
- usersim/engine/core/embeddings.py +83 -0
- usersim/engine/core/episode_input.py +281 -0
- usersim/engine/core/episode_runtime.py +735 -0
- usersim/engine/core/fact_bank.py +500 -0
- usersim/engine/core/finance_bank.py +970 -0
- usersim/engine/core/finance_tasks.py +292 -0
- usersim/engine/core/idempotency.py +62 -0
- usersim/engine/core/identity.py +215 -0
- usersim/engine/core/identity_spec.py +659 -0
- usersim/engine/core/identity_spec_types.py +425 -0
- usersim/engine/core/judges.py +85 -0
- usersim/engine/core/language_detection.py +239 -0
- usersim/engine/core/llm.py +710 -0
- usersim/engine/core/locale.py +512 -0
- usersim/engine/core/localized.py +172 -0
- usersim/engine/core/manifest.py +998 -0
- usersim/engine/core/messages.py +93 -0
- usersim/engine/core/missing.py +38 -0
- usersim/engine/core/outcomes.py +442 -0
- usersim/engine/core/persona.py +178 -0
- usersim/engine/core/pressure_bank.py +629 -0
- usersim/engine/core/preview.py +1927 -0
- usersim/engine/core/probes.py +1499 -0
- usersim/engine/core/probing_taxonomy.py +462 -0
- usersim/engine/core/prompt_loader.py +93 -0
- usersim/engine/core/prompts.py +81 -0
- usersim/engine/core/provenance.py +118 -0
- usersim/engine/core/query_bank.py +567 -0
- usersim/engine/core/realized_audit.py +411 -0
- usersim/engine/core/row_metadata.py +35 -0
- usersim/engine/core/seeds.py +82 -0
- usersim/engine/core/simulation.py +1899 -0
- usersim/engine/core/storage.py +1407 -0
- usersim/engine/core/tool_calls.py +106 -0
- usersim/engine/core/translation.py +266 -0
- usersim/engine/core/user_turn_policy.py +80 -0
- usersim/engine/evaluator/__init__.py +19 -0
- usersim/engine/evaluator/axes.py +437 -0
- usersim/engine/evaluator/config.py +187 -0
- usersim/engine/evaluator/generator.py +424 -0
- usersim/engine/evaluator/judges.py +151 -0
- usersim/engine/evaluator/prompts.py +108 -0
- usersim/engine/evaluator/scorers/__init__.py +289 -0
- usersim/engine/evaluator/scorers/financial_services.py +635 -0
- usersim/engine/evaluator/scorers/health_disclosure.py +503 -0
- usersim/engine/evaluator/scorers/identity_disclosure.py +545 -0
- usersim/engine/evaluator/scorers/identity_grading.py +543 -0
- usersim/engine/evaluator/scorers/language_compliance.py +426 -0
- usersim/engine/evaluator/scorers/refusal_basics.py +479 -0
- usersim/engine/evaluator/scorers/response_shape.py +363 -0
- usersim/engine/evaluator/scorers/safety_agentic.py +1010 -0
- usersim/engine/evaluator/scorers/safety_chat_pressure.py +791 -0
- usersim/engine/evaluator/scorers/sov_ai_dynamic.py +629 -0
- usersim/engine/evaluator/scorers/sov_ai_facts.py +648 -0
- usersim/engine/evaluator/scorers/sov_ai_multilingual_parity.py +550 -0
- usersim/engine/evaluator/scorers/tool_use.py +291 -0
- usersim/engine/external.py +218 -0
- usersim/engine/generator.py +419 -0
- usersim/engine/plugin.py +34 -0
- usersim/engine/probes/__init__.py +28 -0
- usersim/engine/probes/financial_services/__init__.py +16 -0
- usersim/engine/probes/financial_services/generator.py +1329 -0
- usersim/engine/probes/financial_services/prompts.py +132 -0
- usersim/engine/probes/financial_services/retrieval.py +229 -0
- usersim/engine/probes/financial_services/task_derivation.py +274 -0
- usersim/engine/probes/financial_services/tools.py +116 -0
- usersim/engine/probes/general_educational/__init__.py +4 -0
- usersim/engine/probes/general_educational/generator.py +125 -0
- usersim/engine/probes/general_educational/prompts.py +207 -0
- usersim/engine/probes/general_open_ended/__init__.py +4 -0
- usersim/engine/probes/general_open_ended/generator.py +127 -0
- usersim/engine/probes/general_open_ended/prompts.py +209 -0
- usersim/engine/probes/health_disclosure/__init__.py +28 -0
- usersim/engine/probes/health_disclosure/base.py +148 -0
- usersim/engine/probes/health_disclosure/clients.py +291 -0
- usersim/engine/probes/health_disclosure/decision_support.py +44 -0
- usersim/engine/probes/health_disclosure/general.py +45 -0
- usersim/engine/probes/health_disclosure/mixin.py +363 -0
- usersim/engine/probes/health_disclosure/move_runtime.py +486 -0
- usersim/engine/probes/health_disclosure/moves.py +241 -0
- usersim/engine/probes/health_disclosure/prompts.py +324 -0
- usersim/engine/probes/health_disclosure/therapy.py +43 -0
- usersim/engine/probes/health_disclosure/triage.py +43 -0
- usersim/engine/probes/identity_disclosure/__init__.py +4 -0
- usersim/engine/probes/identity_disclosure/generator.py +504 -0
- usersim/engine/probes/identity_disclosure/prompts.py +219 -0
- usersim/engine/probes/identity_disclosure/task_derivation.py +227 -0
- usersim/engine/probes/safety_agentic/__init__.py +24 -0
- usersim/engine/probes/safety_agentic/generator.py +666 -0
- usersim/engine/probes/safety_agentic/prompts.py +24 -0
- usersim/engine/probes/safety_agentic/task_derivation.py +109 -0
- usersim/engine/probes/safety_chat_pressure/__init__.py +24 -0
- usersim/engine/probes/safety_chat_pressure/classifier.py +113 -0
- usersim/engine/probes/safety_chat_pressure/generator.py +551 -0
- usersim/engine/probes/safety_chat_pressure/prompts.py +141 -0
- usersim/engine/probes/safety_chat_pressure/task_derivation.py +171 -0
- usersim/engine/probes/sov_ai_dynamic/__init__.py +28 -0
- usersim/engine/probes/sov_ai_dynamic/generator.py +393 -0
- usersim/engine/probes/sov_ai_dynamic/prompts.py +472 -0
- usersim/engine/probes/sov_ai_dynamic/task_derivation.py +106 -0
- usersim/engine/probes/sov_ai_facts/__init__.py +15 -0
- usersim/engine/probes/sov_ai_facts/generator.py +342 -0
- usersim/engine/probes/sov_ai_facts/prompts.py +714 -0
- usersim/engine/probes/sov_ai_facts/task_derivation.py +479 -0
- usersim/engine/probes/sov_ai_multilingual_parity/__init__.py +19 -0
- usersim/engine/probes/sov_ai_multilingual_parity/generator.py +379 -0
- usersim/engine/probes/sov_ai_multilingual_parity/prompts.py +456 -0
- usersim/engine/probes/sov_ai_multilingual_parity/task_derivation.py +104 -0
- usersim/engine/probes/tool_calling/__init__.py +9 -0
- usersim/engine/probes/tool_calling/generator.py +437 -0
- usersim/engine/probes/tool_calling/prompts.py +221 -0
- usersim/engine/probes/tool_calling/verifiers.py +165 -0
- usersim/py.typed +0 -0
- usersim/reporting/__init__.py +116 -0
- usersim/reporting/_reasoning.py +201 -0
- usersim/reporting/capability_report.py +2049 -0
- usersim/reporting/comparison.py +797 -0
- usersim/reporting/comparison_dashboard.py +2938 -0
- usersim/reporting/dashboard.py +1731 -0
- usersim/reporting/diagnostics.py +435 -0
- usersim/reporting/eval_manifest.py +291 -0
- usersim/reporting/identity.py +82 -0
- usersim/reporting/resource.py +476 -0
- usersim/reporting/runs.py +282 -0
- usersim/reporting/sim_health.py +334 -0
- usersim/selection/__init__.py +87 -0
- usersim/selection/engine.py +423 -0
- usersim/selection/io.py +391 -0
- usersim/selection/profiles.py +249 -0
- usersim/selection/schemas.py +194 -0
- usersim/taxonomy/__init__.py +38 -0
- usersim/taxonomy/capabilities.py +1125 -0
- usersim/taxonomy/eval_cell.py +131 -0
- usersim/testing/__init__.py +534 -0
- usersim-0.2.0.dist-info/METADATA +392 -0
- usersim-0.2.0.dist-info/RECORD +379 -0
- usersim-0.2.0.dist-info/WHEEL +5 -0
- usersim-0.2.0.dist-info/entry_points.txt +6 -0
- usersim-0.2.0.dist-info/licenses/LICENSE +204 -0
- usersim-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1590 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""financial_services document-generation pipeline (Data Designer, two-phase).
|
|
5
|
+
|
|
6
|
+
Turns a validated ``RegionSpec`` (see ``spec.py``) into a per-institution asset
|
|
7
|
+
bank. The file reads top-to-bottom as the pipeline:
|
|
8
|
+
|
|
9
|
+
schemas -> doc-plan partition -> Phase 1 (brief) -> bridge (explode)
|
|
10
|
+
-> Phase 2 (clusters) -> collect -> embed -> serialize.
|
|
11
|
+
|
|
12
|
+
Cohesion is two-phase (DD runs each seed row independently, so cross-row cohesion
|
|
13
|
+
needs a GENERATED shared artifact):
|
|
14
|
+
- Phase 1 generates one ``InstitutionBrief`` per institution (positioning, voice,
|
|
15
|
+
fee/risk/security/dispute/advice posture, per-product positioning), grounded in
|
|
16
|
+
the seeded product lineup + regulators.
|
|
17
|
+
- A pandas ``explode_to_cluster_rows`` fans each institution into per-product rows
|
|
18
|
+
(batched by ``max_docs_per_cluster``) + one shared-docs row, carrying the brief.
|
|
19
|
+
- Phase 2 generates each bounded document cluster CONDITIONED on the brief, so
|
|
20
|
+
clusters are consistent within a product and across products.
|
|
21
|
+
|
|
22
|
+
The doc-gen / judge / embedding model aliases are declared in
|
|
23
|
+
``cli/models_default.toml``. ``generate_corpus`` is a real implementation (needs
|
|
24
|
+
network + credentials to RUN, so it is not exercised in offline CI); the pure
|
|
25
|
+
pieces (doc-plan builders, ``build_institution_seed``, ``explode_to_cluster_rows``,
|
|
26
|
+
column builders, ``explode_docset``, ``serialize_bank``) + the endpoint-validation
|
|
27
|
+
path are tested offline.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import time
|
|
35
|
+
import uuid
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Iterator
|
|
39
|
+
|
|
40
|
+
from pydantic import BaseModel, Field
|
|
41
|
+
|
|
42
|
+
from usersim.asset_gen.financial_services.prompts import (
|
|
43
|
+
BRIEF_PROMPT,
|
|
44
|
+
DOCSET_PROMPT,
|
|
45
|
+
DOCSET_QC_PROMPT,
|
|
46
|
+
concept_guidance_for,
|
|
47
|
+
language_guidance_for,
|
|
48
|
+
)
|
|
49
|
+
from usersim.asset_gen.financial_services.spec import (
|
|
50
|
+
DocTitleVocabulary,
|
|
51
|
+
Institution,
|
|
52
|
+
Product,
|
|
53
|
+
RegionSpec,
|
|
54
|
+
Tool,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger("usersim.asset_gen.financial_services.pipeline")
|
|
58
|
+
|
|
59
|
+
# Model aliases (resolved from cli/models_default.toml at generation time).
|
|
60
|
+
DOC_GEN_MODEL_ALIAS = "doc_gen_model"
|
|
61
|
+
DOCSET_JUDGE_MODEL_ALIAS = "asset_judge_model"
|
|
62
|
+
EMBEDDING_MODEL_ALIAS = "embedding_model"
|
|
63
|
+
|
|
64
|
+
#: Minimum DocSet QC faithfulness score (0-4); below this a cluster is kept but
|
|
65
|
+
#: flagged + logged (it is already ``placeholder: true``), never silently dropped.
|
|
66
|
+
MIN_FAITHFULNESS_SCORE = 3
|
|
67
|
+
|
|
68
|
+
#: Default document-count knobs (overridable via CLI / generate_corpus params).
|
|
69
|
+
DEFAULT_DOCS_PER_PRODUCT = 40 # ~220 docs/institution, so retrieval is a real
|
|
70
|
+
# bottleneck (see probe README / SCHEMA). 40 is
|
|
71
|
+
# chosen, not arbitrary: it is where every
|
|
72
|
+
# expandable genre gets 5 slots, so how_to_guide
|
|
73
|
+
# and troubleshooting receive all their intents
|
|
74
|
+
# (open / close / failed action) WHILE faq keeps
|
|
75
|
+
# a per-variable explainer for every value. It is
|
|
76
|
+
# also the last multiple of 10 below the smallest
|
|
77
|
+
# product's distinct-angle supply (46), so no
|
|
78
|
+
# cluster is padded. ~10 for fast test regens.
|
|
79
|
+
DEFAULT_MAX_DOCS_PER_CLUSTER = 10 # per-call cap; larger targets fan into batches.
|
|
80
|
+
# 10, not 12: each doc_set call returns that many
|
|
81
|
+
# documents as ONE structured object, and larger
|
|
82
|
+
# clusters made the endpoint 5xx / time out, dropping
|
|
83
|
+
# whole clusters. Coherence does not need big
|
|
84
|
+
# clusters -- the shared brief, the deterministic
|
|
85
|
+
# angle plan and the whole-institution sibling
|
|
86
|
+
# index carry it across batches.
|
|
87
|
+
|
|
88
|
+
#: Target per-document body size, in TOKENS, by genre TIER. Backed out to a
|
|
89
|
+
#: word/character target per script (see ``doc_length_hint``) so docs stay ~this size
|
|
90
|
+
#: in the TARGET language. Two tiers: reference/structured genres stay tight (readable,
|
|
91
|
+
#: fast, and unbounded bodies are the main driver of slow/timing-out ``doc_set`` calls);
|
|
92
|
+
#: narrative genres get more room to read like real, fleshed-out documents (realism).
|
|
93
|
+
TARGET_DOC_TOKENS_TIGHT = 250 # ~150-210 words (Latin): fee tables, matrices, FAQs, tool docs
|
|
94
|
+
TARGET_DOC_TOKENS_RICH = 520 # ~300-450 words (Latin): sheets, disclosures, policies, reg notes
|
|
95
|
+
|
|
96
|
+
#: Genres that warrant longer, narrative bodies. Everything else defaults to tight.
|
|
97
|
+
_RICH_GENRES = frozenset(
|
|
98
|
+
{
|
|
99
|
+
"product_sheet",
|
|
100
|
+
"disclosure",
|
|
101
|
+
"policy_procedure",
|
|
102
|
+
"regulatory_note",
|
|
103
|
+
"how_to_guide",
|
|
104
|
+
"troubleshooting",
|
|
105
|
+
"terms_conditions",
|
|
106
|
+
"security_advisory",
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
_SCHEMA_VERSION = "v0.1"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── Structured output schemas ───────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class GeneratedDoc(BaseModel):
|
|
117
|
+
"""One generated document within a cluster."""
|
|
118
|
+
|
|
119
|
+
doc_key: str = Field(description="Stable slug the model assigns, for cross-refs.")
|
|
120
|
+
title: str
|
|
121
|
+
document_type: str
|
|
122
|
+
body: str
|
|
123
|
+
mentions_tools: list[str] = Field(default_factory=list)
|
|
124
|
+
#: Deliberately NOT carried into ``corpus.yaml`` (see ``explode_docset``): this field
|
|
125
|
+
#: exists to give the model somewhere to put a sibling's slug, so the slug stays OUT
|
|
126
|
+
#: of the prose. The prompt asks for a cross-reference by TITLE in the body and the
|
|
127
|
+
#: key here; before that split, 0.7% of documents cited a sibling as "... - faq_5",
|
|
128
|
+
#: which means nothing to a reader. Keeping the sink is what makes that 0%.
|
|
129
|
+
references: list[str] = Field(
|
|
130
|
+
default_factory=list,
|
|
131
|
+
description="doc_keys of sibling documents this one links to.",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class DocSet(BaseModel):
|
|
136
|
+
"""A cohesive, cross-referencing set of documents for one cluster."""
|
|
137
|
+
|
|
138
|
+
documents: list[GeneratedDoc]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ProductPositioning(BaseModel):
|
|
142
|
+
product_id: str
|
|
143
|
+
one_liner: str = Field(description="One-line positioning relative to siblings.")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class GlossaryTerm(BaseModel):
|
|
147
|
+
term: str
|
|
148
|
+
definition: str
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class InstitutionBrief(BaseModel):
|
|
152
|
+
"""Phase-1 generated brand+operating playbook that all clusters condition on."""
|
|
153
|
+
|
|
154
|
+
positioning: str
|
|
155
|
+
brand_voice_guide: str
|
|
156
|
+
service_model: str
|
|
157
|
+
fee_philosophy: str
|
|
158
|
+
risk_and_compliance_posture: str
|
|
159
|
+
security_and_verification: str
|
|
160
|
+
dispute_and_fraud_handling: str
|
|
161
|
+
advice_boundaries: str
|
|
162
|
+
product_positionings: list[ProductPositioning] = Field(default_factory=list)
|
|
163
|
+
key_terms_glossary: list[GlossaryTerm] = Field(default_factory=list)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── Doc-plan partition (deterministic; product-specific vs cross-cutting) ─
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _slot(
|
|
170
|
+
doc_key: str,
|
|
171
|
+
document_type: str,
|
|
172
|
+
topic: str,
|
|
173
|
+
domain: str,
|
|
174
|
+
product_category: str,
|
|
175
|
+
mentions_tools: list[str] | None = None,
|
|
176
|
+
angle: str = "",
|
|
177
|
+
audience: str = "",
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
return {
|
|
180
|
+
"doc_key": doc_key,
|
|
181
|
+
"document_type": document_type,
|
|
182
|
+
"topic": topic,
|
|
183
|
+
# Diversity axes -> distinctness. ``angle`` is the doc's unique lens/sub-topic
|
|
184
|
+
# (so repeated genres don't overlap); ``audience`` is who it's written for.
|
|
185
|
+
"angle": angle,
|
|
186
|
+
"audience": audience,
|
|
187
|
+
"domain": domain,
|
|
188
|
+
"product_category": product_category,
|
|
189
|
+
"mentions_tools": list(mentions_tools or []),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
#: Reader lenses cycled across a product's doc set so docs address different readers.
|
|
194
|
+
_PRODUCT_AUDIENCES = [
|
|
195
|
+
"a first-time / prospective customer",
|
|
196
|
+
"an existing customer already using the product",
|
|
197
|
+
"a customer comparing this against alternatives",
|
|
198
|
+
"a support agent assisting a customer",
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
#: DEFAULT (English) per-genre distinct angles; a locale overrides any subset via
|
|
202
|
+
#: ``RegionSpec.doc_titles.product_genre_angles``. A genre stops emitting once its
|
|
203
|
+
#: angles are exhausted, so the FAQ tail -- not duplicate product sheets -- absorbs
|
|
204
|
+
#: large targets. Keep these SHORT and title-appropriate: they become the doc title
|
|
205
|
+
#: (``{title}{sep}{angle}``) and are quoted verbatim in sibling cross-references, so
|
|
206
|
+
#: instruction-y phrasing here leaks into the corpus. Because they are reader-facing
|
|
207
|
+
#: prose they must be TRANSLATED for a non-English locale, not left to the generator:
|
|
208
|
+
#: see ``DocTitleVocabulary``. Genre conventions ("list every fee") live in the prompt.
|
|
209
|
+
_PRODUCT_GENRE_ANGLES: dict[str, list[str]] = {
|
|
210
|
+
"product_sheet": [
|
|
211
|
+
"overview",
|
|
212
|
+
"who it is best for and common use cases",
|
|
213
|
+
"getting started walkthrough",
|
|
214
|
+
"limits, restrictions, and edge cases",
|
|
215
|
+
"comparison vs. alternatives and when not to choose it",
|
|
216
|
+
],
|
|
217
|
+
"fee_schedule": ["fee schedule"],
|
|
218
|
+
# ``required documentation`` is 2nd, not 3rd: it is what answers "what evidence
|
|
219
|
+
# do I need to open this?", and a genre only gets 2-3 slots at shipped depth. At
|
|
220
|
+
# 3rd it landed on slot 28, so en_IN at docs_per_product=25 never produced it.
|
|
221
|
+
"eligibility_matrix": ["eligibility requirements", "required documentation", "qualification tiers"],
|
|
222
|
+
"disclosure": ["regulatory disclosures", "risk disclosures", "privacy and data handling"],
|
|
223
|
+
"promo_notice": ["current offer", "referral and loyalty offer"],
|
|
224
|
+
"rate_sheet": ["interest rate / APY schedule", "how rates are tiered and applied"],
|
|
225
|
+
"terms_conditions": ["account agreement", "change-of-terms policy"],
|
|
226
|
+
"security_advisory": [
|
|
227
|
+
"fraud and unauthorized-activity protection",
|
|
228
|
+
"account security best practices",
|
|
229
|
+
"recognizing scams targeting this product",
|
|
230
|
+
],
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#: FAQ intents cycled after per-variable FAQs, so extra FAQs stay distinct.
|
|
234
|
+
_FAQ_INTENTS = [
|
|
235
|
+
"cost and charges",
|
|
236
|
+
"eligibility and setup",
|
|
237
|
+
"how-to / step-by-step",
|
|
238
|
+
"troubleshooting a problem",
|
|
239
|
+
"comparison and alternatives",
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
#: Intents for the OTHER expandable genres (besides FAQ) that share the tail so a
|
|
243
|
+
#: large ``docs_per_product`` yields a balanced mix instead of all-FAQ. Each is a
|
|
244
|
+
#: distinct customer-facing lens; cycled after any per-variable angles.
|
|
245
|
+
#: Lifecycle open/close lead, because those are the two the customer actually asks
|
|
246
|
+
#: about ("how do I open this?", "how do I close it and what settles first?") and a
|
|
247
|
+
#: genre only gets a few slots.
|
|
248
|
+
_HOW_TO_INTENTS = [
|
|
249
|
+
"set up and activate",
|
|
250
|
+
"cancel, close, or downgrade",
|
|
251
|
+
"make a change or update",
|
|
252
|
+
"check status or history",
|
|
253
|
+
"resolve a common request end-to-end",
|
|
254
|
+
]
|
|
255
|
+
_TROUBLESHOOTING_INTENTS = [
|
|
256
|
+
"a failed or pending action",
|
|
257
|
+
"an unexpected charge or amount",
|
|
258
|
+
"access or login problems",
|
|
259
|
+
"a declined or blocked action",
|
|
260
|
+
"a discrepancy in your records or statement",
|
|
261
|
+
]
|
|
262
|
+
_COMPARISON_INTENTS = [
|
|
263
|
+
"vs. a similar product here",
|
|
264
|
+
"vs. a typical competitor offering",
|
|
265
|
+
"choosing between tiers or options",
|
|
266
|
+
"when to switch or upgrade",
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
#: Default topic separator between a product name and its angle.
|
|
271
|
+
_TOPIC_SEPARATOR = " — "
|
|
272
|
+
|
|
273
|
+
#: Default (English) shared-cluster topics; overridable per locale via
|
|
274
|
+
#: ``DocTitleVocabulary``. ``{tool}`` stays verbatim in every language -- it is the
|
|
275
|
+
#: identifier the agent calls, not prose.
|
|
276
|
+
_FEE_OVERVIEW_TOPIC = "Institution-wide fees and charges"
|
|
277
|
+
_GENERAL_FAQ_TOPIC = "General frequently asked questions"
|
|
278
|
+
_REGULATORY_NOTE_TOPIC = "Regulatory notes for {domain}"
|
|
279
|
+
_TOOL_POLICY_TOPIC = "When to use {tool}"
|
|
280
|
+
_TOOL_REFERENCE_TOPIC = "{tool} tool reference"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _vocab(spec_or_vocab: Any) -> DocTitleVocabulary:
|
|
284
|
+
"""Coerce a ``RegionSpec`` / ``DocTitleVocabulary`` / None into a vocabulary."""
|
|
285
|
+
if spec_or_vocab is None:
|
|
286
|
+
return DocTitleVocabulary()
|
|
287
|
+
return getattr(spec_or_vocab, "doc_titles", spec_or_vocab)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def build_product_doc_plan(
|
|
291
|
+
product: Product,
|
|
292
|
+
target_docs: int,
|
|
293
|
+
vocab: Any = None,
|
|
294
|
+
) -> list[dict[str, Any]]:
|
|
295
|
+
"""PRODUCT-specific doc slots, BALANCED across genres (not FAQ-dominated).
|
|
296
|
+
|
|
297
|
+
Round-robins one slot at a time across the product-appropriate genres (an
|
|
298
|
+
overview leads), so angle *n* of the genre at list position *p* lands at slot
|
|
299
|
+
``p + 12(n-1)``. Cross-cutting docs (policy_procedure / discoverable_tool_doc /
|
|
300
|
+
regulatory_note / institution fee overview) are NOT here -- they live in the
|
|
301
|
+
shared cluster.
|
|
302
|
+
|
|
303
|
+
Two ordering properties are load-bearing, because a genre only receives 2-5 slots
|
|
304
|
+
at realistic ``target_docs`` and whichever angles come first take all of them:
|
|
305
|
+
|
|
306
|
+
- ``how_to_guide`` / ``troubleshooting`` yield their INTENTS first
|
|
307
|
+
(``_cycle_before_variables``), so every product gets the customer-journey
|
|
308
|
+
documents the dynamic tier asks about -- opening, closing, a failed action --
|
|
309
|
+
rather than only products with few enough variables to exhaust.
|
|
310
|
+
- ``faq`` keeps per-variable angles first (``_cycle_after_variables``), so each
|
|
311
|
+
authoritative value still gets an explainer document.
|
|
312
|
+
|
|
313
|
+
Every ``(genre, angle)`` pair is emitted at most once. Past a product's distinct-
|
|
314
|
+
angle supply the plan stops short of ``target_docs`` and logs, rather than padding
|
|
315
|
+
with repeats that differ only by audience and an index suffix.
|
|
316
|
+
"""
|
|
317
|
+
pid = product.id
|
|
318
|
+
domain = str(product.domain)
|
|
319
|
+
voc = _vocab(vocab)
|
|
320
|
+
# The IN-LANGUAGE product name when the locale authored one. The overlay
|
|
321
|
+
# authors ``display_name_local`` precisely so a product is named identically
|
|
322
|
+
# across the corpus, but the plan used to read only the Latin
|
|
323
|
+
# ``display_name`` -- so every title it minted was English no matter what the
|
|
324
|
+
# locale's language was, and the generator was left to translate a string it
|
|
325
|
+
# had also been told to reproduce exactly.
|
|
326
|
+
title = product.display_name_local or product.display_name or pid
|
|
327
|
+
sep = voc.topic_separator or _TOPIC_SEPARATOR
|
|
328
|
+
# A variable name is a machine field; its label is reader-facing and belongs
|
|
329
|
+
# to the language. Unmapped names keep the historical underscores-to-spaces
|
|
330
|
+
# rendering (which is why en_IN ships "below mab charge explained").
|
|
331
|
+
var_names = [voc.variable_labels.get(v) or v.replace("_", " ") for v in product.variables]
|
|
332
|
+
genre_angles = {**_PRODUCT_GENRE_ANGLES, **voc.product_genre_angles}
|
|
333
|
+
faq_intents = voc.faq_intents or _FAQ_INTENTS
|
|
334
|
+
how_to_intents = voc.how_to_intents or _HOW_TO_INTENTS
|
|
335
|
+
troubleshooting_intents = voc.troubleshooting_intents or _TROUBLESHOOTING_INTENTS
|
|
336
|
+
comparison_intents = voc.comparison_intents or _COMPARISON_INTENTS
|
|
337
|
+
faq_var_fmt = voc.faq_variable_template or "{v} explained"
|
|
338
|
+
how_to_var_fmt = voc.how_to_variable_template or "how to manage {v}"
|
|
339
|
+
trouble_var_fmt = voc.troubleshooting_variable_template or "issues with {v}"
|
|
340
|
+
|
|
341
|
+
def _cycle_after_variables(per_variable_fmt: str, intents: list[str]):
|
|
342
|
+
# Per-variable angles FIRST. Used by ``faq``, whose "{v} explained" angles are
|
|
343
|
+
# the value explainers that carry each authoritative number into prose -- the
|
|
344
|
+
# backbone of the numeric-faithfulness check.
|
|
345
|
+
for v in var_names:
|
|
346
|
+
yield per_variable_fmt.format(v=v)
|
|
347
|
+
yield from intents
|
|
348
|
+
|
|
349
|
+
def _cycle_before_variables(per_variable_fmt: str, intents: list[str]):
|
|
350
|
+
# Intents FIRST. Used by ``how_to_guide`` / ``troubleshooting``, whose intents
|
|
351
|
+
# are the customer-journey documents (open, close, a failed action) that the
|
|
352
|
+
# dynamic tier asks about.
|
|
353
|
+
#
|
|
354
|
+
# Order matters far more than it looks: a genre gets only 2-3 slots at shipped
|
|
355
|
+
# depth, so whichever kind of angle comes first takes all of them. With
|
|
356
|
+
# per-variable first, a product's intents were reached only if it had few
|
|
357
|
+
# enough variables to exhaust -- in en_US exactly the 7 two-variable products
|
|
358
|
+
# got them and the other 11 did not, at any depth.
|
|
359
|
+
yield from intents
|
|
360
|
+
for v in var_names:
|
|
361
|
+
yield per_variable_fmt.format(v=v)
|
|
362
|
+
|
|
363
|
+
def _cycle(intents: list[str]):
|
|
364
|
+
yield from intents
|
|
365
|
+
|
|
366
|
+
def finite(genre: str):
|
|
367
|
+
yield from genre_angles[genre]
|
|
368
|
+
|
|
369
|
+
# Round 1 yields one of each genre (product_sheet leads). Finite genres stop
|
|
370
|
+
# once their angles are exhausted; the FOUR expandable genres (faq /
|
|
371
|
+
# how_to_guide / troubleshooting / comparison) SHARE the tail, so a large
|
|
372
|
+
# ``target_docs`` produces a balanced, realistic mix instead of all-FAQ.
|
|
373
|
+
genres: list[tuple[str, Any]] = [
|
|
374
|
+
("product_sheet", finite("product_sheet")),
|
|
375
|
+
("faq", _cycle_after_variables(faq_var_fmt, faq_intents)),
|
|
376
|
+
("how_to_guide", _cycle_before_variables(how_to_var_fmt, how_to_intents)),
|
|
377
|
+
("fee_schedule", finite("fee_schedule")),
|
|
378
|
+
("troubleshooting", _cycle_before_variables(trouble_var_fmt, troubleshooting_intents)),
|
|
379
|
+
("eligibility_matrix", finite("eligibility_matrix")),
|
|
380
|
+
("comparison", _cycle(comparison_intents)),
|
|
381
|
+
("disclosure", finite("disclosure")),
|
|
382
|
+
("rate_sheet", finite("rate_sheet")),
|
|
383
|
+
("promo_notice", finite("promo_notice")),
|
|
384
|
+
("security_advisory", finite("security_advisory")),
|
|
385
|
+
("terms_conditions", finite("terms_conditions")),
|
|
386
|
+
]
|
|
387
|
+
counts: dict[str, int] = {g: 0 for g, _ in genres}
|
|
388
|
+
slots: list[dict[str, Any]] = []
|
|
389
|
+
seen: set = set()
|
|
390
|
+
target = max(target_docs, 1)
|
|
391
|
+
while len(slots) < target:
|
|
392
|
+
progressed = False
|
|
393
|
+
for genre, gen in genres:
|
|
394
|
+
if len(slots) >= target:
|
|
395
|
+
break
|
|
396
|
+
angle = next(gen, None)
|
|
397
|
+
if angle is None or (genre, angle) in seen:
|
|
398
|
+
# Exhausted, or a repeat. Either way this genre makes no progress
|
|
399
|
+
# this round and its slot passes to one that still has a new angle.
|
|
400
|
+
continue
|
|
401
|
+
seen.add((genre, angle))
|
|
402
|
+
counts[genre] += 1
|
|
403
|
+
n = counts[genre]
|
|
404
|
+
key = f"{pid}_{genre}" if n == 1 else f"{pid}_{genre}_{n}"
|
|
405
|
+
audience = _PRODUCT_AUDIENCES[len(slots) % len(_PRODUCT_AUDIENCES)]
|
|
406
|
+
topic = f"{title}{sep}{angle}"
|
|
407
|
+
slots.append(_slot(key, genre, topic, domain, pid, angle=angle, audience=audience))
|
|
408
|
+
progressed = True
|
|
409
|
+
if not progressed:
|
|
410
|
+
# Every genre is out of NEW angles: the product's distinct-angle supply
|
|
411
|
+
# is spent. Stop rather than pad with repeats differentiated only by
|
|
412
|
+
# audience and an index suffix, which is where near-duplicate documents
|
|
413
|
+
# came from. Supply is 40 + 3 * len(variables), i.e. 46-58 for the
|
|
414
|
+
# shipped products.
|
|
415
|
+
logger.info(
|
|
416
|
+
"%s: angle supply exhausted at %d/%d requested docs "
|
|
417
|
+
"(distinct angles only; raise variables or angle lists to go higher)",
|
|
418
|
+
pid,
|
|
419
|
+
len(slots),
|
|
420
|
+
target,
|
|
421
|
+
)
|
|
422
|
+
break
|
|
423
|
+
return slots[:target]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def build_shared_doc_plan(
|
|
427
|
+
inst: Institution,
|
|
428
|
+
vocab: Any = None,
|
|
429
|
+
) -> list[dict[str, Any]]:
|
|
430
|
+
"""CROSS-CUTTING doc slots owned by the institution's shared cluster.
|
|
431
|
+
|
|
432
|
+
Generated once (never per product) so they cannot drift: institution fee
|
|
433
|
+
overview, general FAQ, a regulatory note per domain, and a policy_procedure +
|
|
434
|
+
discoverable_tool_doc for every discoverable tool.
|
|
435
|
+
|
|
436
|
+
Relationship-level servicing questions (accepted ID evidence, opening/closing,
|
|
437
|
+
beneficiaries, transfer limits and delays, escalation) are deliberately NOT here:
|
|
438
|
+
they are product-specific in this domain -- what a brokerage needs to open a
|
|
439
|
+
demat account differs from a savings account, and transfer limits are product
|
|
440
|
+
variables -- so they are answered by the product plan's `required documentation`
|
|
441
|
+
/ `set up and activate` / `cancel, close, or downgrade` / `a failed or pending
|
|
442
|
+
action` angles, which ``build_product_doc_plan`` guarantees.
|
|
443
|
+
"""
|
|
444
|
+
primary = inst.domains[0]
|
|
445
|
+
voc = _vocab(vocab)
|
|
446
|
+
fee_topic = voc.fee_overview_topic or _FEE_OVERVIEW_TOPIC
|
|
447
|
+
faq_topic = voc.general_faq_topic or _GENERAL_FAQ_TOPIC
|
|
448
|
+
reg_topic = voc.regulatory_note_topic or _REGULATORY_NOTE_TOPIC
|
|
449
|
+
policy_topic = voc.tool_policy_topic or _TOOL_POLICY_TOPIC
|
|
450
|
+
tooldoc_topic = voc.tool_reference_topic or _TOOL_REFERENCE_TOPIC
|
|
451
|
+
slots = [
|
|
452
|
+
_slot("fee_overview", "fee_schedule", fee_topic, primary, "general"),
|
|
453
|
+
_slot("faq_general", "faq", faq_topic, primary, "general"),
|
|
454
|
+
]
|
|
455
|
+
for d in inst.domains:
|
|
456
|
+
# An unmapped domain keeps the raw machine name, which is how en_IN came
|
|
457
|
+
# to ship "Regulatory notes for retail_banking".
|
|
458
|
+
label = voc.domain_labels.get(str(d)) or str(d)
|
|
459
|
+
slots.append(_slot(f"regulatory_note_{d}", "regulatory_note", reg_topic.format(domain=label), d, "compliance"))
|
|
460
|
+
for tool in inst.tool_taxonomy:
|
|
461
|
+
if not tool.discoverable:
|
|
462
|
+
continue
|
|
463
|
+
slots.append(
|
|
464
|
+
_slot(
|
|
465
|
+
f"{tool.name}_policy",
|
|
466
|
+
"policy_procedure",
|
|
467
|
+
policy_topic.format(tool=tool.name),
|
|
468
|
+
primary,
|
|
469
|
+
"procedure",
|
|
470
|
+
[tool.name],
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
slots.append(
|
|
474
|
+
_slot(
|
|
475
|
+
f"{tool.name}_tooldoc",
|
|
476
|
+
"discoverable_tool_doc",
|
|
477
|
+
tooldoc_topic.format(tool=tool.name),
|
|
478
|
+
primary,
|
|
479
|
+
"procedure",
|
|
480
|
+
[tool.name],
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
return slots
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def doc_length_hint(locale: str, language: str = "", genre: str = "") -> str:
|
|
487
|
+
"""Script- AND genre-aware per-document length instruction, sized so each body
|
|
488
|
+
lands near its tier's token budget IN THE TARGET LANGUAGE.
|
|
489
|
+
|
|
490
|
+
Token counts are not comparable across scripts (non-Latin scripts tokenize much
|
|
491
|
+
heavier), so a fixed word count would make CJK/Indic docs balloon in tokens and
|
|
492
|
+
time out. We instead back the token budget out to a script-appropriate WORD (or
|
|
493
|
+
CHARACTER, for CJK where word boundaries are ill-defined) range, keeping doc SIZE
|
|
494
|
+
-- and thus latency / cost / retrieval-chunk parity -- roughly constant.
|
|
495
|
+
|
|
496
|
+
``genre`` selects the tier: narrative genres (product_sheet, disclosure,
|
|
497
|
+
policy_procedure, regulatory_note) get the RICH budget so they read like real,
|
|
498
|
+
fleshed-out documents; everything else stays TIGHT. Detection prefers the
|
|
499
|
+
structured ``locale`` (e.g. ``ja_JP``, ``hi_Deva_IN``); ranges are ~+/-15% slack.
|
|
500
|
+
"""
|
|
501
|
+
tag = f"{locale} {language}".lower()
|
|
502
|
+
budget = TARGET_DOC_TOKENS_RICH if genre in _RICH_GENRES else TARGET_DOC_TOKENS_TIGHT
|
|
503
|
+
|
|
504
|
+
def _band(lo_ratio: float, hi_ratio: float) -> tuple:
|
|
505
|
+
r = lambda x: max(10, int(round(budget * x / 10.0)) * 10)
|
|
506
|
+
return r(lo_ratio), r(hi_ratio)
|
|
507
|
+
|
|
508
|
+
# Romanized/transliterated locales (``hi_Latn_IN``, ``ta_Latn_IN``, ...) are
|
|
509
|
+
# written in the LATIN alphabet, so they tokenize like Latin text and must be
|
|
510
|
+
# checked FIRST: their tag carries the source language too ("hi_latn_in hindi
|
|
511
|
+
# latin"), which would otherwise match the Indic branch below on "hindi" and
|
|
512
|
+
# hand a romanized doc the much tighter Indic word budget.
|
|
513
|
+
if "latn" in tag or "latin" in tag:
|
|
514
|
+
lo, hi = _band(0.6, 0.85)
|
|
515
|
+
return f"roughly {lo}-{hi} words"
|
|
516
|
+
# CJK: instruct in CHARACTERS (~1 token/char; "words" undefined).
|
|
517
|
+
if any(k in tag for k in ("ja_", "japanese", "ko_", "korean", "zh_", "chinese", "hant", "hans")):
|
|
518
|
+
lo, hi = _band(0.6, 0.9)
|
|
519
|
+
return f"roughly {lo}-{hi} characters"
|
|
520
|
+
# Indic scripts (Devanagari etc.): heavy subword tokenization -> ~0.4 words/token.
|
|
521
|
+
if any(
|
|
522
|
+
k in tag
|
|
523
|
+
for k in (
|
|
524
|
+
"deva",
|
|
525
|
+
"hindi",
|
|
526
|
+
"bengali",
|
|
527
|
+
"beng",
|
|
528
|
+
"tamil",
|
|
529
|
+
"taml",
|
|
530
|
+
"telugu",
|
|
531
|
+
"telu",
|
|
532
|
+
"marathi",
|
|
533
|
+
"gujarati",
|
|
534
|
+
"gujr",
|
|
535
|
+
"kannada",
|
|
536
|
+
)
|
|
537
|
+
):
|
|
538
|
+
lo, hi = _band(0.3, 0.45)
|
|
539
|
+
return f"roughly {lo}-{hi} words"
|
|
540
|
+
# Latin scripts (English, Portuguese, French, ...): ~0.75 words/token.
|
|
541
|
+
lo, hi = _band(0.6, 0.85)
|
|
542
|
+
return f"roughly {lo}-{hi} words"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _batches(items: list[Any], cap: int) -> Iterator[list[Any]]:
|
|
546
|
+
cap = max(cap, 1)
|
|
547
|
+
for i in range(0, len(items), cap):
|
|
548
|
+
yield items[i : i + cap]
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# ── Phase 1: institution seed + brief config ────────────────────────────
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def products_for_prompt(products: list[Product], spec: RegionSpec) -> list[dict[str, Any]]:
|
|
555
|
+
"""Each product with a ``display`` rendering of every typed value in the
|
|
556
|
+
locale's own notation (``7,90 €``, ``15,9 %``), which the prompts ask the
|
|
557
|
+
model to write instead of the raw ``value``."""
|
|
558
|
+
from usersim.engine.core.finance_bank import format_amount, format_percent
|
|
559
|
+
|
|
560
|
+
number_format = spec.number_format or spec.locale
|
|
561
|
+
out: list[dict[str, Any]] = []
|
|
562
|
+
for product in products:
|
|
563
|
+
dumped = product.model_dump()
|
|
564
|
+
for var in dumped["variables"].values():
|
|
565
|
+
if var["type"] == "money":
|
|
566
|
+
var["display"] = format_amount(
|
|
567
|
+
var["value"],
|
|
568
|
+
currency_symbol=spec.currency_symbol,
|
|
569
|
+
number_format=number_format,
|
|
570
|
+
drop_zero_cents=True,
|
|
571
|
+
)
|
|
572
|
+
elif var["type"] == "percent":
|
|
573
|
+
var["display"] = format_percent(var["value"], number_format=number_format)
|
|
574
|
+
else:
|
|
575
|
+
var["display"] = str(var["value"])
|
|
576
|
+
out.append(dumped)
|
|
577
|
+
return out
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def build_institution_seed(spec: RegionSpec):
|
|
581
|
+
"""One seed row per institution for the Phase-1 brief (pure)."""
|
|
582
|
+
import pandas as pd
|
|
583
|
+
|
|
584
|
+
rows: list[dict[str, Any]] = []
|
|
585
|
+
for inst in spec.institutions:
|
|
586
|
+
regulators = {d: spec.domain_regulators[d].model_dump() for d in inst.domains if d in spec.domain_regulators}
|
|
587
|
+
rows.append(
|
|
588
|
+
{
|
|
589
|
+
"institution_id": inst.id,
|
|
590
|
+
"display_name": inst.display_name or inst.id,
|
|
591
|
+
# Empty for locales that need no separate in-language rendering; the
|
|
592
|
+
# prompts branch on it.
|
|
593
|
+
"display_name_local": inst.display_name_local,
|
|
594
|
+
"institution_type": inst.type,
|
|
595
|
+
"brand_voice": inst.brand_voice,
|
|
596
|
+
"language": spec.language,
|
|
597
|
+
"language_guidance": language_guidance_for(
|
|
598
|
+
spec.language_conventions.guidance, spec.language_conventions.respell
|
|
599
|
+
),
|
|
600
|
+
"currency_symbol": spec.currency_symbol,
|
|
601
|
+
"products_json": json.dumps(products_for_prompt(inst.products, spec), ensure_ascii=False),
|
|
602
|
+
"regulators_json": json.dumps(regulators, ensure_ascii=False),
|
|
603
|
+
# Market-specific emphasis: region override else region-neutral
|
|
604
|
+
# default, resolved here so the prompt stays free of any one
|
|
605
|
+
# market's instruments.
|
|
606
|
+
"concept_guidance": concept_guidance_for(inst.type, spec.concept_guidance),
|
|
607
|
+
}
|
|
608
|
+
)
|
|
609
|
+
return pd.DataFrame(rows)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def build_brief_columns() -> list[Any]:
|
|
613
|
+
"""Phase-1 DD columns (introspectable): the structured InstitutionBrief."""
|
|
614
|
+
import data_designer.config as dd
|
|
615
|
+
|
|
616
|
+
return [
|
|
617
|
+
dd.LLMStructuredColumnConfig(
|
|
618
|
+
name="institution_brief",
|
|
619
|
+
model_alias=DOC_GEN_MODEL_ALIAS,
|
|
620
|
+
prompt=BRIEF_PROMPT,
|
|
621
|
+
output_format=InstitutionBrief,
|
|
622
|
+
)
|
|
623
|
+
]
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def build_brief_config(models: Any, inst_seed) -> Any:
|
|
627
|
+
"""Assemble the Phase-1 (engine, builder). One place, explicit add_column."""
|
|
628
|
+
import data_designer.config as dd
|
|
629
|
+
|
|
630
|
+
from usersim.cli._models import to_model_configs
|
|
631
|
+
|
|
632
|
+
engine = _make_engine(models)
|
|
633
|
+
builder = dd.DataDesignerConfigBuilder(model_configs=to_model_configs(models))
|
|
634
|
+
builder.with_seed_dataset(dd.DataFrameSeedSource(df=inst_seed))
|
|
635
|
+
for col in build_brief_columns():
|
|
636
|
+
builder.add_column(col)
|
|
637
|
+
return engine, builder
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# ── Bridge: explode institutions -> cluster rows carrying the brief ─────
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def explode_to_cluster_rows(
|
|
644
|
+
brief_df,
|
|
645
|
+
spec: RegionSpec,
|
|
646
|
+
*,
|
|
647
|
+
docs_per_product: int = DEFAULT_DOCS_PER_PRODUCT,
|
|
648
|
+
max_docs_per_cluster: int = DEFAULT_MAX_DOCS_PER_CLUSTER,
|
|
649
|
+
):
|
|
650
|
+
"""Pure pandas fan-out: institution rows -> per-product (batched) + shared rows.
|
|
651
|
+
|
|
652
|
+
Each emitted row carries the generated ``institution_brief`` plus its own
|
|
653
|
+
``cluster_kind`` / ``focus_json`` / ``doc_plan_json`` / ``sibling_doc_index`` /
|
|
654
|
+
``target_doc_count`` so Phase 2 can generate the cluster conditioned on the brief.
|
|
655
|
+
"""
|
|
656
|
+
import pandas as pd
|
|
657
|
+
|
|
658
|
+
spec_by_id = {i.id: i for i in spec.institutions}
|
|
659
|
+
rows: list[dict[str, Any]] = []
|
|
660
|
+
|
|
661
|
+
for _, brow in brief_df.iterrows():
|
|
662
|
+
iid = str(brow["institution_id"])
|
|
663
|
+
inst = spec_by_id[iid]
|
|
664
|
+
brief_json = _as_json(brow.get("institution_brief"))
|
|
665
|
+
|
|
666
|
+
product_plans = {p.id: build_product_doc_plan(p, docs_per_product, spec) for p in inst.products}
|
|
667
|
+
shared_plan = build_shared_doc_plan(inst, spec)
|
|
668
|
+
|
|
669
|
+
# Per-doc, genre-aware length target (script-robust): narrative genres get a
|
|
670
|
+
# longer band, structured genres stay tight. Threaded into each plan slot so
|
|
671
|
+
# the single cluster call can size each doc independently.
|
|
672
|
+
for pl in (*product_plans.values(), shared_plan):
|
|
673
|
+
for s in pl:
|
|
674
|
+
s["length"] = doc_length_hint(spec.locale, spec.language, s["document_type"])
|
|
675
|
+
# Stable, deterministic doc id minted from the PLAN key (not the
|
|
676
|
+
# model's echoed doc_key) -> survives regeneration.
|
|
677
|
+
s["uuid"] = doc_uuid(spec.locale, iid, s["doc_key"])
|
|
678
|
+
|
|
679
|
+
# Deterministic sibling index (doc_key -> intended title/topic) for by-title
|
|
680
|
+
# cross-references across the whole institution.
|
|
681
|
+
sibling_index: dict[str, str] = {}
|
|
682
|
+
for pl in product_plans.values():
|
|
683
|
+
for s in pl:
|
|
684
|
+
sibling_index[s["doc_key"]] = s["topic"]
|
|
685
|
+
for s in shared_plan:
|
|
686
|
+
sibling_index[s["doc_key"]] = s["topic"]
|
|
687
|
+
|
|
688
|
+
base = {
|
|
689
|
+
"institution_id": iid,
|
|
690
|
+
"display_name": inst.display_name or inst.id,
|
|
691
|
+
"display_name_local": inst.display_name_local,
|
|
692
|
+
"institution_type": inst.type,
|
|
693
|
+
"brand_voice": inst.brand_voice,
|
|
694
|
+
"language": spec.language,
|
|
695
|
+
"language_guidance": language_guidance_for(
|
|
696
|
+
spec.language_conventions.guidance, spec.language_conventions.respell
|
|
697
|
+
),
|
|
698
|
+
"currency_symbol": spec.currency_symbol,
|
|
699
|
+
"institution_brief": brief_json,
|
|
700
|
+
"sibling_doc_index": json.dumps(sibling_index, ensure_ascii=False),
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
for product, prompt_product in zip(inst.products, products_for_prompt(inst.products, spec)):
|
|
704
|
+
focus = json.dumps(prompt_product, ensure_ascii=False)
|
|
705
|
+
for batch in _batches(product_plans[product.id], max_docs_per_cluster):
|
|
706
|
+
rows.append(
|
|
707
|
+
{
|
|
708
|
+
**base,
|
|
709
|
+
"cluster_kind": "product",
|
|
710
|
+
"focus_json": focus,
|
|
711
|
+
"doc_plan_json": json.dumps(batch, ensure_ascii=False),
|
|
712
|
+
"target_doc_count": len(batch),
|
|
713
|
+
"cluster_id": _cluster_id(iid, "product", batch),
|
|
714
|
+
}
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
shared_focus = json.dumps(
|
|
718
|
+
{
|
|
719
|
+
"tools": [t.model_dump() for t in inst.tool_taxonomy],
|
|
720
|
+
"products": [
|
|
721
|
+
{"id": p.id, "display_name": p.display_name or p.id, "display_name_local": p.display_name_local}
|
|
722
|
+
for p in inst.products
|
|
723
|
+
],
|
|
724
|
+
},
|
|
725
|
+
ensure_ascii=False,
|
|
726
|
+
)
|
|
727
|
+
for batch in _batches(shared_plan, max_docs_per_cluster):
|
|
728
|
+
rows.append(
|
|
729
|
+
{
|
|
730
|
+
**base,
|
|
731
|
+
"cluster_kind": "shared",
|
|
732
|
+
"focus_json": shared_focus,
|
|
733
|
+
"doc_plan_json": json.dumps(batch, ensure_ascii=False),
|
|
734
|
+
"target_doc_count": len(batch),
|
|
735
|
+
"cluster_id": _cluster_id(iid, "shared", batch),
|
|
736
|
+
}
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
return pd.DataFrame(rows)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# ── Phase 2: cluster generation config ──────────────────────────────────
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def docset_qc_scores() -> list[Any]:
|
|
746
|
+
"""Generation-time LLM-judge rubrics for a cluster (``dd.Score``)."""
|
|
747
|
+
import data_designer.config as dd
|
|
748
|
+
|
|
749
|
+
return [
|
|
750
|
+
dd.Score(
|
|
751
|
+
name="NumericFaithfulness",
|
|
752
|
+
description=(
|
|
753
|
+
"Among the PROVIDED authoritative values, are the ones a document "
|
|
754
|
+
"states reproduced exactly? Judge ONLY the provided values; other "
|
|
755
|
+
"numbers (dates, illustrative examples, regulatory citations) are "
|
|
756
|
+
"expected and must NOT lower the score. If there are no provided "
|
|
757
|
+
"values to check, score 4."
|
|
758
|
+
),
|
|
759
|
+
options={
|
|
760
|
+
4: "Provided values that appear are stated exactly; none contradicted.",
|
|
761
|
+
3: "A single minor rounding/format slip on a provided value.",
|
|
762
|
+
2: "A provided value is rounded, paraphrased, or vague.",
|
|
763
|
+
1: "A provided value is stated with the wrong digits.",
|
|
764
|
+
0: "Multiple provided values are wrong or contradicted.",
|
|
765
|
+
},
|
|
766
|
+
),
|
|
767
|
+
dd.Score(
|
|
768
|
+
name="Cohesion",
|
|
769
|
+
description="Are names, numbers, and facts consistent across the set + brief?",
|
|
770
|
+
options={
|
|
771
|
+
4: "Fully consistent; no contradictions.",
|
|
772
|
+
3: "Minor inconsistency that does not mislead.",
|
|
773
|
+
2: "A noticeable contradiction between two documents.",
|
|
774
|
+
1: "Several contradictions.",
|
|
775
|
+
0: "Documents read as unrelated / mutually inconsistent.",
|
|
776
|
+
},
|
|
777
|
+
),
|
|
778
|
+
dd.Score(
|
|
779
|
+
name="Distinctness",
|
|
780
|
+
description="Is every document materially different (no near-duplicates)?",
|
|
781
|
+
options={
|
|
782
|
+
4: "All documents are distinct in content and purpose.",
|
|
783
|
+
3: "Largely distinct; slight overlap between two.",
|
|
784
|
+
2: "A pair is near-duplicate.",
|
|
785
|
+
1: "Several near-duplicates.",
|
|
786
|
+
0: "Many documents restate the same content.",
|
|
787
|
+
},
|
|
788
|
+
),
|
|
789
|
+
]
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def build_docset_columns() -> list[Any]:
|
|
793
|
+
"""Phase-2 DD columns (introspectable): DocSet -> judge QC -> score flatteners."""
|
|
794
|
+
import data_designer.config as dd
|
|
795
|
+
|
|
796
|
+
cols: list[Any] = [
|
|
797
|
+
dd.LLMStructuredColumnConfig(
|
|
798
|
+
name="doc_set",
|
|
799
|
+
model_alias=DOC_GEN_MODEL_ALIAS,
|
|
800
|
+
prompt=DOCSET_PROMPT,
|
|
801
|
+
output_format=DocSet,
|
|
802
|
+
),
|
|
803
|
+
dd.LLMJudgeColumnConfig(
|
|
804
|
+
name="doc_set_qc",
|
|
805
|
+
model_alias=DOCSET_JUDGE_MODEL_ALIAS,
|
|
806
|
+
prompt=DOCSET_QC_PROMPT,
|
|
807
|
+
scores=docset_qc_scores(),
|
|
808
|
+
),
|
|
809
|
+
]
|
|
810
|
+
# Flatten each rubric score into its own column (DD-idiomatic; cf. product_info_qa).
|
|
811
|
+
for name, rubric in (
|
|
812
|
+
("numeric_faithfulness_score", "NumericFaithfulness"),
|
|
813
|
+
("cohesion_score", "Cohesion"),
|
|
814
|
+
("distinctness_score", "Distinctness"),
|
|
815
|
+
):
|
|
816
|
+
cols.append(dd.ExpressionColumnConfig(name=name, expr="{{ doc_set_qc.%s.score }}" % rubric))
|
|
817
|
+
return cols
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def build_docset_config(models: Any, cluster_seed) -> Any:
|
|
821
|
+
"""Assemble the Phase-2 (engine, builder). One place, explicit add_column."""
|
|
822
|
+
import data_designer.config as dd
|
|
823
|
+
|
|
824
|
+
from usersim.cli._models import to_model_configs
|
|
825
|
+
|
|
826
|
+
engine = _make_engine(models)
|
|
827
|
+
builder = dd.DataDesignerConfigBuilder(model_configs=to_model_configs(models))
|
|
828
|
+
builder.with_seed_dataset(dd.DataFrameSeedSource(df=cluster_seed))
|
|
829
|
+
for col in build_docset_columns():
|
|
830
|
+
builder.add_column(col)
|
|
831
|
+
return engine, builder
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def build_embedding_columns() -> list[Any]:
|
|
835
|
+
"""Embedding pass columns (run on the exploded per-doc frame): one vector/body."""
|
|
836
|
+
import data_designer.config as dd
|
|
837
|
+
|
|
838
|
+
return [
|
|
839
|
+
dd.EmbeddingColumnConfig(
|
|
840
|
+
name="body_embedding",
|
|
841
|
+
target_column="body",
|
|
842
|
+
model_alias=EMBEDDING_MODEL_ALIAS,
|
|
843
|
+
)
|
|
844
|
+
]
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
# ── Explode a generated DocSet into loader-compatible document dicts ────
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def explode_docset(
|
|
851
|
+
docset: "DocSet | dict[str, Any]",
|
|
852
|
+
*,
|
|
853
|
+
id_prefix: str,
|
|
854
|
+
institution_id: str,
|
|
855
|
+
spec_by_key: dict[str, dict[str, Any]],
|
|
856
|
+
default_domain: str,
|
|
857
|
+
placeholder: bool = True,
|
|
858
|
+
) -> list[dict[str, Any]]:
|
|
859
|
+
"""Explode a ``DocSet`` into loader-compatible document dicts.
|
|
860
|
+
|
|
861
|
+
Each generated doc is joined (by ``doc_key``) with the requested plan slot
|
|
862
|
+
(``spec_by_key`` -> domain / product_category) so the emitted docs carry the
|
|
863
|
+
right scope. Docs beyond the plan fall back to ``default_domain``.
|
|
864
|
+
"""
|
|
865
|
+
if isinstance(docset, DocSet):
|
|
866
|
+
docs = docset.documents
|
|
867
|
+
else:
|
|
868
|
+
docs = [GeneratedDoc(**d) for d in (docset.get("documents") or [])]
|
|
869
|
+
out: list[dict[str, Any]] = []
|
|
870
|
+
seen: set = set()
|
|
871
|
+
off_plan: list[str] = []
|
|
872
|
+
for gd in docs:
|
|
873
|
+
slot = spec_by_key.get(gd.doc_key, {})
|
|
874
|
+
if not slot and spec_by_key:
|
|
875
|
+
# The model was told to echo doc_key VERBATIM. When it doesn't (a
|
|
876
|
+
# non-English run may try to localize the slug), the doc loses its
|
|
877
|
+
# product_category and lands in "general" — which then reads as a
|
|
878
|
+
# numeric-faithfulness failure ("typed values absent from prose") far
|
|
879
|
+
# from the real cause. Surface it here instead.
|
|
880
|
+
off_plan.append(gd.doc_key)
|
|
881
|
+
# Prefer the PLAN's deterministic uuid (stable across regens); fall back to a
|
|
882
|
+
# readable derived id for any off-plan doc the model invented.
|
|
883
|
+
doc_id = slot.get("uuid") or f"{id_prefix}-{gd.doc_key.upper().replace(' ', '-')}"
|
|
884
|
+
if doc_id in seen:
|
|
885
|
+
continue
|
|
886
|
+
seen.add(doc_id)
|
|
887
|
+
out.append(
|
|
888
|
+
{
|
|
889
|
+
"id": doc_id,
|
|
890
|
+
# The PLAN owns the title, NOT the model. The plan's topic is already
|
|
891
|
+
# the institution-wide cross-reference handle every sibling is told to
|
|
892
|
+
# cite verbatim, so letting the model re-author it makes that contract
|
|
893
|
+
# a promise the corpus cannot keep. In English the model echoed the
|
|
894
|
+
# topic exactly (1066/1066 en_IN docs), so this is a no-op there; in
|
|
895
|
+
# Hindi it echoed only 40% and translated the rest, which is how a
|
|
896
|
+
# 93%-Devanagari corpus ended up with 44% English customer-facing
|
|
897
|
+
# titles. An off-plan doc_key has no topic, so it keeps the model's.
|
|
898
|
+
"title": slot.get("topic") or gd.title,
|
|
899
|
+
"body": gd.body,
|
|
900
|
+
"domain": slot.get("domain", default_domain),
|
|
901
|
+
"product_category": slot.get("product_category", "general"),
|
|
902
|
+
"document_type": gd.document_type,
|
|
903
|
+
"source_authority": slot.get("source_authority", "official"),
|
|
904
|
+
"placeholder": placeholder,
|
|
905
|
+
# The PLAN decides which tools a document documents, NOT the model.
|
|
906
|
+
# ``mentions_tools`` drives the sim-time discoverable-tool gate (a tool is
|
|
907
|
+
# callable only once a doc naming it has been retrieved), and it is a field
|
|
908
|
+
# on ``GeneratedDoc``, which the model could otherwise set on any
|
|
909
|
+
# document. Left to the model it does: 7.8% of en_IN customer-facing docs
|
|
910
|
+
# claimed tools they merely mentioned in passing, so retrieving a generic
|
|
911
|
+
# how-to unlocked tools whose documentation was never read -- silently
|
|
912
|
+
# weakening the one mechanic the gate exists to enforce. The plan knows
|
|
913
|
+
# (``build_shared_doc_plan`` sets it on each tool's policy/tool doc, and
|
|
914
|
+
# product slots carry none), so read it from there and let the model's
|
|
915
|
+
# value be advisory. An off-plan doc_key therefore unlocks nothing, which
|
|
916
|
+
# is the safe direction and is already reported above.
|
|
917
|
+
"mentions_tools": list(slot.get("mentions_tools") or []),
|
|
918
|
+
"institution_id": institution_id,
|
|
919
|
+
# ``gd.references`` is intentionally absent: it is a prompt-side sink that
|
|
920
|
+
# keeps sibling slugs out of the prose, not corpus data. See GeneratedDoc.
|
|
921
|
+
}
|
|
922
|
+
)
|
|
923
|
+
if off_plan:
|
|
924
|
+
logger.warning(
|
|
925
|
+
"%s: %d/%d generated doc(s) echoed a doc_key that is not in the plan, "
|
|
926
|
+
"so they carry no product scope (product_category='general') and will "
|
|
927
|
+
"not satisfy that product's numeric-faithfulness check. Expected keys "
|
|
928
|
+
"e.g. %s; got e.g. %s",
|
|
929
|
+
institution_id,
|
|
930
|
+
len(off_plan),
|
|
931
|
+
len(docs),
|
|
932
|
+
sorted(spec_by_key)[:3],
|
|
933
|
+
off_plan[:3],
|
|
934
|
+
)
|
|
935
|
+
return out
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
# ── Serialize a generated bank into the committed YAML layout ──────────
|
|
939
|
+
#
|
|
940
|
+
# assets/financial_services/<locale>/
|
|
941
|
+
# region_meta.yaml
|
|
942
|
+
# <institution_id>/{institution_meta,corpus,tools,tasks}.yaml (+ embeddings.parquet)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
@dataclass
|
|
946
|
+
class InstitutionBank:
|
|
947
|
+
"""One institution's serializable content."""
|
|
948
|
+
|
|
949
|
+
institution_meta: dict[str, Any]
|
|
950
|
+
documents: list[dict[str, Any]] = field(default_factory=list)
|
|
951
|
+
tools: list[dict[str, Any]] = field(default_factory=list)
|
|
952
|
+
templates: list[dict[str, Any]] = field(default_factory=list)
|
|
953
|
+
#: doc_id -> dense embedding vector; written as an ``embeddings.parquet`` sidecar
|
|
954
|
+
#: (not into corpus.yaml -- vectors don't belong in human-readable YAML).
|
|
955
|
+
embeddings: dict[str, list[float]] = field(default_factory=dict)
|
|
956
|
+
|
|
957
|
+
@property
|
|
958
|
+
def institution_id(self) -> str:
|
|
959
|
+
return str(self.institution_meta["institution_id"])
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
#: Prepended to every generated tools.yaml so a reader isn't misled by the empty
|
|
963
|
+
#: description/parameters on discoverable tools (they LOOK like placeholders).
|
|
964
|
+
_TOOLS_YAML_HEADER = (
|
|
965
|
+
"# Discoverable domain tools intentionally carry an EMPTY description /\n"
|
|
966
|
+
"# parameters here. Their real schema (signature + args) lives in each tool's\n"
|
|
967
|
+
"# discoverable_tool_doc in corpus.yaml -- the assistant learns a tool + its\n"
|
|
968
|
+
"# arguments by RETRIEVING that doc via kb_search, then calls it through the\n"
|
|
969
|
+
"# call_tool gate. Putting schemas here would defeat that discovery challenge.\n"
|
|
970
|
+
"# Only the always-offered primitives (kb_search / verify_identity) carry a\n"
|
|
971
|
+
"# schema; side_effect_class is always set (the scorer uses it).\n"
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _dump(path: Path, doc: dict[str, Any], *, header: str = "") -> None:
|
|
976
|
+
import yaml
|
|
977
|
+
|
|
978
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
979
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
980
|
+
if header:
|
|
981
|
+
fh.write(header)
|
|
982
|
+
yaml.safe_dump(doc, fh, sort_keys=False, allow_unicode=True)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
#: Fixed namespace for DETERMINISTIC document UUIDs. uuid5 over a stable logical key
|
|
986
|
+
#: (locale + institution + doc_key) -> globally unique, brand-independent, and IDENTICAL
|
|
987
|
+
#: across regenerations (unlike a random uuid4, which would re-dangle every reference).
|
|
988
|
+
_DOC_UUID_NAMESPACE = uuid.UUID("6f9d3c2e-1a4b-5c6d-8e7f-0a1b2c3d4e5f")
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def doc_uuid(locale: str, institution_id: str, doc_key: str) -> str:
|
|
992
|
+
"""Stable document id: ``uuid5(namespace, "<locale>:<institution>:<doc_key>")``.
|
|
993
|
+
|
|
994
|
+
Deterministic, so regenerating the same logical doc yields the same id -- the
|
|
995
|
+
property that keeps cross-references (tasks, sibling links, embeddings) from
|
|
996
|
+
dangling. Assigned from the PLAN's doc_key, not the model's echoed key.
|
|
997
|
+
"""
|
|
998
|
+
key = f"{locale}:{institution_id}:{doc_key}"
|
|
999
|
+
return str(uuid.uuid5(_DOC_UUID_NAMESPACE, key))
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _cluster_id(institution_id: str, cluster_kind: str, batch: list[dict[str, Any]]) -> str:
|
|
1003
|
+
"""Stable id for one generation cluster, minted from its batch's deterministic
|
|
1004
|
+
doc uuids. Used to match a seed row to its generated output so dropped clusters
|
|
1005
|
+
(transient endpoint failures) can be detected and regenerated on their own -- and, being
|
|
1006
|
+
deterministic, it is identical across regenerations."""
|
|
1007
|
+
keys = "|".join(sorted(str(s.get("uuid") or s.get("doc_key")) for s in batch))
|
|
1008
|
+
return str(uuid.uuid5(_DOC_UUID_NAMESPACE, f"cluster:{institution_id}:{cluster_kind}:{keys}"))
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def institution_rel_dir(institution_type: Any, institution_id: str) -> Path:
|
|
1012
|
+
"""Bank-relative dir for an institution: ``<institution_type>/<institution_id>``.
|
|
1013
|
+
|
|
1014
|
+
Grouping by TYPE (a stable taxonomy) rather than brand keeps the repo layout
|
|
1015
|
+
stable across brand renames and lets multiple brands share a vertical. Falls
|
|
1016
|
+
back to a flat ``<institution_id>`` if the type is missing.
|
|
1017
|
+
"""
|
|
1018
|
+
itype = str(institution_type).strip() if institution_type else ""
|
|
1019
|
+
return Path(itype) / institution_id if itype else Path(institution_id)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _dump_embeddings(path: Path, embeddings: dict[str, list[float]]) -> None:
|
|
1023
|
+
"""Write a doc_id -> vector sidecar the dense retriever loads (not in YAML)."""
|
|
1024
|
+
import pandas as pd
|
|
1025
|
+
|
|
1026
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1027
|
+
rows = [{"id": doc_id, "embedding": list(vec)} for doc_id, vec in embeddings.items()]
|
|
1028
|
+
pd.DataFrame(rows).to_parquet(path)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def serialize_bank(
|
|
1032
|
+
out_dir: str | Path,
|
|
1033
|
+
region_meta: dict[str, Any],
|
|
1034
|
+
institutions: list[InstitutionBank],
|
|
1035
|
+
*,
|
|
1036
|
+
write_tasks: bool = True,
|
|
1037
|
+
) -> Path:
|
|
1038
|
+
"""Write a full locale bank; returns the locale directory.
|
|
1039
|
+
|
|
1040
|
+
``write_tasks=False`` preserves any existing ``tasks.yaml`` (corpus
|
|
1041
|
+
regeneration should not clobber curated task templates).
|
|
1042
|
+
"""
|
|
1043
|
+
root = Path(out_dir)
|
|
1044
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
1045
|
+
|
|
1046
|
+
region = dict(region_meta)
|
|
1047
|
+
region.setdefault("schema_version", _SCHEMA_VERSION)
|
|
1048
|
+
_dump(root / "region_meta.yaml", region)
|
|
1049
|
+
|
|
1050
|
+
for inst in institutions:
|
|
1051
|
+
meta = dict(inst.institution_meta)
|
|
1052
|
+
meta.setdefault("schema_version", _SCHEMA_VERSION)
|
|
1053
|
+
idir = root / institution_rel_dir(meta.get("type"), inst.institution_id)
|
|
1054
|
+
_dump(idir / "institution_meta.yaml", meta)
|
|
1055
|
+
_dump(idir / "corpus.yaml", {"schema_version": _SCHEMA_VERSION, "documents": inst.documents})
|
|
1056
|
+
_dump(
|
|
1057
|
+
idir / "tools.yaml",
|
|
1058
|
+
{"schema_version": _SCHEMA_VERSION, "tools": _backfill_primitive_tools(inst.tools)},
|
|
1059
|
+
header=_TOOLS_YAML_HEADER,
|
|
1060
|
+
)
|
|
1061
|
+
if write_tasks:
|
|
1062
|
+
_dump(idir / "tasks.yaml", {"schema_version": _SCHEMA_VERSION, "templates": inst.templates})
|
|
1063
|
+
if inst.embeddings:
|
|
1064
|
+
_dump_embeddings(idir / "embeddings.parquet", inst.embeddings)
|
|
1065
|
+
return root
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
# ── Metadata + validation helpers ───────────────────────────────────────
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def validate_generation_models(models: Any) -> None:
|
|
1072
|
+
"""Fail fast if the doc-gen / judge / embedding aliases aren't configured."""
|
|
1073
|
+
from usersim.cli._models import require_aliases
|
|
1074
|
+
|
|
1075
|
+
require_aliases(
|
|
1076
|
+
models,
|
|
1077
|
+
required=(DOC_GEN_MODEL_ALIAS, DOCSET_JUDGE_MODEL_ALIAS, EMBEDDING_MODEL_ALIAS),
|
|
1078
|
+
context="gen-assets --domain financial_services",
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _make_engine(models: Any) -> Any:
|
|
1083
|
+
"""A DataDesigner engine with the NATIVE Jinja engine (matches cli/_pipeline.py)."""
|
|
1084
|
+
import data_designer.config as dd
|
|
1085
|
+
from data_designer.interface import DataDesigner
|
|
1086
|
+
|
|
1087
|
+
from usersim.cli._models import to_data_designer_kwargs
|
|
1088
|
+
|
|
1089
|
+
engine = DataDesigner(**to_data_designer_kwargs(models))
|
|
1090
|
+
engine.set_run_config(dd.RunConfig(jinja_rendering_engine=dd.JinjaRenderingEngine.NATIVE))
|
|
1091
|
+
return engine
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _institution_meta(inst: Institution) -> dict[str, Any]:
|
|
1095
|
+
meta: dict[str, Any] = {
|
|
1096
|
+
"institution_id": inst.id,
|
|
1097
|
+
"display_name": inst.display_name or inst.id,
|
|
1098
|
+
"type": inst.type,
|
|
1099
|
+
"brand_voice": inst.brand_voice,
|
|
1100
|
+
"domains": list(inst.domains),
|
|
1101
|
+
"placeholder": True,
|
|
1102
|
+
}
|
|
1103
|
+
# Carried into the asset so the RUNTIME can refer to the brand the way this
|
|
1104
|
+
# locale's own documents do. Omitted when unset, so a Latin-only locale's
|
|
1105
|
+
# committed bank is unchanged by this field existing.
|
|
1106
|
+
if inst.display_name_local:
|
|
1107
|
+
meta["display_name_local"] = inst.display_name_local
|
|
1108
|
+
return meta
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _tool_dict(tool: Tool) -> dict[str, Any]:
|
|
1112
|
+
# A discoverable tool is learned from its retrieved doc, so tools.yaml keeps
|
|
1113
|
+
# its description empty even when the spec supplies one to steer that doc.
|
|
1114
|
+
return {
|
|
1115
|
+
"name": tool.name,
|
|
1116
|
+
"description": "" if tool.discoverable else tool.description,
|
|
1117
|
+
"discoverable": tool.discoverable,
|
|
1118
|
+
"side_effect_class": tool.side_effect_class,
|
|
1119
|
+
"parameters": tool.parameters,
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _backfill_primitive_tools(
|
|
1124
|
+
tools: list[dict[str, Any]],
|
|
1125
|
+
) -> list[dict[str, Any]]:
|
|
1126
|
+
"""Fill framework-primitive (kb_search / verify_identity) description +
|
|
1127
|
+
parameters from the runtime bank contract when the source spec left them
|
|
1128
|
+
empty. Region specs list these by name only, so without this the generated
|
|
1129
|
+
``tools.yaml`` would ship a primitive with an empty schema — leaving the
|
|
1130
|
+
assistant to call ``kb_search({})`` (no query), which retrieves nothing and
|
|
1131
|
+
never unlocks the discoverable gold tools. Single source of truth:
|
|
1132
|
+
``core.finance_bank.FRAMEWORK_PRIMITIVE_TOOLS``.
|
|
1133
|
+
"""
|
|
1134
|
+
from usersim.engine.core.finance_bank import framework_primitive_tool
|
|
1135
|
+
|
|
1136
|
+
out: list[dict[str, Any]] = []
|
|
1137
|
+
for t in tools:
|
|
1138
|
+
t = dict(t)
|
|
1139
|
+
canonical = framework_primitive_tool(t.get("name"))
|
|
1140
|
+
if canonical is not None:
|
|
1141
|
+
if not t.get("description"):
|
|
1142
|
+
t["description"] = canonical["description"]
|
|
1143
|
+
if not t.get("parameters"):
|
|
1144
|
+
t["parameters"] = canonical["parameters"]
|
|
1145
|
+
out.append(t)
|
|
1146
|
+
return out
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _region_meta_from_spec(spec: RegionSpec) -> dict[str, Any]:
|
|
1150
|
+
meta: dict[str, Any] = {
|
|
1151
|
+
"locale": spec.locale,
|
|
1152
|
+
"region": spec.region,
|
|
1153
|
+
"currency": spec.currency,
|
|
1154
|
+
"currency_symbol": spec.currency_symbol,
|
|
1155
|
+
"bank_id": spec.bank_id or f"{spec.locale}_financial_services",
|
|
1156
|
+
"bank_version": spec.bank_version or "v0.1.0",
|
|
1157
|
+
"task_contract_version": spec.task_contract_version or "v0.1.0",
|
|
1158
|
+
"domain_regulators": {d: rc.model_dump() for d, rc in spec.domain_regulators.items()},
|
|
1159
|
+
# Always emitted (even at the default) so the sim-side gate reads the
|
|
1160
|
+
# region's KYC contract from the bank rather than hardcoding one.
|
|
1161
|
+
"identity_verification": spec.identity_verification.model_dump(),
|
|
1162
|
+
"placeholder": True,
|
|
1163
|
+
}
|
|
1164
|
+
if spec.account_labels:
|
|
1165
|
+
meta["account_labels"] = dict(spec.account_labels)
|
|
1166
|
+
if spec.number_format is not None:
|
|
1167
|
+
meta["number_format"] = spec.number_format
|
|
1168
|
+
if spec.privacy_regime is not None:
|
|
1169
|
+
meta["privacy_regime"] = spec.privacy_regime
|
|
1170
|
+
return meta
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _as_json(val: Any) -> str:
|
|
1174
|
+
"""Coerce a (possibly structured) DD column value to a JSON string for seeding."""
|
|
1175
|
+
if val is None:
|
|
1176
|
+
return "{}"
|
|
1177
|
+
if isinstance(val, str):
|
|
1178
|
+
return val
|
|
1179
|
+
if hasattr(val, "model_dump"):
|
|
1180
|
+
return json.dumps(val.model_dump(), ensure_ascii=False)
|
|
1181
|
+
return json.dumps(val, ensure_ascii=False, default=str)
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _to_py(obj: Any) -> Any:
|
|
1185
|
+
"""Recursively coerce numpy containers/scalars to plain Python.
|
|
1186
|
+
|
|
1187
|
+
DD hands structured columns back through pandas, so a nested ``List[...]`` can
|
|
1188
|
+
surface as a ``numpy.ndarray`` -- truth-testing one (``not docs`` / ``docs or []``)
|
|
1189
|
+
raises "ambiguous truth value". Normalize to lists/dicts/scalars once, up front.
|
|
1190
|
+
"""
|
|
1191
|
+
if isinstance(obj, dict):
|
|
1192
|
+
return {k: _to_py(v) for k, v in obj.items()}
|
|
1193
|
+
if isinstance(obj, (list, tuple)):
|
|
1194
|
+
return [_to_py(x) for x in obj]
|
|
1195
|
+
tolist = getattr(obj, "tolist", None) # numpy ndarray / scalar
|
|
1196
|
+
if callable(tolist) and not isinstance(obj, (str, bytes)):
|
|
1197
|
+
return _to_py(tolist())
|
|
1198
|
+
return obj
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _as_docset(val: Any) -> dict[str, Any]:
|
|
1202
|
+
"""Coerce a DD structured column value into a ``{"documents": [...]}`` dict,
|
|
1203
|
+
with ``documents`` guaranteed to be a plain Python list."""
|
|
1204
|
+
if val is None:
|
|
1205
|
+
return {"documents": []}
|
|
1206
|
+
if isinstance(val, str):
|
|
1207
|
+
try:
|
|
1208
|
+
val = json.loads(val)
|
|
1209
|
+
except json.JSONDecodeError:
|
|
1210
|
+
return {"documents": []}
|
|
1211
|
+
elif hasattr(val, "model_dump"):
|
|
1212
|
+
val = val.model_dump()
|
|
1213
|
+
val = _to_py(val)
|
|
1214
|
+
if not isinstance(val, dict):
|
|
1215
|
+
return {"documents": []}
|
|
1216
|
+
docs = val.get("documents")
|
|
1217
|
+
val["documents"] = list(docs) if isinstance(docs, (list, tuple)) else []
|
|
1218
|
+
return val
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
# ── Collect Phase-2 results into per-institution banks ──────────────────
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def collect_institutions(result_df, spec: RegionSpec) -> list[InstitutionBank]:
|
|
1225
|
+
"""Group cluster rows by institution, QC-gate, explode, respell, and de-dupe doc ids.
|
|
1226
|
+
|
|
1227
|
+
Respelling applies the region spec's ``language_conventions`` to each body.
|
|
1228
|
+
|
|
1229
|
+
QC-drop policy: an unparseable/empty cluster is dropped (with a warning);
|
|
1230
|
+
a low-faithfulness cluster is KEPT (already ``placeholder: true``) and logged,
|
|
1231
|
+
so we never leave silent gaps that would break future task gold links.
|
|
1232
|
+
"""
|
|
1233
|
+
spec_by_id = {i.id: i for i in spec.institutions}
|
|
1234
|
+
docs_by_inst: dict[str, dict[str, dict[str, Any]]] = {}
|
|
1235
|
+
conventions = spec.language_conventions
|
|
1236
|
+
respelled = 0
|
|
1237
|
+
|
|
1238
|
+
for _, row in result_df.iterrows():
|
|
1239
|
+
iid = str(row["institution_id"])
|
|
1240
|
+
inst = spec_by_id.get(iid)
|
|
1241
|
+
if inst is None:
|
|
1242
|
+
continue
|
|
1243
|
+
docset = _as_docset(row.get("doc_set"))
|
|
1244
|
+
if not docset.get("documents"):
|
|
1245
|
+
logger.warning("empty/unparseable doc_set for %s (%s) — dropping cluster", iid, row.get("cluster_kind"))
|
|
1246
|
+
continue
|
|
1247
|
+
faith = float(row.get("numeric_faithfulness_score") or 0)
|
|
1248
|
+
if faith < MIN_FAITHFULNESS_SCORE:
|
|
1249
|
+
logger.warning(
|
|
1250
|
+
"low faithfulness (%.1f) for %s %s cluster; keeping flagged", faith, iid, row.get("cluster_kind")
|
|
1251
|
+
)
|
|
1252
|
+
plan_raw = row.get("doc_plan_json")
|
|
1253
|
+
plan = json.loads(plan_raw) if isinstance(plan_raw, str) else (plan_raw or [])
|
|
1254
|
+
spec_by_key = {s["doc_key"]: s for s in plan}
|
|
1255
|
+
docs = explode_docset(
|
|
1256
|
+
docset,
|
|
1257
|
+
id_prefix=iid.upper().replace("_", "-"),
|
|
1258
|
+
institution_id=iid,
|
|
1259
|
+
spec_by_key=spec_by_key,
|
|
1260
|
+
default_domain=inst.domains[0],
|
|
1261
|
+
)
|
|
1262
|
+
bucket = docs_by_inst.setdefault(iid, {})
|
|
1263
|
+
for d in docs:
|
|
1264
|
+
# The body only: the PLAN owns the title, so a title using an avoided
|
|
1265
|
+
# form is a doc_titles gap, which validate-assets reports.
|
|
1266
|
+
respelled += len(conventions.find(d["body"]))
|
|
1267
|
+
d["body"] = conventions.apply(d["body"])
|
|
1268
|
+
bucket.setdefault(d["id"], d) # de-dupe ids across the institution's rows
|
|
1269
|
+
|
|
1270
|
+
if respelled:
|
|
1271
|
+
logger.info("respelled %d form(s) the region spec's language conventions avoid", respelled)
|
|
1272
|
+
institutions: list[InstitutionBank] = []
|
|
1273
|
+
for inst in spec.institutions:
|
|
1274
|
+
institutions.append(
|
|
1275
|
+
InstitutionBank(
|
|
1276
|
+
institution_meta=_institution_meta(inst),
|
|
1277
|
+
documents=list(docs_by_inst.get(inst.id, {}).values()),
|
|
1278
|
+
tools=[_tool_dict(t) for t in inst.tool_taxonomy],
|
|
1279
|
+
templates=[], # tasks authored separately
|
|
1280
|
+
)
|
|
1281
|
+
)
|
|
1282
|
+
return institutions
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
#: Doc-set (Phase 2) backfill policy. The doc-gen endpoint can transiently 5xx an
|
|
1286
|
+
#: individual cluster; DD drops that record instead of retrying, so we re-run
|
|
1287
|
+
#: generation for ONLY the dropped clusters (matched by ``cluster_id``) up to a few
|
|
1288
|
+
#: attempts. This makes an unattended, many-region run robust to flaky per-record
|
|
1289
|
+
#: failures without regenerating the whole (expensive) corpus.
|
|
1290
|
+
DOCSET_MAX_ATTEMPTS = 3
|
|
1291
|
+
DOCSET_RETRY_BASE_DELAY_S = 10.0
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _usable_docset_rows(df):
|
|
1295
|
+
"""The subset of ``df`` whose ``doc_set`` parsed into at least one document.
|
|
1296
|
+
|
|
1297
|
+
This is the success predicate for the backfill loop, and it has to be about
|
|
1298
|
+
CONTENT rather than about a row coming back. A truncated, empty or refused
|
|
1299
|
+
response still carries its ``cluster_id``, so a presence test counts it as a
|
|
1300
|
+
success and retries nothing.
|
|
1301
|
+
|
|
1302
|
+
Deliberately does NOT filter on faithfulness. A low-faithfulness cluster is
|
|
1303
|
+
kept and flagged by ``collect_institutions``; that is a judgement about
|
|
1304
|
+
quality, not about whether the call produced anything to judge.
|
|
1305
|
+
"""
|
|
1306
|
+
if "doc_set" not in df.columns:
|
|
1307
|
+
# Nothing to inspect (a shape we do not produce, but the loop must not
|
|
1308
|
+
# discard every row if it ever happens) -- fall back to presence.
|
|
1309
|
+
return df
|
|
1310
|
+
return df[df["doc_set"].map(lambda v: bool(_as_docset(v)["documents"]))]
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _generate_docsets_with_backfill(
|
|
1314
|
+
models: Any,
|
|
1315
|
+
cluster_seed,
|
|
1316
|
+
*,
|
|
1317
|
+
max_attempts: int = DOCSET_MAX_ATTEMPTS,
|
|
1318
|
+
base_delay: float = DOCSET_RETRY_BASE_DELAY_S,
|
|
1319
|
+
):
|
|
1320
|
+
"""Run Phase-2 doc_set generation, auto-regenerating any cluster that did not
|
|
1321
|
+
come back usable.
|
|
1322
|
+
|
|
1323
|
+
Two things can go wrong, and both are equally recoverable:
|
|
1324
|
+
|
|
1325
|
+
- **The record is dropped.** DD omits a record on a generation error (a
|
|
1326
|
+
transient endpoint 5xx), so the result frame is short of the seed.
|
|
1327
|
+
- **The record arrives unusable.** A response can be truncated mid-body,
|
|
1328
|
+
schema-valid but empty, or refused, and still carry its ``cluster_id``.
|
|
1329
|
+
|
|
1330
|
+
We diff by ``cluster_id`` against the rows that produced at least one
|
|
1331
|
+
document (see :func:`_usable_docset_rows`) and re-run generation for the
|
|
1332
|
+
rest, up to ``max_attempts`` with linear backoff. Judging on content rather
|
|
1333
|
+
than row presence matters: on a presence test the second failure marked the
|
|
1334
|
+
cluster done, and the only thing that noticed was ``collect_institutions``
|
|
1335
|
+
dropping it with a warning and no path back to regeneration.
|
|
1336
|
+
|
|
1337
|
+
Returns the combined result frame (usable clusters only); logs a clear
|
|
1338
|
+
warning if any clusters remain missing after the final attempt (the corpus
|
|
1339
|
+
is still written without them, and a later run can fill the gap since ids
|
|
1340
|
+
are deterministic).
|
|
1341
|
+
"""
|
|
1342
|
+
import pandas as pd
|
|
1343
|
+
|
|
1344
|
+
if "cluster_id" not in cluster_seed.columns: # defensive: no keys -> single pass
|
|
1345
|
+
engine, builder = build_docset_config(models, cluster_seed)
|
|
1346
|
+
return engine.create(builder, num_records=len(cluster_seed)).load_dataset().reset_index(drop=True)
|
|
1347
|
+
|
|
1348
|
+
total = len(cluster_seed)
|
|
1349
|
+
frames: list[Any] = []
|
|
1350
|
+
done: set = set()
|
|
1351
|
+
remaining = cluster_seed
|
|
1352
|
+
for attempt in range(1, max_attempts + 1):
|
|
1353
|
+
batch_seed = remaining.reset_index(drop=True)
|
|
1354
|
+
engine, builder = build_docset_config(models, batch_seed)
|
|
1355
|
+
df = engine.create(builder, num_records=len(batch_seed)).load_dataset().reset_index(drop=True)
|
|
1356
|
+
usable = _usable_docset_rows(df)
|
|
1357
|
+
frames.append(usable)
|
|
1358
|
+
if "cluster_id" in usable.columns:
|
|
1359
|
+
done |= set(usable["cluster_id"].dropna())
|
|
1360
|
+
remaining = cluster_seed[~cluster_seed["cluster_id"].isin(done)]
|
|
1361
|
+
n_missing = len(remaining)
|
|
1362
|
+
if n_missing == 0:
|
|
1363
|
+
if attempt > 1:
|
|
1364
|
+
logger.info("doc_set backfill: all %d clusters generated after %d attempt(s)", total, attempt)
|
|
1365
|
+
break
|
|
1366
|
+
if attempt < max_attempts:
|
|
1367
|
+
delay = base_delay * attempt
|
|
1368
|
+
logger.warning(
|
|
1369
|
+
"doc_set backfill: %d/%d clusters dropped or came back unusable "
|
|
1370
|
+
"(a transient endpoint error, or a truncated or empty response); "
|
|
1371
|
+
"regenerating just those in %.0fs (attempt %d/%d)",
|
|
1372
|
+
n_missing,
|
|
1373
|
+
total,
|
|
1374
|
+
delay,
|
|
1375
|
+
attempt + 1,
|
|
1376
|
+
max_attempts,
|
|
1377
|
+
)
|
|
1378
|
+
time.sleep(delay)
|
|
1379
|
+
else:
|
|
1380
|
+
logger.warning(
|
|
1381
|
+
"doc_set backfill exhausted: %d/%d clusters still missing after %d "
|
|
1382
|
+
"attempts; writing the corpus WITHOUT them -- re-run gen-assets to "
|
|
1383
|
+
"fill the gap (doc ids are deterministic)",
|
|
1384
|
+
n_missing,
|
|
1385
|
+
total,
|
|
1386
|
+
max_attempts,
|
|
1387
|
+
)
|
|
1388
|
+
result_df = pd.concat(frames, ignore_index=True)
|
|
1389
|
+
# We only retry missing clusters, so duplicates shouldn't occur -- guard anyway.
|
|
1390
|
+
if "cluster_id" in result_df.columns:
|
|
1391
|
+
result_df = result_df.drop_duplicates(subset="cluster_id", keep="first").reset_index(drop=True)
|
|
1392
|
+
return result_df
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
#: Embedding-pass retry policy. The endpoint's /embeddings route can throw transient
|
|
1396
|
+
#: 5xx / timeouts on the health-check probe; embeddings are idempotent + cheap, so
|
|
1397
|
+
#: retry with linear backoff before giving up (the caller keeps the corpus either way).
|
|
1398
|
+
EMBED_MAX_ATTEMPTS = 4
|
|
1399
|
+
EMBED_RETRY_BASE_DELAY_S = 6.0
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def _embed_documents(
|
|
1403
|
+
institutions: list[InstitutionBank],
|
|
1404
|
+
models: Any,
|
|
1405
|
+
engine: Any,
|
|
1406
|
+
*,
|
|
1407
|
+
max_attempts: int = EMBED_MAX_ATTEMPTS,
|
|
1408
|
+
base_delay: float = EMBED_RETRY_BASE_DELAY_S,
|
|
1409
|
+
) -> None:
|
|
1410
|
+
"""Embedding pass: dense-embed each document body, attach per-institution.
|
|
1411
|
+
|
|
1412
|
+
Retries transient DD generation errors (the embeddings endpoint is flaky under
|
|
1413
|
+
load); on final failure it re-raises so the caller can persist the corpus without
|
|
1414
|
+
embeddings rather than discarding it.
|
|
1415
|
+
"""
|
|
1416
|
+
import data_designer.config as dd
|
|
1417
|
+
import pandas as pd
|
|
1418
|
+
from data_designer.interface.errors import DataDesignerGenerationError
|
|
1419
|
+
|
|
1420
|
+
rows = [{"id": d["id"], "body": d["body"]} for ib in institutions for d in ib.documents]
|
|
1421
|
+
if not rows:
|
|
1422
|
+
return
|
|
1423
|
+
edf = pd.DataFrame(rows)
|
|
1424
|
+
|
|
1425
|
+
from usersim.cli._models import to_model_configs
|
|
1426
|
+
|
|
1427
|
+
result = None
|
|
1428
|
+
for attempt in range(1, max_attempts + 1):
|
|
1429
|
+
builder = dd.DataDesignerConfigBuilder(model_configs=to_model_configs(models))
|
|
1430
|
+
builder.with_seed_dataset(dd.DataFrameSeedSource(df=edf[["body"]]))
|
|
1431
|
+
for col in build_embedding_columns():
|
|
1432
|
+
builder.add_column(col)
|
|
1433
|
+
try:
|
|
1434
|
+
result = engine.create(builder, num_records=len(edf)).load_dataset().reset_index(drop=True)
|
|
1435
|
+
break
|
|
1436
|
+
except DataDesignerGenerationError as e:
|
|
1437
|
+
if attempt == max_attempts:
|
|
1438
|
+
raise
|
|
1439
|
+
delay = base_delay * attempt
|
|
1440
|
+
logger.warning(
|
|
1441
|
+
"embedding pass attempt %d/%d failed (%s); retrying in %.0fs",
|
|
1442
|
+
attempt,
|
|
1443
|
+
max_attempts,
|
|
1444
|
+
str(e).splitlines()[0][:160],
|
|
1445
|
+
delay,
|
|
1446
|
+
)
|
|
1447
|
+
time.sleep(delay)
|
|
1448
|
+
|
|
1449
|
+
# ORDERED seed + reset_index preserves row order, so ids align by position.
|
|
1450
|
+
id_to_vec = {doc_id: r["body_embedding"]["embeddings"][0] for doc_id, (_, r) in zip(edf["id"], result.iterrows())}
|
|
1451
|
+
for ib in institutions:
|
|
1452
|
+
ib.embeddings = {d["id"]: id_to_vec[d["id"]] for d in ib.documents if d["id"] in id_to_vec}
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
# ── Orchestrator ─────────────────────────────────────────────────────────
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def generate_corpus(
|
|
1459
|
+
spec: RegionSpec,
|
|
1460
|
+
models: Any,
|
|
1461
|
+
out_dir: Any,
|
|
1462
|
+
*,
|
|
1463
|
+
docs_per_product: int = DEFAULT_DOCS_PER_PRODUCT,
|
|
1464
|
+
max_docs_per_cluster: int = DEFAULT_MAX_DOCS_PER_CLUSTER,
|
|
1465
|
+
) -> Any:
|
|
1466
|
+
"""Two-phase generation: brief -> explode -> clusters -> collect -> embed -> serialize.
|
|
1467
|
+
|
|
1468
|
+
Needs the gen model aliases + network (validated up front). Generates the CORPUS
|
|
1469
|
+
+ embeddings; task templates are authored separately, so existing ``tasks.yaml``
|
|
1470
|
+
is preserved (``write_tasks=False``).
|
|
1471
|
+
"""
|
|
1472
|
+
validate_generation_models(models) # validate endpoints/aliases first
|
|
1473
|
+
|
|
1474
|
+
# Phase 1: one InstitutionBrief per institution.
|
|
1475
|
+
inst_seed = build_institution_seed(spec)
|
|
1476
|
+
brief_engine, brief_builder = build_brief_config(models, inst_seed)
|
|
1477
|
+
brief_df = brief_engine.create(brief_builder, num_records=len(inst_seed)).load_dataset().reset_index(drop=True)
|
|
1478
|
+
|
|
1479
|
+
# Bridge: fan out to bounded per-product + shared cluster rows carrying the brief.
|
|
1480
|
+
cluster_seed = explode_to_cluster_rows(
|
|
1481
|
+
brief_df,
|
|
1482
|
+
spec,
|
|
1483
|
+
docs_per_product=docs_per_product,
|
|
1484
|
+
max_docs_per_cluster=max_docs_per_cluster,
|
|
1485
|
+
)
|
|
1486
|
+
|
|
1487
|
+
# Phase 2: generate each cluster conditioned on the brief. Auto-backfills any
|
|
1488
|
+
# clusters the endpoint transiently drops so a full corpus doesn't need a manual re-run.
|
|
1489
|
+
result_df = _generate_docsets_with_backfill(models, cluster_seed)
|
|
1490
|
+
|
|
1491
|
+
institutions = collect_institutions(result_df, spec)
|
|
1492
|
+
|
|
1493
|
+
# Persist the (expensive) corpus BEFORE embedding, so a flaky /embeddings
|
|
1494
|
+
# endpoint can't discard a full generation run. Embeddings are a cheap,
|
|
1495
|
+
# resumable sidecar written afterward.
|
|
1496
|
+
root = serialize_bank(
|
|
1497
|
+
out_dir,
|
|
1498
|
+
_region_meta_from_spec(spec),
|
|
1499
|
+
institutions,
|
|
1500
|
+
write_tasks=False,
|
|
1501
|
+
)
|
|
1502
|
+
briefs = {str(r["institution_id"]): _as_json(r.get("institution_brief")) for _, r in brief_df.iterrows()}
|
|
1503
|
+
_write_generation_report(root, result_df, institutions, models, docs_per_product, max_docs_per_cluster, briefs)
|
|
1504
|
+
|
|
1505
|
+
# Embedding pass (retried; non-fatal). On success, write per-institution
|
|
1506
|
+
# sidecars; on failure the corpus is already on disk -- re-run to add them.
|
|
1507
|
+
try:
|
|
1508
|
+
_embed_documents(institutions, models, _make_engine(models))
|
|
1509
|
+
except Exception as e: # noqa: BLE001 -- never lose the corpus over embeddings
|
|
1510
|
+
logger.warning(
|
|
1511
|
+
"embedding pass failed after retries (%s); corpus written to %s WITHOUT "
|
|
1512
|
+
"embeddings.parquet -- re-run gen-assets (or an embed-only step) to add them.",
|
|
1513
|
+
str(e).splitlines()[0][:160],
|
|
1514
|
+
root,
|
|
1515
|
+
)
|
|
1516
|
+
else:
|
|
1517
|
+
for inst in institutions:
|
|
1518
|
+
if inst.embeddings:
|
|
1519
|
+
idir = root / institution_rel_dir(inst.institution_meta.get("type"), inst.institution_id)
|
|
1520
|
+
_dump_embeddings(idir / "embeddings.parquet", inst.embeddings)
|
|
1521
|
+
return root
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _write_generation_report(
|
|
1525
|
+
root: Path,
|
|
1526
|
+
result_df,
|
|
1527
|
+
institutions: list[InstitutionBank],
|
|
1528
|
+
models: Any,
|
|
1529
|
+
docs_per_product: int,
|
|
1530
|
+
max_docs_per_cluster: int,
|
|
1531
|
+
briefs: dict[str, str] | None = None,
|
|
1532
|
+
) -> None:
|
|
1533
|
+
"""Write ``_generation_report.json`` (provenance + per-cluster QC) for the vet gate."""
|
|
1534
|
+
from datetime import datetime, timezone
|
|
1535
|
+
|
|
1536
|
+
def _model(alias: str) -> str:
|
|
1537
|
+
spec = getattr(models, "get", lambda _a: None)(alias)
|
|
1538
|
+
return getattr(spec, "model", alias) if spec is not None else alias
|
|
1539
|
+
|
|
1540
|
+
def _num(v: Any) -> Any:
|
|
1541
|
+
try:
|
|
1542
|
+
return float(v)
|
|
1543
|
+
except (TypeError, ValueError):
|
|
1544
|
+
return None
|
|
1545
|
+
|
|
1546
|
+
def _brief(iid: str) -> Any:
|
|
1547
|
+
raw = (briefs or {}).get(iid)
|
|
1548
|
+
if not raw:
|
|
1549
|
+
return None
|
|
1550
|
+
if not isinstance(raw, str):
|
|
1551
|
+
return raw
|
|
1552
|
+
try:
|
|
1553
|
+
return json.loads(raw)
|
|
1554
|
+
except json.JSONDecodeError:
|
|
1555
|
+
return None
|
|
1556
|
+
|
|
1557
|
+
by_inst: dict[str, list[dict[str, Any]]] = {}
|
|
1558
|
+
for _, row in result_df.iterrows():
|
|
1559
|
+
iid = str(row["institution_id"])
|
|
1560
|
+
docset = _as_docset(row.get("doc_set"))
|
|
1561
|
+
by_inst.setdefault(iid, []).append(
|
|
1562
|
+
{
|
|
1563
|
+
"cluster_kind": row.get("cluster_kind"),
|
|
1564
|
+
"target": int(row["target_doc_count"]) if "target_doc_count" in row else None,
|
|
1565
|
+
"produced": len(docset.get("documents", []) or []),
|
|
1566
|
+
"numeric_faithfulness": _num(row.get("numeric_faithfulness_score")),
|
|
1567
|
+
"cohesion": _num(row.get("cohesion_score")),
|
|
1568
|
+
"distinctness": _num(row.get("distinctness_score")),
|
|
1569
|
+
}
|
|
1570
|
+
)
|
|
1571
|
+
|
|
1572
|
+
report = {
|
|
1573
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
1574
|
+
"models": {
|
|
1575
|
+
"doc_gen": _model(DOC_GEN_MODEL_ALIAS),
|
|
1576
|
+
"judge": _model(DOCSET_JUDGE_MODEL_ALIAS),
|
|
1577
|
+
"embedding": _model(EMBEDDING_MODEL_ALIAS),
|
|
1578
|
+
},
|
|
1579
|
+
"docs_per_product": docs_per_product,
|
|
1580
|
+
"max_docs_per_cluster": max_docs_per_cluster,
|
|
1581
|
+
"institutions": {
|
|
1582
|
+
ib.institution_id: {
|
|
1583
|
+
"n_documents": len(ib.documents),
|
|
1584
|
+
"clusters": by_inst.get(ib.institution_id, []),
|
|
1585
|
+
"brief": _brief(ib.institution_id),
|
|
1586
|
+
}
|
|
1587
|
+
for ib in institutions
|
|
1588
|
+
},
|
|
1589
|
+
}
|
|
1590
|
+
(root / "_generation_report.json").write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|