@kaikybrofc/omnizap-system 2.1.8

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 (166) hide show
  1. package/.env.example +534 -0
  2. package/LICENSE +21 -0
  3. package/README.md +431 -0
  4. package/RELEASE-v2.1.2.md +83 -0
  5. package/app/config/adminIdentity.js +87 -0
  6. package/app/config/baileysConfig.js +693 -0
  7. package/app/config/groupUtils.js +388 -0
  8. package/app/connection/socketController.js +992 -0
  9. package/app/controllers/messageController.js +354 -0
  10. package/app/modules/adminModule/groupCommandHandlers.js +1294 -0
  11. package/app/modules/adminModule/groupEventHandlers.js +355 -0
  12. package/app/modules/aiModule/catCommand.js +1006 -0
  13. package/app/modules/broadcastModule/noticeCommand.js +416 -0
  14. package/app/modules/gameModule/diceCommand.js +67 -0
  15. package/app/modules/menuModule/common.js +311 -0
  16. package/app/modules/menuModule/menus.js +59 -0
  17. package/app/modules/playModule/playCommand.js +1615 -0
  18. package/app/modules/quoteModule/quoteCommand.js +851 -0
  19. package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +786 -0
  20. package/app/modules/rpgPokemonModule/rpgBattleService.js +2082 -0
  21. package/app/modules/rpgPokemonModule/rpgBattleService.test.js +760 -0
  22. package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
  23. package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +172 -0
  24. package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
  25. package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
  26. package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
  27. package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
  28. package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1859 -0
  29. package/app/modules/rpgPokemonModule/rpgPokemonService.js +6738 -0
  30. package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
  31. package/app/modules/statsModule/globalRankingCommand.js +65 -0
  32. package/app/modules/statsModule/noMessageCommand.js +288 -0
  33. package/app/modules/statsModule/rankingCommand.js +60 -0
  34. package/app/modules/statsModule/rankingCommon.js +889 -0
  35. package/app/modules/stickerModule/addStickerMetadata.js +239 -0
  36. package/app/modules/stickerModule/convertToWebp.js +390 -0
  37. package/app/modules/stickerModule/stickerCommand.js +454 -0
  38. package/app/modules/stickerModule/stickerConvertCommand.js +156 -0
  39. package/app/modules/stickerModule/stickerTextCommand.js +657 -0
  40. package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
  41. package/app/modules/stickerPackModule/autoPackCollectorService.js +284 -0
  42. package/app/modules/stickerPackModule/semanticReclassificationEngine.js +466 -0
  43. package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +88 -0
  44. package/app/modules/stickerPackModule/semanticThemeClusterService.js +571 -0
  45. package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +449 -0
  46. package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
  47. package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +180 -0
  48. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +4078 -0
  49. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +598 -0
  50. package/app/modules/stickerPackModule/stickerClassificationService.js +588 -0
  51. package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +102 -0
  52. package/app/modules/stickerPackModule/stickerPackCatalogHttp.js +7506 -0
  53. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1095 -0
  54. package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +108 -0
  55. package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
  56. package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +110 -0
  57. package/app/modules/stickerPackModule/stickerPackItemRepository.js +440 -0
  58. package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +337 -0
  59. package/app/modules/stickerPackModule/stickerPackMessageService.js +296 -0
  60. package/app/modules/stickerPackModule/stickerPackRepository.js +442 -0
  61. package/app/modules/stickerPackModule/stickerPackService.js +788 -0
  62. package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +51 -0
  63. package/app/modules/stickerPackModule/stickerPackUtils.js +97 -0
  64. package/app/modules/stickerPackModule/stickerStorageService.js +507 -0
  65. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +233 -0
  66. package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +205 -0
  67. package/app/modules/systemMetricsModule/pingCommand.js +421 -0
  68. package/app/modules/tiktokModule/tiktokCommand.js +798 -0
  69. package/app/modules/userModule/userCommand.js +1217 -0
  70. package/app/modules/waifuPicsModule/waifuPicsCommand.js +177 -0
  71. package/app/observability/metrics.js +734 -0
  72. package/app/services/captchaService.js +492 -0
  73. package/app/services/dbWriteQueue.js +572 -0
  74. package/app/services/groupMetadataService.js +279 -0
  75. package/app/services/lidMapService.js +663 -0
  76. package/app/services/messagePersistenceService.js +56 -0
  77. package/app/services/newsBroadcastService.js +351 -0
  78. package/app/services/pokeApiService.js +398 -0
  79. package/app/services/queueUtils.js +57 -0
  80. package/app/services/socketState.js +7 -0
  81. package/app/store/aiPromptStore.js +38 -0
  82. package/app/store/groupConfigStore.js +58 -0
  83. package/app/store/premiumUserStore.js +36 -0
  84. package/app/utils/antiLink/antiLinkModule.js +804 -0
  85. package/app/utils/http/getImageBufferModule.js +18 -0
  86. package/app/utils/json/jsonSanitizer.js +113 -0
  87. package/app/utils/json/jsonSanitizer.test.js +40 -0
  88. package/app/utils/logger/loggerModule.js +262 -0
  89. package/app/utils/systemMetrics/systemMetricsModule.js +91 -0
  90. package/database/index.js +2052 -0
  91. package/database/init.js +516 -0
  92. package/database/migrations/20260203_0001_sticker_packs.sql +54 -0
  93. package/database/migrations/20260210_0003_rpg_pokemon.sql +58 -0
  94. package/database/migrations/20260210_0004_rpg_shiny_biome.sql +9 -0
  95. package/database/migrations/20260210_0005_rpg_missions.sql +14 -0
  96. package/database/migrations/20260210_0006_rpg_world_pokedex_traits.sql +27 -0
  97. package/database/migrations/20260210_0007_rpg_raid_pvp.sql +56 -0
  98. package/database/migrations/20260210_0008_rpg_social_system.sql +195 -0
  99. package/database/migrations/20260211_0009_rpg_social_xp.sql +36 -0
  100. package/database/migrations/20260222_0010_remove_message_xp.sql +2 -0
  101. package/database/migrations/20260226_0011_sticker_asset_classification.sql +17 -0
  102. package/database/migrations/20260226_0012_sticker_pack_engagement.sql +16 -0
  103. package/database/migrations/20260226_0013_sticker_marketplace_intelligence.sql +19 -0
  104. package/database/migrations/20260226_0014_sticker_pack_publish_flow.sql +30 -0
  105. package/database/migrations/20260226_0014_sticker_worker_queues.sql +42 -0
  106. package/database/migrations/20260226_0015_sticker_auto_pack_curation_integrity.sql +18 -0
  107. package/database/migrations/20260226_0016_sticker_web_google_auth_persistence.sql +34 -0
  108. package/database/migrations/20260226_0017_sticker_web_admin_ban.sql +22 -0
  109. package/database/migrations/20260226_0018_sticker_web_admin_moderator.sql +18 -0
  110. package/database/migrations/20260227_0019_sticker_classification_v2_signals.sql +12 -0
  111. package/database/migrations/20260227_0020_semantic_theme_clusters.sql +35 -0
  112. package/docker-compose.yml +103 -0
  113. package/ecosystem.prod.config.cjs +35 -0
  114. package/eslint.config.js +61 -0
  115. package/index.js +437 -0
  116. package/ml/clip_classifier/Dockerfile +16 -0
  117. package/ml/clip_classifier/README.md +120 -0
  118. package/ml/clip_classifier/adaptive_scoring.py +40 -0
  119. package/ml/clip_classifier/classifier.py +654 -0
  120. package/ml/clip_classifier/embedding_store.py +481 -0
  121. package/ml/clip_classifier/env_loader.py +15 -0
  122. package/ml/clip_classifier/llm_label_expander.py +144 -0
  123. package/ml/clip_classifier/main.py +213 -0
  124. package/ml/clip_classifier/requirements.txt +10 -0
  125. package/ml/clip_classifier/similarity_engine.py +74 -0
  126. package/observability/alert-rules.yml +60 -0
  127. package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
  128. package/observability/grafana/dashboards/omnizap-overview.json +170 -0
  129. package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
  130. package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
  131. package/observability/loki-config.yml +38 -0
  132. package/observability/mysql-exporter.cnf +5 -0
  133. package/observability/mysql-setup.sql +46 -0
  134. package/observability/prometheus.yml +32 -0
  135. package/observability/promtail-config.yml +84 -0
  136. package/package.json +109 -0
  137. package/public/api-docs/index.html +144 -0
  138. package/public/css/github-project-panel.css +297 -0
  139. package/public/css/stickers-admin.css +1272 -0
  140. package/public/css/styles.css +671 -0
  141. package/public/index.html +1311 -0
  142. package/public/js/apps/apiDocsApp.js +310 -0
  143. package/public/js/apps/createPackApp.js +2069 -0
  144. package/public/js/apps/homeApp.js +396 -0
  145. package/public/js/apps/stickersAdminApp.js +1744 -0
  146. package/public/js/apps/stickersApp.js +4830 -0
  147. package/public/js/catalog.js +1019 -0
  148. package/public/js/github-panel/components/CommitList.js +34 -0
  149. package/public/js/github-panel/components/ErrorState.js +16 -0
  150. package/public/js/github-panel/components/GithubProjectPanel.js +106 -0
  151. package/public/js/github-panel/components/ReleaseList.js +38 -0
  152. package/public/js/github-panel/components/SkeletonPanel.js +22 -0
  153. package/public/js/github-panel/components/StatCard.js +15 -0
  154. package/public/js/github-panel/index.js +15 -0
  155. package/public/js/github-panel/useGithubRepoData.js +154 -0
  156. package/public/js/github-panel/vendor/react.js +11 -0
  157. package/public/js/runtime/react-runtime.js +19 -0
  158. package/public/licenca/index.html +106 -0
  159. package/public/stickers/admin/index.html +23 -0
  160. package/public/stickers/create/index.html +47 -0
  161. package/public/stickers/index.html +48 -0
  162. package/public/termos-de-uso/index.html +125 -0
  163. package/scripts/cache-bust.mjs +107 -0
  164. package/scripts/deploy.sh +458 -0
  165. package/scripts/github-deploy-notify.mjs +174 -0
  166. package/scripts/release.sh +129 -0
@@ -0,0 +1,588 @@
1
+ import logger from '../../utils/logger/loggerModule.js';
2
+ import {
3
+ findStickerClassificationByAssetId,
4
+ listStickerClassificationsByAssetIds,
5
+ upsertStickerAssetClassification,
6
+ } from './stickerAssetClassificationRepository.js';
7
+ import { enqueueSemanticClusterResolution } from './semanticThemeClusterService.js';
8
+
9
+ const parseEnvBool = (value, fallback) => {
10
+ if (value === undefined || value === null || value === '') return fallback;
11
+ const normalized = String(value).trim().toLowerCase();
12
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
13
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
14
+ return fallback;
15
+ };
16
+
17
+ const CLIP_CLASSIFIER_ENABLED = parseEnvBool(process.env.CLIP_CLASSIFIER_ENABLED, true);
18
+ const CLIP_CLASSIFIER_API_URL =
19
+ String(process.env.CLIP_CLASSIFIER_API_URL || 'http://127.0.0.1:8008/classify').trim() ||
20
+ 'http://127.0.0.1:8008/classify';
21
+ const CLIP_CLASSIFIER_FEEDBACK_API_URL = String(
22
+ process.env.CLIP_CLASSIFIER_FEEDBACK_API_URL
23
+ || CLIP_CLASSIFIER_API_URL.replace(/\/classify\/?$/i, '/feedback'),
24
+ ).trim();
25
+ const CLIP_CLASSIFIER_TIMEOUT_MS = Math.max(500, Number(process.env.CLIP_CLASSIFIER_TIMEOUT_MS) || 3000);
26
+ const CLIP_CLASSIFIER_PROVIDER = String(process.env.CLIP_CLASSIFIER_PROVIDER || 'clip').trim() || 'clip';
27
+ const CLIP_CLASSIFIER_CLASSIFICATION_VERSION =
28
+ String(process.env.CLIP_CLASSIFIER_CLASSIFICATION_VERSION || process.env.CLIP_CLASSIFIER_MODEL_VERSION || 'v1').trim() || 'v1';
29
+ const CLIP_CLASSIFIER_NSFW_THRESHOLD = Number.isFinite(Number(process.env.CLIP_CLASSIFIER_NSFW_THRESHOLD))
30
+ ? Number(process.env.CLIP_CLASSIFIER_NSFW_THRESHOLD)
31
+ : null;
32
+ const STICKER_TAG_MIN_SCORE = Number.isFinite(Number(process.env.STICKER_CLASSIFICATION_TAG_MIN_SCORE))
33
+ ? Number(process.env.STICKER_CLASSIFICATION_TAG_MIN_SCORE)
34
+ : 0.2;
35
+ const PACK_TAG_MIN_SCORE = Number.isFinite(Number(process.env.PACK_CLASSIFICATION_TAG_MIN_SCORE))
36
+ ? Number(process.env.PACK_CLASSIFICATION_TAG_MIN_SCORE)
37
+ : 0.18;
38
+ const MAX_TAGS_PER_ENTITY = Math.max(1, Math.min(10, Number(process.env.CLASSIFICATION_MAX_TAGS) || 6));
39
+
40
+ const LABEL_TO_TAG = {
41
+ 'anime illustration': 'anime',
42
+ 'video game screenshot': 'game',
43
+ 'real life photo': 'foto-real',
44
+ 'nsfw content': 'nsfw',
45
+ cartoon: 'cartoon',
46
+ };
47
+
48
+ const normalizeTag = (value) => {
49
+ const raw = String(value || '')
50
+ .trim()
51
+ .toLowerCase();
52
+ if (!raw) return '';
53
+ return raw
54
+ .normalize('NFD')
55
+ .replace(/[\u0300-\u036f]/g, '')
56
+ .replace(/[^a-z0-9]+/g, '-')
57
+ .replace(/^-+|-+$/g, '')
58
+ .slice(0, 40);
59
+ };
60
+
61
+ const mapLabelToTag = (label) => {
62
+ const key = String(label || '').trim().toLowerCase();
63
+ return LABEL_TO_TAG[key] || normalizeTag(key);
64
+ };
65
+
66
+ const normalizeScores = (scores) => {
67
+ const entries = Object.entries(scores || {}).filter(([, value]) => Number.isFinite(Number(value)));
68
+ entries.sort((left, right) => Number(right[1]) - Number(left[1]));
69
+
70
+ const normalized = {};
71
+ for (const [label, value] of entries) {
72
+ normalized[String(label)] = Number(Number(value).toFixed(6));
73
+ }
74
+ return normalized;
75
+ };
76
+
77
+ const normalizeListOfStrings = (values, max = 30) => {
78
+ if (!Array.isArray(values)) return [];
79
+ const list = [];
80
+ const seen = new Set();
81
+ for (const value of values) {
82
+ const normalized = String(value || '').trim();
83
+ if (!normalized) continue;
84
+ const key = normalized.toLowerCase();
85
+ if (seen.has(key)) continue;
86
+ seen.add(key);
87
+ list.push(normalized);
88
+ if (list.length >= max) break;
89
+ }
90
+ return list;
91
+ };
92
+
93
+ const normalizeTopLabels = (entries) => {
94
+ if (!Array.isArray(entries)) return [];
95
+ const normalized = [];
96
+ for (const entry of entries) {
97
+ const label = String(entry?.label || '').trim();
98
+ const score = Number(entry?.score);
99
+ if (!label || !Number.isFinite(score)) continue;
100
+ normalized.push({
101
+ label,
102
+ score: Number(score.toFixed(6)),
103
+ logit: Number.isFinite(Number(entry?.logit)) ? Number(Number(entry.logit).toFixed(6)) : null,
104
+ clip_score: Number.isFinite(Number(entry?.clip_score)) ? Number(Number(entry.clip_score).toFixed(6)) : null,
105
+ });
106
+ }
107
+ normalized.sort((a, b) => b.score - a.score || String(a.label).localeCompare(String(b.label)));
108
+ return normalized.slice(0, 10);
109
+ };
110
+
111
+ const normalizeClassificationResult = (payload) => {
112
+ const allScores = normalizeScores(payload?.all_scores || {});
113
+ const topLabels = normalizeTopLabels(payload?.top_labels || []);
114
+ const explicitCategory = String(payload?.category || '').trim();
115
+ const scoreEntries = Object.entries(allScores);
116
+ const topFromScores = scoreEntries[0] || null;
117
+ const topFromLabels = topLabels[0] || null;
118
+ const category = explicitCategory || topFromLabels?.label || topFromScores?.[0] || null;
119
+ const confidence = Number.isFinite(Number(payload?.confidence))
120
+ ? Number(Number(payload.confidence).toFixed(6))
121
+ : Number.isFinite(Number(topFromLabels?.score))
122
+ ? Number(Number(topFromLabels.score).toFixed(6))
123
+ : topFromScores
124
+ ? Number(Number(topFromScores[1]).toFixed(6))
125
+ : null;
126
+ const nsfwScore = Number.isFinite(Number(payload?.nsfw_score)) ? Number(Number(payload.nsfw_score).toFixed(6)) : null;
127
+ const entropy = Number.isFinite(Number(payload?.entropy)) ? Number(Number(payload.entropy).toFixed(6)) : null;
128
+ const entropyNormalized = Number.isFinite(Number(payload?.entropy_normalized))
129
+ ? Number(Number(payload.entropy_normalized).toFixed(6))
130
+ : null;
131
+ const confidenceMargin = Number.isFinite(Number(payload?.confidence_margin))
132
+ ? Number(Number(payload.confidence_margin).toFixed(6))
133
+ : null;
134
+ const affinityWeight = Number.isFinite(Number(payload?.affinity_weight))
135
+ ? Number(Number(payload.affinity_weight).toFixed(6))
136
+ : null;
137
+ const affinityWeightRaw = Number.isFinite(Number(payload?.affinity_weight_raw))
138
+ ? Number(Number(payload.affinity_weight_raw).toFixed(6))
139
+ : null;
140
+ const imageHash = String(payload?.image_hash || '').trim().toLowerCase();
141
+
142
+ const llmExpansion = payload?.llm_expansion && typeof payload.llm_expansion === 'object'
143
+ ? payload.llm_expansion
144
+ : {};
145
+ const llmSubtags = normalizeListOfStrings(llmExpansion?.subtags, 40);
146
+ const llmStyleTraits = normalizeListOfStrings(llmExpansion?.style_traits, 20);
147
+ const llmEmotions = normalizeListOfStrings(llmExpansion?.emotions, 20);
148
+ const llmPackSuggestions = normalizeListOfStrings(llmExpansion?.pack_suggestions, 30);
149
+
150
+ const similarImages = Array.isArray(payload?.similar_images)
151
+ ? payload.similar_images
152
+ .map((entry) => ({
153
+ image_hash: String(entry?.image_hash || '').trim().toLowerCase(),
154
+ asset_id: entry?.asset_id ? String(entry.asset_id) : null,
155
+ similarity: Number.isFinite(Number(entry?.similarity)) ? Number(Number(entry.similarity).toFixed(6)) : null,
156
+ }))
157
+ .filter((entry) => entry.image_hash && Number.isFinite(Number(entry.similarity)))
158
+ .slice(0, 40)
159
+ : [];
160
+
161
+ return {
162
+ category,
163
+ confidence,
164
+ entropy,
165
+ entropy_normalized: entropyNormalized,
166
+ confidence_margin: confidenceMargin,
167
+ affinity_weight: affinityWeight,
168
+ affinity_weight_raw: affinityWeightRaw,
169
+ all_scores: allScores,
170
+ top_labels: topLabels,
171
+ nsfw_score: nsfwScore,
172
+ is_nsfw: payload?.is_nsfw === true || payload?.is_nsfw === 1,
173
+ ambiguous: payload?.ambiguous === true || payload?.ambiguous === 1,
174
+ image_hash: imageHash || null,
175
+ llm_subtags: llmSubtags,
176
+ llm_style_traits: llmStyleTraits,
177
+ llm_emotions: llmEmotions,
178
+ llm_pack_suggestions: llmPackSuggestions,
179
+ similar_images: similarImages,
180
+ model_name: payload?.model || payload?.model_name || null,
181
+ };
182
+ };
183
+
184
+ const NON_RETRYABLE_CLASSIFIER_HTTP_STATUSES = new Set([400, 413, 415, 422]);
185
+
186
+ const isClassifierNonRetryableError = (error) => {
187
+ const status = Number(error?.status || 0);
188
+ if (NON_RETRYABLE_CLASSIFIER_HTTP_STATUSES.has(status)) return true;
189
+
190
+ const message = String(error?.message || '').toLowerCase();
191
+ return (
192
+ message.includes('could not create decoder object')
193
+ || message.includes('nao foi possivel decodificar a imagem')
194
+ || message.includes('não foi possível decodificar a imagem')
195
+ );
196
+ };
197
+
198
+ const buildFallbackClassificationFromError = ({ asset, error }) => {
199
+ const message = String(error?.message || '').toLowerCase();
200
+ const reasonTag = message.includes('decoder') || message.includes('decodificar')
201
+ ? 'decoder-error'
202
+ : 'non-retryable-error';
203
+
204
+ return {
205
+ asset_id: asset.id,
206
+ provider: CLIP_CLASSIFIER_PROVIDER,
207
+ model_name: null,
208
+ classification_version: CLIP_CLASSIFIER_CLASSIFICATION_VERSION,
209
+ category: 'invalid image',
210
+ confidence: 0,
211
+ entropy: 0,
212
+ confidence_margin: 0,
213
+ top_labels: [{
214
+ label: 'invalid image',
215
+ score: 1,
216
+ logit: null,
217
+ clip_score: null,
218
+ }],
219
+ affinity_weight: 0,
220
+ image_hash: asset?.sha256 || null,
221
+ ambiguous: true,
222
+ nsfw_score: 0,
223
+ is_nsfw: false,
224
+ all_scores: { 'invalid image': 1 },
225
+ llm_subtags: ['classification-failed', reasonTag],
226
+ llm_style_traits: [],
227
+ llm_emotions: [],
228
+ llm_pack_suggestions: [],
229
+ similar_images: [],
230
+ };
231
+ };
232
+
233
+ export const buildStickerTags = (classification) => {
234
+ if (!classification || typeof classification !== 'object') return [];
235
+
236
+ const tags = [];
237
+ const pushTag = (tag) => {
238
+ const normalized = normalizeTag(tag);
239
+ if (!normalized) return;
240
+ if (!tags.includes(normalized)) tags.push(normalized);
241
+ };
242
+
243
+ if (classification.is_nsfw) {
244
+ pushTag('nsfw');
245
+ }
246
+
247
+ if (classification.category) {
248
+ pushTag(mapLabelToTag(classification.category));
249
+ }
250
+
251
+ const orderedScores = Object.entries(classification.all_scores || {})
252
+ .filter(([, value]) => Number(value) >= STICKER_TAG_MIN_SCORE)
253
+ .sort((left, right) => Number(right[1]) - Number(left[1]));
254
+
255
+ for (const [label] of orderedScores) {
256
+ pushTag(mapLabelToTag(label));
257
+ if (tags.length >= MAX_TAGS_PER_ENTITY) break;
258
+ }
259
+
260
+ return tags.slice(0, MAX_TAGS_PER_ENTITY);
261
+ };
262
+
263
+ export const decorateStickerClassification = (classification) => {
264
+ if (!classification || typeof classification !== 'object') return classification || null;
265
+ return {
266
+ ...classification,
267
+ tags: buildStickerTags(classification),
268
+ };
269
+ };
270
+
271
+ const classifyBufferViaHttp = async (buffer, filename = 'sticker.webp', metadata = {}) => {
272
+ if (!CLIP_CLASSIFIER_ENABLED) return null;
273
+ if (!Buffer.isBuffer(buffer) || !buffer.length) return null;
274
+
275
+ if (typeof globalThis.fetch !== 'function' || typeof globalThis.FormData !== 'function') {
276
+ throw new Error('fetch/FormData indisponivel neste runtime Node.');
277
+ }
278
+
279
+ const form = new globalThis.FormData();
280
+ form.append('file', new globalThis.Blob([buffer], { type: 'image/webp' }), filename);
281
+ if (CLIP_CLASSIFIER_NSFW_THRESHOLD !== null) {
282
+ form.append('nsfw_threshold', String(CLIP_CLASSIFIER_NSFW_THRESHOLD));
283
+ }
284
+ if (metadata?.assetId) {
285
+ form.append('asset_id', String(metadata.assetId));
286
+ }
287
+ if (metadata?.assetSha256) {
288
+ form.append('asset_sha256', String(metadata.assetSha256));
289
+ }
290
+ if (metadata?.theme) {
291
+ form.append('theme', String(metadata.theme));
292
+ }
293
+
294
+ const controller = typeof globalThis.AbortController === 'function' ? new globalThis.AbortController() : null;
295
+ const timeout = setTimeout(() => controller?.abort(), CLIP_CLASSIFIER_TIMEOUT_MS);
296
+
297
+ try {
298
+ const response = await globalThis.fetch(CLIP_CLASSIFIER_API_URL, {
299
+ method: 'POST',
300
+ body: form,
301
+ signal: controller?.signal,
302
+ });
303
+
304
+ if (!response.ok) {
305
+ const raw = await response.text().catch(() => '');
306
+ const error = new Error(`Classifier HTTP ${response.status}${raw ? `: ${raw.slice(0, 200)}` : ''}`);
307
+ error.status = Number(response.status || 0);
308
+ error.response_body = raw ? String(raw).slice(0, 500) : '';
309
+ throw error;
310
+ }
311
+
312
+ const json = await response.json();
313
+ return normalizeClassificationResult(json);
314
+ } finally {
315
+ clearTimeout(timeout);
316
+ }
317
+ };
318
+
319
+ export async function classifyStickerAssetBuffer(buffer, filename = 'sticker.webp') {
320
+ return classifyBufferViaHttp(buffer, filename);
321
+ }
322
+
323
+ export async function ensureStickerAssetClassified({ asset, buffer, force = false }) {
324
+ if (!CLIP_CLASSIFIER_ENABLED) return null;
325
+ if (!asset?.id || !Buffer.isBuffer(buffer) || !buffer.length) return null;
326
+
327
+ if (!force) {
328
+ const cached = await findStickerClassificationByAssetId(asset.id);
329
+ if (cached) {
330
+ enqueueSemanticClusterResolution({
331
+ assetId: asset.id,
332
+ suggestions: cached.llm_pack_suggestions || [],
333
+ fallbackText: cached.category || '',
334
+ force: false,
335
+ });
336
+ return cached;
337
+ }
338
+ }
339
+
340
+ let inference = null;
341
+ try {
342
+ inference = await classifyBufferViaHttp(buffer, `${asset.id}.webp`, {
343
+ assetId: asset.id,
344
+ assetSha256: asset.sha256 || null,
345
+ });
346
+ } catch (error) {
347
+ if (!isClassifierNonRetryableError(error)) {
348
+ throw error;
349
+ }
350
+
351
+ const fallbackPayload = buildFallbackClassificationFromError({ asset, error });
352
+ const persistedFallback = await upsertStickerAssetClassification(fallbackPayload);
353
+
354
+ logger.warn('Asset marcado com classificação fallback após erro não recuperável do classificador.', {
355
+ action: 'sticker_asset_classify_non_retryable_fallback',
356
+ asset_id: asset.id,
357
+ owner_jid: asset.owner_jid || null,
358
+ status: Number(error?.status || 0) || null,
359
+ error: error?.message,
360
+ });
361
+
362
+ return persistedFallback;
363
+ }
364
+
365
+ if (!inference) return null;
366
+
367
+ const persisted = await upsertStickerAssetClassification({
368
+ asset_id: asset.id,
369
+ provider: CLIP_CLASSIFIER_PROVIDER,
370
+ model_name: inference.model_name,
371
+ classification_version: CLIP_CLASSIFIER_CLASSIFICATION_VERSION,
372
+ category: inference.category,
373
+ confidence: inference.confidence,
374
+ entropy: inference.entropy,
375
+ confidence_margin: inference.confidence_margin,
376
+ top_labels: inference.top_labels,
377
+ affinity_weight: inference.affinity_weight,
378
+ image_hash: inference.image_hash || asset.sha256 || null,
379
+ ambiguous: inference.ambiguous,
380
+ nsfw_score: inference.nsfw_score,
381
+ is_nsfw: inference.is_nsfw,
382
+ all_scores: inference.all_scores,
383
+ llm_subtags: inference.llm_subtags,
384
+ llm_style_traits: inference.llm_style_traits,
385
+ llm_emotions: inference.llm_emotions,
386
+ llm_pack_suggestions: inference.llm_pack_suggestions,
387
+ similar_images: inference.similar_images,
388
+ });
389
+
390
+ enqueueSemanticClusterResolution({
391
+ assetId: asset.id,
392
+ suggestions: inference.llm_pack_suggestions || [],
393
+ fallbackText: inference.category || '',
394
+ force: Boolean(force),
395
+ });
396
+
397
+ return persisted;
398
+ }
399
+
400
+ const emptyAggregation = (totalItems = 0) => ({
401
+ total_items: totalItems,
402
+ classified_items: 0,
403
+ category: null,
404
+ confidence: null,
405
+ majority_category: null,
406
+ majority_ratio: null,
407
+ average_scores: {},
408
+ categories: [],
409
+ nsfw: {
410
+ avg_score: null,
411
+ max_score: null,
412
+ flagged_items: 0,
413
+ },
414
+ });
415
+
416
+ const aggregateClassifications = (entries = [], totalItems = 0) => {
417
+ if (!entries.length) return emptyAggregation(totalItems);
418
+
419
+ const scoreSums = new Map();
420
+ const categoryVotes = new Map();
421
+ let nsfwSum = 0;
422
+ let nsfwCount = 0;
423
+ let nsfwMax = null;
424
+ let nsfwFlagged = 0;
425
+
426
+ for (const entry of entries) {
427
+ const category = String(entry?.category || '').trim();
428
+ if (category) {
429
+ categoryVotes.set(category, (categoryVotes.get(category) || 0) + 1);
430
+ }
431
+
432
+ for (const [label, value] of Object.entries(entry?.all_scores || {})) {
433
+ const numericValue = Number(value);
434
+ if (!Number.isFinite(numericValue)) continue;
435
+ scoreSums.set(label, (scoreSums.get(label) || 0) + numericValue);
436
+ }
437
+
438
+ if (Number.isFinite(Number(entry?.nsfw_score))) {
439
+ const score = Number(entry.nsfw_score);
440
+ nsfwSum += score;
441
+ nsfwCount += 1;
442
+ nsfwMax = nsfwMax === null ? score : Math.max(nsfwMax, score);
443
+ }
444
+
445
+ if (entry?.is_nsfw) nsfwFlagged += 1;
446
+ }
447
+
448
+ const classifiedItems = entries.length;
449
+ const averageScores = {};
450
+ const scoreRank = [];
451
+
452
+ for (const [label, total] of scoreSums.entries()) {
453
+ const avg = total / classifiedItems;
454
+ const rounded = Number(avg.toFixed(6));
455
+ averageScores[label] = rounded;
456
+ scoreRank.push([label, rounded]);
457
+ }
458
+
459
+ scoreRank.sort((left, right) => right[1] - left[1]);
460
+
461
+ const categoryRank = Array.from(categoryVotes.entries())
462
+ .map(([label, count]) => ({
463
+ label,
464
+ count,
465
+ ratio: Number((count / classifiedItems).toFixed(6)),
466
+ }))
467
+ .sort((left, right) => right.count - left.count || left.label.localeCompare(right.label));
468
+
469
+ const majority = categoryRank[0] || null;
470
+ const topAverage = scoreRank[0] || null;
471
+
472
+ return {
473
+ total_items: totalItems,
474
+ classified_items: classifiedItems,
475
+ category: topAverage?.[0] || majority?.label || null,
476
+ confidence: topAverage ? Number(topAverage[1].toFixed(6)) : majority ? Number(majority.ratio.toFixed(6)) : null,
477
+ majority_category: majority?.label || null,
478
+ majority_ratio: majority ? Number(majority.ratio.toFixed(6)) : null,
479
+ average_scores: averageScores,
480
+ categories: categoryRank,
481
+ nsfw: {
482
+ avg_score: nsfwCount > 0 ? Number((nsfwSum / nsfwCount).toFixed(6)) : null,
483
+ max_score: nsfwMax !== null ? Number(nsfwMax.toFixed(6)) : null,
484
+ flagged_items: nsfwFlagged,
485
+ },
486
+ };
487
+ };
488
+
489
+ export const buildPackTags = (aggregation) => {
490
+ if (!aggregation || typeof aggregation !== 'object') return [];
491
+
492
+ const tags = [];
493
+ const pushTag = (tag) => {
494
+ const normalized = normalizeTag(tag);
495
+ if (!normalized) return;
496
+ if (!tags.includes(normalized)) tags.push(normalized);
497
+ };
498
+
499
+ if (Number(aggregation?.nsfw?.flagged_items || 0) > 0) {
500
+ pushTag('nsfw');
501
+ }
502
+
503
+ if (aggregation.majority_category) {
504
+ pushTag(mapLabelToTag(aggregation.majority_category));
505
+ }
506
+
507
+ const orderedAverageScores = Object.entries(aggregation.average_scores || {})
508
+ .filter(([, value]) => Number(value) >= PACK_TAG_MIN_SCORE)
509
+ .sort((left, right) => Number(right[1]) - Number(left[1]));
510
+
511
+ for (const [label] of orderedAverageScores) {
512
+ pushTag(mapLabelToTag(label));
513
+ if (tags.length >= MAX_TAGS_PER_ENTITY) break;
514
+ }
515
+
516
+ return tags.slice(0, MAX_TAGS_PER_ENTITY);
517
+ };
518
+
519
+ export const decoratePackClassificationSummary = (aggregation) => {
520
+ if (!aggregation || typeof aggregation !== 'object') return aggregation || null;
521
+ return {
522
+ ...aggregation,
523
+ tags: buildPackTags(aggregation),
524
+ };
525
+ };
526
+
527
+ export async function getPackClassificationSummaryByAssetIds(assetIds) {
528
+ const normalizedIds = Array.from(new Set((Array.isArray(assetIds) ? assetIds : []).filter(Boolean)));
529
+ if (!normalizedIds.length) return emptyAggregation(0);
530
+
531
+ const classifications = await listStickerClassificationsByAssetIds(normalizedIds);
532
+ return decoratePackClassificationSummary(aggregateClassifications(classifications, normalizedIds.length));
533
+ }
534
+
535
+ export const classifierConfig = {
536
+ enabled: CLIP_CLASSIFIER_ENABLED,
537
+ api_url: CLIP_CLASSIFIER_API_URL,
538
+ feedback_api_url: CLIP_CLASSIFIER_FEEDBACK_API_URL,
539
+ timeout_ms: CLIP_CLASSIFIER_TIMEOUT_MS,
540
+ nsfw_threshold: CLIP_CLASSIFIER_NSFW_THRESHOLD,
541
+ classification_version: CLIP_CLASSIFIER_CLASSIFICATION_VERSION,
542
+ };
543
+
544
+ export const submitStickerClassificationFeedback = async ({
545
+ imageHash,
546
+ theme,
547
+ accepted,
548
+ assetId = null,
549
+ }) => {
550
+ if (!CLIP_CLASSIFIER_ENABLED) return false;
551
+ const normalizedHash = String(imageHash || '').trim().toLowerCase();
552
+ const normalizedTheme = String(theme || '').trim().toLowerCase();
553
+ if (!normalizedHash || !normalizedTheme) return false;
554
+ if (typeof globalThis.fetch !== 'function') return false;
555
+
556
+ const controller = typeof globalThis.AbortController === 'function' ? new globalThis.AbortController() : null;
557
+ const timeout = setTimeout(() => controller?.abort(), Math.max(500, CLIP_CLASSIFIER_TIMEOUT_MS));
558
+ try {
559
+ const response = await globalThis.fetch(CLIP_CLASSIFIER_FEEDBACK_API_URL, {
560
+ method: 'POST',
561
+ headers: { 'content-type': 'application/json' },
562
+ signal: controller?.signal,
563
+ body: JSON.stringify({
564
+ image_hash: normalizedHash,
565
+ theme: normalizedTheme,
566
+ accepted: Boolean(accepted),
567
+ asset_id: assetId || null,
568
+ }),
569
+ });
570
+ return response.ok;
571
+ } catch {
572
+ return false;
573
+ } finally {
574
+ clearTimeout(timeout);
575
+ }
576
+ };
577
+
578
+ export const classifyStickerAssetBufferSafe = async (buffer, filename) => {
579
+ try {
580
+ return await classifyStickerAssetBuffer(buffer, filename);
581
+ } catch (error) {
582
+ logger.warn('Falha ao classificar figurinha via CLIP.', {
583
+ action: 'sticker_classify_failed',
584
+ error: error?.message,
585
+ });
586
+ return null;
587
+ }
588
+ };
@@ -0,0 +1,102 @@
1
+ import { listClassificationCategoryDistribution } from './stickerAssetClassificationRepository.js';
2
+
3
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
4
+
5
+ const REFRESH_MS = Math.max(30_000, Number(process.env.STICKER_DRIFT_REFRESH_MS) || 10 * 60 * 1000);
6
+ const DIVERGENCE_SENSITIVITY = clamp(Number(process.env.STICKER_DRIFT_SENSITIVITY || 1), 0.5, 2.5);
7
+
8
+ const BASE_WEIGHTS = Object.freeze({
9
+ classification: 0.4,
10
+ engagement: 0.3,
11
+ quality: 0.2,
12
+ diversity: 0.1,
13
+ });
14
+
15
+ let cache = {
16
+ expiresAt: 0,
17
+ weights: BASE_WEIGHTS,
18
+ driftScore: 0,
19
+ distribution7d: {},
20
+ distribution30d: {},
21
+ };
22
+
23
+ const toProbabilityMap = (distribution) => {
24
+ const total = Number(distribution?.total || 0);
25
+ const categories = distribution?.categories instanceof Map ? distribution.categories : new Map();
26
+ const map = new Map();
27
+ if (!total || categories.size === 0) return map;
28
+
29
+ for (const [category, count] of categories.entries()) {
30
+ const probability = Number(count) / total;
31
+ if (probability > 0) {
32
+ map.set(category, probability);
33
+ }
34
+ }
35
+ return map;
36
+ };
37
+
38
+ const computeL1Divergence = (left, right) => {
39
+ const keys = new Set([...left.keys(), ...right.keys()]);
40
+ if (!keys.size) return 0;
41
+
42
+ let sumAbs = 0;
43
+ for (const key of keys) {
44
+ sumAbs += Math.abs((left.get(key) || 0) - (right.get(key) || 0));
45
+ }
46
+
47
+ return clamp(sumAbs / 2, 0, 1);
48
+ };
49
+
50
+ const normalizeWeights = (weights) => {
51
+ const total = Object.values(weights).reduce((sum, value) => sum + value, 0);
52
+ if (!total || !Number.isFinite(total)) return BASE_WEIGHTS;
53
+ return {
54
+ classification: Number((weights.classification / total).toFixed(6)),
55
+ engagement: Number((weights.engagement / total).toFixed(6)),
56
+ quality: Number((weights.quality / total).toFixed(6)),
57
+ diversity: Number((weights.diversity / total).toFixed(6)),
58
+ };
59
+ };
60
+
61
+ const buildDynamicWeights = (driftScore) => {
62
+ const adjustedDrift = clamp(driftScore * DIVERGENCE_SENSITIVITY, 0, 1);
63
+ const classificationShift = clamp(BASE_WEIGHTS.classification - adjustedDrift * 0.1, 0.25, 0.45);
64
+ const engagementShift = clamp(BASE_WEIGHTS.engagement + adjustedDrift * 0.08, 0.25, 0.42);
65
+ const qualityShift = clamp(BASE_WEIGHTS.quality + adjustedDrift * 0.01, 0.15, 0.3);
66
+ const diversityShift = clamp(BASE_WEIGHTS.diversity + adjustedDrift * 0.01, 0.08, 0.2);
67
+ return normalizeWeights({
68
+ classification: classificationShift,
69
+ engagement: engagementShift,
70
+ quality: qualityShift,
71
+ diversity: diversityShift,
72
+ });
73
+ };
74
+
75
+ export const getBaseMarketplaceWeights = () => BASE_WEIGHTS;
76
+
77
+ export const getMarketplaceDriftSnapshot = async ({ force = false } = {}) => {
78
+ const now = Date.now();
79
+ if (!force && now < cache.expiresAt) {
80
+ return cache;
81
+ }
82
+
83
+ const [distribution7d, distribution30d] = await Promise.all([
84
+ listClassificationCategoryDistribution({ days: 7 }),
85
+ listClassificationCategoryDistribution({ days: 30 }),
86
+ ]);
87
+
88
+ const probability7d = toProbabilityMap(distribution7d);
89
+ const probability30d = toProbabilityMap(distribution30d);
90
+ const driftScore = Number(computeL1Divergence(probability7d, probability30d).toFixed(6));
91
+ const weights = buildDynamicWeights(driftScore);
92
+
93
+ cache = {
94
+ expiresAt: now + REFRESH_MS,
95
+ weights,
96
+ driftScore,
97
+ distribution7d: Object.fromEntries(probability7d.entries()),
98
+ distribution30d: Object.fromEntries(probability30d.entries()),
99
+ };
100
+
101
+ return cache;
102
+ };