@omnizap-system/omnizap 2.5.12

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 (425) hide show
  1. package/.clusterfuzzlite/Dockerfile +10 -0
  2. package/.env.example +907 -0
  3. package/.github/codeql/codeql-config.yml +10 -0
  4. package/.github/dependabot.yml +35 -0
  5. package/.github/workflows/ci.yml +73 -0
  6. package/.github/workflows/codeql.yml +106 -0
  7. package/.github/workflows/db-migration-check.yml +98 -0
  8. package/.github/workflows/dependency-review.yml +22 -0
  9. package/.github/workflows/deploy.yml +95 -0
  10. package/.github/workflows/release.yml +106 -0
  11. package/.github/workflows/security-attest-provenance.yml +51 -0
  12. package/.github/workflows/security-gitleaks.yml +34 -0
  13. package/.github/workflows/security-runner-hardening.yml +31 -0
  14. package/.github/workflows/security-scorecard.yml +44 -0
  15. package/.github/workflows/security-zap-baseline.yml +44 -0
  16. package/.github/workflows/security-zap-full-scan.yml +43 -0
  17. package/.github/workflows/security-zizmor.yml +36 -0
  18. package/.github/workflows/wiki-sync.yml +44 -0
  19. package/.gitleaks.toml +15 -0
  20. package/.prettierrc +34 -0
  21. package/CODE_OF_CONDUCT.md +114 -0
  22. package/LICENSE +56 -0
  23. package/README.md +110 -0
  24. package/SECURITY.md +110 -0
  25. package/app/config/index.js +4 -0
  26. package/app/configParts/adminIdentity.js +92 -0
  27. package/app/configParts/baileysConfig.js +1818 -0
  28. package/app/configParts/groupUtils.js +692 -0
  29. package/app/configParts/loggerConfig.js +394 -0
  30. package/app/configParts/messagePersistenceService.js +305 -0
  31. package/app/connection/baileysCompatibility.test.js +40 -0
  32. package/app/connection/baileysDbAuthState.js +344 -0
  33. package/app/connection/socketController.js +2243 -0
  34. package/app/controllers/messageController.js +7 -0
  35. package/app/controllers/messagePipeline/commandMiddleware.js +146 -0
  36. package/app/controllers/messagePipeline/conversationMiddleware.js +183 -0
  37. package/app/controllers/messagePipeline/messagePipelineMiddlewares.test.js +522 -0
  38. package/app/controllers/messagePipeline/postProcessingMiddleware.js +41 -0
  39. package/app/controllers/messagePipeline/preProcessingMiddlewares.js +166 -0
  40. package/app/controllers/messageProcessingPipeline.js +699 -0
  41. package/app/modules/adminModule/AGENT.md +4056 -0
  42. package/app/modules/adminModule/adminAiHelpService.js +56 -0
  43. package/app/modules/adminModule/adminConfigRuntime.js +177 -0
  44. package/app/modules/adminModule/commandConfig.json +7122 -0
  45. package/app/modules/adminModule/groupCommandHandlers.js +1823 -0
  46. package/app/modules/adminModule/groupCommandHandlers.test.js +350 -0
  47. package/app/modules/adminModule/groupEventHandlers.js +399 -0
  48. package/app/modules/aiModule/AGENT.md +547 -0
  49. package/app/modules/aiModule/aiAiHelpService.js +14 -0
  50. package/app/modules/aiModule/aiConfigRuntime.js +135 -0
  51. package/app/modules/aiModule/catCommand.js +967 -0
  52. package/app/modules/aiModule/commandConfig.json +981 -0
  53. package/app/modules/analyticsModule/messageAnalysisEventRepository.js +83 -0
  54. package/app/modules/gameModule/AGENT.md +196 -0
  55. package/app/modules/gameModule/commandConfig.json +366 -0
  56. package/app/modules/gameModule/diceCommand.js +42 -0
  57. package/app/modules/gameModule/gameAiHelpService.js +14 -0
  58. package/app/modules/gameModule/gameConfigRuntime.js +68 -0
  59. package/app/modules/menuModule/AGENT.md +205 -0
  60. package/app/modules/menuModule/commandConfig.json +366 -0
  61. package/app/modules/menuModule/common.js +316 -0
  62. package/app/modules/menuModule/menuAiHelpService.js +14 -0
  63. package/app/modules/menuModule/menuConfigRuntime.js +68 -0
  64. package/app/modules/menuModule/menus.js +66 -0
  65. package/app/modules/playModule/AGENT.md +321 -0
  66. package/app/modules/playModule/commandConfig.json +584 -0
  67. package/app/modules/playModule/playAiHelpService.js +14 -0
  68. package/app/modules/playModule/playCommand.js +1417 -0
  69. package/app/modules/playModule/playConfigRuntime.js +68 -0
  70. package/app/modules/quoteModule/AGENT.md +199 -0
  71. package/app/modules/quoteModule/commandConfig.json +366 -0
  72. package/app/modules/quoteModule/quoteAiHelpService.js +14 -0
  73. package/app/modules/quoteModule/quoteCommand.js +842 -0
  74. package/app/modules/quoteModule/quoteConfigRuntime.js +68 -0
  75. package/app/modules/rpgPokemonModule/AGENT.md +229 -0
  76. package/app/modules/rpgPokemonModule/commandConfig.json +386 -0
  77. package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +795 -0
  78. package/app/modules/rpgPokemonModule/rpgBattleService.js +2110 -0
  79. package/app/modules/rpgPokemonModule/rpgBattleService.test.js +770 -0
  80. package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
  81. package/app/modules/rpgPokemonModule/rpgPokemonAiHelpService.js +14 -0
  82. package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +174 -0
  83. package/app/modules/rpgPokemonModule/rpgPokemonConfigRuntime.js +68 -0
  84. package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
  85. package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
  86. package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
  87. package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
  88. package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1847 -0
  89. package/app/modules/rpgPokemonModule/rpgPokemonService.js +6839 -0
  90. package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
  91. package/app/modules/statsModule/AGENT.md +320 -0
  92. package/app/modules/statsModule/commandConfig.json +540 -0
  93. package/app/modules/statsModule/globalRankingCommand.js +64 -0
  94. package/app/modules/statsModule/rankingCommand.js +41 -0
  95. package/app/modules/statsModule/rankingCommon.js +1305 -0
  96. package/app/modules/statsModule/statsAiHelpService.js +14 -0
  97. package/app/modules/statsModule/statsConfigRuntime.js +68 -0
  98. package/app/modules/stickerModule/AGENT.md +692 -0
  99. package/app/modules/stickerModule/addStickerMetadata.js +239 -0
  100. package/app/modules/stickerModule/commandConfig.json +1216 -0
  101. package/app/modules/stickerModule/convertToWebp.js +367 -0
  102. package/app/modules/stickerModule/stickerAiHelpService.js +14 -0
  103. package/app/modules/stickerModule/stickerCommand.js +446 -0
  104. package/app/modules/stickerModule/stickerConfigRuntime.js +68 -0
  105. package/app/modules/stickerModule/stickerConvertCommand.js +159 -0
  106. package/app/modules/stickerModule/stickerTextCommand.js +653 -0
  107. package/app/modules/stickerPackModule/AGENT.md +215 -0
  108. package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
  109. package/app/modules/stickerPackModule/autoPackCollectorService.js +357 -0
  110. package/app/modules/stickerPackModule/commandConfig.json +387 -0
  111. package/app/modules/stickerPackModule/domainEventOutboxRepository.js +227 -0
  112. package/app/modules/stickerPackModule/domainEvents.js +52 -0
  113. package/app/modules/stickerPackModule/semanticReclassificationEngine.js +429 -0
  114. package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +75 -0
  115. package/app/modules/stickerPackModule/semanticThemeClusterService.js +544 -0
  116. package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +400 -0
  117. package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
  118. package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +175 -0
  119. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +3702 -0
  120. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +559 -0
  121. package/app/modules/stickerPackModule/stickerClassificationService.js +557 -0
  122. package/app/modules/stickerPackModule/stickerDedicatedTaskWorkerRuntime.js +249 -0
  123. package/app/modules/stickerPackModule/stickerDomainEventBus.js +65 -0
  124. package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +208 -0
  125. package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +99 -0
  126. package/app/modules/stickerPackModule/stickerObjectStorageService.js +285 -0
  127. package/app/modules/stickerPackModule/stickerPackAiHelpService.js +14 -0
  128. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1148 -0
  129. package/app/modules/stickerPackModule/stickerPackConfigRuntime.js +68 -0
  130. package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +152 -0
  131. package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
  132. package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +101 -0
  133. package/app/modules/stickerPackModule/stickerPackItemRepository.js +432 -0
  134. package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +313 -0
  135. package/app/modules/stickerPackModule/stickerPackMessageService.js +268 -0
  136. package/app/modules/stickerPackModule/stickerPackRepository.js +450 -0
  137. package/app/modules/stickerPackModule/stickerPackScoreSnapshotRepository.js +179 -0
  138. package/app/modules/stickerPackModule/stickerPackScoreSnapshotRuntime.js +271 -0
  139. package/app/modules/stickerPackModule/stickerPackService.js +733 -0
  140. package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +32 -0
  141. package/app/modules/stickerPackModule/stickerPackUtils.js +107 -0
  142. package/app/modules/stickerPackModule/stickerStorageService.js +559 -0
  143. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +242 -0
  144. package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +242 -0
  145. package/app/modules/systemMetricsModule/AGENT.md +193 -0
  146. package/app/modules/systemMetricsModule/commandConfig.json +344 -0
  147. package/app/modules/systemMetricsModule/pingCommand.js +399 -0
  148. package/app/modules/systemMetricsModule/systemMetricsAiHelpService.js +14 -0
  149. package/app/modules/systemMetricsModule/systemMetricsConfigRuntime.js +68 -0
  150. package/app/modules/tiktokModule/AGENT.md +196 -0
  151. package/app/modules/tiktokModule/commandConfig.json +366 -0
  152. package/app/modules/tiktokModule/tiktokAiHelpService.js +14 -0
  153. package/app/modules/tiktokModule/tiktokCommand.js +716 -0
  154. package/app/modules/tiktokModule/tiktokConfigRuntime.js +68 -0
  155. package/app/modules/userModule/AGENT.md +200 -0
  156. package/app/modules/userModule/commandConfig.json +386 -0
  157. package/app/modules/userModule/userAiHelpService.js +14 -0
  158. package/app/modules/userModule/userCommand.js +1155 -0
  159. package/app/modules/userModule/userConfigRuntime.js +68 -0
  160. package/app/modules/waifuPicsModule/AGENT.md +431 -0
  161. package/app/modules/waifuPicsModule/commandConfig.json +780 -0
  162. package/app/modules/waifuPicsModule/waifuPicsAiHelpService.js +14 -0
  163. package/app/modules/waifuPicsModule/waifuPicsCommand.js +586 -0
  164. package/app/modules/waifuPicsModule/waifuPicsConfigRuntime.js +68 -0
  165. package/app/observability/metrics.js +766 -0
  166. package/app/services/ai/aiHelpResponseCacheRepository.js +280 -0
  167. package/app/services/ai/aiLearningRepository.js +400 -0
  168. package/app/services/ai/commandConfigEnrichmentRepository.js +769 -0
  169. package/app/services/ai/commandConfigEnrichmentService.js +452 -0
  170. package/app/services/ai/commandConfigValidationService.js +443 -0
  171. package/app/services/ai/commandToolBuilderService.js +192 -0
  172. package/app/services/ai/conversationRouterService.js +516 -0
  173. package/app/services/ai/geminiService.js +115 -0
  174. package/app/services/ai/geminiService.test.js +87 -0
  175. package/app/services/ai/globalModuleAiHelpService.js +1412 -0
  176. package/app/services/ai/globalToolCallingService.js +203 -0
  177. package/app/services/ai/messageCommandExecutionService.js +391 -0
  178. package/app/services/ai/moduleAiHelpCoreService.js +1099 -0
  179. package/app/services/ai/moduleAiHelpWrapperFactory.js +65 -0
  180. package/app/services/ai/moduleCommandConfigRuntimeService.js +113 -0
  181. package/app/services/ai/moduleToolExecutorService.js +464 -0
  182. package/app/services/ai/moduleToolRegistryService.js +178 -0
  183. package/app/services/ai/toolCandidateSelectorService.js +781 -0
  184. package/app/services/auth/googleWebLinkService.js +80 -0
  185. package/app/services/auth/whatsappLoginLinkService.js +230 -0
  186. package/app/services/external/pokeApiService.js +398 -0
  187. package/app/services/group/groupMetadataService.js +311 -0
  188. package/app/services/infra/dbWriteQueue.js +874 -0
  189. package/app/services/infra/featureFlagService.js +131 -0
  190. package/app/services/infra/queueUtils.js +55 -0
  191. package/app/services/messaging/captchaService.js +491 -0
  192. package/app/services/messaging/messagePersistenceService.js +1 -0
  193. package/app/services/messaging/newsBroadcastService.js +347 -0
  194. package/app/services/sticker/stickerFocusService.js +347 -0
  195. package/app/services/sticker/stickerFocusService.test.js +43 -0
  196. package/app/store/aiPromptStore.js +38 -0
  197. package/app/store/conversationSessionStore.js +131 -0
  198. package/app/store/groupConfigStore.js +58 -0
  199. package/app/store/premiumUserStore.js +54 -0
  200. package/app/utils/antiLink/antiLinkModule.js +700 -0
  201. package/app/utils/http/getImageBufferModule.js +18 -0
  202. package/app/utils/json/jsonSanitizer.js +113 -0
  203. package/app/utils/json/jsonSanitizer.test.js +40 -0
  204. package/app/utils/systemMetrics/systemMetricsModule.js +88 -0
  205. package/app/workers/aiLearningWorker.js +605 -0
  206. package/app/workers/commandConfigEnrichmentWorker.js +242 -0
  207. package/database/index.js +2075 -0
  208. package/database/init.js +151 -0
  209. package/database/migrations/.gitkeep +0 -0
  210. package/database/migrations/20260307_d0_hardening_down.sql +64 -0
  211. package/database/migrations/20260307_d0_hardening_up.sql +79 -0
  212. package/database/migrations/20260307_d1_terms_acceptance_down.sql +11 -0
  213. package/database/migrations/20260307_d1_terms_acceptance_up.sql +37 -0
  214. package/database/migrations/20260307_d2_auth_hardening_down.sql +75 -0
  215. package/database/migrations/20260307_d2_auth_hardening_up.sql +100 -0
  216. package/database/migrations/20260314_d7_canonical_sender_down.sql +53 -0
  217. package/database/migrations/20260314_d7_canonical_sender_up.sql +114 -0
  218. package/database/migrations/20260406_d30_security_analytics_down.sql +95 -0
  219. package/database/migrations/20260406_d30_security_analytics_up.sql +292 -0
  220. package/database/migrations/20260407_d31_web_google_session_token_hardening_down.sql +2 -0
  221. package/database/migrations/20260407_d31_web_google_session_token_hardening_up.sql +17 -0
  222. package/database/migrations/20260408_d32_ai_help_response_cache_down.sql +1 -0
  223. package/database/migrations/20260408_d32_ai_help_response_cache_up.sql +22 -0
  224. package/database/migrations/20260409_d33_ai_learning_tables_down.sql +4 -0
  225. package/database/migrations/20260409_d33_ai_learning_tables_up.sql +52 -0
  226. package/database/migrations/20260410_d34_command_config_enrichment_down.sql +3 -0
  227. package/database/migrations/20260410_d34_command_config_enrichment_up.sql +48 -0
  228. package/database/schema.sql +1186 -0
  229. package/docker-compose.yml +104 -0
  230. package/docs/audits/stickerCatalogController-out-of-scope.md +103 -0
  231. package/docs/audits/stickerCatalogController-symbols.md +58 -0
  232. package/docs/compliance/acceptable-use-policy-2026-03-07.md +35 -0
  233. package/docs/compliance/dpa-b2b-standard-2026-03-07.md +80 -0
  234. package/docs/compliance/monthly-compliance-checklist-2026-03-07.md +88 -0
  235. package/docs/compliance/notice-and-takedown-policy-2026-03-07.md +34 -0
  236. package/docs/compliance/privacy-policy-2026-03-07.md +75 -0
  237. package/docs/compliance/subprocessors-inventory-2026-03-07.md +16 -0
  238. package/docs/database/production-db-evolution-runbook-2026q1.md +365 -0
  239. package/docs/security/dsar-lgpd-runbook-2026-03-07.md +86 -0
  240. package/docs/security/incident-response-lgpd-anpd-runbook-2026-03-07.md +77 -0
  241. package/docs/security/network-hardening-runbook-2026-03-07.md +137 -0
  242. package/docs/seo/omnizap-seo-playbook-br-2026-02-28.md +238 -0
  243. package/docs/seo/satellite-page-template.md +116 -0
  244. package/docs/seo/satellite-pages-phase1.json +364 -0
  245. package/docs/wiki/Home.md +120 -0
  246. package/docs/wiki/pair-extraordinaire-2026-03-08.md +3 -0
  247. package/docs/wiki/recent-changes-2026-03-08.md +47 -0
  248. package/ecosystem.prod.config.cjs +135 -0
  249. package/eslint.config.js +89 -0
  250. package/index.js +488 -0
  251. package/ml/clip_classifier/Dockerfile +18 -0
  252. package/ml/clip_classifier/README.md +118 -0
  253. package/ml/clip_classifier/adaptive_scoring.py +40 -0
  254. package/ml/clip_classifier/classifier.py +654 -0
  255. package/ml/clip_classifier/embedding_store.py +481 -0
  256. package/ml/clip_classifier/env_loader.py +15 -0
  257. package/ml/clip_classifier/llm_label_expander.py +144 -0
  258. package/ml/clip_classifier/main.py +213 -0
  259. package/ml/clip_classifier/requirements.txt +10 -0
  260. package/ml/clip_classifier/similarity_engine.py +74 -0
  261. package/new-logo.png +0 -0
  262. package/observability/alert-rules.yml +60 -0
  263. package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
  264. package/observability/grafana/dashboards/omnizap-overview.json +170 -0
  265. package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
  266. package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
  267. package/observability/loki-config.yml +38 -0
  268. package/observability/mysql-setup.sql +46 -0
  269. package/observability/prometheus.yml +35 -0
  270. package/observability/promtail-config.yml +84 -0
  271. package/observability/sticker-catalog-slo.md +83 -0
  272. package/observability/sticker-scale-hardening-rollout.md +128 -0
  273. package/package.json +144 -0
  274. package/public/apple-touch-icon.png +0 -0
  275. package/public/assets/css/commands-react.input.css +71 -0
  276. package/public/assets/css/create-pack-react.input.css +31 -0
  277. package/public/assets/css/home-react.input.css +106 -0
  278. package/public/assets/css/login-react.input.css +58 -0
  279. package/public/assets/css/stickers-react.input.css +18 -0
  280. package/public/assets/css/terms-react.input.css +115 -0
  281. package/public/assets/css/user-react.input.css +57 -0
  282. package/public/assets/images/brand-icon-192.png +0 -0
  283. package/public/assets/images/brand-logo-128.webp +0 -0
  284. package/public/assets/images/hero-banner-1280.jpg +0 -0
  285. package/public/comandos/commands-catalog.json +4517 -0
  286. package/public/css/api-docs.css +161 -0
  287. package/public/css/stickers-admin.css +1288 -0
  288. package/public/css/styles.css +679 -0
  289. package/public/css/systemadm/admin.css +474 -0
  290. package/public/css/systemadm/base.css +73 -0
  291. package/public/css/systemadm/components.css +662 -0
  292. package/public/css/systemadm/layout.css +229 -0
  293. package/public/css/systemadm/tokens.css +56 -0
  294. package/public/favicon-16x16.png +0 -0
  295. package/public/favicon-32x32.png +0 -0
  296. package/public/favicon.ico +0 -0
  297. package/public/js/apps/apiDocsApp.js +235 -0
  298. package/public/js/apps/commandsReactApp.js +528 -0
  299. package/public/js/apps/createPackApp.js +1646 -0
  300. package/public/js/apps/homeReactApp.js +942 -0
  301. package/public/js/apps/loginReactApp.js +496 -0
  302. package/public/js/apps/stickersAdminApp.js +1753 -0
  303. package/public/js/apps/stickersApp.js +3797 -0
  304. package/public/js/apps/termsReactApp.js +528 -0
  305. package/public/js/apps/userApp.js +2540 -0
  306. package/public/js/apps/userProfile/actions.js +66 -0
  307. package/public/js/apps/userReactApp.js +547 -0
  308. package/public/js/catalog.js +950 -0
  309. package/public/pages/api-docs.html +40 -0
  310. package/public/pages/aup.html +158 -0
  311. package/public/pages/comandos.html +41 -0
  312. package/public/pages/dpa.html +227 -0
  313. package/public/pages/home.html +45 -0
  314. package/public/pages/licenca.html +182 -0
  315. package/public/pages/login.html +40 -0
  316. package/public/pages/notice-and-takedown.html +234 -0
  317. package/public/pages/politica-de-privacidade.html +251 -0
  318. package/public/pages/seo-bot-whatsapp-para-grupo.html +350 -0
  319. package/public/pages/seo-bot-whatsapp-sem-programar.html +350 -0
  320. package/public/pages/seo-como-automatizar-avisos-no-whatsapp.html +350 -0
  321. package/public/pages/seo-como-criar-comandos-whatsapp.html +350 -0
  322. package/public/pages/seo-como-evitar-spam-no-whatsapp.html +350 -0
  323. package/public/pages/seo-como-moderar-grupo-whatsapp.html +350 -0
  324. package/public/pages/seo-como-organizar-comunidade-whatsapp.html +350 -0
  325. package/public/pages/seo-melhor-bot-whatsapp-para-grupos.html +350 -0
  326. package/public/pages/stickers-admin.html +31 -0
  327. package/public/pages/stickers-create.html +41 -0
  328. package/public/pages/stickers.html +45 -0
  329. package/public/pages/suboperadores.html +237 -0
  330. package/public/pages/termos-de-uso-texto-integral.html +241 -0
  331. package/public/pages/termos-de-uso.html +41 -0
  332. package/public/pages/user-password-reset.html +32 -0
  333. package/public/pages/user-systemadm.html +508 -0
  334. package/public/pages/user.html +39 -0
  335. package/public/robots.txt +9 -0
  336. package/public/site.webmanifest +24 -0
  337. package/public/sitemap.xml +98 -0
  338. package/schemas/command-config.schema.json +582 -0
  339. package/scripts/baileys-compat-smoke.mjs +12 -0
  340. package/scripts/cache-bust.mjs +142 -0
  341. package/scripts/deploy.sh +916 -0
  342. package/scripts/email-broadcast-terms-update.mjs +170 -0
  343. package/scripts/enrich-command-discovery-fields.mjs +286 -0
  344. package/scripts/generate-command-config-schema.mjs +273 -0
  345. package/scripts/generate-commands-catalog.mjs +308 -0
  346. package/scripts/generate-module-agents.mjs +631 -0
  347. package/scripts/generate-seo-satellite-pages.mjs +400 -0
  348. package/scripts/github-deploy-notify.mjs +174 -0
  349. package/scripts/github-release-notify.mjs +219 -0
  350. package/scripts/release.sh +599 -0
  351. package/scripts/run-codeql-local.sh +116 -0
  352. package/scripts/run-prettier-all.mjs +25 -0
  353. package/scripts/security-smoketest.mjs +581 -0
  354. package/scripts/sticker-catalog-loadtest.mjs +210 -0
  355. package/scripts/sticker-worker-task.mjs +119 -0
  356. package/scripts/sync-readme-snapshot.mjs +133 -0
  357. package/scripts/validate-command-config-schema.mjs +130 -0
  358. package/scripts/validate-command-configs.mjs +15 -0
  359. package/scripts/wiki-sync.sh +191 -0
  360. package/server/auth/googleWebAuth/googleWebAuthRuntime.js +62 -0
  361. package/server/auth/googleWebAuth/googleWebAuthService.js +807 -0
  362. package/server/auth/jwt/webJwtService.js +147 -0
  363. package/server/auth/stickerCatalogAuthContext.js +165 -0
  364. package/server/auth/termsAcceptance/termsAcceptanceHandler.js +189 -0
  365. package/server/auth/userPassword/index.js +14 -0
  366. package/server/auth/userPassword/userPasswordAuthService.js +422 -0
  367. package/server/auth/userPassword/userPasswordCrypto.js +199 -0
  368. package/server/auth/userPassword/userPasswordCrypto.test.js +76 -0
  369. package/server/auth/userPassword/userPasswordRecoveryService.js +728 -0
  370. package/server/auth/validation/authSchemas.js +236 -0
  371. package/server/auth/webAccount/webAccountHandlers.js +1434 -0
  372. package/server/controllers/admin/adminBanService.js +138 -0
  373. package/server/controllers/admin/adminPanelHandlers.js +2083 -0
  374. package/server/controllers/admin/stickerCatalogAdminContext.js +17 -0
  375. package/server/controllers/admin/systemAdminController.js +201 -0
  376. package/server/controllers/email/emailAutomationController.js +239 -0
  377. package/server/controllers/metricsController.js +21 -0
  378. package/server/controllers/seo/stickerCatalogSeoContext.js +514 -0
  379. package/server/controllers/sticker/nonCatalogHandlers.js +303 -0
  380. package/server/controllers/sticker/stickerCatalogController.js +4700 -0
  381. package/server/controllers/system/contactController.js +115 -0
  382. package/server/controllers/system/githubController.js +137 -0
  383. package/server/controllers/system/stickerCatalogSystemContext.js +758 -0
  384. package/server/controllers/system/storageController.js +154 -0
  385. package/server/controllers/system/systemController.js +135 -0
  386. package/server/controllers/system/systemMetricsController.js +156 -0
  387. package/server/controllers/system/visitController.js +90 -0
  388. package/server/controllers/userController.js +145 -0
  389. package/server/email/emailAutomationRuntime.js +225 -0
  390. package/server/email/emailAutomationService.js +125 -0
  391. package/server/email/emailOutboxRepository.js +282 -0
  392. package/server/email/emailTemplateService.js +480 -0
  393. package/server/email/emailTransportService.js +156 -0
  394. package/server/http/clientIp.js +95 -0
  395. package/server/http/httpRequestUtils.js +262 -0
  396. package/server/http/httpRequestUtils.test.js +80 -0
  397. package/server/http/httpServer.js +180 -0
  398. package/server/http/requestContext.js +20 -0
  399. package/server/http/siteRoutingUtils.js +87 -0
  400. package/server/index.js +1 -0
  401. package/server/middleware/cachePolicy.js +26 -0
  402. package/server/middleware/cachePolicyHelpers.js +1 -0
  403. package/server/middleware/endpointRateLimit.js +181 -0
  404. package/server/middleware/rateLimit.js +70 -0
  405. package/server/middleware/requireAdminAuth.js +48 -0
  406. package/server/middleware/securityHeaders.js +97 -0
  407. package/server/routes/admin/systemAdminRouter.js +64 -0
  408. package/server/routes/email/emailAutomationRouter.js +46 -0
  409. package/server/routes/health/healthRouter.js +41 -0
  410. package/server/routes/indexRouter.js +234 -0
  411. package/server/routes/metrics/metricsRouter.js +58 -0
  412. package/server/routes/static/staticPageRouter.js +134 -0
  413. package/server/routes/sticker/catalogHandlers/catalogAdminHttp.js +105 -0
  414. package/server/routes/sticker/catalogHandlers/catalogAuthHttp.js +77 -0
  415. package/server/routes/sticker/catalogHandlers/catalogPublicHttp.js +120 -0
  416. package/server/routes/sticker/catalogHandlers/catalogUploadHttp.js +83 -0
  417. package/server/routes/sticker/catalogRouter.js +77 -0
  418. package/server/routes/sticker/stickerApiRouter.js +84 -0
  419. package/server/routes/sticker/stickerDataRouter.js +145 -0
  420. package/server/routes/sticker/stickerSiteRouter.js +43 -0
  421. package/server/routes/user/userApiPaths.js +66 -0
  422. package/server/routes/user/userRouter.js +65 -0
  423. package/server/utils/safePath.js +26 -0
  424. package/utils/logger/loggerModule.js +35 -0
  425. package/vite.config.mjs +38 -0
@@ -0,0 +1,481 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import threading
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+ import pymysql
12
+ from pymysql.cursors import DictCursor
13
+
14
+ from env_loader import load_project_env
15
+
16
+ load_project_env()
17
+
18
+
19
+ def _parse_env_bool(value: str | None, fallback: bool) -> bool:
20
+ if value is None:
21
+ return fallback
22
+ normalized = str(value).strip().lower()
23
+ if normalized in {"1", "true", "yes", "y", "on"}:
24
+ return True
25
+ if normalized in {"0", "false", "no", "n", "off"}:
26
+ return False
27
+ return fallback
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class SimilarImageRow:
32
+ image_hash: str
33
+ asset_id: str | None
34
+ embedding: np.ndarray
35
+
36
+
37
+ class EmbeddingStore:
38
+ """Persistent store for embeddings, feedback and LLM expansion cache."""
39
+
40
+ def __init__(self) -> None:
41
+ self.db_host = os.getenv("DB_HOST", "").strip()
42
+ self.db_user = os.getenv("DB_USER", "").strip()
43
+ self.db_password = os.getenv("DB_PASSWORD", "")
44
+ base_db_name = os.getenv("DB_NAME", "").strip()
45
+ node_env = os.getenv("NODE_ENV", "development").strip().lower() or "development"
46
+ suffix = "prod" if node_env == "production" else "dev"
47
+ if base_db_name.endswith("_prod") or base_db_name.endswith("_dev"):
48
+ self.db_name = base_db_name
49
+ else:
50
+ self.db_name = f"{base_db_name}_{suffix}" if base_db_name else ""
51
+ self.db_port = int(os.getenv("DB_PORT", "3306") or 3306)
52
+
53
+ self.enabled = all([self.db_host, self.db_user, self.db_name])
54
+ self._connection: pymysql.Connection | None = None
55
+ self._schema_ready = False
56
+ self._lock = threading.Lock()
57
+
58
+ def _connect(self) -> pymysql.Connection | None:
59
+ if not self.enabled:
60
+ return None
61
+ if self._connection and self._connection.open:
62
+ return self._connection
63
+
64
+ try:
65
+ self._connection = pymysql.connect(
66
+ host=self.db_host,
67
+ user=self.db_user,
68
+ password=self.db_password,
69
+ database=self.db_name,
70
+ port=self.db_port,
71
+ charset="utf8mb4",
72
+ cursorclass=DictCursor,
73
+ autocommit=True,
74
+ connect_timeout=5,
75
+ read_timeout=10,
76
+ write_timeout=10,
77
+ )
78
+ except Exception:
79
+ self._connection = None
80
+ self.enabled = False
81
+ return None
82
+ return self._connection
83
+
84
+ def _ensure_schema(self) -> None:
85
+ if not self.enabled:
86
+ return
87
+ with self._lock:
88
+ if self._schema_ready:
89
+ return
90
+ connection = self._connect()
91
+ if connection is None:
92
+ return
93
+
94
+ ddl = [
95
+ """
96
+ CREATE TABLE IF NOT EXISTS clip_image_embedding_cache (
97
+ image_hash CHAR(64) NOT NULL,
98
+ asset_id VARCHAR(64) NULL,
99
+ model_name VARCHAR(80) NOT NULL,
100
+ embedding_dim SMALLINT UNSIGNED NOT NULL,
101
+ embedding MEDIUMBLOB NOT NULL,
102
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
103
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
104
+ PRIMARY KEY (image_hash),
105
+ KEY idx_clip_image_embedding_asset_id (asset_id),
106
+ KEY idx_clip_image_embedding_model (model_name)
107
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
108
+ """,
109
+ """
110
+ CREATE TABLE IF NOT EXISTS clip_label_embedding_cache (
111
+ model_name VARCHAR(80) NOT NULL,
112
+ label VARCHAR(191) NOT NULL,
113
+ embedding_dim SMALLINT UNSIGNED NOT NULL,
114
+ embedding MEDIUMBLOB NOT NULL,
115
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
116
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
117
+ PRIMARY KEY (model_name, label)
118
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
119
+ """,
120
+ """
121
+ CREATE TABLE IF NOT EXISTS clip_llm_label_expansion_cache (
122
+ cache_key CHAR(64) NOT NULL,
123
+ model_name VARCHAR(80) NOT NULL,
124
+ top_labels_json TEXT NOT NULL,
125
+ expansion_json LONGTEXT NOT NULL,
126
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
127
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
128
+ PRIMARY KEY (cache_key)
129
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
130
+ """,
131
+ """
132
+ CREATE TABLE IF NOT EXISTS clip_image_theme_feedback (
133
+ image_hash CHAR(64) NOT NULL,
134
+ asset_id VARCHAR(64) NULL,
135
+ theme VARCHAR(120) NOT NULL,
136
+ acceptance_count INT UNSIGNED NOT NULL DEFAULT 0,
137
+ total_assignments INT UNSIGNED NOT NULL DEFAULT 0,
138
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
139
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
140
+ PRIMARY KEY (image_hash, theme),
141
+ KEY idx_clip_feedback_asset_id (asset_id),
142
+ KEY idx_clip_feedback_theme (theme)
143
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
144
+ """,
145
+ ]
146
+
147
+ try:
148
+ with connection.cursor() as cursor:
149
+ for statement in ddl:
150
+ cursor.execute(statement)
151
+ self._schema_ready = True
152
+ except Exception:
153
+ self.enabled = False
154
+ self._schema_ready = False
155
+
156
+ @staticmethod
157
+ def stable_labels_key(model_name: str, labels: list[str]) -> str:
158
+ payload = json.dumps({"model": model_name, "labels": labels}, ensure_ascii=False, separators=(",", ":"))
159
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
160
+
161
+ @staticmethod
162
+ def _serialize_vector(vector: np.ndarray) -> tuple[bytes, int]:
163
+ array = np.asarray(vector, dtype=np.float32).reshape(-1)
164
+ return array.tobytes(), int(array.shape[0])
165
+
166
+ @staticmethod
167
+ def _deserialize_vector(raw: bytes, dim: int) -> np.ndarray:
168
+ if not raw:
169
+ return np.empty((0,), dtype=np.float32)
170
+ vector = np.frombuffer(raw, dtype=np.float32)
171
+ if dim > 0 and vector.shape[0] != dim:
172
+ return vector[:dim]
173
+ return vector
174
+
175
+ def get_image_embedding(self, image_hash: str, model_name: str) -> np.ndarray | None:
176
+ if not self.enabled or not image_hash:
177
+ return None
178
+ self._ensure_schema()
179
+
180
+ connection = self._connect()
181
+ if connection is None:
182
+ return None
183
+
184
+ sql = """
185
+ SELECT embedding, embedding_dim
186
+ FROM clip_image_embedding_cache
187
+ WHERE image_hash = %s AND model_name = %s
188
+ LIMIT 1
189
+ """
190
+ try:
191
+ with connection.cursor() as cursor:
192
+ cursor.execute(sql, (image_hash, model_name))
193
+ row = cursor.fetchone()
194
+ except Exception:
195
+ self.enabled = False
196
+ return None
197
+ if not row:
198
+ return None
199
+ return self._deserialize_vector(row.get("embedding") or b"", int(row.get("embedding_dim") or 0))
200
+
201
+ def save_image_embedding(
202
+ self,
203
+ image_hash: str,
204
+ model_name: str,
205
+ embedding: np.ndarray,
206
+ asset_id: str | None = None,
207
+ ) -> None:
208
+ if not self.enabled or not image_hash:
209
+ return
210
+ self._ensure_schema()
211
+
212
+ payload, dim = self._serialize_vector(embedding)
213
+ sql = """
214
+ INSERT INTO clip_image_embedding_cache (
215
+ image_hash, asset_id, model_name, embedding_dim, embedding
216
+ ) VALUES (%s, %s, %s, %s, %s)
217
+ ON DUPLICATE KEY UPDATE
218
+ asset_id = VALUES(asset_id),
219
+ model_name = VALUES(model_name),
220
+ embedding_dim = VALUES(embedding_dim),
221
+ embedding = VALUES(embedding),
222
+ updated_at = CURRENT_TIMESTAMP
223
+ """
224
+
225
+ connection = self._connect()
226
+ if connection is None:
227
+ return
228
+
229
+ try:
230
+ with connection.cursor() as cursor:
231
+ cursor.execute(sql, (image_hash, asset_id, model_name, dim, payload))
232
+ except Exception:
233
+ self.enabled = False
234
+
235
+ def get_label_embeddings(self, model_name: str, labels: list[str]) -> dict[str, np.ndarray]:
236
+ if not self.enabled or not labels:
237
+ return {}
238
+ self._ensure_schema()
239
+
240
+ connection = self._connect()
241
+ if connection is None:
242
+ return {}
243
+
244
+ placeholders = ", ".join(["%s"] * len(labels))
245
+ sql = f"""
246
+ SELECT label, embedding, embedding_dim
247
+ FROM clip_label_embedding_cache
248
+ WHERE model_name = %s AND label IN ({placeholders})
249
+ """
250
+
251
+ rows: list[dict[str, Any]]
252
+ try:
253
+ with connection.cursor() as cursor:
254
+ cursor.execute(sql, (model_name, *labels))
255
+ rows = cursor.fetchall() or []
256
+ except Exception:
257
+ self.enabled = False
258
+ return {}
259
+
260
+ output: dict[str, np.ndarray] = {}
261
+ for row in rows:
262
+ label = str(row.get("label") or "")
263
+ if not label:
264
+ continue
265
+ output[label] = self._deserialize_vector(row.get("embedding") or b"", int(row.get("embedding_dim") or 0))
266
+ return output
267
+
268
+ def save_label_embeddings(self, model_name: str, embeddings: dict[str, np.ndarray]) -> None:
269
+ if not self.enabled or not embeddings:
270
+ return
271
+ self._ensure_schema()
272
+
273
+ sql = """
274
+ INSERT INTO clip_label_embedding_cache (
275
+ model_name, label, embedding_dim, embedding
276
+ ) VALUES (%s, %s, %s, %s)
277
+ ON DUPLICATE KEY UPDATE
278
+ embedding_dim = VALUES(embedding_dim),
279
+ embedding = VALUES(embedding),
280
+ updated_at = CURRENT_TIMESTAMP
281
+ """
282
+
283
+ connection = self._connect()
284
+ if connection is None:
285
+ return
286
+
287
+ payload = []
288
+ for label, vector in embeddings.items():
289
+ serialized, dim = self._serialize_vector(vector)
290
+ payload.append((model_name, label, dim, serialized))
291
+
292
+ try:
293
+ with connection.cursor() as cursor:
294
+ cursor.executemany(sql, payload)
295
+ except Exception:
296
+ self.enabled = False
297
+
298
+ def list_image_embeddings(self, model_name: str, limit: int = 3000) -> list[SimilarImageRow]:
299
+ if not self.enabled:
300
+ return []
301
+ self._ensure_schema()
302
+
303
+ connection = self._connect()
304
+ if connection is None:
305
+ return []
306
+
307
+ safe_limit = max(50, min(int(limit or 3000), 20000))
308
+ sql = """
309
+ SELECT image_hash, asset_id, embedding, embedding_dim
310
+ FROM clip_image_embedding_cache
311
+ WHERE model_name = %s
312
+ ORDER BY updated_at DESC
313
+ LIMIT %s
314
+ """
315
+
316
+ try:
317
+ with connection.cursor() as cursor:
318
+ cursor.execute(sql, (model_name, safe_limit))
319
+ rows = cursor.fetchall() or []
320
+ except Exception:
321
+ self.enabled = False
322
+ return []
323
+
324
+ output: list[SimilarImageRow] = []
325
+ for row in rows:
326
+ image_hash = str(row.get("image_hash") or "")
327
+ if not image_hash:
328
+ continue
329
+ embedding = self._deserialize_vector(row.get("embedding") or b"", int(row.get("embedding_dim") or 0))
330
+ output.append(
331
+ SimilarImageRow(
332
+ image_hash=image_hash,
333
+ asset_id=str(row.get("asset_id")) if row.get("asset_id") is not None else None,
334
+ embedding=embedding,
335
+ )
336
+ )
337
+ return output
338
+
339
+ def get_affinity_weight(self, image_hash: str, theme: str) -> float:
340
+ if not self.enabled or not image_hash or not theme:
341
+ return 0.0
342
+ self._ensure_schema()
343
+
344
+ connection = self._connect()
345
+ if connection is None:
346
+ return 0.0
347
+
348
+ sql = """
349
+ SELECT acceptance_count, total_assignments
350
+ FROM clip_image_theme_feedback
351
+ WHERE image_hash = %s AND theme = %s
352
+ LIMIT 1
353
+ """
354
+
355
+ try:
356
+ with connection.cursor() as cursor:
357
+ cursor.execute(sql, (image_hash, theme))
358
+ row = cursor.fetchone()
359
+ except Exception:
360
+ self.enabled = False
361
+ return 0.0
362
+ if not row:
363
+ return 0.0
364
+
365
+ acceptance = int(row.get("acceptance_count") or 0)
366
+ total = int(row.get("total_assignments") or 0)
367
+ if total <= 0:
368
+ return 0.0
369
+ return float(acceptance / total)
370
+
371
+ def record_feedback(
372
+ self,
373
+ image_hash: str,
374
+ theme: str,
375
+ accepted: bool,
376
+ asset_id: str | None = None,
377
+ ) -> None:
378
+ if not self.enabled or not image_hash or not theme:
379
+ return
380
+ self._ensure_schema()
381
+
382
+ connection = self._connect()
383
+ if connection is None:
384
+ return
385
+
386
+ accepted_increment = 1 if accepted else 0
387
+ sql = """
388
+ INSERT INTO clip_image_theme_feedback (
389
+ image_hash, asset_id, theme, acceptance_count, total_assignments
390
+ ) VALUES (%s, %s, %s, %s, 1)
391
+ ON DUPLICATE KEY UPDATE
392
+ asset_id = COALESCE(VALUES(asset_id), asset_id),
393
+ acceptance_count = acceptance_count + VALUES(acceptance_count),
394
+ total_assignments = total_assignments + 1,
395
+ updated_at = CURRENT_TIMESTAMP
396
+ """
397
+
398
+ try:
399
+ with connection.cursor() as cursor:
400
+ cursor.execute(sql, (image_hash, asset_id, theme, accepted_increment))
401
+ except Exception:
402
+ self.enabled = False
403
+
404
+ def get_llm_expansion(self, cache_key: str) -> dict[str, list[str]] | None:
405
+ if not self.enabled or not cache_key:
406
+ return None
407
+ self._ensure_schema()
408
+
409
+ connection = self._connect()
410
+ if connection is None:
411
+ return None
412
+
413
+ sql = """
414
+ SELECT expansion_json
415
+ FROM clip_llm_label_expansion_cache
416
+ WHERE cache_key = %s
417
+ LIMIT 1
418
+ """
419
+
420
+ try:
421
+ with connection.cursor() as cursor:
422
+ cursor.execute(sql, (cache_key,))
423
+ row = cursor.fetchone()
424
+ except Exception:
425
+ self.enabled = False
426
+ return None
427
+ if not row:
428
+ return None
429
+
430
+ raw = row.get("expansion_json")
431
+ if not isinstance(raw, str):
432
+ return None
433
+
434
+ try:
435
+ parsed = json.loads(raw)
436
+ except json.JSONDecodeError:
437
+ return None
438
+
439
+ if not isinstance(parsed, dict):
440
+ return None
441
+ return parsed
442
+
443
+ def save_llm_expansion(
444
+ self,
445
+ cache_key: str,
446
+ model_name: str,
447
+ top_labels: list[str],
448
+ expansion_payload: dict[str, list[str]],
449
+ ) -> None:
450
+ if not self.enabled or not cache_key:
451
+ return
452
+ self._ensure_schema()
453
+
454
+ connection = self._connect()
455
+ if connection is None:
456
+ return
457
+
458
+ sql = """
459
+ INSERT INTO clip_llm_label_expansion_cache (
460
+ cache_key, model_name, top_labels_json, expansion_json
461
+ ) VALUES (%s, %s, %s, %s)
462
+ ON DUPLICATE KEY UPDATE
463
+ model_name = VALUES(model_name),
464
+ top_labels_json = VALUES(top_labels_json),
465
+ expansion_json = VALUES(expansion_json),
466
+ updated_at = CURRENT_TIMESTAMP
467
+ """
468
+
469
+ try:
470
+ with connection.cursor() as cursor:
471
+ cursor.execute(
472
+ sql,
473
+ (
474
+ cache_key,
475
+ model_name,
476
+ json.dumps(top_labels, ensure_ascii=False),
477
+ json.dumps(expansion_payload, ensure_ascii=False),
478
+ ),
479
+ )
480
+ except Exception:
481
+ self.enabled = False
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from dotenv import load_dotenv
6
+
7
+
8
+ def load_project_env() -> None:
9
+ """Load local and project-root .env files once per process."""
10
+ current_dir = Path(__file__).resolve().parent
11
+ root_dir = current_dir.parent.parent
12
+
13
+ # Keep existing env precedence: do not overwrite already defined values.
14
+ load_dotenv(current_dir / ".env", override=False)
15
+ load_dotenv(root_dir / ".env", override=False)
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from typing import Any
7
+
8
+ from openai import OpenAI
9
+
10
+ from embedding_store import EmbeddingStore
11
+ from env_loader import load_project_env
12
+
13
+ load_project_env()
14
+
15
+ LLM_EXPANSION_MODEL = os.getenv("LLM_LABEL_EXPANSION_MODEL", "gpt-4.1-mini").strip() or "gpt-4.1-mini"
16
+ LLM_TIMEOUT_MS = max(1000, int(os.getenv("LLM_LABEL_EXPANSION_TIMEOUT_MS", "6000") or 6000))
17
+
18
+ _EMPTY_EXPANSION = {
19
+ "subtags": [],
20
+ "style_traits": [],
21
+ "emotions": [],
22
+ "pack_suggestions": [],
23
+ }
24
+
25
+
26
+ def _sanitize_list(values: Any, max_items: int = 12) -> list[str]:
27
+ if not isinstance(values, list):
28
+ return []
29
+ cleaned: list[str] = []
30
+ seen: set[str] = set()
31
+ for value in values:
32
+ text = str(value).strip()
33
+ if not text:
34
+ continue
35
+ key = text.lower()
36
+ if key in seen:
37
+ continue
38
+ cleaned.append(text)
39
+ seen.add(key)
40
+ if len(cleaned) >= max_items:
41
+ break
42
+ return cleaned
43
+
44
+
45
+ def _normalize_payload(payload: dict[str, Any] | None) -> dict[str, list[str]]:
46
+ source = payload or {}
47
+ return {
48
+ "subtags": _sanitize_list(source.get("subtags"), 20),
49
+ "style_traits": _sanitize_list(source.get("style_traits"), 12),
50
+ "emotions": _sanitize_list(source.get("emotions"), 10),
51
+ "pack_suggestions": _sanitize_list(source.get("pack_suggestions"), 12),
52
+ }
53
+
54
+
55
+ def _extract_json_dict(text: str) -> dict[str, Any] | None:
56
+ raw = (text or "").strip()
57
+ if not raw:
58
+ return None
59
+
60
+ # Accept markdown fenced JSON.
61
+ if raw.startswith("```"):
62
+ raw = raw.strip("`").strip()
63
+ if raw.lower().startswith("json"):
64
+ raw = raw[4:].strip()
65
+
66
+ start = raw.find("{")
67
+ end = raw.rfind("}")
68
+ if start < 0 or end <= start:
69
+ return None
70
+
71
+ candidate = raw[start : end + 1]
72
+ try:
73
+ parsed = json.loads(candidate)
74
+ except json.JSONDecodeError:
75
+ return None
76
+
77
+ return parsed if isinstance(parsed, dict) else None
78
+
79
+
80
+ def _build_cache_key(model_name: str, top_labels: list[str]) -> str:
81
+ payload = json.dumps({"model": model_name, "labels": top_labels}, ensure_ascii=False, separators=(",", ":"))
82
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
83
+
84
+
85
+ def expand_labels_with_llm(
86
+ *,
87
+ top_labels: list[str],
88
+ store: EmbeddingStore,
89
+ enabled: bool,
90
+ model_name: str = LLM_EXPANSION_MODEL,
91
+ ) -> dict[str, list[str]]:
92
+ if not enabled:
93
+ return dict(_EMPTY_EXPANSION)
94
+
95
+ clean_labels = [str(label).strip() for label in top_labels if str(label).strip()][:3]
96
+ if not clean_labels:
97
+ return dict(_EMPTY_EXPANSION)
98
+
99
+ api_key = os.getenv("OPENAI_API_KEY", "").strip()
100
+ if not api_key:
101
+ return dict(_EMPTY_EXPANSION)
102
+
103
+ cache_key = _build_cache_key(model_name=model_name, top_labels=clean_labels)
104
+ cached = store.get_llm_expansion(cache_key)
105
+ if isinstance(cached, dict):
106
+ return _normalize_payload(cached)
107
+
108
+ client = OpenAI(api_key=api_key, timeout=max(1.0, LLM_TIMEOUT_MS / 1000.0))
109
+
110
+ system_prompt = (
111
+ "You are a multimodal taxonomy assistant. "
112
+ "Return only valid JSON with keys: subtags, style_traits, emotions, pack_suggestions."
113
+ )
114
+ user_prompt = (
115
+ "Top labels from an image classifier: "
116
+ f"{json.dumps(clean_labels, ensure_ascii=False)}. "
117
+ "Generate semantic subtags, visual style traits, emotions and related pack themes. "
118
+ "Do not repeat labels verbatim unless needed. Keep each list concise."
119
+ )
120
+
121
+ try:
122
+ completion = client.chat.completions.create(
123
+ model=model_name,
124
+ response_format={"type": "json_object"},
125
+ messages=[
126
+ {"role": "system", "content": system_prompt},
127
+ {"role": "user", "content": user_prompt},
128
+ ],
129
+ temperature=0.2,
130
+ max_tokens=400,
131
+ )
132
+ content = completion.choices[0].message.content if completion.choices else ""
133
+ parsed = _extract_json_dict(content or "")
134
+ normalized = _normalize_payload(parsed)
135
+ except Exception:
136
+ normalized = dict(_EMPTY_EXPANSION)
137
+
138
+ store.save_llm_expansion(
139
+ cache_key=cache_key,
140
+ model_name=model_name,
141
+ top_labels=clean_labels,
142
+ expansion_payload=normalized,
143
+ )
144
+ return normalized