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.
Files changed (379) hide show
  1. usersim/__init__.py +23 -0
  2. usersim/asset_gen/__init__.py +22 -0
  3. usersim/asset_gen/financial_services/__init__.py +22 -0
  4. usersim/asset_gen/financial_services/evaluate.py +330 -0
  5. usersim/asset_gen/financial_services/pipeline.py +1590 -0
  6. usersim/asset_gen/financial_services/prompts.py +318 -0
  7. usersim/asset_gen/financial_services/spec.py +794 -0
  8. usersim/asset_gen/financial_services/validate.py +750 -0
  9. usersim/asset_gen/registry.py +191 -0
  10. usersim/cli/__init__.py +185 -0
  11. usersim/cli/__main__.py +13 -0
  12. usersim/cli/_async.py +35 -0
  13. usersim/cli/_env.py +74 -0
  14. usersim/cli/_errors.py +44 -0
  15. usersim/cli/_inputs.py +691 -0
  16. usersim/cli/_models.py +1222 -0
  17. usersim/cli/_ngc.py +441 -0
  18. usersim/cli/_persona_language.py +316 -0
  19. usersim/cli/_pipeline.py +1007 -0
  20. usersim/cli/evaluate.py +415 -0
  21. usersim/cli/evaluate_assets.py +115 -0
  22. usersim/cli/gen_assets.py +120 -0
  23. usersim/cli/model_catalog.py +451 -0
  24. usersim/cli/models_default.toml +46 -0
  25. usersim/cli/models_openai.toml +26 -0
  26. usersim/cli/models_openrouter.toml +33 -0
  27. usersim/cli/panel.py +193 -0
  28. usersim/cli/report.py +247 -0
  29. usersim/cli/rescore.py +223 -0
  30. usersim/cli/select.py +304 -0
  31. usersim/cli/setup_ngc.py +131 -0
  32. usersim/cli/simulate.py +866 -0
  33. usersim/cli/smoke.py +1019 -0
  34. usersim/cli/validate_assets.py +64 -0
  35. usersim/engine/__init__.py +4 -0
  36. usersim/engine/assets/financial_services/SCHEMA.md +320 -0
  37. usersim/engine/assets/financial_services/de_DE/_generation_report.json +945 -0
  38. usersim/engine/assets/financial_services/de_DE/bank/bausparkasse_heimstatt/corpus.yaml +8006 -0
  39. usersim/engine/assets/financial_services/de_DE/bank/bausparkasse_heimstatt/institution_meta.yaml +9 -0
  40. usersim/engine/assets/financial_services/de_DE/bank/bausparkasse_heimstatt/tasks.yaml +366 -0
  41. usersim/engine/assets/financial_services/de_DE/bank/bausparkasse_heimstatt/tools.yaml +114 -0
  42. usersim/engine/assets/financial_services/de_DE/bank/sparkasse_lindenhain/corpus.yaml +10053 -0
  43. usersim/engine/assets/financial_services/de_DE/bank/sparkasse_lindenhain/institution_meta.yaml +10 -0
  44. usersim/engine/assets/financial_services/de_DE/bank/sparkasse_lindenhain/tasks.yaml +578 -0
  45. usersim/engine/assets/financial_services/de_DE/bank/sparkasse_lindenhain/tools.yaml +154 -0
  46. usersim/engine/assets/financial_services/de_DE/brokerage/kranich_invest/corpus.yaml +6784 -0
  47. usersim/engine/assets/financial_services/de_DE/brokerage/kranich_invest/institution_meta.yaml +8 -0
  48. usersim/engine/assets/financial_services/de_DE/brokerage/kranich_invest/tasks.yaml +394 -0
  49. usersim/engine/assets/financial_services/de_DE/brokerage/kranich_invest/tools.yaml +134 -0
  50. usersim/engine/assets/financial_services/de_DE/dynamic.yaml +252 -0
  51. usersim/engine/assets/financial_services/de_DE/insurer/hanseatica_versicherung/corpus.yaml +9404 -0
  52. usersim/engine/assets/financial_services/de_DE/insurer/hanseatica_versicherung/institution_meta.yaml +8 -0
  53. usersim/engine/assets/financial_services/de_DE/insurer/hanseatica_versicherung/tasks.yaml +394 -0
  54. usersim/engine/assets/financial_services/de_DE/insurer/hanseatica_versicherung/tools.yaml +114 -0
  55. usersim/engine/assets/financial_services/de_DE/neobank/kompass_direktbank/corpus.yaml +8181 -0
  56. usersim/engine/assets/financial_services/de_DE/neobank/kompass_direktbank/institution_meta.yaml +10 -0
  57. usersim/engine/assets/financial_services/de_DE/neobank/kompass_direktbank/tasks.yaml +440 -0
  58. usersim/engine/assets/financial_services/de_DE/neobank/kompass_direktbank/tools.yaml +144 -0
  59. usersim/engine/assets/financial_services/de_DE/region_meta.yaml +108 -0
  60. usersim/engine/assets/financial_services/en_GB/_generation_report.json +1330 -0
  61. usersim/engine/assets/financial_services/en_GB/bank/fernbridge_building_society/corpus.yaml +11053 -0
  62. usersim/engine/assets/financial_services/en_GB/bank/fernbridge_building_society/institution_meta.yaml +9 -0
  63. usersim/engine/assets/financial_services/en_GB/bank/fernbridge_building_society/tasks.yaml +401 -0
  64. usersim/engine/assets/financial_services/en_GB/bank/fernbridge_building_society/tools.yaml +114 -0
  65. usersim/engine/assets/financial_services/en_GB/bank/wrenfield_bank/corpus.yaml +13830 -0
  66. usersim/engine/assets/financial_services/en_GB/bank/wrenfield_bank/institution_meta.yaml +11 -0
  67. usersim/engine/assets/financial_services/en_GB/bank/wrenfield_bank/tasks.yaml +597 -0
  68. usersim/engine/assets/financial_services/en_GB/bank/wrenfield_bank/tools.yaml +159 -0
  69. usersim/engine/assets/financial_services/en_GB/brokerage/copperleaf_invest/corpus.yaml +9717 -0
  70. usersim/engine/assets/financial_services/en_GB/brokerage/copperleaf_invest/institution_meta.yaml +8 -0
  71. usersim/engine/assets/financial_services/en_GB/brokerage/copperleaf_invest/tasks.yaml +418 -0
  72. usersim/engine/assets/financial_services/en_GB/brokerage/copperleaf_invest/tools.yaml +139 -0
  73. usersim/engine/assets/financial_services/en_GB/dynamic.yaml +309 -0
  74. usersim/engine/assets/financial_services/en_GB/insurer/kingsmere_insurance/corpus.yaml +7891 -0
  75. usersim/engine/assets/financial_services/en_GB/insurer/kingsmere_insurance/institution_meta.yaml +8 -0
  76. usersim/engine/assets/financial_services/en_GB/insurer/kingsmere_insurance/tasks.yaml +345 -0
  77. usersim/engine/assets/financial_services/en_GB/insurer/kingsmere_insurance/tools.yaml +114 -0
  78. usersim/engine/assets/financial_services/en_GB/neobank/quill/corpus.yaml +9579 -0
  79. usersim/engine/assets/financial_services/en_GB/neobank/quill/institution_meta.yaml +10 -0
  80. usersim/engine/assets/financial_services/en_GB/neobank/quill/tasks.yaml +453 -0
  81. usersim/engine/assets/financial_services/en_GB/neobank/quill/tools.yaml +154 -0
  82. usersim/engine/assets/financial_services/en_GB/region_meta.yaml +128 -0
  83. usersim/engine/assets/financial_services/en_GB/retirement_provider/oakhaven_pensions/corpus.yaml +8028 -0
  84. usersim/engine/assets/financial_services/en_GB/retirement_provider/oakhaven_pensions/institution_meta.yaml +8 -0
  85. usersim/engine/assets/financial_services/en_GB/retirement_provider/oakhaven_pensions/tasks.yaml +387 -0
  86. usersim/engine/assets/financial_services/en_GB/retirement_provider/oakhaven_pensions/tools.yaml +124 -0
  87. usersim/engine/assets/financial_services/en_IN/_generation_report.json +977 -0
  88. usersim/engine/assets/financial_services/en_IN/bank/chandrika_bank/corpus.yaml +9170 -0
  89. usersim/engine/assets/financial_services/en_IN/bank/chandrika_bank/institution_meta.yaml +10 -0
  90. usersim/engine/assets/financial_services/en_IN/bank/chandrika_bank/tasks.yaml +330 -0
  91. usersim/engine/assets/financial_services/en_IN/bank/chandrika_bank/tools.yaml +139 -0
  92. usersim/engine/assets/financial_services/en_IN/brokerage/tarang_invest/corpus.yaml +7436 -0
  93. usersim/engine/assets/financial_services/en_IN/brokerage/tarang_invest/institution_meta.yaml +8 -0
  94. usersim/engine/assets/financial_services/en_IN/brokerage/tarang_invest/tasks.yaml +323 -0
  95. usersim/engine/assets/financial_services/en_IN/brokerage/tarang_invest/tools.yaml +129 -0
  96. usersim/engine/assets/financial_services/en_IN/dynamic.yaml +256 -0
  97. usersim/engine/assets/financial_services/en_IN/insurer/abhaya_insurance/corpus.yaml +7641 -0
  98. usersim/engine/assets/financial_services/en_IN/insurer/abhaya_insurance/institution_meta.yaml +8 -0
  99. usersim/engine/assets/financial_services/en_IN/insurer/abhaya_insurance/tasks.yaml +350 -0
  100. usersim/engine/assets/financial_services/en_IN/insurer/abhaya_insurance/tools.yaml +114 -0
  101. usersim/engine/assets/financial_services/en_IN/nbfc/kalash_finserv/corpus.yaml +9453 -0
  102. usersim/engine/assets/financial_services/en_IN/nbfc/kalash_finserv/institution_meta.yaml +9 -0
  103. usersim/engine/assets/financial_services/en_IN/nbfc/kalash_finserv/tasks.yaml +346 -0
  104. usersim/engine/assets/financial_services/en_IN/nbfc/kalash_finserv/tools.yaml +114 -0
  105. usersim/engine/assets/financial_services/en_IN/neobank/paysetu/corpus.yaml +8237 -0
  106. usersim/engine/assets/financial_services/en_IN/neobank/paysetu/institution_meta.yaml +10 -0
  107. usersim/engine/assets/financial_services/en_IN/neobank/paysetu/tasks.yaml +313 -0
  108. usersim/engine/assets/financial_services/en_IN/neobank/paysetu/tools.yaml +139 -0
  109. usersim/engine/assets/financial_services/en_IN/region_meta.yaml +81 -0
  110. usersim/engine/assets/financial_services/en_US/_generation_report.json +768 -0
  111. usersim/engine/assets/financial_services/en_US/bank/northwind_bank/corpus.yaml +9131 -0
  112. usersim/engine/assets/financial_services/en_US/bank/northwind_bank/institution_meta.yaml +9 -0
  113. usersim/engine/assets/financial_services/en_US/bank/northwind_bank/tasks.yaml +473 -0
  114. usersim/engine/assets/financial_services/en_US/bank/northwind_bank/tools.yaml +129 -0
  115. usersim/engine/assets/financial_services/en_US/brokerage/solstice_invest/corpus.yaml +7868 -0
  116. usersim/engine/assets/financial_services/en_US/brokerage/solstice_invest/institution_meta.yaml +8 -0
  117. usersim/engine/assets/financial_services/en_US/brokerage/solstice_invest/tasks.yaml +388 -0
  118. usersim/engine/assets/financial_services/en_US/brokerage/solstice_invest/tools.yaml +124 -0
  119. usersim/engine/assets/financial_services/en_US/dynamic.yaml +198 -0
  120. usersim/engine/assets/financial_services/en_US/region_meta.yaml +58 -0
  121. usersim/engine/assets/financial_services/en_US/retirement_provider/evergreen_retirement/corpus.yaml +8117 -0
  122. usersim/engine/assets/financial_services/en_US/retirement_provider/evergreen_retirement/institution_meta.yaml +8 -0
  123. usersim/engine/assets/financial_services/en_US/retirement_provider/evergreen_retirement/tasks.yaml +369 -0
  124. usersim/engine/assets/financial_services/en_US/retirement_provider/evergreen_retirement/tools.yaml +109 -0
  125. usersim/engine/assets/financial_services/en_US/wealth_manager/meridian_wealth/corpus.yaml +6897 -0
  126. usersim/engine/assets/financial_services/en_US/wealth_manager/meridian_wealth/institution_meta.yaml +8 -0
  127. usersim/engine/assets/financial_services/en_US/wealth_manager/meridian_wealth/tasks.yaml +319 -0
  128. usersim/engine/assets/financial_services/en_US/wealth_manager/meridian_wealth/tools.yaml +109 -0
  129. usersim/engine/assets/financial_services/fr_FR/_generation_report.json +1041 -0
  130. usersim/engine/assets/financial_services/fr_FR/bank/credit_mirabelle/corpus.yaml +10842 -0
  131. usersim/engine/assets/financial_services/fr_FR/bank/credit_mirabelle/institution_meta.yaml +10 -0
  132. usersim/engine/assets/financial_services/fr_FR/bank/credit_mirabelle/tasks.yaml +529 -0
  133. usersim/engine/assets/financial_services/fr_FR/bank/credit_mirabelle/tools.yaml +144 -0
  134. usersim/engine/assets/financial_services/fr_FR/brokerage/valbreuil_bourse/corpus.yaml +9091 -0
  135. usersim/engine/assets/financial_services/fr_FR/brokerage/valbreuil_bourse/institution_meta.yaml +8 -0
  136. usersim/engine/assets/financial_services/fr_FR/brokerage/valbreuil_bourse/tasks.yaml +511 -0
  137. usersim/engine/assets/financial_services/fr_FR/brokerage/valbreuil_bourse/tools.yaml +129 -0
  138. usersim/engine/assets/financial_services/fr_FR/dynamic.yaml +264 -0
  139. usersim/engine/assets/financial_services/fr_FR/insurer/egide_assurances/corpus.yaml +9033 -0
  140. usersim/engine/assets/financial_services/fr_FR/insurer/egide_assurances/institution_meta.yaml +8 -0
  141. usersim/engine/assets/financial_services/fr_FR/insurer/egide_assurances/tasks.yaml +505 -0
  142. usersim/engine/assets/financial_services/fr_FR/insurer/egide_assurances/tools.yaml +134 -0
  143. usersim/engine/assets/financial_services/fr_FR/nbfc/ondine_credit/corpus.yaml +7494 -0
  144. usersim/engine/assets/financial_services/fr_FR/nbfc/ondine_credit/institution_meta.yaml +9 -0
  145. usersim/engine/assets/financial_services/fr_FR/nbfc/ondine_credit/tasks.yaml +383 -0
  146. usersim/engine/assets/financial_services/fr_FR/nbfc/ondine_credit/tools.yaml +114 -0
  147. usersim/engine/assets/financial_services/fr_FR/neobank/kiosque/corpus.yaml +7883 -0
  148. usersim/engine/assets/financial_services/fr_FR/neobank/kiosque/institution_meta.yaml +10 -0
  149. usersim/engine/assets/financial_services/fr_FR/neobank/kiosque/tasks.yaml +430 -0
  150. usersim/engine/assets/financial_services/fr_FR/neobank/kiosque/tools.yaml +139 -0
  151. usersim/engine/assets/financial_services/fr_FR/region_meta.yaml +114 -0
  152. usersim/engine/assets/financial_services/hi_Deva_IN/_generation_report.json +977 -0
  153. usersim/engine/assets/financial_services/hi_Deva_IN/bank/chandrika_bank/corpus.yaml +6265 -0
  154. usersim/engine/assets/financial_services/hi_Deva_IN/bank/chandrika_bank/institution_meta.yaml +11 -0
  155. usersim/engine/assets/financial_services/hi_Deva_IN/bank/chandrika_bank/tools.yaml +139 -0
  156. usersim/engine/assets/financial_services/hi_Deva_IN/brokerage/tarang_invest/corpus.yaml +5188 -0
  157. usersim/engine/assets/financial_services/hi_Deva_IN/brokerage/tarang_invest/institution_meta.yaml +9 -0
  158. usersim/engine/assets/financial_services/hi_Deva_IN/brokerage/tarang_invest/tools.yaml +129 -0
  159. usersim/engine/assets/financial_services/hi_Deva_IN/insurer/abhaya_insurance/corpus.yaml +5115 -0
  160. usersim/engine/assets/financial_services/hi_Deva_IN/insurer/abhaya_insurance/institution_meta.yaml +9 -0
  161. usersim/engine/assets/financial_services/hi_Deva_IN/insurer/abhaya_insurance/tools.yaml +114 -0
  162. usersim/engine/assets/financial_services/hi_Deva_IN/nbfc/kalash_finserv/corpus.yaml +6539 -0
  163. usersim/engine/assets/financial_services/hi_Deva_IN/nbfc/kalash_finserv/institution_meta.yaml +10 -0
  164. usersim/engine/assets/financial_services/hi_Deva_IN/nbfc/kalash_finserv/tools.yaml +114 -0
  165. usersim/engine/assets/financial_services/hi_Deva_IN/neobank/paysetu/corpus.yaml +5370 -0
  166. usersim/engine/assets/financial_services/hi_Deva_IN/neobank/paysetu/institution_meta.yaml +11 -0
  167. usersim/engine/assets/financial_services/hi_Deva_IN/neobank/paysetu/tools.yaml +139 -0
  168. usersim/engine/assets/financial_services/hi_Deva_IN/region_meta.yaml +81 -0
  169. usersim/engine/assets/general_educational/prompts.yaml +115 -0
  170. usersim/engine/assets/general_educational/subjects/base/subjects.json +162 -0
  171. usersim/engine/assets/general_educational/subjects/de_DE/subjects.json +162 -0
  172. usersim/engine/assets/general_educational/subjects/en_GB/subjects.json +162 -0
  173. usersim/engine/assets/general_educational/subjects/fr_FR/subjects.json +16 -0
  174. usersim/engine/assets/general_educational/subjects/hi_Deva_IN/subjects.json +14 -0
  175. usersim/engine/assets/general_educational/subjects/hi_Latn_IN/subjects.json +14 -0
  176. usersim/engine/assets/general_educational/subjects/ja_JP/subjects.json +16 -0
  177. usersim/engine/assets/general_educational/subjects/ko_KR/subjects.json +16 -0
  178. usersim/engine/assets/general_educational/subjects/pt_BR/subjects.json +12 -0
  179. usersim/engine/assets/general_open_ended/prompts.yaml +117 -0
  180. usersim/engine/assets/general_open_ended/topics/base/topics.json +194 -0
  181. usersim/engine/assets/general_open_ended/topics/de_DE/topics.json +194 -0
  182. usersim/engine/assets/general_open_ended/topics/en_GB/topics.json +194 -0
  183. usersim/engine/assets/general_open_ended/topics/fr_FR/topics.json +22 -0
  184. usersim/engine/assets/general_open_ended/topics/hi_Deva_IN/topics.json +22 -0
  185. usersim/engine/assets/general_open_ended/topics/hi_Latn_IN/topics.json +22 -0
  186. usersim/engine/assets/general_open_ended/topics/ja_JP/topics.json +17 -0
  187. usersim/engine/assets/general_open_ended/topics/ko_KR/topics.json +17 -0
  188. usersim/engine/assets/general_open_ended/topics/pt_BR/topics.json +22 -0
  189. usersim/engine/assets/health_decision_support_disclosure/sample.yaml +21 -0
  190. usersim/engine/assets/health_general_disclosure/sample.yaml +21 -0
  191. usersim/engine/assets/health_therapy_disclosure/sample.yaml +23 -0
  192. usersim/engine/assets/health_triage_disclosure/sample.yaml +21 -0
  193. usersim/engine/assets/identity_disclosure/SCHEMA.md +303 -0
  194. usersim/engine/assets/identity_disclosure/spec.yaml +1007 -0
  195. usersim/engine/assets/safety_agentic/SCHEMA.md +252 -0
  196. usersim/engine/assets/safety_agentic/action_taxonomy.yaml +682 -0
  197. usersim/engine/assets/safety_agentic/sample.yaml +3993 -0
  198. usersim/engine/assets/safety_chat_pressure/SCHEMA.md +181 -0
  199. usersim/engine/assets/safety_chat_pressure/sample.yaml +3646 -0
  200. usersim/engine/assets/sov_ai_dynamic/SCHEMA.md +98 -0
  201. usersim/engine/assets/sov_ai_dynamic/de_DE/categories.yaml +116 -0
  202. usersim/engine/assets/sov_ai_dynamic/en_GB/categories.yaml +116 -0
  203. usersim/engine/assets/sov_ai_dynamic/en_IN/categories.yaml +116 -0
  204. usersim/engine/assets/sov_ai_dynamic/en_SG/categories.yaml +116 -0
  205. usersim/engine/assets/sov_ai_dynamic/en_US/categories.yaml +116 -0
  206. usersim/engine/assets/sov_ai_dynamic/fr_FR/categories.yaml +116 -0
  207. usersim/engine/assets/sov_ai_dynamic/hi_Deva_IN/categories.yaml +116 -0
  208. usersim/engine/assets/sov_ai_dynamic/hi_Latn_IN/categories.yaml +116 -0
  209. usersim/engine/assets/sov_ai_dynamic/ja_JP/categories.yaml +116 -0
  210. usersim/engine/assets/sov_ai_dynamic/ko_KR/categories.yaml +116 -0
  211. usersim/engine/assets/sov_ai_dynamic/pt_BR/categories.yaml +116 -0
  212. usersim/engine/assets/sov_ai_facts/SCHEMA.md +196 -0
  213. usersim/engine/assets/sov_ai_facts/de_DE/sample.yaml +1421 -0
  214. usersim/engine/assets/sov_ai_facts/en_GB/sample.yaml +1389 -0
  215. usersim/engine/assets/sov_ai_facts/en_IN/sample.yaml +1275 -0
  216. usersim/engine/assets/sov_ai_facts/en_SG/sample.yaml +1358 -0
  217. usersim/engine/assets/sov_ai_facts/en_US/sample.yaml +1270 -0
  218. usersim/engine/assets/sov_ai_facts/fr_FR/sample.yaml +1287 -0
  219. usersim/engine/assets/sov_ai_facts/hi_Deva_IN/sample.yaml +1272 -0
  220. usersim/engine/assets/sov_ai_facts/hi_Latn_IN/sample.yaml +1272 -0
  221. usersim/engine/assets/sov_ai_facts/ja_JP/sample.yaml +1294 -0
  222. usersim/engine/assets/sov_ai_facts/ko_KR/sample.yaml +1435 -0
  223. usersim/engine/assets/sov_ai_facts/pt_BR/sample.yaml +1321 -0
  224. usersim/engine/assets/sov_ai_multilingual_parity/SCHEMA.md +137 -0
  225. usersim/engine/assets/sov_ai_multilingual_parity/sample.yaml +1210 -0
  226. usersim/engine/assets/tool_calling/README.md +225 -0
  227. usersim/engine/assets/tool_calling/prompts.yaml +171 -0
  228. usersim/engine/assets/tool_calling/toolsets_seed.parquet +0 -0
  229. usersim/engine/config.py +296 -0
  230. usersim/engine/core/__init__.py +4 -0
  231. usersim/engine/core/_assets.py +178 -0
  232. usersim/engine/core/_extensions.py +148 -0
  233. usersim/engine/core/action_taxonomy.py +347 -0
  234. usersim/engine/core/agentic_bank.py +725 -0
  235. usersim/engine/core/analysis.py +60 -0
  236. usersim/engine/core/backends.py +180 -0
  237. usersim/engine/core/behavioral.py +755 -0
  238. usersim/engine/core/clinical_profile_bank.py +245 -0
  239. usersim/engine/core/context.py +137 -0
  240. usersim/engine/core/embeddings.py +83 -0
  241. usersim/engine/core/episode_input.py +281 -0
  242. usersim/engine/core/episode_runtime.py +735 -0
  243. usersim/engine/core/fact_bank.py +500 -0
  244. usersim/engine/core/finance_bank.py +970 -0
  245. usersim/engine/core/finance_tasks.py +292 -0
  246. usersim/engine/core/idempotency.py +62 -0
  247. usersim/engine/core/identity.py +215 -0
  248. usersim/engine/core/identity_spec.py +659 -0
  249. usersim/engine/core/identity_spec_types.py +425 -0
  250. usersim/engine/core/judges.py +85 -0
  251. usersim/engine/core/language_detection.py +239 -0
  252. usersim/engine/core/llm.py +710 -0
  253. usersim/engine/core/locale.py +512 -0
  254. usersim/engine/core/localized.py +172 -0
  255. usersim/engine/core/manifest.py +998 -0
  256. usersim/engine/core/messages.py +93 -0
  257. usersim/engine/core/missing.py +38 -0
  258. usersim/engine/core/outcomes.py +442 -0
  259. usersim/engine/core/persona.py +178 -0
  260. usersim/engine/core/pressure_bank.py +629 -0
  261. usersim/engine/core/preview.py +1927 -0
  262. usersim/engine/core/probes.py +1499 -0
  263. usersim/engine/core/probing_taxonomy.py +462 -0
  264. usersim/engine/core/prompt_loader.py +93 -0
  265. usersim/engine/core/prompts.py +81 -0
  266. usersim/engine/core/provenance.py +118 -0
  267. usersim/engine/core/query_bank.py +567 -0
  268. usersim/engine/core/realized_audit.py +411 -0
  269. usersim/engine/core/row_metadata.py +35 -0
  270. usersim/engine/core/seeds.py +82 -0
  271. usersim/engine/core/simulation.py +1899 -0
  272. usersim/engine/core/storage.py +1407 -0
  273. usersim/engine/core/tool_calls.py +106 -0
  274. usersim/engine/core/translation.py +266 -0
  275. usersim/engine/core/user_turn_policy.py +80 -0
  276. usersim/engine/evaluator/__init__.py +19 -0
  277. usersim/engine/evaluator/axes.py +437 -0
  278. usersim/engine/evaluator/config.py +187 -0
  279. usersim/engine/evaluator/generator.py +424 -0
  280. usersim/engine/evaluator/judges.py +151 -0
  281. usersim/engine/evaluator/prompts.py +108 -0
  282. usersim/engine/evaluator/scorers/__init__.py +289 -0
  283. usersim/engine/evaluator/scorers/financial_services.py +635 -0
  284. usersim/engine/evaluator/scorers/health_disclosure.py +503 -0
  285. usersim/engine/evaluator/scorers/identity_disclosure.py +545 -0
  286. usersim/engine/evaluator/scorers/identity_grading.py +543 -0
  287. usersim/engine/evaluator/scorers/language_compliance.py +426 -0
  288. usersim/engine/evaluator/scorers/refusal_basics.py +479 -0
  289. usersim/engine/evaluator/scorers/response_shape.py +363 -0
  290. usersim/engine/evaluator/scorers/safety_agentic.py +1010 -0
  291. usersim/engine/evaluator/scorers/safety_chat_pressure.py +791 -0
  292. usersim/engine/evaluator/scorers/sov_ai_dynamic.py +629 -0
  293. usersim/engine/evaluator/scorers/sov_ai_facts.py +648 -0
  294. usersim/engine/evaluator/scorers/sov_ai_multilingual_parity.py +550 -0
  295. usersim/engine/evaluator/scorers/tool_use.py +291 -0
  296. usersim/engine/external.py +218 -0
  297. usersim/engine/generator.py +419 -0
  298. usersim/engine/plugin.py +34 -0
  299. usersim/engine/probes/__init__.py +28 -0
  300. usersim/engine/probes/financial_services/__init__.py +16 -0
  301. usersim/engine/probes/financial_services/generator.py +1329 -0
  302. usersim/engine/probes/financial_services/prompts.py +132 -0
  303. usersim/engine/probes/financial_services/retrieval.py +229 -0
  304. usersim/engine/probes/financial_services/task_derivation.py +274 -0
  305. usersim/engine/probes/financial_services/tools.py +116 -0
  306. usersim/engine/probes/general_educational/__init__.py +4 -0
  307. usersim/engine/probes/general_educational/generator.py +125 -0
  308. usersim/engine/probes/general_educational/prompts.py +207 -0
  309. usersim/engine/probes/general_open_ended/__init__.py +4 -0
  310. usersim/engine/probes/general_open_ended/generator.py +127 -0
  311. usersim/engine/probes/general_open_ended/prompts.py +209 -0
  312. usersim/engine/probes/health_disclosure/__init__.py +28 -0
  313. usersim/engine/probes/health_disclosure/base.py +148 -0
  314. usersim/engine/probes/health_disclosure/clients.py +291 -0
  315. usersim/engine/probes/health_disclosure/decision_support.py +44 -0
  316. usersim/engine/probes/health_disclosure/general.py +45 -0
  317. usersim/engine/probes/health_disclosure/mixin.py +363 -0
  318. usersim/engine/probes/health_disclosure/move_runtime.py +486 -0
  319. usersim/engine/probes/health_disclosure/moves.py +241 -0
  320. usersim/engine/probes/health_disclosure/prompts.py +324 -0
  321. usersim/engine/probes/health_disclosure/therapy.py +43 -0
  322. usersim/engine/probes/health_disclosure/triage.py +43 -0
  323. usersim/engine/probes/identity_disclosure/__init__.py +4 -0
  324. usersim/engine/probes/identity_disclosure/generator.py +504 -0
  325. usersim/engine/probes/identity_disclosure/prompts.py +219 -0
  326. usersim/engine/probes/identity_disclosure/task_derivation.py +227 -0
  327. usersim/engine/probes/safety_agentic/__init__.py +24 -0
  328. usersim/engine/probes/safety_agentic/generator.py +666 -0
  329. usersim/engine/probes/safety_agentic/prompts.py +24 -0
  330. usersim/engine/probes/safety_agentic/task_derivation.py +109 -0
  331. usersim/engine/probes/safety_chat_pressure/__init__.py +24 -0
  332. usersim/engine/probes/safety_chat_pressure/classifier.py +113 -0
  333. usersim/engine/probes/safety_chat_pressure/generator.py +551 -0
  334. usersim/engine/probes/safety_chat_pressure/prompts.py +141 -0
  335. usersim/engine/probes/safety_chat_pressure/task_derivation.py +171 -0
  336. usersim/engine/probes/sov_ai_dynamic/__init__.py +28 -0
  337. usersim/engine/probes/sov_ai_dynamic/generator.py +393 -0
  338. usersim/engine/probes/sov_ai_dynamic/prompts.py +472 -0
  339. usersim/engine/probes/sov_ai_dynamic/task_derivation.py +106 -0
  340. usersim/engine/probes/sov_ai_facts/__init__.py +15 -0
  341. usersim/engine/probes/sov_ai_facts/generator.py +342 -0
  342. usersim/engine/probes/sov_ai_facts/prompts.py +714 -0
  343. usersim/engine/probes/sov_ai_facts/task_derivation.py +479 -0
  344. usersim/engine/probes/sov_ai_multilingual_parity/__init__.py +19 -0
  345. usersim/engine/probes/sov_ai_multilingual_parity/generator.py +379 -0
  346. usersim/engine/probes/sov_ai_multilingual_parity/prompts.py +456 -0
  347. usersim/engine/probes/sov_ai_multilingual_parity/task_derivation.py +104 -0
  348. usersim/engine/probes/tool_calling/__init__.py +9 -0
  349. usersim/engine/probes/tool_calling/generator.py +437 -0
  350. usersim/engine/probes/tool_calling/prompts.py +221 -0
  351. usersim/engine/probes/tool_calling/verifiers.py +165 -0
  352. usersim/py.typed +0 -0
  353. usersim/reporting/__init__.py +116 -0
  354. usersim/reporting/_reasoning.py +201 -0
  355. usersim/reporting/capability_report.py +2049 -0
  356. usersim/reporting/comparison.py +797 -0
  357. usersim/reporting/comparison_dashboard.py +2938 -0
  358. usersim/reporting/dashboard.py +1731 -0
  359. usersim/reporting/diagnostics.py +435 -0
  360. usersim/reporting/eval_manifest.py +291 -0
  361. usersim/reporting/identity.py +82 -0
  362. usersim/reporting/resource.py +476 -0
  363. usersim/reporting/runs.py +282 -0
  364. usersim/reporting/sim_health.py +334 -0
  365. usersim/selection/__init__.py +87 -0
  366. usersim/selection/engine.py +423 -0
  367. usersim/selection/io.py +391 -0
  368. usersim/selection/profiles.py +249 -0
  369. usersim/selection/schemas.py +194 -0
  370. usersim/taxonomy/__init__.py +38 -0
  371. usersim/taxonomy/capabilities.py +1125 -0
  372. usersim/taxonomy/eval_cell.py +131 -0
  373. usersim/testing/__init__.py +534 -0
  374. usersim-0.2.0.dist-info/METADATA +392 -0
  375. usersim-0.2.0.dist-info/RECORD +379 -0
  376. usersim-0.2.0.dist-info/WHEEL +5 -0
  377. usersim-0.2.0.dist-info/entry_points.txt +6 -0
  378. usersim-0.2.0.dist-info/licenses/LICENSE +204 -0
  379. 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")